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

View File

@@ -0,0 +1,914 @@
"""
Audio Gateway application and WebSocket transport for the Voice Assistant.
Purpose:
Expose the realtime audio protocol and connect client sessions
to the VoicePipeline orchestration layer.
Responsibilities:
- Accept and validate WebSocket protocol messages.
- Receive canonical PCM audio frames.
- Create and configure session-scoped components.
- Forward STT events and TTS audio to connected clients.
- Delegate semantic turn lifecycle to VoicePipeline.
- Coordinate transport cleanup on disconnect.
- Report protocol and cleanup errors.
Non-responsibilities:
- Implementing STT, Agent, or TTS provider logic.
- Managing semantic turn state directly.
- Loading provider implementations dynamically.
- Implementing provider-specific cancellation.
Architecture:
WebSocket Client
|
v
Audio Gateway
|
v
VoicePipeline
| | |
STT Agent TTS
"""
import json
import logging
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ValidationError
import os
import asyncio
from voice_assistant.voice_pipeline import VoicePipeline
from voice_assistant.stt.adapter import STTAdapter
from voice_assistant.tts.adapter import TTSAdapter
from voice_assistant.turn_manager import (
TurnManager,
TurnState,
)
from voice_assistant.agent.adapter import AgentAdapter
from voice_assistant.audio.models import (
CANONICAL_AUDIO_FORMAT,
)
from voice_assistant.providers import (
create_agent_adapter,
create_stt_adapter,
create_tts_adapter,
)
from voice_assistant.config.loader import (
load_config,
load_provider_catalog,
)
from voice_assistant.audio_gateway.app.protocol import (
PROTOCOL_VERSION,
AUDIO_ENCODING,
AUDIO_SAMPLE_RATE,
AUDIO_CHANNELS,
AudioChunk,
AudioStart,
AudioStop,
Interrupt,
SessionEnd,
SessionStart,
error_event,
AgentResponse,
Interrupted,
SessionEnded,
SessionReady,
STTFinal,
STTPartial,
TurnEnded,
TurnStarted,
)
from voice_assistant.audio_gateway.app.session import SessionState
# logger configuration
logger = logging.getLogger(__name__)
app = FastAPI(
title="Voice Assistant Audio Gateway",
version=PROTOCOL_VERSION,
)
app_config = load_config()
provider_catalog = load_provider_catalog()
MAX_AUDIO_CHUNK_BYTES = 64 * 1024 # 64 KB
def create_turn_manager() -> TurnManager:
"""Create the turn manager used by the audio gateway."""
return TurnManager()
async def send_json(
websocket: WebSocket,
payload: BaseModel | dict,
) -> None:
"""
Serialize and send one protocol event through the WebSocket.
Typed Pydantic protocol models are preferred. Dictionary support
remains temporarily available for error events until ErrorEvent
is migrated to the typed protocol.
"""
if isinstance(payload, BaseModel):
data = payload.model_dump()
else:
data = payload
await websocket.send_text(
json.dumps(
data,
separators=(",", ":"),
)
)
async def forward_stt_events(
websocket: WebSocket,
stt: STTAdapter,
pipeline: VoicePipeline,
session_id: str,
turn_id: int,
) -> None:
async for event in stt.events():
if not pipeline.is_active_turn(turn_id):
logger.debug(
"Discarding stale STT event. session_id=%s turn_id=%s",
session_id,
turn_id,
)
break
if event.type == "partial":
pipeline.on_partial(
turn_id,
event.text,
)
await send_json(
websocket,
STTPartial(
session_id=session_id,
turn_id=turn_id,
text=event.text,
),
)
elif event.type == "final":
pipeline.on_final(
turn_id,
event.text,
)
await send_json(
websocket,
STTFinal(
session_id=session_id,
turn_id=turn_id,
text=event.text,
),
)
result = await pipeline.process_transcript(
session_id=session_id,
turn_id=turn_id,
text=event.text,
)
await send_json(
websocket,
AgentResponse(
session_id=session_id,
turn_id=turn_id,
text=result.response_text,
),
)
async for audio_chunk in pipeline.synthesize_response(
result.response_text,
):
await websocket.send_bytes(audio_chunk)
pipeline.on_tts_complete(turn_id)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/ready")
async def ready() -> dict[str, str]:
return {"status": "ready"}
@app.websocket("/ws/audio")
async def audio_socket(websocket: WebSocket) -> None:
await websocket.accept()
session: SessionState | None = None
pending_audio_chunk: AudioChunk | None = None
stt: STTAdapter | None = None
stt_events_task: asyncio.Task | None = None
turn_manager = create_turn_manager()
agent: AgentAdapter | None = None
tts: TTSAdapter | None = None
pipeline: VoicePipeline | None = None
try:
while True:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
break
# ---------------------------------------------------------
# Binary WebSocket frame -> PCM
# ---------------------------------------------------------
if message.get("bytes") is not None:
if session is None:
await send_json(
websocket,
error_event(
"SESSION_NOT_FOUND",
"session.start is required before audio.",
),
)
continue
if pending_audio_chunk is None:
await send_json(
websocket,
error_event(
"INVALID_AUDIO_FRAME",
"Binary PCM frame must be preceded by audio.chunk.",
session_id=session.session_id,
turn_id=session.active_turn_id,
),
)
continue
audio_payload = message["bytes"]
if len(audio_payload) > MAX_AUDIO_CHUNK_BYTES:
await send_json(
websocket,
error_event(
"MESSAGE_TOO_LARGE",
(
"Audio chunk exceeds maximum size of "
f"{MAX_AUDIO_CHUNK_BYTES} bytes."
),
session_id=session.session_id,
turn_id=pending_audio_chunk.turn_id,
details={
"max_bytes": MAX_AUDIO_CHUNK_BYTES,
"received_bytes": len(audio_payload),
},
),
)
pending_audio_chunk = None
continue
try:
session.accept_audio_chunk(
pending_audio_chunk.turn_id,
pending_audio_chunk.sequence_number,
len(audio_payload),
)
except (RuntimeError, ValueError) as exc:
await send_json(
websocket,
error_event(
"AUDIO_SEQUENCE_ERROR",
str(exc),
session_id=session.session_id,
turn_id=pending_audio_chunk.turn_id,
details={
"expected_sequence_number":
session.expected_audio_sequence,
},
),
)
pending_audio_chunk = None
continue
# echo do PCM para validar o transporte.
if stt is None:
await send_json(
websocket,
error_event(
"STT_NOT_STARTED",
"audio.start is required before audio.",
session_id=session.session_id,
turn_id=session.active_turn_id,
),
)
pending_audio_chunk = None
continue
try:
await stt.send_audio(audio_payload)
except (RuntimeError, ValueError) as exc:
await send_json(
websocket,
error_event(
"STT_AUDIO_ERROR",
str(exc),
session_id=session.session_id,
turn_id=pending_audio_chunk.turn_id,
retryable=True,
),
)
pending_audio_chunk = None
continue
pending_audio_chunk = None
continue
# ---------------------------------------------------------
# Text WebSocket frame -> JSON control event
# ---------------------------------------------------------
text = message.get("text")
if text is None:
await send_json(
websocket,
error_event(
"INVALID_MESSAGE",
"Unsupported WebSocket frame.",
),
)
continue
try:
payload = json.loads(text)
except json.JSONDecodeError:
await send_json(
websocket,
error_event(
"INVALID_MESSAGE",
"Message must contain valid JSON.",
),
)
continue
event_type = payload.get("type")
if not isinstance(event_type, str):
await send_json(
websocket,
error_event(
"INVALID_MESSAGE",
"Field 'type' is required.",
),
)
continue
# ---------------------------------------------------------
# session.start
# ---------------------------------------------------------
if event_type == "session.start":
if session is not None:
await send_json(
websocket,
error_event(
"SESSION_ALREADY_STARTED",
"This WebSocket already has an active session.",
session_id=session.session_id,
),
)
continue
try:
request = SessionStart.model_validate(payload)
except ValidationError as exc:
await send_json(
websocket,
error_event(
"INVALID_MESSAGE",
"Invalid session.start payload.",
details={"errors": exc.errors()},
),
)
continue
audio = request.audio
if (
audio.encoding != AUDIO_ENCODING
or audio.sample_rate != AUDIO_SAMPLE_RATE
or audio.channels != AUDIO_CHANNELS
):
await send_json(
websocket,
error_event(
"INVALID_AUDIO_FORMAT",
(
"Only PCM S16LE, mono, 16 kHz "
"is supported in the MVP."
),
details={
"expected": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
}
},
),
)
continue
session = SessionState(
session_id=request.session_id or SessionState().session_id
)
agent = create_agent_adapter(
config=app_config,
catalog=provider_catalog,
)
tts = create_tts_adapter(
config=app_config,
catalog=provider_catalog,
)
pipeline = VoicePipeline(
session=session,
turn_manager=turn_manager,
agent=agent,
tts=tts,
)
await send_json(
websocket,
SessionReady(
protocol_version=PROTOCOL_VERSION,
session_id=session.session_id,
audio=CANONICAL_AUDIO_FORMAT,
),
)
continue
# ---------------------------------------------------------
# Todos os eventos abaixo exigem sessão ativa
# ---------------------------------------------------------
if session.closed:
await send_json(
websocket,
error_event(
"SESSION_ALREADY_CLOSED",
"This session is already closed.",
session_id=session.session_id,
),
)
continue
# ---------------------------------------------------------
# audio.start
# ---------------------------------------------------------
if event_type == "audio.start":
turn_id: int | None = None
try:
if pipeline is None:
raise RuntimeError(
"Voice pipeline is not initialized."
)
turn = pipeline.start_turn()
turn_id = turn.turn_id
# Each turn receives a fresh STT provider instance.
stt = create_stt_adapter(
config=app_config,
catalog=provider_catalog,
)
await stt.start_session()
stt_events_task = asyncio.create_task(
forward_stt_events(
websocket=websocket,
stt=stt,
pipeline=pipeline,
session_id=session.session_id,
turn_id=turn_id,
)
)
pipeline.register_turn_task(stt_events_task)
except (ValidationError, RuntimeError, ValueError) as exc:
# Preserve the original startup failure.
startup_error = exc
cleanup_complete = True
# Cancel and await any task created during startup.
if stt_events_task is not None:
task = stt_events_task
try:
if (
pipeline is not None
and pipeline.turn_task is task
):
await pipeline.cancel_turn_task()
else:
if not task.done():
task.cancel()
try:
await asyncio.wait_for(
asyncio.shield(task),
timeout=5.0,
)
except asyncio.CancelledError:
if not task.cancelled():
raise
except Exception:
cleanup_complete = False
logger.exception(
"Failed to cancel task during "
"audio.start rollback."
)
if task.done():
stt_events_task = None
else:
cleanup_complete = False
# Abort the partially initialized STT provider.
if stt is not None:
try:
await stt.abort_session()
except Exception:
cleanup_complete = False
logger.exception(
"Failed to abort STT during "
"audio.start rollback."
)
# Only release semantic state after execution
# has stopped and provider cleanup succeeded.
if cleanup_complete:
if turn_id is not None and pipeline is not None:
pipeline.interrupt_turn(turn_id)
stt = None
pending_audio_chunk = None
await send_json(
websocket,
error_event(
"STT_START_FAILED",
str(startup_error),
session_id=session.session_id,
turn_id=turn_id,
retryable=cleanup_complete,
),
)
continue
# Startup completed successfully.
await send_json(
websocket,
TurnStarted(
session_id=session.session_id,
turn_id=turn_id,
),
)
continue
# ---------------------------------------------------------
# audio.chunk
# ---------------------------------------------------------
if event_type == "audio.chunk":
try:
request = AudioChunk.model_validate(payload)
if session.active_turn_id != request.turn_id:
raise ValueError(
f"Unexpected turn_id={request.turn_id}; "
f"active_turn_id={session.active_turn_id}"
)
if pending_audio_chunk is not None:
raise ValueError(
"Previous audio chunk is still pending PCM data."
)
pending_audio_chunk = request
except (ValidationError, ValueError) as exc:
await send_json(
websocket,
error_event(
"AUDIO_SEQUENCE_ERROR",
str(exc),
session_id=session.session_id,
turn_id=payload.get("turn_id"),
details={
"expected_sequence_number":
session.expected_audio_sequence,
},
),
)
continue
continue
# ---------------------------------------------------------
# audio.stop
# ---------------------------------------------------------
if event_type == "audio.stop":
try:
request = AudioStop.model_validate(payload)
if stt is None:
raise RuntimeError(
"STT session has not been started."
)
if pending_audio_chunk is not None:
raise ValueError(
"audio.stop received while an audio chunk is pending."
)
if agent is None:
raise RuntimeError(
"Agent adapter is not initialized."
)
if tts is None:
raise RuntimeError(
"TTS adapter is not initialized."
)
if pipeline is None:
raise RuntimeError(
"Voice pipeline is not initialized."
)
# Finish the STT session normally.
await stt.stop_session()
await pipeline.wait_turn_task()
stt_events_task = None
try:
pipeline.complete_turn(request.turn_id)
except (RuntimeError, ValueError) as exc:
raise RuntimeError(
"Turn pipeline did not complete successfully."
) from exc
except (ValidationError, RuntimeError, ValueError) as exc:
await send_json(
websocket,
error_event(
"INVALID_TURN",
str(exc),
session_id=session.session_id,
turn_id=payload.get("turn_id"),
),
)
continue
stt = None
pending_audio_chunk = None
await send_json(
websocket,
TurnEnded(
session_id=session.session_id,
turn_id=request.turn_id,
),
)
continue
# ---------------------------------------------------------
# interrupt
# ---------------------------------------------------------
if event_type == "interrupt":
try:
request = Interrupt.model_validate(payload)
except ValidationError as exc:
await send_json(
websocket,
error_event(
"INVALID_MESSAGE",
"Invalid interrupt payload.",
session_id=session.session_id,
details={"errors": exc.errors()},
),
)
continue
if pipeline is None:
await send_json(
websocket,
error_event(
"INVALID_TURN",
"Voice pipeline is not initialized.",
session_id=session.session_id,
turn_id=request.turn_id,
),
)
continue
# Only cancel resources belonging to the requested active turn.
if pipeline.is_active_turn(request.turn_id):
try:
# Cancel and await STT/Agent/TTS execution before
# confirming the interruption to the client.
await pipeline.cancel_turn_task()
# Abort transcription without requesting a final result.
if stt is not None:
await stt.abort_session()
# Release semantic turn state after execution stops.
pipeline.interrupt_if_active(request.turn_id)
# Clear references to the interrupted execution.
stt_events_task = None
pending_audio_chunk = None
except Exception as exc:
await send_json(
websocket,
error_event(
"TURN_CANCEL_FAILED",
str(exc),
session_id=session.session_id,
turn_id=request.turn_id,
),
)
continue
else:
# Preserve the existing tolerant behavior for unknown turns.
pipeline.interrupt_if_active(request.turn_id)
await send_json(
websocket,
Interrupted(
session_id=session.session_id,
turn_id=request.turn_id,
),
)
continue
# ---------------------------------------------------------
# session.end
# ---------------------------------------------------------
if event_type == "session.end":
try:
SessionEnd.model_validate(payload)
if pipeline is None:
raise RuntimeError(
"Voice pipeline is not initialized."
)
# Cancel and await any running STT/Agent/TTS task.
await pipeline.cancel_turn_task()
# Abort STT without waiting for a final transcript.
if stt is not None:
await stt.abort_session()
# Close semantic turn and session state.
pipeline.close_session()
# Release Gateway-owned references.
stt_events_task = None
stt = None
pending_audio_chunk = None
except (ValidationError, RuntimeError, ValueError) as exc:
await send_json(
websocket,
error_event(
"SESSION_END_FAILED",
str(exc),
session_id=session.session_id,
turn_id=session.active_turn_id,
retryable=True,
),
)
continue
await send_json(
websocket,
SessionEnded(
session_id=session.session_id,
),
)
break
# ---------------------------------------------------------
# Evento desconhecido
# ---------------------------------------------------------
await send_json(
websocket,
error_event(
"UNSUPPORTED_EVENT",
f"Unsupported event type: {event_type!r}.",
session_id=session.session_id,
turn_id=session.active_turn_id,
),
)
except WebSocketDisconnect:
# The finally block owns connection cleanup.
pass
finally:
# Cancel and await the task owned by VoicePipeline.
if pipeline is not None:
try:
await pipeline.cancel_turn_task()
except Exception:
logger.exception(
"Failed to cancel voice turn task. session_id=%s",
session.session_id if session else None,
)
# A task may have been created before registration failed.
# Handle it independently if the pipeline does not own it.
if stt_events_task is not None:
task = stt_events_task
if (
pipeline is None
or pipeline.turn_task is not task
):
try:
if not task.done():
task.cancel()
try:
await asyncio.wait_for(
asyncio.shield(task),
timeout=5.0,
)
except asyncio.CancelledError:
if not task.cancelled():
raise
except TimeoutError:
logger.error(
"Unregistered STT task cleanup timed out. "
"session_id=%s",
session.session_id if session else None,
)
except Exception:
logger.exception(
"Failed to clean up unregistered STT task. "
"session_id=%s",
session.session_id if session else None,
)
if task.done():
stt_events_task = None
# Abort STT without waiting for a final transcript.
if stt is not None:
try:
await stt.abort_session()
except Exception:
logger.exception(
"Failed to abort STT session. session_id=%s",
session.session_id if session else None,
)
# Release session and semantic turn state.
try:
if pipeline is not None:
pipeline.close_session()
elif session is not None:
session.close()
except Exception:
logger.exception(
"Failed to close voice session. session_id=%s",
session.session_id if session else None,
)