diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b212ac55..2c2c43d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,8 @@ jobs: - uses: tauri-apps/tauri-action@v0 env: 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: projectPath: tauri tagName: v__VERSION__ @@ -111,6 +113,9 @@ jobs: - **macOS**: Download the `.dmg` file - **Windows**: Download the `.msi` installer - **Linux**: Download the `.AppImage` or `.deb` package + + The app includes automatic updates - future updates will be installed automatically. releaseDraft: true prerelease: false args: ${{ matrix.args }} + includeUpdaterJson: true diff --git a/.gitignore b/.gitignore index 2cbd3e5c..05f7ef0d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ __pycache__/ venv/ env/ ENV/ - +*.prompt # Build outputs dist/ build/ diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 0a885860..975b1447 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -437,7 +437,7 @@ projects ## 💡 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) 3. **Generate API client**: `bun run generate:api` (after backend changes) 4. **Build server binary**: `bun run build:server` (for Tauri bundling) diff --git a/app/package.json b/app/package.json index 5b99c73e..927c591e 100644 --- a/app/package.json +++ b/app/package.json @@ -13,34 +13,38 @@ "check": "biome check --write src" }, "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", - "zod": "^3.23.8", - "wavesurfer.js": "^7.0.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-alert-dialog": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.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", - "@radix-ui/react-popover": "^1.1.1", - "@radix-ui/react-progress": "^1.1.0", - "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-avatar": "^1.1.0", - "@radix-ui/react-alert-dialog": "^1.1.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": { "@tailwindcss/vite": "^4.1.18", diff --git a/app/src/App.tsx b/app/src/App.tsx index 52c6a18b..7fc4411e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,48 +3,73 @@ import { GenerationForm } from '@/components/Generation/GenerationForm'; import { HistoryTable } from '@/components/History/HistoryTable'; import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; import { ServerStatus } from '@/components/ServerSettings/ServerStatus'; +import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; import { Toaster } from '@/components/ui/toaster'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; 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 let serverStarting = false; function App() { - const [activeTab, setActiveTab] = useState('profiles'); + const [activeTab, setActiveTab] = useState('main'); 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(() => { - 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; } serverStarting = true; - console.log('Running in Tauri, starting bundled server...'); + console.log('Production mode: Starting bundled server...'); startServer(false) .then(() => { console.log('Server is ready'); 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) => { console.error('Failed to auto-start server:', error); serverStarting = false; + // @ts-expect-error - adding property to window + window.__voiceboxServerStartedByApp = false; }); // Cleanup: stop server on actual unmount (not StrictMode remount) + // Note: Window close is handled separately in Tauri Rust code return () => { - // In production builds, we want to stop the server on unmount - // In dev mode, React StrictMode causes remounts, so we skip cleanup - if (import.meta.env?.PROD) { - stopServer().catch((error) => { - console.error('Failed to stop server on cleanup:', error); - }); - serverStarting = false; - } + // Window close event handles server shutdown based on setting + serverStarting = false; }; }, []); @@ -61,40 +86,51 @@ function App() { } return ( -
- - -
-
- {activeTab === 'profiles' && ( -
- -
- )} - - {activeTab === 'generate' && ( -
- -
- )} - - {activeTab === 'history' && ( -
- -
- )} - - {activeTab === 'settings' && ( -
-
- - +
+
+ + +
+
+ + + {activeTab === 'settings' ? ( +
+
+ + +
+ {isTauri() && } +
- -
- )} -
-
+ ) : ( + // Main view: Profiles top left, Generator bottom left, History right +
+ {/* Left Column */} +
+ {/* Profiles - Top Left */} +
+ +
+ + {/* Generator - Bottom Left */} +
+ +
+
+ + {/* Right Column - History */} +
+ +
+
+ )} +
+ + + + {/* Audio Player - always visible except on settings */} + {activeTab !== 'settings' && } diff --git a/app/src/assets/voicebox-logo.png b/app/src/assets/voicebox-logo.png new file mode 100644 index 00000000..eebe5438 Binary files /dev/null and b/app/src/assets/voicebox-logo.png differ diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx new file mode 100644 index 00000000..d6ffb5bd --- /dev/null +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -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(null); + const wavesurferRef = useRef(null); + const loadingRef = useRef(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+ {/* Play/Pause Button */} + + + {/* Waveform */} +
+
+ {duration > 0 && ( + 0 ? [(currentTime / duration) * 100] : [0]} + onValueChange={handleSeek} + max={100} + step={0.1} + className="w-full" + /> + )} + {isLoading && ( +
Loading audio...
+ )} + {error &&
{error}
} +
+ + {/* Time Display */} +
+ {formatAudioDuration(currentTime)} + / + {formatAudioDuration(duration)} +
+ + {/* Title */} + {title && ( +
{title}
+ )} + + {/* Loop Button */} + + + {/* Volume Control */} +
+ + +
+
+
+
+ ); +} diff --git a/app/src/components/Generation/GenerationForm.tsx b/app/src/components/Generation/GenerationForm.tsx index e4f6cbd9..ffff041b 100644 --- a/app/src/components/Generation/GenerationForm.tsx +++ b/app/src/components/Generation/GenerationForm.tsx @@ -1,7 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Mic } from 'lucide-react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -24,42 +25,54 @@ import { import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; 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({ - profileId: z.string().min(1, 'Please select a voice profile'), text: z.string().min(1, 'Text is required').max(5000), language: z.enum(['en', 'zh']), seed: z.number().int().optional(), modelSize: z.enum(['1.7B', '0.6B']).optional(), + instruct: z.string().max(500).optional(), }); type GenerationFormValues = z.infer; export function GenerationForm() { - const { data: profiles } = useProfiles(); + const selectedProfileId = useUIStore((state) => state.selectedProfileId); + const { data: selectedProfile } = useProfile(selectedProfileId || ''); const generation = useGeneration(); const { toast } = useToast(); const form = useForm({ resolver: zodResolver(generationSchema), defaultValues: { - profileId: '', 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 { const result = await generation.mutateAsync({ - profile_id: data.profileId, + profile_id: selectedProfileId, text: data.text, language: data.language, seed: data.seed, model_size: data.modelSize, + instruct: data.instruct || undefined, }); toast({ @@ -85,26 +98,35 @@ export function GenerationForm() {
+
+ Voice Profile + {selectedProfile ? ( +
+ + {selectedProfile.name} + {selectedProfile.language} +
+ ) : ( +
+ Click on a profile card above to select a voice profile +
+ )} +
+ ( - Voice Profile - + Text to Speak + +