Bug Description
livekit-plugins-soniox emits RECOGNITION_USAGE only when an <end> endpoint token arrives, or when the server sends a finished / error frame. Progress reported on every other frame is discarded, so AgentSession.usage under-reports STT audio duration.
_report_processed_audio_duration is called from exactly two places in _recv_messages_task:
total_audio_proc_ms = content.get("total_audio_proc_ms", 0) # read on EVERY response frame
...
if token["is_final"]:
if is_end_token(token):
send_endpoint_transcript()
self._report_processed_audio_duration(total_audio_proc_ms) # (1) endpoint token only
...
if content.get("finished") or has_error:
send_endpoint_transcript()
self._report_processed_audio_duration(total_audio_proc_ms) # (2) finished / error only
Soniox documents total_audio_proc_ms as "audio processed into final + non-final tokens" — it advances on interim frames, not just at endpoints. The plugin already reads it into a local on every frame and then throws it away unless one of the two conditions above holds. Consequences:
- Every stream loses its tail — whatever advanced after the last
<end> token is never reported.
- Path (2) rarely fires in practice. The
finished frame is the intended backstop, but if the stream is cancelled (e.g. a telephony participant disconnects and the session closes) _recv_messages_task is torn down before that frame is read. Since abrupt teardown is the common case for inbound/outbound calls, the backstop is close to dead code in production.
- A stream that never reaches an endpoint reports nothing at all — not a zero-duration
STTModelUsage, an absent one.
Case 3 in practice: a 26-second SIP call where the callee never spoke produced model_usage with populated LLM and TTS entries and no STTModelUsage entry, while the Soniox WebSocket was connected and receiving audio for the whole call.
Worth noting that the absence (rather than a zero row) is Soniox-specific: deepgram and openai call _report_connection_acquired, which emits a zero-usage STTMetrics on connect and so at least creates a (provider, model) key in ModelUsageCollector. Soniox doesn't, so downstream can't distinguish "this STT was never exercised" from "this STT was never configured".
This is a partial regression against #4034, which asked for periodic usage reporting (it proposed a PeriodicCollector flushing every 5s plus a flush in aclose). The implementation that landed in #4332 switched to Soniox's own total_audio_proc_ms — more accurate per report, since it's provider-side rather than client-side frame counting — but tied the reporting cadence to endpoint detection and dropped the close-time flush.
Expected Behavior
RECOGNITION_USAGE should reflect the processed-audio progress Soniox reports, not just the subset that coincides with an endpoint token.
Reporting the delta on every response frame achieves that: _report_processed_audio_duration already subtracts _reported_duration_ms and returns early when to_report_ms <= 0, so calling it per frame is idempotent, monotonic, and emits nothing when the total hasn't advanced. It also degrades safely on frames where the field is absent (.get(..., 0) → negative delta → no-op).
Open question for maintainers
Does Soniox emit response frames during pure silence — a frame with tokens: [] and an advancing total_audio_proc_ms? The docs don't specify the cadence during non-speech, and both counters are defined in terms of tokens (final_audio_proc_ms = "audio processed into final tokens", total_audio_proc_ms = "+ non-final tokens"), so a stream that produces no tokens at all may report nothing even with per-frame reporting.
If so, that's a second, separate gap: total_audio_proc_ms would be a recognition watermark rather than a measure of audio streamed, and wouldn't reconcile against Soniox's real-time metering (documented as "duration of audio or streaming session"). Recovering that would need a client-side estimate — frame counting, as #4034 originally proposed — which trades provider-side numbers for approximated ones. Flagging rather than assuming, since maintainers may know the protocol behaviour here.
Reproduction Steps
# Deterministic, no credentials or network needed — drives `_recv_messages_task`
# through the existing fake-WebSocket harness in tests/test_plugin_soniox_stt.py.
#
# A frame carrying processed audio but no endpoint token emits no
# RECOGNITION_USAGE event, so this times out on current main:
async def test_recognition_usage_reported_without_endpoint_token():
stream = _make_stream(translation=None)
events = await _drive_recv(
stream,
[{"tokens": [_nonfinal_token("hola", "es")], "total_audio_proc_ms": 2500}],
expect_events=3,
)
assert [e.type for e in events] == [
SpeechEventType.START_OF_SPEECH,
SpeechEventType.INTERIM_TRANSCRIPT,
SpeechEventType.RECOGNITION_USAGE,
]
# End to end, against a live Soniox stream (the tail loss, case 1):
# 1. Start an AgentSession with soniox STT and turn_detection="stt".
# 2. Have a participant speak, then disconnect immediately — before the audio
# after their last endpoint can produce another `<end>` token, and before the
# `finished` frame is read.
# 3. Compare the summed STTModelUsage.audio_duration in `session.usage` against
# the stream's connected duration — the difference is never reported.
Operating System
Reproduced on macOS 15 (local) and Debian 12 (deployed agent).
Models Used
Soniox stt-rt-v5 (turn_detection="stt"), ElevenLabs eleven_multilingual_v2, OpenAI gpt-4.1
Package Versions
livekit-agents==1.6.4
livekit-plugins-soniox==1.6.4
# Code path also verified unchanged on main @ d6aa46d
Session/Room/Call IDs
Omitted — the affected sessions carry end-user PII. The repro above is deterministic and needs no session lookup; happy to share identifiers privately if useful.
Proposed Solution
# Drop the two conditional call sites and report once per frame, after the
# transcript events for that frame have been emitted (preserving the existing
# transcript-then-usage ordering) and before the error frame raises.
# 3) on error or finish, flush any remaining final tokens.
if content.get("finished") or has_error:
send_endpoint_transcript()
# 4) report processed audio for every frame.
self._report_processed_audio_duration(total_audio_proc_ms)
if has_error:
...
raise APIStatusError(...)
Net effect is one fewer call site and no new state. I have this working with four unit tests in the existing harness (three fail on current main, one guards the endpoint path against regression) — PR to follow.
Additional Context
Related to #6151 (livekit-plugins-azure STT never emitting RECOGNITION_USAGE), though the cause differs: Azure never reported at all, whereas Soniox reports on a trigger that doesn't cover the general case.
Bug Description
livekit-plugins-sonioxemitsRECOGNITION_USAGEonly when an<end>endpoint token arrives, or when the server sends afinished/ error frame. Progress reported on every other frame is discarded, soAgentSession.usageunder-reports STT audio duration._report_processed_audio_durationis called from exactly two places in_recv_messages_task:Soniox documents
total_audio_proc_msas "audio processed into final + non-final tokens" — it advances on interim frames, not just at endpoints. The plugin already reads it into a local on every frame and then throws it away unless one of the two conditions above holds. Consequences:<end>token is never reported.finishedframe is the intended backstop, but if the stream is cancelled (e.g. a telephony participant disconnects and the session closes)_recv_messages_taskis torn down before that frame is read. Since abrupt teardown is the common case for inbound/outbound calls, the backstop is close to dead code in production.STTModelUsage, an absent one.Case 3 in practice: a 26-second SIP call where the callee never spoke produced
model_usagewith populated LLM and TTS entries and noSTTModelUsageentry, while the Soniox WebSocket was connected and receiving audio for the whole call.Worth noting that the absence (rather than a zero row) is Soniox-specific:
deepgramandopenaicall_report_connection_acquired, which emits a zero-usageSTTMetricson connect and so at least creates a(provider, model)key inModelUsageCollector. Soniox doesn't, so downstream can't distinguish "this STT was never exercised" from "this STT was never configured".This is a partial regression against #4034, which asked for periodic usage reporting (it proposed a
PeriodicCollectorflushing every 5s plus a flush inaclose). The implementation that landed in #4332 switched to Soniox's owntotal_audio_proc_ms— more accurate per report, since it's provider-side rather than client-side frame counting — but tied the reporting cadence to endpoint detection and dropped the close-time flush.Expected Behavior
RECOGNITION_USAGEshould reflect the processed-audio progress Soniox reports, not just the subset that coincides with an endpoint token.Reporting the delta on every response frame achieves that:
_report_processed_audio_durationalready subtracts_reported_duration_msand returns early whento_report_ms <= 0, so calling it per frame is idempotent, monotonic, and emits nothing when the total hasn't advanced. It also degrades safely on frames where the field is absent (.get(..., 0)→ negative delta → no-op).Open question for maintainers
Does Soniox emit response frames during pure silence — a frame with
tokens: []and an advancingtotal_audio_proc_ms? The docs don't specify the cadence during non-speech, and both counters are defined in terms of tokens (final_audio_proc_ms= "audio processed into final tokens",total_audio_proc_ms= "+ non-final tokens"), so a stream that produces no tokens at all may report nothing even with per-frame reporting.If so, that's a second, separate gap:
total_audio_proc_mswould be a recognition watermark rather than a measure of audio streamed, and wouldn't reconcile against Soniox's real-time metering (documented as "duration of audio or streaming session"). Recovering that would need a client-side estimate — frame counting, as #4034 originally proposed — which trades provider-side numbers for approximated ones. Flagging rather than assuming, since maintainers may know the protocol behaviour here.Reproduction Steps
Operating System
Reproduced on macOS 15 (local) and Debian 12 (deployed agent).
Models Used
Soniox
stt-rt-v5(turn_detection="stt"), ElevenLabseleven_multilingual_v2, OpenAIgpt-4.1Package Versions
livekit-agents==1.6.4 livekit-plugins-soniox==1.6.4 # Code path also verified unchanged on main @ d6aa46dSession/Room/Call IDs
Omitted — the affected sessions carry end-user PII. The repro above is deterministic and needs no session lookup; happy to share identifiers privately if useful.
Proposed Solution
Net effect is one fewer call site and no new state. I have this working with four unit tests in the existing harness (three fail on current main, one guards the endpoint path against regression) — PR to follow.
Additional Context
Related to #6151 (
livekit-plugins-azureSTT never emittingRECOGNITION_USAGE), though the cause differs: Azure never reported at all, whereas Soniox reports on a trigger that doesn't cover the general case.