Files
openchamber/packages/web/server/lib/tts/language-detect.js
T
Bohdan Triapitsyn 391f938334 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
2026-08-30 02:24:22 +03:00

211 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Language detection for text-to-speech voice selection.
*
* Picks the language a piece of chat text is written in so a TTS provider
* can choose a matching voice or model. Deliberately small and dependency
* free: the writing system decides most cases outright, and Latin-script
* languages are told apart by function words and characteristic letters.
* The answer is a best effort for voice selection, not a linguistic claim —
* an unknown language falls back to English rather than failing.
*/
const SCRIPT_RANGES = [
['hangul', /[가-힯ᄀ-ᇿ㄰-㆏]/g],
['kana', /[぀-ヿ]/g],
['han', /[一-鿿㐀-䶿]/g],
['cyrillic', /[Ѐ-ӿ]/g],
['greek', /[Ͱ-Ͽ]/g],
['arabic', /[؀-ۿ]/g],
['hebrew', /[֐-׿]/g],
['thai', /[฀-๿]/g],
['devanagari', /[ऀ-ॿ]/g],
['latin', /[A-Za-zÀ-ɏ]/g],
];
const SCRIPT_LANGUAGE = {
hangul: 'ko',
greek: 'el',
arabic: 'ar',
hebrew: 'he',
thai: 'th',
devanagari: 'hi',
};
// Letters that only (or overwhelmingly) occur in one language of a script.
const LATIN_MARKERS = {
pl: /[łęąńśźż]/i,
cs: /[řěůťďň]/i,
tr: /[ğışİ]/,
pt: /[ãõ]/i,
es: /[ñ¿¡]/,
de: /[ß]/,
fr: /[œ]/i,
sv: /[å]/i,
};
// Frequent function words per language. Scored by whole-word hits; every
// list has the same length so scores stay comparable.
const STOPWORDS = {
en: ['the', 'and', 'is', 'to', 'of', 'that', 'you', 'with', 'for', 'this', 'are', 'it', 'not', 'have', 'can', 'will', 'your', 'from', 'which', 'when'],
de: ['und', 'der', 'die', 'das', 'ist', 'nicht', 'mit', 'ein', 'eine', 'auch', 'sich', 'auf', 'für', 'wird', 'werden', 'oder', 'aber', 'wenn', 'sind', 'kann'],
fr: ['le', 'la', 'les', 'et', 'est', 'une', 'des', 'pour', 'que', 'qui', 'dans', 'pas', 'vous', 'sur', 'avec', 'sont', 'nous', 'cette', 'mais', 'plus'],
es: ['el', 'la', 'los', 'las', 'que', 'es', 'una', 'por', 'para', 'con', 'del', 'como', 'pero', 'más', 'este', 'esta', 'son', 'tiene', 'puede', 'también'],
it: ['il', 'la', 'che', 'di', 'è', 'una', 'per', 'non', 'con', 'del', 'della', 'come', 'sono', 'anche', 'questo', 'questa', 'gli', 'nel', 'più', 'essere'],
pt: ['o', 'a', 'os', 'as', 'que', 'é', 'uma', 'para', 'com', 'não', 'do', 'da', 'como', 'mas', 'também', 'este', 'esta', 'são', 'você', 'pode'],
pl: ['i', 'nie', 'jest', 'się', 'na', 'to', 'że', 'jak', 'ale', 'dla', 'oraz', 'przez', 'czy', 'tym', 'jego', 'można', 'jeśli', 'tego', 'które', 'także'],
nl: ['de', 'het', 'een', 'en', 'van', 'is', 'niet', 'dat', 'met', 'voor', 'ook', 'zijn', 'maar', 'als', 'wordt', 'deze', 'kan', 'naar', 'bij', 'dan'],
cs: ['a', 'je', 'se', 'na', 'to', 'že', 'jak', 'ale', 'pro', 'nebo', 'jsou', 'může', 'také', 'tento', 'když', 'jeho', 'které', 'být', 'aby', 'ještě'],
tr: ['ve', 'bir', 'bu', 'için', 'ile', 'de', 'da', 'ama', 'gibi', 'daha', 'var', 'olarak', 'çok', 'ne', 'her', 'kadar', 'sonra', 'değil', 'olan', 'ise'],
sv: ['och', 'att', 'det', 'är', 'en', 'som', 'för', 'inte', 'med', 'till', 'den', 'kan', 'har', 'ett', 'men', 'också', 'eller', 'från', 'när', 'vara'],
uk: ['і', 'та', 'що', 'це', 'не', 'як', 'для', 'він', 'вона', 'але', 'або', 'також', 'тільки', 'вже', 'якщо', 'його', 'цей', 'ця', 'бути', 'коли'],
ru: ['и', 'что', 'это', 'не', 'как', 'для', 'он', 'она', 'но', 'или', 'также', 'только', 'уже', 'если', 'его', 'этот', 'эта', 'быть', 'когда', 'чтобы'],
};
const LATIN_LANGUAGES = ['en', 'de', 'fr', 'es', 'it', 'pt', 'pl', 'nl', 'cs', 'tr', 'sv'];
const CYRILLIC_LANGUAGES = ['uk', 'ru'];
const countMatches = (text, pattern) => {
const matches = text.match(pattern);
return matches ? matches.length : 0;
};
const scoreStopwords = (words, languages) => {
const scores = {};
for (const language of languages) {
const list = new Set(STOPWORDS[language]);
let hits = 0;
for (const word of words) {
if (list.has(word)) hits += 1;
}
scores[language] = hits;
}
return scores;
};
const bestOf = (scores, fallback) => {
let best = fallback;
let bestScore = 0;
for (const [language, score] of Object.entries(scores)) {
if (score > bestScore) {
best = language;
bestScore = score;
}
}
return best;
};
const pickByMarkers = (text, markers) => {
for (const [language, pattern] of Object.entries(markers)) {
if (pattern.test(text)) return language;
}
return null;
};
/**
* @param {string} text
* @returns {{ language: string, script: string }} BCP-47 primary language subtag and the dominant script.
*/
export function detectTextLanguage(text) {
const source = typeof text === 'string' ? text : '';
const counts = SCRIPT_RANGES.map(([script, pattern]) => [script, countMatches(source, pattern)]);
const letters = counts.reduce((sum, [, count]) => sum + count, 0);
if (letters === 0) return { language: 'en', script: 'latin' };
// Kana settles Japanese even when Han dominates the character count.
const kana = counts.find(([script]) => script === 'kana')?.[1] ?? 0;
const han = counts.find(([script]) => script === 'han')?.[1] ?? 0;
if (kana > 0 && kana + han >= letters * 0.3) return { language: 'ja', script: 'kana' };
if (han > 0 && han >= letters * 0.3) return { language: 'zh', script: 'han' };
const [script] = counts.reduce((best, entry) => (entry[1] > best[1] ? entry : best));
if (script in SCRIPT_LANGUAGE) return { language: SCRIPT_LANGUAGE[script], script };
const words = source.toLowerCase().split(/[^\p{L}\p{M}']+/u).filter(Boolean);
if (script === 'cyrillic') {
const scores = scoreStopwords(words, CYRILLIC_LANGUAGES);
const ukMarkers = countMatches(source, /[іїєґ]/gi);
const ruMarkers = countMatches(source, /[ыэъё]/gi);
// Letters decide: the two alphabets differ in letters that occur in
// nearly every sentence. Function words only settle a text that shows
// neither set, and a text with no Russian-only letters is far more
// likely Ukrainian than the reverse, so that tie goes to Ukrainian.
if (ukMarkers !== ruMarkers) return { language: ukMarkers > ruMarkers ? 'uk' : 'ru', script };
if (scores.uk !== scores.ru) return { language: scores.uk > scores.ru ? 'uk' : 'ru', script };
return { language: ruMarkers > 0 ? 'ru' : 'uk', script };
}
const scores = scoreStopwords(words, LATIN_LANGUAGES);
const marked = pickByMarkers(source, LATIN_MARKERS);
// A characteristic letter outranks stopword counts unless another language
// clearly dominates the function words (a German text quoting "façade").
if (marked && scores[marked] * 2 >= scores[bestOf(scores, marked)]) {
return { language: marked, script };
}
return { language: bestOf(scores, 'en'), script };
}
/**
* Map a detected language onto the locales a voice list uses (`uk_UA`,
* `en_US`...). Returns the preferred locale prefixes in order.
* @param {string} language
* @returns {string[]}
*/
function localePrefixesForLanguage(language) {
const table = {
en: ['en_US', 'en_GB', 'en'],
uk: ['uk_UA', 'uk'],
ru: ['ru_RU', 'ru'],
de: ['de_DE', 'de'],
fr: ['fr_FR', 'fr_CA', 'fr'],
es: ['es_ES', 'es_MX', 'es'],
it: ['it_IT', 'it'],
pt: ['pt_BR', 'pt_PT', 'pt'],
pl: ['pl_PL', 'pl'],
nl: ['nl_NL', 'nl_BE', 'nl'],
cs: ['cs_CZ', 'cs'],
tr: ['tr_TR', 'tr'],
sv: ['sv_SE', 'sv'],
zh: ['zh_CN', 'zh_TW', 'zh_HK', 'zh'],
ja: ['ja_JP', 'ja'],
ko: ['ko_KR', 'ko'],
el: ['el_GR', 'el'],
ar: ['ar_001', 'ar_SA', 'ar'],
he: ['he_IL', 'he'],
th: ['th_TH', 'th'],
hi: ['hi_IN', 'hi'],
};
return table[language] ?? [language];
}
/**
* Choose a voice for a language from a `say`-style voice list.
* Prefers an enhanced/premium variant of a matching voice, then any voice of
* the exact locale, then any voice of the language. Returns null when the
* list has no voice for that language.
* @param {string} language
* @param {ReadonlyArray<{ name: string, locale: string }>} voices
* @returns {string | null}
*/
export function pickVoiceForLanguage(language, voices) {
const prefixes = localePrefixesForLanguage(language);
for (const prefix of prefixes) {
const matching = voices.filter((voice) => voice.locale === prefix || voice.locale.startsWith(`${prefix}_`) || (prefix === language && voice.locale.startsWith(`${language}_`)));
if (matching.length === 0) continue;
const enhanced = matching.find((voice) => /\((Enhanced|Premium)\)/i.test(voice.name));
return (enhanced ?? matching[0]).name;
}
return null;
}
/**
* Language of a voice, from its locale (`uk_UA` → `uk`).
* @param {string | null | undefined} locale
* @returns {string | null}
*/
export function languageOfLocale(locale) {
if (typeof locale !== 'string' || !locale) return null;
return locale.split(/[_-]/)[0].toLowerCase();
}