Skip to content

edgeTTS fix stop race condition#4

Merged
hellpanderrr merged 2 commits intomainfrom
feature/tts-hotfix
Sep 21, 2025
Merged

edgeTTS fix stop race condition#4
hellpanderrr merged 2 commits intomainfrom
feature/tts-hotfix

Conversation

@hellpanderrr
Copy link
Copy Markdown
Owner

@hellpanderrr hellpanderrr commented Sep 21, 2025

Summary by CodeRabbit

  • Bug Fixes
    • More reliable stop/teardown for text-to-speech playback, preventing glitches or crashes during shutdown.
    • Ensures streams end cleanly even if buffers are updating, reducing playback errors.
    • Thorough resource cleanup (including revoking blob URLs and resetting audio) to prevent memory leaks.
    • Safer connection shutdown handling to avoid unexpected errors or stuck audio.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Sep 21, 2025

Walkthrough

Refactors TTS shutdown: stop() now tears down WebSocket handlers and conditionally closes the socket, clears audio state and queues, revokes blob URLs, resets the audio element, and delegates MediaSource finalization to a new event-driven #finalizeStream that uses sourceBuffer.updateend and guarded endOfStream calls instead of polling.

Changes

Cohort / File(s) Summary of Changes
TTS stream finalization and cleanup
wiktionary_pron/scripts/tts.js
stop() now nullifies WS handlers and closes the socket when appropriate; clears audio queues/state, pauses and resets the audio element, revokes blob URLs and removes src; introduces private #finalizeStream() that listens for sourceBuffer.updateend (replacing polling) and calls endOfStream() inside guards/try-catch for safe MediaSource finalization.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor U as User
  participant T as TTS Controller
  participant WS as WebSocket
  participant MS as MediaSource
  participant SB as SourceBuffer
  participant A as Audio Element

  U->>T: stop()
  T->>WS: remove handlers (onclose/onerror/onmessage)
  alt WS open or connecting
    T->>WS: close()
    T->>T: null currentSocket
  end
  T->>T: clear queues/state
  T->>A: pause(), remove src, revokeObjectURL (if blob), try load()
  alt MediaSource present and open
    T->>T: #finalizeStream()
    T->>MS: check readyState
    opt SB is updating
      SB-->>T: updateend (event)
    end
    T->>MS: endOfStream() (guarded try/catch)
  end
  note right of T: Cleanup complete
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

I thump my paw—“All streams must end!”
I unbind webs and gently tend,
I wait for update’s final ping,
then close the mouth of every string.
A hop, a hush, the buffers clear 🐇

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "edgeTTS fix stop race condition" is concise and directly reflects the primary change in the diff—targeted fixes to stop() and finalizeStream() to safely teardown WebSocket, MediaSource, and audio resources and prevent race conditions. The file-level summary shows the stop() cleanup, guarded endOfStream, and updateend-based synchronization described by the title.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/tts-hotfix

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2ee3845 and b2a7086.

📒 Files selected for processing (1)
  • wiktionary_pron/scripts/tts.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • wiktionary_pron/scripts/tts.js

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
wiktionary_pron/scripts/tts.js (1)

262-290: Avoid truncating audio: finalize only after the queue fully drains, and clean up references.

Current logic ends the MediaSource after just one updateend, which can cut off remaining queued chunks. Also, references to #sourceBuffer/#mediaSource aren’t cleared, risking leaks.

Apply this diff to make finalization drain-aware and perform cleanup:

-  #finalizeStream() {
-    // This is the more robust version that prevents Firefox warnings and is generally safer.
-    if (!this.#mediaSource || this.#mediaSource.readyState !== "open") {
-      return;
-    }
-
-    const end = () => {
-      if (this.#mediaSource.readyState === "open") {
-        try {
-          this.#mediaSource.endOfStream();
-        } catch (e) {
-          console.warn(
-            "Error calling endOfStream, stream likely already closed.",
-            e,
-          );
-        }
-      }
-    };
-
-    if (this.#sourceBuffer && this.#sourceBuffer.updating) {
-      const onUpdateEnd = () => {
-        this.#sourceBuffer.removeEventListener("updateend", onUpdateEnd);
-        end();
-      };
-      this.#sourceBuffer.addEventListener("updateend", onUpdateEnd);
-    } else {
-      end();
-    }
-  }
+  #finalizeStream() {
+    const ms = this.#mediaSource;
+    if (!ms) return;
+
+    const end = () => {
+      if (this.#mediaSource && this.#mediaSource.readyState === "open") {
+        try {
+          this.#mediaSource.endOfStream();
+        } catch (e) {
+          console.warn("Error calling endOfStream (likely already closed).", e);
+        }
+      }
+      // Remove our queue-drain listener if present
+      try { this.#sourceBuffer?.removeEventListener("updateend", this.#onQueueDrain); } catch {}
+      this.#onQueueDrain = null;
+      // Drop strong refs to allow GC
+      this.#sourceBuffer = null;
+      this.#mediaSource = null;
+      this.#isAppending = false;
+    };
+
+    const tryEndNow = () => {
+      if (!this.#sourceBuffer || (!this.#sourceBuffer.updating && this.#audioQueue.length === 0)) {
+        end();
+      }
+    };
+
+    if (this.#sourceBuffer) {
+      // Keep appending until the queue is empty, then end.
+      const maybeEnd = () => {
+        if (this.#audioQueue.length === 0 && !this.#sourceBuffer.updating) {
+          this.#sourceBuffer.removeEventListener("updateend", maybeEnd);
+          end();
+        }
+      };
+      this.#sourceBuffer.addEventListener("updateend", maybeEnd);
+      // Kick the queue in case it’s idle.
+      this.#processAudioQueue();
+      tryEndNow();
+    } else {
+      end();
+    }
+  }
🧹 Nitpick comments (3)
wiktionary_pron/scripts/tts.js (3)

