MazCodes commited on
Commit
f29605c
·
verified ·
1 Parent(s): 4fda6fc

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
- Upload as UploadIcon,
36
  Play as PlayIcon,
37
  Square as StopIcon,
38
- Activity as ActivityIcon,
39
  SlidersHorizontal as SlidersIcon,
40
- Sparkles as SparklesIcon,
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="start" label="Data Processing" />
1032
- <Tab icon={<ActivityIcon size={20} />} iconPosition="start" label="Training" />
1033
- <Tab icon={<SparklesIcon size={20} />} iconPosition="start" label="Generation" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }