Skip to content

Commit f18ff01

Browse files
authored
fix(voice): rebuild playback bridge after idle to stop slow new turns (#1816)
* fix(voice): rebuild playback bridge after idle to stop slow new turns Assistant playback routes through a MediaStreamAudioDestinationNode -> HTMLAudioElement bridge so a selected output device can be honored. That bridge is a live playout path, and reusing the element for a new turn after it sat idle between turns made the new turn resume at the wrong rate (audible as slow-motion that re-converges over the turn). Tear the bridge down and rebuild it once it has fully drained and been idle past a short threshold, so each turn plays through a fresh element. The scheduledSources.size === 0 guard keeps this from firing mid-turn. Adds regression tests covering within-turn reuse, after-idle rebuild, and sub-threshold reuse. * fix(voice): keep idle-rebuild working after a playback interrupt #stopPlayback reset #lastPlaybackEnd to null, which disabled the post-idle bridge rebuild for the next turn (the guard requires a non-null value). An interrupt leaves the bridge idle just like a natural drain, so interrupt -> long pause -> new turn could reproduce the slow playback. Record the audio clock time at interrupt as the idle start instead, so the next turn still rebuilds. Adds a regression test for the interrupt-then-idle path.
1 parent fa7bfec commit f18ff01

3 files changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/voice": patch
3+
---
4+
5+
Fix assistant speech playing back slow on a new turn after an idle gap. `VoiceClient` routes playback through a `MediaStreamAudioDestinationNode` -> `HTMLAudioElement` bridge, and reusing that element for a fresh burst after it had been idle between turns made the new turn resume at the wrong rate (audible as slow-motion that re-converges to normal over the turn). The bridge is now torn down and rebuilt once it has fully drained and been idle past a short threshold, so each turn plays through a freshly created element. Rebuilds never happen mid-turn, since chunks within a turn keep at least one source scheduled on the playback cursor.

packages/voice/src/tests/voice-client.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,3 +732,99 @@ describe("VoiceClient gapless playback", () => {
732732
expect(sources[1].startedAt).toBe(2);
733733
});
734734
});
735+
736+
describe("VoiceClient playback bridge reuse", () => {
737+
// The audible symptom (a new turn plays back slow, then re-converges) lives
738+
// inside the HTMLAudioElement's playout and is not observable from JS, so we
739+
// cannot assert playback speed. Instead we lock in the mechanism the fix
740+
// relies on: a bridge that has gone idle between turns is rebuilt, not reused,
741+
// and is never rebuilt mid-turn.
742+
function pcm16Chunk(): ArrayBuffer {
743+
// 1600 samples of 16-bit PCM = 0.1s at 16kHz
744+
return new ArrayBuffer(1600 * 2);
745+
}
746+
747+
function startPcm16Call(): { transport: MockTransport; client: VoiceClient } {
748+
const transport = new MockTransport();
749+
const client = new VoiceClient({ agent: "test-agent", transport });
750+
client.connect();
751+
transport.receive(
752+
JSON.stringify({ type: "audio_config", format: "pcm16" })
753+
);
754+
return { transport, client };
755+
}
756+
757+
it("reuses the playback bridge for consecutive chunks within a turn", async () => {
758+
const { transport } = startPcm16Call();
759+
audioContext.currentTime = 5;
760+
761+
transport.receive(pcm16Chunk());
762+
transport.receive(pcm16Chunk());
763+
await waitForSourceCount(2);
764+
765+
// Chunks within a turn stay scheduled ahead on the cursor, so the bridge
766+
// must not be torn down between them.
767+
expect(audioContext.mediaStreamDestinationCount).toBe(1);
768+
expect(audioElement.playCount).toBe(1);
769+
});
770+
771+
it("rebuilds the playback bridge for a new turn after it has gone idle", async () => {
772+
const { transport } = startPcm16Call();
773+
774+
// Turn 1: one chunk ending at t=5.1.
775+
audioContext.currentTime = 5;
776+
transport.receive(pcm16Chunk());
777+
const [first] = await waitForSourceCount(1);
778+
expect(audioContext.mediaStreamDestinationCount).toBe(1);
779+
780+
// Turn 1 finishes playing and the bridge drains.
781+
first.stop();
782+
783+
// A gap longer than the idle threshold passes before the next turn.
784+
audioContext.currentTime = 5.5; // 0.4s past the last chunk's end (5.1)
785+
transport.receive(pcm16Chunk());
786+
await waitForSourceCount(2);
787+
788+
// The idle bridge is torn down and a fresh one is built for turn 2.
789+
expect(audioContext.mediaStreamDestinationCount).toBe(2);
790+
expect(audioElement.playCount).toBe(2);
791+
});
792+
793+
it("rebuilds the playback bridge for a new turn after an interrupt and idle gap", async () => {
794+
const { transport } = startPcm16Call();
795+
796+
// Turn 1 starts playing, then is interrupted mid-playback (abrupt stop,
797+
// not a natural drain).
798+
audioContext.currentTime = 5;
799+
transport.receive(pcm16Chunk());
800+
await waitForSourceCount(1);
801+
expect(audioContext.mediaStreamDestinationCount).toBe(1);
802+
transport.receive(JSON.stringify({ type: "playback_interrupt" }));
803+
804+
// A gap longer than the idle threshold passes before the next turn.
805+
audioContext.currentTime = 6; // 1s after the interrupt
806+
transport.receive(pcm16Chunk());
807+
await waitForSourceCount(2);
808+
809+
// The bridge went idle at the interrupt, so the next turn must rebuild it.
810+
expect(audioContext.mediaStreamDestinationCount).toBe(2);
811+
expect(audioElement.playCount).toBe(2);
812+
});
813+
814+
it("reuses the playback bridge when the next turn follows within the idle threshold", async () => {
815+
const { transport } = startPcm16Call();
816+
817+
audioContext.currentTime = 5;
818+
transport.receive(pcm16Chunk());
819+
const [first] = await waitForSourceCount(1);
820+
first.stop();
821+
822+
// Next chunk arrives only 0.1s after the previous chunk's end (< 0.3s).
823+
audioContext.currentTime = 5.2;
824+
transport.receive(pcm16Chunk());
825+
await waitForSourceCount(2);
826+
827+
expect(audioContext.mediaStreamDestinationCount).toBe(1);
828+
expect(audioElement.playCount).toBe(1);
829+
});
830+
});

