From 6f2281e2a7780e1c6d5ceb9ad91318e50c82dbdd Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Tue, 21 Jul 2026 08:47:10 -0400 Subject: [PATCH] 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 --- Dockerfile | 19 ++++++++- db.js | 1 + public/admin.html | 2 +- public/admin.js | 10 +++-- public/app.js | 101 +++++++++++++++++++++++++++++++++++++++------- public/index.html | 17 ++++---- public/styles.css | 32 +++++++++++++++ server.js | 55 +++++++++++++++++++++---- 8 files changed, 202 insertions(+), 35 deletions(-) diff --git a/Dockerfile b/Dockerfile index 188a9de..de342f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,22 @@ -FROM node:26-alpine +FROM node:26-slim WORKDIR /app + +# Install piper TTS binary and an English voice model +RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -qO /tmp/piper.tar.gz \ + https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_linux_x86_64.tar.gz \ + && tar -xzf /tmp/piper.tar.gz -C /tmp \ + && mv /tmp/piper/piper /usr/local/bin/piper \ + && rm -rf /tmp/piper* + +RUN mkdir -p /app/voices \ + && wget -qO /app/voices/en_US-lessac-medium.onnx \ + "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx" \ + && wget -qO /app/voices/en_US-lessac-medium.onnx.json \ + "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json" + COPY package*.json ./ RUN npm ci --omit=dev COPY . . diff --git a/db.js b/db.js index 5e6a3fd..9757ab2 100644 --- a/db.js +++ b/db.js @@ -222,6 +222,7 @@ tryAlter(`ALTER TABLE submissions ADD COLUMN student_note TEXT`); tryAlter(`ALTER TABLE submissions ADD COLUMN parent_comment TEXT`); tryAlter(`ALTER TABLE schedule ADD COLUMN status TEXT NOT NULL DEFAULT 'not_started'`); tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_notes TEXT`); +tryAlter(`ALTER TABLE words ADD COLUMN definition TEXT NOT NULL DEFAULT ''`); tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_photo TEXT`); export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex'); diff --git a/public/admin.html b/public/admin.html index 32bfd0f..830c340 100644 --- a/public/admin.html +++ b/public/admin.html @@ -258,7 +258,7 @@ + placeholder="One word per line. Optionally add sentence and definition with | like: receive | Did you receive my letter? | to get or accept something">
diff --git a/public/admin.js b/public/admin.js index 4646222..063e45d 100644 --- a/public/admin.js +++ b/public/admin.js @@ -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; diff --git a/public/app.js b/public/app.js index 247facf..3c53566 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = ``; + if (!practice.awaitingRetype) { + practice.firstTryCorrect++; + practice.streak++; + } + const streakMsg = (!practice.awaitingRetype && practice.streak >= 2) + ? ` πŸ”₯ ${practice.streak} in a row!` : ''; + $('#feedback').innerHTML = ``; 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 ${esc(w.word[0].toUpperCase())}`; const render = () => { fb.innerHTML = `
You typed${d.typedHtml}
Correct${d.correctHtml}
${esc(w.word)}
+
${hint}
Memorize it… ${secs}
`; }; 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 ? `

Words to keep working on:

    ${practice.missed.map((m) => `
  • πŸ“Œ ${esc(m)}
  • `).join('')}
` : `

Perfect round β€” every word right on the first try! πŸ†

`; + const drillBtn = missedWords.length + ? `` : ''; $('#practice-done').innerHTML = `
πŸŽ‰ Practice complete!
${practice.firstTryCorrect} / ${practice.words.length}

right on the first try

