Skip to content

turn_latency never fires on the native realtime front (turn-latency decomposition covers only the cascade path) #33

Description

@octalpixel

Problem

The session emits a turn_latency event carrying the per-turn latency decomposition
(ttfaMs plus eouDelayMs / llmTtftMs / ttsTtfbMs). It is the only per-stage latency
instrument in the product.

Actual: on the native realtime front the event never fires at all. Not partial fields —
the entire event is absent for every turn. Any latency dashboard, gate, or regression check
built on it silently reads zero for native while working normally for cascade.

Expected: turn_latency fires once per voice turn on every front, and the meaning of
ttfaMs is unambiguous.

Repro: drive one turn through a native realtime session (realtime adapter + bridge) with a
tool-calling reasoner, and subscribe to turn_latency. Nothing is emitted. The same fixture
through the cascade session emits it normally.

Measured on a live run: assistant first audio landed ~1.6s before the session's own
end-of-speech event for the same turn.

Root Cause Analysis

Three independent defects compound:

1. The instrument encodes a cascade-shaped ordering assumption. The turn-timing record for
a turn is first created when the session handles end-of-speech. The latency event is emitted
from the first-assistant-audio handler, which bails when no timing record exists yet. On
cascade that ordering always holds (endpoint precedes generation precedes audio). Native S2S
inverts it: the provider begins speaking before Syrinx's own endpointer concludes, so at
first-audio time no record exists and the turn is dropped silently.

2. The realtime bridge never contributes turn timings. It surfaces transcripts and
end-of-speech but never records speech-end, first-LLM-delta, or first-TTS-text for the turn.
So even with the ordering fixed, native would yield a total with no stage breakdown.

3. The adapter event surface drops the provider's end-of-user-speech signal. The realtime
event union models speech start but has no speech stop variant. The OpenAI-compatible
adapter receives the provider's speech-stopped event and discards it, so the bridge has no
end-of-speech signal to publish and the session's speech-end anchor is never set.

This is the same lossy-adapter-surface pattern as #28/#32 (Gemini transcription received but
not surfaced): the provider sends a signal, the adapter drops it, a downstream consumer
silently degrades.

Contributing factor — a silent anchor fallback. ttfaMs is anchored on speech-end when
available and falls back to the endpoint event otherwise, with nothing in the payload
recording which was used. So ttfaMs can silently change meaning between turns and between
fronts while keeping the same name. Two different metrics wear one label.

TDD Fix Plan

  1. RED: A turn whose assistant audio arrives before the end-of-speech event still emits
    exactly one turn_latency.
    GREEN: Create the turn-timing record on demand from the first-audio path instead of
    bailing when absent.

  2. RED: The turn_latency payload states which anchor ttfaMs was measured from, and a
    turn with no speech-end anchor reports the endpoint anchor rather than an unlabelled number.
    GREEN: Add an explicit anchor discriminator to the payload and populate it at emit time.

  3. RED: A realtime adapter receiving the provider's end-of-user-speech signal surfaces a
    corresponding speech-stopped event to its consumer.
    GREEN: Add a speech-stopped variant to the realtime event contract and emit it from the
    OpenAI-compatible adapter.

  4. RED: A realtime bridge observing speech-stopped publishes an end-of-speech signal that
    the session records as the turn's speech-end anchor.
    GREEN: Translate the adapter event into the session-level end-of-speech packet.

  5. RED: One native realtime turn emits turn_latency with a speech-end anchor and a
    non-empty stage breakdown.
    GREEN: Populate first-LLM-delta and first-TTS-text timings from the bridge's generation
    path.

  6. RED: A text-injected (non-voice) turn still emits no turn_latency, and a barge-in
    turn is not double-counted.
    GREEN: Preserve the existing guards while relaxing only the ordering assumption.

REFACTOR: Extract the stage-attribution arithmetic so cascade and native produce the
payload through one path, and the ordering assumption cannot be reintroduced in one front only.

Acceptance Criteria

  • turn_latency fires exactly once per voice turn on both the cascade and native realtime fronts
  • The payload states which anchor ttfaMs was measured from; no silent fallback
  • The realtime event contract models end-of-user-speech, and the OpenAI-compatible adapter emits it
  • A native turn reports a non-empty stage breakdown, not just a total
  • Non-voice turns still emit nothing; barge-in turns are not double-counted
  • All new tests pass
  • Existing tests still pass

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions