Skip to content

soniox: an <end>/<fin> between an original token and its translation drops source_texts from the final transcript #6887

Description

@LHMQ878

Bug Description

In the Soniox STT plugin with translation= configured, an <end> or <fin> token that arrives before the translation of the utterance it closes silently destroys the accumulated source-language text. The utterance's FINAL_TRANSCRIPT is then emitted with source_languages=None / source_texts=None, so the original side of a translated turn is lost.

The cause is the fallthrough in send_endpoint_transcript() (livekit-plugins/livekit-plugins-soniox/livekit/plugins/soniox/stt.py):

def send_endpoint_transcript() -> None:
    nonlocal is_speaking
    if final.text:
        # ... emit FINAL_TRANSCRIPT pairing final_original (source) with final (target) ...
        final.reset()
        final_original.reset()
        is_speaking = False
    else:
        final_original.reset()      # <-- discards the pending source side

In translation mode final_original carries the source tokens and final carries the translated ones. The comment on the if branch says the pairing is meant to survive being split across frames:

final_original carries the source side and final carries the target side — even across flush windows where the originals were finalized in a prior message and only translation tokens land in this one.

That works today only when no endpoint marker lands in the gap. When one does, final.text is still empty (no translated token has arrived yet), so the else branch runs and throws away the source text that the very next frame's translation was supposed to be paired with. The utterance also gets no FINAL_TRANSCRIPT at that point — its text reappears only as the next flush, without its source side.

Note that is_end_token() treats <fin> the same as <end>, so Soniox's finalization marker triggers this too — and <fin> is emitted precisely when source tokens are finalized, which is exactly when the translation has not caught up yet.

Expected Behavior

A translated turn's FINAL_TRANSCRIPT carries both sides — source_languages/source_texts for the original and target_languages/target_texts for the translation — regardless of whether an endpoint marker happened to land between the original tokens and their translation.

Reproduction Steps

# tests/test_scratch_soniox.py -- uses the plugin's existing fake-WS harness,
# no Soniox credentials or network needed.
#
#   uv run pytest tests/test_scratch_soniox.py --plugin soniox -s

from __future__ import annotations

from typing import Any

import pytest

from livekit.agents.stt import SpeechEventType

pytestmark = pytest.mark.plugin("soniox")

from .test_plugin_soniox_stt import _drive_recv, _final_token, _make_stream  # noqa: E402

END = {"text": "<end>", "is_final": True}
FIN = {"text": "<fin>", "is_final": True}


async def _run(gap_marker: dict[str, Any] | None):
    from livekit.plugins.soniox.stt import TranslationConfig

    stream = _make_stream(translation=TranslationConfig(type="one_way", target_language="es"))
    first: list[dict[str, Any]] = [
        _final_token("Hello world.", "en", translation_status="original")
    ]
    if gap_marker is not None:
        first.append(gap_marker)
    messages = [
        # frame 1: the source side is finalized; its translation has not been produced yet
        {"tokens": first, "total_audio_proc_ms": 500},
        # frame 2: the translation of that same utterance arrives
        {
            "tokens": [
                _final_token("Hola mundo.", "es", translation_status="translation"),
                END,
            ],
            "total_audio_proc_ms": 700,
        },
    ]
    events = await _drive_recv(stream, messages, expect_events=4, timeout=2.0)
    final = next(e for e in events if e.type == SpeechEventType.FINAL_TRANSCRIPT)
    return final.alternatives[0]


async def test_no_marker_in_the_gap_keeps_the_source():
    sd = await _run(None)
    assert sd.target_texts == ["Hola mundo."]
    assert sd.source_texts == ["Hello world."]          # passes


async def test_end_in_the_gap_drops_the_source():
    sd = await _run(END)
    assert sd.target_texts == ["Hola mundo."]
    assert sd.source_texts == ["Hello world."]          # FAILS: source_texts is None


async def test_fin_in_the_gap_drops_the_source():
    sd = await _run(FIN)
    assert sd.target_texts == ["Hola mundo."]
    assert sd.source_texts == ["Hello world."]          # FAILS: source_texts is None

Observed:

--- no marker (baseline) ---
  FINAL_TRANSCRIPT: text='Hola mundo.' source_texts=['Hello world.'] target_texts=['Hola mundo.']
--- <end> in the gap ---
  FINAL_TRANSCRIPT: text='Hola mundo.' source_texts=None            target_texts=['Hola mundo.']
--- <fin> in the gap ---
  FINAL_TRANSCRIPT: text='Hola mundo.' source_texts=None            target_texts=['Hola mundo.']

Operating System

Windows 11 (harness-only; no OS dependence)

Models Used

Soniox stt-rt-v5 with TranslationConfig(type="one_way", target_language="es") — reproduced against the plugin's fake WebSocket, so no live model is involved.

Package Versions

livekit-agents @ main (c48de80de)
livekit-plugins-soniox @ main

Additional Context

I have verified the plugin-side behaviour with the harness above, but I have not confirmed against a live Soniox stream that the server can place <end>/<fin> between an original token and its translation. The plugin's own comment asserts that the originals-in-a-prior-frame ordering happens, and <fin> marks source finalization, so it looks reachable — but a maintainer with provider access should confirm before picking a fix.

Proposed Solution

Two candidates, and the choice is a semantics call I did not want to make blind:

  1. Keep the pending source across the marker — drop the else: final_original.reset(). This is a one-line change and is a no-op outside translation mode (final_original stays empty there). It makes the marker path behave like the no-marker path, which already carries final_original forward. The residual risk is the mirror case: an utterance whose source is never translated at all (e.g. every token is translation_status: "none") would have its text ride along into the next turn's source_texts instead of being dropped.
  2. Emit the pending source as a source-only FINAL_TRANSCRIPT, then reset. No text is lost and nothing leaks, but a turn whose translation merely lags produces two finals for one utterance.

Separately, and possibly upstream of both: if <fin> is Soniox's finalization marker rather than an utterance endpoint, is_end_token() conflating it with <end> is what puts a flush in the gap in the first place. Treating <fin> as "these tokens are now final, the utterance continues" — accumulate, don't flush — would avoid the situation entirely, and would also stop <fin> from splitting one utterance into several FINAL_TRANSCRIPTs (see #6885).

Happy to send a PR for whichever direction you prefer.

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