feat: establish voice assistant V1 baseline

This commit is contained in:
2026-10-09 18:31:21 +00:00
commit cebcb3fd61
57 changed files with 9501 additions and 0 deletions

40
tests/test_agent.py Normal file
View File

@@ -0,0 +1,40 @@
import pytest
from voice_assistant.agent import MockAgentAdapter
@pytest.mark.anyio
async def text_mock_agent_process_text():
agent = MackAgentAdapter()
response = await agent.process(
session_id="session-1",
turn_id=1,
text="Qual o horário de funcionamento?",
)
assert response == (
"Resposta simulada para: "
"Qual o horário de funcionamento?"
)
@pytest.mark.anyio
async def test_mock_agent_records_call():
agent = MockAgentAdapter()
await agent.process(
session_id="session-1",
turn_id=7,
text= "Olá",
)
assert agent.calls == [
(
"session-1",
7,
"Olá",
)
]

View File

@@ -0,0 +1,31 @@
"""
Tests for the canonical Voice Assistant audio contract.
Purpose:
Verify that the Voice Core exposes the expected provider-independent
canonical audio format.
"""
import pytest
from pydantic import ValidationError
from voice_assistant.audio.models import (
AudioFormat,
CANONICAL_AUDIO_FORMAT,
)
def test_canonical_audio_format():
assert CANONICAL_AUDIO_FORMAT.encoding == "pcm_s16le"
assert CANONICAL_AUDIO_FORMAT.sample_rate == 16000
assert CANONICAL_AUDIO_FORMAT.channels == 1
def test_audio_format_rejects_unsupported_encoding():
with pytest.raises(ValidationError):
AudioFormat(
encoding="mp3",
sample_rate=16000,
channels=1,
)

288
tests/test_config.py Normal file
View File

@@ -0,0 +1,288 @@
"""
Typed configuration models for the Voice Assistant.
Purpose:
Define and validate the configuration structures used by the
application and dynamic provider loading system.
Responsibilities:
- Define the application configuration for STT, Agent, and TTS.
- Define the provider catalog structure.
- Represent provider-specific options without coupling the core
configuration model to a specific vendor or implementation.
- Describe provider capabilities for discovery and future tooling.
Non-responsibilities:
- Loading YAML files.
- Reading environment variables or secrets.
- Selecting or instantiating provider implementations.
- Executing STT, Agent, or TTS functionality.
Architecture:
YAML
|
v
Config Loader
|
v
AppConfig / ProviderCatalog
|
v
Provider Loader
|
v
STTAdapter / AgentAdapter / TTSAdapter
"""
import pytest
import yaml
from voice_assistant.config.loader import load_config
from voice_assistant.config.loader import (
load_config,
load_provider_catalog,
)
def test_load_development_config():
config = load_config()
assert config.stt.provider == "nvidia"
assert config.stt.options["server"] == "127.0.0.1:9000"
assert config.stt.options["language_code"] == "pt-BR"
assert config.stt.options["automatic_punctuation"] is True
assert config.tts.provider == "voxcpm"
assert config.tts.options["server"] == "http://127.0.0.1:8000"
assert config.tts.options["model"] == "openbmb/VoxCPM2"
assert config.tts.options["voice"] == "default"
assert config.agent.provider == "mock"
def test_load_config_missing_file(tmp_path):
with pytest.raises(
FileNotFoundError,
match="Configuration file not found",
):
load_config(
environment="development",
config_dir=tmp_path,
)
def test_load_config_empty_file(tmp_path):
config_file = tmp_path / "development.yaml"
config_file.write_text(
"",
encoding="utf-8",
)
with pytest.raises(
ValueError,
match="Configuration file is empty",
):
load_config(
environment="development",
config_dir=tmp_path,
)
def test_load_config_root_must_be_mapping(tmp_path):
config_file = tmp_path / "development.yaml"
config_file.write_text(
"- item1\n- item2\n",
encoding="utf-8",
)
with pytest.raises(
ValueError,
match="Configuration root must be a mapping",
):
load_config(
environment="development",
config_dir=tmp_path,
)
def test_load_config_validates_required_fields(tmp_path):
config_file = tmp_path / "development.yaml"
config_file.write_text(
yaml.safe_dump(
{
"stt": {
"provider": "nvidia",
"server": "127.0.0.1:9000",
"language": "pt-BR",
},
"tts": {
"provider": "voxcpm",
"server": "http://127.0.0.1:8000",
"model": "openbmb/VoxCPM2",
"voice": "default",
},
# agent intentionally missing
}
),
encoding="utf-8",
)
with pytest.raises(
Exception,
match="agent",
):
load_config(
environment="development",
config_dir=tmp_path,
)
def test_load_provider_catalog():
catalog = load_provider_catalog()
assert "nvidia" in catalog.providers["stt"]
assert "voxcpm" in catalog.providers["tts"]
assert "mock" in catalog.providers["agent"]
voxcpm = catalog.providers["tts"]["voxcpm"]
assert voxcpm.name == "VoxCPM"
assert (
voxcpm.implementation
== "voice_assistant.tts.voxcpm:VoxCPMTTSAdapter"
)
assert voxcpm.capabilities == {
"realtime": True,
"streaming": True,
}
def test_load_provider_catalog_missing_file(tmp_path):
with pytest.raises(
FileNotFoundError,
match="Provider catalog not found",
):
load_provider_catalog(
config_dir=tmp_path,
)
def test_load_config_uses_app_env(
tmp_path,
monkeypatch,
):
development = tmp_path / "development.yaml"
development.write_text(
"""
stt:
provider: nvidia
options:
server: "dev-asr"
tts:
provider: voxcpm
options:
server: "dev-tts"
model: "dev-model"
voice: "default"
agent:
provider: mock
options: {}
""",
encoding="utf-8",
)
monkeypatch.setenv(
"APP_ENV",
"development",
)
config = load_config(
config_dir=tmp_path,
)
assert config.stt.options["server"] == "dev-asr"
assert config.tts.options["server"] == "dev-tts"
assert config.tts.options["model"] == "dev-model"
def test_load_config_rejects_unknown_environment(
tmp_path,
):
with pytest.raises(
FileNotFoundError,
match="Configuration file not found",
):
load_config(
environment="does-not-exist",
config_dir=tmp_path,
)
def test_load_config_uses_app_env(
tmp_path,
monkeypatch,
):
config_file = tmp_path / "staging.yaml"
config_file.write_text(
"""
stt:
provider: nvidia
options:
server: "staging-asr:9000"
tts:
provider: voxcpm
options:
server: "http://staging-tts:8000"
model: "staging-model"
voice: "staging-voice"
agent:
provider: mock
options: {}
""",
encoding="utf-8",
)
monkeypatch.setenv(
"APP_ENV",
"staging",
)
config = load_config(
config_dir=tmp_path,
)
assert config.stt.provider == "nvidia"
assert (
config.stt.options["server"]
== "staging-asr:9000"
)
assert config.tts.provider == "voxcpm"
assert (
config.tts.options["server"]
== "http://staging-tts:8000"
)
assert config.tts.options["model"] == "staging-model"
assert config.tts.options["voice"] == "staging-voice"
assert config.agent.provider == "mock"
def test_load_config_rejects_unknown_environment(
tmp_path,
):
with pytest.raises(
FileNotFoundError,
match="Configuration file not found",
):
load_config(
environment="does-not-exist",
config_dir=tmp_path,
)

