"""Regenerate ``tests/fixtures/golden_timeline.json`` and the demo array for the stage harness. uv run python tests/fixtures/make_golden_timeline.py Downstream of ``make_synth_fixtures.py``: it reads ``audio_query_short.json`` (こんにちは) and runs the real builder over it, so regenerating the synthesis fixtures without regenerating this one leaves the golden file describing an engine that is no longer installed. Needs no VOICEVOX wheel - ``visemes.build_timeline`` is a pure function over the committed JSON. The golden file is the regression guard behind ``test_golden_timeline``: if a ``voicevox_core`` bump changes a single mora length, every lip-sync in the app shifts, and this file is what turns that from an invisible aesthetic regression into a failing test. It also prints the ``DEMO_TIMELINE`` array for ``avatar/stage.html``, so the debug harness and the builder can never drift apart. """ from __future__ import annotations import json import sys from pathlib import Path FIXTURES = Path(__file__).resolve().parent REPO_ROOT = FIXTURES.parents[1] sys.path.insert(0, str(REPO_ROOT / "src")) SOURCE = "audio_query_short.json" GOLDEN = "golden_timeline.json" # こんにちは = k o N n i ch i w a. The N gives a closed mouth sandwiched between two vowels, # which is what makes this the right five-mora sentence for a visual smoke test. EXPECTED_VISEMES = [ "closed", # prePhonemeLength "closed", # k "oh", # コ "closed", # ン "closed", # n "ih", # ニ "closed", # ch "ih", # チ "closed", # w "aa", # ワ "closed", # postPhonemeLength ] def main() -> int: from japanese_avatar.voice.visemes import FRAMERATE, build_timeline query = json.loads((FIXTURES / SOURCE).read_text(encoding="utf-8")) events = build_timeline(query) rows = [{"t": e.t, "dur": e.dur, "viseme": e.viseme, "weight": e.weight} for e in events] visemes = [e.viseme for e in events] if visemes != EXPECTED_VISEMES: print( f"FAIL: {SOURCE} no longer produces the こんにちは mouth-shape sequence.\n" f" expected {EXPECTED_VISEMES}\n got {visemes}", file=sys.stderr, ) return 1 meta = json.loads((FIXTURES / "synth_meta.json").read_text(encoding="utf-8")) true_duration = meta["cases"]["short"]["duration_seconds"] total = sum(e.dur for e in events) if abs(total - true_duration) > 1 / FRAMERATE: print( f"FAIL: the built timeline totals {total} s but speech_ja.wav is {true_duration} s. " f"Regenerate the synthesis fixtures first.", file=sys.stderr, ) return 1 # Explicit LF: the default translates to CRLF on Windows, which would make a committed # fixture differ byte-for-byte depending on which OS last regenerated it. (FIXTURES / GOLDEN).write_text( json.dumps(rows, indent=2) + "\n", encoding="utf-8", newline="\n" ) print(f"wrote {FIXTURES / GOLDEN}: {len(rows)} events, {total} s") print("\nDEMO_TIMELINE for avatar/stage.html:\n") labels = ["prePhonemeLength", "k", "o", "N", "n", "i", "ch", "i", "w", "a", "postPhonemeLength"] for row, label in zip(rows, labels, strict=True): print( f" {{ t: {row['t']}, dur: {row['dur']}, " f"viseme: '{row['viseme']}', weight: {row['weight']} }}, // {label}" ) return 0 if __name__ == "__main__": raise SystemExit(main())