Jim's browser-rendered avatar. Presentation only: Gemini Live, LiveKit audio and transcription, barge-in, and interviewer behavior are unchanged by anything here. No rendered frame is published as a LiveKit track, sent to the server, or stored.
Related documents: third-party notices for library
licenses; the model license record
for the model's grant and attribution; web/vendor/avatar/README.md for
reproducible bundle sources.
Shipping. The renderer, behavior mapping, and fallback are in the repo. The model is not: it is fetched by the browser from a pinned upstream URL and the repo carries only its hash and its license record.
| Field | Value |
|---|---|
| Source | Fetched by the browser from the pinned URL in web/avatar/model.js |
| Size | 10,917,800 bytes (10.92 MB) |
| SHA-256 | 624d0d554bc205bbdc33e22a68a2c3c20edebb3e573011ead8878a65e5329b23 |
| Spec version | VRM 1.0 |
| Triangles | 45,058 |
| Materials | 17 |
| Texture resolution | 1024x1024 maximum |
| glTF extensions | VRMC_vrm, VRMC_springBone, VRMC_node_constraint, VRMC_materials_mtoon, KHR_materials_unlit, KHR_texture_transform, KHR_materials_emissive_strength |
See the model license record for the model's source, attribution, and license terms.
17 materials against a budget of 1 to 3, and VRMC_springBone physics against
"minimal physics." The model's grant is recorded in the
model license record.
Revisit the budget when a commissioned model exists, not before. If render cost becomes a problem, measure it first.
Budget for a commissioned replacement: upper body only, 1 to 3 materials, 512 to 1024 px textures, minimal physics, 5 to 15 MB.
The size half of that budget is enforced rather than advisory. MODEL_MAX_BYTES
in web/avatar/model.js is the 15 MB top of this range, and the fetch refuses a
declared Content-Length above it before reading the body and stops the read
once the streamed total passes it. So the number now says two things: what a
commission may weigh, and what a candidate's browser will accept from the
model's origin. Raising one raises the other, in the same edit.
What that bounds precisely is the bytes accepted from the response body, plus
one chunk of overshoot before the read stops. It is not a bound on how much
memory the load costs: the body is copied once into the buffer that is hashed,
and store copies it again into a Response on the way to the cache, so a
model at the ceiling occupies a multiple of it for as long as the parse runs.
Two things keep that multiple constant rather than open-ended. The read grows
one buffer instead of keeping a chunk per read, because how many chunks a body
arrives in is the origin's choice and not the size's, and an array of them costs
heap in proportion to the splitting rather than to the bytes. And
the fetch carries the renderer's own LOAD_TIMEOUT_MS as an abort signal, so an
origin that trickles under the ceiling forever is a bounded cost rather than a
permanent one.
The delivered VRM must be uncompressed glTF: no KHR_draco_mesh_compression,
no KHR_texture_basisu, no EXT_meshopt_compression. The vendored bundle
registers no decoder for any of them, and GLTFLoader rejects outright with
"no DRACOLoader instance provided" rather than degrading. This matters because
a 5 to 15 MB budget is exactly the pressure that pushes an author toward Draco
or KTX2, and discovering it at delivery time would mean re-vendoring the bundle
with three more decoders for one model. The shipped model satisfies this.
Document the replacement's source, license, attribution requirements, and redistribution rights in the model license record.
The renderer uses the vendored bundle at web/vendor/avatar/three-vrm.js.
Its components, licenses, and reproducible source details are listed in
the third-party notices.
@pixiv/three-vrm and GLTFLoader.js import three by bare specifier. A
browser resolves that only through an import map, import maps cannot be loaded
from a src URL, and so one would have to be inlined into
web/interview.html. src/web/policy.rs sets script-src 'self' with no
'unsafe-inline' and no nonce, so an inline import map would be blocked, and
the alternative is loosening the page's script policy for a build-time
convenience. Bundling resolves the specifiers at vendor time and leaves the CSP
alone.
The same reasoning applies to web/recording/index.html when recording task 8a
builds it: import the same vendored bundle, do not add an import map.
#jim-avatar carries data-avatar-state, and that attribute is the contract.
web/avatar/avatar.js owns the value; web/styles.css owns the appearance.
| State | Meaning | What the candidate sees |
|---|---|---|
loading |
the page is asking whether a model exists | neutral panel |
ready |
a VRM is loaded and rendering | the canvas |
unavailable |
no model, no WebGL, a load failure, or a load timeout | neutral panel, with the reason in #jim-avatar-note |
degraded |
Jim's audio was arriving late, or the live render budget was exceeded | the box is removed and the yellow presence banner explains |
stopped |
the interview ended, the room was left, or the candidate hid the avatar | the box is removed when the candidate hid it; neutral panel when the interview ended or the room was left |
Only ready hides the neutral panel, so a state nobody has invented yet
degrades to the panel rather than to an empty box.
Every failure lands on unavailable: an unreachable model, a browser with no
WebGL, bytes that miss the pin, a corrupt model, and a host that accepts the
connection and then hangs are one path, not five. The load timeout is
LOAD_TIMEOUT_MS, 60000 ms, and it covers the download as well as the parse.
Two timers rather than one, because the race in web/avatar/avatar.js decides
when the panel appears and cannot cancel what it stopped waiting for.
web/avatar/model.js imports that same constant and hands it to the fetch as an
AbortSignal, rather than keeping a second copy of the number: a panel that
gives up while the socket is still open is how a hung host used to keep a
buffer, a reader and a retry alive for the rest of the page's life, and the two
timers only agree if they are one number. The deadline covers the retry as well
as the first attempt, being created once per load, so a transient failure cannot
buy a second 60 seconds.
web/avatar/model.js owns both halves of the pin, the URL and the hash, and
downloads it only when a visible avatar starts.
That is a separate file from the renderer on purpose: none of it touches
Three.js, web/avatar/vrm.js is the one file that does, and the split is what
lets node --test drive the cache branches and the pin without a browser.
Every byte handed to the loader is checked against MODEL_SHA256 there first,
on the cache path as much as the network path: a cached entry is only as
trustworthy as whatever last wrote to the origin's storage, and re-hashing
11 MB costs tens of milliseconds against a download that costs seconds. Bytes
that miss the pin are never rendered, and a cached entry that misses it is
deleted and re-fetched. The renderer is handed the verified ArrayBuffer
through GLTFLoader.parseAsync, so no URL it could re-fetch ever reaches it.
The bytes come first, before the renderer bundle and before the WebGL context.
Before the bundle, because both orders are serial and cost the same when the
model is reachable, while only this one avoids paying 730 KB of Three.js for an
avatar that was never going to render. stage.js and recording.js import
model.js, which is 3 KB from this origin, and only reach for vrm.js once
they are holding verified bytes.
Before the context, because createAvatar can time out a stalled fetch but
cannot cancel it. A context allocated in front of that await is one of the
browser's ~16 held for the rest of the interview, under a neutral panel whose
whole claim is that no canvas exists. A load that completes late is disposed by
createAvatar; one that never completes is not. loadVrm takes bytes rather
than a URL, so this ordering is a property of its signature and not of the
order somebody wrote two statements in.
The tempting objection is that a browser with no WebGL now pays for the download before finding out in milliseconds that it cannot render. That is the trade: an unreachable model is the common failure and a missing WebGL context is the rare one.
Verification has no fallback. A browser with no crypto.subtle, which means an
insecure context, gets the neutral panel rather than 11 MB of unverified
third-party geometry. Storage does have one: caches.open rejects in Firefox
private browsing and cache.put rejects once 11 MB will not fit, and both of
those describe a browser that can still download and render the model
perfectly. Those failures are swallowed and cost a download next time. The
cache is named after the hash, so a model swap can never read a stale entry,
and opening it deletes any superseded cache under the same prefix.
The pinned URL is raw.githubusercontent.com, which is a git host and not a
CDN. It is rate limited, it answers cache-control: max-age=300, and a
commit-pinned path survives a force-push but not a repo rename or deletion. In
exchange the release binary is 11 MB smaller and the bytes are never
redistributed by this project. A deployment that cannot accept a third-party
runtime dependency during an interview should serve the model from its own
origin and change MODEL_URL and MODEL_SHA256 in web/avatar/model.js and
AVATAR_MODEL_ORIGIN in src/web/policy.rs together. Two tests in
tests/browser/avatar-model.test.js hold that URL down, and both are in the
node --test lane rather than the Rust one: one asserts it sits under the
origin the CSP permits, because that drift is otherwise invisible, the download
being blocked and the avatar showing the same neutral panel every other failure
shows; the other asserts the path names a full commit sha, because the
force-push sentence above is a claim about a commit-pinned path and says nothing
about a branch.
Phase 1 reads only lk.agent.state, the three values LiveKit already publishes:
listening, thinking, speaking. An unrecognized state falls back to
listening. questioning, encouraging, and challenging wait for
src/agent.rs to publish a documented lk.avatar.state; inventing them in the
browser would be the avatar guessing at the interviewer's intent.
| Constant | Value | Why |
|---|---|---|
ANALYSER_FFT_SIZE |
512 | ~10.7 ms at 48 kHz |
ANALYSER_WINDOW |
5 frames | smooths syllable gaps without lagging speech |
MOUTH_OPEN_THRESHOLD |
0.04 | below this is room tone |
MOUTH_FULL_AMPLITUDE |
0.32 | above this the jaw is already open |
BLINK_INTERVAL_MS |
4200 | |
BLINK_DURATION_MS |
140 | |
BREATH_PERIOD_MS |
4000 | |
BREATH_AMPLITUDE |
0.02 | radians of head pitch |
GAZE_BOUND |
0.35 rad | |
HEAD_TILT_BOUND |
0.12 rad | the head follows the eyes at a third of the travel |
TRANSITION_MS |
200 | a time constant, not a deadline: ~63% applied after one, visually done at three |
Blink and breath are deterministic functions of the clock, which is the only reason a fake-clock test can assert them exactly. A random blink interval would look better and prove nothing.
The mouth is driven by an analyser on Jim's own track, built with
createMediaStreamSource(new MediaStream([track.mediaStreamTrack])).
createMediaElementSource is the obvious call and the wrong one: it returns
silence for a MediaStream-backed element, so the mouth would never open and
nothing would say why. The candidate's microphone is never observed.
The analyser is built only for a track whose participant passes isAgent, and
torn down only for that same track. playRemoteAudio fires for every remote
audio track, so without the first guard the mouth followed whichever arrived
first, and without the second any other participant leaving killed lip sync for
the rest of the session.
The mouth is not eased at all. The analyser already averages ANALYSER_WINDOW
frames, and running that through the transition filter as well put two lags
between Jim's voice and his jaw. Only the state-driven channels, which change in
steps, are eased.
Audio drives the jaw, and the gate defaults to open. setMouth used to be
gated on setSpeaking(true) having been called, and setSpeaking is only ever
called from updateAgentState, which returns early when it cannot find the
agent participant. One stale "Waiting" pill and Jim sat through an entire
interview talking with his mouth shut, which reads to a candidate as an
interviewer they cannot converse with. Agent state may mute the mouth; it may
not be required to un-mute it. Anything that must be inferred from a channel
that can go silent belongs on the side of the default that fails visible.
The mouth closes immediately, without easing, on any of: the agent leaving the
speaking state, which is what barge-in looks like from here; Jim's track being
unsubscribed; the report appearing; the room being left. A jaw easing shut while
the candidate has the floor reads as Jim talking over them.
prefers-reduced-motion: reduce drops the blink, the breathing, and the easing
between states. It keeps the mouth, because that is a speech cue rather than
decoration.
createAvatar({ mount, loadModel, now, timeoutMs, reducedMotion, onState })
exposes four behavior calls, setMouth(value), setGaze(target),
setExpression(state), and setSpeaking(value), plus frame(at),
destroy(), ready, state(), and frames() for lifecycle. There is no
generic avatar interface and no 2D adapter; add one when a second renderer
exists, not before.
setGaze is an override that outlives the next setExpression. Passing null
hands the eyes back to the agent state. setExpression used to reassign the
gaze from the state table, which silently discarded every setGaze.
onState fires synchronously inside the same call that writes
data-avatar-state. Anything deriving UI from the phase must use it rather than
the ready promise, which resolves a microtask later and still fires after a
destroy().
loadModel resolves to { apply(pose), dispose(), frames() }. One apply per
frame with the whole pose beats eight setters: the frame reaches the renderer
atomically, and a test can assert it by value. The pose is
{ mouth, blink, gaze: {x, y}, headTilt: {x, y}, breath, expression, agentState },
where expression is a weight per preset name, every weight is in 0..1, and
every angle is inside the bounds above.
expression is a map rather than a {name, weight} pair because a single
shared weight applied the incoming preset at the outgoing one's value during a
transition, and cut the outgoing one to zero instead of fading it. Writing every
preset every frame is also what lets the renderer forget which one it set last.
The timeout races the load; it cannot cancel it. Any model the avatar does not
adopt, whether because the timeout won or because destroy() came first, is
disposed when it eventually arrives. Without that it keeps a WebGL context, and
a page gets about 16.
web/avatar/vrm.js is the only first-party file that imports Three.js, and
tests/browser/avatar.test.js asserts that. If web/avatar/avatar.js ever
imports it, the node tests stop being able to run at all.
The avatar floats over the editor's top-right (#jim-stage, fixed), not in the
sidebar. The sidebar already carries the problem, timer, status pill, captions,
controls and the Meet panel; adding a face made it crowded, and the point of the
avatar is that the candidate glances at it while working, so it belongs where
their eyes already are. Its top follows the toolbar on show rather than a
constant: interview.js measures the bottom of the editor's toolbar, or the
whiteboard's in whiteboard mode, and keeps it in --jim-stage-top, re-measuring
through a ResizeObserver, so a toolbar that wraps to a second row moves the
stage down with it, and the toolbar's controls (Run tests in the editor, the
drawing tools at the board) stay visible at every width the avatar is shown. The
stylesheet's 4.25rem is only the fallback before that first measurement. The
stage takes no pointer events, so it can never swallow a click meant for the
editor; the Hide avatar button and the captions re-enable them for themselves.
Below 1200px wide the avatar and its button are hidden rather than allowed to
cover code, and the captions move to the bottom corner.
Every VRM loads in a T-pose and the format ships no idle animation, so
loadVrm drops the upper arms once at load. This is model-independent: without
it any model stands in frame with its arms straight out. It does not help a
model whose arms are rigged props driven by VRMC_node_constraint, because
those constraints are applied inside vrm.update(), after and on top of these
rotations. Seed-san's robot arms are exactly that case, which is one more entry
in the cost column for a borrowed mascot.
#captions-bar lives inside #jim-stage, under the avatar. It grows to fit
its text, with max-height: min(40vh, 20rem), and scrolls once a Jim turn
outgrows that height. Its head, .captions-head, holds the "Live CC:" label
and the Unclear button and is sticky on the bar's own surface, so a bar pinned
to the end of a long turn still shows both without the candidate scrolling up.
A Jim turn is never windowed: from the first revealed character to the last,
the bar holds the whole turn so far, so a long question keeps its opening
sentence while the rest is still typing out. A completed Jim turn is displayed
in full, without CAPTION_MAX_CHARS truncation or an idle timeout. It stays
until the candidate's next transcribed speech or an edit that changes the code
text. Speech replaces it with the candidate's caption; a code text change hides
the bar. Clicking or focusing the editor and switching languages preserve Jim's
caption. A new Jim turn replaces the previous one.
While a Jim turn reveals, the bar follows its newest words. Only the candidate's scrolling changes that: scrolling up to reread stops the follow, and scrolling back to the end resumes it, with or without a tick in between. The bar's own size does not: a bar that shrinks under the reveal, as it does when the ending wait opens, grows taller under a candidate who stopped the follow, or hides after idling out keeps the follow it had.
The bar's last resting position is recorded by each tick and by the scroll listener. A later position counts as the candidate's scroll unless the browser's clamp explains it: the smaller of the recorded position and the bar's current bottom, which is also where a bar that shrank, grew taller or hid ends up. Jim's own ticks therefore never read as a scroll, and a candidate's scroll still counts when its event is queued behind Jim's next tick, because the tick checks the bar before writing. While the bar holds the candidate's caption, nothing done to it changes Jim's follow, including the clamp when it replaces Jim's text. Every new turn follows from its first tick, wherever the candidate left the last one and even if that scroll's event is still queued, and that first tick puts a short opening at the top.
Keeping the last turn lets the candidate reread a hint or follow-up question while thinking, without switching away from the problem. The Transcript tab still keeps every turn.
An "Unclear" button beside the "Live CC:" label marks the Jim turn on screen,
and pressing it again unmarks it. It shows only while a Jim turn is on screen,
and once MAX_UNCLEAR_LINES (10) turns are marked it is disabled on any turn
not already marked. Jim is not told about a mark. When the report is saved,
the marked turns are resolved to their transcript text and stored as
unclearLines in the history entry, outside the report and its request. The
report and its Markdown export list them, and the export tags them in the
transcript. A report reopened from Past attempts or a replay keeps the list,
but its export has no transcript to tag.
Jim captions reveal gradually, even after final text arrives, because
generation can finish while audio is still queued. An edit during the reveal
is remembered for that Jim segment: its final text finishes revealing, then
the bar hides without another edit. While revealing, Jim captions keep the
CAPTION_IDLE_HIDE_MS timeout, refreshed by pacing updates. Candidate captions
keep their 160-character window and 12 s timeout. The "Listening..."
placeholder also keeps its 12 s timeout.
A stream with lk.transcription_final set to "true" must be read
successfully to its end before its turn can be retained. Once the reveal
reaches the end, the full turn stays without an idle timeout. A broken text
stream leaves partial captions temporary. The reveal uses a fixed rate;
audio playout timing is independent. Updates for already dismissed or
replaced Jim segment IDs are ignored. Final copies of earlier candidate turns
do not dismiss Jim's current turn.
A longer interim update to the same candidate segment can pause Jim's interim reveal without retiring it. Jim's next update can resume the reveal. A new candidate segment or a candidate response after Jim's final retires Jim's segment.
node --test tests/browser/avatar.test.js tests/browser/captions.test.js
covers avatar behavior with a fake clock, a fake mount, and a fake model.
Caption tests use a fake clock and actual page functions to cover retention,
dismissal, final-stream handling, and editor input, and a bar laid out like
#captions-bar (clamped, hideable, optionally with the fractional bottom of a
zoomed page, and queuing a scroll event for every change of scrollTop, whoever
caused it) to cover when the reveal follows. These checks
cannot see WebGL, a canvas, or a layout, and no assertion in them should claim
to.
node --test tests/browser/editor-pipeline.test.js runs Chromium against the
interview page. It checks caption retention during editor focus, caret
movement, and language switches, plus a long reveal following its newest
words, a candidate's scroll position surviving an update to the same turn, a
new turn following its reveal after the last one was left midway, a tick that
runs in the same task as a candidate's scroll leaving it in place, and the head
staying in view while the bar is pinned to the end of a long turn.
BROWSER_CHECK_FLOW=avatar ./scripts/browser-check.sh is the only check that
runs the renderer. It asserts the mount leaves loading and then asserts
whichever branch it landed on. The ready branch reads a frame counter the
page exposes as window.__codetrialAvatarFrames and requires it to advance,
because a canvas that exists proves nothing about a loop that runs; an earlier
version sampled toDataURL once, never took a second sample, and compared the
first against a length threshold.
connect-src must contain blob:, asserted in tests/web.rs. GLTFLoader
mints a blob: URL per embedded texture and ImageBitmapLoader reads it with
fetch, which connect-src governs, so without it every texture in the model
fails and the avatar degrades to the neutral panel with no stated reason. No
other check can catch this until a model ships.
No face or voice data is collected for avatar control. The analyser reads Jim's synthesized speech, never the candidate's microphone, and no candidate emotion is inferred or persisted. The model and every animation stay in static browser assets. Render work is capped at one canvas.
The canvas is role="img" with the label "Jim, the AI interviewer". It carried
aria-hidden="true" while it had no name, which was the right answer then: an
unlabeled canvas announced to a screen reader is worse than a hidden one. A
picture of a person whose whole useful content is "this is the interviewer"
needs one sentence, not a live region. The one live region on the stage is
#jim-avatar-status, a visually hidden role="status" that speaks when the
candidate hides the avatar. A manual Hide leaves focus on the button, which now
reads Show avatar, so the same key brings the avatar back and the status region
is what announces the change. An automatic retirement is announced by the
presence banner, an alert region, and the status region stays silent, so the
change is read once. While the wait for the report is up the sidebar holding
the banner is inert but the stage is not, since Jim may still be saying his
closing lines, so the status region carries it there instead. It hides the
button; if the button held focus, focus moves to the stage, since the box that
would have held a note is removed.
The render loop stops scheduling frames while the document is hidden, the stage
is narrower than the breakpoint, or the preflight overlay covers it. It starts
again on visibilitychange, when a ResizeObserver on the mount sees it widen,
and when the page reports the preflight closed (setStageCovered(false)). The
stage is inert while covered, so nothing on it can be reached by keyboard
under the overlay. While running it draws 30 frames a second on average,
whatever the display refresh rate. Browsers already throttle
requestAnimationFrame in a background tab, so what this buys is the analyser
read and the humanoid
update stopping too. There is one door into the loop, resumeAvatar, which
refuses to open a second one and refuses to open any while the model is still
loading: createAvatar returns before the model has arrived, and a visibility
change during that window would otherwise start a loop that poses nothing.
The media contract is unchanged by any of this. No first-party script captures a canvas stream, the only tracks published are the candidate's microphone and camera, and the recording template renders the same avatar from the same vendored bundle rather than subscribing to a second one.
The live interview retires the avatar for the rest of the page session on either of two signals. This policy belongs to the live stage, not the recording renderer.
- Late audio. Every two seconds while frames are being drawn, the stage
reads Jim's inbound audio stats (
getRTCStatsReport()on his LiveKit track) and computes the jitter-buffer delay accrued in that window,Δ jitterBufferDelay / Δ jitterBufferEmittedCount. Two consecutive windows above 200 ms retire the avatar. A window with no audio emitted, as in a silent stretch, is no evidence either way; a window with nothing drawn, or a new receiver, starts the count again. This is the quantity the problem was measured in on a CPU-bound Windows laptop: 75-104 ms with the avatar hidden and 172-1187 ms with it drawn, and a 30 fps cap with a lower pixel ratio left page fps at 38-57 while Jim's audio was still hundreds of milliseconds late, which no frame metric can see. Network jitter that persists retires the avatar too, which is intended: when Jim is already late, decoration goes first. - Render budget. Two consecutive visible windows, each lasting at least two seconds and closing at ten drawn frames or four seconds, whichever comes first. A window is overloaded when more than half its time went to gaps past 45 ms between frames, or when more than half its synchronous draws took over 8 ms. Cadence is judged by time because counting slow frames missed a page frozen in bursts: three quick frames and a 1.5 s stall, repeated, is 94% stalled with only a quarter of its frames slow. Cost is judged by frames so that one shader compilation or long task, however long, cannot decide on its own. One window was not enough: the first one after the preflight lands on the page joining the room and publishing its tracks, which is start-up work rather than sustained load. With the four-second close, a page frozen by rendering is retired within about ten seconds however slow or bursty its frames are. Hidden tabs, the preflight overlay and the narrow layout pause the loop and reset the window, and the playout watch stops with it: its timer runs only while frames are drawn, and a stats answer that arrives after a pause or a track replacement is discarded.
Retiring the avatar cancels its frame callback, stops the stats poll, disposes
the model and releases its audio analyser and context. Jim's audio playback and
captions continue, the avatar box is removed so it stops covering the editor,
and the presence banner (yellow, lowest rank) explains the change. The banner
shows it once, for ten seconds from when it is first on show, because it is
news rather than a fault and the banner is an alert region that would otherwise
announce it again each time a higher owner cleared. It stays off
rather than repeatedly testing whether rendering can compete with audio again.
The console records which signal fired and the number that decided it, as
codetrial avatar_playout_delayed 412ms or codetrial avatar_budget_exceeded stalled=62% expensive=8%, since the thresholds above are calibrated on one
reported machine and real sessions are how they get checked.
The Hide avatar button gives the candidate the same voice-only path at any time once the preflight has closed, including while the model is still loading. Hiding, or leaving, during the load aborts the download and stops before the renderer is imported or a WebGL context is built, so the CPU the candidate asked for is not spent on a model that would only be disposed. After a Hide the box is removed, announced through the status region, and the button offers Show avatar, which loads the model and reattaches Jim's current audio analyser. A Hide that lands during the parse cannot stop it, because the renderer and its WebGL context already exist by then, so Show waits for that parse to settle and its model to be disposed before it starts another load; toggling never runs two parses or two contexts at once. This candidate-controlled hide/show cycle does not change the automatic retirement rule above: an avatar retired for late audio or render pressure stays off for the rest of the interview.
The Windows/Chrome CPU-stress experiment described above remains the end-to-end validation for jitter-buffer delay; deterministic tests cover both signals, the fallback and its lifecycle.