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

528 lines
20 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 weakref
from collections.abc import Callable
from dataclasses import dataclass
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 .log import logger
_STREAMING_PATH = "/audio/transcriptions/streaming"
class _PeriodicCollector:
def __init__(self, duration: float, callback: Callable[[float], None]):
self._duration = duration
self._callback = callback
self._collected_value = 0.0
self._task: asyncio.Task | None = None
self._lock = asyncio.Lock()
async def push(self, value: float) -> None:
async with self._lock:
self._collected_value += value
if not self._task:
self._task = asyncio.create_task(self._run())
async def flush(self) -> None:
async with self._lock:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
if self._collected_value > 0:
self._callback(self._collected_value)
self._collected_value = 0.0
async def _run(self) -> None:
await asyncio.sleep(self._duration)
async with self._lock:
self._callback(self._collected_value)
self._collected_value = 0.0
self._task = None
@dataclass
class STTOptions:
model: NotGivenOr[str]
sample_rate: int
language: NotGivenOr[LanguageCode] = NOT_GIVEN
prompt: NotGivenOr[str] = NOT_GIVEN
temperature: NotGivenOr[float] = NOT_GIVEN
skip_vad: NotGivenOr[bool] = NOT_GIVEN
vad_kwargs: NotGivenOr[dict] = NOT_GIVEN
text_timeout_seconds: float = 1.0
response_format: str = "verbose_json"
timestamp_granularities: NotGivenOr[list[str]] = NOT_GIVEN
base_url: NotGivenOr[str] = NOT_GIVEN
class STT(stt.STT):
def __init__(
self,
*,
model: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
sample_rate: int = 16000,
language: NotGivenOr[str] = NOT_GIVEN,
prompt: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
skip_vad: NotGivenOr[bool] = NOT_GIVEN,
vad_kwargs: NotGivenOr[dict] = NOT_GIVEN,
text_timeout_seconds: float = 1.0,
timestamp_granularities: NotGivenOr[list[str]] = NOT_GIVEN,
response_format: str = "verbose_json",
http_session: aiohttp.ClientSession | None = None,
base_url: str = "wss://audio-streaming.us-virginia-1.direct.fireworks.ai/v1",
):
"""
Create a new instance of Fireworks AI STT.
Args:
model: The Fireworks AI STT model to use. Defaults to NOT_GIVEN (server uses default model).
language: The target language for transcription. Defaults to NOT_GIVEN (server detects language automatically).
Full list: https://fireworks.ai/docs/api-reference/audio-streaming-transcriptions#supported-languages
prompt: The input prompt that the model will use when generating the transcription. Defaults to NOT_GIVEN.
temperature: Sampling temperature to use when decoding text tokens during transcription. Defaults to NOT_GIVEN.
skip_vad: Whether to skip server-side VAD. Defaults to NOT_GIVEN.
vad_kwargs: The optional kwargs to pass to the VAD model.
Defaults to NOT_GIVEN. Example: Set to {"threshold": 0.15} to adjust the VAD threshold.
text_timeout_seconds: Duration of silence before marking transcript as final. Defaults to 1.0.
timestamp_granularities: The timestamp granularities to populate for this streaming transcription.
Defaults to NOT_GIVEN. Set to "word,segment" to enable timestamp granularities.
response_format: The format in which to return the response. Default to "verbose_json".
base_url: The base URL for the Fireworks AI STT.
Defaults to "wss://audio-streaming.us-virginia-1.direct.fireworks.ai/v1".
api_key: The Fireworks AI API key. If not provided, it will be read from
the FIREWORKS_API_KEY environment variable.
http_session: Optional aiohttp ClientSession to use for requests.
Raises:
ValueError: If no API key is provided, found in environment variables, or if a parameter is invalid.
"""
super().__init__(
capabilities=stt.STTCapabilities(
streaming=True,
interim_results=True,
aligned_transcript=False,
offline_recognize=False,
),
)
if sample_rate != 16000:
raise ValueError("FireworksAI STT only supports a sample rate of 16000")
if not 1.0 <= text_timeout_seconds <= 29.0:
raise ValueError("text_timeout_seconds must be between 1.0 and 29.0")
fireworks_api_key = api_key if is_given(api_key) else os.environ.get("FIREWORKS_API_KEY")
if fireworks_api_key is None:
raise ValueError(
"Fireworks API key is required. "
"Pass one in via the `api_key` parameter, "
"or set it as the `FIREWORKS_API_KEY` environment variable"
)
self._api_key = fireworks_api_key
self._opts = STTOptions(
model=model,
sample_rate=sample_rate,
language=LanguageCode(language) if isinstance(language, str) else language,
prompt=prompt,
temperature=temperature,
skip_vad=skip_vad,
vad_kwargs=vad_kwargs,
text_timeout_seconds=text_timeout_seconds,
response_format=response_format,
timestamp_granularities=timestamp_granularities,
base_url=base_url,
)
self._session = http_session
self._streams = weakref.WeakSet[SpeechStream]()
@property
def model(self) -> str:
return self._opts.model if is_given(self._opts.model) else "unknown"
@property
def provider(self) -> str:
return "FireworksAI"
@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(
"FireworksAI STT does not support batch recognition, use stream() instead"
)
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,
opts=config,
conn_options=conn_options,
api_key=self._api_key,
http_session=self.session,
)
self._streams.add(stream)
return stream
def update_options(
self,
*,
model: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
prompt: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
skip_vad: NotGivenOr[bool] = NOT_GIVEN,
vad_kwargs: NotGivenOr[dict] = NOT_GIVEN,
text_timeout_seconds: NotGivenOr[float] = NOT_GIVEN,
timestamp_granularities: NotGivenOr[list[str]] = NOT_GIVEN,
) -> None:
if is_given(model):
self._opts.model = model
if is_given(language):
self._opts.language = LanguageCode(language)
if is_given(prompt):
self._opts.prompt = prompt
if is_given(temperature):
self._opts.temperature = temperature
if is_given(skip_vad):
self._opts.skip_vad = skip_vad
if is_given(vad_kwargs):
self._opts.vad_kwargs = vad_kwargs
if is_given(text_timeout_seconds):
if not 1.0 <= text_timeout_seconds <= 29.0:
raise ValueError("text_timeout_seconds must be between 1.0 and 29.0")
self._opts.text_timeout_seconds = text_timeout_seconds
if is_given(timestamp_granularities):
self._opts.timestamp_granularities = timestamp_granularities
for stream in self._streams:
stream.update_options(
model=model,
language=language,
prompt=prompt,
temperature=temperature,
skip_vad=skip_vad,
vad_kwargs=vad_kwargs,
text_timeout_seconds=text_timeout_seconds,
timestamp_granularities=timestamp_granularities,
)
class SpeechStream(stt.SpeechStream):
_CLOSE_MSG: str = json.dumps({"checkpoint_id": "final"})
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._transcript_state: dict[str, str] = {}
self._reconnect_event = asyncio.Event()
self._speaking = False
self._final_segments_length: dict[int, int] = {}
self._last_final_segment_id = -1
self._audio_duration_collector = _PeriodicCollector(
callback=self._on_audio_duration_report,
duration=10.0,
)
def update_options(
self,
*,
model: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
prompt: NotGivenOr[str] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN,
skip_vad: NotGivenOr[bool] = NOT_GIVEN,
vad_kwargs: NotGivenOr[dict] = NOT_GIVEN,
text_timeout_seconds: NotGivenOr[float] = NOT_GIVEN,
timestamp_granularities: NotGivenOr[list[str]] = NOT_GIVEN,
) -> None:
if is_given(model):
self._opts.model = model
if is_given(language):
self._opts.language = LanguageCode(language)
if is_given(prompt):
self._opts.prompt = prompt
if is_given(temperature):
self._opts.temperature = temperature
if is_given(skip_vad):
self._opts.skip_vad = skip_vad
if is_given(vad_kwargs):
self._opts.vad_kwargs = vad_kwargs
if is_given(text_timeout_seconds):
self._opts.text_timeout_seconds = text_timeout_seconds
if is_given(timestamp_granularities):
self._opts.timestamp_granularities = timestamp_granularities
self._reconnect_event.set()
async def _run(self) -> None:
"""
Run a single websocket connection to Fireworks and make sure to reconnect
when something went wrong.
"""
closing_ws = False
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
samples_per_buffer = self._opts.sample_rate // 20 # 50ms chunk
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:
await self._audio_duration_collector.push(frame.duration)
await ws.send_bytes(frame.data.tobytes())
closing_ws = True
await ws.send_str(self._CLOSE_MSG)
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal closing_ws
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(
"Fireworks 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 FireworksAI message type %s", msg.type)
continue
try:
self._process_stream_event(json.loads(msg.data))
except Exception:
logger.exception("failed to process FireworksAI message")
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())
try:
done, _ = await asyncio.wait(
(asyncio.gather(*tasks), 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)
finally:
if self._speaking:
self._speaking = False
end_event = stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
self._event_ch.send_nowait(end_event)
if ws is not None:
await ws.close()
await self._audio_duration_collector.flush()
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
live_config = {
"model": self._opts.model if is_given(self._opts.model) else None,
"language": self._opts.language if is_given(self._opts.language) else None,
"prompt": self._opts.prompt if is_given(self._opts.prompt) else None,
"temperature": self._opts.temperature if is_given(self._opts.temperature) else None,
"skip_vad": self._opts.skip_vad if is_given(self._opts.skip_vad) else None,
"vad_kwargs": self._opts.vad_kwargs if is_given(self._opts.vad_kwargs) else None,
"text_timeout_seconds": self._opts.text_timeout_seconds,
"response_format": self._opts.response_format,
"timestamp_granularities": (
self._opts.timestamp_granularities
if is_given(self._opts.timestamp_granularities)
else None
),
}
headers = {
"User-Agent": "LiveKit Agents",
"Authorization": self._api_key,
}
ws_url = str(self._opts.base_url).rstrip("/") + _STREAMING_PATH
filtered_config = {k: v for k, v in live_config.items() if v is not None}
url = f"{ws_url}?{urlencode(filtered_config, doseq=True)}"
ws = await self._session.ws_connect(url, headers=headers)
logger.info("connected to Fireworks AI STT", extra={"url": url})
return ws
def _process_stream_event(self, data: dict) -> None:
if "segments" in data and data["segments"]:
latest_segment = max(data["segments"], key=lambda s: s["id"])
max_segment_id = latest_segment["id"]
for segment in data["segments"]:
segment_id = segment["id"]
if segment_id < self._last_final_segment_id:
continue
if segment_id == self._last_final_segment_id:
finalized_word_count = self._final_segments_length.get(segment_id, 0)
words = segment.get("words", [])
if isinstance(words, list) and finalized_word_count < len(words):
new_words = words[finalized_word_count:]
new_text = " ".join(w["word"] for w in new_words if "word" in w).strip()
self._transcript_state[segment_id] = new_text
elif segment_id in self._transcript_state:
del self._transcript_state[segment_id]
else:
self._transcript_state[segment["id"]] = segment["text"]
for local_segment_id in list(self._transcript_state.keys()):
if local_segment_id > max_segment_id:
del self._transcript_state[local_segment_id]
# The state dictionary may not be sorted, so we must sort it by the segment ID
# before joining the text.
sorted_segments = sorted(self._transcript_state.items(), key=lambda item: int(item[0]))
full_transcript = " ".join([text for _, text in sorted_segments])
if not full_transcript:
return
if not self._speaking:
self._speaking = True
start_event = stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
self._event_ch.send_nowait(start_event)
is_final = False
words = latest_segment.get("words")
if words and isinstance(words, list) and words:
last_word = words[-1]
if isinstance(last_word, dict) and last_word.get("is_final") is True:
is_final = True
if is_final:
final_event = stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
alternatives=[
stt.SpeechData(
language=LanguageCode(self._opts.language or ""), text=full_transcript
)
],
)
self._event_ch.send_nowait(final_event)
self._transcript_state.clear()
self._last_final_segment_id = max_segment_id
words = latest_segment.get("words")
if isinstance(words, list):
self._final_segments_length[max_segment_id] = len(words)
else:
interim_event = stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
alternatives=[
stt.SpeechData(
language=LanguageCode(self._opts.language or ""), text=full_transcript
)
],
)
self._event_ch.send_nowait(interim_event)
def _on_audio_duration_report(self, duration: float) -> None:
usage_event = stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
recognition_usage=stt.RecognitionUsage(audio_duration=duration),
)
self._event_ch.send_nowait(usage_event)