mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
Add post-processing audio effects system
Adds a full effects pipeline powered by Spotify's pedalboard library, enabling users to apply professional DSP effects (flanger, reverb, delay, compressor, pitch shift, filters, gain) to generated audio. Key features: - Effects chain editor with drag-and-drop reordering (dnd-kit) - Generation versions: clean copy always saved, processed versions created on top - Built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) + custom user presets - Per-profile default effects chain (auto-applied to new generations) - Per-generation effects override from the generation form - Apply effects to existing generations from history (creates new version) - Ephemeral preview endpoint for auditioning effects without persisting - Dedicated Effects sidebar tab with preset management and live preview - Version switcher in history cards with expandable panel - Backward-compatible: existing generations backfilled as clean versions
This commit is contained in:
@@ -0,0 +1,377 @@
|
|||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { AvailableEffect, EffectConfig, EffectPresetResponse } from '@/lib/api/types';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
// Each effect in the chain gets a stable ID for dnd-kit
|
||||||
|
interface EffectWithId extends EffectConfig {
|
||||||
|
_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
function makeId() {
|
||||||
|
return `fx-${++nextId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EffectsChainEditorProps {
|
||||||
|
value: EffectConfig[];
|
||||||
|
onChange: (chain: EffectConfig[]) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
showPresets?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EffectsChainEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
compact = false,
|
||||||
|
showPresets = true,
|
||||||
|
}: EffectsChainEditorProps) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Maintain stable IDs for each effect across renders.
|
||||||
|
// We use a ref to map value items to IDs, rebuilding when length changes.
|
||||||
|
const idsRef = useRef<string[]>([]);
|
||||||
|
const items: EffectWithId[] = useMemo(() => {
|
||||||
|
// Grow ID array if effects were added
|
||||||
|
while (idsRef.current.length < value.length) {
|
||||||
|
idsRef.current.push(makeId());
|
||||||
|
}
|
||||||
|
// Shrink if effects were removed
|
||||||
|
if (idsRef.current.length > value.length) {
|
||||||
|
idsRef.current = idsRef.current.slice(0, value.length);
|
||||||
|
}
|
||||||
|
return value.map((e, i) => ({ ...e, _id: idsRef.current[i] }));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: availableEffects } = useQuery({
|
||||||
|
queryKey: ['available-effects'],
|
||||||
|
queryFn: () => apiClient.getAvailableEffects(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: presets } = useQuery({
|
||||||
|
queryKey: ['effect-presets'],
|
||||||
|
queryFn: () => apiClient.listEffectPresets(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectsMap = useMemo(() => {
|
||||||
|
const m = new Map<string, AvailableEffect>();
|
||||||
|
if (availableEffects) {
|
||||||
|
for (const e of availableEffects.effects) {
|
||||||
|
m.set(e.type, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [availableEffects]);
|
||||||
|
|
||||||
|
function addEffect(type: string) {
|
||||||
|
const def = effectsMap.get(type);
|
||||||
|
if (!def) return;
|
||||||
|
const params: Record<string, number> = {};
|
||||||
|
for (const [key, p] of Object.entries(def.params)) {
|
||||||
|
params[key] = p.default;
|
||||||
|
}
|
||||||
|
const newEffect: EffectConfig = { type, enabled: true, params };
|
||||||
|
const newId = makeId();
|
||||||
|
idsRef.current = [...idsRef.current, newId];
|
||||||
|
onChange([...value, newEffect]);
|
||||||
|
setExpandedId(newId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEffect = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const removedId = idsRef.current[index];
|
||||||
|
idsRef.current = idsRef.current.filter((_, i) => i !== index);
|
||||||
|
onChange(value.filter((_, i) => i !== index));
|
||||||
|
if (expandedId === removedId) setExpandedId(null);
|
||||||
|
},
|
||||||
|
[value, onChange, expandedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleEnabled = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onChange(value.map((e, i) => (i === index ? { ...e, enabled: !e.enabled } : e)));
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateParam = useCallback(
|
||||||
|
(index: number, paramName: string, paramValue: number) => {
|
||||||
|
onChange(
|
||||||
|
value.map((e, i) =>
|
||||||
|
i === index ? { ...e, params: { ...e.params, [paramName]: paramValue } } : e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadPreset(preset: EffectPresetResponse) {
|
||||||
|
idsRef.current = preset.effects_chain.map(() => makeId());
|
||||||
|
onChange(preset.effects_chain);
|
||||||
|
setExpandedId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
idsRef.current = [];
|
||||||
|
onChange([]);
|
||||||
|
setExpandedId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = idsRef.current.indexOf(active.id as string);
|
||||||
|
const newIndex = idsRef.current.indexOf(over.id as string);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
|
||||||
|
onChange(arrayMove([...value], oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', compact && 'text-sm')}>
|
||||||
|
{/* Preset selector row */}
|
||||||
|
{showPresets && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(id) => {
|
||||||
|
const preset = presets?.find((p) => p.id === id);
|
||||||
|
if (preset) loadPreset(preset);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
|
||||||
|
<SelectValue placeholder="Load preset..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{presets?.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
{p.description && (
|
||||||
|
<span className="ml-1 text-muted-foreground">- {p.description}</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{value.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={clearAll}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sortable effects chain */}
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={items.map((i) => i._id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map((effect, index) => (
|
||||||
|
<SortableEffectItem
|
||||||
|
key={effect._id}
|
||||||
|
id={effect._id}
|
||||||
|
effect={effect}
|
||||||
|
index={index}
|
||||||
|
effectDef={effectsMap.get(effect.type)}
|
||||||
|
isExpanded={expandedId === effect._id}
|
||||||
|
onToggleExpand={() => setExpandedId(expandedId === effect._id ? null : effect._id)}
|
||||||
|
onRemove={() => removeEffect(index)}
|
||||||
|
onToggleEnabled={() => toggleEnabled(index)}
|
||||||
|
onUpdateParam={(paramName, paramValue) => updateParam(index, paramName, paramValue)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* Add effect */}
|
||||||
|
{availableEffects && (
|
||||||
|
<Select onValueChange={addEffect}>
|
||||||
|
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
<SelectValue placeholder="Add effect..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableEffects.effects.map((e) => (
|
||||||
|
<SelectItem key={e.type} value={e.type}>
|
||||||
|
{e.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sortable effect item
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SortableEffectItemProps {
|
||||||
|
id: string;
|
||||||
|
effect: EffectConfig;
|
||||||
|
index: number;
|
||||||
|
effectDef?: AvailableEffect;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onToggleEnabled: () => void;
|
||||||
|
onUpdateParam: (paramName: string, paramValue: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableEffectItem({
|
||||||
|
id,
|
||||||
|
effect,
|
||||||
|
effectDef,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onRemove,
|
||||||
|
onToggleEnabled,
|
||||||
|
onUpdateParam,
|
||||||
|
}: SortableEffectItemProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = effectDef?.label ?? effect.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border',
|
||||||
|
effect.enabled ? 'border-border bg-card' : 'border-border/50 bg-muted/30',
|
||||||
|
isDragging && 'opacity-80 shadow-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing touch-none"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn('flex-1 text-xs font-medium', !effect.enabled && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'p-0.5 transition-colors',
|
||||||
|
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
onClick={onToggleEnabled}
|
||||||
|
title={effect.enabled ? 'Disable' : 'Enable'}
|
||||||
|
>
|
||||||
|
<Power className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Params */}
|
||||||
|
{isExpanded && effectDef && (
|
||||||
|
<div className="space-y-3 border-t px-3 py-2.5">
|
||||||
|
{Object.entries(effectDef.params).map(([paramName, paramDef]) => {
|
||||||
|
const currentValue = effect.params[paramName] ?? paramDef.default;
|
||||||
|
return (
|
||||||
|
<div key={paramName} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">
|
||||||
|
{paramDef.description}
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] font-mono tabular-nums text-foreground">
|
||||||
|
{currentValue.toFixed(
|
||||||
|
paramDef.step < 1 ? Math.max(1, -Math.floor(Math.log10(paramDef.step))) : 0,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={paramDef.min}
|
||||||
|
max={paramDef.max}
|
||||||
|
step={paramDef.step}
|
||||||
|
value={[currentValue]}
|
||||||
|
onValueChange={([v]) => onUpdateParam(paramName, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { ChevronDown, Search } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
interface GenerationPickerProps {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (generation: HistoryResponse) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenerationPicker({ selectedId, onSelect, className }: GenerationPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const { data: historyData } = useHistory({ limit: 50 });
|
||||||
|
|
||||||
|
const completedGenerations = useMemo(() => {
|
||||||
|
if (!historyData?.items) return [];
|
||||||
|
return historyData.items.filter((gen) => gen.status === 'completed');
|
||||||
|
}, [historyData]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!searchQuery) return completedGenerations;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return completedGenerations.filter(
|
||||||
|
(gen) => gen.text.toLowerCase().includes(q) || gen.profile_name.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [completedGenerations, searchQuery]);
|
||||||
|
|
||||||
|
const selectedGeneration = completedGenerations.find((g) => g.id === selectedId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn('h-8 justify-between gap-2 text-xs font-normal', className)}
|
||||||
|
>
|
||||||
|
{selectedGeneration ? (
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="font-medium">{selectedGeneration.profile_name}</span>
|
||||||
|
<span className="text-muted-foreground ml-1.5">
|
||||||
|
{selectedGeneration.text.length > 30
|
||||||
|
? `${selectedGeneration.text.substring(0, 30)}...`
|
||||||
|
: selectedGeneration.text}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Select a generation...</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="start">
|
||||||
|
<div className="p-2 border-b">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by voice or text..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8 pl-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-xs text-muted-foreground">
|
||||||
|
No generations found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((gen) => (
|
||||||
|
<button
|
||||||
|
key={gen.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 hover:bg-muted/50 transition-colors border-b border-border/30 last:border-0',
|
||||||
|
gen.id === selectedId && 'bg-accent/10',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(gen);
|
||||||
|
setOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{gen.profile_name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{gen.text.length > 60 ? `${gen.text.substring(0, 60)}...` : gen.text}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
|
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import { useEffectsStore } from '@/stores/effectsStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
|
export function EffectsDetail() {
|
||||||
|
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||||
|
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||||
|
const workingChain = useEffectsStore((s) => s.workingChain);
|
||||||
|
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||||
|
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||||
|
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const blobUrlRef = useRef<string | null>(null);
|
||||||
|
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Auto-select the most recent generation as preview source
|
||||||
|
const { data: historyData } = useHistory({ limit: 1 });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewGenId && historyData?.items?.length) {
|
||||||
|
const first = historyData.items.find((g) => g.status === 'completed');
|
||||||
|
if (first) setPreviewGenId(first.id);
|
||||||
|
}
|
||||||
|
}, [historyData, previewGenId]);
|
||||||
|
|
||||||
|
const { data: preset } = useQuery({
|
||||||
|
queryKey: ['effect-preset', selectedPresetId],
|
||||||
|
queryFn: () =>
|
||||||
|
selectedPresetId
|
||||||
|
? apiClient
|
||||||
|
.listEffectPresets()
|
||||||
|
.then((all) => all.find((p) => p.id === selectedPresetId) ?? null)
|
||||||
|
: null,
|
||||||
|
enabled: !!selectedPresetId,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync name/description when selecting a preset
|
||||||
|
useEffect(() => {
|
||||||
|
if (preset) {
|
||||||
|
setName(preset.name);
|
||||||
|
setDescription(preset.description ?? '');
|
||||||
|
} else if (isCreatingNew) {
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
}
|
||||||
|
}, [preset, isCreatingNew]);
|
||||||
|
|
||||||
|
// Cleanup blob URL on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (blobUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(blobUrlRef.current);
|
||||||
|
blobUrlRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isEditing = !!selectedPresetId || isCreatingNew;
|
||||||
|
const isBuiltIn = preset?.is_builtin ?? false;
|
||||||
|
|
||||||
|
async function handlePreview() {
|
||||||
|
if (!previewGenId || workingChain.length === 0) return;
|
||||||
|
|
||||||
|
setPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const blob = await apiClient.previewEffects(previewGenId, workingChain);
|
||||||
|
|
||||||
|
// Revoke old blob URL
|
||||||
|
if (blobUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(blobUrlRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
blobUrlRef.current = url;
|
||||||
|
|
||||||
|
// Play through the main audio player
|
||||||
|
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Preview failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectGeneration(gen: HistoryResponse) {
|
||||||
|
setPreviewGenId(gen.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveNew() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast({ title: 'Name required', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await apiClient.createEffectPreset({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
effects_chain: workingChain,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
|
setIsCreatingNew(false);
|
||||||
|
setSelectedPresetId(created.id);
|
||||||
|
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to save',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAsNew() {
|
||||||
|
await handleSaveNew();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!selectedPresetId) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await apiClient.deleteEffectPreset(selectedPresetId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
|
setSelectedPresetId(null);
|
||||||
|
setWorkingChain([]);
|
||||||
|
toast({ title: 'Preset deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
|
||||||
|
<p className="text-sm">Select a preset or create a new one</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isBuiltIn && !isCreatingNew && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-destructive hover:text-destructive gap-1.5"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isCreatingNew && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
onClick={handleSaveNew}
|
||||||
|
disabled={saving || workingChain.length === 0}
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{saving ? 'Saving...' : 'Save Preset'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isBuiltIn && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
onClick={handleSaveAsNew}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{saving ? 'Saving...' : 'Save as Custom'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
|
||||||
|
{/* Name & description */}
|
||||||
|
{(isCreatingNew || !isBuiltIn) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="My preset..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe what this preset does..."
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Built-in description (read-only) */}
|
||||||
|
{isBuiltIn && preset?.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{preset.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Effects chain editor */}
|
||||||
|
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Preview section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs">Preview</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GenerationPicker
|
||||||
|
selectedId={previewGenId}
|
||||||
|
onSelect={handleSelectGeneration}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 shrink-0"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!previewGenId || workingChain.length === 0 || previewLoading}
|
||||||
|
>
|
||||||
|
{previewLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
Preview
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Preview applies effects to the clean version without saving.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { EffectPresetResponse } from '@/lib/api/types';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useEffectsStore } from '@/stores/effectsStore';
|
||||||
|
|
||||||
|
export function EffectsList() {
|
||||||
|
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||||
|
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||||
|
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||||
|
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
|
||||||
|
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||||
|
|
||||||
|
const { data: presets, isLoading } = useQuery({
|
||||||
|
queryKey: ['effect-presets'],
|
||||||
|
queryFn: () => apiClient.listEffectPresets(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const builtIn = presets?.filter((p) => p.is_builtin) ?? [];
|
||||||
|
const userPresets = presets?.filter((p) => !p.is_builtin) ?? [];
|
||||||
|
|
||||||
|
function handleSelect(preset: EffectPresetResponse) {
|
||||||
|
setSelectedPresetId(preset.id);
|
||||||
|
setWorkingChain(preset.effects_chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateNew() {
|
||||||
|
setIsCreatingNew(true);
|
||||||
|
setWorkingChain([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Effects</h2>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New Preset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable list */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
|
||||||
|
{/* Built-in presets */}
|
||||||
|
{builtIn.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
|
Built-in
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{builtIn.map((preset) => (
|
||||||
|
<PresetCard
|
||||||
|
key={preset.id}
|
||||||
|
preset={preset}
|
||||||
|
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||||
|
onSelect={() => handleSelect(preset)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User presets */}
|
||||||
|
{userPresets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
|
Custom
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{userPresets.map((preset) => (
|
||||||
|
<PresetCard
|
||||||
|
key={preset.id}
|
||||||
|
preset={preset}
|
||||||
|
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||||
|
onSelect={() => handleSelect(preset)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New preset placeholder */}
|
||||||
|
{isCreatingNew && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
|
New
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-accent" />
|
||||||
|
<span className="text-sm font-medium">Unsaved Preset</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Configure effects in the panel on the right.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresetCard({
|
||||||
|
preset,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
preset: EffectPresetResponse;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const effectCount = preset.effects_chain.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left rounded-xl border p-3 h-[88px] transition-all duration-150',
|
||||||
|
isSelected
|
||||||
|
? 'border-accent/50 bg-accent/10'
|
||||||
|
: 'border-border bg-card hover:bg-muted/50 hover:border-border',
|
||||||
|
)}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wand2
|
||||||
|
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium truncate">{preset.name}</span>
|
||||||
|
{preset.is_builtin && (
|
||||||
|
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
|
||||||
|
built-in
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
||||||
|
{preset.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5 pl-6">
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{effectCount} effect{effectCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
|
{preset.effects_chain
|
||||||
|
.filter((e) => e.enabled)
|
||||||
|
.map((e) => e.type)
|
||||||
|
.join(' → ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {EffectsDetail} from "./EffectsDetail";
|
||||||
|
import {EffectsList} from "./EffectsList";
|
||||||
|
|
||||||
|
export function EffectsTab() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
|
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
|
||||||
|
{/* Left - Presets list */}
|
||||||
|
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
|
||||||
|
<EffectsList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right - Detail / editor */}
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
|
<EffectsDetail />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMatchRoute } from '@tanstack/react-router';
|
import { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
|
import { Loader2, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { EffectConfig } from '@/lib/api/types';
|
||||||
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
@@ -37,6 +39,8 @@ export function FloatingGenerateBox({
|
|||||||
const { data: profiles } = useProfiles();
|
const { data: profiles } = useProfiles();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isInstructMode, setIsInstructMode] = useState(false);
|
const [isInstructMode, setIsInstructMode] = useState(false);
|
||||||
|
const [isEffectsMode, setIsEffectsMode] = useState(false);
|
||||||
|
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
@@ -57,6 +61,7 @@ export function FloatingGenerateBox({
|
|||||||
addPendingStoryAdd(generationId, selectedStoryId);
|
addPendingStoryAdd(generationId, selectedStoryId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getEffectsChain: () => (effectsChain.length > 0 ? effectsChain : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click away handler to collapse the box
|
// Click away handler to collapse the box
|
||||||
@@ -369,10 +374,66 @@ export function FloatingGenerateBox({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0',
|
||||||
|
form.watch('engine') === 'qwen'
|
||||||
|
? 'right-[calc(100%+3.5rem)]'
|
||||||
|
: 'right-[calc(100%+0.5rem)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="group relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEffectsMode(!isEffectsMode);
|
||||||
|
if (isEffectsMode) setIsInstructMode(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'h-10 w-10 rounded-full transition-all duration-200',
|
||||||
|
isEffectsMode || effectsChain.length > 0
|
||||||
|
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||||
|
: 'bg-card border border-border hover:bg-background/50',
|
||||||
|
)}
|
||||||
|
aria-label={isEffectsMode ? 'Effects, on' : 'Post-processing effects'}
|
||||||
|
>
|
||||||
|
<Wand2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||||
|
Post-processing effects
|
||||||
|
{effectsChain.length > 0 && ` (${effectsChain.length} active)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Effects chain editor panel */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && isEffectsMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden mt-2"
|
||||||
|
>
|
||||||
|
<div className="border-t border-border/50 pt-2 pb-1">
|
||||||
|
<EffectsChainEditor value={effectsChain} onChange={setEffectsChain} compact />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Wand2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import Loader from 'react-loaders';
|
import Loader from 'react-loaders';
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -28,7 +32,7 @@ import {
|
|||||||
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 { HistoryResponse } from '@/lib/api/types';
|
import type { EffectConfig, 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,
|
||||||
@@ -60,6 +64,11 @@ export function HistoryTable() {
|
|||||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [effectsDialogOpen, setEffectsDialogOpen] = useState(false);
|
||||||
|
const [effectsTargetId, setEffectsTargetId] = useState<string | null>(null);
|
||||||
|
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
|
||||||
|
const [applyingEffects, setApplyingEffects] = useState(false);
|
||||||
|
const [expandedVersionsId, setExpandedVersionsId] = useState<string | null>(null);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -215,6 +224,57 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApplyEffects = (generationId: string) => {
|
||||||
|
setEffectsTargetId(generationId);
|
||||||
|
setEffectsChain([]);
|
||||||
|
setEffectsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyEffectsConfirm = async () => {
|
||||||
|
if (!effectsTargetId || effectsChain.length === 0) return;
|
||||||
|
setApplyingEffects(true);
|
||||||
|
try {
|
||||||
|
await apiClient.applyEffectsToGeneration(effectsTargetId, {
|
||||||
|
effects_chain: effectsChain,
|
||||||
|
set_as_default: true,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
setEffectsDialogOpen(false);
|
||||||
|
toast({ title: 'Effects applied', description: 'A new version has been created.' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to apply effects',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setApplyingEffects(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchVersion = async (generationId: string, versionId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.setDefaultVersion(generationId, versionId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to switch version',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayVersion = (
|
||||||
|
generationId: string,
|
||||||
|
versionId: string,
|
||||||
|
text: string,
|
||||||
|
profileId: string,
|
||||||
|
) => {
|
||||||
|
const audioUrl = apiClient.getVersionAudioUrl(versionId);
|
||||||
|
setAudioWithAutoPlay(audioUrl, generationId, profileId, text.substring(0, 50));
|
||||||
|
};
|
||||||
|
|
||||||
const handleImportConfirm = () => {
|
const handleImportConfirm = () => {
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
importGeneration.mutate(selectedFile, {
|
importGeneration.mutate(selectedFile, {
|
||||||
@@ -274,151 +334,224 @@ export function HistoryTable() {
|
|||||||
const isGenerating = gen.status === 'generating';
|
const isGenerating = gen.status === 'generating';
|
||||||
const isFailed = gen.status === 'failed';
|
const isFailed = gen.status === 'failed';
|
||||||
const isPlayable = !isGenerating && !isFailed;
|
const isPlayable = !isGenerating && !isFailed;
|
||||||
|
const hasVersions = gen.versions && gen.versions.length > 1;
|
||||||
|
const isVersionsExpanded = expandedVersionsId === gen.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={gen.id}
|
key={gen.id}
|
||||||
role={isPlayable ? 'button' : undefined}
|
|
||||||
tabIndex={isPlayable ? 0 : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card transition-colors text-left w-full',
|
'border rounded-md bg-card transition-colors text-left w-full',
|
||||||
isPlayable && 'hover:bg-muted/70 cursor-pointer',
|
|
||||||
isCurrentlyPlaying && 'bg-muted/70',
|
isCurrentlyPlaying && 'bg-muted/70',
|
||||||
)}
|
)}
|
||||||
aria-label={
|
|
||||||
isGenerating
|
|
||||||
? `Generating speech for ${gen.profile_name}...`
|
|
||||||
: isFailed
|
|
||||||
? `Generation failed for ${gen.profile_name}`
|
|
||||||
: isCurrentlyPlaying
|
|
||||||
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
|
|
||||||
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Press Enter to play.`
|
|
||||||
}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
if (!isPlayable) return;
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (!isPlayable) return;
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('textarea') || target.closest('button')) return;
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Status icon */}
|
{/* Main row */}
|
||||||
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
|
<div
|
||||||
<div className="scale-50">
|
role={isPlayable ? 'button' : undefined}
|
||||||
<Loader
|
tabIndex={isPlayable ? 0 : undefined}
|
||||||
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
|
className={cn(
|
||||||
active={isGenerating || isCurrentlyPlaying}
|
'flex items-stretch gap-4 h-26 p-3',
|
||||||
|
isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
isGenerating
|
||||||
|
? `Generating speech for ${gen.profile_name}...`
|
||||||
|
: isFailed
|
||||||
|
? `Generation failed for ${gen.profile_name}`
|
||||||
|
: isCurrentlyPlaying
|
||||||
|
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
|
||||||
|
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Press Enter to play.`
|
||||||
|
}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (!isPlayable) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!isPlayable) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('textarea') || target.closest('button')) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status icon */}
|
||||||
|
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
|
||||||
|
<div className="scale-50">
|
||||||
|
<Loader
|
||||||
|
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
|
||||||
|
active={isGenerating || isCurrentlyPlaying}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left side - Meta information */}
|
||||||
|
<div className="flex flex-col gap-1.5 w-48 shrink-0 justify-center">
|
||||||
|
<div className="font-medium text-sm truncate" title={gen.profile_name}>
|
||||||
|
{gen.profile_name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatEngineName(gen.engine, gen.model_size)}
|
||||||
|
</span>
|
||||||
|
{isFailed ? (
|
||||||
|
<span className="text-xs text-destructive">Failed</span>
|
||||||
|
) : !isGenerating ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDuration(gen.duration ?? 0)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{isGenerating ? (
|
||||||
|
<span className="text-accent">Generating...</span>
|
||||||
|
) : (
|
||||||
|
formatDate(gen.created_at)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Transcript textarea */}
|
||||||
|
<div className="flex-1 min-w-0 flex">
|
||||||
|
<Textarea
|
||||||
|
value={gen.text}
|
||||||
|
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||||
|
readOnly
|
||||||
|
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Left side - Meta information */}
|
{/* Far right - Actions */}
|
||||||
<div className="flex flex-col gap-1.5 w-48 shrink-0 justify-center">
|
<div
|
||||||
<div className="font-medium text-sm truncate" title={gen.profile_name}>
|
className="shrink-0 flex flex-col justify-center items-center gap-1"
|
||||||
{gen.profile_name}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
</div>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatEngineName(gen.engine, gen.model_size)}
|
|
||||||
</span>
|
|
||||||
{isFailed ? (
|
{isFailed ? (
|
||||||
<span className="text-xs text-destructive">Failed</span>
|
<Button
|
||||||
) : !isGenerating ? (
|
variant="ghost"
|
||||||
<span className="text-xs text-muted-foreground">
|
size="icon"
|
||||||
{formatDuration(gen.duration ?? 0)}
|
className="h-8 w-8"
|
||||||
</span>
|
aria-label="Retry generation"
|
||||||
|
onClick={() => handleRetry(gen.id)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : isPlayable ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label="Actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Play
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||||
|
disabled={exportGenerationAudio.isPending}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export Audio
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||||
|
disabled={exportGeneration.isPending}
|
||||||
|
>
|
||||||
|
<FileArchive className="mr-2 h-4 w-4" />
|
||||||
|
Export Package
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
|
||||||
|
<Wand2 className="mr-2 h-4 w-4" />
|
||||||
|
Apply Effects
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||||
|
disabled={deleteGeneration.isPending}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{hasVersions && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('h-8 w-8', isVersionsExpanded && 'text-accent')}
|
||||||
|
aria-label="Toggle versions"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedVersionsId(isVersionsExpanded ? null : gen.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{isGenerating ? (
|
|
||||||
<span className="text-accent">Generating...</span>
|
|
||||||
) : (
|
|
||||||
formatDate(gen.created_at)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Transcript textarea */}
|
{/* Expandable versions panel */}
|
||||||
<div className="flex-1 min-w-0 flex">
|
<AnimatePresence>
|
||||||
<Textarea
|
{isVersionsExpanded && gen.versions && (
|
||||||
value={gen.text}
|
<motion.div
|
||||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
initial={{ height: 0, opacity: 0 }}
|
||||||
readOnly
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
/>
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
</div>
|
className="overflow-hidden"
|
||||||
|
|
||||||
{/* Far right - Actions */}
|
|
||||||
<div
|
|
||||||
className="w-10 shrink-0 flex justify-end items-center"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{isFailed ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
aria-label="Retry generation"
|
|
||||||
onClick={() => handleRetry(gen.id)}
|
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<div className="border-t border-border/50 px-3 pb-2 pt-2">
|
||||||
</Button>
|
<div className="divide-y divide-border/40">
|
||||||
) : isPlayable ? (
|
{gen.versions.map((v) => (
|
||||||
<DropdownMenu>
|
<button
|
||||||
<DropdownMenuTrigger asChild>
|
key={v.id}
|
||||||
<Button
|
type="button"
|
||||||
variant="ghost"
|
className="flex items-center gap-2 w-full h-9 px-2 text-left hover:bg-muted/50 transition-colors"
|
||||||
size="icon"
|
onClick={() => {
|
||||||
className="h-8 w-8"
|
handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id);
|
||||||
aria-label="Actions"
|
if (!v.is_default) {
|
||||||
>
|
handleSwitchVersion(gen.id, v.id);
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
}
|
||||||
</Button>
|
}}
|
||||||
</DropdownMenuTrigger>
|
>
|
||||||
<DropdownMenuContent align="end">
|
<Play className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
<DropdownMenuItem
|
<span className="truncate text-xs font-medium">{v.label}</span>
|
||||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
{v.effects_chain && v.effects_chain.length > 0 && (
|
||||||
>
|
<span className="text-[10px] text-muted-foreground">
|
||||||
<Play className="mr-2 h-4 w-4" />
|
{v.effects_chain.length} fx
|
||||||
Play
|
</span>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
<DropdownMenuItem
|
<span className="flex-1" />
|
||||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
{v.is_default && (
|
||||||
disabled={exportGenerationAudio.isPending}
|
<span className="text-[10px] bg-accent/15 text-accent px-1.5 py-0.5 rounded-full">
|
||||||
>
|
active
|
||||||
<Download className="mr-2 h-4 w-4" />
|
</span>
|
||||||
Export Audio
|
)}
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
<DropdownMenuItem
|
))}
|
||||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
</div>
|
||||||
disabled={exportGeneration.isPending}
|
</div>
|
||||||
>
|
</motion.div>
|
||||||
<FileArchive className="mr-2 h-4 w-4" />
|
)}
|
||||||
Export Package
|
</AnimatePresence>
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
|
||||||
disabled={deleteGeneration.isPending}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -500,6 +633,32 @@ export function HistoryTable() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Apply Effects</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure post-processing effects to apply to this generation. A new version will be
|
||||||
|
created.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2 max-h-80 overflow-y-auto">
|
||||||
|
<EffectsChainEditor value={effectsChain} onChange={setEffectsChain} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApplyEffectsConfirm}
|
||||||
|
disabled={applyingEffects || effectsChain.length === 0}
|
||||||
|
>
|
||||||
|
{applyingEffects ? 'Applying...' : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</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 } from 'lucide-react';
|
import { BookOpen, 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';
|
||||||
@@ -14,6 +14,7 @@ const tabs = [
|
|||||||
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
|
{ id: 'stories', path: '/stories', icon: BookOpen, 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: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||||
|
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
|
||||||
{ 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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
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 type { EffectConfig } from '@/lib/api/types';
|
||||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
@@ -125,6 +128,8 @@ export function ProfileForm() {
|
|||||||
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||||
const isCreating = !editingProfileId;
|
const isCreating = !editingProfileId;
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
const [profileEffectsChain, setProfileEffectsChain] = useState<EffectConfig[]>([]);
|
||||||
|
const [effectsDirty, setEffectsDirty] = useState(false);
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
@@ -280,6 +285,8 @@ export function ProfileForm() {
|
|||||||
referenceText: undefined,
|
referenceText: undefined,
|
||||||
avatarFile: undefined,
|
avatarFile: undefined,
|
||||||
});
|
});
|
||||||
|
setProfileEffectsChain(editingProfile.effects_chain ?? []);
|
||||||
|
setEffectsDirty(false);
|
||||||
} else if (profileFormDraft && open) {
|
} else if (profileFormDraft && open) {
|
||||||
// Restore from draft when opening in create mode
|
// Restore from draft when opening in create mode
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -435,6 +442,23 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save effects chain if changed
|
||||||
|
if (effectsDirty) {
|
||||||
|
try {
|
||||||
|
await apiClient.updateProfileEffects(
|
||||||
|
editingProfileId,
|
||||||
|
profileEffectsChain.length > 0 ? profileEffectsChain : null,
|
||||||
|
);
|
||||||
|
} catch (fxError) {
|
||||||
|
toast({
|
||||||
|
title: 'Effects update failed',
|
||||||
|
description:
|
||||||
|
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Voice updated',
|
title: 'Voice updated',
|
||||||
description: `"${data.name}" has been updated successfully.`,
|
description: `"${data.name}" has been updated successfully.`,
|
||||||
@@ -898,6 +922,23 @@ export function ProfileForm() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{editingProfileId && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>Default Effects</FormLabel>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Effects applied automatically to all new generations with this voice.
|
||||||
|
</p>
|
||||||
|
<EffectsChainEditor
|
||||||
|
value={profileEffectsChain}
|
||||||
|
onChange={(chain) => {
|
||||||
|
setProfileEffectsChain(chain);
|
||||||
|
setEffectsDirty(true);
|
||||||
|
}}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground focus:[&_*]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import type { LanguageCode } from '@/lib/constants/languages';
|
|||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import type {
|
import type {
|
||||||
ActiveTasksResponse,
|
ActiveTasksResponse,
|
||||||
|
ApplyEffectsRequest,
|
||||||
|
AvailableEffectsResponse,
|
||||||
CudaStatus,
|
CudaStatus,
|
||||||
|
EffectConfig,
|
||||||
|
EffectPresetCreate,
|
||||||
|
EffectPresetResponse,
|
||||||
GenerationRequest,
|
GenerationRequest,
|
||||||
GenerationResponse,
|
GenerationResponse,
|
||||||
|
GenerationVersionResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
HistoryListResponse,
|
HistoryListResponse,
|
||||||
HistoryQuery,
|
HistoryQuery,
|
||||||
@@ -583,6 +589,93 @@ class ApiClient {
|
|||||||
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effects & Versions
|
||||||
|
async getAvailableEffects(): Promise<AvailableEffectsResponse> {
|
||||||
|
return this.request<AvailableEffectsResponse>('/effects/available');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEffectPresets(): Promise<EffectPresetResponse[]> {
|
||||||
|
return this.request<EffectPresetResponse[]>('/effects/presets');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEffectPreset(data: EffectPresetCreate): Promise<EffectPresetResponse> {
|
||||||
|
return this.request<EffectPresetResponse>('/effects/presets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEffectPreset(presetId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/effects/presets/${presetId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGenerationVersions(generationId: string): Promise<GenerationVersionResponse[]> {
|
||||||
|
return this.request<GenerationVersionResponse[]>(`/generations/${generationId}/versions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyEffectsToGeneration(
|
||||||
|
generationId: string,
|
||||||
|
data: ApplyEffectsRequest,
|
||||||
|
): Promise<GenerationVersionResponse> {
|
||||||
|
return this.request<GenerationVersionResponse>(
|
||||||
|
`/generations/${generationId}/versions/apply-effects`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDefaultVersion(
|
||||||
|
generationId: string,
|
||||||
|
versionId: string,
|
||||||
|
): Promise<GenerationVersionResponse> {
|
||||||
|
return this.request<GenerationVersionResponse>(
|
||||||
|
`/generations/${generationId}/versions/${versionId}/set-default`,
|
||||||
|
{ method: 'PUT' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGenerationVersion(generationId: string, versionId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/generations/${generationId}/versions/${versionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getVersionAudioUrl(versionId: string): string {
|
||||||
|
return `${this.getBaseUrl()}/audio/version/${versionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfileEffects(
|
||||||
|
profileId: string,
|
||||||
|
effectsChain: EffectConfig[] | null,
|
||||||
|
): Promise<VoiceProfileResponse> {
|
||||||
|
return this.request<VoiceProfileResponse>(`/profiles/${profileId}/effects`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ effects_chain: effectsChain }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewEffects(generationId: string, effectsChain: EffectConfig[]): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/effects/preview/${generationId}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ effects_chain: effectsChain }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface VoiceProfileResponse {
|
|||||||
description?: string;
|
description?: string;
|
||||||
language: string;
|
language: string;
|
||||||
avatar_path?: string;
|
avatar_path?: string;
|
||||||
|
effects_chain?: EffectConfig[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,12 @@ export interface ProfileSampleResponse {
|
|||||||
reference_text: string;
|
reference_text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EffectConfig {
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
params: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenerationRequest {
|
export interface GenerationRequest {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -39,6 +46,17 @@ export interface GenerationRequest {
|
|||||||
max_chunk_chars?: number;
|
max_chunk_chars?: number;
|
||||||
crossfade_ms?: number;
|
crossfade_ms?: number;
|
||||||
normalize?: boolean;
|
normalize?: boolean;
|
||||||
|
effects_chain?: EffectConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationVersionResponse {
|
||||||
|
id: string;
|
||||||
|
generation_id: string;
|
||||||
|
label: string;
|
||||||
|
audio_path: string;
|
||||||
|
effects_chain?: EffectConfig[];
|
||||||
|
is_default: boolean;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationResponse {
|
export interface GenerationResponse {
|
||||||
@@ -55,6 +73,8 @@ export interface GenerationResponse {
|
|||||||
status: 'generating' | 'completed' | 'failed';
|
status: 'generating' | 'completed' | 'failed';
|
||||||
error?: string;
|
error?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
versions?: GenerationVersionResponse[];
|
||||||
|
active_version_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryQuery {
|
export interface HistoryQuery {
|
||||||
@@ -66,6 +86,8 @@ export interface HistoryQuery {
|
|||||||
|
|
||||||
export interface HistoryResponse extends GenerationResponse {
|
export interface HistoryResponse extends GenerationResponse {
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
|
versions?: GenerationVersionResponse[];
|
||||||
|
active_version_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryListResponse {
|
export interface HistoryListResponse {
|
||||||
@@ -256,3 +278,45 @@ export interface StoryItemTrim {
|
|||||||
export interface StoryItemSplit {
|
export interface StoryItemSplit {
|
||||||
split_time_ms: number;
|
split_time_ms: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
|
||||||
|
export interface EffectPresetResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
effects_chain: EffectConfig[];
|
||||||
|
is_builtin: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectPresetCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
effects_chain: EffectConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableEffectParam {
|
||||||
|
default: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableEffect {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
params: Record<string, AvailableEffectParam>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableEffectsResponse {
|
||||||
|
effects: AvailableEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyEffectsRequest {
|
||||||
|
effects_chain: EffectConfig[];
|
||||||
|
label?: string;
|
||||||
|
set_as_default?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
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 } from '@/lib/api/types';
|
||||||
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
@@ -24,6 +25,7 @@ export type GenerationFormValues = z.infer<typeof generationSchema>;
|
|||||||
interface UseGenerationFormOptions {
|
interface UseGenerationFormOptions {
|
||||||
onSuccess?: (generationId: string) => void;
|
onSuccess?: (generationId: string) => void;
|
||||||
defaultValues?: Partial<GenerationFormValues>;
|
defaultValues?: Partial<GenerationFormValues>;
|
||||||
|
getEffectsChain?: () => EffectConfig[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||||
@@ -103,6 +105,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isQwen = engine === 'qwen';
|
const isQwen = engine === 'qwen';
|
||||||
|
const effectsChain = options.getEffectsChain?.();
|
||||||
// This now returns immediately with status="generating"
|
// This now returns immediately with status="generating"
|
||||||
const result = await generation.mutateAsync({
|
const result = await generation.mutateAsync({
|
||||||
profile_id: selectedProfileId,
|
profile_id: selectedProfileId,
|
||||||
@@ -115,6 +118,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
max_chunk_chars: maxChunkChars,
|
max_chunk_chars: maxChunkChars,
|
||||||
crossfade_ms: crossfadeMs,
|
crossfade_ms: crossfadeMs,
|
||||||
normalize: normalizeAudio,
|
normalize: normalizeAudio,
|
||||||
|
effects_chain: effectsChain?.length ? effectsChain : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track this generation for SSE status updates
|
// Track this generation for SSE status updates
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
|
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
|
||||||
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
||||||
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
||||||
|
import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
|
||||||
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
||||||
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
||||||
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
||||||
@@ -105,6 +106,13 @@ const audioRoute = createRoute({
|
|||||||
component: AudioTab,
|
component: AudioTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Effects route
|
||||||
|
const effectsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/effects',
|
||||||
|
component: EffectsTab,
|
||||||
|
});
|
||||||
|
|
||||||
// Models route
|
// Models route
|
||||||
const modelsRoute = createRoute({
|
const modelsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -125,6 +133,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
storiesRoute,
|
storiesRoute,
|
||||||
voicesRoute,
|
voicesRoute,
|
||||||
audioRoute,
|
audioRoute,
|
||||||
|
effectsRoute,
|
||||||
modelsRoute,
|
modelsRoute,
|
||||||
serverRoute,
|
serverRoute,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { EffectConfig } from '@/lib/api/types';
|
||||||
|
|
||||||
|
interface EffectsStore {
|
||||||
|
selectedPresetId: string | null;
|
||||||
|
setSelectedPresetId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Working chain for the detail panel (editing a preset or building a new one)
|
||||||
|
workingChain: EffectConfig[];
|
||||||
|
setWorkingChain: (chain: EffectConfig[]) => void;
|
||||||
|
|
||||||
|
// Track if editing an existing preset vs creating new
|
||||||
|
isCreatingNew: boolean;
|
||||||
|
setIsCreatingNew: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEffectsStore = create<EffectsStore>((set) => ({
|
||||||
|
selectedPresetId: null,
|
||||||
|
setSelectedPresetId: (id) => set({ selectedPresetId: id, isCreatingNew: false }),
|
||||||
|
|
||||||
|
workingChain: [],
|
||||||
|
setWorkingChain: (chain) => set({ workingChain: chain }),
|
||||||
|
|
||||||
|
isCreatingNew: false,
|
||||||
|
setIsCreatingNew: (v) => set({ isCreatingNew: v, selectedPresetId: v ? null : null }),
|
||||||
|
}));
|
||||||
@@ -23,6 +23,7 @@ class VoiceProfile(Base):
|
|||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
language = Column(String, default="en")
|
language = Column(String, default="en")
|
||||||
avatar_path = Column(String, nullable=True)
|
avatar_path = Column(String, nullable=True)
|
||||||
|
effects_chain = Column(Text, nullable=True) # JSON-serialized default effects chain
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -92,6 +93,32 @@ class Project(Base):
|
|||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationVersion(Base):
|
||||||
|
"""A version of a generation's audio (clean, processed, alternate takes)."""
|
||||||
|
__tablename__ = "generation_versions"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
||||||
|
label = Column(String, nullable=False) # "clean", "processed", or user-defined
|
||||||
|
audio_path = Column(String, nullable=False)
|
||||||
|
effects_chain = Column(Text, nullable=True) # JSON-serialized effects config, null for clean
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPreset(Base):
|
||||||
|
"""Saved effect chain preset."""
|
||||||
|
__tablename__ = "effect_presets"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String, unique=True, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
effects_chain = Column(Text, nullable=False) # JSON-serialized effects config
|
||||||
|
is_builtin = Column(Boolean, default=False)
|
||||||
|
sort_order = Column(Integer, default=100)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class AudioChannel(Base):
|
class AudioChannel(Base):
|
||||||
"""Audio channel (bus) database model."""
|
"""Audio channel (bus) database model."""
|
||||||
__tablename__ = "audio_channels"
|
__tablename__ = "audio_channels"
|
||||||
@@ -169,6 +196,12 @@ def init_db():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# Backfill: create "clean" GenerationVersion entries for existing generations
|
||||||
|
_backfill_generation_versions()
|
||||||
|
|
||||||
|
# Seed built-in effect presets
|
||||||
|
_seed_builtin_presets()
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations(engine):
|
def _run_migrations(engine):
|
||||||
"""Run database migrations."""
|
"""Run database migrations."""
|
||||||
@@ -322,6 +355,96 @@ def _run_migrations(engine):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
print("Added model_size column to generations")
|
print("Added model_size column to generations")
|
||||||
|
|
||||||
|
# Migration: Add effects_chain to profiles table
|
||||||
|
if 'profiles' in inspector.get_table_names():
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('profiles')}
|
||||||
|
if 'effects_chain' not in columns:
|
||||||
|
print("Migrating profiles: adding effects_chain column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE profiles ADD COLUMN effects_chain TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added effects_chain column to profiles")
|
||||||
|
|
||||||
|
# Migration: Add sort_order to effect_presets table
|
||||||
|
if 'effect_presets' in inspector.get_table_names():
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('effect_presets')}
|
||||||
|
if 'sort_order' not in columns:
|
||||||
|
print("Migrating effect_presets: adding sort_order column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE effect_presets ADD COLUMN sort_order INTEGER DEFAULT 100"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added sort_order column to effect_presets")
|
||||||
|
|
||||||
|
# Migration: Create generation_versions for existing generations
|
||||||
|
# (populate after tables are created, handled in init_db)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_generation_versions():
|
||||||
|
"""Create 'clean' version entries for existing generations that don't have any."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
# Find generations that have no version entries
|
||||||
|
existing_version_gen_ids = {
|
||||||
|
row[0] for row in db.query(GenerationVersion.generation_id).all()
|
||||||
|
}
|
||||||
|
generations = db.query(Generation).filter(
|
||||||
|
Generation.status == "completed",
|
||||||
|
Generation.audio_path.isnot(None),
|
||||||
|
Generation.audio_path != "",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for gen in generations:
|
||||||
|
if gen.id in existing_version_gen_ids:
|
||||||
|
continue
|
||||||
|
if not _Path(gen.audio_path).exists():
|
||||||
|
continue
|
||||||
|
version = GenerationVersion(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
generation_id=gen.id,
|
||||||
|
label="clean",
|
||||||
|
audio_path=gen.audio_path,
|
||||||
|
effects_chain=None,
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
db.commit()
|
||||||
|
print(f"Backfilled {count} generation version entries")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_builtin_presets():
|
||||||
|
"""Ensure built-in effect presets exist in the database."""
|
||||||
|
import json
|
||||||
|
from .utils.effects import BUILTIN_PRESETS
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for idx, (key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
|
||||||
|
sort_order = preset_data.get("sort_order", idx)
|
||||||
|
existing = db.query(EffectPreset).filter_by(name=preset_data["name"]).first()
|
||||||
|
if not existing:
|
||||||
|
preset = EffectPreset(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=preset_data["name"],
|
||||||
|
description=preset_data.get("description"),
|
||||||
|
effects_chain=json.dumps(preset_data["effects_chain"]),
|
||||||
|
is_builtin=True,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
db.add(preset)
|
||||||
|
elif existing.sort_order != sort_order:
|
||||||
|
existing.sort_order = sort_order
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Get database session (generator for dependency injection)."""
|
"""Get database session (generator for dependency injection)."""
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Effect presets CRUD operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .database import EffectPreset as DBEffectPreset
|
||||||
|
from .models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
|
||||||
|
"""Convert a DB preset row to a Pydantic response."""
|
||||||
|
effects_chain = [EffectConfig(**e) for e in json.loads(p.effects_chain)]
|
||||||
|
return EffectPresetResponse(
|
||||||
|
id=p.id,
|
||||||
|
name=p.name,
|
||||||
|
description=p.description,
|
||||||
|
effects_chain=effects_chain,
|
||||||
|
is_builtin=p.is_builtin or False,
|
||||||
|
created_at=p.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_presets(db: Session) -> List[EffectPresetResponse]:
|
||||||
|
"""List all effect presets (built-in + user-created)."""
|
||||||
|
presets = db.query(DBEffectPreset).order_by(DBEffectPreset.sort_order, DBEffectPreset.name).all()
|
||||||
|
return [_preset_response(p) for p in presets]
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
|
||||||
|
"""Get a preset by ID."""
|
||||||
|
p = db.query(DBEffectPreset).filter_by(id=preset_id).first()
|
||||||
|
if not p:
|
||||||
|
return None
|
||||||
|
return _preset_response(p)
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]:
|
||||||
|
"""Get a preset by name."""
|
||||||
|
p = db.query(DBEffectPreset).filter_by(name=name).first()
|
||||||
|
if not p:
|
||||||
|
return None
|
||||||
|
return _preset_response(p)
|
||||||
|
|
||||||
|
|
||||||
|
def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse:
|
||||||
|
"""Create a new user effect preset."""
|
||||||
|
from .utils.effects import validate_effects_chain
|
||||||
|
|
||||||
|
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||||
|
error = validate_effects_chain(chain_dicts)
|
||||||
|
if error:
|
||||||
|
raise ValueError(error)
|
||||||
|
|
||||||
|
preset = DBEffectPreset(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
effects_chain=json.dumps(chain_dicts),
|
||||||
|
is_builtin=False,
|
||||||
|
)
|
||||||
|
db.add(preset)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(preset)
|
||||||
|
return _preset_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Optional[EffectPresetResponse]:
|
||||||
|
"""Update a user effect preset. Cannot modify built-in presets."""
|
||||||
|
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
|
||||||
|
if not preset:
|
||||||
|
return None
|
||||||
|
if preset.is_builtin:
|
||||||
|
raise ValueError("Cannot modify built-in presets")
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
preset.name = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
preset.description = data.description
|
||||||
|
if data.effects_chain is not None:
|
||||||
|
from .utils.effects import validate_effects_chain
|
||||||
|
|
||||||
|
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||||
|
error = validate_effects_chain(chain_dicts)
|
||||||
|
if error:
|
||||||
|
raise ValueError(error)
|
||||||
|
preset.effects_chain = json.dumps(chain_dicts)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(preset)
|
||||||
|
return _preset_response(preset)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_preset(preset_id: str, db: Session) -> bool:
|
||||||
|
"""Delete a user effect preset. Cannot delete built-in presets."""
|
||||||
|
preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
|
||||||
|
if not preset:
|
||||||
|
return False
|
||||||
|
if preset.is_builtin:
|
||||||
|
raise ValueError("Cannot delete built-in presets")
|
||||||
|
|
||||||
|
db.delete(preset)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
+51
-6
@@ -10,8 +10,8 @@ from pathlib import Path
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse
|
from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig
|
||||||
from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
from .database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +20,43 @@ def _get_generations_dir() -> Path:
|
|||||||
return config.get_generations_dir()
|
return config.get_generations_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_versions_for_generation(generation_id: str, db: Session) -> tuple:
|
||||||
|
"""Get versions list and active version ID for a generation."""
|
||||||
|
import json
|
||||||
|
versions_rows = (
|
||||||
|
db.query(DBGenerationVersion)
|
||||||
|
.filter_by(generation_id=generation_id)
|
||||||
|
.order_by(DBGenerationVersion.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not versions_rows:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
versions = []
|
||||||
|
active_version_id = None
|
||||||
|
for v in versions_rows:
|
||||||
|
effects_chain = None
|
||||||
|
if v.effects_chain:
|
||||||
|
try:
|
||||||
|
raw = json.loads(v.effects_chain)
|
||||||
|
effects_chain = [EffectConfig(**e) for e in raw]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
versions.append(GenerationVersionResponse(
|
||||||
|
id=v.id,
|
||||||
|
generation_id=v.generation_id,
|
||||||
|
label=v.label,
|
||||||
|
audio_path=v.audio_path,
|
||||||
|
effects_chain=effects_chain,
|
||||||
|
is_default=v.is_default,
|
||||||
|
created_at=v.created_at,
|
||||||
|
))
|
||||||
|
if v.is_default:
|
||||||
|
active_version_id = v.id
|
||||||
|
|
||||||
|
return versions, active_version_id
|
||||||
|
|
||||||
|
|
||||||
async def create_generation(
|
async def create_generation(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -170,6 +207,7 @@ async def list_generations(
|
|||||||
# Convert to HistoryResponse with profile_name
|
# Convert to HistoryResponse with profile_name
|
||||||
items = []
|
items = []
|
||||||
for generation, profile_name in results:
|
for generation, profile_name in results:
|
||||||
|
versions, active_version_id = _get_versions_for_generation(generation.id, db)
|
||||||
items.append(HistoryResponse(
|
items.append(HistoryResponse(
|
||||||
id=generation.id,
|
id=generation.id,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
@@ -185,6 +223,8 @@ async def list_generations(
|
|||||||
status=generation.status or "completed",
|
status=generation.status or "completed",
|
||||||
error=generation.error,
|
error=generation.error,
|
||||||
created_at=generation.created_at,
|
created_at=generation.created_at,
|
||||||
|
versions=versions,
|
||||||
|
active_version_id=active_version_id,
|
||||||
))
|
))
|
||||||
|
|
||||||
return HistoryListResponse(
|
return HistoryListResponse(
|
||||||
@@ -211,10 +251,15 @@ async def delete_generation(
|
|||||||
if not generation:
|
if not generation:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Delete audio file
|
# Delete all version files and records
|
||||||
audio_path = Path(generation.audio_path)
|
from . import versions as versions_mod
|
||||||
if audio_path.exists():
|
versions_mod.delete_versions_for_generation(generation_id, db)
|
||||||
audio_path.unlink()
|
|
||||||
|
# Delete main audio file (if not already removed by version cleanup)
|
||||||
|
if generation.audio_path:
|
||||||
|
audio_path = Path(generation.audio_path)
|
||||||
|
if audio_path.exists():
|
||||||
|
audio_path.unlink()
|
||||||
|
|
||||||
# Delete from database
|
# Delete from database
|
||||||
db.delete(generation)
|
db.delete(generation)
|
||||||
|
|||||||
+379
-4
@@ -757,6 +757,20 @@ async def generate_speech(
|
|||||||
text=data.text,
|
text=data.text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Resolve effects chain: explicit request > profile default > none
|
||||||
|
effects_chain_config = None
|
||||||
|
if data.effects_chain is not None:
|
||||||
|
effects_chain_config = [e.model_dump() for e in data.effects_chain]
|
||||||
|
else:
|
||||||
|
# Check profile default
|
||||||
|
import json as _json
|
||||||
|
profile_obj = db.query(DBVoiceProfile).filter_by(id=data.profile_id).first()
|
||||||
|
if profile_obj and profile_obj.effects_chain:
|
||||||
|
try:
|
||||||
|
effects_chain_config = _json.loads(profile_obj.effects_chain)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Kick off TTS in background
|
# Kick off TTS in background
|
||||||
async def _run_generation():
|
async def _run_generation():
|
||||||
bg_db = next(get_db())
|
bg_db = next(get_db())
|
||||||
@@ -799,17 +813,55 @@ async def generate_speech(
|
|||||||
audio = normalize_audio(audio)
|
audio = normalize_audio(audio)
|
||||||
|
|
||||||
duration = len(audio) / sample_rate
|
duration = len(audio) / sample_rate
|
||||||
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
|
||||||
|
|
||||||
|
# Always save clean version first
|
||||||
|
clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
||||||
from .utils.audio import save_audio
|
from .utils.audio import save_audio
|
||||||
save_audio(audio, str(audio_path), sample_rate)
|
save_audio(audio, str(clean_audio_path), sample_rate)
|
||||||
|
|
||||||
|
from . import versions as versions_mod
|
||||||
|
|
||||||
|
has_effects = effects_chain_config and any(
|
||||||
|
e.get("enabled", True) for e in effects_chain_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create clean version entry
|
||||||
|
versions_mod.create_version(
|
||||||
|
generation_id=generation_id,
|
||||||
|
label="clean",
|
||||||
|
audio_path=str(clean_audio_path),
|
||||||
|
db=bg_db,
|
||||||
|
effects_chain=None,
|
||||||
|
is_default=not has_effects,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply effects and create processed version if configured
|
||||||
|
final_audio_path = str(clean_audio_path)
|
||||||
|
if has_effects:
|
||||||
|
from .utils.effects import apply_effects, validate_effects_chain
|
||||||
|
error_msg = validate_effects_chain(effects_chain_config)
|
||||||
|
if error_msg:
|
||||||
|
print(f"Warning: invalid effects chain, skipping: {error_msg}")
|
||||||
|
else:
|
||||||
|
processed_audio = apply_effects(audio, sample_rate, effects_chain_config)
|
||||||
|
processed_path = config.get_generations_dir() / f"{generation_id}_processed.wav"
|
||||||
|
save_audio(processed_audio, str(processed_path), sample_rate)
|
||||||
|
final_audio_path = str(processed_path)
|
||||||
|
versions_mod.create_version(
|
||||||
|
generation_id=generation_id,
|
||||||
|
label="processed",
|
||||||
|
audio_path=str(processed_path),
|
||||||
|
db=bg_db,
|
||||||
|
effects_chain=effects_chain_config,
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Update the record to completed
|
# Update the record to completed
|
||||||
await history.update_generation_status(
|
await history.update_generation_status(
|
||||||
generation_id=generation_id,
|
generation_id=generation_id,
|
||||||
status="completed",
|
status="completed",
|
||||||
db=bg_db,
|
db=bg_db,
|
||||||
audio_path=str(audio_path),
|
audio_path=final_audio_path,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1503,13 +1555,336 @@ async def export_story_audio(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# EFFECTS & VERSIONS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@app.post("/effects/preview/{generation_id}")
|
||||||
|
async def preview_effects(
|
||||||
|
generation_id: str,
|
||||||
|
data: models.ApplyEffectsRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Apply effects to a generation's clean audio and stream back the result without saving.
|
||||||
|
|
||||||
|
Used for ephemeral preview/auditioning of effects chains.
|
||||||
|
"""
|
||||||
|
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not gen:
|
||||||
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
if (gen.status or "completed") != "completed":
|
||||||
|
raise HTTPException(status_code=400, detail="Generation is not completed")
|
||||||
|
|
||||||
|
from . import versions as versions_mod
|
||||||
|
from .utils.effects import apply_effects, validate_effects_chain
|
||||||
|
from .utils.audio import load_audio
|
||||||
|
|
||||||
|
# Validate chain
|
||||||
|
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||||
|
error = validate_effects_chain(chain_dicts)
|
||||||
|
if error:
|
||||||
|
raise HTTPException(status_code=400, detail=error)
|
||||||
|
|
||||||
|
# Find clean version
|
||||||
|
all_versions = versions_mod.list_versions(generation_id, db)
|
||||||
|
clean_version = next((v for v in all_versions if v.label == "clean"), None)
|
||||||
|
source_path = clean_version.audio_path if clean_version else gen.audio_path
|
||||||
|
if not source_path or not Path(source_path).exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Source audio file not found")
|
||||||
|
|
||||||
|
# Process in memory
|
||||||
|
audio, sample_rate = load_audio(source_path)
|
||||||
|
processed = apply_effects(audio, sample_rate, chain_dicts)
|
||||||
|
|
||||||
|
# Write to in-memory buffer
|
||||||
|
import soundfile as sf
|
||||||
|
buf = io.BytesIO()
|
||||||
|
sf.write(buf, processed, sample_rate, format="WAV")
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
buf,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'inline; filename="preview_{generation_id}.wav"',
|
||||||
|
"Cache-Control": "no-cache, no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/effects/available", response_model=models.AvailableEffectsResponse)
|
||||||
|
async def get_available_effects():
|
||||||
|
"""List all available effect types with parameter definitions."""
|
||||||
|
from .utils.effects import get_available_effects as _get_effects
|
||||||
|
return models.AvailableEffectsResponse(effects=[
|
||||||
|
models.AvailableEffect(**e) for e in _get_effects()
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/effects/presets", response_model=List[models.EffectPresetResponse])
|
||||||
|
async def list_effect_presets(db: Session = Depends(get_db)):
|
||||||
|
"""List all effect presets (built-in + user-created)."""
|
||||||
|
from . import effects as effects_mod
|
||||||
|
return effects_mod.list_presets(db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse)
|
||||||
|
async def get_effect_preset(preset_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get a specific effect preset."""
|
||||||
|
from . import effects as effects_mod
|
||||||
|
preset = effects_mod.get_preset(preset_id, db)
|
||||||
|
if not preset:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
return preset
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/effects/presets", response_model=models.EffectPresetResponse)
|
||||||
|
async def create_effect_preset(
|
||||||
|
data: models.EffectPresetCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new effect preset."""
|
||||||
|
from . import effects as effects_mod
|
||||||
|
try:
|
||||||
|
return effects_mod.create_preset(data, db)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse)
|
||||||
|
async def update_effect_preset(
|
||||||
|
preset_id: str,
|
||||||
|
data: models.EffectPresetUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an effect preset."""
|
||||||
|
from . import effects as effects_mod
|
||||||
|
try:
|
||||||
|
result = effects_mod.update_preset(preset_id, data, db)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/effects/presets/{preset_id}")
|
||||||
|
async def delete_effect_preset(preset_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Delete a user effect preset."""
|
||||||
|
from . import effects as effects_mod
|
||||||
|
try:
|
||||||
|
if not effects_mod.delete_preset(preset_id, db):
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
return {"status": "deleted"}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/generations/{generation_id}/versions",
|
||||||
|
response_model=List[models.GenerationVersionResponse],
|
||||||
|
)
|
||||||
|
async def list_generation_versions(
|
||||||
|
generation_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all versions for a generation."""
|
||||||
|
gen = await history.get_generation(generation_id, db)
|
||||||
|
if not gen:
|
||||||
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
|
||||||
|
from . import versions as versions_mod
|
||||||
|
return versions_mod.list_versions(generation_id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/generations/{generation_id}/versions/apply-effects",
|
||||||
|
response_model=models.GenerationVersionResponse,
|
||||||
|
)
|
||||||
|
async def apply_effects_to_generation(
|
||||||
|
generation_id: str,
|
||||||
|
data: models.ApplyEffectsRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Apply an effects chain to an existing generation, creating a new version."""
|
||||||
|
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not gen:
|
||||||
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
if (gen.status or "completed") != "completed":
|
||||||
|
raise HTTPException(status_code=400, detail="Generation is not completed")
|
||||||
|
|
||||||
|
from . import versions as versions_mod
|
||||||
|
from .utils.effects import apply_effects, validate_effects_chain
|
||||||
|
from .utils.audio import load_audio, save_audio
|
||||||
|
|
||||||
|
# Validate effects chain
|
||||||
|
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||||
|
error = validate_effects_chain(chain_dicts)
|
||||||
|
if error:
|
||||||
|
raise HTTPException(status_code=400, detail=error)
|
||||||
|
|
||||||
|
# Find the clean version to apply effects to
|
||||||
|
all_versions = versions_mod.list_versions(generation_id, db)
|
||||||
|
clean_version = next(
|
||||||
|
(v for v in all_versions if v.label == "clean"), None
|
||||||
|
)
|
||||||
|
if not clean_version:
|
||||||
|
# Fallback: use the generation's audio_path directly
|
||||||
|
source_path = gen.audio_path
|
||||||
|
else:
|
||||||
|
source_path = clean_version.audio_path
|
||||||
|
|
||||||
|
if not source_path or not Path(source_path).exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Source audio file not found")
|
||||||
|
|
||||||
|
# Load, process, save
|
||||||
|
audio, sample_rate = load_audio(source_path)
|
||||||
|
processed_audio = apply_effects(audio, sample_rate, chain_dicts)
|
||||||
|
|
||||||
|
# Generate a unique filename
|
||||||
|
version_id = str(uuid.uuid4())
|
||||||
|
processed_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
|
||||||
|
save_audio(processed_audio, str(processed_path), sample_rate)
|
||||||
|
|
||||||
|
# Auto-label
|
||||||
|
label = data.label or f"version-{len(all_versions) + 1}"
|
||||||
|
|
||||||
|
version = versions_mod.create_version(
|
||||||
|
generation_id=generation_id,
|
||||||
|
label=label,
|
||||||
|
audio_path=str(processed_path),
|
||||||
|
db=db,
|
||||||
|
effects_chain=chain_dicts,
|
||||||
|
is_default=data.set_as_default,
|
||||||
|
)
|
||||||
|
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
@app.put(
|
||||||
|
"/generations/{generation_id}/versions/{version_id}/set-default",
|
||||||
|
response_model=models.GenerationVersionResponse,
|
||||||
|
)
|
||||||
|
async def set_default_version(
|
||||||
|
generation_id: str,
|
||||||
|
version_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Set a specific version as the default for a generation."""
|
||||||
|
from . import versions as versions_mod
|
||||||
|
|
||||||
|
version = versions_mod.get_version(version_id, db)
|
||||||
|
if not version or version.generation_id != generation_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
|
||||||
|
result = versions_mod.set_default_version(version_id, db)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/generations/{generation_id}/versions/{version_id}")
|
||||||
|
async def delete_generation_version(
|
||||||
|
generation_id: str,
|
||||||
|
version_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a version. Cannot delete the last remaining version."""
|
||||||
|
from . import versions as versions_mod
|
||||||
|
|
||||||
|
version = versions_mod.get_version(version_id, db)
|
||||||
|
if not version or version.generation_id != generation_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
|
||||||
|
if not versions_mod.delete_version(version_id, db):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot delete the last remaining version",
|
||||||
|
)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/audio/version/{version_id}")
|
||||||
|
async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Serve audio for a specific version."""
|
||||||
|
from . import versions as versions_mod
|
||||||
|
|
||||||
|
version = versions_mod.get_version(version_id, db)
|
||||||
|
if not version:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
|
||||||
|
audio_path = Path(version.audio_path)
|
||||||
|
if not audio_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
audio_path,
|
||||||
|
media_type="audio/wav",
|
||||||
|
filename=f"generation_{version.generation_id}_{version.label}.wav",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/profiles/{profile_id}/effects", response_model=models.VoiceProfileResponse)
|
||||||
|
async def update_profile_effects(
|
||||||
|
profile_id: str,
|
||||||
|
data: models.ProfileEffectsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Set or clear the default effects chain for a voice profile."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(status_code=404, detail="Profile not found")
|
||||||
|
|
||||||
|
if data.effects_chain is not None:
|
||||||
|
from .utils.effects import validate_effects_chain
|
||||||
|
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||||
|
error = validate_effects_chain(chain_dicts)
|
||||||
|
if error:
|
||||||
|
raise HTTPException(status_code=400, detail=error)
|
||||||
|
profile.effects_chain = _json.dumps(chain_dicts)
|
||||||
|
else:
|
||||||
|
profile.effects_chain = None
|
||||||
|
|
||||||
|
profile.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(profile)
|
||||||
|
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_to_response(profile) -> models.VoiceProfileResponse:
|
||||||
|
"""Convert a DB profile to a VoiceProfileResponse with parsed effects_chain."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
effects_chain = None
|
||||||
|
if profile.effects_chain:
|
||||||
|
try:
|
||||||
|
raw = _json.loads(profile.effects_chain)
|
||||||
|
effects_chain = [models.EffectConfig(**e) for e in raw]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return models.VoiceProfileResponse(
|
||||||
|
id=profile.id,
|
||||||
|
name=profile.name,
|
||||||
|
description=profile.description,
|
||||||
|
language=profile.language,
|
||||||
|
avatar_path=profile.avatar_path,
|
||||||
|
effects_chain=effects_chain,
|
||||||
|
created_at=profile.created_at,
|
||||||
|
updated_at=profile.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# FILE SERVING
|
# FILE SERVING
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@app.get("/audio/{generation_id}")
|
@app.get("/audio/{generation_id}")
|
||||||
async def get_audio(generation_id: str, db: Session = Depends(get_db)):
|
async def get_audio(generation_id: str, db: Session = Depends(get_db)):
|
||||||
"""Serve generated audio file."""
|
"""Serve generated audio file (serves the default version)."""
|
||||||
generation = await history.get_generation(generation_id, db)
|
generation = await history.get_generation(generation_id, db)
|
||||||
if not generation:
|
if not generation:
|
||||||
raise HTTPException(status_code=404, detail="Generation not found")
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class VoiceProfileResponse(BaseModel):
|
|||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
language: str
|
language: str
|
||||||
avatar_path: Optional[str] = None
|
avatar_path: Optional[str] = None
|
||||||
|
effects_chain: Optional[List["EffectConfig"]] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ class GenerationRequest(BaseModel):
|
|||||||
max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting")
|
max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting")
|
||||||
crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)")
|
crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)")
|
||||||
normalize: bool = Field(default=True, description="Normalize output audio volume")
|
normalize: bool = Field(default=True, description="Normalize output audio volume")
|
||||||
|
effects_chain: Optional[List["EffectConfig"]] = Field(None, description="Effects chain to apply after generation (overrides profile default)")
|
||||||
|
|
||||||
|
|
||||||
class GenerationResponse(BaseModel):
|
class GenerationResponse(BaseModel):
|
||||||
@@ -78,6 +80,8 @@ class GenerationResponse(BaseModel):
|
|||||||
status: str = "completed"
|
status: str = "completed"
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
|
active_version_id: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -107,6 +111,8 @@ class HistoryResponse(BaseModel):
|
|||||||
status: str = "completed"
|
status: str = "completed"
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
|
active_version_id: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -339,3 +345,94 @@ class StoryItemTrim(BaseModel):
|
|||||||
class StoryItemSplit(BaseModel):
|
class StoryItemSplit(BaseModel):
|
||||||
"""Request model for splitting a story item."""
|
"""Request model for splitting a story item."""
|
||||||
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
|
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Effects & Versions
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
class EffectConfig(BaseModel):
|
||||||
|
"""A single effect in an effects chain."""
|
||||||
|
type: str
|
||||||
|
enabled: bool = True
|
||||||
|
params: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectsChain(BaseModel):
|
||||||
|
"""An ordered list of effects to apply."""
|
||||||
|
effects: List[EffectConfig] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPresetCreate(BaseModel):
|
||||||
|
"""Request model for creating an effect preset."""
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
effects_chain: List[EffectConfig]
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPresetUpdate(BaseModel):
|
||||||
|
"""Request model for updating an effect preset."""
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = None
|
||||||
|
effects_chain: Optional[List[EffectConfig]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPresetResponse(BaseModel):
|
||||||
|
"""Response model for effect preset."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
effects_chain: List[EffectConfig]
|
||||||
|
is_builtin: bool = False
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationVersionResponse(BaseModel):
|
||||||
|
"""Response model for a generation version."""
|
||||||
|
id: str
|
||||||
|
generation_id: str
|
||||||
|
label: str
|
||||||
|
audio_path: str
|
||||||
|
effects_chain: Optional[List[EffectConfig]] = None
|
||||||
|
is_default: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyEffectsRequest(BaseModel):
|
||||||
|
"""Request to apply effects to an existing generation."""
|
||||||
|
effects_chain: List[EffectConfig]
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileEffectsUpdate(BaseModel):
|
||||||
|
"""Request to update the default effects chain on a profile."""
|
||||||
|
effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)")
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableEffectParam(BaseModel):
|
||||||
|
"""Description of a single effect parameter."""
|
||||||
|
default: float
|
||||||
|
min: float
|
||||||
|
max: float
|
||||||
|
step: float
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableEffect(BaseModel):
|
||||||
|
"""Description of an available effect type."""
|
||||||
|
type: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
params: dict # param_name -> AvailableEffectParam
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableEffectsResponse(BaseModel):
|
||||||
|
"""Response listing all available effect types."""
|
||||||
|
effects: List[AvailableEffect]
|
||||||
|
|||||||
+28
-5
@@ -20,11 +20,34 @@ from .database import (
|
|||||||
VoiceProfile as DBVoiceProfile,
|
VoiceProfile as DBVoiceProfile,
|
||||||
ProfileSample as DBProfileSample,
|
ProfileSample as DBProfileSample,
|
||||||
)
|
)
|
||||||
|
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
|
||||||
from .utils.images import validate_image, process_avatar
|
from .utils.images import validate_image, process_avatar
|
||||||
from .utils.cache import _get_cache_dir, clear_profile_cache
|
from .utils.cache import _get_cache_dir, clear_profile_cache
|
||||||
from .tts import get_tts_model
|
from .tts import get_tts_model
|
||||||
from . import config
|
from . import config
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_to_response(profile: DBVoiceProfile) -> VoiceProfileResponse:
|
||||||
|
"""Convert a DB profile to a VoiceProfileResponse, deserializing effects_chain."""
|
||||||
|
effects_chain = None
|
||||||
|
if profile.effects_chain:
|
||||||
|
try:
|
||||||
|
raw = _json.loads(profile.effects_chain)
|
||||||
|
effects_chain = [EffectConfig(**e) for e in raw]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return VoiceProfileResponse(
|
||||||
|
id=profile.id,
|
||||||
|
name=profile.name,
|
||||||
|
description=profile.description,
|
||||||
|
language=profile.language,
|
||||||
|
avatar_path=profile.avatar_path,
|
||||||
|
effects_chain=effects_chain,
|
||||||
|
created_at=profile.created_at,
|
||||||
|
updated_at=profile.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_profiles_dir() -> Path:
|
def _get_profiles_dir() -> Path:
|
||||||
@@ -72,7 +95,7 @@ async def create_profile(
|
|||||||
profile_dir = _get_profiles_dir() / db_profile.id
|
profile_dir = _get_profiles_dir() / db_profile.id
|
||||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
return VoiceProfileResponse.model_validate(db_profile)
|
return _profile_to_response(db_profile)
|
||||||
|
|
||||||
|
|
||||||
async def add_profile_sample(
|
async def add_profile_sample(
|
||||||
@@ -154,7 +177,7 @@ async def get_profile(
|
|||||||
if not profile:
|
if not profile:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return VoiceProfileResponse.model_validate(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
async def get_profile_samples(
|
async def get_profile_samples(
|
||||||
@@ -189,7 +212,7 @@ async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
|
|||||||
DBVoiceProfile.created_at.desc()
|
DBVoiceProfile.created_at.desc()
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
return [VoiceProfileResponse.model_validate(p) for p in profiles]
|
return [_profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
@@ -230,7 +253,7 @@ async def update_profile(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(profile)
|
db.refresh(profile)
|
||||||
|
|
||||||
return VoiceProfileResponse.model_validate(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
async def delete_profile(
|
async def delete_profile(
|
||||||
@@ -472,7 +495,7 @@ async def upload_avatar(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(profile)
|
db.refresh(profile)
|
||||||
|
|
||||||
return VoiceProfileResponse.model_validate(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
async def delete_avatar(
|
async def delete_avatar(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ librosa>=0.10.0
|
|||||||
soundfile>=0.12.0
|
soundfile>=0.12.0
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
numba>=0.60.0,<0.61.0
|
numba>=0.60.0,<0.61.0
|
||||||
|
pedalboard>=0.9.0
|
||||||
|
|
||||||
# HTTP client (for CUDA backend download)
|
# HTTP client (for CUDA backend download)
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""
|
||||||
|
Audio post-processing effects engine.
|
||||||
|
|
||||||
|
Uses Spotify's pedalboard library to apply professional-grade DSP effects
|
||||||
|
to generated audio. Effects are described as a JSON-serializable chain
|
||||||
|
(list of effect dicts) so they can be stored in the database and sent
|
||||||
|
over the API.
|
||||||
|
|
||||||
|
Supported effect types:
|
||||||
|
- chorus (flanger-style with short delays)
|
||||||
|
- reverb (room reverb)
|
||||||
|
- delay (echo / delay line)
|
||||||
|
- compressor (dynamic range compression)
|
||||||
|
- gain (volume adjustment in dB)
|
||||||
|
- highpass (high-pass filter)
|
||||||
|
- lowpass (low-pass filter)
|
||||||
|
- pitch_shift (semitone pitch shifting)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pedalboard import (
|
||||||
|
Pedalboard,
|
||||||
|
Chorus,
|
||||||
|
Reverb,
|
||||||
|
Compressor,
|
||||||
|
Gain,
|
||||||
|
HighpassFilter,
|
||||||
|
LowpassFilter,
|
||||||
|
Delay,
|
||||||
|
PitchShift,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Effect registry: maps type names -> (pedalboard class, param definitions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Each param definition: (default, min, max, description)
|
||||||
|
EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
|
||||||
|
"chorus": {
|
||||||
|
"cls": Chorus,
|
||||||
|
"label": "Chorus / Flanger",
|
||||||
|
"description": "Modulated delay for flanging or chorus effects. Short centre_delay_ms (<10) gives flanger; longer gives chorus.",
|
||||||
|
"params": {
|
||||||
|
"rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"},
|
||||||
|
"depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"},
|
||||||
|
"feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
|
||||||
|
"centre_delay_ms": {"default": 7.0, "min": 0.5, "max": 50.0, "step": 0.1, "description": "Centre delay (ms)"},
|
||||||
|
"mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"reverb": {
|
||||||
|
"cls": Reverb,
|
||||||
|
"label": "Reverb",
|
||||||
|
"description": "Room reverb effect.",
|
||||||
|
"params": {
|
||||||
|
"room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"},
|
||||||
|
"damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"},
|
||||||
|
"wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"},
|
||||||
|
"dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"},
|
||||||
|
"width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"delay": {
|
||||||
|
"cls": Delay,
|
||||||
|
"label": "Delay",
|
||||||
|
"description": "Echo / delay line.",
|
||||||
|
"params": {
|
||||||
|
"delay_seconds": {"default": 0.3, "min": 0.01, "max": 2.0, "step": 0.01, "description": "Delay time (seconds)"},
|
||||||
|
"feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
|
||||||
|
"mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"compressor": {
|
||||||
|
"cls": Compressor,
|
||||||
|
"label": "Compressor",
|
||||||
|
"description": "Dynamic range compression for consistent loudness.",
|
||||||
|
"params": {
|
||||||
|
"threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"},
|
||||||
|
"ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"},
|
||||||
|
"attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"},
|
||||||
|
"release_ms": {"default": 100.0, "min": 10.0, "max": 1000.0,"step": 1.0, "description": "Release time (ms)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"gain": {
|
||||||
|
"cls": Gain,
|
||||||
|
"label": "Gain",
|
||||||
|
"description": "Volume adjustment in decibels.",
|
||||||
|
"params": {
|
||||||
|
"gain_db": {"default": 0.0, "min": -40.0, "max": 40.0, "step": 0.5, "description": "Gain (dB)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"highpass": {
|
||||||
|
"cls": HighpassFilter,
|
||||||
|
"label": "High-Pass Filter",
|
||||||
|
"description": "Removes frequencies below the cutoff.",
|
||||||
|
"params": {
|
||||||
|
"cutoff_frequency_hz": {"default": 80.0, "min": 20.0, "max": 8000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"lowpass": {
|
||||||
|
"cls": LowpassFilter,
|
||||||
|
"label": "Low-Pass Filter",
|
||||||
|
"description": "Removes frequencies above the cutoff.",
|
||||||
|
"params": {
|
||||||
|
"cutoff_frequency_hz": {"default": 8000.0, "min": 200.0, "max": 20000.0, "step": 1.0, "description": "Cutoff frequency (Hz)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"pitch_shift": {
|
||||||
|
"cls": PitchShift,
|
||||||
|
"label": "Pitch Shift",
|
||||||
|
"description": "Shift pitch up or down by semitones.",
|
||||||
|
"params": {
|
||||||
|
"semitones": {"default": 0.0, "min": -12.0, "max": 12.0, "step": 0.5, "description": "Semitones to shift"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in presets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"robotic": {
|
||||||
|
"name": "Robotic",
|
||||||
|
"sort_order": 0,
|
||||||
|
"description": "Metallic robotic voice (flanger with slow LFO and high feedback)",
|
||||||
|
"effects_chain": [
|
||||||
|
{
|
||||||
|
"type": "chorus",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {
|
||||||
|
"rate_hz": 0.2,
|
||||||
|
"depth": 1.0,
|
||||||
|
"feedback": 0.35,
|
||||||
|
"centre_delay_ms": 7.0,
|
||||||
|
"mix": 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"radio": {
|
||||||
|
"name": "Radio",
|
||||||
|
"sort_order": 1,
|
||||||
|
"description": "Thin AM-radio voice with band-pass filtering and light compression",
|
||||||
|
"effects_chain": [
|
||||||
|
{
|
||||||
|
"type": "highpass",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {"cutoff_frequency_hz": 300.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lowpass",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {"cutoff_frequency_hz": 3500.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "compressor",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {
|
||||||
|
"threshold_db": -15.0,
|
||||||
|
"ratio": 6.0,
|
||||||
|
"attack_ms": 5.0,
|
||||||
|
"release_ms": 50.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gain",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {"gain_db": 6.0},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"echo_chamber": {
|
||||||
|
"name": "Echo Chamber",
|
||||||
|
"sort_order": 2,
|
||||||
|
"description": "Spacious reverb with trailing echo",
|
||||||
|
"effects_chain": [
|
||||||
|
{
|
||||||
|
"type": "reverb",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {
|
||||||
|
"room_size": 0.85,
|
||||||
|
"damping": 0.3,
|
||||||
|
"wet_level": 0.45,
|
||||||
|
"dry_level": 0.55,
|
||||||
|
"width": 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "delay",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {
|
||||||
|
"delay_seconds": 0.25,
|
||||||
|
"feedback": 0.3,
|
||||||
|
"mix": 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"deep_voice": {
|
||||||
|
"name": "Deep Voice",
|
||||||
|
"sort_order": 99,
|
||||||
|
"description": "Lower pitch with added warmth",
|
||||||
|
"effects_chain": [
|
||||||
|
{
|
||||||
|
"type": "pitch_shift",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {"semitones": -3.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lowpass",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {"cutoff_frequency_hz": 6000.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "compressor",
|
||||||
|
"enabled": True,
|
||||||
|
"params": {
|
||||||
|
"threshold_db": -18.0,
|
||||||
|
"ratio": 3.0,
|
||||||
|
"attack_ms": 10.0,
|
||||||
|
"release_ms": 150.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_available_effects() -> List[Dict[str, Any]]:
|
||||||
|
"""Return the list of available effect types with their parameter definitions.
|
||||||
|
|
||||||
|
Used by the frontend to build the effects chain editor UI.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for effect_type, info in EFFECT_REGISTRY.items():
|
||||||
|
result.append({
|
||||||
|
"type": effect_type,
|
||||||
|
"label": info["label"],
|
||||||
|
"description": info["description"],
|
||||||
|
"params": {
|
||||||
|
name: {k: v for k, v in pdef.items()}
|
||||||
|
for name, pdef in info["params"].items()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_builtin_presets() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Return all built-in effect presets."""
|
||||||
|
return BUILTIN_PRESETS
|
||||||
|
|
||||||
|
|
||||||
|
def validate_effects_chain(effects_chain: List[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
"""Validate an effects chain configuration.
|
||||||
|
|
||||||
|
Returns None if valid, or an error message string.
|
||||||
|
"""
|
||||||
|
if not isinstance(effects_chain, list):
|
||||||
|
return "effects_chain must be a list"
|
||||||
|
|
||||||
|
for i, effect in enumerate(effects_chain):
|
||||||
|
if not isinstance(effect, dict):
|
||||||
|
return f"Effect at index {i} must be a dict"
|
||||||
|
|
||||||
|
effect_type = effect.get("type")
|
||||||
|
if effect_type not in EFFECT_REGISTRY:
|
||||||
|
return f"Unknown effect type '{effect_type}' at index {i}. Available: {list(EFFECT_REGISTRY.keys())}"
|
||||||
|
|
||||||
|
params = effect.get("params", {})
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
return f"Effect '{effect_type}' at index {i}: params must be a dict"
|
||||||
|
|
||||||
|
registry = EFFECT_REGISTRY[effect_type]
|
||||||
|
for param_name, value in params.items():
|
||||||
|
if param_name not in registry["params"]:
|
||||||
|
return f"Effect '{effect_type}' at index {i}: unknown param '{param_name}'"
|
||||||
|
|
||||||
|
pdef = registry["params"][param_name]
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
return f"Effect '{effect_type}' at index {i}: param '{param_name}' must be a number"
|
||||||
|
if value < pdef["min"] or value > pdef["max"]:
|
||||||
|
return (
|
||||||
|
f"Effect '{effect_type}' at index {i}: param '{param_name}' "
|
||||||
|
f"must be between {pdef['min']} and {pdef['max']} (got {value})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_pedalboard(effects_chain: List[Dict[str, Any]]) -> Pedalboard:
|
||||||
|
"""Build a Pedalboard instance from an effects chain config.
|
||||||
|
|
||||||
|
Skips effects where ``enabled`` is ``False``.
|
||||||
|
"""
|
||||||
|
plugins = []
|
||||||
|
for effect in effects_chain:
|
||||||
|
if not effect.get("enabled", True):
|
||||||
|
continue
|
||||||
|
|
||||||
|
effect_type = effect["type"]
|
||||||
|
registry = EFFECT_REGISTRY[effect_type]
|
||||||
|
cls = registry["cls"]
|
||||||
|
|
||||||
|
# Merge defaults with provided params
|
||||||
|
params = {}
|
||||||
|
for pname, pdef in registry["params"].items():
|
||||||
|
params[pname] = effect.get("params", {}).get(pname, pdef["default"])
|
||||||
|
|
||||||
|
plugins.append(cls(**params))
|
||||||
|
|
||||||
|
return Pedalboard(plugins)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_effects(
|
||||||
|
audio: np.ndarray,
|
||||||
|
sample_rate: int,
|
||||||
|
effects_chain: List[Dict[str, Any]],
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Apply an effects chain to audio data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio: Input audio array (1-D mono float32).
|
||||||
|
sample_rate: Sample rate in Hz.
|
||||||
|
effects_chain: List of effect configuration dicts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed audio array.
|
||||||
|
"""
|
||||||
|
if not effects_chain:
|
||||||
|
return audio
|
||||||
|
|
||||||
|
board = build_pedalboard(effects_chain)
|
||||||
|
|
||||||
|
# pedalboard expects shape (channels, samples)
|
||||||
|
if audio.ndim == 1:
|
||||||
|
audio_2d = audio[np.newaxis, :]
|
||||||
|
else:
|
||||||
|
audio_2d = audio
|
||||||
|
|
||||||
|
processed = board(audio_2d.astype(np.float32), sample_rate)
|
||||||
|
|
||||||
|
# Return same dimensionality as input
|
||||||
|
if audio.ndim == 1:
|
||||||
|
return processed[0]
|
||||||
|
return processed
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
Generation versions management module.
|
||||||
|
|
||||||
|
Each generation can have multiple audio versions: a clean (unprocessed)
|
||||||
|
version and any number of processed versions with different effects chains.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .database import (
|
||||||
|
GenerationVersion as DBGenerationVersion,
|
||||||
|
Generation as DBGeneration,
|
||||||
|
)
|
||||||
|
from .models import GenerationVersionResponse, EffectConfig
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
|
||||||
|
def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
|
||||||
|
"""Convert a DB version row to a Pydantic response."""
|
||||||
|
effects_chain = None
|
||||||
|
if v.effects_chain:
|
||||||
|
raw = json.loads(v.effects_chain)
|
||||||
|
effects_chain = [EffectConfig(**e) for e in raw]
|
||||||
|
return GenerationVersionResponse(
|
||||||
|
id=v.id,
|
||||||
|
generation_id=v.generation_id,
|
||||||
|
label=v.label,
|
||||||
|
audio_path=v.audio_path,
|
||||||
|
effects_chain=effects_chain,
|
||||||
|
is_default=v.is_default,
|
||||||
|
created_at=v.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResponse]:
|
||||||
|
"""List all versions for a generation."""
|
||||||
|
versions = (
|
||||||
|
db.query(DBGenerationVersion)
|
||||||
|
.filter_by(generation_id=generation_id)
|
||||||
|
.order_by(DBGenerationVersion.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_version_response(v) for v in versions]
|
||||||
|
|
||||||
|
|
||||||
|
def get_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||||
|
"""Get a specific version by ID."""
|
||||||
|
v = db.query(DBGenerationVersion).filter_by(id=version_id).first()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
return _version_response(v)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_version(generation_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||||
|
"""Get the default version for a generation."""
|
||||||
|
v = (
|
||||||
|
db.query(DBGenerationVersion)
|
||||||
|
.filter_by(generation_id=generation_id, is_default=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not v:
|
||||||
|
# Fallback: return the first version
|
||||||
|
v = (
|
||||||
|
db.query(DBGenerationVersion)
|
||||||
|
.filter_by(generation_id=generation_id)
|
||||||
|
.order_by(DBGenerationVersion.created_at)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
return _version_response(v)
|
||||||
|
|
||||||
|
|
||||||
|
def create_version(
|
||||||
|
generation_id: str,
|
||||||
|
label: str,
|
||||||
|
audio_path: str,
|
||||||
|
db: Session,
|
||||||
|
effects_chain: Optional[List[dict]] = None,
|
||||||
|
is_default: bool = False,
|
||||||
|
) -> GenerationVersionResponse:
|
||||||
|
"""Create a new version for a generation.
|
||||||
|
|
||||||
|
If ``is_default`` is True, all other versions for this generation
|
||||||
|
are un-defaulted first.
|
||||||
|
"""
|
||||||
|
if is_default:
|
||||||
|
_clear_defaults(generation_id, db)
|
||||||
|
|
||||||
|
version = DBGenerationVersion(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
generation_id=generation_id,
|
||||||
|
label=label,
|
||||||
|
audio_path=audio_path,
|
||||||
|
effects_chain=json.dumps(effects_chain) if effects_chain else None,
|
||||||
|
is_default=is_default,
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(version)
|
||||||
|
|
||||||
|
# If this version is the default, update the generation's audio_path
|
||||||
|
if is_default:
|
||||||
|
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if gen:
|
||||||
|
gen.audio_path = audio_path
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return _version_response(version)
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
|
||||||
|
"""Set a version as the default for its generation."""
|
||||||
|
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
|
||||||
|
if not version:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_clear_defaults(version.generation_id, db)
|
||||||
|
version.is_default = True
|
||||||
|
db.commit()
|
||||||
|
db.refresh(version)
|
||||||
|
|
||||||
|
# Update generation's audio_path to point to this version
|
||||||
|
gen = db.query(DBGeneration).filter_by(id=version.generation_id).first()
|
||||||
|
if gen:
|
||||||
|
gen.audio_path = version.audio_path
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return _version_response(version)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_version(version_id: str, db: Session) -> bool:
|
||||||
|
"""Delete a version. Cannot delete the last remaining version."""
|
||||||
|
version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
|
||||||
|
if not version:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Don't allow deleting the last version
|
||||||
|
count = (
|
||||||
|
db.query(DBGenerationVersion)
|
||||||
|
.filter_by(generation_id=version.generation_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if count <= 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
was_default = version.is_default
|
||||||
|
gen_id = version.generation_id
|
||||||
|
|
||||||
|
# Delete audio file
|
||||||
|
audio_path = Path(version.audio_path)
|
||||||
|
if audio_path.exists():
|
||||||
|
audio_path.unlink()
|
||||||
|
|
||||||
|
db.delete(version)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If this was the default, promote the first remaining version
|
||||||
|
if was_default:
|
||||||
|
first = (
|
||||||
|
db.query(DBGenerationVersion)
|
||||||
|
.filter_by(generation_id=gen_id)
|
||||||
|
.order_by(DBGenerationVersion.created_at)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if first:
|
||||||
|
first.is_default = True
|
||||||
|
db.commit()
|
||||||
|
gen = db.query(DBGeneration).filter_by(id=gen_id).first()
|
||||||
|
if gen:
|
||||||
|
gen.audio_path = first.audio_path
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_versions_for_generation(generation_id: str, db: Session) -> int:
|
||||||
|
"""Delete all versions for a generation (used when deleting a generation)."""
|
||||||
|
versions = (
|
||||||
|
db.query(DBGenerationVersion)
|
||||||
|
.filter_by(generation_id=generation_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
count = 0
|
||||||
|
for v in versions:
|
||||||
|
audio_path = Path(v.audio_path)
|
||||||
|
if audio_path.exists():
|
||||||
|
audio_path.unlink()
|
||||||
|
db.delete(v)
|
||||||
|
count += 1
|
||||||
|
if count > 0:
|
||||||
|
db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_defaults(generation_id: str, db: Session) -> None:
|
||||||
|
"""Clear the is_default flag on all versions for a generation."""
|
||||||
|
db.query(DBGenerationVersion).filter_by(
|
||||||
|
generation_id=generation_id, is_default=True
|
||||||
|
).update({"is_default": False})
|
||||||
|
db.flush()
|
||||||
Reference in New Issue
Block a user