From b8239be46b9fffcb44df63aef8d2b5f2a9ca1548 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 05:38:34 +0100 Subject: [PATCH] fix(voice-call): settle cleared tts queue --- CHANGELOG.md | 1 + .../voice-call/src/media-stream.test.ts | 29 ++++++++++++++++++- extensions/voice-call/src/media-stream.ts | 12 ++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c4d5285e5..118c62fa10b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenRouter: add an OpenRouter TTS provider using the OpenAI-compatible `/audio/speech` endpoint and `OPENROUTER_API_KEY`. Fixes #71268. - macOS Talk Mode: retry failed local ElevenLabs stream playback through gateway `talk.speak` before falling back to the system voice, so configured ElevenLabs voices still play when streaming playback fails. Fixes #65662. - Plugins/Voice Call: reap stale pre-answer calls by default, honor configured TTS timeouts for Twilio media-stream playback, and fail empty telephony audio instead of completing as silence. Fixes #42071; supersedes #60957. Thanks @Ryce and @sliekens. +- Plugins/Voice Call: resolve queued-but-not-yet-playing Twilio TTS entries when barge-in or stream teardown clears the playback queue, so callers awaiting `queueTts()` do not hang. Thanks @kevinWangSheng. - Plugins/Voice Call: terminate expired restored call sessions with the provider and restart restored max-duration timers with only the remaining duration, preventing stale outbound retry loops after Gateway restarts. Fixes #48739. Thanks @mira-solari. - Plugins/Voice Call: start provider STT after Telnyx outbound conversation greetings and pass configured Telnyx voice IDs through to the speak action. Fixes #56091. Thanks @Roshan. - Skills: honor legacy `metadata.clawdbot` requirements and installer hints when `metadata.openclaw` is absent, so older skills no longer appear ready when required binaries are missing. Fixes #71323. Thanks @chen-zhang-cs-code. diff --git a/extensions/voice-call/src/media-stream.test.ts b/extensions/voice-call/src/media-stream.test.ts index 16f51aeb4f6..126b78abda0 100644 --- a/extensions/voice-call/src/media-stream.test.ts +++ b/extensions/voice-call/src/media-stream.test.ts @@ -103,7 +103,7 @@ describe("MediaStreamHandler TTS queue", () => { started.push("active"); await waitForAbort(signal); }); - void handler.queueTts("stream-1", async () => { + const queued = handler.queueTts("stream-1", async () => { queuedRan = true; }); @@ -112,10 +112,37 @@ describe("MediaStreamHandler TTS queue", () => { handler.clearTtsQueue("stream-1"); await active; + await withTimeout(queued); await flush(); expect(queuedRan).toBe(false); }); + + it("resolves pending queued playback during stream teardown", async () => { + const handler = new MediaStreamHandler({ + transcriptionProvider: createStubSttProvider(), + providerConfig: {}, + }); + + let queuedRan = false; + const active = handler.queueTts("stream-1", async (signal) => { + await waitForAbort(signal); + }); + const queued = handler.queueTts("stream-1", async () => { + queuedRan = true; + }); + + await flush(); + ( + handler as unknown as { + clearTtsState(streamSid: string): void; + } + ).clearTtsState("stream-1"); + + await withTimeout(active); + await withTimeout(queued); + expect(queuedRan).toBe(false); + }); }); describe("MediaStreamHandler security hardening", () => { diff --git a/extensions/voice-call/src/media-stream.ts b/extensions/voice-call/src/media-stream.ts index 39128a9814e..4a633d3e86c 100644 --- a/extensions/voice-call/src/media-stream.ts +++ b/extensions/voice-call/src/media-stream.ts @@ -561,7 +561,7 @@ export class MediaStreamHandler { */ clearTtsQueue(streamSid: string, _reason = "unspecified"): void { const queue = this.getTtsQueue(streamSid); - queue.length = 0; + this.resolveQueuedTtsEntries(queue); this.ttsActiveControllers.get(streamSid)?.abort(); this.clearAudio(streamSid); } @@ -634,13 +634,21 @@ export class MediaStreamHandler { private clearTtsState(streamSid: string): void { const queue = this.ttsQueues.get(streamSid); if (queue) { - queue.length = 0; + this.resolveQueuedTtsEntries(queue); } this.ttsActiveControllers.get(streamSid)?.abort(); this.ttsActiveControllers.delete(streamSid); this.ttsPlaying.delete(streamSid); this.ttsQueues.delete(streamSid); } + + private resolveQueuedTtsEntries(queue: TtsQueueEntry[]): void { + const pending = queue.splice(0); + for (const entry of pending) { + entry.controller.abort(); + entry.resolve(); + } + } } /**