first git
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
## Realtime Engine -- High-Level Architecture
|
||||
|
||||
The server runs as a handler thread inside the existing pipeline. A FastAPI/uvicorn instance serves the WebSocket endpoint at `/v1/realtime`, while the same queue-driven handler chain (VAD, STT, LLM, TTS) processes audio in parallel threads.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph client [Client]
|
||||
WS["WebSocket /v1/realtime"]
|
||||
end
|
||||
|
||||
subgraph server [Realtime Server]
|
||||
Router["WebSocket Router"]
|
||||
Service["RealtimeService"]
|
||||
Config["RuntimeConfig"]
|
||||
end
|
||||
|
||||
subgraph pipeline [Pipeline Threads]
|
||||
VAD["VAD"]
|
||||
STT["STT"]
|
||||
TN["TranscriptionNotifier"]
|
||||
LLM["LLM"]
|
||||
Proc["LMOutputProcessor"]
|
||||
TTS["TTS"]
|
||||
end
|
||||
|
||||
WS -- "client events (JSON)" --> Router
|
||||
Router -- "parse / dispatch" --> Service
|
||||
Service -- "PCM chunks" --> VAD
|
||||
Service -- "session config" --> Config
|
||||
Config -. "read by" .-> VAD & LLM & TTS
|
||||
VAD -- "speech segments" --> STT
|
||||
STT -- "transcript" --> TN
|
||||
TN -- "transcript + notify" --> LLM
|
||||
LLM -- "text + tools" --> Proc
|
||||
Proc -- "clean text" --> TTS
|
||||
TTS -- "PCM audio" --> Router
|
||||
Proc -- "assistant_text / tools" --> Router
|
||||
VAD -- "speech_started/stopped" --> Router
|
||||
TN -- "transcription events" --> Router
|
||||
Router -- "server events (JSON)" --> WS
|
||||
```
|
||||
|
||||
**Key flow:**
|
||||
|
||||
1. **Inbound audio**: Client sends `input_audio_buffer.append` with base64 PCM. `RealtimeService` decodes, resamples to 16 kHz, splits into 512-sample chunks, and puts them on the `recv_audio_chunks_queue` for VAD.
|
||||
2. **Speech detection**: VAD detects speech boundaries and emits `speech_started` / `speech_stopped` events on the `text_output_queue`. Full utterance audio goes to STT.
|
||||
3. **Transcription**: STT output passes through `TranscriptionNotifier`, which taps the transcript for `transcription.delta` / `transcription.completed` events before forwarding to the LLM.
|
||||
4. **Generation**: The LLM generates text (and optional tool calls). `LMOutputProcessor` splits the output: clean text goes to TTS, and `assistant_text` + tool call dicts go to the `text_output_queue`.
|
||||
5. **Outbound audio**: TTS writes PCM chunks to `send_audio_chunks_queue`. The router's async `_send_loop` drains both queues, encoding PCM as `response.output_audio.delta` events and translating internal messages into protocol events.
|
||||
6. **Session config**: `session.update` events deep-merge into `RuntimeConfig`, which is a shared Pydantic model read by VAD (turn detection thresholds), LLM (instructions, tools), and TTS (voice) at processing time.
|
||||
|
||||
---
|
||||
|
||||
## Supported OpenAI Realtime Events
|
||||
|
||||
### Client -> Server
|
||||
|
||||
| Event | Description |
|
||||
|---|---|
|
||||
| `input_audio_buffer.append` | Stream base64 PCM audio. Decoded, resampled to 16 kHz, and chunked for the VAD. |
|
||||
| `session.update` | Deep-merge session config (instructions, tools, voice, turn detection, audio format). |
|
||||
| `conversation.item.create` | Inject `input_text` or `function_call_output` into the LLM context without triggering generation. |
|
||||
| `response.create` | Trigger LLM generation. Supports per-response `instructions` and `tool_choice` overrides. |
|
||||
| `response.cancel` | Cancel the in-progress response and re-enable listening. |
|
||||
|
||||
### Server -> Client
|
||||
|
||||
| Event | Description |
|
||||
|---|---|
|
||||
| `session.created` | Sent on connection with current session config. |
|
||||
| `error` | Protocol errors (`session_limit_reached`, `unknown_or_invalid_event`, `invalid_session_type`, `conversation_already_has_active_response`, etc.) |
|
||||
| `input_audio_buffer.speech_started` | VAD detected user speech. |
|
||||
| `input_audio_buffer.speech_stopped` | End of user speech segment. |
|
||||
| `conversation.item.created` | Acknowledges injected `input_text` from `conversation.item.create`. |
|
||||
| `conversation.item.input_audio_transcription.delta` | Streaming partial transcript (when live transcription is enabled). |
|
||||
| `conversation.item.input_audio_transcription.completed` | Final transcript for the user turn (with duration usage). |
|
||||
| `response.created` | Emitted on the first outbound audio chunk (response is `in_progress`). |
|
||||
| `response.output_audio.delta` | Base64 PCM audio chunk from TTS. |
|
||||
| `response.output_audio.done` | Audio stream complete for the current output item. |
|
||||
| `response.output_audio_transcript.done` | Full assistant text transcript for the turn. |
|
||||
| `response.function_call_arguments.done` | Tool call with `call_id`, `name`, and JSON `arguments`. |
|
||||
| `response.done` | Response finished (`completed`, `cancelled` with reason `turn_detected` or `client_cancelled`). |
|
||||
|
||||
---
|
||||
|
||||
## Tool Calling Design
|
||||
|
||||
Tool calling works through two distinct paths depending on the LLM backend, but both converge to the same wire protocol for the client.
|
||||
|
||||
### Local LLM path (`LanguageModelHandler` -- transformers / mlx-lm)
|
||||
|
||||
Tools defined in `session.update` are converted to `FunctionTool` objects. Each tool's JSON Schema `parameters` are turned into a Python `inspect.Signature` (via `signature_from_schema`), and `to_code_prompt()` renders a human-readable `def name(...): """docstring"""` block.
|
||||
|
||||
These tool signatures are injected into the system prompt using a Jinja2 template (`tool_prompt.py`) that instructs the model to wrap tool calls in `<code>...</code>` delimiters:
|
||||
|
||||
```
|
||||
<code>
|
||||
function_name(arg_name_1=value1, arg_name_2='string_value')
|
||||
</code>
|
||||
```
|
||||
|
||||
After generation, `_extract_tools` uses a regex to find `<code>` blocks, then `extract_function_calls_from_text` parses each `name(kwargs)` call and validates it against registered tools. Valid calls become `ResponseFunctionToolCall` dicts with generated `call_id`s.
|
||||
|
||||
### OpenAI API path (`ResponsesApiModelHandler`)
|
||||
|
||||
Tools are passed natively as the `tools=` parameter to `client.responses.create`. The API returns structured `function_call` items directly -- no prompt engineering or regex parsing needed. Per-response `tool_choice` overrides from `response.create` are supported.
|
||||
|
||||
### Common output path
|
||||
|
||||
Both handlers yield `(text, language_code, tools)` tuples. `LMOutputProcessor` forwards clean text to TTS and puts `{"type": "assistant_text", "text": ..., "tools": [...]}` on the `text_output_queue`. The router's `_send_loop` translates these into:
|
||||
- `response.output_audio_transcript.done` for the text
|
||||
- `response.function_call_arguments.done` for each tool call
|
||||
|
||||
### Tool result flow
|
||||
|
||||
1. Client executes the tool and sends `conversation.item.create` with `type: "function_call_output"` and `output: "<result>"`
|
||||
2. `RealtimeService` appends the tool output to the chat context and emits `conversation.item.created`; this does not trigger generation.
|
||||
3. If the tool result needs to be spoken to the user, such as camera/search/data results, the client sends `response.create` to trigger follow-up generation.
|
||||
4. For fire-and-forget robot actions such as dance, emotion, head movement, stop, or idle tools, the client can stop after `conversation.item.created`; the assistant should already have spoken the natural lead-in before the tool call.
|
||||
|
||||
---
|
||||
|
||||
## Interruption Handling
|
||||
|
||||
Barge-in (user speaks while the assistant is playing audio) is handled cooperatively between the VAD, the `_send_loop`, and the LLM/TTS handlers via a shared `CancelScope` object (`cancel_scope.py`).
|
||||
|
||||
### CancelScope design
|
||||
|
||||
`CancelScope` replaces the old two-signal pattern (`cancel_response` Event + `discard_stale_output` boolean) with a single object that manages:
|
||||
|
||||
- **Generation counter** (`cancel_scope.generation`): pipeline threads (LLM, TTS) capture the current generation at the start of each response and check `cancel_scope.is_stale(gen)` on every streaming token. When `cancel()` is called, the generation increments and all prior generations become stale -- no timing games required.
|
||||
- **Discard flag** (`cancel_scope.discarding`): set by `cancel()`, checked by the async `_send_loop` to drop output from superseded generations that arrives between `cancel()` and `response_done()`. Cleared by `response_done(generation)` (only when the sentinel's generation matches the discarded or current one -- sentinels from unrelated older generations are ignored), by `new_response()` on an explicit `response.create`, or by `reset()` on session claim/release.
|
||||
|
||||
Pipeline output is **generation-tagged**: `AudioOutput` chunks and `AssistantTextEvent`s carry a `cancel_generation` field stamped by the handler that produced them. The send loop's `_generation_is_discardable` drops an item if its generation is stale, or if `discarding` is set and the item is not from the current generation. Output from the *current* generation always passes through, so a fresh response is never swallowed by a lingering discard window (e.g. a superseded speculative turn whose TTS never emitted a `__RESPONSE_DONE__` sentinel).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant VAD
|
||||
participant SendLoop as _send_loop
|
||||
participant LLM
|
||||
participant TTS
|
||||
participant Client
|
||||
|
||||
Note over TTS,Client: Response active or pending (in_response / response_pending)
|
||||
User->>VAD: speaks
|
||||
VAD->>SendLoop: speech_started on text_output_queue
|
||||
SendLoop->>Client: input_audio_buffer.speech_started
|
||||
SendLoop->>Client: response.done (status=cancelled, reason=turn_detected)
|
||||
SendLoop->>SendLoop: cancel_scope.cancel() (gen++ & discarding=True)
|
||||
SendLoop->>SendLoop: flush output_queue + text_output_queue
|
||||
SendLoop->>SendLoop: response_playing.clear()
|
||||
LLM->>LLM: is_stale(gen) → True, aborts generation
|
||||
TTS->>TTS: is_stale(gen) → True, aborts generation
|
||||
TTS->>SendLoop: __RESPONSE_DONE__ (tagged with gen)
|
||||
SendLoop->>SendLoop: cancel_scope.response_done(gen) (discarding=False)
|
||||
Note over VAD,Client: Pipeline is now processing the new user utterance
|
||||
```
|
||||
|
||||
**Step by step:**
|
||||
|
||||
1. **VAD detects speech**: puts a `SpeechStartedEvent` on `text_output_queue`.
|
||||
2. **`_send_loop` processes text events first** (priority over audio): translates `speech_started` into protocol events. If an active response was in progress, `RealtimeService.dispatch_pipeline_event` emits `response.output_audio.done` + `response.done` with `status="cancelled"` and `reason="turn_detected"`.
|
||||
3. **Cancel + queue flush**: if a response is active (`in_response`) *or* pending (`response_pending` -- accepted `response.create`, no audio yet), and interrupts are enabled (see step 4), the send loop calls `cancel_scope.cancel()` (increments generation, enables discard), clears `response_pending`, drains `output_queue` (preserving `__RESPONSE_DONE__` sentinels) and `text_output_queue` (preserving user-side events: `speech_stopped`, partial/completed transcriptions, token usage), then clears `response_playing`.
|
||||
4. **Interrupt gating**: the cancel only fires if the `SpeechStartedEvent.interrupt_response` flag is set *and* the session config allows it (`turn_detection.interrupt_response`, read via `RuntimeConfig.interrupt_response_enabled`, default true). When disabled, user speech during a response is transcribed but the response keeps playing.
|
||||
5. **LLM/TTS cancellation**: handlers capture `gen = cancel_scope.generation` at the start of each response and check `cancel_scope.is_stale(gen)` on every streaming token, aborting early when stale.
|
||||
6. **Discard guard**: while `cancel_scope.discarding` is True, the send loop drops audio chunks and assistant text whose `cancel_generation` is not current (see `_generation_is_discardable` above). The guard clears when a `__RESPONSE_DONE__` with a matching generation arrives (via `cancel_scope.response_done(gen)`), or when an explicit `response.create` starts a new response (`cancel_scope.new_response()`).
|
||||
7. **Client-initiated cancel**: `response.cancel` calls `cancel_scope.cancel()` (only if a response was active), flushes both queues with the same preservation rules, triggers `finish_response(status="cancelled", reason="client_cancelled")`, re-enables `should_listen`, and clears `response_playing`.
|
||||
8. **Spurious cancel safety**: if no response is active, `cancel_scope.cancel()` is not called, preventing the discard guard from being set without a `__RESPONSE_DONE__` to clear it.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Local LLM with Transformers
|
||||
|
||||
```bash
|
||||
uv run speech-to-speech \
|
||||
--mode realtime \
|
||||
--stt parakeet-tdt \
|
||||
--llm_backend transformers \
|
||||
--tts kokoro \
|
||||
--model_name "Qwen/Qwen3-4B-Instruct-2507" \
|
||||
--llm_device mps \
|
||||
--llm_torch_dtype float16 \
|
||||
--enable_live_transcription
|
||||
```
|
||||
|
||||
### Local LLM with MLX-LM
|
||||
|
||||
```bash
|
||||
uv run speech-to-speech \
|
||||
--mode realtime \
|
||||
--stt parakeet-tdt \
|
||||
--llm_backend mlx-lm \
|
||||
--tts kokoro \
|
||||
--model_name "mlx-community/Qwen3-4B-Instruct-2507-bf16" \
|
||||
--llm_device mps \
|
||||
--llm_torch_dtype float16 \
|
||||
--enable_live_transcription
|
||||
```
|
||||
|
||||
### Remote LLM with OpenAI-compatible API
|
||||
|
||||
```bash
|
||||
uv run speech-to-speech \
|
||||
--mode realtime \
|
||||
--stt parakeet-tdt \
|
||||
--llm_backend responses-api \
|
||||
--tts kokoro \
|
||||
--model_name "openai/gpt-oss-20b:groq" \
|
||||
--responses_api_base_url "https://router.huggingface.co/v1" \
|
||||
--responses_api_api_key "$HF_TOKEN" \
|
||||
--responses_api_stream \
|
||||
--enable_live_transcription
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from speech_to_speech.pipeline.messages import TTSInput
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DelayFillerConfig:
|
||||
enabled: bool
|
||||
threshold_s: float
|
||||
short_phrases: list[str]
|
||||
full_phrases: list[str]
|
||||
|
||||
|
||||
def _split_env_list(name: str) -> list[str]:
|
||||
raw = os.environ.get(name, "").strip()
|
||||
return [part.strip() for part in raw.split("|") if part.strip()]
|
||||
|
||||
|
||||
def _env_flag(name: str, *, default: bool) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in _TRUE_VALUES
|
||||
|
||||
|
||||
def load_delay_filler_config() -> DelayFillerConfig:
|
||||
short_phrases = _split_env_list("TTS_DELAY_FILLERS_SHORT_ES")
|
||||
full_phrases = _split_env_list("TTS_DELAY_FILLERS_FULL_ES")
|
||||
|
||||
# Backward compatibility with the old single-list variable.
|
||||
if not short_phrases and not full_phrases:
|
||||
full_phrases = _split_env_list("TTS_DELAY_FILLERS_ES")
|
||||
|
||||
enabled = _env_flag("TTS_DELAY_FILLERS_ENABLED", default=bool(short_phrases or full_phrases))
|
||||
threshold_ms_raw = os.environ.get("TTS_DELAY_FILLERS_THRESHOLD_MS", "1200").strip() or "1200"
|
||||
try:
|
||||
threshold_ms = max(0, int(threshold_ms_raw))
|
||||
except ValueError:
|
||||
logger.warning("Invalid TTS_DELAY_FILLERS_THRESHOLD_MS=%r; using 1200", threshold_ms_raw)
|
||||
threshold_ms = 1200
|
||||
return DelayFillerConfig(
|
||||
enabled=enabled and bool(short_phrases or full_phrases),
|
||||
threshold_s=threshold_ms / 1000.0,
|
||||
short_phrases=short_phrases,
|
||||
full_phrases=full_phrases,
|
||||
)
|
||||
|
||||
|
||||
def _chunk_to_bytes(chunk: Any) -> bytes:
|
||||
audio = getattr(chunk, "audio", chunk)
|
||||
if isinstance(audio, np.ndarray) or hasattr(audio, "tobytes"):
|
||||
return audio.tobytes()
|
||||
if isinstance(audio, bytes):
|
||||
return audio
|
||||
raise TypeError(f"Unsupported delay filler audio chunk type: {type(audio)!r}")
|
||||
|
||||
|
||||
def pregenerate_delay_filler_clips(tts_handler: Any, phrases: list[str]) -> list[bytes]:
|
||||
clips: list[bytes] = []
|
||||
for phrase in phrases:
|
||||
try:
|
||||
clip = b"".join(
|
||||
_chunk_to_bytes(chunk)
|
||||
for chunk in tts_handler.process(TTSInput(text=phrase, language_code="es"))
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to pregenerate TTS delay filler for phrase %r", phrase)
|
||||
continue
|
||||
if not clip:
|
||||
logger.warning("Discarding empty TTS delay filler for phrase %r", phrase)
|
||||
continue
|
||||
clips.append(clip)
|
||||
return clips
|
||||
@@ -0,0 +1,11 @@
|
||||
from speech_to_speech.api.openai_realtime.handlers.audio import AudioHandler
|
||||
from speech_to_speech.api.openai_realtime.handlers.conversation import ConversationHandler
|
||||
from speech_to_speech.api.openai_realtime.handlers.response import ResponseHandler
|
||||
from speech_to_speech.api.openai_realtime.handlers.session import SessionHandler
|
||||
|
||||
__all__ = [
|
||||
"AudioHandler",
|
||||
"ConversationHandler",
|
||||
"ResponseHandler",
|
||||
"SessionHandler",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openai.types.realtime import (
|
||||
InputAudioBufferAppendEvent,
|
||||
InputAudioBufferSpeechStartedEvent,
|
||||
InputAudioBufferSpeechStoppedEvent,
|
||||
RealtimeErrorEvent,
|
||||
ResponseAudioDeltaEvent,
|
||||
ResponseCreatedEvent,
|
||||
)
|
||||
|
||||
from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler
|
||||
from speech_to_speech.api.openai_realtime.utils import resample
|
||||
from speech_to_speech.pipeline.events import SpeechStartedEvent, SpeechStoppedEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from speech_to_speech.api.openai_realtime.service import ServerEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PIPELINE_SAMPLE_RATE = 16000
|
||||
CHUNK_SAMPLES = 512
|
||||
BYTES_PER_SAMPLE = 2
|
||||
CHUNK_SIZE_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE
|
||||
|
||||
|
||||
class AudioHandler(RealtimeBaseHandler):
|
||||
"""Owns inbound audio decoding/chunking and outbound audio encoding."""
|
||||
|
||||
def _start_input_item(self, conn_id: str, *, preserve_active_response: bool = False) -> str:
|
||||
response = self._service.response
|
||||
st = self._state(conn_id)
|
||||
if not preserve_active_response:
|
||||
item_id = response._start_item(conn_id)
|
||||
else:
|
||||
response_item_id = st.current_item_id
|
||||
response_content_index = st.content_index
|
||||
item_id = response._start_item(conn_id)
|
||||
st.current_item_id = response_item_id
|
||||
st.content_index = response_content_index
|
||||
st.input_content_index = 0
|
||||
return item_id
|
||||
|
||||
def handle_audio_append(self, conn_id: str, event: InputAudioBufferAppendEvent) -> list[bytes]:
|
||||
"""Decode base64 audio, resample to pipeline rate, and split into 512-sample PCM16 chunks for the VAD."""
|
||||
try:
|
||||
pcm_bytes = base64.b64decode(event.audio)
|
||||
except Exception as e:
|
||||
logger.error(f"Base64 decode error: {e}")
|
||||
return []
|
||||
|
||||
st = self._state(conn_id)
|
||||
|
||||
audio_cfg = st.runtime_config.session.audio
|
||||
if audio_cfg is not None and audio_cfg.input is not None:
|
||||
client_in_rate = getattr(audio_cfg.input.format, "rate", None) or PIPELINE_SAMPLE_RATE
|
||||
else:
|
||||
client_in_rate = PIPELINE_SAMPLE_RATE
|
||||
pcm_bytes = resample(pcm_bytes, client_in_rate, PIPELINE_SAMPLE_RATE)
|
||||
|
||||
pcm_bytes = st.audio_remainder + pcm_bytes
|
||||
|
||||
chunks = []
|
||||
for i in range(0, len(pcm_bytes), CHUNK_SIZE_BYTES):
|
||||
chunk = pcm_bytes[i : i + CHUNK_SIZE_BYTES]
|
||||
if len(chunk) == CHUNK_SIZE_BYTES:
|
||||
chunks.append(chunk)
|
||||
else:
|
||||
st.audio_remainder = chunk
|
||||
break
|
||||
else:
|
||||
st.audio_remainder = b""
|
||||
|
||||
if chunks:
|
||||
st.audio_buffer_has_data = True
|
||||
return chunks
|
||||
|
||||
def handle_audio_commit(self, conn_id: str) -> RealtimeErrorEvent | None:
|
||||
"""Commit the audio buffer. Returns an error if no audio was appended."""
|
||||
st = self._state(conn_id)
|
||||
if not st.audio_buffer_has_data:
|
||||
return self.make_error(
|
||||
message="Input audio buffer is empty, nothing to commit.",
|
||||
_type="input_audio_buffer_commit_empty",
|
||||
)
|
||||
st.audio_buffer_has_data = False
|
||||
logger.debug("Audio buffer committed")
|
||||
return None
|
||||
|
||||
# ── Pipeline event handlers ────────────────────
|
||||
|
||||
def on_speech_started(self, conn_id: str, event: SpeechStartedEvent) -> list[ServerEvent]:
|
||||
"""Handle VAD speech_started: cancel active response if interrupts enabled, start new input item."""
|
||||
response = self._service.response
|
||||
events: list[ServerEvent] = []
|
||||
st = self._state(conn_id)
|
||||
if st.in_response and event.interrupt_response and st.runtime_config.interrupt_response_enabled:
|
||||
events.extend(response.finish_response(conn_id, status="cancelled", reason="turn_detected"))
|
||||
is_reopen = bool(event.reopened and event.turn_id is not None and event.turn_id == st.speculative_turn_id)
|
||||
preserve_active_response = st.in_response
|
||||
if is_reopen:
|
||||
input_item_id = st.speculative_input_item_id
|
||||
if input_item_id is None:
|
||||
input_item_id = self._start_input_item(
|
||||
conn_id,
|
||||
preserve_active_response=preserve_active_response,
|
||||
)
|
||||
st.speculative_input_item_id = input_item_id
|
||||
elif not preserve_active_response:
|
||||
st.current_item_id = input_item_id
|
||||
st.content_index = 0
|
||||
st.input_audio_duration_s = 0.0
|
||||
st.input_content_index = 0
|
||||
else:
|
||||
input_item_id = self._start_input_item(
|
||||
conn_id,
|
||||
preserve_active_response=preserve_active_response,
|
||||
)
|
||||
st.speculative_input_item_id = input_item_id
|
||||
st.response_usage.turns += 1
|
||||
st.speculative_turn_id = event.turn_id
|
||||
st.speculative_turn_revision = event.turn_revision
|
||||
st.last_item_id = input_item_id
|
||||
events.append(
|
||||
InputAudioBufferSpeechStartedEvent(
|
||||
type="input_audio_buffer.speech_started",
|
||||
event_id=self._next_event_id(),
|
||||
audio_start_ms=event.audio_start_ms,
|
||||
item_id=input_item_id,
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
def on_speech_stopped(self, conn_id: str, event: SpeechStoppedEvent) -> list[ServerEvent]:
|
||||
"""Handle VAD speech_stopped: record duration and emit stopped event."""
|
||||
if event.duration_s:
|
||||
self._state(conn_id).input_audio_duration_s = event.duration_s
|
||||
return [
|
||||
InputAudioBufferSpeechStoppedEvent(
|
||||
type="input_audio_buffer.speech_stopped",
|
||||
event_id=self._next_event_id(),
|
||||
audio_end_ms=event.audio_end_ms,
|
||||
item_id=self._input_item_id(conn_id),
|
||||
)
|
||||
]
|
||||
|
||||
# ── Outbound audio encoding ──────────────────
|
||||
|
||||
def encode_audio_chunk(self, conn_id: str, audio: bytes) -> list[ServerEvent]:
|
||||
"""Encode a raw PCM audio chunk, emitting ResponseCreated on the first chunk.
|
||||
|
||||
When ``handle_response_create`` already allocated the response,
|
||||
``current_response_id`` is set and no duplicate event is emitted.
|
||||
For the implicit-response path (VAD -> STT -> LLM -> TTS, no
|
||||
``response.create``), ``current_response_id`` is still ``None``
|
||||
and the event is emitted here on the first audio chunk.
|
||||
"""
|
||||
response = self._service.response
|
||||
st = self._state(conn_id)
|
||||
|
||||
events: list[ServerEvent] = []
|
||||
need_created = st.current_response_id is None
|
||||
resp_id, item_id = response._ensure_response(conn_id)
|
||||
if need_created:
|
||||
events.append(
|
||||
ResponseCreatedEvent(
|
||||
type="response.created",
|
||||
event_id=self._next_event_id(),
|
||||
response=response._build_response(conn_id, "in_progress"),
|
||||
)
|
||||
)
|
||||
rp = st.current_response_params
|
||||
client_out_rate = None
|
||||
if rp and rp.audio and rp.audio.output and rp.audio.output.format:
|
||||
client_out_rate = getattr(rp.audio.output.format, "rate", None)
|
||||
if client_out_rate is None:
|
||||
audio_cfg = st.runtime_config.session.audio
|
||||
if audio_cfg is not None and audio_cfg.output is not None:
|
||||
client_out_rate = getattr(audio_cfg.output.format, "rate", None) or PIPELINE_SAMPLE_RATE
|
||||
else:
|
||||
client_out_rate = PIPELINE_SAMPLE_RATE
|
||||
audio = resample(audio, PIPELINE_SAMPLE_RATE, client_out_rate)
|
||||
b64 = base64.b64encode(audio).decode("ascii")
|
||||
events.append(
|
||||
ResponseAudioDeltaEvent(
|
||||
type="response.output_audio.delta",
|
||||
event_id=self._next_event_id(),
|
||||
content_index=response._next_content_index(conn_id),
|
||||
delta=b64,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
response_id=resp_id,
|
||||
)
|
||||
)
|
||||
return events
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from queue import Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openai.types.realtime import RealtimeErrorEvent
|
||||
|
||||
from speech_to_speech.utils.utils import _generate_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from speech_to_speech.api.openai_realtime.service import ConnState, RealtimeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RealtimeBaseHandler:
|
||||
"""Shared base for domain handlers.
|
||||
|
||||
Provides conn_id-keyed accessors for per-connection state, config,
|
||||
and queues. Each connection owns its own ``RuntimeConfig`` via
|
||||
``ConnState``.
|
||||
"""
|
||||
|
||||
def __init__(self, service: RealtimeService) -> None:
|
||||
self._service = service
|
||||
|
||||
# ── conn_id-keyed accessors ──
|
||||
|
||||
def _queue(self, conn_id: str) -> Queue | None:
|
||||
return self._service.text_prompt_queue
|
||||
|
||||
def _should_listen(self, conn_id: str) -> ThreadingEvent | None:
|
||||
return self._service.should_listen
|
||||
|
||||
# ── shared helpers ────────────────────────────────
|
||||
|
||||
def _state(self, conn_id: str) -> ConnState:
|
||||
return self._service._state(conn_id)
|
||||
|
||||
def _input_item_id(self, conn_id: str) -> str:
|
||||
st = self._state(conn_id)
|
||||
return st.speculative_input_item_id or self._service.response._current_item_id(conn_id)
|
||||
|
||||
def _next_input_content_index(self, conn_id: str) -> int:
|
||||
st = self._state(conn_id)
|
||||
idx = st.input_content_index
|
||||
st.input_content_index += 1
|
||||
return idx
|
||||
|
||||
@staticmethod
|
||||
def _next_event_id() -> str:
|
||||
return _generate_id("event")
|
||||
|
||||
def make_error(self, message: str, _type: str) -> RealtimeErrorEvent:
|
||||
return self._service.make_error(message, _type)
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openai.types.realtime import (
|
||||
ConversationItem,
|
||||
ConversationItemCreatedEvent,
|
||||
ConversationItemCreateEvent,
|
||||
ConversationItemInputAudioTranscriptionCompletedEvent,
|
||||
ConversationItemInputAudioTranscriptionDeltaEvent,
|
||||
)
|
||||
from openai.types.realtime.conversation_item_input_audio_transcription_completed_event import (
|
||||
UsageTranscriptTextUsageDuration,
|
||||
)
|
||||
|
||||
from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler
|
||||
from speech_to_speech.LLM.chat import ChatItemError, add_supported_item
|
||||
from speech_to_speech.pipeline.events import PartialTranscriptionEvent, TranscriptionCompletedEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from speech_to_speech.api.openai_realtime.service import ServerEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationHandler(RealtimeBaseHandler):
|
||||
"""Owns conversation item injection and pipeline-to-protocol translation."""
|
||||
|
||||
def handle_conversation_item_create(
|
||||
self,
|
||||
conn_id: str,
|
||||
event: ConversationItemCreateEvent,
|
||||
) -> list[ServerEvent]:
|
||||
"""Inject a text message or function-call output into the LLM context.
|
||||
|
||||
Items are added to the LLM chat context but do NOT trigger response
|
||||
generation on their own. A subsequent ``response.create`` event is
|
||||
required to trigger the model.
|
||||
|
||||
While a response is generating, the item is *deferred*: applying it now
|
||||
would race the LLM handler's end-of-turn chat write-back, which runs on
|
||||
the pipeline thread (e.g. a ``function_call_output`` arriving before its
|
||||
``function_call`` is recorded, or an image stripped before the next
|
||||
turn reads it). Deferred items are flushed, in order, once the response
|
||||
completes — see :meth:`flush_deferred_items`.
|
||||
"""
|
||||
st = self._state(conn_id)
|
||||
if st.in_response:
|
||||
st.deferred_items.append(event.item)
|
||||
logger.debug("Deferred conversation item until the active response completes")
|
||||
return []
|
||||
return self._apply_item(conn_id, event.item)
|
||||
|
||||
def _apply_item(self, conn_id: str, item: ConversationItem) -> list[ServerEvent]:
|
||||
"""Add one item to the chat and build its ``conversation.item.created``."""
|
||||
try:
|
||||
self._append_item(conn_id, item)
|
||||
except ChatItemError as exc:
|
||||
return [self.make_error(str(exc), "invalid_conversation_item")]
|
||||
|
||||
if not item:
|
||||
return []
|
||||
st = self._state(conn_id)
|
||||
event = ConversationItemCreatedEvent(
|
||||
type="conversation.item.created",
|
||||
event_id=self._next_event_id(),
|
||||
previous_item_id=st.last_item_id,
|
||||
item=item,
|
||||
)
|
||||
st.last_item_id = item.id
|
||||
return [event]
|
||||
|
||||
def flush_deferred_items(self, conn_id: str) -> list[ServerEvent]:
|
||||
"""Apply items buffered during a response, in arrival order.
|
||||
|
||||
Called at response completion (after the generation's own write-back),
|
||||
so a ``function_call_output`` pairs with its now-recorded ``function_call``
|
||||
and an image survives the just-finished response's ``strip_images``.
|
||||
"""
|
||||
st = self._state(conn_id)
|
||||
if not st.deferred_items:
|
||||
return []
|
||||
items = st.deferred_items
|
||||
st.deferred_items = []
|
||||
events: list[ServerEvent] = []
|
||||
for item in items:
|
||||
events.extend(self._apply_item(conn_id, item))
|
||||
return events
|
||||
|
||||
def _append_item(self, conn_id: str, item: ConversationItem) -> None:
|
||||
"""Narrow ``ConversationItem`` to ``SupportedItem`` and delegate to ``Chat.add_item``.
|
||||
|
||||
Raises :class:`ChatItemError` on validation failure or unsupported type.
|
||||
"""
|
||||
add_supported_item(self._state(conn_id).runtime_config.chat, item)
|
||||
|
||||
# ── Pipeline event handlers ────────────────────
|
||||
|
||||
def on_partial_transcription(self, conn_id: str, event: PartialTranscriptionEvent) -> list[ServerEvent]:
|
||||
"""Handle partial_transcription: emit transcription delta event."""
|
||||
return [
|
||||
ConversationItemInputAudioTranscriptionDeltaEvent(
|
||||
type="conversation.item.input_audio_transcription.delta",
|
||||
event_id=self._next_event_id(),
|
||||
content_index=self._next_input_content_index(conn_id),
|
||||
item_id=self._input_item_id(conn_id),
|
||||
delta=event.delta,
|
||||
)
|
||||
]
|
||||
|
||||
def on_transcription_completed(self, conn_id: str, event: TranscriptionCompletedEvent) -> list[ServerEvent]:
|
||||
"""Handle transcription_completed: accumulate duration and emit completed event."""
|
||||
st = self._state(conn_id)
|
||||
st.response_usage.audio_duration_s += st.input_audio_duration_s
|
||||
return [
|
||||
ConversationItemInputAudioTranscriptionCompletedEvent(
|
||||
type="conversation.item.input_audio_transcription.completed",
|
||||
event_id=self._next_event_id(),
|
||||
content_index=0,
|
||||
item_id=self._input_item_id(conn_id),
|
||||
transcript=event.transcript,
|
||||
usage=UsageTranscriptTextUsageDuration(
|
||||
seconds=st.input_audio_duration_s,
|
||||
type="duration",
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from time import perf_counter
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from openai.types.realtime import (
|
||||
RealtimeResponse,
|
||||
ResponseAudioDoneEvent,
|
||||
ResponseAudioTranscriptDoneEvent,
|
||||
ResponseCreatedEvent,
|
||||
ResponseCreateEvent,
|
||||
ResponseDoneEvent,
|
||||
ResponseFunctionCallArgumentsDoneEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ResponseTextDoneEvent,
|
||||
)
|
||||
from openai.types.realtime.realtime_response import Audio, AudioOutput
|
||||
from openai.types.realtime.realtime_response_status import RealtimeResponseStatus
|
||||
from openai.types.realtime.realtime_response_usage import RealtimeResponseUsage
|
||||
|
||||
from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler
|
||||
from speech_to_speech.LLM.chat import ChatItemError
|
||||
from speech_to_speech.pipeline.events import AssistantTextEvent
|
||||
from speech_to_speech.pipeline.messages import GenerateResponseRequest
|
||||
from speech_to_speech.utils.utils import _generate_id, is_out_of_band, response_wants_audio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from speech_to_speech.api.openai_realtime.service import ServerEvent, _ResponseStatus, _StatusReason
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResponseHandler(RealtimeBaseHandler):
|
||||
"""Owns the response lifecycle: create, cancel, finish, and ID management."""
|
||||
|
||||
# ── ID / state helpers ────────────────────────
|
||||
|
||||
def _ensure_response(self, conn_id: str) -> tuple[str, str]:
|
||||
"""Ensure a response and output item exist, creating them if needed."""
|
||||
st = self._state(conn_id)
|
||||
if st.current_response_id is None:
|
||||
st.current_response_id = _generate_id("resp")
|
||||
self._start_item(conn_id)
|
||||
st.in_response = True
|
||||
st.response_pending = False
|
||||
return st.current_response_id, self._current_item_id(conn_id)
|
||||
|
||||
def _end_response(self, conn_id: str, status: _ResponseStatus = "completed") -> None:
|
||||
st = self._state(conn_id)
|
||||
if status == "cancelled":
|
||||
st.response_usage.responses_cancelled += 1
|
||||
else:
|
||||
st.response_usage.responses_completed += 1
|
||||
self._service.total_usage += st.response_usage
|
||||
logger.info(
|
||||
"Response done (status=%s) — this response: input_tokens=%d, output_tokens=%d, audio=%.2fs"
|
||||
" | cumulative: input_tokens=%d, output_tokens=%d, audio=%.2fs",
|
||||
status,
|
||||
st.response_usage.input_tokens,
|
||||
st.response_usage.output_tokens,
|
||||
st.response_usage.audio_duration_s,
|
||||
self._service.total_usage.input_tokens,
|
||||
self._service.total_usage.output_tokens,
|
||||
self._service.total_usage.audio_duration_s,
|
||||
)
|
||||
st.response_usage.reset()
|
||||
st.current_response_id = None
|
||||
st.current_item_id = None
|
||||
st.content_index = 0
|
||||
st.in_response = False
|
||||
st.response_pending = False
|
||||
st.current_response_params = None
|
||||
st.pending_output_text_parts = []
|
||||
st.response_audio_wait_started_at_s = None
|
||||
st.delay_filler_sent = False
|
||||
|
||||
def _start_item(self, conn_id: str) -> str:
|
||||
"""Generate a new item ID, reset content index, and store it."""
|
||||
st = self._state(conn_id)
|
||||
item_id = _generate_id("item")
|
||||
st.current_item_id = item_id
|
||||
st.content_index = 0
|
||||
st.input_audio_duration_s = 0.0
|
||||
return item_id
|
||||
|
||||
def _current_item_id(self, conn_id: str) -> str:
|
||||
return self._state(conn_id).current_item_id or self._start_item(conn_id)
|
||||
|
||||
def _next_content_index(self, conn_id: str) -> int:
|
||||
"""Return the current content index and advance it."""
|
||||
st = self._state(conn_id)
|
||||
idx = st.content_index
|
||||
st.content_index += 1
|
||||
return idx
|
||||
|
||||
def _build_response(
|
||||
self,
|
||||
conn_id: str,
|
||||
status: _ResponseStatus,
|
||||
reason: _StatusReason | None = None,
|
||||
) -> RealtimeResponse:
|
||||
"""Build a fully-populated RealtimeResponse from the current connection state."""
|
||||
st = self._state(conn_id)
|
||||
status_details = None
|
||||
if reason or status in ("completed", "cancelled", "incomplete", "failed"):
|
||||
status_details = RealtimeResponseStatus(type=status, reason=reason) # type: ignore[arg-type]
|
||||
|
||||
rp = st.current_response_params
|
||||
metadata = rp.metadata if rp and rp.metadata else None
|
||||
|
||||
voice: Optional[str] = None
|
||||
if rp and rp.audio and rp.audio.output and rp.audio.output.voice:
|
||||
voice = str(rp.audio.output.voice)
|
||||
if not voice:
|
||||
audio_cfg = st.runtime_config.session.audio
|
||||
audio_output = audio_cfg.output if audio_cfg is not None else None
|
||||
voice = str(audio_output.voice) if audio_output is not None and audio_output.voice else None
|
||||
|
||||
# Out-of-band responses are not threaded into any conversation: report a null id.
|
||||
conversation_id = None if is_out_of_band(rp) else st.conversation_id
|
||||
|
||||
return RealtimeResponse(
|
||||
id=st.current_response_id,
|
||||
object="realtime.response",
|
||||
status=status,
|
||||
status_details=status_details,
|
||||
audio=Audio(output=AudioOutput(voice=str(voice) if voice else None)), # type: ignore[arg-type]
|
||||
conversation_id=conversation_id,
|
||||
metadata=metadata,
|
||||
usage=RealtimeResponseUsage(
|
||||
input_tokens=st.response_usage.input_tokens,
|
||||
output_tokens=st.response_usage.output_tokens,
|
||||
total_tokens=st.response_usage.input_tokens + st.response_usage.output_tokens,
|
||||
),
|
||||
)
|
||||
|
||||
# ── Public handlers ───────────────────────────
|
||||
|
||||
def handle_response_create(self, conn_id: str, event: ResponseCreateEvent) -> ServerEvent | None:
|
||||
"""Trigger a response.
|
||||
|
||||
Returns a ``ResponseCreatedEvent`` on success, a ``RealtimeErrorEvent``
|
||||
on failure, or ``None`` if there is no text_prompt_queue.
|
||||
"""
|
||||
st = self._state(conn_id)
|
||||
if event.response:
|
||||
if event.response.tool_choice and not isinstance(event.response.tool_choice, str):
|
||||
return self.make_error(
|
||||
message="Only string tool_choice values are supported for now (auto, required, none).",
|
||||
_type="tool_choice_not_supported",
|
||||
)
|
||||
if st.in_response:
|
||||
return self.make_error(
|
||||
message="Cannot create response while another response is in progress.",
|
||||
_type="conversation_already_has_active_response",
|
||||
)
|
||||
|
||||
out_of_band = is_out_of_band(event.response)
|
||||
|
||||
# In-band: response.input items are added to the default conversation here so
|
||||
# they appear in history. Out-of-band: leave the default conversation untouched —
|
||||
# the input rides along on the request and seeds a throwaway chat in the LM.
|
||||
if not out_of_band and event.response and event.response.input:
|
||||
for input_item in event.response.input:
|
||||
try:
|
||||
self._service.conversation._append_item(conn_id, input_item)
|
||||
except ChatItemError as exc:
|
||||
return self.make_error(message=str(exc), _type="invalid_input_item")
|
||||
|
||||
st.in_response = True
|
||||
st.response_pending = False
|
||||
|
||||
st.current_response_params = event.response
|
||||
st.current_response_id = _generate_id("resp")
|
||||
self._start_item(conn_id)
|
||||
if response_wants_audio(event.response):
|
||||
st.response_audio_wait_started_at_s = perf_counter()
|
||||
st.delay_filler_sent = False
|
||||
else:
|
||||
st.response_audio_wait_started_at_s = None
|
||||
st.delay_filler_sent = False
|
||||
|
||||
cfg = st.runtime_config
|
||||
queue = self._queue(conn_id)
|
||||
if queue:
|
||||
# Out-of-band responses carry no turn identity: a null turn_id makes every
|
||||
# speculative-turn staleness gate treat them as always-latest, so a new user
|
||||
# turn mid-generation can never silently drop their output.
|
||||
queue.put(
|
||||
GenerateResponseRequest(
|
||||
runtime_config=cfg,
|
||||
response=event.response,
|
||||
turn_id=None if out_of_band else st.speculative_user_turn_id,
|
||||
turn_revision=None if out_of_band else st.speculative_user_turn_revision,
|
||||
speech_stopped_at_s=None if out_of_band else st.speculative_user_speech_stopped_at_s,
|
||||
)
|
||||
)
|
||||
logger.debug("response.create received, LLM generation triggered")
|
||||
return ResponseCreatedEvent(
|
||||
type="response.created",
|
||||
event_id=self._next_event_id(),
|
||||
response=self._build_response(conn_id, "in_progress"),
|
||||
)
|
||||
|
||||
def handle_response_cancel(self, conn_id: str) -> list[ServerEvent]:
|
||||
"""Cancel the in-progress response and re-enable listening."""
|
||||
events = self.finish_response(conn_id, status="cancelled", reason="client_cancelled")
|
||||
should_listen = self._should_listen(conn_id)
|
||||
if should_listen:
|
||||
should_listen.set()
|
||||
logger.info("Response cancelled, listening re-enabled")
|
||||
return events
|
||||
|
||||
def finish_response(
|
||||
self,
|
||||
conn_id: str,
|
||||
status: _ResponseStatus = "completed",
|
||||
reason: _StatusReason | None = None,
|
||||
) -> list[ServerEvent]:
|
||||
"""Close the current response (audio/text done + response done).
|
||||
|
||||
Audio responses emit ``response.output_audio.done`` for any terminal
|
||||
status. Text-only responses emit a single ``response.output_text.done``
|
||||
carrying the full streamed text, but only on ``status="completed"`` —
|
||||
a cancelled or failed text response sends no audio, so it just closes
|
||||
with ``response.done``.
|
||||
"""
|
||||
st = self._state(conn_id)
|
||||
events: list[ServerEvent] = []
|
||||
if st.in_response:
|
||||
resp_id, item_id = self._ensure_response(conn_id)
|
||||
if response_wants_audio(st.current_response_params):
|
||||
events.append(
|
||||
ResponseAudioDoneEvent(
|
||||
type="response.output_audio.done",
|
||||
event_id=self._next_event_id(),
|
||||
content_index=0,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
response_id=resp_id,
|
||||
)
|
||||
)
|
||||
elif status == "completed" and st.pending_output_text_parts:
|
||||
events.append(
|
||||
ResponseTextDoneEvent(
|
||||
type="response.output_text.done",
|
||||
event_id=self._next_event_id(),
|
||||
content_index=0,
|
||||
item_id=item_id,
|
||||
output_index=0,
|
||||
response_id=resp_id,
|
||||
text="".join(st.pending_output_text_parts),
|
||||
)
|
||||
)
|
||||
events.append(
|
||||
ResponseDoneEvent(
|
||||
type="response.done",
|
||||
event_id=self._next_event_id(),
|
||||
response=self._build_response(conn_id, status, reason),
|
||||
)
|
||||
)
|
||||
self._end_response(conn_id, status)
|
||||
# Apply any client items that arrived mid-generation now that in_response
|
||||
# is cleared and the generation's own write-back has landed. Done outside
|
||||
# the in_response guard so a stray terminal call still drains the buffer.
|
||||
events.extend(self._service.conversation.flush_deferred_items(conn_id))
|
||||
return events
|
||||
|
||||
# ── Pipeline event handlers ───────────────────
|
||||
|
||||
def on_assistant_text(
|
||||
self,
|
||||
conn_id: str,
|
||||
event: AssistantTextEvent,
|
||||
*,
|
||||
wait_for_pending_reopen: bool = True,
|
||||
) -> list[ServerEvent] | None:
|
||||
"""Handle assistant_text: emit transcript and/or tool-call events."""
|
||||
if self._service.speculative_turns:
|
||||
commit_result: bool | None
|
||||
if wait_for_pending_reopen:
|
||||
commit_result = self._service.speculative_turns.commit_if_latest_after_reopen_grace(
|
||||
event.turn_id,
|
||||
event.turn_revision,
|
||||
)
|
||||
else:
|
||||
commit_result = self._service.speculative_turns.try_commit_if_latest_after_reopen_grace(
|
||||
event.turn_id,
|
||||
event.turn_revision,
|
||||
)
|
||||
if commit_result is None:
|
||||
return None
|
||||
if not commit_result:
|
||||
logger.debug("Dropping stale assistant text for turn=%s rev=%s", event.turn_id, event.turn_revision)
|
||||
return []
|
||||
st = self._state(conn_id)
|
||||
events: list[ServerEvent] = []
|
||||
resp_id, item_id = self._ensure_response(conn_id)
|
||||
st.last_item_id = item_id
|
||||
output_idx = 0
|
||||
if event.text:
|
||||
if response_wants_audio(st.current_response_params):
|
||||
events.append(
|
||||
ResponseAudioTranscriptDoneEvent(
|
||||
type="response.output_audio_transcript.done",
|
||||
event_id=self._next_event_id(),
|
||||
content_index=0,
|
||||
item_id=item_id,
|
||||
output_index=output_idx,
|
||||
response_id=resp_id,
|
||||
transcript=event.text,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Stream the delta now; the matching response.output_text.done is
|
||||
# emitted once at close in finish_response, carrying the per-chunk
|
||||
# parts collected here, space-joined ("" when there were none).
|
||||
st.pending_output_text_parts.append(event.text)
|
||||
events.append(
|
||||
ResponseTextDeltaEvent(
|
||||
type="response.output_text.delta",
|
||||
event_id=self._next_event_id(),
|
||||
content_index=0,
|
||||
item_id=item_id,
|
||||
output_index=output_idx,
|
||||
response_id=resp_id,
|
||||
delta=event.text,
|
||||
)
|
||||
)
|
||||
output_idx += 1
|
||||
if event.tools:
|
||||
st.response_usage.tool_calls += len(event.tools)
|
||||
for tool in event.tools:
|
||||
events.append(
|
||||
ResponseFunctionCallArgumentsDoneEvent(
|
||||
type="response.function_call_arguments.done",
|
||||
event_id=self._next_event_id(),
|
||||
call_id=tool.call_id,
|
||||
name=tool.name,
|
||||
arguments=tool.arguments,
|
||||
item_id=item_id,
|
||||
output_index=output_idx,
|
||||
response_id=resp_id,
|
||||
)
|
||||
)
|
||||
output_idx += 1
|
||||
return events
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from openai.types.realtime import (
|
||||
RealtimeErrorEvent,
|
||||
SessionCreatedEvent,
|
||||
SessionUpdateEvent,
|
||||
)
|
||||
from openai.types.realtime.realtime_transcription_session_create_request import (
|
||||
RealtimeTranscriptionSessionCreateRequest,
|
||||
)
|
||||
|
||||
from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionHandler(RealtimeBaseHandler):
|
||||
"""Owns session lifecycle: config updates and session.created events."""
|
||||
|
||||
def handle_session_update(self, conn_id: str, event: SessionUpdateEvent) -> Optional[RealtimeErrorEvent]:
|
||||
"""Apply session config changes.
|
||||
|
||||
Only ``RealtimeSessionCreateRequest`` sessions are accepted;
|
||||
``RealtimeTranscriptionSessionCreateRequest`` sessions not yet supported.
|
||||
Incoming fields are deep-merged into the existing session so that
|
||||
partial updates preserve previously-set values.
|
||||
"""
|
||||
s = event.session
|
||||
|
||||
# Forza il bypass della voce inviata dal browser
|
||||
if hasattr(s, "voice") and s.voice is not None:
|
||||
s.voice = None
|
||||
elif isinstance(s, dict) and "voice" in s:
|
||||
s["voice"] = None
|
||||
|
||||
if s is None:
|
||||
return None
|
||||
|
||||
if isinstance(s, RealtimeTranscriptionSessionCreateRequest):
|
||||
return self.make_error(
|
||||
message="Only 'realtime' session type is supported; transcription sessions are not.",
|
||||
_type="invalid_session_type",
|
||||
)
|
||||
|
||||
model = getattr(s, "model", None)
|
||||
if model is not None:
|
||||
logger.info(f"Session model set to: {model}")
|
||||
|
||||
cfg = self._state(conn_id).runtime_config
|
||||
current = cfg.session
|
||||
if current is None:
|
||||
cfg.session = s
|
||||
else:
|
||||
cfg.apply_session_update(s)
|
||||
logger.info("Session configuration updated")
|
||||
return None
|
||||
|
||||
def build_session_created(self, conn_id: str) -> SessionCreatedEvent:
|
||||
"""Build a SessionCreatedEvent populated with the current config."""
|
||||
cfg = self._state(conn_id).runtime_config
|
||||
session = cfg.session
|
||||
return SessionCreatedEvent(
|
||||
type="session.created",
|
||||
event_id=self._next_event_id(),
|
||||
session=session,
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
from threading import Event
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from speech_to_speech.api.openai_realtime.service import RealtimeService
|
||||
from speech_to_speech.pipeline.cancel_scope import CancelScope
|
||||
|
||||
|
||||
class SessionState(BaseModel):
|
||||
"""Per-websocket ephemeral state.
|
||||
|
||||
Created when the route handler claims a PipelineUnit on `ws.accept()`; dropped
|
||||
when the websocket disconnects. Holding the websocket reference, the service
|
||||
session id, and any send-loop scratch (pending_output_item) here ensures these
|
||||
fields share one lifecycle — a stale value can't outlive its session.
|
||||
|
||||
`drained` is set by the send loop when SESSION_END travels through the handler
|
||||
chain back to the output queue; the route handler awaits it before clearing
|
||||
`PipelineUnit.session`, so a new client cannot claim the unit until in-flight
|
||||
work from this session has fully reset.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
websocket: Any
|
||||
session_id: str = ""
|
||||
pending_output_item: Any = None
|
||||
drained: asyncio.Event = Field(default_factory=asyncio.Event)
|
||||
# Wall-clock time when the ws disconnected (route handler released its claim).
|
||||
# `None` while the client is still active. Used by /v1/pool to surface stuck
|
||||
# units (handlers haven't finished propagating SESSION_END).
|
||||
released_at: Optional[float] = None
|
||||
# Wall-clock time when the drain wait gave up and quarantined the unit
|
||||
# (SESSION_END_QUARANTINE_TIMEOUT_S elapsed). The unit stays unclaimable —
|
||||
# its handlers may still emit this session's output — until SESSION_END
|
||||
# actually drains. Reported as "stuck" by /v1/pool.
|
||||
quarantined_at: Optional[float] = None
|
||||
|
||||
|
||||
class PipelineUnit(BaseModel):
|
||||
"""One isolated realtime pipeline.
|
||||
|
||||
Each unit owns its queues, events, RealtimeService, and the chain of handler
|
||||
instances (VAD, STT, transcription notifier, LM, LM output processor, TTS).
|
||||
Lives inside the pool managed by RealtimeServer; the websocket route handler
|
||||
claims a free unit (`session is None`) on `accept` and releases it on disconnect
|
||||
by setting `session` back to None.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
index: int
|
||||
service: RealtimeService
|
||||
cancel_scope: CancelScope
|
||||
should_listen: Event
|
||||
response_playing: Event
|
||||
input_queue: Queue
|
||||
output_queue: Queue
|
||||
text_output_queue: Queue
|
||||
text_prompt_queue: Queue
|
||||
handlers: list[Any]
|
||||
delay_filler_threshold_s: float = 0.0
|
||||
delay_filler_short_clips: list[bytes] = Field(default_factory=list)
|
||||
delay_filler_full_clips: list[bytes] = Field(default_factory=list)
|
||||
http_tts_reserved: bool = False
|
||||
|
||||
session: Optional[SessionState] = None
|
||||
@@ -0,0 +1,81 @@
|
||||
from openai.types.realtime import RealtimeSessionCreateRequest
|
||||
from openai.types.realtime.realtime_audio_config import RealtimeAudioConfig
|
||||
from openai.types.realtime.realtime_audio_config_input import RealtimeAudioConfigInput
|
||||
from openai.types.realtime.realtime_audio_config_output import RealtimeAudioConfigOutput
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from speech_to_speech.LLM.chat import Chat
|
||||
|
||||
|
||||
def _apply_update(current: BaseModel, update: BaseModel) -> None:
|
||||
"""Apply explicitly-set fields from *update* onto *current* in-place,
|
||||
recursing into nested BaseModel children so partial nested updates
|
||||
don't overwrite unset fields.
|
||||
|
||||
Only fields present in update.model_fields_set (i.e. actually
|
||||
sent by the client) are considered.
|
||||
"""
|
||||
for field_name in update.model_fields_set:
|
||||
new_val = getattr(update, field_name)
|
||||
old_val = getattr(current, field_name, None)
|
||||
if isinstance(new_val, BaseModel) and isinstance(old_val, BaseModel):
|
||||
_apply_update(old_val, new_val)
|
||||
else:
|
||||
setattr(current, field_name, new_val)
|
||||
|
||||
|
||||
class RuntimeConfig(BaseModel):
|
||||
"""
|
||||
Shared mutable configuration written by the RealtimeService on
|
||||
session.update and read by pipeline handlers (VAD, LLM, TTS) during
|
||||
processing. Python's GIL makes simple attribute reads/writes atomic,
|
||||
so no explicit locking is needed for primitive values.
|
||||
|
||||
The canonical state lives in 'session' (a full
|
||||
'RealtimeSessionCreateRequest').
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True)
|
||||
|
||||
chat: Chat = Field(default_factory=lambda: Chat(10))
|
||||
session: RealtimeSessionCreateRequest = Field(
|
||||
default_factory=lambda: RealtimeSessionCreateRequest(type="realtime"),
|
||||
validate_default=True,
|
||||
)
|
||||
|
||||
@field_validator("session", mode="after")
|
||||
@classmethod
|
||||
def _ensure_audio_structure(cls, v: RealtimeSessionCreateRequest) -> RealtimeSessionCreateRequest:
|
||||
"""Guarantee 'audio.input' and 'audio.output' are never None."""
|
||||
if v.audio is None:
|
||||
v.audio = RealtimeAudioConfig()
|
||||
if v.audio.input is None:
|
||||
v.audio.input = RealtimeAudioConfigInput()
|
||||
if v.audio.output is None:
|
||||
v.audio.output = RealtimeAudioConfigOutput()
|
||||
return v
|
||||
|
||||
@property
|
||||
def interrupt_response_enabled(self) -> bool:
|
||||
"""Whether barge-in should cancel an active response.
|
||||
|
||||
Reads 'turn_detection.interrupt_response' from the session config,
|
||||
handling both Pydantic models ('ServerVad') and plain dicts.
|
||||
Defaults to 'True' (OpenAI API default).
|
||||
"""
|
||||
assert self.session.audio is not None and self.session.audio.input is not None
|
||||
td = self.session.audio.input.turn_detection
|
||||
if td is None:
|
||||
return True
|
||||
if hasattr(td, "interrupt_response"):
|
||||
val = td.interrupt_response
|
||||
elif isinstance(td, dict):
|
||||
val = td.get("interrupt_response", True)
|
||||
else:
|
||||
return True
|
||||
return val if val is not None else True
|
||||
|
||||
def apply_session_update(self, update: RealtimeSessionCreateRequest) -> None:
|
||||
"""Merge non-None, explicitly-set fields from 'update' into the
|
||||
current 'session', preserving any fields not present in the update."""
|
||||
_apply_update(self.session, update)
|
||||
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
import threading
|
||||
from threading import Event
|
||||
|
||||
import uvicorn
|
||||
|
||||
from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit
|
||||
from speech_to_speech.api.openai_realtime.websocket_router import create_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RealtimeServer:
|
||||
"""
|
||||
Pipeline handler for the OpenAI Realtime API mode.
|
||||
|
||||
Owns a pool of isolated PipelineUnits and a single uvicorn server.
|
||||
The websocket route claims the next free unit on each accept and releases
|
||||
it on disconnect; once all units are in use, further connections are rejected.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stop_event: Event,
|
||||
pool: list[PipelineUnit],
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8765,
|
||||
) -> None:
|
||||
if not pool:
|
||||
raise ValueError("RealtimeServer requires at least one PipelineUnit in the pool")
|
||||
self.stop_event = stop_event
|
||||
self.pool = pool
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
def run(self) -> None:
|
||||
"""Start the FastAPI/uvicorn server (called from a ThreadManager thread)."""
|
||||
app = create_app(pool=self.pool, stop_event=self.stop_event)
|
||||
|
||||
logger.info(
|
||||
f"OpenAI Realtime API starting on ws://{self.host}:{self.port}/v1/realtime (pool size {len(self.pool)})"
|
||||
)
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_level="info",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
server.install_signal_handlers = lambda: None # type: ignore[attr-defined]
|
||||
|
||||
def _watch_stop() -> None:
|
||||
self.stop_event.wait()
|
||||
server.should_exit = True
|
||||
|
||||
watcher = threading.Thread(target=_watch_stop, daemon=True)
|
||||
watcher.start()
|
||||
|
||||
server.run()
|
||||
@@ -0,0 +1,568 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from queue import Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
from time import perf_counter
|
||||
from typing import Any, Callable, Literal, Optional, TypeVar, Union
|
||||
|
||||
from openai.types.realtime import (
|
||||
ConversationItem,
|
||||
ConversationItemCreatedEvent,
|
||||
ConversationItemCreateEvent,
|
||||
ConversationItemInputAudioTranscriptionCompletedEvent,
|
||||
ConversationItemInputAudioTranscriptionDeltaEvent,
|
||||
InputAudioBufferAppendEvent,
|
||||
InputAudioBufferSpeechStartedEvent,
|
||||
InputAudioBufferSpeechStoppedEvent,
|
||||
RealtimeError,
|
||||
RealtimeErrorEvent,
|
||||
ResponseAudioDeltaEvent,
|
||||
ResponseAudioDoneEvent,
|
||||
ResponseAudioTranscriptDoneEvent,
|
||||
ResponseCancelEvent,
|
||||
ResponseCreatedEvent,
|
||||
ResponseCreateEvent,
|
||||
ResponseDoneEvent,
|
||||
ResponseFunctionCallArgumentsDoneEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ResponseTextDoneEvent,
|
||||
SessionCreatedEvent,
|
||||
SessionUpdateEvent,
|
||||
)
|
||||
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from speech_to_speech.api.openai_realtime.handlers import (
|
||||
AudioHandler,
|
||||
ConversationHandler,
|
||||
ResponseHandler,
|
||||
SessionHandler,
|
||||
)
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
from speech_to_speech.LLM.chat import Chat, make_user_message
|
||||
from speech_to_speech.pipeline.events import (
|
||||
AssistantTextEvent,
|
||||
PartialTranscriptionEvent,
|
||||
PipelineEvent,
|
||||
ResponseFailedEvent,
|
||||
SpeechStartedEvent,
|
||||
SpeechStoppedEvent,
|
||||
TokenUsageEvent,
|
||||
TranscriptionCompletedEvent,
|
||||
)
|
||||
from speech_to_speech.pipeline.messages import GenerateResponseRequest
|
||||
from speech_to_speech.pipeline.queue_types import TextPromptItem
|
||||
from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
|
||||
from speech_to_speech.utils.utils import _generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PIPELINE_SAMPLE_RATE = 16000
|
||||
CHUNK_SAMPLES = 512
|
||||
BYTES_PER_SAMPLE = 2
|
||||
CHUNK_SIZE_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE
|
||||
|
||||
_ResponseStatus = Literal["completed", "cancelled", "failed", "incomplete", "in_progress"]
|
||||
_StatusReason = Literal["turn_detected", "client_cancelled", "max_output_tokens", "content_filter"]
|
||||
|
||||
_EVENT_TYPE_TO_MODEL: dict[str, type[BaseModel]] = {
|
||||
"input_audio_buffer.append": InputAudioBufferAppendEvent,
|
||||
"session.update": SessionUpdateEvent,
|
||||
"conversation.item.create": ConversationItemCreateEvent,
|
||||
"response.create": ResponseCreateEvent,
|
||||
"response.cancel": ResponseCancelEvent,
|
||||
}
|
||||
|
||||
ClientEvent = Union[
|
||||
InputAudioBufferAppendEvent,
|
||||
SessionUpdateEvent,
|
||||
ConversationItemCreateEvent,
|
||||
ResponseCreateEvent,
|
||||
ResponseCancelEvent,
|
||||
]
|
||||
|
||||
ServerEvent = Union[
|
||||
SessionCreatedEvent,
|
||||
RealtimeErrorEvent,
|
||||
InputAudioBufferSpeechStartedEvent,
|
||||
InputAudioBufferSpeechStoppedEvent,
|
||||
ConversationItemCreatedEvent,
|
||||
ConversationItemInputAudioTranscriptionDeltaEvent,
|
||||
ConversationItemInputAudioTranscriptionCompletedEvent,
|
||||
ResponseCreatedEvent,
|
||||
ResponseDoneEvent,
|
||||
ResponseAudioDeltaEvent,
|
||||
ResponseAudioDoneEvent,
|
||||
ResponseAudioTranscriptDoneEvent,
|
||||
ResponseFunctionCallArgumentsDoneEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ResponseTextDoneEvent,
|
||||
]
|
||||
|
||||
RealtimeEvent = Union[ClientEvent, ServerEvent]
|
||||
|
||||
|
||||
_UsageMetricsT = TypeVar("_UsageMetricsT", bound="UsageMetrics")
|
||||
|
||||
|
||||
class UsageMetrics(BaseModel):
|
||||
"""Per-response usage counters.
|
||||
|
||||
Supports ``+=`` for rolling per-response metrics into a global total
|
||||
and ``reset()`` for clearing per-response state after rollup.
|
||||
"""
|
||||
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
audio_duration_s: float = 0.0
|
||||
responses_completed: int = 0
|
||||
responses_cancelled: int = 0
|
||||
tool_calls: int = 0
|
||||
turns: int = 0
|
||||
|
||||
def __iadd__(self: _UsageMetricsT, other: "UsageMetrics") -> _UsageMetricsT:
|
||||
for field in UsageMetrics.model_fields:
|
||||
setattr(self, field, getattr(self, field) + getattr(other, field))
|
||||
return self
|
||||
|
||||
def reset(self) -> None:
|
||||
for field, info in UsageMetrics.model_fields.items():
|
||||
setattr(self, field, info.default)
|
||||
|
||||
|
||||
class GlobalUsageMetrics(UsageMetrics):
|
||||
"""Server-wide metrics that extend per-response counters with
|
||||
connection and error tracking."""
|
||||
|
||||
connections: int = 0
|
||||
# connection duration in seconds.
|
||||
# latency tts, llm, vad, stt (mean, max, p90)
|
||||
errors_by_type: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
def record_error(self, error_type: str) -> None:
|
||||
self.errors_by_type[error_type] = self.errors_by_type.get(error_type, 0) + 1
|
||||
|
||||
@property
|
||||
def total_errors(self) -> int:
|
||||
return sum(self.errors_by_type.values())
|
||||
|
||||
|
||||
class ConnState(BaseModel):
|
||||
"""Per-connection mutable state, including all protocol-level IDs."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
session_id: str = Field(default_factory=lambda: _generate_id("session"))
|
||||
conversation_id: str = Field(default_factory=lambda: _generate_id("conv"))
|
||||
runtime_config: RuntimeConfig = Field(default_factory=RuntimeConfig)
|
||||
in_response: bool = False
|
||||
response_pending: bool = False
|
||||
audio_buffer_has_data: bool = False
|
||||
audio_remainder: bytes = b""
|
||||
current_response_id: Optional[str] = None
|
||||
current_item_id: Optional[str] = None
|
||||
content_index: int = 0
|
||||
input_content_index: int = 0
|
||||
input_audio_duration_s: float = 0.0
|
||||
last_item_id: Optional[str] = None
|
||||
current_response_params: RealtimeResponseCreateParams | None = None
|
||||
pending_output_text_parts: list[str] = Field(default_factory=list)
|
||||
response_usage: UsageMetrics = Field(default_factory=UsageMetrics)
|
||||
response_audio_wait_started_at_s: float | None = None
|
||||
delay_filler_sent: bool = False
|
||||
delay_filler_cursor: int = 0
|
||||
delay_filler_last_kind: str | None = None
|
||||
delay_filler_last_index: int | None = None
|
||||
speculative_turn_id: Optional[str] = None
|
||||
speculative_turn_revision: Optional[int] = None
|
||||
speculative_user_turn_id: Optional[str] = None
|
||||
speculative_user_turn_revision: Optional[int] = None
|
||||
speculative_user_speech_stopped_at_s: Optional[float] = None
|
||||
speculative_user_item_id: Optional[str] = None
|
||||
speculative_input_item_id: Optional[str] = None
|
||||
speculative_audio_duration_s: float = 0.0
|
||||
# Client conversation.item.create items that arrived while a response was
|
||||
# generating. Applying them mid-generation races the LLM handler's chat
|
||||
# write-back (cross-thread), so they are buffered here and flushed in order
|
||||
# once the response completes. See ConversationHandler.flush_deferred_items.
|
||||
deferred_items: list[ConversationItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RealtimeService:
|
||||
"""Translates between OpenAI Realtime protocol events and internal pipeline messages.
|
||||
|
||||
One instance is shared across all WebSocket connections. Per-connection
|
||||
state (response lifecycle, audio buffer) is tracked internally by
|
||||
connection id.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text_prompt_queue: Queue[TextPromptItem] | None = None,
|
||||
should_listen: ThreadingEvent | None = None,
|
||||
chat_size: int = 10,
|
||||
speculative_turns: SpeculativeTurnTracker | None = None,
|
||||
) -> None:
|
||||
self.text_prompt_queue = text_prompt_queue
|
||||
self.should_listen = should_listen
|
||||
self._chat_size = chat_size
|
||||
self.speculative_turns = speculative_turns
|
||||
self._conns: dict[str, ConnState] = {}
|
||||
self.total_usage = GlobalUsageMetrics()
|
||||
|
||||
self.audio = AudioHandler(self)
|
||||
self.session = SessionHandler(self)
|
||||
self.response = ResponseHandler(self)
|
||||
self.conversation = ConversationHandler(self)
|
||||
|
||||
self._pipeline_dispatch: dict[type[PipelineEvent], Callable[..., list[ServerEvent]]] = {
|
||||
SpeechStartedEvent: self.audio.on_speech_started,
|
||||
SpeechStoppedEvent: self.audio.on_speech_stopped,
|
||||
TokenUsageEvent: self._on_token_usage,
|
||||
PartialTranscriptionEvent: self.conversation.on_partial_transcription,
|
||||
TranscriptionCompletedEvent: self._on_transcription_completed,
|
||||
ResponseFailedEvent: self._on_response_failed,
|
||||
}
|
||||
|
||||
# ── Connection lifecycle ─────────────────────
|
||||
|
||||
def register(self) -> str:
|
||||
"""Register a new connection and return its session_id."""
|
||||
if self.speculative_turns:
|
||||
self.speculative_turns.reset()
|
||||
state = ConnState(runtime_config=RuntimeConfig(chat=Chat(self._chat_size)))
|
||||
self._conns[state.session_id] = state
|
||||
self.total_usage.connections += 1
|
||||
return state.session_id
|
||||
|
||||
def unregister(self, conn_id: str) -> None:
|
||||
st = self._conns.pop(conn_id, None)
|
||||
if st is not None:
|
||||
# Suppress any in-flight compaction splice so a daemon worker can't
|
||||
# mutate a Chat tied to a closed session, and don't make further
|
||||
# billable LLM calls on its behalf once the splice is suppressed.
|
||||
st.runtime_config.chat.close()
|
||||
self.total_usage += st.response_usage
|
||||
logger.info(
|
||||
"Session %s unregistered — cumulative: input_tokens=%d, output_tokens=%d, audio=%.2fs",
|
||||
conn_id,
|
||||
self.total_usage.input_tokens,
|
||||
self.total_usage.output_tokens,
|
||||
self.total_usage.audio_duration_s,
|
||||
)
|
||||
|
||||
def _state(self, conn_id: str) -> ConnState:
|
||||
return self._conns[conn_id]
|
||||
|
||||
@property
|
||||
def connection_ids(self) -> list[str]:
|
||||
return list(self._conns)
|
||||
|
||||
# ── Client event parsing ─────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _next_event_id() -> str:
|
||||
return _generate_id("event")
|
||||
|
||||
def parse_client_event(self, raw: Mapping[str, object]) -> Optional[ClientEvent]:
|
||||
raw_type = raw.get("type")
|
||||
event_type: Optional[str] = raw_type if isinstance(raw_type, str) else None
|
||||
if event_type is None:
|
||||
logger.warning("Client event missing 'type' field")
|
||||
return None
|
||||
model_cls = _EVENT_TYPE_TO_MODEL.get(event_type)
|
||||
if model_cls is None:
|
||||
logger.warning(f"Unknown client event type: {event_type}")
|
||||
return None
|
||||
try:
|
||||
return model_cls.model_validate(raw) # type: ignore[return-value]
|
||||
except ValidationError as e:
|
||||
# ── Retrocompatibilità session.update ─────────────────────────────────
|
||||
# Il formato standard OpenAI Realtime GA annida instructions/tools/tool_choice
|
||||
# DENTRO "session": { ... }. Per tollerare anche client che li mandano
|
||||
# alla RADICE (es. bug prima della Fase 2 MCP), ricostruiamo qui il payload
|
||||
# corretto e riproviamo la validazione. Funziona SOLO per session.update.
|
||||
if event_type == "session.update":
|
||||
try:
|
||||
raw_dict = dict(raw) if not isinstance(raw, dict) else raw
|
||||
session_inner = raw_dict.get("session")
|
||||
if isinstance(session_inner, dict):
|
||||
normalized_inner = dict(session_inner)
|
||||
else:
|
||||
normalized_inner = {}
|
||||
SESSION_KNOWN_TOP_LEVEL = (
|
||||
"instructions",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"model",
|
||||
"voice",
|
||||
"modalities",
|
||||
"input_audio_format",
|
||||
"output_audio_format",
|
||||
"input_audio_transcription",
|
||||
"turn_detection",
|
||||
"input_audio_noise_reduction",
|
||||
)
|
||||
promoted = 0
|
||||
for key in SESSION_KNOWN_TOP_LEVEL:
|
||||
if key in raw_dict and key not in normalized_inner:
|
||||
normalized_inner[key] = raw_dict[key]
|
||||
promoted += 1
|
||||
missing_type = "type" not in normalized_inner
|
||||
if missing_type:
|
||||
normalized_inner["type"] = "realtime"
|
||||
needs_rebuild = (
|
||||
promoted > 0
|
||||
or not isinstance(session_inner, dict)
|
||||
or missing_type
|
||||
)
|
||||
if needs_rebuild:
|
||||
rebuilt = {"type": "session.update", "session": normalized_inner}
|
||||
logger.info(
|
||||
"session.update parser fallback: promoted=%d flat keys, injected_type=%s. Re-validating.",
|
||||
promoted,
|
||||
missing_type,
|
||||
)
|
||||
return model_cls.model_validate(rebuilt) # type: ignore[return-value]
|
||||
except Exception as fallback_err:
|
||||
logger.error(
|
||||
"session.update fallback validation also failed: %s",
|
||||
fallback_err,
|
||||
)
|
||||
logger.error(f"Invalid {event_type} payload: {e}")
|
||||
return None
|
||||
|
||||
# ── Client event handlers ────────────────────
|
||||
|
||||
def build_session_created(self, conn_id: str) -> SessionCreatedEvent:
|
||||
return self.session.build_session_created(conn_id)
|
||||
|
||||
def handle_session_update(self, conn_id: str, event: SessionUpdateEvent) -> Optional[RealtimeErrorEvent]:
|
||||
return self.session.handle_session_update(conn_id, event)
|
||||
|
||||
def handle_audio_append(self, conn_id: str, event: InputAudioBufferAppendEvent) -> list[bytes]:
|
||||
return self.audio.handle_audio_append(conn_id, event)
|
||||
|
||||
def handle_audio_commit(self, conn_id: str) -> RealtimeErrorEvent | None:
|
||||
return self.audio.handle_audio_commit(conn_id)
|
||||
|
||||
def encode_audio_chunk(self, conn_id: str, audio: bytes) -> list[ServerEvent]:
|
||||
return self.audio.encode_audio_chunk(conn_id, audio)
|
||||
|
||||
def handle_response_create(self, conn_id: str, event: ResponseCreateEvent) -> ServerEvent | None:
|
||||
return self.response.handle_response_create(conn_id, event)
|
||||
|
||||
def handle_response_cancel(self, conn_id: str) -> list[ServerEvent]:
|
||||
return self.response.handle_response_cancel(conn_id)
|
||||
|
||||
def finish_response(
|
||||
self,
|
||||
conn_id: str,
|
||||
status: _ResponseStatus = "completed",
|
||||
reason: _StatusReason | None = None,
|
||||
) -> list[ServerEvent]:
|
||||
return self.response.finish_response(conn_id, status, reason)
|
||||
|
||||
def handle_conversation_item_create(self, conn_id: str, event: ConversationItemCreateEvent) -> list[ServerEvent]:
|
||||
return self.conversation.handle_conversation_item_create(conn_id, event)
|
||||
|
||||
def dispatch_pipeline_event(self, conn_id: str, event: PipelineEvent) -> list[ServerEvent]:
|
||||
"""Route a pipeline text_output_queue event to the appropriate handler."""
|
||||
events = self._dispatch_pipeline_event(conn_id, event, wait_for_pending_reopen=True)
|
||||
return [] if events is None else events
|
||||
|
||||
def try_dispatch_pipeline_event(self, conn_id: str, event: PipelineEvent) -> list[ServerEvent] | None:
|
||||
"""Non-blocking dispatch.
|
||||
|
||||
Returns ``None`` when dispatch must be retried after a speculative
|
||||
reopen candidate resolves.
|
||||
"""
|
||||
return self._dispatch_pipeline_event(conn_id, event, wait_for_pending_reopen=False)
|
||||
|
||||
def should_defer_pipeline_event(self, event: PipelineEvent) -> bool:
|
||||
if self.speculative_turns is None or not isinstance(event, (AssistantTextEvent, TokenUsageEvent)):
|
||||
return False
|
||||
return self.speculative_turns.has_pending_reopen_or_grace(
|
||||
getattr(event, "turn_id", None),
|
||||
getattr(event, "turn_revision", None),
|
||||
)
|
||||
|
||||
def _dispatch_pipeline_event(
|
||||
self,
|
||||
conn_id: str,
|
||||
event: PipelineEvent,
|
||||
*,
|
||||
wait_for_pending_reopen: bool,
|
||||
) -> list[ServerEvent] | None:
|
||||
is_stale = self._is_stale_turn_event(event, wait_for_pending_reopen=wait_for_pending_reopen)
|
||||
if is_stale is None:
|
||||
return None
|
||||
if is_stale:
|
||||
logger.info(
|
||||
"Ignoring stale %s for turn=%s rev=%s",
|
||||
event.type,
|
||||
getattr(event, "turn_id", None),
|
||||
getattr(event, "turn_revision", None),
|
||||
)
|
||||
return []
|
||||
|
||||
self._observe_turn_event(event)
|
||||
if isinstance(event, AssistantTextEvent):
|
||||
return self.response.on_assistant_text(
|
||||
conn_id,
|
||||
event,
|
||||
wait_for_pending_reopen=wait_for_pending_reopen,
|
||||
)
|
||||
handler = self._pipeline_dispatch.get(type(event))
|
||||
if handler is None:
|
||||
logger.debug("Unhandled pipeline event type: %s", type(event).__name__)
|
||||
return []
|
||||
return handler(conn_id, event)
|
||||
|
||||
def _is_stale_turn_event(self, event: PipelineEvent, *, wait_for_pending_reopen: bool = True) -> bool | None:
|
||||
if self.speculative_turns is None:
|
||||
return False
|
||||
if not isinstance(
|
||||
event,
|
||||
(PartialTranscriptionEvent, TranscriptionCompletedEvent, AssistantTextEvent, TokenUsageEvent),
|
||||
):
|
||||
return False
|
||||
turn_id = getattr(event, "turn_id", None)
|
||||
turn_revision = getattr(event, "turn_revision", None)
|
||||
if isinstance(event, (AssistantTextEvent, TokenUsageEvent)):
|
||||
is_latest: bool | None
|
||||
if wait_for_pending_reopen:
|
||||
is_latest = self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision)
|
||||
else:
|
||||
is_latest = self.speculative_turns.try_is_latest_after_reopen_grace(turn_id, turn_revision)
|
||||
if is_latest is None:
|
||||
return None
|
||||
return not is_latest
|
||||
return not self.speculative_turns.is_latest(turn_id, turn_revision)
|
||||
|
||||
def _observe_turn_event(self, event: PipelineEvent) -> None:
|
||||
if self.speculative_turns is None:
|
||||
return
|
||||
self.speculative_turns.observe(
|
||||
getattr(event, "turn_id", None),
|
||||
getattr(event, "turn_revision", None),
|
||||
)
|
||||
|
||||
# ── STT → LM bridge ────────────────────────────
|
||||
|
||||
def _on_transcription_completed(self, conn_id: str, event: TranscriptionCompletedEvent) -> list[ServerEvent]:
|
||||
"""Handle a final STT transcription: emit protocol event, append to chat, trigger LM."""
|
||||
st = self._state(conn_id)
|
||||
same_speculative_turn = event.turn_id is not None and event.turn_id == st.speculative_user_turn_id
|
||||
if same_speculative_turn:
|
||||
st.response_usage.audio_duration_s -= st.speculative_audio_duration_s
|
||||
else:
|
||||
st.speculative_audio_duration_s = 0.0
|
||||
|
||||
events = self.conversation.on_transcription_completed(conn_id, event)
|
||||
if event.turn_id is not None:
|
||||
st.speculative_audio_duration_s = st.input_audio_duration_s
|
||||
|
||||
cfg = st.runtime_config
|
||||
transcript = event.transcript
|
||||
if transcript:
|
||||
if same_speculative_turn and st.speculative_user_item_id:
|
||||
replaced = cfg.chat.replace_user_message_text(st.speculative_user_item_id, transcript)
|
||||
if not replaced:
|
||||
item = cfg.chat.add_item(make_user_message(transcript))
|
||||
st.speculative_user_item_id = item.id
|
||||
else:
|
||||
item = cfg.chat.add_item(make_user_message(transcript))
|
||||
st.speculative_user_item_id = item.id
|
||||
elif same_speculative_turn and st.speculative_user_item_id:
|
||||
cfg.chat.remove_user_message(st.speculative_user_item_id)
|
||||
st.speculative_user_item_id = None
|
||||
elif event.turn_id is not None and event.turn_id != st.speculative_user_turn_id:
|
||||
st.speculative_user_item_id = None
|
||||
|
||||
if event.turn_id is not None:
|
||||
st.speculative_user_turn_id = event.turn_id
|
||||
st.speculative_user_turn_revision = event.turn_revision
|
||||
st.speculative_user_speech_stopped_at_s = event.speech_stopped_at_s
|
||||
|
||||
queue = self.text_prompt_queue
|
||||
if queue and transcript:
|
||||
st.response_pending = True
|
||||
st.response_audio_wait_started_at_s = perf_counter()
|
||||
st.delay_filler_sent = False
|
||||
queue.put(
|
||||
GenerateResponseRequest(
|
||||
runtime_config=cfg,
|
||||
language_code=event.language_code,
|
||||
turn_id=event.turn_id,
|
||||
turn_revision=event.turn_revision,
|
||||
speech_stopped_at_s=event.speech_stopped_at_s,
|
||||
)
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
# ── Metrics ────────────────────────────────────
|
||||
|
||||
def _on_token_usage(self, conn_id: str, event: TokenUsageEvent) -> list[ServerEvent]:
|
||||
"""Accumulate input/output token counts on the connection's usage metrics."""
|
||||
if self.speculative_turns and not self.speculative_turns.is_latest(
|
||||
event.turn_id,
|
||||
event.turn_revision,
|
||||
):
|
||||
logger.debug("Dropping stale token usage for turn=%s rev=%s", event.turn_id, event.turn_revision)
|
||||
return []
|
||||
st = self._state(conn_id)
|
||||
st.response_usage.input_tokens += event.input_tokens
|
||||
st.response_usage.output_tokens += event.output_tokens
|
||||
logger.info(
|
||||
"Token usage (response): input=%d, output=%d",
|
||||
st.response_usage.input_tokens,
|
||||
st.response_usage.output_tokens,
|
||||
)
|
||||
return []
|
||||
|
||||
def _on_response_failed(self, conn_id: str, event: ResponseFailedEvent) -> list[ServerEvent]:
|
||||
"""Surface the failure to the client and close the response as ``failed``.
|
||||
|
||||
Emitted when generation failed (e.g. invalid out-of-band input, or the
|
||||
provider rejecting an empty context). A top-level ``error`` event carries
|
||||
the human-readable reason — ``response.done.status_details.error`` only
|
||||
has code/type, no message — then ``finish_response`` closes the slot.
|
||||
|
||||
Idempotent: gated on an active response, and ``finish_response`` is itself
|
||||
a no-op once the slot is closed, so a later EndOfResponse-driven close does
|
||||
nothing.
|
||||
"""
|
||||
logger.info("Response failed: %s", event.message)
|
||||
if not self._state(conn_id).in_response:
|
||||
return []
|
||||
events: list[ServerEvent] = [self.make_error(event.message, "response_failed")]
|
||||
events.extend(self.response.finish_response(conn_id, status="failed"))
|
||||
return events
|
||||
|
||||
def get_usage(self) -> dict[str, Any]:
|
||||
"""Return cumulative usage metrics across all completed responses."""
|
||||
data = self.total_usage.model_dump()
|
||||
data["total_tokens"] = data["input_tokens"] + data["output_tokens"]
|
||||
data["total_errors"] = self.total_usage.total_errors
|
||||
return data
|
||||
|
||||
# ── Error ───────────────────────────────────
|
||||
|
||||
def make_error(self, message: str, _type: str) -> RealtimeErrorEvent:
|
||||
self.total_usage.record_error(_type)
|
||||
return build_error_event(message, _type)
|
||||
|
||||
|
||||
def build_error_event(message: str, error_type: str) -> RealtimeErrorEvent:
|
||||
"""Construct a RealtimeErrorEvent without touching any service-instance state.
|
||||
|
||||
Used by the websocket route handler on pool rejection, where no unit's
|
||||
service should be charged with the error in its usage metrics.
|
||||
"""
|
||||
return RealtimeErrorEvent(
|
||||
type="error",
|
||||
error=RealtimeError(message=message, type=error_type),
|
||||
event_id=_generate_id("event"),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import numpy as np
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
|
||||
def resample(audio_int16: bytes, from_rate: int, to_rate: int) -> bytes:
|
||||
"""Resample int16 PCM audio between sample rates using polyphase filtering."""
|
||||
if from_rate == to_rate:
|
||||
return audio_int16
|
||||
samples = np.frombuffer(audio_int16, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
gcd = np.gcd(to_rate, from_rate)
|
||||
resampled = resample_poly(samples, up=to_rate // gcd, down=from_rate // gcd)
|
||||
return np.clip(resampled * 32768, -32768, 32767).astype(np.int16).tobytes()
|
||||
@@ -0,0 +1,942 @@
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
import wave
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from queue import Empty, Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
from typing import Any, Callable, Literal, TypeVar
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||
from openai.types.realtime import (
|
||||
ConversationItemCreateEvent,
|
||||
InputAudioBufferAppendEvent,
|
||||
InputAudioBufferCommitEvent,
|
||||
ResponseCancelEvent,
|
||||
ResponseCreateEvent,
|
||||
SessionUpdateEvent,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit, SessionState
|
||||
from speech_to_speech.api.openai_realtime.service import ServerEvent, build_error_event
|
||||
from speech_to_speech.RAG import get_global_rag
|
||||
|
||||
# Router amministrazione RAG — importato ma montato solo se RAG è attivo.
|
||||
# Perché in un file separato? Così se `sentence-transformers` non è installato
|
||||
# il resto della pipeline (senza --rag_enabled) continua a funzionare.
|
||||
try:
|
||||
from speech_to_speech.RAG.router import router as _rag_router
|
||||
except Exception as _rag_import_exc: # pragma: no cover - dipendenze mancanti
|
||||
_rag_router = None
|
||||
_rag_import_err = _rag_import_exc
|
||||
else:
|
||||
_rag_import_err = None
|
||||
from speech_to_speech.pipeline.control import SESSION_END, PipelineControlMessage, is_control_message
|
||||
from speech_to_speech.pipeline.events import (
|
||||
AssistantTextEvent,
|
||||
PartialTranscriptionEvent,
|
||||
PipelineEvent,
|
||||
SpeechStartedEvent,
|
||||
SpeechStoppedEvent,
|
||||
TokenUsageEvent,
|
||||
TranscriptionCompletedEvent,
|
||||
)
|
||||
from speech_to_speech.pipeline.log_context import pipeline_log_ctx
|
||||
from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END, AudioOutput, TTSInput
|
||||
from speech_to_speech.utils.utils import response_wants_audio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_AUDIO_BATCH_BYTES = 6400
|
||||
HTTP_TTS_SAMPLE_RATE = 16000
|
||||
# How long the release path waits for SESSION_END to propagate through the
|
||||
# handler chain back to output_queue before warning that the unit is stuck.
|
||||
# Tests monkeypatch this to a small value since their fixtures usually skip
|
||||
# the real handler chain.
|
||||
SESSION_END_DRAIN_TIMEOUT_S = 10.0
|
||||
# Past this, the unit is quarantined: its service session is unregistered
|
||||
# (closing the chat so late handler output can't mutate or bill it), but the
|
||||
# unit stays unclaimable. Releasing it instead would let a new client claim a
|
||||
# unit whose handlers may still emit the previous session's output (e.g. a
|
||||
# transcript, which carries no session identity and would be appended to the
|
||||
# new session's conversation) — a cross-session leak. If SESSION_END does
|
||||
# eventually drain, the chain has proven itself clean and the unit returns to
|
||||
# the pool; a dead handler keeps it quarantined forever, visible in /v1/pool
|
||||
# as "stuck".
|
||||
SESSION_END_QUARANTINE_TIMEOUT_S = 180.0
|
||||
QItem = TypeVar("QItem")
|
||||
|
||||
|
||||
class SpeechSynthesisRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
input: str = Field(min_length=1, description="Text to synthesize")
|
||||
model: str | None = Field(default=None, description="Reserved for OpenAI-compatible clients")
|
||||
voice: str | None = Field(default=None, description="Reserved for future voice override support")
|
||||
response_format: Literal["wav", "mp3"] | None = Field(
|
||||
default=None,
|
||||
description="Desired output audio format",
|
||||
)
|
||||
format: Literal["wav", "mp3"] | None = Field(
|
||||
default=None,
|
||||
alias="format",
|
||||
description="Alias for response_format",
|
||||
)
|
||||
language: str | None = Field(default=None, description="Optional target language hint")
|
||||
|
||||
def resolved_format(self) -> Literal["wav", "mp3"]:
|
||||
return self.response_format or self.format or "wav"
|
||||
|
||||
|
||||
async def _send_event(ws: WebSocket, event: ServerEvent) -> None:
|
||||
# Skip cleanly when the ws is already closing/closed — happens during Ctrl-C
|
||||
# shutdown, where the lifespan starts closing sockets while the route handler
|
||||
# or send loop is still in flight pushing events.
|
||||
if ws.application_state != WebSocketState.CONNECTED:
|
||||
return
|
||||
try:
|
||||
await ws.send_json(event.model_dump())
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Skipped event: ws disconnected mid-send")
|
||||
except RuntimeError as e:
|
||||
# Race: ws closed between the state check above and the send. Starlette
|
||||
# raises a plain RuntimeError("Unexpected ASGI message 'websocket.send'
|
||||
# after sending 'websocket.close' ...") — harmless during shutdown.
|
||||
msg = str(e)
|
||||
if "websocket.close" in msg or "websocket.disconnect" in msg or "response already completed" in msg:
|
||||
logger.debug(f"Skipped event: ws already closed ({msg})")
|
||||
else:
|
||||
logger.error(f"Failed to send event to client: {e}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"Failed to send event to client: {e}")
|
||||
|
||||
|
||||
async def _send_events(ws: WebSocket, events: list[ServerEvent]) -> None:
|
||||
for event in events:
|
||||
await _send_event(ws, event)
|
||||
|
||||
|
||||
def _keep_audio_sentinel(item: Any) -> bool:
|
||||
# SESSION_END must survive barge-in flushes of output_queue: dropping it
|
||||
# would leave the release path waiting forever for the drain signal.
|
||||
return _is_audio_done(item) or is_control_message(item, SESSION_END.kind)
|
||||
|
||||
|
||||
def _keep_user_text_event(item: Any) -> bool:
|
||||
return isinstance(
|
||||
item,
|
||||
(SpeechStoppedEvent, PartialTranscriptionEvent, TranscriptionCompletedEvent, TokenUsageEvent),
|
||||
)
|
||||
|
||||
|
||||
def _audio_payload(item: Any) -> Any:
|
||||
return item.audio if isinstance(item, AudioOutput) else item
|
||||
|
||||
|
||||
def _audio_generation(item: Any) -> int | None:
|
||||
return item.cancel_generation if isinstance(item, AudioOutput) else None
|
||||
|
||||
|
||||
def _flush_queue(q: Queue[QItem], *, preserve: Callable[[QItem], bool] | None = None) -> None:
|
||||
"""Drain a queue, optionally preserving items matching *preserve*.
|
||||
|
||||
Preserved items are re-inserted at the **front** of the queue
|
||||
(atomically under the queue's mutex) so they are processed before
|
||||
anything a pipeline thread may have enqueued during the drain.
|
||||
"""
|
||||
preserved: list[QItem] = []
|
||||
while True:
|
||||
try:
|
||||
item = q.get_nowait()
|
||||
if preserve and preserve(item):
|
||||
preserved.append(item)
|
||||
except Empty:
|
||||
break
|
||||
if preserved:
|
||||
with q.mutex:
|
||||
for item in reversed(preserved):
|
||||
q.queue.appendleft(item)
|
||||
q.not_empty.notify(len(preserved))
|
||||
|
||||
|
||||
async def _drain_pending_response_events(
|
||||
ws: WebSocket | None,
|
||||
unit: PipelineUnit,
|
||||
session_id: str | None,
|
||||
) -> None:
|
||||
if session_id is None:
|
||||
return
|
||||
|
||||
preserved: list[Any] = []
|
||||
drained_assistant = 0
|
||||
drained_usage = 0
|
||||
drain_assistant_events = True
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
item = unit.text_output_queue.get_nowait()
|
||||
except Empty:
|
||||
break
|
||||
# Usage is accounting-only, so keep the old whole-queue drain behavior.
|
||||
# Assistant events are client-visible response output and stop at the
|
||||
# first non-response boundary to preserve normal text-event ordering.
|
||||
if isinstance(item, TokenUsageEvent):
|
||||
unit.service.dispatch_pipeline_event(session_id, item)
|
||||
drained_usage += 1
|
||||
elif drain_assistant_events and isinstance(item, AssistantTextEvent):
|
||||
drained_assistant += 1
|
||||
if _generation_is_discardable(unit, item.cancel_generation):
|
||||
continue
|
||||
events = unit.service.dispatch_pipeline_event(session_id, item)
|
||||
if ws is not None and events:
|
||||
await _send_events(ws, events)
|
||||
else:
|
||||
preserved.append(item)
|
||||
drain_assistant_events = False
|
||||
finally:
|
||||
if preserved:
|
||||
with unit.text_output_queue.mutex:
|
||||
for item in reversed(preserved):
|
||||
unit.text_output_queue.queue.appendleft(item)
|
||||
unit.text_output_queue.not_empty.notify(len(preserved))
|
||||
|
||||
if drained_assistant or drained_usage:
|
||||
logger.debug(
|
||||
"Pipeline %d: drained %d assistant event(s) and %d token usage event(s) before response completion",
|
||||
unit.index,
|
||||
drained_assistant,
|
||||
drained_usage,
|
||||
)
|
||||
|
||||
|
||||
def _clean_unit(unit: PipelineUnit, preserve: Callable[[Any], bool] | None = None) -> None:
|
||||
"""Cancel in-flight work and flush queues for a single pipeline unit.
|
||||
|
||||
All four pipeline queues are drained — input audio, transcript-to-LM,
|
||||
LM-to-TTS output, and the text-event side channel — so pending work from
|
||||
a released session cannot be picked up by handlers and leak into the next
|
||||
session that claims this unit. SESSION_END is enqueued by the route
|
||||
handler *after* this returns to serve as the soft reset signal for
|
||||
stateful handlers.
|
||||
"""
|
||||
unit.cancel_scope.cancel()
|
||||
_flush_queue(unit.input_queue)
|
||||
_flush_queue(unit.text_prompt_queue)
|
||||
_flush_queue(unit.output_queue, preserve=preserve)
|
||||
_flush_queue(unit.text_output_queue, preserve=preserve)
|
||||
unit.response_playing.clear()
|
||||
unit.cancel_scope.reset()
|
||||
unit.should_listen.set()
|
||||
|
||||
|
||||
def _to_audio_bytes(chunk: Any) -> bytes:
|
||||
chunk = _audio_payload(chunk)
|
||||
if isinstance(chunk, PipelineControlMessage):
|
||||
raise TypeError(f"unexpected control message on audio output queue: {chunk!r}")
|
||||
if isinstance(chunk, np.ndarray) or hasattr(chunk, "tobytes"):
|
||||
return chunk.tobytes()
|
||||
return chunk
|
||||
|
||||
|
||||
def _is_audio_done(item: Any) -> bool:
|
||||
payload = _audio_payload(item)
|
||||
return isinstance(payload, bytes) and payload == AUDIO_RESPONSE_DONE
|
||||
|
||||
|
||||
def _is_pipeline_end(item: Any) -> bool:
|
||||
payload = _audio_payload(item)
|
||||
return isinstance(payload, bytes) and payload == PIPELINE_END
|
||||
|
||||
|
||||
def _generation_is_discardable(unit: PipelineUnit, generation: int | None) -> bool:
|
||||
"""Whether output tagged with *generation* should be dropped.
|
||||
|
||||
A generation is discardable if it has been superseded (``is_stale``) or if the
|
||||
cancel scope is in its post-cancel discard window and this is not the current
|
||||
live generation. Shared by audio and assistant-text so the two paths stay in
|
||||
lockstep: dropping text whenever ``discarding`` is set (without this generation
|
||||
check) silently swallows the transcript of a fresh response when ``discarding``
|
||||
lingers — e.g. a superseded speculative turn whose TTS never emitted an
|
||||
AUDIO_RESPONSE_DONE sentinel, so response_done() never cleared the flag.
|
||||
"""
|
||||
if generation is not None and unit.cancel_scope.is_stale(generation):
|
||||
return True
|
||||
if unit.cancel_scope.discarding and generation != unit.cancel_scope.generation:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _should_discard_audio(unit: PipelineUnit, item: Any) -> bool:
|
||||
return _generation_is_discardable(unit, _audio_generation(item))
|
||||
|
||||
|
||||
def _mark_audio_started(unit: PipelineUnit, session_id: str | None) -> None:
|
||||
if session_id is None:
|
||||
return
|
||||
st = unit.service._state(session_id)
|
||||
st.response_pending = False
|
||||
st.response_audio_wait_started_at_s = None
|
||||
|
||||
|
||||
def _get_qwen_tts_handler(unit: PipelineUnit) -> Any | None:
|
||||
for handler in unit.handlers:
|
||||
if handler.__class__.__name__ == "Qwen3TTSHandler":
|
||||
return handler
|
||||
return None
|
||||
|
||||
|
||||
def _encode_wav_bytes(pcm_bytes: bytes, sample_rate: int = HTTP_TTS_SAMPLE_RATE) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with wave.open(buffer, "wb") as wav_file:
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(sample_rate)
|
||||
wav_file.writeframes(pcm_bytes)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _encode_mp3_bytes(pcm_bytes: bytes, sample_rate: int = HTTP_TTS_SAMPLE_RATE) -> bytes:
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
if ffmpeg_path is None:
|
||||
raise RuntimeError("ffmpeg is required to return MP3 audio")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
ffmpeg_path,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"s16le",
|
||||
"-ar",
|
||||
str(sample_rate),
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-f",
|
||||
"mp3",
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"pipe:1",
|
||||
],
|
||||
input=pcm_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="ignore").strip()
|
||||
raise RuntimeError(f"ffmpeg mp3 encoding failed: {stderr or 'unknown error'}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _collect_http_tts_pcm(unit: PipelineUnit, request: SpeechSynthesisRequest) -> bytes:
|
||||
handler = _get_qwen_tts_handler(unit)
|
||||
if handler is None:
|
||||
raise RuntimeError("Qwen3-TTS handler not configured on the reserved pipeline unit")
|
||||
|
||||
_clean_unit(unit)
|
||||
tts_input = TTSInput(
|
||||
text=request.input.strip(),
|
||||
language_code=request.language,
|
||||
turn_id=f"http-tts-{uuid.uuid4().hex}",
|
||||
turn_revision=0,
|
||||
)
|
||||
|
||||
chunks: list[np.ndarray] = []
|
||||
for chunk in handler.process(tts_input):
|
||||
payload = chunk.audio if isinstance(chunk, AudioOutput) else chunk
|
||||
if isinstance(payload, bytes):
|
||||
if payload in (AUDIO_RESPONSE_DONE, PIPELINE_END):
|
||||
continue
|
||||
chunks.append(np.frombuffer(payload, dtype=np.int16).copy())
|
||||
continue
|
||||
|
||||
array = np.asarray(payload, dtype=np.int16).reshape(-1)
|
||||
if array.size:
|
||||
chunks.append(array.copy())
|
||||
|
||||
_clean_unit(unit)
|
||||
if not chunks:
|
||||
raise RuntimeError("Qwen3-TTS produced no audio")
|
||||
return np.concatenate(chunks).astype(np.int16, copy=False).tobytes()
|
||||
|
||||
|
||||
async def _maybe_send_delay_filler(ws: WebSocket | None, unit: PipelineUnit, session_id: str | None) -> bool:
|
||||
if (
|
||||
ws is None
|
||||
or session_id is None
|
||||
or unit.delay_filler_threshold_s <= 0
|
||||
or not (unit.delay_filler_short_clips or unit.delay_filler_full_clips)
|
||||
):
|
||||
return False
|
||||
st = unit.service._state(session_id)
|
||||
started_at_s = st.response_audio_wait_started_at_s
|
||||
if started_at_s is None or st.delay_filler_sent or unit.response_playing.is_set():
|
||||
return False
|
||||
if not (st.response_pending or st.in_response):
|
||||
return False
|
||||
if not response_wants_audio(st.current_response_params):
|
||||
return False
|
||||
if time.perf_counter() - started_at_s < unit.delay_filler_threshold_s:
|
||||
return False
|
||||
|
||||
filler_groups: list[tuple[str, list[bytes], float]] = []
|
||||
if unit.delay_filler_short_clips:
|
||||
filler_groups.append(("short", unit.delay_filler_short_clips, 0.65))
|
||||
if unit.delay_filler_full_clips:
|
||||
filler_groups.append(("full", unit.delay_filler_full_clips, 0.35))
|
||||
|
||||
if len(filler_groups) == 1:
|
||||
filler_kind, filler_clips, _ = filler_groups[0]
|
||||
else:
|
||||
filler_kind, filler_clips, _ = random.choices(
|
||||
filler_groups,
|
||||
weights=[group[2] for group in filler_groups],
|
||||
k=1,
|
||||
)[0]
|
||||
|
||||
if len(filler_clips) == 1:
|
||||
filler_index = 0
|
||||
else:
|
||||
candidate_indexes = list(range(len(filler_clips)))
|
||||
if st.delay_filler_last_kind == filler_kind and st.delay_filler_last_index in candidate_indexes:
|
||||
candidate_indexes.remove(st.delay_filler_last_index)
|
||||
filler_index = random.choice(candidate_indexes)
|
||||
|
||||
clip = filler_clips[filler_index]
|
||||
st.delay_filler_cursor += 1
|
||||
st.delay_filler_last_kind = filler_kind
|
||||
st.delay_filler_last_index = filler_index
|
||||
st.delay_filler_sent = True
|
||||
_mark_audio_started(unit, session_id)
|
||||
|
||||
for offset in range(0, len(clip), MAX_AUDIO_BATCH_BYTES):
|
||||
batch = clip[offset : offset + MAX_AUDIO_BATCH_BYTES]
|
||||
if batch:
|
||||
await _send_events(ws, unit.service.encode_audio_chunk(session_id, batch))
|
||||
|
||||
unit.response_playing.set()
|
||||
unit.should_listen.set()
|
||||
logger.info("Pipeline %d: sent delay filler clip", unit.index)
|
||||
return True
|
||||
|
||||
|
||||
def _safe_unregister(unit: PipelineUnit, session_id: str) -> None:
|
||||
try:
|
||||
unit.service.unregister(session_id)
|
||||
except Exception:
|
||||
logger.exception(f"Pipeline {unit.index}: unregister failed for session {session_id}")
|
||||
|
||||
|
||||
async def _release_unit_after_drain(unit: PipelineUnit, session: Any, session_id: str) -> None:
|
||||
"""Wait for SESSION_END to propagate, then release the unit.
|
||||
|
||||
Runs in its own asyncio task so the route handler's finally block can return
|
||||
immediately. The unit stays unavailable for new claims (unit.session != None)
|
||||
until SESSION_END travels all the way through the handler chain back to
|
||||
output_queue — observed by the send loop, which sets session.drained.
|
||||
|
||||
Past SESSION_END_QUARANTINE_TIMEOUT_S (a wedged or dead handler thread) the
|
||||
unit is quarantined, NOT released: still-running handlers could emit the old
|
||||
session's output (transcripts carry no session identity) into whichever
|
||||
session claimed the unit next, and a dead handler would make the unit accept
|
||||
clients it can never serve. The session is unregistered right away so late
|
||||
output can't mutate or bill the closed conversation; the unit itself only
|
||||
returns to the pool if SESSION_END eventually drains, proving the chain is
|
||||
clean. Operators can spot quarantined units in `/v1/pool` (state "stuck").
|
||||
"""
|
||||
elapsed = 0.0
|
||||
warned = False
|
||||
try:
|
||||
while not session.drained.is_set():
|
||||
await asyncio.sleep(0.05)
|
||||
elapsed += 0.05
|
||||
if not warned and elapsed >= SESSION_END_DRAIN_TIMEOUT_S:
|
||||
logger.warning(
|
||||
f"Pipeline {unit.index}: SESSION_END not drained after {elapsed:.1f}s — "
|
||||
f"unit will remain unavailable until handlers finish (session {session_id})"
|
||||
)
|
||||
warned = True
|
||||
if session.quarantined_at is None and elapsed >= SESSION_END_QUARANTINE_TIMEOUT_S:
|
||||
session.quarantined_at = time.monotonic()
|
||||
_safe_unregister(unit, session_id)
|
||||
logger.error(
|
||||
f"Pipeline {unit.index}: SESSION_END still not drained after {elapsed:.0f}s — "
|
||||
f"quarantining unit until the handler chain drains (session {session_id})"
|
||||
)
|
||||
finally:
|
||||
# Runs when the drain completed (chain proven clean) or the task is
|
||||
# cancelled at shutdown. Release unconditionally: even if unregister
|
||||
# raises, the unit must not stay claimed forever.
|
||||
try:
|
||||
_safe_unregister(unit, session_id)
|
||||
finally:
|
||||
unit.session = None
|
||||
recovered = " after quarantine" if session.quarantined_at is not None else ""
|
||||
logger.info(f"Pipeline {unit.index} released{recovered} (session {session_id} ended)")
|
||||
|
||||
|
||||
def create_app(pool: list[PipelineUnit], stop_event: ThreadingEvent) -> FastAPI:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# One send loop per pipeline unit; each polls its own queues and forwards
|
||||
# to the websocket currently attached via unit.session.
|
||||
send_tasks = [asyncio.create_task(_send_loop_for(unit)) for unit in pool]
|
||||
yield
|
||||
for task in send_tasks:
|
||||
task.cancel()
|
||||
for task in send_tasks:
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
for unit in pool:
|
||||
sess = unit.session
|
||||
if sess is not None:
|
||||
try:
|
||||
await sess.websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# ── Montaggio router RAG (solo se attivo) ────────────────────────────────
|
||||
if get_global_rag() is not None and _rag_router is not None:
|
||||
app.include_router(_rag_router)
|
||||
logger.info(
|
||||
"RAG HTTP API montata su %s",
|
||||
getattr(_rag_router, "prefix", "/v1/rag"),
|
||||
)
|
||||
elif _rag_router is None and _rag_import_err is not None:
|
||||
logger.warning(
|
||||
"Router RAG non disponibile — import fallito: %s",
|
||||
_rag_import_err,
|
||||
)
|
||||
else:
|
||||
logger.debug("Router RAG omesso (RAG non attivo in questa istanza).")
|
||||
|
||||
# Strong references to in-flight drain-and-release tasks (asyncio only
|
||||
# holds tasks weakly); each task removes itself on completion.
|
||||
release_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
def _claim_unit(ws: WebSocket) -> PipelineUnit | None:
|
||||
"""Atomically (between asyncio yield points) reserve the first idle unit.
|
||||
|
||||
Creates a placeholder SessionState that the caller fills in with the
|
||||
session_id after RealtimeService.register().
|
||||
"""
|
||||
for unit in pool:
|
||||
if unit.session is None and not unit.http_tts_reserved:
|
||||
unit.session = SessionState(websocket=ws)
|
||||
return unit
|
||||
return None
|
||||
|
||||
def _reserve_http_tts_unit() -> PipelineUnit | None:
|
||||
for unit in pool:
|
||||
if unit.session is None and not unit.http_tts_reserved and _get_qwen_tts_handler(unit) is not None:
|
||||
unit.http_tts_reserved = True
|
||||
return unit
|
||||
return None
|
||||
|
||||
def _has_qwen_tts_capacity() -> bool:
|
||||
return any(_get_qwen_tts_handler(unit) is not None for unit in pool)
|
||||
|
||||
@app.post("/v1/audio/speech")
|
||||
async def speech_endpoint(request: SpeechSynthesisRequest) -> Response:
|
||||
if not _has_qwen_tts_capacity():
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Qwen TTS is not configured on this server instance.",
|
||||
)
|
||||
|
||||
unit = _reserve_http_tts_unit()
|
||||
if unit is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="All Qwen TTS pipeline slots are currently busy.",
|
||||
)
|
||||
|
||||
try:
|
||||
pcm_bytes = await asyncio.to_thread(_collect_http_tts_pcm, unit, request)
|
||||
fmt = request.resolved_format()
|
||||
if fmt == "mp3":
|
||||
payload = await asyncio.to_thread(_encode_mp3_bytes, pcm_bytes, HTTP_TTS_SAMPLE_RATE)
|
||||
media_type = "audio/mpeg"
|
||||
extension = "mp3"
|
||||
else:
|
||||
payload = _encode_wav_bytes(pcm_bytes, HTTP_TTS_SAMPLE_RATE)
|
||||
media_type = "audio/wav"
|
||||
extension = "wav"
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("HTTP speech synthesis failed on pipeline %d: %s", unit.index, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"TTS synthesis failed: {e}") from e
|
||||
finally:
|
||||
unit.http_tts_reserved = False
|
||||
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="speech.{extension}"',
|
||||
"X-Audio-Sample-Rate": str(HTTP_TTS_SAMPLE_RATE),
|
||||
},
|
||||
)
|
||||
|
||||
@app.websocket("/v1/realtime")
|
||||
async def realtime_endpoint(ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
|
||||
unit = _claim_unit(ws)
|
||||
if unit is None:
|
||||
logger.warning(f"Rejected connection: all {len(pool)} pipeline slots in use")
|
||||
# Stateless error event — rejection is not chargeable to any unit's usage metrics.
|
||||
await _send_event(
|
||||
ws,
|
||||
build_error_event(
|
||||
f"All {len(pool)} session slots are in use. Disconnect an existing client first.",
|
||||
error_type="session_limit_reached",
|
||||
),
|
||||
)
|
||||
await ws.close(code=1008, reason="All session slots are in use")
|
||||
return
|
||||
|
||||
pipeline_log_ctx.set(unit.index)
|
||||
# _claim_unit guarantees unit.session is not None for the returned unit.
|
||||
assert unit.session is not None
|
||||
# Everything after the claim runs inside try so the finally below always
|
||||
# releases the unit, even if session setup fails.
|
||||
session_id = ""
|
||||
try:
|
||||
session_id = unit.service.register()
|
||||
unit.session.session_id = session_id
|
||||
logger.info(f"Client connected to pipeline {unit.index} (session {session_id})")
|
||||
|
||||
# Defensive: drain edge queues and reset events so stale data from a
|
||||
# previous session that survived SESSION_END propagation doesn't leak.
|
||||
_clean_unit(unit)
|
||||
|
||||
await _send_event(ws, unit.service.build_session_created(session_id))
|
||||
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.receive_json(), timeout=0.1)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
event = unit.service.parse_client_event(raw)
|
||||
if event is None:
|
||||
await _send_event(
|
||||
ws,
|
||||
unit.service.make_error(
|
||||
f"Unknown or invalid event: {raw.get('type')}", "unknown_or_invalid_event"
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(event, InputAudioBufferAppendEvent):
|
||||
chunks = unit.service.handle_audio_append(session_id, event)
|
||||
rt_cfg = unit.service._state(session_id).runtime_config
|
||||
for chunk in chunks:
|
||||
unit.input_queue.put((chunk, rt_cfg))
|
||||
|
||||
elif isinstance(event, InputAudioBufferCommitEvent):
|
||||
err = unit.service.handle_audio_commit(session_id)
|
||||
if err:
|
||||
await _send_event(ws, err)
|
||||
|
||||
elif isinstance(event, SessionUpdateEvent):
|
||||
err = unit.service.handle_session_update(session_id, event)
|
||||
if err:
|
||||
await _send_event(ws, err)
|
||||
|
||||
elif isinstance(event, ConversationItemCreateEvent):
|
||||
events = unit.service.handle_conversation_item_create(session_id, event)
|
||||
if events:
|
||||
await _send_events(ws, events)
|
||||
|
||||
elif isinstance(event, ResponseCreateEvent):
|
||||
result = unit.service.handle_response_create(session_id, event)
|
||||
if result:
|
||||
if result.type != "error":
|
||||
unit.cancel_scope.new_response()
|
||||
await _send_event(ws, result)
|
||||
|
||||
elif isinstance(event, ResponseCancelEvent):
|
||||
was_active = unit.service._state(session_id).in_response
|
||||
if was_active:
|
||||
unit.cancel_scope.cancel()
|
||||
_flush_queue(unit.output_queue, preserve=_keep_audio_sentinel)
|
||||
_flush_queue(unit.text_output_queue, preserve=_keep_user_text_event)
|
||||
events = unit.service.handle_response_cancel(session_id)
|
||||
if events:
|
||||
await _send_events(ws, events)
|
||||
unit.response_playing.clear()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"Client {session_id} disconnected from pipeline {unit.index}")
|
||||
except Exception as e:
|
||||
logger.error(f"Client {session_id} on pipeline {unit.index} error: {type(e).__name__}: {e}", exc_info=True)
|
||||
finally:
|
||||
# Hold the session reference: the send loop's snapshot will still resolve
|
||||
# to this object until we clear unit.session, so any handler output that
|
||||
# arrives during the drain window is sent to the now-closed ws (silently
|
||||
# dropped) instead of leaking to whichever client claims this unit next.
|
||||
old_session = unit.session
|
||||
if old_session is not None:
|
||||
old_session.released_at = time.monotonic()
|
||||
_clean_unit(unit)
|
||||
# Tag SESSION_END with this session's id so that, after a force
|
||||
# release, a late arrival can't satisfy the next session's drain.
|
||||
unit.input_queue.put(PipelineControlMessage(SESSION_END.kind, session_id=session_id))
|
||||
# Spawn the drain-and-release as a separate task so the route handler's
|
||||
# finally returns immediately. Awaiting here is unreliable: after
|
||||
# WebSocketDisconnect propagates, subsequent awaits in the same task
|
||||
# can be skipped/cancelled by Starlette's runner and never resume.
|
||||
# asyncio holds tasks weakly — keep a reference until it completes.
|
||||
task = asyncio.create_task(_release_unit_after_drain(unit, old_session, session_id))
|
||||
release_tasks.add(task)
|
||||
task.add_done_callback(release_tasks.discard)
|
||||
|
||||
@app.get("/v1/usage")
|
||||
async def usage_endpoint() -> dict[str, Any]:
|
||||
# Aggregate usage across the pool. Numeric fields sum; dict fields (e.g.
|
||||
# errors_by_type) merge with numeric leaves summed too, so per-unit error
|
||||
# counts don't get dropped by the first-unit's value.
|
||||
def _merge(into: dict[str, Any], src: dict[str, Any]) -> None:
|
||||
for k, v in src.items():
|
||||
if isinstance(v, (int, float)):
|
||||
into[k] = into.get(k, 0) + v
|
||||
elif isinstance(v, dict):
|
||||
sub = into.setdefault(k, {})
|
||||
if isinstance(sub, dict):
|
||||
_merge(sub, v)
|
||||
else:
|
||||
into.setdefault(k, v)
|
||||
|
||||
total: dict[str, Any] = {}
|
||||
for unit in pool:
|
||||
_merge(total, unit.service.get_usage())
|
||||
return total
|
||||
|
||||
@app.get("/v1/pool")
|
||||
async def pool_endpoint() -> dict[str, Any]:
|
||||
now = time.monotonic()
|
||||
|
||||
def _state(u: PipelineUnit) -> dict[str, Any]:
|
||||
s = u.session
|
||||
if s is None and u.http_tts_reserved:
|
||||
return {"index": u.index, "state": "http_tts", "session_id": None}
|
||||
if s is None:
|
||||
return {"index": u.index, "state": "idle", "session_id": None}
|
||||
if s.released_at is None:
|
||||
return {"index": u.index, "state": "active", "session_id": s.session_id}
|
||||
# Drain wait gave up (quarantine timeout): the unit stays occupied
|
||||
# until SESSION_END actually drains — possibly forever if a handler
|
||||
# thread died. Surfaced distinctly so operators can act on it.
|
||||
if s.quarantined_at is not None:
|
||||
return {
|
||||
"index": u.index,
|
||||
"state": "stuck",
|
||||
"session_id": s.session_id,
|
||||
"draining_for_s": round(now - s.released_at, 2),
|
||||
"stuck_for_s": round(now - s.quarantined_at, 2),
|
||||
}
|
||||
# released by client but SESSION_END hasn't drained yet → unit
|
||||
# is still occupied; surface elapsed time so operators can spot
|
||||
# stuck handlers.
|
||||
return {
|
||||
"index": u.index,
|
||||
"state": "draining",
|
||||
"session_id": s.session_id,
|
||||
"draining_for_s": round(now - s.released_at, 2),
|
||||
}
|
||||
|
||||
return {
|
||||
"size": len(pool),
|
||||
"in_use": sum(1 for u in pool if u.session is not None),
|
||||
"units": [_state(u) for u in pool],
|
||||
}
|
||||
|
||||
async def _send_loop_for(unit: PipelineUnit) -> None:
|
||||
"""Per-pipeline send loop. Polls this unit's output queues and forwards
|
||||
to the websocket currently attached via unit.session.
|
||||
|
||||
Per-session scratch (pending_output_item) lives on SessionState, so it
|
||||
disappears together with the websocket when the session is released —
|
||||
no stale sentinel can leak into the next claim.
|
||||
"""
|
||||
pipeline_log_ctx.set(unit.index)
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
# Snapshot the session once per iteration; if the route releases the
|
||||
# unit mid-iteration, we continue against the prior snapshot which is
|
||||
# consistent (its websocket is still valid until ws.close() returns).
|
||||
session = unit.session
|
||||
ws = session.websocket if session is not None else None
|
||||
session_id = session.session_id if session is not None else None
|
||||
|
||||
# Text events first (speech_started cancels active response).
|
||||
try:
|
||||
text_msg = unit.text_output_queue.get_nowait()
|
||||
is_speech_start = isinstance(text_msg, SpeechStartedEvent)
|
||||
|
||||
was_in_response = False
|
||||
was_response_pending = False
|
||||
if is_speech_start and session_id:
|
||||
st = unit.service._state(session_id)
|
||||
was_in_response = st.in_response
|
||||
was_response_pending = st.response_pending
|
||||
|
||||
if isinstance(text_msg, AssistantTextEvent) and _generation_is_discardable(
|
||||
unit, text_msg.cancel_generation
|
||||
):
|
||||
pass
|
||||
elif ws is not None and isinstance(text_msg, PipelineEvent) and session_id:
|
||||
events = unit.service.dispatch_pipeline_event(session_id, text_msg)
|
||||
if events:
|
||||
await _send_events(ws, events)
|
||||
|
||||
if is_speech_start and (was_in_response or was_response_pending):
|
||||
active_cfg = unit.service._state(session_id).runtime_config if session_id else None
|
||||
if text_msg.interrupt_response and (
|
||||
active_cfg is None or active_cfg.interrupt_response_enabled
|
||||
):
|
||||
unit.cancel_scope.cancel()
|
||||
if session_id:
|
||||
unit.service._state(session_id).response_pending = False
|
||||
_flush_queue(unit.output_queue, preserve=_keep_audio_sentinel)
|
||||
_flush_queue(unit.text_output_queue, preserve=_keep_user_text_event)
|
||||
if unit.response_playing.is_set():
|
||||
unit.response_playing.clear()
|
||||
logger.info(
|
||||
"Pipeline %d: speech during %s: cancelled, queue flushed",
|
||||
unit.index,
|
||||
"response" if was_in_response else "pending response",
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Pipeline {unit.index}: speech during response: interrupt_response disabled, ignoring"
|
||||
)
|
||||
except Empty:
|
||||
pass
|
||||
|
||||
if (
|
||||
session is not None
|
||||
and session.pending_output_item is None
|
||||
and unit.output_queue.empty()
|
||||
and await _maybe_send_delay_filler(ws, unit, session_id)
|
||||
):
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
|
||||
try:
|
||||
if session is not None and session.pending_output_item is not None:
|
||||
audio_chunk = session.pending_output_item
|
||||
session.pending_output_item = None
|
||||
else:
|
||||
audio_chunk = unit.output_queue.get_nowait()
|
||||
|
||||
if _is_pipeline_end(audio_chunk):
|
||||
await _drain_pending_response_events(ws, unit, session_id)
|
||||
if ws is not None and session_id:
|
||||
await _send_events(ws, unit.service.finish_response(session_id))
|
||||
break
|
||||
|
||||
if _is_audio_done(audio_chunk):
|
||||
audio_generation = _audio_generation(audio_chunk)
|
||||
if audio_generation is not None and unit.cancel_scope.is_stale(audio_generation):
|
||||
if session_id:
|
||||
unit.service._state(session_id).response_pending = False
|
||||
unit.cancel_scope.response_done(audio_generation)
|
||||
unit.should_listen.set()
|
||||
logger.info(f"Pipeline {unit.index}: stale response complete, listening re-enabled")
|
||||
continue
|
||||
await _drain_pending_response_events(ws, unit, session_id)
|
||||
if ws is not None and session_id:
|
||||
await _send_events(ws, unit.service.finish_response(session_id))
|
||||
if session_id:
|
||||
unit.service._state(session_id).response_pending = False
|
||||
unit.response_playing.clear()
|
||||
unit.cancel_scope.response_done(audio_generation)
|
||||
unit.should_listen.set()
|
||||
logger.info(f"Pipeline {unit.index}: response complete, listening re-enabled")
|
||||
continue
|
||||
|
||||
# SESSION_END travels from input_queue through every handler to
|
||||
# output_queue. Observing it here means the chain has fully reset;
|
||||
# signal the release path so it can clear unit.session. A tag from
|
||||
# another session means the emitting session was force-released —
|
||||
# its late SESSION_END must not stand in for this session's drain.
|
||||
if is_control_message(audio_chunk, SESSION_END.kind):
|
||||
chunk_session_id = getattr(audio_chunk, "session_id", None)
|
||||
if session is not None and chunk_session_id in (None, session.session_id):
|
||||
session.drained.set()
|
||||
logger.debug(f"Pipeline {unit.index}: SESSION_END drained")
|
||||
continue
|
||||
|
||||
if is_control_message(audio_chunk):
|
||||
continue
|
||||
|
||||
if _should_discard_audio(unit, audio_chunk):
|
||||
continue
|
||||
|
||||
audio_chunk = _to_audio_bytes(audio_chunk)
|
||||
|
||||
audio_batch = bytearray(audio_chunk)
|
||||
while len(audio_batch) < MAX_AUDIO_BATCH_BYTES:
|
||||
try:
|
||||
next_chunk = unit.output_queue.get_nowait()
|
||||
except Empty:
|
||||
break
|
||||
|
||||
if (
|
||||
_is_pipeline_end(next_chunk)
|
||||
or _is_audio_done(next_chunk)
|
||||
or is_control_message(next_chunk, SESSION_END.kind)
|
||||
):
|
||||
# Only stash if we still have a session; otherwise drop it.
|
||||
if session is not None:
|
||||
session.pending_output_item = next_chunk
|
||||
break
|
||||
|
||||
if _should_discard_audio(unit, next_chunk):
|
||||
continue
|
||||
|
||||
next_audio = _to_audio_bytes(next_chunk)
|
||||
if len(audio_batch) + len(next_audio) > MAX_AUDIO_BATCH_BYTES:
|
||||
if session is not None:
|
||||
session.pending_output_item = next_chunk
|
||||
break
|
||||
audio_batch.extend(next_audio)
|
||||
|
||||
if not unit.response_playing.is_set():
|
||||
unit.response_playing.set()
|
||||
unit.should_listen.set()
|
||||
_mark_audio_started(unit, session_id)
|
||||
|
||||
if ws is not None and session_id:
|
||||
await _send_events(ws, unit.service.encode_audio_chunk(session_id, bytes(audio_batch)))
|
||||
except Empty:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Pipeline {unit.index} send loop error: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return app
|
||||
Reference in New Issue
Block a user