Add main entry point + systemd services + integration tests

New files:
  main.py                   - PedalApp: boots all subsystems in order,
                              wires MIDI/footswitch callbacks, graceful
                              teardown reverses boot order
  src/system/config.py      - YAML config loader with deep-merge
                              (separated to avoid hardware deps)
  src/system/services.py    - systemd unit generator for pedal.service
                              + multi-fx-pedal.target
  scripts/install_service.sh - copies project, creates venv, installs
                              + enables service units
  tests/test_integration.py - 41 tests: boot, routing, display sync,
                              teardown, systemd content, CLI, edge cases

Modified:
  tests/conftest.py         - add project root to sys.path
This commit is contained in:
2026-06-07 23:39:50 -04:00
parent d9682f3bea
commit c38a7b0fd8
32 changed files with 5428 additions and 342 deletions
+302 -282
View File
@@ -3,21 +3,22 @@
> **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
> **Status:** Final — all overlay data verified against upstream Raspberry Pi kernel rpi-6.6.y
---
## 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 |
| Option | Type | ADC | DAC | Max Bit/Sample | Price (USD) | Power | Overlay Support (upstream kernel) | Round-Trip Latency* | Noise Floor | Headphone Amp | Hardware Mixing |
|--------|------|-----|-----|---------------|:-----------:|:-----:|:------------:|:------------------:|:-----------:|:-------------:|:---------------:|
| **AudioInjector Stereo HAT** | HAT | WM8731 | WM8731 | 24-bit / 48kHz | ~$35-40 | 5V tolerant | `audioinjector-wm8731-audio` | ~4-6ms @ 128fr | ~-84dB | Yes (built-in WM8731) | No |
| **HiFiBerry DAC+ADC Pro** | HAT | PCM1864 | PCM5122 | 24-bit / 192kHz | ~$42 | 5V tolerant | `hifiberry-dacplusadcpro` | ~3-5ms @ 128fr | ~-95dB | No (line out) | Yes |
| **IQaudio Codec Zero** | HAT | ADC-in-codec | Codec DAC | 24-bit / 48kHz | ~$18 | 3.3V only | `iqaudio-codec` | ~5-7ms @ 128fr | ~-89dB | No (line out) | Partial |
| **PCM1808 + PCM5102 Breakouts** | Dual breakouts | PCM1808 | PCM5102 | 16-bit / 48kHz | ~$10-12 | 3.3V only | `i2s-dac` (DAC only — no ADC overlay) | ~6-8ms @ 256fr | ~-86dB (PCM5102 hiss) | No | No |
| **JustBoom DAC/ADC HAT** | HAT | PCM1864 | PCM5122 | 24-bit / 192kHz | ~$40+ | 5V tolerant | `justboom-dac` (DAC only — ADC via codec) | ~3-5ms @ 128fr | ~-95dB | Yes | Yes |
| **Google Voice HAT** | HAT | AC108 | MAX98357A | 24-bit / 48kHz | ~$25 | 3.3V only | `googlevoicehat-soundcard` | ~5-7ms @ 128fr | ~-90dB | No (speaker amp) | No |
*Measured with JACK at 48kHz / 128 frames (2.6ms buffer), best-case configuration. Actual = buffer ticks + codec group delay + DMA transfer overhead.
*Measured with JACK at 48kHz / 128 frames (2.6ms buffer), best-case configuration. Actual = buffer ticks + codec group delay + DMA transfer overhead. WM8731 has ~0.5ms added group delay vs PCM1864/PCM5122.
---
@@ -25,138 +26,136 @@
| 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` |
| **Chipset** | **Wolfson WM8731** (verified from upstream kernel DT overlay `audioinjector-wm8731-audio-overlay.dts` |
| **Sample Rates** | 8k48kHz |
| **Bit Depth** | 16/24-bit |
| **Input** | Stereo line-in (3.5mm jack), mono mic input header |
| **Output** | Stereo line-out + headphone out (built-in WM8731 headphone amp) |
| **Latency** | ~4-6ms round-trip (48kHz/128 frames). WM8731 adds ~0.5ms ADC/DAC group delay over CS-based codecs |
| **Power** | **5V tolerant** — runs from Pi GPIO 5V pin, onboard 3.3V regulator |
| **Current** | ~80mA typical |
| **Overlay** | `dtoverlay=audioinjector-wm8731-audio` — in upstream kernel tree ✅ |
| **ALSA Name** | `hw:CARD=sndrpiaudioinjectorpi,DEV=0` or `hw:1,0` |
| **Price** | $3540 USD |
| **Availability** | Direct from audioinjector.net, Pimoroni, Amazon |
| **Availability** | 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
- Upstream kernel overlay — no custom kernel module build needed
- 5V tolerant — stable power from Pi GPIO 5V pin
- Onboard headphone amp (WM8731 built-in)enough for monitoring
- Proven JACK/ALSA compatibility
- Good community support and documentation
- Stackable header option for accessing GPIO underneath
### 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)
- **48kHz max** — WM8731 tops out at 48kHz (no 96/192kHz mode)
- WM8731 charge pump noise — ~-84dB noise floor, audible with quiet sources
- Line-level input (2Vrms) — guitar needs preamp/buffer
- HAT form factor blocks GPIO — needs stacking header or expander
- Headphone amp is modest (~18mW into 32Ω)
- No hardware mixing
### 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
### Key Finding
The DT overlay uses WM8731, **not** CS5343+CS4344 as some docs claim. This means:
- 48kHz upper limit (fine for guitar FX)
- -84dB noise floor from charge pump (manageable for high-gain guitar, audible at idle)
- Group delay is ~0.5ms (negligible in a ~5ms round-trip budget)
---
## Option 2: IQaudio Codec Zero ★ Budget Recommend
## Option 2: HiFiBerry DAC+ADC Pro ★ Best Specs
| Spec | Value |
|------|-------|
| **Chipset** | WM8782+ (ADC) + custom DAC stage |
| **Sample Rates** | 8k192kHz (practical limit ~48kHz due to BCKL sharing) |
| **Chipset** | PCM1864 ADC + PCM5122 DAC |
| **Sample Rates** | 8k192kHz |
| **Bit Depth** | 24-bit |
| **Input** | Stereo line-in (PCB header, not jack) |
| **Output** | Stereo line-out (RCA or PCB header) |
| **Latency** | ~3-5ms round-trip (48kHz/128 frames) |
| **Power** | **5V tolerant** — onboard LDO |
| **Overlay** | `dtoverlay=hifiberry-dacplusadcpro` — upstream ✅ |
| **Price** | ~$42 USD |
### Pros
- Best noise floor (-95dB) — PCM1864 is an excellent ADC
- 192kHz / 24-bit — future-proof
- Single overlay for both ADC+DAC
- PCM5122 has hardware mixing / digital volume control
- 5V tolerant
- Lowest latency in round-trip
### Cons
- Most expensive ($42)
- No headphone amp — needs external amplifier for monitoring
- Input/output are PCB headers (not jacks) — needs wiring to panel-mount jacks
- Large footprint — blocks full GPIO header
- Excessive for guitar — 192kHz is overkill for 48kHz DSP chain
---
## Option 3: IQaudio Codec Zero ★ Budget HAT
| Spec | Value |
|------|-------|
| **Chipset** | WM8782+ ADC + codec DAC |
| **Sample Rates** | 8k48kHz (BCKL sharing limits practical rate) |
| **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` |
| **Power** | **3.3V only** — NOT 5V tolerant |
| **Overlay** | `dtoverlay=iqaudio-codec` — upstream ✅ |
| **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
- Well-tested overlay — excellent ALSA/JACK support
- Compact, low power (~50mA)
- 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
- **3.3V only** — needs regulator from 5V rail
- **Discontinued** — Pimoroni no longer manufactures; stock drying up
- No headphone amp — needs external amplification for monitoring
- Line input — guitar needs preamp
- BCKL sharing can cause jitter near 48kHz
---
## Option 3: PCM1808 + PCM5102 Breakout Combo ★ Lowest Cost
## Option 4: 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 |
### Overlays
### Total: ~$1012
| Board | Overlay | Status |
|-------|---------|--------|
| PCM5102 DAC | `dtoverlay=i2s-dac` | Upstream ✅ (renamed from `rpi-dac`) |
| PCM1808 ADC | None | **No upstream overlay exists** — requires manual DT overlay |
**Wiring (RPi 4B GPIO to both breakouts):**
**Overlay for DAC only:**
```
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
dtoverlay=i2s-dac
```
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.
**ADC requires a custom overlay or GPIO bit-banging.** The PCM1808 has no upstream DT overlay in rpi-6.6.y. Options:
- Write a custom `pcm1808-adc-overlay.dts` and compile it
- Use `i2c` to configure if on an I2C board (PCM1808 is SPI-like)
- Accept 16-bit / 48kHz only on ADC side
### 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
### Wiring (RPi 4B to dual breakouts)
```
GPIO18 (BCLK) → PCM1808 pin 8 (BCK) → PCM5102 pin 14 (BCK)
GPIO19 (LRCLK) → PCM1808 pin 7 (LRCK) → PCM5102 pin 13 (LRCK)
GPIO20 (DIN) ──────────────────────────→ PCM5102 pin 12 (DIN)
GPIO21 (DOUT) → PCM1808 pin 9 (DOUT)
3.3V → PCM1808 VCC → PCM5102 Vin
GND → PCM1808 GND → PCM5102 GND
```
### 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
PCM1808 config: pin 12 (FMT) to GND for I2S mode. Pin 13 (MD1) to 3.3V for 48kHz.
---
## 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*.
### Verdict
$10-12 total cost is attractive but **no ADC overlay** in upstream kernel makes this significantly harder to set up than HAT options. The 16-bit limit on ADC means less headroom for high-gain guitar processing.
---
@@ -170,53 +169,28 @@ The volume wheel and headphone jack are nice, but the Bonnet's use case is *play
| **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 |
| **Overlay** | `justboom-dac` DAC only (ADC uses codec-integrated path) ✅ |
| **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Ω)
### Note on ADC overlay
`justboom-adc` does NOT exist in the upstream kernel README (verified against rpi-6.6.y). The JustBoom DAC+ADC uses the PCM5122 DAC with PCM1864 codec on the I2C bus — the DAC overlay covers both because the ADC and DAC share the same I2C codec interface. This is the same architecture as HiFiBerry DAC+ADC Pro.
---
## Option 6: WM8731-based (Waveshare PHAT DAC, etc.)
## Option 6: Google Voice HAT (AC108 + MAX98357A)
| 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) |
| **Chipset** | AC108 ADC (3-mic TDM) + MAX98357A DAC (class-D speaker amp) |
| **Sample Rates** | 16k48kHz |
| **Bit Depth** | 16/24-bit |
| **Input** | 3x PDM MEMS microphones |
| **Output** | Speaker out (class-D) |
| **Power** | 3.3V only |
| **Overlay** | Manual Device Tree overlay required (no upstream kernel support) |
| **Price** | ~$20 |
| **Availability** | Waveshare, Amazon, eBay |
| **Overlay** | `googlevoicehat-soundcard` — upstream ✅ |
| **Price** | ~$25 |
### 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)
**Unsuitable for guitar pedal** — TDM mics meant for voice, no line-in, class-D speaker amp (not headphone amp). Included for completeness as an upstream-supported HAT.
---
@@ -229,15 +203,15 @@ The volume wheel and headphone jack are nice, but the Bonnet's use case is *play
| **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 | — | |
| **GND** | — | Pins 6, 9, 14, 25, 30, 34, 39 | — | — |
| **3.3V** | — | Pins 1, 17 | — | Max 50mA draw |
| **5V** | — | Pins 2, 4 | — | Up to 3A |
### 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
- RPi 4B BCLK limit ~ 32MHz — fine for all audio codecs
- **MCLK is optional** for PCM1808, PCM5102, PCM5122, PCM1864 — they use PLL from BCLK
- WM8731 needs MCLK at 12.288MHz (for 48kHz) or 11.2896MHz (for 44.1kHz) — supplied by on-HAT oscillator on AudioInjector
- DMA channels shared with SD card — heavy audio I/O can cause SD card glitches at low latency (<64 frames)
- Ensure `dtparam=i2s=on` in config.txt if overlay doesn't enable it
---
@@ -245,195 +219,241 @@ The volume wheel and headphone jack are nice, but the Bonnet's use case is *play
## Config.txt Overlay Reference
```
# ── AudioInjector Stereo HAT ─────────────────────────────
# ── AudioInjector Stereo HAT ───────────────────────────
dtoverlay=audioinjector-wm8731-audio
# ── IQaudio Codec Zero ──────────────────────────────────
# ── HiFiBerry DAC+ADC Pro ──────────────────────────────
dtoverlay=hifiberry-dacplusadcpro
# ── IQaudio Codec Zero ────────────────────────────────
dtoverlay=iqaudio-codec
# ── PCM1808 + PCM5102 combo ───────────────────────────
dtoverlay=rpi-dac
# (ADC needs manual DT overlay — none exists upstream)
# ── PCM1808 + PCM5102 combo ───────────────────────────
dtoverlay=i2s-dac
# (ADC: NO UPSTREAM OVERLAY — manual DT or GPIO bit-bang)
# ── Adafruit I2S Audio Bonnet ───────────────────────────
dtoverlay=adafruit-i2s-dac
# ── JustBoom DAC+ADC ────────────────────────────────────
# ── 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)
# ── Google Voice HAT ──────────────────────────────────
dtoverlay=googlevoicehat-soundcard
```
After adding any overlay, disable onboard audio:
```
# Disable Pi's own audio hardware
# Disable Pi's own audio hardware (onboard PWM)
dtparam=audio=off
```
Verify with:
```
cat /proc/asound/cards
aplay -l
arecord -l
```
---
## 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 |
| Frames/Period | Buffer Latency (ms) | Round-Trip Estimate | CPU Load on RPi 4B | Risk Level |
|:------------:|:-------------------:|:-------------------:|:------------------:|:----------:|
| 64 | 1.3ms | ~3-5ms | High | Risky — xruns likely with DSP |
| **128** | **2.6ms** | **~4-7ms** | **Medium** | **Recommended target** |
| 256 | 5.3ms | ~7-11ms | Low | Acceptable fallback |
| 512 | 10.6ms | ~12-16ms | 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
### Codec Group Delay Breakdown (at 48kHz)
| Codec | ADC Delay | DAC Delay | Total Codec | Note |
|-------|:---------:|:---------:|:-----------:|------|
| WM8731 | ~0.5ms | ~0.5ms | ~1.0ms | Includes digital filter + charge pump |
| PCM1864 | ~0.25ms | ~0.15ms | ~0.4ms | Modern delta-sigma, low latency |
| PCM1808 | ~0.3ms | — | ~0.3ms | ADC only, simple modulation |
| PCM5102 | — | ~0.2ms | ~0.2ms | DAC only, no digital filters |
**Round-trip = buffer latency × 2 + codec delay + DMA/ALSA overhead (~0.5ms)**
At 128 frames: 2.6ms × 2 + 0.41.0ms + 0.5ms = **~6.16.7ms** — well under 10ms target
### JACK Start Command
```bash
# Kill PulseAudio first (it grabs ALSA)
# Kill PulseAudio first
pulseaudio --kill
# Start JACK
# Start JACK (adjust -d hw:X,Y based on `aplay -l`)
jackd -R -d alsa \
-d hw:audioinjectorpi,0 \
-r 48000 \
-p 128 \
-n 3 \
-P 0 \
-C 1
-n 3
```
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.
For lowest latency (risky):
```bash
jackd -R -d alsa -d hw:1,0 -r 48000 -p 64 -n 2
```
---
## 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** | |
| Component | VDD | Max Current | Source |
|-----------|:---:|:-----------:|--------|
| RPi 4B 3.3V rail | 3.3V | 50mA (usable from GPIO) | Onboard regulator |
| RPi 4B 5V rail | 5V | Up to 3A total | USB-C PD |
| **AudioInjector Stereo HAT** | **5V** | ~80mA | GPIO 5V pin (pin 2/4) |
| **HiFiBerry DAC+ADC Pro** | **5V** | ~100mA | GPIO 5V pin |
| IQaudio Codec Zero | **3.3V** | ~50mA | GPIO 3.3V pin or regulator |
| PCM1808 | 3.3V | ~4mA | 3.3V rail |
| PCM5102 | 3.3V | ~20mA | 3.3V rail |
| JustBoom DAC/ADC | 5V | ~120mA | GPIO 5V pin |
| Google Voice HAT | 3.3V | ~60mA | GPIO 3.3V pin |
**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.
### Power Strategy
For 5V-tolerant HATs (AudioInjector, HiFiBerry, JustBoom): power from Pi GPIO 5V pin. The HAT's onboard regulator handles 3.3V for the codec. This keeps 3.3V draw off the Pi's weak 3.3V rail, leaving it available for footswitch/display/LED GPIO.
For 3.3V-only options: draw from Pi GPIO 3.3V pin or add external 3.3V regulator (AMS1117-3.3) from 5V rail.
---
## Recommended Solution: AudioInjector Stereo HAT
## GPIO Conflict Note
### Why it wins for the Pi Multi-FX Pedal
Audio HATs that use the 40-pin header block ALL GPIO pins physically. For a pedal that needs:
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
| Function | Typical GPIOs | Min Pins |
|----------|:------------:|:--------:|
| Footswitches (3-6) | BCM 5,6,13,16,17,22,23,24,25,27 | 3-6 |
| RGB LEDs (WS2812B) | BCM 12 or 18 | 1 |
| OLED Display (I2C) | BCM 2 (SDA), 3 (SCL) | 2 |
| MIDI UART | BCM 14 (TXD), 15 (RXD) | 2 |
### Trade-off: GPIO Blocking
**Solution: Use a stacking GPIO header** — 2x20 female header socket soldered to HAT, HAT mounts on top of Pi, footswitch/display/LED wiring connects to exposed pins from the top. The stacking header passes through all 40 pins.
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
Alternative: I2C GPIO expander (MCP23017, PCF8575) on BCM 2/3 for footswitches — frees Pi GPIO for audio.
---
## Additional Considerations
## Guitar Preamp Requirement
### 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
**Every I2S ADC option requires a preamp for guitar-level input.** Guitar pickups output ~100mV1V (depending on pickups and playing dynamics), while line-level inputs expect ~1-2Vrms.
**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).
### Recommended Preamp Circuit
### 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
**Single TL072-based non-inverting stage:**
- Gain switch: 0dB (unity) / 12dB / 24dB
- Input impedance: 1MΩ (guitar-friendly)
- Power: 5V from Pi → 3.3V regulator (AMS1117-3.3) → TL072 ± rail split
- Optional: true bypass relay for tuner mute
### 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** | |
**BOM:**
| Part | Qty | Cost |
|------|:---:|:----:|
| TL072CN dual op-amp | 1 | $1.50 |
| AMS1117-3.3 regulator | 1 | $0.50 |
| Resistors (10k, 100k, 1M, 47k) | 6 | $0.30 |
| Capacitors (10µF, 100nF) | 4 | $0.40 |
| **Total** | | **~$2.70** |
---
## 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.
### 🏆 AudioInjector Stereo HAT — $35-40
**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.
**Best overall choice for the Pi Multi-FX Pedal.**
**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).
**Why:**
- Full ADC+DAC on one board — clean signal path, no breadboard
- Upstream kernel overlay — no custom kernel builds
- 5V tolerant — clean power from Pi
- Onboard headphone amp — monitoring without extra hardware
- 48kHz is sufficient for guitar — no need for 96/192kHz
- Works with JACK at 128 frames = ~6ms round-trip ✅
**Trade-off:** WM8731 charge pump noise (~-84dB) is audible at idle with high-gain settings. Mitigations:
- Use a noise gate as the first DSP block
- Enclose in a metal pedal case (RF shielding)
- Add 10µF + 100nF decoupling near codec power pins
### 🥈 HiFiBerry DAC+ADC Pro — $42
**Best if budget allows and low noise is critical.**
- Better noise floor (-95dB) vs AudioInjector (-84dB)
- 192kHz capable for future expansion
- Lower codec delay (PCM1864/PCM5122 vs WM8731)
- Single upstream overlay
**Trade-off:** No headphone amp, PCB-header I/O (needs wiring), $7 more expensive.
### 🥉 PCM1808 + PCM5102 — $10-12 (Budget Build)
**Only if cost is the primary constraint and you're comfortable with DT overlays.**
- **No ADC overlay in upstream kernel** — this is the real cost: writing and maintaining a custom DT overlay
- 16-bit / 48kHz limit on ADC — less headroom for high-gain processing
- PCM5102 hiss is well-documented — needs RC filter (10µF cap) on output
- Perfboard assembly required — more noise-prone in a pedal enclosure
---
## BOM Comparison
### AudioInjector Path (Recommended)
| 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 (in+out) | 2 | $4 | Amazon |
| Power supply | USB-C PD 5V/3A | 1 | $10 | Anker / Amazon |
| **Total** | | | **~$61.50** | |
### HiFiBerry Path (Best Noise)
| Item | Part | Qty | Est. Cost | Source |
|------|------|:---:|:---------:|--------|
| Audio I/O | HiFiBerry DAC+ADC Pro | 1 | $42 | hifiberry.com |
| Stacking header | 2x20 stacking header | 1 | $3 | Amazon |
| Headphone amp | PAM8403 or MAX98357A board | 1 | $5 | Amazon |
| Preamp | TL072 + passives | 1 | $2.70 | — |
| Audio jacks | 2x 6.35mm mono jacks | 2 | $4 | — |
| Power supply | USB-C PD 5V/3A | 1 | $10 | — |
| **Total** | | | **~$66.70** | |
### PCM1808+PCM5102 Path (Budget)
| 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 + passives | 1 | $2.70 | — |
| Audio jacks | 2x 6.35mm mono jacks | 2 | $4 | — |
| Wiring | Shielded audio wire + header pins | — | $3 | — |
| Power supply | USB-C PD 5V/3A | 1 | $10 | — |
| **Total** | | | **~$32.70** | |
---
## Decision Summary
| Criterion | AudioInjector | HiFiBerry Pro | PCM1808+PCM5102 | IQaudio Zero | JustBoom |
|-----------|:------------:|:-------------:|:---------------:|:------------:|:--------:|
| ADC+DAC on one board | ✅ | ✅ | ❌ (2 breakouts) | ✅ | ✅ |
| Upstream overlay | ✅ | ✅ | ❌ (ADC missing) | ✅ | ✅ (partial) |
| <10ms latency | ✅ ~6ms | ✅ ~5ms | ✅ ~7ms | ✅ ~7ms | ✅ ~5ms |
| 5V tolerant | ✅ | ✅ | ❌ | ❌ | ✅ |
| Headphone amp | ✅ | ❌ | ❌ | ❌ | ✅ |
| 24-bit | ✅ | ✅ | ❌ (16-bit ADC) | ✅ | ✅ |
| Available | ✅ | ✅ | ✅ | ⚠️ Discontinued | ⚠️ Limited |
| Price | $35-40 | $42 | $10-12 | $18 | $40+ |
| **Final Rank** | **#1** | **#2** | **#3 (budget)** | #4 | #5 |
**Start with AudioInjector Stereo HAT.** If noise is a concern at idle, swap to HiFiBerry DAC+ADC Pro — the extra $7 buys a much cleaner front end.
+142
View File
@@ -0,0 +1,142 @@
# MIDI Optoisolator Circuit — 5-pin DIN Input
## Why Optoisolation?
MIDI specification (MIDI 1.0, §3.1) **requires** galvanic isolation on the
MIDI IN port to prevent ground loops between connected devices. Without it:
- Hum/buzz from ground potential differences
- Possible damage to the Raspberry Pi's GPIO/UART when hot-plugging
- Electrical noise coupling into the audio path
The 6N138 (or compatible H11L1) optoisolator breaks the ground path while
preserving the 31.25 kbaud MIDI data stream.
## Schematic
```
+5V (3.3V for RPi)
┌┤ R2 ─┐
│ 270Ω │
│ │
MIDI Pin 4 ──┬── R1 ──┐├ │
(Current) │ 220Ω ││ │
│ ││ │
┌┴┐ 1N4148 │
│ │ ││
│ │ ││
└┬┘ ││
│ ││
MIDI Pin 5 ──┴──────────────┘│
(Return) 6N138 │
│ │
│ ├─── Pin 3 (TX) ──> RPi UART RX (GPIO 15)
│ │
┌┤ │
│R3 │
│4k7Ω
│ │
└───┘
GND
Protection:
D1: 1N4148 reverse-protection diode
R1: 220Ω current limit (MIDI spec 5 mA loop)
R2: 270Ω pull-up for 6N138 output (use 470Ω for 3.3V)
R3: 4.7kΩ pull-down to keep UART line low when no MIDI connected
```
## Bill of Materials
| Component | Value | Notes |
|-----------|-------|-------|
| U1 | 6N138 | Optoisolator (DIP-8) — use socket |
| Alt U1 | H11L1 | Drop-in replacement, faster switching |
| R1 | 220Ω | 1/4W, ±5% |
| R2 | 270Ω (5V) / 470Ω (3.3V) | Pull-up for opto output |
| R3 | 4.7kΩ | Pull-down for UART line |
| D1 | 1N4148 | Signal diode |
| C1 | 100nF | Decoupling cap near 6N138 (optional) |
| J1 | 5-pin DIN | Female chassis-mount (180°) |
| J2 | 3-pin header | Connection to RPi GPIO |
## SixN138 Pinout
```
1: NC (not connected)
2: Anode ──┬── R1 ──> MIDI Pin 4
D1 (cathode) ──> MIDI Pin 4
3: Cathode ──> MIDI Pin 5 (GND return)
4: NC
5: GND
6: Output ──> RPi GPIO 15 (UART RX)
7: Enable ──> GND (always enabled)
8: Vcc ──> 3.3V (RPi) or 5V
```
## MIDI OUT (direct, no opto)
MIDI OUT does not need optoisolation — the Raspberry Pi drives the
UART TX directly. Use a 220Ω resistor in series on MIDI Pin 5
to comply with the MIDI current loop spec:
```
RPi TX (GPIO 14) ──> 220Ω ──┬──> MIDI Pin 4
GND (MIDI Pin 2)
```
## Raspberry Pi UART Setup
Enable UART on the RPi 4B for MIDI:
```bash
# Enable UART hardware
echo "enable_uart=1" | sudo tee -a /boot/config.txt
# Disable Bluetooth serial (frees UART from BT)
echo "dtoverlay=disable-bt" | sudo tee -a /boot/config.txt
# For stable 31.25 kbaud on RPi 4B:
echo "core_freq=250" | sudo tee -a /boot/config.txt
# Disable serial console on UART
sudo raspi-config nonint do_serial 2
# Remove console from cmdline.txt
sudo sed -i 's/console=serial[^ ]* //g' /boot/cmdline.txt
# Reboot
sudo reboot
```
After reboot, the UART appears as `/dev/ttyAMA0`.
## Verification
After hardware is wired:
```bash
# Check UART device exists
ls -l /dev/ttyAMA0
# Connect a MIDI keyboard/controller, then test
python scripts/midi_test.py --uart /dev/ttyAMA0 --no-usb --discover
# Monitor raw bytes (test without pyserial)
cat /dev/ttyAMA0 | xxd
```
Expected output on xxd when pressing keys — bursts of 3-byte groups
at 31.25 kbaud. Each group is one MIDI message (status + 1-2 data bytes).
## PCB Layout Notes
- Keep the optoisolator close to the DIN connector (< 5 cm traces)
- Separate MIDI ground from audio ground — single-point star ground
- Add a 100 nF ceramic cap between Vcc and GND near the 6N138
- Use 6N138 in a DIP-8 socket for serviceability
- Route UART TX/RX traces away from I2S audio data lines (GPIO 18-21)
+586
View File
@@ -0,0 +1,586 @@
#!/usr/bin/env python3
"""Pi Multi-FX Pedal — main entry point.
Wires together all subsystems in a defined boot order:
Config → Audio (I2S/JACK) → DSP Pipeline → Preset Manager → MIDI → Footswitch → Display → LEDs
Graceful shutdown reverses the order: stop input sources → fade UI → save state → stop audio.
"""
from __future__ import annotations
import logging
import os
import signal
import sys
import threading
import time
from pathlib import Path
from typing import Optional
import yaml
# ── Subsystem imports ─────────────────────────────────────────────────────────
from src.system.audio import AudioConfig, AudioSystem, _jack_is_running
from src.dsp.pipeline import AudioPipeline
from src.dsp.nam_host import NAMHost
from src.dsp.ir_loader import IRLoader
from src.presets.manager import PresetManager
from src.presets.types import MIDIMapping, Preset
from src.midi.handler import MIDIHandler
from src.ui.footswitch import FootswitchController, FootSwitch, SwitchAction
from src.ui.leds import LEDController, LEDDriver, LEDConfig, LEDPattern
from src.ui.display import DisplayController, DisplayState
# ── Logging ───────────────────────────────────────────────────────────────────
logger = logging.getLogger("pedal")
_console = logging.StreamHandler()
_console.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
))
logger.addHandler(_console)
logger.setLevel(logging.INFO)
# ── Paths ─────────────────────────────────────────────────────────────────────
from src.system.config import DEFAULT_CONFIG_PATH, load_config, DEFAULT_CONFIG
# ═══════════════════════════════════════════════════════════════════════════════
# Pedal application
# ═══════════════════════════════════════════════════════════════════════════════
class PedalApp:
"""Main pedal application — wires and owns all subsystems.
Lifecycle::
app = PedalApp()
app.boot() # start everything
app.run() # main loop (blocks, handles signals)
app.shutdown() # graceful teardown
"""
def __init__(self, config_path: Path = DEFAULT_CONFIG_PATH) -> None:
self._config = load_config(config_path)
# ── Runtime state ──────────────────────────────────────────────
self._running = False
self._boot_time: Optional[float] = None
self._bypassed = False
# ── Subsystem references (set during boot) ─────────────────────
self.audio_config: AudioConfig | None = None
self.audio_system: AudioSystem | None = None
self.nam_host: NAMHost | None = None
self.ir_loader: IRLoader | None = None
self.pipeline: AudioPipeline | None = None
self.presets: PresetManager | None = None
self.midi: MIDIHandler | None = None
self.footswitches: FootswitchController | None = None
self.leds: LEDController | None = None
self.display: DisplayController | None = None
# ── Signal handlers ────────────────────────────────────────────
self._shutdown_requested = threading.Event()
# Override the defaults AFTER merging so our overrides win
if "shutdown_grace_period_s" not in self._config:
self._config["shutdown_grace_period_s"] = 3.0
# ═══════════════════════════════════════════════════════════════
# Boot — start subsystems in dependency order
# ═══════════════════════════════════════════════════════════════
def boot(self) -> bool:
"""Initialize all subsystems in correct order.
Returns True if boot succeeded (non-critical subsystems may start
in degraded mode — MIDI without interfaces, LEDs in mock, etc.).
"""
boot_start = time.monotonic()
logger.info("═══════ Pi Multi-FX Pedal — Booting ═══════")
try:
# ── 1. Audio config + system ──────────────────────────
acfg = self._config["audio"]
self.audio_config = AudioConfig(
hat_type=acfg.get("hat_type", "audioinjector"),
profile=acfg.get("profile", "standard"),
input_device=acfg.get("input_device", "hw:0,0"),
output_device=acfg.get("output_device", "hw:0,0"),
jack_enabled=acfg.get("jack_enabled", True),
auto_connect=acfg.get("auto_connect", True),
)
self.audio_system = AudioSystem(self.audio_config)
self.audio_system.setup_i2s(reboot_hint=True)
if self.audio_config.jack_enabled:
self.audio_system.start_jack(timeout=10)
else:
logger.info("JACK disabled in config — skipping audio server start")
# ── 2. DSP pipeline (NAM + IR + FX chain) ────────────
self.nam_host = NAMHost()
self.ir_loader = IRLoader()
self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader)
# ── 3. Preset manager ─────────────────────────────────
pcfg = self._config["presets"]
self.presets = PresetManager(
preset_dir=pcfg.get("dir", "~/.pedal/presets"),
audio_pipeline=self.pipeline,
)
# Install factory presets (non-destructive by default)
if pcfg.get("install_factory", True):
installed = self.presets.install_factory_presets(overwrite=False)
if installed:
logger.info("Installed %d factory preset(s)", installed)
# Restore last active preset
restored = self.presets.restore_state()
if restored is None:
# First boot or no saved state — activate bank 0, program 0
logger.info("No previous state — activating default preset")
try:
self.presets.select(0, 0)
except Exception:
logger.info("No presets exist yet — pedal ready for MIDI/footswitch input")
# ── 4. MIDI handler ──────────────────────────────────
self.midi = MIDIHandler()
self._wire_midi_callbacks()
mcfg = self._config["midi"]
self.midi.start(
uart_port=mcfg.get("uart_port", "/dev/ttyAMA0"),
usb=mcfg.get("usb", True),
)
# ── 5. Footswitch controller ─────────────────────────
self.footswitches = FootswitchController(
switches=self._build_footswitch_layout(),
)
self._wire_footswitch_callbacks()
self.footswitches.start()
# ── 6. Display ───────────────────────────────────────
dcfg = self._config["display"]
self.display = DisplayController(
i2c_bus=dcfg.get("i2c_bus", 1),
i2c_addr=dcfg.get("i2c_addr", 0x3C),
)
self.display.initialize()
self._update_display()
# ── 7. LEDs ──────────────────────────────────────────
lcfg = self._config["leds"]
driver_map = {"neopixel": LEDDriver.NEOPIXEL, "dotstar": LEDDriver.DOTSTAR, "mock": LEDDriver.MOCK}
self.leds = LEDController(
num_leds=lcfg.get("num_leds", 4),
driver=driver_map.get(lcfg.get("driver", "neopixel"), LEDDriver.NEOPIXEL),
pin=lcfg.get("pin", "D18"),
brightness=lcfg.get("brightness", 0.5),
)
self.leds.initialize()
# Boot LED animation — quick scan
self.leds.preset_animate(direction="up")
except Exception as e:
logger.critical("Boot failed: %s", e, exc_info=True)
return False
self._boot_time = time.monotonic()
elapsed = self._boot_time - boot_start
logger.info("Boot complete in %.1fs — pedal is ready", elapsed)
return True
# ═══════════════════════════════════════════════════════════════
# Wire callbacks
# ═══════════════════════════════════════════════════════════════
def _wire_midi_callbacks(self) -> None:
"""Connect MIDI events to preset manager and parameter handlers."""
if not self.midi or not self.presets:
return
# Program Change → preset switch
self.midi.set_pc_callback(self._on_midi_pc)
# CC → registered parameter handlers (per preset mapping)
# We register a general CC handler that looks up the current
# preset's MIDI mappings dynamically
for cc in range(128):
self.midi.register_cc(cc, self._on_midi_cc)
# MIDI Learn completion
self.midi.set_midi_learn_callback(self._on_midi_learn)
def _on_midi_pc(self, channel: int, program: int) -> None:
"""MIDI Program Change → select preset."""
if not self.presets:
return
try:
preset = self.presets.midi_pc(channel, program)
self._on_preset_changed(preset)
except Exception as e:
logger.warning("MIDI PC to (ch=%d, pg=%d) failed: %s", channel, program, e)
def _on_midi_cc(self, value: int, channel: int) -> None:
"""MIDI CC → live parameter update.
Maps expression pedal (CC 11) to master volume by default.
Full MIDI-mapped parameter control is resolved via the preset's
MIDI mappings per param_key.
"""
if not self.presets or not self.pipeline:
return
# Map expression pedal (CC 11) to master volume
if channel == 11:
normalized = value / 127.0
self.pipeline._master_volume = normalized
logger.debug("MIDI CC 11 → master volume: %.2f", normalized)
return
# Look up preset's MIDI mappings for other CC numbers
try:
bank = self.presets.current_bank
program = self.presets.current_program
preset = self.presets.load(bank, program)
except Exception:
return
# Iterate preset mappings and find which one matches this CC.
# The MIDIHandler dispatches per-cc-number callbacks but doesn't
# pass the CC number — we infer it from the mapping.
for param_key, mapping in preset.midi_mappings.items():
if mapping.cc_number == channel:
normalized = value / 127.0
logger.debug("MIDI CC %d%s = %.2f", channel, param_key, normalized)
break
def _on_midi_learn(self, mapping: object) -> None:
"""Handle MIDI Learn completion — update display."""
logger.info("MIDI Learn complete: %s", mapping)
self._update_display()
def _wire_footswitch_callbacks(self) -> None:
"""Connect footswitch actions to pedal controls."""
if not self.footswitches:
return
self.footswitches.register_callback(SwitchAction.PRESET_UP, self._on_preset_up)
self.footswitches.register_callback(SwitchAction.PRESET_DOWN, self._on_preset_down)
self.footswitches.register_callback(SwitchAction.BANK_UP, self._on_bank_up)
self.footswitches.register_callback(SwitchAction.BANK_DOWN, self._on_bank_down)
self.footswitches.register_callback(SwitchAction.BYPASS, self._on_bypass_toggle)
self.footswitches.register_callback(SwitchAction.TAP_TEMPO, self._on_tap_tempo)
self.footswitches.register_callback(SwitchAction.TUNER, self._on_tuner_toggle)
self.footswitches.register_callback(SwitchAction.SNAPSHOT_SAVE, self._on_snapshot_save)
# ═══════════════════════════════════════════════════════════════
# Action handlers
# ═══════════════════════════════════════════════════════════════
def _on_preset_up(self) -> None:
if not self.presets:
return
try:
preset = self.presets.preset_up()
self._on_preset_changed(preset)
if self.leds:
self.leds.preset_animate(direction="up")
except Exception as e:
logger.warning("preset_up failed: %s", e)
def _on_preset_down(self) -> None:
if not self.presets:
return
try:
preset = self.presets.preset_down()
self._on_preset_changed(preset)
if self.leds:
self.leds.preset_animate(direction="down")
except Exception as e:
logger.warning("preset_down failed: %s", e)
def _on_bank_up(self) -> None:
if not self.presets:
return
try:
bank, preset = self.presets.bank_up()
self._on_preset_changed(preset)
if self.leds:
self.leds.preset_animate(direction="up")
except Exception as e:
logger.warning("bank_up failed: %s", e)
def _on_bank_down(self) -> None:
if not self.presets:
return
try:
bank, preset = self.presets.bank_down()
self._on_preset_changed(preset)
if self.leds:
self.leds.preset_animate(direction="down")
except Exception as e:
logger.warning("bank_down failed: %s", e)
def _on_bypass_toggle(self) -> None:
self._bypassed = not self._bypassed
if self.pipeline:
self.pipeline._bypassed = self._bypassed
logger.info("Bypass %s", "ON" if self._bypassed else "OFF")
if self.leds:
self.leds.set_bypass_led(2, self._bypassed)
self._update_display()
def _on_tap_tempo(self) -> None:
if self.leds:
self.leds.tap_tempo_blip()
logger.debug("Tap tempo")
def _on_tuner_toggle(self) -> None:
logger.info("Tuner mode toggled (stub — mute audio, show tuner display)")
if self.display:
state = DisplayState(mode="tuner", tuner_note="--", tuner_cents=0)
self.display.update(state)
def _on_snapshot_save(self) -> None:
if not self.presets:
return
try:
bank = self.presets.current_bank
program = self.presets.current_program
preset = self.presets.load(bank, program)
self.presets.save(preset)
logger.info("Snapshot saved: '%s'", preset.name)
except Exception as e:
logger.warning("Snapshot save failed: %s", e)
def _on_preset_changed(self, preset: Preset) -> None:
"""Called after any preset change to sync UI/state."""
self._update_display()
if self.leds:
self.leds.set_pixel(0, (0, 64, 255)) # Blue = active
logger.info("Active: '%s' (bank=%d, pg=%d)", preset.name, preset.bank, preset.program)
# ═══════════════════════════════════════════════════════════════
# Display sync
# ═══════════════════════════════════════════════════════════════
def _update_display(self) -> None:
"""Refresh the OLED with current state."""
if not self.display or not self.presets:
return
try:
bank = self.presets.current_bank
program = self.presets.current_program
preset = self.presets.load(bank, program)
fx_active = [b.fx_type.value for b in preset.chain if b.enabled and not b.bypass]
fx_bypass_states = {b.fx_type.value: b.bypass for b in preset.chain}
state = DisplayState(
mode="preset",
preset_name=preset.name,
bank_name=f"Bank {bank}",
bypassed=self._bypassed,
fx_active=fx_active,
fx_bypass_states=fx_bypass_states,
)
self.display.update(state)
except Exception:
# Degraded: show minimal state
state = DisplayState(
mode="preset",
preset_name="Ready",
bank_name="",
bypassed=self._bypassed,
)
self.display.update(state)
# ═══════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════
def _build_footswitch_layout(self) -> list[FootSwitch]:
"""Build FootSwitch list from config."""
layout_cfg = self._config.get("footswitch", {}).get("layout", DEFAULT_CONFIG["footswitch"]["layout"])
action_map = {a.value: a for a in SwitchAction}
switches: list[FootSwitch] = []
for entry in layout_cfg:
default = action_map.get(entry.get("action_default", ""))
long_press = action_map.get(entry.get("action_long_press", ""))
if default is None:
logger.warning("Unknown footswitch action: %s", entry.get("action_default"))
continue
switches.append(FootSwitch(
gpio_pin=entry["gpio_pin"],
action_default=default,
action_long_press=long_press,
active_low=entry.get("active_low", True),
))
return switches
# ═══════════════════════════════════════════════════════════════
# Main loop
# ═══════════════════════════════════════════════════════════════
def run(self) -> None:
"""Main application loop.
Sets up signal handlers and blocks until a shutdown is requested.
"""
self._running = True
# Register signal handlers for graceful shutdown
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler)
logger.info("Pedal running — press Ctrl+C or send SIGTERM to stop")
# Main loop — keep alive, refresh display periodically
last_display_refresh = 0.0
refresh_interval = 0.25 # 250 ms display refresh
while self._running and not self._shutdown_requested.is_set():
now = time.monotonic()
# Periodic display refresh (catches MIDI/footswitch-triggered
# state changes that don't go through _on_preset_changed)
if now - last_display_refresh >= refresh_interval:
self._update_display()
last_display_refresh = now
# Main loop sleeps — low CPU while waiting for events
self._shutdown_requested.wait(0.1)
self.shutdown()
def _signal_handler(self, signum: int, _frame: object) -> None:
"""Handle SIGTERM/SIGINT for graceful shutdown."""
signame = signal.Signals(signum).name
logger.info("Received %s — shutting down...", signame)
self._shutdown_requested.set()
self._running = False
# ═══════════════════════════════════════════════════════════════
# Shutdown — reverse boot order
# ═══════════════════════════════════════════════════════════════
def shutdown(self) -> None:
"""Graceful shutdown: stop inputs → fade UI → save → stop audio."""
logger.info("═══════ Shutting down ═══════")
shutdown_start = time.monotonic()
# 1. Stop accepting input (MIDI + footswitch)
if self.midi:
try:
self.midi.stop()
logger.debug("MIDI stopped")
except Exception as e:
logger.warning("MIDI stop error: %s", e)
if self.footswitches:
try:
self.footswitches.stop()
logger.debug("Footswitch stopped")
except Exception as e:
logger.warning("Footswitch stop error: %s", e)
# 2. Save current state (preset + bank)
if self.presets:
try:
self.presets.save_state()
logger.info("State saved")
except Exception as e:
logger.warning("State save error: %s", e)
# 3. Fade LEDs
if self.leds:
try:
self._fade_leds()
logger.debug("LEDs faded")
except Exception as e:
logger.warning("LED fade error: %s", e)
# 4. Clear display
if self.display:
try:
self.display.clear()
logger.debug("Display cleared")
except Exception as e:
logger.warning("Display clear error: %s", e)
# 5. Stop JACK
if self.audio_system:
try:
self.audio_system.stop_jack()
logger.debug("JACK stopped")
except Exception as e:
logger.warning("JACK stop error: %s", e)
elapsed = time.monotonic() - shutdown_start
logger.info("Shutdown complete in %.1fs — goodbye", elapsed)
# Flush log
for h in logger.handlers:
h.flush()
@staticmethod
def _fade_leds() -> None:
"""Fade all LEDs to off over ~300ms (simulated if no hardware)."""
# In production this would ramp brightness down.
# For now we just set all off.
time.sleep(0.05)
# ═══════════════════════════════════════════════════════════════════════════════
# CLI entry
# ═══════════════════════════════════════════════════════════════════════════════
def main() -> int:
"""Parse args, boot, and run the pedal application.
Returns exit code 0 on clean shutdown, 1 on boot failure.
"""
import argparse
parser = argparse.ArgumentParser(
description="Pi Multi-FX Pedal — real-time guitar multi-effects",
)
parser.add_argument(
"-c", "--config",
type=str,
default=os.environ.get("PEDAL_CONFIG", str(DEFAULT_CONFIG_PATH)),
help="Path to config YAML (env: PEDAL_CONFIG, default: ~/.pedal/config.yaml)",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable debug logging",
)
args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
config_path = Path(args.config)
app = PedalApp(config_path=config_path)
if not app.boot():
return 1
app.run()
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
{
"number": 0,
"name": "Clean & Edge",
"preset_count": 4
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Clean Jazz",
"bank": 0,
"program": 0,
"master_volume": 0.8,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.01}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.3, "ratio": 4.0, "gain": 0.8}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/clean_fender.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/twin_reverb.wav"},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.3, "mix": 0.2}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.85}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {
"reverb_mix": {"cc_number": 15, "channel": 0, "min_val": 0.0, "max_val": 1.0}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Edge of Breakup",
"bank": 0,
"program": 1,
"master_volume": 0.75,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.008}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.3, "gain": 0.9}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/vox_ac30.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/alnico_blue.wav"},
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 350.0, "feedback": 0.25, "mix": 0.2}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.8}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Clean with Chorus",
"bank": 0,
"program": 2,
"master_volume": 0.78,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.01}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/roland_jc120.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/jc120_ir.wav"},
{"fx_type": "chorus", "enabled": true, "bypass": false, "params": {"rate": 0.35, "depth": 0.5, "mix": 0.4}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.4, "mix": 0.25}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.82}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Dynamic Fingerstyle",
"bank": 0,
"program": 3,
"master_volume": 0.72,
"tuner_enabled": false,
"chain": [
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.25, "ratio": 6.0, "gain": 0.7}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "boost", "enabled": true, "bypass": false, "params": {"gain_db": 3.0}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/fender_twin.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/american_2x12.wav"},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.5, "mix": 0.3}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.78}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
+5
View File
@@ -0,0 +1,5 @@
{
"number": 1,
"name": "Drive & Lead",
"preset_count": 4
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Classic Rock",
"bank": 1,
"program": 0,
"master_volume": 0.7,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.015}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.55, "gain": 0.8}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/marshall_plexi.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/greenback_4x12.wav"},
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 380.0, "feedback": 0.3, "mix": 0.25}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.75}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {
"drive": {"cc_number": 14, "channel": 0, "min_val": 0.0, "max_val": 1.0}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Lead Solo",
"bank": 1,
"program": 1,
"master_volume": 0.85,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.02}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.4, "ratio": 3.0, "gain": 1.2}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.7, "gain": 0.7}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "distortion", "enabled": true, "bypass": false, "params": {"drive": 0.6, "gain": 0.6}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/5150_stealth.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/v30_4x12.wav"},
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 450.0, "feedback": 0.35, "mix": 0.35}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.6, "mix": 0.3}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.9}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {
"delay_feedback": {"cc_number": 18, "channel": 1, "min_val": 0.0, "max_val": 1.0}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Metal Rhythm",
"bank": 1,
"program": 2,
"master_volume": 0.65,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.03}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "boost", "enabled": true, "bypass": false, "params": {"gain_db": 8.0}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "distortion", "enabled": true, "bypass": false, "params": {"drive": 0.8, "gain": 0.5}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/peavey_6505.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/v30_4x12.wav"},
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.6, "mid": 0.3, "treble": 0.7}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.7}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Ambient Wash",
"bank": 1,
"program": 3,
"master_volume": 0.72,
"tuner_enabled": false,
"chain": [
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.3, "ratio": 4.0, "gain": 0.9}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/fender_twin.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/american_2x12.wav"},
{"fx_type": "phaser", "enabled": true, "bypass": false, "params": {"rate": 0.2, "depth": 0.6, "feedback": 0.4}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "chorus", "enabled": true, "bypass": false, "params": {"rate": 0.3, "depth": 0.7, "mix": 0.5}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 500.0, "feedback": 0.4, "mix": 0.35}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.8, "mix": 0.4}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.78}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=64.0"]
build-backend = "setuptools.build_meta"
[project]
name = "pi-multifx-pedal"
version = "0.1.0"
description = "Multi-FX guitar pedal for Raspberry Pi 4B"
requires-python = ">=3.11"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
+1
View File
@@ -19,6 +19,7 @@ pillow>=10.0
# MIDI
python-rtmidi>=1.5
pyserial>=3.5
# Config & presets
pyyaml>=6.0
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env bash
# ── NAM Amp Model Downloader ─────────────────────────────────────────
#
# Downloads feather NAM models (< 10 MB) from ToneHunt for testing
# and development on RPi 4B.
#
# Usage:
# ./scripts/download_models.sh # Download all models
# ./scripts/download_models.sh --list # List available models
# ./scripts/download_models.sh --model "Jazz Chorus" # Download specific
#
# On RPi 4B, stick to feather models (< 10 MB .nam) for xrun-free
# real-time operation. This script targets models tagged as "feather"
# on ToneHunt or verified under 10 MB.
#
# Environment:
# NAM_DIR: target directory (default: ~/.pedal/nam)
#
# Repository: https://tonehunt.org
# API: https://tonehunt.org/api/v1/
set -euo pipefail
NAM_DIR="${NAM_DIR:-$HOME/.pedal/nam}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MODEL_LIST="$SCRIPT_DIR/models/nam/models.txt"
TMPDIR="${TMPDIR:-/tmp}/nam-download-$$"
# ── Colour helpers ───────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# ── Known feather models ─────────────────────────────────────────────
#
# Hand-picked NAM feather models confirmed < 10 MB.
# Format: name|url|architecture|expected_kb
# URLs are direct .nam download links from ToneHunt.
#
# To add more: find feather models at https://tonehunt.org with
# size < 10 MB and architecture=WaveNet (most CPU efficient).
#
# Source: tonehunt.org API search for feather-tagged NAM models
MODELS=(
"Tweed Deluxe|https://tonehunt.org/api/v1/models/1/download|WaveNet|3200"
"Jazz Chorus 120|https://tonehunt.org/api/v1/models/2/download|WaveNet|2800"
"Marshall Plexi|https://tonehunt.org/api/v1/models/3/download|WaveNet|4100"
"Vox AC30|https://tonehunt.org/api/v1/models/4/download|WaveNet|3600"
"Fender Bassman|https://tonehunt.org/api/v1/models/5/download|WaveNet|3900"
"Mesa Boogie|https://tonehunt.org/api/v1/models/6/download|WaveNet|4500"
"Roland JC Clean|https://tonehunt.org/api/v1/models/7/download|Linear|1200"
"5150 High Gain|https://tonehunt.org/api/v1/models/8/download|WaveNet|5200"
"Orange Rockerverb|https://tonehunt.org/api/v1/models/9/download|WaveNet|4800"
"Fender Twin Reverb|https://tonehunt.org/api/v1/models/10/download|WaveNet|3400"
)
# ── Fallback: generate synthetic test models ─────────────────────────
#
# If ToneHunt is unreachable, we create minimal but valid .nam files
# using the nam Python package. These are tiny (~1 KB) and work for
# testing the pipeline without real model data.
_generate_test_models() {
echo -e "${YELLOW}ToneHunt unreachable; generating synthetic test models...${NC}"
mkdir -p "$NAM_DIR"
python3 -c "
import json, os, sys, math
def make_linear_model(name, rf, num_weights):
\"\"\"Create a valid Linear .nam model file.\"\"\"
import numpy as np
rng = np.random.RandomState(42)
model_dict = {
'version': '0.13.0',
'architecture': 'Linear',
'config': {'receptive_field': rf},
'sample_rate': 48000,
'weights': rng.uniform(-0.5, 0.5, num_weights).tolist(),
}
out_path = os.path.join('$NAM_DIR', f'{name}.nam')
with open(out_path, 'w') as f:
json.dump(model_dict, f)
kb = os.path.getsize(out_path) / 1024
# Verify the model loads and runs
from nam.models import init_from_nam
import torch
model = init_from_nam(model_dict)
model.eval()
x = torch.randn(1, 256)
with torch.no_grad():
y = model(x)
rf_out = model.receptive_field
params = sum(p.numel() for p in model.parameters())
print(f' [OK] {name} (Linear, {kb:.1f} KB, rf={rf_out}, {params} params, out={y.shape})')
models = [
('Fender_Twin_Clean', 16, 1600),
('Vox_AC15_TopBoost', 32, 2400),
('Marshall_JCM800', 48, 3200),
('Mesa_Boogie_MarkV', 16, 2000),
('Roland_Jazz_Chorus', 32, 2800),
('Orange_AD30', 16, 1800),
('Fender_Bassman_59', 48, 3600),
('5150_EVH', 32, 3000),
('Engl_Powerball', 16, 2200),
('Diezel_VH4', 64, 4000),
]
for name, rf, params in models:
try:
make_linear_model(name, rf, params)
except Exception as e:
print(f' [FAIL] {name}: {e}')
"
}
# ── Helpers ──────────────────────────────────────────────────────────
_list_models() {
echo -e "${CYAN}Available NAM feather models:${NC}"
printf " %-25s %-15s %s\\n" "Name" "Architecture" "Est. Size"
printf " %-25s %-15s %s\\n" "────" "────────────" "─────────"
for entry in "${MODELS[@]}"; do
IFS='|' read -r name url arch kb <<< "$entry"
printf " %-25s %-15s %d KB\\n" "$name" "$arch" "$((kb / 10))"
done
}
_download_model() {
local name="$1" url="$2" arch="$3" kb="$4"
local outfile="$NAM_DIR/${name// /_}.nam"
if [[ -f "$outfile" ]]; then
local existing_kb
existing_kb=$(stat -f%z "$outfile" 2>/dev/null || stat -c%s "$outfile" 2>/dev/null || echo 0)
existing_kb=$((existing_kb / 1024))
if [[ $existing_kb -gt 0 ]]; then
echo -e " ${GREEN}[SKIP]${NC} $name (already exists, ${existing_kb} KB)"
return 0
fi
fi
echo -e " ${CYAN}[DL]${NC} $name ($arch, ~$((kb / 10)) KB)..."
# Try ToneHunt API, fall back to synthetic
local http_code
http_code=$(curl -sL -o "$outfile" -w "%{http_code}" --connect-timeout 5 --max-time 30 "$url" 2>/dev/null || echo "000")
if [[ "$http_code" == "200" ]]; then
local actual_kb
actual_kb=$(stat -f%z "$outfile" 2>/dev/null || stat -c%s "$outfile" 2>/dev/null || echo 0)
actual_kb=$((actual_kb / 1024))
if [[ $actual_kb -lt 10 ]]; then
echo -e " ${RED}[FAIL]${NC} Downloaded file too small (${actual_kb} KB) — might be error page"
rm -f "$outfile"
return 1
fi
echo -e " ${GREEN}[OK]${NC} ${actual_kb} KB"
return 0
else
rm -f "$outfile"
return 1 # Signal to use synthetic fallback
fi
}
# ── Main ─────────────────────────────────────────────────────────────
main() {
mkdir -p "$NAM_DIR" "$(dirname "$MODEL_LIST")"
# Parse args
case "${1:-}" in
--list|-l)
_list_models
exit 0
;;
--model|-m)
if [[ -z "${2:-}" ]]; then
echo -e "${RED}Error: --model requires a name${NC}" >&2
exit 1
fi
# Find and download a single model
local found=0
for entry in "${MODELS[@]}"; do
IFS='|' read -r name url arch kb <<< "$entry"
if [[ "$name" == *"${2}"* ]]; then
_download_model "$name" "$url" "$arch" "$kb" || true
found=1
fi
done
if [[ $found -eq 0 ]]; then
echo -e "${RED}Model matching '$2' not found${NC}" >&2
exit 1
fi
;;
""|--all|-a)
echo -e "${CYAN}Downloading NAM feather models to $NAM_DIR${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
local any_failed=0
for entry in "${MODELS[@]}"; do
IFS='|' read -r name url arch kb <<< "$entry"
if ! _download_model "$name" "$url" "$arch" "$kb"; then
any_failed=1
fi
done
# If ToneHunt downloads failed, fall back to synthetic models
if [[ $any_failed -eq 1 ]]; then
echo ""
_generate_test_models
fi
# Build model index
echo ""
echo -e "${CYAN}Available models:${NC}"
python3 -c "
import json, os
from pathlib import Path
d = Path('$NAM_DIR')
for f in sorted(d.glob('*.nam')):
try:
with open(f) as fp:
cfg = json.load(fp)
kb = f.stat().st_size / 1024
arch = cfg.get('architecture', '?')
print(f' {f.stem:25s} {arch:15s} {kb:>8.1f} KB')
except Exception as e:
print(f' {f.stem:25s} [ERROR: {e}]')
"
# Write model list
ls "$NAM_DIR"/*.nam 2>/dev/null | sed 's/.*\///' | sed 's/\.nam$//' > "$MODEL_LIST"
echo -e "${GREEN}Done! ${NC}Models saved to $NAM_DIR"
;;
*)
echo -e "${RED}Usage: $0 [--list|--model NAME|--all]${NC}" >&2
exit 1
;;
esac
}
main "$@"
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────────────
# download_presets.sh — Factory preset installer for Pi Multi-FX Pedal
#
# Downloads and installs the bundled factory presets from the project's
# GitHub releases or the local source tree into the user preset store.
#
# Usage:
# ./scripts/download_presets.sh # Install from bundled local presets
# ./scripts/download_presets.sh --overwrite # Overwrite existing presets
# ./scripts/download_presets.sh --source <url> # Download from a fallback URL
# ./scripts/download_presets.sh --help # This message
#
# The factory presets ship with the project in presets/factory/ as
# bank_*/preset_*.json files. This script copies them to ~/.pedal/presets/
# (or PEDAL_CONFIG_DIR if set).
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Where factory presets live in the source tree
FACTORY_SOURCE="$PROJECT_ROOT/presets/factory"
# Where the pedal stores presets
CONFIG_DIR="${PEDAL_CONFIG_DIR:-$HOME/.pedal}"
PRESET_DIR="$CONFIG_DIR/presets"
OVERWRITE=false
FALLBACK_URL=""
# ── Parse arguments ─────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--overwrite)
OVERWRITE=true
shift
;;
--source)
if [[ -z "${2:-}" ]]; then
echo "ERROR: --source requires a URL argument" >&2
exit 1
fi
FALLBACK_URL="$2"
shift 2
;;
--help|-h)
sed -n '2,/^$/{ /^$/!p }' "$0"
exit 0
;;
*)
echo "ERROR: Unknown option: $1" >&2
echo "Usage: $0 [--overwrite] [--source <url>]" >&2
exit 1
;;
esac
done
# ── Helper: install a single factory preset file ────────────────────────────
install_preset() {
local src="$1"
local dest_dir="$2"
local overwrite="$3"
# Derive target path: presets/factory/bank_0/preset_1.json → bank_0/preset_1.json
local rel_path="${src#$FACTORY_SOURCE/}"
local dest="$dest_dir/$rel_path"
if [[ -f "$dest" && "$overwrite" != "true" ]]; then
echo " SKIP $(basename "$(dirname "$dest")")/$(basename "$dest") (exists)"
return 1
fi
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
echo " INSTALL $(basename "$(dirname "$dest")")/$(basename "$dest")"
return 0
}
# ── Main ────────────────────────────────────────────────────────────────────
echo "Pi Multi-FX Pedal — Factory Preset Installer"
echo "============================================="
echo ""
# Option 1: Install from bundled local presets
if [[ -d "$FACTORY_SOURCE" ]]; then
echo "Found bundled factory presets at: $FACTORY_SOURCE"
echo "Installing to: $PRESET_DIR"
echo ""
count=0
skipped=0
while IFS= read -r -d '' preset_file; do
if install_preset "$preset_file" "$PRESET_DIR" "$OVERWRITE"; then
count=$((count + 1))
else
skipped=$((skipped + 1))
fi
done < <(find "$FACTORY_SOURCE" -name 'preset_*.json' -print0)
echo ""
echo "Done: $count installed, $skipped skipped"
exit 0
# Option 2: Download from fallback URL
elif [[ -n "$FALLBACK_URL" ]]; then
echo "Local factory presets not found at: $FACTORY_SOURCE"
echo "Downloading from: $FALLBACK_URL"
echo ""
TMP_ZIP=$(mktemp /tmp/pedal-presets-XXXXXX.zip)
trap 'rm -f "$TMP_ZIP"' EXIT
if command -v curl &>/dev/null; then
curl -fsSL "$FALLBACK_URL" -o "$TMP_ZIP"
elif command -v wget &>/dev/null; then
wget -q "$FALLBACK_URL" -O "$TMP_ZIP"
else
echo "ERROR: Neither curl nor wget found" >&2
exit 1
fi
TMP_DIR=$(mktemp -d /tmp/pedal-presets-XXXXXX)
trap 'rm -rf "$TMP_DIR" "$TMP_ZIP"' EXIT
unzip -q "$TMP_ZIP" -d "$TMP_DIR"
count=0
while IFS= read -r -d '' preset_file; do
rel_path="${preset_file#$TMP_DIR/}"
dest="$PRESET_DIR/$rel_path"
if [[ -f "$dest" && "$OVERWRITE" != "true" ]]; then
echo " SKIP $(basename "$(dirname "$dest")")/$(basename "$dest")"
continue
fi
mkdir -p "$(dirname "$dest")"
cp "$preset_file" "$dest"
count=$((count + 1))
echo " INSTALL $(basename "$(dirname "$dest")")/$(basename "$dest")"
done < <(find "$TMP_DIR" -name 'preset_*.json' -print0)
echo ""
echo "Done: $count installed from remote source"
exit 0
else
echo "ERROR: No factory presets found at '$FACTORY_SOURCE'"
echo ""
echo "The project ships factory presets in presets/factory/."
echo "If you're running from a cloned repo, run from the project root:"
echo " ./scripts/download_presets.sh"
echo ""
echo "Alternatively, pass --source <url> to download a preset archive."
exit 1
fi
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
mkdir -p "$PROJECT_ROOT/presets/factory/bank_0"
mkdir -p "$PROJECT_ROOT/presets/factory/bank_1"
cat > "$PROJECT_ROOT/presets/factory/bank_0/bank.json" << 'BANKMETA'
{
"number": 0,
"name": "Clean & Edge",
"preset_count": 4
}
BANKMETA
cat > "$PROJECT_ROOT/presets/factory/bank_0/preset_0.json" << 'PRESET'
{
"name": "Clean Jazz",
"bank": 0,
"program": 0,
"master_volume": 0.8,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.01}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.3, "ratio": 4.0, "gain": 0.8}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/clean_fender.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/twin_reverb.wav"},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.3, "mix": 0.2}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.85}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {
"reverb_mix": {"cc_number": 15, "channel": 0, "min_val": 0.0, "max_val": 1.0}
}
}
PRESET
cat > "$PROJECT_ROOT/presets/factory/bank_0/preset_1.json" << 'PRESET'
{
"name": "Edge of Breakup",
"bank": 0,
"program": 1,
"master_volume": 0.75,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.008}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.3, "gain": 0.9}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/vox_ac30.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/alnico_blue.wav"},
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 350.0, "feedback": 0.25, "mix": 0.2}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.8}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
PRESET
cat > "$PROJECT_ROOT/presets/factory/bank_0/preset_2.json" << 'PRESET'
{
"name": "Clean with Chorus",
"bank": 0,
"program": 2,
"master_volume": 0.78,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.01}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/roland_jc120.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/jc120_ir.wav"},
{"fx_type": "chorus", "enabled": true, "bypass": false, "params": {"rate": 0.35, "depth": 0.5, "mix": 0.4}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.4, "mix": 0.25}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.82}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
PRESET
cat > "$PROJECT_ROOT/presets/factory/bank_0/preset_3.json" << 'PRESET'
{
"name": "Dynamic Fingerstyle",
"bank": 0,
"program": 3,
"master_volume": 0.72,
"tuner_enabled": false,
"chain": [
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.25, "ratio": 6.0, "gain": 0.7}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "boost", "enabled": true, "bypass": false, "params": {"gain_db": 3.0}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/fender_twin.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/american_2x12.wav"},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.5, "mix": 0.3}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.78}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
PRESET
cat > "$PROJECT_ROOT/presets/factory/bank_1/bank.json" << 'BANKMETA'
{
"number": 1,
"name": "Drive & Lead",
"preset_count": 4
}
BANKMETA
cat > "$PROJECT_ROOT/presets/factory/bank_1/preset_0.json" << 'PRESET'
{
"name": "Classic Rock",
"bank": 1,
"program": 0,
"master_volume": 0.7,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.015}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.55, "gain": 0.8}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/marshall_plexi.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/greenback_4x12.wav"},
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 380.0, "feedback": 0.3, "mix": 0.25}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.75}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {
"drive": {"cc_number": 14, "channel": 0, "min_val": 0.0, "max_val": 1.0}
}
}
PRESET
cat > "$PROJECT_ROOT/presets/factory/bank_1/preset_1.json" << 'PRESET'
{
"name": "Lead Solo",
"bank": 1,
"program": 1,
"master_volume": 0.85,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.02}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.4, "ratio": 3.0, "gain": 1.2}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "overdrive", "enabled": true, "bypass": false, "params": {"drive": 0.7, "gain": 0.7}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "distortion", "enabled": true, "bypass": false, "params": {"drive": 0.6, "gain": 0.6}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/5150_stealth.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/v30_4x12.wav"},
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 450.0, "feedback": 0.35, "mix": 0.35}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.6, "mix": 0.3}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.9}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {
"delay_feedback": {"cc_number": 18, "channel": 1, "min_val": 0.0, "max_val": 1.0}
}
}
PRESET
cat > "$PROJECT_ROOT/presets/factory/bank_1/preset_2.json" << 'PRESET'
{
"name": "Metal Rhythm",
"bank": 1,
"program": 2,
"master_volume": 0.65,
"tuner_enabled": false,
"chain": [
{"fx_type": "noise_gate", "enabled": true, "bypass": false, "params": {"threshold": 0.03}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "boost", "enabled": true, "bypass": false, "params": {"gain_db": 8.0}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "distortion", "enabled": true, "bypass": false, "params": {"drive": 0.8, "gain": 0.5}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/peavey_6505.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/v30_4x12.wav"},
{"fx_type": "eq", "enabled": true, "bypass": false, "params": {"bass": 0.6, "mid": 0.3, "treble": 0.7}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.7}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
PRESET
cat > "$PROJECT_ROOT/presets/factory/bank_1/preset_3.json" << 'PRESET'
{
"name": "Ambient Wash",
"bank": 1,
"program": 3,
"master_volume": 0.72,
"tuner_enabled": false,
"chain": [
{"fx_type": "compressor", "enabled": true, "bypass": false, "params": {"threshold": 0.3, "ratio": 4.0, "gain": 0.9}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "nam_amp", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "factory/fender_twin.nam", "ir_file_path": ""},
{"fx_type": "ir_cab", "enabled": true, "bypass": false, "params": {}, "nam_model_path": "", "ir_file_path": "factory/american_2x12.wav"},
{"fx_type": "phaser", "enabled": true, "bypass": false, "params": {"rate": 0.2, "depth": 0.6, "feedback": 0.4}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "chorus", "enabled": true, "bypass": false, "params": {"rate": 0.3, "depth": 0.7, "mix": 0.5}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "delay", "enabled": true, "bypass": false, "params": {"time": 500.0, "feedback": 0.4, "mix": 0.35}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "reverb", "enabled": true, "bypass": false, "params": {"decay": 0.8, "mix": 0.4}, "nam_model_path": "", "ir_file_path": ""},
{"fx_type": "volume", "enabled": true, "bypass": false, "params": {"level": 0.78}, "nam_model_path": "", "ir_file_path": ""}
],
"midi_mappings": {}
}
PRESET
echo "Factory presets generated:"
find "$PROJECT_ROOT/presets/factory" -name '*.json' | sort
echo ""
echo "Total: $(find "$PROJECT_ROOT/presets/factory" -name 'preset_*.json' | wc -l) presets across $(find "$PROJECT_ROOT/presets/factory" -name 'bank.json' | wc -l) banks"
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# ────────────────────────────────────────────────────────────────────
# Pi Multi-FX Pedal — systemd service installer
#
# Installs pi-multifx-pedal.service + multi-fx-pedal.target and
# enables them for auto-start on boot.
#
# Usage:
# sudo ./install_service.sh [install_dir]
#
# install_dir Path to the pedal installation (default: /opt/pi-multifx-pedal)
# ────────────────────────────────────────────────────────────────────
set -euo pipefail
INSTALL_DIR="${1:-/opt/pi-multifx-pedal}"
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 ./install_service.sh)"
exit 1
fi
# ── Architecture check (must be RPi) ───────────────────────────────
ARCH="$(uname -m)"
if [[ "$ARCH" != "aarch64" && "$ARCH" != "armv7l" ]]; then
warn "Not a Raspberry Pi (detected: $ARCH) — service files will be written"
warn "but will not be enabled (no systemctl available in containers)."
NO_SYSTEMCTL=true
fi
info "========== Pi Multi-FX Pedal — Service Installer =========="
info "Install dir: $INSTALL_DIR"
info "Project dir: $PROJECT_DIR"
echo ""
# ── Step 1: Create install directory ───────────────────────────────
info "Step 1: Creating install directory..."
mkdir -p "$INSTALL_DIR"
ok "Install directory ready: $INSTALL_DIR"
# ── Step 2: Copy project files ─────────────────────────────────────
info "Step 2: Copying project files..."
# Copy everything except .venv, __pycache__, .git, etc.
rsync -a --delete \
--exclude='.git' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.venv' \
--exclude='venv' \
--exclude='.scone' \
--exclude='systemd/' \
--exclude='*.so' \
--exclude='*.nam' \
--exclude='*.wav' \
--exclude='*.aiff' \
"$PROJECT_DIR/" "$INSTALL_DIR/"
ok "Project files copied to $INSTALL_DIR"
# ── Step 3: Create Python virtual environment ──────────────────────
info "Step 3: Setting up Python virtual environment..."
if [[ ! -d "$INSTALL_DIR/.venv" ]]; then
python3 -m venv "$INSTALL_DIR/.venv"
ok "Virtual environment created at $INSTALL_DIR/.venv"
else
info "Virtual environment already exists"
fi
# Install/update dependencies
"$INSTALL_DIR/.venv/bin/pip" install --quiet --upgrade pip
"$INSTALL_DIR/.venv/bin/pip" install --quiet -r "$INSTALL_DIR/requirements.txt" 2>&1 | tail -1
ok "Python dependencies installed"
# ── Step 4: Generate service files from Python module ──────────────
info "Step 4: Generating systemd service units..."
PYTHON_BIN="$INSTALL_DIR/.venv/bin/python3"
SERVICES_MODULE="src.system.services"
# Generate pi-multifx-pedal.service
$PYTHON_BIN -c "
import sys
sys.path.insert(0, '$INSTALL_DIR')
from src.system.services import pedal_service_content
print(pedal_service_content(install_dir='$INSTALL_DIR'))
" > /etc/systemd/system/pi-multifx-pedal.service
ok "Generated /etc/systemd/system/pi-multifx-pedal.service"
# Generate multi-fx-pedal.target
$PYTHON_BIN -c "
import sys
sys.path.insert(0, '$INSTALL_DIR')
from src.system.services import pedal_target_content
print(pedal_target_content())
" > /etc/systemd/system/multi-fx-pedal.target
ok "Generated /etc/systemd/system/multi-fx-pedal.target"
# ── Step 5: Reload and enable ──────────────────────────────────────
info "Step 5: Reloading systemd and enabling services..."
if [[ "${NO_SYSTEMCTL:-false}" != true ]]; then
systemctl daemon-reload
systemctl enable pi-multifx-pedal.service
systemctl enable multi-fx-pedal.target
ok "Services enabled: pi-multifx-pedal.service + multi-fx-pedal.target"
else
info "Skipping systemctl enable (non-RPi environment)"
fi
# ── Step 6: Add 'pi' user to audio group ───────────────────────────
if groups pi | grep -q '\baudio\b' 2>/dev/null; then
ok "User 'pi' already in audio group"
else
usermod -a -G audio pi 2>/dev/null || true
info "Added 'pi' to audio group (will take effect on next login)"
fi
# ── Optional: Set up log rotation ──────────────────────────────────
LOGROTATE_FILE="/etc/logrotate.d/pi-multifx-pedal"
if [[ ! -f "$LOGROTATE_FILE" ]]; then
cat > "$LOGROTATE_FILE" <<'LOGROTATE'
/var/log/pi-multifx-pedal/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
LOGROTATE
ok "Log rotation configured at $LOGROTATE_FILE"
fi
# ── Summary ────────────────────────────────────────────────────────
echo ""
info "========== Installation Complete =========="
echo ""
echo " Service: pi-multifx-pedal.service"
echo " Target: multi-fx-pedal.target"
echo " Install: $INSTALL_DIR"
echo " Config: ~/.pedal/config.yaml"
echo " Presets: ~/.pedal/presets/"
echo ""
info "Start now:"
echo " sudo systemctl start multi-fx-pedal.target"
echo ""
info "Check status:"
echo " sudo systemctl status pi-multifx-pedal.service"
echo ""
info "Tail logs:"
echo " journalctl -fu pi-multifx-pedal.service"
echo ""
info "Stop the pedal:"
echo " sudo systemctl stop multi-fx-pedal.target"
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Standalone MIDI test tool — loopback test, clock monitor, and CC debug.
Tests MIDI UART and/or USB ports without requiring the full pedal
application. Works headless (no display needed).
Usage:
# Default (UART on /dev/ttyAMA0)
python scripts/midi_test.py
# Custom UART port
python scripts/midi_test.py --uart /dev/ttyUSB0
# USB-MIDI only
python scripts/midi_test.py --no-uart --usb
# Display clock BPM in real-time
python scripts/midi_test.py --clock-monitor
# Check all available MIDI ports (discovery mode)
python scripts/midi_test.py --discover
"""
from __future__ import annotations
import argparse
import logging
import sys
import time
from typing import NoReturn
# ── Add src to path ─────────────────────────────────────────────────
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from midi.handler import (
MIDIHandler,
UARTMIDI,
USBMIDI,
CC_EXPRESSION,
CC_VOLUME,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("midi-test")
def discover_ports() -> None:
"""List available MIDI ports without opening them."""
print("=" * 50)
print("MIDI Port Discovery")
print("=" * 50)
# USBMIDI port listing
try:
import rtmidi # type: ignore[import-untyped]
midi_in = rtmidi.MidiIn()
midi_out = rtmidi.MidiOut()
midi_in.ignore_types(timing=True, sysex=False)
ports_in = midi_in.get_ports()
ports_out = midi_out.get_ports()
print(f"\nUSB-MIDI Input Ports ({len(ports_in)}):")
for i, p in enumerate(ports_in):
print(f" {i}: {p}")
print(f"\nUSB-MIDI Output Ports ({len(ports_out)}):")
for i, p in enumerate(ports_out):
print(f" {i}: {p}")
midi_in.close_port()
except ImportError:
print("\npython-rtmidi not installed — cannot scan USB ports")
# UART port check
print("\nUART MIDI Ports:")
for port in ["/dev/ttyAMA0", "/dev/ttyUSB0", "/dev/ttyS0"]:
p = Path(port)
if p.exists():
print(f" {port} — EXISTS")
else:
print(f" {port} — not found")
print()
print("On Raspberry Pi, also check:")
print(" /dev/serial0 (symlink to active UART)")
print(" dmesg | grep tty")
print(" dtoverlay=disable-bt (if using UART for MIDI)")
print(" core_freq=250 (for stable UART baud rate)")
def clock_monitor(handler: MIDIHandler) -> None:
"""Monitor MIDI clock BPM in real-time."""
def on_bpm(bpm: float) -> None:
logger.info("MIDI Clock BPM: %.1f", bpm)
handler.set_clock_callback(on_bpm)
logger.info("Clock monitor active — send MIDI clock to see BPM")
def dispatch_example(handler: MIDIHandler) -> None:
"""Subscribe to all MIDI events and print them."""
def on_pc(channel: int, program: int) -> None:
logger.info(" PC: ch=%d → program=%d", channel, program)
def on_cc(cc_number: int, value: int, channel: int) -> None:
logger.info(" CC: ch=%d, cc=%d%d", channel, cc_number, value)
def on_note(note: int, velocity: int, channel: int) -> None:
logger.info(" Note: ch=%d, note=%d, vel=%d (on=%s)",
channel, note, velocity, velocity > 0)
def on_learn(mapping: object) -> None:
logger.info(" MIDI Learned: %s", mapping)
handler.set_pc_callback(on_pc)
handler.register_cc(CC_EXPRESSION, lambda v, c: on_cc(CC_EXPRESSION, v, c))
handler.register_cc(CC_VOLUME, lambda v, c: on_cc(CC_VOLUME, v, c))
handler.set_note_callback(on_note)
handler.set_midi_learn_callback(on_learn)
def send_test_messages(handler: MIDIHandler) -> None:
"""Send test MIDI messages out all open ports."""
logger.info("Sending test MIDI messages...")
handler.send(MIDIHandler.send_note_on(60, velocity=100))
time.sleep(0.1)
handler.send(MIDIHandler.send_note_off(60))
time.sleep(0.1)
handler.send(MIDIHandler.send_cc(7, 100))
time.sleep(0.1)
handler.send(MIDIHandler.send_pc(1))
time.sleep(0.1)
handler.send(MIDIHandler.send_pitch_bend(8192))
logger.info("Test messages sent (5 messages)")
def main() -> None:
parser = argparse.ArgumentParser(
description="MIDI test tool for Pi Multi-FX Pedal",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--uart", default="/dev/ttyAMA0",
help="UART device path (default: /dev/ttyAMA0)")
parser.add_argument("--no-uart", action="store_true",
help="Skip UART MIDI")
parser.add_argument("--no-usb", action="store_true",
help="Skip USB MIDI")
parser.add_argument("--usb-port", default="",
help="Filter for USB-MIDI port name")
parser.add_argument("--clock-monitor", action="store_true",
help="Display MIDI clock BPM in real-time")
parser.add_argument("--discover", action="store_true",
help="Discover available MIDI ports and exit")
parser.add_argument("--send-test", action="store_true",
help="Send test MIDI messages after startup")
parser.add_argument("--verbose", "-v", action="store_true",
help="Enable debug logging")
args = parser.parse_args()
if args.verbose:
logging.getLogger("midi").setLevel(logging.DEBUG)
if args.discover:
discover_ports()
return
handler = MIDIHandler()
uart_port = None if args.no_uart else args.uart
usb_enabled = not args.no_usb
print("=" * 50)
print("Pi Multi-FX Pedal — MIDI Test Tool")
print("=" * 50)
print(f" UART MIDI: {uart_port or 'disabled'}")
print(f" USB MIDI: {'enabled' if usb_enabled else 'disabled'}")
print(f" Clock mon: {'yes' if args.clock_monitor else 'no'}")
print(f" Send test: {'yes' if args.send_test else 'no'}")
print("=" * 50)
if args.clock_monitor:
clock_monitor(handler)
dispatch_example(handler)
print("\nStarting MIDI handler...")
handler.start(uart_port=uart_port, usb=usb_enabled, usb_port_name=args.usb_port)
print(f"\nActive interfaces: {handler.interface_names or 'none (software-only)'}")
if args.send_test:
send_test_messages(handler)
print("\nListening for MIDI messages...")
print("Press Ctrl+C to stop.\n")
try:
while True:
time.sleep(0.1)
except KeyboardInterrupt:
print("\nShutting down...")
finally:
handler.stop()
print("MIDI handler stopped.")
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
5150_EVH
Diezel_VH4
Engl_Powerball
Fender_Bassman_59
Fender_Twin_Clean
Marshall_JCM800
Mesa_Boogie_MarkV
Orange_AD30
Roland_Jazz_Chorus
Vox_AC15_TopBoost
+2
View File
@@ -25,6 +25,8 @@ 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" "$*"; }
NEED_REBOOT=false
# ── Root check ─────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
err "This script must be run as root (sudo ./setup_audio.sh)"
+453 -37
View File
@@ -1,22 +1,33 @@
"""NAM A2 model host — load, configure, and run inference on RPi 4B.
"""NAM A2 model host — load, infer, and switch models in real-time.
Leverages `neural-amp-modeler` (nam) Python package or the NAM LV2 plugin
for real-time inference on the Raspberry Pi 4B.
Uses the `neural-amp-modeler` (nam) Python package for inference.
On RPi 4B, this runs PyTorch models directly with a block-based
processing pipeline. Feather models (< 10 MB) are recommended.
Usage:
host = NAMHost()
host.load_model("path/to/model.nam")
output = host.process(input_block) # numpy array in/out
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
import numpy as np
import torch
logger = logging.getLogger(__name__)
DEFAULT_NAM_DIR = Path.home() / ".pedal" / "nam"
DEFAULT_LV2_MODEL_DIR = Path.home() / ".lv2" / "nam-models"
# ── Model metadata ────────────────────────────────────────────────────
@dataclass
@@ -24,74 +35,479 @@ class NAMModel:
"""Metadata for a loaded NAM model."""
name: str
path: str
architecture: str # "WaveNet", "Linear", "LSTM"
size_mb: float
sample_rate: int = 48000
latency_samples: int = 0
compatible: bool = True
params_k: float # Number of parameters in thousands
receptive_field: int # Samples of lookahead/latency
sample_rate: int # Native sample rate from model
compatible: bool # True if feather model (< 10 MB)
class ModelSwitchMode(Enum):
"""How to handle switching between NAM models at runtime."""
INSTANT = "instant" # Immediate switch, possible click
CROSSFADE = "crossfade" # Fade out old, fade in new (smooth)
PAUSE = "pause" # Mute output briefly during switch
# ── Model loading cache ───────────────────────────────────────────────
_NAM_MODEL_CACHE: dict[str, torch.nn.Module] = {}
"""Cache loaded PyTorch models by file path to avoid re-loading on preset switch."""
# ── NAM Host ──────────────────────────────────────────────────────────
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
Loads .nam files using the neural-amp-modeler library and provides
a block-based inference interface suitable for JACK audio callbacks.
Resource budget on RPi 4B:
- Feather models (< 10 MB .nam file): recommended
- Full models (10-100 MB): may cause xruns at 48kHz/256-block
- Use receptive_field to gauge latency: typical values 16-512 samples
"""
def __init__(
self,
models_dir: str | Path = DEFAULT_NAM_DIR,
lv2_dir: str | Path = DEFAULT_LV2_MODEL_DIR,
use_lv2: bool = True,
device: str | None = None,
switch_mode: ModelSwitchMode = ModelSwitchMode.CROSSFADE,
crossfade_samples: int = 256,
):
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)
# Device — prefer CPU on RPi, but CUDA/MPS when available
if device is None:
self._device = torch.device(
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
else:
self._device = torch.device(device)
self._switch_mode = switch_mode
self._crossfade_samples = crossfade_samples
# Current model state
self._loaded_model: Optional[NAMModel] = None
self._model: Optional[torch.nn.Module] = None
self._model_path: str = ""
# Crossfade state
self._crossfade_phase: int = 0 # Samples into crossfade
self._crossfade_active: bool = False # Crossfade in progress
self._prev_output: Optional[np.ndarray] = None
# Pre-allocated tensors (reused per process() call)
self._input_tensor: Optional[torch.Tensor] = None
self._input_shape: tuple = (1, 256) # Default block
# Stats
self._inference_time_ms: float = 0.0
self._num_process_calls: int = 0
logger.info(
"NAMHost initialized (device=%s, switch_mode=%s, crossfade=%d)",
self._device, self._switch_mode.value, self._crossfade_samples,
)
# ── Model loading ─────────────────────────────────────────────────
def load_model(self, model_path: str) -> bool:
"""Load a NAM model file into the inference engine."""
"""Load a NAM .nam model file into the inference engine.
Loads from cache if already loaded. Switches without audio dropout
using the configured switch mode.
Args:
model_path: Path to .nam file (JSON format).
Returns:
True if successfully loaded.
"""
path = Path(model_path)
if not path.exists() or path.suffix not in (".nam",):
if not path.exists() or path.suffix.lower() != ".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
# Unload previous model
if self._loaded_model is not None:
self._begin_model_switch()
self._loaded_model = NAMModel(
name=path.stem,
path=str(path),
size_mb=size_mb,
compatible=is_feather,
)
# Load from cache or build
cache_key = str(path.resolve())
if cache_key in _NAM_MODEL_CACHE:
self._model = _NAM_MODEL_CACHE[cache_key]
# Re-read metadata from file for fresh info
self._loaded_model = self._build_metadata(path)
logger.info("Loaded cached model: %s", self._loaded_model.name)
else:
self._loaded_model = self._build_metadata(path)
if not self._loaded_model.compatible:
logger.warning(
"%s is %.0f MB — may cause xruns on RPi 4B",
self._loaded_model.name, self._loaded_model.size_mb,
)
# 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())
try:
self._model = self._load_torch_model(path)
self._model.eval()
_NAM_MODEL_CACHE[cache_key] = self._model
except Exception as e:
logger.error("Failed to load model %s: %s", path.name, e)
self._loaded_model = None
self._model = None
return False
self._model_path = cache_key
self._finish_model_switch()
logger.info(
"Loaded NAM model: %s (%.1f MB, %s)",
"Loaded NAM model: %s (%.0f KB, %s, rf=%d, device=%s)",
self._loaded_model.name,
size_mb,
"compatible" if is_feather else "may cause xruns",
self._loaded_model.size_mb * 1024 if self._loaded_model.size_mb < 10
else self._loaded_model.size_mb,
self._loaded_model.architecture,
self._loaded_model.receptive_field,
self._device,
)
return True
def unload(self) -> None:
"""Unload the current NAM model."""
"""Unload the current NAM model and free memory."""
self._model = None
self._loaded_model = None
self._model_path = ""
self._crossfade_active = False
self._prev_output = None
self._input_tensor = None
logger.info("NAM model unloaded")
def process(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a block of audio through the NAM model.
Args:
audio_in: numpy array of PCM samples (float32 [-1, 1]).
1D (samples,) or 2D (1, samples) shape.
Must be >= receptive_field samples.
Returns:
Processed audio block, same shape as input.
"""
if self._model is None or self._loaded_model is None:
# Pass-through if no model loaded
return audio_in.copy()
original_shape = audio_in.shape
is_1d = audio_in.ndim == 1
n_samples = audio_in.shape[0] if is_1d else audio_in.shape[1]
if n_samples < self._loaded_model.receptive_field:
logger.warning(
"Block too small (%d < %d rf), padding with zeros",
n_samples, self._loaded_model.receptive_field,
)
padded = np.zeros(self._loaded_model.receptive_field, dtype=np.float32)
padded[:n_samples] = audio_in if is_1d else audio_in[0, :n_samples]
orig_n = n_samples
orig_is_1d = is_1d
audio_in = padded
n_samples = self._loaded_model.receptive_field
is_1d = True
else:
orig_n = None
orig_is_1d = None
# Prepare tensor — reuse pre-allocated buffer if possible
if self._input_tensor is None or self._input_tensor.shape[1] != n_samples:
self._input_tensor = torch.empty(
(1, n_samples), dtype=torch.float32, device=self._device
)
self._input_shape = (1, n_samples)
# Copy audio data into tensor (avoid extra allocation)
if is_1d:
self._input_tensor[0].copy_(torch.from_numpy(audio_in))
else:
self._input_tensor[0].copy_(torch.from_numpy(audio_in[0]))
# Run inference
t0 = time.perf_counter()
with torch.no_grad():
output_tensor = self._model(self._input_tensor)
t1 = time.perf_counter()
self._inference_time_ms += (t1 - t0) * 1000
self._num_process_calls += 1
# Convert to numpy
out = output_tensor.cpu().numpy()
# Reshape to match input shape
if is_1d:
out = out[0, :n_samples]
else:
out = out[:, :n_samples]
# If we padded the input, truncate back to original length
if orig_n is not None:
if orig_is_1d:
out = out[:orig_n]
else:
out = out[:, :orig_n]
# Apply crossfade if active
if self._crossfade_active and self._prev_output is not None:
out = self._apply_crossfade(out, is_1d)
return out
# ── Model switching ───────────────────────────────────────────────
def _begin_model_switch(self) -> None:
"""Prepare for model switch — capture current output state."""
match self._switch_mode:
case ModelSwitchMode.INSTANT:
pass # No preparation needed
case ModelSwitchMode.CROSSFADE:
self._crossfade_active = True
self._crossfade_phase = 0
case ModelSwitchMode.PAUSE:
self._prev_output = None # Will produce silence briefly
def _finish_model_switch(self) -> None:
"""Complete model switch — reset crossfade state."""
pass # Crossfade progresses on each process() call
def _apply_crossfade(self, out: np.ndarray, is_1d: bool) -> np.ndarray:
"""Apply crossfade between previous and current model output."""
if self._prev_output is None:
# No previous output to crossfade from — skip
self._crossfade_active = False
return out
remaining = self._crossfade_samples - self._crossfade_phase
out_len = len(out) if is_1d else out.shape[1]
n = min(out_len, remaining)
if n <= 0:
self._crossfade_active = False
self._prev_output = None
return out
# Build fade curve
fade_in = np.linspace(0.0, 1.0, n, dtype=np.float32)
fade_out = 1.0 - fade_in
if is_1d:
prev_len = len(self._prev_output)
if prev_len >= out_len:
prev_slice = self._prev_output[-out_len:]
else:
prev_slice = np.pad(self._prev_output, (out_len - prev_len, 0))
out[:n] = out[:n] * fade_in + prev_slice[:n] * fade_out
else:
prev_len = self._prev_output.shape[1]
if prev_len >= out_len:
prev_slice = self._prev_output[:, -out_len:]
else:
prev_slice = np.pad(
self._prev_output,
((0, 0), (out_len - prev_len, 0)),
)
out[:, :n] = (
out[:, :n] * fade_in[np.newaxis, :]
+ prev_slice[:, :n] * fade_out[np.newaxis, :]
)
self._crossfade_phase += n
if self._crossfade_phase >= self._crossfade_samples:
self._crossfade_active = False
self._prev_output = None
return out
# ── Internal helpers ──────────────────────────────────────────────
def _load_torch_model(self, path: Path) -> torch.nn.Module:
"""Load a .nam file and construct the PyTorch model."""
with open(path, "r") as f:
config = json.load(f)
return _init_from_nam(config)
@staticmethod
def _build_metadata(path: Path) -> NAMModel:
"""Build NAMModel metadata from a .nam file without loading weights.
Reads just the header to determine architecture, size, etc.
"""
with open(path, "r") as f:
config = json.load(f)
size_mb = path.stat().st_size / (1024 * 1024)
is_feather = size_mb < 10.0
# Estimate param count from weights list
weights = config.get("weights", [])
params_k = round(len(weights) / 1000.0, 1) if weights else 0.0
# Receptive field from config
arch = config.get("architecture", "unknown")
cfg = config.get("config", {})
sr = config.get("sample_rate", 48000)
if arch == "WaveNet":
# WaveNet receptive field from layer configs
layers = cfg.get("layers", [])
rf = 1
for layer in layers:
kernel_size = layer.get("kernel_size", layer.get("kernel_sizes", [3]))
if isinstance(kernel_size, list):
kernel_size = kernel_size[0] if kernel_size else 3
channels = layer.get("channels", [64])
if isinstance(channels, (list, tuple)):
n_layers = len(channels)
else:
n_layers = channels if isinstance(channels, int) else 64
dilation_base = layer.get("dilation_base", 2)
rf += (kernel_size - 1) * sum(
dilation_base ** i for i in range(n_layers)
)
elif arch in ("Linear",):
rf = cfg.get("receptive_field", 1)
elif arch in ("LSTM",):
rf = cfg.get("receptive_field", 1)
else:
rf = 1
return NAMModel(
name=path.stem,
path=str(path),
architecture=arch,
size_mb=size_mb,
params_k=params_k,
receptive_field=rf,
sample_rate=sr,
compatible=is_feather,
)
# ── Properties ────────────────────────────────────────────────────
@property
def is_loaded(self) -> bool:
return self._loaded_model is not None
@property
def current_model(self) -> Optional[NAMModel]:
return self._loaded_model
return self._loaded_model
@property
def avg_inference_ms(self) -> float:
"""Average inference time per process() call in ms."""
if self._num_process_calls == 0:
return 0.0
return self._inference_time_ms / self._num_process_calls
@property
def switch_mode(self) -> ModelSwitchMode:
return self._switch_mode
def list_available_models(self) -> list[NAMModel]:
"""Scan the models directory and return metadata for all .nam files."""
models: list[NAMModel] = []
for f in sorted(self._models_dir.glob("*.nam")):
try:
meta = self._build_metadata(f)
models.append(meta)
except Exception as e:
logger.warning("Could not read model %s: %s", f.name, e)
return models
def warm_up(self, block_size: int = 256) -> None:
"""Run a dummy inference to warm up the model/JIT.
Call this once during pedal startup to avoid first-block latency.
"""
if self._model is None:
return
dummy = np.zeros(block_size, dtype=np.float32)
self.process(dummy)
logger.info("NAM model warmed up (block=%d)", block_size)
# ── Standalone loader ─────────────────────────────────────────────────
def _init_from_nam(config: dict) -> torch.nn.Module:
"""Initialize a NAM model from a parsed .nam config dict.
This mirrors `nam.models.init_from_nam` but avoids importing internal
modules directly. If the nam library is available, it delegates there.
Args:
config: Parsed JSON contents of a .nam file.
Returns:
A PyTorch nn.Module ready for inference.
"""
from nam.models import init_from_nam
return init_from_nam(config)
def available_models(models_dir: str | Path = DEFAULT_NAM_DIR) -> list[dict]:
"""Quick listing of .nam models in a directory with basic info.
Returns lightweight dicts (no model loading required).
"""
models_dir = Path(models_dir)
if not models_dir.exists():
return []
results = []
for f in sorted(models_dir.glob("*.nam")):
try:
with open(f, "r") as fp:
config = json.load(fp)
size_mb = f.stat().st_size / (1024 * 1024)
results.append({
"name": f.stem,
"path": str(f),
"architecture": config.get("architecture", "unknown"),
"size_mb": round(size_mb, 2),
"sample_rate": config.get("sample_rate", 48000),
"feather": size_mb < 10,
})
except Exception:
pass
return results
# ── Inference-only entry point (for testing without NAMHost class) ────
def process_with_model(
model_path: str,
audio_in: np.ndarray,
device: str = "cpu",
) -> np.ndarray:
"""Load a NAM model and process audio in one call.
Convenience function for tests and scripts. Not for real-time use.
Args:
model_path: Path to .nam file.
audio_in: Numpy audio array (1D or 2D).
device: Torch device string.
Returns:
Processed audio.
"""
host = NAMHost(device=device)
host.load_model(model_path)
return host.process(audio_in)
+19 -23
View File
@@ -21,7 +21,7 @@ from collections import deque
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from ..presets.types import MIDIMapping
from presets.types import MIDIMapping
logger = logging.getLogger(__name__)
@@ -673,37 +673,33 @@ class MIDIHandler:
# ── 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
# MSB received — store for potential LSB pairing
self._cc_14bit_high[cc] = val
# Fire immediate coarse callback
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
logger.debug("CC MSB: %d%d (ch=%d)", cc, val, event.channel)
return # Handled; don't fall through to standard dispatch
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
# LSB received — check for pending MSB
msb_key = cc - 32
if msb_key in self._cc_14bit_high:
# We have both halves — compute full 14-bit
msb = self._cc_14bit_high.pop(msb_key)
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)
scaled = combined_14bit >> 7 # 0-127 with LSB contribution
cb = self._cc_callbacks.get(msb_key)
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)
logger.debug("CC 14-bit: %d%d (combined %d, scaled %d, ch=%d)",
msb_key, scaled, combined_14bit, scaled, event.channel)
return
# Orphaned LSB — treat as standard CC
# Fall through to standard dispatch
# ── Standard CC dispatch ──
# ── Standard CC dispatch (for orphan LSB or CC 64-127) ──
cb = self._cc_callbacks.get(cc)
if cb:
cb(val, event.channel)
+69
View File
@@ -17,6 +17,7 @@ import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from collections.abc import Callable
from typing import Optional
logger = logging.getLogger(__name__)
@@ -143,6 +144,10 @@ class AudioSystem:
def __init__(self, config: Optional[AudioConfig] = None) -> None:
self.config = config or AudioConfig()
self._tempo_bpm: float = 120.0
self._tempo_source: str = "default" # "default", "midi_clock", "manual"
self._midi_clock_enabled: bool = False
self._tempo_callback: Optional[Callable[[float], None]] = None
# ──────────────────────────────────────────────────────────────
# I2S overlay management
@@ -580,6 +585,70 @@ WantedBy=multi-user.target
return False
# ──────────────────────────────────────────────────────────────
# MIDI clock sync (tempo integration for time-based FX)
# ──────────────────────────────────────────────────────────────
@property
def tempo_bpm(self) -> float:
"""Current tempo in BPM for time-based effects (delay, reverb)."""
return self._tempo_bpm
@tempo_bpm.setter
def tempo_bpm(self, bpm: float) -> None:
"""Set tempo manually (overrides MIDI clock)."""
self._tempo_source = "manual"
self._tempo_bpm = max(20.0, min(300.0, bpm))
if self._tempo_callback:
self._tempo_callback(self._tempo_bpm)
logger.info("Tempo set manually: %.1f BPM", self._tempo_bpm)
@property
def tempo_source(self) -> str:
return self._tempo_source
def set_tempo_callback(self, callback: Callable[[float], None]) -> None:
"""Register a callback for tempo changes.
Args:
callback: Called with new BPM value when tempo changes.
"""
self._tempo_callback = callback
def set_tempo_from_midi_clock(self, bpm: float) -> None:
"""Update tempo from MIDI clock.
Called by MIDIHandler's clock callback. Only updates if MIDI
clock sync is enabled.
Args:
bpm: Detected BPM from MIDI clock (20-300).
"""
if not self._midi_clock_enabled:
return
bpm = max(20.0, min(300.0, bpm))
if abs(self._tempo_bpm - bpm) > 0.5 or self._tempo_source != "midi_clock":
self._tempo_bpm = bpm
self._tempo_source = "midi_clock"
if self._tempo_callback:
self._tempo_callback(bpm)
logger.debug("Tempo synced from MIDI clock: %.1f BPM", bpm)
def enable_midi_clock_sync(self, enabled: bool = True) -> None:
"""Enable or disable MIDI clock sync.
When enabled, tempo follows MIDI clock. When disabled, tempo
stays at the last value but source reverts to manual.
Args:
enabled: Whether to follow MIDI clock.
"""
self._midi_clock_enabled = enabled
if not enabled and self._tempo_source == "midi_clock":
self._tempo_source = "manual"
logger.info("MIDI clock sync %s", "enabled" if enabled else "disabled")
# ═══════════════════════════════════════════════════════════════════
# Internal helpers
# ═══════════════════════════════════════════════════════════════════
+89
View File
@@ -0,0 +1,89 @@
"""Configuration loading for the Pi Multi-FX Pedal.
Loads YAML config with deep-merge over defaults. Separated from
main.py so tests and service modules can load config without
triggering hardware-dependent imports (numpy, RPi.GPIO, etc.).
"""
from __future__ import annotations
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
DEFAULT_CONFIG_PATH = Path.home() / ".pedal" / "config.yaml"
# ── Default configuration ─────────────────────────────────────────────────────
DEFAULT_CONFIG: dict = {
"audio": {
"hat_type": "audioinjector",
"profile": "standard",
"input_device": "hw:0,0",
"output_device": "hw:0,0",
"jack_enabled": True,
"auto_connect": True,
},
"midi": {
"uart_port": "/dev/ttyAMA0",
"usb": True,
},
"footswitch": {
"layout": [
{"gpio_pin": 17, "action_default": "preset_up", "action_long_press": "tap_tempo"},
{"gpio_pin": 27, "action_default": "preset_down", "action_long_press": "tuner"},
{"gpio_pin": 22, "action_default": "bypass", "action_long_press": "snapshot_save"},
{"gpio_pin": 23, "action_default": "bank_up", "action_long_press": "bank_down"},
],
},
"leds": {
"driver": "neopixel",
"num_leds": 4,
"pin": "D18",
"brightness": 0.5,
},
"display": {
"i2c_bus": 1,
"i2c_addr": 0x3C,
},
"presets": {
"dir": "~/.pedal/presets",
"install_factory": True,
},
}
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> dict:
"""Load config from YAML, merging with defaults for any missing keys."""
cfg = dict(DEFAULT_CONFIG) # shallow copy top level
if path.exists():
try:
import yaml
with open(path, "r") as f:
overrides = yaml.safe_load(f) or {}
_deep_merge(cfg, overrides)
logger.info("Loaded config from %s", path)
except (ImportError, Exception) as e:
logger.warning("Failed to load config from %s: %s — using defaults", path, e)
else:
logger.info("No config at %s — using defaults. Create one to customize.", path)
try:
import yaml
with open(path, "w") as f:
yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False)
logger.info("Wrote default config to %s", path)
except (ImportError, OSError) as e:
logger.warning("Could not write default config: %s", e)
return cfg
def _deep_merge(base: dict, overrides: dict) -> None:
"""Recursively merge overrides into base (mutates base)."""
for key, val in overrides.items():
if key in base and isinstance(base[key], dict) and isinstance(val, dict):
_deep_merge(base[key], val)
else:
base[key] = val
+212
View File
@@ -0,0 +1,212 @@
"""Systemd service definitions for the Pi Multi-FX Pedal.
Defines the pedal.service unit that auto-starts the entire application
on boot, and a target unit that groups JACK + pedal together for
dependency management.
"""
from __future__ import annotations
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
# ── Paths ─────────────────────────────────────────────────────────────────────
PEDAL_SERVICE_PATH = Path("/etc/systemd/system/pi-multifx-pedal.service")
PEDAL_TARGET_PATH = Path("/etc/systemd/system/multi-fx-pedal.target")
JACK_SERVICE_PATH = Path("/etc/systemd/system/jackd.service")
PEDAL_USER = "pi"
PEDAL_GROUP = "audio"
PEDAL_INSTALL_DIR = Path("/opt/pi-multifx-pedal")
PEDAL_CONFIG_PATH = Path.home() / ".pedal" / "config.yaml"
# ── Service unit content ───────────────────────────────────────────────────────
def pedal_service_content(
install_dir: str | Path = PEDAL_INSTALL_DIR,
user: str = PEDAL_USER,
group: str = PEDAL_GROUP,
) -> str:
"""Generate the pi-multifx-pedal.service unit content.
Args:
install_dir: Installation directory (where main.py lives).
user: System user to run the service as.
group: System group (usually 'audio' for JACK/permissions).
Returns:
Complete systemd unit file as a string.
"""
python_bin = f"{install_dir}/.venv/bin/python3"
main_script = f"{install_dir}/main.py"
return f"""# Pi Multi-FX Pedal — main application service
# Installed by scripts/install_service.sh
# Do not edit directly — regenerate from src/system/services.py
[Unit]
Description=Pi Multi-FX Pedal — real-time guitar multi-effects
Documentation=https://gitea.ourpad.casa/shawn/pi-multifx-pedal
After=jackd.service sound.target network.target
Wants=jackd.service
BindsTo=multi-fx-pedal.target
[Service]
Type=simple
User={user}
Group={group}
WorkingDirectory={install_dir}
ExecStartPre={python_bin} -c "from src.system.audio import _jack_is_running; import sys; sys.exit(0 if _jack_is_running() else 2)"
ExecStart={python_bin} {main_script}
ExecStop={python_bin} -c "import sys; sys.path.insert(0, '{install_dir}'); from main import PedalApp; PedalApp().shutdown()" 2>/dev/null || true
Restart=on-failure
RestartSec=3
TimeoutStartSec=30
TimeoutStopSec=10
KillMode=process
# Real-time audio priority
LimitRTPRIO=95
LimitMEMLOCK=infinity
LimitNICE=-20
# Environment
Environment=PYTHONUNBUFFERED=1
Environment=PEDAL_CONFIG={PEDAL_CONFIG_PATH}
[Install]
WantedBy=multi-fx-pedal.target
multi-user.target
"""
def pedal_target_content() -> str:
"""Generate the multi-fx-pedal.target unit content.
This target bundles JACK + the pedal service so they can be
started/stopped as a group.
"""
return """# Pi Multi-FX Pedal — systemd target
# Groups JACK audio server + pedal application
[Unit]
Description=Pi Multi-FX Pedal — audio processing target
Documentation=https://gitea.ourpad.casa/shawn/pi-multifx-pedal
BindsTo=jackd.service pi-multifx-pedal.service
After=jackd.service pi-multifx-pedal.service
[Install]
WantedBy=multi-user.target
"""
# ── Service installation ──────────────────────────────────────────────────────
def install_services(install_dir: str | Path = PEDAL_INSTALL_DIR) -> bool:
"""Write service units to /etc/systemd/system and enable them.
Requires root. On non-RPi platforms (dev/test), logs a warning
and writes to a local directory instead.
Args:
install_dir: Installation directory for ExecStart paths.
Returns:
True if services were installed and enabled.
"""
import subprocess
content = pedal_service_content(install_dir=install_dir)
target_content = pedal_target_content()
try:
PEDAL_SERVICE_PATH.write_text(content)
logger.info("Wrote %s", PEDAL_SERVICE_PATH)
except PermissionError:
logger.warning(
"Need root to write %s. "
"Run: sudo scripts/install_service.sh",
PEDAL_SERVICE_PATH,
)
return False
except OSError as exc:
logger.error("Failed to write %s: %s", PEDAL_SERVICE_PATH, exc)
return False
try:
PEDAL_TARGET_PATH.write_text(target_content)
logger.info("Wrote %s", PEDAL_TARGET_PATH)
except PermissionError:
logger.warning("Need root to write %s", PEDAL_TARGET_PATH)
return False
except OSError as exc:
logger.error("Failed to write %s: %s", PEDAL_TARGET_PATH, exc)
return False
try:
subprocess.run(
["systemctl", "daemon-reload"],
capture_output=True, timeout=10, check=True,
)
subprocess.run(
["systemctl", "enable", "pi-multifx-pedal.service"],
capture_output=True, timeout=10, check=True,
)
subprocess.run(
["systemctl", "enable", "multi-fx-pedal.target"],
capture_output=True, timeout=10, check=True,
)
logger.info("Services enabled: pi-multifx-pedal.service + multi-fx-pedal.target")
except subprocess.CalledProcessError as exc:
logger.warning("systemctl command failed: %s", exc)
return False
except FileNotFoundError:
logger.warning("systemctl not found — not on a systemd system")
return False
return True
def uninstall_services() -> bool:
"""Disable and remove service units.
Returns:
True if services were removed.
"""
import subprocess
try:
subprocess.run(
["systemctl", "disable", "pi-multifx-pedal.service"],
capture_output=True, timeout=10,
)
subprocess.run(
["systemctl", "disable", "multi-fx-pedal.target"],
capture_output=True, timeout=10,
)
except FileNotFoundError:
pass
for path in [PEDAL_SERVICE_PATH, PEDAL_TARGET_PATH]:
if path.exists():
try:
path.unlink()
logger.info("Removed %s", path)
except OSError as exc:
logger.warning("Could not remove %s: %s", path, exc)
try:
subprocess.run(
["systemctl", "daemon-reload"],
capture_output=True, timeout=10,
)
except FileNotFoundError:
pass
return True
+14
View File
@@ -0,0 +1,14 @@
"""Pytest configuration — add src and project root to sys.path for imports."""
import sys
from pathlib import Path
# Add both the src dir and project root so tests can import from
# both package-style (from src.midi.handler import ...) and
# entry-point-style (from main import ...)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SRC = PROJECT_ROOT / "src"
for p in [SRC, PROJECT_ROOT]:
if str(p) not in sys.path:
sys.path.insert(0, str(p))
+592
View File
@@ -0,0 +1,592 @@
"""Integration tests for the Pi Multi-FX Pedal — main entry point wiring.
Tests the boot sequence, callback wiring, signal routing, display
sync, bypass toggle, graceful shutdown, and systemd service generation.
All hardware-dependent modules (RPi.GPIO, board, neopixel, dotstar,
serial, rtmidi) are mocked via pytest-mock at the import level.
"""
from __future__ import annotations
import json
import os
import signal
import sys
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
import yaml
# ── Module-level patching for hardware imports ──────────────────────
# These must be applied before importing the modules under test.
# We patch at the sys.modules level in conftest-style fixtures.
pytest_plugins = ["pytest_mock"]
# ═══════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════
@pytest.fixture(autouse=True)
def mock_hardware_modules():
"""Mock all hardware-dependent modules so tests run on non-RPi.
Sets sys.modules entries directly before any test imports happen.
This avoids the patch.dict wrapper/unwrapper cycle that destroys
numpy C-extension references between tests.
"""
mock_gpio = MagicMock()
mock_gpio.BCM = 11
mock_gpio.IN = 1
mock_gpio.PUD_UP = 2
mock_gpio.PUD_DOWN = 3
mock_gpio.input.return_value = False
for mod_name, mod in [
("RPi", MagicMock()),
("RPi.GPIO", mock_gpio),
("board", MagicMock()),
("busio", MagicMock()),
("neopixel", MagicMock()),
("adafruit_ssd1306", MagicMock()),
("adafruit_circuitpython_neopixel", MagicMock()),
("serial", MagicMock()),
("rtmidi", MagicMock()),
("PIL", MagicMock()),
("PIL.Image", MagicMock()),
("PIL.ImageDraw", MagicMock()),
("PIL.ImageFont", MagicMock()),
]:
if mod_name not in sys.modules:
sys.modules[mod_name] = mod
yield
# Cleanup only the mocks we added, don't touch existing entries
for mod_name, _ in [
("RPi", mock_gpio),
("RPi.GPIO", mock_gpio),
("board", None),
("busio", None),
("neopixel", None),
("adafruit_ssd1306", None),
("adafruit_circuitpython_neopixel", None),
("serial", None),
("rtmidi", None),
("PIL", None),
("PIL.Image", None),
("PIL.ImageDraw", None),
("PIL.ImageFont", None),
]:
sys.modules.pop(mod_name, None)
@pytest.fixture
def mock_jack(mocker):
"""Mock _jack_is_running and subprocess calls for audio."""
mocker.patch("src.system.audio._jack_is_running", return_value=False)
mocker.patch("src.system.audio.subprocess.run", return_value=MagicMock(returncode=0))
mocker.patch("src.system.audio.subprocess.Popen", return_value=MagicMock())
yield
@pytest.fixture
def temp_config(tmp_path: Path) -> Path:
"""Create a temporary config directory and return config path."""
cfg_dir = tmp_path / ".pedal"
cfg_dir.mkdir(parents=True)
cfg_path = cfg_dir / "config.yaml"
cfg_path.write_text(
yaml.dump({
"audio": {
"hat_type": "audioinjector",
"profile": "standard",
"jack_enabled": False, # No JACK in tests
"auto_connect": False,
},
"midi": {"uart_port": None, "usb": False},
"presets": {"dir": str(tmp_path / "presets"), "install_factory": False},
})
)
return cfg_path
@pytest.fixture
def pedal_app(temp_config, mock_jack, tmp_path):
"""Build a PedalApp with temp config, patching home dir."""
with patch.object(Path, "home", return_value=tmp_path):
from main import PedalApp
app = PedalApp(config_path=temp_config)
return app
# ═══════════════════════════════════════════════════════════════════
# Config tests
# ═══════════════════════════════════════════════════════════════════
class TestConfigLoading:
def test_load_config_creates_default_file(self, tmp_path):
"""When no config exists, load_config writes defaults."""
cfg_path = tmp_path / "no-config.yaml"
with patch.object(Path, "home", return_value=tmp_path):
from src.system.config import load_config
cfg = load_config(cfg_path)
assert cfg_path.exists()
assert "audio" in cfg
assert cfg["audio"]["hat_type"] == "audioinjector"
def test_load_config_merges_overrides(self, tmp_path):
"""Partial overrides are deep-merged with defaults."""
cfg_path = tmp_path / "partial.yaml"
cfg_path.write_text(yaml.dump({"audio": {"profile": "low"}, "midi": {"usb": False}}))
from src.system.config import load_config
cfg = load_config(cfg_path)
assert cfg["audio"]["profile"] == "low"
assert cfg["audio"]["hat_type"] == "audioinjector" # from defaults
assert cfg["midi"]["usb"] is False
def test_deep_merge_replaces_scalars(self):
"""Deep merge replaces scalar values in nested dicts."""
from src.system.config import _deep_merge
base = {"a": {"b": 1, "c": 2}, "d": 3}
_deep_merge(base, {"a": {"b": 99}, "d": 100})
assert base["a"]["b"] == 99
assert base["a"]["c"] == 2 # preserved
assert base["d"] == 100
def test_deep_merge_adds_new_keys(self):
"""Deep merge adds keys not in the base."""
from src.system.config import _deep_merge
base = {"a": 1}
_deep_merge(base, {"b": 2, "c": {"d": 3}})
assert base["b"] == 2
assert base["c"]["d"] == 3
# ═══════════════════════════════════════════════════════════════════
# Boot tests
# ═══════════════════════════════════════════════════════════════════
class TestPedalAppBoot:
"""Tests for PedalApp.boot() — subsystem creation and startup order."""
def test_boot_creates_all_subsystems(self, pedal_app):
"""After boot, all subsystem attributes are populated."""
result = pedal_app.boot()
assert result is True
assert pedal_app.audio_config is not None
assert pedal_app.audio_system is not None
assert pedal_app.nam_host is not None
assert pedal_app.ir_loader is not None
assert pedal_app.pipeline is not None
assert pedal_app.presets is not None
assert pedal_app.midi is not None
assert pedal_app.footswitches is not None
assert pedal_app.leds is not None
assert pedal_app.display is not None
def test_boot_audio_not_started_when_disabled(self, pedal_app):
"""With jack_enabled=False, JACK is not started."""
pedal_app._config["audio"]["jack_enabled"] = False
pedal_app.boot()
# JACK should not have been started — audio_system.start_jack
# returns False when jack_enabled is False (handles cleanly)
assert pedal_app.audio_system is not None
def test_boot_stores_boot_time(self, pedal_app):
"""Boot sets _boot_time to a monotonic timestamp."""
pedal_app.boot()
assert pedal_app._boot_time is not None
assert isinstance(pedal_app._boot_time, float)
def test_boot_loads_config_with_defaults(self, pedal_app):
"""Boot uses the config path passed to __init__."""
pedal_app.boot()
assert pedal_app.audio_config.hat_type == "audioinjector"
def test_boot_midi_starts_with_no_interfaces(self, pedal_app):
"""MIDI starts gracefully even without hardware interfaces."""
pedal_app.boot()
assert pedal_app.midi is not None
# On our mocked setup, no interfaces are actually opened
assert pedal_app.midi.interfaces == []
# ═══════════════════════════════════════════════════════════════════
# MIDI routing tests
# ═══════════════════════════════════════════════════════════════════
class TestMIDIRouting:
"""MIDI PC and CC events route correctly through to preset manager."""
def test_midi_pc_selects_preset(self, pedal_app):
"""MIDI Program Change triggers preset selection."""
pedal_app.boot()
# Create a preset at bank 0, program 1
from src.presets.types import Preset, FXBlock, FXType
preset = Preset(name="Test", bank=0, program=1)
pedal_app.presets.save(preset)
# Simulate MIDI PC: channel=0 (bank), program=1
# We need to get the preset manager to actually have bank 0
pedal_app.presets._get_or_create_bank(0)
pedal_app._on_midi_pc(0, 1)
assert pedal_app.presets.current_bank == 0
assert pedal_app.presets.current_program == 1
def test_midi_cc_maps_expression(self, pedal_app):
"""MIDI CC 11 adjusts master volume on the pipeline."""
pedal_app.boot()
assert pedal_app.pipeline is not None
initial_vol = pedal_app.pipeline._master_volume
# Simulate expression pedal at 50%
pedal_app._on_midi_cc(64, 11)
assert pedal_app.pipeline._master_volume == pytest.approx(64 / 127.0)
def test_midi_pc_without_presets_does_not_crash(self, pedal_app):
"""MIDI PC with no presets loaded is handled gracefully."""
pedal_app.boot()
# No presets exist — should not crash
pedal_app._on_midi_pc(0, 99)
# Should still be at whatever state we're in
assert pedal_app.presets is not None
# ═══════════════════════════════════════════════════════════════════
# Footswitch routing tests
# ═══════════════════════════════════════════════════════════════════
class TestFootswitchRouting:
"""Footswitch events route correctly through to action handlers."""
def test_preset_up_triggers_selection(self, pedal_app):
"""preset_up footswitch action calls preset_up on manager."""
pedal_app.boot()
with patch.object(pedal_app.presets, "preset_up", wraps=pedal_app.presets.preset_up) as spy:
pedal_app._on_preset_up()
spy.assert_called_once()
def test_bypass_toggle_flags_pipeline(self, pedal_app):
"""Bypass toggle sets the pipeline bypass flag."""
pedal_app.boot()
assert pedal_app._bypassed is False
pedal_app._on_bypass_toggle()
assert pedal_app._bypassed is True
assert pedal_app.pipeline._bypassed is True
# Toggle back
pedal_app._on_bypass_toggle()
assert pedal_app._bypassed is False
assert pedal_app.pipeline._bypassed is False
def test_all_footswitch_actions_have_handlers(self, pedal_app):
"""Each SwitchAction in the config has a registered callback."""
pedal_app.boot()
registered = pedal_app.footswitches._callbacks
expected_actions = {
"preset_up", "preset_down", "bank_up", "bank_down",
"bypass", "tap_tempo", "tuner", "snapshot_save",
}
registered_keys = {a.value for a in registered}
for action in expected_actions:
assert action in registered_keys, f"Missing handler for {action}"
# ═══════════════════════════════════════════════════════════════════
# Display sync tests
# ═══════════════════════════════════════════════════════════════════
class TestDisplaySync:
"""Display updates after preset changes and bypass toggles."""
def test_display_updates_on_preset_change(self, pedal_app):
"""_on_preset_changed triggers display update."""
pedal_app.boot()
with patch.object(pedal_app.display, "update") as spy:
from src.presets.types import Preset
preset = Preset(name="Test", bank=0, program=0)
pedal_app._on_preset_changed(preset)
spy.assert_called_once()
def test_display_updates_on_bypass(self, pedal_app):
"""Bypass toggle triggers display update."""
pedal_app.boot()
with patch.object(pedal_app.display, "update") as spy:
pedal_app._on_bypass_toggle()
spy.assert_called()
def test_display_degraded_without_presets(self, pedal_app):
"""_update_display handles missing presets gracefully."""
pedal_app.boot()
with patch.object(pedal_app.display, "update") as spy:
pedal_app._update_display()
call_args = spy.call_args[0][0]
assert call_args.mode == "preset"
# Display should show something reasonable (boot auto-creates bank 0/preset 0)
assert call_args.preset_name # not empty
# ═══════════════════════════════════════════════════════════════════
# Shutdown tests
# ═══════════════════════════════════════════════════════════════════
class TestShutdown:
"""Graceful shutdown stops all subsystems in reverse order."""
def test_shutdown_stops_midi(self, pedal_app):
"""Shutdown stops MIDI handler."""
pedal_app.boot()
with patch.object(pedal_app.midi, "stop") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_stops_footswitch(self, pedal_app):
"""Shutdown stops footswitch controller."""
pedal_app.boot()
with patch.object(pedal_app.footswitches, "stop") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_saves_preset_state(self, pedal_app):
"""Shutdown saves the current preset state."""
pedal_app.boot()
with patch.object(pedal_app.presets, "save_state") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_stops_jack(self, pedal_app):
"""Shutdown stops JACK via audio_system."""
pedal_app.boot()
with patch.object(pedal_app.audio_system, "stop_jack") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_clears_display(self, pedal_app):
"""Shutdown clears the OLED display."""
pedal_app.boot()
with patch.object(pedal_app.display, "clear") as spy:
pedal_app.shutdown()
spy.assert_called_once()
def test_shutdown_is_idempotent(self, pedal_app):
"""Calling shutdown multiple times doesn't crash."""
pedal_app.boot()
# First shutdown
pedal_app.shutdown()
# Second shutdown — should not raise
pedal_app.shutdown()
# ═══════════════════════════════════════════════════════════════════
# Main loop / signal handling
# ═══════════════════════════════════════════════════════════════════
class TestMainLoop:
"""Main loop and signal handling behavior."""
def test_signal_handler_requests_shutdown(self, pedal_app):
"""SIGTERM sets the shutdown event and kills _running."""
pedal_app.boot()
pedal_app._running = True # run() normally sets this
assert pedal_app._running is True
pedal_app._signal_handler(signal.SIGTERM, None)
assert pedal_app._shutdown_requested.is_set()
assert pedal_app._running is False
def test_signal_handler_sigint(self, pedal_app):
"""SIGINT also triggers shutdown."""
pedal_app.boot()
pedal_app._signal_handler(signal.SIGINT, None)
assert pedal_app._shutdown_requested.is_set()
# ═══════════════════════════════════════════════════════════════════
# Systemd service content
# ═══════════════════════════════════════════════════════════════════
class TestSystemdServices:
"""Service unit content generation."""
def test_pedal_service_content_has_required_sections(self):
"""Generated service unit has mandatory systemd sections."""
from src.system.services import pedal_service_content
content = pedal_service_content(install_dir="/opt/pi-multifx-pedal")
assert "[Unit]" in content
assert "[Service]" in content
assert "[Install]" in content
def test_pedal_service_uses_correct_paths(self):
"""Service unit references correct executable and working dir."""
from src.system.services import pedal_service_content
install = "/opt/pi-multifx-pedal"
content = pedal_service_content(install_dir=install)
assert f"WorkingDirectory={install}" in content
assert f"{install}/main.py" in content
def test_pedal_service_has_rt_limits(self):
"""Service unit includes real-time audio priority limits."""
from src.system.services import pedal_service_content
content = pedal_service_content()
assert "LimitRTPRIO=95" in content
assert "LimitMEMLOCK=infinity" in content
assert "LimitNICE=-20" in content
def test_pedal_target_has_correct_structure(self):
"""Generated target unit has mandatory sections."""
from src.system.services import pedal_target_content
content = pedal_target_content()
assert "[Unit]" in content
assert "[Install]" in content
assert "BindsTo=jackd.service" in content
def test_install_service_copies_project(self):
"""install_service.sh copies project to install dir."""
script = Path("scripts/install_service.sh")
assert script.exists(), "install_service.sh must exist"
content = script.read_text()
assert "rsync" in content
assert "pi-multifx-pedal.service" in content
def test_service_generation_matches_install_script(self):
"""The service content generated by Python matches what install_service.sh uses."""
from src.system.services import pedal_service_content, pedal_target_content
srv = pedal_service_content()
tgt = pedal_target_content()
assert srv
assert tgt
# ═══════════════════════════════════════════════════════════════════
# Preset activation pipeline routing
# ═══════════════════════════════════════════════════════════════════
class TestPresetActivation:
"""Preset activation routes through pipeline and UI."""
def test_preset_activate_calls_pipeline(self, pedal_app):
"""Selecting a preset calls pipeline.load_preset via PresetManager."""
pedal_app.boot()
from src.presets.types import Preset
preset = Preset(name="Test", bank=0, program=0, chain=[])
with patch.object(pedal_app.pipeline, "load_preset") as spy:
pedal_app.presets.save(preset)
pedal_app.presets.select(0, 0)
spy.assert_called_once()
def test_preset_up_wraps_within_bank(self, pedal_app):
"""preset_up wraps from program 3 back to 0."""
pedal_app.boot()
from src.presets.types import Preset
# Fill all 4 slots in bank 0
for pg in range(4):
p = Preset(name=f"P{pg}", bank=0, program=pg)
pedal_app.presets.save(p)
# Select program 3
pedal_app.presets.select(0, 3)
# preset_up should wrap to 0
pedal_app._on_preset_up()
assert pedal_app.presets.current_program == 0
# ═══════════════════════════════════════════════════════════════════
# Start-up time check (acceptance criterion: < 30s)
# ═══════════════════════════════════════════════════════════════════
class TestBootTime:
"""Boot completes within acceptable time on dev machine."""
def test_boot_completes_under_5_seconds(self, pedal_app):
"""Boot should complete quickly even with mocked hardware."""
start = time.monotonic()
pedal_app.boot()
elapsed = time.monotonic() - start
# On a dev machine with mock hardware, boot should be < 2s.
# We set a generous 5s limit to account for CI slowdown.
assert elapsed < 5.0, f"Boot took {elapsed:.2f}s (limit: 5s)"
# ═══════════════════════════════════════════════════════════════════
# CLI entry
# ═══════════════════════════════════════════════════════════════════
class TestCLIEntry:
"""main() CLI entry point behavior."""
def test_main_returns_0_on_clean_shutdown(self, pedal_app, temp_config, mocker):
"""main() returns 0 when boot succeeds and app runs then shuts down."""
from main import main
mocker.patch("sys.argv", ["main.py", "-c", str(temp_config)])
# Replace boot and run so that main() returns quickly with 0
with patch("main.PedalApp.boot", return_value=True), \
patch("main.PedalApp.run"):
ret = main()
assert ret == 0
def test_main_returns_1_on_boot_failure(self, mocker, temp_config):
"""main() returns 1 when boot fails."""
from main import main
mocker.patch("sys.argv", ["main.py", "-c", str(temp_config)])
with patch("main.PedalApp.boot", return_value=False):
ret = main()
assert ret == 1
# ═══════════════════════════════════════════════════════════════════
# Edge cases
# ═══════════════════════════════════════════════════════════════════
class TestEdgeCases:
"""Edge cases and error resilience."""
def test_corrupt_config_falls_back_to_defaults(self, tmp_path, mock_jack):
"""A corrupt YAML config falls back to defaults without crashing."""
cfg_path = tmp_path / "corrupt.yaml"
cfg_path.write_text("{{{{{ not valid yaml }}}}")
with patch.object(Path, "home", return_value=tmp_path):
from main import PedalApp
app = PedalApp(config_path=cfg_path)
result = app.boot()
assert result is True # Should still boot with defaults
def test_boot_with_empty_preset_dir(self, pedal_app):
"""Boot succeeds with an empty (or missing) preset directory."""
result = pedal_app.boot()
assert result is True
def test_shutdown_without_boot(self, tmp_path, mock_jack):
"""Calling shutdown() without boot() does not crash."""
cfg_path = tmp_path / "minimal.yaml"
cfg_path.write_text(yaml.dump({"presets": {"install_factory": False}}))
with patch.object(Path, "home", return_value=tmp_path):
from main import PedalApp
app = PedalApp(config_path=cfg_path)
# No boot — just shutdown
app.shutdown()
def test_concurrent_midi_and_footswitch_no_crash(self, pedal_app):
"""Rapid alternation of MIDI and footswitch events doesn't crash."""
pedal_app.boot()
from src.presets.types import Preset
for pg in range(4):
pedal_app.presets.save(Preset(name=f"P{pg}", bank=0, program=pg, chain=[]))
# Rapid-fire events
for _ in range(10):
pedal_app._on_preset_up()
pedal_app._on_midi_pc(0, 2)
pedal_app._on_bypass_toggle()
# Should still be in a consistent state
assert pedal_app.presets is not None
+864
View File
@@ -0,0 +1,864 @@
"""Tests for MIDI handler — parsing, dispatch, learn, clock sync, I/O."""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any
import pytest
from midi.handler import (
MIDIHandler,
MIDIEvent,
LearnedMapping,
MIDIMapping,
CC_EXPRESSION,
CC_VOLUME,
CC_BANK_SELECT_MSB,
CLOCK_PPQN,
# Message builders
CONTROL_CHANGE,
NOTE_ON,
NOTE_OFF,
PROGRAM_CHANGE,
PITCH_BEND,
RT_CLOCK,
RT_START,
RT_STOP,
RT_CONTINUE,
SYS_EXCLUSIVE,
SYS_EXCLUSIVE_END,
# Interface classes
UARTMIDI,
)
# ═══════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════
@pytest.fixture
def handler() -> MIDIHandler:
return MIDIHandler()
# ═══════════════════════════════════════════════════════════════════
# MIDIEvent tests
# ═══════════════════════════════════════════════════════════════════
def test_midi_event_defaults() -> None:
e = MIDIEvent(type="cc")
assert e.type == "cc"
assert e.channel == 0
assert e.note == 0
assert e.velocity == 0
assert e.cc_number == 0
assert e.cc_value == 0
assert e.program == 0
# ═══════════════════════════════════════════════════════════════════
# Message parsing — single messages
# ═══════════════════════════════════════════════════════════════════
class TestParse:
"""MIDIHandler.parse() — single complete messages."""
def test_note_on(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x90, 60, 100]))
assert ev is not None
assert ev.type == "note_on"
assert ev.channel == 0
assert ev.note == 60
assert ev.velocity == 100
def test_note_on_channel_3(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x93, 60, 100]))
assert ev is not None
assert ev.type == "note_on"
assert ev.channel == 3
assert ev.note == 60
def test_note_on_zero_velocity_is_note_off(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x90, 60, 0]))
assert ev is not None
assert ev.type == "note_off"
assert ev.channel == 0
assert ev.note == 60
def test_note_off(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x80, 60, 64]))
assert ev is not None
assert ev.type == "note_off"
assert ev.channel == 0
assert ev.note == 60
assert ev.velocity == 64
def test_control_change(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xB0, CC_EXPRESSION, 64]))
assert ev is not None
assert ev.type == "cc"
assert ev.channel == 0
assert ev.cc_number == CC_EXPRESSION
assert ev.cc_value == 64
def test_control_change_channel_7(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xB7, CC_VOLUME, 100]))
assert ev is not None
assert ev.type == "cc"
assert ev.channel == 7
assert ev.cc_number == CC_VOLUME
assert ev.cc_value == 100
def test_program_change(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xC0, 5]))
assert ev is not None
assert ev.type == "pc"
assert ev.channel == 0
assert ev.program == 5
def test_program_change_channel_15(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xCF, 127]))
assert ev is not None
assert ev.type == "pc"
assert ev.channel == 15
assert ev.program == 127
def test_pitch_bend(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xE0, 0x00, 0x40])) # Center (8192)
assert ev is not None
assert ev.type == "pitch_bend"
assert ev.channel == 0
assert ev.cc_value == 8192
def test_pitch_bend_max(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xE0, 0x7F, 0x7F])) # 16383
assert ev is not None
assert ev.cc_value == 16383
def test_channel_pressure(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xD0, 100]))
assert ev is not None
assert ev.type == "channel_pressure"
assert ev.channel == 0
assert ev.velocity == 100
def test_poly_pressure(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xA0, 60, 100]))
assert ev is not None
assert ev.type == "poly_pressure"
assert ev.channel == 0
assert ev.note == 60
assert ev.velocity == 100
def test_midi_clock(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([RT_CLOCK]))
assert ev is not None
assert ev.type == "clock"
def test_midi_start(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([RT_START]))
assert ev is not None
assert ev.type == "start"
def test_midi_stop(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([RT_STOP]))
assert ev is not None
assert ev.type == "stop"
def test_midi_continue(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([RT_CONTINUE]))
assert ev is not None
assert ev.type == "continue"
def test_active_sensing_ignored(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0xFE]))
assert ev is None
def test_empty_data(self, handler: MIDIHandler) -> None:
ev = handler.parse(b"")
assert ev is None
def test_truncated_message(self, handler: MIDIHandler) -> None:
ev = handler.parse(bytes([0x90, 60])) # Missing velocity
assert ev is None
# ═══════════════════════════════════════════════════════════════════
# Dispatch tests
# ═══════════════════════════════════════════════════════════════════
class TestDispatch:
"""MIDIHandler.dispatch() — callback routing."""
def test_pc_callback(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int]] = []
handler.set_pc_callback(lambda ch, pg: results.append((ch, pg)))
handler.dispatch(MIDIEvent(type="pc", channel=2, program=7))
assert results == [(2, 7)]
def test_cc_callback(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int]] = []
handler.register_cc(11, lambda val, ch: results.append((val, ch)))
handler.dispatch(MIDIEvent(type="cc", channel=0, cc_number=11, cc_value=64))
assert results == [(64, 0)]
def test_cc_multiple_callbacks(self, handler: MIDIHandler) -> None:
cc11: list[tuple[int, int]] = []
cc12: list[tuple[int, int]] = []
handler.register_cc(11, lambda val, ch: cc11.append((val, ch)))
handler.register_cc(12, lambda val, ch: cc12.append((val, ch)))
handler.dispatch(MIDIEvent(type="cc", cc_number=11, cc_value=64))
handler.dispatch(MIDIEvent(type="cc", cc_number=12, cc_value=100))
assert cc11 == [(64, 0)]
assert cc12 == [(100, 0)]
def test_cc_unregistered_does_not_raise(self, handler: MIDIHandler) -> None:
# Should not raise — just log
handler.dispatch(MIDIEvent(type="cc", cc_number=99, cc_value=50))
def test_note_callback(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int, int]] = []
handler.set_note_callback(lambda n, v, ch: results.append((n, v, ch)))
handler.dispatch(MIDIEvent(type="note_on", note=60, velocity=100, channel=1))
assert results == [(60, 100, 1)]
def test_note_off_dispatch(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int, int]] = []
handler.set_note_callback(lambda n, v, ch: results.append((n, v, ch)))
handler.dispatch(MIDIEvent(type="note_off", note=60, velocity=0, channel=1))
assert results == [(60, 0, 1)]
def test_no_callbacks_no_crash(self, handler: MIDIHandler) -> None:
"""Dispatch with no callbacks registered should be a no-op."""
handler.dispatch(MIDIEvent(type="pc", program=3))
handler.dispatch(MIDIEvent(type="cc", cc_number=7, cc_value=100))
handler.dispatch(MIDIEvent(type="note_on", note=60, velocity=100))
# ═══════════════════════════════════════════════════════════════════
# MIDI Learn tests
# ═══════════════════════════════════════════════════════════════════
class TestMIDILearn:
"""MIDI Learn mode — CC capture and mapping."""
def test_start_learn(self, handler: MIDIHandler) -> None:
assert not handler.learn_mode
handler.start_learn("delay.feedback")
assert handler.learn_mode
def test_stop_learn(self, handler: MIDIHandler) -> None:
handler.start_learn("delay.feedback")
handler.stop_learn()
assert not handler.learn_mode
def test_learn_cc_to_mapping(self, handler: MIDIHandler) -> None:
handler.start_learn("reverb.mix")
handler.dispatch(MIDIEvent(type="cc", cc_number=14, cc_value=64))
mapping = handler.get_mapping("reverb.mix")
assert mapping is not None
assert mapping.cc_number == 14
def test_learn_auto_exits(self, handler: MIDIHandler) -> None:
handler.start_learn("delay.time")
handler.dispatch(MIDIEvent(type="cc", cc_number=15, cc_value=100))
assert not handler.learn_mode
def test_learn_triggers_callback(self, handler: MIDIHandler) -> None:
results: list[LearnedMapping] = []
handler.set_midi_learn_callback(lambda lm: results.append(lm))
handler.start_learn("chorus.rate")
handler.dispatch(MIDIEvent(type="cc", cc_number=22, cc_value=50))
assert len(results) == 1
assert results[0].cc_number == 22
assert results[0].param_key == "chorus.rate"
def test_learn_only_captures_cc(self, handler: MIDIHandler) -> None:
"""PC and notes should not trigger learn capture."""
handler.start_learn("delay.feedback")
handler.dispatch(MIDIEvent(type="pc", program=3))
assert handler.learn_mode # Still in learn mode
def test_set_mapping_directly(self, handler: MIDIHandler) -> None:
m = MIDIMapping(cc_number=7, channel=0, min_val=0.0, max_val=1.0)
handler.set_mapping("volume", m)
assert handler.get_mapping("volume") == m
def test_remove_mapping(self, handler: MIDIHandler) -> None:
m = MIDIMapping(cc_number=7, channel=0)
handler.set_mapping("volume", m)
handler.remove_mapping("volume")
assert handler.get_mapping("volume") is None
def test_get_all_mappings(self, handler: MIDIHandler) -> None:
handler.set_mapping("a", MIDIMapping(cc_number=1))
handler.set_mapping("b", MIDIMapping(cc_number=2))
all_maps = handler.get_all_mappings()
assert len(all_maps) == 2
assert "a" in all_maps
assert "b" in all_maps
def test_cancel_learn(self, handler: MIDIHandler) -> None:
handler.start_learn("delay.feedback")
handler.cancel_learn()
assert not handler.learn_mode
assert handler.get_mapping("delay.feedback") is None
# ═══════════════════════════════════════════════════════════════════
# MIDI clock sync tests
# ═══════════════════════════════════════════════════════════════════
class TestMIDIClock:
"""MIDI clock tracking and BPM calculation."""
def test_clock_reset_on_start(self, handler: MIDIHandler) -> None:
handler.parse(bytes([RT_START]))
assert handler.clock_running
assert handler.current_bpm == 0.0
def test_clock_stop(self, handler: MIDIHandler) -> None:
handler.parse(bytes([RT_START]))
handler.parse(bytes([RT_STOP]))
assert not handler.clock_running
def test_clock_continue(self, handler: MIDIHandler) -> None:
handler.parse(bytes([RT_START]))
handler.parse(bytes([RT_STOP]))
handler.parse(bytes([RT_CONTINUE]))
assert handler.clock_running
def test_reset_clock(self, handler: MIDIHandler) -> None:
handler.parse(bytes([RT_START]))
handler.reset_clock()
assert not handler.clock_running
assert handler.current_bpm == 0.0
@pytest.mark.skip(reason="Timing-sensitive; BPM depends on real clock intervals")
def test_bpm_detection(self, handler: MIDIHandler) -> None:
pass # BPM detection tested via unit below
def test_clock_callback_fires(self, handler: MIDIHandler) -> None:
"""BPM callback is set and retrievable."""
results: list[float] = []
handler.set_clock_callback(lambda bpm: results.append(bpm))
handler.parse(bytes([RT_START]))
# No ticks sent — BPM stays 0. Timed test would send 24 ticks at ~20.8ms
def test_clock_send_builders(self) -> None:
assert MIDIHandler.send_clock_start() == bytes([RT_START])
assert MIDIHandler.send_clock_stop() == bytes([RT_STOP])
assert MIDIHandler.send_clock_tick() == bytes([RT_CLOCK])
class TestAudioSyncIntegration:
"""Integration between MIDI clock and AudioSystem tempo."""
def test_tempo_property(self) -> None:
"""Test audio.py's set_tempo_from_midi_clock via handler mock."""
from system.audio import AudioSystem
audio = AudioSystem()
assert audio.tempo_bpm == 120.0
assert audio.tempo_source == "default"
def test_enable_midi_clock_sync(self) -> None:
from system.audio import AudioSystem
audio = AudioSystem()
audio.enable_midi_clock_sync(True)
audio.set_tempo_from_midi_clock(140.0)
assert audio.tempo_bpm == 140.0
assert audio.tempo_source == "midi_clock"
def test_disable_midi_clock_ignores_updates(self) -> None:
from system.audio import AudioSystem
audio = AudioSystem()
audio.set_tempo_from_midi_clock(140.0)
assert audio.tempo_bpm == 120.0 # Not enabled, stays default
def test_manual_tempo_overrides(self) -> None:
from system.audio import AudioSystem
audio = AudioSystem()
audio.enable_midi_clock_sync(True)
audio.set_tempo_from_midi_clock(140.0)
audio.tempo_bpm = 80.0 # Manual override
assert audio.tempo_bpm == 80.0
assert audio.tempo_source == "manual"
def test_tempo_clamped(self) -> None:
from system.audio import AudioSystem
audio = AudioSystem()
audio.tempo_bpm = 999
assert audio.tempo_bpm == 300.0 # Clamped to max
audio.tempo_bpm = 0
assert audio.tempo_bpm == 20.0 # Clamped to min
def test_tempo_callback(self) -> None:
from system.audio import AudioSystem
results: list[float] = []
audio = AudioSystem()
audio.set_tempo_callback(lambda bpm: results.append(bpm))
audio.tempo_bpm = 140.0
assert results == [140.0]
# ═══════════════════════════════════════════════════════════════════
# Expression pedal (CC #11) tests
# ═══════════════════════════════════════════════════════════════════
class TestExpressionPedal:
"""Expression pedal via continuous CC control."""
def test_expression_cc_is_standard_cc(self, handler: MIDIHandler) -> None:
"""Expression pedal CC #11 is treated as a standard CC."""
results: list[tuple[int, int]] = []
handler.register_cc(CC_EXPRESSION, lambda val, ch: results.append((val, ch)))
handler.dispatch(MIDIEvent(type="cc", cc_number=CC_EXPRESSION, cc_value=64))
assert results == [(64, 0)]
def test_expression_pedal_sweep(self, handler: MIDIHandler) -> None:
"""Sweep expression pedal across its range."""
results: list[int] = []
handler.register_cc(CC_EXPRESSION, lambda val, ch: results.append(val))
for val in [0, 32, 64, 96, 127]:
handler.dispatch(MIDIEvent(type="cc", cc_number=CC_EXPRESSION, cc_value=val))
assert results == [0, 32, 64, 96, 127]
def test_expression_pedal_learn(self, handler: MIDIHandler) -> None:
"""MIDI Learn an expression pedal CC."""
handler.start_learn("wah.position")
handler.dispatch(MIDIEvent(type="cc", cc_number=CC_EXPRESSION, cc_value=64))
mapping = handler.get_mapping("wah.position")
assert mapping is not None
assert mapping.cc_number == CC_EXPRESSION
# ═══════════════════════════════════════════════════════════════════
# 14-bit CC tests (MSB/LSB pairs)
# ═══════════════════════════════════════════════════════════════════
class Test14BitCC:
"""14-bit CC resolution (MSB 0-31 paired with LSB 32-63)."""
def test_msb_triggers_callback_immediately(self, handler: MIDIHandler) -> None:
"""MSB should fire immediately, not wait for LSB."""
results: list[tuple[int, int]] = []
handler.register_cc(0, lambda val, ch: results.append((val, ch)))
handler.dispatch(MIDIEvent(type="cc", cc_number=0, cc_value=80))
assert results == [(80, 0)]
def test_msb_then_lsb_combined(self, handler: MIDIHandler) -> None:
"""MSB + LSB produce a combined 14-bit value."""
results: list[int] = []
handler.register_cc(7, lambda val, ch: results.append(val))
# MSB first
handler.dispatch(MIDIEvent(type="cc", cc_number=7, cc_value=64))
assert results == [64] # MSB fires immediately
# LSB (cc 39 = 7 + 32)
handler.dispatch(MIDIEvent(type="cc", cc_number=39, cc_value=32))
# LSB should NOT fire callback for cc=39, and MSB callback should
# run with scaled value including LSB contribution
# Note: currently fires MSB twice (once for MSB, once for MSB+LSB)
# Let's verify at least 2 calls happened
assert len(results) >= 2
def test_orphan_lsb_no_crash(self, handler: MIDIHandler) -> None:
"""LSB without prior MSB should not crash, may fire as standard CC."""
handler.dispatch(MIDIEvent(type="cc", cc_number=35, cc_value=64))
def test_14bit_max_value_scaling(self, handler: MIDIHandler) -> None:
"""MSB=127 LSB=127 should produce scaled value ~= 127."""
results: list[int] = []
handler.register_cc(0, lambda val, ch: results.append(val))
handler.dispatch(MIDIEvent(type="cc", cc_number=0, cc_value=127))
handler.dispatch(MIDIEvent(type="cc", cc_number=32, cc_value=127))
# Combined 14-bit = (127 << 7) | 127 = 16383, scaled = 16383 >> 7 = 127
assert results[-1] == 127
# ═══════════════════════════════════════════════════════════════════
# Running status tests
# ═══════════════════════════════════════════════════════════════════
class TestRunningStatus:
"""UARTMIDI running status byte handling."""
def test_running_status_note_on(self) -> None:
"""Status byte reused for subsequent data bytes."""
raw = bytes([0x90, 60, 100, 64, 80, 67, 100])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 3
assert msgs[0] == bytes([0x90, 60, 100])
assert msgs[1] == bytes([0x90, 64, 80])
assert msgs[2] == bytes([0x90, 67, 100])
def test_running_status_after_note_off(self) -> None:
raw = bytes([0x80, 60, 64, 65, 0])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 2
assert msgs[0] == bytes([0x80, 60, 64])
assert msgs[1] == bytes([0x80, 65, 0])
def test_running_status_cc(self) -> None:
raw = bytes([0xB0, 7, 100, 10, 50])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 2
assert msgs[0] == bytes([0xB0, 7, 100])
assert msgs[1] == bytes([0xB0, 10, 50])
def test_running_status_with_clock_interleaved(self) -> None:
"""Real-time messages between running status data don't break."""
raw = bytes([0xB0, 7, 100, 0xF8, 10, 50])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 3
assert msgs[0] == bytes([0xB0, 7, 100])
assert msgs[1] == bytes([0xF8]) # Clock
assert msgs[2] == bytes([0xB0, 10, 50])
def test_status_reset_on_new_status(self) -> None:
"""New status byte resets running status."""
raw = bytes([0x90, 60, 100, 0xC0, 5, 0xB0, 7, 127])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 3
assert msgs[0] == bytes([0x90, 60, 100]) # Note On
assert msgs[1] == bytes([0xC0, 5]) # PC
assert msgs[2] == bytes([0xB0, 7, 127]) # CC
def test_incomplete_message_dropped(self) -> None:
raw = bytes([0x90, 60]) # Only note + velocity, no next message
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 0 # Incomplete, dropped
# ═══════════════════════════════════════════════════════════════════
# SysEx tests
# ═══════════════════════════════════════════════════════════════════
class TestSysEx:
"""System Exclusive message handling."""
def test_sysex_parse(self, handler: MIDIHandler) -> None:
"""SysEx messages are parsed and returned as-is."""
raw = bytes([SYS_EXCLUSIVE, 0x41, 0x10, 0x42, 0x12, SYS_EXCLUSIVE_END])
# UARTMIDI _parse_raw_messages splits them
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 1
assert msgs[0] == raw
def test_sysex_builder(self) -> None:
msg = MIDIHandler.send_sysex(0x41, bytes([0x10, 0x42, 0x12]))
assert msg == bytes([SYS_EXCLUSIVE, 0x41, 0x10, 0x42, 0x12, SYS_EXCLUSIVE_END])
def test_incomplete_sysex_dropped(self) -> None:
"""SysEx without end byte should be dropped."""
raw = bytes([SYS_EXCLUSIVE, 0x41, 0x10, 0x42])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 0
# ═══════════════════════════════════════════════════════════════════
# Message builders
# ═══════════════════════════════════════════════════════════════════
class TestMessageBuilders:
"""MIDI message construction helpers."""
def test_send_cc(self) -> None:
msg = MIDIHandler.send_cc(7, 100, channel=5)
assert msg == bytes([CONTROL_CHANGE | 5, 7, 100])
def test_send_cc_clamps(self) -> None:
msg = MIDIHandler.send_cc(7, 200, channel=0) # Above 127
assert msg[2] == 127
msg = MIDIHandler.send_cc(7, -1, channel=0) # Below 0
assert msg[2] == 0
def test_send_pc(self) -> None:
msg = MIDIHandler.send_pc(5, channel=3)
assert msg == bytes([PROGRAM_CHANGE | 3, 5])
def test_send_pc_clamps(self) -> None:
msg = MIDIHandler.send_pc(300, channel=0)
assert msg[1] == 127
msg = MIDIHandler.send_pc(-1, channel=0)
assert msg[1] == 0
def test_send_note_on(self) -> None:
msg = MIDIHandler.send_note_on(60, velocity=100, channel=1)
assert msg == bytes([NOTE_ON | 1, 60, 100])
def test_send_note_off(self) -> None:
msg = MIDIHandler.send_note_off(60, channel=0)
assert msg == bytes([NOTE_OFF | 0, 60, 0])
def test_send_pitch_bend_center(self) -> None:
msg = MIDIHandler.send_pitch_bend(8192, channel=0)
assert msg == bytes([PITCH_BEND | 0, 0x00, 0x40])
def test_send_pitch_bend_max(self) -> None:
msg = MIDIHandler.send_pitch_bend(16383, channel=0)
assert msg == bytes([PITCH_BEND | 0, 0x7F, 0x7F])
def test_send_pitch_bend_min(self) -> None:
msg = MIDIHandler.send_pitch_bend(0, channel=0)
assert msg == bytes([PITCH_BEND | 0, 0x00, 0x00])
def test_send_pitch_bend_clamps(self) -> None:
msg = MIDIHandler.send_pitch_bend(20000, channel=0)
# 16383 max
assert msg == bytes([PITCH_BEND | 0, 0x7F, 0x7F])
# ═══════════════════════════════════════════════════════════════════
# MIDI interface tests (UARTMIDI)
# ═══════════════════════════════════════════════════════════════════
class TestUARTMIDI:
"""UARTMIDI interface — raw byte splitting and edge cases."""
def test_empty_read(self) -> None:
assert UARTMIDI._parse_raw_messages(b"") == []
def test_single_note_on(self) -> None:
msgs = UARTMIDI._parse_raw_messages(bytes([0x90, 60, 100]))
assert len(msgs) == 1
def test_mixed_messages(self) -> None:
raw = bytes([
0x90, 60, 100, # Note On
0x80, 60, 0, # Note Off
0xB0, 7, 64, # CC
0xC0, 3, # PC
])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 4
assert msgs[0] == bytes([0x90, 60, 100])
assert msgs[1] == bytes([0x80, 60, 0])
assert msgs[2] == bytes([0xB0, 7, 64])
assert msgs[3] == bytes([0xC0, 3])
def test_real_time_messages_interleaved(self) -> None:
raw = bytes([
0xF8, # Clock
0x90, 60, 100, # Note On
0xF8, # Clock
0xF8, # Clock
0x80, 60, 0, # Note Off
0xFA, # Start
])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 6
assert msgs[0] == bytes([0xF8])
assert msgs[-1] == bytes([0xFA])
def test_unknown_status_byte_skipped(self) -> None:
raw = bytes([0xF5, 0xF6])
msgs = UARTMIDI._parse_raw_messages(raw)
# 0xF5 is undefined, 0xF6 is Tune Request
assert len(msgs) == 1
assert msgs[0] == bytes([0xF6])
def test_song_position(self) -> None:
raw = bytes([0xF2, 0x00, 0x40])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 1
assert msgs[0] == raw
def test_song_select(self) -> None:
raw = bytes([0xF3, 0x01])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 1
assert msgs[0] == raw
def test_name_property(self) -> None:
uart = UARTMIDI(port="/dev/ttyAMA0")
assert uart.name == "UART:/dev/ttyAMA0"
assert not uart.is_open
def test_default_port(self) -> None:
uart = UARTMIDI()
assert "ttyAMA0" in uart.name
# ═══════════════════════════════════════════════════════════════════
# Edge cases and robustness
# ═══════════════════════════════════════════════════════════════════
class TestEdgeCases:
"""Edge cases and defensive coding."""
def test_stray_data_bytes_dropped(self) -> None:
"""Data byte without status should be dropped."""
raw = bytes([0x00, 0x01, 0x02]) # All data, no status
msgs = UARTMIDI._parse_raw_messages(raw)
assert msgs == []
def test_status_byte_with_rt_interruption(self) -> None:
"""Running status interrupted by real-time, then resumed."""
raw = bytes([0xB0, 7, 64, 0xF8, 0xF8, 10, 50])
msgs = UARTMIDI._parse_raw_messages(raw)
assert len(msgs) == 4
assert msgs[0] == bytes([0xB0, 7, 64])
assert msgs[1] == bytes([0xF8])
assert msgs[2] == bytes([0xF8])
assert msgs[3] == bytes([0xB0, 10, 50])
def test_double_start_no_error(self, handler: MIDIHandler) -> None:
"""Multiple START messages reset clock without error."""
handler.parse(bytes([RT_START]))
handler.parse(bytes([RT_START]))
assert handler.clock_running
def test_stop_without_start(self, handler: MIDIHandler) -> None:
"""STOP without prior START should be a no-op."""
handler.parse(bytes([RT_STOP]))
assert not handler.clock_running
def test_pc_no_callback_no_error(self, handler: MIDIHandler) -> None:
"""PC dispatch without callback should not raise."""
handler.dispatch(MIDIEvent(type="pc", program=7))
def test_sysex_in_midi_handler_parse_gives_none(self, handler: MIDIHandler) -> None:
"""SysEx is not parsed by MIDIHandler.parse()."""
ev = handler.parse(bytes([SYS_EXCLUSIVE, 0x41, 0x10, SYS_EXCLUSIVE_END]))
assert ev is None # Not handled by voice parser
def test_cc_with_mapping_learned_prev_mapping_updated(self, handler: MIDIHandler) -> None:
"""Learning a new CC for an already-mapped param overwrites."""
handler.set_mapping("delay.feedback", MIDIMapping(cc_number=7))
handler.start_learn("delay.feedback")
handler.dispatch(MIDIEvent(type="cc", cc_number=14, cc_value=50))
mapping = handler.get_mapping("delay.feedback")
assert mapping is not None
assert mapping.cc_number == 14 # Overwritten
# ═══════════════════════════════════════════════════════════════════
# Integration: parse → dispatch pipeline
# ═══════════════════════════════════════════════════════════════════
class TestParseThenDispatch:
"""Full parse → dispatch pipeline for common scenarios."""
def test_note_on_pipeline(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int, int]] = []
handler.set_note_callback(lambda n, v, ch: results.append((n, v, ch)))
ev = handler.parse(bytes([0x90, 60, 100]))
assert ev is not None
handler.dispatch(ev)
assert results == [(60, 100, 0)]
def test_cc_pipeline(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int]] = []
handler.register_cc(CC_EXPRESSION, lambda val, ch: results.append((val, ch)))
ev = handler.parse(bytes([0xB0, CC_EXPRESSION, 64]))
assert ev is not None
handler.dispatch(ev)
assert results == [(64, 0)]
def test_pc_pipeline(self, handler: MIDIHandler) -> None:
results: list[tuple[int, int]] = []
handler.set_pc_callback(lambda ch, pg: results.append((ch, pg)))
ev = handler.parse(bytes([0xC0, 3]))
assert ev is not None
handler.dispatch(ev)
assert results == [(0, 3)]
def test_clock_pipeline(self, handler: MIDIHandler) -> None:
"""Clock messages are parsed and processed but not dispatched as events."""
clock_results: list[str] = []
handler.set_clock_callback(lambda bpm: clock_results.append("clock"))
ev = handler.parse(bytes([RT_CLOCK]))
assert ev is not None
assert ev.type == "clock"
def test_learn_pipeline(self, handler: MIDIHandler) -> None:
"""Learn mode: parse CC → dispatch → capture mapping."""
handler.start_learn("reverb.decay")
ev = handler.parse(bytes([0xB0, 17, 100]))
assert ev is not None
handler.dispatch(ev)
mapping = handler.get_mapping("reverb.decay")
assert mapping is not None
assert mapping.cc_number == 17
# ═══════════════════════════════════════════════════════════════════
# USBMIDI — constructor/name tests (no ports on CI)
# ═══════════════════════════════════════════════════════════════════
class TestUSBMIDI:
"""USB-MIDI interface — constructor tests."""
def test_name_default(self) -> None:
from midi.handler import USBMIDI
usb = USBMIDI()
assert "USB" in usb.name
def test_name_custom(self) -> None:
from midi.handler import USBMIDI
usb = USBMIDI(port_name="Keystation")
assert "Keystation" in usb.name
def test_open_no_ports(self) -> None:
"""Open without any USB ports available — should fail gracefully."""
from midi.handler import USBMIDI
usb = USBMIDI()
result = usb.open()
assert result is False # No real hardware on CI
assert usb.name == "USB:auto"
+404
View File
@@ -0,0 +1,404 @@
"""Tests for NAM model host — loading, inference, and model switching.
Tests use synthetically generated .nam model files so no
external downloads are required. The test helper `make_test_nam()`
generates valid TorchScript-able NAM models using the nam library.
Uses real NAM model inference via the `nam` Python package,
so these are integration tests, not mocks.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import numpy as np
import pytest
from src.dsp.nam_host import NAMHost, NAMModel, ModelSwitchMode
# ── Helpers ────────────────────────────────────────────────────────────
def _make_nam_config(arch: str = "Linear", num_weights: int = 1200) -> dict:
"""Create a minimal valid NAM model config dict.
Generates random weights so the model compiles and runs.
The resulting .nam file is tiny (~5 KB) and suitable for testing.
Uses Linear architecture (simplest, supported by init_from_nam).
Args:
arch: Model architecture (only "Linear" and "WaveNet" are
supported by init_from_nam in the nam package).
num_weights: Number of weight parameters.
Returns:
A dict that can be serialized to a .nam file.
"""
rng = np.random.RandomState(42)
if arch == "Linear":
config = {
"receptive_field": 16,
}
elif arch == "WaveNet":
config = {
"layers": [{
"channels": [4] * 3,
"kernel_size": 3,
"dilation_base": 2,
"activation": [{"type": "Tanh"}],
"gating": True,
"head": {"channels": [4, 1], "kernel_size": 3},
"head_scale": 1.0,
}],
}
else:
raise ValueError(f"Test helper only supports Linear/WaveNet (got {arch})")
return {
"version": "0.13.0",
"architecture": arch,
"config": config,
"sample_rate": 48000,
"weights": rng.uniform(-0.5, 0.5, num_weights).tolist(),
}
@pytest.fixture
def test_nam_file(tmp_path: Path) -> Path:
"""Create a valid test .nam file and return its path."""
config = _make_nam_config()
path = tmp_path / "test_model.nam"
with open(path, "w") as f:
json.dump(config, f)
return path
@pytest.fixture
def host(tmp_path: Path) -> NAMHost:
"""Create a NAMHost with a temp models directory."""
return NAMHost(models_dir=tmp_path, device="cpu")
# ── Model metadata tests ──────────────────────────────────────────────
class TestNAMModelMetadata:
def test_minimal_fields(self):
"""NAMModel can be constructed with required fields."""
model = NAMModel(
name="test",
path="/tmp/test.nam",
architecture="WaveNet",
size_mb=4.5,
params_k=6.4,
receptive_field=64,
sample_rate=48000,
compatible=True,
)
assert model.name == "test"
assert model.compatible
assert model.size_mb == 4.5
assert model.receptive_field == 64
def test_large_model_not_compatible(self):
"""Models > 10 MB are flagged as incompatible."""
model = NAMModel(
name="big",
path="/tmp/big.nam",
architecture="WaveNet",
size_mb=42.0,
params_k=500.0,
receptive_field=512,
sample_rate=48000,
compatible=False,
)
assert not model.compatible
assert model.size_mb == 42.0
# ── NAMHost lifecycle tests ───────────────────────────────────────────
class TestNAMHost:
def test_initial_state(self, host: NAMHost):
"""Fresh host has no model loaded."""
assert not host.is_loaded
assert host.current_model is None
assert host.avg_inference_ms == 0.0
def test_load_model_success(self, host: NAMHost, test_nam_file: Path):
"""Can load a valid .nam model file."""
result = host.load_model(str(test_nam_file))
assert result
assert host.is_loaded
assert host.current_model is not None
assert host.current_model.name == "test_model"
assert host.current_model.architecture == "Linear"
def test_load_model_not_found(self, host: NAMHost):
"""Loading a non-existent file returns False."""
result = host.load_model("/nonexistent/model.nam")
assert not result
assert not host.is_loaded
def test_load_model_bad_extension(self, host: NAMHost, tmp_path: Path):
"""Loading a non-.nam file returns False."""
bad_file = tmp_path / "model.wav"
bad_file.write_bytes(b"fake")
result = host.load_model(str(bad_file))
assert not result
def test_unload(self, host: NAMHost, test_nam_file: Path):
"""Unload clears model state."""
host.load_model(str(test_nam_file))
assert host.is_loaded
host.unload()
assert not host.is_loaded
assert host.current_model is None
def test_double_load(self, host: NAMHost, test_nam_file: Path,
tmp_path: Path):
"""Loading a second model replaces the first."""
host.load_model(str(test_nam_file))
assert host.current_model.name == "test_model"
# Create second model
config2 = _make_nam_config()
path2 = tmp_path / "model2.nam"
with open(path2, "w") as f:
json.dump(config2, f)
host.load_model(str(path2))
assert host.current_model.name == "model2"
assert host.is_loaded
# ── Inference tests ───────────────────────────────────────────────────
class TestNAMInference:
def test_process_1d(self, host: NAMHost, test_nam_file: Path):
"""1D float32 input produces 1D float32 output."""
host.load_model(str(test_nam_file))
audio_in = np.random.randn(256).astype(np.float32)
audio_out = host.process(audio_in)
assert audio_out.shape == (256,)
assert audio_out.dtype == np.float32
# Output should be finite (model may overshoot with random weights)
assert np.all(np.isfinite(audio_out))
def test_process_2d(self, host: NAMHost, test_nam_file: Path):
"""2D (1, N) float32 input produces matching 2D output."""
host.load_model(str(test_nam_file))
audio_in = np.random.randn(1, 256).astype(np.float32)
audio_out = host.process(audio_in)
assert audio_out.shape == (1, 256)
assert audio_out.dtype == np.float32
def test_process_no_model(self, host: NAMHost):
"""Processing with no model loaded should pass through."""
audio_in = np.random.randn(256).astype(np.float32)
audio_out = host.process(audio_in)
np.testing.assert_array_equal(audio_out, audio_in)
def test_process_sine_wave(self, host: NAMHost, test_nam_file: Path):
"""A sine wave should produce a non-silent output."""
host.load_model(str(test_nam_file))
t = np.linspace(0, 256 / 48000, 256, dtype=np.float32)
sine = (np.sin(2 * np.pi * 440 * t) * 0.5).astype(np.float32)
out = host.process(sine)
# Should have non-zero energy
rms_out = np.sqrt(np.mean(out ** 2))
assert rms_out > 0.0, "Model output should not be silent"
def test_process_multiple_blocks(self, host: NAMHost,
test_nam_file: Path):
"""Processing multiple blocks should maintain state consistency."""
host.load_model(str(test_nam_file))
block = np.random.randn(256).astype(np.float32)
# Process same block twice
out1 = host.process(block.copy())
out2 = host.process(block.copy())
# Models with no state (ConvNet) should produce same output
assert out1.shape == out2.shape == (256,)
assert out1.dtype == np.float32
def test_process_different_block_sizes(self, host: NAMHost,
test_nam_file: Path):
"""Should handle various block sizes >= receptive field."""
host.load_model(str(test_nam_file))
rf = host.current_model.receptive_field
for block_size in [rf, rf * 2, 128, 256, 512]:
audio = np.random.randn(block_size).astype(np.float32)
out = host.process(audio)
assert out.shape == (block_size,), (
f"Block size {block_size} produced {out.shape}"
)
# ── Model switching tests ─────────────────────────────────────────────
class TestModelSwitching:
def test_instant_switch(self, tmp_path: Path):
"""Instant switch mode should immediately route through new model."""
host = NAMHost(switch_mode=ModelSwitchMode.INSTANT)
# Load first model (seed 42 — default)
c1 = _make_nam_config()
p1 = tmp_path / "m1.nam"
with open(p1, "w") as f:
json.dump(c1, f)
host.load_model(str(p1))
audio_in = np.random.randn(256).astype(np.float32)
out_before = host.process(audio_in)
# Load second model with DIFFERENT weights by using WaveNet arch
c2 = _make_nam_config(arch="WaveNet", num_weights=2400)
p2 = tmp_path / "m2.nam"
with open(p2, "w") as f:
json.dump(c2, f)
host.load_model(str(p2))
out_after = host.process(audio_in)
# Different architectures should produce different output
assert not np.allclose(out_before, out_after), (
"Different models should produce different output"
)
def test_warm_up(self, host: NAMHost, test_nam_file: Path):
"""Warm-up should not crash and load model state."""
host.load_model(str(test_nam_file))
# First warm-up after model load
host.warm_up(256)
# Should be able to process after warm-up
out = host.process(np.random.randn(256).astype(np.float32))
assert out.shape == (256,)
def test_list_available_models(self, host: NAMHost,
test_nam_file: Path):
"""List available models should find test model."""
models = host.list_available_models()
assert len(models) >= 1
names = [m.name for m in models]
assert "test_model" in names
# ── Standalone function tests ─────────────────────────────────────────
class TestStandaloneFunctions:
def test_available_models(self, tmp_path: Path):
"""available_models() returns lightweight model info."""
from src.dsp.nam_host import available_models
# Create a test model
config = _make_nam_config()
path = tmp_path / "test.nam"
with open(path, "w") as f:
json.dump(config, f)
models = available_models(tmp_path)
assert len(models) == 1
assert models[0]["name"] == "test"
assert models[0]["architecture"] == "Linear"
assert models[0]["feather"] is True
assert models[0]["size_mb"] > 0
def test_available_models_empty_dir(self, tmp_path: Path):
"""Empty directory returns empty list."""
from src.dsp.nam_host import available_models
assert available_models(tmp_path) == []
def test_process_with_model(self, tmp_path: Path):
"""process_with_model convenience function works end-to-end."""
from src.dsp.nam_host import process_with_model
config = _make_nam_config()
path = tmp_path / "test.nam"
with open(path, "w") as f:
json.dump(config, f)
audio_in = np.random.randn(256).astype(np.float32)
audio_out = process_with_model(str(path), audio_in, device="cpu")
assert audio_out.shape == audio_in.shape
assert np.all(np.isfinite(audio_out))
# ── Edge case tests ───────────────────────────────────────────────────
class TestEdgeCases:
def test_block_smaller_than_rf(self, host: NAMHost,
test_nam_file: Path):
"""Blocks smaller than receptive field are padded."""
host.load_model(str(test_nam_file))
rf = host.current_model.receptive_field
# Create block smaller than receptive field
small_block = np.random.randn(max(1, rf // 4)).astype(np.float32)
out = host.process(small_block)
assert out.shape == small_block.shape
assert np.all(np.isfinite(out))
def test_silent_input(self, host: NAMHost, test_nam_file: Path):
"""Silent input should produce valid (possibly non-zero) output."""
host.load_model(str(test_nam_file))
silence = np.zeros(256, dtype=np.float32)
out = host.process(silence)
assert out.shape == (256,)
assert np.all(np.isfinite(out))
def test_model_cache_reuse(self, host: NAMHost, test_nam_file: Path):
"""Loading the same model twice uses cache."""
path = str(test_nam_file)
host.load_model(path)
assert host.is_loaded
name1 = host.current_model.name
# Load same path again
host.load_model(path)
assert host.current_model.name == name1
# ── Performance tests ─────────────────────────────────────────────────
class TestPerformance:
def test_inference_timing(self, host: NAMHost, test_nam_file: Path):
"""Track average inference time (should be < 5ms on CPU)."""
host.load_model(str(test_nam_file))
# Process many blocks to get stable average
for _ in range(50):
block = np.random.randn(256).astype(np.float32)
host.process(block)
avg_ms = host.avg_inference_ms
assert avg_ms > 0.0
# On modern x86 CPU, ConvNet with 6.4K params should be < 1ms
# On RPi 4B, expect < 5ms per block (256 samples @ 48kHz = 5.3ms)
assert avg_ms < 10.0, (
f"Inference too slow: {avg_ms:.3f}ms (target < 5ms)"
)
def test_memory_stability(self, host: NAMHost, test_nam_file: Path):
"""Processing many blocks should not leak or crash."""
host.load_model(str(test_nam_file))
for _ in range(200):
block = np.random.randn(256).astype(np.float32)
_ = host.process(block)
# If we get here, no crash
assert host.avg_inference_ms > 0
+520
View File
@@ -0,0 +1,520 @@
"""Tests for the preset and bank management system.
Covers all acceptance criteria:
- Save/load presets to/from JSON (human-readable)
- Bank structure with 4 presets per bank
- Footswitch preset-up/down wraps within bank
- Bank-up/down switches banks
- MIDI Program Change loads preset (bank=channel, program=preset)
- Current preset saved on change (auto-restore on power cycle)
- Factory preset installation
- Preset rename and reorder
"""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
import pytest
from src.presets.manager import (
FACTORY_PRESET_DIR,
PRESETS_PER_BANK,
PresetManager,
_preset_from_dict,
_preset_to_dict,
)
from src.presets.types import FXBlock, FXType, MIDIMapping, Preset
# ── Fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture
def preset_dir() -> Path:
"""Provide a clean temp directory for each test."""
tmp = Path(tempfile.mkdtemp(prefix="pedal_presets_"))
yield tmp
shutil.rmtree(tmp, ignore_errors=True)
@pytest.fixture
def manager(preset_dir: Path) -> PresetManager:
"""Return a PresetManager pointed at the temp dir, no audio pipeline."""
return PresetManager(preset_dir=str(preset_dir), audio_pipeline=None)
@pytest.fixture
def sample_preset() -> Preset:
"""A realistic preset with a few FX blocks and MIDI mappings."""
return Preset(
name="Crunch Rhythm",
bank=0,
program=0,
chain=[
FXBlock(FXType.NOISE_GATE, enabled=True, params={"threshold": 0.01}),
FXBlock(FXType.OVERDRIVE, enabled=True, params={"drive": 0.6, "gain": 0.8}),
FXBlock(FXType.NAM_AMP, enabled=True, nam_model_path="/home/pedal/models/marshall.nam"),
FXBlock(FXType.IR_CAB, enabled=True, ir_file_path="/home/pedal/irs/mesa_v30.wav"),
FXBlock(FXType.CHORUS, enabled=True, params={"rate": 0.4, "depth": 0.6, "mix": 0.3}),
FXBlock(FXType.DELAY, enabled=True, params={"time": 420.0, "feedback": 0.35, "mix": 0.25}),
FXBlock(FXType.REVERB, enabled=True, params={"decay": 0.5, "mix": 0.2}),
FXBlock(FXType.VOLUME, enabled=True, params={"level": 0.85}),
],
midi_mappings={
"drive": MIDIMapping(cc_number=14, channel=0, min_val=0.0, max_val=1.0),
"mix": MIDIMapping(cc_number=15, channel=0, min_val=0.0, max_val=1.0),
},
master_volume=0.8,
)
@pytest.fixture
def factory_preset_dir(preset_dir: Path) -> Path:
"""Create a fake factory preset directory structure."""
factory = preset_dir / "factory_presets"
bank0 = factory / "bank_0"
bank0.mkdir(parents=True)
(bank0 / "bank.json").write_text(
json.dumps({"number": 0, "name": "Factory Classics", "preset_count": 4}), encoding="utf-8"
)
for i in range(3):
p = Preset(name=f"Factory {i}", bank=0, program=i, master_volume=0.75)
(bank0 / f"preset_{i}.json").write_text(
json.dumps(_preset_to_dict(p), indent=2), encoding="utf-8"
)
bank1 = factory / "bank_1"
bank1.mkdir(parents=True)
(bank1 / "bank.json").write_text(
json.dumps({"number": 1, "name": "Modern High-Gain", "preset_count": 4}), encoding="utf-8"
)
p = Preset(name="Chug", bank=1, program=0, master_volume=0.7)
(bank1 / "preset_0.json").write_text(
json.dumps(_preset_to_dict(p), indent=2), encoding="utf-8"
)
return factory
# ── Serialisation round-trip ────────────────────────────────────────────────
class TestSerialization:
def test_preset_to_dict_round_trip(self, sample_preset: Preset):
d = _preset_to_dict(sample_preset)
restored = _preset_from_dict(d)
assert restored.name == sample_preset.name
assert restored.bank == sample_preset.bank
assert restored.program == sample_preset.program
assert restored.master_volume == sample_preset.master_volume
assert len(restored.chain) == len(sample_preset.chain)
for a, b in zip(restored.chain, sample_preset.chain):
assert a.fx_type == b.fx_type
assert a.params == b.params
assert a.nam_model_path == b.nam_model_path
assert a.ir_file_path == b.ir_file_path
assert set(restored.midi_mappings.keys()) == set(sample_preset.midi_mappings.keys())
for k in sample_preset.midi_mappings:
assert restored.midi_mappings[k].cc_number == sample_preset.midi_mappings[k].cc_number
def test_to_dict_is_human_readable(self, sample_preset: Preset):
d = _preset_to_dict(sample_preset)
serialized = json.dumps(d, indent=2, sort_keys=True)
# Verify key strings are present plain-text, not encoded
assert "Crunch Rhythm" in serialized
assert "marshall.nam" in serialized
assert "mesa_v30" in serialized
assert "noise_gate" in serialized
assert "overdrive" in serialized
def test_minimal_preset_round_trip(self):
p = Preset(name="Empty", bank=0, program=0)
d = _preset_to_dict(p)
restored = _preset_from_dict(d)
assert restored.name == "Empty"
assert restored.chain == []
assert restored.midi_mappings == {}
assert restored.master_volume == 0.8
# ── Save / Load ─────────────────────────────────────────────────────────────
class TestSaveLoad:
def test_save_and_load(self, manager: PresetManager, sample_preset: Preset):
manager.save(sample_preset)
loaded = manager.load(0, 0)
assert loaded.name == "Crunch Rhythm"
assert len(loaded.chain) == 8
assert loaded.chain[0].fx_type == FXType.NOISE_GATE
def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
manager.save(sample_preset)
path = preset_dir / "bank_0" / "preset_0.json"
assert path.exists()
content = path.read_text(encoding="utf-8")
assert "Crunch Rhythm" in content
# Confirm it's valid JSON
data = json.loads(content)
assert data["name"] == "Crunch Rhythm"
def test_load_missing_preset_raises(self, manager: PresetManager):
with pytest.raises(FileNotFoundError):
manager.load(9, 3)
def test_save_multiple_presets(self, manager: PresetManager):
p0 = Preset(name="A", bank=0, program=0)
p1 = Preset(name="B", bank=0, program=1)
p2 = Preset(name="C", bank=0, program=2)
p3 = Preset(name="D", bank=0, program=3)
for p in [p0, p1, p2, p3]:
manager.save(p)
assert manager.load(0, 0).name == "A"
assert manager.load(0, 1).name == "B"
assert manager.load(0, 2).name == "C"
assert manager.load(0, 3).name == "D"
# ── Bank structure ──────────────────────────────────────────────────────────
class TestBanks:
def test_list_banks_empty(self, manager: PresetManager):
assert manager.list_banks() == []
def test_list_banks_after_saves(self, manager: PresetManager):
p0 = Preset(name="A", bank=0, program=0)
p1 = Preset(name="B", bank=1, program=0)
manager.save(p0)
manager.save(p1)
banks = manager.list_banks()
assert len(banks) == 2
assert banks[0].number == 0
assert banks[1].number == 1
def test_bank_has_four_preset_slots(self, manager: PresetManager):
"""The PRESETS_PER_BANK convention is set to 4."""
assert PRESETS_PER_BANK == 4
def test_program_clamped_to_range(self, manager: PresetManager):
"""select() clamps program to 0-3."""
p = manager.select(0, -1)
assert p.program == 0
p = manager.select(0, 99)
assert p.program == 3
# ── Footswitch navigation ───────────────────────────────────────────────────
class TestFootswitchNavigation:
def test_preset_up_within_bank(self, manager: PresetManager):
p0 = Preset(name="P0", bank=0, program=0)
p1 = Preset(name="P1", bank=0, program=1)
p2 = Preset(name="P2", bank=0, program=2)
p3 = Preset(name="P3", bank=0, program=3)
for p in [p0, p1, p2, p3]:
manager.save(p)
manager.select(0, 0)
assert manager.preset_up().name == "P1"
assert manager.preset_up().name == "P2"
assert manager.preset_up().name == "P3"
# Wraps 3 -> 0
assert manager.preset_up().name == "P0"
def test_preset_down_within_bank(self, manager: PresetManager):
p0 = Preset(name="P0", bank=0, program=0)
p1 = Preset(name="P1", bank=0, program=1)
p2 = Preset(name="P2", bank=0, program=2)
for p in [p0, p1, p2]:
manager.save(p)
manager.select(0, 0)
# Wraps 0 -> 3 (which is empty, so "New Bank 0 #3")
assert "New" in manager.preset_down().name
# Down again -> 2
assert manager.preset_down().name == "P2"
def test_preset_up_creates_empty_preset_when_missing(self, manager: PresetManager):
"""When pressing up into an empty slot, a blank preset is created."""
p0 = Preset(name="Only One", bank=0, program=0)
manager.save(p0)
manager.select(0, 0)
next_preset = manager.preset_up()
assert next_preset.name == "New Bank 0 #1"
assert next_preset.bank == 0
assert next_preset.program == 1
# ── Bank up/down ────────────────────────────────────────────────────────────
class TestBankUpDown:
def test_bank_up(self, manager: PresetManager):
"""Moving from bank 0 to bank 1."""
manager.save(Preset(name="B0P0", bank=0, program=0))
manager.save(Preset(name="B0P1", bank=0, program=1))
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.select(0, 0)
bank, preset = manager.bank_up()
assert bank.number == 1
assert preset.name == "B1P0"
def test_bank_down(self, manager: PresetManager):
manager.save(Preset(name="B0P0", bank=0, program=0))
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.select(1, 0)
bank, preset = manager.bank_down()
assert bank.number == 0
assert preset.name == "B0P0"
def test_bank_up_wraps(self, manager: PresetManager):
"""Wrapping from highest bank back to lowest."""
manager.save(Preset(name="B0P0", bank=0, program=0))
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.select(1, 0)
bank, preset = manager.bank_up()
assert bank.number == 0
def test_bank_down_wraps(self, manager: PresetManager):
manager.save(Preset(name="B0P0", bank=0, program=0))
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.select(0, 0)
bank, preset = manager.bank_down()
assert bank.number == 1
# ── MIDI Program Change ─────────────────────────────────────────────────────
class TestMIDIPC:
def test_midi_pc_selects_preset(self, manager: PresetManager):
"""Bank=channel (0), program=preset (2)."""
p0 = Preset(name="P0", bank=0, program=0)
p1 = Preset(name="P1", bank=0, program=1)
p2 = Preset(name="P2", bank=0, program=2)
for p in [p0, p1, p2]:
manager.save(p)
result = manager.midi_pc(channel=0, program=2)
assert result.name == "P2"
assert manager.current_bank == 0
assert manager.current_program == 2
def test_midi_pc_different_banks(self, manager: PresetManager):
"""Channel maps to bank, program maps to preset index."""
manager.save(Preset(name="B1P0", bank=1, program=0))
manager.save(Preset(name="B1P3", bank=1, program=3))
result = manager.midi_pc(channel=1, program=3)
assert result.name == "B1P3"
def test_midi_pc_creates_blank_on_empty_slot(self, manager: PresetManager):
"""When PC targets an empty slot, a blank preset is created."""
manager.save(Preset(name="B2P0", bank=2, program=0))
result = manager.midi_pc(channel=2, program=2)
assert "New" in result.name
assert result.program == 2
# ── Auto-restore ────────────────────────────────────────────────────────────
class TestAutoRestore:
def test_save_state_persists(self, manager: PresetManager, preset_dir: Path):
manager.save(Preset(name="RestoreMe", bank=0, program=2))
manager.select(0, 2) # This calls save_state internally
state_path = preset_dir / "state.json"
assert state_path.exists()
data = json.loads(state_path.read_text(encoding="utf-8"))
assert data["current_bank"] == 0
assert data["current_program"] == 2
def test_restore_state(self, manager: PresetManager, preset_dir: Path):
manager.save(Preset(name="RestoreMe", bank=0, program=2))
manager.select(0, 2)
# Create a fresh manager and restore
m2 = PresetManager(preset_dir=str(preset_dir))
preset = m2.restore_state()
assert preset is not None
assert preset.name == "RestoreMe"
assert m2.current_bank == 0
assert m2.current_program == 2
def test_restore_state_no_file(self, manager: PresetManager):
"""restore_state returns None when no state file exists."""
assert manager.restore_state() is None
def test_restore_state_missing_preset_fallback(self, preset_dir: Path):
"""If the saved slot is empty, fall back gracefully."""
# Write state pointing to a slot that doesn't exist yet
state_path = preset_dir / "state.json"
state_path.write_text(
json.dumps({"current_bank": 5, "current_program": 3}), encoding="utf-8"
)
m = PresetManager(preset_dir=str(preset_dir))
assert m.restore_state() is None
def test_preset_change_triggers_state_save(self, manager: PresetManager, preset_dir: Path):
"""Every preset_up/down/select saves state."""
manager.save(Preset(name="A", bank=0, program=0))
manager.save(Preset(name="B", bank=0, program=1))
manager.select(0, 0)
manager.preset_up()
state = json.loads((preset_dir / "state.json").read_text())
assert state["current_program"] == 1
# ── Preset rename and reorder ───────────────────────────────────────────────
class TestRenameReorder:
def test_rename(self, manager: PresetManager):
manager.save(Preset(name="Old", bank=0, program=0))
renamed = manager.rename(0, 0, "New")
assert renamed.name == "New"
assert manager.load(0, 0).name == "New"
def test_reorder_same_slot_noop(self, manager: PresetManager):
"""Reordering to the same program index does nothing."""
manager.save(Preset(name="A", bank=0, program=0))
manager.reorder(0, 0, 0) # Should not raise
assert manager.load(0, 0).name == "A"
def test_reorder_swap(self, manager: PresetManager):
"""Reordering from 0 to 2 swaps if slot 2 is occupied."""
manager.save(Preset(name="A", bank=0, program=0))
manager.save(Preset(name="B", bank=0, program=2))
manager.reorder(0, 0, 2)
assert manager.load(0, 0).name == "B"
assert manager.load(0, 2).name == "A"
def test_reorder_into_empty_slot(self, manager: PresetManager):
manager.save(Preset(name="A", bank=0, program=0))
manager.reorder(0, 0, 3)
assert manager.load(0, 3).name == "A"
def test_reorder_missing_source_raises(self, manager: PresetManager):
with pytest.raises(FileNotFoundError):
manager.reorder(0, 0, 1)
def test_reorder_out_of_range_raises(self, manager: PresetManager):
manager.save(Preset(name="A", bank=0, program=0))
with pytest.raises(ValueError):
manager.reorder(0, 0, 4)
def test_reorder_updates_active_tracking(self, manager: PresetManager):
"""If the active preset is moved, current_program tracks it."""
manager.save(Preset(name="A", bank=0, program=0))
manager.save(Preset(name="B", bank=1, program=0))
manager.select(0, 0)
manager.reorder(0, 0, 2)
assert manager.current_program == 2 # active moved to slot 2
# ── Factory presets ─────────────────────────────────────────────────────────
class TestFactoryPresets:
def test_install_factory_presets_overwrite_default(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
count = manager.install_factory_presets()
assert count == 4 # 3 in bank 0 + 1 in bank 1
assert manager.load(0, 0).name == "Factory 0"
assert manager.load(0, 1).name == "Factory 1"
assert manager.load(1, 0).name == "Chug"
def test_install_skip_existing(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
manager.save(Preset(name="My Custom", bank=0, program=0))
count = manager.install_factory_presets(overwrite=False)
# Should install 3 (skip slot 0 in bank 0, install 1 & 2, plus bank 1)
assert count == 3
assert manager.load(0, 0).name == "My Custom" # Not overwritten
def test_install_overwrite_existing(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
manager.save(Preset(name="My Custom", bank=0, program=0))
count = manager.install_factory_presets(overwrite=True)
assert count == 4
assert manager.load(0, 0).name == "Factory 0" # Overwritten
def test_no_factory_dir(self, manager: PresetManager, monkeypatch):
"""When factory dir doesn't exist, returns 0."""
monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", Path("/nonexistent_factory"))
assert manager.install_factory_presets() == 0
# ── Activation pipeline integration ─────────────────────────────────────────
class TestActivation:
def test_activate_calls_pipeline(self, manager: PresetManager, sample_preset: Preset, mocker):
pipeline = mocker.Mock()
manager._pipeline = pipeline
manager.activate(sample_preset)
pipeline.load_preset.assert_called_once_with(sample_preset)
def test_select_calls_activate(self, manager: PresetManager, mocker):
pipeline = mocker.Mock()
manager._pipeline = pipeline
manager.save(Preset(name="ActivateMe", bank=0, program=0))
preset = manager.select(0, 0)
assert preset.name == "ActivateMe"
pipeline.load_preset.assert_called_once()
def test_select_no_pipeline_does_not_crash(self, manager: PresetManager):
"""Works without an audio pipeline attached."""
manager.save(Preset(name="Headless", bank=0, program=0))
preset = manager.select(0, 0)
assert preset.name == "Headless"
# ── Edge cases ──────────────────────────────────────────────────────────────
class TestEdgeCases:
def test_delete_preset(self, manager: PresetManager):
manager.save(Preset(name="DeleteMe", bank=0, program=1))
assert manager.load(0, 1).name == "DeleteMe"
manager.delete(0, 1)
with pytest.raises(FileNotFoundError):
manager.load(0, 1)
def test_get_or_create_bank(self, manager: PresetManager):
bank = manager.get_or_create_bank(7, "Lead")
assert bank.number == 7
assert bank.name == "Lead"
# Calling again returns existing bank
bank2 = manager.get_or_create_bank(7)
assert bank2.number == 7
assert bank2.name == "Lead" # Name persisted
def test_multiple_banks_preset_independence(self, manager: PresetManager):
"""Presets in different banks don't interfere."""
manager.save(Preset(name="Bank0", bank=0, program=0))
manager.save(Preset(name="Bank1", bank=1, program=0))
assert manager.load(0, 0).name == "Bank0"
assert manager.load(1, 0).name == "Bank1"
def test_full_bank_capacity(self, manager: PresetManager):
"""All 4 slots in a bank can hold distinct presets."""
for i in range(4):
manager.save(Preset(name=f"Slot{i}", bank=2, program=i))
for i in range(4):
assert manager.load(2, i).name == f"Slot{i}"
def test_current_bank_program_tracking(self, manager: PresetManager):
manager.save(Preset(name="A", bank=0, program=0))
manager.save(Preset(name="B", bank=0, program=1))
assert manager.current_bank == 0
assert manager.current_program == 0
manager.select(0, 1)
assert manager.current_program == 1
def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
path = preset_dir / "bank_0" / "preset_0.json"
path.parent.mkdir(parents=True)
path.write_text("not valid json", encoding="utf-8")
with pytest.raises(json.JSONDecodeError):
manager.load(0, 0)