livekit--agents
984 行
38 KiB
Python
984 行
38 KiB
Python
# 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 2–100
|
|
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`"
|
|
)
|