1701
tests/test_gateway.py Normal file

File diff suppressed because it is too large Load Diff

0
tests/test_nvidia_stt.py Normal file
View File

View File

@@ -0,0 +1,71 @@
import asyncio
import wave
from voice_assistant.stt.nvidia import NvidiaSTTAdapter
AUDIO_FILE = (
"/home/ubuntu/voice-assistant/audios/chafik_normalize.wav"
)
CHUNK_FRAMES = 1600
async def main() -> None:
stt = NvidiaSTTAdapter(
server="127.0.0.1:9000",
language_code="pt-BR",
automatic_punctuation=True,
)
print("Starting STT session...")
await stt.start_session()
print("STT session ready.")
async def send_audio() -> None:
with wave.open(AUDIO_FILE, "rb") as wav:
while True:
audio = wav.readframes(CHUNK_FRAMES)
if not audio:
break
await stt.send_audio(audio)
async def receive_events() -> None:
async for event in stt.events():
print(
f"[{event.type.upper():7}] {event.text}"
)
sender = asyncio.create_task(send_audio())
receiver = asyncio.create_task(receive_events())
try:
await sender
print("Audio stream sent.")
await stt.stop_session()
await receiver
finally:
if not sender.done():
sender.cancel()
if not receiver.done():
receiver.cancel()
await asyncio.gather(
sender,
receiver,
return_exceptions=True,
)
print("STT session finished.")
if __name__ == "__main__":
asyncio.run(main())

170
tests/test_protocol.py Normal file
View File

@@ -0,0 +1,170 @@
"""
Tests for the Voice Assistant WebSocket protocol.
Purpose:
Verify that protocol message models enforce their declared
event types and canonical audio contract.
Responsibilities:
- Validate inbound protocol event discriminators.
- Validate SessionStart audio configuration.
- Reject messages whose type does not match their model.
Non-responsibilities:
- Testing WebSocket transport behavior.
- Testing session or turn lifecycle.
- Testing STT, Agent, or TTS providers.
"""
import pytest
from pydantic import ValidationError
from voice_assistant.audio.models import CANONICAL_AUDIO_FORMAT
from voice_assistant.audio_gateway.app.protocol import (
AgentResponse,
AudioChunk,
AudioStart,
AudioStop,
ErrorEvent,
Interrupt,
Interrupted,
SessionEnd,
SessionEnded,
SessionReady,
SessionStart,
STTFinal,
STTPartial,
TurnEnded,
TurnStarted,
)
@pytest.mark.parametrize(
("model", "expected_type"),
[
(SessionStart, "session.start"),
(AudioStart, "audio.start"),
(AudioChunk, "audio.chunk"),
(AudioStop, "audio.stop"),
(Interrupt, "interrupt"),
(SessionEnd, "session.end"),
],
)
def test_protocol_event_has_expected_type(
model,
expected_type,
):
payload = {
"type": expected_type,
}
if model in (AudioChunk, AudioStop, Interrupt):
payload["turn_id"] = 1
if model is AudioChunk:
payload["sequence_number"] = 0
event = model.model_validate(payload)
assert event.type == expected_type
def test_protocol_rejects_wrong_event_type():
with pytest.raises(ValidationError):
AudioStart.model_validate(
{
"type": "audio.stop",
}
)
def test_session_ready_output():
event = SessionReady(
protocol_version="1.0",
session_id="session-1",
audio=CANONICAL_AUDIO_FORMAT,
)
assert event.model_dump() == {
"type": "session.ready",
"protocol_version": "1.0",
"session_id": "session-1",
"audio": {
"encoding": "pcm_s16le",
"sample_rate": 16000,
"channels": 1,
},
}
@pytest.mark.parametrize(
("model", "event_type"),
[
(TurnStarted, "turn.started"),
(STTPartial, "stt.partial"),
(STTFinal, "stt.final"),
(AgentResponse, "agent.response"),
(TurnEnded, "turn.ended"),
(Interrupted, "interrupted"),
],
)
def test_turn_output_event_types(
model,
event_type,
):
data = {
"session_id": "session-1",
"turn_id": 1,
}
if model in (STTPartial, STTFinal, AgentResponse):
data["text"] = "test"
event = model(**data)
assert event.type == event_type
def test_session_ended_output():
event = SessionEnded(
session_id="session-1",
)
assert event.type == "session.ended"
assert event.session_id == "session-1"
def test_error_event():
event = ErrorEvent(
session_id="session-1",
turn_id=1,
code="TEST_ERROR",
message="Something went wrong.",
retryable=True,
details={
"reason": "test",
},
)
assert event.model_dump() == {
"type": "error",
"session_id": "session-1",
"turn_id": 1,
"code": "TEST_ERROR",
"message": "Something went wrong.",
"retryable": True,
"details": {
"reason": "test",
},
}
def test_error_event_defaults():
event = ErrorEvent(
code="TEST_ERROR",
message="Something went wrong.",
)
assert event.type == "error"
assert event.session_id is None
assert event.turn_id is None
assert event.retryable is False
assert event.details == {}

View File

