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

433 lines
15 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 base64
import dataclasses
import json
import os
import weakref
from dataclasses import dataclass
from typing import Any, Literal
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.rtc import AudioFrame
from .log import logger
STTEncoding = Literal["pcm_s16le"]
# Define bytes per frame for different encoding types
bytes_per_frame = {
"pcm_s16le": 2,
}
SUPPORTED_SAMPLE_RATE = 24000
@dataclass
class STTOptions:
sample_rate: int = SUPPORTED_SAMPLE_RATE
buffer_size_seconds: float = 0.08
encoding: str = "pcm_s16le"
temperature: float | None = None
# TODO(laurent): support language detection
language: LanguageCode = LanguageCode("en")
vad_threshold: float = 0.6
vad_bucket: int | None = 2
# When set, we flush the stt state on the first time the VAD triggers
# in order to recover the text currently being processed as soon as possible.
vad_flush: bool = True
class STT(stt.STT):
def __init__(
self,
*,
api_key: str | None = None,
model_endpoint: str | None = "wss://api.gradium.ai/api/speech/asr",
model_name: str = "default",
sample_rate: int = SUPPORTED_SAMPLE_RATE,
encoding: NotGivenOr[STTEncoding] = NOT_GIVEN,
buffer_size_seconds: float = 0.08,
http_session: aiohttp.ClientSession | None = None,
vad_threshold: float = 0.9,
vad_bucket: int | None = 2,
vad_flush: bool = True,
temperature: float | None = None,
language: str = "en",
):
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=True, # only final transcripts
aligned_transcript=False, # only chunk start times are available
offline_recognize=False,
),
)
api_key = api_key or os.environ.get("GRADIUM_API_KEY")
if sample_rate != SUPPORTED_SAMPLE_RATE:
raise ValueError(f"Only {SUPPORTED_SAMPLE_RATE}Hz sample rate is supported")
if not api_key:
raise ValueError(
"Gradium API key is required. "
"Pass one in via the `api_key` parameter, "
"or set it as the `GRADIUM_API_KEY` environment variable"
)
self._api_key = api_key
model_endpoint = model_endpoint or os.environ.get("GRADIUM_MODEL_ENDPOINT")
if not model_endpoint:
raise ValueError(
"The model endpoint is required, you can find it in the Gradium dashboard"
)
self._model_endpoint = model_endpoint
self._model_name = model_name
self._opts = STTOptions(
sample_rate=sample_rate,
buffer_size_seconds=buffer_size_seconds,
vad_threshold=vad_threshold,
vad_bucket=vad_bucket,
vad_flush=vad_flush,
temperature=temperature,
language=LanguageCode(language),
)
if is_given(encoding):
self._opts.encoding = encoding
self._session = http_session
self._streams = weakref.WeakSet[SpeechStream]()
@property
def model(self) -> str:
return "unknown"
@property
def provider(self) -> str:
return "Gradium"
@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)
if is_given(language):
config.language = LanguageCode(language)
stream = SpeechStream(
stt=self,
conn_options=conn_options,
opts=config,
api_key=self._api_key,
model_endpoint=self._model_endpoint,
model_name=self._model_name,
http_session=self.session,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
buffer_size_seconds: NotGivenOr[float] = NOT_GIVEN,
) -> None:
if is_given(buffer_size_seconds):
self._opts.buffer_size_seconds = buffer_size_seconds
for stream in self._streams:
stream.update_options(
buffer_size_seconds=buffer_size_seconds,
)
class SpeechStream(stt.SpeechStream):
# Used to close websocket
_CLOSE_MSG: str = json.dumps({"terminate_session": True})
def __init__(
self,
*,
stt: STT,
opts: STTOptions,
conn_options: APIConnectOptions,
api_key: str,
model_endpoint: str,
model_name: str,
http_session: aiohttp.ClientSession,
) -> None:
super().__init__(stt=stt, conn_options=conn_options, sample_rate=opts.sample_rate)
self._opts = opts
self._api_key = api_key
self._model_endpoint = model_endpoint
self._model_name = model_name
self._session = http_session
self._speech_duration: float = 0
self._reconnect_event = asyncio.Event()
self._ready_msg: dict[str, Any] | None = None
@property
def delay_in_tokens(self) -> int:
if self._ready_msg is not None:
return int(self._ready_msg.get("delay_in_tokens", 6))
return 6
@property
def frame_size(self) -> int:
if self._ready_msg is not None:
return int(self._ready_msg.get("frame_size", 1920))
return 1920
def update_options(
self,
*,
buffer_size_seconds: NotGivenOr[float] = NOT_GIVEN,
) -> None:
if is_given(buffer_size_seconds):
self._opts.buffer_size_seconds = buffer_size_seconds
self._reconnect_event.set()
async def _run(self) -> None:
"""
Run a single websocket connection to Gradium and make sure to reconnect
when something went wrong.
"""
closing_ws = False
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
samples_per_buffer = 1920
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=1,
samples_per_channel=samples_per_buffer,
)
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 len(frame.data) % 2 != 0:
logger.warning("Frame data size not aligned to int16 (multiple of 2)")
audio_data = base64.b64encode(frame.data.tobytes()).decode("utf-8")
audio_msg = {
"type": "audio",
"audio": audio_data,
}
await ws.send_str(json.dumps(audio_msg))
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
buffered_text: list[str] = []
speaking = False
remaining_vad_steps: int | None = None
while True:
try:
msg = await asyncio.wait_for(ws.receive(), timeout=5)
except asyncio.TimeoutError:
if closing_ws:
break
continue
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if closing_ws:
return
raise APIStatusError(
"Gradium 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 Gradium message type: %s", msg.type)
continue
try:
data = json.loads(msg.data)
type_ = data.get("type", "")
if type_ == "text":
if speaking is False:
speaking = True
start_event = stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
self._event_ch.send_nowait(start_event)
buffered_text.append(data["text"])
event = stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=[
stt.SpeechData(
text=data["text"],
language=self._opts.language,
start_time=data["start_s"] + self.start_time_offset,
)
],
)
self._event_ch.send_nowait(event)
elif type_ == "step":
if not speaking:
continue
if vad_bucket := self._opts.vad_bucket:
positive_vad = (
data["vad"][vad_bucket]["inactivity_prob"]
> self._opts.vad_threshold
)
if positive_vad:
if remaining_vad_steps is None:
remaining_vad_steps = self.delay_in_tokens
if self._opts.vad_flush:
samples_per_channel = self.frame_size * self.delay_in_tokens
zeros = AudioFrame.create(
sample_rate=self._opts.sample_rate,
num_channels=1,
samples_per_channel=samples_per_channel,
)
await self._input_ch.send(zeros)
else:
remaining_vad_steps -= 1
if remaining_vad_steps <= 0:
speaking = False
remaining_vad_steps = None
event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
stt.SpeechData(
text=" ".join(buffered_text),
language=self._opts.language,
)
],
)
self._event_ch.send_nowait(event)
buffered_text = []
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
)
else:
remaining_vad_steps = None
elif type_ == "ready":
self._ready_msg = data
elif type_ == "end_text":
# This message provides the end timestamp of the previous word in the stop_s field.
pass
else:
logger.warning(f"Unknown message type from Gradium {type_}")
except Exception:
logger.exception("Failed to process message from Gradium")
ws: aiohttp.ClientWebSocketResponse | None = None
while True:
try:
ws = await self._connect_ws()
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
]
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()
finally:
if ws is not None:
await ws.close()
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
headers = {"x-api-key": self._api_key, "x-api-source": "livekit"}
ws = await self._session.ws_connect(self._model_endpoint, headers=headers)
# Build and send the setup payload as the first message
setup_msg: dict[str, Any] = {
"type": "setup",
"model_name": self._model_name,
"input_format": "pcm",
}
json_config: dict[str, Any] = {"language": self._opts.language.language}
if self._opts.temperature is not None:
json_config["temp"] = self._opts.temperature
setup_msg["json_config"] = json_config
await ws.send_str(json.dumps(setup_msg))
return ws