import React, { useState, useRef, useEffect, useCallback } from 'react'; import { Box, IconButton, Slider, Typography, Tooltip, CircularProgress } from '@mui/material'; import { VolumeUp as SpeakerIcon, PlayArrow as PlayIcon, Pause as PauseIcon, Stop as StopIcon, VolumeOff as MuteIcon } from '@mui/icons-material'; import { generateTTS, getTTSAudioURL } from '../api/client'; /** * TTSPlayer Component * Plays text-to-speech audio with playback controls and word highlighting */ function TTSPlayer({ text, provider = 'elevenlabs', autoPlay = false, onError, onWordChange }) { const [isPlaying, setIsPlaying] = useState(false); const [isLoading, setIsLoading] = useState(false); const [progress, setProgress] = useState(0); const [volume, setVolume] = useState(1); const [audioURL, setAudioURL] = useState(null); const [duration, setDuration] = useState(0); const [currentWordIndex, setCurrentWordIndex] = useState(-1); const [words, setWords] = useState([]); const audioRef = useRef(null); const utteranceRef = useRef(null); const wordTimerRef = useRef(null); // Parse text into words on mount useEffect(() => { if (text) { // Split text into words while preserving punctuation const wordList = text.split(/(\s+)/).filter(w => w.trim().length > 0); setWords(wordList); } }, [text]); useEffect(() => { if (autoPlay && text) { handlePlay(); } return () => { cleanup(); }; }, []); const cleanup = useCallback(() => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } if (window.speechSynthesis) { window.speechSynthesis.cancel(); } if (wordTimerRef.current) { clearInterval(wordTimerRef.current); wordTimerRef.current = null; } setCurrentWordIndex(-1); }, []); const handlePlay = async () => { if (!audioURL) { // Generate TTS first setIsLoading(true); try { const result = await generateTTS(text, provider); if (result.success) { if (provider === 'web_speech' || result.client_side) { // Use Web Speech API with word highlighting speakWithWebSpeech(text); } else { // Use ElevenLabs audio file const url = getTTSAudioURL(result.audio_url.split('/').pop()); setAudioURL(url); playAudio(url); } } else { // Fallback to Web Speech API if (result.fallback === 'web_speech') { speakWithWebSpeech(text); } else { throw new Error(result.error || 'TTS generation failed'); } } } catch (error) { console.error('TTS error:', error); if (onError) onError(error); // Final fallback to Web Speech API speakWithWebSpeech(text); } finally { setIsLoading(false); } } else { // Resume existing audio if (audioRef.current) { audioRef.current.play(); setIsPlaying(true); } } }; const playAudio = (url) => { const audio = new Audio(url); audioRef.current = audio; audio.volume = volume; audio.addEventListener('loadedmetadata', () => { setDuration(audio.duration); }); audio.addEventListener('timeupdate', () => { const progressPercent = (audio.currentTime / audio.duration) * 100; setProgress(progressPercent); // Estimate word highlighting for audio playback if (words.length > 0) { const wordIndex = Math.floor((progressPercent / 100) * words.length); if (wordIndex !== currentWordIndex && wordIndex < words.length) { setCurrentWordIndex(wordIndex); if (onWordChange) onWordChange(wordIndex, words[wordIndex]); } } }); audio.addEventListener('ended', () => { setIsPlaying(false); setProgress(0); setCurrentWordIndex(-1); if (onWordChange) onWordChange(-1, null); }); audio.addEventListener('error', (e) => { console.error('Audio playback error:', e); // Fallback to Web Speech speakWithWebSpeech(text); }); audio.play().catch(err => { console.error('Audio play failed:', err); speakWithWebSpeech(text); }); setIsPlaying(true); }; const speakWithWebSpeech = (text) => { if ('speechSynthesis' in window) { // Cancel any ongoing speech window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utteranceRef.current = utterance; utterance.volume = volume; utterance.rate = 1.0; utterance.pitch = 1.0; // Word boundary event for real-time word highlighting let currentCharIndex = 0; utterance.onboundary = (event) => { if (event.name === 'word') { // Find which word is being spoken based on character index const charIndex = event.charIndex; let wordIdx = 0; let charCount = 0; for (let i = 0; i < words.length; i++) { charCount += words[i].length + 1; // +1 for space if (charCount > charIndex) { wordIdx = i; break; } } setCurrentWordIndex(wordIdx); if (onWordChange) onWordChange(wordIdx, words[wordIdx]); // Update progress const progressPercent = ((wordIdx + 1) / words.length) * 100; setProgress(progressPercent); } }; utterance.onstart = () => { setIsPlaying(true); setCurrentWordIndex(0); if (onWordChange && words.length > 0) onWordChange(0, words[0]); }; utterance.onend = () => { setIsPlaying(false); setProgress(100); setCurrentWordIndex(-1); if (onWordChange) onWordChange(-1, null); // Reset progress after a short delay setTimeout(() => setProgress(0), 500); }; utterance.onerror = (error) => { console.error('Web Speech error:', error); setIsPlaying(false); setCurrentWordIndex(-1); if (onError) onError(error); }; window.speechSynthesis.speak(utterance); } else { alert('Text-to-speech is not supported in your browser'); } }; const handlePause = () => { if (audioRef.current) { audioRef.current.pause(); setIsPlaying(false); } else if (window.speechSynthesis) { window.speechSynthesis.pause(); setIsPlaying(false); } }; const handleResume = () => { if (audioRef.current) { audioRef.current.play(); setIsPlaying(true); } else if (window.speechSynthesis) { window.speechSynthesis.resume(); setIsPlaying(true); } }; const handleStop = () => { cleanup(); setIsPlaying(false); setProgress(0); setAudioURL(null); }; const handleVolumeChange = (event, newValue) => { setVolume(newValue); if (audioRef.current) { audioRef.current.volume = newValue; } }; const handleProgressChange = (event, newValue) => { if (audioRef.current && duration) { audioRef.current.currentTime = (newValue / 100) * duration; setProgress(newValue); } }; // Render highlighted text const renderHighlightedText = () => { if (!isPlaying || words.length === 0) return null; return ( {words.map((word, idx) => ( {word} ))} ); }; return ( {/* Play/Pause Button */} {isLoading ? ( ) : isPlaying ? ( ) : ( )} {/* Progress Bar */} {/* Stop Button */} {isPlaying && ( )} {/* Volume Control */} {volume === 0 ? : } {/* Word Highlighting Display */} {renderHighlightedText()} ); } export default TTSPlayer;