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.
80 lines
2.7 KiB
Python
80 lines
2.7 KiB
Python
"""Scene-break (``<<<>>>``) audio handling.
|
|
|
|
A scene break is not spoken text; it marks a change of location, time, or
|
|
perspective. Historically these rows produced no audio at all, which left
|
|
behind 0-byte ``utt_XXXX.wav`` artifacts that broke the chapter stitcher
|
|
(``soundfile`` cannot open an empty file) and gave the utterance regen button
|
|
nothing to show. Every scene break now gets a fixed-length silence clip so
|
|
the final chapter audio carries the intended pause and the stitcher always has
|
|
a valid file to read.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import numpy as np
|
|
import soundfile as sf
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from umbraaudio.generation.staleness import is_current, write_marker
|
|
|
|
SCENE_BREAK_TEXT = "<<<>>>"
|
|
SCENE_BREAK_SPEAKER = "scene_break"
|
|
SCENE_BREAK_SILENCE_MS = 2000
|
|
DEFAULT_SAMPLE_RATE = 24000
|
|
_WAV_HEADER_BYTES = 44
|
|
|
|
|
|
def is_scene_break(text: str | None, utt_type: str | None = None) -> bool:
|
|
"""True when an utterance is a scene break (by type or by its text)."""
|
|
if (utt_type or "").strip() == SCENE_BREAK_SPEAKER:
|
|
return True
|
|
return (text or "").strip() == SCENE_BREAK_TEXT
|
|
|
|
|
|
def _is_valid_wav(path: Path) -> bool:
|
|
try:
|
|
return path.exists() and path.stat().st_size > _WAV_HEADER_BYTES
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def needs_silence(path: Path) -> bool:
|
|
"""True when *path* is missing, empty/corrupt, or lacks a matching marker.
|
|
|
|
A 0-byte file (a historical artifact) must trigger a rewrite, not be
|
|
treated as "already generated".
|
|
"""
|
|
if not _is_valid_wav(path):
|
|
return True
|
|
return not is_current(SCENE_BREAK_TEXT, path, speaker=SCENE_BREAK_SPEAKER)
|
|
|
|
|
|
def write_scene_break_audio(
|
|
output_path: Path,
|
|
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.
|
|
|
|
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():
|
|
output_path.unlink()
|
|
sf.write(str(output_path), np.zeros(samples, dtype=np.float32), sample_rate)
|
|
write_marker(
|
|
SCENE_BREAK_TEXT,
|
|
output_path,
|
|
speaker=SCENE_BREAK_SPEAKER,
|
|
)
|
|
return output_path
|