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

447 lines
16 KiB
Python

# 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 json
import time
from dataclasses import dataclass
from enum import Enum, auto
import aiohttp
from livekit import rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
LanguageCode,
stt,
utils,
)
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.voice.io import TimedString
from .log import logger
from .rtzrapi import DEFAULT_SAMPLE_RATE, RTZRConnectionError, RTZROpenAPIClient, RTZRStatusError
_DEFAULT_CHUNK_MS = 100
_IDLE_TIMEOUT_SECONDS = 25.0
_RECV_COMPLETION_TIMEOUT = 5.0
_IDLE_CHECK_INTERVAL = 1.0
@dataclass
class _STTOptions:
model_name: str = "sommers_ko" # sommers_ko: "ko", sommers_ja: "ja"
language: LanguageCode = LanguageCode("ko") # ko, ja, en
sample_rate: int = DEFAULT_SAMPLE_RATE
encoding: str = "LINEAR16" # or "OGG_OPUS" in future
domain: str = "CALL" # CALL, MEETING
epd_time: float = 0.8 # endpoint detection time in seconds
noise_threshold: float = 0.60
active_threshold: float = 0.80
use_punctuation: bool = False
keywords: list[str] | list[tuple[str, float]] | None = None
class _StreamState(Enum):
"""State machine for SpeechStream lifecycle."""
IDLE = auto() # No active speech, no WS connection
ACTIVE = auto() # Speech active, WS connected
CLOSING = auto() # Sending EOS, waiting for recv completion
CLOSED = auto() # Stream fully closed
class STT(stt.STT):
"""RTZR Streaming STT over WebSocket."""
def __init__(
self,
*,
model: str = "sommers_ko",
language: str = "ko",
sample_rate: int = 8000,
domain: str = "CALL",
epd_time: float = 0.8,
noise_threshold: float = 0.60,
active_threshold: float = 0.80,
use_punctuation: bool = False,
keywords: list[str] | list[tuple[str, float]] | None = None,
http_session: aiohttp.ClientSession | None = None,
) -> None:
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=True,
# word timestamps don't seem to work despite the docs saying they do
aligned_transcript="chunk",
offline_recognize=False,
)
)
self._params = _STTOptions(
model_name=model,
language=LanguageCode(language),
sample_rate=sample_rate,
domain=domain,
epd_time=epd_time,
noise_threshold=noise_threshold,
active_threshold=active_threshold,
use_punctuation=use_punctuation,
keywords=keywords,
)
if keywords and model != "sommers_ko":
logger.warning("RTZR keyword boosting is only supported with sommers_ko model")
self._client = RTZROpenAPIClient(http_session=http_session)
@property
def model(self) -> str:
return self._params.model_name
@property
def provider(self) -> str:
return "RTZR"
async def aclose(self) -> None:
"""Close the RTZR client and cleanup resources."""
await self._client.close()
async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.SpeechEvent:
raise NotImplementedError("Single-shot recognition is not supported; use stream().")
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
return SpeechStream(
stt=self,
conn_options=conn_options,
)
class SpeechStream(stt.SpeechStream):
def __init__(self, *, stt: STT, conn_options: APIConnectOptions) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=stt._params.sample_rate)
self._rtzr_stt: STT = stt
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._recv_task: asyncio.Task[None] | None = None
self._state = _StreamState.IDLE
self._connection_lock = asyncio.Lock()
self._idle_timeout = _IDLE_TIMEOUT_SECONDS
self._last_audio_at: float | None = None
self._idle_task: asyncio.Task[None] | None = None
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
config = self._rtzr_stt._client.build_config(
model_name=self._rtzr_stt._params.model_name,
domain=self._rtzr_stt._params.domain,
sample_rate=self._rtzr_stt._params.sample_rate,
encoding=self._rtzr_stt._params.encoding,
epd_time=self._rtzr_stt._params.epd_time,
noise_threshold=self._rtzr_stt._params.noise_threshold,
active_threshold=self._rtzr_stt._params.active_threshold,
use_punctuation=self._rtzr_stt._params.use_punctuation,
keywords=self._rtzr_stt._params.keywords,
)
try:
ws = await asyncio.wait_for(
self._rtzr_stt._client.connect_websocket(config),
timeout=self._conn_options.timeout,
)
logger.debug(
"RTZR STT WS connected (model=%s, sr=%s, epd=%.2fs, "
"noise=%.2f, active=%.2f, punct=%s)",
self._rtzr_stt._params.model_name,
self._rtzr_stt._params.sample_rate,
self._rtzr_stt._params.epd_time,
self._rtzr_stt._params.noise_threshold,
self._rtzr_stt._params.active_threshold,
self._rtzr_stt._params.use_punctuation,
)
return ws
except asyncio.TimeoutError as e:
raise APITimeoutError("WebSocket connection timeout") from e
except RTZRStatusError as e:
logger.error("RTZR API status error: %s", e)
raise APIStatusError(
message=e.message,
status_code=e.status_code or 500,
request_id=None,
body=None,
) from e
except RTZRConnectionError as e:
logger.error("RTZR API connection error: %s", e)
raise APIConnectionError("RTZR API connection failed") from e
async def _run(self) -> None:
send_task = asyncio.create_task(self._send_audio_task(), name="RTZR.send_audio")
self._idle_task = asyncio.create_task(self._idle_watchdog(), name="RTZR.idle_watchdog")
try:
await send_task
finally:
self._state = _StreamState.CLOSED
if self._idle_task and not self._idle_task.done():
await utils.aio.gracefully_cancel(self._idle_task)
await self._await_recv_completion()
await self._cleanup_connection()
async def _ensure_connected(self) -> None:
"""Lazy connect on first audio."""
async with self._connection_lock:
if self._ws is not None:
return
try:
ws = await self._connect_ws()
self._ws = ws
self._state = _StreamState.ACTIVE
self._recv_task = asyncio.create_task(self._recv_loop(ws), name="RTZR.recv_loop")
self._last_audio_at = time.monotonic()
except Exception:
self._state = _StreamState.IDLE
raise
async def _end_segment(self) -> None:
"""Close current segment, prepare for next."""
async with self._connection_lock:
if not self._ws:
return
self._state = _StreamState.CLOSING
try:
await self._ws.send_str("EOS")
logger.info("Sent EOS to close audio segment")
except Exception:
logger.exception("Failed to send EOS")
await self._await_recv_completion()
await self._cleanup_connection()
self._state = _StreamState.IDLE
self._last_audio_at = None
async def _idle_watchdog(self) -> None:
try:
while self._state != _StreamState.CLOSED:
await asyncio.sleep(_IDLE_CHECK_INTERVAL)
if self._state != _StreamState.ACTIVE or not self._ws:
continue
if self._last_audio_at is None:
continue
if time.monotonic() - self._last_audio_at >= self._idle_timeout:
logger.info(
"RTZR STT idle timeout reached (%.0fs); closing segment",
self._idle_timeout,
)
await self._end_segment()
except asyncio.CancelledError:
pass
async def _cleanup_connection(self) -> None:
if self._ws:
try:
await self._ws.close()
finally:
self._ws = None
async def _await_recv_completion(self) -> None:
if self._recv_task:
try:
await asyncio.wait_for(self._recv_task, timeout=_RECV_COMPLETION_TIMEOUT)
except asyncio.TimeoutError:
await utils.aio.gracefully_cancel(self._recv_task)
finally:
self._recv_task = None
@utils.log_exceptions(logger=logger)
async def _send_audio_task(self) -> None:
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._rtzr_stt._params.sample_rate,
num_channels=1,
samples_per_channel=self._rtzr_stt._params.sample_rate // (1000 // _DEFAULT_CHUNK_MS),
)
has_ended = False
async for data in self._input_ch:
frames: list[rtc.AudioFrame] = []
if isinstance(data, rtc.AudioFrame):
frames.extend(audio_bstream.write(data.data.tobytes()))
elif isinstance(data, self._FlushSentinel):
frames.extend(audio_bstream.flush())
has_ended = True
if frames and not self._ws:
await self._ensure_connected()
for frame in frames:
if self._ws:
await self._ws.send_bytes(frame.data.tobytes())
self._last_audio_at = time.monotonic()
if has_ended:
if self._ws:
await self._end_segment()
has_ended = False # always reset - flush without active WS is a no-op
# Final shutdown
if self._ws:
self._state = _StreamState.CLOSING
try:
await self._ws.send_str("EOS")
logger.info("Sent final EOS to close audio stream")
except Exception:
logger.exception("Failed to send final EOS")
await self._await_recv_completion()
await self._cleanup_connection()
self._state = _StreamState.IDLE
def _parse_words(self, words: list[dict]) -> list[TimedString]:
"""Parse word timing data from RTZR response."""
return [
TimedString(
text=w.get("text", ""),
start_time=w.get("start_at", 0) / 1000.0 + self.start_time_offset,
end_time=(w.get("start_at", 0) + w.get("duration", 0)) / 1000.0
+ self.start_time_offset,
)
for w in words
]
def _check_error_response(self, data: dict) -> None:
"""Check for error in RTZR response and raise if found."""
if "error" in data:
raise APIStatusError(
message=f"Server error: {data['error']}",
status_code=500,
request_id=None,
body=None,
)
if data.get("type") == "error" and "message" in data:
raise APIStatusError(
message=f"Server error: {data['message']}",
status_code=500,
request_id=None,
body=None,
)
def _process_transcript_event(
self,
data: dict,
in_speech: bool,
speech_started_at: float | None,
) -> tuple[list[stt.SpeechEvent], bool, float | None]:
"""Parse RTZR response into SpeechEvents.
Returns: (events, in_speech, speech_started_at)
"""
start_time = data.get("start_at", 0) / 1000.0
duration = data.get("duration", 0) / 1000.0
words = data.get("words", [])
if "alternatives" not in data or not data["alternatives"]:
return [], in_speech, speech_started_at
text = data["alternatives"][0].get("text", "")
is_final = bool(data.get("final", False))
if not text:
return [], in_speech, speech_started_at
events: list[stt.SpeechEvent] = []
if not in_speech:
in_speech = True
speech_started_at = time.monotonic()
events.append(stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH))
event_type = (
stt.SpeechEventType.FINAL_TRANSCRIPT
if is_final
else stt.SpeechEventType.INTERIM_TRANSCRIPT
)
events.append(
stt.SpeechEvent(
type=event_type,
alternatives=[
stt.SpeechData(
text=text,
language=self._rtzr_stt._params.language,
start_time=start_time + self.start_time_offset,
end_time=start_time + duration + self.start_time_offset,
words=self._parse_words(words) if words else None,
)
],
)
)
if is_final:
speech_duration = (
time.monotonic() - speech_started_at if speech_started_at is not None else 0.0
)
logger.debug("RTZR final transcript received (speech_duration=%.2fs)", speech_duration)
events.append(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))
in_speech = False
speech_started_at = None
return events, in_speech, speech_started_at
@utils.log_exceptions(logger=logger)
async def _recv_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None:
in_speech = False
speech_started_at: float | None = None
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
logger.warning("Non-JSON text from RTZR STT: %s", msg.data)
continue
self._check_error_response(data)
events, in_speech, speech_started_at = self._process_transcript_event(
data, in_speech, speech_started_at
)
for event in events:
self._event_ch.send_nowait(event)
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSED,
):
break
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.error("WebSocket error: %s", ws.exception())
raise APIConnectionError("WebSocket error occurred")
else:
logger.debug("Ignored WebSocket message type: %s", msg.type)