sto-k-odnomu/src/hooks/useVoice.js

261 lines
7 KiB
JavaScript
Raw Normal View History

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