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 = `
β
${practice.awaitingRetype ? 'You got it!' : 'Correct!'}
`;
+ if (!practice.awaitingRetype) {
+ practice.firstTryCorrect++;
+ practice.streak++;
+ }
+ const streakMsg = (!practice.awaitingRetype && practice.streak >= 2)
+ ? `
π₯ ${practice.streak} in a row!` : '';
+ $('#feedback').innerHTML = `
β
${practice.awaitingRetype ? 'You got it!' : 'Correct!'}${streakMsg}
`;
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 = `
Not quite β study it!
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;