Compare commits

..
4 Commits
Author SHA1 Message Date
Jamie Pine 3370e3b419 Bump version: 0.1.4 → 0.1.5 2026-01-28 14:38:43 -08:00
Jamie Pine 615bd188a0 Refactor import statements in SampleUpload component for improved readability
- Adjusted the order of imports in SampleUpload.tsx to follow a more conventional structure, enhancing code organization.
2026-01-28 14:38:31 -08:00
Jamie Pine 7208f51eee Refactor audio generation components and improve debugging capabilities
- Introduced useGenerationForm hook to streamline audio generation form handling, including validation and model download management.
- Updated FloatingGenerateBox and GenerationForm components to utilize the new hook, enhancing code organization and reducing duplication.
- Replaced console logging with a debug utility for better logging control during audio playback and generation processes.
- Improved error handling in HistoryTable and MainEditor components by integrating toast notifications for user feedback.
- Adjusted audio recording duration limits across various components for consistency.
2026-01-28 14:30:33 -08:00
Jamie Pine 07a91a2381 Update app version to 0.1.4 and integrate @tanstack/react-router for improved routing functionality
- Bumped version for @voicebox/app, @voicebox/landing, @voicebox/tauri, and @voicebox/web to 0.1.4.
- Added @tanstack/react-router dependency for enhanced routing capabilities.
- Refactored App component to utilize RouterProvider for routing management.
- Created a new router configuration in router.tsx to define application routes and layout.
- Updated Sidebar component to use Link from @tanstack/react-router for navigation.
2026-01-28 00:00:18 -08:00
28 changed files with 503 additions and 459 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.1.4 current_version = 0.1.5
commit = True commit = True
tag = True tag = True
tag_name = v{new_version} tag_name = v{new_version}
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@voicebox/app", "name": "@voicebox/app",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -30,6 +30,7 @@
"@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",
"@tanstack/react-router": "^1.157.16",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0", "@tauri-apps/plugin-fs": "^2.0.0",
+9 -77
View File
@@ -1,32 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { RouterProvider } from '@tanstack/react-router';
import voiceboxLogo from '@/assets/voicebox-logo.png'; import voiceboxLogo from '@/assets/voicebox-logo.png';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudioTab } from '@/components/AudioTab/AudioTab';
import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
import { ServerTab } from '@/components/ServerTab/ServerTab';
// import { GenerationForm } from '@/components/Generation/GenerationForm';
import ShinyText from '@/components/ShinyText'; import ShinyText from '@/components/ShinyText';
import { Sidebar } from '@/components/Sidebar';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
import { import {
isMacOS,
isTauri, isTauri,
setKeepServerRunning, setKeepServerRunning,
setupWindowCloseHandler, setupWindowCloseHandler,
startServer, startServer,
} from '@/lib/tauri'; } from '@/lib/tauri';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { router } from '@/router';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
// Track if server is starting to prevent duplicate starts
let serverStarting = false;
const LOADING_MESSAGES = [ const LOADING_MESSAGES = [
'Warming up tensors...', 'Warming up tensors...',
'Calibrating synthesizer engine...', 'Calibrating synthesizer engine...',
@@ -51,12 +38,9 @@ const LOADING_MESSAGES = [
]; ];
function App() { function App() {
const [activeTab, setActiveTab] = useState('main');
const [serverReady, setServerReady] = useState(false); const [serverReady, setServerReady] = useState(false);
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0); const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
const serverStartingRef = useRef(false);
// Monitor active downloads/generations and show toasts for them
const activeDownloads = useRestoreActiveTasks();
// Sync stored setting to Rust on startup // Sync stored setting to Rust on startup
useEffect(() => { useEffect(() => {
@@ -92,11 +76,11 @@ function App() {
} }
// Auto-start server in production // Auto-start server in production
if (serverStarting) { if (serverStartingRef.current) {
return; return;
} }
serverStarting = true; serverStartingRef.current = true;
console.log('Production mode: Starting bundled server...'); console.log('Production mode: Starting bundled server...');
startServer(false) startServer(false)
@@ -106,13 +90,11 @@ function App() {
useServerStore.getState().setServerUrl(serverUrl); useServerStore.getState().setServerUrl(serverUrl);
setServerReady(true); setServerReady(true);
// Mark that we started the server (so we know to stop it on close) // Mark that we started the server (so we know to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = true; 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; serverStartingRef.current = false;
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false; window.__voiceboxServerStartedByApp = false;
}); });
@@ -120,7 +102,7 @@ function App() {
// Note: Window close is handled separately in Tauri Rust code // Note: Window close is handled separately in Tauri Rust code
return () => { return () => {
// Window close event handles server shutdown based on setting // Window close event handles server shutdown based on setting
serverStarting = false; serverStartingRef.current = false;
}; };
}, []); }, []);
@@ -172,57 +154,7 @@ function App() {
); );
} }
return ( return <RouterProvider router={router} />;
<AppFrame>
<div className="flex flex-1 min-h-0 overflow-hidden">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} isMacOS={isMacOS()} />
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
{activeTab === 'main' && <MainEditor />}
{activeTab === 'voices' && <VoicesTab />}
{activeTab === 'audio' && <AudioTab />}
{activeTab === 'server' && <ServerTab />}
{activeTab === 'models' && <ModelsTab />}
</div>
</main>
</div>
{/* Show download toasts for any active downloads (from anywhere) */}
{activeDownloads.map((download) => {
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
return (
<DownloadToastRestorer
key={download.model_name}
modelName={download.model_name}
displayName={displayName}
/>
);
})}
<Toaster />
</AppFrame>
);
}
/**
* Component that restores a download toast for a specific model.
*/
function DownloadToastRestorer({
modelName,
displayName,
}: {
modelName: string;
displayName: string;
}) {
// Use the download toast hook to restore the toast
useModelDownloadToast({
modelName,
displayName,
enabled: true,
});
return null;
} }
export default App; export default App;
+70 -80
View File
@@ -8,6 +8,7 @@ import { Slider } from '@/components/ui/slider';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
import { formatAudioDuration } from '@/lib/utils/audio'; import { formatAudioDuration } from '@/lib/utils/audio';
import { debug } from '@/lib/utils/debug';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
export function AudioPlayer() { export function AudioPlayer() {
@@ -49,28 +50,17 @@ export function AudioPlayer() {
// Determine if we should use native playback // Determine if we should use native playback
const useNativePlayback = useMemo(() => { const useNativePlayback = useMemo(() => {
console.log('useNativePlayback memo:', {
isTauri: isTauri(),
profileId,
profileChannels,
channels,
});
if (!isTauri() || !profileChannels || !channels) { if (!isTauri() || !profileChannels || !channels) {
console.log('useNativePlayback: false - missing requirements');
return false; return false;
} }
const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id)); const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
console.log('Assigned channels:', assignedChannels);
// Use native playback if any assigned channel has non-default devices // Use native playback if any assigned channel has non-default devices
const shouldUseNative = assignedChannels.some( const shouldUseNative = assignedChannels.some(
(ch) => ch.device_ids.length > 0 && !ch.is_default, (ch) => ch.device_ids.length > 0 && !ch.is_default,
); );
console.log('useNativePlayback result:', shouldUseNative);
return shouldUseNative; return shouldUseNative;
}, [profileChannels, channels, profileId]); }, [profileChannels, channels, profileId]);
@@ -91,11 +81,11 @@ export function AudioPlayer() {
} }
if (wavesurferRef.current) { if (wavesurferRef.current) {
console.log('WaveSurfer already initialized, skipping'); debug.log('WaveSurfer already initialized, skipping');
return; return;
} }
console.log('Creating NEW WaveSurfer instance'); debug.log('Creating NEW WaveSurfer instance');
// Wait for container to be properly rendered // Wait for container to be properly rendered
const initWaveSurfer = () => { const initWaveSurfer = () => {
@@ -121,7 +111,7 @@ export function AudioPlayer() {
return; return;
} }
console.log('Initializing WaveSurfer...', { debug.log('Initializing WaveSurfer...', {
container, container,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
@@ -154,9 +144,9 @@ export function AudioPlayer() {
}); });
wavesurferRef.current = wavesurfer; wavesurferRef.current = wavesurfer;
console.log('WaveSurfer created successfully'); debug.log('WaveSurfer created successfully');
} catch (error) { } catch (error) {
console.error('Failed to create WaveSurfer:', error); debug.error('Failed to create WaveSurfer:', error);
setError( setError(
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`, `Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
); );
@@ -178,8 +168,8 @@ export function AudioPlayer() {
loadingRef.current = false; loadingRef.current = false;
setIsLoading(false); setIsLoading(false);
setError(null); setError(null);
console.log('Audio ready, duration:', dur); debug.log('Audio ready, duration:', dur);
console.log('Waveform should be visible now'); debug.log('Waveform should be visible now');
// Ensure volume is set // Ensure volume is set
const currentVolume = usePlayerStore.getState().volume; const currentVolume = usePlayerStore.getState().volume;
@@ -191,7 +181,7 @@ export function AudioPlayer() {
if (mediaElement && !isUsingNativePlaybackRef.current) { if (mediaElement && !isUsingNativePlaybackRef.current) {
mediaElement.volume = currentVolume; mediaElement.volume = currentVolume;
mediaElement.muted = false; mediaElement.muted = false;
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted); debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
} }
// Auto-play when ready - check if we should use native playback // Auto-play when ready - check if we should use native playback
@@ -199,7 +189,7 @@ export function AudioPlayer() {
const currentAudioUrl = usePlayerStore.getState().audioUrl; const currentAudioUrl = usePlayerStore.getState().audioUrl;
const currentProfileId = usePlayerStore.getState().profileId; const currentProfileId = usePlayerStore.getState().profileId;
console.log('Auto-play check - capturing runtime values...'); debug.log('Auto-play check - capturing runtime values...');
// Fetch profile channels at runtime (not using captured value) // Fetch profile channels at runtime (not using captured value)
let runtimeProfileChannels = null; let runtimeProfileChannels = null;
@@ -208,18 +198,18 @@ export function AudioPlayer() {
if (isTauri() && currentProfileId) { if (isTauri() && currentProfileId) {
try { try {
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId); runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
console.log('Runtime profileChannels:', runtimeProfileChannels); debug.log('Runtime profileChannels:', runtimeProfileChannels);
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) { if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
runtimeChannels = await apiClient.listChannels(); runtimeChannels = await apiClient.listChannels();
console.log('Runtime channels:', runtimeChannels); debug.log('Runtime channels:', runtimeChannels);
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch runtime channel data:', error); debug.error('Failed to fetch runtime channel data:', error);
} }
} }
console.log('Auto-play check:', { debug.log('Auto-play check:', {
isTauri: isTauri(), isTauri: isTauri(),
currentAudioUrl, currentAudioUrl,
currentProfileId, currentProfileId,
@@ -234,15 +224,15 @@ export function AudioPlayer() {
runtimeProfileChannels && runtimeProfileChannels &&
runtimeChannels runtimeChannels
) { ) {
console.log('Attempting native audio playback...'); debug.log('Attempting native audio playback...');
// Stop any existing native playback first // Stop any existing native playback first
if (isUsingNativePlaybackRef.current) { if (isUsingNativePlaybackRef.current) {
try { try {
await invoke('stop_audio_playback'); await invoke('stop_audio_playback');
console.log('Stopped existing native playback before starting new one'); debug.log('Stopped existing native playback before starting new one');
} catch (error) { } catch (error) {
console.error('Failed to stop existing playback:', error); debug.error('Failed to stop existing playback:', error);
} }
} }
@@ -251,16 +241,16 @@ export function AudioPlayer() {
const assignedChannels = runtimeChannels.filter((ch: any) => const assignedChannels = runtimeChannels.filter((ch: any) =>
runtimeProfileChannels.channel_ids.includes(ch.id), runtimeProfileChannels.channel_ids.includes(ch.id),
); );
console.log('Assigned channels for playback:', assignedChannels); debug.log('Assigned channels for playback:', assignedChannels);
// Check if any assigned channel has non-default devices // Check if any assigned channel has non-default devices
const shouldUseNative = assignedChannels.some( const shouldUseNative = assignedChannels.some(
(ch: any) => ch.device_ids.length > 0 && !ch.is_default, (ch: any) => ch.device_ids.length > 0 && !ch.is_default,
); );
console.log('Should use native playback:', shouldUseNative); debug.log('Should use native playback:', shouldUseNative);
if (!shouldUseNative) { if (!shouldUseNative) {
console.log('No custom devices assigned, falling back to WaveSurfer'); debug.log('No custom devices assigned, falling back to WaveSurfer');
// Reset native playback flag and unmute WaveSurfer // Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false; isUsingNativePlaybackRef.current = false;
const mediaElement = wavesurfer.getMediaElement(); const mediaElement = wavesurfer.getMediaElement();
@@ -268,7 +258,7 @@ export function AudioPlayer() {
const currentVolume = usePlayerStore.getState().volume; const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume; mediaElement.volume = currentVolume;
mediaElement.muted = false; mediaElement.muted = false;
console.log( debug.log(
'WaveSurfer unmuted for normal playback - volume:', 'WaveSurfer unmuted for normal playback - volume:',
mediaElement.volume, mediaElement.volume,
'muted:', 'muted:',
@@ -277,23 +267,23 @@ export function AudioPlayer() {
} }
} else { } else {
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids); const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
console.log('Device IDs to play to:', deviceIds); debug.log('Device IDs to play to:', deviceIds);
if (deviceIds.length > 0) { if (deviceIds.length > 0) {
console.log('Fetching audio data from:', currentAudioUrl); debug.log('Fetching audio data from:', currentAudioUrl);
// Fetch audio data // Fetch audio data
const response = await fetch(currentAudioUrl); const response = await fetch(currentAudioUrl);
const audioData = new Uint8Array(await response.arrayBuffer()); const audioData = new Uint8Array(await response.arrayBuffer());
console.log('Audio data size:', audioData.length); debug.log('Audio data size:', audioData.length);
// Play via native audio // Play via native audio
console.log('Invoking play_audio_to_devices...'); debug.log('Invoking play_audio_to_devices...');
try { try {
const result = await invoke('play_audio_to_devices', { const result = await invoke('play_audio_to_devices', {
audioData: Array.from(audioData), audioData: Array.from(audioData),
deviceIds: deviceIds, deviceIds: deviceIds,
}); });
console.log('play_audio_to_devices completed successfully, result:', result); debug.log('play_audio_to_devices completed successfully, result:', result);
// Mark that we're using native playback // Mark that we're using native playback
isUsingNativePlaybackRef.current = true; isUsingNativePlaybackRef.current = true;
@@ -304,7 +294,7 @@ export function AudioPlayer() {
if (mediaElement) { if (mediaElement) {
mediaElement.volume = 0; mediaElement.volume = 0;
mediaElement.muted = true; mediaElement.muted = true;
console.log( debug.log(
'WaveSurfer muted for native playback - volume:', 'WaveSurfer muted for native playback - volume:',
mediaElement.volume, mediaElement.volume,
'muted:', 'muted:',
@@ -314,22 +304,22 @@ export function AudioPlayer() {
// Start WaveSurfer playback for visualization (muted) // Start WaveSurfer playback for visualization (muted)
wavesurfer.play().catch((error) => { wavesurfer.play().catch((error) => {
console.error('Failed to start WaveSurfer visualization:', error); debug.error('Failed to start WaveSurfer visualization:', error);
}); });
setIsPlaying(true); setIsPlaying(true);
console.log('Auto-playing via native audio routing - SUCCESS'); debug.log('Auto-playing via native audio routing - SUCCESS');
return; return;
} catch (invokeError) { } catch (invokeError) {
console.error('play_audio_to_devices invoke failed:', invokeError); debug.error('play_audio_to_devices invoke failed:', invokeError);
throw invokeError; throw invokeError;
} }
} else { } else {
console.log('No device IDs found, falling back to WaveSurfer'); debug.log('No device IDs found, falling back to WaveSurfer');
} }
} }
} catch (error) { } catch (error) {
console.error( debug.error(
'Native playback failed during auto-play, falling back to WaveSurfer:', 'Native playback failed during auto-play, falling back to WaveSurfer:',
error, error,
); );
@@ -340,7 +330,7 @@ export function AudioPlayer() {
const currentVolume = usePlayerStore.getState().volume; const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume; mediaElement.volume = currentVolume;
mediaElement.muted = false; mediaElement.muted = false;
console.log( debug.log(
'WaveSurfer unmuted after native playback failure - volume:', 'WaveSurfer unmuted after native playback failure - volume:',
mediaElement.volume, mediaElement.volume,
'muted:', 'muted:',
@@ -350,7 +340,7 @@ export function AudioPlayer() {
// Fall through to WaveSurfer playback // Fall through to WaveSurfer playback
} }
} else { } else {
console.log('Not using native playback, using WaveSurfer'); debug.log('Not using native playback, using WaveSurfer');
// Reset native playback flag and unmute WaveSurfer // Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false; isUsingNativePlaybackRef.current = false;
const mediaElement = wavesurfer.getMediaElement(); const mediaElement = wavesurfer.getMediaElement();
@@ -358,7 +348,7 @@ export function AudioPlayer() {
const currentVolume = usePlayerStore.getState().volume; const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume; mediaElement.volume = currentVolume;
mediaElement.muted = false; mediaElement.muted = false;
console.log( debug.log(
'WaveSurfer unmuted for normal playback - volume:', 'WaveSurfer unmuted for normal playback - volume:',
mediaElement.volume, mediaElement.volume,
'muted:', 'muted:',
@@ -371,7 +361,7 @@ export function AudioPlayer() {
// Use a small delay to ensure audio element is fully ready // Use a small delay to ensure audio element is fully ready
setTimeout(() => { setTimeout(() => {
wavesurfer.play().catch((error) => { wavesurfer.play().catch((error) => {
console.error('Failed to autoplay:', error); debug.error('Failed to autoplay:', error);
// Don't show error for autoplay failures (browser restrictions) // Don't show error for autoplay failures (browser restrictions)
}); });
}, 100); }, 100);
@@ -388,13 +378,13 @@ export function AudioPlayer() {
if (isUsingNativePlaybackRef.current) { if (isUsingNativePlaybackRef.current) {
mediaElement.volume = 0; mediaElement.volume = 0;
mediaElement.muted = true; mediaElement.muted = true;
console.log('Playing (native mode) - WaveSurfer muted for visualization only'); debug.log('Playing (native mode) - WaveSurfer muted for visualization only');
} else { } else {
// Ensure WaveSurfer is unmuted for normal playback // Ensure WaveSurfer is unmuted for normal playback
const currentVolume = usePlayerStore.getState().volume; const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume; mediaElement.volume = currentVolume;
mediaElement.muted = false; mediaElement.muted = false;
console.log( debug.log(
'Playing (normal mode) - volume:', 'Playing (normal mode) - volume:',
mediaElement.volume, mediaElement.volume,
'muted:', 'muted:',
@@ -417,7 +407,7 @@ export function AudioPlayer() {
// Handle errors // Handle errors
wavesurfer.on('error', (error) => { wavesurfer.on('error', (error) => {
console.error('WaveSurfer error:', error); debug.error('WaveSurfer error:', error);
setIsLoading(false); setIsLoading(false);
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`); setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
}); });
@@ -432,7 +422,7 @@ export function AudioPlayer() {
// Load audio immediately if audioUrl is already set // Load audio immediately if audioUrl is already set
if (audioUrl) { if (audioUrl) {
console.log('WaveSurfer ready, loading audio:', audioUrl); debug.log('WaveSurfer ready, loading audio:', audioUrl);
loadingRef.current = true; loadingRef.current = true;
setIsLoading(true); setIsLoading(true);
// Stop any current playback before loading new audio // Stop any current playback before loading new audio
@@ -442,11 +432,11 @@ export function AudioPlayer() {
wavesurfer wavesurfer
.load(audioUrl) .load(audioUrl)
.then(() => { .then(() => {
console.log('Audio loaded into WaveSurfer'); debug.log('Audio loaded into WaveSurfer');
loadingRef.current = false; loadingRef.current = false;
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to load audio into WaveSurfer:', error); debug.error('Failed to load audio into WaveSurfer:', error);
loadingRef.current = false; loadingRef.current = false;
setIsLoading(false); setIsLoading(false);
setError( setError(
@@ -471,12 +461,12 @@ export function AudioPlayer() {
}); });
return () => { return () => {
console.log('Cleaning up WaveSurfer initialization effect'); debug.log('Cleaning up WaveSurfer initialization effect');
if (rafId1) cancelAnimationFrame(rafId1); if (rafId1) cancelAnimationFrame(rafId1);
if (rafId2) cancelAnimationFrame(rafId2); if (rafId2) cancelAnimationFrame(rafId2);
if (timeoutId) clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
if (wavesurferRef.current) { if (wavesurferRef.current) {
console.log('Destroying WaveSurfer instance'); debug.log('Destroying WaveSurfer instance');
try { try {
const mediaElement = wavesurferRef.current.getMediaElement(); const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) { if (mediaElement) {
@@ -485,7 +475,7 @@ export function AudioPlayer() {
} }
wavesurferRef.current.destroy(); wavesurferRef.current.destroy();
} catch (error) { } catch (error) {
console.error('Error destroying WaveSurfer:', error); debug.error('Error destroying WaveSurfer:', error);
} }
wavesurferRef.current = null; wavesurferRef.current = null;
} }
@@ -517,9 +507,9 @@ export function AudioPlayer() {
(async () => { (async () => {
try { try {
await invoke('stop_audio_playback'); await invoke('stop_audio_playback');
console.log('Stopped native audio playback'); debug.log('Stopped native audio playback');
} catch (error) { } catch (error) {
console.error('Failed to stop native playback:', error); debug.error('Failed to stop native playback:', error);
} }
})(); })();
} }
@@ -537,30 +527,30 @@ export function AudioPlayer() {
// CRITICAL: Force stop any current playback and cancel any pending loads // CRITICAL: Force stop any current playback and cancel any pending loads
// This must happen BEFORE any early returns // This must happen BEFORE any early returns
console.log('Audio URL changed to:', audioUrl); debug.log('Audio URL changed to:', audioUrl);
// COMPLETELY stop and destroy the current audio // COMPLETELY stop and destroy the current audio
try { try {
// First pause if playing // First pause if playing
if (wavesurfer.isPlaying()) { if (wavesurfer.isPlaying()) {
console.log('Pausing current playback'); debug.log('Pausing current playback');
wavesurfer.pause(); wavesurfer.pause();
} }
// Stop the media element explicitly // Stop the media element explicitly
const mediaElement = wavesurfer.getMediaElement(); const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) { if (mediaElement) {
console.log('Stopping media element'); debug.log('Stopping media element');
mediaElement.pause(); mediaElement.pause();
mediaElement.currentTime = 0; mediaElement.currentTime = 0;
mediaElement.src = ''; mediaElement.src = '';
} }
// Use empty() to completely destroy the waveform and media element // Use empty() to completely destroy the waveform and media element
console.log('Calling wavesurfer.empty() to destroy audio'); debug.log('Calling wavesurfer.empty() to destroy audio');
wavesurfer.empty(); wavesurfer.empty();
} catch (error) { } catch (error) {
console.error('Error stopping previous audio:', error); debug.error('Error stopping previous audio:', error);
// Continue anyway to load new audio // Continue anyway to load new audio
} }
@@ -575,16 +565,16 @@ export function AudioPlayer() {
setDuration(0); setDuration(0);
// Load new audio // Load new audio
console.log('Starting new audio load for:', audioUrl); debug.log('Starting new audio load for:', audioUrl);
wavesurfer wavesurfer
.load(audioUrl) .load(audioUrl)
.then(() => { .then(() => {
console.log('Audio load promise resolved'); debug.log('Audio load promise resolved');
// Don't set loading to false here - wait for 'ready' event // Don't set loading to false here - wait for 'ready' event
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to load audio:', error); debug.error('Failed to load audio:', error);
console.error('Audio URL:', audioUrl); debug.error('Audio URL:', audioUrl);
loadingRef.current = false; loadingRef.current = false;
setIsLoading(false); setIsLoading(false);
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`); setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
@@ -599,7 +589,7 @@ export function AudioPlayer() {
if (isPlaying && wavesurferRef.current.isPlaying() === false) { if (isPlaying && wavesurferRef.current.isPlaying() === false) {
// Only auto-play if audio is ready // Only auto-play if audio is ready
wavesurferRef.current.play().catch((error) => { wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error); debug.error('Failed to play:', error);
setIsPlaying(false); setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
}); });
@@ -619,11 +609,11 @@ export function AudioPlayer() {
if (isUsingNativePlaybackRef.current) { if (isUsingNativePlaybackRef.current) {
mediaElement.volume = 0; mediaElement.volume = 0;
mediaElement.muted = true; mediaElement.muted = true;
console.log('Volume sync: Using native playback, keeping WaveSurfer muted'); debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
} else { } else {
mediaElement.volume = volume; mediaElement.volume = volume;
mediaElement.muted = volume === 0; mediaElement.muted = volume === 0;
console.log('Volume synced:', volume, 'muted:', mediaElement.muted); debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
} }
} }
} }
@@ -651,10 +641,10 @@ export function AudioPlayer() {
} }
// Reset to beginning and play // Reset to beginning and play
console.log('Restarting current audio from beginning'); debug.log('Restarting current audio from beginning');
wavesurfer.seekTo(0); wavesurfer.seekTo(0);
wavesurfer.play().catch((error) => { wavesurfer.play().catch((error) => {
console.error('Failed to play after restart:', error); debug.error('Failed to play after restart:', error);
setIsPlaying(false); setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
}); });
@@ -669,13 +659,13 @@ export function AudioPlayer() {
// Standard WaveSurfer playback (works for both normal and native playback modes) // Standard WaveSurfer playback (works for both normal and native playback modes)
// When using native playback, WaveSurfer is muted but still controls visualization // When using native playback, WaveSurfer is muted but still controls visualization
if (!wavesurferRef.current) { if (!wavesurferRef.current) {
console.error('WaveSurfer not initialized'); debug.error('WaveSurfer not initialized');
return; return;
} }
// Check if audio is loaded // Check if audio is loaded
if (duration === 0 && !isLoading) { if (duration === 0 && !isLoading) {
console.error('Audio not loaded yet'); debug.error('Audio not loaded yet');
setError('Audio not loaded. Please wait...'); setError('Audio not loaded. Please wait...');
return; return;
} }
@@ -686,9 +676,9 @@ export function AudioPlayer() {
// Pause: stop native playback and pause WaveSurfer visualization // Pause: stop native playback and pause WaveSurfer visualization
try { try {
await invoke('stop_audio_playback'); await invoke('stop_audio_playback');
console.log('Stopped native audio playback'); debug.log('Stopped native audio playback');
} catch (error) { } catch (error) {
console.error('Failed to stop native playback:', error); debug.error('Failed to stop native playback:', error);
} }
wavesurferRef.current.pause(); wavesurferRef.current.pause();
return; return;
@@ -701,7 +691,7 @@ export function AudioPlayer() {
await invoke('stop_audio_playback'); await invoke('stop_audio_playback');
} catch (_error) { } catch (_error) {
// Ignore errors when stopping (might not be playing) // Ignore errors when stopping (might not be playing)
console.log('No existing playback to stop'); debug.log('No existing playback to stop');
} }
// Collect all device IDs from assigned channels // Collect all device IDs from assigned channels
@@ -733,7 +723,7 @@ export function AudioPlayer() {
// Start WaveSurfer for visualization (muted) // Start WaveSurfer for visualization (muted)
wavesurferRef.current.play().catch((error) => { wavesurferRef.current.play().catch((error) => {
console.error('Failed to start WaveSurfer visualization:', error); debug.error('Failed to start WaveSurfer visualization:', error);
setIsPlaying(false); setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
}); });
@@ -741,7 +731,7 @@ export function AudioPlayer() {
return; return;
} }
} catch (error) { } catch (error) {
console.error('Native playback failed, falling back to WaveSurfer:', error); debug.error('Native playback failed, falling back to WaveSurfer:', error);
// Fall through to WaveSurfer playback // Fall through to WaveSurfer playback
isUsingNativePlaybackRef.current = false; isUsingNativePlaybackRef.current = false;
} }
@@ -761,7 +751,7 @@ export function AudioPlayer() {
} }
wavesurferRef.current.play().catch((error) => { wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error); debug.error('Failed to play:', error);
setIsPlaying(false); setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
}); });
@@ -782,7 +772,7 @@ export function AudioPlayer() {
// Stop any native playback // Stop any native playback
if (isUsingNativePlaybackRef.current && isTauri()) { if (isUsingNativePlaybackRef.current && isTauri()) {
invoke('stop_audio_playback').catch((error) => { invoke('stop_audio_playback').catch((error) => {
console.error('Failed to stop native playback:', error); debug.error('Failed to stop native playback:', error);
}); });
} }
// Stop WaveSurfer // Stop WaveSurfer
@@ -1,9 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, Sparkles } from 'lucide-react'; import { Loader2, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { import {
@@ -14,24 +11,11 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
import { apiClient } from '@/lib/api/client'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useProfile } from '@/lib/hooks/useProfiles'; import { useProfile } from '@/lib/hooks/useProfiles';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
});
type GenerationFormValues = z.infer<typeof generationSchema>;
interface FloatingGenerateBoxProps { interface FloatingGenerateBoxProps {
isPlayerOpen: boolean; isPlayerOpen: boolean;
} }
@@ -39,27 +23,12 @@ interface FloatingGenerateBoxProps {
export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) { export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) {
const selectedProfileId = useUIStore((state) => state.selectedProfileId); const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || ''); const { data: selectedProfile } = useProfile(selectedProfileId || '');
const generation = useGeneration();
const { toast } = useToast();
const setAudio = usePlayerStore((state) => state.setAudio);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useModelDownloadToast({ const { form, handleSubmit, isPending } = useGenerationForm({
modelName: downloadingModelName || '', onSuccess: () => {
displayName: downloadingDisplayName || '', setIsExpanded(false);
enabled: !!downloadingModelName,
});
const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema),
defaultValues: {
text: '',
language: 'en',
modelSize: '1.7B',
}, },
}); });
@@ -93,62 +62,8 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
}; };
}, [isExpanded]); }, [isExpanded]);
async function onSubmit(data: GenerationFormValues) { async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
if (!selectedProfileId) { await handleSubmit(data, selectedProfileId);
toast({
title: 'No profile selected',
description: 'Please select a voice profile from the cards above.',
variant: 'destructive',
});
return;
}
try {
setIsGenerating(true);
const modelName = `qwen-tts-${data.modelSize}`;
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
try {
const modelStatus = await apiClient.getModelStatus();
const model = modelStatus.models.find((m) => m.model_name === modelName);
if (model && !model.downloaded) {
setDownloadingModelName(modelName);
setDownloadingDisplayName(displayName);
}
} catch (error) {
console.error('Failed to check model status:', error);
}
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
text: data.text,
language: data.language,
model_size: data.modelSize,
});
toast({
title: 'Generation complete!',
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
const audioUrl = apiClient.getAudioUrl(result.id);
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset();
setIsExpanded(false);
} catch (error) {
toast({
title: 'Generation failed',
description: error instanceof Error ? error.message : 'Failed to generate audio',
variant: 'destructive',
});
} finally {
setIsGenerating(false);
setDownloadingModelName(null);
setDownloadingDisplayName(null);
}
} }
return ( return (
@@ -168,7 +83,6 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
<div className="flex gap-2"> <div className="flex gap-2">
<motion.div <motion.div
className="flex-1" className="flex-1"
// animate={{ marginBottom: isExpanded ? '0.75rem' : '0' }}
transition={{ duration: 0.3, ease: 'easeOut' }} transition={{ duration: 0.3, ease: 'easeOut' }}
> >
<FormField <FormField
@@ -202,11 +116,11 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
<Button <Button
type="submit" type="submit"
disabled={generation.isPending || !selectedProfileId} disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200" className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200"
size="icon" size="icon"
> >
{generation.isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
@@ -1,8 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Mic } from 'lucide-react'; import { Loader2, Mic } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
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';
import { import {
@@ -23,118 +19,19 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
import { apiClient } from '@/lib/api/client'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useProfile } from '@/lib/hooks/useProfiles'; import { useProfile } from '@/lib/hooks/useProfiles';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
instruct: z.string().max(500).optional(),
});
type GenerationFormValues = z.infer<typeof generationSchema>;
export function GenerationForm() { export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId); const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || ''); const { data: selectedProfile } = useProfile(selectedProfileId || '');
const generation = useGeneration();
const { toast } = useToast();
const setAudio = usePlayerStore((state) => state.setAudio);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
// Use the download toast hook to show progress when model is downloading const { form, handleSubmit, isPending } = useGenerationForm();
useModelDownloadToast({
modelName: downloadingModelName || '',
displayName: downloadingDisplayName || '',
enabled: !!downloadingModelName,
});
const form = useForm<GenerationFormValues>({ async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
resolver: zodResolver(generationSchema), await handleSubmit(data, selectedProfileId);
defaultValues: {
text: '',
language: 'en',
seed: undefined,
modelSize: '1.7B',
instruct: '',
},
});
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 {
setIsGenerating(true);
// Determine model name and display name
const modelName = `qwen-tts-${data.modelSize}`;
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
// Check if model is downloaded before starting generation
try {
const modelStatus = await apiClient.getModelStatus();
const model = modelStatus.models.find((m) => m.model_name === modelName);
if (model && !model.downloaded) {
// Model is not downloaded, enable download toast
setDownloadingModelName(modelName);
setDownloadingDisplayName(displayName);
}
} catch (error) {
// If status check fails, continue anyway - generation will handle it
console.error('Failed to check model status:', error);
}
// Proceed with generation (which will trigger download if needed)
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
text: data.text,
language: data.language,
seed: data.seed,
model_size: data.modelSize,
instruct: data.instruct || undefined,
});
toast({
title: 'Generation complete!',
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
// Autoplay the generated audio
const audioUrl = apiClient.getAudioUrl(result.id);
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset();
} catch (error) {
toast({
title: 'Generation failed',
description: error instanceof Error ? error.message : 'Failed to generate audio',
variant: 'destructive',
});
} finally {
setIsGenerating(false);
// Clear download state after generation completes
setDownloadingModelName(null);
setDownloadingDisplayName(null);
}
} }
return ( return (
@@ -276,9 +173,9 @@ export function GenerationForm() {
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
disabled={generation.isPending || !selectedProfileId} disabled={isPending || !selectedProfileId}
> >
{generation.isPending ? ( {isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating... Generating...
+26 -22
View File
@@ -16,6 +16,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { import {
@@ -41,6 +42,7 @@ export function HistoryTable() {
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const limit = 20; const limit = 20;
const { toast } = useToast();
const { data: historyData, isLoading } = useHistory({ const { data: historyData, isLoading } = useHistory({
limit, limit,
@@ -86,7 +88,11 @@ export function HistoryTable() {
{ generationId, text }, { generationId, text },
{ {
onError: (error) => { onError: (error) => {
alert(`Failed to download audio: ${error.message}`); toast({
title: 'Failed to download audio',
description: error.message,
variant: 'destructive',
});
}, },
}, },
); );
@@ -97,7 +103,11 @@ export function HistoryTable() {
{ generationId, text }, { generationId, text },
{ {
onError: (error) => { onError: (error) => {
alert(`Failed to export generation: ${error.message}`); toast({
title: 'Failed to export generation',
description: error.message,
variant: 'destructive',
});
}, },
}, },
); );
@@ -112,7 +122,11 @@ export function HistoryTable() {
if (file) { if (file) {
// Validate file extension // Validate file extension
if (!file.name.endsWith('.voicebox.zip')) { if (!file.name.endsWith('.voicebox.zip')) {
alert('Please select a valid .voicebox.zip file'); toast({
title: 'Invalid file type',
description: 'Please select a valid .voicebox.zip file',
variant: 'destructive',
});
return; return;
} }
setSelectedFile(file); setSelectedFile(file);
@@ -129,10 +143,17 @@ export function HistoryTable() {
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
alert(data.message || 'Generation imported successfully'); toast({
title: 'Generation imported',
description: data.message || 'Generation imported successfully',
});
}, },
onError: (error) => { onError: (error) => {
alert(`Failed to import generation: ${error.message}`); toast({
title: 'Failed to import generation',
description: error.message,
variant: 'destructive',
});
}, },
}); });
} }
@@ -148,23 +169,6 @@ export function HistoryTable() {
return ( return (
<div className="flex flex-col h-full min-h-0 relative"> <div className="flex flex-col h-full min-h-0 relative">
{/* <div className="flex justify-between items-center mb-4 shrink-0">
<h2 className="text-2xl font-bold">History</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleImportClick}>
<Upload className="mr-2 h-4 w-4" />
Import Generation
</Button>
<input
ref={fileInputRef}
type="file"
accept=".voicebox.zip"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div> */}
{history.length === 0 ? ( {history.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center"> <div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
No voice generations, yet... No voice generations, yet...
+16 -8
View File
@@ -11,6 +11,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useToast } from '@/components/ui/use-toast';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useImportProfile } from '@/lib/hooks/useProfiles'; import { useImportProfile } from '@/lib/hooks/useProfiles';
@@ -27,6 +28,7 @@ export function MainEditor() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const { toast } = useToast();
const handleImportClick = () => { const handleImportClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
@@ -36,7 +38,11 @@ export function MainEditor() {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
if (!file.name.endsWith('.voicebox.zip')) { if (!file.name.endsWith('.voicebox.zip')) {
alert('Please select a valid .voicebox.zip file'); toast({
title: 'Invalid file type',
description: 'Please select a valid .voicebox.zip file',
variant: 'destructive',
});
return; return;
} }
setSelectedFile(file); setSelectedFile(file);
@@ -53,9 +59,17 @@ export function MainEditor() {
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
toast({
title: 'Profile imported',
description: 'Voice profile imported successfully',
});
}, },
onError: (error) => { onError: (error) => {
alert(`Failed to import profile: ${error.message}`); toast({
title: 'Failed to import profile',
description: error.message,
variant: 'destructive',
});
}, },
}); });
} }
@@ -102,15 +116,9 @@ export function MainEditor() {
)} )}
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Profiles - Top Left */}
<div className="shrink-0 flex flex-col"> <div className="shrink-0 flex flex-col">
<ProfileList /> <ProfileList />
</div> </div>
{/* Generator - Bottom Left */}
{/* <div className="shrink-0">
<GenerationForm />
</div> */}
</div> </div>
</div> </div>
</div> </div>
+16 -13
View File
@@ -1,3 +1,4 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react'; import { Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png'; import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
@@ -5,23 +6,22 @@ import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
interface SidebarProps { interface SidebarProps {
activeTab: string;
onTabChange: (tab: string) => void;
isMacOS?: boolean; isMacOS?: boolean;
} }
const tabs = [ const tabs = [
{ id: 'main', icon: Volume2, label: 'Generate' }, { id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'voices', icon: Mic, label: 'Voices' }, { id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'audio', icon: Speaker, label: 'Audio' }, { id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', icon: Box, label: 'Models' }, { id: 'models', path: '/models', icon: Box, label: 'Models' },
{ id: 'server', icon: Server, label: 'Server' }, { id: 'server', path: '/server', icon: Server, label: 'Server' },
]; ];
export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) { export function Sidebar({ isMacOS }: SidebarProps) {
const isGenerating = useGenerationStore((state) => state.isGenerating); const isGenerating = useGenerationStore((state) => state.isGenerating);
const audioUrl = usePlayerStore((state) => state.audioUrl); const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl; const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute();
return ( return (
<div <div
@@ -39,13 +39,16 @@ export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const isActive = activeTab === tab.id; // For index route, use exact match; for others, use default matching
const isActive =
tab.path === '/'
? matchRoute({ to: '/', exact: true })
: matchRoute({ to: tab.path });
return ( return (
<button <Link
key={tab.id} key={tab.id}
type="button" to={tab.path}
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-muted/50', 'hover:bg-muted/50',
@@ -55,7 +58,7 @@ export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
aria-label={tab.label} aria-label={tab.label}
> >
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</button> </Link>
); );
})} })}
</div> </div>
@@ -42,45 +42,13 @@ import {
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription'; import { useTranscription } from '@/lib/hooks/useTranscription';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
import { formatAudioDuration } from '@/lib/utils/audio'; import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { AudioSampleRecording } from './AudioSampleRecording'; import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem'; import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload'; import { AudioSampleUpload } from './AudioSampleUpload';
import { SampleList } from './SampleList'; import { SampleList } from './SampleList';
// Helper function to get audio duration from File
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
// If the file has a recordedDuration property (from our recording hooks),
// use that instead of trying to read metadata. This fixes issues on Windows
// where WebM files from MediaRecorder don't have proper duration metadata.
if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) {
return file.recordedDuration;
}
return new Promise((resolve, reject) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
// Check if duration is valid (not Infinity or NaN)
if (Number.isFinite(audio.duration) && audio.duration > 0) {
resolve(audio.duration);
} else {
reject(new Error('Audio file has invalid duration metadata'));
}
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
});
}
const MAX_AUDIO_DURATION_SECONDS = 30; const MAX_AUDIO_DURATION_SECONDS = 30;
const baseProfileSchema = z.object({ const baseProfileSchema = z.object({
@@ -187,7 +155,7 @@ export function ProfileForm() {
stopRecording, stopRecording,
cancelRecording, cancelRecording,
} = useAudioRecording({ } = useAudioRecording({
maxDurationSeconds: 30, maxDurationSeconds: 29,
onRecordingComplete: (blob, recordedDuration) => { onRecordingComplete: (blob, recordedDuration) => {
const file = new File([blob], `recording-${Date.now()}.webm`, { const file = new File([blob], `recording-${Date.now()}.webm`, {
type: blob.type || 'audio/webm', type: blob.type || 'audio/webm',
@@ -213,7 +181,7 @@ export function ProfileForm() {
stopRecording: stopSystemRecording, stopRecording: stopSystemRecording,
cancelRecording: cancelSystemRecording, cancelRecording: cancelSystemRecording,
} = useSystemAudioCapture({ } = useSystemAudioCapture({
maxDurationSeconds: 30, maxDurationSeconds: 29,
onRecordingComplete: (blob, recordedDuration) => { onRecordingComplete: (blob, recordedDuration) => {
const file = new File([blob], `system-audio-${Date.now()}.wav`, { const file = new File([blob], `system-audio-${Date.now()}.wav`, {
type: blob.type || 'audio/wav', type: blob.type || 'audio/wav',
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Mic, Monitor, Upload } from 'lucide-react'; import { Mic, Monitor, Upload } from 'lucide-react';
import { useState, 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';
@@ -73,7 +73,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
stopRecording, stopRecording,
cancelRecording, cancelRecording,
} = useAudioRecording({ } = useAudioRecording({
maxDurationSeconds: 30, maxDurationSeconds: 29,
onRecordingComplete: (blob, recordedDuration) => { onRecordingComplete: (blob, recordedDuration) => {
// Convert blob to File object // Convert blob to File object
const file = new File([blob], `recording-${Date.now()}.webm`, { const file = new File([blob], `recording-${Date.now()}.webm`, {
@@ -100,7 +100,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
stopRecording: stopSystemRecording, stopRecording: stopSystemRecording,
cancelRecording: cancelSystemRecording, cancelRecording: cancelSystemRecording,
} = useSystemAudioCapture({ } = useSystemAudioCapture({
maxDurationSeconds: 30, maxDurationSeconds: 29,
onRecordingComplete: (blob, recordedDuration) => { onRecordingComplete: (blob, recordedDuration) => {
// Convert blob to File object // Convert blob to File object
const file = new File([blob], `system-audio-${Date.now()}.wav`, { const file = new File([blob], `system-audio-${Date.now()}.wav`, {
+3
View File
@@ -0,0 +1,3 @@
interface Window {
__voiceboxServerStartedByApp?: boolean;
}
+1 -1
View File
@@ -8,7 +8,7 @@ interface UseAudioRecordingOptions {
} }
export function useAudioRecording({ export function useAudioRecording({
maxDurationSeconds = 30, maxDurationSeconds = 29,
onRecordingComplete, onRecordingComplete,
}: UseAudioRecordingOptions = {}) { }: UseAudioRecordingOptions = {}) {
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
+122
View File
@@ -0,0 +1,122 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
instruct: z.string().max(500).optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
interface UseGenerationFormOptions {
onSuccess?: () => void;
defaultValues?: Partial<GenerationFormValues>;
}
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const { toast } = useToast();
const generation = useGeneration();
const setAudio = usePlayerStore((state) => state.setAudio);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
useModelDownloadToast({
modelName: downloadingModelName || '',
displayName: downloadingDisplayName || '',
enabled: !!downloadingModelName,
});
const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema),
defaultValues: {
text: '',
language: 'en',
seed: undefined,
modelSize: '1.7B',
instruct: '',
...options.defaultValues,
},
});
async function handleSubmit(
data: GenerationFormValues,
selectedProfileId: string | null,
): Promise<void> {
if (!selectedProfileId) {
toast({
title: 'No profile selected',
description: 'Please select a voice profile from the cards above.',
variant: 'destructive',
});
return;
}
try {
setIsGenerating(true);
const modelName = `qwen-tts-${data.modelSize}`;
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
try {
const modelStatus = await apiClient.getModelStatus();
const model = modelStatus.models.find((m) => m.model_name === modelName);
if (model && !model.downloaded) {
setDownloadingModelName(modelName);
setDownloadingDisplayName(displayName);
}
} catch (error) {
console.error('Failed to check model status:', error);
}
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
text: data.text,
language: data.language,
seed: data.seed,
model_size: data.modelSize,
instruct: data.instruct || undefined,
});
toast({
title: 'Generation complete!',
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
const audioUrl = apiClient.getAudioUrl(result.id);
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset();
options.onSuccess?.();
} catch (error) {
toast({
title: 'Generation failed',
description: error instanceof Error ? error.message : 'Failed to generate audio',
variant: 'destructive',
});
} finally {
setIsGenerating(false);
setDownloadingModelName(null);
setDownloadingDisplayName(null);
}
}
return {
form,
handleSubmit,
isPending: generation.isPending,
};
}
+1 -1
View File
@@ -12,7 +12,7 @@ interface UseSystemAudioCaptureOptions {
* Uses ScreenCaptureKit on macOS and WASAPI loopback on Windows. * Uses ScreenCaptureKit on macOS and WASAPI loopback on Windows.
*/ */
export function useSystemAudioCapture({ export function useSystemAudioCapture({
maxDurationSeconds = 30, maxDurationSeconds = 29,
onRecordingComplete, onRecordingComplete,
}: UseSystemAudioCaptureOptions = {}) { }: UseSystemAudioCaptureOptions = {}) {
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
+35
View File
@@ -17,6 +17,41 @@ export function formatAudioDuration(seconds: number): string {
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
} }
/**
* Get audio duration from a File.
* If the file has a recordedDuration property (from recording hooks),
* use that instead of trying to read metadata. This fixes issues on Windows
* where WebM files from MediaRecorder don't have proper duration metadata.
*/
export async function getAudioDuration(
file: File & { recordedDuration?: number },
): Promise<number> {
if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) {
return file.recordedDuration;
}
return new Promise((resolve, reject) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
if (Number.isFinite(audio.duration) && audio.duration > 0) {
resolve(audio.duration);
} else {
reject(new Error('Audio file has invalid duration metadata'));
}
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
});
}
/** /**
* Convert any audio blob to WAV format using Web Audio API. * Convert any audio blob to WAV format using Web Audio API.
* This ensures compatibility without requiring ffmpeg on the backend. * This ensures compatibility without requiring ffmpeg on the backend.
+19
View File
@@ -0,0 +1,19 @@
const DEBUG = import.meta.env.DEV;
export const debug = {
log: (...args: unknown[]) => {
if (DEBUG) {
console.log(...args);
}
},
error: (...args: unknown[]) => {
if (DEBUG) {
console.error(...args);
}
},
warn: (...args: unknown[]) => {
if (DEBUG) {
console.warn(...args);
}
},
};
+125
View File
@@ -0,0 +1,125 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudioTab } from '@/components/AudioTab/AudioTab';
import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
import { ServerTab } from '@/components/ServerTab/ServerTab';
import { Sidebar } from '@/components/Sidebar';
import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
import { isMacOS } from '@/lib/tauri';
// Root layout component
function RootLayout() {
// Monitor active downloads/generations and show toasts for them
const activeDownloads = useRestoreActiveTasks();
return (
<AppFrame>
<div className="flex flex-1 min-h-0 overflow-hidden">
<Sidebar isMacOS={isMacOS()} />
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
<Outlet />
</div>
</main>
</div>
{/* Show download toasts for any active downloads (from anywhere) */}
{activeDownloads.map((download) => {
const displayName = MODEL_DISPLAY_NAMES[download.model_name] || download.model_name;
return (
<DownloadToastRestorer
key={download.model_name}
modelName={download.model_name}
displayName={displayName}
/>
);
})}
<Toaster />
</AppFrame>
);
}
/**
* Component that restores a download toast for a specific model.
*/
function DownloadToastRestorer({
modelName,
displayName,
}: {
modelName: string;
displayName: string;
}) {
// Use the download toast hook to restore the toast
useModelDownloadToast({
modelName,
displayName,
enabled: true,
});
return null;
}
// Root route with layout
const rootRoute = createRootRoute({
component: RootLayout,
});
// Index route (main/generate)
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: MainEditor,
});
// Voices route
const voicesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/voices',
component: VoicesTab,
});
// Audio route
const audioRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/audio',
component: AudioTab,
});
// Models route
const modelsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/models',
component: ModelsTab,
});
// Server route
const serverRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/server',
component: ServerTab,
});
// Route tree
const routeTree = rootRoute.addChildren([
indexRoute,
voicesRoute,
audioRoute,
modelsRoute,
serverRoute,
]);
// Create router
export const router = createRouter({ routeTree });
// Register router for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ app.add_middleware(
@app.get("/") @app.get("/")
async def root(): async def root():
"""Root endpoint.""" """Root endpoint."""
return {"message": "voicebox API", "version": "0.1.4"} return {"message": "voicebox API", "version": "0.1.5"}
@app.get("/health", response_model=models.HealthResponse) @app.get("/health", response_model=models.HealthResponse)
+27 -4
View File
@@ -13,7 +13,7 @@
}, },
"app": { "app": {
"name": "@voicebox/app", "name": "@voicebox/app",
"version": "0.1.0", "version": "0.1.4",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.1",
@@ -32,6 +32,7 @@
"@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",
"@tanstack/react-router": "^1.157.16",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0", "@tauri-apps/plugin-fs": "^2.0.0",
@@ -63,7 +64,7 @@
}, },
"landing": { "landing": {
"name": "@voicebox/landing", "name": "@voicebox/landing",
"version": "0.1.0", "version": "0.1.4",
"dependencies": { "dependencies": {
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
@@ -88,7 +89,7 @@
}, },
"tauri": { "tauri": {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"version": "0.1.0", "version": "0.1.4",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
@@ -107,7 +108,7 @@
}, },
"web": { "web": {
"name": "@voicebox/web", "name": "@voicebox/web",
"version": "0.1.0", "version": "0.1.4",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"react": "^18.3.0", "react": "^18.3.0",
@@ -512,6 +513,8 @@
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/history": ["@tanstack/[email protected]", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
"@tanstack/query-core": ["@tanstack/[email protected]", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], "@tanstack/query-core": ["@tanstack/[email protected]", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/query-devtools": ["@tanstack/[email protected]", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="], "@tanstack/query-devtools": ["@tanstack/[email protected]", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="],
@@ -520,6 +523,14 @@
"@tanstack/react-query-devtools": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="], "@tanstack/react-query-devtools": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="],
"@tanstack/react-router": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.16", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg=="],
"@tanstack/react-store": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
"@tanstack/router-core": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA=="],
"@tanstack/store": ["@tanstack/[email protected]", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="], "@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
"@tauri-apps/cli": ["@tauri-apps/[email protected]", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="], "@tauri-apps/cli": ["@tauri-apps/[email protected]", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="],
@@ -664,6 +675,8 @@
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["[email protected]", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"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=="], "cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
@@ -792,6 +805,8 @@
"is-path-inside": ["[email protected]", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], "is-path-inside": ["[email protected]", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
"isbot": ["[email protected]", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], "jiti": ["[email protected]", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
@@ -966,6 +981,10 @@
"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=="],
"seroval": ["[email protected]", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
"seroval-plugins": ["[email protected]", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
"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=="], "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=="],
@@ -1002,6 +1021,10 @@
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], "thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tiny-warning": ["[email protected]", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "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=="],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@voicebox/landing", "name": "@voicebox/landing",
"version": "0.1.4", "version": "0.1.5",
"description": "Landing page for voicebox.sh", "description": "Landing page for voicebox.sh",
"scripts": { "scripts": {
"dev": "bun --bun next dev --turbo", "dev": "bun --bun next dev --turbo",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "voicebox", "name": "voicebox",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"app", "app",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"private": true, "private": true,
"version": "0.1.4", "version": "0.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -4840,7 +4840,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "voicebox" name = "voicebox"
version = "0.1.3" version = "0.1.4"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"core-foundation-sys", "core-foundation-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "voicebox" name = "voicebox"
version = "0.1.4" version = "0.1.5"
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation" description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
authors = ["you"] authors = ["you"]
license = "" license = ""
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Voicebox", "productName": "Voicebox",
"version": "0.1.4", "version": "0.1.5",
"identifier": "sh.voicebox.app", "identifier": "sh.voicebox.app",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@voicebox/web", "name": "@voicebox/web",
"private": true, "private": true,
"version": "0.1.4", "version": "0.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",