Skip to content

soniox: endpoint final can report a span far shorter than its own text (2 words in 180ms) #6885

Description

@aviadr1

Summary

On the Soniox realtime plugin, a FINAL_TRANSCRIPT emitted at a natural endpoint can carry a start_time/end_time span far shorter than the speech its own text represents. We observed a final whose text was He's trying. but whose span was 0.18s:

{
  "content": " He's trying.",
  "start_time": "2026-08-17T18:46:43.663000Z",
  "end_time":   "2026-08-17T18:46:43.843000Z"
}

Two words in 180ms is not a plausible speaking rate. Longer utterances in the same stream were correct — e.g. 46 words over 7.32s, 53 words over 11.76s — so this is not a constant offset, it affects some finals and not others.

Environment

  • livekit-agents / livekit-plugins-soniox 1.6.9
  • Soniox model stt-rt-v5, language_hints=["en","he"], no translation config
  • Python 3.12

Why it matters

SpeechData.start_time/end_time is the only timing signal for a final, so anything that anchors transcripts on a timeline (captions, clipping, alignment, diarization hand-off) places these utterances wrongly and gives them a near-zero duration.

Suspected cause

send_endpoint_transcript() builds the event from the final accumulator alone:

https://github.com/livekit/agents/blob/main/livekit-plugins/livekit-plugins-soniox/livekit/plugins/soniox/stt.py

def send_endpoint_transcript() -> None:
    if final.text:
        ...
        final.to_speech_data(...)   # non_final is never merged here

final only accumulates tokens where token["is_final"] is true, and _TokenAccumulator.update() only advances timing when the token actually carries the keys:

if "start_ms" in token and not self._has_start_time:
    self._has_start_time = True
    self.start_time = float(token["start_ms"])
if "end_ms" in token:
    self.end_time = float(token["end_ms"])

So when an <end>/<fin> token arrives while part of the utterance is still non-final, or when some tokens arrive without start_ms/end_ms, the emitted text can span more audio than the accumulated start_time/end_time do. The interim path already merges both sides via merged_speech_data(...); the endpoint path does not.

Note also that if no token in final ever carried timing, both values stay at their reset() default of 0.0 and the emitted final gets start_time == end_time == start_time_offset.

Suggested fix

Have the endpoint path cover the same tokens its text came from — either merge non_final the way the interim path does, or widen the accumulated span to the tokens actually included in final.text.

Caveat on our setup

We run a thin subclass of SpeechStream that observes incoming frames to emit bounded-latency prefixes; it only peeks at messages and does not modify them or the accumulators. The text and the span above are produced entirely by the stock send_endpoint_transcript() path. Happy to add instrumentation and attach the raw Soniox frames for one of these finals if that would help — say the word and I'll capture them.

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