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

539 lines
19 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 json
import os
import uuid
import weakref
from dataclasses import dataclass
import aiohttp
from livekit import rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
stt,
utils,
)
from livekit.agents.language import LanguageCode
from livekit.agents.types import (
NOT_GIVEN,
NotGivenOr,
TimedString,
)
from livekit.agents.utils import AudioBuffer, is_given
from ._utils import PeriodicCollector
from .log import logger
from .types import STTLanguages
SAMPLE_RATE = 16000
XAI_WEBSOCKET_URL = "wss://api.x.ai/v1/stt"
XAI_REST_URL = "https://api.x.ai/v1/stt"
@dataclass
class STTOptions:
enable_interim_results: bool
sample_rate: int
enable_diarization: bool
language: STTLanguages | str
endpointing: int
class STT(stt.STT):
def __init__(
self,
*,
enable_interim_results: bool = True,
sample_rate: int = SAMPLE_RATE,
enable_diarization: bool = False,
language: STTLanguages | str = "en",
endpointing: int = 100,
api_key: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
) -> None:
"""Create a new instance of xAI STT.
Args:
enable_interim_results (bool, optional): Whether to return interim (non-final) transcription results. Defaults to True.
sample_rate: The sample rate of the audio in Hz. Defaults to 16000.
enable_diarization: Whether to enable speaker diarization. Words will include a speaker field. Defaults to False.
language: BCP-47 language code for transcription (e.g. "en", "fr", "de"). Defaults to "en".
endpointing: Silence duration in milliseconds before an utterance-final event is fired. xAI's default is 10ms, but we default to 100ms for better compatibility with LK EOT models.
api_key: Your xAI API key. If not provided, will look for XAI_API_KEY environment variable.
http_session: Optional aiohttp ClientSession to use for requests.
Raises:
ValueError: If no API key is provided or found in environment variables.
Note:
The api_key must be set either through the constructor argument or by setting
the XAI_API_KEY environmental variable.
""" # noqa: E501
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=enable_interim_results,
diarization=enable_diarization,
aligned_transcript="word",
)
)
xai_api_key = api_key if is_given(api_key) else os.environ.get("XAI_API_KEY")
if not xai_api_key:
raise ValueError("xAI API key is required")
self._api_key = xai_api_key
self._opts = STTOptions(
enable_interim_results=enable_interim_results,
sample_rate=sample_rate,
enable_diarization=enable_diarization,
language=language,
endpointing=endpointing,
)
self._session = http_session
self._streams = weakref.WeakSet[SpeechStream]()
def _ensure_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 = DEFAULT_API_CONNECT_OPTIONS,
) -> stt.SpeechEvent:
lang = language if is_given(language) else self._opts.language
form = aiohttp.FormData()
form.add_field(
"file",
rtc.combine_audio_frames(buffer).to_wav_bytes(),
filename="audio.wav",
content_type="audio/wav",
)
form.add_field("language", lang)
form.add_field("format", "true")
try:
async with self._ensure_session().post(
url=XAI_REST_URL,
data=form,
headers={
"Authorization": f"Bearer {self._api_key}",
"Accept": "application/json",
},
timeout=aiohttp.ClientTimeout(
total=30,
sock_connect=conn_options.timeout,
),
) as res:
return _prerecorded_transcription_to_speech_event(
await res.json(), enable_diarization=self._opts.enable_diarization
)
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from e
except Exception as e:
raise APIConnectionError() from e
def _sanitize_options(self, *, language: NotGivenOr[str] = NOT_GIVEN) -> STTOptions:
config = dataclasses.replace(self._opts)
if is_given(language):
config.language = language
return config
def stream(
self,
*,
language: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
config = self._sanitize_options(language=language)
stream = SpeechStream(
stt=self,
conn_options=conn_options,
opts=config,
api_key=self._api_key,
http_session=self._ensure_session(),
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
interim_results: NotGivenOr[bool] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
enable_diarization: NotGivenOr[bool] = NOT_GIVEN,
language: NotGivenOr[STTLanguages | str] = NOT_GIVEN,
endpointing: NotGivenOr[int] = NOT_GIVEN,
) -> None:
if is_given(interim_results):
self._opts.enable_interim_results = interim_results
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
if is_given(enable_diarization):
self._opts.enable_diarization = enable_diarization
if is_given(language):
self._opts.language = language
if is_given(endpointing):
self._opts.endpointing = endpointing
for stream in self._streams:
stream.update_options(
enable_interim_results=interim_results,
sample_rate=sample_rate,
enable_diarization=enable_diarization,
language=language,
endpointing=endpointing,
)
class SpeechStream(stt.RecognizeStream):
def __init__(
self,
*,
stt: STT,
opts: STTOptions,
conn_options: APIConnectOptions,
api_key: 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._session = http_session
self._speaking = False
self._emitted_chunk_final = False
self._audio_duration_collector = PeriodicCollector(
callback=self._on_audio_duration_report,
duration=5.0,
)
self._request_id = str(uuid.uuid4())
self._reconnect_event = asyncio.Event()
self._server_ready = asyncio.Event()
def update_options(
self,
*,
enable_interim_results: NotGivenOr[bool] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
enable_diarization: NotGivenOr[bool] = NOT_GIVEN,
language: NotGivenOr[STTLanguages | str] = NOT_GIVEN,
endpointing: NotGivenOr[int] = NOT_GIVEN,
) -> None:
if is_given(enable_interim_results):
self._opts.enable_interim_results = enable_interim_results
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
if is_given(enable_diarization):
self._opts.enable_diarization = enable_diarization
if is_given(language):
self._opts.language = language
if is_given(endpointing):
self._opts.endpointing = endpointing
self._reconnect_event.set()
async def _run(self) -> None:
closing_ws = False
@utils.log_exceptions(logger=logger)
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
await self._server_ready.wait()
samples_50ms = self._opts.sample_rate // 20
audio_bstream = utils.audio.AudioByteStream(
sample_rate=self._opts.sample_rate,
num_channels=1,
samples_per_channel=samples_50ms,
)
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())
for frame in frames:
self._audio_duration_collector.push(frame.duration)
await ws.send_bytes(frame.data.tobytes())
self._audio_duration_collector.flush()
await ws.send_str(json.dumps({"type": "audio.done"}))
closing_ws = True
@utils.log_exceptions(logger=logger)
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if closing_ws or self._session.closed:
return
raise APIStatusError(message="xAI connection closed unexpectedly")
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected xAI message type %s", msg.type)
continue
try:
self._process_stream_event(json.loads(msg.data))
except Exception:
logger.exception("failed to process xAI message")
ws: aiohttp.ClientWebSocketResponse | None = None
while True:
try:
closing_ws = False
ws = await self._connect_ws()
self._server_ready.clear()
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
]
tasks_group = asyncio.gather(*tasks)
wait_reconnect_task = asyncio.create_task(self._reconnect_event.wait())
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:
params = {
"encoding": "pcm",
"sample_rate": str(self._opts.sample_rate),
"interim_results": str(self._opts.enable_interim_results).lower(),
"diarize": str(self._opts.enable_diarization).lower(),
"language": str(self._opts.language),
"endpointing": str(self._opts.endpointing),
}
try:
ws = await asyncio.wait_for(
self._session.ws_connect(
XAI_WEBSOCKET_URL,
headers={"Authorization": f"Bearer {self._api_key}"},
params=params,
),
self._conn_options.timeout,
)
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
raise APIConnectionError("failed to connect to xAI") from e
return ws
def _on_audio_duration_report(self, duration: float) -> None:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
request_id=self._request_id,
recognition_usage=stt.RecognitionUsage(audio_duration=duration),
)
)
def _process_stream_event(self, data: dict) -> None:
msg_type = data.get("type", "")
if msg_type == "transcript.created":
self._server_ready.set()
elif msg_type == "transcript.partial":
text = data.get("text", "")
is_final = data.get("is_final", False)
speech_final = data.get("speech_final", False)
words = data.get("words", [])
language = data.get("language", "")
if not text:
return
if not self._speaking:
self._speaking = True
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
)
if is_final:
if not speech_final:
self._emitted_chunk_final = True
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=self._request_id,
alternatives=[
_words_to_speech_data(
words,
text,
language=language,
enable_diarization=self._opts.enable_diarization,
)
],
)
)
else:
if not self._emitted_chunk_final:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=self._request_id,
alternatives=[
_words_to_speech_data(
words,
text,
language=language,
enable_diarization=self._opts.enable_diarization,
)
],
)
)
self._emitted_chunk_final = False
self._speaking = False
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
)
else:
if self._opts.enable_interim_results:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
request_id=self._request_id,
alternatives=[
stt.SpeechData(language=LanguageCode(language), text=text)
],
)
)
elif msg_type == "transcript.done":
text = data.get("text", "")
words = data.get("words", [])
language = data.get("language", "")
if text:
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=self._request_id,
alternatives=[
_words_to_speech_data(
words,
text,
language=language,
enable_diarization=self._opts.enable_diarization,
)
],
)
)
if self._speaking:
self._speaking = False
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))
elif msg_type == "error":
logger.error("xAI STT error: %s", data.get("message", "unknown error"))
else:
logger.warning("received unexpected message from xAI: %s", msg_type)
def _words_to_speech_data(
words: list[dict], text: str, *, language: str, enable_diarization: bool
) -> stt.SpeechData:
speaker_id = (
f"S{words[0]['speaker']}"
if enable_diarization and words and "speaker" in words[0]
else None
)
return stt.SpeechData(
language=LanguageCode(language),
text=text,
start_time=words[0].get("start", 0.0) if words else 0.0,
end_time=words[-1].get("end", 0.0) if words else 0.0,
speaker_id=speaker_id,
words=[
TimedString(
w.get("text", ""), start_time=w.get("start", 0.0), end_time=w.get("end", 0.0)
)
for w in words
]
or None,
)
def _prerecorded_transcription_to_speech_event(
data: dict, *, enable_diarization: bool
) -> stt.SpeechEvent:
text = data.get("text", "")
words = data.get("words", [])
language = data.get("language", "")
return stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
_words_to_speech_data(
words, text, language=language, enable_diarization=enable_diarization
)
],
)