@@ -0,0 +1,83 @@
"""
Provider factory tests for the Voice Assistant.
Purpose:
Verify that the provider composition layer resolves the configured
providers dynamically and passes the configured options correctly.
Responsibilities:
- Validate STT provider creation.
- Validate Agent provider creation.
- Validate TTS provider creation.
- Verify provider-specific options are propagated to each adapter.
Non-responsibilities:
- Testing provider internals.
- Testing WebSocket behavior.
- Testing external services.
Architecture:
AppConfig + ProviderCatalog
|
v
provider factories
|
v
concrete Adapter instances
"""
from voice_assistant.agent.mock import MockAgentAdapter
from voice_assistant.config.loader import (
load_config,
load_provider_catalog,
)
from voice_assistant.providers import (
create_agent_adapter,
create_stt_adapter,
create_tts_adapter,
)
from voice_assistant.stt.nvidia import NvidiaSTTAdapter
from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter
def test_create_stt_adapter_from_config():
config = load_config()
catalog = load_provider_catalog()
stt = create_stt_adapter(
config=config,
catalog=catalog,
)
assert isinstance(stt, NvidiaSTTAdapter)
assert stt.server == "127.0.0.1:9000"
assert stt.language_code == "pt-BR"
assert stt.automatic_punctuation is True
def test_create_agent_adapter_from_config():
config = load_config()
catalog = load_provider_catalog()
agent = create_agent_adapter(
config=config,
catalog=catalog,
)
assert isinstance(agent, MockAgentAdapter)
def test_create_tts_adapter_from_config():
config = load_config()
catalog = load_provider_catalog()
tts = create_tts_adapter(
config=config,
catalog=catalog,
)
assert isinstance(tts, VoxCPMTTSAdapter)
assert tts.server == "http://127.0.0.1:8000"
assert tts.model == "openbmb/VoxCPM2"
assert tts.voice == "default"

75
tests/test_providers.py Normal file
View File

@@ -0,0 +1,75 @@
import pytest
from voice_assistant.agent.mock import MockAgentAdapter
from voice_assistant.config.loader import load_provider_catalog
from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter
from voice_assistant.config.models import ProviderCatalog
from voice_assistant.providers import load_provider_class
def test_provider_must_implement_component_contract():
catalog = ProviderCatalog.model_validate(
{
"providers": {
"tts": {
"invalid": {
"name": "Invalid TTS Provider",
"implementation": (
"voice_assistant.agent.mock:"
"MockAgentAdapter"
),
"capabilities": {},
}
}
}
}
)
with pytest.raises(
TypeError,
match="must implement TTSAdapter",
):
load_provider_class(
catalog=catalog,
component="tts",
provider="invalid",
)
def test_load_tts_provider_class():
catalog = load_provider_catalog()
provider_class = load_provider_class(
catalog=catalog,
component="tts",
provider="voxcpm",
)
assert provider_class is VoxCPMTTSAdapter
def test_load_agent_provider_class():
catalog = load_provider_catalog()
provider_class = load_provider_class(
catalog=catalog,
component="agent",
provider="mock",
)
assert provider_class is MockAgentAdapter
def test_unknown_provider_fails():
catalog = load_provider_catalog()
with pytest.raises(
ValueError,
match="is not registered",
):
load_provider_class(
catalog=catalog,
component="tts",
provider="does-not-exist",
)

View File

@@ -0,0 +1,281 @@
"""
Tests for SessionState and TurnManager lifecycle ownership.
Purpose:
Define and verify the separation between session lifecycle state
and semantic turn state.
Responsibilities:
- Verify SessionState owns session and audio sequencing state.
- Verify TurnManager owns semantic turn transitions.
- Verify the expected lifecycle of a complete turn.
- Verify interruption and invalid transitions.
Non-responsibilities:
- Testing STT, Agent, or TTS providers.
- Testing WebSocket transport.
- Testing provider configuration.
"""
import pytest
from voice_assistant.audio_gateway.app.session import SessionState
from voice_assistant.turn_manager import TurnManager, TurnState
def test_session_creates_sequential_turn_ids():
session = SessionState()
first_turn = session.start_turn()
assert first_turn == 1
assert session.active_turn_id == 1
assert session.expected_audio_sequence == 0
session.end_turn(first_turn)
second_turn = session.start_turn()
assert second_turn == 2
assert session.active_turn_id == 2
assert session.expected_audio_sequence == 0
def test_session_owns_audio_sequence():
session = SessionState()
turn_id = session.start_turn()
session.accept_audio_chunk(
turn_id=turn_id,
sequence_number=0,
payload_size=320,
)
assert session.expected_audio_sequence == 1
session.accept_audio_chunk(
turn_id=turn_id,
sequence_number=1,
payload_size=320,
)
assert session.expected_audio_sequence == 2
def test_session_rejects_out_of_order_audio():
session = SessionState()
turn_id = session.start_turn()
with pytest.raises(
ValueError,
match="Expected sequence_number=0",
):
session.accept_audio_chunk(
turn_id=turn_id,
sequence_number=1,
payload_size=320,
)
def test_turn_manager_owns_semantic_turn_state():
manager = TurnManager()
turn = manager.start(turn_id=1)
assert turn.turn_id == 1
assert turn.state == TurnState.LISTENING
manager.on_partial(
turn_id=1,
text="Olá",
)
assert turn.state == TurnState.LISTENING
manager.on_final(
turn_id=1,
text="Olá!",
)
assert turn.state == TurnState.PROCESSING
manager.on_agent_response(turn_id=1)
assert turn.state == TurnState.SPEAKING
manager.on_tts_complete(turn_id=1)
assert turn.state == TurnState.COMPLETED
def test_turn_manager_rejects_invalid_transition():
manager = TurnManager()
manager.start(turn_id=1)
with pytest.raises(
RuntimeError,
match="Cannot start speaking",
):
manager.on_agent_response(turn_id=1)
def test_turn_manager_interrupts_active_turn():
manager = TurnManager()
manager.start(turn_id=1)
turn = manager.interrupt(turn_id=1)
assert turn.state == TurnState.INTERRUPTED
assert manager.active_turn is turn
def test_turn_manager_stop_requires_completed_state():
manager = TurnManager()
manager.start(turn_id=1)
manager.on_final(
turn_id=1,
text="Olá!",
)
with pytest.raises(
RuntimeError,
match="Cannot stop turn while turn is processing",
):
manager.stop(turn_id=1)
def test_turn_manager_stop_allows_completed_turn():
manager = TurnManager()
manager.start(turn_id=1)
manager.on_final(
turn_id=1,
text="Olá!",
)
manager.on_agent_response(turn_id=1)
manager.on_tts_complete(turn_id=1)
turn = manager.stop(turn_id=1)
assert turn.state == TurnState.COMPLETED
assert turn.final_text == "Olá!"
def test_session_and_turn_manager_complete_same_turn():
session = SessionState()
manager = TurnManager()
turn_id = session.start_turn()
turn = manager.start(turn_id)
assert session.active_turn_id == turn_id
assert turn.turn_id == turn_id
assert turn.state == TurnState.LISTENING
manager.on_final(
turn_id,
"Olá!",
)
manager.on_agent_response(turn_id)
manager.on_tts_complete(turn_id)
completed_turn = manager.stop(turn_id)
assert completed_turn.state == TurnState.COMPLETED
manager.clear(turn_id)
session.end_turn(turn_id)
assert manager.active_turn is None
assert session.active_turn_id is None
def test_session_and_turn_manager_must_use_same_turn_id():
session = SessionState()
manager = TurnManager()
session_turn_id = session.start_turn()
manager.start(1)
with pytest.raises(
ValueError,
match="Unexpected turn_id=2",
):
session.accept_audio_chunk(
turn_id=2,
sequence_number=0,
payload_size=320,
)
assert session.active_turn_id == session_turn_id
assert manager.active_turn is not None
assert manager.active_turn.turn_id == 1
def test_session_ends_turn_after_turn_manager_completion():
session = SessionState()
manager = TurnManager()
turn_id = session.start_turn()
manager.start(turn_id)
manager.on_final(
turn_id,
"Olá!",
)
manager.on_agent_response(turn_id)
manager.on_tts_complete(turn_id)
manager.stop(turn_id)
manager.clear(turn_id)
session.end_turn(turn_id)
assert session.active_turn_id is None
def test_session_and_turn_manager_interrupt_same_turn():
session = SessionState()
manager = TurnManager()
turn_id = session.start_turn()
manager.start(turn_id)
session.interrupt(turn_id)
interrupted_turn = manager.interrupt(turn_id)
assert session.active_turn_id is None
assert manager.active_turn is interrupted_turn
assert interrupted_turn.turn_id == turn_id
assert interrupted_turn.state == TurnState.INTERRUPTED
def test_session_can_start_next_turn_after_interrupt():
session = SessionState()
manager = TurnManager()
first_turn_id = session.start_turn()
manager.start(first_turn_id)
session.interrupt(first_turn_id)
manager.interrupt(first_turn_id)
manager.clear(first_turn_id)
second_turn_id = session.start_turn()
manager.start(second_turn_id)
assert second_turn_id == 2
assert session.active_turn_id == 2
assert manager.active_turn is not None
assert manager.active_turn.turn_id == 2
assert manager.active_turn.state == TurnState.LISTENING

