2026-02-09 13:55:10 -08:00
/**
* 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' ;
2026-07-04 02:48:07 +03:00
import { useLocalTTS } from './useLocalTTS' ;
2026-02-09 13:55:10 -08:00
import { browserVoiceService } from '@/lib/voice/browserVoiceService' ;
2026-05-19 02:06:52 +03:00
import { sanitizeForTTS } from '@/lib/voice/summarize' ;
2026-08-15 01:59:26 +03:00
import { requestSmallModel } from '@/lib/smallModelRequest' ;
2026-07-05 23:19:10 +03:00
// 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 < string | null > {
try {
2026-08-15 01:59:26 +03:00
const response = await requestSmallModel ({
2026-07-05 23:19:10 +03:00
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 ;
}
}
2026-02-09 13:55:10 -08:00
export interface UseMessageTTSReturn {
/** Whether TTS is currently playing for this message */
isPlaying : boolean ;
/** Play the message text */
play : ( text : string ) => Promise < void >;
/** Stop playback */
stop : () => void ;
}
export function useMessageTTS () : UseMessageTTSReturn {
const [ isPlaying , setIsPlaying ] = useState ( false );
2026-04-04 02:19:55 +03:00
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 );
2026-07-04 02:48:07 +03:00
const localTtsVoiceId = useConfigStore (( state ) => state . localTtsVoiceId );
2026-04-04 02:19:55 +03:00
const browserVoice = useConfigStore (( state ) => state . browserVoice );
const openaiVoice = useConfigStore (( state ) => state . openaiVoice );
2026-04-12 09:33:15 +02:00
const openaiCompatibleVoice = useConfigStore (( state ) => state . openaiCompatibleVoice );
const openaiCompatibleUrl = useConfigStore (( state ) => state . openaiCompatibleUrl );
const openaiCompatibleTtsModel = useConfigStore (( state ) => state . openaiCompatibleTtsModel );
2026-04-04 02:19:55 +03:00
const showMessageTTSButtons = useConfigStore (( state ) => state . showMessageTTSButtons );
2026-06-09 00:31:21 +08:00
const ttsInputMode = useConfigStore (( state ) => state . ttsInputMode );
2026-03-23 23:51:55 +02:00
2026-04-12 09:33:15 +02:00
const isServerProvider = voiceProvider === 'openai' || voiceProvider === 'openai-compatible' ;
const shouldCheckOpenAIAvailability = showMessageTTSButtons && isServerProvider ;
2026-03-23 23:51:55 +02:00
const shouldCheckSayAvailability = showMessageTTSButtons && voiceProvider === 'say' ;
const { speak : speakServerTTS , stop : stopServerTTS , isAvailable : isServerTTSAvailable } = useServerTTS ({
enabled : shouldCheckOpenAIAvailability ,
2026-04-12 10:38:16 +03:00
availabilityMode : voiceProvider === 'openai-compatible' ? 'openai-compatible' : 'openai' ,
2026-03-23 23:51:55 +02:00
});
const { speak : speakSayTTS , stop : stopSayTTS , isAvailable : isSayTTSAvailable } = useSayTTS ({
enabled : shouldCheckSayAvailability ,
});
2026-07-04 02:48:07 +03:00
const { speak : speakLocalTTS , stop : stopLocalTTS } = useLocalTTS ();
2026-02-09 13:55:10 -08:00
const stop = useCallback (() => {
setIsPlaying ( false );
stopServerTTS ();
stopSayTTS ();
2026-07-04 02:48:07 +03:00
stopLocalTTS ();
2026-02-09 13:55:10 -08:00
browserVoiceService . cancelSpeech ();
2026-07-04 02:48:07 +03:00
}, [ stopServerTTS , stopSayTTS , stopLocalTTS ]);
2026-02-09 13:55:10 -08:00
const play = useCallback ( async ( text : string ) => {
if ( ! text . trim ()) return ;
// Stop any existing playback
stop ();
setIsPlaying ( true );
try {
2026-07-05 23:19:10 +03:00
// 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 ;
}
}
2026-06-09 00:31:21 +08:00
const shouldUseRaw = ttsInputMode === 'raw' && isServerProvider ;
2026-07-05 23:19:10 +03:00
const sanitizedText = sanitizeForTTS ( sourceText );
const textToSpeak = shouldUseRaw ? sourceText : sanitizedText ;
2026-02-09 13:55:10 -08:00
2026-04-12 09:33:15 +02:00
if ( isServerProvider && isServerTTSAvailable ) {
const voice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice ;
const baseURL = voiceProvider === 'openai-compatible' ? openaiCompatibleUrl : undefined ;
const model = voiceProvider === 'openai-compatible' ? openaiCompatibleTtsModel : undefined ;
2026-02-09 13:55:10 -08:00
await speakServerTTS ( textToSpeak , {
2026-04-12 09:33:15 +02:00
voice ,
model ,
2026-02-09 13:55:10 -08:00
speed : speechRate ,
2026-04-12 09:33:15 +02:00
pitch : speechPitch ,
volume : speechVolume ,
2026-05-19 02:06:52 +03:00
summarize : false ,
2026-04-12 09:33:15 +02:00
baseURL ,
2026-02-09 13:55:10 -08:00
onEnd : () => setIsPlaying ( false ),
onError : () => setIsPlaying ( false ),
});
2026-07-04 02:48:07 +03:00
} else if ( voiceProvider === 'local' ) {
await speakLocalTTS ( sanitizedText , {
speakerId : localTtsVoiceId ,
speed : speechRate ,
onEnd : () => setIsPlaying ( false ),
onError : () => setIsPlaying ( false ),
});
2026-02-09 13:55:10 -08:00
} else if ( voiceProvider === 'say' && isSayTTSAvailable ) {
const wordsPerMinute = Math . round ( 100 + ( speechRate - 0.5 ) * 200 );
2026-06-09 00:31:21 +08:00
await speakSayTTS ( sanitizedText , {
2026-02-09 13:55:10 -08:00
voice : sayVoice ,
rate : wordsPerMinute ,
onEnd : () => setIsPlaying ( false ),
onError : () => setIsPlaying ( false ),
});
} else {
// Browser TTS
await browserVoiceService . waitForVoices ();
await browserVoiceService . resumeAudioContext ();
await browserVoiceService . speakText (
2026-06-09 00:31:21 +08:00
sanitizedText ,
2026-02-09 13:55:10 -08:00
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 ,
2026-04-12 09:33:15 +02:00
isServerProvider ,
2026-02-09 13:55:10 -08:00
speechRate ,
speechPitch ,
speechVolume ,
sayVoice ,
browserVoice ,
openaiVoice ,
2026-04-12 09:33:15 +02:00
openaiCompatibleVoice ,
openaiCompatibleUrl ,
openaiCompatibleTtsModel ,
2026-02-09 13:55:10 -08:00
isServerTTSAvailable ,
isSayTTSAvailable ,
2026-06-09 00:31:21 +08:00
ttsInputMode ,
2026-02-09 13:55:10 -08:00
speakServerTTS ,
speakSayTTS ,
2026-07-04 02:48:07 +03:00
speakLocalTTS ,
localTtsVoiceId ,
2026-02-09 13:55:10 -08:00
stop ,
]);
return {
isPlaying ,
play ,
stop ,
};
}