/** * useMessageTTS Hook * * Hook for playing TTS on individual messages. * Uses the configured voice provider (browser, OpenAI, or macOS Say). */ import { useCallback, useState } from 'react'; import { useConfigStore } from '@/stores/useConfigStore'; import { useServerTTS } from './useServerTTS'; import { useSayTTS } from './useSayTTS'; import { useLocalTTS } from './useLocalTTS'; import { browserVoiceService } from '@/lib/voice/browserVoiceService'; import { sanitizeForTTS } from '@/lib/voice/summarize'; import { runtimeFetch } from '@/lib/runtime-fetch'; // Below this length the reply is comfortable to listen to as-is; summarizing // would only add latency. const TTS_SUMMARIZE_MIN_CHARS = 600; const SUMMARIZE_SYSTEM_PROMPT = 'Summarize the assistant reply for text-to-speech listening. Reply with 2-4 sentences of plain spoken prose in the same language as the reply. No markdown, no lists, no code — mention code changes briefly in words instead.'; async function summarizeForSpeech( text: string, preferred: { providerID?: string; modelID?: string }, ): Promise { try { const response = await runtimeFetch('/api/small-model/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: text, system: SUMMARIZE_SYSTEM_PROMPT, ...(preferred.providerID ? { preferredProviderID: preferred.providerID } : {}), ...(preferred.modelID ? { preferredModelID: preferred.modelID } : {}), }), }); if (!response.ok) return null; const payload = await response.json().catch(() => null) as { text?: unknown } | null; return typeof payload?.text === 'string' && payload.text.trim() ? payload.text.trim() : null; } catch { return null; } } export interface UseMessageTTSReturn { /** Whether TTS is currently playing for this message */ isPlaying: boolean; /** Play the message text */ play: (text: string) => Promise; /** Stop playback */ stop: () => void; } export function useMessageTTS(): UseMessageTTSReturn { const [isPlaying, setIsPlaying] = useState(false); const voiceProvider = useConfigStore((state) => state.voiceProvider); const speechRate = useConfigStore((state) => state.speechRate); const speechPitch = useConfigStore((state) => state.speechPitch); const speechVolume = useConfigStore((state) => state.speechVolume); const sayVoice = useConfigStore((state) => state.sayVoice); const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId); const browserVoice = useConfigStore((state) => state.browserVoice); const openaiVoice = useConfigStore((state) => state.openaiVoice); const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice); const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl); const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const ttsInputMode = useConfigStore((state) => state.ttsInputMode); const isServerProvider = voiceProvider === 'openai' || voiceProvider === 'openai-compatible'; const shouldCheckOpenAIAvailability = showMessageTTSButtons && isServerProvider; const shouldCheckSayAvailability = showMessageTTSButtons && voiceProvider === 'say'; const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable } = useServerTTS({ enabled: shouldCheckOpenAIAvailability, availabilityMode: voiceProvider === 'openai-compatible' ? 'openai-compatible' : 'openai', }); const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable } = useSayTTS({ enabled: shouldCheckSayAvailability, }); const { speak: speakLocalTTS, stop: stopLocalTTS } = useLocalTTS(); const stop = useCallback(() => { setIsPlaying(false); stopServerTTS(); stopSayTTS(); stopLocalTTS(); browserVoiceService.cancelSpeech(); }, [stopServerTTS, stopSayTTS, stopLocalTTS]); const play = useCallback(async (text: string) => { if (!text.trim()) return; // Stop any existing playback stop(); setIsPlaying(true); try { // Summarized mode: replace long replies with a short spoken-prose // summary from the small model; fall back to the sanitized // original when summarization is unavailable. let sourceText = text; if (ttsInputMode === 'summarized' && text.length >= TTS_SUMMARIZE_MIN_CHARS) { const { currentProviderId, currentModelId } = useConfigStore.getState(); const summary = await summarizeForSpeech(text, { providerID: currentProviderId || undefined, modelID: currentModelId || undefined, }); if (summary) { sourceText = summary; } } const shouldUseRaw = ttsInputMode === 'raw' && isServerProvider; const sanitizedText = sanitizeForTTS(sourceText); const textToSpeak = shouldUseRaw ? sourceText : sanitizedText; if (isServerProvider && isServerTTSAvailable) { const voice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice; const baseURL = voiceProvider === 'openai-compatible' ? openaiCompatibleUrl : undefined; const model = voiceProvider === 'openai-compatible' ? openaiCompatibleTtsModel : undefined; await speakServerTTS(textToSpeak, { voice, model, speed: speechRate, pitch: speechPitch, volume: speechVolume, summarize: false, baseURL, onEnd: () => setIsPlaying(false), onError: () => setIsPlaying(false), }); } else if (voiceProvider === 'local') { await speakLocalTTS(sanitizedText, { speakerId: localTtsVoiceId, speed: speechRate, onEnd: () => setIsPlaying(false), onError: () => setIsPlaying(false), }); } else if (voiceProvider === 'say' && isSayTTSAvailable) { const wordsPerMinute = Math.round(100 + (speechRate - 0.5) * 200); await speakSayTTS(sanitizedText, { voice: sayVoice, rate: wordsPerMinute, onEnd: () => setIsPlaying(false), onError: () => setIsPlaying(false), }); } else { // Browser TTS await browserVoiceService.waitForVoices(); await browserVoiceService.resumeAudioContext(); await browserVoiceService.speakText( sanitizedText, navigator.language || 'en-US', () => setIsPlaying(false), { rate: speechRate, pitch: speechPitch, volume: speechVolume, voiceName: browserVoice || undefined, } ); } } catch (err) { console.error('[useMessageTTS] Playback error:', err); setIsPlaying(false); } }, [ voiceProvider, isServerProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, openaiCompatibleVoice, openaiCompatibleUrl, openaiCompatibleTtsModel, isServerTTSAvailable, isSayTTSAvailable, ttsInputMode, speakServerTTS, speakSayTTS, speakLocalTTS, localTtsVoiceId, stop, ]); return { isPlaying, play, stop, }; }