feat(voice): implement turn-detection and real-time audio streaming
This commit is contained in:
@@ -1648,7 +1648,11 @@ export class DaemonClient {
|
||||
return response
|
||||
}
|
||||
|
||||
async sendVoiceAudioChunk(audio: string, format: string, isLast: boolean): Promise<void> {
|
||||
async sendVoiceAudioChunk(
|
||||
audio: string,
|
||||
format: string,
|
||||
isLast = false
|
||||
): Promise<void> {
|
||||
this.sendSessionMessage({ type: 'voice_audio_chunk', audio, format, isLast })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import pino from "pino";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
@@ -74,7 +74,143 @@ describe("TTSManager", () => {
|
||||
);
|
||||
|
||||
expect(calls.length).toBeGreaterThan(1);
|
||||
expect(calls.every((text) => text.length <= 400)).toBe(true);
|
||||
expect(calls.every((text) => text.length <= 260)).toBe(true);
|
||||
expect(calls[0]!.length).toBeLessThanOrEqual(120);
|
||||
expect(calls.slice(1).some((text) => text.length > calls[0]!.length)).toBe(true);
|
||||
});
|
||||
|
||||
it("prefetches the next segment before current playback completes", async () => {
|
||||
const started: string[] = [];
|
||||
const gateResolvers = new Map<string, () => void>();
|
||||
const tts: TextToSpeechProvider = {
|
||||
async synthesizeSpeech(text: string): Promise<{ stream: Readable; format: string }> {
|
||||
started.push(text);
|
||||
await new Promise<void>((resolve) => {
|
||||
gateResolvers.set(text, resolve);
|
||||
});
|
||||
return {
|
||||
stream: Readable.from([Buffer.from(text)]),
|
||||
format: "pcm;rate=24000",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new TTSManager("s1", pino({ level: "silent" }), tts);
|
||||
const abort = new AbortController();
|
||||
const segments = [
|
||||
"One sentence that is long enough to stand alone in the first voice chunk.",
|
||||
"Two sentence that is also long enough to require a separate synthesized group.",
|
||||
"Three sentence that should only be synthesized after playback of the first group starts because it carries extra detail about the lookahead pipeline and should exceed the later packing target on its own.",
|
||||
];
|
||||
const text = segments.join(" ");
|
||||
const emittedGroups: string[] = [];
|
||||
let firstChunkId: string | null = null;
|
||||
let releaseSecondAck: (() => void) | null = null;
|
||||
|
||||
const task = manager.generateAndWaitForPlayback(
|
||||
text,
|
||||
(msg) => {
|
||||
if (msg.type !== "audio_output") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emittedGroups.includes(msg.payload.groupId!)) {
|
||||
emittedGroups.push(msg.payload.groupId!);
|
||||
}
|
||||
|
||||
if (emittedGroups.length === 1) {
|
||||
firstChunkId = msg.payload.id;
|
||||
return;
|
||||
}
|
||||
|
||||
releaseSecondAck?.();
|
||||
manager.confirmAudioPlayed(msg.payload.id);
|
||||
},
|
||||
abort.signal,
|
||||
true
|
||||
);
|
||||
|
||||
expect(started).toEqual([segments[0], segments[1]]);
|
||||
|
||||
gateResolvers.get(segments[0])?.();
|
||||
await vi.waitFor(() => {
|
||||
expect(firstChunkId).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(started).toEqual(segments);
|
||||
expect(emittedGroups).toHaveLength(1);
|
||||
|
||||
const secondGroupPlayed = new Promise<void>((resolve) => {
|
||||
releaseSecondAck = resolve;
|
||||
});
|
||||
|
||||
manager.confirmAudioPlayed(firstChunkId!);
|
||||
gateResolvers.get(segments[1])?.();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(started).toEqual(segments);
|
||||
expect(emittedGroups).toHaveLength(2);
|
||||
});
|
||||
|
||||
gateResolvers.get(segments[2])?.();
|
||||
await secondGroupPlayed;
|
||||
await task;
|
||||
});
|
||||
|
||||
it("destroys prefetched streams after abort", async () => {
|
||||
const destroyed: string[] = [];
|
||||
const tts: TextToSpeechProvider = {
|
||||
async synthesizeSpeech(text: string): Promise<{ stream: Readable; format: string }> {
|
||||
const stream = new Readable({
|
||||
read() {
|
||||
this.push(Buffer.from(text));
|
||||
this.push(null);
|
||||
},
|
||||
});
|
||||
const destroySpy = vi.spyOn(stream, "destroy").mockImplementation(function (
|
||||
this: Readable,
|
||||
error?: Error
|
||||
) {
|
||||
destroyed.push(text);
|
||||
return Readable.prototype.destroy.call(this, error);
|
||||
});
|
||||
void destroySpy;
|
||||
return {
|
||||
stream,
|
||||
format: "pcm;rate=24000",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new TTSManager("s1", pino({ level: "silent" }), tts);
|
||||
const abort = new AbortController();
|
||||
let firstChunkId: string | null = null;
|
||||
|
||||
const task = manager.generateAndWaitForPlayback(
|
||||
[
|
||||
"First sentence that is long enough to stand alone in the first synthesized group.",
|
||||
"Second sentence that should be prefetched and then discarded after abort is requested.",
|
||||
"Third sentence that should also be cleaned up if it was prefetched before the abort.",
|
||||
].join(" "),
|
||||
(msg) => {
|
||||
if (msg.type === "audio_output" && firstChunkId === null) {
|
||||
firstChunkId = msg.payload.id;
|
||||
}
|
||||
},
|
||||
abort.signal,
|
||||
true
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(firstChunkId).not.toBeNull();
|
||||
});
|
||||
|
||||
abort.abort();
|
||||
|
||||
await task;
|
||||
await vi.waitFor(() => {
|
||||
expect(destroyed.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit unhandled rejections when stream iteration fails", async () => {
|
||||
@@ -122,4 +258,31 @@ describe("TTSManager", () => {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores late confirmations for recently closed audio IDs", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const warnSpy = vi.spyOn(logger, "warn");
|
||||
const manager = new TTSManager("s1", logger, new FakeTts());
|
||||
const abort = new AbortController();
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
|
||||
await manager.generateAndWaitForPlayback(
|
||||
"hello",
|
||||
(msg) => {
|
||||
emitted.push(msg);
|
||||
if (msg.type === "audio_output") {
|
||||
manager.confirmAudioPlayed(msg.payload.id);
|
||||
}
|
||||
},
|
||||
abort.signal,
|
||||
true
|
||||
);
|
||||
|
||||
const firstAudio = emitted.find((msg) => msg.type === "audio_output");
|
||||
expect(firstAudio?.type).toBe("audio_output");
|
||||
|
||||
manager.confirmAudioPlayed((firstAudio as Extract<SessionOutboundMessage, { type: "audio_output" }>).payload.id);
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type pino from "pino";
|
||||
import type { Readable } from "node:stream";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { TextToSpeechProvider } from "../speech/speech-provider.js";
|
||||
import { toResolver, type Resolvable } from "../speech/provider-resolver.js";
|
||||
@@ -11,80 +12,114 @@ interface PendingPlayback {
|
||||
streamEnded: boolean;
|
||||
}
|
||||
|
||||
const MAX_TTS_SEGMENT_CHARS = 400;
|
||||
type TtsSegment = {
|
||||
index: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function splitTextForTts(text: string, maxChars: number): string[] {
|
||||
type PreparedTtsSegment = TtsSegment & {
|
||||
format: string;
|
||||
stream: Readable;
|
||||
};
|
||||
|
||||
type PreparedSegmentResult =
|
||||
| { kind: "prepared"; prepared: PreparedTtsSegment }
|
||||
| { kind: "aborted" }
|
||||
| { kind: "error"; error: unknown };
|
||||
|
||||
const MAX_TTS_SEGMENT_CHARS = 260;
|
||||
const TTS_PREFETCH_SEGMENTS = 2;
|
||||
const CLOSED_AUDIO_ID_TTL_MS = 10_000;
|
||||
|
||||
function splitOversizedFragment(fragment: string, maxChars: number): string[] {
|
||||
const trimmed = fragment.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (trimmed.length <= maxChars) {
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
const clauseChunks = trimmed.split(/(?<=[,;:])\s+/);
|
||||
if (clauseChunks.length > 1) {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
|
||||
const pushCurrent = () => {
|
||||
const value = current.trim();
|
||||
if (value) {
|
||||
parts.push(value);
|
||||
}
|
||||
current = "";
|
||||
};
|
||||
|
||||
for (const clause of clauseChunks) {
|
||||
const clauseText = clause.trim();
|
||||
if (!clauseText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (clauseText.length > maxChars) {
|
||||
pushCurrent();
|
||||
parts.push(...splitOversizedFragment(clauseText, maxChars));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
current = clauseText;
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = `${current} ${clauseText}`;
|
||||
if (candidate.length <= maxChars) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
pushCurrent();
|
||||
current = clauseText;
|
||||
}
|
||||
|
||||
pushCurrent();
|
||||
if (parts.length > 1 || parts[0] !== trimmed) {
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let remaining = trimmed;
|
||||
while (remaining.length > maxChars) {
|
||||
let idx = remaining.lastIndexOf(" ", maxChars);
|
||||
if (idx < Math.floor(maxChars * 0.5)) {
|
||||
idx = maxChars;
|
||||
}
|
||||
parts.push(remaining.slice(0, idx).trim());
|
||||
remaining = remaining.slice(idx).trim();
|
||||
}
|
||||
if (remaining.length > 0) {
|
||||
parts.push(remaining);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function splitTextForTts(text: string): TtsSegment[] {
|
||||
const normalized = text.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
throw new Error("Cannot synthesize empty text");
|
||||
}
|
||||
|
||||
if (normalized.length <= maxChars) {
|
||||
return [normalized];
|
||||
}
|
||||
const sentences = normalized.split(/(?<=[.!?])\s+/);
|
||||
const parts: TtsSegment[] = [];
|
||||
let segmentIndex = 0;
|
||||
|
||||
const parts: string[] = [];
|
||||
const sentenceChunks = normalized.split(/(?<=[.!?])\s+/);
|
||||
|
||||
let current = "";
|
||||
const pushCurrent = () => {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed.length > 0) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
current = "";
|
||||
};
|
||||
|
||||
const appendFragment = (fragment: string) => {
|
||||
const trimmed = fragment.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
current = trimmed;
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = `${current} ${trimmed}`;
|
||||
if (candidate.length <= maxChars) {
|
||||
current = candidate;
|
||||
return;
|
||||
}
|
||||
|
||||
pushCurrent();
|
||||
current = trimmed;
|
||||
};
|
||||
|
||||
const splitLargeFragment = (fragment: string): string[] => {
|
||||
const trimmed = fragment.trim();
|
||||
if (trimmed.length <= maxChars) {
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
let remaining = trimmed;
|
||||
while (remaining.length > maxChars) {
|
||||
let idx = remaining.lastIndexOf(" ", maxChars);
|
||||
if (idx < Math.floor(maxChars * 0.5)) {
|
||||
idx = maxChars;
|
||||
}
|
||||
out.push(remaining.slice(0, idx).trim());
|
||||
remaining = remaining.slice(idx).trim();
|
||||
}
|
||||
if (remaining.length > 0) {
|
||||
out.push(remaining);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
for (const sentence of sentenceChunks) {
|
||||
const fragments = splitLargeFragment(sentence);
|
||||
for (const sentence of sentences) {
|
||||
const fragments = splitOversizedFragment(sentence, MAX_TTS_SEGMENT_CHARS);
|
||||
for (const fragment of fragments) {
|
||||
appendFragment(fragment);
|
||||
parts.push({ index: segmentIndex, text: fragment });
|
||||
segmentIndex += 1;
|
||||
}
|
||||
}
|
||||
pushCurrent();
|
||||
|
||||
return parts;
|
||||
}
|
||||
@@ -95,6 +130,7 @@ function splitTextForTts(text: string, maxChars: number): string[] {
|
||||
*/
|
||||
export class TTSManager {
|
||||
private pendingPlaybacks: Map<string, PendingPlayback> = new Map();
|
||||
private readonly recentlyClosedAudioIds: Map<string, number> = new Map();
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly resolveTts: () => TextToSpeechProvider | null;
|
||||
|
||||
@@ -117,6 +153,7 @@ export class TTSManager {
|
||||
abortSignal: AbortSignal,
|
||||
isVoiceMode: boolean
|
||||
): Promise<void> {
|
||||
const ttsStartMs = Date.now();
|
||||
this.logger.info(
|
||||
{
|
||||
isVoiceMode,
|
||||
@@ -126,45 +163,176 @@ export class TTSManager {
|
||||
"TTS input text"
|
||||
);
|
||||
|
||||
const segments = splitTextForTts(text, MAX_TTS_SEGMENT_CHARS);
|
||||
for (const segment of segments) {
|
||||
if (abortSignal.aborted) {
|
||||
this.logger.debug("Aborted before generating segmented audio");
|
||||
return;
|
||||
}
|
||||
const segments = splitTextForTts(text);
|
||||
this.logger.info(
|
||||
{ segmentCount: segments.length, segments: segments.map((s) => ({ index: s.index, chars: s.text.length, text: s.text.slice(0, 80) })) },
|
||||
`TTS split into ${segments.length} segment(s)`
|
||||
);
|
||||
|
||||
await this.generateSegmentAndWaitForPlayback(
|
||||
segment,
|
||||
emitMessage,
|
||||
abortSignal,
|
||||
isVoiceMode
|
||||
const inflight = new Map<number, Promise<PreparedSegmentResult>>();
|
||||
let nextSegmentToSchedule = 0;
|
||||
|
||||
const scheduleNextSegments = () => {
|
||||
while (
|
||||
nextSegmentToSchedule < segments.length &&
|
||||
inflight.size < TTS_PREFETCH_SEGMENTS
|
||||
) {
|
||||
const segment = segments[nextSegmentToSchedule]!;
|
||||
inflight.set(segment.index, this.scheduleSegmentSynthesis(segment, abortSignal));
|
||||
nextSegmentToSchedule += 1;
|
||||
}
|
||||
};
|
||||
|
||||
scheduleNextSegments();
|
||||
|
||||
try {
|
||||
for (const segment of segments) {
|
||||
if (abortSignal.aborted) {
|
||||
this.logger.debug("Aborted before emitting segmented audio");
|
||||
return;
|
||||
}
|
||||
|
||||
const synthWaitStart = Date.now();
|
||||
const result = await inflight.get(segment.index)!;
|
||||
const synthWaitMs = Date.now() - synthWaitStart;
|
||||
inflight.delete(segment.index);
|
||||
scheduleNextSegments();
|
||||
|
||||
if (result.kind === "aborted") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.kind === "error") {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
{ segmentIndex: segment.index, synthWaitMs, totalElapsedMs: Date.now() - ttsStartMs, chars: segment.text.length },
|
||||
`TTS segment ${segment.index} synthesis ready (waited ${synthWaitMs}ms, total ${Date.now() - ttsStartMs}ms)`
|
||||
);
|
||||
|
||||
const emitStart = Date.now();
|
||||
await this.emitPreparedSegment({
|
||||
prepared: result.prepared,
|
||||
emitMessage,
|
||||
abortSignal,
|
||||
isVoiceMode,
|
||||
});
|
||||
this.logger.info(
|
||||
{ segmentIndex: segment.index, emitAndPlayMs: Date.now() - emitStart, totalElapsedMs: Date.now() - ttsStartMs },
|
||||
`TTS segment ${segment.index} playback confirmed (emit+play ${Date.now() - emitStart}ms, total ${Date.now() - ttsStartMs}ms)`
|
||||
);
|
||||
|
||||
scheduleNextSegments();
|
||||
}
|
||||
} finally {
|
||||
this.cleanupPrefetchedSegments(inflight);
|
||||
this.logger.info(
|
||||
{ totalMs: Date.now() - ttsStartMs },
|
||||
`TTS generateAndWaitForPlayback done (${Date.now() - ttsStartMs}ms)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateSegmentAndWaitForPlayback(
|
||||
text: string,
|
||||
emitMessage: (msg: SessionOutboundMessage) => void,
|
||||
abortSignal: AbortSignal,
|
||||
isVoiceMode: boolean
|
||||
): Promise<void> {
|
||||
private async synthesizeSegment(
|
||||
segment: TtsSegment,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<PreparedTtsSegment> {
|
||||
const resolveStart = Date.now();
|
||||
const tts = this.resolveTts();
|
||||
if (!tts) {
|
||||
throw new Error("TTS not configured");
|
||||
}
|
||||
const resolveMs = Date.now() - resolveStart;
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
this.logger.debug("Aborted before generating audio");
|
||||
throw new Error("TTS synthesis aborted");
|
||||
}
|
||||
|
||||
const synthStart = Date.now();
|
||||
const { stream, format } = await tts.synthesizeSpeech(segment.text);
|
||||
this.logger.info(
|
||||
{ segmentIndex: segment.index, resolveMs, synthMs: Date.now() - synthStart, chars: segment.text.length },
|
||||
`TTS segment ${segment.index} synthesized (resolve=${resolveMs}ms, synth=${Date.now() - synthStart}ms, ${segment.text.length} chars)`
|
||||
);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
this.destroySpeechStream(stream);
|
||||
throw new Error("TTS synthesis aborted");
|
||||
}
|
||||
|
||||
return {
|
||||
...segment,
|
||||
stream,
|
||||
format,
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleSegmentSynthesis(
|
||||
segment: TtsSegment,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<PreparedSegmentResult> {
|
||||
return this.synthesizeSegment(segment, abortSignal).then(
|
||||
(prepared) => {
|
||||
if (abortSignal.aborted) {
|
||||
this.destroySpeechStream(prepared.stream);
|
||||
return { kind: "aborted" };
|
||||
}
|
||||
return { kind: "prepared", prepared };
|
||||
},
|
||||
(error) => {
|
||||
if (abortSignal.aborted) {
|
||||
return { kind: "aborted" };
|
||||
}
|
||||
return { kind: "error", error };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private cleanupPrefetchedSegments(
|
||||
inflight: Map<number, Promise<PreparedSegmentResult>>
|
||||
): void {
|
||||
if (inflight.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate TTS audio stream
|
||||
const { stream, format } = await tts.synthesizeSpeech(text);
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
this.logger.debug("Aborted after generating audio");
|
||||
return;
|
||||
for (const pending of inflight.values()) {
|
||||
void pending.then((result) => {
|
||||
if (result.kind === "prepared") {
|
||||
this.destroySpeechStream(result.prepared.stream);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private destroySpeechStream(stream: Readable): void {
|
||||
if (typeof stream.destroy === "function" && !stream.destroyed) {
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private pruneRecentlyClosedAudioIds(now: number): void {
|
||||
for (const [audioId, expiresAt] of this.recentlyClosedAudioIds.entries()) {
|
||||
if (expiresAt <= now) {
|
||||
this.recentlyClosedAudioIds.delete(audioId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rememberClosedAudioId(audioId: string): void {
|
||||
const now = Date.now();
|
||||
this.pruneRecentlyClosedAudioIds(now);
|
||||
this.recentlyClosedAudioIds.set(audioId, now + CLOSED_AUDIO_ID_TTL_MS);
|
||||
}
|
||||
|
||||
private async emitPreparedSegment(params: {
|
||||
prepared: PreparedTtsSegment;
|
||||
emitMessage: (msg: SessionOutboundMessage) => void;
|
||||
abortSignal: AbortSignal;
|
||||
isVoiceMode: boolean;
|
||||
}): Promise<void> {
|
||||
const { prepared, emitMessage, abortSignal, isVoiceMode } = params;
|
||||
const { stream, format, text } = prepared;
|
||||
|
||||
const audioId = uuidv4();
|
||||
let playbackResolve!: () => void;
|
||||
@@ -185,72 +353,53 @@ export class TTSManager {
|
||||
this.pendingPlaybacks.set(audioId, pendingPlayback);
|
||||
|
||||
let onAbort: (() => void) | undefined;
|
||||
const destroyStream = () => {
|
||||
if (typeof stream.destroy === "function" && !stream.destroyed) {
|
||||
stream.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
onAbort = () => {
|
||||
this.logger.debug("Aborted while waiting for playback");
|
||||
pendingPlayback.streamEnded = true;
|
||||
pendingPlayback.pendingChunks = 0;
|
||||
this.pendingPlaybacks.delete(audioId);
|
||||
this.rememberClosedAudioId(audioId);
|
||||
playbackResolve();
|
||||
destroyStream();
|
||||
this.destroySpeechStream(stream);
|
||||
};
|
||||
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
let chunkIndex = 0;
|
||||
let current = await iterator.next();
|
||||
|
||||
if (!current.done) {
|
||||
let next = await iterator.next();
|
||||
|
||||
while (true) {
|
||||
if (abortSignal.aborted) {
|
||||
this.logger.debug("Aborted during stream emission");
|
||||
break;
|
||||
}
|
||||
|
||||
const chunkBuffer = Buffer.isBuffer(current.value)
|
||||
? current.value
|
||||
: Buffer.from(current.value);
|
||||
|
||||
const chunkId = `${audioId}:${chunkIndex}`;
|
||||
pendingPlayback.pendingChunks += 1;
|
||||
|
||||
emitMessage({
|
||||
type: "audio_output",
|
||||
payload: {
|
||||
id: chunkId,
|
||||
groupId: audioId,
|
||||
chunkIndex,
|
||||
isLastChunk: next.done,
|
||||
audio: chunkBuffer.toString("base64"),
|
||||
format,
|
||||
isVoiceMode,
|
||||
},
|
||||
});
|
||||
|
||||
chunkIndex += 1;
|
||||
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = next;
|
||||
next = await iterator.next();
|
||||
const buffers: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
if (abortSignal.aborted) {
|
||||
this.logger.debug("Aborted during stream collection");
|
||||
break;
|
||||
}
|
||||
buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
if (!abortSignal.aborted && buffers.length > 0) {
|
||||
const fullBuffer = Buffer.concat(buffers);
|
||||
const chunkId = `${audioId}:0`;
|
||||
pendingPlayback.pendingChunks = 1;
|
||||
|
||||
emitMessage({
|
||||
type: "audio_output",
|
||||
payload: {
|
||||
id: chunkId,
|
||||
groupId: audioId,
|
||||
chunkIndex: 0,
|
||||
isLastChunk: true,
|
||||
audio: fullBuffer.toString("base64"),
|
||||
format,
|
||||
isVoiceMode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pendingPlayback.streamEnded = true;
|
||||
|
||||
if (pendingPlayback.pendingChunks === 0) {
|
||||
this.pendingPlaybacks.delete(audioId);
|
||||
this.rememberClosedAudioId(audioId);
|
||||
playbackResolve();
|
||||
}
|
||||
|
||||
@@ -267,14 +416,14 @@ export class TTSManager {
|
||||
if (onAbort) {
|
||||
abortSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
destroyStream();
|
||||
this.destroySpeechStream(stream);
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug({ audioId }, "Audio playback confirmed");
|
||||
this.logger.debug({ audioId, textLength: text.length }, "Audio playback confirmed");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,6 +437,13 @@ export class TTSManager {
|
||||
const pending = this.pendingPlaybacks.get(audioId);
|
||||
|
||||
if (!pending) {
|
||||
const now = Date.now();
|
||||
this.pruneRecentlyClosedAudioIds(now);
|
||||
const expiresAt = this.recentlyClosedAudioIds.get(audioId);
|
||||
if (expiresAt && expiresAt > now) {
|
||||
this.logger.debug({ chunkId }, "Ignoring late confirmation for recently closed audio ID");
|
||||
return;
|
||||
}
|
||||
this.logger.warn({ chunkId }, "Received confirmation for unknown audio ID");
|
||||
return;
|
||||
}
|
||||
@@ -297,6 +453,7 @@ export class TTSManager {
|
||||
if (pending.pendingChunks === 0 && pending.streamEnded) {
|
||||
pending.resolve();
|
||||
this.pendingPlaybacks.delete(audioId);
|
||||
this.rememberClosedAudioId(audioId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +473,7 @@ export class TTSManager {
|
||||
for (const [audioId, pending] of this.pendingPlaybacks.entries()) {
|
||||
pending.resolve();
|
||||
this.pendingPlaybacks.delete(audioId);
|
||||
this.rememberClosedAudioId(audioId);
|
||||
this.logger.debug({ audioId }, "Cleared pending playback");
|
||||
}
|
||||
}
|
||||
@@ -328,6 +486,7 @@ export class TTSManager {
|
||||
for (const [audioId, pending] of this.pendingPlaybacks.entries()) {
|
||||
pending.reject(new Error("Session closed"));
|
||||
this.pendingPlaybacks.delete(audioId);
|
||||
this.rememberClosedAudioId(audioId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +512,7 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
});
|
||||
const {
|
||||
resolveVoiceTurnDetection,
|
||||
resolveVoiceStt,
|
||||
resolveVoiceTts,
|
||||
resolveDictationStt,
|
||||
@@ -536,7 +537,7 @@ export async function createPaseoDaemon(
|
||||
config.paseoHome,
|
||||
createInMemoryAgentMcpTransport,
|
||||
{ allowedOrigins, allowedHosts: config.allowedHosts },
|
||||
{ stt: resolveVoiceStt, tts: resolveVoiceTts },
|
||||
{ turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts },
|
||||
terminalManager,
|
||||
{
|
||||
voiceAgentMcpStdio: {
|
||||
|
||||
@@ -107,3 +107,19 @@ describe("PersistedConfigSchema logging config", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema voice mode config", () => {
|
||||
test("accepts a dedicated turn detection provider", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
voiceMode: {
|
||||
turnDetection: {
|
||||
provider: "local",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.features?.voiceMode?.turnDetection?.provider).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,12 @@ const FeatureVoiceModeSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
turnDetection: z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
tts: z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
|
||||
@@ -43,12 +43,17 @@ import {
|
||||
import { TTSManager } from './agent/tts-manager.js'
|
||||
import { STTManager } from './agent/stt-manager.js'
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from './speech/speech-provider.js'
|
||||
import type { TurnDetectionProvider } from './speech/turn-detection-provider.js'
|
||||
import { maybePersistTtsDebugAudio } from './agent/tts-debug.js'
|
||||
import { isPaseoDictationDebugEnabled } from './agent/recordings-debug.js'
|
||||
import {
|
||||
DictationStreamManager,
|
||||
type DictationStreamOutboundMessage,
|
||||
} from './dictation/dictation-stream-manager.js'
|
||||
import {
|
||||
createVoiceTurnController,
|
||||
type VoiceTurnController,
|
||||
} from './voice/voice-turn-controller.js'
|
||||
import { buildConfigOverrides, buildSessionConfig, extractTimestamps } from './persistence-hooks.js'
|
||||
import { experimental_createMCPClient } from 'ai'
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
@@ -159,7 +164,7 @@ import {
|
||||
listLocalSpeechModels,
|
||||
type LocalSpeechModelId,
|
||||
} from './speech/providers/local/models.js'
|
||||
import type { Resolvable } from './speech/provider-resolver.js'
|
||||
import { toResolver, type Resolvable } from './speech/provider-resolver.js'
|
||||
import type { SpeechReadinessSnapshot, SpeechReadinessState } from './speech/speech-runtime.js'
|
||||
import type pino from 'pino'
|
||||
import { resolveClientMessageId } from './client-message-id.js'
|
||||
@@ -331,8 +336,6 @@ const PCM_BITS_PER_SAMPLE = 16
|
||||
const PCM_BYTES_PER_MS = (PCM_SAMPLE_RATE * PCM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8)) / 1000
|
||||
const MIN_STREAMING_SEGMENT_DURATION_MS = 1000
|
||||
const MIN_STREAMING_SEGMENT_BYTES = Math.round(PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS)
|
||||
const VOICE_MODE_INACTIVITY_FLUSH_MS = 4500
|
||||
const VOICE_INTERNAL_DICTATION_ID_PREFIX = '__voice_turn__:'
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/
|
||||
const AgentIdSchema = z.string().uuid()
|
||||
const VOICE_MCP_SERVER_NAME = 'paseo_voice'
|
||||
@@ -380,6 +383,7 @@ export type SessionOptions = {
|
||||
terminalManager: TerminalManager | null
|
||||
voice?: {
|
||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null
|
||||
turnDetection?: Resolvable<TurnDetectionProvider | null>
|
||||
}
|
||||
voiceBridge?: {
|
||||
registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void
|
||||
@@ -528,26 +532,13 @@ export class Session {
|
||||
private speechInProgress = false
|
||||
|
||||
private readonly dictationStreamManager: DictationStreamManager
|
||||
private readonly voiceStreamManager: DictationStreamManager
|
||||
private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null
|
||||
private voiceTurnController: VoiceTurnController | null = null
|
||||
|
||||
// Audio buffering for interruption handling
|
||||
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []
|
||||
private bufferTimeout: NodeJS.Timeout | null = null
|
||||
private voiceModeInactivityTimeout: NodeJS.Timeout | null = null
|
||||
private audioBuffer: AudioBufferState | null = null
|
||||
private activeVoiceDictationId: string | null = null
|
||||
private activeVoiceDictationFormat: string | null = null
|
||||
private activeVoiceDictationNextSeq = 0
|
||||
private activeVoiceDictationStartPromise: Promise<void> | null = null
|
||||
private activeVoiceDictationFinalizePromise: Promise<void> | null = null
|
||||
private activeVoiceDictationResultPromise: Promise<{
|
||||
text: string
|
||||
debugRecordingPath?: string
|
||||
}> | null = null
|
||||
private activeVoiceDictationResolve:
|
||||
| ((value: { text: string; debugRecordingPath?: string }) => void)
|
||||
| null = null
|
||||
private activeVoiceDictationReject: ((error: Error) => void) | null = null
|
||||
|
||||
// Optional TTS debug capture (persisted per utterance)
|
||||
private readonly ttsDebugStreams = new Map<string, { format: string; chunks: Buffer[] }>()
|
||||
@@ -658,6 +649,7 @@ export class Session {
|
||||
)
|
||||
}
|
||||
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null
|
||||
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null)
|
||||
const configuredModelsDir = dictation?.localModels?.modelsDir?.trim()
|
||||
this.localSpeechModelsDir =
|
||||
configuredModelsDir && configuredModelsDir.length > 0
|
||||
@@ -695,13 +687,6 @@ export class Session {
|
||||
stt: dictation?.stt ?? null,
|
||||
finalTimeoutMs: dictation?.finalTimeoutMs,
|
||||
})
|
||||
this.voiceStreamManager = new DictationStreamManager({
|
||||
logger: this.sessionLogger.child({ stream: 'voice-internal' }),
|
||||
sessionId: this.sessionId,
|
||||
emit: (msg) => this.handleDictationManagerMessage(msg),
|
||||
stt: stt,
|
||||
finalTimeoutMs: dictation?.finalTimeoutMs,
|
||||
})
|
||||
|
||||
// Initialize agent MCP client asynchronously
|
||||
void this.initializeAgentMcp()
|
||||
@@ -2167,6 +2152,7 @@ export class Session {
|
||||
this.voiceModeAgentId = refreshedAgentId
|
||||
}
|
||||
|
||||
await this.startVoiceTurnController()
|
||||
this.isVoiceMode = true
|
||||
this.sessionLogger.info(
|
||||
{
|
||||
@@ -2303,8 +2289,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private async disableVoiceModeForActiveAgent(restoreAgentConfig: boolean): Promise<void> {
|
||||
this.clearVoiceModeInactivityTimeout()
|
||||
this.cancelActiveVoiceDictationStream('voice mode disabled')
|
||||
await this.stopVoiceTurnController()
|
||||
|
||||
const agentId = this.voiceModeAgentId
|
||||
if (!agentId) {
|
||||
@@ -2340,231 +2325,74 @@ export class Session {
|
||||
this.voiceModeAgentId = null
|
||||
}
|
||||
|
||||
private isInternalVoiceDictationId(dictationId: string): boolean {
|
||||
return dictationId.startsWith(VOICE_INTERNAL_DICTATION_ID_PREFIX)
|
||||
}
|
||||
|
||||
private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void {
|
||||
if (msg.type === 'activity_log') {
|
||||
const metadata = msg.payload.metadata as { dictationId?: unknown } | undefined
|
||||
const dictationId =
|
||||
metadata && typeof metadata.dictationId === 'string' ? metadata.dictationId : null
|
||||
if (dictationId && this.isInternalVoiceDictationId(dictationId)) {
|
||||
return
|
||||
}
|
||||
this.emit(msg as unknown as SessionOutboundMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const payloadWithDictationId = msg.payload as { dictationId?: unknown }
|
||||
const dictationId =
|
||||
payloadWithDictationId && typeof payloadWithDictationId.dictationId === 'string'
|
||||
? payloadWithDictationId.dictationId
|
||||
: null
|
||||
|
||||
if (!dictationId || !this.isInternalVoiceDictationId(dictationId)) {
|
||||
this.emit(msg as unknown as SessionOutboundMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'dictation_stream_final') {
|
||||
if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationResolve) {
|
||||
return
|
||||
}
|
||||
this.activeVoiceDictationResolve({
|
||||
text: msg.payload.text,
|
||||
...(msg.payload.debugRecordingPath
|
||||
? { debugRecordingPath: msg.payload.debugRecordingPath }
|
||||
: {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'dictation_stream_error') {
|
||||
if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationReject) {
|
||||
return
|
||||
}
|
||||
this.activeVoiceDictationReject(new Error(msg.payload.error))
|
||||
return
|
||||
}
|
||||
|
||||
// Ack/partial messages for internal voice dictation are consumed server-side.
|
||||
this.emit(msg as unknown as SessionOutboundMessage)
|
||||
}
|
||||
|
||||
private resetActiveVoiceDictationState(): void {
|
||||
this.activeVoiceDictationId = null
|
||||
this.activeVoiceDictationFormat = null
|
||||
this.activeVoiceDictationNextSeq = 0
|
||||
this.activeVoiceDictationStartPromise = null
|
||||
this.activeVoiceDictationFinalizePromise = null
|
||||
this.activeVoiceDictationResultPromise = null
|
||||
this.activeVoiceDictationResolve = null
|
||||
this.activeVoiceDictationReject = null
|
||||
}
|
||||
|
||||
private cancelActiveVoiceDictationStream(reason: string): void {
|
||||
const dictationId = this.activeVoiceDictationId
|
||||
if (!dictationId) {
|
||||
private async startVoiceTurnController(): Promise<void> {
|
||||
if (this.voiceTurnController) {
|
||||
return
|
||||
}
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ dictationId, reason },
|
||||
'Cancelling active internal voice dictation stream'
|
||||
)
|
||||
if (this.activeVoiceDictationReject) {
|
||||
this.activeVoiceDictationReject(new Error(`Voice dictation cancelled: ${reason}`))
|
||||
}
|
||||
this.voiceStreamManager.handleCancel(dictationId)
|
||||
this.resetActiveVoiceDictationState()
|
||||
}
|
||||
|
||||
private async ensureActiveVoiceDictationStream(format: string): Promise<void> {
|
||||
if (this.activeVoiceDictationId && this.activeVoiceDictationFormat === format) {
|
||||
if (this.activeVoiceDictationStartPromise) {
|
||||
await this.activeVoiceDictationStartPromise
|
||||
}
|
||||
return
|
||||
const turnDetection = this.resolveVoiceTurnDetection()
|
||||
if (!turnDetection) {
|
||||
throw new Error('Voice turn detection is not configured')
|
||||
}
|
||||
|
||||
if (this.activeVoiceDictationId) {
|
||||
await this.finalizeActiveVoiceDictationStream('voice format changed')
|
||||
}
|
||||
|
||||
const dictationId = `${VOICE_INTERNAL_DICTATION_ID_PREFIX}${uuidv4()}`
|
||||
let resolve: ((value: { text: string; debugRecordingPath?: string }) => void) | null = null
|
||||
let reject: ((error: Error) => void) | null = null
|
||||
const resultPromise = new Promise<{ text: string; debugRecordingPath?: string }>(
|
||||
(resolveFn, rejectFn) => {
|
||||
resolve = resolveFn
|
||||
reject = rejectFn
|
||||
}
|
||||
)
|
||||
// Prevent process-level unhandled rejection warnings when cancellation races are resolved later.
|
||||
void resultPromise.catch(() => undefined)
|
||||
|
||||
this.activeVoiceDictationId = dictationId
|
||||
this.activeVoiceDictationFormat = format
|
||||
this.activeVoiceDictationNextSeq = 0
|
||||
this.activeVoiceDictationFinalizePromise = null
|
||||
this.activeVoiceDictationResultPromise = resultPromise
|
||||
this.activeVoiceDictationResolve = resolve
|
||||
this.activeVoiceDictationReject = reject
|
||||
this.setPhase('transcribing')
|
||||
this.emit({
|
||||
type: 'activity_log',
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: 'system',
|
||||
content: 'Transcribing audio...',
|
||||
const controller = createVoiceTurnController({
|
||||
logger: this.sessionLogger.child({ component: 'voice-turn-controller' }),
|
||||
turnDetection,
|
||||
utteranceSink: {
|
||||
submitUtterance: async ({ pcm16, format, sampleRate, startedAt, endedAt }) => {
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
audioBytes: pcm16.length,
|
||||
sampleRate,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
},
|
||||
'Submitting detected voice utterance'
|
||||
)
|
||||
await this.processCompletedAudio(pcm16, format)
|
||||
},
|
||||
},
|
||||
callbacks: {
|
||||
onSpeechStarted: async () => {
|
||||
this.emit({
|
||||
type: 'voice_input_state',
|
||||
payload: {
|
||||
isSpeaking: true,
|
||||
},
|
||||
})
|
||||
await this.handleVoiceSpeechStart()
|
||||
},
|
||||
onSpeechStopped: async () => {
|
||||
this.emit({
|
||||
type: 'voice_input_state',
|
||||
payload: {
|
||||
isSpeaking: false,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
this.sessionLogger.error({ err: error }, 'Voice turn controller failed')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const startPromise = this.voiceStreamManager.handleStart(dictationId, format)
|
||||
this.activeVoiceDictationStartPromise = startPromise
|
||||
try {
|
||||
await startPromise
|
||||
} catch (error) {
|
||||
this.resetActiveVoiceDictationState()
|
||||
throw error
|
||||
} finally {
|
||||
if (this.activeVoiceDictationId === dictationId) {
|
||||
this.activeVoiceDictationStartPromise = null
|
||||
}
|
||||
}
|
||||
await controller.start()
|
||||
this.voiceTurnController = controller
|
||||
}
|
||||
|
||||
private async appendToActiveVoiceDictationStream(
|
||||
audioBase64: string,
|
||||
format: string
|
||||
): Promise<void> {
|
||||
if (this.activeVoiceDictationFinalizePromise) {
|
||||
await this.activeVoiceDictationFinalizePromise.catch(() => undefined)
|
||||
}
|
||||
await this.ensureActiveVoiceDictationStream(format)
|
||||
const dictationId = this.activeVoiceDictationId
|
||||
if (!dictationId) {
|
||||
throw new Error('Voice dictation stream did not initialize')
|
||||
}
|
||||
|
||||
const seq = this.activeVoiceDictationNextSeq
|
||||
this.activeVoiceDictationNextSeq += 1
|
||||
await this.voiceStreamManager.handleChunk({
|
||||
dictationId,
|
||||
seq,
|
||||
audioBase64,
|
||||
format,
|
||||
})
|
||||
}
|
||||
|
||||
private async finalizeActiveVoiceDictationStream(reason: string): Promise<void> {
|
||||
const dictationId = this.activeVoiceDictationId
|
||||
if (!dictationId) {
|
||||
return
|
||||
}
|
||||
this.clearVoiceModeInactivityTimeout()
|
||||
if (this.activeVoiceDictationStartPromise) {
|
||||
await this.activeVoiceDictationStartPromise
|
||||
}
|
||||
|
||||
if (this.activeVoiceDictationFinalizePromise) {
|
||||
await this.activeVoiceDictationFinalizePromise
|
||||
private async stopVoiceTurnController(): Promise<void> {
|
||||
if (!this.voiceTurnController) {
|
||||
return
|
||||
}
|
||||
|
||||
const finalSeq = this.activeVoiceDictationNextSeq - 1
|
||||
const resultPromise = this.activeVoiceDictationResultPromise
|
||||
if (!resultPromise) {
|
||||
this.resetActiveVoiceDictationState()
|
||||
return
|
||||
}
|
||||
|
||||
this.activeVoiceDictationFinalizePromise = (async () => {
|
||||
this.sessionLogger.debug(
|
||||
{ dictationId, finalSeq, reason },
|
||||
'Finalizing internal voice dictation stream'
|
||||
)
|
||||
await this.voiceStreamManager.handleFinish(dictationId, finalSeq)
|
||||
const result = await resultPromise
|
||||
this.resetActiveVoiceDictationState()
|
||||
const requestId = uuidv4()
|
||||
const transcriptText = result.text.trim()
|
||||
this.sessionLogger.info(
|
||||
{
|
||||
requestId,
|
||||
isVoiceMode: this.isVoiceMode,
|
||||
transcriptLength: transcriptText.length,
|
||||
transcript: transcriptText,
|
||||
},
|
||||
'Transcription result'
|
||||
)
|
||||
await this.handleTranscriptionResultPayload({
|
||||
text: result.text,
|
||||
requestId,
|
||||
...(result.debugRecordingPath
|
||||
? { debugRecordingPath: result.debugRecordingPath, format: 'audio/wav' }
|
||||
: {}),
|
||||
})
|
||||
})()
|
||||
|
||||
try {
|
||||
await this.activeVoiceDictationFinalizePromise
|
||||
} catch (error) {
|
||||
this.resetActiveVoiceDictationState()
|
||||
this.setPhase('idle')
|
||||
this.clearSpeechInProgress('transcription error')
|
||||
this.emit({
|
||||
type: 'activity_log',
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
content: `Transcription error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
})
|
||||
throw error
|
||||
}
|
||||
const controller = this.voiceTurnController
|
||||
this.voiceTurnController = null
|
||||
await controller.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6421,21 +6249,16 @@ export class Session {
|
||||
)
|
||||
}
|
||||
|
||||
await this.handleVoiceSpeechStart()
|
||||
|
||||
const chunkFormat = msg.format || 'audio/wav'
|
||||
|
||||
if (this.isVoiceMode) {
|
||||
await this.appendToActiveVoiceDictationStream(msg.audio, chunkFormat)
|
||||
if (!msg.isLast) {
|
||||
this.setVoiceModeInactivityTimeout()
|
||||
this.sessionLogger.debug('Voice mode: streaming chunk, waiting for speech end')
|
||||
return
|
||||
if (!this.voiceTurnController) {
|
||||
throw new Error('Voice mode is enabled but the voice turn controller is not running')
|
||||
}
|
||||
|
||||
this.clearVoiceModeInactivityTimeout()
|
||||
this.sessionLogger.debug('Voice mode: speech ended, finalizing streaming transcription')
|
||||
await this.finalizeActiveVoiceDictationStream('speech ended')
|
||||
await this.voiceTurnController.appendClientChunk({
|
||||
audioBase64: msg.audio,
|
||||
format: chunkFormat,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6849,8 +6672,6 @@ export class Session {
|
||||
this.audioBuffer = null
|
||||
}
|
||||
|
||||
this.cancelActiveVoiceDictationStream('new speech turn started')
|
||||
this.clearVoiceModeInactivityTimeout()
|
||||
this.clearBufferTimeout()
|
||||
|
||||
this.abortController.abort()
|
||||
@@ -6913,43 +6734,6 @@ export class Session {
|
||||
}, 10000) // 10 second timeout
|
||||
}
|
||||
|
||||
private setVoiceModeInactivityTimeout(): void {
|
||||
if (!this.isVoiceMode) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearVoiceModeInactivityTimeout()
|
||||
this.voiceModeInactivityTimeout = setTimeout(() => {
|
||||
this.voiceModeInactivityTimeout = null
|
||||
if (!this.isVoiceMode || !this.activeVoiceDictationId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.sessionLogger.warn(
|
||||
{
|
||||
timeoutMs: VOICE_MODE_INACTIVITY_FLUSH_MS,
|
||||
dictationId: this.activeVoiceDictationId,
|
||||
nextSeq: this.activeVoiceDictationNextSeq,
|
||||
},
|
||||
'Voice mode inactivity timeout reached without isLast; finalizing active voice dictation stream'
|
||||
)
|
||||
|
||||
void this.finalizeActiveVoiceDictationStream('inactivity timeout').catch((error) => {
|
||||
this.sessionLogger.error(
|
||||
{ err: error },
|
||||
'Failed to finalize voice dictation stream after inactivity timeout'
|
||||
)
|
||||
})
|
||||
}, VOICE_MODE_INACTIVITY_FLUSH_MS)
|
||||
}
|
||||
|
||||
private clearVoiceModeInactivityTimeout(): void {
|
||||
if (this.voiceModeInactivityTimeout) {
|
||||
clearTimeout(this.voiceModeInactivityTimeout)
|
||||
this.voiceModeInactivityTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear buffer timeout
|
||||
*/
|
||||
@@ -7041,18 +6825,16 @@ export class Session {
|
||||
this.abortController.abort()
|
||||
|
||||
// Clear timeouts
|
||||
this.clearVoiceModeInactivityTimeout()
|
||||
this.clearBufferTimeout()
|
||||
|
||||
// Clear buffers
|
||||
this.cancelActiveVoiceDictationStream('session cleanup')
|
||||
this.pendingAudioSegments = []
|
||||
this.audioBuffer = null
|
||||
await this.stopVoiceTurnController()
|
||||
|
||||
// Cleanup managers
|
||||
this.ttsManager.cleanup()
|
||||
this.sttManager.cleanup()
|
||||
this.voiceStreamManager.cleanupAll()
|
||||
this.dictationStreamManager.cleanupAll()
|
||||
|
||||
// Close MCP clients
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Logger } from "pino";
|
||||
import type { PaseoSpeechConfig } from "../../../bootstrap.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js";
|
||||
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||
import type { TurnDetectionProvider } from "../../turn-detection-provider.js";
|
||||
import { PocketTtsOnnxTTS } from "./pocket/pocket-tts-onnx.js";
|
||||
import {
|
||||
ensureLocalSpeechModels,
|
||||
@@ -22,6 +23,7 @@ import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-para
|
||||
import { SherpaRealtimeTranscriptionSession } from "./sherpa/sherpa-realtime-session.js";
|
||||
import { SherpaOnnxSTT } from "./sherpa/sherpa-stt.js";
|
||||
import { SherpaOnnxTTS } from "./sherpa/sherpa-tts.js";
|
||||
import { SherpaSileroTurnDetectionProvider } from "./sherpa/silero-vad-provider.js";
|
||||
|
||||
type LocalSttEngine =
|
||||
| { kind: "offline"; engine: SherpaOfflineRecognizerEngine }
|
||||
@@ -39,6 +41,7 @@ type LocalSpeechAvailability = {
|
||||
};
|
||||
|
||||
export type InitializedLocalSpeech = {
|
||||
turnDetectionService: TurnDetectionProvider | null;
|
||||
sttService: SpeechToTextProvider | null;
|
||||
ttsService: TextToSpeechProvider | null;
|
||||
dictationSttService: SpeechToTextProvider | null;
|
||||
@@ -192,6 +195,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
let sttService: SpeechToTextProvider | null = null;
|
||||
let ttsService: TextToSpeechProvider | null = null;
|
||||
let dictationSttService: SpeechToTextProvider | null = null;
|
||||
let turnDetectionService: TurnDetectionProvider | null = null;
|
||||
let localVoiceTtsProvider: TextToSpeechProvider | null = null;
|
||||
|
||||
const requiredLocalModelIds = computeRequiredLocalModelIds({
|
||||
@@ -262,6 +266,13 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
providers.voiceTurnDetection.enabled !== false &&
|
||||
providers.voiceTurnDetection.provider === "local"
|
||||
) {
|
||||
turnDetectionService = new SherpaSileroTurnDetectionProvider({}, logger);
|
||||
}
|
||||
|
||||
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
|
||||
if (!localConfig) {
|
||||
logger.warn(
|
||||
@@ -357,6 +368,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
};
|
||||
|
||||
return {
|
||||
turnDetectionService,
|
||||
sttService,
|
||||
ttsService,
|
||||
dictationSttService,
|
||||
|
||||
Binary file not shown.
@@ -12,6 +12,8 @@ export type SherpaOnnxNodeModule = {
|
||||
OfflineRecognizer: new (config: any) => any;
|
||||
OnlineRecognizer?: new (config: any) => any;
|
||||
OfflineTts?: new (config: any) => any;
|
||||
Vad?: new (config: any, bufferSizeInSeconds: number) => any;
|
||||
CircularBuffer?: new (capacity: number) => any;
|
||||
};
|
||||
|
||||
let cached: SherpaOnnxNodeModule | null = null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type {
|
||||
TurnDetectionProvider,
|
||||
TurnDetectionSession,
|
||||
} from "../../../turn-detection-provider.js";
|
||||
import {
|
||||
SherpaSileroVadSession,
|
||||
type SherpaSileroVadSessionConfig,
|
||||
} from "./silero-vad-session.js";
|
||||
|
||||
export class SherpaSileroTurnDetectionProvider implements TurnDetectionProvider {
|
||||
public readonly id = "local" as const;
|
||||
|
||||
private readonly config: SherpaSileroVadSessionConfig;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(config: SherpaSileroVadSessionConfig, logger: Logger) {
|
||||
this.config = config;
|
||||
this.logger = logger.child({
|
||||
module: "speech",
|
||||
provider: "local",
|
||||
component: "silero-vad",
|
||||
});
|
||||
}
|
||||
|
||||
createSession(params: { logger: Logger }): TurnDetectionSession {
|
||||
this.logger.debug(
|
||||
{ sampleRate: this.config.sampleRate, modelPath: this.config.modelPath },
|
||||
"Creating Silero VAD turn-detection session"
|
||||
);
|
||||
return new SherpaSileroVadSession({
|
||||
logger: params.logger.child({
|
||||
provider: "local",
|
||||
component: "silero-vad-session",
|
||||
}),
|
||||
config: this.config,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { pcm16leToFloat32 } from "../../../audio.js";
|
||||
import type { TurnDetectionSession } from "../../../turn-detection-provider.js";
|
||||
import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js";
|
||||
|
||||
const DEFAULT_SAMPLE_RATE = 16000;
|
||||
const DEFAULT_BUFFER_SIZE_SECONDS = 60;
|
||||
const DEFAULT_SILERO_THRESHOLD = 0.5;
|
||||
const DEFAULT_MIN_SILENCE_DURATION = 1.2;
|
||||
const DEFAULT_MIN_SPEECH_DURATION = 0.12;
|
||||
const DEFAULT_WINDOW_SIZE = 512;
|
||||
|
||||
type SherpaVadHandle = {
|
||||
acceptWaveform(samples: Float32Array): void;
|
||||
isDetected(): boolean;
|
||||
isEmpty(): boolean;
|
||||
flush(): void;
|
||||
reset(): void;
|
||||
};
|
||||
|
||||
type SherpaCircularBufferHandle = {
|
||||
push(samples: Float32Array): void;
|
||||
get(startIndex: number, n: number, enableExternalBuffer?: boolean): Float32Array;
|
||||
pop(n: number): void;
|
||||
size(): number;
|
||||
head(): number;
|
||||
reset(): void;
|
||||
};
|
||||
|
||||
type SherpaVadModule = {
|
||||
Vad: new (config: Record<string, unknown>, bufferSizeInSeconds: number) => SherpaVadHandle;
|
||||
CircularBuffer: new (capacity: number) => SherpaCircularBufferHandle;
|
||||
};
|
||||
|
||||
function resolveBundledSileroVadModelPath(): string {
|
||||
return fileURLToPath(new URL("./assets/silero_vad.onnx", import.meta.url));
|
||||
}
|
||||
|
||||
export interface SherpaSileroVadSessionConfig {
|
||||
modelPath?: string;
|
||||
sampleRate?: number;
|
||||
threshold?: number;
|
||||
minSilenceDuration?: number;
|
||||
minSpeechDuration?: number;
|
||||
windowSize?: number;
|
||||
bufferSizeInSeconds?: number;
|
||||
}
|
||||
|
||||
export class SherpaSileroVadSession
|
||||
extends EventEmitter
|
||||
implements TurnDetectionSession
|
||||
{
|
||||
public readonly requiredSampleRate: number;
|
||||
|
||||
private readonly vad: SherpaVadHandle;
|
||||
private readonly inputBuffer: SherpaCircularBufferHandle;
|
||||
private readonly windowSize: number;
|
||||
private connected = false;
|
||||
private inSpeech = false;
|
||||
private readonly logger;
|
||||
|
||||
constructor(params: {
|
||||
logger: { debug: (...args: unknown[]) => void };
|
||||
config?: SherpaSileroVadSessionConfig;
|
||||
}) {
|
||||
super();
|
||||
this.logger = params.logger;
|
||||
const config = params.config ?? {};
|
||||
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
||||
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
|
||||
|
||||
const sherpa = loadSherpaOnnxNode() as unknown as SherpaVadModule;
|
||||
this.vad = new sherpa.Vad(
|
||||
{
|
||||
sileroVad: {
|
||||
model: config.modelPath ?? resolveBundledSileroVadModelPath(),
|
||||
threshold: config.threshold ?? DEFAULT_SILERO_THRESHOLD,
|
||||
minSilenceDuration:
|
||||
config.minSilenceDuration ?? DEFAULT_MIN_SILENCE_DURATION,
|
||||
minSpeechDuration:
|
||||
config.minSpeechDuration ?? DEFAULT_MIN_SPEECH_DURATION,
|
||||
windowSize: config.windowSize ?? DEFAULT_WINDOW_SIZE,
|
||||
},
|
||||
sampleRate: this.requiredSampleRate,
|
||||
numThreads: 1,
|
||||
provider: "cpu",
|
||||
debug: 0,
|
||||
},
|
||||
config.bufferSizeInSeconds ?? DEFAULT_BUFFER_SIZE_SECONDS
|
||||
);
|
||||
this.inputBuffer = new sherpa.CircularBuffer(
|
||||
(config.bufferSizeInSeconds ?? DEFAULT_BUFFER_SIZE_SECONDS) * this.requiredSampleRate
|
||||
);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
appendPcm16(pcm16le: Buffer): void {
|
||||
if (!this.connected) {
|
||||
this.emit("error", new Error("Turn detection session not connected"));
|
||||
return;
|
||||
}
|
||||
if (pcm16le.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const samples = pcm16leToFloat32(pcm16le, 1);
|
||||
this.inputBuffer.push(samples);
|
||||
while (this.inputBuffer.size() > this.windowSize) {
|
||||
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize);
|
||||
this.inputBuffer.pop(this.windowSize);
|
||||
this.vad.acceptWaveform(window);
|
||||
this.syncDetectionState();
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.vad.flush();
|
||||
this.syncDetectionState();
|
||||
if (this.inSpeech) {
|
||||
this.inSpeech = false;
|
||||
this.emit("speech_stopped");
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.vad.reset();
|
||||
this.inputBuffer.reset();
|
||||
} catch {
|
||||
// ignore native cleanup failures
|
||||
} finally {
|
||||
this.connected = false;
|
||||
this.inSpeech = false;
|
||||
}
|
||||
}
|
||||
|
||||
private syncDetectionState(): void {
|
||||
const detected = this.vad.isDetected();
|
||||
if (detected && !this.inSpeech) {
|
||||
this.inSpeech = true;
|
||||
this.emit("speech_started");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detected && this.inSpeech && !this.vad.isEmpty()) {
|
||||
this.logger.debug("Silero VAD marked end of speech");
|
||||
this.inSpeech = false;
|
||||
this.emit("speech_stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js";
|
||||
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||
import type { TurnDetectionProvider } from "../../turn-detection-provider.js";
|
||||
import {
|
||||
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
DEFAULT_OPENAI_TTS_MODEL,
|
||||
@@ -24,6 +25,7 @@ export type OpenAiSpeechAvailability = {
|
||||
};
|
||||
|
||||
export type SpeechServices = {
|
||||
turnDetectionService: TurnDetectionProvider | null;
|
||||
sttService: SpeechToTextProvider | null;
|
||||
ttsService: TextToSpeechProvider | null;
|
||||
dictationSttService: SpeechToTextProvider | null;
|
||||
@@ -112,6 +114,7 @@ export function initializeOpenAiSpeechServices(params: {
|
||||
let sttService = existing.sttService;
|
||||
let ttsService = existing.ttsService;
|
||||
let dictationSttService = existing.dictationSttService;
|
||||
const turnDetectionService = existing.turnDetectionService;
|
||||
|
||||
const needsOpenAiStt =
|
||||
!sttService &&
|
||||
@@ -181,6 +184,7 @@ export function initializeOpenAiSpeechServices(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
turnDetectionService,
|
||||
sttService,
|
||||
ttsService,
|
||||
dictationSttService,
|
||||
|
||||
@@ -23,6 +23,11 @@ describe("resolveSpeechConfig", () => {
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
@@ -52,6 +57,7 @@ describe("resolveSpeechConfig", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
voiceMode: {
|
||||
turnDetection: { provider: "local" },
|
||||
stt: { provider: "openai", model: "gpt-4o-transcribe" },
|
||||
},
|
||||
},
|
||||
@@ -98,6 +104,11 @@ describe("resolveSpeechConfig", () => {
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.speech.providers.voiceTts).toEqual({
|
||||
provider: "local",
|
||||
explicit: true,
|
||||
@@ -150,6 +161,11 @@ describe("resolveSpeechConfig", () => {
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.speech.providers.voiceStt).toEqual({
|
||||
provider: "local",
|
||||
explicit: false,
|
||||
|
||||
@@ -38,6 +38,7 @@ const OptionalBooleanFlagSchema = z
|
||||
|
||||
const RequestedSpeechProvidersSchema = z.object({
|
||||
dictationStt: OptionalSpeechProviderSchema.default("local"),
|
||||
voiceTurnDetection: OptionalSpeechProviderSchema.default("local"),
|
||||
voiceStt: OptionalSpeechProviderSchema.default("local"),
|
||||
voiceTts: OptionalSpeechProviderSchema.default("local"),
|
||||
});
|
||||
@@ -62,6 +63,9 @@ function resolveRequestedSpeechProviders(params: {
|
||||
const voiceSttProviderFromConfig =
|
||||
params.env.PASEO_VOICE_STT_PROVIDER ??
|
||||
params.persisted.features?.voiceMode?.stt?.provider;
|
||||
const voiceTurnDetectionProviderFromConfig =
|
||||
params.env.PASEO_VOICE_TURN_DETECTION_PROVIDER ??
|
||||
params.persisted.features?.voiceMode?.turnDetection?.provider;
|
||||
const voiceTtsProviderFromConfig =
|
||||
params.env.PASEO_VOICE_TTS_PROVIDER ??
|
||||
params.persisted.features?.voiceMode?.tts?.provider;
|
||||
@@ -76,6 +80,7 @@ function resolveRequestedSpeechProviders(params: {
|
||||
|
||||
const parsed = RequestedSpeechProvidersSchema.parse({
|
||||
dictationStt: dictationSttProviderFromConfig ?? "local",
|
||||
voiceTurnDetection: voiceTurnDetectionProviderFromConfig ?? "local",
|
||||
voiceStt: voiceSttProviderFromConfig ?? "local",
|
||||
voiceTts: voiceTtsProviderFromConfig ?? "local",
|
||||
});
|
||||
@@ -86,6 +91,11 @@ function resolveRequestedSpeechProviders(params: {
|
||||
parsed.dictationStt,
|
||||
dictationEnabled
|
||||
),
|
||||
voiceTurnDetection: resolveFeatureProvider(
|
||||
voiceTurnDetectionProviderFromConfig,
|
||||
parsed.voiceTurnDetection,
|
||||
voiceModeEnabled
|
||||
),
|
||||
voiceStt: resolveFeatureProvider(
|
||||
voiceSttProviderFromConfig,
|
||||
parsed.voiceStt,
|
||||
|
||||
@@ -4,6 +4,7 @@ import pino from "pino";
|
||||
import type { PaseoSpeechConfig } from "../bootstrap.js";
|
||||
import type { InitializedLocalSpeech } from "./providers/local/runtime.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js";
|
||||
import type { TurnDetectionProvider } from "./turn-detection-provider.js";
|
||||
import { initializeSpeechRuntime } from "./speech-runtime.js";
|
||||
|
||||
const { initializeLocalSpeechServicesMock } = vi.hoisted(() => ({
|
||||
@@ -20,11 +21,13 @@ vi.mock("./providers/openai/runtime.js", () => ({
|
||||
getOpenAiSpeechAvailability: () => ({ configured: false }),
|
||||
initializeOpenAiSpeechServices: (args: {
|
||||
existing: {
|
||||
turnDetectionService: TurnDetectionProvider | null;
|
||||
sttService: SpeechToTextProvider | null;
|
||||
ttsService: TextToSpeechProvider | null;
|
||||
dictationSttService: SpeechToTextProvider | null;
|
||||
};
|
||||
}) => ({
|
||||
turnDetectionService: args.existing.turnDetectionService,
|
||||
sttService: args.existing.sttService,
|
||||
ttsService: args.existing.ttsService,
|
||||
dictationSttService: args.existing.dictationSttService,
|
||||
@@ -56,6 +59,15 @@ function createStubTts(id: string): TextToSpeechProvider {
|
||||
};
|
||||
}
|
||||
|
||||
function createStubTurnDetection(id: string): TurnDetectionProvider {
|
||||
return {
|
||||
id,
|
||||
createSession: vi.fn(() => {
|
||||
throw new Error("not used in this test");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createSpeechConfig(
|
||||
providers: PaseoSpeechConfig["providers"]
|
||||
): PaseoSpeechConfig {
|
||||
@@ -71,6 +83,7 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
const dictationStt = createStubStt("dictation-local");
|
||||
|
||||
initializeLocalSpeechServicesMock.mockResolvedValue({
|
||||
turnDetectionService: null,
|
||||
sttService: null,
|
||||
ttsService: null,
|
||||
dictationSttService: dictationStt,
|
||||
@@ -87,6 +100,7 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
logger: pino({ level: "silent" }),
|
||||
speechConfig: createSpeechConfig({
|
||||
dictationStt: { provider: "local", enabled: true, explicit: true },
|
||||
voiceTurnDetection: { provider: "local", enabled: false, explicit: true },
|
||||
voiceStt: { provider: "local", enabled: false, explicit: true },
|
||||
voiceTts: { provider: "local", enabled: false, explicit: true },
|
||||
}),
|
||||
@@ -104,8 +118,10 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
it("keeps voice feature available when only realtime voice is enabled and ready", async () => {
|
||||
const voiceStt = createStubStt("voice-local");
|
||||
const voiceTts = createStubTts("tts-local");
|
||||
const turnDetection = createStubTurnDetection("turn-local");
|
||||
|
||||
initializeLocalSpeechServicesMock.mockResolvedValue({
|
||||
turnDetectionService: turnDetection,
|
||||
sttService: voiceStt,
|
||||
ttsService: voiceTts,
|
||||
dictationSttService: null,
|
||||
@@ -122,6 +138,7 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
logger: pino({ level: "silent" }),
|
||||
speechConfig: createSpeechConfig({
|
||||
dictationStt: { provider: "local", enabled: false, explicit: true },
|
||||
voiceTurnDetection: { provider: "local", enabled: true, explicit: true },
|
||||
voiceStt: { provider: "local", enabled: true, explicit: true },
|
||||
voiceTts: { provider: "local", enabled: true, explicit: true },
|
||||
}),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./providers/openai/runtime.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js";
|
||||
import type { RequestedSpeechProviders } from "./speech-types.js";
|
||||
import type { TurnDetectionProvider } from "./turn-detection-provider.js";
|
||||
|
||||
const SPEECH_RUNTIME_MONITOR_INTERVAL_MS = 3000;
|
||||
|
||||
@@ -26,6 +27,7 @@ export type SpeechReadinessReasonCode =
|
||||
| "model_download_in_progress"
|
||||
| "models_missing"
|
||||
| "model_download_failed"
|
||||
| "turn_detection_unavailable"
|
||||
| "stt_unavailable"
|
||||
| "tts_unavailable";
|
||||
|
||||
@@ -61,6 +63,7 @@ function resolveRequestedSpeechProviders(
|
||||
|
||||
return {
|
||||
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 },
|
||||
};
|
||||
@@ -118,12 +121,14 @@ function joinModelIds(modelIds: LocalSpeechModelId[]): string {
|
||||
|
||||
function buildRealtimeVoiceReadiness(params: {
|
||||
providers: RequestedSpeechProviders;
|
||||
turnDetectionService: TurnDetectionProvider | null;
|
||||
sttService: SpeechToTextProvider | null;
|
||||
ttsService: TextToSpeechProvider | null;
|
||||
}): SpeechReadinessState {
|
||||
const voiceTurnDetectionEnabled = params.providers.voiceTurnDetection.enabled !== false;
|
||||
const voiceSttEnabled = params.providers.voiceStt.enabled !== false;
|
||||
const voiceTtsEnabled = params.providers.voiceTts.enabled !== false;
|
||||
const enabled = voiceSttEnabled || voiceTtsEnabled;
|
||||
const enabled = voiceTurnDetectionEnabled || voiceSttEnabled || voiceTtsEnabled;
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
@@ -134,6 +139,16 @@ function buildRealtimeVoiceReadiness(params: {
|
||||
missingModelIds: [],
|
||||
};
|
||||
}
|
||||
if (voiceTurnDetectionEnabled && !params.turnDetectionService) {
|
||||
return {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reasonCode: "turn_detection_unavailable",
|
||||
message: "Realtime voice is unavailable: turn-detection service is not ready.",
|
||||
retryable: false,
|
||||
missingModelIds: [],
|
||||
};
|
||||
}
|
||||
if (voiceSttEnabled && !params.sttService) {
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -262,6 +277,7 @@ function buildVoiceFeatureReadiness(params: {
|
||||
|
||||
function describeRequestedProviders(providers: RequestedSpeechProviders): {
|
||||
dictationStt: { provider: string; enabled: boolean; explicit: boolean };
|
||||
voiceTurnDetection: { provider: string; enabled: boolean; explicit: boolean };
|
||||
voiceStt: { provider: string; enabled: boolean; explicit: boolean };
|
||||
voiceTts: { provider: string; enabled: boolean; explicit: boolean };
|
||||
} {
|
||||
@@ -271,6 +287,11 @@ function describeRequestedProviders(providers: RequestedSpeechProviders): {
|
||||
enabled: providers.dictationStt.enabled !== false,
|
||||
explicit: providers.dictationStt.explicit,
|
||||
},
|
||||
voiceTurnDetection: {
|
||||
provider: providers.voiceTurnDetection.provider,
|
||||
enabled: providers.voiceTurnDetection.enabled !== false,
|
||||
explicit: providers.voiceTurnDetection.explicit,
|
||||
},
|
||||
voiceStt: {
|
||||
provider: providers.voiceStt.provider,
|
||||
enabled: providers.voiceStt.enabled !== false,
|
||||
@@ -285,13 +306,20 @@ function describeRequestedProviders(providers: RequestedSpeechProviders): {
|
||||
}
|
||||
|
||||
function resolveEffectiveProviderIds(params: {
|
||||
turnDetectionService: TurnDetectionProvider | null;
|
||||
sttService: SpeechToTextProvider | null;
|
||||
ttsService: TextToSpeechProvider | null;
|
||||
dictationSttService: SpeechToTextProvider | null;
|
||||
localVoiceTtsProvider: TextToSpeechProvider | null;
|
||||
}): { dictationStt: string; voiceStt: string; voiceTts: string } {
|
||||
}): {
|
||||
dictationStt: string;
|
||||
voiceTurnDetection: string;
|
||||
voiceStt: string;
|
||||
voiceTts: string;
|
||||
} {
|
||||
return {
|
||||
dictationStt: params.dictationSttService?.id ?? "unavailable",
|
||||
voiceTurnDetection: params.turnDetectionService?.id ?? "unavailable",
|
||||
voiceStt: params.sttService?.id ?? "unavailable",
|
||||
voiceTts:
|
||||
!params.ttsService
|
||||
@@ -303,6 +331,7 @@ function resolveEffectiveProviderIds(params: {
|
||||
}
|
||||
|
||||
export type InitializedSpeechRuntime = {
|
||||
resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
|
||||
resolveVoiceStt: () => SpeechToTextProvider | null;
|
||||
resolveVoiceTts: () => TextToSpeechProvider | null;
|
||||
resolveDictationStt: () => SpeechToTextProvider | null;
|
||||
@@ -347,6 +376,7 @@ export async function initializeSpeechRuntime(params: {
|
||||
let sttService: SpeechToTextProvider | null = null;
|
||||
let ttsService: TextToSpeechProvider | null = null;
|
||||
let dictationSttService: SpeechToTextProvider | null = null;
|
||||
let turnDetectionService: TurnDetectionProvider | null = null;
|
||||
let localModelConfig: {
|
||||
modelsDir: string;
|
||||
defaultModelIds: LocalSpeechModelId[];
|
||||
@@ -367,6 +397,7 @@ export async function initializeSpeechRuntime(params: {
|
||||
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
|
||||
const realtimeVoice = buildRealtimeVoiceReadiness({
|
||||
providers,
|
||||
turnDetectionService,
|
||||
sttService,
|
||||
ttsService,
|
||||
});
|
||||
@@ -466,6 +497,7 @@ export async function initializeSpeechRuntime(params: {
|
||||
providers,
|
||||
openaiConfig,
|
||||
existing: {
|
||||
turnDetectionService: nextLocalSpeech.turnDetectionService,
|
||||
sttService: nextLocalSpeech.sttService,
|
||||
ttsService: nextLocalSpeech.ttsService,
|
||||
dictationSttService: nextLocalSpeech.dictationSttService,
|
||||
@@ -474,6 +506,7 @@ export async function initializeSpeechRuntime(params: {
|
||||
});
|
||||
|
||||
const previousLocalCleanup = localCleanup;
|
||||
turnDetectionService = nextOpenAiSpeech.turnDetectionService;
|
||||
sttService = nextOpenAiSpeech.sttService;
|
||||
ttsService = nextOpenAiSpeech.ttsService;
|
||||
dictationSttService = nextOpenAiSpeech.dictationSttService;
|
||||
@@ -485,6 +518,7 @@ export async function initializeSpeechRuntime(params: {
|
||||
await refreshMissingLocalModels();
|
||||
|
||||
const effectiveProviders = resolveEffectiveProviderIds({
|
||||
turnDetectionService,
|
||||
sttService,
|
||||
ttsService,
|
||||
dictationSttService,
|
||||
@@ -492,6 +526,9 @@ export async function initializeSpeechRuntime(params: {
|
||||
});
|
||||
const unavailableFeatures = [
|
||||
providers.dictationStt.enabled !== false && !dictationSttService ? "dictation.stt" : null,
|
||||
providers.voiceTurnDetection.enabled !== false && !turnDetectionService
|
||||
? "voice.turnDetection"
|
||||
: null,
|
||||
providers.voiceStt.enabled !== false && !sttService ? "voice.stt" : null,
|
||||
providers.voiceTts.enabled !== false && !ttsService ? "voice.tts" : null,
|
||||
].filter((feature): feature is string => feature !== null);
|
||||
@@ -642,6 +679,7 @@ export async function initializeSpeechRuntime(params: {
|
||||
};
|
||||
|
||||
return {
|
||||
resolveVoiceTurnDetection: () => turnDetectionService,
|
||||
resolveVoiceStt: () => sttService,
|
||||
resolveVoiceTts: () => ttsService,
|
||||
resolveDictationStt: () => dictationSttService,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type RequestedSpeechProvider = z.infer<typeof RequestedSpeechProviderSche
|
||||
|
||||
export type RequestedSpeechProviders = {
|
||||
dictationStt: RequestedSpeechProvider;
|
||||
voiceTurnDetection: RequestedSpeechProvider;
|
||||
voiceStt: RequestedSpeechProvider;
|
||||
voiceTts: RequestedSpeechProvider;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type pino from "pino";
|
||||
|
||||
export type TurnDetectionSession = {
|
||||
/**
|
||||
* Required PCM16LE sample rate for `appendPcm16()`.
|
||||
* Callers are responsible for resampling before appending.
|
||||
*/
|
||||
requiredSampleRate: number;
|
||||
|
||||
connect(): Promise<void>;
|
||||
appendPcm16(pcm16le: Buffer): void;
|
||||
flush(): 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;
|
||||
};
|
||||
|
||||
export interface TurnDetectionProvider {
|
||||
id: "openai" | "local" | (string & {});
|
||||
createSession(params: {
|
||||
logger: pino.Logger;
|
||||
}): TurnDetectionSession;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FixedDurationPcmRingBuffer } from "./fixed-duration-pcm-ring-buffer.js";
|
||||
|
||||
describe("FixedDurationPcmRingBuffer", () => {
|
||||
it("retains only the configured prefix window", () => {
|
||||
const buffer = new FixedDurationPcmRingBuffer({
|
||||
sampleRate: 1000,
|
||||
channels: 1,
|
||||
bitsPerSample: 16,
|
||||
durationMs: 100,
|
||||
});
|
||||
|
||||
buffer.append(Buffer.alloc(120));
|
||||
buffer.append(Buffer.alloc(120, 1));
|
||||
|
||||
const drained = buffer.drain();
|
||||
expect(drained.length).toBe(120);
|
||||
expect(Array.from(drained.slice(0, 4))).toEqual([1, 1, 1, 1]);
|
||||
});
|
||||
|
||||
it("drains buffered chunks in arrival order", () => {
|
||||
const buffer = new FixedDurationPcmRingBuffer({
|
||||
sampleRate: 1000,
|
||||
channels: 1,
|
||||
bitsPerSample: 16,
|
||||
durationMs: 500,
|
||||
});
|
||||
|
||||
buffer.append(Buffer.from([1, 2]));
|
||||
buffer.append(Buffer.from([3, 4]));
|
||||
buffer.append(Buffer.from([5, 6]));
|
||||
|
||||
expect(Array.from(buffer.drain())).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(buffer.drain().length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
export class FixedDurationPcmRingBuffer {
|
||||
private readonly maxBytes: number;
|
||||
private chunks: Buffer[] = [];
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(params: {
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
bitsPerSample: number;
|
||||
durationMs: number;
|
||||
}) {
|
||||
const bytesPerSecond =
|
||||
params.sampleRate * params.channels * (params.bitsPerSample / 8);
|
||||
this.maxBytes = Math.max(
|
||||
1,
|
||||
Math.round((bytesPerSecond * params.durationMs) / 1000)
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
this.totalBytes -= removed.length;
|
||||
}
|
||||
}
|
||||
|
||||
drain(): Buffer {
|
||||
const combined = Buffer.concat(this.chunks);
|
||||
this.clear();
|
||||
return combined;
|
||||
}
|
||||
|
||||
get byteLength(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.chunks = [];
|
||||
this.totalBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import type {
|
||||
TurnDetectionProvider,
|
||||
TurnDetectionSession,
|
||||
} from "../speech/turn-detection-provider.js";
|
||||
import {
|
||||
createVoiceTurnController,
|
||||
type DetectedVoiceUtterance,
|
||||
} from "./voice-turn-controller.js";
|
||||
|
||||
class FakeTurnDetectionSession
|
||||
extends EventEmitter
|
||||
implements TurnDetectionSession
|
||||
{
|
||||
public readonly requiredSampleRate = 16000;
|
||||
public readonly appendedChunks: Buffer[] = [];
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
|
||||
appendPcm16(chunk: Buffer): void {
|
||||
this.appendedChunks.push(chunk);
|
||||
}
|
||||
|
||||
flush(): void {}
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
function createFakeTurnDetectionProvider(
|
||||
session: FakeTurnDetectionSession
|
||||
): TurnDetectionProvider {
|
||||
return {
|
||||
id: "local",
|
||||
createSession() {
|
||||
return session;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settleSerialQueue(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function createControllerHarness() {
|
||||
const detector = new FakeTurnDetectionSession();
|
||||
const onSpeechStarted = vi.fn(async () => {});
|
||||
const onSpeechStopped = vi.fn(async () => {});
|
||||
const submitUtterance = vi.fn(async (_utterance: DetectedVoiceUtterance) => {});
|
||||
const onError = vi.fn();
|
||||
|
||||
const controller = createVoiceTurnController({
|
||||
logger: pino({ level: "silent" }),
|
||||
turnDetection: createFakeTurnDetectionProvider(detector),
|
||||
prefixDurationMs: 100,
|
||||
utteranceSink: {
|
||||
submitUtterance,
|
||||
},
|
||||
callbacks: {
|
||||
onSpeechStarted,
|
||||
onSpeechStopped,
|
||||
onError,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
controller,
|
||||
detector,
|
||||
onSpeechStarted,
|
||||
onSpeechStopped,
|
||||
submitUtterance,
|
||||
onError,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("voice turn controller", () => {
|
||||
it("buffers audio before speech start and includes the prefix in the utterance", async () => {
|
||||
const harness = createControllerHarness();
|
||||
|
||||
await harness.controller.start();
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([5, 6, 7, 8]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
|
||||
harness.detector.emit("speech_started");
|
||||
await settleSerialQueue();
|
||||
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([9, 10, 11, 12]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
|
||||
harness.detector.emit("speech_stopped");
|
||||
await settleSerialQueue();
|
||||
|
||||
expect(harness.onSpeechStarted).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onSpeechStopped).toHaveBeenCalledTimes(1);
|
||||
expect(harness.submitUtterance).toHaveBeenCalledTimes(1);
|
||||
expect(harness.submitUtterance).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pcm16: Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]),
|
||||
sampleRate: 16000,
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
})
|
||||
);
|
||||
expect(harness.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalizes one utterance when speech stops", async () => {
|
||||
const harness = createControllerHarness();
|
||||
|
||||
await harness.controller.start();
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([1, 1, 1, 1]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
|
||||
harness.detector.emit("speech_started");
|
||||
await settleSerialQueue();
|
||||
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([2, 2, 2, 2]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
|
||||
harness.detector.emit("speech_stopped");
|
||||
await settleSerialQueue();
|
||||
harness.detector.emit("speech_stopped");
|
||||
await settleSerialQueue();
|
||||
|
||||
expect(harness.submitUtterance).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onSpeechStopped).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not barge in or emit an utterance on silence-only chunks", async () => {
|
||||
const harness = createControllerHarness();
|
||||
|
||||
await harness.controller.start();
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([0, 0, 0, 0]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([0, 0, 0, 0]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
await settleSerialQueue();
|
||||
|
||||
expect(harness.detector.appendedChunks).toEqual([
|
||||
Buffer.from([0, 0, 0, 0]),
|
||||
Buffer.from([0, 0, 0, 0]),
|
||||
]);
|
||||
expect(harness.onSpeechStarted).not.toHaveBeenCalled();
|
||||
expect(harness.onSpeechStopped).not.toHaveBeenCalled();
|
||||
expect(harness.submitUtterance).not.toHaveBeenCalled();
|
||||
expect(harness.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains a rolling prefix for rapid follow-up utterances", async () => {
|
||||
const harness = createControllerHarness();
|
||||
|
||||
await harness.controller.start();
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
|
||||
harness.detector.emit("speech_started");
|
||||
await settleSerialQueue();
|
||||
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([5, 6, 7, 8]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
|
||||
harness.detector.emit("speech_stopped");
|
||||
await settleSerialQueue();
|
||||
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([9, 10, 11, 12]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
|
||||
harness.detector.emit("speech_started");
|
||||
await settleSerialQueue();
|
||||
|
||||
await harness.controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([13, 14, 15, 16]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
|
||||
harness.detector.emit("speech_stopped");
|
||||
await settleSerialQueue();
|
||||
|
||||
expect(harness.submitUtterance).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onSpeechStopped).toHaveBeenCalledTimes(2);
|
||||
expect(harness.submitUtterance.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
pcm16: Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]),
|
||||
})
|
||||
);
|
||||
expect(harness.submitUtterance.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
pcm16: Buffer.from([5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("continues detecting speech while a previous utterance submission is still pending", async () => {
|
||||
const deferred = createDeferredPromise<void>();
|
||||
const detector = new FakeTurnDetectionSession();
|
||||
const onSpeechStarted = vi.fn(async () => {});
|
||||
const onSpeechStopped = vi.fn(async () => {});
|
||||
const submitUtterance = vi.fn(async () => {
|
||||
await deferred.promise;
|
||||
});
|
||||
const onError = vi.fn();
|
||||
|
||||
const controller = createVoiceTurnController({
|
||||
logger: pino({ level: "silent" }),
|
||||
turnDetection: createFakeTurnDetectionProvider(detector),
|
||||
prefixDurationMs: 100,
|
||||
utteranceSink: {
|
||||
submitUtterance,
|
||||
},
|
||||
callbacks: {
|
||||
onSpeechStarted,
|
||||
onSpeechStopped,
|
||||
onError,
|
||||
},
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
detector.emit("speech_started");
|
||||
await settleSerialQueue();
|
||||
await controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([5, 6, 7, 8]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
detector.emit("speech_stopped");
|
||||
await settleSerialQueue();
|
||||
|
||||
await controller.appendClientChunk({
|
||||
audioBase64: Buffer.from([9, 10, 11, 12]).toString("base64"),
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
});
|
||||
detector.emit("speech_started");
|
||||
await settleSerialQueue();
|
||||
|
||||
expect(onSpeechStarted).toHaveBeenCalledTimes(2);
|
||||
expect(onSpeechStopped).toHaveBeenCalledTimes(1);
|
||||
expect(submitUtterance).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
|
||||
deferred.resolve();
|
||||
await settleSerialQueue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Logger } from "pino";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { Pcm16MonoResampler } from "../agent/pcm16-resampler.js";
|
||||
import { parsePcmRateFromFormat } from "../speech/audio.js";
|
||||
import type { TurnDetectionProvider } from "../speech/turn-detection-provider.js";
|
||||
import { FixedDurationPcmRingBuffer } from "./fixed-duration-pcm-ring-buffer.js";
|
||||
|
||||
const PCM_CHANNELS = 1;
|
||||
const PCM_BITS_PER_SAMPLE = 16;
|
||||
const DEFAULT_PREFIX_DURATION_MS = 400;
|
||||
|
||||
type VoiceInputState =
|
||||
| { status: "idle" }
|
||||
| { status: "listening"; rollingPrefixBytes: number }
|
||||
| {
|
||||
status: "capturing";
|
||||
utteranceId: string;
|
||||
startedAt: number;
|
||||
rollingPrefixBytes: number;
|
||||
utteranceBytes: number;
|
||||
};
|
||||
|
||||
export interface VoiceTurnControllerCallbacks {
|
||||
onSpeechStarted(): Promise<void>;
|
||||
onSpeechStopped(): Promise<void>;
|
||||
onError(error: Error): void;
|
||||
}
|
||||
|
||||
export interface DetectedVoiceUtterance {
|
||||
pcm16: Buffer;
|
||||
sampleRate: number;
|
||||
format: string;
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
}
|
||||
|
||||
export interface VoiceUtteranceSink {
|
||||
submitUtterance(utterance: DetectedVoiceUtterance): Promise<void>;
|
||||
}
|
||||
|
||||
export interface VoiceTurnController {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
appendClientChunk(input: {
|
||||
audioBase64: string;
|
||||
format: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createVoiceTurnController(params: {
|
||||
logger: Logger;
|
||||
turnDetection: TurnDetectionProvider;
|
||||
utteranceSink: VoiceUtteranceSink;
|
||||
callbacks: VoiceTurnControllerCallbacks;
|
||||
prefixDurationMs?: number;
|
||||
}): VoiceTurnController {
|
||||
const detector = params.turnDetection.createSession({
|
||||
logger: params.logger.child({ component: "turn-detection" }),
|
||||
});
|
||||
const prefixBuffer = new FixedDurationPcmRingBuffer({
|
||||
sampleRate: detector.requiredSampleRate,
|
||||
channels: PCM_CHANNELS,
|
||||
bitsPerSample: PCM_BITS_PER_SAMPLE,
|
||||
durationMs: params.prefixDurationMs ?? DEFAULT_PREFIX_DURATION_MS,
|
||||
});
|
||||
|
||||
let state: VoiceInputState = { status: "idle" };
|
||||
let resampler: Pcm16MonoResampler | null = null;
|
||||
let inputRate = detector.requiredSampleRate;
|
||||
let utteranceChunks: Buffer[] = [];
|
||||
let queued = Promise.resolve();
|
||||
let submissionQueue = Promise.resolve();
|
||||
|
||||
function buildVoicePcmFormat(sampleRate: number): string {
|
||||
return `audio/pcm;rate=${sampleRate};bits=16`;
|
||||
}
|
||||
|
||||
function fail(error: unknown): void {
|
||||
params.callbacks.onError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
function runSerial(task: () => Promise<void>): Promise<void> {
|
||||
queued = queued
|
||||
.then(task)
|
||||
.catch((error) => {
|
||||
fail(error);
|
||||
});
|
||||
return queued;
|
||||
}
|
||||
|
||||
function enqueueUtteranceSubmission(utterance: DetectedVoiceUtterance): void {
|
||||
submissionQueue = submissionQueue
|
||||
.then(async () => {
|
||||
await params.utteranceSink.submitUtterance(utterance);
|
||||
})
|
||||
.catch((error) => {
|
||||
fail(error);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSpeechStarted(): Promise<void> {
|
||||
if (state.status === "capturing") {
|
||||
return;
|
||||
}
|
||||
|
||||
await params.callbacks.onSpeechStarted();
|
||||
|
||||
const prefix = prefixBuffer.drain();
|
||||
const startedAt = Date.now();
|
||||
utteranceChunks = prefix.length > 0 ? [prefix] : [];
|
||||
state = {
|
||||
status: "capturing",
|
||||
utteranceId: uuidv4(),
|
||||
startedAt,
|
||||
rollingPrefixBytes: prefixBuffer.byteLength,
|
||||
utteranceBytes: prefix.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSpeechStopped(): Promise<void> {
|
||||
if (state.status !== "capturing") {
|
||||
return;
|
||||
}
|
||||
|
||||
const utterance = Buffer.concat(utteranceChunks);
|
||||
const startedAt = state.startedAt;
|
||||
const endedAt = Date.now();
|
||||
|
||||
utteranceChunks = [];
|
||||
state = { status: "listening", rollingPrefixBytes: prefixBuffer.byteLength };
|
||||
|
||||
await params.callbacks.onSpeechStopped();
|
||||
|
||||
if (utterance.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
enqueueUtteranceSubmission({
|
||||
pcm16: utterance,
|
||||
sampleRate: detector.requiredSampleRate,
|
||||
format: buildVoicePcmFormat(detector.requiredSampleRate),
|
||||
startedAt,
|
||||
endedAt,
|
||||
});
|
||||
}
|
||||
|
||||
detector.on("speech_started", () => {
|
||||
void runSerial(handleSpeechStarted);
|
||||
});
|
||||
detector.on("speech_stopped", () => {
|
||||
void runSerial(handleSpeechStopped);
|
||||
});
|
||||
detector.on("error", fail);
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
await detector.connect();
|
||||
state = { status: "listening", rollingPrefixBytes: prefixBuffer.byteLength };
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await runSerial(async () => {
|
||||
detector.close();
|
||||
prefixBuffer.clear();
|
||||
utteranceChunks = [];
|
||||
resampler?.reset();
|
||||
resampler = null;
|
||||
state = { status: "idle" };
|
||||
});
|
||||
},
|
||||
|
||||
async appendClientChunk(input): Promise<void> {
|
||||
await runSerial(async () => {
|
||||
if (state.status === "idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
const pcm16 = Buffer.from(input.audioBase64, "base64");
|
||||
if (pcm16.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedInputRate =
|
||||
parsePcmRateFromFormat(input.format, detector.requiredSampleRate) ??
|
||||
detector.requiredSampleRate;
|
||||
|
||||
if (parsedInputRate !== inputRate) {
|
||||
inputRate = parsedInputRate;
|
||||
resampler =
|
||||
inputRate === detector.requiredSampleRate
|
||||
? null
|
||||
: new Pcm16MonoResampler({
|
||||
inputRate,
|
||||
outputRate: detector.requiredSampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized =
|
||||
resampler === null ? pcm16 : resampler.processChunk(pcm16);
|
||||
if (normalized.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
prefixBuffer.append(normalized);
|
||||
|
||||
if (state.status === "listening") {
|
||||
state = {
|
||||
status: "listening",
|
||||
rollingPrefixBytes: prefixBuffer.byteLength,
|
||||
};
|
||||
} else if (state.status === "capturing") {
|
||||
utteranceChunks.push(normalized);
|
||||
state = {
|
||||
...state,
|
||||
rollingPrefixBytes: prefixBuffer.byteLength,
|
||||
utteranceBytes: state.utteranceBytes + normalized.length,
|
||||
};
|
||||
}
|
||||
|
||||
detector.appendPcm16(normalized);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-co
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { PushService } from "./push/push-service.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
||||
import type { Resolvable } from "./speech/provider-resolver.js";
|
||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||
@@ -245,6 +246,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly stt: Resolvable<SpeechToTextProvider | null>;
|
||||
private readonly tts: Resolvable<TextToSpeechProvider | null>;
|
||||
private readonly turnDetection: Resolvable<TurnDetectionProvider | null>;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly dictation: {
|
||||
finalTimeoutMs?: number;
|
||||
@@ -301,6 +303,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
createAgentMcpTransport: AgentMcpTransportFactory,
|
||||
wsConfig: WebSocketServerConfig,
|
||||
speech?: {
|
||||
turnDetection: Resolvable<TurnDetectionProvider | null>;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
},
|
||||
@@ -338,6 +341,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.turnDetection = speech?.turnDetection ?? null;
|
||||
this.stt = speech?.stt ?? null;
|
||||
this.tts = speech?.tts ?? null;
|
||||
this.terminalManager = terminalManager ?? null;
|
||||
@@ -633,7 +637,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
stt: this.stt,
|
||||
tts: this.tts,
|
||||
terminalManager: this.terminalManager,
|
||||
voice: this.voice ?? undefined,
|
||||
voice: {
|
||||
...(this.voice ?? {}),
|
||||
turnDetection: this.turnDetection,
|
||||
},
|
||||
voiceBridge: {
|
||||
registerVoiceSpeakHandler: (agentId, handler) => {
|
||||
this.voiceSpeakHandlers.set(agentId, handler);
|
||||
|
||||
@@ -1236,6 +1236,13 @@ export const TranscriptionResultMessageSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export const VoiceInputStateMessageSchema = z.object({
|
||||
type: z.literal('voice_input_state'),
|
||||
payload: z.object({
|
||||
isSpeaking: z.boolean(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const DictationStreamAckMessageSchema = z.object({
|
||||
type: z.literal('dictation_stream_ack'),
|
||||
payload: z.object({
|
||||
@@ -2159,6 +2166,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [
|
||||
AssistantChunkMessageSchema,
|
||||
AudioOutputMessageSchema,
|
||||
TranscriptionResultMessageSchema,
|
||||
VoiceInputStateMessageSchema,
|
||||
DictationStreamAckMessageSchema,
|
||||
DictationStreamFinishAcceptedMessageSchema,
|
||||
DictationStreamPartialMessageSchema,
|
||||
|
||||
Reference in New Issue
Block a user