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 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() {