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
+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