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

466 lines
17 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 base64
import json
import os
import weakref
from dataclasses import dataclass
from typing import Any
import aiohttp
from livekit import rtc
from livekit.agents import (
APIConnectionError,
APIStatusError,
APITimeoutError,
LanguageCode,
stt,
utils,
)
from livekit.agents.stt import SpeechEventType
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from ._utils import PeriodicCollector
from .log import logger
DEFAULT_MODEL = "inworld/inworld-stt-1"
DEFAULT_LANGUAGE = "en-US"
DEFAULT_SAMPLE_RATE = 16000
DEFAULT_NUM_CHANNELS = 1
DEFAULT_API_URL = "https://api.inworld.ai/"
WS_ENDPOINT = "stt/v1/transcribe:streamBidirectional"
# Inworld supports multiple STT models (e.g. inworld/inworld-stt-1,
# assemblyai/universal-streaming-multilingual, soniox/stt-rt-v4, etc.).
# No validation is done here — pass any model string through so new models work
# without a plugin update.
@dataclass
class _STTOptions:
model: str
language: str
sample_rate: int
num_channels: int
enable_voice_profile: bool
voice_profile_top_n: int
vad_threshold: NotGivenOr[float]
min_end_of_turn_silence_when_confident: int
end_of_turn_confidence_threshold: float
class STT(stt.STT):
def __init__(
self,
*,
api_key: NotGivenOr[str] = NOT_GIVEN,
model: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
num_channels: NotGivenOr[int] = NOT_GIVEN,
enable_voice_profile: bool = True,
voice_profile_top_n: int = 1,
vad_threshold: NotGivenOr[float] = NOT_GIVEN,
min_end_of_turn_silence_when_confident: int = 200,
end_of_turn_confidence_threshold: float = 0.3,
base_url: str = DEFAULT_API_URL,
http_session: aiohttp.ClientSession | None = None,
) -> None:
"""Create a new instance of Inworld STT.
Args:
api_key: Inworld API key. If not provided, reads from INWORLD_API_KEY env var.
model: STT model identifier. Any model string supported by the Inworld STT API
is accepted (e.g. "inworld/inworld-stt-1",
"assemblyai/universal-streaming-multilingual", "soniox/stt-rt-v4").
Defaults to "inworld/inworld-stt-1".
language: Language code. Defaults to "en-US".
sample_rate: Audio sample rate in Hz. Defaults to 16000.
num_channels: Number of audio channels. Defaults to 1.
enable_voice_profile: Enable voice profiling (age, gender, emotion, accent).
Defaults to True.
voice_profile_top_n: Number of top voice profile results per category.
vad_threshold: VAD sensitivity threshold.
min_end_of_turn_silence_when_confident: Minimum silence (ms) before end-of-turn
when confidence is high. Defaults to 200.
end_of_turn_confidence_threshold: Confidence threshold for end-of-turn detection.
Lower values trigger end-of-turn more eagerly. Defaults to 0.3.
base_url: Base URL for the Inworld API. Defaults to "https://api.inworld.ai/".
http_session: Optional aiohttp.ClientSession to use.
"""
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True, interim_results=True, offline_recognize=False
),
)
api_key = api_key if utils.is_given(api_key) else os.getenv("INWORLD_API_KEY", "")
if not api_key:
raise ValueError("Inworld API key required. Set INWORLD_API_KEY or provide api_key.")
self._authorization = f"Basic {api_key}"
self._base_url = base_url
self._http_session = http_session
self._streams: weakref.WeakSet[SpeechStream] = weakref.WeakSet()
self._opts = _STTOptions(
model=model if utils.is_given(model) else DEFAULT_MODEL,
language=language if utils.is_given(language) else DEFAULT_LANGUAGE,
sample_rate=sample_rate if utils.is_given(sample_rate) else DEFAULT_SAMPLE_RATE,
num_channels=num_channels if utils.is_given(num_channels) else DEFAULT_NUM_CHANNELS,
enable_voice_profile=enable_voice_profile,
voice_profile_top_n=voice_profile_top_n,
vad_threshold=vad_threshold,
min_end_of_turn_silence_when_confident=min_end_of_turn_silence_when_confident,
end_of_turn_confidence_threshold=end_of_turn_confidence_threshold,
)
@property
def model(self) -> str:
return self._opts.model
@property
def provider(self) -> str:
return "Inworld"
def update_options(
self,
*,
model: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
enable_voice_profile: NotGivenOr[bool] = NOT_GIVEN,
voice_profile_top_n: NotGivenOr[int] = NOT_GIVEN,
vad_threshold: NotGivenOr[float] = NOT_GIVEN,
min_end_of_turn_silence_when_confident: NotGivenOr[int] = NOT_GIVEN,
end_of_turn_confidence_threshold: NotGivenOr[float] = NOT_GIVEN,
) -> None:
"""Update STT options. Changes apply to new streams only.
Args:
model: STT model identifier (e.g. "inworld/inworld-stt-1",
"assemblyai/universal-streaming-multilingual"). Any model string is accepted.
language: Language code (e.g. "en-US").
enable_voice_profile: Enable voice profiling.
voice_profile_top_n: Number of top voice profile results.
vad_threshold: VAD sensitivity threshold.
min_end_of_turn_silence_when_confident: Min silence (ms) for end-of-turn.
end_of_turn_confidence_threshold: Confidence threshold for end-of-turn.
"""
if utils.is_given(model):
self._opts.model = model
if utils.is_given(language):
self._opts.language = language
if utils.is_given(enable_voice_profile):
self._opts.enable_voice_profile = enable_voice_profile
if utils.is_given(voice_profile_top_n):
self._opts.voice_profile_top_n = voice_profile_top_n
if utils.is_given(vad_threshold):
self._opts.vad_threshold = vad_threshold
if utils.is_given(min_end_of_turn_silence_when_confident):
self._opts.min_end_of_turn_silence_when_confident = (
min_end_of_turn_silence_when_confident
)
if utils.is_given(end_of_turn_confidence_threshold):
self._opts.end_of_turn_confidence_threshold = end_of_turn_confidence_threshold
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._http_session:
self._http_session = utils.http_context.http_session()
return self._http_session
async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions,
) -> stt.SpeechEvent:
raise NotImplementedError(
"Inworld STT does not support batch recognition — use streaming via stream()"
)
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
stream = SpeechStream(
stt=self,
conn_options=conn_options,
language=language if utils.is_given(language) else self._opts.language,
)
self._streams.add(stream)
return stream
class SpeechStream(stt.SpeechStream):
def __init__(
self,
*,
stt: STT,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
language: str = DEFAULT_LANGUAGE,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=stt._opts.sample_rate)
self._stt: STT = stt
self._language = language
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._reconnect_event = asyncio.Event()
self._speaking = False
self._request_id = ""
self._audio_duration_collector: PeriodicCollector[float] = PeriodicCollector(
callback=self._on_audio_duration_report,
duration=5.0,
)
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._stt._http_session:
self._stt._http_session = utils.http_context.http_session()
return self._stt._http_session
def _build_transcribe_config(self) -> dict:
opts = self._stt._opts
config: dict = {
"modelId": opts.model,
"audioEncoding": "LINEAR16",
"sampleRateHertz": opts.sample_rate,
"numberOfChannels": opts.num_channels,
"language": self._language,
}
if opts.enable_voice_profile:
config["voiceProfileConfig"] = {
"enableVoiceProfile": True,
"topN": opts.voice_profile_top_n,
}
config["endOfTurnConfidenceThreshold"] = opts.end_of_turn_confidence_threshold
inworld_v1_config: dict = {
"minEndOfTurnSilenceWhenConfident": opts.min_end_of_turn_silence_when_confident,
}
if utils.is_given(opts.vad_threshold):
inworld_v1_config["vadThreshold"] = opts.vad_threshold
config["inworldSttV1Config"] = inworld_v1_config
return config
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
ws_url = self._stt._base_url.replace("https://", "wss://").replace("http://", "ws://")
ws_url = ws_url.rstrip("/") + "/" + WS_ENDPOINT
ws = await asyncio.wait_for(
self._ensure_session().ws_connect(
ws_url,
headers={"Authorization": self._stt._authorization},
),
timeout=self._conn_options.timeout,
)
self._request_id = utils.shortuuid()
await ws.send_str(json.dumps({"transcribeConfig": self._build_transcribe_config()}))
logger.debug("Inworld STT WebSocket connection established")
return ws
async def _run(self) -> None:
while True:
try:
ws = await self._connect_ws()
self._ws = ws
tasks = [
asyncio.create_task(self._send_audio_task()),
asyncio.create_task(self._recv_messages_task()),
]
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
tasks_group: asyncio.Future[Any] = asyncio.gather(*tasks)
try:
done, _ = await asyncio.wait(
[tasks_group, wait_reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
if task != wait_reconnect_task:
task.result()
if wait_reconnect_task not in done:
break
self._reconnect_event.clear()
finally:
await utils.aio.gracefully_cancel(*tasks, wait_reconnect_task)
tasks_group.cancel()
tasks_group.exception()
except asyncio.TimeoutError as e:
logger.error(f"Timeout during Inworld STT connection: {e}")
raise APITimeoutError("Timeout connecting to Inworld STT API") from e
except aiohttp.ClientResponseError as e:
logger.error(f"Inworld STT status error: {e.status} {e.message}")
raise APIStatusError(
message=e.message, status_code=e.status, request_id=None, body=None
) from e
except aiohttp.ClientError as e:
logger.error(f"Inworld STT connection error: {e}")
raise APIConnectionError(f"Inworld STT connection error: {e}") from e
except Exception as e:
logger.exception(f"Unexpected error in Inworld STT: {e}")
raise APIConnectionError(f"Unexpected error: {e}") from e
finally:
if self._ws is not None:
await self._ws.close()
self._ws = None
async def _send_audio_task(self) -> None:
if not self._ws:
return
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
self._audio_duration_collector.flush()
try:
await self._ws.send_str(json.dumps({"endTurn": {}}))
except Exception as e:
logger.error(f"Error sending endTurn: {e}")
break
elif isinstance(data, rtc.AudioFrame):
self._audio_duration_collector.push(data.duration)
pcm_bytes = data.data.tobytes()
audio_b64 = base64.b64encode(pcm_bytes).decode()
try:
await self._ws.send_str(json.dumps({"audioChunk": {"content": audio_b64}}))
except Exception as e:
logger.error(f"Error sending audio chunk: {e}")
break
self._audio_duration_collector.flush()
# Input channel closed — tell the server to close the stream
if self._ws:
try:
await self._ws.send_str(json.dumps({"closeStream": {}}))
except Exception:
pass
def _on_audio_duration_report(self, duration: float) -> None:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=SpeechEventType.RECOGNITION_USAGE,
request_id=self._request_id,
alternatives=[],
recognition_usage=stt.RecognitionUsage(audio_duration=duration),
)
)
async def _recv_messages_task(self) -> None:
if not self._ws:
return
try:
async for msg in self._ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
continue
self._process_stream_event(data)
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
return
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.error(f"Inworld STT WebSocket error: {self._ws.exception()}")
return
except aiohttp.ClientError as e:
logger.error(f"WebSocket error while receiving: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error receiving messages: {e}")
raise
def _process_stream_event(self, data: dict) -> None:
result = data.get("result", {})
if "speechStarted" in result and not self._speaking:
self._speaking = True
self._event_ch.send_nowait(
stt.SpeechEvent(
type=SpeechEventType.START_OF_SPEECH,
request_id=self._request_id,
)
)
return
t = result.get("transcription", {})
if not t:
return
text = t.get("transcript", "")
is_final = t.get("isFinal", False)
voice_profile = t.get("voiceProfile")
# An empty-text final (VAD false positive, unrecognizable noise) must still
# reach the END_OF_SPEECH emission below — otherwise _speaking stays True
# and subsequent speechStarted events are ignored, wedging the stream.
if not text and not is_final:
return
if text:
event_type = (
SpeechEventType.FINAL_TRANSCRIPT if is_final else SpeechEventType.INTERIM_TRANSCRIPT
)
metadata = None
if voice_profile:
metadata = {"voice_profile": voice_profile}
if is_final:
logger.info(f"Inworld voice profile: {voice_profile}")
self._event_ch.send_nowait(
stt.SpeechEvent(
type=event_type,
request_id=self._request_id,
alternatives=[
stt.SpeechData(
text=text,
language=LanguageCode(self._language),
metadata=metadata,
)
],
)
)
if is_final and self._speaking:
self._speaking = False
self._event_ch.send_nowait(
stt.SpeechEvent(
type=SpeechEventType.END_OF_SPEECH,
request_id=self._request_id,
)
)