Files
UmbraAudio/umbraaudio/languages.py
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
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.
2026-11-06 16:55:49 -08:00

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