packages/voice/src/voice-client.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,10 @@ export class VoiceClient {
296296
#isScheduling = false;
297297
#scheduledSources = new Set<AudioBufferSourceNode>();
298298
#playbackCursor = 0;
299+
// End time (on the AudioContext clock) of the most recently scheduled chunk.
300+
// Used to detect when the playback bridge has been idle across the gap
301+
// between turns so it can be rebuilt (see #playAudio).
302+
#lastPlaybackEnd: number | null = null;
299303
#playbackElement: HTMLAudioElement | null = null;
300304
#playbackDestination: MediaStreamAudioDestinationNode | null = null;
301305
#playbackDestinationPromise: Promise<AudioNode> | null = null;
@@ -905,6 +909,28 @@ export class VoiceClient {
905909
}
906910
if (generation !== this.#playbackGeneration) return;
907911

912+
// Assistant playback is routed through a MediaStreamAudioDestinationNode
913+
// -> HTMLAudioElement bridge (so a selected output device can be honored
914+
// via HTMLMediaElement.setSinkId). That bridge is a live playout path:
915+
// when it is reused for a new turn after sitting idle through the gap
916+
// between turns, the element resumes the fresh burst at the wrong rate and
917+
// the new turn plays back noticeably slow (it then re-converges to normal
918+
// over the turn). A freshly created element does not show this, so once
919+
// the bridge has fully drained and been idle past a short threshold, tear
920+
// it down; #getPlaybackDestination rebuilds a fresh element for this turn.
921+
// The scheduledSources.size === 0 guard means this never fires mid-turn:
922+
// chunks within a turn are scheduled ahead on the cursor, keeping at least
923+
// one source live until the turn finishes.
924+
const BRIDGE_IDLE_REBUILD_SECONDS = 0.3;
925+
if (
926+
this.#playbackElement &&
927+
this.#scheduledSources.size === 0 &&
928+
this.#lastPlaybackEnd !== null &&
929+
ctx.currentTime - this.#lastPlaybackEnd > BRIDGE_IDLE_REBUILD_SECONDS
930+
) {
931+
this.#closePlaybackOutput();
932+
}
933+
908934
const destination = await this.#getPlaybackDestination(ctx);
909935
if (generation !== this.#playbackGeneration) return;
910936

@@ -930,6 +956,7 @@ export class VoiceClient {
930956
// a periodic click during agent speech.
931957
const startAt = Math.max(ctx.currentTime, this.#playbackCursor);
932958
this.#playbackCursor = startAt + audioBuffer.duration;
959+
this.#lastPlaybackEnd = this.#playbackCursor;
933960
source.start(startAt);
934961
} catch (err) {
935962
console.error("[VoiceClient] Audio playback error:", err);
@@ -973,6 +1000,15 @@ export class VoiceClient {
9731000
this.#isPlaying = false;
9741001
this.#isScheduling = false;
9751002
this.#playbackCursor = 0;
1003+
// An interrupt (barge-in or a user transcript) stops playback abruptly, so
1004+
// the bridge goes idle from now, not from the cut chunk's scheduled end.
1005+
// Record the current audio-clock time as the idle start so the next turn's
1006+
// rebuild check in #playAudio still fires after interrupt -> long pause ->
1007+
// new turn. Nulling this here would disable that check and let the slow-
1008+
// playback symptom reappear on the post-interrupt turn.
1009+
this.#lastPlaybackEnd = this.#audioContext
1010+
? this.#audioContext.currentTime
1011+
: null;
9761012
}
9771013

9781014
// --- Mic capture ---

0 commit comments

Comments
 (0)