mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -07:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ebf6222e | ||
|
|
509b0e71cc | ||
|
|
81f8be1a94 | ||
|
|
655a60ca81 | ||
|
|
52285362ce | ||
|
|
3ea587797f | ||
|
|
325714bb83 |
+2
-1
@@ -94,10 +94,11 @@ function App() {
|
|||||||
|
|
||||||
serverStartingRef.current = true;
|
serverStartingRef.current = true;
|
||||||
const isRemote = useServerStore.getState().mode === 'remote';
|
const isRemote = useServerStore.getState().mode === 'remote';
|
||||||
|
const customModelsDir = useServerStore.getState().customModelsDir;
|
||||||
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
|
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
|
||||||
|
|
||||||
platform.lifecycle
|
platform.lifecycle
|
||||||
.startServer(isRemote)
|
.startServer(isRemote, customModelsDir)
|
||||||
.then((serverUrl) => {
|
.then((serverUrl) => {
|
||||||
console.log('Server is ready at:', serverUrl);
|
console.log('Server is ready at:', serverUrl);
|
||||||
// Update the server URL in the store with the dynamically assigned port
|
// Update the server URL in the store with the dynamically assigned port
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Slider } from '@/components/ui/slider';
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
import { debug } from '@/lib/utils/debug';
|
import { debug } from '@/lib/utils/debug';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function AudioPlayer() {
|
export function AudioPlayer() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
@@ -833,11 +833,7 @@ export function AudioPlayer() {
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
||||||
aria-label={
|
aria-label={
|
||||||
duration === 0 && !isLoading
|
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
|
||||||
? 'Audio not loaded'
|
|
||||||
: isPlaying
|
|
||||||
? 'Pause'
|
|
||||||
: 'Play'
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||||
@@ -872,7 +868,9 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
{title && (
|
{title && (
|
||||||
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
|
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loop Button */}
|
{/* Loop Button */}
|
||||||
@@ -888,7 +886,11 @@ export function AudioPlayer() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Volume Control */}
|
{/* Volume Control */}
|
||||||
<div className="flex items-center gap-2 shrink-0 w-[120px]" role="group" aria-label="Volume">
|
<div
|
||||||
|
className="flex items-center gap-2 shrink-0 w-[120px]"
|
||||||
|
role="group"
|
||||||
|
aria-label="Volume"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -129,7 +129,7 @@ export function AudioTab() {
|
|||||||
if (await confirm('Delete this channel?')) {
|
if (await confirm('Delete this channel?')) {
|
||||||
deleteChannel.mutate(channelId);
|
deleteChannel.mutate(channelId);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const allChannels = channels || [];
|
const allChannels = channels || [];
|
||||||
const allDevices = devices || [];
|
const allDevices = devices || [];
|
||||||
@@ -168,7 +168,7 @@ export function AudioTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 p-2">
|
<div className="space-y-3">
|
||||||
{allChannels.map((channel) => {
|
{allChannels.map((channel) => {
|
||||||
const isSelected = selectedChannelId === channel.id;
|
const isSelected = selectedChannelId === channel.id;
|
||||||
return (
|
return (
|
||||||
@@ -343,7 +343,9 @@ export function AudioTab() {
|
|||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<p className="text-muted-foreground text-center">
|
<p className="text-muted-foreground text-center">
|
||||||
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
{platform.metadata.isTauri
|
||||||
|
? 'No audio devices found'
|
||||||
|
: 'Audio device selection requires Tauri'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
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';
|
||||||
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||||
@@ -44,8 +44,7 @@ export function FloatingGenerateBox({
|
|||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
const { data: currentStory } = useStory(selectedStoryId);
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
const addStoryItem = useAddStoryItem();
|
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Calculate if track editor is visible (on stories route with items)
|
// Calculate if track editor is visible (on stories route with items)
|
||||||
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||||
@@ -53,25 +52,9 @@ export function FloatingGenerateBox({
|
|||||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
onSuccess: async (generationId) => {
|
onSuccess: async (generationId) => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
// If on stories route and a story is selected, add generation to story
|
// Defer the story add until TTS completes — useGenerationProgress handles it
|
||||||
if (isStoriesRoute && selectedStoryId && generationId) {
|
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||||
try {
|
addPendingStoryAdd(generationId, selectedStoryId);
|
||||||
await addStoryItem.mutateAsync({
|
|
||||||
storyId: selectedStoryId,
|
|
||||||
data: { generation_id: generationId },
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: 'Added to story',
|
|
||||||
description: `Generation added to "${currentStory?.name || 'story'}"`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to add to story',
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : 'Could not add generation to story',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -182,7 +165,7 @@ export function FloatingGenerateBox({
|
|||||||
isStoriesRoute
|
isStoriesRoute
|
||||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||||
'left-[calc(5rem+2rem)] w-[360px]'
|
'left-[calc(5rem+2rem)] w-[360px]'
|
||||||
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
// On stories route: offset by track editor height when visible
|
// On stories route: offset by track editor height when visible
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
AudioWaveform,
|
|
||||||
Download,
|
Download,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
Play,
|
||||||
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Loader from 'react-loaders';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -36,7 +38,8 @@ import {
|
|||||||
useImportGeneration,
|
useImportGeneration,
|
||||||
} from '@/lib/hooks/useHistory';
|
} from '@/lib/hooks/useHistory';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate, formatDuration } from '@/lib/utils/format';
|
import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||||
@@ -54,9 +57,12 @@ export function HistoryTable() {
|
|||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
|
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: historyData,
|
data: historyData,
|
||||||
@@ -71,6 +77,7 @@ export function HistoryTable() {
|
|||||||
const exportGeneration = useExportGeneration();
|
const exportGeneration = useExportGeneration();
|
||||||
const exportGenerationAudio = useExportGenerationAudio();
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
const importGeneration = useImportGeneration();
|
const importGeneration = useImportGeneration();
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
@@ -194,6 +201,20 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (generationId: string) => {
|
||||||
|
try {
|
||||||
|
const result = await apiClient.retryGeneration(generationId);
|
||||||
|
addPendingGeneration(result.id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Retry failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Could not retry generation',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleImportConfirm = () => {
|
const handleImportConfirm = () => {
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
importGeneration.mutate(selectedFile, {
|
importGeneration.mutate(selectedFile, {
|
||||||
@@ -250,22 +271,30 @@ export function HistoryTable() {
|
|||||||
>
|
>
|
||||||
{history.map((gen) => {
|
{history.map((gen) => {
|
||||||
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||||
|
const isGenerating = gen.status === 'generating';
|
||||||
|
const isFailed = gen.status === 'failed';
|
||||||
|
const isPlayable = !isGenerating && !isFailed;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={gen.id}
|
key={gen.id}
|
||||||
role="button"
|
role={isPlayable ? 'button' : undefined}
|
||||||
tabIndex={0}
|
tabIndex={isPlayable ? 0 : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
|
'flex items-stretch gap-4 h-26 border rounded-md p-3 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={
|
aria-label={
|
||||||
isCurrentlyPlaying
|
isGenerating
|
||||||
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
|
? `Generating speech for ${gen.profile_name}...`
|
||||||
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Press Enter to play.`
|
: 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) => {
|
onMouseDown={(e) => {
|
||||||
// Don't trigger play if clicking on textarea or if text is selected
|
if (!isPlayable) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||||
return;
|
return;
|
||||||
@@ -273,6 +302,7 @@ export function HistoryTable() {
|
|||||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
if (!isPlayable) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('textarea') || target.closest('button')) return;
|
if (target.closest('textarea') || target.closest('button')) return;
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -281,9 +311,14 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Waveform icon */}
|
{/* Status icon */}
|
||||||
<div className="flex items-center shrink-0">
|
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
|
||||||
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
|
<div className="scale-50">
|
||||||
|
<Loader
|
||||||
|
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
|
||||||
|
active={isGenerating || isCurrentlyPlaying}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Left side - Meta information */}
|
{/* Left side - Meta information */}
|
||||||
@@ -294,11 +329,22 @@ export function HistoryTable() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatDuration(gen.duration)}
|
{formatEngineName(gen.engine, gen.model_size)}
|
||||||
</span>
|
</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>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatDate(gen.created_at)}
|
{isGenerating ? (
|
||||||
|
<span className="text-accent">Generating...</span>
|
||||||
|
) : (
|
||||||
|
formatDate(gen.created_at)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,16 +354,27 @@ export function HistoryTable() {
|
|||||||
value={gen.text}
|
value={gen.text}
|
||||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||||
readOnly
|
readOnly
|
||||||
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration)}`}
|
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Far right - Ellipsis actions */}
|
{/* Far right - Actions */}
|
||||||
<div
|
<div
|
||||||
className="w-10 shrink-0 flex justify-end"
|
className="w-10 shrink-0 flex justify-end items-center"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(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" />
|
||||||
|
</Button>
|
||||||
|
) : isPlayable ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -360,6 +417,7 @@ export function HistoryTable() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -387,7 +445,8 @@ export function HistoryTable() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Generation</DialogTitle>
|
<DialogTitle>Delete Generation</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
|
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
||||||
|
This action cannot be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
|
||||||
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
@@ -77,9 +77,9 @@ export function MainEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Main view: Profiles top left, Generator bottom left, History right
|
// Main view: Profiles top left, Generator bottom left, History right
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden relative">
|
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
|
||||||
{/* Scroll Mask - Always visible, behind content */}
|
{/* Scroll Mask - Always visible, behind content */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||||
|
|
||||||
@@ -110,10 +110,7 @@ export function MainEditor() {
|
|||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={cn(
|
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
|
||||||
'flex-1 min-h-0 overflow-y-auto pt-14',
|
|
||||||
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="shrink-0 flex flex-col">
|
<div className="shrink-0 flex flex-col">
|
||||||
@@ -123,6 +120,9 @@ export function MainEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider - single column only */}
|
||||||
|
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
|
||||||
|
|
||||||
{/* Right Column - History */}
|
{/* Right Column - History */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
<HistoryTable />
|
<HistoryTable />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
|||||||
|
|
||||||
export function ModelsTab() {
|
export function ModelsTab() {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col p-4">
|
<div className="h-full flex flex-col">
|
||||||
<ModelManagement />
|
<ModelManagement />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function ConnectionForm() {
|
|||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="keepServerRunning"
|
id="keepServerRunning"
|
||||||
|
className="mt-[6px]"
|
||||||
checked={keepServerRunningOnClose}
|
checked={keepServerRunningOnClose}
|
||||||
onCheckedChange={(checked: boolean) => {
|
onCheckedChange={(checked: boolean) => {
|
||||||
setKeepServerRunningOnClose(checked);
|
setKeepServerRunningOnClose(checked);
|
||||||
@@ -158,6 +159,7 @@ export function ConnectionForm() {
|
|||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="allowNetworkAccess"
|
id="allowNetworkAccess"
|
||||||
|
className="mt-[6px]"
|
||||||
checked={mode === 'remote'}
|
checked={mode === 'remote'}
|
||||||
onCheckedChange={(checked: boolean) => {
|
onCheckedChange={(checked: boolean) => {
|
||||||
setMode(checked ? 'remote' : 'local');
|
setMode(checked ? 'remote' : 'local');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
@@ -7,6 +8,10 @@ export function GenerationSettings() {
|
|||||||
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
|
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
|
||||||
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||||
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
|
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
|
||||||
|
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||||
|
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
|
||||||
|
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
|
||||||
|
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
|
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
|
||||||
@@ -32,7 +37,7 @@ export function GenerationSettings() {
|
|||||||
value={[maxChunkChars]}
|
value={[maxChunkChars]}
|
||||||
onValueChange={([value]) => setMaxChunkChars(value)}
|
onValueChange={([value]) => setMaxChunkChars(value)}
|
||||||
min={100}
|
min={100}
|
||||||
max={2000}
|
max={5000}
|
||||||
step={50}
|
step={50}
|
||||||
aria-label="Auto-chunking character limit"
|
aria-label="Auto-chunking character limit"
|
||||||
/>
|
/>
|
||||||
@@ -64,6 +69,46 @@ export function GenerationSettings() {
|
|||||||
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
|
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="normalizeAudio"
|
||||||
|
checked={normalizeAudio}
|
||||||
|
onCheckedChange={setNormalizeAudio}
|
||||||
|
className="mt-[6px]"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="normalizeAudio"
|
||||||
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
Normalize audio
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Adjusts output volume to a consistent level across generations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="autoplayOnGenerate"
|
||||||
|
checked={autoplayOnGenerate}
|
||||||
|
onCheckedChange={setAutoplayOnGenerate}
|
||||||
|
className="mt-[6px]"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="autoplayOnGenerate"
|
||||||
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
Autoplay on generate
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically play audio when a generation completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CircleX,
|
CircleX,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
FolderOpen,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Heart,
|
Heart,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -41,6 +42,8 @@ import { useToast } from '@/components/ui/use-toast';
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { ActiveDownloadTask, HuggingFaceModelInfo, ModelStatus } from '@/lib/api/types';
|
import type { ActiveDownloadTask, HuggingFaceModelInfo, ModelStatus } from '@/lib/api/types';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceModelInfo> {
|
async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceModelInfo> {
|
||||||
const response = await fetch(`https://huggingface.co/api/models/${repoId}`);
|
const response = await fetch(`https://huggingface.co/api/models/${repoId}`);
|
||||||
@@ -48,6 +51,29 @@ async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceMod
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MODEL_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
'qwen-tts-1.7B':
|
||||||
|
'High-quality multilingual TTS by Alibaba. Supports 10 languages with natural prosody and voice cloning from short reference audio.',
|
||||||
|
'qwen-tts-0.6B':
|
||||||
|
'Lightweight version of Qwen TTS. Same language support with faster inference, ideal for lower-end hardware.',
|
||||||
|
luxtts:
|
||||||
|
'Lightweight ZipVoice-based TTS designed for high quality voice cloning and 48kHz speech generation at speeds exceeding 150x realtime.',
|
||||||
|
'chatterbox-tts':
|
||||||
|
'Production-grade open source TTS by Resemble AI. Supports 23 languages with voice cloning and emotion exaggeration control.',
|
||||||
|
'chatterbox-turbo':
|
||||||
|
'Streamlined 350M parameter TTS by Resemble AI. High-quality English speech with less compute and VRAM than larger models.',
|
||||||
|
'whisper-base':
|
||||||
|
'Smallest Whisper model (74M parameters). Fast transcription with moderate accuracy.',
|
||||||
|
'whisper-small':
|
||||||
|
'Whisper Small (244M parameters). Good balance of speed and accuracy for transcription.',
|
||||||
|
'whisper-medium':
|
||||||
|
'Whisper Medium (769M parameters). Higher accuracy transcription at moderate speed.',
|
||||||
|
'whisper-large':
|
||||||
|
'Whisper Large (1.5B parameters). Best accuracy for speech-to-text across multiple languages.',
|
||||||
|
'whisper-turbo':
|
||||||
|
'Whisper Large v3 Turbo. Pruned for significantly faster inference while maintaining near-large accuracy.',
|
||||||
|
};
|
||||||
|
|
||||||
function formatDownloads(n: number): string {
|
function formatDownloads(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||||
@@ -85,6 +111,18 @@ function formatBytes(bytes: number): string {
|
|||||||
export function ModelManagement() {
|
export function ModelManagement() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const platform = usePlatform();
|
||||||
|
const customModelsDir = useServerStore((state) => state.customModelsDir);
|
||||||
|
const setCustomModelsDir = useServerStore((state) => state.setCustomModelsDir);
|
||||||
|
const [migrating, setMigrating] = useState(false);
|
||||||
|
const [migrationProgress, setMigrationProgress] = useState<{
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
progress: number;
|
||||||
|
filename?: string;
|
||||||
|
status: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [pendingMigrateDir, setPendingMigrateDir] = useState<string | null>(null);
|
||||||
const [downloadingModel, setDownloadingModel] = useState<string | null>(null);
|
const [downloadingModel, setDownloadingModel] = useState<string | null>(null);
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||||
@@ -104,6 +142,12 @@ export function ModelManagement() {
|
|||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: cacheDir } = useQuery({
|
||||||
|
queryKey: ['modelsCacheDir'],
|
||||||
|
queryFn: () => apiClient.getModelsCacheDir(),
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: activeTasks } = useQuery({
|
const { data: activeTasks } = useQuery({
|
||||||
queryKey: ['activeTasks'],
|
queryKey: ['activeTasks'],
|
||||||
queryFn: () => apiClient.getActiveTasks(),
|
queryFn: () => apiClient.getActiveTasks(),
|
||||||
@@ -382,6 +426,87 @@ export function ModelManagement() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Model storage location */}
|
||||||
|
{platform.metadata.isTauri && cacheDir && (
|
||||||
|
<div className="shrink-0 pb-4 border-b mb-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-xs text-muted-foreground">Storage location</span>
|
||||||
|
<p
|
||||||
|
className="text-xs font-mono text-muted-foreground/70 truncate"
|
||||||
|
title={cacheDir.path}
|
||||||
|
>
|
||||||
|
{cacheDir.path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-muted-foreground h-7 px-2"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const { open } = await import('@tauri-apps/plugin-shell');
|
||||||
|
await open(cacheDir.path);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to open model folder', variant: 'destructive' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3 w-3" />
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-muted-foreground h-7 px-2"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const { open: openDialog } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const selected = await openDialog({
|
||||||
|
directory: true,
|
||||||
|
title: 'Choose model storage folder',
|
||||||
|
});
|
||||||
|
if (!selected) return;
|
||||||
|
const newDir =
|
||||||
|
typeof selected === 'string' ? selected : (selected as { path: string }).path;
|
||||||
|
if (!newDir) return;
|
||||||
|
setPendingMigrateDir(newDir);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to open folder picker', variant: 'destructive' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={migrating}
|
||||||
|
>
|
||||||
|
{migrating ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FolderOpen className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{migrating ? 'Migrating...' : 'Change'}
|
||||||
|
</Button>
|
||||||
|
{customModelsDir && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-muted-foreground h-7 px-2"
|
||||||
|
disabled={migrating}
|
||||||
|
onClick={async () => {
|
||||||
|
setCustomModelsDir(null);
|
||||||
|
toast({ title: 'Reset to default location. Restarting server...' });
|
||||||
|
await platform.lifecycle.restartServer('');
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Model list */}
|
{/* Model list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@@ -457,9 +582,7 @@ export function ModelManagement() {
|
|||||||
{formatSize(model.size_mb)}
|
{formatSize(model.size_mb)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!model.downloaded && !isDownloading && !hasError && (
|
|
||||||
<span className="text-xs text-muted-foreground/60">Not downloaded</span>
|
|
||||||
)}
|
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
<ChevronRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -571,13 +694,6 @@ export function ModelManagement() {
|
|||||||
Error
|
Error
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!freshSelectedModel.downloaded &&
|
|
||||||
!selectedState?.isDownloading &&
|
|
||||||
!selectedState?.hasError && (
|
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
||||||
Not downloaded
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HuggingFace model card info */}
|
{/* HuggingFace model card info */}
|
||||||
@@ -588,6 +704,13 @@ export function ModelManagement() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{MODEL_DESCRIPTIONS[freshSelectedModel.model_name] && (
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{MODEL_DESCRIPTIONS[freshSelectedModel.model_name]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{hfModelInfo && (
|
{hfModelInfo && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Pipeline tag + author */}
|
{/* Pipeline tag + author */}
|
||||||
@@ -810,6 +933,126 @@ export function ModelManagement() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Migration confirmation dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!pendingMigrateDir}
|
||||||
|
onOpenChange={(open) => !open && setPendingMigrateDir(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Move models to new location?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
The server will shut down while models are being moved to the new folder. It will
|
||||||
|
restart automatically once the migration is complete.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div
|
||||||
|
className="text-xs font-mono text-muted-foreground bg-muted/50 rounded px-3 py-2 truncate"
|
||||||
|
title={pendingMigrateDir ?? ''}
|
||||||
|
>
|
||||||
|
{pendingMigrateDir}
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
if (!pendingMigrateDir) return;
|
||||||
|
const newDir = pendingMigrateDir;
|
||||||
|
setPendingMigrateDir(null);
|
||||||
|
setMigrating(true);
|
||||||
|
setMigrationProgress({
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
progress: 0,
|
||||||
|
status: 'downloading',
|
||||||
|
filename: 'Preparing...',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Start the migration (background task)
|
||||||
|
await apiClient.migrateModels(newDir);
|
||||||
|
|
||||||
|
// Connect to SSE for progress
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const es = new EventSource(apiClient.getMigrationProgressUrl());
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
setMigrationProgress(data);
|
||||||
|
if (data.status === 'complete') {
|
||||||
|
es.close();
|
||||||
|
resolve();
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
es.close();
|
||||||
|
reject(new Error(data.error || 'Migration failed'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore parse errors */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close();
|
||||||
|
reject(new Error('Lost connection during migration'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setCustomModelsDir(newDir);
|
||||||
|
setMigrationProgress({
|
||||||
|
current: 1,
|
||||||
|
total: 1,
|
||||||
|
progress: 100,
|
||||||
|
status: 'complete',
|
||||||
|
filename: 'Restarting server...',
|
||||||
|
});
|
||||||
|
await platform.lifecycle.restartServer(newDir);
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
toast({ title: 'Models moved successfully' });
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Migration failed',
|
||||||
|
description: e instanceof Error ? e.message : 'Failed to migrate models',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setMigrating(false);
|
||||||
|
setMigrationProgress(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Move Models
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Migration progress overlay */}
|
||||||
|
{migrating && migrationProgress && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex items-center justify-center">
|
||||||
|
<div className="w-full max-w-md px-8 space-y-6 text-center">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||||
|
<h2 className="text-lg font-semibold">Moving models</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{migrationProgress.status === 'complete'
|
||||||
|
? 'Restarting server...'
|
||||||
|
: 'The server is offline while models are being moved.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{migrationProgress.total > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress value={migrationProgress.progress} className="h-2" />
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span className="truncate max-w-[60%]">{migrationProgress.filename}</span>
|
||||||
|
<span>
|
||||||
|
{formatBytes(migrationProgress.current)} /{' '}
|
||||||
|
{formatBytes(migrationProgress.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
|||||||
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
|
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
|
||||||
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
|
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
|
||||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function ServerTab() {
|
export function ServerTab() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||||
return (
|
return (
|
||||||
<div className="overflow-y-auto flex flex-col">
|
<div
|
||||||
|
className={cn('overflow-y-auto flex flex-col', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}
|
||||||
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<ConnectionForm />
|
<ConnectionForm />
|
||||||
<GenerationSettings />
|
<GenerationSettings />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||||
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
import { BookOpen, Box, Mic, Server, Speaker, Volume2 } 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 { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { version } from '../../package.json';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isMacOS?: boolean;
|
isMacOS?: boolean;
|
||||||
@@ -19,10 +19,8 @@ const tabs = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||||
const isGenerating = useGenerationStore((state) => state.isGenerating);
|
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
|
||||||
const isPlayerVisible = !!audioUrl;
|
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
|
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -42,9 +40,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
// For index route, use exact match; for others, use default matching
|
// For index route, use exact match; for others, use default matching
|
||||||
const isActive =
|
const isActive =
|
||||||
tab.path === '/'
|
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
|
||||||
? matchRoute({ to: '/', exact: true })
|
|
||||||
: matchRoute({ to: tab.path });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -64,20 +60,13 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spacer to push loader to bottom */}
|
{/* Version */}
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{/* Generation Loader */}
|
|
||||||
{isGenerating && (
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="mt-auto text-[10px] text-muted-foreground/50 transition-all duration-300"
|
||||||
'w-full flex items-center justify-center transition-all duration-200',
|
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
|
||||||
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Loader2 className="h-6 w-6 text-accent animate-spin" />
|
v{version}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Download, Plus } from 'lucide-react';
|
import { Download, Plus } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Loader from 'react-loaders';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
useStory,
|
useStory,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { SortableStoryChatItem } from './StoryChatItem';
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ export function StoryContent() {
|
|||||||
const addStoryItem = useAddStoryItem();
|
const addStoryItem = useAddStoryItem();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||||
|
|
||||||
// Add generation popover state
|
// Add generation popover state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -53,9 +58,9 @@ export function StoryContent() {
|
|||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return historyData.items.filter(
|
return historyData.items.filter(
|
||||||
(gen) =>
|
(gen) =>
|
||||||
|
gen.status === 'completed' &&
|
||||||
!storyGenerationIds.has(gen.id) &&
|
!storyGenerationIds.has(gen.id) &&
|
||||||
(gen.text.toLowerCase().includes(query) ||
|
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
|
||||||
gen.profile_name.toLowerCase().includes(query)),
|
|
||||||
);
|
);
|
||||||
}, [historyData, story, searchQuery]);
|
}, [historyData, story, searchQuery]);
|
||||||
|
|
||||||
@@ -267,7 +272,31 @@ export function StoryContent() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
<AnimatePresence>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, width: 'auto' }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
|
||||||
|
<div className="scale-[0.45]">
|
||||||
|
<Loader type="line-scale" active />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -287,9 +316,7 @@ export function StoryContent() {
|
|||||||
<div className="max-h-60 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto">
|
||||||
{availableGenerations.length === 0 ? (
|
{availableGenerations.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
{searchQuery
|
{searchQuery ? 'No matching generations found' : 'No available generations'}
|
||||||
? 'No matching generations found'
|
|
||||||
: 'No available generations'}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
availableGenerations.map((gen) => (
|
availableGenerations.map((gen) => (
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer hover:shadow-md transition-all flex flex-col',
|
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
|
||||||
isSelected && 'ring-2 ring-primary shadow-md',
|
isSelected && 'ring-2 ring-primary shadow-md',
|
||||||
)}
|
)}
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ export function ProfileList() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
|
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
|
||||||
{allProfiles.map((profile) => (
|
{allProfiles.map((profile) => (
|
||||||
<ProfileCard key={profile.id} profile={profile} />
|
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
|
||||||
|
<ProfileCard profile={profile} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss" source(".");
|
@import "tailwindcss" source(".");
|
||||||
|
@import "loaders.css/loaders.min.css";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
@@ -155,3 +156,18 @@
|
|||||||
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* react-loaders */
|
||||||
|
.line-scale-pulse-out-rapid > div,
|
||||||
|
.line-scale > div {
|
||||||
|
background-color: hsl(var(--accent)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-hidden {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-hidden > div > div {
|
||||||
|
animation-play-state: paused !important;
|
||||||
|
background-color: hsl(var(--muted-foreground)) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async retryGeneration(generationId: string): Promise<GenerationResponse> {
|
||||||
|
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// History
|
// History
|
||||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -278,6 +284,11 @@ class ApiClient {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generation status SSE
|
||||||
|
getGenerationStatusUrl(generationId: string): string {
|
||||||
|
return `${this.getBaseUrl()}/generate/${generationId}/status`;
|
||||||
|
}
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
getAudioUrl(audioId: string): string {
|
getAudioUrl(audioId: string): string {
|
||||||
return `${this.getBaseUrl()}/audio/${audioId}`;
|
return `${this.getBaseUrl()}/audio/${audioId}`;
|
||||||
@@ -316,6 +327,21 @@ class ApiClient {
|
|||||||
return this.request<ModelStatusListResponse>('/models/status');
|
return this.request<ModelStatusListResponse>('/models/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getModelsCacheDir(): Promise<{ path: string }> {
|
||||||
|
return this.request<{ path: string }>('/models/cache-dir');
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
|
||||||
|
return this.request('/models/migrate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ destination }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMigrationProgressUrl(): string {
|
||||||
|
return `${this.getBaseUrl()}/models/migrate/progress`;
|
||||||
|
}
|
||||||
|
|
||||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
||||||
console.log(
|
console.log(
|
||||||
'[API] triggerModelDownload called for:',
|
'[API] triggerModelDownload called for:',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface GenerationRequest {
|
|||||||
instruct?: string;
|
instruct?: string;
|
||||||
max_chunk_chars?: number;
|
max_chunk_chars?: number;
|
||||||
crossfade_ms?: number;
|
crossfade_ms?: number;
|
||||||
|
normalize?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationResponse {
|
export interface GenerationResponse {
|
||||||
@@ -45,9 +46,14 @@ export interface GenerationResponse {
|
|||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: string;
|
language: string;
|
||||||
audio_path: string;
|
audio_path?: string;
|
||||||
duration: number;
|
duration?: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
instruct?: string;
|
||||||
|
engine?: string;
|
||||||
|
model_size?: string;
|
||||||
|
status: 'generating' | 'completed' | 'failed';
|
||||||
|
error?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
const generationSchema = z.object({
|
const generationSchema = z.object({
|
||||||
@@ -30,10 +29,10 @@ interface UseGenerationFormOptions {
|
|||||||
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const generation = useGeneration();
|
const generation = useGeneration();
|
||||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
|
||||||
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||||
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||||
|
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -70,8 +69,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsGenerating(true);
|
|
||||||
|
|
||||||
const engine = data.engine || 'qwen';
|
const engine = data.engine || 'qwen';
|
||||||
const modelName =
|
const modelName =
|
||||||
engine === 'luxtts'
|
engine === 'luxtts'
|
||||||
@@ -92,6 +89,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
? 'Qwen TTS 1.7B'
|
? 'Qwen TTS 1.7B'
|
||||||
: 'Qwen TTS 0.6B';
|
: 'Qwen TTS 0.6B';
|
||||||
|
|
||||||
|
// Check if model needs downloading
|
||||||
try {
|
try {
|
||||||
const modelStatus = await apiClient.getModelStatus();
|
const modelStatus = await apiClient.getModelStatus();
|
||||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||||
@@ -105,6 +103,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isQwen = engine === 'qwen';
|
const isQwen = engine === 'qwen';
|
||||||
|
// This now returns immediately with status="generating"
|
||||||
const result = await generation.mutateAsync({
|
const result = await generation.mutateAsync({
|
||||||
profile_id: selectedProfileId,
|
profile_id: selectedProfileId,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
@@ -115,16 +114,13 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
instruct: isQwen ? data.instruct || undefined : undefined,
|
instruct: isQwen ? data.instruct || undefined : undefined,
|
||||||
max_chunk_chars: maxChunkChars,
|
max_chunk_chars: maxChunkChars,
|
||||||
crossfade_ms: crossfadeMs,
|
crossfade_ms: crossfadeMs,
|
||||||
|
normalize: normalizeAudio,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
// Track this generation for SSE status updates
|
||||||
title: 'Generation complete!',
|
addPendingGeneration(result.id);
|
||||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
|
||||||
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
|
||||||
|
|
||||||
|
// Reset form immediately — user can start typing again
|
||||||
form.reset({
|
form.reset({
|
||||||
text: '',
|
text: '',
|
||||||
language: data.language,
|
language: data.language,
|
||||||
@@ -141,7 +137,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
|
||||||
setDownloadingModelName(null);
|
setDownloadingModelName(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
interface GenerationStatusEvent {
|
||||||
|
id: string;
|
||||||
|
status: 'generating' | 'completed' | 'failed' | 'not_found';
|
||||||
|
duration?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to SSE for all pending generations. When a generation completes,
|
||||||
|
* invalidates the history query, removes it from pending, and auto-plays
|
||||||
|
* if the player is idle.
|
||||||
|
*/
|
||||||
|
export function useGenerationProgress() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
|
||||||
|
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
|
||||||
|
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
|
||||||
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
|
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||||
|
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
|
||||||
|
|
||||||
|
// Keep refs to avoid stale closures in EventSource handlers
|
||||||
|
const isPlayingRef = useRef(isPlaying);
|
||||||
|
const autoplayRef = useRef(autoplayOnGenerate);
|
||||||
|
isPlayingRef.current = isPlaying;
|
||||||
|
autoplayRef.current = autoplayOnGenerate;
|
||||||
|
|
||||||
|
// Track active EventSource instances
|
||||||
|
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
|
||||||
|
|
||||||
|
// Unmount-only cleanup — close all SSE connections when the hook is torn down
|
||||||
|
useEffect(() => {
|
||||||
|
const sources = eventSourcesRef.current;
|
||||||
|
return () => {
|
||||||
|
for (const source of sources.values()) {
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
sources.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentSources = eventSourcesRef.current;
|
||||||
|
|
||||||
|
// Close SSE connections for IDs no longer pending
|
||||||
|
for (const [id, source] of currentSources.entries()) {
|
||||||
|
if (!pendingIds.has(id)) {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open SSE connections for new pending IDs
|
||||||
|
for (const id of pendingIds) {
|
||||||
|
if (currentSources.has(id)) continue;
|
||||||
|
|
||||||
|
const url = apiClient.getGenerationStatusUrl(id);
|
||||||
|
const source = new EventSource(url);
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data: GenerationStatusEvent = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
|
||||||
|
// Refresh history to pick up the completed generation
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
// If this generation was queued for a story, add it now
|
||||||
|
const storyId = removePendingStoryAdd(id);
|
||||||
|
if (storyId) {
|
||||||
|
apiClient
|
||||||
|
.addStoryItem(storyId, { generation_id: id })
|
||||||
|
.then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
|
||||||
|
toast({
|
||||||
|
title: 'Added to story',
|
||||||
|
description: data.duration
|
||||||
|
? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
|
||||||
|
: 'Audio generated and added to story',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast({
|
||||||
|
title: 'Generation complete',
|
||||||
|
description: 'Audio generated but failed to add to story',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// toast({
|
||||||
|
// title: 'Generation complete!',
|
||||||
|
// description: data.duration
|
||||||
|
// ? `Audio generated (${data.duration.toFixed(2)}s)`
|
||||||
|
// : 'Audio generated',
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play if enabled and nothing is currently playing
|
||||||
|
if (autoplayRef.current && !isPlayingRef.current) {
|
||||||
|
const genAudioUrl = apiClient.getAudioUrl(id);
|
||||||
|
setAudioWithAutoPlay(genAudioUrl, id, '', '');
|
||||||
|
}
|
||||||
|
} else if (data.status === 'failed' || data.status === 'not_found') {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
removePendingStoryAdd(id);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
|
||||||
|
description: data.error || 'An error occurred during generation',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors from heartbeats etc
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
// EventSource auto-reconnects, but if we get repeated errors
|
||||||
|
// just clean up
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentSources.set(id, source);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
pendingIds,
|
||||||
|
removePendingGeneration,
|
||||||
|
removePendingStoryAdd,
|
||||||
|
queryClient,
|
||||||
|
toast,
|
||||||
|
setAudioWithAutoPlay,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import type { ActiveDownloadTask } from '@/lib/api/types';
|
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
|
||||||
// Polling interval in milliseconds
|
// Polling interval in milliseconds
|
||||||
const POLL_INTERVAL = 2000;
|
const POLL_INTERVAL = 30000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to monitor active tasks (downloads and generations).
|
* Hook to monitor active tasks (downloads and generations).
|
||||||
@@ -15,8 +15,8 @@ const POLL_INTERVAL = 2000;
|
|||||||
*/
|
*/
|
||||||
export function useRestoreActiveTasks() {
|
export function useRestoreActiveTasks() {
|
||||||
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
|
||||||
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
|
|
||||||
// Track which downloads we've seen to detect new ones
|
// Track which downloads we've seen to detect new ones
|
||||||
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
||||||
@@ -25,15 +25,15 @@ export function useRestoreActiveTasks() {
|
|||||||
try {
|
try {
|
||||||
const tasks = await apiClient.getActiveTasks();
|
const tasks = await apiClient.getActiveTasks();
|
||||||
|
|
||||||
// Update generation state
|
// Restore pending generations (e.g., after page refresh)
|
||||||
if (tasks.generations.length > 0) {
|
if (tasks.generations.length > 0) {
|
||||||
setIsGenerating(true);
|
|
||||||
setActiveGenerationId(tasks.generations[0].task_id);
|
setActiveGenerationId(tasks.generations[0].task_id);
|
||||||
|
for (const gen of tasks.generations) {
|
||||||
|
addPendingGeneration(gen.task_id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only clear if we were tracking a generation
|
|
||||||
const currentId = useGenerationStore.getState().activeGenerationId;
|
const currentId = useGenerationStore.getState().activeGenerationId;
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
setIsGenerating(false);
|
|
||||||
setActiveGenerationId(null);
|
setActiveGenerationId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export function useRestoreActiveTasks() {
|
|||||||
// Silently fail - server might be temporarily unavailable
|
// Silently fail - server might be temporarily unavailable
|
||||||
console.debug('Failed to fetch active tasks:', error);
|
console.debug('Failed to fetch active tasks:', error);
|
||||||
}
|
}
|
||||||
}, [setIsGenerating, setActiveGenerationId]);
|
}, [setActiveGenerationId, addPendingGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch immediately on mount
|
// Fetch immediately on mount
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ export function formatDate(date: string | Date): string {
|
|||||||
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
qwen: 'Qwen',
|
||||||
|
luxtts: 'LuxTTS',
|
||||||
|
chatterbox: 'Chatterbox',
|
||||||
|
chatterbox_turbo: 'Chatterbox Turbo',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatEngineName(engine?: string, modelSize?: string): string {
|
||||||
|
const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen';
|
||||||
|
if (engine === 'qwen' && modelSize) {
|
||||||
|
return `${name} ${modelSize}`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ export interface PlatformAudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformLifecycle {
|
export interface PlatformLifecycle {
|
||||||
startServer(remote?: boolean): Promise<string>;
|
startServer(remote?: boolean, modelsDir?: string | null): Promise<string>;
|
||||||
stopServer(): Promise<void>;
|
stopServer(): Promise<void>;
|
||||||
restartServer(): Promise<string>;
|
restartServer(modelsDir?: string | null): Promise<string>;
|
||||||
setKeepServerRunning(keep: boolean): Promise<void>;
|
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||||
setupWindowCloseHandler(): Promise<void>;
|
setupWindowCloseHandler(): Promise<void>;
|
||||||
onServerReady?: () => void;
|
onServerReady?: () => void;
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { Sidebar } from '@/components/Sidebar';
|
|||||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||||
|
import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||||
|
|
||||||
// Simple platform check that works in both web and Tauri
|
// Simple platform check that works in both web and Tauri
|
||||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ function RootLayout() {
|
|||||||
// Monitor active downloads/generations and show toasts for them
|
// Monitor active downloads/generations and show toasts for them
|
||||||
const activeDownloads = useRestoreActiveTasks();
|
const activeDownloads = useRestoreActiveTasks();
|
||||||
|
|
||||||
|
// Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
|
||||||
|
useGenerationProgress();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppFrame>
|
<AppFrame>
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
|||||||
@@ -1,15 +1,58 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface GenerationState {
|
interface GenerationState {
|
||||||
|
/** IDs of generations currently in progress */
|
||||||
|
pendingGenerationIds: Set<string>;
|
||||||
|
/** Whether any generation is in progress (derived from pendingGenerationIds) */
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
activeGenerationId: string | null;
|
/** Map of generationId → storyId for deferred story additions */
|
||||||
setIsGenerating: (generating: boolean) => void;
|
pendingStoryAdds: Map<string, string>;
|
||||||
|
addPendingGeneration: (id: string) => void;
|
||||||
|
removePendingGeneration: (id: string) => void;
|
||||||
|
addPendingStoryAdd: (generationId: string, storyId: string) => void;
|
||||||
|
removePendingStoryAdd: (generationId: string) => string | undefined;
|
||||||
setActiveGenerationId: (id: string | null) => void;
|
setActiveGenerationId: (id: string | null) => void;
|
||||||
|
activeGenerationId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGenerationStore = create<GenerationState>((set) => ({
|
export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||||
|
pendingGenerationIds: new Set(),
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
activeGenerationId: null,
|
activeGenerationId: null,
|
||||||
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
pendingStoryAdds: new Map(),
|
||||||
|
|
||||||
|
addPendingGeneration: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.pendingGenerationIds);
|
||||||
|
next.add(id);
|
||||||
|
return { pendingGenerationIds: next, isGenerating: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePendingGeneration: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.pendingGenerationIds);
|
||||||
|
next.delete(id);
|
||||||
|
return { pendingGenerationIds: next, isGenerating: next.size > 0 };
|
||||||
|
}),
|
||||||
|
|
||||||
|
addPendingStoryAdd: (generationId, storyId) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.pendingStoryAdds);
|
||||||
|
next.set(generationId, storyId);
|
||||||
|
return { pendingStoryAdds: next };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePendingStoryAdd: (generationId) => {
|
||||||
|
const storyId = get().pendingStoryAdds.get(generationId);
|
||||||
|
if (storyId) {
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.pendingStoryAdds);
|
||||||
|
next.delete(generationId);
|
||||||
|
return { pendingStoryAdds: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return storyId;
|
||||||
|
},
|
||||||
|
|
||||||
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
|
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ interface ServerStore {
|
|||||||
|
|
||||||
crossfadeMs: number;
|
crossfadeMs: number;
|
||||||
setCrossfadeMs: (value: number) => void;
|
setCrossfadeMs: (value: number) => void;
|
||||||
|
|
||||||
|
normalizeAudio: boolean;
|
||||||
|
setNormalizeAudio: (value: boolean) => void;
|
||||||
|
|
||||||
|
autoplayOnGenerate: boolean;
|
||||||
|
setAutoplayOnGenerate: (value: boolean) => void;
|
||||||
|
|
||||||
|
customModelsDir: string | null;
|
||||||
|
setCustomModelsDir: (dir: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServerStore = create<ServerStore>()(
|
export const useServerStore = create<ServerStore>()(
|
||||||
@@ -41,6 +50,15 @@ export const useServerStore = create<ServerStore>()(
|
|||||||
|
|
||||||
crossfadeMs: 50,
|
crossfadeMs: 50,
|
||||||
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
|
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
|
||||||
|
|
||||||
|
normalizeAudio: true,
|
||||||
|
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
|
||||||
|
|
||||||
|
autoplayOnGenerate: true,
|
||||||
|
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
|
||||||
|
|
||||||
|
customModelsDir: null,
|
||||||
|
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'voicebox-server',
|
name: 'voicebox-server',
|
||||||
|
|||||||
+36
-2
@@ -45,10 +45,14 @@ class Generation(Base):
|
|||||||
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
||||||
text = Column(Text, nullable=False)
|
text = Column(Text, nullable=False)
|
||||||
language = Column(String, default="en")
|
language = Column(String, default="en")
|
||||||
audio_path = Column(String, nullable=False)
|
audio_path = Column(String, nullable=True)
|
||||||
duration = Column(Float, nullable=False)
|
duration = Column(Float, nullable=True)
|
||||||
seed = Column(Integer)
|
seed = Column(Integer)
|
||||||
instruct = Column(Text)
|
instruct = Column(Text)
|
||||||
|
engine = Column(String, default="qwen")
|
||||||
|
model_size = Column(String, nullable=True)
|
||||||
|
status = Column(String, default="completed") # generating, completed, failed
|
||||||
|
error = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
@@ -288,6 +292,36 @@ def _run_migrations(engine):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
print("Added avatar_path column to profiles")
|
print("Added avatar_path column to profiles")
|
||||||
|
|
||||||
|
# Migration: Add status and error columns to generations table
|
||||||
|
if 'generations' in inspector.get_table_names():
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('generations')}
|
||||||
|
if 'status' not in columns:
|
||||||
|
print("Migrating generations: adding status column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN status VARCHAR DEFAULT 'completed'"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added status column to generations")
|
||||||
|
if 'error' not in columns:
|
||||||
|
print("Migrating generations: adding error column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN error TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added error column to generations")
|
||||||
|
if 'engine' not in columns:
|
||||||
|
print("Migrating generations: adding engine column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN engine VARCHAR DEFAULT 'qwen'"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added engine column to generations")
|
||||||
|
# Re-read columns after engine migration (variable name shadows outer `engine`)
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('generations')}
|
||||||
|
if 'model_size' not in columns:
|
||||||
|
print("Migrating generations: adding model_size column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE generations ADD COLUMN model_size VARCHAR"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added model_size column to generations")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Get database session (generator for dependency injection)."""
|
"""Get database session (generator for dependency injection)."""
|
||||||
|
|||||||
+42
-1
@@ -29,6 +29,10 @@ async def create_generation(
|
|||||||
seed: Optional[int],
|
seed: Optional[int],
|
||||||
db: Session,
|
db: Session,
|
||||||
instruct: Optional[str] = None,
|
instruct: Optional[str] = None,
|
||||||
|
generation_id: Optional[str] = None,
|
||||||
|
status: str = "completed",
|
||||||
|
engine: Optional[str] = "qwen",
|
||||||
|
model_size: Optional[str] = None,
|
||||||
) -> GenerationResponse:
|
) -> GenerationResponse:
|
||||||
"""
|
"""
|
||||||
Create a new generation history entry.
|
Create a new generation history entry.
|
||||||
@@ -42,12 +46,16 @@ async def create_generation(
|
|||||||
seed: Random seed used (if any)
|
seed: Random seed used (if any)
|
||||||
db: Database session
|
db: Database session
|
||||||
instruct: Natural language instruction used (if any)
|
instruct: Natural language instruction used (if any)
|
||||||
|
generation_id: Pre-assigned ID (for async generation flow)
|
||||||
|
status: Generation status (generating, completed, failed)
|
||||||
|
engine: TTS engine used (qwen, luxtts, chatterbox, chatterbox_turbo)
|
||||||
|
model_size: Model size variant (1.7B, 0.6B) — only relevant for qwen
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created generation entry
|
Created generation entry
|
||||||
"""
|
"""
|
||||||
db_generation = DBGeneration(
|
db_generation = DBGeneration(
|
||||||
id=str(uuid.uuid4()),
|
id=generation_id or str(uuid.uuid4()),
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
text=text,
|
text=text,
|
||||||
language=language,
|
language=language,
|
||||||
@@ -55,6 +63,9 @@ async def create_generation(
|
|||||||
duration=duration,
|
duration=duration,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
instruct=instruct,
|
instruct=instruct,
|
||||||
|
engine=engine,
|
||||||
|
model_size=model_size,
|
||||||
|
status=status,
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,6 +76,32 @@ async def create_generation(
|
|||||||
return GenerationResponse.model_validate(db_generation)
|
return GenerationResponse.model_validate(db_generation)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_generation_status(
|
||||||
|
generation_id: str,
|
||||||
|
status: str,
|
||||||
|
db: Session,
|
||||||
|
audio_path: Optional[str] = None,
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
) -> Optional[GenerationResponse]:
|
||||||
|
"""Update the status of a generation (used by async generation flow)."""
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
generation.status = status
|
||||||
|
if audio_path is not None:
|
||||||
|
generation.audio_path = audio_path
|
||||||
|
if duration is not None:
|
||||||
|
generation.duration = duration
|
||||||
|
if error is not None:
|
||||||
|
generation.error = error
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(generation)
|
||||||
|
return GenerationResponse.model_validate(generation)
|
||||||
|
|
||||||
|
|
||||||
async def get_generation(
|
async def get_generation(
|
||||||
generation_id: str,
|
generation_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -143,6 +180,10 @@ async def list_generations(
|
|||||||
duration=generation.duration,
|
duration=generation.duration,
|
||||||
seed=generation.seed,
|
seed=generation.seed,
|
||||||
instruct=generation.instruct,
|
instruct=generation.instruct,
|
||||||
|
engine=generation.engine or "qwen",
|
||||||
|
model_size=generation.model_size,
|
||||||
|
status=generation.status or "completed",
|
||||||
|
error=generation.error,
|
||||||
created_at=generation.created_at,
|
created_at=generation.created_at,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
+375
-150
@@ -62,6 +62,9 @@ from .platform_detect import get_backend_type
|
|||||||
# Keep references to fire-and-forget background tasks to prevent GC
|
# Keep references to fire-and-forget background tasks to prevent GC
|
||||||
_background_tasks: set = set()
|
_background_tasks: set = set()
|
||||||
|
|
||||||
|
# Generation queue — serializes TTS inference to avoid GPU contention
|
||||||
|
_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
|
||||||
|
|
||||||
|
|
||||||
def _create_background_task(coro) -> asyncio.Task:
|
def _create_background_task(coro) -> asyncio.Task:
|
||||||
"""Create a background task and prevent it from being garbage collected."""
|
"""Create a background task and prevent it from being garbage collected."""
|
||||||
@@ -71,6 +74,24 @@ def _create_background_task(coro) -> asyncio.Task:
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _generation_worker():
|
||||||
|
"""Worker that processes generation tasks one at a time."""
|
||||||
|
while True:
|
||||||
|
coro = await _generation_queue.get()
|
||||||
|
try:
|
||||||
|
await coro
|
||||||
|
except Exception:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
_generation_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
def _enqueue_generation(coro):
|
||||||
|
"""Add a generation coroutine to the serial queue."""
|
||||||
|
_generation_queue.put_nowait(coro)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="voicebox API",
|
title="voicebox API",
|
||||||
description="Production-quality Qwen3-TTS voice cloning API",
|
description="Production-quality Qwen3-TTS voice cloning API",
|
||||||
@@ -695,138 +716,67 @@ async def generate_speech(
|
|||||||
data: models.GenerationRequest,
|
data: models.GenerationRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Generate speech from text using a voice profile."""
|
"""Generate speech from text using a voice profile.
|
||||||
|
|
||||||
|
Creates a history entry immediately with status='generating' and kicks off
|
||||||
|
TTS in the background. The frontend can poll or use SSE to detect completion.
|
||||||
|
"""
|
||||||
task_manager = get_task_manager()
|
task_manager = get_task_manager()
|
||||||
generation_id = str(uuid.uuid4())
|
generation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
try:
|
# Validate profile exists before creating the record
|
||||||
# Start tracking generation
|
profile = await profiles.get_profile(data.profile_id, db)
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(status_code=404, detail="Profile not found")
|
||||||
|
|
||||||
|
from .backends import get_tts_backend_for_engine
|
||||||
|
engine = data.engine or "qwen"
|
||||||
|
tts_model = get_tts_backend_for_engine(engine)
|
||||||
|
model_size = data.model_size or "1.7B"
|
||||||
|
|
||||||
|
# Create the history entry immediately with status="generating"
|
||||||
|
generation = await history.create_generation(
|
||||||
|
profile_id=data.profile_id,
|
||||||
|
text=data.text,
|
||||||
|
language=data.language,
|
||||||
|
audio_path="",
|
||||||
|
duration=0,
|
||||||
|
seed=data.seed,
|
||||||
|
db=db,
|
||||||
|
instruct=data.instruct,
|
||||||
|
generation_id=generation_id,
|
||||||
|
status="generating",
|
||||||
|
engine=engine,
|
||||||
|
model_size=model_size if engine == "qwen" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track in task manager
|
||||||
task_manager.start_generation(
|
task_manager.start_generation(
|
||||||
task_id=generation_id,
|
task_id=generation_id,
|
||||||
profile_id=data.profile_id,
|
profile_id=data.profile_id,
|
||||||
text=data.text,
|
text=data.text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get profile
|
# Kick off TTS in background
|
||||||
profile = await profiles.get_profile(data.profile_id, db)
|
async def _run_generation():
|
||||||
if not profile:
|
bg_db = next(get_db())
|
||||||
raise HTTPException(status_code=404, detail="Profile not found")
|
try:
|
||||||
|
# Load model
|
||||||
# Generate audio
|
|
||||||
from .backends import get_tts_backend_for_engine
|
|
||||||
|
|
||||||
engine = data.engine or "qwen"
|
|
||||||
tts_model = get_tts_backend_for_engine(engine)
|
|
||||||
|
|
||||||
# Resolve model size (only relevant for Qwen engine)
|
|
||||||
model_size = data.model_size or "1.7B"
|
|
||||||
|
|
||||||
# Check if model needs to be downloaded first
|
|
||||||
if engine == "qwen":
|
if engine == "qwen":
|
||||||
if not tts_model._is_model_cached(model_size):
|
|
||||||
model_name = f"qwen-tts-{model_size}"
|
|
||||||
|
|
||||||
async def download_model_background():
|
|
||||||
try:
|
|
||||||
await tts_model.load_model_async(model_size)
|
await tts_model.load_model_async(model_size)
|
||||||
except Exception as e:
|
else:
|
||||||
task_manager.error_download(model_name, str(e))
|
|
||||||
|
|
||||||
task_manager.start_download(model_name)
|
|
||||||
_create_background_task(download_model_background())
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=202,
|
|
||||||
detail={
|
|
||||||
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
|
|
||||||
"model_name": model_name,
|
|
||||||
"downloading": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load (or switch to) the requested model
|
|
||||||
await tts_model.load_model_async(model_size)
|
|
||||||
elif engine == "luxtts":
|
|
||||||
if not tts_model._is_model_cached():
|
|
||||||
model_name = "luxtts"
|
|
||||||
|
|
||||||
async def download_luxtts_background():
|
|
||||||
try:
|
|
||||||
await tts_model.load_model()
|
|
||||||
except Exception as e:
|
|
||||||
task_manager.error_download(model_name, str(e))
|
|
||||||
|
|
||||||
task_manager.start_download(model_name)
|
|
||||||
_create_background_task(download_luxtts_background())
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=202,
|
|
||||||
detail={
|
|
||||||
"message": "LuxTTS model is being downloaded. Please wait and try again.",
|
|
||||||
"model_name": model_name,
|
|
||||||
"downloading": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await tts_model.load_model()
|
|
||||||
elif engine == "chatterbox":
|
|
||||||
if not tts_model._is_model_cached():
|
|
||||||
model_name = "chatterbox-tts"
|
|
||||||
|
|
||||||
async def download_chatterbox_background():
|
|
||||||
try:
|
|
||||||
await tts_model.load_model()
|
|
||||||
except Exception as e:
|
|
||||||
task_manager.error_download(model_name, str(e))
|
|
||||||
|
|
||||||
task_manager.start_download(model_name)
|
|
||||||
asyncio.create_task(download_chatterbox_background())
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=202,
|
|
||||||
detail={
|
|
||||||
"message": "Chatterbox model is being downloaded. Please wait and try again.",
|
|
||||||
"model_name": model_name,
|
|
||||||
"downloading": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await tts_model.load_model()
|
|
||||||
elif engine == "chatterbox_turbo":
|
|
||||||
if not tts_model._is_model_cached():
|
|
||||||
model_name = "chatterbox-turbo"
|
|
||||||
|
|
||||||
async def download_chatterbox_turbo_background():
|
|
||||||
try:
|
|
||||||
await tts_model.load_model()
|
|
||||||
except Exception as e:
|
|
||||||
task_manager.error_download(model_name, str(e))
|
|
||||||
|
|
||||||
task_manager.start_download(model_name)
|
|
||||||
asyncio.create_task(download_chatterbox_turbo_background())
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=202,
|
|
||||||
detail={
|
|
||||||
"message": "Chatterbox Turbo model is being downloaded. Please wait and try again.",
|
|
||||||
"model_name": model_name,
|
|
||||||
"downloading": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await tts_model.load_model()
|
await tts_model.load_model()
|
||||||
|
|
||||||
# Create voice prompt from profile
|
# Create voice prompt
|
||||||
voice_prompt = await profiles.create_voice_prompt_for_profile(
|
voice_prompt = await profiles.create_voice_prompt_for_profile(
|
||||||
data.profile_id,
|
data.profile_id,
|
||||||
db,
|
bg_db,
|
||||||
use_cache=True,
|
use_cache=True,
|
||||||
engine=engine,
|
engine=engine,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .utils.chunked_tts import generate_chunked
|
from .utils.chunked_tts import generate_chunked
|
||||||
|
|
||||||
# Resolve per-chunk trim function for engines that need it
|
|
||||||
trim_fn = None
|
trim_fn = None
|
||||||
if engine in ("chatterbox", "chatterbox_turbo"):
|
if engine in ("chatterbox", "chatterbox_turbo"):
|
||||||
from .utils.audio import trim_tts_output
|
from .utils.audio import trim_tts_output
|
||||||
@@ -844,61 +794,177 @@ async def generate_speech(
|
|||||||
trim_fn=trim_fn,
|
trim_fn=trim_fn,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate duration
|
if data.normalize:
|
||||||
duration = len(audio) / sample_rate
|
from .utils.audio import normalize_audio
|
||||||
|
audio = normalize_audio(audio)
|
||||||
|
|
||||||
# Save audio
|
duration = len(audio) / sample_rate
|
||||||
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
||||||
|
|
||||||
from .utils.audio import save_audio
|
from .utils.audio import save_audio
|
||||||
import errno
|
|
||||||
|
|
||||||
try:
|
|
||||||
save_audio(audio, str(audio_path), sample_rate)
|
save_audio(audio, str(audio_path), sample_rate)
|
||||||
except BrokenPipeError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)",
|
|
||||||
)
|
|
||||||
except OSError as save_err:
|
|
||||||
err_no = getattr(save_err, "errno", None) or (
|
|
||||||
getattr(save_err.__cause__, "errno", None)
|
|
||||||
if save_err.__cause__
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if err_no == errno.ENOENT:
|
|
||||||
msg = f"Audio save failed: directory not found — {audio_path.parent}"
|
|
||||||
elif err_no == errno.EACCES:
|
|
||||||
msg = f"Audio save failed: permission denied — {audio_path.parent}"
|
|
||||||
elif err_no == errno.ENOSPC:
|
|
||||||
msg = "Audio save failed: no disk space remaining"
|
|
||||||
else:
|
|
||||||
msg = f"Audio save failed: {save_err}"
|
|
||||||
raise HTTPException(status_code=500, detail=msg)
|
|
||||||
|
|
||||||
# Create history entry
|
# Update the record to completed
|
||||||
generation = await history.create_generation(
|
await history.update_generation_status(
|
||||||
profile_id=data.profile_id,
|
generation_id=generation_id,
|
||||||
text=data.text,
|
status="completed",
|
||||||
language=data.language,
|
db=bg_db,
|
||||||
audio_path=str(audio_path),
|
audio_path=str(audio_path),
|
||||||
duration=duration,
|
duration=duration,
|
||||||
seed=data.seed,
|
|
||||||
db=db,
|
|
||||||
instruct=data.instruct,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark generation as complete
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
await history.update_generation_status(
|
||||||
|
generation_id=generation_id,
|
||||||
|
status="failed",
|
||||||
|
db=bg_db,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
task_manager.complete_generation(generation_id)
|
task_manager.complete_generation(generation_id)
|
||||||
|
bg_db.close()
|
||||||
|
|
||||||
|
_enqueue_generation(_run_generation())
|
||||||
|
|
||||||
return generation
|
return generation
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
task_manager.complete_generation(generation_id)
|
@app.post("/generate/{generation_id}/retry", response_model=models.GenerationResponse)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Retry a failed generation using the same parameters."""
|
||||||
|
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") != "failed":
|
||||||
|
raise HTTPException(status_code=400, detail="Only failed generations can be retried")
|
||||||
|
|
||||||
|
# Reset the record to generating
|
||||||
|
gen.status = "generating"
|
||||||
|
gen.error = None
|
||||||
|
gen.audio_path = ""
|
||||||
|
gen.duration = 0
|
||||||
|
db.commit()
|
||||||
|
db.refresh(gen)
|
||||||
|
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
task_manager.start_generation(
|
||||||
|
task_id=generation_id,
|
||||||
|
profile_id=gen.profile_id,
|
||||||
|
text=gen.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve engine/model from stored values
|
||||||
|
retry_engine = gen.engine or "qwen"
|
||||||
|
retry_model_size = gen.model_size or "1.7B"
|
||||||
|
|
||||||
|
from .backends import get_tts_backend_for_engine
|
||||||
|
tts_model = get_tts_backend_for_engine(retry_engine)
|
||||||
|
|
||||||
|
async def _run_retry():
|
||||||
|
bg_db = next(get_db())
|
||||||
|
try:
|
||||||
|
if retry_engine == "qwen":
|
||||||
|
await tts_model.load_model_async(retry_model_size)
|
||||||
|
else:
|
||||||
|
await tts_model.load_model()
|
||||||
|
|
||||||
|
voice_prompt = await profiles.create_voice_prompt_for_profile(
|
||||||
|
gen.profile_id,
|
||||||
|
bg_db,
|
||||||
|
use_cache=True,
|
||||||
|
engine=retry_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .utils.chunked_tts import generate_chunked
|
||||||
|
|
||||||
|
trim_fn = None
|
||||||
|
if retry_engine in ("chatterbox", "chatterbox_turbo"):
|
||||||
|
from .utils.audio import trim_tts_output
|
||||||
|
trim_fn = trim_tts_output
|
||||||
|
|
||||||
|
audio, sample_rate = await generate_chunked(
|
||||||
|
tts_model,
|
||||||
|
gen.text,
|
||||||
|
voice_prompt,
|
||||||
|
language=gen.language,
|
||||||
|
seed=gen.seed,
|
||||||
|
instruct=gen.instruct,
|
||||||
|
trim_fn=trim_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = len(audio) / sample_rate
|
||||||
|
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
||||||
|
|
||||||
|
from .utils.audio import save_audio
|
||||||
|
save_audio(audio, str(audio_path), sample_rate)
|
||||||
|
|
||||||
|
await history.update_generation_status(
|
||||||
|
generation_id=generation_id,
|
||||||
|
status="completed",
|
||||||
|
db=bg_db,
|
||||||
|
audio_path=str(audio_path),
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
await history.update_generation_status(
|
||||||
|
generation_id=generation_id,
|
||||||
|
status="failed",
|
||||||
|
db=bg_db,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
task_manager.complete_generation(generation_id)
|
task_manager.complete_generation(generation_id)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
bg_db.close()
|
||||||
|
|
||||||
|
_enqueue_generation(_run_retry())
|
||||||
|
|
||||||
|
return models.GenerationResponse.model_validate(gen)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/generate/{generation_id}/status")
|
||||||
|
async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""SSE endpoint that streams generation status updates.
|
||||||
|
|
||||||
|
Polls the DB every second and yields the current status. Closes when
|
||||||
|
the generation reaches 'completed' or 'failed'.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
while True:
|
||||||
|
db.expire_all()
|
||||||
|
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not gen:
|
||||||
|
yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": gen.id,
|
||||||
|
"status": gen.status or "completed",
|
||||||
|
"duration": gen.duration,
|
||||||
|
"error": gen.error,
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(payload)}\n\n"
|
||||||
|
|
||||||
|
if (gen.status or "completed") in ("completed", "failed"):
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/generate/stream")
|
@app.post("/generate/stream")
|
||||||
@@ -975,6 +1041,10 @@ async def stream_speech(
|
|||||||
trim_fn=trim_fn,
|
trim_fn=trim_fn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if data.normalize:
|
||||||
|
from .utils.audio import normalize_audio
|
||||||
|
audio = normalize_audio(audio)
|
||||||
|
|
||||||
wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
|
wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
|
||||||
|
|
||||||
async def _wav_stream():
|
async def _wav_stream():
|
||||||
@@ -1588,6 +1658,141 @@ async def get_model_progress(model_name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/models/cache-dir")
|
||||||
|
async def get_models_cache_dir():
|
||||||
|
"""Get the path to the HuggingFace model cache directory."""
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
return {"path": str(Path(hf_constants.HF_HUB_CACHE))}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dir_size(path: Path) -> int:
|
||||||
|
"""Get total size of a directory in bytes."""
|
||||||
|
total = 0
|
||||||
|
for f in path.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
total += f.stat().st_size
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_with_progress(src: Path, dst: Path, progress_manager, copied_so_far: int, total_bytes: int) -> int:
|
||||||
|
"""Copy a directory tree with byte-level progress tracking."""
|
||||||
|
import shutil
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
for item in src.iterdir():
|
||||||
|
dest_item = dst / item.name
|
||||||
|
if item.is_dir():
|
||||||
|
copied_so_far = _copy_with_progress(item, dest_item, progress_manager, copied_so_far, total_bytes)
|
||||||
|
else:
|
||||||
|
size = item.stat().st_size
|
||||||
|
shutil.copy2(str(item), str(dest_item))
|
||||||
|
copied_so_far += size
|
||||||
|
progress_manager.update_progress(
|
||||||
|
"migration", copied_so_far, total_bytes,
|
||||||
|
filename=item.name, status="downloading",
|
||||||
|
)
|
||||||
|
return copied_so_far
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/models/migrate")
|
||||||
|
async def migrate_models(request: models.ModelMigrateRequest):
|
||||||
|
"""Move all downloaded models to a new directory with byte-level progress via SSE."""
|
||||||
|
import shutil
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
|
source = Path(hf_constants.HF_HUB_CACHE)
|
||||||
|
destination = Path(request.destination)
|
||||||
|
|
||||||
|
if not source.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Current model cache directory not found")
|
||||||
|
|
||||||
|
model_dirs = [d for d in source.iterdir() if d.name.startswith("models--") and d.is_dir()]
|
||||||
|
if not model_dirs:
|
||||||
|
return {"moved": 0, "errors": [], "source": str(source), "destination": str(destination)}
|
||||||
|
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
|
||||||
|
# Check if source and destination are on the same filesystem (rename is instant)
|
||||||
|
same_fs = False
|
||||||
|
try:
|
||||||
|
same_fs = source.stat().st_dev == destination.stat().st_dev
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def migrate_background():
|
||||||
|
moved = 0
|
||||||
|
errors = []
|
||||||
|
try:
|
||||||
|
if same_fs:
|
||||||
|
# Same filesystem: rename is instant, just track model count
|
||||||
|
total = len(model_dirs)
|
||||||
|
for i, item in enumerate(model_dirs):
|
||||||
|
dest_item = destination / item.name
|
||||||
|
try:
|
||||||
|
if dest_item.exists():
|
||||||
|
shutil.rmtree(dest_item)
|
||||||
|
shutil.move(str(item), str(dest_item))
|
||||||
|
moved += 1
|
||||||
|
progress_manager.update_progress(
|
||||||
|
"migration", i + 1, total,
|
||||||
|
filename=item.name, status="downloading",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{item.name}: {str(e)}")
|
||||||
|
else:
|
||||||
|
# Cross-filesystem: copy with byte-level progress, then delete source
|
||||||
|
total_bytes = sum(_get_dir_size(d) for d in model_dirs)
|
||||||
|
progress_manager.update_progress("migration", 0, total_bytes, filename="Calculating...", status="downloading")
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
for item in model_dirs:
|
||||||
|
dest_item = destination / item.name
|
||||||
|
try:
|
||||||
|
if dest_item.exists():
|
||||||
|
shutil.rmtree(dest_item)
|
||||||
|
copied = await asyncio.to_thread(
|
||||||
|
_copy_with_progress, item, dest_item, progress_manager, copied, total_bytes
|
||||||
|
)
|
||||||
|
# Remove source after successful copy
|
||||||
|
await asyncio.to_thread(shutil.rmtree, str(item))
|
||||||
|
moved += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{item.name}: {str(e)}")
|
||||||
|
|
||||||
|
progress_manager.update_progress("migration", 1, 1, status="complete")
|
||||||
|
progress_manager.mark_complete("migration")
|
||||||
|
except Exception as e:
|
||||||
|
progress_manager.update_progress("migration", 0, 0, status="error")
|
||||||
|
progress_manager.mark_error("migration", str(e))
|
||||||
|
|
||||||
|
_create_background_task(migrate_background())
|
||||||
|
|
||||||
|
return {"source": str(source), "destination": str(destination)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/models/migrate/progress")
|
||||||
|
async def get_migration_progress():
|
||||||
|
"""Get model migration progress via Server-Sent Events."""
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
async for event in progress_manager.subscribe("migration"):
|
||||||
|
yield event
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
||||||
async def get_model_status():
|
async def get_model_status():
|
||||||
"""Get status of all available models."""
|
"""Get status of all available models."""
|
||||||
@@ -2337,9 +2542,29 @@ def _get_gpu_status() -> str:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
"""Run on application startup."""
|
"""Run on application startup."""
|
||||||
|
global _generation_queue
|
||||||
print("voicebox API starting up...")
|
print("voicebox API starting up...")
|
||||||
database.init_db()
|
database.init_db()
|
||||||
print(f"Database initialized at {database._db_path}")
|
print(f"Database initialized at {database._db_path}")
|
||||||
|
|
||||||
|
# Start the serial generation worker
|
||||||
|
_generation_queue = asyncio.Queue()
|
||||||
|
_create_background_task(_generation_worker())
|
||||||
|
|
||||||
|
# Mark any stale "generating" records as failed — these are leftovers
|
||||||
|
# from a previous process that was killed mid-generation
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
db = next(get_db())
|
||||||
|
result = db.execute(
|
||||||
|
sa_text("UPDATE generations SET status = 'failed', error = 'Server was shut down during generation' WHERE status = 'generating'")
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
print(f"Marked {result.rowcount} stale generation(s) as failed")
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not clean up stale generations: {e}")
|
||||||
backend_type = get_backend_type()
|
backend_type = get_backend_type()
|
||||||
print(f"Backend: {backend_type.upper()}")
|
print(f"Backend: {backend_type.upper()}")
|
||||||
print(f"GPU available: {_get_gpu_status()}")
|
print(f"GPU available: {_get_gpu_status()}")
|
||||||
|
|||||||
+22
-8
@@ -60,6 +60,7 @@ class GenerationRequest(BaseModel):
|
|||||||
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$")
|
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$")
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
class GenerationResponse(BaseModel):
|
class GenerationResponse(BaseModel):
|
||||||
@@ -68,10 +69,14 @@ class GenerationResponse(BaseModel):
|
|||||||
profile_id: str
|
profile_id: str
|
||||||
text: str
|
text: str
|
||||||
language: str
|
language: str
|
||||||
audio_path: str
|
audio_path: Optional[str] = None
|
||||||
duration: float
|
duration: Optional[float] = None
|
||||||
seed: Optional[int]
|
seed: Optional[int] = None
|
||||||
instruct: Optional[str]
|
instruct: Optional[str] = None
|
||||||
|
engine: Optional[str] = "qwen"
|
||||||
|
model_size: Optional[str] = None
|
||||||
|
status: str = "completed"
|
||||||
|
error: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -93,10 +98,14 @@ class HistoryResponse(BaseModel):
|
|||||||
profile_name: str
|
profile_name: str
|
||||||
text: str
|
text: str
|
||||||
language: str
|
language: str
|
||||||
audio_path: str
|
audio_path: Optional[str] = None
|
||||||
duration: float
|
duration: Optional[float] = None
|
||||||
seed: Optional[int]
|
seed: Optional[int] = None
|
||||||
instruct: Optional[str]
|
instruct: Optional[str] = None
|
||||||
|
engine: Optional[str] = "qwen"
|
||||||
|
model_size: Optional[str] = None
|
||||||
|
status: str = "completed"
|
||||||
|
error: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -170,6 +179,11 @@ class ModelDownloadRequest(BaseModel):
|
|||||||
model_name: str
|
model_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModelMigrateRequest(BaseModel):
|
||||||
|
"""Request model for migrating models to a new directory."""
|
||||||
|
destination: str
|
||||||
|
|
||||||
|
|
||||||
class ActiveDownloadTask(BaseModel):
|
class ActiveDownloadTask(BaseModel):
|
||||||
"""Response model for active download task."""
|
"""Response model for active download task."""
|
||||||
model_name: str
|
model_name: str
|
||||||
|
|||||||
+6
-6
@@ -270,11 +270,14 @@ async def add_item_to_story(
|
|||||||
generation_created_at=generation.created_at,
|
generation_created_at=generation.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get track from data or default to 0
|
||||||
|
track = data.track if data.track is not None else 0
|
||||||
|
|
||||||
# Calculate start_time_ms if not provided
|
# Calculate start_time_ms if not provided
|
||||||
if data.start_time_ms is not None:
|
if data.start_time_ms is not None:
|
||||||
start_time_ms = data.start_time_ms
|
start_time_ms = data.start_time_ms
|
||||||
else:
|
else:
|
||||||
# Find the maximum end time (start_time_ms + duration_ms) of existing items
|
# Find the maximum end time on the target track only
|
||||||
existing_items = db.query(
|
existing_items = db.query(
|
||||||
DBStoryItem,
|
DBStoryItem,
|
||||||
DBGeneration
|
DBGeneration
|
||||||
@@ -282,11 +285,11 @@ async def add_item_to_story(
|
|||||||
DBGeneration,
|
DBGeneration,
|
||||||
DBStoryItem.generation_id == DBGeneration.id
|
DBStoryItem.generation_id == DBGeneration.id
|
||||||
).filter(
|
).filter(
|
||||||
DBStoryItem.story_id == story_id
|
DBStoryItem.story_id == story_id,
|
||||||
|
DBStoryItem.track == track,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not existing_items:
|
if not existing_items:
|
||||||
# First item starts at 0
|
|
||||||
start_time_ms = 0
|
start_time_ms = 0
|
||||||
else:
|
else:
|
||||||
max_end_time_ms = 0
|
max_end_time_ms = 0
|
||||||
@@ -297,9 +300,6 @@ async def add_item_to_story(
|
|||||||
# Add 200ms gap after the last item
|
# Add 200ms gap after the last item
|
||||||
start_time_ms = max_end_time_ms + 200
|
start_time_ms = max_end_time_ms + 200
|
||||||
|
|
||||||
# Get track from data or default to 0
|
|
||||||
track = data.track if data.track is not None else 0
|
|
||||||
|
|
||||||
# Create item
|
# Create item
|
||||||
item = DBStoryItem(
|
item = DBStoryItem(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
|
"dependencies": {
|
||||||
|
"loaders.css": "^0.1.2",
|
||||||
|
"react-loaders": "^3.0.1",
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.12",
|
"@biomejs/biome": "2.3.12",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
@@ -13,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.11",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -68,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.11",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -93,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"version": "0.1.11",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
@@ -116,7 +120,7 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"version": "0.1.11",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
@@ -125,6 +129,7 @@
|
|||||||
"zustand": "^4.5.0",
|
"zustand": "^4.5.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
@@ -677,6 +682,8 @@
|
|||||||
|
|
||||||
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"classnames": ["[email protected]", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||||
|
|
||||||
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
@@ -873,6 +880,8 @@
|
|||||||
|
|
||||||
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
|
"loaders.css": ["[email protected]", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
|
||||||
|
|
||||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
@@ -959,6 +968,8 @@
|
|||||||
|
|
||||||
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
|
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||||
|
|
||||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
@@ -969,6 +980,10 @@
|
|||||||
|
|
||||||
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
|
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
|
||||||
|
|
||||||
|
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
|
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
|
||||||
|
|
||||||
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Voicebox Issue Pain Points (Snapshot)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Dataset: **128 total issues** (**107 open**, **21 closed**)
|
||||||
|
- Source: GitHub issues in `jamiepine/voicebox`
|
||||||
|
- Classification: keyword/theme clustering
|
||||||
|
- Note: counts below are **non-exclusive** (one issue can belong to multiple pain points)
|
||||||
|
|
||||||
|
## Most Common Pain Points (Open Issues)
|
||||||
|
|
||||||
|
| Rank | Pain Point | Open Issues | What users are reporting |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| 1 | Model download & offline reliability | **32** | Downloads failing/stalling, cache/offline behavior inconsistent, wrong model size selected, Errno issues |
|
||||||
|
| 2 | GPU/backend compatibility | **22** | GPU not detected, backend fallback surprises, platform-specific runtime failures (Windows/Mac) |
|
||||||
|
| 3 | Export/save/file persistence | **15** | Export fails, "failed to fetch/download audio", samples/profiles not saving |
|
||||||
|
| 4 | Language/accent quality & coverage | **14** | Missing language support, accent mismatch, robotic outputs |
|
||||||
|
| 5 | Update/restart safety + long-op controls | **4** | Auto-restart without warning, update confusion, lack of cancel/pause controls |
|
||||||
|
|
||||||
|
## Representative Issues by Pain Point
|
||||||
|
|
||||||
|
### 1) Model download & offline reliability (32)
|
||||||
|
|
||||||
|
- [#159](https://github.com/jamiepine/voicebox/issues/159) - Qwen download fails with Errno 22
|
||||||
|
- [#151](https://github.com/jamiepine/voicebox/issues/151) - Model loading hangs / server crashes
|
||||||
|
- [#150](https://github.com/jamiepine/voicebox/issues/150) - Internet required despite downloaded models
|
||||||
|
- [#149](https://github.com/jamiepine/voicebox/issues/149) - Cancel/pause controls for large downloads
|
||||||
|
- [#96](https://github.com/jamiepine/voicebox/issues/96) - 0.6B selection still uses/downloads 1.7B
|
||||||
|
|
||||||
|
### 2) GPU/backend compatibility (22)
|
||||||
|
|
||||||
|
- [#164](https://github.com/jamiepine/voicebox/issues/164) - Windows: no GPU usage + multiple breakages
|
||||||
|
- [#141](https://github.com/jamiepine/voicebox/issues/141) - Using CPU only, GPU not used
|
||||||
|
- [#131](https://github.com/jamiepine/voicebox/issues/131) - Numpy ABI mismatch in bundled app
|
||||||
|
- [#130](https://github.com/jamiepine/voicebox/issues/130) - Intel Mac tensor/padding generation error
|
||||||
|
- [#127](https://github.com/jamiepine/voicebox/issues/127) - GPU not found
|
||||||
|
|
||||||
|
### 3) Export/save/file persistence (15)
|
||||||
|
|
||||||
|
- [#148](https://github.com/jamiepine/voicebox/issues/148) - Japanese export fails on 0.1.12
|
||||||
|
- [#143](https://github.com/jamiepine/voicebox/issues/143) - Samples not saving
|
||||||
|
- [#134](https://github.com/jamiepine/voicebox/issues/134) - Can't save profile
|
||||||
|
- [#105](https://github.com/jamiepine/voicebox/issues/105) - Export audio fails (failed to fetch)
|
||||||
|
- [#49](https://github.com/jamiepine/voicebox/issues/49) - Export filename/location ignored on Windows
|
||||||
|
|
||||||
|
### 4) Language/accent quality & coverage (14)
|
||||||
|
|
||||||
|
- [#162](https://github.com/jamiepine/voicebox/issues/162) - Persian audio request/problem
|
||||||
|
- [#117](https://github.com/jamiepine/voicebox/issues/117) - Arabic language support
|
||||||
|
- [#113](https://github.com/jamiepine/voicebox/issues/113) - Polish language support
|
||||||
|
- [#109](https://github.com/jamiepine/voicebox/issues/109) - Ukrainian support
|
||||||
|
- [#100](https://github.com/jamiepine/voicebox/issues/100) - Non-US accent quality issues
|
||||||
|
|
||||||
|
### 5) Update/restart safety + controls (4)
|
||||||
|
|
||||||
|
- [#164](https://github.com/jamiepine/voicebox/issues/164) - Update behavior + usability failures
|
||||||
|
- [#136](https://github.com/jamiepine/voicebox/issues/136) - Auto-restart without warning
|
||||||
|
- [#86](https://github.com/jamiepine/voicebox/issues/86) - Unexpected restart with no confirmation
|
||||||
|
- [#149](https://github.com/jamiepine/voicebox/issues/149) - Need pause/cancel and pre-download confirmation
|
||||||
|
|
||||||
|
## Additional Signal
|
||||||
|
|
||||||
|
- There is also a large **feature-request/misc** bucket (**36 open**) that is competing with stability triage (audiobook, Linux build, additional ASR/TTS models, integrations).
|
||||||
|
|
||||||
|
## Takeaway
|
||||||
|
|
||||||
|
Most user pain is concentrated in four stability areas: **download/offline path**, **GPU/backend detection**, **save/export reliability**, and **language/accent correctness**. Addressing those first should reduce the majority of current support friction.
|
||||||
@@ -321,7 +321,7 @@ Notable requests:
|
|||||||
|
|
||||||
## New Model Integration — Landscape
|
## New Model Integration — Landscape
|
||||||
|
|
||||||
### Models Worth Supporting (2026 SOTA)
|
### Models Worth Supporting (2026 SOTA — updated March 13)
|
||||||
|
|
||||||
| Model | Cloning | Speed | Sample Rate | Languages | VRAM | Integration Ease | Status |
|
| Model | Cloning | Speed | Sample Rate | Languages | VRAM | Integration Ease | Status |
|
||||||
|-------|---------|-------|-------------|-----------|------|-----------------|--------|
|
|-------|---------|-------|-------------|-----------|------|-----------------|--------|
|
||||||
@@ -329,10 +329,23 @@ Notable requests:
|
|||||||
| **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English | <1 GB | **Shipped** | PR #254 |
|
| **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English | <1 GB | **Shipped** | PR #254 |
|
||||||
| **Chatterbox MTL** | 5s zero-shot | Medium | 24 kHz | 23 | Medium | **Shipped** | PR #257 |
|
| **Chatterbox MTL** | 5s zero-shot | Medium | 24 kHz | 23 | Medium | **Shipped** | PR #257 |
|
||||||
| **Chatterbox Turbo** | 5s zero-shot | Fast | 24 kHz | English | Low | **PR #258** | In review |
|
| **Chatterbox Turbo** | 5s zero-shot | Fast | 24 kHz | English | Low | **PR #258** | In review |
|
||||||
|
| **HumeAI TADA 1B/3B** | Zero-shot | 5× faster than LLM-TTS | — | EN (1B), Multilingual (3B) | Medium | Needs vetting | MIT, 700s+ coherent, synced transcript output |
|
||||||
|
| **MOSS-TTS Family** | Zero-shot | — | — | Multilingual | Medium | Needs vetting | Apache 2.0, multi-speaker dialogue, text-to-voice design (no ref audio) |
|
||||||
|
| **VoxCPM 1.5** | Zero-shot (seconds) | ~0.15 RTF streaming | — | Bilingual (EN/ZH) | Medium | Needs vetting | Apache 2.0, tokenizer-free continuous diffusion, LoRA-friendly |
|
||||||
|
| **Pocket TTS** | Zero-shot + streaming | >1× RT on CPU | — | English | ~100M params, CPU-first | Needs vetting | MIT, Kyutai Labs, no GPU required |
|
||||||
|
| **Kokoro-82M** | 3s instant | CPU realtime | 24 kHz | English | Tiny (82M) | Ready | Apache 2.0, multi-engine arch in place |
|
||||||
| **XTTS-v2** | 6s zero-shot | Mid-GPU | 24 kHz | 17+ | Medium | Ready | Multi-engine arch in place |
|
| **XTTS-v2** | 6s zero-shot | Mid-GPU | 24 kHz | 17+ | Medium | Ready | Multi-engine arch in place |
|
||||||
| **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | Ready | Multi-engine arch in place |
|
| **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | Ready | Multi-engine arch in place |
|
||||||
| **CosyVoice2-0.5B** | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | Ready | Multi-engine arch in place |
|
| **CosyVoice2-0.5B** | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | Ready | Multi-engine arch in place |
|
||||||
| **Kokoro-82M** | 3s instant | CPU realtime | 24 kHz | English | Tiny | Ready | Multi-engine arch in place |
|
|
||||||
|
#### Notes on New Candidates (March 2026)
|
||||||
|
|
||||||
|
- **HumeAI TADA** — Text-Audio Dual Alignment arch. Near-zero hallucinations/drift, free synced transcript. 700+ seconds coherent audio. Best candidate for Stories long-form reliability. [HF: HumeAI/tada-1b](https://huggingface.co/HumeAI/tada-1b) | [GitHub: HumeAI/tada](https://github.com/HumeAI/tada)
|
||||||
|
- **MOSS-TTS** — Modular suite: flagship cloning, MOSS-TTSD (multi-speaker dialogue), MOSS-VoiceGenerator (create voices from text descriptions, no ref audio). Unique UX for Stories voice design. [GitHub: OpenMOSS/MOSS-TTS](https://github.com/OpenMOSS/MOSS-TTS)
|
||||||
|
- **VoxCPM 1.5** — Tokenizer-free continuous diffusion + autoregressive. No discrete token artifacts. Context-aware prosody/emotion, real-time streaming, LoRA fine-tuning. Trained on 1.8M+ hours. [GitHub: OpenBMB/VoxCPM](https://github.com/OpenBMB/VoxCPM)
|
||||||
|
- **Pocket TTS** — 100M param CPU-first model from Kyutai Labs (Moshi team). Runs >1× realtime without GPU. Broadens hardware support significantly. [GitHub: kyutai-labs/pocket-tts](https://github.com/kyutai-labs/pocket-tts)
|
||||||
|
- **Watch list:** MioTTS-2.6B (fast LLM-based EN/JP, vLLM compatible), Oolel-Voices (Soynade Research, expressive modular control)
|
||||||
|
- **Skipped:** Fish Audio S2 — restrictive research license (commercial use requires approval), despite strong features
|
||||||
|
|
||||||
### Adding a New Engine (Now Straightforward)
|
### Adding a New Engine (Now Straightforward)
|
||||||
|
|
||||||
@@ -402,16 +415,21 @@ The generation form now uses a flat model dropdown with engine-based routing. Pe
|
|||||||
|
|
||||||
### Tier 3 — Future (v0.3.0+)
|
### Tier 3 — Future (v0.3.0+)
|
||||||
|
|
||||||
| Item | Notes |
|
| Priority | Item | Notes |
|
||||||
|------|-------|
|
|----------|------|-------|
|
||||||
| XTTS-v2 / Fish Speech / CosyVoice | Multi-engine arch is ready; just needs backend implementation |
|
| 1 | **HumeAI TADA** | Long-form reliability for Stories, synced transcripts. Addresses #234, #203, #191, #111, #69. Needs API vetting. |
|
||||||
| OpenAI-compatible API (plan doc exists) | Low effort once API is stable |
|
| 2 | **Pocket TTS** (Kyutai) | CPU-first 100M model, broadens hardware support. Kyutai ships clean code. Needs API vetting. |
|
||||||
| LoRA fine-tuning (PR #195) | Complex, needs rework for multi-engine |
|
| 3 | **MOSS-TTS** | Text-to-voice design (no ref audio) is unique. Multi-speaker dialogue for Stories. Needs thorough API vetting. |
|
||||||
| External/remote providers | Depends on use case demand |
|
| 4 | **Kokoro-82M** | 82M params, CPU realtime, Apache 2.0. Easy win. |
|
||||||
| GGUF support (#226) | Depends on model ecosystem maturity |
|
| 5 | **Model config registry refactor** | Reduce 5-dispatch-point duplication in main.py — do before adding 3+ more engines |
|
||||||
| Queue system (#234) | Batch generation |
|
| 6 | XTTS-v2 / Fish Speech / CosyVoice | Multi-engine arch is ready; just needs backend implementation |
|
||||||
| Streaming for non-MLX engines | Currently MLX-only |
|
| 7 | **VoxCPM 1.5** | Tokenizer-free streaming, interesting but uncertain integration surface |
|
||||||
| Kokoro-82M | Tiny model, great for CPU-only machines |
|
| 8 | OpenAI-compatible API (plan doc exists) | Low effort once API is stable |
|
||||||
|
| 9 | LoRA fine-tuning (PR #195) | Complex, needs rework for multi-engine |
|
||||||
|
| 10 | External/remote providers | Depends on use case demand |
|
||||||
|
| 11 | GGUF support (#226) | Depends on model ecosystem maturity |
|
||||||
|
| 12 | Queue system (#234) | Batch generation |
|
||||||
|
| 13 | Streaming for non-MLX engines | Currently MLX-only |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -40,5 +40,9 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.0.0"
|
"bun": ">=1.0.0"
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]"
|
"packageManager": "[email protected]",
|
||||||
|
"dependencies": {
|
||||||
|
"loaders.css": "^0.1.2",
|
||||||
|
"react-loaders": "^3.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -16,6 +16,7 @@ struct ServerState {
|
|||||||
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
||||||
server_pid: Mutex<Option<u32>>,
|
server_pid: Mutex<Option<u32>>,
|
||||||
keep_running_on_close: Mutex<bool>,
|
keep_running_on_close: Mutex<bool>,
|
||||||
|
models_dir: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -23,7 +24,16 @@ async fn start_server(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: State<'_, ServerState>,
|
state: State<'_, ServerState>,
|
||||||
remote: Option<bool>,
|
remote: Option<bool>,
|
||||||
|
models_dir: Option<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
// Store models_dir for use on restart (empty string means reset to default)
|
||||||
|
if let Some(ref dir) = models_dir {
|
||||||
|
if dir.is_empty() {
|
||||||
|
*state.models_dir.lock().unwrap() = None;
|
||||||
|
} else {
|
||||||
|
*state.models_dir.lock().unwrap() = Some(dir.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
// Check if server is already running (managed by this app instance)
|
// Check if server is already running (managed by this app instance)
|
||||||
if state.child.lock().unwrap().is_some() {
|
if state.child.lock().unwrap().is_some() {
|
||||||
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
||||||
@@ -274,6 +284,12 @@ async fn start_server(
|
|||||||
let port_str = SERVER_PORT.to_string();
|
let port_str = SERVER_PORT.to_string();
|
||||||
let is_remote = remote.unwrap_or(false);
|
let is_remote = remote.unwrap_or(false);
|
||||||
|
|
||||||
|
// Resolve the custom models directory from the parameter or stored state
|
||||||
|
let effective_models_dir = models_dir.or_else(|| state.models_dir.lock().unwrap().clone());
|
||||||
|
if let Some(ref dir) = effective_models_dir {
|
||||||
|
println!("Custom models directory: {}", dir);
|
||||||
|
}
|
||||||
|
|
||||||
// If CUDA binary exists, launch it directly instead of the bundled sidecar
|
// If CUDA binary exists, launch it directly instead of the bundled sidecar
|
||||||
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
|
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
|
||||||
println!("Launching CUDA backend: {:?}", cuda_path);
|
println!("Launching CUDA backend: {:?}", cuda_path);
|
||||||
@@ -282,6 +298,9 @@ async fn start_server(
|
|||||||
if is_remote {
|
if is_remote {
|
||||||
cmd = cmd.args(["--host", "0.0.0.0"]);
|
cmd = cmd.args(["--host", "0.0.0.0"]);
|
||||||
}
|
}
|
||||||
|
if let Some(ref dir) = effective_models_dir {
|
||||||
|
cmd = cmd.env("VOICEBOX_MODELS_DIR", dir);
|
||||||
|
}
|
||||||
cmd.spawn()
|
cmd.spawn()
|
||||||
} else {
|
} else {
|
||||||
// Use the bundled CPU sidecar
|
// Use the bundled CPU sidecar
|
||||||
@@ -289,6 +308,9 @@ async fn start_server(
|
|||||||
if is_remote {
|
if is_remote {
|
||||||
sidecar = sidecar.args(["--host", "0.0.0.0"]);
|
sidecar = sidecar.args(["--host", "0.0.0.0"]);
|
||||||
}
|
}
|
||||||
|
if let Some(ref dir) = effective_models_dir {
|
||||||
|
sidecar = sidecar.env("VOICEBOX_MODELS_DIR", dir);
|
||||||
|
}
|
||||||
println!("Spawning server process...");
|
println!("Spawning server process...");
|
||||||
sidecar.spawn()
|
sidecar.spawn()
|
||||||
};
|
};
|
||||||
@@ -613,9 +635,19 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
|||||||
async fn restart_server(
|
async fn restart_server(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: State<'_, ServerState>,
|
state: State<'_, ServerState>,
|
||||||
|
models_dir: Option<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
println!("restart_server: stopping current server...");
|
println!("restart_server: stopping current server...");
|
||||||
|
|
||||||
|
// Update stored models_dir: empty string means reset to default, non-empty means set
|
||||||
|
if let Some(ref dir) = models_dir {
|
||||||
|
if dir.is_empty() {
|
||||||
|
*state.models_dir.lock().unwrap() = None;
|
||||||
|
} else {
|
||||||
|
*state.models_dir.lock().unwrap() = Some(dir.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop the current server
|
// Stop the current server
|
||||||
stop_server(state.clone()).await?;
|
stop_server(state.clone()).await?;
|
||||||
|
|
||||||
@@ -623,9 +655,9 @@ async fn restart_server(
|
|||||||
println!("restart_server: waiting for port release...");
|
println!("restart_server: waiting for port release...");
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||||
|
|
||||||
// Start server again (will auto-detect CUDA binary)
|
// Start server again (will auto-detect CUDA binary and use stored models_dir)
|
||||||
println!("restart_server: starting server...");
|
println!("restart_server: starting server...");
|
||||||
start_server(app, state, None).await
|
start_server(app, state, None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -686,6 +718,7 @@ pub fn run() {
|
|||||||
child: Mutex::new(None),
|
child: Mutex::new(None),
|
||||||
server_pid: Mutex::new(None),
|
server_pid: Mutex::new(None),
|
||||||
keep_running_on_close: Mutex::new(false),
|
keep_running_on_close: Mutex::new(false),
|
||||||
|
models_dir: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.manage(audio_capture::AudioCaptureState::new())
|
.manage(audio_capture::AudioCaptureState::new())
|
||||||
.manage(audio_output::AudioOutputState::new())
|
.manage(audio_output::AudioOutputState::new())
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": ".*"
|
||||||
},
|
},
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUxRENBQkRBQjdBNTM1OTIKUldTU05hVzMycXZjNGJGcUxmcVVocll2QjdSaTJNdlFxR2M3VDJsMnVvbDdyZGRPMmRlOW9aWTcK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUxRENBQkRBQjdBNTM1OTIKUldTU05hVzMycXZjNGJGcUxmcVVocll2QjdSaTJNdlFxR2M3VDJsMnVvbDdyZGRPMmRlOW9aWTcK",
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import type { PlatformLifecycle } from '@/platform/types';
|
|||||||
class TauriLifecycle implements PlatformLifecycle {
|
class TauriLifecycle implements PlatformLifecycle {
|
||||||
onServerReady?: () => void;
|
onServerReady?: () => void;
|
||||||
|
|
||||||
async startServer(remote = false): Promise<string> {
|
async startServer(remote = false, modelsDir?: string | null): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string>('start_server', { remote });
|
const result = await invoke<string>('start_server', {
|
||||||
|
remote,
|
||||||
|
modelsDir: modelsDir ?? undefined,
|
||||||
|
});
|
||||||
console.log('Server started:', result);
|
console.log('Server started:', result);
|
||||||
this.onServerReady?.();
|
this.onServerReady?.();
|
||||||
return result;
|
return result;
|
||||||
@@ -27,9 +30,11 @@ class TauriLifecycle implements PlatformLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async restartServer(): Promise<string> {
|
async restartServer(modelsDir?: string | null): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string>('restart_server');
|
const result = await invoke<string>('restart_server', {
|
||||||
|
modelsDir: modelsDir ?? undefined,
|
||||||
|
});
|
||||||
console.log('Server restarted:', result);
|
console.log('Server restarted:', result);
|
||||||
this.onServerReady?.();
|
this.onServerReady?.();
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PlatformLifecycle } from '@/platform/types';
|
|||||||
class WebLifecycle implements PlatformLifecycle {
|
class WebLifecycle implements PlatformLifecycle {
|
||||||
onServerReady?: () => void;
|
onServerReady?: () => void;
|
||||||
|
|
||||||
async startServer(_remote = false): Promise<string> {
|
async startServer(_remote = false, _modelsDir?: string | null): Promise<string> {
|
||||||
// Web assumes server is running externally
|
// Web assumes server is running externally
|
||||||
// Return a default URL - this should be configured via env vars
|
// Return a default URL - this should be configured via env vars
|
||||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
|
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
|
||||||
@@ -15,7 +15,7 @@ class WebLifecycle implements PlatformLifecycle {
|
|||||||
// No-op for web - server is managed externally
|
// No-op for web - server is managed externally
|
||||||
}
|
}
|
||||||
|
|
||||||
async restartServer(): Promise<string> {
|
async restartServer(_modelsDir?: string | null): Promise<string> {
|
||||||
// No-op for web - server is managed externally
|
// No-op for web - server is managed externally
|
||||||
return import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
|
return import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user