Remove unused runtime source snapshot
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- runtime/src/speech_to_speech/LLM/README.md +0 -139
- runtime/src/speech_to_speech/LLM/__init__.py +0 -0
- runtime/src/speech_to_speech/LLM/audio_input_notifier.py +0 -82
- runtime/src/speech_to_speech/LLM/base_openai_compatible_language_model.py +0 -833
- runtime/src/speech_to_speech/LLM/chat.py +0 -835
- runtime/src/speech_to_speech/LLM/chat_completions_language_model.py +0 -360
- runtime/src/speech_to_speech/LLM/compaction_prompt.py +0 -181
- runtime/src/speech_to_speech/LLM/language_model.py +0 -1011
- runtime/src/speech_to_speech/LLM/lm_output_processor.py +0 -148
- runtime/src/speech_to_speech/LLM/responses_api_language_model.py +0 -201
- runtime/src/speech_to_speech/LLM/text_prompt.py +0 -44
- runtime/src/speech_to_speech/LLM/tool_call/function_call.py +0 -321
- runtime/src/speech_to_speech/LLM/tool_call/function_tool.py +0 -34
- runtime/src/speech_to_speech/LLM/tool_call/signature_from_schema.py +0 -108
- runtime/src/speech_to_speech/LLM/tool_call/tool_prompt.py +0 -111
- runtime/src/speech_to_speech/LLM/utils.py +0 -75
- runtime/src/speech_to_speech/LLM/voice_prompt.py +0 -48
- runtime/src/speech_to_speech/STT/README.md +0 -163
- runtime/src/speech_to_speech/STT/__init__.py +0 -1
- runtime/src/speech_to_speech/STT/base_stt_handler.py +0 -212
- runtime/src/speech_to_speech/STT/faster_whisper_handler.py +0 -68
- runtime/src/speech_to_speech/STT/lightning_whisper_mlx_handler.py +0 -103
- runtime/src/speech_to_speech/STT/mlx_audio_whisper_handler.py +0 -174
- runtime/src/speech_to_speech/STT/paraformer_handler.py +0 -79
- runtime/src/speech_to_speech/STT/parakeet_tdt_handler.py +0 -648
- runtime/src/speech_to_speech/STT/smart_progressive_streaming.py +0 -343
- runtime/src/speech_to_speech/STT/transcription_notifier.py +0 -103
- runtime/src/speech_to_speech/STT/whisper_stt_handler.py +0 -147
- runtime/src/speech_to_speech/TTS/README.md +0 -155
- runtime/src/speech_to_speech/TTS/__init__.py +0 -1
- runtime/src/speech_to_speech/TTS/chatTTS_handler.py +0 -119
- runtime/src/speech_to_speech/TTS/facebookmms_handler.py +0 -212
- runtime/src/speech_to_speech/TTS/kokoro_handler.py +0 -418
- runtime/src/speech_to_speech/TTS/pocket_tts_handler.py +0 -227
- runtime/src/speech_to_speech/TTS/qwen3_tts_handler.py +0 -903
- runtime/src/speech_to_speech/TTS/ref_audio.wav +0 -3
- runtime/src/speech_to_speech/VAD/__init__.py +0 -1
- runtime/src/speech_to_speech/VAD/vad_handler.py +0 -870
- runtime/src/speech_to_speech/VAD/vad_iterator.py +0 -170
- runtime/src/speech_to_speech/__init__.py +0 -3
- runtime/src/speech_to_speech/api/__init__.py +0 -0
- runtime/src/speech_to_speech/api/openai_realtime/README.md +0 -244
- runtime/src/speech_to_speech/api/openai_realtime/__init__.py +0 -0
- runtime/src/speech_to_speech/api/openai_realtime/handlers/__init__.py +0 -11
- runtime/src/speech_to_speech/api/openai_realtime/handlers/audio.py +0 -224
- runtime/src/speech_to_speech/api/openai_realtime/handlers/base.py +0 -57
- runtime/src/speech_to_speech/api/openai_realtime/handlers/conversation.py +0 -128
- runtime/src/speech_to_speech/api/openai_realtime/handlers/response.py +0 -339
- runtime/src/speech_to_speech/api/openai_realtime/handlers/session.py +0 -62
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|