The review UI's language dropdown did nothing: every selection kept rendering the English text. Two independent causes, both fixed. 1. The translations were never being read. The pipeline's `utterance_translations` table holds one row in Deal Hunter, and that row stores the *English* text under a `zh-Hans` code. The real translations live in the on-disk sample files under `samples/translations/`. `umbraaudio/translations.py` now resolves both sources and merges them by (chapter_id, utterance_index): the table wins where it holds a real translation, the sample files fill the gaps. A candidate whose text is identical to the source is a placeholder, not a translation, so it is ignored - otherwise the page would show English under a Chinese heading and claim it was translated. 2. The query that read them was broken. `language_routes.py` selected `u.index` and `u.speaker`; the columns are `utterance_index` and `speaker_name`, so every read raised `sqlite3.OperationalError: near "index": syntax error` and `/language-mockup` returned 500. It also opened `<project>/project.db` - a 0-byte decoy - instead of `<project>/.umbra/project.db`, the file that actually holds the data. Also in this commit: * The chapter review page honours `?lang=`, remembers the choice in a cookie (set by a middleware, so it survives on every page), and shows the selected language's *name* in the "has not been translated yet" message. * The page now shows the translation, not the source, and the edit box is bound to the source text so saving cannot overwrite the English with the Chinese. * The stray language selector that sat after `</html>` in base.html is gone; the chapters-list page's dropdown had zero options because the route never passed the language list. * The language tag is wired into generation: `utterance.language_code` (NULL = project source language) reaches `engine.generate()` and the staleness marker, so a retagged line is re-rendered instead of keeping English phonology behind an unchanged text. The marker records the language; a marker written before the tag existed means English, so the ~4,300 existing clips are not invalidated. * `project.source_language` (spec 31 section 13.1) records the book's own language, with a migration for existing project DBs. * A scene-break marker deliberately records no language: it is silence, identical in every language, and tagging it would claim a phonology the audio does not have. Tests: 883 passed / 8 failed - the same 8 pre-existing failures (2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests cover the resolver and the generation wiring. ruff introduces no new findings.
131 lines
4.5 KiB
Python
131 lines
4.5 KiB
Python
"""The set of languages this system knows about, in one place.
|
|
|
|
Two groups, and the split is a hard capability boundary, not a preference:
|
|
|
|
* **Voiceable** - the ten languages Qwen3-TTS can synthesise. The model's own
|
|
``codec_language_id`` keys are the authority for the name string it expects.
|
|
* **Gap** - languages we can tag and store but cannot speak. Recording the tag
|
|
is useful (it excludes the line from translation and explains why a hole
|
|
exists); emitting audio for it would be wrong, so the render is refused.
|
|
|
|
Spec 31 section 5. ``/v1/tts`` validation, the export filter and the review UI
|
|
all read this module, so they cannot disagree about what is supported.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
# Where a source-language tag came from. Kept as plain strings rather than an
|
|
# enum so the value round-trips through JSON and SQLite without conversion.
|
|
ORIGIN_UNKNOWN = "unknown"
|
|
ORIGIN_UMBRATRANS = "umbratrans"
|
|
ORIGIN_OPERATOR = "operator"
|
|
ORIGINS: tuple[str, ...] = (ORIGIN_UNKNOWN, ORIGIN_UMBRATRANS, ORIGIN_OPERATOR)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Language:
|
|
"""One language. ``tts_name`` is None when it cannot be voiced."""
|
|
|
|
code: str
|
|
name: str
|
|
tts_name: str | None = None
|
|
|
|
|
|
# The ten Qwen3-TTS languages. ``tts_name`` is copied from the model's own
|
|
# codec_language_id keys via scripts/render_lang_test.py, which is the working
|
|
# reference - do not retype these by hand.
|
|
QWEN_LANGUAGES: tuple[Language, ...] = (
|
|
Language("en", "English", "english"),
|
|
Language("zh", "Chinese", "chinese"),
|
|
Language("ja", "Japanese", "japanese"),
|
|
Language("ko", "Korean", "korean"),
|
|
Language("de", "German", "german"),
|
|
Language("es", "Spanish", "spanish"),
|
|
Language("fr", "French", "french"),
|
|
Language("ru", "Russian", "russian"),
|
|
Language("it", "Italian", "italian"),
|
|
Language("pt", "Portuguese", "portuguese"),
|
|
)
|
|
|
|
# Taggable, storable, and deliberately not speakable (spec 31 section 4.3).
|
|
GAP_LANGUAGES: tuple[Language, ...] = (
|
|
Language("nl", "Dutch"),
|
|
Language("no", "Norwegian"),
|
|
Language("pl", "Polish"),
|
|
Language("ar", "Arabic"),
|
|
Language("hi", "Hindi"),
|
|
)
|
|
|
|
ALL_LANGUAGES: tuple[Language, ...] = QWEN_LANGUAGES + GAP_LANGUAGES
|
|
|
|
_BY_CODE: dict[str, Language] = {lang.code: lang for lang in ALL_LANGUAGES}
|
|
|
|
# Accepts a code ('DE'), a name ('German') or the model string ('german').
|
|
_BY_ALIAS: dict[str, Language] = {}
|
|
for _lang in ALL_LANGUAGES:
|
|
_BY_ALIAS[_lang.code] = _lang
|
|
_BY_ALIAS[_lang.name.casefold()] = _lang
|
|
if _lang.tts_name is not None:
|
|
_BY_ALIAS[_lang.tts_name] = _lang
|
|
|
|
|
|
def by_code(value: str | None) -> Language | None:
|
|
"""Look up by code, display name or model string. None when unknown."""
|
|
if not value:
|
|
return None
|
|
return _BY_ALIAS.get(str(value).strip().casefold())
|
|
|
|
|
|
def normalize(value: str | None) -> str | None:
|
|
"""Return the canonical lowercase code for a code or name, else None.
|
|
|
|
``None`` and empty input return ``None``. An unrecognised value also
|
|
returns ``None`` rather than raising: a language we do not know is a
|
|
finding to record, not a crash (spec 31 section 8).
|
|
"""
|
|
lang = by_code(value)
|
|
return lang.code if lang is not None else None
|
|
|
|
|
|
def is_known(value: str | None) -> bool:
|
|
"""True when we recognise the language at all (voiceable or gap)."""
|
|
return by_code(value) is not None
|
|
|
|
|
|
def is_voiceable(value: str | None) -> bool:
|
|
"""True when Qwen3-TTS can synthesise this language."""
|
|
lang = by_code(value)
|
|
return lang is not None and lang.tts_name is not None
|
|
|
|
|
|
def tts_name(value: str | None) -> str | None:
|
|
"""The string to hand the TTS server, or None when not voiceable."""
|
|
lang = by_code(value)
|
|
return lang.tts_name if lang is not None else None
|
|
|
|
|
|
def language_name(value: str | None) -> str:
|
|
"""Display name for a code. Falls back to the raw value, then 'unknown'."""
|
|
lang = by_code(value)
|
|
if lang is not None:
|
|
return lang.name
|
|
return str(value) if value else "unknown"
|
|
|
|
|
|
def refusal_reason(value: str | None) -> str | None:
|
|
"""Why this language cannot be voiced, or None when it can.
|
|
|
|
The wording is used verbatim in the review UI, so a refused render always
|
|
says *why* rather than leaving an unexplained gap (spec 31 section 11).
|
|
"""
|
|
if not value:
|
|
return None
|
|
lang = by_code(value)
|
|
if lang is None:
|
|
return f"unrecognised language {value!r} - no voice available"
|
|
if lang.tts_name is None:
|
|
return f"no Qwen3-TTS voice for {lang.code!r} ({lang.name})"
|
|
return None
|