Skip to content

livekit-plugins-soniox: RECOGNITION_USAGE only reported on endpoint tokens, under-reporting STT usage #6584

Description

@vadimatmurphy

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:

  1. Every stream loses its tail — whatever advanced after the last <end> token is never reported.
  2. 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.
  3. 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.

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