@vellumai/assistant 0.11.3-staging.3 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/config-schema.test.ts +3 -0
- package/src/config/schemas/__tests__/live-voice.test.ts +35 -0
- package/src/config/schemas/live-voice.ts +25 -0
- package/src/live-voice/__tests__/live-voice-integration.test.ts +5 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +278 -1
- package/src/live-voice/live-voice-session.ts +315 -20
- package/src/stt/__tests__/speech-energy.test.ts +79 -0
- package/src/stt/speech-energy.ts +115 -12
package/package.json
CHANGED
|
@@ -932,6 +932,9 @@ describe("AssistantConfigSchema", () => {
|
|
|
932
932
|
silenceThresholdMs: 1200,
|
|
933
933
|
maxTurnDurationMs: 30000,
|
|
934
934
|
bargeInMinSpeechMs: 250,
|
|
935
|
+
echoBargeInMargin: 1.5,
|
|
936
|
+
echoEmaHalfLifeMs: 400,
|
|
937
|
+
echoDrainSlackMs: 300,
|
|
935
938
|
},
|
|
936
939
|
frontModel: {
|
|
937
940
|
endpointDecisionTimeoutMs: 1200,
|
|
@@ -34,6 +34,9 @@ describe("LiveVoiceVadConfigSchema", () => {
|
|
|
34
34
|
silenceThresholdMs: 1200,
|
|
35
35
|
maxTurnDurationMs: 30_000,
|
|
36
36
|
bargeInMinSpeechMs: 250,
|
|
37
|
+
echoBargeInMargin: 1.5,
|
|
38
|
+
echoEmaHalfLifeMs: 400,
|
|
39
|
+
echoDrainSlackMs: 300,
|
|
37
40
|
});
|
|
38
41
|
});
|
|
39
42
|
|
|
@@ -75,6 +78,35 @@ describe("LiveVoiceVadConfigSchema", () => {
|
|
|
75
78
|
});
|
|
76
79
|
expect(result.success).toBe(false);
|
|
77
80
|
});
|
|
81
|
+
|
|
82
|
+
test("accepts echo gate overrides", () => {
|
|
83
|
+
const parsed = LiveVoiceVadConfigSchema.parse({
|
|
84
|
+
echoBargeInMargin: 2.25,
|
|
85
|
+
echoEmaHalfLifeMs: 250,
|
|
86
|
+
echoDrainSlackMs: 500,
|
|
87
|
+
});
|
|
88
|
+
expect(parsed.echoBargeInMargin).toBe(2.25);
|
|
89
|
+
expect(parsed.echoEmaHalfLifeMs).toBe(250);
|
|
90
|
+
expect(parsed.echoDrainSlackMs).toBe(500);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("rejects an echo margin that cannot exceed its reference", () => {
|
|
94
|
+
expect(
|
|
95
|
+
LiveVoiceVadConfigSchema.safeParse({ echoBargeInMargin: 1 }).success,
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("rejects invalid echo timing values", () => {
|
|
100
|
+
expect(
|
|
101
|
+
LiveVoiceVadConfigSchema.safeParse({ echoEmaHalfLifeMs: 0 }).success,
|
|
102
|
+
).toBe(false);
|
|
103
|
+
expect(
|
|
104
|
+
LiveVoiceVadConfigSchema.safeParse({ echoEmaHalfLifeMs: 250.5 }).success,
|
|
105
|
+
).toBe(false);
|
|
106
|
+
expect(
|
|
107
|
+
LiveVoiceVadConfigSchema.safeParse({ echoDrainSlackMs: -1 }).success,
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
78
110
|
});
|
|
79
111
|
|
|
80
112
|
describe("LiveVoiceFrontModelConfigSchema", () => {
|
|
@@ -191,6 +223,9 @@ describe("LiveVoiceConfigSchema", () => {
|
|
|
191
223
|
silenceThresholdMs: 1200,
|
|
192
224
|
maxTurnDurationMs: 30_000,
|
|
193
225
|
bargeInMinSpeechMs: 250,
|
|
226
|
+
echoBargeInMargin: 1.5,
|
|
227
|
+
echoEmaHalfLifeMs: 400,
|
|
228
|
+
echoDrainSlackMs: 300,
|
|
194
229
|
},
|
|
195
230
|
frontModel: FRONT_MODEL_DEFAULTS,
|
|
196
231
|
maxSessionDurationSeconds: 1800,
|
|
@@ -42,6 +42,31 @@ export const LiveVoiceVadConfigSchema = z
|
|
|
42
42
|
.describe(
|
|
43
43
|
"Sustained speech (ms) required before speech during assistant playback interrupts it — the default 'interrupt sensitivity' (higher = harder to interrupt). 0 disables the guard. Clients may override it per-session via the start frame. Raised from 60 so brief TTS bleed through imperfect echo cancellation no longer self-interrupts the assistant.",
|
|
44
44
|
),
|
|
45
|
+
echoBargeInMargin: z
|
|
46
|
+
.number({ error: "liveVoice.vad.echoBargeInMargin must be a number" })
|
|
47
|
+
.gt(1, "liveVoice.vad.echoBargeInMargin must be greater than 1")
|
|
48
|
+
.default(1.5)
|
|
49
|
+
.describe(
|
|
50
|
+
"Multiplier over the learned playback echo level that microphone input must exceed to count as speech during playback. Higher values reduce false interruptions but require louder barge-in speech.",
|
|
51
|
+
),
|
|
52
|
+
echoEmaHalfLifeMs: z
|
|
53
|
+
.number({ error: "liveVoice.vad.echoEmaHalfLifeMs must be a number" })
|
|
54
|
+
.int("liveVoice.vad.echoEmaHalfLifeMs must be an integer")
|
|
55
|
+
.positive("liveVoice.vad.echoEmaHalfLifeMs must be a positive integer")
|
|
56
|
+
.default(400)
|
|
57
|
+
.describe(
|
|
58
|
+
"Half-life (ms) of the learned playback echo level. Smaller values adapt faster to changing speaker volume; larger values are steadier against transients.",
|
|
59
|
+
),
|
|
60
|
+
echoDrainSlackMs: z
|
|
61
|
+
.number({ error: "liveVoice.vad.echoDrainSlackMs must be a number" })
|
|
62
|
+
.int("liveVoice.vad.echoDrainSlackMs must be an integer")
|
|
63
|
+
.nonnegative(
|
|
64
|
+
"liveVoice.vad.echoDrainSlackMs must be a nonnegative integer",
|
|
65
|
+
)
|
|
66
|
+
.default(300)
|
|
67
|
+
.describe(
|
|
68
|
+
"Time (ms) after the estimated client playback tail during which microphone input can still be classified as playback echo.",
|
|
69
|
+
),
|
|
45
70
|
})
|
|
46
71
|
.describe(
|
|
47
72
|
"Voice-activity-detection tuning for live voice sessions (open-mic turn segmentation)",
|
|
@@ -254,6 +254,9 @@ function createMultiCycleHarness(startVoiceTurn: LiveVoiceTurnStarter) {
|
|
|
254
254
|
const session = createLiveVoiceSession(context, {
|
|
255
255
|
// Credential-free harness: every leg is injected, so skip the preflight.
|
|
256
256
|
resolveCredentialReadiness: null,
|
|
257
|
+
// These cycle mechanics use one discrete mic chunk per utterance. Keep
|
|
258
|
+
// the adaptive playback classifier out of their timing model.
|
|
259
|
+
echoBargeInMargin: 1,
|
|
257
260
|
resolveTranscriber,
|
|
258
261
|
startVoiceTurn,
|
|
259
262
|
streamTtsAudio,
|
|
@@ -546,6 +549,8 @@ describe("LiveVoiceSession integration smoke harness", () => {
|
|
|
546
549
|
let turnCount = 0;
|
|
547
550
|
const session = createLiveVoiceSession(context, {
|
|
548
551
|
resolveCredentialReadiness: null,
|
|
552
|
+
// This cycle mechanic uses one discrete mic chunk per utterance.
|
|
553
|
+
echoBargeInMargin: 1,
|
|
549
554
|
resolveTranscriber,
|
|
550
555
|
startVoiceTurn,
|
|
551
556
|
streamTtsAudio,
|
|
@@ -83,6 +83,21 @@ function pcm(amplitude: number, sampleCount = 240): Uint8Array {
|
|
|
83
83
|
return new Uint8Array(buffer);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function tonePcm(
|
|
87
|
+
amplitude: number,
|
|
88
|
+
frequencyHz: number,
|
|
89
|
+
sampleCount = 240,
|
|
90
|
+
): Uint8Array {
|
|
91
|
+
const buffer = Buffer.alloc(sampleCount * 2);
|
|
92
|
+
for (let index = 0; index < sampleCount; index += 1) {
|
|
93
|
+
const sample = Math.round(
|
|
94
|
+
amplitude * Math.sin((2 * Math.PI * frequencyHz * index) / SAMPLE_RATE),
|
|
95
|
+
);
|
|
96
|
+
buffer.writeInt16LE(sample, index * 2);
|
|
97
|
+
}
|
|
98
|
+
return new Uint8Array(buffer);
|
|
99
|
+
}
|
|
100
|
+
|
|
86
101
|
// 10 ms of speech at 24 kHz.
|
|
87
102
|
const LOUD_CHUNK = pcm(8_000);
|
|
88
103
|
// 300 ms of speech at 24 kHz — comfortably exceeds the default sustained-speech
|
|
@@ -164,6 +179,9 @@ function createHarness(options: {
|
|
|
164
179
|
turnDetectorConfig?: TurnDetectorConfig;
|
|
165
180
|
speechEnergyThreshold?: number;
|
|
166
181
|
bargeInMinSpeechMs?: number;
|
|
182
|
+
echoBargeInMargin?: number;
|
|
183
|
+
echoEmaHalfLifeMs?: number;
|
|
184
|
+
echoDrainSlackMs?: number;
|
|
167
185
|
frontDecider?: VoiceFrontDecider | null;
|
|
168
186
|
frontModelConfig?: Partial<LiveVoiceFrontModelConfig>;
|
|
169
187
|
emitMetrics?: boolean;
|
|
@@ -242,6 +260,10 @@ function createHarness(options: {
|
|
|
242
260
|
(options.viaFactory ? undefined : { silenceThresholdMs: 40 }),
|
|
243
261
|
speechEnergyThreshold: options.speechEnergyThreshold,
|
|
244
262
|
bargeInMinSpeechMs: options.bargeInMinSpeechMs,
|
|
263
|
+
echoBargeInMargin:
|
|
264
|
+
options.echoBargeInMargin ?? (options.viaFactory ? undefined : 1),
|
|
265
|
+
echoEmaHalfLifeMs: options.echoEmaHalfLifeMs,
|
|
266
|
+
echoDrainSlackMs: options.echoDrainSlackMs,
|
|
245
267
|
...(options.frontDecider !== undefined
|
|
246
268
|
? { frontDecider: options.frontDecider }
|
|
247
269
|
: {}),
|
|
@@ -291,6 +313,15 @@ function makeTtsChunk(text: string): LiveVoiceTtsAudioChunk {
|
|
|
291
313
|
};
|
|
292
314
|
}
|
|
293
315
|
|
|
316
|
+
function makePcmTtsChunk(audio: Uint8Array): LiveVoiceTtsAudioChunk {
|
|
317
|
+
return {
|
|
318
|
+
type: "tts_audio",
|
|
319
|
+
contentType: "audio/pcm",
|
|
320
|
+
sampleRate: SAMPLE_RATE,
|
|
321
|
+
dataBase64: Buffer.from(audio).toString("base64"),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
294
325
|
function makeTtsResult(text: string): LiveVoiceTtsResult {
|
|
295
326
|
return {
|
|
296
327
|
provider: "fish-audio",
|
|
@@ -3209,6 +3240,9 @@ describe("LiveVoiceSession VAD threshold configuration", () => {
|
|
|
3209
3240
|
silenceThresholdMs: 1200,
|
|
3210
3241
|
maxTurnDurationMs: 30_000,
|
|
3211
3242
|
bargeInMinSpeechMs: 250,
|
|
3243
|
+
echoBargeInMargin: 1.5,
|
|
3244
|
+
echoEmaHalfLifeMs: 400,
|
|
3245
|
+
echoDrainSlackMs: 300,
|
|
3212
3246
|
});
|
|
3213
3247
|
|
|
3214
3248
|
const { frames, session } = createHarness({ viaFactory: true });
|
|
@@ -3322,6 +3356,10 @@ describe("LiveVoiceSession sustained-speech barge-in guard", () => {
|
|
|
3322
3356
|
// detector timers stay out of the guard's audio-duration accounting.
|
|
3323
3357
|
function createSpeakingTurnHarness(options: {
|
|
3324
3358
|
bargeInMinSpeechMs: number;
|
|
3359
|
+
echoEmaHalfLifeMs?: number;
|
|
3360
|
+
echoBargeInMargin?: number;
|
|
3361
|
+
echoDrainSlackMs?: number;
|
|
3362
|
+
ttsAudio?: Uint8Array;
|
|
3325
3363
|
finals?: string[];
|
|
3326
3364
|
startFrame?: LiveVoiceClientStartFrame;
|
|
3327
3365
|
}) {
|
|
@@ -3332,7 +3370,11 @@ describe("LiveVoiceSession sustained-speech barge-in guard", () => {
|
|
|
3332
3370
|
return { turnId: "bridge-turn", abort };
|
|
3333
3371
|
});
|
|
3334
3372
|
const streamTtsAudio = mock(async (ttsOptions: LiveVoiceTtsOptions) => {
|
|
3335
|
-
ttsOptions.onAudioChunk(
|
|
3373
|
+
ttsOptions.onAudioChunk(
|
|
3374
|
+
options.ttsAudio
|
|
3375
|
+
? makePcmTtsChunk(options.ttsAudio)
|
|
3376
|
+
: makeTtsChunk("assistant audio"),
|
|
3377
|
+
);
|
|
3336
3378
|
return makeTtsResult("assistant audio");
|
|
3337
3379
|
});
|
|
3338
3380
|
const harness = createHarness({
|
|
@@ -3340,6 +3382,9 @@ describe("LiveVoiceSession sustained-speech barge-in guard", () => {
|
|
|
3340
3382
|
startVoiceTurn,
|
|
3341
3383
|
streamTtsAudio,
|
|
3342
3384
|
bargeInMinSpeechMs: options.bargeInMinSpeechMs,
|
|
3385
|
+
echoEmaHalfLifeMs: options.echoEmaHalfLifeMs ?? 4,
|
|
3386
|
+
echoBargeInMargin: options.echoBargeInMargin ?? 1,
|
|
3387
|
+
echoDrainSlackMs: options.echoDrainSlackMs ?? 60_000,
|
|
3343
3388
|
turnDetectorConfig: { silenceThresholdMs: 5_000 },
|
|
3344
3389
|
...(options.startFrame ? { startFrame: options.startFrame } : {}),
|
|
3345
3390
|
});
|
|
@@ -3653,6 +3698,238 @@ describe("LiveVoiceSession sustained-speech barge-in guard", () => {
|
|
|
3653
3698
|
).toMatchObject({ type: "turn_cancelled", turnId: "live-turn-1" });
|
|
3654
3699
|
await waitFor(() => abort.mock.calls.length === 1);
|
|
3655
3700
|
});
|
|
3701
|
+
|
|
3702
|
+
describe("echo-adaptive barge-in", () => {
|
|
3703
|
+
const playbackEchoChunk = tonePcm(4_700, 200);
|
|
3704
|
+
const bargeInSpeechChunk = tonePcm(9_400, 530);
|
|
3705
|
+
const playbackReference = tonePcm(4_700, 200, SAMPLE_RATE * 2);
|
|
3706
|
+
|
|
3707
|
+
test("steady loud playback echo does not interrupt the turn", async () => {
|
|
3708
|
+
const { frames, session, abort, speakFirstReply } =
|
|
3709
|
+
createSpeakingTurnHarness({
|
|
3710
|
+
bargeInMinSpeechMs: 60,
|
|
3711
|
+
echoBargeInMargin: 1.5,
|
|
3712
|
+
echoEmaHalfLifeMs: 40,
|
|
3713
|
+
ttsAudio: playbackReference,
|
|
3714
|
+
});
|
|
3715
|
+
await speakFirstReply();
|
|
3716
|
+
const speechStartedBaseline = countType(frames, "speech_started");
|
|
3717
|
+
|
|
3718
|
+
for (let index = 0; index < 40; index += 1) {
|
|
3719
|
+
await session.handleBinaryAudio(playbackEchoChunk);
|
|
3720
|
+
}
|
|
3721
|
+
await flushAsyncCallbacks();
|
|
3722
|
+
|
|
3723
|
+
expect(countType(frames, "speech_started")).toBe(speechStartedBaseline);
|
|
3724
|
+
expect(countType(frames, "turn_cancelled")).toBe(0);
|
|
3725
|
+
expect(abort).not.toHaveBeenCalled();
|
|
3726
|
+
});
|
|
3727
|
+
|
|
3728
|
+
test("speech above the learned echo margin still interrupts", async () => {
|
|
3729
|
+
const { frames, session, abort, speakFirstReply } =
|
|
3730
|
+
createSpeakingTurnHarness({
|
|
3731
|
+
bargeInMinSpeechMs: 60,
|
|
3732
|
+
echoBargeInMargin: 1.5,
|
|
3733
|
+
echoEmaHalfLifeMs: 400,
|
|
3734
|
+
ttsAudio: playbackReference,
|
|
3735
|
+
});
|
|
3736
|
+
await speakFirstReply();
|
|
3737
|
+
|
|
3738
|
+
for (let index = 0; index < 25; index += 1) {
|
|
3739
|
+
await session.handleBinaryAudio(playbackEchoChunk);
|
|
3740
|
+
}
|
|
3741
|
+
for (let index = 0; index < 8; index += 1) {
|
|
3742
|
+
await session.handleBinaryAudio(bargeInSpeechChunk);
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
await waitFor(() => countType(frames, "turn_cancelled") === 1);
|
|
3746
|
+
await waitFor(() => abort.mock.calls.length === 1);
|
|
3747
|
+
});
|
|
3748
|
+
|
|
3749
|
+
test("classified echo resets a partial guard run immediately", async () => {
|
|
3750
|
+
const { frames, session, abort, speakFirstReply } =
|
|
3751
|
+
createSpeakingTurnHarness({
|
|
3752
|
+
bargeInMinSpeechMs: 60,
|
|
3753
|
+
echoBargeInMargin: 1.5,
|
|
3754
|
+
echoEmaHalfLifeMs: 400,
|
|
3755
|
+
ttsAudio: playbackReference,
|
|
3756
|
+
});
|
|
3757
|
+
await speakFirstReply();
|
|
3758
|
+
|
|
3759
|
+
for (let index = 0; index < 25; index += 1) {
|
|
3760
|
+
await session.handleBinaryAudio(playbackEchoChunk);
|
|
3761
|
+
}
|
|
3762
|
+
for (let index = 0; index < 5; index += 1) {
|
|
3763
|
+
await session.handleBinaryAudio(bargeInSpeechChunk);
|
|
3764
|
+
}
|
|
3765
|
+
await session.handleBinaryAudio(playbackEchoChunk);
|
|
3766
|
+
await session.handleBinaryAudio(bargeInSpeechChunk);
|
|
3767
|
+
await flushAsyncCallbacks();
|
|
3768
|
+
|
|
3769
|
+
expect(countType(frames, "turn_cancelled")).toBe(0);
|
|
3770
|
+
expect(abort).not.toHaveBeenCalled();
|
|
3771
|
+
|
|
3772
|
+
for (let index = 0; index < 5; index += 1) {
|
|
3773
|
+
await session.handleBinaryAudio(bargeInSpeechChunk);
|
|
3774
|
+
}
|
|
3775
|
+
await waitFor(() => countType(frames, "turn_cancelled") === 1);
|
|
3776
|
+
});
|
|
3777
|
+
|
|
3778
|
+
test("quiet playback keeps fixed-threshold barge-in sensitivity", async () => {
|
|
3779
|
+
const { frames, session, abort, speakFirstReply } =
|
|
3780
|
+
createSpeakingTurnHarness({
|
|
3781
|
+
bargeInMinSpeechMs: 60,
|
|
3782
|
+
echoBargeInMargin: 1.5,
|
|
3783
|
+
echoEmaHalfLifeMs: 40,
|
|
3784
|
+
ttsAudio: playbackReference,
|
|
3785
|
+
});
|
|
3786
|
+
await speakFirstReply();
|
|
3787
|
+
|
|
3788
|
+
for (let index = 0; index < 31; index += 1) {
|
|
3789
|
+
await session.handleBinaryAudio(pcm(200));
|
|
3790
|
+
}
|
|
3791
|
+
for (let index = 0; index < 7; index += 1) {
|
|
3792
|
+
await session.handleBinaryAudio(bargeInSpeechChunk);
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
await waitFor(() => countType(frames, "turn_cancelled") === 1);
|
|
3796
|
+
await waitFor(() => abort.mock.calls.length === 1);
|
|
3797
|
+
});
|
|
3798
|
+
|
|
3799
|
+
test("playback echo is not forwarded as transcription pre-roll", async () => {
|
|
3800
|
+
const { frames, session, transcribers, speakFirstReply } =
|
|
3801
|
+
createSpeakingTurnHarness({
|
|
3802
|
+
bargeInMinSpeechMs: 60,
|
|
3803
|
+
echoBargeInMargin: 1.5,
|
|
3804
|
+
echoEmaHalfLifeMs: 40,
|
|
3805
|
+
ttsAudio: playbackReference,
|
|
3806
|
+
});
|
|
3807
|
+
await speakFirstReply();
|
|
3808
|
+
|
|
3809
|
+
const echoChunk = playbackEchoChunk;
|
|
3810
|
+
for (let index = 0; index < 5; index += 1) {
|
|
3811
|
+
await session.handleBinaryAudio(echoChunk);
|
|
3812
|
+
}
|
|
3813
|
+
for (let index = 0; index < 7; index += 1) {
|
|
3814
|
+
await session.handleBinaryAudio(bargeInSpeechChunk);
|
|
3815
|
+
}
|
|
3816
|
+
await waitFor(() => countType(frames, "turn_cancelled") === 1);
|
|
3817
|
+
|
|
3818
|
+
const echoBuffer = Buffer.from(echoChunk);
|
|
3819
|
+
expect(
|
|
3820
|
+
transcribers.some((transcriber) =>
|
|
3821
|
+
transcriber.received.some((buffer) => buffer.equals(echoBuffer)),
|
|
3822
|
+
),
|
|
3823
|
+
).toBe(false);
|
|
3824
|
+
});
|
|
3825
|
+
|
|
3826
|
+
test("instant barge-in remains protected from onset echo", async () => {
|
|
3827
|
+
const { frames, session, abort, speakFirstReply } =
|
|
3828
|
+
createSpeakingTurnHarness({
|
|
3829
|
+
bargeInMinSpeechMs: 0,
|
|
3830
|
+
echoBargeInMargin: 1.5,
|
|
3831
|
+
echoEmaHalfLifeMs: 40,
|
|
3832
|
+
ttsAudio: playbackReference,
|
|
3833
|
+
});
|
|
3834
|
+
await speakFirstReply();
|
|
3835
|
+
|
|
3836
|
+
for (let index = 0; index < 30; index += 1) {
|
|
3837
|
+
await session.handleBinaryAudio(playbackEchoChunk);
|
|
3838
|
+
}
|
|
3839
|
+
await flushAsyncCallbacks();
|
|
3840
|
+
expect(countType(frames, "turn_cancelled")).toBe(0);
|
|
3841
|
+
|
|
3842
|
+
await session.handleBinaryAudio(bargeInSpeechChunk);
|
|
3843
|
+
await waitFor(() => countType(frames, "turn_cancelled") === 1);
|
|
3844
|
+
await waitFor(() => abort.mock.calls.length === 1);
|
|
3845
|
+
});
|
|
3846
|
+
|
|
3847
|
+
test("echo suppression covers the client playback tail", async () => {
|
|
3848
|
+
const { frames, session, abort, speakFirstReply, completeFirstReply } =
|
|
3849
|
+
createSpeakingTurnHarness({
|
|
3850
|
+
bargeInMinSpeechMs: 60,
|
|
3851
|
+
echoBargeInMargin: 1.5,
|
|
3852
|
+
echoEmaHalfLifeMs: 40,
|
|
3853
|
+
ttsAudio: playbackReference,
|
|
3854
|
+
});
|
|
3855
|
+
await speakFirstReply();
|
|
3856
|
+
completeFirstReply();
|
|
3857
|
+
await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
|
|
3858
|
+
const speechStartedBaseline = countType(frames, "speech_started");
|
|
3859
|
+
|
|
3860
|
+
for (let index = 0; index < 40; index += 1) {
|
|
3861
|
+
await session.handleBinaryAudio(playbackEchoChunk);
|
|
3862
|
+
}
|
|
3863
|
+
await flushAsyncCallbacks();
|
|
3864
|
+
|
|
3865
|
+
expect(countType(frames, "speech_started")).toBe(speechStartedBaseline);
|
|
3866
|
+
expect(countType(frames, "turn_cancelled")).toBe(0);
|
|
3867
|
+
expect(abort).not.toHaveBeenCalled();
|
|
3868
|
+
});
|
|
3869
|
+
|
|
3870
|
+
test("speech at playback onset cannot seed its own echo threshold", async () => {
|
|
3871
|
+
const { frames, session, abort, speakFirstReply, transcribers } =
|
|
3872
|
+
createSpeakingTurnHarness({
|
|
3873
|
+
bargeInMinSpeechMs: 250,
|
|
3874
|
+
echoBargeInMargin: 1.5,
|
|
3875
|
+
echoEmaHalfLifeMs: 400,
|
|
3876
|
+
ttsAudio: playbackReference,
|
|
3877
|
+
});
|
|
3878
|
+
await speakFirstReply();
|
|
3879
|
+
|
|
3880
|
+
const onsetSpeech = tonePcm(9_400, 530, 7_200);
|
|
3881
|
+
await session.handleBinaryAudio(onsetSpeech);
|
|
3882
|
+
|
|
3883
|
+
await waitFor(() => countType(frames, "turn_cancelled") === 1);
|
|
3884
|
+
await waitFor(() => abort.mock.calls.length === 1);
|
|
3885
|
+
expect(
|
|
3886
|
+
transcribers.some((transcriber) =>
|
|
3887
|
+
transcriber.received.some((buffer) =>
|
|
3888
|
+
buffer.equals(Buffer.from(onsetSpeech)),
|
|
3889
|
+
),
|
|
3890
|
+
),
|
|
3891
|
+
).toBe(true);
|
|
3892
|
+
});
|
|
3893
|
+
|
|
3894
|
+
test("speech already in progress bypasses playback warm-up", async () => {
|
|
3895
|
+
let callbacks: VoiceTurnCallbacks | undefined;
|
|
3896
|
+
const abort = mock();
|
|
3897
|
+
const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
|
|
3898
|
+
callbacks ??= options.callbacks;
|
|
3899
|
+
return { turnId: "bridge-turn", abort };
|
|
3900
|
+
});
|
|
3901
|
+
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
3902
|
+
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
3903
|
+
return makeTtsResult("assistant audio");
|
|
3904
|
+
});
|
|
3905
|
+
const { frames, session } = createHarness({
|
|
3906
|
+
finals: ["what's the weather", "actually never mind"],
|
|
3907
|
+
startVoiceTurn,
|
|
3908
|
+
streamTtsAudio,
|
|
3909
|
+
bargeInMinSpeechMs: 60,
|
|
3910
|
+
echoBargeInMargin: 1.5,
|
|
3911
|
+
echoEmaHalfLifeMs: 400,
|
|
3912
|
+
echoDrainSlackMs: 60_000,
|
|
3913
|
+
turnDetectorConfig: { silenceThresholdMs: 5_000 },
|
|
3914
|
+
});
|
|
3915
|
+
|
|
3916
|
+
await session.start();
|
|
3917
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
3918
|
+
await session.handleClientFrame({ type: "ptt_release" });
|
|
3919
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
3920
|
+
for (let index = 0; index < 3; index += 1) {
|
|
3921
|
+
await session.handleBinaryAudio(pcm(3_000));
|
|
3922
|
+
}
|
|
3923
|
+
callbacks?.assistant_text_delta?.(makeTextDelta("It is sunny today."));
|
|
3924
|
+
await waitFor(() => frames.some((frame) => frame.type === "tts_audio"));
|
|
3925
|
+
for (let index = 0; index < 3; index += 1) {
|
|
3926
|
+
await session.handleBinaryAudio(pcm(3_000));
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
await waitFor(() => countType(frames, "turn_cancelled") === 1);
|
|
3930
|
+
await waitFor(() => abort.mock.calls.length === 1);
|
|
3931
|
+
});
|
|
3932
|
+
});
|
|
3656
3933
|
});
|
|
3657
3934
|
|
|
3658
3935
|
describe("LiveVoiceSession unified front-door endpointing", () => {
|
|
@@ -57,7 +57,11 @@ import {
|
|
|
57
57
|
dominantLanguageTag,
|
|
58
58
|
voteDominantLanguage,
|
|
59
59
|
} from "../stt/language-metadata.js";
|
|
60
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
DEFAULT_SPEECH_ENERGY_THRESHOLD,
|
|
62
|
+
pcm16MaxNormalizedCorrelation,
|
|
63
|
+
pcm16MeanAmplitude,
|
|
64
|
+
} from "../stt/speech-energy.js";
|
|
61
65
|
import type {
|
|
62
66
|
StreamingTranscriber,
|
|
63
67
|
SttProviderId,
|
|
@@ -132,6 +136,13 @@ type LiveVoiceSessionState =
|
|
|
132
136
|
| "failed"
|
|
133
137
|
| "closed";
|
|
134
138
|
|
|
139
|
+
type VadEnergyClassification = "speech" | "silence" | "echo";
|
|
140
|
+
|
|
141
|
+
interface VadClassifiedChunk {
|
|
142
|
+
readonly chunk: Buffer;
|
|
143
|
+
readonly classification: VadEnergyClassification;
|
|
144
|
+
}
|
|
145
|
+
|
|
135
146
|
// Cap on audio buffered while a server-VAD utterance waits for its
|
|
136
147
|
// transcriber (PCM16 mono seconds; oldest chunks are dropped past the cap).
|
|
137
148
|
const SERVER_VAD_PENDING_AUDIO_MAX_SECONDS = 10;
|
|
@@ -151,6 +162,25 @@ const FINALIZE_GRACE_MS = 1_000;
|
|
|
151
162
|
// liveVoice.vad.bargeInMinSpeechMs schema default; 0 disables the guard for
|
|
152
163
|
// instant barge-in.
|
|
153
164
|
const DEFAULT_BARGE_IN_MIN_SPEECH_MS = 250;
|
|
165
|
+
// The playback echo gate learns microphone energy while assistant audio is
|
|
166
|
+
// expected at the speaker. Input must rise above the learned level by this
|
|
167
|
+
// margin to count as user speech.
|
|
168
|
+
const DEFAULT_ECHO_BARGE_IN_MARGIN = 1.5;
|
|
169
|
+
const DEFAULT_ECHO_EMA_HALF_LIFE_MS = 400;
|
|
170
|
+
// Before learning a microphone power baseline, compare a short input window
|
|
171
|
+
// with the PCM sent to the speaker. This keeps a user's first interruption
|
|
172
|
+
// from becoming its own echo threshold.
|
|
173
|
+
const ECHO_CORRELATION_PROBE_MS = 100;
|
|
174
|
+
const ECHO_CORRELATION_MIN_MS = 50;
|
|
175
|
+
const ECHO_CORRELATION_THRESHOLD = 0.65;
|
|
176
|
+
const ECHO_REFERENCE_MAX_MS = 10_000;
|
|
177
|
+
// Echo should reach the microphone near playback onset. If no signal arrives
|
|
178
|
+
// within this much input audio, the gate returns to the fixed base threshold.
|
|
179
|
+
// The same interval expires a learned reference after a real silent gap.
|
|
180
|
+
const ECHO_ONSET_ELIGIBILITY_MS = 300;
|
|
181
|
+
// Client buffering makes audible playback trail the server's send-time
|
|
182
|
+
// estimate. Keep the echo window open briefly past that estimate.
|
|
183
|
+
const DEFAULT_ECHO_DRAIN_SLACK_MS = 300;
|
|
154
184
|
// Mirrors MediaTurnDetector's DEFAULT_SILENCE_THRESHOLD_MS: the session
|
|
155
185
|
// tracks the effective trailing-silence threshold (the detector keeps its own
|
|
156
186
|
// copy private) so the endpoint decider can report the pause length.
|
|
@@ -281,6 +311,17 @@ export interface LiveVoiceSessionOptions {
|
|
|
281
311
|
* defaults to `DEFAULT_BARGE_IN_MIN_SPEECH_MS`.
|
|
282
312
|
*/
|
|
283
313
|
bargeInMinSpeechMs?: number;
|
|
314
|
+
/**
|
|
315
|
+
* Multiplier over the learned playback echo level that input must exceed
|
|
316
|
+
* to count as speech while assistant audio is playing. Values at or below
|
|
317
|
+
* 1 disable adaptation for internal fixed-gate callers; workspace config
|
|
318
|
+
* requires a value greater than 1.
|
|
319
|
+
*/
|
|
320
|
+
echoBargeInMargin?: number;
|
|
321
|
+
/** Half-life in milliseconds for the learned playback echo level. */
|
|
322
|
+
echoEmaHalfLifeMs?: number;
|
|
323
|
+
/** Extra time after the playback estimate during which echo is expected. */
|
|
324
|
+
echoDrainSlackMs?: number;
|
|
284
325
|
/**
|
|
285
326
|
* Overrides the bounded wait for the shared transcriber's finalize
|
|
286
327
|
* flush in persistent mode (test hook). Defaults to `FINALIZE_GRACE_MS`.
|
|
@@ -1033,9 +1074,29 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1033
1074
|
private failureCode: LiveVoiceProtocolErrorCode | null = null;
|
|
1034
1075
|
// Non-null iff the start frame requested turnDetection "server_vad".
|
|
1035
1076
|
private readonly turnDetector: MediaTurnDetector | null;
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1077
|
+
// Base energy gate for server-VAD speech classification. During estimated
|
|
1078
|
+
// playback, classifyVadEnergy raises this above the learned echo level.
|
|
1038
1079
|
private readonly speechEnergyThreshold: number | undefined;
|
|
1080
|
+
private readonly echoBargeInMargin: number;
|
|
1081
|
+
private readonly echoEmaHalfLifeMs: number;
|
|
1082
|
+
private readonly echoDrainSlackMs: number;
|
|
1083
|
+
// Learned microphone energy attributable to assistant playback.
|
|
1084
|
+
private echoEnergyEma = 0;
|
|
1085
|
+
// Signal-bearing microphone audio held until it can be compared with the
|
|
1086
|
+
// assistant PCM. A nonmatch is replayed through VAD in original order.
|
|
1087
|
+
private echoProbeChunks: Buffer[] = [];
|
|
1088
|
+
// Recent raw assistant PCM from the current playback burst.
|
|
1089
|
+
private echoReferenceAudio = Buffer.alloc(0);
|
|
1090
|
+
private echoWindowTotalAudioMs = 0;
|
|
1091
|
+
// Consecutive sub-base input expires a reference that can no longer
|
|
1092
|
+
// describe audible playback.
|
|
1093
|
+
private echoSubBaseRunMs = 0;
|
|
1094
|
+
// Once onset eligibility lapses, later user speech cannot seed a new echo
|
|
1095
|
+
// reference in the same playback window.
|
|
1096
|
+
private echoOnsetLapsed = false;
|
|
1097
|
+
// A live speech run that predates playback belongs to the user and bypasses
|
|
1098
|
+
// echo warm-up until that run genuinely resets.
|
|
1099
|
+
private echoWindowGuardCarryover = false;
|
|
1039
1100
|
// Mutable so a mid-session `update_config` frame can retune "interrupt
|
|
1040
1101
|
// sensitivity" live (see applyConfigUpdate).
|
|
1041
1102
|
private bargeInMinSpeechMs: number;
|
|
@@ -1208,6 +1269,12 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1208
1269
|
context.startFrame.bargeInMinSpeechMs ??
|
|
1209
1270
|
options.bargeInMinSpeechMs ??
|
|
1210
1271
|
DEFAULT_BARGE_IN_MIN_SPEECH_MS;
|
|
1272
|
+
this.echoBargeInMargin =
|
|
1273
|
+
options.echoBargeInMargin ?? DEFAULT_ECHO_BARGE_IN_MARGIN;
|
|
1274
|
+
this.echoEmaHalfLifeMs =
|
|
1275
|
+
options.echoEmaHalfLifeMs ?? DEFAULT_ECHO_EMA_HALF_LIFE_MS;
|
|
1276
|
+
this.echoDrainSlackMs =
|
|
1277
|
+
options.echoDrainSlackMs ?? DEFAULT_ECHO_DRAIN_SLACK_MS;
|
|
1211
1278
|
this.finalizeGraceMs = options.finalizeGraceMs ?? FINALIZE_GRACE_MS;
|
|
1212
1279
|
this.frontDecider = options.frontDecider ?? null;
|
|
1213
1280
|
this.frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
|
|
@@ -1683,12 +1750,26 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1683
1750
|
return;
|
|
1684
1751
|
}
|
|
1685
1752
|
|
|
1686
|
-
const
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1753
|
+
for (const classified of this.classifyVadEnergy(chunk)) {
|
|
1754
|
+
await this.handleClassifiedVadAudio(detector, classified);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
private async handleClassifiedVadAudio(
|
|
1759
|
+
detector: MediaTurnDetector,
|
|
1760
|
+
classified: VadClassifiedChunk,
|
|
1761
|
+
): Promise<void> {
|
|
1762
|
+
const { chunk, classification: energyClassification } = classified;
|
|
1763
|
+
const hasSpeech = energyClassification === "speech";
|
|
1690
1764
|
detector.onMediaChunk(hasSpeech);
|
|
1691
|
-
this.trackBargeInGuard(
|
|
1765
|
+
this.trackBargeInGuard(energyClassification, chunk);
|
|
1766
|
+
|
|
1767
|
+
// Playback echo is neither user audio nor useful pre-roll. Dropping it
|
|
1768
|
+
// prevents the assistant's reply from reaching transcription as a ghost
|
|
1769
|
+
// follow-up turn.
|
|
1770
|
+
if (energyClassification === "echo") {
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1692
1773
|
|
|
1693
1774
|
// Idle mic: hold silent chunks in the bounded pre-roll instead of
|
|
1694
1775
|
// collecting or streaming them; flushed on speech onset so the
|
|
@@ -1766,6 +1847,193 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1766
1847
|
await this.routeVadAudio(utterance, chunk);
|
|
1767
1848
|
}
|
|
1768
1849
|
|
|
1850
|
+
/**
|
|
1851
|
+
* Classify microphone energy while keeping assistant playback echo out of
|
|
1852
|
+
* barge-in, turn detection, pre-roll, and transcription.
|
|
1853
|
+
*
|
|
1854
|
+
* A short onset probe must correlate with PCM sent to the speaker before its
|
|
1855
|
+
* microphone power can seed the adaptive threshold. Nonmatching probe audio
|
|
1856
|
+
* is replayed through VAD in original order, so a user who talks at playback
|
|
1857
|
+
* onset is neither learned as echo nor lost. Once seeded, the EMA follows
|
|
1858
|
+
* confirmed echo while speech above the learned margin remains frozen out.
|
|
1859
|
+
*/
|
|
1860
|
+
private classifyVadEnergy(chunk: Buffer): VadClassifiedChunk[] {
|
|
1861
|
+
const baseThreshold =
|
|
1862
|
+
this.speechEnergyThreshold ?? DEFAULT_SPEECH_ENERGY_THRESHOLD;
|
|
1863
|
+
const meanAmplitude = pcm16MeanAmplitude(chunk);
|
|
1864
|
+
if (
|
|
1865
|
+
this.echoBargeInMargin <= 1 ||
|
|
1866
|
+
!this.isAssistantPlaybackEchoPossible()
|
|
1867
|
+
) {
|
|
1868
|
+
this.resetEchoReference();
|
|
1869
|
+
return [
|
|
1870
|
+
this.classifyAtFixedThreshold(chunk, baseThreshold, meanAmplitude),
|
|
1871
|
+
];
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (this.echoWindowTotalAudioMs === 0) {
|
|
1875
|
+
this.echoWindowGuardCarryover =
|
|
1876
|
+
this.pendingBargeIn !== null && this.pendingBargeIn.speechMs > 0;
|
|
1877
|
+
} else if (this.pendingBargeIn === null) {
|
|
1878
|
+
this.echoWindowGuardCarryover = false;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const chunkMs = pcm16DurationMs(
|
|
1882
|
+
chunk.byteLength,
|
|
1883
|
+
this.context.startFrame.audio.sampleRate,
|
|
1884
|
+
);
|
|
1885
|
+
const onsetWasEligible =
|
|
1886
|
+
!this.echoOnsetLapsed &&
|
|
1887
|
+
this.echoWindowTotalAudioMs < ECHO_ONSET_ELIGIBILITY_MS;
|
|
1888
|
+
this.echoWindowTotalAudioMs += chunkMs;
|
|
1889
|
+
|
|
1890
|
+
if (this.echoProbeChunks.length > 0) {
|
|
1891
|
+
this.echoProbeChunks.push(Buffer.from(chunk));
|
|
1892
|
+
return this.resolveEchoProbe(baseThreshold);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
if (meanAmplitude <= baseThreshold) {
|
|
1896
|
+
this.echoSubBaseRunMs += chunkMs;
|
|
1897
|
+
if (this.echoSubBaseRunMs >= ECHO_ONSET_ELIGIBILITY_MS) {
|
|
1898
|
+
this.echoEnergyEma = 0;
|
|
1899
|
+
this.echoOnsetLapsed = true;
|
|
1900
|
+
}
|
|
1901
|
+
return [{ chunk, classification: "silence" }];
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
this.echoSubBaseRunMs = 0;
|
|
1905
|
+
if (
|
|
1906
|
+
this.echoEnergyEma === 0 &&
|
|
1907
|
+
onsetWasEligible &&
|
|
1908
|
+
!this.echoWindowGuardCarryover
|
|
1909
|
+
) {
|
|
1910
|
+
this.echoProbeChunks.push(Buffer.from(chunk));
|
|
1911
|
+
return this.resolveEchoProbe(baseThreshold);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (this.echoEnergyEma === 0) {
|
|
1915
|
+
this.echoOnsetLapsed = true;
|
|
1916
|
+
return [{ chunk, classification: "speech" }];
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const speechThreshold = Math.max(
|
|
1920
|
+
baseThreshold,
|
|
1921
|
+
this.echoBargeInMargin * this.echoEnergyEma,
|
|
1922
|
+
);
|
|
1923
|
+
if (meanAmplitude > speechThreshold) {
|
|
1924
|
+
const guardHasSpeech =
|
|
1925
|
+
this.pendingBargeIn !== null && this.pendingBargeIn.speechMs > 0;
|
|
1926
|
+
if (!guardHasSpeech && this.echoMatchesAssistant(chunk)) {
|
|
1927
|
+
this.updateEchoEnergy(meanAmplitude, chunkMs);
|
|
1928
|
+
return [{ chunk, classification: "echo" }];
|
|
1929
|
+
}
|
|
1930
|
+
return [{ chunk, classification: "speech" }];
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
this.updateEchoEnergy(meanAmplitude, chunkMs);
|
|
1934
|
+
return [{ chunk, classification: "echo" }];
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
private resolveEchoProbe(baseThreshold: number): VadClassifiedChunk[] {
|
|
1938
|
+
const probe = Buffer.concat(this.echoProbeChunks);
|
|
1939
|
+
const probeAudioMs = pcm16DurationMs(
|
|
1940
|
+
probe.byteLength,
|
|
1941
|
+
this.context.startFrame.audio.sampleRate,
|
|
1942
|
+
);
|
|
1943
|
+
if (
|
|
1944
|
+
probeAudioMs >= ECHO_CORRELATION_MIN_MS &&
|
|
1945
|
+
this.echoMatchesAssistant(probe)
|
|
1946
|
+
) {
|
|
1947
|
+
this.echoEnergyEma = Math.max(baseThreshold, pcm16MeanAmplitude(probe));
|
|
1948
|
+
const chunks = this.echoProbeChunks.splice(0);
|
|
1949
|
+
return chunks.map((chunk) => ({ chunk, classification: "echo" }));
|
|
1950
|
+
}
|
|
1951
|
+
if (probeAudioMs < ECHO_CORRELATION_PROBE_MS) {
|
|
1952
|
+
return [];
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
this.echoOnsetLapsed = true;
|
|
1956
|
+
const chunks = this.echoProbeChunks.splice(0);
|
|
1957
|
+
return chunks.map((chunk) =>
|
|
1958
|
+
this.classifyAtFixedThreshold(chunk, baseThreshold),
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
private echoMatchesAssistant(chunk: Buffer): boolean {
|
|
1963
|
+
const sampleRate = this.context.startFrame.audio.sampleRate;
|
|
1964
|
+
const minimumBytes = Math.ceil(
|
|
1965
|
+
(sampleRate * ECHO_CORRELATION_MIN_MS * 2) / 1_000,
|
|
1966
|
+
);
|
|
1967
|
+
if (
|
|
1968
|
+
chunk.byteLength < minimumBytes ||
|
|
1969
|
+
this.echoReferenceAudio.byteLength < minimumBytes
|
|
1970
|
+
) {
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
const probeByteLength = Math.min(
|
|
1974
|
+
chunk.byteLength,
|
|
1975
|
+
Math.ceil((sampleRate * ECHO_CORRELATION_PROBE_MS * 2) / 1_000),
|
|
1976
|
+
);
|
|
1977
|
+
return (
|
|
1978
|
+
pcm16MaxNormalizedCorrelation(
|
|
1979
|
+
chunk.subarray(0, probeByteLength),
|
|
1980
|
+
this.echoReferenceAudio,
|
|
1981
|
+
) >= ECHO_CORRELATION_THRESHOLD
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
private updateEchoEnergy(meanAmplitude: number, chunkMs: number): void {
|
|
1986
|
+
const alpha = 1 - 0.5 ** (chunkMs / this.echoEmaHalfLifeMs);
|
|
1987
|
+
this.echoEnergyEma =
|
|
1988
|
+
alpha * meanAmplitude + (1 - alpha) * this.echoEnergyEma;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
private classifyAtFixedThreshold(
|
|
1992
|
+
chunk: Buffer,
|
|
1993
|
+
baseThreshold: number,
|
|
1994
|
+
meanAmplitude = pcm16MeanAmplitude(chunk),
|
|
1995
|
+
): VadClassifiedChunk {
|
|
1996
|
+
return {
|
|
1997
|
+
chunk,
|
|
1998
|
+
classification: meanAmplitude > baseThreshold ? "speech" : "silence",
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
private isAssistantPlaybackEchoPossible(): boolean {
|
|
2003
|
+
return (
|
|
2004
|
+
Date.now() < this.assistantPlaybackTailUntilMs + this.echoDrainSlackMs
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
private resetEchoReference(): void {
|
|
2009
|
+
this.echoEnergyEma = 0;
|
|
2010
|
+
this.echoProbeChunks = [];
|
|
2011
|
+
this.echoReferenceAudio = Buffer.alloc(0);
|
|
2012
|
+
this.echoWindowTotalAudioMs = 0;
|
|
2013
|
+
this.echoSubBaseRunMs = 0;
|
|
2014
|
+
this.echoOnsetLapsed = false;
|
|
2015
|
+
this.echoWindowGuardCarryover = false;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
private appendEchoReference(chunk: LiveVoiceTtsAudioChunk): void {
|
|
2019
|
+
if (
|
|
2020
|
+
chunk.contentType.split(";", 1)[0]?.trim().toLowerCase() !==
|
|
2021
|
+
"audio/pcm" ||
|
|
2022
|
+
chunk.sampleRate !== this.context.startFrame.audio.sampleRate
|
|
2023
|
+
) {
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
const audio = Buffer.from(chunk.dataBase64, "base64");
|
|
2027
|
+
const maxBytes = Math.ceil(
|
|
2028
|
+
(chunk.sampleRate * ECHO_REFERENCE_MAX_MS * 2) / 1_000,
|
|
2029
|
+
);
|
|
2030
|
+
const combined = Buffer.concat([this.echoReferenceAudio, audio]);
|
|
2031
|
+
this.echoReferenceAudio =
|
|
2032
|
+
combined.byteLength > maxBytes
|
|
2033
|
+
? combined.subarray(combined.byteLength - maxBytes)
|
|
2034
|
+
: combined;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1769
2037
|
private async routeVadAudio(
|
|
1770
2038
|
utterance: UtteranceCycle,
|
|
1771
2039
|
chunk: Buffer,
|
|
@@ -1913,7 +2181,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1913
2181
|
// The client can still be draining audible playback after tts_done
|
|
1914
2182
|
// (the turn is already cleared server-side) — that tail deserves the
|
|
1915
2183
|
// same guard, or a noise blip clips the reply's last words.
|
|
1916
|
-
const drainingPlayback =
|
|
2184
|
+
const drainingPlayback = this.isAssistantPlaybackEchoPossible();
|
|
1917
2185
|
|
|
1918
2186
|
if ((bargeableTurn || drainingPlayback) && this.bargeInMinSpeechMs > 0) {
|
|
1919
2187
|
// Onset audio keeps flowing into the cycle/pre-roll while the guard
|
|
@@ -1935,13 +2203,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1935
2203
|
}
|
|
1936
2204
|
}
|
|
1937
2205
|
|
|
1938
|
-
//
|
|
1939
|
-
//
|
|
1940
|
-
//
|
|
1941
|
-
//
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2206
|
+
// Advance the sustained-speech barge-in guard by one server-VAD chunk.
|
|
2207
|
+
// Speech accumulates toward bargeInMinSpeechMs, short true-silence gaps are
|
|
2208
|
+
// tolerated, and classified playback echo resets the run immediately.
|
|
2209
|
+
// Longer or mostly silent runs reset through the existing gap limits.
|
|
2210
|
+
private trackBargeInGuard(
|
|
2211
|
+
classification: VadEnergyClassification,
|
|
2212
|
+
chunk: Buffer,
|
|
2213
|
+
): void {
|
|
1945
2214
|
const guard = this.pendingBargeIn;
|
|
1946
2215
|
if (!guard) {
|
|
1947
2216
|
return;
|
|
@@ -1950,7 +2219,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1950
2219
|
chunk.byteLength,
|
|
1951
2220
|
this.context.startFrame.audio.sampleRate,
|
|
1952
2221
|
);
|
|
1953
|
-
if (
|
|
2222
|
+
if (classification === "echo") {
|
|
2223
|
+
this.resetBargeInGuardRun();
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
if (classification === "silence") {
|
|
1954
2227
|
guard.silenceMs += chunkMs;
|
|
1955
2228
|
guard.toleratedSilenceMs += chunkMs;
|
|
1956
2229
|
// Strictly greater on the per-gap check: a gap of exactly
|
|
@@ -1964,9 +2237,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1964
2237
|
guard.toleratedSilenceMs >
|
|
1965
2238
|
this.bargeInMinSpeechMs * BARGE_IN_MAX_TOLERATED_SILENCE_RATIO
|
|
1966
2239
|
) {
|
|
1967
|
-
|
|
1968
|
-
guard.silenceMs = 0;
|
|
1969
|
-
guard.toleratedSilenceMs = 0;
|
|
2240
|
+
this.resetBargeInGuardRun();
|
|
1970
2241
|
}
|
|
1971
2242
|
return;
|
|
1972
2243
|
}
|
|
@@ -1984,6 +2255,21 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1984
2255
|
}
|
|
1985
2256
|
}
|
|
1986
2257
|
|
|
2258
|
+
private resetBargeInGuardRun(): void {
|
|
2259
|
+
const guard = this.pendingBargeIn;
|
|
2260
|
+
if (!guard) {
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
guard.speechMs = 0;
|
|
2264
|
+
guard.silenceMs = 0;
|
|
2265
|
+
guard.toleratedSilenceMs = 0;
|
|
2266
|
+
if (this.echoWindowGuardCarryover) {
|
|
2267
|
+
this.echoWindowGuardCarryover = false;
|
|
2268
|
+
this.echoEnergyEma = 0;
|
|
2269
|
+
this.echoProbeChunks = [];
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1987
2273
|
private bargeIn(turn: ActiveAssistantTurn): void {
|
|
1988
2274
|
// Abort synchronously so no tts_audio frame can follow turn_cancelled,
|
|
1989
2275
|
// and settle the cancelled turn's metrics so the next utterance's marks
|
|
@@ -5328,6 +5614,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5328
5614
|
chunk.sampleRate,
|
|
5329
5615
|
);
|
|
5330
5616
|
const now = Date.now();
|
|
5617
|
+
if (!this.isAssistantPlaybackEchoPossible()) {
|
|
5618
|
+
this.resetEchoReference();
|
|
5619
|
+
}
|
|
5620
|
+
this.appendEchoReference(chunk);
|
|
5331
5621
|
this.assistantPlaybackTailUntilMs =
|
|
5332
5622
|
Math.max(now, this.assistantPlaybackTailUntilMs) + chunkMs;
|
|
5333
5623
|
const turnAfterSend = this.activeAssistantTurn;
|
|
@@ -5832,6 +6122,11 @@ export function createLiveVoiceSession(
|
|
|
5832
6122
|
options.speechEnergyThreshold ?? vadConfig?.speechEnergyThreshold,
|
|
5833
6123
|
bargeInMinSpeechMs:
|
|
5834
6124
|
options.bargeInMinSpeechMs ?? vadConfig?.bargeInMinSpeechMs,
|
|
6125
|
+
echoBargeInMargin:
|
|
6126
|
+
options.echoBargeInMargin ?? vadConfig?.echoBargeInMargin,
|
|
6127
|
+
echoEmaHalfLifeMs:
|
|
6128
|
+
options.echoEmaHalfLifeMs ?? vadConfig?.echoEmaHalfLifeMs,
|
|
6129
|
+
echoDrainSlackMs: options.echoDrainSlackMs ?? vadConfig?.echoDrainSlackMs,
|
|
5835
6130
|
frontModelConfig,
|
|
5836
6131
|
// Eager construction is safe even when the `liveVoice.frontModel` config
|
|
5837
6132
|
// namespace is absent — schema defaults fill the tunables. An explicit
|
|
@@ -3,6 +3,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_SPEECH_ENERGY_THRESHOLD,
|
|
5
5
|
detectPcm16SpeechActivity,
|
|
6
|
+
pcm16MaxNormalizedCorrelation,
|
|
7
|
+
pcm16MeanAmplitude,
|
|
6
8
|
} from "../speech-energy.js";
|
|
7
9
|
|
|
8
10
|
/** Build a PCM16LE buffer from an array of sample values. */
|
|
@@ -66,3 +68,80 @@ describe("detectPcm16SpeechActivity", () => {
|
|
|
66
68
|
expect(detectPcm16SpeechActivity(quiet, 500)).toBe(false);
|
|
67
69
|
});
|
|
68
70
|
});
|
|
71
|
+
|
|
72
|
+
describe("pcm16MeanAmplitude", () => {
|
|
73
|
+
test("returns 0 for an empty buffer", () => {
|
|
74
|
+
expect(pcm16MeanAmplitude(Buffer.alloc(0))).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns the exact mean absolute amplitude", () => {
|
|
78
|
+
expect(pcm16MeanAmplitude(pcm16([1_000, -2_000, 3_000, -4_000]))).toBe(
|
|
79
|
+
2_500,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("ignores a trailing odd byte", () => {
|
|
84
|
+
const samples = pcm16([3_000, -3_000]);
|
|
85
|
+
expect(
|
|
86
|
+
pcm16MeanAmplitude(Buffer.concat([samples, Buffer.from([0x01])])),
|
|
87
|
+
).toBe(3_000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("matches the detector's threshold comparison", () => {
|
|
91
|
+
const chunks = [
|
|
92
|
+
Buffer.alloc(0),
|
|
93
|
+
pcm16([0, 0]),
|
|
94
|
+
pcm16([500, -500]),
|
|
95
|
+
pcm16([DEFAULT_SPEECH_ENERGY_THRESHOLD]),
|
|
96
|
+
pcm16([DEFAULT_SPEECH_ENERGY_THRESHOLD + 1]),
|
|
97
|
+
];
|
|
98
|
+
for (const chunk of chunks) {
|
|
99
|
+
expect(detectPcm16SpeechActivity(chunk)).toBe(
|
|
100
|
+
pcm16MeanAmplitude(chunk) > DEFAULT_SPEECH_ENERGY_THRESHOLD,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("pcm16MaxNormalizedCorrelation", () => {
|
|
107
|
+
const wave = (frequency: number, count: number): Buffer =>
|
|
108
|
+
pcm16(
|
|
109
|
+
Array.from({ length: count }, (_, index) =>
|
|
110
|
+
Math.round(
|
|
111
|
+
8_000 * Math.sin((2 * Math.PI * frequency * index) / 16_000),
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
test("finds a matching waveform at an arbitrary reference offset", () => {
|
|
117
|
+
const input = wave(240, 800);
|
|
118
|
+
const reference = Buffer.concat([wave(410, 400), input, wave(610, 400)]);
|
|
119
|
+
|
|
120
|
+
expect(pcm16MaxNormalizedCorrelation(input, reference)).toBeGreaterThan(
|
|
121
|
+
0.99,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("is invariant to gain and polarity", () => {
|
|
126
|
+
const inputSamples = Array.from({ length: 800 }, (_, index) =>
|
|
127
|
+
Math.round(5_000 * Math.sin((2 * Math.PI * index) / 83)),
|
|
128
|
+
);
|
|
129
|
+
const inverted = pcm16(inputSamples.map((sample) => -2 * sample));
|
|
130
|
+
|
|
131
|
+
expect(
|
|
132
|
+
pcm16MaxNormalizedCorrelation(pcm16(inputSamples), inverted),
|
|
133
|
+
).toBeGreaterThan(0.99);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("rejects an unrelated waveform and flat power", () => {
|
|
137
|
+
expect(
|
|
138
|
+
pcm16MaxNormalizedCorrelation(wave(240, 800), wave(610, 1_600)),
|
|
139
|
+
).toBeLessThan(0.3);
|
|
140
|
+
expect(
|
|
141
|
+
pcm16MaxNormalizedCorrelation(
|
|
142
|
+
pcm16(new Array(800).fill(3_000)),
|
|
143
|
+
pcm16(new Array(1_600).fill(3_000)),
|
|
144
|
+
),
|
|
145
|
+
).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/stt/speech-energy.ts
CHANGED
|
@@ -18,6 +18,120 @@
|
|
|
18
18
|
*/
|
|
19
19
|
export const DEFAULT_SPEECH_ENERGY_THRESHOLD = 800;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Return the mean absolute amplitude of little-endian signed PCM16 audio.
|
|
23
|
+
* Empty buffers return 0, and a trailing odd byte is ignored.
|
|
24
|
+
*/
|
|
25
|
+
export function pcm16MeanAmplitude(chunk: Buffer): number {
|
|
26
|
+
const sampleCount = Math.floor(chunk.length / 2);
|
|
27
|
+
if (sampleCount === 0) {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let totalAmplitude = 0;
|
|
32
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
33
|
+
totalAmplitude += Math.abs(chunk.readInt16LE(i * 2));
|
|
34
|
+
}
|
|
35
|
+
return totalAmplitude / sampleCount;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find the strongest normalized correlation between a PCM16 input window and
|
|
40
|
+
* any same-length window in a PCM16 reference. The samples are block-averaged
|
|
41
|
+
* before matching so this stays cheap enough for live audio and remains
|
|
42
|
+
* tolerant of the low-pass filtering introduced by speakers and microphones.
|
|
43
|
+
*
|
|
44
|
+
* The absolute coefficient makes polarity inversion harmless. A flat input or
|
|
45
|
+
* reference has no identifying waveform and returns 0 instead of being treated
|
|
46
|
+
* as a match based on level alone.
|
|
47
|
+
*/
|
|
48
|
+
export function pcm16MaxNormalizedCorrelation(
|
|
49
|
+
input: Buffer,
|
|
50
|
+
reference: Buffer,
|
|
51
|
+
downsampleFactor = 8,
|
|
52
|
+
): number {
|
|
53
|
+
if (!Number.isInteger(downsampleFactor) || downsampleFactor <= 0) {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let inputSamples = blockAveragePcm16(input, downsampleFactor);
|
|
58
|
+
const referenceSamples = blockAveragePcm16(reference, downsampleFactor);
|
|
59
|
+
if (inputSamples.length > referenceSamples.length) {
|
|
60
|
+
inputSamples = inputSamples.subarray(0, referenceSamples.length);
|
|
61
|
+
}
|
|
62
|
+
if (inputSamples.length < 2) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let inputSum = 0;
|
|
67
|
+
for (let index = 0; index < inputSamples.length; index += 1) {
|
|
68
|
+
inputSum += inputSamples[index]!;
|
|
69
|
+
}
|
|
70
|
+
const inputMean = inputSum / inputSamples.length;
|
|
71
|
+
const centeredInput = new Float64Array(inputSamples.length);
|
|
72
|
+
let inputEnergy = 0;
|
|
73
|
+
for (let index = 0; index < inputSamples.length; index += 1) {
|
|
74
|
+
const centered = inputSamples[index]! - inputMean;
|
|
75
|
+
centeredInput[index] = centered;
|
|
76
|
+
inputEnergy += centered * centered;
|
|
77
|
+
}
|
|
78
|
+
if (inputEnergy === 0) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const prefixSum = new Float64Array(referenceSamples.length + 1);
|
|
83
|
+
const prefixSquareSum = new Float64Array(referenceSamples.length + 1);
|
|
84
|
+
for (let index = 0; index < referenceSamples.length; index += 1) {
|
|
85
|
+
const sample = referenceSamples[index]!;
|
|
86
|
+
prefixSum[index + 1] = prefixSum[index]! + sample;
|
|
87
|
+
prefixSquareSum[index + 1] = prefixSquareSum[index]! + sample * sample;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let best = 0;
|
|
91
|
+
const windowLength = inputSamples.length;
|
|
92
|
+
for (
|
|
93
|
+
let offset = 0;
|
|
94
|
+
offset + windowLength <= referenceSamples.length;
|
|
95
|
+
offset += 1
|
|
96
|
+
) {
|
|
97
|
+
const referenceSum = prefixSum[offset + windowLength]! - prefixSum[offset]!;
|
|
98
|
+
const referenceSquareSum =
|
|
99
|
+
prefixSquareSum[offset + windowLength]! - prefixSquareSum[offset]!;
|
|
100
|
+
const referenceEnergy =
|
|
101
|
+
referenceSquareSum - (referenceSum * referenceSum) / windowLength;
|
|
102
|
+
if (referenceEnergy <= 0) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let dotProduct = 0;
|
|
107
|
+
for (let index = 0; index < windowLength; index += 1) {
|
|
108
|
+
dotProduct += centeredInput[index]! * referenceSamples[offset + index]!;
|
|
109
|
+
}
|
|
110
|
+
const correlation =
|
|
111
|
+
Math.abs(dotProduct) / Math.sqrt(inputEnergy * referenceEnergy);
|
|
112
|
+
best = Math.max(best, Math.min(correlation, 1));
|
|
113
|
+
}
|
|
114
|
+
return best;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function blockAveragePcm16(
|
|
118
|
+
chunk: Buffer,
|
|
119
|
+
downsampleFactor: number,
|
|
120
|
+
): Float64Array {
|
|
121
|
+
const sampleCount = Math.floor(chunk.length / 2);
|
|
122
|
+
const blockCount = Math.floor(sampleCount / downsampleFactor);
|
|
123
|
+
const result = new Float64Array(blockCount);
|
|
124
|
+
for (let block = 0; block < blockCount; block += 1) {
|
|
125
|
+
let sum = 0;
|
|
126
|
+
const firstSample = block * downsampleFactor;
|
|
127
|
+
for (let offset = 0; offset < downsampleFactor; offset += 1) {
|
|
128
|
+
sum += chunk.readInt16LE((firstSample + offset) * 2);
|
|
129
|
+
}
|
|
130
|
+
result[block] = sum / downsampleFactor;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
21
135
|
/**
|
|
22
136
|
* Detect speech activity in a chunk of little-endian signed 16-bit mono
|
|
23
137
|
* PCM samples.
|
|
@@ -34,16 +148,5 @@ export function detectPcm16SpeechActivity(
|
|
|
34
148
|
chunk: Buffer,
|
|
35
149
|
threshold = DEFAULT_SPEECH_ENERGY_THRESHOLD,
|
|
36
150
|
): boolean {
|
|
37
|
-
|
|
38
|
-
if (sampleCount === 0) {
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
let totalAmplitude = 0;
|
|
43
|
-
for (let i = 0; i < sampleCount; i++) {
|
|
44
|
-
totalAmplitude += Math.abs(chunk.readInt16LE(i * 2));
|
|
45
|
-
}
|
|
46
|
-
const avgAmplitude = totalAmplitude / sampleCount;
|
|
47
|
-
|
|
48
|
-
return avgAmplitude > threshold;
|
|
151
|
+
return pcm16MeanAmplitude(chunk) > threshold;
|
|
49
152
|
}
|