diff --git a/app/src/components/History/ClipFolderTree.tsx b/app/src/components/History/ClipFolderTree.tsx new file mode 100644 index 000000000..dd68c58b3 --- /dev/null +++ b/app/src/components/History/ClipFolderTree.tsx @@ -0,0 +1,390 @@ +import { + ChevronDown, + ChevronRight, + FolderPlus, + Inbox, + Layers, + MoreHorizontal, + Pencil, + Trash2, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import type { FolderKind, FolderResponse } from '@/lib/api/types'; +import { + useCreateFolder, + useDeleteFolder, + useDetachFolder, + useFolders, + useUpdateFolder, +} from '@/lib/hooks/useFolders'; +import { cn } from '@/lib/utils/cn'; +import { isFolderDrag, readFolderDragData } from '@/lib/utils/folderDrag'; +import { useUIStore } from '@/stores/uiStore'; + +/** + * What the clip list is currently filtered to. + * + * `uncategorised` is its own selection rather than a null folderId, because + * "no filter" and "clips in no folder" are different requests — the server + * distinguishes them too. + */ +export type ClipFolderSelection = + | { kind: 'all' } + | { kind: 'uncategorised' } + | { kind: 'folder'; folderId: string }; + +interface ClipFolderTreeProps { + selection: ClipFolderSelection; + onSelect: (selection: ClipFolderSelection) => void; + /** Which folder kind to manage. Clips and stories share this tree because + * both nest and both need the same create/rename/move/delete affordances. */ + kind?: FolderKind; + /** Heading above the tree. */ + title?: string; + /** Label for the "no filter" row. */ + allLabel?: string; + /** Called when an item is dropped on a folder. folderId is null for the + * Uncategorised row. */ + onDropItem?: (itemId: string, folderId: string | null) => void; +} + +/** Sentinel for highlighting the Uncategorised row, which has no folder id. */ +const UNCATEGORISED_DROP_ID = '__uncategorised__'; + +/** A folder plus its children, built once per folder list change. */ +interface TreeNode { + folder: FolderResponse; + children: TreeNode[]; +} + +function buildTree(folders: FolderResponse[]): TreeNode[] { + const nodes = new Map(); + for (const folder of folders) nodes.set(folder.id, { folder, children: [] }); + + const roots: TreeNode[] = []; + for (const node of nodes.values()) { + const parentId = node.folder.parent_id; + // A parent that isn't in the list (deleted concurrently) would otherwise + // make the node unreachable — surface it at the root instead. + const parent = parentId ? nodes.get(parentId) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; +} + +export function ClipFolderTree({ + selection, + onSelect, + kind = 'generation', + title, + allLabel, + onDropItem, +}: ClipFolderTreeProps) { + const { t } = useTranslation(); + const { data: folders } = useFolders(kind); + + const collapsedIds = useUIStore((state) => state.collapsedFolderIds[kind] ?? []); + const toggleCollapsed = useUIStore((state) => state.toggleFolderCollapsed); + + const createFolder = useCreateFolder(kind); + const updateFolder = useUpdateFolder(kind); + const detachFolder = useDetachFolder(kind); + const deleteFolder = useDeleteFolder(kind); + + const [dialog, setDialog] = useState< + | { mode: 'create'; parentId: string | null } + | { mode: 'rename'; folder: FolderResponse } + | { mode: 'delete'; folder: FolderResponse } + | null + >(null); + const [draftName, setDraftName] = useState(''); + const [dragOverId, setDragOverId] = useState(null); + + const tree = useMemo(() => buildTree(folders ?? []), [folders]); + + const openCreate = (parentId: string | null) => { + setDraftName(''); + setDialog({ mode: 'create', parentId }); + }; + + const submitDialog = () => { + const trimmed = draftName.trim(); + if (!dialog) return; + + if (dialog.mode === 'create' && trimmed) { + createFolder.mutate({ name: trimmed, parentId: dialog.parentId }); + } else if (dialog.mode === 'rename' && trimmed && trimmed !== dialog.folder.name) { + updateFolder.mutate({ folderId: dialog.folder.id, data: { name: trimmed } }); + } + setDialog(null); + }; + + const renderNode = (node: TreeNode, depth: number) => { + const { folder, children } = node; + const collapsed = collapsedIds.includes(folder.id); + const isSelected = selection.kind === 'folder' && selection.folderId === folder.id; + const Chevron = collapsed ? ChevronRight : ChevronDown; + + return ( +
+ {/* Shaded and bold, matching the voice folders, so a folder never + reads as just another row in the list. */} +
{ + // Both enter and over must preventDefault; some engines only treat + // an element as a drop target once enter has been cancelled. + if (!onDropItem || !isFolderDrag(e)) return; + e.preventDefault(); + setDragOverId(folder.id); + }} + onDragOver={(e) => { + if (!onDropItem || !isFolderDrag(e)) return; + // preventDefault is what marks this a valid drop target. + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverId(folder.id); + }} + onDragLeave={() => setDragOverId((id) => (id === folder.id ? null : id))} + onDrop={(e) => { + setDragOverId(null); + const payload = readFolderDragData(e); + if (!payload || payload.kind !== kind) return; + e.preventDefault(); + onDropItem?.(payload.id, folder.id); + }} + className={cn( + 'group/node my-0.5 flex items-center gap-1 rounded border border-border/60 bg-muted/60 pr-1 transition-colors', + isSelected && 'border-accent/60 bg-accent/40', + dragOverId === folder.id && 'border-accent bg-accent/50 ring-1 ring-accent', + )} + style={{ marginLeft: `${depth * 12}px` }} + > + {children.length > 0 ? ( + + ) : ( + // Keeps leaf labels aligned with their expandable siblings. + + )} + + + + + + + + + openCreate(folder.id)}> + + {t('folders.clip.newSubfolder')} + + { + setDraftName(folder.name); + setDialog({ mode: 'rename', folder }); + }} + > + + {t('folders.rename')} + + {folder.parent_id && ( + detachFolder.mutate(folder.id)}> + + {t('folders.clip.moveToRoot')} + + )} + + setDialog({ mode: 'delete', folder })} + > + + {t('folders.delete')} + + + +
+ + {!collapsed && children.map((child) => renderNode(child, depth + 1))} +
+ ); + }; + + return ( +
+
+ + {title ?? t('folders.clip.filterTitle')} + + +
+ + + + {/* Folders scroll on their own. With a few dozen folders the list + otherwise pushes the clips off-screen entirely, and "All clips" and + "Uncategorised" stay pinned outside it so both are always reachable. */} +
+ {tree.map((node) => renderNode(node, 0))} +
+ + + + setDialog(null)} + > + + + + {dialog?.mode === 'rename' + ? t('folders.renameDialog.title') + : t('folders.newDialog.title')} + + + setDraftName(e.target.value)} + placeholder={t('folders.newDialog.placeholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') submitDialog(); + }} + aria-label={t('folders.newDialog.title')} + /> + + + + + + + + setDialog(null)}> + + + {t('folders.deleteDialog.title')} + + {dialog?.mode === 'delete' && + t('folders.deleteDialog.body', { name: dialog.folder.name })} + + + + + + + + +
+ ); +} diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index aeeae4ece..ff2417c82 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -4,6 +4,8 @@ import { AudioLines, Download, FileArchive, + FolderInput, + GripVertical, Loader2, MoreHorizontal, Play, @@ -18,6 +20,8 @@ import { useTranslation } from 'react-i18next'; import { AudioBars } from '@/components/AudioBars'; import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; +import { type ClipFolderSelection, ClipFolderTree } from '@/components/History/ClipFolderTree'; +import { ListPaneSearch } from '@/components/ListPane'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -31,6 +35,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -45,6 +54,8 @@ import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { useDebouncedValue } from '@/lib/hooks/useDebouncedValue'; +import { useFolders, useSetGenerationFolder } from '@/lib/hooks/useFolders'; import { useClearFailedGenerations, useDeleteGeneration, @@ -54,6 +65,7 @@ import { useImportGeneration, } from '@/lib/hooks/useHistory'; import { cn } from '@/lib/utils/cn'; +import { setFolderDragData } from '@/lib/utils/folderDrag'; import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format'; import { useGenerationStore } from '@/stores/generationStore'; import { usePlayerStore } from '@/stores/playerStore'; @@ -86,13 +98,35 @@ export function HistoryTable() { const { toast } = useToast(); const queryClient = useQueryClient(); + const [folderSelection, setFolderSelection] = useState({ kind: 'all' }); + const { data: clipFolders } = useFolders('generation'); + const setGenerationFolder = useSetGenerationFolder(); + + // Invalidating the query is not enough on its own: allHistory accumulates + // pages, and past page 0 the refreshed first page is appended rather than + // replacing it — so a clip moved out of the current folder stays visible. + // Dropping back to page 0 makes the next response replace the list. + const moveToFolder = (generationId: string, folderId: string | null) => { + setGenerationFolder.mutate({ generationId, folderId }, { onSuccess: () => setPage(0) }); + }; + + // Folder first, then text — the folder is a scope, the search runs inside it. + // Debounced because every keystroke would otherwise be a request; the search + // runs server-side so it covers every clip in scope, not just loaded pages. + const [search, setSearch] = useState(''); + const debouncedSearch = useDebouncedValue(search, 250); + const { data: historyData, isLoading, isFetching, + isPlaceholderData, } = useHistory({ limit, offset: page * limit, + search: debouncedSearch.trim() || undefined, + folder_id: folderSelection.kind === 'folder' ? folderSelection.folderId : undefined, + uncategorised_only: folderSelection.kind === 'uncategorised' || undefined, }); const deleteGeneration = useDeleteGeneration(); @@ -144,6 +178,32 @@ export function HistoryTable() { } }, [historyData, page]); + // Changing the folder filter restarts paging. Deliberately only resets the + // page and not the accumulated list: this effect is declared after the one + // that fills the list, so clearing here wiped rows that React Query had + // already served for the new folder in the same commit — and since + // historyData then never changed again, the list stayed empty. Replacing on + // page 0 above is what actually discards the previous folder's rows. + // biome-ignore lint/correctness/useExhaustiveDependencies: folderSelection is the trigger, not a value the effect reads + useEffect(() => { + setPage(0); + }, [folderSelection]); + + // Same for the search term: a new query must start from page 0, or the first + // response lands at a stale offset and the list looks empty. + // biome-ignore lint/correctness/useExhaustiveDependencies: debouncedSearch is the trigger, not a value the effect reads + useEffect(() => { + setPage(0); + }, [debouncedSearch]); + + // A new scope means new results, so keep the viewport at the top. Otherwise + // you stay scrolled where the old, longer list had you and land in the + // middle of the new one — or past its end, looking at nothing. + // biome-ignore lint/correctness/useExhaustiveDependencies: these are triggers, not values the effect reads + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [debouncedSearch, folderSelection]); + // Reset to page 0 when deletions, imports, or generation completions occur const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size); const prevPendingCountRef = useRef(pendingCount); @@ -430,9 +490,35 @@ export function HistoryTable() { return (
+ {/* Rendered outside the empty-state branch below: filtering to an empty + folder must not remove the only control that can clear the filter. */} + + + {/* Also outside the empty-state branch, for the same reason as the tree: + a search that matches nothing must not hide its own input. */} + + {history.length === 0 ? (
- {t('history.empty')} + {/* Only claim "nothing here" once something has actually loaded. + On the very first fetch there is no previous page to keep, so + without this the empty state flashes before the rows arrive. */} + {isLoading + ? '' + : debouncedSearch.trim() + ? t('history.emptySearch') + : folderSelection.kind === 'all' + ? t('history.empty') + : t('folders.clip.emptyFilter')}
) : ( <> @@ -459,8 +545,12 @@ export function HistoryTable() {
{history.map((gen) => { @@ -477,10 +567,22 @@ export function HistoryTable() {
+ {/* Drag handle — the row body is interactive, and browsers + won't reliably start a native drag from inside a button. */} + setFolderDragData(e, { kind: 'generation', id: gen.id })} + title={t('history.dragHandle')} + aria-label={t('history.dragHandle')} + className="absolute left-0 top-0 bottom-0 z-10 flex w-4 cursor-grab items-center justify-center text-muted-foreground/30 opacity-0 transition-opacity hover:text-foreground active:cursor-grabbing group-hover/clip:opacity-100" + > + + + {/* Main row */}
{t('history.actions.regenerate')} + + + + {t('folders.clip.moveTo')} + + + {t('folders.clip.label')} + + + moveToFolder(gen.id, null) + } + > + {t('folders.uncategorised')} + + {(clipFolders ?? []).map((folder) => ( + + moveToFolder(gen.id, folder.id) + } + > + {folder.name} + + ))} + {(clipFolders ?? []).length === 0 && ( + {t('folders.none')} + )} + + + handleDeleteClick(gen.id, gen.profile_name)} disabled={deleteGeneration.isPending} diff --git a/app/src/components/ListPane.tsx b/app/src/components/ListPane.tsx index caeaa600f..5b44419a5 100644 --- a/app/src/components/ListPane.tsx +++ b/app/src/components/ListPane.tsx @@ -1,4 +1,6 @@ +import { X } from 'lucide-react'; import type { CSSProperties, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils/cn'; @@ -69,14 +71,36 @@ interface ListPaneSearchProps { } export function ListPaneSearch({ value, onChange, placeholder, className }: ListPaneSearchProps) { + const { t } = useTranslation(); return (
onChange(e.target.value)} - className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" + // Escape clears without reaching for the mouse; the button is the + // discoverable equivalent. + onKeyDown={(e) => { + if (e.key === 'Escape' && value) { + e.preventDefault(); + onChange(''); + } + }} + className={cn( + 'h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0', + value && 'pr-9', + )} /> + {value && ( + + )}
); } diff --git a/app/src/components/StoriesTab/StoryChatItem.tsx b/app/src/components/StoriesTab/StoryChatItem.tsx index b048cf9e5..4859bd626 100644 --- a/app/src/components/StoriesTab/StoryChatItem.tsx +++ b/app/src/components/StoriesTab/StoryChatItem.tsx @@ -13,8 +13,8 @@ import { import { Textarea } from '@/components/ui/textarea'; import type { StoryItemDetail } from '@/lib/api/types'; import { cn } from '@/lib/utils/cn'; -import { useStoryStore } from '@/stores/storyStore'; import { useServerStore } from '@/stores/serverStore'; +import { useStoryStore } from '@/stores/storyStore'; interface StoryChatItemProps { item: StoryItemDetail; diff --git a/app/src/components/StoriesTab/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx index 3f19d81d5..a4bebbf31 100644 --- a/app/src/components/StoriesTab/StoryContent.tsx +++ b/app/src/components/StoriesTab/StoryContent.tsx @@ -20,11 +20,21 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Loader from 'react-loaders'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; +import type { ExportAudioFormat } from '@/lib/api/types'; import { useHistory } from '@/lib/hooks/useHistory'; +import { useServerHealth } from '@/lib/hooks/useServer'; import { useAddStoryItem, useExportStoryAudio, @@ -37,6 +47,15 @@ import { useGenerationStore } from '@/stores/generationStore'; import { useStoryStore } from '@/stores/storyStore'; import { SortableStoryChatItem } from './StoryChatItem'; +/** Containers the bundled libsndfile writes; none of them need ffmpeg. */ +const EXPORT_FORMATS: { value: ExportAudioFormat; label: string }[] = [ + { value: 'wav', label: 'WAV — lossless' }, + { value: 'mp3', label: 'MP3 — widely compatible' }, + { value: 'ogg', label: 'OGG Vorbis' }, + { value: 'opus', label: 'Opus — smallest' }, + { value: 'flac', label: 'FLAC — lossless, compressed' }, +]; + export function StoryContent() { const { t } = useTranslation(); const selectedStoryId = useStoryStore((state) => state.selectedStoryId); @@ -45,6 +64,7 @@ export function StoryContent() { const reorderItems = useReorderStoryItems(); const exportAudio = useExportStoryAudio(); const addStoryItem = useAddStoryItem(); + const { data: health } = useServerHealth(); const { toast } = useToast(); const scrollRef = useRef(null); const importInputRef = useRef(null); @@ -213,13 +233,15 @@ export function StoryContent() { ); }; - const handleExportAudio = () => { + const handleExportAudio = (format: ExportAudioFormat = 'wav', normalizeLoudness = false) => { if (!story) return; exportAudio.mutate( { storyId: story.id, storyName: story.name, + format, + normalizeLoudness, }, { onError: (error) => { @@ -446,15 +468,35 @@ export function StoryContent() { {story.items.length > 0 && ( - + + + + + + {t('storyContent.export.format')} + + {EXPORT_FORMATS.map(({ value, label }) => ( + handleExportAudio(value)}> + {label} + + ))} + + {t('storyContent.export.mastering')} + handleExportAudio('mp3', true)} + > + {health?.ffmpeg_available + ? t('storyContent.export.normalized') + : t('storyContent.export.normalizedUnavailable')} + + + )}
diff --git a/app/src/components/StoriesTab/StoryList.tsx b/app/src/components/StoriesTab/StoryList.tsx index 48489a2ee..baaed825f 100644 --- a/app/src/components/StoriesTab/StoryList.tsx +++ b/app/src/components/StoriesTab/StoryList.tsx @@ -1,6 +1,24 @@ -import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { + BookOpen, + FolderInput, + GripVertical, + MoreHorizontal, + Pencil, + Plus, + Trash2, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { type ClipFolderSelection, ClipFolderTree } from '@/components/History/ClipFolderTree'; +import { + ListPane, + ListPaneActions, + ListPaneHeader, + ListPaneScroll, + ListPaneSearch, + ListPaneTitle, + ListPaneTitleRow, +} from '@/components/ListPane'; import { AlertDialog, AlertDialogAction, @@ -25,21 +43,16 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { - ListPane, - ListPaneActions, - ListPaneHeader, - ListPaneScroll, - ListPaneSearch, - ListPaneTitle, - ListPaneTitleRow, -} from '@/components/ListPane'; import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; +import { useFolders, useSetStoryFolder } from '@/lib/hooks/useFolders'; import { useCreateStory, useDeleteStory, @@ -48,12 +61,40 @@ import { useUpdateStory, } from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; +import { setFolderDragData } from '@/lib/utils/folderDrag'; import { formatDate } from '@/lib/utils/format'; import { useStoryStore } from '@/stores/storyStore'; export function StoryList() { const { t } = useTranslation(); const { data: stories, isLoading } = useStories(); + const [folderSelection, setFolderSelection] = useState({ kind: 'all' }); + const { data: storyFolders } = useFolders('story'); + const setStoryFolder = useSetStoryFolder(); + + // Selecting a parent folder should show everything beneath it, matching how + // the clip panel rolls a subtree up. Stories carry only their own folder id, + // so expand the selection to the whole subtree here. + const folderIdsInSubtree = useCallback( + (rootId: string) => { + const all = storyFolders ?? []; + const wanted = new Set([rootId]); + // Repeat until nothing new is added: the list is flat and unordered, so + // a single pass could miss grandchildren listed before their parents. + let grew = true; + while (grew) { + grew = false; + for (const f of all) { + if (f.parent_id && wanted.has(f.parent_id) && !wanted.has(f.id)) { + wanted.add(f.id); + grew = true; + } + } + } + return wanted; + }, + [storyFolders], + ); const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId); const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); @@ -193,14 +234,23 @@ export function StoryList() { const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0; const filtered = useMemo(() => { + // Folder first, then text — the folder is a scope, the search runs inside it. + let scoped = storyList; + if (folderSelection.kind === 'uncategorised') { + scoped = scoped.filter((s) => !s.folder_id); + } else if (folderSelection.kind === 'folder') { + const wanted = folderIdsInSubtree(folderSelection.folderId); + scoped = scoped.filter((s) => s.folder_id && wanted.has(s.folder_id)); + } + const q = search.trim().toLowerCase(); - if (!q) return storyList; - return storyList.filter((s) => { + if (!q) return scoped; + return scoped.filter((s) => { const name = (s.name || '').toLowerCase(); const description = (s.description || '').toLowerCase(); return name.includes(q) || description.includes(q); }); - }, [search, storyList]); + }, [search, storyList, folderSelection, folderIdsInSubtree]); if (isLoading) { return ( @@ -232,6 +282,21 @@ export function StoryList() { + {/* Outside the empty-state branches below: filtering to an empty + folder must not remove the only control that can clear the filter. */} + {storyList.length > 0 && ( +
+ setStoryFolder.mutate({ storyId, folderId })} + /> +
+ )} + {storyList.length === 0 ? (
@@ -245,9 +310,22 @@ export function StoryList() { ) : (
{filtered.map((story) => { + const storyFolderId = story.folder_id ?? null; const isActive = selectedStoryId === story.id; return (
+ {/* Drag handle — the row body is a button, which browsers + won't reliably start a native drag from. */} + setFolderDragData(e, { kind: 'story', id: story.id })} + title={t('stories.dragHandle')} + aria-label={t('stories.dragHandle')} + className="absolute left-0 top-0 bottom-0 z-10 flex w-4 cursor-grab items-center justify-center text-muted-foreground/30 opacity-0 transition-opacity hover:text-foreground active:cursor-grabbing group-hover:opacity-100" + > + + + + + +
+ Fade in + {localIn} ms +
+ setLocalIn(v)} + onValueCommit={([v]) => onChange(v, localOut)} + min={0} + max={5000} + step={50} + aria-label="Fade in" + /> +
+ Fade out + {localOut} ms +
+ setLocalOut(v)} + onValueCommit={([v]) => onChange(localIn, v)} + min={0} + max={5000} + step={50} + aria-label="Fade out" + /> +

+ Fades longer than the clip are scaled down together, so the clip never re-brightens in the + middle. +

+
+ + ); +} + +// Per-clip speed popover. Changing speed changes the clip's own length; because +// clips are absolutely positioned, neighbours don't move. +function ClipSpeedPopover({ + storyId, + itemId, + speed, + onChange, +}: { + storyId: string; + itemId: string; + speed: number; + onChange: (value: number) => void; +}) { + const [localSpeed, setLocalSpeed] = useState(speed); + + // Re-sync when the selected clip changes or the persisted value updates + // out-of-band (split carries the speed forward to both halves). + // biome-ignore lint/correctness/useExhaustiveDependencies: itemId/storyId are re-sync triggers, not values the effect reads + useEffect(() => { + setLocalSpeed(speed); + }, [speed, itemId, storyId]); + + return ( + + + + + +
+ Speed + {localSpeed.toFixed(2)}x +
+ setLocalSpeed(v / 100)} + onValueCommit={([v]) => onChange(v / 100)} + min={50} + max={200} + step={5} + aria-label="Clip speed" + /> +
+ 0.5x + 1x + 2x +
+

+ Pitch is preserved. Faster clips get shorter without moving the clips around them. +

+
+
+ ); +} + // Per-clip volume popover. Local state drives the slider during a drag so // each pointer-move pixel doesn't fire a PATCH; commits on release. function ClipVolumePopover({ @@ -204,7 +364,11 @@ interface StoryTrackEditorProps { const TRACK_HEIGHT = 48; const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px const SCRUB_BAR_HEIGHT = 16; -const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px +// Wide enough for the per-lane strip: import, remove, mute, solo, volume, +// duck and the percentage readout. The label cell is sized from this constant +// rather than a Tailwind width so the timeline's coordinate maths can never +// drift from what is rendered. +const LABEL_COL_WIDTH = 246; // Zoom is expressed to the user as how many seconds of timeline are visible // at once. Min scope = the most you can zoom IN; max scope = the entire // project. Default scope is what we land on when the editor first measures. @@ -214,11 +378,22 @@ const FALLBACK_PIXELS_PER_SECOND = 50; // used until containerWidth is measured const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks const MIN_EDITOR_HEIGHT = 120; const MAX_EDITOR_HEIGHT = 500; +// How far the pointer must travel before a press on a clip becomes a drag. +// Below this it stays a click, so selecting a clip can never move it. +const DRAG_THRESHOLD_PX = 4; +// Snap radius, in screen pixels rather than milliseconds so the feel stays the +// same at every zoom level. +const SNAP_TOLERANCE_PX = 8; export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const [pixelsPerSecond, setPixelsPerSecond] = useState(FALLBACK_PIXELS_PER_SECOND); const hasAppliedDefaultZoomRef = useRef(false); const [draggingItem, setDraggingItem] = useState(null); + // Press that may or may not become a drag. The ref carries the coordinates + // so the threshold check reads them without waiting for a re-render; the + // state exists purely to attach the move/up handlers on that same press. + const pendingDragRef = useRef<{ itemId: string; startX: number; startY: number } | null>(null); + const [pressedItemId, setPressedItemId] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const [isResizing, setIsResizing] = useState(false); @@ -234,12 +409,38 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const removeItem = useRemoveStoryItem(); const setItemVersion = useSetStoryItemVersion(); const updateVolume = useUpdateStoryItemVolume(); + const updateFades = useUpdateStoryItemFades(); + const updateSpeed = useUpdateStoryItemSpeed(); + const { data: storyTracks } = useStoryTracks(storyId); + const upsertTrack = useUpsertStoryTrack(); + const deleteTrack = useDeleteStoryTrack(); + const addItem = useAddStoryItem(); const { toast } = useToast(); + + // Lanes without a row mix at unity gain, so this map is usually sparse. + const trackSettings = useMemo( + () => new Map((storyTracks ?? []).map((t) => [t.index, t])), + [storyTracks], + ); + // Solo is a property of the whole story, matching the mixer: one soloed + // lane silences the rest, including lanes with no settings row. + const anySoloed = useMemo(() => (storyTracks ?? []).some((t) => t.soloed), [storyTracks]); const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration); // User-added empty tracks. Live in component state because a track only // earns its keep once a clip lands on it — no need to persist an unused // row across reloads. const [extraTracks, setExtraTracks] = useState([]); + // Snap dragged clips flush to their neighbours. On by default because + // butt-joining by eye is the common case; hold it off for free placement. + const [snapEnabled, setSnapEnabled] = useState(true); + // Ripple: moving a clip carries everything later on its track. Off by + // default because it rewrites clips the user didn't touch. + const [rippleEnabled, setRippleEnabled] = useState(false); + // Right-click target: set an exact length or speed rather than dragging for + // it. The two are two views of one number, so the dialog shows both. + const [lengthTargetId, setLengthTargetId] = useState(null); + const [lengthDraft, setLengthDraft] = useState(''); + const [speedDraft, setSpeedDraft] = useState(''); // Selection state const selectedClipId = useStoryStore((state) => state.selectedClipId); @@ -322,7 +523,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { ...items.map((item) => { const trimStart = item.trim_start_ms || 0; const trimEnd = item.trim_end_ms || 0; - const effectiveDuration = item.duration * 1000 - trimStart - trimEnd; + // Same formula as getEffectiveDuration below — speed included, so a + // re-timed clip doesn't leave the story's length overstated. + const effectiveDuration = + (item.duration * 1000 - trimStart - trimEnd) / (item.speed || 1); return item.start_time_ms + effectiveDuration; }), 0, @@ -376,6 +580,50 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { }); }, [items]); + /** Whether a lane can be removed: only ever the empty, non-default ones, so + * removing a lane can never take clips with it. */ + const canRemoveTrack = useCallback( + (trackNumber: number) => + !DEFAULT_TRACKS.includes(trackNumber) && !items.some((i) => i.track === trackNumber), + [items], + ); + + const handleRemoveTrack = useCallback( + (trackNumber: number) => { + setExtraTracks((prev) => prev.filter((t) => t !== trackNumber)); + // Drop any mixer settings for the lane too, so a later lane reusing the + // index doesn't silently inherit a mute or a duck target. + if (trackSettings.has(trackNumber)) { + deleteTrack.mutate({ storyId, index: trackNumber }); + } + }, + [trackSettings, deleteTrack, storyId], + ); + + /** Import an audio file straight onto a specific lane at the playhead. */ + const handleImportToTrack = useCallback( + async (trackNumber: number, file: File) => { + try { + const generation = await apiClient.importAudio(file); + await addItem.mutateAsync({ + storyId, + data: { + generation_id: generation.id, + track: trackNumber, + start_time_ms: Math.max(0, Math.round(currentTimeMs)), + }, + }); + } catch (error) { + toast({ + title: 'Import failed', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + } + }, + [storyId, addItem, currentTimeMs, toast], + ); + // Track container width for full-width minimum useEffect(() => { const container = tracksRef.current; @@ -432,10 +680,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { return () => ro.disconnect(); }, []); - // Calculate effective duration (accounting for trims) - const getEffectiveDuration = (item: StoryItemDetail) => { - return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0); - }; + // Calculate effective duration (accounting for trims and speed). + // Must match the mixer's formula in services/stories.py, or the timeline + // will draw a re-timed clip at the wrong length and the playhead will drift. + const getEffectiveDuration = useCallback((item: StoryItemDetail) => { + const trimmed = item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0); + return trimmed / (item.speed || 1); + }, []); // Calculate total duration (using effective durations) const totalDurationMs = useMemo(() => { @@ -466,7 +717,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { useEffect(() => { if (hasAppliedDefaultZoomRef.current) return; if (visibleTrackWidth <= 0) return; - const defaultScope = Math.min(DEFAULT_VISIBLE_SECONDS, Math.max(projectSeconds, MIN_VISIBLE_SECONDS)); + const defaultScope = Math.min( + DEFAULT_VISIBLE_SECONDS, + Math.max(projectSeconds, MIN_VISIBLE_SECONDS), + ); setPixelsPerSecond(visibleTrackWidth / defaultScope); hasAppliedDefaultZoomRef.current = true; }, [visibleTrackWidth, projectSeconds]); @@ -864,24 +1118,40 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { tracksRef.current.scrollLeft - LABEL_COL_WIDTH, // Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container - y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT, + y: + rect.top - + tracksRef.current.getBoundingClientRect().top + + tracksRef.current.scrollTop - + TIME_RULER_HEIGHT, }); - setDraggingItem(item.id); + // Arm the drag, but don't enter drag mode yet — see handleDragMove. A + // plain click must select the clip and nothing else. + pendingDragRef.current = { itemId: item.id, startX: e.clientX, startY: e.clientY }; + setPressedItemId(item.id); }; const handleDragMove = useCallback( (e: React.MouseEvent) => { - if (!draggingItem || !tracksRef.current) return; + if (!tracksRef.current) return; + + // Promote an armed press into a real drag only once the pointer has + // travelled far enough. Without this, mousedown alone entered drag mode + // and mouseup committed a move, so simply clicking a clip could drop it + // on a neighbouring track — selection was destructive. + const pending = pendingDragRef.current; + if (pending && !draggingItem) { + const travelled = Math.hypot(e.clientX - pending.startX, e.clientY - pending.startY); + if (travelled < DRAG_THRESHOLD_PX) return; + setDraggingItem(pending.itemId); + } + if (!pendingDragRef.current) return; const rect = tracksRef.current.getBoundingClientRect(); const x = - e.clientX - - rect.left + - tracksRef.current.scrollLeft - - dragOffset.x - - LABEL_COL_WIDTH; + e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x - LABEL_COL_WIDTH; // Subtract ruler height since clips are positioned relative to tracks area - const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT; + const y = + e.clientY - rect.top + tracksRef.current.scrollTop - dragOffset.y - TIME_RULER_HEIGHT; setDragPosition({ x: Math.max(0, x), y }); }, @@ -889,6 +1159,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { ); const handleDragEnd = useCallback(() => { + // A press that never crossed the threshold was a click, not a drag: clear + // the arming and commit nothing. + pendingDragRef.current = null; + setPressedItemId(null); + if (!draggingItem || !tracksRef.current) { setDraggingItem(null); return; @@ -901,38 +1176,94 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { } // Calculate new time from x position - const newTimeMs = Math.max(0, Math.round(pixelsToMs(dragPosition.x))); + const rawTimeMs = Math.max(0, Math.round(pixelsToMs(dragPosition.x))); // Calculate new track from y position const trackIndex = Math.floor(dragPosition.y / TRACK_HEIGHT); const clampedTrackIndex = Math.max(0, Math.min(trackIndex, tracks.length - 1)); const newTrack = tracks[clampedTrackIndex] ?? 0; + // Snap flush against a neighbour on the destination track. Butt-joining + // clips by eye is fiddly at any zoom, and a few ms of silence between two + // lines is audible. Snap distance is in pixels so it stays consistent as + // you zoom rather than getting stickier the further out you go. + const duration = getEffectiveDuration(item); + const newTimeMs = snapEnabled + ? (() => { + const toleranceMs = pixelsToMs(SNAP_TOLERANCE_PX); + const edges: number[] = [0]; + for (const other of items) { + if (other.id === item.id || other.track !== newTrack) continue; + edges.push(other.start_time_ms + getEffectiveDuration(other)); // our start to their end + edges.push(Math.max(0, other.start_time_ms - duration)); // our end to their start + } + let best = rawTimeMs; + let bestGap = toleranceMs; + for (const edge of edges) { + const gap = Math.abs(edge - rawTimeMs); + if (gap <= bestGap) { + best = edge; + bestGap = gap; + } + } + return Math.max(0, Math.round(best)); + })() + : rawTimeMs; + // Check if position changed if (newTimeMs !== item.start_time_ms || newTrack !== item.track) { + const onError = (error: unknown) => { + toast({ + title: 'Failed to move item', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + }; + moveItem.mutate( - { - storyId, - itemId: item.id, - data: { - start_time_ms: newTimeMs, - track: newTrack, - }, - }, - { - onError: (error) => { - toast({ - title: 'Failed to move item', - description: error instanceof Error ? error.message : String(error), - variant: 'destructive', - }); - }, - }, + { storyId, itemId: item.id, data: { start_time_ms: newTimeMs, track: newTrack } }, + { onError }, ); + + // Ripple: carry everything that started after this clip on its original + // track by the same delta, so inserting an intro pushes the rest of the + // track along instead of leaving a hole or an overlap. + if (rippleEnabled && newTrack === item.track) { + const delta = newTimeMs - item.start_time_ms; + if (delta !== 0) { + for (const other of items) { + if (other.id === item.id || other.track !== item.track) continue; + if (other.start_time_ms < item.start_time_ms) continue; + moveItem.mutate( + { + storyId, + itemId: other.id, + data: { + start_time_ms: Math.max(0, other.start_time_ms + delta), + track: other.track, + }, + }, + { onError }, + ); + } + } + } } setDraggingItem(null); - }, [draggingItem, dragPosition, items, tracks, pixelsToMs, storyId, moveItem, toast]); + }, [ + draggingItem, + dragPosition, + items, + tracks, + pixelsToMs, + storyId, + moveItem, + toast, + snapEnabled, + rippleEnabled, + getEffectiveDuration, + ]); // Get track index for rendering const getTrackIndex = (trackNumber: number) => tracks.indexOf(trackNumber); @@ -1032,8 +1363,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { // Recompute the thumb width that corresponded to the drag start, then // apply the mouse delta to the dragged edge. - const startTimelinePx = - (totalDurationMs / 1000) * drag.startPixelsPerSecond + 200; + const startTimelinePx = (totalDurationMs / 1000) * drag.startPixelsPerSecond + 200; const startThumbWidth = Math.max( 30, Math.min(scrollbarTrackWidth, (containerWidth / startTimelinePx) * scrollbarTrackWidth), @@ -1058,8 +1388,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { } : { type: 'right', - timeMs: - ((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000, + timeMs: ((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000, }; setPixelsPerSecond(newPps); @@ -1074,7 +1403,15 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; - }, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs, minPps, maxPps]); + }, [ + maxTimelineScroll, + thumbRange, + scrollbarTrackWidth, + containerWidth, + totalDurationMs, + minPps, + maxPps, + ]); if (items.length === 0) { return null; @@ -1126,6 +1463,34 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Clip editing controls - center */} + + + + {selectedClipId && (
- {/* Timeline scroll container */} + {/* Timeline scroll container. + + The drag handlers below attach while a press is merely *armed* as + well as while a drag is live. Listening only on draggingItem + deadlocked: the threshold that promotes a press into a drag lives + in handleDragMove, which could never run because the handler was + not attached yet. */} {/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
{/* Ruler row: corner spacer + time ruler, sticky to top */}
-
+
+ )} + n !== trackNumber)} + anySoloed={anySoloed} + onChange={(patch) => + upsertTrack.mutate({ + storyId, + index: trackNumber, + data: { + name: trackSettings.get(trackNumber)?.name ?? null, + volume: patch.volume ?? 1, + muted: patch.muted ?? false, + soloed: patch.soloed ?? false, + duck_under_track: patch.duck_under_track ?? null, + }, + }) + } + /> +
{isFirst && (
{/* Horizontal timeline scrollbar + zoom handles */} -
-
-
+
+
+
+ + setLengthTargetId(null)}> + + + Clip timing + + + {/* Length and speed are two views of the same number: editing one + recomputes the other, and only the resulting speed is stored. */} +
+
+ Target length +
+ { + setLengthDraft(e.target.value); + const target = Number.parseFloat(e.target.value); + const clip = items.find((i) => i.id === lengthTargetId); + if (clip && target > 0) { + const natural = + clip.duration * 1000 - (clip.trim_start_ms || 0) - (clip.trim_end_ms || 0); + setSpeedDraft((natural / 1000 / target).toFixed(2)); + } + }} + className="h-8" + inputMode="decimal" + aria-label="Target length in seconds" + /> + s +
+
+ +
+ Speed +
+ { + setSpeedDraft(e.target.value); + const rate = Number.parseFloat(e.target.value); + const clip = items.find((i) => i.id === lengthTargetId); + if (clip && rate > 0) { + const natural = + clip.duration * 1000 - (clip.trim_start_ms || 0) - (clip.trim_end_ms || 0); + setLengthDraft((natural / 1000 / rate).toFixed(2)); + } + }} + className="h-8" + inputMode="decimal" + aria-label="Speed multiplier" + /> + x +
+
+

+ Pitch is preserved. 0.25x to 4x; outside that the stretch smears speech. +

+
+ + + + + +
+
); } diff --git a/app/src/components/StoriesTab/TrackMixerControls.tsx b/app/src/components/StoriesTab/TrackMixerControls.tsx new file mode 100644 index 000000000..d3348539b --- /dev/null +++ b/app/src/components/StoriesTab/TrackMixerControls.tsx @@ -0,0 +1,145 @@ +import { Headphones, VolumeX, Waves } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Slider } from '@/components/ui/slider'; +import type { StoryTrackResponse } from '@/lib/api/types'; +import { cn } from '@/lib/utils/cn'; + +interface TrackMixerControlsProps { + /** Lane index. Lanes are sparse integers and may be negative. */ + index: number; + /** Undefined when the lane has no settings row — it mixes at unity gain. */ + track?: StoryTrackResponse; + /** Other lane indices, for the duck-under menu. */ + otherTracks: number[]; + /** True when any lane in the story is soloed, so this one may be implicitly silent. */ + anySoloed: boolean; + onChange: (patch: Partial) => void; +} + +const DEFAULTS = { volume: 1, muted: false, soloed: false, duck_under_track: null } as const; + +/** + * Mute / solo / volume / ducking for one timeline lane. + * + * A lane without a settings row is not a special case — it renders these same + * controls at their defaults and creates the row on first change. That mirrors + * the mixer, which treats a missing row as defaults rather than as exempt. + */ +export function TrackMixerControls({ + index, + track, + otherTracks, + anySoloed, + onChange, +}: TrackMixerControlsProps) { + const { t } = useTranslation(); + + const volume = track?.volume ?? DEFAULTS.volume; + const muted = track?.muted ?? DEFAULTS.muted; + const soloed = track?.soloed ?? DEFAULTS.soloed; + const duckUnder = track?.duck_under_track ?? DEFAULTS.duck_under_track; + + // Silent because something *else* is soloed — worth showing differently from + // an explicit mute, so the user knows why a lane went quiet. + const dimmedBySolo = anySoloed && !soloed && !muted; + + // The slider drives local state while dragging and only persists on + // release. Writing on every step fired dozens of PUTs per drag, each + // invalidating the track query, and out-of-order responses snapped the + // thumb backwards mid-gesture. Same approach as ClipVolumePopover. + const [localVolume, setLocalVolume] = useState(volume); + // Re-sync when the persisted value changes from elsewhere, or when this + // row is reused for a different lane. + useEffect(() => { + setLocalVolume(volume); + }, [volume]); + + const patch = (changes: Partial) => + onChange({ volume, muted, soloed, duck_under_track: duckUnder, ...changes }); + + return ( +
+ + + + + setLocalVolume(next)} + onValueCommit={([next]) => patch({ volume: next })} + /> + + + + + + + {t('storyTracks.duckUnder')} + + patch({ duck_under_track: null })} + > + {t('storyTracks.duckOff')} + + {otherTracks.map((other) => ( + patch({ duck_under_track: other })} + > + {t('storyTracks.trackNumber', { index: other })} + + ))} + {otherTracks.length === 0 && ( + {t('storyTracks.noOtherTracks')} + )} + + + + + {Math.round(volume * 100)}% + +
+ ); +} diff --git a/app/src/components/VoiceProfiles/FolderSection.tsx b/app/src/components/VoiceProfiles/FolderSection.tsx new file mode 100644 index 000000000..798fb6aa4 --- /dev/null +++ b/app/src/components/VoiceProfiles/FolderSection.tsx @@ -0,0 +1,217 @@ +import { ChevronDown, ChevronRight, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils/cn'; +import { isFolderDrag, readFolderDragData } from '@/lib/utils/folderDrag'; + +interface FolderSectionProps { + /** Null renders the Uncategorised bucket, which has no menu and no id. */ + folderId: string | null; + name: string; + count: number; + collapsed: boolean; + onToggle: () => void; + onRename?: (name: string) => void; + onDelete?: () => void; + /** Called when an item is dropped on this header. */ + onDropItem?: (itemId: string) => void; + children: React.ReactNode; +} + +/** + * A collapsible group header with its members underneath. + * + * The delete copy is explicit that only the folder goes — the server + * releases members to Uncategorised rather than cascading, and a header + * that just says "Delete" over a group of voices reads far more alarming + * than what actually happens. + */ +export function FolderSection({ + folderId, + name, + count, + collapsed, + onToggle, + onRename, + onDelete, + onDropItem, + children, +}: FolderSectionProps) { + const { t } = useTranslation(); + const [renameOpen, setRenameOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [draftName, setDraftName] = useState(name); + const [dragOver, setDragOver] = useState(false); + + const Chevron = collapsed ? ChevronRight : ChevronDown; + + const submitRename = () => { + const trimmed = draftName.trim(); + if (trimmed && trimmed !== name) onRename?.(trimmed); + setRenameOpen(false); + }; + + return ( +
+ {/* Shaded and bold so the header reads as a container rather than + blending into the rows it holds. */} +
{ + // Both enter and over must preventDefault; some engines only treat + // an element as a drop target once enter has been cancelled. + if (!onDropItem || !isFolderDrag(e)) return; + e.preventDefault(); + setDragOver(true); + }} + onDragOver={(e) => { + if (!onDropItem || !isFolderDrag(e)) return; + // preventDefault is what marks this element as a valid drop target. + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOver(true); + }} + onDragLeave={(e) => { + // Fires again every time the pointer crosses into a child — the + // toggle button, the count badge — which makes the highlight flicker + // for the whole drag. Only a leave of the header itself counts. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setDragOver(false); + }} + onDrop={(e) => { + // Cancel first, decide second. onDragOver already accepted this + // element as a drop target, so returning early on an unwanted + // payload lets the browser default run — and in a webview a dropped + // file or URL then navigates the page away. + e.preventDefault(); + setDragOver(false); + const payload = readFolderDragData(e); + if (!payload || payload.kind !== 'voice') return; + onDropItem?.(payload.id); + }} + className={cn( + 'group/folder flex items-center gap-1 rounded-md border border-border/60 bg-muted/60 px-1 transition-colors', + dragOver && 'border-accent bg-accent/40 ring-1 ring-accent', + )} + > + + + {folderId && ( + + + + + + { + setDraftName(name); + setRenameOpen(true); + }} + > + + {t('folders.rename')} + + + setDeleteOpen(true)} + > + + {t('folders.delete')} + + + + )} +
+ + {/* Indent and rule the members so they read as belonging to the header + above rather than as a flat continuation of the list. */} + {!collapsed && ( +
{children}
+ )} + + + + + {t('folders.renameDialog.title')} + + setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitRename(); + }} + aria-label={t('folders.renameDialog.title')} + /> + + + + + + + + + + + {t('folders.deleteDialog.title')} + {t('folders.deleteDialog.body', { name })} + + + + + + + +
+ ); +} diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index e9042a571..0cdd04d07 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -1,6 +1,7 @@ -import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react'; +import { Copy, Download, Edit, Sparkles, Trash2, Volume2, Wand2 } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useToast } from '@/components/ui/use-toast'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -14,7 +15,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import type { VoiceProfileResponse } from '@/lib/api/types'; -import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles'; +import { useDeleteProfile, useDuplicateProfile, useExportProfile } from '@/lib/hooks/useProfiles'; import { cn } from '@/lib/utils/cn'; import { useUIStore } from '@/stores/uiStore'; @@ -27,14 +28,18 @@ const ENGINE_DISPLAY_NAMES: Record = { interface ProfileCardProps { profile: VoiceProfileResponse; disabled?: boolean; + /** Open the preview dialog for this voice. */ + onPreview?: (profile: VoiceProfileResponse) => void; } -export function ProfileCard({ profile, disabled }: ProfileCardProps) { +export function ProfileCard({ profile, disabled, onPreview }: ProfileCardProps) { const { t } = useTranslation(); + const { toast } = useToast(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const deleteProfile = useDeleteProfile(); const exportProfile = useExportProfile(); + const duplicateProfile = useDuplicateProfile(); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const selectedProfileId = useUIStore((state) => state.selectedProfileId); @@ -66,6 +71,29 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { setDeleteDialogOpen(false); }; + // Same feedback as the list view. Without the callbacks a failed + // duplicate did nothing at all — the button just went idle again. + const handleDuplicate = () => { + duplicateProfile.mutate( + { profileId: profile.id }, + { + onSuccess: (copy) => { + toast({ + title: t('profiles.duplicate.successTitle'), + description: t('profiles.duplicate.successDescription', { name: copy.name }), + }); + }, + onError: (error) => { + toast({ + title: t('profiles.duplicate.failedTitle'), + description: error.message, + variant: 'destructive', + }); + }, + }, + ); + }; + const handleExport = (e: React.MouseEvent) => { e.stopPropagation(); exportProfile.mutate(profile.id); @@ -126,11 +154,26 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { {profile.effects_chain && profile.effects_chain.length > 0 && ( )} - {profile.personality?.trim() && ( - - )} + {profile.personality?.trim() && }
+ { + e.stopPropagation(); + onPreview?.(profile); + }} + aria-label={t('profiles.preview.action')} + /> + { + e.stopPropagation(); + handleDuplicate(); + }} + disabled={duplicateProfile.isPending} + aria-label={t('profiles.card.duplicate')} + /> - - {t('profileForm.fields.personalityHint')} - + {t('profileForm.fields.personalityHint')} )} diff --git a/app/src/components/VoiceProfiles/ProfileList.tsx b/app/src/components/VoiceProfiles/ProfileList.tsx index 3bfad014f..9fe4a8a99 100644 --- a/app/src/components/VoiceProfiles/ProfileList.tsx +++ b/app/src/components/VoiceProfiles/ProfileList.tsx @@ -1,25 +1,80 @@ -import { Info, Mic, Sparkles } from 'lucide-react'; -import { useEffect, useRef } from 'react'; +import { FolderPlus, Info, LayoutGrid, List, Mic, Search, Sparkles } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import type { VoiceProfileResponse } from '@/lib/api/types'; +import { + useCreateFolder, + useDeleteFolder, + useFolders, + useSetProfileFolder, + useUpdateFolder, +} from '@/lib/hooks/useFolders'; import { useProfiles } from '@/lib/hooks/useProfiles'; +import { cn } from '@/lib/utils/cn'; import { useUIStore } from '@/stores/uiStore'; +import { FolderSection } from './FolderSection'; import { ProfileCard } from './ProfileCard'; import { ProfileForm } from './ProfileForm'; +import { ProfileRow } from './ProfileRow'; +import { VoicePreviewDialog } from './VoicePreviewDialog'; /** Engines that use preset (built-in) voices instead of cloned profiles. */ const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']); -export function ProfileList() { +/** Sentinel key for the Uncategorised bucket, which has no folder id. */ +const UNCATEGORISED = '__uncategorised__'; + +/** Fields a search query is matched against. Mirrors #1016. */ +const matchesQuery = (p: VoiceProfileResponse, q: string) => + p.name.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q) || + p.language.toLowerCase().includes(q) || + p.preset_engine?.toLowerCase().includes(q) || + p.default_engine?.toLowerCase().includes(q); + +interface ProfileListProps { + /** Active search query. Filtering happens before folder bucketing, so a + * match keeps its folder rather than collapsing into one flat list. */ + search?: string; + onClearSearch?: () => void; +} + +export function ProfileList({ search = '', onClearSearch }: ProfileListProps) { const { t } = useTranslation(); const { data: profiles, isLoading, error } = useProfiles(); + const { data: folders } = useFolders('voice'); + const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const selectedEngine = useUIStore((state) => state.selectedEngine); const selectedProfileId = useUIStore((state) => state.selectedProfileId); + const viewMode = useUIStore((state) => state.voiceViewMode); + const setViewMode = useUIStore((state) => state.setVoiceViewMode); + const collapsedIds = useUIStore((state) => state.collapsedFolderIds.voice ?? []); + const toggleCollapsed = useUIStore((state) => state.toggleFolderCollapsed); + + const setProfileFolder = useSetProfileFolder(); + const createFolder = useCreateFolder('voice'); + const updateFolder = useUpdateFolder('voice'); + const deleteFolder = useDeleteFolder('voice'); + + const [newFolderOpen, setNewFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [previewProfile, setPreviewProfile] = useState(null); + const cardRefs = useRef>(new Map()); // Scroll to the selected profile after engine/sort changes + // biome-ignore lint/correctness/useExhaustiveDependencies: selectedEngine reorders the list, so it must re-trigger the scroll even though the effect never reads it useEffect(() => { if (!selectedProfileId) return; let timeoutId: ReturnType | null = null; @@ -40,6 +95,62 @@ export function ProfileList() { }; }, [selectedProfileId, selectedEngine]); + const allProfiles = useMemo(() => profiles || [], [profiles]); + const voiceFolders = useMemo(() => folders || [], [folders]); + const isPresetEngine = PRESET_ENGINES.has(selectedEngine); + + /** Whether a profile is supported by the currently selected engine. */ + const isSupported = useMemo( + () => (p: VoiceProfileResponse) => + isPresetEngine + ? p.voice_type === 'preset' && p.preset_engine === selectedEngine + : p.voice_type !== 'preset', + [isPresetEngine, selectedEngine], + ); + + const query = search.trim().toLowerCase(); + + const visibleProfiles = useMemo( + () => (query ? allProfiles.filter((p) => matchesQuery(p, query)) : allProfiles), + [allProfiles, query], + ); + + // Sort so supported profiles come first, then bucket by folder. Sorting + // before grouping keeps the supported-first ordering inside each folder; + // filtering before bucketing keeps a match in the folder it belongs to. + const grouped = useMemo(() => { + const sorted = [...visibleProfiles].sort((a, b) => { + const supported = (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1); + if (supported !== 0) return supported; + // Alphabetical tiebreak, from #1016 — without it the order within a + // folder is whatever the API happened to return. + return a.name.localeCompare(b.name); + }); + + const buckets = new Map(); + buckets.set(UNCATEGORISED, []); + for (const folder of voiceFolders) buckets.set(folder.id, []); + + for (const profile of sorted) { + // A folder_id can outlive its folder if another window deleted it + // between renders — fall back rather than dropping the voice. + const key = + profile.folder_id && buckets.has(profile.folder_id) ? profile.folder_id : UNCATEGORISED; + buckets.get(key)?.push(profile); + } + return buckets; + }, [visibleProfiles, voiceFolders, isSupported]); + + const hasUnsupported = visibleProfiles.some((p) => !isSupported(p)); + + // A folder with no matches is noise while searching, but its header is how + // you drop a voice into it the rest of the time. + const searchableFolders = useMemo( + () => (query ? voiceFolders.filter((f) => (grouped.get(f.id)?.length ?? 0) > 0) : voiceFolders), + [voiceFolders, grouped, query], + ); + const showUncategorised = !query || (grouped.get(UNCATEGORISED)?.length ?? 0) > 0; + if (isLoading) { return null; } @@ -54,21 +165,57 @@ export function ProfileList() { ); } - const allProfiles = profiles || []; - const isPresetEngine = PRESET_ENGINES.has(selectedEngine); + const renderProfiles = (items: VoiceProfileResponse[]) => { + if (items.length === 0) { + return ( +

