@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.3-staging.3",
3
+ "version": "0.11.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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(makeTtsChunk("assistant audio"));
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 { detectPcm16SpeechActivity } from "../stt/speech-energy.js";
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
- // Energy gate for server-VAD speech classification; undefined defers to
1037
- // DEFAULT_SPEECH_ENERGY_THRESHOLD.
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 hasSpeech = detectPcm16SpeechActivity(
1687
- chunk,
1688
- this.speechEnergyThreshold,
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(hasSpeech, chunk);
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 = Date.now() < this.assistantPlaybackTailUntilMs;
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
- // Advances the sustained-speech barge-in guard by one server-VAD chunk:
1939
- // above-gate speech accumulates toward bargeInMinSpeechMs while brief
1940
- // sub-threshold gaps are tolerated (a single continuous silence longer than
1941
- // BARGE_IN_GAP_TOLERANCE_MS, or cumulative tolerated silence past the
1942
- // duty-cycle ceiling, zeroes the run), and once met the deferred
1943
- // speech_started + barge-in fire.
1944
- private trackBargeInGuard(hasSpeech: boolean, chunk: Buffer): void {
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 (!hasSpeech) {
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
- guard.speechMs = 0;
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
+ });
@@ -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
- const sampleCount = Math.floor(chunk.length / 2);
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
  }