Merge pull request #1 from jamiepine/improvements

Improvements
This commit is contained in:
Jamie Pine
2026-01-25 17:20:52 -08:00
committed by GitHub
170 changed files with 6644 additions and 1692 deletions
+5
View File
@@ -99,6 +99,8 @@ jobs:
- uses: tauri-apps/tauri-action@v0 - uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with: with:
projectPath: tauri projectPath: tauri
tagName: v__VERSION__ tagName: v__VERSION__
@@ -111,6 +113,9 @@ jobs:
- **macOS**: Download the `.dmg` file - **macOS**: Download the `.dmg` file
- **Windows**: Download the `.msi` installer - **Windows**: Download the `.msi` installer
- **Linux**: Download the `.AppImage` or `.deb` package - **Linux**: Download the `.AppImage` or `.deb` package
The app includes automatic updates - future updates will be installed automatically.
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
args: ${{ matrix.args }} args: ${{ matrix.args }}
includeUpdaterJson: true
+1 -1
View File
@@ -9,7 +9,7 @@ __pycache__/
venv/ venv/
env/ env/
ENV/ ENV/
*.prompt
# Build outputs # Build outputs
dist/ dist/
build/ build/
+1 -1
View File
@@ -437,7 +437,7 @@ projects
## 💡 Development Workflow ## 💡 Development Workflow
1. **Start backend**: `bun run dev:backend` (or via Tauri) 1. **Start backend**: `bun run dev:server` (or via Tauri)
2. **Start frontend**: `bun run dev` (Tauri) or `bun run dev:web` (web) 2. **Start frontend**: `bun run dev` (Tauri) or `bun run dev:web` (web)
3. **Generate API client**: `bun run generate:api` (after backend changes) 3. **Generate API client**: `bun run generate:api` (after backend changes)
4. **Build server binary**: `bun run build:server` (for Tauri bundling) 4. **Build server binary**: `bun run build:server` (for Tauri bundling)
+25 -21
View File
@@ -13,34 +13,38 @@
"check": "biome check --write src" "check": "biome check --write src"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"zustand": "^4.5.0",
"react-hook-form": "^7.53.0",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"zod": "^3.23.8", "@radix-ui/react-alert-dialog": "^1.1.1",
"wavesurfer.js": "^7.0.0", "@radix-ui/react-avatar": "^1.1.0",
"lucide-react": "^0.454.0",
"date-fns": "^3.6.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.4",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-alert-dialog": "^1.1.1" "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^12.29.0",
"lucide-react": "^0.454.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
"zod": "^3.23.8",
"zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
+80 -44
View File
@@ -3,48 +3,73 @@ import { GenerationForm } from '@/components/Generation/GenerationForm';
import { HistoryTable } from '@/components/History/HistoryTable'; import { HistoryTable } from '@/components/History/HistoryTable';
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus'; import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
import { Toaster } from '@/components/ui/toaster'; import { Toaster } from '@/components/ui/toaster';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { Sidebar } from '@/components/Sidebar'; import { Sidebar } from '@/components/Sidebar';
import { isTauri, startServer, stopServer } from '@/lib/tauri'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { UpdateNotification } from '@/components/UpdateNotification';
import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
// Track if server is starting to prevent duplicate starts // Track if server is starting to prevent duplicate starts
let serverStarting = false; let serverStarting = false;
function App() { function App() {
const [activeTab, setActiveTab] = useState('profiles'); const [activeTab, setActiveTab] = useState('main');
const [serverReady, setServerReady] = useState(false); const [serverReady, setServerReady] = useState(false);
// Auto-start server when running in Tauri // Setup window close handler and auto-start server when running in Tauri (production only)
useEffect(() => { useEffect(() => {
if (!isTauri() || serverStarting) { if (!isTauri()) {
return;
}
// Setup window close handler to check setting and stop server if needed
// This works in both dev and prod, but will only stop server if it was started by the app
setupWindowCloseHandler().catch((error) => {
console.error('Failed to setup window close handler:', error);
});
// Only auto-start server in production mode
// In dev mode, user runs server separately
if (!import.meta.env?.PROD) {
console.log('Dev mode: Skipping auto-start of server (run it separately)');
setServerReady(true); // Mark as ready so UI doesn't show loading screen
// Mark that server was not started by app (so we don't try to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
return;
}
// Auto-start server in production
if (serverStarting) {
return; return;
} }
serverStarting = true; serverStarting = true;
console.log('Running in Tauri, starting bundled server...'); console.log('Production mode: Starting bundled server...');
startServer(false) startServer(false)
.then(() => { .then(() => {
console.log('Server is ready'); console.log('Server is ready');
setServerReady(true); setServerReady(true);
// Mark that we started the server (so we know to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = true;
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to auto-start server:', error); console.error('Failed to auto-start server:', error);
serverStarting = false; serverStarting = false;
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
}); });
// Cleanup: stop server on actual unmount (not StrictMode remount) // Cleanup: stop server on actual unmount (not StrictMode remount)
// Note: Window close is handled separately in Tauri Rust code
return () => { return () => {
// In production builds, we want to stop the server on unmount // Window close event handles server shutdown based on setting
// In dev mode, React StrictMode causes remounts, so we skip cleanup serverStarting = false;
if (import.meta.env?.PROD) {
stopServer().catch((error) => {
console.error('Failed to stop server on cleanup:', error);
});
serverStarting = false;
}
}; };
}, []); }, []);
@@ -61,40 +86,51 @@ function App() {
} }
return ( return (
<div className="min-h-screen bg-background flex"> <div className="h-screen bg-background flex flex-col overflow-hidden">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} /> <div className="flex flex-1 min-h-0 overflow-hidden">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="flex-1 ml-20"> <main className="flex-1 ml-20 overflow-hidden flex flex-col">
<div className="container mx-auto px-8 py-8 max-w-7xl"> <div className="container mx-auto px-8 py-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
{activeTab === 'profiles' && ( <UpdateNotification />
<div className="space-y-4">
<ProfileList />
</div>
)}
{activeTab === 'generate' && ( {activeTab === 'settings' ? (
<div className="space-y-4"> <div className="space-y-4 overflow-y-auto">
<GenerationForm /> <div className="grid gap-4 md:grid-cols-2">
</div> <ConnectionForm />
)} <ServerStatus />
</div>
{activeTab === 'history' && ( {isTauri() && <UpdateStatus />}
<div className="space-y-4"> <ModelManagement />
<HistoryTable />
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<ConnectionForm />
<ServerStatus />
</div> </div>
<ModelManagement /> ) : (
</div> // 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">
</div> {/* Left Column */}
</main> <div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
{/* Profiles - Top Left */}
<div className="shrink-0 flex flex-col">
<ProfileList />
</div>
{/* Generator - Bottom Left */}
<div className="shrink-0">
<GenerationForm />
</div>
</div>
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
</div>
</div>
)}
</div>
</main>
</div>
{/* Audio Player - always visible except on settings */}
{activeTab !== 'settings' && <AudioPlayer />}
<Toaster /> <Toaster />
</div> </div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

@@ -0,0 +1,477 @@
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { formatAudioDuration } from '@/lib/utils/audio';
import { usePlayerStore } from '@/stores/playerStore';
export function AudioPlayer() {
const {
audioUrl,
title,
isPlaying,
currentTime,
duration,
volume,
isLooping,
setIsPlaying,
setCurrentTime,
setDuration,
setVolume,
toggleLoop,
} = usePlayerStore();
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const loadingRef = useRef(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize WaveSurfer (only when audioUrl exists and container is ready)
useEffect(() => {
// Don't initialize if no audioUrl or already initialized
if (!audioUrl) {
return;
}
if (wavesurferRef.current) {
console.log('WaveSurfer already initialized, skipping');
return;
}
console.log('Creating NEW WaveSurfer instance');
// Wait for container to be properly rendered
const initWaveSurfer = () => {
const container = waveformRef.current;
if (!container) {
// Container not ready yet, retry
setTimeout(initWaveSurfer, 50);
return;
}
// Check if container has dimensions and is visible
const rect = container.getBoundingClientRect();
const style = window.getComputedStyle(container);
const isVisible =
rect.width > 0 &&
rect.height > 0 &&
style.display !== 'none' &&
style.visibility !== 'hidden';
if (!isVisible) {
// Retry after a short delay
setTimeout(initWaveSurfer, 50);
return;
}
console.log('Initializing WaveSurfer...', {
container,
width: rect.width,
height: rect.height,
});
try {
// Get computed CSS variable values
const root = document.documentElement;
const getCSSVar = (varName: string) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim();
return value ? `hsl(${value})` : '';
};
const waveColor = getCSSVar('--muted');
const progressColor = getCSSVar('--accent');
const cursorColor = getCSSVar('--accent');
const wavesurfer = WaveSurfer.create({
container: container,
waveColor: waveColor,
progressColor: progressColor,
cursorColor: cursorColor,
barWidth: 2,
barRadius: 2,
height: 80,
normalize: true,
backend: 'WebAudio',
interact: true, // Enable interaction (click to seek)
mediaControls: false, // Don't show native controls
});
wavesurferRef.current = wavesurfer;
console.log('WaveSurfer created successfully');
} catch (error) {
console.error('Failed to create WaveSurfer:', error);
setError(
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
const wavesurfer = wavesurferRef.current;
if (!wavesurfer) return;
// Update store when time changes
wavesurfer.on('timeupdate', (time) => {
setCurrentTime(time);
});
// Update store when duration is loaded
wavesurfer.on('ready', () => {
const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
setIsLoading(false);
setError(null);
console.log('Audio ready, duration:', dur);
console.log('Waveform should be visible now');
// Ensure volume is set
const currentVolume = usePlayerStore.getState().volume;
wavesurfer.setVolume(currentVolume);
// Get the underlying audio element and ensure it's not muted
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.volume = currentVolume;
mediaElement.muted = false;
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
// Auto-play when ready
// Use a small delay to ensure audio element is fully ready
setTimeout(() => {
wavesurfer.play().catch((error) => {
console.error('Failed to autoplay:', error);
// Don't show error for autoplay failures (browser restrictions)
});
}, 100);
});
// Handle play/pause
wavesurfer.on('play', () => {
setIsPlaying(true);
// Ensure audio element is not muted when playing
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.muted = false;
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
});
wavesurfer.on('pause', () => setIsPlaying(false));
wavesurfer.on('finish', () => {
// Check loop state from store
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play();
} else {
setIsPlaying(false);
}
});
// Handle errors
wavesurfer.on('error', (error) => {
console.error('WaveSurfer error:', error);
setIsLoading(false);
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
});
// Handle loading
wavesurfer.on('loading', (percent) => {
setIsLoading(true);
if (percent === 100) {
setIsLoading(false);
}
});
// Load audio immediately if audioUrl is already set
if (audioUrl) {
console.log('WaveSurfer ready, loading audio:', audioUrl);
loadingRef.current = true;
setIsLoading(true);
// Stop any current playback before loading new audio
if (wavesurfer.isPlaying()) {
wavesurfer.pause();
}
wavesurfer
.load(audioUrl)
.then(() => {
console.log('Audio loaded into WaveSurfer');
loadingRef.current = false;
})
.catch((error) => {
console.error('Failed to load audio into WaveSurfer:', error);
loadingRef.current = false;
setIsLoading(false);
setError(
`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
);
});
}
};
// Use double requestAnimationFrame to ensure DOM is fully rendered
let rafId1: number;
let rafId2: number;
let timeoutId: number | null = null;
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
// Add a small delay to ensure container is fully laid out
timeoutId = setTimeout(() => {
initWaveSurfer();
}, 10);
});
});
return () => {
console.log('Cleaning up WaveSurfer initialization effect');
if (rafId1) cancelAnimationFrame(rafId1);
if (rafId2) cancelAnimationFrame(rafId2);
if (timeoutId) clearTimeout(timeoutId);
if (wavesurferRef.current) {
console.log('Destroying WaveSurfer instance');
try {
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.pause();
mediaElement.src = '';
}
wavesurferRef.current.destroy();
} catch (error) {
console.error('Error destroying WaveSurfer:', error);
}
wavesurferRef.current = null;
}
};
}, [audioUrl, setIsPlaying, setCurrentTime, setDuration]);
// Load audio when URL changes (only if WaveSurfer is already initialized)
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!audioUrl || !wavesurfer) {
// Reset state when no audio or WaveSurfer not ready
if (!audioUrl && wavesurfer) {
wavesurfer.pause();
wavesurfer.seekTo(0);
loadingRef.current = false;
setIsLoading(false);
setDuration(0);
setCurrentTime(0);
setError(null);
}
return;
}
// CRITICAL: Force stop any current playback and cancel any pending loads
// This must happen BEFORE any early returns
console.log('Audio URL changed to:', audioUrl);
// COMPLETELY stop and destroy the current audio
try {
// First pause if playing
if (wavesurfer.isPlaying()) {
console.log('Pausing current playback');
wavesurfer.pause();
}
// Stop the media element explicitly
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
console.log('Stopping media element');
mediaElement.pause();
mediaElement.currentTime = 0;
mediaElement.src = '';
}
// Use empty() to completely destroy the waveform and media element
console.log('Calling wavesurfer.empty() to destroy audio');
wavesurfer.empty();
} catch (error) {
console.error('Error stopping previous audio:', error);
// Continue anyway to load new audio
}
// Reset loading state to allow new load (cancel any pending loads)
loadingRef.current = false;
// Now start the new load
loadingRef.current = true;
setIsLoading(true);
setError(null);
setCurrentTime(0);
setDuration(0);
// Load new audio
console.log('Starting new audio load for:', audioUrl);
wavesurfer
.load(audioUrl)
.then(() => {
console.log('Audio load promise resolved');
// Don't set loading to false here - wait for 'ready' event
})
.catch((error) => {
console.error('Failed to load audio:', error);
console.error('Audio URL:', audioUrl);
loadingRef.current = false;
setIsLoading(false);
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
});
}, [audioUrl, setCurrentTime, setDuration]);
// Sync play/pause state (only when user clicks play/pause button, not auto-sync)
// This effect is kept for external state changes but should be minimal
useEffect(() => {
if (!wavesurferRef.current || duration === 0) return;
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
// Only auto-play if audio is ready
wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
} else if (!isPlaying && wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
}
}, [isPlaying, setIsPlaying, duration]);
// Sync volume
useEffect(() => {
if (wavesurferRef.current) {
wavesurferRef.current.setVolume(volume);
// Also ensure the underlying audio element volume is set
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.volume = volume;
mediaElement.muted = volume === 0;
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
}
}
}, [volume]);
// Handle loop - WaveSurfer handles this via the 'finish' event
const handlePlayPause = () => {
if (!wavesurferRef.current) {
console.error('WaveSurfer not initialized');
return;
}
// Check if audio is loaded
if (duration === 0 && !isLoading) {
console.error('Audio not loaded yet');
setError('Audio not loaded. Please wait...');
return;
}
if (wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
} else {
wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
}
};
const handleSeek = (value: number[]) => {
if (!wavesurferRef.current || duration === 0) return;
const progress = value[0] / 100;
wavesurferRef.current.seekTo(progress);
};
const handleVolumeChange = (value: number[]) => {
setVolume(value[0] / 100);
};
// Don't render if no audio
if (!audioUrl) {
return null;
}
return (
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center gap-4">
{/* Play/Pause Button */}
<Button
variant="ghost"
size="icon"
onClick={handlePlayPause}
disabled={isLoading || duration === 0}
className="shrink-0"
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
{/* Waveform */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div ref={waveformRef} className="w-full min-h-[80px]" />
{duration > 0 && (
<Slider
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
onValueChange={handleSeek}
max={100}
step={0.1}
className="w-full"
/>
)}
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-2">Loading audio...</div>
)}
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
</div>
{/* Time Display */}
<div className="flex items-center gap-2 text-sm text-muted-foreground shrink-0 min-w-[100px]">
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
<span>/</span>
<span className="font-mono">{formatAudioDuration(duration)}</span>
</div>
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
)}
{/* Loop Button */}
<Button
variant="ghost"
size="icon"
onClick={toggleLoop}
className={isLooping ? 'text-primary' : ''}
title="Toggle loop"
>
<Repeat className="h-4 w-4" />
</Button>
{/* Volume Control */}
<div className="flex items-center gap-2 shrink-0 w-[120px]">
<Button
variant="ghost"
size="icon"
onClick={() => setVolume(volume > 0 ? 0 : 1)}
className="h-8 w-8"
>
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</Button>
<Slider
value={[volume * 100]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
/>
</div>
</div>
</div>
</div>
);
}
@@ -1,7 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react'; import { Loader2, Mic } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
@@ -24,42 +25,54 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useGeneration } from '@/lib/hooks/useGeneration'; import { useGeneration } from '@/lib/hooks/useGeneration';
import { useProfiles } from '@/lib/hooks/useProfiles'; import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({ const generationSchema = z.object({
profileId: z.string().min(1, 'Please select a voice profile'),
text: z.string().min(1, 'Text is required').max(5000), text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(['en', 'zh']), language: z.enum(['en', 'zh']),
seed: z.number().int().optional(), seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(), modelSize: z.enum(['1.7B', '0.6B']).optional(),
instruct: z.string().max(500).optional(),
}); });
type GenerationFormValues = z.infer<typeof generationSchema>; type GenerationFormValues = z.infer<typeof generationSchema>;
export function GenerationForm() { export function GenerationForm() {
const { data: profiles } = useProfiles(); const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const generation = useGeneration(); const generation = useGeneration();
const { toast } = useToast(); const { toast } = useToast();
const form = useForm<GenerationFormValues>({ const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema), resolver: zodResolver(generationSchema),
defaultValues: { defaultValues: {
profileId: '',
text: '', text: '',
language: 'en', language: 'en',
seed: undefined, seed: undefined,
modelSize: '1.7B', modelSize: '1.7B',
instruct: '',
}, },
}); });
async function onSubmit(data: GenerationFormValues) { async function onSubmit(data: GenerationFormValues) {
if (!selectedProfileId) {
toast({
title: 'No profile selected',
description: 'Please select a voice profile from the cards above.',
variant: 'destructive',
});
return;
}
try { try {
const result = await generation.mutateAsync({ const result = await generation.mutateAsync({
profile_id: data.profileId, profile_id: selectedProfileId,
text: data.text, text: data.text,
language: data.language, language: data.language,
seed: data.seed, seed: data.seed,
model_size: data.modelSize, model_size: data.modelSize,
instruct: data.instruct || undefined,
}); });
toast({ toast({
@@ -85,30 +98,20 @@ export function GenerationForm() {
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <div>
control={form.control} <FormLabel>Voice Profile</FormLabel>
name="profileId" {selectedProfile ? (
render={({ field }) => ( <div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
<FormItem> <Mic className="h-4 w-4 text-muted-foreground" />
<FormLabel>Voice Profile</FormLabel> <span className="font-medium">{selectedProfile.name}</span>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Badge variant="outline">{selectedProfile.language}</Badge>
<FormControl> </div>
<SelectTrigger> ) : (
<SelectValue placeholder="Select a voice" /> <div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
</SelectTrigger> Click on a profile card above to select a voice profile
</FormControl> </div>
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} )}
/> </div>
<FormField <FormField
control={form.control} control={form.control}
@@ -119,7 +122,7 @@ export function GenerationForm() {
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Enter the text you want to generate..." placeholder="Enter the text you want to generate..."
className="min-h-[200px]" className="min-h-[150px]"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -129,6 +132,28 @@ export function GenerationForm() {
)} )}
/> />
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion, pace).
Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<FormField <FormField
control={form.control} control={form.control}
@@ -198,7 +223,11 @@ export function GenerationForm() {
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={generation.isPending}> <Button
type="submit"
className="w-full"
disabled={generation.isPending || !selectedProfileId}
>
{generation.isPending ? ( {generation.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+97 -71
View File
@@ -1,7 +1,13 @@
import { Download, Play, Trash2 } from 'lucide-react'; import { Download, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
Table, Table,
TableBody, TableBody,
@@ -10,15 +16,15 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory'; import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
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';
export function HistoryTable() { export function HistoryTable() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const limit = 20; const limit = 20;
const { toast } = useToast();
const { data: historyData, isLoading } = useHistory({ const { data: historyData, isLoading } = useHistory({
limit, limit,
@@ -26,17 +32,16 @@ export function HistoryTable() {
}); });
const deleteGeneration = useDeleteGeneration(); const deleteGeneration = useDeleteGeneration();
const setAudio = usePlayerStore((state) => state.setAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
const isPlaying = usePlayerStore((state) => state.isPlaying);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const handlePlay = (audioId: string) => { const handlePlay = (audioId: string, text: string) => {
const audioUrl = apiClient.getAudioUrl(audioId); const audioUrl = apiClient.getAudioUrl(audioId);
const audio = new Audio(audioUrl); // If clicking the same audio that's playing, it will be handled by the player
audio.play().catch((_error) => { setAudio(audioUrl, audioId, text.substring(0, 50));
toast({
title: 'Error',
description: 'Failed to play audio',
variant: 'destructive',
});
});
}; };
const handleDownload = (audioId: string, text: string) => { const handleDownload = (audioId: string, text: string) => {
@@ -63,71 +68,92 @@ export function HistoryTable() {
const hasMore = history.length === limit && (page + 1) * limit < total; const hasMore = history.length === limit && (page + 1) * limit < total;
return ( return (
<div className="space-y-4"> <div className="flex flex-col h-full min-h-0">
<h2 className="text-2xl font-bold">Generation History</h2>
{history.length === 0 ? ( {history.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 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>
) : ( ) : (
<> <>
<Table> <div
<TableHeader> className={cn(
<TableRow> 'flex-1 min-h-0 overflow-y-auto border rounded-md overflow-x-hidden',
<TableHead>Text</TableHead> isPlayerVisible && 'max-h-[calc(100vh-220px)]',
<TableHead>Profile</TableHead> )}
<TableHead>Language</TableHead> >
<TableHead>Duration</TableHead> <Table className="w-full table-fixed">
<TableHead>Created</TableHead> <TableHeader className="sticky top-0 bg-background z-10">
<TableHead className="text-right">Actions</TableHead> <TableRow>
</TableRow> <TableHead className="w-[38%]">Input</TableHead>
</TableHeader> <TableHead className="w-[13%]">Voice</TableHead>
<TableBody> <TableHead className="w-[9%]">Lang</TableHead>
{history.map((gen) => ( <TableHead className="w-[9%]">Length</TableHead>
<TableRow key={gen.id}> <TableHead className="w-[13%]">Date</TableHead>
<TableCell className="max-w-[300px] truncate">{gen.text}</TableCell> <TableHead className="w-[8%] text-right"></TableHead>
<TableCell>{gen.profile_name}</TableCell>
<TableCell>
<Badge variant="outline">{gen.language}</Badge>
</TableCell>
<TableCell>{formatDuration(gen.duration)}</TableCell>
<TableCell>{formatDate(gen.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handlePlay(gen.id)}
aria-label="Play audio"
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(gen.id, gen.text)}
aria-label="Download audio"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteGeneration.mutate(gen.id)}
disabled={deleteGeneration.isPending}
aria-label="Delete generation"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {history.map((gen) => {
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
return (
<TableRow
key={gen.id}
className={cn(isCurrentlyPlaying && 'bg-muted/50', 'cursor-pointer')}
onClick={() => handlePlay(gen.id, gen.text)}
>
<TableCell className="truncate">{gen.text}</TableCell>
<TableCell className="truncate">{gen.profile_name}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs text-muted-foreground">
{gen.language}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDuration(gen.duration)}</TableCell>
<TableCell className="text-xs text-muted-foreground/60">
{formatDate(gen.created_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
aria-label="Actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text)}>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDownload(gen.id, gen.text)}>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteGeneration.mutate(gen.id)}
disabled={deleteGeneration.isPending}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="flex justify-between"> <div className="flex justify-between items-center mt-4 shrink-0">
<Button <Button
variant="outline" variant="outline"
onClick={() => setPage((p) => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
@@ -135,7 +161,7 @@ export function HistoryTable() {
> >
Previous Previous
</Button> </Button>
<div className="text-sm text-muted-foreground flex items-center"> <div className="text-sm text-muted-foreground">
Page {page + 1} • {total} total Page {page + 1} • {total} total
</div> </div>
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}> <Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
@@ -1,5 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useEffect } from 'react';
import * as z from 'zod'; import * as z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -13,6 +14,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
@@ -25,6 +27,8 @@ type ConnectionFormValues = z.infer<typeof connectionSchema>;
export function ConnectionForm() { export function ConnectionForm() {
const serverUrl = useServerStore((state) => state.serverUrl); const serverUrl = useServerStore((state) => state.serverUrl);
const setServerUrl = useServerStore((state) => state.setServerUrl); const setServerUrl = useServerStore((state) => state.setServerUrl);
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
const { toast } = useToast(); const { toast } = useToast();
const form = useForm<ConnectionFormValues>({ const form = useForm<ConnectionFormValues>({
@@ -34,8 +38,16 @@ export function ConnectionForm() {
}, },
}); });
// Sync form with store when serverUrl changes externally
useEffect(() => {
form.reset({ serverUrl });
}, [serverUrl, form]);
const { isDirty } = form.formState;
function onSubmit(data: ConnectionFormValues) { function onSubmit(data: ConnectionFormValues) {
setServerUrl(data.serverUrl); setServerUrl(data.serverUrl);
form.reset(data); // Reset form state after successful submission
toast({ toast({
title: 'Server URL updated', title: 'Server URL updated',
description: `Connected to ${data.serverUrl}`, description: `Connected to ${data.serverUrl}`,
@@ -65,9 +77,39 @@ export function ConnectionForm() {
)} )}
/> />
<Button type="submit">Update Connection</Button> {isDirty && <Button type="submit">Update Connection</Button>}
</form> </form>
</Form> </Form>
<div className="mt-6 pt-6 border-t">
<div className="flex items-start space-x-3">
<Checkbox
id="keepServerRunning"
checked={keepServerRunningOnClose}
onCheckedChange={(checked: boolean) => {
setKeepServerRunningOnClose(checked);
toast({
title: 'Setting updated',
description: checked
? 'Server will continue running when app closes'
: 'Server will stop when app closes',
});
}}
/>
<div className="space-y-1">
<label
htmlFor="keepServerRunning"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Keep server running when app closes
</label>
<p className="text-sm text-muted-foreground">
When enabled, the server will continue running in the background after closing the
app. Disabled by default.
</p>
</div>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -73,7 +73,9 @@ export function ModelManagement() {
<div className="space-y-4"> <div className="space-y-4">
{/* TTS Models */} {/* TTS Models */}
<div> <div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Voice Generation Models</h3> <h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Voice Generation Models
</h3>
<div className="space-y-2"> <div className="space-y-2">
{modelStatus.models {modelStatus.models
.filter((m) => m.model_name.startsWith('qwen-tts')) .filter((m) => m.model_name.startsWith('qwen-tts'))
@@ -91,7 +93,9 @@ export function ModelManagement() {
{/* Whisper Models */} {/* Whisper Models */}
<div> <div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Transcription Models</h3> <h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Transcription Models
</h3>
<div className="space-y-2"> <div className="space-y-2">
{modelStatus.models {modelStatus.models
.filter((m) => m.model_name.startsWith('whisper')) .filter((m) => m.model_name.startsWith('whisper'))
@@ -109,7 +113,9 @@ export function ModelManagement() {
{/* Progress indicators */} {/* Progress indicators */}
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Download Progress</h3> <h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Download Progress
</h3>
<div className="space-y-2"> <div className="space-y-2">
{modelStatus.models.map((model) => ( {modelStatus.models.map((model) => (
<ModelProgress <ModelProgress
@@ -147,10 +153,14 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm">{model.display_name}</span> <span className="font-medium text-sm">{model.display_name}</span>
{model.loaded && ( {model.loaded && (
<Badge variant="default" className="text-xs">Loaded</Badge> <Badge variant="default" className="text-xs">
Loaded
</Badge>
)} )}
{model.downloaded && !model.loaded && ( {model.downloaded && !model.loaded && (
<Badge variant="secondary" className="text-xs">Downloaded</Badge> <Badge variant="secondary" className="text-xs">
Downloaded
</Badge>
)} )}
</div> </div>
{model.downloaded && model.size_mb && ( {model.downloaded && model.size_mb && (
@@ -166,12 +176,7 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
<span>Ready</span> <span>Ready</span>
</div> </div>
) : ( ) : (
<Button <Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
size="sm"
onClick={onDownload}
disabled={isDownloading}
variant="outline"
>
{isDownloading ? ( {isDownloading ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -51,7 +51,10 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
}, [serverUrl, modelName, isSubscribed]); }, [serverUrl, modelName, isSubscribed]);
// Don't render if no progress or if complete/error and some time has passed // Don't render if no progress or if complete/error and some time has passed
if (!progress || (progress.status === 'complete' && Date.now() - new Date(progress.timestamp).getTime() > 5000)) { if (
!progress ||
(progress.status === 'complete' && Date.now() - new Date(progress.timestamp).getTime() > 5000)
) {
return null; return null;
} }
@@ -111,9 +114,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
</span> </span>
)} )}
</div> </div>
{progress.total > 0 && ( {progress.total > 0 && <Progress value={progress.progress} className="h-2" />}
<Progress value={progress.progress} className="h-2" />
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -47,7 +47,9 @@ export function ServerStatus() {
<span className="text-sm">Connected</span> <span className="text-sm">Connected</span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}> <Badge
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
>
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'} {health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
</Badge> </Badge>
<Badge variant={health.gpu_available ? 'default' : 'secondary'}> <Badge variant={health.gpu_available ? 'default' : 'secondary'}>
@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { getVersion } from '@tauri-apps/api/app';
export function UpdateStatus() {
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
useEffect(() => {
getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion('0.1.0'));
}, []);
return (
<Card>
<CardHeader>
<CardTitle>App Updates</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Current Version</div>
<div className="text-sm text-muted-foreground">v{currentVersion}</div>
</div>
<Button
onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.installing}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
Check for Updates
</Button>
</div>
{status.checking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
Checking for updates...
</div>
)}
{status.error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
</div>
)}
{status.available && !status.downloading && !status.installing && (
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">Update Available</div>
<div className="text-sm text-muted-foreground">Version {status.version}</div>
</div>
<Badge>New</Badge>
</div>
<Button onClick={downloadAndInstall} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Install Update
</Button>
</div>
)}
{status.downloading && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Download className="h-4 w-4" />
Downloading update...
</div>
<Progress />
</div>
)}
{status.installing && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<RefreshCw className="h-4 w-4 animate-spin" />
Installing update...
</div>
<div className="text-xs text-muted-foreground">App will restart automatically</div>
</div>
)}
{!status.available && !status.checking && !status.error && status.checking === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-500" />
You're up to date
</div>
)}
</CardContent>
</Card>
);
}
+15 -9
View File
@@ -1,5 +1,6 @@
import { History, Mic, Settings, Sparkles } from 'lucide-react'; import { Home, Settings } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import voiceboxLogo from '@/assets/voicebox-logo.png';
interface SidebarProps { interface SidebarProps {
activeTab: string; activeTab: string;
@@ -7,15 +8,22 @@ interface SidebarProps {
} }
const tabs = [ const tabs = [
{ id: 'profiles', icon: Mic, label: 'Profiles' }, { id: 'main', icon: Home, label: 'Main' },
{ id: 'generate', icon: Sparkles, label: 'Generate' },
{ id: 'history', icon: History, label: 'History' },
{ id: 'settings', icon: Settings, label: 'Settings' }, { id: 'settings', icon: Settings, label: 'Settings' },
]; ];
export function Sidebar({ activeTab, onTabChange }: SidebarProps) { export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
return ( return (
<div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6"> <div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6">
{/* Logo */}
<div className="mb-2">
<img
src={voiceboxLogo}
alt="Voicebox"
className="w-12 h-12 object-contain"
/>
</div>
{/* Navigation Buttons */} {/* Navigation Buttons */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tabs.map((tab) => { {tabs.map((tab) => {
@@ -28,11 +36,9 @@ export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
type="button" type="button"
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
className={cn( className={cn(
"w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200", 'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
"hover:bg-accent hover:text-accent-foreground", 'hover:bg-accent hover:text-accent-foreground',
isActive isActive ? 'bg-accent text-accent-foreground shadow-lg' : 'text-muted-foreground',
? "bg-primary text-primary-foreground shadow-lg"
: "text-muted-foreground"
)} )}
title={tab.label} title={tab.label}
aria-label={tab.label} aria-label={tab.label}
+69
View File
@@ -0,0 +1,69 @@
import { useAutoUpdater } from '../hooks/useAutoUpdater';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Progress } from './ui/progress';
export function UpdateNotification() {
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(true);
if (status.error) {
return null;
}
if (!status.available && !status.checking) {
return null;
}
if (status.checking) {
return (
<Card className="p-4 mb-4">
<div className="flex items-center gap-3">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm">Checking for updates...</span>
</div>
</Card>
);
}
if (status.available) {
return (
<Card className="p-4 mb-4 border-primary">
<div className="space-y-3">
<div>
<h3 className="font-semibold">Update Available</h3>
<p className="text-sm text-muted-foreground">
Version {status.version} is ready to install
</p>
</div>
{status.downloading && (
<div className="space-y-2">
<p className="text-sm">Downloading update...</p>
<Progress />
</div>
)}
{status.installing && (
<div className="space-y-2">
<p className="text-sm">Installing update...</p>
<p className="text-xs text-muted-foreground">App will restart automatically</p>
</div>
)}
{!status.downloading && !status.installing && (
<div className="flex gap-2">
<Button onClick={downloadAndInstall} size="sm">
Install Now
</Button>
<Button onClick={() => window.location.reload()} variant="outline" size="sm">
Later
</Button>
</div>
)}
</div>
</Card>
);
}
return null;
}
@@ -1,11 +1,20 @@
import { Edit, Mic, Trash2 } from 'lucide-react'; import { Edit, Eye, Mic, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CircleButton } from '@/components/ui/circle-button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { VoiceProfileResponse } from '@/lib/api/types'; import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile } from '@/lib/hooks/useProfiles'; import { useDeleteProfile } from '@/lib/hooks/useProfiles';
import { formatDate } from '@/lib/utils/format'; import { cn } from '@/lib/utils/cn';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { ProfileDetail } from './ProfileDetail'; import { ProfileDetail } from './ProfileDetail';
@@ -15,63 +24,111 @@ interface ProfileCardProps {
export function ProfileCard({ profile }: ProfileCardProps) { export function ProfileCard({ profile }: ProfileCardProps) {
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile(); const deleteProfile = useDeleteProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId); const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const isSelected = selectedProfileId === profile.id;
const handleSelect = () => {
setSelectedProfileId(isSelected ? null : profile.id);
};
const handleEdit = () => { const handleEdit = () => {
setEditingProfileId(profile.id); setEditingProfileId(profile.id);
setProfileDialogOpen(true); setProfileDialogOpen(true);
}; };
const handleDelete = () => { const handleDeleteClick = (e: React.MouseEvent) => {
if ( e.stopPropagation();
confirm(`Are you sure you want to delete "${profile.name}"? This action cannot be undone.`) setDeleteDialogOpen(true);
) { };
deleteProfile.mutate(profile.id);
} const handleDeleteConfirm = () => {
deleteProfile.mutate(profile.id);
setDeleteDialogOpen(false);
}; };
return ( return (
<> <>
<Card <Card
className="cursor-pointer hover:shadow-lg transition-shadow" className={cn(
onClick={() => setDetailOpen(true)} 'cursor-pointer hover:shadow-md transition-all flex flex-col',
isSelected && 'ring-2 ring-primary shadow-md',
)}
onClick={handleSelect}
> >
<CardHeader> <CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center gap-1.5 text-base font-medium">
<span className="flex items-center gap-2"> <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0">
<Mic className="h-5 w-5" /> <Mic className="h-3.5 w-3.5 text-muted-foreground" />
{profile.name}
</span>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={handleEdit} aria-label="Edit profile">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
disabled={deleteProfile.isPending}
aria-label="Delete profile"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div> </div>
<span className="break-words">{profile.name}</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-3 pt-0 flex flex-col flex-1">
{profile.description && ( <p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
<p className="text-sm text-muted-foreground mb-2">{profile.description}</p> {profile.description || 'No description'}
)} </p>
<div className="flex gap-2 mb-2"> <div className="mb-2">
<Badge variant="outline">{profile.language}</Badge> <Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
{profile.language}
</Badge>
</div>
<div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton
icon={Eye}
onClick={(e) => {
e.stopPropagation();
setDetailOpen(true);
}}
aria-label="View details"
/>
<CircleButton
icon={Edit}
onClick={(e) => {
e.stopPropagation();
handleEdit();
}}
aria-label="Edit profile"
/>
<CircleButton
icon={Trash2}
onClick={handleDeleteClick}
disabled={deleteProfile.isPending}
aria-label="Delete profile"
/>
</div> </div>
<p className="text-xs text-muted-foreground">Created {formatDate(profile.created_at)}</p>
</CardContent> </CardContent>
</Card> </Card>
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} /> <ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteProfile.isPending}
>
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }
+525 -65
View File
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -13,6 +13,7 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -27,15 +28,64 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useCreateProfile, useProfile, useUpdateProfile } from '@/lib/hooks/useProfiles'; import {
useCreateProfile,
useProfile,
useUpdateProfile,
useAddSample,
} from '@/lib/hooks/useProfiles';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { Mic, Square, Upload } from 'lucide-react';
import { formatAudioDuration } from '@/lib/utils/audio';
const profileSchema = z.object({ // Helper function to get audio duration from File
name: z.string().min(1, 'Name is required').max(100), async function getAudioDuration(file: File): Promise<number> {
description: z.string().max(500).optional(), return new Promise((resolve, reject) => {
language: z.enum(['en', 'zh']), const audio = new Audio();
}); const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
resolve(audio.duration);
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
});
}
const MAX_AUDIO_DURATION_SECONDS = 30;
const profileSchema = z
.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(['en', 'zh']),
// Sample fields - only required when creating (not editing)
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
})
.refine(
(data) => {
// If sample file is provided, reference text is required
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
return false;
}
return true;
},
{
message: 'Reference text is required when adding a sample',
path: ['referenceText'],
},
);
type ProfileFormValues = z.infer<typeof profileSchema>; type ProfileFormValues = z.infer<typeof profileSchema>;
@@ -47,7 +97,13 @@ export function ProfileForm() {
const { data: editingProfile } = useProfile(editingProfileId || ''); const { data: editingProfile } = useProfile(editingProfileId || '');
const createProfile = useCreateProfile(); const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile(); const updateProfile = useUpdateProfile();
const addSample = useAddSample();
const transcribe = useTranscription();
const { toast } = useToast(); const { toast } = useToast();
const [sampleMode, setSampleMode] = useState<'upload' | 'record'>('upload');
const [audioDuration, setAudioDuration] = useState<number | null>(null);
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
const isCreating = !editingProfileId;
const form = useForm<ProfileFormValues>({ const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
@@ -58,39 +114,213 @@ export function ProfileForm() {
}, },
}); });
const selectedFile = form.watch('sampleFile');
// Validate audio duration when file is selected
useEffect(() => {
if (selectedFile && selectedFile instanceof File) {
setIsValidatingAudio(true);
getAudioDuration(selectedFile)
.then((duration) => {
setAudioDuration(duration);
if (duration > MAX_AUDIO_DURATION_SECONDS) {
form.setError('sampleFile', {
type: 'manual',
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
});
} else {
form.clearErrors('sampleFile');
}
})
.catch((error) => {
console.error('Failed to get audio duration:', error);
setAudioDuration(null);
form.setError('sampleFile', {
type: 'manual',
message: 'Failed to validate audio file. Please try a different file.',
});
})
.finally(() => {
setIsValidatingAudio(false);
});
} else {
setAudioDuration(null);
form.clearErrors('sampleFile');
}
}, [selectedFile, form]);
const {
isRecording,
duration,
error: recordingError,
startRecording,
stopRecording,
cancelRecording,
} = useAudioRecording({
maxDurationSeconds: 30,
onRecordingComplete: (blob) => {
const file = new File([blob], `recording-${Date.now()}.webm`, {
type: blob.type || 'audio/webm',
});
form.setValue('sampleFile', file, { shouldValidate: true });
toast({
title: 'Recording complete',
description: 'Audio has been recorded successfully.',
});
},
});
// Show recording errors
useEffect(() => {
if (recordingError) {
toast({
title: 'Recording error',
description: recordingError,
variant: 'destructive',
});
}
}, [recordingError, toast]);
useEffect(() => { useEffect(() => {
if (editingProfile) { if (editingProfile) {
form.reset({ form.reset({
name: editingProfile.name, name: editingProfile.name,
description: editingProfile.description || '', description: editingProfile.description || '',
language: editingProfile.language as 'en' | 'zh', language: editingProfile.language as 'en' | 'zh',
sampleFile: undefined,
referenceText: undefined,
}); });
} else { } else {
form.reset({ form.reset({
name: '', name: '',
description: '', description: '',
language: 'en', language: 'en',
sampleFile: undefined,
referenceText: undefined,
}); });
setSampleMode('upload');
} }
}, [editingProfile, form]); }, [editingProfile, form]);
async function handleTranscribe() {
const file = form.getValues('sampleFile');
if (!file) {
toast({
title: 'No file selected',
description: 'Please select an audio file first.',
variant: 'destructive',
});
return;
}
try {
const language = form.getValues('language') as 'en' | 'zh' | undefined;
const result = await transcribe.mutateAsync({ file, language });
form.setValue('referenceText', result.text, { shouldValidate: true });
toast({
title: 'Transcription complete',
description: 'Audio has been transcribed successfully.',
});
} catch (error) {
toast({
title: 'Transcription failed',
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
variant: 'destructive',
});
}
}
function handleCancelRecording() {
cancelRecording();
form.resetField('sampleFile');
}
async function onSubmit(data: ProfileFormValues) { async function onSubmit(data: ProfileFormValues) {
try { try {
if (editingProfileId) { if (editingProfileId) {
// Editing: just update profile
await updateProfile.mutateAsync({ await updateProfile.mutateAsync({
profileId: editingProfileId, profileId: editingProfileId,
data, data: {
name: data.name,
description: data.description,
language: data.language,
},
}); });
toast({ toast({
title: 'Profile updated', title: 'Profile updated',
description: `"${data.name}" has been updated successfully.`, description: `"${data.name}" has been updated successfully.`,
}); });
} else { } else {
await createProfile.mutateAsync(data); // Get file and reference text directly from form state to ensure we have the values
toast({ const sampleFile = form.getValues('sampleFile');
title: 'Profile created', const referenceText = form.getValues('referenceText');
description: `"${data.name}" has been created successfully.`,
// Validate audio duration before creating profile
if (sampleFile) {
try {
const duration = await getAudioDuration(sampleFile);
if (duration > MAX_AUDIO_DURATION_SECONDS) {
form.setError('sampleFile', {
type: 'manual',
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
});
toast({
title: 'Invalid audio file',
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
variant: 'destructive',
});
return; // Prevent form submission
}
} catch (error) {
form.setError('sampleFile', {
type: 'manual',
message: 'Failed to validate audio file. Please try a different file.',
});
toast({
title: 'Validation error',
description: error instanceof Error ? error.message : 'Failed to validate audio file',
variant: 'destructive',
});
return; // Prevent form submission
}
}
// Creating: create profile, then optionally add sample
const profile = await createProfile.mutateAsync({
name: data.name,
description: data.description,
language: data.language,
}); });
// If sample file and reference text provided, add it
if (sampleFile && referenceText && referenceText.trim().length > 0) {
try {
await addSample.mutateAsync({
profileId: profile.id,
file: sampleFile,
referenceText: referenceText,
});
toast({
title: 'Profile created',
description: `"${data.name}" has been created with a sample.`,
});
} catch (sampleError) {
// Profile was created but sample failed - still show success for profile
toast({
title: 'Profile created',
description: `"${data.name}" has been created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
variant: 'destructive',
});
}
} else {
toast({
title: 'Profile created',
description: `"${data.name}" has been created successfully. You can add samples later.`,
});
}
} }
form.reset(); form.reset();
@@ -110,79 +340,309 @@ export function ProfileForm() {
if (!open) { if (!open) {
setEditingProfileId(null); setEditingProfileId(null);
form.reset(); form.reset();
setSampleMode('upload');
if (isRecording) {
cancelRecording();
}
} }
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent> <DialogContent className="max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle> <DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
<DialogDescription> <DialogDescription>
{editingProfileId {editingProfileId
? 'Update your voice profile details.' ? 'Update your voice profile details.'
: 'Add a new voice profile with samples.'} : 'Create a new voice profile. You can add a sample now or later.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)}>
<FormField <div className={`grid gap-6 ${isCreating ? 'grid-cols-2' : 'grid-cols-1'}`}>
control={form.control} {/* Left column: Profile info */}
name="name" <div className="space-y-4">
render={({ field }) => ( <FormField
<FormItem> control={form.control}
<FormLabel>Name</FormLabel> name="name"
<FormControl> render={({ field }) => (
<Input placeholder="My Voice" {...field} /> <FormItem>
</FormControl> <FormLabel>Name</FormLabel>
<FormMessage /> <FormControl>
</FormItem> <Input placeholder="My Voice" {...field} />
)} </FormControl>
/> <FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description (Optional)</FormLabel>
<FormControl> <FormControl>
<Textarea placeholder="Describe this voice..." {...field} /> <Textarea placeholder="Describe this voice..." {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="language" name="language"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Language</FormLabel> <FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="en">English</SelectItem> <SelectItem value="en">English</SelectItem>
<SelectItem value="zh">Chinese</SelectItem> <SelectItem value="zh">Chinese</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
<div className="flex gap-2 justify-end"> {/* Right column: Sample upload section - only show when creating */}
{isCreating && (
<div className="space-y-4 border-l pl-6">
<div>
<h3 className="text-sm font-medium mb-2">Add Sample (Optional)</h3>
<p className="text-sm text-muted-foreground mb-4">
Add an audio sample to get started immediately. You can add more samples
later.
</p>
</div>
<Tabs
value={sampleMode}
onValueChange={(v) => setSampleMode(v as 'upload' | 'record')}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Upload
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4" />
Record
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={({ field: { onChange, name, ref } }) => (
<FormItem>
<FormLabel>Audio File</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
<Input
type="file"
accept="audio/*"
name={name}
ref={ref}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
} else {
onChange(undefined);
}
}}
/>
{selectedFile && (
<>
{isValidatingAudio && (
<p className="text-sm text-muted-foreground">
Validating audio...
</p>
)}
{!isValidatingAudio && audioDuration !== null && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Duration:</span>
<span
className={
audioDuration > MAX_AUDIO_DURATION_SECONDS
? 'text-destructive font-medium'
: 'text-foreground'
}
>
{formatAudioDuration(audioDuration)}
</span>
<span className="text-muted-foreground">
/ {formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)} max
</span>
</div>
)}
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending || isValidatingAudio || (audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS)}
className="flex items-center gap-2 w-full"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
</>
)}
</div>
</FormControl>
<FormDescription>
Supported formats: WAV, MP3, M4A. Maximum duration:{' '}
{formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}. Click "Transcribe"
to automatically extract text from the audio.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="record" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<FormItem>
<FormLabel>Record Audio</FormLabel>
<FormControl>
<div className="space-y-4">
{!isRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-dashed rounded-lg">
<Button
type="button"
onClick={startRecording}
size="lg"
className="flex items-center gap-2"
>
<Mic className="h-5 w-5" />
Start Recording
</Button>
<p className="text-sm text-muted-foreground text-center">
Click to start recording. Maximum duration: 30 seconds.
</p>
</div>
)}
{isRecording && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-lg font-mono font-semibold">
{formatAudioDuration(duration)}
</span>
</div>
</div>
<Button
type="button"
onClick={stopRecording}
variant="destructive"
className="flex items-center gap-2"
>
<Square className="h-4 w-4" />
Stop Recording
</Button>
<p className="text-sm text-muted-foreground text-center">
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
remaining)
</p>
</div>
)}
{selectedFile && !isRecording && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5">
<div className="flex items-center gap-2">
<Mic className="h-5 w-5 text-primary" />
<span className="font-medium">Recording complete</span>
</div>
<p className="text-sm text-muted-foreground text-center">
File: {selectedFile.name}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCancelRecording}
className="flex items-center gap-2"
>
Record Again
</Button>
</div>
</div>
)}
</div>
</FormControl>
<FormDescription>
Record audio directly from your microphone. Maximum duration is 30
seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<FormField
control={form.control}
name="referenceText"
render={({ field }) => (
<FormItem>
<FormLabel>Reference Text</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the exact text spoken in the audio..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormDescription>
This should match exactly what is spoken in the audio file. Required if
you add a sample.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={createProfile.isPending || updateProfile.isPending}> <Button
{createProfile.isPending || updateProfile.isPending type="submit"
disabled={createProfile.isPending || updateProfile.isPending || addSample.isPending}
>
{createProfile.isPending || updateProfile.isPending || addSample.isPending
? 'Saving...' ? 'Saving...'
: editingProfileId : editingProfileId
? 'Update Profile' ? 'Update Profile'
@@ -1,4 +1,4 @@
import { Mic, Plus } from 'lucide-react'; import { Mic, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useProfiles } from '@/lib/hooks/useProfiles'; import { useProfiles } from '@/lib/hooks/useProfiles';
@@ -26,36 +26,40 @@ export function ProfileList() {
); );
} }
const allProfiles = profiles || [];
return ( return (
<div className="space-y-4"> <div className="flex flex-col">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-4 shrink-0">
<h2 className="text-2xl font-bold">Voice Profiles</h2> <h2 className="text-2xl font-bold">Voicebox</h2>
<Button onClick={() => setDialogOpen(true)}> <Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> <Sparkles className="mr-2 h-4 w-4" />
New Profile New Profile
</Button> </Button>
</div> </div>
{profiles && profiles.length === 0 ? ( <div className="shrink-0">
<Card> {allProfiles.length === 0 ? (
<CardContent className="flex flex-col items-center justify-center py-12"> <Card>
<Mic className="h-12 w-12 text-muted-foreground mb-4" /> <CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground mb-4"> <Mic className="h-12 w-12 text-muted-foreground mb-4" />
No voice profiles yet. Create your first profile to get started. <p className="text-muted-foreground mb-4">
</p> No voice profiles yet. Create your first profile to get started.
<Button onClick={() => setDialogOpen(true)}> </p>
<Plus className="mr-2 h-4 w-4" /> <Button onClick={() => setDialogOpen(true)}>
Create Profile <Sparkles className="mr-2 h-4 w-4" />
</Button> Create Profile
</CardContent> </Button>
</Card> </CardContent>
) : ( </Card>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> ) : (
{profiles?.map((profile) => ( <div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
<ProfileCard key={profile.id} profile={profile} /> {allProfiles.map((profile) => (
))} <ProfileCard key={profile.id} profile={profile} />
</div> ))}
)} </div>
)}
</div>
<ProfileForm /> <ProfileForm />
</div> </div>
+14 -11
View File
@@ -1,9 +1,10 @@
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2, Play } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles'; import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
import { usePlayerStore } from '@/stores/playerStore';
import { SampleUpload } from './SampleUpload'; import { SampleUpload } from './SampleUpload';
interface SampleListProps { interface SampleListProps {
@@ -16,6 +17,9 @@ export function SampleList({ profileId }: SampleListProps) {
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const serverUrl = useServerStore((state) => state.serverUrl); const serverUrl = useServerStore((state) => state.serverUrl);
const setAudio = usePlayerStore((state) => state.setAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
const isPlaying = usePlayerStore((state) => state.isPlaying);
const handleDelete = (sampleId: string) => { const handleDelete = (sampleId: string) => {
if (confirm('Are you sure you want to delete this sample?')) { if (confirm('Are you sure you want to delete this sample?')) {
@@ -23,16 +27,9 @@ export function SampleList({ profileId }: SampleListProps) {
} }
}; };
const handlePlay = (audioPath: string) => { const handlePlay = (audioPath: string, referenceText: string, sampleId: string) => {
const audioUrl = `${serverUrl}${audioPath}`; const audioUrl = `${serverUrl}${audioPath}`;
const audio = new Audio(audioUrl); setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
audio.play().catch((_error) => {
toast({
title: 'Error',
description: 'Failed to play audio',
variant: 'destructive',
});
});
}; };
if (isLoading) { if (isLoading) {
@@ -65,7 +62,13 @@ export function SampleList({ profileId }: SampleListProps) {
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p> <p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => handlePlay(sample.audio_path)}> <Button
variant="ghost"
size="sm"
onClick={() => handlePlay(sample.audio_path, sample.reference_text, sample.id)}
className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
>
<Play className="h-4 w-4 mr-1" />
Play Play
</Button> </Button>
<Button <Button
+245 -31
View File
@@ -1,5 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useState, useEffect } from 'react';
import * as z from 'zod'; import * as z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -20,8 +21,13 @@ import {
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useAddSample } from '@/lib/hooks/useProfiles'; import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { Mic, Square, Upload } from 'lucide-react';
import { formatAudioDuration } from '@/lib/utils/audio';
const sampleSchema = z.object({ const sampleSchema = z.object({
file: z.instanceof(File, { message: 'Please select an audio file' }), file: z.instanceof(File, { message: 'Please select an audio file' }),
@@ -41,7 +47,10 @@ interface SampleUploadProps {
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) { export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
const addSample = useAddSample(); const addSample = useAddSample();
const transcribe = useTranscription();
const { data: profile } = useProfile(profileId);
const { toast } = useToast(); const { toast } = useToast();
const [mode, setMode] = useState<'upload' | 'record'>('upload');
const form = useForm<SampleFormValues>({ const form = useForm<SampleFormValues>({
resolver: zodResolver(sampleSchema), resolver: zodResolver(sampleSchema),
@@ -50,6 +59,71 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
}, },
}); });
const selectedFile = form.watch('file');
const {
isRecording,
duration,
error: recordingError,
startRecording,
stopRecording,
cancelRecording,
} = useAudioRecording({
maxDurationSeconds: 30,
onRecordingComplete: (blob) => {
// Convert blob to File object
const file = new File([blob], `recording-${Date.now()}.webm`, {
type: blob.type || 'audio/webm',
});
form.setValue('file', file, { shouldValidate: true });
toast({
title: 'Recording complete',
description: 'Audio has been recorded successfully.',
});
},
});
// Show recording errors
useEffect(() => {
if (recordingError) {
toast({
title: 'Recording error',
description: recordingError,
variant: 'destructive',
});
}
}, [recordingError, toast]);
async function handleTranscribe() {
const file = form.getValues('file');
if (!file) {
toast({
title: 'No file selected',
description: 'Please select an audio file first.',
variant: 'destructive',
});
return;
}
try {
const language = profile?.language as 'en' | 'zh' | undefined;
const result = await transcribe.mutateAsync({ file, language });
form.setValue('referenceText', result.text, { shouldValidate: true });
toast({
title: 'Transcription complete',
description: 'Audio has been transcribed successfully.',
});
} catch (error) {
toast({
title: 'Transcription failed',
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
variant: 'destructive',
});
}
}
async function onSubmit(data: SampleFormValues) { async function onSubmit(data: SampleFormValues) {
try { try {
await addSample.mutateAsync({ await addSample.mutateAsync({
@@ -63,8 +137,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
description: 'Audio sample has been added successfully.', description: 'Audio sample has been added successfully.',
}); });
form.reset(); handleOpenChange(false);
onOpenChange(false);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Error', title: 'Error',
@@ -74,8 +147,25 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
} }
} }
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
form.reset();
setMode('upload');
if (isRecording) {
cancelRecording();
}
}
onOpenChange(newOpen);
}
function handleCancelRecording() {
cancelRecording();
// Reset file field by clearing the input
form.resetField('file');
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Add Audio Sample</DialogTitle> <DialogTitle>Add Audio Sample</DialogTitle>
@@ -86,32 +176,156 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
control={form.control} <TabsList className="grid w-full grid-cols-2">
name="file" <TabsTrigger value="upload" className="flex items-center gap-2">
render={({ field: { onChange, value, ...field } }) => ( <Upload className="h-4 w-4" />
<FormItem> Upload
<FormLabel>Audio File</FormLabel> </TabsTrigger>
<FormControl> <TabsTrigger value="record" className="flex items-center gap-2">
<div className="flex items-center gap-2"> <Mic className="h-4 w-4" />
<Input Record
type="file" </TabsTrigger>
accept="audio/*" </TabsList>
onChange={(e) => {
const file = e.target.files?.[0]; <TabsContent value="upload" className="space-y-4">
if (file) { <FormField
onChange(file); control={form.control}
} name="file"
}} render={({ field: { onChange, value, ...field } }) => (
{...field} <FormItem>
/> <FormLabel>Audio File</FormLabel>
</div> <FormControl>
</FormControl> <div className="flex items-center gap-2">
<FormDescription>Supported formats: WAV, MP3, M4A</FormDescription> <Input
<FormMessage /> type="file"
</FormItem> accept="audio/*"
)} onChange={(e) => {
/> const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
{...field}
/>
{selectedFile && (
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
)}
</div>
</FormControl>
<FormDescription>
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically
extract text from the audio.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="record" className="space-y-4">
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormLabel>Record Audio</FormLabel>
<FormControl>
<div className="space-y-4">
{!isRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg">
<Button
type="button"
onClick={startRecording}
size="lg"
className="flex items-center gap-2"
>
<Mic className="h-5 w-5" />
Start Recording
</Button>
<p className="text-sm text-muted-foreground text-center">
Click to start recording. Maximum duration: 30 seconds.
</p>
</div>
)}
{isRecording && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-lg font-mono font-semibold">
{formatAudioDuration(duration)}
</span>
</div>
</div>
<Button
type="button"
onClick={stopRecording}
variant="destructive"
className="flex items-center gap-2"
>
<Square className="h-4 w-4" />
Stop Recording
</Button>
<p className="text-sm text-muted-foreground text-center">
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
remaining)
</p>
</div>
)}
{selectedFile && !isRecording && (
<div className="flex flex-col items-center gap-4 p-6 border-2 border-primary rounded-lg bg-primary/5">
<div className="flex items-center gap-2">
<Mic className="h-5 w-5 text-primary" />
<span className="font-medium">Recording complete</span>
</div>
<p className="text-sm text-muted-foreground">
File: {selectedFile.name}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCancelRecording}
className="flex items-center gap-2"
>
Record Again
</Button>
</div>
</div>
)}
</div>
</FormControl>
<FormDescription>
Record audio directly from your microphone. Maximum duration is 30 seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<FormField <FormField
control={form.control} control={form.control}
@@ -135,7 +349,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
/> />
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={addSample.isPending}> <Button type="submit" disabled={addSample.isPending}>
+6 -6
View File
@@ -4,22 +4,22 @@ import * as React from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: 'bg-accent text-accent-foreground hover:bg-accent/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-accent underline-offset-4 hover:underline',
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3', sm: 'h-9 rounded-full px-3',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-full px-8',
icon: 'h-10 w-10', icon: 'h-10 w-10 rounded-full',
}, },
}, },
defaultVariants: { defaultVariants: {
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
onCheckedChange?: (checked: boolean) => void;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onCheckedChange) {
onCheckedChange(e.target.checked);
}
// Call original onChange if provided
if (props.onChange) {
props.onChange(e);
}
};
return (
<input
type="checkbox"
className={cn(
'h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
onChange={handleChange}
{...props}
/>
);
},
);
Checkbox.displayName = 'Checkbox';
export { Checkbox };
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: React.ComponentType<{ className?: string }>;
}
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
({ className, icon: Icon, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
'hover:bg-muted transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
className,
)}
{...props}
>
<Icon className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
);
},
);
CircleButton.displayName = 'CircleButton';
export { CircleButton };
+177
View File
@@ -0,0 +1,177 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<MoreHorizontal className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<MoreHorizontal className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils/cn';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 translate-x-0.5" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import { check, type Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
export interface UpdateStatus {
checking: boolean;
available: boolean;
version?: string;
downloading: boolean;
installing: boolean;
error?: string;
}
const isTauri = () => {
return '__TAURI_INTERNALS__' in window;
};
export function useAutoUpdater(checkOnMount = false) {
const [status, setStatus] = useState<UpdateStatus>({
checking: false,
available: false,
downloading: false,
installing: false,
});
const [update, setUpdate] = useState<Update | null>(null);
const checkForUpdates = async () => {
if (!isTauri()) {
return;
}
try {
setStatus((prev) => ({ ...prev, checking: true, error: undefined }));
const foundUpdate = await check();
if (foundUpdate?.available) {
setUpdate(foundUpdate);
setStatus({
checking: false,
available: true,
version: foundUpdate.version,
downloading: false,
installing: false,
});
} else {
setStatus({
checking: false,
available: false,
downloading: false,
installing: false,
});
}
} catch (error) {
setStatus({
checking: false,
available: false,
downloading: false,
installing: false,
error: error instanceof Error ? error.message : 'Failed to check for updates',
});
}
};
const downloadAndInstall = async () => {
if (!update || !isTauri()) return;
try {
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
setStatus((prev) => ({ ...prev, downloading: true }));
break;
case 'Progress':
console.log(`Downloaded ${event.data.chunkLength} bytes`);
break;
case 'Finished':
setStatus((prev) => ({
...prev,
downloading: false,
installing: true,
}));
break;
}
});
await relaunch();
} catch (error) {
setStatus((prev) => ({
...prev,
downloading: false,
installing: false,
error: error instanceof Error ? error.message : 'Failed to install update',
}));
}
};
useEffect(() => {
if (checkOnMount && isTauri()) {
checkForUpdates();
}
}, [checkOnMount]);
return {
status,
checkForUpdates,
downloadAndInstall,
};
}
+31 -19
View File
@@ -42,26 +42,26 @@
} }
:root { :root {
--background: 0 0% 100%; --background: 0 0% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 222.2 84% 4.9%;
--card: 0 0% 100%; --card: 0 0% 97%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%; --popover: 0 0% 97%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%; --primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%; --secondary: 210 40% 92%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%; --muted: 210 40% 90%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%; --accent: 43 50% 50%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%; --border: 214.3 31.8% 85%;
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 88%;
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
--sidebar: 0 0% 98%; --sidebar: 0 0% 92%;
--radius: 0.5rem; --radius: 0.5rem;
--chart-1: 12 76% 61%; --chart-1: 12 76% 61%;
--chart-2: 173 58% 39%; --chart-2: 173 58% 39%;
@@ -71,26 +71,26 @@
} }
.dark { .dark {
--background: 0 0% 8%; --background: 0 0% 6%;
--foreground: 0 0% 95%; --foreground: 0 0% 95%;
--card: 0 0% 10%; --card: 0 0% 8%;
--card-foreground: 0 0% 95%; --card-foreground: 0 0% 95%;
--popover: 0 0% 10%; --popover: 0 0% 8%;
--popover-foreground: 0 0% 95%; --popover-foreground: 0 0% 95%;
--primary: 0 0% 20%; --primary: 0 0% 18%;
--primary-foreground: 0 0% 95%; --primary-foreground: 0 0% 95%;
--secondary: 0 0% 15%; --secondary: 0 0% 12%;
--secondary-foreground: 0 0% 95%; --secondary-foreground: 0 0% 95%;
--muted: 0 0% 15%; --muted: 0 0% 12%;
--muted-foreground: 0 0% 60%; --muted-foreground: 0 0% 60%;
--accent: 0 0% 15%; --accent: 43 50% 45%;
--accent-foreground: 0 0% 95%; --accent-foreground: 0 0% 95%;
--destructive: 0 62.8% 50%; --destructive: 0 62.8% 50%;
--destructive-foreground: 0 0% 95%; --destructive-foreground: 0 0% 95%;
--border: 0 0% 15%; --border: 0 0% 12%;
--input: 0 0% 15%; --input: 0 0% 12%;
--ring: 0 0% 40%; --ring: 0 0% 40%;
--sidebar: 0 0% 6%; --sidebar: 0 0% 4%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;
@@ -102,9 +102,21 @@
* { * {
@apply border-border; @apply border-border;
} }
html,
body {
@apply overflow-hidden;
}
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/* Hide scrollbars globally */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
} }
@layer utilities { @layer utilities {
+6 -21
View File
@@ -20,10 +20,7 @@ class ApiClient {
return serverUrl; return serverUrl;
} }
private async request<T>( private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
endpoint: string,
options?: RequestInit,
): Promise<T> {
const url = `${this.getBaseUrl()}${endpoint}`; const url = `${this.getBaseUrl()}${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
@@ -64,10 +61,7 @@ class ApiClient {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`); return this.request<VoiceProfileResponse>(`/profiles/${profileId}`);
} }
async updateProfile( async updateProfile(profileId: string, data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
profileId: string,
data: VoiceProfileCreate,
): Promise<VoiceProfileResponse> {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, { return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -105,12 +99,8 @@ class ApiClient {
return response.json(); return response.json();
} }
async listProfileSamples( async listProfileSamples(profileId: string): Promise<ProfileSampleResponse[]> {
profileId: string, return this.request<ProfileSampleResponse[]>(`/profiles/${profileId}/samples`);
): Promise<ProfileSampleResponse[]> {
return this.request<ProfileSampleResponse[]>(
`/profiles/${profileId}/samples`,
);
} }
async deleteProfileSample(sampleId: string): Promise<void> { async deleteProfileSample(sampleId: string): Promise<void> {
@@ -120,9 +110,7 @@ class ApiClient {
} }
// Generation // Generation
async generateSpeech( async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
data: GenerationRequest,
): Promise<GenerationResponse> {
return this.request<GenerationResponse>('/generate', { return this.request<GenerationResponse>('/generate', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -159,10 +147,7 @@ class ApiClient {
} }
// Transcription // Transcription
async transcribeAudio( async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> {
file: File,
language?: 'en' | 'zh',
): Promise<TranscriptionResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (language) { if (language) {
+14 -14
View File
@@ -6,20 +6,20 @@ import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult'; import type { ApiResult } from './ApiResult';
export class ApiError extends Error { export class ApiError extends Error {
public readonly url: string; public readonly url: string;
public readonly status: number; public readonly status: number;
public readonly statusText: string; public readonly statusText: string;
public readonly body: any; public readonly body: any;
public readonly request: ApiRequestOptions; public readonly request: ApiRequestOptions;
constructor(request: ApiRequestOptions, response: ApiResult, message: string) { constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
this.url = response.url; this.url = response.url;
this.status = response.status; this.status = response.status;
this.statusText = response.statusText; this.statusText = response.statusText;
this.body = response.body; this.body = response.body;
this.request = request; this.request = request;
} }
} }
+11 -11
View File
@@ -3,15 +3,15 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type ApiRequestOptions = { export type ApiRequestOptions = {
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
readonly url: string; readonly url: string;
readonly path?: Record<string, any>; readonly path?: Record<string, any>;
readonly cookies?: Record<string, any>; readonly cookies?: Record<string, any>;
readonly headers?: Record<string, any>; readonly headers?: Record<string, any>;
readonly query?: Record<string, any>; readonly query?: Record<string, any>;
readonly formData?: Record<string, any>; readonly formData?: Record<string, any>;
readonly body?: any; readonly body?: any;
readonly mediaType?: string; readonly mediaType?: string;
readonly responseHeader?: string; readonly responseHeader?: string;
readonly errors?: Record<number, string>; readonly errors?: Record<number, string>;
}; };
+5 -5
View File
@@ -3,9 +3,9 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type ApiResult = { export type ApiResult = {
readonly url: string; readonly url: string;
readonly ok: boolean; readonly ok: boolean;
readonly status: number; readonly status: number;
readonly statusText: string; readonly statusText: string;
readonly body: any; readonly body: any;
}; };
+110 -111
View File
@@ -3,129 +3,128 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export class CancelError extends Error { export class CancelError extends Error {
constructor(message: string) {
super(message);
this.name = 'CancelError';
}
constructor(message: string) { public get isCancelled(): boolean {
super(message); return true;
this.name = 'CancelError'; }
}
public get isCancelled(): boolean {
return true;
}
} }
export interface OnCancel { export interface OnCancel {
readonly isResolved: boolean; readonly isResolved: boolean;
readonly isRejected: boolean; readonly isRejected: boolean;
readonly isCancelled: boolean; readonly isCancelled: boolean;
(cancelHandler: () => void): void; (cancelHandler: () => void): void;
} }
export class CancelablePromise<T> implements Promise<T> { export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean; #isResolved: boolean;
#isRejected: boolean; #isRejected: boolean;
#isCancelled: boolean; #isCancelled: boolean;
readonly #cancelHandlers: (() => void)[]; readonly #cancelHandlers: (() => void)[];
readonly #promise: Promise<T>; readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void; #resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void; #reject?: (reason?: any) => void;
constructor( constructor(
executor: ( executor: (
resolve: (value: T | PromiseLike<T>) => void, resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void, reject: (reason?: any) => void,
onCancel: OnCancel onCancel: OnCancel,
) => void ) => void,
) { ) {
this.#isResolved = false; this.#isResolved = false;
this.#isRejected = false; this.#isRejected = false;
this.#isCancelled = false; this.#isCancelled = false;
this.#cancelHandlers = []; this.#cancelHandlers = [];
this.#promise = new Promise<T>((resolve, reject) => { this.#promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve; this.#resolve = resolve;
this.#reject = reject; this.#reject = reject;
const onResolve = (value: T | PromiseLike<T>): void => { const onResolve = (value: T | PromiseLike<T>): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isResolved = true;
if (this.#resolve) this.#resolve(value);
};
const onReject = (reason?: any): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
if (this.#reject) this.#reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return "Cancellable Promise";
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) { if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return; return;
} }
this.#isCancelled = true; this.#isResolved = true;
if (this.#cancelHandlers.length) { if (this.#resolve) this.#resolve(value);
try { };
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
}
public get isCancelled(): boolean { const onReject = (reason?: any): void => {
return this.#isCancelled; if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
if (this.#reject) this.#reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return 'Cancellable Promise';
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
} }
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
}
public get isCancelled(): boolean {
return this.#isCancelled;
}
} }
+18 -18
View File
@@ -8,25 +8,25 @@ type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
type Headers = Record<string, string>; type Headers = Record<string, string>;
export type OpenAPIConfig = { export type OpenAPIConfig = {
BASE: string; BASE: string;
VERSION: string; VERSION: string;
WITH_CREDENTIALS: boolean; WITH_CREDENTIALS: boolean;
CREDENTIALS: 'include' | 'omit' | 'same-origin'; CREDENTIALS: 'include' | 'omit' | 'same-origin';
TOKEN?: string | Resolver<string> | undefined; TOKEN?: string | Resolver<string> | undefined;
USERNAME?: string | Resolver<string> | undefined; USERNAME?: string | Resolver<string> | undefined;
PASSWORD?: string | Resolver<string> | undefined; PASSWORD?: string | Resolver<string> | undefined;
HEADERS?: Headers | Resolver<Headers> | undefined; HEADERS?: Headers | Resolver<Headers> | undefined;
ENCODE_PATH?: ((path: string) => string) | undefined; ENCODE_PATH?: ((path: string) => string) | undefined;
}; };
export const OpenAPI: OpenAPIConfig = { export const OpenAPI: OpenAPIConfig = {
BASE: '', BASE: '',
VERSION: '0.1.0', VERSION: '0.1.0',
WITH_CREDENTIALS: false, WITH_CREDENTIALS: false,
CREDENTIALS: 'include', CREDENTIALS: 'include',
TOKEN: undefined, TOKEN: undefined,
USERNAME: undefined, USERNAME: undefined,
PASSWORD: undefined, PASSWORD: undefined,
HEADERS: undefined, HEADERS: undefined,
ENCODE_PATH: undefined, ENCODE_PATH: undefined,
}; };
+247 -228
View File
@@ -9,278 +9,294 @@ import { CancelablePromise } from './CancelablePromise';
import type { OnCancel } from './CancelablePromise'; import type { OnCancel } from './CancelablePromise';
import type { OpenAPIConfig } from './OpenAPI'; import type { OpenAPIConfig } from './OpenAPI';
export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { export const isDefined = <T>(
return value !== undefined && value !== null; value: T | null | undefined,
): value is Exclude<T, null | undefined> => {
return value !== undefined && value !== null;
}; };
export const isString = (value: any): value is string => { export const isString = (value: any): value is string => {
return typeof value === 'string'; return typeof value === 'string';
}; };
export const isStringWithValue = (value: any): value is string => { export const isStringWithValue = (value: any): value is string => {
return isString(value) && value !== ''; return isString(value) && value !== '';
}; };
export const isBlob = (value: any): value is Blob => { export const isBlob = (value: any): value is Blob => {
return ( return (
typeof value === 'object' && typeof value === 'object' &&
typeof value.type === 'string' && typeof value.type === 'string' &&
typeof value.stream === 'function' && typeof value.stream === 'function' &&
typeof value.arrayBuffer === 'function' && typeof value.arrayBuffer === 'function' &&
typeof value.constructor === 'function' && typeof value.constructor === 'function' &&
typeof value.constructor.name === 'string' && typeof value.constructor.name === 'string' &&
/^(Blob|File)$/.test(value.constructor.name) && /^(Blob|File)$/.test(value.constructor.name) &&
/^(Blob|File)$/.test(value[Symbol.toStringTag]) /^(Blob|File)$/.test(value[Symbol.toStringTag])
); );
}; };
export const isFormData = (value: any): value is FormData => { export const isFormData = (value: any): value is FormData => {
return value instanceof FormData; return value instanceof FormData;
}; };
export const base64 = (str: string): string => { export const base64 = (str: string): string => {
try { try {
return btoa(str); return btoa(str);
} catch (err) { } catch (err) {
// @ts-ignore // @ts-ignore
return Buffer.from(str).toString('base64'); return Buffer.from(str).toString('base64');
} }
}; };
export const getQueryString = (params: Record<string, any>): string => { export const getQueryString = (params: Record<string, any>): string => {
const qs: string[] = []; const qs: string[] = [];
const append = (key: string, value: any) => { const append = (key: string, value: any) => {
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}; };
const process = (key: string, value: any) => { const process = (key: string, value: any) => {
if (isDefined(value)) { if (isDefined(value)) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(v => { value.forEach((v) => {
process(key, v); process(key, v);
}); });
} else if (typeof value === 'object') { } else if (typeof value === 'object') {
Object.entries(value).forEach(([k, v]) => { Object.entries(value).forEach(([k, v]) => {
process(`${key}[${k}]`, v); process(`${key}[${k}]`, v);
}); });
} else { } else {
append(key, value); append(key, value);
} }
}
};
Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join('&')}`;
} }
};
return ''; Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join('&')}`;
}
return '';
}; };
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
const encoder = config.ENCODE_PATH || encodeURI; const encoder = config.ENCODE_PATH || encodeURI;
const path = options.url const path = options.url
.replace('{api-version}', config.VERSION) .replace('{api-version}', config.VERSION)
.replace(/{(.*?)}/g, (substring: string, group: string) => { .replace(/{(.*?)}/g, (substring: string, group: string) => {
if (options.path?.hasOwnProperty(group)) { if (options.path?.hasOwnProperty(group)) {
return encoder(String(options.path[group])); return encoder(String(options.path[group]));
} }
return substring; return substring;
}); });
const url = `${config.BASE}${path}`; const url = `${config.BASE}${path}`;
if (options.query) { if (options.query) {
return `${url}${getQueryString(options.query)}`; return `${url}${getQueryString(options.query)}`;
} }
return url; return url;
}; };
export const getFormData = (options: ApiRequestOptions): FormData | undefined => { export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
if (options.formData) { if (options.formData) {
const formData = new FormData(); const formData = new FormData();
const process = (key: string, value: any) => { const process = (key: string, value: any) => {
if (isString(value) || isBlob(value)) { if (isString(value) || isBlob(value)) {
formData.append(key, value); formData.append(key, value);
} else { } else {
formData.append(key, JSON.stringify(value)); formData.append(key, JSON.stringify(value));
} }
}; };
Object.entries(options.formData) Object.entries(options.formData)
.filter(([_, value]) => isDefined(value)) .filter(([_, value]) => isDefined(value))
.forEach(([key, value]) => { .forEach(([key, value]) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(v => process(key, v)); value.forEach((v) => process(key, v));
} else { } else {
process(key, value); process(key, value);
} }
}); });
return formData; return formData;
} }
return undefined; return undefined;
}; };
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { export const resolve = async <T>(
if (typeof resolver === 'function') { options: ApiRequestOptions,
return (resolver as Resolver<T>)(options); resolver?: T | Resolver<T>,
} ): Promise<T | undefined> => {
return resolver; if (typeof resolver === 'function') {
return (resolver as Resolver<T>)(options);
}
return resolver;
}; };
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise<Headers> => { export const getHeaders = async (
const [token, username, password, additionalHeaders] = await Promise.all([ config: OpenAPIConfig,
resolve(options, config.TOKEN), options: ApiRequestOptions,
resolve(options, config.USERNAME), ): Promise<Headers> => {
resolve(options, config.PASSWORD), const [token, username, password, additionalHeaders] = await Promise.all([
resolve(options, config.HEADERS), resolve(options, config.TOKEN),
]); resolve(options, config.USERNAME),
resolve(options, config.PASSWORD),
resolve(options, config.HEADERS),
]);
const headers = Object.entries({ const headers = Object.entries({
Accept: 'application/json', Accept: 'application/json',
...additionalHeaders, ...additionalHeaders,
...options.headers, ...options.headers,
}) })
.filter(([_, value]) => isDefined(value)) .filter(([_, value]) => isDefined(value))
.reduce((headers, [key, value]) => ({ .reduce(
...headers, (headers, [key, value]) => ({
[key]: String(value), ...headers,
}), {} as Record<string, string>); [key]: String(value),
}),
{} as Record<string, string>,
);
if (isStringWithValue(token)) { if (isStringWithValue(token)) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
}
if (isStringWithValue(username) && isStringWithValue(password)) {
const credentials = base64(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers['Content-Type'] = options.mediaType;
} else if (isBlob(options.body)) {
headers['Content-Type'] = options.body.type || 'application/octet-stream';
} else if (isString(options.body)) {
headers['Content-Type'] = 'text/plain';
} else if (!isFormData(options.body)) {
headers['Content-Type'] = 'application/json';
} }
}
if (isStringWithValue(username) && isStringWithValue(password)) { return new Headers(headers);
const credentials = base64(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers['Content-Type'] = options.mediaType;
} else if (isBlob(options.body)) {
headers['Content-Type'] = options.body.type || 'application/octet-stream';
} else if (isString(options.body)) {
headers['Content-Type'] = 'text/plain';
} else if (!isFormData(options.body)) {
headers['Content-Type'] = 'application/json';
}
}
return new Headers(headers);
}; };
export const getRequestBody = (options: ApiRequestOptions): any => { export const getRequestBody = (options: ApiRequestOptions): any => {
if (options.body !== undefined) { if (options.body !== undefined) {
if (options.mediaType?.includes('/json')) { if (options.mediaType?.includes('/json')) {
return JSON.stringify(options.body) return JSON.stringify(options.body);
} else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) {
return options.body; return options.body;
} else { } else {
return JSON.stringify(options.body); return JSON.stringify(options.body);
}
} }
return undefined; }
return undefined;
}; };
export const sendRequest = async ( export const sendRequest = async (
config: OpenAPIConfig, config: OpenAPIConfig,
options: ApiRequestOptions, options: ApiRequestOptions,
url: string, url: string,
body: any, body: any,
formData: FormData | undefined, formData: FormData | undefined,
headers: Headers, headers: Headers,
onCancel: OnCancel onCancel: OnCancel,
): Promise<Response> => { ): Promise<Response> => {
const controller = new AbortController(); const controller = new AbortController();
const request: RequestInit = { const request: RequestInit = {
headers, headers,
body: body ?? formData, body: body ?? formData,
method: options.method, method: options.method,
signal: controller.signal, signal: controller.signal,
}; };
if (config.WITH_CREDENTIALS) { if (config.WITH_CREDENTIALS) {
request.credentials = config.CREDENTIALS; request.credentials = config.CREDENTIALS;
} }
onCancel(() => controller.abort()); onCancel(() => controller.abort());
return await fetch(url, request); return await fetch(url, request);
}; };
export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { export const getResponseHeader = (
if (responseHeader) { response: Response,
const content = response.headers.get(responseHeader); responseHeader?: string,
if (isString(content)) { ): string | undefined => {
return content; if (responseHeader) {
} const content = response.headers.get(responseHeader);
if (isString(content)) {
return content;
} }
return undefined; }
return undefined;
}; };
export const getResponseBody = async (response: Response): Promise<any> => { export const getResponseBody = async (response: Response): Promise<any> => {
if (response.status !== 204) { if (response.status !== 204) {
try { try {
const contentType = response.headers.get('Content-Type'); const contentType = response.headers.get('Content-Type');
if (contentType) { if (contentType) {
const jsonTypes = ['application/json', 'application/problem+json'] const jsonTypes = ['application/json', 'application/problem+json'];
const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type)); const isJSON = jsonTypes.some((type) => contentType.toLowerCase().startsWith(type));
if (isJSON) { if (isJSON) {
return await response.json(); return await response.json();
} else { } else {
return await response.text(); return await response.text();
}
}
} catch (error) {
console.error(error);
} }
}
} catch (error) {
console.error(error);
} }
return undefined; }
return undefined;
}; };
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
const errors: Record<number, string> = { const errors: Record<number, string> = {
400: 'Bad Request', 400: 'Bad Request',
401: 'Unauthorized', 401: 'Unauthorized',
403: 'Forbidden', 403: 'Forbidden',
404: 'Not Found', 404: 'Not Found',
500: 'Internal Server Error', 500: 'Internal Server Error',
502: 'Bad Gateway', 502: 'Bad Gateway',
503: 'Service Unavailable', 503: 'Service Unavailable',
...options.errors, ...options.errors,
} };
const error = errors[result.status]; const error = errors[result.status];
if (error) { if (error) {
throw new ApiError(options, result, error); throw new ApiError(options, result, error);
} }
if (!result.ok) { if (!result.ok) {
const errorStatus = result.status ?? 'unknown'; const errorStatus = result.status ?? 'unknown';
const errorStatusText = result.statusText ?? 'unknown'; const errorStatusText = result.statusText ?? 'unknown';
const errorBody = (() => { const errorBody = (() => {
try { try {
return JSON.stringify(result.body, null, 2); return JSON.stringify(result.body, null, 2);
} catch (e) { } catch (e) {
return undefined; return undefined;
} }
})(); })();
throw new ApiError(options, result, throw new ApiError(
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` options,
); result,
} `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
);
}
}; };
/** /**
@@ -290,33 +306,36 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult):
* @returns CancelablePromise<T> * @returns CancelablePromise<T>
* @throws ApiError * @throws ApiError
*/ */
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise<T> => { export const request = <T>(
return new CancelablePromise(async (resolve, reject, onCancel) => { config: OpenAPIConfig,
try { options: ApiRequestOptions,
const url = getUrl(config, options); ): CancelablePromise<T> => {
const formData = getFormData(options); return new CancelablePromise(async (resolve, reject, onCancel) => {
const body = getRequestBody(options); try {
const headers = await getHeaders(config, options); const url = getUrl(config, options);
const formData = getFormData(options);
const body = getRequestBody(options);
const headers = await getHeaders(config, options);
if (!onCancel.isCancelled) { if (!onCancel.isCancelled) {
const response = await sendRequest(config, options, url, body, formData, headers, onCancel); const response = await sendRequest(config, options, url, body, formData, headers, onCancel);
const responseBody = await getResponseBody(response); const responseBody = await getResponseBody(response);
const responseHeader = getResponseHeader(response, options.responseHeader); const responseHeader = getResponseHeader(response, options.responseHeader);
const result: ApiResult = { const result: ApiResult = {
url, url,
ok: response.ok, ok: response.ok,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
body: responseHeader ?? responseBody, body: responseHeader ?? responseBody,
}; };
catchErrorCodes(options, result); catchErrorCodes(options, result);
resolve(result.body); resolve(result.body);
} }
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
}); });
}; };
@@ -3,7 +3,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type Body_add_profile_sample_profiles__profile_id__samples_post = { export type Body_add_profile_sample_profiles__profile_id__samples_post = {
file: Blob; file: Blob;
reference_text: string; reference_text: string;
}; };
@@ -3,7 +3,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type Body_transcribe_audio_transcribe_post = { export type Body_transcribe_audio_transcribe_post = {
file: Blob; file: Blob;
language?: (string | null); language?: string | null;
}; };
+6 -6
View File
@@ -6,10 +6,10 @@
* Request model for voice generation. * Request model for voice generation.
*/ */
export type GenerationRequest = { export type GenerationRequest = {
profile_id: string; profile_id: string;
text: string; text: string;
language?: string; language?: string;
seed?: (number | null); seed?: number | null;
model_size?: (string | null); model_size?: string | null;
instruct?: string | null;
}; };
+9 -9
View File
@@ -6,13 +6,13 @@
* Response model for voice generation. * Response model for voice generation.
*/ */
export type GenerationResponse = { export type GenerationResponse = {
id: string; id: string;
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 | null); seed: number | null;
created_at: string; instruct: string | null;
created_at: string;
}; };
@@ -4,6 +4,5 @@
/* eslint-disable */ /* eslint-disable */
import type { ValidationError } from './ValidationError'; import type { ValidationError } from './ValidationError';
export type HTTPValidationError = { export type HTTPValidationError = {
detail?: Array<ValidationError>; detail?: Array<ValidationError>;
}; };
+6 -7
View File
@@ -6,11 +6,10 @@
* Response model for health check. * Response model for health check.
*/ */
export type HealthResponse = { export type HealthResponse = {
status: string; status: string;
model_loaded: boolean; model_loaded: boolean;
model_downloaded?: (boolean | null); model_downloaded?: boolean | null;
model_size?: (string | null); model_size?: string | null;
gpu_available: boolean; gpu_available: boolean;
vram_used_mb?: (number | null); vram_used_mb?: number | null;
}; };
@@ -7,7 +7,6 @@ import type { HistoryResponse } from './HistoryResponse';
* Response model for history list. * Response model for history list.
*/ */
export type HistoryListResponse = { export type HistoryListResponse = {
items: Array<HistoryResponse>; items: Array<HistoryResponse>;
total: number; total: number;
}; };
+10 -10
View File
@@ -6,14 +6,14 @@
* Response model for history entry (includes profile name). * Response model for history entry (includes profile name).
*/ */
export type HistoryResponse = { export type HistoryResponse = {
id: string; id: string;
profile_id: string; profile_id: string;
profile_name: string; profile_name: string;
text: string; text: string;
language: string; language: string;
audio_path: string; audio_path: string;
duration: number; duration: number;
seed: (number | null); seed: number | null;
created_at: string; instruct: string | null;
created_at: string;
}; };
@@ -6,6 +6,5 @@
* Request model for triggering model download. * Request model for triggering model download.
*/ */
export type ModelDownloadRequest = { export type ModelDownloadRequest = {
model_name: string; model_name: string;
}; };
+5 -6
View File
@@ -6,10 +6,9 @@
* Response model for model status. * Response model for model status.
*/ */
export type ModelStatus = { export type ModelStatus = {
model_name: string; model_name: string;
display_name: string; display_name: string;
downloaded: boolean; downloaded: boolean;
size_mb?: (number | null); size_mb?: number | null;
loaded?: boolean; loaded?: boolean;
}; };
@@ -7,6 +7,5 @@ import type { ModelStatus } from './ModelStatus';
* Response model for model status list. * Response model for model status list.
*/ */
export type ModelStatusListResponse = { export type ModelStatusListResponse = {
models: Array<ModelStatus>; models: Array<ModelStatus>;
}; };
@@ -6,9 +6,8 @@
* Response model for profile sample. * Response model for profile sample.
*/ */
export type ProfileSampleResponse = { export type ProfileSampleResponse = {
id: string; id: string;
profile_id: string; profile_id: string;
audio_path: string; audio_path: string;
reference_text: string; reference_text: string;
}; };
@@ -6,7 +6,6 @@
* Response model for transcription. * Response model for transcription.
*/ */
export type TranscriptionResponse = { export type TranscriptionResponse = {
text: string; text: string;
duration: number; duration: number;
}; };
+3 -4
View File
@@ -3,8 +3,7 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type ValidationError = { export type ValidationError = {
loc: Array<(string | number)>; loc: Array<string | number>;
msg: string; msg: string;
type: string; type: string;
}; };
+3 -4
View File
@@ -6,8 +6,7 @@
* Request model for creating a voice profile. * Request model for creating a voice profile.
*/ */
export type VoiceProfileCreate = { export type VoiceProfileCreate = {
name: string; name: string;
description?: (string | null); description?: string | null;
language?: string; language?: string;
}; };
@@ -6,11 +6,10 @@
* Response model for voice profile. * Response model for voice profile.
*/ */
export type VoiceProfileResponse = { export type VoiceProfileResponse = {
id: string; id: string;
name: string; name: string;
description: (string | null); description: string | null;
language: string; language: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -3,15 +3,15 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $Body_add_profile_sample_profiles__profile_id__samples_post = { export const $Body_add_profile_sample_profiles__profile_id__samples_post = {
properties: { properties: {
file: { file: {
type: 'binary', type: 'binary',
isRequired: true, isRequired: true,
format: 'binary', format: 'binary',
},
reference_text: {
type: 'string',
isRequired: true,
},
}, },
reference_text: {
type: 'string',
isRequired: true,
},
},
} as const; } as const;
@@ -3,19 +3,22 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $Body_transcribe_audio_transcribe_post = { export const $Body_transcribe_audio_transcribe_post = {
properties: { properties: {
file: { file: {
type: 'binary', type: 'binary',
isRequired: true, isRequired: true,
format: 'binary', format: 'binary',
},
language: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
}, },
language: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
},
},
} as const; } as const;
+39 -33
View File
@@ -3,38 +3,44 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $GenerationRequest = { export const $GenerationRequest = {
description: `Request model for voice generation.`, description: `Request model for voice generation.`,
properties: { properties: {
profile_id: { profile_id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
text: {
type: 'string',
isRequired: true,
maxLength: 5000,
minLength: 1,
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
seed: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
},
model_size: {
type: 'any-of',
contains: [{
type: 'string',
pattern: '^(1\\.7B|0\\.6B)$',
}, {
type: 'null',
}],
},
}, },
text: {
type: 'string',
isRequired: true,
maxLength: 5000,
minLength: 1,
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
model_size: {
type: 'any-of',
contains: [
{
type: 'string',
pattern: '^(1\\.7B|0\\.6B)$',
},
{
type: 'null',
},
],
},
},
} as const; } as const;
+43 -40
View File
@@ -3,45 +3,48 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $GenerationResponse = { export const $GenerationResponse = {
description: `Response model for voice generation.`, description: `Response model for voice generation.`,
properties: { properties: {
id: { id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
}, },
profile_id: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const; } as const;
@@ -3,12 +3,12 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $HTTPValidationError = { export const $HTTPValidationError = {
properties: { properties: {
detail: { detail: {
type: 'array', type: 'array',
contains: { contains: {
type: 'ValidationError', type: 'ValidationError',
}, },
},
}, },
},
} as const; } as const;
+47 -38
View File
@@ -3,43 +3,52 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $HealthResponse = { export const $HealthResponse = {
description: `Response model for health check.`, description: `Response model for health check.`,
properties: { properties: {
status: { status: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
model_loaded: {
type: 'boolean',
isRequired: true,
},
model_downloaded: {
type: 'any-of',
contains: [{
type: 'boolean',
}, {
type: 'null',
}],
},
model_size: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
gpu_available: {
type: 'boolean',
isRequired: true,
},
vram_used_mb: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
},
}, },
model_loaded: {
type: 'boolean',
isRequired: true,
},
model_downloaded: {
type: 'any-of',
contains: [
{
type: 'boolean',
},
{
type: 'null',
},
],
},
model_size: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
},
gpu_available: {
type: 'boolean',
isRequired: true,
},
vram_used_mb: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
},
} as const; } as const;
+13 -13
View File
@@ -3,18 +3,18 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $HistoryListResponse = { export const $HistoryListResponse = {
description: `Response model for history list.`, description: `Response model for history list.`,
properties: { properties: {
items: { items: {
type: 'array', type: 'array',
contains: { contains: {
type: 'HistoryResponse', type: 'HistoryResponse',
}, },
isRequired: true, isRequired: true,
},
total: {
type: 'number',
isRequired: true,
},
}, },
total: {
type: 'number',
isRequired: true,
},
},
} as const; } as const;
+47 -44
View File
@@ -3,49 +3,52 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $HistoryResponse = { export const $HistoryResponse = {
description: `Response model for history entry (includes profile name).`, description: `Response model for history entry (includes profile name).`,
properties: { properties: {
id: { id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
profile_name: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
}, },
profile_id: {
type: 'string',
isRequired: true,
},
profile_name: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const; } as const;
@@ -3,11 +3,11 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ModelDownloadRequest = { export const $ModelDownloadRequest = {
description: `Request model for triggering model download.`, description: `Request model for triggering model download.`,
properties: { properties: {
model_name: { model_name: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
}, },
},
} as const; } as const;
+28 -25
View File
@@ -3,30 +3,33 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ModelStatus = { export const $ModelStatus = {
description: `Response model for model status.`, description: `Response model for model status.`,
properties: { properties: {
model_name: { model_name: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
display_name: {
type: 'string',
isRequired: true,
},
downloaded: {
type: 'boolean',
isRequired: true,
},
size_mb: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
},
loaded: {
type: 'boolean',
},
}, },
display_name: {
type: 'string',
isRequired: true,
},
downloaded: {
type: 'boolean',
isRequired: true,
},
size_mb: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
loaded: {
type: 'boolean',
},
},
} as const; } as const;
@@ -3,14 +3,14 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ModelStatusListResponse = { export const $ModelStatusListResponse = {
description: `Response model for model status list.`, description: `Response model for model status list.`,
properties: { properties: {
models: { models: {
type: 'array', type: 'array',
contains: { contains: {
type: 'ModelStatus', type: 'ModelStatus',
}, },
isRequired: true, isRequired: true,
},
}, },
},
} as const; } as const;
@@ -3,23 +3,23 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ProfileSampleResponse = { export const $ProfileSampleResponse = {
description: `Response model for profile sample.`, description: `Response model for profile sample.`,
properties: { properties: {
id: { id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
reference_text: {
type: 'string',
isRequired: true,
},
}, },
profile_id: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
reference_text: {
type: 'string',
isRequired: true,
},
},
} as const; } as const;
@@ -3,15 +3,15 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $TranscriptionResponse = { export const $TranscriptionResponse = {
description: `Response model for transcription.`, description: `Response model for transcription.`,
properties: { properties: {
text: { text: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
}, },
duration: {
type: 'number',
isRequired: true,
},
},
} as const; } as const;
+23 -20
View File
@@ -3,26 +3,29 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ValidationError = { export const $ValidationError = {
properties: { properties: {
loc: { loc: {
type: 'array', type: 'array',
contains: { contains: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
type: 'string', {
}, {
type: 'number',
}],
},
isRequired: true,
},
msg: {
type: 'string', type: 'string',
isRequired: true, },
}, {
type: { type: 'number',
type: 'string', },
isRequired: true, ],
}, },
isRequired: true,
}, },
msg: {
type: 'string',
isRequired: true,
},
type: {
type: 'string',
isRequired: true,
},
},
} as const; } as const;
+24 -21
View File
@@ -3,26 +3,29 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $VoiceProfileCreate = { export const $VoiceProfileCreate = {
description: `Request model for creating a voice profile.`, description: `Request model for creating a voice profile.`,
properties: { properties: {
name: { name: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
maxLength: 100, maxLength: 100,
minLength: 1, minLength: 1,
},
description: {
type: 'any-of',
contains: [{
type: 'string',
maxLength: 500,
}, {
type: 'null',
}],
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
}, },
description: {
type: 'any-of',
contains: [
{
type: 'string',
maxLength: 500,
},
{
type: 'null',
},
],
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
},
} as const; } as const;
@@ -3,38 +3,41 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $VoiceProfileResponse = { export const $VoiceProfileResponse = {
description: `Response model for voice profile.`, description: `Response model for voice profile.`,
properties: { properties: {
id: { id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
name: {
type: 'string',
isRequired: true,
},
description: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
updated_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
}, },
name: {
type: 'string',
isRequired: true,
},
description: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
updated_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const; } as const;
+437 -437
View File
@@ -19,441 +19,441 @@ import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI'; import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request'; import { request as __request } from '../core/request';
export class DefaultService { export class DefaultService {
/** /**
* Root * Root
* Root endpoint. * Root endpoint.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static rootGet(): CancelablePromise<any> { public static rootGet(): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/', url: '/',
}); });
} }
/** /**
* Health * Health
* Health check endpoint. * Health check endpoint.
* @returns HealthResponse Successful Response * @returns HealthResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static healthHealthGet(): CancelablePromise<HealthResponse> { public static healthHealthGet(): CancelablePromise<HealthResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/health', url: '/health',
}); });
} }
/** /**
* List Profiles * List Profiles
* List all voice profiles. * List all voice profiles.
* @returns VoiceProfileResponse Successful Response * @returns VoiceProfileResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static listProfilesProfilesGet(): CancelablePromise<Array<VoiceProfileResponse>> { public static listProfilesProfilesGet(): CancelablePromise<Array<VoiceProfileResponse>> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/profiles', url: '/profiles',
}); });
} }
/** /**
* Create Profile * Create Profile
* Create a new voice profile. * Create a new voice profile.
* @returns VoiceProfileResponse Successful Response * @returns VoiceProfileResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static createProfileProfilesPost({ public static createProfileProfilesPost({
requestBody, requestBody,
}: { }: {
requestBody: VoiceProfileCreate, requestBody: VoiceProfileCreate;
}): CancelablePromise<VoiceProfileResponse> { }): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/profiles', url: '/profiles',
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Profile * Get Profile
* Get a voice profile by ID. * Get a voice profile by ID.
* @returns VoiceProfileResponse Successful Response * @returns VoiceProfileResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getProfileProfilesProfileIdGet({ public static getProfileProfilesProfileIdGet({
profileId, profileId,
}: { }: {
profileId: string, profileId: string;
}): CancelablePromise<VoiceProfileResponse> { }): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/profiles/{profile_id}', url: '/profiles/{profile_id}',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Update Profile * Update Profile
* Update a voice profile. * Update a voice profile.
* @returns VoiceProfileResponse Successful Response * @returns VoiceProfileResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static updateProfileProfilesProfileIdPut({ public static updateProfileProfilesProfileIdPut({
profileId, profileId,
requestBody, requestBody,
}: { }: {
profileId: string, profileId: string;
requestBody: VoiceProfileCreate, requestBody: VoiceProfileCreate;
}): CancelablePromise<VoiceProfileResponse> { }): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'PUT', method: 'PUT',
url: '/profiles/{profile_id}', url: '/profiles/{profile_id}',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Delete Profile * Delete Profile
* Delete a voice profile. * Delete a voice profile.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static deleteProfileProfilesProfileIdDelete({ public static deleteProfileProfilesProfileIdDelete({
profileId, profileId,
}: { }: {
profileId: string, profileId: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'DELETE', method: 'DELETE',
url: '/profiles/{profile_id}', url: '/profiles/{profile_id}',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Add Profile Sample * Add Profile Sample
* Add a sample to a voice profile. * Add a sample to a voice profile.
* @returns ProfileSampleResponse Successful Response * @returns ProfileSampleResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static addProfileSampleProfilesProfileIdSamplesPost({ public static addProfileSampleProfilesProfileIdSamplesPost({
profileId, profileId,
formData, formData,
}: { }: {
profileId: string, profileId: string;
formData: Body_add_profile_sample_profiles__profile_id__samples_post, formData: Body_add_profile_sample_profiles__profile_id__samples_post;
}): CancelablePromise<ProfileSampleResponse> { }): CancelablePromise<ProfileSampleResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/profiles/{profile_id}/samples', url: '/profiles/{profile_id}/samples',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
formData: formData, formData: formData,
mediaType: 'multipart/form-data', mediaType: 'multipart/form-data',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Profile Samples * Get Profile Samples
* Get all samples for a profile. * Get all samples for a profile.
* @returns ProfileSampleResponse Successful Response * @returns ProfileSampleResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getProfileSamplesProfilesProfileIdSamplesGet({ public static getProfileSamplesProfilesProfileIdSamplesGet({
profileId, profileId,
}: { }: {
profileId: string, profileId: string;
}): CancelablePromise<Array<ProfileSampleResponse>> { }): CancelablePromise<Array<ProfileSampleResponse>> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/profiles/{profile_id}/samples', url: '/profiles/{profile_id}/samples',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Delete Profile Sample * Delete Profile Sample
* Delete a profile sample. * Delete a profile sample.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static deleteProfileSampleProfilesSamplesSampleIdDelete({ public static deleteProfileSampleProfilesSamplesSampleIdDelete({
sampleId, sampleId,
}: { }: {
sampleId: string, sampleId: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'DELETE', method: 'DELETE',
url: '/profiles/samples/{sample_id}', url: '/profiles/samples/{sample_id}',
path: { path: {
'sample_id': sampleId, sample_id: sampleId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Generate Speech * Generate Speech
* Generate speech from text using a voice profile. * Generate speech from text using a voice profile.
* @returns GenerationResponse Successful Response * @returns GenerationResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static generateSpeechGeneratePost({ public static generateSpeechGeneratePost({
requestBody, requestBody,
}: { }: {
requestBody: GenerationRequest, requestBody: GenerationRequest;
}): CancelablePromise<GenerationResponse> { }): CancelablePromise<GenerationResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/generate', url: '/generate',
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* List History * List History
* List generation history with optional filters. * List generation history with optional filters.
* @returns HistoryListResponse Successful Response * @returns HistoryListResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static listHistoryHistoryGet({ public static listHistoryHistoryGet({
profileId, profileId,
search, search,
limit = 50, limit = 50,
offset, offset,
}: { }: {
profileId?: (string | null), profileId?: string | null;
search?: (string | null), search?: string | null;
limit?: number, limit?: number;
offset?: number, offset?: number;
}): CancelablePromise<HistoryListResponse> { }): CancelablePromise<HistoryListResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/history', url: '/history',
query: { query: {
'profile_id': profileId, profile_id: profileId,
'search': search, search: search,
'limit': limit, limit: limit,
'offset': offset, offset: offset,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Generation * Get Generation
* Get a generation by ID. * Get a generation by ID.
* @returns HistoryResponse Successful Response * @returns HistoryResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getGenerationHistoryGenerationIdGet({ public static getGenerationHistoryGenerationIdGet({
generationId, generationId,
}: { }: {
generationId: string, generationId: string;
}): CancelablePromise<HistoryResponse> { }): CancelablePromise<HistoryResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/history/{generation_id}', url: '/history/{generation_id}',
path: { path: {
'generation_id': generationId, generation_id: generationId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Delete Generation * Delete Generation
* Delete a generation. * Delete a generation.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static deleteGenerationHistoryGenerationIdDelete({ public static deleteGenerationHistoryGenerationIdDelete({
generationId, generationId,
}: { }: {
generationId: string, generationId: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'DELETE', method: 'DELETE',
url: '/history/{generation_id}', url: '/history/{generation_id}',
path: { path: {
'generation_id': generationId, generation_id: generationId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Stats * Get Stats
* Get generation statistics. * Get generation statistics.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getStatsHistoryStatsGet(): CancelablePromise<any> { public static getStatsHistoryStatsGet(): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/history/stats', url: '/history/stats',
}); });
} }
/** /**
* Transcribe Audio * Transcribe Audio
* Transcribe audio file to text. * Transcribe audio file to text.
* @returns TranscriptionResponse Successful Response * @returns TranscriptionResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static transcribeAudioTranscribePost({ public static transcribeAudioTranscribePost({
formData, formData,
}: { }: {
formData: Body_transcribe_audio_transcribe_post, formData: Body_transcribe_audio_transcribe_post;
}): CancelablePromise<TranscriptionResponse> { }): CancelablePromise<TranscriptionResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/transcribe', url: '/transcribe',
formData: formData, formData: formData,
mediaType: 'multipart/form-data', mediaType: 'multipart/form-data',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Audio * Get Audio
* Serve generated audio file. * Serve generated audio file.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getAudioAudioGenerationIdGet({ public static getAudioAudioGenerationIdGet({
generationId, generationId,
}: { }: {
generationId: string, generationId: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/audio/{generation_id}', url: '/audio/{generation_id}',
path: { path: {
'generation_id': generationId, generation_id: generationId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Load Model * Load Model
* Manually load TTS model. * Manually load TTS model.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static loadModelModelsLoadPost({ public static loadModelModelsLoadPost({
modelSize = '1.7B', modelSize = '1.7B',
}: { }: {
modelSize?: string, modelSize?: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/models/load', url: '/models/load',
query: { query: {
'model_size': modelSize, model_size: modelSize,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Unload Model * Unload Model
* Unload TTS model to free memory. * Unload TTS model to free memory.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static unloadModelModelsUnloadPost(): CancelablePromise<any> { public static unloadModelModelsUnloadPost(): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/models/unload', url: '/models/unload',
}); });
} }
/** /**
* Get Model Progress * Get Model Progress
* Get model download progress via Server-Sent Events. * Get model download progress via Server-Sent Events.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getModelProgressModelsProgressModelNameGet({ public static getModelProgressModelsProgressModelNameGet({
modelName, modelName,
}: { }: {
modelName: string, modelName: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/models/progress/{model_name}', url: '/models/progress/{model_name}',
path: { path: {
'model_name': modelName, model_name: modelName,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Model Status * Get Model Status
* Get status of all available models. * Get status of all available models.
* @returns ModelStatusListResponse Successful Response * @returns ModelStatusListResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getModelStatusModelsStatusGet(): CancelablePromise<ModelStatusListResponse> { public static getModelStatusModelsStatusGet(): CancelablePromise<ModelStatusListResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/models/status', url: '/models/status',
}); });
} }
/** /**
* Trigger Model Download * Trigger Model Download
* Trigger download of a specific model. * Trigger download of a specific model.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static triggerModelDownloadModelsDownloadPost({ public static triggerModelDownloadModelsDownloadPost({
requestBody, requestBody,
}: { }: {
requestBody: ModelDownloadRequest, requestBody: ModelDownloadRequest;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/models/download', url: '/models/download',
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
} }
+190
View File
@@ -0,0 +1,190 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { isTauri } from '@/lib/tauri';
interface UseAudioRecordingOptions {
maxDurationSeconds?: number;
onRecordingComplete?: (blob: Blob) => void;
}
export function useAudioRecording({
maxDurationSeconds = 30,
onRecordingComplete,
}: UseAudioRecordingOptions = {}) {
const [isRecording, setIsRecording] = useState(false);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const startRecording = useCallback(async () => {
try {
setError(null);
chunksRef.current = [];
setDuration(0);
// Check if getUserMedia is available
// In Tauri, navigator.mediaDevices might not be available immediately
if (typeof navigator === 'undefined') {
const errorMsg =
'Navigator API is not available. This might be a Tauri configuration issue.';
setError(errorMsg);
throw new Error(errorMsg);
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
// Try waiting a bit for Tauri webview to initialize
await new Promise((resolve) => setTimeout(resolve, 100));
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const isTauriEnv = isTauri();
console.error('MediaDevices check:', {
hasNavigator: typeof navigator !== 'undefined',
hasMediaDevices: !!navigator?.mediaDevices,
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
isTauri: isTauriEnv,
});
const errorMsg = isTauriEnv
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
setError(errorMsg);
throw new Error(errorMsg);
}
}
// Request microphone access
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
streamRef.current = stream;
// Create MediaRecorder with preferred MIME type
const options: MediaRecorderOptions = {
mimeType: 'audio/webm;codecs=opus',
};
// Fallback to default if webm not supported
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
delete options.mimeType;
}
const mediaRecorder = new MediaRecorder(stream, options);
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
onRecordingComplete?.(blob);
// Stop all tracks
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
};
mediaRecorder.onerror = (event) => {
setError('Recording error occurred');
console.error('MediaRecorder error:', event);
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setIsRecording(true);
startTimeRef.current = Date.now();
// Start timer
timerRef.current = window.setInterval(() => {
if (startTimeRef.current) {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
// Auto-stop at max duration
if (elapsed >= maxDurationSeconds) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
}
}
}, 100);
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to access microphone. Please check permissions.';
setError(errorMessage);
setIsRecording(false);
}
}, [maxDurationSeconds, onRecordingComplete]);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
}, [isRecording]);
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current) {
mediaRecorderRef.current.stop();
setIsRecording(false);
chunksRef.current = [];
setDuration(0);
}
// Stop all tracks
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
}
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
};
}, []);
return {
isRecording,
duration,
error,
startRecording,
stopRecording,
cancelRecording,
};
}
+39
View File
@@ -3,6 +3,7 @@
*/ */
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen, emit } from '@tauri-apps/api/event';
/** /**
* Check if running in Tauri environment * Check if running in Tauri environment
@@ -45,3 +46,41 @@ export async function stopServer(): Promise<void> {
throw error; throw error;
} }
} }
/**
* Setup window close handler to check setting and stop server if needed
*/
export async function setupWindowCloseHandler(): Promise<void> {
if (!isTauri()) {
return;
}
try {
// Listen for window close request from Rust
await listen<null>('window-close-requested', async () => {
// Import store here to avoid circular dependency
const { useServerStore } = await import('@/stores/serverStore');
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
// Check if server was started by this app instance
// In dev mode, serverStartedByApp will be false, so we won't try to stop a separately-run server
// We need to access the module-level variable - this is a bit hacky but works
// @ts-expect-error - accessing module-level variable from another module
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
if (!keepRunning && serverStartedByApp) {
// Stop server before closing (only if we started it)
try {
await stopServer();
} catch (error) {
console.error('Failed to stop server on close:', error);
}
}
// Emit event back to Rust to allow close
await emit('window-close-allowed');
});
} catch (error) {
console.error('Failed to setup window close handler:', error);
}
}
+23 -5
View File
@@ -1,37 +1,55 @@
import { create } from 'zustand'; import { create } from 'zustand';
interface PlayerState { interface PlayerState {
currentAudioId: string | null; audioUrl: string | null;
audioId: string | null;
title: string | null;
isPlaying: boolean; isPlaying: boolean;
currentTime: number; currentTime: number;
duration: number; duration: number;
volume: number; volume: number;
isLooping: boolean;
setCurrentAudio: (audioId: string | null) => void; setAudio: (url: string, id: string, title?: string) => void;
setIsPlaying: (playing: boolean) => void; setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void; setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void; setDuration: (duration: number) => void;
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
toggleLoop: () => void;
reset: () => void; reset: () => void;
} }
export const usePlayerStore = create<PlayerState>((set) => ({ export const usePlayerStore = create<PlayerState>((set) => ({
currentAudioId: null, audioUrl: null,
audioId: null,
title: null,
isPlaying: false, isPlaying: false,
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
volume: 1, volume: 1,
isLooping: false,
setCurrentAudio: (audioId) => set({ currentAudioId: audioId, currentTime: 0, isPlaying: false }), setAudio: (url, id, title) =>
set({
audioUrl: url,
audioId: id,
title: title || null,
currentTime: 0,
isPlaying: false,
}),
setIsPlaying: (playing) => set({ isPlaying: playing }), setIsPlaying: (playing) => set({ isPlaying: playing }),
setCurrentTime: (time) => set({ currentTime: time }), setCurrentTime: (time) => set({ currentTime: time }),
setDuration: (duration) => set({ duration }), setDuration: (duration) => set({ duration }),
setVolume: (volume) => set({ volume }), setVolume: (volume) => set({ volume }),
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
reset: () => reset: () =>
set({ set({
currentAudioId: null, audioUrl: null,
audioId: null,
title: null,
isPlaying: false, isPlaying: false,
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
isLooping: false,
}), }),
})); }));
+6
View File
@@ -10,6 +10,9 @@ interface ServerStore {
mode: 'local' | 'remote'; mode: 'local' | 'remote';
setMode: (mode: 'local' | 'remote') => void; setMode: (mode: 'local' | 'remote') => void;
keepServerRunningOnClose: boolean;
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
} }
export const useServerStore = create<ServerStore>()( export const useServerStore = create<ServerStore>()(
@@ -23,6 +26,9 @@ export const useServerStore = create<ServerStore>()(
mode: 'local', mode: 'local',
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
keepServerRunningOnClose: false,
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
}), }),
{ {
name: 'voicebox-server', name: 'voicebox-server',
+7
View File
@@ -14,6 +14,10 @@ interface UIStore {
generationDialogOpen: boolean; generationDialogOpen: boolean;
setGenerationDialogOpen: (open: boolean) => void; setGenerationDialogOpen: (open: boolean) => void;
// Selected profile for generation
selectedProfileId: string | null;
setSelectedProfileId: (id: string | null) => void;
// Theme // Theme
theme: 'light' | 'dark'; theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void; setTheme: (theme: 'light' | 'dark') => void;
@@ -31,6 +35,9 @@ export const useUIStore = create<UIStore>((set) => ({
generationDialogOpen: false, generationDialogOpen: false,
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }), setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
theme: 'light', theme: 'light',
setTheme: (theme) => { setTheme: (theme) => {
set({ theme }); set({ theme });
+1
View File
@@ -47,6 +47,7 @@ class Generation(Base):
audio_path = Column(String, nullable=False) audio_path = Column(String, nullable=False)
duration = Column(Float, nullable=False) duration = Column(Float, nullable=False)
seed = Column(Integer) seed = Column(Integer)
instruct = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
+4
View File
@@ -28,6 +28,7 @@ async def create_generation(
duration: float, duration: float,
seed: Optional[int], seed: Optional[int],
db: Session, db: Session,
instruct: Optional[str] = None,
) -> GenerationResponse: ) -> GenerationResponse:
""" """
Create a new generation history entry. Create a new generation history entry.
@@ -40,6 +41,7 @@ async def create_generation(
duration: Audio duration in seconds duration: Audio duration in seconds
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)
Returns: Returns:
Created generation entry Created generation entry
@@ -52,6 +54,7 @@ async def create_generation(
audio_path=audio_path, audio_path=audio_path,
duration=duration, duration=duration,
seed=seed, seed=seed,
instruct=instruct,
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
) )
@@ -139,6 +142,7 @@ async def list_generations(
audio_path=generation.audio_path, audio_path=generation.audio_path,
duration=generation.duration, duration=generation.duration,
seed=generation.seed, seed=generation.seed,
instruct=generation.instruct,
created_at=generation.created_at, created_at=generation.created_at,
)) ))
+4
View File
@@ -259,6 +259,7 @@ async def generate_speech(
voice_prompt, voice_prompt,
data.language, data.language,
data.seed, data.seed,
data.instruct,
) )
# Calculate duration # Calculate duration
@@ -280,6 +281,7 @@ async def generate_speech(
duration=duration, duration=duration,
seed=data.seed, seed=data.seed,
db=db, db=db,
instruct=data.instruct,
) )
return generation return generation
@@ -342,6 +344,7 @@ async def get_generation(
audio_path=gen.audio_path, audio_path=gen.audio_path,
duration=gen.duration, duration=gen.duration,
seed=gen.seed, seed=gen.seed,
instruct=gen.instruct,
created_at=gen.created_at, created_at=gen.created_at,
) )
@@ -706,6 +709,7 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
async def startup_event(): async def startup_event():
"""Run on application startup.""" """Run on application startup."""
print("voicebox API starting up...") print("voicebox API starting up...")
database.init_db()
print(f"Database initialized at {database._db_path}") print(f"Database initialized at {database._db_path}")
print(f"GPU available: {torch.cuda.is_available()}") print(f"GPU available: {torch.cuda.is_available()}")
+48
View File
@@ -0,0 +1,48 @@
"""
Database migration script to add instruct column to generations table.
Run this once to update existing databases:
python -m backend.migrate_add_instruct
"""
import sqlite3
import os
from pathlib import Path
def migrate():
"""Add instruct column to generations table if it doesn't exist."""
# Get data directory
data_dir = os.environ.get("VOICEBOX_DATA_DIR")
if data_dir:
db_path = Path(data_dir) / "voicebox.db"
else:
db_path = Path.cwd() / "data" / "voicebox.db"
if not db_path.exists():
print(f"Database not found at {db_path}, skipping migration")
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if instruct column already exists
cursor.execute("PRAGMA table_info(generations)")
columns = [row[1] for row in cursor.fetchall()]
if 'instruct' in columns:
print("instruct column already exists, skipping migration")
conn.close()
return
# Add instruct column
print("Adding instruct column to generations table...")
cursor.execute("ALTER TABLE generations ADD COLUMN instruct TEXT")
conn.commit()
conn.close()
print("Migration complete!")
if __name__ == "__main__":
migrate()
+3
View File
@@ -50,6 +50,7 @@ class GenerationRequest(BaseModel):
language: str = Field(default="en", pattern="^(en|zh)$") language: str = Field(default="en", pattern="^(en|zh)$")
seed: Optional[int] = Field(None, ge=0) seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$") model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
instruct: Optional[str] = Field(None, max_length=500)
class GenerationResponse(BaseModel): class GenerationResponse(BaseModel):
@@ -61,6 +62,7 @@ class GenerationResponse(BaseModel):
audio_path: str audio_path: str
duration: float duration: float
seed: Optional[int] seed: Optional[int]
instruct: Optional[str]
created_at: datetime created_at: datetime
class Config: class Config:
@@ -85,6 +87,7 @@ class HistoryResponse(BaseModel):
audio_path: str audio_path: str
duration: float duration: float
seed: Optional[int] seed: Optional[int]
instruct: Optional[str]
created_at: datetime created_at: datetime
class Config: class Config:
+3
View File
@@ -241,6 +241,7 @@ class TTSModel:
voice_prompt: dict, voice_prompt: dict,
language: str = "en", language: str = "en",
seed: Optional[int] = None, seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]: ) -> Tuple[np.ndarray, int]:
""" """
Generate audio from text using voice prompt. Generate audio from text using voice prompt.
@@ -250,6 +251,7 @@ class TTSModel:
voice_prompt: Voice prompt dictionary from create_voice_prompt voice_prompt: Voice prompt dictionary from create_voice_prompt
language: Language code (en or zh) language: Language code (en or zh)
seed: Random seed for reproducibility seed: Random seed for reproducibility
instruct: Natural language instruction for speech delivery control
Returns: Returns:
Tuple of (audio_array, sample_rate) Tuple of (audio_array, sample_rate)
@@ -266,6 +268,7 @@ class TTSModel:
wavs, sample_rate = self.model.generate_voice_clone( wavs, sample_rate = self.model.generate_voice_clone(
text=text, text=text,
voice_clone_prompt=voice_prompt, voice_clone_prompt=voice_prompt,
instruct=instruct,
) )
audio = wavs[0] # Get first result audio = wavs[0] # Get first result
+226 -1
View File
@@ -26,15 +26,19 @@
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0", "@tanstack/react-query-devtools": "^5.0.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"framer-motion": "^12.29.0",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
@@ -54,6 +58,31 @@
"vite": "^5.4.0", "vite": "^5.4.0",
}, },
}, },
"landing": {
"name": "@voicebox/landing",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.316.0",
"next": "^16.1.3",
"postcss": "^8.4.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"typescript": "^5.3.3",
},
},
"tauri": { "tauri": {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"version": "0.1.0", "version": "0.1.0",
@@ -98,6 +127,8 @@
}, },
}, },
"packages": { "packages": {
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], "@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], "@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
@@ -154,6 +185,8 @@
"@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="], "@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], "@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
@@ -224,6 +257,56 @@
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], "@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/[email protected]", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -234,6 +317,24 @@
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@next/env": ["@next/[email protected]", "", {}, "sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A=="],
"@next/swc-darwin-arm64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg=="],
"@next/swc-darwin-x64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg=="],
"@next/swc-linux-arm64-gnu": ["@next/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ=="],
"@next/swc-linux-arm64-musl": ["@next/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q=="],
"@next/swc-linux-x64-gnu": ["@next/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ=="],
"@next/swc-linux-x64-musl": ["@next/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw=="],
"@next/swc-win32-arm64-msvc": ["@next/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw=="],
"@next/swc-win32-x64-msvc": ["@next/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w=="],
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -294,6 +395,8 @@
"@radix-ui/react-separator": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], "@radix-ui/react-separator": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slider": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tabs": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-tabs": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
@@ -374,6 +477,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
"@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], "@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@@ -438,8 +543,12 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
"@tauri-apps/plugin-process": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA=="], "@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], "@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -480,6 +589,8 @@
"@voicebox/app": ["@voicebox/app@workspace:app"], "@voicebox/app": ["@voicebox/app@workspace:app"],
"@voicebox/landing": ["@voicebox/landing@workspace:landing"],
"@voicebox/tauri": ["@voicebox/tauri@workspace:tauri"], "@voicebox/tauri": ["@voicebox/tauri@workspace:tauri"],
"@voicebox/web": ["@voicebox/web@workspace:web"], "@voicebox/web": ["@voicebox/web@workspace:web"],
@@ -494,16 +605,26 @@
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"array-union": ["[email protected]", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], "array-union": ["[email protected]", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="], "baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -512,24 +633,34 @@
"callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], "caniuse-lite": ["[email protected]", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"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=="],
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"date-fns": ["[email protected]", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], "date-fns": ["[email protected]", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
@@ -542,8 +673,12 @@
"detect-node-es": ["[email protected]", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "detect-node-es": ["[email protected]", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dir-glob": ["[email protected]", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dir-glob": ["[email protected]", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="], "electron-to-chromium": ["[email protected]", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="],
@@ -586,6 +721,8 @@
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], "file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -596,10 +733,16 @@
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], "flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-nonce": ["[email protected]", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-nonce": ["[email protected]", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -618,6 +761,8 @@
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -628,6 +773,10 @@
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@@ -638,7 +787,7 @@
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -682,6 +831,10 @@
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"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=="],
@@ -700,14 +853,28 @@
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
"motion-utils": ["[email protected]", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["[email protected]", "", { "dependencies": { "@next/env": "16.1.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.4", "@next/swc-darwin-x64": "16.1.4", "@next/swc-linux-arm64-gnu": "16.1.4", "@next/swc-linux-arm64-musl": "16.1.4", "@next/swc-linux-x64-gnu": "16.1.4", "@next/swc-linux-x64-musl": "16.1.4", "@next/swc-win32-arm64-msvc": "16.1.4", "@next/swc-win32-x64-msvc": "16.1.4", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ=="],
"node-releases": ["[email protected]", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["[email protected]", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -724,14 +891,32 @@
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-type": ["[email protected]", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "path-type": ["[email protected]", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pify": ["[email protected]", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -752,6 +937,12 @@
"react-style-singleton": ["[email protected]", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-style-singleton": ["[email protected]", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"read-cache": ["[email protected]", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
@@ -766,6 +957,8 @@
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"sharp": ["[email protected]", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -778,8 +971,14 @@
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["[email protected]", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "tailwind-merge": ["[email protected]", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
"tailwindcss": ["[email protected]", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["[email protected]", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@@ -790,10 +989,18 @@
"text-table": ["[email protected]", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], "text-table": ["[email protected]", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="], "ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="],
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -814,6 +1021,8 @@
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"wavesurfer.js": ["[email protected]", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="], "wavesurfer.js": ["[email protected]", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
@@ -858,6 +1067,8 @@
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@tailwindcss/node/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -874,8 +1085,22 @@
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
} }
} }
+205
View File
@@ -0,0 +1,205 @@
# Tauri v2 Autoupdater Setup
The autoupdater has been configured for this project. Follow these steps to complete the setup.
## 1. Generate Signing Keys
Run this command to generate your signing keypair:
```bash
cd tauri && bun tauri signer generate -w ~/.tauri/voicebox.key
```
This creates:
- **Private key**: `~/.tauri/voicebox.key` (keep this secret!)
- **Public key**: `~/.tauri/voicebox.key.pub`
## 2. Update Configuration
Copy the content from `~/.tauri/voicebox.key.pub` and replace the placeholder in `tauri/src-tauri/tauri.conf.json`:
```json
{
"plugins": {
"updater": {
"pubkey": "PASTE_PUBLIC_KEY_CONTENT_HERE",
"endpoints": [
"https://github.com/YOUR_USERNAME/voicebox/releases/latest/download/latest.json"
]
}
}
}
```
Update the endpoint URL with your actual GitHub username/organization.
## 3. Building with Signatures
When building releases, set these environment variables:
**macOS/Linux:**
```bash
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/voicebox.key)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
bun run build
```
**Windows PowerShell:**
```powershell
$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content ~/.tauri/voicebox.key -Raw
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = ""
bun run build
```
## 4. GitHub Release Setup
When you create a GitHub release, the build process will generate:
- Installers for each platform
- `.sig` signature files
- `latest.json` update manifest
### Manual Release Process
1. Build the app with signing keys set
2. Create a new GitHub release
3. Upload all files from `tauri/src-tauri/target/release/bundle/`
4. Create `latest.json` in your release assets:
```json
{
"version": "0.2.0",
"notes": "Bug fixes and improvements",
"pub_date": "2026-01-25T12:00:00Z",
"platforms": {
"darwin-aarch64": {
"signature": "CONTENT_FROM_.app.tar.gz.sig",
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_aarch64.dmg"
},
"darwin-x86_64": {
"signature": "CONTENT_FROM_.app.tar.gz.sig",
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_x64.dmg"
},
"linux-x86_64": {
"signature": "CONTENT_FROM_.AppImage.sig",
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_amd64.AppImage"
},
"windows-x86_64": {
"signature": "CONTENT_FROM_.msi.sig",
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_x64_en-US.msi"
}
}
}
```
### Automated GitHub Actions (Recommended)
Create `.github/workflows/release.yml`:
```yaml
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
strategy:
matrix:
platform: [macos-latest, ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies (Ubuntu)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install dependencies
run: bun install
- name: Build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: bun run build
- name: Upload Release
uses: softprops/action-gh-release@v1
with:
files: tauri/src-tauri/target/release/bundle/**/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
Add your private key to GitHub secrets:
- Go to Settings → Secrets and variables → Actions
- Add `TAURI_SIGNING_PRIVATE_KEY` with the content of `~/.tauri/voicebox.key`
- Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (empty string if no password)
## 5. Frontend Integration (Already Completed)
The frontend integration is already complete in this project with the following components:
### Automatic Update Notifications
- `app/src/components/UpdateNotification.tsx` - Shows a banner when updates are available
- Automatically checks for updates on app startup
- Displays download/install progress
- Only shows in Tauri desktop builds
### Manual Update Check
- `app/src/components/ServerSettings/UpdateStatus.tsx` - Settings panel for updates
- Allows manual update checks via "Check for Updates" button
- Shows current version and update status
- Located in the Settings tab (only visible in Tauri builds)
### Update Hook
- `app/src/hooks/useAutoUpdater.ts` - React hook for update functionality
- Handles update checking, downloading, and installation
- Includes Tauri context detection (won't run in web builds)
The components are already integrated into the main App layout.
## Security Notes
- Never commit your private key to version control
- Store private keys securely (use GitHub secrets for CI/CD)
- The public key in `tauri.conf.json` is safe to commit
- Updates are cryptographically verified before installation
- HTTP endpoints are blocked by default (HTTPS only)
## Testing Updates
1. Build version 0.1.0 and install it
2. Update version in `tauri.conf.json` to 0.2.0
3. Build version 0.2.0 with signatures
4. Create a local server or GitHub release with `latest.json`
5. Run version 0.1.0 and trigger update check
6. Verify update downloads and installs correctly
## Troubleshooting
**"Invalid signature" error:**
- Verify public key matches the private key used to sign
- Ensure signature files (.sig) are uploaded correctly
**"No update available" when one exists:**
- Check endpoint URL is correct
- Verify `latest.json` format matches specification
- Ensure version in latest.json is higher than current version
**Build fails with signing:**
- Confirm environment variables are set correctly
- Check private key file exists and is readable
- Verify private key format (should start with `dW50cnVzdGVkIGNvbW1lbnQ6`)
+116
View File
@@ -0,0 +1,116 @@
# Autoupdater Quick Start
The Tauri v2 autoupdater has been fully configured and integrated. Follow these steps to activate it.
## What's Already Done
✅ Rust plugin installed and initialized
✅ Tauri configuration set up with updater settings
✅ Permissions granted for update operations
✅ GitHub Actions workflow updated with signing support
✅ Frontend components created and integrated
✅ Update notifications on app startup
✅ Manual update check in Settings tab
## Required Steps (5 minutes)
### 1. Generate Signing Keys
```bash
bun run generate:keys
```
This creates:
- Private key: `~/.tauri/voicebox.key` (keep secret!)
- Public key: `~/.tauri/voicebox.key.pub` (safe to share)
### 2. Update Tauri Config
Open `tauri/src-tauri/tauri.conf.json` and:
1. Replace `"REPLACE_WITH_YOUR_PUBLIC_KEY"` with the content from `~/.tauri/voicebox.key.pub`
2. Update the endpoint URL with your GitHub username:
```json
"endpoints": [
"https://github.com/YOUR_USERNAME/voicebox/releases/latest/download/latest.json"
]
```
### 3. Add GitHub Secrets
Go to your repo Settings → Secrets and variables → Actions:
1. Add `TAURI_SIGNING_PRIVATE_KEY`:
```bash
cat ~/.tauri/voicebox.key
```
Copy the entire output and paste as the secret value
2. Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`:
Leave empty (or add your password if you set one)
### 4. Test the Setup
To test locally before creating a release:
```bash
bun run build:release
```
This will verify your keys are set up correctly.
## How It Works
### For Users
1. App checks for updates on startup (only in Tauri builds)
2. If an update is available, a banner appears at the top
3. Users can click "Install Now" to download and install
4. App restarts automatically after installation
### For Developers
1. Create a new git tag: `git tag v0.2.0 && git push --tags`
2. GitHub Actions builds signed releases for all platforms
3. Uploads installers and generates `latest.json` manifest
4. Users running older versions will be notified automatically
## UI Components
### Update Notification Banner
- Shows at top of app when update is available
- Appears automatically on startup
- Displays download/install progress
### Settings Panel
- Located in Settings tab
- Shows current version
- Manual "Check for Updates" button
- Update status and progress
## Troubleshooting
**"Public key not configured"**
- Make sure you copied the entire content from `voicebox.key.pub`
- The key should start with `dW50cnVzdGVkIGNvbW1lbnQ6`
**"Failed to check for updates"**
- Endpoint URL might be incorrect
- No releases published yet (expected for first setup)
**Build fails with signing error**
- Check that GitHub secrets are set correctly
- Verify private key file exists at `~/.tauri/voicebox.key`
## Next Release Workflow
1. Update version in `tauri/src-tauri/tauri.conf.json`
2. Commit changes
3. Create and push tag: `git tag v0.2.0 && git push --tags`
4. GitHub Actions will automatically build and create a draft release
5. Review the release and publish it
6. Users will be notified of the update
## See Also
- Full documentation: `docs/AUTOUPDATER.md`
- Build script: `scripts/prepare-release.sh`
- GitHub workflow: `.github/workflows/release.yml`
+150
View File
@@ -0,0 +1,150 @@
# Icon Update Workflow for voicebox
## Prerequisites
- Xcode Command Line Tools installed (`xcode-select --install`)
- Your icon designed in Icon Composer and saved as `voicebox.icon` in the project root
## Directory Structure
```
voicebox/
├── voicebox.icon/ # macOS 26 Liquid Glass icon bundle
│ ├── icon.json
│ └── Assets/
│ └── Voicebox.png # Your source image
├── tauri/
│ ├── assets/
│ │ └── voicebox_exports/ # Your 1024x1024 exports
│ └── src-tauri/
│ ├── gen/ # Auto-generated at build time
│ │ ├── Assets.car # Liquid Glass assets
│ │ ├── voicebox.icns # Generated fallback icon
│ │ └── partial.plist
│ └── icons/ # Tauri fallback icons (all platforms)
│ ├── icon.icns
│ ├── icon.ico
│ ├── 32x32.png
│ ├── 128x128.png
│ └── ...
```
## Step 1: Update the Liquid Glass Icon
Edit `voicebox.icon/` in Icon Composer (or manually update `icon.json` and `Assets/`).
The build script (`build.rs`) automatically compiles this during `cargo build`.
## Step 2: Regenerate Fallback Icons
After updating the `.icon` bundle, regenerate the fallback icons:
```bash
cd tauri/src-tauri
# Trigger rebuild to generate new icns
cargo build
# Copy the generated icns to icons folder
cp gen/voicebox.icns icons/icon.icns
# Generate PNGs from the icns
sips -s format png -z 32 32 gen/voicebox.icns --out icons/32x32.png
sips -s format png -z 64 64 gen/voicebox.icns --out icons/64x64.png
sips -s format png -z 128 128 gen/voicebox.icns --out icons/128x128.png
sips -s format png -z 256 256 gen/voicebox.icns --out icons/128x128@2x.png
sips -s format png -z 512 512 gen/voicebox.icns --out icons/icon.png
# Windows Square logos
for size in 30 44 71 89 107 142 150 284 310; do
sips -s format png -z $size $size gen/voicebox.icns --out "icons/Square${size}x${size}Logo.png"
done
```
## Step 3: Rebuild the App
```bash
cd tauri && bun run tauri build
```
## Step 4: Clear Icon Cache (if icons don't update)
```bash
sudo rm -rf /Library/Caches/com.apple.iconservices.store
sudo killall Finder
sudo killall Dock
```
---
## How It Works
| File | Purpose |
|------|---------|
| `voicebox.icon/` | Source for macOS 26 Liquid Glass icon |
| `build.rs` | Compiles `.icon` → `Assets.car` + `voicebox.icns` at build time |
| `gen/Assets.car` | Liquid Glass assets (macOS 26+) |
| `gen/voicebox.icns` | Auto-generated fallback icon |
| `icons/icon.icns` | Tauri's fallback for older macOS |
| `icons/*.png` | Tauri's fallback for other platforms |
| `Info.plist` | Points to `voicebox` as icon name |
| `tauri.conf.json` | Bundles `gen/*` to Resources root |
## Key Config Files
### `build.rs` — Compiles the icon
```rust
xcrun actool --app-icon voicebox ... voicebox.icon
```
### `tauri.conf.json` — Bundles generated assets to Resources root
```json
"resources": {
"gen/Assets.car": "./",
"gen/voicebox.icns": "./",
"gen/partial.plist": "./"
}
```
### `Info.plist` — Tells macOS which icon to use
```xml
<key>CFBundleIconFile</key>
<string>voicebox</string>
<key>CFBundleIconName</key>
<string>voicebox</string>
```
## Quick Reference Script
Save this as `scripts/update-icons.sh`:
```bash
#!/bin/bash
set -e
cd "$(dirname "$0")/../tauri/src-tauri"
echo "Building to generate new icons..."
cargo build
echo "Copying icns to icons folder..."
cp gen/voicebox.icns icons/icon.icns
echo "Generating PNG icons..."
sips -s format png -z 32 32 gen/voicebox.icns --out icons/32x32.png
sips -s format png -z 64 64 gen/voicebox.icns --out icons/64x64.png
sips -s format png -z 128 128 gen/voicebox.icns --out icons/128x128.png
sips -s format png -z 256 256 gen/voicebox.icns --out icons/128x128@2x.png
sips -s format png -z 512 512 gen/voicebox.icns --out icons/icon.png
echo "Generating Windows Square logos..."
for size in 30 44 71 89 107 142 150 284 310; do
sips -s format png -z $size $size gen/voicebox.icns --out "icons/Square${size}x${size}Logo.png"
done
echo "Done! Icons updated."
echo "Run 'bun run tauri build' to rebuild the app."
```
Make it executable: `chmod +x scripts/update-icons.sh`
+35
View File
@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+100
View File
@@ -0,0 +1,100 @@
# Voicebox Landing Page
Landing page for voicebox.sh - a modern Next.js 16 application.
## Tech Stack
- **Next.js 16** with App Router
- **Bun** for package management
- **Tailwind CSS** with shadcn/ui components
- **TypeScript** with strict mode
- **Railway** deployment ready
## Getting Started
### Prerequisites
- Bun installed ([bun.sh](https://bun.sh))
### Installation
```bash
cd landing
bun install
```
### Development
```bash
bun run dev
```
Open [http://localhost:3000](http://localhost:3000) to view the landing page.
### Build
```bash
bun run build
```
### Production
```bash
bun run start
```
## Configuration
### Update Download Links
Edit `src/lib/constants.ts` to update:
- `LATEST_VERSION` - Current release version
- `DOWNLOAD_LINKS` - GitHub release download URLs
- `GITHUB_REPO` - Repository URL
### Update GitHub Username
Replace `USERNAME` in `src/lib/constants.ts` with your actual GitHub username.
## Deployment to Railway
1. Connect your GitHub repository to Railway
2. Railway will auto-detect `nixpacks.toml`
3. Set root directory to `landing/`
4. Railway will automatically:
- Install dependencies with `bun install`
- Build with `bun run build`
- Start with `bun run start`
5. Configure custom domain `voicebox.sh` in Railway settings
## Project Structure
```
landing/
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout with metadata
│ │ ├── page.tsx # Landing page
│ │ └── globals.css # Global styles
│ ├── components/
│ │ ├── Header.tsx # Top navigation
│ │ ├── Footer.tsx # Footer
│ │ ├── DownloadSection.tsx # Download buttons
│ │ └── ui/ # shadcn/ui components
│ └── lib/
│ ├── utils.ts # Utility functions
│ └── constants.ts # App constants
├── public/
│ └── voicebox-logo.png # Logo asset
└── nixpacks.toml # Railway deployment config
```
## Features
- Responsive design (mobile-first)
- Dark mode by default
- SEO optimized metadata
- Download links for Mac, Windows, Linux
- Feature showcase
- Platform highlights
- GitHub integration
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
images: {
unoptimized: false,
formats: ['image/avif', 'image/webp'],
},
turbopack: {},
};
module.exports = nextConfig;
+11
View File
@@ -0,0 +1,11 @@
[phases.setup]
nixPkgs = ["nodejs_20", "bun"]
[phases.install]
cmds = ["bun install"]
[phases.build]
cmds = ["bun run build"]
[start]
cmd = "bun run start"
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@voicebox/landing",
"version": "0.1.0",
"description": "Landing page for voicebox.sh",
"scripts": {
"dev": "bun --bun next dev --turbo",
"build": "bun --bun next build",
"start": "bun --bun next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.316.0",
"next": "^16.1.3",
"postcss": "^8.4.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"typescript": "^5.3.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+69
View File
@@ -0,0 +1,69 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 0%;
--card: 0 0% 100%;
--card-foreground: 0 0% 0%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 0%;
--primary: 0 0% 0%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 0 0% 96%;
--accent-foreground: 0 0% 0%;
--destructive: 0 0% 0%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 0 0% 0%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3%;
--foreground: 0 0% 98%;
--card: 0 0% 8% / 0.6;
--card-foreground: 0 0% 98%;
--popover: 0 0% 8% / 0.8;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 8%;
--secondary: 0 0% 12% / 0.5;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 12% / 0.4;
--muted-foreground: 0 0% 65%;
--accent: 0 0% 15% / 0.5;
--accent-foreground: 0 0% 98%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 15% / 0.5;
--input: 0 0% 15% / 0.5;
--ring: 0 0% 98% / 0.2;
--radius: 1rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
background-image:
radial-gradient(at 0% 0%, rgba(255, 255, 255, 0.03) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(255, 255, 255, 0.02) 0px, transparent 50%);
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const metadata: Metadata = {
title: 'voicebox - Professional Voice Cloning Desktop App',
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.',
keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'],
openGraph: {
title: 'voicebox',
description: 'Professional voice cloning with Qwen3-TTS',
type: 'website',
url: 'https://voicebox.sh',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className="dark">
<body className={inter.variable}>
<div className="relative min-h-screen bg-background font-sans flex flex-col">
<Header />
<main className="container mx-auto px-4 sm:px-6 md:px-4 flex-1 py-4 sm:py-6 md:py-0">
{children}
</main>
<Footer />
</div>
</body>
</html>
);
}
+155
View File
@@ -0,0 +1,155 @@
'use client';
import { Cloud, Code, Cpu, Github, Shield, Zap } from 'lucide-react';
import Image from 'next/image';
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
import { Button } from '@/components/ui/button';
import { Hero } from '@/components/ui/hero';
import { Section, SectionTitle } from '@/components/ui/section';
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
import { FeatureCard } from '../components/ui/feature-card';
export default function Home() {
const features = [
{
title: 'Near-Perfect Voice Cloning',
description:
"Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
icon: <Zap className="h-6 w-6" />,
},
{
title: 'Multi-Sample Support',
description:
'Combine multiple voice samples for higher quality and more natural-sounding results.',
icon: <Code className="h-6 w-6" />,
},
{
title: 'Smart Caching',
description: 'Instant re-generation with voice prompt caching. No need to reprocess samples.',
icon: <Zap className="h-6 w-6" />,
},
{
title: 'Local or Remote',
description:
'Run GPU inference locally or connect to a remote machine. One-click server setup.',
icon: <Cloud className="h-6 w-6" />,
},
{
title: 'Audio Transcription',
description:
'Powered by Whisper for accurate speech-to-text. Extract reference text from voice samples automatically.',
icon: <Shield className="h-6 w-6" />,
},
{
title: 'Cross-Platform',
description: 'Available for macOS, Windows, and Linux. No Python installation required.',
icon: <Cpu className="h-6 w-6" />,
},
];
return (
<div className="space-y-12 sm:space-y-16 md:space-y-20">
{/* Hero Section */}
<Hero
title="voicebox"
description="Open source voice cloning powered by Qwen3-TTS. Create natural-sounding speech from text with near-perfect voice replication."
actions={
<div className="space-y-4 w-full lg:w-auto">
<div className="w-full max-w-2xl mb-6">
<Image
src="/App.webp"
alt="Voicebox Application Screenshot"
width={1920}
height={1080}
className="w-full h-auto rounded-lg border border-border shadow-lg"
priority
/>
</div>
<div>
<h2 className="text-2xl sm:text-3xl font-bold mb-1">Download</h2>
<p className="text-sm text-muted-foreground">Choose your platform</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl">
<Button asChild size="lg" className="w-full px-0">
<a
href={DOWNLOAD_LINKS.macArm}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">macOS (ARM)</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a
href={DOWNLOAD_LINKS.macIntel}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">macOS (Intel)</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a
href={DOWNLOAD_LINKS.windows}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<WindowsIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">Windows</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a
href={DOWNLOAD_LINKS.linux}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<LinuxIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">Linux</span>
</a>
</Button>
</div>
<Button variant="outline" size="lg" asChild className="w-full">
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
<Github className="h-4 w-4 mr-2" />
View on GitHub
</a>
</Button>
</div>
}
/>
{/* Features Section */}
<Section id="features">
<SectionTitle className="mb-4 text-center">Features</SectionTitle>
<p className="text-sm text-muted-foreground mb-8 text-center max-w-2xl mx-auto">
Everything you need for professional voice cloning in a desktop app.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{features.map((feature) => (
<FeatureCard
key={feature.title}
title={feature.title}
description={feature.description}
icon={feature.icon}
/>
))}
</div>
</Section>
</div>
);
}
@@ -0,0 +1,64 @@
'use client';
import { Download, Laptop, Monitor, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { DOWNLOAD_LINKS, LATEST_VERSION } from '@/lib/constants';
export function DownloadSection() {
const downloads = [
{
platform: 'Mac',
icon: Laptop,
link: DOWNLOAD_LINKS.mac,
description: 'macOS (Intel + Apple Silicon)',
},
{
platform: 'Windows',
icon: Monitor,
link: DOWNLOAD_LINKS.windows,
description: 'Windows x64',
},
{
platform: 'Linux',
icon: Terminal,
link: DOWNLOAD_LINKS.linux,
description: 'Linux AppImage',
},
];
return (
<div className="space-y-6">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Latest Version</p>
<p className="text-2xl font-bold">{LATEST_VERSION}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
{downloads.map(({ platform, icon: Icon, link, description }) => (
<Card
key={platform}
className="hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5"
>
<CardContent className="p-6">
<div className="flex flex-col items-center text-center space-y-4">
<div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border">
<Icon className="h-8 w-8" />
</div>
<div>
<h3 className="text-lg font-semibold mb-1">{platform}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<Button asChild size="lg" className="w-full">
<a href={link} download>
<Download className="h-4 w-4 mr-2" />
Download
</a>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import Link from 'next/link';
import { Separator } from '@/components/ui/separator';
import { GITHUB_REPO } from '@/lib/constants';
export function Footer() {
return (
<footer className="border-t border-border pt-8 sm:pt-12 pb-6 sm:pb-8 mt-12 sm:mt-16 md:mt-20">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 sm:gap-8 mb-6 sm:mb-8">
<div>
<h3 className="font-bold text-lg mb-4">voicebox</h3>
<p className="text-muted-foreground text-sm">
Professional voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and
Linux.
</p>
</div>
<div>
<h4 className="font-semibold mb-3">Product</h4>
<ul className="space-y-2 text-muted-foreground text-sm">
<li>
<a href="#features" className="hover:text-foreground transition-colors">
Features
</a>
</li>
<li>
<a href="#download" className="hover:text-foreground transition-colors">
Download
</a>
</li>
<li>
<Link
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
GitHub
</Link>
</li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-3">Resources</h4>
<ul className="space-y-2 text-muted-foreground text-sm">
<li>
<Link
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Source Code
</Link>
</li>
<li>
<Link
href={`${GITHUB_REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Releases
</Link>
</li>
<li>
<Link
href={`${GITHUB_REPO}/issues`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Issues
</Link>
</li>
</ul>
</div>
</div>
<Separator className="my-8" />
<div className="text-center text-muted-foreground text-sm space-y-2">
<p>© 2026 voicebox. All rights reserved.</p>
</div>
</div>
</footer>
);
}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import Link from 'next/link';
import { Github } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { GITHUB_REPO } from '@/lib/constants';
export function Header() {
return (
<header className="border-b border-border bg-background/60 backdrop-blur-2xl sticky top-0 z-50 supports-[backdrop-filter]:bg-background/40">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link
href="/"
className="flex items-center gap-2 font-bold text-xl sm:text-2xl hover:opacity-80 transition-opacity tracking-tight"
>
voicebox
</Link>
{/* Actions */}
<div className="flex items-center gap-2 sm:gap-4">
<Button variant="outline" size="sm" asChild className="hidden sm:flex">
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
<Github className="h-4 w-4 mr-2" />
GitHub
</a>
</Button>
<Button variant="ghost" size="icon" asChild className="sm:hidden">
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" aria-label="GitHub">
<Github className="h-5 w-5" />
</a>
</Button>
</div>
</div>
</div>
</header>
);
}
+23
View File
@@ -0,0 +1,23 @@
export function AppleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
);
}
export function WindowsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v7.81l-6-1.15V13zm17 .25V22l-10-1.8v-7.15l10 .15z" />
</svg>
);
}
export function LinuxIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.26-.195.69-.133 1.001.054.27.112.553.077.784-.12.794-.3 1.593-.3 2.406 0 .599.18 1.193.3 1.791.12.599.3 1.193.3 1.792 0 .812.18 1.611.3 2.405.035.23-.023.514-.077.783-.062.312-.127.742.133 1.002a.424.424 0 00.11.135c-.123.805.01 1.657.287 2.489.589 1.771 1.831 3.47 2.716 4.521.75 1.067 0.974 1.928 1.05 3.02.065 1.491-1.056 5.965 3.17 6.298.165.013.325.021.48.021.155 0 .315-.008.48-.021 4.226-.333 3.105-4.807 3.17-6.298.076-1.092.3-1.953 1.05-3.02.885-1.051 2.127-2.75 2.716-4.521.278-.832.41-1.684.287-2.489a.424.424 0 00.11-.135c.26-.26.195-.69.133-1.001-.054-.27-.112-.553-.077-.784.12-.794.3-1.593.3-2.406 0-.599-.18-1.193-.3-1.791-.12-.599-.3-1.193-.3-1.792 0-.812-.18-1.611-.3-2.405-.035-.23.023-.514.077-.783.062-.312.127-.742-.133-1.002a.424.424 0 00-.11-.135c.123-.805-.01-1.657-.287-2.489-.589-1.771-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298C12.819.008 12.659 0 12.504 0z" />
</svg>
);
}

Some files were not shown because too many files have changed in this diff Show More