sto-k-odnomu/src/hooks/useVoice.js
2026-01-05 03:25:02 +03:00

229 lines
5.5 KiB
JavaScript

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<string>} Audio URL
*/
const generateSpeech = useCallback(async (text, options = {}) => {
const { voice = 'sarah', 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<string>} 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,
};
}