diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19dbe9a3..6a654bc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,6 @@ jobs: args: "--target x86_64-apple-darwin" python-version: "3.12" backend: "pytorch" - - platform: "namespace-profile-voicebox" - args: "--bundles deb,rpm" - python-version: "3.12" - backend: "pytorch" - platform: "windows-latest" args: "" python-version: "3.12" @@ -150,7 +146,7 @@ jobs: - **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference - **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch - **Windows**: Download the `.msi` installer - - **Linux**: Download the `.AppImage` or `.deb` package + - **Linux**: Compile from source (see README) The app includes automatic updates - future updates will be installed automatically. releaseDraft: true diff --git a/.gitignore b/.gitignore index 05f7ef0d..118735ec 100644 --- a/.gitignore +++ b/.gitignore @@ -35,10 +35,7 @@ target/ Thumbs.db # Data (user-generated) -data/profiles/* -data/generations/* -data/projects/* -data/voicebox.db +data/ !data/.gitkeep # Logs diff --git a/README.md b/README.md index 00eaab6d..b655bfc3 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Voicebox is available now for macOS and Windows. | Windows (MSI) | [Latest Windows MSI](https://github.com/jamiepine/voicebox/releases/latest) | | Windows (Setup) | [Latest Windows Setup](https://github.com/jamiepine/voicebox/releases/latest) | -> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations. +> **Linux** — Pre-built binaries are not yet available. Linux users can compile from source, see [Development](#development) below. --- diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 44ebb266..75e1b4e5 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -157,8 +157,21 @@ export function AudioPlayer() { const wavesurfer = wavesurferRef.current; if (!wavesurfer) return; - // Update store when time changes + // Update store when time changes, stop if past duration wavesurfer.on('timeupdate', (time) => { + const dur = usePlayerStore.getState().duration; + if (dur > 0 && time >= dur) { + setCurrentTime(dur); + const loop = usePlayerStore.getState().isLooping; + if (loop) { + wavesurfer.seekTo(0); + wavesurfer.play(); + } else { + wavesurfer.pause(); + setIsPlaying(false); + } + return; + } setCurrentTime(time); }); diff --git a/app/src/components/Effects/EffectsChainEditor.tsx b/app/src/components/Effects/EffectsChainEditor.tsx new file mode 100644 index 00000000..e913808c --- /dev/null +++ b/app/src/components/Effects/EffectsChainEditor.tsx @@ -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(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([]); + 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(); + 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 = {}; + 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 ( +
+ {/* Preset selector row */} + {showPresets && ( +
+ + + {value.length > 0 && ( + + )} +
+ )} + + {/* Sortable effects chain */} + + i._id)} strategy={verticalListSortingStrategy}> + {items.map((effect, index) => ( + setExpandedId(expandedId === effect._id ? null : effect._id)} + onRemove={() => removeEffect(index)} + onToggleEnabled={() => toggleEnabled(index)} + onUpdateParam={(paramName, paramValue) => updateParam(index, paramName, paramValue)} + /> + ))} + + + + {/* Add effect */} + {availableEffects && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ {/* Header */} +
+ + + + + + {label} + + + + + +
+ + {/* Params */} + {isExpanded && effectDef && ( +
+ {Object.entries(effectDef.params).map(([paramName, paramDef]) => { + const currentValue = effect.params[paramName] ?? paramDef.default; + return ( +
+
+ + + {currentValue.toFixed( + paramDef.step < 1 ? Math.max(1, -Math.floor(Math.log10(paramDef.step))) : 0, + )} + +
+ onUpdateParam(paramName, v)} + /> +
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/src/components/Effects/GenerationPicker.tsx b/app/src/components/Effects/GenerationPicker.tsx new file mode 100644 index 00000000..d8ab3d2f --- /dev/null +++ b/app/src/components/Effects/GenerationPicker.tsx @@ -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 ( + + + + + +
+
+ + setSearchQuery(e.target.value)} + className="h-8 pl-7 text-xs" + /> +
+
+
+ {filtered.length === 0 ? ( +
+ No generations found +
+ ) : ( + filtered.map((gen) => ( + + )) + )} +
+
+
+ ); +} diff --git a/app/src/components/EffectsTab/EffectsDetail.tsx b/app/src/components/EffectsTab/EffectsDetail.tsx new file mode 100644 index 00000000..f877c914 --- /dev/null +++ b/app/src/components/EffectsTab/EffectsDetail.tsx @@ -0,0 +1,332 @@ +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(null); + const [previewLoading, setPreviewLoading] = useState(false); + const blobUrlRef = useRef(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 handleSaveExisting() { + if (!selectedPresetId || !name.trim()) return; + setSaving(true); + try { + await apiClient.updateEffectPreset(selectedPresetId, { + name: name.trim(), + description: description.trim() || undefined, + effects_chain: workingChain, + }); + queryClient.invalidateQueries({ queryKey: ['effect-presets'] }); + queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] }); + toast({ title: 'Preset updated' }); + } 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 ( +
+
+ +

Select a preset or create a new one

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ {isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'} +

+
+ {!isBuiltIn && !isCreatingNew && ( + <> + + + + )} + {isCreatingNew && ( + + )} + {isBuiltIn && ( + + )} +
+
+ + {/* Scrollable content */} +
+ {/* Name & description */} + {(isCreatingNew || !isBuiltIn) && ( +
+
+ + setName(e.target.value)} + placeholder="My preset..." + className="h-9" + /> +
+
+ +