Fix TTS audio stalling and add back/previous-word navigation in spelling
Build and Push Docker Image / build-and-push (push) Successful in 55s

- 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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-26 15:52:28 -04:00
1 parent 74c4530060
commit 6baea15986
2 files changed
+26 -7

No files matched your search

+23 -5
View File
@@ -122,22 +122,30 @@ function ttsCancel() {
async function ttsDrain() { async function ttsDrain() {
if (!ttsQueue.length) { ttsCurrentAudio = null; return; } if (!ttsQueue.length) { ttsCurrentAudio = null; return; }
const text = ttsQueue.shift(); 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) { if (piperAvailable) {
const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`); const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`);
ttsCurrentAudio = audio; ttsCurrentAudio = audio;
await new Promise((resolve) => { await new Promise((resolve) => {
audio.onended = resolve; let t = setTimeout(resolve, timeoutMs);
audio.onerror = resolve; const done = () => { clearTimeout(t); resolve(); };
audio.play().catch(resolve); audio.onended = done;
audio.onerror = done;
audio.play().catch(done);
}); });
} else { } else {
const u = new SpeechSynthesisUtterance(text); const u = new SpeechSynthesisUtterance(text);
u.rate = 0.85; u.lang = 'en-US'; u.rate = 0.85; u.lang = 'en-US';
speechSynthesis.cancel(); speechSynthesis.cancel();
await new Promise((resolve) => { await new Promise((resolve) => {
u.onend = resolve; let t = setTimeout(resolve, timeoutMs);
u.onerror = resolve; 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 // some browsers need a short delay after cancel() before speak() works
setTimeout(() => speechSynthesis.speak(u), 50); setTimeout(() => speechSynthesis.speak(u), 50);
}); });
@@ -2386,6 +2394,7 @@ function presentTestWord() {
input.disabled = true; input.disabled = true;
$('#test-listening').hidden = false; $('#test-listening').hidden = false;
$('#test-form-row').hidden = true; $('#test-form-row').hidden = true;
$('#btn-test-prev').hidden = test.i === 0;
speakWord(w); speakWord(w);
// Enable input after TTS has had time to start (rough estimate: 1.5s per word) // 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); const delay = 1500 + (w.definition ? 1500 : 0) + (w.sentence ? 1500 : 0);
@@ -2397,6 +2406,15 @@ function presentTestWord() {
}, delay); }, 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])); $('#btn-test-speak').addEventListener('click', () => speakWord(test.words[test.i]));
$('#test-form').addEventListener('submit', async (e) => { $('#test-form').addEventListener('submit', async (e) => {
+3 -2
View File
@@ -245,7 +245,7 @@
<!-- Practice (spelling) --> <!-- Practice (spelling) -->
<section id="view-practice" class="view" hidden> <section id="view-practice" class="view" hidden>
<button class="back-btn" data-back="practice">← Stop</button> <button class="back-btn" data-back="practice">← Back</button>
<p id="practice-progress" class="progress-label"></p> <p id="practice-progress" class="progress-label"></p>
<div class="progress-track"><div id="practice-bar" class="progress-fill"></div></div> <div class="progress-track"><div id="practice-bar" class="progress-fill"></div></div>
@@ -265,7 +265,7 @@
<!-- Friday test (spelling) --> <!-- Friday test (spelling) -->
<section id="view-test" class="view" hidden> <section id="view-test" class="view" hidden>
<button class="back-btn" data-back="test">← Cancel</button> <button class="back-btn" data-back="test">← Back</button>
<p id="test-progress" class="progress-label"></p> <p id="test-progress" class="progress-label"></p>
<div class="progress-track"><div id="test-bar" class="progress-fill test-fill"></div></div> <div class="progress-track"><div id="test-bar" class="progress-fill test-fill"></div></div>
@@ -279,6 +279,7 @@
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Spell the word"> autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Spell the word">
<button type="submit" class="check-btn">Next →</button> <button type="submit" class="check-btn">Next →</button>
</form> </form>
<button id="btn-test-prev" class="speak-btn" style="margin-top:.75rem" hidden>← Previous word</button>
</div> </div>
</div> </div>