Fix TTS streaming race conditions and audio cancellation bugs

- Replace for-loop with while-loop in speakStream() to handle fast streaming Without this, if finished=true arrives while speech is running, remaining chunks are lost forever due to the guard at line 208 rejecting updates. The while-loop continues checking for new chunks until stream finishes. - Fix audio cancellation bug in speakWithBrowser() and speakWithKokoro() Both functions were calling stopAudio() even when waitForPrevious=true, canceling the speech immediately after waiting for it to finish. Now only calls stopAudio() when NOT waiting for previous speech. - Fix replay button to pass HTML instead of plain text The speak button in simple-action-buttons.js was extracting innerText, preventing cleanText() from finding <pre> and <code> tags to replace. Now passes HTML so code blocks are properly replaced with placeholder. Fixes: #901 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Frederic Thomas committed Jan 14, 2026 at 14:59 UTC dd7ef855ce196bd3931c9d823492ec03b157b9ac
2 files changed +36 -16
webui/components/chat/speech/speech-store.js
+34 -15
@@ -205,6 +205,7 @@ const model = {
205 if (this.ttsStream.chunks.length == 0) return;
206
207 // if stream was already running, just updating chunks is enough
208 + // The running loop will pick up the new chunks automatically
209 if (this.ttsStream.running) return;
210 else this.ttsStream.running = true; // proceed to running phase
211
@@ -214,22 +215,40 @@ const model = {
215
216 const spoken = [];
217
217 - // loop chunks from last spoken chunk index
218 - for (
219 - let i = this.ttsStream.lastChunkIndex + 1;
220 - i < this.ttsStream.chunks.length;
221 - i++
222 - ) {
218 + // continuously loop until all chunks are spoken and stream is finished
219 + while (true) {
220 + // check if we should stop
221 + if (terminator()) break;
222 +
223 + // get the next chunk index to speak
224 + const nextIndex = this.ttsStream.lastChunkIndex + 1;
225 +
226 + // if no more chunks available, check if we should wait or exit
227 + if (nextIndex >= this.ttsStream.chunks.length) {
228 + // if stream is finished, we're done
229 + if (this.ttsStream.finished) break;
230 + // otherwise wait a bit for more chunks to arrive
231 + await new Promise((resolve) => setTimeout(resolve, 50));
232 + continue;
233 + }
234 +
235 // do not speak the last chunk until finished (it is being generated)
224 - if (i == this.ttsStream.chunks.length - 1 && !this.ttsStream.finished)
225 - break;
236 + if (
237 + nextIndex == this.ttsStream.chunks.length - 1 &&
238 + !this.ttsStream.finished
239 + ) {
240 + // wait a bit for more content or finish signal
241 + await new Promise((resolve) => setTimeout(resolve, 50));
242 + continue;
243 + }
244
245 // set the index of last spoken chunk
228 - this.ttsStream.lastChunkIndex = i;
246 + this.ttsStream.lastChunkIndex = nextIndex;
247
248 // speak the chunk
231 - spoken.push(this.ttsStream.chunks[i]);
232 - await this._speak(this.ttsStream.chunks[i], i > 0, () => terminator());
249 + const chunk = this.ttsStream.chunks[nextIndex];
250 + spoken.push(chunk);
251 + await this._speak(chunk, nextIndex > 0, () => terminator());
252 }
253
254 // at the end, finish stream data
@@ -386,8 +405,8 @@ const model = {
405 while (waitForPrevious && this.isSpeaking) await sleep(25);
406 if (terminator && terminator()) return;
407
389 - // stop previous if any
390 - this.stopAudio();
408 + // stop previous only if not waiting for it
409 + if (!waitForPrevious) this.stopAudio();
410
411 this.browserUtterance = new SpeechSynthesisUtterance(text);
412 this.browserUtterance.onstart = () => {
@@ -410,8 +429,8 @@ const model = {
429 while (waitForPrevious && this.isSpeaking) await sleep(25);
430 if (terminator && terminator()) return;
431
413 - // stop previous if any
414 - this.stopAudio();
432 + // stop previous only if not waiting for it
433 + if (!waitForPrevious) this.stopAudio();
434
435 if (response.success) {
436 if (response.audio_parts) {
webui/components/messages/action-buttons/simple-action-buttons.js
+2 -1
@@ -97,7 +97,8 @@ export function addActionButtonsToElement(element) {
97 // Check if the button container is still fading in (opacity < 0.5)
98 if (parseFloat(window.getComputedStyle(container).opacity) < 0.5) return; // Don't proceed if still fading in
99
100 - const text = getTextContent(element);
100 + // Get HTML content instead of plain text so cleanText() can properly handle code blocks
101 + const text = getTextContent(element, true);
102 const icon = speakBtn.querySelector(".material-symbols-outlined");
103
104 if (!text || text.trim().length === 0) return;