andito HF Staff commited on
Commit
8d18cdc
·
verified ·
1 Parent(s): 2ea1988

Remove unused runtime source snapshot

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. runtime/src/speech_to_speech/LLM/README.md +0 -139
  2. runtime/src/speech_to_speech/LLM/__init__.py +0 -0
  3. runtime/src/speech_to_speech/LLM/audio_input_notifier.py +0 -82
  4. runtime/src/speech_to_speech/LLM/base_openai_compatible_language_model.py +0 -833
  5. runtime/src/speech_to_speech/LLM/chat.py +0 -835
  6. runtime/src/speech_to_speech/LLM/chat_completions_language_model.py +0 -360
  7. runtime/src/speech_to_speech/LLM/compaction_prompt.py +0 -181
  8. runtime/src/speech_to_speech/LLM/language_model.py +0 -1011
  9. runtime/src/speech_to_speech/LLM/lm_output_processor.py +0 -148
  10. runtime/src/speech_to_speech/LLM/responses_api_language_model.py +0 -201
  11. runtime/src/speech_to_speech/LLM/text_prompt.py +0 -44
  12. runtime/src/speech_to_speech/LLM/tool_call/function_call.py +0 -321
  13. runtime/src/speech_to_speech/LLM/tool_call/function_tool.py +0 -34
  14. runtime/src/speech_to_speech/LLM/tool_call/signature_from_schema.py +0 -108
  15. runtime/src/speech_to_speech/LLM/tool_call/tool_prompt.py +0 -111
  16. runtime/src/speech_to_speech/LLM/utils.py +0 -75
  17. runtime/src/speech_to_speech/LLM/voice_prompt.py +0 -48
  18. runtime/src/speech_to_speech/STT/README.md +0 -163
  19. runtime/src/speech_to_speech/STT/__init__.py +0 -1
  20. runtime/src/speech_to_speech/STT/base_stt_handler.py +0 -212
  21. runtime/src/speech_to_speech/STT/faster_whisper_handler.py +0 -68
  22. runtime/src/speech_to_speech/STT/lightning_whisper_mlx_handler.py +0 -103
  23. runtime/src/speech_to_speech/STT/mlx_audio_whisper_handler.py +0 -174
  24. runtime/src/speech_to_speech/STT/paraformer_handler.py +0 -79
  25. runtime/src/speech_to_speech/STT/parakeet_tdt_handler.py +0 -648
  26. runtime/src/speech_to_speech/STT/smart_progressive_streaming.py +0 -343
  27. runtime/src/speech_to_speech/STT/transcription_notifier.py +0 -103
  28. runtime/src/speech_to_speech/STT/whisper_stt_handler.py +0 -147
  29. runtime/src/speech_to_speech/TTS/README.md +0 -155
  30. runtime/src/speech_to_speech/TTS/__init__.py +0 -1
  31. runtime/src/speech_to_speech/TTS/chatTTS_handler.py +0 -119
  32. runtime/src/speech_to_speech/TTS/facebookmms_handler.py +0 -212
  33. runtime/src/speech_to_speech/TTS/kokoro_handler.py +0 -418
  34. runtime/src/speech_to_speech/TTS/pocket_tts_handler.py +0 -227
  35. runtime/src/speech_to_speech/TTS/qwen3_tts_handler.py +0 -903
  36. runtime/src/speech_to_speech/TTS/ref_audio.wav +0 -3
  37. runtime/src/speech_to_speech/VAD/__init__.py +0 -1
  38. runtime/src/speech_to_speech/VAD/vad_handler.py +0 -870
  39. runtime/src/speech_to_speech/VAD/vad_iterator.py +0 -170
  40. runtime/src/speech_to_speech/__init__.py +0 -3
  41. runtime/src/speech_to_speech/api/__init__.py +0 -0
  42. runtime/src/speech_to_speech/api/openai_realtime/README.md +0 -244
  43. runtime/src/speech_to_speech/api/openai_realtime/__init__.py +0 -0
  44. runtime/src/speech_to_speech/api/openai_realtime/handlers/__init__.py +0 -11
  45. runtime/src/speech_to_speech/api/openai_realtime/handlers/audio.py +0 -224
  46. runtime/src/speech_to_speech/api/openai_realtime/handlers/base.py +0 -57
  47. runtime/src/speech_to_speech/api/openai_realtime/handlers/conversation.py +0 -128
  48. runtime/src/speech_to_speech/api/openai_realtime/handlers/response.py +0 -339
  49. runtime/src/speech_to_speech/api/openai_realtime/handlers/session.py +0 -62
  50. runtime/src/speech_to_speech/api/openai_realtime/pipeline_unit.py +0 -72
runtime/src/speech_to_speech/LLM/README.md DELETED
@@ -1,139 +0,0 @@
1
- # LLM Summary
2
-
3
- ## Available LLM backends (`--llm_backend`)
4
-
5
- Runtime-supported values in `s2s_pipeline.py`:
6
-
7
- - `transformers` → `language_model.py` (Transformers backend)
8
- - `mlx-lm` → `language_model.py` (MLX backend)
9
- - `responses-api` → `responses_api_language_model.py`
10
-
11
- ## Usage
12
-
13
- ### 1) Transformers (`--llm_backend transformers`)
14
-
15
- - Handler: `LanguageModelHandler`
16
- - Typical use: local GPU/CPU inference using Hugging Face Transformers
17
- - Backend-specific args prefix: `--llm_*`
18
- - Shared args (from base): `--model_name`, `--chat_size`, `--init_chat_prompt`, `--enable_lang_prompt`
19
-
20
- ```bash
21
- python s2s_pipeline.py \
22
- --llm_backend transformers \
23
- --model_name Qwen/Qwen3-4B-Instruct-2507 \
24
- --llm_device cuda \
25
- --llm_torch_dtype float16 \
26
- --llm_gen_max_new_tokens 128
27
- ```
28
-
29
- Common options:
30
- - `--llm_gen_min_new_tokens`
31
- - `--llm_gen_temperature`
32
- - `--llm_gen_do_sample`
33
- - `--chat_size`
34
- - `--init_chat_prompt`
35
-
36
- ### 2) MLX-LM (`--llm_backend mlx-lm`)
37
-
38
- - Handler: `LanguageModelHandler`
39
- - Typical use: Apple Silicon local inference
40
- - Backend-specific args prefix: same as Transformers (`--llm_*`)
41
-
42
- ```bash
43
- python s2s_pipeline.py \
44
- --llm_backend mlx-lm \
45
- --model_name mlx-community/Qwen3-4B-Instruct-2507-bf16 \
46
- --llm_device mps \
47
- --llm_gen_max_new_tokens 128
48
- ```
49
-
50
- Common options:
51
- - `--llm_gen_temperature`
52
- - `--llm_gen_do_sample`
53
- - `--chat_size`
54
- - `--init_chat_prompt`
55
-
56
- ### 3) OpenAI-compatible API (`--llm_backend responses-api`)
57
-
58
- - Handler: `ResponsesApiModelHandler`
59
- - Typical use: remote model serving via OpenAI-compatible endpoints
60
- - Backend-specific args prefix: `--responses_api_*`
61
- - Shared args (from base): `--model_name`, `--chat_size`, `--init_chat_prompt`, `--enable_lang_prompt`
62
-
63
- ```bash
64
- python s2s_pipeline.py \
65
- --llm_backend responses-api \
66
- --model_name gpt-5.4-mini \
67
- --responses_api_api_key YOUR_API_KEY \
68
- --responses_api_base_url https://api.example.com/v1 \
69
- --responses_api_stream true
70
- ```
71
-
72
- Common options:
73
- - `--chat_size`
74
- - `--init_chat_prompt`
75
- - `--user_role`
76
-
77
- ## LLM Behavior
78
-
79
- When STT is set to language auto-detection (`--language auto`), LLM handlers can receive `(text, language_code)` and prepend a language control instruction like:
80
-
81
- - `Please reply to my message in <language>.`
82
-
83
- This helps the assistant respond in the detected language. The behavior is opt-in via `--enable_lang_prompt` (shared across all backends); it defaults to `False`.
84
-
85
- ## Setup
86
-
87
- ### CUDA setup
88
-
89
- ```bash
90
- python s2s_pipeline.py \
91
- --llm_backend transformers \
92
- --model_name microsoft/Phi-3-mini-4k-instruct
93
- ```
94
-
95
- ### Local Mac setup
96
-
97
- ```bash
98
- python s2s_pipeline.py \
99
- --local_mac_optimal_settings \
100
- --model_name mlx-community/Qwen3-4B-Instruct-2507-bf16
101
- ```
102
-
103
- `--local_mac_optimal_settings` already sets `--llm_backend mlx-lm` and will default the model to `mlx-community/Qwen3-4B-Instruct-2507-bf16` if not overridden.
104
-
105
- ### Realtime (OpenAI-compatible) setup
106
-
107
- Run the server in realtime mode, then connect with the realtime client:
108
-
109
- ```bash
110
- # 1. Start the pipeline in realtime mode
111
- python s2s_pipeline.py \
112
- --mode realtime \
113
- --llm_backend mlx-lm \
114
- --model_name mlx-community/Qwen3-4B-Instruct-2507-bf16 \
115
- --ws_host 0.0.0.0 \
116
- --ws_port 8765
117
-
118
- # 2. Connect with the realtime client
119
- python listen_and_play_realtime.py --host 127.0.0.1 --port 8765
120
- ```
121
-
122
- Or with `--local_mac_optimal_settings` on Apple Silicon:
123
-
124
- ```bash
125
- python s2s_pipeline.py \
126
- --local_mac_optimal_settings \
127
- --mode realtime \
128
- --ws_host 0.0.0.0 \
129
- --ws_port 8765
130
- ```
131
-
132
- ### Remote API setup
133
-
134
- ```bash
135
- python s2s_pipeline.py \
136
- --llm_backend responses-api \
137
- --model_name gpt-5.4-mini \
138
- --responses_api_api_key YOUR_API_KEY
139
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/__init__.py DELETED
File without changes
runtime/src/speech_to_speech/LLM/audio_input_notifier.py DELETED
@@ -1,82 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from collections.abc import Iterator
5
- from queue import Queue
6
- from threading import Event
7
-
8
- from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
9
- from speech_to_speech.baseHandler import BaseHandler
10
- from speech_to_speech.pipeline.events import AudioInputCompletedEvent
11
- from speech_to_speech.pipeline.handler_types import LLMIn
12
- from speech_to_speech.pipeline.messages import GenerateResponseRequest, VADAudio
13
- from speech_to_speech.pipeline.queue_types import TextEventItem
14
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- class AudioInputNotifier(BaseHandler[VADAudio, LLMIn]):
20
- """Bridge final VAD audio directly into the LLM stage."""
21
-
22
- def setup(
23
- self,
24
- runtime_config: RuntimeConfig | None = None,
25
- should_listen: Event | None = None,
26
- sample_rate: int = 16000,
27
- speculative_turns: SpeculativeTurnTracker | None = None,
28
- text_output_queue: Queue[TextEventItem] | None = None,
29
- ) -> None:
30
- self.runtime_config = runtime_config
31
- self.should_listen = should_listen
32
- self.sample_rate = sample_rate
33
- self.speculative_turns = speculative_turns
34
- self.text_output_queue = text_output_queue
35
-
36
- def should_process_input(self, item: VADAudio) -> bool:
37
- if item.mode == "progressive":
38
- return False
39
- if self.speculative_turns is None or item.turn_id is None or item.turn_revision is None:
40
- return True
41
- return self.speculative_turns.is_latest_after_pending_reopen(item.turn_id, item.turn_revision)
42
-
43
- def process(self, vad_audio: VADAudio) -> Iterator[LLMIn]:
44
- audio_duration_s = len(vad_audio.audio) / self.sample_rate if self.sample_rate else 0.0
45
- logger.info(
46
- "Audio input completed: %.3fs turn=%s rev=%s",
47
- audio_duration_s,
48
- vad_audio.turn_id,
49
- vad_audio.turn_revision,
50
- )
51
-
52
- # Realtime mode has no setup-level RuntimeConfig. Route the completed
53
- # audio through RealtimeService so response lifecycle and usage
54
- # bookkeeping happen before the LLM request is queued.
55
- if self.runtime_config is None and self.text_output_queue is not None:
56
- self.text_output_queue.put(
57
- AudioInputCompletedEvent(
58
- audio=vad_audio.audio,
59
- audio_sample_rate=self.sample_rate,
60
- audio_duration_s=audio_duration_s,
61
- turn_id=vad_audio.turn_id,
62
- turn_revision=vad_audio.turn_revision,
63
- speech_stopped_at_s=vad_audio.created_at_s,
64
- )
65
- )
66
- return
67
-
68
- runtime_config = vad_audio.runtime_config or self.runtime_config
69
- if runtime_config is None:
70
- logger.error("AudioInputNotifier received audio without RuntimeConfig; dropping LLM request")
71
- if self.should_listen is not None:
72
- self.should_listen.set()
73
- return
74
-
75
- yield GenerateResponseRequest(
76
- runtime_config=runtime_config,
77
- audio=vad_audio.audio,
78
- audio_sample_rate=self.sample_rate,
79
- turn_id=vad_audio.turn_id,
80
- turn_revision=vad_audio.turn_revision,
81
- speech_stopped_at_s=vad_audio.created_at_s,
82
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/base_openai_compatible_language_model.py DELETED
@@ -1,833 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import io
5
- import ipaddress
6
- import logging
7
- import os
8
- import wave
9
- from abc import ABC, abstractmethod
10
- from collections.abc import Callable, Generator, Iterator
11
- from typing import Any, Literal, Optional
12
- from urllib.parse import urlparse
13
-
14
- import httpx
15
- import numpy as np
16
- from nltk import sent_tokenize
17
- from openai import OpenAI
18
- from openai.types.realtime.conversation_item import (
19
- RealtimeConversationItemAssistantMessage,
20
- RealtimeConversationItemFunctionCall,
21
- )
22
- from openai.types.realtime.realtime_conversation_item_assistant_message import (
23
- Content as AssistantContent,
24
- )
25
- from openai.types.responses import ResponseFunctionToolCall
26
- from pydantic import BaseModel, ConfigDict, Field
27
-
28
- from speech_to_speech.baseHandler import BaseHandler
29
- from speech_to_speech.LLM.chat import (
30
- Chat,
31
- ChatItemError,
32
- SupportedItem,
33
- build_active_chat,
34
- make_system_message,
35
- make_user_audio_message,
36
- make_user_message,
37
- )
38
- from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn, build_compactor
39
- from speech_to_speech.LLM.text_prompt import build_text_system_prompt
40
- from speech_to_speech.LLM.utils import remove_unspeechable, resolve_auto_language
41
- from speech_to_speech.LLM.voice_prompt import build_voice_system_prompt
42
- from speech_to_speech.pipeline.cancel_scope import CancelScope
43
- from speech_to_speech.pipeline.handler_types import LLMIn, LLMOut
44
- from speech_to_speech.pipeline.messages import (
45
- EndOfResponse,
46
- LLMResponseChunk,
47
- TokenUsage,
48
- )
49
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
50
- from speech_to_speech.utils.utils import is_out_of_band, response_wants_audio
51
-
52
- logger = logging.getLogger(__name__)
53
-
54
- # About 18–24 seconds of default SDK backoff before warmup fails.
55
- WARMUP_MAX_RETRIES = 6
56
-
57
-
58
- # ── Normalised provider events ────────────────────────────────────────────────
59
- # Each backend's stream/response is mapped to this small vocabulary so the shared
60
- # speech-pipeline logic (sentence batching, cancellation, history, token usage)
61
- # lives in one place. Subclasses differ only in how they produce these events.
62
-
63
-
64
- class TextDelta(BaseModel):
65
- """Incremental assistant text. Always RAW (unfiltered); the base applies
66
- ``remove_unspeechable`` for the audio path."""
67
-
68
- text: str
69
-
70
-
71
- class AssistantMessage(BaseModel):
72
- """A complete assistant turn to write back to history."""
73
-
74
- content: list[AssistantContent]
75
-
76
-
77
- class ToolCall(BaseModel):
78
- """A complete function tool call (``call_id`` / ``id`` already regenerated)."""
79
-
80
- item: ResponseFunctionToolCall
81
-
82
-
83
- class Usage(BaseModel):
84
- """Token accounting for the turn."""
85
-
86
- input_tokens: int
87
- output_tokens: int
88
-
89
-
90
- ProviderEvent = TextDelta | AssistantMessage | ToolCall | Usage
91
- SerializeFn = Callable[[Chat], Any]
92
- RequestFn = Callable[[Any, dict[str, Any]], Any]
93
- EventIteratorFn = Callable[[Any], Iterator[ProviderEvent]]
94
-
95
-
96
- class _Turn(BaseModel):
97
- """Per-request context threaded through generation (immutable for the turn)."""
98
-
99
- model_config = ConfigDict(arbitrary_types_allowed=True)
100
-
101
- language_code: Optional[str]
102
- gen: int | None
103
- runtime_config: Any
104
- response: Any
105
- turn_id: str | None
106
- turn_revision: int | None
107
- speech_stopped_at_s: float | None
108
- wants_audio: bool
109
-
110
-
111
- class _GenState(BaseModel):
112
- """Mutable accumulators collected while consuming a turn's events."""
113
-
114
- model_config = ConfigDict(arbitrary_types_allowed=True)
115
-
116
- tools: list[ResponseFunctionToolCall] = Field(default_factory=list)
117
- pending: list[SupportedItem] = Field(default_factory=list)
118
- recorded_item_ids: set[str] = Field(default_factory=set)
119
- recorded_call_ids: set[str] = Field(default_factory=set)
120
- clean_text: str = "" # filtered text, kept only for the debug log
121
- input_tokens: int = 0
122
- output_tokens: int = 0
123
-
124
-
125
- class BaseOpenAICompatibleHandler(BaseHandler[LLMIn, LLMOut], ABC):
126
- """Shared lifecycle for OpenAI-compatible LLM backends (Responses & Chat
127
- Completions).
128
-
129
- Subclasses implement four hooks — :meth:`warmup`,
130
- :meth:`_build_compaction_generate_fn`, :meth:`_serialize`, :meth:`_request`,
131
- :meth:`_iter_events` and :meth:`_build_optional_kwargs` — and inherit the
132
- request/response orchestration: speculative-turn gating, cancellation,
133
- sentence batching, text-only vs audio handling, history write-back, token
134
- usage, out-of-band handling and error termination.
135
- """
136
-
137
- # ── setup ─────────────────────────────────────────────────────────────────
138
-
139
- def setup(
140
- self,
141
- model_name: str = "gpt-5.4-mini",
142
- device: str = "cuda",
143
- gen_kwargs: dict[str, Any] = {},
144
- base_url: Optional[str] = None,
145
- api_key: Optional[str] = None,
146
- stream: bool = True,
147
- user_role: str = "user",
148
- cancel_scope: CancelScope | None = None,
149
- speculative_turns: SpeculativeTurnTracker | None = None,
150
- disable_thinking: bool = True,
151
- reasoning_effort: Optional[str] = None,
152
- request_timeout_s: float = 20.0,
153
- stream_batch_sentences: int = 3,
154
- enable_lang_prompt: bool = False,
155
- compact_history: bool = False,
156
- audio_max_tokens: int = 256,
157
- audio_temperature: float = 0.0,
158
- audio_content_type: Literal["input_audio", "audio_url"] = "input_audio",
159
- audio_history_turns: int = 1,
160
- **_kwargs: Any,
161
- ) -> None:
162
- self.cancel_scope = cancel_scope
163
- self.speculative_turns = speculative_turns
164
- self.model_name = model_name
165
- self.stream = stream
166
- self.stream_batch_sentences = max(1, stream_batch_sentences)
167
- self.enable_lang_prompt = enable_lang_prompt
168
- self.gen_kwargs = dict(gen_kwargs)
169
- self.audio_max_tokens = audio_max_tokens
170
- self.audio_temperature = audio_temperature
171
- if audio_content_type not in {"input_audio", "audio_url"}:
172
- raise ValueError("audio_content_type must be either 'input_audio' or 'audio_url'.")
173
- self.audio_content_type = audio_content_type
174
- self.audio_history_turns = max(0, audio_history_turns)
175
- self.request_timeout_s = float(request_timeout_s)
176
- self.request_timeout = httpx.Timeout(
177
- self.request_timeout_s,
178
- connect=min(10.0, self.request_timeout_s),
179
- )
180
-
181
- self.user_role = user_role
182
- if (
183
- api_key is None
184
- and not os.environ.get("OPENAI_API_KEY")
185
- and base_url is not None
186
- and self._is_local_base_url(base_url)
187
- ):
188
- api_key = "none"
189
- self.client = OpenAI(api_key=api_key, base_url=base_url)
190
- self._extra_body = self._build_extra_body(base_url, disable_thinking, reasoning_effort)
191
- self.compactor = build_compactor(self._build_compaction_generate_fn()) if compact_history else None
192
- self.warmup()
193
-
194
- @staticmethod
195
- def _is_official_openai(base_url: Optional[str]) -> bool:
196
- """Whether ``base_url`` points at the official OpenAI server.
197
-
198
- Normalises a trailing slash so ``https://api.openai.com/v1/`` is also
199
- recognised; the official server rejects the provider-specific extra_body
200
- keys we send to vLLM / the HF router.
201
- """
202
- if base_url is None:
203
- return False
204
- return base_url.rstrip("/") == "https://api.openai.com/v1"
205
-
206
- @staticmethod
207
- def _is_local_base_url(base_url: str) -> bool:
208
- """Whether *base_url* points at localhost or a loopback IP address."""
209
- host = urlparse(base_url).hostname
210
- if host is None:
211
- return False
212
- if host.rstrip(".").lower() == "localhost":
213
- return True
214
- try:
215
- return ipaddress.ip_address(host).is_loopback
216
- except ValueError:
217
- return False
218
-
219
- @classmethod
220
- def _build_extra_body(
221
- cls,
222
- base_url: Optional[str],
223
- disable_thinking: bool,
224
- reasoning_effort: Optional[str],
225
- ) -> Optional[dict[str, Any]]:
226
- """Build the provider-specific ``extra_body`` used to disable reasoning.
227
-
228
- Providers differ in how reasoning is turned off: vLLM/Qwen honour
229
- ``chat_template_kwargs.enable_thinking=false``, while others (e.g. GLM via
230
- the HF router) ignore that and require ``reasoning_effort='none'``. A
231
- non-empty ``reasoning_effort`` therefore takes precedence; otherwise we fall
232
- back to the chat-template flag. None of this applies to the official
233
- OpenAI server, which rejects unknown extra_body keys.
234
- """
235
- if base_url is None or cls._is_official_openai(base_url):
236
- return None
237
- if reasoning_effort:
238
- return {"reasoning_effort": reasoning_effort}
239
- if disable_thinking:
240
- return {"chat_template_kwargs": {"enable_thinking": False}}
241
- return None
242
-
243
- # ── subclass hooks ──────────────────────────────────────────────────────--
244
-
245
- @abstractmethod
246
- def warmup(self) -> None:
247
- """Issue a cheap request so the model/connection is ready before serving."""
248
- ...
249
-
250
- @abstractmethod
251
- def _build_compaction_generate_fn(self) -> CompactGenerateFn:
252
- """Return a ``(system, user) -> text`` fn used to compact long histories."""
253
- ...
254
-
255
- @abstractmethod
256
- def _serialize(self, active_chat: Chat) -> Any:
257
- """Serialise the chat to the backend's request payload (input/messages)."""
258
- ...
259
-
260
- @abstractmethod
261
- def _request(self, api_input: Any, optional_kwargs: dict[str, Any], runtime_config: Any) -> Any:
262
- """Issue the create() call and return the response or stream."""
263
- ...
264
-
265
- @abstractmethod
266
- def _iter_stream_events(self, api_response: Any) -> Iterator[ProviderEvent]:
267
- """Map a streaming response to normalised :data:`ProviderEvent`s."""
268
- ...
269
-
270
- @abstractmethod
271
- def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]:
272
- """Map a non-streaming response to normalised :data:`ProviderEvent`s."""
273
- ...
274
-
275
- def _iter_events(self, api_response: Any) -> Iterator[ProviderEvent]:
276
- """Dispatch to the stream/non-stream mapper. ``self.stream`` is the single
277
- source of truth (it set the request's ``stream=`` flag), so the response
278
- type always matches it."""
279
- if self.stream:
280
- yield from self._iter_stream_events(api_response)
281
- else:
282
- yield from self._iter_response_events(api_response)
283
-
284
- @abstractmethod
285
- def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]:
286
- """Build the per-request tools/tool_choice kwargs in the backend's shape."""
287
- ...
288
-
289
- # ── audio-input protocol hooks ───────────────────────────────────────────
290
-
291
- def _serialize_audio(self, active_chat: Chat) -> Any:
292
- """Serialize an audio turn using the selected backend's native protocol."""
293
- return self._serialize(active_chat)
294
-
295
- def _build_audio_optional_kwargs(
296
- self,
297
- response: Any,
298
- req_tools: Any,
299
- req_tool_choice: Any,
300
- ) -> dict[str, Any]:
301
- """Build audio request parameters in the selected backend's shape."""
302
- kwargs = self._build_optional_kwargs(req_tools, req_tool_choice)
303
- max_tokens = getattr(response, "max_output_tokens", None) if response is not None else None
304
- kwargs.setdefault("max_tokens", max_tokens or self.audio_max_tokens)
305
- kwargs.setdefault("temperature", self.audio_temperature)
306
- return kwargs
307
-
308
- def _request_audio(self, api_input: Any, optional_kwargs: dict[str, Any], runtime_config: Any) -> Any:
309
- return self._request(api_input, optional_kwargs, runtime_config)
310
-
311
- def _iter_audio_events(self, api_response: Any) -> Iterator[ProviderEvent]:
312
- yield from self._iter_events(api_response)
313
-
314
- @staticmethod
315
- def _audio_to_wav_base64(audio: np.ndarray, sample_rate: int) -> str:
316
- """Encode a mono 16-bit WAV payload without touching the filesystem."""
317
- audio_array = np.asarray(audio)
318
- if audio_array.ndim > 1:
319
- audio_array = np.mean(audio_array, axis=1)
320
- if np.issubdtype(audio_array.dtype, np.floating):
321
- pcm = (np.clip(audio_array, -1.0, 1.0) * 32767.0).astype("<i2")
322
- else:
323
- pcm = np.clip(audio_array, -32768, 32767).astype("<i2")
324
-
325
- with io.BytesIO() as wav_io:
326
- with wave.open(wav_io, "wb") as wav_file:
327
- wav_file.setnchannels(1)
328
- wav_file.setsampwidth(2)
329
- wav_file.setframerate(sample_rate)
330
- wav_file.writeframes(pcm.tobytes())
331
- return base64.b64encode(wav_io.getvalue()).decode("ascii")
332
-
333
- # ── speculative-turn / cancellation gating ─────────────────────────────────
334
-
335
- def _turn_is_latest(self, turn_id: str | None, turn_revision: int | None) -> bool:
336
- return self.speculative_turns is None or self.speculative_turns.is_latest(turn_id, turn_revision)
337
-
338
- def _generation_is_stale(self, gen: int | None) -> bool:
339
- return gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen)
340
-
341
- def _turn_output_allowed(self, turn_id: str | None, turn_revision: int | None) -> bool:
342
- if self.speculative_turns is None:
343
- return True
344
- return self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision)
345
-
346
- def _apply_config(
347
- self,
348
- chat: Chat,
349
- instructions: Optional[str],
350
- wants_audio: bool = True,
351
- ) -> None:
352
- if instructions:
353
- builder = build_voice_system_prompt if wants_audio else build_text_system_prompt
354
- full_instructions = builder(instructions)
355
- chat.add_item(make_system_message(full_instructions))
356
-
357
- # ── output helpers ──────────────────────────────────────────────────────--
358
-
359
- def _chunk(
360
- self,
361
- turn: _Turn,
362
- *,
363
- text: str = "",
364
- tools: list[ResponseFunctionToolCall] | None = None,
365
- language_code: Optional[str] = None,
366
- ) -> LLMResponseChunk:
367
- return LLMResponseChunk(
368
- text=text,
369
- language_code=language_code if language_code is not None else turn.language_code,
370
- tools=tools or [],
371
- runtime_config=turn.runtime_config,
372
- response=turn.response,
373
- turn_id=turn.turn_id,
374
- turn_revision=turn.turn_revision,
375
- speech_stopped_at_s=turn.speech_stopped_at_s,
376
- cancel_generation=turn.gen,
377
- )
378
-
379
- def _record_tool_call(self, state: _GenState, turn: _Turn, item: ResponseFunctionToolCall) -> Iterator[LLMOut]:
380
- """Emit a tool call, persisting it (and any assistant text seen so far)
381
- to history *before* it is forwarded to the client.
382
-
383
- The function_call must already exist in the conversation by the time the
384
- client returns its ``function_call_output``; otherwise a fast client
385
- races ahead of the deferred end-of-turn write-back and the output is
386
- rejected ("No function_call with call_id ... found"), which makes the
387
- model re-issue the same tool call. The call lands in ``_pending_tool_calls``
388
- (not serialized until its output pairs it), so eager recording is safe.
389
-
390
- Out-of-band turns never touch the default conversation, and a stale turn
391
- records nothing (it is not forwarded to the client either)."""
392
- state.tools.append(item)
393
- fc_item = RealtimeConversationItemFunctionCall(
394
- type="function_call",
395
- name=item.name,
396
- arguments=item.arguments,
397
- call_id=item.call_id,
398
- id=item.id,
399
- status=item.status,
400
- )
401
- if self._generation_is_stale(turn.gen) or not self._turn_output_allowed(turn.turn_id, turn.turn_revision):
402
- logger.info("LLM generation cancelled (stale speculative turn)")
403
- return
404
- if not is_out_of_band(turn.response):
405
- # Flush assistant text accumulated before this call first (so history
406
- # order matches what the client received), then persist the call —
407
- # all before the chunk leaves for the client.
408
- chat = turn.runtime_config.chat
409
- for pending_item in state.pending:
410
- recorded = chat.add_item(pending_item)
411
- if recorded.id is not None:
412
- state.recorded_item_ids.add(recorded.id)
413
- state.pending.clear()
414
- recorded_call = chat.add_item(fc_item)
415
- if recorded_call.id is not None:
416
- state.recorded_item_ids.add(recorded_call.id)
417
- state.recorded_call_ids.add(item.call_id)
418
- yield self._chunk(turn, tools=[item])
419
-
420
- # ── consumption ─────────────────────────────────────────────────────────--
421
-
422
- def _consume_streaming(
423
- self,
424
- events: Iterator[ProviderEvent],
425
- state: _GenState,
426
- turn: _Turn,
427
- ) -> Generator[LLMOut, None, bool]:
428
- cancelled = False
429
- printable_text = ""
430
- sentence_batch: list[str] = []
431
-
432
- def _flush(batch: list[str]) -> Iterator[LLMOut]:
433
- if not batch:
434
- return
435
- if not self._turn_output_allowed(turn.turn_id, turn.turn_revision):
436
- logger.info("LLM generation cancelled (stale speculative turn)")
437
- return
438
- yield self._chunk(turn, text=" ".join(batch))
439
-
440
- for event in events:
441
- if self._generation_is_stale(turn.gen) or not self._turn_is_latest(turn.turn_id, turn.turn_revision):
442
- logger.info("LLM generation cancelled (interruption)")
443
- cancelled = True
444
- break
445
-
446
- if isinstance(event, Usage):
447
- state.input_tokens = event.input_tokens
448
- state.output_tokens = event.output_tokens
449
- elif isinstance(event, AssistantMessage):
450
- state.pending.append(
451
- RealtimeConversationItemAssistantMessage(type="message", role="assistant", content=event.content)
452
- )
453
- elif isinstance(event, ToolCall):
454
- # Flush any pending spoken text before emitting the tool call.
455
- if printable_text.strip():
456
- sentence_batch.append(printable_text.strip())
457
- printable_text = ""
458
- if sentence_batch:
459
- if not self._turn_output_allowed(turn.turn_id, turn.turn_revision):
460
- logger.info("LLM generation cancelled (stale speculative turn)")
461
- cancelled = True
462
- break
463
- yield from _flush(sentence_batch)
464
- sentence_batch = []
465
- yield from self._record_tool_call(state, turn, event.item)
466
- elif isinstance(event, TextDelta):
467
- if not turn.wants_audio:
468
- # Text-only: forward verbatim. Keep every character (no
469
- # remove_unspeechable, which strips TTS-unfriendly symbols) and
470
- # don't sentence-split (sent_tokenize collapses newlines/markdown).
471
- state.clean_text += event.text
472
- if event.text:
473
- if not self._turn_output_allowed(turn.turn_id, turn.turn_revision):
474
- logger.info("LLM generation cancelled (stale speculative turn)")
475
- cancelled = True
476
- break
477
- yield self._chunk(turn, text=event.text)
478
- continue
479
- new_text = remove_unspeechable(event.text)
480
- state.clean_text += new_text
481
- printable_text += new_text
482
- sentences = sent_tokenize(printable_text)
483
- if len(sentences) > 1:
484
- for s in sentences[:-1]:
485
- sentence_batch.append(s)
486
- if len(sentence_batch) >= self.stream_batch_sentences:
487
- if not self._turn_output_allowed(turn.turn_id, turn.turn_revision):
488
- logger.info("LLM generation cancelled (stale speculative turn)")
489
- cancelled = True
490
- break
491
- yield from _flush(sentence_batch)
492
- sentence_batch = []
493
- if cancelled:
494
- break
495
- printable_text = sentences[-1]
496
-
497
- if not cancelled:
498
- if printable_text.strip():
499
- sentence_batch.append(printable_text.strip())
500
- if sentence_batch:
501
- if self._generation_is_stale(turn.gen):
502
- logger.info("LLM generation cancelled (interruption)")
503
- else:
504
- logger.debug(f"Clean text: {state.clean_text}")
505
- yield from _flush(sentence_batch)
506
- logger.info(f"Tools: {state.tools}")
507
- return (
508
- not cancelled
509
- and not self._generation_is_stale(turn.gen)
510
- and self._turn_is_latest(turn.turn_id, turn.turn_revision)
511
- and self._turn_output_allowed(turn.turn_id, turn.turn_revision)
512
- )
513
-
514
- def _consume_nonstreaming(
515
- self,
516
- events: Iterator[ProviderEvent],
517
- state: _GenState,
518
- turn: _Turn,
519
- ) -> Generator[LLMOut, None, bool]:
520
- if self._generation_is_stale(turn.gen) or not self._turn_is_latest(turn.turn_id, turn.turn_revision):
521
- logger.info("LLM generation cancelled (interruption)")
522
- return False
523
- for event in events:
524
- if isinstance(event, Usage):
525
- state.input_tokens = event.input_tokens
526
- state.output_tokens = event.output_tokens
527
- elif isinstance(event, AssistantMessage):
528
- state.pending.append(
529
- RealtimeConversationItemAssistantMessage(type="message", role="assistant", content=event.content)
530
- )
531
- elif isinstance(event, ToolCall):
532
- yield from self._record_tool_call(state, turn, event.item)
533
- elif isinstance(event, TextDelta):
534
- # Text-only keeps every character verbatim; audio strips
535
- # TTS-unfriendly symbols via remove_unspeechable.
536
- spoken = event.text if not turn.wants_audio else remove_unspeechable(event.text)
537
- state.clean_text += spoken
538
- out = spoken if not turn.wants_audio else spoken.strip()
539
- if (
540
- out
541
- and not self._generation_is_stale(turn.gen)
542
- and self._turn_output_allowed(turn.turn_id, turn.turn_revision)
543
- ):
544
- yield self._chunk(turn, text=out)
545
- logger.debug(f"Clean text: {state.clean_text}")
546
- logger.info(f"Tools: {state.tools}")
547
- return (
548
- not self._generation_is_stale(turn.gen)
549
- and self._turn_is_latest(turn.turn_id, turn.turn_revision)
550
- and self._turn_output_allowed(turn.turn_id, turn.turn_revision)
551
- )
552
-
553
- # ── orchestration ─────────────────────────────────────────────────────────
554
-
555
- def _generate(
556
- self,
557
- active_chat: Chat,
558
- original_chat: Chat,
559
- turn: _Turn,
560
- optional_kwargs: dict[str, Any],
561
- *,
562
- serialize_fn: SerializeFn | None = None,
563
- request_fn: RequestFn | None = None,
564
- event_iterator_fn: EventIteratorFn | None = None,
565
- transactional_user_message_id: str | None = None,
566
- history_commit_fn: Callable[[], None] | None = None,
567
- ) -> Generator[LLMOut, None, bool]:
568
- api_response: Any = None
569
- state = _GenState()
570
- error_message: str | None = None
571
- generation_completed = False
572
- history_committed = False
573
- transaction_rolled_back = False
574
- consumed_image_ids: set[str] = set()
575
-
576
- def rollback_transaction() -> None:
577
- nonlocal transaction_rolled_back
578
- if transactional_user_message_id is None or history_committed or transaction_rolled_back:
579
- return
580
- original_chat.rollback_generation(
581
- transactional_user_message_id,
582
- item_ids=state.recorded_item_ids,
583
- call_ids=state.recorded_call_ids,
584
- )
585
- transaction_rolled_back = True
586
-
587
- try:
588
- try:
589
- api_input = (serialize_fn or self._serialize)(active_chat)
590
- # Images the model actually sees this turn; only these are stripped on
591
- # write-back, so an image a fast client injects mid-generation for the
592
- # next turn survives (it is not in this serialized snapshot).
593
- consumed_image_ids = active_chat.image_message_ids()
594
- if not api_input:
595
- # Nothing to send: empty `instructions` and no `input` (in the response,
596
- # the default conversation, or the out-of-band context). The provider
597
- # would reject this; fail with a clear message instead of an opaque error.
598
- error_message = "Cannot generate a response: no instructions and no input were provided."
599
- else:
600
- if request_fn is not None:
601
- api_response = request_fn(api_input, optional_kwargs)
602
- else:
603
- api_response = self._request(api_input, optional_kwargs, turn.runtime_config)
604
- if api_response is not None:
605
- events = (event_iterator_fn or self._iter_events)(api_response)
606
- if self.stream:
607
- generation_completed = yield from self._consume_streaming(events, state, turn)
608
- else:
609
- generation_completed = yield from self._consume_nonstreaming(events, state, turn)
610
- except httpx.ReadTimeout:
611
- logger.warning(
612
- "OpenAI API read timed out after %.1fs; ending the current response",
613
- self.request_timeout_s,
614
- )
615
- if not self._generation_is_stale(turn.gen) and self._turn_output_allowed(
616
- turn.turn_id, turn.turn_revision
617
- ):
618
- # Canned apology carries no language_code (mirrors the prior handlers).
619
- yield LLMResponseChunk(
620
- text="Wow I'm a bit slow today, could you repeat that?",
621
- runtime_config=turn.runtime_config,
622
- response=turn.response,
623
- turn_id=turn.turn_id,
624
- turn_revision=turn.turn_revision,
625
- speech_stopped_at_s=turn.speech_stopped_at_s,
626
- cancel_generation=turn.gen,
627
- )
628
- except Exception as exc:
629
- # Any other generation failure must still terminate the response: record
630
- # the error and fall through to the EndOfResponse below. Without this the
631
- # exception would escape process() and no EndOfResponse would be emitted,
632
- # leaving st.in_response stuck and locking every subsequent response.
633
- logger.exception("LLM generation failed; ending the current response")
634
- if error_message is None:
635
- error_message = f"Language model generation failed: {exc}"
636
-
637
- can_commit = (
638
- error_message is None
639
- and generation_completed
640
- and not self._generation_is_stale(turn.gen)
641
- and self._turn_is_latest(turn.turn_id, turn.turn_revision)
642
- and self._turn_output_allowed(turn.turn_id, turn.turn_revision)
643
- )
644
- if can_commit:
645
- try:
646
- # Out-of-band responses emit output and usage but never write back to the
647
- # default conversation (their context was a throwaway chat).
648
- if not is_out_of_band(turn.response):
649
- # Tool calls (and any assistant text preceding them) were already
650
- # written eagerly in _record_tool_call; only trailing items remain.
651
- for item in state.pending:
652
- recorded = original_chat.add_item(item)
653
- if recorded.id is not None:
654
- state.recorded_item_ids.add(recorded.id)
655
- original_chat.strip_images(consumed_image_ids)
656
- if history_commit_fn is not None:
657
- history_commit_fn()
658
- original_chat.trim_if_needed(self.compactor)
659
- history_committed = True
660
- except Exception as exc:
661
- logger.exception("LLM history commit failed; rolling back the current response")
662
- error_message = f"Language model history commit failed: {exc}"
663
-
664
- rollback_transaction()
665
- if history_committed and (state.input_tokens or state.output_tokens):
666
- yield TokenUsage(
667
- input_tokens=state.input_tokens,
668
- output_tokens=state.output_tokens,
669
- turn_id=turn.turn_id,
670
- turn_revision=turn.turn_revision,
671
- )
672
- yield EndOfResponse(
673
- turn_id=turn.turn_id,
674
- turn_revision=turn.turn_revision,
675
- cancel_generation=turn.gen,
676
- error=error_message,
677
- )
678
- return history_committed
679
- finally:
680
- if api_response is not None and hasattr(api_response, "close"):
681
- try:
682
- api_response.close()
683
- except Exception:
684
- pass
685
- rollback_transaction()
686
-
687
- def _process_audio(self, request: LLMIn) -> Iterator[LLMOut]:
688
- """Process an audio-input turn through the selected backend protocol."""
689
- assert request.audio is not None
690
- runtime_config = request.runtime_config
691
- response = request.response
692
- turn_id = request.turn_id
693
- turn_revision = request.turn_revision
694
- speech_stopped_at_s = request.speech_stopped_at_s
695
- if not self._turn_is_latest(turn_id, turn_revision):
696
- logger.info("Skipping stale LLM request for turn=%s rev=%s", turn_id, turn_revision)
697
- yield EndOfResponse(turn_id=turn_id, turn_revision=turn_revision)
698
- return
699
-
700
- original_chat = runtime_config.chat
701
- if is_out_of_band(response):
702
- try:
703
- active_chat = build_active_chat(original_chat, response)
704
- except ChatItemError as exc:
705
- logger.info("Out-of-band response rejected: %s", exc)
706
- yield EndOfResponse(turn_id=turn_id, turn_revision=turn_revision, error=str(exc))
707
- return
708
- else:
709
- active_chat = original_chat.copy()
710
-
711
- language_code = request.language_code
712
- instructions = (
713
- response.instructions if response and response.instructions else runtime_config.session.instructions
714
- ) or ""
715
- req_tools = response.tools if response and response.tools else runtime_config.session.tools
716
- req_tool_choice = (
717
- response.tool_choice if response and response.tool_choice else runtime_config.session.tool_choice
718
- )
719
- wants_audio = response_wants_audio(response)
720
- self._apply_config(active_chat, instructions, wants_audio)
721
- language_code, lang_name = resolve_auto_language(language_code)
722
- if lang_name and self.enable_lang_prompt:
723
- active_chat.add_item(make_user_message(f"Please reply to my message in {lang_name}."))
724
-
725
- audio_b64 = self._audio_to_wav_base64(request.audio, request.audio_sample_rate)
726
- audio_message = active_chat.add_item(make_user_audio_message(audio_b64))
727
- optional_kwargs = self._build_audio_optional_kwargs(response, req_tools, req_tool_choice)
728
-
729
- transactional_user_message_id: str | None = None
730
- history_commit_fn: Callable[[], None] | None = None
731
- if not is_out_of_band(response):
732
- provisional_message = make_user_audio_message(audio_b64)
733
- provisional_message.id = audio_message.id
734
- original_chat.add_item(provisional_message)
735
- assert provisional_message.id is not None
736
- transactional_user_message_id = provisional_message.id
737
-
738
- def commit_audio_history() -> None:
739
- original_chat.compact_audio_history(self.audio_history_turns)
740
-
741
- history_commit_fn = commit_audio_history
742
-
743
- # CancelScope.is_stale(gen) is checked when the stream iterator advances; a
744
- # blocked read inside httpx cannot be aborted by cancel_scope.cancel() from
745
- # the websocket router. Mitigations: request_timeout_s / ReadTimeout.
746
- gen = self.cancel_scope.generation if self.cancel_scope else None
747
- turn = _Turn(
748
- language_code=language_code,
749
- gen=gen,
750
- runtime_config=runtime_config,
751
- response=response,
752
- turn_id=turn_id,
753
- turn_revision=turn_revision,
754
- speech_stopped_at_s=speech_stopped_at_s,
755
- wants_audio=wants_audio,
756
- )
757
- yield from self._generate(
758
- active_chat,
759
- original_chat,
760
- turn,
761
- optional_kwargs,
762
- serialize_fn=self._serialize_audio,
763
- request_fn=lambda api_input, kwargs: self._request_audio(api_input, kwargs, runtime_config),
764
- event_iterator_fn=self._iter_audio_events,
765
- transactional_user_message_id=transactional_user_message_id,
766
- history_commit_fn=history_commit_fn,
767
- )
768
-
769
- def process(self, request: LLMIn) -> Iterator[LLMOut]:
770
- """Process a language model request and yield LLMResponseChunks."""
771
- if request.audio is not None:
772
- yield from self._process_audio(request)
773
- return
774
-
775
- runtime_config = request.runtime_config
776
- response = request.response
777
- turn_id = request.turn_id
778
- turn_revision = request.turn_revision
779
- speech_stopped_at_s = request.speech_stopped_at_s
780
- if not self._turn_is_latest(turn_id, turn_revision):
781
- logger.info("Skipping stale LLM request for turn=%s rev=%s", turn_id, turn_revision)
782
- yield EndOfResponse(turn_id=turn_id, turn_revision=turn_revision)
783
- return
784
-
785
- original_chat = runtime_config.chat
786
- if is_out_of_band(response):
787
- try:
788
- active_chat = build_active_chat(original_chat, response)
789
- except ChatItemError as exc:
790
- logger.info("Out-of-band response rejected: %s", exc)
791
- yield EndOfResponse(turn_id=turn_id, turn_revision=turn_revision, error=str(exc))
792
- return
793
- else:
794
- active_chat = original_chat.copy()
795
- language_code = request.language_code
796
- instructions = (
797
- response.instructions if response and response.instructions else runtime_config.session.instructions
798
- ) or ""
799
- req_tools = response.tools if response and response.tools else runtime_config.session.tools
800
- req_tool_choice = (
801
- response.tool_choice if response and response.tool_choice else runtime_config.session.tool_choice
802
- )
803
- wants_audio = response_wants_audio(response)
804
- self._apply_config(active_chat, instructions, wants_audio)
805
- language_code, lang_name = resolve_auto_language(language_code)
806
- if lang_name and self.enable_lang_prompt:
807
- active_chat.add_item(make_user_message(f"Please reply to my message in {lang_name}."))
808
-
809
- optional_kwargs = self._build_optional_kwargs(req_tools, req_tool_choice)
810
-
811
- # CancelScope.is_stale(gen) is checked when the stream iterator advances; a
812
- # blocked read inside httpx cannot be aborted by cancel_scope.cancel() from
813
- # the websocket router. Mitigations: request_timeout_s / ReadTimeout.
814
- gen = self.cancel_scope.generation if self.cancel_scope else None
815
-
816
- turn = _Turn(
817
- language_code=language_code,
818
- gen=gen,
819
- runtime_config=runtime_config,
820
- response=response,
821
- turn_id=turn_id,
822
- turn_revision=turn_revision,
823
- speech_stopped_at_s=speech_stopped_at_s,
824
- wants_audio=wants_audio,
825
- )
826
- yield from self._generate(active_chat, original_chat, turn, optional_kwargs)
827
-
828
- @property
829
- def timing_log_level(self) -> int:
830
- return logging.INFO
831
-
832
- def should_log_timing(self, output: LLMOut) -> bool:
833
- return isinstance(output, LLMResponseChunk) and self.last_time > self.min_time_to_debug
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/chat.py DELETED
@@ -1,835 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import logging
5
- import threading
6
- from collections.abc import Callable
7
- from typing import Any, Literal, Union
8
-
9
- from openai.types.realtime import ConversationItem
10
- from openai.types.realtime.conversation_item import (
11
- RealtimeConversationItemAssistantMessage,
12
- RealtimeConversationItemFunctionCall,
13
- RealtimeConversationItemFunctionCallOutput,
14
- RealtimeConversationItemSystemMessage,
15
- RealtimeConversationItemUserMessage,
16
- )
17
- from openai.types.realtime.realtime_conversation_item_assistant_message import (
18
- Content as AssistantContent,
19
- )
20
- from openai.types.realtime.realtime_conversation_item_system_message import Content as SystemContent
21
- from openai.types.realtime.realtime_conversation_item_user_message import Content as UserContent
22
- from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
23
- from openai.types.responses.response_input_image_param import ResponseInputImageParam
24
- from openai.types.responses.response_input_message_content_list_param import (
25
- ResponseInputMessageContentListParam,
26
- )
27
- from openai.types.responses.response_input_param import (
28
- FunctionCallOutput,
29
- ResponseFunctionToolCallParam,
30
- ResponseInputItemParam,
31
- ResponseInputParam,
32
- ResponseOutputMessageParam,
33
- )
34
- from openai.types.responses.response_input_param import (
35
- Message as ResponseMessage,
36
- )
37
- from openai.types.responses.response_input_text_param import ResponseInputTextParam
38
- from openai.types.responses.response_output_text_param import ResponseOutputTextParam
39
- from pydantic import BaseModel
40
-
41
- from speech_to_speech.utils.utils import _generate_id
42
-
43
- logger = logging.getLogger(__name__)
44
-
45
- AUDIO_INPUT_HISTORY_PLACEHOLDER = "[User audio input]"
46
-
47
-
48
- class ChatItemError(Exception):
49
- """Raised when a conversation item fails validation in :meth:`Chat.add_item`."""
50
-
51
-
52
- class CompactionResult(BaseModel):
53
- """Output of a :data:`CompactFn` summarization run."""
54
-
55
- user_summary: str
56
- assistant_summary: str
57
-
58
-
59
- def _ensure_id(value: str | None, prefix: str) -> str:
60
- if value is None:
61
- return _generate_id(prefix)
62
- if not value.startswith(f"{prefix}_"):
63
- raise ChatItemError(f"ID must start with '{prefix}_', got {value!r}")
64
- return value
65
-
66
-
67
- SupportedItem = Union[
68
- RealtimeConversationItemSystemMessage,
69
- RealtimeConversationItemUserMessage,
70
- RealtimeConversationItemAssistantMessage,
71
- RealtimeConversationItemFunctionCall,
72
- RealtimeConversationItemFunctionCallOutput,
73
- ]
74
-
75
-
76
- CompactFn = Callable[[ResponseInputParam], CompactionResult]
77
-
78
-
79
- class Chat:
80
- """Manages conversation history with bounded size to avoid OOM issues.
81
-
82
- The buffer stores ``ConversationItem`` objects (user messages, assistant
83
- messages, function calls, function call outputs). System messages are
84
- stored separately in ``init_chat_message`` and never placed in the buffer.
85
-
86
- History bounding is decided per ``add_item`` call via the ``compactor``
87
- argument:
88
-
89
- - ``compactor=None``: when the user-turn count exceeds ``size`` the oldest
90
- complete turn is evicted in place. Synchronous, lossy, no LLM involvement.
91
- - ``compactor=<fn>``: when ``size`` is exceeded, ``fn`` is invoked in a
92
- background thread to summarize older turns into a single user/assistant
93
- pair (with pending function calls preserved). Single-flight: while a
94
- compaction is running, additional triggers are silently bypassed.
95
- """
96
-
97
- def __init__(self, size: int) -> None:
98
- self.size = size
99
- self.init_chat_message: RealtimeConversationItemSystemMessage | None = None
100
- # ``size`` is the number of user turns to keep. When exceeded the
101
- # oldest complete turn (everything up to the next user message)
102
- # is evicted -- or, with a compactor, summarized in the background.
103
- self.buffer: list[SupportedItem] = []
104
- self._pending_tool_calls: dict[str, RealtimeConversationItemFunctionCall] = {}
105
- self._user_turn_count: int = 0
106
-
107
- # All state mutations and serializations go through _lock. Public methods
108
- # acquire it once; internal callers that already hold it use the
109
- # ``_locked`` helpers, so no reentry is needed (regular Lock is safe).
110
- self._lock = threading.Lock()
111
- self._compact_in_flight: bool = False
112
- self._compact_thread: threading.Thread | None = None
113
- self._shutdown = threading.Event()
114
- self._gen_counter = 0
115
-
116
- # ── Internal mutators (caller holds _lock) ─────────────────
117
-
118
- def _evict_oldest_turn(self) -> None:
119
- """Remove items from the front until the next user message boundary."""
120
- if not self.buffer:
121
- return
122
- first = self.buffer.pop(0)
123
- if isinstance(first, RealtimeConversationItemUserMessage):
124
- self._user_turn_count -= 1
125
- while self.buffer and not isinstance(self.buffer[0], RealtimeConversationItemUserMessage):
126
- self.buffer.pop(0)
127
-
128
- def _has_call_id_in_buffer(self, call_id: str) -> bool:
129
- for entry in self.buffer:
130
- if isinstance(entry, RealtimeConversationItemFunctionCall) and entry.call_id == call_id:
131
- return True
132
- return False
133
-
134
- def _mark_call_completed(
135
- self, call_id: str, status: Literal["completed", "incomplete", "in_progress"] | None = None
136
- ) -> None:
137
- """Set ``status`` to ``"completed"`` on the matching function_call."""
138
- for entry in self.buffer:
139
- if isinstance(entry, RealtimeConversationItemFunctionCall) and entry.call_id == call_id:
140
- entry.status = "completed" if status is None else status
141
- return
142
-
143
- def append_tool_output(self, call_id: str, output_item: RealtimeConversationItemFunctionCallOutput) -> None:
144
- """Append a ``function_call_output``, re-injecting its ``function_call`` if evicted.
145
-
146
- Also marks the paired ``function_call`` as ``"completed"`` if its
147
- status was ``None``.
148
-
149
- Raises :class:`ChatItemError` if *call_id* is unknown.
150
- """
151
- with self._lock:
152
- self._append_tool_output_locked(call_id, output_item)
153
-
154
- def _append_tool_output_locked(self, call_id: str, output_item: RealtimeConversationItemFunctionCallOutput) -> None:
155
- """Body of :meth:`append_tool_output`. Caller must hold ``_lock``."""
156
- if self._has_call_id_in_buffer(call_id):
157
- self._pending_tool_calls.pop(call_id, None)
158
- self._mark_call_completed(call_id, output_item.status)
159
- self.buffer.append(output_item)
160
- return
161
-
162
- if call_id in self._pending_tool_calls:
163
- logger.info("Re-injecting evicted function_call for call_id=%s", call_id)
164
- fc = self._pending_tool_calls.pop(call_id)
165
- fc.status = "completed" if output_item.status is None else output_item.status
166
- self.buffer.append(fc)
167
- self.buffer.append(output_item)
168
- return
169
-
170
- raise ChatItemError(f"No function_call with call_id '{call_id}' found in conversation history.")
171
-
172
- def init_chat(self, message: RealtimeConversationItemSystemMessage) -> None:
173
- with self._lock:
174
- self.init_chat_message = message
175
-
176
- def add_item(self, item: SupportedItem) -> SupportedItem:
177
- """Validate and route a conversation item into the chat buffer.
178
-
179
- Does not enforce the soft size limit — call :meth:`trim_if_needed`
180
- explicitly after each successful generation to evict or compact old
181
- turns. A hard upper bound at ``2 * size`` is enforced inline as a
182
- runaway-client safety net: if the user-turn count exceeds it, the
183
- oldest complete turn is evicted (lossy, no compaction).
184
-
185
- Raises :class:`ChatItemError` if the item fails validation.
186
- """
187
- with self._lock:
188
- if isinstance(item, RealtimeConversationItemSystemMessage):
189
- item.id = _ensure_id(item.id, "sys")
190
- self.init_chat_message = item
191
- logger.debug("Set system message via conversation item")
192
-
193
- elif isinstance(item, RealtimeConversationItemUserMessage):
194
- item.id = _ensure_id(item.id, "msg")
195
- item.content = [
196
- p
197
- for p in item.content
198
- if (p.type == "input_text" and p.text)
199
- or (p.type == "input_image" and p.image_url)
200
- or (p.type == "input_audio" and p.audio)
201
- ]
202
- if not item.content:
203
- raise ChatItemError(
204
- "Message has no supported content. Supported modalities: input_text, input_image, input_audio."
205
- )
206
- self.buffer.append(item)
207
- self._user_turn_count += 1
208
- logger.debug("Added user message to chat (%d parts)", len(item.content))
209
-
210
- elif isinstance(item, RealtimeConversationItemAssistantMessage):
211
- item.id = _ensure_id(item.id, "msg")
212
- item.content = [p for p in item.content if p.type == "output_text" and p.text]
213
- if not item.content:
214
- return item
215
- self.buffer.append(item)
216
- logger.debug("Added assistant message to chat (%d parts)", len(item.content))
217
-
218
- elif isinstance(item, RealtimeConversationItemFunctionCall):
219
- item.id = _ensure_id(item.id, "fc")
220
- item.call_id = _ensure_id(item.call_id, "call")
221
- self._pending_tool_calls[item.call_id] = item
222
- logger.debug("Added function_call to chat (call_id=%s)", item.call_id)
223
-
224
- elif isinstance(item, RealtimeConversationItemFunctionCallOutput):
225
- item.id = _ensure_id(item.id, "fco")
226
- self._append_tool_output_locked(item.call_id, item)
227
- logger.debug("Added function_call_output to chat (call_id=%s)", item.call_id)
228
-
229
- else:
230
- raise ChatItemError(f"Unsupported item type: {getattr(item, 'type', None)}")
231
-
232
- if self.size > 0 and self._user_turn_count > 2 * self.size:
233
- logger.warning(
234
- "Chat buffer exceeded hard cap (%d > 2 * size=%d); evicting oldest turn",
235
- self._user_turn_count,
236
- self.size,
237
- )
238
- while self._user_turn_count > 2 * self.size:
239
- self._evict_oldest_turn()
240
-
241
- return item
242
-
243
- def trim_if_needed(self, compactor: CompactFn | None = None) -> None:
244
- """Enforce the size limit after a generation completes. Fires when
245
- ``user_turn_count > size``.
246
-
247
- - ``compactor=None``: synchronous eviction of the oldest complete turn.
248
- - ``compactor=<fn>``: launch a background compaction (single-flight).
249
-
250
- Call once after each successful generation, not inside :meth:`add_item`.
251
- """
252
- with self._lock:
253
- if self._user_turn_count <= self.size:
254
- return
255
- if compactor is not None:
256
- self._maybe_trigger_compaction(compactor)
257
- else:
258
- while self._user_turn_count > self.size:
259
- self._evict_oldest_turn()
260
-
261
- def replace_user_message_text(self, item_id: str, text: str) -> bool:
262
- """Replace the text content of an existing user message.
263
-
264
- Used by speculative turn revisions: the conversation turn remains the
265
- same, but the STT transcript is superseded by a transcription of a
266
- longer raw-audio buffer.
267
- """
268
-
269
- with self._lock:
270
- for item in self.buffer:
271
- if not isinstance(item, RealtimeConversationItemUserMessage) or item.id != item_id:
272
- continue
273
- item.content = [UserContent(type="input_text", text=text)]
274
- logger.debug("Replaced speculative user message %s", item_id)
275
- return True
276
- return False
277
-
278
- def remove_user_message(self, item_id: str) -> bool:
279
- """Remove an existing user message from the bounded chat buffer."""
280
-
281
- with self._lock:
282
- for index, item in enumerate(self.buffer):
283
- if not isinstance(item, RealtimeConversationItemUserMessage) or item.id != item_id:
284
- continue
285
- del self.buffer[index]
286
- self._user_turn_count -= 1
287
- logger.debug("Removed speculative user message %s", item_id)
288
- return True
289
- return False
290
-
291
- def rollback_generation(
292
- self,
293
- user_message_id: str,
294
- *,
295
- item_ids: set[str],
296
- call_ids: set[str],
297
- ) -> None:
298
- """Remove only the provisional state written by one failed generation.
299
-
300
- A tool output may be appended by a fast client while generation is still
301
- streaming, so rollback matches both item IDs and tool ``call_id`` values.
302
- Unrelated messages injected concurrently for a later turn are preserved.
303
- """
304
-
305
- with self._lock:
306
- kept: list[SupportedItem] = []
307
- for item in self.buffer:
308
- remove = item.id == user_message_id or item.id in item_ids
309
- if isinstance(item, (RealtimeConversationItemFunctionCall, RealtimeConversationItemFunctionCallOutput)):
310
- remove = remove or item.call_id in call_ids
311
- if not remove:
312
- kept.append(item)
313
- self.buffer = kept
314
- for call_id in call_ids:
315
- self._pending_tool_calls.pop(call_id, None)
316
- self._user_turn_count = sum(isinstance(item, RealtimeConversationItemUserMessage) for item in self.buffer)
317
- logger.debug("Rolled back failed generation for user message %s", user_message_id)
318
-
319
- def compact_audio_history(self, max_audio_turns: int) -> None:
320
- """Retain only the newest bounded set of audio turns.
321
-
322
- Older audio parts are replaced with a textual placeholder so the user
323
- role and its paired assistant response remain valid in serialized
324
- history. The newest turns keep their audio semantics for subsequent
325
- Chat Completions requests.
326
- """
327
-
328
- with self._lock:
329
- remaining = max(0, max_audio_turns)
330
- for item in reversed(self.buffer):
331
- if not isinstance(item, RealtimeConversationItemUserMessage):
332
- continue
333
- if not any(part.type == "input_audio" for part in item.content):
334
- continue
335
- if remaining:
336
- remaining -= 1
337
- continue
338
- replacement_added = False
339
- compacted: list[UserContent] = []
340
- for part in item.content:
341
- if part.type != "input_audio":
342
- compacted.append(part)
343
- elif not replacement_added:
344
- compacted.append(UserContent(type="input_text", text=AUDIO_INPUT_HISTORY_PLACEHOLDER))
345
- replacement_added = True
346
- item.content = compacted
347
-
348
- def to_responses_api_chat(self, items: list[SupportedItem] | None = None) -> ResponseInputParam:
349
- """Serialize the chat (system prompt + buffer) for the OpenAI Responses API.
350
-
351
- If *items* is provided, serialize that slice instead of the live buffer
352
- (used by the compaction snapshot).
353
- """
354
- with self._lock:
355
- return self._to_responses_api_chat_locked(items if items is not None else self.buffer)
356
-
357
- def _to_responses_api_chat_locked(self, items: list[SupportedItem]) -> ResponseInputParam:
358
- """Body of :meth:`to_responses_api_chat`. Caller must hold ``_lock``."""
359
- buffer_items = list(items)
360
- result: list[ResponseInputItemParam] = []
361
- if self.init_chat_message:
362
- result.append(
363
- ResponseMessage(
364
- content=[
365
- ResponseInputTextParam(text=p.text or "A helpful AI assistant.", type="input_text")
366
- for p in self.init_chat_message.content
367
- ],
368
- role="system",
369
- type="message",
370
- )
371
- )
372
- for item in buffer_items:
373
- assert item.id is not None and item.id != "", f"item.id is {item.id}"
374
- if isinstance(item, RealtimeConversationItemUserMessage):
375
- content: ResponseInputMessageContentListParam = []
376
- audio_placeholder_added = False
377
- for user_part in item.content:
378
- if user_part.type == "input_text" and user_part.text is not None:
379
- content.append(ResponseInputTextParam(text=user_part.text or "", type="input_text"))
380
- elif user_part.type == "input_image" and user_part.image_url is not None:
381
- img = ResponseInputImageParam(type="input_image", detail=user_part.detail or "auto")
382
- if user_part.image_url is not None:
383
- img["image_url"] = user_part.image_url
384
- content.append(img)
385
- elif user_part.type == "input_audio" and not audio_placeholder_added:
386
- content.append(ResponseInputTextParam(text=AUDIO_INPUT_HISTORY_PLACEHOLDER, type="input_text"))
387
- audio_placeholder_added = True
388
- if content:
389
- result.append(ResponseMessage(content=content, role="user", type="message"))
390
- elif isinstance(item, RealtimeConversationItemAssistantMessage):
391
- assistant_content: list[ResponseOutputTextParam] = []
392
- for assistant_part in item.content:
393
- if assistant_part.type == "output_text" and assistant_part.text is not None:
394
- assistant_content.append(
395
- ResponseOutputTextParam(text=assistant_part.text, type="output_text", annotations=[])
396
- )
397
- if assistant_content:
398
- result.append(
399
- ResponseOutputMessageParam(
400
- id=item.id,
401
- content=assistant_content,
402
- role="assistant",
403
- status=item.status or "completed",
404
- type="message",
405
- )
406
- )
407
- elif isinstance(item, RealtimeConversationItemFunctionCall) and item.call_id is not None:
408
- assert item.call_id is not None and item.call_id != ""
409
- function_call = ResponseFunctionToolCallParam(
410
- arguments=item.arguments,
411
- call_id=item.call_id,
412
- name=item.name,
413
- type="function_call",
414
- id=item.id,
415
- )
416
- if item.id is not None:
417
- function_call["id"] = item.id
418
- if item.status is not None:
419
- function_call["status"] = item.status
420
- result.append(function_call)
421
- elif isinstance(item, RealtimeConversationItemFunctionCallOutput):
422
- function_call_output = FunctionCallOutput(
423
- call_id=item.call_id,
424
- output=item.output,
425
- type="function_call_output",
426
- )
427
- if item.id is not None:
428
- function_call_output["id"] = item.id
429
- if item.status is not None:
430
- function_call_output["status"] = item.status
431
- result.append(function_call_output)
432
- return result
433
-
434
- def to_transformers_chat(self) -> list[dict[str, Any]]:
435
- """Serialize the full chat for HuggingFace transformers ``apply_chat_template``.
436
-
437
- User messages with only text produce a plain string ``content`` value.
438
- User messages containing images keep ``content`` as a list of dicts so
439
- VLM pipelines can process them.
440
- """
441
- with self._lock:
442
- messages: list[TransformersChatMessage] = []
443
- if self.init_chat_message:
444
- text = " ".join(p.text for p in self.init_chat_message.content if p.text)
445
- messages.append(TransformersSystemMessage(content=text))
446
- for item in self.buffer:
447
- if isinstance(item, RealtimeConversationItemUserMessage):
448
- has_media = any(p.type in {"input_image", "input_audio"} for p in item.content)
449
- if has_media:
450
- messages.append(
451
- TransformersUserMessage(content=[p.model_dump(exclude_none=True) for p in item.content])
452
- )
453
- else:
454
- text = " ".join(p.text for p in item.content if p.type == "input_text" and p.text)
455
- messages.append(TransformersUserMessage(content=text))
456
- elif isinstance(item, RealtimeConversationItemAssistantMessage):
457
- text = " ".join(p.text for p in item.content if p.text)
458
- messages.append(TransformersAssistantMessage(content=text))
459
- elif isinstance(item, RealtimeConversationItemFunctionCall):
460
- assert item.call_id is not None and item.call_id != ""
461
- args: Any = item.arguments
462
- try:
463
- args = json.loads(args) if isinstance(args, str) else args
464
- except (json.JSONDecodeError, TypeError):
465
- args = {}
466
- messages.append(
467
- TransformersFunctionCallMessage(
468
- tool_calls=[
469
- TransformersToolCall(
470
- id=item.call_id,
471
- function=TransformersToolCallFunction(name=item.name, arguments=args),
472
- )
473
- ]
474
- )
475
- )
476
- elif isinstance(item, RealtimeConversationItemFunctionCallOutput):
477
- name = ""
478
- for prev in reversed(messages):
479
- if isinstance(prev, TransformersFunctionCallMessage):
480
- for tc in prev.tool_calls:
481
- if tc.id == item.call_id:
482
- name = tc.function.name
483
- break
484
- if name:
485
- break
486
- messages.append(
487
- TransformersToolMessage(
488
- tool_call_id=item.call_id,
489
- name=name,
490
- content=item.output,
491
- )
492
- )
493
- return [m.model_dump() for m in messages]
494
-
495
- def copy(self) -> Chat:
496
- """Return a shallow snapshot safe for concurrent read access."""
497
- with self._lock:
498
- clone = Chat(self.size)
499
- clone.init_chat_message = self.init_chat_message
500
- clone.buffer = list(self.buffer)
501
- clone._pending_tool_calls = dict(self._pending_tool_calls)
502
- clone._user_turn_count = self._user_turn_count
503
- return clone
504
-
505
- def reset(self) -> None:
506
- """Clear all conversation state. Cancels any in-flight compaction splice."""
507
- with self._lock:
508
- self._gen_counter += 1
509
- self._compact_in_flight = False
510
- self.buffer = []
511
- self.init_chat_message = None
512
- self._pending_tool_calls = {}
513
- self._user_turn_count = 0
514
-
515
- def close(self) -> None:
516
- """Permanently shut down the chat. In-flight compaction splice is suppressed.
517
-
518
- The compaction worker (a daemon thread) is not joined: it may be blocked
519
- in an LLM call. Process exit reaps it.
520
- """
521
- self._shutdown.set()
522
- with self._lock:
523
- self._gen_counter += 1
524
- self._compact_in_flight = False
525
-
526
- def image_message_ids(self) -> set[str]:
527
- """IDs of user messages currently carrying ``input_image`` content."""
528
- with self._lock:
529
- return {
530
- item.id
531
- for item in self.buffer
532
- if isinstance(item, RealtimeConversationItemUserMessage)
533
- and item.id is not None
534
- and any(p.type == "input_image" for p in item.content)
535
- }
536
-
537
- def strip_images(self, only_ids: set[str] | None = None) -> None:
538
- """Remove image content parts from user messages in the buffer.
539
-
540
- Called after appending the assistant response so images don't persist
541
- across turns. With *only_ids*, strip only those message IDs — the images
542
- the just-completed response actually consumed (captured before the
543
- request was sent). This leaves intact an image a fast client injected
544
- mid-generation for the *next* turn, which the current response never saw.
545
- Without *only_ids*, every image is stripped.
546
- """
547
- with self._lock:
548
- for item in self.buffer:
549
- if isinstance(item, RealtimeConversationItemUserMessage):
550
- if only_ids is not None and item.id not in only_ids:
551
- continue
552
- item.content = [p for p in item.content if p.type != "input_image"]
553
-
554
- # ── Compaction internals ──────────────────────────────────
555
-
556
- def _snapshot_for_compaction(
557
- self,
558
- ) -> tuple[ResponseInputParam, set[str], int]:
559
- """Compute the snapshot of items eligible for compaction.
560
-
561
- Caller must hold ``_lock``. Returns
562
- ``(serialized_snapshot, marker_ids, n_turns)``. ``marker_ids``
563
- identifies the buffer items that may be removed when the splice runs.
564
- Always leaves the most recent user turn untouched (it may be in-flight).
565
- Returns an empty result if there are fewer than 2 compactable turns.
566
- """
567
- n_turns = max(0, self._user_turn_count - 1)
568
- if n_turns < 2:
569
- return [], set(), n_turns
570
-
571
- # Slice up to (but not including) the (n_turns + 1)-th user message.
572
- user_seen = 0
573
- end_idx = len(self.buffer)
574
- for i, entry in enumerate(self.buffer):
575
- if isinstance(entry, RealtimeConversationItemUserMessage):
576
- user_seen += 1
577
- if user_seen == n_turns + 1:
578
- end_idx = i
579
- break
580
-
581
- items_to_compact = self.buffer[:end_idx]
582
- marker_ids = {entry.id for entry in items_to_compact if entry.id is not None}
583
- snapshot = self._to_responses_api_chat_locked(items=items_to_compact)
584
- # Strip media parts so the summarizer doesn't have to handle them.
585
- for raw in snapshot:
586
- if not isinstance(raw, dict) or raw.get("role") != "user":
587
- continue
588
- msg: dict[str, Any] = raw # type: ignore[assignment]
589
- content = msg.get("content")
590
- if isinstance(content, list):
591
- msg["content"] = [
592
- c for c in content if not (isinstance(c, dict) and c.get("type") in {"input_image", "input_audio"})
593
- ]
594
- return snapshot, marker_ids, n_turns
595
-
596
- def _maybe_trigger_compaction(self, compactor: CompactFn) -> None:
597
- """Start a background compaction worker. Bypass silently if one is running.
598
-
599
- Caller must hold ``_lock``.
600
- """
601
- if self._shutdown.is_set() or self._compact_in_flight:
602
- return
603
- snapshot, marker_ids, n_turns = self._snapshot_for_compaction()
604
- if n_turns < 2 or not marker_ids:
605
- return
606
- gen = self._gen_counter
607
- self._compact_in_flight = True
608
- thread = threading.Thread(
609
- target=self._compact_worker,
610
- args=(compactor, snapshot, marker_ids, gen),
611
- daemon=True,
612
- name="chat-compact",
613
- )
614
- self._compact_thread = thread
615
- logger.info(
616
- "Chat compaction triggered: compacting %d turn(s) (%d item(s)), buffer size=%d",
617
- n_turns,
618
- len(marker_ids),
619
- len(self.buffer),
620
- )
621
- thread.start()
622
-
623
- def _compact_worker(
624
- self,
625
- compactor: CompactFn,
626
- snapshot: ResponseInputParam,
627
- marker_ids: set[str],
628
- gen: int,
629
- ) -> None:
630
- """Worker thread entry point."""
631
- try:
632
- if self._shutdown.is_set() or self._gen_counter != gen:
633
- return
634
- try:
635
- result = compactor(snapshot)
636
- except Exception:
637
- logger.exception("Chat compaction failed; chat unchanged")
638
- return
639
- if not isinstance(result, CompactionResult):
640
- logger.error("Compactor must return a CompactionResult, got %r", type(result).__name__)
641
- return
642
- if self._shutdown.is_set() or self._gen_counter != gen:
643
- return
644
- self._apply_compaction(result, marker_ids, gen)
645
- finally:
646
- # Don't clobber the flag if reset/close has advanced the gen.
647
- with self._lock:
648
- if self._gen_counter == gen:
649
- self._compact_in_flight = False
650
-
651
- def _apply_compaction(
652
- self,
653
- result: CompactionResult,
654
- marker_ids: set[str],
655
- gen: int,
656
- ) -> None:
657
- """Splice the summary in front of items not consumed by compaction.
658
-
659
- FC/FCO pairing is left entirely to :meth:`add_item` / :meth:`append_tool_output`.
660
- Compaction only drops items; it never inserts an FC into the buffer.
661
- Pending FCs (no FCO yet) stay in ``_pending_tool_calls`` and will be
662
- appended adjacent to their FCO when it arrives.
663
- """
664
- with self._lock:
665
- if self._shutdown.is_set() or self._gen_counter != gen:
666
- return
667
- # Keep FC if its FCO is outside the compacted range -- otherwise
668
- # the FCO in `remaining` would be orphaned.
669
- fco_call_ids_in_range = {
670
- x.call_id
671
- for x in self.buffer
672
- if isinstance(x, RealtimeConversationItemFunctionCallOutput) and x.id in marker_ids
673
- }
674
- fc_ids_to_keep = {
675
- x.id
676
- for x in self.buffer
677
- if x.id in marker_ids
678
- and isinstance(x, RealtimeConversationItemFunctionCall)
679
- and x.call_id not in fco_call_ids_in_range
680
- }
681
- drop_ids = marker_ids - fc_ids_to_keep
682
- remaining = [x for x in self.buffer if x.id not in drop_ids]
683
-
684
- user_msg = make_user_message(result.user_summary)
685
- user_msg.id = _generate_id("msg")
686
- asst_msg = make_assistant_message(result.assistant_summary)
687
- asst_msg.id = _generate_id("msg")
688
-
689
- self.buffer = [user_msg, asst_msg, *remaining]
690
- self._user_turn_count = sum(1 for x in self.buffer if isinstance(x, RealtimeConversationItemUserMessage))
691
- logger.info(
692
- "Chat compaction applied: buffer now %d item(s), %d user turn(s)",
693
- len(self.buffer),
694
- self._user_turn_count,
695
- )
696
-
697
-
698
- # ---------------------------------------------------------------------------
699
- # Transformers chat message models
700
- # ---------------------------------------------------------------------------
701
-
702
-
703
- class TransformersToolCallFunction(BaseModel):
704
- name: str
705
- arguments: dict[str, Any]
706
-
707
-
708
- class TransformersToolCall(BaseModel):
709
- type: Literal["function"] = "function"
710
- id: str
711
- function: TransformersToolCallFunction
712
-
713
-
714
- class TransformersSystemMessage(BaseModel):
715
- role: Literal["system"] = "system"
716
- content: str
717
-
718
-
719
- class TransformersUserMessage(BaseModel):
720
- role: Literal["user"] = "user"
721
- content: str | list[dict[str, Any]]
722
-
723
-
724
- class TransformersAssistantMessage(BaseModel):
725
- role: Literal["assistant"] = "assistant"
726
- content: str
727
-
728
-
729
- class TransformersFunctionCallMessage(BaseModel):
730
- role: Literal["assistant"] = "assistant"
731
- tool_calls: list[TransformersToolCall]
732
-
733
-
734
- class TransformersToolMessage(BaseModel):
735
- role: Literal["tool"] = "tool"
736
- tool_call_id: str
737
- name: str
738
- content: str
739
-
740
-
741
- TransformersChatMessage = Union[
742
- TransformersSystemMessage,
743
- TransformersUserMessage,
744
- TransformersAssistantMessage,
745
- TransformersFunctionCallMessage,
746
- TransformersToolMessage,
747
- ]
748
-
749
-
750
- # ---------------------------------------------------------------------------
751
- # Factory helpers -- hide verbose constructors behind simple calls
752
- # ---------------------------------------------------------------------------
753
-
754
-
755
- def make_user_message(text: str) -> RealtimeConversationItemUserMessage:
756
- return RealtimeConversationItemUserMessage(
757
- type="message",
758
- role="user",
759
- content=[UserContent(type="input_text", text=text)],
760
- )
761
-
762
-
763
- def make_user_audio_message(audio_b64: str) -> RealtimeConversationItemUserMessage:
764
- return RealtimeConversationItemUserMessage(
765
- type="message",
766
- role="user",
767
- content=[UserContent(type="input_audio", audio=audio_b64)],
768
- )
769
-
770
-
771
- def make_assistant_message(text: str) -> RealtimeConversationItemAssistantMessage:
772
- return RealtimeConversationItemAssistantMessage(
773
- type="message",
774
- role="assistant",
775
- content=[AssistantContent(type="output_text", text=text)],
776
- )
777
-
778
-
779
- def make_system_message(text: str) -> RealtimeConversationItemSystemMessage:
780
- return RealtimeConversationItemSystemMessage(
781
- type="message",
782
- role="system",
783
- content=[SystemContent(type="input_text", text=text)],
784
- )
785
-
786
-
787
- def add_supported_item(chat: Chat, item: ConversationItem) -> None:
788
- """Narrow a protocol ``ConversationItem`` to a :data:`SupportedItem` and add it to *chat*.
789
-
790
- Raises :class:`ChatItemError` on validation failure or unsupported type. Shared
791
- by the conversation handler (in-band item injection) and the language-model
792
- handlers (seeding an out-of-band response's throwaway chat from ``response.input``).
793
- """
794
- # call_id on function_call items must be client-supplied: it is referenced later by
795
- # function_call_output items, so we cannot silently generate one here.
796
- if isinstance(item, RealtimeConversationItemFunctionCall) and (
797
- item.call_id is None or not item.call_id.startswith("call_")
798
- ):
799
- raise ChatItemError("function_call item is missing a call_id. The call_id should start with 'call_'.")
800
-
801
- if isinstance(
802
- item,
803
- (
804
- RealtimeConversationItemSystemMessage,
805
- RealtimeConversationItemUserMessage,
806
- RealtimeConversationItemAssistantMessage,
807
- RealtimeConversationItemFunctionCall,
808
- RealtimeConversationItemFunctionCallOutput,
809
- ),
810
- ):
811
- chat.add_item(item)
812
- return
813
-
814
- raise ChatItemError(f"Unsupported item type: {getattr(item, 'type', None)}")
815
-
816
-
817
- def build_active_chat(original_chat: Chat, response: RealtimeResponseCreateParams | None) -> Chat:
818
- """Build the chat an *out-of-band* response generates against (caller ensures out-of-band).
819
-
820
- Mirrors the OpenAI realtime semantics for ``input``:
821
-
822
- - ``input is None`` -> a read-only **copy of the default conversation** (the
823
- out-of-band response reads history but never commits back).
824
- - ``input == []`` -> a **fresh, empty chat** (context cleared; only the
825
- system prompt, added later by the handler, will be present).
826
- - ``input == [...]`` -> a **fresh chat seeded** with those items.
827
-
828
- Raises :class:`ChatItemError` if an ``input`` item fails validation.
829
- """
830
- if response is not None and response.input is not None:
831
- fresh = Chat(original_chat.size)
832
- for item in response.input:
833
- add_supported_item(fresh, item)
834
- return fresh
835
- return original_chat.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/chat_completions_language_model.py DELETED
@@ -1,360 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import logging
5
- import time
6
- from collections.abc import Iterator
7
- from typing import Any, cast
8
-
9
- from openai import Stream
10
- from openai.types.chat import (
11
- ChatCompletionChunk,
12
- ChatCompletionContentPartImageParam,
13
- ChatCompletionContentPartParam,
14
- ChatCompletionContentPartTextParam,
15
- ChatCompletionNamedToolChoiceParam,
16
- ChatCompletionToolChoiceOptionParam,
17
- ChatCompletionToolParam,
18
- )
19
- from openai.types.chat.chat_completion_content_part_image_param import ImageURL
20
- from openai.types.chat.chat_completion_named_tool_choice_param import Function as NamedToolChoiceFunction
21
- from openai.types.realtime.realtime_conversation_item_assistant_message import (
22
- Content as AssistantContent,
23
- )
24
- from openai.types.responses import ResponseFunctionToolCall
25
- from openai.types.shared_params import FunctionDefinition
26
-
27
- from speech_to_speech.LLM.base_openai_compatible_language_model import (
28
- WARMUP_MAX_RETRIES,
29
- AssistantMessage,
30
- BaseOpenAICompatibleHandler,
31
- ProviderEvent,
32
- TextDelta,
33
- ToolCall,
34
- Usage,
35
- )
36
- from speech_to_speech.LLM.chat import Chat
37
- from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn
38
- from speech_to_speech.utils.utils import _generate_id
39
-
40
- logger = logging.getLogger(__name__)
41
-
42
-
43
- def _to_chat_tools(req_tools: Any) -> list[ChatCompletionToolParam] | None:
44
- """Convert Responses-API function tools to Chat-Completions tool format.
45
-
46
- Responses tools are flat ``{type:"function", name, description, parameters}``;
47
- Chat Completions nests them under a ``function`` key. Items already in the
48
- nested form (or non-function tools) are passed through untouched.
49
- """
50
- if not req_tools:
51
- return None
52
- chat_tools: list[ChatCompletionToolParam] = []
53
- for t in req_tools:
54
- d = t if isinstance(t, dict) else t.model_dump(exclude_none=True)
55
- if d.get("type") == "function" and "function" not in d:
56
- fn = FunctionDefinition(name=d["name"])
57
- if d.get("description") is not None:
58
- fn["description"] = d["description"]
59
- if d.get("parameters") is not None:
60
- fn["parameters"] = d["parameters"]
61
- chat_tools.append(ChatCompletionToolParam(type="function", function=fn))
62
- else:
63
- chat_tools.append(cast("ChatCompletionToolParam", d))
64
- return chat_tools
65
-
66
-
67
- def _to_chat_tool_choice(tool_choice: Any) -> ChatCompletionToolChoiceOptionParam:
68
- """Convert a Responses-API tool_choice to Chat-Completions form.
69
-
70
- The string forms ("auto"/"required"/"none") are identical across both APIs;
71
- only the forced-function object differs (flat ``name`` vs nested ``function``).
72
- """
73
- if isinstance(tool_choice, dict) and tool_choice.get("type") == "function" and "name" in tool_choice:
74
- return ChatCompletionNamedToolChoiceParam(
75
- type="function", function=NamedToolChoiceFunction(name=tool_choice["name"])
76
- )
77
- if tool_choice is not None and not isinstance(tool_choice, (str, dict)):
78
- d = tool_choice.model_dump(exclude_none=True)
79
- if d.get("type") == "function" and "name" in d:
80
- return ChatCompletionNamedToolChoiceParam(type="function", function=NamedToolChoiceFunction(name=d["name"]))
81
- return cast("ChatCompletionToolChoiceOptionParam", d)
82
- return cast("ChatCompletionToolChoiceOptionParam", tool_choice)
83
-
84
-
85
- def _build_chat_optional_kwargs(req_tools: Any, req_tool_choice: Any) -> dict[str, Any]:
86
- """Build Chat Completions tool arguments from the Responses-style session config."""
87
- optional_kwargs: dict[str, Any] = {}
88
- chat_tools = _to_chat_tools(req_tools)
89
- if chat_tools is not None:
90
- optional_kwargs["tools"] = chat_tools
91
- if req_tool_choice is not None:
92
- optional_kwargs["tool_choice"] = _to_chat_tool_choice(req_tool_choice)
93
- return optional_kwargs
94
-
95
-
96
- def _to_chat_content_part(
97
- part: dict[str, Any],
98
- audio_content_type: str = "input_audio",
99
- ) -> ChatCompletionContentPartParam:
100
- """Convert one Realtime/transformers content part to Chat Completions shape."""
101
- ptype = part.get("type")
102
- if ptype == "input_text":
103
- return ChatCompletionContentPartTextParam(type="text", text=part.get("text") or "")
104
- if ptype == "input_image":
105
- raw_url: Any = part.get("image_url")
106
- if isinstance(raw_url, dict):
107
- image_url = cast("ImageURL", raw_url)
108
- else:
109
- image_url = ImageURL(url=raw_url)
110
- detail = part.get("detail")
111
- if detail is not None:
112
- image_url["detail"] = detail
113
- return ChatCompletionContentPartImageParam(type="image_url", image_url=image_url)
114
- if ptype == "input_audio":
115
- audio_b64 = part.get("audio") or ""
116
- if audio_content_type == "audio_url":
117
- return cast(
118
- "ChatCompletionContentPartParam",
119
- {
120
- "type": "audio_url",
121
- "audio_url": {
122
- "url": f"data:audio/wav;base64,{audio_b64}",
123
- },
124
- },
125
- )
126
- return cast(
127
- "ChatCompletionContentPartParam",
128
- {
129
- "type": "input_audio",
130
- "input_audio": {
131
- "data": audio_b64,
132
- "format": "wav",
133
- },
134
- },
135
- )
136
- return cast("ChatCompletionContentPartParam", part)
137
-
138
-
139
- def _chat_messages(
140
- chat: Chat,
141
- audio_content_type: str = "input_audio",
142
- ) -> list[dict[str, Any]]:
143
- """Serialise chat history, including media and tool messages, for Chat Completions."""
144
- messages = chat.to_transformers_chat()
145
- for message in messages:
146
- for tool_call in message.get("tool_calls") or []:
147
- fn = tool_call.get("function")
148
- if fn is not None and not isinstance(fn.get("arguments"), str):
149
- fn["arguments"] = json.dumps(fn.get("arguments") or {}, ensure_ascii=False)
150
- content = message.get("content")
151
- if isinstance(content, list):
152
- message["content"] = [
153
- _to_chat_content_part(part, audio_content_type=audio_content_type) for part in content
154
- ]
155
- if message.get("role") == "tool":
156
- message.pop("name", None)
157
- return messages
158
-
159
-
160
- def _request_chat_completions(
161
- *,
162
- client: Any,
163
- model_name: str,
164
- messages: list[dict[str, Any]],
165
- stream: bool,
166
- extra_body: dict[str, Any] | None,
167
- timeout: Any,
168
- optional_kwargs: dict[str, Any],
169
- ) -> Any:
170
- """Issue a Chat Completions request with consistent streaming usage accounting."""
171
- create_kwargs = dict(optional_kwargs)
172
- if stream:
173
- create_kwargs["stream_options"] = {"include_usage": True}
174
- return client.chat.completions.create(
175
- model=model_name,
176
- messages=messages,
177
- stream=stream,
178
- extra_body=extra_body,
179
- timeout=timeout,
180
- **create_kwargs,
181
- )
182
-
183
-
184
- def _tool_calls_from_accum(tool_accum: dict[int, dict[str, str]]) -> Iterator[ToolCall]:
185
- """Turn accumulated Chat Completions tool deltas into normalized tool-call events."""
186
- for index in sorted(tool_accum):
187
- entry = tool_accum[index]
188
- if not entry["name"]:
189
- continue
190
- yield ToolCall(
191
- item=ResponseFunctionToolCall(
192
- type="function_call",
193
- name=entry["name"],
194
- arguments=entry["args"] or "{}",
195
- call_id=_generate_id("call"),
196
- id=_generate_id("fc"),
197
- status="completed",
198
- )
199
- )
200
-
201
-
202
- def _iter_chat_stream_events(api_response: Stream[ChatCompletionChunk]) -> Iterator[ProviderEvent]:
203
- """Normalize a streaming Chat Completions response."""
204
- tool_accum: dict[int, dict[str, str]] = {}
205
- usage: Usage | None = None
206
- raw_text = ""
207
- for chunk in api_response:
208
- if chunk.usage is not None:
209
- usage = Usage(
210
- input_tokens=chunk.usage.prompt_tokens or 0,
211
- output_tokens=chunk.usage.completion_tokens or 0,
212
- )
213
- if not chunk.choices:
214
- continue
215
- delta = chunk.choices[0].delta
216
- if delta.tool_calls:
217
- for tool_call in delta.tool_calls:
218
- entry = tool_accum.setdefault(tool_call.index, {"name": "", "args": "", "id": ""})
219
- if tool_call.id:
220
- entry["id"] = tool_call.id
221
- if tool_call.function is not None:
222
- if tool_call.function.name:
223
- entry["name"] = tool_call.function.name
224
- if tool_call.function.arguments:
225
- entry["args"] += tool_call.function.arguments
226
- text_piece = delta.content or getattr(delta, "refusal", None)
227
- if text_piece:
228
- raw_text += text_piece
229
- yield TextDelta(text=text_piece)
230
-
231
- if raw_text.strip():
232
- yield AssistantMessage(content=[AssistantContent(type="output_text", text=raw_text)])
233
- yield from _tool_calls_from_accum(tool_accum)
234
- if usage is not None:
235
- yield usage
236
-
237
-
238
- def _iter_chat_response_events(api_response: Any) -> Iterator[ProviderEvent]:
239
- """Normalize a non-streaming Chat Completions response."""
240
- usage = api_response.usage
241
- if usage:
242
- yield Usage(input_tokens=usage.prompt_tokens or 0, output_tokens=usage.completion_tokens or 0)
243
- message = api_response.choices[0].message if api_response.choices else None
244
- if message is None:
245
- return
246
- raw_content = message.content or getattr(message, "refusal", None)
247
- if raw_content:
248
- yield AssistantMessage(content=[AssistantContent(type="output_text", text=raw_content)])
249
- yield TextDelta(text=raw_content)
250
- tool_accum: dict[int, dict[str, str]] = {}
251
- for tool_call in message.tool_calls or []:
252
- tool_accum[len(tool_accum)] = {
253
- "name": tool_call.function.name or "",
254
- "args": tool_call.function.arguments or "",
255
- "id": tool_call.id or "",
256
- }
257
- yield from _tool_calls_from_accum(tool_accum)
258
-
259
-
260
- class ChatCompletionsApiModelHandler(BaseOpenAICompatibleHandler):
261
- """LLM handler that talks to an OpenAI-compatible ``/v1/chat/completions`` server.
262
-
263
- Functionally mirrors :class:`ResponsesApiModelHandler` but uses the mature
264
- Chat Completions streaming tool-call protocol (``choices[].delta.tool_calls``)
265
- instead of ``/v1/responses``. This is the robust path for vLLM + Qwen tool
266
- calling. The conversation is serialised with :meth:`Chat.to_transformers_chat`,
267
- which already emits OpenAI chat messages including ``tool_calls``/``tool`` roles.
268
- """
269
-
270
- def warmup(self) -> None:
271
- logger.info(f"Warming up {self.__class__.__name__}")
272
- start = time.time()
273
- self.client.with_options(max_retries=WARMUP_MAX_RETRIES).chat.completions.create(
274
- model=self.model_name,
275
- messages=[
276
- {"role": "system", "content": "You are a helpful assistant"},
277
- {"role": "user", "content": "Hello"},
278
- ],
279
- extra_body=self._extra_body,
280
- timeout=self.request_timeout,
281
- )
282
- end = time.time()
283
- logger.info(f"{self.__class__.__name__}: warmed up! time: {(end - start):.3f} s")
284
-
285
- def _build_compaction_generate_fn(self) -> CompactGenerateFn:
286
- """Return a generate fn that calls Chat Completions for compaction."""
287
- client = self.client
288
- model_name = self.model_name
289
- timeout = self.request_timeout
290
- extra_body = self._extra_body
291
-
292
- def generate(system: str, user: str) -> str:
293
- response = client.chat.completions.create(
294
- model=model_name,
295
- messages=[
296
- {"role": "system", "content": system},
297
- {"role": "user", "content": user},
298
- ],
299
- extra_body=extra_body,
300
- timeout=timeout,
301
- )
302
- return response.choices[0].message.content or ""
303
-
304
- return generate
305
-
306
- @staticmethod
307
- def _to_chat_content_part(
308
- part: dict[str, Any],
309
- audio_content_type: str = "input_audio",
310
- ) -> ChatCompletionContentPartParam:
311
- return _to_chat_content_part(part, audio_content_type=audio_content_type)
312
-
313
- @classmethod
314
- def _chat_messages(
315
- cls,
316
- chat: Chat,
317
- audio_content_type: str = "input_audio",
318
- ) -> list[dict[str, Any]]:
319
- return _chat_messages(chat, audio_content_type=audio_content_type)
320
-
321
- # ── base hooks ──────────────────────────────────────────────────────────--
322
-
323
- def _serialize(self, active_chat: Chat) -> list[dict[str, Any]]:
324
- return self._chat_messages(active_chat, audio_content_type=self.audio_content_type)
325
-
326
- def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]:
327
- return _build_chat_optional_kwargs(req_tools, req_tool_choice)
328
-
329
- def _request(
330
- self,
331
- api_input: list[dict[str, Any]],
332
- optional_kwargs: dict[str, Any],
333
- runtime_config: Any,
334
- ) -> Any:
335
- extra_body = self._extra_body
336
- session_reasoning_effort = getattr(runtime_config.session, "reasoning_effort", None)
337
- if session_reasoning_effort:
338
- extra_body = {"reasoning_effort": session_reasoning_effort}
339
- return _request_chat_completions(
340
- client=self.client,
341
- model_name=self.model_name,
342
- messages=api_input,
343
- stream=self.stream,
344
- extra_body=extra_body,
345
- timeout=self.request_timeout,
346
- optional_kwargs=optional_kwargs,
347
- )
348
-
349
- def _iter_stream_events(self, api_response: Stream[ChatCompletionChunk]) -> Iterator[ProviderEvent]:
350
- yield from _iter_chat_stream_events(api_response)
351
-
352
- def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]:
353
- yield from _iter_chat_response_events(api_response)
354
-
355
- @staticmethod
356
- def _tool_calls_from_accum(tool_accum: dict[int, dict[str, str]]) -> Iterator[ToolCall]:
357
- yield from _tool_calls_from_accum(tool_accum)
358
-
359
- def on_session_end(self) -> None:
360
- logger.debug("Chat Completions API language model session state reset")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/compaction_prompt.py DELETED
@@ -1,181 +0,0 @@
1
- """Prompt template and factory for the conversation compaction (history summarization) function.
2
-
3
- Compaction reduces an unbounded conversation history to a tight user/assistant
4
- summary pair, letting the pipeline continue indefinitely without running out of
5
- context window. The factory :func:`build_compactor` returns a :data:`CompactFn`
6
- compatible with :meth:`~speech_to_speech.LLM.chat.Chat.trim_if_needed`.
7
-
8
- :data:`CompactGenerateFn` is the backend-agnostic generation interface:
9
- ``(system_prompt: str, user_prompt: str) -> response_text: str``.
10
- Each handler wraps its own model into this interface and passes it to
11
- :func:`build_compactor`.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import json
17
- import logging
18
- import re
19
- from collections.abc import Callable
20
- from typing import Any
21
-
22
- from speech_to_speech.LLM.chat import CompactFn, CompactionResult
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
- # Callable[[system_prompt, user_prompt], response_text]
27
- CompactGenerateFn = Callable[[str, str], str]
28
-
29
- # ---------------------------------------------------------------------------
30
- # Prompts
31
- # ---------------------------------------------------------------------------
32
-
33
- COMPACTION_SYSTEM_PROMPT = """\
34
- You are a conversation memory compressor for a real-time voice AI assistant.
35
-
36
- Your task: read a multi-turn conversation and produce a dense summary so the
37
- assistant can continue naturally, as if it remembers everything that was said.
38
-
39
- Output a single JSON object with exactly two string fields:
40
- "user_summary" — 1–5 sentences capturing what the user has been asking
41
- about, any preferences or constraints they have stated,
42
- and where the conversation stands from their perspective.
43
- "assistant_summary" — 1–5 sentences capturing what the assistant has
44
- explained, decided, or done (including tool calls and
45
- their results), plus any open questions or commitments.
46
-
47
- Rules:
48
- - Be information-dense: preserve names, numbers, file paths, error messages, and
49
- other specifics that would be needed to continue the conversation correctly.
50
- - Omit small-talk and filler that carries no forward context.
51
- - Write in third person, past tense
52
- (e.g. "The user asked about…", "The assistant provided…").
53
- - Emit only the JSON object — no markdown, no code fences, no extra keys.\
54
- """
55
-
56
- COMPACTION_USER_TEMPLATE = """\
57
- Summarize the following conversation. Return only the JSON object.
58
-
59
- --- CONVERSATION START ---
60
- {conversation}
61
- --- CONVERSATION END ---\
62
- """
63
-
64
- # ---------------------------------------------------------------------------
65
- # Helpers
66
- # ---------------------------------------------------------------------------
67
-
68
-
69
- def _render_transcript(snapshot: list[Any]) -> str:
70
- """Render a ResponseInputParam snapshot as a readable plain-text transcript."""
71
- lines: list[str] = []
72
- for item in snapshot:
73
- if not isinstance(item, dict):
74
- continue
75
- item_type = item.get("type", "message")
76
- role: str = item.get("role", "")
77
-
78
- if role == "system":
79
- continue
80
-
81
- if item_type == "function_call":
82
- name = item.get("name", "")
83
- args = item.get("arguments", "")
84
- lines.append(f"[Tool call: {name}({args})]")
85
- continue
86
-
87
- if item_type == "function_call_output":
88
- out = item.get("output", "")
89
- lines.append(f"[Tool result: {out}]")
90
- continue
91
-
92
- # Regular user / assistant message
93
- content = item.get("content", "")
94
- if isinstance(content, list):
95
- text = " ".join(
96
- c.get("text", "")
97
- for c in content
98
- if isinstance(c, dict) and c.get("type") in ("input_text", "output_text")
99
- ).strip()
100
- elif isinstance(content, str):
101
- text = content.strip()
102
- else:
103
- continue
104
-
105
- if text:
106
- label = role.capitalize() if role else "Unknown"
107
- lines.append(f"{label}: {text}")
108
-
109
- return "\n\n".join(lines)
110
-
111
-
112
- _JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
113
-
114
-
115
- def _extract_json(text: str) -> dict[str, Any]:
116
- """Extract the first JSON object from *text*, stripping markdown code fences."""
117
- try:
118
- return json.loads(text)
119
- except json.JSONDecodeError:
120
- pass
121
-
122
- m = _JSON_BLOCK_RE.search(text)
123
- if m:
124
- return json.loads(m.group(1))
125
-
126
- start = text.find("{")
127
- end = text.rfind("}")
128
- if start != -1 and end > start:
129
- return json.loads(text[start : end + 1])
130
-
131
- raise ValueError(f"No JSON object found in compaction response: {text!r}")
132
-
133
-
134
- # ---------------------------------------------------------------------------
135
- # Factory
136
- # ---------------------------------------------------------------------------
137
-
138
-
139
- def build_compactor(generate_fn: CompactGenerateFn) -> CompactFn:
140
- """Return a :data:`CompactFn` that summarizes history using *generate_fn*.
141
-
142
- *generate_fn* is the only model-specific dependency: it receives
143
- ``(system_prompt, user_prompt)`` and returns the model's text response.
144
- Both :class:`~speech_to_speech.LLM.openai_api_language_model.OpenApiModelHandler`
145
- and :class:`~speech_to_speech.LLM.language_model.BaseLanguageModelHandler`
146
- subclasses expose a ``_build_compaction_generate_fn()`` method that wraps
147
- their respective backend into this interface.
148
-
149
- The returned callable is safe to call from a background thread.
150
-
151
- Args:
152
- generate_fn: Backend-agnostic text generation callable.
153
-
154
- Returns:
155
- A callable ``(snapshot: ResponseInputParam) -> CompactionResult``.
156
- """
157
-
158
- def compact(snapshot: list[Any]) -> CompactionResult:
159
- transcript = _render_transcript(snapshot)
160
- if not transcript.strip():
161
- logger.warning("Compaction called with an empty transcript; returning empty summaries")
162
- return CompactionResult(user_summary="", assistant_summary="")
163
-
164
- user_content = COMPACTION_USER_TEMPLATE.format(conversation=transcript)
165
- raw_text = generate_fn(COMPACTION_SYSTEM_PROMPT, user_content)
166
-
167
- data = _extract_json(raw_text)
168
- user_summary = str(data.get("user_summary", "")).strip()
169
- assistant_summary = str(data.get("assistant_summary", "")).strip()
170
-
171
- if not user_summary or not assistant_summary:
172
- raise ValueError(f"Compaction response missing required fields. Got: {data!r}")
173
-
174
- logger.debug(
175
- "Compaction complete. user=%d chars assistant=%d chars",
176
- len(user_summary),
177
- len(assistant_summary),
178
- )
179
- return CompactionResult(user_summary=user_summary, assistant_summary=assistant_summary)
180
-
181
- return compact
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/language_model.py DELETED
@@ -1,1011 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from abc import ABC, abstractmethod
5
- from collections.abc import Iterable, Iterator, Sized
6
- from queue import Empty
7
- from threading import Lock, Thread
8
- from typing import Any, Literal, Optional, Protocol, runtime_checkable
9
-
10
- import torch
11
- from nltk import sent_tokenize
12
- from openai.types.realtime.realtime_conversation_item_function_call import (
13
- RealtimeConversationItemFunctionCall,
14
- )
15
- from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
16
- from openai.types.responses import ResponseFunctionToolCall
17
- from pydantic import BaseModel, ConfigDict, Field
18
- from transformers import (
19
- AutoModelForCausalLM,
20
- AutoModelForImageTextToText,
21
- AutoProcessor,
22
- AutoTokenizer,
23
- Pipeline,
24
- PreTrainedModel,
25
- PreTrainedTokenizerBase,
26
- StoppingCriteria,
27
- StoppingCriteriaList,
28
- TextIteratorStreamer,
29
- pipeline,
30
- )
31
-
32
- from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
33
- from speech_to_speech.baseHandler import BaseHandler
34
- from speech_to_speech.LLM.chat import (
35
- Chat,
36
- ChatItemError,
37
- build_active_chat,
38
- make_assistant_message,
39
- make_system_message,
40
- make_user_message,
41
- )
42
- from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn, build_compactor
43
- from speech_to_speech.LLM.text_prompt import build_text_system_prompt
44
- from speech_to_speech.LLM.tool_call.function_call import extract_function_calls_from_text
45
- from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
46
- from speech_to_speech.LLM.tool_call.tool_prompt import END_CODE, ENTER_CODE, build_block_regex, build_tool_system_prompt
47
- from speech_to_speech.LLM.utils import image_url_to_pil, remove_unspeechable, resolve_auto_language
48
- from speech_to_speech.LLM.voice_prompt import build_voice_system_prompt
49
- from speech_to_speech.pipeline.cancel_scope import CancelScope
50
- from speech_to_speech.pipeline.handler_types import LLMIn, LLMOut
51
- from speech_to_speech.pipeline.messages import (
52
- EndOfResponse,
53
- GenerateResponseRequest,
54
- LLMResponseChunk,
55
- TokenUsage,
56
- )
57
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
58
- from speech_to_speech.utils.utils import is_out_of_band, response_wants_audio
59
-
60
- try:
61
- import mlx.core as mx
62
- from mlx_lm import generate as mlx_generate
63
- from mlx_lm import load as mlx_load
64
- from mlx_lm import stream_generate as mlx_stream_generate
65
-
66
- from speech_to_speech.utils.mlx_lock import MLXLockContext
67
-
68
- HAS_MLX = True
69
- except ImportError:
70
- HAS_MLX = False
71
-
72
- try:
73
- from mlx_vlm import load as mlx_vlm_load
74
- from mlx_vlm import stream_generate as mlx_vlm_stream_generate
75
-
76
- HAS_MLX_VLM = True
77
- except ImportError:
78
- HAS_MLX_VLM = False
79
-
80
- logger = logging.getLogger(__name__)
81
-
82
-
83
- @runtime_checkable
84
- class _Tokenizer(Protocol):
85
- """Minimal interface the base class needs from any tokenizer."""
86
-
87
- def encode(self, text: str, /, **kwargs: Any) -> list[int] | Sized: ...
88
-
89
-
90
- @runtime_checkable
91
- class _Processor(Protocol):
92
- """Minimal interface the VLM handler needs from any processor."""
93
-
94
- tokenizer: _Tokenizer
95
-
96
- def apply_chat_template(self, conversation: Any, /, **kwargs: Any) -> Any: ...
97
- def __call__(self, **kwargs: Any) -> Any: ...
98
-
99
-
100
- class _CancelCriteria(StoppingCriteria):
101
- """Stopping criteria that can be signalled externally to abort generation."""
102
-
103
- def __init__(self) -> None:
104
- self._cancelled = False
105
-
106
- def cancel(self) -> None:
107
- self._cancelled = True
108
-
109
- def reset(self) -> None:
110
- self._cancelled = False
111
-
112
- def __call__(self, input_ids: Any, scores: Any, **kwargs: Any) -> bool:
113
- return self._cancelled
114
-
115
-
116
- class StreamContext(BaseModel):
117
- """Mutable accumulator passed through ``_stream_tokens`` so the caller
118
- can read back generation state after the iterator is exhausted."""
119
-
120
- model_config = ConfigDict(arbitrary_types_allowed=True)
121
-
122
- cancelled: bool = False
123
- stopped: bool = False
124
- raw_generated_text: str = ""
125
- generated_text: str = ""
126
- printable_text: str = ""
127
- tools: list[ResponseFunctionToolCall] = Field(default_factory=list)
128
- function_tools: list[FunctionTool] = Field(default_factory=list)
129
- block_regex: Optional[str] = None
130
- enter_code: Optional[str] = None
131
- end_code: Optional[str] = None
132
- input_tokens: int = 0
133
- sentence_batch: list[str] = Field(default_factory=list)
134
- turn_id: str | None = None
135
- turn_revision: int | None = None
136
- speech_stopped_at_s: float | None = None
137
- cancel_generation: int | None = None
138
-
139
- @property
140
- def interrupted(self) -> bool:
141
- """True when generation ended early for any reason."""
142
- return self.cancelled or self.stopped
143
-
144
-
145
- class BaseLanguageModelHandler(BaseHandler[LLMIn, LLMOut], ABC):
146
- """Abstract base for text-only and vision language model handlers.
147
-
148
- Holds shared pipeline logic (streaming, tool extraction, chat management)
149
- and delegates model-specific behaviour to two abstract hooks:
150
- ``_load_model`` and ``_generate``.
151
- """
152
-
153
- _cancel_criteria: _CancelCriteria
154
- streamer: Iterable[str]
155
- tokenizer: _Tokenizer
156
-
157
- def setup(
158
- self,
159
- model_name: str = "Qwen/Qwen3-4B-Instruct-2507",
160
- device: str = "cuda",
161
- torch_dtype: str = "float16",
162
- gen_kwargs: dict[str, Any] = {},
163
- user_role: str = "user",
164
- cancel_scope: CancelScope | None = None,
165
- speculative_turns: SpeculativeTurnTracker | None = None,
166
- backend: Literal["transformers", "mlx"] = "transformers",
167
- enable_thinking: bool = False,
168
- stream_batch_sentences: int = 3,
169
- enable_lang_prompt: bool = False,
170
- compact_history: bool = False,
171
- **_kwargs: Any,
172
- ) -> None:
173
- self.backend = backend
174
- self.cancel_scope = cancel_scope
175
- self.speculative_turns = speculative_turns
176
- self.device = device
177
- self.model_name = model_name
178
- self.enable_thinking = enable_thinking
179
- self.stream_batch_sentences = max(1, stream_batch_sentences)
180
- self.enable_lang_prompt = enable_lang_prompt
181
-
182
- self._load_model(model_name, device, torch_dtype, gen_kwargs)
183
-
184
- self.user_role = user_role
185
- # Serializes transformers pipe/model.generate calls between the speech
186
- # response path and the background compaction worker. MLX paths use
187
- # MLXLockContext instead.
188
- self._transformers_lock = Lock()
189
- self.compactor = build_compactor(self._build_compaction_generate_fn()) if compact_history else None
190
-
191
- def _turn_is_latest(self, turn_id: str | None, turn_revision: int | None) -> bool:
192
- return self.speculative_turns is None or self.speculative_turns.is_latest(turn_id, turn_revision)
193
-
194
- def _turn_output_allowed(self, turn_id: str | None, turn_revision: int | None) -> bool:
195
- if self.speculative_turns is None:
196
- return True
197
- return self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision)
198
-
199
- @abstractmethod
200
- def _load_model(
201
- self,
202
- model_name: str,
203
- device: str,
204
- torch_dtype: str,
205
- gen_kwargs: dict[str, Any],
206
- ) -> None:
207
- """Load the model, tokenizer, and any backend-specific resources."""
208
-
209
- def _build_compaction_generate_fn(self) -> CompactGenerateFn:
210
- """Return a ``(system, user) -> text`` callable for compaction.
211
-
212
- Subclasses must override this when ``compact_history=True`` is used.
213
- """
214
- raise NotImplementedError(
215
- f"{type(self).__name__} does not implement _build_compaction_generate_fn. "
216
- "Override it or pass compact_history=False."
217
- )
218
-
219
- @abstractmethod
220
- def _generate(
221
- self,
222
- chat: Chat,
223
- language_code: Optional[str],
224
- gen: int | None,
225
- ctx: StreamContext,
226
- runtime_config: RuntimeConfig | None = None,
227
- response: RealtimeResponseCreateParams | None = None,
228
- ) -> Iterator[LLMResponseChunk]:
229
- """Run model generation, stream sentence chunks, set ``ctx.input_tokens``."""
230
-
231
- # ------------------------------------------------------------------
232
- # Generation lifecycle helpers
233
- # ------------------------------------------------------------------
234
-
235
- def _finish_mlx_generation(self, token_iter: Any) -> None:
236
- """Close the MLX generator to release resources immediately."""
237
- token_iter.close()
238
-
239
- def _finish_transformers_generation(self, thread: Thread) -> None:
240
- """Signal the stopping criteria, drain the streamer, and join the thread."""
241
- self._cancel_criteria.cancel()
242
- try:
243
- for _ in self.streamer:
244
- pass
245
- except Empty:
246
- pass
247
- thread.join(timeout=5.0)
248
-
249
- # ------------------------------------------------------------------
250
- # Shared helpers
251
- # ------------------------------------------------------------------
252
-
253
- def _apply_instructions(
254
- self,
255
- chat: Chat,
256
- instructions: Optional[str],
257
- raw_tools: list[Any] | None,
258
- tool_choice: Optional[str],
259
- ctx: StreamContext | None = None,
260
- wants_audio: bool = True,
261
- ) -> None:
262
- if not instructions:
263
- return
264
-
265
- raw_tools = raw_tools or []
266
-
267
- function_tools = [FunctionTool(**t.model_dump()) for t in raw_tools if t.type == "function"]
268
-
269
- build_system_prompt = build_voice_system_prompt if wants_audio else build_text_system_prompt
270
-
271
- if function_tools and tool_choice != "none":
272
- tool_section = build_tool_system_prompt(function_tools, text_only=not wants_audio)
273
- full_instructions = build_system_prompt(instructions, tool_section=tool_section)
274
- block_regex = build_block_regex()
275
- enter_code = ENTER_CODE
276
- end_code = END_CODE
277
- else:
278
- full_instructions = build_system_prompt(instructions)
279
- block_regex = None
280
- enter_code = None
281
- end_code = None
282
-
283
- chat.add_item(make_system_message(full_instructions))
284
-
285
- if ctx is not None:
286
- ctx.function_tools = function_tools
287
- ctx.block_regex = block_regex
288
- ctx.enter_code = enter_code
289
- ctx.end_code = end_code
290
-
291
- def _process_printable_text(
292
- self,
293
- printable_text: str,
294
- language_code: Optional[str],
295
- tools: list[ResponseFunctionToolCall],
296
- ctx: StreamContext,
297
- runtime_config: RuntimeConfig | None = None,
298
- response: RealtimeResponseCreateParams | None = None,
299
- ) -> tuple[list[LLMResponseChunk], list[ResponseFunctionToolCall], str]:
300
- """Extract complete code blocks and return complete sentences to yield.
301
-
302
- Returns ``(chunks_to_yield, updated_tools, remaining_printable_text)``.
303
- Each element in *chunks_to_yield* is an :class:`LLMResponseChunk`
304
- ready to be yielded to the downstream pipeline.
305
-
306
- Sentences are accumulated in ``ctx.sentence_batch`` and only yielded
307
- when the batch reaches ``self.stream_batch_sentences``.
308
- """
309
- chunks: list[LLMResponseChunk] = []
310
-
311
- if ctx.enter_code and ctx.enter_code in printable_text:
312
- idx = printable_text.index(ctx.enter_code)
313
- before = printable_text[:idx]
314
- code_and_after = printable_text[idx:]
315
- if before.strip():
316
- for s in sent_tokenize(before):
317
- ctx.sentence_batch.append(s)
318
- if ctx.sentence_batch:
319
- chunks.append(
320
- LLMResponseChunk(
321
- text=" ".join(ctx.sentence_batch),
322
- language_code=language_code,
323
- runtime_config=runtime_config,
324
- response=response,
325
- turn_id=ctx.turn_id,
326
- turn_revision=ctx.turn_revision,
327
- speech_stopped_at_s=ctx.speech_stopped_at_s,
328
- cancel_generation=ctx.cancel_generation,
329
- )
330
- )
331
- ctx.sentence_batch = []
332
- if ctx.block_regex and ctx.end_code and ctx.end_code in code_and_after:
333
- stripped, func_calls = extract_function_calls_from_text(
334
- code_and_after,
335
- ctx.block_regex,
336
- )
337
- parsed_tools: list[ResponseFunctionToolCall] = []
338
- for fc in func_calls:
339
- if tools:
340
- logger.warning(
341
- "Skipping extra tool call '%s'; only one tool call is allowed per response",
342
- fc.function_name,
343
- )
344
- continue
345
- try:
346
- tool_call = fc.to_realtime_function_tool_call(ctx.function_tools)
347
- except ValueError as e:
348
- logger.warning("Skipping invalid tool call: %s", e)
349
- continue
350
- tools.append(tool_call)
351
- parsed_tools.append(tool_call)
352
- if parsed_tools:
353
- chunks.append(
354
- LLMResponseChunk(
355
- text="",
356
- language_code=language_code,
357
- tools=parsed_tools,
358
- runtime_config=runtime_config,
359
- response=response,
360
- turn_id=ctx.turn_id,
361
- turn_revision=ctx.turn_revision,
362
- speech_stopped_at_s=ctx.speech_stopped_at_s,
363
- cancel_generation=ctx.cancel_generation,
364
- )
365
- )
366
- printable_text = stripped
367
- else:
368
- printable_text = code_and_after
369
- return chunks, tools, printable_text
370
-
371
- if printable_text and not response_wants_audio(response) and ctx.enter_code is None:
372
- # Text-only with no tool block: stream the raw text immediately. No TTS
373
- # means no need to split into sentences, and sent_tokenize would collapse
374
- # newlines / markdown. Streaming (vs buffering to the end) keeps the
375
- # response interruptible by a new speech turn.
376
- chunks.append(
377
- LLMResponseChunk(
378
- text=printable_text,
379
- language_code=language_code,
380
- runtime_config=runtime_config,
381
- response=response,
382
- turn_id=ctx.turn_id,
383
- turn_revision=ctx.turn_revision,
384
- speech_stopped_at_s=ctx.speech_stopped_at_s,
385
- cancel_generation=ctx.cancel_generation,
386
- )
387
- )
388
- return chunks, tools, ""
389
-
390
- if printable_text:
391
- sentences = sent_tokenize(printable_text)
392
- if len(sentences) > 1:
393
- for s in sentences[:-1]:
394
- ctx.sentence_batch.append(s)
395
- if len(ctx.sentence_batch) >= self.stream_batch_sentences:
396
- chunks.append(
397
- LLMResponseChunk(
398
- text=" ".join(ctx.sentence_batch),
399
- language_code=language_code,
400
- runtime_config=runtime_config,
401
- response=response,
402
- turn_id=ctx.turn_id,
403
- turn_revision=ctx.turn_revision,
404
- speech_stopped_at_s=ctx.speech_stopped_at_s,
405
- cancel_generation=ctx.cancel_generation,
406
- )
407
- )
408
- ctx.sentence_batch = []
409
- printable_text = sentences[-1]
410
-
411
- return chunks, tools, printable_text
412
-
413
- def _check_stop(self, gen: int | None, ctx: StreamContext) -> bool:
414
- """Check whether generation should be aborted and mark the reason on *ctx*."""
415
- if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen):
416
- ctx.cancelled = True
417
- logger.info("LLM generation cancelled (interruption)")
418
- return True
419
- if not self._turn_is_latest(ctx.turn_id, ctx.turn_revision):
420
- ctx.cancelled = True
421
- logger.info("LLM generation cancelled (stale speculative turn)")
422
- return True
423
- if self.stop_event.is_set():
424
- ctx.stopped = True
425
- logger.info("LLM generation stopped (shutdown)")
426
- return True
427
- return False
428
-
429
- def _stream_tokens(
430
- self,
431
- token_iter: Iterator[Any],
432
- gen: int | None,
433
- language_code: Optional[str],
434
- ctx: StreamContext,
435
- runtime_config: RuntimeConfig | None = None,
436
- response: RealtimeResponseCreateParams | None = None,
437
- ) -> Iterator[LLMResponseChunk]:
438
- """Consume *token_iter*, accumulate text in *ctx*, yield sentence chunks."""
439
- # Text-only output keeps every character; only audio strips TTS-unfriendly
440
- # symbols via remove_unspeechable.
441
- wants_audio = response_wants_audio(response)
442
- while True:
443
- try:
444
- token = next(token_iter)
445
- except StopIteration:
446
- break
447
- except Empty:
448
- if self._check_stop(gen, ctx):
449
- break
450
- continue
451
-
452
- if self._check_stop(gen, ctx):
453
- break
454
-
455
- raw_text: str = token.text if hasattr(token, "text") else token
456
- ctx.raw_generated_text += raw_text
457
- clean = raw_text if not wants_audio else remove_unspeechable(raw_text)
458
- ctx.generated_text += clean
459
- ctx.printable_text += clean
460
- chunks, ctx.tools, ctx.printable_text = self._process_printable_text(
461
- ctx.printable_text,
462
- language_code,
463
- ctx.tools,
464
- ctx,
465
- runtime_config,
466
- response,
467
- )
468
- if chunks and not self._turn_output_allowed(ctx.turn_id, ctx.turn_revision):
469
- ctx.cancelled = True
470
- logger.info("LLM generation cancelled (stale speculative turn)")
471
- break
472
- yield from chunks
473
-
474
- if ctx.sentence_batch and not ctx.interrupted:
475
- if ctx.printable_text.strip():
476
- ctx.sentence_batch.append(ctx.printable_text.strip())
477
- ctx.printable_text = ""
478
- if not self._turn_output_allowed(ctx.turn_id, ctx.turn_revision):
479
- ctx.cancelled = True
480
- logger.info("LLM generation cancelled (stale speculative turn)")
481
- return
482
- yield LLMResponseChunk(
483
- text=" ".join(ctx.sentence_batch),
484
- language_code=language_code,
485
- runtime_config=runtime_config,
486
- response=response,
487
- turn_id=ctx.turn_id,
488
- turn_revision=ctx.turn_revision,
489
- speech_stopped_at_s=ctx.speech_stopped_at_s,
490
- cancel_generation=ctx.cancel_generation,
491
- )
492
- ctx.sentence_batch = []
493
-
494
- # ------------------------------------------------------------------
495
- # Main pipeline entry point
496
- # ------------------------------------------------------------------
497
-
498
- def process(self, request: LLMIn) -> Iterator[LLMOut]:
499
- ctx = StreamContext()
500
-
501
- if not isinstance(request, GenerateResponseRequest):
502
- raise TypeError(f"Unexpected request type: {type(request)}")
503
-
504
- ctx.turn_id = request.turn_id
505
- ctx.turn_revision = request.turn_revision
506
- ctx.speech_stopped_at_s = request.speech_stopped_at_s
507
- if not self._turn_is_latest(ctx.turn_id, ctx.turn_revision):
508
- logger.info("Skipping stale LLM request for turn=%s rev=%s", ctx.turn_id, ctx.turn_revision)
509
- yield EndOfResponse(turn_id=ctx.turn_id, turn_revision=ctx.turn_revision)
510
- return
511
-
512
- runtime_config = request.runtime_config
513
- response = request.response
514
- original_chat = runtime_config.chat
515
- out_of_band = is_out_of_band(response)
516
- if out_of_band:
517
- try:
518
- active_chat = build_active_chat(original_chat, response)
519
- except ChatItemError as exc:
520
- logger.info("Out-of-band response rejected: %s", exc)
521
- yield EndOfResponse(turn_id=ctx.turn_id, turn_revision=ctx.turn_revision, error=str(exc))
522
- return
523
- else:
524
- active_chat = original_chat.copy()
525
- language_code = request.language_code
526
- instructions = (
527
- response.instructions if response and response.instructions else runtime_config.session.instructions
528
- )
529
- tools = response.tools if response and response.tools else runtime_config.session.tools
530
- tool_choice = response.tool_choice if response and response.tool_choice else runtime_config.session.tool_choice
531
- self._apply_instructions(
532
- active_chat,
533
- instructions,
534
- tools,
535
- str(tool_choice) if tool_choice else None,
536
- ctx,
537
- response_wants_audio(response),
538
- )
539
- language_code, lang_name = resolve_auto_language(language_code)
540
- if lang_name and self.enable_lang_prompt:
541
- active_chat.add_item(make_user_message(f"Please reply to my message in {lang_name}."))
542
-
543
- gen = self.cancel_scope.generation if self.cancel_scope else None
544
- ctx.cancel_generation = gen
545
- # Images the model sees this turn; only these are stripped on write-back,
546
- # so an image a fast client injects mid-generation for the next turn
547
- # survives (it is not in this serialized snapshot).
548
- consumed_image_ids = active_chat.image_message_ids()
549
-
550
- try:
551
- yield from self._generate(active_chat, language_code, gen, ctx, runtime_config, response)
552
-
553
- if ctx.stopped:
554
- return
555
-
556
- turn_output_allowed = not ctx.cancelled and self._turn_output_allowed(ctx.turn_id, ctx.turn_revision)
557
- # Out-of-band responses still emit output, but never write back to the default
558
- # conversation (their context was a throwaway chat).
559
- commit_allowed = turn_output_allowed and not out_of_band
560
- if commit_allowed:
561
- original_chat.add_item(make_assistant_message(ctx.generated_text))
562
- if commit_allowed and ctx.tools:
563
- for t in ctx.tools:
564
- original_chat.add_item(
565
- RealtimeConversationItemFunctionCall(
566
- type="function_call",
567
- id=t.id,
568
- call_id=t.call_id,
569
- name=t.name,
570
- arguments=t.arguments,
571
- status=t.status,
572
- )
573
- )
574
- if commit_allowed:
575
- original_chat.strip_images(consumed_image_ids)
576
- original_chat.trim_if_needed(self.compactor)
577
- logger.debug("Clean text: %s", ctx.generated_text)
578
- logger.info(f"Tools: {ctx.tools}")
579
-
580
- if turn_output_allowed and ctx.printable_text.strip():
581
- yield LLMResponseChunk(
582
- text=ctx.printable_text.strip(),
583
- language_code=language_code,
584
- runtime_config=runtime_config,
585
- response=response,
586
- turn_id=ctx.turn_id,
587
- turn_revision=ctx.turn_revision,
588
- speech_stopped_at_s=ctx.speech_stopped_at_s,
589
- cancel_generation=ctx.cancel_generation,
590
- )
591
-
592
- output_tokens = len(self.tokenizer.encode(ctx.raw_generated_text)) if ctx.raw_generated_text else 0
593
- if turn_output_allowed and (ctx.input_tokens or output_tokens):
594
- yield TokenUsage(
595
- input_tokens=ctx.input_tokens,
596
- output_tokens=output_tokens,
597
- turn_id=ctx.turn_id,
598
- turn_revision=ctx.turn_revision,
599
- )
600
- except Exception as exc:
601
- # Any generation failure must still terminate the response. Without this
602
- # the exception would escape process() and no EndOfResponse would be
603
- # emitted, leaving st.in_response stuck and locking every later response.
604
- logger.exception("LLM generation failed; ending the current response")
605
- yield EndOfResponse(
606
- turn_id=ctx.turn_id,
607
- turn_revision=ctx.turn_revision,
608
- cancel_generation=ctx.cancel_generation,
609
- error=f"Language model generation failed: {exc}",
610
- )
611
- return
612
- yield EndOfResponse(
613
- turn_id=ctx.turn_id,
614
- turn_revision=ctx.turn_revision,
615
- cancel_generation=ctx.cancel_generation,
616
- )
617
-
618
- def on_session_end(self) -> None:
619
- logger.debug("Language model session state reset")
620
-
621
-
622
- # ======================================================================
623
- # Text-only LLM
624
- # ======================================================================
625
-
626
-
627
- class LanguageModelHandler(BaseLanguageModelHandler):
628
- """Text-only language model handler (transformers or MLX backend)."""
629
-
630
- model: PreTrainedModel
631
- tokenizer: PreTrainedTokenizerBase
632
- gen_kwargs: dict
633
- torch_dtype: torch.dtype
634
- pipe: Pipeline
635
- streamer: TextIteratorStreamer
636
-
637
- def setup(self, **kwargs: Any) -> None: # type: ignore[override]
638
- super().setup(**kwargs)
639
- logger.info(f"LLM Backend: {self.backend}")
640
- self.warmup()
641
-
642
- def _load_model(self, model_name: str, device: str, torch_dtype: str, gen_kwargs: dict[str, Any]) -> None:
643
-
644
- logger.info("LLM Language Model Handler setup")
645
-
646
- if self.backend == "mlx":
647
- if not HAS_MLX:
648
- raise ImportError("mlx_lm is required for the 'mlx' backend. Install with: pip install mlx-lm")
649
- self.model, self.tokenizer = mlx_load(model_name) # type: ignore[assignment, misc]
650
- self.gen_kwargs = gen_kwargs
651
- else:
652
- self.torch_dtype = getattr(torch, torch_dtype)
653
- self.tokenizer = AutoTokenizer.from_pretrained(model_name) # type: ignore[assignment]
654
- self.model = AutoModelForCausalLM.from_pretrained(
655
- model_name, torch_dtype=torch_dtype, trust_remote_code=True
656
- ).to(device) # type: ignore[arg-type]
657
- self.pipe = pipeline( # type: ignore[call-overload]
658
- "text-generation", model=self.model, tokenizer=self.tokenizer, device=device
659
- )
660
- self.streamer = TextIteratorStreamer(
661
- self.tokenizer, # type: ignore[arg-type]
662
- skip_prompt=True,
663
- skip_special_tokens=True,
664
- timeout=1.0,
665
- )
666
- self._cancel_criteria = _CancelCriteria()
667
- self.gen_kwargs = {
668
- "streamer": self.streamer,
669
- "return_full_text": False,
670
- "stopping_criteria": StoppingCriteriaList([self._cancel_criteria]),
671
- **gen_kwargs,
672
- }
673
-
674
- def _generate(
675
- self,
676
- chat: Chat,
677
- language_code: Optional[str],
678
- gen: int | None,
679
- ctx: StreamContext,
680
- runtime_config: RuntimeConfig | None = None,
681
- response: RealtimeResponseCreateParams | None = None,
682
- ) -> Iterator[LLMResponseChunk]:
683
- chat_messages = chat.to_transformers_chat()
684
- chat_input = self.tokenizer.apply_chat_template(chat_messages, tokenize=True)
685
- ctx.input_tokens += len(chat_input if isinstance(chat_input, list) else chat_input["input_ids"]) # type: ignore[index]
686
- logger.debug("Prompt token count: %d", ctx.input_tokens)
687
-
688
- chat_prompt = self.tokenizer.apply_chat_template(
689
- chat_messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
690
- )
691
-
692
- if self.backend == "mlx":
693
- with MLXLockContext(handler_name="MLX-LLM", timeout=10.0):
694
- token_iter = mlx_stream_generate(
695
- self.model, # type: ignore[arg-type]
696
- self.tokenizer, # type: ignore[arg-type]
697
- chat_prompt, # type: ignore[arg-type]
698
- max_tokens=self.gen_kwargs["max_new_tokens"],
699
- )
700
- yield from self._stream_tokens(token_iter, gen, language_code, ctx, runtime_config, response)
701
- self._finish_mlx_generation(token_iter)
702
- try:
703
- mx.clear_cache()
704
- except Exception:
705
- pass
706
- torch.mps.empty_cache()
707
- else:
708
- self._cancel_criteria.reset()
709
- lock = self._transformers_lock
710
-
711
- def _locked_pipe() -> None:
712
- with lock:
713
- self.pipe(chat_prompt, **self.gen_kwargs)
714
-
715
- thread = Thread(target=_locked_pipe)
716
- thread.start()
717
- yield from self._stream_tokens(self.streamer, gen, language_code, ctx, runtime_config, response)
718
- self._finish_transformers_generation(thread)
719
- if self.device == "mps":
720
- torch.mps.empty_cache()
721
-
722
- def _build_compaction_generate_fn(self) -> CompactGenerateFn:
723
- if self.backend == "mlx":
724
- model = self.model
725
- tokenizer = self.tokenizer
726
- max_tokens = 1024
727
-
728
- def generate_mlx(system: str, user: str) -> str:
729
- messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
730
- prompt = tokenizer.apply_chat_template( # type: ignore[union-attr]
731
- messages, tokenize=False, add_generation_prompt=True
732
- )
733
- with MLXLockContext(handler_name="MLX-compact", timeout=10.0):
734
- return mlx_generate(model, tokenizer, prompt=prompt, max_tokens=max_tokens, verbose=False) # type: ignore[arg-type]
735
-
736
- return generate_mlx
737
- else:
738
- pipe = self.pipe
739
- tokenizer = self.tokenizer
740
- gen_kwargs = {
741
- k: v
742
- for k, v in self.gen_kwargs.items()
743
- if k not in ("streamer", "stopping_criteria", "max_new_tokens", "return_full_text")
744
- }
745
- max_new_tokens = 1024
746
- lock = self._transformers_lock
747
-
748
- def generate_transformers(system: str, user: str) -> str:
749
- messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
750
- prompt = tokenizer.apply_chat_template( # type: ignore[union-attr]
751
- messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
752
- )
753
- with lock:
754
- result = pipe(prompt, return_full_text=False, max_new_tokens=max_new_tokens, **gen_kwargs)
755
- return result[0]["generated_text"] # type: ignore[index]
756
-
757
- return generate_transformers
758
-
759
- def warmup(self) -> None:
760
- logger.info(f"Warming up {self.__class__.__name__}")
761
-
762
- dummy_input_text = "Repeat the word 'home'."
763
- dummy_chat = [{"role": self.user_role, "content": dummy_input_text}]
764
- n_steps = 2
765
-
766
- if self.backend == "mlx":
767
- for _ in range(n_steps):
768
- prompt = self.tokenizer.apply_chat_template(dummy_chat, tokenize=False)
769
- mlx_generate(
770
- self.model, # type: ignore[arg-type]
771
- self.tokenizer, # type: ignore[arg-type]
772
- prompt=prompt, # type: ignore[arg-type]
773
- max_tokens=self.gen_kwargs["max_new_tokens"],
774
- verbose=False,
775
- )
776
- else:
777
- warmup_gen_kwargs = {
778
- "min_new_tokens": self.gen_kwargs["min_new_tokens"],
779
- "max_new_tokens": self.gen_kwargs["max_new_tokens"],
780
- **self.gen_kwargs,
781
- }
782
-
783
- if self.device == "cuda":
784
- start_event = torch.cuda.Event(enable_timing=True)
785
- end_event = torch.cuda.Event(enable_timing=True)
786
- torch.cuda.synchronize()
787
- start_event.record()
788
-
789
- for _ in range(n_steps):
790
- thread = Thread(target=self.pipe, args=(dummy_chat,), kwargs=warmup_gen_kwargs)
791
- thread.start()
792
- for _ in self.streamer:
793
- pass
794
-
795
- if self.device == "cuda":
796
- end_event.record()
797
- torch.cuda.synchronize()
798
- logger.info(
799
- f"{self.__class__.__name__}: warmed up! time: {start_event.elapsed_time(end_event) * 1e-3:.3f} s"
800
- )
801
-
802
-
803
- # ======================================================================
804
- # Vision Language Model (VLM)
805
- # ======================================================================
806
-
807
-
808
- class VisionLanguageModelHandler(BaseLanguageModelHandler):
809
- """Vision language model handler (transformers or MLX-VLM backend)."""
810
-
811
- model: PreTrainedModel
812
- tokenizer: PreTrainedTokenizerBase
813
- processor: _Processor
814
- gen_kwargs: dict
815
- torch_dtype: torch.dtype
816
- streamer: TextIteratorStreamer
817
-
818
- def setup(self, **kwargs: Any) -> None: # type: ignore[override]
819
- super().setup(**kwargs)
820
- logger.info(f"VLM Backend: {self.backend}")
821
-
822
- def _load_model(self, model_name: str, device: str, torch_dtype: str, gen_kwargs: dict[str, Any]) -> None:
823
-
824
- logger.info("VLM Language Model Handler setup")
825
-
826
- if self.backend == "mlx":
827
- if not HAS_MLX_VLM:
828
- raise ImportError("mlx-vlm is required for MLX VLM models. Install with: pip install mlx-vlm")
829
- self.model, self.processor = mlx_vlm_load(model_name) # type: ignore[assignment]
830
- self.tokenizer = self.processor.tokenizer # type: ignore[assignment]
831
- self.gen_kwargs = gen_kwargs
832
- else:
833
- self.torch_dtype = getattr(torch, torch_dtype)
834
- self.processor = AutoProcessor.from_pretrained(model_name, trust_remote_code=True) # type: ignore[assignment]
835
- self.tokenizer = self.processor.tokenizer # type: ignore[assignment]
836
- self.model = AutoModelForImageTextToText.from_pretrained(
837
- model_name, torch_dtype=self.torch_dtype, trust_remote_code=True
838
- ).to(device) # type: ignore[arg-type]
839
- self.streamer = TextIteratorStreamer(
840
- self.tokenizer, # type: ignore[arg-type]
841
- skip_prompt=True,
842
- skip_special_tokens=True,
843
- timeout=1.0,
844
- )
845
- self._cancel_criteria = _CancelCriteria()
846
- self.gen_kwargs = gen_kwargs
847
-
848
- def _generate(
849
- self,
850
- chat: Chat,
851
- language_code: Optional[str],
852
- gen: int | None,
853
- ctx: StreamContext,
854
- runtime_config: RuntimeConfig | None = None,
855
- response: RealtimeResponseCreateParams | None = None,
856
- ) -> Iterator[LLMResponseChunk]:
857
- prepared = chat.to_transformers_chat()
858
- if self.backend == "mlx":
859
- images, formatted_prompt = self._prepare_mlx_vlm_inputs(prepared)
860
- ctx.input_tokens += len(self.tokenizer.encode(formatted_prompt))
861
- logger.debug("MLX VLM prompt token count: %d", ctx.input_tokens)
862
-
863
- with MLXLockContext(handler_name="MLX-VLM", timeout=10.0):
864
- token_iter = mlx_vlm_stream_generate( # type: ignore[arg-type]
865
- self.model,
866
- self.processor,
867
- formatted_prompt,
868
- images or None,
869
- max_tokens=self.gen_kwargs.get("max_new_tokens", 1024),
870
- enable_thinking=self.enable_thinking,
871
- )
872
- yield from self._stream_tokens(token_iter, gen, language_code, ctx, runtime_config, response)
873
- self._finish_mlx_generation(token_iter)
874
- try:
875
- mx.clear_cache()
876
- except Exception:
877
- pass
878
- torch.mps.empty_cache()
879
- else:
880
- inputs, input_tokens = self._prepare_vlm_inputs(prepared)
881
- ctx.input_tokens += input_tokens
882
- logger.debug("VLM prompt token count: %d", ctx.input_tokens)
883
-
884
- self._cancel_criteria.reset()
885
- generate_kwargs = {
886
- **inputs,
887
- "max_new_tokens": self.gen_kwargs.get("max_new_tokens", 1024),
888
- "streamer": self.streamer,
889
- "stopping_criteria": StoppingCriteriaList([self._cancel_criteria]),
890
- }
891
- lock = self._transformers_lock
892
-
893
- def _locked_generate() -> None:
894
- with lock:
895
- self.model.generate(**generate_kwargs) # type: ignore[union-attr,operator]
896
-
897
- thread = Thread(target=_locked_generate)
898
- thread.start()
899
- yield from self._stream_tokens(self.streamer, gen, language_code, ctx, runtime_config, response)
900
- self._finish_transformers_generation(thread)
901
- if self.device == "mps":
902
- torch.mps.empty_cache()
903
-
904
- def _build_compaction_generate_fn(self) -> CompactGenerateFn:
905
- if self.backend == "mlx":
906
- model = self.model
907
- processor = self.processor
908
- tokenizer = self.tokenizer
909
- max_tokens = 1024
910
-
911
- def generate_mlx(system: str, user: str) -> str:
912
- messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
913
- formatted_prompt = processor.apply_chat_template( # type: ignore[union-attr]
914
- messages, tokenize=False, add_generation_prompt=True
915
- )
916
- with MLXLockContext(handler_name="MLX-VLM-compact", timeout=10.0):
917
- token_iter = mlx_vlm_stream_generate( # type: ignore[arg-type]
918
- model, processor, formatted_prompt, None, max_tokens=max_tokens
919
- )
920
- return "".join(t.text if hasattr(t, "text") else str(t) for t in token_iter)
921
-
922
- return generate_mlx
923
- else:
924
- model = self.model
925
- processor = self.processor
926
- tokenizer = self.tokenizer
927
- device = self.device
928
- max_new_tokens = 1024
929
- lock = self._transformers_lock
930
-
931
- def generate_transformers(system: str, user: str) -> str:
932
- messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
933
- prompt = processor.apply_chat_template( # type: ignore[union-attr]
934
- messages, tokenize=False, add_generation_prompt=True
935
- )
936
- inputs = processor(text=[prompt], return_tensors="pt", padding=True).to(device) # type: ignore[operator]
937
- input_len = inputs["input_ids"].shape[1]
938
- with lock:
939
- output_ids = model.generate(**inputs, max_new_tokens=max_new_tokens) # type: ignore[union-attr,operator]
940
- return str(tokenizer.decode(output_ids[0][input_len:], skip_special_tokens=True)) # type: ignore[union-attr]
941
-
942
- return generate_transformers
943
-
944
- def _prepare_vlm_inputs(self, chat_messages: list[dict[str, Any]]) -> tuple[Any, int]:
945
- """Build processor inputs for transformers VLM generation.
946
-
947
- Converts Realtime-style content (``input_text``/``input_image``) to the
948
- transformers chat-template format (``text``/``image``) and decodes image
949
- URLs to PIL images.
950
- """
951
- converted_messages = []
952
- images = []
953
- for msg in chat_messages:
954
- content = msg.get("content")
955
- if isinstance(content, list):
956
- parts = []
957
- for p in content:
958
- if p.get("type") == "input_text":
959
- parts.append({"type": "text", "text": p["text"]})
960
- elif p.get("type") == "input_image":
961
- pil_img = image_url_to_pil(p["image_url"])
962
- images.append(pil_img)
963
- parts.append({"type": "image", "image": pil_img})
964
- converted_messages.append({"role": msg["role"], "content": parts})
965
- else:
966
- converted_messages.append(msg)
967
-
968
- text_prompt = self.processor.apply_chat_template(
969
- converted_messages,
970
- tokenize=False,
971
- add_generation_prompt=True,
972
- )
973
- inputs = self.processor(
974
- text=[text_prompt],
975
- images=images if images else None,
976
- return_tensors="pt",
977
- padding=True,
978
- ).to(self.device)
979
- return inputs, len(inputs["input_ids"][0])
980
-
981
- def _prepare_mlx_vlm_inputs(self, chat_messages: list[dict[str, Any]]) -> tuple[list[Any], str]:
982
- """Build formatted prompt and extract PIL images for MLX VLM generation.
983
-
984
- Converts Realtime-style content (``input_text``/``input_image``) to the
985
- HF chat-template format and uses the processor's own ``apply_chat_template``
986
- for full multi-turn support.
987
-
988
- Returns ``(images, formatted_prompt)`` ready for ``mlx_vlm.stream_generate``.
989
- """
990
- images = []
991
- converted_messages = []
992
- for msg in chat_messages:
993
- content = msg.get("content")
994
- if isinstance(content, list):
995
- parts = []
996
- for p in content:
997
- if p.get("type") == "input_text":
998
- parts.append({"type": "text", "text": p["text"]})
999
- elif p.get("type") == "input_image":
1000
- images.append(image_url_to_pil(p["image_url"]))
1001
- parts.append({"type": "image"})
1002
- converted_messages.append({"role": msg["role"], "content": parts})
1003
- else:
1004
- converted_messages.append(msg)
1005
-
1006
- formatted_prompt = self.processor.apply_chat_template(
1007
- converted_messages,
1008
- tokenize=False,
1009
- add_generation_prompt=True,
1010
- )
1011
- return images, formatted_prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/lm_output_processor.py DELETED
@@ -1,148 +0,0 @@
1
- """
2
- LLM Output Processor
3
-
4
- Intercepts LLM output to:
5
- 1. Extract tool calls and send them via text_output_queue
6
- 2. Forward clean text to TTS pipeline
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import logging
12
- from collections.abc import Iterator
13
- from queue import Queue
14
-
15
- from speech_to_speech.baseHandler import BaseHandler
16
- from speech_to_speech.pipeline.events import AssistantTextEvent, ResponseFailedEvent, TokenUsageEvent
17
- from speech_to_speech.pipeline.handler_types import LLMOut, TTSIn
18
- from speech_to_speech.pipeline.messages import EndOfResponse, LLMResponseChunk, TokenUsage, TTSInput
19
- from speech_to_speech.pipeline.queue_types import TextEventItem
20
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
21
- from speech_to_speech.utils.utils import response_wants_audio
22
-
23
- logger = logging.getLogger(__name__)
24
-
25
-
26
- class LMOutputProcessor(BaseHandler[LLMOut, TTSIn]):
27
- """
28
- Processes LLM output to extract tool calls and forward clean text to TTS.
29
-
30
- Input: :class:`LLMResponseChunk`, :class:`TokenUsage`, or :class:`EndOfResponse` from LLM
31
- Output: :class:`TTSInput` or :class:`EndOfResponse` to TTS
32
- Side effect: Sends :class:`AssistantTextEvent` / :class:`TokenUsageEvent` to text_output_queue
33
- """
34
-
35
- def setup(
36
- self,
37
- text_output_queue: Queue[TextEventItem] | None = None,
38
- speculative_turns: SpeculativeTurnTracker | None = None,
39
- ) -> None:
40
- """
41
- Initialize the processor.
42
-
43
- Args:
44
- text_output_queue: Queue to send text messages and tool calls
45
- """
46
- self.text_output_queue = text_output_queue
47
- self.speculative_turns = speculative_turns
48
-
49
- def _turn_output_allowed(self, turn_id: str | None, turn_revision: int | None) -> bool:
50
- if self.speculative_turns is None:
51
- return True
52
- return self.speculative_turns.is_latest_after_reopen_grace(turn_id, turn_revision)
53
-
54
- def process(self, lm_output: LLMOut) -> Iterator[TTSIn]:
55
- """
56
- Process LLM output: send text/tools to WebSocket, forward clean text to TTS.
57
-
58
- Yields:
59
- :class:`TTSInput` or :class:`EndOfResponse` for TTS
60
- """
61
- if isinstance(lm_output, TokenUsage):
62
- if not self._turn_output_allowed(
63
- lm_output.turn_id,
64
- lm_output.turn_revision,
65
- ):
66
- logger.debug(
67
- "Dropping stale token usage for turn=%s rev=%s", lm_output.turn_id, lm_output.turn_revision
68
- )
69
- return
70
- if self.text_output_queue is not None:
71
- self.text_output_queue.put(
72
- TokenUsageEvent(
73
- input_tokens=lm_output.input_tokens or 0,
74
- output_tokens=lm_output.output_tokens or 0,
75
- turn_id=lm_output.turn_id,
76
- turn_revision=lm_output.turn_revision,
77
- )
78
- )
79
- return
80
-
81
- if isinstance(lm_output, EndOfResponse):
82
- if not self._turn_output_allowed(
83
- lm_output.turn_id,
84
- lm_output.turn_revision,
85
- ):
86
- logger.debug(
87
- "Dropping stale end-of-response for turn=%s rev=%s",
88
- lm_output.turn_id,
89
- lm_output.turn_revision,
90
- )
91
- return
92
- # A failed generation (e.g. invalid out-of-band input) closes the response as
93
- # "failed" via the text side-channel, then falls through to emit the normal
94
- # EndOfResponse so the audio path still re-enables listening / releases the slot.
95
- if lm_output.error and self.text_output_queue is not None:
96
- self.text_output_queue.put(
97
- ResponseFailedEvent(
98
- message=lm_output.error,
99
- turn_id=lm_output.turn_id,
100
- turn_revision=lm_output.turn_revision,
101
- )
102
- )
103
- yield EndOfResponse(
104
- turn_id=lm_output.turn_id,
105
- turn_revision=lm_output.turn_revision,
106
- cancel_generation=lm_output.cancel_generation,
107
- )
108
- return
109
-
110
- if not isinstance(lm_output, LLMResponseChunk):
111
- logger.warning("LMOutputProcessor received unexpected type: %s", type(lm_output))
112
- return
113
-
114
- if not self._turn_output_allowed(
115
- lm_output.turn_id,
116
- lm_output.turn_revision,
117
- ):
118
- logger.debug("Dropping stale LLM chunk for turn=%s rev=%s", lm_output.turn_id, lm_output.turn_revision)
119
- return
120
-
121
- logger.debug(f"LM processor: text='{lm_output.text}', tools={lm_output.tools}")
122
-
123
- if self.text_output_queue is not None:
124
- event = AssistantTextEvent(
125
- text=lm_output.text,
126
- turn_id=lm_output.turn_id,
127
- turn_revision=lm_output.turn_revision,
128
- cancel_generation=lm_output.cancel_generation,
129
- )
130
- if lm_output.tools:
131
- event.tools = lm_output.tools
132
- logger.info(f"Sending to clients: text='{lm_output.text}', tools={[t.name for t in lm_output.tools]}")
133
- else:
134
- logger.debug(f"Sending to clients: text='{lm_output.text}' (no tools)")
135
- self.text_output_queue.put(event)
136
-
137
- if lm_output.text and response_wants_audio(lm_output.response):
138
- logger.debug(f"Forwarding to TTS: '{lm_output.text}'")
139
- yield TTSInput(
140
- text=lm_output.text,
141
- language_code=lm_output.language_code,
142
- runtime_config=lm_output.runtime_config,
143
- response=lm_output.response,
144
- turn_id=lm_output.turn_id,
145
- turn_revision=lm_output.turn_revision,
146
- speech_stopped_at_s=lm_output.speech_stopped_at_s,
147
- cancel_generation=lm_output.cancel_generation,
148
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/responses_api_language_model.py DELETED
@@ -1,201 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- import time
5
- from collections.abc import Iterator
6
- from typing import Any
7
-
8
- from openai import Stream
9
- from openai.types.realtime.realtime_conversation_item_assistant_message import (
10
- Content as AssistantContent,
11
- )
12
- from openai.types.responses import (
13
- ResponseCompletedEvent,
14
- ResponseFunctionToolCall,
15
- ResponseOutputItemDoneEvent,
16
- ResponseOutputMessage,
17
- ResponseTextDeltaEvent,
18
- )
19
-
20
- from speech_to_speech.LLM.base_openai_compatible_language_model import (
21
- WARMUP_MAX_RETRIES,
22
- AssistantMessage,
23
- BaseOpenAICompatibleHandler,
24
- ProviderEvent,
25
- TextDelta,
26
- ToolCall,
27
- Usage,
28
- )
29
- from speech_to_speech.LLM.chat import Chat
30
- from speech_to_speech.LLM.chat_completions_language_model import (
31
- _build_chat_optional_kwargs,
32
- _chat_messages,
33
- _iter_chat_response_events,
34
- _iter_chat_stream_events,
35
- _request_chat_completions,
36
- )
37
- from speech_to_speech.LLM.compaction_prompt import CompactGenerateFn
38
- from speech_to_speech.utils.utils import _generate_id
39
-
40
- logger = logging.getLogger(__name__)
41
-
42
-
43
- class ResponsesApiModelHandler(BaseOpenAICompatibleHandler):
44
- """LLM handler that talks to an OpenAI ``/v1/responses`` server."""
45
-
46
- def warmup(self) -> None:
47
- logger.info(f"Warming up {self.__class__.__name__}")
48
- start = time.time()
49
- self.client.with_options(max_retries=WARMUP_MAX_RETRIES).responses.create(
50
- model=self.model_name,
51
- input=[
52
- {
53
- "type": "message",
54
- "role": "system",
55
- "content": [{"type": "input_text", "text": "You are a helpful assistant"}],
56
- },
57
- {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hello"}]},
58
- ],
59
- timeout=self.request_timeout,
60
- )
61
- end = time.time()
62
- logger.info(f"{self.__class__.__name__}: warmed up! time: {(end - start):.3f} s")
63
-
64
- def _build_compaction_generate_fn(self) -> CompactGenerateFn:
65
- """Return a generate fn that calls the Responses API for compaction."""
66
- client = self.client
67
- model_name = self.model_name
68
- timeout = self.request_timeout
69
-
70
- def generate(system: str, user: str) -> str:
71
- response = client.responses.create(
72
- model=model_name,
73
- input=[
74
- {
75
- "type": "message",
76
- "role": "system",
77
- "content": [{"type": "input_text", "text": system}],
78
- },
79
- {
80
- "type": "message",
81
- "role": "user",
82
- "content": [{"type": "input_text", "text": user}],
83
- },
84
- ],
85
- timeout=timeout,
86
- )
87
- return response.output_text
88
-
89
- return generate
90
-
91
- def _build_audio_optional_kwargs(
92
- self,
93
- response: Any,
94
- req_tools: Any,
95
- req_tool_choice: Any,
96
- ) -> dict[str, Any]:
97
- kwargs = _build_chat_optional_kwargs(req_tools, req_tool_choice)
98
- max_tokens = getattr(response, "max_output_tokens", None) if response is not None else None
99
- kwargs.setdefault("max_tokens", max_tokens or self.audio_max_tokens)
100
- kwargs.setdefault("temperature", self.audio_temperature)
101
- return kwargs
102
-
103
- def _serialize_audio(self, active_chat: Chat) -> list[dict[str, Any]]:
104
- return _chat_messages(active_chat, audio_content_type=self.audio_content_type)
105
-
106
- def _request_audio(
107
- self,
108
- api_input: list[dict[str, Any]],
109
- optional_kwargs: dict[str, Any],
110
- runtime_config: Any,
111
- ) -> Any:
112
- extra_body = self._extra_body
113
- session_reasoning_effort = getattr(runtime_config.session, "reasoning_effort", None)
114
- if session_reasoning_effort:
115
- extra_body = {"reasoning_effort": session_reasoning_effort}
116
- return _request_chat_completions(
117
- client=self.client,
118
- model_name=self.model_name,
119
- messages=api_input,
120
- stream=self.stream,
121
- extra_body=extra_body,
122
- timeout=self.request_timeout,
123
- optional_kwargs=optional_kwargs,
124
- )
125
-
126
- def _iter_audio_events(self, api_response: Any) -> Iterator[ProviderEvent]:
127
- if self.stream:
128
- yield from _iter_chat_stream_events(api_response)
129
- else:
130
- yield from _iter_chat_response_events(api_response)
131
-
132
- # ── base hooks ──────────────────────────────────────────────────────────--
133
-
134
- def _serialize(self, active_chat: Chat) -> Any:
135
- return active_chat.to_responses_api_chat()
136
-
137
- def _build_optional_kwargs(self, req_tools: Any, req_tool_choice: Any) -> dict[str, Any]:
138
- optional_kwargs: dict[str, Any] = {}
139
- if req_tools is not None:
140
- optional_kwargs["tools"] = req_tools
141
- if req_tool_choice is not None:
142
- optional_kwargs["tool_choice"] = req_tool_choice
143
- return optional_kwargs
144
-
145
- def _request(self, api_input: Any, optional_kwargs: dict[str, Any], runtime_config: Any) -> Any:
146
- extra_body = self._extra_body
147
- session_reasoning_effort = getattr(runtime_config.session, "reasoning_effort", None)
148
- if session_reasoning_effort:
149
- extra_body = {"reasoning_effort": session_reasoning_effort}
150
- return self.client.responses.create(
151
- model=self.model_name,
152
- input=api_input,
153
- stream=self.stream,
154
- extra_body=extra_body,
155
- timeout=self.request_timeout,
156
- **optional_kwargs,
157
- )
158
-
159
- @staticmethod
160
- def _assistant_content(content: Any) -> list[AssistantContent]:
161
- return [
162
- AssistantContent(type="output_text", text=c.text if c.type == "output_text" else c.refusal) for c in content
163
- ]
164
-
165
- def _iter_stream_events(self, api_response: Stream) -> Iterator[ProviderEvent]:
166
- for raw_event in api_response:
167
- if isinstance(raw_event, ResponseTextDeltaEvent):
168
- yield TextDelta(text=raw_event.delta)
169
- elif isinstance(raw_event, ResponseOutputItemDoneEvent):
170
- item = raw_event.item
171
- if isinstance(item, ResponseFunctionToolCall):
172
- item.call_id = _generate_id("call")
173
- item.id = _generate_id("fc")
174
- yield ToolCall(item=item)
175
- elif isinstance(item, ResponseOutputMessage):
176
- yield AssistantMessage(content=self._assistant_content(item.content))
177
- elif isinstance(raw_event, ResponseCompletedEvent):
178
- usage = getattr(raw_event.response, "usage", None)
179
- if usage:
180
- yield Usage(input_tokens=usage.input_tokens or 0, output_tokens=usage.output_tokens or 0)
181
-
182
- def _iter_response_events(self, api_response: Any) -> Iterator[ProviderEvent]:
183
- usage = api_response.usage
184
- if usage:
185
- yield Usage(input_tokens=usage.input_tokens or 0, output_tokens=usage.output_tokens or 0)
186
- for message in api_response.output:
187
- if isinstance(message, ResponseFunctionToolCall):
188
- message.call_id = _generate_id("call")
189
- message.id = _generate_id("fc")
190
- yield ToolCall(item=message)
191
- elif isinstance(message, ResponseOutputMessage):
192
- yield AssistantMessage(content=self._assistant_content(message.content))
193
- # Text-only keeps every character; the base applies remove_unspeechable
194
- # for audio. Only output_text parts are spoken (refusals are stored).
195
- raw = "".join(c.text for c in message.content if c.type == "output_text")
196
- yield TextDelta(text=raw)
197
- else:
198
- logger.warning(f"Not supported message type: {message.type}")
199
-
200
- def on_session_end(self) -> None:
201
- logger.debug("OpenAI API language model session state reset")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/text_prompt.py DELETED
@@ -1,44 +0,0 @@
1
- """Text-channel system prompt: lead + session prompt + tail (strongest constraints last)."""
2
-
3
- TEXT_SYSTEM_PROMPT_LEAD = """\
4
- You are a helpful assistant in a text conversation.
5
- """
6
-
7
- TEXT_SYSTEM_PROMPT_TAIL = """\
8
- ## Text Rules
9
- - Write clearly and directly. Match length to the request: concise for simple questions, fuller when the task genuinely needs it.
10
- - Use markdown when it helps (lists, code blocks, tables, emphasis); don't over-format simple answers.
11
- - This is a written channel: no spoken-style filler and no action/emote text like *laughs*.
12
- - Use tools when they help fulfill the request. No preamble sentence is required before a tool call.
13
- - For slow or external tools, just call the tool and use the result; you don't need to announce it.
14
- - If unsure whether a tool is needed, just answer directly.
15
- """
16
-
17
- # Skeleton for the assembled system message (placeholders filled in build_text_system_prompt).
18
- _TEXT_SYSTEM_PROMPT_FULL = """\
19
- {lead}
20
-
21
- Session Prompt:
22
- {session_prompt}{optional_tools}
23
-
24
- {tail}
25
- """
26
-
27
-
28
- def build_text_system_prompt(session_prompt: str, *, tool_section: str = "") -> str:
29
- """Context → session prompt → optional tool block → strongest text rules last."""
30
- tools = tool_section.strip()
31
- optional_tools = f"\n\n{tools}" if tools else ""
32
- return _TEXT_SYSTEM_PROMPT_FULL.format(
33
- lead=TEXT_SYSTEM_PROMPT_LEAD.rstrip(),
34
- session_prompt=session_prompt.strip(),
35
- optional_tools=optional_tools,
36
- tail=TEXT_SYSTEM_PROMPT_TAIL.rstrip(),
37
- )
38
-
39
-
40
- # Full text instructions without a separate session block (legacy / rare direct use).
41
- TEXT_SYSTEM_PROMPT = "{lead}\n\n{tail}".format(
42
- lead=TEXT_SYSTEM_PROMPT_LEAD.rstrip(),
43
- tail=TEXT_SYSTEM_PROMPT_TAIL.rstrip(),
44
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/tool_call/function_call.py DELETED
@@ -1,321 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Function parser for extracting function names, parameter names, and values from string function calls.
4
-
5
- Uses Python's ``tokenize`` and ``ast`` modules so that nested parentheses,
6
- strings containing ')' characters, tuples, dicts, etc. are handled correctly.
7
- """
8
-
9
- import ast
10
- import io
11
- import json
12
- import logging
13
- import re
14
- import tokenize
15
- from collections import OrderedDict
16
- from typing import Any, Dict, List, Tuple
17
-
18
- from openai.types.responses import ResponseFunctionToolCall
19
- from pydantic import BaseModel
20
-
21
- from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
22
- from speech_to_speech.utils.utils import _generate_id
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
- _POSITIONAL_RE = re.compile(r"^__arg_\d+__$")
27
- _LENIENT_CALL_RE = re.compile(
28
- r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*"
29
- r"\((?:[^()\"']+|\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')*\)"
30
- )
31
-
32
-
33
- # ── AST / tokenize helpers ───────────────────────────────────────────
34
-
35
-
36
- def _split_top_level_calls(source: str) -> List[str]:
37
- """Split *source* into individual ``name(...)`` expression strings.
38
-
39
- Uses the tokenizer to walk tokens and track parenthesis depth so that
40
- nested parens, strings with ')' chars, etc. are handled correctly.
41
- """
42
- tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
43
- calls: List[str] = []
44
- i = 0
45
-
46
- while i < len(tokens):
47
- tok = tokens[i]
48
- if tok.type != tokenize.NAME:
49
- i += 1
50
- continue
51
-
52
- start = i
53
- j = i + 1
54
-
55
- # Walk past dotted attribute access (e.g. ``mobile.click``)
56
- while j + 1 < len(tokens) and tokens[j].string == "." and tokens[j + 1].type == tokenize.NAME:
57
- j += 2
58
-
59
- if j >= len(tokens) or tokens[j].string != "(":
60
- i += 1
61
- continue
62
-
63
- # Track balanced parens
64
- depth = 0
65
- end = None
66
- k = j
67
- while k < len(tokens):
68
- t = tokens[k]
69
- if t.type == tokenize.OP and t.string == "(":
70
- depth += 1
71
- elif t.type == tokenize.OP and t.string == ")":
72
- depth -= 1
73
- if depth == 0:
74
- end = k
75
- break
76
- k += 1
77
-
78
- if end is None:
79
- i += 1
80
- continue
81
-
82
- calls.append(tokenize.untokenize(tokens[start : end + 1]).strip())
83
- i = end + 1
84
-
85
- return calls
86
-
87
-
88
- def _split_simple_calls_with_regex(source: str) -> List[str]:
89
- """Extract complete simple ``name(args)`` spans from malformed model output.
90
-
91
- This fallback can recover well-formed siblings before a tokenizer error,
92
- but not the incomplete call that caused the tokenizer error.
93
- """
94
- return [match.group(0).strip() for match in _LENIENT_CALL_RE.finditer(source)]
95
-
96
-
97
- def _parse_function_exprs(
98
- expressions: List[str],
99
- pattern_to_match: list[str],
100
- *,
101
- skip_invalid: bool = False,
102
- ) -> List["FunctionToolCall"]:
103
- results: List[FunctionToolCall] = []
104
- for expr in expressions:
105
- try:
106
- call = _parse_call_expr(expr)
107
- except Exception:
108
- if skip_invalid:
109
- continue
110
- raise
111
- if pattern_to_match and all(pattern not in call.function_name for pattern in pattern_to_match):
112
- continue
113
- results.append(call)
114
- return results
115
-
116
-
117
- def _extract_function_name(node: ast.expr) -> str:
118
- """Return the dotted function name from a Call node's ``func`` attribute."""
119
- if isinstance(node, ast.Name):
120
- return node.id
121
- if isinstance(node, ast.Attribute):
122
- base = _extract_function_name(node.value)
123
- return f"{base}.{node.attr}" if base else node.attr
124
- raise ValueError(f"Unsupported function target: {ast.dump(node)}")
125
-
126
-
127
- def _literal_from_ast(node: ast.AST) -> Any:
128
- """Convert an AST node to a Python literal value."""
129
- if isinstance(node, ast.Constant):
130
- return node.value
131
-
132
- if isinstance(node, ast.Name):
133
- return node.id
134
-
135
- if isinstance(node, ast.List):
136
- return [_literal_from_ast(elt) for elt in node.elts]
137
-
138
- if isinstance(node, ast.Tuple):
139
- return [_literal_from_ast(elt) for elt in node.elts]
140
-
141
- if isinstance(node, ast.Dict):
142
- return {
143
- _literal_from_ast(key): _literal_from_ast(value)
144
- for key, value in zip(node.keys, node.values)
145
- if key is not None
146
- }
147
-
148
- if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
149
- value = _literal_from_ast(node.operand)
150
- if not isinstance(value, (int, float)):
151
- raise ValueError(f"Unsupported unary literal: {ast.dump(node)}")
152
- return -value if isinstance(node.op, ast.USub) else value
153
-
154
- raise ValueError(f"Unsupported literal: {ast.dump(node)}")
155
-
156
-
157
- def _parse_call_expr(expr: str) -> "FunctionToolCall":
158
- """Parse a single ``name(args...)`` expression string into a FunctionToolCall."""
159
- parsed = ast.parse(expr, mode="eval").body
160
- if not isinstance(parsed, ast.Call):
161
- raise ValueError(f"Expression is not a function call: {expr!r}")
162
-
163
- parameters: "OrderedDict[str, Any]" = OrderedDict()
164
-
165
- for idx, arg in enumerate(parsed.args):
166
- parameters[f"__arg_{idx}__"] = _literal_from_ast(arg)
167
-
168
- for kw in parsed.keywords:
169
- if kw.arg is None:
170
- raise ValueError("**kwargs are not supported")
171
- parameters[kw.arg] = _literal_from_ast(kw.value)
172
-
173
- return FunctionToolCall(
174
- function_name=_extract_function_name(parsed.func),
175
- parameters=parameters,
176
- original_string=expr,
177
- )
178
-
179
-
180
- # ── Data model ───────────────────────────────────────────────────────
181
-
182
-
183
- class FunctionToolCall(BaseModel):
184
- """Represents a parsed function call with its parameters."""
185
-
186
- function_name: str
187
- parameters: Dict[str, Any]
188
- original_string: str
189
- description: str = ""
190
-
191
- def to_realtime_function_tool_call(
192
- self,
193
- function_tools: list[FunctionTool] | None = None,
194
- ) -> ResponseFunctionToolCall:
195
- positional = {k for k in self.parameters if _POSITIONAL_RE.match(k)}
196
- if positional:
197
- logger.warning(
198
- "Dropping positional arguments for '%s': %s",
199
- self.function_name,
200
- positional,
201
- )
202
- arguments = {k: v for k, v in self.parameters.items() if not _POSITIONAL_RE.match(k)}
203
-
204
- if function_tools is not None:
205
- tool = next(
206
- (t for t in function_tools if t.name == self.function_name),
207
- None,
208
- )
209
- if tool is None:
210
- available = [t.name for t in function_tools]
211
- raise ValueError(f"Function '{self.function_name}' not found in available tools: {available}")
212
-
213
- schema = tool.parameters if isinstance(tool.parameters, dict) else {}
214
- properties = schema.get("properties", {})
215
- required = set(schema.get("required", []))
216
-
217
- undeclared = {k for k in arguments if k not in properties}
218
- if undeclared:
219
- logger.warning(
220
- "Dropping undeclared parameters for '%s': %s",
221
- self.function_name,
222
- undeclared,
223
- )
224
- arguments = {k: v for k, v in arguments.items() if k in properties}
225
-
226
- missing = required - set(arguments.keys())
227
- if missing:
228
- raise ValueError(f"Missing required parameters for '{self.function_name}': {missing}")
229
-
230
- return ResponseFunctionToolCall(
231
- name=self.function_name,
232
- arguments=json.dumps(arguments),
233
- call_id=_generate_id("call"),
234
- type="function_call",
235
- id=_generate_id("fc"),
236
- status="in_progress",
237
- )
238
-
239
-
240
- # ── Public API ───────────────────────────────────────────────────────
241
-
242
-
243
- def parse_function_call(function_string: str, pattern_to_match: list[str] = []) -> List[FunctionToolCall]:
244
- """Parse a function call string and extract all function calls found.
245
-
246
- Args:
247
- function_string: String representation of function calls.
248
- pattern_to_match: If non-empty, only calls whose function name
249
- contains at least one of these substrings are returned.
250
-
251
- Returns:
252
- List of FunctionToolCall objects with parsed information.
253
- """
254
- function_string = function_string.strip()
255
- if not function_string:
256
- return []
257
-
258
- try:
259
- expressions = _split_top_level_calls(function_string)
260
- except tokenize.TokenError:
261
- return _parse_function_exprs(
262
- _split_simple_calls_with_regex(function_string),
263
- pattern_to_match,
264
- skip_invalid=True,
265
- )
266
-
267
- return _parse_function_exprs(expressions, pattern_to_match)
268
-
269
-
270
- def parse_multiple_functions(function_strings: List[str]) -> List[FunctionToolCall]:
271
- """Parse multiple function call strings.
272
-
273
- Args:
274
- function_strings: List of function call strings.
275
-
276
- Returns:
277
- List of FunctionToolCall objects.
278
- """
279
- results: List[FunctionToolCall] = []
280
- for func_str in function_strings:
281
- try:
282
- results.extend(parse_function_call(func_str))
283
- except Exception:
284
- continue
285
- return results
286
-
287
-
288
- def extract_function_calls_from_text(text: str, block_regex: str = ".*") -> Tuple[str, List[FunctionToolCall]]:
289
- """Extract function calls from delimited code blocks inside *text*.
290
-
291
- The LLM is prompted to wrap tool calls inside code-block delimiters
292
- (e.g. ``<code>func(x=1)</code>``). This function finds those blocks,
293
- parses the function calls within them, and returns the remaining text
294
- (with blocks stripped) alongside the parsed calls.
295
-
296
- Args:
297
- text: Full model output potentially containing code blocks.
298
- block_regex: Regex matching the code-block delimiters **and** their
299
- content (e.g. ``r"<code>.*?</code>"``). Only text **inside**
300
- matched blocks is scanned for function calls.
301
-
302
- Returns:
303
- ``(outside_text, function_calls)`` -- the text with blocks stripped
304
- and the parsed function calls found inside the blocks.
305
- """
306
- if not block_regex:
307
- return text, []
308
-
309
- matches = list(re.finditer(block_regex, text, flags=re.DOTALL))
310
- if not matches:
311
- return text, []
312
-
313
- outside = re.sub(block_regex, "", text, flags=re.DOTALL)
314
- inside = " ".join(match.group(0) for match in matches).strip()
315
- if not inside:
316
- return outside, []
317
-
318
- try:
319
- return outside, parse_function_call(inside)
320
- except Exception:
321
- return outside, []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/tool_call/function_tool.py DELETED
@@ -1,34 +0,0 @@
1
- import textwrap
2
-
3
- from openai.types.realtime import RealtimeFunctionTool
4
-
5
- from speech_to_speech.LLM.tool_call.signature_from_schema import signature_from_schema
6
-
7
-
8
- class FunctionTool(RealtimeFunctionTool):
9
- def to_code_prompt(self, include_args_doc: bool = True) -> str:
10
- """Generate a code-style prompt string for this function tool.
11
-
12
- Args:
13
- include_args_doc: If True, include argument descriptions in the docstring.
14
- ⚠️ This lets the model see each argument's purpose but significantly increases
15
- token usage (e.g. 906 tokens without vs 3434 with for the default Reachy Mini
16
- tool profile). Enable depending on the model's capabilities and context limit.
17
- """
18
- signature = signature_from_schema(self.parameters)
19
-
20
- tool_doc = self.description or ""
21
-
22
- if isinstance(self.parameters, dict) and include_args_doc:
23
- props = self.parameters.get("properties", {})
24
- if props:
25
- arg_lines = []
26
- for arg_name, arg_schema in props.items():
27
- desc = arg_schema.get("description", "") if isinstance(arg_schema, dict) else ""
28
- arg_lines.append(f"{arg_name}: {desc}")
29
- args_doc = f"Args:\n{textwrap.indent(chr(10).join(arg_lines), ' ')}"
30
- tool_doc += f"\n\n{args_doc}"
31
-
32
- tool_doc = f'"""{tool_doc}\n"""'
33
-
34
- return f"def {self.name}{signature}:\n{textwrap.indent(tool_doc, ' ')}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/tool_call/signature_from_schema.py DELETED
@@ -1,108 +0,0 @@
1
- import inspect
2
- from typing import Any, Literal, Union
3
-
4
- JSON_TYPE_TO_PYTHON_TYPE = {
5
- "string": str,
6
- "number": float,
7
- "boolean": bool,
8
- "integer": int,
9
- "object": dict,
10
- "array": list,
11
- "null": type(None),
12
- }
13
-
14
-
15
- def _dedupe_types(types: list[Any]) -> list[Any]:
16
- seen = []
17
- for t in types:
18
- if t not in seen:
19
- seen.append(t)
20
- return seen
21
-
22
-
23
- def _annotation_from_spec(spec: dict[str, Any]) -> Any:
24
- if not spec or not isinstance(spec, dict):
25
- return Any
26
-
27
- # const → Literal[value]
28
- if "const" in spec:
29
- return Literal[spec["const"]]
30
-
31
- # enum → Literal[val1, val2, ...]
32
- if "enum" in spec:
33
- values = spec["enum"]
34
- if not values:
35
- return Any
36
- return Literal[tuple(values)]
37
-
38
- # anyOf / oneOf → Union[Type1, Type2, ...]
39
- for key in ("anyOf", "oneOf"):
40
- if key in spec:
41
- variants = [_annotation_from_spec(s) for s in spec[key]]
42
- unique = _dedupe_types(variants)
43
- if len(unique) == 0:
44
- return Any
45
- if len(unique) == 1:
46
- return unique[0]
47
- return Union[tuple(unique)]
48
-
49
- # allOf → merge sub-schemas then resolve
50
- if "allOf" in spec:
51
- merged = {}
52
- for sub in spec["allOf"]:
53
- merged.update(sub)
54
- return _annotation_from_spec(merged)
55
-
56
- json_type = spec.get("type")
57
-
58
- if json_type is None:
59
- return Any
60
-
61
- # type as list, e.g. ["string", "null"] → Union[str, None] (Optional)
62
- if isinstance(json_type, list):
63
- types = [JSON_TYPE_TO_PYTHON_TYPE.get(t, Any) for t in json_type]
64
- unique = _dedupe_types(types)
65
- if len(unique) == 0:
66
- return Any
67
- if len(unique) == 1:
68
- return unique[0]
69
- return Union[tuple(unique)]
70
-
71
- # array with items → list[ItemType]
72
- if json_type == "array" and "items" in spec:
73
- item_type = _annotation_from_spec(spec["items"])
74
- return list[item_type] # type: ignore[valid-type]
75
-
76
- return JSON_TYPE_TO_PYTHON_TYPE.get(json_type, Any)
77
-
78
-
79
- def signature_from_schema(schema: object | None) -> inspect.Signature:
80
- if not schema or not isinstance(schema, dict):
81
- return inspect.Signature()
82
-
83
- props = schema.get("properties", {})
84
- required = set(schema.get("required", []))
85
- params = []
86
-
87
- for name, spec in props.items():
88
- annotation = _annotation_from_spec(spec)
89
-
90
- has_schema_default = "default" in spec if isinstance(spec, dict) else False
91
-
92
- if name in required and not has_schema_default:
93
- default = inspect.Parameter.empty
94
- elif has_schema_default:
95
- default = spec["default"]
96
- else:
97
- default = None
98
-
99
- params.append(
100
- inspect.Parameter(
101
- name,
102
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
103
- default=default,
104
- annotation=annotation,
105
- )
106
- )
107
-
108
- return inspect.Signature(params)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/tool_call/tool_prompt.py DELETED
@@ -1,111 +0,0 @@
1
- """
2
- Optional system-prompt builder that instructs a local LLM to output tool calls
3
- inside delimited code blocks (e.g. ``<code>func(arg='val')</code>``).
4
-
5
- The prompt is rendered from a Jinja2 template and relies on
6
- ``FunctionTool.to_code_prompt()`` to expose each tool's Python-style signature.
7
- """
8
-
9
- import re
10
-
11
- from jinja2 import Template
12
-
13
- from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
14
-
15
- # ---------------------------------------------------------------------------
16
- # Default delimiters
17
- # ---------------------------------------------------------------------------
18
- ENTER_CODE = "<code>"
19
- END_CODE = "</code>"
20
-
21
- # ---------------------------------------------------------------------------
22
- # Jinja2 template
23
- # ---------------------------------------------------------------------------
24
- # ``enter_code`` / ``end_code`` are the block delimiters the model must wrap
25
- # every tool call in. ``tools`` is a list of FunctionTool instances whose
26
- # ``.to_code_prompt()`` is called inside the template.
27
- # ---------------------------------------------------------------------------
28
-
29
- TOOL_PROMPT_TEMPLATE = Template(
30
- """\
31
- Available tools:
32
-
33
- {% for tool in tools %}
34
- {{ tool.to_code_prompt() }}
35
-
36
- {% endfor %}
37
- To call a tool, put exactly one named-argument function call inside {{ enter_code }}...{{ end_code }}:
38
- {{ enter_code }}function_name(required_arg='value'){{ end_code }}
39
-
40
- Rules:
41
- - You may say one brief natural sentence before the tool call; for slow information tools, briefly say that you will check.
42
- - For expression/background tools, always speak first. For requested expressions, use a short pattern like "Sure, here's my best <emotion>."; otherwise use a fitting empathetic sentence.
43
- - Do not mention tags, functions, or tools. Keep prose outside tags brief, and do not claim tool results before a tool result is available.
44
- - Use named arguments only; quote strings. Omit optional args instead of placeholder values like "random", "none", "", or null.
45
- - Only one tool call may appear in a response.\
46
- """,
47
- keep_trailing_newline=True,
48
- )
49
-
50
- # Text-channel variant: same call format and structural rules, without the
51
- # voice-specific "speak first" prose.
52
- TEXT_TOOL_PROMPT_TEMPLATE = Template(
53
- """\
54
- Available tools:
55
-
56
- {% for tool in tools %}
57
- {{ tool.to_code_prompt() }}
58
-
59
- {% endfor %}
60
- To call a tool, put exactly one named-argument function call inside {{ enter_code }}...{{ end_code }}:
61
- {{ enter_code }}function_name(required_arg='value'){{ end_code }}
62
-
63
- Rules:
64
- - Call a tool directly when it helps fulfill the request; no preamble sentence is required.
65
- - Do not mention tags, functions, or tools in your prose, and do not claim tool results before a tool result is available.
66
- - Use named arguments only; quote strings. Omit optional args instead of placeholder values like "random", "none", "", or null.
67
- - Only one tool call may appear in a response.\
68
- """,
69
- keep_trailing_newline=True,
70
- )
71
-
72
-
73
- # ---------------------------------------------------------------------------
74
- # Public helpers
75
- # ---------------------------------------------------------------------------
76
-
77
-
78
- def build_tool_system_prompt(
79
- tools: list[FunctionTool],
80
- enter_code: str = ENTER_CODE,
81
- end_code: str = END_CODE,
82
- *,
83
- text_only: bool = False,
84
- ) -> str:
85
- """Render the tool-calling system-prompt section.
86
-
87
- Returns an empty string when *tools* is empty so it can be
88
- unconditionally appended to a base system prompt. When *text_only* is set,
89
- the written-channel variant is used (no voice "speak first" prose).
90
- """
91
- if not tools:
92
- return ""
93
-
94
- template = TEXT_TOOL_PROMPT_TEMPLATE if text_only else TOOL_PROMPT_TEMPLATE
95
- return template.render(
96
- tools=tools,
97
- enter_code=enter_code,
98
- end_code=end_code,
99
- )
100
-
101
-
102
- def build_block_regex(
103
- enter_code: str = ENTER_CODE,
104
- end_code: str = END_CODE,
105
- ) -> str:
106
- """Build a regex that matches a single code block (non-greedy).
107
-
108
- >>> build_block_regex("<code>", "</code>")
109
- '<code>.*?</code>'
110
- """
111
- return f"{re.escape(enter_code)}.*?{re.escape(end_code)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/utils.py DELETED
@@ -1,75 +0,0 @@
1
- import base64
2
- import io
3
- import re
4
- from typing import Optional
5
-
6
- import requests # type: ignore[import-untyped]
7
- from PIL import Image
8
-
9
- SMART_PUNCT_TRANSLATION = str.maketrans(
10
- {
11
- "\u2018": "'",
12
- "\u2019": "'",
13
- "\u201c": '"',
14
- "\u201d": '"',
15
- }
16
- )
17
-
18
- SPEECHABLE_PATTERN = re.compile(
19
- r"[^\w\s.,!?;:'\"\-()\/\\@#%&*+=$€£¥₹₽¢\[\]{}<>~`^|…—–\n\r\t]",
20
- flags=re.UNICODE,
21
- )
22
-
23
-
24
- def remove_unspeechable(text: str) -> str:
25
- """Keep only speechable characters: letters, digits, punctuation, whitespace.
26
- support unicode characters (english, arabic, chinese, japanese, korean, etc.)
27
- """
28
- text = text.translate(SMART_PUNCT_TRANSLATION)
29
- return SPEECHABLE_PATTERN.sub("", text)
30
-
31
-
32
- WHISPER_LANGUAGE_TO_LLM_LANGUAGE = {
33
- "en": "english",
34
- "fr": "french",
35
- "es": "spanish",
36
- "zh": "chinese",
37
- "ja": "japanese",
38
- "ko": "korean",
39
- "hi": "hindi",
40
- "de": "german",
41
- "pt": "portuguese",
42
- "pl": "polish",
43
- "it": "italian",
44
- "nl": "dutch",
45
- }
46
-
47
-
48
- def resolve_auto_language(language_code: Optional[str]) -> tuple[Optional[str], Optional[str]]:
49
- """Strip the ``-auto`` suffix and resolve the human-readable language name.
50
-
51
- Returns ``(clean_code, language_name)``. ``language_name`` is non-None
52
- when the code (with or without ``-auto``) maps to a known language.
53
- """
54
- if not language_code:
55
- return language_code, None
56
- if language_code.endswith("-auto"):
57
- language_code = language_code[:-5]
58
- if language_code not in WHISPER_LANGUAGE_TO_LLM_LANGUAGE:
59
- return language_code, None
60
- return language_code, WHISPER_LANGUAGE_TO_LLM_LANGUAGE.get(language_code)
61
-
62
-
63
- def image_url_to_pil(image_url: str) -> Image.Image:
64
- """Convert an image URL or base64 data URI to a PIL Image.
65
-
66
- Accepts:
67
- - 'data:image/...;base64,<b64>' data URIs
68
- - 'https://...`` or ``http://...' URLs (fetched with a 10s timeout)
69
- """
70
- if image_url.startswith("data:"):
71
- _, b64_data = image_url.split(",", 1)
72
- return Image.open(io.BytesIO(base64.b64decode(b64_data)))
73
- resp = requests.get(image_url, timeout=10)
74
- resp.raise_for_status()
75
- return Image.open(io.BytesIO(resp.content))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/LLM/voice_prompt.py DELETED
@@ -1,48 +0,0 @@
1
- """Voice-channel system prompt: lead + session prompt + tail (strongest constraints last)."""
2
-
3
- VOICE_SYSTEM_PROMPT_LEAD = """\
4
- You are in a spoken conversation. The user speaks and hears you.
5
- The session prompt defines persona, facts, goals, and tool descriptions. These channel rules only control spoken output and tool-use behavior.
6
- """
7
-
8
- VOICE_SYSTEM_PROMPT_TAIL = """\
9
- ## Voice Rules
10
- - Keep replies brief by default: usually one spoken sentence, two if needed. Go longer only when asked.
11
- - Speak naturally. No markdown, bullets, headings, visual formatting, or action/emote text like *laughs*.
12
- - Treat transcripts as noisy. Correct likely mishearings only if asked or meaning depends on it.
13
- - Speech is the default. Use at most one tool when it helps fulfill the request or clearly fits the moment.
14
- - Before a tool call, use a brief natural utterance unless the user asked for silence or tool-only output. For slow information tools, briefly say that you will check.
15
- - For expression/background tools, speak first. If asked to show an expression, use a short pattern like "Sure, here's my best <emotion>." Otherwise use a fitting empathetic sentence. Never mention tools.
16
- - After completed expression/background/physical-action tools, do not add a second spoken comment unless the result has user-facing information.
17
- - Use motion, dance, emotion, and similar tools sparingly when they add empathy, celebration, playfulness, or a requested physical action.
18
- - If unsure whether a tool is needed, just speak.
19
- """
20
-
21
- # Skeleton for the assembled system message (placeholders filled in build_voice_system_prompt).
22
- _VOICE_SYSTEM_PROMPT_FULL = """\
23
- {lead}
24
-
25
- Session Prompt:
26
- {session_prompt}{optional_tools}
27
-
28
- {tail}
29
- """
30
-
31
-
32
- def build_voice_system_prompt(session_prompt: str, *, tool_section: str = "") -> str:
33
- """Context → session prompt → optional tool block → strongest voice rules last."""
34
- tools = tool_section.strip()
35
- optional_tools = f"\n\n{tools}" if tools else ""
36
- return _VOICE_SYSTEM_PROMPT_FULL.format(
37
- lead=VOICE_SYSTEM_PROMPT_LEAD.rstrip(),
38
- session_prompt=session_prompt.strip(),
39
- optional_tools=optional_tools,
40
- tail=VOICE_SYSTEM_PROMPT_TAIL.rstrip(),
41
- )
42
-
43
-
44
- # Full voice instructions without a separate session block (legacy / rare direct use).
45
- VOICE_SYSTEM_PROMPT = "{lead}\n\n{tail}".format(
46
- lead=VOICE_SYSTEM_PROMPT_LEAD.rstrip(),
47
- tail=VOICE_SYSTEM_PROMPT_TAIL.rstrip(),
48
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/README.md DELETED
@@ -1,163 +0,0 @@
1
- # STT Summary
2
-
3
- This document summarizes the Speech-to-Text (STT) implementations in the `STT/` folder, including language support, language abbreviations, and usage in `s2s_pipeline.py`.
4
-
5
- ## Available STT Modes (`--stt`)
6
-
7
- - `whisper` → `STT/whisper_stt_handler.py`
8
- - `whisper-mlx` → `STT/lightning_whisper_mlx_handler.py`
9
- - `mlx-audio-whisper` → `STT/mlx_audio_whisper_handler.py`
10
- - `faster-whisper` → `STT/faster_whisper_handler.py`
11
- - `parakeet-tdt` → `STT/parakeet_tdt_handler.py`
12
- - `paraformer` → `STT/paraformer_handler.py`
13
-
14
- ## Language Support by Handler
15
-
16
- ### 1) Whisper (`--stt whisper`)
17
-
18
- - Handler: `WhisperSTTHandler`
19
- - Language input flag: `--language` (from shared Whisper args)
20
- - Supports fixed language (e.g. `en`) or `auto`
21
- - Internal supported language list:
22
- - `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl`
23
- - Behavior:
24
- - Detects language from token output
25
- - If detected language is outside the supported list, it falls back to the previous language
26
-
27
- ### 2) Lightning Whisper MLX (`--stt whisper-mlx`)
28
-
29
- - Handler: `LightningWhisperSTTHandler`
30
- - Uses same shared `--language` argument as Whisper
31
- - Internal supported language list:
32
- - `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl`
33
- - Behavior:
34
- - If `--language auto`, model auto-detects each utterance
35
- - If detected language is unsupported, falls back to last supported language
36
-
37
- ### 3) MLX Audio Whisper (`--stt mlx-audio-whisper`)
38
-
39
- - Handler: `MLXAudioWhisperSTTHandler`
40
- - Model flag: `--mlx_audio_whisper_model_name`
41
- - Language still comes from shared `--language` flag (wired in pipeline)
42
- - Internal supported language list:
43
- - `en`, `fr`, `es`, `zh`, `ja`, `ko`, `hi`, `de`, `pt`, `pl`, `it`, `nl`
44
- - Behavior:
45
- - Uses fixed language unless `--language auto`
46
- - Falls back to last known supported language when needed
47
-
48
- ### 4) Faster-Whisper (`--stt faster-whisper`)
49
-
50
- - Handler: `FasterWhisperSTTHandler`
51
- - Language flag: `--faster_whisper_stt_gen_language`
52
- - Default language: `en`
53
- - Note:
54
- - This handler passes generation kwargs directly to `faster_whisper.WhisperModel.transcribe(...)`
55
- - Effective language coverage depends on selected Faster-Whisper/OpenAI Whisper model
56
-
57
- ### 5) Parakeet TDT (`--stt parakeet-tdt`)
58
-
59
- - Handler: `ParakeetTDTSTTHandler`
60
- - Language flag: `--parakeet_tdt_language` (optional)
61
- - Supports auto language detection when language not specified
62
- - Declared supported language list (25 European languages):
63
- - `en`, `de`, `fr`, `es`, `it`, `pt`, `nl`, `pl`, `ru`, `uk`, `cs`, `sk`, `hu`, `ro`, `bg`, `hr`, `sl`, `sr`, `da`, `no`, `sv`, `fi`, `et`, `lv`, `lt`
64
- - Backend behavior:
65
- - On macOS/MPS: MLX (`mlx-community/parakeet-tdt-0.6b-v3`)
66
- - On CUDA/CPU: nano-parakeet (`nvidia/parakeet-tdt-0.6b-v3`)
67
-
68
- ### 6) Paraformer (`--stt paraformer`)
69
-
70
- - Handler: `ParaformerSTTHandler`
71
- - Model flag: `--paraformer_stt_model_name`
72
- - Default model: `paraformer-zh`
73
- - No dedicated language flag in current args class
74
- - Practical support:
75
- - Depends on selected FunASR model checkpoint
76
- - Default setup is Chinese-oriented (`zh`)
77
-
78
- ## Language Abbreviations (ISO-style codes seen in STT handlers)
79
-
80
- | Code | Language |
81
- |---|---|
82
- | `en` | English |
83
- | `fr` | French |
84
- | `es` | Spanish |
85
- | `zh` | Chinese |
86
- | `ja` | Japanese |
87
- | `ko` | Korean |
88
- | `hi` | Hindi |
89
- | `de` | German |
90
- | `pt` | Portuguese |
91
- | `pl` | Polish |
92
- | `it` | Italian |
93
- | `nl` | Dutch |
94
- | `ru` | Russian |
95
- | `uk` | Ukrainian |
96
- | `cs` | Czech |
97
- | `sk` | Slovak |
98
- | `hu` | Hungarian |
99
- | `ro` | Romanian |
100
- | `bg` | Bulgarian |
101
- | `hr` | Croatian |
102
- | `sl` | Slovenian |
103
- | `sr` | Serbian |
104
- | `da` | Danish |
105
- | `no` | Norwegian |
106
- | `sv` | Swedish |
107
- | `fi` | Finnish |
108
- | `et` | Estonian |
109
- | `lv` | Latvian |
110
- | `lt` | Lithuanian |
111
- | `auto` | Per-utterance automatic language detection |
112
-
113
- ## Usage Examples
114
-
115
- ### Whisper (Transformers)
116
-
117
- ```bash
118
- python s2s_pipeline.py --stt whisper --language en
119
- python s2s_pipeline.py --stt whisper --language auto
120
- ```
121
-
122
- ### Whisper MLX (LightningWhisperMLX)
123
-
124
- ```bash
125
- python s2s_pipeline.py --stt whisper-mlx --language auto --device mps
126
- ```
127
-
128
- ### MLX Audio Whisper
129
-
130
- ```bash
131
- python s2s_pipeline.py --stt mlx-audio-whisper \
132
- --mlx_audio_whisper_model_name mlx-community/whisper-large-v3-turbo \
133
- --language auto
134
- ```
135
-
136
- ### Faster-Whisper
137
-
138
- ```bash
139
- python s2s_pipeline.py --stt faster-whisper \
140
- --faster_whisper_stt_model_name large-v3 \
141
- --faster_whisper_stt_gen_language en
142
- ```
143
-
144
- ### Parakeet TDT
145
-
146
- ```bash
147
- python s2s_pipeline.py --stt parakeet-tdt --parakeet_tdt_device auto
148
- python s2s_pipeline.py --stt parakeet-tdt --parakeet_tdt_language de
149
- ```
150
-
151
- With live transcription (MLX or CUDA/nano-parakeet backend):
152
-
153
- ```bash
154
- python s2s_pipeline.py --stt parakeet-tdt \
155
- --enable_live_transcription \
156
- --live_transcription_update_interval 0.25
157
- ```
158
-
159
- ### Paraformer
160
-
161
- ```bash
162
- python s2s_pipeline.py --stt paraformer --paraformer_stt_model_name paraformer-zh
163
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
runtime/src/speech_to_speech/STT/base_stt_handler.py DELETED
@@ -1,212 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from collections import Counter, OrderedDict
5
- from time import perf_counter
6
- from typing import Any
7
-
8
- from speech_to_speech.baseHandler import BaseHandler
9
- from speech_to_speech.pipeline.handler_types import STTIn, STTOut
10
- from speech_to_speech.pipeline.messages import PartialTranscription, Transcription, VADAudio
11
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- class BaseSTTHandler(BaseHandler[STTIn, STTOut]):
17
- """Base STT handler with speculative-turn stale input filtering."""
18
-
19
- _MAX_COMPLETED_FINAL_REVISIONS = 2048
20
-
21
- speculative_turns: SpeculativeTurnTracker | None = None
22
- final_revision_settle_s: float = 0.0
23
-
24
- def should_process_input(self, item: STTIn) -> bool:
25
- mode = getattr(item, "mode", None)
26
- turn_id = getattr(item, "turn_id", None)
27
- turn_revision = getattr(item, "turn_revision", None)
28
- if self._is_completed_final_revision(item):
29
- queued_drops = self._drop_stale_queued_inputs()
30
- self._log_stale_turn_item(item, "input-after-final", queued_drops=queued_drops)
31
- return False
32
- if mode == "progressive" and self._has_queued_final_for_revision(item):
33
- self._log_stale_turn_item(item, "progressive-before-final")
34
- return False
35
-
36
- wait_for_stability = mode == "final"
37
- gate_start = perf_counter()
38
- is_latest = self._is_latest_turn_item(
39
- item,
40
- wait_for_pending_reopen=True,
41
- wait_for_stability=wait_for_stability,
42
- )
43
- gate_wait_s = perf_counter() - gate_start
44
- if gate_wait_s >= 0.05:
45
- logger.info(
46
- "%s: STT input gate waited %.3fs for turn=%s rev=%s mode=%s latest=%s age=%.3fs queue=%s",
47
- self.__class__.__name__,
48
- gate_wait_s,
49
- turn_id,
50
- turn_revision,
51
- mode,
52
- is_latest,
53
- self._item_age_s(item),
54
- self._safe_qsize(),
55
- )
56
-
57
- if not is_latest:
58
- queued_drops = self._drop_stale_queued_inputs()
59
- self._log_stale_turn_item(item, "input", queued_drops=queued_drops)
60
- return False
61
- return True
62
-
63
- def should_emit_output(self, output: STTOut) -> bool:
64
- if isinstance(output, PartialTranscription) and self._is_completed_final_revision(output):
65
- self._log_stale_turn_item(output, "output-after-final")
66
- return False
67
-
68
- if not self._is_latest_turn_item(output, wait_for_pending_reopen=True, wait_for_stability=False):
69
- self._log_stale_turn_item(output, "output")
70
- return False
71
- return True
72
-
73
- def before_emit_output(self, output: STTOut) -> None:
74
- if isinstance(output, Transcription):
75
- self._mark_completed_final_revision(output)
76
-
77
- def _is_latest_turn_item(
78
- self,
79
- item: object,
80
- *,
81
- wait_for_pending_reopen: bool,
82
- wait_for_stability: bool,
83
- ) -> bool:
84
- if self.speculative_turns is None:
85
- return True
86
- turn_id = getattr(item, "turn_id", None)
87
- turn_revision = getattr(item, "turn_revision", None)
88
- if turn_id is None or turn_revision is None:
89
- return True
90
-
91
- if wait_for_stability:
92
- is_latest = self.speculative_turns.is_latest_after_stability_window(
93
- turn_id,
94
- turn_revision,
95
- self.final_revision_settle_s,
96
- )
97
- elif wait_for_pending_reopen:
98
- is_latest = self.speculative_turns.is_latest_after_pending_reopen(turn_id, turn_revision)
99
- else:
100
- is_latest = self.speculative_turns.is_latest(turn_id, turn_revision)
101
- return is_latest
102
-
103
- def _drop_stale_queued_inputs(self) -> int:
104
- if self.speculative_turns is None or not hasattr(self.queue_in, "mutex") or not hasattr(self.queue_in, "queue"):
105
- return 0
106
-
107
- dropped = 0
108
- with self.queue_in.mutex:
109
- kept: list[Any] = []
110
- while self.queue_in.queue:
111
- queued_item = self.queue_in.queue.popleft()
112
- if isinstance(queued_item, VADAudio) and (
113
- self._is_completed_final_revision(queued_item)
114
- or (queued_item.mode == "progressive" and self._has_queued_final_for_revision_locked(queued_item))
115
- or not self._is_latest_turn_item(
116
- queued_item,
117
- wait_for_pending_reopen=False,
118
- wait_for_stability=False,
119
- )
120
- ):
121
- dropped += 1
122
- else:
123
- kept.append(queued_item)
124
- self.queue_in.queue.extend(kept)
125
- if dropped:
126
- self.queue_in.not_full.notify_all()
127
- return dropped
128
-
129
- def _log_stale_turn_item(self, item: object, stage: str, *, queued_drops: int = 0) -> None:
130
- turn_id = getattr(item, "turn_id", None)
131
- turn_revision = getattr(item, "turn_revision", None)
132
- if turn_id is None or turn_revision is None:
133
- return
134
-
135
- if not hasattr(self, "_stale_drop_counts"):
136
- self._stale_drop_counts: Counter[tuple[str, str, int]] = Counter()
137
- key = (stage, turn_id, turn_revision)
138
- self._stale_drop_counts[key] += 1
139
-
140
- message = "%s: dropping stale STT %s for turn=%s rev=%s age=%.3fs"
141
- args: tuple[object, ...] = (
142
- self.__class__.__name__,
143
- stage,
144
- turn_id,
145
- turn_revision,
146
- self._item_age_s(item),
147
- )
148
- if queued_drops:
149
- message += " (+%d queued)"
150
- args = (*args, queued_drops)
151
-
152
- if self._stale_drop_counts[key] == 1:
153
- logger.info(message, *args)
154
- else:
155
- logger.debug(message, *args)
156
-
157
- def _item_age_s(self, item: object) -> float:
158
- created_at_s = getattr(item, "created_at_s", None)
159
- if not isinstance(created_at_s, float):
160
- return 0.0
161
- return max(0.0, perf_counter() - created_at_s)
162
-
163
- def _safe_qsize(self) -> int | str:
164
- try:
165
- return self.queue_in.qsize()
166
- except NotImplementedError:
167
- return "unknown"
168
-
169
- def _has_queued_final_for_revision(self, item: object) -> bool:
170
- if not hasattr(self.queue_in, "mutex") or not hasattr(self.queue_in, "queue"):
171
- return False
172
- with self.queue_in.mutex:
173
- return self._has_queued_final_for_revision_locked(item)
174
-
175
- def _has_queued_final_for_revision_locked(self, item: object) -> bool:
176
- key = self._revision_key(item)
177
- if key is None:
178
- return False
179
- return any(
180
- isinstance(queued_item, VADAudio) and queued_item.mode == "final" and self._revision_key(queued_item) == key
181
- for queued_item in self.queue_in.queue
182
- )
183
-
184
- def _revision_key(self, item: object) -> tuple[str, int] | None:
185
- turn_id = getattr(item, "turn_id", None)
186
- turn_revision = getattr(item, "turn_revision", None)
187
- if not isinstance(turn_id, str) or not isinstance(turn_revision, int):
188
- return None
189
- return (turn_id, turn_revision)
190
-
191
- def _completed_final_revisions(self) -> OrderedDict[tuple[str, int], None]:
192
- if not hasattr(self, "_completed_final_revision_keys"):
193
- self._completed_final_revision_keys: OrderedDict[tuple[str, int], None] = OrderedDict()
194
- return self._completed_final_revision_keys
195
-
196
- def _is_completed_final_revision(self, item: object) -> bool:
197
- key = self._revision_key(item)
198
- return key is not None and key in self._completed_final_revisions()
199
-
200
- def _mark_completed_final_revision(self, output: Transcription) -> None:
201
- key = self._revision_key(output)
202
- if key is None:
203
- return
204
- completed = self._completed_final_revisions()
205
- completed[key] = None
206
- completed.move_to_end(key)
207
- while len(completed) > self._MAX_COMPLETED_FINAL_REVISIONS:
208
- completed.popitem(last=False)
209
-
210
- def on_session_end(self) -> None:
211
- if hasattr(self, "_completed_final_revision_keys"):
212
- self._completed_final_revision_keys.clear()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/faster_whisper_handler.py DELETED
@@ -1,68 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- import os
5
- from typing import Any, Iterator
6
-
7
- from faster_whisper import WhisperModel
8
- from rich.console import Console
9
-
10
- from speech_to_speech.pipeline.handler_types import STTIn, STTOut
11
- from speech_to_speech.pipeline.messages import Transcription
12
- from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
13
-
14
- console = Console()
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- class FasterWhisperSTTHandler(BaseSTTHandler):
20
- """
21
- Handles the Speech To Text generation using a Whisper model.
22
- """
23
-
24
- def setup(
25
- self,
26
- model_name: str = "tiny.en",
27
- device: str = "auto",
28
- compute_type: str = "auto",
29
- gen_kwargs: dict[str, Any] = {},
30
- ) -> None:
31
- self.gen_kwargs = self.adapt_gen_kwargs(gen_kwargs)
32
-
33
- os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
34
- self.model = WhisperModel(model_name, device=device, compute_type=compute_type)
35
-
36
- def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
37
- logger.debug("infering faster whisper...")
38
-
39
- segments, info = self.model.transcribe(vad_audio.audio, **self.gen_kwargs)
40
- output_text = []
41
-
42
- for segment in segments:
43
- logger.debug("[%.2fs -> %.2fs] %s" % (segment.start, segment.end, segment.text))
44
- output_text.append(segment.text)
45
-
46
- pred_text = " ".join(output_text).strip()
47
-
48
- logger.debug("finished whisper inference")
49
- if pred_text:
50
- console.print(f"[yellow]USER: {pred_text}")
51
-
52
- yield Transcription(
53
- text=pred_text,
54
- turn_id=vad_audio.turn_id,
55
- turn_revision=vad_audio.turn_revision,
56
- speech_stopped_at_s=vad_audio.created_at_s,
57
- )
58
- else:
59
- logger.debug("no text detected. skipping...")
60
-
61
- def cleanup(self) -> None:
62
- print("Stopping FasterWhisperSTTHandler")
63
- del self.model
64
-
65
- def adapt_gen_kwargs(self, gen_kwargs: dict[str, Any]) -> dict[str, Any]:
66
- gen_kwargs["without_timestamps"] = not gen_kwargs.pop("return_timestamps", True)
67
-
68
- return gen_kwargs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/lightning_whisper_mlx_handler.py DELETED
@@ -1,103 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from typing import Any, Iterator, Optional
5
-
6
- import numpy as np
7
- import torch
8
- from lightning_whisper_mlx import LightningWhisperMLX
9
- from rich.console import Console
10
-
11
- from speech_to_speech.pipeline.handler_types import STTIn, STTOut
12
- from speech_to_speech.pipeline.messages import Transcription
13
- from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
- console = Console()
18
-
19
- SUPPORTED_LANGUAGES = [
20
- "en",
21
- "fr",
22
- "es",
23
- "zh",
24
- "ja",
25
- "ko",
26
- "hi",
27
- "de",
28
- "pt",
29
- "pl",
30
- "it",
31
- "nl",
32
- ]
33
-
34
-
35
- class LightningWhisperSTTHandler(BaseSTTHandler):
36
- """
37
- Handles the Speech To Text generation using a Whisper model.
38
- """
39
-
40
- def setup(
41
- self,
42
- model_name: str = "distil-large-v3",
43
- device: str = "mps",
44
- torch_dtype: str = "float16",
45
- compile_mode: Optional[str] = None,
46
- language: Optional[str] = None,
47
- gen_kwargs: dict[str, Any] = {},
48
- ) -> None:
49
- if len(model_name.split("/")) > 1:
50
- model_name = model_name.split("/")[-1]
51
- self.device = device
52
- self.model = LightningWhisperMLX(model=model_name, batch_size=6, quant=None)
53
- self.start_language = language
54
- self.last_language = language
55
-
56
- self.warmup()
57
-
58
- def warmup(self) -> None:
59
- logger.info(f"Warming up {self.__class__.__name__}")
60
-
61
- # 2 warmup steps for no compile or compile mode with CUDA graphs capture
62
- n_steps = 1
63
- dummy_input = np.array([0] * 512)
64
-
65
- for _ in range(n_steps):
66
- _ = self.model.transcribe(dummy_input)["text"].strip()
67
-
68
- def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
69
- logger.debug("infering whisper...")
70
-
71
- audio = vad_audio.audio
72
- if self.start_language != "auto":
73
- transcription_dict = self.model.transcribe(audio, language=self.start_language)
74
- else:
75
- transcription_dict = self.model.transcribe(audio)
76
- language_code = transcription_dict["language"]
77
- if language_code not in SUPPORTED_LANGUAGES:
78
- logger.warning(f"Whisper detected unsupported language: {language_code}")
79
- if self.last_language in SUPPORTED_LANGUAGES: # reprocess with the last language
80
- transcription_dict = self.model.transcribe(audio, language=self.last_language)
81
- else:
82
- transcription_dict = {"text": "", "language": "en"}
83
- else:
84
- self.last_language = language_code
85
-
86
- pred_text = transcription_dict["text"].strip()
87
- language_code = transcription_dict["language"]
88
- torch.mps.empty_cache()
89
-
90
- logger.debug("finished whisper inference")
91
- console.print(f"[yellow]USER: {pred_text}")
92
- logger.debug(f"Language Code Whisper: {language_code}")
93
-
94
- if self.start_language == "auto":
95
- language_code += "-auto"
96
-
97
- yield Transcription(
98
- text=pred_text,
99
- language_code=language_code,
100
- turn_id=vad_audio.turn_id,
101
- turn_revision=vad_audio.turn_revision,
102
- speech_stopped_at_s=vad_audio.created_at_s,
103
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/mlx_audio_whisper_handler.py DELETED
@@ -1,174 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from typing import Any, Iterator, Optional
5
-
6
- import numpy as np
7
- from rich.console import Console
8
-
9
- from speech_to_speech.pipeline.handler_types import STTIn, STTOut
10
- from speech_to_speech.pipeline.messages import Transcription
11
- from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
12
- from speech_to_speech.utils.mlx_lock import MLXLockContext
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
- console = Console()
17
-
18
- DEFAULT_LANGUAGE = "en"
19
-
20
- SUPPORTED_LANGUAGES = [
21
- "en",
22
- "fr",
23
- "es",
24
- "zh",
25
- "ja",
26
- "ko",
27
- "hi",
28
- "de",
29
- "pt",
30
- "pl",
31
- "it",
32
- "nl",
33
- ]
34
-
35
-
36
- class MLXAudioWhisperSTTHandler(BaseSTTHandler):
37
- """
38
- Handles the Speech To Text generation using MLX Audio's Whisper implementation.
39
- Optimized for Apple Silicon using the MLX framework.
40
- """
41
-
42
- def setup(
43
- self,
44
- model_name: str = "mlx-community/whisper-large-v3-turbo",
45
- language: Optional[str] = None,
46
- gen_kwargs: dict[str, Any] = {},
47
- ) -> None:
48
- from mlx_audio.stt.generate import load_model
49
- from transformers import WhisperProcessor
50
-
51
- self.model_name = model_name
52
- self.start_language = language
53
- # "auto" is a request to detect, not a language code, so it must never leak into
54
- # last_language -- it would fail every SUPPORTED_LANGUAGES check downstream.
55
- self.last_language = language if language != "auto" else None
56
- self.gen_kwargs = gen_kwargs
57
-
58
- # Load the model directly
59
- logger.info(f"Loading model {model_name}...")
60
- self.model = load_model(model_name)
61
-
62
- # Check if processor was loaded, if not, load it manually from original model
63
- if self.model._processor is None:
64
- logger.info("Processor not found in MLX model, loading from original Whisper model...")
65
- # Map MLX model names to their original Whisper counterparts
66
- processor_model_map = {
67
- "mlx-community/whisper-large-v3-turbo": "openai/whisper-large-v3",
68
- "mlx-community/whisper-large-v3": "openai/whisper-large-v3",
69
- "mlx-community/whisper-medium": "openai/whisper-medium",
70
- "mlx-community/whisper-small": "openai/whisper-small",
71
- "mlx-community/whisper-base": "openai/whisper-base",
72
- "mlx-community/whisper-tiny": "openai/whisper-tiny",
73
- }
74
-
75
- # Get the appropriate processor model name
76
- processor_model = processor_model_map.get(model_name, "openai/whisper-large-v3")
77
- logger.info(f"Loading processor from {processor_model}...")
78
-
79
- try:
80
- self.model._processor = WhisperProcessor.from_pretrained(processor_model)
81
- logger.info("Processor loaded successfully")
82
- except Exception as e:
83
- logger.error(f"Failed to load processor: {e}")
84
- raise
85
-
86
- logger.info(f"Model {model_name} loaded successfully")
87
-
88
- self.warmup()
89
-
90
- def warmup(self) -> None:
91
- logger.info(f"Warming up {self.__class__.__name__}")
92
-
93
- # Warmup with a dummy input
94
- dummy_audio = np.zeros(16000, dtype=np.float32)
95
-
96
- try:
97
- # Pre-warm the model by running a transcription
98
- with MLXLockContext(handler_name=self.__class__.__name__):
99
- _ = self.model.generate(dummy_audio, verbose=False)
100
- logger.info("Model warmed up and ready")
101
- except Exception as e:
102
- logger.warning(f"Warmup failed: {e}")
103
-
104
- def _forced_language(self) -> Optional[str]:
105
- """The language explicitly requested by the user, if any."""
106
- if self.start_language and self.start_language != "auto":
107
- return self.start_language
108
- return None
109
-
110
- def _resolve_language(self, result: Any) -> str:
111
- """Pick the language code to report, updating the sticky fallback on success."""
112
- forced = self._forced_language()
113
- if forced is not None:
114
- # generate() ran with this language, so it is authoritative.
115
- self.last_language = forced
116
- return forced
117
-
118
- detected = getattr(result, "language", None)
119
- if isinstance(detected, str) and detected:
120
- if detected in SUPPORTED_LANGUAGES:
121
- self.last_language = detected
122
- return detected
123
- logger.warning("Detected unsupported language: %s", detected)
124
-
125
- last_language = self.last_language
126
- if last_language is not None and last_language in SUPPORTED_LANGUAGES:
127
- return last_language
128
- return DEFAULT_LANGUAGE
129
-
130
- def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
131
- logger.debug("inferring mlx-audio whisper...")
132
-
133
- assert isinstance(vad_audio.audio, np.ndarray), "Audio must be a numpy array"
134
- audio_input = vad_audio.audio.astype(np.float32)
135
-
136
- # Prepare generation kwargs - only pass valid parameters
137
- gen_kwargs = {}
138
-
139
- # Add language if specified
140
- forced_language = self._forced_language()
141
- if forced_language is not None:
142
- gen_kwargs["language"] = forced_language
143
-
144
- try:
145
- # MLX models share a single Metal command queue, so concurrent inference from
146
- # the STT/LLM/TTS threads aborts the process with
147
- # "Completed handler provided after commit call". Every other MLX path in the
148
- # pipeline serializes through this lock; this one must too.
149
- with MLXLockContext(handler_name=self.__class__.__name__):
150
- result = self.model.generate(audio_input, verbose=False, **gen_kwargs)
151
-
152
- # Extract text from result
153
- pred_text = result.text.strip() if hasattr(result, "text") else str(result).strip()
154
- language_code = self._resolve_language(result)
155
-
156
- except Exception as e:
157
- logger.error(f"MLX Audio Whisper inference failed: {e}")
158
- pred_text = ""
159
- language_code = self.last_language if self.last_language else DEFAULT_LANGUAGE
160
-
161
- logger.debug("finished mlx-audio whisper inference")
162
- console.print(f"[yellow]USER: {pred_text}")
163
- logger.debug(f"Language Code: {language_code}")
164
-
165
- if self.start_language == "auto":
166
- language_code += "-auto"
167
-
168
- yield Transcription(
169
- text=pred_text,
170
- language_code=language_code,
171
- turn_id=vad_audio.turn_id,
172
- turn_revision=vad_audio.turn_revision,
173
- speech_stopped_at_s=vad_audio.created_at_s,
174
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/paraformer_handler.py DELETED
@@ -1,79 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from typing import Any, Iterator
5
-
6
- import numpy as np
7
- import torch
8
- from rich.console import Console
9
-
10
- from speech_to_speech.pipeline.handler_types import STTIn, STTOut
11
- from speech_to_speech.pipeline.messages import PartialTranscription, Transcription
12
- from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
13
-
14
- logging.basicConfig(
15
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
16
- )
17
- logger = logging.getLogger(__name__)
18
-
19
- console = Console()
20
-
21
-
22
- class ParaformerSTTHandler(BaseSTTHandler):
23
- """
24
- Handles the Speech To Text generation using a Paraformer model.
25
- The default for this model is set to Chinese.
26
- This model was contributed by @wuhongsheng.
27
- """
28
-
29
- def setup(
30
- self,
31
- model_name: str = "paraformer-zh",
32
- device: str = "cuda",
33
- gen_kwargs: dict[str, Any] = {},
34
- ) -> None:
35
- print(model_name)
36
- if len(model_name.split("/")) > 1:
37
- model_name = model_name.split("/")[-1]
38
- self.device = device
39
- try:
40
- from funasr import AutoModel
41
- except ModuleNotFoundError as exc:
42
- raise ModuleNotFoundError(
43
- "Paraformer STT requires the optional 'paraformer' extra. "
44
- "Install it with `pip install speech-to-speech[paraformer]`."
45
- ) from exc
46
- self.model = AutoModel(model=model_name, device=device)
47
- self.warmup()
48
-
49
- def warmup(self) -> None:
50
- logger.info(f"Warming up {self.__class__.__name__}")
51
-
52
- # 2 warmup steps for no compile or compile mode with CUDA graphs capture
53
- n_steps = 1
54
- dummy_input = np.array([0] * 512, dtype=np.float32)
55
- for _ in range(n_steps):
56
- _ = self.model.generate(dummy_input)[0]["text"].strip().replace(" ", "")
57
-
58
- def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
59
- logger.debug("infering paraformer...")
60
-
61
- pred_text = self.model.generate(vad_audio.audio)[0]["text"].strip().replace(" ", "")
62
- torch.mps.empty_cache()
63
-
64
- logger.debug("finished paraformer inference")
65
- console.print(f"[yellow]USER: {pred_text}")
66
-
67
- if vad_audio.mode == "progressive":
68
- yield PartialTranscription(
69
- text=pred_text,
70
- turn_id=vad_audio.turn_id,
71
- turn_revision=vad_audio.turn_revision,
72
- )
73
- else:
74
- yield Transcription(
75
- text=pred_text,
76
- turn_id=vad_audio.turn_id,
77
- turn_revision=vad_audio.turn_revision,
78
- speech_stopped_at_s=vad_audio.created_at_s,
79
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/parakeet_tdt_handler.py DELETED
@@ -1,648 +0,0 @@
1
- """
2
- Parakeet TDT Speech-to-Text Handler
3
-
4
- Supports NVIDIA Parakeet TDT model for high-quality multilingual ASR.
5
- - On Apple Silicon (MPS): Uses mlx-audio with mlx-community/parakeet-tdt-0.6b-v3
6
- - On CUDA/CPU: Uses nano-parakeet (pure PyTorch) with nvidia/parakeet-tdt-0.6b-v3
7
-
8
- Model supports 25 European languages with automatic language detection.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import logging
14
- from contextlib import contextmanager
15
- from sys import platform
16
- from threading import Lock
17
- from time import perf_counter
18
- from typing import Any, Iterator, Optional
19
-
20
- import numpy as np
21
- from rich.console import Console
22
- from rich.text import Text
23
-
24
- from speech_to_speech.pipeline.handler_types import STTIn, STTOut
25
- from speech_to_speech.pipeline.messages import PartialTranscription, Transcription
26
- from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
27
- from speech_to_speech.STT.smart_progressive_streaming import PartialTranscription as ProgressiveStreamPartial
28
- from speech_to_speech.utils.mlx_lock import MLXLockContext
29
-
30
- try:
31
- from lingua import Language, LanguageDetectorBuilder
32
-
33
- LINGUA_AVAILABLE = True
34
- except ImportError:
35
- LINGUA_AVAILABLE = False
36
-
37
- logger = logging.getLogger(__name__)
38
- console = Console()
39
-
40
- # Parakeet TDT v3 supports 25 European languages
41
- SUPPORTED_LANGUAGES = [
42
- "en",
43
- "de",
44
- "fr",
45
- "es",
46
- "it",
47
- "pt",
48
- "nl",
49
- "pl",
50
- "ru",
51
- "uk",
52
- "cs",
53
- "sk",
54
- "hu",
55
- "ro",
56
- "bg",
57
- "hr",
58
- "sl",
59
- "sr",
60
- "da",
61
- "no",
62
- "sv",
63
- "fi",
64
- "et",
65
- "lv",
66
- "lt",
67
- ]
68
-
69
- # Lingua uses "nb" (Bokmål) for Norwegian instead of "no"
70
- _LINGUA_CODE_MAP = {"no": "nb"}
71
-
72
- if LINGUA_AVAILABLE:
73
- _lingua_iso_to_code = {
74
- lang.iso_code_639_1.name.lower(): lang for lang in Language.all() if lang.iso_code_639_1 is not None
75
- }
76
- _lingua_languages = [
77
- _lingua_iso_to_code[_LINGUA_CODE_MAP.get(code, code)]
78
- for code in SUPPORTED_LANGUAGES
79
- if _LINGUA_CODE_MAP.get(code, code) in _lingua_iso_to_code
80
- ]
81
-
82
- def _build_lingua_detector():
83
- # Preloading can take multiple seconds on some hardware, including the
84
- # deployed server. Pay that cost at startup instead of on the first user
85
- # request, where it would look like slow STT.
86
- return LanguageDetectorBuilder.from_languages(*_lingua_languages).with_preloaded_language_models().build()
87
-
88
- _lingua_detector = _build_lingua_detector()
89
-
90
-
91
- class ParakeetTDTSTTHandler(BaseSTTHandler):
92
- """
93
- Handles Speech-to-Text using NVIDIA Parakeet TDT model.
94
-
95
- On Apple Silicon (MPS): Uses mlx-audio with the MLX-converted model.
96
- On CUDA/CPU: Uses nano-parakeet (pure PyTorch) for NeMo-free inference.
97
-
98
- Parakeet TDT 0.6B v3 is a 600M parameter multilingual ASR model
99
- supporting 25 European languages with automatic language detection.
100
- """
101
-
102
- def setup(
103
- self,
104
- model_name: Optional[str] = None,
105
- device: str = "auto",
106
- compute_type: str = "float16",
107
- language: Optional[str] = None,
108
- gen_kwargs: dict[str, Any] = {},
109
- enable_live_transcription: bool = False,
110
- live_transcription_update_interval: float = 0.5,
111
- ) -> None:
112
- """
113
- Initialize the Parakeet TDT model.
114
-
115
- Args:
116
- model_name: Model identifier. Defaults are:
117
- - MPS: "mlx-community/parakeet-tdt-0.6b-v3"
118
- - CUDA/CPU: "nvidia/parakeet-tdt-0.6b-v3"
119
- device: Device to use ("auto", "cuda", "mps", "cpu")
120
- compute_type: Compute precision ("float16", "float32")
121
- language: Target language code (optional, model auto-detects)
122
- gen_kwargs: Additional generation kwargs
123
- """
124
- self.gen_kwargs = gen_kwargs
125
- self.start_language = language
126
- self.last_language = language if language else "en"
127
- self.enable_live_transcription = enable_live_transcription
128
- self.live_transcription_update_interval = live_transcription_update_interval
129
- self.compute_lock = Lock()
130
- self.sample_rate = 16000
131
-
132
- # Determine device
133
- if device == "auto":
134
- if platform == "darwin":
135
- self.device = "mps"
136
- else:
137
- import torch
138
-
139
- self.device = "cuda" if torch.cuda.is_available() else "cpu"
140
- else:
141
- self.device = device
142
-
143
- # Set default model based on device
144
- if model_name is None:
145
- if self.device == "mps":
146
- model_name = "mlx-community/parakeet-tdt-0.6b-v3"
147
- else:
148
- model_name = "nvidia/parakeet-tdt-0.6b-v3"
149
-
150
- self.model_name = model_name
151
- self.compute_type = compute_type
152
-
153
- logger.info(f"Loading Parakeet TDT model: {model_name} on {self.device}")
154
-
155
- if self.device == "mps":
156
- self._setup_mlx(model_name)
157
- else:
158
- self._setup_nano_parakeet(model_name)
159
-
160
- # Setup streaming handler if live transcription is enabled
161
- self.streaming_handler = None
162
- if self.enable_live_transcription:
163
- from speech_to_speech.STT.smart_progressive_streaming import (
164
- SmartProgressiveStreamingHandler,
165
- )
166
-
167
- self.streaming_handler = SmartProgressiveStreamingHandler(
168
- self.model,
169
- emission_interval=self.live_transcription_update_interval,
170
- max_window_size=15.0,
171
- sentence_buffer=2.0,
172
- )
173
- self.processing_final = False # Track if we're processing final audio
174
- logger.info(f"Live transcription enabled for Parakeet TDT ({self.backend})")
175
- self._live_transcription_active = False
176
- self._live_turn_key: tuple[str | None, int | None] | None = None
177
-
178
- self.warmup()
179
-
180
- def _setup_mlx(self, model_name: str) -> None:
181
- """Setup for Apple Silicon using mlx-audio."""
182
- try:
183
- from mlx_audio.stt.generate import load_model
184
-
185
- self.backend = "mlx"
186
- self.model = load_model(model_name)
187
- logger.info("MLX Audio Parakeet model loaded successfully")
188
- except ImportError as e:
189
- raise ImportError(
190
- "mlx-audio is required for Parakeet TDT on Apple Silicon. Install with: pip install mlx-audio"
191
- ) from e
192
-
193
- def _setup_nano_parakeet(self, model_name: str) -> None:
194
- """Setup for CUDA/CPU using nano-parakeet."""
195
- try:
196
- import torch
197
- from nano_parakeet import from_pretrained
198
-
199
- self.backend = "nano_parakeet"
200
-
201
- if self.device == "cuda" and not torch.cuda.is_available():
202
- logger.warning("CUDA requested but not available. Falling back to CPU for nano-parakeet.")
203
- self.device = "cpu"
204
-
205
- self.model = from_pretrained(model_name=model_name, device=self.device)
206
-
207
- logger.info(f"nano-parakeet model loaded successfully on {self.device}")
208
- except ImportError as e:
209
- raise ImportError(
210
- "nano-parakeet is required for Parakeet TDT on CUDA/CPU. Install with: pip install nano-parakeet"
211
- ) from e
212
-
213
- def warmup(self) -> None:
214
- """Warm up the model with a dummy input."""
215
- logger.info(f"Warming up {self.__class__.__name__}")
216
-
217
- # Create 1 second of silence at 16kHz
218
- dummy_audio = np.zeros(16000, dtype=np.float32)
219
-
220
- try:
221
- if self.backend == "mlx":
222
- import mlx.core as mx
223
-
224
- # Convert to mx.array and call decode_chunk directly
225
- audio_mx = mx.array(dummy_audio, dtype=mx.float32)
226
- _ = self.model.decode_chunk(audio_mx, verbose=False)
227
- elif self.backend == "nano_parakeet":
228
- _ = self.model.transcribe(dummy_audio)
229
- else:
230
- _ = self.model.transcribe([dummy_audio], batch_size=1, verbose=False)
231
-
232
- logger.info("Model warmed up and ready")
233
- except Exception as e:
234
- logger.warning(f"Warmup failed: {e}")
235
-
236
- def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
237
- """
238
- Process audio and generate transcription.
239
-
240
- Yields:
241
- :class:`PartialTranscription` or :class:`Transcription`
242
- """
243
- process_start_s = perf_counter()
244
- is_progressive = vad_audio.mode == "progressive"
245
- audio_input = vad_audio.audio
246
-
247
- # Ensure audio is float32 numpy array
248
- if not isinstance(audio_input, np.ndarray):
249
- audio_input = np.array(audio_input, dtype=np.float32)
250
- else:
251
- audio_input = audio_input.astype(np.float32)
252
- audio_duration_s = len(audio_input) / getattr(self, "sample_rate", 16000)
253
- item_age_s = self._item_age_s(vad_audio)
254
-
255
- self._prepare_live_transcription_turn(vad_audio.turn_id, vad_audio.turn_revision)
256
-
257
- # Handle progressive updates: yield tagged partial for TranscriptionNotifier
258
- if self.enable_live_transcription and is_progressive:
259
- # Ignore progressive updates if we're already processing final audio
260
- if self.processing_final:
261
- logger.debug("Skipping stale progressive update (final audio already received)")
262
- return
263
-
264
- # Try to acquire lock with short timeout - skip if busy
265
- lock_scope_start_s = perf_counter()
266
- with self._compute_lock_context(handler_name="ParakeetSTT-Progressive", timeout=0.01) as acquired:
267
- if acquired:
268
- try:
269
- inference_start_s = perf_counter()
270
- progressive_text = self._show_progressive_transcription(audio_input)
271
- inference_s = perf_counter() - inference_start_s
272
- if inference_s >= 0.25:
273
- logger.info(
274
- "Parakeet progressive STT timing turn=%s rev=%s audio=%.3fs age=%.3fs "
275
- "lock_scope=%.3fs inference=%.3fs chars=%d",
276
- vad_audio.turn_id,
277
- vad_audio.turn_revision,
278
- audio_duration_s,
279
- item_age_s,
280
- perf_counter() - lock_scope_start_s,
281
- inference_s,
282
- len(progressive_text),
283
- )
284
- if progressive_text:
285
- yield PartialTranscription(
286
- text=progressive_text,
287
- turn_id=vad_audio.turn_id,
288
- turn_revision=vad_audio.turn_revision,
289
- )
290
- return
291
- except Exception as e:
292
- logger.debug(f"Progressive transcription failed: {e}")
293
- else:
294
- logger.debug("Skipping progressive update (compute busy)")
295
- return
296
-
297
- # Handle final transcription (send to LLM)
298
- logger.info(
299
- "Parakeet final STT start turn=%s rev=%s audio=%.3fs age=%.3fs",
300
- vad_audio.turn_id,
301
- vad_audio.turn_revision,
302
- audio_duration_s,
303
- item_age_s,
304
- )
305
- inference_s = 0.0
306
- lock_scope_s = 0.0
307
- try:
308
- if self.enable_live_transcription:
309
- # Mark that we're processing final audio (ignore stale progressive updates)
310
- self.processing_final = True
311
-
312
- # Acquire lock with longer timeout for final transcription
313
- lock_scope_start_s = perf_counter()
314
- with self._compute_lock_context(handler_name="ParakeetSTT-Final", timeout=5.0) as acquired:
315
- lock_scope_s = perf_counter() - lock_scope_start_s
316
- if not acquired:
317
- logger.error("Failed to acquire compute lock for final transcription")
318
- pred_text = ""
319
- language_code = self.last_language
320
- else:
321
- inference_start_s = perf_counter()
322
- if self.backend == "mlx":
323
- pred_text, language_code = self._process_mlx_final(audio_input)
324
- else:
325
- pred_text, language_code = self._process_nano_parakeet(audio_input)
326
- inference_s = perf_counter() - inference_start_s
327
- lock_scope_s = perf_counter() - lock_scope_start_s
328
-
329
- # Validate and update language
330
- if language_code and language_code in SUPPORTED_LANGUAGES:
331
- self.last_language = language_code
332
- else:
333
- language_code = self.last_language
334
-
335
- except Exception as e:
336
- logger.error(f"Parakeet TDT inference failed: {e}")
337
- pred_text = ""
338
- language_code = self.last_language
339
-
340
- total_s = perf_counter() - process_start_s
341
- logger.info(
342
- "Parakeet final STT done turn=%s rev=%s total=%.3fs lock_scope=%.3fs inference=%.3fs chars=%d",
343
- vad_audio.turn_id,
344
- vad_audio.turn_revision,
345
- total_s,
346
- lock_scope_s,
347
- inference_s,
348
- len(pred_text),
349
- )
350
- logger.debug("Finished Parakeet TDT inference")
351
- self._clear_live_transcription_line()
352
- if pred_text.strip():
353
- console.print(f"[yellow]USER: {pred_text.strip()}")
354
- if language_code:
355
- console.print(f"[dim]Language: {language_code}[/dim]")
356
-
357
- # Reset per-utterance live transcription state only after final STT
358
- # completes. The streaming handler carries fixed sentence timing within
359
- # an utterance, and stale timing must not leak into the next turn.
360
- if self.enable_live_transcription:
361
- self.processing_final = False
362
- self._reset_live_transcription_state(clear_turn=True)
363
-
364
- yield Transcription(
365
- text=pred_text,
366
- language_code=language_code,
367
- turn_id=vad_audio.turn_id,
368
- turn_revision=vad_audio.turn_revision,
369
- speech_stopped_at_s=vad_audio.created_at_s,
370
- )
371
-
372
- @property
373
- def timing_log_level(self) -> int:
374
- return logging.INFO
375
-
376
- def should_log_timing(self, output: STTOut) -> bool:
377
- return isinstance(output, Transcription) and self.last_time > self.min_time_to_debug
378
-
379
- def _detect_language_from_text(self, text: str) -> Optional[str]:
380
- """
381
- Detect language from transcribed text using lingua-py.
382
-
383
- Args:
384
- text: Transcribed text string
385
-
386
- Returns:
387
- Detected language code or None if detection fails
388
- """
389
- if not LINGUA_AVAILABLE:
390
- logger.warning("lingua-py not available, cannot detect language from text")
391
- return None
392
-
393
- # Skip very short utterances where language ID is still too noisy.
394
- if not text or len(text.strip()) < 20:
395
- return None
396
-
397
- detected = _lingua_detector.detect_language_of(text)
398
- if detected is None:
399
- return None
400
-
401
- code = detected.iso_code_639_1.name.lower()
402
- # Map back lingua-specific codes to our supported codes
403
- return {v: k for k, v in _LINGUA_CODE_MAP.items()}.get(code, code)
404
-
405
- @contextmanager
406
- def _compute_lock_context(self, handler_name: str, timeout: float) -> Iterator[bool]:
407
- if self.backend == "mlx":
408
- with MLXLockContext(handler_name=handler_name, timeout=timeout) as acquired:
409
- yield acquired
410
- return
411
-
412
- lock_start_s = perf_counter()
413
- acquired = self.compute_lock.acquire(timeout=timeout)
414
- wait_s = perf_counter() - lock_start_s
415
- hold_start_s: float | None = None
416
- if acquired:
417
- if wait_s >= 0.25:
418
- logger.info("%s: compute lock acquired after %.2fs", handler_name, wait_s)
419
- else:
420
- logger.debug("%s: compute lock acquired after %.3fs", handler_name, wait_s)
421
- hold_start_s = perf_counter()
422
- else:
423
- logger.warning("%s: Failed to acquire compute lock after %.3fs (timeout=%s)", handler_name, wait_s, timeout)
424
- try:
425
- yield acquired
426
- finally:
427
- if acquired:
428
- assert hold_start_s is not None
429
- self.compute_lock.release()
430
- hold_s = perf_counter() - hold_start_s
431
- if hold_s >= 0.25:
432
- logger.info("%s: compute lock released after holding %.2fs", handler_name, hold_s)
433
- else:
434
- logger.debug("%s: compute lock released after holding %.3fs", handler_name, hold_s)
435
-
436
- def _show_progressive_transcription(self, audio_input: np.ndarray) -> str:
437
- """Run progressive transcription, print to console, and return the text."""
438
- result = self.streaming_handler.transcribe_incremental(audio_input)
439
- rich_text = Text()
440
- if result.fixed_text:
441
- rich_text.append("Live: ", style="dim")
442
- rich_text.append(result.fixed_text, style="yellow")
443
- if result.active_text:
444
- rich_text.append(" ", style="dim")
445
-
446
- if result.active_text:
447
- if not result.fixed_text:
448
- rich_text.append("Live: ", style="dim")
449
- rich_text.append(result.active_text, style="cyan dim")
450
-
451
- progressive_text = self._build_progressive_text(result)
452
- if progressive_text:
453
- self._print_live_transcription(rich_text, progressive_text)
454
-
455
- return progressive_text
456
-
457
- def _print_live_transcription(self, rich_text: Text, progressive_text: str) -> None:
458
- is_terminal = bool(getattr(console, "is_terminal", False))
459
- if is_terminal:
460
- self._write_live_control("\r\x1b[2K")
461
- if rich_text:
462
- console.print(self._truncate_live_transcription(rich_text), end="")
463
- else:
464
- fallback = Text("Live: ", style="dim")
465
- fallback.append(progressive_text, style="cyan dim")
466
- console.print(self._truncate_live_transcription(fallback), end="")
467
- self._write_live_control("\r")
468
- self._live_transcription_active = True
469
- return
470
-
471
- if rich_text:
472
- console.print(rich_text)
473
- else:
474
- console.print(f"[dim]Live: [/dim]{progressive_text}")
475
-
476
- def _clear_live_transcription_line(self) -> None:
477
- if not getattr(self, "_live_transcription_active", False):
478
- return
479
- self._write_live_control("\r\x1b[2K")
480
- self._live_transcription_active = False
481
-
482
- def _write_live_control(self, sequence: str) -> None:
483
- file = getattr(console, "file", None)
484
- if file is None:
485
- return
486
- file.write(sequence)
487
- file.flush()
488
-
489
- def _truncate_live_transcription(self, text: Text) -> Text:
490
- text = text.copy()
491
- width = getattr(console, "width", 80)
492
- try:
493
- max_width = max(1, int(width) - 1)
494
- except (TypeError, ValueError):
495
- max_width = 79
496
- text.truncate(max_width, overflow="ellipsis")
497
- return text
498
-
499
- def _prepare_live_transcription_turn(self, turn_id: str | None, turn_revision: int | None) -> None:
500
- if not self.enable_live_transcription:
501
- return
502
- turn_key = (turn_id, turn_revision)
503
- if getattr(self, "_live_turn_key", None) == turn_key:
504
- return
505
- self._reset_live_transcription_state(clear_turn=False)
506
- self._live_turn_key = turn_key
507
-
508
- def _reset_live_transcription_state(self, clear_turn: bool) -> None:
509
- self._clear_live_transcription_line()
510
- streaming_handler = getattr(self, "streaming_handler", None)
511
- if streaming_handler is not None:
512
- streaming_handler.reset()
513
- if clear_turn:
514
- self._live_turn_key = None
515
-
516
- def _build_progressive_text(self, result: ProgressiveStreamPartial) -> str:
517
- parts = []
518
- if result.fixed_text:
519
- parts.append(result.fixed_text.strip())
520
- if result.active_text:
521
- parts.append(result.active_text.strip())
522
- return " ".join(part for part in parts if part).strip()
523
-
524
- def _process_mlx_final(self, audio_input: np.ndarray) -> tuple[str, str]:
525
- """Process final audio using MLX backend with streaming handler."""
526
- # If we have fixed sentences from progressive updates, only transcribe the new part
527
- if (
528
- self.streaming_handler is not None
529
- and hasattr(self.streaming_handler, "fixed_sentences")
530
- and self.streaming_handler.fixed_sentences
531
- ):
532
- self._clear_live_transcription_line()
533
-
534
- # Get fixed text from previous progressive updates
535
- fixed_text = " ".join(self.streaming_handler.fixed_sentences).strip()
536
-
537
- # Calculate where fixed part ends in audio
538
- fixed_end_time = self.streaming_handler.fixed_end_time
539
- sample_rate = 16000
540
- fixed_end_sample = int(fixed_end_time * sample_rate)
541
- if fixed_end_sample > len(audio_input):
542
- logger.warning(
543
- "Ignoring stale progressive fixed text: fixed_end_sample=%d exceeds final audio samples=%d",
544
- fixed_end_sample,
545
- len(audio_input),
546
- )
547
- pred_text, language_code = self._process_mlx(audio_input)
548
- return pred_text, language_code
549
-
550
- # Only transcribe the part after fixed sentences
551
- if fixed_end_sample < len(audio_input):
552
- remaining_audio = audio_input[fixed_end_sample:]
553
-
554
- import mlx.core as mx
555
-
556
- audio_mx = mx.array(remaining_audio, dtype=mx.float32)
557
- result = self.model.decode_chunk(audio_mx, verbose=False)
558
-
559
- if hasattr(result, "text"):
560
- new_text = result.text.strip()
561
- else:
562
- new_text = str(result).strip()
563
-
564
- # Combine fixed + new
565
- pred_text = f"{fixed_text} {new_text}".strip() if new_text else fixed_text
566
- else:
567
- # All audio already transcribed in progressive updates
568
- pred_text = fixed_text
569
-
570
- # Reset streaming handler for next utterance
571
- self.streaming_handler.reset()
572
- else:
573
- # No progressive updates, transcribe everything
574
- pred_text, language_code = self._process_mlx(audio_input)
575
- return pred_text, language_code
576
-
577
- # Determine language
578
- if self.start_language and self.start_language != "auto":
579
- language_code = self.start_language
580
- else:
581
- detected_lang = self._detect_language_from_text(pred_text)
582
- if detected_lang:
583
- language_code = detected_lang
584
- else:
585
- language_code = self.last_language
586
-
587
- return pred_text, language_code
588
-
589
- def _process_mlx(self, audio_input: np.ndarray) -> tuple[str, str]:
590
- """Process audio using MLX backend."""
591
- import mlx.core as mx
592
-
593
- # Convert numpy array to mx.array
594
- audio_mx = mx.array(audio_input, dtype=mx.float32)
595
-
596
- # Call decode_chunk directly with the audio array
597
- result = self.model.decode_chunk(audio_mx, verbose=False)
598
-
599
- # Extract text from result
600
- if hasattr(result, "text"):
601
- pred_text = result.text.strip()
602
- else:
603
- pred_text = str(result).strip()
604
-
605
- # Determine language:
606
- # 1. Use fixed language if specified by user
607
- # 2. Try to detect from transcribed text using langdetect
608
- # 3. Fall back to last known language
609
- if self.start_language and self.start_language != "auto":
610
- language_code = self.start_language
611
- else:
612
- # Detect language from transcribed text
613
- detected_lang = self._detect_language_from_text(pred_text)
614
- if detected_lang:
615
- language_code = detected_lang
616
- else:
617
- language_code = self.last_language
618
-
619
- return pred_text, language_code
620
-
621
- def _process_nano_parakeet(self, audio_input: np.ndarray) -> tuple[str, str]:
622
- """Process audio using nano-parakeet backend."""
623
- pred_text = self.model.transcribe(audio_input).strip()
624
-
625
- if self.start_language and self.start_language != "auto":
626
- language_code = self.start_language
627
- else:
628
- detected_lang = self._detect_language_from_text(pred_text)
629
- if detected_lang:
630
- language_code = detected_lang
631
- else:
632
- language_code = self.last_language
633
-
634
- return pred_text, language_code
635
-
636
- def cleanup(self) -> None:
637
- """Clean up model resources."""
638
- logger.info(f"Cleaning up {self.__class__.__name__}")
639
- if hasattr(self, "model"):
640
- del self.model
641
-
642
- def on_session_end(self) -> None:
643
- super().on_session_end()
644
- self.last_language = self.start_language if self.start_language else "en"
645
- if self.enable_live_transcription:
646
- self.processing_final = False
647
- self._reset_live_transcription_state(clear_turn=True)
648
- logger.debug("Parakeet TDT session state reset")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/smart_progressive_streaming.py DELETED
@@ -1,343 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Smart Progressive Streaming Handler
4
-
5
- Provides frequent partial transcriptions (every 500ms) with:
6
- - Growing window up to 15s for accuracy
7
- - Sentence-boundary-aware window sliding for audio > 15s
8
- - Fixed sentences + active transcription
9
- """
10
-
11
- from dataclasses import dataclass
12
- from types import SimpleNamespace
13
- from typing import Any, Generator
14
-
15
- import numpy as np
16
-
17
-
18
- @dataclass
19
- class PartialTranscription:
20
- """Result from progressive streaming."""
21
-
22
- fixed_text: str # Sentences that won't change
23
- active_text: str # Current partial transcription
24
- timestamp: float # Current position in audio
25
- is_final: bool # True if this is the last update
26
-
27
-
28
- class SmartProgressiveStreamingHandler:
29
- """
30
- Smart progressive streaming with sentence-aware window management.
31
-
32
- Strategy:
33
- 1. Emit partial transcriptions every 500ms
34
- 2. Use growing window (up to 15s) for better accuracy
35
- 3. When audio > 15s, slide window using sentence boundaries:
36
- - Keep completed sentences as "fixed"
37
- - Only re-transcribe the "active" portion
38
- """
39
-
40
- def __init__(
41
- self,
42
- model: Any,
43
- emission_interval: float = 0.5,
44
- max_window_size: float = 15.0,
45
- sentence_buffer: float = 2.0,
46
- ) -> None:
47
- """
48
- Args:
49
- model: Parakeet model with sentence alignment
50
- emission_interval: Emit partial transcription every N seconds (default 500ms)
51
- max_window_size: Maximum window size before sliding (default 15s)
52
- sentence_buffer: Keep last N seconds of sentences in active window (default 2s)
53
- """
54
- self.model = model
55
- self.emission_interval = emission_interval
56
- self.max_window_size = max_window_size
57
- self.sentence_buffer = sentence_buffer
58
- self.sample_rate = 16000
59
-
60
- # State for incremental streaming
61
- self.reset()
62
-
63
- def reset(self) -> None:
64
- """Reset state for new streaming session."""
65
- self.fixed_sentences: list[str] = []
66
- self.fixed_end_time: float = 0.0
67
- self.last_transcribed_length: int = 0
68
-
69
- def _decode_window(self, audio_window: np.ndarray) -> Any:
70
- """
71
- Decode an audio window and return an object with:
72
- - text: full transcript for the window
73
- - sentences: list of objects with .text and .end (seconds)
74
- """
75
- if hasattr(self.model, "decode_chunk"):
76
- import mlx.core as mx
77
-
78
- audio_mx = mx.array(audio_window, dtype=mx.float32)
79
- return self.model.decode_chunk(audio_mx, verbose=False)
80
-
81
- # nano-parakeet: use timestamps from transcribe
82
- result = self.model.transcribe(audio_window, timestamps=True)
83
- segments = result.timestamp.get("segment", []) if hasattr(result, "timestamp") else []
84
- sentences = [SimpleNamespace(text=seg.get("segment", "").strip(), end=seg.get("end", 0.0)) for seg in segments]
85
- return SimpleNamespace(text=getattr(result, "text", ""), sentences=sentences)
86
-
87
- def transcribe_incremental(self, audio: np.ndarray) -> PartialTranscription:
88
- """
89
- Transcribe audio incrementally (for live streaming).
90
-
91
- Call this repeatedly with growing audio buffer.
92
- Returns a single PartialTranscription for current state.
93
- """
94
- # Skip if not enough new audio
95
- current_length = len(audio)
96
- if current_length < self.sample_rate * 0.5: # Need at least 500ms
97
- return PartialTranscription(
98
- fixed_text=" ".join(self.fixed_sentences),
99
- active_text="",
100
- timestamp=current_length / self.sample_rate,
101
- is_final=False,
102
- )
103
-
104
- # Skip if no new audio since last transcription
105
- if current_length == self.last_transcribed_length:
106
- return PartialTranscription(
107
- fixed_text=" ".join(self.fixed_sentences),
108
- active_text="",
109
- timestamp=current_length / self.sample_rate,
110
- is_final=False,
111
- )
112
-
113
- self.last_transcribed_length = current_length
114
-
115
- # Extract window for transcription (from last fixed sentence to end)
116
- window_start_samples = int(self.fixed_end_time * self.sample_rate)
117
- audio_window = audio[window_start_samples:]
118
-
119
- # Transcribe current window
120
- result = self._decode_window(audio_window)
121
-
122
- # Check if window exceeds max_window_size
123
- window_duration = len(audio_window) / self.sample_rate
124
-
125
- if window_duration >= self.max_window_size and len(result.sentences) > 1:
126
- # Window is too large - fix some sentences
127
- cutoff_time = window_duration - self.sentence_buffer
128
-
129
- # Find sentences to fix
130
- new_fixed_sentences = []
131
- new_fixed_end_time = self.fixed_end_time
132
-
133
- for sentence in result.sentences:
134
- sentence_abs_time = self.fixed_end_time + sentence.end
135
-
136
- if sentence.end < cutoff_time:
137
- # Fix this sentence
138
- new_fixed_sentences.append(sentence.text.strip())
139
- new_fixed_end_time = sentence_abs_time
140
- else:
141
- break
142
-
143
- if new_fixed_sentences:
144
- self.fixed_sentences.extend(new_fixed_sentences)
145
- self.fixed_end_time = new_fixed_end_time
146
-
147
- # Re-transcribe from new fixed point
148
- window_start_samples = int(self.fixed_end_time * self.sample_rate)
149
- audio_window = audio[window_start_samples:]
150
- result = self._decode_window(audio_window)
151
-
152
- # Build output
153
- fixed_text = " ".join(self.fixed_sentences)
154
- active_text = result.text.strip()
155
- timestamp = len(audio) / self.sample_rate
156
-
157
- return PartialTranscription(fixed_text=fixed_text, active_text=active_text, timestamp=timestamp, is_final=False)
158
-
159
- def transcribe_progressive(self, audio: np.ndarray) -> Generator[PartialTranscription, None, None]:
160
- """
161
- Transcribe audio with smart progressive emissions.
162
-
163
- Yields PartialTranscription with:
164
- - fixed_text: Completed sentences (won't change)
165
- - active_text: Current partial transcription
166
- - timestamp: Current position
167
- - is_final: True for last update
168
- """
169
- position = 0 # Start of current window (in samples)
170
- fixed_sentences = [] # List of completed sentence texts
171
- fixed_end_time = 0.0 # End time of last fixed sentence
172
-
173
- while position < len(audio):
174
- # Determine current window end
175
- remaining = (len(audio) - position) / self.sample_rate
176
-
177
- if remaining <= self.emission_interval:
178
- # Last chunk - process everything remaining
179
- window_end = len(audio)
180
- is_final = True
181
- else:
182
- # Regular chunk
183
- window_end = min(len(audio), position + int(self.emission_interval * self.sample_rate))
184
- is_final = False
185
-
186
- # Extract window for transcription
187
- # Window includes: fixed_end_time to current position
188
- # (we re-transcribe from last fixed sentence to get better context)
189
- window_start_samples = int(fixed_end_time * self.sample_rate)
190
- audio_window = audio[window_start_samples:window_end]
191
-
192
- # Transcribe current window
193
- result = self._decode_window(audio_window)
194
-
195
- # Check if window exceeds max_window_size
196
- window_duration = (window_end - window_start_samples) / self.sample_rate
197
-
198
- if window_duration >= self.max_window_size and len(result.sentences) > 1:
199
- # Window is too large - need to fix some sentences
200
- # Strategy: Keep sentences up to (max_window - sentence_buffer) as fixed
201
- cutoff_time = window_duration - self.sentence_buffer
202
-
203
- # Find sentences that are completed and before cutoff
204
- new_fixed_sentences = []
205
- new_fixed_end_time = fixed_end_time
206
-
207
- for sentence in result.sentences:
208
- sentence_abs_time = fixed_end_time + sentence.end
209
-
210
- if sentence.end < cutoff_time:
211
- # This sentence is complete and old enough to fix
212
- new_fixed_sentences.append(sentence.text.strip())
213
- new_fixed_end_time = sentence_abs_time
214
- else:
215
- # Stop here - keep remaining sentences as active
216
- break
217
-
218
- if new_fixed_sentences:
219
- fixed_sentences.extend(new_fixed_sentences)
220
- fixed_end_time = new_fixed_end_time
221
-
222
- # Re-transcribe from new fixed_end_time
223
- window_start_samples = int(fixed_end_time * self.sample_rate)
224
- audio_window = audio[window_start_samples:window_end]
225
- result = self._decode_window(audio_window)
226
-
227
- # Build output
228
- fixed_text = " ".join(fixed_sentences)
229
- active_text = result.text.strip()
230
- timestamp = window_end / self.sample_rate
231
-
232
- yield PartialTranscription(
233
- fixed_text=fixed_text, active_text=active_text, timestamp=timestamp, is_final=is_final
234
- )
235
-
236
- # Move to next position
237
- position = window_end
238
-
239
- if is_final:
240
- break
241
-
242
-
243
- def demo_smart_progressive() -> None:
244
- """Demonstrate smart progressive streaming."""
245
- import time
246
-
247
- import soundfile as sf
248
- from mlx_audio.stt.generate import load_model
249
-
250
- print("=" * 80)
251
- print("SMART PROGRESSIVE STREAMING DEMO")
252
- print("=" * 80)
253
- print("\nFeatures:")
254
- print(" • Partial updates every 500ms")
255
- print(" • Growing window up to 15s")
256
- print(" • Sentence-aware window sliding for long audio")
257
- print()
258
-
259
- # Load model
260
- print("Loading model...")
261
- model = load_model("mlx-community/parakeet-tdt-0.6b-v3")
262
-
263
- # Load audio
264
- audio, sr = sf.read("reachy-voice-test.wav")
265
- if len(audio.shape) > 1:
266
- audio = audio.mean(axis=1)
267
- if sr != 16000:
268
- from scipy import signal
269
-
270
- audio = signal.resample(audio, int(len(audio) * 16000 / sr))
271
- audio = audio.astype(np.float32)
272
-
273
- # Test cases
274
- test_cases = [
275
- (audio, "Short (5.8s)"),
276
- (np.concatenate([audio] * 3), "Medium (17.5s) - exceeds 15s window"),
277
- (np.concatenate([audio] * 5), "Long (29.1s) - multiple sentence fixings"),
278
- ]
279
-
280
- for test_audio, label in test_cases:
281
- duration = len(test_audio) / 16000
282
-
283
- print(f"\n{'=' * 80}")
284
- print(f"TEST: {label} - Duration: {duration:.1f}s")
285
- print("=" * 80)
286
-
287
- handler = SmartProgressiveStreamingHandler(
288
- model,
289
- emission_interval=0.5, # 500ms updates
290
- max_window_size=15.0, # Max 15s window
291
- sentence_buffer=2.0, # Keep 2s of sentences in active window
292
- )
293
-
294
- print("\nProgressive transcriptions (showing every 4th update for readability):")
295
- print()
296
-
297
- update_count = 0
298
- start_time = time.perf_counter()
299
-
300
- for result in handler.transcribe_progressive(test_audio):
301
- update_count += 1
302
-
303
- # Show every other update (every 1s) to keep output manageable
304
- if update_count % 2 == 0 or result.is_final:
305
- elapsed = time.perf_counter() - start_time
306
- marker = "FINAL" if result.is_final else f"Update {update_count}"
307
-
308
- print(f"[{result.timestamp:5.1f}s | {elapsed:.3f}s elapsed] {marker}:")
309
-
310
- if result.fixed_text:
311
- print(f" Fixed: {result.fixed_text[:70]}...")
312
- print(f" Active: {result.active_text[:70]}...")
313
- print()
314
-
315
- total_time = time.perf_counter() - start_time
316
- print(f"Total updates: {update_count}")
317
- print(f"Total time: {total_time:.3f}s ({total_time / duration:.3f}s per audio second)")
318
-
319
- print("\n" + "=" * 80)
320
- print("KEY INSIGHTS")
321
- print("=" * 80)
322
- print("""
323
- 1. Frequent updates (500ms):
324
- - User sees transcription building in real-time
325
- - Low latency feel
326
-
327
- 2. Growing window (up to 15s):
328
- - Better accuracy with more context
329
- - No arbitrary chunking for short audio
330
-
331
- 3. Sentence-aware sliding (for audio > 15s):
332
- - Completed sentences become "fixed"
333
- - Only active portion re-transcribed
334
- - Maintains accuracy while managing window size
335
-
336
- 4. Performance:
337
- - Processing happens DURING user speaking
338
- - Final result available immediately when user stops
339
- """)
340
-
341
-
342
- if __name__ == "__main__":
343
- demo_smart_progressive()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/transcription_notifier.py DELETED
@@ -1,103 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from queue import Queue
5
- from threading import Event
6
- from typing import Iterator, Union
7
-
8
- from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
9
- from speech_to_speech.baseHandler import BaseHandler
10
- from speech_to_speech.LLM.chat import make_user_message
11
- from speech_to_speech.pipeline.events import PartialTranscriptionEvent, TranscriptionCompletedEvent
12
- from speech_to_speech.pipeline.handler_types import LLMIn, STTOut
13
- from speech_to_speech.pipeline.messages import GenerateResponseRequest, PartialTranscription, Transcription
14
- from speech_to_speech.pipeline.queue_types import TextEventItem
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- class TranscriptionNotifier(BaseHandler[STTOut, Union[STTOut, LLMIn]]):
20
- """Sits between STT and LLM.
21
-
22
- For **realtime mode** (no ``runtime_config``): emits transcription events
23
- on ``text_output_queue`` for protocol translation but yields nothing -- the
24
- ``RealtimeService`` builds ``GenerateResponseRequest`` directly.
25
-
26
- For **legacy mode** (``runtime_config`` provided): appends the user
27
- message to ``runtime_config.chat`` and yields a
28
- ``GenerateResponseRequest`` so the LLM handler receives a uniform input
29
- type regardless of pipeline mode.
30
- """
31
-
32
- def setup(
33
- self,
34
- text_output_queue: Queue[TextEventItem] | None = None,
35
- runtime_config: RuntimeConfig | None = None,
36
- should_listen: Event | None = None,
37
- ) -> None:
38
- self.text_output_queue = text_output_queue
39
- self.runtime_config = runtime_config
40
- self.should_listen = should_listen
41
-
42
- def process(self, transcription: STTOut) -> Iterator[Union[STTOut, LLMIn]]:
43
- if isinstance(transcription, PartialTranscription):
44
- if self.text_output_queue and transcription.text:
45
- self.text_output_queue.put(
46
- PartialTranscriptionEvent(
47
- delta=str(transcription.text),
48
- turn_id=transcription.turn_id,
49
- turn_revision=transcription.turn_revision,
50
- )
51
- )
52
- logger.debug("Partial transcription: %s", str(transcription.text)[:80])
53
- return
54
-
55
- if isinstance(transcription, Transcription):
56
- text = transcription.text
57
- language_code = transcription.language_code
58
- turn_id = transcription.turn_id
59
- turn_revision = transcription.turn_revision
60
- speech_stopped_at_s = transcription.speech_stopped_at_s
61
- else:
62
- text = transcription
63
- language_code = None
64
- turn_id = None
65
- turn_revision = None
66
- speech_stopped_at_s = None
67
-
68
- transcript = str(text)
69
- # Always close the client-visible transcription item. Empty final STT
70
- # results should not trigger the LLM, but clients may already have
71
- # received partial deltas and still need a completed event.
72
- if self.text_output_queue is not None:
73
- self.text_output_queue.put(
74
- TranscriptionCompletedEvent(
75
- transcript=transcript,
76
- language_code=language_code,
77
- turn_id=turn_id,
78
- turn_revision=turn_revision,
79
- speech_stopped_at_s=speech_stopped_at_s,
80
- )
81
- )
82
-
83
- if not transcript:
84
- logger.debug("Transcription completed with empty transcript")
85
- if self.should_listen is not None:
86
- self.should_listen.set()
87
- logger.debug("Empty transcription completed; listening re-enabled")
88
- return
89
-
90
- if language_code:
91
- logger.info("Transcription completed (language=%s): %s", language_code, transcript)
92
- else:
93
- logger.info("Transcription completed: %s", transcript)
94
-
95
- if self.runtime_config is not None:
96
- self.runtime_config.chat.add_item(make_user_message(transcript))
97
- yield GenerateResponseRequest(
98
- runtime_config=self.runtime_config,
99
- language_code=language_code,
100
- turn_id=turn_id,
101
- turn_revision=turn_revision,
102
- speech_stopped_at_s=speech_stopped_at_s,
103
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/STT/whisper_stt_handler.py DELETED
@@ -1,147 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from copy import copy
5
- from typing import Any, Iterator, Optional
6
-
7
- import numpy as np
8
- import torch
9
- from rich.console import Console
10
- from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
11
-
12
- from speech_to_speech.pipeline.handler_types import STTIn, STTOut
13
- from speech_to_speech.pipeline.messages import Transcription
14
- from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
15
-
16
- logger = logging.getLogger(__name__)
17
- console = Console()
18
-
19
- SUPPORTED_LANGUAGES = [
20
- "en",
21
- "fr",
22
- "es",
23
- "zh",
24
- "ja",
25
- "ko",
26
- "hi",
27
- "de",
28
- "pt",
29
- "pl",
30
- "it",
31
- "nl",
32
- ]
33
-
34
-
35
- class WhisperSTTHandler(BaseSTTHandler):
36
- """
37
- Handles the Speech To Text generation using a Whisper model.
38
- """
39
-
40
- def setup(
41
- self,
42
- model_name: str = "distil-whisper/distil-large-v3",
43
- device: str = "cuda",
44
- torch_dtype: str = "float16",
45
- compile_mode: Optional[str] = None,
46
- language: Optional[str] = None,
47
- gen_kwargs: dict[str, Any] = {},
48
- ) -> None:
49
- self.device = device
50
- self.torch_dtype = getattr(torch, torch_dtype)
51
- self.compile_mode = compile_mode
52
- self.gen_kwargs = gen_kwargs
53
- self.start_language = language
54
- self.last_language = language if language != "auto" else None
55
- if self.last_language is not None:
56
- self.gen_kwargs["language"] = self.last_language
57
-
58
- self.processor = AutoProcessor.from_pretrained(model_name)
59
- self.model = AutoModelForSpeechSeq2Seq.from_pretrained(
60
- model_name,
61
- torch_dtype=self.torch_dtype,
62
- ).to(device)
63
-
64
- # compile
65
- if self.compile_mode:
66
- self.model.generation_config.cache_implementation = "static"
67
- self.model.forward = torch.compile(self.model.forward, mode=self.compile_mode, fullgraph=True)
68
- self.warmup()
69
-
70
- def prepare_model_inputs(self, audio: np.ndarray) -> torch.Tensor:
71
- input_features = self.processor(audio, sampling_rate=16000, return_tensors="pt").input_features
72
- input_features = input_features.to(self.device, dtype=self.torch_dtype)
73
-
74
- return input_features
75
-
76
- def warmup(self) -> None:
77
- logger.info(f"Warming up {self.__class__.__name__}")
78
-
79
- # 2 warmup steps for no compile or compile mode with CUDA graphs capture
80
- n_steps = 1 if self.compile_mode == "default" else 2
81
- dummy_input = torch.randn(
82
- (1, self.model.config.num_mel_bins, 3000),
83
- dtype=self.torch_dtype,
84
- device=self.device,
85
- )
86
- if self.compile_mode not in (None, "default"):
87
- # generating more tokens than previously will trigger CUDA graphs capture
88
- # one should warmup with a number of generated tokens above max tokens targeted for subsequent generation
89
- # hence, having min_new_tokens < max_new_tokens in the future doesn't make sense
90
- warmup_gen_kwargs = {
91
- "min_new_tokens": self.gen_kwargs["max_new_tokens"], # Yes, assign max_new_tokens to min_new_tokens
92
- "max_new_tokens": self.gen_kwargs["max_new_tokens"],
93
- **self.gen_kwargs,
94
- }
95
- else:
96
- warmup_gen_kwargs = self.gen_kwargs
97
-
98
- if self.device == "cuda":
99
- start_event = torch.cuda.Event(enable_timing=True)
100
- end_event = torch.cuda.Event(enable_timing=True)
101
- torch.cuda.synchronize()
102
- start_event.record()
103
-
104
- for _ in range(n_steps):
105
- _ = self.model.generate(dummy_input, **warmup_gen_kwargs)
106
-
107
- if self.device == "cuda":
108
- end_event.record()
109
- torch.cuda.synchronize()
110
-
111
- logger.info(
112
- f"{self.__class__.__name__}: warmed up! time: {start_event.elapsed_time(end_event) * 1e-3:.3f} s"
113
- )
114
-
115
- def process(self, vad_audio: STTIn) -> Iterator[STTOut]:
116
- logger.debug("infering whisper...")
117
-
118
- input_features = self.prepare_model_inputs(vad_audio.audio)
119
- pred_ids = self.model.generate(input_features, **self.gen_kwargs)
120
- language_code = self.processor.tokenizer.decode(pred_ids[0, 1])[2:-2] # remove "<|" and "|>"
121
-
122
- if language_code not in SUPPORTED_LANGUAGES: # reprocess with the last language
123
- logger.warning("Whisper detected unsupported language: %s", language_code)
124
- gen_kwargs = copy(self.gen_kwargs)
125
- gen_kwargs["language"] = self.last_language
126
- language_code = self.last_language
127
- pred_ids = self.model.generate(input_features, **gen_kwargs)
128
- else:
129
- self.last_language = language_code
130
-
131
- pred_text = self.processor.batch_decode(pred_ids, skip_special_tokens=True, decode_with_timestamps=False)[0]
132
- language_code = self.processor.tokenizer.decode(pred_ids[0, 1])[2:-2] # remove "<|" and "|>"
133
-
134
- logger.debug("finished whisper inference")
135
- console.print(f"[yellow]USER: {pred_text}")
136
- logger.debug(f"Language Code Whisper: {language_code}")
137
-
138
- if self.start_language == "auto":
139
- language_code += "-auto"
140
-
141
- yield Transcription(
142
- text=pred_text,
143
- language_code=language_code,
144
- turn_id=vad_audio.turn_id,
145
- turn_revision=vad_audio.turn_revision,
146
- speech_stopped_at_s=vad_audio.created_at_s,
147
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/TTS/README.md DELETED
@@ -1,155 +0,0 @@
1
- # TTS Summary
2
-
3
- ## Available TTS modes (`--tts`)
4
-
5
- Runtime-supported values in `s2s_pipeline.py`:
6
-
7
- - `chatTTS` → `chatTTS_handler.py`
8
- - `facebookMMS` → `facebookmms_handler.py`
9
- - `pocket` → `pocket_tts_handler.py`
10
- - `kokoro` → `kokoro_handler.py`
11
- - `qwen3` → `qwen3_tts_handler.py`
12
-
13
- Deprecated TTS implementations, including MeloTTS, live in [`../../../archive/TTS`](../../../archive/TTS) and are no longer wired into `s2s_pipeline.py`.
14
-
15
- ## Usage
16
-
17
- ### 1) ChatTTS (`--tts chatTTS`)
18
-
19
- Primary args prefix: `--chat_tts_*`
20
-
21
- ```bash
22
- python s2s_pipeline.py \
23
- --tts chatTTS \
24
- --chat_tts_device cuda \
25
- --chat_tts_stream true \
26
- --chat_tts_chunk_size 512
27
- ```
28
-
29
- ### 2) Facebook MMS (`--tts facebookMMS`)
30
-
31
- Primary args prefix: `--facebook_mms_*` plus `--tts_language`
32
-
33
- ```bash
34
- python s2s_pipeline.py \
35
- --tts facebookMMS \
36
- --facebook_mms_device cuda \
37
- --tts_language en
38
- ```
39
-
40
- This handler maps STT language codes (e.g. `en`, `fr`, `es`) to MMS model suffixes (e.g. `eng`, `fra`, `spa`) and reloads the model on language changes.
41
-
42
- ### 3) Pocket TTS (`--tts pocket`)
43
-
44
- Primary args prefix: `--pocket_tts_*`
45
-
46
- ```bash
47
- python s2s_pipeline.py \
48
- --tts pocket \
49
- --pocket_tts_voice jean \
50
- --pocket_tts_device cpu \
51
- --pocket_tts_sample_rate 16000
52
- ```
53
-
54
- Available preset voices include:
55
- `alba`, `marius`, `javert`, `jean`, `fantine`, `cosette`, `eponine`, `azelma`.
56
-
57
- ### 4) Kokoro (`--tts kokoro`)
58
-
59
- Primary args prefix: `--kokoro_*`
60
-
61
- ```bash
62
- python s2s_pipeline.py \
63
- --tts kokoro \
64
- --kokoro_device auto \
65
- --kokoro_voice bm_fable \
66
- --kokoro_lang_code b
67
- ```
68
-
69
- Behavior:
70
- - Uses MLX backend on Apple Silicon (`mlx-community/Kokoro-82M-bf16`)
71
- - Uses native kokoro pipeline otherwise (`hexgrad/Kokoro-82M`)
72
- - Can auto-switch voice/language based on STT language code mapping
73
-
74
- ### 5) Qwen3-TTS (`--tts qwen3`)
75
-
76
- Primary args prefix: `--qwen3_tts_*`
77
-
78
- ```bash
79
- python s2s_pipeline.py \
80
- --tts qwen3 \
81
- --qwen3_tts_model_name Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
82
- --qwen3_tts_device cuda \
83
- --qwen3_tts_backend ggml \
84
- --qwen3_tts_speaker Aiden \
85
- --qwen3_tts_language auto \
86
- --qwen3_tts_non_streaming_mode True
87
- ```
88
-
89
- Behavior:
90
- - Uses `faster-qwen3-tts` on non-macOS platforms, defaulting to the GGML backend. Pass `--qwen3_tts_backend torch` to use the CUDA-graphs backend instead.
91
- - Uses `mlx-audio` on Apple Silicon and auto-maps `Qwen/...` model IDs to `mlx-community/...`, defaulting to the `6bit` MLX variant unless the model name already pins a suffix.
92
- - Supports MLX quantization overrides on Apple Silicon via `--qwen3_tts_mlx_quantization bf16|4bit|6bit|8bit`.
93
- - Keeps the existing voice-clone/custom-voice/voice-design handler flow intact.
94
- - Defaults to the CustomVoice model with speaker `Aiden`, so no reference audio is required. Voice-clone/base models can still use `--qwen3_tts_ref_audio`.
95
-
96
- Install notes for Linux GGML:
97
- - The default PyPI `qwentts-cpp-python` wheel targets CUDA 12.8.
98
- - If that wheel does not match your CUDA runtime, install one of the Hugging Face wheelhouse builds before installing `speech-to-speech`.
99
-
100
- ```bash
101
- pip install "qwentts-cpp-python==0.3.1+cu130" \
102
- -f https://huggingface.co/datasets/andito/qwentts-cpp-python-wheels/tree/main/whl/cu130
103
- pip install speech-to-speech
104
- ```
105
-
106
- Available wheelhouse directories include `cu124`, `cu128`, `cu130`, and `cpu`.
107
-
108
- Example for Apple Silicon using the default 6-bit MLX variant:
109
-
110
- ```bash
111
- python s2s_pipeline.py \
112
- --tts qwen3 \
113
- --qwen3_tts_model_name Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
114
- --qwen3_tts_speaker Aiden
115
- ```
116
-
117
- You can override the default and select `bf16`, `4bit`, or `8bit` explicitly:
118
-
119
- ```bash
120
- python s2s_pipeline.py \
121
- --tts qwen3 \
122
- --qwen3_tts_model_name Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \
123
- --qwen3_tts_mlx_quantization 4bit \
124
- --qwen3_tts_speaker Aiden
125
- ```
126
-
127
- To benchmark the Apple Silicon MLX variants side by side:
128
-
129
- ```bash
130
- .venv/bin/python benchmark_tts.py \
131
- --handlers qwen3 \
132
- --iterations 3 \
133
- --qwen3_mlx_quantizations bf16 4bit 6bit 8bit
134
- ```
135
-
136
- This will run separate benchmark entries for `qwen3[bf16]`, `qwen3[4bit]`, `qwen3[6bit]`, and `qwen3[8bit]`.
137
-
138
- ## Setup
139
-
140
- ### Low-latency GPU setup
141
-
142
- ```bash
143
- python s2s_pipeline.py \
144
- --stt whisper \
145
- --tts pocket
146
- ```
147
-
148
- ### Apple Silicon setup
149
-
150
- ```bash
151
- python s2s_pipeline.py \
152
- --local_mac_optimal_settings
153
- ```
154
-
155
- `--tts pocket` and `--tts kokoro` are also valid options on macOS.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/TTS/__init__.py DELETED
@@ -1 +0,0 @@
1
- # TTS module
 
 
runtime/src/speech_to_speech/TTS/chatTTS_handler.py DELETED
@@ -1,119 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from threading import Event
5
- from typing import Any, Iterator
6
-
7
- import ChatTTS
8
- import librosa
9
- import numpy as np
10
- import torch
11
- from rich.console import Console
12
-
13
- from speech_to_speech.baseHandler import BaseHandler
14
- from speech_to_speech.pipeline.cancel_scope import CancelScope
15
- from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut
16
- from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse
17
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
18
-
19
- logging.basicConfig(
20
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
21
- )
22
-
23
- logger = logging.getLogger(__name__)
24
-
25
- console = Console()
26
-
27
-
28
- class ChatTTSHandler(BaseHandler[TTSIn, TTSOut]):
29
- def setup(
30
- self,
31
- should_listen: Event,
32
- device: str = "cuda",
33
- gen_kwargs: dict[str, Any] = {}, # Unused
34
- stream: bool = True,
35
- chunk_size: int = 512,
36
- cancel_scope: CancelScope | None = None,
37
- speculative_turns: SpeculativeTurnTracker | None = None,
38
- ) -> None:
39
- self.should_listen = should_listen
40
- self.cancel_scope = cancel_scope
41
- self.speculative_turns = speculative_turns
42
- self.device = device
43
- self.model = ChatTTS.Chat()
44
- self.model.load(compile=False) # Doesn't work for me with True
45
- self.chunk_size = chunk_size
46
- self.stream = stream
47
- rnd_spk_emb = self.model.sample_random_speaker()
48
- self.params_infer_code = ChatTTS.Chat.InferCodeParams(
49
- spk_emb=rnd_spk_emb,
50
- )
51
- self.warmup()
52
-
53
- def warmup(self) -> None:
54
- logger.info(f"Warming up {self.__class__.__name__}")
55
- _ = self.model.infer("text")
56
-
57
- def process(self, tts_input: TTSIn) -> Iterator[TTSOut]:
58
- speculative_turns = getattr(self, "speculative_turns", None)
59
- if isinstance(tts_input, EndOfResponse):
60
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
61
- tts_input.turn_id,
62
- tts_input.turn_revision,
63
- ):
64
- return
65
- yield AUDIO_RESPONSE_DONE
66
- return
67
-
68
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
69
- tts_input.turn_id,
70
- tts_input.turn_revision,
71
- ):
72
- logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision)
73
- return
74
- if speculative_turns:
75
- speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision)
76
-
77
- text = tts_input.text
78
-
79
- _cancel_gen = self.cancel_scope.generation if self.cancel_scope else None
80
- console.print(f"[green]ASSISTANT: {text}")
81
- if self.device == "mps":
82
- import time
83
-
84
- start = time.time()
85
- torch.mps.synchronize() # Waits for all kernels in all streams on the MPS device to complete.
86
- torch.mps.empty_cache() # Frees all memory allocated by the MPS device.
87
- _ = time.time() - start # Removing this line makes it fail more often. I'm looking into it.
88
-
89
- wavs_gen = self.model.infer(text, params_infer_code=self.params_infer_code, stream=self.stream)
90
-
91
- if self.stream:
92
- wavs = [np.array([])]
93
- for gen in wavs_gen:
94
- if (
95
- _cancel_gen is not None
96
- and self.cancel_scope is not None
97
- and self.cancel_scope.is_stale(_cancel_gen)
98
- ):
99
- logger.info("TTS generation cancelled (interruption)")
100
- return
101
- if gen[0] is None or len(gen[0]) == 0:
102
- return
103
- audio_chunk = librosa.resample(gen[0], orig_sr=24000, target_sr=16000)
104
- audio_chunk = (audio_chunk * 32768).astype(np.int16)[0]
105
- while len(audio_chunk) > self.chunk_size:
106
- yield audio_chunk[: self.chunk_size] # Return the first chunk_size samples of the audio data
107
- audio_chunk = audio_chunk[self.chunk_size :] # Remove the samples that have already been returned
108
- yield np.pad(audio_chunk, (0, self.chunk_size - len(audio_chunk)))
109
- else:
110
- wavs = wavs_gen
111
- if len(wavs[0]) == 0:
112
- return
113
- audio_chunk = librosa.resample(wavs[0], orig_sr=24000, target_sr=16000)
114
- audio_chunk = (audio_chunk * 32768).astype(np.int16)
115
- for i in range(0, len(audio_chunk), self.chunk_size):
116
- yield np.pad(
117
- audio_chunk[i : i + self.chunk_size],
118
- (0, self.chunk_size - len(audio_chunk[i : i + self.chunk_size])),
119
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/TTS/facebookmms_handler.py DELETED
@@ -1,212 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from threading import Event
5
- from typing import Any, Iterator
6
-
7
- import librosa
8
- import numpy as np
9
- import torch
10
- from rich.console import Console
11
- from transformers import AutoTokenizer, VitsModel
12
-
13
- from speech_to_speech.baseHandler import BaseHandler
14
- from speech_to_speech.pipeline.cancel_scope import CancelScope
15
- from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut
16
- from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse
17
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
18
-
19
- logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG)
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
- console = Console()
24
-
25
- WHISPER_LANGUAGE_TO_FACEBOOK_LANGUAGE = {
26
- "en": "eng", # English
27
- "fr": "fra", # French
28
- "es": "spa", # Spanish
29
- "ko": "kor", # Korean
30
- "hi": "hin", # Hindi
31
- "ar": "ara", # Arabic
32
- "hy": "hyw", # Armenian
33
- "az": "azb", # Azerbaijani
34
- "bu": "bul", # Bulgarian
35
- "ca": "cat", # Catalan
36
- "nl": "nld", # Dutch
37
- "fi": "fin", # Finnish
38
- "de": "deu", # German
39
- "el": "ell", # Greek
40
- "he": "heb", # Hebrew
41
- "hu": "hun", # Hungarian
42
- "is": "isl", # Icelandic
43
- "id": "ind", # Indonesian
44
- "ka": "kan", # Kannada
45
- "kk": "kaz", # Kazakh
46
- "lv": "lav", # Latvian
47
- "zl": "zlm", # Malay
48
- "ma": "mar", # Marathi
49
- "fa": "fas", # Persian
50
- "po": "pol", # Polish
51
- "pt": "por", # Portuguese
52
- "ro": "ron", # Romanian
53
- "ru": "rus", # Russian
54
- "sw": "swh", # Swahili
55
- "sv": "swe", # Swedish
56
- "tg": "tgl", # Tagalog
57
- "ta": "tam", # Tamil
58
- "th": "tha", # Thai
59
- "tu": "tur", # Turkish
60
- "uk": "ukr", # Ukrainian
61
- "ur": "urd", # Urdu
62
- "vi": "vie", # Vietnamese
63
- "cy": "cym", # Welsh
64
- }
65
-
66
-
67
- class FacebookMMSTTSHandler(BaseHandler[TTSIn, TTSOut]):
68
- def setup(
69
- self,
70
- should_listen: Event,
71
- device: str = "cuda",
72
- torch_dtype: str = "float32",
73
- language: str = "en",
74
- stream: bool = True,
75
- chunk_size: int = 512,
76
- cancel_scope: CancelScope | None = None,
77
- speculative_turns: SpeculativeTurnTracker | None = None,
78
- **kwargs: Any,
79
- ) -> None:
80
- self.should_listen = should_listen
81
- self.cancel_scope = cancel_scope
82
- self.speculative_turns = speculative_turns
83
- self.device = device
84
- self.torch_dtype = getattr(torch, torch_dtype)
85
- self.stream = stream
86
- self.chunk_size = chunk_size
87
- self.language = language
88
-
89
- self._initial_language = self.language
90
- self.load_model(self.language)
91
- self.warmup()
92
-
93
- def load_model(self, language_code: str) -> None:
94
- try:
95
- model_name = f"facebook/mms-tts-{WHISPER_LANGUAGE_TO_FACEBOOK_LANGUAGE[language_code]}"
96
- logger.info(f"Loading model: {model_name}")
97
- self.model = VitsModel.from_pretrained(model_name).to(self.device) # type: ignore[arg-type]
98
- self.tokenizer = AutoTokenizer.from_pretrained(model_name)
99
- self.language = language_code
100
- except KeyError:
101
- logger.warning(f"Unsupported language: {language_code}. Falling back to English.")
102
- self.load_model("en")
103
-
104
- def warmup(self) -> None:
105
- logger.info(f"Warming up {self.__class__.__name__}")
106
- self.generate_audio("Hello, this is a test")
107
-
108
- def generate_audio(self, text: str) -> torch.Tensor | None:
109
- if not text:
110
- logger.warning("Received empty text input")
111
- return None
112
-
113
- try:
114
- logger.debug(f"Tokenizing text: {text}")
115
- logger.debug(f"Current language: {self.language}")
116
- logger.debug(f"Tokenizer: {self.tokenizer}")
117
-
118
- inputs = self.tokenizer(text, return_tensors="pt", padding=True, truncation=True)
119
- input_ids = inputs.input_ids.to(self.device).long()
120
- attention_mask = inputs.attention_mask.to(self.device)
121
-
122
- logger.debug(f"Input IDs shape: {input_ids.shape}, dtype: {input_ids.dtype}")
123
- logger.debug(f"Input IDs: {input_ids}")
124
-
125
- if input_ids.numel() == 0:
126
- logger.error("Input IDs tensor is empty")
127
- return None
128
-
129
- with torch.no_grad():
130
- output = self.model(input_ids=input_ids, attention_mask=attention_mask)
131
-
132
- logger.debug(f"Output waveform shape: {output.waveform.shape}")
133
- return output.waveform
134
- except Exception as e:
135
- logger.error(f"Error in generate_audio: {str(e)}")
136
- logger.exception("Full traceback:")
137
- return None
138
-
139
- def process(self, tts_input: TTSIn) -> Iterator[TTSOut]:
140
- speculative_turns = getattr(self, "speculative_turns", None)
141
- if isinstance(tts_input, EndOfResponse):
142
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
143
- tts_input.turn_id,
144
- tts_input.turn_revision,
145
- ):
146
- return
147
- yield AUDIO_RESPONSE_DONE
148
- return
149
-
150
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
151
- tts_input.turn_id,
152
- tts_input.turn_revision,
153
- ):
154
- logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision)
155
- return
156
- if speculative_turns:
157
- speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision)
158
-
159
- gen = self.cancel_scope.generation if self.cancel_scope else None
160
- language_code = tts_input.language_code
161
- text = tts_input.text
162
-
163
- console.print(f"[green]ASSISTANT: {text}")
164
- logger.debug(f"Processing text: {text}")
165
- logger.debug(f"Language code: {language_code}")
166
-
167
- if language_code is not None and self.language != language_code:
168
- try:
169
- logger.info(f"Switching language from {self.language} to {language_code}")
170
- self.load_model(language_code)
171
- except KeyError:
172
- console.print(
173
- f"[red]Language {language_code} not supported by Facebook MMS. Using {self.language} instead."
174
- )
175
- logger.warning(f"Unsupported language: {language_code}")
176
-
177
- audio_output = self.generate_audio(text)
178
-
179
- if audio_output is None or audio_output.numel() == 0:
180
- logger.warning("No audio output generated")
181
- return
182
-
183
- audio_numpy = audio_output.cpu().numpy().squeeze()
184
- logger.debug(f"Raw audio shape: {audio_numpy.shape}, dtype: {audio_numpy.dtype}")
185
-
186
- audio_resampled = librosa.resample(audio_numpy, orig_sr=self.model.config.sampling_rate, target_sr=16000)
187
- logger.debug(f"Resampled audio shape: {audio_resampled.shape}, dtype: {audio_resampled.dtype}")
188
-
189
- audio_int16 = (audio_resampled * 32768).astype(np.int16)
190
- logger.debug(f"Final audio shape: {audio_int16.shape}, dtype: {audio_int16.dtype}")
191
-
192
- if self.stream:
193
- for i in range(0, len(audio_int16), self.chunk_size):
194
- if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen):
195
- logger.info("TTS generation cancelled (interruption)")
196
- return
197
- chunk = audio_int16[i : i + self.chunk_size]
198
- yield np.pad(chunk, (0, self.chunk_size - len(chunk)))
199
- else:
200
- for i in range(0, len(audio_int16), self.chunk_size):
201
- if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen):
202
- logger.info("TTS generation cancelled (interruption)")
203
- return
204
- yield np.pad(
205
- audio_int16[i : i + self.chunk_size],
206
- (0, self.chunk_size - len(audio_int16[i : i + self.chunk_size])),
207
- )
208
-
209
- def on_session_end(self) -> None:
210
- if self.language != self._initial_language:
211
- self.load_model(self._initial_language)
212
- logger.debug("Facebook MMS TTS session state reset")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/TTS/kokoro_handler.py DELETED
@@ -1,418 +0,0 @@
1
- """
2
- Kokoro TTS Handler
3
-
4
- Supports NVIDIA Kokoro TTS model for high-quality multilingual speech synthesis.
5
- - On Apple Silicon (MPS): Uses mlx-audio with mlx-community/Kokoro-82M-bf16
6
- - On CUDA/CPU: Uses native kokoro library with hexgrad/Kokoro-82M
7
-
8
- Model supports 8 languages with multiple voices per language.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import logging
14
- from sys import platform
15
- from threading import Event
16
- from typing import Any, Iterator, Optional
17
-
18
- import numpy as np
19
- from rich.console import Console
20
-
21
- from speech_to_speech.baseHandler import BaseHandler
22
- from speech_to_speech.pipeline.cancel_scope import CancelScope
23
- from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut
24
- from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse
25
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
26
- from speech_to_speech.utils.mlx_lock import MLXLockContext
27
-
28
- logger = logging.getLogger(__name__)
29
- console = Console()
30
-
31
- # Language code mapping from Whisper/langdetect language codes to Kokoro lang codes
32
- WHISPER_LANGUAGE_TO_KOKORO_LANG = {
33
- "en": "b", # British English
34
- "ja": "j", # Japanese
35
- "zh": "z", # Chinese
36
- "fr": "f", # French
37
- "es": "e", # Spanish
38
- "it": "i", # Italian
39
- "pt": "p", # Portuguese
40
- "hi": "h", # Hindi
41
- # Additional European languages from Parakeet - map to closest Kokoro language
42
- "de": "b", # German -> British English (no German voice available)
43
- "nl": "b", # Dutch -> British English
44
- "pl": "b", # Polish -> British English
45
- "ru": "b", # Russian -> British English
46
- "uk": "b", # Ukrainian -> British English
47
- }
48
-
49
- # Default voices for each Kokoro language code
50
- # These are native voices that sound natural for each language
51
- # Voice naming: first letter = language, second letter = gender (f=female, m=male)
52
- # Available voices per language:
53
- # a (American): af_alloy, af_aoede, af_bella, af_heart, af_jessica, af_kore, af_nicole, af_nova, af_river, af_sarah, af_sky
54
- # am_adam, am_echo, am_eric, am_fenrir, am_liam, am_michael, am_onyx, am_puck, am_santa
55
- # b (British): bf_alice, bf_emma, bf_isabella, bf_lily, bm_daniel, bm_fable, bm_george, bm_lewis
56
- # e (Spanish): ef_dora, em_alex, em_santa
57
- # f (French): ff_siwis
58
- # h (Hindi): hf_alpha, hf_beta, hm_omega, hm_psi
59
- # i (Italian): if_sara, im_nicola
60
- # j (Japanese): jf_alpha, jf_gongitsune, jf_nezumi, jf_tebukuro, jm_kumo
61
- # p (Portuguese): pf_dora, pm_alex, pm_santa
62
- # z (Chinese): zf_xiaobei, zf_xiaoni, zf_xiaoxiao, zf_xiaoyi, zm_yunjian, zm_yunxi, zm_yunxia, zm_yunyang
63
- KOKORO_LANG_DEFAULT_VOICES = {
64
- "a": "af_heart", # American English female
65
- "b": "bm_fable", # British English male
66
- "e": "ef_dora", # Spanish female
67
- "f": "ff_siwis", # French female
68
- "h": "hf_alpha", # Hindi female
69
- "i": "if_sara", # Italian female
70
- "j": "jf_alpha", # Japanese female
71
- "p": "pf_dora", # Portuguese female
72
- "z": "zf_xiaobei", # Chinese female
73
- }
74
-
75
-
76
- class KokoroTTSHandler(BaseHandler[TTSIn, TTSOut]):
77
- """
78
- Handles Text-to-Speech using Kokoro TTS model.
79
-
80
- On Apple Silicon (MPS): Uses mlx-audio with the MLX-converted model.
81
- On CUDA/CPU: Uses native kokoro library for optimal performance.
82
-
83
- Kokoro 82M is a 82M parameter multilingual TTS model
84
- supporting 8 languages with multiple voices per language.
85
- """
86
-
87
- def setup(
88
- self,
89
- should_listen: Event,
90
- model_name: Optional[str] = None,
91
- device: str = "auto",
92
- voice: str = "bm_fable",
93
- lang_code: str = "b",
94
- speed: float = 1.0,
95
- blocksize: int = 512,
96
- gen_kwargs: dict[str, Any] | None = None,
97
- cancel_scope: CancelScope | None = None,
98
- speculative_turns: SpeculativeTurnTracker | None = None,
99
- ) -> None:
100
- """
101
- Initialize the Kokoro TTS model.
102
-
103
- Args:
104
- model_name: Model identifier. Defaults are:
105
- - MPS: "mlx-community/Kokoro-82M-bf16"
106
- - CUDA/CPU: "hexgrad/Kokoro-82M"
107
- device: Device to use ("auto", "cuda", "mps", "cpu")
108
- voice: Voice identifier (e.g. "bm_fable")
109
- lang_code: Language code (e.g. "b" for British English)
110
- speed: Speech speed multiplier
111
- blocksize: Audio chunk size for streaming
112
- gen_kwargs: Unused, for pipeline compatibility
113
- """
114
- self.should_listen = should_listen
115
- self.voice = voice
116
- self.lang_code = lang_code
117
- self.speed = speed
118
- self.blocksize = blocksize
119
- self.cancel_scope = cancel_scope
120
- self.speculative_turns = speculative_turns
121
-
122
- # Determine device
123
- if device == "auto":
124
- if platform == "darwin":
125
- self.device = "mps"
126
- else:
127
- import torch
128
-
129
- self.device = "cuda" if torch.cuda.is_available() else "cpu"
130
- else:
131
- self.device = device
132
-
133
- # Set default model based on device
134
- if model_name is None:
135
- if self.device == "mps":
136
- model_name = "mlx-community/Kokoro-82M-bf16"
137
- else:
138
- model_name = "hexgrad/Kokoro-82M"
139
-
140
- self.model_name = model_name
141
-
142
- logger.info(f"Loading Kokoro model: {model_name} on {self.device}")
143
-
144
- if self.device == "mps":
145
- self._setup_mlx(model_name)
146
- else:
147
- self._setup_kokoro(model_name)
148
-
149
- self._initial_voice = self.voice
150
- self._initial_lang_code = self.lang_code
151
-
152
- self.warmup()
153
-
154
- def _setup_mlx(self, model_name: str) -> None:
155
- """Setup for Apple Silicon using mlx-audio."""
156
- try:
157
- from mlx_audio.tts.utils import load_model
158
-
159
- self.backend = "mlx"
160
- self.model = load_model(model_name)
161
- logger.info("MLX Audio Kokoro model loaded successfully")
162
-
163
- # Get or create the pipeline for our language and preload the voice
164
- # This avoids the voice being reloaded on every generate() call
165
- self._pipeline = self.model._get_pipeline(self.lang_code)
166
- self._voice_tensor = self._pipeline.load_voice(self.voice)
167
- logger.info(f"Preloaded voice: {self.voice}")
168
-
169
- # Preload voices for common languages to avoid download delays during inference
170
- self._preload_multilingual_voices()
171
- except ImportError as e:
172
- message = str(e)
173
- if "misaki" in message or "espeakng_loader" in message:
174
- raise ImportError(
175
- "Kokoro TTS on Apple Silicon requires additional mlx-audio TTS dependencies. "
176
- f"Missing dependency: {message}. "
177
- "Install with: pip install misaki espeakng-loader"
178
- ) from e
179
- raise ImportError(
180
- "mlx-audio is required for Kokoro TTS on Apple Silicon. Install with: pip install mlx-audio"
181
- ) from e
182
-
183
- def _setup_kokoro(self, model_name: str) -> None:
184
- """Setup for CUDA/CPU using native kokoro library."""
185
- try:
186
- from kokoro import KPipeline
187
-
188
- self.backend = "kokoro"
189
- self.pipeline = KPipeline(lang_code=self.lang_code)
190
- logger.info("Native Kokoro pipeline loaded successfully")
191
- except ImportError as e:
192
- raise ImportError(
193
- "kokoro is required for Kokoro TTS on CUDA/CPU. "
194
- "Install with: pip install kokoro>=0.9.2 soundfile\n"
195
- "Also ensure espeak-ng is installed: apt-get install espeak-ng (Linux) or brew install espeak-ng (macOS)"
196
- ) from e
197
-
198
- def _preload_multilingual_voices(self) -> None:
199
- """Preload voices for common languages to avoid download delays during inference."""
200
- # Only preload a few commonly used language voices to avoid excessive startup time
201
- # Users speaking other languages will experience a one-time download delay
202
- # preload_langs = ["a", "b", "e", "f", "h", "i", "j", "p", "z"] # English, Spanish, French, Hindi, Italian, Japanese, Portuguese
203
- preload_langs = ["a", "e", "f"] # English, Spanish, French
204
-
205
- for lang_code in preload_langs:
206
- voice = KOKORO_LANG_DEFAULT_VOICES.get(lang_code)
207
- if voice:
208
- try:
209
- logger.info(f"Preloading voice for language '{lang_code}': {voice}")
210
- pipeline = self.model._get_pipeline(lang_code)
211
- pipeline.load_voice(voice)
212
- except Exception as e:
213
- logger.warning(f"Failed to preload voice {voice}: {e}")
214
-
215
- def warmup(self) -> None:
216
- """Warm up the model with a dummy inference."""
217
- logger.info(f"Warming up {self.__class__.__name__}")
218
-
219
- # Run a short dummy inference to warm up the model
220
- if self.backend == "mlx":
221
- for _ in self.model.generate(
222
- text="Hello",
223
- voice=self.voice,
224
- speed=self.speed,
225
- lang_code=self.lang_code,
226
- ):
227
- pass
228
- else:
229
- for _ in self.pipeline("Hello", voice=self.voice, speed=self.speed):
230
- pass
231
-
232
- logger.info(f"{self.__class__.__name__} warmed up")
233
-
234
- def process(self, tts_input: TTSIn) -> Iterator[TTSOut]:
235
- """
236
- Process text input and generate audio output.
237
-
238
- Yields:
239
- Audio chunks as numpy int16 arrays
240
- """
241
- speculative_turns = getattr(self, "speculative_turns", None)
242
- if isinstance(tts_input, EndOfResponse):
243
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
244
- tts_input.turn_id,
245
- tts_input.turn_revision,
246
- ):
247
- return
248
- yield AUDIO_RESPONSE_DONE
249
- return
250
-
251
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
252
- tts_input.turn_id,
253
- tts_input.turn_revision,
254
- ):
255
- logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision)
256
- return
257
- if speculative_turns:
258
- speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision)
259
-
260
- runtime_config = tts_input.runtime_config
261
- response = tts_input.response
262
- language_code = tts_input.language_code
263
- text = tts_input.text
264
-
265
- voice: Optional[str] = None
266
- if response and response.audio and response.audio.output and response.audio.output.voice:
267
- voice = str(response.audio.output.voice)
268
- if not voice and runtime_config:
269
- audio_cfg = runtime_config.session.audio
270
- audio_output = audio_cfg.output if audio_cfg is not None else None
271
- voice = str(audio_output.voice) if audio_output is not None and audio_output.voice else None
272
- if voice:
273
- self.voice = voice
274
-
275
- if self.backend == "mlx":
276
- yield from self._process_mlx(text, language_code)
277
- else:
278
- yield from self._process_kokoro(text, language_code)
279
-
280
- def _process_mlx(self, llm_sentence: str, language_code: Optional[str] = None) -> Iterator[np.ndarray]:
281
- """Process using MLX backend with Apple Silicon optimizations."""
282
- from scipy.signal import resample_poly
283
-
284
- gen = self.cancel_scope.generation if self.cancel_scope else None
285
- with MLXLockContext(handler_name="KokoroTTS", timeout=10.0):
286
- if language_code is not None:
287
- new_lang_code = WHISPER_LANGUAGE_TO_KOKORO_LANG.get(language_code, self.lang_code)
288
- if new_lang_code != self.lang_code:
289
- new_voice = KOKORO_LANG_DEFAULT_VOICES.get(new_lang_code, self.voice)
290
- logger.info(
291
- f"Language change detected: {self.lang_code} -> {new_lang_code}, voice: {self.voice} -> {new_voice}"
292
- )
293
- try:
294
- new_pipeline = self.model._get_pipeline(new_lang_code)
295
- new_voice_tensor = new_pipeline.load_voice(new_voice)
296
- self.lang_code = new_lang_code
297
- self.voice = new_voice
298
- self._pipeline = new_pipeline
299
- self._voice_tensor = new_voice_tensor
300
- except Exception as e:
301
- logger.warning(
302
- f"Failed to switch language/voice: {e}. Keeping current language: {self.lang_code}"
303
- )
304
-
305
- console.print(f"[green]ASSISTANT: {llm_sentence}")
306
-
307
- # Generate audio using the preloaded pipeline directly
308
- # This avoids the voice reload that happens in model.generate()
309
- for result in self._pipeline(
310
- text=llm_sentence,
311
- voice=self.voice,
312
- speed=self.speed,
313
- ):
314
- if result.audio is None:
315
- continue
316
- # result.audio is an mx.array with shape (1, samples), convert to numpy and squeeze
317
- audio = np.array(result.audio, dtype=np.float32).squeeze(0)
318
-
319
- # Trim silence from start and end of audio
320
- # Kokoro generates ~250ms of silence at the start and variable silence at the end
321
- threshold = 0.01
322
- abs_audio = np.abs(audio)
323
- # Find first and last samples above threshold
324
- above_threshold = abs_audio > threshold
325
- if np.any(above_threshold):
326
- start_idx = int(np.argmax(above_threshold))
327
- end_idx = len(audio) - int(np.argmax(above_threshold[::-1]))
328
- # Add small padding (5ms = 120 samples at 24kHz) to avoid cutting speech
329
- padding = 120
330
- start_idx = max(0, start_idx - padding)
331
- end_idx = min(len(audio), end_idx + padding)
332
- audio = audio[start_idx:end_idx]
333
-
334
- # Kokoro outputs at 24kHz, resample to 16kHz for the pipeline
335
- # Using scipy's polyphase resampling (fast and high quality)
336
- # 16000/24000 = 2/3, so up=2, down=3
337
- audio = resample_poly(audio, up=2, down=3)
338
-
339
- # Convert to int16 format
340
- audio = (audio * 32768).astype(np.int16)
341
-
342
- # Yield audio in fixed-size chunks
343
- for i in range(0, len(audio), self.blocksize):
344
- if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen):
345
- logger.info("TTS generation cancelled (interruption)")
346
- return
347
- chunk = audio[i : i + self.blocksize]
348
- # Pad the last chunk if necessary
349
- if len(chunk) < self.blocksize:
350
- chunk = np.pad(chunk, (0, self.blocksize - len(chunk)))
351
- logger.debug(f"TTS yielding audio chunk: {len(chunk)} samples")
352
- yield chunk
353
-
354
- def _process_kokoro(self, llm_sentence: str, language_code: Optional[str] = None) -> Iterator[np.ndarray]:
355
- """Process using native kokoro library."""
356
- from scipy.signal import resample_poly
357
-
358
- gen = self.cancel_scope.generation if self.cancel_scope else None
359
- if language_code is not None:
360
- new_lang_code = WHISPER_LANGUAGE_TO_KOKORO_LANG.get(language_code, self.lang_code)
361
- if new_lang_code != self.lang_code:
362
- new_voice = KOKORO_LANG_DEFAULT_VOICES.get(new_lang_code, self.voice)
363
- logger.info(
364
- f"Language change detected: {self.lang_code} -> {new_lang_code}, voice: {self.voice} -> {new_voice}"
365
- )
366
- self.lang_code = new_lang_code
367
- self.voice = new_voice
368
- from kokoro import KPipeline
369
-
370
- self.pipeline = KPipeline(lang_code=self.lang_code)
371
-
372
- console.print(f"[green]ASSISTANT: {llm_sentence}")
373
-
374
- # Generate audio using Kokoro
375
- # The pipeline yields tuples of (graphemes, phonemes, audio)
376
- for gs, ps, audio in self.pipeline(llm_sentence, voice=self.voice, speed=self.speed):
377
- if audio is None:
378
- continue
379
-
380
- # Ensure audio is numpy array
381
- if not isinstance(audio, np.ndarray):
382
- audio = np.array(audio, dtype=np.float32)
383
- else:
384
- audio = audio.astype(np.float32)
385
-
386
- # Kokoro outputs at 24kHz, resample to 16kHz for the pipeline
387
- # Using scipy's polyphase resampling (fast and high quality)
388
- # 16000/24000 = 2/3, so up=2, down=3
389
- audio = resample_poly(audio, up=2, down=3)
390
-
391
- # Convert to int16 format
392
- audio = (audio * 32768).astype(np.int16)
393
-
394
- # Yield audio in fixed-size chunks
395
- for i in range(0, len(audio), self.blocksize):
396
- if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen):
397
- logger.info("TTS generation cancelled (interruption)")
398
- return
399
- chunk = audio[i : i + self.blocksize]
400
- # Pad the last chunk if necessary
401
- if len(chunk) < self.blocksize:
402
- chunk = np.pad(chunk, (0, self.blocksize - len(chunk)))
403
- yield chunk
404
-
405
- def on_session_end(self) -> None:
406
- self.voice = self._initial_voice
407
- self.lang_code = self._initial_lang_code
408
- if self.backend == "mlx":
409
- try:
410
- self._pipeline = self.model._get_pipeline(self.lang_code)
411
- self._voice_tensor = self._pipeline.load_voice(self.voice)
412
- except Exception as e:
413
- logger.warning(f"Failed to restore initial voice/language on session end: {e}")
414
- else:
415
- from kokoro import KPipeline
416
-
417
- self.pipeline = KPipeline(lang_code=self.lang_code)
418
- logger.debug("Kokoro TTS session state reset")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/TTS/pocket_tts_handler.py DELETED
@@ -1,227 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from threading import Event
5
- from time import perf_counter
6
- from typing import Any, Iterator
7
-
8
- import numpy as np
9
- from rich.console import Console
10
-
11
- from speech_to_speech.baseHandler import BaseHandler
12
- from speech_to_speech.pipeline.cancel_scope import CancelScope
13
- from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut
14
- from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse
15
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
16
-
17
- logger = logging.getLogger(__name__)
18
- console = Console()
19
-
20
-
21
- class PocketTTSHandler(BaseHandler[TTSIn, TTSOut]):
22
- """
23
- Handler for Pocket TTS model from Kyutai Labs.
24
- Supports streaming audio generation with voice cloning.
25
- """
26
-
27
- def setup(
28
- self,
29
- should_listen: Event,
30
- device: str = "cpu",
31
- voice: str = "alba", # Default voice from catalog
32
- sample_rate: int = 16000, # Match the pipeline's audio output (LocalAudioStreamer uses 16kHz)
33
- blocksize: int = 512,
34
- max_tokens: int = 50,
35
- gen_kwargs: dict[str, Any] | None = None, # For compatibility with pipeline, not used
36
- cancel_scope: CancelScope | None = None,
37
- speculative_turns: SpeculativeTurnTracker | None = None,
38
- ) -> None:
39
- """
40
- Initialize Pocket TTS handler.
41
-
42
- Args:
43
- should_listen: Event to control when to start listening again
44
- device: Device to run model on ('cpu', 'cuda', 'mps')
45
- voice: Voice to use. Can be:
46
- - A preset name: 'alba', 'marius', 'javert', 'jean', 'fantine', 'cosette', 'eponine', 'azelma'
47
- - A local audio file path
48
- - A Hugging Face path like "hf://kyutai/tts-voices/..."
49
- sample_rate: Output sample rate (pocket-tts generates at 24kHz and will be resampled to this rate). Default 16kHz matches the pipeline's audio output.
50
- blocksize: Size of audio blocks to yield
51
- max_tokens: Maximum tokens to generate
52
- """
53
- self.should_listen = should_listen
54
- self.cancel_scope = cancel_scope
55
- self.speculative_turns = speculative_turns
56
- self.device = device
57
- self.voice = voice
58
- self.sample_rate = sample_rate
59
- self.blocksize = blocksize
60
- self.max_tokens = max_tokens
61
-
62
- # Suppress verbose logging from pocket_tts library
63
- logging.getLogger("pocket_tts").setLevel(logging.WARNING)
64
- logging.getLogger("pocket_tts.models.tts_model").setLevel(logging.WARNING)
65
- logging.getLogger("pocket_tts.utils.utils").setLevel(logging.WARNING)
66
-
67
- # Import and load model
68
- from pocket_tts import TTSModel
69
-
70
- logger.info("Loading Pocket TTS model")
71
- self.model = TTSModel.load_model()
72
-
73
- # Move model to specified device
74
- if device == "cuda":
75
- self.model = self.model.cuda()
76
- elif device == "mps":
77
- self.model = self.model.to("mps")
78
- elif device != "cpu":
79
- self.model = self.model.to(device)
80
-
81
- logger.info(f"Pocket TTS model moved to {device}")
82
-
83
- # Load voice state
84
- logger.info(f"Loading voice: {voice}")
85
- self.voice_state = self.model.get_state_for_audio_prompt(voice)
86
-
87
- logger.info(f"Pocket TTS model sample rate: {self.model.sample_rate}")
88
-
89
- @property
90
- def min_time_to_debug(self) -> float:
91
- """
92
- Override to suppress logging for individual audio chunks.
93
- Pocket TTS yields many small chunks (~10-20ms each), which would flood logs.
94
- Only log if a chunk takes unusually long (>100ms).
95
- """
96
- return 0.1 # 100ms threshold
97
-
98
- def process(self, tts_input: TTSIn) -> Iterator[TTSOut]:
99
- speculative_turns = getattr(self, "speculative_turns", None)
100
- if isinstance(tts_input, EndOfResponse):
101
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
102
- tts_input.turn_id,
103
- tts_input.turn_revision,
104
- ):
105
- return
106
- yield AUDIO_RESPONSE_DONE
107
- return
108
-
109
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
110
- tts_input.turn_id,
111
- tts_input.turn_revision,
112
- ):
113
- logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision)
114
- return
115
- if speculative_turns:
116
- speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision)
117
-
118
- gen = self.cancel_scope.generation if self.cancel_scope else None
119
- language_code = tts_input.language_code
120
- text = tts_input.text
121
- logger.debug(f"Received language code: {language_code}")
122
-
123
- console.print(f"[green]ASSISTANT: {text}")
124
-
125
- # Generate audio stream
126
- logger.debug(f"Generating audio for: {text[:50]}...")
127
-
128
- pipeline_start = perf_counter()
129
- first_chunk = True
130
-
131
- # Calculate target chunk size in original sample rate
132
- # We need enough samples so that after resampling we get blocksize samples
133
- needs_resampling = self.model.sample_rate != self.sample_rate
134
- if needs_resampling:
135
- from scipy.signal import resample_poly
136
-
137
- # Target chunk size in original sample rate (before resampling)
138
- resample_ratio = self.model.sample_rate / self.sample_rate
139
- target_chunk_size = int(self.blocksize * resample_ratio)
140
- # Calculate up/down factors for polyphase resampling
141
- # For 24kHz -> 16kHz: 16000/24000 = 2/3
142
- from math import gcd
143
-
144
- g = gcd(self.sample_rate, self.model.sample_rate)
145
- self._resample_up = self.sample_rate // g
146
- self._resample_down = self.model.sample_rate // g
147
- else:
148
- target_chunk_size = self.blocksize
149
-
150
- # Buffer to accumulate audio until we have enough for one block
151
- audio_buffer = []
152
- buffer_size = 0
153
-
154
- for audio_chunk in self.model.generate_audio_stream(
155
- self.voice_state,
156
- text,
157
- max_tokens=self.max_tokens,
158
- copy_state=True, # Don't modify the original voice state
159
- ):
160
- if gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(gen):
161
- logger.info("TTS generation cancelled (interruption)")
162
- return
163
- if first_chunk:
164
- logger.debug(f"Time to first audio: {perf_counter() - pipeline_start:.3f}s")
165
- first_chunk = False
166
-
167
- # Convert from torch tensor to numpy and add to buffer
168
- audio_np = audio_chunk.cpu().numpy()
169
- audio_buffer.append(audio_np)
170
- buffer_size += len(audio_np)
171
-
172
- # Process buffer when we have enough samples for at least one block
173
- while buffer_size >= target_chunk_size:
174
- # Concatenate buffer
175
- concatenated = np.concatenate(audio_buffer)
176
-
177
- # Extract enough samples for one block
178
- chunk_to_process = concatenated[:target_chunk_size]
179
- remainder = concatenated[target_chunk_size:]
180
-
181
- # Resample this chunk if needed
182
- if needs_resampling:
183
- chunk_resampled = resample_poly(
184
- chunk_to_process,
185
- up=self._resample_up,
186
- down=self._resample_down,
187
- )
188
- else:
189
- chunk_resampled = chunk_to_process
190
-
191
- # Convert to int16 format expected by audio output
192
- audio_int16 = (chunk_resampled * 32768).astype(np.int16)
193
-
194
- # Ensure exact blocksize (pad or trim if resampling caused slight size differences)
195
- if len(audio_int16) < self.blocksize:
196
- audio_int16 = np.pad(audio_int16, (0, self.blocksize - len(audio_int16)))
197
- elif len(audio_int16) > self.blocksize:
198
- audio_int16 = audio_int16[: self.blocksize]
199
-
200
- yield audio_int16
201
-
202
- # Update buffer with remainder
203
- audio_buffer = [remainder] if len(remainder) > 0 else []
204
- buffer_size = len(remainder)
205
-
206
- # Process any remaining audio in buffer
207
- if audio_buffer and buffer_size > 0:
208
- concatenated = np.concatenate(audio_buffer)
209
-
210
- # Resample the remainder
211
- if needs_resampling:
212
- concatenated = resample_poly(
213
- concatenated,
214
- up=self._resample_up,
215
- down=self._resample_down,
216
- )
217
-
218
- # Convert to int16
219
- audio_int16 = (concatenated * 32768).astype(np.int16)
220
-
221
- # Yield in blocks
222
- for i in range(0, len(audio_int16), self.blocksize):
223
- chunk = audio_int16[i : i + self.blocksize]
224
- # Pad last chunk if needed
225
- if len(chunk) < self.blocksize:
226
- chunk = np.pad(chunk, (0, self.blocksize - len(chunk)))
227
- yield chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/TTS/qwen3_tts_handler.py DELETED
@@ -1,903 +0,0 @@
1
- """
2
- Qwen3 TTS Handler
3
-
4
- - On Apple Silicon: Uses mlx-audio with MLX-converted Qwen3-TTS models.
5
- - On CUDA/CPU: Uses faster-qwen3-tts for low-latency streaming.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import logging
11
- import math
12
- import re
13
- import tempfile
14
- import unicodedata
15
- from collections.abc import Callable
16
- from pathlib import Path
17
- from sys import platform
18
- from threading import Event
19
- from time import perf_counter
20
- from typing import Any, Iterator, Optional
21
-
22
- import numpy as np
23
- import torch
24
- from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
25
- from rich.console import Console
26
-
27
- from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
28
- from speech_to_speech.baseHandler import BaseHandler
29
- from speech_to_speech.pipeline.cancel_scope import CancelScope
30
- from speech_to_speech.pipeline.control import SESSION_END, is_control_message
31
- from speech_to_speech.pipeline.handler_types import TTSIn, TTSOut
32
- from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END, EndOfResponse, TTSInput
33
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
34
- from speech_to_speech.utils.mlx_lock import MLXLockContext
35
-
36
- logger = logging.getLogger(__name__)
37
- console = Console()
38
-
39
- DEFAULT_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
40
- DEFAULT_MLX_MODEL = "mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-6bit"
41
- DEFAULT_REF_TEXT = "I'm confused why some people have super short timelines, yet at the same time are bullish on scaling up reinforcement learning atop LLMs. If we're actually close to a human-like learner, then this whole approach of training on verifiable outcomes."
42
- DEFAULT_FASTER_STREAMING_CHUNK_SIZE = 8
43
- DEFAULT_MLX_STREAMING_CHUNK_SIZE = 4
44
- DEFAULT_QWEN3_TTS_MAX_NEW_TOKENS = 1536
45
- MIN_QWEN3_TTS_UTTERANCE_TOKENS = 360
46
- VALID_MLX_QUANTIZATION_SUFFIXES = ("bf16", "4bit", "6bit", "8bit")
47
- VALID_FASTER_BACKENDS = ("ggml", "torch")
48
- MLX_STREAMING_TOKENS_PER_SECOND = 12.5
49
- PIPELINE_SR = 16000
50
- ESTIMATED_QWEN3_WORDS_PER_SECOND = 2.6
51
- ESTIMATED_QWEN3_CHARS_PER_SECOND = 14.0
52
- QWEN3_TOKEN_SAFETY_MARGIN = 1.35
53
- QWEN3_BASE_PROMPT_SECONDS = 1.0
54
- QWEN3_PUNCTUATION_PAUSE_SECONDS = 0.5
55
- QWEN3_LANGUAGE_ALIASES = {
56
- "zh": "chinese",
57
- "zh-cn": "chinese",
58
- "zh-hans": "chinese",
59
- "zh-hant": "chinese",
60
- "cmn": "chinese",
61
- "en": "english",
62
- "en-us": "english",
63
- "en-gb": "english",
64
- "ja": "japanese",
65
- "jp": "japanese",
66
- "ko": "korean",
67
- "kr": "korean",
68
- "de": "german",
69
- "fr": "french",
70
- "ru": "russian",
71
- "pt": "portuguese",
72
- "pt-br": "portuguese",
73
- "pt-pt": "portuguese",
74
- "es": "spanish",
75
- "it": "italian",
76
- }
77
-
78
-
79
- class Qwen3TTSHandler(BaseHandler[TTSIn, TTSOut]):
80
- """
81
- Handles Text-to-Speech using Qwen3-TTS.
82
-
83
- Backend selection:
84
- - Apple Silicon (Darwin): mlx-audio
85
- - Other platforms: faster-qwen3-tts
86
-
87
- Supports three generation modes depending on the loaded model:
88
- - Voice cloning (ref_audio + ref_text)
89
- - Custom voice (preset speakers)
90
- - Voice design (instruct prompt)
91
- """
92
-
93
- def setup(
94
- self,
95
- should_listen: Event,
96
- model_name: str = DEFAULT_MODEL,
97
- device: str = "cuda",
98
- dtype: str | torch.dtype = "auto",
99
- attn_implementation: str = "eager",
100
- backend: str = "ggml",
101
- ref_audio: str | Path | None = None,
102
- ref_text: str = DEFAULT_REF_TEXT,
103
- language: str = "auto",
104
- speaker: Optional[str] = "Aiden",
105
- instruct: Optional[str] = None,
106
- xvec_only: bool = False,
107
- parity_mode: bool = False,
108
- non_streaming_mode: bool | None = True,
109
- mlx_quantization: Optional[str] = None,
110
- streaming_chunk_size: int | None = None,
111
- max_new_tokens: int = DEFAULT_QWEN3_TTS_MAX_NEW_TOKENS,
112
- blocksize: int = 512,
113
- gen_kwargs: dict[str, Any] | None = None,
114
- cancel_scope: CancelScope | None = None,
115
- speculative_turns: SpeculativeTurnTracker | None = None,
116
- ) -> None:
117
- self.cancel_scope = cancel_scope
118
- self.speculative_turns = speculative_turns
119
- self.should_listen = should_listen
120
- self.requested_device = device
121
- self.ref_audio = ref_audio
122
- self.ref_text = ref_text
123
- self.language = self._normalize_language(language)
124
- self.speaker = speaker
125
- self.instruct = instruct
126
- self.xvec_only = xvec_only
127
- self.parity_mode = parity_mode
128
- self.non_streaming_mode = non_streaming_mode
129
- self.faster_backend = self._normalize_faster_backend(backend)
130
- self.mlx_quantization = self._normalize_mlx_quantization(mlx_quantization)
131
- self.max_new_tokens = max_new_tokens
132
- self.blocksize = blocksize
133
- self.dtype: torch.dtype | None | str = None
134
- self.gen_kwargs = gen_kwargs or {}
135
- self._mlx_ref_audio_cache: dict[str, Any] = {}
136
- self._mlx_temp_ref_audio_files: set[str] = set()
137
-
138
- self.backend = "mlx" if platform == "darwin" else "faster_qwen3_tts"
139
- self.streaming_chunk_size = self._resolve_streaming_chunk_size(streaming_chunk_size)
140
-
141
- if self.backend == "mlx":
142
- self.device = "mps"
143
- self.model_name = self._resolve_mlx_model_name(model_name)
144
- logger.info(f"Loading Qwen3-TTS model: {self.model_name} via mlx-audio on Apple Silicon")
145
- if self.non_streaming_mode is not None:
146
- logger.debug(
147
- "qwen3_tts_non_streaming_mode=%s is ignored on Apple Silicon because "
148
- "mlx-audio does not expose non_streaming_mode yet.",
149
- self.non_streaming_mode,
150
- )
151
- model_quantization = self._model_name_quantization_suffix(self.model_name)
152
- if model_quantization and model_quantization != "bf16":
153
- logger.info(
154
- "Using MLX quantized Qwen3-TTS variant: %s",
155
- model_quantization,
156
- )
157
- self._setup_mlx(self.model_name)
158
- else:
159
- self.device = device
160
- self.model_name = model_name
161
- logger.info(
162
- "Loading Qwen3-TTS model: %s via faster-qwen3-tts (%s backend)",
163
- self.model_name,
164
- self.faster_backend,
165
- )
166
- self._setup_faster(
167
- model_name=self.model_name,
168
- dtype=dtype,
169
- attn_implementation=attn_implementation,
170
- backend=self.faster_backend,
171
- )
172
-
173
- logger.info(
174
- "Using Qwen3-TTS streaming chunk size %d (~%.0fms audio per chunk) on %s",
175
- self.streaming_chunk_size,
176
- self.streaming_chunk_size / MLX_STREAMING_TOKENS_PER_SECOND * 1000,
177
- self.backend,
178
- )
179
-
180
- self._initial_speaker = self.speaker
181
- self._initial_ref_audio = self.ref_audio
182
-
183
- self.warmup()
184
-
185
- def _setup_faster(self, model_name: str, dtype: Any, attn_implementation: str, backend: str) -> None:
186
- try:
187
- import torch
188
- except ImportError as e:
189
- raise ImportError("torch is required. Install with: pip install torch") from e
190
-
191
- if dtype == "auto":
192
- self.dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
193
- elif isinstance(dtype, str):
194
- self.dtype = getattr(torch, dtype)
195
- else:
196
- self.dtype = dtype
197
-
198
- try:
199
- from faster_qwen3_tts import FasterQwen3TTS
200
- except ImportError as e:
201
- raise ImportError(
202
- "faster-qwen3-tts is required for Qwen3 TTS on non-macOS platforms. "
203
- "Install with: pip install 'faster-qwen3-tts[ggml]'"
204
- ) from e
205
-
206
- self.model = FasterQwen3TTS.from_pretrained(
207
- model_name,
208
- device=self.device,
209
- dtype=self.dtype,
210
- attn_implementation=attn_implementation,
211
- backend=backend,
212
- )
213
- logger.info("Qwen3-TTS model loaded")
214
-
215
- def _setup_mlx(self, model_name: str) -> None:
216
- try:
217
- from mlx_audio.tts.utils import load_model
218
-
219
- self.model = load_model(model_name)
220
- except ImportError as e:
221
- message = str(e)
222
- if any(
223
- dep in message
224
- for dep in (
225
- "misaki",
226
- "spacy",
227
- "phonemizer",
228
- "espeakng_loader",
229
- )
230
- ):
231
- raise ImportError(
232
- "Qwen3-TTS on Apple Silicon requires mlx-audio and its TTS dependencies. "
233
- f"Missing dependency: {message}. "
234
- "Install with: pip install mlx-audio misaki spacy phonemizer-fork espeakng-loader"
235
- ) from e
236
- raise ImportError(
237
- "mlx-audio is required for Qwen3 TTS on Apple Silicon. Install with: pip install mlx-audio"
238
- ) from e
239
-
240
- logger.info("MLX Audio Qwen3-TTS model loaded")
241
-
242
- def _normalize_faster_backend(self, backend: Any) -> str:
243
- value = str(backend or "ggml").strip().lower()
244
- if value not in VALID_FASTER_BACKENDS:
245
- raise ValueError(
246
- f"Unsupported qwen3_tts_backend value {backend!r}. Supported values: {', '.join(VALID_FASTER_BACKENDS)}"
247
- )
248
- return value
249
-
250
- def _normalize_mlx_quantization(self, mlx_quantization: Any) -> Optional[str]:
251
- if mlx_quantization is None:
252
- return None
253
-
254
- value = str(mlx_quantization).strip().lower()
255
- if value in ("", "none", "default"):
256
- return None
257
- if value not in VALID_MLX_QUANTIZATION_SUFFIXES:
258
- raise ValueError(
259
- "Unsupported qwen3_tts_mlx_quantization value "
260
- f"{mlx_quantization!r}. Supported values: {', '.join(VALID_MLX_QUANTIZATION_SUFFIXES)}"
261
- )
262
- return value
263
-
264
- def _apply_mlx_quantization_suffix(self, model_name: str) -> str:
265
- if self.mlx_quantization is None:
266
- return model_name
267
-
268
- desired_suffix = f"-{self.mlx_quantization}"
269
- for suffix in VALID_MLX_QUANTIZATION_SUFFIXES:
270
- current_suffix = f"-{suffix}"
271
- if model_name.endswith(current_suffix):
272
- return model_name[: -len(current_suffix)] + desired_suffix
273
-
274
- return f"{model_name}{desired_suffix}"
275
-
276
- def _model_name_quantization_suffix(self, model_name: str) -> Optional[str]:
277
- if not model_name:
278
- return None
279
-
280
- for suffix in VALID_MLX_QUANTIZATION_SUFFIXES:
281
- if model_name.endswith(f"-{suffix}"):
282
- return suffix
283
-
284
- return None
285
-
286
- def _resolve_mlx_model_name(self, model_name: str) -> str:
287
- if not model_name:
288
- return self._apply_mlx_quantization_suffix(DEFAULT_MLX_MODEL)
289
- if model_name.startswith("mlx-community/"):
290
- if self.mlx_quantization is None and self._model_name_quantization_suffix(model_name) is None:
291
- return f"{model_name}-6bit"
292
- return self._apply_mlx_quantization_suffix(model_name)
293
- if model_name.startswith("Qwen/"):
294
- mapped = model_name.replace("Qwen/", "mlx-community/", 1)
295
- if self._model_name_quantization_suffix(mapped) is None:
296
- if self.mlx_quantization is None:
297
- return f"{mapped}-6bit"
298
- mapped = f"{mapped}-bf16"
299
- return self._apply_mlx_quantization_suffix(mapped)
300
- return model_name
301
-
302
- def _resolve_streaming_chunk_size(self, streaming_chunk_size: int | None) -> int:
303
- if streaming_chunk_size is not None:
304
- return max(1, int(streaming_chunk_size))
305
- if self.backend == "mlx":
306
- return DEFAULT_MLX_STREAMING_CHUNK_SIZE
307
- return DEFAULT_FASTER_STREAMING_CHUNK_SIZE
308
-
309
- def _normalize_language(self, language: str | None) -> str:
310
- if language is None:
311
- return "auto"
312
- normalized = str(language).strip().replace("_", "-").lower()
313
- if not normalized:
314
- return "auto"
315
- return QWEN3_LANGUAGE_ALIASES.get(normalized, normalized)
316
-
317
- def _infer_model_type_from_name(self) -> str:
318
- name = (self.model_name or "").lower()
319
- if "voicedesign" in name:
320
- return "voice_design"
321
- if "customvoice" in name:
322
- return "custom_voice"
323
- return "base"
324
-
325
- def _resolve_audio_path(self, audio: Any) -> Path | None:
326
- if not isinstance(audio, (str, Path)) or not audio:
327
- return None
328
-
329
- candidate = Path(audio).expanduser()
330
- repo_root = Path(__file__).resolve().parents[1]
331
- search_paths = []
332
-
333
- if candidate.is_absolute():
334
- search_paths.append(candidate)
335
- else:
336
- search_paths.append(Path.cwd() / candidate)
337
- search_paths.append(repo_root / candidate)
338
-
339
- seen = set()
340
- for path in search_paths:
341
- normalized = str(path)
342
- if normalized in seen:
343
- continue
344
- seen.add(normalized)
345
- if path.exists():
346
- return path.resolve()
347
-
348
- return None
349
-
350
- def _prepare_mlx_ref_audio(self, ref_audio: Any) -> Any:
351
- if self.backend != "mlx" or ref_audio is None:
352
- return ref_audio
353
-
354
- if not isinstance(ref_audio, (str, Path)):
355
- return ref_audio
356
-
357
- resolved_path = self._resolve_audio_path(ref_audio)
358
- if resolved_path is None:
359
- raise FileNotFoundError(
360
- "Qwen3-TTS on Apple Silicon requires qwen3_tts_ref_audio to point to "
361
- f"a readable audio file. Got: {ref_audio!r}"
362
- )
363
-
364
- cache_key = str(resolved_path)
365
- cached_path = self._mlx_ref_audio_cache.get(cache_key)
366
- if cached_path and Path(cached_path).exists():
367
- return cached_path
368
-
369
- try:
370
- import soundfile as sf
371
- from scipy.signal import resample_poly
372
-
373
- waveform, sample_rate = sf.read(str(resolved_path), always_2d=False, dtype="float32")
374
- waveform = np.asarray(waveform, dtype=np.float32)
375
- if waveform.ndim > 1:
376
- waveform = waveform.mean(axis=1)
377
- target_sample_rate = getattr(self.model, "sample_rate", 24000)
378
- if sample_rate != target_sample_rate:
379
- gcd = np.gcd(int(sample_rate), int(target_sample_rate))
380
- waveform = resample_poly(
381
- waveform,
382
- up=int(target_sample_rate) // gcd,
383
- down=int(sample_rate) // gcd,
384
- )
385
- sample_rate = target_sample_rate
386
-
387
- with tempfile.NamedTemporaryFile(
388
- prefix="qwen3_ref_",
389
- suffix=".wav",
390
- delete=False,
391
- ) as temp_file:
392
- normalized_path = temp_file.name
393
-
394
- sf.write(
395
- normalized_path,
396
- waveform,
397
- sample_rate,
398
- format="WAV",
399
- subtype="PCM_16",
400
- )
401
- except Exception as e:
402
- raise RuntimeError(f"Failed to normalize Qwen3-TTS reference audio {resolved_path}: {e}") from e
403
-
404
- self._mlx_ref_audio_cache[cache_key] = normalized_path
405
- self._mlx_temp_ref_audio_files.add(normalized_path)
406
- return normalized_path
407
-
408
- def _apply_session_voice_override(
409
- self,
410
- model_type: str,
411
- runtime_config: RuntimeConfig | None = None,
412
- response: RealtimeResponseCreateParams | None = None,
413
- ) -> None:
414
- session_voice: Optional[str] = None
415
- if response and response.audio and response.audio.output:
416
- resp_voice = response.audio.output.voice
417
- session_voice = str(resp_voice) if resp_voice else None
418
- if not session_voice and runtime_config is not None:
419
- audio = runtime_config.session.audio
420
- output = audio.output if audio is not None else None
421
- sess_voice = output.voice if output is not None else None
422
- session_voice = str(sess_voice) if sess_voice else None
423
- if not session_voice:
424
- return
425
-
426
- if model_type == "custom_voice":
427
- supported_speakers = self._supported_speakers()
428
- if supported_speakers is not None:
429
- speakers_by_lower = {speaker.lower(): speaker for speaker in supported_speakers}
430
- speaker = speakers_by_lower.get(session_voice.lower())
431
- if speaker is None:
432
- supported_text = ", ".join(sorted(supported_speakers, key=str.lower)) or "unknown"
433
- logger.warning(
434
- "Ignoring Qwen3-TTS session voice override %r because it is not a supported "
435
- "CustomVoice speaker. Supported speakers: %s",
436
- session_voice,
437
- supported_text,
438
- )
439
- return
440
- session_voice = speaker
441
-
442
- self.speaker = session_voice
443
- self.ref_audio = None
444
- return
445
-
446
- if self._resolve_audio_path(session_voice) is not None:
447
- self.ref_audio = session_voice
448
- return
449
-
450
- logger.warning(
451
- "Ignoring Qwen3-TTS session voice override because it is not an audio file path: %r",
452
- session_voice,
453
- )
454
-
455
- def warmup(self) -> None:
456
- logger.info(f"Warming up {self.__class__.__name__}")
457
-
458
- if self.backend == "faster_qwen3_tts":
459
- if self.parity_mode:
460
- logger.info("Qwen3-TTS parity mode enabled: skipping backend warmup")
461
- else:
462
- try:
463
- self.model.warmup(prefill_len=100)
464
- except Exception as e:
465
- logger.warning("Qwen3-TTS backend warmup failed: %s", e)
466
-
467
- try:
468
- for _ in self._warmup_process("Hello, this is a warmup."):
469
- pass
470
- logger.info(f"{self.__class__.__name__} warmed up")
471
- except Exception as e:
472
- logger.warning(f"Warmup generation failed: {e}")
473
-
474
- def _model_type(self) -> str:
475
- if self.backend == "mlx":
476
- config = getattr(self.model, "config", None)
477
- return getattr(config, "tts_model_type", None) or self._infer_model_type_from_name()
478
-
479
- inner = getattr(getattr(self.model, "model", None), "model", None)
480
- return getattr(inner, "tts_model_type", None) or self._infer_model_type_from_name()
481
-
482
- def _supported_speakers(self) -> list[str] | None:
483
- for candidate in (
484
- self.model,
485
- getattr(self.model, "model", None),
486
- getattr(getattr(self.model, "model", None), "model", None),
487
- ):
488
- get_speakers = getattr(candidate, "get_supported_speakers", None)
489
- if callable(get_speakers):
490
- speakers = get_speakers()
491
- if speakers is None:
492
- return None
493
- return [str(speaker) for speaker in speakers if speaker]
494
- return None
495
-
496
- def _resolve_speaker(self) -> Optional[str]:
497
- if self.speaker:
498
- return self.speaker
499
-
500
- for candidate in (
501
- self.model,
502
- getattr(getattr(self.model, "model", None), "model", None),
503
- ):
504
- get_speakers = getattr(candidate, "get_supported_speakers", None)
505
- if callable(get_speakers):
506
- speakers = list(get_speakers() or [])
507
- if speakers:
508
- return speakers[0]
509
-
510
- return None
511
-
512
- def _to_int16(self, audio: np.ndarray) -> np.ndarray:
513
- return np.clip(audio * 32768, -32768, 32767).astype(np.int16)
514
-
515
- def _estimate_max_new_tokens(self, text: Optional[str]) -> int:
516
- text = (text or "").strip()
517
- chunk_size = max(1, int(getattr(self, "streaming_chunk_size", 1)))
518
- configured_cap = max(1, int(getattr(self, "max_new_tokens", DEFAULT_QWEN3_TTS_MAX_NEW_TOKENS)))
519
-
520
- if not text:
521
- return min(configured_cap, MIN_QWEN3_TTS_UTTERANCE_TOKENS)
522
-
523
- word_count = len(re.findall(r"\w+", text, flags=re.UNICODE))
524
- char_count = len(re.sub(r"\s+", "", text))
525
- word_seconds = word_count / ESTIMATED_QWEN3_WORDS_PER_SECOND if word_count else 0.0
526
- char_seconds = char_count / ESTIMATED_QWEN3_CHARS_PER_SECOND if char_count else 0.0
527
- punctuation_count = sum(unicodedata.category(ch).startswith("P") for ch in text)
528
- punctuation_seconds = punctuation_count * QWEN3_PUNCTUATION_PAUSE_SECONDS
529
- estimated_seconds = max(word_seconds, char_seconds) + punctuation_seconds + QWEN3_BASE_PROMPT_SECONDS
530
- estimated_tokens = math.ceil(estimated_seconds * MLX_STREAMING_TOKENS_PER_SECOND * QWEN3_TOKEN_SAFETY_MARGIN)
531
- aligned_tokens = max(
532
- chunk_size,
533
- math.ceil(estimated_tokens / chunk_size) * chunk_size,
534
- )
535
- requested_tokens = max(MIN_QWEN3_TTS_UTTERANCE_TOKENS, aligned_tokens)
536
- resolved_tokens = min(configured_cap, requested_tokens)
537
-
538
- if resolved_tokens < requested_tokens:
539
- logger.warning(
540
- "Qwen3-TTS estimated %d codec tokens for a %d-character utterance, "
541
- "but max_new_tokens is capped at %d; output may still truncate.",
542
- requested_tokens,
543
- len(text),
544
- configured_cap,
545
- )
546
-
547
- logger.debug(
548
- "Qwen3-TTS using max_new_tokens=%d for utterance with %d words and %d chars",
549
- resolved_tokens,
550
- word_count,
551
- char_count,
552
- )
553
- return resolved_tokens
554
-
555
- def _warmup_process(self, llm_sentence: str) -> Iterator[bytes | np.ndarray]:
556
- model_type = self._model_type()
557
- if self.ref_audio:
558
- yield from self._process_voice_clone(llm_sentence)
559
- elif model_type == "custom_voice":
560
- yield from self._process_custom_voice(llm_sentence)
561
- elif model_type == "voice_design":
562
- yield from self._process_voice_design(llm_sentence)
563
- else:
564
- raise ValueError(
565
- "Qwen3-TTS Base model requires ref_audio for voice cloning. "
566
- "Provide qwen3_tts_ref_audio or use a CustomVoice/VoiceDesign model."
567
- )
568
-
569
- def _resample_to_pipeline_sr(self, audio: np.ndarray, sr: int) -> np.ndarray:
570
- if sr == PIPELINE_SR:
571
- return audio
572
- from scipy.signal import resample_poly
573
-
574
- gcd = np.gcd(PIPELINE_SR, sr)
575
- return resample_poly(audio, up=PIPELINE_SR // gcd, down=sr // gcd)
576
-
577
- def _prepare_audio_chunk(self, item: Any) -> tuple[np.ndarray | None, int | None]:
578
- if isinstance(item, tuple):
579
- audio_chunk, sr, _timing = item
580
- return np.asarray(audio_chunk, dtype=np.float32), sr
581
-
582
- audio = getattr(item, "audio", None)
583
- if audio is None:
584
- return None, None
585
-
586
- audio_chunk = np.asarray(audio, dtype=np.float32).squeeze()
587
- sr = getattr(item, "sample_rate", None) or PIPELINE_SR
588
- return audio_chunk, sr
589
-
590
- def _stream(self, gen: Any, label: str) -> Iterator[bytes | np.ndarray]:
591
- """Common streaming loop: log TTFA and RTF, yield int16 chunks."""
592
- cancel_gen = self.cancel_scope.generation if self.cancel_scope else None
593
- start = perf_counter()
594
- total_samples = 0
595
- first_chunk = True
596
- found_speech = False
597
- leftover = np.array([], dtype=np.int16)
598
-
599
- for item in gen:
600
- if cancel_gen is not None and self.cancel_scope is not None and self.cancel_scope.is_stale(cancel_gen):
601
- logger.info("TTS generation cancelled (interruption)")
602
- return
603
-
604
- audio_chunk, sr = self._prepare_audio_chunk(item)
605
- if audio_chunk is None or sr is None or audio_chunk.size == 0:
606
- continue
607
-
608
- if first_chunk:
609
- logger.info(f"Qwen3-TTS TTFA: {perf_counter() - start:.2f}s ({label})")
610
- first_chunk = False
611
-
612
- audio_chunk = self._resample_to_pipeline_sr(audio_chunk, sr)
613
- audio_chunk = self._to_int16(audio_chunk)
614
-
615
- # Trim the initial silent ramp-up, but keep enough preroll to avoid
616
- # shaving soft initial phonemes at the start of the utterance.
617
- if not found_speech:
618
- threshold = int(32768 * 0.01)
619
- above = np.abs(audio_chunk) > threshold
620
- if not np.any(above):
621
- continue
622
- start_idx = max(0, int(np.argmax(above)) - int(PIPELINE_SR * 0.040))
623
- audio_chunk = audio_chunk[start_idx:]
624
- found_speech = True
625
-
626
- audio_chunk = np.concatenate([leftover, audio_chunk])
627
-
628
- n = (len(audio_chunk) // self.blocksize) * self.blocksize
629
- for i in range(0, n, self.blocksize):
630
- yield audio_chunk[i : i + self.blocksize]
631
- total_samples += self.blocksize
632
- leftover = audio_chunk[n:]
633
-
634
- if len(leftover) > 0:
635
- chunk = np.pad(leftover, (0, self.blocksize - len(leftover)))
636
- yield chunk
637
- total_samples += len(leftover)
638
-
639
- generation_time = perf_counter() - start
640
- audio_duration = total_samples / PIPELINE_SR
641
- rtf = audio_duration / generation_time if generation_time > 0 else 0
642
- logger.info(
643
- f"Qwen3-TTS generated {audio_duration:.2f}s audio in {generation_time:.2f}s (RTF: {rtf:.2f}, {label})"
644
- )
645
-
646
- def _coalesce_pending_tts_input(self, current_input: TTSInput) -> tuple[str, Optional[str], bool]:
647
- """Combine already-queued text chunks before the next TTS synthesis call."""
648
- if not hasattr(self.queue_in, "mutex") or not hasattr(self.queue_in, "queue"):
649
- return current_input.text, current_input.language_code, False
650
-
651
- text = current_input.text
652
- language_code = current_input.language_code
653
-
654
- parts = [text.strip()] if text and text.strip() else []
655
- saw_end_of_response = False
656
-
657
- with self.queue_in.mutex:
658
- while self.queue_in.queue:
659
- next_item = self.queue_in.queue[0]
660
- if is_control_message(next_item, SESSION_END.kind):
661
- break
662
- if isinstance(next_item, bytes) and next_item == PIPELINE_END:
663
- break
664
- if isinstance(next_item, EndOfResponse):
665
- saw_end_of_response = True
666
- break
667
- if not isinstance(next_item, TTSInput):
668
- break
669
- if current_input.turn_id != next_item.turn_id or current_input.turn_revision != next_item.turn_revision:
670
- break
671
- if (
672
- language_code is not None
673
- and next_item.language_code is not None
674
- and next_item.language_code != language_code
675
- ):
676
- break
677
-
678
- self.queue_in.queue.popleft()
679
- if next_item.text.strip():
680
- parts.append(next_item.text.strip())
681
- if language_code is None:
682
- language_code = next_item.language_code
683
-
684
- combined_text = " ".join(parts).strip()
685
- return combined_text, language_code, saw_end_of_response
686
-
687
- def process(self, tts_input: TTSIn) -> Iterator[TTSOut]:
688
- speculative_turns = getattr(self, "speculative_turns", None)
689
- if isinstance(tts_input, EndOfResponse):
690
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
691
- tts_input.turn_id,
692
- tts_input.turn_revision,
693
- ):
694
- return
695
- yield AUDIO_RESPONSE_DONE
696
- return
697
-
698
- if speculative_turns and not speculative_turns.is_latest_after_reopen_grace(
699
- tts_input.turn_id,
700
- tts_input.turn_revision,
701
- ):
702
- logger.debug("Dropping stale TTS input for turn=%s rev=%s", tts_input.turn_id, tts_input.turn_revision)
703
- return
704
- if speculative_turns:
705
- speculative_turns.commit(tts_input.turn_id, tts_input.turn_revision)
706
-
707
- runtime_config = tts_input.runtime_config
708
- response = tts_input.response
709
-
710
- coalesced_text, _language_code, _saw_end_of_response = self._coalesce_pending_tts_input(tts_input)
711
-
712
- text = coalesced_text or "Hello."
713
-
714
- model_type = self._model_type()
715
- self._apply_session_voice_override(model_type, runtime_config, response)
716
-
717
- console.print(f"[green]ASSISTANT: {text}")
718
-
719
- try:
720
- if self.ref_audio:
721
- audio_iter = self._process_voice_clone(text)
722
- elif model_type == "custom_voice":
723
- audio_iter = self._process_custom_voice(text)
724
- elif model_type == "voice_design":
725
- audio_iter = self._process_voice_design(text)
726
- else:
727
- raise ValueError(
728
- "Qwen3-TTS Base model requires ref_audio for voice cloning. "
729
- "Provide qwen3_tts_ref_audio or use a CustomVoice/VoiceDesign model."
730
- )
731
- first_audio = True
732
- for audio_chunk in audio_iter:
733
- if first_audio:
734
- self._log_first_audio_latency(tts_input)
735
- first_audio = False
736
- yield audio_chunk
737
- except Exception as e:
738
- logger.error(f"Error during Qwen3-TTS generation: {e}", exc_info=True)
739
-
740
- def _log_first_audio_latency(self, tts_input: TTSInput) -> None:
741
- if tts_input.speech_stopped_at_s is None:
742
- return
743
- latency_s = perf_counter() - tts_input.speech_stopped_at_s
744
- if latency_s < 0:
745
- return
746
- logger.info(
747
- "Last speech detected to first speech out: %.3fs (turn=%s rev=%s)",
748
- latency_s,
749
- tts_input.turn_id,
750
- tts_input.turn_revision,
751
- )
752
-
753
- def _mlx_streaming_interval(self) -> float:
754
- return max(1, self.streaming_chunk_size) / MLX_STREAMING_TOKENS_PER_SECOND
755
-
756
- def _mlx_stream_kwargs(self, max_tokens: int) -> dict[str, Any]:
757
- return {
758
- "max_tokens": max_tokens,
759
- "verbose": False,
760
- "stream": True,
761
- "streaming_interval": self._mlx_streaming_interval(),
762
- **self.gen_kwargs,
763
- }
764
-
765
- def _stream_mlx_generation(
766
- self,
767
- generation_fn: Callable,
768
- label: str,
769
- max_tokens: int,
770
- **generation_kwargs: Any,
771
- ) -> Iterator[bytes | np.ndarray]:
772
- with MLXLockContext(handler_name="Qwen3TTS", timeout=10.0) as acquired:
773
- if not acquired:
774
- raise TimeoutError("Timed out waiting for MLX lock")
775
- yield from self._stream(
776
- generation_fn(
777
- **self._mlx_stream_kwargs(max_tokens=max_tokens),
778
- **generation_kwargs,
779
- ),
780
- label=label,
781
- )
782
-
783
- def _process_voice_clone(self, text: str) -> Iterator[bytes | np.ndarray]:
784
- utterance_max_new_tokens = self._estimate_max_new_tokens(text)
785
- if self.backend == "mlx":
786
- if self.xvec_only:
787
- logger.warning("mlx-audio Qwen3-TTS does not support xvec_only; ignoring it")
788
- if self.parity_mode:
789
- logger.info("Qwen3-TTS parity mode is CUDA-specific and is ignored on mlx-audio")
790
-
791
- yield from self._stream_mlx_generation(
792
- self.model.generate,
793
- label="voice_clone_mlx",
794
- max_tokens=utterance_max_new_tokens,
795
- text=text,
796
- ref_audio=self._prepare_mlx_ref_audio(self.ref_audio),
797
- ref_text=self.ref_text,
798
- lang_code=self.language,
799
- )
800
- return
801
-
802
- yield from self._stream(
803
- self.model.generate_voice_clone_streaming(
804
- text=text,
805
- language=self.language,
806
- ref_audio=self.ref_audio,
807
- ref_text=self.ref_text,
808
- xvec_only=self.xvec_only,
809
- chunk_size=self.streaming_chunk_size,
810
- max_new_tokens=utterance_max_new_tokens,
811
- parity_mode=self.parity_mode,
812
- non_streaming_mode=self.non_streaming_mode,
813
- ),
814
- label="voice_clone_parity" if self.parity_mode else "voice_clone",
815
- )
816
-
817
- def _process_custom_voice(self, text: str) -> Iterator[bytes | np.ndarray]:
818
- utterance_max_new_tokens = self._estimate_max_new_tokens(text)
819
- speaker = self._resolve_speaker()
820
- if not speaker:
821
- raise ValueError(
822
- "CustomVoice generation requires a speaker. "
823
- "Set qwen3_tts_speaker or use a voice-clone model with ref_audio."
824
- )
825
-
826
- if self.backend == "mlx":
827
- yield from self._stream_mlx_generation(
828
- self.model.generate_custom_voice,
829
- label="custom_voice_mlx",
830
- max_tokens=utterance_max_new_tokens,
831
- text=text,
832
- speaker=speaker,
833
- language=self.language,
834
- instruct=self.instruct,
835
- )
836
- return
837
-
838
- yield from self._stream(
839
- self.model.generate_custom_voice_streaming(
840
- text=text,
841
- speaker=speaker,
842
- language=self.language,
843
- instruct=self.instruct,
844
- chunk_size=self.streaming_chunk_size,
845
- max_new_tokens=utterance_max_new_tokens,
846
- non_streaming_mode=self.non_streaming_mode,
847
- ),
848
- label="custom_voice",
849
- )
850
-
851
- def _process_voice_design(self, text: str) -> Iterator[bytes | np.ndarray]:
852
- utterance_max_new_tokens = self._estimate_max_new_tokens(text)
853
- if self.backend == "mlx":
854
- yield from self._stream_mlx_generation(
855
- self.model.generate_voice_design,
856
- label="voice_design_mlx",
857
- max_tokens=utterance_max_new_tokens,
858
- text=text,
859
- instruct=self.instruct,
860
- language=self.language,
861
- )
862
- return
863
-
864
- yield from self._stream(
865
- self.model.generate_voice_design_streaming(
866
- text=text,
867
- instruct=self.instruct,
868
- language=self.language,
869
- chunk_size=self.streaming_chunk_size,
870
- max_new_tokens=utterance_max_new_tokens,
871
- non_streaming_mode=self.non_streaming_mode,
872
- ),
873
- label="voice_design",
874
- )
875
-
876
- def on_session_end(self) -> None:
877
- self.speaker = self._initial_speaker
878
- self.ref_audio = self._initial_ref_audio
879
- logger.debug("Qwen3-TTS session state reset")
880
-
881
- def cleanup(self) -> None:
882
- try:
883
- del self.model
884
- for path in list(getattr(self, "_mlx_temp_ref_audio_files", set())):
885
- try:
886
- Path(path).unlink(missing_ok=True)
887
- except Exception:
888
- pass
889
- if self.backend == "mlx":
890
- try:
891
- import mlx.core as mx
892
-
893
- mx.clear_cache()
894
- except Exception:
895
- pass
896
- else:
897
- import torch
898
-
899
- if torch.cuda.is_available():
900
- torch.cuda.empty_cache()
901
- logger.info("Qwen3-TTS handler cleaned up")
902
- except Exception as e:
903
- logger.warning(f"Cleanup error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/TTS/ref_audio.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:c8f3be0d4e32aa3ab4bbc859bb5c2ae176d2170853661edc65c94f232ecca232
3
- size 159092
 
 
 
 
runtime/src/speech_to_speech/VAD/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
runtime/src/speech_to_speech/VAD/vad_handler.py DELETED
@@ -1,870 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- import time
5
- from collections.abc import Iterator
6
- from dataclasses import dataclass
7
- from queue import Queue
8
- from threading import Event
9
- from typing import Any, TypeAlias
10
-
11
- import numpy as np
12
- import torch
13
-
14
- from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
15
- from speech_to_speech.baseHandler import BaseHandler
16
- from speech_to_speech.pipeline.events import SpeechStartedEvent, SpeechStoppedEvent
17
- from speech_to_speech.pipeline.handler_types import VADIn, VADOut
18
- from speech_to_speech.pipeline.messages import VADAudio
19
- from speech_to_speech.pipeline.queue_types import TextEventItem
20
- from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
21
- from speech_to_speech.utils.utils import int2float
22
- from speech_to_speech.VAD.vad_iterator import VADIterator
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
- VADInput: TypeAlias = bytes | tuple[bytes, RuntimeConfig]
27
-
28
-
29
- @dataclass
30
- class _PendingShortSegment:
31
- audio: np.ndarray
32
- active_ms: float
33
- start_ms: int
34
- end_ms: int
35
-
36
-
37
- # Fragments with less active speech than this are treated as noise and never
38
- # held for stitching, so sub-threshold bursts cannot sum past min_speech_ms
39
- # and fire a false barge-in.
40
- _SHORT_SEGMENT_MIN_FRAGMENT_MS = 100
41
-
42
-
43
- # Optional import for audio enhancement
44
- try:
45
- from df.enhance import enhance, init_df
46
-
47
- HAS_DF = True
48
- except (ImportError, ModuleNotFoundError) as e:
49
- HAS_DF = False
50
- logger.warning(f"DeepFilterNet not available for audio enhancement: {e}")
51
-
52
-
53
- class VADHandler(BaseHandler[VADIn, VADOut]):
54
- """
55
- Handles voice activity detection. When voice activity is detected, audio will be accumulated until the end of speech is detected and then passed
56
- to the following part.
57
- """
58
-
59
- def setup(
60
- self,
61
- should_listen: Event,
62
- thresh: float = 0.6,
63
- sample_rate: int = 16000,
64
- min_silence_ms: int = 64,
65
- min_speech_ms: int = 384,
66
- min_speech_continuation_ms: int = 192,
67
- max_speech_ms: float = float("inf"),
68
- speech_pad_ms: int = 30,
69
- audio_enhancement: bool = False,
70
- enable_realtime_transcription: bool = False,
71
- realtime_processing_pause: float = 0.5,
72
- text_output_queue: Queue[TextEventItem] | None = None,
73
- speculative_turns: SpeculativeTurnTracker | None = None,
74
- speculative_reopen_ms: int = 1000,
75
- unanswered_reopen_ms: int = 7000,
76
- short_segment_merge_ms: int = 0,
77
- ) -> None:
78
- self.should_listen = should_listen
79
- self.sample_rate = sample_rate
80
- self.min_silence_ms = min_silence_ms
81
- self.min_speech_ms = min_speech_ms
82
- self.min_speech_continuation_ms = self._resolve_min_speech_continuation_ms(
83
- self.min_speech_ms,
84
- min_speech_continuation_ms,
85
- )
86
- self.max_speech_ms = max_speech_ms
87
- self.enable_realtime_transcription = enable_realtime_transcription
88
- self.realtime_processing_pause = realtime_processing_pause
89
- self.text_output_queue = text_output_queue
90
- self.speculative_turns = speculative_turns
91
- self.speculative_reopen_ms = speculative_reopen_ms
92
- self.unanswered_reopen_ms = max(self.speculative_reopen_ms, unanswered_reopen_ms)
93
- self.short_segment_merge_ms = max(0, short_segment_merge_ms)
94
- self._last_turn_detection: dict | None = None
95
- self.model, _ = torch.hub.load(
96
- "snakers4/silero-vad",
97
- "silero_vad",
98
- trust_repo=True,
99
- skip_validation=True,
100
- )
101
- self.iterator = VADIterator(
102
- self.model,
103
- threshold=thresh,
104
- sampling_rate=sample_rate,
105
- min_silence_duration_ms=min_silence_ms,
106
- speech_pad_ms=speech_pad_ms,
107
- )
108
- self.audio_enhancement = audio_enhancement
109
- if audio_enhancement:
110
- if not HAS_DF:
111
- logger.error(
112
- "Audio enhancement requested but DeepFilterNet is not available. Disabling audio enhancement."
113
- )
114
- self.audio_enhancement = False
115
- else:
116
- self.enhanced_model, self.df_state, _ = init_df()
117
-
118
- # State for progressive audio release
119
- self.last_process_time: float = 0.0
120
-
121
- # Cumulative sample counter for audio_start_ms / audio_end_ms
122
- self._total_samples: int = 0
123
-
124
- # Throttled logging state (summary once per second)
125
- self._last_log_time = 0.0
126
- self._log_chunks = 0
127
- self._log_speech_starts = 0
128
- self._log_speech_ends = 0
129
- self._log_progressive_yields = 0
130
- self._speech_started_emitted = False
131
- self._turn_counter = 0
132
- self._current_turn_id: str | None = None
133
- self._current_turn_revision: int | None = None
134
- self._speculative_audio_prefix: np.ndarray | None = None
135
- self._last_final_wall_time: float | None = None
136
- self._last_final_audio_ms: int | None = None
137
- self._pending_reopen_candidate: tuple[str, int, int] | None = None
138
- self._pending_short_segment: _PendingShortSegment | None = None
139
-
140
- @property
141
- def _audio_ms(self) -> int:
142
- """Cumulative audio received so far, in milliseconds."""
143
- return int(self._total_samples / self.sample_rate * 1000)
144
-
145
- def _apply_runtime_turn_detection(self, runtime_config: RuntimeConfig | None = None) -> None:
146
- """Check RuntimeConfig for turn_detection changes and apply them."""
147
- audio = runtime_config.session.audio if runtime_config else None
148
- audio_input = audio.input if audio is not None else None
149
- if not runtime_config or not audio_input or not audio_input.turn_detection:
150
- return
151
- td_raw = audio_input.turn_detection
152
-
153
- # Convert Pydantic models (e.g. OpenAI SDK ServerVad) to dict;
154
- # plain dicts pass through unchanged.
155
- if hasattr(td_raw, "model_dump"):
156
- td = td_raw.model_dump(exclude_none=True)
157
- elif isinstance(td_raw, dict):
158
- td = td_raw
159
- else:
160
- logger.warning(f"Unexpected turn_detection type: {type(td_raw)}")
161
- return
162
-
163
- # Compare normalized snapshot (identity on td_raw vs stored dict was wrong after first apply).
164
- if td == self._last_turn_detection:
165
- return
166
-
167
- self._last_turn_detection = dict(td)
168
-
169
- if "threshold" in td:
170
- self.iterator.threshold = td["threshold"]
171
- logger.info(f"VAD threshold updated to {td['threshold']}")
172
- if "silence_duration_ms" in td:
173
- self.iterator.min_silence_samples = self.sample_rate * td["silence_duration_ms"] / 1000
174
- logger.info(f"VAD silence duration updated to {td['silence_duration_ms']}ms")
175
-
176
- def _start_new_turn(self) -> tuple[str, int]:
177
- self._cancel_pending_reopen()
178
- self._turn_counter += 1
179
- self._current_turn_id = f"turn_{self._turn_counter}"
180
- self._current_turn_revision = 0
181
- self._speculative_audio_prefix = None
182
- self._last_final_wall_time = None
183
- self._last_final_audio_ms = None
184
- if self.speculative_turns:
185
- self.speculative_turns.observe(self._current_turn_id, self._current_turn_revision)
186
- return self._current_turn_id, self._current_turn_revision
187
-
188
- def _speech_buffer_duration_ms(self) -> float:
189
- if not hasattr(self.iterator, "speech_buffer"):
190
- return 0.0
191
- buffer_samples = sum(len(t) for t in self.iterator.speech_buffer())
192
- return buffer_samples / self.sample_rate * 1000
193
-
194
- def _current_active_speech_duration_ms(self) -> float:
195
- active_speech_samples = getattr(self.iterator, "active_speech_samples", 0)
196
- return active_speech_samples / self.sample_rate * 1000
197
-
198
- def _last_utterance_active_speech_duration_ms(self) -> float:
199
- active_speech_samples = getattr(self.iterator, "last_utterance_active_speech_samples", 0)
200
- return active_speech_samples / self.sample_rate * 1000
201
-
202
- @staticmethod
203
- def _resolve_min_speech_continuation_ms(min_speech_ms: int, min_speech_continuation_ms: int) -> int:
204
- if min_speech_continuation_ms <= 0:
205
- return min_speech_ms
206
- return min(
207
- min_speech_ms,
208
- max(_SHORT_SEGMENT_MIN_FRAGMENT_MS, min_speech_continuation_ms),
209
- )
210
-
211
- def _uses_realtime_turn_handling(self) -> bool:
212
- return self.enable_realtime_transcription or self.speculative_turns is not None
213
-
214
- def _active_speech_min_ms(self, start_ms: int) -> float:
215
- """Duration hysteresis for speech that continues a reopenable turn."""
216
- if self._pending_reopen_candidate is not None or self._should_reopen_current_turn(start_ms):
217
- return self.min_speech_continuation_ms
218
- return self.min_speech_ms
219
-
220
- def _should_reopen_current_turn(self, audio_start_ms: int) -> bool:
221
- if not self._uses_realtime_turn_handling():
222
- return False
223
- if self._current_turn_id is None or self._current_turn_revision is None or self._last_final_audio_ms is None:
224
- return False
225
-
226
- is_committed = self.speculative_turns is not None and self.speculative_turns.is_committed(
227
- self._current_turn_id,
228
- self._current_turn_revision,
229
- )
230
- if is_committed:
231
- return False
232
-
233
- # Elapsed is measured on the audio clock, so the window only advances
234
- # while the client streams audio (continuous capture behaves like wall
235
- # time; push-to-talk style gaps freeze it).
236
- elapsed_ms = max(0, audio_start_ms - self._last_final_audio_ms)
237
-
238
- # Within the short grace window, any uncommitted turn may reopen.
239
- # Beyond it, an unanswered turn (no assistant output committed yet)
240
- # remains reopenable up to the unanswered_reopen_ms sanity cap, so a
241
- # user pause longer than speculative_reopen_ms does not orphan a turn
242
- # the assistant has not replied to. The cap also bounds the
243
- # empty-transcript case, where no request is queued and the turn would
244
- # otherwise never commit.
245
- reopen_limit_ms = self.speculative_reopen_ms
246
- if self.speculative_turns is not None:
247
- reopen_limit_ms = self.unanswered_reopen_ms
248
- return elapsed_ms <= reopen_limit_ms
249
-
250
- def _begin_pending_reopen_if_needed(self, audio_start_ms: int) -> None:
251
- if self._pending_reopen_candidate is not None or not self._should_reopen_current_turn(audio_start_ms):
252
- return
253
- if self.speculative_turns is None:
254
- return
255
- candidate_revision = self.speculative_turns.begin_reopen_candidate(
256
- self._current_turn_id,
257
- self._current_turn_revision,
258
- )
259
- if candidate_revision is None or self._current_turn_id is None or self._current_turn_revision is None:
260
- return
261
- self._pending_reopen_candidate = (
262
- self._current_turn_id,
263
- self._current_turn_revision,
264
- candidate_revision,
265
- )
266
- logger.info(
267
- "VAD: pending reopen candidate for speculative turn %s revision %d",
268
- self._current_turn_id,
269
- candidate_revision,
270
- )
271
-
272
- def _cancel_pending_reopen(self) -> None:
273
- if self._pending_reopen_candidate is None:
274
- return
275
- turn_id, _base_revision, candidate_revision = self._pending_reopen_candidate
276
- if self.speculative_turns:
277
- self.speculative_turns.cancel_reopen_candidate(turn_id, candidate_revision)
278
- self._pending_reopen_candidate = None
279
-
280
- def _confirm_pending_reopen(self) -> tuple[str, int, bool] | None:
281
- if self._pending_reopen_candidate is None:
282
- return None
283
- turn_id, base_revision, candidate_revision = self._pending_reopen_candidate
284
- self._pending_reopen_candidate = None
285
- if self.speculative_turns and not self.speculative_turns.confirm_reopen_candidate(
286
- turn_id,
287
- base_revision,
288
- candidate_revision,
289
- ):
290
- return None
291
- self._current_turn_id = turn_id
292
- self._current_turn_revision = candidate_revision
293
- logger.info("VAD: reopened speculative turn %s revision %d", turn_id, candidate_revision)
294
- return turn_id, candidate_revision, True
295
-
296
- def _reopen_current_turn(self) -> tuple[str, int, bool] | None:
297
- if self._current_turn_id is None or self._current_turn_revision is None:
298
- return None
299
-
300
- turn_id = self._current_turn_id
301
- base_revision = self._current_turn_revision
302
- if self.speculative_turns is not None:
303
- candidate_revision = self.speculative_turns.begin_reopen_candidate(turn_id, base_revision)
304
- if candidate_revision is None or not self.speculative_turns.confirm_reopen_candidate(
305
- turn_id,
306
- base_revision,
307
- candidate_revision,
308
- ):
309
- return None
310
- else:
311
- candidate_revision = base_revision + 1
312
-
313
- self._current_turn_id = turn_id
314
- self._current_turn_revision = candidate_revision
315
- logger.info("VAD: reopened speculative turn %s revision %d", turn_id, candidate_revision)
316
- return turn_id, candidate_revision, True
317
-
318
- def _ensure_turn_for_speech_start(self, audio_start_ms: int) -> tuple[str, int, bool]:
319
- if (
320
- self._speech_started_emitted
321
- and self._current_turn_id is not None
322
- and self._current_turn_revision is not None
323
- ):
324
- return self._current_turn_id, self._current_turn_revision, False
325
-
326
- confirmed_reopen = self._confirm_pending_reopen()
327
- if confirmed_reopen is not None:
328
- return confirmed_reopen
329
-
330
- reopened = False
331
- if self._should_reopen_current_turn(audio_start_ms):
332
- reopened_turn = self._reopen_current_turn()
333
- if reopened_turn is not None:
334
- return reopened_turn
335
-
336
- self._start_new_turn()
337
-
338
- if self._current_turn_id is None or self._current_turn_revision is None:
339
- raise RuntimeError("VAD failed to allocate turn metadata")
340
- if self.speculative_turns:
341
- self.speculative_turns.observe(self._current_turn_id, self._current_turn_revision)
342
- return self._current_turn_id, self._current_turn_revision, reopened
343
-
344
- def _current_turn_metadata(self) -> tuple[str | None, int | None]:
345
- return self._current_turn_id, self._current_turn_revision
346
-
347
- def _combined_turn_audio(self, current_segment: np.ndarray) -> np.ndarray:
348
- if self._speculative_audio_prefix is None:
349
- return current_segment
350
- return np.concatenate((self._speculative_audio_prefix, current_segment))
351
-
352
- def _short_segment_merge_window_ms(self) -> int:
353
- return int(getattr(self, "short_segment_merge_ms", 0))
354
-
355
- def _segment_duration_ms(self, segment: np.ndarray) -> float:
356
- return len(segment) / self.sample_rate * 1000
357
-
358
- def _segment_start_ms(self, segment: np.ndarray, end_ms: int) -> int:
359
- return max(0, end_ms - int(self._segment_duration_ms(segment)))
360
-
361
- def _short_segment_gap_ms(self, start_ms: int) -> float:
362
- if self._pending_short_segment is None:
363
- return float("inf")
364
- return max(0, start_ms - self._pending_short_segment.end_ms)
365
-
366
- def _can_merge_pending_short_segment(self, start_ms: int) -> bool:
367
- return (
368
- self._pending_short_segment is not None
369
- and self._short_segment_merge_window_ms() > 0
370
- and self._short_segment_gap_ms(start_ms) <= self._short_segment_merge_window_ms()
371
- )
372
-
373
- def _effective_active_speech_for_start(self, start_ms: int, active_ms: float) -> tuple[int, float]:
374
- # A live fragment below the noise floor never counts the held segment
375
- # toward the speech-start threshold, mirroring the finalization path.
376
- if active_ms < _SHORT_SEGMENT_MIN_FRAGMENT_MS:
377
- return start_ms, active_ms
378
- if not self._can_merge_pending_short_segment(start_ms):
379
- return start_ms, active_ms
380
- assert self._pending_short_segment is not None
381
- return self._pending_short_segment.start_ms, self._pending_short_segment.active_ms + active_ms
382
-
383
- def _merge_pending_short_segment(
384
- self,
385
- segment: np.ndarray,
386
- active_ms: float,
387
- end_ms: int,
388
- ) -> tuple[np.ndarray, float, int, bool]:
389
- start_ms = self._segment_start_ms(segment, end_ms)
390
- if not self._can_merge_pending_short_segment(start_ms):
391
- self._discard_expired_pending_short_segment(start_ms)
392
- return segment, active_ms, start_ms, False
393
-
394
- pending = self._pending_short_segment
395
- assert pending is not None
396
- # Reinsert the silence between the two segments so the stitched audio
397
- # keeps its acoustic gap and its length matches the audio-clock span.
398
- gap_samples = int(self._short_segment_gap_ms(start_ms) * self.sample_rate / 1000)
399
- self._pending_short_segment = None
400
- parts = [pending.audio]
401
- if gap_samples > 0:
402
- parts.append(np.zeros(gap_samples, dtype=segment.dtype))
403
- parts.append(segment)
404
- merged = np.concatenate(parts)
405
- return merged, pending.active_ms + active_ms, pending.start_ms, True
406
-
407
- def _hold_short_segment(self, segment: np.ndarray, active_ms: float, start_ms: int, end_ms: int) -> None:
408
- self._pending_short_segment = _PendingShortSegment(
409
- audio=segment,
410
- active_ms=active_ms,
411
- start_ms=start_ms,
412
- end_ms=end_ms,
413
- )
414
- logger.info(
415
- "VAD: holding short segment=%.0fms active=%.0fms (active_min=%sms, merge_max=%sms)",
416
- self._segment_duration_ms(segment),
417
- active_ms,
418
- self.min_speech_ms,
419
- self._short_segment_merge_window_ms(),
420
- )
421
-
422
- def _discard_pending_short_segment(self, reason: str = "expired") -> None:
423
- pending = self._pending_short_segment
424
- if pending is None:
425
- return
426
- self._pending_short_segment = None
427
- logger.info(
428
- "VAD: discarding held short segment=%.0fms active=%.0fms (%s, active_min=%sms)",
429
- self._segment_duration_ms(pending.audio),
430
- pending.active_ms,
431
- reason,
432
- self.min_speech_ms,
433
- )
434
-
435
- def _discard_expired_pending_short_segment(self, next_start_ms: int | None = None) -> None:
436
- pending = self._pending_short_segment
437
- if pending is None or self._short_segment_merge_window_ms() <= 0:
438
- return
439
- reference_ms = self._audio_ms if next_start_ms is None else next_start_ms
440
- gap_ms = max(0, reference_ms - pending.end_ms)
441
- if gap_ms > self._short_segment_merge_window_ms():
442
- self._discard_pending_short_segment("merge window elapsed")
443
-
444
- def before_emit_output(self, output: VADOut) -> None:
445
- if isinstance(output, VADAudio):
446
- self._drop_superseded_vad_audio(output)
447
-
448
- def _drop_superseded_vad_audio(self, latest: VADAudio) -> int:
449
- if not hasattr(self.queue_out, "mutex") or not hasattr(self.queue_out, "queue"):
450
- return 0
451
-
452
- dropped = 0
453
- with self.queue_out.mutex:
454
- kept: list[Any] = []
455
- while self.queue_out.queue:
456
- queued_item = self.queue_out.queue.popleft()
457
- if isinstance(queued_item, VADAudio) and self._vad_audio_is_superseded(
458
- queued_item,
459
- latest,
460
- ):
461
- dropped += 1
462
- else:
463
- kept.append(queued_item)
464
- self.queue_out.queue.extend(kept)
465
- if dropped:
466
- self.queue_out.not_full.notify_all()
467
-
468
- if dropped:
469
- logger.debug(
470
- "VAD: dropped %d superseded audio chunk(s) before enqueueing turn=%s rev=%s mode=%s",
471
- dropped,
472
- latest.turn_id,
473
- latest.turn_revision,
474
- latest.mode,
475
- )
476
- return dropped
477
-
478
- def _vad_audio_is_superseded(self, queued_item: VADAudio, latest: VADAudio) -> bool:
479
- if queued_item.turn_id is None or queued_item.turn_revision is None:
480
- return False
481
- if self.speculative_turns is not None and not self.speculative_turns.is_latest(
482
- queued_item.turn_id,
483
- queued_item.turn_revision,
484
- ):
485
- return True
486
- return (
487
- queued_item.mode == "progressive"
488
- and queued_item.turn_id == latest.turn_id
489
- and queued_item.turn_revision == latest.turn_revision
490
- )
491
-
492
- def process(self, audio_chunk: VADIn) -> Iterator[VADOut]:
493
- runtime_config = None
494
- if isinstance(audio_chunk, tuple):
495
- audio_chunk, runtime_config = audio_chunk
496
- self._apply_runtime_turn_detection(runtime_config)
497
-
498
- if not self.should_listen.is_set():
499
- return
500
-
501
- # Normal listening mode
502
- self._log_chunks += 1
503
- audio_int16 = np.frombuffer(audio_chunk, dtype=np.int16)
504
- self._total_samples += len(audio_int16)
505
- audio_float32 = int2float(audio_int16)
506
-
507
- vad_output = self.iterator(torch.from_numpy(audio_float32))
508
-
509
- # Deferred speech_started: only emit once active VAD speech reaches the valid speech threshold.
510
- is_triggered_now = self.iterator.triggered
511
- if is_triggered_now and not self._speech_started_emitted:
512
- segment_samples = sum(len(t) for t in self.iterator.buffer)
513
- segment_duration_ms = segment_samples / self.sample_rate * 1000
514
- active_speech_duration_ms = self._current_active_speech_duration_ms()
515
- speech_buffer_duration_ms = self._speech_buffer_duration_ms()
516
- start_ms = max(0, self._audio_ms - int(speech_buffer_duration_ms))
517
- effective_start_ms, effective_active_speech_duration_ms = self._effective_active_speech_for_start(
518
- start_ms,
519
- active_speech_duration_ms,
520
- )
521
- self._begin_pending_reopen_if_needed(effective_start_ms)
522
- active_speech_min_ms = self._active_speech_min_ms(effective_start_ms)
523
- if effective_active_speech_duration_ms >= active_speech_min_ms:
524
- turn_id, turn_revision, reopened = self._ensure_turn_for_speech_start(effective_start_ms)
525
- self._speech_started_emitted = True
526
- self._log_speech_starts += 1
527
- logger.info(
528
- "Speech started (confirmed, active=%.0fms, min=%.0fms, segment=%.0fms, turn=%s rev=%s)",
529
- effective_active_speech_duration_ms,
530
- active_speech_min_ms,
531
- segment_duration_ms,
532
- turn_id,
533
- turn_revision,
534
- )
535
- if self.text_output_queue:
536
- self.text_output_queue.put(
537
- SpeechStartedEvent(
538
- audio_start_ms=effective_start_ms,
539
- turn_id=turn_id,
540
- turn_revision=turn_revision,
541
- reopened=reopened,
542
- )
543
- )
544
- elif not is_triggered_now and vad_output is None:
545
- self._discard_expired_pending_short_segment()
546
-
547
- # Log a summary once per second instead of every chunk
548
- now = time.time()
549
- if now - self._last_log_time >= 1.0:
550
- state = "SPEAKING" if is_triggered_now else "silent"
551
- logger.debug(
552
- f"VAD: {self._log_chunks} chunks/s | {state} | "
553
- f"starts={self._log_speech_starts} ends={self._log_speech_ends} progressive={self._log_progressive_yields}"
554
- )
555
- self._log_chunks = 0
556
- self._log_speech_starts = 0
557
- self._log_speech_ends = 0
558
- self._log_progressive_yields = 0
559
- self._last_log_time = now
560
-
561
- if self._uses_realtime_turn_handling():
562
- # Realtime mode keeps turns reopenable; live transcription additionally
563
- # emits progressive audio chunks while speaking.
564
- yield from self._process_realtime(vad_output, runtime_config)
565
- else:
566
- # Original mode: yield only when speech ends
567
- yield from self._process_normal(vad_output, runtime_config)
568
-
569
- def _process_realtime(
570
- self,
571
- vad_output: list[torch.Tensor] | None,
572
- runtime_config: RuntimeConfig | None = None,
573
- ) -> Iterator[VADOut]:
574
- """Process with real-time progressive audio release."""
575
- # Check if we're currently in a speech segment.
576
- if self.enable_realtime_transcription and hasattr(self.iterator, "buffer") and len(self.iterator.buffer) > 0:
577
- current_time = time.time()
578
- duration_ms = self._speech_buffer_duration_ms()
579
- progressive_pause = self._progressive_processing_pause(duration_ms)
580
-
581
- # Yield accumulated audio periodically while speaking
582
- if (current_time - self.last_process_time) >= progressive_pause:
583
- array = torch.cat(self.iterator.speech_buffer()).cpu().numpy()
584
- duration_ms = len(array) / self.sample_rate * 1000
585
- active_speech_duration_ms = self._current_active_speech_duration_ms()
586
- start_ms = max(0, self._audio_ms - int(duration_ms))
587
-
588
- if active_speech_duration_ms >= self._active_speech_min_ms(start_ms):
589
- self._log_progressive_yields += 1
590
- logger.debug(
591
- "VAD: yielding progressive audio (segment=%.0fms, active=%.0fms, interval=%.2fs)",
592
- duration_ms,
593
- active_speech_duration_ms,
594
- progressive_pause,
595
- )
596
- turn_id, turn_revision = self._current_turn_metadata()
597
- yield VADAudio(
598
- audio=self._combined_turn_audio(array),
599
- runtime_config=runtime_config,
600
- mode="progressive",
601
- turn_id=turn_id,
602
- turn_revision=turn_revision,
603
- )
604
- self.last_process_time = current_time
605
-
606
- # Handle end of speech
607
- if vad_output is not None:
608
- if len(vad_output) == 0:
609
- logger.info("VAD: phantom trigger (empty buffer), closing speech pair")
610
- if self._speech_started_emitted and self.text_output_queue:
611
- turn_id, turn_revision = self._current_turn_metadata()
612
- self.text_output_queue.put(
613
- SpeechStoppedEvent(
614
- audio_end_ms=self._audio_ms,
615
- turn_id=turn_id,
616
- turn_revision=turn_revision,
617
- )
618
- )
619
- if not self._speech_started_emitted:
620
- self._cancel_pending_reopen()
621
- self._speech_started_emitted = False
622
- self._discard_expired_pending_short_segment()
623
- return
624
-
625
- array = torch.cat(vad_output).cpu().numpy()
626
- end_ms = self._audio_ms
627
- raw_active_ms = self._last_utterance_active_speech_duration_ms()
628
- active_speech_duration_ms = raw_active_ms
629
- stitched_short_segment = False
630
- # Fragments below the noise floor never merge with or replace a
631
- # held segment; the pending segment's own expiry handles it.
632
- if raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS:
633
- array, active_speech_duration_ms, start_ms, stitched_short_segment = self._merge_pending_short_segment(
634
- array,
635
- active_speech_duration_ms,
636
- end_ms,
637
- )
638
- else:
639
- start_ms = self._segment_start_ms(array, end_ms)
640
- duration_ms = self._segment_duration_ms(array)
641
- min_active_ms = 0.0 if self._speech_started_emitted else self._active_speech_min_ms(start_ms)
642
-
643
- if active_speech_duration_ms < min_active_ms or duration_ms > self.max_speech_ms:
644
- if (
645
- self._short_segment_merge_window_ms() > 0
646
- and raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS
647
- and active_speech_duration_ms < min_active_ms
648
- and duration_ms <= self.max_speech_ms
649
- ):
650
- self._hold_short_segment(array, active_speech_duration_ms, start_ms, end_ms)
651
- else:
652
- logger.info(
653
- "VAD: discarding segment=%.0fms active=%.0fms (active_min=%sms, segment_max=%sms)",
654
- duration_ms,
655
- active_speech_duration_ms,
656
- min_active_ms,
657
- self.max_speech_ms,
658
- )
659
- if self._speech_started_emitted and self.text_output_queue:
660
- turn_id, turn_revision = self._current_turn_metadata()
661
- self.text_output_queue.put(
662
- SpeechStoppedEvent(
663
- audio_end_ms=self._audio_ms,
664
- turn_id=turn_id,
665
- turn_revision=turn_revision,
666
- )
667
- )
668
- if not self._speech_started_emitted:
669
- self._cancel_pending_reopen()
670
- self._speech_started_emitted = False
671
- else:
672
- if stitched_short_segment:
673
- logger.info(
674
- "VAD: stitched short segment(s) into segment=%.0fms active=%.0fms",
675
- duration_ms,
676
- active_speech_duration_ms,
677
- )
678
- if not self._speech_started_emitted:
679
- turn_id, turn_revision, reopened = self._ensure_turn_for_speech_start(start_ms)
680
- if self.text_output_queue:
681
- self.text_output_queue.put(
682
- SpeechStartedEvent(
683
- audio_start_ms=start_ms,
684
- turn_id=turn_id,
685
- turn_revision=turn_revision,
686
- reopened=reopened,
687
- interrupt_response=False,
688
- )
689
- )
690
- else:
691
- turn_id, turn_revision = self._current_turn_metadata()
692
- self._log_speech_ends += 1
693
- logger.info(
694
- "Speech soft-ended (segment=%.0fms, active=%.0fms, turn=%s rev=%s)",
695
- duration_ms,
696
- active_speech_duration_ms,
697
- turn_id,
698
- turn_revision,
699
- )
700
- if self.audio_enhancement:
701
- array = self._apply_audio_enhancement(array)
702
- output_array = self._combined_turn_audio(array)
703
- combined_duration_s = len(output_array) / self.sample_rate
704
- if self.text_output_queue:
705
- self.text_output_queue.put(
706
- SpeechStoppedEvent(
707
- duration_s=combined_duration_s,
708
- audio_end_ms=end_ms,
709
- turn_id=turn_id,
710
- turn_revision=turn_revision,
711
- )
712
- )
713
- self._speculative_audio_prefix = output_array
714
- self._last_final_wall_time = time.time()
715
- self._last_final_audio_ms = end_ms
716
- if self.speculative_turns:
717
- # The grace window only delays response commits; reopen
718
- # eligibility for unanswered turns is extended separately
719
- # via unanswered_reopen_ms in _should_reopen_current_turn.
720
- self.speculative_turns.start_reopen_grace(
721
- turn_id,
722
- turn_revision,
723
- self.speculative_reopen_ms / 1000.0,
724
- )
725
- else:
726
- self.should_listen.clear()
727
- yield VADAudio(
728
- audio=output_array,
729
- runtime_config=runtime_config,
730
- mode="final",
731
- turn_id=turn_id,
732
- turn_revision=turn_revision,
733
- )
734
- self.last_process_time = 0.0
735
- self._speech_started_emitted = False
736
-
737
- def _progressive_processing_pause(self, duration_ms: float) -> float:
738
- base_pause = max(0.0, self.realtime_processing_pause)
739
- duration_s = duration_ms / 1000.0
740
- if duration_s < 8.0:
741
- multiplier = 1.0
742
- elif duration_s < 15.0:
743
- multiplier = 2.0
744
- elif duration_s < 30.0:
745
- multiplier = 4.0
746
- else:
747
- multiplier = 6.0
748
- return min(base_pause * multiplier, 2.0)
749
-
750
- def _process_normal(
751
- self,
752
- vad_output: list[torch.Tensor] | None,
753
- runtime_config: RuntimeConfig | None = None,
754
- ) -> Iterator[VADOut]:
755
- """Original processing: yield only when speech ends."""
756
- if vad_output is not None:
757
- if len(vad_output) == 0:
758
- logger.info("VAD: phantom trigger (empty buffer), closing speech pair")
759
- if self._speech_started_emitted and self.text_output_queue:
760
- self.text_output_queue.put(SpeechStoppedEvent(audio_end_ms=self._audio_ms))
761
- self._speech_started_emitted = False
762
- self._discard_expired_pending_short_segment()
763
- return
764
-
765
- array = torch.cat(vad_output).cpu().numpy()
766
- end_ms = self._audio_ms
767
- raw_active_ms = self._last_utterance_active_speech_duration_ms()
768
- active_speech_duration_ms = raw_active_ms
769
- stitched_short_segment = False
770
- # Fragments below the noise floor never merge with or replace a
771
- # held segment; the pending segment's own expiry handles it.
772
- if raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS:
773
- array, active_speech_duration_ms, start_ms, stitched_short_segment = self._merge_pending_short_segment(
774
- array,
775
- active_speech_duration_ms,
776
- end_ms,
777
- )
778
- else:
779
- start_ms = self._segment_start_ms(array, end_ms)
780
- duration_ms = self._segment_duration_ms(array)
781
- min_active_ms = 0.0 if self._speech_started_emitted else self._active_speech_min_ms(start_ms)
782
- if active_speech_duration_ms < min_active_ms or duration_ms > self.max_speech_ms:
783
- if (
784
- self._short_segment_merge_window_ms() > 0
785
- and raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS
786
- and active_speech_duration_ms < min_active_ms
787
- and duration_ms <= self.max_speech_ms
788
- ):
789
- self._hold_short_segment(array, active_speech_duration_ms, start_ms, end_ms)
790
- else:
791
- logger.info(
792
- "VAD: discarding segment=%.0fms active=%.0fms (active_min=%sms, segment_max=%sms)",
793
- duration_ms,
794
- active_speech_duration_ms,
795
- min_active_ms,
796
- self.max_speech_ms,
797
- )
798
- if self._speech_started_emitted and self.text_output_queue:
799
- self.text_output_queue.put(SpeechStoppedEvent(audio_end_ms=self._audio_ms))
800
- self._speech_started_emitted = False
801
- else:
802
- if stitched_short_segment:
803
- logger.info(
804
- "VAD: stitched short segment(s) into segment=%.0fms active=%.0fms",
805
- duration_ms,
806
- active_speech_duration_ms,
807
- )
808
- if not self._speech_started_emitted and self.text_output_queue:
809
- self.text_output_queue.put(
810
- SpeechStartedEvent(
811
- audio_start_ms=start_ms,
812
- interrupt_response=False,
813
- )
814
- )
815
- self._log_speech_ends += 1
816
- self.should_listen.clear()
817
- logger.info(f"Speech ended ({duration_ms:.0f}ms), stop listening")
818
- if self.text_output_queue:
819
- self.text_output_queue.put(SpeechStoppedEvent(duration_s=duration_ms / 1000.0, audio_end_ms=end_ms))
820
- if self.audio_enhancement:
821
- array = self._apply_audio_enhancement(array)
822
- yield VADAudio(audio=array, runtime_config=runtime_config)
823
- self._speech_started_emitted = False
824
-
825
- def _apply_audio_enhancement(self, array: np.ndarray) -> np.ndarray:
826
- """Apply audio enhancement if enabled."""
827
- import torchaudio
828
-
829
- if self.sample_rate != self.df_state.sr():
830
- audio_float32 = torchaudio.functional.resample(
831
- torch.from_numpy(array),
832
- orig_freq=self.sample_rate,
833
- new_freq=self.df_state.sr(),
834
- )
835
- enhanced = enhance(
836
- self.enhanced_model,
837
- self.df_state,
838
- audio_float32.unsqueeze(0),
839
- )
840
- enhanced = torchaudio.functional.resample(
841
- enhanced,
842
- orig_freq=self.df_state.sr(),
843
- new_freq=self.sample_rate,
844
- )
845
- else:
846
- enhanced = enhance(self.enhanced_model, self.df_state, torch.from_numpy(array))
847
- return enhanced.numpy().squeeze()
848
-
849
- def on_session_end(self):
850
- self.iterator.reset_states()
851
- self._pending_short_segment = None
852
- self.iterator.buffer = []
853
- self.last_process_time = 0.0
854
- self._total_samples = 0
855
- self._speech_started_emitted = False
856
- self._turn_counter = 0
857
- self._current_turn_id = None
858
- self._current_turn_revision = None
859
- self._speculative_audio_prefix = None
860
- self._last_final_wall_time = None
861
- self._last_final_audio_ms = None
862
- self._pending_reopen_candidate = None
863
- if self.speculative_turns:
864
- self.speculative_turns.reset()
865
- self.should_listen.set()
866
- logger.debug("VAD session state reset")
867
-
868
- @property
869
- def min_time_to_debug(self) -> float:
870
- return 0.00001
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/VAD/vad_iterator.py DELETED
@@ -1,170 +0,0 @@
1
- from collections import deque
2
-
3
- import torch
4
-
5
-
6
- class VADIterator:
7
- def __init__(
8
- self,
9
- model,
10
- threshold: float = 0.5,
11
- sampling_rate: int = 16000,
12
- min_silence_duration_ms: int = 300,
13
- speech_pad_ms: int = 30,
14
- ) -> None:
15
- """
16
- Mainly taken from https://github.com/snakers4/silero-vad
17
- Class for stream imitation
18
-
19
- Parameters
20
- ----------
21
- model: preloaded .jit/.onnx silero VAD model
22
-
23
- threshold: float (default - 0.5)
24
- Speech threshold. Silero VAD outputs speech probabilities for each audio chunk, probabilities ABOVE this value are considered as SPEECH.
25
- It is better to tune this parameter for each dataset separately, but "lazy" 0.5 is pretty good for most datasets.
26
-
27
- sampling_rate: int (default - 16000)
28
- Currently silero VAD models support 8000 and 16000 sample rates
29
-
30
- min_silence_duration_ms: int (default - 300 milliseconds)
31
- In the end of each speech chunk wait for min_silence_duration_ms before separating it
32
-
33
- speech_pad_ms: int (default - 30 milliseconds)
34
- Retain up to speech_pad_ms of audio before VAD triggers and prepend it
35
- to the detected speech chunk
36
- """
37
-
38
- self.model = model
39
- self.threshold = threshold
40
- self.sampling_rate = sampling_rate
41
- self.is_speaking = False
42
- self.buffer: list[torch.Tensor] = []
43
- self.prefix_buffer: list[torch.Tensor] = []
44
- self.active_speech_samples = 0
45
- self.last_utterance_active_speech_samples = 0
46
- self._pre_speech_buffer: deque[torch.Tensor] = deque()
47
- self._pre_speech_samples = 0
48
-
49
- if sampling_rate not in [8000, 16000]:
50
- raise ValueError("VADIterator does not support sampling rates other than [8000, 16000]")
51
-
52
- self.min_silence_samples = int(sampling_rate * min_silence_duration_ms / 1000)
53
- self.speech_pad_samples = int(sampling_rate * speech_pad_ms / 1000)
54
- self.reset_states()
55
-
56
- def reset_states(self) -> None:
57
- self.model.reset_states()
58
- self.triggered = False
59
- self.temp_end = 0
60
- self.current_sample = 0
61
- self.buffer = []
62
- self.prefix_buffer = []
63
- self.active_speech_samples = 0
64
- self.last_utterance_active_speech_samples = 0
65
- self._pre_speech_buffer.clear()
66
- self._pre_speech_samples = 0
67
-
68
- def _num_samples(self, chunk: torch.Tensor) -> int:
69
- return len(chunk[0]) if chunk.dim() == 2 else len(chunk)
70
-
71
- def _trim_pre_speech_buffer(self) -> None:
72
- while (
73
- self.speech_pad_samples > 0
74
- and self._pre_speech_buffer
75
- and self._pre_speech_samples > self.speech_pad_samples
76
- ):
77
- first = self._pre_speech_buffer[0]
78
- first_samples = self._num_samples(first)
79
- excess = self._pre_speech_samples - self.speech_pad_samples
80
-
81
- if excess >= first_samples:
82
- self._pre_speech_buffer.popleft()
83
- self._pre_speech_samples -= first_samples
84
- continue
85
-
86
- if first.dim() == 2:
87
- self._pre_speech_buffer[0] = first[:, excess:]
88
- else:
89
- self._pre_speech_buffer[0] = first[excess:]
90
- self._pre_speech_samples -= excess
91
-
92
- def _remember_pre_speech(self, chunk: torch.Tensor) -> None:
93
- if self.speech_pad_samples <= 0:
94
- self._pre_speech_buffer.clear()
95
- self._pre_speech_samples = 0
96
- return
97
-
98
- self._pre_speech_buffer.append(chunk)
99
- self._pre_speech_samples += self._num_samples(chunk)
100
- self._trim_pre_speech_buffer()
101
-
102
- def _speech_buffer(self) -> list[torch.Tensor]:
103
- if not self.prefix_buffer:
104
- return list(self.buffer)
105
- return [*self.prefix_buffer, *self.buffer]
106
-
107
- def speech_buffer(self) -> list[torch.Tensor]:
108
- return self._speech_buffer()
109
-
110
- @torch.no_grad()
111
- def __call__(self, x: torch.Tensor) -> list[torch.Tensor] | None:
112
- """
113
- x: torch.Tensor
114
- audio chunk (see examples in repo)
115
-
116
- return_seconds: bool (default - False)
117
- whether return timestamps in seconds (default - samples)
118
- """
119
-
120
- if not torch.is_tensor(x):
121
- try:
122
- x = torch.Tensor(x)
123
- except Exception:
124
- raise TypeError("Audio cannot be casted to tensor. Cast it manually")
125
-
126
- window_size_samples = len(x[0]) if x.dim() == 2 else len(x)
127
- self.current_sample += window_size_samples
128
-
129
- speech_prob = self.model(x, self.sampling_rate).item()
130
-
131
- if (speech_prob >= self.threshold) and not self.triggered:
132
- self.triggered = True
133
- self.prefix_buffer = list(self._pre_speech_buffer)
134
- self._pre_speech_buffer.clear()
135
- self._pre_speech_samples = 0
136
- self.buffer.append(x)
137
- self.active_speech_samples = window_size_samples
138
- self.last_utterance_active_speech_samples = 0
139
- return None
140
-
141
- if not self.triggered:
142
- self._remember_pre_speech(x)
143
- return None
144
-
145
- if self.triggered:
146
- self.buffer.append(x)
147
- if speech_prob >= self.threshold - 0.15:
148
- self.active_speech_samples += window_size_samples
149
- if self.temp_end:
150
- self.temp_end = 0
151
- return None
152
-
153
- if speech_prob < self.threshold - 0.15:
154
- if not self.temp_end:
155
- self.temp_end = self.current_sample
156
- if self.current_sample - self.temp_end < self.min_silence_samples:
157
- return None
158
-
159
- # End of speech: keep the final low-confidence chunks that were
160
- # observed before VAD decided the utterance was done.
161
- self.temp_end = 0
162
- self.triggered = False
163
- spoken_utterance = self.speech_buffer()
164
- self.last_utterance_active_speech_samples = self.active_speech_samples
165
- self.active_speech_samples = 0
166
- self.buffer = []
167
- self.prefix_buffer = []
168
- return spoken_utterance
169
-
170
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/__init__.py DELETED
@@ -1,3 +0,0 @@
1
- """speech-to-speech: low-latency speech-to-speech pipeline."""
2
-
3
- __version__ = "0.2.11"
 
 
 
 
runtime/src/speech_to_speech/api/__init__.py DELETED
File without changes
runtime/src/speech_to_speech/api/openai_realtime/README.md DELETED
@@ -1,244 +0,0 @@
1
- ## Realtime Engine -- High-Level Architecture
2
-
3
- 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.
4
-
5
- ```mermaid
6
- flowchart LR
7
- subgraph client [Client]
8
- WS["WebSocket /v1/realtime"]
9
- end
10
-
11
- subgraph server [Realtime Server]
12
- Router["WebSocket Router"]
13
- Service["RealtimeService"]
14
- Config["RuntimeConfig"]
15
- end
16
-
17
- subgraph pipeline [Pipeline Threads]
18
- VAD["VAD"]
19
- STT["STT"]
20
- TN["TranscriptionNotifier"]
21
- LLM["LLM"]
22
- Proc["LMOutputProcessor"]
23
- TTS["TTS"]
24
- end
25
-
26
- WS -- "client events (JSON)" --> Router
27
- Router -- "parse / dispatch" --> Service
28
- Service -- "PCM chunks" --> VAD
29
- Service -- "session config" --> Config
30
- Config -. "read by" .-> VAD & LLM & TTS
31
- VAD -- "speech segments" --> STT
32
- STT -- "transcript" --> TN
33
- TN -- "transcript + notify" --> LLM
34
- LLM -- "text + tools" --> Proc
35
- Proc -- "clean text" --> TTS
36
- TTS -- "PCM audio" --> Router
37
- Proc -- "assistant_text / tools" --> Router
38
- VAD -- "speech_started/stopped" --> Router
39
- TN -- "transcription events" --> Router
40
- Router -- "server events (JSON)" --> WS
41
- ```
42
-
43
- **Key flow:**
44
-
45
- 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.
46
- 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.
47
- 3. **Transcription**: STT output passes through `TranscriptionNotifier`, which taps the transcript for `transcription.delta` / `transcription.completed` events before forwarding to the LLM.
48
- 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`.
49
- 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.
50
- 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.
51
-
52
- ---
53
-
54
- ## Supported OpenAI Realtime Events
55
-
56
- ### Client -> Server
57
-
58
- | Event | Description |
59
- |---|---|
60
- | `input_audio_buffer.append` | Stream base64 PCM audio. Decoded, resampled to 16 kHz, and chunked for the VAD. |
61
- | `session.update` | Deep-merge session config (instructions, tools, voice, turn detection, audio format). |
62
- | `conversation.item.create` | Inject `input_text` or `function_call_output` into the LLM context without triggering generation. |
63
- | `response.create` | Trigger LLM generation. Supports per-response `instructions` and `tool_choice` overrides. |
64
- | `response.cancel` | Cancel the in-progress response and re-enable listening. |
65
-
66
- ### Server -> Client
67
-
68
- | Event | Description |
69
- |---|---|
70
- | `session.created` | Sent on connection with current session config. |
71
- | `error` | Protocol errors (`session_limit_reached`, `unknown_or_invalid_event`, `invalid_session_type`, `conversation_already_has_active_response`, etc.) |
72
- | `input_audio_buffer.speech_started` | VAD detected user speech. |
73
- | `input_audio_buffer.speech_stopped` | End of user speech segment. |
74
- | `conversation.item.created` | Acknowledges injected `input_text` from `conversation.item.create`. |
75
- | `conversation.item.input_audio_transcription.delta` | Streaming partial transcript (when live transcription is enabled). |
76
- | `conversation.item.input_audio_transcription.completed` | Final transcript for the user turn (with duration usage). |
77
- | `response.created` | Emitted on the first outbound audio chunk (response is `in_progress`). |
78
- | `response.output_audio.delta` | Base64 PCM audio chunk from TTS. |
79
- | `response.output_audio.done` | Audio stream complete for the current output item. |
80
- | `response.output_audio_transcript.done` | Full assistant text transcript for the turn. |
81
- | `response.function_call_arguments.done` | Tool call with `call_id`, `name`, and JSON `arguments`. |
82
- | `response.done` | Response finished (`completed`, `cancelled` with reason `turn_detected` or `client_cancelled`). |
83
-
84
- ---
85
-
86
- ## WebRTC Transport
87
-
88
- Alongside the WebSocket endpoint, the server supports the OpenAI GA WebRTC handshake (requires the `webrtc` extra: `pip install 'speech-to-speech[webrtc]'`):
89
-
90
- ```
91
- POST /v1/realtime/calls Content-Type: application/sdp
92
- ```
93
-
94
- The client POSTs an SDP offer and receives an SDP answer (`201`, with a `Location: /v1/realtime/calls/{call_id}` header). Audio then flows over RTP media tracks (Opus at 48 kHz, resampled to and from the 16 kHz pipeline rate with a stateful resampler), while all JSON events use the same protocol as WebSocket mode, carried on the `oai-events` data channel.
95
-
96
- A WebRTC session claims a pipeline unit from the same pool as WebSocket clients; the per-unit send loop stays the sole consumer of the pipeline output queues and hands PCM to the transport, which paces it out as 20 ms RTP frames (silence when idle).
97
-
98
- Differences from WebSocket mode:
99
-
100
- - `input_audio_buffer.append` is rejected with `invalid_event_for_transport` — audio arrives on the media track.
101
- - `output_audio_buffer.clear` is supported (WebRTC only): unplayed audio buffers server-side, so barge-in and cancellation flush it there. The server also flushes it automatically on `response.cancel` and VAD interruption.
102
- - `session.created` is sent when the data channel opens rather than on connection.
103
-
104
- ICE servers (STUN/TURN) can be configured via the `SPEECH_TO_SPEECH_ICE_SERVERS` env var, a JSON list of server entries:
105
-
106
- ```bash
107
- export SPEECH_TO_SPEECH_ICE_SERVERS='[{"urls": "stun:stun.example.com:3478"}, {"urls": "turn:turn.example.com", "username": "u", "credential": "c"}]'
108
- ```
109
-
110
- Without it, aiortc defaults apply (host candidates + Google STUN). Deployments where clients cannot reach the server directly (symmetric NAT, containers without exposed UDP) need a TURN server.
111
-
112
- ---
113
-
114
- ## Tool Calling Design
115
-
116
- Tool calling works through two distinct paths depending on the LLM backend, but both converge to the same wire protocol for the client.
117
-
118
- ### Local LLM path (`LanguageModelHandler` -- transformers / mlx-lm)
119
-
120
- 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.
121
-
122
- 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:
123
-
124
- ```
125
- <code>
126
- function_name(arg_name_1=value1, arg_name_2='string_value')
127
- </code>
128
- ```
129
-
130
- 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.
131
-
132
- ### OpenAI API path (`ResponsesApiModelHandler`)
133
-
134
- 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.
135
-
136
- ### Common output path
137
-
138
- 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:
139
- - `response.output_audio_transcript.done` for the text
140
- - `response.function_call_arguments.done` for each tool call
141
-
142
- ### Tool result flow
143
-
144
- 1. Client executes the tool and sends `conversation.item.create` with `type: "function_call_output"` and `output: "<result>"`
145
- 2. `RealtimeService` appends the tool output to the chat context and emits `conversation.item.created`; this does not trigger generation.
146
- 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.
147
- 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.
148
-
149
- ---
150
-
151
- ## Interruption Handling
152
-
153
- 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`).
154
-
155
- ### CancelScope design
156
-
157
- `CancelScope` replaces the old two-signal pattern (`cancel_response` Event + `discard_stale_output` boolean) with a single object that manages:
158
-
159
- - **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.
160
- - **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.
161
-
162
- 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).
163
-
164
- ```mermaid
165
- sequenceDiagram
166
- participant User
167
- participant VAD
168
- participant SendLoop as _send_loop
169
- participant LLM
170
- participant TTS
171
- participant Client
172
-
173
- Note over TTS,Client: Response active or pending (in_response / response_pending)
174
- User->>VAD: speaks
175
- VAD->>SendLoop: speech_started on text_output_queue
176
- SendLoop->>Client: input_audio_buffer.speech_started
177
- SendLoop->>Client: response.done (status=cancelled, reason=turn_detected)
178
- SendLoop->>SendLoop: cancel_scope.cancel() (gen++ & discarding=True)
179
- SendLoop->>SendLoop: flush output_queue + text_output_queue
180
- SendLoop->>SendLoop: response_playing.clear()
181
- LLM->>LLM: is_stale(gen) → True, aborts generation
182
- TTS->>TTS: is_stale(gen) → True, aborts generation
183
- TTS->>SendLoop: __RESPONSE_DONE__ (tagged with gen)
184
- SendLoop->>SendLoop: cancel_scope.response_done(gen) (discarding=False)
185
- Note over VAD,Client: Pipeline is now processing the new user utterance
186
- ```
187
-
188
- **Step by step:**
189
-
190
- 1. **VAD detects speech**: puts a `SpeechStartedEvent` on `text_output_queue`.
191
- 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"`.
192
- 3. **Cancel + queue flush**: if a response is active (`in_response`) *or* pending (`response_pending` -- a model request is queued, but no output has started), 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`, direct-audio completions, partial/completed transcriptions, token usage), then clears `response_playing`.
193
- 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.
194
- 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.
195
- 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()`).
196
- 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`.
197
- 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.
198
-
199
- ---
200
-
201
- ## Testing
202
-
203
- ### Local LLM with Transformers
204
-
205
- ```bash
206
- uv run speech-to-speech \
207
- --mode realtime \
208
- --stt parakeet-tdt \
209
- --llm_backend transformers \
210
- --tts kokoro \
211
- --model_name "Qwen/Qwen3-4B-Instruct-2507" \
212
- --llm_device mps \
213
- --llm_torch_dtype float16 \
214
- --enable_live_transcription
215
- ```
216
-
217
- ### Local LLM with MLX-LM
218
-
219
- ```bash
220
- uv run speech-to-speech \
221
- --mode realtime \
222
- --stt parakeet-tdt \
223
- --llm_backend mlx-lm \
224
- --tts kokoro \
225
- --model_name "mlx-community/Qwen3-4B-Instruct-2507-bf16" \
226
- --llm_device mps \
227
- --llm_torch_dtype float16 \
228
- --enable_live_transcription
229
- ```
230
-
231
- ### Remote LLM with OpenAI-compatible API
232
-
233
- ```bash
234
- uv run speech-to-speech \
235
- --mode realtime \
236
- --stt parakeet-tdt \
237
- --llm_backend responses-api \
238
- --tts kokoro \
239
- --model_name "openai/gpt-oss-20b:groq" \
240
- --responses_api_base_url "https://router.huggingface.co/v1" \
241
- --responses_api_api_key "$HF_TOKEN" \
242
- --responses_api_stream \
243
- --enable_live_transcription
244
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/api/openai_realtime/__init__.py DELETED
File without changes
runtime/src/speech_to_speech/api/openai_realtime/handlers/__init__.py DELETED
@@ -1,11 +0,0 @@
1
- from speech_to_speech.api.openai_realtime.handlers.audio import AudioHandler
2
- from speech_to_speech.api.openai_realtime.handlers.conversation import ConversationHandler
3
- from speech_to_speech.api.openai_realtime.handlers.response import ResponseHandler
4
- from speech_to_speech.api.openai_realtime.handlers.session import SessionHandler
5
-
6
- __all__ = [
7
- "AudioHandler",
8
- "ConversationHandler",
9
- "ResponseHandler",
10
- "SessionHandler",
11
- ]
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/api/openai_realtime/handlers/audio.py DELETED
@@ -1,224 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import logging
5
- from typing import TYPE_CHECKING
6
-
7
- from openai.types.realtime import (
8
- InputAudioBufferAppendEvent,
9
- InputAudioBufferSpeechStartedEvent,
10
- InputAudioBufferSpeechStoppedEvent,
11
- RealtimeErrorEvent,
12
- ResponseAudioDeltaEvent,
13
- ResponseCreatedEvent,
14
- )
15
-
16
- from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler
17
- from speech_to_speech.api.openai_realtime.utils import resample
18
- from speech_to_speech.pipeline.events import SpeechStartedEvent, SpeechStoppedEvent
19
-
20
- if TYPE_CHECKING:
21
- from speech_to_speech.api.openai_realtime.service import ServerEvent
22
-
23
- logger = logging.getLogger(__name__)
24
-
25
- PIPELINE_SAMPLE_RATE = 16000
26
- CHUNK_SAMPLES = 512
27
- BYTES_PER_SAMPLE = 2
28
- CHUNK_SIZE_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE
29
-
30
-
31
- class AudioHandler(RealtimeBaseHandler):
32
- """Owns inbound audio decoding/chunking and outbound audio encoding."""
33
-
34
- def _start_input_item(self, conn_id: str, *, preserve_active_response: bool = False) -> str:
35
- response = self._service.response
36
- st = self._state(conn_id)
37
- if not preserve_active_response:
38
- item_id = response._start_item(conn_id)
39
- else:
40
- response_item_id = st.current_item_id
41
- response_content_index = st.content_index
42
- item_id = response._start_item(conn_id)
43
- st.current_item_id = response_item_id
44
- st.content_index = response_content_index
45
- st.input_content_index = 0
46
- return item_id
47
-
48
- def handle_audio_append(self, conn_id: str, event: InputAudioBufferAppendEvent) -> list[bytes]:
49
- """Decode base64 audio, resample to pipeline rate, and split into 512-sample PCM16 chunks for the VAD."""
50
- try:
51
- pcm_bytes = base64.b64decode(event.audio)
52
- except Exception as e:
53
- logger.error(f"Base64 decode error: {e}")
54
- return []
55
-
56
- st = self._state(conn_id)
57
-
58
- audio_cfg = st.runtime_config.session.audio
59
- if audio_cfg is not None and audio_cfg.input is not None:
60
- client_in_rate = getattr(audio_cfg.input.format, "rate", None) or PIPELINE_SAMPLE_RATE
61
- else:
62
- client_in_rate = PIPELINE_SAMPLE_RATE
63
- return self.append_pcm(conn_id, pcm_bytes, client_in_rate)
64
-
65
- def append_pcm(self, conn_id: str, pcm_bytes: bytes, src_rate: int) -> list[bytes]:
66
- """Resample raw PCM16 to the pipeline rate and split into 512-sample chunks for the VAD.
67
-
68
- Shared by both transports: the WebSocket route feeds it decoded
69
- ``input_audio_buffer.append`` payloads, the WebRTC transport feeds it
70
- PCM decoded from inbound media-track frames. Keeps the sub-chunk
71
- remainder and the commit bookkeeping (``audio_buffer_has_data``) in
72
- one place regardless of how audio arrives.
73
- """
74
- st = self._state(conn_id)
75
- pcm_bytes = resample(pcm_bytes, src_rate, PIPELINE_SAMPLE_RATE)
76
-
77
- pcm_bytes = st.audio_remainder + pcm_bytes
78
-
79
- chunks = []
80
- for i in range(0, len(pcm_bytes), CHUNK_SIZE_BYTES):
81
- chunk = pcm_bytes[i : i + CHUNK_SIZE_BYTES]
82
- if len(chunk) == CHUNK_SIZE_BYTES:
83
- chunks.append(chunk)
84
- else:
85
- st.audio_remainder = chunk
86
- break
87
- else:
88
- st.audio_remainder = b""
89
-
90
- if chunks:
91
- st.audio_buffer_has_data = True
92
- return chunks
93
-
94
- def handle_audio_commit(self, conn_id: str) -> RealtimeErrorEvent | None:
95
- """Commit the audio buffer. Returns an error if no audio was appended."""
96
- st = self._state(conn_id)
97
- if not st.audio_buffer_has_data:
98
- return self.make_error(
99
- message="Input audio buffer is empty, nothing to commit.",
100
- _type="input_audio_buffer_commit_empty",
101
- )
102
- st.audio_buffer_has_data = False
103
- logger.debug("Audio buffer committed")
104
- return None
105
-
106
- # ── Pipeline event handlers ────────────────────
107
-
108
- def on_speech_started(self, conn_id: str, event: SpeechStartedEvent) -> list[ServerEvent]:
109
- """Handle VAD speech_started: cancel active response if interrupts enabled, start new input item."""
110
- response = self._service.response
111
- events: list[ServerEvent] = []
112
- st = self._state(conn_id)
113
- if st.in_response and event.interrupt_response and st.runtime_config.interrupt_response_enabled:
114
- events.extend(response.finish_response(conn_id, status="cancelled", reason="turn_detected"))
115
- is_reopen = bool(event.reopened and event.turn_id is not None and event.turn_id == st.speculative_turn_id)
116
- preserve_active_response = st.in_response
117
- if is_reopen:
118
- input_item_id = st.speculative_input_item_id
119
- if input_item_id is None:
120
- input_item_id = self._start_input_item(
121
- conn_id,
122
- preserve_active_response=preserve_active_response,
123
- )
124
- st.speculative_input_item_id = input_item_id
125
- elif not preserve_active_response:
126
- st.current_item_id = input_item_id
127
- st.content_index = 0
128
- st.input_audio_duration_s = 0.0
129
- st.input_content_index = 0
130
- else:
131
- input_item_id = self._start_input_item(
132
- conn_id,
133
- preserve_active_response=preserve_active_response,
134
- )
135
- st.speculative_input_item_id = input_item_id
136
- st.response_usage.turns += 1
137
- st.speculative_turn_id = event.turn_id
138
- st.speculative_turn_revision = event.turn_revision
139
- st.last_item_id = input_item_id
140
- events.append(
141
- InputAudioBufferSpeechStartedEvent(
142
- type="input_audio_buffer.speech_started",
143
- event_id=self._next_event_id(),
144
- audio_start_ms=event.audio_start_ms,
145
- item_id=input_item_id,
146
- )
147
- )
148
- return events
149
-
150
- def on_speech_stopped(self, conn_id: str, event: SpeechStoppedEvent) -> list[ServerEvent]:
151
- """Handle VAD speech_stopped: record duration and emit stopped event."""
152
- if event.duration_s:
153
- self._state(conn_id).input_audio_duration_s = event.duration_s
154
- return [
155
- InputAudioBufferSpeechStoppedEvent(
156
- type="input_audio_buffer.speech_stopped",
157
- event_id=self._next_event_id(),
158
- audio_end_ms=event.audio_end_ms,
159
- item_id=self._input_item_id(conn_id),
160
- )
161
- ]
162
-
163
- # ── Outbound audio encoding ──────────────────
164
-
165
- def begin_audio_response(self, conn_id: str) -> tuple[str, str, list[ServerEvent]]:
166
- """Ensure a response exists for outbound audio, emitting ResponseCreated once.
167
-
168
- When ``handle_response_create`` already allocated the response,
169
- ``current_response_id`` is set and no duplicate event is emitted.
170
- For the implicit-response path (VAD -> STT -> LLM -> TTS, no
171
- ``response.create``), ``current_response_id`` is still ``None``
172
- and the event is emitted here on the first audio chunk.
173
-
174
- Returns ``(response_id, item_id, events)``. Shared by both
175
- transports: the WebSocket path appends the base64 audio delta to the
176
- returned events, the WebRTC path sends only the bookkeeping events
177
- over the data channel while audio travels on the media track.
178
- """
179
- response = self._service.response
180
- st = self._state(conn_id)
181
-
182
- events: list[ServerEvent] = []
183
- need_created = st.current_response_id is None
184
- resp_id, item_id = response._ensure_response(conn_id)
185
- if need_created:
186
- events.append(
187
- ResponseCreatedEvent(
188
- type="response.created",
189
- event_id=self._next_event_id(),
190
- response=response._build_response(conn_id, "in_progress"),
191
- )
192
- )
193
- return resp_id, item_id, events
194
-
195
- def encode_audio_chunk(self, conn_id: str, audio: bytes) -> list[ServerEvent]:
196
- """Encode a raw PCM audio chunk as a base64 delta event for the WebSocket transport."""
197
- response = self._service.response
198
- st = self._state(conn_id)
199
-
200
- resp_id, item_id, events = self.begin_audio_response(conn_id)
201
- rp = st.current_response_params
202
- client_out_rate = None
203
- if rp and rp.audio and rp.audio.output and rp.audio.output.format:
204
- client_out_rate = getattr(rp.audio.output.format, "rate", None)
205
- if client_out_rate is None:
206
- audio_cfg = st.runtime_config.session.audio
207
- if audio_cfg is not None and audio_cfg.output is not None:
208
- client_out_rate = getattr(audio_cfg.output.format, "rate", None) or PIPELINE_SAMPLE_RATE
209
- else:
210
- client_out_rate = PIPELINE_SAMPLE_RATE
211
- audio = resample(audio, PIPELINE_SAMPLE_RATE, client_out_rate)
212
- b64 = base64.b64encode(audio).decode("ascii")
213
- events.append(
214
- ResponseAudioDeltaEvent(
215
- type="response.output_audio.delta",
216
- event_id=self._next_event_id(),
217
- content_index=response._next_content_index(conn_id),
218
- delta=b64,
219
- item_id=item_id,
220
- output_index=0,
221
- response_id=resp_id,
222
- )
223
- )
224
- return events
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/api/openai_realtime/handlers/base.py DELETED
@@ -1,57 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from queue import Queue
5
- from threading import Event as ThreadingEvent
6
- from typing import TYPE_CHECKING
7
-
8
- from openai.types.realtime import RealtimeErrorEvent
9
-
10
- from speech_to_speech.utils.utils import _generate_id
11
-
12
- if TYPE_CHECKING:
13
- from speech_to_speech.api.openai_realtime.service import ConnState, RealtimeService
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- class RealtimeBaseHandler:
19
- """Shared base for domain handlers.
20
-
21
- Provides conn_id-keyed accessors for per-connection state, config,
22
- and queues. Each connection owns its own ``RuntimeConfig`` via
23
- ``ConnState``.
24
- """
25
-
26
- def __init__(self, service: RealtimeService) -> None:
27
- self._service = service
28
-
29
- # ── conn_id-keyed accessors ──
30
-
31
- def _queue(self, conn_id: str) -> Queue | None:
32
- return self._service.text_prompt_queue
33
-
34
- def _should_listen(self, conn_id: str) -> ThreadingEvent | None:
35
- return self._service.should_listen
36
-
37
- # ── shared helpers ────────────────────────────────
38
-
39
- def _state(self, conn_id: str) -> ConnState:
40
- return self._service._state(conn_id)
41
-
42
- def _input_item_id(self, conn_id: str) -> str:
43
- st = self._state(conn_id)
44
- return st.speculative_input_item_id or self._service.response._current_item_id(conn_id)
45
-
46
- def _next_input_content_index(self, conn_id: str) -> int:
47
- st = self._state(conn_id)
48
- idx = st.input_content_index
49
- st.input_content_index += 1
50
- return idx
51
-
52
- @staticmethod
53
- def _next_event_id() -> str:
54
- return _generate_id("event")
55
-
56
- def make_error(self, message: str, _type: str) -> RealtimeErrorEvent:
57
- return self._service.make_error(message, _type)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/api/openai_realtime/handlers/conversation.py DELETED
@@ -1,128 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from typing import TYPE_CHECKING
5
-
6
- from openai.types.realtime import (
7
- ConversationItem,
8
- ConversationItemCreatedEvent,
9
- ConversationItemCreateEvent,
10
- ConversationItemInputAudioTranscriptionCompletedEvent,
11
- ConversationItemInputAudioTranscriptionDeltaEvent,
12
- )
13
- from openai.types.realtime.conversation_item_input_audio_transcription_completed_event import (
14
- UsageTranscriptTextUsageDuration,
15
- )
16
-
17
- from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler
18
- from speech_to_speech.LLM.chat import ChatItemError, add_supported_item
19
- from speech_to_speech.pipeline.events import PartialTranscriptionEvent, TranscriptionCompletedEvent
20
-
21
- if TYPE_CHECKING:
22
- from speech_to_speech.api.openai_realtime.service import ServerEvent
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
-
27
- class ConversationHandler(RealtimeBaseHandler):
28
- """Owns conversation item injection and pipeline-to-protocol translation."""
29
-
30
- def handle_conversation_item_create(
31
- self,
32
- conn_id: str,
33
- event: ConversationItemCreateEvent,
34
- ) -> list[ServerEvent]:
35
- """Inject a text message or function-call output into the LLM context.
36
-
37
- Items are added to the LLM chat context but do NOT trigger response
38
- generation on their own. A subsequent ``response.create`` event is
39
- required to trigger the model.
40
-
41
- While a response is generating, the item is *deferred*: applying it now
42
- would race the LLM handler's end-of-turn chat write-back, which runs on
43
- the pipeline thread (e.g. a ``function_call_output`` arriving before its
44
- ``function_call`` is recorded, or an image stripped before the next
45
- turn reads it). Deferred items are flushed, in order, once the response
46
- completes — see :meth:`flush_deferred_items`.
47
- """
48
- st = self._state(conn_id)
49
- if st.in_response:
50
- st.deferred_items.append(event.item)
51
- logger.debug("Deferred conversation item until the active response completes")
52
- return []
53
- return self._apply_item(conn_id, event.item)
54
-
55
- def _apply_item(self, conn_id: str, item: ConversationItem) -> list[ServerEvent]:
56
- """Add one item to the chat and build its ``conversation.item.created``."""
57
- try:
58
- self._append_item(conn_id, item)
59
- except ChatItemError as exc:
60
- return [self.make_error(str(exc), "invalid_conversation_item")]
61
-
62
- if not item:
63
- return []
64
- st = self._state(conn_id)
65
- event = ConversationItemCreatedEvent(
66
- type="conversation.item.created",
67
- event_id=self._next_event_id(),
68
- previous_item_id=st.last_item_id,
69
- item=item,
70
- )
71
- st.last_item_id = item.id
72
- return [event]
73
-
74
- def flush_deferred_items(self, conn_id: str) -> list[ServerEvent]:
75
- """Apply items buffered during a response, in arrival order.
76
-
77
- Called at response completion (after the generation's own write-back),
78
- so a ``function_call_output`` pairs with its now-recorded ``function_call``
79
- and an image survives the just-finished response's ``strip_images``.
80
- """
81
- st = self._state(conn_id)
82
- if not st.deferred_items:
83
- return []
84
- items = st.deferred_items
85
- st.deferred_items = []
86
- events: list[ServerEvent] = []
87
- for item in items:
88
- events.extend(self._apply_item(conn_id, item))
89
- return events
90
-
91
- def _append_item(self, conn_id: str, item: ConversationItem) -> None:
92
- """Narrow ``ConversationItem`` to ``SupportedItem`` and delegate to ``Chat.add_item``.
93
-
94
- Raises :class:`ChatItemError` on validation failure or unsupported type.
95
- """
96
- add_supported_item(self._state(conn_id).runtime_config.chat, item)
97
-
98
- # ── Pipeline event handlers ────────────────────
99
-
100
- def on_partial_transcription(self, conn_id: str, event: PartialTranscriptionEvent) -> list[ServerEvent]:
101
- """Handle partial_transcription: emit transcription delta event."""
102
- return [
103
- ConversationItemInputAudioTranscriptionDeltaEvent(
104
- type="conversation.item.input_audio_transcription.delta",
105
- event_id=self._next_event_id(),
106
- content_index=self._next_input_content_index(conn_id),
107
- item_id=self._input_item_id(conn_id),
108
- delta=event.delta,
109
- )
110
- ]
111
-
112
- def on_transcription_completed(self, conn_id: str, event: TranscriptionCompletedEvent) -> list[ServerEvent]:
113
- """Handle transcription_completed: accumulate duration and emit completed event."""
114
- st = self._state(conn_id)
115
- st.response_usage.audio_duration_s += st.input_audio_duration_s
116
- return [
117
- ConversationItemInputAudioTranscriptionCompletedEvent(
118
- type="conversation.item.input_audio_transcription.completed",
119
- event_id=self._next_event_id(),
120
- content_index=0,
121
- item_id=self._input_item_id(conn_id),
122
- transcript=event.transcript,
123
- usage=UsageTranscriptTextUsageDuration(
124
- seconds=st.input_audio_duration_s,
125
- type="duration",
126
- ),
127
- )
128
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/api/openai_realtime/handlers/response.py DELETED
@@ -1,339 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from typing import TYPE_CHECKING, Optional
5
-
6
- from openai.types.realtime import (
7
- RealtimeResponse,
8
- ResponseAudioDoneEvent,
9
- ResponseAudioTranscriptDoneEvent,
10
- ResponseCreatedEvent,
11
- ResponseCreateEvent,
12
- ResponseDoneEvent,
13
- ResponseFunctionCallArgumentsDoneEvent,
14
- ResponseTextDeltaEvent,
15
- ResponseTextDoneEvent,
16
- )
17
- from openai.types.realtime.realtime_response import Audio, AudioOutput
18
- from openai.types.realtime.realtime_response_status import RealtimeResponseStatus
19
- from openai.types.realtime.realtime_response_usage import RealtimeResponseUsage
20
-
21
- from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler
22
- from speech_to_speech.LLM.chat import ChatItemError
23
- from speech_to_speech.pipeline.events import AssistantTextEvent
24
- from speech_to_speech.pipeline.messages import GenerateResponseRequest
25
- from speech_to_speech.utils.utils import _generate_id, is_out_of_band, response_wants_audio
26
-
27
- if TYPE_CHECKING:
28
- from speech_to_speech.api.openai_realtime.service import ServerEvent, _ResponseStatus, _StatusReason
29
-
30
- logger = logging.getLogger(__name__)
31
-
32
-
33
- class ResponseHandler(RealtimeBaseHandler):
34
- """Owns the response lifecycle: create, cancel, finish, and ID management."""
35
-
36
- # ── ID / state helpers ────────────────────────
37
-
38
- def _ensure_response(self, conn_id: str) -> tuple[str, str]:
39
- """Ensure a response and output item exist, creating them if needed."""
40
- st = self._state(conn_id)
41
- if st.current_response_id is None:
42
- st.current_response_id = _generate_id("resp")
43
- self._start_item(conn_id)
44
- st.in_response = True
45
- st.response_pending = False
46
- return st.current_response_id, self._current_item_id(conn_id)
47
-
48
- def _end_response(self, conn_id: str, status: _ResponseStatus = "completed") -> None:
49
- st = self._state(conn_id)
50
- if status == "cancelled":
51
- st.response_usage.responses_cancelled += 1
52
- else:
53
- st.response_usage.responses_completed += 1
54
- self._service.total_usage += st.response_usage
55
- logger.info(
56
- "Response done (status=%s) — this response: input_tokens=%d, output_tokens=%d, audio=%.2fs"
57
- " | cumulative: input_tokens=%d, output_tokens=%d, audio=%.2fs",
58
- status,
59
- st.response_usage.input_tokens,
60
- st.response_usage.output_tokens,
61
- st.response_usage.audio_duration_s,
62
- self._service.total_usage.input_tokens,
63
- self._service.total_usage.output_tokens,
64
- self._service.total_usage.audio_duration_s,
65
- )
66
- st.response_usage.reset()
67
- st.current_response_id = None
68
- st.current_item_id = None
69
- st.content_index = 0
70
- st.in_response = False
71
- st.response_pending = False
72
- st.current_response_params = None
73
- st.pending_output_text_parts = []
74
-
75
- def _start_item(self, conn_id: str) -> str:
76
- """Generate a new item ID, reset content index, and store it."""
77
- st = self._state(conn_id)
78
- item_id = _generate_id("item")
79
- st.current_item_id = item_id
80
- st.content_index = 0
81
- st.input_audio_duration_s = 0.0
82
- return item_id
83
-
84
- def _current_item_id(self, conn_id: str) -> str:
85
- return self._state(conn_id).current_item_id or self._start_item(conn_id)
86
-
87
- def _next_content_index(self, conn_id: str) -> int:
88
- """Return the current content index and advance it."""
89
- st = self._state(conn_id)
90
- idx = st.content_index
91
- st.content_index += 1
92
- return idx
93
-
94
- def _build_response(
95
- self,
96
- conn_id: str,
97
- status: _ResponseStatus,
98
- reason: _StatusReason | None = None,
99
- ) -> RealtimeResponse:
100
- """Build a fully-populated RealtimeResponse from the current connection state."""
101
- st = self._state(conn_id)
102
- status_details = None
103
- if reason or status in ("completed", "cancelled", "incomplete", "failed"):
104
- status_details = RealtimeResponseStatus(type=status, reason=reason) # type: ignore[arg-type]
105
-
106
- rp = st.current_response_params
107
- metadata = rp.metadata if rp and rp.metadata else None
108
-
109
- voice: Optional[str] = None
110
- if rp and rp.audio and rp.audio.output and rp.audio.output.voice:
111
- voice = str(rp.audio.output.voice)
112
- if not voice:
113
- audio_cfg = st.runtime_config.session.audio
114
- audio_output = audio_cfg.output if audio_cfg is not None else None
115
- voice = str(audio_output.voice) if audio_output is not None and audio_output.voice else None
116
-
117
- # Out-of-band responses are not threaded into any conversation: report a null id.
118
- conversation_id = None if is_out_of_band(rp) else st.conversation_id
119
-
120
- return RealtimeResponse(
121
- id=st.current_response_id,
122
- object="realtime.response",
123
- status=status,
124
- status_details=status_details,
125
- audio=Audio(output=AudioOutput(voice=str(voice) if voice else None)), # type: ignore[arg-type]
126
- conversation_id=conversation_id,
127
- metadata=metadata,
128
- usage=RealtimeResponseUsage(
129
- input_tokens=st.response_usage.input_tokens,
130
- output_tokens=st.response_usage.output_tokens,
131
- total_tokens=st.response_usage.input_tokens + st.response_usage.output_tokens,
132
- ),
133
- )
134
-
135
- # ── Public handlers ───────────────────────────
136
-
137
- def handle_response_create(self, conn_id: str, event: ResponseCreateEvent) -> ServerEvent | None:
138
- """Trigger a response.
139
-
140
- Returns a ``ResponseCreatedEvent`` on success, a ``RealtimeErrorEvent``
141
- on failure, or ``None`` if there is no text_prompt_queue.
142
- """
143
- st = self._state(conn_id)
144
- if event.response:
145
- if event.response.tool_choice and not isinstance(event.response.tool_choice, str):
146
- return self.make_error(
147
- message="Only string tool_choice values are supported for now (auto, required, none).",
148
- _type="tool_choice_not_supported",
149
- )
150
- if st.in_response:
151
- return self.make_error(
152
- message="Cannot create response while another response is in progress.",
153
- _type="conversation_already_has_active_response",
154
- )
155
-
156
- out_of_band = is_out_of_band(event.response)
157
-
158
- # In-band: response.input items are added to the default conversation here so
159
- # they appear in history. Out-of-band: leave the default conversation untouched —
160
- # the input rides along on the request and seeds a throwaway chat in the LM.
161
- if not out_of_band and event.response and event.response.input:
162
- for input_item in event.response.input:
163
- try:
164
- self._service.conversation._append_item(conn_id, input_item)
165
- except ChatItemError as exc:
166
- return self.make_error(message=str(exc), _type="invalid_input_item")
167
-
168
- st.in_response = True
169
- st.response_pending = False
170
-
171
- st.current_response_params = event.response
172
- st.current_response_id = _generate_id("resp")
173
- self._start_item(conn_id)
174
-
175
- cfg = st.runtime_config
176
- queue = self._queue(conn_id)
177
- if queue:
178
- # Out-of-band responses carry no turn identity: a null turn_id makes every
179
- # speculative-turn staleness gate treat them as always-latest, so a new user
180
- # turn mid-generation can never silently drop their output.
181
- queue.put(
182
- GenerateResponseRequest(
183
- runtime_config=cfg,
184
- response=event.response,
185
- turn_id=None if out_of_band else st.speculative_user_turn_id,
186
- turn_revision=None if out_of_band else st.speculative_user_turn_revision,
187
- speech_stopped_at_s=None if out_of_band else st.speculative_user_speech_stopped_at_s,
188
- )
189
- )
190
- logger.debug("response.create received, LLM generation triggered")
191
- return ResponseCreatedEvent(
192
- type="response.created",
193
- event_id=self._next_event_id(),
194
- response=self._build_response(conn_id, "in_progress"),
195
- )
196
-
197
- def handle_response_cancel(self, conn_id: str) -> list[ServerEvent]:
198
- """Cancel the in-progress response and re-enable listening."""
199
- events = self.finish_response(conn_id, status="cancelled", reason="client_cancelled")
200
- should_listen = self._should_listen(conn_id)
201
- if should_listen:
202
- should_listen.set()
203
- logger.info("Response cancelled, listening re-enabled")
204
- return events
205
-
206
- def finish_response(
207
- self,
208
- conn_id: str,
209
- status: _ResponseStatus = "completed",
210
- reason: _StatusReason | None = None,
211
- ) -> list[ServerEvent]:
212
- """Close the current response (audio/text done + response done).
213
-
214
- Audio responses emit ``response.output_audio.done`` for any terminal
215
- status. Text-only responses emit a single ``response.output_text.done``
216
- carrying the full streamed text, but only on ``status="completed"`` —
217
- a cancelled or failed text response sends no audio, so it just closes
218
- with ``response.done``.
219
- """
220
- st = self._state(conn_id)
221
- events: list[ServerEvent] = []
222
- if st.in_response:
223
- resp_id, item_id = self._ensure_response(conn_id)
224
- if response_wants_audio(st.current_response_params):
225
- events.append(
226
- ResponseAudioDoneEvent(
227
- type="response.output_audio.done",
228
- event_id=self._next_event_id(),
229
- content_index=0,
230
- item_id=item_id,
231
- output_index=0,
232
- response_id=resp_id,
233
- )
234
- )
235
- elif status == "completed" and st.pending_output_text_parts:
236
- events.append(
237
- ResponseTextDoneEvent(
238
- type="response.output_text.done",
239
- event_id=self._next_event_id(),
240
- content_index=0,
241
- item_id=item_id,
242
- output_index=0,
243
- response_id=resp_id,
244
- text="".join(st.pending_output_text_parts),
245
- )
246
- )
247
- events.append(
248
- ResponseDoneEvent(
249
- type="response.done",
250
- event_id=self._next_event_id(),
251
- response=self._build_response(conn_id, status, reason),
252
- )
253
- )
254
- self._end_response(conn_id, status)
255
- # Apply any client items that arrived mid-generation now that in_response
256
- # is cleared and the generation's own write-back has landed. Done outside
257
- # the in_response guard so a stray terminal call still drains the buffer.
258
- events.extend(self._service.conversation.flush_deferred_items(conn_id))
259
- return events
260
-
261
- # ── Pipeline event handlers ───────────────────
262
-
263
- def on_assistant_text(
264
- self,
265
- conn_id: str,
266
- event: AssistantTextEvent,
267
- *,
268
- wait_for_pending_reopen: bool = True,
269
- ) -> list[ServerEvent] | None:
270
- """Handle assistant_text: emit transcript and/or tool-call events."""
271
- if self._service.speculative_turns:
272
- commit_result: bool | None
273
- if wait_for_pending_reopen:
274
- commit_result = self._service.speculative_turns.commit_if_latest_after_reopen_grace(
275
- event.turn_id,
276
- event.turn_revision,
277
- )
278
- else:
279
- commit_result = self._service.speculative_turns.try_commit_if_latest_after_reopen_grace(
280
- event.turn_id,
281
- event.turn_revision,
282
- )
283
- if commit_result is None:
284
- return None
285
- if not commit_result:
286
- logger.debug("Dropping stale assistant text for turn=%s rev=%s", event.turn_id, event.turn_revision)
287
- return []
288
- st = self._state(conn_id)
289
- events: list[ServerEvent] = []
290
- resp_id, item_id = self._ensure_response(conn_id)
291
- st.last_item_id = item_id
292
- output_idx = 0
293
- if event.text:
294
- if response_wants_audio(st.current_response_params):
295
- events.append(
296
- ResponseAudioTranscriptDoneEvent(
297
- type="response.output_audio_transcript.done",
298
- event_id=self._next_event_id(),
299
- content_index=0,
300
- item_id=item_id,
301
- output_index=output_idx,
302
- response_id=resp_id,
303
- transcript=event.text,
304
- )
305
- )
306
- else:
307
- # Stream the delta now; the matching response.output_text.done is
308
- # emitted once at close in finish_response, carrying the per-chunk
309
- # parts collected here, space-joined ("" when there were none).
310
- st.pending_output_text_parts.append(event.text)
311
- events.append(
312
- ResponseTextDeltaEvent(
313
- type="response.output_text.delta",
314
- event_id=self._next_event_id(),
315
- content_index=0,
316
- item_id=item_id,
317
- output_index=output_idx,
318
- response_id=resp_id,
319
- delta=event.text,
320
- )
321
- )
322
- output_idx += 1
323
- if event.tools:
324
- st.response_usage.tool_calls += len(event.tools)
325
- for tool in event.tools:
326
- events.append(
327
- ResponseFunctionCallArgumentsDoneEvent(
328
- type="response.function_call_arguments.done",
329
- event_id=self._next_event_id(),
330
- call_id=tool.call_id,
331
- name=tool.name,
332
- arguments=tool.arguments,
333
- item_id=item_id,
334
- output_index=output_idx,
335
- response_id=resp_id,
336
- )
337
- )
338
- output_idx += 1
339
- return events
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/api/openai_realtime/handlers/session.py DELETED
@@ -1,62 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from typing import Optional
5
-
6
- from openai.types.realtime import (
7
- RealtimeErrorEvent,
8
- SessionCreatedEvent,
9
- SessionUpdateEvent,
10
- )
11
- from openai.types.realtime.realtime_transcription_session_create_request import (
12
- RealtimeTranscriptionSessionCreateRequest,
13
- )
14
-
15
- from speech_to_speech.api.openai_realtime.handlers.base import RealtimeBaseHandler
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
-
20
- class SessionHandler(RealtimeBaseHandler):
21
- """Owns session lifecycle: config updates and session.created events."""
22
-
23
- def handle_session_update(self, conn_id: str, event: SessionUpdateEvent) -> Optional[RealtimeErrorEvent]:
24
- """Apply session config changes.
25
-
26
- Only ``RealtimeSessionCreateRequest`` sessions are accepted;
27
- ``RealtimeTranscriptionSessionCreateRequest`` sessions not yet supported.
28
- Incoming fields are deep-merged into the existing session so that
29
- partial updates preserve previously-set values.
30
- """
31
- s = event.session
32
- if s is None:
33
- return None
34
-
35
- if isinstance(s, RealtimeTranscriptionSessionCreateRequest):
36
- return self.make_error(
37
- message="Only 'realtime' session type is supported; transcription sessions are not.",
38
- _type="invalid_session_type",
39
- )
40
-
41
- model = getattr(s, "model", None)
42
- if model is not None:
43
- logger.info(f"Session model set to: {model}")
44
-
45
- cfg = self._state(conn_id).runtime_config
46
- current = cfg.session
47
- if current is None:
48
- cfg.session = s
49
- else:
50
- cfg.apply_session_update(s)
51
- logger.info("Session configuration updated")
52
- return None
53
-
54
- def build_session_created(self, conn_id: str) -> SessionCreatedEvent:
55
- """Build a SessionCreatedEvent populated with the current config."""
56
- cfg = self._state(conn_id).runtime_config
57
- session = cfg.session
58
- return SessionCreatedEvent(
59
- type="session.created",
60
- event_id=self._next_event_id(),
61
- session=session,
62
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime/src/speech_to_speech/api/openai_realtime/pipeline_unit.py DELETED
@@ -1,72 +0,0 @@
1
- import asyncio
2
- from queue import Queue
3
- from threading import Event
4
- from typing import Any, Optional
5
-
6
- from pydantic import BaseModel, ConfigDict, Field
7
-
8
- from speech_to_speech.api.openai_realtime.service import RealtimeService
9
- from speech_to_speech.api.openai_realtime.transports import SessionTransport
10
- from speech_to_speech.pipeline.cancel_scope import CancelScope
11
-
12
-
13
- class SessionState(BaseModel):
14
- """Per-client ephemeral state.
15
-
16
- Created when a route handler claims a PipelineUnit (WebSocket accept or
17
- WebRTC SDP offer); dropped when the client disconnects. Holding the
18
- transport reference, the service session id, and any send-loop scratch
19
- (pending_output_item) here ensures these fields share one lifecycle — a
20
- stale value can't outlive its session.
21
-
22
- `transport` may briefly be None while a WebRTC claim finishes
23
- constructing the session, so the send loop must tolerate a
24
- transport-less snapshot.
25
-
26
- `drained` is set by the send loop when SESSION_END travels through the handler
27
- chain back to the output queue; the release path awaits it before clearing
28
- `PipelineUnit.session`, so a new client cannot claim the unit until in-flight
29
- work from this session has fully reset.
30
- """
31
-
32
- model_config = ConfigDict(arbitrary_types_allowed=True)
33
-
34
- transport: Optional[SessionTransport] = None
35
- session_id: str = ""
36
- pending_output_item: Any = None
37
- drained: asyncio.Event = Field(default_factory=asyncio.Event)
38
- # Wall-clock time when the client disconnected (route handler released its
39
- # claim). `None` while the client is still active. Used by /v1/pool to
40
- # surface stuck units (handlers haven't finished propagating SESSION_END).
41
- released_at: Optional[float] = None
42
- # Wall-clock time when the drain wait gave up and quarantined the unit
43
- # (SESSION_END_QUARANTINE_TIMEOUT_S elapsed). The unit stays unclaimable —
44
- # its handlers may still emit this session's output — until SESSION_END
45
- # actually drains. Reported as "stuck" by /v1/pool.
46
- quarantined_at: Optional[float] = None
47
-
48
-
49
- class PipelineUnit(BaseModel):
50
- """One isolated realtime pipeline.
51
-
52
- Each unit owns its queues, events, RealtimeService, and the chain of handler
53
- instances (VAD, STT, transcription notifier, LM, LM output processor, TTS).
54
- Lives inside the pool managed by RealtimeServer; the websocket route handler
55
- claims a free unit (`session is None`) on `accept` and releases it on disconnect
56
- by setting `session` back to None.
57
- """
58
-
59
- model_config = ConfigDict(arbitrary_types_allowed=True)
60
-
61
- index: int
62
- service: RealtimeService
63
- cancel_scope: CancelScope
64
- should_listen: Event
65
- response_playing: Event
66
- input_queue: Queue
67
- output_queue: Queue
68
- text_output_queue: Queue
69
- text_prompt_queue: Queue
70
- handlers: list[Any]
71
-
72
- session: Optional[SessionState] = None