Add spelling improvements and local Piper TTS

Practice: fire streak badge, first-letter hint on wrong answers, missed-words drill session after practice completes.
Test: audio-only mode with listening state before input appears, larger re-read button.
Words: definition field (DB migration + admin editor + TTS playback).
TTS: replace speechSynthesis with local Piper via /api/tts, WAV cached in data/tts-cache, falls back to browser voice when Piper is not installed.
Dockerfile: switched to node:26-slim, installs Piper binary + en_US-lessac-medium voice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-07-21 08:47:10 -04:00
1 parent 544469d6c9
commit 6f2281e2a7
8 files changed
+202 -35

No files matched your search

+1 -1
View File
@@ -258,7 +258,7 @@
<input type="hidden" id="list-id">
<input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name">
<textarea id="list-words" rows="8"
placeholder="One word per line. Add an example sentence with | like:&#10;receive | Did you receive my letter?"></textarea>
placeholder="One word per line. Optionally add sentence and definition with | like:&#10;receive | Did you receive my letter? | to get or accept something"></textarea>
<div class="row-actions">
<button type="submit">Save list</button>
<button type="button" id="list-cancel" class="secondary">Clear</button>
+7 -3
View File
@@ -897,7 +897,11 @@ function renderLists(lists) {
const list = await api(`/api/lists/${btn.dataset.editList}`);
$('#list-id').value = list.builtin ? '' : list.id;
$('#list-name').value = list.builtin ? `${list.name} (copy)` : list.name;
$('#list-words').value = list.words.map((w) => (w.sentence ? `${w.word} | ${w.sentence}` : w.word)).join('\n');
$('#list-words').value = list.words.map((w) => {
if (w.definition) return `${w.word} | ${w.sentence} | ${w.definition}`;
if (w.sentence) return `${w.word} | ${w.sentence}`;
return w.word;
}).join('\n');
$('#list-editor-title').textContent = list.builtin ? 'New list (from copy)' : `Editing: ${list.name}`;
$('#list-editor-details').open = true;
$('#list-name').scrollIntoView({ behavior: 'smooth' });
@@ -920,8 +924,8 @@ $('#list-form').addEventListener('submit', async (e) => {
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [word, sentence = ''] = line.split('|').map((p) => p.trim());
return { word, sentence };
const [word, sentence = '', definition = ''] = line.split('|').map((p) => p.trim());
return { word, sentence, definition };
});
if (!name || words.length === 0) return msg('Give the list a name and at least one word.');
const id = $('#list-id').value;
+86 -15
View File
@@ -96,17 +96,55 @@ $('#assignment-photo-input').addEventListener('change', async () => {
// ---------- text to speech (spelling module) ----------
function speak(text, rate = 0.85) {
const u = new SpeechSynthesisUtterance(text);
u.rate = rate;
u.lang = 'en-US';
speechSynthesis.speak(u);
// piperAvailable: null = not yet checked, true/false after first /api/tts/status call
let piperAvailable = null;
let ttsQueue = [];
let ttsCurrentAudio = null;
async function checkPiper() {
if (piperAvailable !== null) return piperAvailable;
try {
const { available } = await api('/api/tts/status');
piperAvailable = available;
} catch {
piperAvailable = false;
}
return piperAvailable;
}
function speakWord(w) {
function ttsCancel() {
ttsQueue = [];
if (ttsCurrentAudio) { ttsCurrentAudio.pause(); ttsCurrentAudio = null; }
speechSynthesis.cancel();
speak(w.word);
if (w.sentence) speak(w.sentence, 0.95);
}
async function ttsDrain() {
if (!ttsQueue.length) { ttsCurrentAudio = null; return; }
const text = ttsQueue.shift();
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);
});
} else {
const u = new SpeechSynthesisUtterance(text);
u.rate = 0.85; u.lang = 'en-US';
await new Promise((resolve) => { u.onend = resolve; u.onerror = resolve; speechSynthesis.speak(u); });
}
ttsDrain();
}
async function speakWord(w) {
ttsCancel();
await checkPiper();
ttsQueue.push(w.word);
if (w.definition) ttsQueue.push(w.definition);
if (w.sentence) ttsQueue.push(w.sentence);
ttsDrain();
}
// ---------- letter diff (LCS alignment, spelling module) ----------
@@ -612,7 +650,7 @@ async function finishFlashcards(nothingDue) {
// ---------- spelling: practice ----------
const practice = { words: [], i: 0, missed: [], firstTryCorrect: 0, awaitingRetype: false, itemId: null };
const practice = { words: [], i: 0, missed: [], missedWords: [], firstTryCorrect: 0, awaitingRetype: false, itemId: null, streak: 0 };
async function startPractice({ listId, itemId } = {}) {
const url = listId ? `/api/session/${currentStudent.id}?listId=${listId}` : `/api/session/${currentStudent.id}`;
@@ -625,7 +663,7 @@ async function startPractice({ listId, itemId } = {}) {
show('practice');
return;
}
Object.assign(practice, { words, i: 0, missed: [], firstTryCorrect: 0, awaitingRetype: false, itemId: itemId || null });
Object.assign(practice, { words, i: 0, missed: [], missedWords: [], firstTryCorrect: 0, awaitingRetype: false, itemId: itemId || null, streak: 0 });
$('#practice-card').hidden = false;
$('#practice-done').hidden = true;
show('practice');
@@ -661,12 +699,21 @@ $('#practice-form').addEventListener('submit', async (e) => {
});
if (result.correct) {
if (!practice.awaitingRetype) practice.firstTryCorrect++;
$('#feedback').innerHTML = `<div class="banner good">✅ ${practice.awaitingRetype ? 'You got it!' : 'Correct!'}</div>`;
if (!practice.awaitingRetype) {
practice.firstTryCorrect++;
practice.streak++;
}
const streakMsg = (!practice.awaitingRetype && practice.streak >= 2)
? ` <span class="streak-badge">🔥 ${practice.streak} in a row!</span>` : '';
$('#feedback').innerHTML = `<div class="banner good">✅ ${practice.awaitingRetype ? 'You got it!' : 'Correct!'}${streakMsg}</div>`;
input.disabled = true;
setTimeout(nextPracticeWord, 900);
setTimeout(nextPracticeWord, practice.streak >= 2 ? 1300 : 900);
} else {
if (!practice.awaitingRetype) practice.missed.push(w.word);
if (!practice.awaitingRetype) {
practice.missed.push(w.word);
practice.missedWords.push(w);
practice.streak = 0;
}
showStudyThenRetype(typed, w);
}
});
@@ -680,12 +727,14 @@ function showStudyThenRetype(typed, w) {
const fb = $('#feedback');
let secs = 4;
const hint = `Hint: starts with <strong>${esc(w.word[0].toUpperCase())}</strong>`;
const render = () => {
fb.innerHTML = `
<div class="banner bad">Not quite — study it!</div>
<div class="diff-line"><span class="label">You typed</span>${d.typedHtml}</div>
<div class="diff-line"><span class="label">Correct</span>${d.correctHtml}</div>
<div class="study-word">${esc(w.word)}</div>
<div class="hint-badge">${hint}</div>
<div class="countdown">Memorize it… ${secs}</div>`;
};
render();
@@ -711,16 +760,28 @@ async function nextPracticeWord() {
if (practice.itemId) {
await api(`/api/items/${practice.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
}
const missedWords = practice.missedWords.slice();
const missedHtml = practice.missed.length
? `<p>Words to keep working on:</p><ul>${practice.missed.map((m) => `<li>📌 ${esc(m)}</li>`).join('')}</ul>`
: `<p>Perfect round — every word right on the first try! 🏆</p>`;
const drillBtn = missedWords.length
? `<button id="practice-drill-missed" class="check-btn secondary">🔁 Drill missed words</button>` : '';
$('#practice-done').innerHTML = `
<div>🎉 Practice complete!</div>
<div class="big-score">${practice.firstTryCorrect} / ${practice.words.length}</div>
<p>right on the first try</p>
${missedHtml}
${drillBtn}
<button id="practice-home">Back</button>`;
$('#practice-done').hidden = false;
if (missedWords.length) {
$('#practice-drill-missed').addEventListener('click', () => {
Object.assign(practice, { words: shuffle(missedWords), i: 0, missed: [], missedWords: [], firstTryCorrect: 0, awaitingRetype: false, streak: 0 });
$('#practice-card').hidden = false;
$('#practice-done').hidden = true;
presentPracticeWord();
});
}
$('#practice-home').addEventListener('click', () => backTarget());
}
@@ -744,8 +805,18 @@ function presentTestWord() {
$('#test-bar').style.width = `${(test.i / test.words.length) * 100}%`;
const input = $('#test-input');
input.value = '';
input.focus();
input.disabled = true;
$('#test-listening').hidden = false;
$('#test-form-row').hidden = true;
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);
setTimeout(() => {
$('#test-listening').hidden = true;
$('#test-form-row').hidden = false;
input.disabled = false;
input.focus();
}, delay);
}
$('#btn-test-speak').addEventListener('click', () => speakWord(test.words[test.i]));
+10 -7
View File
@@ -156,13 +156,16 @@
<div class="progress-track"><div id="test-bar" class="progress-fill test-fill"></div></div>
<div id="test-card" class="word-card">
<button id="btn-test-speak" class="speak-btn">🔊 Say it again</button>
<p class="prompt">Spell the word. No hints — you've got this!</p>
<form id="test-form" autocomplete="off">
<input id="test-input" class="spell-input" type="text" autocomplete="off"
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Spell the word">
<button type="submit" class="check-btn">Next →</button>
</form>
<p id="test-listening" class="test-listening-state">🔊 Listen carefully…</p>
<div id="test-form-row" hidden>
<button id="btn-test-speak" class="speak-btn speak-btn-prominent">🔊 Hear it again</button>
<p class="prompt">Now spell the word:</p>
<form id="test-form" autocomplete="off">
<input id="test-input" class="spell-input" type="text" autocomplete="off"
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Spell the word">
<button type="submit" class="check-btn">Next →</button>
</form>
</div>
</div>
<div id="test-done" class="done-card" hidden></div>
+32
View File
@@ -141,6 +141,34 @@ body {
font-size: 2.6rem; font-weight: 800; letter-spacing: 6px; margin: 0.5rem 0;
}
.countdown { color: var(--muted); }
.hint-badge { font-size: 1rem; color: var(--accent-dark); margin: 0.3rem 0 0.5rem; }
.streak-badge {
display: inline-block; margin-left: 0.5rem;
background: #fff3cd; color: #7c5200;
border-radius: 999px; padding: 0.1rem 0.7rem;
font-size: 0.95rem; font-weight: 700;
animation: streak-pop 0.3s ease;
}
@keyframes streak-pop {
0% { transform: scale(0.7); opacity: 0; }
70% { transform: scale(1.15); }
100% { transform: scale(1); opacity: 1; }
}
.test-listening-state {
font-size: 1.5rem; font-weight: 700; color: var(--accent);
text-align: center; padding: 2rem 0; letter-spacing: 1px;
animation: pulse 1s ease-in-out infinite alternate;
}
@keyframes pulse {
from { opacity: 0.6; }
to { opacity: 1; }
}
.speak-btn-prominent {
font-size: 1.4rem; padding: 0.75rem 2rem; width: 100%; max-width: 340px;
display: block; margin: 0 auto 1.25rem;
}
.done-card {
background: var(--card); border-radius: var(--radius);
@@ -154,6 +182,10 @@ body {
font-size: 1.2rem; font-weight: 700; font-family: inherit;
background: var(--accent); color: #fff; border: none;
border-radius: 12px; padding: 0.7rem 2rem; cursor: pointer; margin-top: 1rem;
display: block; width: 100%;
}
.done-card button.secondary {
background: var(--good-bg); color: var(--good); border: 2px solid var(--good);
}
/* ---------- parent dashboard ---------- */