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() {
-
- }
- onClick={() => setAuthDialogOpen(true)}
- sx={appStyles.headerActionButton}
- >
- Get Models
-
- }
- onClick={() => setShowFreeGPUDialog(true)}
- disabled={isFreeingGPU || !(gpuMemoryStatus && gpuMemoryStatus.cuda)}
- sx={appStyles.headerActionButtonWithOpacity(Boolean(gpuMemoryStatus && gpuMemoryStatus.cuda))}
- >
- {isFreeingGPU ? 'Freeing...' : 'Free GPU'}
-
- }
- onClick={handleOpenOutputFolder}
- sx={appStyles.headerActionButton}
- >
- Outputs
-
- }
- onClick={() => setShowStartFreshDialog(true)}
- disabled={isStartingFresh}
- sx={appStyles.headerActionButton}
- >
- {isStartingFresh ? 'Starting...' : 'Fresh Start'}
-
-
-
-
+
{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) => (
-
- ))}
-
- }
- onClick={addUploadRow}
- sx={appStyles.addRowButton}
- >
- Add Another Row
-
-
- : }
- fullWidth
- >
- {isProcessing ? 'Saving…' : 'Save to dataset'}
-
-
-
-
-
-
-
-
-
-
-
- {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 (
+
+ : null}
+ sx={{
+ justifyContent: 'center',
+ textTransform: 'none',
+ // Make the "done" state visibly disabled (gray border /
+ // muted text) while still showing the success-green
+ // checkmark so the user can read the status at a glance.
+ ...(ready ? {
+ '&.Mui-disabled': {
+ color: 'text.disabled',
+ borderColor: 'divider',
+ '& .MuiButton-startIcon': {
+ color: 'success.main',
+ opacity: 0.8,
+ },
+ },
+ } : {}),
+ }}
+ >
+ {label}
+
+ {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() {
- }>
- setPerformanceResetKey(prev => prev + 1)}
- />
-
- ) : (
-
-
-
- Performance mode is turned off. Toggle on from the sidebar if you wish to enter performance mode.
-
-
- )}
+
+ setCheckpointMgrOpen(true)}
+ />
-
+
+
- {/* Start Fresh Confirmation Dialog */}
-
-
{/* Free GPU Memory Confirmation Dialog */}