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:
+223
-44
@@ -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": (
|
||||
|
||||
Reference in New Issue
Block a user