Files
UmbraAudio/umbraaudio/generation/scene_break.py
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
The review UI's language dropdown did nothing: every selection kept
rendering the English text. Two independent causes, both fixed.

1. The translations were never being read. The pipeline's
   `utterance_translations` table holds one row in Deal Hunter, and that
   row stores the *English* text under a `zh-Hans` code. The real
   translations live in the on-disk sample files under
   `samples/translations/`. `umbraaudio/translations.py` now resolves both
   sources and merges them by (chapter_id, utterance_index): the table wins
   where it holds a real translation, the sample files fill the gaps. A
   candidate whose text is identical to the source is a placeholder, not a
   translation, so it is ignored - otherwise the page would show English
   under a Chinese heading and claim it was translated.

2. The query that read them was broken. `language_routes.py` selected
   `u.index` and `u.speaker`; the columns are `utterance_index` and
   `speaker_name`, so every read raised `sqlite3.OperationalError: near
   "index": syntax error` and `/language-mockup` returned 500. It also
   opened `<project>/project.db` - a 0-byte decoy - instead of
   `<project>/.umbra/project.db`, the file that actually holds the data.

Also in this commit:

* The chapter review page honours `?lang=`, remembers the choice in a
  cookie (set by a middleware, so it survives on every page), and shows the
  selected language's *name* in the "has not been translated yet" message.
* The page now shows the translation, not the source, and the edit box is
  bound to the source text so saving cannot overwrite the English with the
  Chinese.
* The stray language selector that sat after `</html>` in base.html is
  gone; the chapters-list page's dropdown had zero options because the
  route never passed the language list.
* The language tag is wired into generation: `utterance.language_code`
  (NULL = project source language) reaches `engine.generate()` and the
  staleness marker, so a retagged line is re-rendered instead of keeping
  English phonology behind an unchanged text. The marker records the
  language; a marker written before the tag existed means English, so the
  ~4,300 existing clips are not invalidated.
* `project.source_language` (spec 31 section 13.1) records the book's own
  language, with a migration for existing project DBs.
* A scene-break marker deliberately records no language: it is silence,
  identical in every language, and tagging it would claim a phonology the
  audio does not have.

Tests: 883 passed / 8 failed - the same 8 pre-existing failures
(2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests
cover the resolver and the generation wiring. ruff introduces no new
findings.
2026-11-06 16:55:49 -08:00

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