R1: Dashboard 500 fix, Phase 2/7 uncommitted changes
- Fix dashboard 500 when deps disconnected — _gather_state() now returns
full defaults instead of bare {'connected': False}
- Phase 2: FX param schemas aligned with DSP implementations
- Phase 7: Pipeline routing, preset manager improvements
- Factory presets: cleanup and normalization
Fixes dashboard crash on disconnected state (dogfood issue #1)
This commit is contained in:
+40
-1
@@ -312,6 +312,13 @@ class AudioPipeline:
|
||||
# Cached filter coefficients per block
|
||||
self._coeffs: dict[str, tuple] = {}
|
||||
|
||||
# VU meter level tracking — updated on every process() call
|
||||
# Smoothed RMS levels (0.0–1.0) read by web server for live VU meters
|
||||
self._input_level: float = 0.0
|
||||
self._output_level: float = 0.0
|
||||
# Smoothing factor: ~50ms time constant at 48kHz/256 block
|
||||
self._vu_alpha: float = np.exp(-BLOCK_SIZE / (0.05 * SAMPLE_RATE))
|
||||
|
||||
logger.info("Audio pipeline initialized (block=%d, sr=%d)",
|
||||
BLOCK_SIZE, SAMPLE_RATE)
|
||||
|
||||
@@ -373,12 +380,28 @@ class AudioPipeline:
|
||||
|
||||
def _process_mono(self, audio_in: np.ndarray) -> np.ndarray:
|
||||
"""Process a mono block through the full chain (all blocks)."""
|
||||
# Update input VU level (RMS with envelope smoothing)
|
||||
in_rms = np.sqrt(np.mean(audio_in ** 2) + _EPS)
|
||||
self._input_level = (
|
||||
self._input_level * self._vu_alpha
|
||||
+ in_rms * (1.0 - self._vu_alpha)
|
||||
)
|
||||
|
||||
buf = audio_in.copy()
|
||||
for idx, entry in enumerate(self._chain):
|
||||
if entry["bypass"] or not entry["enabled"]:
|
||||
continue
|
||||
buf = self._process_single_block(buf, idx, entry)
|
||||
return buf * self._master_volume
|
||||
out = buf * self._master_volume
|
||||
|
||||
# Update output VU level
|
||||
out_rms = np.sqrt(np.mean(out ** 2) + _EPS)
|
||||
self._output_level = (
|
||||
self._output_level * self._vu_alpha
|
||||
+ out_rms * (1.0 - self._vu_alpha)
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
def _process_4cm(self, audio_in: np.ndarray) -> np.ndarray:
|
||||
"""Process stereo block with 4CM split routing.
|
||||
@@ -397,6 +420,14 @@ class AudioPipeline:
|
||||
"""
|
||||
ch0 = audio_in[0, :].copy()
|
||||
ch1 = audio_in[1, :].copy()
|
||||
|
||||
# Update input VU level from the guitar input channel (ch0)
|
||||
in_rms = np.sqrt(np.mean(ch0 ** 2) + _EPS)
|
||||
self._input_level = (
|
||||
self._input_level * self._vu_alpha
|
||||
+ in_rms * (1.0 - self._vu_alpha)
|
||||
)
|
||||
|
||||
bp = self._routing_breakpoint
|
||||
|
||||
for idx, entry in enumerate(self._chain):
|
||||
@@ -413,6 +444,14 @@ class AudioPipeline:
|
||||
out = np.zeros_like(audio_in)
|
||||
out[0, :] = ch0 * self._master_volume
|
||||
out[1, :] = ch1 * self._master_volume
|
||||
|
||||
# Update output VU level from the processed effect return (ch1)
|
||||
out_rms = np.sqrt(np.mean(out ** 2) + _EPS)
|
||||
self._output_level = (
|
||||
self._output_level * self._vu_alpha
|
||||
+ out_rms * (1.0 - self._vu_alpha)
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
def _process_single_block(self, buf: np.ndarray, idx: int,
|
||||
|
||||
@@ -34,6 +34,9 @@ AUTO_SAVE_DELAY_S = 1.0
|
||||
FACTORY_PRESET_DIR = Path(__file__).resolve().parent.parent.parent / "presets" / "factory"
|
||||
"""Location of bundled factory preset JSON files."""
|
||||
|
||||
FACTORY_IR_DIR = FACTORY_PRESET_DIR / "irs"
|
||||
"""Location of bundled factory cabinet IR .wav files."""
|
||||
|
||||
|
||||
# ── Serialisation helpers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -513,6 +516,41 @@ class PresetManager:
|
||||
logger.info("Installed %d factory presets", count)
|
||||
return count
|
||||
|
||||
def install_factory_irs(self, overwrite: bool = False) -> int:
|
||||
"""Install bundled factory cabinet IR .wav files into the IR directory.
|
||||
|
||||
Copies IR files from ``FACTORY_IR_DIR`` into the IR loader's default
|
||||
directory (``~/.pedal/irs/``). Existing files are skipped unless
|
||||
``overwrite=True``.
|
||||
|
||||
Args:
|
||||
overwrite: If True, overwrite existing IR files with the same name.
|
||||
|
||||
Returns:
|
||||
Number of factory IR files installed.
|
||||
"""
|
||||
if not FACTORY_IR_DIR.is_dir():
|
||||
logger.warning("Factory IR directory not found: %s", FACTORY_IR_DIR)
|
||||
return 0
|
||||
|
||||
from ..dsp.ir_loader import DEFAULT_IR_DIR
|
||||
dest = DEFAULT_IR_DIR
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
count = 0
|
||||
for src in sorted(FACTORY_IR_DIR.glob("*.wav")):
|
||||
dst = dest / src.name
|
||||
if dst.exists() and not overwrite:
|
||||
logger.debug("Skipping existing IR: %s", dst.name)
|
||||
continue
|
||||
shutil.copy2(src, dst)
|
||||
count += 1
|
||||
logger.debug("Installed factory IR: %s", dst.name)
|
||||
|
||||
if count:
|
||||
logger.info("Installed %d factory cab IRs into %s", count, dest)
|
||||
return count
|
||||
|
||||
# ── Internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _preset_path(self, bank: int, program: int) -> Path:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""System integration subpackage.
|
||||
|
||||
- audio: ALSA/JACK/I2S configuration and lifecycle
|
||||
- defaults: Default IRs, NAM models, and directory structure
|
||||
- setup (WIP): First-boot setup scripts
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Default NAM models, IRs, and directory structure for the Pi Multi-FX Pedal.
|
||||
|
||||
Provides helpers for:
|
||||
- Getting canonical default directories for IRs and NAM models
|
||||
- Generating a basic default IR WAV file
|
||||
- Generating a minimal pass-through NAM model (Linear, identity)
|
||||
- Ensuring all default directories and fallback files exist at startup
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.io import wavfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Canonical paths ────────────────────────────────────────────────────────────
|
||||
|
||||
_DEFAULT_IRS_DIR = Path.home() / ".pedal" / "irs" / "default"
|
||||
_DEFAULT_MODELS_DIR = Path.home() / ".pedal" / "nam" / "default"
|
||||
_FACTORY_PRESET_DIR = (
|
||||
Path(__file__).resolve().parent.parent.parent / "presets" / "factory"
|
||||
)
|
||||
|
||||
|
||||
def get_default_irs_dir() -> Path:
|
||||
"""Return the canonical directory for default IR files.
|
||||
|
||||
This is ``~/.pedal/irs/default/``.
|
||||
"""
|
||||
return _DEFAULT_IRS_DIR
|
||||
|
||||
|
||||
def get_default_models_dir() -> Path:
|
||||
"""Return the canonical directory for default NAM model files.
|
||||
|
||||
This is ``~/.pedal/nam/default/``.
|
||||
"""
|
||||
return _DEFAULT_MODELS_DIR
|
||||
|
||||
|
||||
# ── Default IR generation ─────────────────────────────────────────────────────
|
||||
|
||||
_DEFAULT_IR_SAMPLE_RATE = 48000
|
||||
_DEFAULT_IR_NUM_SAMPLES = 1024
|
||||
|
||||
|
||||
def generate_default_ir(
|
||||
path: Path | None = None,
|
||||
sample_rate: int = _DEFAULT_IR_SAMPLE_RATE,
|
||||
num_samples: int = _DEFAULT_IR_NUM_SAMPLES,
|
||||
) -> Path:
|
||||
"""Generate a simple default impulse response WAV file.
|
||||
|
||||
Produces a short exponentially-decaying impulse (not silence) so
|
||||
convolution will colour the sound subtly rather than mute it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : Path or None
|
||||
Destination path. Defaults to ``~/.pedal/irs/default/default_ir.wav``.
|
||||
sample_rate : int
|
||||
Sample rate in Hz (default 48000).
|
||||
num_samples : int
|
||||
Length of the IR in samples (default 1024, ≈21 ms @ 48 kHz).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
The path the WAV file was written to.
|
||||
"""
|
||||
if path is None:
|
||||
path = _DEFAULT_IRS_DIR / "default_ir.wav"
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# A short decaying impulse: 1 at sample 0, then exponential decay.
|
||||
# Using a mild decay constant so the IR is audibly non-trivial.
|
||||
t = np.arange(num_samples, dtype=np.float32)
|
||||
ir = np.exp(-t / (num_samples * 0.15)) # decay tail
|
||||
ir[0] = 1.0 # initial impulse
|
||||
|
||||
# Normalise to [-1, 1]
|
||||
peak = np.max(np.abs(ir))
|
||||
if peak > 0:
|
||||
ir = ir / peak
|
||||
|
||||
# Write as 16-bit PCM WAV
|
||||
wavfile.write(str(path), sample_rate, (ir * 32767).astype(np.int16))
|
||||
|
||||
logger.info("Generated default IR: %s (%d samples, %d Hz)", path, num_samples, sample_rate)
|
||||
return path
|
||||
|
||||
|
||||
# ── Pass-through NAM model generation ─────────────────────────────────────────
|
||||
|
||||
_NAM_PASSTHROUGH = {
|
||||
"architecture": "Linear",
|
||||
"config": {
|
||||
"receptive_field": 1,
|
||||
"bias": False,
|
||||
},
|
||||
"weights": [1.0],
|
||||
}
|
||||
|
||||
|
||||
def generate_passthrough_nam(path: Path | None = None) -> Path:
|
||||
"""Generate a minimal pass-through NAM model file (Linear, identity).
|
||||
|
||||
The model is a ``Conv1d(1, 1, 1)`` with weight = ``[1.0]`` and no bias.
|
||||
Loading this model in :class:`~src.dsp.nam_host.NAMHost` results in a
|
||||
transparent pass-through — audio is not modified.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : Path or None
|
||||
Destination path. Defaults to ``~/.pedal/nam/default/pass_through.nam``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
The path the .nam file was written to.
|
||||
"""
|
||||
if path is None:
|
||||
path = _DEFAULT_MODELS_DIR / "pass_through.nam"
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(_NAM_PASSTHROUGH, f)
|
||||
|
||||
logger.info("Generated pass-through NAM model: %s", path)
|
||||
return path
|
||||
|
||||
|
||||
# ── Factory preset directories ────────────────────────────────────────────────
|
||||
|
||||
_BASE_DIRS = [
|
||||
Path.home() / ".pedal",
|
||||
Path.home() / ".pedal" / "presets",
|
||||
Path.home() / ".pedal" / "irs",
|
||||
Path.home() / ".pedal" / "nam",
|
||||
]
|
||||
|
||||
|
||||
def _ensure_base_dirs() -> None:
|
||||
"""Create the base ``~/.pedal`` directory tree if it doesn't exist."""
|
||||
for d in _BASE_DIRS:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ── Main entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def ensure_defaults_exist() -> bool:
|
||||
"""Ensure all default directories, IRs, and NAM models exist on disk.
|
||||
|
||||
Creates the following if missing:
|
||||
|
||||
* ``~/.pedal/``, ``~/.pedal/presets/``, ``~/.pedal/irs/``, ``~/.pedal/nam/``
|
||||
* ``~/.pedal/irs/default/default_ir.wav`` (exponentially-decaying impulse)
|
||||
* ``~/.pedal/nam/default/pass_through.nam`` (Linear identity model)
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if all resources exist or were successfully created.
|
||||
"""
|
||||
try:
|
||||
_ensure_base_dirs()
|
||||
|
||||
# Generate default IR if it doesn't exist
|
||||
default_ir_path = _DEFAULT_IRS_DIR / "default_ir.wav"
|
||||
if not default_ir_path.exists():
|
||||
generate_default_ir(default_ir_path)
|
||||
else:
|
||||
logger.debug("Default IR already exists: %s", default_ir_path)
|
||||
|
||||
# Generate pass-through NAM model if it doesn't exist
|
||||
default_nam_path = _DEFAULT_MODELS_DIR / "pass_through.nam"
|
||||
if not default_nam_path.exists():
|
||||
generate_passthrough_nam(default_nam_path)
|
||||
else:
|
||||
logger.debug("Default NAM model already exists: %s", default_nam_path)
|
||||
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to ensure defaults exist: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── Standalone CLI ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
ok = ensure_defaults_exist()
|
||||
print(f"Defaults {'OK' if ok else 'FAILED'}")
|
||||
print(f" IRs dir: {get_default_irs_dir()}")
|
||||
print(f" Models dir: {get_default_models_dir()}")
|
||||
if ok:
|
||||
print(f" IR file: {'EXISTS' if (get_default_irs_dir() / 'default_ir.wav').exists() else 'MISSING'}")
|
||||
print(f" NAM file: {'EXISTS' if (get_default_models_dir() / 'pass_through.nam').exists() else 'MISSING'}")
|
||||
+15
-1
@@ -1004,7 +1004,21 @@ class WebServer:
|
||||
ir = self.deps.ir_loader
|
||||
|
||||
if not pm:
|
||||
return {"connected": False}
|
||||
return {
|
||||
"connected": False,
|
||||
"current_preset": None,
|
||||
"bypass": False,
|
||||
"tuner_enabled": False,
|
||||
"master_volume": 0.8,
|
||||
"routing_mode": "mono",
|
||||
"routing_breakpoint": 7,
|
||||
"nam_loaded": False,
|
||||
"nam_model": None,
|
||||
"ir_loaded": False,
|
||||
"ir_name": None,
|
||||
"wifi": {},
|
||||
"bluetooth": {},
|
||||
}
|
||||
|
||||
try:
|
||||
bank = pm.current_bank
|
||||
|
||||
Reference in New Issue
Block a user