307
tests/test_stt_abort.py Normal file
View File

@@ -0,0 +1,307 @@
"""
Tests for STT session abort behavior.
Purpose:
Verify that STT implementations support interruption
without delivering stale transcription results.
Responsibilities:
- Validate immediate abort.
- Verify pending transcript events are discarded.
- Verify repeated abort calls are safe.
- Verify audio is rejected after abort.
"""
import pytest
import asyncio
from unittest.mock import AsyncMock
from voice_assistant.stt.nvidia import NvidiaSTTAdapter
from voice_assistant.stt.models import TranscriptEvent
from voice_assistant.stt.mock import MockSTTAdapter
@pytest.mark.anyio
async def test_mock_stt_abort_discards_pending_events():
stt = MockSTTAdapter()
await stt.start_session()
await stt.emit_partial("Olá")
await stt.emit_final("Olá, tudo bem?")
await stt.abort_session()
events = [
event
async for event in stt.events()
]
assert events == []
@pytest.mark.anyio
async def test_mock_stt_abort_rejects_audio():
stt = MockSTTAdapter()
await stt.start_session()
await stt.abort_session()
with pytest.raises(
RuntimeError,
match="STT session has not been started",
):
await stt.send_audio(b"\x00\x01")
@pytest.mark.anyio
async def test_mock_stt_abort_is_idempotent():
stt = MockSTTAdapter()
await stt.start_session()
await stt.abort_session()
await stt.abort_session()
assert stt._started is False
@pytest.mark.anyio
async def test_mock_stt_abort_unblocks_event_consumer():
stt = MockSTTAdapter()
await stt.start_session()
async def consume():
return [
event
async for event in stt.events()
]
import asyncio
consumer = asyncio.create_task(consume())
# Allow the consumer to start waiting for events.
await asyncio.sleep(0)
await stt.abort_session()
events = await asyncio.wait_for(
consumer,
timeout=1.0,
)
assert events == []
@pytest.mark.anyio
async def test_nvidia_abort_without_started_session():
stt = NvidiaSTTAdapter()
await stt.abort_session()
await stt.abort_session()
assert stt._started is False
assert stt._websocket is None
assert stt._http is None
@pytest.mark.anyio
async def test_nvidia_abort_discards_pending_events():
stt = NvidiaSTTAdapter()
stt._started = True
await stt._events.put(
TranscriptEvent(
type="partial",
text="Transcrição antiga",
)
)
await stt.abort_session()
events = [
event
async for event in stt.events()
]
assert events == []
@pytest.mark.anyio
async def test_nvidia_abort_unblocks_consumer():
stt = NvidiaSTTAdapter()
stt._started = True
async def consume():
return [
event
async for event in stt.events()
]
consumer = asyncio.create_task(consume())
await asyncio.sleep(0)
await stt.abort_session()
events = await asyncio.wait_for(
consumer,
timeout=1.0,
)
assert events == []
@pytest.mark.anyio
async def test_nvidia_abort_closes_resources_without_done():
stt = NvidiaSTTAdapter()
websocket = AsyncMock()
http_client = AsyncMock()
stt._started = True
stt._websocket = websocket
stt._http = http_client
await stt.abort_session()
websocket.close.assert_awaited_once()
websocket.send.assert_not_awaited()
http_client.aclose.assert_awaited_once()
assert stt._websocket is None
assert stt._http is None
assert stt._started is False
@pytest.mark.anyio
async def test_nvidia_abort_cancels_receiver_task():
stt = NvidiaSTTAdapter()
stt._started = True
started = asyncio.Event()
finished = asyncio.Event()
async def receiver():
started.set()
try:
await asyncio.Event().wait()
finally:
finished.set()
task = asyncio.create_task(receiver())
stt._receiver_task = task
await started.wait()
await stt.abort_session()
assert task.done()
assert task.cancelled()
assert finished.is_set()
assert stt._receiver_task is None
@pytest.mark.anyio
async def test_nvidia_concurrent_abort():
"""
Verify that concurrent abort requests produce a single
end-of-stream marker and leave the adapter stopped.
"""
stt = NvidiaSTTAdapter()
stt._started = True
await asyncio.gather(
stt.abort_session(),
stt.abort_session(),
)
assert stt._started is False
assert stt._receiver_task is None
assert stt._websocket is None
assert stt._http is None
assert stt._events.qsize() == 1
@pytest.mark.anyio
async def test_nvidia_abort_does_not_deliver_late_events():
"""Queued transcripts must not survive an explicit abort."""
stt = NvidiaSTTAdapter()
stt._started = True
await stt._events.put(
TranscriptEvent(
type="partial",
text="Resultado antigo",
)
)
await stt.abort_session()
events = [
event
async for event in stt.events()
]
assert events == []
@pytest.mark.anyio
async def test_nvidia_concurrent_abort_closes_resources_once():
"""
Verify that concurrent abort requests do not close
provider resources more than once.
"""
stt = NvidiaSTTAdapter()
websocket = AsyncMock()
http_client = AsyncMock()
stt._started = True
stt._websocket = websocket
stt._http = http_client
# Force both abort operations to overlap during cleanup.
close_started = asyncio.Event()
allow_close = asyncio.Event()
async def slow_close():
close_started.set()
await allow_close.wait()
websocket.close.side_effect = slow_close
first_abort = asyncio.create_task(
stt.abort_session()
)
try:
await asyncio.wait_for(
close_started.wait(),
timeout=1.0,
)
second_abort = asyncio.create_task(
stt.abort_session()
)
# Give the second abort a chance to enter cleanup.
await asyncio.sleep(0)
finally:
allow_close.set()
await asyncio.gather(
first_abort,
second_abort,
)
websocket.close.assert_awaited_once()
http_client.aclose.assert_awaited_once()
assert stt._events.qsize() == 1

