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)} /> Audio Setup handlePickAudioDevice('')} selected={outputDeviceId === ''} sx={{ fontSize: perfTokens.fontSize.body }} > {audioDevices.length === 0 && ( No additional output devices detected )} {audioDevices.map(d => ( handlePickAudioDevice(d.deviceId)} selected={outputDeviceId === d.deviceId} sx={{ fontSize: perfTokens.fontSize.body }} > ))} {/* SAVE section — type a name and click save (or press Enter) */} Save setSaveAsName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSaveAs(); e.stopPropagation(); }} sx={{ flex: 1, '& .MuiOutlinedInput-root': { borderRadius: 1.5, height: perfTokens.height.compact, fontSize: perfTokens.fontSize.body, }, }} /> {saveAsName.trim() && presetNames.includes(saveAsName.trim()) && ( Will overwrite existing preset. )} {/* LOAD section — list of saved presets */} Load {presetNames.length === 0 ? ( No presets saved yet. ) : ( {presetNames.map((name) => ( handleLoadPreset(name)} sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, fontSize: perfTokens.fontSize.body, fontWeight: 600, letterSpacing: perfTokens.letterSpacing.wide, }} > handleDeletePreset(name, e)} sx={{ ml: 1, width: perfTokens.height.sub, height: perfTokens.height.sub }} > ))} )} {/* Destructive: wipes session + MIDI mappings. Two-click arm prevents accidental clicks; the menu auto-disarms after 3 s. */} 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. */} {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)} /> ); }