mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -07:00
Enhance history management with export and import functionalities
- Added endpoints for exporting generations as ZIP archives and audio files. - Implemented import functionality for generations from ZIP archives with validation. - Updated HistoryTable component to support new export and import features. - Improved error handling and user notifications for export/import processes. - Refactored related hooks and API client methods to accommodate new functionalities.
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
import { AudioWaveform, Download, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -9,7 +17,13 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
|
import {
|
||||||
|
useDeleteGeneration,
|
||||||
|
useExportGeneration,
|
||||||
|
useExportGenerationAudio,
|
||||||
|
useHistory,
|
||||||
|
useImportGeneration,
|
||||||
|
} 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 } from '@/lib/utils/format';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
@@ -22,6 +36,9 @@ export function HistoryTable() {
|
|||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|
||||||
const { data: historyData, isLoading } = useHistory({
|
const { data: historyData, isLoading } = useHistory({
|
||||||
@@ -30,6 +47,9 @@ export function HistoryTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteGeneration = useDeleteGeneration();
|
const deleteGeneration = useDeleteGeneration();
|
||||||
|
const exportGeneration = useExportGeneration();
|
||||||
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
|
const importGeneration = useImportGeneration();
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
@@ -60,23 +80,65 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (audioId: string, text: string) => {
|
const handleDownloadAudio = (generationId: string, text: string) => {
|
||||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
exportGenerationAudio.mutate(
|
||||||
const filename = `${text.substring(0, 30).replace(/[^a-z0-9]/gi, '_')}.wav`;
|
{ generationId, text },
|
||||||
const link = document.createElement('a');
|
{
|
||||||
link.href = audioUrl;
|
onError: (error) => {
|
||||||
link.download = filename;
|
alert(`Failed to download audio: ${error.message}`);
|
||||||
document.body.appendChild(link);
|
},
|
||||||
link.click();
|
},
|
||||||
document.body.removeChild(link);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPackage = (generationId: string, text: string) => {
|
||||||
|
exportGeneration.mutate(
|
||||||
|
{ generationId, text },
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
alert(`Failed to export generation: ${error.message}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleImportClick = () => {
|
||||||
|
file_handleImportClickk.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleFileChange = (_e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
cons_handleFileChangeet.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file extension
|
||||||
|
if (!file.name.endsWith('.voicebox.zip')) {
|
||||||
|
alert('Please select a valid .voicebox.zip file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFile(file);
|
||||||
|
setImportDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportConfirm = () => {
|
||||||
|
if (selectedFile) {
|
||||||
|
importGeneration.mutate(selectedFile, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
alert(data.message || 'Generation imported successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
alert(`Failed to import generation: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return null;
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<div className="text-muted-foreground">Loading history...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = historyData?.items || [];
|
const history = historyData?.items || [];
|
||||||
@@ -85,8 +147,25 @@ export function HistoryTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 relative">
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
|
{/* <div className="flex justify-between items-center mb-4 shrink-0">
|
||||||
|
<h2 className="text-2xl font-bold">History</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleImportClick}>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Import Generation
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".voicebox.zip"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground flex-1 flex items-center justify-center">
|
<div className="text-center py-12 px-5 border-2 border-dashed border-gray-200 rounded-md text-muted-foreground flex-1 flex items-center justify-center">
|
||||||
No generation history yet. Generate your first audio to see it here.
|
No generation history yet. Generate your first audio to see it here.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -104,15 +183,20 @@ export function HistoryTable() {
|
|||||||
{history.map((gen) => {
|
{history.map((gen) => {
|
||||||
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={gen.id}
|
key={gen.id}
|
||||||
type="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-stretch gap-4 h-24 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors cursor-pointer text-left w-full',
|
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
|
||||||
isCurrentlyPlaying && 'bg-muted/70',
|
isCurrentlyPlaying && 'bg-muted/70',
|
||||||
)}
|
)}
|
||||||
onClick={() => handlePlay(gen.id, gen.text)}
|
onMouseDown={(e) => {
|
||||||
aria-label={`Play audio: ${gen.text.substring(0, 50)}`}
|
// Don't trigger play if clicking on textarea or if text is selected
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePlay(gen.id, gen.text);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Waveform icon */}
|
{/* Waveform icon */}
|
||||||
<div className="flex items-center shrink-0">
|
<div className="flex items-center shrink-0">
|
||||||
@@ -138,10 +222,8 @@ export function HistoryTable() {
|
|||||||
{/* Right side - Transcript textarea */}
|
{/* Right side - Transcript textarea */}
|
||||||
<div className="flex-1 min-w-0 flex">
|
<div className="flex-1 min-w-0 flex">
|
||||||
<Textarea
|
<Textarea
|
||||||
readOnly
|
|
||||||
value={gen.text}
|
value={gen.text}
|
||||||
className="flex-1 resize-none text-sm text-muted-foreground"
|
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,9 +246,19 @@ export function HistoryTable() {
|
|||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Play
|
Play
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleDownload(gen.id, gen.text)}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||||
|
disabled={exportGenerationAudio.isPending}
|
||||||
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download
|
Export Audio
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||||
|
disabled={exportGeneration.isPending}
|
||||||
|
>
|
||||||
|
<FileArchive className="mr-2 h-4 w-4" />
|
||||||
|
Export Package
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => deleteGeneration.mutate(gen.id)}
|
onClick={() => deleteGeneration.mutate(gen.id)}
|
||||||
@@ -179,7 +271,7 @@ export function HistoryTable() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -203,6 +295,37 @@ export function HistoryTable() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Generation</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Import the generation from "{selectedFile?.name}". This will add it to your history.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImportConfirm}
|
||||||
|
disabled={importGeneration.isPending || !selectedFile}
|
||||||
|
>
|
||||||
|
{importGeneration.isPending ? 'Importing...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Volume2, Loader2, Settings } from 'lucide-react';
|
import { Loader2, Settings, 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 { useGenerationStore } from '@/stores/generationStore';
|
||||||
@@ -21,10 +21,12 @@ export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
|
|||||||
const isPlayerVisible = !!audioUrl;
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
"fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6",
|
className={cn(
|
||||||
isMacOS && "pt-14"
|
'fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6',
|
||||||
)}>
|
isMacOS && 'pt-14',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<img src={voiceboxLogo} alt="Voicebox" className="w-12 h-12 object-contain" />
|
<img src={voiceboxLogo} alt="Voicebox" className="w-12 h-12 object-contain" />
|
||||||
|
|||||||
@@ -58,11 +58,7 @@ export function ProfileList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return null;
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<div className="text-muted-foreground">Loading profiles...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -176,6 +176,54 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportGeneration(generationId: string): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/history/${generationId}/export`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportGenerationAudio(generationId: string): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/history/${generationId}/export-audio`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
|
||||||
|
const url = `${this.getBaseUrl()}/history/import`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
getAudioUrl(audioId: string): string {
|
getAudioUrl(audioId: string): string {
|
||||||
return `${this.getBaseUrl()}/audio/${audioId}`;
|
return `${this.getBaseUrl()}/audio/${audioId}`;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { HistoryQuery } from '@/lib/api/types';
|
import type { HistoryQuery } from '@/lib/api/types';
|
||||||
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
|
||||||
export function useHistory(query?: HistoryQuery) {
|
export function useHistory(query?: HistoryQuery) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -27,3 +28,130 @@ export function useDeleteGeneration() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useExportGeneration() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||||
|
const blob = await apiClient.exportGeneration(generationId);
|
||||||
|
|
||||||
|
// Create safe filename from text
|
||||||
|
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
|
const filename = `generation-${safeText}.voicebox.zip`;
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
// Use Tauri's native save dialog
|
||||||
|
try {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: filename,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: 'Voicebox Generation',
|
||||||
|
extensions: ['voicebox.zip', 'zip'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
// Write file using Tauri's filesystem API
|
||||||
|
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||||
|
// Fall back to browser download if Tauri dialog fails
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Browser: trigger download
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportGenerationAudio() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||||
|
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||||
|
|
||||||
|
// Create safe filename from text
|
||||||
|
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
|
const filename = `${safeText}.wav`;
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
// Use Tauri's native save dialog
|
||||||
|
try {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: filename,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: 'Audio File',
|
||||||
|
extensions: ['wav'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
// Write file using Tauri's filesystem API
|
||||||
|
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
|
||||||
|
// Fall back to browser download if Tauri dialog fails
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Browser: trigger download
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportGeneration() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => apiClient.importGeneration(file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+195
-1
@@ -2,6 +2,7 @@
|
|||||||
Voice profile export/import module.
|
Voice profile export/import module.
|
||||||
|
|
||||||
Handles exporting profiles to ZIP archives and importing them back.
|
Handles exporting profiles to ZIP archives and importing them back.
|
||||||
|
Also handles exporting individual generations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -12,7 +13,7 @@ from typing import Optional
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import VoiceProfileResponse
|
from .models import VoiceProfileResponse
|
||||||
from .database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample
|
from .database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration
|
||||||
from .profiles import create_profile, add_profile_sample
|
from .profiles import create_profile, add_profile_sample
|
||||||
from .models import VoiceProfileCreate
|
from .models import VoiceProfileCreate
|
||||||
from . import config
|
from . import config
|
||||||
@@ -211,3 +212,196 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
|
|||||||
if isinstance(e, ValueError):
|
if isinstance(e, ValueError):
|
||||||
raise
|
raise
|
||||||
raise ValueError(f"Error importing profile: {str(e)}")
|
raise ValueError(f"Error importing profile: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
|
||||||
|
"""
|
||||||
|
Export a generation to a ZIP archive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
generation_id: Generation ID to export
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ZIP file contents as bytes
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If generation not found
|
||||||
|
"""
|
||||||
|
# Get generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
raise ValueError(f"Generation {generation_id} not found")
|
||||||
|
|
||||||
|
# Get profile info
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
if not profile:
|
||||||
|
raise ValueError(f"Profile {generation.profile_id} not found")
|
||||||
|
|
||||||
|
# Get audio file
|
||||||
|
audio_path = Path(generation.audio_path)
|
||||||
|
if not audio_path.exists():
|
||||||
|
raise ValueError(f"Audio file not found: {audio_path}")
|
||||||
|
|
||||||
|
# Create ZIP in memory
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
# Create manifest.json
|
||||||
|
manifest = {
|
||||||
|
"version": "1.0",
|
||||||
|
"generation": {
|
||||||
|
"id": generation.id,
|
||||||
|
"text": generation.text,
|
||||||
|
"language": generation.language,
|
||||||
|
"duration": generation.duration,
|
||||||
|
"seed": generation.seed,
|
||||||
|
"instruct": generation.instruct,
|
||||||
|
"created_at": generation.created_at.isoformat(),
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"id": profile.id,
|
||||||
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"language": profile.language,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
# Add audio file
|
||||||
|
filename = audio_path.name
|
||||||
|
zip_file.write(audio_path, f"audio/{filename}")
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
return zip_buffer.read()
|
||||||
|
|
||||||
|
|
||||||
|
async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
|
||||||
|
"""
|
||||||
|
Import a generation from a ZIP archive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_bytes: ZIP file contents
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with generation ID and profile info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If ZIP is invalid or missing required files
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO(file_bytes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'r') as zip_file:
|
||||||
|
# Validate ZIP structure
|
||||||
|
namelist = zip_file.namelist()
|
||||||
|
|
||||||
|
if "manifest.json" not in namelist:
|
||||||
|
raise ValueError("ZIP archive missing manifest.json")
|
||||||
|
|
||||||
|
# Read manifest
|
||||||
|
manifest_data = json.loads(zip_file.read("manifest.json"))
|
||||||
|
|
||||||
|
if "version" not in manifest_data:
|
||||||
|
raise ValueError("Invalid manifest.json: missing version")
|
||||||
|
|
||||||
|
if "generation" not in manifest_data:
|
||||||
|
raise ValueError("Invalid manifest.json: missing generation data")
|
||||||
|
|
||||||
|
generation_data = manifest_data["generation"]
|
||||||
|
profile_data = manifest_data.get("profile", {})
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ["text", "language", "duration"]
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in generation_data:
|
||||||
|
raise ValueError(f"Invalid manifest.json: missing generation.{field}")
|
||||||
|
|
||||||
|
# Find audio file in archive
|
||||||
|
audio_files = [f for f in namelist if f.startswith("audio/") and f.endswith(".wav")]
|
||||||
|
if not audio_files:
|
||||||
|
raise ValueError("No audio file found in ZIP archive")
|
||||||
|
|
||||||
|
audio_file_path = audio_files[0]
|
||||||
|
|
||||||
|
# Check if we should match an existing profile or create metadata
|
||||||
|
profile_id = None
|
||||||
|
profile_name = profile_data.get("name", "Unknown Profile")
|
||||||
|
|
||||||
|
# Try to find matching profile by name
|
||||||
|
if profile_name and profile_name != "Unknown Profile":
|
||||||
|
existing_profile = db.query(DBVoiceProfile).filter_by(name=profile_name).first()
|
||||||
|
if existing_profile:
|
||||||
|
profile_id = existing_profile.id
|
||||||
|
|
||||||
|
# If no matching profile, use a placeholder or the first available profile
|
||||||
|
if not profile_id:
|
||||||
|
# Get any profile, or None if no profiles exist
|
||||||
|
any_profile = db.query(DBVoiceProfile).first()
|
||||||
|
if any_profile:
|
||||||
|
profile_id = any_profile.id
|
||||||
|
profile_name = any_profile.name
|
||||||
|
else:
|
||||||
|
raise ValueError("No voice profiles found. Please create a profile before importing generations.")
|
||||||
|
|
||||||
|
# Extract audio file to temporary location
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||||
|
tmp.write(zip_file.read(audio_file_path))
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create generations directory
|
||||||
|
generations_dir = config.get_generations_dir()
|
||||||
|
generations_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate new ID for this generation
|
||||||
|
new_generation_id = str(__import__('uuid').uuid4())
|
||||||
|
|
||||||
|
# Copy audio to generations directory
|
||||||
|
audio_dest = generations_dir / f"{new_generation_id}.wav"
|
||||||
|
shutil.copy(tmp_path, audio_dest)
|
||||||
|
|
||||||
|
# Create generation record
|
||||||
|
db_generation = DBGeneration(
|
||||||
|
id=new_generation_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
text=generation_data["text"],
|
||||||
|
language=generation_data["language"],
|
||||||
|
audio_path=str(audio_dest),
|
||||||
|
duration=generation_data["duration"],
|
||||||
|
seed=generation_data.get("seed"),
|
||||||
|
instruct=generation_data.get("instruct"),
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(db_generation)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_generation)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": db_generation.id,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"text": db_generation.text,
|
||||||
|
"message": f"Generation imported successfully (assigned to profile: {profile_name})"
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
raise ValueError("Invalid ZIP file")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Invalid JSON in archive: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, ValueError):
|
||||||
|
raise
|
||||||
|
raise ValueError(f"Error importing generation: {str(e)}")
|
||||||
|
|||||||
+95
-4
@@ -393,6 +393,39 @@ async def list_history(
|
|||||||
return await history.list_generations(query, db)
|
return await history.list_generations(query, db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/history/stats")
|
||||||
|
async def get_stats(db: Session = Depends(get_db)):
|
||||||
|
"""Get generation statistics."""
|
||||||
|
return await history.get_generation_stats(db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/history/import")
|
||||||
|
async def import_generation(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Import a generation from a ZIP archive."""
|
||||||
|
# Validate file size (max 50MB)
|
||||||
|
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
if len(content) > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await export_import.import_generation_from_zip(content, db)
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/history/{generation_id}", response_model=models.HistoryResponse)
|
@app.get("/history/{generation_id}", response_model=models.HistoryResponse)
|
||||||
async def get_generation(
|
async def get_generation(
|
||||||
generation_id: str,
|
generation_id: str,
|
||||||
@@ -440,10 +473,68 @@ async def delete_generation(
|
|||||||
return {"message": "Generation deleted successfully"}
|
return {"message": "Generation deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/history/stats")
|
@app.get("/history/{generation_id}/export")
|
||||||
async def get_stats(db: Session = Depends(get_db)):
|
async def export_generation(
|
||||||
"""Get generation statistics."""
|
generation_id: str,
|
||||||
return await history.get_generation_stats(db)
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Export a generation as a ZIP archive."""
|
||||||
|
try:
|
||||||
|
# Get generation to create filename
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
|
||||||
|
# Export to ZIP
|
||||||
|
zip_bytes = export_import.export_generation_to_zip(generation_id, db)
|
||||||
|
|
||||||
|
# Create safe filename from text
|
||||||
|
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||||
|
if not safe_text:
|
||||||
|
safe_text = "generation"
|
||||||
|
filename = f"generation-{safe_text}.voicebox.zip"
|
||||||
|
|
||||||
|
# Return as streaming response
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(zip_bytes),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/history/{generation_id}/export-audio")
|
||||||
|
async def export_generation_audio(
|
||||||
|
generation_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Export only the audio file from a generation."""
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
raise HTTPException(status_code=404, detail="Generation not found")
|
||||||
|
|
||||||
|
audio_path = Path(generation.audio_path)
|
||||||
|
if not audio_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||||
|
|
||||||
|
# Create safe filename from text
|
||||||
|
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||||
|
if not safe_text:
|
||||||
|
safe_text = "generation"
|
||||||
|
filename = f"{safe_text}.wav"
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
audio_path,
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
@@ -11,6 +11,15 @@ export const metadata: Metadata = {
|
|||||||
description:
|
description:
|
||||||
'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
|
'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
|
||||||
keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'],
|
keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'],
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.ico', sizes: 'any' },
|
||||||
|
{ url: '/favicon.png', type: 'image/png' },
|
||||||
|
],
|
||||||
|
apple: [
|
||||||
|
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'voicebox',
|
title: 'voicebox',
|
||||||
description: 'Professional voice cloning with Qwen3-TTS',
|
description: 'Professional voice cloning with Qwen3-TTS',
|
||||||
|
|||||||
+18
-1
@@ -11,6 +11,7 @@ ICON_BUNDLE="tauri/assets/voicebox.icon"
|
|||||||
ASSETS_DIR="$ICON_BUNDLE/Assets"
|
ASSETS_DIR="$ICON_BUNDLE/Assets"
|
||||||
ICONS_DIR="tauri/src-tauri/icons"
|
ICONS_DIR="tauri/src-tauri/icons"
|
||||||
LANDING_LOGO="landing/public/voicebox-logo.png"
|
LANDING_LOGO="landing/public/voicebox-logo.png"
|
||||||
|
LANDING_PUBLIC="landing/public"
|
||||||
SOURCE_ICON="$EXPORTS_DIR/[email protected]"
|
SOURCE_ICON="$EXPORTS_DIR/[email protected]"
|
||||||
|
|
||||||
echo "🎨 Updating all Voicebox icons from exports..."
|
echo "🎨 Updating all Voicebox icons from exports..."
|
||||||
@@ -152,10 +153,25 @@ sips -s format png -z 192 192 "$SOURCE_ICON" --out "$ICONS_DIR/android/mipmap-xx
|
|||||||
sips -s format png -z 192 192 "$SOURCE_ICON" --out "$ICONS_DIR/android/mipmap-xxxhdpi/ic_launcher_round.png" 2>/dev/null
|
sips -s format png -z 192 192 "$SOURCE_ICON" --out "$ICONS_DIR/android/mipmap-xxxhdpi/ic_launcher_round.png" 2>/dev/null
|
||||||
sips -s format png -z 192 192 "$SOURCE_ICON" --out "$ICONS_DIR/android/mipmap-xxxhdpi/ic_launcher_foreground.png" 2>/dev/null
|
sips -s format png -z 192 192 "$SOURCE_ICON" --out "$ICONS_DIR/android/mipmap-xxxhdpi/ic_launcher_foreground.png" 2>/dev/null
|
||||||
|
|
||||||
# Landing Page Logo
|
# Landing Page Logo & Favicon
|
||||||
echo "Generating landing page logo..."
|
echo "Generating landing page logo..."
|
||||||
|
mkdir -p "$LANDING_PUBLIC"
|
||||||
sips -s format png -z 1024 1024 "$SOURCE_ICON" --out "$LANDING_LOGO" 2>/dev/null
|
sips -s format png -z 1024 1024 "$SOURCE_ICON" --out "$LANDING_LOGO" 2>/dev/null
|
||||||
|
|
||||||
|
echo "Generating landing page favicon..."
|
||||||
|
# Generate favicon.png (32x32 is standard for favicons)
|
||||||
|
sips -s format png -z 32 32 "$SOURCE_ICON" --out "$LANDING_PUBLIC/favicon.png" 2>/dev/null
|
||||||
|
# Generate favicon.ico - try ImageMagick if available, otherwise use PNG (Next.js handles PNG favicons)
|
||||||
|
if command -v convert &> /dev/null; then
|
||||||
|
convert "$LANDING_PUBLIC/favicon.png" "$LANDING_PUBLIC/favicon.ico" 2>/dev/null || \
|
||||||
|
cp "$LANDING_PUBLIC/favicon.png" "$LANDING_PUBLIC/favicon.ico" 2>/dev/null
|
||||||
|
else
|
||||||
|
# Fallback: copy PNG as ICO (Next.js and modern browsers handle this)
|
||||||
|
cp "$LANDING_PUBLIC/favicon.png" "$LANDING_PUBLIC/favicon.ico" 2>/dev/null
|
||||||
|
fi
|
||||||
|
# Also generate apple-touch-icon (180x180 for iOS)
|
||||||
|
sips -s format png -z 180 180 "$SOURCE_ICON" --out "$LANDING_PUBLIC/apple-touch-icon.png" 2>/dev/null
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo "✅ All icons updated successfully!"
|
echo "✅ All icons updated successfully!"
|
||||||
@@ -168,5 +184,6 @@ echo " ✓ Windows Square logos"
|
|||||||
echo " ✓ iOS AppIcons (18 sizes)"
|
echo " ✓ iOS AppIcons (18 sizes)"
|
||||||
echo " ✓ Android mipmap icons (5 densities)"
|
echo " ✓ Android mipmap icons (5 densities)"
|
||||||
echo " ✓ Landing page logo"
|
echo " ✓ Landing page logo"
|
||||||
|
echo " ✓ Landing page favicon"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next: Rebuild the app with 'cd tauri && bun run tauri build'"
|
echo "Next: Rebuild the app with 'cd tauri && bun run tauri build'"
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user