diff --git a/javascript/src/voice/__tests__/interrupt-truncation.test.ts b/javascript/src/voice/__tests__/interrupt-truncation.test.ts index 57c733965..6fa64811f 100644 --- a/javascript/src/voice/__tests__/interrupt-truncation.test.ts +++ b/javascript/src/voice/__tests__/interrupt-truncation.test.ts @@ -124,6 +124,30 @@ 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("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 09c398fde..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(); + 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();