diff --git "a/app/frontend/src/components/PerformancePanel.js" "b/app/frontend/src/components/PerformancePanel.js" --- "a/app/frontend/src/components/PerformancePanel.js" +++ "b/app/frontend/src/components/PerformancePanel.js" @@ -6,6 +6,7 @@ import { Slider, Button, Alert, + Divider, FormControl, FormControlLabel, Switch, @@ -13,11 +14,18 @@ import { MenuItem, TextField, IconButton, - Tooltip, ButtonBase, Menu, ListItemText, + ListSubheader, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + InputAdornment, } from '@mui/material'; +import { TIPS } from '../tooltips'; +import Tooltip from './Tooltip'; import { Play as PlayAllIcon, Square as StopAllIcon, @@ -27,14 +35,18 @@ import { X as CloseXIcon, Headphones as CueIcon, Volume2 as AudioSetupIcon, + RotateCcw as RestoreIcon, + Download as DownloadIcon, + Circle as RecordIcon, } from 'lucide-react'; import api from '../api'; import PerformanceChannel from './PerformanceChannel'; -import { PerformanceEngine } from '../utils/performanceAudio'; +import { PerformanceEngine, IMPULSE_RESPONSES, MASTER_DELAY_DIVISIONS } from '../utils/performanceAudio'; import { performancePanelStyles as styles, perfTokens } from '../theme'; import { MidiProvider, MidiMappable, useMidi, clearMidiConfig } from './MidiContext'; import MidiConfigMenu from './MidiConfigMenu'; import { isCueSupported, listOutputDevices, setCueDevice, setCueOutputPair } from '../utils/cueAudio'; +import { filterLorasForModel } from '../utils/loraMatch'; import { usePerformanceSession, listPresetNames, @@ -43,9 +55,20 @@ import { loadPresetIntoSession, clearPerformanceSession, } from './usePerformanceSession'; +import { + channelScope, + presetChannelScope, + copyScope, + clearScope as clearFragmentScope, +} from '../utils/fragmentStorage'; const CHANNEL_COUNT = 4; const MASTER_COLOR = '#35C2D4'; +// Transport colors (original): Play uses the master cyan, Stop the error red, +// Record its own red — kept as icon-only buttons. +const RECORD_COLOR = '#E5484D'; // record red +const STOP_COLOR = '#F2A06A'; // light orange — kept distinct from record red +const PLAY_COLOR = '#35C2D4'; // master cyan const MASTER_DB_MIN = -60; const MASTER_DB_MAX = 0; const MASTER_DB_DEFAULT = -6; @@ -78,23 +101,32 @@ const formatDb = (db) => { }; export default function PerformancePanel(props) { + // Reset key for the inner panel. Bumping it forces a full remount of + // PerformancePanelInner, which makes usePerformanceSession re-read from + // localStorage and every PerformanceChannel re-hydrate from IDB. The + // MidiProvider sits outside so MIDI mappings survive a reset (they + // have their own clearMidiConfig pathway when needed). + const [resetKey, setResetKey] = useState(0); + const triggerReset = useCallback(() => setResetKey((k) => k + 1), []); return ( - + ); } function PerformancePanelInner({ selectedModel, - selectedUnwrappedModel, availableModels = [], baseModels = [], availableLoras = [], selectedLora = '', loraMultiplier = 1.0, onSelectModel, - onSelectUnwrappedModel, onRefreshModels, onSelectLora, onLoraMultiplierChange, @@ -105,6 +137,7 @@ function PerformancePanelInner({ onRandomSeedChange, onSeedValueChange, onPresetLoaded, + onOpenCheckpointManager, }) { const { session, updateGlobal, updateChannel } = usePerformanceSession(CHANNEL_COUNT); @@ -129,7 +162,19 @@ function PerformancePanelInner({ const [channelStates, setChannelStates] = useState(() => Array.from({ length: CHANNEL_COUNT }, () => ({ loaded: false, playing: false })) ); - const [injectBpm, setInjectBpm] = useState(session.injectBpm ?? true); + const [promptKey, setPromptKey] = useState(session.promptKey ?? ''); + const [promptInjectBpm, setPromptInjectBpm] = useState(session.promptInjectBpm ?? false); + const [promptTimeSig, setPromptTimeSig] = useState(session.promptTimeSig ?? ''); + const [masterReverbIR, setMasterReverbIR] = useState(session.masterReverbIR ?? 'hall'); + const [masterDelayDivision, setMasterDelayDivision] = useState(session.masterDelayDivision ?? '1/4'); + + // Master recording. `recording` reflects an active capture; once stopped, + // `pendingRecording` holds the encoded WAV { blob, durationSec } until the + // user names + saves it (or discards) via the name dialog. + const [recording, setRecording] = useState(false); + const [pendingRecording, setPendingRecording] = useState(null); + const [recordingName, setRecordingName] = useState(''); + const [savingRecording, setSavingRecording] = useState(false); // Audio output device. setSinkId requires Chromium ≥ 110 (cueSupported // is the runtime check). One device drives BOTH main and cue. Per-pair @@ -201,9 +246,6 @@ function PerformancePanelInner({ if (session.selectedModel && session.selectedModel !== selectedModel) { onSelectModel?.(session.selectedModel); } - if (session.selectedUnwrappedModel && session.selectedUnwrappedModel !== selectedUnwrappedModel) { - onSelectUnwrappedModel?.(session.selectedUnwrappedModel); - } if (typeof session.steps === 'number' && session.steps !== steps) { onStepsChange?.(session.steps); } @@ -221,10 +263,19 @@ function PerformancePanelInner({ useEffect(() => { updateGlobal('bpm', bpm); }, [bpm, updateGlobal]); useEffect(() => { updateGlobal('launchQuantum', launchQuantum); }, [launchQuantum, updateGlobal]); useEffect(() => { updateGlobal('masterDb', masterDb); }, [masterDb, updateGlobal]); - useEffect(() => { updateGlobal('injectBpm', injectBpm); }, [injectBpm, updateGlobal]); + useEffect(() => { updateGlobal('promptKey', promptKey); }, [promptKey, updateGlobal]); + useEffect(() => { updateGlobal('promptInjectBpm', promptInjectBpm); }, [promptInjectBpm, updateGlobal]); + useEffect(() => { updateGlobal('promptTimeSig', promptTimeSig); }, [promptTimeSig, updateGlobal]); + useEffect(() => { + updateGlobal('masterReverbIR', masterReverbIR); + engineRef.current?.setMasterReverbIR?.(masterReverbIR); + }, [masterReverbIR, updateGlobal]); + useEffect(() => { + updateGlobal('masterDelayDivision', masterDelayDivision); + engineRef.current?.setMasterDelayDivision?.(masterDelayDivision); + }, [masterDelayDivision, updateGlobal]); useEffect(() => { updateGlobal('linkEnabled', linkEnabled); }, [linkEnabled, updateGlobal]); useEffect(() => { updateGlobal('selectedModel', selectedModel || ''); }, [selectedModel, updateGlobal]); - useEffect(() => { updateGlobal('selectedUnwrappedModel', selectedUnwrappedModel || ''); }, [selectedUnwrappedModel, updateGlobal]); useEffect(() => { updateGlobal('steps', steps); }, [steps, updateGlobal]); useEffect(() => { updateGlobal('randomSeed', randomSeed); }, [randomSeed, updateGlobal]); useEffect(() => { updateGlobal('seedValue', seedValue); }, [seedValue, updateGlobal]); @@ -259,7 +310,7 @@ function PerformancePanelInner({ } }; - const handleRestoreDefaults = () => { + const handleRestoreDefaults = async () => { if (!restoreArmed) { // First click arms; second click within 3 s commits. Disarms // automatically so the destructive path is never one accidental @@ -271,50 +322,136 @@ function PerformancePanelInner({ } clearPerformanceSession(); clearMidiConfig(); + // Drop every channel's fragment blobs from IDB so a fresh start is + // actually fresh. Presets keep their own scopes and survive. + await Promise.all( + Array.from({ length: CHANNEL_COUNT }, (_, i) => + clearFragmentScope(channelScope(i)).catch(() => { /* ignore */ }) + ) + ); closePresetMenu(); onPresetLoaded?.(); }; - const handleSaveAs = () => { + const handleSaveAs = async () => { const name = saveAsName.trim(); if (!name) return; savePreset(name, session); + // Copy each channel's session-scope blobs into the preset-scope so + // the preset's fragments survive overwrites of the live session. Done + // after the metadata save so a quota failure here still leaves a + // recoverable (if blob-less) preset entry. + await Promise.all( + Array.from({ length: CHANNEL_COUNT }, async (_, i) => { + const dst = presetChannelScope(name, i); + // Replace, don't merge — a re-save of the same preset name + // should reflect the current session exactly. + await clearFragmentScope(dst).catch(() => { /* ignore */ }); + await copyScope(channelScope(i), dst).catch(() => { /* ignore */ }); + }) + ); setSaveAsName(''); refreshPresetNames(); }; - const handleLoadPreset = (name) => { + const handleLoadPreset = async (name) => { if (!loadPresetIntoSession(name)) return; + // Swap the IDB session-scope blobs to match the loaded preset's + // metadata. MUST complete before onPresetLoaded triggers remount — + // otherwise the new channels hydrate from a stale session scope. + await Promise.all( + Array.from({ length: CHANNEL_COUNT }, async (_, i) => { + const dst = channelScope(i); + await clearFragmentScope(dst).catch(() => { /* ignore */ }); + await copyScope(presetChannelScope(name, i), dst).catch(() => { /* ignore */ }); + }) + ); closePresetMenu(); // Force-remount via the App-level reset key. Same pathway as Fresh // Start, just with a different localStorage payload pre-loaded. onPresetLoaded?.(); }; - const handleDeletePreset = (name, e) => { + const handleDeletePreset = async (name, e) => { e?.stopPropagation(); deletePreset(name); + await Promise.all( + Array.from({ length: CHANNEL_COUNT }, (_, i) => + clearFragmentScope(presetChannelScope(name, i)).catch(() => { /* ignore */ }) + ) + ); refreshPresetNames(); }; - // Resolve which base model the current selection actually is. Fine-tuned - // models carry `base_model` from training_metadata.json via /api/models; - // legacy fine-tunes without that field fall back to the size heuristic. + // Resolve which SA3 base the current selection maps to. Direct picks of + // `sa3-*` models are themselves the base; fine-tuned models carry their + // base_model in training_metadata.json (exposed via /api/models). const resolvedBaseModel = (() => { if (!selectedModel) return null; - if (selectedModel === 'stable-audio-open-small' || selectedModel === 'stable-audio-open-1.0') { - return selectedModel; - } + if (selectedModel.startsWith('sa3-')) return selectedModel; const model = availableModels.find((m) => m.name === selectedModel); - if (model?.base_model) return model.base_model; - if (model && selectedUnwrappedModel) { - const u = model.unwrapped_models?.find((x) => x.path === selectedUnwrappedModel); - if (u) return (u.size_mb || 0) < 2000 ? 'stable-audio-open-small' : 'stable-audio-open-1.0'; - } - return null; + return model?.base_model || null; })(); - const isSmallModel = resolvedBaseModel === 'stable-audio-open-small'; + const isSmallModel = !!resolvedBaseModel && resolvedBaseModel.startsWith('sa3-small-'); + // Distilled (post-trained) SA3 variants — names that DON'T end in `-base`. + // The Steps dropdown only locks at 8 for these; the *-base checkpoints let + // the user pick a real step count. + const isDistilledSA3 = !!resolvedBaseModel + && resolvedBaseModel.startsWith('sa3-') + && !resolvedBaseModel.endsWith('-base'); + + // Split baseModels by `kind` for the model-picker grouping. The render + // helper is also hoisted to component scope so its MenuItems land as + // direct children of setLaunchQuantum(Number(e.target.value))} @@ -1051,8 +1321,8 @@ function PerformancePanelInner({ }} > {LAUNCH_QUANTIZE_OPTIONS.map((opt) => ( - - {opt.label} + + {opt.label} ))} @@ -1075,31 +1345,35 @@ function PerformancePanelInner({ onChange={handleBpmChange} onFocus={handleBpmFocus} onBlur={handleBpmBlur} - inputProps={{ step: 1, inputMode: 'numeric', 'aria-label': 'Tempo in BPM' }} + // inputProps.style wins against MUI's + // .MuiInputBase-inputSizeSmall 14px default — needed + // because the pillControl theme can't reach the + // rendered at the same CSS specificity. + inputProps={{ + step: 1, + inputMode: 'numeric', + 'aria-label': 'Tempo in BPM', + style: { + textAlign: 'right', + fontVariantNumeric: 'tabular-nums', + fontSize: perfTokens.fontSize.sm, + fontWeight: perfTokens.weight.bold, + paddingRight: 0, + }, + }} InputProps={{ endAdornment: ( - + BPM - + ), }} sx={{ - width: 84, - '& .MuiOutlinedInput-root': { borderRadius: 1.5, pr: 1 }, - '& input': { - textAlign: 'right', - fontVariantNumeric: 'tabular-nums', - fontSize: perfTokens.fontSize.body, - pr: 0, + ...styles.pillControl, + width: 92, + '& .MuiOutlinedInput-root': { + ...styles.pillControl['& .MuiOutlinedInput-root'], + pr: 1, }, '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { WebkitAppearance: 'none', @@ -1113,30 +1387,98 @@ function PerformancePanelInner({ {/* Model picker + FT-checkpoint + LoRA pickers now live in the bottom bar so the top strip stays just BPM + transport. */} + {/* Icon-only transport — titles live in the tooltips. */} - + + + + + + + - + + + + + + + + + + {/* Record the master output. Stopping opens a dialog to name + the capture, saved as a WAV in the output folder. */} + + + + + {recording + ? + : } + + + + + {/* Main / Cue output channel-pair selectors. Pushed to the right + edge of the bar via ml: 'auto' so the transport controls keep + their cluster on the left. Pairs are derived from the current + device's maxChannelCount; Stage 1 stores the selection, + Stage 2 wires the ChannelMergerNode routing. */} + {(() => { + const pairCount = Math.max(1, Math.floor(maxChannelCount / 2)); + const pairs = Array.from({ length: pairCount }, (_, i) => ({ + value: i, + label: `${i * 2 + 1}–${i * 2 + 2}`, + })); + const fieldGroup = (label, value, onChange) => ( + + {label} + + + + + ); + return ( + + {fieldGroup( + 'Main out', + Math.min(mainOutputPair, pairCount - 1), + (e) => setMainOutputPair(Number(e.target.value)), + )} + {fieldGroup( + 'Cue out', + Math.min(cueOutputPair, pairCount - 1), + (e) => setCueOutputPair(Number(e.target.value)), + )} + + ); + })()} {error && ( @@ -1168,186 +1510,170 @@ function PerformancePanelInner({ - MASTER + Master - - - - - - handleMasterChange(null, v)} - sx={{ flex: 1, alignSelf: 'stretch' }} - > - + + + + + + - - - - - - dBFS {formatDb(masterDb)} - - - pk {formatDb(peakLabelDb)} - - + value={masterDb} + onChange={(v) => handleMasterChange(null, v)} + sx={{ + alignSelf: 'stretch', + // Fixed-width lane wide enough for the 16 px + // thumb plus a touch of hit area. Without an + // explicit width the wrapper would either + // collapse to nothing (no flex parent on the + // slider) or stretch to fill (with flex: 1), + // which pinned the fader to the left edge. + width: 20, + display: 'flex', + justifyContent: 'center', + }} + > + + + - {/* Main / Cue output channel pair selection. Pairs are - derived from the current device's maxChannelCount. - Stage 1: selections are stored but routing still goes - through the stereo destination — channel-pair routing - via ChannelMergerNode lands in Stage 2. */} - {(() => { - const pairCount = Math.max(1, Math.floor(maxChannelCount / 2)); - const pairs = Array.from({ length: pairCount }, (_, i) => ({ - value: i, - label: `${i * 2 + 1}–${i * 2 + 2}`, - })); - const labelSx = { - fontSize: perfTokens.fontSize.small, - letterSpacing: perfTokens.letterSpacing.wide, - color: 'text.disabled', - display: 'block', - mb: 0.25, - }; - const selectSx = { - width: '100%', - '& .MuiOutlinedInput-root': { borderRadius: 1.5 }, - '& .MuiSelect-select': { - fontSize: perfTokens.fontSize.body, - py: 0.5, - }, - }; - return ( - - - MAIN OUT - - - + + + + DBFS - - CUE OUT - - - + {' '}{formatDb(masterDb)} + + + + Pk - - ); - })()} + {' '}{formatDb(peakLabelDb)} + + + + + {/* Master FX pickers — Reverb IR and Delay division. + No wet sliders: the shared FX run always-on, and the + per-channel DLY/REV knobs drive the send amounts. */} + + + + + + + + - + {/* Model + artifact pickers — moved here from the top row so the primary strip stays just BPM + transport. Each is gated so they only appear when relevant. */} - + - {unwrappedModels.length > 0 && ( - - - - )} - - {/* LoRA + its checkpoint sub-picker — always rendered (disabled - when irrelevant) so the bar layout doesn't shift as the user - picks. */} + {/* Combined LoRA + checkpoint picker — every option is a + specific (LoRA, checkpoint) pair. Multi-checkpoint LoRAs + show a ListSubheader with the LoRA name + delete, then one + MenuItem per checkpoint indented below. Single-checkpoint + LoRAs collapse to one MenuItem with the LoRA name and + inline delete. Saves the separate Ckpt dropdown's slot. */} {(() => { const isBaseModel = baseModels.some(m => m.name === selectedModel); const compatibleLoras = isBaseModel - ? availableLoras.filter(l => l.base_model === selectedModel) + ? filterLorasForModel(availableLoras, selectedModel) : []; - const currentLora = compatibleLoras.find( - l => l.path === selectedLora || - (l.all_checkpoints || []).includes(selectedLora) + const findLoraForPath = (path) => compatibleLoras.find( + l => l.path === path || (l.all_checkpoints || []).includes(path) ); - const currentLoraName = currentLora?.name || ''; const parseCheckpointLabel = (filepath) => { const name = (filepath || '').split('/').pop() || filepath || ''; const m = name.match(/epoch=(\d+)-step=(\d+)/); @@ -1426,148 +1713,144 @@ function PerformancePanelInner({ return name.replace(/\.ckpt$/i, ''); }; const loraDisabled = compatibleLoras.length === 0; - const ckptCount = currentLora?.all_checkpoints?.length ?? 0; - const ckptDisabled = !currentLora || ckptCount <= 1; + + const deleteBtn = (loraName, size = 'md') => ( + + { e.stopPropagation(); e.preventDefault(); }} + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + handleDeleteLora(loraName); + }} + sx={styles.compactIconBtn(size, 'danger')} + > + + + + ); + return ( - <> - - onSelectLora?.(String(e.target.value))} + displayEmpty + renderValue={(value) => { + if (!value) return No LoRA; + const lora = findLoraForPath(value); + if (!lora) return value; + const ckpts = lora.all_checkpoints || [lora.path]; + return ckpts.length === 1 + ? lora.name + : `${lora.name} · ${parseCheckpointLabel(value)}`; + }} + MenuProps={{ PaperProps: { sx: { maxHeight: 360 } } }} + > + + No LoRA + + {compatibleLoras.flatMap((lora) => { + const ckpts = lora.all_checkpoints || [lora.path]; + if (ckpts.length <= 1) { + // Single-checkpoint LoRA collapses to one row + // with the LoRA name and inline delete. + return [ + + + {lora.name} + + {deleteBtn(lora.name)} + , + ]; + } + // Multi-checkpoint LoRA — subheader with name + + // delete, then one MenuItem per checkpoint. + return [ + - - {lora.name} - - rank={lora.rank} - {lora.all_checkpoints?.length > 1 - ? ` · ${lora.all_checkpoints.length} ckpts` - : ''} - + + {lora.name} - - { e.stopPropagation(); e.preventDefault(); }} - onClick={(e) => { - e.stopPropagation(); - e.preventDefault(); - handleDeleteLora(lora.name); - }} - sx={{ - color: 'text.disabled', - '&:hover': { color: 'error.main', bgcolor: 'action.hover' }, - }} - > - - - - - ))} - - - - - - - + {i === ckpts.length - 1 ? ' (latest)' : ''} + + )), + ]; + })} + + ); })()} - - STEPS - + + Steps + - + + @@ -1576,16 +1859,9 @@ function PerformancePanelInner({ - - SEED - + + Seed + } label={ - + Random } @@ -1608,41 +1884,148 @@ function PerformancePanelInner({ value={seedValue} onChange={(e) => onSeedValueChange?.(e.target.value)} disabled={randomSeed} - inputProps={{ min: 0, max: 4294967295, step: 1 }} - sx={{ - width: 78, - '& .MuiOutlinedInput-root': { borderRadius: 1.5 }, - '& input': { + // inputProps.style wins against MuiInputBase-inputSizeSmall's 14px default. + inputProps={{ + min: 0, + max: 4294967295, + step: 1, + style: { fontVariantNumeric: 'tabular-nums', - fontSize: perfTokens.fontSize.body, + fontSize: perfTokens.fontSize.sm, + fontWeight: perfTokens.weight.bold, }, }} + sx={{ ...styles.pillControl, width: 78 }} /> - - - + + Inject + + + setPromptKey(e.target.value)} + inputProps={{ + 'aria-label': 'Key to inject into prompt', + style: { + fontSize: perfTokens.fontSize.sm, + fontWeight: perfTokens.weight.bold, + }, }} + sx={{ ...styles.pillControl, width: 78 }} + /> + + + setPromptInjectBpm((p) => !p)} + aria-label={promptInjectBpm ? 'Disable master BPM injection' : 'Enable master BPM injection'} + aria-pressed={promptInjectBpm} + sx={(theme) => ({ + height: perfTokens.height.compact, + minWidth: 62, + px: 1, + borderRadius: 1.5, + border: '1px solid', + borderColor: promptInjectBpm ? MASTER_COLOR : theme.palette.divider, + backgroundColor: promptInjectBpm ? MASTER_COLOR : 'transparent', + color: promptInjectBpm ? '#0c1018' : 'text.disabled', + fontSize: perfTokens.fontSize.sm, + fontWeight: perfTokens.weight.bold, + fontVariantNumeric: 'tabular-nums', + transition: 'background-color 120ms, color 120ms, border-color 120ms', + '&:hover': { + backgroundColor: promptInjectBpm ? MASTER_COLOR : 'action.hover', + color: promptInjectBpm ? '#0c1018' : 'text.secondary', + }, + })} > - AUTO BPM - - + + + setInjectBpm(e.target.checked)} + placeholder="Time" + value={promptTimeSig} + onChange={(e) => setPromptTimeSig(e.target.value)} + inputProps={{ + 'aria-label': 'Time signature to inject into prompt', + style: { + fontVariantNumeric: 'tabular-nums', + fontSize: perfTokens.fontSize.sm, + fontWeight: perfTokens.weight.bold, + }, + }} + sx={{ ...styles.pillControl, width: 62 }} /> - - + + + + {/* Name + save dialog for a finished master recording. */} + + Save performance recording + + + {(() => { + const s = Math.round(pendingRecording?.durationSec ?? 0); + const mm = Math.floor(s / 60); + const ss = String(s % 60).padStart(2, '0'); + return `Captured ${mm}:${ss}. Name the file — it will be saved to your output folder.`; + })()} + + setRecordingName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && recordingName.trim() && !savingRecording) { + handleSaveRecording(); + } + }} + disabled={savingRecording} + InputProps={{ + endAdornment: ( + + .wav + + ), + }} + /> + + + + + + ); }