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.
Summary
On the Soniox realtime plugin, a
FINAL_TRANSCRIPTemitted at a natural endpoint can carry astart_time/end_timespan far shorter than the speech its owntextrepresents. We observed a final whose text wasHe'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-soniox1.6.9stt-rt-v5,language_hints=["en","he"], no translation configWhy it matters
SpeechData.start_time/end_timeis 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 thefinalaccumulator alone:https://github.com/livekit/agents/blob/main/livekit-plugins/livekit-plugins-soniox/livekit/plugins/soniox/stt.py
finalonly accumulates tokens wheretoken["is_final"]is true, and_TokenAccumulator.update()only advances timing when the token actually carries the keys:So when an
<end>/<fin>token arrives while part of the utterance is still non-final, or when some tokens arrive withoutstart_ms/end_ms, the emittedtextcan span more audio than the accumulatedstart_time/end_timedo. The interim path already merges both sides viamerged_speech_data(...); the endpoint path does not.Note also that if no token in
finalever carried timing, both values stay at theirreset()default of0.0and the emitted final getsstart_time == end_time == start_time_offset.Suggested fix
Have the endpoint path cover the same tokens its text came from — either merge
non_finalthe way the interim path does, or widen the accumulated span to the tokens actually included infinal.text.Caveat on our setup
We run a thin subclass of
SpeechStreamthat observes incoming frames to emit bounded-latency prefixes; it only peeks at messages and does not modify them or the accumulators. Thetextand the span above are produced entirely by the stocksend_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.