first git
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata as metadata
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _require_modules(modules: list[str]) -> None:
|
||||
missing = [module for module in modules if importlib.util.find_spec(module) is None]
|
||||
if missing:
|
||||
raise RuntimeError(f"Missing expected install-time modules: {', '.join(missing)}")
|
||||
|
||||
|
||||
def _run_installed_cli_help() -> None:
|
||||
env = {**os.environ, "OPENAI_API_KEY": ""}
|
||||
result = subprocess.run(
|
||||
["speech-to-speech", "--help"],
|
||||
check=True,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
expected_flags = ("--mode", "--stt", "--llm_backend", "--tts")
|
||||
missing_flags = [flag for flag in expected_flags if flag not in result.stdout]
|
||||
if missing_flags:
|
||||
raise RuntimeError(f"Installed CLI help is missing expected flags: {', '.join(missing_flags)}")
|
||||
|
||||
|
||||
def _validate_package_defaults() -> None:
|
||||
import speech_to_speech
|
||||
from speech_to_speech.arguments_classes.module_arguments import ModuleArguments
|
||||
from speech_to_speech.arguments_classes.qwen3_tts_arguments import Qwen3TTSHandlerArguments
|
||||
from speech_to_speech.arguments_classes.responses_api_language_model_arguments import (
|
||||
ResponsesApiLanguageModelHandlerArguments,
|
||||
)
|
||||
from speech_to_speech.arguments_classes.vad_arguments import VADHandlerArguments
|
||||
|
||||
module_args = ModuleArguments()
|
||||
responses_api_args = ResponsesApiLanguageModelHandlerArguments()
|
||||
qwen3_args = Qwen3TTSHandlerArguments()
|
||||
vad_args = VADHandlerArguments()
|
||||
|
||||
assert module_args.mode == "realtime"
|
||||
assert module_args.stt == "parakeet-tdt"
|
||||
assert module_args.llm_backend == "responses-api"
|
||||
assert module_args.tts == "qwen3"
|
||||
assert module_args.log_level == "info"
|
||||
assert module_args.enable_live_transcription is True
|
||||
assert module_args.live_transcription_update_interval == 0.5
|
||||
assert responses_api_args.model_name == "gpt-5.4-mini"
|
||||
assert responses_api_args.responses_api_stream is True
|
||||
assert qwen3_args.qwen3_tts_model_name == "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||
assert qwen3_args.qwen3_tts_speaker == "Aiden"
|
||||
assert qwen3_args.qwen3_tts_language == "auto"
|
||||
assert qwen3_args.qwen3_tts_backend == "ggml"
|
||||
assert qwen3_args.qwen3_tts_non_streaming_mode is True
|
||||
assert qwen3_args.qwen3_tts_ref_audio is None
|
||||
assert qwen3_args.qwen3_tts_mlx_quantization == "6bit"
|
||||
assert vad_args.thresh == 0.6
|
||||
assert vad_args.min_silence_ms == 64
|
||||
assert vad_args.min_speech_ms == 384
|
||||
assert vad_args.min_speech_continuation_ms == 192
|
||||
assert vad_args.realtime_processing_pause == 0.5
|
||||
|
||||
package_root = Path(speech_to_speech.__file__).resolve().parent
|
||||
ref_audio = package_root / "TTS" / "ref_audio.wav"
|
||||
if not ref_audio.exists():
|
||||
raise RuntimeError(f"Packaged Qwen3-TTS reference audio is missing: {ref_audio}")
|
||||
|
||||
|
||||
def _validate_empty_qwen_ref_audio_arg() -> None:
|
||||
from speech_to_speech.s2s_pipeline import parse_arguments
|
||||
|
||||
original_argv = sys.argv[:]
|
||||
try:
|
||||
sys.argv = ["speech-to-speech", "--qwen3_tts_ref_audio="]
|
||||
qwen3_args = parse_arguments().qwen3_tts_handler_kwargs
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
assert qwen3_args.qwen3_tts_ref_audio == ""
|
||||
assert qwen3_args.qwen3_tts_model_name == "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||
assert qwen3_args.qwen3_tts_speaker == "Aiden"
|
||||
|
||||
|
||||
def _validate_pipeline_startup_primitives() -> None:
|
||||
from speech_to_speech.s2s_pipeline import initialize_queues_and_events
|
||||
|
||||
queues_and_events = initialize_queues_and_events()
|
||||
expected_keys = {
|
||||
"recv_audio_chunks_queue",
|
||||
"send_audio_chunks_queue",
|
||||
"spoken_prompt_queue",
|
||||
"stt_output_queue",
|
||||
"text_prompt_queue",
|
||||
"lm_response_queue",
|
||||
"lm_processed_queue",
|
||||
"text_output_queue",
|
||||
}
|
||||
missing_keys = expected_keys.difference(queues_and_events)
|
||||
if missing_keys:
|
||||
raise RuntimeError(f"Pipeline startup primitives are missing: {', '.join(sorted(missing_keys))}")
|
||||
|
||||
|
||||
def _validate_default_handler_imports() -> None:
|
||||
default_handler_modules = [
|
||||
"speech_to_speech.LLM.responses_api_language_model",
|
||||
"speech_to_speech.STT.parakeet_tdt_handler",
|
||||
"speech_to_speech.TTS.qwen3_tts_handler",
|
||||
"speech_to_speech.VAD.vad_handler",
|
||||
]
|
||||
for module_name in default_handler_modules:
|
||||
importlib.import_module(module_name)
|
||||
|
||||
|
||||
def _validate_realtime_websocket_support() -> None:
|
||||
importlib.import_module("uvicorn.protocols.websockets.websockets_impl")
|
||||
|
||||
|
||||
def _validate_darwin_dependency_pins() -> None:
|
||||
expected_versions = {
|
||||
"miniaudio": "1.61",
|
||||
"mlx": "0.31.1",
|
||||
"mlx-audio": "0.4.2",
|
||||
"mlx-lm": "0.31.1",
|
||||
"mlx-metal": "0.31.1",
|
||||
"sounddevice": "0.5.3",
|
||||
"transformers": "5.6.2",
|
||||
}
|
||||
mismatches = []
|
||||
for package_name, expected_version in expected_versions.items():
|
||||
actual_version = metadata.version(package_name)
|
||||
if actual_version != expected_version:
|
||||
mismatches.append(f"{package_name}=={actual_version} (expected {expected_version})")
|
||||
|
||||
numpy_version = metadata.version("numpy")
|
||||
numpy_version_parts = tuple(int(part) for part in numpy_version.split(".")[:3])
|
||||
if numpy_version_parts >= (2, 4, 4):
|
||||
mismatches.append(f"numpy=={numpy_version} (expected <2.4.4 on macOS)")
|
||||
|
||||
if mismatches:
|
||||
raise RuntimeError("Unexpected macOS dependency versions: " + ", ".join(mismatches))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
required_modules = [
|
||||
"fastapi",
|
||||
"lingua",
|
||||
"openai",
|
||||
"PIL",
|
||||
"scipy",
|
||||
"sounddevice",
|
||||
"torch",
|
||||
"torchaudio",
|
||||
"transformers",
|
||||
"uvicorn",
|
||||
"websockets",
|
||||
]
|
||||
if sys.platform == "darwin":
|
||||
required_modules.extend(["miniaudio", "mlx", "mlx_audio", "mlx_lm", "misaki", "soundfile", "spacy"])
|
||||
else:
|
||||
required_modules.extend(["faster_qwen3_tts", "nano_parakeet"])
|
||||
|
||||
_require_modules(required_modules)
|
||||
if sys.platform == "darwin":
|
||||
_validate_darwin_dependency_pins()
|
||||
_run_installed_cli_help()
|
||||
_validate_package_defaults()
|
||||
_validate_empty_qwen_ref_audio_arg()
|
||||
_validate_pipeline_startup_primitives()
|
||||
_validate_default_handler_imports()
|
||||
_validate_realtime_websocket_support()
|
||||
print("speech-to-speech installed package smoke test passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
from queue import Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
|
||||
import pytest
|
||||
from openai.types.realtime import RealtimeSessionCreateRequest
|
||||
from openai.types.realtime.realtime_audio_config import RealtimeAudioConfig
|
||||
from openai.types.realtime.realtime_audio_config_input import RealtimeAudioConfigInput
|
||||
from openai.types.realtime.realtime_audio_config_output import RealtimeAudioConfigOutput
|
||||
from openai.types.realtime.realtime_audio_formats import AudioPCM
|
||||
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
from speech_to_speech.api.openai_realtime.service import RealtimeService
|
||||
|
||||
|
||||
def _session_16k() -> RealtimeSessionCreateRequest:
|
||||
"""Build a test session with 16 kHz audio rates (matches PIPELINE_SAMPLE_RATE)."""
|
||||
fmt = AudioPCM.model_construct(rate=16000, type="audio/pcm")
|
||||
return RealtimeSessionCreateRequest.model_construct(
|
||||
type="realtime",
|
||||
audio=RealtimeAudioConfig.model_construct(
|
||||
input=RealtimeAudioConfigInput.model_construct(format=fmt),
|
||||
output=RealtimeAudioConfigOutput.model_construct(format=fmt),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime_config():
|
||||
cfg = RuntimeConfig()
|
||||
cfg.session = _session_16k()
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def text_prompt_queue():
|
||||
return Queue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def should_listen():
|
||||
ev = ThreadingEvent()
|
||||
ev.set()
|
||||
return ev
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(runtime_config, text_prompt_queue, should_listen):
|
||||
svc = RealtimeService(
|
||||
text_prompt_queue=text_prompt_queue,
|
||||
should_listen=should_listen,
|
||||
)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn_id(service, runtime_config):
|
||||
cid = service.register()
|
||||
service._state(cid).runtime_config = runtime_config
|
||||
yield cid
|
||||
service.unregister(cid)
|
||||
@@ -0,0 +1,676 @@
|
||||
"""Tests using the real OpenAI Python SDK client connected to our local server.
|
||||
|
||||
``AsyncOpenAI.realtime.connect()`` (non-beta) establishes a WebSocket
|
||||
connection and returns parsed event objects from ``openai.types.realtime``.
|
||||
These tests start our FastAPI app on a local port with uvicorn, then drive
|
||||
a real SDK client against it — exactly as the production client does.
|
||||
|
||||
The pipeline side (audio output, text events) is driven through the queues,
|
||||
while the client side uses ``conn.send()`` and ``async for event in conn``.
|
||||
|
||||
We use ``client.realtime.connect()`` (non-beta), **not**
|
||||
``client.realtime.connect()``. The non-beta path expects the GA type
|
||||
strings our server emits (e.g. ``response.output_audio.delta``), whereas
|
||||
the beta path expects the older ``response.audio.delta`` variants.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from queue import Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit
|
||||
from speech_to_speech.api.openai_realtime.service import RealtimeService
|
||||
from speech_to_speech.api.openai_realtime.websocket_router import create_app
|
||||
from speech_to_speech.pipeline.cancel_scope import CancelScope
|
||||
from speech_to_speech.pipeline.events import (
|
||||
AssistantTextEvent,
|
||||
PartialTranscriptionEvent,
|
||||
SpeechStartedEvent,
|
||||
SpeechStoppedEvent,
|
||||
TranscriptionCompletedEvent,
|
||||
)
|
||||
from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _pcm_bytes(n_samples: int) -> bytes:
|
||||
return b"\x00" * (n_samples * 2)
|
||||
|
||||
|
||||
class _ServerEnv:
|
||||
"""Wraps a running uvicorn server + all pipeline queues."""
|
||||
|
||||
def __init__(self):
|
||||
self.text_prompt_queue: Queue = Queue()
|
||||
self.should_listen = ThreadingEvent()
|
||||
self.should_listen.set()
|
||||
self.service = RealtimeService(
|
||||
text_prompt_queue=self.text_prompt_queue,
|
||||
should_listen=self.should_listen,
|
||||
)
|
||||
self.input_queue: Queue = Queue()
|
||||
self.output_queue: Queue = Queue()
|
||||
self.text_output_queue: Queue = Queue()
|
||||
self.stop_event = ThreadingEvent()
|
||||
self.response_playing = ThreadingEvent()
|
||||
self.cancel_scope = CancelScope()
|
||||
self.unit = PipelineUnit(
|
||||
index=0,
|
||||
service=self.service,
|
||||
cancel_scope=self.cancel_scope,
|
||||
should_listen=self.should_listen,
|
||||
response_playing=self.response_playing,
|
||||
input_queue=self.input_queue,
|
||||
output_queue=self.output_queue,
|
||||
text_output_queue=self.text_output_queue,
|
||||
text_prompt_queue=self.text_prompt_queue,
|
||||
handlers=[],
|
||||
)
|
||||
self.app = create_app(pool=[self.unit], stop_event=self.stop_event)
|
||||
self.port = _free_port()
|
||||
self._server_thread: threading.Thread | None = None
|
||||
|
||||
def start(self):
|
||||
config = uvicorn.Config(
|
||||
self.app,
|
||||
host="127.0.0.1",
|
||||
port=self.port,
|
||||
log_level="warning",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
self._server = server
|
||||
self._server_thread = threading.Thread(target=server.run, daemon=True)
|
||||
self._server_thread.start()
|
||||
for _ in range(50):
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=0.1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError("Server did not start in time")
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
self._server.should_exit = True
|
||||
if self._server_thread:
|
||||
self._server_thread.join(timeout=5)
|
||||
|
||||
def make_client(self) -> AsyncOpenAI:
|
||||
return AsyncOpenAI(
|
||||
api_key="test-key",
|
||||
base_url=f"http://127.0.0.1:{self.port}/v1",
|
||||
websocket_base_url=f"ws://127.0.0.1:{self.port}/v1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_env():
|
||||
env = _ServerEnv()
|
||||
env.start()
|
||||
yield env
|
||||
env.stop()
|
||||
|
||||
|
||||
async def _recv(conn, timeout: float = 3.0):
|
||||
"""Receive next event with a timeout to avoid hanging tests."""
|
||||
return await asyncio.wait_for(conn.recv(), timeout=timeout)
|
||||
|
||||
|
||||
# Our server uses the openai.types.realtime type strings (e.g.
|
||||
# "response.output_audio.done"). The production client code matches on
|
||||
# event.type using both GA and legacy names for compatibility. These
|
||||
# constants match the Literal values from openai.types.realtime.
|
||||
SESSION_CREATED = "session.created"
|
||||
SPEECH_STARTED = "input_audio_buffer.speech_started"
|
||||
SPEECH_STOPPED = "input_audio_buffer.speech_stopped"
|
||||
TRANSCRIPTION_DELTA = "conversation.item.input_audio_transcription.delta"
|
||||
TRANSCRIPTION_COMPLETED = "conversation.item.input_audio_transcription.completed"
|
||||
ITEM_CREATED = "conversation.item.created"
|
||||
RESPONSE_CREATED = "response.created"
|
||||
RESPONSE_DONE = "response.done"
|
||||
AUDIO_DELTA = "response.output_audio.delta"
|
||||
AUDIO_DONE = "response.output_audio.done"
|
||||
TRANSCRIPT_DONE = "response.output_audio_transcript.done"
|
||||
FUNCTION_CALL_DONE = "response.function_call_arguments.done"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. Connection and session.created
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKConnection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_receives_session_created(self, server_env):
|
||||
"""SDK connect yields session.created as the first event."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
event = await _recv(conn)
|
||||
assert event.type == SESSION_CREATED
|
||||
assert event.event_id.startswith("event_")
|
||||
assert event.session is not None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. Session update
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKSessionUpdate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_update_applies_config(self, server_env):
|
||||
"""conn.session.update() applies config server-side."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
await conn.send(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"type": "realtime",
|
||||
"instructions": "You are a helpful robot",
|
||||
"audio": {
|
||||
"input": {
|
||||
"transcription": {"model": "gpt-4o-transcribe", "language": "en"},
|
||||
"turn_detection": {
|
||||
"type": "server_vad",
|
||||
"interrupt_response": True,
|
||||
},
|
||||
},
|
||||
"output": {
|
||||
"voice": "alloy",
|
||||
},
|
||||
},
|
||||
"tools": [{"type": "function", "name": "get_weather"}],
|
||||
"tool_choice": "auto",
|
||||
},
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
cid = server_env.service.connection_ids[0]
|
||||
s = server_env.service._state(cid).runtime_config.session
|
||||
assert s.audio.output.voice == "alloy"
|
||||
assert s.instructions == "You are a helpful robot"
|
||||
assert s.audio.input.turn_detection.type == "server_vad"
|
||||
assert s.tools is not None
|
||||
assert s.tool_choice == "auto"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. Full voice conversation turn
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKVoiceTurn:
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_voice_turn(self, server_env):
|
||||
"""
|
||||
Pipeline-driven voice turn through the real SDK:
|
||||
speech_started → partial transcription → speech_stopped →
|
||||
transcription_completed → audio response → transcript → done
|
||||
"""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
# -- User speech --
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STARTED
|
||||
assert event.audio_start_ms == 0
|
||||
item_id = event.item_id
|
||||
|
||||
server_env.text_output_queue.put(PartialTranscriptionEvent(delta="hel"))
|
||||
event = await _recv(conn)
|
||||
assert event.type == TRANSCRIPTION_DELTA
|
||||
assert event.delta == "hel"
|
||||
assert event.item_id == item_id
|
||||
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent(duration_s=1.9))
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STOPPED
|
||||
assert event.audio_end_ms == 0
|
||||
assert event.item_id == item_id
|
||||
|
||||
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="hello"))
|
||||
event = await _recv(conn)
|
||||
assert event.type == TRANSCRIPTION_COMPLETED
|
||||
assert event.transcript == "hello"
|
||||
assert event.usage.seconds == 1.9
|
||||
|
||||
# -- Server audio response --
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_CREATED
|
||||
assert event.response.status == "in_progress"
|
||||
assert event.response.object == "realtime.response"
|
||||
conversation_id = event.response.conversation_id
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == AUDIO_DELTA
|
||||
decoded = base64.b64decode(event.delta)
|
||||
assert len(decoded) == len(_pcm_bytes(256))
|
||||
|
||||
server_env.text_output_queue.put(AssistantTextEvent(text="Hi there!"))
|
||||
event = await _recv(conn)
|
||||
assert event.type == TRANSCRIPT_DONE
|
||||
assert event.transcript == "Hi there!"
|
||||
|
||||
server_env.output_queue.put(PIPELINE_END)
|
||||
event = await _recv(conn)
|
||||
assert event.type == AUDIO_DONE
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_DONE
|
||||
assert event.response.status == "completed"
|
||||
assert event.response.conversation_id == conversation_id
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4. Interruption (barge-in)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKBargeIn:
|
||||
@pytest.mark.asyncio
|
||||
async def test_speech_interrupts_active_response(self, server_env):
|
||||
"""User speech during audio streaming cancels with turn_detected."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
|
||||
events = []
|
||||
for _ in range(3):
|
||||
events.append(await _recv(conn))
|
||||
|
||||
types = [e.type for e in events]
|
||||
assert AUDIO_DONE in types
|
||||
assert RESPONSE_DONE in types
|
||||
assert SPEECH_STARTED in types
|
||||
|
||||
done = next(e for e in events if e.type == RESPONSE_DONE)
|
||||
assert done.response.status == "cancelled"
|
||||
assert done.response.status_details.reason == "turn_detected"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_assistant_text_flushed_on_interruption(self, server_env):
|
||||
"""Stale assistant_text queued during interruption is flushed, not reopened as a new response."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
server_env.text_output_queue.put(AssistantTextEvent(text="stale response text"))
|
||||
|
||||
events = []
|
||||
for _ in range(3):
|
||||
events.append(await _recv(conn))
|
||||
|
||||
types = [e.type for e in events]
|
||||
assert AUDIO_DONE in types
|
||||
assert RESPONSE_DONE in types
|
||||
assert SPEECH_STARTED in types
|
||||
|
||||
done = next(e for e in events if e.type == RESPONSE_DONE)
|
||||
assert done.response.status == "cancelled"
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(conn.recv(), timeout=0.5)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4b. Phantom speech & interruption state
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKPhantomSpeech:
|
||||
@pytest.mark.asyncio
|
||||
async def test_phantom_speech_does_not_block_pipeline(self, server_env):
|
||||
"""speech_started + speech_stopped(duration=0) doesn't hang; a normal turn follows."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STARTED
|
||||
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent())
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STOPPED
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STARTED
|
||||
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent(duration_s=2.0))
|
||||
event = await _recv(conn)
|
||||
assert event.type == SPEECH_STOPPED
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
server_env.output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
event = await _recv(conn)
|
||||
assert event.type == AUDIO_DONE
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_DONE
|
||||
assert event.response.status == "completed"
|
||||
|
||||
|
||||
class TestSDKInterruptionState:
|
||||
@pytest.mark.asyncio
|
||||
async def test_interruption_resets_pipeline_state(self, server_env):
|
||||
"""After interruption, response_playing is cleared and cancel_scope
|
||||
enters discarding mode until __RESPONSE_DONE__ arrives."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
assert not server_env.response_playing.is_set()
|
||||
assert not server_env.cancel_scope.discarding
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
await _recv(conn) # response.created
|
||||
await _recv(conn) # audio delta
|
||||
assert server_env.response_playing.is_set()
|
||||
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
events = []
|
||||
for _ in range(3):
|
||||
events.append(await _recv(conn))
|
||||
|
||||
types = [e.type for e in events]
|
||||
assert SPEECH_STARTED in types
|
||||
assert RESPONSE_DONE in types
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
assert not server_env.response_playing.is_set()
|
||||
assert server_env.cancel_scope.discarding
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 5. Tool calling
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKToolCalling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_events(self, server_env):
|
||||
"""Tool calls produce events with name, call_id, arguments."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="Checking weather",
|
||||
tools=[
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_xyz",
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Tokyo"}',
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == TRANSCRIPT_DONE
|
||||
assert event.transcript == "Checking weather"
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == FUNCTION_CALL_DONE
|
||||
assert event.name == "get_weather"
|
||||
assert event.call_id == "call_xyz"
|
||||
assert json.loads(event.arguments) == {"city": "Tokyo"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_tool_calls_output_index(self, server_env):
|
||||
"""Multiple tool calls have incrementing output_index."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="",
|
||||
tools=[
|
||||
{"type": "function_call", "call_id": "c1", "name": "tool_a", "arguments": "{}"},
|
||||
{"type": "function_call", "call_id": "c2", "name": "tool_b", "arguments": '{"x": 1}'},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
e1 = await _recv(conn)
|
||||
e2 = await _recv(conn)
|
||||
assert e1.type == FUNCTION_CALL_DONE
|
||||
assert e2.type == FUNCTION_CALL_DONE
|
||||
assert e1.output_index == 0
|
||||
assert e2.output_index == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 6. Text input via SDK
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKTextInput:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_conversation_item_create(self, server_env):
|
||||
"""Sending conversation.item.create produces an item.created event."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
await conn.send(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"id": "msg_sdk_1",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Hello from SDK"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == ITEM_CREATED
|
||||
assert event.item.role == "user"
|
||||
assert event.item.content[0].text == "Hello from SDK"
|
||||
assert event.previous_item_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_input_previous_item_id_chain(self, server_env):
|
||||
"""Sequential text items chain via previous_item_id."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
await conn.send(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"id": "msg_a",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "first"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
e1 = await _recv(conn)
|
||||
assert e1.previous_item_id is None
|
||||
|
||||
await conn.send(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"id": "msg_b",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "second"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
e2 = await _recv(conn)
|
||||
assert e2.previous_item_id == e1.item.id
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 7. Error handling
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKErrorHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_event_returns_error(self, server_env):
|
||||
"""Unknown event type returns an error event."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
await conn.send({"type": "bogus.nonexistent"})
|
||||
event = await _recv(conn)
|
||||
assert event.type == ERROR
|
||||
assert event.error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_response_create_error(self, server_env):
|
||||
"""response.create while response is active returns error."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
await _recv(conn) # response.created
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
await conn.send({"type": "response.create"})
|
||||
event = await _recv(conn)
|
||||
assert event.type == ERROR
|
||||
assert event.error.type == "conversation_already_has_active_response"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 8. Response cancel
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKResponseCancel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_active_response(self, server_env):
|
||||
"""response.cancel produces done events with cancelled status."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn)
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(256))
|
||||
await _recv(conn) # response.created
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
await conn.send({"type": "response.cancel"})
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == AUDIO_DONE
|
||||
|
||||
event = await _recv(conn)
|
||||
assert event.type == RESPONSE_DONE
|
||||
assert event.response.status == "cancelled"
|
||||
assert event.response.status_details.reason == "client_cancelled"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 9. Multi-turn conversation_id consistency
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSDKMultiTurn:
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_turns_same_conversation(self, server_env):
|
||||
"""Two voice turns share the same conversation_id."""
|
||||
client = server_env.make_client()
|
||||
async with client.realtime.connect(model="test") as conn:
|
||||
await _recv(conn) # session.created
|
||||
|
||||
# Turn 1
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent())
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="hi"))
|
||||
await _recv(conn)
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(128))
|
||||
t1_created = await _recv(conn)
|
||||
assert t1_created.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
# Barge-in
|
||||
server_env.text_output_queue.put(SpeechStartedEvent())
|
||||
events = []
|
||||
for _ in range(3):
|
||||
events.append(await _recv(conn))
|
||||
|
||||
t1_done = next(e for e in events if e.type == RESPONSE_DONE)
|
||||
|
||||
# Simulate pipeline acknowledging cancellation so discard guard clears
|
||||
server_env.output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# Turn 2
|
||||
server_env.text_output_queue.put(SpeechStoppedEvent())
|
||||
await _recv(conn)
|
||||
|
||||
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="bye"))
|
||||
await _recv(conn)
|
||||
|
||||
server_env.output_queue.put(_pcm_bytes(128))
|
||||
t2_created = await _recv(conn)
|
||||
assert t2_created.type == RESPONSE_CREATED
|
||||
await _recv(conn) # audio delta
|
||||
|
||||
server_env.output_queue.put(PIPELINE_END)
|
||||
await _recv(conn) # audio done
|
||||
t2_done = await _recv(conn)
|
||||
assert t2_done.type == RESPONSE_DONE
|
||||
|
||||
assert t1_done.response.conversation_id == t2_done.response.conversation_id
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
"""Unit tests for RuntimeConfig.apply_session_update / _apply_update.
|
||||
|
||||
Verifies the merge semantics: only explicitly-set fields (model_fields_set)
|
||||
are written, nested BaseModels recurse without clobbering siblings, and
|
||||
explicit None clears a previously-set value.
|
||||
"""
|
||||
|
||||
from openai.types.realtime import RealtimeSessionCreateRequest
|
||||
from openai.types.realtime.session_update_event import SessionUpdateEvent
|
||||
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
|
||||
|
||||
def _parse_session(**session_fields) -> RealtimeSessionCreateRequest:
|
||||
"""Parse a session dict the same way the SDK does (tracks model_fields_set)."""
|
||||
session_fields.setdefault("type", "realtime")
|
||||
evt = SessionUpdateEvent.model_validate(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": session_fields,
|
||||
}
|
||||
)
|
||||
return evt.session # type: ignore[return-value]
|
||||
|
||||
|
||||
class TestApplySessionUpdate:
|
||||
def test_partial_update_preserves_untouched_fields(self):
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
instructions="Be a pirate",
|
||||
audio={"output": {"voice": "coral"}},
|
||||
tool_choice="auto",
|
||||
)
|
||||
)
|
||||
assert cfg.session.instructions == "Be a pirate"
|
||||
assert cfg.session.audio.output.voice == "coral"
|
||||
assert cfg.session.tool_choice == "auto"
|
||||
|
||||
cfg.apply_session_update(_parse_session(instructions="Be an astronaut"))
|
||||
assert cfg.session.instructions == "Be an astronaut"
|
||||
assert cfg.session.audio.output.voice == "coral"
|
||||
assert cfg.session.tool_choice == "auto"
|
||||
|
||||
def test_explicit_none_clears_field(self):
|
||||
"""Sending ``"turn_detection": null`` clears a previously-set value."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={"input": {"turn_detection": {"type": "server_vad"}}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.input.turn_detection is not None
|
||||
assert cfg.session.audio.input.turn_detection.type == "server_vad"
|
||||
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={"input": {"turn_detection": None}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.input.turn_detection is None
|
||||
|
||||
def test_nested_sibling_preserved(self):
|
||||
"""Updating audio.output.voice must not touch audio.input.turn_detection."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={
|
||||
"input": {"turn_detection": {"type": "server_vad", "threshold": 0.6}},
|
||||
"output": {"voice": "echo"},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.output.voice == "echo"
|
||||
assert cfg.session.audio.input.turn_detection.threshold == 0.6
|
||||
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={"output": {"voice": "shimmer"}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.output.voice == "shimmer"
|
||||
assert cfg.session.audio.input.turn_detection.type == "server_vad"
|
||||
assert cfg.session.audio.input.turn_detection.threshold == 0.6
|
||||
|
||||
def test_sequential_updates_accumulate(self):
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(_parse_session(instructions="Step 1"))
|
||||
cfg.apply_session_update(_parse_session(audio={"output": {"voice": "alloy"}}))
|
||||
cfg.apply_session_update(_parse_session(tool_choice="required"))
|
||||
|
||||
assert cfg.session.instructions == "Step 1"
|
||||
assert cfg.session.audio.output.voice == "alloy"
|
||||
assert cfg.session.tool_choice == "required"
|
||||
|
||||
def test_deep_nested_leaf_update(self):
|
||||
"""Changing only turn_detection.threshold preserves the rest."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={
|
||||
"input": {
|
||||
"turn_detection": {
|
||||
"type": "server_vad",
|
||||
"threshold": 0.5,
|
||||
"silence_duration_ms": 800,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.input.turn_detection.threshold == 0.5
|
||||
assert cfg.session.audio.input.turn_detection.silence_duration_ms == 800
|
||||
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
audio={"input": {"turn_detection": {"type": "server_vad", "threshold": 0.8}}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.audio.input.turn_detection.threshold == 0.8
|
||||
assert cfg.session.audio.input.turn_detection.silence_duration_ms == 800
|
||||
|
||||
def test_tools_replaced_wholesale(self):
|
||||
"""Tools is a list, not a BaseModel — the whole list is replaced."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
tools=[{"type": "function", "name": "get_weather"}],
|
||||
)
|
||||
)
|
||||
assert len(cfg.session.tools) == 1
|
||||
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
tools=[
|
||||
{"type": "function", "name": "get_weather"},
|
||||
{"type": "function", "name": "get_time"},
|
||||
],
|
||||
)
|
||||
)
|
||||
assert len(cfg.session.tools) == 2
|
||||
|
||||
def test_update_after_fresh_init(self):
|
||||
"""apply_session_update works on a freshly-constructed RuntimeConfig."""
|
||||
cfg = RuntimeConfig()
|
||||
cfg.apply_session_update(
|
||||
_parse_session(
|
||||
instructions="Hello",
|
||||
audio={"output": {"voice": "sage"}},
|
||||
)
|
||||
)
|
||||
assert cfg.session.instructions == "Hello"
|
||||
assert cfg.session.audio.output.voice == "sage"
|
||||
|
||||
def test_validator_ensures_audio_structure_on_init(self):
|
||||
"""Default construction guarantees audio.input and audio.output are not None."""
|
||||
cfg = RuntimeConfig()
|
||||
assert cfg.session.audio is not None
|
||||
assert cfg.session.audio.input is not None
|
||||
assert cfg.session.audio.output is not None
|
||||
@@ -0,0 +1,949 @@
|
||||
"""Integration tests for api.openai_realtime.websocket_router.
|
||||
|
||||
Uses Starlette's synchronous TestClient with WebSocket support to exercise
|
||||
the full FastAPI app produced by ``create_app``. Each test gets a fresh
|
||||
PipelineUnit pool (size 1, matching the single-session semantics of the
|
||||
old tests) so there is no cross-test state.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import subprocess
|
||||
import time
|
||||
import wave
|
||||
from queue import Empty, Queue
|
||||
from threading import Event as ThreadingEvent
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
import speech_to_speech.api.openai_realtime.websocket_router as router_module
|
||||
from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit
|
||||
from speech_to_speech.api.openai_realtime.service import CHUNK_SIZE_BYTES, RealtimeService
|
||||
from speech_to_speech.api.openai_realtime.websocket_router import create_app
|
||||
from speech_to_speech.pipeline.cancel_scope import CancelScope
|
||||
from speech_to_speech.pipeline.control import SESSION_END, PipelineControlMessage, is_control_message
|
||||
from speech_to_speech.pipeline.events import AssistantTextEvent, SpeechStartedEvent, TokenUsageEvent
|
||||
from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END, AudioOutput
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def short_drain_timeout(monkeypatch):
|
||||
"""Shorten the SESSION_END drain warning threshold so tests don't wait 10s.
|
||||
|
||||
The constant only controls when the release task logs a warning about a
|
||||
slow-draining unit. The quarantine timeout
|
||||
(SESSION_END_QUARANTINE_TIMEOUT_S) is left at its real value so units
|
||||
stay unavailable until SESSION_END actually drains; tests that exercise the
|
||||
quarantine shorten it themselves.
|
||||
"""
|
||||
monkeypatch.setattr(router_module, "SESSION_END_DRAIN_TIMEOUT_S", 0.1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup():
|
||||
"""Return (app, service, input_queue, output_queue, text_output_queue,
|
||||
should_listen, stop_event, response_playing, cancel_scope) for a pool of one.
|
||||
|
||||
There is no real handler chain in this fixture, so SESSION_END enqueued by
|
||||
the route handler on disconnect never reaches output_queue. Tests that need
|
||||
the release task to complete (verifying unit.session is cleared and the
|
||||
service unregistered) must drain SESSION_END themselves — see
|
||||
`_simulate_session_end_drain` below.
|
||||
"""
|
||||
text_prompt_queue: Queue = Queue()
|
||||
should_listen = ThreadingEvent()
|
||||
should_listen.set()
|
||||
service = RealtimeService(
|
||||
text_prompt_queue=text_prompt_queue,
|
||||
should_listen=should_listen,
|
||||
)
|
||||
input_queue: Queue = Queue()
|
||||
output_queue: Queue = Queue()
|
||||
text_output_queue: Queue = Queue()
|
||||
stop_event = ThreadingEvent()
|
||||
response_playing = ThreadingEvent()
|
||||
cancel_scope = CancelScope()
|
||||
unit = PipelineUnit(
|
||||
index=0,
|
||||
service=service,
|
||||
cancel_scope=cancel_scope,
|
||||
should_listen=should_listen,
|
||||
response_playing=response_playing,
|
||||
input_queue=input_queue,
|
||||
output_queue=output_queue,
|
||||
text_output_queue=text_output_queue,
|
||||
text_prompt_queue=text_prompt_queue,
|
||||
handlers=[],
|
||||
)
|
||||
app = create_app(pool=[unit], stop_event=stop_event)
|
||||
return (
|
||||
app,
|
||||
service,
|
||||
input_queue,
|
||||
output_queue,
|
||||
text_output_queue,
|
||||
should_listen,
|
||||
stop_event,
|
||||
response_playing,
|
||||
cancel_scope,
|
||||
unit,
|
||||
)
|
||||
|
||||
|
||||
def _simulate_session_end_drain(input_queue: Queue, output_queue: Queue, timeout: float = 1.0) -> None:
|
||||
"""Wait for SESSION_END to land in input_queue (from the route handler's
|
||||
release path) and forward it to output_queue — simulating the handler chain.
|
||||
The send loop will then observe SESSION_END and set `session.drained`,
|
||||
letting the release task complete (unregister + clear `unit.session`).
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
item = input_queue.get(timeout=0.05)
|
||||
except Empty:
|
||||
continue
|
||||
if isinstance(item, PipelineControlMessage) and is_control_message(item, SESSION_END.kind):
|
||||
output_queue.put(item)
|
||||
return
|
||||
raise AssertionError("SESSION_END did not appear on input_queue within timeout")
|
||||
|
||||
|
||||
def _pcm_bytes(n_samples: int) -> bytes:
|
||||
return b"\x00" * (n_samples * 2)
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
application_state = WebSocketState.CONNECTED
|
||||
|
||||
def __init__(self):
|
||||
self.sent: list[dict] = []
|
||||
|
||||
async def send_json(self, payload: dict) -> None:
|
||||
self.sent.append(payload)
|
||||
|
||||
|
||||
class Qwen3TTSHandler:
|
||||
def __init__(self, chunks=None):
|
||||
self.chunks = chunks or [np.array([0, 1000, -1000], dtype=np.int16)]
|
||||
|
||||
def process(self, _tts_input):
|
||||
yield from self.chunks
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Connection
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestConnection:
|
||||
def test_connect_receives_session_created(self, setup):
|
||||
app, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "session.created"
|
||||
assert msg["event_id"].startswith("event_")
|
||||
assert "session" in msg
|
||||
|
||||
def test_second_connection_rejected(self, setup):
|
||||
app, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws1:
|
||||
ws1.receive_json() # session.created
|
||||
with client.websocket_connect("/v1/realtime") as ws2:
|
||||
msg = ws2.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
# Rejection uses the stateless build_error_event helper —
|
||||
# the error type identifies pool exhaustion specifically.
|
||||
assert msg["error"]["type"] == "session_limit_reached"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Client event dispatch
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestClientEventDispatch:
|
||||
def test_audio_append_forwarded_to_input_queue(self, setup):
|
||||
app, _, input_queue, *_ = setup
|
||||
audio_b64 = base64.b64encode(_pcm_bytes(512)).decode("ascii")
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": audio_b64,
|
||||
}
|
||||
)
|
||||
time.sleep(0.1)
|
||||
item = input_queue.get(timeout=1)
|
||||
assert isinstance(item, tuple) and len(item) == 2
|
||||
chunk, rt_cfg = item
|
||||
assert isinstance(chunk, bytes)
|
||||
assert len(chunk) == CHUNK_SIZE_BYTES
|
||||
|
||||
def test_session_update_applied(self, setup):
|
||||
app, service, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"type": "realtime",
|
||||
"audio": {"output": {"voice": "coral"}},
|
||||
},
|
||||
}
|
||||
)
|
||||
time.sleep(0.1)
|
||||
cid = service.connection_ids[0]
|
||||
assert service._state(cid).runtime_config.session.audio.output.voice == "coral"
|
||||
|
||||
def test_conversation_item_create_returns_events(self, setup):
|
||||
app, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "ping"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "conversation.item.created"
|
||||
assert msg["item"]["content"][0]["text"] == "ping"
|
||||
|
||||
def test_response_create_error_when_active(self, setup):
|
||||
app, service, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
ws.send_json({"type": "response.create"})
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
assert "another response is in progress" in msg["error"]["message"].lower()
|
||||
|
||||
def test_response_cancel_returns_events(self, setup):
|
||||
app, service, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
ws.send_json({"type": "response.cancel"})
|
||||
msg1 = ws.receive_json()
|
||||
msg2 = ws.receive_json()
|
||||
types = {msg1["type"], msg2["type"]}
|
||||
assert "response.output_audio.done" in types
|
||||
assert "response.done" in types
|
||||
|
||||
def test_response_cancel_flushes_queues(self, setup):
|
||||
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
text_output_queue.put(AssistantTextEvent(text="stale"))
|
||||
ws.send_json({"type": "response.cancel"})
|
||||
ws.receive_json() # response.output_audio.done
|
||||
ws.receive_json() # response.done
|
||||
time.sleep(0.1)
|
||||
assert output_queue.empty()
|
||||
assert text_output_queue.empty()
|
||||
assert not response_playing.is_set()
|
||||
assert cancel_scope.discarding
|
||||
|
||||
def test_response_cancel_spurious_does_not_set_discarding(self, setup):
|
||||
"""response.cancel when no response is active must NOT enable discarding,
|
||||
otherwise it would stick True forever (no __RESPONSE_DONE__ to clear it)."""
|
||||
app, service, _, _, _, _, _, _, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
assert not service._state(list(service._conns.keys())[0]).in_response
|
||||
ws.send_json({"type": "response.cancel"})
|
||||
time.sleep(0.1)
|
||||
assert not cancel_scope.discarding
|
||||
|
||||
def test_response_cancel_late_audio_is_discarded(self, setup):
|
||||
"""Audio arriving after response.cancel is silently dropped (discard guard)."""
|
||||
app, service, _, output_queue, _, _, _, response_playing, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
ws.send_json({"type": "response.cancel"})
|
||||
ws.receive_json() # response.output_audio.done
|
||||
ws.receive_json() # response.done
|
||||
time.sleep(0.1)
|
||||
assert cancel_scope.discarding
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
time.sleep(0.15)
|
||||
# No response.created or audio delta should appear; only
|
||||
# __RESPONSE_DONE__ will eventually clear the guard.
|
||||
output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
time.sleep(0.15)
|
||||
assert not cancel_scope.discarding
|
||||
|
||||
def test_unknown_event_returns_error(self, setup):
|
||||
app, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
ws.send_json({"type": "bogus.event"})
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Send loop (pipeline -> client)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSendLoop:
|
||||
def test_pending_response_sends_delay_filler_after_threshold(self, setup):
|
||||
app, service, _, _, _, _, _, _, _, unit = setup
|
||||
unit.delay_filler_threshold_s = 0.01
|
||||
unit.delay_filler_clips = [_pcm_bytes(256)]
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
state = service._state(conn_id)
|
||||
state.response_pending = True
|
||||
state.response_audio_wait_started_at_s = time.perf_counter() - 1.0
|
||||
|
||||
msg1 = ws.receive_json()
|
||||
msg2 = ws.receive_json()
|
||||
|
||||
assert msg1["type"] == "response.created"
|
||||
assert msg2["type"] == "response.output_audio.delta"
|
||||
assert state.delay_filler_sent is True
|
||||
assert state.response_pending is False
|
||||
|
||||
def test_audio_output_ignores_session_end_control_message(self, setup):
|
||||
app, _, _, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
output_queue.put(SESSION_END)
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
|
||||
msg1 = ws.receive_json()
|
||||
assert msg1["type"] == "response.created"
|
||||
msg2 = ws.receive_json()
|
||||
assert msg2["type"] == "response.output_audio.delta"
|
||||
|
||||
def test_audio_output_sends_response_created_and_delta(self, setup):
|
||||
app, _, _, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
msg1 = ws.receive_json()
|
||||
assert msg1["type"] == "response.created"
|
||||
assert msg1["response"]["status"] == "in_progress"
|
||||
msg2 = ws.receive_json()
|
||||
assert msg2["type"] == "response.output_audio.delta"
|
||||
assert "delta" in msg2
|
||||
|
||||
def test_audio_output_batches_immediately_available_chunks(self, setup):
|
||||
app, _, _, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
output_queue.put(PIPELINE_END)
|
||||
|
||||
msg1 = ws.receive_json()
|
||||
assert msg1["type"] == "response.created"
|
||||
|
||||
msg2 = ws.receive_json()
|
||||
assert msg2["type"] == "response.output_audio.delta"
|
||||
decoded = base64.b64decode(msg2["delta"])
|
||||
assert len(decoded) == len(_pcm_bytes(512))
|
||||
|
||||
msg3 = ws.receive_json()
|
||||
msg4 = ws.receive_json()
|
||||
types = {msg3["type"], msg4["type"]}
|
||||
assert "response.output_audio.done" in types
|
||||
assert "response.done" in types
|
||||
|
||||
def test_end_marker_sends_finish_events(self, setup):
|
||||
app, _, _, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
ws.receive_json() # response.created
|
||||
ws.receive_json() # audio delta
|
||||
output_queue.put(PIPELINE_END)
|
||||
msg1 = ws.receive_json()
|
||||
msg2 = ws.receive_json()
|
||||
types = {msg1["type"], msg2["type"]}
|
||||
assert "response.output_audio.done" in types
|
||||
assert "response.done" in types
|
||||
|
||||
def test_text_output_sends_pipeline_events(self, setup):
|
||||
app, _, _, _, text_output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "input_audio_buffer.speech_started"
|
||||
assert msg["audio_start_ms"] == 0
|
||||
|
||||
def test_barge_in_discard_clears_after_response_done(self, setup):
|
||||
"""After barge-in sets discarding=True, __RESPONSE_DONE__ must clear it back to False."""
|
||||
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
# Trigger barge-in
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
ws.receive_json() # input_audio_buffer.speech_started
|
||||
ws.receive_json() # response.output_audio.done
|
||||
ws.receive_json() # response.done
|
||||
time.sleep(0.1)
|
||||
assert cancel_scope.discarding
|
||||
output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
time.sleep(0.15)
|
||||
assert not cancel_scope.discarding
|
||||
|
||||
def test_speech_started_cancels_pending_implicit_response(self, setup):
|
||||
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
stale_generation = cancel_scope.generation
|
||||
service._state(conn_id).response_pending = True
|
||||
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
msg = ws.receive_json()
|
||||
|
||||
assert msg["type"] == "input_audio_buffer.speech_started"
|
||||
time.sleep(0.15)
|
||||
assert cancel_scope.discarding
|
||||
assert cancel_scope.generation == stale_generation + 1
|
||||
assert service._state(conn_id).response_pending is False
|
||||
assert service._state(conn_id).in_response is False
|
||||
assert not response_playing.is_set()
|
||||
|
||||
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=stale_generation))
|
||||
time.sleep(0.15)
|
||||
assert not cancel_scope.discarding
|
||||
|
||||
def test_speech_started_does_not_cancel_pending_when_internal_non_interrupt(self, setup):
|
||||
app, service, _, _, text_output_queue, _, _, _, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service._state(conn_id).response_pending = True
|
||||
|
||||
text_output_queue.put(SpeechStartedEvent(interrupt_response=False))
|
||||
msg = ws.receive_json()
|
||||
|
||||
assert msg["type"] == "input_audio_buffer.speech_started"
|
||||
time.sleep(0.15)
|
||||
assert not cancel_scope.discarding
|
||||
assert service._state(conn_id).response_pending is True
|
||||
|
||||
def test_stale_tagged_audio_is_dropped_after_interruption(self, setup):
|
||||
app, _, _, output_queue, _, _, _, _, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
stale_generation = cancel_scope.generation
|
||||
cancel_scope.cancel()
|
||||
current_generation = cancel_scope.generation
|
||||
output_queue.put(AudioOutput(audio=_pcm_bytes(64), cancel_generation=stale_generation))
|
||||
output_queue.put(AudioOutput(audio=_pcm_bytes(512), cancel_generation=current_generation))
|
||||
|
||||
assert ws.receive_json()["type"] == "response.created"
|
||||
delta = ws.receive_json()
|
||||
|
||||
assert delta["type"] == "response.output_audio.delta"
|
||||
assert len(base64.b64decode(delta["delta"])) == len(_pcm_bytes(512))
|
||||
|
||||
def test_current_generation_text_survives_stuck_discarding(self, setup):
|
||||
"""Regression: a fresh response's transcript must survive a stuck discard guard.
|
||||
|
||||
A superseded speculative turn can leave ``cancel_scope.discarding`` stuck True
|
||||
(its TTS dropped the stale ``EndOfResponse`` without emitting AUDIO_RESPONSE_DONE,
|
||||
so ``response_done()`` never cleared the flag). The next response's audio is tagged
|
||||
with the current generation and streams fine, but the assistant text used to be
|
||||
blanket-dropped while discarding — leaving audio + ``response.done`` with no
|
||||
``response.output_audio_transcript.done``. The text is now discarded by the same
|
||||
generation-aware rule as audio, so a current-generation transcript is kept.
|
||||
"""
|
||||
app, _, _, output_queue, text_output_queue, _, _, _, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
cancel_scope.cancel() # discarding=True, generation bumped; sentinel never arrived
|
||||
current_generation = cancel_scope.generation
|
||||
assert cancel_scope.discarding
|
||||
|
||||
text_output_queue.put(AssistantTextEvent(text="hello there", cancel_generation=current_generation))
|
||||
output_queue.put(AudioOutput(audio=_pcm_bytes(256), cancel_generation=current_generation))
|
||||
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=current_generation))
|
||||
|
||||
types: list[str] = []
|
||||
transcript = None
|
||||
for _ in range(8):
|
||||
msg = ws.receive_json()
|
||||
types.append(msg["type"])
|
||||
if msg["type"] == "response.output_audio_transcript.done":
|
||||
transcript = msg["transcript"]
|
||||
if msg["type"] == "response.done":
|
||||
break
|
||||
assert "response.output_audio_transcript.done" in types
|
||||
assert transcript == "hello there"
|
||||
|
||||
def test_stale_tagged_response_done_does_not_finish_current_response(self, setup):
|
||||
app, service, _, output_queue, _, _, _, _, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
stale_generation = cancel_scope.generation
|
||||
service.response._ensure_response(conn_id)
|
||||
service.finish_response(conn_id, status="cancelled")
|
||||
cancel_scope.cancel()
|
||||
current_response_id, _ = service.response._ensure_response(conn_id)
|
||||
|
||||
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=stale_generation))
|
||||
time.sleep(0.15)
|
||||
|
||||
state = service._state(conn_id)
|
||||
assert state.in_response
|
||||
assert state.current_response_id == current_response_id
|
||||
|
||||
def test_response_done_drains_pending_token_usage_before_finish(self, setup):
|
||||
app, service, _, output_queue, text_output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
|
||||
text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="",
|
||||
tools=[{"type": "function_call", "call_id": "c1", "name": "f1", "arguments": "{}"}],
|
||||
)
|
||||
)
|
||||
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
|
||||
output_queue.put(AUDIO_RESPONSE_DONE)
|
||||
|
||||
assert ws.receive_json()["type"] == "response.function_call_arguments.done"
|
||||
msg1 = ws.receive_json()
|
||||
msg2 = ws.receive_json()
|
||||
assert {msg1["type"], msg2["type"]} == {"response.output_audio.done", "response.done"}
|
||||
|
||||
assert service.total_usage.input_tokens == 10
|
||||
assert service.total_usage.output_tokens == 5
|
||||
assert service._state(conn_id).response_usage.input_tokens == 0
|
||||
assert service._state(conn_id).response_usage.output_tokens == 0
|
||||
|
||||
def test_response_completion_drain_sends_pending_tool_before_done(self, setup):
|
||||
_, service, input_queue, output_queue, text_output_queue, should_listen, _, response_playing, cancel_scope, _ = (
|
||||
setup
|
||||
)
|
||||
unit = PipelineUnit(
|
||||
index=0,
|
||||
service=service,
|
||||
cancel_scope=cancel_scope,
|
||||
should_listen=should_listen,
|
||||
response_playing=response_playing,
|
||||
input_queue=input_queue,
|
||||
output_queue=output_queue,
|
||||
text_output_queue=text_output_queue,
|
||||
text_prompt_queue=Queue(),
|
||||
handlers=[],
|
||||
)
|
||||
conn_id = service.register()
|
||||
response_id, _ = service.response._ensure_response(conn_id)
|
||||
text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="",
|
||||
tools=[
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"name": "play_emotion",
|
||||
"arguments": '{"emotion":"loving"}',
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
|
||||
ws = _FakeWebSocket()
|
||||
|
||||
asyncio.run(router_module._drain_pending_response_events(ws, unit, conn_id))
|
||||
done_events = service.finish_response(conn_id)
|
||||
|
||||
assert [payload["type"] for payload in ws.sent] == ["response.function_call_arguments.done"]
|
||||
assert [event.type for event in done_events] == ["response.output_audio.done", "response.done"]
|
||||
assert ws.sent[0]["response_id"] == response_id
|
||||
assert done_events[1].response.id == response_id
|
||||
assert done_events[1].response.usage.input_tokens == 10
|
||||
assert done_events[1].response.usage.output_tokens == 5
|
||||
assert text_output_queue.empty()
|
||||
|
||||
def test_response_completion_drain_preserves_usage_across_non_response_boundary(self, setup):
|
||||
_, service, input_queue, output_queue, text_output_queue, should_listen, _, response_playing, cancel_scope = (
|
||||
setup
|
||||
)
|
||||
unit = PipelineUnit(
|
||||
index=0,
|
||||
service=service,
|
||||
cancel_scope=cancel_scope,
|
||||
should_listen=should_listen,
|
||||
response_playing=response_playing,
|
||||
input_queue=input_queue,
|
||||
output_queue=output_queue,
|
||||
text_output_queue=text_output_queue,
|
||||
text_prompt_queue=Queue(),
|
||||
handlers=[],
|
||||
)
|
||||
conn_id = service.register()
|
||||
response_id, _ = service.response._ensure_response(conn_id)
|
||||
text_output_queue.put(
|
||||
AssistantTextEvent(
|
||||
text="",
|
||||
tools=[{"type": "function_call", "call_id": "c1", "name": "play_emotion", "arguments": "{}"}],
|
||||
)
|
||||
)
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
|
||||
text_output_queue.put(AssistantTextEvent(text="queued after boundary"))
|
||||
ws = _FakeWebSocket()
|
||||
|
||||
asyncio.run(router_module._drain_pending_response_events(ws, unit, conn_id))
|
||||
done_events = service.finish_response(conn_id)
|
||||
|
||||
assert [payload["type"] for payload in ws.sent] == ["response.function_call_arguments.done"]
|
||||
assert ws.sent[0]["response_id"] == response_id
|
||||
assert done_events[1].response.usage.input_tokens == 10
|
||||
assert done_events[1].response.usage.output_tokens == 5
|
||||
|
||||
boundary = text_output_queue.get_nowait()
|
||||
queued_assistant = text_output_queue.get_nowait()
|
||||
assert isinstance(boundary, SpeechStartedEvent)
|
||||
assert isinstance(queued_assistant, AssistantTextEvent)
|
||||
assert queued_assistant.text == "queued after boundary"
|
||||
assert text_output_queue.empty()
|
||||
|
||||
def test_speech_started_does_not_cancel_when_interrupt_disabled(self, setup):
|
||||
"""With interrupt_response=False, speech during playback should NOT cancel or flush."""
|
||||
from openai.types.realtime.realtime_audio_input_turn_detection import ServerVad
|
||||
|
||||
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json() # session.created
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service._state(conn_id).runtime_config.session.audio.input.turn_detection = ServerVad(
|
||||
type="server_vad",
|
||||
interrupt_response=False,
|
||||
)
|
||||
_, response_item_id = service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
text_output_queue.put(SpeechStartedEvent())
|
||||
msg = ws.receive_json()
|
||||
assert msg["type"] == "input_audio_buffer.speech_started"
|
||||
time.sleep(0.15)
|
||||
assert response_playing.is_set(), "response_playing should remain set"
|
||||
assert not cancel_scope.discarding, "cancel_scope should not be discarding"
|
||||
assert service._state(conn_id).in_response, "response should still be active"
|
||||
assert service._state(conn_id).current_item_id == response_item_id
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Cleanup
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
def test_new_connection_resets_discard_after_invalidating_generation(self, setup):
|
||||
"""connect-time _clean_unit cancels+resets: stale work is invalidated, discarding cleared."""
|
||||
app, _, *_rest, cancel_scope, _ = setup
|
||||
cancel_scope.cancel()
|
||||
assert cancel_scope.discarding
|
||||
assert cancel_scope.generation == 1
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
assert not cancel_scope.discarding
|
||||
assert cancel_scope.generation == 2
|
||||
|
||||
def test_disconnect_bumps_cancel_scope_generation(self, setup):
|
||||
"""_clean_unit() on disconnect calls cancel() so in-flight generations go stale."""
|
||||
app, _, _, _, _, _, _, _, cancel_scope, _ = setup
|
||||
assert cancel_scope.generation == 0
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
assert cancel_scope.generation == 1
|
||||
# disconnect triggers _clean_unit again + drain (short timeout in tests)
|
||||
time.sleep(0.3)
|
||||
assert cancel_scope.generation == 2
|
||||
|
||||
def test_disconnect_unregisters(self, setup):
|
||||
app, service, input_queue, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
assert len(service._conns) == 1
|
||||
# Simulate the handler chain consuming SESSION_END so the release
|
||||
# task can complete and unregister the session.
|
||||
_simulate_session_end_drain(input_queue, output_queue)
|
||||
time.sleep(0.3)
|
||||
assert len(service._conns) == 0
|
||||
|
||||
def test_last_disconnect_cancels_and_clears_response_state(self, setup):
|
||||
app, service, input_queue, output_queue, text_output_queue, _, _, response_playing, cancel_scope, _ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
conn_id = list(service._conns.keys())[0]
|
||||
service.response._ensure_response(conn_id)
|
||||
response_playing.set()
|
||||
output_queue.put(_pcm_bytes(256))
|
||||
text_output_queue.put(AssistantTextEvent(text="stale"))
|
||||
_simulate_session_end_drain(input_queue, output_queue)
|
||||
time.sleep(0.3)
|
||||
|
||||
assert not cancel_scope.discarding
|
||||
assert cancel_scope.generation == 2
|
||||
assert not response_playing.is_set()
|
||||
assert text_output_queue.empty()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Drain / release robustness
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDrainRelease:
|
||||
def test_barge_in_flush_preserves_session_end(self):
|
||||
"""The output_queue flush on barge-in must not swallow an in-flight
|
||||
SESSION_END — losing it would leave the release task waiting forever."""
|
||||
q: Queue = Queue()
|
||||
q.put(_pcm_bytes(10))
|
||||
q.put(PipelineControlMessage(SESSION_END.kind, session_id="sess_a"))
|
||||
q.put(_pcm_bytes(10))
|
||||
router_module._flush_queue(q, preserve=router_module._keep_audio_sentinel)
|
||||
assert is_control_message(q.get_nowait(), SESSION_END.kind)
|
||||
assert q.empty()
|
||||
|
||||
def test_quarantine_keeps_unit_unclaimable_when_session_end_never_drains(self, setup, monkeypatch):
|
||||
"""With no handler chain, SESSION_END never reaches output_queue; past
|
||||
the quarantine timeout the session is unregistered (no more chat
|
||||
mutation or billing) but the unit must NOT become claimable — its
|
||||
handlers could still emit the old session's output."""
|
||||
monkeypatch.setattr(router_module, "SESSION_END_QUARANTINE_TIMEOUT_S", 0.2)
|
||||
app, service, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
time.sleep(0.8)
|
||||
assert len(service._conns) == 0
|
||||
pool = client.get("/v1/pool").json()
|
||||
assert pool["in_use"] == 1
|
||||
assert pool["units"][0]["state"] == "stuck"
|
||||
assert pool["units"][0]["stuck_for_s"] >= 0
|
||||
with client.websocket_connect("/v1/realtime") as ws2:
|
||||
msg = ws2.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
assert msg["error"]["type"] == "session_limit_reached"
|
||||
|
||||
def test_quarantined_unit_returns_to_pool_after_late_drain(self, setup, monkeypatch):
|
||||
"""If SESSION_END eventually drains after the quarantine kicked in, the
|
||||
chain has proven itself clean and the unit becomes claimable again."""
|
||||
monkeypatch.setattr(router_module, "SESSION_END_QUARANTINE_TIMEOUT_S", 0.2)
|
||||
app, service, input_queue, output_queue, *_ = setup
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
time.sleep(0.8)
|
||||
assert client.get("/v1/pool").json()["units"][0]["state"] == "stuck"
|
||||
# Late drain: the wedged "handler chain" finally forwards SESSION_END.
|
||||
_simulate_session_end_drain(input_queue, output_queue)
|
||||
time.sleep(0.3)
|
||||
assert client.get("/v1/pool").json()["in_use"] == 0
|
||||
with client.websocket_connect("/v1/realtime") as ws2:
|
||||
assert ws2.receive_json()["type"] == "session.created"
|
||||
|
||||
def test_stale_session_end_does_not_satisfy_next_sessions_drain(self):
|
||||
"""A SESSION_END tagged with a force-released session's id must not set
|
||||
`drained` for the session that claimed the unit afterwards."""
|
||||
unit = _make_unit(0)
|
||||
app = create_app(pool=[unit], stop_event=ThreadingEvent())
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws:
|
||||
ws.receive_json()
|
||||
assert unit.session is not None
|
||||
unit.output_queue.put(PipelineControlMessage(SESSION_END.kind, session_id="sess_stale"))
|
||||
time.sleep(0.3)
|
||||
assert not unit.session.drained.is_set()
|
||||
unit.output_queue.put(PipelineControlMessage(SESSION_END.kind, session_id=unit.session.session_id))
|
||||
time.sleep(0.3)
|
||||
assert unit.session.drained.is_set()
|
||||
|
||||
def test_register_failure_still_releases_unit(self, setup, monkeypatch):
|
||||
"""An exception during session setup (after the claim) must not leak the
|
||||
slot: the finally still enqueues SESSION_END and spawns the release task."""
|
||||
app, service, input_queue, output_queue, *_ = setup
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("register failed")
|
||||
|
||||
monkeypatch.setattr(service, "register", _boom)
|
||||
with TestClient(app) as client:
|
||||
try:
|
||||
with client.websocket_connect("/v1/realtime"):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
_simulate_session_end_drain(input_queue, output_queue)
|
||||
time.sleep(0.3)
|
||||
assert client.get("/v1/pool").json()["in_use"] == 0
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Pool semantics (new in pool refactor)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
def _make_unit(index: int, handlers=None) -> PipelineUnit:
|
||||
text_prompt_queue: Queue = Queue()
|
||||
should_listen = ThreadingEvent()
|
||||
should_listen.set()
|
||||
return PipelineUnit(
|
||||
index=index,
|
||||
service=RealtimeService(text_prompt_queue=text_prompt_queue, should_listen=should_listen),
|
||||
cancel_scope=CancelScope(),
|
||||
should_listen=should_listen,
|
||||
response_playing=ThreadingEvent(),
|
||||
input_queue=Queue(),
|
||||
output_queue=Queue(),
|
||||
text_output_queue=Queue(),
|
||||
text_prompt_queue=text_prompt_queue,
|
||||
handlers=handlers or [],
|
||||
)
|
||||
|
||||
|
||||
class TestPool:
|
||||
def test_pool_endpoint_reports_idle_state(self):
|
||||
pool = [_make_unit(0), _make_unit(1)]
|
||||
app = create_app(pool=pool, stop_event=ThreadingEvent())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/v1/pool")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["size"] == 2
|
||||
assert data["in_use"] == 0
|
||||
assert [u["session_id"] for u in data["units"]] == [None, None]
|
||||
|
||||
def test_two_clients_claim_two_slots_third_rejected(self):
|
||||
pool = [_make_unit(0), _make_unit(1)]
|
||||
app = create_app(pool=pool, stop_event=ThreadingEvent())
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/v1/realtime") as ws1:
|
||||
ws1.receive_json() # session.created
|
||||
with client.websocket_connect("/v1/realtime") as ws2:
|
||||
ws2.receive_json() # session.created (different unit)
|
||||
with client.websocket_connect("/v1/realtime") as ws3:
|
||||
msg = ws3.receive_json()
|
||||
assert msg["type"] == "error"
|
||||
assert msg["error"]["type"] == "session_limit_reached"
|
||||
# Pool now reports 2 in_use
|
||||
r = client.get("/v1/pool")
|
||||
assert r.json()["in_use"] == 2
|
||||
|
||||
def test_usage_aggregates_errors_by_type_across_units(self):
|
||||
pool = [_make_unit(0), _make_unit(1)]
|
||||
pool[0].service.total_usage.record_error("foo")
|
||||
pool[0].service.total_usage.record_error("foo")
|
||||
pool[1].service.total_usage.record_error("bar")
|
||||
app = create_app(pool=pool, stop_event=ThreadingEvent())
|
||||
with TestClient(app) as client:
|
||||
data = client.get("/v1/usage").json()
|
||||
assert data["errors_by_type"] == {"foo": 2, "bar": 1}
|
||||
assert data["total_errors"] == 3
|
||||
|
||||
|
||||
class TestHttpSpeech:
|
||||
def test_audio_speech_wav_returns_wav_payload(self):
|
||||
pool = [_make_unit(0, handlers=[Qwen3TTSHandler()])]
|
||||
app = create_app(pool=pool, stop_event=ThreadingEvent())
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/v1/audio/speech", json={"input": "Hola", "response_format": "wav"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("audio/wav")
|
||||
with wave.open(io.BytesIO(response.content), "rb") as wav_file:
|
||||
assert wav_file.getnchannels() == 1
|
||||
assert wav_file.getframerate() == 16000
|
||||
assert wav_file.readframes(3)
|
||||
|
||||
def test_audio_speech_mp3_uses_ffmpeg_encoder(self, monkeypatch):
|
||||
pool = [_make_unit(0, handlers=[Qwen3TTSHandler()])]
|
||||
app = create_app(pool=pool, stop_event=ThreadingEvent())
|
||||
|
||||
monkeypatch.setattr(router_module.shutil, "which", lambda _name: "/usr/bin/ffmpeg")
|
||||
monkeypatch.setattr(
|
||||
router_module.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: subprocess.CompletedProcess(args[0], 0, stdout=b"fake-mp3", stderr=b""),
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/v1/audio/speech", json={"input": "Hola", "response_format": "mp3"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("audio/mpeg")
|
||||
assert response.content == b"fake-mp3"
|
||||
|
||||
def test_audio_speech_returns_503_when_qwen_slot_busy(self):
|
||||
unit = _make_unit(0, handlers=[Qwen3TTSHandler()])
|
||||
unit.http_tts_reserved = True
|
||||
app = create_app(pool=[unit], stop_event=ThreadingEvent())
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/v1/audio/speech", json={"input": "Hola"})
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "All Qwen TTS pipeline slots are currently busy."
|
||||
+1280
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
"""Unit tests for the chat-completions LLM backend.
|
||||
|
||||
These run without a GPU or a live server: the OpenAI client is faked at the
|
||||
module level, so the streaming/non-streaming parse logic and the format
|
||||
converters are exercised purely in-process.
|
||||
|
||||
Run with pytest, or standalone: python tests/test_chat_completions_backend.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openai.types.realtime.conversation_item import (
|
||||
RealtimeConversationItemFunctionCall,
|
||||
RealtimeConversationItemFunctionCallOutput,
|
||||
RealtimeConversationItemUserMessage,
|
||||
)
|
||||
from openai.types.realtime.realtime_conversation_item_user_message import Content as UserContent
|
||||
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
|
||||
from openai.types.realtime.realtime_session_create_request import RealtimeSessionCreateRequest
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
import speech_to_speech.LLM.base_openai_compatible_language_model as base_mod
|
||||
import speech_to_speech.LLM.chat_completions_language_model as ccm
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
from speech_to_speech.LLM.chat import Chat, make_user_message
|
||||
from speech_to_speech.LLM.chat_completions_language_model import (
|
||||
ChatCompletionsApiModelHandler,
|
||||
_to_chat_tool_choice,
|
||||
_to_chat_tools,
|
||||
)
|
||||
from speech_to_speech.pipeline.messages import (
|
||||
EndOfResponse,
|
||||
GenerateResponseRequest,
|
||||
LLMResponseChunk,
|
||||
TokenUsage,
|
||||
)
|
||||
|
||||
# ── Fakes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""Iterable stand-in for openai.Stream; yields preset chunks."""
|
||||
|
||||
def __init__(self, chunks):
|
||||
self._chunks = chunks
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._chunks)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
# Make the handler's ``isinstance(resp, Stream)`` check recognise our fake as a
|
||||
# stream. Non-streaming fakes stay plain SimpleNamespace, so they still take the
|
||||
# non-stream branch.
|
||||
ccm.Stream = _FakeStream
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
def __init__(self):
|
||||
self.next_result = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="ok", tool_calls=[]))],
|
||||
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1),
|
||||
)
|
||||
self.last_kwargs = None
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.last_kwargs = kwargs
|
||||
return self.next_result
|
||||
|
||||
|
||||
class _FakeChat:
|
||||
def __init__(self):
|
||||
self.completions = _FakeCompletions()
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
self.chat = _FakeChat()
|
||||
self.last_options = None
|
||||
|
||||
def with_options(self, **kwargs):
|
||||
self.last_options = kwargs
|
||||
return self
|
||||
|
||||
|
||||
def _make_handler(stream=True):
|
||||
"""Build a handler whose warmup hits the fake client (no network)."""
|
||||
orig_openai = base_mod.OpenAI
|
||||
base_mod.OpenAI = _FakeClient
|
||||
try:
|
||||
h = ChatCompletionsApiModelHandler(
|
||||
threading.Event(),
|
||||
queue.Queue(),
|
||||
queue.Queue(),
|
||||
setup_kwargs=dict(
|
||||
model_name="test-model",
|
||||
base_url="http://fake/v1",
|
||||
api_key="k",
|
||||
stream=stream,
|
||||
disable_thinking=True,
|
||||
compact_history=False,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
base_mod.OpenAI = orig_openai
|
||||
return h
|
||||
|
||||
|
||||
def test_warmup_uses_request_scoped_sdk_retries():
|
||||
handler = _make_handler()
|
||||
|
||||
assert handler.client.last_options == {"max_retries": base_mod.WARMUP_MAX_RETRIES}
|
||||
|
||||
|
||||
def _chunk(content=None, tool_calls=None, usage=None):
|
||||
choices = []
|
||||
if content is not None or tool_calls is not None:
|
||||
choices = [SimpleNamespace(delta=SimpleNamespace(content=content, tool_calls=tool_calls), finish_reason=None)]
|
||||
return SimpleNamespace(choices=choices, usage=usage)
|
||||
|
||||
|
||||
def _tc_delta(index, id=None, name=None, arguments=None):
|
||||
return SimpleNamespace(index=index, id=id, function=SimpleNamespace(name=name, arguments=arguments))
|
||||
|
||||
|
||||
def _drive(
|
||||
handler,
|
||||
*,
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
user="Hallo",
|
||||
chat=None,
|
||||
response=None,
|
||||
instructions="Du bist ein Roboter.",
|
||||
):
|
||||
chat = chat or Chat(10)
|
||||
if user:
|
||||
chat.add_item(make_user_message(user))
|
||||
session = RealtimeSessionCreateRequest(type="realtime", instructions=instructions)
|
||||
if tools is not None:
|
||||
session.tools = tools
|
||||
if tool_choice is not None:
|
||||
session.tool_choice = tool_choice
|
||||
rc = RuntimeConfig(chat=chat, session=session)
|
||||
req = GenerateResponseRequest(
|
||||
runtime_config=rc, response=response, language_code="de", turn_id="t", turn_revision=0
|
||||
)
|
||||
text, tools_out, usage, end = "", [], None, None
|
||||
for out in handler.process(req):
|
||||
if isinstance(out, LLMResponseChunk):
|
||||
text += out.text
|
||||
tools_out += list(out.tools)
|
||||
elif isinstance(out, TokenUsage):
|
||||
usage = (out.input_tokens, out.output_tokens)
|
||||
elif isinstance(out, EndOfResponse):
|
||||
end = out
|
||||
return text, tools_out, usage, chat, end
|
||||
|
||||
|
||||
# ── Converter tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_to_chat_tools_flat_to_nested():
|
||||
out = _to_chat_tools([{"type": "function", "name": "f", "description": "d", "parameters": {"type": "object"}}])
|
||||
assert out == [
|
||||
{"type": "function", "function": {"name": "f", "description": "d", "parameters": {"type": "object"}}}
|
||||
]
|
||||
|
||||
|
||||
def test_to_chat_tools_passthrough_and_none():
|
||||
nested = [{"type": "function", "function": {"name": "f"}}]
|
||||
assert _to_chat_tools(nested) == nested
|
||||
assert _to_chat_tools(None) is None
|
||||
assert _to_chat_tools([]) is None
|
||||
|
||||
|
||||
def test_to_chat_tool_choice():
|
||||
assert _to_chat_tool_choice("auto") == "auto"
|
||||
assert _to_chat_tool_choice("required") == "required"
|
||||
assert _to_chat_tool_choice({"type": "function", "name": "f"}) == {"type": "function", "function": {"name": "f"}}
|
||||
|
||||
|
||||
def test_build_extra_body_variants():
|
||||
f = ChatCompletionsApiModelHandler._build_extra_body
|
||||
assert f("http://x/v1", True, None) == {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
assert f("http://x/v1", True, "none") == {"reasoning_effort": "none"} # explicit effort wins
|
||||
assert f("https://api.openai.com/v1", True, "none") is None # official OpenAI: no extra_body
|
||||
assert f("https://api.openai.com/v1/", True, "none") is None # trailing slash still official
|
||||
assert f("http://x/v1", True, "") == {"chat_template_kwargs": {"enable_thinking": False}} # empty effort ignored
|
||||
assert f("http://x/v1", False, None) is None
|
||||
assert f(None, True, None) is None
|
||||
|
||||
|
||||
def test_chat_messages_encodes_tool_arguments_as_string():
|
||||
"""to_transformers_chat emits arguments as a dict; the chat API needs a string."""
|
||||
chat = Chat(10)
|
||||
chat.add_item(make_user_message("Kopf links"))
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCall(
|
||||
type="function_call", name="move_head", arguments='{"direction": "left"}', call_id="call_1", id="fc_1"
|
||||
)
|
||||
)
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCallOutput(type="function_call_output", call_id="call_1", output="ok")
|
||||
)
|
||||
messages = ChatCompletionsApiModelHandler._chat_messages(chat)
|
||||
tool_call_msgs = [m for m in messages if m.get("tool_calls")]
|
||||
assert tool_call_msgs, "expected an assistant message carrying tool_calls"
|
||||
args = tool_call_msgs[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert isinstance(args, str), f"arguments must be a JSON string, got {type(args)}"
|
||||
assert json.loads(args) == {"direction": "left"}
|
||||
|
||||
|
||||
def test_chat_messages_strips_tool_output_name():
|
||||
"""to_transformers_chat adds a tool name for HF templates; Chat Completions
|
||||
tool messages only accept role/tool_call_id/content."""
|
||||
chat = Chat(10)
|
||||
chat.add_item(make_user_message("Search for x"))
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCall(
|
||||
type="function_call",
|
||||
name="search",
|
||||
arguments='{"q": "x"}',
|
||||
call_id="call_1",
|
||||
id="fc_1",
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCallOutput(type="function_call_output", call_id="call_1", output="found")
|
||||
)
|
||||
|
||||
messages = ChatCompletionsApiModelHandler._chat_messages(chat)
|
||||
tool_message = [m for m in messages if m.get("role") == "tool"][0]
|
||||
assert tool_message == {"role": "tool", "tool_call_id": "call_1", "content": "found"}
|
||||
|
||||
|
||||
def test_chat_messages_converts_image_and_text_parts_to_chat_shape():
|
||||
"""to_transformers_chat emits Realtime-shaped parts (input_text / input_image
|
||||
with a bare-string image_url); the Chat Completions API needs text / image_url
|
||||
with a nested object."""
|
||||
chat = Chat(10)
|
||||
chat.add_item(
|
||||
RealtimeConversationItemUserMessage(
|
||||
type="message",
|
||||
role="user",
|
||||
content=[
|
||||
UserContent(type="input_text", text="What is this?"),
|
||||
UserContent(type="input_image", image_url="https://example.com/img.png", detail="auto"),
|
||||
],
|
||||
)
|
||||
)
|
||||
messages = ChatCompletionsApiModelHandler._chat_messages(chat)
|
||||
user = [m for m in messages if m.get("role") == "user"][0]
|
||||
assert isinstance(user["content"], list)
|
||||
parts = {p["type"]: p for p in user["content"]}
|
||||
assert parts["text"]["text"] == "What is this?"
|
||||
assert parts["image_url"]["image_url"] == {"url": "https://example.com/img.png", "detail": "auto"}
|
||||
# No Realtime-shaped parts leak through.
|
||||
assert all(p["type"] not in ("input_text", "input_image") for p in user["content"])
|
||||
|
||||
|
||||
# ── Streaming / non-streaming parse tests ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_streaming_text_and_usage():
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
_chunk(content="Hallo. "),
|
||||
_chunk(content="Wie geht es dir?"),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=12, completion_tokens=5)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(h)
|
||||
assert "Hallo" in text and "Wie geht es dir" in text
|
||||
assert usage == (12, 5)
|
||||
assert tools == []
|
||||
# assistant text was stored back into the conversation history
|
||||
assert any(getattr(i, "role", None) == "assistant" for i in chat.buffer)
|
||||
|
||||
|
||||
def test_streaming_tool_call_accumulates_arguments():
|
||||
h = _make_handler(stream=True)
|
||||
# Arguments arrive split across deltas, as real servers stream them.
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
_chunk(tool_calls=[_tc_delta(0, id="srv_1", name="move_head", arguments='{"direction"')]),
|
||||
_chunk(tool_calls=[_tc_delta(0, arguments=': "left"}')]),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=20, completion_tokens=8)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(
|
||||
h,
|
||||
tools=[{"type": "function", "name": "move_head", "parameters": {"type": "object"}}],
|
||||
tool_choice="required",
|
||||
)
|
||||
assert len(tools) == 1
|
||||
tc = tools[0]
|
||||
assert isinstance(tc, ResponseFunctionToolCall)
|
||||
assert tc.name == "move_head"
|
||||
assert json.loads(tc.arguments) == {"direction": "left"} # reassembled from two deltas
|
||||
assert usage == (20, 8)
|
||||
# the function_call was stored in history with a freshly minted call_id
|
||||
assert chat._pending_tool_calls, "tool call should be recorded in chat history"
|
||||
|
||||
|
||||
def test_tool_call_recorded_before_chunk_is_emitted():
|
||||
"""Regression: a fast client can return function_call_output before the
|
||||
deferred end-of-turn write-back runs. The call must already be in history
|
||||
the instant its chunk is yielded, otherwise the output is rejected with
|
||||
'No function_call with call_id ... found' and the model re-issues the call."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
_chunk(content="Sure."),
|
||||
_chunk(tool_calls=[_tc_delta(0, id="srv_1", name="camera_snapshot", arguments="{}")]),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=5, completion_tokens=2)),
|
||||
]
|
||||
)
|
||||
chat = Chat(10)
|
||||
chat.add_item(make_user_message("take a photo"))
|
||||
session = RealtimeSessionCreateRequest(type="realtime", instructions="Du bist ein Roboter.")
|
||||
session.tools = [{"type": "function", "name": "camera_snapshot", "parameters": {"type": "object"}}]
|
||||
rc = RuntimeConfig(chat=chat, session=session)
|
||||
req = GenerateResponseRequest(runtime_config=rc, language_code="de", turn_id="t", turn_revision=0)
|
||||
|
||||
emitted_call_id = None
|
||||
for out in h.process(req):
|
||||
if isinstance(out, LLMResponseChunk) and out.tools:
|
||||
emitted_call_id = out.tools[0].call_id
|
||||
# At the moment the client receives the call, it must exist in history.
|
||||
assert emitted_call_id in chat._pending_tool_calls, (
|
||||
"function_call must be recorded BEFORE its chunk is forwarded to the client"
|
||||
)
|
||||
# A fast client returning the output here must pair cleanly (no raise).
|
||||
chat.add_item(
|
||||
RealtimeConversationItemFunctionCallOutput(
|
||||
type="function_call_output", call_id=emitted_call_id, output="ok"
|
||||
)
|
||||
)
|
||||
assert emitted_call_id is not None, "a tool call should have been emitted"
|
||||
assert chat._has_call_id_in_buffer(emitted_call_id), "call+output should be paired in the buffer"
|
||||
|
||||
|
||||
def test_non_streaming_tool_call():
|
||||
h = _make_handler(stream=False)
|
||||
h.client.chat.completions.create = lambda **k: SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
content="",
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
id="srv_9",
|
||||
function=SimpleNamespace(name="move_head", arguments='{"direction": "right"}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(prompt_tokens=7, completion_tokens=3),
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(
|
||||
h,
|
||||
tools=[{"type": "function", "name": "move_head", "parameters": {"type": "object"}}],
|
||||
tool_choice="required",
|
||||
)
|
||||
assert len(tools) == 1 and tools[0].name == "move_head"
|
||||
assert json.loads(tools[0].arguments) == {"direction": "right"}
|
||||
assert usage == (7, 3)
|
||||
|
||||
|
||||
def test_streaming_refusal_is_spoken_and_stored():
|
||||
"""A refusal streams as delta.refusal (content None); it must be surfaced as
|
||||
assistant text and written to history, not silently dropped."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
delta=SimpleNamespace(content=None, refusal="I cannot help with that.", tool_calls=None),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=4, completion_tokens=6)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(h)
|
||||
assert "I cannot help with that." in text
|
||||
assert any(getattr(i, "role", None) == "assistant" for i in chat.buffer)
|
||||
|
||||
|
||||
def test_non_streaming_refusal_is_spoken_and_stored():
|
||||
h = _make_handler(stream=False)
|
||||
h.client.chat.completions.create = lambda **k: SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content=None, refusal="No can do.", tool_calls=[]))],
|
||||
usage=SimpleNamespace(prompt_tokens=2, completion_tokens=2),
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(h)
|
||||
assert text == "No can do."
|
||||
assert any(getattr(i, "role", None) == "assistant" for i in chat.buffer)
|
||||
|
||||
|
||||
def test_non_streaming_empty_choices_completes_cleanly():
|
||||
"""A valid response with no choices (e.g. content filter) completes with no
|
||||
assistant text and no error, instead of raising IndexError."""
|
||||
h = _make_handler(stream=False)
|
||||
h.client.chat.completions.create = lambda **k: SimpleNamespace(
|
||||
choices=[], usage=SimpleNamespace(prompt_tokens=1, completion_tokens=0)
|
||||
)
|
||||
text, tools, usage, chat, end = _drive(h)
|
||||
assert text == ""
|
||||
assert tools == []
|
||||
assert end is not None and end.error is None # clean end, not a generation failure
|
||||
|
||||
|
||||
def test_tools_converted_to_chat_format_on_request():
|
||||
"""The request sent to the server must carry Chat-Completions-shaped tools."""
|
||||
h = _make_handler(stream=True)
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _FakeStream([_chunk(content="ok.")])
|
||||
|
||||
h.client.chat.completions.create = fake_create
|
||||
_drive(h, tools=[{"type": "function", "name": "f", "parameters": {"type": "object"}}], tool_choice="auto")
|
||||
assert captured["tools"] == [{"type": "function", "function": {"name": "f", "parameters": {"type": "object"}}}]
|
||||
assert captured["tool_choice"] == "auto"
|
||||
assert captured["stream"] is True
|
||||
assert captured["stream_options"] == {"include_usage": True}
|
||||
|
||||
|
||||
# ── Text-only (output_modalities=["text"]) ────────────────────────────────────
|
||||
|
||||
|
||||
def test_text_only_streaming_preserves_raw_deltas():
|
||||
"""With output_modalities=["text"], deltas are forwarded verbatim: no
|
||||
remove_unspeechable (emoji/markdown survive) and no sentence batching."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
_chunk(content="# Title 🎉\n"),
|
||||
_chunk(content="- one\n- two 😀\n"),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=3, completion_tokens=4)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, end = _drive(h, response=RealtimeResponseCreateParams(output_modalities=["text"]))
|
||||
# Raw markdown layout and emoji preserved end-to-end.
|
||||
assert text == "# Title 🎉\n- one\n- two 😀\n"
|
||||
assert tools == []
|
||||
assert usage == (3, 4)
|
||||
# Raw assistant text is committed to history (not the filtered TTS string).
|
||||
assert any(getattr(i, "role", None) == "assistant" for i in chat.buffer), "assistant turn should be stored"
|
||||
|
||||
|
||||
def test_text_only_tool_call_in_same_delta_not_dropped():
|
||||
"""In text-only mode a delta can carry both content and a tool_call fragment;
|
||||
the tool_call must still be accumulated despite the verbatim-forward `continue`."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
delta=SimpleNamespace(
|
||||
content="Looking it up. ",
|
||||
tool_calls=[_tc_delta(0, id="srv_1", name="search", arguments='{"q":"x"}')],
|
||||
),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
),
|
||||
_chunk(usage=SimpleNamespace(prompt_tokens=5, completion_tokens=5)),
|
||||
]
|
||||
)
|
||||
text, tools, usage, chat, _end = _drive(
|
||||
h,
|
||||
tools=[{"type": "function", "name": "search", "parameters": {"type": "object"}}],
|
||||
response=RealtimeResponseCreateParams(output_modalities=["text"]),
|
||||
)
|
||||
assert "Looking it up." in text
|
||||
assert len(tools) == 1 and tools[0].name == "search" # not dropped by the text-only continue
|
||||
assert json.loads(tools[0].arguments) == {"q": "x"}
|
||||
|
||||
|
||||
def test_non_streaming_text_only_preserves_symbols():
|
||||
h = _make_handler(stream=False)
|
||||
h.client.chat.completions.create = lambda **k: SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content="**bold** 🎉", tool_calls=[]))],
|
||||
usage=SimpleNamespace(prompt_tokens=2, completion_tokens=2),
|
||||
)
|
||||
text, tools, usage, chat, end = _drive(h, response=RealtimeResponseCreateParams(output_modalities=["text"]))
|
||||
assert text == "**bold** 🎉" # symbols not stripped
|
||||
|
||||
|
||||
# ── tool_choice decoupled from tools ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tool_choice_sent_without_tools():
|
||||
"""A session-level tool_choice must reach the server even when no tools list
|
||||
is supplied (e.g. tool_choice="none" to suppress tool use)."""
|
||||
h = _make_handler(stream=True)
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _FakeStream([_chunk(content="ok.")])
|
||||
|
||||
h.client.chat.completions.create = fake_create
|
||||
_drive(h, tool_choice="none")
|
||||
assert "tools" not in captured
|
||||
assert captured["tool_choice"] == "none"
|
||||
|
||||
|
||||
# ── Error propagation ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_input_emits_failed_end_of_response():
|
||||
"""No instructions and no conversation input → terminating EndOfResponse with
|
||||
an error, instead of an opaque provider 400."""
|
||||
h = _make_handler(stream=True)
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
called["n"] += 1
|
||||
return _FakeStream([_chunk(content="should not happen")])
|
||||
|
||||
h.client.chat.completions.create = fake_create
|
||||
# Empty chat + empty instructions => nothing to send.
|
||||
text, tools, usage, chat, end = _drive(h, user="", instructions="", chat=Chat(10))
|
||||
assert called["n"] == 0, "no API call should be made when there is nothing to send"
|
||||
assert end is not None and end.error is not None
|
||||
assert text == ""
|
||||
|
||||
|
||||
def test_generation_error_emits_failed_end_of_response():
|
||||
"""An exception during generation is caught and surfaced on EndOfResponse.error
|
||||
so the response is closed instead of leaving the pipeline stuck."""
|
||||
h = _make_handler(stream=True)
|
||||
|
||||
def boom(**kwargs):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
h.client.chat.completions.create = boom
|
||||
text, tools, usage, chat, end = _drive(h)
|
||||
assert end is not None and end.error is not None
|
||||
assert "kaboom" in end.error
|
||||
|
||||
|
||||
# ── Out-of-band (conversation="none") responses ───────────────────────────────
|
||||
|
||||
|
||||
def test_out_of_band_does_not_commit_to_default_conversation():
|
||||
"""Out-of-band output is emitted but never written back to the default chat."""
|
||||
h = _make_handler(stream=True)
|
||||
h.client.chat.completions.create = lambda **k: _FakeStream(
|
||||
[_chunk(content="Background note."), _chunk(usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1))]
|
||||
)
|
||||
chat = Chat(10)
|
||||
text, tools, usage, chat, end = _drive(
|
||||
h, chat=chat, response=RealtimeResponseCreateParams(conversation="none", output_modalities=["text"])
|
||||
)
|
||||
assert "Background note." in text
|
||||
# Default conversation keeps only the seeded user turn — no assistant commit.
|
||||
assert not any(getattr(i, "role", None) == "assistant" for i in chat.buffer)
|
||||
|
||||
|
||||
# ── Standalone runner (no pytest required) ────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
failed = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
print(f"PASS {t.__name__}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
print(f"FAIL {t.__name__}: {type(e).__name__}: {e}")
|
||||
print(f"\n{len(tests) - failed}/{len(tests)} passed")
|
||||
raise SystemExit(1 if failed else 0)
|
||||
@@ -0,0 +1,148 @@
|
||||
import sys
|
||||
from dataclasses import fields
|
||||
|
||||
from speech_to_speech.arguments_classes.chat_tts_arguments import ChatTTSHandlerArguments
|
||||
from speech_to_speech.arguments_classes.facebookmms_tts_arguments import FacebookMMSTTSHandlerArguments
|
||||
from speech_to_speech.arguments_classes.faster_whisper_stt_arguments import FasterWhisperSTTHandlerArguments
|
||||
from speech_to_speech.arguments_classes.kokoro_tts_arguments import KokoroTTSHandlerArguments
|
||||
from speech_to_speech.arguments_classes.language_model_arguments import LanguageModelHandlerArguments
|
||||
from speech_to_speech.arguments_classes.mlx_audio_whisper_arguments import MLXAudioWhisperSTTHandlerArguments
|
||||
from speech_to_speech.arguments_classes.module_arguments import ModuleArguments
|
||||
from speech_to_speech.arguments_classes.paraformer_stt_arguments import ParaformerSTTHandlerArguments
|
||||
from speech_to_speech.arguments_classes.parakeet_tdt_arguments import ParakeetTDTSTTHandlerArguments
|
||||
from speech_to_speech.arguments_classes.pocket_tts_arguments import PocketTTSHandlerArguments
|
||||
from speech_to_speech.arguments_classes.qwen3_tts_arguments import Qwen3TTSHandlerArguments
|
||||
from speech_to_speech.arguments_classes.responses_api_language_model_arguments import (
|
||||
ResponsesApiLanguageModelHandlerArguments,
|
||||
)
|
||||
from speech_to_speech.arguments_classes.socket_receiver_arguments import SocketReceiverArguments
|
||||
from speech_to_speech.arguments_classes.socket_sender_arguments import SocketSenderArguments
|
||||
from speech_to_speech.arguments_classes.vad_arguments import VADHandlerArguments
|
||||
from speech_to_speech.arguments_classes.websocket_streamer_arguments import WebSocketStreamerArguments
|
||||
from speech_to_speech.arguments_classes.whisper_stt_arguments import WhisperSTTHandlerArguments
|
||||
from speech_to_speech.s2s_pipeline import ParsedArguments, parse_arguments
|
||||
|
||||
|
||||
def test_release_defaults_match_responses_api_parakeet_qwen3_realtime_profile():
|
||||
module_args = ModuleArguments()
|
||||
vad_args = VADHandlerArguments()
|
||||
responses_api_args = ResponsesApiLanguageModelHandlerArguments()
|
||||
qwen3_args = Qwen3TTSHandlerArguments()
|
||||
|
||||
assert module_args.mode == "realtime"
|
||||
assert module_args.stt == "parakeet-tdt"
|
||||
assert module_args.llm_backend == "responses-api"
|
||||
assert module_args.tts == "qwen3"
|
||||
assert module_args.log_level == "info"
|
||||
assert module_args.enable_live_transcription is True
|
||||
assert module_args.live_transcription_update_interval == 0.5
|
||||
|
||||
assert vad_args.thresh == 0.6
|
||||
assert vad_args.min_silence_ms == 64
|
||||
assert vad_args.min_speech_ms == 384
|
||||
assert vad_args.min_speech_continuation_ms == 192
|
||||
assert vad_args.realtime_processing_pause == 0.5
|
||||
assert responses_api_args.model_name == "gpt-5.4-mini"
|
||||
assert responses_api_args.chat_size == 30
|
||||
assert responses_api_args.responses_api_stream is True
|
||||
assert qwen3_args.qwen3_tts_model_name == "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||
assert qwen3_args.qwen3_tts_speaker == "Aiden"
|
||||
assert qwen3_args.qwen3_tts_language == "auto"
|
||||
assert qwen3_args.qwen3_tts_backend == "ggml"
|
||||
assert qwen3_args.qwen3_tts_non_streaming_mode is True
|
||||
assert qwen3_args.qwen3_tts_ref_audio is None
|
||||
assert qwen3_args.qwen3_tts_mlx_quantization == "6bit"
|
||||
|
||||
|
||||
# -- ParsedArguments dataclass tests ------------------------------------------
|
||||
|
||||
EXPECTED_FIELD_TYPES = {
|
||||
"module_kwargs": ModuleArguments,
|
||||
"socket_receiver_kwargs": SocketReceiverArguments,
|
||||
"socket_sender_kwargs": SocketSenderArguments,
|
||||
"websocket_streamer_kwargs": WebSocketStreamerArguments,
|
||||
"vad_handler_kwargs": VADHandlerArguments,
|
||||
"whisper_stt_handler_kwargs": WhisperSTTHandlerArguments,
|
||||
"paraformer_stt_handler_kwargs": ParaformerSTTHandlerArguments,
|
||||
"faster_whisper_stt_handler_kwargs": FasterWhisperSTTHandlerArguments,
|
||||
"mlx_audio_whisper_stt_handler_kwargs": MLXAudioWhisperSTTHandlerArguments,
|
||||
"parakeet_tdt_stt_handler_kwargs": ParakeetTDTSTTHandlerArguments,
|
||||
"language_model_handler_kwargs": LanguageModelHandlerArguments,
|
||||
"responses_api_language_model_handler_kwargs": ResponsesApiLanguageModelHandlerArguments,
|
||||
"chat_tts_handler_kwargs": ChatTTSHandlerArguments,
|
||||
"facebook_mms_tts_handler_kwargs": FacebookMMSTTSHandlerArguments,
|
||||
"pocket_tts_handler_kwargs": PocketTTSHandlerArguments,
|
||||
"kokoro_tts_handler_kwargs": KokoroTTSHandlerArguments,
|
||||
"qwen3_tts_handler_kwargs": Qwen3TTSHandlerArguments,
|
||||
}
|
||||
|
||||
|
||||
def test_parsed_arguments_has_all_expected_fields():
|
||||
actual_fields = {f.name: f.type for f in fields(ParsedArguments)}
|
||||
assert set(actual_fields) == set(EXPECTED_FIELD_TYPES)
|
||||
|
||||
|
||||
def test_parsed_arguments_field_types_match():
|
||||
for f in fields(ParsedArguments):
|
||||
assert f.type is EXPECTED_FIELD_TYPES[f.name], (
|
||||
f"Field {f.name!r}: expected {EXPECTED_FIELD_TYPES[f.name].__name__}, got {f.type}"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_arguments_default_backend_returns_openai_api():
|
||||
original_argv = sys.argv[:]
|
||||
try:
|
||||
sys.argv = ["speech-to-speech"]
|
||||
args = parse_arguments()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
assert isinstance(args, ParsedArguments)
|
||||
assert isinstance(args.module_kwargs, ModuleArguments)
|
||||
assert isinstance(args.responses_api_language_model_handler_kwargs, ResponsesApiLanguageModelHandlerArguments)
|
||||
assert isinstance(args.language_model_handler_kwargs, LanguageModelHandlerArguments)
|
||||
assert args.responses_api_language_model_handler_kwargs.model_name == "gpt-5.4-mini"
|
||||
assert args.module_kwargs.llm_backend == "responses-api"
|
||||
|
||||
|
||||
def test_parse_arguments_accepts_qwen3_tts_backend_override():
|
||||
original_argv = sys.argv[:]
|
||||
try:
|
||||
sys.argv = ["speech-to-speech", "--qwen3_tts_backend", "torch"]
|
||||
args = parse_arguments()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
assert args.qwen3_tts_handler_kwargs.qwen3_tts_backend == "torch"
|
||||
|
||||
|
||||
def test_parse_arguments_transformers_backend():
|
||||
original_argv = sys.argv[:]
|
||||
try:
|
||||
sys.argv = ["speech-to-speech", "--llm_backend", "transformers"]
|
||||
args = parse_arguments()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
assert isinstance(args, ParsedArguments)
|
||||
assert isinstance(args.language_model_handler_kwargs, LanguageModelHandlerArguments)
|
||||
assert isinstance(args.responses_api_language_model_handler_kwargs, ResponsesApiLanguageModelHandlerArguments)
|
||||
assert args.language_model_handler_kwargs.model_name == "Qwen/Qwen3-4B-Instruct-2507"
|
||||
# unused slot gets a default instance
|
||||
assert args.responses_api_language_model_handler_kwargs.model_name == "gpt-5.4-mini"
|
||||
|
||||
|
||||
def test_parse_arguments_all_fields_populated():
|
||||
original_argv = sys.argv[:]
|
||||
try:
|
||||
sys.argv = ["speech-to-speech"]
|
||||
args = parse_arguments()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
for f in fields(ParsedArguments):
|
||||
value = getattr(args, f.name)
|
||||
assert value is not None, f"Field {f.name!r} is None"
|
||||
assert isinstance(value, EXPECTED_FIELD_TYPES[f.name]), (
|
||||
f"Field {f.name!r}: expected {EXPECTED_FIELD_TYPES[f.name].__name__}, got {type(value).__name__}"
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
from speech_to_speech.LLM.utils import remove_unspeechable
|
||||
|
||||
|
||||
def test_remove_unspeechable_normalizes_smart_apostrophes() -> None:
|
||||
assert remove_unspeechable("I’ll reply if here’s the plan.") == "I'll reply if here's the plan."
|
||||
|
||||
|
||||
def test_remove_unspeechable_keeps_text_and_drops_emoji() -> None:
|
||||
assert remove_unspeechable("Hello 👋 lobster 🦞") == "Hello lobster "
|
||||
@@ -0,0 +1,218 @@
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
|
||||
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
|
||||
|
||||
from speech_to_speech.LLM.lm_output_processor import LMOutputProcessor
|
||||
from speech_to_speech.pipeline.messages import EndOfResponse, LLMResponseChunk, TTSInput
|
||||
from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
|
||||
|
||||
|
||||
def _processor(tracker: SpeculativeTurnTracker) -> LMOutputProcessor:
|
||||
processor = LMOutputProcessor.__new__(LMOutputProcessor)
|
||||
processor.setup(text_output_queue=Queue(), speculative_turns=tracker)
|
||||
return processor
|
||||
|
||||
|
||||
def test_stale_end_of_response_is_not_forwarded_to_tts():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 1)
|
||||
processor = _processor(tracker)
|
||||
|
||||
outputs = list(processor.process(EndOfResponse(turn_id="turn_1", turn_revision=0)))
|
||||
|
||||
assert outputs == []
|
||||
|
||||
|
||||
def test_latest_end_of_response_is_forwarded_to_tts():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 1)
|
||||
processor = _processor(tracker)
|
||||
|
||||
outputs = list(processor.process(EndOfResponse(turn_id="turn_1", turn_revision=1)))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].turn_id == "turn_1"
|
||||
assert outputs[0].turn_revision == 1
|
||||
|
||||
|
||||
def test_cancel_generation_is_forwarded_to_tts():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
processor = _processor(tracker)
|
||||
|
||||
outputs = list(
|
||||
processor.process(
|
||||
LLMResponseChunk(
|
||||
text="hello",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
cancel_generation=7,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].cancel_generation == 7
|
||||
|
||||
|
||||
def test_text_only_chunk_is_not_forwarded_to_tts():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
processor = _processor(tracker)
|
||||
|
||||
outputs = list(
|
||||
processor.process(
|
||||
LLMResponseChunk(
|
||||
text="hello",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
response=RealtimeResponseCreateParams(output_modalities=["text"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert outputs == []
|
||||
# The assistant text still reaches clients even when TTS is skipped.
|
||||
event = processor.text_output_queue.get_nowait()
|
||||
assert event.text == "hello"
|
||||
|
||||
|
||||
def test_audio_chunk_is_forwarded_to_tts():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
processor = _processor(tracker)
|
||||
|
||||
outputs = list(
|
||||
processor.process(
|
||||
LLMResponseChunk(
|
||||
text="hello",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
response=RealtimeResponseCreateParams(output_modalities=["audio"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert isinstance(outputs[0], TTSInput)
|
||||
assert outputs[0].text == "hello"
|
||||
|
||||
|
||||
def test_empty_modalities_is_forwarded_to_tts():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
processor = _processor(tracker)
|
||||
|
||||
outputs = list(
|
||||
processor.process(
|
||||
LLMResponseChunk(
|
||||
text="hello",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
response=RealtimeResponseCreateParams(output_modalities=[]),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert isinstance(outputs[0], TTSInput)
|
||||
|
||||
|
||||
def test_pending_reopen_holds_assistant_chunk_until_cancelled():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
processor = _processor(tracker)
|
||||
done = Event()
|
||||
outputs = []
|
||||
|
||||
def run_processor():
|
||||
outputs.extend(
|
||||
processor.process(
|
||||
LLMResponseChunk(
|
||||
text="hello",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
done.set()
|
||||
|
||||
thread = Thread(target=run_processor)
|
||||
thread.start()
|
||||
|
||||
assert not done.wait(0.05)
|
||||
tracker.cancel_reopen_candidate("turn_1", candidate_revision)
|
||||
assert done.wait(1.0)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].text == "hello"
|
||||
event = processor.text_output_queue.get_nowait()
|
||||
assert event.text == "hello"
|
||||
|
||||
|
||||
def test_reopen_grace_holds_assistant_chunk_until_elapsed():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
tracker.start_reopen_grace("turn_1", 0, grace_s=0.08)
|
||||
processor = _processor(tracker)
|
||||
done = Event()
|
||||
outputs = []
|
||||
|
||||
def run_processor():
|
||||
outputs.extend(
|
||||
processor.process(
|
||||
LLMResponseChunk(
|
||||
text="hello",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
done.set()
|
||||
|
||||
thread = Thread(target=run_processor)
|
||||
thread.start()
|
||||
|
||||
assert not done.wait(0.02)
|
||||
assert done.wait(1.0)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].text == "hello"
|
||||
event = processor.text_output_queue.get_nowait()
|
||||
assert event.text == "hello"
|
||||
|
||||
|
||||
def test_confirmed_reopen_drops_held_assistant_chunk():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
processor = _processor(tracker)
|
||||
done = Event()
|
||||
outputs = []
|
||||
|
||||
def run_processor():
|
||||
outputs.extend(
|
||||
processor.process(
|
||||
LLMResponseChunk(
|
||||
text="hello",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
done.set()
|
||||
|
||||
thread = Thread(target=run_processor)
|
||||
thread.start()
|
||||
|
||||
assert not done.wait(0.05)
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
assert done.wait(1.0)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert outputs == []
|
||||
assert processor.text_output_queue.empty()
|
||||
@@ -0,0 +1,62 @@
|
||||
import numpy as np
|
||||
|
||||
from speech_to_speech.pipeline.messages import PartialTranscription, Transcription, VADAudio
|
||||
from speech_to_speech.STT import paraformer_handler
|
||||
from speech_to_speech.STT.paraformer_handler import ParaformerSTTHandler
|
||||
|
||||
|
||||
class _FakeParaformerModel:
|
||||
def generate(self, audio):
|
||||
return [{"text": " 今 天 天 气 不 错 "}]
|
||||
|
||||
|
||||
def _handler():
|
||||
handler = object.__new__(ParaformerSTTHandler)
|
||||
handler.model = _FakeParaformerModel()
|
||||
return handler
|
||||
|
||||
|
||||
def test_progressive_paraformer_transcription_is_partial(monkeypatch):
|
||||
monkeypatch.setattr(paraformer_handler.console, "print", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(paraformer_handler.torch.mps, "empty_cache", lambda: None)
|
||||
|
||||
result = list(
|
||||
_handler().process(
|
||||
VADAudio(
|
||||
audio=np.zeros(16000, dtype=np.float32),
|
||||
mode="progressive",
|
||||
turn_id="turn_1",
|
||||
turn_revision=2,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], PartialTranscription)
|
||||
assert result[0].text == "今天天气不错"
|
||||
assert result[0].turn_id == "turn_1"
|
||||
assert result[0].turn_revision == 2
|
||||
|
||||
|
||||
def test_final_paraformer_transcription_is_final(monkeypatch):
|
||||
monkeypatch.setattr(paraformer_handler.console, "print", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(paraformer_handler.torch.mps, "empty_cache", lambda: None)
|
||||
|
||||
result = list(
|
||||
_handler().process(
|
||||
VADAudio(
|
||||
audio=np.zeros(16000, dtype=np.float32),
|
||||
mode="final",
|
||||
turn_id="turn_1",
|
||||
turn_revision=2,
|
||||
created_at_s=123.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Transcription)
|
||||
assert result[0].text == "今天天气不错"
|
||||
assert result[0].turn_id == "turn_1"
|
||||
assert result[0].turn_revision == 2
|
||||
assert result[0].speech_stopped_at_s == 123.0
|
||||
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
|
||||
from speech_to_speech.STT import parakeet_tdt_handler
|
||||
from speech_to_speech.STT.parakeet_tdt_handler import ParakeetTDTSTTHandler
|
||||
|
||||
|
||||
def test_build_lingua_detector_preloads_language_models(monkeypatch):
|
||||
if not parakeet_tdt_handler.LINGUA_AVAILABLE:
|
||||
pytest.skip("lingua-language-detector is not installed")
|
||||
|
||||
calls = []
|
||||
detector = object()
|
||||
|
||||
class Builder:
|
||||
def with_preloaded_language_models(self):
|
||||
calls.append("preload")
|
||||
return self
|
||||
|
||||
def build(self):
|
||||
calls.append("build")
|
||||
return detector
|
||||
|
||||
class BuilderFactory:
|
||||
@staticmethod
|
||||
def from_languages(*languages):
|
||||
calls.append(("languages", languages))
|
||||
return Builder()
|
||||
|
||||
monkeypatch.setattr(parakeet_tdt_handler, "LanguageDetectorBuilder", BuilderFactory)
|
||||
|
||||
assert parakeet_tdt_handler._build_lingua_detector() is detector
|
||||
assert calls == [("languages", tuple(parakeet_tdt_handler._lingua_languages)), "preload", "build"]
|
||||
|
||||
|
||||
def test_detect_language_from_short_text_returns_none_without_querying_detector(monkeypatch):
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
|
||||
class ExplodingDetector:
|
||||
def detect_language_of(self, text):
|
||||
raise AssertionError("short text should not invoke lingua")
|
||||
|
||||
monkeypatch.setattr(parakeet_tdt_handler, "LINGUA_AVAILABLE", True)
|
||||
monkeypatch.setattr(parakeet_tdt_handler, "_lingua_detector", ExplodingDetector(), raising=False)
|
||||
|
||||
assert handler._detect_language_from_text("Okay.") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"Okay, open the door.",
|
||||
"Okay, try that again.",
|
||||
"Open the door, please.",
|
||||
],
|
||||
)
|
||||
def test_detect_language_from_short_english_text_uses_lingua_successfully(text):
|
||||
if not parakeet_tdt_handler.LINGUA_AVAILABLE:
|
||||
pytest.skip("lingua-language-detector is not installed")
|
||||
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
|
||||
assert len(text) >= 20
|
||||
assert handler._detect_language_from_text(text) == "en"
|
||||
|
||||
|
||||
def test_detect_language_from_long_norwegian_text_maps_nb_to_no():
|
||||
if not parakeet_tdt_handler.LINGUA_AVAILABLE:
|
||||
pytest.skip("lingua-language-detector is not installed")
|
||||
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
text = (
|
||||
"Jeg heter Øyvind og bor i Trondheim. Denne teksten er lang nok til å teste "
|
||||
"om språkdetektoren virkelig klarer å skille norsk bokmål fra dansk og svensk "
|
||||
"i et realistisk avsnitt med vanlige ord og tegn."
|
||||
)
|
||||
|
||||
assert handler._detect_language_from_text(text) == "no"
|
||||
@@ -0,0 +1,304 @@
|
||||
import io
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
from rich.text import Text
|
||||
|
||||
from speech_to_speech.pipeline.messages import PartialTranscription, Transcription, VADAudio
|
||||
from speech_to_speech.STT import parakeet_tdt_handler
|
||||
from speech_to_speech.STT.parakeet_tdt_handler import ParakeetTDTSTTHandler
|
||||
from speech_to_speech.STT.smart_progressive_streaming import SmartProgressiveStreamingHandler
|
||||
|
||||
|
||||
def test_show_progressive_transcription_returns_combined_text(monkeypatch):
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler.streaming_handler = SimpleNamespace(
|
||||
transcribe_incremental=lambda audio: SimpleNamespace(
|
||||
fixed_text="I just wanted",
|
||||
active_text="to check in",
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(parakeet_tdt_handler.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
result = handler._show_progressive_transcription(np.zeros(16000, dtype=np.float32))
|
||||
|
||||
assert result == "I just wanted to check in"
|
||||
|
||||
|
||||
def test_live_transcription_clears_terminal_line_before_each_update(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeConsole:
|
||||
is_terminal = True
|
||||
width = 80
|
||||
|
||||
def __init__(self):
|
||||
self.file = io.StringIO()
|
||||
|
||||
def print(self, *args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler._live_transcription_active = False
|
||||
fake_console = FakeConsole()
|
||||
monkeypatch.setattr(parakeet_tdt_handler, "console", fake_console)
|
||||
|
||||
handler._print_live_transcription(Text("Live: first"), "first")
|
||||
handler._print_live_transcription(Text("Live: second"), "second")
|
||||
handler._clear_live_transcription_line()
|
||||
|
||||
assert [args[0].plain for args, _ in calls] == ["Live: first", "Live: second"]
|
||||
assert [kwargs for _, kwargs in calls] == [{"end": ""}, {"end": ""}]
|
||||
assert fake_console.file.getvalue() == "\r\x1b[2K\r\r\x1b[2K\r\r\x1b[2K"
|
||||
assert handler._live_transcription_active is False
|
||||
|
||||
|
||||
def test_live_transcription_truncates_terminal_updates(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeConsole:
|
||||
is_terminal = True
|
||||
width = 14
|
||||
|
||||
def __init__(self):
|
||||
self.file = io.StringIO()
|
||||
|
||||
def print(self, *args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler._live_transcription_active = False
|
||||
monkeypatch.setattr(parakeet_tdt_handler, "console", FakeConsole())
|
||||
|
||||
handler._print_live_transcription(Text("Live: abcdefghijklmnopqrstuvwxyz"), "abcdefghijklmnopqrstuvwxyz")
|
||||
|
||||
printed_text = calls[0][0][0]
|
||||
assert printed_text.plain == "Live: abcdef\u2026"
|
||||
assert len(printed_text.plain) == 13
|
||||
|
||||
|
||||
def test_live_transcription_uses_lines_for_non_terminal_logs(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeConsole:
|
||||
is_terminal = False
|
||||
|
||||
def print(self, *args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler._live_transcription_active = False
|
||||
monkeypatch.setattr(parakeet_tdt_handler, "console", FakeConsole())
|
||||
|
||||
handler._print_live_transcription(Text("Live: first"), "first")
|
||||
|
||||
assert calls == [((Text("Live: first"),), {})]
|
||||
assert handler._live_transcription_active is False
|
||||
|
||||
|
||||
def test_process_yields_partial_tagged_tuple(monkeypatch):
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler.enable_live_transcription = True
|
||||
handler.processing_final = False
|
||||
|
||||
@contextmanager
|
||||
def fake_lock(*args, **kwargs):
|
||||
yield True
|
||||
|
||||
handler._compute_lock_context = fake_lock
|
||||
handler._show_progressive_transcription = lambda audio: "partial text"
|
||||
monkeypatch.setattr(parakeet_tdt_handler.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
result = list(handler.process(VADAudio(audio=np.zeros(16000, dtype=np.float32), mode="progressive")))
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], PartialTranscription)
|
||||
assert result[0].text == "partial text"
|
||||
|
||||
|
||||
def test_process_yields_final_transcript(monkeypatch):
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler.enable_live_transcription = False
|
||||
handler.backend = "nano_parakeet"
|
||||
handler.last_language = "en"
|
||||
handler.start_language = None
|
||||
|
||||
@contextmanager
|
||||
def fake_lock(*args, **kwargs):
|
||||
yield True
|
||||
|
||||
handler._compute_lock_context = fake_lock
|
||||
handler._process_nano_parakeet = lambda audio_input: ("I am here.", "en")
|
||||
monkeypatch.setattr(parakeet_tdt_handler.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
result = list(handler.process(VADAudio(audio=np.zeros(16000, dtype=np.float32))))
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Transcription)
|
||||
assert result[0].text == "I am here."
|
||||
assert result[0].language_code == "en"
|
||||
|
||||
|
||||
def test_parakeet_timing_logs_only_final_transcriptions():
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler._times = [0.01]
|
||||
|
||||
assert handler.timing_log_level == logging.INFO
|
||||
assert handler.should_log_timing(Transcription(text="I am here.", language_code="en"))
|
||||
assert not handler.should_log_timing(PartialTranscription(text="I am"))
|
||||
|
||||
|
||||
def test_final_transcription_resets_live_streaming_state(monkeypatch):
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler.enable_live_transcription = True
|
||||
handler.backend = "nano_parakeet"
|
||||
handler.last_language = "en"
|
||||
handler.start_language = None
|
||||
handler.processing_final = False
|
||||
handler._live_turn_key = (None, None)
|
||||
reset_calls = []
|
||||
handler.streaming_handler = SimpleNamespace(reset=lambda: reset_calls.append(True))
|
||||
|
||||
@contextmanager
|
||||
def fake_lock(*args, **kwargs):
|
||||
yield True
|
||||
|
||||
handler._compute_lock_context = fake_lock
|
||||
handler._process_nano_parakeet = lambda audio_input: ("I am here.", "en")
|
||||
monkeypatch.setattr(parakeet_tdt_handler.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
result = list(handler.process(VADAudio(audio=np.zeros(16000, dtype=np.float32), mode="final")))
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Transcription)
|
||||
assert handler.processing_final is False
|
||||
assert reset_calls == [True]
|
||||
|
||||
|
||||
def test_turn_change_resets_live_streaming_state_before_progressive(monkeypatch):
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler.enable_live_transcription = True
|
||||
handler.processing_final = False
|
||||
handler._live_turn_key = ("turn_1", 0)
|
||||
reset_calls = []
|
||||
handler.streaming_handler = SimpleNamespace(reset=lambda: reset_calls.append(True))
|
||||
|
||||
@contextmanager
|
||||
def fake_lock(*args, **kwargs):
|
||||
yield True
|
||||
|
||||
handler._compute_lock_context = fake_lock
|
||||
handler._show_progressive_transcription = lambda audio: "new partial"
|
||||
monkeypatch.setattr(parakeet_tdt_handler.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
result = list(
|
||||
handler.process(
|
||||
VADAudio(
|
||||
audio=np.zeros(16000, dtype=np.float32),
|
||||
mode="progressive",
|
||||
turn_id="turn_2",
|
||||
turn_revision=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert reset_calls == [True]
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], PartialTranscription)
|
||||
assert result[0].text == "new partial"
|
||||
|
||||
|
||||
def test_mlx_final_ignores_fixed_text_that_exceeds_current_audio(monkeypatch):
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler.enable_live_transcription = True
|
||||
handler.backend = "mlx"
|
||||
handler.last_language = "en"
|
||||
handler.start_language = None
|
||||
handler.processing_final = False
|
||||
handler._live_turn_key = ("turn_3", 0)
|
||||
handler.streaming_handler = SimpleNamespace(
|
||||
fixed_sentences=["stale previous transcript"],
|
||||
fixed_end_time=10.0,
|
||||
reset=lambda: None,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def fake_lock(*args, **kwargs):
|
||||
yield True
|
||||
|
||||
handler._compute_lock_context = fake_lock
|
||||
handler._process_mlx = lambda audio_input: ("new short turn", "en")
|
||||
monkeypatch.setattr(parakeet_tdt_handler.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
result = list(
|
||||
handler.process(
|
||||
VADAudio(
|
||||
audio=np.zeros(16000, dtype=np.float32),
|
||||
mode="final",
|
||||
turn_id="turn_3",
|
||||
turn_revision=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], Transcription)
|
||||
assert result[0].text == "new short turn"
|
||||
|
||||
|
||||
def test_final_transcription_prevents_stale_fixed_window_on_next_progressive(monkeypatch):
|
||||
class Model:
|
||||
def __init__(self):
|
||||
self.progressive_window_lengths = []
|
||||
|
||||
def transcribe(self, audio, timestamps=True):
|
||||
self.progressive_window_lengths.append(len(audio))
|
||||
return SimpleNamespace(text="new partial", timestamp={"segment": []})
|
||||
|
||||
model = Model()
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler.enable_live_transcription = True
|
||||
handler.backend = "nano_parakeet"
|
||||
handler.last_language = "en"
|
||||
handler.start_language = None
|
||||
handler.processing_final = False
|
||||
handler.streaming_handler = SmartProgressiveStreamingHandler(model)
|
||||
handler.streaming_handler.fixed_sentences = ["previous fixed sentence"]
|
||||
handler.streaming_handler.fixed_end_time = 10.0
|
||||
handler.streaming_handler.last_transcribed_length = 20 * 16000
|
||||
|
||||
@contextmanager
|
||||
def fake_lock(*args, **kwargs):
|
||||
yield True
|
||||
|
||||
handler._compute_lock_context = fake_lock
|
||||
handler._process_nano_parakeet = lambda audio_input: ("previous final", "en")
|
||||
monkeypatch.setattr(parakeet_tdt_handler.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
final_result = list(handler.process(VADAudio(audio=np.zeros(16000, dtype=np.float32), mode="final")))
|
||||
progressive_audio = np.zeros(852 * 16, dtype=np.float32)
|
||||
progressive_result = list(handler.process(VADAudio(audio=progressive_audio, mode="progressive")))
|
||||
|
||||
assert len(final_result) == 1
|
||||
assert isinstance(final_result[0], Transcription)
|
||||
assert model.progressive_window_lengths == [len(progressive_audio)]
|
||||
assert len(progressive_result) == 1
|
||||
assert isinstance(progressive_result[0], PartialTranscription)
|
||||
assert progressive_result[0].text == "new partial"
|
||||
|
||||
|
||||
def test_on_session_end_resets_streaming_state():
|
||||
handler = object.__new__(ParakeetTDTSTTHandler)
|
||||
handler.start_language = "en"
|
||||
handler.enable_live_transcription = True
|
||||
handler.processing_final = True
|
||||
reset_calls = []
|
||||
handler.streaming_handler = SimpleNamespace(reset=lambda: reset_calls.append(True))
|
||||
|
||||
handler.on_session_end()
|
||||
|
||||
assert handler.processing_final is False
|
||||
assert handler.last_language == "en"
|
||||
assert reset_calls == [True]
|
||||
@@ -0,0 +1,875 @@
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import speech_to_speech.TTS.qwen3_tts_handler as qwen3_tts_module
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, EndOfResponse, TTSInput
|
||||
from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
|
||||
from speech_to_speech.TTS.qwen3_tts_handler import Qwen3TTSHandler
|
||||
|
||||
|
||||
def _audible_stream_chunk():
|
||||
return np.full(512, 0.1, dtype=np.float32)
|
||||
|
||||
|
||||
def test_setup_uses_mlx_backend_on_darwin_and_maps_qwen_repo_ids(monkeypatch):
|
||||
recorded = {}
|
||||
|
||||
def _setup_mlx(self, model_name):
|
||||
recorded["model_name"] = model_name
|
||||
|
||||
def _setup_faster(self, *args, **kwargs):
|
||||
raise AssertionError("Darwin setup should not use the faster-qwen3-tts backend")
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "darwin")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_faster", _setup_faster)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.setup(
|
||||
Event(),
|
||||
model_name="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
||||
device="cuda",
|
||||
)
|
||||
|
||||
assert handler.backend == "mlx"
|
||||
assert handler.device == "mps"
|
||||
assert handler.dtype is None
|
||||
assert handler.streaming_chunk_size == 4
|
||||
assert recorded["model_name"] == "mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-6bit"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("quantization", ["4bit", "6bit", "8bit"])
|
||||
def test_setup_supports_quantized_mlx_mapping_on_darwin(monkeypatch, quantization):
|
||||
recorded = {}
|
||||
|
||||
def _setup_mlx(self, model_name):
|
||||
recorded["model_name"] = model_name
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "darwin")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.setup(
|
||||
Event(),
|
||||
model_name="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||
mlx_quantization=quantization,
|
||||
)
|
||||
|
||||
assert handler.backend == "mlx"
|
||||
assert handler.mlx_quantization == quantization
|
||||
assert recorded["model_name"] == f"mlx-community/Qwen3-TTS-12Hz-0.6B-Base-{quantization}"
|
||||
|
||||
|
||||
def test_setup_preserves_explicit_mlx_model_suffix_when_quantization_unset(monkeypatch):
|
||||
recorded = {}
|
||||
|
||||
def _setup_mlx(self, model_name):
|
||||
recorded["model_name"] = model_name
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "darwin")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.setup(
|
||||
Event(),
|
||||
model_name="mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16",
|
||||
)
|
||||
|
||||
assert handler.backend == "mlx"
|
||||
assert handler.mlx_quantization is None
|
||||
assert recorded["model_name"] == "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||
|
||||
|
||||
def test_setup_preserves_faster_backend_off_darwin(monkeypatch):
|
||||
recorded = {}
|
||||
|
||||
def _setup_mlx(self, *args, **kwargs):
|
||||
raise AssertionError("Non-Darwin setup should not use the mlx backend")
|
||||
|
||||
def _setup_faster(self, model_name, dtype, attn_implementation, backend):
|
||||
recorded["model_name"] = model_name
|
||||
recorded["dtype"] = dtype
|
||||
recorded["attn_implementation"] = attn_implementation
|
||||
recorded["backend"] = backend
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "linux")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_faster", _setup_faster)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.setup(
|
||||
Event(),
|
||||
model_name="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||
device="cuda",
|
||||
dtype="float16",
|
||||
attn_implementation="sdpa",
|
||||
)
|
||||
|
||||
assert handler.backend == "faster_qwen3_tts"
|
||||
assert handler.faster_backend == "ggml"
|
||||
assert handler.streaming_chunk_size == 8
|
||||
assert recorded == {
|
||||
"model_name": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||
"dtype": "float16",
|
||||
"attn_implementation": "sdpa",
|
||||
"backend": "ggml",
|
||||
}
|
||||
|
||||
|
||||
def test_setup_passes_torch_backend_override_off_darwin(monkeypatch):
|
||||
recorded = {}
|
||||
|
||||
def _setup_mlx(self, *args, **kwargs):
|
||||
raise AssertionError("Non-Darwin setup should not use the mlx backend")
|
||||
|
||||
def _setup_faster(self, model_name, dtype, attn_implementation, backend):
|
||||
recorded["backend"] = backend
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "linux")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_faster", _setup_faster)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.setup(Event(), backend="torch")
|
||||
|
||||
assert handler.backend == "faster_qwen3_tts"
|
||||
assert handler.faster_backend == "torch"
|
||||
assert recorded["backend"] == "torch"
|
||||
|
||||
|
||||
def test_setup_defaults_to_custom_voice_profile_off_darwin(monkeypatch):
|
||||
recorded = {}
|
||||
|
||||
def _setup_mlx(self, *args, **kwargs):
|
||||
raise AssertionError("Non-Darwin setup should not use the mlx backend")
|
||||
|
||||
def _setup_faster(self, model_name, dtype, attn_implementation, backend):
|
||||
recorded["model_name"] = model_name
|
||||
recorded["dtype"] = dtype
|
||||
recorded["attn_implementation"] = attn_implementation
|
||||
recorded["backend"] = backend
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "linux")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_faster", _setup_faster)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.setup(Event())
|
||||
|
||||
assert handler.backend == "faster_qwen3_tts"
|
||||
assert handler.faster_backend == "ggml"
|
||||
assert recorded["model_name"] == "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||
assert recorded["backend"] == "ggml"
|
||||
assert handler.ref_audio is None
|
||||
assert handler.speaker == "Aiden"
|
||||
assert handler.language == "auto"
|
||||
assert handler.non_streaming_mode is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("language", "expected"),
|
||||
[
|
||||
("zh", "chinese"),
|
||||
("zh-CN", "chinese"),
|
||||
("zh_Hans", "chinese"),
|
||||
("Chinese", "chinese"),
|
||||
("en-US", "english"),
|
||||
("English", "english"),
|
||||
("Auto", "auto"),
|
||||
("", "auto"),
|
||||
],
|
||||
)
|
||||
def test_setup_normalizes_qwen3_language_aliases(monkeypatch, language, expected):
|
||||
def _setup_mlx(self, *args, **kwargs):
|
||||
raise AssertionError("Non-Darwin setup should not use the mlx backend")
|
||||
|
||||
def _setup_faster(self, model_name, dtype, attn_implementation, backend):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "linux")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_faster", _setup_faster)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.setup(Event(), language=language)
|
||||
|
||||
assert handler.language == expected
|
||||
|
||||
|
||||
def test_setup_preserves_explicit_chunk_size_on_darwin(monkeypatch):
|
||||
def _setup_mlx(self, model_name):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "darwin")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.setup(
|
||||
Event(),
|
||||
model_name="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||
streaming_chunk_size=4,
|
||||
)
|
||||
|
||||
assert handler.backend == "mlx"
|
||||
assert handler.streaming_chunk_size == 4
|
||||
|
||||
|
||||
def test_setup_logs_when_non_streaming_mode_set_on_darwin(monkeypatch, caplog):
|
||||
def _setup_mlx(self, model_name):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "darwin")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
|
||||
with caplog.at_level("DEBUG"):
|
||||
handler.setup(
|
||||
Event(),
|
||||
model_name="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||
non_streaming_mode=True,
|
||||
)
|
||||
|
||||
assert "mlx-audio does not expose non_streaming_mode yet" in caplog.text
|
||||
|
||||
|
||||
def test_setup_rejects_invalid_mlx_quantization(monkeypatch):
|
||||
def _setup_mlx(self, model_name):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "darwin")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "_setup_mlx", _setup_mlx)
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported qwen3_tts_mlx_quantization"):
|
||||
handler.setup(
|
||||
Event(),
|
||||
model_name="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||
mlx_quantization="5bit",
|
||||
)
|
||||
|
||||
|
||||
def test_setup_rejects_invalid_faster_backend(monkeypatch):
|
||||
monkeypatch.setattr(qwen3_tts_module, "platform", "linux")
|
||||
monkeypatch.setattr(Qwen3TTSHandler, "warmup", lambda self: None)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported qwen3_tts_backend"):
|
||||
handler.setup(Event(), backend="cuda")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("faster_backend", ["ggml", "torch"])
|
||||
def test_warmup_uses_public_faster_backend_api(faster_backend):
|
||||
calls = []
|
||||
generated = []
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.backend = "faster_qwen3_tts"
|
||||
handler.faster_backend = faster_backend
|
||||
handler.parity_mode = False
|
||||
handler.model = SimpleNamespace(
|
||||
warmup=lambda **kwargs: calls.append(kwargs),
|
||||
)
|
||||
handler._warmup_process = lambda text: generated.append(text) or iter(())
|
||||
|
||||
handler.warmup()
|
||||
|
||||
assert calls == [{"prefill_len": 100}]
|
||||
assert generated == ["Hello, this is a warmup."]
|
||||
|
||||
|
||||
def test_warmup_logs_backend_neutral_failure(caplog):
|
||||
def fail_warmup(**_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.backend = "faster_qwen3_tts"
|
||||
handler.faster_backend = "ggml"
|
||||
handler.parity_mode = False
|
||||
handler.model = SimpleNamespace(warmup=fail_warmup)
|
||||
handler._warmup_process = lambda _text: iter(())
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
handler.warmup()
|
||||
|
||||
assert "Qwen3-TTS backend warmup failed: boom" in caplog.text
|
||||
assert "CUDA graph capture failed" not in caplog.text
|
||||
|
||||
|
||||
def test_mlx_helper_methods_use_model_config_and_streaming_conversion():
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.backend = "mlx"
|
||||
handler.model_name = "mlx-community/Qwen3-TTS-12Hz-1.7B-CustomVoice-bf16"
|
||||
handler.speaker = None
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.model = SimpleNamespace(
|
||||
config=SimpleNamespace(tts_model_type="custom_voice"),
|
||||
get_supported_speakers=lambda: ["Vivian", "Ryan"],
|
||||
)
|
||||
|
||||
assert handler._model_type() == "custom_voice"
|
||||
assert handler._resolve_speaker() == "Vivian"
|
||||
assert handler._mlx_streaming_interval() == pytest.approx(0.64)
|
||||
|
||||
|
||||
def test_prepare_mlx_ref_audio_normalizes_file_and_caches_result(monkeypatch, tmp_path):
|
||||
source = tmp_path / "source.wav"
|
||||
source.write_bytes(b"fake")
|
||||
|
||||
save_calls = []
|
||||
|
||||
fake_sf = SimpleNamespace(
|
||||
read=lambda path, always_2d=False, dtype=None: (
|
||||
[[0.1, 0.2], [0.3, 0.4]],
|
||||
44100,
|
||||
),
|
||||
write=lambda path, waveform, sample_rate, format=None, subtype=None: (
|
||||
save_calls.append((path, sample_rate, format, subtype)),
|
||||
Path(path).write_bytes(b"RIFF"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "soundfile", fake_sf)
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.backend = "mlx"
|
||||
handler.model = SimpleNamespace(sample_rate=24000)
|
||||
handler._mlx_ref_audio_cache = {}
|
||||
handler._mlx_temp_ref_audio_files = set()
|
||||
|
||||
normalized = handler._prepare_mlx_ref_audio(str(source))
|
||||
normalized_again = handler._prepare_mlx_ref_audio(str(source))
|
||||
|
||||
assert normalized == normalized_again
|
||||
assert Path(normalized).exists()
|
||||
assert save_calls == [(normalized, 24000, "WAV", "PCM_16")]
|
||||
|
||||
|
||||
def test_apply_session_voice_override_warns_for_non_file_for_base_model(caplog):
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
fake_cfg = SimpleNamespace(session=SimpleNamespace(audio=SimpleNamespace(output=SimpleNamespace(voice="alloy"))))
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.speaker = None
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
handler._apply_session_voice_override("base", runtime_config=fake_cfg)
|
||||
|
||||
assert handler.ref_audio == "TTS/ref_audio.wav"
|
||||
assert handler.speaker is None
|
||||
assert "Ignoring Qwen3-TTS session voice override" in caplog.text
|
||||
|
||||
|
||||
def test_apply_session_voice_override_ignores_unsupported_custom_voice_speaker(caplog):
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
fake_cfg = SimpleNamespace(session=SimpleNamespace(audio=SimpleNamespace(output=SimpleNamespace(voice="cedar"))))
|
||||
handler.ref_audio = None
|
||||
handler.speaker = "Aiden"
|
||||
handler.model = SimpleNamespace(model=SimpleNamespace(get_supported_speakers=lambda: ["aiden", "vivian"]))
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
handler._apply_session_voice_override("custom_voice", runtime_config=fake_cfg)
|
||||
|
||||
assert handler.ref_audio is None
|
||||
assert handler.speaker == "Aiden"
|
||||
assert "not a supported CustomVoice speaker" in caplog.text
|
||||
assert "cedar" in caplog.text
|
||||
|
||||
|
||||
def test_apply_session_voice_override_accepts_supported_custom_voice_speaker():
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
fake_cfg = SimpleNamespace(session=SimpleNamespace(audio=SimpleNamespace(output=SimpleNamespace(voice="Vivian"))))
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.speaker = "Aiden"
|
||||
handler.model = SimpleNamespace(model=SimpleNamespace(get_supported_speakers=lambda: ["aiden", "vivian"]))
|
||||
|
||||
handler._apply_session_voice_override("custom_voice", runtime_config=fake_cfg)
|
||||
|
||||
assert handler.ref_audio is None
|
||||
assert handler.speaker == "vivian"
|
||||
|
||||
|
||||
def test_process_only_reenables_listening_after_end_of_response(monkeypatch):
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.speaker = None
|
||||
handler.instruct = None
|
||||
handler.language = "English"
|
||||
handler.backend = "mlx"
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(config=SimpleNamespace(tts_model_type="base"))
|
||||
handler._apply_session_voice_override = lambda model_type, runtime_config=None, response=None: None
|
||||
handler._process_voice_clone = lambda text: iter([np.zeros(512, dtype=np.int16)])
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
outputs = list(handler.process(TTSInput(text="Hello there.", runtime_config=RuntimeConfig())))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert handler.should_listen.is_set() is False
|
||||
|
||||
end_outputs = list(handler.process(EndOfResponse()))
|
||||
|
||||
assert end_outputs == [AUDIO_RESPONSE_DONE]
|
||||
|
||||
|
||||
def test_process_waits_for_pending_reopen_and_drops_stale_tts_input():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.speculative_turns = tracker
|
||||
done = Event()
|
||||
outputs = []
|
||||
|
||||
def run_process():
|
||||
outputs.extend(
|
||||
handler.process(
|
||||
TTSInput(
|
||||
text="stale",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
done.set()
|
||||
|
||||
thread = Thread(target=run_process)
|
||||
thread.start()
|
||||
|
||||
assert not done.wait(0.05)
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
assert done.wait(1.0)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert outputs == []
|
||||
|
||||
|
||||
def test_process_waits_for_pending_reopen_and_drops_stale_end_of_response():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.speculative_turns = tracker
|
||||
done = Event()
|
||||
outputs = []
|
||||
|
||||
def run_process():
|
||||
outputs.extend(handler.process(EndOfResponse(turn_id="turn_1", turn_revision=0)))
|
||||
done.set()
|
||||
|
||||
thread = Thread(target=run_process)
|
||||
thread.start()
|
||||
|
||||
assert not done.wait(0.05)
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
assert done.wait(1.0)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert outputs == []
|
||||
|
||||
|
||||
def test_process_waits_for_reopen_grace_and_drops_stale_tts_input():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
tracker.start_reopen_grace("turn_1", 0, grace_s=0.5)
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.speculative_turns = tracker
|
||||
done = Event()
|
||||
outputs = []
|
||||
|
||||
def run_process():
|
||||
outputs.extend(
|
||||
handler.process(
|
||||
TTSInput(
|
||||
text="stale",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
done.set()
|
||||
|
||||
thread = Thread(target=run_process)
|
||||
thread.start()
|
||||
|
||||
assert not done.wait(0.05)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
assert done.wait(1.0)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert outputs == []
|
||||
|
||||
|
||||
def test_process_waits_for_reopen_grace_and_drops_stale_end_of_response():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
tracker.start_reopen_grace("turn_1", 0, grace_s=0.5)
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.speculative_turns = tracker
|
||||
done = Event()
|
||||
outputs = []
|
||||
|
||||
def run_process():
|
||||
outputs.extend(handler.process(EndOfResponse(turn_id="turn_1", turn_revision=0)))
|
||||
done.set()
|
||||
|
||||
thread = Thread(target=run_process)
|
||||
thread.start()
|
||||
|
||||
assert not done.wait(0.05)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
assert done.wait(1.0)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert outputs == []
|
||||
|
||||
|
||||
def test_process_commits_turn_before_generating_audio(monkeypatch, caplog):
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.speculative_turns = tracker
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.speaker = None
|
||||
handler.instruct = None
|
||||
handler.language = "English"
|
||||
handler.backend = "mlx"
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(config=SimpleNamespace(tts_model_type="base"))
|
||||
handler._apply_session_voice_override = lambda model_type, runtime_config=None, response=None: None
|
||||
|
||||
def _process_voice_clone(text):
|
||||
assert tracker.is_committed("turn_1", 0)
|
||||
yield np.zeros(512, dtype=np.int16)
|
||||
|
||||
handler._process_voice_clone = _process_voice_clone
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="speech_to_speech.TTS.qwen3_tts_handler"):
|
||||
outputs = list(
|
||||
handler.process(
|
||||
TTSInput(
|
||||
text="Hello there.",
|
||||
turn_id="turn_1",
|
||||
turn_revision=0,
|
||||
speech_stopped_at_s=qwen3_tts_module.perf_counter() - 1.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert tracker.is_committed("turn_1", 0)
|
||||
assert "Last speech detected to first speech out:" in caplog.text
|
||||
|
||||
|
||||
def test_process_does_not_set_should_listen_when_generation_fails(monkeypatch):
|
||||
"""TTS no longer manages should_listen; the I/O streamer does via AUDIO_RESPONSE_DONE."""
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.speaker = None
|
||||
handler.instruct = None
|
||||
handler.language = "English"
|
||||
handler.backend = "mlx"
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(config=SimpleNamespace(tts_model_type="base"))
|
||||
handler._apply_session_voice_override = lambda model_type, runtime_config=None, response=None: None
|
||||
|
||||
def _boom(text):
|
||||
raise RuntimeError("boom")
|
||||
yield # pragma: no cover
|
||||
|
||||
handler._process_voice_clone = _boom
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
outputs = list(handler.process(TTSInput(text="Hello there.")))
|
||||
|
||||
assert outputs == []
|
||||
assert handler.should_listen.is_set() is False
|
||||
|
||||
|
||||
def test_process_voice_clone_passes_non_streaming_mode_to_faster_backend(monkeypatch):
|
||||
captured = {}
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.ref_text = "Reference text."
|
||||
handler.speaker = None
|
||||
handler.instruct = None
|
||||
handler.language = "English"
|
||||
handler.xvec_only = False
|
||||
handler.parity_mode = False
|
||||
handler.non_streaming_mode = False
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.max_new_tokens = 360
|
||||
handler.blocksize = 512
|
||||
handler.backend = "faster_qwen3_tts"
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(
|
||||
model=SimpleNamespace(model=SimpleNamespace(tts_model_type="base")),
|
||||
generate_voice_clone_streaming=lambda **kwargs: (
|
||||
captured.update(kwargs),
|
||||
iter([(_audible_stream_chunk(), 16000, {})]),
|
||||
)[1],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
outputs = list(handler.process(TTSInput(text="Hello there.")))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert captured["non_streaming_mode"] is False
|
||||
|
||||
|
||||
def test_process_voice_clone_passes_none_non_streaming_mode_when_unset(monkeypatch):
|
||||
captured = {}
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.ref_text = "Reference text."
|
||||
handler.speaker = None
|
||||
handler.instruct = None
|
||||
handler.language = "English"
|
||||
handler.xvec_only = False
|
||||
handler.parity_mode = False
|
||||
handler.non_streaming_mode = None
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.max_new_tokens = 360
|
||||
handler.blocksize = 512
|
||||
handler.backend = "faster_qwen3_tts"
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(
|
||||
model=SimpleNamespace(model=SimpleNamespace(tts_model_type="base")),
|
||||
generate_voice_clone_streaming=lambda **kwargs: (
|
||||
captured.update(kwargs),
|
||||
iter([(_audible_stream_chunk(), 16000, {})]),
|
||||
)[1],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
outputs = list(handler.process(TTSInput(text="Hello there.")))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert captured["non_streaming_mode"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [None, False, True])
|
||||
def test_process_custom_voice_passes_non_streaming_mode_to_faster_backend(monkeypatch, override):
|
||||
captured = {}
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.ref_audio = None
|
||||
handler.ref_text = "Reference text."
|
||||
handler.speaker = "Vivian"
|
||||
handler.instruct = "calm"
|
||||
handler.language = "English"
|
||||
handler.xvec_only = False
|
||||
handler.parity_mode = False
|
||||
handler.non_streaming_mode = override
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.max_new_tokens = 360
|
||||
handler.blocksize = 512
|
||||
handler.backend = "faster_qwen3_tts"
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(
|
||||
model=SimpleNamespace(model=SimpleNamespace(tts_model_type="custom_voice")),
|
||||
generate_custom_voice_streaming=lambda **kwargs: (
|
||||
captured.update(kwargs),
|
||||
iter([(_audible_stream_chunk(), 16000, {})]),
|
||||
)[1],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
outputs = list(handler.process(TTSInput(text="Hello there.")))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert captured["non_streaming_mode"] is override
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [None, False, True])
|
||||
def test_process_voice_design_passes_non_streaming_mode_to_faster_backend(monkeypatch, override):
|
||||
captured = {}
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.ref_audio = None
|
||||
handler.ref_text = "Reference text."
|
||||
handler.speaker = None
|
||||
handler.instruct = "bright radio voice"
|
||||
handler.language = "English"
|
||||
handler.xvec_only = False
|
||||
handler.parity_mode = False
|
||||
handler.non_streaming_mode = override
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.max_new_tokens = 360
|
||||
handler.blocksize = 512
|
||||
handler.backend = "faster_qwen3_tts"
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(
|
||||
model=SimpleNamespace(model=SimpleNamespace(tts_model_type="voice_design")),
|
||||
generate_voice_design_streaming=lambda **kwargs: (
|
||||
captured.update(kwargs),
|
||||
iter([(_audible_stream_chunk(), 16000, {})]),
|
||||
)[1],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
outputs = list(handler.process(TTSInput(text="Hello there.")))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert captured["non_streaming_mode"] is override
|
||||
|
||||
|
||||
def test_estimate_max_new_tokens_scales_with_utterance_length():
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.max_new_tokens = 1536
|
||||
|
||||
short_budget = handler._estimate_max_new_tokens("Hello there.")
|
||||
long_text = " ".join(["This is a deliberately long sentence for the Qwen3 TTS budget estimator."] * 12)
|
||||
long_budget = handler._estimate_max_new_tokens(long_text)
|
||||
|
||||
assert short_budget == 360
|
||||
assert long_budget > short_budget
|
||||
assert long_budget % handler.streaming_chunk_size == 0
|
||||
assert long_budget <= handler.max_new_tokens
|
||||
|
||||
|
||||
def test_estimate_max_new_tokens_respects_configured_cap():
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.max_new_tokens = 400
|
||||
|
||||
long_text = " ".join(["This is a deliberately long sentence for the Qwen3 TTS budget estimator."] * 12)
|
||||
|
||||
assert handler._estimate_max_new_tokens(long_text) == 400
|
||||
|
||||
|
||||
def test_estimate_max_new_tokens_can_exceed_default_ceiling_when_raised():
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.max_new_tokens = 2400
|
||||
|
||||
long_text = " ".join(["This is a deliberately long sentence for the Qwen3 TTS budget estimator."] * 30)
|
||||
|
||||
assert handler._estimate_max_new_tokens(long_text) > 1536
|
||||
|
||||
|
||||
def test_process_voice_clone_scales_max_new_tokens_for_faster_backend(monkeypatch):
|
||||
captured = {}
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.ref_text = "Reference text."
|
||||
handler.speaker = None
|
||||
handler.instruct = None
|
||||
handler.language = "English"
|
||||
handler.xvec_only = False
|
||||
handler.parity_mode = False
|
||||
handler.non_streaming_mode = None
|
||||
handler.streaming_chunk_size = 8
|
||||
handler.max_new_tokens = 1536
|
||||
handler.blocksize = 512
|
||||
handler.backend = "faster_qwen3_tts"
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(
|
||||
model=SimpleNamespace(model=SimpleNamespace(tts_model_type="base")),
|
||||
generate_voice_clone_streaming=lambda **kwargs: (
|
||||
captured.update(kwargs),
|
||||
iter([(_audible_stream_chunk(), 16000, {})]),
|
||||
)[1],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
|
||||
long_text = " ".join(["This is a deliberately long sentence for the faster Qwen3 TTS backend."] * 12)
|
||||
outputs = list(handler.process(TTSInput(text=long_text)))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert captured["max_new_tokens"] == handler._estimate_max_new_tokens(long_text)
|
||||
assert captured["max_new_tokens"] > 360
|
||||
|
||||
|
||||
def test_process_voice_clone_scales_max_tokens_for_mlx_backend(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class _FakeMLXLockContext:
|
||||
def __init__(self, handler_name, timeout):
|
||||
self.handler_name = handler_name
|
||||
self.timeout = timeout
|
||||
|
||||
def __enter__(self):
|
||||
return True
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.cancel_scope = None
|
||||
handler.ref_audio = "TTS/ref_audio.wav"
|
||||
handler.ref_text = "Reference text."
|
||||
handler.speaker = None
|
||||
handler.instruct = None
|
||||
handler.language = "English"
|
||||
handler.xvec_only = False
|
||||
handler.parity_mode = False
|
||||
handler.non_streaming_mode = None
|
||||
handler.streaming_chunk_size = 4
|
||||
handler.max_new_tokens = 1536
|
||||
handler.blocksize = 512
|
||||
handler.backend = "mlx"
|
||||
handler.gen_kwargs = {}
|
||||
handler.queue_in = Queue()
|
||||
handler.model = SimpleNamespace(
|
||||
config=SimpleNamespace(tts_model_type="base"),
|
||||
generate=lambda **kwargs: (
|
||||
captured.update(kwargs),
|
||||
iter([(_audible_stream_chunk(), 16000, {})]),
|
||||
)[1],
|
||||
)
|
||||
handler._prepare_mlx_ref_audio = lambda ref_audio: ref_audio
|
||||
|
||||
monkeypatch.setattr(qwen3_tts_module.console, "print", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(qwen3_tts_module, "MLXLockContext", _FakeMLXLockContext)
|
||||
|
||||
long_text = " ".join(["This is a deliberately long sentence for the MLX Qwen3 TTS backend."] * 12)
|
||||
outputs = list(handler.process(TTSInput(text=long_text)))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert captured["max_tokens"] == handler._estimate_max_new_tokens(long_text)
|
||||
assert captured["max_tokens"] > 360
|
||||
@@ -0,0 +1,597 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from openai import Stream
|
||||
from openai.types.realtime.conversation_item import (
|
||||
RealtimeConversationItemAssistantMessage,
|
||||
RealtimeConversationItemFunctionCallOutput,
|
||||
)
|
||||
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
|
||||
from openai.types.responses import (
|
||||
Response,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseTextDeltaEvent,
|
||||
)
|
||||
from openai.types.responses.response_output_text import ResponseOutputText
|
||||
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
from speech_to_speech.LLM.base_openai_compatible_language_model import WARMUP_MAX_RETRIES
|
||||
from speech_to_speech.LLM.chat import Chat, make_user_message
|
||||
from speech_to_speech.LLM.responses_api_language_model import ResponsesApiModelHandler
|
||||
from speech_to_speech.pipeline.cancel_scope import CancelScope
|
||||
from speech_to_speech.pipeline.messages import EndOfResponse, GenerateResponseRequest, LLMResponseChunk, TokenUsage
|
||||
|
||||
|
||||
def _make_text_delta_event(text):
|
||||
evt = MagicMock(spec=ResponseTextDeltaEvent)
|
||||
evt.type = "response.output_text.delta"
|
||||
evt.delta = text
|
||||
return evt
|
||||
|
||||
|
||||
def _make_output_item_done_event(role="assistant", content="Hello.", item_type="message"):
|
||||
evt = MagicMock(spec=ResponseOutputItemDoneEvent)
|
||||
evt.type = "response.output_item.done"
|
||||
if item_type == "function_call":
|
||||
evt.item = SimpleNamespace(
|
||||
type="function_call",
|
||||
model_dump=lambda: {"type": "function_call", "name": "test_fn"},
|
||||
)
|
||||
else:
|
||||
evt.item = SimpleNamespace(
|
||||
type="message",
|
||||
role=role,
|
||||
content=content,
|
||||
)
|
||||
return evt
|
||||
|
||||
|
||||
def _make_stream(events):
|
||||
stream = MagicMock(spec=Stream)
|
||||
stream.__iter__.return_value = iter(events)
|
||||
return stream
|
||||
|
||||
|
||||
def _make_function_call_done_event(name="camera", arguments="{}"):
|
||||
return ResponseOutputItemDoneEvent(
|
||||
type="response.output_item.done",
|
||||
output_index=1,
|
||||
sequence_number=2,
|
||||
item=ResponseFunctionToolCall(
|
||||
type="function_call",
|
||||
call_id="call_original",
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_response(output, usage=None):
|
||||
resp = MagicMock(spec=Response)
|
||||
resp.usage = usage
|
||||
resp.output = output
|
||||
return resp
|
||||
|
||||
|
||||
def _make_runtime_config(chat_size=2, instructions="You are a helpful AI assistant."):
|
||||
from openai.types.realtime import RealtimeSessionCreateRequest
|
||||
|
||||
return RuntimeConfig(
|
||||
chat=Chat(chat_size),
|
||||
session=RealtimeSessionCreateRequest(type="realtime", instructions=instructions),
|
||||
)
|
||||
|
||||
|
||||
def _make_request(text="Hi", chat_size=2):
|
||||
cfg = _make_runtime_config(chat_size=chat_size)
|
||||
cfg.chat.add_item(make_user_message(text))
|
||||
return GenerateResponseRequest(runtime_config=cfg)
|
||||
|
||||
|
||||
def _make_handler(*, disable_thinking=False, stream=True, cancel_scope=None):
|
||||
handler = object.__new__(ResponsesApiModelHandler)
|
||||
handler.model_name = "test-model"
|
||||
handler.stream = stream
|
||||
handler.stream_batch_sentences = 1
|
||||
handler.gen_kwargs = {}
|
||||
handler.request_timeout_s = 20.0
|
||||
handler.request_timeout = 20.0
|
||||
handler.disable_thinking = disable_thinking
|
||||
handler._extra_body = {"chat_template_kwargs": {"enable_thinking": False}} if disable_thinking else None
|
||||
handler.user_role = "user"
|
||||
handler.cancel_scope = cancel_scope
|
||||
handler.speculative_turns = None
|
||||
handler.tools = None
|
||||
handler.tools_choice = None
|
||||
handler.enable_lang_prompt = False
|
||||
handler.compactor = None
|
||||
return handler
|
||||
|
||||
|
||||
def test_warmup_uses_request_scoped_sdk_retries():
|
||||
handler = _make_handler()
|
||||
handler.client = MagicMock()
|
||||
handler.client.with_options.return_value = handler.client
|
||||
|
||||
handler.warmup()
|
||||
|
||||
handler.client.with_options.assert_called_once_with(max_retries=WARMUP_MAX_RETRIES)
|
||||
handler.client.responses.create.assert_called_once()
|
||||
|
||||
|
||||
def test_warmup_failure_propagates_and_prevents_readiness():
|
||||
handler = _make_handler()
|
||||
handler.client = MagicMock()
|
||||
handler.client.with_options.return_value = handler.client
|
||||
handler.client.responses.create.side_effect = RuntimeError("provider unavailable")
|
||||
|
||||
with pytest.raises(RuntimeError, match="provider unavailable"):
|
||||
handler.warmup()
|
||||
|
||||
|
||||
def test_process_streams_text_from_response_events():
|
||||
handler = _make_handler()
|
||||
|
||||
streamed_events = [
|
||||
_make_text_delta_event("Hello. "),
|
||||
_make_text_delta_event("How are you?"),
|
||||
_make_output_item_done_event(content="Hello. How are you?"),
|
||||
]
|
||||
|
||||
handler.client = SimpleNamespace(
|
||||
responses=SimpleNamespace(
|
||||
create=lambda **kwargs: _make_stream(streamed_events),
|
||||
)
|
||||
)
|
||||
|
||||
outputs = list(handler.process(_make_request("Hi")))
|
||||
|
||||
assert len(outputs) == 3
|
||||
assert isinstance(outputs[0], LLMResponseChunk) and outputs[0].text == "Hello."
|
||||
assert isinstance(outputs[1], LLMResponseChunk) and outputs[1].text == "How are you?"
|
||||
assert isinstance(outputs[2], EndOfResponse)
|
||||
|
||||
|
||||
def test_text_only_streams_raw_deltas_without_sentence_trimming():
|
||||
"""Text-only streams (so a new speech turn can interrupt it) and forwards each
|
||||
delta verbatim — no sent_tokenize (newlines / markdown survive) and no
|
||||
remove_unspeechable (emoji / symbols survive)."""
|
||||
handler = _make_handler(stream=True)
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _make_stream(
|
||||
[
|
||||
_make_text_delta_event("# Title 🎉\n"),
|
||||
_make_text_delta_event("- one\n- two 😀\n"),
|
||||
_make_output_item_done_event(content="# Title 🎉\n- one\n- two 😀\n"),
|
||||
]
|
||||
)
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
|
||||
cfg = _make_runtime_config()
|
||||
cfg.chat.add_item(make_user_message("Hi"))
|
||||
req = GenerateResponseRequest(
|
||||
runtime_config=cfg,
|
||||
response=RealtimeResponseCreateParams(output_modalities=["text"]),
|
||||
)
|
||||
|
||||
outputs = list(handler.process(req))
|
||||
|
||||
# Still streamed, not a single buffered chunk.
|
||||
assert captured["stream"] is True
|
||||
texts = [o.text for o in outputs if isinstance(o, LLMResponseChunk)]
|
||||
# Raw deltas: markdown layout AND emoji preserved (no trimming, no unspeechable filter).
|
||||
assert texts == ["# Title 🎉\n", "- one\n- two 😀\n"]
|
||||
assert "".join(texts) == "# Title 🎉\n- one\n- two 😀\n"
|
||||
|
||||
|
||||
def test_audio_response_sentence_batches_streaming_call():
|
||||
handler = _make_handler(stream=True)
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _make_stream([_make_text_delta_event("Hi."), _make_output_item_done_event(content="Hi.")])
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
|
||||
# response=None defaults to audio, so streaming is preserved.
|
||||
list(handler.process(_make_request("Hi")))
|
||||
|
||||
assert captured["stream"] is True
|
||||
|
||||
|
||||
def test_process_flushes_tool_lead_in_before_function_call_with_sentence_batching():
|
||||
handler = _make_handler()
|
||||
handler.stream_batch_sentences = 3
|
||||
|
||||
streamed_events = [
|
||||
_make_text_delta_event("Let me check with my camera."),
|
||||
_make_function_call_done_event(name="camera"),
|
||||
]
|
||||
|
||||
handler.client = SimpleNamespace(
|
||||
responses=SimpleNamespace(
|
||||
create=lambda **kwargs: _make_stream(streamed_events),
|
||||
)
|
||||
)
|
||||
|
||||
outputs = list(handler.process(_make_request("What do you see?")))
|
||||
|
||||
assert len(outputs) == 3
|
||||
assert isinstance(outputs[0], LLMResponseChunk)
|
||||
assert outputs[0].text == "Let me check with my camera."
|
||||
assert outputs[0].tools == []
|
||||
assert isinstance(outputs[1], LLMResponseChunk)
|
||||
assert outputs[1].text == ""
|
||||
assert [tool.name for tool in outputs[1].tools] == ["camera"]
|
||||
assert isinstance(outputs[2], EndOfResponse)
|
||||
|
||||
|
||||
def test_process_preserves_streamed_text_after_function_call_order():
|
||||
handler = _make_handler()
|
||||
handler.stream_batch_sentences = 3
|
||||
|
||||
streamed_events = [
|
||||
_make_text_delta_event("Let me check."),
|
||||
_make_function_call_done_event(name="camera"),
|
||||
_make_text_delta_event("This may take a second."),
|
||||
]
|
||||
|
||||
handler.client = SimpleNamespace(
|
||||
responses=SimpleNamespace(
|
||||
create=lambda **kwargs: _make_stream(streamed_events),
|
||||
)
|
||||
)
|
||||
|
||||
outputs = list(handler.process(_make_request("What do you see?")))
|
||||
|
||||
assert len(outputs) == 4
|
||||
assert isinstance(outputs[0], LLMResponseChunk)
|
||||
assert outputs[0].text == "Let me check."
|
||||
assert outputs[0].tools == []
|
||||
assert isinstance(outputs[1], LLMResponseChunk)
|
||||
assert outputs[1].text == ""
|
||||
assert [tool.name for tool in outputs[1].tools] == ["camera"]
|
||||
assert isinstance(outputs[2], LLMResponseChunk)
|
||||
assert outputs[2].text == "This may take a second."
|
||||
assert outputs[2].tools == []
|
||||
assert isinstance(outputs[3], EndOfResponse)
|
||||
|
||||
|
||||
def test_process_preserves_nonstreaming_text_tool_text_order():
|
||||
handler = _make_handler(stream=False)
|
||||
|
||||
api_response = _make_response(
|
||||
output=[
|
||||
ResponseOutputMessage(
|
||||
id="msg_1",
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[ResponseOutputText(type="output_text", text="Let me check.", annotations=[])],
|
||||
),
|
||||
ResponseFunctionToolCall(
|
||||
type="function_call",
|
||||
call_id="call_original",
|
||||
name="camera",
|
||||
arguments="{}",
|
||||
),
|
||||
ResponseOutputMessage(
|
||||
id="msg_2",
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[ResponseOutputText(type="output_text", text="This may take a second.", annotations=[])],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=lambda **kwargs: api_response))
|
||||
|
||||
outputs = list(handler.process(_make_request("What do you see?")))
|
||||
|
||||
assert len(outputs) == 4
|
||||
assert isinstance(outputs[0], LLMResponseChunk)
|
||||
assert outputs[0].text == "Let me check."
|
||||
assert outputs[0].tools == []
|
||||
assert isinstance(outputs[1], LLMResponseChunk)
|
||||
assert outputs[1].text == ""
|
||||
assert [tool.name for tool in outputs[1].tools] == ["camera"]
|
||||
assert isinstance(outputs[2], LLMResponseChunk)
|
||||
assert outputs[2].text == "This may take a second."
|
||||
assert outputs[2].tools == []
|
||||
assert isinstance(outputs[3], EndOfResponse)
|
||||
|
||||
|
||||
def test_process_handles_cancellation():
|
||||
scope = CancelScope()
|
||||
handler = _make_handler(cancel_scope=scope)
|
||||
|
||||
def fake_create(**kwargs):
|
||||
scope.cancel()
|
||||
return _make_stream([_make_text_delta_event("Hello")])
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
|
||||
outputs = list(handler.process(_make_request("Hi")))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert isinstance(outputs[0], EndOfResponse)
|
||||
|
||||
|
||||
def test_responses_api_timing_logs_only_text_chunks():
|
||||
handler = object.__new__(ResponsesApiModelHandler)
|
||||
handler._times = [0.01]
|
||||
|
||||
assert handler.timing_log_level == logging.INFO
|
||||
assert handler.should_log_timing(LLMResponseChunk(text="Hello."))
|
||||
assert not handler.should_log_timing(TokenUsage(input_tokens=1, output_tokens=1))
|
||||
assert not handler.should_log_timing(EndOfResponse())
|
||||
|
||||
|
||||
def test_process_read_timeout_ends_response_cleanly():
|
||||
handler = _make_handler()
|
||||
|
||||
def make_timeout_stream():
|
||||
stream = MagicMock(spec=Stream)
|
||||
stream.__iter__.side_effect = httpx.ReadTimeout("timed out")
|
||||
return stream
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=lambda **kwargs: make_timeout_stream()))
|
||||
|
||||
outputs = list(handler.process(_make_request("Hi")))
|
||||
|
||||
assert len(outputs) == 2
|
||||
assert (
|
||||
isinstance(outputs[0], LLMResponseChunk)
|
||||
and outputs[0].text == "Wow I'm a bit slow today, could you repeat that?"
|
||||
)
|
||||
assert isinstance(outputs[1], EndOfResponse)
|
||||
|
||||
|
||||
def test_generation_error_emits_failed_end_of_response():
|
||||
"""A non-timeout failure (e.g. provider rejecting empty input) must still emit a
|
||||
terminating EndOfResponse carrying the error, so the response is closed instead
|
||||
of escaping process() and locking st.in_response forever."""
|
||||
handler = _make_handler()
|
||||
|
||||
def boom(**kwargs):
|
||||
raise RuntimeError("input must not be empty")
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=boom))
|
||||
|
||||
outputs = list(handler.process(_make_request("Hi")))
|
||||
|
||||
eors = [o for o in outputs if isinstance(o, EndOfResponse)]
|
||||
assert len(eors) == 1
|
||||
assert eors[0].error is not None
|
||||
assert "input must not be empty" in eors[0].error
|
||||
# No partial output committed; the only thing emitted is the failed EndOfResponse.
|
||||
assert all(isinstance(o, EndOfResponse) for o in outputs)
|
||||
|
||||
|
||||
def test_empty_context_fails_with_clear_message_without_calling_provider():
|
||||
"""Out-of-band, text-only, empty `instructions`, input=[] -> empty context. We
|
||||
fail fast with a clear, instructions-aware message and never call the provider
|
||||
(which would reject the empty input), so the response terminates instead of
|
||||
hanging."""
|
||||
handler = _make_handler()
|
||||
called = False
|
||||
|
||||
def fake_create(**kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
return _make_response(output=[])
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
|
||||
cfg = _make_runtime_config(instructions="") # empty instructions -> no system message
|
||||
req = GenerateResponseRequest(
|
||||
runtime_config=cfg,
|
||||
response=RealtimeResponseCreateParams(
|
||||
conversation="none",
|
||||
output_modalities=["text"],
|
||||
input=[],
|
||||
),
|
||||
)
|
||||
|
||||
outputs = list(handler.process(req))
|
||||
|
||||
assert not called # short-circuited before reaching the provider
|
||||
eors = [o for o in outputs if isinstance(o, EndOfResponse)]
|
||||
assert len(eors) == 1
|
||||
assert eors[0].error is not None
|
||||
assert "instructions" in eors[0].error
|
||||
assert "input" in eors[0].error
|
||||
|
||||
|
||||
def test_disable_thinking_passes_extra_body():
|
||||
handler = _make_handler(disable_thinking=True)
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _make_stream(
|
||||
[
|
||||
_make_text_delta_event("Ok"),
|
||||
_make_output_item_done_event(content="Ok"),
|
||||
]
|
||||
)
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
|
||||
list(handler.process(_make_request("Hi")))
|
||||
|
||||
assert captured["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
|
||||
def test_no_disable_thinking_omits_extra_body():
|
||||
handler = _make_handler(disable_thinking=False)
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _make_stream(
|
||||
[
|
||||
_make_text_delta_event("Ok"),
|
||||
_make_output_item_done_event(content="Ok"),
|
||||
]
|
||||
)
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
|
||||
list(handler.process(_make_request("Hi")))
|
||||
|
||||
assert captured.get("extra_body") is None
|
||||
|
||||
|
||||
def test_second_turn_flattens_assistant_history_for_responses():
|
||||
handler = _make_handler(stream=False)
|
||||
captured = {}
|
||||
cfg = _make_runtime_config(chat_size=2)
|
||||
|
||||
first_response = _make_response(
|
||||
output=[
|
||||
ResponseOutputMessage(
|
||||
id="msg_1",
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[ResponseOutputText(type="output_text", text="Hello.", annotations=[])],
|
||||
)
|
||||
],
|
||||
)
|
||||
second_response = _make_response(output=[])
|
||||
call_count = 0
|
||||
|
||||
def fake_create(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return first_response
|
||||
captured.update(kwargs)
|
||||
return second_response
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
|
||||
cfg.chat.add_item(make_user_message("Hi"))
|
||||
list(handler.process(GenerateResponseRequest(runtime_config=cfg)))
|
||||
cfg.chat.add_item(make_user_message("Again"))
|
||||
list(handler.process(GenerateResponseRequest(runtime_config=cfg)))
|
||||
|
||||
assistant_items = [item for item in captured["input"] if item.get("role") == "assistant"]
|
||||
assert len(assistant_items) == 1
|
||||
ai = assistant_items[0]
|
||||
assert ai["role"] == "assistant"
|
||||
assert ai["type"] == "message"
|
||||
assert ai["status"] == "completed"
|
||||
assert len(ai["content"]) == 1
|
||||
assert ai["content"][0]["type"] == "output_text"
|
||||
assert ai["content"][0]["text"] == "Hello."
|
||||
|
||||
|
||||
# ── Out-of-band (conversation="none") responses ──────────────────────────
|
||||
|
||||
|
||||
def _make_oob_request(input_items, *, conversation="none", chat_size=2, seed_default="Hi"):
|
||||
cfg = _make_runtime_config(chat_size=chat_size)
|
||||
if seed_default is not None:
|
||||
cfg.chat.add_item(make_user_message(seed_default))
|
||||
resp = RealtimeResponseCreateParams(conversation=conversation, input=input_items)
|
||||
return GenerateResponseRequest(runtime_config=cfg, response=resp), cfg
|
||||
|
||||
|
||||
def _capture_create(handler, events):
|
||||
captured = {}
|
||||
|
||||
def fake_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _make_stream(events)
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
return captured
|
||||
|
||||
|
||||
def test_out_of_band_emits_output_but_does_not_commit_to_default_conversation():
|
||||
handler = _make_handler()
|
||||
req, cfg = _make_oob_request([make_user_message("OOB question")])
|
||||
events = [_make_text_delta_event("OOB answer."), _make_output_item_done_event(content="OOB answer.")]
|
||||
_capture_create(handler, events)
|
||||
|
||||
outputs = list(handler.process(req))
|
||||
|
||||
# The response is still produced and streamed back to the client...
|
||||
assert any(isinstance(o, LLMResponseChunk) and o.text == "OOB answer." for o in outputs)
|
||||
# ...but the default conversation keeps only the seeded user turn — no assistant commit.
|
||||
assert len(cfg.chat.buffer) == 1
|
||||
assert not any(isinstance(i, RealtimeConversationItemAssistantMessage) for i in cfg.chat.buffer)
|
||||
|
||||
|
||||
def test_out_of_band_input_builds_fresh_context():
|
||||
handler = _make_handler()
|
||||
req, _cfg = _make_oob_request([make_user_message("OOB question")])
|
||||
captured = _capture_create(handler, [_make_output_item_done_event(content="ok")])
|
||||
|
||||
list(handler.process(req))
|
||||
|
||||
serialized = str(captured["input"])
|
||||
assert "OOB question" in serialized
|
||||
assert "Hi" not in serialized # default conversation history is excluded
|
||||
|
||||
|
||||
def test_out_of_band_empty_input_clears_context():
|
||||
handler = _make_handler()
|
||||
req, _cfg = _make_oob_request([])
|
||||
captured = _capture_create(handler, [_make_output_item_done_event(content="ok")])
|
||||
|
||||
list(handler.process(req))
|
||||
|
||||
serialized = str(captured["input"])
|
||||
assert "Hi" not in serialized # default conversation not used
|
||||
assert "helpful AI assistant" in serialized # only the system prompt remains
|
||||
|
||||
|
||||
def test_out_of_band_absent_input_reads_default_conversation():
|
||||
handler = _make_handler()
|
||||
req, cfg = _make_oob_request(None)
|
||||
captured = _capture_create(handler, [_make_output_item_done_event(content="ok")])
|
||||
|
||||
list(handler.process(req))
|
||||
|
||||
serialized = str(captured["input"])
|
||||
assert "Hi" in serialized # default conversation used as read-only context
|
||||
# Still read-only: no assistant message committed back.
|
||||
assert len(cfg.chat.buffer) == 1
|
||||
|
||||
|
||||
def test_out_of_band_invalid_input_emits_failed_end_of_response():
|
||||
handler = _make_handler()
|
||||
called = False
|
||||
|
||||
def fake_create(**kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
return _make_stream([])
|
||||
|
||||
handler.client = SimpleNamespace(responses=SimpleNamespace(create=fake_create))
|
||||
# function_call_output referencing an unknown call_id fails validation.
|
||||
orphan = RealtimeConversationItemFunctionCallOutput(
|
||||
type="function_call_output", call_id="call_missing", output="{}"
|
||||
)
|
||||
req, _cfg = _make_oob_request([orphan])
|
||||
|
||||
outputs = list(handler.process(req))
|
||||
|
||||
assert not called # generation never started
|
||||
assert len(outputs) == 1
|
||||
assert isinstance(outputs[0], EndOfResponse)
|
||||
assert outputs[0].error is not None
|
||||
@@ -0,0 +1,845 @@
|
||||
import time
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from speech_to_speech.pipeline.events import SpeechStartedEvent, SpeechStoppedEvent
|
||||
from speech_to_speech.pipeline.messages import VADAudio
|
||||
from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
|
||||
from speech_to_speech.VAD.vad_handler import VADHandler
|
||||
|
||||
|
||||
def test_pending_reopen_defers_commit_until_cancelled():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
|
||||
tracker.commit("turn_1", 0)
|
||||
|
||||
assert candidate_revision == 1
|
||||
assert not tracker.is_committed("turn_1", 0)
|
||||
|
||||
tracker.cancel_reopen_candidate("turn_1", candidate_revision)
|
||||
tracker.commit("turn_1", 0)
|
||||
|
||||
assert tracker.is_committed("turn_1", 0)
|
||||
|
||||
|
||||
def test_confirmed_reopen_makes_previous_revision_stale():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
|
||||
assert not tracker.is_latest("turn_1", 0)
|
||||
assert tracker.is_latest("turn_1", 1)
|
||||
|
||||
|
||||
def test_tracker_prunes_old_turn_revisions():
|
||||
tracker = SpeculativeTurnTracker(max_tracked_turns=2)
|
||||
tracker.observe("turn_1", 0)
|
||||
tracker.commit("turn_1", 0)
|
||||
tracker.observe("turn_2", 0)
|
||||
tracker.observe("turn_3", 0)
|
||||
|
||||
assert list(tracker._latest_revision) == ["turn_2", "turn_3"]
|
||||
assert "turn_1" not in tracker._committed_revision
|
||||
|
||||
|
||||
def test_tracker_keeps_pending_reopen_while_pruning():
|
||||
tracker = SpeculativeTurnTracker(max_tracked_turns=1)
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
|
||||
tracker.observe("turn_2", 0)
|
||||
|
||||
assert candidate_revision == 1
|
||||
assert "turn_1" in tracker._latest_revision
|
||||
assert "turn_1" in tracker._pending_reopen
|
||||
assert "turn_2" in tracker._latest_revision
|
||||
|
||||
|
||||
def test_pending_reopen_wait_timeout_clears_candidate():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
|
||||
tracker.wait_for_pending_reopen("turn_1", 0, timeout_s=0)
|
||||
|
||||
assert candidate_revision == 1
|
||||
assert tracker._pending_reopen == {}
|
||||
|
||||
|
||||
def test_commit_if_latest_waits_for_pending_reopen_and_drops_confirmed_reopen():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
assert not tracker.commit_if_latest_after_pending_reopen("turn_1", 0)
|
||||
assert not tracker.is_committed("turn_1", 0)
|
||||
|
||||
|
||||
def test_commit_if_latest_commits_after_pending_reopen_is_cancelled():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
|
||||
tracker.cancel_reopen_candidate("turn_1", candidate_revision)
|
||||
|
||||
assert tracker.commit_if_latest_after_pending_reopen("turn_1", 0)
|
||||
assert tracker.is_committed("turn_1", 0)
|
||||
|
||||
|
||||
def test_try_is_latest_after_pending_reopen_reports_pending_without_blocking():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
|
||||
assert tracker.has_pending_reopen("turn_1", 0)
|
||||
assert tracker.try_is_latest_after_pending_reopen("turn_1", 0) is None
|
||||
assert tracker.try_commit_if_latest_after_pending_reopen("turn_1", 0) is None
|
||||
|
||||
tracker.cancel_reopen_candidate("turn_1", candidate_revision)
|
||||
|
||||
assert tracker.try_is_latest_after_pending_reopen("turn_1", 0) is True
|
||||
assert tracker.try_commit_if_latest_after_pending_reopen("turn_1", 0) is True
|
||||
assert tracker.is_committed("turn_1", 0)
|
||||
|
||||
|
||||
def test_try_is_latest_after_reopen_grace_reports_pending_without_blocking():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
tracker.start_reopen_grace("turn_1", 0, grace_s=0.05)
|
||||
|
||||
assert tracker.try_is_latest_after_reopen_grace("turn_1", 0) is None
|
||||
assert tracker.try_commit_if_latest_after_reopen_grace("turn_1", 0) is None
|
||||
|
||||
time.sleep(0.06)
|
||||
|
||||
assert tracker.try_is_latest_after_reopen_grace("turn_1", 0) is True
|
||||
assert tracker.try_commit_if_latest_after_reopen_grace("turn_1", 0) is True
|
||||
assert tracker.is_committed("turn_1", 0)
|
||||
|
||||
|
||||
def test_reopen_grace_wait_drops_confirmed_reopen():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
tracker.start_reopen_grace("turn_1", 0, grace_s=0.2)
|
||||
result: dict[str, bool] = {}
|
||||
|
||||
def wait_for_grace():
|
||||
result["is_latest"] = tracker.is_latest_after_reopen_grace("turn_1", 0)
|
||||
|
||||
thread = Thread(target=wait_for_grace)
|
||||
thread.start()
|
||||
|
||||
time.sleep(0.02)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert result == {"is_latest": False}
|
||||
|
||||
|
||||
def test_is_latest_after_stability_window_catches_reopen_started_during_wait():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
|
||||
def reopen_turn():
|
||||
time.sleep(0.02)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
|
||||
thread = Thread(target=reopen_turn)
|
||||
thread.start()
|
||||
|
||||
assert not tracker.is_latest_after_stability_window("turn_1", 0, settle_s=0.2)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
|
||||
def test_vad_direct_reopen_path_uses_tracker_candidate_protocol():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.enable_realtime_transcription = True
|
||||
handler._speech_started_emitted = False
|
||||
handler._current_turn_id = "turn_1"
|
||||
handler._current_turn_revision = 0
|
||||
handler._last_final_audio_ms = 1000
|
||||
handler.speculative_reopen_ms = 1200
|
||||
handler.unanswered_reopen_ms = 1200
|
||||
handler.speculative_turns = tracker
|
||||
handler._pending_reopen_candidate = None
|
||||
|
||||
turn_id, revision, reopened = handler._ensure_turn_for_speech_start(1100)
|
||||
|
||||
assert (turn_id, revision, reopened) == ("turn_1", 1, True)
|
||||
assert not tracker.is_latest("turn_1", 0)
|
||||
assert tracker.is_latest("turn_1", 1)
|
||||
assert tracker._pending_reopen == {}
|
||||
|
||||
|
||||
def test_vad_reopens_speculative_turn_when_live_transcription_disabled():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.enable_realtime_transcription = False
|
||||
handler._speech_started_emitted = False
|
||||
handler._current_turn_id = "turn_1"
|
||||
handler._current_turn_revision = 0
|
||||
handler._last_final_audio_ms = 1000
|
||||
handler.speculative_reopen_ms = 1200
|
||||
handler.unanswered_reopen_ms = 1200
|
||||
handler.speculative_turns = tracker
|
||||
handler._pending_reopen_candidate = None
|
||||
|
||||
turn_id, revision, reopened = handler._ensure_turn_for_speech_start(1100)
|
||||
|
||||
assert (turn_id, revision, reopened) == ("turn_1", 1, True)
|
||||
assert not tracker.is_latest("turn_1", 0)
|
||||
assert tracker.is_latest("turn_1", 1)
|
||||
|
||||
|
||||
def test_vad_starts_new_turn_after_committed_turn_would_have_reopened():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
tracker.commit("turn_1", 0)
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.enable_realtime_transcription = False
|
||||
handler._speech_started_emitted = False
|
||||
handler._current_turn_id = "turn_1"
|
||||
handler._current_turn_revision = 0
|
||||
handler._turn_counter = 1
|
||||
handler._last_final_audio_ms = 1000
|
||||
handler.speculative_reopen_ms = 1200
|
||||
handler.speculative_turns = tracker
|
||||
handler._pending_reopen_candidate = None
|
||||
|
||||
turn_id, revision, reopened = handler._ensure_turn_for_speech_start(1100)
|
||||
|
||||
assert (turn_id, revision, reopened) == ("turn_2", 0, False)
|
||||
assert tracker.is_committed("turn_1", 0)
|
||||
assert tracker.is_latest("turn_2", 0)
|
||||
|
||||
|
||||
def test_vad_realtime_path_does_not_emit_progressive_when_live_transcription_disabled():
|
||||
class FakeIterator:
|
||||
buffer = [object()]
|
||||
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.enable_realtime_transcription = False
|
||||
handler.iterator = FakeIterator()
|
||||
|
||||
assert list(handler._process_realtime(None)) == []
|
||||
|
||||
|
||||
class _StaticVADIterator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
triggered: bool,
|
||||
vad_output: list[torch.Tensor] | None,
|
||||
buffer_chunks: list[torch.Tensor] | None = None,
|
||||
speech_chunks: list[torch.Tensor] | None = None,
|
||||
active_speech_samples: int = 0,
|
||||
last_utterance_active_speech_samples: int = 0,
|
||||
) -> None:
|
||||
self.triggered = triggered
|
||||
self._vad_output = vad_output
|
||||
self.buffer = buffer_chunks or []
|
||||
self._speech_chunks = speech_chunks or self.buffer
|
||||
self.active_speech_samples = active_speech_samples
|
||||
self.last_utterance_active_speech_samples = last_utterance_active_speech_samples
|
||||
|
||||
def __call__(self, _chunk: torch.Tensor) -> list[torch.Tensor] | None:
|
||||
return self._vad_output
|
||||
|
||||
def speech_buffer(self) -> list[torch.Tensor]:
|
||||
return self._speech_chunks
|
||||
|
||||
|
||||
def _vad_handler_for_iterator(iterator: _StaticVADIterator) -> VADHandler:
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.should_listen = Event()
|
||||
handler.should_listen.set()
|
||||
handler.sample_rate = 16000
|
||||
handler.min_silence_ms = 300
|
||||
handler.min_speech_ms = 384
|
||||
handler.min_speech_continuation_ms = handler.min_speech_ms
|
||||
handler.max_speech_ms = float("inf")
|
||||
handler.enable_realtime_transcription = False
|
||||
handler.realtime_processing_pause = 0.5
|
||||
handler.text_output_queue = Queue()
|
||||
handler.speculative_turns = SpeculativeTurnTracker()
|
||||
handler.speculative_reopen_ms = 1000
|
||||
handler.unanswered_reopen_ms = handler.speculative_reopen_ms
|
||||
handler._last_turn_detection = None
|
||||
handler.iterator = iterator
|
||||
handler.audio_enhancement = False
|
||||
handler.last_process_time = 0.0
|
||||
handler._total_samples = 0
|
||||
handler._last_log_time = time.time()
|
||||
handler._log_chunks = 0
|
||||
handler._log_speech_starts = 0
|
||||
handler._log_speech_ends = 0
|
||||
handler._log_progressive_yields = 0
|
||||
handler._speech_started_emitted = False
|
||||
handler._turn_counter = 0
|
||||
handler._current_turn_id = None
|
||||
handler._current_turn_revision = None
|
||||
handler._speculative_audio_prefix = None
|
||||
handler._last_final_wall_time = None
|
||||
handler._last_final_audio_ms = None
|
||||
handler._pending_reopen_candidate = None
|
||||
handler.short_segment_merge_ms = 0
|
||||
handler._pending_short_segment = None
|
||||
return handler
|
||||
|
||||
|
||||
def _audio_bytes(samples: int = 512) -> bytes:
|
||||
return np.zeros(samples, dtype=np.int16).tobytes()
|
||||
|
||||
|
||||
def _drain_text_events(handler: VADHandler) -> None:
|
||||
while not handler.text_output_queue.empty():
|
||||
handler.text_output_queue.get_nowait()
|
||||
|
||||
|
||||
def test_vad_interruption_uses_active_speech_duration_not_padded_segment():
|
||||
chunks = [torch.zeros(512) for _ in range(20)]
|
||||
iterator = _StaticVADIterator(
|
||||
triggered=True,
|
||||
vad_output=None,
|
||||
buffer_chunks=chunks,
|
||||
speech_chunks=chunks,
|
||||
active_speech_samples=10 * 512,
|
||||
)
|
||||
handler = _vad_handler_for_iterator(iterator)
|
||||
handler.enable_realtime_transcription = True
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
|
||||
assert handler.text_output_queue.empty()
|
||||
assert handler._speech_started_emitted is False
|
||||
|
||||
|
||||
def test_vad_pending_reopen_starts_before_active_speech_threshold():
|
||||
chunks = [torch.zeros(512) for _ in range(12)]
|
||||
iterator = _StaticVADIterator(
|
||||
triggered=True,
|
||||
vad_output=None,
|
||||
buffer_chunks=chunks,
|
||||
speech_chunks=chunks,
|
||||
active_speech_samples=8 * 512,
|
||||
)
|
||||
handler = _vad_handler_for_iterator(iterator)
|
||||
tracker = handler.speculative_turns
|
||||
tracker.observe("turn_1", 0)
|
||||
handler._current_turn_id = "turn_1"
|
||||
handler._current_turn_revision = 0
|
||||
handler._last_final_audio_ms = 0
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
|
||||
assert tracker.has_pending_reopen("turn_1", 0)
|
||||
tracker.commit("turn_1", 0)
|
||||
assert not tracker.is_committed("turn_1", 0)
|
||||
assert handler.text_output_queue.empty()
|
||||
assert handler._speech_started_emitted is False
|
||||
|
||||
|
||||
def test_vad_interruption_emits_after_active_speech_threshold():
|
||||
chunks = [torch.zeros(512) for _ in range(20)]
|
||||
iterator = _StaticVADIterator(
|
||||
triggered=True,
|
||||
vad_output=None,
|
||||
buffer_chunks=chunks,
|
||||
speech_chunks=chunks,
|
||||
active_speech_samples=12 * 512,
|
||||
)
|
||||
handler = _vad_handler_for_iterator(iterator)
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
|
||||
event = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(event, SpeechStartedEvent)
|
||||
assert event.interrupt_response is True
|
||||
assert handler._speech_started_emitted is True
|
||||
|
||||
|
||||
def test_vad_live_transcription_without_speculative_turns_stops_listening_on_final():
|
||||
final_chunks = [torch.zeros(512) for _ in range(31)]
|
||||
iterator = _StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=final_chunks,
|
||||
last_utterance_active_speech_samples=12 * 512,
|
||||
)
|
||||
handler = _vad_handler_for_iterator(iterator)
|
||||
handler.enable_realtime_transcription = True
|
||||
handler.speculative_turns = None
|
||||
|
||||
outputs = list(handler.process(_audio_bytes()))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert not handler.should_listen.is_set()
|
||||
|
||||
|
||||
def test_vad_discards_final_segment_when_active_speech_is_short():
|
||||
final_chunks = [torch.zeros(512) for _ in range(31)]
|
||||
iterator = _StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=final_chunks,
|
||||
last_utterance_active_speech_samples=11 * 512,
|
||||
)
|
||||
handler = _vad_handler_for_iterator(iterator)
|
||||
|
||||
outputs = list(handler.process(_audio_bytes()))
|
||||
|
||||
assert outputs == []
|
||||
assert handler.text_output_queue.empty()
|
||||
|
||||
|
||||
def _drive_final_segment(handler: VADHandler, active_chunks: int = 12, segment_chunks: int = 31) -> list:
|
||||
handler.iterator = _StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=[torch.zeros(512) for _ in range(segment_chunks)],
|
||||
last_utterance_active_speech_samples=active_chunks * 512,
|
||||
)
|
||||
return list(handler.process(_audio_bytes()))
|
||||
|
||||
|
||||
def _handler_after_soft_ended_turn() -> VADHandler:
|
||||
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
|
||||
outputs = _drive_final_segment(handler, active_chunks=12, segment_chunks=12)
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 0)
|
||||
_drain_text_events(handler)
|
||||
return handler
|
||||
|
||||
|
||||
def test_continuation_start_confirms_at_lower_bar():
|
||||
handler = _handler_after_soft_ended_turn()
|
||||
handler.min_speech_continuation_ms = 192
|
||||
chunks = [torch.zeros(512) for _ in range(8)]
|
||||
handler.iterator = _StaticVADIterator(
|
||||
triggered=True,
|
||||
vad_output=None,
|
||||
buffer_chunks=chunks,
|
||||
speech_chunks=chunks,
|
||||
active_speech_samples=8 * 512,
|
||||
)
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
|
||||
started = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(started, SpeechStartedEvent)
|
||||
assert (started.turn_id, started.turn_revision, started.reopened) == ("turn_1", 1, True)
|
||||
assert handler._speech_started_emitted is True
|
||||
|
||||
|
||||
def test_trailing_continuation_fragment_accepted_at_finalization():
|
||||
handler = _handler_after_soft_ended_turn()
|
||||
handler.min_speech_continuation_ms = 192
|
||||
|
||||
outputs = _drive_final_segment(handler, active_chunks=8, segment_chunks=8)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 1)
|
||||
started = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(started, SpeechStartedEvent)
|
||||
assert (started.turn_id, started.turn_revision, started.reopened) == ("turn_1", 1, True)
|
||||
|
||||
|
||||
def test_continuation_bar_inactive_when_turn_committed():
|
||||
handler = _handler_after_soft_ended_turn()
|
||||
handler.min_speech_continuation_ms = 192
|
||||
tracker = handler.speculative_turns
|
||||
tracker.commit("turn_1", 0)
|
||||
|
||||
outputs = _drive_final_segment(handler, active_chunks=8, segment_chunks=8)
|
||||
|
||||
assert outputs == []
|
||||
assert handler.text_output_queue.empty()
|
||||
assert handler._current_turn_id == "turn_1"
|
||||
assert handler._current_turn_revision == 0
|
||||
assert tracker.is_committed("turn_1", 0)
|
||||
|
||||
|
||||
def test_entry_bar_unchanged_for_new_speech():
|
||||
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
|
||||
handler.min_speech_continuation_ms = 192
|
||||
|
||||
outputs = _drive_final_segment(handler, active_chunks=8, segment_chunks=8)
|
||||
|
||||
assert outputs == []
|
||||
assert handler.text_output_queue.empty()
|
||||
assert handler._current_turn_id is None
|
||||
assert handler._turn_counter == 0
|
||||
|
||||
|
||||
def test_confirmed_segment_not_discarded_at_finalization():
|
||||
handler = _handler_after_soft_ended_turn()
|
||||
handler.min_speech_continuation_ms = 192
|
||||
chunks = [torch.zeros(512) for _ in range(8)]
|
||||
handler.iterator = _StaticVADIterator(
|
||||
triggered=True,
|
||||
vad_output=None,
|
||||
buffer_chunks=chunks,
|
||||
speech_chunks=chunks,
|
||||
active_speech_samples=8 * 512,
|
||||
)
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
started = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(started, SpeechStartedEvent)
|
||||
assert (started.turn_id, started.turn_revision, started.reopened) == ("turn_1", 1, True)
|
||||
|
||||
handler.iterator = _StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=chunks,
|
||||
last_utterance_active_speech_samples=8 * 512,
|
||||
)
|
||||
outputs = list(handler.process(_audio_bytes()))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 1)
|
||||
|
||||
|
||||
def test_continuation_threshold_clamping():
|
||||
assert VADHandler._resolve_min_speech_continuation_ms(384, 0) == 384
|
||||
assert VADHandler._resolve_min_speech_continuation_ms(384, 50) == 100
|
||||
assert VADHandler._resolve_min_speech_continuation_ms(384, 500) == 384
|
||||
assert VADHandler._resolve_min_speech_continuation_ms(384, 192) == 192
|
||||
|
||||
|
||||
def test_vad_reopens_unanswered_turn_after_grace_window():
|
||||
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
|
||||
handler.unanswered_reopen_ms = 8000
|
||||
tracker = handler.speculative_turns
|
||||
|
||||
outputs = _drive_final_segment(handler)
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 0)
|
||||
assert handler._last_final_audio_ms is not None
|
||||
while not handler.text_output_queue.empty():
|
||||
handler.text_output_queue.get_nowait()
|
||||
|
||||
# Advance the audio clock so the resumed speech starts well past
|
||||
# speculative_reopen_ms (1000) but within unanswered_reopen_ms (8000).
|
||||
handler._total_samples = 16000 * 3
|
||||
|
||||
outputs = _drive_final_segment(handler)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 1)
|
||||
started = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(started, SpeechStartedEvent)
|
||||
assert (started.turn_id, started.turn_revision, started.reopened) == ("turn_1", 1, True)
|
||||
assert not tracker.is_latest("turn_1", 0)
|
||||
|
||||
|
||||
def test_vad_does_not_reopen_committed_turn():
|
||||
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
|
||||
handler.unanswered_reopen_ms = 8000
|
||||
tracker = handler.speculative_turns
|
||||
|
||||
outputs = _drive_final_segment(handler)
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 0)
|
||||
while not handler.text_output_queue.empty():
|
||||
handler.text_output_queue.get_nowait()
|
||||
|
||||
tracker.commit("turn_1", 0)
|
||||
handler._total_samples = 16000 * 3
|
||||
|
||||
outputs = _drive_final_segment(handler)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_2", 0)
|
||||
started = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(started, SpeechStartedEvent)
|
||||
assert started.reopened is False
|
||||
|
||||
|
||||
def test_vad_new_turn_after_unanswered_cap():
|
||||
handler = _vad_handler_for_iterator(_StaticVADIterator(triggered=False, vad_output=None))
|
||||
handler.unanswered_reopen_ms = 8000
|
||||
|
||||
outputs = _drive_final_segment(handler)
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_1", 0)
|
||||
while not handler.text_output_queue.empty():
|
||||
handler.text_output_queue.get_nowait()
|
||||
|
||||
# Advance the audio clock so the resumed speech starts past the cap.
|
||||
handler._total_samples = 16000 * 12
|
||||
|
||||
outputs = _drive_final_segment(handler)
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert (outputs[0].turn_id, outputs[0].turn_revision) == ("turn_2", 0)
|
||||
started = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(started, SpeechStartedEvent)
|
||||
assert started.reopened is False
|
||||
|
||||
|
||||
def test_vad_does_not_hold_sub_floor_fragments():
|
||||
handler = _vad_handler_for_iterator(
|
||||
_StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=[torch.zeros(512)],
|
||||
last_utterance_active_speech_samples=512,
|
||||
)
|
||||
)
|
||||
handler.short_segment_merge_ms = 384
|
||||
|
||||
outputs = list(handler.process(_audio_bytes()))
|
||||
|
||||
assert outputs == []
|
||||
assert handler._pending_short_segment is None
|
||||
assert handler.text_output_queue.empty()
|
||||
|
||||
|
||||
def test_vad_stitches_adjacent_short_segments_before_discarding():
|
||||
first_chunks = [torch.zeros(512) for _ in range(7)]
|
||||
second_chunks = [torch.zeros(512) for _ in range(8)]
|
||||
handler = _vad_handler_for_iterator(
|
||||
_StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=first_chunks,
|
||||
last_utterance_active_speech_samples=4 * 512,
|
||||
)
|
||||
)
|
||||
handler.short_segment_merge_ms = 384
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
assert handler.text_output_queue.empty()
|
||||
assert handler._pending_short_segment is not None
|
||||
|
||||
handler.iterator = _StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=second_chunks,
|
||||
last_utterance_active_speech_samples=8 * 512,
|
||||
)
|
||||
outputs = list(handler.process(_audio_bytes()))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0].audio) == 15 * 512
|
||||
started = handler.text_output_queue.get_nowait()
|
||||
stopped = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(started, SpeechStartedEvent)
|
||||
assert started.interrupt_response is False
|
||||
assert isinstance(stopped, SpeechStoppedEvent)
|
||||
assert handler._pending_short_segment is None
|
||||
|
||||
|
||||
def test_vad_pending_short_segment_contributes_to_early_speech_start():
|
||||
first_chunks = [torch.zeros(512) for _ in range(7)]
|
||||
current_chunks = [torch.zeros(512) for _ in range(8)]
|
||||
handler = _vad_handler_for_iterator(
|
||||
_StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=first_chunks,
|
||||
last_utterance_active_speech_samples=4 * 512,
|
||||
)
|
||||
)
|
||||
handler.short_segment_merge_ms = 384
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
|
||||
handler.iterator = _StaticVADIterator(
|
||||
triggered=True,
|
||||
vad_output=None,
|
||||
buffer_chunks=current_chunks,
|
||||
speech_chunks=current_chunks,
|
||||
active_speech_samples=8 * 512,
|
||||
)
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
event = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(event, SpeechStartedEvent)
|
||||
assert event.interrupt_response is True
|
||||
assert handler._speech_started_emitted is True
|
||||
|
||||
|
||||
def test_vad_pending_short_segment_does_not_start_on_sub_floor_current_fragment():
|
||||
first_chunks = [torch.zeros(512) for _ in range(10)]
|
||||
current_chunks = [torch.zeros(512) for _ in range(3)]
|
||||
handler = _vad_handler_for_iterator(
|
||||
_StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=first_chunks,
|
||||
last_utterance_active_speech_samples=9 * 512,
|
||||
)
|
||||
)
|
||||
handler.short_segment_merge_ms = 384
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
assert handler._pending_short_segment is not None
|
||||
|
||||
# Pending holds 288ms active speech; the live fragment has only 96ms,
|
||||
# below the noise floor, so the combined 384ms must not start speech.
|
||||
handler.iterator = _StaticVADIterator(
|
||||
triggered=True,
|
||||
vad_output=None,
|
||||
buffer_chunks=current_chunks,
|
||||
speech_chunks=current_chunks,
|
||||
active_speech_samples=3 * 512,
|
||||
)
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
assert handler.text_output_queue.empty()
|
||||
assert handler._speech_started_emitted is False
|
||||
|
||||
|
||||
def test_vad_stitching_preserves_silence_gap_between_segments():
|
||||
first_chunks = [torch.zeros(512) for _ in range(7)]
|
||||
second_chunks = [torch.zeros(512) for _ in range(8)]
|
||||
handler = _vad_handler_for_iterator(
|
||||
_StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=first_chunks,
|
||||
last_utterance_active_speech_samples=4 * 512,
|
||||
)
|
||||
)
|
||||
handler.short_segment_merge_ms = 384
|
||||
|
||||
assert list(handler.process(_audio_bytes())) == []
|
||||
assert handler._pending_short_segment is not None
|
||||
assert handler._pending_short_segment.end_ms == 32
|
||||
|
||||
# Advance the audio clock so the second segment starts 32ms after the
|
||||
# pending one ends; the stitched audio must include that silent gap.
|
||||
handler._total_samples = 9 * 512
|
||||
handler.iterator = _StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=second_chunks,
|
||||
last_utterance_active_speech_samples=8 * 512,
|
||||
)
|
||||
outputs = list(handler.process(_audio_bytes()))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert len(outputs[0].audio) == 16 * 512
|
||||
|
||||
|
||||
def test_vad_final_synthetic_start_does_not_interrupt_response():
|
||||
final_chunks = [torch.zeros(512) for _ in range(31)]
|
||||
iterator = _StaticVADIterator(
|
||||
triggered=False,
|
||||
vad_output=final_chunks,
|
||||
last_utterance_active_speech_samples=12 * 512,
|
||||
)
|
||||
handler = _vad_handler_for_iterator(iterator)
|
||||
|
||||
outputs = list(handler.process(_audio_bytes()))
|
||||
|
||||
assert len(outputs) == 1
|
||||
started = handler.text_output_queue.get_nowait()
|
||||
stopped = handler.text_output_queue.get_nowait()
|
||||
assert isinstance(started, SpeechStartedEvent)
|
||||
assert started.interrupt_response is False
|
||||
assert isinstance(stopped, SpeechStoppedEvent)
|
||||
|
||||
|
||||
def test_vad_keeps_single_speculative_audio_prefix():
|
||||
handler = object.__new__(VADHandler)
|
||||
handler._speculative_audio_prefix = None
|
||||
first_segment = np.array([1.0, 2.0], dtype=np.float32)
|
||||
second_segment = np.array([3.0], dtype=np.float32)
|
||||
third_segment = np.array([4.0], dtype=np.float32)
|
||||
|
||||
first_output = handler._combined_turn_audio(first_segment)
|
||||
handler._speculative_audio_prefix = first_output
|
||||
second_output = handler._combined_turn_audio(second_segment)
|
||||
handler._speculative_audio_prefix = second_output
|
||||
third_output = handler._combined_turn_audio(third_segment)
|
||||
|
||||
assert first_output is first_segment
|
||||
np.testing.assert_array_equal(second_output, np.array([1.0, 2.0, 3.0], dtype=np.float32))
|
||||
np.testing.assert_array_equal(third_output, np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32))
|
||||
|
||||
|
||||
def _vad_audio(
|
||||
turn_id: str = "turn_1",
|
||||
revision: int = 0,
|
||||
mode: Literal["progressive", "final"] | None = "progressive",
|
||||
) -> VADAudio:
|
||||
return VADAudio(audio=np.zeros(512, dtype=np.float32), mode=mode, turn_id=turn_id, turn_revision=revision)
|
||||
|
||||
|
||||
def test_vad_drops_superseded_progressive_audio_from_output_queue():
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.queue_out = Queue()
|
||||
handler.speculative_turns = None
|
||||
first_progressive = _vad_audio()
|
||||
final_audio = _vad_audio(mode="final")
|
||||
second_progressive = _vad_audio()
|
||||
other_turn_progressive = _vad_audio(turn_id="turn_2")
|
||||
|
||||
handler.queue_out.put(first_progressive)
|
||||
handler.queue_out.put(final_audio)
|
||||
handler.queue_out.put(second_progressive)
|
||||
handler.queue_out.put(other_turn_progressive)
|
||||
|
||||
dropped = handler._drop_superseded_vad_audio(_vad_audio())
|
||||
|
||||
assert dropped == 2
|
||||
queued_items = list(handler.queue_out.queue)
|
||||
assert queued_items == [final_audio, other_turn_progressive]
|
||||
|
||||
|
||||
def test_vad_drops_stale_progressive_revisions_from_output_queue():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 1)
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.queue_out = Queue()
|
||||
handler.speculative_turns = tracker
|
||||
stale_progressive = _vad_audio(revision=0)
|
||||
current_progressive = _vad_audio(revision=1)
|
||||
|
||||
handler.queue_out.put(stale_progressive)
|
||||
handler.queue_out.put(current_progressive)
|
||||
|
||||
handler.before_emit_output(_vad_audio(revision=1))
|
||||
|
||||
assert handler.queue_out.empty()
|
||||
|
||||
|
||||
def test_vad_final_audio_replaces_queued_progressive_audio_for_same_revision():
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.queue_out = Queue()
|
||||
handler.speculative_turns = None
|
||||
progressive_audio = _vad_audio()
|
||||
final_audio = _vad_audio(mode="final")
|
||||
other_turn_progressive = _vad_audio(turn_id="turn_2")
|
||||
|
||||
handler.queue_out.put(progressive_audio)
|
||||
handler.queue_out.put(other_turn_progressive)
|
||||
|
||||
handler.before_emit_output(final_audio)
|
||||
|
||||
queued_items = list(handler.queue_out.queue)
|
||||
assert queued_items == [other_turn_progressive]
|
||||
|
||||
|
||||
def test_vad_progressive_processing_pause_increases_with_speech_duration():
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.realtime_processing_pause = 0.25
|
||||
|
||||
assert handler._progressive_processing_pause(7_999) == 0.25
|
||||
assert handler._progressive_processing_pause(8_000) == 0.5
|
||||
assert handler._progressive_processing_pause(15_000) == 1.0
|
||||
assert handler._progressive_processing_pause(30_000) == 1.5
|
||||
|
||||
|
||||
def test_vad_progressive_processing_pause_is_capped():
|
||||
handler = object.__new__(VADHandler)
|
||||
handler.realtime_processing_pause = 0.5
|
||||
|
||||
assert handler._progressive_processing_pause(30_000) == 2.0
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from queue import Empty, Queue
|
||||
from threading import Event, Thread
|
||||
from time import sleep
|
||||
from typing import Iterator, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from speech_to_speech.pipeline.messages import PIPELINE_END, PartialTranscription, Transcription, VADAudio
|
||||
from speech_to_speech.pipeline.speculative_turns import SpeculativeTurnTracker
|
||||
from speech_to_speech.STT.base_stt_handler import BaseSTTHandler
|
||||
|
||||
|
||||
class RecordingSTTHandler(BaseSTTHandler):
|
||||
def setup(
|
||||
self,
|
||||
speculative_turns: SpeculativeTurnTracker | None = None,
|
||||
mark_stale_during_process: bool = False,
|
||||
final_revision_settle_s: float = 0.0,
|
||||
) -> None:
|
||||
self.speculative_turns = speculative_turns
|
||||
self.mark_stale_during_process = mark_stale_during_process
|
||||
self.final_revision_settle_s = final_revision_settle_s
|
||||
self.processed: list[tuple[str | None, int | None]] = []
|
||||
|
||||
def process(self, vad_audio: VADAudio) -> Iterator[Transcription]:
|
||||
self.processed.append((vad_audio.turn_id, vad_audio.turn_revision))
|
||||
if self.mark_stale_during_process and vad_audio.turn_id is not None and vad_audio.turn_revision is not None:
|
||||
assert self.speculative_turns is not None
|
||||
self.speculative_turns.observe(vad_audio.turn_id, vad_audio.turn_revision + 1)
|
||||
yield Transcription(
|
||||
text="hello",
|
||||
turn_id=vad_audio.turn_id,
|
||||
turn_revision=vad_audio.turn_revision,
|
||||
)
|
||||
|
||||
|
||||
def _vad_audio(
|
||||
turn_id: str = "turn_1",
|
||||
revision: int = 0,
|
||||
mode: Literal["progressive", "final"] | None = None,
|
||||
) -> VADAudio:
|
||||
return VADAudio(audio=np.zeros(512, dtype=np.float32), mode=mode, turn_id=turn_id, turn_revision=revision)
|
||||
|
||||
|
||||
def _handler(
|
||||
tracker: SpeculativeTurnTracker,
|
||||
queue_in: Queue,
|
||||
queue_out: Queue,
|
||||
*,
|
||||
mark_stale_during_process: bool = False,
|
||||
final_revision_settle_s: float = 0.0,
|
||||
) -> RecordingSTTHandler:
|
||||
return RecordingSTTHandler(
|
||||
Event(),
|
||||
queue_in=queue_in,
|
||||
queue_out=queue_out,
|
||||
setup_kwargs={
|
||||
"speculative_turns": tracker,
|
||||
"mark_stale_during_process": mark_stale_during_process,
|
||||
"final_revision_settle_s": final_revision_settle_s,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_stt_handler_drops_stale_queued_audio_without_processing():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 1)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
queue_in.put(_vad_audio(revision=0))
|
||||
queue_in.put(PIPELINE_END)
|
||||
|
||||
handler.run()
|
||||
|
||||
assert handler.processed == []
|
||||
assert queue_out.get_nowait() == PIPELINE_END
|
||||
|
||||
|
||||
def test_stt_handler_bulk_drops_stale_queued_audio():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 1)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
for _ in range(5):
|
||||
queue_in.put(_vad_audio(revision=0))
|
||||
queue_in.put(_vad_audio(revision=1))
|
||||
queue_in.put(PIPELINE_END)
|
||||
|
||||
handler.run()
|
||||
|
||||
assert handler.processed == [("turn_1", 1)]
|
||||
output = queue_out.get_nowait()
|
||||
assert isinstance(output, Transcription)
|
||||
assert output.turn_revision == 1
|
||||
assert queue_out.get_nowait() == PIPELINE_END
|
||||
|
||||
|
||||
def test_stt_handler_waits_for_pending_reopen_before_processing():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
queue_in.put(_vad_audio(revision=0))
|
||||
queue_in.put(PIPELINE_END)
|
||||
thread = Thread(target=handler.run)
|
||||
thread.start()
|
||||
|
||||
sleep(0.05)
|
||||
assert handler.processed == []
|
||||
assert queue_out.empty()
|
||||
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert handler.processed == []
|
||||
assert queue_out.get_nowait() == PIPELINE_END
|
||||
|
||||
|
||||
def test_stt_handler_waits_for_final_revision_stability_window():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out, final_revision_settle_s=0.2)
|
||||
|
||||
queue_in.put(_vad_audio(revision=0, mode="final"))
|
||||
queue_in.put(PIPELINE_END)
|
||||
thread = Thread(target=handler.run)
|
||||
thread.start()
|
||||
|
||||
sleep(0.05)
|
||||
candidate_revision = tracker.begin_reopen_candidate("turn_1", 0)
|
||||
assert tracker.confirm_reopen_candidate("turn_1", 0, candidate_revision)
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert handler.processed == []
|
||||
assert queue_out.get_nowait() == PIPELINE_END
|
||||
|
||||
|
||||
def test_stt_handler_drops_output_that_became_stale_during_processing():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out, mark_stale_during_process=True)
|
||||
|
||||
queue_in.put(_vad_audio(revision=0))
|
||||
queue_in.put(PIPELINE_END)
|
||||
|
||||
handler.run()
|
||||
|
||||
assert handler.processed == [("turn_1", 0)]
|
||||
assert queue_out.get_nowait() == PIPELINE_END
|
||||
try:
|
||||
queue_out.get_nowait()
|
||||
except Empty:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("stale transcription output was emitted")
|
||||
|
||||
|
||||
def test_stt_handler_drops_progressive_input_after_final_emit():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
handler.before_emit_output(Transcription(text="done", turn_id="turn_1", turn_revision=0))
|
||||
|
||||
assert not handler.should_process_input(_vad_audio(revision=0, mode="progressive"))
|
||||
|
||||
|
||||
def test_stt_handler_drops_partial_output_after_final_emit():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
handler.before_emit_output(Transcription(text="done", turn_id="turn_1", turn_revision=0))
|
||||
|
||||
assert not handler.should_emit_output(PartialTranscription(text="partial", turn_id="turn_1", turn_revision=0))
|
||||
|
||||
|
||||
def test_stt_handler_bulk_drops_queued_progressives_after_final_emit():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
handler.before_emit_output(Transcription(text="done", turn_id="turn_1", turn_revision=0))
|
||||
for _ in range(3):
|
||||
queue_in.put(_vad_audio(revision=0, mode="progressive"))
|
||||
queue_in.put(_vad_audio(turn_id="turn_2", revision=0, mode="progressive"))
|
||||
|
||||
assert not handler.should_process_input(_vad_audio(revision=0, mode="progressive"))
|
||||
remaining = queue_in.get_nowait()
|
||||
|
||||
assert isinstance(remaining, VADAudio)
|
||||
assert remaining.turn_id == "turn_2"
|
||||
assert queue_in.empty()
|
||||
|
||||
|
||||
def test_stt_handler_drops_progressive_when_final_for_same_revision_is_queued():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
queue_in.put(_vad_audio(revision=0, mode="final"))
|
||||
|
||||
assert not handler.should_process_input(_vad_audio(revision=0, mode="progressive"))
|
||||
|
||||
|
||||
def test_stt_handler_keeps_progressive_when_final_for_different_turn_is_queued():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
queue_in.put(_vad_audio(turn_id="turn_2", revision=0, mode="final"))
|
||||
|
||||
assert handler.should_process_input(_vad_audio(revision=0, mode="progressive"))
|
||||
|
||||
|
||||
def test_stt_handler_bulk_drops_progressives_queued_before_matching_final():
|
||||
tracker = SpeculativeTurnTracker()
|
||||
tracker.observe("turn_1", 0)
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = _handler(tracker, queue_in, queue_out)
|
||||
|
||||
queue_in.put(_vad_audio(revision=0, mode="progressive"))
|
||||
queue_in.put(_vad_audio(revision=0, mode="final"))
|
||||
queue_in.put(_vad_audio(turn_id="turn_2", revision=0, mode="progressive"))
|
||||
|
||||
assert handler._drop_stale_queued_inputs() == 1
|
||||
first = queue_in.get_nowait()
|
||||
second = queue_in.get_nowait()
|
||||
|
||||
assert isinstance(first, VADAudio)
|
||||
assert first.mode == "final"
|
||||
assert first.turn_id == "turn_1"
|
||||
assert isinstance(second, VADAudio)
|
||||
assert second.turn_id == "turn_2"
|
||||
assert queue_in.empty()
|
||||
@@ -0,0 +1,51 @@
|
||||
from speech_to_speech.LLM.text_prompt import TEXT_SYSTEM_PROMPT, build_text_system_prompt
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
from speech_to_speech.LLM.tool_call.tool_prompt import build_tool_system_prompt
|
||||
|
||||
|
||||
def test_text_prompt_keeps_persona_in_session_prompt():
|
||||
prompt = build_text_system_prompt("Be helpful.")
|
||||
|
||||
assert "Be helpful." in prompt
|
||||
assert "You are a helpful assistant in a text conversation." in prompt
|
||||
assert "You are a helpful assistant in a text conversation." in TEXT_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_text_prompt_allows_markdown_and_drops_voice_rules():
|
||||
prompt = build_text_system_prompt("Be helpful.")
|
||||
|
||||
assert "Use markdown when it helps" in prompt
|
||||
assert "If unsure whether a tool is needed, just answer directly." in prompt
|
||||
# No spoken-channel rules leak into the text prompt.
|
||||
assert "Speech is the default." not in prompt
|
||||
assert "Treat transcripts as noisy." not in prompt
|
||||
assert "Before a tool call, use a brief natural utterance" not in prompt
|
||||
assert "speak first" not in prompt
|
||||
|
||||
|
||||
def _dance_tool() -> FunctionTool:
|
||||
return FunctionTool(
|
||||
type="function",
|
||||
name="dance",
|
||||
description="Dance once.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
|
||||
|
||||
def test_text_tool_prompt_drops_speak_first_but_keeps_structural_rules():
|
||||
prompt = build_tool_system_prompt([_dance_tool()], text_only=True)
|
||||
|
||||
assert "no preamble sentence is required" in prompt
|
||||
assert "Only one tool call may appear in a response." in prompt
|
||||
assert "Omit optional args instead of placeholder values" in prompt
|
||||
assert "do not claim tool results before a tool result is available" in prompt
|
||||
# Voice choreography must not appear in the text variant.
|
||||
assert "always speak first" not in prompt
|
||||
assert "Sure, here's my best <emotion>." not in prompt
|
||||
assert "fitting empathetic sentence" not in prompt
|
||||
|
||||
|
||||
def test_voice_tool_prompt_is_default():
|
||||
prompt = build_tool_system_prompt([_dance_tool()])
|
||||
|
||||
assert "always speak first" in prompt
|
||||
@@ -0,0 +1,80 @@
|
||||
import logging
|
||||
from queue import Queue
|
||||
from threading import Event
|
||||
|
||||
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
|
||||
from speech_to_speech.pipeline.events import PartialTranscriptionEvent, TranscriptionCompletedEvent
|
||||
from speech_to_speech.pipeline.messages import GenerateResponseRequest, PartialTranscription, Transcription
|
||||
from speech_to_speech.STT.transcription_notifier import TranscriptionNotifier
|
||||
|
||||
|
||||
def _notifier(
|
||||
text_output_queue: Queue | None = None,
|
||||
runtime_config: RuntimeConfig | None = None,
|
||||
should_listen: Event | None = None,
|
||||
) -> TranscriptionNotifier:
|
||||
notifier = object.__new__(TranscriptionNotifier)
|
||||
notifier.setup(text_output_queue=text_output_queue, runtime_config=runtime_config, should_listen=should_listen)
|
||||
return notifier
|
||||
|
||||
|
||||
def test_empty_final_transcription_still_emits_completion_after_partial():
|
||||
text_output_queue = Queue()
|
||||
notifier = _notifier(text_output_queue=text_output_queue)
|
||||
|
||||
assert list(notifier.process(PartialTranscription(text="Yeah."))) == []
|
||||
assert list(notifier.process(Transcription(text="", language_code="en", speech_stopped_at_s=123.0))) == []
|
||||
|
||||
partial = text_output_queue.get_nowait()
|
||||
completed = text_output_queue.get_nowait()
|
||||
|
||||
assert isinstance(partial, PartialTranscriptionEvent)
|
||||
assert partial.delta == "Yeah."
|
||||
assert isinstance(completed, TranscriptionCompletedEvent)
|
||||
assert completed.transcript == ""
|
||||
assert completed.language_code == "en"
|
||||
assert completed.speech_stopped_at_s == 123.0
|
||||
assert text_output_queue.empty()
|
||||
|
||||
|
||||
def test_empty_final_transcription_does_not_trigger_legacy_generation():
|
||||
runtime_config = RuntimeConfig()
|
||||
should_listen = Event()
|
||||
notifier = _notifier(runtime_config=runtime_config, should_listen=should_listen)
|
||||
|
||||
assert list(notifier.process(Transcription(text="", language_code="en"))) == []
|
||||
assert should_listen.is_set()
|
||||
|
||||
|
||||
def test_non_empty_final_transcription_still_triggers_legacy_generation():
|
||||
runtime_config = RuntimeConfig()
|
||||
should_listen = Event()
|
||||
notifier = _notifier(runtime_config=runtime_config, should_listen=should_listen)
|
||||
|
||||
result = list(notifier.process(Transcription(text="hello", language_code="en", speech_stopped_at_s=123.0)))
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], GenerateResponseRequest)
|
||||
assert result[0].runtime_config is runtime_config
|
||||
assert result[0].language_code == "en"
|
||||
assert result[0].speech_stopped_at_s == 123.0
|
||||
assert not should_listen.is_set()
|
||||
|
||||
|
||||
def test_non_empty_final_transcription_logs_full_text_at_info(caplog):
|
||||
notifier = _notifier()
|
||||
transcript = "hello " * 30
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="speech_to_speech.STT.transcription_notifier"):
|
||||
assert list(notifier.process(Transcription(text=transcript, language_code="en"))) == []
|
||||
|
||||
assert "Transcription completed (language=en): " + transcript in caplog.text
|
||||
|
||||
|
||||
def test_empty_final_transcription_reenables_listening_without_runtime_config():
|
||||
should_listen = Event()
|
||||
notifier = _notifier(should_listen=should_listen)
|
||||
|
||||
assert list(notifier.process(Transcription(text="", language_code="en"))) == []
|
||||
|
||||
assert should_listen.is_set()
|
||||
@@ -0,0 +1,42 @@
|
||||
from queue import Queue
|
||||
from threading import Event
|
||||
|
||||
from speech_to_speech.pipeline.control import SESSION_END
|
||||
from speech_to_speech.pipeline.messages import EndOfResponse, TTSInput
|
||||
from speech_to_speech.TTS.qwen3_tts_handler import Qwen3TTSHandler
|
||||
|
||||
|
||||
def _make_handler():
|
||||
handler = object.__new__(Qwen3TTSHandler)
|
||||
handler.queue_in = Queue()
|
||||
handler.queue_out = Queue()
|
||||
handler.stop_event = Event()
|
||||
return handler
|
||||
|
||||
|
||||
def test_coalesce_pending_tts_input_merges_ready_sentences_and_absorbs_response_end():
|
||||
handler = _make_handler()
|
||||
|
||||
handler.queue_in.put(TTSInput(text="Second sentence.", language_code="en"))
|
||||
handler.queue_in.put(TTSInput(text="Third sentence.", language_code="en"))
|
||||
handler.queue_in.put(EndOfResponse())
|
||||
|
||||
text, lang, saw_end = handler._coalesce_pending_tts_input(TTSInput(text="First sentence.", language_code="en"))
|
||||
|
||||
assert text == "First sentence. Second sentence. Third sentence."
|
||||
assert lang == "en"
|
||||
assert saw_end is True
|
||||
remaining = handler.queue_in.get_nowait()
|
||||
assert isinstance(remaining, EndOfResponse)
|
||||
|
||||
|
||||
def test_coalesce_pending_tts_input_stops_before_control_messages():
|
||||
handler = _make_handler()
|
||||
|
||||
handler.queue_in.put(SESSION_END)
|
||||
text, lang, saw_end = handler._coalesce_pending_tts_input(TTSInput(text="Hello.", language_code="en"))
|
||||
|
||||
assert text == "Hello."
|
||||
assert lang == "en"
|
||||
assert saw_end is False
|
||||
assert handler.queue_in.get_nowait() == SESSION_END
|
||||
@@ -0,0 +1,198 @@
|
||||
import torch
|
||||
|
||||
from speech_to_speech.VAD.vad_iterator import VADIterator
|
||||
|
||||
|
||||
class _FakeVADModel:
|
||||
def __init__(self, probs: list[float]) -> None:
|
||||
self._probs = iter(probs)
|
||||
|
||||
def reset_states(self) -> None:
|
||||
pass
|
||||
|
||||
def __call__(self, x: torch.Tensor, sampling_rate: int) -> torch.Tensor:
|
||||
return torch.tensor(next(self._probs), dtype=torch.float32)
|
||||
|
||||
|
||||
def _finish_utterance(iterator: VADIterator, silence_chunk: torch.Tensor):
|
||||
spoken_utterance = None
|
||||
for _ in range(5):
|
||||
spoken_utterance = iterator(silence_chunk)
|
||||
if spoken_utterance is not None:
|
||||
break
|
||||
return spoken_utterance
|
||||
|
||||
|
||||
def test_triggering_chunk_is_kept_in_buffer() -> None:
|
||||
model = _FakeVADModel([0.9, 0.9, 0.1, 0.1, 0.1, 0.1, 0.1])
|
||||
iterator = VADIterator(
|
||||
model=model,
|
||||
threshold=0.5,
|
||||
sampling_rate=16000,
|
||||
min_silence_duration_ms=100,
|
||||
speech_pad_ms=0,
|
||||
)
|
||||
|
||||
first_chunk = torch.ones(512)
|
||||
second_chunk = torch.ones(512) * 2
|
||||
silence_chunk = torch.zeros(512)
|
||||
|
||||
assert iterator(first_chunk) is None
|
||||
assert iterator(second_chunk) is None
|
||||
spoken_utterance = _finish_utterance(iterator, silence_chunk)
|
||||
|
||||
assert spoken_utterance is not None
|
||||
assert len(spoken_utterance) == 7
|
||||
assert torch.equal(spoken_utterance[0], first_chunk)
|
||||
assert torch.equal(spoken_utterance[1], second_chunk)
|
||||
assert all(torch.equal(chunk, silence_chunk) for chunk in spoken_utterance[2:])
|
||||
|
||||
|
||||
def test_pre_speech_padding_is_prepended_to_final_utterance() -> None:
|
||||
model = _FakeVADModel([0.1, 0.1, 0.9, 0.9, 0.1, 0.1, 0.1, 0.1, 0.1])
|
||||
iterator = VADIterator(
|
||||
model=model,
|
||||
threshold=0.5,
|
||||
sampling_rate=16000,
|
||||
min_silence_duration_ms=100,
|
||||
speech_pad_ms=64,
|
||||
)
|
||||
|
||||
first_chunk = torch.ones(512)
|
||||
second_chunk = torch.ones(512) * 2
|
||||
third_chunk = torch.ones(512) * 3
|
||||
fourth_chunk = torch.ones(512) * 4
|
||||
silence_chunk = torch.zeros(512)
|
||||
|
||||
assert iterator(first_chunk) is None
|
||||
assert iterator(second_chunk) is None
|
||||
assert iterator(third_chunk) is None
|
||||
assert iterator(fourth_chunk) is None
|
||||
|
||||
spoken_utterance = _finish_utterance(iterator, silence_chunk)
|
||||
|
||||
assert spoken_utterance is not None
|
||||
assert len(spoken_utterance) == 9
|
||||
assert torch.equal(spoken_utterance[0], first_chunk)
|
||||
assert torch.equal(spoken_utterance[1], second_chunk)
|
||||
assert torch.equal(spoken_utterance[2], third_chunk)
|
||||
assert torch.equal(spoken_utterance[3], fourth_chunk)
|
||||
assert all(torch.equal(chunk, silence_chunk) for chunk in spoken_utterance[4:])
|
||||
|
||||
|
||||
def test_speech_buffer_keeps_prefix_out_of_active_speech_buffer() -> None:
|
||||
model = _FakeVADModel([0.1, 0.1, 0.9])
|
||||
iterator = VADIterator(
|
||||
model=model,
|
||||
threshold=0.5,
|
||||
sampling_rate=16000,
|
||||
min_silence_duration_ms=100,
|
||||
speech_pad_ms=32,
|
||||
)
|
||||
|
||||
older_chunk = torch.ones(512)
|
||||
latest_pre_speech_chunk = torch.ones(512) * 2
|
||||
triggering_chunk = torch.ones(512) * 3
|
||||
|
||||
assert iterator(older_chunk) is None
|
||||
assert iterator(latest_pre_speech_chunk) is None
|
||||
assert iterator(triggering_chunk) is None
|
||||
|
||||
assert len(iterator.buffer) == 1
|
||||
assert torch.equal(iterator.buffer[0], triggering_chunk)
|
||||
|
||||
speech_buffer = iterator.speech_buffer()
|
||||
assert len(speech_buffer) == 2
|
||||
assert torch.equal(speech_buffer[0], latest_pre_speech_chunk)
|
||||
assert torch.equal(speech_buffer[1], triggering_chunk)
|
||||
|
||||
|
||||
def test_final_samples_are_kept_until_vad_declares_done() -> None:
|
||||
model = _FakeVADModel([0.9, 0.9, 0.1, 0.1, 0.1, 0.1, 0.1])
|
||||
iterator = VADIterator(
|
||||
model=model,
|
||||
threshold=0.5,
|
||||
sampling_rate=16000,
|
||||
min_silence_duration_ms=100,
|
||||
speech_pad_ms=64,
|
||||
)
|
||||
|
||||
first_chunk = torch.ones(512)
|
||||
second_chunk = torch.ones(512) * 2
|
||||
trailing_chunks = [torch.ones(512) * value for value in (10, 11, 12, 13, 14)]
|
||||
|
||||
assert iterator(first_chunk) is None
|
||||
assert iterator(second_chunk) is None
|
||||
|
||||
spoken_utterance = None
|
||||
for chunk in trailing_chunks:
|
||||
spoken_utterance = iterator(chunk)
|
||||
|
||||
assert spoken_utterance is not None
|
||||
assert len(spoken_utterance) == 7
|
||||
assert torch.equal(spoken_utterance[0], first_chunk)
|
||||
assert torch.equal(spoken_utterance[1], second_chunk)
|
||||
assert torch.equal(spoken_utterance[2], trailing_chunks[0])
|
||||
assert torch.equal(spoken_utterance[3], trailing_chunks[1])
|
||||
assert torch.equal(spoken_utterance[4], trailing_chunks[2])
|
||||
assert torch.equal(spoken_utterance[5], trailing_chunks[3])
|
||||
assert torch.equal(spoken_utterance[6], trailing_chunks[4])
|
||||
|
||||
|
||||
def test_brief_silence_is_preserved_when_speech_resumes() -> None:
|
||||
model = _FakeVADModel([0.9, 0.1, 0.1, 0.9, 0.1, 0.1, 0.1, 0.1, 0.1])
|
||||
iterator = VADIterator(
|
||||
model=model,
|
||||
threshold=0.5,
|
||||
sampling_rate=16000,
|
||||
min_silence_duration_ms=100,
|
||||
speech_pad_ms=0,
|
||||
)
|
||||
|
||||
first_chunk = torch.ones(512)
|
||||
pause_chunks = [torch.ones(512) * value for value in (8, 9)]
|
||||
resumed_chunk = torch.ones(512) * 2
|
||||
ending_silence = torch.zeros(512)
|
||||
|
||||
assert iterator(first_chunk) is None
|
||||
assert iterator(pause_chunks[0]) is None
|
||||
assert iterator(pause_chunks[1]) is None
|
||||
assert iterator(resumed_chunk) is None
|
||||
|
||||
spoken_utterance = _finish_utterance(iterator, ending_silence)
|
||||
|
||||
assert spoken_utterance is not None
|
||||
assert len(spoken_utterance) == 9
|
||||
assert torch.equal(spoken_utterance[0], first_chunk)
|
||||
assert torch.equal(spoken_utterance[1], pause_chunks[0])
|
||||
assert torch.equal(spoken_utterance[2], pause_chunks[1])
|
||||
assert torch.equal(spoken_utterance[3], resumed_chunk)
|
||||
assert all(torch.equal(chunk, ending_silence) for chunk in spoken_utterance[4:])
|
||||
|
||||
|
||||
def test_active_speech_samples_include_hysteresis_band_and_exclude_trailing_silence() -> None:
|
||||
model = _FakeVADModel([0.1, 0.9, 0.4, 0.1, 0.1, 0.1, 0.1, 0.1])
|
||||
iterator = VADIterator(
|
||||
model=model,
|
||||
threshold=0.5,
|
||||
sampling_rate=16000,
|
||||
min_silence_duration_ms=100,
|
||||
speech_pad_ms=512,
|
||||
)
|
||||
|
||||
pre_speech_chunk = torch.ones(512)
|
||||
speech_chunk = torch.ones(512) * 2
|
||||
maintained_speech_chunk = torch.ones(512) * 3
|
||||
silence_chunk = torch.zeros(512)
|
||||
|
||||
assert iterator(pre_speech_chunk) is None
|
||||
assert iterator(speech_chunk) is None
|
||||
assert iterator(maintained_speech_chunk) is None
|
||||
assert iterator.active_speech_samples == 1024
|
||||
|
||||
spoken_utterance = _finish_utterance(iterator, silence_chunk)
|
||||
|
||||
assert spoken_utterance is not None
|
||||
assert iterator.last_utterance_active_speech_samples == 1024
|
||||
assert iterator.active_speech_samples == 0
|
||||
assert len(spoken_utterance) > 2
|
||||
@@ -0,0 +1,152 @@
|
||||
from speech_to_speech.LLM.language_model import LanguageModelHandler, StreamContext
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
from speech_to_speech.LLM.tool_call.tool_prompt import END_CODE, ENTER_CODE, build_block_regex, build_tool_system_prompt
|
||||
from speech_to_speech.LLM.voice_prompt import VOICE_SYSTEM_PROMPT, build_voice_system_prompt
|
||||
|
||||
|
||||
def test_voice_prompt_is_short_and_keeps_persona_in_session_prompt():
|
||||
prompt = build_voice_system_prompt("Be concise.")
|
||||
|
||||
assert len(VOICE_SYSTEM_PROMPT.split()) < 230
|
||||
assert len(prompt.split()) < 240
|
||||
assert "The session prompt defines persona" in prompt
|
||||
assert "Match the user's intent" not in prompt
|
||||
|
||||
|
||||
def test_voice_prompt_makes_speech_the_default_and_handles_noisy_stt():
|
||||
prompt = build_voice_system_prompt("Be concise.")
|
||||
|
||||
assert "Speech is the default." in prompt
|
||||
assert "Use at most one tool" in prompt
|
||||
assert "Treat transcripts as noisy." in prompt
|
||||
assert "Correct likely mishearings only if asked or meaning depends on it" in prompt
|
||||
assert "Reachy/Richie/Richy" not in prompt
|
||||
assert "If unsure whether a tool is needed, just speak." in prompt
|
||||
|
||||
|
||||
def test_voice_prompt_requests_spoken_lead_in_and_sparing_expression_tools():
|
||||
prompt = build_voice_system_prompt("Be concise.")
|
||||
|
||||
assert "Before a tool call, use a brief natural utterance" in prompt
|
||||
assert "briefly say that you will check" in prompt
|
||||
assert "For expression/background tools, speak first." in prompt
|
||||
assert "Sure, here's my best <emotion>." in prompt
|
||||
assert "Sure, here's my best sadness." not in prompt
|
||||
assert "Never mention tools." in prompt
|
||||
assert "do not add a second spoken comment" in prompt
|
||||
assert "Use motion, dance, emotion, and similar tools sparingly" in prompt
|
||||
|
||||
|
||||
def test_local_tool_prompt_forbids_multiple_tool_calls():
|
||||
prompt = build_tool_system_prompt(
|
||||
[
|
||||
FunctionTool(
|
||||
type="function",
|
||||
name="dance",
|
||||
description="Dance once.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert "Only one tool call may appear in a response." in prompt
|
||||
assert "Multiple tool calls can live" not in prompt
|
||||
|
||||
|
||||
def test_local_tool_prompt_allows_spoken_lead_in_before_code_block():
|
||||
prompt = build_tool_system_prompt(
|
||||
[
|
||||
FunctionTool(
|
||||
type="function",
|
||||
name="camera",
|
||||
description="Look through the camera.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert "one brief natural sentence before the tool call" in prompt
|
||||
assert "always speak first" in prompt
|
||||
assert "Sure, here's my best <emotion>." in prompt
|
||||
assert "Sure, here's my best sadness." not in prompt
|
||||
assert "fitting empathetic sentence" in prompt
|
||||
assert "do not claim tool results before a tool result is available" in prompt
|
||||
assert "Omit optional args instead of placeholder values" in prompt
|
||||
|
||||
|
||||
def test_local_tool_parser_flushes_lead_in_before_tool_even_with_large_sentence_batch():
|
||||
handler = object.__new__(LanguageModelHandler)
|
||||
ctx = StreamContext(
|
||||
function_tools=[
|
||||
FunctionTool(
|
||||
type="function",
|
||||
name="dance",
|
||||
description="Dance once.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
],
|
||||
block_regex=build_block_regex(),
|
||||
enter_code=ENTER_CODE,
|
||||
end_code=END_CODE,
|
||||
)
|
||||
text = f"Here we go. {ENTER_CODE}dance(){END_CODE}"
|
||||
|
||||
chunks, tools, remaining = handler._process_printable_text(text, None, [], ctx)
|
||||
|
||||
assert [chunk.text for chunk in chunks] == ["Here we go.", ""]
|
||||
assert chunks[0].tools == []
|
||||
assert [tool.name for tool in chunks[1].tools] == ["dance"]
|
||||
assert [tool.name for tool in tools] == ["dance"]
|
||||
assert remaining == ""
|
||||
|
||||
|
||||
def test_local_tool_parser_flushes_pending_batch_before_tool_with_empty_before_text():
|
||||
handler = object.__new__(LanguageModelHandler)
|
||||
ctx = StreamContext(
|
||||
function_tools=[
|
||||
FunctionTool(
|
||||
type="function",
|
||||
name="dance",
|
||||
description="Dance once.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
],
|
||||
block_regex=build_block_regex(),
|
||||
enter_code=ENTER_CODE,
|
||||
end_code=END_CODE,
|
||||
sentence_batch=["Queued lead-in."],
|
||||
)
|
||||
text = f"{ENTER_CODE}dance(){END_CODE}"
|
||||
|
||||
chunks, tools, remaining = handler._process_printable_text(text, None, [], ctx)
|
||||
|
||||
assert [chunk.text for chunk in chunks] == ["Queued lead-in.", ""]
|
||||
assert chunks[0].tools == []
|
||||
assert [tool.name for tool in chunks[1].tools] == ["dance"]
|
||||
assert [tool.name for tool in tools] == ["dance"]
|
||||
assert remaining == ""
|
||||
|
||||
|
||||
def test_local_tool_parser_skips_duplicate_tool_blocks_and_preserves_trailing_text():
|
||||
handler = object.__new__(LanguageModelHandler)
|
||||
ctx = StreamContext(
|
||||
function_tools=[
|
||||
FunctionTool(
|
||||
type="function",
|
||||
name="dance",
|
||||
description="Dance once.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
],
|
||||
block_regex=build_block_regex(),
|
||||
enter_code=ENTER_CODE,
|
||||
end_code=END_CODE,
|
||||
)
|
||||
text = f"Watch this. {ENTER_CODE}dance(){END_CODE} Watch this. {ENTER_CODE}dance(){END_CODE}"
|
||||
|
||||
chunks, tools, remaining = handler._process_printable_text(text, None, [], ctx)
|
||||
|
||||
assert [chunk.text for chunk in chunks] == ["Watch this.", ""]
|
||||
assert [tool.name for tool in chunks[1].tools] == ["dance"]
|
||||
assert [tool.name for tool in tools] == ["dance"]
|
||||
assert remaining.strip() == "Watch this."
|
||||
@@ -0,0 +1,94 @@
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
|
||||
from speech_to_speech.baseHandler import BaseHandler
|
||||
from speech_to_speech.connections.websocket_streamer import WebSocketStreamer
|
||||
from speech_to_speech.pipeline.control import SESSION_END, is_control_message
|
||||
from speech_to_speech.pipeline.messages import PIPELINE_END
|
||||
|
||||
|
||||
class EchoHandler(BaseHandler):
|
||||
def setup(self):
|
||||
self.processed = []
|
||||
self.session_end_calls = 0
|
||||
|
||||
def process(self, item):
|
||||
self.processed.append(item)
|
||||
yield item.upper()
|
||||
|
||||
def on_session_end(self):
|
||||
self.session_end_calls += 1
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self, messages):
|
||||
self._messages = iter(messages)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._messages)
|
||||
except StopIteration as exc:
|
||||
raise StopAsyncIteration from exc
|
||||
|
||||
|
||||
def test_base_handler_session_end_resets_without_stopping():
|
||||
stop_event = Event()
|
||||
queue_in = Queue()
|
||||
queue_out = Queue()
|
||||
handler = EchoHandler(stop_event, queue_in=queue_in, queue_out=queue_out)
|
||||
|
||||
thread = Thread(target=handler.run)
|
||||
thread.start()
|
||||
|
||||
queue_in.put(SESSION_END)
|
||||
queue_in.put("hello")
|
||||
queue_in.put(PIPELINE_END)
|
||||
|
||||
thread.join(timeout=2)
|
||||
assert not thread.is_alive()
|
||||
|
||||
outputs = [queue_out.get(timeout=1) for _ in range(3)]
|
||||
assert is_control_message(outputs[0], SESSION_END.kind)
|
||||
assert outputs[1] == "HELLO"
|
||||
assert outputs[2] == PIPELINE_END
|
||||
assert handler.processed == ["hello"]
|
||||
assert handler.session_end_calls == 1
|
||||
|
||||
|
||||
def test_websocket_streamer_last_disconnect_queues_session_end():
|
||||
streamer = WebSocketStreamer(
|
||||
stop_event=Event(),
|
||||
input_queue=Queue(),
|
||||
output_queue=Queue(),
|
||||
should_listen=Event(),
|
||||
)
|
||||
|
||||
asyncio.run(streamer._handle_client(FakeWebSocket([])))
|
||||
|
||||
queued = streamer.input_queue.get_nowait()
|
||||
assert is_control_message(queued, SESSION_END.kind)
|
||||
assert streamer.should_listen.is_set()
|
||||
|
||||
|
||||
def test_websocket_send_loop_ignores_session_end_until_stop():
|
||||
stop_event = Event()
|
||||
streamer = WebSocketStreamer(
|
||||
stop_event=stop_event,
|
||||
input_queue=Queue(),
|
||||
output_queue=Queue(),
|
||||
should_listen=Event(),
|
||||
)
|
||||
|
||||
async def exercise_send_loop():
|
||||
task = asyncio.create_task(streamer._send_loop())
|
||||
streamer.output_queue.put(SESSION_END)
|
||||
await asyncio.sleep(0.05)
|
||||
assert not task.done()
|
||||
stop_event.set()
|
||||
await asyncio.wait_for(task, timeout=1)
|
||||
|
||||
asyncio.run(exercise_send_loop())
|
||||
@@ -0,0 +1,285 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from speech_to_speech.LLM.tool_call import function_call as function_call_module
|
||||
from speech_to_speech.LLM.tool_call.function_call import (
|
||||
FunctionToolCall,
|
||||
extract_function_calls_from_text,
|
||||
parse_function_call,
|
||||
)
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_function_call – single calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseFunctionCall:
|
||||
@pytest.mark.parametrize(
|
||||
"call_str, expected_name, expected_params",
|
||||
[
|
||||
("mobile.home()", "mobile.home", {}),
|
||||
("mobile.back()", "mobile.back", {}),
|
||||
("mobile.open_app(app_name='drupe')", "mobile.open_app", {"app_name": "drupe"}),
|
||||
("mobile.long_press(x=0.799, y=0.911)", "mobile.long_press", {"x": 0.799, "y": 0.911}),
|
||||
("mobile.terminate(status='success')", "mobile.terminate", {"status": "success"}),
|
||||
("answer('text')", "answer", {"__arg_0__": "text"}),
|
||||
("pyautogui.hscroll(page=-0.1)", "pyautogui.hscroll", {"page": -0.1}),
|
||||
("pyautogui.scroll(page=-0.1)", "pyautogui.scroll", {"page": -0.1}),
|
||||
("pyautogui.scroll(0.13)", "pyautogui.scroll", {"__arg_0__": 0.13}),
|
||||
("pyautogui.click(x=0.8102, y=0.9463)", "pyautogui.click", {"x": 0.8102, "y": 0.9463}),
|
||||
("pyautogui.hotkey(keys=['ctrl', 'c'])", "pyautogui.hotkey", {"keys": ["ctrl", "c"]}),
|
||||
("pyautogui.press(keys='enter')", "pyautogui.press", {"keys": "enter"}),
|
||||
("pyautogui.press(keys=['enter'])", "pyautogui.press", {"keys": ["enter"]}),
|
||||
("pyautogui.moveTo(x=0.04, y=0.405)", "pyautogui.moveTo", {"x": 0.04, "y": 0.405}),
|
||||
("pyautogui.write(message='bread buns')", "pyautogui.write", {"message": "bread buns"}),
|
||||
("pyautogui.dragTo(x=0.8102, y=0.9463)", "pyautogui.dragTo", {"x": 0.8102, "y": 0.9463}),
|
||||
],
|
||||
)
|
||||
def test_single_call(self, call_str, expected_name, expected_params):
|
||||
results = parse_function_call(call_str)
|
||||
assert len(results) == 1
|
||||
assert results[0].function_name == expected_name
|
||||
assert results[0].parameters == expected_params
|
||||
|
||||
def test_swipe_with_list_params(self):
|
||||
results = parse_function_call("mobile.swipe(from_coord=[0.581, 0.898], to_coord=[0.601, 0.518])")
|
||||
assert len(results) == 1
|
||||
assert results[0].function_name == "mobile.swipe"
|
||||
assert results[0].parameters["from_coord"] == [0.581, 0.898]
|
||||
assert results[0].parameters["to_coord"] == [0.601, 0.518]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_function_call – multiple positional arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPositionalArguments:
|
||||
def test_bare_identifiers(self):
|
||||
results = parse_function_call("function(arg1, arg2, arg3)")
|
||||
assert len(results) == 1
|
||||
assert results[0].function_name == "function"
|
||||
|
||||
def test_mixed_positional_and_named(self):
|
||||
results = parse_function_call("function('hello', 123, x=0.5)")
|
||||
r = results[0]
|
||||
assert r.parameters["__arg_0__"] == "hello"
|
||||
assert r.parameters["__arg_1__"] == 123
|
||||
assert r.parameters["x"] == 0.5
|
||||
|
||||
def test_positional_with_named_trailing(self):
|
||||
results = parse_function_call("function(arg1, arg2, named_param='value')")
|
||||
assert results[0].parameters["named_param"] == "value"
|
||||
|
||||
def test_many_positional(self):
|
||||
results = parse_function_call("function(1, 2, 3, 4, 5)")
|
||||
r = results[0]
|
||||
for i in range(5):
|
||||
assert r.parameters[f"__arg_{i}__"] == i + 1
|
||||
|
||||
def test_strings_with_kwargs(self):
|
||||
results = parse_function_call("function('a', 'b', 'c', x=1, y=2)")
|
||||
r = results[0]
|
||||
assert r.parameters["__arg_0__"] == "a"
|
||||
assert r.parameters["__arg_1__"] == "b"
|
||||
assert r.parameters["__arg_2__"] == "c"
|
||||
assert r.parameters["x"] == 1
|
||||
assert r.parameters["y"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_function_call – nested parens / special characters (Bug 1 fixes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNestedParens:
|
||||
def test_closing_paren_inside_string(self):
|
||||
results = parse_function_call("tool(msg='hello ) world')")
|
||||
assert len(results) == 1
|
||||
assert results[0].function_name == "tool"
|
||||
assert results[0].parameters == {"msg": "hello ) world"}
|
||||
|
||||
def test_tuple_argument(self):
|
||||
results = parse_function_call("tool(x=(1, 2))")
|
||||
assert len(results) == 1
|
||||
assert results[0].parameters == {"x": [1, 2]}
|
||||
|
||||
def test_dict_with_paren_in_value(self):
|
||||
results = parse_function_call("tool(a={'nested': ')'})")
|
||||
assert len(results) == 1
|
||||
assert results[0].parameters == {"a": {"nested": ")"}}
|
||||
|
||||
def test_mixed_nested_structures(self):
|
||||
results = parse_function_call("tool(items=[1, (2, 3), 4])")
|
||||
assert len(results) == 1
|
||||
assert results[0].parameters == {"items": [1, [2, 3], 4]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_function_call – multi-line (multiple calls)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiLineParsing:
|
||||
def test_two_calls_on_separate_lines(self):
|
||||
text = "mobile.wait(seconds=3)\nmobile.swipe(from_coord=[0.581, 0.898], to_coord=[0.601, 0.518])"
|
||||
results = parse_function_call(text)
|
||||
assert len(results) == 2
|
||||
assert results[0].function_name == "mobile.wait"
|
||||
assert results[1].function_name == "mobile.swipe"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_function_calls_from_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractFromText:
|
||||
CODE_BLOCK_REGEX = r"<code>.*?</code>"
|
||||
|
||||
def test_no_code_block_returns_original_text_no_calls(self):
|
||||
text = "Hello world, no code blocks here"
|
||||
outside, calls = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
assert outside == text
|
||||
assert calls == []
|
||||
|
||||
def test_extracts_calls_inside_code_block(self):
|
||||
text = "Sure, I'll do that.\n<code>mobile.click(x=0.5)</code>\nDone."
|
||||
outside, calls = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
assert len(calls) == 1
|
||||
assert calls[0].function_name == "mobile.click"
|
||||
assert "mobile.click" not in outside
|
||||
|
||||
def test_ignores_calls_outside_code_block(self):
|
||||
text = "mobile.click(x=0.5)\n<code>real.call(a=1)</code>\nmobile.home()"
|
||||
outside, calls = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
names = [c.function_name for c in calls]
|
||||
assert names == ["real.call"]
|
||||
|
||||
def test_multiline_code_block(self):
|
||||
text = "Here:\n<code>\ndo.a()\ndo.b()\n</code>\nDone."
|
||||
outside, calls = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
names = [c.function_name for c in calls]
|
||||
assert "do.a" in names
|
||||
assert "do.b" in names
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_multiple_code_blocks(self):
|
||||
text = "Step 1\n<code>a.first()</code>\nStep 2\n<code>b.second()</code>\nDone"
|
||||
outside, calls = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
names = [c.function_name for c in calls]
|
||||
assert names == ["a.first", "b.second"]
|
||||
|
||||
def test_outside_text_excludes_code_blocks(self):
|
||||
text = "Hello\n<code>hidden()</code>\nWorld"
|
||||
outside, _ = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
assert "<code>" not in outside
|
||||
assert "hidden" not in outside
|
||||
assert "Hello" in outside
|
||||
assert "World" in outside
|
||||
|
||||
def test_no_calls_when_code_block_has_no_functions(self):
|
||||
text = "<code>just plain text</code>"
|
||||
outside, calls = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
assert calls == []
|
||||
|
||||
def test_nested_parens_inside_code_block(self):
|
||||
text = "<code>tool(msg='hello ) world')</code>"
|
||||
_, calls = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
assert len(calls) == 1
|
||||
assert calls[0].parameters == {"msg": "hello ) world"}
|
||||
|
||||
def test_recovers_simple_sibling_call_from_malformed_code_block(self, monkeypatch):
|
||||
fallback_used = False
|
||||
original_fallback = function_call_module._split_simple_calls_with_regex
|
||||
|
||||
def spy_fallback(source: str) -> list[str]:
|
||||
nonlocal fallback_used
|
||||
fallback_used = True
|
||||
return original_fallback(source)
|
||||
|
||||
monkeypatch.setattr(function_call_module, "_split_simple_calls_with_regex", spy_fallback)
|
||||
text = "Let me check.\n<code>camera(question='What is in front of me?') dance(</code>"
|
||||
outside, calls = extract_function_calls_from_text(text, block_regex=self.CODE_BLOCK_REGEX)
|
||||
assert "Let me check." in outside
|
||||
assert fallback_used
|
||||
assert len(calls) == 1
|
||||
assert calls[0].function_name == "camera"
|
||||
assert calls[0].parameters == {"question": "What is in front of me?"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# to_realtime_function_tool_call – arg stripping & validation (Bug 2 fixes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_tool(name: str, properties: dict, required: list[str] | None = None) -> FunctionTool:
|
||||
schema = {"type": "object", "properties": properties}
|
||||
if required is not None:
|
||||
schema["required"] = required
|
||||
return FunctionTool(type="function", name=name, parameters=schema)
|
||||
|
||||
|
||||
class TestToRealtimeToolCall:
|
||||
def test_positional_args_stripped_when_required_present(self):
|
||||
fc = FunctionToolCall(
|
||||
function_name="greet",
|
||||
parameters={"__arg_0__": 1, "msg": "hi"},
|
||||
original_string="greet(1, msg='hi')",
|
||||
)
|
||||
tool = _make_tool("greet", {"msg": {"type": "string"}}, required=["msg"])
|
||||
result = fc.to_realtime_function_tool_call([tool])
|
||||
args = json.loads(result.arguments)
|
||||
assert "__arg_0__" not in args
|
||||
assert args == {"msg": "hi"}
|
||||
|
||||
def test_undeclared_args_stripped_when_required_present(self):
|
||||
fc = FunctionToolCall(
|
||||
function_name="greet",
|
||||
parameters={"msg": "hi", "bogus": 42},
|
||||
original_string="greet(msg='hi', bogus=42)",
|
||||
)
|
||||
tool = _make_tool("greet", {"msg": {"type": "string"}}, required=["msg"])
|
||||
result = fc.to_realtime_function_tool_call([tool])
|
||||
args = json.loads(result.arguments)
|
||||
assert "bogus" not in args
|
||||
assert args == {"msg": "hi"}
|
||||
|
||||
def test_raises_when_required_missing_after_strip(self):
|
||||
fc = FunctionToolCall(
|
||||
function_name="greet",
|
||||
parameters={"__arg_0__": 1, "bogus": 2},
|
||||
original_string="greet(1, bogus=2)",
|
||||
)
|
||||
tool = _make_tool("greet", {"msg": {"type": "string"}}, required=["msg"])
|
||||
with pytest.raises(ValueError, match="Missing required"):
|
||||
fc.to_realtime_function_tool_call([tool])
|
||||
|
||||
def test_succeeds_with_no_required_after_full_strip(self):
|
||||
fc = FunctionToolCall(
|
||||
function_name="noop",
|
||||
parameters={"__arg_0__": 1, "yy": 2},
|
||||
original_string="noop(1, yy=2)",
|
||||
)
|
||||
tool = _make_tool("noop", {"x": {"type": "integer"}})
|
||||
result = fc.to_realtime_function_tool_call([tool])
|
||||
args = json.loads(result.arguments)
|
||||
assert args == {}
|
||||
|
||||
def test_no_collision_with_real_arg_prefix(self):
|
||||
"""A real parameter named 'arg_0' should NOT be stripped."""
|
||||
fc = FunctionToolCall(
|
||||
function_name="calc",
|
||||
parameters={"arg_0": 10, "x": 5},
|
||||
original_string="calc(arg_0=10, x=5)",
|
||||
)
|
||||
tool = _make_tool(
|
||||
"calc",
|
||||
{"arg_0": {"type": "integer"}, "x": {"type": "integer"}},
|
||||
required=["arg_0"],
|
||||
)
|
||||
result = fc.to_realtime_function_tool_call([tool])
|
||||
args = json.loads(result.arguments)
|
||||
assert args == {"arg_0": 10, "x": 5}
|
||||
@@ -0,0 +1,242 @@
|
||||
from typing import Any, Literal, Optional, Union
|
||||
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
from speech_to_speech.LLM.tool_call.signature_from_schema import _annotation_from_spec, signature_from_schema
|
||||
|
||||
# --- _annotation_from_spec tests ---
|
||||
|
||||
|
||||
class TestAnnotationFromSpec:
|
||||
def test_basic_string(self):
|
||||
assert _annotation_from_spec({"type": "string"}) is str
|
||||
|
||||
def test_basic_number(self):
|
||||
assert _annotation_from_spec({"type": "number"}) is float
|
||||
|
||||
def test_basic_boolean(self):
|
||||
assert _annotation_from_spec({"type": "boolean"}) is bool
|
||||
|
||||
def test_basic_integer(self):
|
||||
assert _annotation_from_spec({"type": "integer"}) is int
|
||||
|
||||
def test_basic_object(self):
|
||||
assert _annotation_from_spec({"type": "object"}) is dict
|
||||
|
||||
def test_basic_array(self):
|
||||
assert _annotation_from_spec({"type": "array"}) is list
|
||||
|
||||
def test_null(self):
|
||||
assert _annotation_from_spec({"type": "null"}) is type(None)
|
||||
|
||||
def test_enum(self):
|
||||
result = _annotation_from_spec({"type": "string", "enum": ["a", "b", "c"]})
|
||||
assert result == Literal["a", "b", "c"]
|
||||
|
||||
def test_enum_empty(self):
|
||||
assert _annotation_from_spec({"enum": []}) is Any
|
||||
|
||||
def test_const(self):
|
||||
assert _annotation_from_spec({"const": "turbo"}) == Literal["turbo"]
|
||||
|
||||
def test_nullable_type_list(self):
|
||||
result = _annotation_from_spec({"type": ["string", "null"]})
|
||||
assert result == Optional[str]
|
||||
|
||||
def test_any_of(self):
|
||||
spec = {"anyOf": [{"type": "string"}, {"type": "integer"}]}
|
||||
assert _annotation_from_spec(spec) == Union[str, int]
|
||||
|
||||
def test_one_of(self):
|
||||
spec = {"oneOf": [{"type": "boolean"}, {"type": "number"}]}
|
||||
assert _annotation_from_spec(spec) == Union[bool, float]
|
||||
|
||||
def test_one_of_single(self):
|
||||
spec = {"oneOf": [{"type": "string"}]}
|
||||
assert _annotation_from_spec(spec) is str
|
||||
|
||||
def test_all_of_merge(self):
|
||||
spec = {"allOf": [{"type": "string"}, {"enum": ["x", "y"]}]}
|
||||
assert _annotation_from_spec(spec) == Literal["x", "y"]
|
||||
|
||||
def test_array_with_items(self):
|
||||
spec = {"type": "array", "items": {"type": "integer"}}
|
||||
assert _annotation_from_spec(spec) == list[int]
|
||||
|
||||
def test_array_with_nested_enum_items(self):
|
||||
spec = {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}}
|
||||
assert _annotation_from_spec(spec) == list[Literal["a", "b"]]
|
||||
|
||||
def test_unknown_type(self):
|
||||
assert _annotation_from_spec({"type": "foobar"}) is Any
|
||||
|
||||
def test_missing_type(self):
|
||||
assert _annotation_from_spec({}) is Any
|
||||
|
||||
def test_none_spec(self):
|
||||
assert _annotation_from_spec(None) is Any
|
||||
|
||||
def test_empty_dict(self):
|
||||
assert _annotation_from_spec({}) is Any
|
||||
|
||||
|
||||
# --- signature_from_schema tests ---
|
||||
|
||||
|
||||
class TestSignatureFromSchema:
|
||||
def test_empty_schema(self):
|
||||
sig = signature_from_schema({})
|
||||
assert str(sig) == "()"
|
||||
|
||||
def test_none_schema(self):
|
||||
sig = signature_from_schema(None)
|
||||
assert str(sig) == "()"
|
||||
|
||||
def test_no_properties(self):
|
||||
sig = signature_from_schema({"type": "object"})
|
||||
assert str(sig) == "()"
|
||||
|
||||
def test_required_param(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
}
|
||||
sig = signature_from_schema(schema)
|
||||
assert str(sig) == "(name: str)"
|
||||
|
||||
def test_optional_param_defaults_to_none(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
}
|
||||
sig = signature_from_schema(schema)
|
||||
assert str(sig) == "(name: str = None)"
|
||||
|
||||
def test_schema_default_on_required(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"count": {"type": "integer", "default": 5}},
|
||||
"required": ["count"],
|
||||
}
|
||||
sig = signature_from_schema(schema)
|
||||
assert str(sig) == "(count: int = 5)"
|
||||
|
||||
def test_schema_default_on_optional(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"limit": {"type": "integer", "default": 10}},
|
||||
}
|
||||
sig = signature_from_schema(schema)
|
||||
assert str(sig) == "(limit: int = 10)"
|
||||
|
||||
def test_enum_required(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"direction": {"type": "string", "enum": ["left", "right"]},
|
||||
},
|
||||
"required": ["direction"],
|
||||
}
|
||||
sig = signature_from_schema(schema)
|
||||
assert str(sig) == "(direction: Literal['left', 'right'])"
|
||||
|
||||
def test_mixed_required_optional_no_star(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 10},
|
||||
"verbose": {"type": "boolean"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
sig = signature_from_schema(schema)
|
||||
assert "*" not in str(sig)
|
||||
assert str(sig) == "(query: str, limit: int = 10, verbose: bool = None)"
|
||||
|
||||
def test_all_required(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"type": "string"},
|
||||
"b": {"type": "integer"},
|
||||
},
|
||||
"required": ["a", "b"],
|
||||
}
|
||||
sig = signature_from_schema(schema)
|
||||
assert str(sig) == "(a: str, b: int)"
|
||||
|
||||
def test_all_optional(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"type": "number"},
|
||||
"y": {"type": "number"},
|
||||
},
|
||||
}
|
||||
sig = signature_from_schema(schema)
|
||||
assert str(sig) == "(x: float = None, y: float = None)"
|
||||
|
||||
|
||||
# --- Tool.to_code_prompt tests ---
|
||||
|
||||
|
||||
class TestToolToCodePrompt:
|
||||
def _make_tool(self, name, description, parameters):
|
||||
tool = FunctionTool()
|
||||
tool.name = name
|
||||
tool.description = description
|
||||
tool.type = "function"
|
||||
tool.parameters = parameters
|
||||
return tool
|
||||
|
||||
def test_basic_code_prompt(self):
|
||||
tool = self._make_tool(
|
||||
"greet",
|
||||
"Greet the user.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "User name."},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
result = tool.to_code_prompt(include_args_doc=True)
|
||||
assert "def greet(name: str):" in result
|
||||
assert "Greet the user." in result
|
||||
assert "name: User name." in result
|
||||
|
||||
def test_no_params(self):
|
||||
tool = self._make_tool(
|
||||
"ping",
|
||||
"Ping the server.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
)
|
||||
result = tool.to_code_prompt()
|
||||
assert "def ping():" in result
|
||||
|
||||
def test_enum_and_optional(self):
|
||||
tool = self._make_tool(
|
||||
"move",
|
||||
"Move robot.",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"enum": ["left", "right"],
|
||||
"description": "Direction.",
|
||||
},
|
||||
"speed": {"type": "number", "description": "Speed."},
|
||||
},
|
||||
"required": ["direction"],
|
||||
},
|
||||
)
|
||||
result = tool.to_code_prompt()
|
||||
assert "Literal['left', 'right']" in result
|
||||
assert "speed: float = None" in result
|
||||
assert "*" not in result.split("\n")[0]
|
||||
Reference in New Issue
Block a user