Files
2026-07-13 13:39:38 +08:00

984 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import dataclasses
import os
from enum import Enum
from typing import Any
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectOptions,
LanguageCode,
stt,
utils,
vad,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given
from speechmatics.rt import ClientMessageType
from speechmatics.voice import (
AdditionalVocabEntry,
AgentServerMessageType,
AudioEncoding,
OperatingPoint,
SpeakerFocusConfig,
SpeakerFocusMode,
SpeakerIdentifier,
VoiceAgentClient,
VoiceAgentConfig,
VoiceAgentConfigPreset,
)
from .log import logger
from .version import __version__ as lk_version
class TurnDetectionMode(str, Enum):
"""Endpoint and turn detection handling mode.
How the STT engine handles the endpointing of speech. Use `TurnDetectionMode.EXTERNAL` when
turn boundaries are controlled manually, for example via an external VAD or the `finalize()`
method.
To use the STT engine's built-in endpointing, use `TurnDetectionMode.ADAPTIVE` for simple
voice activity detection or `TurnDetectionMode.SMART_TURN` for more advanced ML-based
endpointing.
The `TurnDetectionMode.FIXED` mode uses a fixed amount of silence, as determined by the
`end_of_utterance_silence_trigger` parameter.
The default is `TurnDetectionMode.EXTERNAL` which delegates endpointing to an external VAD
(Silero is auto-loaded if no `vad` is provided).
"""
EXTERNAL = "external"
FIXED = "fixed"
ADAPTIVE = "adaptive"
SMART_TURN = "smart_turn"
@dataclasses.dataclass
class STTOptions:
"""Configuration parameters for Speechmatics STT service."""
# Service configuration
language: LanguageCode = LanguageCode("en")
output_locale: str | None = None
domain: str | None = None
# Endpointing mode
turn_detection_mode: TurnDetectionMode = TurnDetectionMode.EXTERNAL
# Output formatting
speaker_active_format: str | None = None
speaker_passive_format: str | None = None
# Speakers
focus_speakers: list[str] = dataclasses.field(default_factory=list)
ignore_speakers: list[str] = dataclasses.field(default_factory=list)
focus_mode: SpeakerFocusMode = SpeakerFocusMode.RETAIN
known_speakers: list[SpeakerIdentifier] = dataclasses.field(default_factory=list)
# Custom dictionary
additional_vocab: list[AdditionalVocabEntry] = dataclasses.field(default_factory=list)
# -------------------
# Advanced features
# -------------------
# Features
operating_point: OperatingPoint | None = None
max_delay: float | None = None
end_of_utterance_silence_trigger: float | None = None
end_of_utterance_max_delay: float | None = None
punctuation_overrides: dict | None = None
include_partials: bool | None = None
# Diarization
enable_diarization: bool | None = None
speaker_sensitivity: float | None = None
max_speakers: int | None = None
prefer_current_speaker: bool | None = None
class STT(stt.STT):
def __init__(
self,
*,
api_key: NotGivenOr[str] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
turn_detection_mode: TurnDetectionMode = TurnDetectionMode.EXTERNAL,
operating_point: NotGivenOr[OperatingPoint] = NOT_GIVEN,
domain: NotGivenOr[str] = NOT_GIVEN,
language: str = "en",
output_locale: NotGivenOr[str] = NOT_GIVEN,
include_partials: NotGivenOr[bool] = NOT_GIVEN,
enable_diarization: NotGivenOr[bool] = NOT_GIVEN,
max_delay: NotGivenOr[float] = NOT_GIVEN,
end_of_utterance_silence_trigger: NotGivenOr[float] = NOT_GIVEN,
end_of_utterance_max_delay: NotGivenOr[float] = NOT_GIVEN,
additional_vocab: NotGivenOr[list[AdditionalVocabEntry]] = NOT_GIVEN,
punctuation_overrides: NotGivenOr[dict] = NOT_GIVEN,
speaker_sensitivity: NotGivenOr[float] = NOT_GIVEN,
max_speakers: NotGivenOr[int] = NOT_GIVEN,
speaker_active_format: NotGivenOr[str] = NOT_GIVEN,
speaker_passive_format: NotGivenOr[str] = NOT_GIVEN,
prefer_current_speaker: NotGivenOr[bool] = NOT_GIVEN,
focus_speakers: NotGivenOr[list[str]] = NOT_GIVEN,
ignore_speakers: NotGivenOr[list[str]] = NOT_GIVEN,
focus_mode: SpeakerFocusMode = SpeakerFocusMode.RETAIN,
known_speakers: NotGivenOr[list[SpeakerIdentifier]] = NOT_GIVEN,
sample_rate: int = 16000,
audio_encoding: AudioEncoding = AudioEncoding.PCM_S16LE,
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
**kwargs: Any,
):
"""Create a new instance of Speechmatics STT using the Voice SDK.
Args:
api_key: Speechmatics API key. Can be set via `api_key` argument
or `SPEECHMATICS_API_KEY` environment variable.
base_url: Custom base URL for the API. Can be set via `base_url`
argument or `SPEECHMATICS_RT_URL` environment variable. Optional.
turn_detection_mode: Controls how the STT engine detects end of speech
turns. Use `EXTERNAL` when turn boundaries are controlled manually,
for example via an external VAD or the `finalize()` method. Use
`ADAPTIVE` for simple VAD or `SMART_TURN` for ML-based endpointing.
`FIXED` uses a fixed amount of silence, as determined by the
`end_of_utterance_silence_trigger` parameter.
Defaults to `TurnDetectionMode.EXTERNAL`.
operating_point: Operating point for transcription accuracy vs. latency
tradeoff. Overrides preset if provided. Optional.
domain: Domain to use. Optional.
language: Language code for the STT model. Defaults to `en`.
output_locale: Output locale for the STT model, e.g. `en-GB`. Optional.
include_partials: Include partial segment fragments (words) in the output
of AddPartialSegment messages. Partial fragments from the STT will
always be used for speaker activity detection. This setting is used
only for the formatted text output of individual segments. Optional.
enable_diarization: Enable speaker diarization. When enabled, the STT
engine will determine and attribute words to unique speakers.
Overrides preset if provided. Defaults to True.
max_delay: Maximum delay in seconds for transcription. This forces the
STT engine to speed up the processing of transcribed words and reduces
the interval between partial and final results. Lower values can have
an impact on accuracy. Overrides preset if provided. Optional.
end_of_utterance_silence_trigger: Silence duration in seconds that
triggers end of utterance. The delay is used to wait for any further
transcribed words before emitting the `FINAL_TRANSCRIPT` events.
Overrides preset if provided. Optional.
end_of_utterance_max_delay: Maximum delay in seconds for end of utterance.
Must be greater than `end_of_utterance_silence_trigger`.
Overrides preset if provided. Optional.
additional_vocab: List of additional vocabulary entries to increase the
weight of specific words in the transcription model. Defaults to [].
punctuation_overrides: Punctuation overrides. Allows overriding the
punctuation behaviour in the STT engine. Overrides preset if provided.
Optional.
speaker_sensitivity: Diarization sensitivity. A higher value increases the
sensitivity of diarization and helps when two or more speakers have
similar voices. Overrides preset if provided. Optional.
max_speakers: Maximum number of speakers to detect during diarization.
When set, the STT engine will limit the number of unique speakers
identified. Overrides preset if provided. Optional.
speaker_active_format: Formatter for active speaker output. The attributes
`text` and `speaker_id` are available. Example: `@{speaker_id}: {text}`.
Defaults to transcription output.
speaker_passive_format: Formatter for passive speaker output. The attributes
`text` and `speaker_id` are available. Example:
`@{speaker_id} [background]: {text}`. Defaults to transcription output.
prefer_current_speaker: When True, groups of words close together are given
extra weight to be identified as the same speaker. Overrides preset if
provided. Optional.
focus_speakers: List of speaker IDs to focus on. Only these speakers are
emitted as `FINAL_TRANSCRIPT` events; others are treated as passive.
Words from passive speakers are still processed but only emitted when a
focused speaker has also said new words. Defaults to [].
ignore_speakers: List of speaker IDs to ignore. These speakers are excluded
from transcription and their speech will not trigger VAD or end of
utterance detection. By default, any speaker with a label wrapped in
double underscores (e.g. `__ASSISTANT__`) is excluded. Defaults to [].
focus_mode: Controls what happens to words from non-focused speakers. When
`RETAIN`, non-ignored speakers are processed as passive frames. When
`IGNORE`, their words are discarded entirely. Defaults to
`SpeakerFocusMode.RETAIN`.
known_speakers: List of known speaker labels and identifiers. When supplied,
the STT engine uses them to attribute words to specific speakers across
sessions. Defaults to [].
sample_rate: Audio sample rate in Hz. Defaults to 16000.
audio_encoding: Audio encoding format. Defaults to `AudioEncoding.PCM_S16LE`.
vad: Optional external Voice Activity Detector. When provided, the STT
engine's endpointing is replaced by the VAD: each audio frame is
forwarded to the VAD, and `finalize()` is called whenever the VAD
reports end of speech. Providing a VAD implicitly sets
`turn_detection_mode` to `EXTERNAL`. When `turn_detection_mode` is
`EXTERNAL` and `vad` is not provided, Silero is auto-loaded to drive
finalize. Pass `vad=None` to opt out of the auto-load if you intend
to call `finalize()` from your own logic. Defaults to NOT_GIVEN.
**kwargs: Catches deprecated parameters. A warning is logged for any
recognised deprecated name.
"""
# Resolve final turn_detection_mode — a real `vad` forces EXTERNAL.
if is_given(vad) and vad is not None and turn_detection_mode != TurnDetectionMode.EXTERNAL:
logger.info(
"External `vad` provided; overriding turn_detection_mode "
f"{turn_detection_mode.value!r} -> 'external'"
)
turn_detection_mode = TurnDetectionMode.EXTERNAL
# In EXTERNAL mode the STT does not endpoint on its own. Auto-load Silero
# so finalize() is wired up, unless the caller explicitly passed `vad=None`
# to opt out (they'll drive finalize() themselves).
if turn_detection_mode == TurnDetectionMode.EXTERNAL and not is_given(vad):
try:
from livekit.plugins.silero import VAD as SileroVAD
except ImportError as e:
raise ImportError(
"livekit-plugins-silero is required for Speechmatics with "
"turn_detection_mode=EXTERNAL (no server-side endpointing). "
"Pass `vad=None` to opt out and drive finalize() manually."
) from e
vad = SileroVAD.load()
# Normalize NOT_GIVEN -> None for downstream storage.
self._vad = vad if is_given(vad) else None
# Set default values for optional parameters
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=True,
diarization=enable_diarization if is_given(enable_diarization) else True,
aligned_transcript="chunk",
offline_recognize=False,
),
)
# Set STT options
def _set(value: Any) -> Any:
return value if is_given(value) else None
# Create STT options from parameters
self._stt_options = STTOptions(
language=LanguageCode(language),
output_locale=_set(output_locale),
domain=_set(domain),
turn_detection_mode=turn_detection_mode,
speaker_active_format=_set(speaker_active_format),
speaker_passive_format=_set(speaker_passive_format),
focus_speakers=_set(focus_speakers) or [],
ignore_speakers=_set(ignore_speakers) or [],
focus_mode=focus_mode,
known_speakers=_set(known_speakers) or [],
additional_vocab=_set(additional_vocab) or [],
operating_point=_set(operating_point),
max_delay=_set(max_delay),
end_of_utterance_silence_trigger=_set(end_of_utterance_silence_trigger),
end_of_utterance_max_delay=_set(end_of_utterance_max_delay),
punctuation_overrides=_set(punctuation_overrides),
include_partials=_set(include_partials),
enable_diarization=_set(enable_diarization),
speaker_sensitivity=_set(speaker_sensitivity),
max_speakers=_set(max_speakers),
prefer_current_speaker=_set(prefer_current_speaker),
)
# Migrate / warn about any deprecated kwargs
_check_deprecated_args(kwargs, self._stt_options)
# Validate config options
errors = self._validate_stt_options()
if errors:
raise ValueError("Invalid STT options: " + ", ".join(errors))
# Set API key
self._api_key: str = api_key if is_given(api_key) else os.getenv("SPEECHMATICS_API_KEY", "")
# Set base URL
self._base_url: str = (
base_url
if is_given(base_url)
else os.getenv("SPEECHMATICS_RT_URL", "wss://eu2.rt.speechmatics.com/v2")
)
# Validate API key and base URL
if not self._api_key:
raise ValueError("Missing Speechmatics API key")
if not self._base_url:
raise ValueError("Missing Speechmatics base URL")
# Set audio parameters
self._sample_rate = sample_rate
self._audio_encoding = audio_encoding
# Initialize list of streams
self._streams: list[SpeechStream] = []
# Show warning for external
if self._stt_options.turn_detection_mode == TurnDetectionMode.EXTERNAL:
logger.info("STT under external turn detection control")
@property
def provider(self) -> str:
return "Speechmatics"
@property
def model(self) -> str:
op = self._stt_options.operating_point
return str(op.value) if op is not None else "enhanced"
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.SpeechEvent:
raise NotImplementedError("Not implemented")
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.RecognizeStream:
"""Create a new SpeechStream."""
# Create the stream
stream = SpeechStream(
stt=self,
conn_options=conn_options,
config=self._prepare_config(language),
id=len(self._streams),
vad_instance=self._vad,
)
# Add to the list of streams
self._streams.append(stream)
# Return the stream
return stream
def _validate_stt_options(self) -> list[str]:
"""Validate options in STTOptions."""
errors: list[str] = []
opts = self._stt_options
# end_of_utterance_silence_trigger must be between 0 and 2
if opts.end_of_utterance_silence_trigger is not None and not (
0 < opts.end_of_utterance_silence_trigger < 2
):
errors.append("end_of_utterance_silence_trigger must be between 0 and 2")
# end_of_utterance_max_delay must exceed end_of_utterance_silence_trigger so the engine has time to detect silence
if (
opts.end_of_utterance_max_delay is not None
and opts.end_of_utterance_silence_trigger is not None
and opts.end_of_utterance_max_delay <= opts.end_of_utterance_silence_trigger
):
errors.append(
"end_of_utterance_max_delay must be greater than end_of_utterance_silence_trigger"
)
# server rejects speaker counts outside 2100
if opts.max_speakers is not None and not (1 < opts.max_speakers <= 100):
errors.append("max_speakers must be between 2 and 100")
# latency budget: below 0.7s is unsupported
if opts.max_delay is not None and not (0.7 <= opts.max_delay <= 4.0):
errors.append("max_delay must be between 0.7 and 4.0")
# diarization sensitivity range enforced by the engine
if opts.speaker_sensitivity is not None and not (0.0 < opts.speaker_sensitivity < 1.0):
errors.append("speaker_sensitivity must be between 0.0 and 1.0")
return errors
def _prepare_config(self, language: NotGivenOr[str] = NOT_GIVEN) -> VoiceAgentConfig:
"""Prepare VoiceAgentConfig from STTOptions."""
# Reference to STT options
opts = self._stt_options
# Preset taken from `FIXED`, `EXTERNAL`, `ADAPTIVE` or `SMART_TURN`
config = VoiceAgentConfigPreset.load(opts.turn_detection_mode.value)
# Set sample rate and encoding
config.sample_rate = self._sample_rate
config.audio_encoding = self._audio_encoding
# LanguageCode and domain
config.language = LanguageCode(language) if is_given(language) else opts.language
config.domain = opts.domain
config.output_locale = opts.output_locale
# Speaker configuration
config.speaker_config = SpeakerFocusConfig(
focus_speakers=opts.focus_speakers,
ignore_speakers=opts.ignore_speakers,
focus_mode=opts.focus_mode,
)
config.known_speakers = opts.known_speakers
# Additional vocabulary
config.additional_vocab = opts.additional_vocab
# Override preset parameters if provided
advanced_params = [
"enable_diarization",
"end_of_utterance_max_delay",
"end_of_utterance_silence_trigger",
"include_partials",
"max_delay",
"max_speakers",
"operating_point",
"prefer_current_speaker",
"punctuation_overrides",
"speaker_sensitivity",
]
# Override preset parameters if provided
for param in advanced_params:
value = getattr(opts, param)
if value is not None:
setattr(config, param, value)
# Return the config
return config
def update_speakers(
self,
focus_speakers: NotGivenOr[list[str]] = NOT_GIVEN,
ignore_speakers: NotGivenOr[list[str]] = NOT_GIVEN,
focus_mode: NotGivenOr[SpeakerFocusMode] = NOT_GIVEN,
) -> None:
"""Updates the speaker configuration.
This can update the speakers to listen to or ignore during an in-flight
transcription. Only available if diarization is enabled.
This will be applied to *all* streams (typically only one).
Args:
focus_speakers: List of speakers to focus on.
ignore_speakers: List of speakers to ignore.
focus_mode: Focus mode to use.
"""
# Do this for each stream
for stream in self._streams:
# Check if diarization is enabled
if not stream._config.enable_diarization:
raise ValueError("Diarization is not enabled")
# Update the configuration
if is_given(focus_speakers):
self._stt_options.focus_speakers = focus_speakers
stream._config.speaker_config.focus_speakers = focus_speakers
if is_given(ignore_speakers):
self._stt_options.ignore_speakers = ignore_speakers
stream._config.speaker_config.ignore_speakers = ignore_speakers
if is_given(focus_mode):
self._stt_options.focus_mode = focus_mode
stream._config.speaker_config.focus_mode = focus_mode
# Send update to client if stream is active
if stream._client and stream._client._is_connected:
stream._client.update_diarization_config(stream._config.speaker_config)
def finalize(self) -> None:
"""Finalize the turn (from external VAD).
When using an external VAD, such as Silero, this should be called
when the VAD detects the end of a speech turn. This will force the
finalization of the words in the STT buffer and emit them as final
segments.
"""
# Iterate over the streams
for stream in self._streams:
# Do not finalize if being handled by a client
if not stream._client or not stream._client._is_connected:
continue
# Check that VAD is not being handled by the client
if stream._config.vad_config is None or not stream._config.vad_config.enabled:
stream._client.finalize()
async def get_speaker_ids(
self,
) -> list[SpeakerIdentifier] | list[list[SpeakerIdentifier]]:
"""Get the list of speakers from the current STT session.
If diarization is enabled, then this will use the GET_SPEAKERS message
to retrieve the list of speakers for the current session. This should
be used once speakers have said at least 5 words to improve the results.
Returns:
list[SpeakerIdentifier]: List of speakers in the session.
"""
# Results
results: list[list[SpeakerIdentifier]] = []
# Iterate over all streams
for idx, stream in enumerate(self._streams):
# Skip streams that aren't actively connected
if stream._client is None or not stream._client._is_connected:
logger.warning(f"Not connected in stream {idx}")
results.append([])
continue
# Return if diarization is not enabled
if not stream._config.enable_diarization:
logger.warning(f"Diarization is not enabled in stream {idx}")
results.append([])
continue
# Clear the speaker result
stream._speaker_result_event.clear()
# Send message to client
await stream._client.send_message({"message": ClientMessageType.GET_SPEAKERS.value})
# Wait the result (5 second timeout)
try:
await asyncio.wait_for(
stream._speaker_result_event.wait(),
timeout=5.0,
)
except asyncio.TimeoutError:
logger.warning(f"GetSpeakers timed-out for stream {idx}")
results.append([])
continue
# Return the list of speakers
results.append(stream._speaker_result or [])
# Return the list of speakers
if len(results) == 1:
return results[0]
return results
class SpeechStream(stt.RecognizeStream):
def __init__(
self,
stt: STT,
conn_options: APIConnectOptions,
config: VoiceAgentConfig,
id: int,
vad_instance: vad.VAD | None = None,
) -> None:
super().__init__(
stt=stt,
conn_options=conn_options,
sample_rate=stt._sample_rate,
)
self._stt: STT = stt
self._id: int = id
self._config: VoiceAgentConfig = config
self._client: VoiceAgentClient | None = None
self._msg_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self._speech_duration: float = 0
self._vad: vad.VAD | None = vad_instance
self._vad_stream: vad.VADStream | None = None
self._tasks: list[asyncio.Task] = []
# Speaker result event
self._speaker_result_event: asyncio.Event = asyncio.Event()
self._speaker_result: list[SpeakerIdentifier] | None = None
async def _run(self) -> None:
"""Run the STT stream."""
logger.debug("Connecting to Speechmatics STT service")
# Config is required
if not self._config:
raise ValueError("Config is required")
# Create the Voice Agent client
self._client = VoiceAgentClient(
api_key=self._stt._api_key,
url=self._stt._base_url,
app=f"livekit/{lk_version}",
config=self._config,
)
# Add message handlers
def add_message(message: dict[str, Any]) -> None:
self._msg_queue.put_nowait(message)
# Default messages to listen to
messages: list[AgentServerMessageType] = [
AgentServerMessageType.RECOGNITION_STARTED,
AgentServerMessageType.INFO,
AgentServerMessageType.ERROR,
AgentServerMessageType.WARNING,
AgentServerMessageType.ADD_PARTIAL_SEGMENT,
AgentServerMessageType.ADD_SEGMENT,
AgentServerMessageType.START_OF_TURN,
AgentServerMessageType.END_OF_TURN,
]
# Speaker IDs message handler
if self._config.enable_diarization:
messages.append(AgentServerMessageType.SPEAKERS_RESULT)
# Optional debug messages to log
# messages.append(AgentServerMessageType.END_OF_UTTERANCE)
# messages.append(AgentServerMessageType.END_OF_TURN_PREDICTION)
# messages.append(AgentServerMessageType.DIAGNOSTICS)
# Add message handlers
for event in messages:
self._client.on(event, add_message) # type: ignore[arg-type]
# Connect to the service
await self._client.connect()
logger.debug("Connected to Speechmatics STT service")
# Open external VAD stream (if provided) before tasks start pushing frames
if self._vad is not None:
self._vad_stream = self._vad.stream()
# Audio and messaging tasks
audio_task = asyncio.create_task(self._process_audio())
message_task = asyncio.create_task(self._process_messages())
# Tasks
self._tasks = [audio_task, message_task]
# Optional VAD task: calls `client.finalize()` on end of speech
vad_task: asyncio.Task | None = None
if self._vad_stream is not None:
vad_task = asyncio.create_task(self._process_vad(self._vad_stream))
self._tasks.append(vad_task)
# Wait for tasks to complete
try:
done, pending = await asyncio.wait(self._tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
task.result()
# Disconnect the client
finally:
# Cancel audio first — stops sending audio to the STT engine
audio_task.cancel()
try:
await audio_task
except asyncio.CancelledError:
pass
# Close the VAD stream so its task drains and exits
if self._vad_stream is not None:
await self._vad_stream.aclose()
self._vad_stream = None
if vad_task is not None:
await utils.aio.cancel_and_wait(vad_task)
# Disconnect flushes final messages from the STT engine
await self._client.disconnect()
# Cancel message task after disconnect — final messages have been processed
message_task.cancel()
try:
await message_task
except asyncio.CancelledError:
pass
# Remove from active streams so stale streams aren't iterated
if self in self._stt._streams:
self._stt._streams.remove(self)
async def _process_audio(self) -> None:
"""Process audio from the input channel."""
try:
# Input audio stream
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._stt._sample_rate,
num_channels=1,
)
# Process input audio
async for data in self._input_ch:
# Handle flush sentinel
if isinstance(data, self._FlushSentinel):
frames = audio_bstream.flush()
else:
# Forward the original frame to the VAD before resampling/repacking
if self._vad_stream is not None:
self._vad_stream.push_frame(data)
frames = audio_bstream.write(data.data.tobytes())
# Send audio frames
if self._client:
for frame in frames:
self._speech_duration += frame.duration
await self._client.send_audio(frame.data.tobytes())
# No more input — let the VAD flush any pending event
if self._vad_stream is not None:
self._vad_stream.end_input()
except asyncio.CancelledError:
pass
async def _process_vad(self, vad_stream: vad.VADStream) -> None:
"""Call `client.finalize()` whenever the external VAD reports end of speech."""
try:
async for ev in vad_stream:
if ev.type == vad.VADEventType.END_OF_SPEECH:
if self._client and self._client._is_connected:
self._client.finalize()
except asyncio.CancelledError:
pass
async def _process_messages(self) -> None:
"""Process messages from the STT client."""
try:
while True:
message = await self._msg_queue.get()
self._handle_message(message)
except asyncio.CancelledError:
pass
def _handle_message(self, message: dict[str, Any]) -> None:
"""Handle a message from the STT client."""
# Get the message type
event = message.get("message", None)
# Only handle valid messages
if event is None:
return
# Log info, error and warning messages
elif event in [
AgentServerMessageType.RECOGNITION_STARTED,
AgentServerMessageType.INFO,
]:
logger.info(f"{event} -> {message}")
elif event == AgentServerMessageType.WARNING:
logger.warning(f"{event} -> {message}")
elif event == AgentServerMessageType.ERROR:
logger.error(f"{event} -> {message}")
# Handle the messages
elif event == AgentServerMessageType.ADD_PARTIAL_SEGMENT:
self._handle_partial_segment(message)
elif event == AgentServerMessageType.ADD_SEGMENT:
self._handle_segment(message)
elif event == AgentServerMessageType.START_OF_TURN:
self._handle_start_of_turn(message)
elif event == AgentServerMessageType.END_OF_TURN:
self._handle_end_of_turn(message)
# Handle the speaker result message
elif event == AgentServerMessageType.SPEAKERS_RESULT:
self._handle_speakers_result(message)
# Log all other messages
else:
logger.debug(f"{event} -> {message}")
def _handle_partial_segment(self, message: dict[str, Any]) -> None:
"""Handle AddPartialSegment events."""
segments: list[dict[str, Any]] = message.get("segments", [])
if segments:
self._send_frames(segments, is_final=False)
def _handle_segment(self, message: dict[str, Any]) -> None:
"""Handle AddSegment events."""
segments: list[dict[str, Any]] = message.get("segments", [])
if segments:
self._send_frames(segments, is_final=True)
def _handle_start_of_turn(self, message: dict[str, Any]) -> None:
"""Handle StartOfTurn events."""
logger.debug("StartOfTurn received")
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH))
def _handle_end_of_turn(self, message: dict[str, Any]) -> None:
"""Handle EndOfTurn events."""
logger.debug("EndOfTurn received")
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))
if self._speech_duration > 0.0:
usage_event = stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
alternatives=[],
recognition_usage=stt.RecognitionUsage(audio_duration=self._speech_duration),
)
self._event_ch.send_nowait(usage_event)
self._speech_duration = 0
def _handle_speakers_result(self, message: dict[str, Any]) -> None:
"""Handle SpeakersResult events."""
logger.debug("SpeakersResult received")
self._speaker_result = message.get("speakers", [])
self._speaker_result_event.set()
def _send_frames(self, segments: list[dict[str, Any]], is_final: bool) -> None:
"""Send frames to the pipeline."""
# Check for empty segments
if not segments:
return
# Get the options
opts = self._stt._stt_options
# Determine the event type
event_type = (
stt.SpeechEventType.FINAL_TRANSCRIPT
if is_final
else stt.SpeechEventType.INTERIM_TRANSCRIPT
)
# Process each segment
for segment in segments:
# Format the text based on speaker activity
is_active = segment.get("is_active", True)
format_str = (
opts.speaker_active_format if is_active else opts.speaker_passive_format
) or "{text}"
text = format_str.format(
speaker_id=segment.get("speaker_id", "UU"),
text=segment.get("text", ""),
)
# Create speech event
speech_data = stt.SpeechData(
language=LanguageCode(segment.get("language", opts.language)),
text=text,
speaker_id=segment.get("speaker_id", "UU"),
start_time=segment.get("metadata", {}).get("start_time", 0)
+ self.start_time_offset,
end_time=segment.get("metadata", {}).get("end_time", 0) + self.start_time_offset,
)
# Create speech event
event = stt.SpeechEvent(
type=event_type,
alternatives=[speech_data],
)
# Send the event
self._event_ch.send_nowait(event)
async def aclose(self) -> None:
"""Close the STT stream."""
await super().aclose()
# Cancel message processing task
if self._tasks:
for task in self._tasks:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Close the VAD stream if it's still open
if self._vad_stream is not None:
await self._vad_stream.aclose()
self._vad_stream = None
# Close the client
if self._client and self._client._is_connected:
await self._client.disconnect()
self._client = None
# Remove from active streams
if self in self._stt._streams:
self._stt._streams.remove(self)
def _check_deprecated_args(kwargs: dict[str, Any], opts: STTOptions) -> None:
"""Warn about deprecated kwargs and migrate values where possible."""
# Removed — no replacement
for name in (
"end_of_utterance_mode",
"chunk_size",
"transcription_config",
"audio_settings",
"http_session",
):
if name in kwargs:
logger.warning(f"`{name}` is deprecated and no longer used")
# Partials
if "enable_partials" in kwargs:
if opts.include_partials is None:
logger.warning("`enable_partials` is deprecated, migrated to `include_partials`")
opts.include_partials = bool(kwargs["enable_partials"])
else:
logger.warning(
"Both `enable_partials` and `include_partials` provided; using `include_partials`"
)
# Diarization
if "diarization_sensitivity" in kwargs and isinstance(
kwargs["diarization_sensitivity"], (int, float)
):
if opts.speaker_sensitivity is None:
logger.warning(
"`diarization_sensitivity` is deprecated, migrated to `speaker_sensitivity`"
)
opts.speaker_sensitivity = kwargs["diarization_sensitivity"]
else:
logger.warning(
"Both `diarization_sensitivity` and `speaker_sensitivity` provided;"
" using `speaker_sensitivity`"
)