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:
+124
-63
@@ -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 */}
|
||||
<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)}%` }} />;
|
||||
})}
|
||||
{/* 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>
|
||||
</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>
|
||||
{/* Captured files */}
|
||||
{!recording && (
|
||||
<>
|
||||
<div className="card-sm">
|
||||
<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>
|
||||
|
||||
<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() {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user