import { useState, useEffect, useRef, useCallback } from 'react'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; /** * Hook for voice generation and playback */ export function useVoice() { const [isEnabled, setIsEnabled] = useState(() => { const saved = localStorage.getItem('voice-enabled'); return saved ? JSON.parse(saved) : false; }); const [volume, setVolume] = useState(() => { const saved = localStorage.getItem('voice-volume'); return saved ? parseFloat(saved) : 0.8; }); const [effectsVolume, setEffectsVolume] = useState(() => { const saved = localStorage.getItem('effects-volume'); return saved ? parseFloat(saved) : 0.6; }); const [isPlaying, setIsPlaying] = useState(false); const [currentText, setCurrentText] = useState(null); const audioRef = useRef(null); const audioCache = useRef(new Map()); // Save settings to localStorage useEffect(() => { localStorage.setItem('voice-enabled', JSON.stringify(isEnabled)); }, [isEnabled]); useEffect(() => { localStorage.setItem('voice-volume', volume.toString()); }, [volume]); useEffect(() => { localStorage.setItem('effects-volume', effectsVolume.toString()); }, [effectsVolume]); /** * Generate speech from text * @param {string} text - Text to speak * @param {Object} options - Options * @returns {Promise} Audio URL */ const generateSpeech = useCallback(async (text, options = {}) => { const { cache = true } = options; // Check cache first const cacheKey = `${text}_${voice}`; if (cache && audioCache.current.has(cacheKey)) { return audioCache.current.get(cacheKey); } try { const response = await fetch(`${API_URL}/voice/tts`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ text, voice }), }); if (!response.ok) { throw new Error(`Voice service error: ${response.status}`); } // Create blob URL from response const blob = await response.blob(); const audioUrl = URL.createObjectURL(blob); // Cache the URL if (cache) { audioCache.current.set(cacheKey, audioUrl); } return audioUrl; } catch (error) { console.error('Failed to generate speech:', error); throw error; } }, []); /** * Speak text * @param {string} text - Text to speak * @param {Object} options - Options */ const speak = useCallback(async (text, options = {}) => { if (!isEnabled) return; if (!text) return; try { setCurrentText(text); setIsPlaying(true); const audioUrl = await generateSpeech(text, options); // Create or reuse audio element if (!audioRef.current) { audioRef.current = new Audio(); } const audio = audioRef.current; audio.src = audioUrl; audio.volume = volume; audio.onended = () => { setIsPlaying(false); setCurrentText(null); }; audio.onerror = () => { console.error('Audio playback error'); setIsPlaying(false); setCurrentText(null); }; await audio.play(); } catch (error) { console.error('Failed to speak:', error); setIsPlaying(false); setCurrentText(null); } }, [isEnabled, volume, generateSpeech]); /** * Stop current speech */ const stop = useCallback(() => { if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; setIsPlaying(false); setCurrentText(null); } }, []); /** * Play sound effect * @param {string} effectType - Type of effect (correct/error/victory) */ const playEffect = useCallback(async (effectType) => { if (!isEnabled) return; try { const audio = new Audio(`${API_URL}/voice/effects/${effectType}`); audio.volume = effectsVolume; await audio.play(); } catch (error) { console.error(`Failed to play effect ${effectType}:`, error); } }, [isEnabled, effectsVolume]); /** * Preload speech for text * @param {string} text - Text to preload * @param {Object} options - Options */ const preload = useCallback(async (text, options = {}) => { if (!isEnabled) return; if (!text) return; try { await generateSpeech(text, { ...options, cache: true }); } catch (error) { console.error('Failed to preload speech:', error); } }, [isEnabled, generateSpeech]); /** * Preload multiple texts * @param {Array} texts - Texts to preload */ const preloadBatch = useCallback(async (texts) => { if (!isEnabled) return; if (!texts || texts.length === 0) return; try { const promises = texts.map((text) => preload(text)); await Promise.all(promises); } catch (error) { console.error('Failed to preload batch:', error); } }, [isEnabled, preload]); /** * Clear audio cache */ const clearCache = useCallback(() => { // Revoke blob URLs to free memory audioCache.current.forEach((url) => { URL.revokeObjectURL(url); }); audioCache.current.clear(); }, []); // Cleanup on unmount useEffect(() => { return () => { stop(); clearCache(); }; }, [stop, clearCache]); return { // State isEnabled, isPlaying, currentText, volume, effectsVolume, // Actions setIsEnabled, setVolume, setEffectsVolume, speak, stop, playEffect, preload, preloadBatch, clearCache, }; }