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:
@@ -71,6 +71,7 @@ const LOCAL_STT_MODELS = [
|
||||
|
||||
interface DictationModelState {
|
||||
id: string;
|
||||
description?: string;
|
||||
installed: boolean;
|
||||
downloading: boolean;
|
||||
downloadProgress: number | null;
|
||||
@@ -288,10 +289,32 @@ const KOKORO_VOICE_OPTIONS = [
|
||||
|
||||
const LOCAL_TTS_MODEL_ID = 'kokoro-en-v0_19';
|
||||
|
||||
const LocalTtsModelStatus = () => {
|
||||
const { t } = useI18n();
|
||||
const [model, setModel] = useState<DictationModelState | null>(null);
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const KOKORO_MULTI_LANG_MODEL_ID = 'kokoro-multi-lang-v1_1';
|
||||
// A few named speakers out of the 103 in the Chinese/English Kokoro build.
|
||||
const KOKORO_MULTI_LANG_VOICE_OPTIONS = [
|
||||
{ id: 0, label: 'Maple (af)' },
|
||||
{ id: 1, label: 'Sol (af)' },
|
||||
{ id: 2, label: 'Vale (bf)' },
|
||||
{ id: 3, label: 'Xiaoxiao (zf)' },
|
||||
{ id: 58, label: 'Yunxi (zm)' },
|
||||
];
|
||||
|
||||
interface LocalTtsVoiceOption {
|
||||
modelId: string;
|
||||
speakerId: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const localTtsVoiceKey = (modelId: string, speakerId: number): string => `${modelId}:${speakerId}`;
|
||||
|
||||
/**
|
||||
* Local TTS models as the server reports them, plus the actions Settings
|
||||
* offers on them. Shared by the model list and the voice picker so both see
|
||||
* the same install state.
|
||||
*/
|
||||
const useLocalTtsModels = () => {
|
||||
const [models, setModels] = useState<DictationModelState[]>([]);
|
||||
const [requestingId, setRequestingId] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -300,11 +323,8 @@ const LocalTtsModelStatus = () => {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
const entry = Array.isArray(data?.ttsModels)
|
||||
? data.ttsModels.find((m: DictationModelState) => m.id === LOCAL_TTS_MODEL_ID)
|
||||
: null;
|
||||
if (entry) {
|
||||
setModel(entry);
|
||||
if (Array.isArray(data?.ttsModels)) {
|
||||
setModels(data.ttsModels);
|
||||
}
|
||||
} catch {
|
||||
// Display-only status; keep the previous state on fetch failure.
|
||||
@@ -315,81 +335,118 @@ const LocalTtsModelStatus = () => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const anyDownloading = models.some((model) => model.downloading);
|
||||
useEffect(() => {
|
||||
if (!model?.downloading) {
|
||||
if (!anyDownloading) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
void refresh();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [model?.downloading, refresh]);
|
||||
}, [anyDownloading, refresh]);
|
||||
|
||||
const request = async (method: 'POST' | 'DELETE') => {
|
||||
setRequesting(true);
|
||||
const request = useCallback(async (modelId: string, method: 'POST' | 'DELETE') => {
|
||||
setRequestingId(modelId);
|
||||
try {
|
||||
const path = method === 'POST'
|
||||
? `/api/dictation/models/${LOCAL_TTS_MODEL_ID}/download`
|
||||
: `/api/dictation/models/${LOCAL_TTS_MODEL_ID}`;
|
||||
? `/api/dictation/models/${modelId}/download`
|
||||
: `/api/dictation/models/${modelId}`;
|
||||
await runtimeFetch(path, { method });
|
||||
await refresh();
|
||||
} catch {
|
||||
// Status refresh reports errors.
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
setRequestingId(null);
|
||||
}
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
if (!model) {
|
||||
return { models, requestingId, request, refresh };
|
||||
};
|
||||
|
||||
// Voices the picker offers: Kokoro speakers for the Kokoro models, one voice
|
||||
// per installed Piper model. Only installed models (plus the default) appear,
|
||||
// so a language model the server fetched on its own becomes selectable once
|
||||
// it is on disk.
|
||||
const buildLocalTtsVoiceOptions = (models: DictationModelState[]): LocalTtsVoiceOption[] => {
|
||||
const options: LocalTtsVoiceOption[] = KOKORO_VOICE_OPTIONS.map((voice) => ({
|
||||
modelId: LOCAL_TTS_MODEL_ID,
|
||||
speakerId: voice.id,
|
||||
label: voice.label,
|
||||
}));
|
||||
for (const model of models) {
|
||||
if (model.id === LOCAL_TTS_MODEL_ID || !model.installed) continue;
|
||||
if (model.id === KOKORO_MULTI_LANG_MODEL_ID) {
|
||||
for (const voice of KOKORO_MULTI_LANG_VOICE_OPTIONS) {
|
||||
options.push({ modelId: model.id, speakerId: voice.id, label: `${voice.label} · Kokoro zh/en` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
options.push({ modelId: model.id, speakerId: 0, label: model.description ?? model.id });
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const LocalTtsModelStatus = ({ models, requestingId, request }: ReturnType<typeof useLocalTtsModels>) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// The default English model is always listed; language models the server
|
||||
// fetched on its own appear once they are installed or downloading, so
|
||||
// the list shows what is on disk rather than the whole catalog.
|
||||
const visible = models.filter((model) => model.id === LOCAL_TTS_MODEL_ID || model.installed || model.downloading);
|
||||
if (visible.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1.5">
|
||||
<span className="typography-ui-label text-foreground">Kokoro</span>
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">305 MB</span>
|
||||
{model.installed ? (
|
||||
<>
|
||||
<Icon
|
||||
name="checkbox-circle"
|
||||
className="h-4 w-4 text-[var(--status-success)]"
|
||||
aria-label={t('settings.voice.page.stt.modelInstalled')}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
disabled={requesting}
|
||||
onClick={() => { void request('DELETE'); }}
|
||||
title={t('settings.voice.page.stt.modelDelete')}
|
||||
aria-label={t('settings.voice.page.stt.modelDelete')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : model.downloading ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">
|
||||
{typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={requesting}
|
||||
onClick={() => { void request('POST'); }}
|
||||
title={t('settings.voice.page.stt.modelDownload')}
|
||||
aria-label={t('settings.voice.page.stt.modelDownload')}
|
||||
>
|
||||
<Icon name="download" className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{model.downloadError ? (
|
||||
<span className="typography-meta text-[var(--status-error)]">{model.downloadError}</span>
|
||||
) : null}
|
||||
<div className="flex flex-col">
|
||||
{visible.map((model) => (
|
||||
<div key={model.id} className="flex items-center gap-2 py-1.5">
|
||||
<span className="typography-ui-label text-foreground">{model.description ?? model.id}</span>
|
||||
{model.installed ? (
|
||||
<>
|
||||
<Icon
|
||||
name="checkbox-circle"
|
||||
className="h-4 w-4 text-[var(--status-success)]"
|
||||
aria-label={t('settings.voice.page.stt.modelInstalled')}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
disabled={requestingId !== null}
|
||||
onClick={() => { void request(model.id, 'DELETE'); }}
|
||||
title={t('settings.voice.page.stt.modelDelete')}
|
||||
aria-label={t('settings.voice.page.stt.modelDelete')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : model.downloading ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="typography-ui-compact tabular-nums text-muted-foreground">
|
||||
{typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={requestingId !== null}
|
||||
onClick={() => { void request(model.id, 'POST'); }}
|
||||
title={t('settings.voice.page.stt.modelDownload')}
|
||||
aria-label={t('settings.voice.page.stt.modelDownload')}
|
||||
>
|
||||
<Icon name="download" className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{model.downloadError ? (
|
||||
<span className="typography-meta text-[var(--status-error)]">{model.downloadError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -424,6 +481,12 @@ export const VoiceSettings: React.FC = () => {
|
||||
const sayVoice = useConfigStore((state) => state.sayVoice);
|
||||
const setSayVoice = useConfigStore((state) => state.setSayVoice);
|
||||
const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId);
|
||||
const localTtsModelId = useConfigStore((state) => state.localTtsModelId);
|
||||
const setLocalTtsModelId = useConfigStore((state) => state.setLocalTtsModelId);
|
||||
const localTtsModels = useLocalTtsModels();
|
||||
const localTtsVoiceOptions = useMemo(() => buildLocalTtsVoiceOptions(localTtsModels.models), [localTtsModels.models]);
|
||||
const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage);
|
||||
const setTtsFollowTextLanguage = useConfigStore((state) => state.setTtsFollowTextLanguage);
|
||||
const setLocalTtsVoiceId = useConfigStore((state) => state.setLocalTtsVoiceId);
|
||||
const { speak: speakLocalTts, stop: stopLocalTts, isPlaying: isLocalTtsPlaying, error: localTtsError } = useLocalTTS();
|
||||
|
||||
@@ -432,13 +495,14 @@ export const VoiceSettings: React.FC = () => {
|
||||
stopLocalTts();
|
||||
return;
|
||||
}
|
||||
const voiceLabel = KOKORO_VOICE_OPTIONS.find((v) => v.id === localTtsVoiceId)?.label
|
||||
const voiceLabel = localTtsVoiceOptions.find((v) => v.modelId === localTtsModelId && v.speakerId === localTtsVoiceId)?.label
|
||||
?? String(localTtsVoiceId);
|
||||
void speakLocalTts(t('settings.voice.page.preview.voiceLine', { voiceName: voiceLabel }), {
|
||||
model: localTtsModelId,
|
||||
speakerId: localTtsVoiceId,
|
||||
speed: useConfigStore.getState().speechRate,
|
||||
});
|
||||
}, [isLocalTtsPlaying, localTtsVoiceId, speakLocalTts, stopLocalTts, t]);
|
||||
}, [isLocalTtsPlaying, localTtsModelId, localTtsVoiceId, localTtsVoiceOptions, speakLocalTts, stopLocalTts, t]);
|
||||
const browserVoice = useConfigStore((state) => state.browserVoice);
|
||||
const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice);
|
||||
const openaiVoice = useConfigStore((state) => state.openaiVoice);
|
||||
@@ -959,24 +1023,39 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Local (Kokoro) TTS model status */}
|
||||
{voiceProvider === 'local' && <LocalTtsModelStatus />}
|
||||
{voiceProvider === 'local' && <LocalTtsModelStatus {...localTtsModels} />}
|
||||
|
||||
{(voiceProvider === 'local' || voiceProvider === 'say') && (
|
||||
<SettingsCheckboxRow
|
||||
checked={ttsFollowTextLanguage}
|
||||
onChange={setTtsFollowTextLanguage}
|
||||
label={t('settings.voice.page.field.followTextLanguage')}
|
||||
ariaLabel={t('settings.voice.page.field.followTextLanguageAria')}
|
||||
info={t('settings.voice.page.field.followTextLanguageInfo')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Voice Selection */}
|
||||
<SettingsFieldRow label={t('settings.voice.page.field.voice')}>
|
||||
{voiceProvider === 'local' && (
|
||||
<>
|
||||
<Select
|
||||
value={String(localTtsVoiceId)}
|
||||
onValueChange={(value) => setLocalTtsVoiceId(Number.parseInt(value, 10) || 0)}
|
||||
value={localTtsVoiceKey(localTtsModelId, localTtsVoiceId)}
|
||||
onValueChange={(value) => {
|
||||
const option = localTtsVoiceOptions.find((v) => localTtsVoiceKey(v.modelId, v.speakerId) === value);
|
||||
if (!option) return;
|
||||
setLocalTtsModelId(option.modelId);
|
||||
setLocalTtsVoiceId(option.speakerId);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={t('settings.voice.page.field.selectVoicePlaceholder')}>
|
||||
{(value) => KOKORO_VOICE_OPTIONS.find((v) => String(v.id) === value)?.label ?? value}
|
||||
{(value) => localTtsVoiceOptions.find((v) => localTtsVoiceKey(v.modelId, v.speakerId) === value)?.label ?? value}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KOKORO_VOICE_OPTIONS.map((v) => (
|
||||
<SelectItem key={v.id} value={String(v.id)}>{v.label}</SelectItem>
|
||||
{localTtsVoiceOptions.map((v) => (
|
||||
<SelectItem key={localTtsVoiceKey(v.modelId, v.speakerId)} value={localTtsVoiceKey(v.modelId, v.speakerId)}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -14,10 +14,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export interface LocalTTSSpeakOptions {
|
||||
/** Kokoro speaker id (0-10) */
|
||||
/** Catalog id of the local model to use; defaults to the server's default model. */
|
||||
model?: string;
|
||||
/** Speaker id within the model (Kokoro voices; Piper models have one) */
|
||||
speakerId?: number;
|
||||
/** Playback speed multiplier (1.0 = normal) */
|
||||
speed?: number;
|
||||
/**
|
||||
* `'auto'`: the server picks a model and voice for the text's language.
|
||||
* The language is judged on the whole message, not on each chunk sent for
|
||||
* synthesis, so a short chunk cannot flip the voice mid-reply.
|
||||
*/
|
||||
language?: 'auto';
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
@@ -35,6 +43,8 @@ export interface UseLocalTTSReturn {
|
||||
/** Target chunk size: big enough to amortize requests, small enough for low latency. */
|
||||
const MIN_CHUNK_CHARS = 60;
|
||||
const MAX_CHUNK_CHARS = 400;
|
||||
// Enough of the message for language detection to see whole sentences.
|
||||
const LANGUAGE_SAMPLE_CHARS = 2000;
|
||||
|
||||
/**
|
||||
* Split text into sentence-aligned chunks for pipelined synthesis.
|
||||
@@ -170,6 +180,7 @@ export function useLocalTTS(): UseLocalTTSReturn {
|
||||
|
||||
const session: PlaybackSession = { cancelled: false, abort: new AbortController() };
|
||||
sessionRef.current = session;
|
||||
const languageSample = options?.language === 'auto' ? text.slice(0, LANGUAGE_SAMPLE_CHARS) : undefined;
|
||||
|
||||
const fetchChunk = async (chunk: string): Promise<ArrayBuffer> => {
|
||||
const response = await runtimeFetch('/api/dictation/tts/speak', {
|
||||
@@ -177,8 +188,11 @@ export function useLocalTTS(): UseLocalTTSReturn {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: chunk,
|
||||
model: options?.model,
|
||||
...(typeof options?.speakerId === 'number' ? { speakerId: options.speakerId } : {}),
|
||||
...(typeof options?.speed === 'number' ? { speed: options.speed } : {}),
|
||||
language: options?.language,
|
||||
languageSample,
|
||||
}),
|
||||
signal: session.abort.signal,
|
||||
});
|
||||
|
||||
@@ -61,6 +61,8 @@ export function useMessageTTS(): UseMessageTTSReturn {
|
||||
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);
|
||||
@@ -135,8 +137,10 @@ export function useMessageTTS(): UseMessageTTSReturn {
|
||||
});
|
||||
} else if (voiceProvider === 'local') {
|
||||
await speakLocalTTS(sanitizedText, {
|
||||
model: localTtsModelId,
|
||||
speakerId: localTtsVoiceId,
|
||||
speed: speechRate,
|
||||
language: ttsFollowTextLanguage ? 'auto' : undefined,
|
||||
onEnd: () => setIsPlaying(false),
|
||||
onError: () => setIsPlaying(false),
|
||||
});
|
||||
@@ -145,6 +149,7 @@ export function useMessageTTS(): UseMessageTTSReturn {
|
||||
await speakSayTTS(sanitizedText, {
|
||||
voice: sayVoice,
|
||||
rate: wordsPerMinute,
|
||||
language: ttsFollowTextLanguage ? 'auto' : undefined,
|
||||
onEnd: () => setIsPlaying(false),
|
||||
onError: () => setIsPlaying(false),
|
||||
});
|
||||
@@ -187,6 +192,8 @@ export function useMessageTTS(): UseMessageTTSReturn {
|
||||
speakSayTTS,
|
||||
speakLocalTTS,
|
||||
localTtsVoiceId,
|
||||
localTtsModelId,
|
||||
ttsFollowTextLanguage,
|
||||
stop,
|
||||
]);
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ interface SpeakOptions {
|
||||
voice?: string;
|
||||
/** Speech rate in words per minute (defaults to 200) */
|
||||
rate?: number;
|
||||
/** `'auto'`: the server switches to a voice that speaks the text's language. */
|
||||
language?: 'auto';
|
||||
/** Callback when playback starts */
|
||||
onStart?: () => void;
|
||||
/** Callback when playback ends */
|
||||
@@ -229,6 +231,7 @@ export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn {
|
||||
text: text.trim(),
|
||||
voice: options?.voice || 'Samantha',
|
||||
rate: options?.rate || 200,
|
||||
language: options?.language,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
@@ -1795,7 +1795,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': 'Server',
|
||||
'settings.voice.page.provider.local': 'Lokal',
|
||||
'settings.voice.page.tooltip.sttLocal': 'On-device Transkription auf dem OpenChamber-Server. Modelle werden automatisch heruntergeladen; kein API-Schlüssel erforderlich.',
|
||||
'settings.voice.page.tooltip.localTts': 'On-device Synthese auf dem OpenChamber-Server (Kokoro, Englisch). Das Modell wird automatisch heruntergeladen; kein API-Schlüssel erforderlich.',
|
||||
'settings.voice.page.tooltip.localTts': 'On-Device-Synthese auf dem OpenChamber-Server (Kokoro für Englisch; Modelle für andere Sprachen werden beim ersten Einsatz geladen). Kein API-Schlüssel nötig.',
|
||||
'settings.voice.page.field.followTextLanguage': 'Stimme an die Sprache des Textes anpassen',
|
||||
'settings.voice.page.field.followTextLanguageAria': 'Stimme an die Sprache des Textes anpassen',
|
||||
'settings.voice.page.field.followTextLanguageInfo': 'Ist eine Antwort in einer anderen Sprache, wird eine Stimme für diese Sprache verwendet: eine passende macOS-Stimme oder ein lokales Modell, das beim ersten Einsatz geladen wird.',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (Englisch)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 europäische Sprachen)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base (mehrsprachig)',
|
||||
|
||||
@@ -1862,7 +1862,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': 'Server',
|
||||
'settings.voice.page.provider.local': 'Local',
|
||||
'settings.voice.page.tooltip.sttLocal': 'On-device transcription on the OpenChamber server. Models download automatically; no API key needed.',
|
||||
'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro, English). The model downloads automatically; no API key needed.',
|
||||
'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro for English; models for other languages download on first use). No API key needed.',
|
||||
'settings.voice.page.field.followTextLanguage': 'Match the voice to the language of the text',
|
||||
'settings.voice.page.field.followTextLanguageAria': 'Match the voice to the language of the text',
|
||||
'settings.voice.page.field.followTextLanguageInfo': 'When a reply is in another language, a voice for that language is used: a matching macOS voice, or a local model that downloads on first use.',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (English)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 European languages)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingual)',
|
||||
|
||||
@@ -1839,7 +1839,10 @@ export const settingsDict = {
|
||||
"settings.voice.page.provider.server": "Servidor",
|
||||
"settings.voice.page.provider.local": "Local",
|
||||
"settings.voice.page.tooltip.sttLocal": "Transcripción local en el servidor de OpenChamber. Los modelos se descargan automáticamente; no se necesita clave de API.",
|
||||
"settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro, inglés). El modelo se descarga automáticamente; no se necesita clave de API.",
|
||||
"settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro para inglés; los modelos de otros idiomas se descargan en el primer uso). No requiere clave de API.",
|
||||
"settings.voice.page.field.followTextLanguage": "Ajustar la voz al idioma del texto",
|
||||
"settings.voice.page.field.followTextLanguageAria": "Ajustar la voz al idioma del texto",
|
||||
"settings.voice.page.field.followTextLanguageInfo": "Si una respuesta está en otro idioma, se usa una voz para ese idioma: una voz de macOS adecuada o un modelo local que se descarga en el primer uso.",
|
||||
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglés)",
|
||||
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeos)",
|
||||
"settings.voice.page.stt.model.whisperBase": "Whisper base (multilingüe)",
|
||||
|
||||
@@ -1757,7 +1757,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': 'Serveur',
|
||||
'settings.voice.page.provider.local': 'Local',
|
||||
'settings.voice.page.tooltip.sttLocal': 'Transcription locale sur le serveur OpenChamber. Les modèles se téléchargent automatiquement ; aucune clé d\'API requise.',
|
||||
'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro, anglais). Le modèle se télécharge automatiquement ; aucune clé d’API requise.',
|
||||
'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro pour l’anglais ; les modèles des autres langues sont téléchargés à la première utilisation). Aucune clé API requise.',
|
||||
'settings.voice.page.field.followTextLanguage': 'Adapter la voix à la langue du texte',
|
||||
'settings.voice.page.field.followTextLanguageAria': 'Adapter la voix à la langue du texte',
|
||||
'settings.voice.page.field.followTextLanguageInfo': 'Si une réponse est dans une autre langue, une voix pour cette langue est utilisée : une voix macOS adaptée ou un modèle local téléchargé à la première utilisation.',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (anglais)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 langues européennes)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingue)',
|
||||
|
||||
@@ -1872,7 +1872,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': 'サーバー',
|
||||
'settings.voice.page.provider.local': 'ローカル',
|
||||
'settings.voice.page.tooltip.sttLocal': 'OpenChamber サーバー上でローカルに文字起こしします。モデルは自動でダウンロードされ、API キーは不要です。',
|
||||
'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(Kokoro、英語)。モデルは自動でダウンロードされ、API キーは不要です。',
|
||||
'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(英語は Kokoro、他の言語のモデルは初回使用時にダウンロード)。API キーは不要です。',
|
||||
'settings.voice.page.field.followTextLanguage': 'テキストの言語に合わせて音声を選ぶ',
|
||||
'settings.voice.page.field.followTextLanguageAria': 'テキストの言語に合わせて音声を選ぶ',
|
||||
'settings.voice.page.field.followTextLanguageInfo': '返答が別の言語の場合、その言語の音声を使います。対応する macOS の音声、または初回使用時にダウンロードされるローカルモデルです。',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英語)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(ヨーロッパ25言語)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多言語)',
|
||||
|
||||
@@ -1839,7 +1839,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': '서버',
|
||||
'settings.voice.page.provider.local': '로컬',
|
||||
'settings.voice.page.tooltip.sttLocal': 'OpenChamber 서버에서 로컬로 변환합니다. 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.',
|
||||
'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(Kokoro, 영어). 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.',
|
||||
'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(영어는 Kokoro, 다른 언어 모델은 처음 사용할 때 다운로드). API 키가 필요 없습니다.',
|
||||
'settings.voice.page.field.followTextLanguage': '텍스트 언어에 맞는 음성 사용',
|
||||
'settings.voice.page.field.followTextLanguageAria': '텍스트 언어에 맞는 음성 사용',
|
||||
'settings.voice.page.field.followTextLanguageInfo': '응답이 다른 언어이면 해당 언어의 음성을 사용합니다. 일치하는 macOS 음성 또는 처음 사용할 때 다운로드되는 로컬 모델입니다.',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (영어)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (유럽 25개 언어)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base (다국어)',
|
||||
|
||||
@@ -2176,7 +2176,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': 'Serwer',
|
||||
'settings.voice.page.provider.local': 'Lokalny',
|
||||
'settings.voice.page.tooltip.sttLocal': 'Transkrypcja lokalna na serwerze OpenChamber. Modele pobierają się automatycznie; klucz API nie jest potrzebny.',
|
||||
'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro, angielski). Model pobiera się automatycznie; klucz API nie jest potrzebny.',
|
||||
'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro dla angielskiego; modele innych języków pobierane przy pierwszym użyciu). Klucz API nie jest potrzebny.',
|
||||
'settings.voice.page.field.followTextLanguage': 'Dopasuj głos do języka tekstu',
|
||||
'settings.voice.page.field.followTextLanguageAria': 'Dopasuj głos do języka tekstu',
|
||||
'settings.voice.page.field.followTextLanguageInfo': 'Gdy odpowiedź jest w innym języku, używany jest głos dla tego języka: pasujący głos macOS albo lokalny model pobierany przy pierwszym użyciu.',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (angielski)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 języków europejskich)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base (wielojęzyczny)',
|
||||
|
||||
@@ -1839,7 +1839,10 @@ export const settingsDict = {
|
||||
"settings.voice.page.provider.server": "Servidor",
|
||||
"settings.voice.page.provider.local": "Local",
|
||||
"settings.voice.page.tooltip.sttLocal": "Transcrição local no servidor do OpenChamber. Os modelos são baixados automaticamente; não é necessária chave de API.",
|
||||
"settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro, inglês). O modelo é baixado automaticamente; não é necessária chave de API.",
|
||||
"settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro para inglês; modelos de outros idiomas são baixados no primeiro uso). Não requer chave de API.",
|
||||
"settings.voice.page.field.followTextLanguage": "Ajustar a voz ao idioma do texto",
|
||||
"settings.voice.page.field.followTextLanguageAria": "Ajustar a voz ao idioma do texto",
|
||||
"settings.voice.page.field.followTextLanguageInfo": "Se uma resposta estiver em outro idioma, uma voz desse idioma é usada: uma voz do macOS correspondente ou um modelo local baixado no primeiro uso.",
|
||||
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglês)",
|
||||
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeus)",
|
||||
"settings.voice.page.stt.model.whisperBase": "Whisper base (multilíngue)",
|
||||
|
||||
@@ -1787,7 +1787,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': 'Sunucu',
|
||||
'settings.voice.page.provider.local': 'Yerel',
|
||||
'settings.voice.page.tooltip.sttLocal': 'OpenChamber sunucusunda cihaz üstü transkripsiyon. Modeller otomatik indirilir; API anahtarı gerekmez.',
|
||||
'settings.voice.page.tooltip.localTts': 'OpenChamber sunucusunda cihaz üstü sentez (Kokoro, İngilizce). Model otomatik indirilir; API anahtarı gerekmez.',
|
||||
'settings.voice.page.tooltip.localTts': 'OpenChamber sunucusunda yerel sentez (İngilizce için Kokoro; diğer dillerin modelleri ilk kullanımda indirilir). API anahtarı gerekmez.',
|
||||
'settings.voice.page.field.followTextLanguage': 'Sesi metnin diline göre seç',
|
||||
'settings.voice.page.field.followTextLanguageAria': 'Sesi metnin diline göre seç',
|
||||
'settings.voice.page.field.followTextLanguageInfo': 'Yanıt başka bir dildeyse o dil için bir ses kullanılır: uygun bir macOS sesi veya ilk kullanımda indirilen yerel bir model.',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (İngilizce)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 Avrupa dili)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base (çok dilli)',
|
||||
|
||||
@@ -1839,7 +1839,10 @@ export const settingsDict = {
|
||||
"settings.voice.page.provider.server": "Сервер",
|
||||
"settings.voice.page.provider.local": "Локальний",
|
||||
"settings.voice.page.tooltip.sttLocal": "Локальна розшифровка на сервері OpenChamber. Моделі завантажуються автоматично; ключ API не потрібен.",
|
||||
"settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro, англійська). Модель завантажується автоматично; ключ API не потрібен.",
|
||||
"settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro для англійської; моделі для інших мов завантажуються при першому використанні). Ключ API не потрібен.",
|
||||
"settings.voice.page.field.followTextLanguage": "Підбирати голос під мову тексту",
|
||||
"settings.voice.page.field.followTextLanguageAria": "Підбирати голос під мову тексту",
|
||||
"settings.voice.page.field.followTextLanguageInfo": "Якщо відповідь іншою мовою, використовується голос цієї мови: відповідний голос macOS або локальна модель, яка завантажується при першому використанні.",
|
||||
"settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (англійська)",
|
||||
"settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 європейських мов)",
|
||||
"settings.voice.page.stt.model.whisperBase": "Whisper base (мультимовна)",
|
||||
|
||||
@@ -1839,7 +1839,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': '服务器',
|
||||
'settings.voice.page.provider.local': '本地',
|
||||
'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 服务器上本地转写。模型自动下载,无需 API 密钥。',
|
||||
'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(Kokoro,英语)。模型自动下载,无需 API 密钥。',
|
||||
'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(英语使用 Kokoro;其他语言的模型在首次使用时下载)。无需 API 密钥。',
|
||||
'settings.voice.page.field.followTextLanguage': '根据文本语言匹配语音',
|
||||
'settings.voice.page.field.followTextLanguageAria': '根据文本语言匹配语音',
|
||||
'settings.voice.page.field.followTextLanguageInfo': '当回复使用其他语言时,将使用该语言的语音:匹配的 macOS 语音,或首次使用时下载的本地模型。',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英语)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(25 种欧洲语言)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多语言)',
|
||||
|
||||
@@ -1746,7 +1746,10 @@ export const settingsDict = {
|
||||
'settings.voice.page.provider.server': '伺服器',
|
||||
'settings.voice.page.provider.local': '本機',
|
||||
'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 伺服器上本機轉寫。模型會自動下載,無需 API 金鑰。',
|
||||
'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(Kokoro,英文)。模型會自動下載,無需 API 金鑰。',
|
||||
'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(英文使用 Kokoro;其他語言的模型在首次使用時下載)。不需要 API 金鑰。',
|
||||
'settings.voice.page.field.followTextLanguage': '依文字語言選擇語音',
|
||||
'settings.voice.page.field.followTextLanguageAria': '依文字語言選擇語音',
|
||||
'settings.voice.page.field.followTextLanguageInfo': '當回覆使用其他語言時,會使用該語言的語音:相符的 macOS 語音,或首次使用時下載的本機模型。',
|
||||
'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英文)',
|
||||
'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(25 種歐洲語言)',
|
||||
'settings.voice.page.stt.model.whisperBase': 'Whisper base(多語言)',
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user