v2 UI at root, instrument type selector, Gitea push
This commit is contained in:
+655
-46
@@ -42,6 +42,10 @@ from ..system.tonedownload import (
|
|||||||
format_size,
|
format_size,
|
||||||
)
|
)
|
||||||
from ..system.audio import AudioSystem, JackAudioClient, LATENCY_PROFILES
|
from ..system.audio import AudioSystem, JackAudioClient, LATENCY_PROFILES
|
||||||
|
from ..system.config import save_config
|
||||||
|
from ..ui.leds import LEDController
|
||||||
|
from ..ui.display import DisplayController
|
||||||
|
from ..midi.handler import MIDIHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -207,6 +211,15 @@ class WebServerDeps:
|
|||||||
# JACK audio client (needs restart when JACK restarts)
|
# JACK audio client (needs restart when JACK restarts)
|
||||||
jack_audio: Optional[JackAudioClient] = None
|
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
|
||||||
|
|
||||||
|
# Runtime subsystems (for live apply of settings changes)
|
||||||
|
leds: Optional[LEDController] = None
|
||||||
|
midi: Optional[MIDIHandler] = None
|
||||||
|
display: Optional[DisplayController] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""True if we're wired to a live pedal instance (any channel)."""
|
"""True if we're wired to a live pedal instance (any channel)."""
|
||||||
@@ -316,12 +329,25 @@ class WebServer:
|
|||||||
logger.info("Mounting React UI from %s at /ui", UI_DIST_DIR)
|
logger.info("Mounting React UI from %s at /ui", UI_DIST_DIR)
|
||||||
app.mount("/ui", StaticFiles(directory=str(UI_DIST_DIR), html=True), name="ui")
|
app.mount("/ui", StaticFiles(directory=str(UI_DIST_DIR), html=True), name="ui")
|
||||||
|
|
||||||
|
# Mount V2 React UI (if dist exists)
|
||||||
|
V2_DIST_DIR = Path(__file__).resolve().parent / "ui-v2-dist"
|
||||||
|
if V2_DIST_DIR.is_dir():
|
||||||
|
logger.info("Mounting V2 React UI from %s at /v2", V2_DIST_DIR)
|
||||||
|
app.mount("/v2", StaticFiles(directory=str(V2_DIST_DIR), html=True), name="ui-v2")
|
||||||
|
|
||||||
# ── HTML pages ──────────────────────────────────────────────
|
# ── HTML pages ──────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def root(request: Request):
|
async def root(request: Request):
|
||||||
"""Serve the React SPA (or legacy dashboard as fallback)."""
|
"""Serve the v2 UI (or React SPA / legacy dashboard as fallback)."""
|
||||||
# If we have a React build, serve it at /
|
# Prefer the v2 UI if available
|
||||||
|
v2_dir = Path(__file__).resolve().parent / "ui-v2-dist"
|
||||||
|
if v2_dir.is_dir():
|
||||||
|
v2_index = v2_dir / "index.html"
|
||||||
|
if v2_index.exists():
|
||||||
|
content = v2_index.read_text()
|
||||||
|
return HTMLResponse(content)
|
||||||
|
# Fallback: React build
|
||||||
if UI_DIST_DIR and UI_DIST_DIR.is_dir():
|
if UI_DIST_DIR and UI_DIST_DIR.is_dir():
|
||||||
index_html = UI_DIST_DIR / "index.html"
|
index_html = UI_DIST_DIR / "index.html"
|
||||||
if index_html.exists():
|
if index_html.exists():
|
||||||
@@ -394,11 +420,21 @@ class WebServer:
|
|||||||
"chain": [
|
"chain": [
|
||||||
{
|
{
|
||||||
"fx_type": b.fx_type.value,
|
"fx_type": b.fx_type.value,
|
||||||
|
"block_id": b.block_id,
|
||||||
"enabled": b.enabled,
|
"enabled": b.enabled,
|
||||||
"bypass": b.bypass,
|
"bypass": b.bypass,
|
||||||
"params": dict(b.params),
|
"params": dict(b.params),
|
||||||
"nam_model_path": b.nam_model_path,
|
"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_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
|
for b in p.chain
|
||||||
],
|
],
|
||||||
@@ -429,11 +465,21 @@ class WebServer:
|
|||||||
"chain": [
|
"chain": [
|
||||||
{
|
{
|
||||||
"fx_type": b.fx_type.value,
|
"fx_type": b.fx_type.value,
|
||||||
|
"block_id": b.block_id,
|
||||||
"enabled": b.enabled,
|
"enabled": b.enabled,
|
||||||
"bypass": b.bypass,
|
"bypass": b.bypass,
|
||||||
"params": dict(b.params),
|
"params": dict(b.params),
|
||||||
"nam_model_path": b.nam_model_path,
|
"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_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
|
for b in p.chain
|
||||||
],
|
],
|
||||||
@@ -535,6 +581,7 @@ class WebServer:
|
|||||||
for b in preset.chain:
|
for b in preset.chain:
|
||||||
chain_data.append({
|
chain_data.append({
|
||||||
"fx_type": b.fx_type.value,
|
"fx_type": b.fx_type.value,
|
||||||
|
"block_id": b.block_id,
|
||||||
"enabled": b.enabled,
|
"enabled": b.enabled,
|
||||||
"bypass": b.bypass,
|
"bypass": b.bypass,
|
||||||
"params": dict(b.params),
|
"params": dict(b.params),
|
||||||
@@ -562,6 +609,7 @@ class WebServer:
|
|||||||
for b in snap.chain:
|
for b in snap.chain:
|
||||||
chain.append({
|
chain.append({
|
||||||
"fx_type": b.fx_type.value,
|
"fx_type": b.fx_type.value,
|
||||||
|
"block_id": b.block_id,
|
||||||
"enabled": b.enabled,
|
"enabled": b.enabled,
|
||||||
"bypass": b.bypass,
|
"bypass": b.bypass,
|
||||||
"params": dict(b.params),
|
"params": dict(b.params),
|
||||||
@@ -721,6 +769,7 @@ class WebServer:
|
|||||||
"chain": [
|
"chain": [
|
||||||
{
|
{
|
||||||
"fx_type": b.fx_type.value,
|
"fx_type": b.fx_type.value,
|
||||||
|
"block_id": b.block_id,
|
||||||
"enabled": b.enabled,
|
"enabled": b.enabled,
|
||||||
"bypass": b.bypass,
|
"bypass": b.bypass,
|
||||||
"params": dict(b.params),
|
"params": dict(b.params),
|
||||||
@@ -737,13 +786,13 @@ class WebServer:
|
|||||||
if not pl:
|
if not pl:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
if data and "bypass" in data:
|
if data and "bypass" in data:
|
||||||
pl._bypassed = bool(data["bypass"])
|
pl.bypassed = bool(data["bypass"])
|
||||||
else:
|
else:
|
||||||
pl._bypassed = not pl._bypassed
|
pl.bypassed = not pl.bypassed
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "bypass_changed",
|
"type": "bypass_changed",
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
"bypass": pl._bypassed,
|
"bypass": pl.bypassed,
|
||||||
})
|
})
|
||||||
return {"ok": True, "bypass": pl._bypassed}
|
return {"ok": True, "bypass": pl._bypassed}
|
||||||
|
|
||||||
@@ -790,6 +839,12 @@ class WebServer:
|
|||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
|
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
|
||||||
pl._master_volume = vol
|
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}
|
return {"ok": True, "volume": vol, "channel": channel}
|
||||||
|
|
||||||
# ── POST alias for volume (UI uses POST, server had PUT) ──
|
# ── POST alias for volume (UI uses POST, server had PUT) ──
|
||||||
@@ -801,6 +856,12 @@ class WebServer:
|
|||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
|
vol = max(0.0, min(1.0, float(data.get("volume", 0.8))))
|
||||||
pl._master_volume = vol
|
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}
|
return {"ok": True, "volume": vol, "channel": channel}
|
||||||
|
|
||||||
# ── Toggle bypass alias (UI calls /api/bypass/toggle) ─────
|
# ── Toggle bypass alias (UI calls /api/bypass/toggle) ─────
|
||||||
@@ -811,40 +872,48 @@ class WebServer:
|
|||||||
if not pl:
|
if not pl:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
if data and "bypass" in data:
|
if data and "bypass" in data:
|
||||||
pl._bypassed = bool(data["bypass"])
|
pl.bypassed = bool(data["bypass"])
|
||||||
else:
|
else:
|
||||||
pl._bypassed = not pl._bypassed
|
pl.bypassed = not pl.bypassed
|
||||||
await self._manager.broadcast({
|
await self._manager.broadcast({
|
||||||
"type": "bypass_changed",
|
"type": "bypass_changed",
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
"bypass": pl._bypassed,
|
"bypass": pl.bypassed,
|
||||||
})
|
})
|
||||||
return {"ok": True, "bypass": pl._bypassed}
|
return {"ok": True, "bypass": pl._bypassed}
|
||||||
|
|
||||||
# ── Block toggle (UI: PATCH /api/blocks {id, enabled}) ──
|
# ── Block toggle (UI: PATCH /api/blocks {id, enabled}) ──
|
||||||
@app.patch("/api/blocks")
|
@app.patch("/api/blocks")
|
||||||
async def toggle_block(data: dict, channel: str = "guitar"):
|
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
|
from ..presets.types import Channel
|
||||||
pm, pl, nam, ir = self._channel_deps(channel)
|
pm, pl, nam, ir = self._channel_deps(channel)
|
||||||
if not pm:
|
if not pm:
|
||||||
raise HTTPException(status_code=503)
|
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")
|
enabled = data.get("enabled")
|
||||||
if not block_id or enabled is None:
|
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:
|
try:
|
||||||
bank = pm.current_bank
|
bank = pm.current_bank
|
||||||
program = pm.current_program
|
program = pm.current_program
|
||||||
preset = pm.load(bank, program, channel=Channel(channel))
|
preset = pm.load(bank, program, channel=Channel(channel))
|
||||||
for b in preset.chain:
|
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.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))
|
pm.save(preset, channel=Channel(channel))
|
||||||
# Reload pipeline if needed
|
# Reload pipeline if needed
|
||||||
if pl:
|
if pl:
|
||||||
pl.load_preset(preset)
|
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}
|
return {"ok": True, "id": block_id, "enabled": b.enabled, "bypass": b.bypass}
|
||||||
raise HTTPException(status_code=404, detail=f"Block '{block_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Block '{block_id}' not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -853,12 +922,13 @@ class WebServer:
|
|||||||
# ── Block param update (UI: PATCH /api/block {id, ...params}) ──
|
# ── Block param update (UI: PATCH /api/block {id, ...params}) ──
|
||||||
@app.patch("/api/block")
|
@app.patch("/api/block")
|
||||||
async def update_block_params(data: dict, channel: str = "guitar"):
|
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
|
from ..presets.types import Channel, FXBlock
|
||||||
pm, pl, nam, ir = self._channel_deps(channel)
|
pm, pl, nam, ir = self._channel_deps(channel)
|
||||||
if not pm:
|
if not pm:
|
||||||
raise HTTPException(status_code=503)
|
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:
|
if not block_id:
|
||||||
raise HTTPException(status_code=400, detail="Missing 'id'")
|
raise HTTPException(status_code=400, detail="Missing 'id'")
|
||||||
try:
|
try:
|
||||||
@@ -866,7 +936,7 @@ class WebServer:
|
|||||||
program = pm.current_program
|
program = pm.current_program
|
||||||
preset = pm.load(bank, program, channel=Channel(channel))
|
preset = pm.load(bank, program, channel=Channel(channel))
|
||||||
for b in preset.chain:
|
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)
|
# Update params from request body (skip 'id' key)
|
||||||
for key, val in data.items():
|
for key, val in data.items():
|
||||||
if key == "id":
|
if key == "id":
|
||||||
@@ -874,8 +944,10 @@ class WebServer:
|
|||||||
if key in ("nam_model_path", "ir_file_path", "bypass", "enabled", "subtype"):
|
if key in ("nam_model_path", "ir_file_path", "bypass", "enabled", "subtype"):
|
||||||
if key == "bypass":
|
if key == "bypass":
|
||||||
b.bypass = bool(val)
|
b.bypass = bool(val)
|
||||||
|
b.enabled = not b.bypass # sync: bypass=true → enabled=false
|
||||||
elif key == "enabled":
|
elif key == "enabled":
|
||||||
b.enabled = bool(val)
|
b.enabled = bool(val)
|
||||||
|
b.bypass = not b.enabled # sync: enabled=false → bypass=true
|
||||||
elif key == "subtype":
|
elif key == "subtype":
|
||||||
b.subtype = str(val)
|
b.subtype = str(val)
|
||||||
else:
|
else:
|
||||||
@@ -886,6 +958,14 @@ class WebServer:
|
|||||||
pm.save(preset, channel=Channel(channel))
|
pm.save(preset, channel=Channel(channel))
|
||||||
if pl:
|
if pl:
|
||||||
pl.load_preset(preset)
|
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)}
|
return {"ok": True, "id": block_id, "params": dict(b.params)}
|
||||||
raise HTTPException(status_code=404, detail=f"Block '{block_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Block '{block_id}' not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1015,6 +1095,10 @@ class WebServer:
|
|||||||
if not nam:
|
if not nam:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
nam.unload()
|
nam.unload()
|
||||||
|
await self._manager.broadcast({
|
||||||
|
"type": "model_unloaded",
|
||||||
|
"channel": channel,
|
||||||
|
})
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/irs")
|
@app.get("/api/irs")
|
||||||
@@ -1064,6 +1148,10 @@ class WebServer:
|
|||||||
if not ir:
|
if not ir:
|
||||||
raise HTTPException(status_code=503)
|
raise HTTPException(status_code=503)
|
||||||
ir.unload()
|
ir.unload()
|
||||||
|
await self._manager.broadcast({
|
||||||
|
"type": "ir_unloaded",
|
||||||
|
"channel": channel,
|
||||||
|
})
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
# ── File upload endpoints ─────────────────────────────────
|
# ── File upload endpoints ─────────────────────────────────
|
||||||
@@ -1321,64 +1409,291 @@ class WebServer:
|
|||||||
|
|
||||||
@app.post("/api/audio/profile")
|
@app.post("/api/audio/profile")
|
||||||
async def set_audio_profile(data: dict):
|
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
|
Changes are saved to ~/.pedal/config.yaml so they survive reboot.
|
||||||
new period / nperiods / priority settings.
|
The switch restarts JACK (brief audio dropout ~1s).
|
||||||
"""
|
"""
|
||||||
profile_key = data.get("profile", "")
|
audio_sys = self.deps.audio_system
|
||||||
if profile_key not in LATENCY_PROFILES:
|
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(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Unknown profile: {profile_key!r}. Options: {list(LATENCY_PROFILES.keys())}",
|
detail=f"Unknown profile: {profile_key!r}. Options: {list(LATENCY_PROFILES.keys())}",
|
||||||
)
|
)
|
||||||
audio_sys = self.deps.audio_system
|
|
||||||
if not audio_sys:
|
if profile_key and profile_key in LATENCY_PROFILES:
|
||||||
raise HTTPException(status_code=503, detail="Audio system not available")
|
# 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
|
old_key = audio_sys.config.profile
|
||||||
if old_key == profile_key:
|
# Check if anything actually changed
|
||||||
return {"ok": True, "profile": profile_key, "restarted": False}
|
cur_profile = audio_sys.config.latency_profile
|
||||||
# Apply new profile
|
if (audio_sys.config.profile == effective_key
|
||||||
audio_sys.config.profile = profile_key
|
and cur_profile["period"] == target_profile["period"]
|
||||||
profile = audio_sys.config.latency_profile
|
and cur_profile["rate"] == target_profile["rate"]):
|
||||||
logger.info("Switching audio profile: %s → %s (period=%d, nperiods=%d)",
|
return {"ok": True, "profile": effective_key, "restarted": False}
|
||||||
old_key, profile_key, profile["period"], profile["nperiods"])
|
|
||||||
# Stop JACK audio client first (holds a live JACK C client)
|
# ── 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
|
jack_client = self.deps.jack_audio
|
||||||
if jack_client:
|
if jack_client:
|
||||||
jack_client.stop()
|
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:
|
if not ok:
|
||||||
# Rollback on failure
|
# Rollback on failure
|
||||||
audio_sys.config.profile = old_key
|
audio_sys.config.profile = old_key
|
||||||
|
logger.warning("JACK restart failed — attempting rollback to %s", old_key)
|
||||||
audio_sys.start_jack(timeout=10)
|
audio_sys.start_jack(timeout=10)
|
||||||
if jack_client:
|
if jack_client:
|
||||||
jack_client.start()
|
try:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to start JACK with profile {profile_key!r} — rolled back to {old_key!r}")
|
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
|
# Restart JACK audio client
|
||||||
if jack_client:
|
if jack_client:
|
||||||
jack_client.start()
|
try:
|
||||||
# Sync NAM engine block size with new period
|
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
|
nam_host = self.deps.nam_host
|
||||||
if nam_host and hasattr(nam_host, 'set_block_size'):
|
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({
|
await self._manager.broadcast({
|
||||||
"type": "audio_profile_changed",
|
"type": "audio_profile_changed",
|
||||||
"profile": profile_key,
|
"profile": effective_key,
|
||||||
"period": profile["period"],
|
"period": target_profile["period"],
|
||||||
"nperiods": profile["nperiods"],
|
"rate": target_profile["rate"],
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"profile": profile_key,
|
"profile": effective_key,
|
||||||
"period": profile["period"],
|
"period": target_profile["period"],
|
||||||
"nperiods": profile["nperiods"],
|
"rate": target_profile["rate"],
|
||||||
"restarted": True,
|
"restarted": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Generic settings persistence ──────────────────────────
|
||||||
|
|
||||||
|
KEY_MAP = {
|
||||||
|
"input_device": ("audio", "input_device"),
|
||||||
|
"output_device": ("audio", "output_device"),
|
||||||
|
"channel_mode": ("audio", "mode"),
|
||||||
|
"input_pad": ("audio", "input_pad"),
|
||||||
|
"input_impedance": ("audio", "input_impedance"),
|
||||||
|
"midi_channel": ("midi", "channel"),
|
||||||
|
"midi_clock": ("midi", "clock"),
|
||||||
|
"midi_thru": ("midi", "thru"),
|
||||||
|
"auto_dim": ("display", "auto_dim"),
|
||||||
|
"screen_saver": ("display", "screen_saver"),
|
||||||
|
"tap_tempo": ("tempo", "tap_tempo"),
|
||||||
|
"tempo_division": ("tempo", "division"),
|
||||||
|
"tap_tempo_mute": ("tempo", "mute_on_tap"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/settings")
|
||||||
|
async def get_settings():
|
||||||
|
"""Return the full config dict (read-only snapshot)."""
|
||||||
|
if self.deps.config is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Config not available")
|
||||||
|
return self.deps.config
|
||||||
|
@app.post("/api/settings")
|
||||||
|
async def save_settings(data: dict):
|
||||||
|
"""Save arbitrary settings to config.yaml.
|
||||||
|
|
||||||
|
Accepts a dict of key-value pairs. Each key is mapped to
|
||||||
|
the correct ``section.key`` in ``~/.pedal/config.yaml`` and
|
||||||
|
persisted immediately.
|
||||||
|
|
||||||
|
Special handling:
|
||||||
|
``brightness`` — UI sends 1–10, stored as 0.1–1.0 float.
|
||||||
|
"""
|
||||||
|
if self.deps.config is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Config not available")
|
||||||
|
|
||||||
|
cfg = self.deps.config
|
||||||
|
n_saved = 0
|
||||||
|
|
||||||
|
for key, val in data.items():
|
||||||
|
if key == "brightness":
|
||||||
|
leds = cfg.setdefault("leds", {})
|
||||||
|
if isinstance(val, (int, float)) and val >= 1:
|
||||||
|
leds["brightness"] = val / 10.0
|
||||||
|
else:
|
||||||
|
leds["brightness"] = val
|
||||||
|
n_saved += 1
|
||||||
|
elif key in KEY_MAP:
|
||||||
|
section, skey = KEY_MAP[key]
|
||||||
|
cfg.setdefault(section, {})[skey] = val
|
||||||
|
n_saved += 1
|
||||||
|
|
||||||
|
save_config(cfg, self.deps.config_path)
|
||||||
|
logger.info("Settings saved: %d keys from %s", n_saved, data)
|
||||||
|
|
||||||
|
# ── Runtime apply for live-updatable settings ──────────
|
||||||
|
if "brightness" in data and self.deps.leds is not None:
|
||||||
|
try:
|
||||||
|
bv = data["brightness"]
|
||||||
|
if isinstance(bv, (int, float)) and bv >= 1:
|
||||||
|
bv = bv / 10.0
|
||||||
|
self.deps.leds.set_brightness(float(bv))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply brightness: %s", e)
|
||||||
|
|
||||||
|
if "channel_mode" in data and self.deps.pipeline is not None:
|
||||||
|
try:
|
||||||
|
mode = str(data["channel_mode"])
|
||||||
|
self.deps.pipeline.routing_mode = "4cm" if mode == "stereo_4cm" else "mono"
|
||||||
|
logger.info("Pipeline routing_mode set to %s", self.deps.pipeline.routing_mode)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply channel_mode: %s", e)
|
||||||
|
|
||||||
|
# ── JACK device hot-swap for input_device / output_device ──
|
||||||
|
if "input_device" in data or "output_device" in data:
|
||||||
|
try:
|
||||||
|
audio_sys = self.deps.audio_system
|
||||||
|
jack_client = self.deps.jack_audio
|
||||||
|
if jack_client:
|
||||||
|
jack_client.stop()
|
||||||
|
if "input_device" in data:
|
||||||
|
audio_sys.config.input_device = str(data["input_device"])
|
||||||
|
if "output_device" in data:
|
||||||
|
audio_sys.config.output_device = str(data["output_device"])
|
||||||
|
audio_sys.stop_jack()
|
||||||
|
ok = audio_sys.start_jack(timeout=10)
|
||||||
|
if not ok:
|
||||||
|
logger.warning("JACK restart failed after device change — rolling back")
|
||||||
|
# Restore old devices is complex, just log the failure
|
||||||
|
if jack_client:
|
||||||
|
jack_client.start()
|
||||||
|
logger.info("JACK restarted for device change: input=%s output=%s",
|
||||||
|
audio_sys.config.input_device, audio_sys.config.output_device)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply device change: %s", e)
|
||||||
|
|
||||||
|
# ── MIDI channel filter ──
|
||||||
|
if "midi_channel" in data and self.deps.midi is not None:
|
||||||
|
try:
|
||||||
|
val = data["midi_channel"]
|
||||||
|
if val is None or str(val).lower() == "omni":
|
||||||
|
self.deps.midi.set_channel_filter(None)
|
||||||
|
logger.info("MIDI channel filter set to omni")
|
||||||
|
else:
|
||||||
|
ch = int(val) - 1 # UI sends "1"-"16", store as 0-15
|
||||||
|
self.deps.midi.set_channel_filter(ch)
|
||||||
|
logger.info("MIDI channel filter set to %d", ch)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply midi_channel: %s", e)
|
||||||
|
|
||||||
|
# ── Display settings ──
|
||||||
|
if "auto_dim" in data and self.deps.display is not None:
|
||||||
|
try:
|
||||||
|
self.deps.display.set_auto_dim(bool(data["auto_dim"]))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply auto_dim: %s", e)
|
||||||
|
|
||||||
|
if "screen_saver" in data and self.deps.display is not None:
|
||||||
|
try:
|
||||||
|
self.deps.display.set_screen_saver(str(data["screen_saver"]))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply screen_saver: %s", e)
|
||||||
|
|
||||||
|
# ── Tempo settings ──
|
||||||
|
if "tap_tempo" in data and self.deps.pipeline is not None:
|
||||||
|
try:
|
||||||
|
self.deps.pipeline.set_tempo(float(data["tap_tempo"]))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply tap_tempo: %s", e)
|
||||||
|
|
||||||
|
if "tempo_division" in data:
|
||||||
|
logger.info("tempo_division change noted (boot-only): %s", data["tempo_division"])
|
||||||
|
|
||||||
|
if "tap_tempo_mute" in data:
|
||||||
|
logger.info("tap_tempo_mute change noted (boot-only): %s", data["tap_tempo_mute"])
|
||||||
|
|
||||||
|
return {"ok": True, "saved": n_saved}
|
||||||
|
|
||||||
# ── FX block param schemas ───────────────────────────────
|
# ── FX block param schemas ───────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/block-params/{fx_type}")
|
@app.get("/api/block-params/{fx_type}")
|
||||||
@@ -1680,6 +1995,275 @@ class WebServer:
|
|||||||
ir_loader.get_irs()
|
ir_loader.get_irs()
|
||||||
return {"ok": True, "path": str(path), "filename": path.name}
|
return {"ok": True, "path": str(path), "filename": path.name}
|
||||||
|
|
||||||
|
@app.get("/api/system")
|
||||||
|
async def get_system():
|
||||||
|
import os, subprocess
|
||||||
|
info = {"hostname": os.uname().nodename, "kernel": os.uname().release}
|
||||||
|
try:
|
||||||
|
with open("/proc/uptime") as f:
|
||||||
|
secs = float(f.read().split()[0])
|
||||||
|
info["uptime_seconds"] = round(secs)
|
||||||
|
d = int(secs // 86400); h = int((secs % 86400) // 3600); m = int((secs % 3600) // 60)
|
||||||
|
info["uptime"] = f"{d}d {h}h {m}m"
|
||||||
|
except: info["uptime"] = "?"
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["cat","/sys/class/thermal/thermal_zone0/temp"], capture_output=True, text=True, timeout=3)
|
||||||
|
if r.returncode == 0: info["cpu_temp_c"] = round(int(r.stdout.strip()) / 1000, 1)
|
||||||
|
except: pass
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["free","-m"], capture_output=True, text=True, timeout=3)
|
||||||
|
for line in r.stdout.splitlines():
|
||||||
|
p = line.split()
|
||||||
|
if p and p[0] == "Mem:":
|
||||||
|
info["memory_mb"] = {"total": int(p[1]), "used": int(p[2]), "free": int(p[3])}
|
||||||
|
except: pass
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["df","-h","/"], capture_output=True, text=True, timeout=3)
|
||||||
|
parts = r.stdout.splitlines()[1].split()
|
||||||
|
info["disk"] = {"total": parts[1], "used": parts[2], "avail": parts[3], "pct": parts[4]}
|
||||||
|
except: pass
|
||||||
|
try:
|
||||||
|
with open("/proc/loadavg") as f:
|
||||||
|
info["load"] = f.read().split()[:3]
|
||||||
|
except: pass
|
||||||
|
return info
|
||||||
|
|
||||||
|
@app.get("/api/system/logs")
|
||||||
|
async def get_system_logs(lines: int = 50):
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["journalctl","-u","pedal","--no-pager","-n",str(lines)], capture_output=True, text=True, timeout=10)
|
||||||
|
return {"logs": r.stdout.splitlines(), "exit_code": r.returncode}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e), "logs": []}
|
||||||
|
|
||||||
|
@app.post("/api/system/hostname")
|
||||||
|
async def set_hostname(data: dict):
|
||||||
|
import subprocess
|
||||||
|
hostname = data.get("hostname", "").strip()
|
||||||
|
if not hostname or not hostname.replace("-","").replace("_","").isalnum():
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid hostname")
|
||||||
|
try:
|
||||||
|
subprocess.run(["hostnamectl","set-hostname",hostname], check=True, timeout=10)
|
||||||
|
return {"ok": True, "hostname": hostname}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/network")
|
||||||
|
async def get_network():
|
||||||
|
import subprocess, re
|
||||||
|
info = {"ethernet": {}, "wifi": {}}
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["ip","-4","addr","show","eth0"], capture_output=True, text=True, timeout=3)
|
||||||
|
m = re.search(r"inet (\S+)", r.stdout)
|
||||||
|
if m: info["ethernet"]["ip"] = m.group(1)
|
||||||
|
r2 = subprocess.run(["ip","link","show","eth0"], capture_output=True, text=True, timeout=3)
|
||||||
|
m2 = re.search(r"link/ether (\S+)", r2.stdout)
|
||||||
|
if m2: info["ethernet"]["mac"] = m2.group(1)
|
||||||
|
r3 = subprocess.run(["ip","route","show","default"], capture_output=True, text=True, timeout=3)
|
||||||
|
m3 = re.search(r"default via (\S+)", r3.stdout)
|
||||||
|
if m3: info["ethernet"]["gateway"] = m3.group(1)
|
||||||
|
except: pass
|
||||||
|
try:
|
||||||
|
with open("/etc/resolv.conf") as f:
|
||||||
|
dns = [line.split()[1] for line in f if line.startswith("nameserver")]
|
||||||
|
if dns: info["dns"] = dns
|
||||||
|
except: pass
|
||||||
|
return info
|
||||||
|
|
||||||
|
@app.post("/api/network/static")
|
||||||
|
async def set_network_static(data: dict):
|
||||||
|
import subprocess, os
|
||||||
|
ip = data.get("ip", "").strip()
|
||||||
|
gw = data.get("gateway", "").strip()
|
||||||
|
mask = data.get("netmask", "24").strip()
|
||||||
|
dns_servers = data.get("dns", "9.9.9.9 149.112.112.112")
|
||||||
|
if not ip:
|
||||||
|
raise HTTPException(status_code=400, detail="ip required")
|
||||||
|
config = f"""auto eth0
|
||||||
|
iface eth0 inet static
|
||||||
|
address {ip}/{mask}
|
||||||
|
gateway {gw}
|
||||||
|
dns-nameservers {dns_servers}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open("/etc/network/interfaces.d/eth0", "w") as f:
|
||||||
|
f.write(config)
|
||||||
|
subprocess.run(["ifdown","eth0"], capture_output=True, timeout=10)
|
||||||
|
subprocess.run(["ifup","eth0"], capture_output=True, timeout=30)
|
||||||
|
return {"ok": True, "ip": ip, "mode": "static"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/network/dhcp")
|
||||||
|
async def set_network_dhcp():
|
||||||
|
import subprocess, os
|
||||||
|
try:
|
||||||
|
os.remove("/etc/network/interfaces.d/eth0")
|
||||||
|
except FileNotFoundError: pass
|
||||||
|
try:
|
||||||
|
subprocess.run(["ifdown","eth0"], capture_output=True, timeout=10)
|
||||||
|
subprocess.run(["ifup","eth0"], capture_output=True, timeout=30)
|
||||||
|
except: pass
|
||||||
|
return {"ok": True, "mode": "dhcp"}
|
||||||
|
|
||||||
|
@app.get("/api/audio/devices")
|
||||||
|
async def list_audio_devices():
|
||||||
|
import subprocess, re
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["aplay","-l"], capture_output=True, text=True, timeout=5)
|
||||||
|
cards = []
|
||||||
|
for line in r.stdout.splitlines():
|
||||||
|
m = re.match(r"card (\d+):.*?\[(.*?)\].*device (\d+):.*?\[(.*?)\]", line)
|
||||||
|
if m:
|
||||||
|
cards.append({"card": int(m.group(1)), "name": m.group(2).strip(),
|
||||||
|
"device": int(m.group(3)), "desc": m.group(4).strip()})
|
||||||
|
r2 = subprocess.run(["cat","/proc/asound/cards"], capture_output=True, text=True, timeout=3)
|
||||||
|
usb_id = None
|
||||||
|
for line in r2.stdout.splitlines():
|
||||||
|
if "USB" in line or "usb" in line.lower():
|
||||||
|
m = re.match(r"\s*(\d+)", line)
|
||||||
|
if m: usb_id = int(m.group(1))
|
||||||
|
return {"devices": cards, "usb_card": usb_id, "suggested": f"hw:{usb_id or 0},0"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e), "devices": []}
|
||||||
|
|
||||||
|
@app.get("/api/levels")
|
||||||
|
async def get_signal_levels():
|
||||||
|
levels = {"channels": {}}
|
||||||
|
ja = self.deps.jack_audio
|
||||||
|
if ja and hasattr(ja, 'get_levels'):
|
||||||
|
try: levels = ja.get_levels()
|
||||||
|
except: pass
|
||||||
|
if not levels.get("channels"):
|
||||||
|
for ch in ["guitar", "bass", "keys", "vocals", "backing_tracks"]:
|
||||||
|
levels["channels"][ch] = {"input_rms": 0, "input_peak": 0, "output_rms": 0, "output_peak": 0}
|
||||||
|
return levels
|
||||||
|
|
||||||
|
@app.delete("/api/models/{name:path}")
|
||||||
|
async def delete_model(name: str):
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
models_dir = Path(self.deps.config.get("models", {}).get("dir", "/home/oplabs/projects/pi-multifx-pedal/src/models/nam"))
|
||||||
|
target = models_dir / name
|
||||||
|
if not target.exists() or not target.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Model not found")
|
||||||
|
if target.suffix not in (".nam", ".wav", ".onnx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Not a model file")
|
||||||
|
os.remove(str(target))
|
||||||
|
nam = self.deps.nam_host
|
||||||
|
if nam: nam.list_available_models()
|
||||||
|
return {"ok": True, "deleted": name}
|
||||||
|
|
||||||
|
@app.delete("/api/irs/{name:path}")
|
||||||
|
async def delete_ir(name: str):
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
irs_dir = Path(self.deps.config.get("irs", {}).get("dir", "/home/oplabs/projects/pi-multifx-pedal/src/models/ir"))
|
||||||
|
target = irs_dir / name
|
||||||
|
if not target.exists() or not target.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="IR not found")
|
||||||
|
if target.suffix != ".wav":
|
||||||
|
raise HTTPException(status_code=400, detail="Not an IR file")
|
||||||
|
os.remove(str(target))
|
||||||
|
ir_loader = self.deps.ir_loader
|
||||||
|
if ir_loader: ir_loader.get_irs()
|
||||||
|
return {"ok": True, "deleted": name}
|
||||||
|
|
||||||
|
@app.get("/api/midi/mappings")
|
||||||
|
async def get_midi_mappings():
|
||||||
|
mappings = {}
|
||||||
|
if self.deps.midi and hasattr(self.deps.midi, 'get_mappings'):
|
||||||
|
try: mappings = self.deps.midi.get_mappings()
|
||||||
|
except: pass
|
||||||
|
return {"mappings": mappings}
|
||||||
|
|
||||||
|
@app.post("/api/midi/learn")
|
||||||
|
async def midi_learn(data: dict = None):
|
||||||
|
if data and "enable" in data:
|
||||||
|
enable = bool(data["enable"])
|
||||||
|
if self.deps.midi and hasattr(self.deps.midi, 'set_learn_mode'):
|
||||||
|
try: self.deps.midi.set_learn_mode(enable)
|
||||||
|
except: pass
|
||||||
|
return {"learn_active": enable}
|
||||||
|
if data and "param" in data:
|
||||||
|
if self.deps.midi and hasattr(self.deps.midi, 'set_mapping'):
|
||||||
|
try: self.deps.midi.set_mapping(data["param"], data.get("cc"))
|
||||||
|
except: pass
|
||||||
|
return {"mapped": data["param"]}
|
||||||
|
return {"learn_active": False, "mappings": {}}
|
||||||
|
|
||||||
|
@app.post("/api/backup")
|
||||||
|
async def create_backup():
|
||||||
|
import subprocess, os, datetime
|
||||||
|
backup_dir = "/tmp/pedal-backups"
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"pedal-backup-{ts}.tar.gz"
|
||||||
|
outpath = os.path.join(backup_dir, filename)
|
||||||
|
cfg_path = os.path.expanduser("~/.pedal")
|
||||||
|
try:
|
||||||
|
subprocess.run(["tar","-czf",outpath,"-C",os.path.dirname(cfg_path),os.path.basename(cfg_path)], check=True, timeout=30)
|
||||||
|
size = os.path.getsize(outpath)
|
||||||
|
return {"ok": True, "filename": filename, "path": outpath, "size": size}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/backup/list")
|
||||||
|
async def list_backups():
|
||||||
|
import os, datetime
|
||||||
|
backup_dir = "/tmp/pedal-backups"
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
bks = []
|
||||||
|
for f in sorted(os.listdir(backup_dir), reverse=True):
|
||||||
|
fp = os.path.join(backup_dir, f)
|
||||||
|
if os.path.isfile(fp):
|
||||||
|
bks.append({"name": f, "size": os.path.getsize(fp),
|
||||||
|
"modified": datetime.datetime.fromtimestamp(os.path.getmtime(fp)).isoformat()})
|
||||||
|
return {"backups": bks}
|
||||||
|
|
||||||
|
@app.post("/api/restore")
|
||||||
|
async def restore_backup(data: dict):
|
||||||
|
import subprocess, os
|
||||||
|
filename = data.get("filename") if data else None
|
||||||
|
if not filename:
|
||||||
|
return {"error": "filename required"}
|
||||||
|
backup_path = os.path.join("/tmp/pedal-backups", filename)
|
||||||
|
if not os.path.exists(backup_path):
|
||||||
|
raise HTTPException(status_code=404, detail="Backup not found")
|
||||||
|
try:
|
||||||
|
subprocess.run(["tar","-xzf",backup_path,"-C",os.path.expanduser("~")], check=True, timeout=30)
|
||||||
|
return {"ok": True, "restored": filename}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/diagnostics")
|
||||||
|
async def get_diagnostics():
|
||||||
|
import subprocess
|
||||||
|
diag = {}
|
||||||
|
ja = self.deps.jack_audio
|
||||||
|
diag["jack"] = {"running": ja is not None and hasattr(ja, 'is_running') and ja.is_running()}
|
||||||
|
nam = self.deps.nam_host
|
||||||
|
diag["nam"] = {"loaded": nam is not None and nam.current_model is not None}
|
||||||
|
if diag["nam"]["loaded"]: diag["nam"]["model"] = str(nam.current_model)
|
||||||
|
midi = self.deps.midi
|
||||||
|
diag["midi"] = {"available": midi is not None}
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["iwgetid"], capture_output=True, text=True, timeout=3)
|
||||||
|
diag["wifi"] = {"connected": r.returncode == 0, "ssid": r.stdout.strip() if r.returncode == 0 else None}
|
||||||
|
except: diag["wifi"] = {"connected": False}
|
||||||
|
bt = getattr(self.deps, 'bt_controller', None)
|
||||||
|
diag["bluetooth"] = {"available": bt is not None}
|
||||||
|
if self.deps.audio_system:
|
||||||
|
try:
|
||||||
|
ap = self.deps.audio_system.config
|
||||||
|
diag["audio"] = {"profile": getattr(ap, 'profile', '?'), "rate": getattr(ap, 'rate', '?')}
|
||||||
|
except: diag["audio"] = {"profile": "?"}
|
||||||
|
diag["api"] = {"status": "ok"}
|
||||||
|
return diag
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── Channel-prefixed route aliases ──────────────────────
|
# ── Channel-prefixed route aliases ──────────────────────
|
||||||
# Every state/control endpoint that accepts ?channel= is also
|
# Every state/control endpoint that accepts ?channel= is also
|
||||||
# available at /api/channel/{channel}/... for explicit routing.
|
# available at /api/channel/{channel}/... for explicit routing.
|
||||||
@@ -1853,6 +2437,31 @@ class WebServer:
|
|||||||
"nam_model": current_model_name,
|
"nam_model": current_model_name,
|
||||||
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
|
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
|
||||||
"ir_name": current_ir_name,
|
"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,
|
"channel_mode": self._channel_mgr.mode,
|
||||||
# Audio levels from pipeline (RMS float32 normalized 0.0-1.0, scaled to 0-100)
|
# Audio levels from pipeline (RMS float32 normalized 0.0-1.0, scaled to 0-100)
|
||||||
"input_level": (
|
"input_level": (
|
||||||
|
|||||||
@@ -0,0 +1,730 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||||||
|
<title>Pedal v2</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||||
|
:root{--bg:#0A0A0E;--surface:#16161E;--panel:#1E1E2A;--border:#2A2A3E;--text:#EAEAFF;--text-dim:#6A6A8A;--text-sec:#9A9AB0;--amber:#E8A030;--green:#50D090;--red:#E05050;--blue:#4090E0;--radius:10px;--font:'Space Grotesk','Inter',system-ui,-apple-system,sans-serif}
|
||||||
|
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--font);overflow:hidden;touch-action:manipulation}
|
||||||
|
body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webkit-user-select:none}
|
||||||
|
|
||||||
|
/* Status Bar */
|
||||||
|
.status-bar{display:flex;align-items:center;gap:6px;padding:3px 8px;background:var(--panel);border-bottom:1px solid var(--border);flex-shrink:0;min-height:34px}
|
||||||
|
.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||||
|
.status-dot.on{background:var(--green);box-shadow:0 0 6px var(--green)}
|
||||||
|
.status-dot.off{background:var(--red)}
|
||||||
|
.status-channel{font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--amber);padding:2px 6px;border-radius:4px;background:rgba(232,160,48,.12);white-space:nowrap}
|
||||||
|
.status-preset{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-weight:600;min-width:0}
|
||||||
|
.status-vol{font-size:10px;font-weight:700;color:var(--amber);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||||
|
.status-btn{width:40px;height:40px;border:none;background:transparent;color:var(--text-dim);font-size:16px;border-radius:6px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;transition:all .15s}
|
||||||
|
.status-btn:active{background:var(--border);color:var(--amber)}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:4px 6px;gap:4px}
|
||||||
|
|
||||||
|
/* FX Chain */
|
||||||
|
.fx-strip{display:flex;gap:6px;overflow-x:auto;overflow-y:hidden;flex-shrink:0;padding:4px 0;min-height:72px;scrollbar-width:none;-ms-overflow-style:none}
|
||||||
|
.fx-strip::-webkit-scrollbar{display:none}
|
||||||
|
.fx-block{flex-shrink:0;width:100px;border-radius:var(--radius);background:var(--surface);border:1.5px solid var(--border);padding:4px 6px;display:flex;flex-direction:column;cursor:pointer;transition:all .15s;position:relative}
|
||||||
|
.fx-block:active{transform:scale(.96)}
|
||||||
|
.fx-block.on{border-color:var(--green);background:rgba(80,208,144,.06)}
|
||||||
|
.fx-block.off{opacity:.4}
|
||||||
|
.fx-block-add{flex-shrink:0;width:72px;border-radius:var(--radius);border:1.5px dashed var(--border);background:transparent;display:flex;align-items:center;justify-content:center;font-size:22px;color:var(--text-dim);cursor:pointer;transition:all .15s}
|
||||||
|
.fx-block-add:active{background:var(--surface);color:var(--amber);border-color:var(--amber)}
|
||||||
|
.fx-type{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:1px}
|
||||||
|
.fx-name{font-size:10px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||||
|
.fx-indicator{display:flex;gap:2px;margin-top:auto;padding-top:2px}
|
||||||
|
.fx-dot{width:6px;height:6px;border-radius:50%;background:var(--border)}
|
||||||
|
.fx-dot.active{background:var(--amber)}
|
||||||
|
|
||||||
|
/* Volume */
|
||||||
|
.vol-row{display:flex;align-items:center;gap:8px;flex-shrink:0;min-height:36px;padding:0 4px}
|
||||||
|
.vol-label{font-size:9px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em;width:44px;flex-shrink:0}
|
||||||
|
.vol-track{flex:1;height:36px;display:flex;align-items:center;cursor:pointer;touch-action:none;position:relative}
|
||||||
|
.vol-track-bg{width:100%;height:6px;border-radius:3px;background:var(--border);overflow:hidden;position:relative}
|
||||||
|
.vol-track-fill{height:100%;border-radius:3px;background:var(--amber);transition:width .05s linear}
|
||||||
|
.vol-track-thumb{position:absolute;top:50%;transform:translate(-50%,-50%);width:24px;height:24px;border-radius:50%;background:var(--amber);border:2px solid var(--bg);box-shadow:0 1px 6px rgba(0,0,0,.5);pointer-events:none}
|
||||||
|
.vol-pct{font-size:12px;font-weight:700;color:var(--amber);min-width:38px;text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* Bottom Tab Bar */
|
||||||
|
.tab-bar{display:flex;flex-shrink:0;background:var(--panel);border-top:1px solid var(--border);min-height:44px}
|
||||||
|
.tab-btn{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;border:none;background:transparent;color:var(--text-dim);cursor:pointer;padding:4px 2px;transition:all .1s;min-height:44px}
|
||||||
|
.tab-btn .tab-icon{font-size:14px;line-height:1}
|
||||||
|
.tab-btn .tab-label{font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
.tab-btn.active{color:var(--amber);background:rgba(232,160,48,.08)}
|
||||||
|
.tab-btn:active{opacity:.7}
|
||||||
|
|
||||||
|
/* Footswitch */
|
||||||
|
.footswitch-row{flex-shrink:0;padding:3px 6px}
|
||||||
|
.footswitch{width:100%;min-height:64px;border-radius:12px;border:none;font-family:var(--font);font-size:16px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;cursor:pointer;transition:all .12s;display:flex;align-items:center;justify-content:center;gap:8px;box-shadow:0 3px 16px rgba(0,0,0,.4)}
|
||||||
|
.footswitch:active{transform:scale(.97)}
|
||||||
|
.footswitch.on{background:linear-gradient(180deg,#60D898,#30A068);color:#003020}
|
||||||
|
.footswitch.off{background:linear-gradient(180deg,#E86868,#B03030);color:#200000}
|
||||||
|
|
||||||
|
/* Overlays */
|
||||||
|
.overlay{position:fixed;inset:0;z-index:100;background:var(--bg);display:none;flex-direction:column;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
|
||||||
|
.overlay.open{display:flex}
|
||||||
|
.overlay-hdr{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--panel);border-bottom:1px solid var(--border);flex-shrink:0;min-height:38px}
|
||||||
|
.overlay-title{font-size:13px;font-weight:700}
|
||||||
|
.overlay-close{width:38px;height:38px;border:none;background:transparent;color:var(--text-dim);font-size:16px;border-radius:6px;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||||
|
.overlay-close:active{background:var(--border)}
|
||||||
|
.overlay-body{flex:1;overflow-y:auto;padding:6px 10px}
|
||||||
|
|
||||||
|
/* Preset Grid */
|
||||||
|
.preset-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:6px 0}
|
||||||
|
.preset-card{border-radius:var(--radius);border:1.5px solid var(--border);background:var(--surface);padding:10px 8px;cursor:pointer;transition:all .12s;min-height:52px;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.preset-card:active{transform:scale(.97);border-color:var(--amber)}
|
||||||
|
.preset-card.active{border-color:var(--amber);background:rgba(232,160,48,.08)}
|
||||||
|
.preset-card .pc-num{font-size:9px;font-weight:700;color:var(--text-dim)}
|
||||||
|
.preset-card .pc-name{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|
||||||
|
/* Tuner */
|
||||||
|
.tuner-view{position:fixed;inset:0;z-index:90;background:var(--bg);display:none;flex-direction:column;align-items:center;justify-content:center}
|
||||||
|
.tuner-view.open{display:flex}
|
||||||
|
.tuner-note{font-size:64px;font-weight:800;color:var(--text);line-height:1;margin-bottom:4px}
|
||||||
|
.tuner-cents{height:44px;display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:8px}
|
||||||
|
.tuner-cents-bar{width:100px;height:6px;border-radius:3px;background:var(--border);overflow:hidden;position:relative}
|
||||||
|
.tuner-cents-fill{height:100%;border-radius:3px;position:absolute}
|
||||||
|
.tuner-cents-fill.in-tune{background:var(--green);left:calc(50% - 3px);width:6px}
|
||||||
|
.tuner-cents-fill.sharp{background:var(--red)}
|
||||||
|
.tuner-cents-fill.flat{background:var(--blue)}
|
||||||
|
.tuner-freq{font-size:11px;color:var(--text-dim);margin-bottom:12px}
|
||||||
|
.tuner-exit{width:60%;max-width:260px;min-height:50px;border-radius:var(--radius);border:1.5px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:14px;font-weight:700;cursor:pointer}
|
||||||
|
.tuner-exit:active{background:var(--panel);border-color:var(--amber)}
|
||||||
|
|
||||||
|
/* Settings */
|
||||||
|
.setting-group{margin-bottom:12px}
|
||||||
|
.setting-group-title{font-size:10px;font-weight:700;color:var(--text-sec);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;padding:0 4px}
|
||||||
|
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--surface);border-radius:6px;border:1px solid var(--border);margin-bottom:3px;min-height:42px}
|
||||||
|
.setting-row .sr-label{font-size:11px;font-weight:600}
|
||||||
|
.setting-row .sr-value{font-size:10px;color:var(--text-sec)}
|
||||||
|
.setting-row .sr-action{min-width:52px;min-height:32px;border-radius:6px;border:1px solid var(--border);background:var(--panel);color:var(--text);font-family:var(--font);font-size:10px;font-weight:600;cursor:pointer;padding:0 8px;display:flex;align-items:center;justify-content:center}
|
||||||
|
.setting-row .sr-action:active{background:var(--border)}
|
||||||
|
.setting-row .sr-action.primary{background:var(--amber);color:#000;border-color:var(--amber)}
|
||||||
|
.setting-row .sr-action.danger{background:var(--red);color:#fff;border-color:var(--red)}
|
||||||
|
.wifi-list{display:flex;flex-direction:column;gap:3px}
|
||||||
|
.wifi-item{display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--surface);border-radius:6px;border:1px solid var(--border);cursor:pointer;min-height:40px}
|
||||||
|
.wifi-item:active{background:var(--panel)}
|
||||||
|
.wifi-item .wi-ssid{font-size:11px;font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.wifi-connect-pw{display:flex;gap:4px;padding:6px 0}
|
||||||
|
.wifi-connect-pw input{flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:11px;outline:none;min-height:36px}
|
||||||
|
.wifi-connect-pw input:focus{border-color:var(--amber)}
|
||||||
|
.wifi-connect-pw button{min-width:52px;min-height:36px;border-radius:6px;border:1px solid var(--amber);background:var(--amber);color:#000;font-family:var(--font);font-size:11px;font-weight:700;cursor:pointer}
|
||||||
|
.audio-profile-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px}
|
||||||
|
.ap-card{border-radius:6px;border:1px solid var(--border);background:var(--surface);padding:6px;cursor:pointer;text-align:center;transition:all .12s;min-height:40px}
|
||||||
|
.ap-card:active{transform:scale(.97)}
|
||||||
|
.ap-card.active{border-color:var(--amber);background:rgba(232,160,48,.1)}
|
||||||
|
.ap-card .ap-key{font-size:10px;font-weight:700;text-transform:capitalize}
|
||||||
|
.ap-card .ap-desc{font-size:8px;color:var(--text-dim);margin-top:1px}
|
||||||
|
.snap-grid{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:4px}
|
||||||
|
.snap-slot{border-radius:6px;border:1px solid var(--border);background:var(--surface);padding:6px 3px;cursor:pointer;text-align:center;transition:all .12s;min-height:40px;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||||
|
.snap-slot:active{transform:scale(.95)}
|
||||||
|
.snap-slot.used{border-color:var(--amber);background:rgba(232,160,48,.06)}
|
||||||
|
.snap-slot.current{border-color:var(--green);background:rgba(80,208,144,.1)}
|
||||||
|
.snap-slot .ss-num{font-size:9px;font-weight:700;color:var(--text-dim)}
|
||||||
|
.snap-slot .ss-name{font-size:9px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:50px}
|
||||||
|
.spinner{width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--amber);border-radius:50%;animation:spin .6s linear infinite;display:inline-block;vertical-align:middle}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.toast{position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:200;background:var(--panel);border:1px solid var(--amber);border-radius:8px;padding:6px 14px;font-size:11px;font-weight:600;color:var(--text);display:none;white-space:nowrap;box-shadow:0 4px 20px rgba(0,0,0,.5)}
|
||||||
|
.toast.show{display:block;animation:toastIn .2s ease}
|
||||||
|
@keyframes toastIn{from{opacity:0;transform:translateX(-50%) translateY(10px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status-dot on" id="statusDot"></div>
|
||||||
|
<div class="status-channel" id="statusChannel" onclick="cycleInstrument()" style="cursor:pointer">Guitar</div>
|
||||||
|
<div class="status-preset" id="statusPreset">—</div>
|
||||||
|
<div class="status-vol" id="statusVolPct">80%</div>
|
||||||
|
<button class="status-btn" id="btnChannel" onclick="cycleInstrument()">🎛</button>
|
||||||
|
<button class="status-btn" id="btnSettings">⚙</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="fx-strip" id="fxStrip">
|
||||||
|
<div class="fx-block-add" id="btnAddBlock">+</div>
|
||||||
|
</div>
|
||||||
|
<div class="vol-row">
|
||||||
|
<span class="vol-label">Vol</span>
|
||||||
|
<div class="vol-track" id="volTrack">
|
||||||
|
<div class="vol-track-bg"><div class="vol-track-fill" id="volFill" style="width:80%"></div></div>
|
||||||
|
<div class="vol-track-thumb" id="volThumb" style="left:80%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="vol-pct" id="volPct">80%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="tab-bar" id="tabBar">
|
||||||
|
<button class="tab-btn active" data-view="main"><span class="tab-icon">🎛</span><span class="tab-label">FX</span></button>
|
||||||
|
<button class="tab-btn" data-view="presets"><span class="tab-icon">🎸</span><span class="tab-label">Presets</span></button>
|
||||||
|
<button class="tab-btn" data-view="snapshots"><span class="tab-icon">📸</span><span class="tab-label">Snapshots</span></button>
|
||||||
|
<button class="tab-btn" data-view="downloads"><span class="tab-icon">⬇</span><span class="tab-label">Downloads</span></button>
|
||||||
|
<button class="tab-btn" id="tabTuner"><span class="tab-icon">🎵</span><span class="tab-label">Tuner</span></button>
|
||||||
|
<button class="tab-btn" data-view="settings"><span class="tab-icon">⚙</span><span class="tab-label">Settings</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footswitch -->
|
||||||
|
<div class="footswitch-row">
|
||||||
|
<button class="footswitch on" id="footswitch">BYpass</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Presets Overlay -->
|
||||||
|
<div class="overlay" id="overlayPresets">
|
||||||
|
<div class="overlay-hdr"><span class="overlay-title">🎸 Presets</span><button class="overlay-close" id="closePresets">✕</button></div>
|
||||||
|
<div class="overlay-body"><div class="preset-grid" id="presetGrid"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Snapshots Overlay -->
|
||||||
|
<div class="overlay" id="overlaySnapshots">
|
||||||
|
<div class="overlay-hdr"><span class="overlay-title">📸 Snapshots</span><button class="overlay-close" id="closeSnapshots">✕</button></div>
|
||||||
|
<div class="overlay-body">
|
||||||
|
<div style="display:flex;gap:6px;margin-bottom:6px"><button class="sr-action primary" id="snapSaveCurrent" style="flex:1">💾 Save Current</button></div>
|
||||||
|
<div class="snap-grid" id="snapGrid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Downloads Overlay -->
|
||||||
|
<div class="overlay" id="overlayDownloads">
|
||||||
|
<div class="overlay-hdr"><span class="overlay-title">⬇ Downloads</span><button class="overlay-close" id="closeDownloads">✕</button></div>
|
||||||
|
<div class="overlay-body">
|
||||||
|
<div style="display:flex;gap:6px;margin-bottom:6px">
|
||||||
|
<input id="dlSearch" placeholder="Search Tone3000..." style="flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:11px;outline:none;min-height:36px">
|
||||||
|
<button class="sr-action primary" id="dlSearchBtn">🔍</button>
|
||||||
|
</div>
|
||||||
|
<div id="dlResults"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Overlay -->
|
||||||
|
<div class="overlay" id="overlaySettings">
|
||||||
|
<div class="overlay-hdr"><span class="overlay-title">⚙ Settings</span><button class="overlay-close" id="closeSettings">✕</button></div>
|
||||||
|
<div class="overlay-body">
|
||||||
|
<div class="setting-group"><div class="setting-group-title">Network</div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🌐 Eth IP</span><span class="sr-value" id="netEthIp">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔗 MAC</span><span class="sr-value" id="netEthMac">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🚪 Gateway</span><span class="sr-value" id="netGateway">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🌐 DNS</span><span class="sr-value" id="netDns">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🌍 IP Mode</span><span class="sr-value" id="netDhcpStatus">DHCP</span><button class="sr-action" id="netModeToggle">Static</button></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔗 Hostname</span><span class="sr-value" id="netHostname">pedal</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">📶 WiFi</span><span class="sr-value" id="wifiStatus">—</span><button class="sr-action" id="wifiScanBtn">Scan</button></div>
|
||||||
|
<div id="wifiNetworks" class="wifi-list"></div>
|
||||||
|
<div class="wifi-connect-pw" id="wifiConnectPw" style="display:none"><input type="password" id="wifiPw" placeholder="WiFi password"><button id="wifiConnectBtn">Connect</button></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔥 Hotspot</span><span class="sr-value" id="hotspotStatus">Off</span><button class="sr-action" id="hotspotToggle">Enable</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group"><div class="setting-group-title">Audio</div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🎛 Profile</span><span class="sr-value" id="audioProfileLabel">—</span></div>
|
||||||
|
<div class="audio-profile-grid" id="audioProfileGrid"></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🎸 Instrument</span><span class="sr-value" id="instrumentLabel">Guitar</span><button class="sr-action" id="instrumentCycleBtn" style="min-width:44px;padding:4px 10px">Cycle</button></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔄 Channel Mode</span><span class="sr-value" id="channelModeLabel">Dual Mono</span><button class="sr-action" id="channelModeToggle">Toggle</button></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔗 Routing</span><span class="sr-value" id="routingModeLabel">4CM</span><button class="sr-action" id="routingModeToggle">Toggle</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group"><div class="setting-group-title">Bluetooth</div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔵 Status</span><span class="sr-value" id="btStatus">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🎛 MIDI</span><span class="sr-value" id="btMidiStatus">Off</span><button class="sr-action" id="btMidiToggle">Enable</button></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔓 Discoverable</span><span class="sr-value" id="btDiscStatus">On</span><button class="sr-action" id="btDiscToggle">Disable</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group"><div class="setting-group-title">System</div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔧 CPU</span><span class="sr-value" id="cpuLabel">0%</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🌡 Temp</span><span class="sr-value" id="sysTemp">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">⏱ Uptime</span><span class="sr-value" id="sysUptime">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">💾 Disk</span><span class="sr-value" id="sysDisk">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🧠 RAM</span><span class="sr-value" id="sysMemory">—</span></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">↺ Reset</span><button class="sr-action danger" id="btnResetDefaults">Reset to Defaults</button></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">🔄 Service</span><button class="sr-action danger" id="btnRestartSvc">Restart</button></div>
|
||||||
|
<div class="setting-row"><span class="sr-label">⏻ Reboot</span><button class="sr-action danger" id="btnReboot">Reboot</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tuner -->
|
||||||
|
<div class="tuner-view" id="tunerView">
|
||||||
|
<div class="tuner-note" id="tunerNote">—</div>
|
||||||
|
<div class="tuner-cents"><span style="font-size:10px;color:var(--text-dim)">♭</span><div class="tuner-cents-bar"><div class="tuner-cents-fill in-tune" id="tunerCentsFill"></div></div><span style="font-size:10px;color:var(--text-dim)">♯</span></div>
|
||||||
|
<div class="tuner-freq" id="tunerFreq">0.0 Hz</div>
|
||||||
|
<button class="tuner-exit" id="tunerExit">✕ Close Tuner</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ═══ API Client ═══
|
||||||
|
const API = {
|
||||||
|
_base:'',
|
||||||
|
async _fetch(path,opts={}){
|
||||||
|
const ac=new AbortController();
|
||||||
|
const to=setTimeout(()=>ac.abort(),opts.timeout||6000);
|
||||||
|
try{
|
||||||
|
const r=await fetch(this._base+path,{...opts,signal:ac.signal,headers:{'Content-Type':'application/json',...opts.headers}});
|
||||||
|
clearTimeout(to);
|
||||||
|
if(!r.ok)throw new Error(r.status+' '+r.statusText);
|
||||||
|
return r.status===204?null:r.json();
|
||||||
|
}catch(e){clearTimeout(to);if(e.name==='AbortError')throw new Error('timeout');throw e;}
|
||||||
|
},
|
||||||
|
getState:(ch)=>API._fetch('/api/state?channel='+(ch||'guitar')),
|
||||||
|
getTunerPitch:(ch)=>API._fetch('/api/tuner/pitch?channel='+(ch||'guitar'),{timeout:3000}),
|
||||||
|
bypassToggle:(ch)=>API._fetch('/api/bypass?channel='+(ch||'guitar'),{method:'POST'}),
|
||||||
|
tunerToggle:(ch,on)=>API._fetch('/api/tuner?channel='+(ch||'guitar'),{method:'POST',body:JSON.stringify({on})}),
|
||||||
|
setVolume:(ch,v)=>API._fetch('/api/volume?channel='+(ch||'guitar'),{method:'PUT',body:JSON.stringify({volume:v})}),
|
||||||
|
toggleBlock:(ch,id,en)=>API._fetch('/api/blocks?channel='+(ch||'guitar'),{method:'PATCH',body:JSON.stringify({block_id:id,enabled:en})}),
|
||||||
|
listPresets:(ch)=>API._fetch('/api/presets?channel='+(ch||'guitar')),
|
||||||
|
activatePreset:(ch,b,p)=>API._fetch('/api/presets/'+b+'/'+p+'/activate?channel='+(ch||'guitar'),{method:'POST'}),
|
||||||
|
listSnapshots:(ch)=>API._fetch('/api/snapshots?channel='+(ch||'guitar')),
|
||||||
|
saveSnapshot:(ch,s,data)=>API._fetch('/api/snapshots/'+s+'/save?channel='+(ch||'guitar'),{method:'POST',body:data?JSON.stringify(data):undefined}),
|
||||||
|
recallSnapshot:(ch,s)=>API._fetch('/api/snapshots/'+s+'/recall?channel='+(ch||'guitar'),{method:'POST'}),
|
||||||
|
wifiScan:()=>API._fetch('/api/wifi/scan',{timeout:15000}),
|
||||||
|
wifiStatus:()=>API._fetch('/api/wifi/status'),
|
||||||
|
wifiConnect:(s,p)=>API._fetch('/api/wifi/connect',{method:'POST',body:JSON.stringify({ssid:s,password:p}),timeout:20000}),
|
||||||
|
hotspotEnable:()=>API._fetch('/api/wifi/hotspot/enable',{method:'POST',timeout:15000}),
|
||||||
|
hotspotDisable:()=>API._fetch('/api/wifi/hotspot/disable',{method:'POST'}),
|
||||||
|
hotspotStatus:()=>API._fetch('/api/wifi/hotspot'),
|
||||||
|
btStatus:()=>API._fetch('/api/bluetooth/status'),
|
||||||
|
btPaired:()=>API._fetch('/api/bluetooth/paired'),
|
||||||
|
btMidiStatus:()=>API._fetch('/api/bluetooth/midi-status'),
|
||||||
|
btMidiEnable:()=>API._fetch('/api/bluetooth/midi-enable',{method:'POST'}),
|
||||||
|
btMidiDisable:()=>API._fetch('/api/bluetooth/midi-disable',{method:'POST'}),
|
||||||
|
btDiscoverableSet:(on)=>API._fetch('/api/bluetooth/discoverable',{method:'POST',body:JSON.stringify({on})}),
|
||||||
|
listAudioProfiles:()=>API._fetch('/api/audio/profiles'),
|
||||||
|
setAudioProfile:(p)=>API._fetch('/api/audio/profile',{method:'POST',body:JSON.stringify({profile:p})}),
|
||||||
|
getChannelMode:()=>API._fetch('/api/channel-mode'),
|
||||||
|
setChannelMode:(m)=>API._fetch('/api/channel-mode',{method:'POST',body:JSON.stringify({channel_mode:m})}),
|
||||||
|
getRouting:(ch)=>API._fetch('/api/routing?channel='+(ch||'guitar')),
|
||||||
|
setRouting:(ch,data)=>API._fetch('/api/routing?channel='+(ch||'guitar'),{method:'POST',body:JSON.stringify(data)}),
|
||||||
|
saveSettings:(d)=>API._fetch('/api/settings',{method:'POST',body:JSON.stringify(d)}),
|
||||||
|
getSettings:()=>API._fetch('/api/settings'),
|
||||||
|
getSystem:()=>API._fetch('/api/system'),
|
||||||
|
getSystemLogs:(n)=>API._fetch('/api/system/logs?lines='+(n||50)),
|
||||||
|
setHostname:(h)=>API._fetch('/api/system/hostname',{method:'POST',body:JSON.stringify({hostname:h})}),
|
||||||
|
getNetwork:()=>API._fetch('/api/network'),
|
||||||
|
setNetworkStatic:(d)=>API._fetch('/api/network/static',{method:'POST',body:JSON.stringify(d),timeout:25000}),
|
||||||
|
setNetworkDhcp:()=>API._fetch('/api/network/dhcp',{method:'POST',timeout:25000}),
|
||||||
|
listAudioDevices:()=>API._fetch('/api/audio/devices'),
|
||||||
|
getDiagnostics:()=>API._fetch('/api/diagnostics'),
|
||||||
|
createBackup:()=>API._fetch('/api/backup',{method:'POST',timeout:35000}),
|
||||||
|
listBackups:()=>API._fetch('/api/backup/list'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══ State ═══
|
||||||
|
let state={channel:'guitar',connected:false,bypass:false,master_volume:0.8,blocks:[],routing_mode:'4cm',tuner_enabled:false,cpu_percent:0};
|
||||||
|
let currentChannel='guitar';
|
||||||
|
let channels=['guitar','bass','keys','vocals','backing_tracks'];
|
||||||
|
let presets=[];
|
||||||
|
let snapshots={};
|
||||||
|
let audioProfiles=[];
|
||||||
|
let currentBank=0;
|
||||||
|
let pollTimer=null;
|
||||||
|
let tunerPollTimer=null;
|
||||||
|
let volDragging=false;
|
||||||
|
|
||||||
|
// ═══ Helpers ═══
|
||||||
|
const $=(s)=>document.querySelector(s);
|
||||||
|
const $$=(s)=>document.querySelectorAll(s);
|
||||||
|
function cycleInstrument(){
|
||||||
|
const idx=channels.indexOf(currentChannel);
|
||||||
|
currentChannel=channels[(idx+1)%channels.length];
|
||||||
|
document.getElementById('statusChannel').textContent=currentChannel.charAt(0).toUpperCase()+currentChannel.slice(1).replace('_',' ');
|
||||||
|
localStorage.setItem('pedalCurrentChannel',currentChannel);
|
||||||
|
toast(currentChannel);
|
||||||
|
}
|
||||||
|
function toast(msg,t){const e=$('#toast');e.textContent=msg;e.classList.add('show');clearTimeout(e._h);e._h=setTimeout(()=>e.classList.remove('show'),t||2000);}
|
||||||
|
|
||||||
|
// ═══ Overlay Controller ═══
|
||||||
|
function openOverlay(id){document.getElementById('overlay'+id).classList.add('open');}
|
||||||
|
function closeOverlay(id){document.getElementById('overlay'+id).classList.remove('open');}
|
||||||
|
document.querySelectorAll('.overlay-close').forEach(b=>{
|
||||||
|
b.addEventListener('click',function(){this.closest('.overlay').classList.remove('open');});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══ Init Volume Slider ═══
|
||||||
|
function initVolSlider(){
|
||||||
|
const track=$('#volTrack'),fill=$('#volFill'),thumb=$('#volThumb'),pct=$('#volPct');
|
||||||
|
function setV(p){const v=Math.max(0,Math.min(1,p/100));fill.style.width=(v*100)+'%';thumb.style.left=(v*100)+'%';pct.textContent=Math.round(v*100)+'%';}
|
||||||
|
function upd(e){const r=track.getBoundingClientRect();const x=(e.touches?e.touches[0].clientX:e.clientX)-r.left;setV((x/r.width)*100);}
|
||||||
|
track.addEventListener('mousedown',(e)=>{volDragging=true;upd(e);});
|
||||||
|
track.addEventListener('touchstart',(e)=>{volDragging=true;upd(e);},{passive:true});
|
||||||
|
document.addEventListener('mousemove',(e)=>{if(volDragging){e.preventDefault();upd(e);}});
|
||||||
|
document.addEventListener('touchmove',(e)=>{if(volDragging)upd(e);},{passive:true});
|
||||||
|
document.addEventListener('mouseup',()=>{if(!volDragging)return;volDragging=false;API.setVolume(currentChannel,parseFloat($('#volFill').style.width)/100).catch(()=>{});});
|
||||||
|
document.addEventListener('touchend',()=>{if(!volDragging)return;volDragging=false;API.setVolume(currentChannel,parseFloat($('#volFill').style.width)/100).catch(()=>{});});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ State Loading ═══
|
||||||
|
async function loadState(){
|
||||||
|
try{
|
||||||
|
const s=await API.getState(currentChannel);
|
||||||
|
if(!s)return;
|
||||||
|
state=s;
|
||||||
|
document.getElementById('statusChannel').textContent=currentChannel.charAt(0).toUpperCase()+currentChannel.slice(1).replace('_',' ');
|
||||||
|
document.getElementById('statusDot').className='status-dot '+(s.connected?'on':'off');
|
||||||
|
document.getElementById('statusPreset').textContent=(s.current_preset&&s.current_preset.name)||'—';
|
||||||
|
const vp=Math.round((s.master_volume||0)*100);
|
||||||
|
document.getElementById('statusVolPct').textContent=vp+'%';
|
||||||
|
document.getElementById('volFill').style.width=vp+'%';
|
||||||
|
document.getElementById('volThumb').style.left=vp+'%';
|
||||||
|
document.getElementById('volPct').textContent=vp+'%';
|
||||||
|
const fs=document.getElementById('footswitch');
|
||||||
|
if(s.bypass){fs.className='footswitch off';fs.textContent='MUTE';}
|
||||||
|
else{fs.className='footswitch on';fs.textContent='BYpass';}
|
||||||
|
document.getElementById('cpuLabel').textContent=(s.cpu_percent!=null?s.cpu_percent:'—')+'%';
|
||||||
|
renderFxBlocks();
|
||||||
|
const tb=document.getElementById('tabTuner');
|
||||||
|
if(s.tuner_enabled){tb.querySelector('.tab-label').textContent='Tuning';tb.classList.add('active');}
|
||||||
|
else{tb.querySelector('.tab-label').textContent='Tuner';tb.classList.remove('active');}
|
||||||
|
}catch(e){document.getElementById('statusDot').className='status-dot off';}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ FX Blocks ═══
|
||||||
|
const BLOCK_COLORS={nam_amp:'#E8A030',ir:'#4090E0',gate:'#50D090',compressor:'#E0E060',eq:'#A080E0',reverb:'#60C0E0',delay:'#80D0A0',distortion:'#E06060'};
|
||||||
|
function renderFxBlocks(){
|
||||||
|
const strip=document.getElementById('fxStrip');
|
||||||
|
const blocks=state.blocks||[];
|
||||||
|
const addBtn=document.getElementById('btnAddBlock');
|
||||||
|
strip.innerHTML='';
|
||||||
|
strip.appendChild(addBtn);
|
||||||
|
blocks.forEach((b,i)=>{
|
||||||
|
const div=document.createElement('div');
|
||||||
|
const en=b.enabled!==false&&!b.bypass;
|
||||||
|
div.className='fx-block '+(en?'on':'off');
|
||||||
|
div.innerHTML='<div class="fx-type">'+(b.fx_type||'fx')+'</div><div class="fx-name">'+(b.name||b.fx_type||'Block')+'</div><div class="fx-indicator">'+[...Array(4)].map((_,j)=>'<div class="fx-dot'+(j<3?' active':'')+'"></div>').join('')+'</div>';
|
||||||
|
strip.insertBefore(div,addBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Presets ═══
|
||||||
|
async function loadPresets(){
|
||||||
|
try{
|
||||||
|
presets=await API.listPresets(currentChannel);
|
||||||
|
const grid=document.getElementById('presetGrid');
|
||||||
|
grid.innerHTML='';
|
||||||
|
const bank=(presets.banks||[])[currentBank];
|
||||||
|
const slots=bank?(bank.presets||[]):[];
|
||||||
|
for(let i=0;i<4;i++){
|
||||||
|
const p=slots[i];
|
||||||
|
const div=document.createElement('div');
|
||||||
|
const active=state.current_preset&&state.current_preset.bank===currentBank&&state.current_preset.program===i;
|
||||||
|
div.className='preset-card'+(active?' active':'');
|
||||||
|
if(p){
|
||||||
|
div.innerHTML='<div class="pc-num">'+currentBank+'.'+i+'</div><div class="pc-name">'+(p.name||'Preset')+'</div>';
|
||||||
|
div.onclick=()=>{API.activatePreset(currentChannel,currentBank,i).then(()=>{closeOverlay('Presets');toast('Loaded: '+(p.name||'Preset'));}).catch(e=>toast(e.message));};
|
||||||
|
}else{
|
||||||
|
div.innerHTML='<div class="pc-num">'+currentBank+'.'+i+'</div><div class="pc-name" style="color:var(--text-dim);font-weight:400">Empty</div>';
|
||||||
|
div.style.opacity='.35';
|
||||||
|
}
|
||||||
|
grid.appendChild(div);
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Snapshots ═══
|
||||||
|
async function loadSnapshots(){
|
||||||
|
try{
|
||||||
|
snapshots=await API.listSnapshots(currentChannel);
|
||||||
|
const grid=document.getElementById('snapGrid');
|
||||||
|
const slots=snapshots.snapshots||{};
|
||||||
|
const cur=snapshots.current_snapshot||0;
|
||||||
|
grid.innerHTML='';
|
||||||
|
for(let i=1;i<=8;i++){
|
||||||
|
const s=slots[i];
|
||||||
|
const div=document.createElement('div');
|
||||||
|
div.className='snap-slot'+(s?' used':'')+(cur===i?' current':'');
|
||||||
|
if(s){div.innerHTML='<div class="ss-num">'+i+'</div><div class="ss-name">'+(s.name||'Slot '+i)+'</div>';
|
||||||
|
div.onclick=()=>{API.recallSnapshot(currentChannel,i).then(()=>toast('Recalled: '+(s.name||'Slot '+i))).catch(e=>toast(e.message));};}
|
||||||
|
else{div.innerHTML='<div class="ss-num">'+i+'</div><div class="ss-name" style="color:var(--text-dim)">Empty</div>';}
|
||||||
|
grid.appendChild(div);
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Settings Loaders ═══
|
||||||
|
async function loadNetworkInfo(){
|
||||||
|
try{
|
||||||
|
const n=await API.getNetwork();
|
||||||
|
if(n.ethernet){
|
||||||
|
document.getElementById('netEthIp').textContent=n.ethernet.ip||'—';
|
||||||
|
document.getElementById('netEthMac').textContent=n.ethernet.mac||'—';
|
||||||
|
document.getElementById('netGateway').textContent=n.ethernet.gateway||'—';
|
||||||
|
}
|
||||||
|
if(n.dns)document.getElementById('netDns').textContent=n.dns.join(', ');
|
||||||
|
}catch(e){}
|
||||||
|
try{
|
||||||
|
const w=await API.wifiStatus();
|
||||||
|
if(w.ssid)document.getElementById('wifiStatus').textContent=w.ssid+(w.signal?' ('+w.signal+'dB)':'');
|
||||||
|
else if(w.connected)document.getElementById('wifiStatus').textContent='Connected';
|
||||||
|
else document.getElementById('wifiStatus').textContent='—';
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSystemInfo(){
|
||||||
|
try{
|
||||||
|
const s=await API.getSystem();
|
||||||
|
if(s.cpu_temp_c)document.getElementById('sysTemp').textContent=s.cpu_temp_c+'°C';
|
||||||
|
document.getElementById('sysUptime').textContent=s.uptime||'—';
|
||||||
|
document.getElementById('sysDisk').textContent=s.disk?s.disk.pct+' ('+s.disk.avail+' free)':'—';
|
||||||
|
document.getElementById('sysMemory').textContent=s.memory_mb?Math.round(s.memory_mb.used/s.memory_mb.total*100)+'% ('+s.memory_mb.used+'/'+s.memory_mb.total+'MB)':'—';
|
||||||
|
document.getElementById('netHostname').textContent=s.hostname||'pedal';
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudioProfiles(){
|
||||||
|
try{
|
||||||
|
audioProfiles=await API.listAudioProfiles();
|
||||||
|
const grid=document.getElementById('audioProfileGrid');
|
||||||
|
const cp=audioProfiles.current||'standard';
|
||||||
|
grid.innerHTML='';
|
||||||
|
(audioProfiles.profiles||[]).forEach(p=>{
|
||||||
|
const d=document.createElement('div');
|
||||||
|
d.className='ap-card'+(p.key===cp?' active':'');
|
||||||
|
d.innerHTML='<div class="ap-key">'+p.key.replace(/_/g,' ')+'</div><div class="ap-desc">'+p.period+'fr / '+p.rate+'Hz</div>'+((p.key===cp)?'<div style="font-size:8px;color:var(--amber)">● Active</div>':'');
|
||||||
|
d.onclick=()=>{API.setAudioProfile(p.key).then(()=>{toast('Profile: '+p.key);loadAudioProfiles();}).catch(e=>toast(e.message));};
|
||||||
|
grid.appendChild(d);
|
||||||
|
});
|
||||||
|
document.getElementById('audioProfileLabel').textContent=cp;
|
||||||
|
const cm=await API.getChannelMode();
|
||||||
|
if(cm.channel_mode)document.getElementById('channelModeLabel').textContent=cm.channel_mode.replace(/-/g,' ').replace(/\b\w/g,l=>l.toUpperCase());
|
||||||
|
const rt=await API.getRouting(currentChannel);
|
||||||
|
if(rt.routing_mode)document.getElementById('routingModeLabel').textContent=rt.routing_mode.toUpperCase();
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBtStatus(){
|
||||||
|
try{
|
||||||
|
const st=await API.btStatus();
|
||||||
|
document.getElementById('btStatus').textContent=st.powered?'Powered On':'Powered Off';
|
||||||
|
document.getElementById('btDiscStatus').textContent=st.discoverable?'On':'Off';
|
||||||
|
document.getElementById('btDiscToggle').textContent=st.discoverable?'Disable':'Enable';
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBtMidi(){
|
||||||
|
try{
|
||||||
|
const st=await API.btMidiStatus();
|
||||||
|
const r=st.running||st.enabled||false;
|
||||||
|
document.getElementById('btMidiStatus').textContent=r?'Running':'Off';
|
||||||
|
document.getElementById('btMidiToggle').textContent=r?'Disable':'Enable';
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHotspotStatus(){
|
||||||
|
try{
|
||||||
|
const hs=await API.hotspotStatus();
|
||||||
|
const a=hs.active||false;
|
||||||
|
document.getElementById('hotspotStatus').textContent=a?'On':'Off';
|
||||||
|
document.getElementById('hotspotToggle').textContent=a?'Disable':'Enable';
|
||||||
|
document.getElementById('hotspotToggle').className='sr-action'+(a?' danger':'');
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doWifiScan(){
|
||||||
|
const list=document.getElementById('wifiNetworks');
|
||||||
|
list.innerHTML='<div style="text-align:center;padding:10px;color:var(--text-dim);font-size:11px">Scanning...</div>';
|
||||||
|
try{
|
||||||
|
const r=await API.wifiScan();
|
||||||
|
const nets=r.networks||[];
|
||||||
|
list.innerHTML='';
|
||||||
|
if(nets.length===0){list.innerHTML='<div style="text-align:center;padding:10px;color:var(--text-dim);font-size:11px">No networks found</div>';return;}
|
||||||
|
nets.forEach(n=>{
|
||||||
|
const item=document.createElement('div');
|
||||||
|
item.className='wifi-item';
|
||||||
|
const sec=n.secured!==false;
|
||||||
|
item.innerHTML='<span class="wi-ssid">'+(n.ssid||n.name||'Unknown')+'</span>'+(sec?'<span style="font-size:8px;color:var(--text-dim)">🔒</span>':'<span style="font-size:8px;color:var(--green)">Open</span>')+'<span style="font-size:8px;color:var(--text-dim)">'+(n.signal||'')+'dB</span>';
|
||||||
|
item.onclick=()=>{
|
||||||
|
if(sec){
|
||||||
|
const pwDiv=document.getElementById('wifiConnectPw');
|
||||||
|
pwDiv.style.display='flex';
|
||||||
|
document.getElementById('wifiPw').value='';
|
||||||
|
document.getElementById('wifiPw').focus();
|
||||||
|
document.getElementById('wifiConnectBtn').onclick=async()=>{
|
||||||
|
const pw=document.getElementById('wifiPw').value;
|
||||||
|
if(!pw){toast('Enter password');return;}
|
||||||
|
pwDiv.style.display='none';
|
||||||
|
try{await API.wifiConnect(n.ssid||n.name,pw);toast('Connected!');setTimeout(doWifiScan,3000);}catch(e){toast(e.message);}
|
||||||
|
};
|
||||||
|
}else{
|
||||||
|
API.wifiConnect(n.ssid||n.name,'').then(()=>toast('Connected!')).catch(e=>toast(e.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
document.getElementById('wifiStatus').textContent=nets.length+' networks';
|
||||||
|
}catch(e){list.innerHTML='<div style="text-align:center;padding:10px;color:var(--text-dim);font-size:11px">Scan failed</div>';}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Tuner ═══
|
||||||
|
function startTunerPolling(){stopTunerPolling();if(state.tuner_enabled)tunerPollTimer=setInterval(pollTuner,150);}
|
||||||
|
function stopTunerPolling(){if(tunerPollTimer){clearInterval(tunerPollTimer);tunerPollTimer=null;}}
|
||||||
|
async function pollTuner(){
|
||||||
|
if(!state.tuner_enabled)return;
|
||||||
|
try{
|
||||||
|
const d=await API.getTunerPitch(currentChannel);
|
||||||
|
if(!d)return;
|
||||||
|
document.getElementById('tunerNote').textContent=d.note||'—';
|
||||||
|
const fill=document.getElementById('tunerCentsFill');
|
||||||
|
const c=d.cents||0;
|
||||||
|
if(Math.abs(c)<3){fill.className='tuner-cents-fill in-tune';fill.style.left='calc(50% - 3px)';fill.style.width='6px';}
|
||||||
|
else if(c>0){fill.className='tuner-cents-fill sharp';fill.style.left='calc(50% + '+Math.min(c,50)*1.2+'px)';fill.style.width=Math.min(Math.abs(c),50)+'px';}
|
||||||
|
else{fill.className='tuner-cents-fill flat';fill.style.left='calc(50% - '+Math.min(Math.abs(c),50)*1.2+'px)';fill.style.width=Math.min(Math.abs(c),50)+'px';}
|
||||||
|
document.getElementById('tunerFreq').textContent=(d.frequency||0).toFixed(1)+' Hz';
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Main Poll Loop ═══
|
||||||
|
function startPolling(){stopPolling();loadState();pollTimer=setInterval(loadState,2000);}
|
||||||
|
function stopPolling(){if(pollTimer){clearInterval(pollTimer);pollTimer=null;}}
|
||||||
|
|
||||||
|
// ═══ Init! ═══
|
||||||
|
function init(){
|
||||||
|
// Restore saved channel
|
||||||
|
const savedChannel=localStorage.getItem('pedalCurrentChannel');
|
||||||
|
if(savedChannel && channels.includes(savedChannel)) currentChannel=savedChannel;
|
||||||
|
|
||||||
|
initVolSlider();
|
||||||
|
|
||||||
|
// Footswitch
|
||||||
|
document.getElementById('footswitch').onclick=()=>API.bypassToggle(currentChannel).catch(()=>{});
|
||||||
|
|
||||||
|
// Tab bar navigation
|
||||||
|
document.querySelectorAll('.tab-btn[data-view]').forEach(btn=>{
|
||||||
|
btn.onclick=()=>{
|
||||||
|
const view=btn.dataset.view;
|
||||||
|
if(view==='main'){document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');}
|
||||||
|
else if(view==='settings')openOverlay('Settings');
|
||||||
|
else if(view==='presets'){openOverlay('Presets');loadPresets();}
|
||||||
|
else if(view==='snapshots'){openOverlay('Snapshots');loadSnapshots();}
|
||||||
|
else if(view==='downloads')openOverlay('Downloads');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status bar: settings
|
||||||
|
document.getElementById('btnSettings').onclick=async()=>{
|
||||||
|
document.getElementById('instrumentLabel').textContent=currentChannel.charAt(0).toUpperCase()+currentChannel.slice(1).replace('_',' ');
|
||||||
|
openOverlay('Settings');
|
||||||
|
await loadAudioProfiles();await loadBtStatus();await loadBtMidi();await loadHotspotStatus();
|
||||||
|
await loadNetworkInfo();await loadSystemInfo();await doWifiScan();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close all overlays
|
||||||
|
document.querySelectorAll('.overlay').forEach(o=>{o.onclick=function(e){if(e.target===this)this.classList.remove('open');};});
|
||||||
|
|
||||||
|
// Settings: Toggles
|
||||||
|
document.getElementById('instrumentCycleBtn').onclick=()=>{
|
||||||
|
const idx=channels.indexOf(currentChannel);
|
||||||
|
currentChannel=channels[(idx+1)%channels.length];
|
||||||
|
const displayName=currentChannel.charAt(0).toUpperCase()+currentChannel.slice(1).replace('_',' ');
|
||||||
|
document.getElementById('instrumentLabel').textContent=displayName;
|
||||||
|
document.getElementById('statusChannel').textContent=displayName;
|
||||||
|
localStorage.setItem('pedalCurrentChannel',currentChannel);
|
||||||
|
toast(currentChannel);
|
||||||
|
loadState();
|
||||||
|
};
|
||||||
|
document.getElementById('channelModeToggle').onclick=async()=>{
|
||||||
|
const cur=document.getElementById('channelModeLabel').textContent.toLowerCase();
|
||||||
|
const next=cur.includes('dual')?'stereo':'dual-mono';
|
||||||
|
try{await API.setChannelMode(next);loadAudioProfiles();toast('Mode: '+next);}catch(e){toast(e.message);}
|
||||||
|
};
|
||||||
|
document.getElementById('routingModeToggle').onclick=async()=>{
|
||||||
|
const cur=document.getElementById('routingModeLabel').textContent;
|
||||||
|
try{await API.setRouting(currentChannel,{routing_mode:cur==='MONO'?'4cm':'mono'});loadAudioProfiles();}catch(e){toast(e.message);}
|
||||||
|
};
|
||||||
|
document.getElementById('netModeToggle').onclick=async()=>{
|
||||||
|
const lbl=document.getElementById('netDhcpStatus');
|
||||||
|
if(lbl.textContent==='DHCP'){
|
||||||
|
const ip=prompt('Enter static IP:','192.168.0.100');
|
||||||
|
if(!ip)return;
|
||||||
|
const gw=prompt('Gateway:','192.168.0.1');
|
||||||
|
try{await API.setNetworkStatic({ip,netmask:'24',gateway:gw});lbl.textContent='Static';toast('Static: '+ip);}catch(e){toast(e.message);}
|
||||||
|
}else{
|
||||||
|
try{await API.setNetworkDhcp();lbl.textContent='DHCP';toast('DHCP enabled');}catch(e){toast(e.message);}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById('wifiScanBtn').onclick=doWifiScan;
|
||||||
|
document.getElementById('hotspotToggle').onclick=async()=>{
|
||||||
|
const on=document.getElementById('hotspotToggle').textContent==='Disable';
|
||||||
|
try{if(on)await API.hotspotDisable();else await API.hotspotEnable();loadHotspotStatus();toast(on?'Hotspot off':'Hotspot on');}catch(e){toast(e.message);}
|
||||||
|
};
|
||||||
|
document.getElementById('btMidiToggle').onclick=async()=>{
|
||||||
|
const on=document.getElementById('btMidiToggle').textContent==='Disable';
|
||||||
|
try{if(on)await API.btMidiDisable();else await API.btMidiEnable();loadBtMidi();toast('MIDI '+(on?'off':'on'));}catch(e){toast(e.message);}
|
||||||
|
};
|
||||||
|
document.getElementById('btDiscToggle').onclick=async()=>{
|
||||||
|
const on=document.getElementById('btDiscToggle').textContent==='Disable';
|
||||||
|
try{await API.btDiscoverableSet(!on);loadBtStatus();toast(on?'Hidden':'Visible');}catch(e){toast(e.message);}
|
||||||
|
};
|
||||||
|
|
||||||
|
// System
|
||||||
|
document.getElementById('btnResetDefaults').onclick=async()=>{
|
||||||
|
if(!confirm('Reset all to factory defaults?'))return;
|
||||||
|
toast('Resetting...');
|
||||||
|
try{
|
||||||
|
await API.saveSettings({channel_mode:'dual-mono',brightness:8,tap_tempo:120});
|
||||||
|
await API.setRouting(currentChannel,{routing_mode:'mono'}).catch(()=>{});
|
||||||
|
toast('Defaults restored');
|
||||||
|
setTimeout(()=>{closeOverlay('Settings');loadAudioProfiles();},1000);
|
||||||
|
}catch(e){toast('Failed: '+e.message);}
|
||||||
|
};
|
||||||
|
document.getElementById('btnRestartSvc').onclick=()=>{toast('Restarting service...');fetch('/api/settings',{method:'POST',body:JSON.stringify({_restart:true})}).catch(()=>{});};
|
||||||
|
document.getElementById('btnReboot').onclick=()=>{if(confirm('Reboot pedal?')){toast('Rebooting...');fetch('/api/settings',{method:'POST',body:JSON.stringify({_reboot:true})}).catch(()=>{});}};
|
||||||
|
|
||||||
|
// Tuner
|
||||||
|
document.getElementById('tabTuner').onclick=async()=>{
|
||||||
|
state.tuner_enabled=!state.tuner_enabled;
|
||||||
|
if(state.tuner_enabled){document.getElementById('tunerView').classList.add('open');startTunerPolling();}
|
||||||
|
else{document.getElementById('tunerView').classList.remove('open');stopTunerPolling();}
|
||||||
|
API.tunerToggle(currentChannel,state.tuner_enabled).catch(()=>{});
|
||||||
|
};
|
||||||
|
document.getElementById('tunerExit').onclick=()=>{
|
||||||
|
state.tuner_enabled=false;
|
||||||
|
document.getElementById('tunerView').classList.remove('open');
|
||||||
|
stopTunerPolling();
|
||||||
|
API.tunerToggle(currentChannel,false).catch(()=>{});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshots save
|
||||||
|
document.getElementById('snapSaveCurrent').onclick=async()=>{
|
||||||
|
const slots=snapshots.snapshots||{};
|
||||||
|
let slot=1;
|
||||||
|
for(let i=1;i<=8;i++){if(!slots[i]){slot=i;break;}}
|
||||||
|
try{await API.saveSnapshot(currentChannel,slot);toast('Saved to slot '+slot);loadSnapshots();}catch(e){toast(e.message);}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Downloads search
|
||||||
|
document.getElementById('dlSearchBtn').onclick=async()=>{
|
||||||
|
const q=document.getElementById('dlSearch').value.trim();
|
||||||
|
if(!q)return;
|
||||||
|
const res=document.getElementById('dlResults');
|
||||||
|
res.innerHTML='<div style="text-align:center;padding:16px"><span class="spinner"></span></div>';
|
||||||
|
try{
|
||||||
|
const data=await API._fetch('/api/models/tonedownload/search?q='+encodeURIComponent(q)+'&page=0');
|
||||||
|
const items=data.results||[];
|
||||||
|
res.innerHTML='';
|
||||||
|
if(items.length===0){res.innerHTML='<div style="text-align:center;padding:16px;color:var(--text-dim);font-size:11px">No results</div>';return;}
|
||||||
|
items.forEach(item=>{
|
||||||
|
const d=document.createElement('div');
|
||||||
|
d.className='preset-card';
|
||||||
|
d.style.cursor='default';
|
||||||
|
d.innerHTML='<div class="pc-name">'+(item.name||'Unknown')+'</div><div class="pc-num">'+(item.author||'')+'</div><button class="sr-action primary" style="margin-top:4px;align-self:flex-end">Get</button>';
|
||||||
|
d.querySelector('button').onclick=async()=>{
|
||||||
|
d.querySelector('button').textContent='...';
|
||||||
|
try{await API._fetch('/api/models/tonedownload/install',{method:'POST',body:JSON.stringify({download_url:item.download_url,name:item.name}),timeout:60000});toast('Downloaded!');d.querySelector('button').textContent='✓';}catch(e){toast(e.message);d.querySelector('button').textContent='Retry';}
|
||||||
|
};
|
||||||
|
res.appendChild(d);
|
||||||
|
});
|
||||||
|
}catch(e){res.innerHTML='<div style="text-align:center;padding:16px;color:var(--text-dim)">Error</div>';}
|
||||||
|
};
|
||||||
|
document.getElementById('dlSearch').onkeydown=(e)=>{if(e.key==='Enter')document.getElementById('dlSearchBtn').click();};
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
startPolling();
|
||||||
|
loadPresets();
|
||||||
|
loadSnapshots();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded',init);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user