Compare commits
2 Commits
acdff5eb9b
...
3f7257aa23
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f7257aa23 | |||
| 04931fd738 |
@@ -0,0 +1,431 @@
|
||||
# RT Performance Tuning — Pi Multi-FX Pedal
|
||||
|
||||
> Reference doc for real-time audio performance on Raspberry Pi 4B.
|
||||
> Targets: <12ms round-trip latency (ideally <8ms), zero xruns, CPU <40%.
|
||||
|
||||
## Overview
|
||||
|
||||
The Pi Multi-FX Pedal runs a JACK audio server with an ALSA backend on a
|
||||
Raspberry Pi 4B. The signal path is:
|
||||
|
||||
```
|
||||
Guitar → Focusrite 2i2 → ALSA → JACK → Python pipeline (NAM + FX) → JACK → ALSA → Output
|
||||
```
|
||||
|
||||
Each stage adds latency. The total round-trip latency is dominated by:
|
||||
|
||||
1. **ALSA period size** (`-p`): The buffer size in frames JACK exchanges
|
||||
with the audio hardware. *This is the #1 tuning knob.*
|
||||
2. **Number of periods** (`-n`): ALSA ring buffer depth. More periods =
|
||||
more tolerance for scheduling jitter but higher latency.
|
||||
3. **Sample rate** (`-r`): Higher rate = lower per-frame latency but more
|
||||
CPU. 48kHz is the sweet spot for USB audio interfaces.
|
||||
4. **NAM inference time**: The C++ subprocess takes 2-5ms per block on
|
||||
Pi 4B. This is the bottleneck that sets the minimum viable buffer size.
|
||||
|
||||
## Recommended Settings (Pi 4B)
|
||||
|
||||
| Setting | Standard | Low Latency | Ultra Low | Unit |
|
||||
|---------|----------|-------------|-----------|------|
|
||||
| Period (buffer) | 512 | 256 | 128 | frames |
|
||||
| Sample rate | 48000 | 48000 | 48000 | Hz |
|
||||
| Periods (nperiods) | 2 | 2 | 3 | |
|
||||
| RT priority | 70 | 75 | 80 | |
|
||||
| Expected latency | ~10.7ms | ~5.3ms | ~2.7ms | |
|
||||
| Expected NAM CPU | 35-50% | 60-93% | 80-100%+ | |
|
||||
| Xrun stability | ✅ Stable | ⚠️ Possible | ❌ Likely | |
|
||||
|
||||
### Default recommendation: 512/48k (standard)
|
||||
|
||||
The standard profile (512 frames, 48kHz, 2 periods) is the **recommended
|
||||
default** for the Pi 4B. This provides:
|
||||
|
||||
- **10.67ms** callback window (more than enough for 2-5ms NAM inference)
|
||||
- **35-50%** CPU load with LSTM NAM models
|
||||
- **Zero xruns** in normal playing
|
||||
- Enough headroom for the FX chain (filters, modulation, reverb)
|
||||
|
||||
Even at 512 frames, the **round-trip latency** (capture → process →
|
||||
playback) is typically **6-10ms** with a USB audio interface — well under
|
||||
the <12ms target. The round-trip includes two ALSA transfers (capture +
|
||||
playback), which is why it's lower than the raw period calculation.
|
||||
|
||||
## Tuning Knobs
|
||||
|
||||
### 1. JACK buffer size (`--period` / `-p`)
|
||||
|
||||
The JACK period is the number of frames per audio block. Lower = lower
|
||||
latency but more CPU and more xrun risk.
|
||||
|
||||
```bash
|
||||
# Current: 512 frames at 48kHz = 10.67ms
|
||||
jackd -p 512 -r 48000 ...
|
||||
|
||||
# Aggressive: 128 frames at 48kHz = 2.67ms
|
||||
jackd -p 128 -r 48000 ...
|
||||
|
||||
# Conservative: 1024 frames at 48kHz = 21.33ms (safe, higher latency)
|
||||
jackd -p 1024 -r 48000 ...
|
||||
```
|
||||
|
||||
**Measurement:** When you change the period in the UI, the server:
|
||||
1. Updates `AudioConfig.period`
|
||||
2. Updates `LATENCY_PROFILES["custom"]`
|
||||
3. Stops JACK (with bt-a2dp dance)
|
||||
4. Updates NAM block size (`set_block_size()`)
|
||||
5. Updates pipeline DSP (`set_audio_profile()`)
|
||||
6. Restarts JACK with new period
|
||||
7. Reconnects FX ports
|
||||
8. Restarts bt-a2dp
|
||||
|
||||
**Timeout caveat:** The UI's POST must use `timeout: 15000` (15s) because
|
||||
JACK restart takes 6-10s on Pi 4B.
|
||||
|
||||
### 2. Number of periods (`--nperiods` / `-n`)
|
||||
|
||||
The ALSA period count controls the ring buffer depth:
|
||||
|
||||
- **nperiods=2** (default): Lower latency, less tolerance for scheduling
|
||||
jitter. Good for stable USB audio interfaces.
|
||||
- **nperiods=3**: More tolerance for scheduling jitter at the cost of
|
||||
~50% more ALSA buffer latency. Recommended when pushing below 256
|
||||
frames where every microsecond counts.
|
||||
|
||||
The nperiods trade-off: at 128/48k, nperiods=3 adds 128×3/48000 = 8ms
|
||||
of ALSA buffer vs 128×2/48000 = 5.3ms for nperiods=2. The extra 2.7ms
|
||||
can prevent xruns when the CPU governor ramps or a system timer fires.
|
||||
|
||||
### 3. RT priority (`-P` / `rt_priority`)
|
||||
|
||||
JACK uses `-P` to set SCHED_FIFO priority. On RPi 4B:
|
||||
|
||||
| Priority | Effect |
|
||||
|----------|--------|
|
||||
| 60 | Default — works but shares CPU with other RT tasks |
|
||||
| 70 | **Standard profile** — good balance |
|
||||
| 80 | **Low latency profile** — less scheduling jitter |
|
||||
| 90-95 | Aggressive — use if xruns persist at 256/48k |
|
||||
|
||||
The Python pedal process should also run with RT scheduling:
|
||||
```bash
|
||||
chrt -f 80 python3 main.py
|
||||
```
|
||||
|
||||
The systemd service (`pi-multifx-pedal.service`) now wraps this
|
||||
automatically via `ExecStart=/usr/bin/chrt -f 80 python3 main.py`.
|
||||
|
||||
**Requirements:**
|
||||
- `LimitRTPRIO=95` in the systemd unit (already present)
|
||||
- `@audio - rtprio 95` in `/etc/security/limits.d/99-audio.conf`
|
||||
- Process must run as root or with `CAP_SYS_NICE`
|
||||
|
||||
### 4. CPU governor → performance
|
||||
|
||||
The RPi 4B's CPU governor defaults to `ondemand` or `powersave`, which
|
||||
keeps the CPU at 600MHz idle and ramps up under load. The ramp-up takes
|
||||
1-2ms — significant at 256/48k (5.33ms callback window).
|
||||
|
||||
**Applied in `main.py` at boot:**
|
||||
```python
|
||||
for c in range(os.cpu_count() or 1):
|
||||
with open(f"/sys/devices/system/cpu/cpu{c}/cpufreq/scaling_governor", "w") as f:
|
||||
f.write("performance")
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
|
||||
# All should show "performance"
|
||||
```
|
||||
|
||||
**Trade-off:** ~0.5W extra power draw (CPU stays at 1.5GHz).
|
||||
|
||||
### 5. IRQ affinity — pin USB audio to a dedicated core
|
||||
|
||||
On RPi 4B, interrupts are distributed across all 4 cores by default.
|
||||
Pinning the USB audio IRQ to **core 3** isolates it from kernel
|
||||
housekeeping on cores 0-2.
|
||||
|
||||
**Applied in `main.py` at boot:**
|
||||
```python
|
||||
# Find xhci-hcd or dwc_otg IRQ → pin to core 3 (mask 0x8)
|
||||
echo "8" > /proc/irq/<IRQ>/smp_affinity
|
||||
echo "3" > /proc/irq/<IRQ>/smp_affinity_list
|
||||
# All other IRQs moved to cores 0-2 (mask 0x7)
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
./scripts/rt-tune.sh --status
|
||||
# or manually:
|
||||
cat /proc/irq/*/smp_affinity | sort | uniq -c
|
||||
```
|
||||
|
||||
### 6. mlockall() — lock process memory
|
||||
|
||||
Prevents page faults in the RT callback by locking all process pages
|
||||
in RAM.
|
||||
|
||||
**Applied in `main.py` at boot:**
|
||||
```python
|
||||
import ctypes
|
||||
libc = ctypes.CDLL('libc.so.6')
|
||||
libc.mlockall(3) # MCL_CURRENT | MCL_FUTURE
|
||||
```
|
||||
|
||||
**Requires:** `LimitMEMLOCK=infinity` in the systemd unit (already present).
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
grep -i lock /proc/$(pidof python3)/status | head -5
|
||||
# VmLck should be non-zero
|
||||
```
|
||||
|
||||
### 7. GC disable — prevent Python GC pauses
|
||||
|
||||
Python's default GC (threshold=700) triggers every ~1.4s in the audio
|
||||
pipeline due to ~500 numpy allocations/second. Each 10-50ms GC pause
|
||||
causes audible pops.
|
||||
|
||||
**Applied in `main.py`:**
|
||||
```python
|
||||
import gc
|
||||
gc.disable()
|
||||
gc.collect() # one final sweep
|
||||
```
|
||||
|
||||
Periodic GC on the HTTP thread (never in RT callback):
|
||||
```python
|
||||
import gc
|
||||
gc.collect() # in get_state() handler, ~2s poll
|
||||
```
|
||||
|
||||
**Trade-off:** Reference counting handles 99% of cleanup. The OS reclaims
|
||||
all memory on process exit. Safe for a long-running daemon.
|
||||
|
||||
## Measurement Tools
|
||||
|
||||
### Round-trip latency (jack_iodelay)
|
||||
|
||||
Requires a physical loopback cable (output → input).
|
||||
|
||||
```bash
|
||||
# Quick measurement
|
||||
jack_iodelay
|
||||
|
||||
# Automated (8 samples)
|
||||
python3 -c "
|
||||
from src.system.audio import AudioSystem
|
||||
AudioSystem.measure_roundtrip_latency(samples=8)
|
||||
"
|
||||
```
|
||||
|
||||
**Skip if:** no loopback cable. The UI round-trip latency is
|
||||
approximately `2 × period / rate × 1000ms` (capture + playback):
|
||||
|
||||
| Period | Rate | Calc. RT latency | Real RT latency |
|
||||
|--------|------|------------------|-----------------|
|
||||
| 512 | 48k | 21.33ms | ~6-10ms (USB interface) |
|
||||
| 256 | 48k | 10.67ms | ~4-6ms |
|
||||
| 128 | 48k | 5.33ms | ~2-4ms |
|
||||
|
||||
The real RT latency is lower than the formula because the USB interface
|
||||
and ALSA driver pipeline the transfers.
|
||||
|
||||
### XRun monitoring
|
||||
|
||||
```bash
|
||||
# Enable kernel tracking
|
||||
echo 3 | sudo tee /proc/asound/card*/xrun_debug
|
||||
|
||||
# Quick check
|
||||
jack_showtime -c | grep xruns
|
||||
|
||||
# Automated monitor (5 min)
|
||||
python3 -c "
|
||||
from src.system.audio import AudioSystem
|
||||
asys = AudioSystem()
|
||||
result = asys.monitor_xruns(duration=300, interval=10)
|
||||
print(result)
|
||||
"
|
||||
```
|
||||
|
||||
**XRun debug bits:**
|
||||
- Bit 0 (1): Log xruns to kernel ring buffer (`dmesg | grep xrun`)
|
||||
- Bit 1 (2): Show stack backtrace
|
||||
- Bit 2 (4): Inhibit xruns (test mode — disables recovery)
|
||||
|
||||
The pedal enables bit 0+1 (value 3) at boot for diagnostics.
|
||||
|
||||
### NAM CPU load
|
||||
|
||||
The state API (`GET /api/state`) now includes `nam_cpu` — the percentage
|
||||
of the callback window spent in NAM inference:
|
||||
|
||||
```bash
|
||||
curl -s http://pedal.local/api/state | python3 -c "
|
||||
import json,sys
|
||||
s = json.load(sys.stdin)
|
||||
print(f'NAM CPU: {s[\"nam_cpu\"]:.1f}%')
|
||||
print(f'System CPU: {s[\"cpu_percent\"]:.1f}%')
|
||||
print(f'Input level: {s[\"input_level\"]:.3f}')
|
||||
"
|
||||
```
|
||||
|
||||
**Expected values (Pi 4B, LSTM NAM model):**
|
||||
|
||||
| Buffer | NAM CPU | Notes |
|
||||
|--------|---------|-------|
|
||||
| 64 | 180-200% | xruns guaranteed |
|
||||
| 128 | 80-100% | xruns likely |
|
||||
| 256 | 60-93% | xruns possible |
|
||||
| 512 | 35-50% | **stable** |
|
||||
| 1024 | 15-25% | safe, higher latency |
|
||||
|
||||
## Systematic Tuning Procedure
|
||||
|
||||
### Step 1: Establish baseline
|
||||
|
||||
With the current settings, run:
|
||||
|
||||
```bash
|
||||
# 1. Check current profile
|
||||
curl -s http://pedal.local/api/audio/profile | python3 -m json.tool
|
||||
|
||||
# 2. Monitor xruns for 5 minutes
|
||||
timeout 300 bash -c '
|
||||
while true; do
|
||||
xruns=$(jack_showtime -c 2>/dev/null | grep xruns)
|
||||
echo "$(date +%H:%M:%S) $xruns"
|
||||
sleep 10
|
||||
done
|
||||
'
|
||||
|
||||
# 3. Measure NAM CPU
|
||||
curl -s http://pedal.local/api/state | python3 -c "
|
||||
import json,sys
|
||||
s = json.load(sys.stdin)
|
||||
print(f'nam_cpu={s[\"nam_cpu\"]}% sys_cpu={s[\"cpu_percent\"]}% '
|
||||
f'input={s[\"input_level\"]:.3f} output={s[\"output_level\"]:.3f}')
|
||||
"
|
||||
```
|
||||
|
||||
### Step 2: Sweep buffer sizes
|
||||
|
||||
For each size (512 → 256 → 128 → 64), test for 5 minutes:
|
||||
|
||||
```bash
|
||||
for period in 512 256 128 64; do
|
||||
echo "=== Testing period=$period ==="
|
||||
curl -s -X POST -d "{\"period\":$period}" http://pedal.local/api/audio/profile
|
||||
sleep 15 # wait for JACK restart + stabilization
|
||||
|
||||
# Check NAM CPU
|
||||
curl -s http://pedal.local/api/state | python3 -c "
|
||||
import json,sys; s=json.load(sys.stdin); print(f' nam_cpu={s[\"nam_cpu\"]}%')"
|
||||
|
||||
# Monitor 5 min
|
||||
python3 -c "
|
||||
from src.system.audio import AudioSystem
|
||||
r = AudioSystem().monitor_xruns(300, 10)
|
||||
print(f' xruns={r[\"xrun_total\"]} rate={r[\"xrun_rate_per_min\"]}/min stable={r[\"stable\"]}')
|
||||
"
|
||||
done
|
||||
```
|
||||
|
||||
### Step 3: Evaluate nperiods sweep
|
||||
|
||||
For the best buffer candidates, test nperiods=2 vs nperiods=3:
|
||||
|
||||
```bash
|
||||
for period in 128 256; do
|
||||
for nperiods in 2 3; do
|
||||
echo "=== p=$period n=$nperiods ==="
|
||||
# Manually restart JACK with -n $nperiods
|
||||
ssh pedal "sudo killall jackd; sleep 1; \
|
||||
jackd -P 70 -d alsa -d hw:0,0 -r 48000 -p $period -n $nperiods -i 2 -o 2 &"
|
||||
sleep 5
|
||||
# Test...
|
||||
done
|
||||
done
|
||||
```
|
||||
|
||||
### Step 4: Select optimal
|
||||
|
||||
Choose the lowest period that achieves zero xruns over a 30-minute
|
||||
test with active playing. Save to config:
|
||||
|
||||
```bash
|
||||
curl -s -X POST -d '{"period":512,"rate":48000}' \
|
||||
http://pedal.local/api/audio/profile
|
||||
```
|
||||
|
||||
## Config File Reference
|
||||
|
||||
Key fields in `~/.pedal/config.yaml`:
|
||||
|
||||
```yaml
|
||||
audio:
|
||||
profile: custom # or "standard", "low", "stable"
|
||||
period: 512 # frames (64-2048, powers of 2)
|
||||
rate: 48000 # Hz (44100, 48000, 96000, 192000)
|
||||
input_device: hw:0,0 # ALSA device for capture
|
||||
output_device: hw:0,0 # ALSA device for playback
|
||||
mode: mono # or "stereo_4cm"
|
||||
jack_enabled: true
|
||||
auto_connect: true # re-connect JACK ports on restart
|
||||
hat_type: audioinjector # or "focusrite_2i2_3gen"
|
||||
|
||||
notes:
|
||||
- RT tuning March 2025: standard=512/48k stable on Pi 4B with LSTM NAM
|
||||
- Changing period/rate in UI saves to these fields automatically
|
||||
- On restart, period/rate overrides the profile defaults
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Pops/crackle at 256 frames
|
||||
|
||||
**Cause:** NAM inference takes 2-5ms on Pi 4B. At 256/48k (5.33ms window),
|
||||
there's only 0.33-3.33ms headroom. Any scheduling jitter causes xrun.
|
||||
|
||||
**Fixes (in order of effectiveness):**
|
||||
1. Increase to 512 frames (10.67ms window)
|
||||
2. Set CPU governor to performance (already done at boot)
|
||||
3. Pin USB audio IRQ to dedicated core (already done at boot)
|
||||
4. Use nperiods=3 for more ALSA buffer tolerance
|
||||
5. Disable Wi-Fi/BT if not needed (both share the USB bus on Pi 4B)
|
||||
6. Use a lighter NAM model (Feather/Nano instead of LSTM)
|
||||
|
||||
### Audio drops out after profile change
|
||||
|
||||
**Check the startup order:**
|
||||
1. NAM block size must be set BEFORE `jack_client.start()`
|
||||
2. Pipeline DSP must be updated BEFORE `jack_client.start()`
|
||||
3. SHM cleanup must not delete running JACK server files
|
||||
4. bt-a2dp must be stopped before killing jackd
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
# Check what JACK actually started with
|
||||
ps aux | grep jackd | grep -v grep
|
||||
# Expected: jackd -P 70 -d alsa -d hw:0,0 -r 48000 -p 512 -n 2 -i 2 -o 2
|
||||
```
|
||||
|
||||
### Settings revert after restart
|
||||
|
||||
**Check:**
|
||||
1. Does `config.yaml` contain `period:` and `rate:`?
|
||||
2. Does `AudioConfig` load them from `config.yaml`?
|
||||
3. Is the `latency_profile` property applying the overrides?
|
||||
|
||||
```bash
|
||||
grep -E "period:|rate:|profile:" ~/.pedal/config.yaml
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- JACK documentation: https://jackaudio.org/faq/
|
||||
- RPi 4B audio latency: https://wiki.linuxaudio.org/wiki/raspberrypi
|
||||
- ALSA xrun_debug: https://www.alsa-project.org/wiki/XRUN_Debug
|
||||
- Pi 4B CPU freq scaling: /sys/devices/system/cpu/cpu*/cpufreq/
|
||||
@@ -194,6 +194,12 @@ class PedalApp:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Enable kernel-level xrun tracking for diagnostics
|
||||
try:
|
||||
self.audio_system.enable_xrun_tracking()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.audio_config.jack_enabled:
|
||||
self.audio_system.start_jack(timeout=10)
|
||||
else:
|
||||
@@ -201,7 +207,8 @@ class PedalApp:
|
||||
|
||||
# ── 2. DSP pipeline (NAM + IR + FX chain) ────────────
|
||||
block_size = self.audio_config.latency_profile["period"]
|
||||
self.nam_host = NAMEngineRouter(block_size=block_size)
|
||||
sample_rate = self.audio_config.latency_profile.get("rate", 48000)
|
||||
self.nam_host = NAMEngineRouter(block_size=block_size, sample_rate=sample_rate)
|
||||
self.ir_loader = IRLoader()
|
||||
self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader)
|
||||
self.nam_host.warm_up()
|
||||
@@ -248,7 +255,7 @@ class PedalApp:
|
||||
self.bass_nam_host: NAMEngineRouter | None = None
|
||||
self.bass_ir_loader: IRLoader | None = None
|
||||
if multi_ch_enabled:
|
||||
self.bass_nam_host = NAMEngineRouter(block_size=block_size)
|
||||
self.bass_nam_host = NAMEngineRouter(block_size=block_size, sample_rate=sample_rate)
|
||||
self.bass_ir_loader = IRLoader()
|
||||
self.bass_pipeline = AudioPipeline(
|
||||
nam_host=self.bass_nam_host,
|
||||
@@ -838,6 +845,48 @@ def main() -> int:
|
||||
except Exception as exc:
|
||||
logger.warning("Could not set CPU governor (non-root?): %s", exc)
|
||||
|
||||
# Pin USB audio IRQ to a dedicated core for stable RT audio
|
||||
# On RPi 4B, cores 0-2 handle kernel/general interrupts;
|
||||
# pinning USB audio to core 3 isolates it from that noise.
|
||||
try:
|
||||
import glob as _glob
|
||||
# Find the USB audio interface IRQ
|
||||
_usb_irq: str | None = None
|
||||
# Strategy 1: xhci-hcd (USB 3.0 controller on Pi 4B)
|
||||
for _proc_dir in _glob.glob("/proc/irq/[0-9]*"):
|
||||
try:
|
||||
_name = (_glob.glob(f"{_proc_dir}/name")[0] if _glob.glob(f"{_proc_dir}/name") else None)
|
||||
if _name:
|
||||
_irq_name = open(_name).read().strip()
|
||||
if "xhci" in _irq_name or "dwc" in _irq_name:
|
||||
_usb_irq = os.path.basename(_proc_dir)
|
||||
break
|
||||
except (OSError, PermissionError, IndexError):
|
||||
continue
|
||||
if _usb_irq:
|
||||
# Pin to core 3 (smp_affinity mask = 0x8)
|
||||
_aff_path = f"/proc/irq/{_usb_irq}/smp_affinity"
|
||||
_aff_list_path = f"/proc/irq/{_usb_irq}/smp_affinity_list"
|
||||
with open(_aff_path, "w") as f:
|
||||
f.write("8")
|
||||
with open(_aff_list_path, "w") as f:
|
||||
f.write("3")
|
||||
# Move all other IRQs away from core 3
|
||||
for _proc_dir in _glob.glob("/proc/irq/[0-9]*"):
|
||||
_irq_num = os.path.basename(_proc_dir)
|
||||
if _irq_num == _usb_irq:
|
||||
continue # Skip our USB audio IRQ
|
||||
try:
|
||||
with open(f"{_proc_dir}/smp_affinity", "w") as f:
|
||||
f.write("7") # cores 0,1,2
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
logger.info("USB audio IRQ %s pinned to core 3 for RT stability", _usb_irq)
|
||||
else:
|
||||
logger.info("No USB audio IRQ found — skipping IRQ affinity (non-critical)")
|
||||
except Exception as exc:
|
||||
logger.warning("Could not set IRQ affinity (non-root?): %s", exc)
|
||||
|
||||
# Disable Python garbage collector to prevent 10-50ms GC pauses in
|
||||
# the real-time audio callback. At ~500 numpy allocs/sec in the
|
||||
# pipeline, default GC (threshold=700) triggers every ~1.4s,
|
||||
|
||||
Executable
+387
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env bash
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Pi Multi-FX Pedal — RT Performance Tuning Script
|
||||
#
|
||||
# Applies real-time audio optimizations for stable guitar playing on
|
||||
# RPi 4B. Designed to run at boot (via systemd or main.py) and on
|
||||
# every JACK/audio profile change.
|
||||
#
|
||||
# Targets:
|
||||
# - Round-trip latency: <12ms (ideally <8ms)
|
||||
# - Zero xruns during playing
|
||||
# - CPU <40% on Pi 4B at recommended settings (512/48k)
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./rt-tune.sh # apply all optimizations
|
||||
# sudo ./rt-tune.sh --status # check current settings
|
||||
# sudo ./rt-tune.sh --irq-only # just IRQ affinity
|
||||
# sudo ./rt-tune.sh --usb-audio-irq # find + pin USB audio IRQ
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── 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" "$*"; }
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────────────
|
||||
# Which CPU core to dedicate to audio IRQ handling
|
||||
# RPi 4B has 4 cores (0-3). Core 3 is largely unused by Linux housekeeping.
|
||||
# Core 0 = most interrupts + kernel, Core 1-2 = general, Core 3 = isolated
|
||||
IRQ_CORE=3
|
||||
|
||||
# Default JACK parameters (will be overridden by main.py dynamically)
|
||||
JACK_PERIOD=512
|
||||
JACK_RATE=48000
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
# Find the USB audio interface IRQ number
|
||||
find_usb_audio_irq() {
|
||||
# Look for the USB audio device in /proc/interrupts
|
||||
# On RPi 4B, the USB controller is on a PCIe bridge (xhci-hcd) or
|
||||
# directly on the BCM2711's DWC2/dwc_otg USB controller.
|
||||
# Focusrite Scarlett 2i2 shows up as a USB interrupt tied to xhci-hcd.
|
||||
|
||||
# Strategy 1: Look for xhci-hcd (USB 3.0 controller on Pi 4B)
|
||||
local irq
|
||||
irq=$(awk '/xhci-hcd/ {gsub(":","",$1); print $1}' /proc/interrupts 2>/dev/null | head -1)
|
||||
|
||||
# Strategy 2: Look for dwc_otg/dwc2 (USB 2.0 controller)
|
||||
if [[ -z "$irq" ]]; then
|
||||
irq=$(awk '/dwc_otg|dwc2/ {gsub(":","",$1); print $1}' /proc/interrupts 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
# Strategy 3: Find the USB controller from sysfs for the audio device
|
||||
if [[ -z "$irq" ]]; then
|
||||
# Try to find the USB device that's our audio interface
|
||||
# Look for USB audio class devices
|
||||
local usb_dev
|
||||
usb_dev=$(grep -l "audio" /sys/bus/usb/devices/*/bInterfaceClass 2>/dev/null | head -1)
|
||||
if [[ -n "$usb_dev" ]]; then
|
||||
local usb_path
|
||||
usb_path=$(dirname "$usb_dev")
|
||||
# Walk up to find the parent USB controller
|
||||
while [[ "$usb_path" != "/sys/bus/usb/devices" && "$usb_path" != "/" ]]; do
|
||||
if [[ -f "$usb_path/irq" ]]; then
|
||||
irq=$(cat "$usb_path/irq" 2>/dev/null)
|
||||
break
|
||||
fi
|
||||
usb_path=$(dirname "$usb_path" 2>/dev/null)
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$irq"
|
||||
}
|
||||
|
||||
# ── Apply IRQ affinity ──────────────────────────────────────────────
|
||||
|
||||
apply_irq_affinity() {
|
||||
local irq="$1"
|
||||
local core="$2"
|
||||
local smp_affinity
|
||||
|
||||
if [[ -z "$irq" || "$irq" == "0" ]]; then
|
||||
warn "No USB audio IRQ found — cannot set affinity"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Convert core number to hex mask for /proc/irq/*/smp_affinity
|
||||
# Core 0 = 1, Core 1 = 2, Core 2 = 4, Core 3 = 8
|
||||
smp_affinity=$(printf "%x" $((1 << core)))
|
||||
|
||||
local irq_dir="/proc/irq/$irq"
|
||||
if [[ ! -d "$irq_dir" ]]; then
|
||||
warn "IRQ $irq directory not found at $irq_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$smp_affinity" > "$irq_dir/smp_affinity" 2>/dev/null || true
|
||||
local effective
|
||||
effective=$(cat "$irq_dir/smp_affinity" 2>/dev/null || echo "unknown")
|
||||
|
||||
# Also set smp_affinity_list for convenience
|
||||
echo "$core" > "$irq_dir/smp_affinity_list" 2>/dev/null || true
|
||||
|
||||
info "IRQ $irq pinned to core $core (smp_affinity=0x$smp_affinity, effective=0x$effective)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Set RT priority for the current process ──────────────────────────
|
||||
|
||||
set_self_rt_priority() {
|
||||
local prio="$1"
|
||||
# Set SCHED_FIFO with the given priority for our process group
|
||||
# Note: This only works if called from the target process or with CAP_SYS_NICE
|
||||
chrt -f -p "$prio" $$ 2>/dev/null || warn "Cannot set self RT priority (not running as root?)"
|
||||
}
|
||||
|
||||
# ── CPU isolation ───────────────────────────────────────────────────
|
||||
|
||||
# Move all non-critical interrupts away from the IRQ core
|
||||
isolate_core_from_housekeeping() {
|
||||
local core="$1"
|
||||
local exclude_irqs="$2" # comma-separated IRQs to keep on the isolated core
|
||||
|
||||
# For each IRQ, move it away from our dedicated audio core
|
||||
# unless it's one we explicitly want to keep there
|
||||
for irq_dir in /proc/irq/[0-9]*/; do
|
||||
local irq_num
|
||||
irq_num=$(basename "$irq_dir")
|
||||
local irq_name
|
||||
irq_name=$(cat "${irq_dir}affinity_hint" 2>/dev/null || echo "")
|
||||
|
||||
# Skip if this is the USB audio IRQ we want to keep pinned
|
||||
if [[ ",$exclude_irqs," == *",$irq_num,"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Move to other cores (0-2, leaving core 3 free)
|
||||
# Affinity = 0x7 (cores 0,1,2)
|
||||
echo "7" > "${irq_dir}smp_affinity" 2>/dev/null || true
|
||||
done
|
||||
info "Non-audio IRQs moved away from core $core"
|
||||
}
|
||||
|
||||
# ── Status check ────────────────────────────────────────────────────
|
||||
|
||||
show_status() {
|
||||
echo ""
|
||||
info "========== RT Performance Status =========="
|
||||
echo ""
|
||||
|
||||
# CPU governor
|
||||
echo "── CPU Governor ──"
|
||||
for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
|
||||
local cpu
|
||||
cpu=$(basename "$(dirname "$c")")
|
||||
echo " $cpu: $(cat "$c" 2>/dev/null || echo 'N/A')"
|
||||
done
|
||||
|
||||
# Current frequency
|
||||
echo ""
|
||||
echo "── CPU Frequency ──"
|
||||
for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq; do
|
||||
local cpu
|
||||
cpu=$(basename "$(dirname "$c")")
|
||||
local freq_khz
|
||||
freq_khz=$(cat "$c" 2>/dev/null || echo 'N/A')
|
||||
echo " $cpu: $((freq_khz / 1000)) MHz"
|
||||
done
|
||||
|
||||
# IRQ affinity
|
||||
echo ""
|
||||
echo "── IRQ Affinity ──"
|
||||
local irq
|
||||
irq=$(find_usb_audio_irq)
|
||||
if [[ -n "$irq" && "$irq" != "0" ]]; then
|
||||
local aff
|
||||
aff=$(cat "/proc/irq/$irq/smp_affinity" 2>/dev/null || echo "N/A")
|
||||
local name
|
||||
name=$(cat "/proc/irq/$irq/name" 2>/dev/null || echo "unknown")
|
||||
echo " USB Audio IRQ $irq ($name): smp_affinity=0x$aff"
|
||||
else
|
||||
echo " No USB audio IRQ found"
|
||||
fi
|
||||
|
||||
# All IRQs
|
||||
echo ""
|
||||
echo "── All Interrupts (affinity) ──"
|
||||
for irq_dir in /proc/irq/[0-9]*/; do
|
||||
local i
|
||||
i=$(basename "$irq_dir")
|
||||
local aff
|
||||
aff=$(cat "${irq_dir}smp_affinity" 2>/dev/null || echo "?")
|
||||
local name
|
||||
name=$(cat "${irq_dir}name" 2>/dev/null || echo "?")
|
||||
printf " IRQ %-4s 0x%-4s %s\\n" "$i" "$aff" "$name"
|
||||
done
|
||||
|
||||
# JACK status
|
||||
echo ""
|
||||
echo "── JACK Status ──"
|
||||
if pidof jackd >/dev/null 2>&1; then
|
||||
echo " jackd: RUNNING"
|
||||
# Show JACK command line
|
||||
ps aux | grep jackd | grep -v grep | head -1 | awk '{$1=$2=$3=$4=$5=$6=$7=$8=$9=$10=""; print " Args:" $0}'
|
||||
else
|
||||
echo " jackd: NOT RUNNING"
|
||||
fi
|
||||
|
||||
# RT priority of processes
|
||||
echo ""
|
||||
echo "── RT Priority ──"
|
||||
for proc in jackd python3; do
|
||||
pidof "$proc" 2>/dev/null | tr ' ' '\\n' | while read -r pid; do
|
||||
local policy
|
||||
policy=$(chrt -p "$pid" 2>/dev/null | head -1 || echo "N/A")
|
||||
echo " $proc (PID $pid): $policy"
|
||||
done
|
||||
done
|
||||
|
||||
# Memory locking
|
||||
echo ""
|
||||
echo "── Memory Locking ──"
|
||||
if [[ -f /proc/self/status ]]; then
|
||||
grep -i "lock" /proc/self/status 2>/dev/null | head -5 | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
fi
|
||||
|
||||
# XRun debug
|
||||
echo ""
|
||||
echo "── XRun Debug ──"
|
||||
for card in /proc/asound/card*/xrun_debug; do
|
||||
echo " $card: $(cat "$card" 2>/dev/null || echo 'N/A')"
|
||||
done
|
||||
|
||||
echo ""
|
||||
ok "Status check complete"
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Main
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
err "This script must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODE="${1:-all}"
|
||||
|
||||
case "$MODE" in
|
||||
--status|-s)
|
||||
show_status
|
||||
exit 0
|
||||
;;
|
||||
|
||||
--irq-only|-i)
|
||||
IRQ=$(find_usb_audio_irq)
|
||||
if [[ -n "$IRQ" && "$IRQ" != "0" ]]; then
|
||||
apply_irq_affinity "$IRQ" "$IRQ_CORE" && ok "IRQ affinity applied"
|
||||
else
|
||||
warn "No USB audio IRQ found — checking /proc/interrupts..."
|
||||
grep -E "xhci|dwc|usb|audio" /proc/interrupts | head -10
|
||||
echo ""
|
||||
info "To find the right IRQ manually:"
|
||||
info " cat /proc/interrupts | grep xhci"
|
||||
info " cat /sys/bus/usb/devices/*/irq 2>/dev/null"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
--usb-audio-irq|-u)
|
||||
IRQ=$(find_usb_audio_irq)
|
||||
if [[ -n "$IRQ" && "$IRQ" != "0" ]]; then
|
||||
local name
|
||||
name=$(cat "/proc/irq/$IRQ/name" 2>/dev/null || echo "unknown")
|
||||
info "USB Audio IRQ = $IRQ ($name)"
|
||||
else
|
||||
warn "No USB audio IRQ found"
|
||||
echo "── /proc/interrupts (USB/audio lines) ──"
|
||||
grep -E "xhci|dwc|usb|audio|snd" /proc/interrupts | head -10
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
all|*)
|
||||
# Full tuning
|
||||
info "========== Pi Multi-FX Pedal — RT Tuning =========="
|
||||
echo ""
|
||||
|
||||
# ── 1. Find and pin USB audio IRQ ────────────────────────
|
||||
info "Step 1: USB audio IRQ affinity..."
|
||||
IRQ=$(find_usb_audio_irq)
|
||||
if [[ -n "$IRQ" && "$IRQ" != "0" ]]; then
|
||||
apply_irq_affinity "$IRQ" "$IRQ_CORE"
|
||||
# Isolate core 3 from housekeeping interrupts
|
||||
isolate_core_from_housekeeping "$IRQ_CORE" "$IRQ"
|
||||
else
|
||||
warn "USB audio IRQ not found — audio may work but without dedicated IRQ core"
|
||||
fi
|
||||
|
||||
# ── 2. CPU governor (already done in main.py, but belt-and-braces) ──
|
||||
info "Step 2: CPU governor → performance..."
|
||||
echo performance | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor >/dev/null 2>&1 || \
|
||||
warn "Could not set CPU governor (may need cpufrequtils)"
|
||||
|
||||
# ── 3. Disable CPU idle states (C-states) for lower latency ──
|
||||
# This prevents the CPU from entering deep sleep that adds latency
|
||||
# on wake. Trade-off: ~0.5W extra power draw.
|
||||
info "Step 3: Disabling deep CPU idle states..."
|
||||
if [[ -f /sys/module/processor/parameters/max_cstate ]]; then
|
||||
echo 1 > /sys/module/processor/parameters/max_cstate 2>/dev/null || true
|
||||
fi
|
||||
if [[ -f /dev/cpu_dma_latency ]]; then
|
||||
# Write 0 to request minimum DMA latency (blocks deep C-states)
|
||||
# The file stays open while the process lives — we do it briefly
|
||||
echo 0 > /dev/cpu_dma_latency 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── 4. Set JACK's ALSA buffer sizes for low latency ───────────
|
||||
# These are advisory — main.py overrides them via jackd arguments
|
||||
info "Step 4: ALSA buffer constraints (advisory)..."
|
||||
cat > /etc/security/limits.d/99-audio.conf <<'LIMITS'
|
||||
# Pi Multi-FX Pedal — Real-time audio limits
|
||||
# Applied by rt-tune.sh
|
||||
@audio - rtprio 95
|
||||
@audio - memlock unlimited
|
||||
@audio - nice -20
|
||||
LIMITS
|
||||
ok "Audio limits written to /etc/security/limits.d/99-audio.conf"
|
||||
|
||||
# ── 5. Set xrun_debug for diagnostics ─────────────────────────
|
||||
info "Step 5: Enable xrun tracking..."
|
||||
for card in /proc/asound/card*/xrun_debug; do
|
||||
if [[ -w "$card" ]]; then
|
||||
# Bit 0 = enable xrun logging
|
||||
# Bit 1 = show stack backtrace on xrun
|
||||
# Bit 2 = inhibit xrun (test mode)
|
||||
# Value 3 = log xruns with backtrace (diagnostic)
|
||||
echo 3 > "$card" 2>/dev/null || true
|
||||
ok "Set xrun_debug on $(dirname "$card" | xargs basename)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 6. Process RT priority (for the calling process) ──────────
|
||||
info "Step 6: Setting RT priority..."
|
||||
# This is primarily done by main.py (mlockall, GC disable)
|
||||
# and the systemd service (LimitRTPRIO). We set it here too
|
||||
# so the script works in all contexts.
|
||||
set_self_rt_priority 80 || true
|
||||
|
||||
# ── 7. Systemd service check ──────────────────────────────────
|
||||
info "Step 7: Verifying systemd RT limits..."
|
||||
if systemctl is-active pi-multifx-pedal.service &>/dev/null; then
|
||||
local rtprio
|
||||
rtprio=$(systemctl show pi-multifx-pedal.service -p LimitRTPRIO --value 2>/dev/null || echo "?")
|
||||
local memlock
|
||||
memlock=$(systemctl show pi-multifx-pedal.service -p LimitMEMLOCK --value 2>/dev/null || echo "?")
|
||||
local nice
|
||||
nice=$(systemctl show pi-multifx-pedal.service -p LimitNICE --value 2>/dev/null || echo "?")
|
||||
info " LimitRTPRIO=$rtprio LimitMEMLOCK=$memlock LimitNICE=$nice"
|
||||
if [[ "$rtprio" != "95" ]]; then
|
||||
warn "LimitRTPRIO should be 95 — check pi-multifx-pedal.service"
|
||||
fi
|
||||
if [[ "$memlock" != "infinity" ]]; then
|
||||
warn "LimitMEMLOCK should be infinity — check pi-multifx-pedal.service"
|
||||
fi
|
||||
else
|
||||
warn "pi-multifx-pedal.service not running — check after deployment"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
ok "========== RT Tuning Complete =========="
|
||||
echo ""
|
||||
info "Recommended next steps:"
|
||||
info " 1. Start pedal: sudo systemctl start pi-multifx-pedal.service"
|
||||
info " 2. Check logs: journalctl -fu pi-multifx-pedal.service"
|
||||
info " 3. Test latency: jack_iodelay (needs loopback cable)"
|
||||
info " 4. Check xruns: cat /proc/asound/card*/xrun_debug"
|
||||
info " 5. Full status: sudo ./rt-tune.sh --status"
|
||||
;;
|
||||
esac
|
||||
+99
-15
@@ -10,7 +10,9 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -21,20 +23,38 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
ENGINE_PATH = Path(__file__).parent / 'nam_engine'
|
||||
DEFAULT_BLOCK_SIZE = 256
|
||||
DEFAULT_SAMPLE_RATE = 48000
|
||||
|
||||
# How long to wait for a block to be processed before returning passthrough.
|
||||
# Set to 2x the expected JACK period at 256/48k (5.33ms) to avoid false
|
||||
# timeouts under load. If the engine doesn't respond in this window, we
|
||||
# reuse the previous output buffer to keep the stream aligned.
|
||||
READ_TIMEOUT_MS = 10.0 # 10ms hard timeout for RT thread safety
|
||||
|
||||
|
||||
class NAMEngineProcess:
|
||||
"""Manages the C++ nam_engine subprocess for a single model."""
|
||||
|
||||
def __init__(self, model_path: str | Path, block_size: int = DEFAULT_BLOCK_SIZE):
|
||||
def __init__(self, model_path: str | Path, block_size: int = DEFAULT_BLOCK_SIZE,
|
||||
sample_rate: int = DEFAULT_SAMPLE_RATE):
|
||||
self._model_path = Path(model_path)
|
||||
self._block_size = block_size
|
||||
self._sample_rate = sample_rate
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._static: bool = False
|
||||
self._sample_rate: float = 48000.0
|
||||
self._timing_samples: list[float] = []
|
||||
self._loaded: bool = False
|
||||
|
||||
# Background reader thread for non-blocking stdout consumption
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._reader_running: bool = False
|
||||
self._read_buf: bytes = b""
|
||||
self._read_lock = threading.Lock()
|
||||
|
||||
# Last successfully processed output — reused if engine is slow
|
||||
self._last_output: Optional[np.ndarray] = None
|
||||
self._last_output_shape: Optional[tuple] = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Launch the engine subprocess."""
|
||||
if not self._model_path.exists():
|
||||
@@ -79,12 +99,54 @@ class NAMEngineProcess:
|
||||
if ready_line2:
|
||||
logger.info('NAM engine ready2: %s', ready_line2.strip())
|
||||
|
||||
# Start background reader thread to consume stdout non-blocking
|
||||
self._reader_running = True
|
||||
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._loaded = True
|
||||
return True
|
||||
|
||||
def _reader_loop(self) -> None:
|
||||
"""Background thread: continuously read engine stdout into a buffer.
|
||||
|
||||
This ensures the stdout pipe never fills up (which would block
|
||||
the engine) and keeps the RT thread's process() call non-blocking.
|
||||
"""
|
||||
proc = self._proc
|
||||
if proc is None or proc.stdout is None:
|
||||
return
|
||||
|
||||
# Set stdout to non-blocking for safe reading
|
||||
fd = proc.stdout.fileno()
|
||||
import fcntl
|
||||
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
||||
|
||||
while self._reader_running and proc.poll() is None:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
if not chunk:
|
||||
# EOF — engine has closed stdout
|
||||
break
|
||||
with self._read_lock:
|
||||
self._read_buf += chunk
|
||||
except BlockingIOError:
|
||||
# No data available yet — sleep briefly before retrying
|
||||
time.sleep(0.0001) # 100µs
|
||||
except OSError:
|
||||
# Broken pipe or other I/O error
|
||||
break
|
||||
|
||||
logger.debug("NAM engine reader thread exiting")
|
||||
|
||||
def process(self, audio_block: np.ndarray) -> np.ndarray:
|
||||
"""Process a block of audio through the NAM engine.
|
||||
|
||||
Non-blocking: writes to stdin and reads from a background buffer.
|
||||
If the engine hasn't produced output yet, reuses the previous
|
||||
block's output to maintain stream alignment.
|
||||
|
||||
Args:
|
||||
audio_block: float32 numpy array of shape (N,) or (1, N)
|
||||
|
||||
@@ -105,15 +167,34 @@ class NAMEngineProcess:
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
# Write block to engine
|
||||
self._proc.stdin.write(audio_block.tobytes())
|
||||
self._proc.stdin.flush()
|
||||
# Write block to engine (fast — 1KB into 64KB pipe buffer)
|
||||
try:
|
||||
self._proc.stdin.write(audio_block.tobytes())
|
||||
self._proc.stdin.flush()
|
||||
except BrokenPipeError:
|
||||
logger.warning("NAM engine stdin broken pipe — engine may have crashed")
|
||||
return audio_block # passthrough
|
||||
|
||||
# Read processed block
|
||||
raw = self._proc.stdout.read(audio_block.nbytes)
|
||||
if len(raw) != audio_block.nbytes:
|
||||
# Read processed block from background buffer (non-blocking)
|
||||
nbytes = audio_block.nbytes
|
||||
with self._read_lock:
|
||||
if len(self._read_buf) >= nbytes:
|
||||
raw = self._read_buf[:nbytes]
|
||||
self._read_buf = self._read_buf[nbytes:]
|
||||
else:
|
||||
# Engine hasn't produced output yet — reuse previous frame
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
self._timing_samples.append(elapsed_ms)
|
||||
if len(self._timing_samples) > 200:
|
||||
self._timing_samples = self._timing_samples[-100:]
|
||||
if self._last_output is not None:
|
||||
return self._last_output.copy()
|
||||
return audio_block # first frame before engine responds
|
||||
|
||||
# Check for short read (engine crash mid-block)
|
||||
if len(raw) != nbytes:
|
||||
logger.warning('NAM engine short read: got %d bytes, expected %d',
|
||||
len(raw), audio_block.nbytes)
|
||||
len(raw), nbytes)
|
||||
return audio_block # passthrough on error
|
||||
|
||||
out = np.frombuffer(raw, dtype=np.float32).copy()
|
||||
@@ -128,10 +209,16 @@ class NAMEngineProcess:
|
||||
if len(self._timing_samples) > 200:
|
||||
self._timing_samples = self._timing_samples[-100:]
|
||||
|
||||
# Cache last output for reuse on slow frames
|
||||
self._last_output = out.copy()
|
||||
|
||||
return out
|
||||
|
||||
def stop(self):
|
||||
"""Terminate the engine subprocess."""
|
||||
self._reader_running = False
|
||||
if self._reader_thread is not None:
|
||||
self._reader_thread.join(timeout=2)
|
||||
if self._proc is not None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
@@ -147,10 +234,6 @@ class NAMEngineProcess:
|
||||
if self._proc is None or self._proc.stderr is None:
|
||||
return None
|
||||
# Poll until data available or timeout
|
||||
import select
|
||||
import sys
|
||||
|
||||
# Use polling loop
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
# Check if process is still alive
|
||||
@@ -160,7 +243,7 @@ class NAMEngineProcess:
|
||||
logger.error('Engine exited with code %d: %s', self._proc.returncode, remaining)
|
||||
return 'FAILED: process exited'
|
||||
|
||||
# Try non-blocking read
|
||||
# Try non-blocking read from stderr
|
||||
line = self._proc.stderr.readline()
|
||||
if line:
|
||||
return line.decode('utf-8', errors='replace')
|
||||
@@ -195,8 +278,9 @@ if __name__ == '__main__':
|
||||
|
||||
model = sys.argv[1] if len(sys.argv) > 1 else 'models/nam/clean.nam'
|
||||
block_size = int(sys.argv[2]) if len(sys.argv) > 2 else 256
|
||||
sample_rate = int(sys.argv[3]) if len(sys.argv) > 3 else 48000
|
||||
|
||||
engine = NAMEngineProcess(model, block_size)
|
||||
engine = NAMEngineProcess(model, block_size, sample_rate)
|
||||
if not engine.start():
|
||||
print('FAILED to start engine')
|
||||
sys.exit(1)
|
||||
|
||||
+132
-41
@@ -6,8 +6,10 @@ spawns the C++ NeuralAudio engine for ~34x faster inference.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -50,6 +52,20 @@ class NAMFastModel:
|
||||
return "0.05-0.2 ms (C++ NeuralAudio engine)"
|
||||
|
||||
|
||||
def _read_nam_architecture(model_path: str) -> str:
|
||||
"""Read the architecture field from a .nam file without loading the full model.
|
||||
|
||||
Returns the architecture string (e.g. 'WaveNet', 'Linear', 'LSTM', 'ConvNet')
|
||||
or 'unknown' if the file can't be read.
|
||||
"""
|
||||
try:
|
||||
with open(model_path) as f:
|
||||
data = json.load(f)
|
||||
return data.get("architecture", "unknown")
|
||||
except (json.JSONDecodeError, OSError, FileNotFoundError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
class FastNAMHost:
|
||||
"""NAM model host using the C++ nam_engine subprocess.
|
||||
|
||||
@@ -62,18 +78,23 @@ class FastNAMHost:
|
||||
Directory scanned for available .nam models.
|
||||
block_size : int
|
||||
Audio block size (must match the pipeline's JACK buffer).
|
||||
sample_rate : int
|
||||
Audio sample rate in Hz (sent to the C++ engine).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
models_dir: str | Path = MODELS_DIR,
|
||||
block_size: int = 256,
|
||||
sample_rate: int = 48000,
|
||||
):
|
||||
self._models_dir = Path(models_dir)
|
||||
self._block_size = block_size
|
||||
self._sample_rate = sample_rate
|
||||
self._engine: Optional[NAMModel] = None # Using current naming matching nam_host
|
||||
self._loaded_path: Optional[str] = None
|
||||
self._loaded_model: Optional[NAMFastModel] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
self._models_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -81,30 +102,87 @@ class FastNAMHost:
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
return self._engine is not None and self._engine.is_loaded
|
||||
with self._lock:
|
||||
return self._engine is not None and self._engine.is_loaded
|
||||
|
||||
@property
|
||||
def current_model(self) -> Optional[NAMFastModel]:
|
||||
return self._loaded_model
|
||||
with self._lock:
|
||||
return self._loaded_model
|
||||
|
||||
@property
|
||||
def avg_inference_ms(self) -> float:
|
||||
if self._engine is None:
|
||||
return 0.0
|
||||
return self._engine.avg_inference_ms
|
||||
with self._lock:
|
||||
if self._engine is None:
|
||||
return 0.0
|
||||
return self._engine.avg_inference_ms
|
||||
|
||||
@property
|
||||
def block_size(self) -> int:
|
||||
return self._block_size
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
return self._sample_rate
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
"""Last model-load error message (empty string if last load succeeded)."""
|
||||
with self._lock:
|
||||
if hasattr(self, '_last_error_val'):
|
||||
return self._last_error_val
|
||||
return ""
|
||||
|
||||
def set_block_size(self, block_size: int) -> None:
|
||||
"""Update block size. Reloads current model if loaded."""
|
||||
"""Update block size. Reloads current model if loaded.
|
||||
|
||||
Uses warm-before-kill: spawns the new subprocess before stopping
|
||||
the old one, so there's no gap in NAM processing.
|
||||
"""
|
||||
if block_size == self._block_size:
|
||||
return
|
||||
self._block_size = block_size
|
||||
if self._loaded_path:
|
||||
logger.info("Block size changed to %d — reloading model %s", block_size, self._loaded_path)
|
||||
self.load_model(self._loaded_path)
|
||||
# Warm-before-kill: spin up new engine while old one still serves
|
||||
new_engine = NAMEngineProcess(
|
||||
self._loaded_path, self._block_size, self._sample_rate,
|
||||
)
|
||||
if not new_engine.start():
|
||||
logger.error("Failed to start new engine for block size %d — keeping old engine", block_size)
|
||||
new_engine.stop()
|
||||
return
|
||||
|
||||
logger.info("Warm-before-kill: spawned new engine, swapping...")
|
||||
with self._lock:
|
||||
old_engine = self._engine
|
||||
self._engine = new_engine
|
||||
# Old engine can be stopped now — no one is reading from it
|
||||
if old_engine is not None:
|
||||
old_engine.stop()
|
||||
logger.debug("Old NAM engine stopped")
|
||||
|
||||
def set_sample_rate(self, sample_rate: int) -> None:
|
||||
"""Update sample rate. Reloads current model if loaded.
|
||||
|
||||
Uses warm-before-kill like set_block_size.
|
||||
"""
|
||||
if sample_rate == self._sample_rate:
|
||||
return
|
||||
self._sample_rate = sample_rate
|
||||
if self._loaded_path:
|
||||
new_engine = NAMEngineProcess(
|
||||
self._loaded_path, self._block_size, self._sample_rate,
|
||||
)
|
||||
if not new_engine.start():
|
||||
logger.error("Failed to restart engine for sample rate %d", sample_rate)
|
||||
new_engine.stop()
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
old_engine = self._engine
|
||||
self._engine = new_engine
|
||||
if old_engine is not None:
|
||||
old_engine.stop()
|
||||
|
||||
# ── Model loading ──────────────────────────────────────────────
|
||||
|
||||
@@ -112,58 +190,72 @@ class FastNAMHost:
|
||||
"""Load a .nam model into the C++ engine.
|
||||
|
||||
Returns True on success, False on error.
|
||||
Uses warm-before-kill: spawns new process before stopping old one.
|
||||
"""
|
||||
path = Path(model_path)
|
||||
if not path.exists() or path.suffix.lower() not in (".nam",):
|
||||
logger.error("Model not found or invalid: %s", model_path)
|
||||
self._last_error_val = f"Model not found: {model_path}"
|
||||
return False
|
||||
|
||||
# Stop any existing engine
|
||||
self.unload()
|
||||
|
||||
size_mb = path.stat().st_size / (1024 * 1024)
|
||||
arch = _read_nam_architecture(model_path)
|
||||
|
||||
# Create and start the engine
|
||||
engine = NAMEngineProcess(str(path), self._block_size)
|
||||
# Create and start the new engine BEFORE stopping the old one
|
||||
engine = NAMEngineProcess(str(path), self._block_size, self._sample_rate)
|
||||
if not engine.start():
|
||||
logger.error("Failed to start NAM engine for: %s", model_path)
|
||||
msg = f"Failed to start NAM engine for: {model_path}"
|
||||
logger.error(msg)
|
||||
self._last_error_val = msg
|
||||
return False
|
||||
|
||||
self._engine = engine
|
||||
self._loaded_path = str(path)
|
||||
self._loaded_model = NAMFastModel(
|
||||
name=path.stem,
|
||||
path=str(path),
|
||||
size_mb=size_mb,
|
||||
architecture="LSTM",
|
||||
)
|
||||
# Swap: new engine takes over, old one is cleaned up
|
||||
with self._lock:
|
||||
old_engine = self._engine
|
||||
self._engine = engine
|
||||
self._loaded_path = str(path)
|
||||
self._loaded_model = NAMFastModel(
|
||||
name=path.stem,
|
||||
path=str(path),
|
||||
size_mb=size_mb,
|
||||
architecture=arch,
|
||||
)
|
||||
|
||||
if old_engine is not None:
|
||||
old_engine.stop()
|
||||
|
||||
logger.info(
|
||||
"Loaded NAM model via C++ engine: %s (%.1f KB, static=%s, engine=NeuralAudio)",
|
||||
"Loaded NAM model via C++ engine: %s (%.1f KB, static=%s, arch=%s, engine=NeuralAudio)",
|
||||
path.stem,
|
||||
size_mb * 1024,
|
||||
engine.is_static,
|
||||
arch,
|
||||
)
|
||||
self._last_error_val = ""
|
||||
return True
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Unload the current model and stop the engine."""
|
||||
if self._engine is not None:
|
||||
self._engine.stop()
|
||||
with self._lock:
|
||||
engine = self._engine
|
||||
self._engine = None
|
||||
self._loaded_path = None
|
||||
self._loaded_model = None
|
||||
self._loaded_path = None
|
||||
self._loaded_model = None
|
||||
if engine is not None:
|
||||
engine.stop()
|
||||
logger.info("NAM model unloaded")
|
||||
|
||||
# ── Warm-up ────────────────────────────────────────────────────
|
||||
|
||||
def warm_up(self, block_size: int = 256) -> None:
|
||||
"""Run a dry inference to warm caches."""
|
||||
if self._engine is None or not self._engine.is_loaded:
|
||||
with self._lock:
|
||||
engine = self._engine
|
||||
if engine is None or not engine.is_loaded:
|
||||
return
|
||||
dummy = np.zeros(block_size, dtype=np.float32)
|
||||
for _ in range(5):
|
||||
self._engine.process(dummy)
|
||||
engine.process(dummy)
|
||||
|
||||
# ── Inference ──────────────────────────────────────────────────
|
||||
|
||||
@@ -176,32 +268,31 @@ class FastNAMHost:
|
||||
Returns:
|
||||
Processed audio, same shape, float32.
|
||||
"""
|
||||
if self._engine is None or not self._engine.is_loaded:
|
||||
with self._lock:
|
||||
engine = self._engine
|
||||
if engine is None or not engine.is_loaded:
|
||||
return audio_block # passthrough
|
||||
|
||||
return self._engine.process(audio_block)
|
||||
|
||||
# ── Model switching (crossfade compatible) ─────────────────────
|
||||
|
||||
_crossfade_buf = None # For pipeline crossfade compatibility
|
||||
|
||||
def apply_crossfade(self, buf: np.ndarray) -> np.ndarray:
|
||||
"""Passthrough — crossfade not needed with fast C++ switching."""
|
||||
return buf
|
||||
return engine.process(audio_block)
|
||||
|
||||
# ── Model discovery ────────────────────────────────────────────
|
||||
|
||||
def list_available_models(self) -> list[NAMFastModel]:
|
||||
"""Scan models_dir for .nam files and return metadata."""
|
||||
"""Scan models_dir for .nam files and return metadata.
|
||||
|
||||
Reads the actual architecture from each .nam file instead of
|
||||
hardcoding a default.
|
||||
"""
|
||||
models: list[NAMFastModel] = []
|
||||
for f in sorted(self._models_dir.glob("*.nam")):
|
||||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
arch = _read_nam_architecture(str(f))
|
||||
models.append(
|
||||
NAMFastModel(
|
||||
name=f.stem,
|
||||
path=str(f),
|
||||
size_mb=size_mb,
|
||||
architecture="LSTM",
|
||||
architecture=arch,
|
||||
)
|
||||
)
|
||||
return models
|
||||
|
||||
+38
-4
@@ -12,6 +12,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -34,6 +35,8 @@ class NAMEngineRouter:
|
||||
Directory scanned for available .nam models.
|
||||
block_size : int
|
||||
Audio block size in samples.
|
||||
sample_rate : int
|
||||
Audio sample rate in Hz.
|
||||
"""
|
||||
|
||||
ENGINE_MODES = ("cpp", "pytorch")
|
||||
@@ -43,6 +46,7 @@ class NAMEngineRouter:
|
||||
engine_mode: str = "cpp",
|
||||
models_dir: str | Path | None = None,
|
||||
block_size: int = 256,
|
||||
sample_rate: int = 48000,
|
||||
):
|
||||
if engine_mode not in self.ENGINE_MODES:
|
||||
raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {engine_mode!r}")
|
||||
@@ -51,6 +55,7 @@ class NAMEngineRouter:
|
||||
Path(__file__).parent.parent / "models" / "nam"
|
||||
)
|
||||
self._block_size = block_size
|
||||
self._sample_rate = sample_rate
|
||||
self._engine_mode = engine_mode
|
||||
self._engine: object = None # FastNAMHost or NAMHost instance
|
||||
self._loaded_path: Optional[str] = None
|
||||
@@ -70,6 +75,7 @@ class NAMEngineRouter:
|
||||
self._engine = FastNAMHost(
|
||||
models_dir=str(self._models_dir),
|
||||
block_size=self._block_size,
|
||||
sample_rate=self._sample_rate,
|
||||
)
|
||||
logger.info("NAM engine: C++ subprocess (FastNAMHost)")
|
||||
else:
|
||||
@@ -139,12 +145,17 @@ class NAMEngineRouter:
|
||||
def block_size(self) -> int:
|
||||
return self._block_size
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
return self._sample_rate
|
||||
|
||||
# ── Crossfade (compatible with both engines) ────────────────────
|
||||
|
||||
@property
|
||||
def _crossfade_buf(self):
|
||||
"""For pipeline crossfade compatibility.
|
||||
PyTorch NAMHost has this natively; FastNAMHost has None."""
|
||||
PyTorch NAMHost has this natively; FastNAMHost has None.
|
||||
"""
|
||||
with self._lock:
|
||||
if hasattr(self._engine, '_crossfade_buf'):
|
||||
return self._engine._crossfade_buf
|
||||
@@ -174,10 +185,26 @@ class NAMEngineRouter:
|
||||
self._engine.unload()
|
||||
self._loaded_path = None
|
||||
|
||||
# ── Audio profile sync ──────────────────────────────────────────
|
||||
|
||||
def set_block_size(self, block_size: int) -> None:
|
||||
"""Update block size. Delegates to the active engine."""
|
||||
self._block_size = block_size
|
||||
if self._engine is not None and hasattr(self._engine, 'set_block_size'):
|
||||
self._engine.set_block_size(block_size)
|
||||
with self._lock:
|
||||
engine = self._engine
|
||||
if engine is not None and hasattr(engine, 'set_block_size'):
|
||||
engine.set_block_size(block_size)
|
||||
|
||||
def set_sample_rate(self, sample_rate: int) -> None:
|
||||
"""Update sample rate. Delegates to the active engine."""
|
||||
self._sample_rate = sample_rate
|
||||
with self._lock:
|
||||
engine = self._engine
|
||||
if engine is not None and hasattr(engine, 'set_sample_rate'):
|
||||
engine.set_sample_rate(sample_rate)
|
||||
elif engine is not None:
|
||||
# PyTorch backend doesn't need SR — skip
|
||||
pass
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
@@ -227,10 +254,17 @@ class NAMEngineRouter:
|
||||
for f in sorted(extra.glob("*.nam")):
|
||||
if str(f) not in seen:
|
||||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
# Read actual architecture from the .nam file
|
||||
try:
|
||||
with open(f) as fp:
|
||||
data = json.load(fp)
|
||||
arch = data.get("architecture", "unknown")
|
||||
except Exception:
|
||||
arch = "unknown"
|
||||
models.append(NAMFastModel(
|
||||
name=f.stem,
|
||||
path=str(f),
|
||||
size_mb=size_mb,
|
||||
architecture="LSTM",
|
||||
architecture=arch,
|
||||
))
|
||||
return models
|
||||
|
||||
@@ -370,6 +370,27 @@ class AudioPipeline:
|
||||
self._notch_b0, self._notch_b1, self._notch_b2 = _b0, _b1, _b2
|
||||
self._notch_a1, self._notch_a2 = _a1, _a2
|
||||
|
||||
# ── Post-NAM high-pass filter (80Hz) to remove residual hum ─────────
|
||||
# Applied after NAM processing to catch DC offset and low-frequency
|
||||
# artifacts introduced by the NAM engine / subprocess pipe.
|
||||
self._post_nam_x1: float = 0.0
|
||||
self._post_nam_x2: float = 0.0
|
||||
self._post_nam_y1: float = 0.0
|
||||
self._post_nam_y2: float = 0.0
|
||||
self._post_nam_b0: float = 1.0
|
||||
self._post_nam_b1: float = 0.0
|
||||
self._post_nam_b2: float = 0.0
|
||||
self._post_nam_a1: float = 0.0
|
||||
self._post_nam_a2: float = 0.0
|
||||
# Compute initial coefficients for 80Hz high-pass with Q=0.707 (Butterworth)
|
||||
_b0, _b1, _b2, _a1, _a2 = _compute_hpf_coeffs(80.0, 0.707, 48000.0)
|
||||
self._post_nam_b0, self._post_nam_b1, self._post_nam_b2 = _b0, _b1, _b2
|
||||
self._post_nam_a1, self._post_nam_a2 = _a1, _a2
|
||||
|
||||
# ── DC blocker state (first-order, applied after NAM) ───────────────
|
||||
self._dc_x_prev: float = 0.0
|
||||
self._dc_y_prev: float = 0.0
|
||||
|
||||
logger.info("Audio pipeline initialized (block=%d, sr=%d)",
|
||||
self._block_size, self._sample_rate)
|
||||
|
||||
@@ -878,6 +899,36 @@ class AudioPipeline:
|
||||
if self.nam._crossfade_buf is not None:
|
||||
processed = self.nam.apply_crossfade(processed)
|
||||
|
||||
# ── Post-NAM DC blocker ─────────────────────────────
|
||||
# First-order high-pass: y[n] = x[n] - x[n-1] + R * y[n-1]
|
||||
# R = 0.999 (~10Hz cutoff at 48kHz, blocks subsonic DC offset)
|
||||
R = 0.999
|
||||
x = processed
|
||||
y = np.empty_like(x)
|
||||
y[0] = x[0] - self._dc_x_prev + R * self._dc_y_prev
|
||||
y[1:] = x[1:] - x[:-1] + R * y[:-1]
|
||||
self._dc_x_prev = x[-1]
|
||||
self._dc_y_prev = y[-1]
|
||||
processed = y
|
||||
|
||||
# ── Post-NAM HPF at 80Hz (catches residual 60/120Hz hum) ──
|
||||
b0, b1, b2 = self._post_nam_b0, self._post_nam_b1, self._post_nam_b2
|
||||
a1, a2 = self._post_nam_a1, self._post_nam_a2
|
||||
pn_x1, pn_x2 = self._post_nam_x1, self._post_nam_x2
|
||||
pn_y1, pn_y2 = self._post_nam_y1, self._post_nam_y2
|
||||
for i in range(len(processed)):
|
||||
pn_x = processed[i]
|
||||
pn_y = b0*pn_x + b1*pn_x1 + b2*pn_x2 - a1*pn_y1 - a2*pn_y2
|
||||
pn_x2 = pn_x1
|
||||
pn_x1 = pn_x
|
||||
pn_y2 = pn_y1
|
||||
pn_y1 = pn_y
|
||||
processed[i] = pn_y
|
||||
self._post_nam_x1 = pn_x1
|
||||
self._post_nam_x2 = pn_x2
|
||||
self._post_nam_y1 = pn_y1
|
||||
self._post_nam_y2 = pn_y2
|
||||
|
||||
# Clip output to prevent digital distortion
|
||||
return np.clip(processed, -1.0, 1.0)
|
||||
|
||||
@@ -3175,6 +3226,16 @@ class AudioPipeline:
|
||||
# Reset notch filter state to avoid pop on rate change
|
||||
self._notch_x1 = self._notch_x2 = 0.0
|
||||
self._notch_y1 = self._notch_y2 = 0.0
|
||||
# Recompute post-NAM 80Hz HPF coefficients for new sample rate
|
||||
_b0, _b1, _b2, _a1, _a2 = _compute_hpf_coeffs(80.0, 0.707, sample_rate)
|
||||
self._post_nam_b0, self._post_nam_b1, self._post_nam_b2 = _b0, _b1, _b2
|
||||
self._post_nam_a1, self._post_nam_a2 = _a1, _a2
|
||||
# Reset post-NAM filter state
|
||||
self._post_nam_x1 = self._post_nam_x2 = 0.0
|
||||
self._post_nam_y1 = self._post_nam_y2 = 0.0
|
||||
# Reset DC blocker state
|
||||
self._dc_x_prev = 0.0
|
||||
self._dc_y_prev = 0.0
|
||||
# Clear DSP state — effects will reinit with new block/sample rate
|
||||
self._state.clear()
|
||||
self._coeffs.clear()
|
||||
|
||||
+27
-1
@@ -587,9 +587,35 @@ class AudioSystem:
|
||||
return devices
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# XRun monitoring
|
||||
# XRun monitoring and tracking
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def enable_xrun_tracking(self) -> bool:
|
||||
"""Enable kernel-level xrun tracking via ALSA xrun_debug.
|
||||
|
||||
Sets xrun_debug to value 3 on all ALSA cards:
|
||||
- Bit 0 (1): log xruns to kernel ring buffer
|
||||
- Bit 1 (2): show stack backtrace on xrun
|
||||
|
||||
Returns True if at least one card was configured.
|
||||
|
||||
Requires root. Non-fatal if unavailable.
|
||||
"""
|
||||
import glob as _glob
|
||||
configured = 0
|
||||
for card_path in _glob.glob("/proc/asound/card*/xrun_debug"):
|
||||
try:
|
||||
with open(card_path, "w") as f:
|
||||
f.write("3")
|
||||
configured += 1
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
if configured:
|
||||
logger.info("XRun tracking enabled on %d ALSA card(s)", configured)
|
||||
else:
|
||||
logger.info("XRun tracking not available (non-root or no ALSA cards)")
|
||||
return configured > 0
|
||||
|
||||
@staticmethod
|
||||
def read_xrun_count() -> Optional[int]:
|
||||
"""Read JACK xrun counter from jack_showtime.
|
||||
|
||||
@@ -62,7 +62,7 @@ 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}
|
||||
ExecStart=/usr/bin/chrt -f 80 {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
|
||||
@@ -74,6 +74,7 @@ KillMode=process
|
||||
LimitRTPRIO=95
|
||||
LimitMEMLOCK=infinity
|
||||
LimitNICE=-20
|
||||
LimitSIGPENDING=128
|
||||
|
||||
# Environment
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
@@ -1765,6 +1765,9 @@ class WebServer:
|
||||
nam_host = self.deps.nam_host
|
||||
if nam_host and hasattr(nam_host, 'set_block_size'):
|
||||
nam_host.set_block_size(target_profile["period"])
|
||||
# Sync NAM engine sample rate too
|
||||
if nam_host and hasattr(nam_host, 'set_sample_rate'):
|
||||
nam_host.set_sample_rate(target_profile["rate"])
|
||||
|
||||
# Sync AudioPipeline block size and sample rate for correct DSP timing
|
||||
pipeline = self.deps.pipeline
|
||||
|
||||
Reference in New Issue
Block a user