japanese-learning-avatar / tests /fixtures /make_golden_timeline.py
WolfDavid's picture
feat(01-06): add the golden timeline fixture and its regenerator
02b0716
Raw History Blame
3.5 kB
"""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())