Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,10 @@ def _on_audio_duration_report(self, duration: float) -> None:
)
self._event_ch.send_nowait(usage_event)

@property
def _server_vad(self) -> VADOptions | None:
return self._opts.server_vad if is_given(self._opts.server_vad) else None

async def _run(self) -> None:
"""Run the streaming transcription session"""
closing_ws = False
Expand Down Expand Up @@ -481,7 +485,7 @@ async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:

async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
"""Establish WebSocket connection to ElevenLabs Scribe v2 API"""
commit_strategy = "manual" if self._opts.server_vad is None else "vad"
commit_strategy = "vad" if self._server_vad is not None else "manual"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Fix for pre-existing bug: NOT_GIVEN incorrectly treated as VAD-enabled

The old code at livekit-plugins/livekit-plugins-elevenlabs/livekit/plugins/elevenlabs/stt.py:488 had commit_strategy = "manual" if self._opts.server_vad is None else "vad". Since the default value of server_vad is NOT_GIVEN (not None), the is None check evaluated to False, causing commit_strategy to be "vad" for every user who didn't explicitly pass server_vad. The new _server_vad property correctly normalizes both NOT_GIVEN and None to None, fixing the default commit strategy to "manual". This is a behavioral change for all users relying on the default — they were previously getting server-side VAD without knowing it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

params = [
f"model_id={self._opts.model_id}",
f"audio_format=pcm_{self._opts.sample_rate}",
Expand All @@ -491,7 +495,7 @@ async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
if not self._language:
params.append("include_language_detection=true")

if server_vad := self._opts.server_vad:
if (server_vad := self._server_vad) is not None:
if (
vad_silence_threshold_secs := server_vad.get("vad_silence_threshold_secs")
) is not None:
Expand Down Expand Up @@ -596,6 +600,9 @@ def _process_stream_event(self, data: dict) -> None:
alternatives=[speech_data],
)
self._event_ch.send_nowait(final_event)
if self._server_vad is not None:
self._event_ch.send_nowait(stt.SpeechEvent(type=SpeechEventType.END_OF_SPEECH))
self._speaking = False
else:
# Empty commit signals end of speech segment (similar to Cartesia's is_final flag)
# This groups multiple committed transcripts into one speech segment
Expand Down
80 changes: 80 additions & 0 deletions tests/test_plugin_elevenlabs_stt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from __future__ import annotations

import pytest

from livekit.agents import stt
from livekit.agents.types import NOT_GIVEN
from livekit.plugins.elevenlabs import stt as elevenlabs_stt

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


class _EventSink:
def __init__(self) -> None:
self.events: list[stt.SpeechEvent] = []

def send_nowait(self, event: stt.SpeechEvent) -> None:
self.events.append(event)


def _new_stream(*, server_vad=NOT_GIVEN) -> elevenlabs_stt.SpeechStream:
stream = object.__new__(elevenlabs_stt.SpeechStream)
stream._opts = elevenlabs_stt.STTOptions(
model_id="scribe_v2_realtime",
api_key="test-key",
base_url=elevenlabs_stt.API_BASE_URL_V1,
language_code=None,
tag_audio_events=True,
include_timestamps=False,
sample_rate=16000,
server_vad=server_vad,
keyterms=NOT_GIVEN,
)
stream._language = None
stream._event_ch = _EventSink()
stream._speaking = False
stream._start_time_offset = 0.0
return stream


def _committed_transcript(text: str) -> dict:
return {
"message_type": "committed_transcript",
"text": text,
"words": [
{"text": text, "start": 0.1, "end": 0.4},
]
if text
else [],
}


def test_server_vad_commit_emits_end_of_speech() -> None:
stream = _new_stream(server_vad={"vad_silence_threshold_secs": 0.5})

stream._process_stream_event(_committed_transcript("hello"))

assert [event.type for event in stream._event_ch.events] == [
stt.SpeechEventType.START_OF_SPEECH,
stt.SpeechEventType.FINAL_TRANSCRIPT,
stt.SpeechEventType.END_OF_SPEECH,
]
assert stream._event_ch.events[1].alternatives[0].text == "hello"
assert stream._speaking is False


def test_manual_commit_still_waits_for_empty_commit() -> None:
stream = _new_stream(server_vad=None)

stream._process_stream_event(_committed_transcript("hello"))

assert [event.type for event in stream._event_ch.events] == [
stt.SpeechEventType.START_OF_SPEECH,
stt.SpeechEventType.FINAL_TRANSCRIPT,
]
assert stream._speaking is True

stream._process_stream_event(_committed_transcript(""))

assert stream._event_ch.events[-1].type == stt.SpeechEventType.END_OF_SPEECH
assert stream._speaking is False
Loading