import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Typography,
Paper,
Slider,
Button,
Alert,
FormControl,
FormControlLabel,
Switch,
Select,
MenuItem,
TextField,
IconButton,
Tooltip,
ButtonBase,
Menu,
ListItemText,
} from '@mui/material';
import {
Play as PlayAllIcon,
Square as StopAllIcon,
Trash2 as DeleteIcon,
Settings as SettingsIcon,
Save as SaveIcon,
X as CloseXIcon,
Headphones as CueIcon,
Volume2 as AudioSetupIcon,
} from 'lucide-react';
import api from '../api';
import PerformanceChannel from './PerformanceChannel';
import { PerformanceEngine } 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 {
usePerformanceSession,
listPresetNames,
savePreset,
deletePreset,
loadPresetIntoSession,
clearPerformanceSession,
} from './usePerformanceSession';
const CHANNEL_COUNT = 4;
const MASTER_COLOR = '#35C2D4';
const MASTER_DB_MIN = -60;
const MASTER_DB_MAX = 0;
const MASTER_DB_DEFAULT = -6;
const METER_FLOOR_DB = -60;
const BPM_MIN = 20;
const BPM_MAX = 300;
const BPM_DEFAULT = 120;
const LAUNCH_QUANTIZE_OPTIONS = [
{ value: 0, label: 'None' },
{ value: 32, label: '8 Bars' },
{ value: 16, label: '4 Bars' },
{ value: 8, label: '2 Bars' },
{ value: 4, label: '1 Bar' },
{ value: 2, label: '1/2' },
{ value: 1, label: '1/4' },
{ value: 0.5, label: '1/8' },
{ value: 0.25, label: '1/16' },
{ value: 0.125, label: '1/32' },
];
const LAUNCH_Q_DEFAULT = 4;
const dbToGain = (db) => (db <= MASTER_DB_MIN ? 0 : Math.pow(10, db / 20));
const ampToDb = (amp) => (amp <= 0 ? -Infinity : 20 * Math.log10(amp));
const formatDb = (db) => {
if (!isFinite(db) || db <= METER_FLOOR_DB) return '−∞';
if (Math.abs(db) < 0.05) return '0.0';
return db.toFixed(1);
};
export default function PerformancePanel(props) {
return (
);
}
function PerformancePanelInner({
selectedModel,
selectedUnwrappedModel,
availableModels = [],
baseModels = [],
availableLoras = [],
selectedLora = '',
loraMultiplier = 1.0,
onSelectModel,
onSelectUnwrappedModel,
onRefreshModels,
onSelectLora,
onLoraMultiplierChange,
steps = 250,
onStepsChange,
randomSeed = true,
seedValue = '',
onRandomSeedChange,
onSeedValueChange,
onPresetLoaded,
}) {
const { session, updateGlobal, updateChannel } = usePerformanceSession(CHANNEL_COUNT);
const engineRef = useRef(null);
const meterFillRef = useRef(null);
const peakHoldRef = useRef({ db: METER_FLOOR_DB, decayedAt: performance.now() });
const meterRafRef = useRef(null);
const [engineReady, setEngineReady] = useState(false);
const [masterDb, setMasterDb] = useState(session.masterDb ?? MASTER_DB_DEFAULT);
const [bpm, setBpm] = useState(session.bpm ?? BPM_DEFAULT);
const [bpmInput, setBpmInput] = useState(String(session.bpm ?? BPM_DEFAULT));
const bpmInputFocusedRef = useRef(false);
const [error, setError] = useState(null);
const [linkAvailable, setLinkAvailable] = useState(false);
const [linkEnabled, setLinkEnabled] = useState(session.linkEnabled ?? false);
const [linkPeers, setLinkPeers] = useState(0);
const [linkInstalling, setLinkInstalling] = useState(false);
const [launchQuantum, setLaunchQuantum] = useState(session.launchQuantum ?? LAUNCH_Q_DEFAULT);
const wasPlayingRef = useRef(false);
const bpmOriginRef = useRef('user');
const [peakLabelDb, setPeakLabelDb] = useState(METER_FLOOR_DB);
const [channelStates, setChannelStates] = useState(() =>
Array.from({ length: CHANNEL_COUNT }, () => ({ loaded: false, playing: false }))
);
const [injectBpm, setInjectBpm] = useState(session.injectBpm ?? true);
// Audio output device. setSinkId requires Chromium ≥ 110 (cueSupported
// is the runtime check). One device drives BOTH main and cue. Per-pair
// channel routing within the device is Stage 2 — pair selections are
// tracked here but the merger plumbing comes later.
const cueSupported = useMemo(() => isCueSupported(), []);
const [outputDeviceId, setOutputDeviceId] = useState(session.outputDeviceId ?? session.cueDeviceId ?? '');
const [audioDevices, setAudioDevices] = useState([]);
const [audioMenuAnchor, setAudioMenuAnchor] = useState(null);
const [maxChannelCount, setMaxChannelCount] = useState(2);
const [mainOutputPair, setMainOutputPair] = useState(session.mainOutputPair ?? 0);
const [cueOutputPair, setCueOutputPair] = useState(session.cueOutputPair ?? 0);
useEffect(() => { updateGlobal('outputDeviceId', outputDeviceId); }, [outputDeviceId, updateGlobal]);
useEffect(() => {
updateGlobal('mainOutputPair', mainOutputPair);
engineRef.current?.setMainOutputPair?.(mainOutputPair);
}, [mainOutputPair, updateGlobal]);
useEffect(() => {
updateGlobal('cueOutputPair', cueOutputPair);
setCueOutputPair(cueOutputPair);
}, [cueOutputPair, updateGlobal]);
// When the chosen device changes, bind both the main engine context and
// the (separate) cue context to it. Re-read maxChannelCount afterwards so
// the pair selectors populate with everything the device exposes.
useEffect(() => {
if (!cueSupported) return;
let cancelled = false;
(async () => {
const engine = engineRef.current;
if (engine?.setOutputDevice) {
const max = await engine.setOutputDevice(outputDeviceId);
if (!cancelled) setMaxChannelCount(max);
}
await setCueDevice(outputDeviceId).catch(() => { /* logged in cueAudio */ });
})();
return () => { cancelled = true; };
}, [cueSupported, outputDeviceId]);
const refreshAudioDevices = useCallback(async () => {
if (!cueSupported) return;
try {
const devices = await listOutputDevices();
setAudioDevices(devices);
} catch (err) {
console.warn('[PerformancePanel] device enumeration failed', err);
}
}, [cueSupported]);
const handleOpenAudioMenu = (e) => {
setAudioMenuAnchor(e.currentTarget);
refreshAudioDevices();
};
const handleCloseAudioMenu = () => setAudioMenuAnchor(null);
const handlePickAudioDevice = (id) => {
setOutputDeviceId(id);
handleCloseAudioMenu();
};
// Restore App-level state (model, steps, seed) once on mount via the setter
// props the panel was given. The panel doesn't own those, so this is the
// only point at which we push session into App state. Subsequent changes
// flow normally through the prop callbacks.
const appStateRestoredRef = useRef(false);
useEffect(() => {
if (appStateRestoredRef.current) return;
appStateRestoredRef.current = true;
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);
}
if (typeof session.randomSeed === 'boolean' && session.randomSeed !== randomSeed) {
onRandomSeedChange?.(session.randomSeed);
}
if (typeof session.seedValue === 'string' && session.seedValue !== seedValue) {
onSeedValueChange?.(session.seedValue);
}
// Intentionally only run on first mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Push panel + App-level state into the session whenever any of it changes.
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('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]);
const handleChannelFormChange = useCallback((index, partial) => {
updateChannel(index, partial);
}, [updateChannel]);
// ---- Preset menu state ----
const [presetMenuAnchor, setPresetMenuAnchor] = useState(null);
const [presetNames, setPresetNames] = useState(() => listPresetNames());
const [saveAsName, setSaveAsName] = useState('');
const [restoreArmed, setRestoreArmed] = useState(false);
const restoreArmTimerRef = useRef(null);
const refreshPresetNames = useCallback(() => {
setPresetNames(listPresetNames());
}, []);
const openPresetMenu = (e) => {
refreshPresetNames();
setSaveAsName('');
setRestoreArmed(false);
setPresetMenuAnchor(e.currentTarget);
};
const closePresetMenu = () => {
setPresetMenuAnchor(null);
setRestoreArmed(false);
if (restoreArmTimerRef.current) {
clearTimeout(restoreArmTimerRef.current);
restoreArmTimerRef.current = null;
}
};
const handleRestoreDefaults = () => {
if (!restoreArmed) {
// First click arms; second click within 3 s commits. Disarms
// automatically so the destructive path is never one accidental
// click away.
setRestoreArmed(true);
if (restoreArmTimerRef.current) clearTimeout(restoreArmTimerRef.current);
restoreArmTimerRef.current = setTimeout(() => setRestoreArmed(false), 3000);
return;
}
clearPerformanceSession();
clearMidiConfig();
closePresetMenu();
onPresetLoaded?.();
};
const handleSaveAs = () => {
const name = saveAsName.trim();
if (!name) return;
savePreset(name, session);
setSaveAsName('');
refreshPresetNames();
};
const handleLoadPreset = (name) => {
if (!loadPresetIntoSession(name)) return;
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) => {
e?.stopPropagation();
deletePreset(name);
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.
const resolvedBaseModel = (() => {
if (!selectedModel) return null;
if (selectedModel === 'stable-audio-open-small' || selectedModel === 'stable-audio-open-1.0') {
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;
})();
const isSmallModel = resolvedBaseModel === 'stable-audio-open-small';
if (!engineRef.current) {
engineRef.current = new PerformanceEngine(CHANNEL_COUNT);
engineRef.current.setMasterGain(dbToGain(MASTER_DB_DEFAULT));
}
useEffect(() => {
setEngineReady(true);
const engine = engineRef.current;
const tick = () => {
const now = performance.now();
const peakAmp = engine ? engine.getMasterPeak() : 0;
const instantDb = ampToDb(peakAmp);
const hold = peakHoldRef.current;
const elapsed = now - hold.decayedAt;
let displayDb = hold.db - (elapsed / 1000) * 24;
if (instantDb > displayDb) displayDb = instantDb;
if (displayDb < METER_FLOOR_DB) displayDb = METER_FLOOR_DB;
hold.db = displayDb;
hold.decayedAt = now;
const fill = meterFillRef.current;
if (fill) {
const pct = ((displayDb - METER_FLOOR_DB) / -METER_FLOOR_DB) * 100;
fill.style.height = `${Math.max(0, Math.min(100, pct))}%`;
}
if (Math.abs(displayDb - peakLabelDb) > 0.5) {
setPeakLabelDb(displayDb);
}
meterRafRef.current = requestAnimationFrame(tick);
};
meterRafRef.current = requestAnimationFrame(tick);
return () => {
if (meterRafRef.current) cancelAnimationFrame(meterRafRef.current);
engine.dispose();
engineRef.current = null;
};
}, []);
const handleMasterChange = (_, value) => {
setMasterDb(value);
engineRef.current?.setMasterGain(dbToGain(value));
};
useEffect(() => {
engineRef.current?.setBpm(bpm);
}, [bpm]);
const handleBpmChange = (event) => {
const raw = event.target.value;
setBpmInput(raw);
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed >= BPM_MIN && parsed <= BPM_MAX) {
bpmOriginRef.current = 'user';
setBpm(Math.round(parsed));
}
};
const handleBpmBlur = () => {
bpmInputFocusedRef.current = false;
const parsed = Number(bpmInput);
if (!Number.isFinite(parsed) || bpmInput.trim() === '') {
// Non-numeric or empty — restore the committed value.
setBpmInput(String(bpm));
return;
}
const clamped = Math.max(BPM_MIN, Math.min(BPM_MAX, Math.round(parsed)));
bpmOriginRef.current = 'user';
setBpm(clamped);
setBpmInput(String(clamped));
};
const handleBpmFocus = () => {
bpmInputFocusedRef.current = true;
};
useEffect(() => {
if (!bpmInputFocusedRef.current) setBpmInput(String(bpm));
}, [bpm]);
useEffect(() => {
api.get('/api/link/state')
.then((r) => {
setLinkAvailable(Boolean(r.data?.available));
setLinkEnabled(Boolean(r.data?.enabled));
})
.catch(() => setLinkAvailable(false));
}, []);
useEffect(() => {
if (!linkEnabled) {
engineRef.current?.setLinkSnapshot(null);
wasPlayingRef.current = false;
return undefined;
}
let cancelled = false;
const poll = async () => {
const capturedAt = performance.now();
try {
const r = await api.get('/api/link/state');
if (cancelled || !r.data?.enabled) return;
const serverBpm = Math.round(r.data.bpm);
if (serverBpm >= BPM_MIN && serverBpm <= BPM_MAX) {
setBpm((prev) => {
if (prev === serverBpm) return prev;
bpmOriginRef.current = 'link';
return serverBpm;
});
}
setLinkPeers(Number(r.data.num_peers || 0));
const isPlaying = Boolean(r.data.is_playing);
const beat = Number(r.data.beat) || 0;
const bpmFloat = Number(r.data.bpm) || 120;
engineRef.current?.setLinkSnapshot({
beat,
bpm: bpmFloat,
isPlaying,
capturedAt,
});
if (wasPlayingRef.current && !isPlaying) {
engineRef.current?.stopAll();
setChannelStates(prev => prev.map(s => ({ ...s, playing: false })));
}
wasPlayingRef.current = isPlaying;
} catch {
/* transient network blip — next tick will retry */
}
};
poll();
const timer = setInterval(poll, 500);
return () => { cancelled = true; clearInterval(timer); };
}, [linkEnabled]);
useEffect(() => {
engineRef.current?.setLaunchQuantum(launchQuantum);
}, [launchQuantum]);
useEffect(() => {
if (!linkEnabled) return;
if (bpmOriginRef.current === 'link') {
bpmOriginRef.current = 'user';
return;
}
api.post('/api/link/bpm', { bpm }).catch(() => {});
}, [bpm, linkEnabled]);
const handleToggleLink = useCallback(async () => {
// First click when Link isn't installed: offer to install it.
if (!linkAvailable) {
const confirmed = window.confirm(
'Ableton Link requires the LinkPython-extern package (~1–2 MB, ~30s install).\n\n'
+ 'Install it now? You\'ll only need to do this once.'
);
if (!confirmed) return;
setLinkInstalling(true);
try {
await api.post('/api/link/install');
setLinkAvailable(true);
await api.post('/api/link/enable');
setLinkEnabled(true);
} catch (err) {
const msg = err?.response?.data?.error || err?.response?.data?.detail || err.message || 'Install failed';
setError(`Link install failed: ${msg}`);
} finally {
setLinkInstalling(false);
}
return;
}
try {
if (linkEnabled) {
await api.post('/api/link/disable');
setLinkEnabled(false);
setLinkPeers(0);
} else {
await api.post('/api/link/enable');
setLinkEnabled(true);
}
} catch (err) {
const status = err?.response?.status;
const msg = err?.response?.data?.error || err.message || 'Link toggle failed';
if (status === 503) setLinkAvailable(false);
setError(msg);
}
}, [linkEnabled, linkAvailable]);
const handlePlayAll = () => {
engineRef.current?.playAll(true);
setChannelStates(prev => prev.map(s => (s.loaded ? { ...s, playing: true } : s)));
};
const handleStopAll = () => {
engineRef.current?.stopAll();
setChannelStates(prev => prev.map(s => ({ ...s, playing: false })));
};
const applyExternalBpm = useCallback((value) => {
const next = Math.max(BPM_MIN, Math.min(BPM_MAX, Math.round(value)));
bpmOriginRef.current = 'user';
setBpm(next);
}, []);
const midi = useMidi();
const [midiMenuAnchor, setMidiMenuAnchor] = useState(null);
useEffect(() => {
if (!midi?.learnMode) return undefined;
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
midi.exitLearnMode();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [midi?.learnMode, midi?.exitLearnMode]);
const generateForChannel = async ({ prompt, duration, alignBars, alignBpm, batchSize = 1 }) => {
setError(null);
if (!selectedModel) {
const msg = 'Pick a model first.';
setError(msg);
throw new Error(msg);
}
const trimmed = (prompt || '').trim();
const hasExplicitBpm = /\b\d{2,3}\s*bpm\b/i.test(trimmed);
const finalPrompt = injectBpm && !hasExplicitBpm
? `${trimmed}${trimmed ? ', ' : ''}${Math.round(bpm)} BPM`
: trimmed;
let baseSeed;
if (randomSeed) {
baseSeed = Math.floor(Math.random() * 0xffffffff);
} else {
const parsed = parseInt(seedValue, 10);
if (Number.isNaN(parsed) || parsed < 0) {
const msg = 'Enter a valid seed (0 or greater) or enable Random.';
setError(msg);
throw new Error(msg);
}
baseSeed = parsed;
}
const count = Math.max(1, Math.min(4, batchSize | 0));
const blobs = [];
for (let i = 0; i < count; i++) {
// Sequential rather than parallel — the backend serves one
// generation at a time anyway, and parallelizing would just
// queue them server-side with no time saved. Each take gets a
// distinct seed so the batch produces actual variations rather
// than the same audio repeated.
const seed = (baseSeed + i * 0x9e3779b1) >>> 0;
// LoRA only meaningful on top of a base model (the LoRA was bound
// to that exact base at training time; applying it to a full-FT
// model is undefined).
const isBaseModel = baseModels.some(m => m.name === selectedModel);
const requestData = {
prompt: finalPrompt,
duration,
cfg_scale: 7.0,
steps,
seed,
model_name: selectedModel,
batch_index: i + 1,
batch_total: count,
...(selectedUnwrappedModel ? { unwrapped_model_path: selectedUnwrappedModel } : {}),
...(selectedLora && isBaseModel ? { lora_path: selectedLora, lora_multiplier: loraMultiplier } : {}),
...(alignBars && alignBpm ? { align_bars: alignBars, align_bpm: alignBpm } : {}),
};
const response = await api.post('/api/generate', requestData, { responseType: 'blob' });
blobs.push(response.data);
}
return blobs;
};
const handleChannelStateChange = (index, change) => {
setChannelStates(prev => {
const next = [...prev];
next[index] = { ...next[index], ...change };
return next;
});
};
const anyLoaded = channelStates.some(s => s.loaded);
const anyPlaying = channelStates.some(s => s.playing);
const maxDuration = isSmallModel ? 10 : 47;
const handleMuteSoloChange = (index, change) => {
const engine = engineRef.current;
if (!engine) return;
if ('mute' in change) engine.setMute(index, change.mute);
if ('solo' in change) engine.setSolo(index, change.solo);
};
const channels = useMemo(() => {
if (!engineReady || !engineRef.current) return [];
return engineRef.current.channels;
}, [engineReady]);
const handleModelChange = (event) => {
const value = event.target.value;
if (onSelectModel) onSelectModel(value);
if (onSelectUnwrappedModel) onSelectUnwrappedModel('');
};
const handleCheckpointChange = (event) => {
if (onSelectUnwrappedModel) onSelectUnwrappedModel(String(event.target.value));
};
const handleDeleteFineTuned = async (modelName) => {
const confirmed = window.confirm(
`Delete fine-tuned model "${modelName}"? This removes the directory and all its checkpoints. This cannot be undone.`
);
if (!confirmed) return;
try {
await api.delete(`/api/models/fine-tuned/${encodeURIComponent(modelName)}`);
if (selectedModel === modelName) {
onSelectModel?.('');
onSelectUnwrappedModel?.('');
}
onRefreshModels?.();
} catch (err) {
const msg = err?.response?.data?.error || err.message || 'Delete failed';
setError(`Failed to delete "${modelName}": ${msg}`);
}
};
// LoRAs share the same on-disk layout as fine-tuned models (one dir under
// models/fine_tuned//), so the same DELETE endpoint handles both.
const handleDeleteLora = async (loraName) => {
const confirmed = window.confirm(
`Delete LoRA "${loraName}"? This removes the directory and all its checkpoints. This cannot be undone.`
);
if (!confirmed) return;
try {
await api.delete(`/api/models/fine-tuned/${encodeURIComponent(loraName)}`);
// Clear the active LoRA if it points anywhere inside the deleted dir.
const deleted = availableLoras.find(l => l.name === loraName);
const paths = deleted ? (deleted.all_checkpoints || [deleted.path]) : [];
if (paths.includes(selectedLora)) onSelectLora?.('');
onRefreshModels?.();
} catch (err) {
const msg = err?.response?.data?.error || err.message || 'Delete failed';
setError(`Failed to delete LoRA "${loraName}": ${msg}`);
}
};
const selectedFineTuned = selectedModel
? availableModels.find((m) => m.name === selectedModel)
: null;
const unwrappedModels = selectedFineTuned?.unwrapped_models || [];
const checkpointValue = unwrappedModels
.map((u) => String(u.path))
.includes(selectedUnwrappedModel)
? selectedUnwrappedModel
: '';
return (
{/* Link — compact rectangle, Ableton-style */}
0
? MASTER_COLOR
: '#F5C542',
color: linkEnabled ? '#000' : '#2a2a2a',
opacity: linkInstalling ? 0.55 : 1,
transition: 'background-color 120ms',
'&:hover': {
bgcolor: !linkEnabled
? '#7d7d7d'
: linkPeers > 0
? '#4DD0DE'
: '#FFD54F',
},
'&.Mui-disabled': {
color: linkEnabled ? '#000' : '#2a2a2a',
},
}}
>
{linkInstalling
? 'installing…'
: linkEnabled && linkPeers > 0
? `${linkPeers} Link`
: 'Link'}
{/* MIDI learn toggle — same compact rectangle style as Link. */}
midi?.toggleLearnMode()}
disabled={!midi?.supported}
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'inherit',
fontSize: perfTokens.fontSize.body,
fontWeight: 600,
px: 1,
minWidth: 46,
height: perfTokens.height.compact,
borderRadius: '2px',
bgcolor: midi?.learnMode ? '#F5C542' : '#6e6e6e',
color: midi?.learnMode ? '#000' : '#2a2a2a',
opacity: midi?.supported ? 1 : 0.45,
transition: 'background-color 120ms',
'&:hover': {
bgcolor: midi?.learnMode ? '#FFD54F' : '#7d7d7d',
},
'&.Mui-disabled': {
color: '#2a2a2a',
},
}}
>
MIDI
setMidiMenuAnchor(e.currentTarget)}
sx={{ width: perfTokens.height.compact, height: perfTokens.height.compact, color: 'text.secondary' }}
>
setMidiMenuAnchor(null)}
/>
BPM
),
}}
sx={{
width: 84,
'& .MuiOutlinedInput-root': { borderRadius: 1.5, pr: 1 },
'& input': {
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
fontSize: perfTokens.fontSize.body,
pr: 0,
},
'& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': {
WebkitAppearance: 'none',
margin: 0,
},
'& input[type=number]': { MozAppearance: 'textfield' },
}}
/>
{/* Model picker + FT-checkpoint + LoRA pickers now live in the
bottom bar so the top strip stays just BPM + transport. */}
}
onClick={handlePlayAll}
disabled={!anyLoaded}
sx={styles.masterBtn(MASTER_COLOR, 'play')}
>
Play All
}
onClick={handleStopAll}
disabled={!anyPlaying}
sx={styles.masterBtn(MASTER_COLOR, 'stop')}
>
Stop All
{error && (
setError(null)}>
{error}
)}
{channels.map((strip, i) => (
))}
MASTER
handleMasterChange(null, v)}
sx={{ flex: 1, alignSelf: 'stretch' }}
>
dBFS {formatDb(masterDb)}
pk {formatDb(peakLabelDb)}
{/* 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
CUE OUT
);
})()}
{/* 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. */}
{(() => {
const isBaseModel = baseModels.some(m => m.name === selectedModel);
const compatibleLoras = isBaseModel
? availableLoras.filter(l => l.base_model === selectedModel)
: [];
const currentLora = compatibleLoras.find(
l => l.path === selectedLora ||
(l.all_checkpoints || []).includes(selectedLora)
);
const currentLoraName = currentLora?.name || '';
const parseCheckpointLabel = (filepath) => {
const name = (filepath || '').split('/').pop() || filepath || '';
const m = name.match(/epoch=(\d+)-step=(\d+)/);
if (m) return `Ep ${m[1]} · ${m[2]}`;
return name.replace(/\.ckpt$/i, '');
};
const loraDisabled = compatibleLoras.length === 0;
const ckptCount = currentLora?.all_checkpoints?.length ?? 0;
const ckptDisabled = !currentLora || ckptCount <= 1;
return (
<>
>
);
})()}
STEPS
SEED
onRandomSeedChange?.(e.target.checked)}
/>
}
label={
Random
}
/>
onSeedValueChange?.(e.target.value)}
disabled={randomSeed}
inputProps={{ min: 0, max: 4294967295, step: 1 }}
sx={{
width: 78,
'& .MuiOutlinedInput-root': { borderRadius: 1.5 },
'& input': {
fontVariantNumeric: 'tabular-nums',
fontSize: perfTokens.fontSize.body,
},
}}
/>
AUTO BPM
setInjectBpm(e.target.checked)}
/>
);
}