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
+
);
})()}
-
- STEPS
-
+
+ Steps
+
-
+
+
onStepsChange?.(Number(e.target.value))}
- disabled={isSmallModel}
+ disabled={isDistilledSA3}
renderValue={(value) => `${value}`}
>
- {isSmallModel && (
-
- 8 (locked)
+ {isDistilledSA3 && (
+
+ 8 (locked)
)}
{[50, 100, 150, 200, 250].map((n) => (
-
- {n}
+
+ {n}
))}
@@ -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. */}
+
);
}