39
tests/test_tts.py Normal file
View File

@@ -0,0 +1,39 @@
import pytest
from voice_assistant.stt.mock import MockSTTAdapter
@pytest.mark.anyio
async def test_mock_stt_lifecycle():
stt = MockSTTAdapter()
await stt.start_session()
await stt.send_audio(b"\x00\x00" * 160)
await stt.emit_partial("eu gostaria de saber")
await stt.emit_final(
"eu gostaria de saber o horário de funcionamento"
)
await stt.stop_session()
events = [event async for event in stt.events()]
assert len(events) == 2
assert events[0].type == "partial"
assert events[0].text == "eu gostaria de saber"
assert events[1].type == "final"
assert events[1].text == (
"eu gostaria de saber o horário de funcionamento"
)
@pytest.mark.anyio
async def test_mock_stt_rejects_audio_before_start():
stt = MockSTTAdapter()
with pytest.raises(RuntimeError, match="has not been started"):
await stt.send_audio(b"\x00\x00")

32
tests/test_tts_adapter.py Normal file
View File

@@ -0,0 +1,32 @@
import pytest
from voice_assistant.tts import MockTTSAdapter
@pytest.mark.anyio
async def test_mock_tts_synthesizes_audio_chunks():
tts= MockTTSAdapter()
chunks = [
chunk
async for chunk in tts.synthesize("Olá, tudo bem?")
]
assert chunks == [
b"\x01\x02\x03\x04",
b"\x05\x06\x07\x08",
]
@pytest.mark.anyio
async def test_mock_tts_records_text():
tts = MockTTSAdapter()
chunks = [
chunk
async for chunk in tts.synthesize("Olá")
]
assert chunks
assert tts.calls == ["Olá"], "A mensagem de retorno deve ser Olá"

194
tests/test_turn_manager.py Normal file
View File

@@ -0,0 +1,194 @@
import pytest
from voice_assistant.turn_manager import (
TurnManager,
TurnState,
)
def test_start_turn():
manager = TurnManager()
turn = manager.start(1)
assert turn.turn_id == 1
assert turn.state == TurnState.LISTENING
assert turn.partial_text == ""
assert turn.final_text is None
def test_partial_replaces_previous_partial():
manager = TurnManager()
manager.start(1)
manager.on_partial(1, "Ô Alex")
assert manager.active_turn is not None
assert manager.active_turn.partial_text == "Ô Alex"
manager.on_partial(1, "Ô Alex tudo bem")
assert manager.active_turn.partial_text == "Ô Alex tudo bem"
def test_final_moves_turn_to_processing():
manager = TurnManager()
manager.start(1)
manager.on_partial(1, "Ô Alex tudo bem")
manager.on_final(1, "Ô Alex tudo bem?")
assert manager.active_turn is not None
assert manager.active_turn.final_text == "Ô Alex tudo bem?"
assert manager.active_turn.partial_text == "Ô Alex tudo bem?"
assert manager.active_turn.state == TurnState.PROCESSING
def test_cannot_start_second_turn():
manager = TurnManager()
manager.start(1)
with pytest.raises(
RuntimeError,
match="A turn is already active",
):
manager.start(2)
def test_cannot_stop_before_completed():
manager = TurnManager()
manager.start(1)
with pytest.raises(
RuntimeError,
match="final transcript",
):
manager.stop(1)
def test_interrupt():
manager = TurnManager()
manager.start(1)
manager.interrupt(1)
assert manager.active_turn is not None
assert (
manager.active_turn.state
== TurnState.INTERRUPTED
)
def test_clear_turn():
manager = TurnManager()
manager.start(1)
manager.clear(1)
assert manager.active_turn is None
def test_agent_response_moves_turn_to_speaking():
manager = TurnManager()
manager.start(1)
manager.on_final(
1,
"Qual o horário?",
)
manager.on_agent_response(1)
assert manager.active_turn is not None
assert (
manager.active_turn.state
== TurnState.SPEAKING
)
def test_tts_completion_moves_turn_to_completed():
manager = TurnManager()
manager.start(1)
manager.on_final(
1,
"Qual o horário?",
)
manager.on_agent_response(1)
manager.on_tts_complete(1)
assert manager.active_turn is not None
assert (
manager.active_turn.state
== TurnState.COMPLETED
)
def test_agent_response_requires_processing_state():
manager = TurnManager()
manager.start(1)
with pytest.raises(
RuntimeError,
match="turn is listening",
):
manager.on_agent_response(1)
def test_tts_completion_requires_speaking_state():
manager = TurnManager()
manager.start(1)
with pytest.raises(
RuntimeError,
match="turn is listening",
):
manager.on_tts_complete(1)
def test_cannot_stop_before_completed():
manager = TurnManager()
manager.start(1)
with pytest.raises(
RuntimeError,
match="Cannot stop turn while turn is listening",
):
manager.stop(1)
def test_clear_removes_completed_turn():
manager = TurnManager()
manager.start(1)
manager.on_final(
1,
"Olá!",
)
manager.on_agent_response(1)
manager.on_tts_complete(1)
manager.clear(1)
assert manager.active_turn is None
def test_clear_rejects_unexpected_turn_id():
manager = TurnManager()
manager.start(1)
with pytest.raises(
ValueError,
match="Unexpected turn_id=2",
):
manager.clear(2)

