Files
UmbraAudio/umbraaudio/generation/staleness.py
B.D. Murphy 829a03b89f Action the controller alignment proposal: source_unit_key + phase reports
The controller (oldbox) sent a data-model alignment proposal and asked for six
additive changes on this node, the validation protocol, and an honest report.
Six of the seven changes are done; the seventh is declared missing rather than faked.

Stage 0 - reachability verified exactly as documented: health 200, heartbeat 401
(the correct answer - auth is refusing), missing Host 400. Nothing else was sent.

source_unit_key (controller 5.1, the blocking item)
  umbraaudio/source_units.py. sha256("uk1" | chapter_source_text_sha256 |
  occurrence | text_sha256). Nothing positional in the key: "occurrence"
  disambiguates repeated text in document order.
  - 18,181/18,181 units keyed across all four projects, 0 collisions
  - chapter 1 renumbered +100 -> 0 keys changed (the load-bearing proof)
  - backfill 10,015 rows in 0.11s, second run stamps 0 (idempotent)
  - alignment phase 3: 200/200 probe units resolved with used_index: false
  - tolerates the older projects that have no chapters.chapter_index
  Recorded at generation time in chapter_builder, BEFORE the rows are read and
  BEFORE the marker is written, so every path that renders audio also produces
  identity and the clip can name its source unit.

5.7 recorded: utterance_index is KEPT as a display ordinal and made
  non-load-bearing. spec 21 §10.

Blob hashes and effect chain (5.2)
  Markers now carry the full sha256 of the delivered bytes and the effects that
  produced them. Verified the existing order is already correct - write_marker
  runs AFTER apply_effects - and closed the remaining hole: a changed trait list
  changed the hash with no recorded cause. sha256 verification is opt-in, since
  hashing every file on every validation pass would re-read gigabytes.

Vocabularies published (5.4) - umbraaudio/definitions.py, 19 sets, 126 values,
  each value carrying whether it is 'declared' (spec/controller) or 'extracted'
  (read out of code), so a diff shows a difference of meaning, not of wording.

Alignment report tool (6.6) - scripts/alignment_report.py --phase all. Emits the
  controller's envelope with the per-phase evidence shapes, and obeys the two
  rules they set: no evidence means blocked, never pass; a field we cannot produce
  is null PLUS a finding, never omitted. Phase 2 is deliberately 'fail' - the
  three-way store disagreement is real, and a pass there would be a check that
  passed by doing nothing.

Fingerprint discipline (5.5) - the drift detector reported its OWN fingerprint
  change when source_unit_key was added rather than hiding it. Node profile
  updated with the new fingerprints and an honest note that they moved.

Node report extended - per-project source-unit-key coverage, per-voice-asset
  sha256 + object_key, definition_set_version, controller stage reached.

Six gaps found in the proposal (spec 21 §11), including the one they invited plus
five more. The sharpest: "hash at generation" ignores that audio is
post-processed afterwards, and our key is anchored to chapters.source_text - a
second copy of the manuscript - so any change to chapter storage silently
invalidates every key in that chapter.

5.6 deliberately NOT faked: personality has no versioned artifact here, only
  unversioned LLM prose in speakers.json. Reported as phase 6b finding B-03
  instead, because wrapping unversioned inputs in a version field would produce
  artifacts claiming a lineage they do not have.

Answered the controller's two open questions (infra/controller-handoff-2026-10-03.md):
forward-only link for a revealed recurring character, and yes - a new edition may
contradict a frozen state, since this node's correction path IS a regeneration.

Full suite 484 passed / 8 failed - the same 8 pre-existing and unrelated failures.
ruff clean on every file touched. The parallel session's chapter_builder hunk
(helper_url) was filtered out of the commit and left intact in the working tree.
2026-10-02 20:16:06 -07:00

165 lines
6.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
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,
) -> 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 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,
) -> 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 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"
)