fix: code review batch 2 — multi-channel, thread safety, DSP reset, MIDI, hotspot, NAM, docs/CI, code quality
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-17 22:38:24 -04:00
parent c65e4816c4
commit 2558306e78
23 changed files with 631 additions and 125 deletions
+170 -55
View File
@@ -17,8 +17,9 @@ from __future__ import annotations
import json
import logging
import os
import threading
import time
import warnings
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
@@ -101,6 +102,48 @@ def _build_linear(config: dict, weights: list) -> "torch.nn.Module":
return LinearNAM(gain, bias)
def _validate_wavenet_arch(config: dict) -> None:
"""Validate WaveNet config is standard A2 architecture.
Raises ValueError with a clear message if unsupported features
(FiLM, head1x1, multi-array) are detected — prevents silent garbage
output from a corrupted model load.
Checks performed:
1. condition_size > 1 — indicates FiLM conditioning (extra weight tensors)
2. head1x1 key in layer array — extra 1x1 head conv not in A2 format
3. Multiple entries in layers array — multi-array not supported
"""
layers_cfg = config.get("layers", [])
if not layers_cfg:
raise ValueError("WaveNet config has no layers")
la = layers_cfg[0]
# FiLM detection: condition_size > 1 means conditioning inputs
# that require FiLM-specific weight tensors the A2 builder doesn't create
condition_size = la.get("condition_size", 1)
if condition_size > 1:
raise ValueError(
f"FiLM architecture (condition_size={condition_size}) is not supported. "
"Only standard A2 WaveNet without FiLM conditioning is supported."
)
# head1x1 detection: some models export an extra 1x1 conv in the head
if "head1x1" in la:
raise ValueError(
"head1x1 architecture detected — not supported. "
"Only standard A2 WaveNet head is supported."
)
# Multiple layer arrays are not supported
if len(layers_cfg) > 1:
raise ValueError(
f"Multi-array WaveNet ({len(layers_cfg)} layer arrays) is not supported. "
"Only single-array A2 WaveNet is supported."
)
def _build_wavenet(config: dict, weights: list) -> "torch.nn.Module":
"""Build a WaveNet NAM model from config and flat weight array.
@@ -130,6 +173,9 @@ def _build_wavenet(config: dict, weights: list) -> "torch.nn.Module":
if not layers_cfg:
raise ValueError("WaveNet config has no layers")
# ── Validate architecture is A2-compatible before building ─────────
_validate_wavenet_arch(config)
# ── Extract layer array config (we use first/only layer array) ─────
la = layers_cfg[0] # A2 models have single layer array
input_size = la.get("input_size", 1)
@@ -346,12 +392,14 @@ def _import_wavenet_weights(model: "torch.nn.Module", weights: list) -> None:
model.head_scale = float(w[i])
i += 1
# Verify we consumed all weights
# Verify we consumed all weights — mismatch means unsupported arch
if i != len(w):
warnings.warn(
f"NAM weight import consumed {i}/{len(w)} weights. "
raise ValueError(
f"NAM weight import consumed {i}/{len(w)} weights "
f"(expected {len(w)}, got {i}). "
f"Model has {sum(p.numel() for p in model.parameters())} params. "
f"Check weight order matches architecture."
f"Weight order does not match architecture — likely FiLM, head1x1, "
f"or other non-A2 format."
)
@@ -454,6 +502,10 @@ class NAMHost:
self._torch = None # lazy import
self._torch_device = None # resolved device object
# Thread-safety lock — protects _inference_model from concurrent
# access by the audio thread (process) and control thread (load/unload)
self._lock = threading.Lock()
# Timing stats
self._timing_samples: list[float] = []
@@ -462,8 +514,13 @@ class NAMHost:
self._crossfade_buf: Optional[np.ndarray] = None
self._crossfade_pos: int = 0
# Simple model cache (path -> model instance)
self._model_cache: dict[str, object] = {}
# Model cache keyed by (path, mtime_ns) for automatic
# invalidation on re-upload (same filename, different content)
self._model_cache: dict[tuple[str, int], object] = {}
self._lock = threading.Lock()
# Last error message (for UI surfacing on failed load)
self._last_error: str = ""
self._models_dir.mkdir(parents=True, exist_ok=True)
@@ -497,16 +554,44 @@ class NAMHost:
return 0.0
return float(np.mean(self._timing_samples[-50:]))
@property
def last_error(self) -> str:
"""Last model-load error message (empty string if last load succeeded)."""
return self._last_error
# ── Cache management ───────────────────────────────────────────
def evict_cache(self, model_path: str) -> None:
"""Remove a model from cache so it's reloaded on next use.
Called automatically by the upload endpoint to invalidate the
cached representation when a file is replaced.
"""
path = str(Path(model_path))
with self._lock:
keys_to_remove = [
k for k in self._model_cache
if isinstance(k, tuple) and k[0] == path
]
for k in keys_to_remove:
del self._model_cache[k]
if keys_to_remove:
logger.debug("Evicted %d cache entries for %s", len(keys_to_remove), path)
# ── Model loading ──────────────────────────────────────────────
def load_model(self, model_path: str) -> bool:
"""Load a ``.nam`` model file and build its inference model.
Returns ``True`` on success, ``False`` on error.
Returns ``True`` on success, ``False`` on error. On failure,
``self.last_error`` contains the error message.
"""
self._last_error = ""
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)
msg = f"Model not found or invalid: {model_path}"
logger.error(msg)
self._last_error = msg
return False
# Read file
@@ -514,56 +599,68 @@ class NAMHost:
with open(path, "r") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.error("Failed to parse .nam file: %s", e)
msg = f"Failed to parse .nam file: {e}"
logger.error(msg)
self._last_error = msg
return False
architecture = data.get("architecture", "Linear")
config = data.get("config", {})
size_mb = path.stat().st_size / (1024 * 1024)
# Build metadata
self._loaded_model = NAMModel(
name=path.stem,
path=str(path),
size_mb=size_mb,
architecture=architecture,
receptive_field=config.get("receptive_field", 0),
)
# Build PyTorch inference model
model_ok = self._build_inference(data)
# Build PyTorch inference model (includes cache check and architecture validation)
model_ok = self._build_inference(data, model_path=model_path)
if model_ok:
architecture = data.get("architecture", "Linear")
config = data.get("config", {})
size_mb = path.stat().st_size / (1024 * 1024)
self._loaded_model = NAMModel(
name=path.stem,
path=str(path),
size_mb=size_mb,
architecture=architecture,
receptive_field=config.get("receptive_field", 0),
)
# Store param count in metadata
params = sum(p.numel() for p in self._inference_model.parameters())
self._loaded_model.params_k = round(params / 1000, 1)
logger.info(
"Loaded NAM model: %s (%.1f MB, %s, %s family, rf=%d, params=%.1fK)",
self._loaded_model.name,
size_mb,
architecture,
self._loaded_model.family,
self._loaded_model.receptive_field,
self._loaded_model.params_k,
)
return True
logger.info(
"Loaded NAM model: %s (%.1f MB, %s, %s family, rf=%d, params=%.1fK)",
self._loaded_model.name, size_mb, architecture,
self._loaded_model.family, self._loaded_model.receptive_field,
self._loaded_model.params_k,
)
else:
self._loaded_model = None
logger.warning("Failed to load model: %s", self._last_error)
def _build_inference(self, data: dict) -> bool:
return model_ok
def _build_inference(self, data: dict, model_path: str = "") -> bool:
"""Instantiate a PyTorch model from a NAM config dict.
Uses our lightweight in-process builder that handles
SlimmableContainer (A2), WaveNet, and Linear architectures.
The cache is keyed by (model_path, mtime_ns) so re-uploading
with the same filename but different content naturally invalidates
the cache entry.
"""
self._import_torch()
path = data.get("path", "")
cache_key = str(path)
# Build cache key from path + mtime for invalidation on re-upload
cache_key: tuple[str, int] | None = None
if model_path:
try:
mtime_ns = os.path.getmtime(model_path)
cache_key = (model_path, int(mtime_ns))
except OSError:
cache_key = None
# Check cache first
if cache_key and cache_key in self._model_cache:
self._inference_model = self._model_cache[cache_key]
self._inference_model.eval()
if cache_key is not None and cache_key in self._model_cache:
with self._lock:
self._inference_model = self._model_cache[cache_key]
self._inference_model.eval()
return True
try:
@@ -575,28 +672,38 @@ class NAMHost:
model = model.to(self._torch_device)
# Cache it
if cache_key:
self._model_cache[cache_key] = model
if cache_key is not None:
with self._lock:
self._model_cache[cache_key] = model
self._inference_model = model
# Swap reference under lock so process() sees a consistent model
with self._lock:
self._inference_model = model
return True
except ImportError as exc:
logger.warning("Required package not installed; inference unavailable: %s", exc)
self._inference_model = None
msg = f"Required package not installed; inference unavailable: {exc}"
logger.warning(msg)
self._last_error = str(exc)
with self._lock:
self._inference_model = None
return False
except Exception as exc:
logger.warning("Failed to build inference model: %s", exc)
self._inference_model = None
msg = f"Failed to build inference model: {exc}"
logger.warning(msg)
self._last_error = str(exc)
with self._lock:
self._inference_model = None
return False
def unload(self) -> None:
"""Unload the current model and free resources."""
self._loaded_model = None
if self._inference_model is not None:
self._inference_model = self._inference_model.to("cpu")
del self._inference_model
self._inference_model = None
with self._lock:
self._loaded_model = None
if self._inference_model is not None:
self._inference_model = self._inference_model.to("cpu")
del self._inference_model
self._inference_model = None
self._torch = None
self._torch_device = None
self._timing_samples.clear()
@@ -627,6 +734,10 @@ class NAMHost:
def process(self, audio_block: np.ndarray) -> np.ndarray:
"""Run a block of audio through the loaded NAM model.
Thread-safe: captures a local reference to ``_inference_model``
under ``_lock`` so the control thread can safely swap models
without racing with the audio callback.
Handles 1-D (``(N,)``) and 2-D (``(1, N)``) float32 input.
If no model is loaded, passes audio through unchanged.
@@ -640,7 +751,11 @@ class NAMHost:
np.ndarray
Processed audio, same shape.
"""
if self._inference_model is None:
# Capture model reference atomically — the model instance stays alive
# through this local ref even if the control thread swaps it out.
with self._lock:
model = self._inference_model
if model is None:
# Pass-through when no model is loaded
return audio_block
@@ -659,7 +774,7 @@ class NAMHost:
if str(self._torch_device) != "cpu":
x = x.to(self._torch_device)
y = self._inference_model(x)
y = model(x)
# Squeeze back
if was_1d:
+64 -23
View File
@@ -13,6 +13,7 @@ Usage:
from __future__ import annotations
import logging
import threading
from pathlib import Path
from typing import Optional
@@ -56,6 +57,10 @@ class NAMEngineRouter:
self._create_engine()
# Thread-safety lock — protects _engine and _loaded_path from
# concurrent access between audio callback and set_engine()
self._lock = threading.Lock()
# ── Engine lifecycle ────────────────────────────────────────────
def _create_engine(self) -> None:
@@ -79,6 +84,9 @@ class NAMEngineRouter:
def set_engine(self, mode: str) -> bool:
"""Switch engine type at runtime.
Thread-safe: holds ``_lock`` during the engine swap to prevent
the audio callback from seeing a half-destroyed engine.
Unloads the current model and reloads it in the new engine.
Returns True on success, False if the model couldn't be reloaded.
"""
@@ -87,14 +95,18 @@ class NAMEngineRouter:
if mode not in self.ENGINE_MODES:
raise ValueError(f"engine_mode must be one of {self.ENGINE_MODES}, got {mode!r}")
# Save currently loaded model path
old_path = self._loaded_path
self.unload()
with self._lock:
# Save currently loaded model path
old_path = self._loaded_path
if self._engine is not None:
self._engine.unload()
self._engine = None
self._loaded_path = None
self._engine_mode = mode
self._create_engine()
self._engine_mode = mode
self._create_engine()
# Reload current model if one was loaded
# Reload current model if one was loaded (outside lock — may do I/O)
if old_path:
logger.info("Reloading model %s in new engine %s", old_path, mode)
return self.load_model(old_path)
@@ -109,11 +121,13 @@ class NAMEngineRouter:
@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):
return getattr(self._engine, 'current_model', None) if self._engine else None
with self._lock:
return getattr(self._engine, 'current_model', None) if self._engine else None
@property
def avg_inference_ms(self) -> float:
@@ -131,43 +145,70 @@ class NAMEngineRouter:
def _crossfade_buf(self):
"""For pipeline crossfade compatibility.
PyTorch NAMHost has this natively; FastNAMHost has None."""
if hasattr(self._engine, '_crossfade_buf'):
return self._engine._crossfade_buf
with self._lock:
if hasattr(self._engine, '_crossfade_buf'):
return self._engine._crossfade_buf
return None
def apply_crossfade(self, buf: np.ndarray) -> np.ndarray:
if hasattr(self._engine, 'apply_crossfade'):
return self._engine.apply_crossfade(buf)
with self._lock:
engine = self._engine
if engine is not None and hasattr(engine, 'apply_crossfade'):
return engine.apply_crossfade(buf)
return buf
# ── Model loading ───────────────────────────────────────────────
def load_model(self, model_path: str) -> bool:
if self._engine is None:
return False
ok = self._engine.load_model(model_path)
if ok:
self._loaded_path = model_path
return ok
with self._lock:
if self._engine is None:
return False
ok = self._engine.load_model(model_path)
if ok:
self._loaded_path = model_path
return ok
def unload(self) -> None:
if self._engine is not None:
self._engine.unload()
self._loaded_path = None
with self._lock:
if self._engine is not None:
self._engine.unload()
self._loaded_path = None
def set_block_size(self, block_size: int) -> None:
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)
@property
def last_error(self) -> str:
"""Last model-load error from the active engine."""
with self._lock:
if self._engine is not None and hasattr(self._engine, 'last_error'):
return self._engine.last_error
return ""
def evict_cache(self, model_path: str) -> None:
"""Evict model from engine's model cache."""
with self._lock:
if self._engine is not None and hasattr(self._engine, 'evict_cache'):
self._engine.evict_cache(model_path)
def warm_up(self) -> None:
if self._engine is not None and hasattr(self._engine, 'warm_up'):
self._engine.warm_up(block_size=self._block_size)
def process(self, audio_block: np.ndarray) -> np.ndarray:
if self._engine is None:
"""Run inference through the current engine.
Thread-safe: captures the engine reference under ``_lock`` so
``set_engine()`` can swap engines without racing with the audio
callback.
"""
with self._lock:
engine = self._engine
if engine is None:
return audio_block
return self._engine.process(audio_block)
return engine.process(audio_block)
# ── Model discovery ─────────────────────────────────────────────
+40 -2
View File
@@ -28,8 +28,9 @@ from ..presets.types import FXBlock, FXType, Preset
logger = logging.getLogger(__name__)
BLOCK_SIZE = 256 # Samples per JACK callback
SAMPLE_RATE = 48000 # Standard guitar audio rate
# BLOCK_SIZE and SAMPLE_RATE are now instance attributes on AudioPipeline
# (self._block_size, self._sample_rate) — set at construction time.
# Do not re-introduce module-level constants; use the instance properties.
# ── Biquad coefficient helpers ─────────────────────────────────────
@@ -379,6 +380,7 @@ class AudioPipeline:
for block in preset.chain:
entry = {
"block_id": block.block_id,
"fx_type": block.fx_type,
"enabled": block.enabled,
"bypass": block.bypass,
@@ -410,6 +412,42 @@ class AudioPipeline:
preset.name, len(self._chain),
self._routing_mode, self._routing_breakpoint)
def set_block_in_place(self, block_id: str, *,
params: dict | None = None,
enabled: bool | None = None,
bypass: bool | None = None) -> bool:
"""Update a block's params / enabled / bypass in-place without clearing DSP state.
Unlike :meth:`load_preset`, this mutates ``self._chain[idx]`` directly
while preserving ``self._state`` and ``self._coeffs``. Reverb tails,
delay buffers, and filter states continue uninterrupted.
Must be called after the corresponding ``FXBlock`` on the preset object
has already been updated. This method only needs to push the change
into the real-time chain.
Args:
block_id: The ``block_id`` of the target block.
params: Subset of params to update (or ``None`` to skip).
enabled: New enabled state (or ``None`` to skip).
bypass: New bypass state (or ``None`` to skip).
Returns:
``True`` if a matching block was found and updated, ``False`` otherwise.
"""
with self._lock:
for entry in self._chain:
if entry.get("block_id") == block_id:
if params is not None:
entry["params"].update(params)
if enabled is not None:
entry["enabled"] = bool(enabled)
if bypass is not None:
entry["bypass"] = bool(bypass)
return True
logger.warning("set_block_in_place: block_id=%r not found in chain", block_id)
return False
def process(self, audio_in: np.ndarray) -> np.ndarray:
"""Process a block of audio through the entire FX chain.
+27 -1
View File
@@ -141,9 +141,35 @@ class Preset:
"""Snapshot slots 1-8. Empty dict means no snapshots saved."""
def resolve_block_by_key(preset: Preset, param_key: str) -> Optional[tuple[FXBlock, str]]:
"""Resolve a ``param_key`` like ``"delay.feedback"`` or ``"<block_id>.feedback"``
to a specific ``(FXBlock, param_name)`` in the preset's chain.
Tries ``block_id`` match first (for multiple blocks of the same type),
then falls back to ``fx_type.value`` match. Returns ``None`` if no
block matches the key prefix or the key format is invalid.
This is the shared resolver used by both MIDI CC mapping
(``PedalApp._on_midi_cc``) and the REST API
(``WebServer.update_block_params``).
"""
parts = param_key.rsplit('.', 1)
if len(parts) != 2:
return None
prefix, param_name = parts
for b in preset.chain:
if b.block_id == prefix:
return (b, param_name)
for b in preset.chain:
if b.fx_type.value == prefix:
return (b, param_name)
return None
@dataclass
class Bank:
"""A bank of presets (typically 4 per bank)."""
name: str
number: int
presets: list[Preset] = field(default_factory=list)
presets: list[Preset] = field(default_factory=list)
+60 -5
View File
@@ -13,6 +13,8 @@ import json
import logging
import os
import re
import secrets
import string
import subprocess
import tempfile
import time
@@ -25,6 +27,7 @@ logger = logging.getLogger(__name__)
HOTSPOT_SCRIPT = Path(__file__).resolve().parent.parent.parent / "scripts" / "setup-wifi-ap.sh"
WPA_SUPPLICANT_CONF = Path("/etc/wpa_supplicant/wpa_supplicant.conf")
CONFIG_PATH = Path.home() / ".pedal" / "config.yaml"
# ── Capability detection ──────────────────────────────────────────────────────
@@ -356,6 +359,43 @@ def _wpa_connect(ssid: str, password: str) -> bool:
# ── Hotspot control ───────────────────────────────────────────────────────────
def get_hotspot_password() -> str:
"""Get the persisted hotspot password, generating a random one on first boot.
Looks up ``hotspot.password`` in ``~/.pedal/config.yaml``. If unset,
generates a cryptographically random 12-character alphanumeric password,
persists it, and returns it. This ensures each device gets a unique,
non-guessable password.
Returns:
The hotspot password (always at least 8 characters).
"""
try:
from src.system.config import load_config, save_config
cfg = load_config(CONFIG_PATH)
hotspot_cfg = cfg.setdefault("hotspot", {})
password = hotspot_cfg.get("password")
if password and len(password) >= 8:
return password
except Exception:
pass # fall through to generate a fresh one
# Generate a random 12-char alphanumeric password
alphabet = string.ascii_letters + string.digits
password = "".join(secrets.choice(alphabet) for _ in range(12))
# Try to persist
try:
from src.system.config import load_config, save_config
cfg = load_config(CONFIG_PATH)
cfg.setdefault("hotspot", {})["password"] = password
save_config(cfg, CONFIG_PATH)
except Exception:
logger.warning("Could not persist hotspot password to config")
return password
def _hotspot_status() -> dict[str, Any]:
"""Check if the WiFi hotspot is currently active."""
# Check if hostapd is running
@@ -366,7 +406,7 @@ def _hotspot_status() -> dict[str, Any]:
active = result.stdout.strip() == "active"
if not active:
return {"active": False, "ssid": None, "clients": 0, "ip": None}
return {"active": False, "ssid": None, "password": None, "clients": 0, "ip": None}
# Read SSID from config
ssid = None
@@ -386,16 +426,28 @@ def _hotspot_status() -> dict[str, Any]:
if iw_result.returncode == 0:
clients = len([l for l in iw_result.stdout.split("\n") if "Station" in l])
# Read password from config
password = None
try:
from src.system.config import load_config
cfg = load_config(CONFIG_PATH)
password = cfg.get("hotspot", {}).get("password")
except Exception:
pass
return {
"active": True,
"ssid": ssid or "Pi-Pedal",
"password": password,
"clients": clients,
"ip": "192.168.4.1",
}
def _hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> bool:
def _hotspot_enable(ssid: str = "Pi-Pedal", password: str | None = None) -> bool:
"""Enable WiFi hotspot mode by calling the setup script."""
if not password:
raise ValueError("hotspot password is required")
result = _run(
["sudo", "bash", str(HOTSPOT_SCRIPT), "--ssid", ssid, "--psk", password],
timeout=60, check=False,
@@ -578,16 +630,17 @@ def wifi_forget(ssid: str) -> dict[str, Any]:
def hotspot_status() -> dict[str, Any]:
"""Get the current hotspot status.
Returns dict with active (bool), ssid (str|None), clients (int), ip (str|None).
Returns dict with active (bool), ssid (str|None), password (str|None),
clients (int), ip (str|None).
"""
try:
return _hotspot_status()
except Exception as e:
logger.error("Hotspot status failed: %s", str(e))
return {"active": False, "ssid": None, "clients": 0, "ip": None}
return {"active": False, "ssid": None, "password": None, "clients": 0, "ip": None}
def hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> dict[str, Any]:
def hotspot_enable(ssid: str = "Pi-Pedal", password: str | None = None) -> dict[str, Any]:
"""Enable the WiFi hotspot.
Args:
@@ -597,6 +650,8 @@ def hotspot_enable(ssid: str = "Pi-Pedal", password: str = "pedal1234") -> dict[
Returns:
dict with ok (bool) and error (str|None) keys.
"""
if not password:
return {"ok": False, "error": "Password is required"}
if len(password) < 8:
return {"ok": False, "error": "Password must be at least 8 characters"}
try:
+5 -1
View File
@@ -52,6 +52,7 @@ class DisplayState:
tuner_cents: int = 0
param_name: str = ""
param_value: float = 0.0
hotspot_password: str = "" # Shown in footer when hotspot active
def _import_display_driver():
@@ -208,7 +209,10 @@ class DisplayController:
draw.text((MARGIN, fx_y), fx_line, fill=255, font=font)
# Footer — preset number or info
draw.text((MARGIN, FOOTER_Y), s.mode.upper(), fill=255, font=font)
footer_text = s.mode.upper()
if s.hotspot_password:
footer_text = f"AP:{s.hotspot_password}"
draw.text((MARGIN, FOOTER_Y), footer_text, fill=255, font=font)
def _render_tuner(self, draw) -> None:
"""Render tuner mode — note name + cents indicator."""
+80 -9
View File
@@ -18,6 +18,7 @@ import logging
import math
import mimetypes
import datetime
import threading
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pathlib import Path
@@ -340,6 +341,8 @@ class WebServer:
self._task: Optional[asyncio.Task] = None
self._tonedownload: Optional[Tone3000Client] = None
self._preset_write_locks: dict[str, asyncio.Lock] = {}
self._debounce_timers: dict[str, threading.Timer] = {}
self._pending_presets: dict[str, Any] = {}
self._needs_reboot: bool = False
self._app = self._build_app()
@@ -365,6 +368,51 @@ class WebServer:
self._preset_write_locks[key] = asyncio.Lock()
return self._preset_write_locks[key]
# ── Debounced preset save (write-behind) ─────────────────────────
def _debounced_save_preset(self, channel: str, bank: int, program: int,
preset: Any) -> None:
"""Save preset to disk with a 500 ms write-behind timer.
Each call cancels any pending save for the same ``(channel, bank,
program)``, then schedules a new one. This prevents a storm of
disk writes when the user drags a slider in the UI — only the
final value is persisted, 500 ms after the last tweak.
Args:
channel: Channel name (e.g. ``\"guitar\"``).
bank: Bank number.
program: Program number.
preset: The :class:`~src.presets.types.Preset` to persist.
"""
key = f"{channel}:{bank}:{program}"
# Cancel any pending timer
old = self._debounce_timers.pop(key, None)
if old is not None:
old.cancel()
# Hold a reference so GC doesn't collect it before the timer fires
self._pending_presets[key] = preset
def _flush() -> None:
try:
pm, _pl, _nam, _ir = self._channel_deps(channel)
if pm is not None:
from ..presets.types import Channel
pm.save(preset, channel=Channel(channel))
logger.debug("Debounced save: ch=%s b=%d p=%d", channel, bank, program)
except Exception as exc:
logger.warning("Debounced preset save failed: %s", exc)
finally:
# Clean up the held reference on completion
self._pending_presets.pop(key, None)
t = threading.Timer(0.5, _flush)
t.daemon = True
t.start()
self._debounce_timers[key] = t
# ── App factory ─────────────────────────────────────────────────────
# ── Auth helpers ─────────────────────────────────────────────
@@ -979,10 +1027,15 @@ class WebServer:
if b.block_id == block_id or (not data.get("block_id") and b.fx_type.value == block_id):
b.enabled = bool(enabled)
b.bypass = not bool(enabled) # sync bypass with enabled
pm.save(preset, channel=Channel(channel))
# Reload pipeline if needed
if pl:
pl.load_preset(preset)
# In-place pipeline update — preserves DSP state
if pl is not None:
pl.set_block_in_place(
b.block_id,
enabled=b.enabled,
bypass=b.bypass,
)
# Deferred disk write — 500 ms debounce
self._debounced_save_preset(channel, bank, program, preset)
await self._manager.broadcast({
"type": "block_toggled",
"channel": channel,
@@ -1051,9 +1104,27 @@ class WebServer:
else:
# Safe: key already validated against schema
b.params[key] = float(val)
pm.save(preset, channel=Channel(channel))
if pl:
pl.load_preset(preset)
# In-place pipeline update — preserves DSP state
if pl is not None:
changed_params = {
k: float(v) for k, v in data.items()
if k not in ("id", "block_id", "nam_model_path", "ir_file_path", "bypass", "enabled", "subtype")
and k in valid_param_keys
}
changed_enabled = None
changed_bypass = None
if "enabled" in data:
changed_enabled = bool(data["enabled"])
if "bypass" in data:
changed_bypass = bool(data["bypass"])
pl.set_block_in_place(
b.block_id,
params=changed_params or None,
enabled=changed_enabled,
bypass=changed_bypass,
)
# Deferred disk write — 500 ms debounce
self._debounced_save_preset(channel, bank, program, preset)
await self._manager.broadcast({
"type": "block_params_changed",
"channel": channel,
@@ -1411,12 +1482,12 @@ class WebServer:
Body: {ssid: str (optional), password: str (optional)}
"""
from ..system.network import hotspot_enable
from ..system.network import hotspot_enable, get_hotspot_password
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, partial(hotspot_enable,
ssid=data.get("ssid", "Pi-Pedal"),
password=data.get("password", "pedal1234"),
password=data.get("password", get_hotspot_password()),
),
)
return result