751 lines
30 KiB
Python
751 lines
30 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 json
|
|
import os
|
|
import time
|
|
import weakref
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from urllib.parse import urlencode
|
|
|
|
import aiohttp
|
|
import httpx
|
|
|
|
import openai
|
|
from livekit import rtc
|
|
from livekit.agents import (
|
|
DEFAULT_API_CONNECT_OPTIONS,
|
|
APIConnectionError,
|
|
APIConnectOptions,
|
|
APIError,
|
|
APIStatusError,
|
|
APITimeoutError,
|
|
LanguageCode,
|
|
stt,
|
|
utils,
|
|
vad,
|
|
)
|
|
from livekit.agents.types import (
|
|
NOT_GIVEN,
|
|
NotGivenOr,
|
|
)
|
|
from livekit.agents.utils import AudioBuffer, is_given
|
|
from openai.types.audio import TranscriptionVerbose
|
|
from openai.types.beta.realtime.transcription_session_update_param import (
|
|
SessionTurnDetection,
|
|
)
|
|
|
|
from .log import logger
|
|
from .models import STTModels
|
|
from .utils import AsyncAzureADTokenProvider
|
|
|
|
# OpenAI Realtime API has a timeout of 15 mins, we'll attempt to restart the session
|
|
# before that timeout is reached
|
|
_max_session_duration = 10 * 60
|
|
# emit interim transcriptions every 0.5 seconds
|
|
_delta_transcript_interval = 0.5
|
|
SAMPLE_RATE = 24000
|
|
NUM_CHANNELS = 1
|
|
|
|
|
|
def _is_whisper_realtime(model: str) -> bool:
|
|
# gpt-realtime-whisper rejects any turn_detection config; the client must
|
|
# commit the audio buffer manually (e.g. driven by an external VAD).
|
|
return model.startswith("gpt-realtime-whisper")
|
|
|
|
|
|
@dataclass
|
|
class _STTOptions:
|
|
model: STTModels | str
|
|
language: LanguageCode
|
|
detect_language: bool
|
|
turn_detection: SessionTurnDetection
|
|
prompt: NotGivenOr[str] = NOT_GIVEN
|
|
noise_reduction_type: NotGivenOr[str] = NOT_GIVEN
|
|
temperature: NotGivenOr[float] = NOT_GIVEN
|
|
|
|
|
|
class STT(stt.STT):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
language: str = "en",
|
|
detect_language: bool = False,
|
|
model: STTModels | str = "gpt-4o-mini-transcribe",
|
|
prompt: NotGivenOr[str] = NOT_GIVEN,
|
|
turn_detection: NotGivenOr[SessionTurnDetection] = NOT_GIVEN,
|
|
noise_reduction_type: NotGivenOr[str] = NOT_GIVEN,
|
|
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
base_url: NotGivenOr[str] = NOT_GIVEN,
|
|
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
client: openai.AsyncClient | None = None,
|
|
use_realtime: bool = False,
|
|
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
|
|
):
|
|
"""
|
|
Create a new instance of OpenAI STT.
|
|
|
|
Args:
|
|
language: The language code to use for transcription (e.g., "en" for English).
|
|
detect_language: Whether to automatically detect the language.
|
|
model: The OpenAI model to use for transcription.
|
|
prompt: Optional text prompt to guide the transcription. Only supported for whisper-1.
|
|
turn_detection: When using realtime transcription, this controls how model detects the user is done speaking.
|
|
Final transcripts are generated only after the turn is over. See: https://platform.openai.com/docs/guides/realtime-vad
|
|
Ignored for `gpt-realtime-whisper`, which does not support server-side turn detection.
|
|
noise_reduction_type: Type of noise reduction to apply. "near_field" or "far_field"
|
|
This isn't needed when using LiveKit's noise cancellation.
|
|
temperature: Sampling temperature between 0 and 1. Lower values make the
|
|
transcription more deterministic. Not supported for realtime transcription.
|
|
base_url: Custom base URL for OpenAI API.
|
|
api_key: Your OpenAI API key. If not provided, will use the OPENAI_API_KEY environment variable.
|
|
client: Optional pre-configured OpenAI AsyncClient instance.
|
|
use_realtime: Whether to use the realtime transcription API. (default: False)
|
|
vad: Optional Voice Activity Detector used to commit the audio buffer when the model
|
|
does not support server-side turn detection (e.g. `gpt-realtime-whisper`).
|
|
When not provided and the model requires it, Silero VAD is auto-loaded with default
|
|
settings. Pass `vad=None` to opt out of the auto-load and drive
|
|
`input_audio_buffer.commit` yourself.
|
|
""" # noqa: E501
|
|
|
|
if use_realtime and is_given(temperature):
|
|
logger.warning(
|
|
"temperature is not supported for realtime transcription; "
|
|
"ignoring the provided value"
|
|
)
|
|
temperature = NOT_GIVEN
|
|
|
|
whisper_realtime = use_realtime and _is_whisper_realtime(model)
|
|
if whisper_realtime:
|
|
if is_given(turn_detection):
|
|
logger.warning(
|
|
"turn_detection is not supported for %s; ignoring the provided value", model
|
|
)
|
|
turn_detection = NOT_GIVEN
|
|
if not is_given(vad):
|
|
try:
|
|
from livekit.plugins.silero import VAD as SileroVAD
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"livekit-plugins-silero is required for the gpt-realtime-whisper model "
|
|
"(no server-side endpointing). Pass `vad=None` to opt out and drive "
|
|
"`input_audio_buffer.commit` manually."
|
|
) from e
|
|
vad = SileroVAD.load()
|
|
|
|
super().__init__(
|
|
capabilities=stt.STTCapabilities(
|
|
streaming=use_realtime, interim_results=use_realtime, aligned_transcript=False
|
|
)
|
|
)
|
|
if detect_language:
|
|
language = ""
|
|
|
|
if not is_given(turn_detection):
|
|
turn_detection = {
|
|
"type": "server_vad",
|
|
"threshold": 0.5,
|
|
"prefix_padding_ms": 600,
|
|
"silence_duration_ms": 350,
|
|
}
|
|
|
|
self._opts = _STTOptions(
|
|
language=LanguageCode(language),
|
|
detect_language=detect_language,
|
|
model=model,
|
|
prompt=prompt,
|
|
turn_detection=turn_detection,
|
|
temperature=temperature,
|
|
)
|
|
if is_given(noise_reduction_type):
|
|
self._opts.noise_reduction_type = noise_reduction_type
|
|
|
|
self._vad = vad if is_given(vad) else None
|
|
|
|
if is_given(api_key) and not api_key:
|
|
raise ValueError(
|
|
"OpenAI API key is required, either as argument or set"
|
|
" OPENAI_API_KEY environment variable"
|
|
)
|
|
|
|
self._client = client or openai.AsyncClient(
|
|
max_retries=0,
|
|
api_key=api_key if is_given(api_key) else None,
|
|
base_url=base_url if is_given(base_url) else None,
|
|
http_client=httpx.AsyncClient(
|
|
timeout=httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
|
|
follow_redirects=True,
|
|
limits=httpx.Limits(
|
|
max_connections=50,
|
|
max_keepalive_connections=50,
|
|
keepalive_expiry=120,
|
|
),
|
|
),
|
|
)
|
|
|
|
self._streams = weakref.WeakSet[SpeechStream]()
|
|
self._session: aiohttp.ClientSession | None = None
|
|
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
|
|
max_session_duration=_max_session_duration,
|
|
connect_cb=self._connect_ws,
|
|
close_cb=self._close_ws,
|
|
)
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return self._opts.model
|
|
|
|
@property
|
|
def provider(self) -> str:
|
|
return self._client._base_url.netloc.decode("utf-8")
|
|
|
|
@staticmethod
|
|
def with_azure(
|
|
*,
|
|
language: str = "en",
|
|
detect_language: bool = False,
|
|
model: STTModels | str = "gpt-4o-mini-transcribe",
|
|
prompt: NotGivenOr[str] = NOT_GIVEN,
|
|
turn_detection: NotGivenOr[SessionTurnDetection] = NOT_GIVEN,
|
|
noise_reduction_type: NotGivenOr[str] = NOT_GIVEN,
|
|
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
azure_endpoint: str | None = None,
|
|
azure_deployment: str | None = None,
|
|
api_version: str | None = None,
|
|
api_key: str | None = None,
|
|
azure_ad_token: str | None = None,
|
|
azure_ad_token_provider: AsyncAzureADTokenProvider | None = None,
|
|
organization: str | None = None,
|
|
project: str | None = None,
|
|
base_url: str | None = None,
|
|
use_realtime: bool = False,
|
|
timeout: httpx.Timeout | None = None,
|
|
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
|
|
) -> STT:
|
|
"""
|
|
Create a new instance of Azure OpenAI STT.
|
|
|
|
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
|
|
- `api_key` from `AZURE_OPENAI_API_KEY`
|
|
- `organization` from `OPENAI_ORG_ID`
|
|
- `project` from `OPENAI_PROJECT_ID`
|
|
- `azure_ad_token` from `AZURE_OPENAI_AD_TOKEN`
|
|
- `api_version` from `OPENAI_API_VERSION`
|
|
- `azure_endpoint` from `AZURE_OPENAI_ENDPOINT`
|
|
""" # noqa: E501
|
|
|
|
azure_client = openai.AsyncAzureOpenAI(
|
|
max_retries=0,
|
|
azure_endpoint=azure_endpoint,
|
|
azure_deployment=azure_deployment,
|
|
api_version=api_version,
|
|
api_key=api_key,
|
|
azure_ad_token=azure_ad_token,
|
|
azure_ad_token_provider=azure_ad_token_provider,
|
|
organization=organization,
|
|
project=project,
|
|
base_url=base_url,
|
|
timeout=timeout
|
|
if timeout
|
|
else httpx.Timeout(connect=15.0, read=5.0, write=5.0, pool=5.0),
|
|
) # type: ignore
|
|
|
|
return STT(
|
|
language=language,
|
|
detect_language=detect_language,
|
|
model=model,
|
|
prompt=prompt,
|
|
turn_detection=turn_detection,
|
|
noise_reduction_type=noise_reduction_type,
|
|
temperature=temperature,
|
|
client=azure_client,
|
|
use_realtime=use_realtime,
|
|
vad=vad,
|
|
)
|
|
|
|
@staticmethod
|
|
def with_ovhcloud(
|
|
*,
|
|
model: str = "whisper-large-v3-turbo",
|
|
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
base_url: str = "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
|
|
client: openai.AsyncClient | None = None,
|
|
language: str = "en",
|
|
detect_language: bool = False,
|
|
prompt: NotGivenOr[str] = NOT_GIVEN,
|
|
) -> STT:
|
|
"""
|
|
Create a new instance of OVHcloud AI Endpoints STT.
|
|
|
|
``api_key`` must be set to your OVHcloud AI Endpoints API key, either using the argument or by setting
|
|
the ``OVHCLOUD_API_KEY`` environmental variable.
|
|
"""
|
|
ovhcloud_api_key = api_key if is_given(api_key) else os.environ.get("OVHCLOUD_API_KEY")
|
|
if not ovhcloud_api_key:
|
|
raise ValueError("OVHcloud AI Endpoints API key is required")
|
|
|
|
return STT(
|
|
model=model,
|
|
api_key=ovhcloud_api_key,
|
|
base_url=base_url,
|
|
client=client,
|
|
language=language,
|
|
detect_language=detect_language,
|
|
prompt=prompt,
|
|
use_realtime=False,
|
|
)
|
|
|
|
def stream(
|
|
self,
|
|
*,
|
|
language: NotGivenOr[str] = NOT_GIVEN,
|
|
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
) -> SpeechStream:
|
|
if is_given(language):
|
|
self._opts.language = LanguageCode(language)
|
|
stream = SpeechStream(
|
|
stt=self,
|
|
pool=self._pool,
|
|
conn_options=conn_options,
|
|
vad_instance=self._vad,
|
|
)
|
|
self._streams.add(stream)
|
|
return stream
|
|
|
|
def update_options(
|
|
self,
|
|
*,
|
|
model: NotGivenOr[STTModels | str] = NOT_GIVEN,
|
|
language: NotGivenOr[str] = NOT_GIVEN,
|
|
detect_language: NotGivenOr[bool] = NOT_GIVEN,
|
|
prompt: NotGivenOr[str] = NOT_GIVEN,
|
|
turn_detection: NotGivenOr[SessionTurnDetection] = NOT_GIVEN,
|
|
noise_reduction_type: NotGivenOr[str] = NOT_GIVEN,
|
|
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
) -> None:
|
|
"""
|
|
Update the options for the speech stream. Most options are updated at the
|
|
connection level. SpeechStreams will be recreated when options are updated.
|
|
|
|
Args:
|
|
language: The language to transcribe in.
|
|
detect_language: Whether to automatically detect the language.
|
|
model: The model to use for transcription.
|
|
prompt: Optional text prompt to guide the transcription. Only supported for whisper-1.
|
|
turn_detection: When using realtime, this controls how model detects the user is done speaking.
|
|
noise_reduction_type: Type of noise reduction to apply. "near_field" or "far_field"
|
|
temperature: Sampling temperature between 0 and 1. Not supported for realtime transcription.
|
|
""" # noqa: E501
|
|
if is_given(model):
|
|
self._opts.model = model
|
|
if is_given(language):
|
|
self._opts.language = LanguageCode(language)
|
|
if is_given(detect_language):
|
|
self._opts.detect_language = detect_language
|
|
self._opts.language = LanguageCode("")
|
|
if is_given(prompt):
|
|
self._opts.prompt = prompt
|
|
if is_given(turn_detection):
|
|
self._opts.turn_detection = turn_detection
|
|
if is_given(noise_reduction_type):
|
|
self._opts.noise_reduction_type = noise_reduction_type
|
|
if is_given(temperature):
|
|
if self.capabilities.streaming:
|
|
logger.warning(
|
|
"temperature is not supported for realtime transcription; "
|
|
"ignoring the provided value"
|
|
)
|
|
else:
|
|
self._opts.temperature = temperature
|
|
|
|
for stream in self._streams:
|
|
if is_given(language):
|
|
stream.update_options(language=language)
|
|
|
|
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
|
|
prompt = self._opts.prompt if is_given(self._opts.prompt) else ""
|
|
transcription_config: dict[str, Any] = {
|
|
"model": self._opts.model,
|
|
}
|
|
if prompt:
|
|
transcription_config["prompt"] = prompt
|
|
if self._opts.language:
|
|
transcription_config["language"] = self._opts.language.language
|
|
|
|
input_config: dict[str, Any] = {
|
|
"format": {
|
|
"type": "audio/pcm",
|
|
"rate": SAMPLE_RATE,
|
|
},
|
|
"transcription": transcription_config,
|
|
}
|
|
# gpt-realtime-whisper rejects any turn_detection config — omit the key entirely.
|
|
# For other models, send the configured turn_detection (server-side VAD).
|
|
if not _is_whisper_realtime(self._opts.model):
|
|
input_config["turn_detection"] = self._opts.turn_detection
|
|
|
|
if self._opts.noise_reduction_type:
|
|
input_config["noise_reduction"] = {"type": self._opts.noise_reduction_type}
|
|
|
|
realtime_config: dict[str, Any] = {
|
|
"type": "session.update",
|
|
"session": {
|
|
"type": "transcription",
|
|
"audio": {
|
|
"input": input_config,
|
|
},
|
|
},
|
|
}
|
|
|
|
query_params: dict[str, str] = {
|
|
"intent": "transcription",
|
|
}
|
|
headers = {
|
|
"User-Agent": "LiveKit Agents",
|
|
"Authorization": f"Bearer {self._client.api_key}",
|
|
}
|
|
url = f"{str(self._client.base_url).rstrip('/')}/realtime?{urlencode(query_params)}"
|
|
if url.startswith("http"):
|
|
url = url.replace("http", "ws", 1)
|
|
|
|
session = self._ensure_session()
|
|
ws = await asyncio.wait_for(session.ws_connect(url, headers=headers), timeout)
|
|
await ws.send_json(realtime_config)
|
|
return ws
|
|
|
|
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
await ws.close()
|
|
|
|
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,
|
|
) -> stt.SpeechEvent:
|
|
try:
|
|
if is_given(language):
|
|
self._opts.language = LanguageCode(language)
|
|
data = rtc.combine_audio_frames(buffer).to_wav_bytes()
|
|
prompt = self._opts.prompt if is_given(self._opts.prompt) else openai.omit
|
|
|
|
format = "json"
|
|
if self._opts.model == "whisper-1":
|
|
# verbose_json returns language and other details, only supported for whisper-1
|
|
format = "verbose_json"
|
|
|
|
resp = await self._client.audio.transcriptions.create(
|
|
file=(
|
|
"file.wav",
|
|
data,
|
|
"audio/wav",
|
|
),
|
|
model=self._opts.model, # type: ignore
|
|
language=self._opts.language.language if self._opts.language else "",
|
|
prompt=prompt,
|
|
response_format=format,
|
|
temperature=self._opts.temperature
|
|
if is_given(self._opts.temperature)
|
|
else openai.omit,
|
|
timeout=httpx.Timeout(30, connect=conn_options.timeout),
|
|
)
|
|
|
|
sd = stt.SpeechData(text=resp.text, language=self._opts.language)
|
|
if isinstance(resp, TranscriptionVerbose) and resp.language:
|
|
sd.language = LanguageCode(resp.language)
|
|
|
|
return stt.SpeechEvent(
|
|
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
|
|
alternatives=[sd],
|
|
)
|
|
|
|
except openai.APITimeoutError:
|
|
raise APITimeoutError() from None
|
|
except openai.APIStatusError as e:
|
|
raise APIStatusError(
|
|
e.message, status_code=e.status_code, request_id=e.request_id, body=e.body
|
|
) from None
|
|
except Exception as e:
|
|
raise APIConnectionError() from e
|
|
|
|
|
|
class SpeechStream(stt.SpeechStream):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
stt: STT,
|
|
conn_options: APIConnectOptions,
|
|
pool: utils.ConnectionPool[aiohttp.ClientWebSocketResponse],
|
|
vad_instance: vad.VAD | None = None,
|
|
) -> None:
|
|
super().__init__(stt=stt, conn_options=conn_options, sample_rate=SAMPLE_RATE)
|
|
|
|
self._pool = pool
|
|
self._language = stt._opts.language
|
|
self._request_id = ""
|
|
self._reconnect_event = asyncio.Event()
|
|
self._vad = vad_instance
|
|
self._speaking = False
|
|
|
|
def update_options(
|
|
self,
|
|
*,
|
|
language: str,
|
|
) -> None:
|
|
self._language = LanguageCode(language)
|
|
self._pool.invalidate()
|
|
self._reconnect_event.set()
|
|
|
|
def _start_speaking(self) -> None:
|
|
if self._speaking:
|
|
return
|
|
self._speaking = True
|
|
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH))
|
|
|
|
def _stop_speaking(self) -> None:
|
|
if not self._speaking:
|
|
return
|
|
self._speaking = False
|
|
self._event_ch.send_nowait(stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH))
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def _run(self) -> None:
|
|
closing_ws = False
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def send_task(
|
|
ws: aiohttp.ClientWebSocketResponse, vad_stream: vad.VADStream | None
|
|
) -> None:
|
|
nonlocal closing_ws
|
|
|
|
# forward audio to OAI in chunks of 50ms
|
|
audio_bstream = utils.audio.AudioByteStream(
|
|
sample_rate=SAMPLE_RATE,
|
|
num_channels=NUM_CHANNELS,
|
|
samples_per_channel=SAMPLE_RATE // 20,
|
|
)
|
|
|
|
async for data in self._input_ch:
|
|
frames: list[rtc.AudioFrame] = []
|
|
if isinstance(data, rtc.AudioFrame):
|
|
if vad_stream is not None:
|
|
vad_stream.push_frame(data)
|
|
frames.extend(audio_bstream.write(data.data.tobytes()))
|
|
elif isinstance(data, self._FlushSentinel):
|
|
frames.extend(audio_bstream.flush())
|
|
|
|
for frame in frames:
|
|
encoded_frame = {
|
|
"type": "input_audio_buffer.append",
|
|
"audio": base64.b64encode(frame.data.tobytes()).decode("utf-8"),
|
|
}
|
|
await ws.send_json(encoded_frame)
|
|
|
|
if vad_stream is not None:
|
|
vad_stream.end_input()
|
|
closing_ws = True
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def vad_task(ws: aiohttp.ClientWebSocketResponse, vad_stream: vad.VADStream) -> None:
|
|
async for ev in vad_stream:
|
|
if ev.type == vad.VADEventType.START_OF_SPEECH:
|
|
self._start_speaking()
|
|
elif ev.type == vad.VADEventType.END_OF_SPEECH:
|
|
self._stop_speaking()
|
|
await ws.send_json({"type": "input_audio_buffer.commit"})
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
nonlocal closing_ws
|
|
current_text = ""
|
|
current_item_id = ""
|
|
last_interim_at: float = 0
|
|
connected_at = time.time()
|
|
item_audio_timing: dict[str, dict[str, int]] = {}
|
|
while True:
|
|
msg = await ws.receive()
|
|
if msg.type in (
|
|
aiohttp.WSMsgType.CLOSED,
|
|
aiohttp.WSMsgType.CLOSE,
|
|
aiohttp.WSMsgType.CLOSING,
|
|
):
|
|
if closing_ws: # close is expected, see SpeechStream.aclose
|
|
return
|
|
|
|
# this will trigger a reconnection, see the _run loop
|
|
raise APIStatusError(
|
|
message="OpenAI Realtime STT connection closed unexpectedly",
|
|
status_code=ws.close_code or -1,
|
|
body=f"{msg.data=} {msg.extra=}",
|
|
)
|
|
|
|
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
logger.warning("unexpected OpenAI message type %s", msg.type)
|
|
continue
|
|
|
|
try:
|
|
data = json.loads(msg.data)
|
|
msg_type = data.get("type")
|
|
if msg_type == "input_audio_buffer.speech_started":
|
|
item_id = data.get("item_id", "")
|
|
current_item_id = item_id
|
|
audio_start_ms = data.get("audio_start_ms", 0)
|
|
item_audio_timing[item_id] = {"start_ms": audio_start_ms}
|
|
if self._vad is None:
|
|
self._start_speaking()
|
|
|
|
elif msg_type == "input_audio_buffer.speech_stopped":
|
|
item_id = data.get("item_id", "")
|
|
audio_end_ms = data.get("audio_end_ms", 0)
|
|
if item_id in item_audio_timing:
|
|
item_audio_timing[item_id]["end_ms"] = audio_end_ms
|
|
if self._vad is None:
|
|
self._stop_speaking()
|
|
|
|
elif msg_type == "conversation.item.input_audio_transcription.delta":
|
|
delta = data.get("delta", "")
|
|
item_id = data.get("item_id", "") or current_item_id
|
|
if item_id:
|
|
current_item_id = item_id
|
|
if delta:
|
|
current_text += delta
|
|
if time.time() - last_interim_at > _delta_transcript_interval:
|
|
self._event_ch.send_nowait(
|
|
stt.SpeechEvent(
|
|
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
|
|
request_id=current_item_id,
|
|
alternatives=[
|
|
stt.SpeechData(
|
|
text=current_text,
|
|
language=self._language,
|
|
)
|
|
],
|
|
)
|
|
)
|
|
last_interim_at = time.time()
|
|
|
|
elif msg_type == "conversation.item.input_audio_transcription.completed":
|
|
current_text = ""
|
|
transcript = data.get("transcript", "")
|
|
item_id = data.get("item_id", "")
|
|
|
|
if transcript:
|
|
self._event_ch.send_nowait(
|
|
stt.SpeechEvent(
|
|
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
|
|
request_id=item_id,
|
|
alternatives=[
|
|
stt.SpeechData(
|
|
text=transcript,
|
|
language=self._language,
|
|
)
|
|
],
|
|
)
|
|
)
|
|
|
|
audio_duration = 0.0
|
|
if item_id in item_audio_timing:
|
|
timing = item_audio_timing[item_id]
|
|
start_ms = timing.get("start_ms", 0)
|
|
end_ms = timing.get("end_ms", 0)
|
|
if end_ms > start_ms:
|
|
audio_duration = (end_ms - start_ms) / 1000.0
|
|
del item_audio_timing[item_id]
|
|
|
|
# extract token usage if available
|
|
usage = data.get("usage", {})
|
|
input_tokens = usage.get("input_tokens", 0)
|
|
output_tokens = usage.get("output_tokens", 0)
|
|
|
|
self._event_ch.send_nowait(
|
|
stt.SpeechEvent(
|
|
type=stt.SpeechEventType.RECOGNITION_USAGE,
|
|
alternatives=[],
|
|
recognition_usage=stt.RecognitionUsage(
|
|
audio_duration=audio_duration,
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
),
|
|
)
|
|
)
|
|
|
|
# restart session if needed
|
|
if time.time() - connected_at > _max_session_duration:
|
|
logger.info("resetting Realtime STT session due to timeout")
|
|
self._pool.remove(ws)
|
|
self._reconnect_event.set()
|
|
return
|
|
elif msg_type == "error":
|
|
error_body = data.get("error", {})
|
|
raise APIError(
|
|
message=f"OpenAI Realtime STT error: {error_body.get('message', 'Unknown error')}",
|
|
body=error_body,
|
|
retryable=False,
|
|
)
|
|
|
|
except APIError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("failed to process OpenAI message")
|
|
|
|
while True:
|
|
closing_ws = False # reset the flag
|
|
# a segment left open across the reconnect gap would fuse into the next utterance
|
|
self._stop_speaking()
|
|
async with self._pool.connection(timeout=self._conn_options.timeout) as ws:
|
|
self._report_connection_acquired(
|
|
self._pool.last_acquire_time, self._pool.last_connection_reused
|
|
)
|
|
vad_stream = self._vad.stream() if self._vad is not None else None
|
|
tasks = [
|
|
asyncio.create_task(send_task(ws, vad_stream)),
|
|
asyncio.create_task(recv_task(ws)),
|
|
]
|
|
if vad_stream is not None:
|
|
tasks.append(asyncio.create_task(vad_task(ws, vad_stream)))
|
|
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,
|
|
)
|
|
|
|
# propagate exceptions from completed tasks
|
|
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() # retrieve the exception
|
|
if vad_stream is not None:
|
|
await vad_stream.aclose()
|