View File

@@ -0,0 +1,820 @@
"""
Tests for the Voice Pipeline lifecycle orchestration.
Purpose:
Verify that the VoicePipeline coordinates SessionState and
TurnManager without coupling either component to transport details.
Responsibilities:
- Validate turn creation.
- Validate semantic turn completion.
- Validate coordinated interruption.
- Verify SessionState and TurnManager remain independently modeled.
Non-responsibilities:
- Testing WebSocket transport.
- Testing STT, Agent, or TTS providers.
"""
import pytest
import asyncio
from voice_assistant.audio_gateway.app.session import SessionState
from voice_assistant.turn_manager import TurnManager, TurnState
from voice_assistant.voice_pipeline import VoicePipeline
from voice_assistant.agent.mock import MockAgentAdapter
from voice_assistant.tts.mock import MockTTSAdapter
def create_pipeline() -> tuple[
VoicePipeline,
SessionState,
TurnManager,
MockAgentAdapter,
MockTTSAdapter,
]:
session = SessionState()
turn_manager = TurnManager()
agent = MockAgentAdapter()
tts = MockTTSAdapter()
pipeline = VoicePipeline(
session=session,
turn_manager=turn_manager,
agent=agent,
tts=tts,
)
return (
pipeline,
session,
turn_manager,
agent,
tts,
)
def test_pipeline_starts_turn():
pipeline, session, turn_manager, _, _ = create_pipeline()
turn = pipeline.start_turn()
assert turn.turn_id == 1
assert turn.state == TurnState.LISTENING
assert session.active_turn_id == 1
assert turn_manager.active_turn is turn
def test_pipeline_completes_turn():
pipeline, session, turn_manager, _, _ = create_pipeline()
turn = pipeline.start_turn()
turn_manager.on_final(
turn.turn_id,
"Olá!",
)
turn_manager.on_agent_response(turn.turn_id)
turn_manager.on_tts_complete(turn.turn_id)
completed_turn = pipeline.complete_turn(
turn.turn_id,
)
assert completed_turn.state == TurnState.COMPLETED
assert session.active_turn_id is None
assert turn_manager.active_turn is None
def test_pipeline_rejects_incomplete_turn():
pipeline, session, turn_manager, _, _ = create_pipeline()
turn = pipeline.start_turn()
with pytest.raises(
RuntimeError,
match="Cannot stop turn while turn is listening",
):
pipeline.complete_turn(turn.turn_id)
# Failed completion must not destroy the active turn.
assert session.active_turn_id == turn.turn_id
assert turn_manager.active_turn is turn
def test_pipeline_interrupts_turn():
pipeline, session, turn_manager, _, _ = create_pipeline()
turn = pipeline.start_turn()
interrupted_turn = pipeline.interrupt_turn(
turn.turn_id,
)
assert interrupted_turn.state == TurnState.INTERRUPTED
assert session.active_turn_id is None
assert turn_manager.active_turn is None
def test_pipeline_can_start_turn_after_interrupt():
pipeline, session, turn_manager, _, _ = create_pipeline()
first_turn = pipeline.start_turn()
pipeline.interrupt_turn(
first_turn.turn_id,
)
second_turn = pipeline.start_turn()
assert second_turn.turn_id == 2
assert second_turn.state == TurnState.LISTENING
assert session.active_turn_id == 2
assert turn_manager.active_turn is second_turn
@pytest.mark.anyio
async def test_pipeline_processes_transcript_through_agent():
(
pipeline,
session,
turn_manager,
agent,
_,
) = create_pipeline()
turn = pipeline.start_turn()
turn_manager.on_final(
turn.turn_id,
"Olá!",
)
result = await pipeline.process_transcript(
session_id=session.session_id,
turn_id=turn.turn_id,
text="Olá!",
)
assert result.response_text == (
"Resposta simulada para: Olá!"
)
assert agent.calls == [
(
session.session_id,
turn.turn_id,
"Olá!",
)
]
assert turn_manager.active_turn is not None
assert (
turn_manager.active_turn.state
== TurnState.SPEAKING
)
@pytest.mark.anyio
async def test_pipeline_streams_tts_audio():
(
pipeline,
session,
turn_manager,
_,
tts,
) = create_pipeline()
turn = pipeline.start_turn()
turn_manager.on_final(
turn.turn_id,
"Olá!",
)
result = await pipeline.process_transcript(
session_id=session.session_id,
turn_id=turn.turn_id,
text="Olá!",
)
chunks = [
chunk
async for chunk in pipeline.synthesize_response(
result.response_text,
)
]
assert chunks == [
b"\x01\x02\x03\x04",
b"\x05\x06\x07\x08",
]
assert tts.calls == [
"Resposta simulada para: Olá!",
]
@pytest.mark.anyio
async def test_pipeline_processes_complete_voice_turn():
(
pipeline,
session,
turn_manager,
agent,
tts,
) = create_pipeline()
turn = pipeline.start_turn()
turn_manager.on_final(
turn.turn_id,
"Olá!",
)
result = await pipeline.process_transcript(
session_id=session.session_id,
turn_id=turn.turn_id,
text="Olá!",
)
audio_chunks = [
chunk
async for chunk in pipeline.synthesize_response(
result.response_text,
)
]
turn_manager.on_tts_complete(
turn.turn_id,
)
assert result.response_text == (
"Resposta simulada para: Olá!"
)
assert audio_chunks == [
b"\x01\x02\x03\x04",
b"\x05\x06\x07\x08",
]
assert turn_manager.active_turn is not None
assert (
turn_manager.active_turn.state
== TurnState.COMPLETED
)
assert agent.calls == [
(
session.session_id,
turn.turn_id,
"Olá!",
)
]
assert tts.calls == [
"Resposta simulada para: Olá!",
]
def test_pipeline_handles_partial_transcript():
pipeline, _, _, _, _ = create_pipeline()
turn = pipeline.start_turn()
pipeline.on_partial(
turn.turn_id,
"Olá",
)
assert turn.partial_text == "Olá"
assert turn.state == TurnState.LISTENING
def test_pipeline_handles_final_transcript():
pipeline, _, _, _, _ = create_pipeline()
turn = pipeline.start_turn()
pipeline.on_final(
turn.turn_id,
"Olá!",
)
assert turn.final_text == "Olá!"
assert turn.partial_text == "Olá!"
assert turn.state == TurnState.PROCESSING
def test_pipeline_handles_tts_completion():
pipeline, _, _, _, _ = create_pipeline()
turn = pipeline.start_turn()
pipeline.on_final(
turn.turn_id,
"Olá!",
)
# The Agent transition is part of the normal semantic lifecycle.
pipeline.on_agent_response(
turn.turn_id,
)
pipeline.on_tts_complete(
turn.turn_id,
)
assert turn.state == TurnState.COMPLETED
def test_pipeline_closes_session_without_active_turn():
pipeline, session, turn_manager, _, _ = create_pipeline()
pipeline.close_session()
assert session.closed is True
assert session.active_turn_id is None
assert turn_manager.active_turn is None
def test_pipeline_closes_session_with_active_turn():
pipeline, session, turn_manager, _, _ = create_pipeline()
turn = pipeline.start_turn()
pipeline.close_session()
assert session.closed is True
assert session.active_turn_id is None
assert turn_manager.active_turn is None
assert turn.state == TurnState.INTERRUPTED
def test_pipeline_cannot_start_turn_after_session_close():
pipeline, session, turn_manager, _, _ = create_pipeline()
pipeline.close_session()
with pytest.raises(
RuntimeError,
match="Session is closed",
):
pipeline.start_turn()
def test_pipeline_interrupt_if_active():
pipeline, session, turn_manager, _, _ = create_pipeline()
turn = pipeline.start_turn()
interrupted = pipeline.interrupt_if_active(
turn.turn_id,
)
assert interrupted is True
assert session.active_turn_id is None
assert turn_manager.active_turn is None
assert turn.state == TurnState.INTERRUPTED
def test_pipeline_interrupt_if_active_unknown_turn():
pipeline, session, turn_manager, _, _ = create_pipeline()
turn = pipeline.start_turn()
interrupted = pipeline.interrupt_if_active(
turn.turn_id + 1,
)
assert interrupted is False
assert session.active_turn_id == turn.turn_id
assert turn_manager.active_turn is turn
assert turn.state == TurnState.LISTENING
@pytest.mark.anyio
async def test_pipeline_registers_turn_task():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
async def worker():
await asyncio.sleep(0)
task = asyncio.create_task(worker())
pipeline.register_turn_task(task)
assert pipeline.turn_task is task
await task
@pytest.mark.anyio
async def test_pipeline_rejects_duplicate_running_task():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
started = asyncio.Event()
release = asyncio.Event()
async def worker():
started.set()
await release.wait()
task = asyncio.create_task(worker())
try:
await started.wait()
pipeline.register_turn_task(task)
with pytest.raises(
RuntimeError,
match="A turn execution task is already active",
):
pipeline.register_turn_task(task)
finally:
release.set()
await task
@pytest.mark.anyio
async def test_pipeline_rejects_task_without_active_turn():
pipeline, _, _, _, _ = create_pipeline()
async def worker():
await asyncio.sleep(0)
task = asyncio.create_task(worker())
try:
with pytest.raises(
RuntimeError,
match="Cannot register task without an active turn",
):
pipeline.register_turn_task(task)
finally:
await task
@pytest.mark.anyio
async def test_pipeline_cancels_running_turn_task():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
started = asyncio.Event()
cancelled = asyncio.Event()
async def worker():
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
task = asyncio.create_task(worker())
await started.wait()
pipeline.register_turn_task(task)
await pipeline.cancel_turn_task()
assert cancelled.is_set()
assert task.done()
assert task.cancelled()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_cancel_without_registered_task():
pipeline, _, _, _, _ = create_pipeline()
await pipeline.cancel_turn_task()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_cancel_completed_task():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
async def worker():
return None
task = asyncio.create_task(worker())
pipeline.register_turn_task(task)
await task
await pipeline.cancel_turn_task()
assert task.done()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_rejects_self_cancellation():
"""
Verify that a turn task cannot cancel itself.
The task reference must remain available so another
task can subsequently perform cleanup.
"""
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
async def worker():
await pipeline.cancel_turn_task()
task = asyncio.create_task(worker())
pipeline.register_turn_task(task)
# Self-cancellation must be rejected.
with pytest.raises(
RuntimeError,
match="A turn task cannot cancel itself",
):
await task
# The failed task remains registered until explicit cleanup.
assert pipeline.turn_task is task
# Cleanup is performed from the test task, not the worker.
with pytest.raises(
RuntimeError,
match="A turn task cannot cancel itself",
):
await pipeline.cancel_turn_task()
# The reference must be released after the completed task
# has been awaited, even when its exception is propagated.
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_propagates_turn_task_failure():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
async def worker():
raise ValueError(
"Provider execution failed."
)
task = asyncio.create_task(worker())
pipeline.register_turn_task(task)
# Allow the worker to execute and finish with its error.
await asyncio.sleep(0)
assert task.done()
with pytest.raises(
ValueError,
match="Provider execution failed",
):
await pipeline.cancel_turn_task()
assert task.done()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_propagates_waiter_cancellation():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
worker_started = asyncio.Event()
allow_cleanup = asyncio.Event()
async def worker():
worker_started.set()
try:
await asyncio.Event().wait()
finally:
await allow_cleanup.wait()
task = asyncio.create_task(worker())
await worker_started.wait()
pipeline.register_turn_task(task)
waiter = asyncio.create_task(
pipeline.cancel_turn_task()
)
try:
# Allow cancellation to reach the worker.
await asyncio.sleep(0)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
# The worker is still completing its cancellation cleanup.
assert pipeline.turn_task is task
finally:
allow_cleanup.set()
await pipeline.cancel_turn_task()
assert task.done()
assert pipeline.turn_task is None
def test_pipeline_identifies_active_turn():
pipeline, _, _, _, _ = create_pipeline()
turn = pipeline.start_turn()
assert pipeline.is_active_turn(turn.turn_id) is True
def test_pipeline_rejects_inactive_turn_id():
pipeline, _, _, _, _ = create_pipeline()
turn = pipeline.start_turn()
assert pipeline.is_active_turn(turn.turn_id + 1) is False
pipeline.interrupt_turn(turn.turn_id)
assert pipeline.is_active_turn(turn.turn_id) is False
@pytest.mark.anyio
async def test_pipeline_waits_for_completed_turn_task():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
completed = asyncio.Event()
async def worker():
completed.set()
task = asyncio.create_task(worker())
pipeline.register_turn_task(task)
await pipeline.wait_turn_task()
assert completed.is_set()
assert task.done()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_wait_propagates_task_failure():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
async def worker():
raise ValueError("Agent processing failed.")
task = asyncio.create_task(worker())
pipeline.register_turn_task(task)
with pytest.raises(
ValueError,
match="Agent processing failed",
):
await pipeline.wait_turn_task()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_wait_without_registered_task():
pipeline, _, _, _, _ = create_pipeline()
await pipeline.wait_turn_task()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_cancel_rejects_invalid_timeout():
pipeline, _, _, _, _ = create_pipeline()
with pytest.raises(
ValueError,
match="Turn cancellation timeout must be positive",
):
await pipeline.cancel_turn_task(timeout=0)
@pytest.mark.anyio
async def test_pipeline_cancel_completes_within_timeout():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
started = asyncio.Event()
async def worker():
started.set()
await asyncio.Event().wait()
task = asyncio.create_task(worker())
await started.wait()
pipeline.register_turn_task(task)
await pipeline.cancel_turn_task(timeout=1.0)
assert task.done()
assert task.cancelled()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_cancel_timeout_preserves_task_ownership():
pipeline, _, _, _, _ = create_pipeline()
pipeline.start_turn()
started = asyncio.Event()
allow_cleanup = asyncio.Event()
cancellation_received = asyncio.Event()
async def worker():
started.set()
try:
await asyncio.Event().wait()
finally:
cancellation_received.set()
await allow_cleanup.wait()
task = asyncio.create_task(worker())
await started.wait()
pipeline.register_turn_task(task)
try:
with pytest.raises(
TimeoutError,
match="Turn task cancellation timed out",
):
await pipeline.cancel_turn_task(timeout=0.01)
assert cancellation_received.is_set()
assert not task.done()
assert pipeline.turn_task is task
finally:
allow_cleanup.set()
await pipeline.cancel_turn_task(timeout=1.0)
assert task.done()
assert pipeline.turn_task is None
@pytest.mark.anyio
async def test_pipeline_rejects_new_turn_while_previous_task_runs():
pipeline, session, _, _, _ = create_pipeline()
first_turn = pipeline.start_turn()
started = asyncio.Event()
release = asyncio.Event()
async def worker():
started.set()
await release.wait()
task = asyncio.create_task(worker())
await started.wait()
pipeline.register_turn_task(task)
# Simulate semantic interruption without task cancellation.
pipeline.interrupt_turn(first_turn.turn_id)
try:
with pytest.raises(
RuntimeError,
match="previous turn task is still running",
):
pipeline.start_turn()
assert session.active_turn_id is None
finally:
release.set()
await pipeline.wait_turn_task()
second_turn = pipeline.start_turn()
assert second_turn.turn_id == 2
assert session.active_turn_id == 2

