feat: migra cliente web para áudio contínuo com VAD no backend
This commit is contained in:
54
README.md
54
README.md
@@ -321,17 +321,16 @@ sequenceDiagram
|
|||||||
participant C as Client
|
participant C as Client
|
||||||
participant G as Audio Gateway
|
participant G as Audio Gateway
|
||||||
participant P as VoicePipeline / Providers
|
participant P as VoicePipeline / Providers
|
||||||
C->>G: session.start
|
C->>G: session.start (turn_detection=server)
|
||||||
G-->>C: session.ready
|
G-->>C: session.ready
|
||||||
C->>G: audio.start
|
loop Continuous audio input, including silence and playback
|
||||||
G-->>C: turn.started
|
C->>G: audio.input (sequence_number)
|
||||||
loop Audio input
|
|
||||||
C->>G: audio.chunk (turn_id, sequence_number)
|
|
||||||
C->>G: Binary PCM frame
|
C->>G: Binary PCM frame
|
||||||
|
G-->>C: turn.started (when backend VAD detects speech)
|
||||||
P-->>G: STT partial events
|
P-->>G: STT partial events
|
||||||
G-->>C: stt.partial
|
G-->>C: stt.partial
|
||||||
end
|
end
|
||||||
C->>G: audio.stop (turn_id)
|
Note over G,P: Backend VAD ends input after silence
|
||||||
P-->>G: STT final transcript
|
P-->>G: STT final transcript
|
||||||
G-->>C: stt.final
|
G-->>C: stt.final
|
||||||
P-->>G: Agent response
|
P-->>G: Agent response
|
||||||
@@ -341,7 +340,7 @@ sequenceDiagram
|
|||||||
G-->>C: turn.ended
|
G-->>C: turn.ended
|
||||||
```
|
```
|
||||||
|
|
||||||
STT events may arrive while input audio is still streaming. Binary audio input is preceded by an `audio.chunk` control message containing `turn_id` and `sequence_number`. The Gateway limits each binary chunk to 64 KiB. Input frames must contain complete PCM samples; rejected frames do not advance the audio sequence. `session.ready.audio` describes input and `session.ready.output_audio` describes output. The browser configures playback from the declared output format. VoxCPM reassembles HTTP fragments into complete PCM frames and rejects a truncated final sample. Its `sample_rate` option declares the expected native output rate; it does not request resampling from the external service.
|
STT events may arrive while input audio is still streaming. In server detection mode, each binary PCM frame is preceded by `audio.input` with a session-wide `sequence_number`, starting at zero and continuing across turns. `session.ready.max_audio_chunk_bytes` declares the accepted limit, capped at 64 KiB. The browser sizes worklet chunks to respect it. Manual mode uses `audio.chunk` with `turn_id` and a per-turn sequence. Input frames must contain complete PCM samples; rejected frames do not advance the audio sequence. `session.ready.audio` describes input and `session.ready.output_audio` describes output. The browser configures playback from the declared output format. VoxCPM reassembles HTTP fragments into complete PCM frames and rejects a truncated final sample. Its `sample_rate` option declares the expected native output rate; it does not request resampling from the external service.
|
||||||
|
|
||||||
### Interruption and session closure
|
### Interruption and session closure
|
||||||
|
|
||||||
@@ -351,7 +350,7 @@ STT events may arrive while input audio is still streaming. Binary audio input i
|
|||||||
|
|
||||||
## Lifecycle and interruption
|
## Lifecycle and interruption
|
||||||
|
|
||||||
The system separates **semantic state** from **asynchronous execution**. `TurnManager` controls listening, processing, speaking, and completion; `VoicePipeline` owns the registered turn task. The Gateway coordinates provider shutdown and WebSocket delivery.
|
The system separates **semantic state** from **asynchronous execution**. `TurnManager` controls listening, processing, speaking, and completion; `VoicePipeline` owns the registered turn task. VoiceSession coordinates provider shutdown; the Gateway owns WebSocket delivery and shields cleanup from transport cancellation.
|
||||||
|
|
||||||
Cancellation is cooperative: `task.cancel()` requests termination, and the Gateway awaits task completion before confirming a successful interruption. Startup rollback attempts to cancel any created task and abort a partially initialized STT provider. Failed cleanup is reported rather than represented as a successful retry.
|
Cancellation is cooperative: `task.cancel()` requests termination, and the Gateway awaits task completion before confirming a successful interruption. Startup rollback attempts to cancel any created task and abort a partially initialized STT provider. Failed cleanup is reported rather than represented as a successful retry.
|
||||||
|
|
||||||
@@ -433,8 +432,8 @@ boundary. Model padding never extends the input timeline.
|
|||||||
releases resources without emitting new speech events. Calls are serialized.
|
releases resources without emitting new speech events. Calls are serialized.
|
||||||
These contracts do not start turns, select providers, or carry transport IDs.
|
These contracts do not start turns, select providers, or carry transport IDs.
|
||||||
The Silero adapter can control turns through the continuous VoiceSession API.
|
The Silero adapter can control turns through the continuous VoiceSession API.
|
||||||
Browser/WebSocket migration is the next task; the existing web client continues
|
The main web client requests server detection and streams continuous PCM through
|
||||||
to use its current turn commands until that migration.
|
the WebSocket route. Speech detection and turn decisions run in the backend.
|
||||||
|
|
||||||
|
|
||||||
### Local Silero VAD adapter
|
### Local Silero VAD adapter
|
||||||
@@ -497,8 +496,8 @@ remains compatible with existing configurations; explicitly creating VAD in
|
|||||||
such an environment raises a configuration error. Changing YAML values affects
|
such an environment raises a configuration error. Changing YAML values affects
|
||||||
new adapter instances after configuration is reloaded; there is no live reload.
|
new adapter instances after configuration is reloaded; there is no live reload.
|
||||||
|
|
||||||
The VAD factory can be supplied to continuous voice sessions. The existing web
|
The main web client uses the VAD factory through continuous voice sessions.
|
||||||
client remains on manual turn commands until the browser migration.
|
Its detection settings come from the active environment YAML.
|
||||||
|
|
||||||
|
|
||||||
### Continuous audio and backend turn control
|
### Continuous audio and backend turn control
|
||||||
@@ -541,5 +540,32 @@ turn completion describes produced output, not physical speaker completion.
|
|||||||
Manual `start_turn`/`send_audio`/`stop_turn` commands remain available on sessions
|
Manual `start_turn`/`send_audio`/`stop_turn` commands remain available on sessions
|
||||||
without VAD. Mixing those commands with a VAD-controlled session is rejected.
|
without VAD. Mixing those commands with a VAD-controlled session is rejected.
|
||||||
`create_voice_session(..., server_vad=True)` composes the continuous backend API;
|
`create_voice_session(..., server_vad=True)` composes the continuous backend API;
|
||||||
the current `/ws/audio` receive loop still uses the manual mode. The next task
|
`/ws/audio` selects this mode when `session.start.turn_detection` is `server`.
|
||||||
connects continuous input to that route and simplifies the browser accordingly.
|
Omitting that option selects the existing manual input mode for compatible clients.
|
||||||
|
|
||||||
|
|
||||||
|
### Browser continuous capture
|
||||||
|
|
||||||
|
The main `/ui` client requests `turn_detection: server` and waits for the matching
|
||||||
|
confirmation in `session.ready` before starting microphone capture. The worklet
|
||||||
|
then stays active throughout listening, processing, response playback, and
|
||||||
|
interruption. Input sequence numbers do not reset between turns. Microphone
|
||||||
|
permission, echo cancellation, and audio device ownership remain in the page.
|
||||||
|
|
||||||
|
The browser's custom `vad.js` and its detection tests have been removed. The voice
|
||||||
|
controller now handles connection, continuous PCM transport, protocol events,
|
||||||
|
and playback. Existing visual amplitude analysis is used only for animation.
|
||||||
|
It sends no automatic `audio.start`, `audio.stop`, or `interrupt` commands.
|
||||||
|
|
||||||
|
A new `turn.started` stops previous playback and discards obsolete queued audio.
|
||||||
|
`turn.ended` waits for scheduled speaker playback before updating the display to
|
||||||
|
listening; microphone streaming continues during that wait. Stale turn events
|
||||||
|
and completion callbacks cannot overwrite a newer turn. Outbound WebSocket
|
||||||
|
buffering and handshake waiting are bounded; failures stop local capture and
|
||||||
|
close the session.
|
||||||
|
|
||||||
|
`audio.input.end` finishes a finite continuous stream normally and requires no
|
||||||
|
pending binary frame. It is intended for file/terminal clients; the live browser
|
||||||
|
uses `session.end` or disconnect to abort. Manual input remains available with
|
||||||
|
`turn_detection: client`, including the standalone manual `app.js` and existing
|
||||||
|
smoke scripts. The main page uses `voice-session.js`.
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ class VoiceSession:
|
|||||||
def output_audio_format(self) -> AudioFormat:
|
def output_audio_format(self) -> AudioFormat:
|
||||||
return self._pipeline.tts.output_format
|
return self._pipeline.tts.output_format
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_input_chunk_bytes(self) -> int:
|
||||||
|
return self._max_input_chunk_bytes
|
||||||
|
|
||||||
def validate_audio_input(self, turn_id: int) -> None:
|
def validate_audio_input(self, turn_id: int) -> None:
|
||||||
self._require_open()
|
self._require_open()
|
||||||
if self._audio_input_closed:
|
if self._audio_input_closed:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from voice_assistant.application.voice_session import VoiceSession, VoiceSession
|
|||||||
from voice_assistant.audio.models import CANONICAL_AUDIO_FORMAT
|
from voice_assistant.audio.models import CANONICAL_AUDIO_FORMAT
|
||||||
from voice_assistant.audio_gateway.app.protocol import (
|
from voice_assistant.audio_gateway.app.protocol import (
|
||||||
PROTOCOL_VERSION, AUDIO_ENCODING, AUDIO_SAMPLE_RATE, AUDIO_CHANNELS,
|
PROTOCOL_VERSION, AUDIO_ENCODING, AUDIO_SAMPLE_RATE, AUDIO_CHANNELS,
|
||||||
AudioChunk, AudioStop, Interrupt, SessionEnd, SessionStart,
|
AudioChunk, InputAudioChunk, InputAudioEnd, AudioStop, Interrupt, SessionEnd, SessionStart,
|
||||||
AgentDelta, AgentResponse, Interrupted, SessionEnded, SessionReady,
|
AgentDelta, AgentResponse, Interrupted, SessionEnded, SessionReady,
|
||||||
STTFinal, STTPartial, TurnEnded, TurnStarted, error_event,
|
STTFinal, STTPartial, TurnEnded, TurnStarted, error_event,
|
||||||
)
|
)
|
||||||
@@ -139,7 +139,10 @@ async def create_voice_session(
|
|||||||
async def audio_socket(websocket: WebSocket) -> None:
|
async def audio_socket(websocket: WebSocket) -> None:
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
session: VoiceSession | None = None
|
session: VoiceSession | None = None
|
||||||
pending_audio_chunk: AudioChunk | None = None
|
pending_audio_chunk: AudioChunk | InputAudioChunk | None = None
|
||||||
|
server_vad = False
|
||||||
|
expected_input_sequence = 0
|
||||||
|
max_audio_chunk_bytes = MAX_AUDIO_CHUNK_BYTES
|
||||||
|
|
||||||
async def report(
|
async def report(
|
||||||
code: str, message: str, *, turn_id: int | None = None,
|
code: str, message: str, *, turn_id: int | None = None,
|
||||||
@@ -169,22 +172,27 @@ async def audio_socket(websocket: WebSocket) -> None:
|
|||||||
chunk = pending_audio_chunk
|
chunk = pending_audio_chunk
|
||||||
pending_audio_chunk = None
|
pending_audio_chunk = None
|
||||||
audio = message["bytes"]
|
audio = message["bytes"]
|
||||||
if len(audio) > MAX_AUDIO_CHUNK_BYTES:
|
turn_id = chunk.turn_id if isinstance(chunk, AudioChunk) else session.active_turn_id
|
||||||
|
if len(audio) > max_audio_chunk_bytes:
|
||||||
await report(
|
await report(
|
||||||
"MESSAGE_TOO_LARGE",
|
"MESSAGE_TOO_LARGE",
|
||||||
f"Audio chunk exceeds maximum size of {MAX_AUDIO_CHUNK_BYTES} bytes.",
|
f"Audio chunk exceeds maximum size of {max_audio_chunk_bytes} bytes.",
|
||||||
turn_id=chunk.turn_id,
|
turn_id=turn_id,
|
||||||
details={"max_bytes": MAX_AUDIO_CHUNK_BYTES, "received_bytes": len(audio)},
|
details={"max_bytes": max_audio_chunk_bytes, "received_bytes": len(audio)},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
if isinstance(chunk, InputAudioChunk):
|
||||||
|
await session.receive_audio(audio)
|
||||||
|
expected_input_sequence += 1
|
||||||
|
else:
|
||||||
await session.send_audio(chunk.turn_id, chunk.sequence_number, audio)
|
await session.send_audio(chunk.turn_id, chunk.sequence_number, audio)
|
||||||
except VoiceSessionError as exc:
|
except VoiceSessionError as exc:
|
||||||
await report(exc.code, str(exc), turn_id=exc.turn_id, retryable=exc.retryable)
|
await report(exc.code, str(exc), turn_id=exc.turn_id, retryable=exc.retryable)
|
||||||
except (RuntimeError, ValueError) as exc:
|
except (RuntimeError, ValueError) as exc:
|
||||||
await report(
|
await report(
|
||||||
"AUDIO_SEQUENCE_ERROR", str(exc), turn_id=chunk.turn_id,
|
"AUDIO_SEQUENCE_ERROR", str(exc), turn_id=turn_id,
|
||||||
details={"expected_sequence_number": session.expected_audio_sequence},
|
details={"expected_sequence_number": expected_input_sequence if server_vad else session.expected_audio_sequence},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -232,11 +240,28 @@ async def audio_socket(websocket: WebSocket) -> None:
|
|||||||
}},
|
}},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
session = await create_voice_session(websocket, request.session_id or None)
|
try:
|
||||||
|
session = await create_voice_session(
|
||||||
|
websocket, request.session_id or None,
|
||||||
|
server_vad=request.turn_detection == "server",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to initialize voice session.")
|
||||||
|
await report("SESSION_START_FAILED", "Voice session could not be initialized.")
|
||||||
|
continue
|
||||||
|
server_vad = request.turn_detection == "server"
|
||||||
|
max_audio_chunk_bytes = min(
|
||||||
|
MAX_AUDIO_CHUNK_BYTES, session.max_input_chunk_bytes,
|
||||||
|
) if server_vad else MAX_AUDIO_CHUNK_BYTES
|
||||||
|
if max_audio_chunk_bytes < 2:
|
||||||
|
await report("INVALID_CONFIGURATION", "Audio input limit must allow a complete PCM frame.")
|
||||||
|
break
|
||||||
await send_json(websocket, SessionReady(
|
await send_json(websocket, SessionReady(
|
||||||
protocol_version=PROTOCOL_VERSION, session_id=session.session_id,
|
protocol_version=PROTOCOL_VERSION, session_id=session.session_id,
|
||||||
audio=CANONICAL_AUDIO_FORMAT,
|
audio=CANONICAL_AUDIO_FORMAT,
|
||||||
output_audio=session.output_audio_format,
|
output_audio=session.output_audio_format,
|
||||||
|
turn_detection=request.turn_detection,
|
||||||
|
max_audio_chunk_bytes=max_audio_chunk_bytes,
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
if session is None:
|
if session is None:
|
||||||
@@ -246,6 +271,43 @@ async def audio_socket(websocket: WebSocket) -> None:
|
|||||||
await report("SESSION_ALREADY_CLOSED", "This session is already closed.")
|
await report("SESSION_ALREADY_CLOSED", "This session is already closed.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if server_vad and event_type in {"audio.start", "audio.chunk", "audio.stop"}:
|
||||||
|
await report("TURN_CONTROLLED_BY_VAD", "This session uses server-side turn detection.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "audio.input":
|
||||||
|
if not server_vad:
|
||||||
|
await report("INVALID_INPUT_MODE", "Continuous audio requires server-side turn detection.")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
request = InputAudioChunk.model_validate(payload)
|
||||||
|
if pending_audio_chunk is not None:
|
||||||
|
raise ValueError("Previous audio chunk is still pending PCM data.")
|
||||||
|
if request.sequence_number != expected_input_sequence:
|
||||||
|
raise ValueError(f"Expected sequence_number={expected_input_sequence}.")
|
||||||
|
pending_audio_chunk = request
|
||||||
|
except (ValidationError, ValueError) as exc:
|
||||||
|
await report(
|
||||||
|
"AUDIO_SEQUENCE_ERROR", str(exc),
|
||||||
|
details={"expected_sequence_number": expected_input_sequence},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "audio.input.end":
|
||||||
|
if not server_vad:
|
||||||
|
await report("INVALID_INPUT_MODE", "Continuous audio requires server-side turn detection.")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
InputAudioEnd.model_validate(payload)
|
||||||
|
if pending_audio_chunk is not None:
|
||||||
|
raise ValueError("An audio chunk is still pending PCM data.")
|
||||||
|
await session.finish_audio_input()
|
||||||
|
except VoiceSessionError as exc:
|
||||||
|
await report(exc.code, str(exc), turn_id=exc.turn_id, retryable=exc.retryable)
|
||||||
|
except (ValidationError, ValueError) as exc:
|
||||||
|
await report("INVALID_MESSAGE", str(exc))
|
||||||
|
continue
|
||||||
|
|
||||||
if event_type == "audio.start":
|
if event_type == "audio.start":
|
||||||
try:
|
try:
|
||||||
turn_id = await session.start_turn()
|
turn_id = await session.start_turn()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class SessionStart(BaseModel):
|
|||||||
|
|
||||||
type: Literal["session.start"] = "session.start"
|
type: Literal["session.start"] = "session.start"
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
|
turn_detection: Literal["client", "server"] = "client"
|
||||||
|
|
||||||
audio: AudioFormat = Field(
|
audio: AudioFormat = Field(
|
||||||
default_factory=lambda: CANONICAL_AUDIO_FORMAT.model_copy()
|
default_factory=lambda: CANONICAL_AUDIO_FORMAT.model_copy()
|
||||||
@@ -54,6 +55,19 @@ class AudioChunk(BaseModel):
|
|||||||
sequence_number: int
|
sequence_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class InputAudioChunk(BaseModel):
|
||||||
|
"""Metadata for continuous PCM, sequenced across the entire session."""
|
||||||
|
|
||||||
|
type: Literal["audio.input"] = "audio.input"
|
||||||
|
sequence_number: int = Field(ge=0, strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
class InputAudioEnd(BaseModel):
|
||||||
|
"""Normal end of a finite continuous input stream."""
|
||||||
|
|
||||||
|
type: Literal["audio.input.end"] = "audio.input.end"
|
||||||
|
|
||||||
|
|
||||||
class AudioStop(BaseModel):
|
class AudioStop(BaseModel):
|
||||||
"""Request that finishes audio input for the active turn."""
|
"""Request that finishes audio input for the active turn."""
|
||||||
|
|
||||||
@@ -82,6 +96,8 @@ class SessionReady(BaseModel):
|
|||||||
session_id: str
|
session_id: str
|
||||||
audio: AudioFormat
|
audio: AudioFormat
|
||||||
output_audio: AudioFormat
|
output_audio: AudioFormat
|
||||||
|
turn_detection: Literal["client", "server"] = "client"
|
||||||
|
max_audio_chunk_bytes: int = Field(default=65536, ge=2)
|
||||||
|
|
||||||
|
|
||||||
class TurnStarted(BaseModel):
|
class TurnStarted(BaseModel):
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const audioBands=new Float32Array(3);
|
|||||||
let voiceController=null;
|
let voiceController=null;
|
||||||
|
|
||||||
const activeStates=new Set([
|
const activeStates=new Set([
|
||||||
'connecting','listening','starting','recording','flushing',
|
'connecting','listening','recording',
|
||||||
'processing','speaking','waiting-playback'
|
'processing','speaking','waiting-playback'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -107,9 +107,7 @@ const assistantIntegration={
|
|||||||
const messages={
|
const messages={
|
||||||
connecting:'Conectando ao AgentVoice...',
|
connecting:'Conectando ao AgentVoice...',
|
||||||
listening:'Estou ouvindo. Pode falar.',
|
listening:'Estou ouvindo. Pode falar.',
|
||||||
starting:'Iniciando reconhecimento...',
|
|
||||||
recording:'Estou ouvindo sua pergunta...',
|
recording:'Estou ouvindo sua pergunta...',
|
||||||
flushing:'Finalizando captura de áudio...',
|
|
||||||
processing:'Preparando sua resposta...',
|
processing:'Preparando sua resposta...',
|
||||||
speaking:'Reproduzindo resposta...',
|
speaking:'Reproduzindo resposta...',
|
||||||
'waiting-playback':'Aguardando fim da reprodução.'
|
'waiting-playback':'Aguardando fim da reprodução.'
|
||||||
|
|||||||
@@ -16,10 +16,19 @@ class PCMInputProcessor extends AudioWorkletProcessor {
|
|||||||
|
|
||||||
this.port.onmessage = ({ data }) => {
|
this.port.onmessage = ({ data }) => {
|
||||||
switch (data?.type) {
|
switch (data?.type) {
|
||||||
case "start":
|
case "start": {
|
||||||
|
const chunkSamples = data.chunkSamples ?? 1600;
|
||||||
|
if (!Number.isInteger(chunkSamples) || chunkSamples < 1 || chunkSamples > 1600) {
|
||||||
|
this.capturing = false;
|
||||||
|
this.reset();
|
||||||
|
this.port.postMessage({ type: "error", message: "Tamanho de captura inválido." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.chunkSamples = chunkSamples;
|
||||||
this.reset();
|
this.reset();
|
||||||
this.capturing = true;
|
this.capturing = true;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "flush":
|
case "flush":
|
||||||
this.capturing = false;
|
this.capturing = false;
|
||||||
@@ -44,14 +53,16 @@ class PCMInputProcessor extends AudioWorkletProcessor {
|
|||||||
emitPCM() {
|
emitPCM() {
|
||||||
if (!this.output.length) return;
|
if (!this.output.length) return;
|
||||||
|
|
||||||
const pcm = new Int16Array(this.output);
|
const pcm = new ArrayBuffer(this.output.length * 2);
|
||||||
|
const view = new DataView(pcm);
|
||||||
|
this.output.forEach((sample, index) => view.setInt16(index * 2, sample, true));
|
||||||
this.output = [];
|
this.output = [];
|
||||||
|
|
||||||
this.port.postMessage({
|
this.port.postMessage({
|
||||||
type: "pcm",
|
type: "pcm",
|
||||||
sequence: this.sequence++,
|
sequence: this.sequence++,
|
||||||
buffer: pcm.buffer
|
buffer: pcm
|
||||||
}, [pcm.buffer]);
|
}, [pcm]);
|
||||||
}
|
}
|
||||||
|
|
||||||
flush() {
|
flush() {
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
|
|
||||||
export class VoiceActivityDetector {
|
|
||||||
constructor({
|
|
||||||
onSpeechStart,
|
|
||||||
onSpeechEnd,
|
|
||||||
minSpeechMs = 250,
|
|
||||||
silenceMs = 800,
|
|
||||||
threshold = 0.012,
|
|
||||||
noiseMultiplier = 3,
|
|
||||||
} = {}) {
|
|
||||||
this.onSpeechStart = onSpeechStart;
|
|
||||||
this.onSpeechEnd = onSpeechEnd;
|
|
||||||
|
|
||||||
this.minSpeechMs = minSpeechMs;
|
|
||||||
this.silenceMs = silenceMs;
|
|
||||||
this.threshold = threshold;
|
|
||||||
this.noiseMultiplier = noiseMultiplier;
|
|
||||||
|
|
||||||
this.noiseFloor = 0.003;
|
|
||||||
this.speaking = false;
|
|
||||||
this.speechStartedAt = null;
|
|
||||||
this.lastVoiceAt = null;
|
|
||||||
this.active = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
this.active = true;
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.active = false;
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.speaking = false;
|
|
||||||
this.speechStartedAt = null;
|
|
||||||
this.lastVoiceAt = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
process(rms, now = performance.now()) {
|
|
||||||
if (!this.active) return;
|
|
||||||
|
|
||||||
if (!Number.isFinite(rms) || rms < 0) return;
|
|
||||||
|
|
||||||
const threshold = Math.max(
|
|
||||||
this.threshold,
|
|
||||||
this.noiseFloor * this.noiseMultiplier
|
|
||||||
);
|
|
||||||
|
|
||||||
const voiced = rms >= threshold;
|
|
||||||
|
|
||||||
if (!this.speaking) {
|
|
||||||
// Update noise estimation only outside detected speech.
|
|
||||||
if (!voiced) {
|
|
||||||
this.noiseFloor =
|
|
||||||
this.noiseFloor * 0.98 + rms * 0.02;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (voiced) {
|
|
||||||
if (this.speechStartedAt === null) {
|
|
||||||
this.speechStartedAt = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (now - this.speechStartedAt >= this.minSpeechMs) {
|
|
||||||
this.speaking = true;
|
|
||||||
this.lastVoiceAt = now;
|
|
||||||
this.onSpeechStart?.();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.speechStartedAt = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (voiced) {
|
|
||||||
this.lastVoiceAt = now;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.lastVoiceAt !== null &&
|
|
||||||
now - this.lastVoiceAt >= this.silenceMs
|
|
||||||
) {
|
|
||||||
this.speaking = false;
|
|
||||||
this.onSpeechEnd?.();
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
import { VoiceActivityDetector } from "./vad.js";
|
|
||||||
import { PCMPlayer } from "./pcm-player.js";
|
import { PCMPlayer } from "./pcm-player.js";
|
||||||
|
|
||||||
export class VoiceSessionController {
|
export class VoiceSessionController {
|
||||||
@@ -8,61 +6,38 @@ export class VoiceSessionController {
|
|||||||
onTranscript = () => {},
|
onTranscript = () => {},
|
||||||
onAgentText = () => {},
|
onAgentText = () => {},
|
||||||
onError = () => {},
|
onError = () => {},
|
||||||
silenceMs = 800,
|
maxBufferedBytes = 256 * 1024,
|
||||||
prebufferChunks = 4,
|
handshakeTimeoutMs = 10000,
|
||||||
maxPendingChunks = 100,
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes < 2) {
|
||||||
|
throw new Error("Limite de envio de áudio inválido.");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs <= 0) {
|
||||||
|
throw new Error("Prazo de conexão inválido.");
|
||||||
|
}
|
||||||
this.onStateChange = onStateChange;
|
this.onStateChange = onStateChange;
|
||||||
this.onTranscript = onTranscript;
|
this.onTranscript = onTranscript;
|
||||||
this.onAgentText = onAgentText;
|
this.onAgentText = onAgentText;
|
||||||
this.onError = onError;
|
this.onError = onError;
|
||||||
|
this.maxBufferedBytes = maxBufferedBytes;
|
||||||
this.prebufferChunks = prebufferChunks;
|
this.handshakeTimeoutMs = handshakeTimeoutMs;
|
||||||
this.maxPendingChunks = maxPendingChunks;
|
|
||||||
|
|
||||||
this.state = "closed";
|
this.state = "closed";
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.stream = null;
|
|
||||||
this.context = null;
|
this.context = null;
|
||||||
this.analyser = null;
|
|
||||||
this.worklet = null;
|
this.worklet = null;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
this.silentGain = null;
|
this.silentGain = null;
|
||||||
|
|
||||||
this.turnId = null;
|
|
||||||
this.sequence = 0;
|
|
||||||
this.prebuffer = [];
|
|
||||||
this.pending = [];
|
|
||||||
this.stopRequested = false;
|
|
||||||
this.frameId = 0;
|
|
||||||
this.rmsSamples = null;
|
|
||||||
|
|
||||||
this.vad = new VoiceActivityDetector({
|
|
||||||
silenceMs,
|
|
||||||
onSpeechStart: () => this.beginTurn(),
|
|
||||||
onSpeechEnd: () => this.finishSpeech(),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.player = null;
|
this.player = null;
|
||||||
this.playbackPending = Promise.resolve();
|
this.ready = false;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.turnId = null;
|
||||||
|
this.latestTurnId = 0;
|
||||||
this.gatewayTurnEnded = false;
|
this.gatewayTurnEnded = false;
|
||||||
this.playbackGeneration = 0;
|
this.playbackGeneration = 0;
|
||||||
this.bargeInPending = false;
|
this.connectionGeneration = 0;
|
||||||
this.bargeInBuffer = [];
|
this.playbackPending = Promise.resolve();
|
||||||
this.maxBargeInChunks = 100;
|
this.handshakeTimer = null;
|
||||||
this.bargeInSpeechEnded = false;
|
this.maxAudioChunkBytes = 0;
|
||||||
this.interruptedTurnId = null;
|
|
||||||
this.bargeInCaptureStarted = false;
|
|
||||||
|
|
||||||
this.bargeInVad = new VoiceActivityDetector({
|
|
||||||
minSpeechMs: 350,
|
|
||||||
silenceMs: 800,
|
|
||||||
threshold: 0.025,
|
|
||||||
noiseMultiplier: 4,
|
|
||||||
|
|
||||||
onSpeechStart: () => this.requestBargeIn(),
|
|
||||||
onSpeechEnd: () => this.finishBargeInSpeech(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaybackAnalyser() {
|
getPlaybackAnalyser() {
|
||||||
@@ -70,16 +45,6 @@ export class VoiceSessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(state) {
|
setState(state) {
|
||||||
console.debug(
|
|
||||||
`[AgentVoice][STATE] ${this.state} -> ${state}`,
|
|
||||||
{
|
|
||||||
turnId: this.turnId,
|
|
||||||
playerFinished: this.player?.finished,
|
|
||||||
bargeInPending: this.bargeInPending,
|
|
||||||
bargeInRecording: this.bargeInRecording,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.onStateChange(state);
|
this.onStateChange(state);
|
||||||
}
|
}
|
||||||
@@ -91,688 +56,234 @@ export class VoiceSessionController {
|
|||||||
this.ws.send(JSON.stringify(event));
|
this.ws.send(JSON.stringify(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
async start({ stream, audioContext, analyser }) {
|
async start({ stream, audioContext }) {
|
||||||
if (this.state !== "closed") {
|
if (this.state !== "closed") throw new Error("Sessão já iniciada.");
|
||||||
throw new Error("Sessão já iniciada.");
|
const generation = ++this.connectionGeneration;
|
||||||
}
|
|
||||||
|
|
||||||
this.stream = stream;
|
|
||||||
this.context = audioContext;
|
this.context = audioContext;
|
||||||
this.analyser = analyser;
|
this.ready = false;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.turnId = null;
|
||||||
|
this.latestTurnId = 0;
|
||||||
this.player = new PCMPlayer({
|
this.player = new PCMPlayer({
|
||||||
audioContext,
|
audioContext,
|
||||||
|
|
||||||
onPlaybackStart: () => {
|
onPlaybackStart: () => {
|
||||||
if (
|
if (this.ready && this.turnId !== null) this.setState("speaking");
|
||||||
this.state === "processing" ||
|
|
||||||
this.state === "speaking"
|
|
||||||
) {
|
|
||||||
this.setState("speaking");
|
|
||||||
this.bargeInVad.start();
|
|
||||||
this.monitorVoice();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.rmsSamples = new Float32Array(analyser.fftSize);
|
|
||||||
|
|
||||||
this.setState("connecting");
|
this.setState("connecting");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.context.audioWorklet.addModule(
|
await audioContext.audioWorklet.addModule("/web/pcm-worklet.js");
|
||||||
"/web/pcm-worklet.js"
|
if (generation !== this.connectionGeneration) return;
|
||||||
);
|
this.source = audioContext.createMediaStreamSource(stream);
|
||||||
|
this.worklet = new AudioWorkletNode(audioContext, "pcm-input");
|
||||||
this.source = this.context.createMediaStreamSource(stream);
|
this.silentGain = audioContext.createGain();
|
||||||
this.worklet = new AudioWorkletNode(
|
|
||||||
this.context,
|
|
||||||
"pcm-input"
|
|
||||||
);
|
|
||||||
|
|
||||||
this.silentGain = this.context.createGain();
|
|
||||||
this.silentGain.gain.value = 0;
|
this.silentGain.gain.value = 0;
|
||||||
|
|
||||||
this.source.connect(this.worklet);
|
this.source.connect(this.worklet);
|
||||||
this.worklet.connect(this.silentGain);
|
this.worklet.connect(this.silentGain);
|
||||||
this.silentGain.connect(this.context.destination);
|
this.silentGain.connect(audioContext.destination);
|
||||||
|
|
||||||
this.worklet.port.onmessage = ({ data }) => {
|
this.worklet.port.onmessage = ({ data }) => {
|
||||||
this.handleWorkletMessage(data);
|
if (generation !== this.connectionGeneration) return;
|
||||||
|
if (data?.type === "pcm") this.handlePCM(data.buffer);
|
||||||
|
if (data?.type === "error") this.fail(new Error(data.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
||||||
const socket = new WebSocket(
|
const socket = new WebSocket(`${scheme}://${location.host}/ws/audio`);
|
||||||
`${scheme}://${location.host}/ws/audio`
|
|
||||||
);
|
|
||||||
|
|
||||||
this.ws = socket;
|
this.ws = socket;
|
||||||
socket.binaryType = "arraybuffer";
|
socket.binaryType = "arraybuffer";
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
if (this.ws !== socket) return;
|
if (this.ws !== socket) return;
|
||||||
|
try {
|
||||||
this.send({
|
this.send({
|
||||||
type: "session.start",
|
type: "session.start",
|
||||||
audio: {
|
turn_detection: "server",
|
||||||
encoding: "pcm_s16le",
|
audio: { encoding: "pcm_s16le", sample_rate: 16000, channels: 1 },
|
||||||
sample_rate: 16000,
|
|
||||||
channels: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
socket.onmessage = ({ data }) => {
|
|
||||||
if (this.ws !== socket) return;
|
|
||||||
|
|
||||||
if (typeof data === "string") {
|
|
||||||
try {
|
|
||||||
this.handleGatewayEvent(JSON.parse(data));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.fail(error);
|
this.fail(error);
|
||||||
}
|
}
|
||||||
} else if (data instanceof ArrayBuffer) {
|
|
||||||
this.handleAudioChunk(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
if (this.ws === socket) {
|
|
||||||
this.fail(new Error("Falha na conexão WebSocket."));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
if (this.ws === socket && this.state !== "closed") {
|
|
||||||
this.fail(new Error("Conexão WebSocket encerrada."));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
socket.onmessage = ({ data }) => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
|
try {
|
||||||
|
if (typeof data === "string") this.handleGatewayEvent(JSON.parse(data));
|
||||||
|
else if (data instanceof ArrayBuffer) this.handleAudioChunk(data);
|
||||||
|
else throw new Error("Resposta de áudio inválida.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.fail(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onerror = () => {
|
||||||
|
if (this.ws === socket) this.fail(new Error("Falha na conexão WebSocket."));
|
||||||
|
};
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (this.ws === socket) this.fail(new Error("Conexão WebSocket encerrada."));
|
||||||
|
};
|
||||||
|
this.handshakeTimer = setTimeout(() => {
|
||||||
|
if (this.ws === socket && !this.ready) {
|
||||||
|
this.fail(new Error("O servidor não confirmou o início da conversa."));
|
||||||
|
}
|
||||||
|
}, this.handshakeTimeoutMs);
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== this.connectionGeneration) return;
|
||||||
this.close();
|
this.close();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
startListening() {
|
|
||||||
this.playbackGeneration++;
|
|
||||||
|
|
||||||
this.gatewayTurnEnded = false;
|
|
||||||
this.player?.reset();
|
|
||||||
this.playbackPending = Promise.resolve();
|
|
||||||
|
|
||||||
this.bargeInVad.stop();
|
|
||||||
this.bargeInPending = false;
|
|
||||||
this.bargeInSpeechEnded = false;
|
|
||||||
this.interruptedTurnId = null;
|
|
||||||
this.bargeInBuffer = [];
|
|
||||||
|
|
||||||
this.turnId = null;
|
|
||||||
this.sequence = 0;
|
|
||||||
this.prebuffer = [];
|
|
||||||
this.pending = [];
|
|
||||||
this.stopRequested = false;
|
|
||||||
|
|
||||||
this.worklet.port.postMessage({ type: "start" });
|
|
||||||
|
|
||||||
this.vad.start();
|
|
||||||
this.setState("listening");
|
|
||||||
this.monitorVoice();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
monitorVoice() {
|
|
||||||
cancelAnimationFrame(this.frameId);
|
|
||||||
|
|
||||||
const tick = (now) => {
|
|
||||||
const normalListening = [
|
|
||||||
"listening",
|
|
||||||
"starting",
|
|
||||||
"recording"
|
|
||||||
].includes(this.state);
|
|
||||||
|
|
||||||
const bargeInListening = [
|
|
||||||
"processing",
|
|
||||||
"speaking",
|
|
||||||
"waiting-playback",
|
|
||||||
"interrupting"
|
|
||||||
].includes(this.state) || this.bargeInRecording;
|
|
||||||
|
|
||||||
if (!normalListening && !bargeInListening) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.analyser.getFloatTimeDomainData(this.rmsSamples);
|
|
||||||
|
|
||||||
let power = 0;
|
|
||||||
|
|
||||||
for (const sample of this.rmsSamples) {
|
|
||||||
power += sample * sample;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rms = Math.sqrt(
|
|
||||||
power / this.rmsSamples.length
|
|
||||||
);
|
|
||||||
|
|
||||||
// Normal speech detection.
|
|
||||||
if (
|
|
||||||
normalListening &&
|
|
||||||
!this.bargeInRecording &&
|
|
||||||
!this.stopRequested
|
|
||||||
) {
|
|
||||||
this.vad.process(rms, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue tracking speech initiated during TTS.
|
|
||||||
if (
|
|
||||||
bargeInListening &&
|
|
||||||
this.bargeInVad.active
|
|
||||||
) {
|
|
||||||
this.bargeInVad.process(rms, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.frameId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.frameId = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
beginTurn() {
|
|
||||||
if (this.state !== "listening") return;
|
|
||||||
|
|
||||||
this.pending = [...this.prebuffer];
|
|
||||||
this.setState("starting");
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.send({ type: "audio.start" });
|
|
||||||
} catch (error) {
|
|
||||||
this.fail(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleWorkletMessage(data) {
|
|
||||||
if (data?.type === "pcm") {
|
|
||||||
this.handlePCM(data.buffer);
|
|
||||||
} else if (data?.type === "flushed") {
|
|
||||||
this.handleFlushed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePCM(buffer) {
|
handlePCM(buffer) {
|
||||||
if (this.state === "listening") {
|
if (!this.ready || this.state === "closed") return;
|
||||||
this.prebuffer.push(buffer);
|
|
||||||
|
|
||||||
if (this.prebuffer.length > this.prebufferChunks) {
|
|
||||||
this.prebuffer.shift();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.state === "processing" ||
|
|
||||||
this.state === "speaking" ||
|
|
||||||
this.state === "waiting-playback" ||
|
|
||||||
this.state === "interrupting"
|
|
||||||
) {
|
|
||||||
this.bargeInBuffer.push(buffer);
|
|
||||||
|
|
||||||
if (this.bargeInBuffer.length > this.maxBargeInChunks) {
|
|
||||||
this.bargeInBuffer.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state === "starting") {
|
|
||||||
this.pending.push(buffer);
|
|
||||||
|
|
||||||
if (this.pending.length > this.maxPendingChunks) {
|
|
||||||
this.fail(new Error("Buffer de início de turno excedido."));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state === "recording" ||
|
|
||||||
this.state === "flushing") {
|
|
||||||
this.sendPCM(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPCM(buffer) {
|
|
||||||
if (this.turnId === null) return;
|
|
||||||
|
|
||||||
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
||||||
|
|
||||||
this.send({
|
|
||||||
type: "audio.chunk",
|
|
||||||
turn_id: this.turnId,
|
|
||||||
sequence_number: this.sequence++,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ws.send(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
finishSpeech() {
|
|
||||||
if (this.stopRequested) return;
|
|
||||||
|
|
||||||
if (this.state !== "starting" &&
|
|
||||||
this.state !== "recording") return;
|
|
||||||
|
|
||||||
this.stopRequested = true;
|
|
||||||
this.vad.stop();
|
|
||||||
|
|
||||||
if (this.state === "recording") {
|
|
||||||
this.flushTurn();
|
|
||||||
}
|
|
||||||
// If still starting, flush after turn.started.
|
|
||||||
}
|
|
||||||
|
|
||||||
flushTurn() {
|
|
||||||
if (this.state !== "recording") return;
|
|
||||||
|
|
||||||
this.setState("flushing");
|
|
||||||
this.worklet.port.postMessage({ type: "flush" });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFlushed() {
|
|
||||||
if (this.state !== "flushing") return;
|
|
||||||
|
|
||||||
this.send({
|
|
||||||
type: "audio.stop",
|
|
||||||
turn_id: this.turnId,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState("processing");
|
|
||||||
|
|
||||||
// Resume local PCM capture for a possible interruption.
|
|
||||||
// Audio will remain buffered until a new turn is accepted.
|
|
||||||
this.worklet.port.postMessage({ type: "start" });
|
|
||||||
|
|
||||||
// The microphone remains available for barge-in.
|
|
||||||
// Detection starts when TTS playback begins.
|
|
||||||
this.monitorVoice();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
handleAudioChunk(buffer) {
|
|
||||||
if (
|
|
||||||
this.state === "closed" ||
|
|
||||||
this.state === "interrupting" ||
|
|
||||||
this.bargeInPending ||
|
|
||||||
this.turnId === null ||
|
|
||||||
this.gatewayTurnEnded
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug("[AgentVoice][PCM recebido]", {
|
|
||||||
bytes: buffer.byteLength,
|
|
||||||
turnId: this.turnId,
|
|
||||||
state: this.state,
|
|
||||||
generation: this.playbackGeneration,
|
|
||||||
finished: this.player?.finished,
|
|
||||||
gatewayTurnEnded: this.gatewayTurnEnded,
|
|
||||||
});
|
|
||||||
|
|
||||||
const generation = this.playbackGeneration;
|
|
||||||
|
|
||||||
this.playbackPending = this.playbackPending
|
|
||||||
.then(async () => {
|
|
||||||
if (generation !== this.playbackGeneration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.state === "interrupting" ||
|
|
||||||
this.bargeInPending
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug("[AgentVoice][PCM enqueue]", {
|
|
||||||
turnId: this.turnId,
|
|
||||||
state: this.state,
|
|
||||||
generation,
|
|
||||||
currentGeneration: this.playbackGeneration,
|
|
||||||
finished: this.player?.finished,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
await this.player.enqueue(buffer);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
// An obsolete playback must never terminate the session.
|
|
||||||
if (generation !== this.playbackGeneration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.state === "interrupting" ||
|
|
||||||
this.bargeInPending
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fail(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async finishPlaybackAndResume() {
|
|
||||||
const generation = this.playbackGeneration;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.playbackPending;
|
if (!(buffer instanceof ArrayBuffer) || !buffer.byteLength || buffer.byteLength % 2) {
|
||||||
|
throw new Error("Áudio capturado inválido.");
|
||||||
if (generation !== this.playbackGeneration) return;
|
}
|
||||||
if (this.state === "closed") return;
|
if (buffer.byteLength > this.maxAudioChunkBytes) {
|
||||||
|
throw new Error("O bloco de áudio excede o limite do servidor.");
|
||||||
this.player.finish();
|
}
|
||||||
await this.player.waitForCompletion();
|
if ((this.ws?.bufferedAmount ?? 0) + buffer.byteLength > this.maxBufferedBytes) {
|
||||||
|
throw new Error("A conexão não está acompanhando o envio de áudio.");
|
||||||
if (generation !== this.playbackGeneration) return;
|
}
|
||||||
if (this.state === "closed") return;
|
this.send({ type: "audio.input", sequence_number: this.sequence });
|
||||||
if (!this.gatewayTurnEnded) return;
|
this.ws.send(buffer);
|
||||||
|
this.sequence++;
|
||||||
this.startListening();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation === this.playbackGeneration) {
|
|
||||||
this.fail(error);
|
this.fail(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
invalidatePlayback() {
|
||||||
requestBargeIn() {
|
|
||||||
if (this.bargeInPending) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.state !== "speaking" &&
|
|
||||||
this.state !== "waiting-playback"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.turnId === null) return;
|
|
||||||
|
|
||||||
const interruptedTurnId = this.turnId;
|
|
||||||
|
|
||||||
this.bargeInPending = true;
|
|
||||||
this.bargeInSpeechEnded = false;
|
|
||||||
this.bargeInCaptureStarted = true;
|
|
||||||
this.interruptedTurnId = interruptedTurnId;
|
|
||||||
|
|
||||||
// Stop local playback immediately.
|
|
||||||
this.playbackGeneration++;
|
this.playbackGeneration++;
|
||||||
this.player?.stop();
|
this.player?.stop();
|
||||||
this.playbackPending = Promise.resolve();
|
this.playbackPending = Promise.resolve();
|
||||||
|
|
||||||
// Prevent normal playback completion from resuming listening.
|
|
||||||
this.gatewayTurnEnded = false;
|
this.gatewayTurnEnded = false;
|
||||||
|
}
|
||||||
|
|
||||||
// The normal VAD must not start a competing turn.
|
handleAudioChunk(buffer) {
|
||||||
this.vad.stop();
|
if (!this.ready || this.turnId === null || this.gatewayTurnEnded) return;
|
||||||
|
const generation = this.playbackGeneration;
|
||||||
this.setState("interrupting");
|
this.playbackPending = this.playbackPending.then(async () => {
|
||||||
|
if (generation !== this.playbackGeneration || !this.ready) return;
|
||||||
try {
|
await this.player.enqueue(buffer);
|
||||||
this.send({
|
}).catch((error) => {
|
||||||
type: "interrupt",
|
if (generation === this.playbackGeneration && this.ready) this.fail(error);
|
||||||
turn_id: interruptedTurnId,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async finishPlayback(generation, turnId) {
|
||||||
|
try {
|
||||||
|
await this.playbackPending;
|
||||||
|
if (generation !== this.playbackGeneration || !this.ready) return;
|
||||||
|
this.player.finish();
|
||||||
|
await this.player.waitForCompletion();
|
||||||
|
if (generation !== this.playbackGeneration || !this.ready || this.turnId !== turnId) return;
|
||||||
|
this.turnId = null;
|
||||||
|
this.setState("listening");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.fail(error);
|
if (generation === this.playbackGeneration && this.ready) this.fail(error);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finishBargeInSpeech() {
|
|
||||||
if (!this.bargeInPending && !this.bargeInRecording) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bargeInSpeechEnded = true;
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.bargeInRecording &&
|
|
||||||
this.state === "recording"
|
|
||||||
) {
|
|
||||||
this.bargeInRecording = false;
|
|
||||||
this.bargeInVad.stop();
|
|
||||||
|
|
||||||
this.stopRequested = true;
|
|
||||||
this.flushTurn();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGatewayEvent(event) {
|
handleGatewayEvent(event) {
|
||||||
switch (event.type) {
|
if (this.state === "closed") return;
|
||||||
case "session.ready":
|
if (event.type === "session.ready") {
|
||||||
|
if (this.ready) throw new Error("Confirmação de sessão duplicada.");
|
||||||
|
if (
|
||||||
|
event.turn_detection !== "server" || event.audio?.encoding !== "pcm_s16le" ||
|
||||||
|
event.audio.sample_rate !== 16000 || event.audio.channels !== 1 ||
|
||||||
|
!Number.isInteger(event.max_audio_chunk_bytes) || event.max_audio_chunk_bytes < 2
|
||||||
|
) {
|
||||||
|
throw new Error("O servidor não oferece suporte a esta versão do cliente.");
|
||||||
|
}
|
||||||
this.player.configure(event.output_audio);
|
this.player.configure(event.output_audio);
|
||||||
this.startListening();
|
this.maxAudioChunkBytes = event.max_audio_chunk_bytes;
|
||||||
break;
|
this.ready = true;
|
||||||
|
clearTimeout(this.handshakeTimer);
|
||||||
// Debug logging for all events received from the Gateway.
|
this.handshakeTimer = null;
|
||||||
console.debug("[AgentVoice][Gateway]", {
|
this.worklet.port.postMessage({
|
||||||
type: event.type,
|
type: "start", chunkSamples: Math.min(1600, Math.floor(this.maxAudioChunkBytes / 2)),
|
||||||
turnId: event.turn_id,
|
|
||||||
currentTurnId: this.turnId,
|
|
||||||
state: this.state,
|
|
||||||
playbackGeneration: this.playbackGeneration,
|
|
||||||
playerFinished: this.player?.finished,
|
|
||||||
playerPlaying: this.player?.playing,
|
|
||||||
bargeInPending: this.bargeInPending,
|
|
||||||
bargeInRecording: this.bargeInRecording,
|
|
||||||
text: event.type === "stt.final"
|
|
||||||
? event.text
|
|
||||||
: event.type === "agent.response"
|
|
||||||
? event.text
|
|
||||||
: undefined,
|
|
||||||
error: event.type === "error"
|
|
||||||
? { code: event.code, message: event.message }
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
// Debug logging for all events received from the Gateway.
|
this.setState("listening");
|
||||||
|
|
||||||
|
|
||||||
case "turn.started":
|
|
||||||
if (this.state !== "starting") return;
|
|
||||||
|
|
||||||
// Prepare playback for the newly accepted turn.
|
|
||||||
// The previous TTS may have been stopped by barge-in.
|
|
||||||
this.playbackGeneration++;
|
|
||||||
this.player?.reset();
|
|
||||||
this.playbackPending = Promise.resolve();
|
|
||||||
this.gatewayTurnEnded = false;
|
|
||||||
|
|
||||||
this.turnId = event.turn_id;
|
|
||||||
this.sequence = 0;
|
|
||||||
|
|
||||||
this.setState("recording");
|
|
||||||
|
|
||||||
for (const buffer of this.pending) {
|
|
||||||
this.sendPCM(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pending = [];
|
|
||||||
|
|
||||||
if (this.bargeInPending) {
|
|
||||||
this.bargeInPending = false;
|
|
||||||
this.bargeInCaptureStarted = false;
|
|
||||||
|
|
||||||
if (this.bargeInSpeechEnded) {
|
|
||||||
// User finished speaking before turn.started.
|
|
||||||
this.bargeInRecording = false;
|
|
||||||
this.bargeInVad.stop();
|
|
||||||
this.stopRequested = true;
|
|
||||||
} else {
|
|
||||||
// User is still speaking.
|
|
||||||
// Preserve the active barge-in VAD state.
|
|
||||||
this.bargeInRecording = true;
|
|
||||||
this.stopRequested = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bargeInSpeechEnded = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.stopRequested) {
|
|
||||||
this.flushTurn();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "stt.partial":
|
|
||||||
case "stt.final":
|
|
||||||
if (event.turn_id === this.turnId) {
|
|
||||||
this.onTranscript(event.text, event.type === "stt.final");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "agent.delta":
|
|
||||||
if (event.turn_id === this.turnId) {
|
|
||||||
this.onAgentText(event.text, false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "agent.response":
|
|
||||||
if (event.turn_id === this.turnId) {
|
|
||||||
this.onAgentText(event.text, true);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "turn.ended":
|
|
||||||
if (
|
|
||||||
event.turn_id === this.turnId &&
|
|
||||||
!this.bargeInPending
|
|
||||||
) {
|
|
||||||
this.gatewayTurnEnded = true;
|
|
||||||
this.setState("waiting-playback");
|
|
||||||
|
|
||||||
void this.finishPlaybackAndResume();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "interrupted":
|
|
||||||
if (
|
|
||||||
!this.bargeInPending ||
|
|
||||||
event.turn_id !== this.interruptedTurnId
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.type === "error") {
|
||||||
this.interruptedTurnId = null;
|
this.fail(new Error(`${event.code}: ${event.message}`));
|
||||||
this.turnId = null;
|
return;
|
||||||
this.sequence = 0;
|
|
||||||
this.pending = [...this.bargeInBuffer];
|
|
||||||
this.bargeInBuffer = [];
|
|
||||||
|
|
||||||
this.stopRequested = this.bargeInSpeechEnded;
|
|
||||||
|
|
||||||
this.setState("starting");
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.send({ type: "audio.start" });
|
|
||||||
} catch (error) {
|
|
||||||
this.fail(error);
|
|
||||||
}
|
}
|
||||||
break;
|
if (event.type === "session.ended") {
|
||||||
|
|
||||||
case "error":
|
|
||||||
if (
|
|
||||||
event.code === "INVALID_TURN" &&
|
|
||||||
this.bargeInPending &&
|
|
||||||
event.turn_id === this.interruptedTurnId
|
|
||||||
) {
|
|
||||||
// The old turn completed before interrupt was processed.
|
|
||||||
// Its semantic state is already released by the Gateway.
|
|
||||||
this.interruptedTurnId = null;
|
|
||||||
this.turnId = null;
|
|
||||||
this.sequence = 0;
|
|
||||||
|
|
||||||
this.pending = [...this.bargeInBuffer];
|
|
||||||
this.bargeInBuffer = [];
|
|
||||||
|
|
||||||
this.stopRequested = this.bargeInSpeechEnded;
|
|
||||||
this.gatewayTurnEnded = false;
|
|
||||||
|
|
||||||
this.setState("starting");
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.send({ type: "audio.start" });
|
|
||||||
} catch (error) {
|
|
||||||
this.fail(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fail(
|
|
||||||
new Error(`${event.code}: ${event.message}`)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "session.ended":
|
|
||||||
this.close();
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ready) return;
|
||||||
|
if (event.type === "turn.started") {
|
||||||
|
if (!Number.isSafeInteger(event.turn_id) || event.turn_id <= this.latestTurnId) return;
|
||||||
|
this.invalidatePlayback();
|
||||||
|
this.player.reset();
|
||||||
|
this.latestTurnId = event.turn_id;
|
||||||
|
this.turnId = event.turn_id;
|
||||||
|
this.setState("recording");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.turn_id !== this.turnId) return;
|
||||||
|
switch (event.type) {
|
||||||
|
case "stt.partial":
|
||||||
|
case "stt.final":
|
||||||
|
this.onTranscript(event.text, event.type === "stt.final");
|
||||||
|
if (event.type === "stt.final") this.setState("processing");
|
||||||
|
break;
|
||||||
|
case "agent.delta":
|
||||||
|
case "agent.response":
|
||||||
|
this.onAgentText(event.text, event.type === "agent.response");
|
||||||
|
break;
|
||||||
|
case "interrupted":
|
||||||
|
this.invalidatePlayback();
|
||||||
|
this.turnId = null;
|
||||||
|
this.setState("listening");
|
||||||
|
break;
|
||||||
|
case "turn.ended":
|
||||||
|
if (this.gatewayTurnEnded) return;
|
||||||
|
this.gatewayTurnEnded = true;
|
||||||
|
this.setState("waiting-playback");
|
||||||
|
void this.finishPlayback(this.playbackGeneration, this.turnId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fail(error) {
|
fail(error) {
|
||||||
|
if (this.state === "closed") return;
|
||||||
|
try {
|
||||||
this.onError(error);
|
this.onError(error);
|
||||||
|
} finally {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
if (this.state === "closed") return;
|
if (this.state === "closed") return;
|
||||||
|
this.connectionGeneration++;
|
||||||
this.playbackGeneration++;
|
this.ready = false;
|
||||||
this.player?.stop();
|
clearTimeout(this.handshakeTimer);
|
||||||
|
this.handshakeTimer = null;
|
||||||
this.vad.stop();
|
this.invalidatePlayback();
|
||||||
this.bargeInVad.stop();
|
|
||||||
|
|
||||||
this.bargeInPending = false;
|
|
||||||
this.bargeInSpeechEnded = false;
|
|
||||||
this.bargeInCaptureStarted = false;
|
|
||||||
this.interruptedTurnId = null;
|
|
||||||
this.bargeInBuffer = [];
|
|
||||||
|
|
||||||
cancelAnimationFrame(this.frameId);
|
|
||||||
|
|
||||||
if (this.worklet) {
|
if (this.worklet) {
|
||||||
this.worklet.port.postMessage({ type: "stop" });
|
this.worklet.port.postMessage({ type: "stop" });
|
||||||
this.worklet.port.onmessage = null;
|
this.worklet.port.onmessage = null;
|
||||||
this.worklet.disconnect();
|
this.worklet.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.source?.disconnect();
|
this.source?.disconnect();
|
||||||
this.silentGain?.disconnect();
|
this.silentGain?.disconnect();
|
||||||
|
|
||||||
const socket = this.ws;
|
const socket = this.ws;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.onclose = null;
|
socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
|
||||||
socket.onerror = null;
|
try {
|
||||||
socket.onmessage = null;
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "session.end" }));
|
||||||
|
} catch {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
// Local cleanup must complete even when the transport has already failed.
|
||||||
if (this.turnId === null) {
|
} finally {
|
||||||
socket.send(JSON.stringify({ type: "session.end" }));
|
|
||||||
} else {
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
type: "interrupt",
|
|
||||||
turn_id: this.turnId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.close();
|
socket.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.worklet = null;
|
this.worklet = this.source = this.silentGain = null;
|
||||||
this.source = null;
|
|
||||||
this.silentGain = null;
|
|
||||||
this.turnId = null;
|
this.turnId = null;
|
||||||
this.prebuffer = [];
|
|
||||||
this.pending = [];
|
|
||||||
this.setState("closed");
|
this.setState("closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
tests/js/pcm-worklet.test.mjs
Normal file
48
tests/js/pcm-worklet.test.mjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
function processor() {
|
||||||
|
let Processor;
|
||||||
|
class Base {
|
||||||
|
constructor() {
|
||||||
|
this.port = { sent: [], postMessage(data) { this.sent.push(data); } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vm.runInNewContext(readFileSync(new URL("../../src/voice_assistant/audio_gateway/web/pcm-worklet.js", import.meta.url), "utf8"), {
|
||||||
|
AudioWorkletProcessor: Base, sampleRate: 16000,
|
||||||
|
registerProcessor(name, constructor) { assert.equal(name, "pcm-input"); Processor = constructor; },
|
||||||
|
});
|
||||||
|
return new Processor();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("negotiated capture emits complete little-endian PCM16 frames", () => {
|
||||||
|
const input = processor();
|
||||||
|
input.port.onmessage({ data: { type: "start", chunkSamples: 2 } });
|
||||||
|
input.process([[new Float32Array([-1, 0, 1])]]);
|
||||||
|
const chunk = input.port.sent[0];
|
||||||
|
assert.equal(chunk.type, "pcm");
|
||||||
|
assert.equal(chunk.buffer.byteLength, 4);
|
||||||
|
const samples = new DataView(chunk.buffer);
|
||||||
|
assert.equal(samples.getInt16(0, true), -32768);
|
||||||
|
assert.equal(samples.getInt16(2, true), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capture continues across process callbacks until stop", () => {
|
||||||
|
const input = processor();
|
||||||
|
input.port.onmessage({ data: { type: "start", chunkSamples: 2 } });
|
||||||
|
input.process([[new Float32Array([0, 0, 0])]]);
|
||||||
|
input.process([[new Float32Array([0, 0])]]);
|
||||||
|
assert.equal(input.port.sent.filter((event) => event.type === "pcm").length, 2);
|
||||||
|
input.port.onmessage({ data: { type: "stop" } });
|
||||||
|
input.process([[new Float32Array([0, 0, 0])]]);
|
||||||
|
assert.equal(input.port.sent.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid capture window reports an error", () => {
|
||||||
|
const input = processor();
|
||||||
|
input.port.onmessage({ data: { type: "start", chunkSamples: 0 } });
|
||||||
|
assert.equal(input.port.sent[0].type, "error");
|
||||||
|
assert.equal(input.capturing, false);
|
||||||
|
});
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { VoiceActivityDetector } from "../../src/voice_assistant/audio_gateway/web/vad.js";
|
|
||||||
|
|
||||||
test("detects speech start after minimum duration", () => {
|
|
||||||
let starts = 0;
|
|
||||||
|
|
||||||
const vad = new VoiceActivityDetector({
|
|
||||||
minSpeechMs: 250,
|
|
||||||
onSpeechStart: () => starts++,
|
|
||||||
});
|
|
||||||
|
|
||||||
vad.start();
|
|
||||||
|
|
||||||
vad.process(0.04, 0);
|
|
||||||
vad.process(0.04, 100);
|
|
||||||
|
|
||||||
assert.equal(starts, 0);
|
|
||||||
|
|
||||||
vad.process(0.04, 260);
|
|
||||||
|
|
||||||
assert.equal(starts, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("detects speech end after silence", () => {
|
|
||||||
let ends = 0;
|
|
||||||
|
|
||||||
const vad = new VoiceActivityDetector({
|
|
||||||
minSpeechMs: 100,
|
|
||||||
silenceMs: 800,
|
|
||||||
onSpeechEnd: () => ends++,
|
|
||||||
});
|
|
||||||
|
|
||||||
vad.start();
|
|
||||||
|
|
||||||
vad.process(0.04, 0);
|
|
||||||
vad.process(0.04, 120);
|
|
||||||
vad.process(0.04, 300);
|
|
||||||
|
|
||||||
vad.process(0.001, 600);
|
|
||||||
vad.process(0.001, 1000);
|
|
||||||
|
|
||||||
assert.equal(ends, 0);
|
|
||||||
|
|
||||||
vad.process(0.001, 1101);
|
|
||||||
|
|
||||||
assert.equal(ends, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ignores short noise spikes", () => {
|
|
||||||
let starts = 0;
|
|
||||||
|
|
||||||
const vad = new VoiceActivityDetector({
|
|
||||||
minSpeechMs: 250,
|
|
||||||
onSpeechStart: () => starts++,
|
|
||||||
});
|
|
||||||
|
|
||||||
vad.start();
|
|
||||||
|
|
||||||
vad.process(0.05, 0);
|
|
||||||
vad.process(0.001, 80);
|
|
||||||
vad.process(0.001, 300);
|
|
||||||
|
|
||||||
assert.equal(starts, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stop disables detection", () => {
|
|
||||||
let starts = 0;
|
|
||||||
|
|
||||||
const vad = new VoiceActivityDetector({
|
|
||||||
minSpeechMs: 100,
|
|
||||||
onSpeechStart: () => starts++,
|
|
||||||
});
|
|
||||||
|
|
||||||
vad.start();
|
|
||||||
vad.stop();
|
|
||||||
|
|
||||||
vad.process(0.05, 0);
|
|
||||||
vad.process(0.05, 200);
|
|
||||||
|
|
||||||
assert.equal(starts, 0);
|
|
||||||
});
|
|
||||||
@@ -1,746 +1,270 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { afterEach, test } from "node:test";
|
import { afterEach, test } from "node:test";
|
||||||
|
import { VoiceSessionController } from "../../src/voice_assistant/audio_gateway/web/voice-session.js";
|
||||||
|
|
||||||
import { VoiceSessionController } from
|
const controllers = [];
|
||||||
"../../src/voice_assistant/audio_gateway/web/voice-session.js";
|
class Node {
|
||||||
|
constructor() { this.disconnected = false; }
|
||||||
const originalWebSocket = globalThis.WebSocket;
|
connect() {}
|
||||||
|
disconnect() { this.disconnected = true; }
|
||||||
class FakeWebSocket {
|
}
|
||||||
|
class Context {
|
||||||
|
constructor() {
|
||||||
|
this.destination = {};
|
||||||
|
this.currentTime = 0;
|
||||||
|
this.sources = [];
|
||||||
|
this.audioWorklet = { addModule: async () => {} };
|
||||||
|
this.nextResume = null;
|
||||||
|
}
|
||||||
|
createAnalyser() { return new Node(); }
|
||||||
|
createGain() { return Object.assign(new Node(), { gain: { value: 1 } }); }
|
||||||
|
createMediaStreamSource() { this.input = new Node(); return this.input; }
|
||||||
|
async resume() { const pending = this.nextResume; this.nextResume = null; if (pending) await pending; }
|
||||||
|
createBuffer(channels, length, sampleRate) {
|
||||||
|
return { duration: length / sampleRate, copyToChannel(samples) { this.samples = samples; } };
|
||||||
|
}
|
||||||
|
createBufferSource() {
|
||||||
|
const source = Object.assign(new Node(), {
|
||||||
|
stopped: false, onended: null,
|
||||||
|
start: () => this.sources.push(source),
|
||||||
|
stop() { this.stopped = true; },
|
||||||
|
end() { this.onended?.(); },
|
||||||
|
});
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Worklet extends Node {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.port = { messages: [], postMessage(data) { this.messages.push(data); }, onmessage: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Socket {
|
||||||
static OPEN = 1;
|
static OPEN = 1;
|
||||||
static CLOSED = 3;
|
static CLOSED = 3;
|
||||||
|
constructor(url) { this.url = url; this.readyState = 0; this.sent = []; this.bufferedAmount = 0; }
|
||||||
constructor() {
|
send(data) { if (this.sendFailure) throw new Error("Transport failed"); this.sent.push(data); }
|
||||||
this.readyState = FakeWebSocket.OPEN;
|
open() { this.readyState = 1; this.onopen?.(); }
|
||||||
this.sent = [];
|
close() { this.readyState = 3; this.onclose?.(); }
|
||||||
this.closed = false;
|
receive(event) { this.onmessage?.({ data: JSON.stringify(event) }); }
|
||||||
}
|
|
||||||
|
|
||||||
send(data) {
|
|
||||||
this.sent.push(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.closed = true;
|
|
||||||
this.readyState = FakeWebSocket.CLOSED;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
globalThis.WebSocket = Socket;
|
||||||
globalThis.WebSocket = FakeWebSocket;
|
globalThis.AudioWorkletNode = Worklet;
|
||||||
globalThis.cancelAnimationFrame = () => {};
|
globalThis.location = { protocol: "http:", host: "localhost:8001" };
|
||||||
globalThis.requestAnimationFrame = () => 1;
|
afterEach(() => { for (const controller of controllers.splice(0)) controller.close(); });
|
||||||
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const ready = (overrides = {}) => ({
|
||||||
afterEach(() => {
|
type: "session.ready", turn_detection: "server",
|
||||||
// Restore only state modified by individual tests.
|
audio: { encoding: "pcm_s16le", sample_rate: 16000, channels: 1 },
|
||||||
|
output_audio: { encoding: "pcm_s16le", sample_rate: 48000, channels: 1 },
|
||||||
|
max_audio_chunk_bytes: 65536, ...overrides,
|
||||||
});
|
});
|
||||||
|
function messages(socket) { return socket.sent.filter((data) => typeof data === "string").map(JSON.parse); }
|
||||||
function makeController() {
|
async function setup(options = {}, confirm = true) {
|
||||||
const states = [];
|
const errors = [], states = [], transcripts = [], replies = [];
|
||||||
const errors = [];
|
|
||||||
const workletMessages = [];
|
|
||||||
|
|
||||||
const controller = new VoiceSessionController({
|
const controller = new VoiceSessionController({
|
||||||
onStateChange: (state) => states.push(state),
|
onError: (error) => errors.push(error), onStateChange: (state) => states.push(state),
|
||||||
onError: (error) => errors.push(error),
|
onTranscript: (...args) => transcripts.push(args), onAgentText: (...args) => replies.push(args),
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
|
controllers.push(controller);
|
||||||
controller.ws = new FakeWebSocket();
|
const context = new Context();
|
||||||
controller.worklet = {
|
await controller.start({ stream: {}, audioContext: context });
|
||||||
port: {
|
const socket = controller.ws;
|
||||||
postMessage: (data) => workletMessages.push(data),
|
socket.open();
|
||||||
onmessage: null,
|
if (confirm) socket.receive(ready());
|
||||||
},
|
return { controller, context, socket, errors, states, transcripts, replies };
|
||||||
disconnect() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.source = { disconnect() {} };
|
|
||||||
controller.silentGain = { disconnect() {} };
|
|
||||||
|
|
||||||
return {
|
|
||||||
controller,
|
|
||||||
states,
|
|
||||||
errors,
|
|
||||||
workletMessages,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("speech detection requests a new turn", () => {
|
test("requests server VAD and starts capture only after session.ready", async () => {
|
||||||
const { controller } = makeController();
|
const { controller, socket } = await setup({}, false);
|
||||||
|
assert.equal(messages(socket)[0].turn_detection, "server");
|
||||||
controller.setState("listening");
|
assert.deepEqual(controller.worklet.port.messages, []);
|
||||||
controller.beginTurn();
|
|
||||||
|
|
||||||
assert.equal(controller.state, "starting");
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(controller.ws.sent[0]),
|
|
||||||
{ type: "audio.start" }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("PCM is buffered until turn.started", () => {
|
|
||||||
const { controller } = makeController();
|
|
||||||
|
|
||||||
controller.setState("listening");
|
|
||||||
|
|
||||||
controller.handlePCM(new ArrayBuffer(4));
|
controller.handlePCM(new ArrayBuffer(4));
|
||||||
|
assert.equal(socket.sent.length, 1);
|
||||||
|
socket.receive(ready());
|
||||||
|
assert.deepEqual(controller.worklet.port.messages, [{ type: "start", chunkSamples: 1600 }]);
|
||||||
|
assert.equal(controller.state, "listening");
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(controller.prebuffer.length, 1);
|
for (const state of ["listening", "recording", "processing", "speaking", "waiting-playback"]) {
|
||||||
assert.equal(controller.ws.sent.length, 0);
|
test(`sends continuous PCM while ${state}, without turn metadata`, async () => {
|
||||||
|
const { controller, socket } = await setup();
|
||||||
controller.beginTurn();
|
controller.setState(state);
|
||||||
controller.handlePCM(new ArrayBuffer(6));
|
const audio = new ArrayBuffer(4);
|
||||||
|
controller.handlePCM(audio);
|
||||||
assert.equal(controller.pending.length, 2);
|
assert.deepEqual(messages(socket).at(-1), { type: "audio.input", sequence_number: 0 });
|
||||||
assert.equal(controller.ws.sent.length, 1);
|
assert.equal(socket.sent.at(-1), audio);
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.started",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("global input sequence never resets when backend opens another turn", async () => {
|
||||||
|
const { controller, socket } = await setup();
|
||||||
|
controller.handlePCM(new ArrayBuffer(4));
|
||||||
|
socket.receive({ type: "turn.started", turn_id: 1 });
|
||||||
|
controller.handlePCM(new ArrayBuffer(4));
|
||||||
|
socket.receive({ type: "interrupted", turn_id: 1 });
|
||||||
|
socket.receive({ type: "turn.started", turn_id: 2 });
|
||||||
|
controller.handlePCM(new ArrayBuffer(4));
|
||||||
|
assert.deepEqual(messages(socket).filter((m) => m.type === "audio.input").map((m) => m.sequence_number), [0, 1, 2]);
|
||||||
|
assert.equal(messages(socket).some((m) => ["audio.start", "audio.stop", "interrupt"].includes(m.type)), false);
|
||||||
|
assert.equal(controller.worklet.port.messages.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the negotiated chunk size for microphone capture", async () => {
|
||||||
|
const { controller, socket } = await setup({}, false);
|
||||||
|
socket.receive(ready({ max_audio_chunk_bytes: 1000 }));
|
||||||
|
assert.deepEqual(controller.worklet.port.messages, [{ type: "start", chunkSamples: 500 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn.ended waits for speakers without pausing microphone input", async () => {
|
||||||
|
const { controller, context, socket } = await setup();
|
||||||
|
socket.receive({ type: "turn.started", turn_id: 1 });
|
||||||
|
controller.handleAudioChunk(new ArrayBuffer(8));
|
||||||
|
await controller.playbackPending;
|
||||||
|
assert.equal(controller.state, "speaking");
|
||||||
|
socket.receive({ type: "turn.ended", turn_id: 1 });
|
||||||
|
await tick();
|
||||||
|
assert.equal(controller.state, "waiting-playback");
|
||||||
|
controller.handlePCM(new ArrayBuffer(4));
|
||||||
|
assert.equal(messages(socket).at(-1).type, "audio.input");
|
||||||
|
context.sources[0].end();
|
||||||
|
await tick();
|
||||||
|
assert.equal(controller.state, "listening");
|
||||||
|
assert.equal(controller.worklet.port.messages.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a new backend turn stops old playback and ignores its pending completion", async () => {
|
||||||
|
const { controller, context, socket } = await setup();
|
||||||
|
socket.receive({ type: "turn.started", turn_id: 1 });
|
||||||
|
controller.handleAudioChunk(new ArrayBuffer(8));
|
||||||
|
await controller.playbackPending;
|
||||||
|
socket.receive({ type: "turn.ended", turn_id: 1 });
|
||||||
|
await tick();
|
||||||
|
socket.receive({ type: "turn.started", turn_id: 2 });
|
||||||
|
await tick();
|
||||||
|
assert.equal(context.sources[0].stopped, true);
|
||||||
|
assert.equal(controller.turnId, 2);
|
||||||
assert.equal(controller.state, "recording");
|
assert.equal(controller.state, "recording");
|
||||||
assert.equal(controller.pending.length, 0);
|
|
||||||
assert.equal(controller.sequence, 2);
|
|
||||||
assert.equal(controller.ws.sent.length, 5);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("PCM metadata precedes each binary frame", () => {
|
test("old queued PCM is discarded after playback generation changes", async () => {
|
||||||
const { controller } = makeController();
|
const { controller, context, socket } = await setup();
|
||||||
|
let release;
|
||||||
controller.turnId = 7;
|
context.nextResume = new Promise((resolve) => { release = resolve; });
|
||||||
controller.setState("recording");
|
socket.receive({ type: "turn.started", turn_id: 1 });
|
||||||
|
controller.handleAudioChunk(new ArrayBuffer(8));
|
||||||
const pcm = new ArrayBuffer(8);
|
const obsolete = controller.playbackPending;
|
||||||
|
await tick();
|
||||||
controller.sendPCM(pcm);
|
socket.receive({ type: "turn.started", turn_id: 2 });
|
||||||
|
release();
|
||||||
assert.deepEqual(
|
await obsolete;
|
||||||
JSON.parse(controller.ws.sent[0]),
|
assert.equal(context.sources.length, 0);
|
||||||
{
|
controller.handleAudioChunk(new ArrayBuffer(4));
|
||||||
type: "audio.chunk",
|
await controller.playbackPending;
|
||||||
turn_id: 7,
|
assert.equal(context.sources.length, 1);
|
||||||
sequence_number: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(controller.ws.sent[1], pcm);
|
|
||||||
assert.equal(controller.sequence, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("speech end requests worklet flush", () => {
|
test("a failure in obsolete playback does not close the new turn", async () => {
|
||||||
const { controller, workletMessages } = makeController();
|
const { controller, context, socket, errors } = await setup();
|
||||||
|
let reject;
|
||||||
controller.turnId = 1;
|
context.nextResume = new Promise((resolve, fail) => { reject = fail; });
|
||||||
controller.setState("recording");
|
socket.receive({ type: "turn.started", turn_id: 1 });
|
||||||
|
controller.handleAudioChunk(new ArrayBuffer(8));
|
||||||
controller.finishSpeech();
|
const obsolete = controller.playbackPending;
|
||||||
|
await tick();
|
||||||
assert.equal(controller.state, "flushing");
|
socket.receive({ type: "turn.started", turn_id: 2 });
|
||||||
assert.deepEqual(
|
reject(new Error("Old playback failed"));
|
||||||
workletMessages.at(-1),
|
await obsolete;
|
||||||
{ type: "flush" }
|
assert.equal(errors.length, 0);
|
||||||
);
|
assert.equal(controller.turnId, 2);
|
||||||
|
assert.equal(socket.readyState, Socket.OPEN);
|
||||||
assert.equal(controller.ws.sent.length, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stale turn events cannot change the current response", async () => {
|
||||||
test("audio.stop is sent after flushed", () => {
|
const { controller, socket, transcripts, replies } = await setup();
|
||||||
const { controller } = makeController();
|
socket.receive({ type: "turn.started", turn_id: 2 });
|
||||||
|
socket.receive({ type: "stt.final", turn_id: 1, text: "old" });
|
||||||
controller.turnId = 1;
|
socket.receive({ type: "agent.response", turn_id: 1, text: "old" });
|
||||||
controller.setState("flushing");
|
socket.receive({ type: "interrupted", turn_id: 1 });
|
||||||
|
socket.receive({ type: "turn.ended", turn_id: 1 });
|
||||||
controller.handleFlushed();
|
socket.receive({ type: "turn.started", turn_id: 1 });
|
||||||
|
assert.equal(controller.turnId, 2);
|
||||||
const messages = controller.ws.sent
|
assert.deepEqual(transcripts, []);
|
||||||
.filter((message) => typeof message === "string")
|
assert.deepEqual(replies, []);
|
||||||
.map((message) => JSON.parse(message));
|
socket.receive({ type: "stt.final", turn_id: 2, text: "new" });
|
||||||
|
socket.receive({ type: "agent.response", turn_id: 2, text: "reply" });
|
||||||
assert.ok(
|
assert.deepEqual(transcripts, [["new", true]]);
|
||||||
messages.some(
|
assert.deepEqual(replies, [["reply", true]]);
|
||||||
(message) =>
|
|
||||||
message.type === "audio.stop" &&
|
|
||||||
message.turn_id === 1
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(controller.state, "processing");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("interrupted returns to listening without stopping capture or starting a client turn", async () => {
|
||||||
test("speech ending before turn.started is handled", () => {
|
const { controller, socket } = await setup();
|
||||||
const { controller, workletMessages } = makeController();
|
socket.receive({ type: "turn.started", turn_id: 1 });
|
||||||
|
socket.receive({ type: "interrupted", turn_id: 1 });
|
||||||
controller.setState("listening");
|
assert.equal(controller.state, "listening");
|
||||||
controller.beginTurn();
|
assert.equal(controller.turnId, null);
|
||||||
controller.finishSpeech();
|
assert.deepEqual(messages(socket).map((m) => m.type), ["session.start"]);
|
||||||
|
assert.equal(controller.worklet.port.messages.length, 1);
|
||||||
assert.equal(controller.stopRequested, true);
|
|
||||||
assert.equal(controller.state, "starting");
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.started",
|
|
||||||
turn_id: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "flushing");
|
|
||||||
assert.deepEqual(
|
|
||||||
workletMessages.at(-1),
|
|
||||||
{ type: "flush" }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("closing a session releases resources", () => {
|
for (const metadata of [{ turn_detection: "client" }, { turn_detection: undefined }, { max_audio_chunk_bytes: 1 }]) {
|
||||||
const { controller, workletMessages } = makeController();
|
test(`rejects incompatible session metadata: ${JSON.stringify(metadata)}`, async () => {
|
||||||
const socket = controller.ws;
|
const { controller, socket, errors } = await setup({}, false);
|
||||||
|
socket.receive(ready(metadata));
|
||||||
controller.setState("listening");
|
|
||||||
controller.close();
|
|
||||||
|
|
||||||
assert.equal(controller.state, "closed");
|
|
||||||
assert.equal(socket.closed, true);
|
|
||||||
assert.equal(controller.ws, null);
|
|
||||||
assert.deepEqual(
|
|
||||||
workletMessages.at(-1),
|
|
||||||
{ type: "stop" }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gateway errors close the session", () => {
|
|
||||||
const { controller, errors } = makeController();
|
|
||||||
|
|
||||||
controller.setState("processing");
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "error",
|
|
||||||
code: "STT_START_FAILED",
|
|
||||||
message: "Provider unavailable",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "closed");
|
assert.equal(controller.state, "closed");
|
||||||
assert.equal(errors.length, 1);
|
assert.equal(errors.length, 1);
|
||||||
assert.match(
|
|
||||||
errors[0].message,
|
|
||||||
/STT_START_FAILED/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
test("turn.ended waits for playback before listening", async () => {
|
|
||||||
const { controller } = makeController();
|
|
||||||
|
|
||||||
let resolvePlayback;
|
|
||||||
|
|
||||||
controller.player = {
|
|
||||||
finish() {},
|
|
||||||
reset() {},
|
|
||||||
stop() {},
|
|
||||||
waitForCompletion() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
resolvePlayback = resolve;
|
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
test("backpressure closes the session before queuing more PCM", async () => {
|
||||||
controller.setState("processing");
|
const { controller, socket, errors } = await setup({ maxBufferedBytes: 8 });
|
||||||
|
socket.bufferedAmount = 8;
|
||||||
let resumed = false;
|
controller.handlePCM(new ArrayBuffer(4));
|
||||||
|
|
||||||
controller.startListening = () => {
|
|
||||||
resumed = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.ended",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
assert.equal(resumed, false);
|
|
||||||
|
|
||||||
resolvePlayback();
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
assert.equal(resumed, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closing during playback prevents listening resume", async () => {
|
|
||||||
const { controller } = makeController();
|
|
||||||
|
|
||||||
let resolvePlayback;
|
|
||||||
|
|
||||||
controller.player = {
|
|
||||||
finish() {},
|
|
||||||
reset() {},
|
|
||||||
stop() {},
|
|
||||||
waitForCompletion() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
resolvePlayback = resolve;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("processing");
|
|
||||||
|
|
||||||
let resumed = false;
|
|
||||||
|
|
||||||
controller.startListening = () => {
|
|
||||||
resumed = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.ended",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
controller.close();
|
|
||||||
|
|
||||||
resolvePlayback();
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
assert.equal(resumed, false);
|
|
||||||
assert.equal(controller.state, "closed");
|
assert.equal(controller.state, "closed");
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
assert.equal(messages(socket).some((m) => m.type === "audio.input"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("binary audio is forwarded to PCMPlayer", async () => {
|
test("invalid PCM closes the session without emitting metadata", async () => {
|
||||||
const { controller } = makeController();
|
const { controller, socket, errors } = await setup();
|
||||||
|
controller.handlePCM(new ArrayBuffer(3));
|
||||||
const received = [];
|
assert.equal(errors.length, 1);
|
||||||
|
assert.equal(messages(socket).some((m) => m.type === "audio.input"), false);
|
||||||
controller.player = {
|
|
||||||
async enqueue(buffer) {
|
|
||||||
received.push(buffer);
|
|
||||||
},
|
|
||||||
finish() {},
|
|
||||||
reset() {},
|
|
||||||
stop() {},
|
|
||||||
waitForCompletion: async () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("processing");
|
|
||||||
|
|
||||||
const pcm = new ArrayBuffer(8);
|
|
||||||
|
|
||||||
controller.handleAudioChunk(pcm);
|
|
||||||
|
|
||||||
await controller.playbackPending;
|
|
||||||
|
|
||||||
assert.deepEqual(received, [pcm]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("close stops capture and playback even if session.end cannot be sent", async () => {
|
||||||
|
const { controller, socket, context } = await setup();
|
||||||
test("barge-in stops playback and starts a new turn", () => {
|
const worklet = controller.worklet;
|
||||||
const { controller } = makeController();
|
socket.receive({ type: "turn.started", turn_id: 1 });
|
||||||
|
|
||||||
let playbackStopped = false;
|
|
||||||
|
|
||||||
controller.player = {
|
|
||||||
stop() {
|
|
||||||
playbackStopped = true;
|
|
||||||
},
|
|
||||||
reset() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("speaking");
|
|
||||||
|
|
||||||
// Simulate user speech detected during TTS.
|
|
||||||
controller.requestBargeIn();
|
|
||||||
|
|
||||||
assert.equal(playbackStopped, true);
|
|
||||||
assert.equal(controller.state, "interrupting");
|
|
||||||
assert.equal(controller.bargeInPending, true);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(controller.ws.sent[0]),
|
|
||||||
{
|
|
||||||
type: "interrupt",
|
|
||||||
turn_id: 1,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Gateway confirms the previous turn was cancelled.
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "interrupted",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "starting");
|
|
||||||
assert.equal(controller.ws.closed, false);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(controller.ws.sent[1]),
|
|
||||||
{
|
|
||||||
type: "audio.start",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Gateway accepts the new turn.
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.started",
|
|
||||||
turn_id: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.turnId, 2);
|
|
||||||
assert.equal(controller.state, "recording");
|
|
||||||
assert.equal(controller.bargeInPending, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
test("barge-in preserves PCM until new turn starts", () => {
|
|
||||||
const { controller } = makeController();
|
|
||||||
|
|
||||||
controller.player = {
|
|
||||||
stop() {},
|
|
||||||
reset() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("speaking");
|
|
||||||
|
|
||||||
const pcm1 = new ArrayBuffer(4);
|
|
||||||
const pcm2 = new ArrayBuffer(6);
|
|
||||||
const pcm3 = new ArrayBuffer(8);
|
|
||||||
|
|
||||||
// Audio captured before speech confirmation.
|
|
||||||
controller.handlePCM(pcm1);
|
|
||||||
|
|
||||||
controller.requestBargeIn();
|
|
||||||
|
|
||||||
// Audio captured while interruption is pending.
|
|
||||||
controller.handlePCM(pcm2);
|
|
||||||
|
|
||||||
assert.equal(controller.ws.sent.length, 1);
|
|
||||||
assert.equal(controller.bargeInBuffer.length, 2);
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "interrupted",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Still no PCM sent to the old turn.
|
|
||||||
assert.equal(controller.state, "starting");
|
|
||||||
assert.equal(controller.ws.sent.length, 2);
|
|
||||||
|
|
||||||
// Additional speech arrives before turn.started.
|
|
||||||
controller.handlePCM(pcm3);
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.started",
|
|
||||||
turn_id: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sent = controller.ws.sent;
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(sent[0]),
|
|
||||||
{ type: "interrupt", turn_id: 1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(sent[1]),
|
|
||||||
{ type: "audio.start" }
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(sent[2]),
|
|
||||||
{
|
|
||||||
type: "audio.chunk",
|
|
||||||
turn_id: 2,
|
|
||||||
sequence_number: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(sent[3], pcm1);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(sent[4]),
|
|
||||||
{
|
|
||||||
type: "audio.chunk",
|
|
||||||
turn_id: 2,
|
|
||||||
sequence_number: 1,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(sent[5], pcm2);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(sent[6]),
|
|
||||||
{
|
|
||||||
type: "audio.chunk",
|
|
||||||
turn_id: 2,
|
|
||||||
sequence_number: 2,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(sent[7], pcm3);
|
|
||||||
|
|
||||||
assert.equal(controller.sequence, 3);
|
|
||||||
assert.equal(controller.state, "recording");
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
test("barge-in finishes speech before new turn starts", () => {
|
|
||||||
const { controller, workletMessages } = makeController();
|
|
||||||
|
|
||||||
controller.player = {
|
|
||||||
stop() {},
|
|
||||||
reset() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("speaking");
|
|
||||||
|
|
||||||
const pcm = new ArrayBuffer(8);
|
|
||||||
|
|
||||||
controller.handlePCM(pcm);
|
|
||||||
controller.requestBargeIn();
|
|
||||||
|
|
||||||
// User finishes speaking before the Gateway responds.
|
|
||||||
controller.finishBargeInSpeech();
|
|
||||||
|
|
||||||
assert.equal(controller.bargeInSpeechEnded, true);
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "interrupted",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "starting");
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.started",
|
|
||||||
turn_id: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Buffered audio must be transmitted before flushing.
|
|
||||||
assert.equal(controller.sequence, 1);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
JSON.parse(controller.ws.sent[2]),
|
|
||||||
{
|
|
||||||
type: "audio.chunk",
|
|
||||||
turn_id: 2,
|
|
||||||
sequence_number: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(controller.ws.sent[3], pcm);
|
|
||||||
assert.equal(controller.state, "flushing");
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
workletMessages.at(-1),
|
|
||||||
{ type: "flush" }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
test("barge-in continues recording until speech ends", () => {
|
|
||||||
const { controller, workletMessages } = makeController();
|
|
||||||
|
|
||||||
controller.player = {
|
|
||||||
stop() {},
|
|
||||||
reset() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("speaking");
|
|
||||||
|
|
||||||
controller.bargeInVad.start();
|
|
||||||
controller.requestBargeIn();
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "interrupted",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.started",
|
|
||||||
turn_id: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "recording");
|
|
||||||
assert.equal(controller.bargeInRecording, true);
|
|
||||||
assert.equal(controller.bargeInVad.active, true);
|
|
||||||
|
|
||||||
// Speech continues after the new turn starts.
|
|
||||||
const pcm = new ArrayBuffer(8);
|
|
||||||
controller.handlePCM(pcm);
|
|
||||||
|
|
||||||
assert.equal(controller.sequence, 1);
|
|
||||||
|
|
||||||
// Barge-in VAD detects the end of speech.
|
|
||||||
controller.finishBargeInSpeech();
|
|
||||||
|
|
||||||
assert.equal(controller.bargeInRecording, false);
|
|
||||||
assert.equal(controller.state, "flushing");
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
workletMessages.at(-1),
|
|
||||||
{ type: "flush" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Worklet confirms that remaining PCM was emitted.
|
|
||||||
controller.handleFlushed();
|
|
||||||
|
|
||||||
assert.equal(controller.state, "processing");
|
|
||||||
|
|
||||||
const messages = controller.ws.sent
|
|
||||||
.filter((message) => typeof message === "string")
|
|
||||||
.map((message) => JSON.parse(message));
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
messages.some(
|
|
||||||
(message) =>
|
|
||||||
message.type === "audio.stop" &&
|
|
||||||
message.turn_id === 2
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
test("late INVALID_TURN recovers barge-in session", () => {
|
|
||||||
const { controller } = makeController();
|
|
||||||
|
|
||||||
controller.player = {
|
|
||||||
stop() {},
|
|
||||||
reset() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("speaking");
|
|
||||||
|
|
||||||
controller.requestBargeIn();
|
|
||||||
|
|
||||||
assert.equal(controller.state, "interrupting");
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "error",
|
|
||||||
code: "INVALID_TURN",
|
|
||||||
message: "Turn 1 is no longer active.",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "starting");
|
|
||||||
assert.equal(controller.ws.closed, false);
|
|
||||||
|
|
||||||
const messages = controller.ws.sent
|
|
||||||
.filter((message) => typeof message === "string")
|
|
||||||
.map((message) => JSON.parse(message));
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
messages.map((message) => message.type),
|
|
||||||
["interrupt", "audio.start"]
|
|
||||||
);
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.started",
|
|
||||||
turn_id: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "recording");
|
|
||||||
assert.equal(controller.turnId, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
test("late TTS chunk during barge-in does not close session", () => {
|
|
||||||
const { controller } = makeController();
|
|
||||||
|
|
||||||
let stopped = false;
|
|
||||||
|
|
||||||
controller.player = {
|
|
||||||
stop() {
|
|
||||||
stopped = true;
|
|
||||||
},
|
|
||||||
reset() {},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("speaking");
|
|
||||||
|
|
||||||
controller.requestBargeIn();
|
|
||||||
|
|
||||||
assert.equal(stopped, true);
|
|
||||||
assert.equal(controller.state, "interrupting");
|
|
||||||
|
|
||||||
// A late PCM chunk from the interrupted TTS.
|
|
||||||
controller.handleAudioChunk(new ArrayBuffer(8));
|
controller.handleAudioChunk(new ArrayBuffer(8));
|
||||||
|
await controller.playbackPending;
|
||||||
// The WebSocket session must remain alive.
|
socket.sendFailure = true;
|
||||||
assert.equal(controller.ws.closed, false);
|
controller.close();
|
||||||
assert.equal(controller.state, "interrupting");
|
assert.equal(controller.state, "closed");
|
||||||
assert.equal(controller.bargeInPending, true);
|
assert.equal(worklet.disconnected, true);
|
||||||
|
assert.deepEqual(worklet.port.messages.at(-1), { type: "stop" });
|
||||||
|
assert.equal(context.sources[0].stopped, true);
|
||||||
|
assert.equal(socket.readyState, Socket.CLOSED);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("closing during worklet loading prevents orphan microphone graphs and sockets", async () => {
|
||||||
test("barge-in resets player before new TTS response", () => {
|
let release;
|
||||||
const { controller } = makeController();
|
const context = new Context();
|
||||||
|
context.audioWorklet.addModule = () => new Promise((resolve) => { release = resolve; });
|
||||||
let finished = false;
|
const controller = new VoiceSessionController();
|
||||||
let resetCount = 0;
|
controllers.push(controller);
|
||||||
|
const starting = controller.start({ stream: {}, audioContext: context });
|
||||||
controller.player = {
|
controller.close();
|
||||||
stop() {
|
release();
|
||||||
finished = true;
|
await starting;
|
||||||
},
|
assert.equal(controller.ws, null);
|
||||||
|
assert.equal(context.input, undefined);
|
||||||
reset() {
|
|
||||||
finished = false;
|
|
||||||
resetCount++;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
controller.turnId = 1;
|
|
||||||
controller.setState("speaking");
|
|
||||||
|
|
||||||
controller.requestBargeIn();
|
|
||||||
|
|
||||||
assert.equal(finished, true);
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "interrupted",
|
|
||||||
turn_id: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "starting");
|
|
||||||
|
|
||||||
controller.handleGatewayEvent({
|
|
||||||
type: "turn.started",
|
|
||||||
turn_id: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.state, "recording");
|
|
||||||
assert.equal(finished, false);
|
|
||||||
assert.equal(resetCount, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("missing session.ready times out and closes the connection", async () => {
|
||||||
test("session.ready configures output before starting microphone capture", () => {
|
const { controller, errors } = await setup({ handshakeTimeoutMs: 5 }, false);
|
||||||
const { controller } = makeController();
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
const operations = [];
|
assert.equal(controller.state, "closed");
|
||||||
const format = { encoding: "pcm_s16le", sample_rate: 24000, channels: 1 };
|
assert.equal(errors.length, 1);
|
||||||
controller.player = { configure: (received) => operations.push(received) };
|
|
||||||
controller.startListening = () => operations.push("listen");
|
|
||||||
controller.handleGatewayEvent({ type: "session.ready", output_audio: format });
|
|
||||||
assert.deepEqual(operations, [format, "listen"]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
223
tests/test_continuous_gateway.py
Normal file
223
tests/test_continuous_gateway.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""Continuous PCM WebSocket mode delegates turn decisions to backend VAD."""
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from voice_assistant.agent.mock import MockAgentAdapter
|
||||||
|
from voice_assistant.audio_gateway.app import main
|
||||||
|
from voice_assistant.stt.mock import MockSTTAdapter
|
||||||
|
from voice_assistant.tts.mock import MockTTSAdapter
|
||||||
|
from voice_assistant.vad.adapter import VADAdapter
|
||||||
|
from voice_assistant.vad.models import SpeechStarted, SpeechEnded
|
||||||
|
|
||||||
|
|
||||||
|
class VAD(VADAdapter):
|
||||||
|
def __init__(self):
|
||||||
|
self.script = []
|
||||||
|
self.tail = ()
|
||||||
|
self.received = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def process(self, audio):
|
||||||
|
self.received.append(audio)
|
||||||
|
return self.script.pop(0) if self.script else ()
|
||||||
|
|
||||||
|
async def flush(self):
|
||||||
|
return self.tail
|
||||||
|
|
||||||
|
async def reset(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class STT(MockSTTAdapter):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.received = []
|
||||||
|
|
||||||
|
async def send_audio(self, audio):
|
||||||
|
await super().send_audio(audio)
|
||||||
|
self.received.append(audio)
|
||||||
|
|
||||||
|
async def stop_session(self):
|
||||||
|
await self.emit_final('Pergunta de teste')
|
||||||
|
await super().stop_session()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def components(monkeypatch):
|
||||||
|
detector = VAD()
|
||||||
|
providers = []
|
||||||
|
|
||||||
|
def create_stt(**kwargs):
|
||||||
|
stt = STT()
|
||||||
|
providers.append(stt)
|
||||||
|
return stt
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, 'create_vad_adapter', lambda **kwargs: detector)
|
||||||
|
monkeypatch.setattr(main, 'create_stt_adapter', create_stt)
|
||||||
|
monkeypatch.setattr(main, 'create_agent_adapter', lambda **kwargs: MockAgentAdapter())
|
||||||
|
monkeypatch.setattr(main, 'create_tts_adapter', lambda **kwargs: MockTTSAdapter())
|
||||||
|
return detector, providers
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect(mode='server'):
|
||||||
|
with TestClient(main.app).websocket_connect('/ws/audio') as socket:
|
||||||
|
socket.send_json({'type': 'session.start', 'turn_detection': mode})
|
||||||
|
ready = socket.receive_json()
|
||||||
|
assert ready['type'] == 'session.ready'
|
||||||
|
yield socket, ready
|
||||||
|
|
||||||
|
|
||||||
|
def send_audio(socket, sequence, audio):
|
||||||
|
socket.send_json({'type': 'audio.input', 'sequence_number': sequence})
|
||||||
|
socket.send_bytes(audio)
|
||||||
|
|
||||||
|
|
||||||
|
def read_turn(socket):
|
||||||
|
events, audio = [], []
|
||||||
|
for _ in range(20):
|
||||||
|
message = socket.receive()
|
||||||
|
if message.get('bytes') is not None:
|
||||||
|
audio.append(message['bytes'])
|
||||||
|
else:
|
||||||
|
import json
|
||||||
|
event = json.loads(message['text'])
|
||||||
|
assert event['type'] != 'error', event
|
||||||
|
events.append(event)
|
||||||
|
if event['type'] == 'turn.ended':
|
||||||
|
return events, audio
|
||||||
|
raise AssertionError('Turn did not complete within expected events')
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuous_mode_receives_audio_before_first_turn(components):
|
||||||
|
detector, providers = components
|
||||||
|
with connect() as (socket, ready):
|
||||||
|
assert ready['turn_detection'] == 'server'
|
||||||
|
assert ready['max_audio_chunk_bytes'] == 65536
|
||||||
|
send_audio(socket, 0, bytes(512))
|
||||||
|
socket.send_json({'type': 'session.end'})
|
||||||
|
assert socket.receive_json()['type'] == 'session.ended'
|
||||||
|
assert detector.received == [bytes(512)]
|
||||||
|
assert providers == []
|
||||||
|
assert detector.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_vad_opens_and_closes_turn_and_input_sequence_survives_next_turn(components):
|
||||||
|
detector, providers = components
|
||||||
|
detector.script = [
|
||||||
|
(SpeechStarted(0),), (SpeechEnded(400, 'silence'),),
|
||||||
|
(), (SpeechStarted(900),),
|
||||||
|
]
|
||||||
|
detector.tail = (SpeechEnded(1024, 'end_of_input'),)
|
||||||
|
with connect() as (socket, ready):
|
||||||
|
send_audio(socket, 0, bytes(512))
|
||||||
|
assert socket.receive_json()['type'] == 'turn.started'
|
||||||
|
send_audio(socket, 1, bytes(512))
|
||||||
|
events, audio = read_turn(socket)
|
||||||
|
assert events[-1]['turn_id'] == 1
|
||||||
|
assert any(event['type'] == 'stt.final' for event in events)
|
||||||
|
assert b''.join(audio) == b'\x01\x02\x03\x04\x05\x06\x07\x08'
|
||||||
|
send_audio(socket, 2, bytes(512))
|
||||||
|
send_audio(socket, 3, bytes(512))
|
||||||
|
assert socket.receive_json()['turn_id'] == 2
|
||||||
|
socket.send_json({'type': 'audio.input.end'})
|
||||||
|
events, audio = read_turn(socket)
|
||||||
|
assert events[-1]['turn_id'] == 2
|
||||||
|
assert audio
|
||||||
|
assert len(providers) == 2
|
||||||
|
assert len(detector.received) == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('sequence', [-1, True, 0.5, '0', 1, 2])
|
||||||
|
def test_bad_sequence_is_rejected_without_consuming_input(components, sequence):
|
||||||
|
detector, _ = components
|
||||||
|
with connect() as (socket, ready):
|
||||||
|
socket.send_json({'type': 'audio.input', 'sequence_number': sequence})
|
||||||
|
error = socket.receive_json()
|
||||||
|
assert error['code'] == 'AUDIO_SEQUENCE_ERROR'
|
||||||
|
assert error['details']['expected_sequence_number'] == 0
|
||||||
|
send_audio(socket, 0, bytes(512))
|
||||||
|
socket.send_json({'type': 'session.end'})
|
||||||
|
assert socket.receive_json()['type'] == 'session.ended'
|
||||||
|
assert len(detector.received) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(('audio', 'code'), [
|
||||||
|
(b'', 'AUDIO_SEQUENCE_ERROR'), (bytes(3), 'AUDIO_SEQUENCE_ERROR'),
|
||||||
|
(bytes(6), 'MESSAGE_TOO_LARGE'),
|
||||||
|
])
|
||||||
|
def test_bad_pcm_does_not_advance_global_sequence(components, monkeypatch, audio, code):
|
||||||
|
detector, _ = components
|
||||||
|
config = main.app_config.model_copy(update={'voice_input': {'max_input_chunk_bytes': 4}})
|
||||||
|
monkeypatch.setattr(main, 'app_config', config)
|
||||||
|
with connect() as (socket, ready):
|
||||||
|
assert ready['max_audio_chunk_bytes'] == 4
|
||||||
|
send_audio(socket, 0, audio)
|
||||||
|
assert socket.receive_json()['code'] == code
|
||||||
|
send_audio(socket, 0, bytes(4))
|
||||||
|
socket.send_json({'type': 'session.end'})
|
||||||
|
assert socket.receive_json()['type'] == 'session.ended'
|
||||||
|
assert detector.received == [bytes(4)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_metadata_does_not_overwrite_pending_pcm(components):
|
||||||
|
detector, _ = components
|
||||||
|
with connect() as (socket, ready):
|
||||||
|
socket.send_json({'type': 'audio.input', 'sequence_number': 0})
|
||||||
|
socket.send_json({'type': 'audio.input', 'sequence_number': 1})
|
||||||
|
assert socket.receive_json()['code'] == 'AUDIO_SEQUENCE_ERROR'
|
||||||
|
socket.send_bytes(bytes(512))
|
||||||
|
socket.send_json({'type': 'session.end'})
|
||||||
|
assert socket.receive_json()['type'] == 'session.ended'
|
||||||
|
assert len(detector.received) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_binary_frame_requires_metadata(components):
|
||||||
|
with connect() as (socket, ready):
|
||||||
|
socket.send_bytes(bytes(512))
|
||||||
|
assert socket.receive_json()['code'] == 'INVALID_AUDIO_FRAME'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('event', [
|
||||||
|
{'type': 'audio.start'}, {'type': 'audio.stop', 'turn_id': 1},
|
||||||
|
{'type': 'audio.chunk', 'turn_id': 1, 'sequence_number': 0},
|
||||||
|
])
|
||||||
|
def test_client_turn_commands_are_rejected_in_server_mode(components, event):
|
||||||
|
with connect() as (socket, ready):
|
||||||
|
socket.send_json(event)
|
||||||
|
assert socket.receive_json()['code'] == 'TURN_CONTROLLED_BY_VAD'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('event', [
|
||||||
|
{'type': 'audio.input', 'sequence_number': 0}, {'type': 'audio.input.end'},
|
||||||
|
])
|
||||||
|
def test_manual_mode_rejects_continuous_input(components, event):
|
||||||
|
with connect('client') as (socket, ready):
|
||||||
|
assert ready['turn_detection'] == 'client'
|
||||||
|
socket.send_json(event)
|
||||||
|
assert socket.receive_json()['code'] == 'INVALID_INPUT_MODE'
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_end_cannot_overtake_pending_binary_frame(components):
|
||||||
|
with connect() as (socket, ready):
|
||||||
|
socket.send_json({'type': 'audio.input', 'sequence_number': 0})
|
||||||
|
socket.send_json({'type': 'audio.input.end'})
|
||||||
|
assert socket.receive_json()['code'] == 'INVALID_MESSAGE'
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialization_failure_is_reported_and_can_be_followed_by_manual_session(components, monkeypatch):
|
||||||
|
def unavailable(**kwargs):
|
||||||
|
raise ValueError('VAD unavailable')
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, 'create_vad_adapter', unavailable)
|
||||||
|
with TestClient(main.app).websocket_connect('/ws/audio') as socket:
|
||||||
|
socket.send_json({'type': 'session.start', 'turn_detection': 'server'})
|
||||||
|
assert socket.receive_json()['code'] == 'SESSION_START_FAILED'
|
||||||
|
socket.send_json({'type': 'session.start'})
|
||||||
|
assert socket.receive_json()['type'] == 'session.ready'
|
||||||
@@ -95,6 +95,8 @@ def test_session_ready_output():
|
|||||||
"sample_rate": 16000,
|
"sample_rate": 16000,
|
||||||
"channels": 1,
|
"channels": 1,
|
||||||
},
|
},
|
||||||
|
"turn_detection": "client",
|
||||||
|
"max_audio_chunk_bytes": 65536,
|
||||||
"output_audio": {
|
"output_audio": {
|
||||||
"encoding": "pcm_s16le",
|
"encoding": "pcm_s16le",
|
||||||
"sample_rate": 48000,
|
"sample_rate": 48000,
|
||||||
|
|||||||
@@ -32,3 +32,13 @@ def test_web_client_audio_worklet():
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "registerProcessor" in response.text
|
assert "registerProcessor" in response.text
|
||||||
assert "pcm-input" in response.text
|
assert "pcm-input" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_web_client_uses_continuous_server_detection():
|
||||||
|
response = client.get("/web/voice-session.js")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'turn_detection: "server"' in response.text
|
||||||
|
assert 'type: "audio.input"' in response.text
|
||||||
|
assert "VoiceActivityDetector" not in response.text
|
||||||
|
assert client.get("/web/vad.js").status_code == 404
|
||||||
|
|||||||
Reference in New Issue
Block a user