AgentVoice
+Realtime Voice Console
+Uma conversa, naturalmente.
+Fale pelo microfone e receba respostas + geradas por inteligência artificial.
+Conecte para começar
+diff --git a/.gitignore b/.gitignore
index 8ffc57f..8aa2ae3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ venv/
.ruff_cache/
.coverage
htmlcov/
+config/oci-e2e.yaml
# IDE and operating system
.vscode/
diff --git a/README.md b/README.md
index 8084cad..b1385d6 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ A modular voice pipeline for streaming audio input, speech transcription, agent
- [Testing](#testing)
- [Project structure](#project-structure)
- [Known limitations](#known-limitations)
+- [Architecture Backbone & Abstraction Boundaries](#architecture-backbone--abstraction-boundaries)
## Architecture
@@ -38,6 +39,212 @@ flowchart TB
The **Audio Gateway** handles transport and protocol messages. **VoicePipeline** coordinates processing and task ownership. **SessionState** tracks session and audio sequence; **TurnManager** enforces turn transitions. A new STT adapter is created for each turn, while Agent and TTS adapters are created per session.
+## Architecture Backbone & Abstraction Boundaries
+
+AgentVoice follows a **provider-independent architecture** built around explicit contracts, lifecycle ownership, and asynchronous streaming.
+
+The architecture separates five concerns:
+
+- **Transport:** WebSocket communication and protocol serialization.
+- **Orchestration:** Coordination of STT, Agent, and TTS execution.
+- **Lifecycle:** Session state, turn transitions, interruption, and task ownership.
+- **Provider contracts:** Abstract interfaces defining component behavior.
+- **Provider implementations:** Concrete integrations with NVIDIA, OCI Generative AI, and VoxCPM.
+
+### Architectural Backbone
+
+```mermaid
+flowchart TB
+ Client["Web Client / Audio Client"]
+ Gateway["Audio Gateway
FastAPI · WebSocket"]
+ Pipeline["VoicePipeline
Orchestration & Streaming"]
+ Session["SessionState
Session Lifecycle"]
+ Turns["TurnManager
Semantic Turn Lifecycle"]
+
+ subgraph Contracts["Provider Abstraction Layer"]
+ direction LR
+ STTA["STTAdapter
Abstract Contract"]
+ AgentA["AgentAdapter
Abstract Contract"]
+ TTSA["TTSAdapter
Abstract Contract"]
+ end
+
+ subgraph Implementations["Provider Implementation Layer"]
+ direction LR
+ Nvidia["NvidiaSTTAdapter"]
+ OCI["OCIAgentAdapter"]
+ Mock["MockAgentAdapter"]
+ Vox["VoxCPMTTSAdapter"]
+ end
+
+ subgraph External["External AI Services"]
+ ASR["NVIDIA ASR"]
+ LLM["OCI Generative AI"]
+ TTS["VoxCPM"]
+ end
+
+ Client <-->|"JSON Events + Binary PCM"| Gateway
+ Gateway --> Pipeline
+
+ Pipeline --- Session
+ Pipeline --- Turns
+
+ Pipeline --> STTA
+ Pipeline --> AgentA
+ Pipeline --> TTSA
+
+ STTA -.->|implements| Nvidia
+ AgentA -.->|implements| OCI
+ AgentA -.->|implements| Mock
+ TTSA -.->|implements| Vox
+
+ Nvidia --> ASR
+ OCI --> LLM
+ Vox --> TTS
+```
+
+The `VoicePipeline` depends on provider contracts rather than concrete implementations. Provider selection is performed through configuration and dynamic factories.
+
+This allows implementations to evolve without requiring the Audio Gateway to understand provider-specific APIs.
+
+### Provider Contracts
+
+| Abstraction | Contract | Implementations | Responsibility |
+|---|---|---|---|
+| Speech-to-text | `STTAdapter` | `NvidiaSTTAdapter`, `MockSTTAdapter` | Audio ingestion and transcript events |
+| Agent | `AgentAdapter` | `OCIAgentAdapter`, `MockAgentAdapter` | Text processing and conversational responses |
+| Text-to-speech | `TTSAdapter` | `VoxCPMTTSAdapter`, `MockTTSAdapter` | Speech synthesis and audio streaming |
+
+The `AgentAdapter` defines the common text-processing interface. OCI-specific incremental generation is currently exposed through an additional `stream()` method, with streaming capability detected by the orchestration layer.
+
+### Runtime Composition
+
+Provider implementations are selected through environment configuration and resolved by the provider factory.
+
+```mermaid
+flowchart LR
+ Config["Environment YAML"]
+ Catalog["Provider Catalog"]
+ Factory["Provider Factories"]
+ Contracts["Adapter Contracts"]
+ Runtime["VoicePipeline"]
+
+ Config --> Factory
+ Catalog --> Factory
+ Factory -->|Validate implementation| Contracts
+ Factory -->|Instantiate providers| Runtime
+```
+
+The primary configuration components are:
+
+| Component | Location |
+|---|---|
+| Environment selection | `config/loader.py` |
+| Typed configuration | `config/models.py` |
+| Provider catalog | `config/providers.yaml` |
+| Dynamic provider loading | `providers.py` |
+| Session lifecycle | `audio_gateway/app/session.py` |
+| Semantic turn lifecycle | `turn_manager.py` |
+| Execution orchestration | `voice_pipeline.py` |
+
+### Streaming Execution Backbone
+
+The streaming pipeline allows OCI Generative AI to continue producing text while VoxCPM synthesizes previously completed text segments.
+
+```mermaid
+flowchart TB
+ Input["Microphone / PCM Input"]
+ Gateway["Audio Gateway"]
+ STT["NVIDIA STT"]
+ Transcript["Final Transcript"]
+
+ subgraph Pipeline["VoicePipeline · Concurrent Execution"]
+ direction TB
+ Agent["OCIAgentAdapter.stream()"]
+ Bridge["OCI SSE Async Bridge"]
+ Chunker["TextChunker"]
+ Queue["Bounded Segment Queue"]
+ Synth["VoxCPM TTS Streaming"]
+
+ Agent --> Bridge
+ Bridge --> Chunker
+ Chunker --> Queue
+ Queue --> Synth
+ end
+
+ OCI["OCI Generative AI"]
+ Vox["VoxCPM Service"]
+ Output["WebSocket PCM Output"]
+
+ Input --> Gateway
+ Gateway --> STT
+ STT --> Transcript
+ Transcript --> Agent
+
+ Agent <--> OCI
+ Synth <--> Vox
+ Synth --> Gateway
+ Gateway --> Output
+```
+
+The pipeline uses bounded queues and asynchronous task coordination to support incremental generation, ordered synthesis, and cooperative cancellation.
+
+### Lifecycle Ownership
+
+AgentVoice separates semantic state from asynchronous execution.
+
+```mermaid
+flowchart LR
+ Listening["LISTENING"]
+ Processing["PROCESSING"]
+ Speaking["SPEAKING"]
+ Completed["COMPLETED"]
+ Interrupted["INTERRUPTED"]
+
+ Listening -->|"STT final"| Processing
+ Processing -->|"First TTS segment"| Speaking
+ Speaking -->|"Synthesis complete"| Completed
+
+ Listening --> Interrupted
+ Processing --> Interrupted
+ Speaking --> Interrupted
+```
+
+**Ownership responsibilities:**
+
+| Component | Owns |
+|---|---|
+| Audio Gateway | WebSocket connection, protocol messages, provider cleanup coordination |
+| SessionState | Session lifecycle and audio sequence validation |
+| TurnManager | Semantic turn state transitions |
+| VoicePipeline | Turn execution task and Agent/TTS orchestration |
+| OCI streaming bridge | SSE reader, event queue, and stream closure |
+| Provider adapters | Provider-specific requests, connections, and data formats |
+
+Cancellation is cooperative. The system requests cancellation and waits for task termination, but termination of blocking external operations depends on the underlying provider and transport behavior.
+
+### Architectural Extension Points
+
+The abstraction layer allows additional implementations to be introduced through the provider catalog.
+
+For example, a future LiteLLM integration could implement the Agent contract without requiring a redesign of the Gateway or the semantic turn lifecycle.
+
+```mermaid
+flowchart TB
+ Pipeline["VoicePipeline"]
+ Contract["AgentAdapter"]
+
+ OCI["OCIAgentAdapter
Current"]
+ Mock["MockAgentAdapter
Testing"]
+ Lite["LiteLLMAgentAdapter
Future"]
+
+ Pipeline --> Contract
+ Contract -.-> OCI
+ Contract -.-> Mock
+ Contract -.-> Lite
+```
+
+**Design principle:** transport, orchestration, lifecycle, and provider-specific integrations remain separate concerns. Provider independence is established through contracts and factories, while streaming and cancellation behavior are validated at their respective integration boundaries.
+
## Quickstart
### Requirements
@@ -197,3 +404,5 @@ tools/ Integration and smoke scripts
---
**Status:** V1 development baseline · Python 3.12+ · WebSocket protocol 1.0
+
+
diff --git a/config/providers.yaml b/config/providers.yaml
index c654c92..cea6747 100644
--- a/config/providers.yaml
+++ b/config/providers.yaml
@@ -27,3 +27,9 @@ providers:
realtime: false
streaming: false
+ oci:
+ name: "OCI Generative AI"
+ implementation: "voice_assistant.agent.oci:OCIAgentAdapter"
+ capabilities:
+ realtime: true
+ streaming: true
\ No newline at end of file
diff --git a/src/voice_assistant/agent/oci.py b/src/voice_assistant/agent/oci.py
index d586777..b68305c 100644
--- a/src/voice_assistant/agent/oci.py
+++ b/src/voice_assistant/agent/oci.py
@@ -10,7 +10,6 @@ from __future__ import annotations
import json
from typing import Any
-import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import aclosing
@@ -19,6 +18,7 @@ import oci
from voice_assistant.agent.adapter import AgentAdapter
from voice_assistant.agent.oci_stream import stream_sse_events
+from voice_assistant.agent.oci_connection import open_oci_stream
def extract_text_delta(raw_data: str) -> str | None:
@@ -205,9 +205,8 @@ class OCIAgentAdapter(AgentAdapter):
self.model_id,
)
- response = await asyncio.to_thread(
- self.client.chat,
- details,
+ response = await open_oci_stream(
+ lambda: self.client.chat(details)
)
if response.status != 200:
@@ -256,4 +255,3 @@ class OCIAgentAdapter(AgentAdapter):
)
return response_text
-
diff --git a/src/voice_assistant/agent/oci_connection.py b/src/voice_assistant/agent/oci_connection.py
new file mode 100644
index 0000000..35514d7
--- /dev/null
+++ b/src/voice_assistant/agent/oci_connection.py
@@ -0,0 +1,107 @@
+"""
+Lifecycle management for blocking OCI HTTP connection setup.
+
+Ensures that responses arriving after coroutine cancellation
+are closed instead of being abandoned.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import threading
+from typing import Any, Callable
+
+
+logger = logging.getLogger(__name__)
+
+
+def _close_response(response: Any) -> None:
+ """Close the SSE response returned by the OCI SDK."""
+ stream = getattr(response, "data", None)
+
+ if stream is not None:
+ close = getattr(stream, "close", None)
+
+ if callable(close):
+ close()
+
+
+async def open_oci_stream(
+ open_connection: Callable[[], Any],
+) -> Any:
+ """
+ Open a blocking OCI connection with explicit response ownership.
+
+ The worker retains ownership until the async caller claims
+ the response. Cancellation closes any unclaimed response.
+
+ A blocking SDK call may continue after cancellation.
+ """
+ ownership_lock = threading.Lock()
+ abandoned = False
+ claimed = False
+ response = None
+
+ def worker() -> Any:
+ nonlocal response
+
+ opened = open_connection()
+
+ with ownership_lock:
+ if abandoned:
+ should_close = True
+ else:
+ response = opened
+ should_close = False
+
+ if should_close:
+ try:
+ _close_response(opened)
+ except Exception:
+ logger.exception(
+ "Failed to close abandoned OCI response."
+ )
+
+ return opened
+
+ task = asyncio.create_task(
+ asyncio.to_thread(worker)
+ )
+
+ try:
+ opened = await asyncio.shield(task)
+
+ with ownership_lock:
+ if abandoned:
+ raise RuntimeError(
+ "OCI connection was abandoned."
+ )
+
+ claimed = True
+
+ return opened
+
+ except asyncio.CancelledError:
+ with ownership_lock:
+ abandoned = True
+
+ if not claimed:
+ unclaimed = response
+ response = None
+ else:
+ unclaimed = None
+
+ if unclaimed is not None:
+ try:
+ await asyncio.to_thread(
+ _close_response,
+ unclaimed,
+ )
+ except Exception:
+ logger.exception(
+ "Failed to close unclaimed OCI response."
+ )
+
+ raise
+
diff --git a/src/voice_assistant/agent/text_chunker.py b/src/voice_assistant/agent/text_chunker.py
new file mode 100644
index 0000000..26b7d33
--- /dev/null
+++ b/src/voice_assistant/agent/text_chunker.py
@@ -0,0 +1,85 @@
+"""
+Incremental text segmentation for streaming voice synthesis.
+
+Converts LLM text deltas into speech-ready segments while
+preserving word boundaries and punctuation.
+"""
+
+from __future__ import annotations
+
+
+class TextChunker:
+ """
+ Accumulate text deltas and emit segments suitable for TTS.
+
+ Segments are emitted on sentence-ending punctuation or
+ at a configurable maximum length.
+ """
+
+ def __init__(
+ self,
+ min_chars: int = 20,
+ max_chars: int = 160,
+ ) -> None:
+ if min_chars < 1:
+ raise ValueError("min_chars must be positive.")
+
+ if max_chars < min_chars:
+ raise ValueError(
+ "max_chars must be greater than or equal to min_chars."
+ )
+
+ self.min_chars = min_chars
+ self.max_chars = max_chars
+ self._buffer = ""
+
+ def feed(self, delta: str) -> list[str]:
+ """Accept a text delta and return completed segments."""
+ self._buffer += delta
+ segments: list[str] = []
+
+ while len(self._buffer) >= self.min_chars:
+ boundary = self._find_boundary()
+
+ if boundary is None:
+ break
+
+ segment = self._buffer[:boundary].strip()
+ self._buffer = self._buffer[boundary:]
+
+ if segment:
+ segments.append(segment)
+
+ return segments
+
+ def _find_boundary(self) -> int | None:
+ """Find a sentence boundary or safe length boundary."""
+ for index, char in enumerate(self._buffer):
+ if char in ".!?":
+ boundary = index + 1
+
+ if boundary >= self.min_chars:
+ return boundary
+
+ if len(self._buffer) < self.max_chars:
+ return None
+
+ # Prefer breaking at a word boundary.
+ boundary = self._buffer.rfind(
+ " ",
+ 0,
+ self.max_chars + 1,
+ )
+
+ if boundary >= self.min_chars:
+ return boundary + 1
+
+ return self.max_chars
+
+ def flush(self) -> str | None:
+ """Return any remaining text after generation completes."""
+ remaining = self._buffer.strip()
+ self._buffer = ""
+
+ return remaining or None
+
diff --git a/src/voice_assistant/audio_gateway/app/main.py b/src/voice_assistant/audio_gateway/app/main.py
index 98439e6..ecfa135 100644
--- a/src/voice_assistant/audio_gateway/app/main.py
+++ b/src/voice_assistant/audio_gateway/app/main.py
@@ -34,8 +34,13 @@ Architecture:
import json
import logging
+from pathlib import Path
+
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ValidationError
+from contextlib import aclosing
import asyncio
@@ -83,8 +88,16 @@ from voice_assistant.audio_gateway.app.protocol import (
STTPartial,
TurnEnded,
TurnStarted,
+ AgentDelta,
)
+from voice_assistant.voice_pipeline import (
+ PipelineTextDelta,
+ PipelineAgentResponse,
+ PipelineAudioChunk,
+)
+
+
from voice_assistant.audio_gateway.app.session import SessionState
# logger configuration
@@ -100,6 +113,18 @@ provider_catalog = load_provider_catalog()
MAX_AUDIO_CHUNK_BYTES = 64 * 1024 # 64 KB
+WEB_DIR = Path(__file__).resolve().parents[1] / "web"
+
+app.mount(
+ "/web",
+ StaticFiles(directory=WEB_DIR),
+ name="web",
+)
+
+
+@app.get("/ui", include_in_schema=False)
+async def web_client() -> FileResponse:
+ return FileResponse(WEB_DIR / "index.html")
def create_turn_manager() -> TurnManager:
@@ -176,27 +201,63 @@ async def forward_stt_events(
),
)
- result = await pipeline.process_transcript(
- session_id=session_id,
- turn_id=turn_id,
- text=event.text,
- )
+ if callable(getattr(pipeline.agent, "stream", None)):
+ async with aclosing(
+ pipeline.stream_turn_response(
+ session_id=session_id,
+ turn_id=turn_id,
+ text=event.text,
+ )
+ ) as response_events:
+ async for response_event in response_events:
+ if not pipeline.is_active_turn(turn_id):
+ break
- await send_json(
- websocket,
- AgentResponse(
+ if isinstance(response_event, PipelineTextDelta):
+ await send_json(
+ websocket,
+ AgentDelta(
+ session_id=session_id,
+ turn_id=turn_id,
+ text=response_event.text,
+ ),
+ )
+
+ elif isinstance(response_event, PipelineAgentResponse):
+ await send_json(
+ websocket,
+ AgentResponse(
+ session_id=session_id,
+ turn_id=turn_id,
+ text=response_event.text,
+ ),
+ )
+
+ elif isinstance(response_event, PipelineAudioChunk):
+ await websocket.send_bytes(response_event.audio)
+
+ else:
+ result = await pipeline.process_transcript(
session_id=session_id,
turn_id=turn_id,
- text=result.response_text,
- ),
- )
+ text=event.text,
+ )
- async for audio_chunk in pipeline.synthesize_response(
- result.response_text,
- ):
- await websocket.send_bytes(audio_chunk)
+ await send_json(
+ websocket,
+ AgentResponse(
+ session_id=session_id,
+ turn_id=turn_id,
+ text=result.response_text,
+ ),
+ )
- pipeline.on_tts_complete(turn_id)
+ async for audio_chunk in pipeline.synthesize_response(
+ result.response_text,
+ ):
+ await websocket.send_bytes(audio_chunk)
+
+ pipeline.on_tts_complete(turn_id)
diff --git a/src/voice_assistant/audio_gateway/app/protocol.py b/src/voice_assistant/audio_gateway/app/protocol.py
index bb3adda..68effc8 100644
--- a/src/voice_assistant/audio_gateway/app/protocol.py
+++ b/src/voice_assistant/audio_gateway/app/protocol.py
@@ -109,6 +109,20 @@ class STTFinal(BaseModel):
text: str
+class AgentDelta(BaseModel):
+ """
+ Incremental text generated by the configured Agent.
+
+ Each event belongs to one session and turn.
+ Text fragments must be concatenated in arrival order.
+ """
+
+ type: Literal["agent.delta"] = "agent.delta"
+ session_id: str
+ turn_id: int
+ text: str
+
+
class AgentResponse(BaseModel):
"""Text response produced by the configured Agent."""
diff --git a/src/voice_assistant/audio_gateway/web/app.js b/src/voice_assistant/audio_gateway/web/app.js
new file mode 100644
index 0000000..35c7852
--- /dev/null
+++ b/src/voice_assistant/audio_gateway/web/app.js
@@ -0,0 +1,387 @@
+
+const $ = (id) => document.getElementById(id);
+
+const status = $("status");
+const talkButton = $("talkButton");
+const talkLabel = $("talkLabel");
+const connectButton = $("connectButton");
+const interruptButton = $("interruptButton");
+const messages = $("messages");
+
+let ws = null;
+let sessionId = null;
+let turnId = null;
+let sequence = 0;
+let phase = "disconnected";
+
+let microphone = null;
+let inputContext = null;
+let inputSource = null;
+let inputNode = null;
+let silentGain = null;
+
+let playbackContext = null;
+let playbackTime = 0;
+const scheduledAudio = new Set();
+
+let userMessage = null;
+let agentMessage = null;
+let pendingFinal = null;
+
+function setPhase(next) {
+ phase = next;
+
+ status.textContent = {
+ disconnected: "Desconectado",
+ connecting: "Conectando",
+ ready: "Conectado",
+ starting: "Iniciando turno",
+ recording: "Gravando",
+ processing: "Processando",
+ speaking: "Respondendo"
+ }[next] || next;
+
+ status.classList.toggle(
+ "connected",
+ next !== "disconnected" && next !== "connecting"
+ );
+
+ connectButton.disabled = next !== "disconnected";
+ talkButton.disabled = next !== "ready" && next !== "recording";
+ interruptButton.disabled = turnId === null;
+
+ talkButton.classList.toggle("recording", next === "recording");
+ talkLabel.textContent = next === "recording"
+ ? "Clique para terminar de falar"
+ : next === "ready"
+ ? "Clique para falar"
+ : status.textContent;
+}
+
+function addMessage(role, text = "") {
+ $("emptyState")?.remove();
+
+ const element = document.createElement("div");
+ element.className = `message ${role}`;
+
+ const label = document.createElement("small");
+ label.textContent = role === "user" ? "Você" : "AgentVoice";
+
+ const content = document.createElement("span");
+ content.textContent = text;
+
+ element.append(label, content);
+ messages.appendChild(element);
+ messages.scrollTop = messages.scrollHeight;
+
+ return content;
+}
+
+function sendJson(payload) {
+ if (ws?.readyState !== WebSocket.OPEN) {
+ throw new Error("WebSocket não está conectado.");
+ }
+ ws.send(JSON.stringify(payload));
+}
+
+async function ensurePlayback() {
+ if (!playbackContext) {
+ playbackContext = new AudioContext();
+ }
+ await playbackContext.resume();
+}
+
+async function playPCM(bytes) {
+ await ensurePlayback();
+
+ const rate = Number($("outputRate").value);
+ const sampleCount = Math.floor(bytes.byteLength / 2);
+ if (!sampleCount) return;
+
+ const view = new DataView(bytes);
+ const samples = new Float32Array(sampleCount);
+
+ for (let i = 0; i < sampleCount; i++) {
+ samples[i] = view.getInt16(i * 2, true) / 32768;
+ }
+
+ const audio = playbackContext.createBuffer(
+ 1,
+ sampleCount,
+ rate
+ );
+ audio.copyToChannel(samples, 0);
+
+ const source = playbackContext.createBufferSource();
+ source.buffer = audio;
+ source.connect(playbackContext.destination);
+
+ const startAt = Math.max(
+ playbackContext.currentTime + 0.025,
+ playbackTime
+ );
+
+ source.start(startAt);
+ playbackTime = startAt + audio.duration;
+
+ scheduledAudio.add(source);
+ source.onended = () => scheduledAudio.delete(source);
+}
+
+function stopPlayback() {
+ for (const source of scheduledAudio) {
+ try { source.stop(); } catch {}
+ }
+ scheduledAudio.clear();
+ playbackTime = 0;
+}
+
+async function stopMicrophone() {
+ if (inputNode) {
+ inputNode.port.onmessage = null;
+ inputNode.disconnect();
+ inputNode = null;
+ }
+
+ inputSource?.disconnect();
+ silentGain?.disconnect();
+
+ inputSource = null;
+ silentGain = null;
+
+ microphone?.getTracks().forEach((track) => track.stop());
+ microphone = null;
+
+ if (inputContext) {
+ const context = inputContext;
+ inputContext = null;
+ await context.close();
+ }
+}
+
+async function startMicrophone() {
+ microphone = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ channelCount: 1,
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true
+ }
+ });
+
+ inputContext = new AudioContext();
+ await inputContext.audioWorklet.addModule("/web/pcm-worklet.js");
+
+ inputSource = inputContext.createMediaStreamSource(microphone);
+ inputNode = new AudioWorkletNode(inputContext, "pcm-input");
+
+ inputNode.port.onmessage = ({ data }) => {
+ if (
+ data.type !== "pcm" ||
+ phase !== "recording" ||
+ turnId === null
+ ) return;
+
+ if (ws?.readyState !== WebSocket.OPEN) return;
+
+ sendJson({
+ type: "audio.chunk",
+ turn_id: turnId,
+ sequence_number: sequence++
+ });
+
+ ws.send(data.buffer);
+ };
+
+ silentGain = inputContext.createGain();
+ silentGain.gain.value = 0;
+
+ inputSource.connect(inputNode);
+ inputNode.connect(silentGain);
+ silentGain.connect(inputContext.destination);
+
+ await inputContext.resume();
+}
+
+async function beginTurn() {
+ if (phase !== "ready") return;
+
+ try {
+ await ensurePlayback();
+ stopPlayback();
+
+ setPhase("starting");
+ sendJson({ type: "audio.start" });
+ } catch (error) {
+ console.error(error);
+ setPhase("ready");
+ }
+}
+
+async function endRecording() {
+ if (phase !== "recording") return;
+
+ const activeTurn = turnId;
+ setPhase("processing");
+
+ await stopMicrophone();
+
+ if (activeTurn !== null && ws?.readyState === WebSocket.OPEN) {
+ sendJson({ type: "audio.stop", turn_id: activeTurn });
+ }
+}
+
+async function interruptTurn() {
+ if (turnId === null) return;
+
+ const activeTurn = turnId;
+
+ setPhase("processing");
+ await stopMicrophone();
+ stopPlayback();
+
+ if (ws?.readyState === WebSocket.OPEN) {
+ sendJson({ type: "interrupt", turn_id: activeTurn });
+ }
+}
+
+async function handleEvent(event) {
+ switch (event.type) {
+ case "session.ready":
+ sessionId = event.session_id;
+ setPhase("ready");
+ break;
+
+ case "turn.started":
+ turnId = event.turn_id;
+ sequence = 0;
+ userMessage = addMessage("user");
+ agentMessage = addMessage("agent");
+ pendingFinal = null;
+
+ try {
+ await startMicrophone();
+ setPhase("recording");
+ } catch (error) {
+ console.error(error);
+ talkLabel.textContent = "Não foi possível acessar o microfone.";
+ await interruptTurn();
+ }
+ break;
+
+ case "stt.partial":
+ if (event.turn_id === turnId && userMessage) {
+ userMessage.textContent = event.text;
+ }
+ break;
+
+ case "stt.final":
+ if (event.turn_id === turnId && userMessage) {
+ userMessage.textContent = event.text;
+ }
+ break;
+
+ case "agent.delta":
+ if (event.turn_id === turnId && agentMessage) {
+ agentMessage.textContent += event.text;
+ }
+ break;
+
+ case "agent.response":
+ if (event.turn_id === turnId && agentMessage) {
+ agentMessage.textContent = event.text;
+ }
+ break;
+
+ case "turn.ended":
+ if (event.turn_id === turnId) {
+ turnId = null;
+ await stopMicrophone();
+ setPhase("ready");
+ }
+ break;
+
+ case "interrupted":
+ if (event.turn_id === turnId) {
+ turnId = null;
+ await stopMicrophone();
+ stopPlayback();
+ setPhase("ready");
+ }
+ break;
+
+ case "error":
+ console.error("Gateway error:", event);
+ talkLabel.textContent =
+ `Erro: ${event.code} — ${event.message}`;
+
+ if (phase === "starting") {
+ setPhase("ready");
+ } else if (turnId !== null && phase !== "disconnected") {
+ await interruptTurn();
+ }
+ break;
+
+ case "session.ended":
+ ws?.close();
+ break;
+ }
+}
+
+async function connect() {
+ if (ws && ws.readyState !== WebSocket.CLOSED) return;
+
+ setPhase("connecting");
+
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
+ ws = new WebSocket(`${scheme}://${location.host}/ws/audio`);
+ ws.binaryType = "arraybuffer";
+
+ // Serialize event handling to preserve ordering.
+ let incoming = Promise.resolve();
+
+ ws.onopen = () => {
+ sendJson({
+ type: "session.start",
+ audio: {
+ encoding: "pcm_s16le",
+ sample_rate: 16000,
+ channels: 1
+ }
+ });
+ };
+
+ ws.onmessage = ({ data }) => {
+ incoming = incoming.then(async () => {
+ if (data instanceof ArrayBuffer) {
+ if (turnId !== null) {
+ await playPCM(data);
+ }
+ } else {
+ await handleEvent(JSON.parse(data));
+ }
+ }).catch(console.error);
+ };
+
+ ws.onerror = (error) => console.error("WebSocket error", error);
+
+ ws.onclose = async () => {
+ await stopMicrophone();
+ stopPlayback();
+ ws = null;
+ sessionId = null;
+ turnId = null;
+ setPhase("disconnected");
+ };
+}
+
+connectButton.addEventListener("click", connect);
+
+talkButton.addEventListener("click", async () => {
+ if (phase === "ready") await beginTurn();
+ else if (phase === "recording") await endRecording();
+});
+
+interruptButton.addEventListener("click", interruptTurn);
+
+setPhase("disconnected");
diff --git a/src/voice_assistant/audio_gateway/web/index.html b/src/voice_assistant/audio_gateway/web/index.html
new file mode 100644
index 0000000..26e6799
--- /dev/null
+++ b/src/voice_assistant/audio_gateway/web/index.html
@@ -0,0 +1,83 @@
+
+
+
+
Realtime Voice Console
+Fale pelo microfone e receba respostas + geradas por inteligência artificial.
+Conecte para começar
+