From 6baea15986a30dfe8faf90c676be82aa7f75be95 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:52:28 -0400 Subject: [PATCH] Fix TTS audio stalling and add back/previous-word navigation in spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add timeout fallback in ttsDrain so the queue never permanently stalls when the browser fails to fire onend (common Chrome/Safari bug after extended use) - Call speechSynthesis.resume() before speak() to un-stick suspended synthesis on mobile - Rename "← Stop" / "← Cancel" to "← Back" on practice and test views - Add "← Previous word" button in spelling test so kids can go back one word (e.g. when audio broke) — pops the saved answer and replays Co-Authored-By: Claude Sonnet 4.6 --- public/app.js | 28 +++++++++++++++++++++++----- public/index.html | 5 +++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/public/app.js b/public/app.js index 5d225dd..af89be1 100644 --- a/public/app.js +++ b/public/app.js @@ -122,22 +122,30 @@ function ttsCancel() { async function ttsDrain() { if (!ttsQueue.length) { ttsCurrentAudio = null; return; } const text = ttsQueue.shift(); + // Fallback timeout in case browser never fires onend (common speechSynthesis bug) + const timeoutMs = Math.max(4000, text.length * 80); if (piperAvailable) { const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`); ttsCurrentAudio = audio; await new Promise((resolve) => { - audio.onended = resolve; - audio.onerror = resolve; - audio.play().catch(resolve); + let t = setTimeout(resolve, timeoutMs); + const done = () => { clearTimeout(t); resolve(); }; + audio.onended = done; + audio.onerror = done; + audio.play().catch(done); }); } else { const u = new SpeechSynthesisUtterance(text); u.rate = 0.85; u.lang = 'en-US'; speechSynthesis.cancel(); await new Promise((resolve) => { - u.onend = resolve; - u.onerror = resolve; + let t = setTimeout(resolve, timeoutMs); + const done = () => { clearTimeout(t); resolve(); }; + u.onend = done; + u.onerror = done; + // Un-stick suspended synthesis (common on mobile after a few minutes) + if (speechSynthesis.paused) speechSynthesis.resume(); // some browsers need a short delay after cancel() before speak() works setTimeout(() => speechSynthesis.speak(u), 50); }); @@ -2386,6 +2394,7 @@ function presentTestWord() { input.disabled = true; $('#test-listening').hidden = false; $('#test-form-row').hidden = true; + $('#btn-test-prev').hidden = test.i === 0; speakWord(w); // Enable input after TTS has had time to start (rough estimate: 1.5s per word) const delay = 1500 + (w.definition ? 1500 : 0) + (w.sentence ? 1500 : 0); @@ -2397,6 +2406,15 @@ function presentTestWord() { }, delay); } +$('#btn-test-prev').addEventListener('click', () => { + if (test.i > 0) { + ttsCancel(); + test.answers.pop(); + test.i--; + presentTestWord(); + } +}); + $('#btn-test-speak').addEventListener('click', () => speakWord(test.words[test.i])); $('#test-form').addEventListener('submit', async (e) => { diff --git a/public/index.html b/public/index.html index e15b78c..d68eb77 100644 --- a/public/index.html +++ b/public/index.html @@ -245,7 +245,7 @@