${missedHtml} + ${drillBtn} `; $('#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])); diff --git a/public/index.html b/public/index.html index 37e7e2c..05a1834 100644 --- a/public/index.html +++ b/public/index.html @@ -156,13 +156,16 @@
- -

Spell the word. No hints β€” you've got this!

-
- - -
+

πŸ”Š Listen carefully…

+
diff --git a/public/styles.css b/public/styles.css index c7d74c6..d64e360 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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 ---------- */ diff --git a/server.js b/server.js index 739a6f8..6a6e2f6 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,19 @@ import express from 'express'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { db, sha256 } from './db.js'; const ROOT = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = process.env.DATA_DIR || join(ROOT, 'data'); +const TTS_CACHE = join(DATA_DIR, 'tts-cache'); +mkdirSync(TTS_CACHE, { recursive: true }); +const PIPER_BIN = process.env.PIPER_BIN || 'piper'; +const PIPER_MODEL = process.env.PIPER_MODEL || join(ROOT, 'voices', 'en_US-lessac-medium.onnx'); +const PIPER_OK = existsSync(PIPER_MODEL); + const app = express(); app.use(express.json({ limit: '15mb' })); // photos for page-scanning are base64 in the JSON body app.use(express.static(join(ROOT, 'public'))); @@ -119,7 +129,7 @@ app.get('/api/session/:studentId', (req, res) => { } const weekWords = db.prepare(` - SELECT w.id, w.word, w.sentence, COALESCE(p.box, 0) AS box + SELECT w.id, w.word, w.sentence, w.definition, COALESCE(p.box, 0) AS box FROM words w LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ? WHERE w.list_id = ? AND COALESCE(p.box, 0) < ${MASTERED_BOX} @@ -128,7 +138,7 @@ app.get('/api/session/:studentId', (req, res) => { `).all(id, listId); const reviews = db.prepare(` - SELECT w.id, w.word, w.sentence, p.box + SELECT w.id, w.word, w.sentence, w.definition, p.box FROM progress p JOIN words w ON w.id = p.word_id WHERE p.student_id = ? AND w.list_id <> ? AND p.box < ${MASTERED_BOX} AND date(p.due) <= date('now') @@ -169,7 +179,7 @@ app.get('/api/test/:studentId', (req, res) => { } if (!list) return res.status(404).json({ error: 'No list assigned' }); - const words = db.prepare(`SELECT id, word, sentence FROM words WHERE list_id = ?`).all(list.id); + const words = db.prepare(`SELECT id, word, sentence, definition FROM words WHERE list_id = ?`).all(list.id); res.json({ list, words: shuffle(words) }); }); @@ -243,7 +253,7 @@ app.get('/api/lists', (req, res) => { app.get('/api/lists/:id', (req, res) => { const list = db.prepare(`SELECT id, name, builtin FROM lists WHERE id = ?`).get(req.params.id); if (!list) return res.status(404).json({ error: 'No such list' }); - list.words = db.prepare(`SELECT id, word, sentence FROM words WHERE list_id = ? ORDER BY id`).all(req.params.id); + list.words = db.prepare(`SELECT id, word, sentence, definition FROM words WHERE list_id = ? ORDER BY id`).all(req.params.id); res.json(list); }); @@ -253,8 +263,8 @@ app.post('/api/lists', requirePin, (req, res) => { return res.status(400).json({ error: 'Name and at least one word required' }); } const id = db.prepare(`INSERT INTO lists (name, builtin) VALUES (?, 0)`).run(String(name).trim()).lastInsertRowid; - const ins = db.prepare(`INSERT INTO words (list_id, word, sentence) VALUES (?, ?, ?)`); - for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim()); + const ins = db.prepare(`INSERT INTO words (list_id, word, sentence, definition) VALUES (?, ?, ?, ?)`); + for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim(), String(w.definition || '').trim()); res.json({ id }); }); @@ -266,8 +276,8 @@ app.put('/api/lists/:id', requirePin, (req, res) => { } db.prepare(`UPDATE lists SET name = ? WHERE id = ?`).run(String(name).trim(), id); db.prepare(`DELETE FROM words WHERE list_id = ?`).run(id); - const ins = db.prepare(`INSERT INTO words (list_id, word, sentence) VALUES (?, ?, ?)`); - for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim()); + const ins = db.prepare(`INSERT INTO words (list_id, word, sentence, definition) VALUES (?, ?, ?, ?)`); + for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim(), String(w.definition || '').trim()); res.json({ ok: true }); }); @@ -1279,6 +1289,35 @@ app.post('/api/admin/ollama-test', requirePin, async (req, res) => { } }); +// ---------- local TTS (Piper) ---------- + +app.get('/api/tts/status', (_req, res) => res.json({ available: PIPER_OK })); + +app.get('/api/tts', (req, res) => { + if (!PIPER_OK) return res.status(503).json({ error: 'Piper not configured' }); + const text = String(req.query.text || '').trim().slice(0, 500); + if (!text) return res.status(400).json({ error: 'text required' }); + + const hash = createHash('sha256').update(text).digest('hex').slice(0, 20); + const cachePath = join(TTS_CACHE, `${hash}.wav`); + + if (!existsSync(cachePath)) { + const result = spawnSync(PIPER_BIN, [ + '--model', PIPER_MODEL, + '--output_file', cachePath, + '--quiet', + ], { input: text, encoding: 'buffer', timeout: 15000 }); + + if (result.status !== 0 || !existsSync(cachePath)) { + return res.status(500).json({ error: 'TTS generation failed' }); + } + } + + res.setHeader('Content-Type', 'audio/wav'); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + res.send(readFileSync(cachePath)); +}); + app.get('/health', (req, res) => res.json({ ok: true })); const PORT = process.env.PORT || 3000;