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.
This commit is contained in:
139
samples/translations/speakers.json
Normal file
139
samples/translations/speakers.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"speakers": [
|
||||
{
|
||||
"name": "Kainda Walters Foster Murphy",
|
||||
"type": "narration",
|
||||
"voice_ref": "kainda_walters_foster_murphy_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "narrator",
|
||||
"type": "narration",
|
||||
"voice_ref": "narrator_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Deal Hunter",
|
||||
"type": "narration",
|
||||
"voice_ref": "deal_hunter_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Nanny",
|
||||
"type": "narration",
|
||||
"voice_ref": "nanny_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Princess Kainda",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "princess_kainda_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Duchess Victoria",
|
||||
"type": "narration",
|
||||
"voice_ref": "duchess_victoria_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Adric",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "adric_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Kabir",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "kabir_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Adonai Maul",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "adonai_maul_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Xu Ming",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "xu_ming_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Willamina Wake",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "willamina_wake_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Distorted Voice",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "distorted_voice_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Worker Robot (Bill)",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "worker_robot_(bill)_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Captain Weston",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "captain_weston_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Security Administrator",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "security_administrator_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Pirate Clan Leader",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "pirate_clan_leader_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Narrator",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "narrator_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Trader Wakam",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "trader_wakam_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Service Robot",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "service_robot_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Admiral Wilbur Wake",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "admiral_wilbur_wake_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Pilot (Freighter)",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "pilot_(freighter)_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Trader Romano",
|
||||
"type": "narration",
|
||||
"voice_ref": "trader_romano_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Frigate Captain",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "frigate_captain_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Crew Member",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "crew_member_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Brutus",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "brutus_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Guard Detail Speaker 1",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "guard_detail_speaker_1_ref.wav"
|
||||
},
|
||||
{
|
||||
"name": "Guard Detail Speaker 2",
|
||||
"type": "dialogue",
|
||||
"voice_ref": "guard_detail_speaker_2_ref.wav"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -139,10 +139,16 @@ def generate_chapter(
|
||||
title = chapter["title"] or f"Chapter {chapter['chapter_index'] + 1}"
|
||||
|
||||
utterances = db.execute(
|
||||
"SELECT id, speaker_name, text, utterance_index, emotion, pace, instruct "
|
||||
"SELECT id, speaker_name, text, utterance_index, emotion, pace, instruct, "
|
||||
"language_code "
|
||||
"FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
||||
(chapter_id,),
|
||||
).fetchall()
|
||||
# The project's source language. An utterance with no language tag is
|
||||
# written in it (spec 31 section 6.1).
|
||||
from umbraaudio.translations import source_language
|
||||
|
||||
project_language = source_language(db)
|
||||
db.close()
|
||||
|
||||
chapter_dir = output_base / f"ch{chapter_id}"
|
||||
@@ -189,9 +195,13 @@ def generate_chapter(
|
||||
|
||||
fx_version = EMOTION_FX_VERSION if (emotion_fx and utt["emotion"]) else None
|
||||
|
||||
# The language the line is written in (spec 31 source axis).
|
||||
line_language = utt["language_code"] or project_language
|
||||
|
||||
if is_current(
|
||||
spoken_text, output_path, speaker=speaker, voice_ref=ref_path,
|
||||
instruct=instruct, fx_version=fx_version,
|
||||
language=line_language,
|
||||
):
|
||||
skipped += 1
|
||||
if utterance_callback:
|
||||
@@ -207,12 +217,14 @@ def generate_chapter(
|
||||
voice_ref=ref_path,
|
||||
ref_text=VOICE_REFERENCE_TEXT,
|
||||
instruct=instruct,
|
||||
language=line_language,
|
||||
)
|
||||
else:
|
||||
engine.generate(
|
||||
text=spoken_text,
|
||||
output_path=output_path,
|
||||
instruct=instruct or "A calm, neutral voice speaking clearly.",
|
||||
language=line_language,
|
||||
)
|
||||
|
||||
if output_path.exists():
|
||||
@@ -230,6 +242,7 @@ def generate_chapter(
|
||||
voice_ref=ref_path,
|
||||
voice_mode="embedding" if ref_path else None,
|
||||
instruct=instruct, fx_version=fx_version,
|
||||
language=line_language,
|
||||
)
|
||||
generated += 1
|
||||
utt_success = True
|
||||
|
||||
238
specs/31-utterance-language-and-voiced-flag.md
Normal file
238
specs/31-utterance-language-and-voiced-flag.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Spec 31 — Utterance source language and the voiced flag
|
||||
|
||||
**Status:** agreed 2026-10-17 (operator decisions recorded in §4), being implemented.
|
||||
Extends spec 30, which remains the *target*-language design.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two axes, kept apart
|
||||
|
||||
The single most important thing in this spec. Conflating these two is the design error it
|
||||
exists to prevent.
|
||||
|
||||
| | **Source axis** (this spec) | **Target axis** (spec 30) |
|
||||
|---|---|---|
|
||||
| Question | What language is this utterance's text *written in*? | What language are we rendering it *into*? |
|
||||
| Example | A Spanish line inside an English book | The whole book in German |
|
||||
| Where it lives | `utterances.language_code` | `utterance_translations.language_code` |
|
||||
| Translated? | **No** — it is story content | Yes, that is the point |
|
||||
| Cardinailty | one per utterance | one per (utterance, target language) |
|
||||
|
||||
A Spanish phrase in a Chinese scene is **not a translation target**. It is source content
|
||||
that must be *spoken in Spanish* and must be *excluded from translation*. "Bob overhears
|
||||
them speaking in German" is the test case: the German is narrative payload. The reader is
|
||||
meant to read it. Translating it destroys the scene.
|
||||
|
||||
Both axes are needed. They do not conflict, and they are not the same column.
|
||||
|
||||
## 2. What exists today — measured, not assumed
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| `utterances` (Deal Hunter) | **4,319 rows, no language column** — one `text`, implicitly English |
|
||||
| `qwen_tts.py` language | **hardcoded `language="english"`** at lines 494 and 522 — never varies per utterance |
|
||||
| `/v1/tts` language | `language: str = Form("english")`, free-form, **not validated** against the supported set |
|
||||
| `utterance_translations` | **does not exist** (spec 30 is a proposal; only a mockup route is built) |
|
||||
| translation export/import | **does not exist** |
|
||||
| language in config | **none** — the source language is the constant `SOURCE_LANGUAGE = "en"` in the mockup |
|
||||
| `characters.status = 'voiced'` | **already means "this speaker has a voice reference"** — a *different* concept, at speaker grain |
|
||||
| mockup "translated but not voiced" | **pipeline progress** (text delivered, audio not yet rendered) — also *not* this flag |
|
||||
| sample translations | 10 languages delivered as samples; the **5 gap languages have none** |
|
||||
|
||||
Three different things in this tree say "voiced" and mean different things. §6 gives this
|
||||
flag an unambiguous name for that reason.
|
||||
|
||||
## 3. The constraint that shapes the design
|
||||
|
||||
`is_current()` (`umbraaudio/generation/staleness.py`) compares the marker's recorded `text`
|
||||
against the utterance's text. Spec 30 §2 established that audio must be namespaced per
|
||||
language or two languages destroy each other's markers.
|
||||
|
||||
This spec adds a second, finer requirement: **language must be in the marker**, because an
|
||||
utterance's language can be corrected *after* its audio was made. A Spanish line voiced as
|
||||
English is byte-comparable (the text is unchanged) but acoustically wrong. Without
|
||||
`language` in the marker, correcting the tag would not invalidate the audio, and the wrong
|
||||
render would survive every subsequent check.
|
||||
|
||||
## 4. Decisions (operator, 2026-10-17)
|
||||
|
||||
1. **The voiced flag is per-language.** It lives with the translation row, not on the
|
||||
utterance. The source language always voices everything, so a single utterance-level
|
||||
flag cannot express "voiced in English, not in German" — which is exactly the
|
||||
sentence-collapse case.
|
||||
2. **The source language tag comes from UmbraTrans during translation.** UmbraTrans is the
|
||||
authority on identification. See §9 for the sequencing consequence this creates.
|
||||
3. **A tag in an unsupported language is allowed and recorded; the render is refused.**
|
||||
Never emit a degraded voice. Fail loud, never wrong.
|
||||
4. **Scope: source side + translation alignment.**
|
||||
|
||||
## 5. Language registry
|
||||
|
||||
One module, one source of truth: `umbraaudio/languages.py`.
|
||||
|
||||
| Class | Languages | Voiceable? |
|
||||
|---|---|---|
|
||||
| Qwen3-TTS (10) | Chinese, English, French, German, Italian, Japanese, Korean, Portuguese, Russian, Spanish | yes |
|
||||
| Gap (5) | Dutch, Norwegian, Polish, Arabic, Hindi | **no** — tag recorded, render refused |
|
||||
|
||||
`sources` — where a tag can originate — is recorded so a reviewer can tell a human
|
||||
correction from a machine identification:
|
||||
|
||||
| origin | meaning |
|
||||
|---|---|
|
||||
| `unknown` | no tag yet (default) |
|
||||
| `umbratrans` | identified by the controller during translation |
|
||||
| `operator` | corrected by hand |
|
||||
|
||||
The registry is the only place the supported set is written down. `/v1/tts` validation
|
||||
(spec 30 §8) and the export filter both read it, so they cannot disagree.
|
||||
|
||||
## 6. Schema
|
||||
|
||||
### 6.1 `utterances` gains the source tag
|
||||
|
||||
```sql
|
||||
ALTER TABLE utterances ADD COLUMN language_code TEXT; -- NULL = project source language
|
||||
ALTER TABLE utterances ADD COLUMN language_origin TEXT; -- 'unknown' | 'umbratrans' | 'operator'
|
||||
```
|
||||
|
||||
NULL means "the project's source language", which keeps all 4,319 existing rows correct
|
||||
with no backfill and no ambiguity. A non-NULL value is a *deliberate* claim that the text is
|
||||
in another language.
|
||||
|
||||
**Not named `voiced`.** The per-language flag lives on the translation row (§6.2), and the
|
||||
word `voiced` is already taken twice in this tree (§2).
|
||||
|
||||
### 6.2 `utterance_translations` gains the flag
|
||||
|
||||
Spec 30 §3.2's table, verbatim, plus one column:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS utterance_translations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL,
|
||||
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
||||
language_code TEXT NOT NULL, -- target language, 'de'
|
||||
language_name TEXT NOT NULL, -- 'German'
|
||||
text TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- 'draft' | 'confirmed'
|
||||
origin TEXT NOT NULL, -- 'umbratrans' | 'manual'
|
||||
source_ref TEXT,
|
||||
delivered_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
voiced INTEGER NOT NULL DEFAULT 1, -- NEW: render audio for this row?
|
||||
review_reason TEXT, -- NEW: why it is not voiced
|
||||
UNIQUE(utterance_id, language_code)
|
||||
);
|
||||
```
|
||||
|
||||
`voiced = 0` means *this* target language does not voice this utterance. The canonical case
|
||||
is collapse: the source used three sentences, the target needs two, so one source utterance
|
||||
has no target counterpart. It is **not deleted** — deleting it would lose the alignment and
|
||||
make the gap unreconstructable. It is marked not-voiced with a reason and surfaced for
|
||||
review.
|
||||
|
||||
The default is `1`, so every ordinary translation is voiced and nothing changes for the
|
||||
common case.
|
||||
|
||||
### 6.3 Nothing else moves
|
||||
|
||||
English audio stays at `.umbra/chapter_audio/`. No existing column is renamed or dropped.
|
||||
Zero regression risk to the pipeline that works today — the same reasoning spec 30 §4 used.
|
||||
|
||||
## 7. Export contract (UmbraAudio → UmbraTrans)
|
||||
|
||||
One row per utterance. This extends spec 30 §7 rather than replacing it.
|
||||
|
||||
```
|
||||
{utterance_id, chapter_id, chapter_index, utterance_index, speaker_name,
|
||||
source_text, context_before, context_after,
|
||||
language_code, -- NEW: non-null only when already known
|
||||
book_language} -- NEW: the project's primary language
|
||||
```
|
||||
|
||||
`language_code` is sent when known so a re-delivery does not ask UmbraTrans to re-identify
|
||||
a line the operator already corrected. It is null on the first pass, which is normal.
|
||||
|
||||
## 8. Import contract (UmbraTrans → UmbraAudio)
|
||||
|
||||
`POST /api/import/translations`. Idempotent via `UNIQUE(utterance_id, language_code)`.
|
||||
Refuses to silently overwrite a `confirmed` row.
|
||||
|
||||
Per utterance, UmbraTrans may return:
|
||||
|
||||
| field | meaning |
|
||||
|---|---|
|
||||
| `source_language` | identified language of the *original* text. Stored on `utterances`. |
|
||||
| `text` + `language_code` | a translation *into* `language_code`. Stored in `utterance_translations`. |
|
||||
| `voiced: false` + `reason` | this utterance has no target counterpart (collapse). |
|
||||
| *(no `text`)* | the utterance is in a non-primary language — **not translated, by design**. |
|
||||
|
||||
The last row is the whole point of the source axis: a Spanish line comes back with
|
||||
`source_language: "es"` and **no translation**. That is a correct, expected response, not an
|
||||
omission to be chased.
|
||||
|
||||
An `source_language` in the gap set (Dutch, Norwegian, Polish, Arabic, Hindi) is stored and
|
||||
recorded (§4.3), and the render is refused. It is reported as an explicit refusal, not as a
|
||||
failure of the import.
|
||||
|
||||
## 9. The sequencing consequence (stated plainly)
|
||||
|
||||
Because the tag arrives *during translation* (§4.2), the source-language master is voiced
|
||||
**before any tag exists**. So today's English master of a bilingual book contains at least
|
||||
one wrong-voice line: a Spanish phrase read with English phonology.
|
||||
|
||||
That line is corrected only after a translation round-trip returns `es`. For the correction
|
||||
to actually happen, `language` must be in the staleness marker (§10) — otherwise the audio
|
||||
is never invalidated and the wrong render survives indefinitely.
|
||||
|
||||
This is a consequence of the decision, not a defect in it. It is recorded here so nobody
|
||||
later reads "the English master is complete" as "the English master is correct".
|
||||
|
||||
## 10. Staleness
|
||||
|
||||
`write_marker()` records `language` (the effective language the clip was synthesised in);
|
||||
`is_current()` compares it. A clip whose utterance language has changed since it was made is
|
||||
stale, even when its text is byte-identical.
|
||||
|
||||
This is the same class of bug the release work already fixed: a *declared* value
|
||||
(the marker says English) disagreeing with a *measured* one (the tag now says Spanish) must
|
||||
force a re-render, not pass silently.
|
||||
|
||||
## 11. Refuse-render is loud
|
||||
|
||||
An utterance whose language cannot be voiced is **not silently skipped**. It appears:
|
||||
|
||||
- in a count on the page (`N utterances cannot be voiced in <language>`),
|
||||
- in a list, with the language and the reason (`no Qwen3-TTS voice for 'nl'`),
|
||||
- and in the pipeline's completion report.
|
||||
|
||||
A deliberate hole that nobody can see is indistinguishable from a bug. The operator chose
|
||||
fail-loud (§4.3); this is the part that makes it actually loud.
|
||||
|
||||
## 12. What this does not do
|
||||
|
||||
- **Does not translate anything.** UmbraTrans does the translating; this stores what comes
|
||||
back and honours the source axis.
|
||||
- **Does not implement spec 30's UI language selector.** That remains spec 30, which can now
|
||||
build on a schema that already knows what "the source language" means per utterance.
|
||||
- **Does not backfill a language onto existing rows.** Every existing utterance is correctly
|
||||
NULL = source language. Backfilling would assert an identification nobody performed.
|
||||
- **Does not choose a voice for a foreign passage.** A character keeps their own voice
|
||||
(spec 29); only the *language* of the phonology changes.
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. **Book-level source language.** Currently a constant (`"en"`). Should it become a column
|
||||
on `projects`, set at parse time? Needed to know what "not the primary language" means
|
||||
per book. Proposed: yes, column on `projects`, defaulting to `en`.
|
||||
2. **Character voice for a foreign line.** A character with an Australian accent reading
|
||||
Spanish — spec 29 says characters keep their accent. Does "accent" mean *English-accented
|
||||
Spanish*, or native Spanish in that character's timbre? Spec 29 leans to the latter for
|
||||
the narrator and the former for characters; unresolved for in-dialogue code-switching.
|
||||
3. **Detecting a missing language tag.** If UmbraTrans never runs, no foreign line is ever
|
||||
tagged and the master is silently all-English. A cheap heuristic (non-Latin script,
|
||||
dictionary miss) could flag candidates for review. Out of scope; noted because §9's
|
||||
correction path never fires without a tag.
|
||||
195
tests/test_generation/test_language_wiring.py
Normal file
195
tests/test_generation/test_language_wiring.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""The per-utterance language tag must reach the TTS engine and the marker.
|
||||
|
||||
Spec 31's source axis: an utterance tagged with the language it is *written in*
|
||||
must be voiced in that language, and the marker must record it so a later
|
||||
retag invalidates the clip instead of leaving English phonology behind an
|
||||
unchanged text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from umbraaudio.generation import chapter_builder as cb
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.data_dir = data_dir
|
||||
self.project_dir = data_dir.parent
|
||||
self.tts = type(
|
||||
"T",
|
||||
(),
|
||||
{"qwen_tts_url": "http://127.0.0.1:1", "qwen_tts_helper_url": ""},
|
||||
)()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path: Path) -> Path:
|
||||
"""A minimal project DB with one English line and one Spanish line."""
|
||||
data_dir = tmp_path / ".umbra"
|
||||
data_dir.mkdir()
|
||||
con = sqlite3.connect(str(data_dir / "project.db"))
|
||||
con.executescript(
|
||||
"""
|
||||
CREATE TABLE project (id INTEGER PRIMARY KEY, name TEXT, source_language TEXT DEFAULT 'en');
|
||||
CREATE TABLE chapters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chapter_index INTEGER,
|
||||
title TEXT,
|
||||
source_text TEXT
|
||||
);
|
||||
CREATE TABLE utterances (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chapter_id INTEGER,
|
||||
utterance_index INTEGER,
|
||||
text TEXT,
|
||||
speaker_name TEXT,
|
||||
emotion TEXT,
|
||||
pace TEXT,
|
||||
instruct TEXT,
|
||||
type TEXT,
|
||||
source_unit_key TEXT,
|
||||
language_code TEXT,
|
||||
language_origin TEXT,
|
||||
context_before TEXT,
|
||||
context_after TEXT
|
||||
);
|
||||
INSERT INTO project (id, name, source_language) VALUES (1, 'Test', 'en');
|
||||
INSERT INTO chapters (id, chapter_index, title, source_text)
|
||||
VALUES (1, 0, 'One', 'The ship landed. Hola, amigo.');
|
||||
INSERT INTO utterances
|
||||
(id, chapter_id, utterance_index, text, speaker_name, type, language_code)
|
||||
VALUES
|
||||
(1, 1, 0, 'The ship landed.', 'Narrator', 'narration', NULL),
|
||||
(2, 1, 1, 'Hola, amigo.', 'Kabir', 'narration', 'es');
|
||||
"""
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _builder(project: Path) -> Any:
|
||||
return cb.ChapterBuilder(_FakeConfig(project / ".umbra"), chapter_id=1)
|
||||
|
||||
|
||||
def test_untranslated_line_uses_the_project_source_language(project: Path) -> None:
|
||||
builder = _builder(project)
|
||||
missing = builder._validate_utterances()
|
||||
# Both lines need generating (no audio on disk yet); the point is that
|
||||
# the validation pass did not crash and found the right chapter.
|
||||
assert len(missing) == 2
|
||||
|
||||
|
||||
def test_retagged_line_goes_stale_even_when_the_text_is_unchanged(project: Path) -> None:
|
||||
"""The marker records the language, so a retag invalidates the clip."""
|
||||
from umbraaudio.generation.staleness import write_marker
|
||||
|
||||
audio_dir = project / ".umbra" / "chapter_audio" / "ch1"
|
||||
audio_dir.mkdir(parents=True)
|
||||
wav = audio_dir / "utt_0001.wav"
|
||||
wav.write_bytes(b"RIFF----WAVEfmt ")
|
||||
|
||||
# The clip was rendered as English.
|
||||
write_marker("Hola, amigo.", wav, speaker="Kabir", language="en")
|
||||
|
||||
builder = _builder(project)
|
||||
missing = builder._validate_utterances()
|
||||
indexes = {u["utterance_index"] for u in missing}
|
||||
assert 1 in indexes, "a Spanish line rendered in English must be regenerated"
|
||||
|
||||
|
||||
def test_a_line_already_rendered_in_its_tagged_language_is_current(project: Path) -> None:
|
||||
from umbraaudio.generation.staleness import write_marker
|
||||
|
||||
audio_dir = project / ".umbra" / "chapter_audio" / "ch1"
|
||||
audio_dir.mkdir(parents=True)
|
||||
|
||||
english = audio_dir / "utt_0000.wav"
|
||||
english.write_bytes(b"RIFF----WAVEfmt ")
|
||||
write_marker("The ship landed.", english, speaker="Narrator", language="en")
|
||||
|
||||
spanish = audio_dir / "utt_0001.wav"
|
||||
spanish.write_bytes(b"RIFF----WAVEfmt ")
|
||||
write_marker("Hola, amigo.", spanish, speaker="Kabir", language="es")
|
||||
|
||||
builder = _builder(project)
|
||||
missing = builder._validate_utterances()
|
||||
assert missing == [], f"unexpected regeneration: {missing}"
|
||||
|
||||
|
||||
def test_legacy_marker_without_a_language_is_still_current_for_english(
|
||||
project: Path,
|
||||
) -> None:
|
||||
"""Existing clips must not all be invalidated by adding the tag.
|
||||
|
||||
A marker written before the language tag existed records no language;
|
||||
that audio was English, so absence means English (spec 31 section 10).
|
||||
"""
|
||||
audio_dir = project / ".umbra" / "chapter_audio" / "ch1"
|
||||
audio_dir.mkdir(parents=True)
|
||||
wav = audio_dir / "utt_0000.wav"
|
||||
wav.write_bytes(b"RIFF----WAVEfmt ")
|
||||
|
||||
# Simulate a legacy marker: no language key at all.
|
||||
marker = wav.with_suffix(".wav.txt")
|
||||
marker.write_text(json.dumps({"speaker": "Narrator", "text": "The ship landed."}))
|
||||
|
||||
builder = _builder(project)
|
||||
missing = builder._validate_utterances()
|
||||
indexes = {u["utterance_index"] for u in missing}
|
||||
assert 0 not in indexes
|
||||
|
||||
|
||||
def test_engine_receives_the_language_the_line_is_written_in(
|
||||
project: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The language must reach the TTS engine, not just the marker."""
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
import umbraaudio.voices.qwen_tts as qwen_tts
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class _FakeEngine:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
def build_voice_prompt(self, *args: Any, **kwargs: Any) -> str:
|
||||
return "prompt-slug"
|
||||
|
||||
def generate(self, **kwargs: Any) -> Path:
|
||||
calls.append(kwargs)
|
||||
out = kwargs["output_path"]
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
sf.write(str(out), np.zeros(2400, dtype=np.float32), 24000)
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(qwen_tts, "QwenTTSEngine", _FakeEngine)
|
||||
|
||||
builder = _builder(project)
|
||||
# The health monitor would race the generation loop against a TTS server
|
||||
# that does not exist; the builder's own flag is what matters here.
|
||||
monkeypatch.setattr(builder, "_start_health_monitor", lambda: None)
|
||||
builder._tts_healthy = True
|
||||
|
||||
missing = builder._validate_utterances()
|
||||
builder._generate_utterances(missing)
|
||||
|
||||
languages = {c["text"]: c["language"] for c in calls}
|
||||
assert languages["The ship landed."] == "en"
|
||||
assert languages["Hola, amigo."] == "es"
|
||||
|
||||
# The marker records the same language, so a later retag invalidates it.
|
||||
marker = json.loads(
|
||||
(project / ".umbra" / "chapter_audio" / "ch1" / "utt_0001.wav.txt").read_text()
|
||||
)
|
||||
assert marker["language"] == "es"
|
||||
|
||||
@@ -226,3 +226,70 @@ def test_sha256_verification_is_opt_in_and_detects_tampering(tmp_path: Path) ->
|
||||
wav.write_bytes(b"RIFF-tampered")
|
||||
assert is_current("Same words.", wav, speaker="Narrator") is True
|
||||
assert is_current("Same words.", wav, speaker="Narrator", verify_sha256=True) is False
|
||||
|
||||
|
||||
def test_language_recorded_in_marker(tmp_path: Path) -> None:
|
||||
import json
|
||||
|
||||
wav = tmp_path / "utt_0040.wav"
|
||||
wav.write_bytes(b"RIFF")
|
||||
write_marker("Hola, amigo.", wav, speaker="Kabir", language="es")
|
||||
payload = json.loads(marker_path(wav).read_text())
|
||||
assert payload["language"] == "es"
|
||||
|
||||
|
||||
def test_omitting_language_keeps_markers_byte_identical(tmp_path: Path) -> None:
|
||||
"""Existing callers pass no language; their markers must not change shape."""
|
||||
import json
|
||||
|
||||
wav = tmp_path / "utt_0041.wav"
|
||||
write_marker("Some text", wav, speaker="Narrator")
|
||||
payload = json.loads(marker_path(wav).read_text())
|
||||
assert "language" not in payload
|
||||
|
||||
|
||||
def test_changed_language_is_stale(tmp_path: Path) -> None:
|
||||
"""A retagged line must re-render even when its text is untouched.
|
||||
|
||||
The audio is wrong (English phonology) while the text is identical, so only
|
||||
the language in the marker can invalidate it (spec 31 section 10).
|
||||
"""
|
||||
wav = tmp_path / "utt_0042.wav"
|
||||
wav.write_bytes(b"RIFF")
|
||||
write_marker("Bonjour, ami.", wav, speaker="Kabir", language="en")
|
||||
assert is_current("Bonjour, ami.", wav, speaker="Kabir", language="en") is True
|
||||
assert is_current("Bonjour, ami.", wav, speaker="Kabir", language="fr") is False
|
||||
|
||||
|
||||
def test_matching_language_is_current(tmp_path: Path) -> None:
|
||||
wav = tmp_path / "utt_0043.wav"
|
||||
wav.write_bytes(b"RIFF")
|
||||
write_marker("Hola, amigo.", wav, speaker="Kabir", language="es")
|
||||
assert is_current("Hola, amigo.", wav, speaker="Kabir", language="es") is True
|
||||
|
||||
|
||||
def test_legacy_english_marker_stays_current(tmp_path: Path) -> None:
|
||||
"""Markers written before the language tag exist stay valid.
|
||||
|
||||
Absence means English, so ~4,300 existing clips are not invalidated by this
|
||||
feature.
|
||||
"""
|
||||
wav = tmp_path / "utt_0044.wav"
|
||||
wav.write_bytes(b"RIFF")
|
||||
write_marker("Hello there.", wav, speaker="Narrator")
|
||||
assert is_current("Hello there.", wav, speaker="Narrator", language="en") is True
|
||||
|
||||
|
||||
def test_legacy_marker_is_stale_when_a_foreign_language_is_expected(tmp_path: Path) -> None:
|
||||
wav = tmp_path / "utt_0045.wav"
|
||||
wav.write_bytes(b"RIFF")
|
||||
write_marker("Hola, amigo.", wav, speaker="Kabir")
|
||||
assert is_current("Hola, amigo.", wav, speaker="Kabir", language="es") is False
|
||||
|
||||
|
||||
def test_language_not_compared_when_caller_omits_it(tmp_path: Path) -> None:
|
||||
"""Callers not yet language-aware keep working exactly as before."""
|
||||
wav = tmp_path / "utt_0046.wav"
|
||||
wav.write_bytes(b"RIFF")
|
||||
write_marker("Same words.", wav, speaker="Narrator", language="es")
|
||||
assert is_current("Same words.", wav, speaker="Narrator") is True
|
||||
|
||||
101
tests/test_languages.py
Normal file
101
tests/test_languages.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Tests for the language registry (spec 31 section 5).
|
||||
|
||||
The registry is the single source of truth for what this system can speak, so
|
||||
these tests pin the two things that would silently mis-voice audio: the
|
||||
voiceable/gap split, and the exact strings the TTS model expects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from umbraaudio import languages as lang
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_voiceable_count_is_ten() -> None:
|
||||
assert len(lang.QWEN_LANGUAGES) == 10
|
||||
assert sum(1 for x in lang.QWEN_LANGUAGES if x.tts_name) == 10
|
||||
|
||||
|
||||
def test_gap_languages_are_not_voiceable() -> None:
|
||||
assert len(lang.GAP_LANGUAGES) == 5
|
||||
for gap in lang.GAP_LANGUAGES:
|
||||
assert gap.tts_name is None
|
||||
assert not lang.is_voiceable(gap.code)
|
||||
assert lang.is_known(gap.code), "gap languages are known, just not speakable"
|
||||
|
||||
|
||||
def test_gap_set_is_exactly_the_five_unsupported() -> None:
|
||||
# spec 30 section 8 named these five; a sixth appearing here silently would
|
||||
# change what the product claims to support.
|
||||
assert {g.code for g in lang.GAP_LANGUAGES} == {"nl", "no", "pl", "ar", "hi"}
|
||||
|
||||
|
||||
def test_no_duplicate_codes_or_names() -> None:
|
||||
codes = [x.code for x in lang.ALL_LANGUAGES]
|
||||
names = [x.name for x in lang.ALL_LANGUAGES]
|
||||
assert len(codes) == len(set(codes))
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
|
||||
def test_codes_are_lowercase_iso() -> None:
|
||||
for x in lang.ALL_LANGUAGES:
|
||||
assert re.fullmatch(r"[a-z]{2}", x.code), x
|
||||
|
||||
|
||||
def test_normalize_accepts_code_name_and_model_string() -> None:
|
||||
for x in lang.ALL_LANGUAGES:
|
||||
assert lang.normalize(x.code) == x.code
|
||||
assert lang.normalize(x.code.upper()) == x.code
|
||||
assert lang.normalize(x.name) == x.code
|
||||
assert lang.normalize(f" {x.name.lower()} ") == x.code
|
||||
|
||||
|
||||
def test_normalize_rejects_unknown_without_raising() -> None:
|
||||
# spec 31 section 8: an unrecognised language is a finding, not a crash.
|
||||
for bad in (None, "", " ", "klingon", "zz"):
|
||||
assert lang.normalize(bad) is None
|
||||
|
||||
|
||||
def test_tts_name_is_the_model_string() -> None:
|
||||
assert lang.tts_name("es") == "spanish"
|
||||
assert lang.tts_name("Spanish") == "spanish"
|
||||
assert lang.tts_name("zh") == "chinese"
|
||||
assert lang.tts_name("nl") is None
|
||||
|
||||
|
||||
def test_language_name_falls_back_gracefully() -> None:
|
||||
assert lang.language_name("fr") == "French"
|
||||
assert lang.language_name("xx") == "xx"
|
||||
assert lang.language_name(None) == "unknown"
|
||||
|
||||
|
||||
def test_refusal_reason_is_explicit() -> None:
|
||||
assert lang.refusal_reason("en") is None
|
||||
assert lang.refusal_reason(None) is None
|
||||
nl = lang.refusal_reason("nl")
|
||||
assert nl is not None and "'nl'" in nl and "Dutch" in nl
|
||||
xx = lang.refusal_reason("xx")
|
||||
assert xx is not None and "unrecognised" in xx
|
||||
|
||||
|
||||
def test_origins_cover_the_three_sources() -> None:
|
||||
assert set(lang.ORIGINS) == {"unknown", "umbratrans", "operator"}
|
||||
|
||||
|
||||
def test_matches_the_working_reference_map() -> None:
|
||||
"""The renderer that actually produces 10 languages is the authority.
|
||||
|
||||
If the registry and render_lang_test.py disagree, one of them is wrong and
|
||||
the audio is mis-voiced. This is the test that catches it.
|
||||
"""
|
||||
src = (REPO / "scripts" / "render_lang_test.py").read_text(encoding="utf-8")
|
||||
block = re.search(r"LANG_CODES = \{(.*?)\}", src, re.S)
|
||||
assert block is not None, "LANG_CODES not found - did the renderer change?"
|
||||
pairs = dict(re.findall(r'"([A-Z]{2})": "(\w+)"', block.group(1)))
|
||||
assert len(pairs) == 10
|
||||
for code, model_name in pairs.items():
|
||||
assert lang.tts_name(code.lower()) == model_name, (code, model_name)
|
||||
266
tests/test_translation_resolution.py
Normal file
266
tests/test_translation_resolution.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Tests for resolving a translation from the DB and the on-disk sample files.
|
||||
|
||||
The behaviours pinned here are the ones that made the review UI lie: a valid
|
||||
Chinese translation existed on disk but the page showed English, and the DB
|
||||
held an English string under a ``zh-Hans`` code that the page would have
|
||||
rendered as "translated" if it had simply trusted the row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from umbraaudio import translations as tr
|
||||
|
||||
|
||||
def _seed(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE project (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE chapters (id INTEGER PRIMARY KEY, chapter_index INTEGER);
|
||||
CREATE TABLE utterances (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chapter_id INTEGER,
|
||||
utterance_index INTEGER,
|
||||
speaker_name TEXT,
|
||||
text TEXT,
|
||||
context_before TEXT,
|
||||
context_after TEXT,
|
||||
language_code TEXT,
|
||||
language_origin TEXT
|
||||
);
|
||||
INSERT INTO project (id, name) VALUES (1, 'Test');
|
||||
INSERT INTO chapters (id, chapter_index) VALUES (1, 1);
|
||||
INSERT INTO utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES
|
||||
(1, 1, 0, 'Narrator', 'Welcome to the book.'),
|
||||
(2, 1, 1, 'Kainda', 'Hello, I am the main character.');
|
||||
"""
|
||||
)
|
||||
tr.ensure_tables(conn)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn() -> sqlite3.Connection:
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
_seed(c)
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def _write_sample(directory: Path, code: str, texts: list[str], chapter_id: int = 1) -> None:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"project": "Test",
|
||||
"chapter_id": chapter_id,
|
||||
"language_code": code,
|
||||
"utterances": [
|
||||
{"index": str(i), "speaker": "Narrator", "type": "narration", "text": text}
|
||||
for i, text in enumerate(texts)
|
||||
],
|
||||
}
|
||||
(directory / f"sample_ch1_{code.upper()}.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
# --- code normalisation ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("zh", "zh"),
|
||||
("zh-Hans", "zh"),
|
||||
("zh_CN", "zh"),
|
||||
("EN", "en"),
|
||||
("en-US", "en"),
|
||||
("Russian", "ru"),
|
||||
("nonsense", None),
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_canonical_code_tolerates_script_subtags(value: str | None, expected: str | None) -> None:
|
||||
assert tr.canonical_code(value) == expected
|
||||
|
||||
|
||||
# --- reading the sample files ---------------------------------------------
|
||||
|
||||
|
||||
def test_sample_translations_dir_is_beside_the_project(tmp_path: Path) -> None:
|
||||
# .umbra is gitignored runtime state; the translations are project content.
|
||||
assert tr.sample_translations_dir(tmp_path) == tmp_path / "samples" / "translations"
|
||||
|
||||
|
||||
def test_load_sample_translations_reads_every_file(tmp_path: Path) -> None:
|
||||
directory = tr.sample_translations_dir(tmp_path)
|
||||
_write_sample(directory, "en", ["Welcome.", "Hello."])
|
||||
_write_sample(directory, "zh", ["欢迎。", "你好。"])
|
||||
|
||||
files = tr.load_sample_translations(tmp_path)
|
||||
|
||||
assert set(files) == {"en", "zh"}
|
||||
assert files["zh"][(1, 0)] == "欢迎。"
|
||||
assert files["zh"][(1, 1)] == "你好。"
|
||||
|
||||
|
||||
def test_load_sample_translations_survives_a_broken_file(tmp_path: Path) -> None:
|
||||
directory = tr.sample_translations_dir(tmp_path)
|
||||
_write_sample(directory, "zh", ["欢迎。", "你好。"])
|
||||
(directory / "sample_ch1_BROKEN.json").write_text("{not json", encoding="utf-8")
|
||||
|
||||
files = tr.load_sample_translations(tmp_path)
|
||||
|
||||
assert files["zh"][(1, 0)] == "欢迎。"
|
||||
|
||||
|
||||
def test_load_sample_translations_missing_directory_is_empty(tmp_path: Path) -> None:
|
||||
assert tr.load_sample_translations(tmp_path) == {}
|
||||
|
||||
|
||||
# --- resolving ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sample_file_supplies_a_translation_the_db_does_not_have(
|
||||
conn: sqlite3.Connection, tmp_path: Path
|
||||
) -> None:
|
||||
directory = tr.sample_translations_dir(tmp_path)
|
||||
_write_sample(directory, "en", ["Welcome.", "Hello."])
|
||||
_write_sample(directory, "zh", ["欢迎。", "你好。"])
|
||||
|
||||
resolved = tr.resolved_translations(conn, tmp_path, "zh")
|
||||
|
||||
assert resolved[(1, 0)].text == "欢迎。"
|
||||
assert resolved[(1, 1)].text == "你好。"
|
||||
assert resolved[(1, 0)].origin == "sample"
|
||||
|
||||
|
||||
def test_english_is_not_reported_as_a_translation(
|
||||
conn: sqlite3.Connection, tmp_path: Path
|
||||
) -> None:
|
||||
# The English file is the reference the other files are compared against,
|
||||
# so English must never come back as a translation of itself.
|
||||
directory = tr.sample_translations_dir(tmp_path)
|
||||
_write_sample(directory, "en", ["Welcome.", "Hello."])
|
||||
|
||||
resolved = tr.resolved_translations(conn, tmp_path, "en")
|
||||
|
||||
assert resolved == {}
|
||||
|
||||
|
||||
def test_db_row_wins_over_the_sample_file(
|
||||
conn: sqlite3.Connection, tmp_path: Path
|
||||
) -> None:
|
||||
directory = tr.sample_translations_dir(tmp_path)
|
||||
_write_sample(directory, "en", ["Welcome.", "Hello."])
|
||||
_write_sample(directory, "zh", ["欢迎。", "你好。"])
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO utterance_translations
|
||||
(project_id, utterance_id, language_code, language_name, text, status, origin)
|
||||
VALUES (1, 1, 'zh', 'Chinese', '从数据库来的欢迎。', 'confirmed', 'umbratrans')
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
resolved = tr.resolved_translations(conn, tmp_path, "zh")
|
||||
|
||||
assert resolved[(1, 0)].text == "从数据库来的欢迎。"
|
||||
assert resolved[(1, 0)].status == "confirmed"
|
||||
assert resolved[(1, 0)].origin == "umbratrans"
|
||||
# The second utterance still comes from the file.
|
||||
assert resolved[(1, 1)].text == "你好。"
|
||||
|
||||
|
||||
def test_db_row_holding_the_english_source_is_a_placeholder_not_a_translation(
|
||||
conn: sqlite3.Connection, tmp_path: Path
|
||||
) -> None:
|
||||
# This is the shape of the one row that was actually in Deal Hunter's DB:
|
||||
# the English text stored under a zh-Hans code. Rendering it would show
|
||||
# English under a Chinese heading and claim it was translated.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO utterance_translations
|
||||
(project_id, utterance_id, language_code, language_name, text, status, origin)
|
||||
VALUES (1, 1, 'zh-Hans', 'Chinese', 'Welcome to the book.', 'draft', 'umbratrans')
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
resolved = tr.resolved_translations(conn, tmp_path, "zh")
|
||||
|
||||
assert resolved == {}
|
||||
|
||||
|
||||
def test_script_subtag_rows_are_matched_to_the_dropdown_code(
|
||||
conn: sqlite3.Connection, tmp_path: Path
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO utterance_translations
|
||||
(project_id, utterance_id, language_code, language_name, text, status, origin)
|
||||
VALUES (1, 1, 'zh-Hans', 'Chinese', '从数据库来的欢迎。', 'draft', 'umbratrans')
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
resolved = tr.resolved_translations(conn, tmp_path, "zh")
|
||||
|
||||
assert resolved[(1, 0)].text == "从数据库来的欢迎。"
|
||||
|
||||
|
||||
def test_a_row_for_another_language_is_not_returned(
|
||||
conn: sqlite3.Connection, tmp_path: Path
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO utterance_translations
|
||||
(project_id, utterance_id, language_code, language_name, text, status, origin)
|
||||
VALUES (1, 1, 'ru', 'Russian', 'Добро пожаловать.', 'draft', 'umbratrans')
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
resolved = tr.resolved_translations(conn, tmp_path, "zh")
|
||||
|
||||
assert resolved == {}
|
||||
|
||||
|
||||
def test_missing_translations_table_is_not_an_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
c.executescript(
|
||||
"""
|
||||
CREATE TABLE utterances (
|
||||
id INTEGER PRIMARY KEY, chapter_id INTEGER, utterance_index INTEGER,
|
||||
speaker_name TEXT, text TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
c.commit()
|
||||
try:
|
||||
assert tr.resolved_translations(c, tmp_path, "zh") == {}
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def test_untranslated_target_file_is_not_counted_as_translated(
|
||||
conn: sqlite3.Connection, tmp_path: Path
|
||||
) -> None:
|
||||
# A target file whose text is identical to the source file has not been
|
||||
# translated; reporting it as translated would hide the gap.
|
||||
directory = tr.sample_translations_dir(tmp_path)
|
||||
_write_sample(directory, "en", ["Welcome.", "Hello."])
|
||||
_write_sample(directory, "ru", ["Welcome.", "Hello."])
|
||||
|
||||
resolved = tr.resolved_translations(conn, tmp_path, "ru")
|
||||
|
||||
assert resolved == {}
|
||||
366
tests/test_translations.py
Normal file
366
tests/test_translations.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Tests for source-language tagging and per-language translations (spec 31).
|
||||
|
||||
The behaviours pinned here are the ones that would silently corrupt audio or
|
||||
lose alignment if they regressed: the source/target axis split, the per-language
|
||||
voiced flag, idempotent re-delivery, and refusal to overwrite approved text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from umbraaudio import translations as tr
|
||||
|
||||
|
||||
def _seed(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE project (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE chapters (id INTEGER PRIMARY KEY, chapter_index INTEGER);
|
||||
CREATE TABLE utterances (
|
||||
id INTEGER PRIMARY KEY,
|
||||
chapter_id INTEGER,
|
||||
utterance_index INTEGER,
|
||||
speaker_name TEXT,
|
||||
text TEXT,
|
||||
context_before TEXT,
|
||||
context_after TEXT,
|
||||
language_code TEXT,
|
||||
language_origin TEXT
|
||||
);
|
||||
INSERT INTO project (id, name) VALUES (1, 'Test');
|
||||
INSERT INTO chapters (id, chapter_index) VALUES (1, 1), (2, 2);
|
||||
INSERT INTO utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES
|
||||
(1, 1, 0, 'narrator', 'The ship landed.'),
|
||||
(2, 1, 1, 'Kabir', 'Hola, amigo.'),
|
||||
(3, 1, 2, 'Kabir', 'We should go.'),
|
||||
(4, 2, 0, 'narrator', 'Later that day.'),
|
||||
(5, 2, 1, 'Nanny', 'Danger.');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn() -> sqlite3.Connection:
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
_seed(c)
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_conn() -> sqlite3.Connection:
|
||||
"""No row_factory - proves the accessors do not assume sqlite3.Row."""
|
||||
c = sqlite3.connect(":memory:")
|
||||
_seed(c)
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
# --- source axis ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_source_language_defaults_to_english(conn: sqlite3.Connection) -> None:
|
||||
assert tr.source_language(conn) == "en"
|
||||
|
||||
|
||||
def test_source_language_reads_project_column() -> None:
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.execute("CREATE TABLE project (id INTEGER PRIMARY KEY, source_language TEXT)")
|
||||
c.execute("INSERT INTO project VALUES (1, 'ZH')")
|
||||
assert tr.source_language(c) == "zh"
|
||||
c.close()
|
||||
|
||||
|
||||
def test_set_source_language_normalises_and_reports_change(conn: sqlite3.Connection) -> None:
|
||||
assert tr.set_source_language(conn, 2, "Spanish") is True
|
||||
row = conn.execute(
|
||||
"SELECT language_code, language_origin FROM utterances WHERE id=2"
|
||||
).fetchone()
|
||||
assert row["language_code"] == "es"
|
||||
assert row["language_origin"] == "umbratrans"
|
||||
# Setting the same value again is not a change.
|
||||
assert tr.set_source_language(conn, 2, "es") is False
|
||||
|
||||
|
||||
def test_set_source_language_unknown_id_is_not_an_error(conn: sqlite3.Connection) -> None:
|
||||
assert tr.set_source_language(conn, 9999, "es") is False
|
||||
|
||||
|
||||
def test_set_source_language_rejects_bad_origin(conn: sqlite3.Connection) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
tr.set_source_language(conn, 1, "es", "nonsense")
|
||||
|
||||
|
||||
def test_operator_tag_outranks_machine_reidentification(conn: sqlite3.Connection) -> None:
|
||||
tr.set_source_language(conn, 2, "es", tr.lang.ORIGIN_OPERATOR)
|
||||
assert tr.set_source_language(conn, 2, "fr", tr.lang.ORIGIN_UMBRATRANS) is False
|
||||
row = conn.execute("SELECT language_code FROM utterances WHERE id=2").fetchone()
|
||||
assert row["language_code"] == "es"
|
||||
|
||||
|
||||
def test_force_overrides_operator_tag(conn: sqlite3.Connection) -> None:
|
||||
tr.set_source_language(conn, 2, "es", tr.lang.ORIGIN_OPERATOR)
|
||||
assert tr.set_source_language(conn, 2, "fr", tr.lang.ORIGIN_UMBRATRANS, force=True) is True
|
||||
row = conn.execute("SELECT language_code FROM utterances WHERE id=2").fetchone()
|
||||
assert row["language_code"] == "fr"
|
||||
|
||||
|
||||
def test_foreign_lines_are_excluded_from_translation(conn: sqlite3.Connection) -> None:
|
||||
tr.set_source_language(conn, 2, "es")
|
||||
assert tr.foreign_utterance_ids(conn) == {2}
|
||||
to_translate = tr.utterances_to_translate(conn)
|
||||
assert 2 not in to_translate
|
||||
assert to_translate == [1, 3, 4, 5]
|
||||
|
||||
|
||||
def test_untagged_rows_count_as_source_language(conn: sqlite3.Connection) -> None:
|
||||
# NULL tag = the project's primary language (spec 31 section 6.1).
|
||||
assert tr.foreign_utterance_ids(conn) == set()
|
||||
assert tr.utterances_to_translate(conn) == [1, 2, 3, 4, 5]
|
||||
|
||||
|
||||
def test_effective_language_resolves_null_to_primary(conn: sqlite3.Connection) -> None:
|
||||
tr.set_source_language(conn, 2, "es")
|
||||
assert tr.effective_language(conn, 1) == "en"
|
||||
assert tr.effective_language(conn, 2) == "es"
|
||||
|
||||
|
||||
def test_unknown_tag_never_falls_back_to_the_primary_language(conn: sqlite3.Connection) -> None:
|
||||
"""A Klingon line must not be spoken as English.
|
||||
|
||||
This is the fail-loud guarantee (spec 31 section 4.3). If an unrecognised tag
|
||||
collapsed to NULL, the line would look untagged, resolve to 'en', and be
|
||||
voiced with English phonology - wrong, and silent about being wrong.
|
||||
"""
|
||||
tr.set_source_language(conn, 1, "klingon")
|
||||
assert tr.effective_language(conn, 1) == "klingon"
|
||||
assert 1 in tr.foreign_utterance_ids(conn), "an unreadable language is not the primary one"
|
||||
assert not tr.lang.is_voiceable(tr.effective_language(conn, 1))
|
||||
|
||||
|
||||
# --- target axis ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_import_stores_a_translation(conn: sqlite3.Connection) -> None:
|
||||
report = tr.import_translations(
|
||||
conn,
|
||||
1,
|
||||
{
|
||||
"target_language": "de",
|
||||
"utterances": [
|
||||
{"utterance_id": 1, "source_language": "en", "text": "Das Schiff landete."}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert report.accepted == 1
|
||||
assert report.source_tagged == 1
|
||||
row = conn.execute(
|
||||
"SELECT language_name, text, status, origin, voiced"
|
||||
" FROM utterance_translations WHERE utterance_id=1"
|
||||
).fetchone()
|
||||
assert row["language_name"] == "German"
|
||||
assert row["text"] == "Das Schiff landete."
|
||||
assert row["status"] == "draft"
|
||||
assert row["origin"] == "umbratrans"
|
||||
assert row["voiced"] == 1, "a normal translation is voiced by default"
|
||||
|
||||
|
||||
def test_import_is_idempotent_on_redelivery(conn: sqlite3.Connection) -> None:
|
||||
payload = {"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Erste"}]}
|
||||
tr.import_translations(conn, 1, payload)
|
||||
payload["utterances"][0]["text"] = "Zweite"
|
||||
tr.import_translations(conn, 1, payload)
|
||||
rows = conn.execute("SELECT text FROM utterance_translations WHERE utterance_id=1").fetchall()
|
||||
assert len(rows) == 1, "re-delivery updates in place, it does not accumulate"
|
||||
assert rows[0]["text"] == "Zweite"
|
||||
|
||||
|
||||
def test_confirmed_translation_is_not_overwritten(conn: sqlite3.Connection) -> None:
|
||||
tr.import_translations(
|
||||
conn,
|
||||
1,
|
||||
{"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Approved"}]},
|
||||
confirm=True,
|
||||
)
|
||||
report = tr.import_translations(
|
||||
conn, 1, {"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Sneaky"}]}
|
||||
)
|
||||
assert report.skipped_confirmed == 1
|
||||
assert report.accepted == 0
|
||||
row = conn.execute(
|
||||
"SELECT text, status FROM utterance_translations WHERE utterance_id=1"
|
||||
).fetchone()
|
||||
assert row["text"] == "Approved"
|
||||
assert row["status"] == "confirmed"
|
||||
|
||||
|
||||
def test_foreign_line_returns_identification_only(conn: sqlite3.Connection) -> None:
|
||||
"""The whole point of the source axis: a Spanish line is not translated."""
|
||||
report = tr.import_translations(
|
||||
conn,
|
||||
1,
|
||||
{"target_language": "de", "utterances": [{"utterance_id": 2, "source_language": "es"}]},
|
||||
)
|
||||
assert report.source_tagged == 1
|
||||
assert report.foreign_untouched == 1
|
||||
assert report.accepted == 0
|
||||
assert (
|
||||
conn.execute("SELECT COUNT(*) FROM utterance_translations WHERE utterance_id=2").fetchone()[
|
||||
0
|
||||
]
|
||||
== 0
|
||||
)
|
||||
assert tr.effective_language(conn, 2) == "es"
|
||||
|
||||
|
||||
def test_collapse_orphan_is_kept_with_a_reason(conn: sqlite3.Connection) -> None:
|
||||
"""Three source sentences collapsing into two leaves one with no target."""
|
||||
report = tr.import_translations(
|
||||
conn,
|
||||
1,
|
||||
{
|
||||
"target_language": "de",
|
||||
"utterances": [
|
||||
{"utterance_id": 1, "text": "Das Schiff landete. Wir gingen."},
|
||||
{"utterance_id": 3, "voiced": False, "reason": "absorbed into utterance 1"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert report.accepted == 2
|
||||
assert report.not_voiced == 1
|
||||
row = conn.execute(
|
||||
"SELECT voiced, review_reason FROM utterance_translations WHERE utterance_id=3"
|
||||
).fetchone()
|
||||
assert row["voiced"] == 0
|
||||
assert row["review_reason"] == "absorbed into utterance 1"
|
||||
orphans = tr.not_voiced_rows(conn, "de")
|
||||
assert [o["utterance_id"] for o in orphans] == [3]
|
||||
|
||||
|
||||
def test_gap_language_tag_is_stored_and_render_refused(conn: sqlite3.Connection) -> None:
|
||||
report = tr.import_translations(
|
||||
conn,
|
||||
1,
|
||||
{"target_language": "de", "utterances": [{"utterance_id": 1, "source_language": "nl"}]},
|
||||
)
|
||||
assert report.refused_voice == 1
|
||||
assert tr.effective_language(conn, 1) == "nl"
|
||||
refused = tr.refused_render_rows(conn)
|
||||
assert len(refused) == 1
|
||||
assert refused[0]["language_code"] == "nl"
|
||||
assert "Dutch" in refused[0]["reason"]
|
||||
|
||||
|
||||
def test_unknown_language_is_reported_not_crashed(conn: sqlite3.Connection) -> None:
|
||||
report = tr.import_translations(
|
||||
conn,
|
||||
1,
|
||||
{
|
||||
"target_language": "de",
|
||||
"utterances": [{"utterance_id": 1, "source_language": "klingon"}],
|
||||
},
|
||||
)
|
||||
assert "klingon" in report.unknown_language
|
||||
assert tr.refused_render_rows(conn)[0]["reason"].startswith("unrecognised")
|
||||
|
||||
|
||||
def test_identification_only_payload_needs_no_target_language(conn: sqlite3.Connection) -> None:
|
||||
report = tr.import_translations(
|
||||
conn, 1, {"utterances": [{"utterance_id": 2, "source_language": "es"}]}
|
||||
)
|
||||
assert report.source_tagged == 1
|
||||
assert report.problems == []
|
||||
|
||||
|
||||
def test_text_without_target_language_is_a_problem(conn: sqlite3.Connection) -> None:
|
||||
report = tr.import_translations(conn, 1, {"utterances": [{"utterance_id": 1, "text": "Hallo"}]})
|
||||
assert report.problems and "target_language" in report.problems[0]
|
||||
|
||||
|
||||
def test_row_without_utterance_id_is_reported(conn: sqlite3.Connection) -> None:
|
||||
report = tr.import_translations(
|
||||
conn, 1, {"target_language": "de", "utterances": [{"text": "x"}]}
|
||||
)
|
||||
assert report.problems and "utterance_id" in report.problems[0]
|
||||
|
||||
|
||||
def test_utterances_must_be_a_list(conn: sqlite3.Connection) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
tr.import_translations(conn, 1, {"target_language": "de", "utterances": {"a": 1}})
|
||||
|
||||
|
||||
def test_plain_tuple_connection_is_supported(plain_conn: sqlite3.Connection) -> None:
|
||||
"""No row_factory set - the accessors must still work."""
|
||||
tr.set_source_language(plain_conn, 2, "es")
|
||||
assert tr.effective_language(plain_conn, 2) == "es"
|
||||
report = tr.import_translations(
|
||||
plain_conn,
|
||||
1,
|
||||
{"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Hallo"}]},
|
||||
)
|
||||
assert report.accepted == 1
|
||||
|
||||
|
||||
# --- coverage and export -------------------------------------------------
|
||||
|
||||
|
||||
def test_coverage_counts_the_gap_honestly(conn: sqlite3.Connection) -> None:
|
||||
tr.import_translations(
|
||||
conn,
|
||||
1,
|
||||
{
|
||||
"target_language": "de",
|
||||
"utterances": [
|
||||
{"utterance_id": 1, "text": "Eins"},
|
||||
{"utterance_id": 3, "voiced": False, "reason": "merged"},
|
||||
],
|
||||
},
|
||||
)
|
||||
cov = tr.coverage(conn, "de")
|
||||
assert cov["total"] == 5
|
||||
assert cov["delivered"] == 2
|
||||
assert cov["voiced"] == 1
|
||||
assert cov["not_voiced"] == 1
|
||||
assert cov["missing"] == 3
|
||||
|
||||
|
||||
def test_coverage_of_an_untouched_language_is_all_missing(conn: sqlite3.Connection) -> None:
|
||||
cov = tr.coverage(conn, "fr")
|
||||
assert cov["delivered"] == 0 and cov["missing"] == 5 and cov["not_voiced"] == 0
|
||||
|
||||
|
||||
def test_export_payload_shape(conn: sqlite3.Connection) -> None:
|
||||
tr.set_source_language(conn, 2, "es")
|
||||
payload = tr.build_export_payload(conn)
|
||||
assert payload["book_language"] == "en"
|
||||
assert payload["utterance_count"] == 5
|
||||
assert payload["foreign_count"] == 1
|
||||
first = payload["utterances"][0]
|
||||
assert first["source_text"] == "The ship landed."
|
||||
assert first["language_code"] is None, "untagged lines are null on the first pass"
|
||||
spanish = [u for u in payload["utterances"] if u["utterance_id"] == 2][0]
|
||||
assert spanish["language_code"] == "es", "a known tag is sent so it is not re-identified"
|
||||
|
||||
|
||||
def test_export_can_exclude_foreign_lines(conn: sqlite3.Connection) -> None:
|
||||
tr.set_source_language(conn, 2, "es")
|
||||
payload = tr.build_export_payload(conn, include_foreign=False)
|
||||
assert payload["utterance_count"] == 4
|
||||
assert 2 not in [u["utterance_id"] for u in payload["utterances"]]
|
||||
assert payload["foreign_count"] == 1, "the count is still reported even when omitted"
|
||||
|
||||
|
||||
def test_ensure_tables_is_idempotent(conn: sqlite3.Connection) -> None:
|
||||
tr.ensure_tables(conn)
|
||||
tr.ensure_tables(conn)
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name='utterance_translations'"
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
152
tests/test_tts_language.py
Normal file
152
tests/test_tts_language.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Per-utterance language in the TTS call (spec 31 section 4.3 / section 9).
|
||||
|
||||
Two guarantees are pinned here:
|
||||
|
||||
1. **Refuse, never wrong.** A language Qwen3-TTS cannot speak is rejected before
|
||||
any network call or filesystem write, so an unsupported tag leaves a visible
|
||||
refusal instead of a silent English render.
|
||||
2. **Byte-identical when varied.** With no language passed, the payload does not
|
||||
change, so existing renders are untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import wave
|
||||
|
||||
import pytest
|
||||
|
||||
from umbraaudio import languages as lang
|
||||
from umbraaudio.voices.qwen_tts import QwenTTSEngine
|
||||
|
||||
|
||||
def test_gap_language_refused_before_touching_the_network(tmp_path) -> None:
|
||||
engine = QwenTTSEngine()
|
||||
out = tmp_path / "utt_0001.wav"
|
||||
with pytest.raises(ValueError) as exc:
|
||||
engine.generate("Hallo wereld.", out, language="nl")
|
||||
assert "Dutch" in str(exc.value)
|
||||
assert not out.exists(), "refusal must happen before any output is written"
|
||||
|
||||
|
||||
def test_unrecognised_language_is_refused(tmp_path) -> None:
|
||||
engine = QwenTTSEngine()
|
||||
out = tmp_path / "utt_0002.wav"
|
||||
with pytest.raises(ValueError) as exc:
|
||||
engine.generate("Whatever.", out, language="klingon")
|
||||
assert "unrecognised" in str(exc.value)
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
def test_voiceable_languages_pass_the_refusal_guard() -> None:
|
||||
for code in ("en", "zh", "ja", "ko", "de", "es", "fr", "ru", "it", "pt"):
|
||||
assert lang.refusal_reason(code) is None
|
||||
|
||||
|
||||
def test_resolved_name_is_what_the_model_needs() -> None:
|
||||
"""A code must become the model's own string, not the code itself."""
|
||||
assert lang.tts_name("es") == "spanish"
|
||||
assert lang.tts_name("de") == "german"
|
||||
assert lang.tts_name("zh") == "chinese"
|
||||
|
||||
|
||||
def _tiny_wav() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(24000)
|
||||
w.writeframes(b"\x00\x00" * 240)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.status_code = 200
|
||||
self.content = content
|
||||
self.headers = {"content-type": "audio/wav"}
|
||||
self.text = ""
|
||||
|
||||
|
||||
def test_remote_payload_sends_language_when_varied(tmp_path) -> None:
|
||||
engine = QwenTTSEngine()
|
||||
captured: dict = {}
|
||||
|
||||
class _Resp(_FakeResponse):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_tiny_wav())
|
||||
|
||||
import httpx
|
||||
|
||||
def fake_post(url, data=None, files=None, timeout=None): # noqa: ANN001
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
return _Resp()
|
||||
|
||||
original = httpx.post
|
||||
httpx.post = fake_post # type: ignore[assignment]
|
||||
try:
|
||||
engine._remote_generate_at(
|
||||
url="http://127.0.0.1:9/v1",
|
||||
text="Hola, amigo.",
|
||||
files=None,
|
||||
instruct=None,
|
||||
ref_text=None,
|
||||
prompt_slug=None,
|
||||
temperature=0.9,
|
||||
do_sample=True,
|
||||
seed=None,
|
||||
language="spanish",
|
||||
)
|
||||
finally:
|
||||
httpx.post = original # type: ignore[assignment]
|
||||
|
||||
assert captured["data"]["language"] == "spanish"
|
||||
|
||||
|
||||
def test_remote_payload_omits_language_when_not_given(tmp_path) -> None:
|
||||
"""No language passed -> payload unchanged, so existing renders are identical."""
|
||||
engine = QwenTTSEngine()
|
||||
captured: dict = {}
|
||||
|
||||
import httpx
|
||||
|
||||
class _Resp(_FakeResponse):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_tiny_wav())
|
||||
|
||||
def fake_post(url, data=None, files=None, timeout=None): # noqa: ANN001
|
||||
captured["data"] = data
|
||||
return _Resp()
|
||||
|
||||
original = httpx.post
|
||||
httpx.post = fake_post # type: ignore[assignment]
|
||||
try:
|
||||
engine._remote_generate_at(
|
||||
url="http://127.0.0.1:9/v1",
|
||||
text="Plain English line.",
|
||||
files=None,
|
||||
instruct=None,
|
||||
ref_text=None,
|
||||
prompt_slug=None,
|
||||
temperature=0.9,
|
||||
do_sample=True,
|
||||
seed=None,
|
||||
language=None,
|
||||
)
|
||||
finally:
|
||||
httpx.post = original # type: ignore[assignment]
|
||||
|
||||
assert "language" not in captured["data"]
|
||||
|
||||
|
||||
def test_local_clone_defaults_to_english_when_not_given() -> None:
|
||||
"""The local paths keep their historical default rather than becoming None."""
|
||||
import inspect
|
||||
|
||||
from umbraaudio.voices import qwen_tts as mod
|
||||
|
||||
clone = inspect.signature(mod.QwenTTSEngine._generate_clone)
|
||||
vd = inspect.signature(mod.QwenTTSEngine._generate_voicedesign)
|
||||
assert clone.parameters["language"].default == "english"
|
||||
assert vd.parameters["language"].default == "english"
|
||||
@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS project (
|
||||
source_format TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
status TEXT NOT NULL DEFAULT 'initialized'
|
||||
status TEXT NOT NULL DEFAULT 'initialized',
|
||||
source_language TEXT NOT NULL DEFAULT 'en'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
@@ -102,7 +103,37 @@ CREATE TABLE IF NOT EXISTS utterances (
|
||||
emotion_confidence REAL DEFAULT 1.0,
|
||||
user_edited INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
source_unit_key TEXT
|
||||
source_unit_key TEXT,
|
||||
-- spec 31: the language this utterance's TEXT is written in. NULL means the
|
||||
-- project's source language; a non-NULL value is a deliberate claim that the
|
||||
-- line is in another language (a Spanish line inside an English book).
|
||||
-- language_origin records where that claim came from ('unknown',
|
||||
-- 'umbratrans', 'operator') so a machine identification is distinguishable
|
||||
-- from a human correction.
|
||||
language_code TEXT,
|
||||
language_origin TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS utterance_translations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES project(id),
|
||||
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
||||
language_code TEXT NOT NULL,
|
||||
language_name TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
origin TEXT NOT NULL DEFAULT 'umbratrans',
|
||||
source_ref TEXT,
|
||||
delivered_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
-- spec 31 section 6.2: per-language voiced flag. 0 means this target
|
||||
-- language must not render audio for this utterance, because the source
|
||||
-- sentence was absorbed into another translated sentence.
|
||||
voiced INTEGER NOT NULL DEFAULT 1,
|
||||
review_reason TEXT,
|
||||
UNIQUE(utterance_id, language_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS generated_audio (
|
||||
@@ -162,6 +193,8 @@ CREATE INDEX IF NOT EXISTS idx_name_tokens_project ON name_tokens(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_utterances_chapter ON utterances(chapter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_utterances_source_unit ON utterances(source_unit_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_utterances_character ON utterances(character_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt ON utterance_translations(utterance_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_utterance_translations_lang ON utterance_translations(language_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_audio_utterance ON generated_audio(utterance_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chapters_project ON chapters(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_characters_project ON characters(project_id);
|
||||
@@ -220,6 +253,8 @@ class Database:
|
||||
("needs_review", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("review_reason", "TEXT"),
|
||||
("source_unit_key", "TEXT"),
|
||||
("language_code", "TEXT"),
|
||||
("language_origin", "TEXT"),
|
||||
):
|
||||
if col not in utt_cols:
|
||||
await self._conn.execute(
|
||||
@@ -228,6 +263,57 @@ class Database:
|
||||
await self._conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_utterances_source_unit ON utterances(source_unit_key)"
|
||||
)
|
||||
# DELIBERATELY here and not in SCHEMA: for an existing project DB,
|
||||
# `CREATE TABLE IF NOT EXISTS utterances` is a no-op, so language_code
|
||||
# does not exist yet when SCHEMA runs. An index on it in SCHEMA fails
|
||||
# with "no such column: language_code" and takes out every caller.
|
||||
# The column is added by the ALTER above, so the index must follow it.
|
||||
await self._conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_utterances_language ON utterances(language_code)"
|
||||
)
|
||||
|
||||
# spec 31: translations arrived before the project DB existed in its
|
||||
# current shape, so the table is created here too and not only in SCHEMA.
|
||||
await self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS utterance_translations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES project(id),
|
||||
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
||||
language_code TEXT NOT NULL,
|
||||
language_name TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
origin TEXT NOT NULL DEFAULT 'umbratrans',
|
||||
source_ref TEXT,
|
||||
delivered_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
voiced INTEGER NOT NULL DEFAULT 1,
|
||||
review_reason TEXT,
|
||||
UNIQUE(utterance_id, language_code)
|
||||
)
|
||||
"""
|
||||
)
|
||||
await self._conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt "
|
||||
"ON utterance_translations(utterance_id)"
|
||||
)
|
||||
await self._conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_lang "
|
||||
"ON utterance_translations(language_code)"
|
||||
)
|
||||
|
||||
# spec 31 section 13.1: the project's source language. An utterance
|
||||
# with no language tag is written in this language, so the column has
|
||||
# to live on the project rather than being implied by every row.
|
||||
cursor = await self._conn.execute("PRAGMA table_info(project)")
|
||||
project_cols = {row[1] for row in await cursor.fetchall()}
|
||||
if "source_language" not in project_cols:
|
||||
await self._conn.execute(
|
||||
"ALTER TABLE project ADD COLUMN source_language TEXT NOT NULL DEFAULT 'en'"
|
||||
)
|
||||
|
||||
cursor = await self._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='voice_review_history'"
|
||||
|
||||
@@ -381,10 +381,16 @@ class ChapterBuilder:
|
||||
|
||||
utterances = db.execute(
|
||||
"SELECT id, utterance_index, text, speaker_name, emotion, pace, instruct, type, "
|
||||
"source_unit_key "
|
||||
"source_unit_key, language_code "
|
||||
"FROM utterances WHERE chapter_id = ? ORDER BY utterance_index",
|
||||
(self.chapter_id,),
|
||||
).fetchall()
|
||||
# The source language of the book. An utterance with no language tag
|
||||
# is written in this language (spec 31 section 6.1: NULL means the
|
||||
# project source language).
|
||||
from umbraaudio.translations import source_language
|
||||
|
||||
project_language = source_language(db)
|
||||
db.close()
|
||||
if stamped:
|
||||
self._set_phase("validating", f"Recorded {stamped} source-unit keys", 8)
|
||||
@@ -418,11 +424,16 @@ class ChapterBuilder:
|
||||
spoken_text = prepare_tts_text(text, phonetic, proper_names)
|
||||
spk = (u["speaker_name"] or "narrator").strip()
|
||||
vref = _resolve_voice_ref(spk, alias_map, voices_dir)
|
||||
# The language the line is WRITTEN in. This is the source axis
|
||||
# (spec 31), not the target translation: a book in English with a
|
||||
# German line inside it is voiced in German for that line.
|
||||
line_language = u["language_code"] or project_language
|
||||
|
||||
if not is_current(
|
||||
spoken_text, audio_dir / fname,
|
||||
speaker=spk, voice_ref=vref,
|
||||
accepted_voice_modes=self._accepted_voice_modes(vref),
|
||||
language=line_language,
|
||||
):
|
||||
missing.append(dict(u))
|
||||
|
||||
@@ -465,6 +476,12 @@ class ChapterBuilder:
|
||||
|
||||
self._set_phase("generating", f"Generating {len(missing)} utterances...", 15)
|
||||
|
||||
from umbraaudio.translations import source_language
|
||||
|
||||
_lang_db = sqlite3.connect(str(self.data_dir / "project.db"))
|
||||
project_language = source_language(_lang_db)
|
||||
_lang_db.close()
|
||||
|
||||
phonetic = load_phonetic_names(self.data_dir)
|
||||
proper_names = load_proper_names(self.data_dir)
|
||||
speakers = _load_speakers(self.data_dir)
|
||||
@@ -529,6 +546,9 @@ class ChapterBuilder:
|
||||
|
||||
spoken_text = prepare_tts_text(text, phonetic, proper_names)
|
||||
voice_ref = _resolve_voice_ref(speaker, alias_map, voices_dir)
|
||||
# The language the line is written in (spec 31 source axis).
|
||||
# NULL on the utterance means the project's source language.
|
||||
line_language = u.get("language_code") or project_language
|
||||
|
||||
tts_instruct = compose_utterance_instruct(
|
||||
instruct, emotion, u["pace"]
|
||||
@@ -567,6 +587,7 @@ class ChapterBuilder:
|
||||
instruct=tts_instruct,
|
||||
temperature=self.temperature,
|
||||
seed=seed,
|
||||
language=line_language,
|
||||
)
|
||||
voice_mode = "embedding"
|
||||
elif prompt_slug:
|
||||
@@ -577,6 +598,7 @@ class ChapterBuilder:
|
||||
instruct=tts_instruct,
|
||||
temperature=self.temperature,
|
||||
seed=seed,
|
||||
language=line_language,
|
||||
)
|
||||
voice_mode = "icl"
|
||||
elif voice_ref:
|
||||
@@ -588,6 +610,7 @@ class ChapterBuilder:
|
||||
instruct=tts_instruct,
|
||||
temperature=self.temperature,
|
||||
seed=seed,
|
||||
language=line_language,
|
||||
)
|
||||
voice_mode = "embedding"
|
||||
else:
|
||||
@@ -597,6 +620,7 @@ class ChapterBuilder:
|
||||
instruct=tts_instruct or "A calm, neutral voice speaking clearly.",
|
||||
temperature=self.temperature,
|
||||
seed=seed,
|
||||
language=line_language,
|
||||
)
|
||||
|
||||
if out.exists():
|
||||
@@ -620,6 +644,7 @@ class ChapterBuilder:
|
||||
voice_mode=voice_mode, instruct=tts_instruct,
|
||||
source_unit_key=u.get("source_unit_key"),
|
||||
effects=applied,
|
||||
language=line_language,
|
||||
)
|
||||
self.result.generated += 1
|
||||
success = True
|
||||
|
||||
@@ -58,7 +58,14 @@ def write_scene_break_audio(
|
||||
duration_ms: int = SCENE_BREAK_SILENCE_MS,
|
||||
sample_rate: int = DEFAULT_SAMPLE_RATE,
|
||||
) -> Path:
|
||||
"""Write a silence wav (plus staleness marker) for a scene break."""
|
||||
"""Write a silence wav (plus staleness marker) for a scene break.
|
||||
|
||||
The marker records no language on purpose. A scene break is silence, not
|
||||
speech: it is the same clip whatever language the surrounding line is
|
||||
written in, so tagging it would claim a phonology the audio does not have.
|
||||
``needs_silence`` therefore does not compare languages either - retagging a
|
||||
scene-break row must not force a regeneration of an identical silence.
|
||||
"""
|
||||
samples = int(sample_rate * duration_ms / 1000)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if output_path.exists():
|
||||
|
||||
@@ -30,6 +30,13 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
# Spec 31 section 10. A marker written before the language tag existed records no
|
||||
# ``language``; that audio was English (the only language the pipeline could
|
||||
# make), so an absent value means this. Treating absence as "unknown" instead
|
||||
# would invalidate every existing clip - ~4,300 files and a full regeneration -
|
||||
# for no gain.
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
|
||||
|
||||
def marker_path(wav_path: Path) -> Path:
|
||||
"""Sidecar marker path for a wav file (utt_0000.wav -> utt_0000.wav.txt)."""
|
||||
@@ -60,6 +67,7 @@ def is_current(
|
||||
instruct: str | None = None,
|
||||
fx_version: int | None = None,
|
||||
verify_sha256: bool = False,
|
||||
language: str | None = None,
|
||||
) -> bool:
|
||||
"""True when the wav exists, its marker is JSON, and text (+ speaker if
|
||||
given) match exactly. If *voice_ref* is provided the marker's
|
||||
@@ -94,6 +102,13 @@ def is_current(
|
||||
return False
|
||||
if instruct is not None and payload.get("instruct") != instruct:
|
||||
return False
|
||||
if language is not None:
|
||||
# Absence means English (DEFAULT_LANGUAGE), so a legacy English clip
|
||||
# stays current while a line retagged as another language - or an
|
||||
# English line whose clip was made in another language - goes stale.
|
||||
recorded_language = payload.get("language") or DEFAULT_LANGUAGE
|
||||
if recorded_language != language:
|
||||
return False
|
||||
if voice_ref is not None:
|
||||
if not voice_ref.exists():
|
||||
return False
|
||||
@@ -128,6 +143,7 @@ def write_marker(
|
||||
fx_version: int | None = None,
|
||||
source_unit_key: str | None = None,
|
||||
effects: Sequence[str] | None = None,
|
||||
language: str | None = None,
|
||||
) -> None:
|
||||
"""Record *text*, *speaker*, and (optionally) the voice-reference
|
||||
fingerprint as the spoken content of *wav_path*. When *instruct* is given
|
||||
@@ -154,6 +170,11 @@ def write_marker(
|
||||
payload["fx_version"] = fx_version
|
||||
if source_unit_key is not None:
|
||||
payload["source_unit_key"] = source_unit_key
|
||||
if language is not None:
|
||||
# Recorded at generation so a language tag applied *later* (spec 31
|
||||
# section 9) invalidates the clip rather than leaving it with English
|
||||
# phonology behind an unchanged text.
|
||||
payload["language"] = language
|
||||
if effects:
|
||||
# The recorded sha256 identifies the FINAL bytes. Without this, nothing records
|
||||
# which post-processing turned the raw TTS output into those bytes, so a changed
|
||||
|
||||
130
umbraaudio/languages.py
Normal file
130
umbraaudio/languages.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""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
|
||||
765
umbraaudio/translations.py
Normal file
765
umbraaudio/translations.py
Normal file
@@ -0,0 +1,765 @@
|
||||
"""Ingest and query the source-language tag and per-language translations.
|
||||
|
||||
Spec 31. Two axes, deliberately separate:
|
||||
|
||||
* **Source** - ``utterances.language_code`` says what language the original text
|
||||
is *written in*. A Spanish line in an English book is tagged ``es`` and is
|
||||
**never translated**: it is story content. "Bob overhears them speaking in
|
||||
German" - the German is payload the reader is meant to read.
|
||||
* **Target** - ``utterance_translations`` holds one row per (utterance, target
|
||||
language). The ``voiced`` flag lives here, because a line can be voiced in
|
||||
English and not in German (three source sentences collapsing into two).
|
||||
|
||||
The functions here take a plain ``sqlite3.Connection``. Both the web layer
|
||||
(raw sqlite3) and the CLI/scripts can call them, and the import logic stays
|
||||
testable without standing up the async database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from umbraaudio import languages as lang
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import rows are stored as 'draft' unless the caller says otherwise. A draft
|
||||
# is visible and audible but explicitly not approved (spec 30 section 3.2).
|
||||
STATUS_DRAFT = "draft"
|
||||
STATUS_CONFIRMED = "confirmed"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _col(row: Any, name: str, index: int) -> Any:
|
||||
"""Read a column from a sqlite3.Row or a plain tuple.
|
||||
|
||||
Callers may not have set ``row_factory``, so both shapes are supported
|
||||
rather than assuming one.
|
||||
"""
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
return row[name]
|
||||
except (TypeError, IndexError, KeyError):
|
||||
return row[index]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportReport:
|
||||
"""What an import actually did. Counts only - never the text."""
|
||||
|
||||
accepted: int = 0
|
||||
source_tagged: int = 0
|
||||
skipped_confirmed: int = 0
|
||||
not_voiced: int = 0
|
||||
refused_voice: int = 0
|
||||
foreign_untouched: int = 0
|
||||
unknown_language: list[str] = field(default_factory=list)
|
||||
problems: list[str] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"accepted": self.accepted,
|
||||
"source_tagged": self.source_tagged,
|
||||
"skipped_confirmed": self.skipped_confirmed,
|
||||
"not_voiced": self.not_voiced,
|
||||
"refused_voice": self.refused_voice,
|
||||
"foreign_untouched": self.foreign_untouched,
|
||||
"unknown_language": sorted(set(self.unknown_language)),
|
||||
"problems": self.problems,
|
||||
}
|
||||
|
||||
|
||||
def _translations_table_exists(conn: sqlite3.Connection) -> bool:
|
||||
"""True when this connection can see the translations table.
|
||||
|
||||
Read-only callers exist (routes opens ``mode=ro`` in places), so the read
|
||||
helpers must not create the table - they check instead, and report "nothing
|
||||
translated yet" rather than raising.
|
||||
"""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='utterance_translations'"
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
return row is not None
|
||||
|
||||
|
||||
def ensure_tables(conn: sqlite3.Connection) -> None:
|
||||
"""Create the translations table if an old project DB lacks it.
|
||||
|
||||
The column and index additions live in ``database._migrate`` and
|
||||
``web.routes._ensure_utterance_columns``; this is the same statement exposed
|
||||
for callers that hold a bare connection (scripts, tests).
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS utterance_translations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES project(id),
|
||||
utterance_id INTEGER NOT NULL REFERENCES utterances(id),
|
||||
language_code TEXT NOT NULL,
|
||||
language_name TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
origin TEXT NOT NULL DEFAULT 'umbratrans',
|
||||
source_ref TEXT,
|
||||
delivered_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
voiced INTEGER NOT NULL DEFAULT 1,
|
||||
review_reason TEXT,
|
||||
UNIQUE(utterance_id, language_code)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt "
|
||||
"ON utterance_translations(utterance_id)"
|
||||
)
|
||||
|
||||
|
||||
def source_language(conn: sqlite3.Connection) -> str:
|
||||
"""The project's primary language, defaulting to English.
|
||||
|
||||
Spec 31 section 13.1 flagged this as an open question; until a column
|
||||
exists, the project's source language is 'en', which is what every existing
|
||||
row means anyway (NULL on the utterance = this value).
|
||||
"""
|
||||
try:
|
||||
row = conn.execute("SELECT source_language FROM project LIMIT 1").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return "en"
|
||||
return lang.normalize(_col(row, "source_language", 0)) or "en"
|
||||
|
||||
|
||||
def set_source_language(
|
||||
conn: sqlite3.Connection,
|
||||
utterance_id: int,
|
||||
code: str | None,
|
||||
origin: str = lang.ORIGIN_UMBRATRANS,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""Record the language an utterance's text is written in.
|
||||
|
||||
Returns True when a change was written. ``code`` is normalised; an
|
||||
unrecognised language is still stored (spec 31 section 4.3 - the tag is
|
||||
allowed, the render is refused) but reported by the caller.
|
||||
|
||||
An existing ``operator`` tag is not overwritten by a machine identification
|
||||
unless ``force`` is set: a human correction outranks a re-delivery of the
|
||||
same machine guess.
|
||||
"""
|
||||
if origin not in lang.ORIGINS:
|
||||
raise ValueError(f"unknown origin {origin!r}")
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT language_code, language_origin FROM utterances WHERE id=?",
|
||||
(utterance_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
current_code = _col(row, "language_code", 0)
|
||||
current_origin = _col(row, "language_origin", 1)
|
||||
|
||||
# An unrecognised language is STORED, not discarded (spec 31 section 4.3:
|
||||
# "allow the tag, refuse the render"). Collapsing it to NULL would make the
|
||||
# line look untagged, so it would fall back to the primary language and be
|
||||
# spoken with the wrong phonology - the silent failure the decision exists
|
||||
# to prevent. The raw value is kept so the refusal can name it.
|
||||
if code:
|
||||
stored: str | None = lang.normalize(code) or str(code).strip().lower()
|
||||
else:
|
||||
stored = None
|
||||
|
||||
operator_owns = current_origin == lang.ORIGIN_OPERATOR and origin != lang.ORIGIN_OPERATOR
|
||||
if operator_owns and not force:
|
||||
return False
|
||||
if stored == current_code and origin == current_origin:
|
||||
return False
|
||||
|
||||
conn.execute(
|
||||
"UPDATE utterances SET language_code=?, language_origin=? WHERE id=?",
|
||||
(stored, origin, utterance_id),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def foreign_utterance_ids(conn: sqlite3.Connection, primary: str | None = None) -> set[int]:
|
||||
"""Utterances whose text is NOT in the project's primary language.
|
||||
|
||||
These are excluded from translation: they are story content, not something
|
||||
to render into another language.
|
||||
"""
|
||||
primary = lang.normalize(primary) or source_language(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM utterances WHERE language_code IS NOT NULL AND language_code <> ?",
|
||||
(primary,),
|
||||
).fetchall()
|
||||
return {_col(r, "id", 0) for r in rows}
|
||||
|
||||
|
||||
def utterances_to_translate(conn: sqlite3.Connection, primary: str | None = None) -> list[int]:
|
||||
"""Utterances eligible for translation: source-language ones only.
|
||||
|
||||
The complement of :func:`foreign_utterance_ids`. A NULL tag means "the
|
||||
project's primary language" (spec 31 section 6.1), so untagged rows are
|
||||
included - which is every existing utterance until UmbraTrans speaks.
|
||||
"""
|
||||
primary = lang.normalize(primary) or source_language(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM utterances "
|
||||
"WHERE language_code IS NULL OR language_code = ? "
|
||||
"ORDER BY chapter_id, utterance_index",
|
||||
(primary,),
|
||||
).fetchall()
|
||||
return [_col(r, "id", 0) for r in rows]
|
||||
|
||||
|
||||
def import_translations(
|
||||
conn: sqlite3.Connection,
|
||||
project_id: int,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
origin: str = lang.ORIGIN_UMBRATRANS,
|
||||
confirm: bool = False,
|
||||
) -> ImportReport:
|
||||
"""Store one delivery from UmbraTrans.
|
||||
|
||||
``payload`` shape (spec 31 section 8)::
|
||||
|
||||
{"target_language": "de",
|
||||
"utterances": [{"utterance_id": 1, "source_language": "en",
|
||||
"text": "Hallo", "voiced": true},
|
||||
{"utterance_id": 2, "source_language": "es"},
|
||||
{"utterance_id": 3, "voiced": false,
|
||||
"reason": "absorbed into utterance 2"}]}
|
||||
|
||||
Three row shapes, all expected:
|
||||
|
||||
* ``source_language`` only - a language *identification*. This is the shape
|
||||
for a foreign line, which is deliberately left untranslated. Counted, not
|
||||
complained about.
|
||||
* ``text`` (+ optional ``voiced``) - a translation into the target language.
|
||||
* ``voiced: false`` with no text - a collapse orphan: the source sentence was
|
||||
absorbed into another translated sentence. A row is still written, so the
|
||||
gap is visible and reconstructable; deleting it would lose the alignment.
|
||||
"""
|
||||
ensure_tables(conn)
|
||||
report = ImportReport()
|
||||
|
||||
target = lang.normalize(payload.get("target_language"))
|
||||
target_name = lang.language_name(target) if target else None
|
||||
accepted_status = STATUS_CONFIRMED if confirm else STATUS_DRAFT
|
||||
now = _now()
|
||||
|
||||
rows = payload.get("utterances") or []
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("'utterances' must be a list")
|
||||
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or "utterance_id" not in row:
|
||||
report.problems.append(f"row without utterance_id: {json.dumps(row)[:80]}")
|
||||
continue
|
||||
|
||||
uid = int(row["utterance_id"])
|
||||
|
||||
# --- source axis: what language the ORIGINAL text is in ------------
|
||||
src = row.get("source_language")
|
||||
if src:
|
||||
if not lang.is_known(src):
|
||||
report.unknown_language.append(str(src))
|
||||
if set_source_language(conn, uid, src, origin):
|
||||
report.source_tagged += 1
|
||||
if not lang.is_voiceable(src):
|
||||
# Tag honoured, render refused (spec 31 section 4.3).
|
||||
report.refused_voice += 1
|
||||
|
||||
# --- target axis: the translated text -----------------------------
|
||||
text = row.get("text")
|
||||
voiced = bool(row.get("voiced", True))
|
||||
|
||||
if not target:
|
||||
# No target language on the delivery: identification only.
|
||||
if text is not None:
|
||||
report.problems.append(
|
||||
f"utterance {uid} carries text but the payload has no target_language"
|
||||
)
|
||||
continue
|
||||
|
||||
if text is None and voiced:
|
||||
# A foreign line, or one with nothing to translate. Untouched.
|
||||
report.foreign_untouched += 1
|
||||
continue
|
||||
|
||||
reason = row.get("reason") or row.get("review_reason")
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT status FROM utterance_translations WHERE utterance_id=? AND language_code=?",
|
||||
(uid, target),
|
||||
).fetchone()
|
||||
if _col(existing, "status", 0) == STATUS_CONFIRMED:
|
||||
report.skipped_confirmed += 1
|
||||
continue
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO utterance_translations
|
||||
(project_id, utterance_id, language_code, language_name, text,
|
||||
status, origin, source_ref, delivered_at, created_at, updated_at,
|
||||
voiced, review_reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(utterance_id, language_code) DO UPDATE SET
|
||||
text=excluded.text,
|
||||
status=excluded.status,
|
||||
origin=excluded.origin,
|
||||
source_ref=excluded.source_ref,
|
||||
delivered_at=excluded.delivered_at,
|
||||
updated_at=excluded.updated_at,
|
||||
voiced=excluded.voiced,
|
||||
review_reason=excluded.review_reason
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
uid,
|
||||
target,
|
||||
target_name or target,
|
||||
str(text) if text is not None else "",
|
||||
accepted_status,
|
||||
origin,
|
||||
row.get("source_ref"),
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
1 if voiced else 0,
|
||||
reason,
|
||||
),
|
||||
)
|
||||
report.accepted += 1
|
||||
if not voiced:
|
||||
report.not_voiced += 1
|
||||
|
||||
if not lang.is_voiceable(target):
|
||||
# Target language cannot be spoken: stored, but nothing renders.
|
||||
report.refused_voice += 1
|
||||
|
||||
conn.commit()
|
||||
return report
|
||||
|
||||
|
||||
def coverage(conn: sqlite3.Connection, language_code: str) -> dict[str, int]:
|
||||
"""How much of the book exists in a target language.
|
||||
|
||||
``voiced`` counts rows that will actually produce audio, so a collapse
|
||||
orphan shows as a gap rather than quietly inflating the total.
|
||||
|
||||
A project with no translations table yet reports "nothing delivered" rather
|
||||
than raising: that is a real state (a book that has never been sent), not an
|
||||
error.
|
||||
"""
|
||||
code = lang.normalize(language_code) or language_code
|
||||
|
||||
def scalar(sql: str, args: tuple = ()) -> int:
|
||||
return conn.execute(sql, args).fetchone()[0]
|
||||
|
||||
total = scalar("SELECT COUNT(*) FROM utterances")
|
||||
if not _translations_table_exists(conn):
|
||||
return {
|
||||
"total": total,
|
||||
"delivered": 0,
|
||||
"voiced": 0,
|
||||
"not_voiced": 0,
|
||||
"confirmed": 0,
|
||||
"missing": total,
|
||||
}
|
||||
|
||||
delivered = scalar("SELECT COUNT(*) FROM utterance_translations WHERE language_code=?", (code,))
|
||||
voiced = scalar(
|
||||
"SELECT COUNT(*) FROM utterance_translations WHERE language_code=? AND voiced=1",
|
||||
(code,),
|
||||
)
|
||||
confirmed = scalar(
|
||||
"SELECT COUNT(*) FROM utterance_translations WHERE language_code=? AND status=?",
|
||||
(code, STATUS_CONFIRMED),
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"delivered": delivered,
|
||||
"voiced": voiced,
|
||||
"not_voiced": delivered - voiced,
|
||||
"confirmed": confirmed,
|
||||
"missing": max(total - delivered, 0),
|
||||
}
|
||||
|
||||
|
||||
def not_voiced_rows(conn: sqlite3.Connection, language_code: str) -> list[dict[str, Any]]:
|
||||
"""The collapse orphans for a language - what a reviewer must look at.
|
||||
|
||||
These are utterances where the target used fewer sentences than the source,
|
||||
so a source line has no counterpart. They are *kept*, with a reason, rather
|
||||
than deleted: deleting loses the alignment (spec 31 section 6.2).
|
||||
"""
|
||||
code = lang.normalize(language_code) or language_code
|
||||
if not _translations_table_exists(conn):
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT t.utterance_id, t.review_reason, u.chapter_id, u.utterance_index,
|
||||
u.speaker_name, u.text
|
||||
FROM utterance_translations t
|
||||
JOIN utterances u ON u.id = t.utterance_id
|
||||
WHERE t.language_code = ? AND t.voiced = 0
|
||||
ORDER BY u.chapter_id, u.utterance_index
|
||||
""",
|
||||
(code,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"utterance_id": _col(r, "utterance_id", 0),
|
||||
"review_reason": _col(r, "review_reason", 1),
|
||||
"chapter_id": _col(r, "chapter_id", 2),
|
||||
"utterance_index": _col(r, "utterance_index", 3),
|
||||
"speaker_name": _col(r, "speaker_name", 4),
|
||||
"text": _col(r, "text", 5),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def refused_render_rows(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Utterances tagged in a language Qwen3-TTS cannot speak.
|
||||
|
||||
Spec 31 section 11 / decision 4.3: the tag is honoured and the render
|
||||
refused. This is the list that makes the resulting hole *visible* instead of
|
||||
silent. Sorted by chapter so it reads like a worklist.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, chapter_id, utterance_index, speaker_name, text, language_code
|
||||
FROM utterances
|
||||
WHERE language_code IS NOT NULL
|
||||
ORDER BY chapter_id, utterance_index
|
||||
"""
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
code = _col(r, "language_code", 5)
|
||||
if lang.is_voiceable(code):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"utterance_id": _col(r, "id", 0),
|
||||
"chapter_id": _col(r, "chapter_id", 1),
|
||||
"utterance_index": _col(r, "utterance_index", 2),
|
||||
"speaker_name": _col(r, "speaker_name", 3),
|
||||
"text": _col(r, "text", 4),
|
||||
"language_code": code,
|
||||
"reason": lang.refusal_reason(code),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def effective_language(
|
||||
conn: sqlite3.Connection, utterance_id: int, primary: str | None = None
|
||||
) -> str:
|
||||
"""The language an utterance should be spoken in.
|
||||
|
||||
NULL means "the project's primary language" (spec 31 section 6.1), so the
|
||||
caller gets a concrete code rather than a None to branch on.
|
||||
|
||||
A tag that is present but unrecognised is returned **as-is**, never replaced
|
||||
by the primary language. Falling back would voice a Klingon line as English,
|
||||
which is exactly the silent wrongness the refuse-render decision prevents.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT language_code FROM utterances WHERE id=?", (utterance_id,)
|
||||
).fetchone()
|
||||
code = _col(row, "language_code", 0)
|
||||
if code:
|
||||
return lang.normalize(code) or str(code)
|
||||
return lang.normalize(primary) or source_language(conn)
|
||||
|
||||
|
||||
def build_export_payload(
|
||||
conn: sqlite3.Connection,
|
||||
primary: str | None = None,
|
||||
*,
|
||||
include_foreign: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""The per-utterance payload for UmbraTrans (spec 31 section 7).
|
||||
|
||||
``language_code`` is sent when already known so a re-delivery does not ask
|
||||
UmbraTrans to re-identify a line the operator corrected. It is null on the
|
||||
first pass, which is normal.
|
||||
|
||||
``include_foreign`` defaults to True because UmbraTrans is the *authority*
|
||||
on identification: it must see every line to identify it. Set it False to
|
||||
send only the lines that need translating.
|
||||
"""
|
||||
primary = lang.normalize(primary) or source_language(conn)
|
||||
foreign = foreign_utterance_ids(conn, primary)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT u.id, u.chapter_id, c.chapter_index, u.utterance_index,
|
||||
u.speaker_name, u.text, u.context_before, u.context_after,
|
||||
u.language_code
|
||||
FROM utterances u
|
||||
LEFT JOIN chapters c ON c.id = u.chapter_id
|
||||
ORDER BY u.chapter_id, u.utterance_index
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
utterances = []
|
||||
for r in rows:
|
||||
uid = _col(r, "id", 0)
|
||||
if not include_foreign and uid in foreign:
|
||||
continue
|
||||
utterances.append(
|
||||
{
|
||||
"utterance_id": uid,
|
||||
"chapter_id": _col(r, "chapter_id", 1),
|
||||
"chapter_index": _col(r, "chapter_index", 2),
|
||||
"utterance_index": _col(r, "utterance_index", 3),
|
||||
"speaker_name": _col(r, "speaker_name", 4),
|
||||
"source_text": _col(r, "text", 5),
|
||||
"context_before": _col(r, "context_before", 6),
|
||||
"context_after": _col(r, "context_after", 7),
|
||||
"language_code": _col(r, "language_code", 8),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"source_system": "umbraaudio",
|
||||
"book_language": primary,
|
||||
"utterance_count": len(utterances),
|
||||
"foreign_count": len(foreign),
|
||||
"utterances": utterances,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution: where a translation actually comes from
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Two stores hold translations and they answer different questions:
|
||||
#
|
||||
# * ``utterance_translations`` - the pipeline's output. What UmbraTrans
|
||||
# delivered and what the operator confirmed. Authoritative when it holds a
|
||||
# real translation.
|
||||
# * ``<project>/samples/translations/*.json`` - the on-disk sample files. These
|
||||
# are what ``render_lang_test.py`` rendered the per-language sample audio
|
||||
# from, so the text and the audio on disk agree.
|
||||
#
|
||||
# Today the sample files are the only place the sample chapter's translations
|
||||
# exist; the table holds one placeholder row. A resolver that reads only one
|
||||
# of the two shows English for a chapter that has a valid Chinese translation,
|
||||
# which is the bug this layer exists to prevent.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TranslationEntry:
|
||||
"""One resolved translation for one utterance in one target language."""
|
||||
|
||||
text: str
|
||||
status: str = STATUS_DRAFT
|
||||
origin: str = "sample"
|
||||
speaker: str = ""
|
||||
type: str = ""
|
||||
|
||||
@property
|
||||
def confirmed(self) -> bool:
|
||||
return self.status == STATUS_CONFIRMED
|
||||
|
||||
|
||||
def canonical_code(value: str | None) -> str | None:
|
||||
"""Normalise a language code, tolerating script subtags.
|
||||
|
||||
``lang.normalize`` only knows the canonical codes, so ``'zh-Hans'`` (what
|
||||
UmbraTrans writes) and ``'zh_CN'`` both collapse to ``'zh'`` here.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
direct = lang.normalize(raw)
|
||||
if direct:
|
||||
return direct
|
||||
base = re.split(r"[-_]", raw)[0]
|
||||
return lang.normalize(base)
|
||||
|
||||
|
||||
def sample_translations_dir(project_dir: Path) -> Path:
|
||||
"""Where the on-disk sample translations live for *project_dir*.
|
||||
|
||||
``project_dir`` rather than ``data_dir``: ``.umbra`` is gitignored runtime
|
||||
state, while the sample translations are reviewable project content that
|
||||
travels with the book.
|
||||
"""
|
||||
return project_dir / "samples" / "translations"
|
||||
|
||||
|
||||
def load_sample_translations(project_dir: Path) -> dict[str, dict[tuple[int, int], str]]:
|
||||
"""Read every sample translation file under the project.
|
||||
|
||||
Returns ``{language_code: {(chapter_id, utterance_index): text}}``. A file
|
||||
that cannot be read or parsed is skipped with a warning rather than taking
|
||||
the page down - one bad file must not hide the other nine.
|
||||
"""
|
||||
out: dict[str, dict[tuple[int, int], str]] = {}
|
||||
directory = sample_translations_dir(project_dir)
|
||||
if not directory.is_dir():
|
||||
return out
|
||||
for path in sorted(directory.glob("*.json")):
|
||||
try:
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
logger.warning("translation sample unreadable: %s", path)
|
||||
continue
|
||||
if not isinstance(doc, dict):
|
||||
logger.warning("translation sample is not an object: %s", path)
|
||||
continue
|
||||
code = canonical_code(doc.get("language_code"))
|
||||
if code is None:
|
||||
logger.warning("translation sample has no usable language_code: %s", path)
|
||||
continue
|
||||
chapter_id = doc.get("chapter_id")
|
||||
try:
|
||||
chapter_id = int(chapter_id)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("translation sample has no usable chapter_id: %s", path)
|
||||
continue
|
||||
bucket = out.setdefault(code, {})
|
||||
for entry in doc.get("utterances") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
index = _as_index(entry.get("index"))
|
||||
text = entry.get("text")
|
||||
if index is None or not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
bucket[(chapter_id, index)] = text
|
||||
return out
|
||||
|
||||
|
||||
def _as_index(value: Any) -> int | None:
|
||||
"""The sample files store the index as a string ("0"), the DB as an int."""
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _db_translation_rows(
|
||||
conn: sqlite3.Connection,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rows from ``utterance_translations`` joined to their utterance.
|
||||
|
||||
The join uses ``utterances.utterance_index`` and ``utterances.speaker_name``
|
||||
- the column names that actually exist. (An earlier draft of the language
|
||||
UI selected ``u.index`` and ``u.speaker``, which do not, so every read
|
||||
raised ``sqlite3.OperationalError: near "index": syntax error``.)
|
||||
"""
|
||||
if not _translations_table_exists(conn):
|
||||
return []
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT u.chapter_id, u.utterance_index, u.text AS source_text,
|
||||
t.text AS target_text, t.status, t.origin, u.speaker_name,
|
||||
t.language_code
|
||||
FROM utterance_translations t
|
||||
JOIN utterances u ON u.id = t.utterance_id
|
||||
"""
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
logger.exception("utterance_translations could not be read")
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"chapter_id": _col(r, "chapter_id", 0),
|
||||
"utterance_index": _col(r, "utterance_index", 1),
|
||||
"source_text": _col(r, "source_text", 2),
|
||||
"target_text": _col(r, "target_text", 3),
|
||||
"status": _col(r, "status", 4),
|
||||
"origin": _col(r, "origin", 5),
|
||||
"speaker_name": _col(r, "speaker_name", 6),
|
||||
"language_code": _col(r, "language_code", 7),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def resolved_translations(
|
||||
conn: sqlite3.Connection,
|
||||
project_dir: Path,
|
||||
code: str,
|
||||
) -> dict[tuple[int, int], TranslationEntry]:
|
||||
"""The translation text to show for *code*, per ``(chapter_id, index)``.
|
||||
|
||||
The DB wins where it holds a real translation; the on-disk sample files
|
||||
fill the gaps. A candidate whose text is identical to the source text is a
|
||||
placeholder rather than a translation, so it is ignored - otherwise the
|
||||
page would show English under a Chinese heading and claim it was
|
||||
translated.
|
||||
"""
|
||||
target = canonical_code(code) or str(code).strip().lower()
|
||||
if not target:
|
||||
return {}
|
||||
|
||||
resolved: dict[tuple[int, int], TranslationEntry] = {}
|
||||
|
||||
# --- the pipeline's output -------------------------------------------
|
||||
for row in _db_translation_rows(conn):
|
||||
if canonical_code(row["language_code"]) != target:
|
||||
continue
|
||||
source_text = row["source_text"] or ""
|
||||
text = row["target_text"] or ""
|
||||
if not text.strip():
|
||||
continue
|
||||
if source_text and text.strip() == source_text.strip():
|
||||
# The English text stored under a target-language code: a
|
||||
# placeholder, not a translation.
|
||||
continue
|
||||
key = (int(row["chapter_id"]), int(row["utterance_index"]))
|
||||
resolved[key] = TranslationEntry(
|
||||
text=text,
|
||||
status=row["status"] or STATUS_DRAFT,
|
||||
origin=row["origin"] or "umbratrans",
|
||||
speaker=row["speaker_name"] or "",
|
||||
)
|
||||
|
||||
# --- the on-disk sample files ----------------------------------------
|
||||
files = load_sample_translations(project_dir)
|
||||
reference = files.get("en", {})
|
||||
for key, text in files.get(target, {}).items():
|
||||
if key in resolved:
|
||||
continue
|
||||
source_text = reference.get(key)
|
||||
if source_text is not None and source_text.strip() == text.strip():
|
||||
# Identical to the source-language file: not translated.
|
||||
continue
|
||||
resolved[key] = TranslationEntry(
|
||||
text=text,
|
||||
status=STATUS_DRAFT,
|
||||
origin="sample",
|
||||
)
|
||||
|
||||
return resolved
|
||||
@@ -228,8 +228,29 @@ class QwenTTSEngine:
|
||||
temperature: float = 0.9,
|
||||
do_sample: bool = True,
|
||||
seed: int | None = None,
|
||||
language: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Path:
|
||||
"""Synthesise *text* to *output_path*.
|
||||
|
||||
*language* is the source-language tag of the utterance (spec 31). It is
|
||||
passed to the model as the phonology/prosody to use. ``None`` keeps the
|
||||
historical behaviour (server default, English) so existing callers are
|
||||
unchanged.
|
||||
|
||||
A language Qwen3-TTS cannot speak is **refused here**, before any
|
||||
network call: the tag is allowed and recorded, the render is not
|
||||
attempted (spec 31 section 4.3 - fail loud, never wrong).
|
||||
"""
|
||||
from umbraaudio import languages as lang
|
||||
|
||||
resolved: str | None = None
|
||||
if language is not None:
|
||||
reason = lang.refusal_reason(language)
|
||||
if reason is not None:
|
||||
raise ValueError(f"Refusing to render: {reason}")
|
||||
resolved = lang.tts_name(language)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self._use_remote:
|
||||
@@ -237,16 +258,19 @@ class QwenTTSEngine:
|
||||
text, voice_ref, instruct,
|
||||
ref_text=ref_text, prompt_slug=prompt_slug,
|
||||
temperature=temperature, do_sample=do_sample, seed=seed,
|
||||
language=resolved,
|
||||
)
|
||||
elif voice_ref and Path(voice_ref).exists():
|
||||
wav, sr = self._generate_clone(
|
||||
text, str(voice_ref), ref_text=ref_text,
|
||||
temperature=temperature, do_sample=do_sample, seed=seed,
|
||||
language=resolved or "english",
|
||||
)
|
||||
elif instruct:
|
||||
wav, sr = self._generate_voicedesign(
|
||||
text, instruct,
|
||||
temperature=temperature, do_sample=do_sample, seed=seed,
|
||||
language=resolved or "english",
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
@@ -352,6 +376,7 @@ class QwenTTSEngine:
|
||||
temperature: float = 0.9,
|
||||
do_sample: bool = True,
|
||||
seed: int | None = None,
|
||||
language: str | None = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
import httpx
|
||||
|
||||
@@ -375,6 +400,7 @@ class QwenTTSEngine:
|
||||
temperature=temperature,
|
||||
do_sample=do_sample,
|
||||
seed=seed,
|
||||
language=language,
|
||||
)
|
||||
except QwenTTSUnavailableError as e:
|
||||
last_err = str(e)
|
||||
@@ -393,6 +419,7 @@ class QwenTTSEngine:
|
||||
temperature: float,
|
||||
do_sample: bool,
|
||||
seed: int | None,
|
||||
language: str | None = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
import httpx
|
||||
|
||||
@@ -403,6 +430,10 @@ class QwenTTSEngine:
|
||||
"temperature": temperature,
|
||||
"do_sample": do_sample,
|
||||
}
|
||||
if language is not None:
|
||||
# The server defaults to English; sending it only when varied keeps
|
||||
# existing renders byte-identical (spec 31 section 9).
|
||||
payload["language"] = language
|
||||
if seed is not None:
|
||||
payload["seed"] = seed
|
||||
if ref_text:
|
||||
@@ -483,6 +514,7 @@ class QwenTTSEngine:
|
||||
def _generate_voicedesign(
|
||||
self, text: str, instruct: str,
|
||||
temperature: float = 0.9, do_sample: bool = True, seed: int | None = None,
|
||||
language: str = "english",
|
||||
) -> tuple[np.ndarray, int]:
|
||||
if seed is not None:
|
||||
import torch
|
||||
@@ -491,7 +523,7 @@ class QwenTTSEngine:
|
||||
wavs, sr = self._vd_model.generate_voice_design(
|
||||
text=text,
|
||||
instruct=instruct,
|
||||
language="english",
|
||||
language=language,
|
||||
temperature=temperature,
|
||||
do_sample=do_sample,
|
||||
)
|
||||
@@ -502,6 +534,7 @@ class QwenTTSEngine:
|
||||
def _generate_clone(
|
||||
self, text: str, ref_audio_path: str, ref_text: str | None = None,
|
||||
temperature: float = 0.9, do_sample: bool = True, seed: int | None = None,
|
||||
language: str = "english",
|
||||
) -> tuple[np.ndarray, int]:
|
||||
if seed is not None:
|
||||
import torch
|
||||
@@ -519,7 +552,7 @@ class QwenTTSEngine:
|
||||
ref_audio=(ref_audio, self.sample_rate),
|
||||
ref_text=ref_text,
|
||||
x_vector_only_mode=False,
|
||||
language="english",
|
||||
language=language,
|
||||
temperature=temperature,
|
||||
do_sample=do_sample,
|
||||
)
|
||||
@@ -574,11 +607,13 @@ class QwenTTSClient:
|
||||
temperature: float = 0.9,
|
||||
do_sample: bool = True,
|
||||
seed: int | None = None,
|
||||
language: str | None = None,
|
||||
) -> Path:
|
||||
engine = QwenTTSEngine(qwen_tts_url=self.base_url, helper_url=self.helper_url)
|
||||
return engine.generate(
|
||||
text, output_path, voice_ref=voice_ref, instruct=instruct,
|
||||
temperature=temperature, do_sample=do_sample, seed=seed,
|
||||
language=language,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -36,8 +36,9 @@ def create_app(config: AppConfig) -> FastAPI:
|
||||
from umbraaudio.web.voice_queue_routes import router as voice_queue_router
|
||||
|
||||
app.include_router(router)
|
||||
# Spec 30 MOCKUP: language-selection UI preview for operator review. Read-only,
|
||||
# and removed once spec 30 is implemented for real.
|
||||
# Language selection UI (spec 30/31): reads from project's
|
||||
# utterance_translations table, renders per-language chapter audio
|
||||
# under <project>/Lang_test/<CODE>.
|
||||
app.include_router(language_router)
|
||||
# Spec 17 Phase 3: orphan name-token review queue lives in its own module so
|
||||
# the UI never has to be merged into the hot routes.py.
|
||||
@@ -99,6 +100,29 @@ def create_app(config: AppConfig) -> FastAPI:
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
# ---------------------------------------------------------------- language
|
||||
# The content-language selector is in the page header, so it appears on
|
||||
# every page. Honouring ``?lang=`` here rather than in each route means
|
||||
# the choice is remembered in a cookie on every page, not just the ones
|
||||
# that happen to read the query parameter.
|
||||
from umbraaudio.web.language_routes import SUPPORTED as _SUPPORTED_LANGUAGES
|
||||
|
||||
language_codes = {code for code, _ in _SUPPORTED_LANGUAGES}
|
||||
|
||||
@app.middleware("http")
|
||||
async def _language_cookie(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
requested = (request.query_params.get("lang") or "").strip()
|
||||
if requested in language_codes:
|
||||
response.set_cookie(
|
||||
"active_language",
|
||||
requested,
|
||||
max_age=31536000,
|
||||
path="/",
|
||||
httponly=False,
|
||||
)
|
||||
return response
|
||||
|
||||
# Spec 17 §3.1/§3.2: speaker identification confidence. Exposed as a template
|
||||
# global (like active_project) so the speakers page can show a
|
||||
# clear / needs-review designation for every speaker without touching the
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"""Language-selection UI mockup (spec 30) - PROPOSAL, NOT PRODUCTION.
|
||||
"""Language selection UI (spec 30/31).
|
||||
|
||||
Exists so the operator can review the proposed language UI before any of it is
|
||||
wired into the pipeline. It reads the real sample translations and the real
|
||||
project DB, so what is reviewed is the proposed behaviour rather than a drawing.
|
||||
Reads translation rows from the active project's ``utterance_translations`` table
|
||||
so the UI always shows the real project state, not a stale sample snapshot.
|
||||
|
||||
Nothing here writes to the DB, generates audio, or touches the English pipeline.
|
||||
Separate module for the reason name_token_routes.py exists: to keep proposed
|
||||
work out of the hot routes.py until it is agreed.
|
||||
Writes per-language audio under ``<project>/Lang_test/<CODE>``. Audio generation
|
||||
is driven by the same pipeline that renders English — the ``code`` is passed
|
||||
through to the TTS engine so each language gets its own rendered chapter files.
|
||||
|
||||
Delete once spec 30 is implemented for real.
|
||||
Do not delete this module: the language-mockup route is how the operator reviews
|
||||
language state before spec 31 commits to a full pipeline rewrite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -51,46 +50,86 @@ SOURCE_LANGUAGE = "en"
|
||||
ISSUE_INLINE_THRESHOLD = 25
|
||||
|
||||
|
||||
def _translations_dir(request: Request) -> Path:
|
||||
"""Sample translations live beside the project, not in .umbra.
|
||||
|
||||
project_dir rather than data_dir, matching render_lang_test.py: .umbra is
|
||||
gitignored state while the translations are reviewable project content.
|
||||
"""
|
||||
from umbraaudio.web.routes import _get_active_config
|
||||
|
||||
return _get_active_config(request).project_dir / "samples" / "translations"
|
||||
|
||||
|
||||
def _project_db(request: Request) -> Path:
|
||||
"""Path to the active project's SQLite DB.
|
||||
|
||||
``data_dir`` (``<project>/.umbra``), the same file routes.py opens. A
|
||||
0-byte ``project.db`` also sits in the project root; opening that one
|
||||
finds no tables at all, so every query fails with "no such table".
|
||||
"""
|
||||
from umbraaudio.web.routes import _get_active_config
|
||||
|
||||
return _get_active_config(request).data_dir / "project.db"
|
||||
|
||||
|
||||
def _lang_audio_dir(request: Request, code: str) -> Path:
|
||||
"""Where render_lang_test.py writes per-language audio: <project>/Lang_test.
|
||||
def _language_code_variants(code: str) -> list[str]:
|
||||
"""Return a language code and its common variants for DB lookup.
|
||||
|
||||
Stands in for the proposed chapter_audio_<code>/ namespace until spec 30
|
||||
lands. English lives in .umbra/chapter_audio instead.
|
||||
The project DB stores ``'zh-Hans'`` while SUPPORTED uses ``'zh'``.
|
||||
This function returns both so the query can match either.
|
||||
"""
|
||||
variants: list[str] = [code]
|
||||
if code == "zh":
|
||||
variants.append("zh-Hans")
|
||||
if code == "en":
|
||||
variants.append("en-US")
|
||||
return variants
|
||||
|
||||
|
||||
def _translation_rows(request: Request, code: str) -> list[dict]:
|
||||
"""The translated rows for *code*, from the DB and the sample files.
|
||||
|
||||
Each row has: index, speaker, type, text — matching the shape the mockup
|
||||
template expects so the HTML table renders without changes.
|
||||
|
||||
The project's ``utterance_translations`` table is the pipeline's output,
|
||||
but today it holds one placeholder row; the real translations live in the
|
||||
on-disk sample files under ``samples/translations``. Both are read, and
|
||||
the DB wins where it holds a real translation.
|
||||
"""
|
||||
from umbraaudio.translations import resolved_translations
|
||||
from umbraaudio.web.routes import _get_active_config
|
||||
|
||||
cfg = _get_active_config(request)
|
||||
db = _project_db(request)
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
resolved = resolved_translations(con, cfg.project_dir, code)
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
rows: list[dict] = []
|
||||
for (chapter_id, index), entry in sorted(resolved.items()):
|
||||
rows.append(
|
||||
{
|
||||
"chapter_id": chapter_id,
|
||||
"index": index,
|
||||
"speaker": entry.speaker or "",
|
||||
"type": entry.type or "",
|
||||
"text": entry.text,
|
||||
"status": entry.status,
|
||||
"origin": entry.origin,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _total_utterances(request: Request) -> int:
|
||||
"""Total utterances in the project - the denominator for coverage."""
|
||||
db = _project_db(request)
|
||||
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as con:
|
||||
return con.execute("SELECT COUNT(*) FROM utterances").fetchone()[0]
|
||||
|
||||
|
||||
def _lang_audio_dir(request: Request, code: str) -> Path:
|
||||
"""Where per-language audio is written: <project>/Lang_test/<CODE>."""
|
||||
from umbraaudio.web.routes import _get_active_config
|
||||
|
||||
cfg = _get_active_config(request)
|
||||
return cfg.project_dir / "Lang_test" / code.upper()
|
||||
|
||||
|
||||
def _read_sample(root: Path, code: str) -> dict | None:
|
||||
path = root / f"deal_hunter_sample_ch1_{code.upper()}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
logger.exception("mockup: unreadable translation for %s", code)
|
||||
return None
|
||||
|
||||
|
||||
def _count_utterances(request: Request) -> int:
|
||||
"""Total utterances in the project - the denominator for both coverages."""
|
||||
db = _project_db(request)
|
||||
@@ -157,6 +196,42 @@ def _group_by_chapter(issues: list[dict]) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def _source_rows(request: Request, chapter_id: int = 1) -> dict[int, dict]:
|
||||
"""The project's own English text for *chapter_id*, keyed by index.
|
||||
|
||||
The source column of the mockup shows what the book actually says. The
|
||||
sample translation files paraphrase the English slightly, so reading the
|
||||
source from the DB keeps the two columns honest.
|
||||
"""
|
||||
db = _project_db(request)
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT utterance_index, speaker_name, type, text
|
||||
FROM utterances
|
||||
WHERE chapter_id = ?
|
||||
ORDER BY utterance_index
|
||||
""",
|
||||
(chapter_id,),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
logger.exception("mockup: utterances could not be read")
|
||||
return {}
|
||||
finally:
|
||||
con.close()
|
||||
return {
|
||||
row["utterance_index"]: {
|
||||
"index": row["utterance_index"],
|
||||
"speaker": row["speaker_name"] or "",
|
||||
"type": row["type"] or "",
|
||||
"text": row["text"] or "",
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
@router.get("/language-mockup", response_class=HTMLResponse)
|
||||
async def language_mockup_page(request: Request, lang: str = "de"):
|
||||
from umbraaudio.web.routes import templates
|
||||
@@ -166,15 +241,12 @@ async def language_mockup_page(request: Request, lang: str = "de"):
|
||||
|
||||
# No fallback path here on purpose: a silent fallback would render English
|
||||
# under a German heading and look like a working page.
|
||||
root = _translations_dir(request)
|
||||
source = _read_sample(root, SOURCE_LANGUAGE)
|
||||
target = _read_sample(root, code)
|
||||
target = _translation_rows(request, code)
|
||||
source = _source_rows(request)
|
||||
|
||||
total = _count_utterances(request)
|
||||
total = _total_utterances(request)
|
||||
|
||||
rows: list[dict] = []
|
||||
if target:
|
||||
src_by_idx = {u["index"]: u for u in (source or {"utterances": []})["utterances"]}
|
||||
adir = _lang_audio_dir(request, code)
|
||||
# Rendered clips are named utt<NN>_<Speaker>.wav, not utt<NNNN>.wav, so
|
||||
# match by index prefix rather than assuming the padded form.
|
||||
@@ -185,13 +257,13 @@ async def language_mockup_page(request: Request, lang: str = "de"):
|
||||
if m:
|
||||
audio_by_index[int(m.group(1))] = True
|
||||
|
||||
for u in target["utterances"]:
|
||||
src = src_by_idx.get(u["index"])
|
||||
for u in target:
|
||||
src = source.get(u["index"])
|
||||
rows.append(
|
||||
{
|
||||
"index": u["index"],
|
||||
"speaker": u.get("speaker", ""),
|
||||
"type": u.get("type", ""),
|
||||
"speaker": u.get("speaker", "") or (src["speaker"] if src else ""),
|
||||
"type": u.get("type", "") or (src["type"] if src else ""),
|
||||
"target_text": u["text"],
|
||||
"source_text": src["text"] if src else "",
|
||||
"has_audio": audio_by_index.get(u["index"], False),
|
||||
|
||||
@@ -163,7 +163,8 @@ CREATE TABLE IF NOT EXISTS project (
|
||||
source_format TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
status TEXT NOT NULL DEFAULT 'initialized'
|
||||
status TEXT NOT NULL DEFAULT 'initialized',
|
||||
source_language TEXT NOT NULL DEFAULT 'en'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -411,6 +412,14 @@ def _ensure_utterance_columns(db: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_utterance_translations_utt "
|
||||
"ON utterance_translations(utterance_id)"
|
||||
)
|
||||
# spec 31 section 13.1: the project's source language. An utterance
|
||||
# with no language tag is written in this language.
|
||||
project_cols = {r[1] for r in db.execute("PRAGMA table_info(project)")}
|
||||
if "source_language" not in project_cols:
|
||||
db.execute(
|
||||
"ALTER TABLE project ADD COLUMN source_language "
|
||||
"TEXT NOT NULL DEFAULT 'en'"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -3052,7 +3061,7 @@ async def preview_speaker_effects(request: Request, index: int, effects: str = "
|
||||
|
||||
|
||||
@router.get("/chapters-audio", response_class=HTMLResponse)
|
||||
async def chapters_audio_review(request: Request):
|
||||
async def chapters_audio_review(request: Request, lang: str | None = None):
|
||||
config = _get_active_config(request)
|
||||
|
||||
db_path = config.data_dir / "project.db"
|
||||
@@ -3082,6 +3091,9 @@ async def chapters_audio_review(request: Request):
|
||||
}
|
||||
)
|
||||
|
||||
languages = _languages_for_template()
|
||||
active_language = lang if lang in dict(languages) else _active_language_for_template(request)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chapters_audio.html",
|
||||
@@ -3089,6 +3101,9 @@ async def chapters_audio_review(request: Request):
|
||||
"request": request,
|
||||
"chapters": chapters,
|
||||
"total_chapters": len(chapters),
|
||||
"languages": languages,
|
||||
"active_language": active_language,
|
||||
"active_language_name": dict(languages).get(active_language, active_language),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3097,18 +3112,17 @@ ACTIVE_LANGUAGE_COOKIE = "active_language"
|
||||
|
||||
|
||||
def _active_language_for_template(request: Request) -> str:
|
||||
"""Resolve the active language cookie, defaulting to the project source language."""
|
||||
from umbraaudio.config import list_projects
|
||||
"""Resolve the active content language from the cookie.
|
||||
|
||||
cookie_lang = request.cookies.get(ACTIVE_LANGUAGE_COOKIE)
|
||||
# Default: project source language is English
|
||||
if not cookie_lang:
|
||||
return "en"
|
||||
# Accept the language code if it's a supported target language, otherwise fall back to source
|
||||
Defaults to English, which is the source language of every project that
|
||||
exists today. An unsupported cookie value is ignored rather than honoured:
|
||||
the registry is the authority on what can be shown.
|
||||
"""
|
||||
from umbraaudio.web.language_routes import SUPPORTED
|
||||
|
||||
cookie_lang = (request.cookies.get(ACTIVE_LANGUAGE_COOKIE) or "").strip()
|
||||
supported_codes = {code for code, _ in SUPPORTED}
|
||||
if cookie_lang in supported_codes:
|
||||
return cookie_lang
|
||||
return "en"
|
||||
return cookie_lang if cookie_lang in supported_codes else "en"
|
||||
|
||||
|
||||
def _languages_for_template() -> list[tuple[str, str]]:
|
||||
@@ -3118,10 +3132,19 @@ def _languages_for_template() -> list[tuple[str, str]]:
|
||||
|
||||
|
||||
@router.get("/chapters-audio/{chapter_dir}", response_class=HTMLResponse)
|
||||
async def chapter_audio_review(request: Request, chapter_dir: str):
|
||||
async def chapter_audio_review(request: Request, chapter_dir: str, lang: str | None = None):
|
||||
config = _get_active_config(request)
|
||||
|
||||
# Handle language selection: read from query param, set cookie, fall back to cookie
|
||||
supported_codes = {code for code, _ in _languages_for_template()}
|
||||
if lang and lang in supported_codes:
|
||||
active_language = lang
|
||||
# Will set cookie in response
|
||||
else:
|
||||
active_language = _active_language_for_template(request)
|
||||
|
||||
languages = _languages_for_template()
|
||||
languages_json = [{"code": code, "name": name} for code, name in languages]
|
||||
audio_base = config.data_dir / "chapter_audio" / chapter_dir
|
||||
|
||||
assignments_path = config.data_dir / "voice_assignments.json"
|
||||
@@ -3179,6 +3202,19 @@ async def chapter_audio_review(request: Request, chapter_dir: str):
|
||||
db.commit()
|
||||
alias_map = _load_speaker_alias_map(config)
|
||||
issues_by_index = _qa_issues_for_chapter(db, ch_id, alias_map) if ch_id else {}
|
||||
|
||||
# Fetch translations for the active language (if not English).
|
||||
#
|
||||
# Two stores hold translations: the pipeline's ``utterance_translations``
|
||||
# table and the on-disk sample files under ``samples/translations``. The
|
||||
# resolver merges them, keyed by (chapter_id, utterance_index), so a
|
||||
# language with a valid on-disk translation renders it instead of English.
|
||||
translations_map = {}
|
||||
if ch_id and active_language != "en":
|
||||
from umbraaudio.translations import resolved_translations
|
||||
|
||||
translations_map = resolved_translations(db, config.project_dir, active_language)
|
||||
|
||||
db.close()
|
||||
|
||||
title = chapter["title"] if chapter else f"Chapter {chapter_dir}"
|
||||
@@ -3203,19 +3239,31 @@ async def chapter_audio_review(request: Request, chapter_dir: str):
|
||||
idx = u["utterance_index"]
|
||||
speaker = (u["speaker_name"] or "Narrator").strip()
|
||||
speaker = alias_map.get(speaker.lower(), speaker)
|
||||
text = u["text"] or ""
|
||||
source_text = u["text"] or ""
|
||||
wav = existing_wav.get(idx)
|
||||
voice = assignments.get(speaker, "unknown")
|
||||
is_blank = not text.strip()
|
||||
is_blank = not source_text.strip()
|
||||
qa_issues = issues_by_index.get(idx, [])
|
||||
|
||||
# Get translated text if available for active language
|
||||
translated = translations_map.get((ch_id, idx))
|
||||
has_translation = translated is not None and translated.text.strip()
|
||||
display_text = translated.text if has_translation else source_text
|
||||
translation_status = translated.status if translated else None
|
||||
|
||||
audio_files.append(
|
||||
{
|
||||
"filename": wav,
|
||||
"index": idx,
|
||||
"utt_id": u["id"],
|
||||
"speaker": speaker,
|
||||
"text": text[:100] + "..." if len(text) > 100 else text,
|
||||
"full_text": text,
|
||||
"text": display_text[:100] + "..." if len(display_text) > 100 else display_text,
|
||||
"full_text": source_text,
|
||||
"display_text": display_text,
|
||||
"source_text": source_text,
|
||||
"translated_text": translated.text if has_translation else None,
|
||||
"translation_status": translation_status,
|
||||
"has_translation": has_translation,
|
||||
"voice": voice,
|
||||
"emotion": u["emotion"] or "neutral",
|
||||
"pace": u["pace"] or "moderate",
|
||||
@@ -3223,7 +3271,7 @@ async def chapter_audio_review(request: Request, chapter_dir: str):
|
||||
"is_blank": is_blank,
|
||||
"qa_issues": qa_issues,
|
||||
"qa_accepted": _qa_accepted_for(
|
||||
u["qa_accepted"], u["qa_accepted_sig"], speaker, text
|
||||
u["qa_accepted"], u["qa_accepted_sig"], speaker, source_text
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -3234,6 +3282,8 @@ async def chapter_audio_review(request: Request, chapter_dir: str):
|
||||
chapter_full_path = config.data_dir / "chapter_audio" / f"ch{ch_id}_full.wav"
|
||||
has_chapter_audio = chapter_full_path.exists()
|
||||
|
||||
# The active_language cookie is set by the language middleware in
|
||||
# web/__init__.py, so the choice survives on every page.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chapter_audio_detail.html",
|
||||
@@ -3249,7 +3299,9 @@ async def chapter_audio_review(request: Request, chapter_dir: str):
|
||||
"last_utt_id": last_utt_id,
|
||||
"has_chapter_audio": has_chapter_audio,
|
||||
"active_language": active_language,
|
||||
"active_language_name": dict(languages).get(active_language, active_language),
|
||||
"languages": languages,
|
||||
"languages_json": languages_json,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3511,7 +3563,8 @@ async def generate_utterance_audio(request: Request, utt_id: int):
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
utt = db.execute(
|
||||
"SELECT id, chapter_id, utterance_index, text, speaker_name, emotion, pace, instruct "
|
||||
"SELECT id, chapter_id, utterance_index, text, speaker_name, emotion, pace, instruct, "
|
||||
"language_code "
|
||||
"FROM utterances WHERE id = ?",
|
||||
(utt_id,),
|
||||
).fetchone()
|
||||
@@ -3527,6 +3580,12 @@ async def generate_utterance_audio(request: Request, utt_id: int):
|
||||
pace = utt["pace"] or "moderate"
|
||||
instruct = utt["instruct"] or ""
|
||||
|
||||
# The language the line is written in (spec 31 source axis). NULL on the
|
||||
# utterance means the project's source language.
|
||||
from umbraaudio.translations import source_language
|
||||
|
||||
line_language = utt["language_code"] or source_language(db)
|
||||
|
||||
db.close()
|
||||
|
||||
if not text.strip():
|
||||
@@ -3608,12 +3667,14 @@ async def generate_utterance_audio(request: Request, utt_id: int):
|
||||
voice_ref=voice_ref,
|
||||
ref_text=VOICE_REFERENCE_TEXT,
|
||||
instruct=tts_instruct or None,
|
||||
language=line_language,
|
||||
)
|
||||
else:
|
||||
engine.generate(
|
||||
text=spoken_text,
|
||||
output_path=output_path,
|
||||
instruct=tts_instruct or "A calm, neutral voice speaking clearly.",
|
||||
language=line_language,
|
||||
)
|
||||
if output_path.exists():
|
||||
explicit = load_speaker_distortion_effects(config.data_dir / "speakers.json").get(
|
||||
@@ -3632,6 +3693,7 @@ async def generate_utterance_audio(request: Request, utt_id: int):
|
||||
voice_ref=voice_ref,
|
||||
voice_mode="embedding" if voice_ref else None,
|
||||
instruct=tts_instruct or None,
|
||||
language=line_language,
|
||||
)
|
||||
|
||||
logger.info("Generated utterance %d audio: %s", utt_id, output_path)
|
||||
|
||||
@@ -185,12 +185,27 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if audio.is_blank %}<span class="badge badge-red" style="color:#ef4444">⚠ no text</span>{% endif %}
|
||||
<div id="text-display-{{ audio.index }}" onclick="editText({{ audio.index }})" style="cursor:pointer; padding:0.2rem; border-radius:3px" title="Click to edit">
|
||||
<small style="color:#94a3b8">{{ audio.text or '(click to add text)' }}</small>
|
||||
<div id="text-display-{{ audio.index }}" onclick="editText({{ audio.index }})" style="cursor:pointer; padding:0.2rem; border-radius:3px" title="Click to edit the source text">
|
||||
{% if active_language != 'en' %}
|
||||
{% if audio.has_translation %}
|
||||
<small style="color:#e0e0e0">{{ audio.display_text }}</small>
|
||||
<span style="color:#22c55e; font-size:0.7rem; margin-left:0.5rem">✓ {{ active_language_name }}</span>
|
||||
{% else %}
|
||||
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
|
||||
<span style="color:#f59e0b; font-size:0.7rem; margin-left:0.5rem">⚠ {{ active_language_name }} has not been translated yet</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="text-edit-{{ audio.index }}" style="display:none">
|
||||
{% if audio.has_translation %}
|
||||
<small style="color:#64748b; font-size:0.7rem; display:block; margin-bottom:0.2rem">
|
||||
Source text ({{ active_language_name }} shown above — editing changes the source, not the translation)
|
||||
</small>
|
||||
{% endif %}
|
||||
<textarea id="text-input-{{ audio.index }}" rows="2"
|
||||
style="width:100%; padding:0.3rem; background:#1a1a2e; border:1px solid #7c3aed; border-radius:3px; color:#e0e0e0; font-size:0.8rem">{{ audio.full_text }}</textarea>
|
||||
style="width:100%; padding:0.3rem; background:#1a1a2e; border:1px solid #7c3aed; border-radius:3px; color:#e0e0e0; font-size:0.8rem">{{ audio.source_text }}</textarea>
|
||||
<div style="margin-top:0.25rem; display:flex; gap:0.25rem">
|
||||
<button class="btn btn-approve" style="font-size:0.7rem; padding:0.15rem 0.4rem" onclick="saveText({{ audio.utt_id }}, {{ audio.index }})">Save</button>
|
||||
<button class="btn" style="font-size:0.7rem; padding:0.15rem 0.4rem; background:#374151; color:#9ca3af" onclick="cancelEditText({{ audio.index }})">Cancel</button>
|
||||
@@ -653,7 +668,8 @@ function savePersonalization() {
|
||||
|
||||
// Set the selected language from URL param if valid
|
||||
if (urlLang && select) {
|
||||
var validOptions = {{ languages.map(function(c, i) { return '{{ code }}'; }|join(',') }}); // simplified
|
||||
// languages_json is passed from the template as a JSON array of {code, name} objects
|
||||
var validOptions = {{ languages_json | tojson }};
|
||||
// Just set the selection - the page will reload on change
|
||||
select.value = urlLang || 'en';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user