mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
Add source version selection when applying effects, voices tab overhaul with inline inspector
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Generated
+1
-1
@@ -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.
Reference in New Issue
Block a user