296-307: Avoid leaking bound listeners: keep a stable handler reference.

this.#processAudioQueue.bind(this) creates a new function you can’t remove later, retaining the SourceBuffer. Store the bound handler and remove it in #finalizeStream().

           try {
             this.#sourceBuffer = this.#mediaSource.addSourceBuffer(
               this.#mimeType,
             );
-            this.#sourceBuffer.addEventListener(
-              "updateend",
-              this.#processAudioQueue.bind(this),
-            );
+            this.#onQueueDrain = this.#processAudioQueue.bind(this);
+            this.#sourceBuffer.addEventListener("updateend", this.#onQueueDrain);
             resolve();

Add this private field to the class (anywhere among other fields):

#onQueueDrain = null;

223-233: Undefined vars in caching path (audioChunks, cacheKey). Gate or implement before enabling.

This will throw if #enableCache is ever set to true. Either remove for now or switch to defined state tracked on the instance.

Minimal safe guard:

   socket.onclose = () => {
     this.#finalizeStream();
-    if (this.#enableCache && audioChunks.length > 0) {
-      //Use dynamic mimeType for blob creation ---
-      const audioBlob = new Blob(audioChunks, { type: this.#mimeType });
-      console.log(
-        `Saving to cache (${(audioBlob.size / 1024).toFixed(2)} KB)`,
-      );
-      this.#cache.saveAudio(cacheKey, audioBlob);
-    }
+    if (this.#enableCache && this.#receivedChunks && this.#receivedChunks.length > 0) {
+      const audioBlob = new Blob(this.#receivedChunks, { type: this.#mimeType });
+      console.log(`Saving to cache (${(audioBlob.size / 1024).toFixed(2)} KB)`);
+      this.#cache.saveAudio(this.#lastCacheKey, audioBlob);
+    }
   };

To complete this approach, add these fields and set them where appropriate:

// Private fields (with others)
#receivedChunks = null;
#lastCacheKey = null;

// In speak(config), before opening the socket:
this.#lastCacheKey = JSON.stringify({ text, voice: voice.raw.ShortName, fmt: this.#apiAudioFormat });
this.#receivedChunks = [];

// In socket.onmessage, when you compute `audioData`:
if (this.#enableCache && audioData.byteLength > 0) {
  // Store a copy to avoid mutation surprises
  this.#receivedChunks.push(audioData.slice(0));
}

571-583: Guard EdgeTTS.init() when constructor failed.

If new StreamingTTS() throws, EdgeTTS is undefined and calling .init() will throw before allSettled. Guard and let allSettled report rejection.

-    const results = await Promise.allSettled([
-      EasySpeech.init({ maxTimeout: 5000, interval: 250 }),
-      EdgeTTS.init(),
-    ]);
+    const results = await Promise.allSettled([
+      EasySpeech.init({ maxTimeout: 5000, interval: 250 }),
+      EdgeTTS ? EdgeTTS.init() : Promise.reject(new Error("EdgeTTS unavailable")),
+    ]);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 395d04b and 2ee3845.

📒 Files selected for processing (1)
  • wiktionary_pron/scripts/tts.js (1 hunks)

Comment thread wiktionary_pron/scripts/tts.js
@hellpanderrr hellpanderrr added the bug Something isn't working label Sep 21, 2025
@hellpanderrr hellpanderrr self-assigned this Sep 21, 2025
Implements suggestions from the AI PR review to make the shutdown procedure more robust.

- Nullifies all WebSocket event handlers (onopen, onmessage, etc.) to prevent lingering callbacks.
- Defensively checks WebSocket readyState before calling close().
- Fully resets the audio element by calling load() in a try-catch block.
@hellpanderrr hellpanderrr merged commit cc3cec4 into main Sep 21, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant