diff --git a/CHANGELOG.md b/CHANGELOG.md index 9056c239..00bcf160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ ## [Unreleased] +This release rewrites the backend into a modular architecture, migrates the documentation site to Fumadocs, and ships a batch of bug fixes and UI polish across the stack. + +The backend's 3,000-line monolith `main.py` has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, model loading status is now visible in the UI, effects presets get a dropdown, and several race conditions and accessibility gaps are closed. + +### Backend Refactor ([#285](https://github.com/jamiepine/voicebox/pull/285)) +- Extracted all routes from `main.py` into 13 domain routers under `backend/routes/` — `main.py` dropped from ~3,100 lines to ~10 +- Moved CRUD and service modules into `backend/services/`, platform detection into `backend/utils/` +- Split monolithic `database.py` into a `database/` package with separate `models`, `session`, `migrations`, and `seed` modules +- Added `backend/STYLE_GUIDE.md` and `pyproject.toml` with ruff linting config +- Removed dead code: unused `_get_cuda_dll_excludes`, stale `studio.py`, `example_usage.py`, old `Makefile` +- Deduplicated shared logic across TTS backends into `backends/base.py` +- Improved startup logging with version, platform, data directory, and database stats +- Fixed startup database session leak — sessions now rollback and close in `finally` block +- Isolated shutdown unload calls so one backend failure doesn't block the others +- Handled null duration in `story_items` migration +- Reject model migration when target is a subdirectory of source cache + +### Documentation Rewrite ([#288](https://github.com/jamiepine/voicebox/pull/288)) +- Migrated docs site from Mintlify to Fumadocs (Next.js-based) +- Rewrote introduction and root page with content from README +- Added "Edit on GitHub" links and last-updated timestamps on all pages +- Generated OpenAPI spec and auto-generated API reference pages +- Removed stale planning docs (`CUDA_BACKEND_SWAP`, `EXTERNAL_PROVIDERS`, `MLX_AUDIO`, `TTS_PROVIDER_ARCHITECTURE`, etc.) +- Sidebar groups now expand by default; root redirects to `/docs` +- Added OG image metadata and `/og` preview page + +### UI & Frontend +- Added model loading status indicator and effects preset dropdown ([3187344](https://github.com/jamiepine/voicebox/commit/3187344)) +- Fixed take-label race condition during regeneration +- Added accessible focus styling to select component +- Softened select focus indicator opacity +- Addressed 4 critical and 12 major issues from CodeRabbit review + +### Platform Fixes +- Replaced `netstat` with `TcpStream` + PowerShell for Windows port detection ([#277](https://github.com/jamiepine/voicebox/pull/277)) +- Fixed Docker frontend build and cleaned up Docker docs +- Fixed macOS download links to use `.dmg` instead of `.app.tar.gz` +- Added dynamic download redirect routes to landing site + +### Release Tooling +- Added `draft-release-notes` and `release-bump` agent skills +- Wired CI release workflow to extract notes from `CHANGELOG.md` for GitHub Releases +- Backfilled changelog with all historical releases + ## [0.2.3] - 2026-03-15 The "it works in dev but not in prod" release. This version fixes a series of PyInstaller bundling issues that prevented model downloading, loading, generation, and progress tracking from working in production builds. diff --git a/app/plugins/changelog.ts b/app/plugins/changelog.ts new file mode 100644 index 00000000..d131fe53 --- /dev/null +++ b/app/plugins/changelog.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { Plugin } from 'vite'; + +/** Vite plugin that exposes CHANGELOG.md as `virtual:changelog`. */ +export function changelogPlugin(repoRoot: string): Plugin { + const virtualId = 'virtual:changelog'; + const resolvedId = '\0' + virtualId; + const changelogPath = path.resolve(repoRoot, 'CHANGELOG.md'); + + return { + name: 'changelog', + resolveId(id) { + if (id === virtualId) return resolvedId; + }, + load(id) { + if (id === resolvedId) { + const raw = readFileSync(changelogPath, 'utf-8'); + return `export default ${JSON.stringify(raw)};`; + } + }, + }; +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 458686c8..b6964db1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,6 +8,7 @@ import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { cn } from '@/lib/utils/cn'; import { usePlatform } from '@/platform/PlatformContext'; import { router } from '@/router'; +import { useLogStore } from '@/stores/logStore'; import { useServerStore } from '@/stores/serverStore'; const LOADING_MESSAGES = [ @@ -63,6 +64,14 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [platform.lifecycle]); + // Subscribe to server logs + useEffect(() => { + const unsubscribe = platform.lifecycle.subscribeToServerLogs((entry) => { + useLogStore.getState().addEntry(entry); + }); + return unsubscribe; + }, [platform.lifecycle]); + // Setup window close handler and auto-start server when running in Tauri (production only) useEffect(() => { if (!platform.metadata.isTauri) { diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index b37817df..e7b87e0c 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -15,7 +15,7 @@ import { Wand2, } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -import Loader from 'react-loaders'; + import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; import { Button } from '@/components/ui/button'; import { @@ -56,8 +56,35 @@ import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format import { useGenerationStore } from '@/stores/generationStore'; import { usePlayerStore } from '@/stores/playerStore'; -// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) -// This is the new alternate history view with fixed height rows +// ─── Audio Bars ───────────────────────────────────────────────────────────── + +function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) { + const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40'; + return ( +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ ); +} // NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL export function HistoryTable() { @@ -446,12 +473,9 @@ export function HistoryTable() { > {/* Status icon */}
-
- -
+
{/* Left side - Meta information */} diff --git a/app/src/components/ServerTab/AboutPage.tsx b/app/src/components/ServerTab/AboutPage.tsx new file mode 100644 index 00000000..29c27814 --- /dev/null +++ b/app/src/components/ServerTab/AboutPage.tsx @@ -0,0 +1,135 @@ +import { ArrowUpRight } from 'lucide-react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import voiceboxLogo from '@/assets/voicebox-logo.png'; +import { usePlatform } from '@/platform/PlatformContext'; + +function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function AboutPage() { + const platform = usePlatform(); + const [version, setVersion] = useState(''); + + useEffect(() => { + platform.metadata + .getVersion() + .then(setVersion) + .catch(() => setVersion('')); + }, [platform]); + + return ( + <> + +
+
+ + Voicebox + + + +
+

Voicebox

+

+ {version ? `v${version}` : '\u00A0'} +

+
+
+ + +

+ The open-source voice synthesis studio. Clone voices, generate speech, apply effects, + and build voice-powered apps — all running locally on your machine. +

+
+ + +
+ Created by + + Jamie Pine + +
+
+ + +
+ + + Buy me a coffee + + + + + GitHub + + +
+
+ + +

+ Licensed under{' '} + + MIT + +

+
+
+
+ + ); +} diff --git a/app/src/components/ServerTab/ChangelogPage.tsx b/app/src/components/ServerTab/ChangelogPage.tsx new file mode 100644 index 00000000..afb32b47 --- /dev/null +++ b/app/src/components/ServerTab/ChangelogPage.tsx @@ -0,0 +1,220 @@ +import changelogRaw from 'virtual:changelog'; +import { useMemo, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog'; + +function renderMarkdown(md: string): React.ReactNode[] { + const lines = md.split('\n'); + const elements: React.ReactNode[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Skip empty lines + if (line.trim() === '') { + i++; + continue; + } + + // Tables — collect all lines starting with | + if (line.trim().startsWith('|')) { + const tableLines: string[] = []; + while (i < lines.length && lines[i].trim().startsWith('|')) { + tableLines.push(lines[i]); + i++; + } + elements.push(renderTable(tableLines, elements.length)); + continue; + } + + // Headings + if (line.startsWith('#### ')) { + elements.push( +
+ {inlineMarkdown(line.slice(5))} +
, + ); + i++; + continue; + } + if (line.startsWith('### ')) { + elements.push( +

+ {inlineMarkdown(line.slice(4))} +

, + ); + i++; + continue; + } + + // List items — collect consecutive + if (line.startsWith('- ')) { + const items: string[] = []; + while (i < lines.length && lines[i].startsWith('- ')) { + items.push(lines[i].slice(2)); + i++; + } + elements.push( + , + ); + continue; + } + + // Paragraph + elements.push( +

+ {inlineMarkdown(line)} +

