2026-01-04 21:48:55 +00:00
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
|
|
|
2026-01-05 00:25:02 +00:00
|
|
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
2026-01-04 21:48:55 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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]);
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-08 20:14:58 +00:00
|
|
|
* 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
|
2026-01-04 21:48:55 +00:00
|
|
|
* @returns {Promise<string>} Audio URL
|
|
|
|
|
*/
|
2026-01-08 20:14:58 +00:00
|
|
|
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"');
|
|
|
|
|
}
|
2026-01-04 21:48:55 +00:00
|
|
|
|
2026-01-08 20:14:58 +00:00
|
|
|
// Create cache key from parameters
|
|
|
|
|
const cacheKey = `${roomId}:${questionId}:${contentType}:${answerId || ''}`;
|
2026-01-04 21:48:55 +00:00
|
|
|
if (cache && audioCache.current.has(cacheKey)) {
|
|
|
|
|
return audioCache.current.get(cacheKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-08 20:14:58 +00:00
|
|
|
const requestBody = {
|
|
|
|
|
roomId,
|
|
|
|
|
questionId,
|
|
|
|
|
contentType,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (answerId) {
|
|
|
|
|
requestBody.answerId = answerId;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 02:25:35 +00:00
|
|
|
if (voice) {
|
|
|
|
|
requestBody.voice = voice;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 00:25:02 +00:00
|
|
|
const response = await fetch(`${API_URL}/voice/tts`, {
|
2026-01-04 21:48:55 +00:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
2026-01-05 00:25:02 +00:00
|
|
|
credentials: 'include',
|
2026-01-05 02:25:35 +00:00
|
|
|
body: JSON.stringify(requestBody),
|
2026-01-04 21:48:55 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-08 20:14:58 +00:00
|
|
|
* Speak question or answer
|
|
|
|
|
* @param {Object} params - Parameters (roomId, questionId, contentType, answerId?, voice?)
|
2026-01-04 21:48:55 +00:00
|
|
|
* @param {Object} options - Options
|
|
|
|
|
*/
|
2026-01-08 20:14:58 +00:00
|
|
|
const speak = useCallback(async (params, options = {}) => {
|
2026-01-04 21:48:55 +00:00
|
|
|
if (!isEnabled) return;
|
2026-01-08 20:14:58 +00:00
|
|
|
if (!params || !params.roomId || !params.questionId || !params.contentType) return;
|
2026-01-04 21:48:55 +00:00
|
|
|
|
|
|
|
|
try {
|
2026-01-08 20:14:58 +00:00
|
|
|
// Create a unique identifier for this speech request
|
|
|
|
|
const speechId = `${params.roomId}:${params.questionId}:${params.contentType}:${params.answerId || ''}`;
|
|
|
|
|
setCurrentText(speechId);
|
2026-01-04 21:48:55 +00:00
|
|
|
setIsPlaying(true);
|
|
|
|
|
|
2026-01-08 20:14:58 +00:00
|
|
|
const audioUrl = await generateSpeech(params, options);
|
2026-01-04 21:48:55 +00:00
|
|
|
|
|
|
|
|
// 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 {
|
2026-01-05 00:25:02 +00:00
|
|
|
const audio = new Audio(`${API_URL}/voice/effects/${effectType}`);
|
2026-01-04 21:48:55 +00:00
|
|
|
audio.volume = effectsVolume;
|
|
|
|
|
await audio.play();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Failed to play effect ${effectType}:`, error);
|
|
|
|
|
}
|
|
|
|
|
}, [isEnabled, effectsVolume]);
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-08 20:14:58 +00:00
|
|
|
* Preload speech for question/answer
|
|
|
|
|
* @param {Object} params - Parameters (roomId, questionId, contentType, answerId?, voice?)
|
2026-01-04 21:48:55 +00:00
|
|
|
* @param {Object} options - Options
|
|
|
|
|
*/
|
2026-01-08 20:14:58 +00:00
|
|
|
const preload = useCallback(async (params, options = {}) => {
|
2026-01-04 21:48:55 +00:00
|
|
|
if (!isEnabled) return;
|
2026-01-08 20:14:58 +00:00
|
|
|
if (!params || !params.roomId || !params.questionId || !params.contentType) return;
|
2026-01-04 21:48:55 +00:00
|
|
|
|
|
|
|
|
try {
|
2026-01-08 20:14:58 +00:00
|
|
|
await generateSpeech(params, { ...options, cache: true });
|
2026-01-04 21:48:55 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to preload speech:', error);
|
|
|
|
|
}
|
|
|
|
|
}, [isEnabled, generateSpeech]);
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-08 20:14:58 +00:00
|
|
|
* Preload multiple questions/answers
|
|
|
|
|
* @param {Array<Object>} items - Array of params objects to preload
|
2026-01-04 21:48:55 +00:00
|
|
|
*/
|
2026-01-08 20:14:58 +00:00
|
|
|
const preloadBatch = useCallback(async (items) => {
|
2026-01-04 21:48:55 +00:00
|
|
|
if (!isEnabled) return;
|
2026-01-08 20:14:58 +00:00
|
|
|
if (!items || items.length === 0) return;
|
2026-01-04 21:48:55 +00:00
|
|
|
|
|
|
|
|
try {
|
2026-01-08 20:14:58 +00:00
|
|
|
const promises = items.map((params) => preload(params));
|
2026-01-04 21:48:55 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|