feat: new preset, looper recorder, IR display improvements

- New Preset button: finds first empty slot, saves with default name
- Looper/Recorder tab: Record/Stop via arecord backend, list captures, play back
- Rig: shows loaded IR name more clearly + quick-link buttons to browse IRs/Models
- Backend: capture/start, capture/stop, captures API endpoints with file serve
This commit is contained in:
2026-06-12 17:24:14 -04:00
parent 2a30592ab3
commit a1f35a56e3
5 changed files with 347 additions and 216 deletions
+124 -63
View File
@@ -603,12 +603,27 @@ function RigScreen({ state, connected, refresh }) {
<div> <div>
<div className="section-label" style={{ marginBottom: 6 }}>IR</div> <div className="section-label" style={{ marginBottom: 6 }}>IR</div>
{state?.ir_name ? ( {state?.ir_name ? (
<div className="nam-chip" style={{ color: T.green, borderColor: `${T.green}55`, background: `${T.green}15` }}>{state.ir_name}</div> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div className="nam-chip" style={{ color: T.green, borderColor: `${T.green}55`, background: `${T.green}15`, fontSize: 10 }}>{state.ir_name}</div>
</div>
) : ( ) : (
<div style={{ fontSize: 11, color: T.textDim }}>Not loaded</div> <div style={{ fontSize: 11, color: T.textDim }}>Not loaded</div>
)} )}
</div> </div>
</div> </div>
{/* Quick links */}
<div className="card-sm" style={{ display: "flex", justifyContent: "space-around", gap: 6 }}>
<button className="btn btn-ghost btn-sm" style={{ flex: 1, fontSize: 10 }} onClick={() => {
const tabbar = document.querySelector('.tabbar');
const tabs = tabbar?.children;
if (tabs) tabs[3]?.click(); // IRs tab index
}}>Browse IRs</button>
<button className="btn btn-ghost btn-sm" style={{ flex: 1, fontSize: 10 }} onClick={() => {
const tabbar = document.querySelector('.tabbar');
const tabs = tabbar?.children;
if (tabs) tabs[2]?.click(); // Models tab index
}}>Browse Models</button>
</div>
</div> </div>
</div> </div>
); );
@@ -1093,6 +1108,22 @@ function PresetsScreen({ state, connected, refresh }) {
} catch (e) { alert('Failed to save'); } } catch (e) { alert('Failed to save'); }
}; };
const handleNewPreset = async () => {
// Find the first empty slot, or create at next program number
const bank = selectedBank;
const bankData = banks[bank];
const presets = bankData?.presets || [];
let nextProg = presets.findIndex(p => p == null);
if (nextProg === -1) nextProg = presets.length;
const name = `New Preset ${nextProg + 1}`;
try {
await apiPut(`/api/presets/${bank}/${nextProg}`, { name });
await loadPresets();
await apiPost(`/api/presets/${bank}/${nextProg}/activate`);
refresh();
} catch (e) { alert('Failed to create preset'); }
};
const handleDelete = async (bank, program) => { const handleDelete = async (bank, program) => {
try { try {
await apiDelete(`/api/presets/${bank}/${program}`); await apiDelete(`/api/presets/${bank}/${program}`);
@@ -1113,6 +1144,7 @@ function PresetsScreen({ state, connected, refresh }) {
<div className="screen-title">Presets</div> <div className="screen-title">Presets</div>
<div style={{ display: "flex", gap: 6 }}> <div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost btn-sm" onClick={() => setShowSave(true)}>Save</button> <button className="btn btn-ghost btn-sm" onClick={() => setShowSave(true)}>Save</button>
<button className="btn btn-primary btn-sm" onClick={handleNewPreset}>+ New</button>
</div> </div>
</div> </div>
@@ -1481,100 +1513,129 @@ function SettingsScreen({ state, connected, refresh }) {
); );
} }
// 7. RECORD / SCOPE SCREEN ───────────────────────────────────── // 7. LOOPER / RECORDER SCREEN ──────────────────────────────────
function RecordScreen({ state }) { function RecordScreen({ state }) {
// Live input/output levels from the pipeline (updated every poll) const [recording, setRecording] = useState(false);
const [captures, setCaptures] = useState([]);
const [loading, setLoading] = useState(true);
const inputLevel = state?.input_level ?? 0; const inputLevel = state?.input_level ?? 0;
const outputLevel = state?.output_level ?? 0; const outputLevel = state?.output_level ?? 0;
const [clipL, setClipL] = useState(false);
const [clipR, setClipR] = useState(false);
// Clip detection const loadCaptures = useCallback(async () => {
useEffect(() => { try {
if (inputLevel > 95) { setClipL(true); setTimeout(() => setClipL(false), 300); } const data = await apiGet('/api/captures');
if (outputLevel > 95) { setClipR(true); setTimeout(() => setClipR(false), 300); } setCaptures(data.captures || []);
}, [inputLevel, outputLevel]); } catch (e) { /* ignore */ }
setLoading(false);
}, []);
useEffect(() => { loadCaptures(); }, [loadCaptures]);
const handleRecord = async () => {
try {
await apiPost('/api/capture/start');
setRecording(true);
} catch (e) { alert('Failed to start recording'); }
};
const handleStop = async () => {
try {
await apiPost('/api/capture/stop');
setRecording(false);
loadCaptures();
} catch (e) { alert('Failed to stop recording'); }
};
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header"> <div className="screen-header">
<div> <div>
<div className="screen-title">Signal Monitor</div> <div className="screen-title">Looper / Recorder</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}> <div className="mono" style={{ fontSize: 10, color: T.textDim }}>
{state?.sample_rate ? `${state.sample_rate/1000}kHz` : '—'} · {state?.cpu_percent != null ? `CPU ${state.cpu_percent}%` : ''} {state?.sample_rate ? `${state.sample_rate/1000}kHz` : '—'}
{recording && <span style={{ color: T.red, marginLeft: 8 }}> RECORDING</span>}
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 6 }}> <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div className={`badge ${clipL ? 'badge-red' : 'badge-green'}`}>L</div> {recording ? (
<div className={`badge ${clipR ? 'badge-red' : 'badge-green'}`}>R</div> <button className="btn btn-danger btn-sm" onClick={handleStop} style={{ padding: "8px 16px" }}>
Stop
</button>
) : (
<button className="btn btn-primary btn-sm" onClick={handleRecord} style={{ padding: "8px 16px", background: T.red, color: '#fff' }}>
Record
</button>
)}
</div> </div>
</div> </div>
<div className="screen-body"> <div className="screen-body">
{/* Big VU display */} {/* Live levels during recording */}
<div className="card" style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 24, padding: "20px 16px" }}> <div className="card" style={{ padding: "16px", display: "flex", gap: 16, alignItems: "center" }}>
{/* Input Level */} <div style={{ flex: 1 }}>
<div> <div className="section-label">Input Level</div>
<div className="section-label" style={{ marginBottom: 8 }}> <div style={{ display: "flex", gap: 2, height: 20, marginTop: 4 }}>
Input {inputLevel > 0 ? `${inputLevel}%` : '—'}
{clipL && <span style={{ color: T.red, marginLeft: 8 }}>CLIP</span>}
</div>
<div style={{ display: "flex", gap: 3, height: 24 }}>
{Array.from({ length: 12 }).map((_, i) => { {Array.from({ length: 12 }).map((_, i) => {
const active = Math.round((inputLevel / 100) * 12) > i; const active = Math.round((inputLevel / 100) * 12) > i;
const c = i >= 10 ? T.red : i >= 8 ? '#E8C030' : T.green; const c = i >= 10 ? T.red : i >= 8 ? '#E8C030' : T.green;
return <div key={i} style={{ flex: 1, borderRadius: 3, background: active ? c : T.border, return <div key={i} style={{ flex: 1, borderRadius: 2, background: active ? c : T.border,
boxShadow: active ? `0 0 6px ${c}` : 'none', transition: 'all .06s' }} />; transition: 'all .05s' }} />;
})} })}
</div> </div>
</div> </div>
<div style={{ flex: 1 }}>
{/* Output Level */} <div className="section-label">Output Level</div>
<div> <div style={{ display: "flex", gap: 2, height: 20, marginTop: 4 }}>
<div className="section-label" style={{ marginBottom: 8 }}>
Output {outputLevel > 0 ? `${outputLevel}%` : '—'}
{clipR && <span style={{ color: T.red, marginLeft: 8 }}>CLIP</span>}
</div>
<div style={{ display: "flex", gap: 3, height: 24 }}>
{Array.from({ length: 12 }).map((_, i) => { {Array.from({ length: 12 }).map((_, i) => {
const active = Math.round((outputLevel / 100) * 12) > i; const active = Math.round((outputLevel / 100) * 12) > i;
const c = i >= 10 ? T.red : i >= 8 ? '#E8C030' : T.green; const c = i >= 10 ? T.red : i >= 8 ? '#E8C030' : T.green;
return <div key={i} style={{ flex: 1, borderRadius: 3, background: active ? c : T.border, return <div key={i} style={{ flex: 1, borderRadius: 2, background: active ? c : T.border,
boxShadow: active ? `0 0 6px ${c}` : 'none', transition: 'all .06s' }} />; transition: 'all .05s' }} />;
})} })}
</div> </div>
</div> </div>
</div> </div>
{/* Level history chart */} {/* Recording status */}
<div className="card-sm"> {recording && (
<div className="section-label">Level History</div> <div className="card-sm" style={{ background: `${T.red}15`, borderColor: T.red, textAlign: 'center', padding: 20 }}>
<div style={{ display: "flex", gap: 1, height: 40, alignItems: "flex-end" }}> <div style={{ fontSize: 40, marginBottom: 8, animation: 'pulse 1s ease-in-out infinite' }}>🔴</div>
{Array.from({ length: 40 }).map((_, i) => { <div style={{ fontSize: 14, fontWeight: 600, color: T.red }}>Recording...</div>
const h = Math.max(2, Math.random() * inputLevel * 0.6 + outputLevel * 0.4); <div className="mono" style={{ fontSize: 11, color: T.textDim, marginTop: 4 }}>
return <div key={i} style={{ flex: 1, borderRadius: '1px 1px 0 0', Saving to captures folder tap Stop to finish
background: h > 60 ? T.amber : T.blue, opacity: 0.5 + (h / 200), </div>
height: `${Math.min(100, h)}%` }} />;
})}
</div> </div>
</div> )}
{/* Status info */} {/* Captured files */}
<div className="card-sm" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}> {!recording && (
<div> <>
<div className="section-label">NAM Status</div> <div className="card-sm">
<span className={`badge ${state?.nam_loaded ? 'badge-green' : 'badge-red'}`}> <div className="section-label" style={{ marginBottom: 6 }}>Recordings ({captures.length})</div>
{state?.nam_loaded ? 'Loaded' : '—'} {loading ? (
</span> <div className="loader" style={{ margin: '10px auto' }} />
</div> ) : captures.length === 0 ? (
<div> <div style={{ fontSize: 12, color: T.textDim, padding: '10px 0' }}>
<div className="section-label">IR Status</div> No recordings yet. Tap Record to capture audio.
<span className={`badge ${state?.ir_loaded ? 'badge-green' : 'badge-red'}`}> </div>
{state?.ir_loaded ? 'Loaded' : '—'} ) : (
</span> captures.map(c => (
</div> <div key={c.name} className="preset-row" style={{ padding: '8px 0', borderRadius: 0, borderBottom: `1px solid ${T.border}` }}>
</div> <div style={{ flex: 1 }}>
<div style={{ fontSize: 13 }}>{c.name}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>{c.size_display}</div>
</div>
<a href={`/api/captures/${c.name}`} target="_blank" rel="noopener"
className="btn btn-ghost btn-sm" style={{ textDecoration: 'none' }}> Play</a>
</div>
))
)}
</div>
</>
)}
</div> </div>
<style>{`@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
</div> </div>
); );
} }
@@ -1590,7 +1651,7 @@ const TABS = [
{ id: "irs", label: "IRs", icon: "🎚" }, { id: "irs", label: "IRs", icon: "🎚" },
{ id: "presets", label: "Presets", icon: "⭐" }, { id: "presets", label: "Presets", icon: "⭐" },
{ id: "settings", label: "Settings", icon: "⚙" }, { id: "settings", label: "Settings", icon: "⚙" },
{ id: "record", label: "Record", icon: "🎙" }, { id: "record", label: "Looper", icon: "🎙" },
]; ];
export default function App() { export default function App() {
+70
View File
@@ -16,6 +16,7 @@ import json
import os import os
import logging import logging
import time import time
import datetime
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -892,6 +893,75 @@ class WebServer:
}, },
) )
# ── Audio capture (looper/recorder) ──────────────────────
_capture_process = None
_capture_path = None
@app.post("/api/capture/start")
async def capture_start():
"""Start recording audio to a WAV file via arecord."""
nonlocal _capture_process, _capture_path
import subprocess, datetime
captures_dir = Path.home() / ".pedal" / "captures"
captures_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
path = captures_dir / f"capture_{ts}.wav"
try:
_capture_process = subprocess.Popen(
["arecord", "-f", "S16_LE", "-r", "48000", "-c", "2", str(path)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
_capture_path = path
return {"ok": True, "path": str(path), "filename": path.name}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/capture/stop")
async def capture_stop():
"""Stop the current recording."""
nonlocal _capture_process, _capture_path
if _capture_process is None:
raise HTTPException(status_code=400, detail="No active recording")
try:
_capture_process.terminate()
_capture_process.wait(timeout=5)
except Exception:
_capture_process.kill()
name = _capture_path.name if _capture_path else "unknown"
_capture_process = None
_capture_path = None
return {"ok": True, "filename": name}
@app.get("/api/captures")
async def list_captures():
"""List saved captures."""
captures_dir = Path.home() / ".pedal" / "captures"
if not captures_dir.is_dir():
return {"captures": []}
files = sorted(captures_dir.glob("*.wav"), key=lambda f: f.stat().st_mtime, reverse=True)
return {
"captures": [
{
"name": f.name,
"path": str(f),
"size": f.stat().st_size,
"size_display": f"{f.stat().st_size / 1024:.0f} KB" if f.stat().st_size < 1024*1024 else f"{f.stat().st_size / (1024*1024):.1f} MB",
"created": datetime.datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
}
for f in files
]
}
@app.get("/api/captures/{filename}")
async def serve_capture(filename: str):
"""Serve a captured WAV file for download/playback."""
from fastapi.responses import FileResponse
captures_dir = Path.home() / ".pedal" / "captures"
file_path = captures_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="Capture not found")
return FileResponse(str(file_path), media_type="audio/wav", filename=filename)
# ── Tonehub API alias routes ───────────────────────────── # ── Tonehub API alias routes ─────────────────────────────
# These are direct aliases for Tone3000 search, providing a # These are direct aliases for Tone3000 search, providing a
# simpler /api/tonehub/search path for the web UI. # simpler /api/tonehub/search path for the web UI.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pi Multi-FX Pedal</title> <title>Pi Multi-FX Pedal</title>
<script type="module" crossorigin src="/ui/assets/index-RvIwpJwB.js"></script> <script type="module" crossorigin src="/ui/assets/index-BwPywO0-.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-CuJgR-s6.css"> <link rel="stylesheet" crossorigin href="/ui/assets/index-CuJgR-s6.css">
</head> </head>
<body> <body>