feat(voice): match local and macOS voices to the language of the text

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
This commit is contained in:
Bohdan Triapitsyn
2026-08-30 02:24:22 +03:00
parent 49f0a9e62f
commit 391f938334
29 changed files with 949 additions and 115 deletions
+35
View File
@@ -1067,6 +1067,10 @@ interface ConfigStore {
sayVoice: string;
browserVoice: string;
localTtsVoiceId: number;
/** Local TTS model the chosen voice belongs to (catalog id). */
localTtsModelId: string;
/** Local and macOS voices follow the language of the text being read. */
ttsFollowTextLanguage: boolean;
openaiVoice: string;
openaiApiKey: string;
openaiCompatibleUrl: string;
@@ -1094,6 +1098,8 @@ interface ConfigStore {
setSayVoice: (voice: string) => void;
setBrowserVoice: (voice: string) => void;
setLocalTtsVoiceId: (voiceId: number) => void;
setLocalTtsModelId: (modelId: string) => void;
setTtsFollowTextLanguage: (enabled: boolean) => void;
setOpenaiVoice: (voice: string) => void;
setOpenaiApiKey: (apiKey: string) => void;
setOpenaiCompatibleUrl: (url: string) => void;
@@ -1277,6 +1283,21 @@ export const useConfigStore = create<ConfigStore>()(
}
return 0;
})(),
localTtsModelId: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('localTtsModelId');
if (saved) return saved;
}
return 'kokoro-en-v0_19';
})(),
ttsFollowTextLanguage: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ttsFollowTextLanguage');
if (saved !== null) return saved === 'true';
}
return true;
})(),
// Browser voice - load from localStorage or default to empty (auto-select)
browserVoice: (() => {
if (typeof window !== 'undefined') {
@@ -2962,6 +2983,20 @@ export const useConfigStore = create<ConfigStore>()(
}
},
setLocalTtsModelId: (modelId: string) => {
set({ localTtsModelId: modelId });
if (typeof window !== 'undefined') {
localStorage.setItem('localTtsModelId', modelId);
}
},
setTtsFollowTextLanguage: (enabled: boolean) => {
set({ ttsFollowTextLanguage: enabled });
if (typeof window !== 'undefined') {
localStorage.setItem('ttsFollowTextLanguage', String(enabled));
}
},
setBrowserVoice: (voice: string) => {
set({ browserVoice: voice });
if (typeof window !== 'undefined') {