, + ); + i++; + } + + return elements; +} + +function renderTable(tableLines: string[], keyBase: number): React.ReactNode { + const parseRow = (line: string) => + line + .split('|') + .slice(1, -1) + .map((c) => c.trim()); + + const headers = parseRow(tableLines[0]); + // Skip separator line (index 1) + const rows = tableLines.slice(2).map(parseRow); + + return ( +
+ + + + {headers.map((h, hIdx) => ( + + ))} + + + + {rows.map((row, rowIdx) => ( + + {row.map((cell, cellIdx) => ( + + ))} + + ))} + +
+ {inlineMarkdown(h)} +
+ {inlineMarkdown(cell)} +
+
+ ); +} + +function inlineMarkdown(text: string): React.ReactNode { + // Process inline markdown: bold, code, links + const parts: React.ReactNode[] = []; + // Regex matches: **bold**, `code`, [text](url) + const inlineRe = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g; + let lastIndex = 0; + let match: RegExpExecArray | null = inlineRe.exec(text); + + while (match !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + if (match[1] !== undefined) { + // Bold + parts.push( + + {match[1]} + , + ); + } else if (match[2] !== undefined) { + // Code + parts.push( + + {match[2]} + , + ); + } else if (match[3] !== undefined && match[4] !== undefined) { + // Link + parts.push( + + {match[3]} + , + ); + } + + lastIndex = match.index + match[0].length; + match = inlineRe.exec(text); + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length === 1 ? parts[0] : parts; +} + +function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) { + const [expanded, setExpanded] = useState(false); + const content = useMemo(() => renderMarkdown(entry.body), [entry.body]); + const isLong = entry.body.split('\n').length > 12; + + return ( +
+
+

{entry.version}

+ {entry.date && {entry.date}} + {entry.version === 'Unreleased' && dev} +
+ +
+ {content} + {isLong && !expanded && ( +
+ )} +
+ + {isLong && ( + + )} +
+ ); +} + +export function ChangelogPage() { + const entries = useMemo(() => parseChangelog(changelogRaw), []); + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} diff --git a/app/src/components/ServerTab/GeneralPage.tsx b/app/src/components/ServerTab/GeneralPage.tsx new file mode 100644 index 00000000..ccf117b1 --- /dev/null +++ b/app/src/components/ServerTab/GeneralPage.tsx @@ -0,0 +1,379 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Progress } from '@/components/ui/progress'; +import { Toggle } from '@/components/ui/toggle'; +import { useToast } from '@/components/ui/use-toast'; +import { useAutoUpdater } from '@/hooks/useAutoUpdater'; +import { useServerHealth } from '@/lib/hooks/useServer'; +import { usePlatform } from '@/platform/PlatformContext'; +import { useServerStore } from '@/stores/serverStore'; +import { SettingRow, SettingSection } from './SettingRow'; + +const connectionSchema = z.object({ + serverUrl: z.string().url('Please enter a valid URL'), +}); + +type ConnectionFormValues = z.infer; + +export function GeneralPage() { + const platform = usePlatform(); + const serverUrl = useServerStore((state) => state.serverUrl); + const setServerUrl = useServerStore((state) => state.setServerUrl); + const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose); + const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose); + const mode = useServerStore((state) => state.mode); + const setMode = useServerStore((state) => state.setMode); + const { toast } = useToast(); + const { data: health, isLoading, error: healthError } = useServerHealth(); + + const form = useForm({ + resolver: zodResolver(connectionSchema), + defaultValues: { serverUrl }, + }); + + useEffect(() => { + form.reset({ serverUrl }); + }, [serverUrl, form]); + + const { isDirty } = form.formState; + + function onSubmit(data: ConnectionFormValues) { + setServerUrl(data.serverUrl); + form.reset(data); + toast({ + title: 'Server URL updated', + description: `Connected to ${data.serverUrl}`, + }); + } + + return ( +
+
+ + +
+
Read the Docs
+
docs.voicebox.sh
+
+ +
+ + +
+
Join the Discord
+
Get help & share voices
+
+ +
+
+ + + + } + > +
+ + ( + + + + + + + )} + /> + {isDirty && ( + + )} + + +
+ + { + setKeepServerRunningOnClose(checked); + platform.lifecycle.setKeepServerRunning(checked).catch((error) => { + console.error('Failed to sync setting to Rust:', error); + setKeepServerRunningOnClose(!checked); + toast({ + title: 'Failed to update setting', + description: 'Could not sync setting to backend.', + variant: 'destructive', + }); + return; + }); + toast({ + title: 'Setting updated', + description: checked + ? 'Server will continue running when app closes' + : 'Server will stop when app closes', + }); + }} + /> + } + /> + + {platform.metadata.isTauri && ( + { + setMode(checked ? 'remote' : 'local'); + toast({ + title: 'Setting updated', + description: checked + ? 'Network access enabled. Restart the app to apply.' + : 'Network access disabled. Restart the app to apply.', + }); + }} + /> + } + /> + )} +
+ + + + {platform.metadata.isTauri && } +
+ ); +} + +function ConnectionStatus({ + health, + isLoading, + healthError, +}: { + health: ReturnType['data']; + isLoading: boolean; + healthError: ReturnType['error']; +}) { + if (isLoading) { + return ( +
+ + Connecting +
+ ); + } + if (healthError) { + return ( +
+ + + + + Offline +
+ ); + } + if (health) { + return ( +
+ + + + + Online +
+ ); + } + return null; +} + +function UpdatesSection() { + const platform = usePlatform(); + const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false); + const [currentVersion, setCurrentVersion] = useState(''); + const isDev = !import.meta.env?.PROD; + + useEffect(() => { + platform.metadata + .getVersion() + .then(setCurrentVersion) + .catch(() => setCurrentVersion('Unknown')); + }, [platform]); + + return ( + + {isDev ? ( + + ) : ( + <> + + + Check + + } + /> + + {status.error && ( + +
+ + {status.error} +
+
+ )} + + {status.available && !status.downloading && !status.readyToInstall && ( + + + Download + + } + /> + )} + + {status.downloading && ( + +
+ +
+ {status.downloadedBytes !== undefined && + status.totalBytes !== undefined && + status.totalBytes > 0 ? ( + + {(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '} + {(status.totalBytes / 1024 / 1024).toFixed(1)} MB + + ) : ( + + )} + {status.downloadProgress !== undefined && {status.downloadProgress}%} +
+
+
+ )} + + {status.readyToInstall && ( + + + Restart Now + + } + /> + )} + + )} +
+ ); +} + +const API_ENDPOINTS = [ + { method: 'POST', path: '/generate', label: 'Generate speech' }, + { method: 'GET', path: '/health', label: 'Server status' }, + { method: 'GET', path: '/profiles', label: 'List voices' }, + { method: 'GET', path: '/history', label: 'Past generations' }, +]; + +function ApiReferenceCard({ serverUrl }: { serverUrl: string }) { + return ( +
+
+

API Access

+

+ Integrate Voicebox into your workflow via the REST API at{' '} + {serverUrl} +

+
+
+ {API_ENDPOINTS.map((ep) => ( +
+ + {ep.method} + + {ep.path} + {ep.label} +
+ ))} +
+

+ + View the full API reference + +

+
+ ); +} diff --git a/app/src/components/ServerTab/GenerationPage.tsx b/app/src/components/ServerTab/GenerationPage.tsx new file mode 100644 index 00000000..4bb162a2 --- /dev/null +++ b/app/src/components/ServerTab/GenerationPage.tsx @@ -0,0 +1,138 @@ +import { FolderOpen } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Slider } from '@/components/ui/slider'; +import { Toggle } from '@/components/ui/toggle'; +import { usePlatform } from '@/platform/PlatformContext'; +import { useServerStore } from '@/stores/serverStore'; +import { SettingRow, SettingSection } from './SettingRow'; + +export function GenerationPage() { + const platform = usePlatform(); + const serverUrl = useServerStore((state) => state.serverUrl); + const maxChunkChars = useServerStore((state) => state.maxChunkChars); + const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars); + const crossfadeMs = useServerStore((state) => state.crossfadeMs); + const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs); + const normalizeAudio = useServerStore((state) => state.normalizeAudio); + const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio); + const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate); + const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate); + const [opening, setOpening] = useState(false); + const [generationsPath, setGenerationsPath] = useState(null); + + useEffect(() => { + fetch(`${serverUrl}/health/filesystem`) + .then((res) => res.json()) + .then((data) => { + const genDir = data.directories?.find((d: { path: string }) => + d.path.includes('generations'), + ); + if (genDir?.path) setGenerationsPath(genDir.path); + }) + .catch(() => {}); + }, [serverUrl]); + + const openGenerationsFolder = useCallback(async () => { + if (!generationsPath) return; + setOpening(true); + try { + await platform.filesystem.openPath(generationsPath); + } catch (e) { + console.error('Failed to open generations folder:', e); + } finally { + setOpening(false); + } + }, [platform, generationsPath]); + + return ( +
+ + + {maxChunkChars} chars + + } + > + setMaxChunkChars(value)} + min={100} + max={5000} + step={50} + aria-label="Auto-chunking character limit" + /> + + + + {crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`} + + } + > + setCrossfadeMs(value)} + min={0} + max={200} + step={10} + aria-label="Chunk crossfade duration" + /> + + + + } + /> + + + } + /> + + + + Open + + } + /> + +
+ ); +} diff --git a/app/src/components/ServerTab/GpuPage.tsx b/app/src/components/ServerTab/GpuPage.tsx new file mode 100644 index 00000000..0ebe1d37 --- /dev/null +++ b/app/src/components/ServerTab/GpuPage.tsx @@ -0,0 +1,405 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { apiClient } from '@/lib/api/client'; +import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types'; +import { useServerHealth } from '@/lib/hooks/useServer'; +import { usePlatform } from '@/platform/PlatformContext'; +import { useServerStore } from '@/stores/serverStore'; +import { SettingRow, SettingSection } from './SettingRow'; + +type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready'; + +function AppleLogo({ className }: { className?: string }) { + return ( + + ); +} + +function GpuIcon({ className }: { className?: string }) { + return ( + + ); +} + +function GpuInfoCard({ health }: { health: HealthResponse }) { + const hasGpu = health.gpu_available && health.gpu_type; + + // Parse GPU name from type string like "CUDA (NVIDIA RTX 4090)" or "MPS (Apple M2 Pro)" + const gpuName = hasGpu + ? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') || + health.gpu_type! + : null; + const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null; + const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal'; + const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu'; + + return ( +
+
+ {hasGpu ? ( + isApple ? ( + + ) : ( + + ) + ) : ( + + )} +
+
{hasGpu ? gpuName : 'CPU Only'}
+
+ {hasGpu ? ( + <> + {gpuBackend} + {showBackendVariant && ( + <> + | + {health.backend_variant} + + )} + {health.vram_used_mb != null && health.vram_used_mb > 0 && ( + <> + | + {health.vram_used_mb.toFixed(0)} MB VRAM + + )} + + ) : ( + No GPU acceleration detected + )} +
+
+ {hasGpu && ( +
+ + + + + Active +
+ )} +
+
+ ); +} + +export function GpuPage() { + const platform = usePlatform(); + const queryClient = useQueryClient(); + const serverUrl = useServerStore((state) => state.serverUrl); + const { data: health } = useServerHealth(); + + const [restartPhase, setRestartPhase] = useState('idle'); + const [error, setError] = useState(null); + const [downloadProgress, setDownloadProgress] = useState(null); + const healthPollRef = useRef | null>(null); + + const { + data: cudaStatus, + isLoading: _cudaStatusLoading, + refetch: refetchCudaStatus, + } = useQuery({ + queryKey: ['cuda-status', serverUrl], + queryFn: () => apiClient.getCudaStatus(), + refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000), + retry: 1, + enabled: !!health, + }); + + const isCurrentlyCuda = health?.backend_variant === 'cuda'; + const cudaAvailable = cudaStatus?.available ?? false; + const cudaDownloading = cudaStatus?.downloading ?? false; + + useEffect(() => { + return () => { + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + }; + }, []); + + useEffect(() => { + if (!cudaDownloading || !serverUrl) return; + + const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as CudaDownloadProgress; + setDownloadProgress(data); + + if (data.status === 'complete') { + eventSource.close(); + setDownloadProgress(null); + refetchCudaStatus(); + } else if (data.status === 'error') { + eventSource.close(); + setError(data.error || 'Download failed'); + setDownloadProgress(null); + refetchCudaStatus(); + } + } catch (e) { + console.error('Error parsing CUDA progress event:', e); + } + }; + + eventSource.onerror = () => { + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, [cudaDownloading, serverUrl, refetchCudaStatus]); + + const clearHealthPolling = useCallback(() => { + if (healthPollRef.current) { + clearInterval(healthPollRef.current); + healthPollRef.current = null; + } + }, []); + + const startHealthPolling = useCallback(() => { + clearHealthPolling(); + + healthPollRef.current = setInterval(async () => { + try { + const result = await apiClient.getHealth(); + if (result.status === 'healthy') { + clearHealthPolling(); + setRestartPhase('ready'); + queryClient.invalidateQueries(); + setTimeout(() => setRestartPhase('idle'), 2000); + } + } catch { + // Server still down, keep polling + } + }, 1000); + }, [queryClient, clearHealthPolling]); + + const restartServerWithPolling = useCallback( + async (errorMessage: string) => { + setRestartPhase('stopping'); + try { + await platform.lifecycle.restartServer(); + setRestartPhase('waiting'); + startHealthPolling(); + } catch (e: unknown) { + clearHealthPolling(); + setRestartPhase('idle'); + throw new Error(e instanceof Error ? e.message : errorMessage); + } + }, + [platform, startHealthPolling, clearHealthPolling], + ); + + const handleDownload = async () => { + setError(null); + try { + await apiClient.downloadCudaBackend(); + refetchCudaStatus(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Failed to start download'; + if (msg.includes('already downloaded')) { + refetchCudaStatus(); + } else { + setError(msg); + } + } + }; + + const handleRestart = async () => { + setError(null); + try { + await restartServerWithPolling('Restart failed'); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Restart failed'); + } + }; + + const handleSwitchToCpu = async () => { + setError(null); + setRestartPhase('stopping'); + try { + await apiClient.deleteCudaBackend(); + await restartServerWithPolling('Failed to switch to CPU'); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to switch to CPU'); + refetchCudaStatus(); + } + }; + + const handleDelete = async () => { + setError(null); + try { + await apiClient.deleteCudaBackend(); + refetchCudaStatus(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend'); + } + }; + + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`; + }; + + if (!health) return null; + + const hasNativeGpu = + health.gpu_available && + !isCurrentlyCuda && + health.gpu_type && + !health.gpu_type.includes('CUDA'); + + return ( +
+ + + {/* CUDA section — only when no native GPU and not already on CUDA */} + {!hasNativeGpu && !isCurrentlyCuda && ( + + {/* Download progress */} + {cudaDownloading && downloadProgress && ( + +
+ +
+ + {downloadProgress.filename || + (cudaAvailable ? 'Updating...' : 'Downloading...')} + + + {downloadProgress.total > 0 + ? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}` + : `${downloadProgress.progress.toFixed(1)}%`} + +
+
+
+ )} + + {/* Restart in progress */} + {restartPhase !== 'idle' && ( + } + /> + )} + + {/* Error */} + {error && ( + +
+ + {error} +
+
+ )} + + {/* Actions */} + {restartPhase === 'idle' && !cudaDownloading && ( + <> + {!cudaAvailable && !isCurrentlyCuda && ( + + + Download + + } + /> + )} + + {cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && ( + + + Restart + + } + /> + )} + + {isCurrentlyCuda && platform.metadata.isTauri && ( + + + Switch + + } + /> + )} + + {cudaAvailable && !isCurrentlyCuda && ( + + + Remove + + } + /> + )} + + )} +
+ )} + +

+ Voicebox automatically detects and uses the best available GPU on your system. On Apple + Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal + Performance Shaders (MPS), with no additional setup required. On Windows and Linux with + NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference. + AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When + no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower. +

+
+ ); +} diff --git a/app/src/components/ServerTab/LogsPage.tsx b/app/src/components/ServerTab/LogsPage.tsx new file mode 100644 index 00000000..68b8f317 --- /dev/null +++ b/app/src/components/ServerTab/LogsPage.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils/cn'; +import { type LogEntry, useLogStore } from '@/stores/logStore'; + +function formatTime(timestamp: number): string { + const d = new Date(timestamp); + return d.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} + +function LogLine({ entry }: { entry: LogEntry }) { + return ( +
+ + {formatTime(entry.timestamp)} + + + {entry.line} + +
+ ); +} + +export function LogsPage() { + const entries = useLogStore((s) => s.entries); + const clear = useLogStore((s) => s.clear); + const containerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Auto-scroll to bottom when new entries arrive + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [entries.length, autoScroll]); + + // Detect manual scroll to disable auto-scroll + const handleScroll = () => { + const el = containerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + setAutoScroll(atBottom); + }; + + return ( +
+
+
+

Server Logs

+

+ {entries.length} {entries.length === 1 ? 'line' : 'lines'} +

+
+
+ {!autoScroll && ( + + )} + +
+
+ +
+ {entries.length === 0 ? ( +
+

No log output yet.

+ {!import.meta.env?.PROD && ( +

+ Server logs are only captured when the app manages the server process (production + builds). +

+ )} +
+ ) : ( + entries.map((entry) => ) + )} +
+
+ ); +} diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx index d9954c90..b2f78dc3 100644 --- a/app/src/components/ServerTab/ServerTab.tsx +++ b/app/src/components/ServerTab/ServerTab.tsx @@ -1,35 +1,70 @@ -import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; -import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings'; -import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration'; -import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; +import { Link, Outlet, useMatchRoute } from '@tanstack/react-router'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { cn } from '@/lib/utils/cn'; import { usePlatform } from '@/platform/PlatformContext'; import { usePlayerStore } from '@/stores/playerStore'; -export function ServerTab() { +interface SettingsTab { + label: string; + path: + | '/settings' + | '/settings/generation' + | '/settings/gpu' + | '/settings/logs' + | '/settings/changelog' + | '/settings/about'; + tauriOnly?: boolean; +} + +const tabs: SettingsTab[] = [ + { label: 'General', path: '/settings' }, + { label: 'Generation', path: '/settings/generation' }, + { label: 'GPU', path: '/settings/gpu', tauriOnly: true }, + { label: 'Logs', path: '/settings/logs', tauriOnly: true }, + { label: 'Changelog', path: '/settings/changelog' }, + { label: 'About', path: '/settings/about' }, +]; + +export function SettingsLayout() { const platform = usePlatform(); const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl); + const matchRoute = useMatchRoute(); + return ( -
-
- - - {platform.metadata.isTauri && } - {platform.metadata.isTauri && } -
-
- Created by{' '} - - Jamie Pine - +
+ + +
+
); diff --git a/app/src/components/ServerTab/SettingRow.tsx b/app/src/components/ServerTab/SettingRow.tsx new file mode 100644 index 00000000..f4b42faa --- /dev/null +++ b/app/src/components/ServerTab/SettingRow.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react'; + +/** + * A section header with title and optional description, separated by a border. + */ +export function SettingSection({ + title, + description, + children, +}: { + title?: string; + description?: string; + children: ReactNode; +}) { + return ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ {children} +
+
+ ); +} + +/** + * A single settings row: label+description on the left, action on the right. + * Use for toggles, inputs, buttons, badges — any control type. + */ +export function SettingRow({ + title, + description, + htmlFor, + action, + children, +}: { + title: string; + description?: string; + htmlFor?: string; + /** Right-aligned control (checkbox, button, badge, etc.) */ + action?: ReactNode; + /** Full-width content rendered below the label row (for sliders, inputs, etc.) */ + children?: ReactNode; +}) { + return ( +
+
+
+ + {description &&

{description}

} +
+ {action &&
{action}
} +
+ {children &&
{children}
} +
+ ); +} diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 5a37bd64..e4985a44 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { Link, useMatchRoute } from '@tanstack/react-router'; -import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react'; +import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react'; import { useEffect, useState } from 'react'; import voiceboxLogo from '@/assets/voicebox-logo.png'; import { cn } from '@/lib/utils/cn'; @@ -19,7 +19,7 @@ const tabs = [ { id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' }, { id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' }, { id: 'models', path: '/models', icon: Box, label: 'Models' }, - { id: 'server', path: '/server', icon: Server, label: 'Server' }, + { id: 'settings', path: '/settings', icon: Settings, label: 'Settings' }, ]; export function Sidebar({ isMacOS }: SidebarProps) { @@ -54,9 +54,10 @@ export function Sidebar({ isMacOS }: SidebarProps) {
{tabs.map((tab, index) => { const Icon = tab.icon; - // For index route, use exact match; for others, use default matching const isActive = - tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path }); + tab.path === '/' + ? matchRoute({ to: '/', fuzzy: false }) + : matchRoute({ to: tab.path, fuzzy: true }); // Accent fades as buttons get further from the logo const accentOpacity = Math.max(0.08, 0.5 - index * 0.07); @@ -98,7 +99,7 @@ export function Sidebar({ isMacOS }: SidebarProps) { v{version} {updateStatus.available && ( Update diff --git a/app/src/components/ui/slider.tsx b/app/src/components/ui/slider.tsx index 8c944ec7..c567ec10 100644 --- a/app/src/components/ui/slider.tsx +++ b/app/src/components/ui/slider.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import * as SliderPrimitive from '@radix-ui/react-slider'; +import * as React from 'react'; import { cn } from '@/lib/utils/cn'; const Slider = React.forwardRef< @@ -14,7 +14,7 @@ const Slider = React.forwardRef< - + )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/app/src/components/ui/toaster.tsx b/app/src/components/ui/toaster.tsx index ae772ab6..3c1d2978 100644 --- a/app/src/components/ui/toaster.tsx +++ b/app/src/components/ui/toaster.tsx @@ -1,3 +1,4 @@ +import { usePlayerStore } from '@/stores/playerStore'; import { Toast, ToastClose, @@ -10,6 +11,7 @@ import { useToast } from './use-toast'; export function Toaster() { const { toasts } = useToast(); + const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl); return ( @@ -23,7 +25,7 @@ export function Toaster() { ))} - + ); } diff --git a/app/src/components/ui/toggle.tsx b/app/src/components/ui/toggle.tsx new file mode 100644 index 00000000..3db31a81 --- /dev/null +++ b/app/src/components/ui/toggle.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils/cn'; + +export interface ToggleProps { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + disabled?: boolean; + className?: string; + id?: string; +} + +const Toggle = React.forwardRef( + ({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => { + return ( + + ); + }, +); +Toggle.displayName = 'Toggle'; + +export { Toggle }; diff --git a/app/src/global.d.ts b/app/src/global.d.ts index d405eef5..9f97c210 100644 --- a/app/src/global.d.ts +++ b/app/src/global.d.ts @@ -1,3 +1,8 @@ interface Window { __voiceboxServerStartedByApp?: boolean; } + +declare module 'virtual:changelog' { + const raw: string; + export default raw; +} diff --git a/app/src/lib/utils/parseChangelog.ts b/app/src/lib/utils/parseChangelog.ts new file mode 100644 index 00000000..96e561a4 --- /dev/null +++ b/app/src/lib/utils/parseChangelog.ts @@ -0,0 +1,37 @@ +export interface ChangelogEntry { + version: string; + date: string | null; + body: string; +} + +/** + * Parses a Keep-a-Changelog style markdown string into structured entries. + * + * Splits on `## [version]` headings and extracts the version + date from each. + * The body is the raw markdown between headings (trimmed), with the leading + * `# Changelog` title and trailing link references stripped. + */ +export function parseChangelog(raw: string): ChangelogEntry[] { + const entries: ChangelogEntry[] = []; + + // Strip trailing link reference definitions (e.g. [0.1.0]: https://...) + const cleaned = raw.replace(/^\[[\w.]+\]:.*$/gm, '').trimEnd(); + + // Match `## [version]` or `## [version] - date` + const headingRe = /^## \[(.+?)\](?:\s*-\s*(.+))?$/gm; + const matches = [...cleaned.matchAll(headingRe)]; + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const version = match[1]; + const date = match[2]?.trim() || null; + + const start = match.index! + match[0].length; + const end = i + 1 < matches.length ? matches[i + 1].index! : cleaned.length; + const body = cleaned.slice(start, end).trim(); + + entries.push({ version, date, body }); + } + + return entries; +} diff --git a/app/src/platform/types.ts b/app/src/platform/types.ts index ef936575..28c3c071 100644 --- a/app/src/platform/types.ts +++ b/app/src/platform/types.ts @@ -50,12 +50,18 @@ export interface PlatformAudio { stopPlayback(): void; } +export interface ServerLogEntry { + stream: 'stdout' | 'stderr'; + line: string; +} + export interface PlatformLifecycle { startServer(remote?: boolean, modelsDir?: string | null): Promise; stopServer(): Promise; restartServer(modelsDir?: string | null): Promise; setKeepServerRunning(keep: boolean): Promise; setupWindowCloseHandler(): Promise; + subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void; onServerReady?: () => void; } diff --git a/app/src/router.tsx b/app/src/router.tsx index 7e6e14ef..45876cd3 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -1,10 +1,22 @@ -import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'; +import { + createRootRoute, + createRoute, + createRouter, + Outlet, + redirect, +} from '@tanstack/react-router'; import { AppFrame } from '@/components/AppFrame/AppFrame'; import { AudioTab } from '@/components/AudioTab/AudioTab'; import { EffectsTab } from '@/components/EffectsTab/EffectsTab'; import { MainEditor } from '@/components/MainEditor/MainEditor'; import { ModelsTab } from '@/components/ModelsTab/ModelsTab'; -import { ServerTab } from '@/components/ServerTab/ServerTab'; +import { AboutPage } from '@/components/ServerTab/AboutPage'; +import { ChangelogPage } from '@/components/ServerTab/ChangelogPage'; +import { GeneralPage } from '@/components/ServerTab/GeneralPage'; +import { GenerationPage } from '@/components/ServerTab/GenerationPage'; +import { GpuPage } from '@/components/ServerTab/GpuPage'; +import { LogsPage } from '@/components/ServerTab/LogsPage'; +import { SettingsLayout } from '@/components/ServerTab/ServerTab'; import { Sidebar } from '@/components/Sidebar'; import { StoriesTab } from '@/components/StoriesTab/StoriesTab'; import { Toaster } from '@/components/ui/toaster'; @@ -120,11 +132,57 @@ const modelsRoute = createRoute({ component: ModelsTab, }); -// Server route -const serverRoute = createRoute({ +// Settings layout route (parent for sub-tabs) +const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + component: SettingsLayout, +}); + +// Settings sub-routes +const settingsGeneralRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/', + component: GeneralPage, +}); + +const settingsGenerationRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/generation', + component: GenerationPage, +}); + +const settingsGpuRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/gpu', + component: GpuPage, +}); + +const settingsChangelogRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/changelog', + component: ChangelogPage, +}); + +const settingsLogsRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/logs', + component: LogsPage, +}); + +const settingsAboutRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: '/about', + component: AboutPage, +}); + +// Redirect old /server path to /settings +const serverRedirectRoute = createRoute({ getParentRoute: () => rootRoute, path: '/server', - component: ServerTab, + beforeLoad: () => { + throw redirect({ to: '/settings' }); + }, }); // Route tree @@ -135,7 +193,15 @@ const routeTree = rootRoute.addChildren([ audioRoute, effectsRoute, modelsRoute, - serverRoute, + settingsRoute.addChildren([ + settingsGeneralRoute, + settingsGenerationRoute, + settingsGpuRoute, + settingsLogsRoute, + settingsChangelogRoute, + settingsAboutRoute, + ]), + serverRedirectRoute, ]); // Create router diff --git a/app/src/stores/logStore.ts b/app/src/stores/logStore.ts new file mode 100644 index 00000000..833f3088 --- /dev/null +++ b/app/src/stores/logStore.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; +import type { ServerLogEntry } from '@/platform/types'; + +const MAX_LOG_ENTRIES = 2000; + +let nextLogEntryId = 0; + +export interface LogEntry extends ServerLogEntry { + id: number; + timestamp: number; +} + +interface LogStore { + entries: LogEntry[]; + addEntry: (entry: ServerLogEntry) => void; + clear: () => void; +} + +export const useLogStore = create((set) => ({ + entries: [], + addEntry: (entry) => + set((state) => { + const newEntry: LogEntry = { ...entry, id: nextLogEntryId++, timestamp: Date.now() }; + const entries = [...state.entries, newEntry]; + if (entries.length > MAX_LOG_ENTRIES) { + return { entries: entries.slice(entries.length - MAX_LOG_ENTRIES) }; + } + return { entries }; + }), + clear: () => set({ entries: [] }), +})); diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json index 42872c59..ff9e35d5 100644 --- a/app/tsconfig.node.json +++ b/app/tsconfig.node.json @@ -6,5 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "plugins/**/*.ts"] } diff --git a/app/vite.config.ts b/app/vite.config.ts index 69105abc..36bc168b 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -2,9 +2,10 @@ import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; +import { changelogPlugin } from './plugins/changelog'; export default defineConfig({ - plugins: [tailwindcss(), react()], + plugins: [tailwindcss(), react(), changelogPlugin(path.resolve(__dirname, '..'))], resolve: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/backend/build_binary.py b/backend/build_binary.py index ce789895..f9cdb1b7 100644 --- a/backend/build_binary.py +++ b/backend/build_binary.py @@ -127,7 +127,13 @@ def build_server(cuda=False): "uvicorn", "--hidden-import", "sqlalchemy", - "--hidden-import", + # librosa uses lazy_loader which generates .pyi stub files at + # install time and reads them at runtime to discover submodules. + # --hidden-import alone doesn't bundle the stubs, causing + # "Cannot load imports from non-existent stub" at runtime. + "--collect-all", + "lazy_loader", + "--collect-all", "librosa", "--hidden-import", "soundfile", diff --git a/backend/routes/health.py b/backend/routes/health.py index 0053f423..f138e336 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -207,7 +207,7 @@ async def filesystem_health(): checks.append( models.DirectoryCheck( - path=str(dir_path), + path=str(dir_path.resolve()), exists=exists, writable=writable, error=error, diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec index e38d7233..b5756c66 100644 --- a/backend/voicebox-server.spec +++ b/backend/voicebox-server.spec @@ -6,7 +6,7 @@ from PyInstaller.utils.hooks import copy_metadata datas = [] binaries = [] -hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] +hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt'] datas += collect_data_files('qwen_tts') datas += copy_metadata('qwen-tts') datas += copy_metadata('requests') @@ -23,6 +23,10 @@ tmp_ret = collect_all('zipvoice') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('linacodec') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('lazy_loader') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('librosa') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('inflect') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('perth') diff --git a/docs/notes/BACKEND_CODE_REVIEW.md b/docs/notes/BACKEND_CODE_REVIEW.md new file mode 100644 index 00000000..ccb8bfd4 --- /dev/null +++ b/docs/notes/BACKEND_CODE_REVIEW.md @@ -0,0 +1,188 @@ +# Code Review: `backend/` Post-Refactor + +**Date:** 2026-03-16 +**Scope:** Full review of `backend/` after major refactor + +## Overall Assessment + +The refactor is well-executed. The codebase follows a clean layered architecture (routes -> services -> backends) with good separation of concerns. The code is readable, the module boundaries are sensible, and the migration strategy is pragmatic for a desktop app. Below are findings organized by severity. + +--- + +## Critical Issues + +### 1. Double `init_db()` in bundled entry point + +**File:** `server.py:261` + +`server.py:261` calls `database.init_db()` explicitly, but `app.py:140` also calls `database.init_db()` inside the `startup` event handler. When running via `server.py`, the database gets initialized twice -- once before uvicorn starts and once during the startup event. This is likely benign (idempotent migrations), but the second call recreates the engine and `SessionLocal`, which could cause subtle issues if any sessions were opened between the two calls. + +**Recommendation:** Remove the explicit `init_db()` call in `server.py:260-262` and rely solely on the startup event in `app.py`. The same issue exists in `main.py:38`. + +### 2. SSE endpoint holds DB session open indefinitely + +**File:** `routes/generations.py:179-212` + +The `get_generation_status` SSE endpoint receives a `db` session via `Depends(get_db)` but keeps it open for the lifetime of the SSE stream (polling every 1 second). This ties up a SQLite connection for potentially minutes. With SQLite's single-writer model, this is a contention risk. + +**Recommendation:** Open and close a short-lived session on each poll iteration instead of holding one via dependency injection: + +```python +async def event_stream(): + while True: + db = next(get_db()) + try: + gen = db.query(DBGeneration).filter_by(id=generation_id).first() + ... + finally: + db.close() + await asyncio.sleep(1) +``` + +--- + +## High Severity + +### 3. `_save_retry` creates no version record + +**File:** `services/generation.py:201-214` + +`_save_retry` writes the audio file but creates no `GenerationVersion` entry. If the generation previously had versions (from an initial generate that failed mid-effects, for example), the retry result won't appear in the versions list. This creates an inconsistency: some generations have versions, retried ones don't. + +**Recommendation:** Create a "clean" version in `_save_retry` the same way `_save_generate` does. + +### 4. `datetime.utcnow()` is deprecated + +**File:** `services/stories.py` and others + +`datetime.utcnow()` is deprecated as of Python 3.12 and returns a naive datetime. Used throughout `services/stories.py` (lines 95, 96, 193, 307, 360, 404, 457, 529, 537, 598, 610, 652, 716, 775) and possibly other service files. + +**Recommendation:** Replace with `datetime.now(datetime.UTC)` or `datetime.now(timezone.utc)`. + +### 5. `list_stories` N+1 query + +**File:** `services/stories.py:122-132` + +`list_stories` issues one `COUNT(*)` query per story inside a loop. For N stories, that's N+1 queries. + +**Recommendation:** Use a subquery or a single aggregated query: + +```python +from sqlalchemy import func +counts = dict( + db.query(DBStoryItem.story_id, func.count(DBStoryItem.id)) + .group_by(DBStoryItem.story_id) + .all() +) +``` + +--- + +## Medium Severity + +### 6. `create_story` queries item count immediately after creation + +**File:** `services/stories.py:103` + +Line 103 queries the item count for a story that was just created -- it will always be 0. This is wasted I/O. + +### 7. Bare `except Exception` with silent `pass` + +**File:** `routes/generations.py:69-70` + +When parsing a profile's stored `effects_chain` JSON, exceptions are silently swallowed. A corrupt JSON blob would result in no effects being applied with no logging. + +**Recommendation:** Log the exception at warning level. + +### 8. `update_story_item_times` uses `generation_id` as key + +**File:** `services/stories.py:643-649` + +`item_map` is keyed by `generation_id`, but the same generation can appear in a story multiple times (via split/duplicate). This would cause key collisions, and only the last item per generation_id would be updatable. + +**Recommendation:** Key by `item_id` instead, and change the `StoryItemUpdateTime` model to use `item_id`. + +### 9. Thread safety gap in `get_stt_backend` + +**File:** `backends/__init__.py:499-520` + +`get_stt_backend()` uses no locking (unlike `get_tts_backend_for_engine` which uses `_tts_backends_lock`). A race condition could create duplicate STT backend instances. + +**Recommendation:** Add a lock or use the same double-checked locking pattern. + +### 10. Unused `_tts_backend` global + +**File:** `backends/__init__.py:156` + +`_tts_backend` is declared but never read or written outside of `reset_backends()`. All TTS access goes through `_tts_backends` dict. Dead code. + +### 11. `trim_story_item` returns `None` for validation errors + +**File:** `services/stories.py:448` + +Returning `None` for "item not found" and "invalid trim values" is ambiguous. The route handler can't distinguish between a 404 and a 400 response. + +**Recommendation:** Raise specific exceptions (e.g., `ValueError` for invalid trim) so the route can return the appropriate HTTP status. + +### 12. `load_engine_model` calls different method names + +**File:** `backends/__init__.py:340-346` + +For Qwen, it calls `load_model_async(model_size)`. For others, it calls `load_model()` with no arguments. But the `TTSBackend` protocol defines `load_model(self, model_size: str)`. This means the protocol signature doesn't match actual usage for either path. + +**Recommendation:** Align the protocol definition with actual backend implementations, or add `load_model_async` to the protocol. + +--- + +## Low Severity / Style + +### 13. Inconsistent `async` usage in services + +Functions like `create_story`, `list_stories`, etc. in `services/stories.py` are `async def` but contain no `await` expressions. They do synchronous SQLAlchemy I/O. While this works (the functions are awaitable), it's misleading -- these will block the event loop during DB access. + +This is a known tradeoff with synchronous SQLAlchemy + FastAPI, and acceptable for a single-user desktop app with SQLite, but worth noting for documentation. + +### 14. `getattr(item, "version_id", None)` pattern + +**File:** `services/stories.py:57, 504, 524, etc.` + +Multiple places use `getattr(item, "version_id", None)` on a DB model that has `version_id` as a declared column (from migrations). After the migration runs, this is always a real attribute. The defensive `getattr` is cargo-culted. + +**Recommendation:** Access `item.version_id` directly. If the column is missing, the ORM will raise a clear error. + +### 15. `reorder_story_items` ignores trim values + +**File:** `services/stories.py:707` + +When recalculating timecodes, it uses the full `generation.duration` rather than the effective (trimmed) duration. Trimmed items will have larger gaps than intended. + +### 16. Module-level `import torch` in `app.py:44` + +`import torch` at module level in `app.py` means torch loads on every import of the app module. This is intentional (AMD env vars must be set first), but the comment on line 38 should mention that this is why the import is here and not at the top. + +### 17. f-strings in logging in `server.py` + +`server.py` uses f-strings in logging calls (e.g., lines 63-66, 252, 256, 264). This evaluates the string even when the log level is filtered out. The rest of the codebase correctly uses `%s` style (e.g., `app.py:131`). + +--- + +## Architecture Observations (Not Issues) + +- **Clean layered design**: routes -> services -> backends with Pydantic models as the API contract. +- **Backend abstraction** with `Protocol` classes and a config registry is a solid pattern. +- **Serial generation queue** (`task_queue.py`) is simple and effective for single-GPU serialization. +- **Migration approach** is pragmatic for the use case. The idempotent, check-then-act pattern is reliable. +- **The `generation.py` refactor** (collapsing three closures into `run_generation` with a mode parameter) is a clear improvement. + +--- + +## Summary + +| Severity | Count | +|----------|-------| +| Critical | 2 | +| High | 3 | +| Medium | 7 | +| Low/Style | 5 | + +The refactor achieved its goals: clear module boundaries, reduced duplication (especially in `generation.py`), and a well-organized backend abstraction. The critical items (double init_db and SSE session leak) should be addressed first, followed by the version consistency issue in retry and the N+1 query. diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 21c44190..a881f46e 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -413,6 +413,10 @@ async fn start_server( tauri_plugin_shell::process::CommandEvent::Stdout(line) => { let line_str = String::from_utf8_lossy(&line); println!("Server output: {}", line_str); + let _ = app.emit("server-log", serde_json::json!({ + "stream": "stdout", + "line": line_str.trim_end(), + })); if line_str.contains("Uvicorn running") || line_str.contains("Application startup complete") { println!("Server is ready!"); @@ -422,6 +426,10 @@ async fn start_server( tauri_plugin_shell::process::CommandEvent::Stderr(line) => { let line_str = String::from_utf8_lossy(&line).to_string(); eprintln!("Server: {}", line_str); + let _ = app.emit("server-log", serde_json::json!({ + "stream": "stderr", + "line": line_str.trim_end(), + })); // Collect error lines for debugging if line_str.contains("ERROR") || line_str.contains("Error") || line_str.contains("Failed") { @@ -482,15 +490,26 @@ async fn start_server( } } - // Spawn task to continue reading output + // Spawn task to continue reading output and emit to frontend + let app_handle = app.clone(); tokio::spawn(async move { while let Some(event) = rx.recv().await { match event { tauri_plugin_shell::process::CommandEvent::Stdout(line) => { - println!("Server: {}", String::from_utf8_lossy(&line)); + let line_str = String::from_utf8_lossy(&line); + println!("Server: {}", line_str); + let _ = app_handle.emit("server-log", serde_json::json!({ + "stream": "stdout", + "line": line_str.trim_end(), + })); } tauri_plugin_shell::process::CommandEvent::Stderr(line) => { - eprintln!("Server error: {}", String::from_utf8_lossy(&line)); + let line_str = String::from_utf8_lossy(&line); + eprintln!("Server error: {}", line_str); + let _ = app_handle.emit("server-log", serde_json::json!({ + "stream": "stderr", + "line": line_str.trim_end(), + })); } _ => {} } diff --git a/tauri/src/platform/lifecycle.ts b/tauri/src/platform/lifecycle.ts index 357f48d3..b20da778 100644 --- a/tauri/src/platform/lifecycle.ts +++ b/tauri/src/platform/lifecycle.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { emit, listen } from '@tauri-apps/api/event'; -import type { PlatformLifecycle } from '@/platform/types'; +import type { PlatformLifecycle, ServerLogEntry } from '@/platform/types'; class TauriLifecycle implements PlatformLifecycle { onServerReady?: () => void; @@ -86,6 +86,31 @@ class TauriLifecycle implements PlatformLifecycle { console.error('Failed to setup window close handler:', error); } } + + subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void { + let disposed = false; + let unlisten: (() => void) | null = null; + + void listen('server-log', (event) => { + callback(event.payload); + }) + .then((fn) => { + if (disposed) { + fn(); + return; + } + unlisten = fn; + }) + .catch((error) => { + console.error('Failed to subscribe to server logs:', error); + }); + + return () => { + disposed = true; + unlisten?.(); + unlisten = null; + }; + } } export const tauriLifecycle = new TauriLifecycle(); diff --git a/tauri/vite.config.ts b/tauri/vite.config.ts index 71caf5f7..8e0362e4 100644 --- a/tauri/vite.config.ts +++ b/tauri/vite.config.ts @@ -1,10 +1,11 @@ import path from 'node:path'; -import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; +import { changelogPlugin } from '../app/plugins/changelog'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), changelogPlugin(path.resolve(__dirname, '..'))], resolve: { alias: { '@': path.resolve(__dirname, '../app/src'), diff --git a/web/src/platform/lifecycle.ts b/web/src/platform/lifecycle.ts index 9a6d825a..14156180 100644 --- a/web/src/platform/lifecycle.ts +++ b/web/src/platform/lifecycle.ts @@ -1,4 +1,4 @@ -import type { PlatformLifecycle } from '@/platform/types'; +import type { PlatformLifecycle, ServerLogEntry } from '@/platform/types'; class WebLifecycle implements PlatformLifecycle { onServerReady?: () => void; @@ -27,6 +27,11 @@ class WebLifecycle implements PlatformLifecycle { async setupWindowCloseHandler(): Promise { // No-op for web - no window close handling needed } + + subscribeToServerLogs(_callback: (_entry: ServerLogEntry) => void): () => void { + // No-op for web - server logs are not available + return () => {}; + } } export const webLifecycle = new WebLifecycle(); diff --git a/web/vite.config.ts b/web/vite.config.ts index e3aa4013..b9abffe5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,10 +1,11 @@ import path from 'node:path'; -import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; +import { changelogPlugin } from '../app/plugins/changelog'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), changelogPlugin(path.resolve(__dirname, '..'))], resolve: { alias: { '@': path.resolve(__dirname, '../app/src'),