199 lines
8.1 KiB
TypeScript
199 lines
8.1 KiB
TypeScript
/**
|
|
* 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 { requestSmallModel } from '@/lib/smallModelRequest';
|
|
|
|
// 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 {
|
|
const response = await requestSmallModel({
|
|
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<void>;
|
|
/** 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,
|
|
};
|
|
}
|