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

This commit is contained in:
valenti
2026-08-26 11:30:14 +00:00
commit b5f82fb48c
207 changed files with 42776 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+182
View File
@@ -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()
View File
+60
View File
@@ -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)
+676
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+594
View File
@@ -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)
+148
View File
@@ -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__}"
)
+9
View File
@@ -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 "
+218
View File
@@ -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
+77
View File
@@ -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"
+304
View File
@@ -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]
+875
View File
@@ -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
+597
View File
@@ -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
+845
View File
@@ -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
+261
View File
@@ -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()
+51
View File
@@ -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
+80
View File
@@ -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()
+42
View File
@@ -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
+198
View File
@@ -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
+152
View File
@@ -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."
+94
View File
@@ -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())
View File
+285
View File
@@ -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]