chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Realtime transcription WebSocket package. Exposes the FastAPI WS entry."""
|
||||
|
||||
from sglang.srt.entrypoints.openai.realtime.handler import (
|
||||
handle_realtime_transcription as handle_realtime_transcription,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""WebSocket entry for realtime transcription.
|
||||
|
||||
Handles accept, concurrency, cleanup. Event loop is in session.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from openai.types.realtime import RealtimeErrorEvent
|
||||
from openai.types.realtime.realtime_error import RealtimeError
|
||||
|
||||
from sglang.srt.entrypoints.openai.realtime.session import RealtimeConnection
|
||||
from sglang.srt.entrypoints.openai.transcription_adapters.base import (
|
||||
TranscriptionAdapter,
|
||||
)
|
||||
from sglang.srt.managers.tokenizer_manager import TokenizerManager
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.srt.utils import random_uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _safe_send(websocket: WebSocket, text: str) -> None:
|
||||
try:
|
||||
await websocket.send_text(text)
|
||||
except (WebSocketDisconnect, RuntimeError) as e:
|
||||
logger.debug("[realtime] send failed (peer gone): %s", e)
|
||||
|
||||
|
||||
async def _safe_close(websocket: WebSocket) -> None:
|
||||
try:
|
||||
await websocket.close()
|
||||
except (WebSocketDisconnect, RuntimeError) as e:
|
||||
logger.debug("[realtime] close failed (already closed): %s", e)
|
||||
|
||||
|
||||
async def _reject_before_session(
|
||||
websocket: WebSocket,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
error_type: str = "invalid_request_error",
|
||||
) -> None:
|
||||
"""Reject path that runs before acquiring the session semaphore, so
|
||||
unsupported / over-capacity peers don't hold a session slot."""
|
||||
try:
|
||||
await websocket.accept()
|
||||
except (WebSocketDisconnect, RuntimeError) as e:
|
||||
logger.debug("[realtime] reject: accept failed: %s", e)
|
||||
return
|
||||
logger.info("[realtime] rejected (%s)", code)
|
||||
envelope = RealtimeErrorEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="error",
|
||||
error=RealtimeError(type=error_type, code=code, message=message),
|
||||
)
|
||||
await _safe_send(websocket, envelope.model_dump_json())
|
||||
await _safe_close(websocket)
|
||||
|
||||
|
||||
async def handle_realtime_transcription(
|
||||
websocket: WebSocket,
|
||||
tokenizer_manager: TokenizerManager,
|
||||
adapter: TranscriptionAdapter,
|
||||
server_args: ServerArgs,
|
||||
session_semaphore: asyncio.Semaphore,
|
||||
) -> None:
|
||||
"""WS endpoint for /v1/realtime. Pre-session validation runs before
|
||||
the semaphore so rejects don't consume a session slot; the
|
||||
``async with`` then guarantees the slot is released even if
|
||||
RealtimeConnection raises."""
|
||||
if not adapter.supports_chunked_streaming:
|
||||
await _reject_before_session(
|
||||
websocket,
|
||||
"not_supported",
|
||||
"Model does not support streaming ASR",
|
||||
)
|
||||
return
|
||||
|
||||
if session_semaphore.locked():
|
||||
await _reject_before_session(
|
||||
websocket,
|
||||
"too_many_sessions",
|
||||
f"Maximum concurrent sessions reached "
|
||||
f"({server_args.asr_max_concurrent_sessions}).",
|
||||
error_type="rate_limit_exceeded",
|
||||
)
|
||||
return
|
||||
|
||||
async with session_semaphore:
|
||||
try:
|
||||
try:
|
||||
await websocket.accept()
|
||||
except (WebSocketDisconnect, RuntimeError) as e:
|
||||
logger.debug("[realtime] accept failed: %s", e)
|
||||
return
|
||||
connection = RealtimeConnection(
|
||||
websocket, tokenizer_manager, adapter, server_args
|
||||
)
|
||||
await connection.run()
|
||||
except WebSocketDisconnect:
|
||||
logger.info("[realtime] client disconnected (normal)")
|
||||
except Exception:
|
||||
logger.exception("[realtime] unexpected error in session")
|
||||
envelope = RealtimeErrorEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="error",
|
||||
error=RealtimeError(
|
||||
type="server_error",
|
||||
code="inference_failed",
|
||||
message="Internal server error",
|
||||
),
|
||||
)
|
||||
await _safe_send(websocket, envelope.model_dump_json())
|
||||
finally:
|
||||
await _safe_close(websocket)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Wire schema for Realtime WS transcription sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
from openai.types.realtime import SessionUpdateEvent as _SessionUpdateEvent
|
||||
from openai.types.realtime.audio_transcription import (
|
||||
AudioTranscription as _AudioTranscription,
|
||||
)
|
||||
from openai.types.realtime.realtime_audio_formats import AudioPCM as _AudioPCM
|
||||
from openai.types.realtime.realtime_audio_formats import AudioPCMA as _AudioPCMA
|
||||
from openai.types.realtime.realtime_audio_formats import AudioPCMU as _AudioPCMU
|
||||
from openai.types.realtime.realtime_transcription_session_audio import (
|
||||
RealtimeTranscriptionSessionAudio as _AudioCfg,
|
||||
)
|
||||
from openai.types.realtime.realtime_transcription_session_audio_input import (
|
||||
RealtimeTranscriptionSessionAudioInput as _AudioInputCfg,
|
||||
)
|
||||
from openai.types.realtime.realtime_transcription_session_create_request import (
|
||||
RealtimeTranscriptionSessionCreateRequest as _SessionCfg,
|
||||
)
|
||||
from pydantic import Field
|
||||
from typing_extensions import Annotated
|
||||
|
||||
# Fallback rate when the client omits `audio.input.format.rate`. SDK pins
|
||||
# `AudioPCM.rate` to Literal[24000], so this matches the only value the SDK
|
||||
# accepts when the field is present.
|
||||
DEFAULT_INPUT_SAMPLE_RATE = 24000
|
||||
|
||||
# Wire rates we accept on `audio.input.format.rate` and resample to
|
||||
# `adapter.model_sample_rate` server-side. 24000 matches the SDK pin;
|
||||
# 16000 and 48000 widen it to cover common ASR-client and consumer-audio
|
||||
# rates. Add a value here only after verifying transcription quality.
|
||||
SUPPORTED_INPUT_SAMPLE_RATES = (16000, 24000, 48000)
|
||||
|
||||
|
||||
class AudioPCM(_AudioPCM):
|
||||
type: Literal["audio/pcm"] = "audio/pcm"
|
||||
rate: Optional[int] = None
|
||||
|
||||
|
||||
class AudioPCMU(_AudioPCMU):
|
||||
type: Literal["audio/pcmu"] = "audio/pcmu"
|
||||
|
||||
|
||||
class AudioPCMA(_AudioPCMA):
|
||||
type: Literal["audio/pcma"] = "audio/pcma"
|
||||
|
||||
|
||||
AudioInputFormat = Annotated[
|
||||
Union[AudioPCM, AudioPCMU, AudioPCMA],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class AudioTranscription(_AudioTranscription):
|
||||
# SDK pins model to Literal["whisper-1", "gpt-4o-*-transcribe", ...];
|
||||
# sglang serves arbitrary ASR models (Qwen3-ASR, etc.) and treats the
|
||||
# client-supplied name as echo-only.
|
||||
model: Optional[str] = None
|
||||
|
||||
|
||||
class TranscriptionSessionAudioInput(_AudioInputCfg):
|
||||
format: Optional[AudioInputFormat] = None
|
||||
transcription: Optional[AudioTranscription] = None
|
||||
|
||||
|
||||
class TranscriptionSessionAudio(_AudioCfg):
|
||||
input: Optional[TranscriptionSessionAudioInput] = None
|
||||
|
||||
|
||||
class TranscriptionSessionConfig(_SessionCfg):
|
||||
audio: Optional[TranscriptionSessionAudio] = None
|
||||
|
||||
|
||||
class SessionUpdateEvent(_SessionUpdateEvent):
|
||||
session: TranscriptionSessionConfig
|
||||
@@ -0,0 +1,740 @@
|
||||
"""WebSocket session for realtime ASR.
|
||||
|
||||
Pre-commit deltas reference the reserved current_item_id that the
|
||||
subsequent input_audio_buffer.committed and conversation.item.created
|
||||
events will announce — sglang-specific, deviates from OpenAI's
|
||||
commit-only delta emission.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pybase64
|
||||
import soundfile as sf
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from openai.types.realtime import (
|
||||
ConversationItemCreatedEvent,
|
||||
InputAudioBufferAppendEvent,
|
||||
InputAudioBufferClearedEvent,
|
||||
InputAudioBufferClearEvent,
|
||||
InputAudioBufferCommitEvent,
|
||||
InputAudioBufferCommittedEvent,
|
||||
RealtimeErrorEvent,
|
||||
SessionCreatedEvent,
|
||||
SessionUpdatedEvent,
|
||||
)
|
||||
from openai.types.realtime.conversation_item_input_audio_transcription_completed_event import (
|
||||
ConversationItemInputAudioTranscriptionCompletedEvent,
|
||||
UsageTranscriptTextUsageDuration,
|
||||
)
|
||||
from openai.types.realtime.conversation_item_input_audio_transcription_delta_event import (
|
||||
ConversationItemInputAudioTranscriptionDeltaEvent,
|
||||
)
|
||||
from openai.types.realtime.conversation_item_input_audio_transcription_failed_event import (
|
||||
ConversationItemInputAudioTranscriptionFailedEvent,
|
||||
)
|
||||
from openai.types.realtime.conversation_item_input_audio_transcription_failed_event import (
|
||||
Error as TranscriptionFailedError,
|
||||
)
|
||||
from openai.types.realtime.realtime_conversation_item_user_message import (
|
||||
Content as InputAudioContent,
|
||||
)
|
||||
from openai.types.realtime.realtime_conversation_item_user_message import (
|
||||
RealtimeConversationItemUserMessage,
|
||||
)
|
||||
from openai.types.realtime.realtime_error import RealtimeError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from sglang.srt.entrypoints.openai.protocol import TranscriptionRequest
|
||||
from sglang.srt.entrypoints.openai.realtime.protocol import (
|
||||
DEFAULT_INPUT_SAMPLE_RATE,
|
||||
SUPPORTED_INPUT_SAMPLE_RATES,
|
||||
AudioPCM,
|
||||
SessionUpdateEvent,
|
||||
TranscriptionSessionAudioInput,
|
||||
TranscriptionSessionConfig,
|
||||
)
|
||||
from sglang.srt.entrypoints.openai.streaming_asr import (
|
||||
StreamingASRState,
|
||||
needs_space,
|
||||
normalize_whitespace,
|
||||
process_asr_chunk,
|
||||
)
|
||||
from sglang.srt.entrypoints.openai.transcription_adapters.base import (
|
||||
TranscriptionAdapter,
|
||||
)
|
||||
from sglang.srt.managers.tokenizer_manager import TokenizerManager
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.srt.utils import random_uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# PCM16: 16-bit samples → 2 bytes each. Used for frame-length validation
|
||||
# and bytes/sec arithmetic against `np.frombuffer(..., dtype=np.int16)` below.
|
||||
_SAMPLE_WIDTH = 2
|
||||
|
||||
|
||||
def _resample_to_target_rate(pcm: bytes, src_rate: int, target_rate: int) -> bytes:
|
||||
if src_rate == target_rate or not pcm:
|
||||
return pcm
|
||||
import torch
|
||||
import torchaudio
|
||||
|
||||
samples = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
audio = torch.from_numpy(samples).unsqueeze(0)
|
||||
audio = torchaudio.functional.resample(
|
||||
audio, orig_freq=src_rate, new_freq=target_rate
|
||||
)
|
||||
samples = audio.squeeze(0).numpy()
|
||||
# Clip to int16 range via 2^15 - 1 so a clipped 1.0 stays representable.
|
||||
return (np.clip(samples, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes()
|
||||
|
||||
|
||||
def _pcm_to_wav(pcm: bytes, sample_rate: int) -> bytes:
|
||||
samples = np.frombuffer(pcm, dtype=np.int16)
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, samples, sample_rate, format="WAV")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
_CLIENT_EVENT_TYPES: Dict[str, type] = {
|
||||
"session.update": SessionUpdateEvent,
|
||||
"input_audio_buffer.append": InputAudioBufferAppendEvent,
|
||||
"input_audio_buffer.commit": InputAudioBufferCommitEvent,
|
||||
"input_audio_buffer.clear": InputAudioBufferClearEvent,
|
||||
}
|
||||
|
||||
|
||||
def _parse_client_event(raw: Dict[str, Any]) -> Optional[BaseModel]:
|
||||
"""Parse, returning None if type is unknown. Raises ValidationError on
|
||||
a malformed payload of a known type."""
|
||||
cls = _CLIENT_EVENT_TYPES.get(raw.get("type"))
|
||||
if cls is None:
|
||||
return None
|
||||
return cls.model_validate(raw)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SessionConfig:
|
||||
"""Session-level configuration negotiated via session.update: audio
|
||||
input format, requested language, sampling params. Persists until
|
||||
the session ends; ``configured`` gates audio-frame handling so the
|
||||
server doesn't run inference on PCM sent before session.update."""
|
||||
|
||||
input_sample_rate: int = DEFAULT_INPUT_SAMPLE_RATE
|
||||
language: Optional[str] = None
|
||||
client_model: Optional[str] = None
|
||||
sampling_params: Optional[Dict[str, Any]] = None
|
||||
configured: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AudioState:
|
||||
"""Per-item audio state: PCM buffer accumulated from
|
||||
input_audio_buffer.append, the chunked ASR rollback state, and the
|
||||
static buffer-size limits set at __init__. pcm_buffer / state /
|
||||
last_inference_offset reset on commit-roll and clear; the size limits
|
||||
stay constant for the session's lifetime."""
|
||||
|
||||
max_buffer_bytes: int
|
||||
chunk_size_bytes: int
|
||||
state: StreamingASRState
|
||||
pcm_buffer: bytearray = field(default_factory=bytearray)
|
||||
last_inference_offset: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ItemState:
|
||||
"""Per-item conversation-item ids and the wire-formatted deltas
|
||||
emitted so far for the current item. current_item_id is reserved at
|
||||
__init__ and only announced to the client by
|
||||
input_audio_buffer.committed."""
|
||||
|
||||
current_item_id: str
|
||||
previous_item_id: Optional[str] = None
|
||||
emitted_deltas: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class RealtimeConnection:
|
||||
"""One realtime transcription session. Drives the WS receive loop,
|
||||
dispatches typed client events to the matching _on_* handler, and
|
||||
triggers chunked ASR inference at audio buffer thresholds."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
tokenizer_manager: TokenizerManager,
|
||||
adapter: TranscriptionAdapter,
|
||||
server_args: ServerArgs,
|
||||
) -> None:
|
||||
self.websocket = websocket
|
||||
self.tokenizer_manager = tokenizer_manager
|
||||
self.adapter = adapter
|
||||
self.server_args = server_args
|
||||
|
||||
self.session_id = f"sess_{random_uuid()}"
|
||||
self._current_client_event_id: Optional[str] = None
|
||||
|
||||
self.model_sample_rate = adapter.model_sample_rate
|
||||
self.bytes_per_second = self.model_sample_rate * _SAMPLE_WIDTH
|
||||
self.max_buffer_seconds = server_args.asr_max_buffer_seconds
|
||||
|
||||
self.config = _SessionConfig()
|
||||
|
||||
state = StreamingASRState(**adapter.chunked_streaming_config)
|
||||
chunk_size_bytes = int(state.chunk_size_sec * self.bytes_per_second)
|
||||
if chunk_size_bytes <= 0:
|
||||
raise RuntimeError(
|
||||
f"adapter.chunked_streaming_config produced non-positive "
|
||||
f"chunk_size_sec; got {state.chunk_size_sec!r}"
|
||||
)
|
||||
self.audio = _AudioState(
|
||||
max_buffer_bytes=self.max_buffer_seconds * self.bytes_per_second,
|
||||
chunk_size_bytes=chunk_size_bytes,
|
||||
state=state,
|
||||
)
|
||||
|
||||
self.item = _ItemState(current_item_id=f"item_{random_uuid()}")
|
||||
|
||||
async def run(self) -> None:
|
||||
await self._send(
|
||||
SessionCreatedEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="session.created",
|
||||
session=self._build_session_info(),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await self._run_loop()
|
||||
except WebSocketDisconnect:
|
||||
logger.info("[realtime] client disconnected: %s", self.session_id)
|
||||
except Exception:
|
||||
logger.exception("[realtime] unexpected error: %s", self.session_id)
|
||||
try:
|
||||
await self._send_error(
|
||||
"inference_failed",
|
||||
"Internal server error",
|
||||
error_type="server_error",
|
||||
)
|
||||
except (WebSocketDisconnect, RuntimeError) as e:
|
||||
logger.debug(
|
||||
"[realtime] failed to notify client of unexpected error: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""Receive-and-dispatch loop. Validation errors emit an error event
|
||||
and continue; fatal append-path errors (buffer overflow, append-time
|
||||
inference failure) close the WebSocket and terminate the loop.
|
||||
"""
|
||||
while True:
|
||||
self._current_client_event_id = None
|
||||
message = await self.websocket.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
return
|
||||
|
||||
text = message.get("text")
|
||||
if not text:
|
||||
if message.get("bytes") is not None:
|
||||
# OpenAI Realtime is base64 PCM in JSON; binary frames aren't supported.
|
||||
await self._send_error(
|
||||
"invalid_payload",
|
||||
"Binary frames are not supported on /v1/realtime; "
|
||||
"use input_audio_buffer.append with base64 audio.",
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
await self._send_error("invalid_payload", "Invalid JSON")
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
await self._send_error(
|
||||
"invalid_payload", "Top-level event must be a JSON object"
|
||||
)
|
||||
continue
|
||||
|
||||
self._current_client_event_id = raw.get("event_id")
|
||||
try:
|
||||
event = _parse_client_event(raw)
|
||||
except ValidationError as e:
|
||||
# Report first error only; matches OpenAI server behavior.
|
||||
err = e.errors()[0]
|
||||
loc = ".".join(str(x) for x in err["loc"])
|
||||
await self._send_error(
|
||||
"invalid_value",
|
||||
err.get("msg") or "Invalid payload",
|
||||
param=loc or None,
|
||||
)
|
||||
continue
|
||||
if event is None:
|
||||
await self._send_error(
|
||||
"unknown_event",
|
||||
f"Unknown event type: {raw.get('type')!r}",
|
||||
)
|
||||
continue
|
||||
terminate = await self._dispatch(event)
|
||||
if terminate:
|
||||
return
|
||||
|
||||
async def _dispatch(self, event: BaseModel) -> bool:
|
||||
"""Returns True if the session should terminate."""
|
||||
if isinstance(event, InputAudioBufferAppendEvent):
|
||||
return await self._on_input_audio_buffer_append(event)
|
||||
if isinstance(event, SessionUpdateEvent):
|
||||
await self._on_session_update(event)
|
||||
elif isinstance(event, InputAudioBufferCommitEvent):
|
||||
await self._on_input_audio_buffer_commit(event)
|
||||
elif isinstance(event, InputAudioBufferClearEvent):
|
||||
await self._on_input_audio_buffer_clear(event)
|
||||
return False
|
||||
|
||||
async def _on_session_update(self, event: SessionUpdateEvent) -> None:
|
||||
cfg = event.session
|
||||
|
||||
# Normalize audio to an empty input cfg if absent so downstream
|
||||
# `audio.X is not None` reads as a business rule, not an existence check.
|
||||
# transcription stays nullable so partial-update can detect whether
|
||||
# the client sent the block.
|
||||
audio = (
|
||||
cfg.audio.input if cfg.audio else None
|
||||
) or TranscriptionSessionAudioInput()
|
||||
transcription = audio.transcription
|
||||
|
||||
# Validate first, then mutate config only after the whole update is accepted.
|
||||
if audio.turn_detection is not None:
|
||||
await self._send_error(
|
||||
"not_supported",
|
||||
"Server-side VAD is not implemented; "
|
||||
"set audio.input.turn_detection: null and commit explicitly.",
|
||||
param="session.audio.input.turn_detection",
|
||||
)
|
||||
return
|
||||
if audio.noise_reduction is not None:
|
||||
await self._send_error(
|
||||
"not_supported",
|
||||
"audio.input.noise_reduction is not supported; set to null.",
|
||||
param="session.audio.input.noise_reduction",
|
||||
)
|
||||
return
|
||||
if transcription is not None and transcription.prompt is not None:
|
||||
await self._send_error(
|
||||
"not_supported",
|
||||
"audio.input.transcription.prompt is not supported.",
|
||||
param="session.audio.input.transcription.prompt",
|
||||
)
|
||||
return
|
||||
if (
|
||||
transcription is not None
|
||||
and transcription.model
|
||||
and transcription.model != self.server_args.served_model_name
|
||||
):
|
||||
await self._send_error(
|
||||
"not_supported",
|
||||
f"Model {transcription.model!r} is not served by this endpoint "
|
||||
f"(serving {self.server_args.served_model_name!r}); set "
|
||||
f"transcription.model to null or to the server's model name.",
|
||||
param="session.audio.input.transcription.model",
|
||||
)
|
||||
return
|
||||
|
||||
new_rate = self.config.input_sample_rate # default: keep current
|
||||
fmt = audio.format
|
||||
if fmt is not None:
|
||||
if not isinstance(fmt, AudioPCM):
|
||||
# G.711 (pcmu / pcma): not implemented.
|
||||
await self._send_error(
|
||||
"not_supported",
|
||||
f"audio.input.format.type must be 'audio/pcm'; "
|
||||
f"{fmt.type!r} is not implemented",
|
||||
param="session.audio.input.format.type",
|
||||
)
|
||||
return
|
||||
if fmt.rate is not None and fmt.rate not in SUPPORTED_INPUT_SAMPLE_RATES:
|
||||
await self._send_error(
|
||||
"invalid_value",
|
||||
f"audio.input.format.rate must be one of "
|
||||
f"{SUPPORTED_INPUT_SAMPLE_RATES}, got {fmt.rate}",
|
||||
param="session.audio.input.format.rate",
|
||||
)
|
||||
return
|
||||
new_rate = fmt.rate or DEFAULT_INPUT_SAMPLE_RATE
|
||||
# Changing the rate mid-item would leave already-buffered PCM
|
||||
# at the old rate mixed with new audio at the new rate, so
|
||||
# require the client to commit or clear before switching.
|
||||
if new_rate != self.config.input_sample_rate and self.audio.pcm_buffer:
|
||||
await self._send_error(
|
||||
"invalid_state",
|
||||
"Cannot change audio.input.format.rate while audio is "
|
||||
"buffered; commit or clear the current item first.",
|
||||
param="session.audio.input.format.rate",
|
||||
)
|
||||
return
|
||||
|
||||
# Mutation pass — no early returns past this point.
|
||||
self.config.input_sample_rate = new_rate
|
||||
if transcription is not None:
|
||||
self.config.client_model = transcription.model
|
||||
self.config.language = transcription.language
|
||||
self.config.sampling_params = self.adapter.build_sampling_params(
|
||||
TranscriptionRequest(language=self.config.language)
|
||||
)
|
||||
self.config.configured = True
|
||||
|
||||
# Side effects: log + ack.
|
||||
if cfg.include:
|
||||
logger.info(
|
||||
"[realtime] %s: include[] received but not implemented; ignoring: %s",
|
||||
self.session_id,
|
||||
cfg.include,
|
||||
)
|
||||
if self.config.input_sample_rate != self.model_sample_rate:
|
||||
logger.info(
|
||||
"[realtime] %s configured: resample %d→%d (ratio %.2f), language=%s",
|
||||
self.session_id,
|
||||
self.config.input_sample_rate,
|
||||
self.model_sample_rate,
|
||||
self.config.input_sample_rate / self.model_sample_rate,
|
||||
self.config.language,
|
||||
)
|
||||
await self._send(
|
||||
SessionUpdatedEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="session.updated",
|
||||
session=self._build_session_info(),
|
||||
)
|
||||
)
|
||||
|
||||
async def _on_input_audio_buffer_append(
|
||||
self, event: InputAudioBufferAppendEvent
|
||||
) -> bool:
|
||||
"""Returns True if the session should terminate (buffer overflow or
|
||||
append-time inference failure)."""
|
||||
if not self.config.configured:
|
||||
await self._send_error(
|
||||
"invalid_state", "Send session.update before audio frames"
|
||||
)
|
||||
return False
|
||||
|
||||
# Empty audio is a no-op (heartbeat frames); skip b64decode.
|
||||
if not event.audio:
|
||||
return False
|
||||
|
||||
try:
|
||||
data = pybase64.b64decode(event.audio, validate=True)
|
||||
except (ValueError, TypeError):
|
||||
await self._send_error(
|
||||
"invalid_audio", "audio field is not valid base64", param="audio"
|
||||
)
|
||||
return False
|
||||
|
||||
if len(data) % _SAMPLE_WIDTH != 0:
|
||||
await self._send_error(
|
||||
"invalid_audio_format",
|
||||
f"PCM16 frame length must be a multiple of {_SAMPLE_WIDTH} bytes",
|
||||
)
|
||||
return False
|
||||
|
||||
# Estimate post-resample size before resampling so oversized frames fail early.
|
||||
src_samples = len(data) // _SAMPLE_WIDTH
|
||||
target_samples = math.ceil(
|
||||
src_samples * self.model_sample_rate / self.config.input_sample_rate
|
||||
)
|
||||
if (
|
||||
len(self.audio.pcm_buffer) + target_samples * _SAMPLE_WIDTH
|
||||
> self.audio.max_buffer_bytes
|
||||
):
|
||||
# Close 1009 ("message too big") so clients can distinguish
|
||||
# session-resource exhaustion from a normal close.
|
||||
await self._send_error_and_close(
|
||||
"buffer_overflow",
|
||||
f"Accumulated audio exceeded {self.max_buffer_seconds}s; "
|
||||
f"client is sending faster than inference can keep up",
|
||||
close_code=1009,
|
||||
)
|
||||
return True
|
||||
|
||||
if self.config.input_sample_rate != self.model_sample_rate:
|
||||
data = await asyncio.to_thread(
|
||||
_resample_to_target_rate,
|
||||
data,
|
||||
self.config.input_sample_rate,
|
||||
self.model_sample_rate,
|
||||
)
|
||||
self.audio.pcm_buffer.extend(data)
|
||||
|
||||
new_audio_bytes = len(self.audio.pcm_buffer) - self.audio.last_inference_offset
|
||||
if new_audio_bytes >= self.audio.chunk_size_bytes:
|
||||
ok = await self._run_inference(is_last=False)
|
||||
if not ok:
|
||||
# WS already closed inside _run_inference.
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _on_input_audio_buffer_commit(
|
||||
self, event: InputAudioBufferCommitEvent
|
||||
) -> None:
|
||||
if not self.config.configured:
|
||||
await self._send_error("invalid_state", "Send session.update before commit")
|
||||
return
|
||||
if not self.audio.pcm_buffer and not self.audio.state.full_transcript:
|
||||
await self._send_error(
|
||||
"invalid_state", "Cannot commit an empty audio buffer"
|
||||
)
|
||||
return
|
||||
|
||||
has_new_audio = len(self.audio.pcm_buffer) > self.audio.last_inference_offset
|
||||
item_id = self.item.current_item_id
|
||||
prev_item_id = self.item.previous_item_id
|
||||
|
||||
partial_transcript = normalize_whitespace("".join(self.item.emitted_deltas))
|
||||
|
||||
await self._send(
|
||||
InputAudioBufferCommittedEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="input_audio_buffer.committed",
|
||||
item_id=item_id,
|
||||
previous_item_id=prev_item_id,
|
||||
)
|
||||
)
|
||||
await self._send(
|
||||
ConversationItemCreatedEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="conversation.item.created",
|
||||
previous_item_id=prev_item_id,
|
||||
item=RealtimeConversationItemUserMessage(
|
||||
id=item_id,
|
||||
type="message",
|
||||
role="user",
|
||||
status="completed",
|
||||
content=[
|
||||
InputAudioContent(
|
||||
type="input_audio", transcript=partial_transcript
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Capture pcm duration before `_start_next_item()` runs: starting
|
||||
# the next item clears pcm_buffer, so reading it after gives 0.
|
||||
pcm_duration_seconds = len(self.audio.pcm_buffer) / self.bytes_per_second
|
||||
|
||||
if has_new_audio:
|
||||
ok = await self._run_inference(is_last=True)
|
||||
if not ok:
|
||||
# _run_inference already emitted transcription.failed and
|
||||
# rolled the item; don't also emit completed.
|
||||
return
|
||||
elif self.audio.state.full_transcript:
|
||||
# Audio length was exactly a chunk_size_bytes multiple. Flush
|
||||
# the tail tokens update() held back.
|
||||
tail = self.audio.state.finalize()
|
||||
await self._emit_transcription_delta(tail)
|
||||
|
||||
# Build from emitted_deltas, not state.full_transcript: prefix injection
|
||||
# means the last chunk's full_transcript is only the continuation tail.
|
||||
transcript = normalize_whitespace("".join(self.item.emitted_deltas))
|
||||
|
||||
await self._send(
|
||||
ConversationItemInputAudioTranscriptionCompletedEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="conversation.item.input_audio_transcription.completed",
|
||||
item_id=item_id,
|
||||
content_index=0,
|
||||
transcript=transcript,
|
||||
usage=UsageTranscriptTextUsageDuration(
|
||||
type="duration", seconds=pcm_duration_seconds
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
self._start_next_item()
|
||||
|
||||
async def _on_input_audio_buffer_clear(
|
||||
self, event: InputAudioBufferClearEvent
|
||||
) -> None:
|
||||
# Reserve a fresh current_item_id so post-clear pre-commit deltas
|
||||
# don't share an item_id with deltas the client already received
|
||||
# for the abandoned audio. previous_item_id is NOT touched — the
|
||||
# cleared item was never committed, so the prior-commit chain
|
||||
# shouldn't include it.
|
||||
self._reset_inference_state()
|
||||
self.item.current_item_id = f"item_{random_uuid()}"
|
||||
await self._send(
|
||||
InputAudioBufferClearedEvent(
|
||||
event_id=f"event_{random_uuid()}", type="input_audio_buffer.cleared"
|
||||
)
|
||||
)
|
||||
|
||||
async def _run_inference(self, is_last: bool) -> bool:
|
||||
"""Run ASR on the current cumulative buffer. Returns False on failure:
|
||||
commit-time emits transcription.failed and rolls the item; append-time
|
||||
emits a generic error envelope and closes the WebSocket."""
|
||||
wav_data = await asyncio.to_thread(
|
||||
_pcm_to_wav, bytes(self.audio.pcm_buffer), self.model_sample_rate
|
||||
)
|
||||
try:
|
||||
delta = await process_asr_chunk(
|
||||
tokenizer_manager=self.tokenizer_manager,
|
||||
adapter=self.adapter,
|
||||
state=self.audio.state,
|
||||
audio_data=wav_data,
|
||||
sampling_params=self.config.sampling_params,
|
||||
is_last=is_last,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[realtime] inference failed: session=%s item=%s buffer_bytes=%d",
|
||||
self.session_id,
|
||||
self.item.current_item_id,
|
||||
len(self.audio.pcm_buffer),
|
||||
)
|
||||
if is_last:
|
||||
# Commit-time failure: committed + created already emitted,
|
||||
# so the item exists client-side and transcription.failed
|
||||
# can reference it. Wire message is hardcoded "Transcription
|
||||
# failed" — don't leak backend traces to the client; full
|
||||
# error is in the logger.exception above.
|
||||
await self._send(
|
||||
ConversationItemInputAudioTranscriptionFailedEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="conversation.item.input_audio_transcription.failed",
|
||||
item_id=self.item.current_item_id,
|
||||
content_index=0,
|
||||
error=TranscriptionFailedError(
|
||||
type="server_error",
|
||||
code="inference_failed",
|
||||
message="Transcription failed",
|
||||
),
|
||||
)
|
||||
)
|
||||
self._start_next_item()
|
||||
else:
|
||||
# Append-time failure: the item isn't visible client-side
|
||||
# yet (committed/created fire at commit), so
|
||||
# transcription.failed would reference a ghost id.
|
||||
await self._send_error_and_close(
|
||||
"inference_failed",
|
||||
"Transcription failed",
|
||||
close_code=1011,
|
||||
)
|
||||
return False
|
||||
|
||||
self.audio.last_inference_offset = len(self.audio.pcm_buffer)
|
||||
await self._emit_transcription_delta(delta)
|
||||
return True
|
||||
|
||||
async def _emit_transcription_delta(self, delta: str) -> None:
|
||||
"""emitted_deltas stores wire-formatted text (with leading
|
||||
boundary spaces baked in), so "".join(...) reconstructs the
|
||||
cumulative transcript verbatim."""
|
||||
if not delta:
|
||||
return
|
||||
for word in delta.split(" "):
|
||||
if not word:
|
||||
continue
|
||||
prev = self.item.emitted_deltas[-1] if self.item.emitted_deltas else ""
|
||||
formatted = f" {word}" if needs_space(prev, word) else word
|
||||
self.item.emitted_deltas.append(formatted)
|
||||
await self._send(
|
||||
ConversationItemInputAudioTranscriptionDeltaEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="conversation.item.input_audio_transcription.delta",
|
||||
item_id=self.item.current_item_id,
|
||||
content_index=0,
|
||||
delta=formatted,
|
||||
)
|
||||
)
|
||||
|
||||
def _start_next_item(self) -> None:
|
||||
self.item.previous_item_id = self.item.current_item_id
|
||||
self.item.current_item_id = f"item_{random_uuid()}"
|
||||
self._reset_inference_state()
|
||||
|
||||
def _reset_inference_state(self) -> None:
|
||||
"""Missing any of these resets leaks state across items."""
|
||||
self.audio.state = StreamingASRState(**self.adapter.chunked_streaming_config)
|
||||
self.audio.pcm_buffer.clear() # in-place; reuses the buffer's allocation
|
||||
self.item.emitted_deltas.clear()
|
||||
self.audio.last_inference_offset = 0
|
||||
|
||||
def _build_session_info(self) -> TranscriptionSessionConfig:
|
||||
# id / object aren't SDK fields; round-trip via extra='allow' so
|
||||
# dumps emit them like the real server.
|
||||
return TranscriptionSessionConfig.model_validate(
|
||||
{
|
||||
"type": "transcription",
|
||||
"id": self.session_id,
|
||||
"object": "realtime.transcription_session",
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {
|
||||
"type": "audio/pcm",
|
||||
"rate": self.config.input_sample_rate,
|
||||
},
|
||||
"transcription": {
|
||||
"model": self.config.client_model,
|
||||
"language": self.config.language,
|
||||
},
|
||||
"noise_reduction": None,
|
||||
"turn_detection": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def _send(self, event: BaseModel) -> None:
|
||||
await self.websocket.send_text(event.model_dump_json())
|
||||
|
||||
async def _send_error(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
error_type: str = "invalid_request_error",
|
||||
param: Optional[str] = None,
|
||||
) -> None:
|
||||
envelope = RealtimeErrorEvent(
|
||||
event_id=f"event_{random_uuid()}",
|
||||
type="error",
|
||||
error=RealtimeError(
|
||||
type=error_type,
|
||||
code=code,
|
||||
message=message,
|
||||
param=param,
|
||||
event_id=self._current_client_event_id,
|
||||
),
|
||||
)
|
||||
await self.websocket.send_text(envelope.model_dump_json())
|
||||
|
||||
async def _send_error_and_close(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
close_code: int,
|
||||
error_type: str = "server_error",
|
||||
) -> None:
|
||||
# Independent try-blocks: a failed send must not skip the close.
|
||||
# We still need to release local starlette socket state even when
|
||||
# the wire send doesn't reach the peer.
|
||||
try:
|
||||
await self._send_error(code, message, error_type=error_type)
|
||||
except (WebSocketDisconnect, RuntimeError) as e:
|
||||
logger.debug("[realtime] send error %s before close failed: %s", code, e)
|
||||
try:
|
||||
await self.websocket.close(code=close_code)
|
||||
except (WebSocketDisconnect, RuntimeError) as e:
|
||||
logger.debug("[realtime] close %d after %s failed: %s", close_code, code, e)
|
||||
Reference in New Issue
Block a user