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 question/answer IDs * @param {Object} params - Parameters * @param {string} params.roomId - Room ID * @param {string} params.questionId - Question ID * @param {string} params.contentType - 'question' or 'answer' * @param {string} [params.answerId] - Answer ID (required if contentType is 'answer') * @param {string} [params.voice] - Voice ID (optional) * @param {boolean} [params.cache=true] - Whether to cache the result * @returns {Promise} Audio URL */ const generateSpeech = useCallback(async (params, options = {}) => { const { roomId, questionId, contentType, answerId, voice } = params; const { cache = true } = options; // Validate required parameters if (!roomId || !questionId || !contentType) { throw new Error('roomId, questionId, and contentType are required'); } if (contentType === 'answer' && !answerId) { throw new Error('answerId is required when contentType is "answer"'); } // Create cache key from parameters const cacheKey = `${roomId}:${questionId}:${contentType}:${answerId || ''}`; if (cache && audioCache.current.has(cacheKey)) { return audioCache.current.get(cacheKey); } try { const requestBody = { roomId, questionId, contentType, }; if (answerId) { requestBody.answerId = answerId; } if (voice) { requestBody.voice = voice; } const response = await fetch(`${API_URL}/voice/tts`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify(requestBody), }); 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 question or answer * @param {Object} params - Parameters (roomId, questionId, contentType, answerId?, voice?) * @param {Object} options - Options */ const speak = useCallback(async (params, options = {}) => { if (!isEnabled) return; if (!params || !params.roomId || !params.questionId || !params.contentType) return; try { // Create a unique identifier for this speech request const speechId = `${params.roomId}:${params.questionId}:${params.contentType}:${params.answerId || ''}`; setCurrentText(speechId); setIsPlaying(true); const audioUrl = await generateSpeech(params, 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 question/answer * @param {Object} params - Parameters (roomId, questionId, contentType, answerId?, voice?) * @param {Object} options - Options */ const preload = useCallback(async (params, options = {}) => { if (!isEnabled) return; if (!params || !params.roomId || !params.questionId || !params.contentType) return; try { await generateSpeech(params, { ...options, cache: true }); } catch (error) { console.error('Failed to preload speech:', error); } }, [isEnabled, generateSpeech]); /** * Preload multiple questions/answers * @param {Array} items - Array of params objects to preload */ const preloadBatch = useCallback(async (items) => { if (!isEnabled) return; if (!items || items.length === 0) return; try { const promises = items.map((params) => preload(params)); 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, }; }