{t('folders.emptyFolder')}

+ ); + } - /** Whether a profile is supported by the currently selected engine. */ - const isSupported = (p: (typeof allProfiles)[number]) => - isPresetEngine - ? p.voice_type === 'preset' && p.preset_engine === selectedEngine - : p.voice_type !== 'preset'; - - // Sort so supported profiles come first - const sortedProfiles = [...allProfiles].sort( - (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), - ); + return ( +
+ {items.map((profile) => ( +
{ + if (el) cardRefs.current.set(profile.id, el); + else cardRefs.current.delete(profile.id); + }} + > + {viewMode === 'card' ? ( + + ) : ( + + )} +
+ ))} +
+ ); + }; - const hasUnsupported = sortedProfiles.some((p) => !isSupported(p)); + const submitNewFolder = () => { + const trimmed = newFolderName.trim(); + if (!trimmed) return; + createFolder.mutate({ name: trimmed }); + setNewFolderName(''); + setNewFolderOpen(false); + }; return (
@@ -85,21 +232,87 @@ export function ProfileList() { ) : ( -
- {sortedProfiles.map((profile) => ( -
{ - if (el) cardRefs.current.set(profile.id, el); - else cardRefs.current.delete(profile.id); - }} +
+
+
+ + {t('folders.new')} + + +
+ + {query && visibleProfiles.length === 0 && ( + + + +

+ {t('profiles.list.noVoicesMatch', { query: search.trim() })} +

+ {onClearSearch && ( + + )} +
+
+ )} + + {searchableFolders.map((folder) => ( + toggleCollapsed('voice', folder.id)} + onRename={(name) => updateFolder.mutate({ folderId: folder.id, data: { name } })} + onDelete={() => deleteFolder.mutate(folder.id)} + onDropItem={(profileId) => + setProfileFolder.mutate({ profileId, folderId: folder.id }) + } + > + {renderProfiles(grouped.get(folder.id) ?? [])} + ))} + + {/* Only worth a header once folders exist to contrast it with. */} + {!showUncategorised ? null : voiceFolders.length > 0 ? ( + toggleCollapsed('voice', UNCATEGORISED)} + onDropItem={(profileId) => setProfileFolder.mutate({ profileId, folderId: null })} + > + {renderProfiles(grouped.get(UNCATEGORISED) ?? [])} + + ) : ( + renderProfiles(grouped.get(UNCATEGORISED) ?? []) + )} + {hasUnsupported && ( -
+
{t('profiles.list.unsupportedNote')}
@@ -108,6 +321,36 @@ export function ProfileList() { )}
+ + + + {t('folders.newDialog.title')} + + setNewFolderName(e.target.value)} + placeholder={t('folders.newDialog.placeholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') submitNewFolder(); + }} + aria-label={t('folders.newDialog.title')} + /> + + + + + + + + !open && setPreviewProfile(null)} + /> +
); diff --git a/app/src/components/VoiceProfiles/ProfileRow.tsx b/app/src/components/VoiceProfiles/ProfileRow.tsx new file mode 100644 index 000000000..8788da641 --- /dev/null +++ b/app/src/components/VoiceProfiles/ProfileRow.tsx @@ -0,0 +1,297 @@ +import { + Copy, + Download, + Edit, + FolderInput, + GripVertical, + MoreHorizontal, + Sparkles, + Trash2, + Volume2, + Wand2, +} from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useToast } from '@/components/ui/use-toast'; +import type { FolderResponse, VoiceProfileResponse } from '@/lib/api/types'; +import { useSetProfileFolder } from '@/lib/hooks/useFolders'; +import { useDeleteProfile, useDuplicateProfile, useExportProfile } from '@/lib/hooks/useProfiles'; +import { cn } from '@/lib/utils/cn'; +import { setFolderDragData } from '@/lib/utils/folderDrag'; +import { useUIStore } from '@/stores/uiStore'; + +/** Human-readable display names for preset engine badges. */ +const ENGINE_DISPLAY_NAMES: Record = { + kokoro: 'Kokoro', + qwen_custom_voice: 'CustomVoice', +}; + +interface ProfileRowProps { + profile: VoiceProfileResponse; + /** Not usable by the selected engine — dimmed but still selectable. */ + disabled?: boolean; + /** Voice folders, for the "Move to" submenu. */ + folders: FolderResponse[]; + /** Open the preview dialog for this voice. */ + onPreview?: (profile: VoiceProfileResponse) => void; +} + +/** + * One voice as a two-line row: name, language and trait icons on the first + * line, description on the second. + * + * Row actions live behind a menu rather than always-visible buttons — + * at list density a row is ~48px tall, too tight for four icon buttons + * without crowding the text. + */ +export function ProfileRow({ profile, disabled, folders, onPreview }: ProfileRowProps) { + const { t } = useTranslation(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { toast } = useToast(); + + const deleteProfile = useDeleteProfile(); + const exportProfile = useExportProfile(); + const duplicateProfile = useDuplicateProfile(); + const setProfileFolder = useSetProfileFolder(); + + const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); + const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); + const selectedProfileId = useUIStore((state) => state.selectedProfileId); + const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId); + + const isSelected = selectedProfileId === profile.id; + + const handleSelect = () => { + // Re-selecting a disabled voice re-fires the selection so the generate + // form can surface its unsupported-engine hint again. + if (disabled && isSelected) { + setSelectedProfileId(null); + setTimeout(() => setSelectedProfileId(profile.id), 0); + return; + } + setSelectedProfileId(isSelected ? null : profile.id); + }; + + const handleDuplicate = () => { + duplicateProfile.mutate( + { profileId: profile.id }, + { + onSuccess: (copy) => { + toast({ + title: t('profiles.duplicate.successTitle'), + description: t('profiles.duplicate.successDescription', { name: copy.name }), + }); + }, + onError: (error) => { + toast({ + title: t('profiles.duplicate.failedTitle'), + description: error.message, + variant: 'destructive', + }); + }, + }, + ); + }; + + const selectLabel = t( + isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel', + { name: profile.name, language: profile.language }, + ); + + return ( + <> + {/* The row is a plain container rather than role="button": the actions + menu is itself a button, and nesting interactive elements is invalid. + The selectable area is a real + + + + + + e.stopPropagation()}> + onPreview?.(profile)}> + + {t('profiles.preview.action')} + + { + setEditingProfileId(profile.id); + setProfileDialogOpen(true); + }} + > + + {t('profiles.card.edit')} + + + + {t('profiles.card.duplicate')} + + + + + + {t('profiles.row.moveTo')} + + + {t('folders.voice.label')} + + setProfileFolder.mutate({ profileId: profile.id, folderId: null })} + > + {t('folders.uncategorised')} + + {folders.map((folder) => ( + + setProfileFolder.mutate({ profileId: profile.id, folderId: folder.id }) + } + > + {folder.name} + + ))} + {folders.length === 0 && ( + {t('folders.none')} + )} + + + + + exportProfile.mutate(profile.id)} + disabled={exportProfile.isPending} + > + + {t('profiles.card.export')} + + + setDeleteDialogOpen(true)} + > + + {t('profiles.card.delete')} + + + +
+ + + + + {t('profiles.deleteDialog.title')} + + {t('profiles.deleteDialog.body', { name: profile.name })} + + + + + + + + + + ); +} diff --git a/app/src/components/VoiceProfiles/SampleList.tsx b/app/src/components/VoiceProfiles/SampleList.tsx index 6c95355fc..0dc2602a9 100644 --- a/app/src/components/VoiceProfiles/SampleList.tsx +++ b/app/src/components/VoiceProfiles/SampleList.tsx @@ -24,7 +24,7 @@ interface MiniSamplePlayerProps { audioUrl: string; } -function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) { +export function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) { const { t } = useTranslation(); const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); diff --git a/app/src/components/VoiceProfiles/VoicePreviewDialog.tsx b/app/src/components/VoiceProfiles/VoicePreviewDialog.tsx new file mode 100644 index 000000000..b16d7c1bb --- /dev/null +++ b/app/src/components/VoiceProfiles/VoicePreviewDialog.tsx @@ -0,0 +1,82 @@ +import { Mic, Sparkles, Wand2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { apiClient } from '@/lib/api/client'; +import type { VoiceProfileResponse } from '@/lib/api/types'; +import { useProfileSamples } from '@/lib/hooks/useProfiles'; +import { MiniSamplePlayer } from './SampleList'; + +interface VoicePreviewDialogProps { + profile: VoiceProfileResponse | null; + onOpenChange: (open: boolean) => void; +} + +/** + * Hear a voice without leaving the Generate tab. + * + * Uses the same MiniSamplePlayer as the Voices tab rather than a second + * implementation, so playback behaves identically in both places. Read-only by + * design: this is for deciding which voice to use, not for editing it — the + * Voices tab still owns adding, retitling and deleting samples. + */ +export function VoicePreviewDialog({ profile, onOpenChange }: VoicePreviewDialogProps) { + const { t } = useTranslation(); + const { data: samples, isLoading, isError } = useProfileSamples(profile?.id ?? ''); + + return ( + + + + + {profile?.name} + {profile?.language && ( + + {profile.language} + + )} + {profile?.effects_chain && profile.effects_chain.length > 0 && ( + + )} + {profile?.personality?.trim() && } + + {profile?.description && {profile.description}} + + +
+ {isLoading ? ( +

{t('common.loading')}

+ ) : isError ? ( + // A failed request must not read as "this voice has no audio" — + // one is a problem to retry, the other is normal for presets. +

+ {t('profiles.preview.loadFailed')} +

+ ) : !samples || samples.length === 0 ? ( + // Preset and designed voices have no reference audio to play — + // say so rather than showing an empty box. +
+ +

{t('profiles.preview.noSamples')}

+
+ ) : ( + samples.map((sample) => ( +
+

+ {sample.reference_text} +

+ +
+ )) + )} +
+
+
+ ); +} diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 7f96d9d05..2575c5c56 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Cancel", + "clearSearch": "Clear search", "save": "Save", "delete": "Delete", "edit": "Edit", @@ -9,7 +10,8 @@ "loading": "Loading…", "error": "Error", "unknown": "Unknown", - "unknownError": "Unknown error" + "unknownError": "Unknown error", + "create": "Create" }, "nav": { "generate": "Generate", @@ -399,14 +401,35 @@ "export": "Export profile", "edit": "Edit profile", "delete": "Delete profile", + "duplicate": "Duplicate profile", "selectLabel": "{{name}}, {{language}}. Select as voice for generation.", "selectLabelSelected": "{{name}}, {{language}}. Selected as voice for generation." }, + "preview": { + "action": "Preview voice", + "loadFailed": "Couldn't load this voice's samples.", + "noSamples": "This voice has no reference audio to play. Preset and designed voices are generated on demand." + }, + "row": { + "actions": "Actions for {{name}}", + "dragHandle": "Drag into a folder", + "moveTo": "Move to folder", + "hasEffects": "Has an effects chain", + "hasPersonality": "Has a personality" + }, "list": { "errorLoading": "Error loading profiles: {{message}}", "empty": "No voice profiles yet. Create your first profile to get started.", "createVoice": "Create Voice", - "unsupportedNote": "Only supported voice profiles can be selected for the current model." + "noVoicesMatch": "No profiles match \"{{query}}\"", + "unsupportedNote": "Only supported voice profiles can be selected for the current model.", + "showCards": "Show voices as cards", + "showList": "Show voices as a list" + }, + "duplicate": { + "successTitle": "Voice duplicated", + "successDescription": "Created \"{{name}}\" with the same samples, personality and effects.", + "failedTitle": "Could not duplicate voice" }, "deleteDialog": { "title": "Delete Profile", @@ -414,6 +437,55 @@ "deleting": "Deleting…" } }, + "storyTracks": { + "muteTrack": "Mute track {{index}}", + "soloTrack": "Solo track {{index}}", + "volumeTrack": "Volume for track {{index}}", + "duck": "Ducking", + "duckUnder": "Duck under track", + "duckOff": "No ducking", + "trackNumber": "Track {{index}}", + "noOtherTracks": "No other tracks" + }, + "folders": { + "new": "New folder", + "none": "No folders yet", + "uncategorised": "Uncategorised", + "emptyFolder": "Empty", + "rename": "Rename", + "delete": "Delete folder", + "actions": "Actions for folder {{name}}", + "voice": { + "label": "Voice folders" + }, + "story": { + "label": "Story folders", + "moveTo": "Move to folder", + "filterTitle": "Story folders", + "allStories": "All stories" + }, + "clip": { + "label": "Clip folders", + "newSubfolder": "New subfolder", + "moveTo": "Move to folder", + "moveToRoot": "Move to top level", + "filterTitle": "Folders", + "allClips": "All clips", + "emptyFilter": "No clips in this folder yet." + }, + "newDialog": { + "title": "New folder", + "placeholder": "Folder name" + }, + "renameDialog": { + "title": "Rename folder" + }, + "deleteDialog": { + "title": "Delete folder", + "body": "Delete \"{{name}}\"? Everything inside it is kept — items move to Uncategorised and any subfolders move up one level.", + "confirm": "Delete folder" + } + }, "effects": { "title": "Effects", "newPreset": "New Preset", @@ -561,6 +633,7 @@ "stories": { "title": "Stories", "newStory": "New Story", + "dragHandle": "Drag into a folder", "loading": "Loading stories…", "searchPlaceholder": "Search stories…", "empty": { @@ -623,6 +696,12 @@ "searchNoMatches": "No matching generations found", "searchNoAvailable": "No available generations", "exportAudio": "Export Audio", + "export": { + "format": "Format", + "mastering": "Mastering", + "normalized": "MP3, loudness normalised", + "normalizedUnavailable": "Loudness normalising needs ffmpeg" + }, "empty": { "title": "No items in this story", "hint": "Generate speech using the box below to add items" @@ -646,6 +725,9 @@ }, "history": { "empty": "No voice generations, yet…", + "searchPlaceholder": "Search clips…", + "emptySearch": "No clips match that search.", + "dragHandle": "Drag into a folder", "actions": { "menu": "Actions", "play": "Play", diff --git a/app/src/i18n/locales/es/translation.json b/app/src/i18n/locales/es/translation.json index f1cbfd19f..21a679ba2 100644 --- a/app/src/i18n/locales/es/translation.json +++ b/app/src/i18n/locales/es/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Cancelar", + "clearSearch": "Borrar búsqueda", "save": "Guardar", "delete": "Eliminar", "edit": "Editar", @@ -406,6 +407,7 @@ "errorLoading": "Error al cargar los perfiles: {{message}}", "empty": "Aún no hay perfiles de voz. Crea tu primer perfil para empezar.", "createVoice": "Crear voz", + "noVoicesMatch": "Ningún perfil coincide con \"{{query}}\"", "unsupportedNote": "Solo se pueden seleccionar los perfiles de voz compatibles con el modelo actual." }, "deleteDialog": { @@ -645,7 +647,10 @@ } }, "history": { + "dragHandle": "Arrastrar a una carpeta", "empty": "Aún no hay generaciones de voz…", + "searchPlaceholder": "Buscar clips…", + "emptySearch": "Ningún clip coincide con esa búsqueda.", "actions": { "menu": "Acciones", "play": "Reproducir", diff --git a/app/src/i18n/locales/fr/translation.json b/app/src/i18n/locales/fr/translation.json index 8ce2aae60..b1569ce8c 100644 --- a/app/src/i18n/locales/fr/translation.json +++ b/app/src/i18n/locales/fr/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Annuler", + "clearSearch": "Effacer la recherche", "save": "Enregistrer", "delete": "Supprimer", "edit": "Modifier", @@ -406,6 +407,7 @@ "errorLoading": "Erreur lors du chargement des profils : {{message}}", "empty": "Encore aucun profil vocal. Créez votre premier profil pour commencer.", "createVoice": "Créer une voix", + "noVoicesMatch": "Aucun profil ne correspond à \"{{query}}\"", "unsupportedNote": "Seuls les profils vocaux pris en charge peuvent être sélectionnés pour le modèle actuel." }, "deleteDialog": { @@ -645,7 +647,10 @@ } }, "history": { + "dragHandle": "Faire glisser vers un dossier", "empty": "Encore aucune génération vocale…", + "searchPlaceholder": "Rechercher des clips…", + "emptySearch": "Aucun clip ne correspond à cette recherche.", "actions": { "menu": "Actions", "play": "Lire", diff --git a/app/src/i18n/locales/it/translation.json b/app/src/i18n/locales/it/translation.json index 5c21cb9d6..dc2b5016b 100644 --- a/app/src/i18n/locales/it/translation.json +++ b/app/src/i18n/locales/it/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Annulla", + "clearSearch": "Cancella ricerca", "save": "Salva", "delete": "Elimina", "edit": "Modifica", @@ -406,6 +407,7 @@ "errorLoading": "Errore durante il caricamento dei profili: {{message}}", "empty": "Ancora nessun profilo vocale. Crea il tuo primo profilo per iniziare.", "createVoice": "Crea voce", + "noVoicesMatch": "Nessun profilo corrisponde a \"{{query}}\"", "unsupportedNote": "È possibile selezionare solo i profili vocali supportati dal modello attuale." }, "deleteDialog": { @@ -645,7 +647,10 @@ } }, "history": { + "dragHandle": "Trascina in una cartella", "empty": "Ancora nessuna generazione vocale…", + "searchPlaceholder": "Cerca clip…", + "emptySearch": "Nessuna clip corrisponde a questa ricerca.", "actions": { "menu": "Azioni", "play": "Riproduci", diff --git a/app/src/i18n/locales/ja/translation.json b/app/src/i18n/locales/ja/translation.json index a7f0fd24d..adde9078a 100644 --- a/app/src/i18n/locales/ja/translation.json +++ b/app/src/i18n/locales/ja/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "キャンセル", + "clearSearch": "検索をクリア", "save": "保存", "delete": "削除", "edit": "編集", @@ -406,6 +407,7 @@ "errorLoading": "プロファイルの読み込みエラー:{{message}}", "empty": "ボイスプロファイルがまだありません。最初のプロファイルを作成して始めましょう。", "createVoice": "ボイスを作成", + "noVoicesMatch": "\"{{query}}\" に一致するプロファイルはありません", "unsupportedNote": "現在のモデルでは、対応しているボイスプロファイルのみ選択できます。" }, "deleteDialog": { @@ -645,7 +647,10 @@ } }, "history": { + "dragHandle": "フォルダーにドラッグ", "empty": "音声生成はまだありません…", + "searchPlaceholder": "クリップを検索…", + "emptySearch": "その検索に一致するクリップはありません。", "actions": { "menu": "操作", "play": "再生", diff --git a/app/src/i18n/locales/ko/translation.json b/app/src/i18n/locales/ko/translation.json index 94aa26fb0..8b4573ae6 100644 --- a/app/src/i18n/locales/ko/translation.json +++ b/app/src/i18n/locales/ko/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "취소", + "clearSearch": "검색 지우기", "save": "저장", "delete": "삭제", "edit": "편집", @@ -406,6 +407,7 @@ "errorLoading": "프로필 로딩 오류: {{message}}", "empty": "아직 음성 프로필이 없습니다. 첫 번째 프로필을 만들어 시작하세요.", "createVoice": "음성 만들기", + "noVoicesMatch": "\"{{query}}\"와(과) 일치하는 프로필이 없습니다", "unsupportedNote": "현재 모델에서 지원되는 음성 프로필만 선택할 수 있습니다." }, "deleteDialog": { @@ -645,7 +647,10 @@ } }, "history": { + "dragHandle": "폴더로 드래그", "empty": "아직 생성된 음성이 없습니다…", + "searchPlaceholder": "클립 검색…", + "emptySearch": "검색과 일치하는 클립이 없습니다.", "actions": { "menu": "작업", "play": "재생", diff --git a/app/src/i18n/locales/pt-BR/translation.json b/app/src/i18n/locales/pt-BR/translation.json index d2885d8e3..80c690972 100644 --- a/app/src/i18n/locales/pt-BR/translation.json +++ b/app/src/i18n/locales/pt-BR/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "Cancelar", + "clearSearch": "Limpar busca", "save": "Salvar", "delete": "Excluir", "edit": "Editar", @@ -406,6 +407,7 @@ "errorLoading": "Erro ao carregar perfis: {{message}}", "empty": "Nenhum perfil de voz ainda. Crie seu primeiro perfil para começar.", "createVoice": "Criar Voz", + "noVoicesMatch": "Nenhum perfil corresponde a \"{{query}}\"", "unsupportedNote": "Apenas perfis de voz suportados podem ser selecionados para o modelo atual." }, "deleteDialog": { @@ -645,7 +647,10 @@ } }, "history": { + "dragHandle": "Arrastar para uma pasta", "empty": "Nenhuma geração de voz ainda…", + "searchPlaceholder": "Buscar clipes…", + "emptySearch": "Nenhum clipe corresponde a essa busca.", "actions": { "menu": "Ações", "play": "Reproduzir", diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json index b0c78241b..46e4e38db 100644 --- a/app/src/i18n/locales/zh-CN/translation.json +++ b/app/src/i18n/locales/zh-CN/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "取消", + "clearSearch": "清除搜索", "save": "保存", "delete": "删除", "edit": "编辑", @@ -406,6 +407,7 @@ "errorLoading": "加载声音档案时出错:{{message}}", "empty": "还没有声音档案。创建您的第一个档案以开始使用。", "createVoice": "创建声音", + "noVoicesMatch": "没有与“{{query}}”匹配的配置文件", "unsupportedNote": "当前模型仅可选择支持的声音档案。" }, "deleteDialog": { @@ -645,7 +647,10 @@ } }, "history": { + "dragHandle": "拖到文件夹", "empty": "暂无语音生成…", + "searchPlaceholder": "搜索片段…", + "emptySearch": "没有符合该搜索的片段。", "actions": { "menu": "操作", "play": "播放", diff --git a/app/src/i18n/locales/zh-TW/translation.json b/app/src/i18n/locales/zh-TW/translation.json index e946fc95e..a6420e171 100644 --- a/app/src/i18n/locales/zh-TW/translation.json +++ b/app/src/i18n/locales/zh-TW/translation.json @@ -1,6 +1,7 @@ { "common": { "cancel": "取消", + "clearSearch": "清除搜尋", "save": "儲存", "delete": "刪除", "edit": "編輯", @@ -406,6 +407,7 @@ "errorLoading": "載入聲音檔案時出錯:{{message}}", "empty": "尚無聲音檔案。建立您的第一個檔案以開始使用。", "createVoice": "建立聲音", + "noVoicesMatch": "沒有與「{{query}}」相符的設定檔", "unsupportedNote": "目前模型僅可選擇支援的聲音檔案。" }, "deleteDialog": { @@ -645,7 +647,10 @@ } }, "history": { + "dragHandle": "拖曳到資料夾", "empty": "尚無語音生成…", + "searchPlaceholder": "搜尋片段…", + "emptySearch": "沒有符合該搜尋的片段。", "actions": { "menu": "操作", "play": "播放", diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index f89a17a3f..4af3d8759 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -8,6 +8,10 @@ import type { EffectConfig, EffectPresetCreate, EffectPresetResponse, + FolderCreate, + FolderKind, + FolderResponse, + FolderUpdate, GenerationRequest, GenerationResponse, GenerationVersionResponse, @@ -23,16 +27,21 @@ import type { RocmStatus, StoryCreate, StoryDetailResponse, + ExportAudioFormat, StoryItemBatchUpdate, StoryItemCreate, StoryItemDetail, + StoryItemFadeUpdate, StoryItemMove, StoryItemReorder, + StoryItemSpeedUpdate, StoryItemSplit, StoryItemTrim, StoryItemVersionUpdate, StoryItemVolumeUpdate, StoryResponse, + StoryTrackResponse, + StoryTrackUpsert, TranscriptionResponse, VoiceProfileCreate, VoiceProfileResponse, @@ -134,6 +143,87 @@ class ApiClient { }); } + /** + * Copy a voice, including its samples, avatar, personality and effects. + * Not an export/import round-trip — that transfer format drops everything + * except name, description and language. + */ + async duplicateProfile(profileId: string, name?: string): Promise { + return this.request(`/profiles/${profileId}/duplicate`, { + method: 'POST', + body: JSON.stringify(name ? { name } : {}), + }); + } + + // ── Folders ──────────────────────────────────────────────────────── + + async listFolders(kind: FolderKind): Promise { + return this.request(`/folders?kind=${kind}`); + } + + async createFolder(data: FolderCreate): Promise { + return this.request('/folders', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateFolder(folderId: string, data: FolderUpdate): Promise { + return this.request(`/folders/${folderId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + /** Move a folder back to the root. Separate from updateFolder because a + * null parent_id there is indistinguishable from an omitted field. */ + async detachFolder(folderId: string): Promise { + return this.request(`/folders/${folderId}/detach`, { + method: 'POST', + }); + } + + /** Deletes the folder only — members become uncategorised and child + * folders rise to this folder's parent. */ + async deleteFolder(folderId: string): Promise { + await this.request(`/folders/${folderId}`, { + method: 'DELETE', + }); + } + + /** Pass null to move the voice out of any folder. */ + async setProfileFolder( + profileId: string, + folderId: string | null, + ): Promise { + return this.request(`/profiles/${profileId}/folder`, { + method: 'PUT', + body: JSON.stringify({ folder_id: folderId }), + }); + } + + /** Pass null to move the story out of any folder. */ + async setStoryFolder( + storyId: string, + folderId: string | null, + ): Promise<{ id: string; folder_id: string | null }> { + return this.request(`/stories/${storyId}/folder`, { + method: 'PUT', + body: JSON.stringify({ folder_id: folderId }), + }); + } + + /** Pass null to move the clip out of any folder. */ + async setGenerationFolder( + generationId: string, + folderId: string | null, + ): Promise<{ id: string; folder_id: string | null }> { + return this.request(`/history/${generationId}/folder`, { + method: 'PUT', + body: JSON.stringify({ folder_id: folderId }), + }); + } + // ── Personality-driven text generation ───────────────────────────── // Compose produces a fresh in-character utterance the UI drops into // the generate textarea. Rewrite now happens server-side inside @@ -301,6 +391,10 @@ class ApiClient { const params = new URLSearchParams(); if (query?.profile_id) params.append('profile_id', query.profile_id); if (query?.search) params.append('search', query.search); + if (query?.folder_id) params.append('folder_id', query.folder_id); + if (query?.uncategorised_only) params.append('uncategorised_only', 'true'); + // Server-side default is true, so only the opt-out needs sending. + if (query?.include_subfolders === false) params.append('include_subfolders', 'false'); if (query?.limit) params.append('limit', query.limit.toString()); if (query?.offset) params.append('offset', query.offset.toString()); @@ -802,6 +896,54 @@ class ApiClient { }); } + async updateStoryItemFades( + storyId: string, + itemId: string, + data: StoryItemFadeUpdate, + ): Promise { + return this.request(`/stories/${storyId}/items/${itemId}/fades`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async updateStoryItemSpeed( + storyId: string, + itemId: string, + data: StoryItemSpeedUpdate, + ): Promise { + return this.request(`/stories/${storyId}/items/${itemId}/speed`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + // ── Story track mixer settings ───────────────────────────────────── + // Lanes without a row here mix at unity gain, so this list is often + // shorter than the number of lanes on screen. + + async listStoryTracks(storyId: string): Promise { + return this.request(`/stories/${storyId}/tracks`); + } + + async upsertStoryTrack( + storyId: string, + index: number, + data: StoryTrackUpsert, + ): Promise { + return this.request(`/stories/${storyId}/tracks/${index}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + /** Resets the lane to defaults; clips on it are kept. */ + async deleteStoryTrack(storyId: string, index: number): Promise { + await this.request(`/stories/${storyId}/tracks/${index}`, { + method: 'DELETE', + }); + } + async splitStoryItem( storyId: string, itemId: string, @@ -830,8 +972,21 @@ class ApiClient { }); } - async exportStoryAudio(storyId: string): Promise { - const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`; + /** + * Mix a story down to one file. `format` defaults to wav server-side. + * `normalizeLoudness` needs ffmpeg and is silently skipped without it — + * check `ffmpeg_available` on /health before offering it. + */ + async exportStoryAudio( + storyId: string, + options?: { format?: ExportAudioFormat; normalizeLoudness?: boolean }, + ): Promise { + const params = new URLSearchParams(); + if (options?.format) params.append('format', options.format); + if (options?.normalizeLoudness) params.append('normalize_loudness', 'true'); + + const query = params.toString(); + const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio${query ? `?${query}` : ''}`; const response = await fetch(url); if (!response.ok) { diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index d360ed1c4..f217adb3a 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -29,12 +29,42 @@ export interface VoiceProfileResponse { design_prompt?: string; default_engine?: string; personality?: string | null; + /** null / undefined means the voice sits in the Uncategorised bucket. */ + folder_id?: string | null; generation_count: number; sample_count: number; created_at: string; updated_at: string; } +/** What a folder groups. Voice folders are flat; clip and story folders nest. */ +export type FolderKind = 'voice' | 'generation' | 'story'; + +export interface FolderResponse { + id: string; + name: string; + kind: FolderKind; + /** Always null for voice folders. */ + parent_id?: string | null; + position: number; + /** Direct members only — a parent does not count its children's items. */ + item_count: number; + created_at: string; + updated_at: string; +} + +export interface FolderCreate { + name: string; + kind: FolderKind; + parent_id?: string | null; +} + +export interface FolderUpdate { + name?: string; + parent_id?: string; + position?: number; +} + /** Response returned by /profiles/{id}/compose. */ export interface PersonalityTextResponse { text: string; @@ -121,6 +151,15 @@ export interface GenerationResponse { export interface HistoryQuery { profile_id?: string; search?: string; + /** Show only this folder's clips. Ignored when uncategorised_only is set. */ + folder_id?: string; + /** + * Show only clips in no folder at all. Distinct from an absent folder_id, + * which means "no folder filter" rather than "the Uncategorised bucket". + */ + uncategorised_only?: boolean; + /** Whether folder_id also matches clips in that folder's descendants. */ + include_subfolders?: boolean; limit?: number; offset?: number; } @@ -129,6 +168,8 @@ export interface HistoryResponse extends GenerationResponse { profile_name: string; versions?: GenerationVersionResponse[]; active_version_id?: string; + /** null / undefined means the clip sits in the Uncategorised bucket. */ + folder_id?: string | null; } export interface HistoryListResponse { @@ -271,6 +312,11 @@ export interface HealthResponse { backend_type?: string; backend_variant?: string; // "cpu", "cuda", or "rocm" supports_rocm?: boolean; // AMD GPU on Windows — the ROCm backend is applicable + /** + * ffmpeg is optional. Without it, loudness normalisation is unavailable and + * m4a/aac/webm cannot be imported — libsndfile cannot open those. + */ + ffmpeg_available?: boolean; } export interface CudaDownloadProgress { @@ -392,6 +438,8 @@ export interface StoryResponse { id: string; name: string; description?: string; + /** null / undefined means the story sits in the Uncategorised bucket. */ + folder_id?: string | null; created_at: string; updated_at: string; item_count: number; @@ -417,6 +465,10 @@ export interface StoryItemDetail { instruct?: string; engine?: string; volume: number; + fade_in_ms: number; + fade_out_ms: number; + /** >1 plays faster and therefore shorter. */ + speed: number; generation_created_at: string; versions?: GenerationVersionResponse[]; active_version_id?: string; @@ -426,6 +478,42 @@ export interface StoryItemVolumeUpdate { volume: number; } +export interface StoryItemFadeUpdate { + fade_in_ms: number; + fade_out_ms: number; +} + +export interface StoryItemSpeedUpdate { + speed: number; +} + +/** Containers the bundled libsndfile can write — none of them need ffmpeg. */ +export type ExportAudioFormat = 'wav' | 'mp3' | 'ogg' | 'opus' | 'flac'; + +/** + * Mixer settings for one timeline lane. A lane with no entry mixes at unity + * gain, so the list is often shorter than the lanes on screen. + */ +export interface StoryTrackResponse { + id: string; + story_id: string; + index: number; + name?: string | null; + volume: number; + muted: boolean; + soloed: boolean; + /** Lane whose loudness ducks this one; null disables ducking. */ + duck_under_track?: number | null; +} + +export interface StoryTrackUpsert { + name?: string | null; + volume: number; + muted: boolean; + soloed: boolean; + duck_under_track?: number | null; +} + export interface StoryItemVersionUpdate { version_id: string | null; } diff --git a/app/src/lib/hooks/useDebouncedValue.ts b/app/src/lib/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..0cb9f12eb --- /dev/null +++ b/app/src/lib/hooks/useDebouncedValue.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +/** + * The value, updated only once it has been stable for `delayMs`. + * + * For search inputs that drive a server request: without this, every keystroke + * is a query. Client-side filters over an in-memory list don't need it. + */ +export function useDebouncedValue(value: T, delayMs = 250): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/app/src/lib/hooks/useFolders.ts b/app/src/lib/hooks/useFolders.ts new file mode 100644 index 000000000..3f095a502 --- /dev/null +++ b/app/src/lib/hooks/useFolders.ts @@ -0,0 +1,126 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api/client'; +import type { FolderKind, FolderUpdate } from '@/lib/api/types'; + +/** + * Folders for voices, clips and stories. + * + * All kinds live in one table server-side, so every query is keyed by kind + * — otherwise the panels would evict each other's cache entry on every + * mutation. + */ + +/** + * The query key holding a folder's members, per kind. Exhaustive over + * FolderKind so a new kind is a type error here rather than a silently + * stale list. + */ +const MEMBER_QUERY_KEY: Record = { + voice: 'profiles', + generation: 'history', + story: 'stories', +}; + +export function useFolders(kind: FolderKind) { + return useQuery({ + queryKey: ['folders', kind], + queryFn: () => apiClient.listFolders(kind), + }); +} + +export function useCreateFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, parentId }: { name: string; parentId?: string | null }) => + apiClient.createFolder({ name, kind, parent_id: parentId ?? null }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + }, + }); +} + +export function useUpdateFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ folderId, data }: { folderId: string; data: FolderUpdate }) => + apiClient.updateFolder(folderId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + }, + }); +} + +export function useDetachFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (folderId: string) => apiClient.detachFolder(folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + // Detaching moves a folder out of its parent, and member lists include + // subfolders by default — so anyone filtered by the former parent is + // still being shown the detached child's items. + queryClient.invalidateQueries({ + queryKey: [kind === 'voice' ? 'profiles' : 'history'], + }); + }, + }); +} + +export function useDeleteFolder(kind: FolderKind) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (folderId: string) => apiClient.deleteFolder(folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['folders', kind] }); + // Deleting a folder releases its members, so whichever list holds them + // is now stale too. + queryClient.invalidateQueries({ + queryKey: [MEMBER_QUERY_KEY[kind]], + }); + }, + }); +} + +export function useSetProfileFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ profileId, folderId }: { profileId: string; folderId: string | null }) => + apiClient.setProfileFolder(profileId, folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + // item_count changes on both the old and new folder. + queryClient.invalidateQueries({ queryKey: ['folders', 'voice'] }); + }, + }); +} + +export function useSetStoryFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ storyId, folderId }: { storyId: string; folderId: string | null }) => + apiClient.setStoryFolder(storyId, folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['stories'] }); + queryClient.invalidateQueries({ queryKey: ['folders', 'story'] }); + }, + }); +} + +export function useSetGenerationFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ generationId, folderId }: { generationId: string; folderId: string | null }) => + apiClient.setGenerationFolder(generationId, folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['history'] }); + queryClient.invalidateQueries({ queryKey: ['folders', 'generation'] }); + }, + }); +} diff --git a/app/src/lib/hooks/useHistory.ts b/app/src/lib/hooks/useHistory.ts index 51983339c..bc182dd61 100644 --- a/app/src/lib/hooks/useHistory.ts +++ b/app/src/lib/hooks/useHistory.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import type { HistoryQuery } from '@/lib/api/types'; import { usePlatform } from '@/platform/PlatformContext'; @@ -7,6 +7,12 @@ export function useHistory(query?: HistoryQuery) { return useQuery({ queryKey: ['history', query], queryFn: () => apiClient.listHistory(query), + // Keep showing the previous results while a new folder or search term + // loads. Without this the list empties on every keystroke and the UI + // flashes its empty state between each request. Callers can tell the + // difference via isPlaceholderData. No effect on callers whose query + // never changes. + placeholderData: keepPreviousData, }); } diff --git a/app/src/lib/hooks/useProfiles.ts b/app/src/lib/hooks/useProfiles.ts index f05fd999a..74d8eb261 100644 --- a/app/src/lib/hooks/useProfiles.ts +++ b/app/src/lib/hooks/useProfiles.ts @@ -55,6 +55,25 @@ export function useDeleteProfile() { }); } +/** + * Copy a voice in one step. Unlike export-then-import this keeps the + * personality, effects chain, default engine and preset fields, and works + * for voices with no samples. + */ +export function useDuplicateProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ profileId, name }: { profileId: string; name?: string }) => + apiClient.duplicateProfile(profileId, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + // The copy lands in the source's folder, changing that folder's count. + queryClient.invalidateQueries({ queryKey: ['folders', 'voice'] }); + }, + }); +} + export function useProfileSamples(profileId: string) { return useQuery({ queryKey: ['profiles', profileId, 'samples'], diff --git a/app/src/lib/hooks/useStories.ts b/app/src/lib/hooks/useStories.ts index 7c7eae6c9..d9ddc79ed 100644 --- a/app/src/lib/hooks/useStories.ts +++ b/app/src/lib/hooks/useStories.ts @@ -1,15 +1,19 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import type { + ExportAudioFormat, StoryCreate, StoryItemBatchUpdate, StoryItemCreate, + StoryItemFadeUpdate, StoryItemMove, StoryItemReorder, + StoryItemSpeedUpdate, StoryItemSplit, StoryItemTrim, StoryItemVersionUpdate, StoryItemVolumeUpdate, + StoryTrackUpsert, } from '@/lib/api/types'; import { usePlatform } from '@/platform/PlatformContext'; @@ -175,6 +179,87 @@ export function useUpdateStoryItemVolume() { }); } +export function useUpdateStoryItemFades() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + storyId, + itemId, + data, + }: { + storyId: string; + itemId: string; + data: StoryItemFadeUpdate; + }) => apiClient.updateStoryItemFades(storyId, itemId, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories'] }); + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); + }, + }); +} + +export function useUpdateStoryItemSpeed() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + storyId, + itemId, + data, + }: { + storyId: string; + itemId: string; + data: StoryItemSpeedUpdate; + }) => apiClient.updateStoryItemSpeed(storyId, itemId, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories'] }); + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] }); + }, + }); +} + +// ── Track mixer settings ───────────────────────────────────────────── + +export function useStoryTracks(storyId: string | null) { + return useQuery({ + queryKey: ['stories', storyId, 'tracks'], + queryFn: () => apiClient.listStoryTracks(storyId as string), + enabled: !!storyId, + }); +} + +export function useUpsertStoryTrack() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + storyId, + index, + data, + }: { + storyId: string; + index: number; + data: StoryTrackUpsert; + }) => apiClient.upsertStoryTrack(storyId, index, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId, 'tracks'] }); + }, + }); +} + +export function useDeleteStoryTrack() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ storyId, index }: { storyId: string; index: number }) => + apiClient.deleteStoryTrack(storyId, index), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId, 'tracks'] }); + }, + }); +} + export function useSplitStoryItem() { const queryClient = useQueryClient(); @@ -232,20 +317,30 @@ export function useExportStoryAudio() { const platform = usePlatform(); return useMutation({ - mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => { - const blob = await apiClient.exportStoryAudio(storyId); + mutationFn: async ({ + storyId, + storyName, + format = 'wav', + normalizeLoudness = false, + }: { + storyId: string; + storyName: string; + format?: ExportAudioFormat; + normalizeLoudness?: boolean; + }) => { + const blob = await apiClient.exportStoryAudio(storyId, { format, normalizeLoudness }); // Create safe filename const safeName = storyName .substring(0, 50) .replace(/[^a-z0-9]/gi, '-') .toLowerCase(); - const filename = `${safeName || 'story'}.wav`; + const filename = `${safeName || 'story'}.${format}`; await platform.filesystem.saveFile(filename, blob, [ { name: 'Audio File', - extensions: ['wav'], + extensions: [format], }, ]); diff --git a/app/src/lib/utils/folderDrag.ts b/app/src/lib/utils/folderDrag.ts new file mode 100644 index 000000000..6594a571f --- /dev/null +++ b/app/src/lib/utils/folderDrag.ts @@ -0,0 +1,51 @@ +import type { FolderKind } from '@/lib/api/types'; + +/** + * Payload shared by every "drag an item onto a folder" interaction. + * + * Uses native HTML5 drag and drop rather than dnd-kit, which the story + * timeline uses: those lists only need a drop target, not sortable reordering + * or collision detection, and native DnD keeps the folder headers as plain + * elements instead of sensor-wrapped ones. + * + * The kind travels with the id so a folder can refuse a drop that belongs to a + * different panel — dragging a voice onto a clip folder should do nothing + * rather than fail server-side. + */ + +const MIME = 'application/x-voicebox-item'; + +export interface FolderDragPayload { + kind: FolderKind; + id: string; +} + +export function setFolderDragData(e: React.DragEvent, payload: FolderDragPayload) { + e.dataTransfer.setData(MIME, JSON.stringify(payload)); + // Some targets only inspect text/plain; harmless and aids debugging. + e.dataTransfer.setData('text/plain', payload.id); + e.dataTransfer.effectAllowed = 'move'; +} + +/** Read a drag payload, or null when the drag isn't one of ours. */ +export function readFolderDragData(e: React.DragEvent): FolderDragPayload | null { + const raw = e.dataTransfer.getData(MIME); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as FolderDragPayload; + return parsed?.id && parsed?.kind ? parsed : null; + } catch { + return null; + } +} + +/** + * Whether a drag currently in flight is for this folder kind. + * + * dragover cannot read dataTransfer contents (the browser withholds them until + * drop, for security), so accept-or-not is decided from the *type* being + * present. The kind is re-checked properly on drop. + */ +export function isFolderDrag(e: React.DragEvent): boolean { + return e.dataTransfer.types.includes(MIME); +} diff --git a/app/src/stores/uiStore.ts b/app/src/stores/uiStore.ts index dfaf6d2ff..d72622ce0 100644 --- a/app/src/stores/uiStore.ts +++ b/app/src/stores/uiStore.ts @@ -1,8 +1,12 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import type { FolderKind } from '@/lib/api/types'; export type Theme = 'light' | 'dark' | 'system'; +/** How the Generate tab lists voices. */ +export type VoiceViewMode = 'list' | 'card'; + function resolveTheme(theme: Theme): 'light' | 'dark' { if (theme !== 'system') return theme; if (typeof window === 'undefined') return 'dark'; @@ -58,6 +62,16 @@ interface UIStore { profileFormDraft: ProfileFormDraft | null; setProfileFormDraft: (draft: ProfileFormDraft | null) => void; + // How the Generate tab renders voices + voiceViewMode: VoiceViewMode; + setVoiceViewMode: (mode: VoiceViewMode) => void; + + // Collapsed folder ids, keyed by folder kind. Collapsed rather than + // expanded ids so a newly created folder starts open without having to + // touch this set. + collapsedFolderIds: Partial>; + toggleFolderCollapsed: (kind: FolderKind, folderId: string) => void; + // Theme theme: Theme; setTheme: (theme: Theme) => void; @@ -89,6 +103,19 @@ export const useUIStore = create()( profileFormDraft: null, setProfileFormDraft: (draft) => set({ profileFormDraft: draft }), + voiceViewMode: 'list', + setVoiceViewMode: (mode) => set({ voiceViewMode: mode }), + + collapsedFolderIds: { voice: [], generation: [], story: [] }, + toggleFolderCollapsed: (kind, folderId) => + set((state) => { + const current = state.collapsedFolderIds[kind] ?? []; + const next = current.includes(folderId) + ? current.filter((id) => id !== folderId) + : [...current, folderId]; + return { collapsedFolderIds: { ...state.collapsedFolderIds, [kind]: next } }; + }), + theme: 'system', setTheme: (theme) => { set({ theme }); @@ -100,9 +127,16 @@ export const useUIStore = create()( partialize: (state) => ({ selectedProfileId: state.selectedProfileId, theme: state.theme, + voiceViewMode: state.voiceViewMode, + collapsedFolderIds: state.collapsedFolderIds, }), onRehydrateStorage: () => (state) => { if (state) applyTheme(state.theme); + // Persisted before folders existed, so an older store has no map — + // and a store persisted before story folders has no 'story' key. + if (state && !state.collapsedFolderIds) { + state.collapsedFolderIds = { voice: [], generation: [], story: [] }; + } }, }, ), diff --git a/backend/README.md b/backend/README.md index 170cab1cd..b608ef170 100644 --- a/backend/README.md +++ b/backend/README.md @@ -75,19 +75,20 @@ Detection is handled by `utils/platform_detect.py`. Both backends implement the ## API -90 endpoints organized by domain. Full interactive documentation available at `http://localhost:17493/docs` when the server is running. +135 endpoints organized by domain. Full interactive documentation available at `http://localhost:17493/docs` when the server is running. | Domain | Prefix | Description | |--------|--------|-------------| -| Health | `/`, `/health` | Server status, GPU info, filesystem checks | -| Profiles | `/profiles` | Voice profile CRUD, samples, avatars, import/export | +| Health | `/`, `/health` | Server status, GPU info, ffmpeg availability, filesystem checks | +| Profiles | `/profiles` | Voice profile CRUD, samples, avatars, duplication, import/export | +| Folders | `/folders` | Organising voices and generated clips | | Channels | `/channels` | Audio channel management and voice assignment | -| Generation | `/generate` | TTS generation, retry, regenerate, status SSE | +| Generation | `/generate` | TTS generation, retry, regenerate, status SSE, audio import | | History | `/history` | Generation history, search, favorites, export | | Transcription | `/transcribe` | Whisper-based audio-to-text | -| Stories | `/stories` | Multi-track timeline editor, audio export | +| Stories | `/stories` | Multi-track timeline editor, per-lane mixing, audio export | | Effects | `/effects` | Effect presets, preview, version management | -| Audio | `/audio`, `/samples` | Audio file serving | +| Audio | `/audio`, `/samples` | Audio file serving, on-the-fly transcoding | | Models | `/models` | Load, unload, download, migrate, status | | Tasks | `/tasks`, `/cache` | Active task tracking, cache management | | CUDA | `/backend/cuda-*` | CUDA binary download and management | @@ -105,6 +106,21 @@ curl http://localhost:17493/profiles # Stream generation status (SSE) curl http://localhost:17493/generate/{id}/status + +# File a voice into a folder (null moves it back to Uncategorised) +curl -X PUT http://localhost:17493/profiles/{id}/folder \ + -H "Content-Type: application/json" \ + -d '{"folder_id": "..."}' + +# Duplicate a voice with its samples, personality and effects +curl -X POST http://localhost:17493/profiles/{id}/duplicate + +# Mix a story down to a single file (wav | mp3 | ogg | opus | flac) +curl "http://localhost:17493/stories/{id}/export-audio?format=mp3" -o story.mp3 + +# Fetch a single generation in a container of your choosing. +# Omit ?format to get the stored file untouched. +curl "http://localhost:17493/audio/{generation_id}?format=mp3" -o clip.mp3 ``` ## Data directory @@ -118,7 +134,13 @@ curl http://localhost:17493/generate/{id}/status backends/ # Downloaded CUDA binary (if applicable) ``` -Default location is the OS-specific app data directory. Override with `--data-dir` or the `VOICEBOX_DATA_DIR` environment variable. +Default location is the OS-specific app data directory. Override with `--data-dir` or the `VOICEBOX_DATA_DIR` environment variable. `--data-dir` wins when both are set; the environment variable is the only option that reaches a bare `uvicorn backend.main:app`, which never parses CLI arguments. + +## Optional dependencies + +**ffmpeg** is not bundled and never required. When present on `PATH` it adds EBU R128 loudness normalisation to story export (`?normalize_loudness=true`); without it that request still succeeds using peak normalisation. It is also the only decoder for `.m4a`, `.aac` and `.webm` imports — libsndfile cannot open those, so `POST /generate/import` rejects them with a clear message when ffmpeg is missing. `GET /health` reports `ffmpeg_available`. + +Every audio export container (WAV, MP3, OGG, Opus, FLAC) is written by the bundled libsndfile and needs no ffmpeg. ## Code quality diff --git a/backend/config.py b/backend/config.py index cb6bc168c..0cb47caf3 100644 --- a/backend/config.py +++ b/backend/config.py @@ -18,8 +18,10 @@ os.environ["HF_HUB_CACHE"] = _custom_models_dir logger.info("Model download path set to: %s", _custom_models_dir) -# Default data directory (used in development) -_data_dir = Path("data").resolve() +# Default data directory (used in development). VOICEBOX_DATA_DIR lets a bare +# `uvicorn backend.main:app` point at the packaged app's data dir without a CLI +# flag. The --data-dir argument still wins: it calls set_data_dir() after import. +_data_dir = Path(os.environ.get("VOICEBOX_DATA_DIR") or "data").resolve() def _path_relative_to_any_data_dir(path: Path) -> Path | None: diff --git a/backend/database/migrations.py b/backend/database/migrations.py index d353b58c8..3ca7dca67 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -43,6 +43,8 @@ def run_migrations(engine) -> None: _migrate_generation_versions(engine, inspector, tables) _migrate_capture_settings(engine, inspector, tables) _migrate_mcp_bindings(engine, inspector, tables) + _migrate_folders(engine, inspector, tables) + _migrate_story_item_audio(engine, inspector, tables) _normalize_storage_paths(engine, tables) @@ -334,3 +336,47 @@ def _normalize_storage_paths(engine, tables: set[str]) -> None: if total_fixed > 0: conn.commit() logger.info("Normalized %d stored file paths", total_fixed) + + +def _migrate_folders(engine, inspector, tables: set[str]) -> None: + """Add folder_id to profiles and generations. + + The ``folders`` table itself is left to ``Base.metadata.create_all()``, + which runs straight after migrations and creates missing tables. Only + the columns on pre-existing tables need adding by hand. + + Declared without a REFERENCES clause, matching story_items.version_id: + SQLite cannot add a column with a foreign key to a table that does not + exist yet, and on a fresh database ``folders`` is created after this + runs. The relationship is still declared on the ORM models. + """ + if "profiles" in tables: + if "folder_id" not in _get_columns(inspector, "profiles"): + _add_column(engine, "profiles", "folder_id VARCHAR", "folder_id") + + if "generations" in tables: + if "folder_id" not in _get_columns(inspector, "generations"): + _add_column(engine, "generations", "folder_id VARCHAR", "folder_id") + + if "stories" in tables: + if "folder_id" not in _get_columns(inspector, "stories"): + _add_column(engine, "stories", "folder_id VARCHAR", "folder_id") + + +def _migrate_story_item_audio(engine, inspector, tables: set[str]) -> None: + """Add per-clip fade and speed columns to story_items. + + The ``story_tracks`` table is left to ``Base.metadata.create_all()``, which + runs straight after migrations and creates missing tables; only columns on + pre-existing tables need adding by hand. + """ + if "story_items" not in tables: + return + + columns = _get_columns(inspector, "story_items") + if "fade_in_ms" not in columns: + _add_column(engine, "story_items", "fade_in_ms INTEGER NOT NULL DEFAULT 0", "fade_in_ms") + if "fade_out_ms" not in columns: + _add_column(engine, "story_items", "fade_out_ms INTEGER NOT NULL DEFAULT 0", "fade_out_ms") + if "speed" not in columns: + _add_column(engine, "story_items", "speed FLOAT NOT NULL DEFAULT 1.0", "speed") diff --git a/backend/database/models.py b/backend/database/models.py index b85a55b17..fe2a66b73 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -3,7 +3,18 @@ from datetime import datetime import uuid -from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON +from sqlalchemy import ( + Column, + String, + Integer, + Float, + DateTime, + Text, + ForeignKey, + Boolean, + JSON, + UniqueConstraint, +) from sqlalchemy.ext.declarative import declarative_base from ..utils.capture_chords import ( @@ -14,6 +25,31 @@ Base = declarative_base() +class Folder(Base): + """A user-created folder for organising voices or generated clips. + + One table serves both, discriminated by ``kind``: + - "voice" — groups profiles. Flat: parent_id is always NULL. + - "generation" — groups generations. Nests to arbitrary depth. + + The asymmetry is a product decision, not a schema limit — voices are a + small, stable set that reads better as one level, while clips accumulate + per project and need real hierarchy. Nesting is enforced in the routes + rather than here so the constraint can relax without a migration. + """ + + __tablename__ = "folders" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String, nullable=False) + kind = Column(String, nullable=False, default="voice") # "voice" | "generation" + parent_id = Column(String, ForeignKey("folders.id"), nullable=True) + # Manual ordering within a parent. Ties break by name in the routes. + position = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class VoiceProfile(Base): """Voice profile. @@ -44,6 +80,11 @@ class VoiceProfile(Base): # cloning metadata above). personality = Column(Text, nullable=True) + # NULL means "Uncategorised" — the absence of a folder, not a missing + # reference. Deleting a folder nulls this rather than cascading, so a + # folder is never a way to lose voices. + folder_id = Column(String, ForeignKey("folders.id"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -82,9 +123,43 @@ class Generation(Base): # profile's personality LLM before TTS. Future sources (bulk import, # agent replies, etc.) can extend this. source = Column(String, nullable=False, default="manual") + # NULL means "Uncategorised". See VoiceProfile.folder_id. + folder_id = Column(String, ForeignKey("folders.id"), nullable=True) created_at = Column(DateTime, default=datetime.utcnow) +class StoryTrack(Base): + """Mixer settings for one lane of a story's timeline. + + Keyed by ``(story_id, index)`` where ``index`` is the same integer held in + ``StoryItem.track`` -- this table is metadata *about* a lane, not its + owner. A lane can hold clips with no row here at all, in which case it + mixes at unity gain; rows are created lazily when a lane is first named or + adjusted. Deleting a row therefore only resets the lane to defaults, it + never removes clips. + + Solo is stored per track but evaluated globally at mix time: if any track + in the story is soloed, every non-soloed track is silent. + """ + + __tablename__ = "story_tracks" + __table_args__ = (UniqueConstraint("story_id", "index", name="uq_story_track_index"),) + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + story_id = Column(String, ForeignKey("stories.id"), nullable=False) + index = Column(Integer, nullable=False) + name = Column(String, nullable=True) + volume = Column(Float, nullable=False, default=1.0) + muted = Column(Boolean, nullable=False, default=False) + soloed = Column(Boolean, nullable=False, default=False) + # Lane index whose loudness ducks this one — how a music bed sits under + # narration. NULL disables ducking. Not a FK: it references a lane + # number, same as StoryItem.track. + duck_under_track = Column(Integer, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + class Story(Base): """A story that sequences multiple generations.""" @@ -93,6 +168,8 @@ class Story(Base): id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) name = Column(String, nullable=False) description = Column(Text) + # NULL means "Uncategorised". See VoiceProfile.folder_id. + folder_id = Column(String, ForeignKey("folders.id"), nullable=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -107,10 +184,25 @@ class StoryItem(Base): generation_id = Column(String, ForeignKey("generations.id"), nullable=False) version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) start_time_ms = Column(Integer, nullable=False, default=0) + # Lane index. Deliberately a plain integer rather than a FK to + # story_tracks: clips are positioned by (track, start_time_ms) and the + # drag/move/reorder paths all treat the lane as a number. StoryTrack is + # metadata *keyed by* this value, so a lane can hold clips with no track + # row at all. track = Column(Integer, nullable=False, default=0) trim_start_ms = Column(Integer, nullable=False, default=0) trim_end_ms = Column(Integer, nullable=False, default=0) volume = Column(Float, nullable=False, default=1.0) + # Positional envelope over this clip, applied after trim and before the + # track gain. Not a pedalboard effect: EFFECT_REGISTRY entries are DSP + # plugins instantiated as cls(**params), whereas a fade depends on where + # the clip starts and ends. + fade_in_ms = Column(Integer, nullable=False, default=0) + fade_out_ms = Column(Integer, nullable=False, default=0) + # Playback rate. >1 plays faster and therefore *shorter*; because clips + # are absolutely positioned, a re-timed clip changes its own length + # without shifting its neighbours, exactly as trimming already does. + speed = Column(Float, nullable=False, default=1.0) created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/models.py b/backend/models.py index 7970ce41e..6fc514aaf 100644 --- a/backend/models.py +++ b/backend/models.py @@ -2,7 +2,8 @@ Pydantic models for request/response validation. """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, StringConstraints +from typing_extensions import Annotated from typing import Optional, List from datetime import datetime @@ -12,6 +13,71 @@ ) +FOLDER_KIND_PATTERN = "^(voice|generation|story)$" + +# Names arrive from text inputs, so a value of " " passes a raw +# min_length check and then stores as an empty label once stripped. +# Strip first, then length-check the result. +TrimmedName = Annotated[ + str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100) +] + + +class FolderCreate(BaseModel): + """Request model for creating a folder.""" + + name: TrimmedName + kind: str = Field(default="voice", pattern=FOLDER_KIND_PATTERN) + # Only meaningful for kind="generation"; voice folders are flat and the + # route rejects a non-null parent for them. + parent_id: Optional[str] = None + + +class FolderUpdate(BaseModel): + """Request model for renaming or reparenting a folder. + + Every field is optional so a rename doesn't have to restate the parent. + ``parent_id`` therefore can't distinguish "unset" from "move to root" — + use the dedicated move endpoint to detach a folder to the root. + """ + + name: Optional[TrimmedName] = None + parent_id: Optional[str] = None + position: Optional[int] = Field(None, ge=0) + + +class FolderResponse(BaseModel): + """Response model for a folder.""" + + id: str + name: str + kind: str + parent_id: Optional[str] = None + position: int = 0 + # Direct members only — a parent folder does not count its children's items. + item_count: int = 0 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FolderAssign(BaseModel): + """Request model for moving an item into a folder (null = uncategorised).""" + + folder_id: Optional[str] = None + + +class ProfileDuplicateRequest(BaseModel): + """Optional overrides when duplicating a profile. + + Omit entirely to accept the default " (copy)" name. + """ + + name: Optional[TrimmedName] = None + + class VoiceProfileCreate(BaseModel): """Request model for creating a voice profile.""" @@ -26,6 +92,7 @@ class VoiceProfileCreate(BaseModel): design_prompt: Optional[str] = Field(None, max_length=2000) default_engine: Optional[str] = Field(None, max_length=50) personality: Optional[str] = Field(None, max_length=2000) + folder_id: Optional[str] = None class VoiceProfileResponse(BaseModel): @@ -43,6 +110,7 @@ class VoiceProfileResponse(BaseModel): design_prompt: Optional[str] = None default_engine: Optional[str] = None personality: Optional[str] = None + folder_id: Optional[str] = None generation_count: int = 0 sample_count: int = 0 created_at: datetime @@ -119,6 +187,7 @@ class GenerationResponse(BaseModel): error: Optional[str] = None is_favorited: bool = False source: str = "manual" + folder_id: Optional[str] = None created_at: datetime versions: Optional[List["GenerationVersionResponse"]] = None active_version_id: Optional[str] = None @@ -132,6 +201,14 @@ class HistoryQuery(BaseModel): profile_id: Optional[str] = None search: Optional[str] = None + # Folder filter. A plain absent/None folder_id means "no filter, show + # everything"; selecting the Uncategorised bucket is a distinct request + # that a nullable field can't express, hence the explicit flag below. + folder_id: Optional[str] = None + uncategorised_only: bool = False + # Whether folder_id also matches clips in that folder's descendants. + # Clip folders nest, so a parent should be able to show the whole subtree. + include_subfolders: bool = True limit: int = Field(default=50, ge=1, le=100) offset: int = Field(default=0, ge=0) @@ -153,6 +230,7 @@ class HistoryResponse(BaseModel): status: str = "completed" error: Optional[str] = None is_favorited: bool = False + folder_id: Optional[str] = None created_at: datetime versions: Optional[List["GenerationVersionResponse"]] = None active_version_id: Optional[str] = None @@ -445,6 +523,10 @@ class HealthResponse(BaseModel): backend_variant: Optional[str] = None # Binary variant (cpu, cuda, or rocm) supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported + # ffmpeg is optional; when absent, loudness normalisation is unavailable + # and m4a/aac/webm cannot be imported. The UI labels those rather than + # letting them fail silently. + ffmpeg_available: bool = False class DirectoryCheck(BaseModel): @@ -576,6 +658,7 @@ class StoryResponse(BaseModel): id: str name: str description: Optional[str] + folder_id: Optional[str] = None created_at: datetime updated_at: datetime item_count: int = 0 @@ -607,6 +690,9 @@ class StoryItemDetail(BaseModel): instruct: Optional[str] engine: Optional[str] = None volume: float = 1.0 + fade_in_ms: int = 0 + fade_out_ms: int = 0 + speed: float = 1.0 generation_created_at: datetime # Versions available for this generation versions: Optional[List["GenerationVersionResponse"]] = None @@ -635,7 +721,10 @@ class StoryItemCreate(BaseModel): generation_id: str start_time_ms: Optional[int] = None # If not provided, will be calculated automatically - track: Optional[int] = 0 # Track number (0 = main track) + # Lane index. None means "decide for me": TTS clips append to track 0, + # imported audio gets its own empty lane so it plays under the narration. + # Must stay nullable to tell "omitted" apart from an explicit track 0. + track: Optional[int] = None class StoryItemUpdateTime(BaseModel): @@ -694,6 +783,57 @@ class StoryItemVolumeUpdate(BaseModel): volume: float = Field(..., ge=0.0, le=2.0) +class StoryItemFadeUpdate(BaseModel): + """Request model for a story item's fade in/out lengths, in milliseconds. + + The mixer scales both down proportionally if together they exceed the + clip, so no cross-field validation is needed here. + """ + + fade_in_ms: int = Field(..., ge=0, le=60000) + fade_out_ms: int = Field(..., ge=0, le=60000) + + +class StoryItemSpeedUpdate(BaseModel): + """Request model for a story item's playback rate. + + Above 1.0 plays faster and therefore shorter. Bounded because the phase + vocoder smears badly on speech outside roughly half to double speed. + """ + + speed: float = Field(..., ge=0.25, le=4.0) + + +class StoryTrackUpsert(BaseModel): + """Request model for creating or updating a lane's mixer settings.""" + + name: Optional[str] = Field(None, max_length=100) + volume: float = Field(default=1.0, ge=0.0, le=2.0) + muted: bool = False + soloed: bool = False + # Lane index whose loudness ducks this one; null disables ducking. + # Bounded because lane indices are non-negative -- a negative value is not + # a lane, and would silently never match one at mix time. Self-ducking is + # rejected in the route, which is where the lane's own index is known. + duck_under_track: Optional[int] = Field(None, ge=0) + + +class StoryTrackResponse(BaseModel): + """Response model for a lane's mixer settings.""" + + id: str + story_id: str + index: int + name: Optional[str] = None + volume: float = 1.0 + muted: bool = False + soloed: bool = False + duck_under_track: Optional[int] = None + + class Config: + from_attributes = True + + class EffectConfig(BaseModel): """A single effect in an effects chain.""" diff --git a/backend/requirements.txt b/backend/requirements.txt index 3c6160a58..672bffb0f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -55,7 +55,10 @@ unidic-lite>=1.0.8 # Audio processing audioop-lts>=0.2.1; python_version >= "3.13" librosa>=0.10.0 -soundfile>=0.12.0 +# 0.13.0 is the first wheel bundling libsndfile 1.2.2, which is what makes +# the MP3/Opus export formats work without ffmpeg. 0.12.x ships 1.1.0, +# which cannot write either -- see EXPORT_FORMATS in utils/audio.py. +soundfile>=0.13.0 numpy>=1.24.0,<2.0 numba>=0.60.0,<0.61.0 pedalboard>=0.9.0 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py index 42999d2d1..dab0aa13a 100644 --- a/backend/routes/__init__.py +++ b/backend/routes/__init__.py @@ -25,9 +25,11 @@ def register_routers(app: FastAPI) -> None: from .mcp_bindings import router as mcp_bindings_router from .events import router as events_router from .cloud import router as cloud_router + from .folders import router as folders_router app.include_router(health_router) app.include_router(profiles_router) + app.include_router(folders_router) app.include_router(channels_router) app.include_router(generations_router) app.include_router(history_router) diff --git a/backend/routes/audio.py b/backend/routes/audio.py index 685136c78..699b968b5 100644 --- a/backend/routes/audio.py +++ b/backend/routes/audio.py @@ -1,15 +1,19 @@ """Audio file serving endpoints.""" +import asyncio +import io import mimetypes from pathlib import Path +import librosa from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session from .. import config, models from ..services import history from ..database import get_db +from ..utils.audio import EXPORT_FORMATS, encode_audio router = APIRouter() @@ -24,9 +28,66 @@ def _audio_media_type(path: Path) -> str: return guessed or "audio/wav" +def _transcode(path: Path, fmt: str) -> bytes: + """Decode a stored file and re-encode it into ``fmt``. + + Decoded at the file's own rate and channel count, matching the story + mixdown, so a transcode is a container change rather than a resample.""" + audio, sr = librosa.load(str(path), sr=None, mono=False) + return encode_audio(audio, int(sr), fmt=fmt) + + +async def _serve_audio(path: Path, fmt: str | None, stem: str): + """Serve a stored audio file, optionally transcoded to ``fmt``. + + With no ``fmt`` the file is streamed untouched by ``FileResponse``, which + keeps range requests working for the player. A transcode has to buffer the + whole encode, so it is only paid for when a caller explicitly asks.""" + if fmt is None: + return FileResponse( + path, + media_type=_audio_media_type(path), + filename=f"{stem}{path.suffix}", + ) + + spec = EXPORT_FORMATS.get(fmt.lower()) + if spec is None: + raise HTTPException( + status_code=400, + detail=f"Unsupported format '{fmt}'. Supported: {sorted(EXPORT_FORMATS)}", + ) + + # Already in the requested container: hand back the bytes on disk rather + # than decoding and re-encoding, which would only lose quality. + if path.suffix.lower() == spec["ext"]: + return FileResponse( + path, + media_type=spec["mime"], + filename=f"{stem}{spec['ext']}", + ) + + try: + audio_bytes = await asyncio.to_thread(_transcode, path, fmt.lower()) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Transcode failed: {exc}") from exc + + return StreamingResponse( + io.BytesIO(audio_bytes), + media_type=spec["mime"], + headers={"Content-Disposition": f'attachment; filename="{stem}{spec["ext"]}"'}, + ) + + @router.get("/audio/version/{version_id}") -async def get_version_audio(version_id: str, db: Session = Depends(get_db)): - """Serve audio for a specific version.""" +async def get_version_audio( + version_id: str, + format: str | None = None, + db: Session = Depends(get_db), +): + """Serve audio for a specific version. + + ``format`` is one of :data:`EXPORT_FORMATS`; omitted, the stored file is + served as-is.""" from ..services import versions as versions_mod version = versions_mod.get_version(version_id, db) @@ -37,16 +98,24 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)): if audio_path is None or not audio_path.is_file(): raise HTTPException(status_code=404, detail="Audio file not found") - return FileResponse( + return await _serve_audio( audio_path, - media_type=_audio_media_type(audio_path), - filename=f"generation_{version.generation_id}_{version.label}{audio_path.suffix}", + format, + f"generation_{version.generation_id}_{version.label}", ) @router.get("/audio/{generation_id}") -async def get_audio(generation_id: str, db: Session = Depends(get_db)): - """Serve generated audio file (serves the default version).""" +async def get_audio( + generation_id: str, + format: str | None = None, + db: Session = Depends(get_db), +): + """Serve generated audio file (serves the default version). + + ``format`` is one of :data:`EXPORT_FORMATS` — ``mp3``, ``ogg``, ``opus``, + ``flac`` or ``wav``. Omitted, the stored file is served as-is, so existing + callers and range requests are unaffected.""" generation = await history.get_generation(generation_id, db) if not generation: raise HTTPException(status_code=404, detail="Generation not found") @@ -60,11 +129,7 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)): ) raise HTTPException(status_code=404, detail=detail) - return FileResponse( - audio_path, - media_type=_audio_media_type(audio_path), - filename=f"generation_{generation_id}{audio_path.suffix}", - ) + return await _serve_audio(audio_path, format, f"generation_{generation_id}") @router.get("/samples/{sample_id}") diff --git a/backend/routes/folders.py b/backend/routes/folders.py new file mode 100644 index 000000000..bad5ccdd3 --- /dev/null +++ b/backend/routes/folders.py @@ -0,0 +1,294 @@ +"""REST endpoints for organising voices and generated clips into folders. + +One ``folders`` table backs both, discriminated by ``kind``: + + - ``voice`` — flat. A voice folder never has a parent, because the + voice list reads better as one level (see Folder in database/models.py). + - ``generation`` — nests to arbitrary depth, since clips accumulate per + project and need real hierarchy. + +Deleting a folder never deletes its contents. Members are moved to +Uncategorised and child folders are re-parented to the deleted folder's +parent, so a folder is only ever a view over items, never an owner of them. +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlalchemy.orm import Session + +from .. import models +from ..database import get_db +from ..database.models import Folder, Generation, ProfileSample, Story, VoiceProfile +from ..services.folders import folder_and_descendants +from ..services.profiles import _profile_to_response + +router = APIRouter() + +# Maps a folder kind to the table whose rows it groups. +_MEMBER_MODEL = { + "voice": VoiceProfile, + "generation": Generation, + "story": Story, +} + +# Kinds that stay one level deep. Voices are a small, stable set that reads +# better flat; clips and stories accumulate per project and need real nesting. +_FLAT_KINDS = {"voice"} + + +def _get_folder_or_404(folder_id: str, db: Session) -> Folder: + folder = db.query(Folder).filter(Folder.id == folder_id).first() + if folder is None: + raise HTTPException(status_code=404, detail="Folder not found") + return folder + + +def _member_counts(kind: str, db: Session) -> dict[str, int]: + """Direct member count per folder id, for one kind. + + One grouped query rather than a count per folder — the folder list is + rendered on every voice/clip panel render. + """ + model = _MEMBER_MODEL[kind] + rows = ( + db.query(model.folder_id, func.count(model.id)) + .filter(model.folder_id.isnot(None)) + .group_by(model.folder_id) + .all() + ) + return {folder_id: count for folder_id, count in rows} + + +def _to_response(folder: Folder, counts: dict[str, int]) -> models.FolderResponse: + return models.FolderResponse( + id=folder.id, + name=folder.name, + kind=folder.kind, + parent_id=folder.parent_id, + position=folder.position, + item_count=counts.get(folder.id, 0), + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + + +@router.get("/folders", response_model=list[models.FolderResponse]) +async def list_folders(kind: str = "voice", db: Session = Depends(get_db)): + """List folders of one kind, ordered for direct rendering.""" + if kind not in _MEMBER_MODEL: + raise HTTPException(status_code=400, detail=f"Unknown folder kind: {kind}") + + folders = ( + db.query(Folder) + .filter(Folder.kind == kind) + .order_by(Folder.position, Folder.name) + .all() + ) + counts = _member_counts(kind, db) + return [_to_response(f, counts) for f in folders] + + +@router.post("/folders", response_model=models.FolderResponse) +async def create_folder(data: models.FolderCreate, db: Session = Depends(get_db)): + """Create a folder. Voice folders must be top-level.""" + if data.parent_id is not None: + if data.kind in _FLAT_KINDS: + raise HTTPException( + status_code=400, detail=f"{data.kind.capitalize()} folders cannot be nested" + ) + parent = _get_folder_or_404(data.parent_id, db) + if parent.kind != data.kind: + raise HTTPException( + status_code=400, detail="Parent folder has a different kind" + ) + + folder = Folder( + name=data.name.strip(), + kind=data.kind, + parent_id=data.parent_id, + position=_next_position(data.kind, data.parent_id, db), + ) + db.add(folder) + db.commit() + db.refresh(folder) + return _to_response(folder, {}) + + +@router.patch("/folders/{folder_id}", response_model=models.FolderResponse) +async def update_folder( + folder_id: str, + data: models.FolderUpdate, + db: Session = Depends(get_db), +): + """Rename, reposition, or reparent a folder.""" + folder = _get_folder_or_404(folder_id, db) + + if data.name is not None: + folder.name = data.name.strip() + + if data.position is not None: + folder.position = data.position + + if data.parent_id is not None: + if folder.kind in _FLAT_KINDS: + raise HTTPException( + status_code=400, detail=f"{folder.kind.capitalize()} folders cannot be nested" + ) + if data.parent_id == folder_id: + raise HTTPException( + status_code=400, detail="A folder cannot be its own parent" + ) + parent = _get_folder_or_404(data.parent_id, db) + if parent.kind != folder.kind: + raise HTTPException( + status_code=400, detail="Parent folder has a different kind" + ) + # Reparenting under your own descendant would detach the whole + # subtree from the root and make it unreachable in the tree UI. + if data.parent_id in folder_and_descendants(folder_id, db): + raise HTTPException( + status_code=400, detail="Cannot move a folder inside itself" + ) + folder.parent_id = data.parent_id + + db.commit() + db.refresh(folder) + return _to_response(folder, _member_counts(folder.kind, db)) + + +@router.post("/folders/{folder_id}/detach", response_model=models.FolderResponse) +async def detach_folder(folder_id: str, db: Session = Depends(get_db)): + """Move a folder back to the root. + + Separate from PATCH because FolderUpdate.parent_id=None is + indistinguishable from "field omitted". + """ + folder = _get_folder_or_404(folder_id, db) + folder.parent_id = None + db.commit() + db.refresh(folder) + return _to_response(folder, _member_counts(folder.kind, db)) + + +@router.delete("/folders/{folder_id}") +async def delete_folder(folder_id: str, db: Session = Depends(get_db)): + """Delete a folder, preserving everything inside it. + + Members become uncategorised; child folders rise to this folder's + parent. Nothing the user made is removed. + """ + folder = _get_folder_or_404(folder_id, db) + model = _MEMBER_MODEL[folder.kind] + + released = ( + db.query(model) + .filter(model.folder_id == folder_id) + .update({model.folder_id: None}, synchronize_session=False) + ) + reparented = ( + db.query(Folder) + .filter(Folder.parent_id == folder_id) + .update({Folder.parent_id: folder.parent_id}, synchronize_session=False) + ) + + db.delete(folder) + db.commit() + return { + "deleted": folder_id, + "items_released": released, + "folders_reparented": reparented, + } + + +def _next_position(kind: str, parent_id: str | None, db: Session) -> int: + """Append position — one past the highest sibling.""" + highest = ( + db.query(func.max(Folder.position)) + .filter(Folder.kind == kind, Folder.parent_id == parent_id) + .scalar() + ) + return 0 if highest is None else highest + 1 + + +# ── Membership ─────────────────────────────────────────────────────── + + +@router.put("/profiles/{profile_id}/folder", response_model=models.VoiceProfileResponse) +async def set_profile_folder( + profile_id: str, + data: models.FolderAssign, + db: Session = Depends(get_db), +): + """Move a voice into a folder, or out of one when folder_id is null.""" + profile = db.query(VoiceProfile).filter(VoiceProfile.id == profile_id).first() + if profile is None: + raise HTTPException(status_code=404, detail="Profile not found") + + if data.folder_id is not None: + folder = _get_folder_or_404(data.folder_id, db) + if folder.kind != "voice": + raise HTTPException( + status_code=400, detail="Target folder does not hold voices" + ) + + profile.folder_id = data.folder_id + db.commit() + db.refresh(profile) + + generation_count = ( + db.query(func.count(Generation.id)) + .filter(Generation.profile_id == profile.id) + .scalar() + ) + sample_count = ( + db.query(func.count(ProfileSample.id)) + .filter(ProfileSample.profile_id == profile.id) + .scalar() + ) + return _profile_to_response(profile, generation_count, sample_count) + + +@router.put("/stories/{story_id}/folder") +async def set_story_folder( + story_id: str, + data: models.FolderAssign, + db: Session = Depends(get_db), +): + """Move a story into a folder, or out of one when folder_id is null.""" + story = db.query(Story).filter(Story.id == story_id).first() + if story is None: + raise HTTPException(status_code=404, detail="Story not found") + + if data.folder_id is not None: + folder = _get_folder_or_404(data.folder_id, db) + if folder.kind != "story": + raise HTTPException( + status_code=400, detail="Target folder does not hold stories" + ) + + story.folder_id = data.folder_id + db.commit() + return {"id": story_id, "folder_id": story.folder_id} + + +@router.put("/history/{generation_id}/folder") +async def set_generation_folder( + generation_id: str, + data: models.FolderAssign, + db: Session = Depends(get_db), +): + """Move a clip into a folder, or out of one when folder_id is null.""" + generation = db.query(Generation).filter(Generation.id == generation_id).first() + if generation is None: + raise HTTPException(status_code=404, detail="Generation not found") + + if data.folder_id is not None: + folder = _get_folder_or_404(data.folder_id, db) + if folder.kind != "generation": + raise HTTPException( + status_code=400, detail="Target folder does not hold clips" + ) + + generation.folder_id = data.folder_id + db.commit() + return {"id": generation_id, "folder_id": generation.folder_id} diff --git a/backend/routes/generations.py b/backend/routes/generations.py index fbbeece67..7ebc5c0de 100644 --- a/backend/routes/generations.py +++ b/backend/routes/generations.py @@ -14,6 +14,7 @@ from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db from ..services.generation import run_generation from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation +from ..utils import ffmpeg from ..utils.audio import load_audio from ..utils.tasks import get_task_manager @@ -431,6 +432,18 @@ async def import_audio( detail=f"Unsupported audio format '{suffix}'. Allowed: {sorted(IMPORT_AUDIO_EXTENSIONS)}", ) + # libsndfile cannot open these, so librosa falls through to audioread, + # which shells out to ffmpeg. Without it the decode fails much later with + # an opaque error, so say so up front. + if ffmpeg.requires_ffmpeg(suffix) and not ffmpeg.is_available(): + raise HTTPException( + status_code=400, + detail=( + f"Importing '{suffix}' files needs ffmpeg, which was not found on PATH. " + "Install ffmpeg, or convert the file to WAV, FLAC, OGG or MP3 first." + ), + ) + chunks: list[bytes] = [] total = 0 while True: diff --git a/backend/routes/health.py b/backend/routes/health.py index 1568455dc..5f98d2005 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -13,6 +13,7 @@ from .. import config, models from ..services import tts from ..database import get_db +from ..utils import ffmpeg from ..utils.platform_detect import get_backend_type, is_amd_gpu_windows router = APIRouter() @@ -188,6 +189,7 @@ async def health(): backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", default_variant), supports_rocm=is_amd_gpu_windows(), gpu_compatibility_warning=gpu_compat_warning, + ffmpeg_available=ffmpeg.is_available(), ) diff --git a/backend/routes/history.py b/backend/routes/history.py index 694d35beb..428c4f69e 100644 --- a/backend/routes/history.py +++ b/backend/routes/history.py @@ -2,7 +2,7 @@ import io -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session @@ -18,14 +18,23 @@ async def list_history( profile_id: str | None = None, search: str | None = None, - limit: int = 50, - offset: int = 0, + folder_id: str | None = None, + uncategorised_only: bool = False, + include_subfolders: bool = True, + # Bounds mirror HistoryQuery so FastAPI rejects out-of-range values with + # a 422 before the model is constructed. Building HistoryQuery from raw + # ints instead surfaced its ValidationError as an opaque 500. + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ): """List generation history with optional filters.""" query = models.HistoryQuery( profile_id=profile_id, search=search, + folder_id=folder_id, + uncategorised_only=uncategorised_only, + include_subfolders=include_subfolders, limit=limit, offset=offset, ) diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index 68e5f2af0..0a1189441 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -134,6 +134,26 @@ async def update_profile( raise HTTPException(status_code=400, detail=str(e)) +@router.post("/profiles/{profile_id}/duplicate", response_model=models.VoiceProfileResponse) +async def duplicate_profile( + profile_id: str, + data: models.ProfileDuplicateRequest | None = None, + db: Session = Depends(get_db), +): + """Duplicate a voice profile, including its samples, avatar and settings. + + Unlike an export/import round-trip this preserves personality, effects + chain, default engine and preset/designed fields, and works for profiles + with no samples. Defaults the new name to " (copy)". + """ + try: + return await profiles.duplicate_profile( + profile_id, db, name=data.name if data else None + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + @router.delete("/profiles/{profile_id}") async def delete_profile( profile_id: str, diff --git a/backend/routes/stories.py b/backend/routes/stories.py index 73757d34f..15bcc4196 100644 --- a/backend/routes/stories.py +++ b/backend/routes/stories.py @@ -10,6 +10,8 @@ from ..services import stories from ..app import safe_content_disposition from ..database import get_db +from ..utils import ffmpeg +from ..utils.audio import EXPORT_FORMATS router = APIRouter() @@ -165,6 +167,70 @@ async def update_story_item_volume( return item +@router.put("/stories/{story_id}/items/{item_id}/fades", response_model=models.StoryItemDetail) +async def update_story_item_fades( + story_id: str, + item_id: str, + data: models.StoryItemFadeUpdate, + db: Session = Depends(get_db), +): + """Set a story item's fade in/out lengths (ms).""" + item = await stories.update_story_item_fades(story_id, item_id, data, db) + if item is None: + raise HTTPException(status_code=404, detail="Story item not found") + return item + + +@router.put("/stories/{story_id}/items/{item_id}/speed", response_model=models.StoryItemDetail) +async def update_story_item_speed( + story_id: str, + item_id: str, + data: models.StoryItemSpeedUpdate, + db: Session = Depends(get_db), +): + """Set a story item's playback rate (pitch-preserving).""" + item = await stories.update_story_item_speed(story_id, item_id, data, db) + if item is None: + raise HTTPException(status_code=404, detail="Story item not found") + return item + + +# ── Track mixer settings ───────────────────────────────────────────── + + +@router.get("/stories/{story_id}/tracks", response_model=list[models.StoryTrackResponse]) +async def list_story_tracks(story_id: str, db: Session = Depends(get_db)): + """Mixer settings for lanes that have them; others render at unity gain.""" + return await stories.list_story_tracks(story_id, db) + + +@router.put("/stories/{story_id}/tracks/{index}", response_model=models.StoryTrackResponse) +async def upsert_story_track( + story_id: str, + index: int, + data: models.StoryTrackUpsert, + db: Session = Depends(get_db), +): + """Create or update one lane's mixer settings.""" + # A lane ducking under itself would attenuate by its own envelope — quieter + # wherever it is loudest, which is never what anyone means. + if data.duck_under_track is not None and data.duck_under_track == index: + raise HTTPException(status_code=400, detail="A track cannot duck under itself") + track = await stories.upsert_story_track(story_id, index, data, db) + if track is None: + raise HTTPException(status_code=404, detail="Story not found") + return track + + +@router.delete("/stories/{story_id}/tracks/{index}") +async def delete_story_track(story_id: str, index: int, db: Session = Depends(get_db)): + """Reset a lane to defaults. Clips on the lane are kept.""" + ok = await stories.delete_story_track(story_id, index, db) + if not ok: + raise HTTPException(status_code=404, detail="Track settings not found") + return {"deleted": index} + + @router.post("/stories/{story_id}/items/{item_id}/split", response_model=list[models.StoryItemDetail]) async def split_story_item( story_id: str, @@ -209,26 +275,48 @@ async def set_story_item_version( @router.get("/stories/{story_id}/export-audio") async def export_story_audio( story_id: str, + format: str = "wav", + normalize_loudness: bool = False, db: Session = Depends(get_db), ): - """Export story as single mixed audio file.""" + """Export story as a single mixed audio file. + + ``format`` defaults to wav so existing callers are unaffected; every + supported container is handled by the bundled libsndfile, no ffmpeg. + + ``normalize_loudness`` applies EBU R128 normalisation and needs ffmpeg. It + is a no-op when ffmpeg is absent rather than an error — the export still + succeeds with the mixer's own peak normalisation. + """ + spec = EXPORT_FORMATS.get(format.lower()) + if spec is None: + raise HTTPException( + status_code=400, + detail=f"Unsupported export format '{format}'. Supported: {sorted(EXPORT_FORMATS)}", + ) + try: story = db.query(database.Story).filter_by(id=story_id).first() if not story: raise HTTPException(status_code=404, detail="Story not found") - audio_bytes = await stories.export_story_audio(story_id, db) + audio_bytes = await stories.export_story_audio(story_id, db, fmt=format.lower()) if not audio_bytes: raise HTTPException(status_code=400, detail="Story has no audio items") + if normalize_loudness: + normalized = ffmpeg.normalize_loudness(audio_bytes, suffix=spec["ext"]) + if normalized is not None: + audio_bytes = normalized + safe_name = "".join(c for c in story.name if c.isalnum() or c in (" ", "-", "_")).strip() if not safe_name: safe_name = "story" - filename = f"{safe_name}.wav" + filename = f"{safe_name}{spec['ext']}" return StreamingResponse( io.BytesIO(audio_bytes), - media_type="audio/wav", + media_type=spec["mime"], headers={"Content-Disposition": safe_content_disposition("attachment", filename)}, ) except HTTPException: diff --git a/backend/services/export_import.py b/backend/services/export_import.py index 514eaacda..3c49223e7 100644 --- a/backend/services/export_import.py +++ b/backend/services/export_import.py @@ -14,32 +14,14 @@ from ..models import VoiceProfileResponse from ..database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration, GenerationVersion as DBGenerationVersion -from .profiles import create_profile, add_profile_sample +from .profiles import create_profile, add_profile_sample, get_unique_profile_name from ..models import VoiceProfileCreate from .. import config - -def _get_unique_profile_name(name: str, db: Session) -> str: - """ - Get a unique profile name by appending a number if needed. - - Args: - name: Original profile name - db: Database session - - Returns: - Unique profile name - """ - base_name = name - counter = 1 - - while True: - existing = db.query(DBVoiceProfile).filter_by(name=name).first() - if not existing: - return name - - name = f"{base_name} ({counter})" - counter += 1 +# Kept as a module-level alias: this helper started life here, and moved to +# services.profiles so profile duplication could reuse it without importing +# the export/import layer. +_get_unique_profile_name = get_unique_profile_name def export_profile_to_zip(profile_id: str, db: Session) -> bytes: diff --git a/backend/services/folders.py b/backend/services/folders.py new file mode 100644 index 000000000..fe54ccf1a --- /dev/null +++ b/backend/services/folders.py @@ -0,0 +1,29 @@ +"""Folder tree helpers shared by the folders routes and history queries. + +Clip folders nest, so both "list this folder's clips" and "don't let a +folder be moved inside itself" need the same subtree walk. It lives here +so the two callers can't drift apart. +""" + +from sqlalchemy.orm import Session + +from ..database.models import Folder + + +def folder_and_descendants(folder_id: str, db: Session) -> set[str]: + """Return ``folder_id`` plus every folder beneath it. + + Breadth-first over ``parent_id``. The seen-set both prevents revisiting + shared subtrees and stops a cycle -- which the reparent guard should make + impossible, but which a hand-edited database could still contain -- from + looping forever. + """ + seen = {folder_id} + frontier = [folder_id] + + while frontier: + rows = db.query(Folder.id).filter(Folder.parent_id.in_(frontier)).all() + frontier = [child_id for (child_id,) in rows if child_id not in seen] + seen.update(frontier) + + return seen diff --git a/backend/services/history.py b/backend/services/history.py index 3062f7d6a..dac081aff 100644 --- a/backend/services/history.py +++ b/backend/services/history.py @@ -13,6 +13,7 @@ from ..models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig from ..database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile from .. import config +from .folders import folder_and_descendants def _get_versions_for_generation(generation_id: str, db: Session) -> tuple: @@ -192,7 +193,18 @@ async def list_generations( if query.search: search_pattern = f"%{query.search}%" q = q.filter(DBGeneration.text.like(search_pattern)) - + + # Apply folder filter. uncategorised_only is checked first because it + # is the one folder request a plain folder_id cannot express. + if query.uncategorised_only: + q = q.filter(DBGeneration.folder_id.is_(None)) + elif query.folder_id: + if query.include_subfolders: + folder_ids = folder_and_descendants(query.folder_id, db) + q = q.filter(DBGeneration.folder_id.in_(folder_ids)) + else: + q = q.filter(DBGeneration.folder_id == query.folder_id) + # Get total count before pagination total_count = q.count() @@ -224,6 +236,7 @@ async def list_generations( status=generation.status or "completed", error=generation.error, is_favorited=bool(generation.is_favorited), + folder_id=generation.folder_id, created_at=generation.created_at, versions=versions, active_version_id=active_version_id, diff --git a/backend/services/profiles.py b/backend/services/profiles.py index d7d32fa0f..2d020940d 100644 --- a/backend/services/profiles.py +++ b/backend/services/profiles.py @@ -4,14 +4,17 @@ import logging import shutil import uuid +from collections.abc import Callable from datetime import datetime from pathlib import Path from sqlalchemy import func +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from .. import config from ..database import Generation as DBGeneration, ProfileSample as DBProfileSample, VoiceProfile as DBVoiceProfile +from ..database.models import Folder as DBFolder from ..models import ( EffectConfig, ProfileSampleResponse, @@ -55,6 +58,7 @@ def _profile_to_response( design_prompt=getattr(profile, "design_prompt", None), default_engine=getattr(profile, "default_engine", None), personality=getattr(profile, "personality", None), + folder_id=getattr(profile, "folder_id", None), generation_count=generation_count, sample_count=sample_count, created_at=profile.created_at, @@ -135,6 +139,67 @@ def validate_profile_engine(profile, engine: str) -> None: raise ValueError(f"Engine '{engine}' does not support cloned voice profiles") +# How many "name (n)" variants to try before giving up. Only reached under +# genuine contention -- a single caller finds a free name on the first miss. +_NAME_ALLOCATION_ATTEMPTS = 50 + + +def get_unique_profile_name(name: str, db: Session) -> str: + """Return ``name``, or the first free "name (n)" variant. + + ``profiles.name`` is UNIQUE, so anything that creates a profile from an + existing one -- import, duplicate -- has to resolve collisions first. + + This only *reads*, so it is a check-then-act: two concurrent callers can + both be handed the same name and the second insert then fails the unique + constraint. Prefer :func:`insert_profile_with_unique_name`, which settles + the name by inserting it. This remains for callers that need a candidate + name before they have a row to insert. + """ + base_name = name + counter = 1 + + while True: + existing = db.query(DBVoiceProfile).filter_by(name=name).first() + if not existing: + return name + + name = f"{base_name} ({counter})" + counter += 1 + + +def insert_profile_with_unique_name( + base_name: str, + db: Session, + build_row: Callable[[str], DBVoiceProfile], +) -> DBVoiceProfile: + """Insert a profile under the first free variant of *base_name*. + + The name is settled by the insert rather than by a preceding SELECT, so + concurrent callers cannot both take it -- the unique constraint arbitrates + and the loser retries with the next suffix instead of surfacing a 500. + + ``build_row`` is called per attempt because a rolled-back commit expunges + the instance, so each try needs a fresh one. + """ + name = base_name + for counter in range(1, _NAME_ALLOCATION_ATTEMPTS + 1): + row = build_row(name) + db.add(row) + try: + db.commit() + db.refresh(row) + return row + except IntegrityError: + db.rollback() + name = f"{base_name} ({counter})" + + raise ValueError( + f"Could not find a free name for {base_name!r} after " + f"{_NAME_ALLOCATION_ATTEMPTS} attempts" + ) + + async def create_profile( data: VoiceProfileCreate, db: Session, @@ -172,6 +237,16 @@ async def create_profile( if validation_error: raise ValueError(validation_error) + # The folder-assignment endpoint enforces that a voice only lands in a + # voice folder; creation has to enforce the same contract, or a client can + # file a new profile straight into a clip folder and bypass it. + if data.folder_id is not None: + folder = db.query(DBFolder).filter_by(id=data.folder_id).first() + if folder is None: + raise ValueError(f"Folder not found: {data.folder_id}") + if folder.kind != "voice": + raise ValueError("Target folder does not hold voices") + db_profile = DBVoiceProfile( id=str(uuid.uuid4()), name=data.name, @@ -183,6 +258,7 @@ async def create_profile( design_prompt=data.design_prompt, default_engine=default_engine, personality=data.personality, + folder_id=data.folder_id, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) @@ -708,3 +784,99 @@ async def delete_avatar( db.commit() return True + + +async def duplicate_profile( + profile_id: str, + db: Session, + name: str | None = None, +) -> VoiceProfileResponse: + """Copy a profile, its samples, and its avatar. + + Deliberately not implemented as export-then-import. The transfer format + carries only name/description/language (see export_import.py), so a + round-trip silently drops personality, effects_chain, default_engine and + every preset/designed field -- and refuses profiles with no samples, + which is every preset voice. Copying the row directly keeps all of it. + + Sample audio is copied byte-for-byte rather than re-encoded through + add_profile_sample(): the source files were already validated when they + were first added, and a duplicate should be identical, not resampled. + """ + source = db.query(DBVoiceProfile).filter_by(id=profile_id).first() + if not source: + raise ValueError(f"Profile {profile_id} not found") + + new_id = str(uuid.uuid4()) + base_name = name.strip() if name else f"{source.name} (copy)" + + def _build(candidate: str) -> DBVoiceProfile: + return DBVoiceProfile( + id=new_id, + name=candidate, + description=source.description, + language=source.language, + effects_chain=source.effects_chain, + voice_type=source.voice_type, + preset_engine=source.preset_engine, + preset_voice_id=source.preset_voice_id, + design_prompt=source.design_prompt, + default_engine=source.default_engine, + personality=source.personality, + folder_id=source.folder_id, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + # Reserve the name by inserting it, before any file work. Retrying after + # the copies would mean undoing them; the directory is keyed by new_id, so + # a name retry does not affect it. + duplicate = insert_profile_with_unique_name(base_name, db, _build) + + new_dir = config.get_profiles_dir() / new_id + new_dir.mkdir(parents=True, exist_ok=True) + + # Samples: copy each file under a fresh id, then point a new row at it. + samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all() + for sample in samples: + source_audio = config.resolve_storage_path(sample.audio_path) + if source_audio is None or not source_audio.exists(): + # A profile can outlive its audio (moved data dir, manual + # cleanup). Skip the orphan rather than fail the whole copy. + logger.warning( + "Skipping sample %s while duplicating %s: audio missing at %s", + sample.id, + profile_id, + sample.audio_path, + ) + continue + + new_sample_id = str(uuid.uuid4()) + dest = new_dir / f"{new_sample_id}{source_audio.suffix}" + shutil.copy2(source_audio, dest) + db.add( + DBProfileSample( + id=new_sample_id, + profile_id=new_id, + audio_path=config.to_storage_path(dest), + reference_text=sample.reference_text, + ) + ) + + if source.avatar_path: + source_avatar = config.resolve_storage_path(source.avatar_path) + if source_avatar is not None and source_avatar.exists(): + dest_avatar = new_dir / source_avatar.name + shutil.copy2(source_avatar, dest_avatar) + duplicate.avatar_path = config.to_storage_path(dest_avatar) + + db.commit() + db.refresh(duplicate) + + sample_count = ( + db.query(func.count(DBProfileSample.id)) + .filter(DBProfileSample.profile_id == new_id) + .scalar() + ) + # A fresh copy has no generations of its own. + return _profile_to_response(duplicate, generation_count=0, sample_count=sample_count) diff --git a/backend/services/stories.py b/backend/services/stories.py index 44ae1cdc1..76e052778 100644 --- a/backend/services/stories.py +++ b/backend/services/stories.py @@ -4,9 +4,8 @@ from typing import List, Optional from datetime import datetime +import logging import uuid -import tempfile -from pathlib import Path from sqlalchemy.orm import Session from sqlalchemy import func @@ -21,8 +20,12 @@ StoryItemMove, StoryItemTrim, StoryItemVolumeUpdate, + StoryItemFadeUpdate, + StoryItemSpeedUpdate, StoryItemSplit, StoryItemVersionUpdate, + StoryTrackResponse, + StoryTrackUpsert, ) from ..database import ( Story as DBStory, @@ -30,10 +33,22 @@ Generation as DBGeneration, VoiceProfile as DBVoiceProfile, ) +from ..database.models import StoryTrack as DBStoryTrack from .history import _get_versions_for_generation -from ..utils.audio import load_audio, save_audio +from ..utils.audio import encode_audio, time_stretch_speech +import librosa import numpy as np +# Mixdown never exceeds this even if a source is higher — 48 kHz is the +# practical ceiling for delivery, and resampling a 96 kHz bed up there costs +# memory for no audible gain. +MAX_PROJECT_SAMPLE_RATE = 48000 + +# Used when a story's sources give us nothing to go on (all unreadable). +FALLBACK_SAMPLE_RATE = 24000 + +logger = logging.getLogger(__name__) + def _build_item_detail( item: DBStoryItem, @@ -72,6 +87,9 @@ def _build_item_detail( instruct=generation.instruct, engine=generation.engine, volume=getattr(item, "volume", 1.0), + fade_in_ms=getattr(item, "fade_in_ms", 0) or 0, + fade_out_ms=getattr(item, "fade_out_ms", 0) or 0, + speed=getattr(item, "speed", 1.0) or 1.0, generation_created_at=generation.created_at, versions=versions, active_version_id=active_version_id, @@ -239,6 +257,10 @@ async def delete_story( # Delete all items db.query(DBStoryItem).filter_by(story_id=story_id).delete() + # Delete per-lane mixer settings. They are keyed by story_id but have no + # FK cascade, so without this they outlive the story as unreachable rows. + db.query(DBStoryTrack).filter_by(story_id=story_id).delete() + # Delete story db.delete(story) db.commit() @@ -279,12 +301,25 @@ async def add_item_to_story( profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() return _build_item_detail(existing, generation, profile.name if profile else "Unknown", db) - # Get track from data or default to 0 - track = data.track if data.track is not None else 0 + # Imported audio is a bed, not another line of dialogue: default it to its + # own empty lane starting at zero so it plays *under* the narration. + # Appending it to track 0 like a TTS clip put the music after the voice, + # which is never what someone dropping in a music file wants. + profile_for_default = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() + is_imported = getattr(profile_for_default, "voice_type", None) == "import" + + if data.track is not None: + track = data.track + elif is_imported: + track = _next_free_track(story_id, db) + else: + track = 0 # Calculate start_time_ms if not provided if data.start_time_ms is not None: start_time_ms = data.start_time_ms + elif is_imported: + start_time_ms = 0 else: existing_items = ( db.query(DBStoryItem, DBGeneration) @@ -512,6 +547,139 @@ async def update_story_item_volume( return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) +def _next_free_track(story_id: str, db: Session) -> int: + """Lowest lane index at or above 0 holding no clips. + + Lanes are sparse integers rather than a dense list, and negative indices + are legitimate (the editor shows [1, 0, -1] by default), so this scans + upward from 0 rather than taking a max. + """ + used = {row[0] for row in db.query(DBStoryItem.track).filter_by(story_id=story_id).distinct()} + index = 0 + while index in used: + index += 1 + return index + + +async def _update_story_item_fields( + story_id: str, + item_id: str, + db: Session, + **fields, +) -> Optional[StoryItemDetail]: + """Set fields on a story item and return the refreshed detail. + + Shared by the fade and speed endpoints, which differ only in what they + assign — the lookup, story timestamp bump and detail rebuild are identical. + """ + item = db.query(DBStoryItem).filter_by(id=item_id, story_id=story_id).first() + if not item: + return None + generation = db.query(DBGeneration).filter_by(id=item.generation_id).first() + if not generation: + return None + + for key, value in fields.items(): + setattr(item, key, value) + + story = db.query(DBStory).filter_by(id=story_id).first() + if story: + story.updated_at = datetime.utcnow() + + db.commit() + db.refresh(item) + + profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first() + return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) + + +async def update_story_item_fades( + story_id: str, + item_id: str, + data: StoryItemFadeUpdate, + db: Session, +) -> Optional[StoryItemDetail]: + """Set a story item's fade in/out lengths.""" + return await _update_story_item_fields( + story_id, + item_id, + db, + fade_in_ms=data.fade_in_ms, + fade_out_ms=data.fade_out_ms, + ) + + +async def update_story_item_speed( + story_id: str, + item_id: str, + data: StoryItemSpeedUpdate, + db: Session, +) -> Optional[StoryItemDetail]: + """Set a story item's playback rate.""" + return await _update_story_item_fields(story_id, item_id, db, speed=data.speed) + + +# ── Track mixer settings ───────────────────────────────────────────── + + +async def list_story_tracks(story_id: str, db: Session) -> List[StoryTrackResponse]: + """Mixer settings for every lane that has them. + + Lanes without a row simply mix at unity gain, so the list is often + shorter than the number of lanes on screen. + """ + rows = ( + db.query(DBStoryTrack) + .filter_by(story_id=story_id) + .order_by(DBStoryTrack.index) + .all() + ) + return [StoryTrackResponse.model_validate(r) for r in rows] + + +async def upsert_story_track( + story_id: str, + index: int, + data: StoryTrackUpsert, + db: Session, +) -> Optional[StoryTrackResponse]: + """Create or update one lane's mixer settings.""" + story = db.query(DBStory).filter_by(id=story_id).first() + if not story: + return None + + row = db.query(DBStoryTrack).filter_by(story_id=story_id, index=index).first() + if row is None: + row = DBStoryTrack(story_id=story_id, index=index) + db.add(row) + + row.name = data.name + row.volume = data.volume + row.muted = data.muted + row.soloed = data.soloed + row.duck_under_track = data.duck_under_track + row.updated_at = datetime.utcnow() + + story.updated_at = datetime.utcnow() + db.commit() + db.refresh(row) + return StoryTrackResponse.model_validate(row) + + +async def delete_story_track(story_id: str, index: int, db: Session) -> bool: + """Reset a lane to defaults. + + Only the settings row goes — clips on that lane are untouched, and the + lane keeps rendering at unity gain. + """ + row = db.query(DBStoryTrack).filter_by(story_id=story_id, index=index).first() + if row is None: + return False + db.delete(row) + db.commit() + return True + + async def split_story_item( story_id: str, item_id: str, @@ -565,6 +733,12 @@ async def split_story_item( # Update original clip: trim from the end item.trim_end_ms = original_duration_ms - absolute_split_ms + # Fades split with the audio: the head keeps its fade-in, the tail keeps + # the fade-out. Leaving both on both halves would insert an audible dip at + # the seam of what the user hears as one continuous clip. + tail_fade_out = getattr(item, "fade_out_ms", 0) or 0 + item.fade_out_ms = 0 + # Create new clip: starts after the split, trimmed from the start new_item = DBStoryItem( id=str(uuid.uuid4()), @@ -576,6 +750,9 @@ async def split_story_item( trim_start_ms=absolute_split_ms, trim_end_ms=current_trim_end, volume=getattr(item, "volume", 1.0), + fade_in_ms=0, + fade_out_ms=tail_fade_out, + speed=getattr(item, "speed", 1.0) or 1.0, created_at=datetime.utcnow(), ) @@ -833,16 +1010,108 @@ async def set_story_item_version( return _build_item_detail(item, generation, profile.name if profile else "Unknown", db) +def _to_stereo(audio: np.ndarray) -> np.ndarray: + """Normalise any loaded clip to a ``(2, samples)`` float32 array. + + librosa hands back ``(samples,)`` for mono and ``(channels, samples)`` + otherwise. Mono is duplicated rather than panned so a voice clip sits + centred; anything above stereo is folded down to the first two channels. + """ + audio = np.asarray(audio, dtype=np.float32) + if audio.ndim == 1: + return np.stack([audio, audio]) + if audio.shape[0] == 1: + return np.repeat(audio, 2, axis=0) + return audio[:2] + + +def _apply_fades(audio: np.ndarray, sample_rate: int, fade_in_ms: int, fade_out_ms: int) -> np.ndarray: + """Apply linear fades to a ``(channels, samples)`` clip, in place-safe form. + + The two fades are scaled down together if they would overlap, so a short + clip with long fades still ends up monotonic rather than re-brightening in + the middle. + """ + n = audio.shape[1] + if n == 0 or (fade_in_ms <= 0 and fade_out_ms <= 0): + return audio + + fade_in = int(sample_rate * max(fade_in_ms, 0) / 1000) + fade_out = int(sample_rate * max(fade_out_ms, 0) / 1000) + + total = fade_in + fade_out + if total > n and total > 0: + scale = n / total + fade_in = int(fade_in * scale) + fade_out = int(fade_out * scale) + + audio = audio.copy() + if fade_in > 0: + audio[:, :fade_in] *= np.linspace(0.0, 1.0, fade_in, dtype=np.float32) + if fade_out > 0: + audio[:, n - fade_out :] *= np.linspace(1.0, 0.0, fade_out, dtype=np.float32) + return audio + + +def _duck_envelope( + source: np.ndarray, + sample_rate: int, + depth: float = 0.75, + attack_ms: int = 80, + release_ms: int = 400, +) -> np.ndarray: + """Gain curve that pulls a bed down while ``source`` is loud. + + A plain RMS follower with asymmetric smoothing: duck quickly when speech + starts, recover slowly so the bed doesn't pump between words. + """ + mono = source.mean(axis=0) + frame = max(1, sample_rate // 100) # 10 ms + + padded = np.pad(mono, (0, (-len(mono)) % frame)) + rms = np.sqrt((padded.reshape(-1, frame) ** 2).mean(axis=1)) + + peak = rms.max() + if peak <= 1e-6: + return np.ones(source.shape[1], dtype=np.float32) + + # 0 where silent, 1 where at peak, then invert into a gain reduction. + activity = np.clip(rms / peak, 0.0, 1.0) + gain = 1.0 - depth * activity + + attack = max(1, int(attack_ms / 10)) + release = max(1, int(release_ms / 10)) + smoothed = np.empty_like(gain) + current = 1.0 + for i, target in enumerate(gain): + coeff = 1.0 / (attack if target < current else release) + current += (target - current) * coeff + smoothed[i] = current + + envelope = np.repeat(smoothed, frame)[: source.shape[1]] + return envelope.astype(np.float32) + + async def export_story_audio( story_id: str, db: Session, + fmt: str = "wav", ) -> Optional[bytes]: """ Export story as single mixed audio file with timecode-based mixing. + Mixes in stereo at the highest sample rate any source actually uses + (capped at 48 kHz) rather than flattening everything to 24 kHz mono, so an + imported music bed keeps its bandwidth and stereo image. + + Each lane is rendered to its own buffer first. That is what makes ducking + possible — a bed can be attenuated by the *finished* speech lane — and it + is also where track volume, mute and solo apply. + Args: story_id: Story ID db: Database session + fmt: Output container; see ``utils.audio.EXPORT_FORMATS``. Returns: Audio file bytes or None if story not found @@ -863,12 +1132,14 @@ async def export_story_audio( if not items: return None - # Load all audio files and calculate total duration - audio_data = [] - sample_rate = 24000 # Default sample rate + tracks = {t.index: t for t in db.query(DBStoryTrack).filter_by(story_id=story_id).all()} + any_soloed = any(t.soloed for t in tracks.values()) + # --- decode once, at native rate --------------------------------------- + # Decoding at each file's own rate lets us pick the project rate from what + # the sources actually are, instead of forcing 24 kHz on a 48 kHz bed. + loaded = [] for item, generation in items: - # Resolve audio path: use pinned version if set, otherwise generation default resolved_audio_path = generation.audio_path if getattr(item, "version_id", None): from ..database import GenerationVersion as DBGenerationVersion @@ -879,100 +1150,125 @@ async def export_story_audio( audio_path = config.resolve_storage_path(resolved_audio_path) if audio_path is None or not audio_path.exists(): + logger.warning("Story %s: skipping item %s, audio missing", story_id, item.id) continue try: - audio, sr = load_audio(str(audio_path), sample_rate=sample_rate) - sample_rate = sr # Use actual sample rate from first file - - # Get trim values - trim_start_ms = getattr(item, "trim_start_ms", 0) - trim_end_ms = getattr(item, "trim_end_ms", 0) - - # Calculate effective duration - original_duration_ms = int(generation.duration * 1000) - effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms - - # Slice audio based on trim values - trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate) - trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate) - - # Extract the trimmed portion - if trim_end_ms > 0: - trimmed_audio = ( - audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:] - ) - else: - trimmed_audio = audio[trim_start_sample:] - - # Apply per-clip volume to the export mix. - volume = float(getattr(item, "volume", 1.0) or 1.0) - if volume != 1.0: - trimmed_audio = trimmed_audio * volume - - # Store audio with its timecode info - start_time_ms = item.start_time_ms - - audio_data.append( - { - "audio": trimmed_audio, - "start_time_ms": start_time_ms, - "duration_ms": effective_duration_ms, - } - ) - except Exception: - # Skip files that can't be loaded + audio, sr = librosa.load(str(audio_path), sr=None, mono=False) + except Exception as exc: + logger.warning("Story %s: skipping item %s, decode failed: %s", story_id, item.id, exc) continue - if not audio_data: + loaded.append((item, _to_stereo(audio), int(sr))) + + if not loaded: return None - # Calculate total duration: max(start_time_ms + duration_ms) - max_end_time_ms = max((data["start_time_ms"] + data["duration_ms"] for data in audio_data), default=0) + project_sr = min(max(sr for _item, _audio, sr in loaded), MAX_PROJECT_SAMPLE_RATE) + + # --- per-lane submixes -------------------------------------------------- + lanes: dict[int, np.ndarray] = {} + placements = [] - # Convert to samples - total_samples = int((max_end_time_ms / 1000.0) * sample_rate) + for item, audio, sr in loaded: + if sr != project_sr: + audio = librosa.resample(audio, orig_sr=sr, target_sr=project_sr) - # Create output buffer initialized to zeros - final_audio = np.zeros(total_samples, dtype=np.float32) + # Duration comes from the array we actually decoded, not from + # generation.duration: a pinned version can be a different length, and + # a NULL duration used to raise inside a bare except and silently drop + # the clip from the export. + trim_start = int(project_sr * max(getattr(item, "trim_start_ms", 0), 0) / 1000) + trim_end = int(project_sr * max(getattr(item, "trim_end_ms", 0), 0) / 1000) + audio = audio[:, trim_start : audio.shape[1] - trim_end if trim_end else None] + if audio.shape[1] == 0: + continue - # Mix each audio segment at its timecode position - for data in audio_data: - audio = data["audio"] - start_time_ms = data["start_time_ms"] + speed = float(getattr(item, "speed", 1.0) or 1.0) + if speed != 1.0: + # WSOLA rather than a phase vocoder: the vocoder resynthesises from + # magnitude and estimated phase, which on speech smears consonants + # and leaves a phasey ring. Pitch survives either way; only the + # artefacts differ. + audio = time_stretch_speech(audio, speed, project_sr) + + audio = _apply_fades( + audio, + project_sr, + int(getattr(item, "fade_in_ms", 0) or 0), + int(getattr(item, "fade_out_ms", 0) or 0), + ) - # Calculate start sample index - start_sample = int((start_time_ms / 1000.0) * sample_rate) + volume = float(getattr(item, "volume", 1.0) or 1.0) + if volume != 1.0: + audio = audio * volume - # Ensure we don't exceed buffer bounds - audio_length = len(audio) - end_sample = min(start_sample + audio_length, total_samples) + placements.append((item.track, int(item.start_time_ms), audio)) - if start_sample < total_samples: - # Trim audio if it extends beyond buffer - audio_to_mix = audio[: end_sample - start_sample] + if not placements: + return None - # Mix: add audio to existing buffer (overlapping audio will sum) - # Normalize to prevent clipping (simple approach: divide by max) - final_audio[start_sample:end_sample] += audio_to_mix + total_samples = max( + int(project_sr * start_ms / 1000) + audio.shape[1] for _track, start_ms, audio in placements + ) - # Normalize to prevent clipping - max_val = np.abs(final_audio).max() - if max_val > 1.0: - final_audio = final_audio / max_val + for track_index, start_ms, audio in placements: + lane = lanes.get(track_index) + if lane is None: + lane = np.zeros((2, total_samples), dtype=np.float32) + lanes[track_index] = lane + + start = int(project_sr * start_ms / 1000) + end = min(start + audio.shape[1], total_samples) + if start < total_samples: + lane[:, start:end] += audio[:, : end - start] + + # --- track gain, mute and solo ----------------------------------------- + for index, lane in lanes.items(): + # A lane with no settings row means *defaults*, not *exempt* — it still + # has to be silenced when another lane is soloed. Skipping it here let + # the un-configured lane (usually the voice on track 0) play through a + # solo of the music bed. + track = tracks.get(index) + muted = bool(track.muted) if track else False + soloed = bool(track.soloed) if track else False + volume = float(track.volume) if track else 1.0 + + # Solo is a property of the whole story: once anything is soloed, + # everything else is silent regardless of its own mute flag. + if muted or (any_soloed and not soloed): + lane[:] = 0.0 + continue + if volume != 1.0: + lane *= volume + + # --- ducking ------------------------------------------------------------ + # Runs after gain so the envelope reflects what will actually be heard, + # and after mute/solo so a silenced lane ducks nothing. + # Envelopes are computed from the pre-ducking lanes, before any are + # attenuated. Applying them inside the loop instead would make the result + # depend on dict order whenever two lanes duck under each other: whichever + # ran first would read an untouched source, the second an already-ducked + # one. Same input, different mixdown. + envelopes: dict[int, np.ndarray] = {} + for index in lanes: + track = tracks.get(index) + if track is None or track.duck_under_track is None: + continue + source = lanes.get(track.duck_under_track) + if source is None: + continue + envelopes[index] = _duck_envelope(source, project_sr) - # Save to temporary file - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: - tmp_path = tmp.name + for index, envelope in envelopes.items(): + lanes[index] *= envelope - try: - save_audio(final_audio, tmp_path, sample_rate) + final_audio = np.zeros((2, total_samples), dtype=np.float32) + for lane in lanes.values(): + final_audio += lane - # Read file bytes - with open(tmp_path, "rb") as f: - audio_bytes = f.read() + peak = np.abs(final_audio).max() + if peak > 1.0: + final_audio /= peak - return audio_bytes - finally: - # Clean up temp file - Path(tmp_path).unlink(missing_ok=True) + return encode_audio(final_audio, project_sr, fmt) diff --git a/backend/tests/test_audio_format_param.py b/backend/tests/test_audio_format_param.py new file mode 100644 index 000000000..f9ee7ee11 --- /dev/null +++ b/backend/tests/test_audio_format_param.py @@ -0,0 +1,138 @@ +""" +Tests for the ``format`` parameter on the audio-serving endpoints (#869). + +Generations are stored as WAV. Callers that want anything else previously had +to transcode themselves; these tests pin the container negotiation, that the +default path is untouched, and that a bad format is rejected rather than +silently served as WAV. + +Usage: + python -m pytest backend/tests/test_audio_format_param.py -v +""" + +import io +import os +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-audio-format-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +def _tone(seconds: float, sr: int, freq: float = 440.0, amp: float = 0.3): + t = np.linspace(0, seconds, int(sr * seconds), endpoint=False) + return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32) + + +@pytest.fixture +def generation(client, tmp_path): + """A stored WAV generation to serve back in various containers.""" + path = tmp_path / "source.wav" + sf.write(str(path), _tone(1.0, 48000), 48000) + with path.open("rb") as fh: + r = client.post("/generate/import", files={"file": ("source.wav", fh, "audio/wav")}) + assert r.status_code == 200, r.text + return r.json() + + +# ── Default behaviour ──────────────────────────────────────────────── + + +def test_no_format_serves_the_stored_file(client, generation): + """Existing callers must be untouched: no query param, no transcode.""" + r = client.get(f"/audio/{generation['id']}") + assert r.status_code == 200, r.text + assert r.headers["content-type"].startswith("audio/") + + data, sr = sf.read(io.BytesIO(r.content), dtype="float32") + assert sr == 48000 + assert len(data) == pytest.approx(48000, rel=0.01) + + +def test_wav_requested_on_a_wav_file_is_not_re_encoded(client, generation): + """Same container: hand back the bytes on disk rather than round-tripping + them through the encoder.""" + plain = client.get(f"/audio/{generation['id']}") + as_wav = client.get(f"/audio/{generation['id']}", params={"format": "wav"}) + + assert as_wav.status_code == 200, as_wav.text + assert as_wav.content == plain.content + + +# ── Transcoding ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "fmt,mime", + [ + ("mp3", "audio/mpeg"), + ("ogg", "audio/ogg"), + ("opus", "audio/ogg"), + ("flac", "audio/flac"), + ], +) +def test_format_returns_that_container(client, generation, fmt, mime): + r = client.get(f"/audio/{generation['id']}", params={"format": fmt}) + assert r.status_code == 200, r.text + assert r.headers["content-type"].startswith(mime) + assert f".{fmt}" in r.headers.get("content-disposition", "") + + # Decodable, and still about a second of audio. + data, sr = sf.read(io.BytesIO(r.content), dtype="float32") + assert len(data) / sr == pytest.approx(1.0, abs=0.05) + + +def test_lossless_transcode_preserves_the_sample_rate(client, generation): + """FLAC is the format where a resample would be a bug, not a trade-off.""" + r = client.get(f"/audio/{generation['id']}", params={"format": "flac"}) + _, sr = sf.read(io.BytesIO(r.content), dtype="float32") + assert sr == 48000 + + +def test_opus_is_served_at_48k(client, tmp_path): + """Opus only encodes at 48 kHz; a 24 kHz source has to be resampled rather + than erroring out of libsndfile.""" + path = tmp_path / "narrow.wav" + sf.write(str(path), _tone(1.0, 24000), 24000) + with path.open("rb") as fh: + created = client.post( + "/generate/import", files={"file": ("narrow.wav", fh, "audio/wav")} + ).json() + + r = client.get(f"/audio/{created['id']}", params={"format": "opus"}) + assert r.status_code == 200, r.text + _, sr = sf.read(io.BytesIO(r.content), dtype="float32") + assert sr == 48000 + + +# ── Rejections ─────────────────────────────────────────────────────── + + +def test_unsupported_format_is_a_400(client, generation): + """Not a silent fallback to WAV — a caller asking for m4a should learn that + it is not on offer.""" + r = client.get(f"/audio/{generation['id']}", params={"format": "m4a"}) + assert r.status_code == 400 + assert "m4a" in r.json()["detail"] + + +def test_missing_generation_still_404s_with_a_format(client): + r = client.get("/audio/does-not-exist", params={"format": "mp3"}) + assert r.status_code == 404 diff --git a/backend/tests/test_data_dir_env.py b/backend/tests/test_data_dir_env.py new file mode 100644 index 000000000..cef81949e --- /dev/null +++ b/backend/tests/test_data_dir_env.py @@ -0,0 +1,78 @@ +""" +Unit tests for the ``VOICEBOX_DATA_DIR`` environment variable. + +``backend/README.md`` documents ``VOICEBOX_DATA_DIR`` alongside ``--data-dir``, +but the default was previously hardcoded to ``./data``, so a bare +``uvicorn backend.main:app`` (how ``just dev`` starts the backend) always wrote +to the repo instead of the app data dir. + +The variable is read at import time, so each test reloads the module under a +patched environment. + +NOTE: These tests reload ``config``, which rebinds its module-global +``_data_dir``. Import the module fresh rather than holding a reference across +reloads. +""" + +import importlib +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import config # noqa: E402 + + +@pytest.fixture +def reload_config(monkeypatch): + """Reload ``config`` with a patched environment, restoring it afterwards.""" + + def _reload(data_dir: str | None): + if data_dir is None: + monkeypatch.delenv("VOICEBOX_DATA_DIR", raising=False) + else: + monkeypatch.setenv("VOICEBOX_DATA_DIR", data_dir) + return importlib.reload(config) + + yield _reload + # Leave the module matching the real process environment for later tests. + importlib.reload(config) + + +def test_env_var_sets_data_dir(reload_config, tmp_path): + target = tmp_path / "appdata" + cfg = reload_config(str(target)) + + assert cfg.get_data_dir() == target.resolve() + + +def test_defaults_to_local_data_dir_when_unset(reload_config): + cfg = reload_config(None) + + assert cfg.get_data_dir() == Path("data").resolve() + + +def test_empty_env_var_falls_back_to_default(reload_config): + cfg = reload_config("") + + assert cfg.get_data_dir() == Path("data").resolve() + + +def test_relative_env_var_is_resolved_to_absolute(reload_config): + cfg = reload_config("relative/data") + + assert cfg.get_data_dir().is_absolute() + assert cfg.get_data_dir() == Path("relative/data").resolve() + + +def test_set_data_dir_still_wins_over_env_var(reload_config, tmp_path): + """``--data-dir`` calls set_data_dir() after import, so it must take + precedence over the environment variable.""" + cfg = reload_config(str(tmp_path / "from-env")) + explicit = tmp_path / "from-flag" + + cfg.set_data_dir(explicit) + + assert cfg.get_data_dir() == explicit.resolve() diff --git a/backend/tests/test_ffmpeg_optional.py b/backend/tests/test_ffmpeg_optional.py new file mode 100644 index 000000000..bd18babb4 --- /dev/null +++ b/backend/tests/test_ffmpeg_optional.py @@ -0,0 +1,146 @@ +""" +Tests that ffmpeg stays optional. + +Voicebox does not bundle ffmpeg, so every path that can use it must still work +without it. These tests run the relevant behaviour twice — once as configured +on this machine, once with detection forced off — so a missing binary degrades +rather than breaks. + +Usage: + python -m pytest backend/tests/test_ffmpeg_optional.py -v +""" + +import io +import os +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-ffmpeg-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 +from backend.utils import ffmpeg # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +@pytest.fixture +def no_ffmpeg(monkeypatch): + """Pretend ffmpeg is not installed, however this machine is set up.""" + ffmpeg.reset_cache() + monkeypatch.setattr(ffmpeg.shutil, "which", lambda _name: None) + ffmpeg.reset_cache() + yield + ffmpeg.reset_cache() + + +@pytest.fixture +def story_with_audio(client, tmp_path): + story = client.post("/stories", json={"name": "ffmpeg test"}).json() + + sr = 48000 + t = np.linspace(0, 2.0, sr * 2, endpoint=False) + wav = tmp_path / "clip.wav" + sf.write(str(wav), (0.2 * np.sin(2 * np.pi * 440 * t)).astype(np.float32), sr) + + with wav.open("rb") as fh: + gen = client.post("/generate/import", files={"file": ("clip.wav", fh, "audio/wav")}).json() + client.post(f"/stories/{story['id']}/items", json={"generation_id": gen["id"]}) + + yield story + client.delete(f"/stories/{story['id']}") + + +# ── Detection ──────────────────────────────────────────────────────── + + +def test_detection_is_cached(): + ffmpeg.reset_cache() + first = ffmpeg.ffmpeg_path() + assert ffmpeg.ffmpeg_path() is first + + +def test_health_reports_availability(client): + body = client.get("/health").json() + assert "ffmpeg_available" in body + assert isinstance(body["ffmpeg_available"], bool) + + +def test_is_available_false_without_binary(no_ffmpeg): + assert ffmpeg.is_available() is False + + +# ── Export still works without ffmpeg ──────────────────────────────── + + +def test_export_succeeds_without_ffmpeg(client, story_with_audio, no_ffmpeg): + """The mixdown and all containers come from libsndfile, not ffmpeg.""" + for fmt in ("wav", "mp3", "ogg", "flac"): + r = client.get( + f"/stories/{story_with_audio['id']}/export-audio", params={"format": fmt} + ) + assert r.status_code == 200, f"{fmt} failed without ffmpeg: {r.text}" + assert len(r.content) > 0 + + +def test_loudness_request_degrades_rather_than_failing(client, story_with_audio, no_ffmpeg): + """Asking for normalisation without ffmpeg must still return audio.""" + r = client.get( + f"/stories/{story_with_audio['id']}/export-audio", + params={"format": "wav", "normalize_loudness": True}, + ) + assert r.status_code == 200 + data, sr = sf.read(io.BytesIO(r.content), dtype="float32", always_2d=True) + assert sr == 48000 + assert np.abs(data).max() > 0 + + +def test_normalize_loudness_returns_none_without_ffmpeg(no_ffmpeg): + assert ffmpeg.normalize_loudness(b"not really audio") is None + + +# ── Import formats are honest ──────────────────────────────────────── + + +def test_ffmpeg_only_extensions_are_identified(): + assert ffmpeg.requires_ffmpeg(".m4a") + assert ffmpeg.requires_ffmpeg(".webm") + assert not ffmpeg.requires_ffmpeg(".wav") + assert not ffmpeg.requires_ffmpeg(".mp3") + + +def test_m4a_import_rejected_clearly_without_ffmpeg(client, no_ffmpeg): + """Previously this got past validation and died deep in the decoder.""" + r = client.post( + "/generate/import", + files={"file": ("music.m4a", io.BytesIO(b"\x00" * 1024), "audio/mp4")}, + ) + assert r.status_code == 400 + assert "ffmpeg" in r.json()["detail"].lower() + + +def test_libsndfile_formats_need_no_ffmpeg(client, tmp_path, no_ffmpeg): + """WAV/FLAC/OGG/MP3 must import with ffmpeg absent.""" + sr = 24000 + t = np.linspace(0, 1.0, sr, endpoint=False) + tone = (0.2 * np.sin(2 * np.pi * 330 * t)).astype(np.float32) + + for name, fmt in (("a.wav", "WAV"), ("a.flac", "FLAC"), ("a.ogg", "OGG")): + path = tmp_path / name + sf.write(str(path), tone, sr, format=fmt) + with path.open("rb") as fh: + r = client.post("/generate/import", files={"file": (name, fh, "audio/*")}) + assert r.status_code == 200, f"{name} rejected without ffmpeg: {r.text}" diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py new file mode 100644 index 000000000..087eac95b --- /dev/null +++ b/backend/tests/test_folders.py @@ -0,0 +1,330 @@ +""" +Tests for folder organisation of voices and generated clips. + +Covers the asymmetry between the two folder kinds: voice folders are flat, +clip folders nest. Also covers the guarantee that deleting a folder never +deletes what is inside it. + +VOICEBOX_DATA_DIR is set before importing the app so the whole suite runs +against a throwaway data directory and never touches a real install. + +Usage: + python -m pytest backend/tests/test_folders.py -v +""" + +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-folders-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + # Context-manager form triggers the lifespan, which runs init_db(). + with TestClient(app) as c: + yield c + + +@pytest.fixture +def voice_folder(client): + r = client.post("/folders", json={"name": "Podcast voices", "kind": "voice"}) + assert r.status_code == 200, r.text + folder = r.json() + yield folder + client.delete(f"/folders/{folder['id']}") + + +@pytest.fixture +def profile(client): + """A preset profile — deliberately one with no samples, since those are + exactly the profiles export/import cannot round-trip.""" + r = client.post( + "/profiles", + json={ + "name": "Folder Test Voice", + "description": "fixture", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": "Speaks in short, dry sentences.", + }, + ) + assert r.status_code == 200, r.text + created = r.json() + yield created + client.delete(f"/profiles/{created['id']}") + + +# ── Folder CRUD ────────────────────────────────────────────────────── + + +def test_create_and_list_voice_folder(client, voice_folder): + assert voice_folder["kind"] == "voice" + assert voice_folder["parent_id"] is None + + listed = client.get("/folders", params={"kind": "voice"}).json() + assert voice_folder["id"] in [f["id"] for f in listed] + + +def test_voice_folders_are_excluded_from_generation_listing(client, voice_folder): + listed = client.get("/folders", params={"kind": "generation"}).json() + assert voice_folder["id"] not in [f["id"] for f in listed] + + +def test_unknown_kind_is_rejected(client): + assert client.get("/folders", params={"kind": "nonsense"}).status_code == 400 + + +# ── Story folders ──────────────────────────────────────────────────── + + +def test_story_folders_nest(client): + """Stories nest like clips, not flat like voices.""" + parent = client.post("/folders", json={"name": "Podcast", "kind": "story"}).json() + child = client.post( + "/folders", json={"name": "Season 2", "kind": "story", "parent_id": parent["id"]} + ) + assert child.status_code == 200, child.text + assert child.json()["parent_id"] == parent["id"] + + client.delete(f"/folders/{child.json()['id']}") + client.delete(f"/folders/{parent['id']}") + + +def test_assign_and_unassign_story(client): + folder = client.post("/folders", json={"name": "Archive", "kind": "story"}).json() + story = client.post("/stories", json={"name": "Folder Test Story"}).json() + + r = client.put(f"/stories/{story['id']}/folder", json={"folder_id": folder["id"]}) + assert r.status_code == 200, r.text + assert r.json()["folder_id"] == folder["id"] + + listed = client.get("/stories").json() + filed = next(s for s in listed if s["id"] == story["id"]) + assert filed["folder_id"] == folder["id"], "folder_id missing from the story list response" + + assert ( + client.put(f"/stories/{story['id']}/folder", json={"folder_id": None}).json()["folder_id"] + is None + ) + + client.delete(f"/stories/{story['id']}") + client.delete(f"/folders/{folder['id']}") + + +def test_story_cannot_go_into_a_voice_folder(client, voice_folder): + story = client.post("/stories", json={"name": "Wrong Folder Story"}).json() + r = client.put(f"/stories/{story['id']}/folder", json={"folder_id": voice_folder["id"]}) + assert r.status_code == 400 + client.delete(f"/stories/{story['id']}") + + +def test_deleting_a_story_folder_keeps_the_stories(client): + folder = client.post("/folders", json={"name": "Doomed", "kind": "story"}).json() + story = client.post("/stories", json={"name": "Survivor Story"}).json() + client.put(f"/stories/{story['id']}/folder", json={"folder_id": folder["id"]}) + + r = client.delete(f"/folders/{folder['id']}") + assert r.json()["items_released"] == 1 + + survivor = client.get("/stories").json() + kept = next(s for s in survivor if s["id"] == story["id"]) + assert kept["folder_id"] is None + + client.delete(f"/stories/{story['id']}") + + +def test_rename_folder(client, voice_folder): + r = client.patch(f"/folders/{voice_folder['id']}", json={"name": "Renamed"}) + assert r.status_code == 200 + assert r.json()["name"] == "Renamed" + + +def test_folder_name_is_trimmed(client): + r = client.post("/folders", json={"name": " Padded ", "kind": "voice"}) + assert r.json()["name"] == "Padded" + client.delete(f"/folders/{r.json()['id']}") + + +def test_missing_folder_is_404(client): + assert client.patch("/folders/nope", json={"name": "x"}).status_code == 404 + assert client.delete("/folders/nope").status_code == 404 + + +# ── Nesting rules ──────────────────────────────────────────────────── + + +def test_voice_folders_cannot_nest(client, voice_folder): + r = client.post( + "/folders", + json={"name": "Child", "kind": "voice", "parent_id": voice_folder["id"]}, + ) + assert r.status_code == 400 + assert "nested" in r.json()["detail"].lower() + + +def test_generation_folders_nest(client): + parent = client.post("/folders", json={"name": "Season 1", "kind": "generation"}).json() + child = client.post( + "/folders", + json={"name": "Episode 1", "kind": "generation", "parent_id": parent["id"]}, + ).json() + + assert child["parent_id"] == parent["id"] + + grandchild = client.post( + "/folders", + json={"name": "Takes", "kind": "generation", "parent_id": child["id"]}, + ) + assert grandchild.status_code == 200 + + client.delete(f"/folders/{grandchild.json()['id']}") + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +def test_parent_must_share_kind(client, voice_folder): + r = client.post( + "/folders", + json={"name": "Mismatched", "kind": "generation", "parent_id": voice_folder["id"]}, + ) + assert r.status_code == 400 + + +def test_folder_cannot_be_its_own_parent(client): + folder = client.post("/folders", json={"name": "Loop", "kind": "generation"}).json() + r = client.patch(f"/folders/{folder['id']}", json={"parent_id": folder["id"]}) + assert r.status_code == 400 + client.delete(f"/folders/{folder['id']}") + + +def test_folder_cannot_move_into_its_own_descendant(client): + """The cycle that would orphan a whole subtree from the root.""" + parent = client.post("/folders", json={"name": "Outer", "kind": "generation"}).json() + child = client.post( + "/folders", + json={"name": "Inner", "kind": "generation", "parent_id": parent["id"]}, + ).json() + + r = client.patch(f"/folders/{parent['id']}", json={"parent_id": child["id"]}) + assert r.status_code == 400 + assert "inside itself" in r.json()["detail"].lower() + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +def test_detach_moves_folder_to_root(client): + parent = client.post("/folders", json={"name": "P", "kind": "generation"}).json() + child = client.post( + "/folders", json={"name": "C", "kind": "generation", "parent_id": parent["id"]} + ).json() + + r = client.post(f"/folders/{child['id']}/detach") + assert r.status_code == 200 + assert r.json()["parent_id"] is None + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{parent['id']}") + + +# ── Membership ─────────────────────────────────────────────────────── + + +def test_assign_and_unassign_profile(client, voice_folder, profile): + r = client.put( + f"/profiles/{profile['id']}/folder", json={"folder_id": voice_folder["id"]} + ) + assert r.status_code == 200 + assert r.json()["folder_id"] == voice_folder["id"] + + r = client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": None}) + assert r.json()["folder_id"] is None + + +def test_profile_cannot_go_into_a_clip_folder(client, profile): + clip_folder = client.post( + "/folders", json={"name": "Clips", "kind": "generation"} + ).json() + + r = client.put( + f"/profiles/{profile['id']}/folder", json={"folder_id": clip_folder["id"]} + ) + assert r.status_code == 400 + + client.delete(f"/folders/{clip_folder['id']}") + + +def test_item_count_reflects_members(client, voice_folder, profile): + client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": voice_folder["id"]}) + + listed = client.get("/folders", params={"kind": "voice"}).json() + entry = next(f for f in listed if f["id"] == voice_folder["id"]) + assert entry["item_count"] == 1 + + +# ── Deletion preserves contents ────────────────────────────────────── + + +def test_deleting_folder_releases_members_but_keeps_them(client, profile): + folder = client.post("/folders", json={"name": "Temp", "kind": "voice"}).json() + client.put(f"/profiles/{profile['id']}/folder", json={"folder_id": folder["id"]}) + + r = client.delete(f"/folders/{folder['id']}") + assert r.status_code == 200 + assert r.json()["items_released"] == 1 + + # The voice itself must survive, now uncategorised. + survivor = client.get(f"/profiles/{profile['id']}") + assert survivor.status_code == 200 + assert survivor.json()["folder_id"] is None + + +def test_deleting_parent_reparents_children_rather_than_orphaning(client): + grandparent = client.post("/folders", json={"name": "GP", "kind": "generation"}).json() + parent = client.post( + "/folders", json={"name": "P", "kind": "generation", "parent_id": grandparent["id"]} + ).json() + child = client.post( + "/folders", json={"name": "C", "kind": "generation", "parent_id": parent["id"]} + ).json() + + r = client.delete(f"/folders/{parent['id']}") + assert r.json()["folders_reparented"] == 1 + + listed = client.get("/folders", params={"kind": "generation"}).json() + moved = next(f for f in listed if f["id"] == child["id"]) + assert moved["parent_id"] == grandparent["id"] + + client.delete(f"/folders/{child['id']}") + client.delete(f"/folders/{grandparent['id']}") + + +# ── History filtering ──────────────────────────────────────────────── + + +def test_history_rejects_out_of_range_limit_with_422(client): + """Previously surfaced as a 500 — HistoryQuery was built from raw ints + inside the handler, so its ValidationError escaped as a server error.""" + assert client.get("/history", params={"limit": 500}).status_code == 422 + + +def test_history_accepts_folder_filters(client): + folder = client.post("/folders", json={"name": "F", "kind": "generation"}).json() + + assert client.get("/history", params={"folder_id": folder["id"]}).status_code == 200 + assert client.get("/history", params={"uncategorised_only": True}).status_code == 200 + + client.delete(f"/folders/{folder['id']}") diff --git a/backend/tests/test_profile_duplicate.py b/backend/tests/test_profile_duplicate.py new file mode 100644 index 000000000..f6f7b3ebe --- /dev/null +++ b/backend/tests/test_profile_duplicate.py @@ -0,0 +1,343 @@ +""" +Tests for POST /profiles/{id}/duplicate. + +The point of the endpoint is that it is *not* an export/import round-trip. +export_import.py writes only name/description/language into its manifest, so +importing an exported profile silently drops personality, effects_chain, +default_engine and the preset/designed fields -- and export refuses profiles +with no samples, which is every preset voice. These tests pin the fields a +round-trip would have lost. + +Usage: + python -m pytest backend/tests/test_profile_duplicate.py -v +""" + +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-duplicate-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + +EFFECTS = [{"type": "reverb", "params": {"room_size": 0.4}}] +PERSONALITY = "Speaks in short, dry sentences. Never uses exclamation marks." + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +def _delete(client, profile_id: str) -> None: + client.delete(f"/profiles/{profile_id}") + + +@pytest.fixture +def preset_profile(client): + """A preset (Kokoro) profile: rich metadata, zero samples.""" + r = client.post( + "/profiles", + json={ + "name": "Duplicate Source", + "description": "original description", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": PERSONALITY, + }, + ) + assert r.status_code == 200, r.text + created = r.json() + yield created + _delete(client, created["id"]) + + +def test_duplicate_preserves_personality(client, preset_profile): + """The field an export/import round-trip loses most damagingly.""" + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["personality"] == PERSONALITY + finally: + _delete(client, copy["id"]) + + +def test_duplicate_preserves_preset_fields(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["voice_type"] == "preset" + assert copy["preset_engine"] == "kokoro" + assert copy["preset_voice_id"] == "af_bella" + # default_engine is auto-derived from preset_engine on create. + assert copy["default_engine"] == "kokoro" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_works_for_profiles_without_samples(client, preset_profile): + """Export raises ValueError for a sample-less profile, so this case is + unreachable via export/import.""" + r = client.post(f"/profiles/{preset_profile['id']}/duplicate") + assert r.status_code == 200, r.text + try: + assert r.json()["sample_count"] == 0 + finally: + _delete(client, r.json()["id"]) + + +def test_duplicate_preserves_description_and_language(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["description"] == "original description" + assert copy["language"] == "en" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_gets_a_new_id_and_copy_suffixed_name(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["id"] != preset_profile["id"] + assert copy["name"] == "Duplicate Source (copy)" + finally: + _delete(client, copy["id"]) + + +def test_repeated_duplicates_get_distinct_names(client, preset_profile): + """profiles.name is UNIQUE, so the second copy must not collide.""" + first = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + second = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert first["name"] == "Duplicate Source (copy)" + assert second["name"] == "Duplicate Source (copy) (1)" + finally: + _delete(client, first["id"]) + _delete(client, second["id"]) + + +def test_duplicate_accepts_an_explicit_name(client, preset_profile): + copy = client.post( + f"/profiles/{preset_profile['id']}/duplicate", json={"name": "Chosen Name"} + ).json() + try: + assert copy["name"] == "Chosen Name" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_starts_with_no_generations(client, preset_profile): + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["generation_count"] == 0 + finally: + _delete(client, copy["id"]) + + +def test_duplicate_preserves_effects_chain(client, preset_profile): + set_effects = client.put( + f"/profiles/{preset_profile['id']}/effects", json={"effects_chain": EFFECTS} + ) + assert set_effects.status_code == 200, set_effects.text + + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["effects_chain"], "effects chain was dropped by duplicate" + assert copy["effects_chain"][0]["type"] == "reverb" + finally: + _delete(client, copy["id"]) + + +def test_duplicate_inherits_folder(client, preset_profile): + folder = client.post("/folders", json={"name": "Dup Folder", "kind": "voice"}).json() + client.put(f"/profiles/{preset_profile['id']}/folder", json={"folder_id": folder["id"]}) + + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + assert copy["folder_id"] == folder["id"] + finally: + _delete(client, copy["id"]) + client.delete(f"/folders/{folder['id']}") + + +def test_editing_the_copy_leaves_the_original_untouched(client, preset_profile): + """A duplicate must be independent, not a shared reference.""" + copy = client.post(f"/profiles/{preset_profile['id']}/duplicate").json() + try: + client.put( + f"/profiles/{copy['id']}", + json={ + "name": "Edited Copy", + "description": "changed", + "language": "en", + "voice_type": "preset", + "preset_engine": "kokoro", + "preset_voice_id": "af_bella", + "personality": "Totally different.", + }, + ) + original = client.get(f"/profiles/{preset_profile['id']}").json() + assert original["personality"] == PERSONALITY + assert original["description"] == "original description" + finally: + _delete(client, copy["id"]) + + +def test_duplicating_a_missing_profile_is_404(client): + assert client.post("/profiles/does-not-exist/duplicate").status_code == 404 + + +# ── Sample copying ─────────────────────────────────────────────────── + + +def _write_reference_wav(path: Path) -> None: + """A 3s tone: long enough for the 2s minimum, loud enough for the RMS floor.""" + import numpy as np + import soundfile as sf + + sr = 24000 + t = np.linspace(0, 3.0, int(sr * 3.0), endpoint=False) + tone = (0.3 * np.sin(2 * np.pi * 220 * t)).astype("float32") + sf.write(str(path), tone, sr) + + +@pytest.fixture +def cloned_profile_with_sample(client, tmp_path): + r = client.post( + "/profiles", + json={"name": "Cloned Source", "description": "has a sample", "language": "en"}, + ) + assert r.status_code == 200, r.text + created = r.json() + + # Delete unconditionally: if the upload below fails, the profile would + # otherwise survive and collide with the next test's UNIQUE name. + try: + wav = tmp_path / "reference.wav" + _write_reference_wav(wav) + with wav.open("rb") as fh: + upload = client.post( + f"/profiles/{created['id']}/samples", + files={"file": ("reference.wav", fh, "audio/wav")}, + data={"reference_text": "This is the reference transcript."}, + ) + assert upload.status_code == 200, upload.text + + yield created + finally: + _delete(client, created["id"]) + + +def test_duplicate_copies_samples(client, cloned_profile_with_sample): + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + try: + assert copy["sample_count"] == 1 + + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + copy_samples = client.get(f"/profiles/{copy['id']}/samples").json() + + assert copy_samples[0]["reference_text"] == original_samples[0]["reference_text"] + # Distinct rows pointing at distinct files — not a shared reference. + assert copy_samples[0]["id"] != original_samples[0]["id"] + assert copy_samples[0]["audio_path"] != original_samples[0]["audio_path"] + finally: + _delete(client, copy["id"]) + + +def test_duplicated_sample_audio_is_byte_identical(client, cloned_profile_with_sample): + """Copied rather than re-encoded, so the clone sounds like the original.""" + from backend import config + + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + try: + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + copy_samples = client.get(f"/profiles/{copy['id']}/samples").json() + + original_bytes = config.resolve_storage_path( + original_samples[0]["audio_path"] + ).read_bytes() + copy_bytes = config.resolve_storage_path( + copy_samples[0]["audio_path"] + ).read_bytes() + + assert copy_bytes == original_bytes + finally: + _delete(client, copy["id"]) + + +def test_deleting_the_copy_leaves_the_originals_audio_intact(client, cloned_profile_with_sample): + """Copied files must be independent — deleting one must not take the + other's audio with it.""" + from backend import config + + copy = client.post(f"/profiles/{cloned_profile_with_sample['id']}/duplicate").json() + original_samples = client.get( + f"/profiles/{cloned_profile_with_sample['id']}/samples" + ).json() + original_audio = config.resolve_storage_path(original_samples[0]["audio_path"]) + + _delete(client, copy["id"]) + + assert original_audio.exists() + + +# ── Name allocation under contention (review finding on #1007) ─────── + + +def test_duplicate_names_are_settled_by_the_insert(client): + """`get_unique_profile_name` was a check-then-act: it SELECTed a free name + and a later INSERT took it, so two concurrent duplicates could both be + handed the same name and the loser hit the unique constraint as a 500. + + Simulated here by pre-taking the name between allocation attempts, which is + what a racing request does.""" + import uuid as _uuid + + from backend.database import VoiceProfile as DBVoiceProfile, get_db + from backend.services.profiles import insert_profile_with_unique_name + + db = next(get_db()) + try: + base = f"Race Test {_uuid.uuid4().hex[:8]}" + + # Something else already holds the base name. + db.add(DBVoiceProfile(id=str(_uuid.uuid4()), name=base, language="en")) + db.commit() + + row = insert_profile_with_unique_name( + base, + db, + lambda candidate: DBVoiceProfile( + id=str(_uuid.uuid4()), name=candidate, language="en" + ), + ) + assert row.name == f"{base} (1)", "should fall through to the next suffix" + + # And again, so the counter keeps advancing rather than sticking. + row2 = insert_profile_with_unique_name( + base, + db, + lambda candidate: DBVoiceProfile( + id=str(_uuid.uuid4()), name=candidate, language="en" + ), + ) + assert row2.name == f"{base} (2)" + + for name in (base, f"{base} (1)", f"{base} (2)"): + db.query(DBVoiceProfile).filter_by(name=name).delete() + db.commit() + finally: + db.close() diff --git a/backend/tests/test_story_mixdown.py b/backend/tests/test_story_mixdown.py new file mode 100644 index 000000000..87cba7d31 --- /dev/null +++ b/backend/tests/test_story_mixdown.py @@ -0,0 +1,407 @@ +""" +Tests for the story mixdown: stereo/project-rate mixing, fades, speed, +track gain, mute/solo, ducking, and export formats. + +The mixer previously flattened everything to 24 kHz mono, which destroyed an +imported music bed (12 kHz Nyquist, stereo image folded flat). These tests pin +the new behaviour and the placement rules that let a bed sit under narration. + +Usage: + python -m pytest backend/tests/test_story_mixdown.py -v +""" + +import io +import os +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +_DATA_DIR = tempfile.mkdtemp(prefix="voicebox-mixdown-test-") +os.environ["VOICEBOX_DATA_DIR"] = _DATA_DIR + +from starlette.testclient import TestClient # noqa: E402 + +from backend.app import app # noqa: E402 + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +def _tone(seconds: float, sr: int, freq: float, channels: int = 1, amp: float = 0.3): + t = np.linspace(0, seconds, int(sr * seconds), endpoint=False) + mono = (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32) + if channels == 1: + return mono + # Distinct content per channel so a mono fold-down is detectable. + right = (amp * np.sin(2 * np.pi * (freq * 1.5) * t)).astype(np.float32) + return np.stack([mono, right], axis=1) + + +def _import_audio(client, tmp_path, name, seconds=2.0, sr=48000, freq=440.0, channels=1): + """Register an audio file as an importable generation.""" + path = tmp_path / name + sf.write(str(path), _tone(seconds, sr, freq, channels), sr) + with path.open("rb") as fh: + r = client.post("/generate/import", files={"file": (name, fh, "audio/wav")}) + assert r.status_code == 200, r.text + return r.json() + + +@pytest.fixture +def story(client): + r = client.post("/stories", json={"name": "Mixdown Test"}) + assert r.status_code == 200, r.text + created = r.json() + yield created + client.delete(f"/stories/{created['id']}") + + +def _export(client, story_id, fmt=None): + params = {"format": fmt} if fmt else None + r = client.get(f"/stories/{story_id}/export-audio", params=params) + assert r.status_code == 200, r.text + return r.content + + +def _decode(raw): + data, sr = sf.read(io.BytesIO(raw), dtype="float32", always_2d=True) + return data, sr + + +# ── Placement ──────────────────────────────────────────────────────── + + +def test_imported_audio_lands_on_its_own_lane_at_zero(client, story, tmp_path): + """The bug this fixes: music used to be appended after the narration on + track 0 instead of playing underneath it.""" + voice = _import_audio(client, tmp_path, "voice.wav", seconds=2.0) + client.post(f"/stories/{story['id']}/items", json={"generation_id": voice["id"], "track": 0}) + + bed = _import_audio(client, tmp_path, "bed.wav", seconds=2.0, freq=220.0) + r = client.post(f"/stories/{story['id']}/items", json={"generation_id": bed["id"]}) + assert r.status_code == 200, r.text + + item = r.json() + assert item["track"] != 0, "bed landed on the voice lane" + assert item["start_time_ms"] == 0, "bed did not start at the top of the timeline" + + +def test_explicit_track_zero_is_respected(client, story, tmp_path): + """track=0 must mean track 0, not 'unspecified' — the reason + StoryItemCreate.track had to become nullable.""" + clip = _import_audio(client, tmp_path, "explicit.wav") + r = client.post( + f"/stories/{story['id']}/items", + json={"generation_id": clip["id"], "track": 0, "start_time_ms": 500}, + ) + assert r.json()["track"] == 0 + assert r.json()["start_time_ms"] == 500 + + +# ── Project rate and channels ──────────────────────────────────────── + + +def test_mixdown_keeps_48k_stereo(client, story, tmp_path): + """A 48 kHz stereo bed must survive; it used to come out 24 kHz mono.""" + bed = _import_audio(client, tmp_path, "stereo48.wav", sr=48000, channels=2) + client.post(f"/stories/{story['id']}/items", json={"generation_id": bed["id"]}) + + data, sr = _decode(_export(client, story["id"])) + assert sr == 48000, f"project rate collapsed to {sr}" + assert data.shape[1] == 2 + # Channels carry different tones, so a mono fold-down would make them equal. + assert not np.allclose(data[:, 0], data[:, 1]), "stereo image was folded to mono" + + +def test_project_rate_is_capped_at_48k(client, story, tmp_path): + bed = _import_audio(client, tmp_path, "hires.wav", sr=96000) + client.post(f"/stories/{story['id']}/items", json={"generation_id": bed["id"]}) + + _data, sr = _decode(_export(client, story["id"])) + assert sr == 48000 + + +# ── Fades ──────────────────────────────────────────────────────────── + + +def test_fades_ramp_from_and_to_silence(client, story, tmp_path): + clip = _import_audio(client, tmp_path, "fade.wav", seconds=2.0, sr=48000) + item = client.post( + f"/stories/{story['id']}/items", json={"generation_id": clip["id"]} + ).json() + + r = client.put( + f"/stories/{story['id']}/items/{item['id']}/fades", + json={"fade_in_ms": 500, "fade_out_ms": 500}, + ) + assert r.status_code == 200, r.text + + data, sr = _decode(_export(client, story["id"])) + mono = data.mean(axis=1) + + head = np.abs(mono[: sr // 100]).max() + tail = np.abs(mono[-sr // 100 :]).max() + middle = np.abs(mono[len(mono) // 2 - sr // 20 : len(mono) // 2 + sr // 20]).max() + + assert head < 0.02, f"fade-in did not start near silence ({head:.4f})" + assert tail < 0.02, f"fade-out did not end near silence ({tail:.4f})" + assert middle > 0.1, "fades swallowed the whole clip" + + +def test_overlong_fades_are_scaled_not_clipped(client, story, tmp_path): + """Fades longer than the clip must stay monotonic rather than + re-brightening in the middle.""" + clip = _import_audio(client, tmp_path, "shortfade.wav", seconds=1.0, sr=48000) + item = client.post( + f"/stories/{story['id']}/items", json={"generation_id": clip["id"]} + ).json() + client.put( + f"/stories/{story['id']}/items/{item['id']}/fades", + json={"fade_in_ms": 5000, "fade_out_ms": 5000}, + ) + + data, _sr = _decode(_export(client, story["id"])) + mono = np.abs(data.mean(axis=1)) + peak_at = int(np.argmax(mono)) + # Peak should sit near the middle, where the two ramps cross. + assert 0.3 < peak_at / len(mono) < 0.7 + + +# ── Speed ──────────────────────────────────────────────────────────── + + +def test_double_speed_halves_the_clip(client, story, tmp_path): + clip = _import_audio(client, tmp_path, "speed.wav", seconds=4.0, sr=48000) + item = client.post( + f"/stories/{story['id']}/items", json={"generation_id": clip["id"]} + ).json() + + baseline, _sr = _decode(_export(client, story["id"])) + + r = client.put(f"/stories/{story['id']}/items/{item['id']}/speed", json={"speed": 2.0}) + assert r.status_code == 200, r.text + + faster, _sr = _decode(_export(client, story["id"])) + ratio = len(faster) / len(baseline) + assert 0.45 < ratio < 0.55, f"expected ~half length, got ratio {ratio:.2f}" + + +def test_speed_is_bounded(client, story, tmp_path): + clip = _import_audio(client, tmp_path, "speedbound.wav") + item = client.post( + f"/stories/{story['id']}/items", json={"generation_id": clip["id"]} + ).json() + assert ( + client.put( + f"/stories/{story['id']}/items/{item['id']}/speed", json={"speed": 99.0} + ).status_code + == 422 + ) + + +# ── Track gain, mute, solo ─────────────────────────────────────────── + + +def _two_lane_story(client, story, tmp_path, prefix): + voice = _import_audio(client, tmp_path, f"{prefix}-voice.wav", seconds=2.0, sr=48000, freq=440) + bed = _import_audio(client, tmp_path, f"{prefix}-bed.wav", seconds=2.0, sr=48000, freq=220) + client.post( + f"/stories/{story['id']}/items", json={"generation_id": voice["id"], "track": 0} + ) + client.post( + f"/stories/{story['id']}/items", + json={"generation_id": bed["id"], "track": 1, "start_time_ms": 0}, + ) + + +def test_track_volume_attenuates_that_lane(client, story, tmp_path): + _two_lane_story(client, story, tmp_path, "vol") + before, _ = _decode(_export(client, story["id"])) + + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 0.0, "muted": False, "soloed": False}, + ) + after, _ = _decode(_export(client, story["id"])) + + assert np.abs(after).max() < np.abs(before).max() + + +def test_mute_silences_a_lane(client, story, tmp_path): + _two_lane_story(client, story, tmp_path, "mute") + client.put( + f"/stories/{story['id']}/tracks/0", + json={"volume": 1.0, "muted": True, "soloed": False}, + ) + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 0.0, "muted": True, "soloed": False}, + ) + data, _ = _decode(_export(client, story["id"])) + assert np.abs(data).max() < 1e-6, "muting every lane should render silence" + + +def test_solo_silences_every_other_lane(client, story, tmp_path): + """Solo is global: one soloed lane mutes the rest regardless of their own + mute flags.""" + _two_lane_story(client, story, tmp_path, "solo") + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 1.0, "muted": False, "soloed": True}, + ) + data, sr = _decode(_export(client, story["id"])) + + # Only the 220 Hz bed should remain; check via a coarse spectrum. + spectrum = np.abs(np.fft.rfft(data.mean(axis=1))) + freqs = np.fft.rfftfreq(len(data), 1 / sr) + energy_220 = spectrum[(freqs > 200) & (freqs < 240)].sum() + energy_440 = spectrum[(freqs > 420) & (freqs < 460)].sum() + assert energy_220 > energy_440 * 5, "soloed lane did not dominate" + + +def test_deleting_a_story_removes_its_track_settings(client, tmp_path): + """Track rows are keyed by story_id with no FK cascade, so deleting a + story has to clear them or they linger as unreachable rows.""" + doomed = client.post("/stories", json={"name": "Doomed"}).json() + clip = _import_audio(client, tmp_path, "doomed.wav") + client.post(f"/stories/{doomed['id']}/items", json={"generation_id": clip["id"], "track": 0}) + client.put( + f"/stories/{doomed['id']}/tracks/0", + json={"volume": 0.5, "muted": False, "soloed": False}, + ) + assert len(client.get(f"/stories/{doomed['id']}/tracks").json()) == 1 + + assert client.delete(f"/stories/{doomed['id']}").status_code == 200 + assert client.get(f"/stories/{doomed['id']}/tracks").json() == [] + + +def test_deleting_track_settings_keeps_the_clips(client, story, tmp_path): + _two_lane_story(client, story, tmp_path, "del") + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 0.5, "muted": False, "soloed": False}, + ) + assert client.delete(f"/stories/{story['id']}/tracks/1").status_code == 200 + + detail = client.get(f"/stories/{story['id']}").json() + assert any(i["track"] == 1 for i in detail["items"]), "clips vanished with the settings row" + assert client.get(f"/stories/{story['id']}/tracks").json() == [] + + +def test_ducking_lowers_the_bed(client, story, tmp_path): + _two_lane_story(client, story, tmp_path, "duck") + before, _ = _decode(_export(client, story["id"])) + + client.put( + f"/stories/{story['id']}/tracks/1", + json={"volume": 1.0, "muted": False, "soloed": False, "duck_under_track": 0}, + ) + after, sr = _decode(_export(client, story["id"])) + + spectrum_before = np.abs(np.fft.rfft(before.mean(axis=1))) + spectrum_after = np.abs(np.fft.rfft(after.mean(axis=1))) + freqs = np.fft.rfftfreq(len(before), 1 / sr) + band = (freqs > 200) & (freqs < 240) + assert spectrum_after[band].sum() < spectrum_before[band].sum(), "bed was not ducked" + + +# ── Export formats ─────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("fmt", "magic"), + [ + ("wav", (b"RIFF",)), + ("mp3", (b"ID3", b"\xff\xfb", b"\xff\xf3", b"\xff\xf2")), + ("ogg", (b"OggS",)), + ("opus", (b"OggS",)), + ("flac", (b"fLaC",)), + ], +) +def test_export_formats(client, story, tmp_path, fmt, magic): + clip = _import_audio(client, tmp_path, f"fmt-{fmt}.wav", sr=48000) + client.post(f"/stories/{story['id']}/items", json={"generation_id": clip["id"]}) + + raw = _export(client, story["id"], fmt=fmt) + assert any(raw.startswith(m) for m in magic), f"{fmt} magic bytes wrong: {raw[:4]!r}" + + +def test_default_export_is_still_wav(client, story, tmp_path): + clip = _import_audio(client, tmp_path, "default.wav") + client.post(f"/stories/{story['id']}/items", json={"generation_id": clip["id"]}) + assert _export(client, story["id"]).startswith(b"RIFF") + + +def test_unknown_format_is_rejected(client, story): + r = client.get(f"/stories/{story['id']}/export-audio", params={"format": "aiff"}) + assert r.status_code == 400 + + +# ── Track validation (review findings on #1007) ────────────────────── + + +def test_a_lane_cannot_duck_under_itself(client, story): + """It would attenuate by its own envelope — quieter wherever it is loudest.""" + r = client.put(f"/stories/{story['id']}/tracks/1", json={"duck_under_track": 1}) + assert r.status_code == 400 + assert "itself" in r.json()["detail"] + + +def test_a_negative_duck_target_is_rejected(client, story): + """Lane indices are non-negative, so a negative target is not a lane — it + would just silently never match one at mix time.""" + r = client.put(f"/stories/{story['id']}/tracks/0", json={"duck_under_track": -1}) + assert r.status_code == 422 + + +def test_a_valid_duck_target_is_accepted(client, story): + r = client.put(f"/stories/{story['id']}/tracks/1", json={"duck_under_track": 0}) + assert r.status_code == 200, r.text + assert r.json()["duck_under_track"] == 0 + + +# ── Time stretching for per-clip speed ─────────────────────────────── + + +@pytest.mark.parametrize("rate", [0.5, 0.8, 1.25, 2.0]) +def test_speed_change_has_an_accurate_ratio(rate): + from backend.utils.audio import time_stretch_speech + + out = time_stretch_speech(_tone(1.0, 24000, 220.0), rate, 24000) + assert len(out) / 24000 == pytest.approx(1.0 / rate, rel=0.06) + + +def test_speed_change_preserves_pitch(): + """Resampling would transpose the voice, which is not what a speed control + means. WSOLA keeps the pitch and only changes the tempo.""" + from backend.utils.audio import time_stretch_speech + + sr = 24000 + original = _tone(1.0, sr, 220.0) + slower = time_stretch_speech(original, 0.5, sr) + + def dominant_hz(x): + return np.fft.rfftfreq(len(x), 1 / sr)[int(np.argmax(np.abs(np.fft.rfft(x))))] + + assert dominant_hz(slower) == pytest.approx(dominant_hz(original), rel=0.05) + + +def test_speed_change_handles_stereo(): + """The mixer works in (channels, samples); both channels must stretch by + the same amount or the image tears.""" + from backend.utils.audio import time_stretch_speech + + sr = 24000 + stereo = np.stack([_tone(1.0, sr, 220.0), _tone(1.0, sr, 330.0)]) + out = time_stretch_speech(stereo, 0.8, sr) + assert out.shape[0] == 2 + assert out.shape[1] == pytest.approx(sr / 0.8, rel=0.06) diff --git a/backend/utils/audio.py b/backend/utils/audio.py index bccab2c80..23e261a12 100644 --- a/backend/utils/audio.py +++ b/backend/utils/audio.py @@ -2,6 +2,8 @@ Audio processing utilities. """ +import io + import numpy as np import soundfile as sf import librosa @@ -51,12 +53,12 @@ def load_audio( ) -> Tuple[np.ndarray, int]: """ Load audio file with normalization. - + Args: path: Path to audio file sample_rate: Target sample rate mono: Convert to mono - + Returns: Tuple of (audio_array, sample_rate) """ @@ -64,6 +66,164 @@ def load_audio( return audio, sr +# Container -> (soundfile format, default subtype, MIME type, file extension). +# Every one of these is compiled into the libsndfile shipped with the app +# (1.2.2, with LAME, mpg123, Vorbis, Opus and FLAC), so none of them needs +# ffmpeg. +EXPORT_FORMATS: dict[str, dict[str, str]] = { + "wav": {"format": "WAV", "subtype": "PCM_16", "mime": "audio/wav", "ext": ".wav"}, + "mp3": {"format": "MP3", "subtype": "MPEG_LAYER_III", "mime": "audio/mpeg", "ext": ".mp3"}, + "ogg": {"format": "OGG", "subtype": "VORBIS", "mime": "audio/ogg", "ext": ".ogg"}, + "opus": {"format": "OGG", "subtype": "OPUS", "mime": "audio/ogg", "ext": ".opus"}, + "flac": {"format": "FLAC", "subtype": "PCM_16", "mime": "audio/flac", "ext": ".flac"}, +} + +# Opus only ever encodes at 48 kHz; libsndfile errors on anything else. +_OPUS_SAMPLE_RATE = 48000 + + +def encode_audio(audio: np.ndarray, sample_rate: int, fmt: str = "wav") -> bytes: + """Encode audio to a container's bytes. + + Args: + audio: ``(samples,)`` mono or ``(channels, samples)`` multi-channel. + sample_rate: Sample rate in Hz. + fmt: A key of :data:`EXPORT_FORMATS`. + + Returns: + Encoded file contents. + + Raises: + ValueError: If ``fmt`` is not a supported container. + """ + spec = EXPORT_FORMATS.get(fmt.lower()) + if spec is None: + raise ValueError(f"Unsupported export format '{fmt}'. Supported: {sorted(EXPORT_FORMATS)}") + + # soundfile wants (samples, channels); the mixer works in (channels, samples). + data = audio.T if audio.ndim > 1 else audio + + if fmt.lower() == "opus" and sample_rate != _OPUS_SAMPLE_RATE: + data = librosa.resample( + data.T if data.ndim > 1 else data, + orig_sr=sample_rate, + target_sr=_OPUS_SAMPLE_RATE, + ) + data = data.T if data.ndim > 1 else data + sample_rate = _OPUS_SAMPLE_RATE + + buffer = io.BytesIO() + sf.write( + buffer, + data.astype(np.float32), + sample_rate, + format=spec["format"], + subtype=spec["subtype"], + ) + return buffer.getvalue() + + +# WSOLA windowing. 30ms frames are long enough to hold a pitch period at any +# adult speaking F0 and short enough that a splice lands inside one phoneme. +_WSOLA_FRAME_MS = 30 +_WSOLA_SEARCH_MS = 10 + + +def _wsola_splices(reference: np.ndarray, rate: float, sr: int) -> tuple[list[int], int, int]: + """Plan the splice points for a WSOLA stretch of *reference*. + + Returns ``(offsets, frame, synthesis_hop)``. Planning separately from + applying is what lets every channel of a multi-channel clip use the *same* + splices: the search is content-dependent, so planning per channel would + choose different points for left and right and tear the stereo image. + """ + frame = max(2, int(sr * _WSOLA_FRAME_MS / 1000)) + search = max(1, int(sr * _WSOLA_SEARCH_MS / 1000)) + synthesis_hop = frame // 2 + analysis_hop = round(synthesis_hop * rate) + if analysis_hop < 1: + return [], frame, synthesis_hop + + offsets: list[int] = [] + read = 0 + expected = reference[:frame].astype(np.float32) + + while read + frame + search < len(reference): + lo = max(0, read - search) + hi = min(len(reference) - frame, read + search) + if hi <= lo: + offset = read + else: + candidates = np.arange(lo, hi + 1) + scores = [float(np.dot(reference[c : c + frame], expected)) for c in candidates] + offset = int(candidates[int(np.argmax(scores))]) + + offsets.append(offset) + nxt = reference[offset + synthesis_hop : offset + synthesis_hop + frame] + if len(nxt) < frame: + break + expected = nxt + # The nominal pointer advances by analysis_hop regardless of where the + # search landed. Folding the offset back in would let a run of + # forward-biased matches accelerate the read and cut the output short. + read += analysis_hop + + return offsets, frame, synthesis_hop + + +def _wsola_apply(channel: np.ndarray, offsets: list[int], frame: int, hop: int) -> np.ndarray: + """Overlap-add *channel* at the planned splice points.""" + if not offsets: + return channel + window = np.hanning(frame).astype(np.float32) + length = hop * len(offsets) + frame + out = np.zeros(length, dtype=np.float32) + weights = np.zeros(length, dtype=np.float32) + + for i, offset in enumerate(offsets): + segment = channel[offset : offset + frame] + if len(segment) < frame: + break + write = i * hop + out[write : write + frame] += segment * window + weights[write : write + frame] += window + + nonzero = weights > 1e-6 + out[nonzero] /= weights[nonzero] + return out + + +def time_stretch_speech(audio, rate: float, sr: int): + """Change tempo without changing pitch, tuned for speech. + + A phase vocoder (``librosa.effects.time_stretch``) reconstructs from + magnitudes and re-estimated phase; on speech that smears consonants and + leaves a phasey ring, audible enough to be rejected in listening tests. + WSOLA stays in the time domain and overlap-adds real waveform segments, so + nothing is resynthesised. + + Resampling would transpose the voice, which is not what a speed control + means, so it is not an option either. + + Accepts mono ``(samples,)`` or multi-channel ``(channels, samples)`` and + returns the same shape. Multi-channel input is planned once from the + downmix, so every channel is spliced identically and the stereo image + survives -- and every channel comes out the same length, which stacking + requires. + """ + audio = np.asarray(audio, dtype=np.float32) + if rate == 1.0 or audio.size == 0: + return audio + + if audio.ndim > 1: + reference = audio.mean(axis=0) + offsets, frame, hop = _wsola_splices(reference, rate, sr) + return np.stack([_wsola_apply(ch, offsets, frame, hop) for ch in audio]) + + offsets, frame, hop = _wsola_splices(audio, rate, sr) + return _wsola_apply(audio, offsets, frame, hop) + + def save_audio( audio: np.ndarray, path: str, diff --git a/backend/utils/ffmpeg.py b/backend/utils/ffmpeg.py new file mode 100644 index 000000000..f96d91879 --- /dev/null +++ b/backend/utils/ffmpeg.py @@ -0,0 +1,110 @@ +"""Optional ffmpeg integration. + +Voicebox does not bundle ffmpeg and must not require it: the mixdown, the +export formats, the time-stretch and the ducking all have working pure-Python +paths. ffmpeg is used only where it is genuinely better, and every call site +falls back when it is absent. + +Where it wins: + - ``loudnorm`` — EBU R128 loudness normalisation. Clips generated from + different voices land at noticeably different levels, and peak + normalisation (the fallback) does nothing about that. + +Where it is already load-bearing, whether we like it or not: + - Decoding ``.m4a`` / ``.aac`` / ``.webm``. libsndfile handles none of them, + so librosa falls through to audioread, which shells out to ffmpeg. Those + extensions are advertised by the import endpoint, so without ffmpeg they + fail deep in the decoder with an opaque message. :func:`requires_ffmpeg` + lets callers reject them up front instead. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Containers libsndfile cannot open, so librosa must fall back to +# audioread -> ffmpeg. Keep in sync with IMPORT_AUDIO_EXTENSIONS. +FFMPEG_ONLY_EXTENSIONS = {".m4a", ".aac", ".webm"} + +# Resolved once — a PATH lookup per audio operation is wasteful, and the +# answer cannot change within a process run. +_cached_path: str | None = None +_probed = False + + +def ffmpeg_path() -> str | None: + """Absolute path to ffmpeg, or None when it isn't installed.""" + global _cached_path, _probed + if not _probed: + _cached_path = shutil.which("ffmpeg") + _probed = True + logger.info("ffmpeg %s", f"found at {_cached_path}" if _cached_path else "not found on PATH") + return _cached_path + + +def is_available() -> bool: + """Whether the optional ffmpeg paths can be used.""" + return ffmpeg_path() is not None + + +def reset_cache() -> None: + """Forget the cached lookup. Used by tests to exercise the fallback path.""" + global _cached_path, _probed + _cached_path = None + _probed = False + + +def requires_ffmpeg(suffix: str) -> bool: + """Whether decoding ``suffix`` needs ffmpeg that we may not have.""" + return suffix.lower() in FFMPEG_ONLY_EXTENSIONS + + +def normalize_loudness( + audio_bytes: bytes, + suffix: str = ".wav", + target_lufs: float = -16.0, + true_peak: float = -1.5, +) -> bytes | None: + """Loudness-normalise an encoded file to ``target_lufs`` (EBU R128). + + -16 LUFS is the usual target for spoken-word podcasts; -1.5 dBTP leaves + headroom for lossy codecs, which can overshoot on decode. + + Returns: + Normalised file bytes, or ``None`` if ffmpeg is unavailable or fails — + callers keep their existing output in that case. + """ + exe = ffmpeg_path() + if exe is None: + return None + + with tempfile.TemporaryDirectory(prefix="voicebox-loudnorm-") as tmp: + src = Path(tmp) / f"in{suffix}" + dst = Path(tmp) / f"out{suffix}" + src.write_bytes(audio_bytes) + + cmd = [ + exe, + "-hide_banner", + "-loglevel", "error", + "-nostdin", + "-y", + "-i", str(src), + "-af", f"loudnorm=I={target_lufs}:TP={true_peak}:LRA=11", + str(dst), + ] + try: + subprocess.run(cmd, check=True, capture_output=True, timeout=300) + except (subprocess.SubprocessError, OSError) as exc: + logger.warning("ffmpeg loudnorm failed, keeping un-normalised audio: %s", exc) + return None + + if not dst.exists() or dst.stat().st_size == 0: + return None + return dst.read_bytes() diff --git a/requirements.txt b/requirements.txt index ea444b9cf..2d9a50fd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ fastapi sqlalchemy torch torchvision -soundfile +soundfile>=0.13.0 # libsndfile 1.2.2, for MP3/Opus export librosa python-multipart huggingface_hub diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock index 779f098c0..1f0549fd6 100644 --- a/tauri/src-tauri/Cargo.lock +++ b/tauri/src-tauri/Cargo.lock @@ -5113,7 +5113,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "voicebox" -version = "0.5.0" +version = "0.5.1" dependencies = [ "base64 0.22.1", "core-foundation-sys", diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json index 9c055848f..9b753d836 100644 --- a/tauri/src-tauri/tauri.conf.json +++ b/tauri/src-tauri/tauri.conf.json @@ -49,6 +49,7 @@ "resizable": true, "fullscreen": false, "devtools": true, + "dragDropEnabled": false, "userAgent": null, "titleBarStyle": "Overlay" }