Files
UmbraAudio/umbraaudio/generation/staleness.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

186 lines
7.6 KiB
Python

"""Utterance-audio staleness guard.
Per-utterance audio is keyed by index (utt_XXXX.wav). Indexes stay stable
across re-segmentation while the underlying text changes, so a "file exists"
check silently reuses audio that no longer matches the utterance text. Each
wav gets a sidecar marker (utt_XXXX.wav.txt) recording the exact spoken text
AND the assigned speaker; a file is only considered current when both match.
Markers are JSON ``{"speaker": ..., "text": ..., "voice_fp": ..., "instruct": ...,
"sha256": ...}``. Legacy plain-text markers carry no speaker information, so they are
always treated as stale. The ``voice_fp`` field is a truncated SHA-256 of the
voice-reference wav used during generation; when a speaker's ref changes
(e.g. gender re-assignment) the fingerprint no longer matches and the file
is considered stale, forcing regeneration.
``sha256`` is the FULL content hash of the rendered wav, recorded at generation
(specs/21 §9.1 M4-04 / controller §5.2: "every blob gets its hash at generation, not at
export"). It is recorded but **not** verified by default: hashing ~10k files on every
validation pass would re-read gigabytes. Pass ``verify_sha256=True`` when an integrity
check is actually wanted.
"""
from __future__ import annotations
import hashlib
import json
from typing import TYPE_CHECKING
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)."""
return wav_path.with_suffix(".wav.txt")
def _voice_fingerprint(voice_ref: Path) -> str:
"""Truncated SHA-256 fingerprint of a voice-reference wav file."""
h = hashlib.sha256(voice_ref.read_bytes()).hexdigest()
return h[:16]
def file_sha256(path: Path) -> str | None:
"""Full SHA-256 of a file, or None if it cannot be read."""
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return None
def is_current(
text: str,
wav_path: Path,
speaker: str | None = None,
voice_ref: Path | None = None,
voice_mode: str | None = None,
accepted_voice_modes: set[str] | None = None,
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
``voice_fp`` must also match the current ref's fingerprint; when no
ref is given the fingerprint check is skipped (backward compatible).
If *instruct* is provided the marker's ``instruct`` must match too
(a changed emotion/variation therefore invalidates old audio).
Mode handling: if *voice_mode* is given (e.g. ``"icl"``) the marker must
record that exact mode; otherwise if *accepted_voice_modes* is given the
marker's mode just needs to be one of the accepted set (e.g.
``{"embedding", "icl"}``) so a historical mixed-mode chapter is treated as
current. When *neither* is given the mode check is skipped.
This lets a generation-technology change (e.g. embedding -> a brand-new
mode) invalidate previously generated audio, while tolerating equivalent
voice-identity modes (embedding/icl) that share the same ref fingerprint."""
if not wav_path.exists():
return False
marker = marker_path(wav_path)
if not marker.exists():
return False
try:
payload = json.loads(marker.read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
if not isinstance(payload, dict):
return False
if payload.get("text") != text:
return False
if speaker is not None and payload.get("speaker") != speaker:
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
expected_fp = _voice_fingerprint(voice_ref)
if payload.get("voice_fp") != expected_fp:
return False
recorded = payload.get("voice_mode")
if voice_mode is not None and recorded != voice_mode:
return False
if (
voice_mode is None
and accepted_voice_modes is not None
and recorded not in accepted_voice_modes
):
return False
if fx_version is not None and payload.get("fx_version") != fx_version:
return False
if verify_sha256:
recorded = payload.get("sha256")
if not recorded or recorded != file_sha256(wav_path):
return False
return not (fx_version is None and payload.get("fx_version") is not None)
def write_marker(
text: str,
wav_path: Path,
speaker: str | None = None,
voice_ref: Path | None = None,
voice_mode: str | None = None,
instruct: str | None = None,
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
(the exact TTS instruction used) it is recorded too, so a later run with a
different emotion/instruct regenerates instead of reusing the file.
*fx_version* records which post-processing emotion recipe (if any) was
stamped onto the audio; a bump forces regeneration of fx'd files.
The rendered wav's own full SHA-256 is always recorded when the file exists,
so every generated blob carries its content hash from the moment it is born
rather than at export time."""
payload: dict[str, str] = {"speaker": speaker or "", "text": text}
if wav_path.exists():
digest = file_sha256(wav_path)
if digest:
payload["sha256"] = digest
if voice_ref is not None and voice_ref.exists():
payload["voice_fp"] = _voice_fingerprint(voice_ref)
if voice_mode is not None:
payload["voice_mode"] = voice_mode
if instruct is not None:
payload["instruct"] = instruct
if fx_version is not None:
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
# trait list would change the hash with no explanation (spec 21 §12 gap G-A).
payload["effects"] = list(effects)
marker_path(wav_path).write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)