From d7c8010c32419f9a3043b07eb0a4ec00a0f4d007 Mon Sep 17 00:00:00 2001 From: allin2 <37659199+allin2@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:03:34 +0800 Subject: [PATCH 1/2] fix(voice): ignore empty first chunk for speaking event --- .../src/voice/__tests__/interrupt-truncation.test.ts | 11 +++++++++++ javascript/src/voice/adapter.runtime.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/javascript/src/voice/__tests__/interrupt-truncation.test.ts b/javascript/src/voice/__tests__/interrupt-truncation.test.ts index 57c733965..27ce86f31 100644 --- a/javascript/src/voice/__tests__/interrupt-truncation.test.ts +++ b/javascript/src/voice/__tests__/interrupt-truncation.test.ts @@ -124,6 +124,17 @@ describe("defaultVoiceCall publishes the agent speaking event", () => { await expect(adapter.agentSpeakingEvent!.wait()).resolves.toBeUndefined(); }); + it("does not set the speaking event when the first chunk is empty", async () => { + const adapter = new SpeakingAdapter([ + new AudioChunk({ data: new Uint8Array(0) }), + ]); + + await defaultVoiceCall(adapter, bareInput); + + expect(adapter.agentSpeakingEvent).toBeDefined(); + expect(adapter.agentSpeakingEvent!.isSet()).toBe(false); + }); + it("clears the event at the start of the next turn (re-armed)", async () => { // Turn-2's first chunk is delayed so the cleared-but-not-yet-set window is // observable across a macrotask yield. Without it the immediate chunk would diff --git a/javascript/src/voice/adapter.runtime.ts b/javascript/src/voice/adapter.runtime.ts index 09c398fde..03c4e1d07 100644 --- a/javascript/src/voice/adapter.runtime.ts +++ b/javascript/src/voice/adapter.runtime.ts @@ -721,8 +721,8 @@ async function drainInner( ); if (first.data.length > 0) { onFirstChunk(); + speakingEvent.set(); } - speakingEvent.set(); const chunks: AudioChunk[] = [first]; let accumulated = first.durationSeconds; From 3380f97992847641f75449b012c4eb74f9f1da03 Mon Sep 17 00:00:00 2001 From: allin2 <37659199+allin2@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:21:41 +0800 Subject: [PATCH 2/2] fix(voice): mark speaking on first playable chunk --- .../voice/__tests__/interrupt-truncation.test.ts | 13 +++++++++++++ javascript/src/voice/adapter.runtime.ts | 11 +++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/javascript/src/voice/__tests__/interrupt-truncation.test.ts b/javascript/src/voice/__tests__/interrupt-truncation.test.ts index 27ce86f31..6fa64811f 100644 --- a/javascript/src/voice/__tests__/interrupt-truncation.test.ts +++ b/javascript/src/voice/__tests__/interrupt-truncation.test.ts @@ -135,6 +135,19 @@ describe("defaultVoiceCall publishes the agent speaking event", () => { expect(adapter.agentSpeakingEvent!.isSet()).toBe(false); }); + it("sets the speaking event when audio follows a leading empty chunk", async () => { + const adapter = new SpeakingAdapter([ + new AudioChunk({ data: new Uint8Array(0) }), + tone(0.1, "after empty"), + ]); + + await defaultVoiceCall(adapter, bareInput); + + expect(adapter.agentSpeakingEvent).toBeDefined(); + expect(adapter.agentSpeakingEvent!.isSet()).toBe(true); + await expect(adapter.agentSpeakingEvent!.wait()).resolves.toBeUndefined(); + }); + it("clears the event at the start of the next turn (re-armed)", async () => { // Turn-2's first chunk is delayed so the cleared-but-not-yet-set window is // observable across a macrotask yield. Without it the immediate chunk would diff --git a/javascript/src/voice/adapter.runtime.ts b/javascript/src/voice/adapter.runtime.ts index 03c4e1d07..a8cf7511b 100644 --- a/javascript/src/voice/adapter.runtime.ts +++ b/javascript/src/voice/adapter.runtime.ts @@ -719,10 +719,16 @@ async function drainInner( "voice.audio.first_chunk_latency_ms", Math.round(performance.now() - receiveStart), ); - if (first.data.length > 0) { + // The initial receive can be an empty prefix while the drain still yields + // audio. Publish speech on the first playable chunk wherever it appears. + let sawPlayableChunk = false; + const markFirstPlayableChunk = (chunk: AudioChunk): void => { + if (sawPlayableChunk || chunk.data.length === 0) return; + sawPlayableChunk = true; onFirstChunk(); speakingEvent.set(); - } + }; + markFirstPlayableChunk(first); const chunks: AudioChunk[] = [first]; let accumulated = first.durationSeconds; @@ -761,6 +767,7 @@ async function drainInner( terminatedReason = "terminal_chunk"; break; } + markFirstPlayableChunk(next); chunks.push(next); accumulated += next.durationSeconds; maybeWarnSoftCap();