You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

255 lines
7.6 KiB

/**
* Piper voices match `services/piper-tts-proxy/server.ts` `getVoiceForLanguage` (`TRINITY_PIPER_VOICE` +
* `EXTRA_READ_ALOUD_PIPER_VOICE` for a few extra locales).
* Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native Piper for trinity UI codes, then extras
* (Arabic, Italian, …), then **related** Piper for regional neighbors, then English (including Japanese
* and Korean — there is no dedicated Piper ja/ko voice in rhasspy/piper-voices).
*
* **Translate UIs** use `filterTranslateLanguagesWithGrammarCatalog` in `language-display-meta.ts`:
* Libre `/languages` ∩ LanguageTool pairing (installed translate targets only).
*/
import {
normalizeTranslateLangCode,
type TranslateLanguageOption
} from '@/lib/translate-client'
import { translateTargetToLanguageToolCode } from '@/lib/languagetool-language-order'
export const TRINITY_LANGUAGE_CODES = [
'en',
'de',
'fr',
'es',
'ru',
'zh',
'pl',
'nl',
'cs',
'tr'
] as const
export type TrinityLanguageCode = (typeof TRINITY_LANGUAGE_CODES)[number]
const TRINITY_SET = new Set<string>(TRINITY_LANGUAGE_CODES)
/** Piper voice ids — same as `services/piper-tts-proxy/server.ts` `voiceMap`. */
export const TRINITY_PIPER_VOICE: Record<TrinityLanguageCode, string> = {
en: 'en_US-lessac-medium',
de: 'de_DE-thorsten-medium',
fr: 'fr_FR-siwis-medium',
es: 'es_ES-davefx-medium',
ru: 'ru_RU-ruslan-medium',
zh: 'zh_CN-huayan-medium',
pl: 'pl_PL-darkman-medium',
nl: 'nl_NL-mls-medium',
cs: 'cs_CZ-jirka-medium',
tr: 'tr_TR-dfki-medium'
}
/**
* Read-aloud Piper voices beyond app UI locales (rhasspy/piper-voices). Install files into
* `.local-piper-data` — see `scripts/download-piper-extra-voices.sh`.
*/
export const EXTRA_READ_ALOUD_PIPER_VOICE: Record<string, string> = {
ar: 'ar_JO-kareem-medium',
it: 'it_IT-paola-medium',
pt: 'pt_BR-cadu-medium',
'en-gb': 'en_GB-alan-medium'
}
export type PiperReadAloudProfileCode = TrinityLanguageCode | keyof typeof EXTRA_READ_ALOUD_PIPER_VOICE
export const TRINITY_FALLBACK_ENGLISH_VOICE = TRINITY_PIPER_VOICE.en
/** Native autonyms / labels for **app UI** locales (General settings); not the full translate menu. */
export const TRINITY_LANGUAGE_DISPLAY_NAMES: { [K in TrinityLanguageCode]: string } = {
en: 'English',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
ru: 'Русский',
zh: '简体中文',
pl: 'Polski',
nl: 'Nederlands',
cs: 'Čeština',
tr: 'Türkçe'
}
export const PIPER_READ_ALOUD_PROFILE_LABELS: Record<PiperReadAloudProfileCode, string> = {
...TRINITY_LANGUAGE_DISPLAY_NAMES,
ar: 'العربية',
it: 'Italiano',
pt: 'Português',
'en-gb': 'English (United Kingdom)'
}
export function piperReadAloudProfileLabel(code: PiperReadAloudProfileCode): string {
return PIPER_READ_ALOUD_PROFILE_LABELS[code]
}
export function isTrinityLanguageCode(s: string): s is TrinityLanguageCode {
return TRINITY_SET.has(s as TrinityLanguageCode)
}
/** Map browser / i18next tag to a trinity app locale (defaults to `en`). */
export function normalizeBrowserLangToTrinityCode(lng: string): TrinityLanguageCode {
const raw = lng.trim().toLowerCase()
const first = raw.split(/[-_]/u)[0] ?? 'en'
if (isTrinityLanguageCode(first)) return first
if (isTrinityLanguageCode(raw)) return raw as TrinityLanguageCode
return 'en'
}
/** LibreTranslate `target` / `source` code (after normalization). */
export function trinityTranslateTarget(code: string): string {
return normalizeTranslateLangCode(code)
}
/** LanguageTool `language` parameter for grammar. */
export function trinityLanguageToolCode(code: string): string {
return translateTargetToLanguageToolCode(code)
}
export type PiperVoiceResolution = {
voice: string
/** True when using `TRINITY_PIPER_VOICE.en` because no related model was chosen. */
usedEnglishVoiceFallback: boolean
/** True when using another trinity voice (e.g. `zh`) to approximate the requested language. */
usedRelatedVoiceFallback: boolean
/** Piper profile for UI labels (trinity locale or e.g. `ar` for Arabic read-aloud). */
piperProfileCode: PiperReadAloudProfileCode
}
/**
* ISO 639-1 (or base BCP47 segment) → trinity Piper voice to approximate when we have no native model.
* Keep conservative: same-script / regional neighbors only where it helps more than English.
*/
const RELATED_PIPER_FOR_BASE: Record<string, TrinityLanguageCode> = {
uk: 'ru',
be: 'ru',
bg: 'ru',
mk: 'ru',
sr: 'ru',
bs: 'ru',
kk: 'ru',
ky: 'ru',
mn: 'ru',
tg: 'ru',
sk: 'cs',
sl: 'de',
hr: 'ru',
ca: 'es',
gl: 'es',
ro: 'es',
la: 'es',
sq: 'es',
oc: 'es',
eu: 'es',
da: 'de',
sv: 'de',
no: 'de',
nb: 'de',
nn: 'de',
is: 'de',
fo: 'de',
lb: 'de',
fy: 'de',
gsw: 'de',
nds: 'de',
et: 'de',
lv: 'de',
fi: 'de',
hu: 'de',
el: 'es',
br: 'fr',
mt: 'es'
}
export function getPiperVoiceForTrinityLanguage(lang: TrinityLanguageCode): PiperVoiceResolution {
return {
voice: TRINITY_PIPER_VOICE[lang],
usedEnglishVoiceFallback: false,
usedRelatedVoiceFallback: false,
piperProfileCode: lang
}
}
function baseLangTag(raw: string): string {
const n = normalizeTranslateLangCode(raw).toLowerCase().replace(/_/gu, '-')
return n.split(/-/u)[0] ?? n
}
/** Piper voice for read-aloud: native trinity → related trinity → English. */
export function getPiperVoiceForChosenLanguage(rawLang: string): PiperVoiceResolution {
const full = normalizeTranslateLangCode(rawLang).toLowerCase().replace(/_/gu, '-')
if (full === 'en-gb' || full.startsWith('en-gb-')) {
return {
voice: EXTRA_READ_ALOUD_PIPER_VOICE['en-gb']!,
usedEnglishVoiceFallback: false,
usedRelatedVoiceFallback: false,
piperProfileCode: 'en-gb'
}
}
const base = baseLangTag(rawLang)
if (isTrinityLanguageCode(base)) {
return getPiperVoiceForTrinityLanguage(base)
}
if (isTrinityLanguageCode(full)) {
return getPiperVoiceForTrinityLanguage(full)
}
const extraVoice = EXTRA_READ_ALOUD_PIPER_VOICE[base]
if (extraVoice) {
return {
voice: extraVoice,
usedEnglishVoiceFallback: false,
usedRelatedVoiceFallback: false,
piperProfileCode: base as PiperReadAloudProfileCode
}
}
const related = RELATED_PIPER_FOR_BASE[full] ?? RELATED_PIPER_FOR_BASE[base] ?? null
if (related) {
return {
voice: TRINITY_PIPER_VOICE[related],
usedEnglishVoiceFallback: false,
usedRelatedVoiceFallback: true,
piperProfileCode: related
}
}
return {
voice: TRINITY_FALLBACK_ENGLISH_VOICE,
usedEnglishVoiceFallback: base !== 'en' && full !== 'en',
usedRelatedVoiceFallback: false,
piperProfileCode: 'en'
}
}
/**
* LanguageTool `language` dropdown for the lab: same logical set as the translate targets (Libre
* `/languages` ∩ LT), so grammar never offers e.g. Spanish when the translator was not built with
* Spanish. Order: UI language’s LT (if installed), `en-US` (if English is installed), then other
* targets in **translate list order** (matches source/target dropdowns).
*/
export function buildLabLanguageToolPreferenceList(
i18nLanguage: string | undefined,
translateLangs: readonly TranslateLanguageOption[]
): string[] {
const allowedLt = new Set(
translateLangs.map((l) => translateTargetToLanguageToolCode(l.code))
)
const ordered: string[] = []
const push = (c: string) => {
if (!ordered.includes(c) && allowedLt.has(c)) ordered.push(c)
}
const raw = (i18nLanguage ?? 'en').trim() || 'en'
push(translateTargetToLanguageToolCode(raw))
push('en-US')
for (const l of translateLangs) {
push(translateTargetToLanguageToolCode(l.code))
}
return ordered
}