147
tests/test_voxcpm_tts.py Normal file
View File

@@ -0,0 +1,147 @@
import httpx
import pytest
from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter
class MockHTTPStream:
def __init__(
self,
chunks: list[bytes],
status_code: int = 200,
) -> None:
self.chunks = chunks
self.status_code = status_code
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def raise_for_status(self):
if self.status_code >= 400:
raise httpx.HTTPStatusError(
"mock error",
request=httpx.Request("POST", "http://test"),
response=httpx.Response(
status_code=self.status_code,
),
)
async def aiter_bytes(self):
for chunk in self.chunks:
yield chunk
class MockHTTPClient:
last_request = None
def __init__(self, *args, **kwargs) -> None:
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def stream(self, method, url, json):
self.last_request = {
"method": method,
"url": url,
"json": json,
}
MockHTTPClient.last_request = self.last_request
return MockHTTPStream(
[
b"\x01\x02\x03\x04",
b"\x05\x06\x07\x08",
]
)
@pytest.mark.anyio
async def test_voxcpm_tts_streams_audio(monkeypatch):
monkeypatch.setattr(
httpx,
"AsyncClient",
MockHTTPClient,
)
tts = VoxCPMTTSAdapter(
server="http://127.0.0.1:8000",
model="openbmb/VoxCPM2",
voice="default",
)
chunks = [
chunk
async for chunk in tts.synthesize(
"Olá Alex, tudo bem?"
)
]
assert chunks == [
b"\x01\x02\x03\x04",
b"\x05\x06\x07\x08",
]
@pytest.mark.anyio
async def test_voxcpm_tts_sends_expected_payload(monkeypatch):
monkeypatch.setattr(
httpx,
"AsyncClient",
MockHTTPClient,
)
tts = VoxCPMTTSAdapter(
server="http://127.0.0.1:8000",
model="openbmb/VoxCPM2",
voice="default",
)
chunks = [
chunk
async for chunk in tts.synthesize("Olá")
]
assert chunks
request = MockHTTPClient.last_request
assert request is not None
assert request["method"] == "POST"
assert request["url"] == (
"http://127.0.0.1:8000/v1/audio/speech"
)
assert request["json"] == {
"input": "Olá",
"model": "openbmb/VoxCPM2",
"voice": "default",
"response_format": "pcm",
"stream": True,
"stream_format": "audio",
}
@pytest.mark.anyio
async def test_voxcpm_tts_rejects_empty_text():
tts = VoxCPMTTSAdapter(
server="http://127.0.0.1:8000",
model="openbmb/VoxCPM2",
voice="default",
)
with pytest.raises(
ValueError,
match="Text cannot be empty",
):
chunks = [
chunk
async for chunk in tts.synthesize("")
]

View File

@@ -0,0 +1,29 @@
import pytest
from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter
@pytest.mark.anyio
async def test_voxcpm_tts_real():
tts = VoxCPMTTSAdapter(
server="http://127.0.0.1:8000",
model="openbmb/VoxCPM2",
voice="default",
)
chunks = [
chunk
async for chunk in tts.synthesize(
"Olá Alex, este é um teste do VoxCPM."
)
]
assert chunks
assert all(isinstance(chunk, bytes) for chunk in chunks)
total_bytes = sum(len(chunk) for chunk in chunks)
assert total_bytes > 0
print(f"Chunks recebidos : {len(chunks)}")
print(f"Bytes recebidos : {total_bytes}")