Files
wehub-resource-sync 94057c3d3e
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
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

741 lines
29 KiB
Python

"""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)