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
+123 -62
View File
@@ -603,12 +603,27 @@ function RigScreen({ state, connected, refresh }) {
<div>
<div className="section-label" style={{ marginBottom: 6 }}>IR</div>
{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>
</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>
);
@@ -1093,6 +1108,22 @@ function PresetsScreen({ state, connected, refresh }) {
} 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) => {
try {
await apiDelete(`/api/presets/${bank}/${program}`);
@@ -1113,6 +1144,7 @@ function PresetsScreen({ state, connected, refresh }) {
<div className="screen-title">Presets</div>
<div style={{ display: "flex", gap: 6 }}>
<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>
@@ -1481,100 +1513,129 @@ function SettingsScreen({ state, connected, refresh }) {
);
}
// 7. RECORD / SCOPE SCREEN ─────────────────────────────────────
// 7. LOOPER / RECORDER SCREEN ──────────────────────────────────
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 outputLevel = state?.output_level ?? 0;
const [clipL, setClipL] = useState(false);
const [clipR, setClipR] = useState(false);
// Clip detection
useEffect(() => {
if (inputLevel > 95) { setClipL(true); setTimeout(() => setClipL(false), 300); }
if (outputLevel > 95) { setClipR(true); setTimeout(() => setClipR(false), 300); }
}, [inputLevel, outputLevel]);
const loadCaptures = useCallback(async () => {
try {
const data = await apiGet('/api/captures');
setCaptures(data.captures || []);
} 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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="screen-header">
<div>
<div className="screen-title">Signal Monitor</div>
<div className="screen-title">Looper / Recorder</div>
<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 style={{ display: "flex", gap: 6 }}>
<div className={`badge ${clipL ? 'badge-red' : 'badge-green'}`}>L</div>
<div className={`badge ${clipR ? 'badge-red' : 'badge-green'}`}>R</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{recording ? (
<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 className="screen-body">
{/* Big VU display */}
<div className="card" style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 24, padding: "20px 16px" }}>
{/* Input Level */}
<div>
<div className="section-label" style={{ marginBottom: 8 }}>
Input {inputLevel > 0 ? `${inputLevel}%` : '—'}
{clipL && <span style={{ color: T.red, marginLeft: 8 }}>CLIP</span>}
</div>
<div style={{ display: "flex", gap: 3, height: 24 }}>
{/* Live levels during recording */}
<div className="card" style={{ padding: "16px", display: "flex", gap: 16, alignItems: "center" }}>
<div style={{ flex: 1 }}>
<div className="section-label">Input Level</div>
<div style={{ display: "flex", gap: 2, height: 20, marginTop: 4 }}>
{Array.from({ length: 12 }).map((_, i) => {
const active = Math.round((inputLevel / 100) * 12) > i;
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,
boxShadow: active ? `0 0 6px ${c}` : 'none', transition: 'all .06s' }} />;
return <div key={i} style={{ flex: 1, borderRadius: 2, background: active ? c : T.border,
transition: 'all .05s' }} />;
})}
</div>
</div>
{/* Output Level */}
<div>
<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 }}>
<div style={{ flex: 1 }}>
<div className="section-label">Output Level</div>
<div style={{ display: "flex", gap: 2, height: 20, marginTop: 4 }}>
{Array.from({ length: 12 }).map((_, i) => {
const active = Math.round((outputLevel / 100) * 12) > i;
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,
boxShadow: active ? `0 0 6px ${c}` : 'none', transition: 'all .06s' }} />;
return <div key={i} style={{ flex: 1, borderRadius: 2, background: active ? c : T.border,
transition: 'all .05s' }} />;
})}
</div>
</div>
</div>
{/* Level history chart */}
{/* Recording status */}
{recording && (
<div className="card-sm" style={{ background: `${T.red}15`, borderColor: T.red, textAlign: 'center', padding: 20 }}>
<div style={{ fontSize: 40, marginBottom: 8, animation: 'pulse 1s ease-in-out infinite' }}>🔴</div>
<div style={{ fontSize: 14, fontWeight: 600, color: T.red }}>Recording...</div>
<div className="mono" style={{ fontSize: 11, color: T.textDim, marginTop: 4 }}>
Saving to captures folder tap Stop to finish
</div>
</div>
)}
{/* Captured files */}
{!recording && (
<>
<div className="card-sm">
<div className="section-label">Level History</div>
<div style={{ display: "flex", gap: 1, height: 40, alignItems: "flex-end" }}>
{Array.from({ length: 40 }).map((_, i) => {
const h = Math.max(2, Math.random() * inputLevel * 0.6 + outputLevel * 0.4);
return <div key={i} style={{ flex: 1, borderRadius: '1px 1px 0 0',
background: h > 60 ? T.amber : T.blue, opacity: 0.5 + (h / 200),
height: `${Math.min(100, h)}%` }} />;
})}
<div className="section-label" style={{ marginBottom: 6 }}>Recordings ({captures.length})</div>
{loading ? (
<div className="loader" style={{ margin: '10px auto' }} />
) : captures.length === 0 ? (
<div style={{ fontSize: 12, color: T.textDim, padding: '10px 0' }}>
No recordings yet. Tap Record to capture audio.
</div>
) : (
captures.map(c => (
<div key={c.name} className="preset-row" style={{ padding: '8px 0', borderRadius: 0, borderBottom: `1px solid ${T.border}` }}>
<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>
{/* Status info */}
<div className="card-sm" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<div>
<div className="section-label">NAM Status</div>
<span className={`badge ${state?.nam_loaded ? 'badge-green' : 'badge-red'}`}>
{state?.nam_loaded ? 'Loaded' : '—'}
</span>
</div>
<div>
<div className="section-label">IR Status</div>
<span className={`badge ${state?.ir_loaded ? 'badge-green' : 'badge-red'}`}>
{state?.ir_loaded ? 'Loaded' : '—'}
</span>
</div>
</div>
</div>
<style>{`@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
</div>
);
}
@@ -1590,7 +1651,7 @@ const TABS = [
{ id: "irs", label: "IRs", icon: "🎚" },
{ id: "presets", label: "Presets", icon: "⭐" },
{ id: "settings", label: "Settings", icon: "⚙" },
{ id: "record", label: "Record", icon: "🎙" },
{ id: "record", label: "Looper", icon: "🎙" },
];
export default function App() {
+70
View File
@@ -16,6 +16,7 @@ import json
import os
import logging
import time
import datetime
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
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 ─────────────────────────────
# These are direct aliases for Tone3000 search, providing a
# 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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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">
</head>
<body>