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>
|
||||||
<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() {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user