Add source version selection when applying effects, voices tab overhaul with inline inspector

This commit is contained in:
Jamie Pine
2026-03-14 11:07:32 -07:00
parent 899b90202b
commit 310a4acb02
17 changed files with 708 additions and 235 deletions
+98 -38
View File
@@ -1,9 +1,11 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
AlignCenter,
AudioLines,
AudioWaveform,
Download, Download,
FileArchive, FileArchive,
GalleryVerticalEnd,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Play, Play,
@@ -30,10 +32,17 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { EffectConfig, HistoryResponse } from '@/lib/api/types'; import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { import {
useDeleteGeneration, useDeleteGeneration,
@@ -67,6 +76,10 @@ export function HistoryTable() {
); );
const [effectsDialogOpen, setEffectsDialogOpen] = useState(false); const [effectsDialogOpen, setEffectsDialogOpen] = useState(false);
const [effectsTargetId, setEffectsTargetId] = useState<string | null>(null); const [effectsTargetId, setEffectsTargetId] = useState<string | null>(null);
const [effectsTargetVersions, setEffectsTargetVersions] = useState<GenerationVersionResponse[]>(
[],
);
const [effectsSourceVersionId, setEffectsSourceVersionId] = useState<string | null>(null);
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]); const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
const [applyingEffects, setApplyingEffects] = useState(false); const [applyingEffects, setApplyingEffects] = useState(false);
const [expandedVersionsId, setExpandedVersionsId] = useState<string | null>(null); const [expandedVersionsId, setExpandedVersionsId] = useState<string | null>(null);
@@ -253,7 +266,13 @@ export function HistoryTable() {
}; };
const handleApplyEffects = (generationId: string) => { const handleApplyEffects = (generationId: string) => {
const gen = allHistory.find((g) => g.id === generationId);
const versions = gen?.versions ?? [];
setEffectsTargetId(generationId); setEffectsTargetId(generationId);
setEffectsTargetVersions(versions);
// Default to clean/original version (no effects chain)
const cleanVersion = versions.find((v) => !v.effects_chain || v.effects_chain.length === 0);
setEffectsSourceVersionId(cleanVersion?.id ?? null);
setEffectsChain([]); setEffectsChain([]);
setEffectsDialogOpen(true); setEffectsDialogOpen(true);
}; };
@@ -264,6 +283,7 @@ export function HistoryTable() {
try { try {
const newVersion = await apiClient.applyEffectsToGeneration(effectsTargetId, { const newVersion = await apiClient.applyEffectsToGeneration(effectsTargetId, {
effects_chain: effectsChain, effects_chain: effectsChain,
source_version_id: effectsSourceVersionId ?? undefined,
set_as_default: true, set_as_default: true,
}); });
queryClient.invalidateQueries({ queryKey: ['history'] }); queryClient.invalidateQueries({ queryKey: ['history'] });
@@ -476,11 +496,41 @@ export function HistoryTable() {
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
gen.is_favorited && 'text-accent hover:text-accent',
)}
aria-label={gen.is_favorited ? 'Unfavorite' : 'Favorite'}
onClick={() => handleToggleFavorite(gen.id)}
>
<Star
className="h-2 w-2"
fill={gen.is_favorited ? 'currentColor' : 'none'}
/>
</Button>
{hasVersions && (
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
isVersionsExpanded && 'text-accent hover:text-accent',
)}
aria-label="Toggle versions"
onClick={() => setExpandedVersionsId(isVersionsExpanded ? null : gen.id)}
>
<AudioLines className="h-2 w-2" />
</Button>
)}
{isFailed ? ( {isFailed ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-muted-foreground hover:bg-muted-foreground/20 hover:text-muted-foreground" className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Retry generation" aria-label="Retry generation"
onClick={() => handleRetry(gen.id)} onClick={() => handleRetry(gen.id)}
> >
@@ -493,7 +543,7 @@ export function HistoryTable() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-muted-foreground hover:bg-muted-foreground/20 hover:text-muted-foreground" className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Actions" aria-label="Actions"
disabled={isGenerating} disabled={isGenerating}
> >
@@ -539,37 +589,6 @@ export function HistoryTable() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button
variant="ghost"
size="icon"
className={cn(
'h-7 w-7 text-muted-foreground hover:bg-muted-foreground/20 hover:text-muted-foreground',
gen.is_favorited && 'text-accent hover:text-accent',
)}
aria-label={gen.is_favorited ? 'Unfavorite' : 'Favorite'}
onClick={() => handleToggleFavorite(gen.id)}
>
<Star
className="h-2 w-2"
fill={gen.is_favorited ? 'currentColor' : 'none'}
/>
</Button>
{hasVersions && (
<Button
variant="ghost"
size="icon"
className={cn(
'h-7 w-7 text-muted-foreground hover:bg-muted-foreground/20 hover:text-muted-foreground',
isVersionsExpanded && 'text-accent hover:text-accent',
)}
aria-label="Toggle versions"
onClick={() =>
setExpandedVersionsId(isVersionsExpanded ? null : gen.id)
}
>
<GalleryVerticalEnd className="h-2 w-2" />
</Button>
)}
</> </>
)} )}
</div> </div>
@@ -587,7 +606,17 @@ export function HistoryTable() {
> >
<div className="border-t border-border/50"> <div className="border-t border-border/50">
<div className="divide-y divide-border/40"> <div className="divide-y divide-border/40">
{gen.versions.map((v) => ( {gen.versions.map((v) => {
// Show source provenance when effects were applied to a non-clean version
const sourceVersion = v.source_version_id
? gen.versions?.find((sv) => sv.id === v.source_version_id)
: null;
const showSource =
sourceVersion &&
sourceVersion.effects_chain &&
sourceVersion.effects_chain.length > 0;
return (
<button <button
key={v.id} key={v.id}
type="button" type="button"
@@ -599,13 +628,18 @@ export function HistoryTable() {
} }
}} }}
> >
<Play className="h-3 w-3 shrink-0 text-muted-foreground" /> <AudioLines className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-medium">{v.label}</span> <span className="truncate text-xs font-medium">{v.label}</span>
{v.effects_chain && v.effects_chain.length > 0 && ( {v.effects_chain && v.effects_chain.length > 0 && (
<span className="text-[10px] text-muted-foreground truncate"> <span className="text-[10px] text-muted-foreground truncate">
{v.effects_chain.map((e) => e.type).join(' → ')} {v.effects_chain.map((e) => e.type).join(' → ')}
</span> </span>
)} )}
{showSource && (
<span className="text-[10px] text-muted-foreground/60 truncate">
from {sourceVersion.label}
</span>
)}
<span className="flex-1" /> <span className="flex-1" />
{v.is_default && ( {v.is_default && (
<span className="text-[10px] bg-accent/15 text-accent px-1.5 py-0.5 rounded-full"> <span className="text-[10px] bg-accent/15 text-accent px-1.5 py-0.5 rounded-full">
@@ -613,7 +647,8 @@ export function HistoryTable() {
</span> </span>
)} )}
</button> </button>
))} );
})}
</div> </div>
</div> </div>
</motion.div> </motion.div>
@@ -710,6 +745,31 @@ export function HistoryTable() {
created. created.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{effectsTargetVersions.length > 1 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Source</label>
<Select
value={effectsSourceVersionId ?? ''}
onValueChange={(val) => setEffectsSourceVersionId(val || null)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select source version" />
</SelectTrigger>
<SelectContent>
{effectsTargetVersions.map((v) => (
<SelectItem key={v.id} value={v.id} className="text-xs">
{v.label}
{v.effects_chain && v.effects_chain.length > 0 && (
<span className="text-muted-foreground ml-1.5">
({v.effects_chain.map((e) => e.type).join(' + ')})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="py-2 max-h-80 overflow-y-auto"> <div className="py-2 max-h-80 overflow-y-auto">
<EffectsChainEditor value={effectsChain} onChange={setEffectsChain} /> <EffectsChainEditor value={effectsChain} onChange={setEffectsChain} />
</div> </div>
+3 -3
View File
@@ -1,5 +1,5 @@
import { Link, useMatchRoute } from '@tanstack/react-router'; import { Link, useMatchRoute } from '@tanstack/react-router';
import { BookOpen, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react'; import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png'; import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
@@ -11,10 +11,10 @@ interface SidebarProps {
const tabs = [ const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' }, { id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' }, { id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' }, { id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' }, { id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' }, { id: 'models', path: '/models', icon: Box, label: 'Models' },
{ id: 'server', path: '/server', icon: Server, label: 'Server' }, { id: 'server', path: '/server', icon: Server, label: 'Server' },
]; ];
@@ -1,4 +1,4 @@
import { Download, Edit, Mic, Sparkles, Trash2 } from 'lucide-react'; import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -15,7 +15,6 @@ import {
import type { VoiceProfileResponse } from '@/lib/api/types'; import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles'; import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
interface ProfileCardProps { interface ProfileCardProps {
@@ -24,19 +23,16 @@ interface ProfileCardProps {
export function ProfileCard({ profile }: ProfileCardProps) { export function ProfileCard({ profile }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [avatarError, setAvatarError] = useState(false);
const deleteProfile = useDeleteProfile(); const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile(); const exportProfile = useExportProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId); const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId); const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const serverUrl = useServerStore((state) => state.serverUrl);
const isSelected = selectedProfileId === profile.id; const isSelected = selectedProfileId === profile.id;
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
const handleSelect = () => { const handleSelect = () => {
setSelectedProfileId(isSelected ? null : profile.id); setSelectedProfileId(isSelected ? null : profile.id);
}; };
@@ -89,22 +85,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<CardHeader className="p-3 pb-2"> <CardHeader className="p-3 pb-2">
<CardTitle className="flex items-start gap-1.5 text-base font-medium"> <CardTitle className="text-base font-medium">
<div className="h-6 w-6 mt-[3px] rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
className={cn(
'h-full w-full object-cover transition-all duration-200',
!isSelected && 'grayscale',
)}
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
<span className="break-words">{profile.name}</span> <span className="break-words">{profile.name}</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -117,7 +98,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
{profile.language} {profile.language}
</Badge> </Badge>
{profile.effects_chain && profile.effects_chain.length > 0 && ( {profile.effects_chain && profile.effects_chain.length > 0 && (
<Sparkles className="h-3.5 w-3.5 text-accent" /> <Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
)} )}
</div> </div>
<div className="flex gap-0.5 justify-end items-end mt-auto"> <div className="flex gap-0.5 justify-end items-end mt-auto">
@@ -0,0 +1,340 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Edit2, Mic, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { SampleList } from '@/components/VoiceProfiles/SampleList';
import { apiClient } from '@/lib/api/client';
import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import {
useDeleteAvatar,
useProfile,
useUpdateProfile,
useUploadAvatar,
} from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
interface VoiceInspectorProps {
profileId: string;
}
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const { data: profile } = useProfile(profileId);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const updateProfile = useUpdateProfile();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const serverUrl = useServerStore((state) => state.serverUrl);
const { toast } = useToast();
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState(false);
const avatarInputRef = useRef<HTMLInputElement>(null);
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: '',
description: '',
language: 'en',
},
});
// Populate form when profile loads
useEffect(() => {
if (profile) {
form.reset({
name: profile.name,
description: profile.description || '',
language: profile.language as LanguageCode,
});
setEffectsChain(profile.effects_chain ?? []);
setEffectsDirty(false);
}
}, [profile, form]);
// Avatar preview
useEffect(() => {
if (profile?.avatar_path) {
setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`);
} else {
setAvatarPreview(null);
}
setAvatarError(false);
}, [profile, serverUrl]);
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select PNG, JPG, or WebP',
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Image must be less than 5MB',
variant: 'destructive',
});
return;
}
// Upload immediately
uploadAvatar.mutate(
{ profileId, file },
{
onSuccess: () => {
setAvatarPreview(URL.createObjectURL(file));
toast({ title: 'Avatar updated' });
},
onError: (err) => {
toast({
title: 'Avatar upload failed',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
},
},
);
}
async function handleRemoveAvatar() {
if (profile?.avatar_path) {
try {
await deleteAvatar.mutateAsync(profileId);
toast({ title: 'Avatar removed' });
} catch (err) {
toast({
title: 'Failed to remove avatar',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'destructive',
});
}
}
setAvatarPreview(null);
if (avatarInputRef.current) avatarInputRef.current.value = '';
}
async function onSubmit(data: ProfileFormValues) {
try {
await updateProfile.mutateAsync({
profileId,
data: {
name: data.name,
description: data.description,
language: data.language,
},
});
if (effectsDirty) {
try {
await apiClient.updateProfileEffects(
profileId,
effectsChain.length > 0 ? effectsChain : null,
);
setEffectsDirty(false);
} catch (fxError) {
toast({
title: 'Effects update failed',
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
variant: 'destructive',
});
return;
}
}
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
variant: 'destructive',
});
}
}
if (!profile) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
</div>
);
}
const isDirty = form.formState.isDirty || effectsDirty;
return (
<div className="h-full flex flex-col overflow-hidden">
<div className={cn('flex-1 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-0">
{/* Avatar */}
<div className="flex justify-center pt-5 pb-3">
<div className="relative group">
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
{avatarPreview && !avatarError ? (
<img
src={avatarPreview}
alt={profile.name}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-8 w-8 text-muted-foreground" />
)}
</div>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
>
<Edit2 className="h-5 w-5 text-accent-foreground" />
</button>
{avatarPreview && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={deleteAvatar.isPending}
className="absolute bottom-0 right-0 h-5 w-5 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleAvatarFileChange}
className="hidden"
/>
</div>
{/* Fields */}
<div className="space-y-3 px-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My Voice" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Effects */}
<div className="space-y-2">
<FormLabel>Default Effects</FormLabel>
<p className="text-xs text-muted-foreground">
Applied automatically to new generations with this voice.
</p>
<EffectsChainEditor
value={effectsChain}
onChange={(chain) => {
setEffectsChain(chain);
setEffectsDirty(true);
}}
compact
/>
</div>
{/* Save */}
{isDirty && (
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
</Button>
)}
</div>
{/* Samples */}
<div className="px-5 pb-5">
<SampleList profileId={profileId} />
</div>
</form>
</Form>
</div>
</div>
);
}
+110 -93
View File
@@ -1,13 +1,9 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react'; import { Mic, Plus, Search, Sparkles } from 'lucide-react';
import { useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Input } from '@/components/ui/input';
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MultiSelect } from '@/components/ui/multi-select'; import { MultiSelect } from '@/components/ui/multi-select';
import { import {
Table, Table,
@@ -21,33 +17,46 @@ import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import type { VoiceProfileResponse } from '@/lib/api/types'; import type { VoiceProfileResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useHistory } from '@/lib/hooks/useHistory'; import { useProfiles } from '@/lib/hooks/useProfiles';
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { VoiceInspector } from './VoiceInspector';
export function VoicesTab() { export function VoicesTab() {
const { data: profiles, isLoading } = useProfiles(); const { data: profiles, isLoading } = useProfiles();
const { data: historyData } = useHistory({ limit: 1000 });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
const deleteProfile = useDeleteProfile(); const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const audioUrl = usePlayerStore((state) => state.audioUrl); const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl; const isPlayerVisible = !!audioUrl;
const [search, setSearch] = useState('');
// Get generation counts per profile const filteredProfiles = useMemo(() => {
const generationCounts = useMemo(() => { if (!profiles) return [];
const counts: Record<string, number> = {}; if (!search.trim()) return profiles;
if (historyData?.items) { const q = search.toLowerCase();
historyData.items.forEach((item) => { return profiles.filter(
counts[item.profile_id] = (counts[item.profile_id] || 0) + 1; (p) =>
}); p.name.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.language.toLowerCase().includes(q),
);
}, [profiles, search]);
// Auto-select first profile if none selected
useEffect(() => {
if (!selectedVoiceId && profiles && profiles.length > 0) {
setSelectedVoiceId(profiles[0].id);
} }
return counts; // Clear selection if selected profile was deleted
}, [historyData]); if (selectedVoiceId && profiles && !profiles.find((p) => p.id === selectedVoiceId)) {
setSelectedVoiceId(profiles.length > 0 ? profiles[0].id : null);
}
}, [profiles, selectedVoiceId, setSelectedVoiceId]);
// Get channel assignments for each profile // Get channel assignments for each profile
const { data: channelAssignments } = useQuery({ const { data: channelAssignments } = useQuery({
@@ -74,17 +83,6 @@ export function VoicesTab() {
queryFn: () => apiClient.listChannels(), queryFn: () => apiClient.listChannels(),
}); });
const handleEdit = (profileId: string) => {
setEditingProfileId(profileId);
setDialogOpen(true);
};
const handleProfileDelete = async (profileId: string) => {
if (await confirm('Are you sure you want to delete this profile?')) {
deleteProfile.mutate(profileId);
}
};
const handleChannelChange = async (profileId: string, channelIds: string[]) => { const handleChannelChange = async (profileId: string, channelIds: string[]) => {
try { try {
await apiClient.setProfileChannels(profileId, channelIds); await apiClient.setProfileChannels(profileId, channelIds);
@@ -103,14 +101,26 @@ export function VoicesTab() {
} }
return ( return (
<div className="h-full flex flex-col relative overflow-hidden"> <div className="h-full flex gap-0 overflow-hidden -mx-8">
{/* Scroll Mask - Always visible, behind content */} {/* Left: Table */}
<div className="flex-1 min-w-0 flex flex-col relative overflow-hidden">
{/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" /> <div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{/* Fixed Header */} {/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20"> <div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">Voices</h1> <h1 className="text-2xl font-bold">Voices</h1>
<div className="flex-1" />
<div className="relative w-[240px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search voices..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<Button onClick={() => setDialogOpen(true)}> <Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
New Voice New Voice
@@ -122,37 +132,45 @@ export function VoicesTab() {
<div <div
ref={scrollRef} ref={scrollRef}
className={cn( className={cn(
'flex-1 overflow-y-auto pt-16 relative z-0', 'flex-1 overflow-y-auto overflow-x-hidden pt-16 relative z-0',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING, isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)} )}
> >
<Table> <Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead className="w-[30%]">Name</TableHead>
<TableHead>Language</TableHead> <TableHead className="w-[10%]">Language</TableHead>
<TableHead>Generations</TableHead> <TableHead className="w-[10%]">Generations</TableHead>
<TableHead>Samples</TableHead> <TableHead className="w-[8%]">Samples</TableHead>
<TableHead>Channels</TableHead> <TableHead className="w-[8%]">Effects</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[24%]">Channels</TableHead>
<TableHead className="w-6"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{profiles?.map((profile) => ( {filteredProfiles.map((profile) => (
<VoiceRow <VoiceRow
key={profile.id} key={profile.id}
profile={profile} profile={profile}
generationCount={generationCounts[profile.id] || 0} isSelected={selectedVoiceId === profile.id}
onSelect={() => setSelectedVoiceId(profile.id)}
channelIds={channelAssignments?.[profile.id] || []} channelIds={channelAssignments?.[profile.id] || []}
channels={channels || []} channels={channels || []}
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)} onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
onEdit={() => handleEdit(profile.id)}
onDelete={() => handleProfileDelete(profile.id)}
/> />
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
{/* Right: Inspector */}
{selectedVoiceId && (
<div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
<VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
</div>
)}
<ProfileForm /> <ProfileForm />
</div> </div>
@@ -161,42 +179,46 @@ export function VoicesTab() {
interface VoiceRowProps { interface VoiceRowProps {
profile: VoiceProfileResponse; profile: VoiceProfileResponse;
generationCount: number; isSelected: boolean;
onSelect: () => void;
channelIds: string[]; channelIds: string[];
channels: Array<{ id: string; name: string; is_default: boolean }>; channels: Array<{ id: string; name: string; is_default: boolean }>;
onChannelChange: (channelIds: string[]) => void; onChannelChange: (channelIds: string[]) => void;
onEdit: () => void;
onDelete: () => void;
} }
function VoiceRow({ function VoiceRow({
profile, profile,
generationCount, isSelected,
onSelect,
channelIds, channelIds,
channels, channels,
onChannelChange, onChannelChange,
onEdit,
onDelete,
}: VoiceRowProps) { }: VoiceRowProps) {
const { data: samples } = useProfileSamples(profile.id); const serverUrl = useServerStore((state) => state.serverUrl);
const sampleCount = samples?.length || 0; const [avatarError, setAvatarError] = useState(false);
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
const rowLabel = `${profile.name}, ${profile.language}, ${generationCount} generations, ${sampleCount} samples. Press Enter to edit.`; const enabledEffects = profile.effects_chain?.filter((e) => e.enabled) ?? [];
const effectsSummary = enabledEffects.map((e) => e.type).join(' → ');
return ( return (
<TableRow className="cursor-pointer" onClick={onEdit}> <TableRow
<TableCell> className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
<button onClick={onSelect}
type="button"
className="flex w-full min-w-0 items-center gap-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
aria-label={rowLabel}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
> >
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0"> <TableCell>
<div className="flex w-full min-w-0 items-center gap-2">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-4 w-4 text-muted-foreground" /> <Mic className="h-4 w-4 text-muted-foreground" />
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium truncate">{profile.name}</div> <div className="font-medium truncate">{profile.name}</div>
@@ -204,11 +226,24 @@ function VoiceRow({
<div className="text-sm text-muted-foreground truncate">{profile.description}</div> <div className="text-sm text-muted-foreground truncate">{profile.description}</div>
)} )}
</div> </div>
</button> </div>
</TableCell>
<TableCell>{profile.language}</TableCell>
<TableCell>{profile.generation_count}</TableCell>
<TableCell>{profile.sample_count}</TableCell>
<TableCell>
{enabledEffects.length > 0 ? (
<span
className="inline-flex items-center gap-1 text-xs text-accent"
title={effectsSummary}
>
<Sparkles className="h-3 w-3 fill-accent" />
{enabledEffects.length}
</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</TableCell> </TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{sampleCount}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell onClick={(e) => e.stopPropagation()}>
<MultiSelect <MultiSelect
options={channels.map((ch) => ({ options={channels.map((ch) => ({
@@ -218,28 +253,10 @@ function VoiceRow({
value={channelIds} value={channelIds}
onChange={onChannelChange} onChange={onChannelChange}
placeholder="Select channels..." placeholder="Select channels..."
className="min-w-[200px]" className="w-full"
/> />
</TableCell> </TableCell>
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Actions for ${profile.name}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={onEdit}>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow> </TableRow>
); );
} }
+5 -3
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { MoreHorizontal } from 'lucide-react'; import { MoreHorizontal } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -73,7 +73,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8', inset && 'pl-8',
className, className,
)} )}
@@ -154,7 +154,9 @@ const DropdownMenuSeparator = React.forwardRef<
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />; return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
}; };
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
+6 -5
View File
@@ -14,7 +14,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} /> <thead
ref={ref}
className={cn('[&_tr]:border-b [&_tr]:hover:bg-transparent', className)}
{...props}
/>
)); ));
TableHeader.displayName = 'TableHeader'; TableHeader.displayName = 'TableHeader';
@@ -42,10 +46,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn('border-b hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props} {...props}
/> />
), ),
+4
View File
@@ -14,6 +14,8 @@ export interface VoiceProfileResponse {
language: string; language: string;
avatar_path?: string; avatar_path?: string;
effects_chain?: EffectConfig[]; effects_chain?: EffectConfig[];
generation_count: number;
sample_count: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -55,6 +57,7 @@ export interface GenerationVersionResponse {
label: string; label: string;
audio_path: string; audio_path: string;
effects_chain?: EffectConfig[]; effects_chain?: EffectConfig[];
source_version_id?: string;
is_default: boolean; is_default: boolean;
created_at: string; created_at: string;
} }
@@ -331,6 +334,7 @@ export interface AvailableEffectsResponse {
export interface ApplyEffectsRequest { export interface ApplyEffectsRequest {
effects_chain: EffectConfig[]; effects_chain: EffectConfig[];
source_version_id?: string;
label?: string; label?: string;
set_as_default?: boolean; set_as_default?: boolean;
} }
+1 -1
View File
@@ -12,7 +12,7 @@ import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
const generationSchema = z.object({ const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(50000), text: z.string().min(1, '').max(50000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
seed: z.number().int().optional(), seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(), modelSize: z.enum(['1.7B', '0.6B']).optional(),
+7
View File
@@ -31,6 +31,10 @@ interface UIStore {
selectedProfileId: string | null; selectedProfileId: string | null;
setSelectedProfileId: (id: string | null) => void; setSelectedProfileId: (id: string | null) => void;
// Selected voice in Voices tab inspector
selectedVoiceId: string | null;
setSelectedVoiceId: (id: string | null) => void;
// Profile form draft (for persisting create voice modal state) // Profile form draft (for persisting create voice modal state)
profileFormDraft: ProfileFormDraft | null; profileFormDraft: ProfileFormDraft | null;
setProfileFormDraft: (draft: ProfileFormDraft | null) => void; setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
@@ -55,6 +59,9 @@ export const useUIStore = create<UIStore>((set) => ({
selectedProfileId: null, selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }), setSelectedProfileId: (id) => set({ selectedProfileId: id }),
selectedVoiceId: null,
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
profileFormDraft: null, profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }), setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
+11
View File
@@ -104,6 +104,7 @@ class GenerationVersion(Base):
label = Column(String, nullable=False) # "clean", "processed", or user-defined label = Column(String, nullable=False) # "clean", "processed", or user-defined
audio_path = Column(String, nullable=False) audio_path = Column(String, nullable=False)
effects_chain = Column(Text, nullable=True) # JSON-serialized effects config, null for clean effects_chain = Column(Text, nullable=True) # JSON-serialized effects config, null for clean
source_version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True) # Which version was used as input
is_default = Column(Boolean, default=False) is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
@@ -387,6 +388,16 @@ def _run_migrations(engine):
conn.commit() conn.commit()
print("Added version_id column to story_items") print("Added version_id column to story_items")
# Migration: Add source_version_id to generation_versions table
if 'generation_versions' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('generation_versions')}
if 'source_version_id' not in columns:
print("Migrating generation_versions: adding source_version_id column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generation_versions ADD COLUMN source_version_id VARCHAR"))
conn.commit()
print("Added source_version_id column to generation_versions")
if 'generations' in inspector.get_table_names(): if 'generations' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('generations')} columns = {col['name'] for col in inspector.get_columns('generations')}
if 'is_favorited' not in columns: if 'is_favorited' not in columns:
+12 -2
View File
@@ -1863,16 +1863,25 @@ async def apply_effects_to_generation(
if error: if error:
raise HTTPException(status_code=400, detail=error) raise HTTPException(status_code=400, detail=error)
# Find the original unprocessed version (no effects applied) # Determine source audio: use specified version, or fall back to clean/original
all_versions = versions_mod.list_versions(generation_id, db) all_versions = versions_mod.list_versions(generation_id, db)
source_version_id = data.source_version_id
if source_version_id:
source_version = next(
(v for v in all_versions if v.id == source_version_id), None
)
if not source_version:
raise HTTPException(status_code=404, detail="Source version not found")
source_path = source_version.audio_path
else:
clean_version = next( clean_version = next(
(v for v in all_versions if v.effects_chain is None), None (v for v in all_versions if v.effects_chain is None), None
) )
if not clean_version: if not clean_version:
# Fallback: use the generation's audio_path directly
source_path = gen.audio_path source_path = gen.audio_path
else: else:
source_path = clean_version.audio_path source_path = clean_version.audio_path
source_version_id = clean_version.id
if not source_path or not Path(source_path).exists(): if not source_path or not Path(source_path).exists():
raise HTTPException(status_code=404, detail="Source audio file not found") raise HTTPException(status_code=404, detail="Source audio file not found")
@@ -1896,6 +1905,7 @@ async def apply_effects_to_generation(
db=db, db=db,
effects_chain=chain_dicts, effects_chain=chain_dicts,
is_default=data.set_as_default, is_default=data.set_as_default,
source_version_id=source_version_id,
) )
return version return version
+4
View File
@@ -22,6 +22,8 @@ class VoiceProfileResponse(BaseModel):
language: str language: str
avatar_path: Optional[str] = None avatar_path: Optional[str] = None
effects_chain: Optional[List["EffectConfig"]] = None effects_chain: Optional[List["EffectConfig"]] = None
generation_count: int = 0
sample_count: int = 0
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -408,6 +410,7 @@ class GenerationVersionResponse(BaseModel):
label: str label: str
audio_path: str audio_path: str
effects_chain: Optional[List[EffectConfig]] = None effects_chain: Optional[List[EffectConfig]] = None
source_version_id: Optional[str] = None
is_default: bool is_default: bool
created_at: datetime created_at: datetime
@@ -418,6 +421,7 @@ class GenerationVersionResponse(BaseModel):
class ApplyEffectsRequest(BaseModel): class ApplyEffectsRequest(BaseModel):
"""Request to apply effects to an existing generation.""" """Request to apply effects to an existing generation."""
effects_chain: List[EffectConfig] effects_chain: List[EffectConfig]
source_version_id: Optional[str] = Field(None, description="Version to use as source audio (defaults to clean/original)")
label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)") label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
set_as_default: bool = Field(default=True, description="Set this version as the default") set_as_default: bool = Field(default=True, description="Set this version as the default")
+37 -4
View File
@@ -8,7 +8,7 @@ import uuid
import shutil import shutil
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select from sqlalchemy import func, select
from .models import ( from .models import (
VoiceProfileCreate, VoiceProfileCreate,
@@ -19,6 +19,7 @@ from .models import (
from .database import ( from .database import (
VoiceProfile as DBVoiceProfile, VoiceProfile as DBVoiceProfile,
ProfileSample as DBProfileSample, ProfileSample as DBProfileSample,
Generation as DBGeneration,
) )
from .models import EffectConfig from .models import EffectConfig
from .utils.audio import validate_reference_audio, load_audio, save_audio from .utils.audio import validate_reference_audio, load_audio, save_audio
@@ -29,7 +30,11 @@ from . import config
import json as _json import json as _json
def _profile_to_response(profile: DBVoiceProfile) -> VoiceProfileResponse: def _profile_to_response(
profile: DBVoiceProfile,
generation_count: int = 0,
sample_count: int = 0,
) -> VoiceProfileResponse:
"""Convert a DB profile to a VoiceProfileResponse, deserializing effects_chain.""" """Convert a DB profile to a VoiceProfileResponse, deserializing effects_chain."""
effects_chain = None effects_chain = None
if profile.effects_chain: if profile.effects_chain:
@@ -46,6 +51,8 @@ def _profile_to_response(profile: DBVoiceProfile) -> VoiceProfileResponse:
language=profile.language, language=profile.language,
avatar_path=profile.avatar_path, avatar_path=profile.avatar_path,
effects_chain=effects_chain, effects_chain=effects_chain,
generation_count=generation_count,
sample_count=sample_count,
created_at=profile.created_at, created_at=profile.created_at,
updated_at=profile.updated_at, updated_at=profile.updated_at,
) )
@@ -201,7 +208,7 @@ async def get_profile_samples(
async def list_profiles(db: Session) -> List[VoiceProfileResponse]: async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
""" """
List all voice profiles. List all voice profiles with generation and sample counts.
Args: Args:
db: Database session db: Database session
@@ -213,7 +220,33 @@ async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
DBVoiceProfile.created_at.desc() DBVoiceProfile.created_at.desc()
).all() ).all()
return [_profile_to_response(p) for p in profiles] if not profiles:
return []
# Batch-fetch generation counts
gen_counts_rows = (
db.query(DBGeneration.profile_id, func.count(DBGeneration.id))
.group_by(DBGeneration.profile_id)
.all()
)
gen_counts = {row[0]: row[1] for row in gen_counts_rows}
# Batch-fetch sample counts
sample_counts_rows = (
db.query(DBProfileSample.profile_id, func.count(DBProfileSample.id))
.group_by(DBProfileSample.profile_id)
.all()
)
sample_counts = {row[0]: row[1] for row in sample_counts_rows}
return [
_profile_to_response(
p,
generation_count=gen_counts.get(p.id, 0),
sample_count=sample_counts.get(p.id, 0),
)
for p in profiles
]
async def update_profile( async def update_profile(
+3
View File
@@ -34,6 +34,7 @@ def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
label=v.label, label=v.label,
audio_path=v.audio_path, audio_path=v.audio_path,
effects_chain=effects_chain, effects_chain=effects_chain,
source_version_id=v.source_version_id,
is_default=v.is_default, is_default=v.is_default,
created_at=v.created_at, created_at=v.created_at,
) )
@@ -85,6 +86,7 @@ def create_version(
db: Session, db: Session,
effects_chain: Optional[List[dict]] = None, effects_chain: Optional[List[dict]] = None,
is_default: bool = False, is_default: bool = False,
source_version_id: Optional[str] = None,
) -> GenerationVersionResponse: ) -> GenerationVersionResponse:
"""Create a new version for a generation. """Create a new version for a generation.
@@ -100,6 +102,7 @@ def create_version(
label=label, label=label,
audio_path=audio_path, audio_path=audio_path,
effects_chain=json.dumps(effects_chain) if effects_chain else None, effects_chain=json.dumps(effects_chain) if effects_chain else None,
source_version_id=source_version_id,
is_default=is_default, is_default=is_default,
) )
db.add(version) db.add(version)
+1 -1
View File
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "voicebox" name = "voicebox"
version = "0.1.13" version = "0.2.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"core-foundation-sys", "core-foundation-sys",
Binary file not shown.