diff --git "a/app/frontend/src/App.js" "b/app/frontend/src/App.js" --- "a/app/frontend/src/App.js" +++ "b/app/frontend/src/App.js" @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useRef, Suspense, lazy } from 'react'; +import React, { useCallback, useState, useEffect, useMemo, useRef } from 'react'; import { Container, Box, @@ -21,6 +21,11 @@ import { FormControl, Select, MenuItem, + Menu, + ListItemIcon, + ListItemText, + Divider, + Snackbar, Accordion, AccordionSummary, AccordionDetails, @@ -31,8 +36,9 @@ import { useMediaQuery, ToggleButton, ToggleButtonGroup, - Tooltip, } from '@mui/material'; +import { TIPS } from './tooltips'; +import Tooltip from './components/Tooltip'; import { Plus as AddIcon, Database as UploadIcon, @@ -46,61 +52,76 @@ import { CloudDownload as CloudDownloadIcon, FolderOpen as FolderOpenIcon, Info as InfoIcon, - BookOpen as BookOpenIcon, + HelpCircle as InfoViewIcon, Moon as MoonIcon, Sun as SunIcon, Piano as PerformanceIcon, AlertCircle as AlertIcon, Wand2 as WandIcon, - Trash2 as DeleteIcon + Trash2 as DeleteIcon, + Menu as MenuIcon, + CheckCircle2 as CheckCircleIcon, } from 'lucide-react'; import api from './api'; -import HfAuthDialog from './components/HfAuthDialog'; +import AboutDialog from './components/AboutDialog'; +import { InfoViewProvider } from './components/InfoView'; import TabPanel from './components/TabPanel'; -import AudioUploadRow from './components/AudioUploadRow'; -import BulkAnnotatePanel from './components/BulkAnnotatePanel'; -import CsvImportPanel from './components/CsvImportPanel'; +import DatasetPrep from './components/DatasetPrep'; import TrainingMonitor from './components/TrainingMonitor'; -import ModelUnwrapButton from './components/ModelUnwrapButton'; -import CheckpointManager from './components/CheckpointManager'; +import CheckpointManagerWindow from './components/CheckpointManagerWindow'; +import LoraStack from './components/LoraStack'; +import EditPanel from './components/EditPanel'; import GeneratedFragmentsWindow from './components/GeneratedFragmentsWindow'; import WelcomePage from './components/WelcomePage'; -import { clearPerformanceSession } from './components/usePerformanceSession'; import { formatDuration } from './utils/format'; import theme, { appStyles, lightTheme } from './theme'; -const PerformancePanel = lazy(() => import('./components/PerformancePanel')); +import PerformancePanel from './components/PerformancePanel'; const COLOR_MODE_STORAGE_KEY = 'fragmenta-color-mode'; -const HIDE_WELCOME_PAGE_KEY = 'fragmenta-hide-welcome'; -const PERFORMANCE_ENABLED_KEY = 'fragmenta-performance-enabled'; +const HIDE_WELCOME_PAGE_KEY = 'fragmenta-hide-welcome-v2'; +const INFO_VIEW_STORAGE_KEY = 'fragmenta-info-view'; + +// Persisted across reload so the user lands back where they were. +// Tabs are: 0=Dataset, 1=Training, 2=Generation, 3=Performance. +const TAB_STORAGE_KEY = 'fragmenta.lastTab'; +const TAB_COUNT = 4; +const readStoredTab = () => { + try { + const raw = window.localStorage.getItem(TAB_STORAGE_KEY); + const n = Number(raw); + return Number.isFinite(n) && n >= 0 && n < TAB_COUNT ? n : 0; + } catch { + return 0; + } +}; function App() { - const [tabValue, setTabValue] = useState(0); - const [uploadRows, setUploadRows] = useState([ - { file: null, prompt: '', audioUrl: '' } - ]); + const [tabValue, setTabValue] = useState(readStoredTab); + // Lags behind tabValue by ~fadeDuration so content swap happens + // while the panel is invisible (cross-fade between pages). + const [displayedTab, setDisplayedTab] = useState(readStoredTab); + const TAB_FADE_MS = 180; + + // Persist the active tab so a reload returns the user to it. + useEffect(() => { + try { window.localStorage.setItem(TAB_STORAGE_KEY, String(tabValue)); } catch {} + }, [tabValue]); + // Header sticky chrome only kicks in once the page has scrolled. + const [isScrolled, setIsScrolled] = useState(false); + // Measure the header's actual rendered height so the fixed nav + // rail can be pinned at exactly the first card's top edge. + const headerRef = useRef(null); + const [navTopPx, setNavTopPx] = useState(94); const [processingStatus, setProcessingStatus] = useState(''); const [isProcessing, setIsProcessing] = useState(false); - const [processedCount, setProcessedCount] = useState(0); - const [chunksPreview, setChunksPreview] = useState([]); const [showWelcomePage, setShowWelcomePage] = useState( () => window.localStorage.getItem(HIDE_WELCOME_PAGE_KEY) !== 'true' ); - const [performanceEnabled, setPerformanceEnabled] = useState( - () => window.localStorage.getItem(PERFORMANCE_ENABLED_KEY) === 'true' - ); - const togglePerformance = () => { - setPerformanceEnabled((prev) => { - const next = !prev; - window.localStorage.setItem(PERFORMANCE_ENABLED_KEY, next ? 'true' : 'false'); - if (!next && tabValue === 3) setTabValue(0); - if (next) setTabValue(3); - return next; - }); - }; - const [authDialogOpen, setAuthDialogOpen] = useState(false); + const [checkpointMgrOpen, setCheckpointMgrOpen] = useState(false); + const [generationModelSelectOpen, setGenerationModelSelectOpen] = useState(false); + const [trainingBaseModelSelectOpen, setTrainingBaseModelSelectOpen] = useState(false); const [showInfoDialog, setShowInfoDialog] = useState(false); const [isOpeningDocumentation, setIsOpeningDocumentation] = useState(false); const [colorMode, setColorMode] = useState(() => { @@ -116,22 +137,48 @@ function App() { return 'dark'; }); + // Ableton-style Info View: when on, control help text shows in a fixed + // bottom bar (fed by the shared ) instead of popping over each + // control. Off by default; preference persisted. + const [infoViewEnabled, setInfoViewEnabled] = useState(() => { + if (typeof window === 'undefined') return false; + // Off by default — only on if the user explicitly turned it on. + return window.localStorage.getItem(INFO_VIEW_STORAGE_KEY) === 'on'; + }); + const toggleInfoView = useCallback(() => { + setInfoViewEnabled((prev) => { + const next = !prev; + try { window.localStorage.setItem(INFO_VIEW_STORAGE_KEY, next ? 'on' : 'off'); } catch (_) {} + return next; + }); + }, []); + const [trainingConfig, setTrainingConfig] = useState({ - mode: 'lora', - epochs: 30, - checkpointSteps: 500, + steps: 1000, // SA3 quick-start + checkpointSteps: 250, checkpointAuto: true, - batchSize: 4, + batchSize: 1, // SA3 examples all use 1 learningRate: 1e-4, - modelName: 'my_fine_tuned_model', - baseModel: 'stable-audio-open-1.0', - saveWrappedCheckpoint: false, - precision: 'auto', + modelName: 'my_lora', + baseModel: 'sa3-small-music-base', // only *-base checkpoints are valid targets + precision: 'bf16', + // Training window defaults to the base model's native length (small + // ≈120s; medium ≈380s — set on base-model change). Default base is + // small-music-base → 120s. + duration: 120.0, loraRank: 16, loraAlpha: 16, loraDropout: 0, - loraMultiplier: 1.0, + adapterType: 'dora-rows', // SA3 upstream default + seedRandom: true, // fresh random seed each run (recorded server-side) + seed: 42, // used only when seedRandom is off + + // SA3 docs' "common case" layer filter — prevents conditioner-hijacking + // on small datasets. Stored as space-separated strings (the format SA3's + // CLI consumes) so the Advanced TextFields can edit them directly. + include: 'transformer.layers', + exclude: 'seconds_total to_local_embed', }); const [checkpointPreview, setCheckpointPreview] = useState(null); const [suggestionDialog, setSuggestionDialog] = useState({ open: false, data: null, loading: false }); @@ -143,14 +190,18 @@ function App() { const [trainingStartTime, setTrainingStartTime] = useState(null); const [trainingError, setTrainingError] = useState(null); + // Generation panel top-level mode: 'create' (text → audio) or + // 'edit' (audio → audio: style transfer, inpaint, extend). + const [generationMode, setGenerationMode] = useState('create'); const [generationPrompt, setGenerationPrompt] = useState(''); + const [negativePrompt, setNegativePrompt] = useState(''); + const [loraStack, setLoraStack] = useState([]); // [{path, strength}] const [generationDuration, setGenerationDuration] = useState(10); const [generatedAudio, setGeneratedAudio] = useState(null); const [generatedAudioBlob, setGeneratedAudioBlob] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const [generationProgress, setGenerationProgress] = useState(0); const [selectedModel, setSelectedModel] = useState(''); - const [selectedUnwrappedModel, setSelectedUnwrappedModel] = useState(''); const [generatedFragments, setGeneratedFragments] = useState([]); const [currentFilename, setCurrentFilename] = useState(''); const [cfgScale, setCfgScale] = useState(7.0); @@ -200,48 +251,199 @@ function App() { } }; - const downloadFragment = (fragment) => { - const link = document.createElement('a'); - link.href = fragment.audioUrl; - link.download = fragment.filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const deleteFragment = async (fragment) => { + if (!fragment?.filename) return; + try { + await api.delete(`/api/fragments/${encodeURIComponent(fragment.filename)}`); + setGeneratedFragments(prev => prev.filter(f => f.id !== fragment.id)); + // Best-effort revoke of blob URLs created during this session so + // we don't leak object URLs after delete. + if (fragment.audioUrl?.startsWith('blob:')) { + try { URL.revokeObjectURL(fragment.audioUrl); } catch { /* ignore */ } + } + } catch (err) { + console.error('Delete fragment failed:', err); + } + }; + + const clearAllFragments = async () => { + try { + await api.delete('/api/fragments'); + // Revoke any in-session blob URLs before clearing state. + generatedFragments.forEach(f => { + if (f.audioUrl?.startsWith('blob:')) { + try { URL.revokeObjectURL(f.audioUrl); } catch { /* ignore */ } + } + }); + setGeneratedFragments([]); + } catch (err) { + console.error('Clear all fragments failed:', err); + } }; - const [systemStatus, setSystemStatus] = useState(null); - const [isStatusLoading, setIsStatusLoading] = useState(false); const [availableModels, setAvailableModels] = useState([]); const [gpuMemoryStatus, setGpuMemoryStatus] = useState(null); const [isUpdatingGpuMemory, setIsUpdatingGpuMemory] = useState(false); const [baseModels, setBaseModels] = useState([ - { - name: 'stable-audio-open-small', - displayName: 'Stable Audio Open Small (Recommended)', - description: 'Faster - Lower memory usage', - type: 'base', - path: '/models/pretrained/stable-audio-open-small-model.safetensors', - configPath: '/models/config/model_config_small.json', - downloaded: false - }, - { - name: 'stable-audio-open-1.0', - displayName: 'Stable Audio Open 1.0', - description: 'Higher quality - Requires more memory', - type: 'base', - path: '/models/pretrained/stable-audio-open-model.safetensors', - configPath: '/models/config/model_config.json', - downloaded: false - } + { name: 'sa3-small-music', displayName: 'Small - Music', description: 'CPU/GPU · ≤ 120s', kind: 'post-trained', downloaded: false }, + { name: 'sa3-small-sfx', displayName: 'Small - SFX', description: 'CPU/GPU · ≤ 120s', kind: 'post-trained', downloaded: false }, + { name: 'sa3-medium', displayName: 'Medium', description: 'CUDA + Flash-Attn · ≤ 380s', kind: 'post-trained', downloaded: false }, + { name: 'sa3-small-music-base', displayName: 'Small - Music (Base)', description: 'CPU/GPU · ≤ 120s', kind: 'base', downloaded: false }, + { name: 'sa3-small-sfx-base', displayName: 'Small - SFX (Base)', description: 'CPU/GPU · ≤ 120s', kind: 'base', downloaded: false }, + { name: 'sa3-medium-base', displayName: 'Medium (Base)', description: 'CUDA + Flash-Attn · ≤ 380s', kind: 'base', downloaded: false }, ]); - const [showStartFreshDialog, setShowStartFreshDialog] = useState(false); - const [isStartingFresh, setIsStartingFresh] = useState(false); - const [uploadKey, setUploadKey] = useState(0); - // Bumping this key forces the performance panel to remount, which is how - // we flush its in-memory session state on Fresh Start (clearing localStorage - // alone wouldn't reset the mounted panel's useState mirrors). - const [performanceResetKey, setPerformanceResetKey] = useState(0); + // Dataset Workbench projects available as training inputs. Refreshed on + // mount and every time the Training tab becomes visible (in case the user + // just committed a project on the Dataset tab). + const [trainingProjects, setTrainingProjects] = useState([]); + const [trainingProject, setTrainingProject] = useState(() => { + try { return window.localStorage.getItem('fragmenta.training.lastProject') || ''; } + catch { return ''; } + }); + // Phase 6 — pre-encode state for the selected training project. + // { latents_count, latents_present, job: {state, current, total, ...} | null } + const [trainingPreEncode, setTrainingPreEncode] = useState({ + latents_count: 0, + latents_present: false, + job: null, + }); + const preEncodePollRef = useRef(null); + const refreshTrainingProjects = useCallback(async () => { + try { + const { data } = await api.get('/api/projects'); + setTrainingProjects(data.projects || []); + } catch { /* non-fatal */ } + }, []); + useEffect(() => { refreshTrainingProjects(); }, [refreshTrainingProjects]); + useEffect(() => { + if (tabValue === 1) refreshTrainingProjects(); + }, [tabValue, refreshTrainingProjects]); + + // Hydrate the Generated Fragments panel from disk on mount. Each + // /api/generate writes a sidecar JSON next to the WAV; this restores + // the latest 100 across page reloads. Server returns newest-first; we + // reverse so the in-memory order stays oldest-first (matches the + // append-at-end pattern used elsewhere — GeneratedFragmentsWindow + // reverses for display). + useEffect(() => { + let cancelled = false; + (async () => { + try { + const r = await api.get('/api/fragments?limit=100'); + if (cancelled) return; + const items = (r.data?.fragments || []) + // Performance-tab master recordings live in the same output + // folder but aren't generations — keep them out of here. + .filter((f) => f.source !== 'performance') + // Cap the browser at the 50 most recent generations. + .slice(0, 50) + .map((f, i) => ({ + id: f.created_at ? Math.round(f.created_at * 1000) + i : Date.now() - i, + prompt: f.prompt || '', + duration: f.duration, + cfgScale: f.cfg_scale, + steps: f.steps, + seed: f.seed, + modelId: f.model_id || '', + batchIndex: 1, + batchTotal: f.batch_size || 1, + audioUrl: `/api/fragments/${encodeURIComponent(f.filename)}`, + audioBlob: null, + filename: f.filename, + timestamp: f.created_at + ? new Date(f.created_at * 1000).toLocaleString() + : '', + createdAt: f.created_at ? f.created_at * 1000 : null, + editMode: f.edit_mode || null, + })); + // Server sends newest-first; reverse to keep the in-memory + // append-at-end convention. + items.reverse(); + setGeneratedFragments(items); + } catch (err) { + // Non-fatal — empty list is fine. + console.warn('Failed to hydrate fragments from server:', err); + } + })(); + return () => { cancelled = true; }; + }, []); + useEffect(() => { + try { + if (trainingProject) window.localStorage.setItem('fragmenta.training.lastProject', trainingProject); + } catch {} + }, [trainingProject]); + // If the persisted project no longer exists, clear it so the picker shows "(none)". + useEffect(() => { + if (trainingProject && trainingProjects.length > 0 && !trainingProjects.some(p => p.name === trainingProject)) { + setTrainingProject(''); + } + }, [trainingProject, trainingProjects]); + + // Phase 6 — refresh pre-encode state when the user changes which project + // they're training on, and keep polling while a job is in flight. + const refreshTrainingPreEncode = useCallback(async (name) => { + if (!name) { + setTrainingPreEncode({ latents_count: 0, latents_present: false, job: null }); + return; + } + try { + const [proj, status] = await Promise.all([ + api.get(`/api/projects/${encodeURIComponent(name)}`), + api.get(`/api/projects/${encodeURIComponent(name)}/pre-encode/status`), + ]); + setTrainingPreEncode({ + latents_count: proj.data.latents_count ?? 0, + latents_present: !!proj.data.latents_present, + job: status.data.job ?? null, + }); + } catch { /* non-fatal */ } + }, []); + + useEffect(() => { + refreshTrainingPreEncode(trainingProject); + }, [trainingProject, refreshTrainingPreEncode]); + + // Poll while a job is queued/running. Clean up on project change or unmount. + useEffect(() => { + const job = trainingPreEncode.job; + const inFlight = job && (job.state === 'queued' || job.state === 'running'); + if (!inFlight || !trainingProject) { + if (preEncodePollRef.current) { + window.clearTimeout(preEncodePollRef.current); + preEncodePollRef.current = null; + } + return; + } + preEncodePollRef.current = window.setTimeout(() => { + refreshTrainingPreEncode(trainingProject); + }, 750); + return () => { + if (preEncodePollRef.current) { + window.clearTimeout(preEncodePollRef.current); + preEncodePollRef.current = null; + } + }; + }, [trainingProject, trainingPreEncode.job, refreshTrainingPreEncode]); + + const startTrainingPreEncode = useCallback(async () => { + if (!trainingProject) return; + try { + await api.post(`/api/projects/${encodeURIComponent(trainingProject)}/pre-encode`); + refreshTrainingPreEncode(trainingProject); + } catch (e) { + console.error('Failed to start pre-encode', e); + } + }, [trainingProject, refreshTrainingPreEncode]); + + const cancelTrainingPreEncode = useCallback(async () => { + if (!trainingProject) return; + try { + await api.post(`/api/projects/${encodeURIComponent(trainingProject)}/pre-encode/cancel`); + refreshTrainingPreEncode(trainingProject); + } catch (e) { /* non-fatal */ } + }, [trainingProject, refreshTrainingPreEncode]); + const [isFreeingGPU, setIsFreeingGPU] = useState(false); const [showFreeGPUDialog, setShowFreeGPUDialog] = useState(false); const [modelWarning, setModelWarning] = useState({ @@ -255,11 +457,18 @@ function App() { [colorMode] ); const isCompactLayout = useMediaQuery(appTheme.breakpoints.down('md')); - const isIconOnlySidebar = useMediaQuery(appTheme.breakpoints.between('md', 'lg')); - - useEffect(() => { - setSelectedUnwrappedModel(''); - }, [selectedModel]); + // Vertical icon-only mode: between the compact (horizontal) threshold + // and a custom upper bound. The MUI `lg` breakpoint at 1200 was too + // eager — labels collapsed while there was still plenty of room. + const isIconOnlySidebar = useMediaQuery('(min-width: 900px) and (max-width: 1099.95px)'); + // Mobile/very-small width — the nav rail goes horizontal (compact) + // AND drops the text labels, matching the icon-only treatment used + // on mid-size vertical. + const isMobileLayout = useMediaQuery(appTheme.breakpoints.down('sm')); + // Dock collapses to a hamburger at the same threshold where the nav + // rail flips horizontal — keeps the chrome transition unified. + const isDockCollapsed = isCompactLayout; + const [dockMenuAnchor, setDockMenuAnchor] = useState(null); useEffect(() => { console.log('Model changed:', selectedModel); @@ -268,35 +477,27 @@ function App() { setSelectedLora(''); }, [selectedModel]); - // Resolve the base model identity for the currently-selected entry. Works - // for both base-model selections (selectedModel === 'stable-audio-open-...') - // and fine-tunes (where the API returns base_model from training_metadata). + // Resolve the base SA3 model identity for the currently-selected entry. + // For a direct base pick it's selectedModel itself; for a fine-tune we + // read base_model from the training_metadata exposed by /api/models. const resolvedBaseModel = (() => { if (!selectedModel) return null; - if (selectedModel === 'stable-audio-open-small' || selectedModel === 'stable-audio-open-1.0') { - return selectedModel; - } + if (selectedModel.startsWith('sa3-')) return selectedModel; const model = availableModels.find(m => m.name === selectedModel); - if (model?.base_model) return model.base_model; - // Legacy fine-tunes without base_model metadata: fall back to the - // unwrapped-file size heuristic. - if (model && selectedUnwrappedModel) { - const u = model.unwrapped_models?.find(x => x.path === selectedUnwrappedModel); - if (u) return (u.size_mb || 0) < 2000 ? 'stable-audio-open-small' : 'stable-audio-open-1.0'; - } - return null; + return model?.base_model || null; })(); - // True only for the original distilled small base, NOT for fine-tunes of - // it. Fine-tuning destroys the CFG distillation, so the 8-step / CFG-1.0 - // lock no longer applies — the user controls steps and CFG normally. - const isDistilledBase = selectedModel === 'stable-audio-open-small'; + // All three user-visible SA3 models are post-trained (distilled to 8 + // steps, CFG baked at 1.0). The backend ignores cfg_scale on these and + // defaults steps to 8 — the UI just mirrors that so the controls don't + // show misleading values. + const isDistilledBase = !!selectedModel && selectedModel.startsWith('sa3-') && !selectedModel.endsWith('-base'); const getMaxDuration = () => { - if (!selectedModel) return 10; - if (resolvedBaseModel === 'stable-audio-open-small') return 11; - if (resolvedBaseModel === 'stable-audio-open-1.0') return 47; - return 10; + if (!selectedModel) return 30; + if (resolvedBaseModel === 'sa3-medium' || resolvedBaseModel === 'sa3-medium-base') return 380; + if (resolvedBaseModel && resolvedBaseModel.startsWith('sa3-')) return 120; + return 30; }; useEffect(() => { @@ -304,49 +505,65 @@ function App() { if (generationDuration > maxDuration) { setGenerationDuration(maxDuration); } - // The distilled small model is hard-coded to 8 steps + pingpong sampler - // at the backend regardless of slider value; snap the slider so the UI - // reflects what will actually run. When switching BACK to a non- - // distilled model, restore a sensible default — otherwise the slider - // is stuck at 8 from the prior selection and the big model runs 8 - // steps (which produces noise). + // SA3 post-trained models run at 8 steps with CFG=1.0; base variants + // want ~50 steps with CFG~7. Snap the slider so the UI reflects what + // will actually run. if (isDistilledBase && steps !== 8) { setSteps(8); } else if (!isDistilledBase && steps < 50) { - setSteps(250); + setSteps(50); } - }, [selectedModel, selectedUnwrappedModel, isDistilledBase]); + }, [selectedModel, isDistilledBase]); const handleTabChange = (event, newValue) => { + if (newValue === tabValue) return; setTabValue(newValue); }; - const addUploadRow = () => { - setUploadRows([...uploadRows, { file: null, prompt: '', audioUrl: '' }]); - }; + // Sync displayedTab to tabValue with a fade-out delay so content + // swap happens while the wrapper opacity is at 0. Works for any + // code path that updates tabValue (Tabs click, model-warning + // auto-jump, etc). + useEffect(() => { + if (tabValue === displayedTab) return; + const t = window.setTimeout(() => setDisplayedTab(tabValue), TAB_FADE_MS); + return () => window.clearTimeout(t); + }, [tabValue, displayedTab]); - const removeUploadRow = (index) => { - const newRows = uploadRows.filter((_, i) => i !== index); - setUploadRows(newRows); - }; + useEffect(() => { + const onScroll = () => setIsScrolled(window.scrollY > 8); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); - const updateUploadRow = (index, data) => { - const newRows = [...uploadRows]; - newRows[index] = data; - setUploadRows(newRows); - }; + // Re-measure header bottom edge on mount, resize, and content + // reflows. Nav rail's `top` = headerBottom + headerRow.mb + + // tabPanelStyles.pt so it lines up with the first card. + useEffect(() => { + if (!headerRef.current) return undefined; + const el = headerRef.current; + const measure = () => { + // Header is sticky at top: 0, so rect.bottom is already the + // viewport y of the header's bottom edge. + const rect = el.getBoundingClientRect(); + const w = window.innerWidth; + const offset = w >= 900 ? 18 : w >= 600 ? 14 : 12; + setNavTopPx(rect.bottom + offset); + }; + measure(); + // Re-measure only when the header's actual size changes (e.g. + // GPU card transitions detected ↔ not on first load) or the + // window resizes — never on scroll, never on poll churn. + const ro = new ResizeObserver(measure); + ro.observe(el); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); - const fetchSystemStatus = async () => { - setIsStatusLoading(true); - try { - const response = await api.get('/api/status'); - setSystemStatus(response.data); - } catch (error) { - console.error('Error fetching system status:', error); - } finally { - setIsStatusLoading(false); - } - }; const fetchAvailableModels = async () => { try { @@ -369,17 +586,18 @@ function App() { const fetchBaseModelsStatus = async () => { try { - const response = await api.get('/api/base-models/status'); - const baseModelsStatus = response.data.base_models; - + const response = await api.get('/api/checkpoints'); + const byId = Object.fromEntries( + (response.data.checkpoints || []).map(c => [c.id, c]) + ); setBaseModels(prevModels => prevModels.map(model => ({ ...model, - downloaded: baseModelsStatus[model.name]?.downloaded || false + downloaded: byId[model.name]?.downloaded || false, })) ); } catch (error) { - console.error('Error fetching base models status:', error); + console.error('Error fetching checkpoint status:', error); } }; @@ -402,7 +620,6 @@ function App() { } else { if (selectedModel === name) { setSelectedModel(''); - setSelectedUnwrappedModel(''); } } refreshAllModels(); @@ -435,7 +652,6 @@ function App() { }; useEffect(() => { - fetchSystemStatus(); fetchAvailableModels(); fetchBaseModelsStatus(); fetchAvailableLoras(); @@ -467,7 +683,7 @@ function App() { }, 300); return () => clearTimeout(handle); }, [ - trainingConfig.epochs, + trainingConfig.steps, trainingConfig.batchSize, trainingConfig.checkpointSteps, trainingConfig.checkpointAuto, @@ -496,10 +712,10 @@ function App() { const newEntry = { timestamp: Date.now(), progress: currentStatus.progress || 0, - current_epoch: currentStatus.current_epoch || 0, - current_step: currentStatus.current_step || 0, + current_step: currentStatus.current_step ?? currentStatus.step ?? 0, loss: currentStatus.loss, - checkpoints_saved: currentStatus.checkpoints_saved || 0, + checkpoints_saved: currentStatus.checkpoints_saved + ?? (currentStatus.checkpoints?.length || 0), is_training: currentStatus.is_training, message: currentStatus.error || (currentStatus.progress > 0 ? `Progress: ${currentStatus.progress}%` : 'Starting...') @@ -508,7 +724,6 @@ function App() { const lastEntry = prev[prev.length - 1]; if (!lastEntry || lastEntry.progress !== newEntry.progress || - lastEntry.current_epoch !== newEntry.current_epoch || lastEntry.current_step !== newEntry.current_step || lastEntry.loss !== newEntry.loss || lastEntry.checkpoints_saved !== newEntry.checkpoints_saved || @@ -530,11 +745,9 @@ function App() { setTrainingProgress(100); } setTimeout(() => { - fetchSystemStatus(); - // refreshAllModels picks up the new LoRA too if - // this was a LoRA run — without it, the LoRA - // picker stays empty until the user manually hits - // refresh. + // refreshAllModels picks up the new LoRA — without it, + // the LoRA picker stays empty until the user manually + // hits refresh. refreshAllModels(); }, 0); } @@ -552,41 +765,23 @@ function App() { }; }, [isTraining]); - const processFiles = async () => { - setIsProcessing(true); - setProcessingStatus('Processing files...'); - - try { - const formData = new FormData(); - - uploadRows.forEach((row, index) => { - if (row.file && row.prompt) { - formData.append(`file_${index}`, row.file); - formData.append(`prompt_${index}`, row.prompt); - } - }); - - const response = await api.post('/api/process-files', formData); - - setProcessingStatus(response.data.message); - setProcessedCount(response.data.processed_count); - setChunksPreview(response.data.chunks_preview || []); - - setUploadRows([{ file: null, prompt: '', audioUrl: '' }]); - - fetchSystemStatus(); - } catch (error) { - setProcessingStatus(`Error: ${error.response?.data?.error || error.message}`); - } finally { - setIsProcessing(false); - } - }; const fetchHyperparamSuggestion = async () => { setShowRationale(false); + if (!trainingProject) { + setSuggestionDialog({ + open: true, + data: { ok: false, error: "Pick a dataset project first." }, + loading: false, + }); + return; + } setSuggestionDialog({ open: true, data: null, loading: true }); try { - const resp = await api.get(`/api/training/suggest-hyperparams?mode=${trainingConfig.mode}`); + const url = `/api/training/suggest-hyperparams` + + `?project_name=${encodeURIComponent(trainingProject)}` + + `&base_model=${encodeURIComponent(trainingConfig.baseModel || '')}`; + const resp = await api.get(url); setSuggestionDialog({ open: true, data: resp.data, loading: false }); } catch (e) { setSuggestionDialog({ @@ -600,11 +795,25 @@ function App() { const applyHyperparamSuggestion = () => { const cfg = suggestionDialog.data?.config; if (!cfg) return; - setTrainingConfig({ ...trainingConfig, ...cfg }); + // Suggester returns include/exclude as arrays; the form edits them as + // space-separated strings. Backend's sa3_trainer accepts either. + const normalized = { + ...cfg, + include: Array.isArray(cfg.include) ? cfg.include.join(' ') : (cfg.include || ''), + exclude: Array.isArray(cfg.exclude) ? cfg.exclude.join(' ') : (cfg.exclude || ''), + }; + setTrainingConfig({ ...trainingConfig, ...normalized }); setSuggestionDialog({ open: false, data: null, loading: false }); }; - const startTraining = async () => { + // Confirm dialog for the same-name LoRA collision case. + const [overwriteConfirm, setOverwriteConfirm] = useState(null); + + const startTraining = async (overwrite = false) => { + // Defensive: an `onClick={startTraining}` would pass React's + // SyntheticEvent in as the first arg; coerce so it can never + // leak into the JSON payload as a circular DOM reference. + overwrite = overwrite === true; const selectedBaseModel = baseModels.find(m => m.name === trainingConfig.baseModel); if (!selectedBaseModel) { showModelWarning({ @@ -624,19 +833,33 @@ function App() { return; } + if (!trainingProject) { + showModelWarning({ + title: 'Dataset Required', + message: 'Pick a dataset project before starting training. ' + + 'Create one in the Dataset tab if you don\'t have any yet.', + canOpenModels: false, + }); + return; + } + setIsTraining(true); setTrainingProgress(0); setTrainingError(null); setTrainingStartTime(Date.now()); setTrainingHistory([]); - await api.post('/api/bulk-annotate/unload-clap').catch(() => {}); + await api.post('/api/clap/unload').catch(() => {}); try { - const { checkpointAuto, ...rest } = trainingConfig; + const { checkpointAuto, seedRandom, ...rest } = trainingConfig; const payload = { ...rest, + projectName: trainingProject, checkpointSteps: checkpointAuto ? null : trainingConfig.checkpointSteps, + // null = let the backend roll a fresh seed and record it. + seed: seedRandom ? null : trainingConfig.seed, + overwrite: overwrite, }; const response = await api.post('/api/start-training', payload); setProcessingStatus('Training started successfully!'); @@ -644,6 +867,19 @@ function App() { const errorData = error.response?.data; const errorMessage = errorData?.error || error.message; + // Same-name collision (HTTP 409) — surface a confirm dialog so the + // user can choose to overwrite the previous run rather than + // co-mingling its checkpoints. + if (error.response?.status === 409 && errorData?.code === 'run_exists') { + setIsTraining(false); + setOverwriteConfirm({ + runName: errorData.run_name, + checkpointCount: errorData.checkpoint_count, + message: errorData.message, + }); + return; + } + if (errorData?.checkpoint_warning) { setTrainingError(errorMessage); setProcessingStatus(errorMessage); @@ -677,36 +913,56 @@ function App() { const baseRequestData = { prompt: generationPrompt, duration: generationDuration, - cfg_scale: cfgScale, - steps: steps + steps: steps, }; + const negTrim = negativePrompt.trim(); + if (negTrim) { + baseRequestData.negative_prompt = negTrim; + } + + // LoRA stack — LoraStack is the single source of truth for the + // Generation panel. Empty slots (path === '') are filtered out + // so an unused slot doesn't break the request. + const activeLoras = (loraStack || []).filter(s => s.path); + if (activeLoras.length) { + // Bypassed slots stay in the stack (load order preserved) but + // contribute nothing — send strength 0. + baseRequestData.loras = activeLoras.map(s => ({ + path: s.path, + strength: s.bypassed ? 0 : s.strength, + })); + } + // SA3 post-trained models bake CFG at 1.0 — only the *-base variants + // honour cfg_scale. Sending it on a post-trained model is harmless + // (backend forces 1.0), but we only attach it for base variants so + // the UI matches what the backend will use. + if (!isDistilledBase) { + baseRequestData.cfg_scale = cfgScale; + } const baseModel = baseModels.find(m => m.name === selectedModel); if (baseModel) { if (!baseModel.downloaded) { showModelWarning({ - title: 'Base Model Not Downloaded', - message: `The selected base model "${baseModel.displayName}" is not downloaded.`, + title: 'Model Not Downloaded', + message: `"${baseModel.displayName}" hasn't been downloaded yet. Open the Checkpoint Manager to fetch it.`, canOpenModels: true, }); return; } - - baseRequestData.model_name = selectedModel; - } else if (selectedUnwrappedModel) { - baseRequestData.unwrapped_model_path = selectedUnwrappedModel; + baseRequestData.model_id = selectedModel; + } else if (selectedModel && selectedModel.startsWith('sa3-')) { + // Hidden SA3 variant (base or AE) reachable via /api/checkpoints?include=all. + baseRequestData.model_id = selectedModel; } else { - setProcessingStatus('Please select a model'); + setProcessingStatus( + selectedModel + ? `'${selectedModel}' is an SA2 fine-tune; SA3 cannot load it. Pick a Stable Audio 3 model.` + : 'Please select a model' + ); return; } - // LoRA only meaningful on top of a base model (the LoRA was trained - // against that exact base — applying it to a full-FT model is undefined). - if (selectedLora && baseModel) { - baseRequestData.lora_path = selectedLora; - baseRequestData.lora_multiplier = loraMultiplier; - } - const parsedSeed = parseInt(seedValue, 10); if (!randomSeed && (Number.isNaN(parsedSeed) || parsedSeed < 0)) { setProcessingStatus('Please enter a non-negative integer seed, or enable Random Seed'); @@ -715,7 +971,7 @@ function App() { const totalRuns = Math.max(1, Math.min(10, batchCount)); - await api.post('/api/bulk-annotate/unload-clap').catch(() => {}); + await api.post('/api/clap/unload').catch(() => {}); stopGenerationRef.current = false; const abortController = new AbortController(); @@ -724,14 +980,26 @@ function App() { setIsGenerating(true); setGenerationProgress(0); + // Real progress polling — the backend exposes /api/generation-progress + // which reflects the SA3 sampler's per-ODE-step callback. We poll at + // ~250ms; sampling is N steps total (8 for distilled, ~50 for base) + // so each step takes hundreds of ms to several seconds — finer polling + // is unnecessary. let progressInterval; const startProgressTicker = () => { - progressInterval = setInterval(() => { - setGenerationProgress(prev => { - if (prev >= 90) return prev; - return prev + Math.random() * 3; - }); - }, 1000); + progressInterval = setInterval(async () => { + try { + const r = await api.get('/api/generation-progress'); + const d = r.data || {}; + // Don't drop to 0 just because backend briefly reports + // idle between batch elements; clamp monotonic until + // we hand off to setGenerationProgress(100) on response. + const pct = Number(d.progress) || 0; + setGenerationProgress(prev => Math.max(prev, Math.min(95, pct))); + } catch { + /* poll failure is non-fatal — bar just freezes briefly */ + } + }, 250); }; const stopProgressTicker = () => { if (progressInterval) { @@ -780,9 +1048,15 @@ function App() { setGenerationProgress(100); const audioUrl = URL.createObjectURL(response.data); - const fragmentFilename = buildFragmentFilename( - generationPrompt, batchTimestamp, batchIndex, totalRuns - ); + // The backend is authoritative for the on-disk name (it writes + // the WAV + sidecar). Use the header it returns so reveal / + // delete / serve all hit the real file; only fall back to a + // locally-built name if the header is somehow missing. + const fragmentFilename = + response.headers?.['x-fragment-filename'] || + buildFragmentFilename( + generationPrompt, batchTimestamp, batchIndex, totalRuns + ); setGeneratedAudio(audioUrl); setGeneratedAudioBlob(response.data); @@ -795,15 +1069,20 @@ function App() { cfgScale, steps, seed: seedForRun, + modelId: selectedModel, batchIndex, batchTotal: totalRuns, audioUrl, audioBlob: response.data, filename: fragmentFilename, - timestamp: new Date().toLocaleString() + timestamp: new Date().toLocaleString(), + createdAt: Date.now(), }; - setGeneratedFragments(prev => [...prev, newFragment]); + setGeneratedFragments(prev => { + const next = [...prev, newFragment]; + return next.length > 100 ? next.slice(next.length - 100) : next; + }); completedRuns += 1; } @@ -852,40 +1131,6 @@ function App() { setProcessingStatus('Stopping generation…'); }; - const handleStartFresh = async () => { - setIsStartingFresh(true); - setShowStartFreshDialog(false); - - try { - const response = await api.post('/api/start-fresh'); - - setUploadRows([{ file: null, prompt: '', audioUrl: '' }]); - setProcessedCount(0); - setChunksPreview([]); - setGeneratedAudio(null); - setGeneratedAudioBlob(null); - setGeneratedFragments([]); - setProcessingStatus(''); - setGenerationPrompt(''); - setUploadKey(prev => prev + 1); - - // Wipe persisted performance session and force-remount the panel so - // its in-memory state resets to defaults along with localStorage. - // (MIDI mappings and other app preferences are intentionally kept.) - clearPerformanceSession(); - setPerformanceResetKey(prev => prev + 1); - - setProcessingStatus(response.data.message); - - fetchSystemStatus(); - - } catch (error) { - setProcessingStatus(`Start fresh error: ${error.response?.data?.error || error.message}`); - } finally { - setIsStartingFresh(false); - } - }; - const handleFreeGPUMemory = async () => { setIsFreeingGPU(true); setShowFreeGPUDialog(false); @@ -946,32 +1191,9 @@ function App() { }; const getSelectedModelDisplayName = () => { - console.log('=== GETTING DISPLAY NAME ==='); - console.log('selectedModel:', selectedModel); - console.log('selectedUnwrappedModel:', selectedUnwrappedModel); - - if (!selectedModel) { - console.log('No selectedModel, returning empty string'); - return ''; - } - + if (!selectedModel) return ''; const baseModel = baseModels.find(m => m.name === selectedModel); - if (baseModel) { - console.log('Found base model:', baseModel.displayName); - return baseModel.displayName; - } - - const model = availableModels.find(m => m.name === selectedModel); - if (model && selectedUnwrappedModel) { - const selectedUnwrapped = model.unwrapped_models?.find(u => u.path === selectedUnwrappedModel); - if (selectedUnwrapped) { - const displayName = `${model.name} (${selectedUnwrapped.name})`; - console.log('Generated fine-tuned display name:', displayName); - return displayName; - } - } - - console.log('Using fallback name:', selectedModel); + if (baseModel) return baseModel.displayName; return selectedModel; }; @@ -984,8 +1206,6 @@ function App() { const newSelectedModel = event.target.value; setSelectedModel(newSelectedModel); - setSelectedUnwrappedModel(''); - const selectedBaseModel = baseModels.find(m => m.name === newSelectedModel); if (selectedBaseModel && !selectedBaseModel.downloaded) { showModelWarning({ @@ -1011,7 +1231,7 @@ function App() { const handleOpenModelsFromWarning = () => { closeModelWarning(); - setAuthDialogOpen(true); + setCheckpointMgrOpen(true); }; const getTrainingIndicatorState = () => { @@ -1032,6 +1252,7 @@ function App() { return ( + - + {/* Logo */} @@ -1067,127 +1288,46 @@ function App() { - - - - - - - - + {gpuMemoryStatus && gpuMemoryStatus.cuda ? ( <> - - - GPU Memory + + + GPU - - 2 ? 'good' : gpuMemoryStatus.cuda.free > 0.5 ? 'low' : 'critical' - )} - /> - - {gpuMemoryStatus.cuda.free > 2 ? 'Good' : - gpuMemoryStatus.cuda.free > 0.5 ? 'Low' : 'Critical'} - - - - - - - - - - - - - - {gpuMemoryStatus.cuda.free.toFixed(1)}GB free - - - {gpuMemoryStatus.cuda.total.toFixed(1)}GB total + + {gpuMemoryStatus.cuda.free.toFixed(1)} / {gpuMemoryStatus.cuda.total.toFixed(0)} GB free + + + ) : ( - <> - - - GPU Status - - - - - No GPU - - - - - - No CUDA GPU detected + + + GPU - - Using CPU for processing + + Not detected · CPU mode - + )} - + {/* Main Content with Sidebar Layout */} - + {/* Left Sidebar with Vertical Tabs */} - + - } iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={isIconOnlySidebar ? undefined : 'Data Processing'} /> - } iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={isIconOnlySidebar ? undefined : 'Training'} /> - } iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={isIconOnlySidebar ? undefined : 'Generation'} /> + } iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={(isIconOnlySidebar || isMobileLayout) ? undefined : 'Dataset'} /> + } iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={(isIconOnlySidebar || isMobileLayout) ? undefined : 'Training'} /> + } iconPosition={isIconOnlySidebar ? 'top' : 'start'} label={(isIconOnlySidebar || isMobileLayout) ? undefined : 'Generation'} /> } iconPosition={isIconOnlySidebar ? 'top' : 'start'} - label={isIconOnlySidebar ? undefined : ( - - Performance - {}} - onClick={(e) => { e.stopPropagation(); togglePerformance(); }} - sx={{ transform: 'scale(0.75)' }} - /> - - )} - sx={{ opacity: performanceEnabled ? 1 : 0.5, transition: 'opacity 0.2s' }} + label={(isIconOnlySidebar || isMobileLayout) ? undefined : 'Performance'} /> {/* Main Content Area */} - - - {/* Data Processing Tab */} - - - - - - - - Manual Annotation - - - Upload audio files one by one and annotate them yourself. - Use this when you want full control over every annotation. - - - {uploadRows.map((row, index) => ( - - ))} - - - - - - - - - - - - - - - {processingStatus && ( - - {processingStatus} - - )} - - {!systemStatus && ( - - - - - - Dataset Status - - - - - Scanning dataset… - - - - )} - - {systemStatus && ( - - - - - - Dataset Status - {isStatusLoading && ( - - )} - - Raw Files: {systemStatus.raw_files} - - Total Duration: {formatDuration(systemStatus.total_duration || 0)} - - - Custom Metadata: {systemStatus.has_metadata_json ? 'Yes' : 'Not Found'} - - {systemStatus.raw_file_names && systemStatus.raw_file_names.length > 0 && ( - - - Recent files: {systemStatus.raw_file_names.join(', ')} - - - )} - - )} + + - - + {/* Dataset Tab */} + + setCheckpointMgrOpen(true)} /> {/* Training Tab */} - + @@ -1343,34 +1377,6 @@ function App() { Training Configuration - - - Training mode - - { - if (newMode !== null) { - setTrainingConfig({ ...trainingConfig, mode: newMode }); - } - }} - fullWidth - > - - - LoRA Adapter - - - - - Full Fine-tune - - - - - - - }> - Advanced Settings - - - - - Epochs - - + + Dataset + + {trainingProjects.length === 0 ? ( + + No projects yet — create one in the Dataset tab. + + ) : ( + + )} + + {/* Phase 6 — pre-encode latents button. State machine: + no latents → "Pre-encode latents · N clips" (clickable, outlined) + running → "Encoding… X / Y" (disabled, with Stop button) + present → "✓ Pre-encoded · N latents" (disabled, outlined, green tint). */} + {trainingProject && (() => { + const job = trainingPreEncode.job; + const inFlight = job && (job.state === 'queued' || job.state === 'running'); + const ready = trainingPreEncode.latents_present && !inFlight; + const project = trainingProjects.find(p => p.name === trainingProject); + const clipCount = project?.clip_count ?? 0; + let label = `Pre-encode latents · ${clipCount} clip${clipCount === 1 ? '' : 's'}`; + if (inFlight) { + label = job.total > 0 + ? `Encoding… ${job.current} / ${job.total}` + : 'Encoding…'; + } else if (ready) { + label = `Pre-encoded · ${trainingPreEncode.latents_count} latent${trainingPreEncode.latents_count === 1 ? '' : 's'}`; + } + return ( + + + {inFlight && ( + + )} + + ); + })()} + + + + + Base model to fine-tune + + + + + + }> + Advanced Settings + + + + + + + Training Steps + + setTrainingConfig({ ...trainingConfig, - epochs: value + steps: value })} - min={1} - max={1000} + min={500} + max={20000} + step={500} + marks={[ + { value: 1000, label: '1k' }, + { value: 5000, label: '5k' }, + { value: 10000, label: '10k' }, + { value: 20000, label: '20k' }, + ]} valueLabelDisplay="auto" sx={appStyles.sliderFlexGrow} /> { - const val = parseInt(e.target.value) || 1; + const val = parseInt(e.target.value) || 500; setTrainingConfig({ ...trainingConfig, - epochs: Math.max(1, Math.min(1000, val)) + steps: Math.max(500, Math.min(20000, val)) }); }} - inputProps={{ min: 1, max: 1000, step: 1 }} + inputProps={{ min: 500, max: 20000, step: 100 }} sx={appStyles.sliderInputSmall} size="small" /> + + + + + Adapter Type + + + + + + + + Checkpoint Interval (steps) )} + + - Learning Rate + + + Learning Rate + + - Batch Size + + + Batch Size @@ -1542,21 +1765,22 @@ function App() { const val = parseInt(e.target.value, 10) || 1; setTrainingConfig({ ...trainingConfig, - batchSize: Math.max(1, Math.min(32, val)) + batchSize: Math.max(1, Math.min(8, val)) }); }} - inputProps={{ min: 1, max: 32, step: 1 }} + inputProps={{ min: 1, max: 8, step: 1 }} sx={appStyles.sliderInputSmall} size="small" /> - - Lower this if you hit CUDA out-of-memory; raise it for faster training on large GPUs. - + + - Precision + + + Base-model Precision - - Auto picks bf16-mixed on modern CUDA, 16-mixed on older cards, fp32 on CPU/MPS. - + + - {trainingConfig.mode === 'lora' && ( - - - LoRA settings - + + + LoRA settings + - Rank + + + Rank - - Higher rank = more capacity but more VRAM. r=16 fits comfortably on 16 GB. - - - Alpha + + + + + Alpha - - Scaling factor for the LoRA update. Conventional choice: alpha = rank. - - - Dropout + + + + + Dropout - - Regularization for the LoRA layers. 0 is fine for most cases; raise if overfitting on small datasets. - - - )} + + + + + + + Seed + setTrainingConfig({ + ...trainingConfig, + seedRandom: e.target.checked, + })} + /> + } + label="Random" + labelPlacement="start" + /> + + { + const v = parseInt(e.target.value, 10); + setTrainingConfig({ + ...trainingConfig, + seed: Number.isFinite(v) ? v : 42, + }); + }} + inputProps={{ min: 0, step: 1 }} + /> + + + + + + Training Window (seconds) + + setTrainingConfig({ + ...trainingConfig, + duration: value, + })} + min={5} + max={(trainingConfig.baseModel || '').includes('medium') ? 380 : 120} + step={1} + marks={[{ value: 30, label: '30s' }]} + valueLabelDisplay="auto" + sx={appStyles.sliderFlexGrow} + /> + { + const cap = (trainingConfig.baseModel || '').includes('medium') ? 380 : 120; + const v = Math.max(5, Math.min(cap, parseFloat(e.target.value) || 30)); + setTrainingConfig({ ...trainingConfig, duration: v }); + }} + inputProps={{ min: 5, max: (trainingConfig.baseModel || '').includes('medium') ? 380 : 120, step: 1 }} + sx={appStyles.sliderInputSmall} + size="small" + /> + + + + {/* include/exclude layer targeting is intentionally not + exposed — the default (transformer.layers / exclude + seconds_total to_local_embed) is SA3's documented + small-dataset-safe filter; a wrong value silently + degrades training. Still sent from trainingConfig. */} + @@ -1686,8 +1983,8 @@ function App() { - - - - {/* Free GPU Memory Confirmation Dialog */} - - {colorMode === 'light' ? : } - - - setShowInfoDialog(true)} - sx={appStyles.infoButton} + { if (reason !== 'clickaway') setProcessingStatus(''); }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > - - - - setShowInfoDialog(false)} - aria-labelledby="about-documentation-dialog-title" - maxWidth="sm" - fullWidth - > - - - setProcessingStatus('')} + severity={ + /error|failed/i.test(processingStatus) ? 'error' + : /completed|success/i.test(processingStatus) ? 'success' + : 'info' + } + variant="filled" + sx={{ minWidth: 280, boxShadow: 6 }} + > + {processingStatus} + + + + {isDockCollapsed ? ( + <> + setDockMenuAnchor(e.currentTarget)} + sx={appStyles.dockHamburger} + > + + + setDockMenuAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + { setDockMenuAnchor(null); setCheckpointMgrOpen(true); }} + > + + Get Models + + { setDockMenuAnchor(null); handleOpenOutputFolder(); }} + > + + Outputs + + { setDockMenuAnchor(null); setShowFreeGPUDialog(true); }} + disabled={isFreeingGPU || !(gpuMemoryStatus && gpuMemoryStatus.cuda)} + > + + {isFreeingGPU ? : } + + {isFreeingGPU ? 'Freeing…' : 'Free GPU'} + + + { setDockMenuAnchor(null); toggleColorMode(); }} + > + + {colorMode === 'light' ? : } + + {colorMode === 'light' ? 'Dark Mode' : 'Light Mode'} + + { setDockMenuAnchor(null); setShowInfoDialog(true); }} + > + + About + + + + ) : ( + ({ + position: 'fixed', + left: { xs: theme.spacing(1.5), sm: theme.spacing(2), md: theme.spacing(3) }, + bottom: { xs: theme.spacing(7), sm: theme.spacing(9), md: theme.spacing(12) }, + zIndex: 1350, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 0.75, + })} + > + {/* Small Info View toggle, sitting above the dock card. */} + ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 0.4, + px: 0.5, + py: 0.25, + m: 0, border: 'none', - boxShadow: 'none', - filter: 'none', - }} /> - - Fragmenta - + background: 'transparent', + cursor: 'pointer', + borderRadius: 999, + fontFamily: 'inherit', + fontSize: '0.6rem', + lineHeight: 1, + letterSpacing: '0.02em', + color: infoViewEnabled ? theme.palette.primary.main : theme.palette.text.disabled, + opacity: infoViewEnabled ? 0.9 : 0.55, + transition: 'color 160ms ease, opacity 160ms ease', + '&:hover': { + opacity: 1, + color: infoViewEnabled ? theme.palette.primary.light : theme.palette.text.secondary, + }, + })} + > + + Info - - - - Fragmenta is an open source, local-first pipeline to fine-tune, LoRA, train, generate and perform with text-to-audio diffusion models. - Made by the composer and researcher Misagh Azimi. - - - Resources - - - - - - + {isFreeingGPU ? : } + + + {isFreeingGPU ? 'Freeing…' : 'Free GPU'} + - - - Powered by Stability AI —{' '} - - Stable Audio Open - {' '} - models, governed by the{' '} - - Stability AI Community License - . + + + {colorMode === 'light' ? : } + + + {colorMode === 'light' ? 'Dark Mode' : 'Light Mode'} - - "This Stability AI Model is licensed under the Stability AI Community License,{' '} - Copyright © Stability AI Ltd. All Rights Reserved" + + + + setShowInfoDialog(true)} + sx={appStyles.dockIconButton} + > + + + + About - - - - - + + + )} + + setShowInfoDialog(false)} + onOpenDocumentation={handleOpenDocumentation} + isOpeningDocumentation={isOpeningDocumentation} + /> )} {!suggestionDialog.loading && suggestionDialog.data?.ok && (() => { - const { stats, config, rationale } = suggestionDialog.data; + const { stats, config, rationale, warnings } = suggestionDialog.data; + const includeStr = (config.include || []).join(', ') || '(all layers)'; + const excludeStr = (config.exclude || []).join(', ') || '(none)'; return ( {stats.file_count} files · {stats.duration_human} - {stats.vram_gb ? ` · GPU ${stats.vram_gb} GB` : ''} + {stats.median_clip_sec ? ` · median ${stats.median_clip_sec.toFixed(1)}s` : ''} + {stats.vram_gb ? ` · GPU ${stats.vram_gb} GB` : ' · no GPU'} + {(warnings || []).map((w, i) => ( + + {w} + + ))} + + Steps + {config.steps.toLocaleString()} Batch size {config.batchSize} Learning rate {config.learningRate} - Epochs - {config.epochs} - {trainingConfig.mode === 'lora' && ( - <> - LoRA rank / alpha - - {config.loraRank} / {config.loraAlpha} - - - )} - Total steps - {stats.total_steps} + Training window + {config.duration.toFixed(0)}s + Adapter · rank / α + + {config.adapterType} · {config.loraRank} / {config.loraAlpha} + + Dropout · precision + + {config.loraDropout} · {config.precision} + + Include layers + {includeStr} + Exclude layers + {excludeStr} + Checkpoint every + {config.checkpointSteps.toLocaleString()} steps - { - setAuthDialogOpen(false); - if (success) { - refreshAllModels(); - } + setOverwriteConfirm(null)} + maxWidth="xs" + fullWidth + > + Overwrite existing run? + + + {overwriteConfirm?.message} + + + The previous run dir for {overwriteConfirm?.runName} will + be deleted, including {overwriteConfirm?.checkpointCount} checkpoint(s), + training.log, metrics.csv and any Lightning logs. This cannot be undone. + + + + + + + + + { + setCheckpointMgrOpen(false); + refreshAllModels(); }} /> + ); }