Text-to-speech picked one voice regardless of what language a reply was in. A dependency-free language detector (script, marker letters, function words) now decides the language of the whole message once; with the new "Match the voice to the language of the text" setting the local provider switches to a catalog model for that language (Kokoro zh/en and Piper models for 12 languages, downloaded on first use like the existing model) and macOS say switches to an installed voice whose locale matches. The local voice picker lists voices of every installed model, and the settings show which language models are on disk. The Ukrainian Piper medium build is a character-level model that sherpa-onnx turns into noise, so the espeak-based Lada build is used instead. Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
206 lines
8.5 KiB
TypeScript
206 lines
8.5 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 localTtsModelId = useConfigStore((state) => state.localTtsModelId);
|
|
const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage);
|
|
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, {
|
|
model: localTtsModelId,
|
|
speakerId: localTtsVoiceId,
|
|
speed: speechRate,
|
|
language: ttsFollowTextLanguage ? 'auto' : undefined,
|
|
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,
|
|
language: ttsFollowTextLanguage ? 'auto' : undefined,
|
|
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,
|
|
localTtsModelId,
|
|
ttsFollowTextLanguage,
|
|
stop,
|
|
]);
|
|
|
|
return {
|
|
isPlaying,
|
|
play,
|
|
stop,
|
|
};
|
|
}
|