Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
app/frontend/src/App.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
| 2 |
import {
|
| 3 |
Container,
|
| 4 |
Box,
|
|
@@ -32,12 +32,12 @@ import {
|
|
| 32 |
} from '@mui/material';
|
| 33 |
import {
|
| 34 |
Plus as AddIcon,
|
| 35 |
-
|
| 36 |
Play as PlayIcon,
|
| 37 |
Square as StopIcon,
|
| 38 |
-
|
| 39 |
SlidersHorizontal as SlidersIcon,
|
| 40 |
-
|
| 41 |
RefreshCw as RefreshIcon,
|
| 42 |
ChevronDown as ExpandMoreIcon,
|
| 43 |
CloudDownload as CloudDownloadIcon,
|
|
@@ -45,7 +45,9 @@ import {
|
|
| 45 |
Info as InfoIcon,
|
| 46 |
BookOpen as BookOpenIcon,
|
| 47 |
Moon as MoonIcon,
|
| 48 |
-
Sun as SunIcon
|
|
|
|
|
|
|
| 49 |
} from 'lucide-react';
|
| 50 |
import api from './api';
|
| 51 |
import HfAuthDialog from './components/HfAuthDialog';
|
|
@@ -60,8 +62,11 @@ import WelcomePage from './components/WelcomePage';
|
|
| 60 |
import { formatDuration } from './utils/format';
|
| 61 |
import theme, { appStyles, lightTheme } from './theme';
|
| 62 |
|
|
|
|
|
|
|
| 63 |
const COLOR_MODE_STORAGE_KEY = 'fragmenta-color-mode';
|
| 64 |
const HIDE_WELCOME_PAGE_KEY = 'fragmenta-hide-welcome';
|
|
|
|
| 65 |
|
| 66 |
function App() {
|
| 67 |
const [tabValue, setTabValue] = useState(0);
|
|
@@ -76,6 +81,18 @@ function App() {
|
|
| 76 |
const [showWelcomePage, setShowWelcomePage] = useState(
|
| 77 |
() => window.localStorage.getItem(HIDE_WELCOME_PAGE_KEY) !== 'true'
|
| 78 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
const [authDialogOpen, setAuthDialogOpen] = useState(false);
|
| 80 |
const [showInfoDialog, setShowInfoDialog] = useState(false);
|
| 81 |
const [isOpeningDocumentation, setIsOpeningDocumentation] = useState(false);
|
|
@@ -202,6 +219,7 @@ function App() {
|
|
| 202 |
[colorMode]
|
| 203 |
);
|
| 204 |
const isCompactLayout = useMediaQuery(appTheme.breakpoints.down('md'));
|
|
|
|
| 205 |
|
| 206 |
useEffect(() => {
|
| 207 |
setSelectedUnwrappedModel('');
|
|
@@ -1026,11 +1044,28 @@ function App() {
|
|
| 1026 |
onChange={handleTabChange}
|
| 1027 |
orientation={isCompactLayout ? 'horizontal' : 'vertical'}
|
| 1028 |
aria-label="main navigation tabs"
|
| 1029 |
-
sx={appStyles.navigationTabs(isCompactLayout)}
|
| 1030 |
>
|
| 1031 |
-
<Tab icon={<UploadIcon size={20} />} iconPosition=
|
| 1032 |
-
<Tab icon={<ActivityIcon size={20} />} iconPosition=
|
| 1033 |
-
<Tab icon={<SparklesIcon size={20} />} iconPosition=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
</Tabs>
|
| 1035 |
</Paper>
|
| 1036 |
|
|
@@ -1828,6 +1863,32 @@ function App() {
|
|
| 1828 |
</Grid>
|
| 1829 |
</Grid>
|
| 1830 |
</TabPanel>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1831 |
</Paper>
|
| 1832 |
</Box>
|
| 1833 |
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useMemo, useRef, Suspense, lazy } from 'react';
|
| 2 |
import {
|
| 3 |
Container,
|
| 4 |
Box,
|
|
|
|
| 32 |
} from '@mui/material';
|
| 33 |
import {
|
| 34 |
Plus as AddIcon,
|
| 35 |
+
Database as UploadIcon,
|
| 36 |
Play as PlayIcon,
|
| 37 |
Square as StopIcon,
|
| 38 |
+
Cpu as ActivityIcon,
|
| 39 |
SlidersHorizontal as SlidersIcon,
|
| 40 |
+
Music as SparklesIcon,
|
| 41 |
RefreshCw as RefreshIcon,
|
| 42 |
ChevronDown as ExpandMoreIcon,
|
| 43 |
CloudDownload as CloudDownloadIcon,
|
|
|
|
| 45 |
Info as InfoIcon,
|
| 46 |
BookOpen as BookOpenIcon,
|
| 47 |
Moon as MoonIcon,
|
| 48 |
+
Sun as SunIcon,
|
| 49 |
+
Piano as PerformanceIcon,
|
| 50 |
+
AlertCircle as AlertIcon
|
| 51 |
} from 'lucide-react';
|
| 52 |
import api from './api';
|
| 53 |
import HfAuthDialog from './components/HfAuthDialog';
|
|
|
|
| 62 |
import { formatDuration } from './utils/format';
|
| 63 |
import theme, { appStyles, lightTheme } from './theme';
|
| 64 |
|
| 65 |
+
const PerformancePanel = lazy(() => import('./components/PerformancePanel'));
|
| 66 |
+
|
| 67 |
const COLOR_MODE_STORAGE_KEY = 'fragmenta-color-mode';
|
| 68 |
const HIDE_WELCOME_PAGE_KEY = 'fragmenta-hide-welcome';
|
| 69 |
+
const PERFORMANCE_ENABLED_KEY = 'fragmenta-performance-enabled';
|
| 70 |
|
| 71 |
function App() {
|
| 72 |
const [tabValue, setTabValue] = useState(0);
|
|
|
|
| 81 |
const [showWelcomePage, setShowWelcomePage] = useState(
|
| 82 |
() => window.localStorage.getItem(HIDE_WELCOME_PAGE_KEY) !== 'true'
|
| 83 |
);
|
| 84 |
+
const [performanceEnabled, setPerformanceEnabled] = useState(
|
| 85 |
+
() => window.localStorage.getItem(PERFORMANCE_ENABLED_KEY) === 'true'
|
| 86 |
+
);
|
| 87 |
+
const togglePerformance = () => {
|
| 88 |
+
setPerformanceEnabled((prev) => {
|
| 89 |
+
const next = !prev;
|
| 90 |
+
window.localStorage.setItem(PERFORMANCE_ENABLED_KEY, next ? 'true' : 'false');
|
| 91 |
+
if (!next && tabValue === 3) setTabValue(0);
|
| 92 |
+
if (next) setTabValue(3);
|
| 93 |
+
return next;
|
| 94 |
+
});
|
| 95 |
+
};
|
| 96 |
const [authDialogOpen, setAuthDialogOpen] = useState(false);
|
| 97 |
const [showInfoDialog, setShowInfoDialog] = useState(false);
|
| 98 |
const [isOpeningDocumentation, setIsOpeningDocumentation] = useState(false);
|
|
|
|
| 219 |
[colorMode]
|
| 220 |
);
|
| 221 |
const isCompactLayout = useMediaQuery(appTheme.breakpoints.down('md'));
|
| 222 |
+
const isIconOnlySidebar = useMediaQuery(appTheme.breakpoints.between('md', 'lg'));
|
| 223 |
|
| 224 |
useEffect(() => {
|
| 225 |
setSelectedUnwrappedModel('');
|
|
|
|
| 1044 |
onChange={handleTabChange}
|
| 1045 |
orientation={isCompactLayout ? 'horizontal' : 'vertical'}
|
| 1046 |
aria-label="main navigation tabs"
|
| 1047 |
+
sx={appStyles.navigationTabs(isCompactLayout, isIconOnlySidebar)}
|
| 1048 |
>
|
| 1049 |
+
<Tab icon={<UploadIcon size={20} />} iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={isIconOnlySidebar ? undefined : 'Data Processing'} />
|
| 1050 |
+
<Tab icon={<ActivityIcon size={20} />} iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={isIconOnlySidebar ? undefined : 'Training'} />
|
| 1051 |
+
<Tab icon={<SparklesIcon size={20} />} iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={isIconOnlySidebar ? undefined : 'Generation'} />
|
| 1052 |
+
<Tab
|
| 1053 |
+
icon={<PerformanceIcon size={20} />}
|
| 1054 |
+
iconPosition={isIconOnlySidebar ? 'top' : 'start'}
|
| 1055 |
+
label={isIconOnlySidebar ? undefined : (
|
| 1056 |
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
| 1057 |
+
Performance
|
| 1058 |
+
<Switch
|
| 1059 |
+
size="small"
|
| 1060 |
+
checked={performanceEnabled}
|
| 1061 |
+
onChange={() => {}}
|
| 1062 |
+
onClick={(e) => { e.stopPropagation(); togglePerformance(); }}
|
| 1063 |
+
sx={{ transform: 'scale(0.75)' }}
|
| 1064 |
+
/>
|
| 1065 |
+
</Box>
|
| 1066 |
+
)}
|
| 1067 |
+
sx={{ opacity: performanceEnabled ? 1 : 0.5, transition: 'opacity 0.2s' }}
|
| 1068 |
+
/>
|
| 1069 |
</Tabs>
|
| 1070 |
</Paper>
|
| 1071 |
|
|
|
|
| 1863 |
</Grid>
|
| 1864 |
</Grid>
|
| 1865 |
</TabPanel>
|
| 1866 |
+
|
| 1867 |
+
<TabPanel value={tabValue} index={3}>
|
| 1868 |
+
{performanceEnabled ? (
|
| 1869 |
+
<Suspense fallback={
|
| 1870 |
+
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
| 1871 |
+
<CircularProgress size={28} />
|
| 1872 |
+
</Box>
|
| 1873 |
+
}>
|
| 1874 |
+
<PerformancePanel
|
| 1875 |
+
selectedModel={selectedModel}
|
| 1876 |
+
selectedUnwrappedModel={selectedUnwrappedModel}
|
| 1877 |
+
availableModels={availableModels}
|
| 1878 |
+
baseModels={baseModels}
|
| 1879 |
+
onSelectModel={setSelectedModel}
|
| 1880 |
+
onSelectUnwrappedModel={setSelectedUnwrappedModel}
|
| 1881 |
+
/>
|
| 1882 |
+
</Suspense>
|
| 1883 |
+
) : (
|
| 1884 |
+
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 8, gap: 2 }}>
|
| 1885 |
+
<AlertIcon size={48} color="#FFB74D" />
|
| 1886 |
+
<Typography variant="body1" color="textSecondary" align="center">
|
| 1887 |
+
Performance mode is turned off. Toggle on from the sidebar if you wish to enter performance mode.
|
| 1888 |
+
</Typography>
|
| 1889 |
+
</Box>
|
| 1890 |
+
)}
|
| 1891 |
+
</TabPanel>
|
| 1892 |
</Paper>
|
| 1893 |
</Box>
|
| 1894 |
|
app/frontend/src/components/PerformanceChannel.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Box,
|
| 4 |
+
Typography,
|
| 5 |
+
TextField,
|
| 6 |
+
IconButton,
|
| 7 |
+
Slider,
|
| 8 |
+
CircularProgress,
|
| 9 |
+
Tooltip,
|
| 10 |
+
} from '@mui/material';
|
| 11 |
+
import {
|
| 12 |
+
Play as PlayIcon,
|
| 13 |
+
Square as StopIcon,
|
| 14 |
+
Repeat as LoopIcon,
|
| 15 |
+
Sparkles as GenerateIcon,
|
| 16 |
+
Volume2 as VolumeIcon,
|
| 17 |
+
VolumeX as MuteIcon,
|
| 18 |
+
} from 'lucide-react';
|
| 19 |
+
import { performanceChannelStyles as styles } from '../theme';
|
| 20 |
+
|
| 21 |
+
const CHANNEL_COLORS = [
|
| 22 |
+
'#35C2D4', '#9F8AE6', '#53C18A', '#E3A34B',
|
| 23 |
+
'#E36C61', '#F08AD2', '#5BA0F0', '#A8D86B',
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
const KNOB_DEFS = [
|
| 27 |
+
{ key: 'gain', label: 'GAIN', min: 0, max: 1.5, step: 0.01, default: 0 },
|
| 28 |
+
{ key: 'filter', label: 'LPF', min: 200, max: 18000, step: 1, default: 18000, log: true },
|
| 29 |
+
{ key: 'delay', label: 'DLY', min: 0, max: 1.0, step: 0.01, default: 0.0 },
|
| 30 |
+
{ key: 'reverb', label: 'REV', min: 0, max: 1.0, step: 0.01, default: 0.0 },
|
| 31 |
+
];
|
| 32 |
+
|
| 33 |
+
export default function PerformanceChannel({
|
| 34 |
+
index,
|
| 35 |
+
strip,
|
| 36 |
+
onGenerate,
|
| 37 |
+
canGenerate,
|
| 38 |
+
onMuteSoloChange,
|
| 39 |
+
onStateChange,
|
| 40 |
+
maxDuration = 47,
|
| 41 |
+
}) {
|
| 42 |
+
const color = CHANNEL_COLORS[index % CHANNEL_COLORS.length];
|
| 43 |
+
const canvasRef = useRef(null);
|
| 44 |
+
const meterRef = useRef(null);
|
| 45 |
+
const meterRafRef = useRef(null);
|
| 46 |
+
|
| 47 |
+
const [prompt, setPrompt] = useState('');
|
| 48 |
+
const [duration, setDuration] = useState(8);
|
| 49 |
+
const [generating, setGenerating] = useState(false);
|
| 50 |
+
const [loaded, setLoaded] = useState(false);
|
| 51 |
+
const [playing, setPlaying] = useState(false);
|
| 52 |
+
const [looping, setLooping] = useState(true);
|
| 53 |
+
const [muted, setMuted] = useState(false);
|
| 54 |
+
const [soloed, setSoloed] = useState(false);
|
| 55 |
+
const [knobs, setKnobs] = useState(() => {
|
| 56 |
+
const initial = Object.fromEntries(KNOB_DEFS.map(k => [k.key, k.default]));
|
| 57 |
+
initial.pan = 0;
|
| 58 |
+
return initial;
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
const tick = () => {
|
| 63 |
+
const el = meterRef.current;
|
| 64 |
+
if (el && strip) {
|
| 65 |
+
const level = strip.getLevel();
|
| 66 |
+
el.style.width = `${Math.min(100, level * 140)}%`;
|
| 67 |
+
}
|
| 68 |
+
meterRafRef.current = requestAnimationFrame(tick);
|
| 69 |
+
};
|
| 70 |
+
meterRafRef.current = requestAnimationFrame(tick);
|
| 71 |
+
return () => {
|
| 72 |
+
if (meterRafRef.current) cancelAnimationFrame(meterRafRef.current);
|
| 73 |
+
};
|
| 74 |
+
}, [strip]);
|
| 75 |
+
|
| 76 |
+
const drawWave = useCallback(() => {
|
| 77 |
+
if (strip && canvasRef.current) {
|
| 78 |
+
strip.drawWaveform(canvasRef.current, color);
|
| 79 |
+
}
|
| 80 |
+
}, [strip, color]);
|
| 81 |
+
|
| 82 |
+
useEffect(() => { drawWave(); }, [drawWave, loaded]);
|
| 83 |
+
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
setDuration(prev => Math.min(prev, maxDuration));
|
| 86 |
+
}, [maxDuration]);
|
| 87 |
+
|
| 88 |
+
const handleGenerate = async () => {
|
| 89 |
+
if (!prompt.trim() || generating) return;
|
| 90 |
+
setGenerating(true);
|
| 91 |
+
try {
|
| 92 |
+
const blob = await onGenerate({ prompt, duration });
|
| 93 |
+
await strip.loadBlob(blob);
|
| 94 |
+
setLoaded(true);
|
| 95 |
+
onStateChange?.(index, { loaded: true });
|
| 96 |
+
requestAnimationFrame(drawWave);
|
| 97 |
+
} catch (err) {
|
| 98 |
+
console.error(`Channel ${index + 1} generate failed:`, err);
|
| 99 |
+
} finally {
|
| 100 |
+
setGenerating(false);
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
const handlePlay = () => {
|
| 105 |
+
if (!loaded) return;
|
| 106 |
+
strip.play(looping);
|
| 107 |
+
setPlaying(true);
|
| 108 |
+
onStateChange?.(index, { playing: true });
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const handleStop = () => {
|
| 112 |
+
strip.stop();
|
| 113 |
+
setPlaying(false);
|
| 114 |
+
onStateChange?.(index, { playing: false });
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const handleLoopToggle = () => {
|
| 118 |
+
setLooping(prev => {
|
| 119 |
+
const next = !prev;
|
| 120 |
+
strip.setLoop(next);
|
| 121 |
+
return next;
|
| 122 |
+
});
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const handleMuteToggle = () => {
|
| 126 |
+
const next = !muted;
|
| 127 |
+
setMuted(next);
|
| 128 |
+
onMuteSoloChange(index, { mute: next });
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const handleSoloToggle = () => {
|
| 132 |
+
const next = !soloed;
|
| 133 |
+
setSoloed(next);
|
| 134 |
+
onMuteSoloChange(index, { solo: next });
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
const handleKnob = (key, value) => {
|
| 138 |
+
setKnobs(prev => ({ ...prev, [key]: value }));
|
| 139 |
+
if (key === 'gain') strip.setUserGain(value);
|
| 140 |
+
else if (key === 'pan') strip.setPan(value);
|
| 141 |
+
else if (key === 'filter') strip.setFilter(value);
|
| 142 |
+
else if (key === 'delay') strip.setDelayMix(value);
|
| 143 |
+
else if (key === 'reverb') strip.setReverbMix(value);
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
return (
|
| 147 |
+
<Box sx={styles.strip(color, playing)}>
|
| 148 |
+
<Box sx={styles.stripHeader(color)}>
|
| 149 |
+
<Box sx={styles.channelBadge(color)}>{String(index + 1).padStart(2, '0')}</Box>
|
| 150 |
+
<Box sx={styles.muteSoloRow}>
|
| 151 |
+
<Tooltip title="Mute">
|
| 152 |
+
<IconButton size="small" onClick={handleMuteToggle} sx={styles.muteBtn(muted)}>M</IconButton>
|
| 153 |
+
</Tooltip>
|
| 154 |
+
<Tooltip title="Solo">
|
| 155 |
+
<IconButton size="small" onClick={handleSoloToggle} sx={styles.soloBtn(soloed)}>S</IconButton>
|
| 156 |
+
</Tooltip>
|
| 157 |
+
</Box>
|
| 158 |
+
</Box>
|
| 159 |
+
|
| 160 |
+
<Box sx={styles.promptBox}>
|
| 161 |
+
<TextField
|
| 162 |
+
placeholder="prompt…"
|
| 163 |
+
value={prompt}
|
| 164 |
+
onChange={(e) => setPrompt(e.target.value)}
|
| 165 |
+
multiline
|
| 166 |
+
minRows={2}
|
| 167 |
+
maxRows={3}
|
| 168 |
+
size="small"
|
| 169 |
+
fullWidth
|
| 170 |
+
sx={styles.promptField}
|
| 171 |
+
disabled={generating}
|
| 172 |
+
/>
|
| 173 |
+
<Box sx={styles.durationRow}>
|
| 174 |
+
<Typography variant="caption" sx={styles.durationLabel}>{duration.toFixed(0)}s</Typography>
|
| 175 |
+
<Slider
|
| 176 |
+
value={duration}
|
| 177 |
+
onChange={(_, v) => setDuration(v)}
|
| 178 |
+
min={2}
|
| 179 |
+
max={maxDuration}
|
| 180 |
+
step={1}
|
| 181 |
+
size="small"
|
| 182 |
+
sx={styles.durationSlider(color)}
|
| 183 |
+
/>
|
| 184 |
+
</Box>
|
| 185 |
+
<IconButton
|
| 186 |
+
onClick={handleGenerate}
|
| 187 |
+
disabled={!canGenerate || !prompt.trim() || generating}
|
| 188 |
+
sx={styles.generateBtn(color)}
|
| 189 |
+
size="small"
|
| 190 |
+
>
|
| 191 |
+
{generating ? <CircularProgress size={16} sx={{ color }} /> : <GenerateIcon size={16} />}
|
| 192 |
+
</IconButton>
|
| 193 |
+
</Box>
|
| 194 |
+
|
| 195 |
+
<Box sx={styles.waveformWrap}>
|
| 196 |
+
<canvas
|
| 197 |
+
ref={canvasRef}
|
| 198 |
+
width={140}
|
| 199 |
+
height={42}
|
| 200 |
+
style={{ width: '100%', height: 42, display: 'block' }}
|
| 201 |
+
/>
|
| 202 |
+
{!loaded && (
|
| 203 |
+
<Typography variant="caption" sx={styles.waveformPlaceholder}>
|
| 204 |
+
empty
|
| 205 |
+
</Typography>
|
| 206 |
+
)}
|
| 207 |
+
</Box>
|
| 208 |
+
|
| 209 |
+
<Box sx={{ px: 1, py: 1 }}>
|
| 210 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
| 211 |
+
<Box component="span" sx={{ fontSize: '0.53rem', color: 'text.secondary', letterSpacing: '0.06em', minWidth: 28 }}>PAN</Box>
|
| 212 |
+
<Slider
|
| 213 |
+
value={knobs.pan ?? 0}
|
| 214 |
+
onChange={(_, v) => handleKnob('pan', v)}
|
| 215 |
+
min={-1}
|
| 216 |
+
max={1}
|
| 217 |
+
step={0.01}
|
| 218 |
+
size="small"
|
| 219 |
+
sx={{ flex: 1 }}
|
| 220 |
+
/>
|
| 221 |
+
</Box>
|
| 222 |
+
</Box>
|
| 223 |
+
|
| 224 |
+
<Box sx={styles.knobsGrid}>
|
| 225 |
+
{KNOB_DEFS.map((k) => (
|
| 226 |
+
<Box key={k.key} sx={styles.knobCell}>
|
| 227 |
+
<Slider
|
| 228 |
+
orientation="vertical"
|
| 229 |
+
value={knobs[k.key]}
|
| 230 |
+
onChange={(_, v) => handleKnob(k.key, v)}
|
| 231 |
+
min={k.min}
|
| 232 |
+
max={k.max}
|
| 233 |
+
step={k.step}
|
| 234 |
+
size="small"
|
| 235 |
+
sx={styles.knobSlider(color)}
|
| 236 |
+
/>
|
| 237 |
+
<Box component="span" sx={styles.knobLabel}>{k.label}</Box>
|
| 238 |
+
</Box>
|
| 239 |
+
))}
|
| 240 |
+
</Box>
|
| 241 |
+
|
| 242 |
+
<Box sx={styles.transportRow}>
|
| 243 |
+
<IconButton
|
| 244 |
+
onClick={playing ? handleStop : handlePlay}
|
| 245 |
+
disabled={!loaded}
|
| 246 |
+
sx={styles.transportBtn(color, playing)}
|
| 247 |
+
size="small"
|
| 248 |
+
>
|
| 249 |
+
{playing ? <StopIcon size={16} /> : <PlayIcon size={16} />}
|
| 250 |
+
</IconButton>
|
| 251 |
+
<IconButton
|
| 252 |
+
onClick={handleLoopToggle}
|
| 253 |
+
sx={styles.loopBtn(color, looping)}
|
| 254 |
+
size="small"
|
| 255 |
+
>
|
| 256 |
+
<LoopIcon size={14} />
|
| 257 |
+
</IconButton>
|
| 258 |
+
<Box sx={styles.meterTrack}>
|
| 259 |
+
<Box ref={meterRef} sx={styles.meterFill(color)} />
|
| 260 |
+
</Box>
|
| 261 |
+
</Box>
|
| 262 |
+
|
| 263 |
+
</Box>
|
| 264 |
+
);
|
| 265 |
+
}
|
app/frontend/src/components/PerformancePanel.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Box,
|
| 4 |
+
Typography,
|
| 5 |
+
Paper,
|
| 6 |
+
Slider,
|
| 7 |
+
Button,
|
| 8 |
+
Alert,
|
| 9 |
+
FormControl,
|
| 10 |
+
Select,
|
| 11 |
+
MenuItem,
|
| 12 |
+
} from '@mui/material';
|
| 13 |
+
import {
|
| 14 |
+
Piano as PerformanceIcon,
|
| 15 |
+
Play as PlayAllIcon,
|
| 16 |
+
Square as StopAllIcon,
|
| 17 |
+
} from 'lucide-react';
|
| 18 |
+
import api from '../api';
|
| 19 |
+
import PerformanceChannel from './PerformanceChannel';
|
| 20 |
+
import { PerformanceEngine } from '../utils/performanceAudio';
|
| 21 |
+
import { performancePanelStyles as styles } from '../theme';
|
| 22 |
+
|
| 23 |
+
const CHANNEL_COUNT = 4;
|
| 24 |
+
const MASTER_COLOR = '#35C2D4';
|
| 25 |
+
const MASTER_DB_MIN = -60;
|
| 26 |
+
const MASTER_DB_MAX = 0;
|
| 27 |
+
const MASTER_DB_DEFAULT = -1;
|
| 28 |
+
const METER_FLOOR_DB = -60;
|
| 29 |
+
|
| 30 |
+
const dbToGain = (db) => (db <= MASTER_DB_MIN ? 0 : Math.pow(10, db / 20));
|
| 31 |
+
const ampToDb = (amp) => (amp <= 0 ? -Infinity : 20 * Math.log10(amp));
|
| 32 |
+
const formatDb = (db) => {
|
| 33 |
+
if (!isFinite(db) || db <= METER_FLOOR_DB) return '−∞';
|
| 34 |
+
if (Math.abs(db) < 0.05) return '0.0';
|
| 35 |
+
return db.toFixed(1);
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
export default function PerformancePanel({
|
| 39 |
+
selectedModel,
|
| 40 |
+
selectedUnwrappedModel,
|
| 41 |
+
availableModels = [],
|
| 42 |
+
baseModels = [],
|
| 43 |
+
onSelectModel,
|
| 44 |
+
onSelectUnwrappedModel,
|
| 45 |
+
}) {
|
| 46 |
+
const engineRef = useRef(null);
|
| 47 |
+
const meterFillRef = useRef(null);
|
| 48 |
+
const peakHoldRef = useRef({ db: METER_FLOOR_DB, decayedAt: performance.now() });
|
| 49 |
+
const meterRafRef = useRef(null);
|
| 50 |
+
const [engineReady, setEngineReady] = useState(false);
|
| 51 |
+
const [masterDb, setMasterDb] = useState(MASTER_DB_DEFAULT);
|
| 52 |
+
const [error, setError] = useState(null);
|
| 53 |
+
const [peakLabelDb, setPeakLabelDb] = useState(METER_FLOOR_DB);
|
| 54 |
+
const [channelStates, setChannelStates] = useState(() =>
|
| 55 |
+
Array.from({ length: CHANNEL_COUNT }, () => ({ loaded: false, playing: false }))
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
if (!engineRef.current) {
|
| 59 |
+
engineRef.current = new PerformanceEngine(CHANNEL_COUNT);
|
| 60 |
+
engineRef.current.setMasterGain(dbToGain(MASTER_DB_DEFAULT));
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
setEngineReady(true);
|
| 65 |
+
const engine = engineRef.current;
|
| 66 |
+
|
| 67 |
+
const tick = () => {
|
| 68 |
+
const now = performance.now();
|
| 69 |
+
const peakAmp = engine ? engine.getMasterPeak() : 0;
|
| 70 |
+
const instantDb = ampToDb(peakAmp);
|
| 71 |
+
const hold = peakHoldRef.current;
|
| 72 |
+
const elapsed = now - hold.decayedAt;
|
| 73 |
+
let displayDb = hold.db - (elapsed / 1000) * 24;
|
| 74 |
+
if (instantDb > displayDb) displayDb = instantDb;
|
| 75 |
+
if (displayDb < METER_FLOOR_DB) displayDb = METER_FLOOR_DB;
|
| 76 |
+
hold.db = displayDb;
|
| 77 |
+
hold.decayedAt = now;
|
| 78 |
+
|
| 79 |
+
const fill = meterFillRef.current;
|
| 80 |
+
if (fill) {
|
| 81 |
+
const pct = ((displayDb - METER_FLOOR_DB) / -METER_FLOOR_DB) * 100;
|
| 82 |
+
fill.style.height = `${Math.max(0, Math.min(100, pct))}%`;
|
| 83 |
+
}
|
| 84 |
+
if (Math.abs(displayDb - peakLabelDb) > 0.5) {
|
| 85 |
+
setPeakLabelDb(displayDb);
|
| 86 |
+
}
|
| 87 |
+
meterRafRef.current = requestAnimationFrame(tick);
|
| 88 |
+
};
|
| 89 |
+
meterRafRef.current = requestAnimationFrame(tick);
|
| 90 |
+
|
| 91 |
+
return () => {
|
| 92 |
+
if (meterRafRef.current) cancelAnimationFrame(meterRafRef.current);
|
| 93 |
+
engine.dispose();
|
| 94 |
+
engineRef.current = null;
|
| 95 |
+
};
|
| 96 |
+
}, []);
|
| 97 |
+
|
| 98 |
+
const handleMasterChange = (_, value) => {
|
| 99 |
+
setMasterDb(value);
|
| 100 |
+
engineRef.current?.setMasterGain(dbToGain(value));
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const handlePlayAll = () => engineRef.current?.playAll(true);
|
| 104 |
+
const handleStopAll = () => engineRef.current?.stopAll();
|
| 105 |
+
|
| 106 |
+
const generateForChannel = async ({ prompt, duration }) => {
|
| 107 |
+
setError(null);
|
| 108 |
+
if (!selectedModel) {
|
| 109 |
+
const msg = 'Pick a model first.';
|
| 110 |
+
setError(msg);
|
| 111 |
+
throw new Error(msg);
|
| 112 |
+
}
|
| 113 |
+
const requestData = {
|
| 114 |
+
prompt,
|
| 115 |
+
duration,
|
| 116 |
+
cfg_scale: 7.0,
|
| 117 |
+
seed: Math.floor(Math.random() * 0xffffffff),
|
| 118 |
+
model_name: selectedModel,
|
| 119 |
+
...(selectedUnwrappedModel ? { unwrapped_model_path: selectedUnwrappedModel } : {}),
|
| 120 |
+
};
|
| 121 |
+
const response = await api.post('/api/generate', requestData, { responseType: 'blob' });
|
| 122 |
+
return response.data;
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const handleChannelStateChange = (index, change) => {
|
| 126 |
+
setChannelStates(prev => {
|
| 127 |
+
const next = [...prev];
|
| 128 |
+
next[index] = { ...next[index], ...change };
|
| 129 |
+
return next;
|
| 130 |
+
});
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const anyLoaded = channelStates.some(s => s.loaded);
|
| 134 |
+
const anyPlaying = channelStates.some(s => s.playing);
|
| 135 |
+
const maxDuration = selectedModel && selectedModel.includes('small') ? 10 : 47;
|
| 136 |
+
|
| 137 |
+
const handleMuteSoloChange = (index, change) => {
|
| 138 |
+
const engine = engineRef.current;
|
| 139 |
+
if (!engine) return;
|
| 140 |
+
if ('mute' in change) engine.setMute(index, change.mute);
|
| 141 |
+
if ('solo' in change) engine.setSolo(index, change.solo);
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
const channels = useMemo(() => {
|
| 145 |
+
if (!engineReady || !engineRef.current) return [];
|
| 146 |
+
return engineRef.current.channels;
|
| 147 |
+
}, [engineReady]);
|
| 148 |
+
|
| 149 |
+
const handleModelChange = (event) => {
|
| 150 |
+
const value = event.target.value;
|
| 151 |
+
if (onSelectModel) onSelectModel(value);
|
| 152 |
+
if (onSelectUnwrappedModel) onSelectUnwrappedModel('');
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const handleCheckpointChange = (event) => {
|
| 156 |
+
if (onSelectUnwrappedModel) onSelectUnwrappedModel(String(event.target.value));
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
const selectedFineTuned = selectedModel
|
| 160 |
+
? availableModels.find((m) => m.name === selectedModel)
|
| 161 |
+
: null;
|
| 162 |
+
const unwrappedModels = selectedFineTuned?.unwrapped_models || [];
|
| 163 |
+
const checkpointValue = unwrappedModels
|
| 164 |
+
.map((u) => String(u.path))
|
| 165 |
+
.includes(selectedUnwrappedModel)
|
| 166 |
+
? selectedUnwrappedModel
|
| 167 |
+
: '';
|
| 168 |
+
|
| 169 |
+
return (
|
| 170 |
+
<Box sx={styles.root}>
|
| 171 |
+
<Paper sx={styles.headerCard}>
|
| 172 |
+
<Box sx={styles.headerLeft}>
|
| 173 |
+
<Box sx={styles.titleRow}>
|
| 174 |
+
<PerformanceIcon size={22} />
|
| 175 |
+
<Typography variant="h6" sx={styles.title}>Fragmenta Performance</Typography>
|
| 176 |
+
</Box>
|
| 177 |
+
<Typography variant="caption" sx={styles.subtitle}>
|
| 178 |
+
4-voice diffusion sampler
|
| 179 |
+
</Typography>
|
| 180 |
+
</Box>
|
| 181 |
+
|
| 182 |
+
<Box sx={styles.headerPickers}>
|
| 183 |
+
<FormControl size="small" sx={styles.headerModelPicker}>
|
| 184 |
+
<Select
|
| 185 |
+
value={selectedModel || ''}
|
| 186 |
+
onChange={handleModelChange}
|
| 187 |
+
displayEmpty
|
| 188 |
+
renderValue={(value) => {
|
| 189 |
+
if (!value) return <em style={{ opacity: 0.6 }}>Select a model</em>;
|
| 190 |
+
const base = baseModels.find((m) => m.name === value);
|
| 191 |
+
if (base) return base.displayName || base.name;
|
| 192 |
+
return value;
|
| 193 |
+
}}
|
| 194 |
+
>
|
| 195 |
+
{baseModels.length > 0 && (
|
| 196 |
+
<MenuItem disabled>
|
| 197 |
+
<Typography variant="caption" color="textSecondary">
|
| 198 |
+
── Base Models ──
|
| 199 |
+
</Typography>
|
| 200 |
+
</MenuItem>
|
| 201 |
+
)}
|
| 202 |
+
{baseModels.map((model) => (
|
| 203 |
+
<MenuItem
|
| 204 |
+
key={model.name}
|
| 205 |
+
value={String(model.name)}
|
| 206 |
+
disabled={!model.downloaded}
|
| 207 |
+
>
|
| 208 |
+
<Typography variant="body2">
|
| 209 |
+
{model.displayName || model.name}
|
| 210 |
+
</Typography>
|
| 211 |
+
</MenuItem>
|
| 212 |
+
))}
|
| 213 |
+
{availableModels.length > 0 && (
|
| 214 |
+
<MenuItem disabled>
|
| 215 |
+
<Typography variant="caption" color="textSecondary">
|
| 216 |
+
── Fine-tuned Models ──
|
| 217 |
+
</Typography>
|
| 218 |
+
</MenuItem>
|
| 219 |
+
)}
|
| 220 |
+
{availableModels.map((model) => (
|
| 221 |
+
<MenuItem key={model.name} value={String(model.name)}>
|
| 222 |
+
<Typography variant="body2">{model.name}</Typography>
|
| 223 |
+
</MenuItem>
|
| 224 |
+
))}
|
| 225 |
+
</Select>
|
| 226 |
+
</FormControl>
|
| 227 |
+
|
| 228 |
+
{unwrappedModels.length > 0 && (
|
| 229 |
+
<FormControl size="small" sx={styles.headerCheckpointPicker}>
|
| 230 |
+
<Select
|
| 231 |
+
value={checkpointValue}
|
| 232 |
+
onChange={handleCheckpointChange}
|
| 233 |
+
displayEmpty
|
| 234 |
+
renderValue={(value) => {
|
| 235 |
+
if (!value) return <em style={{ opacity: 0.6 }}>Select checkpoint</em>;
|
| 236 |
+
const found = unwrappedModels.find((u) => String(u.path) === value);
|
| 237 |
+
return found ? found.name : value;
|
| 238 |
+
}}
|
| 239 |
+
>
|
| 240 |
+
{unwrappedModels.map((unwrapped, index) => (
|
| 241 |
+
<MenuItem key={index} value={String(unwrapped.path)}>
|
| 242 |
+
<Box>
|
| 243 |
+
<Typography variant="body2">{unwrapped.name}</Typography>
|
| 244 |
+
{unwrapped.size_mb && (
|
| 245 |
+
<Typography variant="caption" color="textSecondary">
|
| 246 |
+
{unwrapped.size_mb} MB
|
| 247 |
+
</Typography>
|
| 248 |
+
)}
|
| 249 |
+
</Box>
|
| 250 |
+
</MenuItem>
|
| 251 |
+
))}
|
| 252 |
+
</Select>
|
| 253 |
+
</FormControl>
|
| 254 |
+
)}
|
| 255 |
+
</Box>
|
| 256 |
+
</Paper>
|
| 257 |
+
|
| 258 |
+
{error && (
|
| 259 |
+
<Alert severity="warning" sx={styles.errorAlert} onClose={() => setError(null)}>
|
| 260 |
+
{error}
|
| 261 |
+
</Alert>
|
| 262 |
+
)}
|
| 263 |
+
|
| 264 |
+
<Box sx={styles.channelsRow}>
|
| 265 |
+
<Box sx={styles.channelsGrid}>
|
| 266 |
+
{channels.map((strip, i) => (
|
| 267 |
+
<PerformanceChannel
|
| 268 |
+
key={i}
|
| 269 |
+
index={i}
|
| 270 |
+
strip={strip}
|
| 271 |
+
onGenerate={generateForChannel}
|
| 272 |
+
canGenerate={Boolean(selectedModel)}
|
| 273 |
+
onMuteSoloChange={handleMuteSoloChange}
|
| 274 |
+
onStateChange={handleChannelStateChange}
|
| 275 |
+
maxDuration={maxDuration}
|
| 276 |
+
/>
|
| 277 |
+
))}
|
| 278 |
+
</Box>
|
| 279 |
+
|
| 280 |
+
<Box sx={styles.masterStrip(MASTER_COLOR)}>
|
| 281 |
+
<Box sx={styles.masterHeader(MASTER_COLOR)}>
|
| 282 |
+
<Box sx={styles.masterBadge(MASTER_COLOR)}>MASTER</Box>
|
| 283 |
+
</Box>
|
| 284 |
+
|
| 285 |
+
<Box sx={styles.masterFaderWrap}>
|
| 286 |
+
<Box sx={styles.masterMeterTrack}>
|
| 287 |
+
<Box ref={meterFillRef} sx={styles.masterMeterFill(MASTER_COLOR)} />
|
| 288 |
+
<Box sx={styles.masterMeterSegments} />
|
| 289 |
+
</Box>
|
| 290 |
+
<Slider
|
| 291 |
+
orientation="vertical"
|
| 292 |
+
value={masterDb}
|
| 293 |
+
onChange={handleMasterChange}
|
| 294 |
+
min={MASTER_DB_MIN}
|
| 295 |
+
max={MASTER_DB_MAX}
|
| 296 |
+
step={0.1}
|
| 297 |
+
sx={styles.masterFader(MASTER_COLOR)}
|
| 298 |
+
/>
|
| 299 |
+
</Box>
|
| 300 |
+
|
| 301 |
+
<Box sx={styles.masterReadouts}>
|
| 302 |
+
<Typography variant="caption" sx={styles.masterValue}>
|
| 303 |
+
dBFS {formatDb(masterDb)}
|
| 304 |
+
</Typography>
|
| 305 |
+
<Typography variant="caption" sx={styles.masterPeakValue}>
|
| 306 |
+
pk {formatDb(peakLabelDb)}
|
| 307 |
+
</Typography>
|
| 308 |
+
</Box>
|
| 309 |
+
|
| 310 |
+
<Box sx={styles.masterTransport}>
|
| 311 |
+
<Button
|
| 312 |
+
size="small"
|
| 313 |
+
variant="outlined"
|
| 314 |
+
startIcon={<PlayAllIcon size={14} />}
|
| 315 |
+
onClick={handlePlayAll}
|
| 316 |
+
disabled={!anyLoaded}
|
| 317 |
+
sx={styles.masterBtn(MASTER_COLOR, 'play')}
|
| 318 |
+
fullWidth
|
| 319 |
+
>
|
| 320 |
+
Play All
|
| 321 |
+
</Button>
|
| 322 |
+
<Button
|
| 323 |
+
size="small"
|
| 324 |
+
variant="outlined"
|
| 325 |
+
startIcon={<StopAllIcon size={14} />}
|
| 326 |
+
onClick={handleStopAll}
|
| 327 |
+
disabled={!anyPlaying}
|
| 328 |
+
sx={styles.masterBtn(MASTER_COLOR, 'stop')}
|
| 329 |
+
fullWidth
|
| 330 |
+
>
|
| 331 |
+
Stop All
|
| 332 |
+
</Button>
|
| 333 |
+
</Box>
|
| 334 |
+
</Box>
|
| 335 |
+
</Box>
|
| 336 |
+
</Box>
|
| 337 |
+
);
|
| 338 |
+
}
|
app/frontend/src/theme.js
CHANGED
|
@@ -518,6 +518,14 @@ export const lightTheme = createTheme(theme, {
|
|
| 518 |
text: {
|
| 519 |
primary: '#0F172A',
|
| 520 |
secondary: '#475569',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
},
|
| 522 |
divider: 'rgba(15, 23, 42, 0.14)',
|
| 523 |
error: {
|
|
@@ -1028,7 +1036,7 @@ export const appStyles = {
|
|
| 1028 |
minHeight: 0,
|
| 1029 |
},
|
| 1030 |
navPaper: {
|
| 1031 |
-
width: { xs: '100%', md: 220 },
|
| 1032 |
backgroundColor: 'background.paper',
|
| 1033 |
borderRadius: 2.5,
|
| 1034 |
overflow: 'hidden',
|
|
@@ -1036,7 +1044,7 @@ export const appStyles = {
|
|
| 1036 |
flexDirection: 'column',
|
| 1037 |
height: '100%',
|
| 1038 |
},
|
| 1039 |
-
navigationTabs: (isCompactLayout) => ({
|
| 1040 |
height: isCompactLayout ? 'auto' : '100%',
|
| 1041 |
p: { xs: 0.5, sm: 1 },
|
| 1042 |
gap: { xs: 0.25, sm: 0.5 },
|
|
@@ -1045,17 +1053,18 @@ export const appStyles = {
|
|
| 1045 |
},
|
| 1046 |
'& .MuiTab-root': {
|
| 1047 |
alignItems: 'center',
|
| 1048 |
-
justifyContent: isCompactLayout ? 'center' : 'flex-start',
|
| 1049 |
-
textAlign: isCompactLayout ? 'center' : 'left',
|
| 1050 |
minHeight: { xs: 40, sm: 46 },
|
| 1051 |
fontSize: { xs: '0.78rem', sm: '0.86rem' },
|
| 1052 |
fontWeight: 500,
|
| 1053 |
textTransform: 'none',
|
| 1054 |
color: 'text.secondary',
|
| 1055 |
borderRadius: 2,
|
| 1056 |
-
px: { xs: 1, sm: 1.5 },
|
| 1057 |
py: { xs: 0.75, sm: 1 },
|
| 1058 |
mx: isCompactLayout ? 0.25 : 0,
|
|
|
|
| 1059 |
'& .MuiTab-iconWrapper': {
|
| 1060 |
marginBottom: '0 !important',
|
| 1061 |
},
|
|
@@ -2110,4 +2119,416 @@ export const lossChartStyles = {
|
|
| 2110 |
},
|
| 2111 |
};
|
| 2112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2113 |
export default theme;
|
|
|
|
| 518 |
text: {
|
| 519 |
primary: '#0F172A',
|
| 520 |
secondary: '#475569',
|
| 521 |
+
disabled: 'rgba(0, 0, 0, 0.38)',
|
| 522 |
+
},
|
| 523 |
+
action: {
|
| 524 |
+
active: 'rgba(0, 0, 0, 0.54)',
|
| 525 |
+
hover: 'rgba(0, 0, 0, 0.04)',
|
| 526 |
+
selected: 'rgba(0, 0, 0, 0.08)',
|
| 527 |
+
disabled: 'rgba(0, 0, 0, 0.26)',
|
| 528 |
+
disabledBackground: 'rgba(0, 0, 0, 0.12)',
|
| 529 |
},
|
| 530 |
divider: 'rgba(15, 23, 42, 0.14)',
|
| 531 |
error: {
|
|
|
|
| 1036 |
minHeight: 0,
|
| 1037 |
},
|
| 1038 |
navPaper: {
|
| 1039 |
+
width: { xs: '100%', md: 64, lg: 220 },
|
| 1040 |
backgroundColor: 'background.paper',
|
| 1041 |
borderRadius: 2.5,
|
| 1042 |
overflow: 'hidden',
|
|
|
|
| 1044 |
flexDirection: 'column',
|
| 1045 |
height: '100%',
|
| 1046 |
},
|
| 1047 |
+
navigationTabs: (isCompactLayout, isIconOnly = false) => ({
|
| 1048 |
height: isCompactLayout ? 'auto' : '100%',
|
| 1049 |
p: { xs: 0.5, sm: 1 },
|
| 1050 |
gap: { xs: 0.25, sm: 0.5 },
|
|
|
|
| 1053 |
},
|
| 1054 |
'& .MuiTab-root': {
|
| 1055 |
alignItems: 'center',
|
| 1056 |
+
justifyContent: (isCompactLayout || isIconOnly) ? 'center' : 'flex-start',
|
| 1057 |
+
textAlign: (isCompactLayout || isIconOnly) ? 'center' : 'left',
|
| 1058 |
minHeight: { xs: 40, sm: 46 },
|
| 1059 |
fontSize: { xs: '0.78rem', sm: '0.86rem' },
|
| 1060 |
fontWeight: 500,
|
| 1061 |
textTransform: 'none',
|
| 1062 |
color: 'text.secondary',
|
| 1063 |
borderRadius: 2,
|
| 1064 |
+
px: isIconOnly ? 0 : { xs: 1, sm: 1.5 },
|
| 1065 |
py: { xs: 0.75, sm: 1 },
|
| 1066 |
mx: isCompactLayout ? 0.25 : 0,
|
| 1067 |
+
minWidth: isIconOnly ? 0 : undefined,
|
| 1068 |
'& .MuiTab-iconWrapper': {
|
| 1069 |
marginBottom: '0 !important',
|
| 1070 |
},
|
|
|
|
| 2119 |
},
|
| 2120 |
};
|
| 2121 |
|
| 2122 |
+
export const performancePanelStyles = {
|
| 2123 |
+
root: {
|
| 2124 |
+
display: 'flex',
|
| 2125 |
+
flexDirection: 'column',
|
| 2126 |
+
gap: 1.5,
|
| 2127 |
+
width: '100%',
|
| 2128 |
+
minHeight: 0,
|
| 2129 |
+
},
|
| 2130 |
+
headerCard: {
|
| 2131 |
+
display: 'flex',
|
| 2132 |
+
alignItems: 'center',
|
| 2133 |
+
gap: 2,
|
| 2134 |
+
p: { xs: 1.25, sm: 1.75 },
|
| 2135 |
+
borderRadius: 2.5,
|
| 2136 |
+
border: '1px solid',
|
| 2137 |
+
borderColor: 'divider',
|
| 2138 |
+
background: 'linear-gradient(135deg, rgba(53, 194, 212, 0.08) 0%, rgba(159, 138, 230, 0.06) 100%)',
|
| 2139 |
+
flexWrap: { xs: 'wrap', md: 'nowrap' },
|
| 2140 |
+
},
|
| 2141 |
+
headerLeft: {
|
| 2142 |
+
display: 'flex',
|
| 2143 |
+
flexDirection: 'column',
|
| 2144 |
+
gap: 0.25,
|
| 2145 |
+
minWidth: 200,
|
| 2146 |
+
flex: '0 0 auto',
|
| 2147 |
+
},
|
| 2148 |
+
titleRow: {
|
| 2149 |
+
display: 'flex',
|
| 2150 |
+
alignItems: 'center',
|
| 2151 |
+
gap: 1,
|
| 2152 |
+
color: 'primary.main',
|
| 2153 |
+
},
|
| 2154 |
+
title: {
|
| 2155 |
+
fontWeight: 400,
|
| 2156 |
+
letterSpacing: '0.02em',
|
| 2157 |
+
},
|
| 2158 |
+
subtitle: {
|
| 2159 |
+
color: 'text.secondary',
|
| 2160 |
+
fontSize: '0.72rem',
|
| 2161 |
+
},
|
| 2162 |
+
headerPickers: {
|
| 2163 |
+
display: 'flex',
|
| 2164 |
+
gap: 1,
|
| 2165 |
+
flex: 1,
|
| 2166 |
+
minWidth: 0,
|
| 2167 |
+
flexWrap: { xs: 'wrap', sm: 'nowrap' },
|
| 2168 |
+
},
|
| 2169 |
+
headerModelPicker: {
|
| 2170 |
+
flex: 1,
|
| 2171 |
+
minWidth: 200,
|
| 2172 |
+
'& .MuiOutlinedInput-root': { borderRadius: 2 },
|
| 2173 |
+
},
|
| 2174 |
+
headerCheckpointPicker: {
|
| 2175 |
+
flex: 1,
|
| 2176 |
+
minWidth: 180,
|
| 2177 |
+
'& .MuiOutlinedInput-root': { borderRadius: 2 },
|
| 2178 |
+
},
|
| 2179 |
+
errorAlert: {
|
| 2180 |
+
borderRadius: 2,
|
| 2181 |
+
},
|
| 2182 |
+
channelsRow: {
|
| 2183 |
+
display: 'flex',
|
| 2184 |
+
gap: 1.5,
|
| 2185 |
+
alignItems: 'stretch',
|
| 2186 |
+
flexWrap: { xs: 'wrap', md: 'nowrap' },
|
| 2187 |
+
},
|
| 2188 |
+
channelsGrid: {
|
| 2189 |
+
display: 'grid',
|
| 2190 |
+
gridTemplateColumns: 'repeat(4, minmax(150px, 1fr))',
|
| 2191 |
+
gap: 1.25,
|
| 2192 |
+
flex: 1,
|
| 2193 |
+
minWidth: 0,
|
| 2194 |
+
},
|
| 2195 |
+
masterStrip: (color) => (theme) => ({
|
| 2196 |
+
display: 'flex',
|
| 2197 |
+
flexDirection: 'column',
|
| 2198 |
+
gap: 1.5,
|
| 2199 |
+
p: 1.25,
|
| 2200 |
+
borderRadius: 2.5,
|
| 2201 |
+
border: `1px solid ${color}55`,
|
| 2202 |
+
background: theme.palette.mode === 'dark'
|
| 2203 |
+
? `linear-gradient(160deg, ${color}14 0%, rgba(13, 20, 31, 0.94) 70%)`
|
| 2204 |
+
: `linear-gradient(160deg, ${color}14 0%, ${theme.palette.background.paper} 70%)`,
|
| 2205 |
+
boxShadow: theme.palette.mode === 'dark'
|
| 2206 |
+
? `0 8px 22px rgba(4, 8, 14, 0.44), inset 0 0 0 1px ${color}22`
|
| 2207 |
+
: `0 2px 8px rgba(0,0,0,0.1), inset 0 0 0 1px ${color}22`,
|
| 2208 |
+
width: { xs: '100%', md: 160 },
|
| 2209 |
+
flex: { xs: '1 1 100%', md: '0 0 160px' },
|
| 2210 |
+
minHeight: 0,
|
| 2211 |
+
overflow: 'hidden',
|
| 2212 |
+
boxSizing: 'border-box',
|
| 2213 |
+
}),
|
| 2214 |
+
masterHeader: (color) => ({
|
| 2215 |
+
display: 'flex',
|
| 2216 |
+
alignItems: 'center',
|
| 2217 |
+
justifyContent: 'center',
|
| 2218 |
+
gap: 0.5,
|
| 2219 |
+
borderBottom: `1px solid ${color}33`,
|
| 2220 |
+
pb: 0.75,
|
| 2221 |
+
color,
|
| 2222 |
+
}),
|
| 2223 |
+
masterBadge: (color) => ({
|
| 2224 |
+
fontSize: '0.72rem',
|
| 2225 |
+
fontWeight: 600,
|
| 2226 |
+
color,
|
| 2227 |
+
letterSpacing: '0.14em',
|
| 2228 |
+
px: 0.75,
|
| 2229 |
+
py: 0.25,
|
| 2230 |
+
borderRadius: 1,
|
| 2231 |
+
backgroundColor: `${color}1F`,
|
| 2232 |
+
border: `1px solid ${color}55`,
|
| 2233 |
+
}),
|
| 2234 |
+
masterFaderWrap: {
|
| 2235 |
+
flex: 1,
|
| 2236 |
+
display: 'flex',
|
| 2237 |
+
flexDirection: 'row',
|
| 2238 |
+
alignItems: 'stretch',
|
| 2239 |
+
justifyContent: 'center',
|
| 2240 |
+
gap: 1,
|
| 2241 |
+
py: 1.25,
|
| 2242 |
+
minHeight: 0,
|
| 2243 |
+
},
|
| 2244 |
+
masterMeterTrack: (theme) => ({
|
| 2245 |
+
width: 10,
|
| 2246 |
+
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(9, 12, 18, 0.7)' : 'rgba(0, 0, 0, 0.08)',
|
| 2247 |
+
borderRadius: 0.75,
|
| 2248 |
+
border: '1px solid',
|
| 2249 |
+
borderColor: theme.palette.divider,
|
| 2250 |
+
position: 'relative',
|
| 2251 |
+
overflow: 'hidden',
|
| 2252 |
+
display: 'flex',
|
| 2253 |
+
alignItems: 'flex-end',
|
| 2254 |
+
}),
|
| 2255 |
+
masterMeterFill: (color) => ({
|
| 2256 |
+
width: '100%',
|
| 2257 |
+
height: '0%',
|
| 2258 |
+
background: `linear-gradient(0deg, ${color} 0%, ${color}DD 60%, #E3A34B 80%, #E36C61 100%)`,
|
| 2259 |
+
transition: 'height 0.05s linear',
|
| 2260 |
+
}),
|
| 2261 |
+
masterFader: (color) => (theme) => ({
|
| 2262 |
+
height: '100%',
|
| 2263 |
+
color,
|
| 2264 |
+
'& .MuiSlider-thumb': { width: 16, height: 16 },
|
| 2265 |
+
'& .MuiSlider-rail': { opacity: 0.3, width: 4 },
|
| 2266 |
+
'& .MuiSlider-track': { display: 'none' },
|
| 2267 |
+
'& .MuiSlider-mark': {
|
| 2268 |
+
width: 6,
|
| 2269 |
+
height: 1,
|
| 2270 |
+
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(157, 169, 188, 0.55)' : 'rgba(0, 0, 0, 0.25)',
|
| 2271 |
+
opacity: 1,
|
| 2272 |
+
},
|
| 2273 |
+
'& .MuiSlider-markActive': {
|
| 2274 |
+
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(157, 169, 188, 0.55)' : 'rgba(0, 0, 0, 0.25)',
|
| 2275 |
+
},
|
| 2276 |
+
}),
|
| 2277 |
+
masterReadouts: {
|
| 2278 |
+
display: 'flex',
|
| 2279 |
+
flexDirection: 'column',
|
| 2280 |
+
gap: 0.25,
|
| 2281 |
+
alignItems: 'center',
|
| 2282 |
+
},
|
| 2283 |
+
masterValue: {
|
| 2284 |
+
textAlign: 'center',
|
| 2285 |
+
color: 'primary.main',
|
| 2286 |
+
fontSize: '0.68rem',
|
| 2287 |
+
letterSpacing: '0.04em',
|
| 2288 |
+
},
|
| 2289 |
+
masterPeakValue: {
|
| 2290 |
+
textAlign: 'center',
|
| 2291 |
+
color: 'text.disabled',
|
| 2292 |
+
fontSize: '0.58rem',
|
| 2293 |
+
letterSpacing: '0.04em',
|
| 2294 |
+
},
|
| 2295 |
+
masterTransport: {
|
| 2296 |
+
display: 'flex',
|
| 2297 |
+
flexDirection: 'column',
|
| 2298 |
+
gap: 0.5,
|
| 2299 |
+
pt: 0.75,
|
| 2300 |
+
borderTop: '1px solid',
|
| 2301 |
+
borderTopColor: 'divider',
|
| 2302 |
+
},
|
| 2303 |
+
masterBtn: (color, variant) => (theme) => ({
|
| 2304 |
+
textTransform: 'none',
|
| 2305 |
+
borderRadius: 1.5,
|
| 2306 |
+
fontSize: '0.72rem',
|
| 2307 |
+
py: 0.5,
|
| 2308 |
+
...(variant === 'play'
|
| 2309 |
+
? {
|
| 2310 |
+
color,
|
| 2311 |
+
borderColor: theme.palette.mode === 'dark' ? `${color}66` : `${color}BB`,
|
| 2312 |
+
backgroundColor: `${color}14`,
|
| 2313 |
+
'&:hover': { backgroundColor: `${color}26`, borderColor: color },
|
| 2314 |
+
}
|
| 2315 |
+
: {
|
| 2316 |
+
color: '#E36C61',
|
| 2317 |
+
borderColor: theme.palette.mode === 'dark' ? 'rgba(227, 108, 97, 0.5)' : 'rgba(227, 108, 97, 0.8)',
|
| 2318 |
+
'&:hover': { backgroundColor: 'rgba(227, 108, 97, 0.12)', borderColor: '#E36C61' },
|
| 2319 |
+
}),
|
| 2320 |
+
}),
|
| 2321 |
+
};
|
| 2322 |
+
|
| 2323 |
+
export const performanceChannelStyles = {
|
| 2324 |
+
strip: (color, playing) => (theme) => ({
|
| 2325 |
+
display: 'flex',
|
| 2326 |
+
flexDirection: 'column',
|
| 2327 |
+
gap: 1,
|
| 2328 |
+
p: 1.25,
|
| 2329 |
+
borderRadius: 2.5,
|
| 2330 |
+
border: '1px solid',
|
| 2331 |
+
borderColor: playing ? color : theme.palette.divider,
|
| 2332 |
+
background: playing
|
| 2333 |
+
? `linear-gradient(160deg, ${color}1F 0%, ${theme.palette.background.paper} 60%)`
|
| 2334 |
+
: theme.palette.background.paper,
|
| 2335 |
+
boxShadow: playing
|
| 2336 |
+
? `0 0 0 1px ${color}66, 0 8px 22px ${theme.palette.mode === 'dark' ? 'rgba(4, 8, 14, 0.5)' : 'rgba(0,0,0,0.15)'}`
|
| 2337 |
+
: `0 2px 8px ${theme.palette.mode === 'dark' ? 'rgba(4, 8, 14, 0.36)' : 'rgba(0,0,0,0.08)'}`,
|
| 2338 |
+
transition: 'box-shadow 0.2s ease, border-color 0.2s ease, background 0.3s ease',
|
| 2339 |
+
height: '100%',
|
| 2340 |
+
minWidth: 150,
|
| 2341 |
+
}),
|
| 2342 |
+
stripHeader: (color) => ({
|
| 2343 |
+
display: 'flex',
|
| 2344 |
+
alignItems: 'center',
|
| 2345 |
+
justifyContent: 'space-between',
|
| 2346 |
+
borderBottom: `1px solid ${color}33`,
|
| 2347 |
+
pb: 0.75,
|
| 2348 |
+
}),
|
| 2349 |
+
channelBadge: (color) => ({
|
| 2350 |
+
fontFamily: 'inherit',
|
| 2351 |
+
fontSize: '0.78rem',
|
| 2352 |
+
fontWeight: 600,
|
| 2353 |
+
color,
|
| 2354 |
+
letterSpacing: '0.08em',
|
| 2355 |
+
px: 0.75,
|
| 2356 |
+
py: 0.25,
|
| 2357 |
+
borderRadius: 1,
|
| 2358 |
+
backgroundColor: `${color}1F`,
|
| 2359 |
+
border: `1px solid ${color}55`,
|
| 2360 |
+
}),
|
| 2361 |
+
muteSoloRow: {
|
| 2362 |
+
display: 'flex',
|
| 2363 |
+
gap: 0.5,
|
| 2364 |
+
},
|
| 2365 |
+
muteBtn: (active) => ({
|
| 2366 |
+
width: 22,
|
| 2367 |
+
height: 22,
|
| 2368 |
+
fontSize: '0.65rem',
|
| 2369 |
+
fontWeight: 700,
|
| 2370 |
+
borderRadius: 1,
|
| 2371 |
+
color: active ? '#fff' : 'text.secondary',
|
| 2372 |
+
backgroundColor: active ? 'rgba(227, 108, 97, 0.85)' : 'transparent',
|
| 2373 |
+
border: '1px solid',
|
| 2374 |
+
borderColor: active ? 'rgba(227, 108, 97, 0.85)' : 'divider',
|
| 2375 |
+
'&:hover': {
|
| 2376 |
+
backgroundColor: active ? 'rgba(227, 108, 97, 0.95)' : 'rgba(227, 108, 97, 0.18)',
|
| 2377 |
+
},
|
| 2378 |
+
}),
|
| 2379 |
+
soloBtn: (active) => ({
|
| 2380 |
+
width: 22,
|
| 2381 |
+
height: 22,
|
| 2382 |
+
fontSize: '0.65rem',
|
| 2383 |
+
fontWeight: 700,
|
| 2384 |
+
borderRadius: 1,
|
| 2385 |
+
color: active ? '#0c1018' : 'text.secondary',
|
| 2386 |
+
backgroundColor: active ? 'rgba(227, 163, 75, 0.95)' : 'transparent',
|
| 2387 |
+
border: '1px solid',
|
| 2388 |
+
borderColor: active ? 'rgba(227, 163, 75, 0.95)' : 'divider',
|
| 2389 |
+
'&:hover': {
|
| 2390 |
+
backgroundColor: active ? 'rgba(227, 163, 75, 1)' : 'rgba(227, 163, 75, 0.2)',
|
| 2391 |
+
},
|
| 2392 |
+
}),
|
| 2393 |
+
promptBox: {
|
| 2394 |
+
display: 'flex',
|
| 2395 |
+
flexDirection: 'column',
|
| 2396 |
+
gap: 0.5,
|
| 2397 |
+
},
|
| 2398 |
+
promptField: (theme) => ({
|
| 2399 |
+
'& .MuiOutlinedInput-root': {
|
| 2400 |
+
fontSize: '0.75rem',
|
| 2401 |
+
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(9, 12, 18, 0.5)' : 'rgba(0, 0, 0, 0.04)',
|
| 2402 |
+
borderRadius: 1.5,
|
| 2403 |
+
'& textarea': { lineHeight: 1.3 },
|
| 2404 |
+
},
|
| 2405 |
+
'& fieldset': { borderColor: theme.palette.divider },
|
| 2406 |
+
}),
|
| 2407 |
+
durationRow: {
|
| 2408 |
+
display: 'flex',
|
| 2409 |
+
alignItems: 'center',
|
| 2410 |
+
gap: 0.75,
|
| 2411 |
+
},
|
| 2412 |
+
durationLabel: {
|
| 2413 |
+
fontFamily: 'inherit',
|
| 2414 |
+
fontSize: '0.65rem',
|
| 2415 |
+
color: 'text.secondary',
|
| 2416 |
+
minWidth: 22,
|
| 2417 |
+
},
|
| 2418 |
+
durationSlider: (color) => ({
|
| 2419 |
+
flex: 1,
|
| 2420 |
+
color,
|
| 2421 |
+
'& .MuiSlider-thumb': { width: 10, height: 10 },
|
| 2422 |
+
'& .MuiSlider-rail': { opacity: 0.3 },
|
| 2423 |
+
}),
|
| 2424 |
+
generateBtn: (color) => (theme) => ({
|
| 2425 |
+
alignSelf: 'flex-end',
|
| 2426 |
+
width: 28,
|
| 2427 |
+
height: 28,
|
| 2428 |
+
borderRadius: 1.5,
|
| 2429 |
+
color,
|
| 2430 |
+
border: `1px solid ${color}55`,
|
| 2431 |
+
backgroundColor: `${color}14`,
|
| 2432 |
+
'&:hover': { backgroundColor: `${color}26` },
|
| 2433 |
+
'&.Mui-disabled': theme.palette.mode === 'dark' ? { opacity: 0.35 } : {},
|
| 2434 |
+
}),
|
| 2435 |
+
waveformWrap: (theme) => ({
|
| 2436 |
+
position: 'relative',
|
| 2437 |
+
height: 42,
|
| 2438 |
+
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(9, 12, 18, 0.6)' : 'rgba(0, 0, 0, 0.06)',
|
| 2439 |
+
borderRadius: 1.5,
|
| 2440 |
+
border: '1px solid',
|
| 2441 |
+
borderColor: theme.palette.divider,
|
| 2442 |
+
overflow: 'hidden',
|
| 2443 |
+
}),
|
| 2444 |
+
waveformPlaceholder: {
|
| 2445 |
+
position: 'absolute',
|
| 2446 |
+
inset: 0,
|
| 2447 |
+
display: 'flex',
|
| 2448 |
+
alignItems: 'center',
|
| 2449 |
+
justifyContent: 'center',
|
| 2450 |
+
color: 'text.disabled',
|
| 2451 |
+
fontFamily: 'inherit',
|
| 2452 |
+
fontSize: '0.65rem',
|
| 2453 |
+
letterSpacing: '0.1em',
|
| 2454 |
+
pointerEvents: 'none',
|
| 2455 |
+
},
|
| 2456 |
+
knobsGrid: {
|
| 2457 |
+
display: 'grid',
|
| 2458 |
+
gridTemplateColumns: 'repeat(4, 1fr)',
|
| 2459 |
+
gap: 0.5,
|
| 2460 |
+
py: 0.5,
|
| 2461 |
+
},
|
| 2462 |
+
knobCell: {
|
| 2463 |
+
display: 'flex',
|
| 2464 |
+
flexDirection: 'column',
|
| 2465 |
+
alignItems: 'center',
|
| 2466 |
+
gap: 0.25,
|
| 2467 |
+
height: 70,
|
| 2468 |
+
},
|
| 2469 |
+
knobSlider: (color) => ({
|
| 2470 |
+
height: 50,
|
| 2471 |
+
color,
|
| 2472 |
+
'& .MuiSlider-thumb': { width: 10, height: 10 },
|
| 2473 |
+
'& .MuiSlider-rail': { opacity: 0.3, width: 2 },
|
| 2474 |
+
'& .MuiSlider-track': { width: 2, border: 'none' },
|
| 2475 |
+
}),
|
| 2476 |
+
knobLabel: {
|
| 2477 |
+
display: 'block',
|
| 2478 |
+
fontFamily: 'inherit',
|
| 2479 |
+
fontSize: '0.53rem',
|
| 2480 |
+
color: 'text.secondary',
|
| 2481 |
+
letterSpacing: '0.06em',
|
| 2482 |
+
mt: 0.75,
|
| 2483 |
+
},
|
| 2484 |
+
transportRow: {
|
| 2485 |
+
display: 'flex',
|
| 2486 |
+
alignItems: 'center',
|
| 2487 |
+
gap: 0.5,
|
| 2488 |
+
mt: 'auto',
|
| 2489 |
+
pt: 0.75,
|
| 2490 |
+
borderTop: '1px solid',
|
| 2491 |
+
borderTopColor: 'divider',
|
| 2492 |
+
},
|
| 2493 |
+
transportBtn: (color, playing) => (theme) => ({
|
| 2494 |
+
width: 26,
|
| 2495 |
+
height: 26,
|
| 2496 |
+
borderRadius: 1.5,
|
| 2497 |
+
color: playing ? '#0c1018' : color,
|
| 2498 |
+
backgroundColor: playing ? color : `${color}14`,
|
| 2499 |
+
border: `1px solid ${color}55`,
|
| 2500 |
+
'&:hover': { backgroundColor: playing ? color : `${color}28` },
|
| 2501 |
+
'&.Mui-disabled': theme.palette.mode === 'dark' ? { opacity: 0.3 } : {},
|
| 2502 |
+
}),
|
| 2503 |
+
loopBtn: (color, active) => ({
|
| 2504 |
+
width: 22,
|
| 2505 |
+
height: 22,
|
| 2506 |
+
borderRadius: 1,
|
| 2507 |
+
color: active ? color : 'text.secondary',
|
| 2508 |
+
backgroundColor: active ? `${color}1F` : 'transparent',
|
| 2509 |
+
border: '1px solid',
|
| 2510 |
+
borderColor: active ? `${color}55` : 'divider',
|
| 2511 |
+
'&:hover': { backgroundColor: `${color}1F` },
|
| 2512 |
+
}),
|
| 2513 |
+
meterTrack: (theme) => ({
|
| 2514 |
+
flex: 1,
|
| 2515 |
+
height: 12,
|
| 2516 |
+
mt: 1,
|
| 2517 |
+
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(9, 12, 18, 0.7)' : 'rgba(0, 0, 0, 0.08)',
|
| 2518 |
+
borderRadius: 0.75,
|
| 2519 |
+
border: '1px solid',
|
| 2520 |
+
borderColor: theme.palette.divider,
|
| 2521 |
+
position: 'relative',
|
| 2522 |
+
overflow: 'hidden',
|
| 2523 |
+
display: 'flex',
|
| 2524 |
+
alignItems: 'center',
|
| 2525 |
+
}),
|
| 2526 |
+
meterFill: (color) => ({
|
| 2527 |
+
width: '0%',
|
| 2528 |
+
height: '100%',
|
| 2529 |
+
background: `linear-gradient(90deg, ${color} 0%, ${color}AA 70%, #E36C61 100%)`,
|
| 2530 |
+
transition: 'width 0.05s linear',
|
| 2531 |
+
}),
|
| 2532 |
+
};
|
| 2533 |
+
|
| 2534 |
export default theme;
|
app/frontend/src/utils/performanceAudio.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let sharedCtx = null;
|
| 2 |
+
|
| 3 |
+
export function getAudioContext() {
|
| 4 |
+
if (!sharedCtx) {
|
| 5 |
+
sharedCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 6 |
+
}
|
| 7 |
+
if (sharedCtx.state === 'suspended') {
|
| 8 |
+
sharedCtx.resume();
|
| 9 |
+
}
|
| 10 |
+
return sharedCtx;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
let sharedImpulse = null;
|
| 14 |
+
function getImpulse(ctx, duration = 2.6, decay = 3.0) {
|
| 15 |
+
if (sharedImpulse && sharedImpulse.sampleRate === ctx.sampleRate) {
|
| 16 |
+
return sharedImpulse;
|
| 17 |
+
}
|
| 18 |
+
const length = Math.floor(ctx.sampleRate * duration);
|
| 19 |
+
const buf = ctx.createBuffer(2, length, ctx.sampleRate);
|
| 20 |
+
for (let ch = 0; ch < 2; ch++) {
|
| 21 |
+
const data = buf.getChannelData(ch);
|
| 22 |
+
for (let i = 0; i < length; i++) {
|
| 23 |
+
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
sharedImpulse = buf;
|
| 27 |
+
return buf;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export class ChannelStrip {
|
| 31 |
+
constructor(masterBus) {
|
| 32 |
+
const ctx = getAudioContext();
|
| 33 |
+
this.ctx = ctx;
|
| 34 |
+
this.buffer = null;
|
| 35 |
+
this.source = null;
|
| 36 |
+
this.isPlaying = false;
|
| 37 |
+
this.isLooping = false;
|
| 38 |
+
this.isMuted = false;
|
| 39 |
+
this.isSoloed = false;
|
| 40 |
+
|
| 41 |
+
this.filter = ctx.createBiquadFilter();
|
| 42 |
+
this.filter.type = 'lowpass';
|
| 43 |
+
this.filter.frequency.value = 18000;
|
| 44 |
+
this.filter.Q.value = 0.7;
|
| 45 |
+
|
| 46 |
+
this.dryGain = ctx.createGain();
|
| 47 |
+
this.dryGain.gain.value = 1.0;
|
| 48 |
+
|
| 49 |
+
this.delayNode = ctx.createDelay(2.0);
|
| 50 |
+
this.delayNode.delayTime.value = 0.32;
|
| 51 |
+
this.delayFeedback = ctx.createGain();
|
| 52 |
+
this.delayFeedback.gain.value = 0.42;
|
| 53 |
+
this.delayWet = ctx.createGain();
|
| 54 |
+
this.delayWet.gain.value = 0.0;
|
| 55 |
+
|
| 56 |
+
this.reverbNode = ctx.createConvolver();
|
| 57 |
+
this.reverbNode.buffer = getImpulse(ctx);
|
| 58 |
+
this.reverbWet = ctx.createGain();
|
| 59 |
+
this.reverbWet.gain.value = 0.0;
|
| 60 |
+
|
| 61 |
+
this.channelGain = ctx.createGain();
|
| 62 |
+
this.channelGain.gain.value = 0;
|
| 63 |
+
|
| 64 |
+
this.pan = ctx.createStereoPanner();
|
| 65 |
+
this.pan.pan.value = 0;
|
| 66 |
+
|
| 67 |
+
this.analyser = ctx.createAnalyser();
|
| 68 |
+
this.analyser.fftSize = 256;
|
| 69 |
+
this.analyserData = new Uint8Array(this.analyser.frequencyBinCount);
|
| 70 |
+
|
| 71 |
+
this.filter.connect(this.dryGain);
|
| 72 |
+
this.dryGain.connect(this.channelGain);
|
| 73 |
+
|
| 74 |
+
this.filter.connect(this.delayNode);
|
| 75 |
+
this.delayNode.connect(this.delayFeedback);
|
| 76 |
+
this.delayFeedback.connect(this.delayNode);
|
| 77 |
+
this.delayNode.connect(this.delayWet);
|
| 78 |
+
this.delayWet.connect(this.channelGain);
|
| 79 |
+
|
| 80 |
+
this.filter.connect(this.reverbNode);
|
| 81 |
+
this.reverbNode.connect(this.reverbWet);
|
| 82 |
+
this.reverbWet.connect(this.channelGain);
|
| 83 |
+
|
| 84 |
+
this.channelGain.connect(this.pan);
|
| 85 |
+
this.pan.connect(this.analyser);
|
| 86 |
+
this.analyser.connect(masterBus);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async loadBlob(blob) {
|
| 90 |
+
this.stop();
|
| 91 |
+
const arrayBuffer = await blob.arrayBuffer();
|
| 92 |
+
this.buffer = await this.ctx.decodeAudioData(arrayBuffer);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
play(loop = this.isLooping) {
|
| 96 |
+
if (!this.buffer) return;
|
| 97 |
+
this.stop();
|
| 98 |
+
this.isLooping = loop;
|
| 99 |
+
const src = this.ctx.createBufferSource();
|
| 100 |
+
src.buffer = this.buffer;
|
| 101 |
+
src.loop = loop;
|
| 102 |
+
src.connect(this.filter);
|
| 103 |
+
src.onended = () => {
|
| 104 |
+
if (this.source === src) {
|
| 105 |
+
this.source = null;
|
| 106 |
+
this.isPlaying = false;
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
src.start(0);
|
| 110 |
+
this.source = src;
|
| 111 |
+
this.isPlaying = true;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
stop() {
|
| 115 |
+
if (this.source) {
|
| 116 |
+
try { this.source.stop(0); } catch (_) { /* already stopped */ }
|
| 117 |
+
this.source.disconnect();
|
| 118 |
+
this.source = null;
|
| 119 |
+
}
|
| 120 |
+
this.isPlaying = false;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
setGain(value) { this.channelGain.gain.setTargetAtTime(value, this.ctx.currentTime, 0.01); }
|
| 124 |
+
setFilter(hz) { this.filter.frequency.setTargetAtTime(hz, this.ctx.currentTime, 0.01); }
|
| 125 |
+
setDelayMix(value) { this.delayWet.gain.setTargetAtTime(value, this.ctx.currentTime, 0.02); }
|
| 126 |
+
setReverbMix(value) { this.reverbWet.gain.setTargetAtTime(value, this.ctx.currentTime, 0.05); }
|
| 127 |
+
setPan(value) { this.pan.pan.setTargetAtTime(value, this.ctx.currentTime, 0.01); }
|
| 128 |
+
setLoop(value) {
|
| 129 |
+
this.isLooping = value;
|
| 130 |
+
if (this.source) this.source.loop = value;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
applyMuteSolo(anySoloed) {
|
| 134 |
+
const audible = !this.isMuted && (!anySoloed || this.isSoloed);
|
| 135 |
+
this.channelGain.gain.setTargetAtTime(audible ? this._lastUserGain ?? 0 : 0, this.ctx.currentTime, 0.01);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
setUserGain(value) {
|
| 139 |
+
this._lastUserGain = value;
|
| 140 |
+
this.channelGain.gain.setTargetAtTime(value, this.ctx.currentTime, 0.01);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
getLevel() {
|
| 144 |
+
if (!this.isPlaying) return 0;
|
| 145 |
+
this.analyser.getByteTimeDomainData(this.analyserData);
|
| 146 |
+
let peak = 0;
|
| 147 |
+
for (let i = 0; i < this.analyserData.length; i++) {
|
| 148 |
+
const v = Math.abs(this.analyserData[i] - 128) / 128;
|
| 149 |
+
if (v > peak) peak = v;
|
| 150 |
+
}
|
| 151 |
+
return peak;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
drawWaveform(canvas, color) {
|
| 155 |
+
if (!canvas || !this.buffer) return;
|
| 156 |
+
const ctx2d = canvas.getContext('2d');
|
| 157 |
+
const w = canvas.width;
|
| 158 |
+
const h = canvas.height;
|
| 159 |
+
ctx2d.clearRect(0, 0, w, h);
|
| 160 |
+
const data = this.buffer.getChannelData(0);
|
| 161 |
+
const step = Math.max(1, Math.floor(data.length / w));
|
| 162 |
+
ctx2d.strokeStyle = color || '#35C2D4';
|
| 163 |
+
ctx2d.lineWidth = 1;
|
| 164 |
+
ctx2d.beginPath();
|
| 165 |
+
for (let i = 0; i < w; i++) {
|
| 166 |
+
let min = 1.0, max = -1.0;
|
| 167 |
+
const start = i * step;
|
| 168 |
+
const end = Math.min(data.length, start + step);
|
| 169 |
+
for (let j = start; j < end; j++) {
|
| 170 |
+
const v = data[j];
|
| 171 |
+
if (v < min) min = v;
|
| 172 |
+
if (v > max) max = v;
|
| 173 |
+
}
|
| 174 |
+
const yMin = (1 + min) * 0.5 * h;
|
| 175 |
+
const yMax = (1 + max) * 0.5 * h;
|
| 176 |
+
ctx2d.moveTo(i + 0.5, yMin);
|
| 177 |
+
ctx2d.lineTo(i + 0.5, yMax);
|
| 178 |
+
}
|
| 179 |
+
ctx2d.stroke();
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
dispose() {
|
| 183 |
+
this.stop();
|
| 184 |
+
try {
|
| 185 |
+
this.filter.disconnect();
|
| 186 |
+
this.dryGain.disconnect();
|
| 187 |
+
this.delayNode.disconnect();
|
| 188 |
+
this.delayFeedback.disconnect();
|
| 189 |
+
this.delayWet.disconnect();
|
| 190 |
+
this.reverbNode.disconnect();
|
| 191 |
+
this.reverbWet.disconnect();
|
| 192 |
+
this.channelGain.disconnect();
|
| 193 |
+
this.pan.disconnect();
|
| 194 |
+
this.analyser.disconnect();
|
| 195 |
+
} catch (_) { /* already disconnected */ }
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
export class PerformanceEngine {
|
| 200 |
+
constructor(channelCount = 8) {
|
| 201 |
+
const ctx = getAudioContext();
|
| 202 |
+
this.ctx = ctx;
|
| 203 |
+
this.masterBus = ctx.createGain();
|
| 204 |
+
this.masterBus.gain.value = 0.9;
|
| 205 |
+
this.masterAnalyser = ctx.createAnalyser();
|
| 206 |
+
this.masterAnalyser.fftSize = 1024;
|
| 207 |
+
this.masterAnalyserData = new Uint8Array(this.masterAnalyser.frequencyBinCount);
|
| 208 |
+
this.masterBus.connect(this.masterAnalyser);
|
| 209 |
+
this.masterAnalyser.connect(ctx.destination);
|
| 210 |
+
this.channels = Array.from({ length: channelCount }, () => new ChannelStrip(this.masterBus));
|
| 211 |
+
this.channels.forEach(ch => { ch._lastUserGain = 0; });
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
setMasterGain(value) {
|
| 215 |
+
this.masterBus.gain.setTargetAtTime(value, this.ctx.currentTime, 0.01);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
getMasterPeak() {
|
| 219 |
+
this.masterAnalyser.getByteTimeDomainData(this.masterAnalyserData);
|
| 220 |
+
let peak = 0;
|
| 221 |
+
for (let i = 0; i < this.masterAnalyserData.length; i++) {
|
| 222 |
+
const v = Math.abs(this.masterAnalyserData[i] - 128) / 128;
|
| 223 |
+
if (v > peak) peak = v;
|
| 224 |
+
}
|
| 225 |
+
return peak;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
refreshMuteSolo() {
|
| 229 |
+
const anySoloed = this.channels.some(ch => ch.isSoloed);
|
| 230 |
+
this.channels.forEach(ch => ch.applyMuteSolo(anySoloed));
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
setMute(index, value) {
|
| 234 |
+
this.channels[index].isMuted = value;
|
| 235 |
+
this.refreshMuteSolo();
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
setSolo(index, value) {
|
| 239 |
+
this.channels[index].isSoloed = value;
|
| 240 |
+
this.refreshMuteSolo();
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
playAll(loop = true) {
|
| 244 |
+
this.channels.forEach(ch => { if (ch.buffer) ch.play(loop); });
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
stopAll() {
|
| 248 |
+
this.channels.forEach(ch => ch.stop());
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
dispose() {
|
| 252 |
+
this.channels.forEach(ch => ch.dispose());
|
| 253 |
+
try { this.masterBus.disconnect(); } catch (_) { /* already disconnected */ }
|
| 254 |
+
try { this.masterAnalyser.disconnect(); } catch (_) { /* already disconnected */ }
|
| 255 |
+
}
|
| 256 |
+
}
|