refactor(voice): defer speech confirmation and add diagnostics

This commit is contained in:
Mohamed Boudra
2026-03-14 20:05:48 +07:00
parent cad4110e24
commit d1632d80e3
13 changed files with 223 additions and 790 deletions
-581
View File
@@ -1,581 +0,0 @@
# Local Voice Turn Detection Plan
## Goal
For local realtime voice, the client should stream PCM continuously to the server. The server should own turn detection using bundled Sherpa + Silero VAD. Client-side logic stays only for meter/activation visuals and is not authoritative for turn boundaries.
The server should start buffering immediately so we do not clip the beginning of speech.
## Scope
This plan is intentionally local-only for now.
- Turn detection: local only
- STT: local only
- TTS: unchanged
- Client VAD: removed as an authority, retained only as UI metering/activation
## Design Summary
### Boundaries
- Client
- Captures PCM
- Streams fixed-size chunks continuously during voice mode
- Shows volume meter / activation visuals
- Does not decide turn start or turn end
- Server turn detection
- Consumes live PCM stream
- Runs local Silero VAD through Sherpa
- Emits normalized events:
- `speech_started`
- `speech_stopped`
- Server voice turn controller
- Owns barge-in
- Owns rolling pre-speech buffer
- Owns utterance lifecycle
- Feeds utterance audio into the existing STT path
- Server STT
- Transcribes utterance audio selected by turn detection
## Core Principle
Do not hide VAD inside STT.
If STT owns VAD, the abstraction gets coupled again. Turn detection and transcription are different responsibilities and should be modeled separately.
Bad shape:
```ts
interface SpeechToTextProvider {
createSession(): {
appendPcm16(chunk: Buffer): void;
commit(): void;
on(event: "speech_started" | "speech_stopped" | "transcript", handler: unknown): unknown;
};
}
```
Better shape:
```ts
interface TurnDetectionProvider {
createSession(input: {
logger: pino.Logger;
}): TurnDetectionSession;
}
interface TurnDetectionSession {
requiredSampleRate: number;
connect(): Promise<void>;
appendPcm16(chunk: Buffer): void;
close(): void;
on(event: "speech_started", handler: () => void): unknown;
on(event: "speech_stopped", handler: () => void): unknown;
on(event: "error", handler: (err: unknown) => void): unknown;
}
```
And keep STT separate:
```ts
interface SpeechToTextProvider {
id: "local" | "openai" | (string & {});
createSession(input: {
logger: pino.Logger;
language?: string;
prompt?: string;
}): StreamingTranscriptionSession;
}
```
## Config Shape
Even in a local-only rollout, the boundary should be explicit now so it does not need a second refactor later.
Current shape:
```ts
type RequestedSpeechProviders = {
dictationStt: RequestedSpeechProvider;
voiceStt: RequestedSpeechProvider;
voiceTts: RequestedSpeechProvider;
};
```
Proposed shape:
```ts
type RequestedSpeechProviders = {
dictationStt: RequestedSpeechProvider;
voiceTurnDetection: RequestedSpeechProvider;
voiceStt: RequestedSpeechProvider;
voiceTts: RequestedSpeechProvider;
};
```
For the first implementation, resolution can still force local:
```ts
const providers = {
dictationStt: { provider: "local", explicit: false, enabled: true },
voiceTurnDetection: { provider: "local", explicit: false, enabled: true },
voiceStt: { provider: "local", explicit: false, enabled: true },
voiceTts: { provider: "local", explicit: false, enabled: true },
};
```
## Runtime Shape
### 1. Local Turn Detection Provider
Add a local provider parallel to local STT/TTS runtime wiring.
Suggested files:
- `packages/server/src/server/speech/turn-detection-provider.ts`
- `packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts`
- `packages/server/src/server/speech/providers/local/sherpa/silero-vad-provider.ts`
Expanded local runtime output:
```ts
interface InitializedLocalSpeech {
sttService: SpeechToTextProvider | null;
ttsService: TextToSpeechProvider | null;
dictationSttService: SpeechToTextProvider | null;
turnDetectionService: TurnDetectionProvider | null;
cleanup: () => void;
}
```
Provider construction in local runtime:
```ts
if (
providers.voiceTurnDetection.enabled !== false &&
providers.voiceTurnDetection.provider === "local"
) {
turnDetectionService = new SherpaSileroTurnDetectionProvider(
{
modelPath: bundledSileroModelPath,
sampleRate: 16000,
},
logger
);
}
```
### 2. Voice Turn Controller
This should sit in the server session layer and own the utterance state machine.
Suggested file:
- `packages/server/src/server/voice/voice-turn-controller.ts`
Responsibilities:
- receive continuous PCM chunks
- write chunks into a rolling pre-speech buffer
- feed chunks into detector
- on `speech_started`
- perform barge-in
- start a new utterance
- flush pre-speech buffer into utterance
- while speech is active
- append live chunks into utterance
- on `speech_stopped`
- finalize utterance into STT
Public shape:
```ts
interface VoiceTurnController {
start(): Promise<void>;
stop(): Promise<void>;
appendClientChunk(input: {
audioBase64: string;
format: string;
}): Promise<void>;
}
```
Internal state should be explicit:
```ts
type VoiceInputState =
| { status: "idle" }
| {
status: "listening";
rollingPrefixBytes: number;
}
| {
status: "capturing";
utteranceId: string;
rollingPrefixBytes: number;
utteranceBytes: number;
};
```
That is better than several booleans like `speechInProgress`, `hasStarted`, or `awaitingStop`.
## Immediate Buffering Strategy
Because buffering starts immediately, the detector does not decide whether audio is kept at all. It decides when buffered audio becomes an utterance.
### Rolling Prefix Buffer
Maintain a small ring buffer of recent PCM before speech start.
Suggested defaults:
- sample rate: `16000`
- mono PCM16
- prefix duration: `300ms` to `500ms`
Example utility:
```ts
interface RollingPcmBuffer {
append(chunk: Buffer): void;
drain(): Buffer;
clear(): void;
}
```
Example implementation shape:
```ts
class FixedDurationPcmRingBuffer {
private chunks: Buffer[] = [];
private totalBytes = 0;
constructor(private readonly maxBytes: number) {}
append(chunk: Buffer): void {
if (chunk.length === 0) {
return;
}
this.chunks.push(chunk);
this.totalBytes += chunk.length;
while (this.totalBytes > this.maxBytes && this.chunks.length > 0) {
const removed = this.chunks.shift()!;
this.totalBytes -= removed.length;
}
}
drain(): Buffer {
return Buffer.concat(this.chunks);
}
clear(): void {
this.chunks = [];
this.totalBytes = 0;
}
}
```
### Flow
Per incoming chunk:
```ts
prefixBuffer.append(chunk);
detector.appendPcm16(chunk);
if (state.status === "capturing") {
utteranceChunks.push(chunk);
}
```
On speech start:
```ts
async function onSpeechStarted(): Promise<void> {
await interruptPlaybackAndAgentRun();
const prefix = prefixBuffer.drain();
const utteranceChunks = prefix.length > 0 ? [prefix] : [];
state = {
status: "capturing",
utteranceId: uuidv4(),
rollingPrefixBytes: prefix.length,
utteranceBytes: prefix.length,
};
activeUtteranceChunks = utteranceChunks;
}
```
On speech stop:
```ts
async function onSpeechStopped(): Promise<void> {
if (state.status !== "capturing") {
return;
}
const pcm16 = Buffer.concat(activeUtteranceChunks);
await submitUtterance({
pcm16,
sampleRate: 16000,
format: "audio/pcm;rate=16000;bits=16",
});
activeUtteranceChunks = [];
state = {
status: "listening",
rollingPrefixBytes: 0,
};
}
```
## STT Integration
Do not make the turn controller talk to STT one chunk at a time unless there is a strong need.
For the first version, the simplest shape is:
- detector runs on the continuous stream
- utterance audio is accumulated in memory
- on stop, the utterance goes through the existing internal voice dictation path
That minimizes refactor risk and keeps the first migration focused on authority and layering.
Boundary:
```ts
interface VoiceUtteranceSink {
submitUtterance(input: {
pcm16: Buffer;
sampleRate: number;
format: string;
}): Promise<void>;
}
```
Then `session.ts` can implement that sink using the existing voice dictation internals.
## Session Refactor
Current problem:
- raw `voice_audio_chunk` handling triggers speech-start behavior too early
- the client remains the authority for `isLast`
Target shape:
```ts
private async handleAudioChunk(
msg: Extract<SessionInboundMessage, { type: "voice_audio_chunk" }>
): Promise<void> {
if (this.isVoiceMode) {
await this.voiceTurnController.appendClientChunk({
audioBase64: msg.audio,
format: msg.format || "audio/pcm;rate=16000;bits=16",
});
return;
}
// existing non-voice path remains
}
```
And the controller calls back into the session:
```ts
interface VoiceTurnControllerCallbacks {
onSpeechStarted(): Promise<void>;
onUtteranceReady(input: {
pcm16: Buffer;
sampleRate: number;
format: string;
}): Promise<void>;
onError(error: Error): void;
}
```
Session remains responsible for:
- aborting agent/TTS
- submitting transcript text to the agent
- emitting logs
The controller is responsible for:
- audio and detector state
- prefix buffering
- utterance assembly
## Client Refactor
The app should stop using `SpeechSegmenter` as authoritative segmentation in voice mode.
Instead, voice mode uses a continuous chunk sender:
```ts
interface ContinuousVoiceUploader {
appendPcmChunk(chunk: Uint8Array): void;
flush(): Promise<void>;
}
```
In `voice-runtime.ts`, the path becomes:
```ts
handleCapturePcm(chunk) {
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
return;
}
continuousUploader.appendPcmChunk(chunk);
}
```
And UI activation remains independent:
```ts
handleCaptureVolume(level) {
const nowMs = Date.now();
publishDisplayVolume(level, nowMs);
const isActive = level > DISPLAY_ACTIVE_THRESHOLD;
patchTelemetry((prev) => ({
...prev,
isSpeaking: isActive,
}));
}
```
The client should no longer use a “segmenter” abstraction for voice mode. The concept is now wrong for that layer.
## Bundling Silero
Silero VAD should be treated as a bundled runtime asset, not a user-downloaded speech model.
Implications:
- not listed in `paseo speech download`
- not part of user-facing missing-model readiness
- shipped with `packages/server`
Example:
```ts
function resolveBundledSileroVadModelPath(): string {
return fileURLToPath(
new URL("./assets/silero_vad.onnx", import.meta.url)
);
}
```
That keeps the model an implementation detail of local realtime voice infrastructure.
## What Not To Do In V1
- No semantic turn detection
- No mixed authorities
- No client-side fallback turn detection
- No detector logic buried inside dictation manager
- No detector-specific branching spread through session logic
If local VAD is the authority, then only local VAD decides `speech_started` and `speech_stopped`.
## Verification Plan
### Unit Tests
`silero-vad-session.test.ts`
- emits `speech_started` after speech frames
- emits `speech_stopped` after silence frames
- does not emit duplicate starts/stops
`fixed-duration-pcm-ring-buffer.test.ts`
- retains only configured prefix window
- drains prefix in correct order
`voice-turn-controller.test.ts`
- buffers before speech start
- includes prefix in utterance on start
- finalizes one utterance on stop
- does not barge in on silence-only chunks
### Integration Tests
`session.voice-turn-control.test.ts`
- continuous voice chunks do not immediately interrupt agent output
- detector start triggers barge-in
- detector stop triggers one finalized transcript submission
`voice-local-agent.e2e.test.ts`
- local voice mode works with continuous client upload
- leading words are preserved because prefix buffering is included
### Regression
- dictation stream tests remain unchanged
- TTS playback tests remain unchanged
- app voice UI tests still verify meter/activation behavior
## Ordered Implementation Plan
1. Add server-side turn detection interfaces and config type.
2. Add bundled local Silero provider via Sherpa.
3. Add rolling prefix buffer utility.
4. Add `VoiceTurnController`.
5. Refactor `session.ts` voice path to use controller callbacks instead of client `isLast`.
6. Replace app voice segmentation with continuous PCM upload.
7. Keep app meter/activation visuals only.
8. Add tests bottom-up.
9. Run `npm run typecheck`.
## Suggested Canonical Types
```ts
interface DetectedVoiceUtterance {
pcm16: Buffer;
sampleRate: number;
startedAt: number;
endedAt: number;
}
```
```ts
interface VoiceTurnControllerCallbacks {
onSpeechStarted(): Promise<void>;
onUtteranceReady(utterance: DetectedVoiceUtterance): Promise<void>;
onError(error: Error): void;
}
```
```ts
interface TurnDetectionProvider {
id: "local" | "openai" | (string & {});
createSession(input: {
logger: pino.Logger;
}): TurnDetectionSession;
}
```
## Recommendation
For v1, optimize for correctness and clean layering rather than minimum diff size:
- continuous upload from client
- bundled local Silero detector on server
- rolling prefix buffer
- detector-owned turn boundaries
- existing STT pipeline reused as utterance sink
That keeps responsibilities clear and avoids inventing a leaky “smart STT session” abstraction.