japanese-learning-avatar / tests /test_transport_seam.py
WolfDavid's picture
fix(01-11): resume the AudioContext inside the gesture, refuse to speak on a stopped clock
6b68c4d
Raw History Blame
20.2 kB
"""The transport seam. If the gr.HTML spike fails, only the stagePort changes.
These tests exist so that stays true as the phase progresses. Plans 01-07 and 01-08
add turn behaviour; these tests fail if it lands in a transport file instead of in
the shared turn-loop module.
"""
import re
from pathlib import Path
import pytest
# Resolved from this file rather than the process cwd: the quick loop is documented as
# running from the repo root, but a relative Path() would make that a silent
# precondition of every assertion below.
AVATAR = Path(__file__).resolve().parent.parent / "avatar"
CORE = ["vrm-stage.js", "lipsync.js", "audio-queue.js"]
SHARED = ["facade.js", "turn-loop.js"]
TRANSPORTS = ["avatar.js", "avatar-iframe.js"]
GRADIO_TOKENS = ["gradio", "gradio_api", "server.", "trigger("]
AMPLITUDE_TOKENS = ["AnalyserNode", "getByteFrequencyData", "getFloatTimeDomainData"]
TURN_SURFACE = ["startListening", "stopListening", "dispatchTurn", "requestSlower"]
# Plan 01-07. Mic capture and ASR run in the PARENT document under both transports -
# only rendering and audio playback live inside the iframe - so these modules must hang
# off the shared turn loop, never off a transport.
AUDIO_IN = ["mic.js", "asr.js"]
# Wave 5. The host glue binds the page's controls to window.Avatar. It is loaded by the
# shared boot template in avatar_component.py, so both transports get the same controls;
# it must import no avatar module, be imported by none, and implement no turn behaviour.
HOST = "host.js"
COMPONENT = AVATAR.parent / "src" / "japanese_avatar" / "ui" / "avatar_component.py"
def src(name: str) -> str:
return (AVATAR / name).read_text(encoding="utf-8")
def avatar_surface() -> list[str]:
"""Parse AVATAR_SURFACE out of facade.js. Never duplicate the list in the test."""
m = re.search(r"AVATAR_SURFACE\s*=\s*\[(.*?)\]", src("facade.js"), re.S)
assert m, "AVATAR_SURFACE not found in avatar/facade.js"
return re.findall(r"'([a-zA-Z]+)'", m.group(1))
@pytest.mark.parametrize("name", CORE)
def test_stage_core_is_gradio_free(name):
lowered = src(name).lower()
for tok in GRADIO_TOKENS:
assert tok.lower() not in lowered, (
f"{name} references {tok!r}; the stage core must stay transport-agnostic"
)
@pytest.mark.parametrize("name", CORE)
def test_no_amplitude_lipsync(name):
for tok in AMPLITUDE_TOKENS:
assert tok not in src(name), f"{name} uses {tok!r}; AVTR-02 disqualifies amplitude lip-sync"
@pytest.mark.parametrize("name", SHARED)
def test_shared_modules_are_gradio_free(name):
lowered = src(name).lower()
for tok in GRADIO_TOKENS:
assert tok.lower() not in lowered, f"{name} must not know about Gradio"
def test_lipsync_uses_audiocontext_clock():
s = src("lipsync.js")
assert "currentTime" in s
assert "setTimeout" not in s
assert "requestAnimationFrame" not in s # the stage owns the loop; the player is a tick fn
def test_module_urls_are_deps_pinned():
"""The three runtime modules are the VENDORED, version-pinned builds (plan 01-10).
Until 01-10 this asserted the esm.sh ``?deps=three@0.185.1`` URLs; the pin now lives
in scripts/vendor_modules.py and the stage imports the files it wrote. Every one of
the three must resolve relative to the module, so that the app
(/gradio_api/file=avatar/...) and the standalone stage.html share one rule.
"""
s = src("vrm-stage.js")
for name in ("three.mjs", "GLTFLoader.mjs", "three-vrm.mjs"):
assert f"resolveModuleUrl('./vendor/{name}')" in s, f"{name} is not vendored-resolved"
assert "esm.sh/" not in s, "a CDN URL is back in the render path"
assert "import.meta.url" in s
assert "importmap" not in s
def test_only_facade_assigns_window_avatar():
"""One facade builder. Two transports cannot drift if only one of them writes the object."""
for p in AVATAR.glob("*.js"):
assigns = re.search(r"window\.Avatar\s*=", p.read_text(encoding="utf-8"))
if p.name == "facade.js":
assert assigns, "facade.js must be the one that installs window.Avatar"
else:
assert not assigns, f"{p.name} assigns window.Avatar; only facade.js may"
@pytest.mark.parametrize("name", TRANSPORTS)
def test_transports_only_do_plumbing(name):
"""A transport builds a stagePort and delegates. It never implements turn behaviour."""
s = src(name)
assert "installFacade" in s and "createTurnLoop" in s, (
f"{name} must use the shared facade/turn loop"
)
for member in TURN_SURFACE:
assert f"function {member}" not in s and f"{member}(text" not in s, (
f"{name} implements {member}; turn behaviour belongs in avatar/turn-loop.js "
"or the iframe fallback silently loses it"
)
@pytest.mark.parametrize(
"member",
[
"mount",
"speak",
"replay",
"setListening",
"setThinking",
"on",
"getDebug",
"startListening",
"stopListening",
"dispatchTurn",
"requestSlower",
"unlockAudio",
],
)
def test_surface_member_is_declared_once(member):
"""Every promised method must resolve in facade.js's surface list."""
assert member in avatar_surface()
@pytest.mark.parametrize("member", TURN_SURFACE)
def test_turn_surface_lives_in_the_shared_module(member):
assert member in src("turn-loop.js"), (
f"{member} must be defined in avatar/turn-loop.js so BOTH transports get it"
)
@pytest.mark.parametrize(
"key",
[
"ready",
"mountCount",
"vrmMetaTitle",
"threeInstanceCount",
"currentVisemes",
"blinkValue",
"clockOffset",
"transport",
],
)
def test_debug_contract_keys_present(key):
joined = src("facade.js") + src("turn-loop.js") + src("vrm-stage.js")
assert key in joined, f"__debug.{key} is required by 01-VALIDATION.md Observable Signals"
@pytest.mark.parametrize("name", AUDIO_IN)
def test_audio_input_modules_are_gradio_free(name):
lowered = src(name).lower()
for tok in GRADIO_TOKENS:
assert tok.lower() not in lowered, (
f"{name} references {tok!r}; mic capture and ASR must stay host-agnostic"
)
@pytest.mark.parametrize("name", AUDIO_IN)
def test_audio_input_modules_hang_off_the_turn_loop(name):
"""Imported by turn-loop.js, by neither transport.
If a transport imported these directly, the other transport would silently lose
push-to-talk - the exact failure mode the shared turn loop exists to prevent.
"""
assert f"./{name}" in src("turn-loop.js"), (
f"avatar/turn-loop.js must import {name} so BOTH transports get it at once"
)
for transport in TRANSPORTS:
assert name not in src(transport), (
f"{transport} imports {name}; mic/ASR wiring belongs in avatar/turn-loop.js"
)
@pytest.mark.parametrize("name", AUDIO_IN)
def test_audio_input_modules_do_not_do_amplitude_lipsync(name):
"""RMS in mic.js gates ASR. It must never become a lip-sync source (AVTR-02)."""
for tok in AMPLITUDE_TOKENS:
assert tok not in src(name), f"{name} uses {tok!r}; AVTR-02 disqualifies amplitude lip-sync"
def test_blocklist_does_not_swallow_ordinary_japanese():
"""The bare polite form is a thing a learner says out loud.
Blocking it would be worse than the hallucination it prevents, and this guard exists
because that entry is exactly what a later "helpful" edit would add.
"""
entries = re.findall(r"^\s*'([^']+)',\s*$", src("mic.js"), re.M)
assert "ご視聴ありがとうございました" in entries, (
"the subtitle-boilerplate blocklist is missing its canonical entry"
)
assert "ありがとうございました" not in entries, (
"the bare polite form is blocklisted; it is ordinary Japanese, not a hallucination"
)
def test_gate_thresholds_match_the_measured_fixtures():
"""The three constants the gate lives or dies by, pinned to docs/ASR-TIERS.md.
Measured: cafe_noise_30s.wav modulates 1.957, speech_ja.wav modulates 10.716. A
threshold above ~10 would reject real speech; below ~2.0 would pass the noise fixture.
"""
s = src("mic.js")
assert "MIN_DURATION_MS: 300" in s
assert "MIN_RMS: 0.01" in s
assert "MIN_MODULATION: 2.5" in s
assert "BLOCKLIST_MAX_MS: 1500" in s
assert "REARM_TAIL_MS = 200" in s
def test_no_remote_asr_path_exists():
"""Tier D would burn the exact visitor GPU quota SC-4 tests the loop's survival
without, so no hosted transcription path may exist anywhere in avatar/."""
for name in ["asr.js", "mic.js", "turn-loop.js"]:
lowered = src(name).lower()
for tok in ["zerogpu", "spaces.gpu", "/api/asr", "@spaces"]:
assert tok not in lowered, (
f"{name} references {tok!r}; browser ASR must cost zero GPU quota"
)
def test_asr_module_url_is_pinned():
s = src("asr.js")
assert s.count("https://esm.sh/@huggingface/transformers@4.2.0") == 1, (
"exactly one pinned runtime URL, same discipline as vrm-stage.js"
)
assert "importmap" not in s
def test_asr_webgpu_fallback_is_real_not_aspirational():
"""The fallback must probe for an ADAPTER, not merely for navigator.gpu.
Measured on this stack: when navigator.gpu exists but requestAdapter() resolves to
null, calling pipeline(..., {device:'webgpu'}) throws AND poisons the ONNX Runtime
backend registry for the whole page, so the subsequent WASM re-instantiation fails
with the same WebGPU error. Catch-and-retry alone is therefore not a working
fallback; the doomed call must never be made. Deleting this assertion re-introduces
a bug that only shows up on the machines the fallback exists for.
"""
s = src("asr.js")
assert "navigator.gpu" in s
assert "webgpu" in s
assert "requestAdapter" in s, (
"avatar/asr.js checks navigator.gpu but never requests an adapter; that is the "
"exact naive check that ships a broken app to every machine without a GPU"
)
assert re.search(r"catch\s*\(", s), "no catch around the WebGPU init path"
assert s.count("language: 'ja'") == 1
@pytest.mark.parametrize("key", ["asrTier", "asrModel", "lastTranscript", "micRejectedCount"])
def test_push_to_talk_debug_keys_present(key):
assert key in src("turn-loop.js"), (
f"__debug.{key} is contributed by avatar/turn-loop.js, so both transports report it"
)
@pytest.mark.parametrize("member", ["startListening", "stopListening"])
def test_push_to_talk_is_implemented_not_deferred(member):
"""The two methods this plan owns must no longer be notWiredYet stubs."""
s = src("turn-loop.js")
assert f"notWiredYet('{member}'" not in s, (
f"{member} is still a deferred stub in avatar/turn-loop.js"
)
assert f"async {member}(" in s, f"{member} must be implemented in avatar/turn-loop.js"
@pytest.mark.parametrize("name", TRANSPORTS)
def test_transports_gained_push_to_talk_without_gaining_code(name):
"""The whole point of the seam: the iframe fallback got this feature for free."""
s = src(name)
for token in ["startListening", "stopListening", "mic.", "asr.", "createMic", "createAsr"]:
assert token not in s, (
f"{name} mentions {token!r}; push-to-talk must live only in avatar/turn-loop.js"
)
@pytest.mark.parametrize("member", ["dispatchTurn", "requestSlower"])
def test_turn_dispatch_is_implemented_not_deferred(member):
"""The wave-5 methods are real, and nothing in the file is a deferred stub any more."""
s = src("turn-loop.js")
assert "notWiredYet" not in s, "a deferred stub survives in avatar/turn-loop.js"
assert f"{member}(" in s, f"{member} must be implemented in avatar/turn-loop.js"
def test_thinking_engages_before_the_first_await():
"""VOIC-05's cheapest latency mitigation: the pose switches at dispatch, not at response.
Verified by line number inside dispatchTurn: setThinking(true) precedes the first
await, which is the server call.
"""
lines = src("turn-loop.js").splitlines()
start = next(i for i, line in enumerate(lines) if "async function dispatchTurn(" in line)
body = lines[start:]
think_at = next(i for i, line in enumerate(body) if "setThinking(true)" in line)
await_at = next(i for i, line in enumerate(body) if "await " in line)
assert think_at < await_at, (
f"setThinking(true) is on dispatchTurn line {think_at} but the first await is on "
f"line {await_at}; the thinking pose must engage before the server round trip"
)
assert "await bridge." in body[await_at], body[await_at]
def test_replay_is_networkless_by_construction():
"""replay() re-emits the cached buffer. No fetch, no dynamic import, anywhere in the loop."""
s = src("turn-loop.js")
assert "fetch(" not in s
assert "import(" not in s
assert "XMLHttpRequest" not in s
def test_turn_loop_publishes_the_latency_numbers():
s = src("turn-loop.js")
for key in ("lastTurnMs", "lastStageTimings", "lastSubtitle", "replayCount", "turnCount"):
assert f"{key}:" in s, f"__debug.{key} is not initialised in avatar/turn-loop.js"
for mark in ("turn:dispatch", "turn:response", "turn:speech-start"):
assert f"performance.mark('{mark}')" in s, f"performance.mark({mark!r}) missing"
assert "0.75" in s, "the slower speed must be the VOICEVOX speedScale 0.75"
assert "greeting" in s
def test_host_glue_is_neither_a_transport_nor_the_turn_loop():
"""host.js is page glue: loaded from the shared boot template, importing nothing here."""
host = src(HOST)
assert not re.search(r"^\s*import\s", host, re.M), (
"host.js must not import any avatar module; it is handed the facade"
)
for member in TURN_SURFACE:
assert f"function {member}" not in host and f"async {member}(" not in host, (
f"host.js implements {member}; turn behaviour belongs in avatar/turn-loop.js"
)
for member in ["dispatchTurn", "requestSlower", "replay", "startListening", "stopListening"]:
assert f".{member}(" in host, f"host.js never calls Avatar.{member}()"
for name in [*TRANSPORTS, *SHARED, *CORE, *AUDIO_IN]:
assert HOST not in src(name), f"{name} references {HOST}; only the boot template may"
component = COMPONENT.read_text(encoding="utf-8")
assert component.count(f"import('/gradio_api/file=avatar/{HOST}')") == 1, (
"the shared boot template must load host.js exactly once, for both transports"
)
assert "bindHost(avatar, document)" in component
# ------------------------------------------------------------------ plan 01-11: audio unlock
#
# The owner heard nothing on a phone. The only resume() on the turn path lived in
# playBuffer, AFTER the server round trip and therefore outside the tap's user activation;
# a gesture-gated browser leaves the context suspended, and every test had been masking it
# with --autoplay-policy=no-user-gesture-required. These guards pin the fix statically; the
# strict-policy browser tests prove it behaviourally at three layers.
# The four turn-loop methods a gesture reaches, by the signature line each starts on.
GESTURE_ENTRY_POINTS = [
"async function dispatchTurn(",
"async replay()",
"async requestSlower()",
"async startListening()",
]
UNLOCK_CALL = "stagePort.unlockAudio()"
def _function_body(source: str, signature: str) -> list[str]:
"""The lines of the function whose signature line contains ``signature``, from the
signature to its closing brace, found by brace counting from the signature's ``{``."""
lines = source.splitlines()
start = next(i for i, line in enumerate(lines) if signature in line)
depth = 0
opened = False
for i in range(start, len(lines)):
depth += lines[i].count("{") - lines[i].count("}")
if "{" in lines[i]:
opened = True
if opened and depth == 0:
return lines[start : i + 1]
raise AssertionError(f"unbalanced braces after {signature!r}")
def _statements(body: list[str]) -> list[str]:
"""The body's lines after the signature, minus blank lines and comment-only lines."""
out = []
for line in body[1:]:
stripped = line.strip()
if not stripped or stripped.startswith(("//", "/*", "*")):
continue
out.append(stripped)
return out
@pytest.mark.parametrize("name", TRANSPORTS)
def test_stage_port_unlocks_audio_under_both_transports(name):
"""unlockAudio is a stagePort method on BOTH transports, and the iframe stage answers
the message the iframe transport sends for it."""
assert "unlockAudio" in src(name), f"{name} builds a stagePort with no unlockAudio"
assert "unlockAudio" in avatar_surface(), "unlockAudio is not in AVATAR_SURFACE"
assert "'avatar:unlockAudio'" in src("avatar-iframe.js")
assert "'avatar:unlockAudio'" in src("stage.html"), (
"avatar/stage.html has no handler for the iframe transport's unlock message"
)
def test_inline_unlock_is_synchronous():
"""The inline transport must CALL resume() synchronously, never await it: the call has
to happen in the gesture's stack, and a blocked resume() promise never settles."""
body = _function_body(src("avatar.js"), "unlockAudio()")
joined = "\n".join(body)
assert "audioCtx.resume()" in joined
assert "await" not in joined, "unlockAudio must not await; the CALL is the unlock"
assert "return audioCtx.state" in joined
@pytest.mark.parametrize("signature", GESTURE_ENTRY_POINTS)
def test_turn_loop_unlocks_audio_before_its_first_await(signature):
"""Line-order proof, in the spirit of test_thinking_engages_before_the_first_await:
the unlock is the FIRST statement of every gesture-reachable entry point, so it runs
inside the gesture's call stack - anything after an await does not."""
body = _function_body(src("turn-loop.js"), signature)
statements = _statements(body)
assert statements and statements[0].startswith(UNLOCK_CALL), (
f"{signature} does not start with {UNLOCK_CALL}; first statement is {statements[:1]}"
)
awaits = [i for i, line in enumerate(statements) if "await " in line]
if awaits:
assert awaits[0] > 0, f"{signature} awaits before it unlocks"
def test_turn_loop_unlock_count():
assert src("turn-loop.js").count(UNLOCK_CALL) >= 4
def test_host_unlocks_audio_in_every_gesture_handler():
"""Belt and braces: the host unlocks first thing in all six gesture handlers, so a
gesture that never reaches the loop (Send with an empty box) still unlocks."""
host = src(HOST)
assert host.count("avatar.unlockAudio()") >= 6, (
f"host.js calls avatar.unlockAudio() {host.count('avatar.unlockAudio()')} time(s); "
"every gesture handler (send, Enter, hello, replay, slower, ptt) must call it first"
)
for handler in ("'click'", "'keydown'", "'pointerdown'"):
assert handler in host
def test_audio_queue_refuses_to_fake_speech_on_a_stopped_clock():
"""playBuffer must check the context is running BEFORE it emits speech-start, and must
report the blocked state as an 'error' rather than scheduling against a dead clock."""
s = src("audio-queue.js")
assert "state !== 'running'" in s
assert s.index("state !== 'running'") < s.index("emit('speech-start'"), (
"audio-queue.js emits speech-start before it checks audioCtx.state"
)
assert "where: 'audio'" in s
# A blocked resume() promise never settles in Chromium; the wait must be bounded.
assert "Promise.race" in s and "RESUME_GRACE_MS" in s
@pytest.mark.parametrize("name", ["avatar.js", "stage.html", "facade.js"])
def test_audio_state_is_published(name):
"""audioState is a published field on getDebug() from both transports and the
standalone harness, so the strict-policy tests assert a number, not a silence."""
assert "audioState" in src(name), f"{name} does not publish audioState"