diff --git a/app/package.json b/app/package.json index 5b99c73e..78b15c71 100644 --- a/app/package.json +++ b/app/package.json @@ -13,34 +13,35 @@ "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-select": "^2.1.1", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-tabs": "^1.1.0", - "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-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" + "@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", + "@tanstack/react-query": "^5.0.0", + "@tanstack/react-query-devtools": "^5.0.0", + "@tauri-apps/api": "^2.0.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "date-fns": "^3.6.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 c3c7cb3c..72c8e97c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -7,6 +7,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; import { Toaster } from '@/components/ui/toaster'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { Sidebar } from '@/components/Sidebar'; +import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri'; // Track if server is starting to prevent duplicate starts @@ -83,40 +84,45 @@ function App() { } return ( -
- +
+
+ -
-
- {activeTab === 'profiles' && ( -
- -
- )} - - {activeTab === 'generate' && ( -
- -
- )} - - {activeTab === 'history' && ( -
- -
- )} - - {activeTab === 'settings' && ( -
-
- - +
+
+ {activeTab === 'profiles' && ( +
+
- -
- )} -
-
+ )} + + {activeTab === 'generate' && ( +
+ +
+ )} + + {activeTab === 'history' && ( +
+ +
+ )} + + {activeTab === 'settings' && ( +
+
+ + +
+ +
+ )} +
+ +
+ + {/* Audio Player - always visible except on settings */} + {activeTab !== 'settings' && }
diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx new file mode 100644 index 00000000..1f51c87c --- /dev/null +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -0,0 +1,466 @@ +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 { + const wavesurfer = WaveSurfer.create({ + container: container, + waveColor: '#ffffff', + progressColor: '#d3d3d3', + cursorColor: 'hsl(var(--primary))', + 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/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index af9fde58..d24b15b3 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -14,6 +14,7 @@ import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory'; import { formatDate, formatDuration } from '@/lib/utils/format'; +import { usePlayerStore } from '@/stores/playerStore'; export function HistoryTable() { const [page, setPage] = useState(0); @@ -26,17 +27,14 @@ export function HistoryTable() { }); const deleteGeneration = useDeleteGeneration(); + const setAudio = usePlayerStore((state) => state.setAudio); + const currentAudioId = usePlayerStore((state) => state.audioId); + const isPlaying = usePlayerStore((state) => state.isPlaying); - const handlePlay = (audioId: string) => { + const handlePlay = (audioId: string, text: string) => { const audioUrl = apiClient.getAudioUrl(audioId); - const audio = new Audio(audioUrl); - audio.play().catch((_error) => { - toast({ - title: 'Error', - description: 'Failed to play audio', - variant: 'destructive', - }); - }); + // If clicking the same audio that's playing, it will be handled by the player + setAudio(audioUrl, audioId, text.substring(0, 50)); }; const handleDownload = (audioId: string, text: string) => { @@ -98,8 +96,11 @@ export function HistoryTable() { diff --git a/app/src/components/VoiceProfiles/SampleList.tsx b/app/src/components/VoiceProfiles/SampleList.tsx index db4360fe..6b17fb1a 100644 --- a/app/src/components/VoiceProfiles/SampleList.tsx +++ b/app/src/components/VoiceProfiles/SampleList.tsx @@ -1,9 +1,10 @@ -import { Plus, Trash2 } from 'lucide-react'; +import { Plus, Trash2, Play } from 'lucide-react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/use-toast'; import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles'; import { useServerStore } from '@/stores/serverStore'; +import { usePlayerStore } from '@/stores/playerStore'; import { SampleUpload } from './SampleUpload'; interface SampleListProps { @@ -16,6 +17,9 @@ export function SampleList({ profileId }: SampleListProps) { const [uploadOpen, setUploadOpen] = useState(false); const { toast } = useToast(); const serverUrl = useServerStore((state) => state.serverUrl); + const setAudio = usePlayerStore((state) => state.setAudio); + const currentAudioId = usePlayerStore((state) => state.audioId); + const isPlaying = usePlayerStore((state) => state.isPlaying); const handleDelete = (sampleId: string) => { if (confirm('Are you sure you want to delete this sample?')) { @@ -23,16 +27,9 @@ export function SampleList({ profileId }: SampleListProps) { } }; - const handlePlay = (audioPath: string) => { + const handlePlay = (audioPath: string, referenceText: string, sampleId: string) => { const audioUrl = `${serverUrl}${audioPath}`; - const audio = new Audio(audioUrl); - audio.play().catch((_error) => { - toast({ - title: 'Error', - description: 'Failed to play audio', - variant: 'destructive', - }); - }); + setAudio(audioUrl, sampleId, referenceText.substring(0, 50)); }; if (isLoading) { @@ -65,7 +62,15 @@ export function SampleList({ profileId }: SampleListProps) {

{sample.audio_path}

-