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}
-