fix: pass config+config_path to WebServerDeps so audio profile persists to disk; add boot-time period/rate override loading from config.yaml

This commit is contained in:
2026-06-14 12:39:10 -04:00
parent f41ea6a9a0
commit 1991dc6032
71 changed files with 1604 additions and 13170 deletions
+223 -44
View File
@@ -42,6 +42,7 @@ from ..system.tonedownload import (
format_size,
)
from ..system.audio import AudioSystem, JackAudioClient, LATENCY_PROFILES
from ..system.config import save_config
logger = logging.getLogger(__name__)
@@ -207,6 +208,10 @@ class WebServerDeps:
# JACK audio client (needs restart when JACK restarts)
jack_audio: Optional[JackAudioClient] = None
# Full config dict (for saving profile/rate/period changes to disk)
config: Optional[dict] = None
config_path: Optional[Path] = None
@property
def is_connected(self) -> bool:
"""True if we're wired to a live pedal instance (any channel)."""
@@ -394,11 +399,21 @@ class WebServer:
"chain": [
{
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
"nam_model_path": b.nam_model_path,
"nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "",
"ir_file_path": b.ir_file_path,
"ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "",
"name": (
Path(b.nam_model_path).stem.replace("_", " ")
if b.nam_model_path
else Path(b.ir_file_path).stem.replace("_", " ")
if b.ir_file_path
else b.fx_type.value.replace("_", " ").title()
),
}
for b in p.chain
],
@@ -429,11 +444,21 @@ class WebServer:
"chain": [
{
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
"nam_model_path": b.nam_model_path,
"nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "",
"ir_file_path": b.ir_file_path,
"ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "",
"name": (
Path(b.nam_model_path).stem.replace("_", " ")
if b.nam_model_path
else Path(b.ir_file_path).stem.replace("_", " ")
if b.ir_file_path
else b.fx_type.value.replace("_", " ").title()
),
}
for b in p.chain
],
@@ -535,6 +560,7 @@ class WebServer:
for b in preset.chain:
chain_data.append({
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
@@ -562,6 +588,7 @@ class WebServer:
for b in snap.chain:
chain.append({
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
@@ -721,6 +748,7 @@ class WebServer:
"chain": [
{
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
@@ -737,13 +765,13 @@ class WebServer:
if not pl:
raise HTTPException(status_code=503)
if data and "bypass" in data:
pl._bypassed = bool(data["bypass"])
pl.bypassed = bool(data["bypass"])
else:
pl._bypassed = not pl._bypassed
pl.bypassed = not pl.bypassed
await self._manager.broadcast({
"type": "bypass_changed",
"channel": channel,
"bypass": pl._bypassed,
"bypass": pl.bypassed,
})
return {"ok": True, "bypass": pl._bypassed}
@@ -790,6 +818,12 @@ class WebServer:
raise HTTPException(status_code=503)
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
pl._master_volume = vol
import asyncio
asyncio.ensure_future(self._manager.broadcast({
"type": "volume_changed",
"channel": channel,
"volume": vol,
}))
return {"ok": True, "volume": vol, "channel": channel}
# ── POST alias for volume (UI uses POST, server had PUT) ──
@@ -801,6 +835,12 @@ class WebServer:
raise HTTPException(status_code=503)
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
pl._master_volume = vol
import asyncio
asyncio.ensure_future(self._manager.broadcast({
"type": "volume_changed",
"channel": channel,
"volume": vol,
}))
return {"ok": True, "volume": vol, "channel": channel}
# ── Toggle bypass alias (UI calls /api/bypass/toggle) ─────
@@ -811,40 +851,48 @@ class WebServer:
if not pl:
raise HTTPException(status_code=503)
if data and "bypass" in data:
pl._bypassed = bool(data["bypass"])
pl.bypassed = bool(data["bypass"])
else:
pl._bypassed = not pl._bypassed
pl.bypassed = not pl.bypassed
await self._manager.broadcast({
"type": "bypass_changed",
"channel": channel,
"bypass": pl._bypassed,
"bypass": pl.bypassed,
})
return {"ok": True, "bypass": pl._bypassed}
# ── Block toggle (UI: PATCH /api/blocks {id, enabled}) ──
@app.patch("/api/blocks")
async def toggle_block(data: dict, channel: str = "guitar"):
"""Enable/disable a block by its fx_type id — sets both enabled and bypass."""
"""Enable/disable a block by block_id or fx_type id — sets both enabled and bypass."""
from ..presets.types import Channel
pm, pl, nam, ir = self._channel_deps(channel)
if not pm:
raise HTTPException(status_code=503)
block_id = data.get("id")
# Support both block_id (preferred) and id (fx_type, backward compat)
block_id = data.get("block_id") or data.get("id")
enabled = data.get("enabled")
if not block_id or enabled is None:
raise HTTPException(status_code=400, detail="Missing 'id' or 'enabled'")
raise HTTPException(status_code=400, detail="Missing 'block_id'/'id' or 'enabled'")
try:
bank = pm.current_bank
program = pm.current_program
preset = pm.load(bank, program, channel=Channel(channel))
for b in preset.chain:
if b.fx_type.value == block_id:
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) # <-- added: sync bypass with 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)
await self._manager.broadcast({
"type": "block_toggled",
"channel": channel,
"id": block_id,
"enabled": b.enabled,
"bypass": b.bypass,
})
return {"ok": True, "id": block_id, "enabled": b.enabled, "bypass": b.bypass}
raise HTTPException(status_code=404, detail=f"Block '{block_id}' not found")
except Exception as e:
@@ -853,12 +901,13 @@ class WebServer:
# ── Block param update (UI: PATCH /api/block {id, ...params}) ──
@app.patch("/api/block")
async def update_block_params(data: dict, channel: str = "guitar"):
"""Update parameters for a block by its fx_type id."""
"""Update parameters for a block by block_id or fx_type id."""
from ..presets.types import Channel, FXBlock
pm, pl, nam, ir = self._channel_deps(channel)
if not pm:
raise HTTPException(status_code=503)
block_id = data.get("id")
# Support both block_id (preferred) and id (fx_type, backward compat)
block_id = data.get("block_id") or data.get("id")
if not block_id:
raise HTTPException(status_code=400, detail="Missing 'id'")
try:
@@ -866,7 +915,7 @@ class WebServer:
program = pm.current_program
preset = pm.load(bank, program, channel=Channel(channel))
for b in preset.chain:
if b.fx_type.value == block_id:
if b.block_id == block_id or (not data.get("block_id") and b.fx_type.value == block_id):
# Update params from request body (skip 'id' key)
for key, val in data.items():
if key == "id":
@@ -874,8 +923,10 @@ class WebServer:
if key in ("nam_model_path", "ir_file_path", "bypass", "enabled", "subtype"):
if key == "bypass":
b.bypass = bool(val)
b.enabled = not b.bypass # sync: bypass=true → enabled=false
elif key == "enabled":
b.enabled = bool(val)
b.bypass = not b.enabled # sync: enabled=false → bypass=true
elif key == "subtype":
b.subtype = str(val)
else:
@@ -886,6 +937,14 @@ class WebServer:
pm.save(preset, channel=Channel(channel))
if pl:
pl.load_preset(preset)
await self._manager.broadcast({
"type": "block_params_changed",
"channel": channel,
"id": block_id,
"params": dict(b.params),
"bypass": b.bypass,
"enabled": b.enabled,
})
return {"ok": True, "id": block_id, "params": dict(b.params)}
raise HTTPException(status_code=404, detail=f"Block '{block_id}' not found")
except Exception as e:
@@ -1015,6 +1074,10 @@ class WebServer:
if not nam:
raise HTTPException(status_code=503)
nam.unload()
await self._manager.broadcast({
"type": "model_unloaded",
"channel": channel,
})
return {"ok": True}
@app.get("/api/irs")
@@ -1064,6 +1127,10 @@ class WebServer:
if not ir:
raise HTTPException(status_code=503)
ir.unload()
await self._manager.broadcast({
"type": "ir_unloaded",
"channel": channel,
})
return {"ok": True}
# ── File upload endpoints ─────────────────────────────────
@@ -1321,61 +1388,148 @@ class WebServer:
@app.post("/api/audio/profile")
async def set_audio_profile(data: dict):
"""Switch JACK latency profile (restarts JACK server).
"""Switch JACK latency profile and/or set custom period/rate.
Body: { "profile": "standard" | "low" }
Body — at least one of:
{ "profile": "stable" } → switch to a named profile
{ "period": 256, "rate": 48000 } → custom settings
{ "profile": "stable", → named profile with rate override
"rate": 44100 }
The switch takes effect immediately by restarting JACK with the
new period / nperiods / priority settings.
Changes are saved to ~/.pedal/config.yaml so they survive reboot.
The switch restarts JACK (brief audio dropout ~1s).
"""
profile_key = data.get("profile", "")
if profile_key not in LATENCY_PROFILES:
audio_sys = self.deps.audio_system
if not audio_sys:
raise HTTPException(status_code=503, detail="Audio system not available")
profile_key = data.get("profile")
custom_period = data.get("period")
custom_rate = data.get("rate")
# ── Resolve target profile ───────────────────────────────
if profile_key and profile_key not in LATENCY_PROFILES and profile_key != "custom":
raise HTTPException(
status_code=400,
detail=f"Unknown profile: {profile_key!r}. Options: {list(LATENCY_PROFILES.keys())}",
)
audio_sys = self.deps.audio_system
if not audio_sys:
raise HTTPException(status_code=503, detail="Audio system not available")
if profile_key and profile_key in LATENCY_PROFILES:
# Named profile — start from profile defaults
target_profile = dict(LATENCY_PROFILES[profile_key])
else:
# Custom — use current as base, or fall back to stable
current = audio_sys.config.latency_profile
target_profile = dict(current)
# Apply overrides
if custom_period is not None:
if custom_period not in (16, 32, 64, 128, 256, 512, 1024, 2048):
raise HTTPException(status_code=400, detail=f"Invalid period: {custom_period}. Must be power of 2 (16-2048)")
target_profile["period"] = custom_period
if custom_rate is not None:
if custom_rate not in (44100, 48000, 96000, 192000):
raise HTTPException(status_code=400, detail=f"Invalid rate: {custom_rate}. Must be 44100, 48000, 96000, or 192000")
target_profile["rate"] = custom_rate
# Build effective profile name
if profile_key and profile_key in LATENCY_PROFILES:
if custom_period is not None or custom_rate is not None:
effective_key = f"{profile_key}_custom"
else:
effective_key = profile_key
else:
effective_key = "custom"
old_key = audio_sys.config.profile
if old_key == profile_key:
return {"ok": True, "profile": profile_key, "restarted": False}
# Apply new profile
audio_sys.config.profile = profile_key
profile = audio_sys.config.latency_profile
logger.info("Switching audio profile: %s%s (period=%d, nperiods=%d)",
old_key, profile_key, profile["period"], profile["nperiods"])
# Stop JACK audio client first (holds a live JACK C client)
# Check if anything actually changed
cur_profile = audio_sys.config.latency_profile
if (audio_sys.config.profile == effective_key
and cur_profile["period"] == target_profile["period"]
and cur_profile["rate"] == target_profile["rate"]):
return {"ok": True, "profile": effective_key, "restarted": False}
# ── Apply new profile on the fly ─────────────────────────
# Update AudioConfig — store named overrides inline in profile key
audio_sys.config.profile = effective_key
logger.info(
"Switching audio: %s%s (period=%d, rate=%d)",
old_key, effective_key,
target_profile["period"], target_profile["rate"],
)
# Stop JACK audio client first
jack_client = self.deps.jack_audio
if jack_client:
jack_client.stop()
# Restart JACK server with new parameters
ok = audio_sys.restart_jack(timeout=10)
# Update LATENCY_PROFILES with custom entry so latency_profile resolves it
LATENCY_PROFILES[effective_key] = target_profile
# ── Kill JACK server FIRST (breaks any client deadlock) ──
# This interrupts the audio callback thread, releasing any lock
# that jack_client.stop() would need.
try:
audio_sys.stop_jack()
except Exception:
logger.warning("stop_jack() failed during profile switch")
if jack_client:
try:
jack_client.stop()
except Exception:
logger.warning("jack_client.stop() failed (expected if JACK was mid-callback)")
# Start JACK with new parameters
ok = audio_sys.start_jack(timeout=10)
if not ok:
# Rollback on failure
audio_sys.config.profile = old_key
logger.warning("JACK restart failed — attempting rollback to %s", old_key)
audio_sys.start_jack(timeout=10)
if jack_client:
jack_client.start()
raise HTTPException(status_code=500, detail=f"Failed to start JACK with profile {profile_key!r} — rolled back to {old_key!r}")
try:
jack_client.start()
except Exception:
pass
raise HTTPException(
status_code=500,
detail=f"Failed to start JACK with period={target_profile['period']}, rate={target_profile['rate']} — rolled back",
)
# Restart JACK audio client
if jack_client:
jack_client.start()
# Sync NAM engine block size with new period
try:
jack_client.start()
except Exception as e:
logger.warning("jack_client.start() failed after profile switch: %s", e)
# Sync NAM engine block size
nam_host = self.deps.nam_host
if nam_host and hasattr(nam_host, 'set_block_size'):
nam_host.set_block_size(profile["period"])
nam_host.set_block_size(target_profile["period"])
# ── Persist to config.yaml ──────────────────────────────
if self.deps.config is not None:
audio_cfg = self.deps.config.setdefault("audio", {})
audio_cfg["profile"] = effective_key
# Store custom period/rate in config too
audio_cfg["period"] = target_profile["period"]
audio_cfg["rate"] = target_profile["rate"]
save_config(self.deps.config, self.deps.config_path)
await self._manager.broadcast({
"type": "audio_profile_changed",
"profile": profile_key,
"period": profile["period"],
"nperiods": profile["nperiods"],
"profile": effective_key,
"period": target_profile["period"],
"rate": target_profile["rate"],
})
return {
"ok": True,
"profile": profile_key,
"period": profile["period"],
"nperiods": profile["nperiods"],
"profile": effective_key,
"period": target_profile["period"],
"rate": target_profile["rate"],
"restarted": True,
}
@@ -1853,6 +2007,31 @@ class WebServer:
"nam_model": current_model_name,
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
"ir_name": current_ir_name,
"blocks": (
[
{
"fx_type": b.fx_type.value,
"block_id": b.block_id,
"enabled": b.enabled,
"bypass": b.bypass,
"params": dict(b.params),
"nam_model_path": b.nam_model_path,
"nam_model_name": Path(b.nam_model_path).stem if b.nam_model_path else "",
"ir_file_path": b.ir_file_path,
"ir_file_name": Path(b.ir_file_path).stem if b.ir_file_path else "",
"name": (
Path(b.nam_model_path).stem.replace("_", " ")
if b.nam_model_path
else Path(b.ir_file_path).stem.replace("_", " ")
if b.ir_file_path
else b.fx_type.value.replace("_", " ").title()
),
}
for b in (preset.chain if preset else [])
]
if preset
else []
),
"channel_mode": self._channel_mgr.mode,
# Audio levels from pipeline (RMS float32 normalized 0.0-1.0, scaled to 0-100)
"input_level": (