+
{p.codeLabel}
-
+
{p.englishName}
-
+
{p.nativeName}
diff --git a/src/lib/languagetool-language-order.test.ts b/src/lib/languagetool-language-order.test.ts
index a4fb9389..49749f60 100644
--- a/src/lib/languagetool-language-order.test.ts
+++ b/src/lib/languagetool-language-order.test.ts
@@ -35,16 +35,22 @@ describe('pickLanguageToolCodeForTranslateTarget', () => {
})
describe('buildLabLanguageToolPreferenceList', () => {
- it('puts client language first then en-US then LT codes from translate options', () => {
+ it('only lists LT codes for installed translate targets (no extra UI language)', () => {
const list = buildLabLanguageToolPreferenceList('de', [
{ code: 'fr', name: 'French' },
{ code: 'es', name: 'Spanish' }
])
+ expect(list).toEqual(['fr-FR', 'es'])
+ })
+
+ it('prepends UI language and en-US when those targets are installed', () => {
+ const list = buildLabLanguageToolPreferenceList('de', [
+ { code: 'en', name: 'English' },
+ { code: 'de', name: 'German' },
+ { code: 'fr', name: 'French' }
+ ])
expect(list[0]).toBe('de-DE')
expect(list[1]).toBe('en-US')
- expect(list.includes('de-DE')).toBe(true)
- expect(list.indexOf('en-US')).toBe(1)
- expect(list.includes('fr-FR')).toBe(true)
- expect(list.includes('es')).toBe(true)
+ expect(list).toEqual(['de-DE', 'en-US', 'fr-FR'])
})
})
diff --git a/src/lib/read-aloud.ts b/src/lib/read-aloud.ts
index 63f41d64..f940c7de 100644
--- a/src/lib/read-aloud.ts
+++ b/src/lib/read-aloud.ts
@@ -1,7 +1,11 @@
import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants'
import i18n, { LocalizedLanguageNames, normalizeToSupportedAppLanguage, type TLanguage } from '@/i18n'
import { getNoteTranslation } from '@/lib/note-translation-display'
-import { getPiperVoiceForChosenLanguage, isTrinityLanguageCode } from '@/lib/trinity-languages'
+import {
+ getPiperVoiceForChosenLanguage,
+ isTrinityLanguageCode,
+ TRINITY_LANGUAGE_DISPLAY_NAMES
+} from '@/lib/trinity-languages'
import { takeReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override'
import {
buildPiperTtsCacheKey,
@@ -71,8 +75,12 @@ export type ReadAloudSnapshot = {
backend: string
/** Piper has no model for the chosen language; the English Piper voice is used instead. */
piperUsedEnglishVoiceFallback: boolean
- /** Display name of the requested language when {@link piperUsedEnglishVoiceFallback} is true. */
+ /** Piper uses another trinity voice (e.g. Chinese) to approximate the chosen language. */
+ piperUsedRelatedVoiceFallback: boolean
+ /** Display name of the requested language when a Piper fallback (English or related) applies. */
piperVoiceRequestedLanguageName: string
+ /** Autonym of the Piper profile actually used when English or related fallback applies. */
+ piperVoiceProfileName: string
}
const initialSnapshot: ReadAloudSnapshot = {
@@ -97,7 +105,9 @@ const initialSnapshot: ReadAloudSnapshot = {
volume: 1,
backend: '',
piperUsedEnglishVoiceFallback: false,
- piperVoiceRequestedLanguageName: ''
+ piperUsedRelatedVoiceFallback: false,
+ piperVoiceRequestedLanguageName: '',
+ piperVoiceProfileName: ''
}
let snapshot: ReadAloudSnapshot = { ...initialSnapshot }
@@ -648,7 +658,12 @@ async function speakViaWebSpeech(
error: null,
...(!options?.fromPiperFallback ? { usedPiperFallback: false, piperFallbackDetail: null } : {}),
...(options?.browserOnlyNoPiper
- ? { piperUsedEnglishVoiceFallback: false, piperVoiceRequestedLanguageName: '' }
+ ? {
+ piperUsedEnglishVoiceFallback: false,
+ piperUsedRelatedVoiceFallback: false,
+ piperVoiceRequestedLanguageName: '',
+ piperVoiceProfileName: ''
+ }
: {}),
...webspeechPiperFields
})
@@ -707,14 +722,24 @@ export async function speakNoteReadAloud(event: Event): Promise
const chosenReadAloudLang: string =
persistedTranslation?.lang ?? normalizeToSupportedAppLanguage(i18n.language || 'en')
- const { voice: piperVoice, usedEnglishVoiceFallback } =
- getPiperVoiceForChosenLanguage(chosenReadAloudLang)
- const piperVoiceRequestedLanguageName = usedEnglishVoiceFallback
- ? (persistedTranslation?.langLabel ??
+ const {
+ voice: piperVoice,
+ usedEnglishVoiceFallback,
+ usedRelatedVoiceFallback,
+ piperProfileCode
+ } = getPiperVoiceForChosenLanguage(chosenReadAloudLang)
+ const piperNotice =
+ usedEnglishVoiceFallback || usedRelatedVoiceFallback
+ ? (persistedTranslation?.langLabel ??
(isTrinityLanguageCode(chosenReadAloudLang)
? LocalizedLanguageNames[chosenReadAloudLang]
: chosenReadAloudLang))
- : ''
+ : ''
+ const piperVoiceRequestedLanguageName = piperNotice
+ const piperVoiceProfileName =
+ usedEnglishVoiceFallback || usedRelatedVoiceFallback
+ ? TRINITY_LANGUAGE_DISPLAY_NAMES[piperProfileCode]
+ : ''
if (READ_ALOUD_TTS_URL) {
stopReadAloudPlayback()
@@ -743,7 +768,9 @@ export async function speakNoteReadAloud(event: Event): Promise
readAloudPiperTryStartedAt: Date.now(),
backend: readAloudEndpointForLog(),
piperUsedEnglishVoiceFallback: usedEnglishVoiceFallback,
- piperVoiceRequestedLanguageName
+ piperUsedRelatedVoiceFallback: usedRelatedVoiceFallback,
+ piperVoiceRequestedLanguageName,
+ piperVoiceProfileName
})
await yieldForReadAloudUi()
diff --git a/src/lib/trinity-languages.test.ts b/src/lib/trinity-languages.test.ts
index abdf4ac9..2a02fa04 100644
--- a/src/lib/trinity-languages.test.ts
+++ b/src/lib/trinity-languages.test.ts
@@ -1,21 +1,49 @@
import { describe, expect, it } from 'vitest'
-import { filterTranslateLanguagesWithLanguageToolPairing } from '@/lib/trinity-languages'
+import { getPiperVoiceForChosenLanguage, TRINITY_PIPER_VOICE } from '@/lib/trinity-languages'
-describe('filterTranslateLanguagesWithLanguageToolPairing', () => {
- it('dedupes by LanguageTool grammar code and prefers shorter API codes', () => {
- const list = filterTranslateLanguagesWithLanguageToolPairing([
- { code: 'zh-CN', name: 'Chinese (Simplified)' },
- { code: 'zh', name: 'Chinese' },
- { code: 'de', name: 'German' }
- ])
- expect(list.map((l) => l.code).sort()).toEqual(['de', 'zh'])
+describe('getPiperVoiceForChosenLanguage', () => {
+ it('uses native Piper for trinity codes', () => {
+ const r = getPiperVoiceForChosenLanguage('de')
+ expect(r.voice).toBe(TRINITY_PIPER_VOICE.de)
+ expect(r.usedEnglishVoiceFallback).toBe(false)
+ expect(r.usedRelatedVoiceFallback).toBe(false)
+ expect(r.piperProfileCode).toBe('de')
})
- it('drops codes with no LT pairing', () => {
- const list = filterTranslateLanguagesWithLanguageToolPairing([
- { code: 'en', name: 'English' },
- { code: 'zz-fake', name: 'Fake' }
- ])
- expect(list.map((l) => l.code)).toEqual(['en'])
+ it('routes Japanese and Korean to Chinese Piper', () => {
+ const ja = getPiperVoiceForChosenLanguage('ja')
+ expect(ja.voice).toBe(TRINITY_PIPER_VOICE.zh)
+ expect(ja.usedRelatedVoiceFallback).toBe(true)
+ expect(ja.piperProfileCode).toBe('zh')
+
+ const ko = getPiperVoiceForChosenLanguage('ko')
+ expect(ko.piperProfileCode).toBe('zh')
+ })
+
+ it('routes Ukrainian to Russian Piper', () => {
+ const r = getPiperVoiceForChosenLanguage('uk')
+ expect(r.voice).toBe(TRINITY_PIPER_VOICE.ru)
+ expect(r.usedRelatedVoiceFallback).toBe(true)
+ expect(r.piperProfileCode).toBe('ru')
+ })
+
+ it('routes Portuguese to Spanish Piper', () => {
+ const r = getPiperVoiceForChosenLanguage('pt')
+ expect(r.voice).toBe(TRINITY_PIPER_VOICE.es)
+ expect(r.usedRelatedVoiceFallback).toBe(true)
+ })
+
+ it('routes Dutch-adjacent tags to German when not trinity native', () => {
+ const r = getPiperVoiceForChosenLanguage('gsw')
+ expect(r.voice).toBe(TRINITY_PIPER_VOICE.de)
+ expect(r.usedRelatedVoiceFallback).toBe(true)
+ })
+
+ it('falls back to English Piper for unmapped languages', () => {
+ const r = getPiperVoiceForChosenLanguage('ar')
+ expect(r.voice).toBe(TRINITY_PIPER_VOICE.en)
+ expect(r.usedEnglishVoiceFallback).toBe(true)
+ expect(r.usedRelatedVoiceFallback).toBe(false)
+ expect(r.piperProfileCode).toBe('en')
})
})
diff --git a/src/lib/trinity-languages.ts b/src/lib/trinity-languages.ts
index 8d227535..65326faa 100644
--- a/src/lib/trinity-languages.ts
+++ b/src/lib/trinity-languages.ts
@@ -1,19 +1,17 @@
/**
- * Piper “native” voices: codes with an entry in `TRINITY_PIPER_VOICE` match `getVoiceForLanguage` in
- * `services/piper-tts-proxy/server.ts`. Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native
- * Piper for those codes, **English Piper** for every other translate target.
+ * Piper voices match `services/piper-tts-proxy/server.ts` `getVoiceForLanguage`.
+ * Read-aloud uses {@link getPiperVoiceForChosenLanguage}: native Piper for trinity UI codes, then
+ * **related** Piper (e.g. Chinese for Japanese/Korean, Russian for Ukrainian, Spanish for Portuguese),
+ * then **English** when no heuristic fits.
*
- * **Translate UIs** (note menu, Advanced lab) list languages from LibreTranslate `/languages` that also
- * have an explicit LanguageTool mapping (`translateCodeHasLanguageToolPairing` in
- * `languagetool-language-order.ts`). That avoids offering targets LT cannot pair with, and avoids
- * showing “Turkish” when your LibreTranslate image has no `tr` Argos model (the API would error).
+ * **Translate UIs** use `filterTranslateLanguagesWithGrammarCatalog` in `language-display-meta.ts`:
+ * Libre `/languages` ∩ LanguageTool pairing (installed translate targets only).
*/
-import type { TranslateLanguageOption } from '@/lib/translate-client'
-import { normalizeTranslateLangCode } from '@/lib/translate-client'
import {
- translateCodeHasLanguageToolPairing,
- translateTargetToLanguageToolCode
-} from '@/lib/languagetool-language-order'
+ normalizeTranslateLangCode,
+ type TranslateLanguageOption
+} from '@/lib/translate-client'
+import { translateTargetToLanguageToolCode } from '@/lib/languagetool-language-order'
export const TRINITY_LANGUAGE_CODES = [
'en',
@@ -85,70 +83,132 @@ 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
+ /** Which trinity Piper profile is actually used (native, related, or `en`). */
+ piperProfileCode: TrinityLanguageCode
+}
+
/**
- * LibreTranslate `/languages` entries that have an explicit LT mapping, deduped by LT grammar code
- * (one row per LanguageTool language; prefers shorter API codes like `zh` over `zh-CN`).
+ * 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.
*/
-export function filterTranslateLanguagesWithLanguageToolPairing(
- list: TranslateLanguageOption[]
-): TranslateLanguageOption[] {
- const withPairing = list.filter((l) => translateCodeHasLanguageToolPairing(l.code))
- const byLt = new Map()
- for (const l of withPairing) {
- const lt = translateTargetToLanguageToolCode(l.code)
- const prev = byLt.get(lt)
- if (!prev || l.code.trim().length < prev.code.trim().length) {
- byLt.set(lt, l)
- }
- }
- return Array.from(byLt.values()).sort((a, b) =>
- a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
- )
+const RELATED_PIPER_FOR_BASE: Record = {
+ ja: 'zh',
+ ko: 'zh',
+ 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',
+ pt: 'es',
+ ca: 'es',
+ gl: 'es',
+ it: '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): {
- voice: string
- usedEnglishVoiceFallback: boolean
-} {
+export function getPiperVoiceForTrinityLanguage(lang: TrinityLanguageCode): PiperVoiceResolution {
return {
voice: TRINITY_PIPER_VOICE[lang],
- usedEnglishVoiceFallback: false
+ usedEnglishVoiceFallback: false,
+ usedRelatedVoiceFallback: false,
+ piperProfileCode: lang
}
}
-/** Native Piper when we ship a voice; otherwise English Piper (read-aloud / lab). */
-export function getPiperVoiceForChosenLanguage(lang: string): {
- voice: string
- usedEnglishVoiceFallback: boolean
-} {
- if (isTrinityLanguageCode(lang)) {
- return getPiperVoiceForTrinityLanguage(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 base = baseLangTag(rawLang)
+ const full = normalizeTranslateLangCode(rawLang).toLowerCase().replace(/_/gu, '-')
+
+ if (isTrinityLanguageCode(base)) {
+ return getPiperVoiceForTrinityLanguage(base)
+ }
+ if (isTrinityLanguageCode(full)) {
+ return getPiperVoiceForTrinityLanguage(full)
+ }
+
+ 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: lang !== 'en'
+ usedEnglishVoiceFallback: base !== 'en' && full !== 'en',
+ usedRelatedVoiceFallback: false,
+ piperProfileCode: 'en'
}
}
/**
- * LanguageTool `language` dropdown for the lab: UI language’s LT code, `en-US`, then one entry per
- * translate target (from the filtered LibreTranslate list).
+ * 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)) ordered.push(c)
+ if (!ordered.includes(c) && allowedLt.has(c)) ordered.push(c)
}
const raw = (i18nLanguage ?? 'en').trim() || 'en'
push(translateTargetToLanguageToolCode(raw))
push('en-US')
- const extras = translateLangs.map((l) => translateTargetToLanguageToolCode(l.code))
- extras.sort((a, b) => a.localeCompare(b))
- for (const c of extras) {
- push(c)
+ for (const l of translateLangs) {
+ push(translateTargetToLanguageToolCode(l.code))
}
return ordered
}
diff --git a/src/pages/secondary/GeneralSettingsPage/index.tsx b/src/pages/secondary/GeneralSettingsPage/index.tsx
index 65d73457..402512c9 100644
--- a/src/pages/secondary/GeneralSettingsPage/index.tsx
+++ b/src/pages/secondary/GeneralSettingsPage/index.tsx
@@ -84,8 +84,12 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
{SUPPORTED_APP_LANGUAGE_CODES.map((key) => (
-
-
+
+
))}