HomePilot / frontend /src /ui /studio /hooks /useTTSPlayback.js
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
6.34 kB
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
const DEFAULT_VOICE_CONFIG = {
voiceURI: "",
rate: 1.0,
pitch: 1.0,
volume: 1.0,
enabled: true,
};
/**
* Hook for integrating TTS playback with TV Mode
* Automatically speaks scene narration when scenes change
*/
export function useTTSPlayback({ isActive, isPlaying, currentScene, currentSceneIndex, scenes, onSceneNarrationEnd, }) {
const svc = useMemo(() => window.SpeechService, []);
const [ttsEnabled, setTtsEnabledState] = useState(() => {
return svc?.isTTSEnabled?.() ?? true;
});
const [isSpeaking, setIsSpeaking] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [isSupported, setIsSupported] = useState(false);
const [voices, setVoices] = useState([]);
const [voiceConfig, setVoiceConfigState] = useState(() => svc?.getVoiceConfig?.() ?? DEFAULT_VOICE_CONFIG);
const lastSpokenSceneRef = useRef(-1);
const speakingPromiseRef = useRef(null);
// Use ref to always call the latest callback (avoids stale closure issues)
const onSceneNarrationEndRef = useRef(onSceneNarrationEnd);
useEffect(() => {
onSceneNarrationEndRef.current = onSceneNarrationEnd;
}, [onSceneNarrationEnd]);
// Initialize and load voices
useEffect(() => {
if (!svc)
return;
setIsSupported(svc.isSynthesisSupported ?? false);
setTtsEnabledState(svc.isTTSEnabled?.() ?? true);
setVoiceConfigState(svc.getVoiceConfig?.() ?? DEFAULT_VOICE_CONFIG);
const loadVoices = () => {
const availableVoices = svc.getVoices?.() ?? [];
setVoices(availableVoices);
};
loadVoices();
// Voices might load asynchronously
if (window.speechSynthesis?.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = loadVoices;
}
}, [svc]);
// Set TTS enabled
const setTTSEnabled = useCallback((enabled) => {
setTtsEnabledState(enabled);
if (svc?.setTTSEnabled) {
svc.setTTSEnabled(enabled);
}
if (!enabled) {
svc?.stopSpeaking?.();
setIsSpeaking(false);
setIsPaused(false);
}
}, [svc]);
// Update voice config
const setVoiceConfig = useCallback((config) => {
const newConfig = { ...voiceConfig, ...config };
setVoiceConfigState(newConfig);
if (svc?.setVoiceConfig) {
svc.setVoiceConfig(config);
}
}, [svc, voiceConfig]);
// Speak the current scene's narration
const speakCurrentScene = useCallback(async () => {
if (!svc || !currentScene?.narration || !ttsEnabled) {
return false;
}
// Stop any ongoing speech
svc.stopSpeaking?.();
setIsSpeaking(true);
setIsPaused(false);
try {
const success = await svc.speak(currentScene.narration, {
onStart: () => {
setIsSpeaking(true);
setIsPaused(false);
},
onEnd: () => {
setIsSpeaking(false);
setIsPaused(false);
// Use ref to always call the latest callback (avoids stale closure)
onSceneNarrationEndRef.current?.();
},
onError: (error) => {
console.warn("[TTS] Speech error:", error);
setIsSpeaking(false);
setIsPaused(false);
},
});
return success;
}
catch (error) {
console.warn("[TTS] Failed to speak:", error);
setIsSpeaking(false);
return false;
}
}, [svc, currentScene?.narration, ttsEnabled]);
// Stop speaking
const stopSpeaking = useCallback(() => {
svc?.stopSpeaking?.();
setIsSpeaking(false);
setIsPaused(false);
}, [svc]);
// Pause speaking
const pauseSpeaking = useCallback(() => {
svc?.pauseSpeaking?.();
setIsPaused(true);
}, [svc]);
// Resume speaking
const resumeSpeaking = useCallback(() => {
svc?.resumeSpeaking?.();
setIsPaused(false);
}, [svc]);
// Auto-speak when scene changes
useEffect(() => {
if (!isActive || !isPlaying || !ttsEnabled || !svc) {
return;
}
// Only speak if we haven't spoken this scene yet
if (currentSceneIndex === lastSpokenSceneRef.current) {
return;
}
if (!currentScene?.narration) {
lastSpokenSceneRef.current = currentSceneIndex;
return;
}
console.log(`[TTS] Scene ${currentSceneIndex + 1} changed, speaking narration...`);
lastSpokenSceneRef.current = currentSceneIndex;
// Speak the scene narration
speakingPromiseRef.current = speakCurrentScene();
}, [isActive, isPlaying, ttsEnabled, currentSceneIndex, currentScene?.narration, speakCurrentScene, svc]);
// Sync with play/pause state
useEffect(() => {
if (!svc || !isActive)
return;
if (isPlaying && isPaused) {
// TV Mode resumed, resume TTS
resumeSpeaking();
}
else if (!isPlaying && isSpeaking && !isPaused) {
// TV Mode paused, pause TTS
pauseSpeaking();
}
}, [isPlaying, isActive, isSpeaking, isPaused, pauseSpeaking, resumeSpeaking, svc]);
// Stop TTS when TV Mode exits
useEffect(() => {
if (!isActive && svc) {
svc.stopSpeaking?.();
setIsSpeaking(false);
setIsPaused(false);
lastSpokenSceneRef.current = -1;
}
}, [isActive, svc]);
// Reset spoken scene tracking when scenes change significantly
useEffect(() => {
if (scenes.length === 0) {
lastSpokenSceneRef.current = -1;
}
}, [scenes.length]);
return {
ttsEnabled,
setTTSEnabled,
isSpeaking,
isPaused,
isSupported,
voices,
voiceConfig,
setVoiceConfig,
speakCurrentScene,
stopSpeaking,
pauseSpeaking,
resumeSpeaking,
};
}
export default useTTSPlayback;