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

967 lines
43 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 2023 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 json
import os
import time
import weakref
from dataclasses import dataclass
from typing import Literal
from urllib.parse import urlencode
import aiohttp
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectOptions,
APIStatusError,
LanguageCode,
stt,
utils,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import AudioBuffer, is_given
from livekit.agents.voice.events import ConversationItemAddedEvent
from livekit.agents.voice.io import TimedString
from .log import logger
@dataclass
class STTOptions:
sample_rate: int
buffer_size_seconds: float
encoding: Literal["pcm_s16le", "pcm_mulaw"] = "pcm_s16le"
speech_model: Literal[
"universal-streaming-english",
"universal-streaming-multilingual",
"u3-rt-pro",
"u3-rt-pro-beta-1",
"u3-pro",
"universal-3-5-pro",
] = "universal-3-5-pro"
language_detection: NotGivenOr[bool] = NOT_GIVEN
language_code: NotGivenOr[str] = NOT_GIVEN
end_of_turn_confidence_threshold: NotGivenOr[float] = NOT_GIVEN
min_turn_silence: NotGivenOr[int] = NOT_GIVEN
max_turn_silence: NotGivenOr[int] = NOT_GIVEN
format_turns: NotGivenOr[bool] = NOT_GIVEN
continuous_partials: NotGivenOr[bool] = NOT_GIVEN
interruption_delay: NotGivenOr[int] = NOT_GIVEN
keyterms_prompt: NotGivenOr[list[str]] = NOT_GIVEN
prompt: NotGivenOr[str] = NOT_GIVEN
agent_context: NotGivenOr[str] = NOT_GIVEN
previous_context_n_turns: NotGivenOr[int] = NOT_GIVEN
vad_threshold: NotGivenOr[float] = NOT_GIVEN
speaker_labels: NotGivenOr[bool] = NOT_GIVEN
max_speakers: NotGivenOr[int] = NOT_GIVEN
domain: NotGivenOr[str] = NOT_GIVEN
voice_focus: NotGivenOr[Literal["near-field", "far-field"]] = NOT_GIVEN
voice_focus_threshold: NotGivenOr[float] = NOT_GIVEN
mode: NotGivenOr[Literal["min_latency", "balanced", "max_accuracy"]] = NOT_GIVEN
# Speech models in the Universal-3 Pro family, which share the same parameter support
# (prompt, agent_context, previous_context_n_turns, continuous_partials,
# interruption_delay, voice_focus, voice_focus_threshold) and connect-time
# defaults. Mirrors the server-side `SpeechModel.is_u3_pro`.
_U3_PRO_MODELS = ("u3-rt-pro", "u3-rt-pro-beta-1", "universal-3-5-pro")
class STT(stt.STT):
def __init__(
self,
*,
api_key: NotGivenOr[str] = NOT_GIVEN,
sample_rate: int = 16000,
encoding: Literal["pcm_s16le", "pcm_mulaw"] = "pcm_s16le",
model: Literal[
"universal-streaming-english",
"universal-streaming-multilingual",
"u3-rt-pro",
"u3-rt-pro-beta-1",
"u3-pro",
"universal-3-5-pro",
] = "universal-3-5-pro",
language_detection: NotGivenOr[bool] = NOT_GIVEN,
language_code: NotGivenOr[str] = NOT_GIVEN,
end_of_turn_confidence_threshold: NotGivenOr[float] = NOT_GIVEN,
min_turn_silence: NotGivenOr[int] = NOT_GIVEN,
max_turn_silence: NotGivenOr[int] = NOT_GIVEN,
format_turns: NotGivenOr[bool] = NOT_GIVEN,
continuous_partials: NotGivenOr[bool] = NOT_GIVEN,
interruption_delay: NotGivenOr[int] = NOT_GIVEN,
keyterms_prompt: NotGivenOr[list[str]] = NOT_GIVEN,
prompt: NotGivenOr[str] = NOT_GIVEN,
agent_context: NotGivenOr[str] = NOT_GIVEN,
previous_context_n_turns: NotGivenOr[int] = NOT_GIVEN,
agent_context_carryover: bool = False,
vad_threshold: NotGivenOr[float] = NOT_GIVEN,
speaker_labels: NotGivenOr[bool] = NOT_GIVEN,
max_speakers: NotGivenOr[int] = NOT_GIVEN,
domain: NotGivenOr[str] = NOT_GIVEN,
voice_focus: NotGivenOr[Literal["near-field", "far-field"]] = NOT_GIVEN,
voice_focus_threshold: NotGivenOr[float] = NOT_GIVEN,
mode: NotGivenOr[Literal["min_latency", "balanced", "max_accuracy"]] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
buffer_size_seconds: float = 0.05,
base_url: str = "wss://streaming.assemblyai.com",
# Deprecated — use min_turn_silence instead
min_end_of_turn_silence_when_confident: NotGivenOr[int] = NOT_GIVEN,
):
"""
Args:
base_url: The AssemblyAI streaming endpoint base URL. Use the EU endpoint
(wss://streaming.eu.assemblyai.com) for streaming in the EU. Defaults to
wss://streaming.assemblyai.com.
See https://www.assemblyai.com/docs/universal-streaming for more details.
vad_threshold: The threshold for voice activity detection (VAD). A value between
0 and 1 that determines how sensitive the VAD is. Lower values make the VAD
more sensitive (detects quieter speech). Higher values make it less sensitive.
Defaults to 0.4.
language_code: Steer transcription toward a specific language (e.g. 'en', 'es',
'fr'). Accepts any common format ('en', 'en-US', 'english'); it is normalized
to a bare ISO 639-1 code before being sent. When set, the model is biased
toward this language instead of automatically detecting/code-switching across
the supported languages. Leave unset to use the model's default multilingual
behavior. Only supported with the Universal-3 Pro family models. Set at
construction (connect) time only.
min_turn_silence: Minimum silence in ms before a confident end-of-turn is finalized.
min_end_of_turn_silence_when_confident: Deprecated. Use min_turn_silence instead.
continuous_partials: Whether to emit additional partial transcripts during long
turns at a steady ~3 second cadence. By default, partials are emitted at
two points: one at 750 ms after turn start (configurable via
`interruption_delay`), and one each time silence exceeds
`min_turn_silence` without ending the turn. When enabled (default in
LiveKit; AssemblyAI server defaults to False), additional partials covering
the full turn transcript are emitted approximately every 3 seconds while
speech continues, on top of those baseline partials. Only supported with
the Universal-3 Pro family models.
interruption_delay: How soon the first early partial is emitted, in ms.
Range 01000, default 500. Lower values produce faster time-to-first-token
for barge-in; higher values produce more confident first partials. Only
supported with the Universal-3 Pro family models.
agent_context: Free-text context describing what the agent said, used to bias
transcription of the user's reply. Set at construction or updated per-turn
via `update_options(agent_context=...)`. Only supported with the
Universal-3 Pro family models (max 1500 characters).
previous_context_n_turns: Maximum number of prior conversation entries (user
transcripts and any `agent_context` values) carried forward as context for
each transcription. Set to 0 to disable automatic context carryover
entirely; leave unset to use the server default (recommended). Range 0100.
Only supported with the Universal-3 Pro family models. Set at construction
(connect) time only; it cannot be changed via `update_options`.
agent_context_carryover: When the model supports it, let an ``AgentSession`` push each
assistant reply into ``agent_context`` so it is carried into the model's
conversation context. Defaults to False; set True to enable. Prior user turns are
carried automatically by the model regardless of this flag. Ignored on models
without context support.
voice_focus: Voice Focus isolates the primary voice and suppresses background
noise (chatter, keyboard clicks, fan hum, room echo) before the audio reaches
the model. Use 'near-field' for headsets, handsets, and close-talking
microphones; use 'far-field' for conference rooms, laptop mics, and other
distant-mic setups. Only supported with the Universal-3 Pro family models.
Set at construction (connect) time only.
See https://www.assemblyai.com/docs/streaming/voice-focus.
voice_focus_threshold: Controls how aggressively background audio is suppressed,
a float between 0.0 and 1.0 (higher is more aggressive). Only takes effect
alongside `voice_focus`. Only supported with the Universal-3 Pro family
models. Set at construction (connect) time only.
mode: Accuracy/latency preset for the Universal-3 Pro family: 'min_latency'
(fastest time-to-text), 'balanced' (the server default, recommended for
voice agents), or 'max_accuracy' (highest accuracy, for scribes/post-call).
The model applies its own per-mode silence tuning. To let that tuning take
effect, the plugin suppresses its default 100ms min/max turn-silence windows
when a mode is set; values you pass explicitly for `min_turn_silence` /
`max_turn_silence` still take precedence over the mode's defaults.
Leave unset to use the server default. Only supported with the Universal-3 Pro
family models. Set at construction (connect) time only.
"""
# agent_context carryover is only available on the u3-rt-pro family
# ("u3-pro" is normalized to "u3-rt-pro" below) and is opt-in via the user
supports_carryover = model in _U3_PRO_MODELS or model == "u3-pro"
if agent_context_carryover and not supports_carryover:
logger.warning(
"agent_context_carryover is enabled but model %r does not support it; ignoring",
model,
)
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=True,
aligned_transcript="word",
offline_recognize=False,
diarization=is_given(speaker_labels) and speaker_labels is True,
keyterms=True,
chat_context=agent_context_carryover and supports_carryover,
),
)
if model == "u3-pro":
logger.warning("'u3-pro' is deprecated, use 'universal-3-5-pro' instead.")
model = "universal-3-5-pro"
# These parameters are only supported by the Universal-3 Pro family of models.
if model not in _U3_PRO_MODELS:
_u3_pro_only_params = {
"prompt": prompt,
"agent_context": agent_context,
"previous_context_n_turns": previous_context_n_turns,
"continuous_partials": continuous_partials,
"interruption_delay": interruption_delay,
"voice_focus": voice_focus,
"voice_focus_threshold": voice_focus_threshold,
"mode": mode,
"language_code": language_code,
}
for _param_name, _param_value in _u3_pro_only_params.items():
if is_given(_param_value):
raise ValueError(
f"The {_param_name!r} parameter is only supported with the "
f"{', '.join(_U3_PRO_MODELS)} models."
)
# LiveKit defaults continuous_partials to True (vs. AssemblyAI's server default of
# False) for steady-cadence partials. This parameter is only supported for
# the Universal-3 Pro family, enforced by the validation above.
if not is_given(continuous_partials) and model in _U3_PRO_MODELS:
continuous_partials = True
self._base_url = base_url
assemblyai_api_key = api_key if is_given(api_key) else os.environ.get("ASSEMBLYAI_API_KEY")
if not assemblyai_api_key:
raise ValueError(
"AssemblyAI API key is required. "
"Pass one in via the `api_key` parameter, "
"or set it as the `ASSEMBLYAI_API_KEY` environment variable"
)
self._api_key = assemblyai_api_key
# Handle deprecated min_end_of_turn_silence_when_confident
if is_given(min_end_of_turn_silence_when_confident):
logger.warning(
"'min_end_of_turn_silence_when_confident' is deprecated, "
"use 'min_turn_silence' instead."
)
if not is_given(min_turn_silence):
min_turn_silence = min_end_of_turn_silence_when_confident
# we want to minimize latency as much as possible, it's ok if the phrase arrives in multiple final transcripts
# designed to work with LK's end of turn models.
# Skip this default when a `mode` preset is selected so the server's
# per-mode silence tuning governs instead of being overridden by 100.
if not is_given(min_turn_silence) and not is_given(mode):
min_turn_silence = 100
# Normalize to a bare ISO 639-1 code (e.g. "es-ES" / "Spanish" -> "es"),
# the form AssemblyAI's language steering expects.
normalized_language_code: NotGivenOr[str] = NOT_GIVEN
if is_given(language_code):
normalized_language_code = LanguageCode(language_code).language
self._opts = STTOptions(
sample_rate=sample_rate,
buffer_size_seconds=buffer_size_seconds,
encoding=encoding,
speech_model=model,
language_detection=language_detection,
language_code=normalized_language_code,
end_of_turn_confidence_threshold=end_of_turn_confidence_threshold,
min_turn_silence=min_turn_silence,
max_turn_silence=max_turn_silence,
format_turns=format_turns,
continuous_partials=continuous_partials,
interruption_delay=interruption_delay,
keyterms_prompt=keyterms_prompt,
prompt=prompt,
agent_context=agent_context,
previous_context_n_turns=previous_context_n_turns,
vad_threshold=vad_threshold,
speaker_labels=speaker_labels,
max_speakers=max_speakers,
domain=domain,
voice_focus=voice_focus,
voice_focus_threshold=voice_focus_threshold,
mode=mode,
)
self._session = http_session
# user keyterms; _opts.keyterms_prompt holds the effective set (user + session)
self._user_keyterms: list[str] = list(keyterms_prompt or [])
self._session_keyterms: list[str] = []
self._streams = weakref.WeakSet[SpeechStream]()
@property
def model(self) -> str:
return self._opts.speech_model
@property
def provider(self) -> str:
return "AssemblyAI"
@property
def session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _recognize_impl(
self,
buffer: AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> stt.SpeechEvent:
raise NotImplementedError("Not implemented")
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = dataclasses.replace(self._opts)
stream = SpeechStream(
stt=self,
conn_options=conn_options,
opts=config,
api_key=self._api_key,
http_session=self.session,
base_url=self._base_url,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
buffer_size_seconds: NotGivenOr[float] = NOT_GIVEN,
end_of_turn_confidence_threshold: NotGivenOr[float] = NOT_GIVEN,
min_turn_silence: NotGivenOr[int] = NOT_GIVEN,
max_turn_silence: NotGivenOr[int] = NOT_GIVEN,
prompt: NotGivenOr[str] = NOT_GIVEN,
agent_context: NotGivenOr[str] = NOT_GIVEN,
keyterms_prompt: NotGivenOr[list[str]] = NOT_GIVEN,
vad_threshold: NotGivenOr[float] = NOT_GIVEN,
continuous_partials: NotGivenOr[bool] = NOT_GIVEN,
interruption_delay: NotGivenOr[int] = NOT_GIVEN,
# Deprecated — use min_turn_silence instead
min_end_of_turn_silence_when_confident: NotGivenOr[int] = NOT_GIVEN,
) -> None:
if is_given(min_end_of_turn_silence_when_confident):
logger.warning(
"'min_end_of_turn_silence_when_confident' is deprecated, "
"use 'min_turn_silence' instead."
)
if not is_given(min_turn_silence):
min_turn_silence = min_end_of_turn_silence_when_confident
if is_given(buffer_size_seconds):
self._opts.buffer_size_seconds = buffer_size_seconds
if is_given(end_of_turn_confidence_threshold):
self._opts.end_of_turn_confidence_threshold = end_of_turn_confidence_threshold
if is_given(min_turn_silence):
self._opts.min_turn_silence = min_turn_silence
if is_given(max_turn_silence):
self._opts.max_turn_silence = max_turn_silence
if is_given(prompt):
self._opts.prompt = prompt
if is_given(agent_context):
self._opts.agent_context = agent_context
if is_given(keyterms_prompt):
self._user_keyterms = list(keyterms_prompt)
# re-merge with the active session keyterms so a user update doesn't drop them
keyterms_prompt = list(dict.fromkeys([*self._user_keyterms, *self._session_keyterms]))
self._opts.keyterms_prompt = keyterms_prompt
if is_given(vad_threshold):
self._opts.vad_threshold = vad_threshold
if is_given(continuous_partials):
self._opts.continuous_partials = continuous_partials
if is_given(interruption_delay):
self._opts.interruption_delay = interruption_delay
for stream in self._streams:
stream.update_options(
buffer_size_seconds=buffer_size_seconds,
end_of_turn_confidence_threshold=end_of_turn_confidence_threshold,
min_turn_silence=min_turn_silence,
max_turn_silence=max_turn_silence,
prompt=prompt,
agent_context=agent_context,
keyterms_prompt=keyterms_prompt,
vad_threshold=vad_threshold,
continuous_partials=continuous_partials,
interruption_delay=interruption_delay,
)
def _update_session_keyterms(self, keyterms: list[str]) -> None:
if keyterms == self._session_keyterms:
return
self._session_keyterms = list(keyterms)
merged = list(dict.fromkeys([*self._user_keyterms, *keyterms]))
self._opts.keyterms_prompt = merged
# applied live via the stream's UpdateConfiguration (no reconnect)
for stream in self._streams:
stream.update_options(keyterms_prompt=merged)
def _push_conversation_item(self, ev: ConversationItemAddedEvent) -> None:
if (
(chat_item := ev.item).type == "message"
and chat_item.role == "assistant"
and chat_item.text_content
):
self.update_options(agent_context=chat_item.text_content)
class SpeechStream(stt.SpeechStream):
# Used to close websocket
_CLOSE_MSG: str = json.dumps({"type": "Terminate"})
def __init__(
self,
*,
stt: STT,
opts: STTOptions,
conn_options: APIConnectOptions,
api_key: str,
http_session: aiohttp.ClientSession,
base_url: str,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate)
self._opts = opts
self._api_key = api_key
self._session = http_session
self._base_url = base_url
self._speech_duration: float = 0
self._last_preflight_start_time: float = 0
self._config_update_queue: asyncio.Queue[dict] = asyncio.Queue()
self._session_id: str | None = None
self._expires_at: int | None = None
self._last_frame_sent_at: float | None = None
@property
def session_id(self) -> str | None:
"""The AssemblyAI session ID. Set when the WebSocket connection is established
(before any speech events). None until the connection completes.
Share this with the AssemblyAI team when reporting issues."""
return self._session_id
@property
def expires_at(self) -> int | None:
"""Unix timestamp when the AssemblyAI session expires. Set alongside session_id
when the WebSocket connection is established."""
return self._expires_at
def update_options(
self,
*,
buffer_size_seconds: NotGivenOr[float] = NOT_GIVEN,
end_of_turn_confidence_threshold: NotGivenOr[float] = NOT_GIVEN,
min_turn_silence: NotGivenOr[int] = NOT_GIVEN,
max_turn_silence: NotGivenOr[int] = NOT_GIVEN,
prompt: NotGivenOr[str] = NOT_GIVEN,
agent_context: NotGivenOr[str] = NOT_GIVEN,
keyterms_prompt: NotGivenOr[list[str]] = NOT_GIVEN,
vad_threshold: NotGivenOr[float] = NOT_GIVEN,
continuous_partials: NotGivenOr[bool] = NOT_GIVEN,
interruption_delay: NotGivenOr[int] = NOT_GIVEN,
# Deprecated — use min_turn_silence instead
min_end_of_turn_silence_when_confident: NotGivenOr[int] = NOT_GIVEN,
) -> None:
if is_given(min_end_of_turn_silence_when_confident):
logger.warning(
"'min_end_of_turn_silence_when_confident' is deprecated, "
"use 'min_turn_silence' instead."
)
if not is_given(min_turn_silence):
min_turn_silence = min_end_of_turn_silence_when_confident
if is_given(buffer_size_seconds):
self._opts.buffer_size_seconds = buffer_size_seconds
if is_given(end_of_turn_confidence_threshold):
self._opts.end_of_turn_confidence_threshold = end_of_turn_confidence_threshold
if is_given(min_turn_silence):
self._opts.min_turn_silence = min_turn_silence
if is_given(max_turn_silence):
self._opts.max_turn_silence = max_turn_silence
if is_given(prompt):
self._opts.prompt = prompt
if is_given(agent_context):
self._opts.agent_context = agent_context
if is_given(keyterms_prompt):
self._opts.keyterms_prompt = keyterms_prompt
if is_given(vad_threshold):
self._opts.vad_threshold = vad_threshold
if is_given(continuous_partials):
self._opts.continuous_partials = continuous_partials
if is_given(interruption_delay):
self._opts.interruption_delay = interruption_delay
# Send UpdateConfiguration message over the active websocket
config_msg: dict = {"type": "UpdateConfiguration"}
if is_given(prompt):
config_msg["prompt"] = prompt
if is_given(agent_context):
config_msg["agent_context"] = agent_context
if is_given(keyterms_prompt):
config_msg["keyterms_prompt"] = keyterms_prompt
if is_given(max_turn_silence):
config_msg["max_turn_silence"] = max_turn_silence
if is_given(min_turn_silence):
config_msg["min_turn_silence"] = min_turn_silence
if is_given(end_of_turn_confidence_threshold):
config_msg["end_of_turn_confidence_threshold"] = end_of_turn_confidence_threshold
if is_given(continuous_partials):
config_msg["continuous_partials"] = continuous_partials
if is_given(interruption_delay):
config_msg["interruption_delay"] = interruption_delay
if is_given(vad_threshold):
config_msg["vad_threshold"] = vad_threshold
if len(config_msg) > 1:
self._config_update_queue.put_nowait(config_msg)
def force_endpoint(self) -> None:
"""Force-finalize the current turn immediately."""
self._config_update_queue.put_nowait({"type": "ForceEndpoint"})
async def _run(self) -> None:
"""Run a single websocket connection to AssemblyAI."""
closing_ws = False
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
anchored = False
samples_per_buffer = self._opts.sample_rate // round(1 / self._opts.buffer_size_seconds)
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=1,
samples_per_channel=samples_per_buffer,
)
# forward inputs to AssemblyAI
# if we receive a close message, signal it to AssemblyAI and break.
# the recv task will then make sure to process the remaining audio and stop
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
frames = audio_bstream.flush()
else:
frames = audio_bstream.write(data.data.tobytes())
for frame in frames:
if not anchored:
# Anchor the stream's wall-clock to the moment just
# before the first frame is sent — aligned with the
# server's stream-relative zero used by
# SpeechStarted.timestamp.
self.start_time = time.time()
anchored = True
self._speech_duration += frame.duration
await ws.send_bytes(frame.data.tobytes())
self._last_frame_sent_at = time.time()
closing_ws = True
logger.debug("AssemblyAI sending close message session=%s", self._session_id)
await ws.send_str(SpeechStream._CLOSE_MSG)
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
consecutive_timeouts = 0
while True:
try:
msg = await asyncio.wait_for(ws.receive(), timeout=5)
consecutive_timeouts = 0
except asyncio.TimeoutError:
if closing_ws:
break
consecutive_timeouts += 1
# First warning at 15s, then every 15s while silence continues.
# `session=None` here means WS connected but AAI never sent `Begin`.
if consecutive_timeouts % 3 == 0:
logger.warning(
"AssemblyAI no messages received for %ds session=%s",
consecutive_timeouts * 5,
self._session_id,
)
# If the send side is also idle, the stall is upstream
# of this plugin (no audio reaching us). Otherwise
# frames are flowing and the stall is downstream.
if self._last_frame_sent_at is not None:
send_idle_s = time.time() - self._last_frame_sent_at
if send_idle_s >= 15:
logger.warning(
"AssemblyAI no audio frames sent for %.0fs session=%s",
send_idle_s,
self._session_id,
)
continue
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if closing_ws: # close is expected, see SpeechStream.aclose
return
logger.warning(
"AssemblyAI WebSocket closed unexpectedly "
"session=%s code=%s data=%s extra=%s",
self._session_id,
ws.close_code,
msg.data,
msg.extra,
)
raise APIStatusError(
"AssemblyAI connection closed unexpectedly",
status_code=ws.close_code or -1,
body=f"{msg.data=} {msg.extra=}",
)
if msg.type != aiohttp.WSMsgType.TEXT:
logger.error(
"unexpected AssemblyAI message type=%s session=%s",
msg.type,
self._session_id,
)
continue
try:
self._process_stream_event(json.loads(msg.data))
except Exception:
logger.exception(
"failed to process AssemblyAI message session=%s",
self._session_id,
)
async def send_config_task(ws: aiohttp.ClientWebSocketResponse) -> None:
"""Send config updates and control messages immediately, independent of audio."""
while True:
config_msg = await self._config_update_queue.get()
await ws.send_str(json.dumps(config_msg))
ws: aiohttp.ClientWebSocketResponse | None = None
try:
ws = await self._connect_ws()
config_task = asyncio.create_task(send_config_task(ws))
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
]
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.gracefully_cancel(config_task, *tasks)
finally:
if ws is not None:
await ws.close()
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
# Universal-3 Pro family defaults: min=100, max=min (so both 100 unless overridden).
# When a `mode` preset is selected, leave them unset (None) unless the
# caller set them explicitly, so the server's per-mode silence tuning is
# not overridden by the latency-optimized 100ms default.
min_silence: int | None
max_silence: int | None
if self._opts.speech_model in _U3_PRO_MODELS:
default_min = None if is_given(self._opts.mode) else 100
min_silence = (
self._opts.min_turn_silence
if is_given(self._opts.min_turn_silence)
else default_min
)
max_silence = (
self._opts.max_turn_silence
if is_given(self._opts.max_turn_silence)
else min_silence
)
else:
min_silence = (
self._opts.min_turn_silence if is_given(self._opts.min_turn_silence) else None
)
max_silence = (
self._opts.max_turn_silence if is_given(self._opts.max_turn_silence) else None
)
live_config = {
"sample_rate": self._opts.sample_rate,
"encoding": self._opts.encoding,
"speech_model": self._opts.speech_model,
"format_turns": self._opts.format_turns if is_given(self._opts.format_turns) else None,
"continuous_partials": self._opts.continuous_partials
if is_given(self._opts.continuous_partials)
else None,
"interruption_delay": self._opts.interruption_delay
if is_given(self._opts.interruption_delay)
else None,
"end_of_turn_confidence_threshold": self._opts.end_of_turn_confidence_threshold
if is_given(self._opts.end_of_turn_confidence_threshold)
else None,
"min_turn_silence": min_silence,
"max_turn_silence": max_silence,
"keyterms_prompt": json.dumps(self._opts.keyterms_prompt)
if self._opts.keyterms_prompt
else None,
"language_detection": self._opts.language_detection
if is_given(self._opts.language_detection)
else True
if "multilingual" in self._opts.speech_model
or self._opts.speech_model in _U3_PRO_MODELS
else False,
"language_code": self._opts.language_code
if is_given(self._opts.language_code)
else None,
"prompt": self._opts.prompt if is_given(self._opts.prompt) else None,
"agent_context": self._opts.agent_context
if is_given(self._opts.agent_context)
else None,
"previous_context_n_turns": self._opts.previous_context_n_turns
if is_given(self._opts.previous_context_n_turns)
else None,
"vad_threshold": self._opts.vad_threshold
if is_given(self._opts.vad_threshold)
else None,
"speaker_labels": self._opts.speaker_labels
if is_given(self._opts.speaker_labels)
else None,
"max_speakers": self._opts.max_speakers if is_given(self._opts.max_speakers) else None,
"domain": self._opts.domain if is_given(self._opts.domain) else None,
"voice_focus": self._opts.voice_focus if is_given(self._opts.voice_focus) else None,
"voice_focus_threshold": self._opts.voice_focus_threshold
if is_given(self._opts.voice_focus_threshold)
else None,
"mode": self._opts.mode if is_given(self._opts.mode) else None,
}
headers = {
"Authorization": self._api_key,
"Content-Type": "application/json",
"User-Agent": "AssemblyAI/1.0 (integration=Livekit)",
}
filtered_config = {
k: ("true" if v else "false") if isinstance(v, bool) else v
for k, v in live_config.items()
if v is not None
}
url = f"{self._base_url}/v3/ws?{urlencode(filtered_config)}"
logger.debug(
"connecting to AssemblyAI model=%s base_url=%s",
self._opts.speech_model,
self._base_url,
)
ws = await self._session.ws_connect(url, headers=headers)
logger.debug(
"AssemblyAI WebSocket connected status=%s",
ws._response.status if ws._response is not None else None,
)
return ws
def _process_stream_event(self, data: dict) -> None:
message_type = data.get("type")
if message_type == "Begin":
self._session_id = data.get("id")
self._expires_at = data.get("expires_at")
logger.info(
"AssemblyAI session started id=%s expires_at=%s",
self._session_id,
self._expires_at,
)
return
if message_type == "SpeechStarted":
# SpeechStarted can arrive well after actual speech onset. The
# `timestamp` field carries the server VAD's onset time in stream-
# relative ms. Convert to wall-clock by adding self.start_time
# (the stream's wall-clock anchor) so the framework records an
# accurate _speech_start_time instead of message arrival.
timestamp_ms = data.get("timestamp")
speech_start_time: float | None = None
if timestamp_ms is not None:
speech_start_time = self.start_time + timestamp_ms / 1000
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.START_OF_SPEECH,
speech_start_time=speech_start_time,
)
)
return
if message_type == "Termination":
audio_duration = data.get("audio_duration_seconds")
session_duration = data.get("session_duration_seconds")
logger.debug(
"AssemblyAI session terminated session=%s audio_duration=%ss session_duration=%ss",
self._session_id,
audio_duration,
session_duration,
)
return
if message_type != "Turn":
logger.debug(
"AssemblyAI unhandled message type=%s session=%s",
message_type,
self._session_id,
)
return
words = data.get("words", [])
end_of_turn = data.get("end_of_turn", False)
end_of_turn_confidence = data.get("end_of_turn_confidence")
turn_is_formatted = data.get("turn_is_formatted", False)
utterance = data.get("utterance", "")
transcript = data.get("transcript", "")
language = LanguageCode(data.get("language_code", "en"))
# Extract speaker label for diarization (returns "A", "B", ... or "UNKNOWN")
speaker_label = data.get("speaker_label")
speaker_id = speaker_label if speaker_label and speaker_label != "UNKNOWN" else None
# transcript (final) and words (interim) are cumulative
# utterance (preflight) is chunk based
start_time: float = 0
end_time: float = 0
confidence: float = 0
# word timestamps are in milliseconds
# https://www.assemblyai.com/docs/api-reference/streaming-api/streaming-api#receive.receiveTurn.words
timed_words: list[TimedString] = [
TimedString(
text=word.get("text", ""),
start_time=word.get("start", 0) / 1000 + self.start_time_offset,
end_time=word.get("end", 0) / 1000 + self.start_time_offset,
start_time_offset=self.start_time_offset,
confidence=word.get("confidence", 0),
)
for word in words
]
# words are cumulative
if timed_words:
interim_text = " ".join(word for word in timed_words)
start_time = timed_words[0].start_time or start_time
end_time = timed_words[-1].end_time or end_time
confidence = sum(word.confidence or 0.0 for word in timed_words) / len(timed_words)
interim_event = stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=[
stt.SpeechData(
language=language,
text=interim_text,
start_time=start_time,
end_time=end_time,
words=timed_words,
confidence=confidence,
speaker_id=speaker_id,
)
],
)
self._event_ch.send_nowait(interim_event)
logger.debug(
"interim transcript session=%s end_of_turn_confidence=%s",
self._session_id,
end_of_turn_confidence,
)
if utterance:
if self._last_preflight_start_time == 0.0:
self._last_preflight_start_time = start_time
# utterance is chunk based so we need to filter the words to
# only include the ones that are part of the current utterance
utterance_words = [
word
for word in timed_words
if is_given(word.start_time) and word.start_time >= self._last_preflight_start_time
]
utterance_confidence = sum(word.confidence or 0.0 for word in utterance_words) / max(
len(utterance_words), 1
)
final_event = stt.SpeechEvent(
type=stt.SpeechEventType.PREFLIGHT_TRANSCRIPT,
alternatives=[
stt.SpeechData(
language=language,
text=utterance,
start_time=self._last_preflight_start_time,
end_time=end_time,
words=utterance_words,
confidence=utterance_confidence,
speaker_id=speaker_id,
)
],
)
self._event_ch.send_nowait(final_event)
logger.debug(
"preflight transcript session=%s end_of_turn_confidence=%s",
self._session_id,
end_of_turn_confidence,
)
self._last_preflight_start_time = end_time
if end_of_turn and (
not (is_given(self._opts.format_turns) and self._opts.format_turns) or turn_is_formatted
):
final_event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
stt.SpeechData(
language=language,
text=transcript,
start_time=start_time,
end_time=end_time,
words=timed_words,
confidence=confidence,
speaker_id=speaker_id,
)
],
)
self._event_ch.send_nowait(final_event)
logger.debug(
"final transcript session=%s end_of_turn_confidence=%s",
self._session_id,
end_of_turn_confidence,
)
if words:
first_word_start = words[0].get("start", 0)
last_word_end = words[-1].get("end", 0)
logger.debug(
"turn speech_duration=%.3fs session=%s (from word timestamps)",
(last_word_end - first_word_start) / 1000,
self._session_id,
)
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
self._last_preflight_start_time = 0.0