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:
1 parent
544469d6c9
commit
6f2281e2a7
8 files changed
+202
-35
No files matched your search
+18
-1
@@ -1,5 +1,22 @@
|
|||||||
FROM node:26-alpine
|
FROM node:26-slim
|
||||||
WORKDIR /app
|
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 ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -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 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 status TEXT NOT NULL DEFAULT 'not_started'`);
|
||||||
tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_notes TEXT`);
|
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`);
|
tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_photo TEXT`);
|
||||||
|
|
||||||
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
|
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
|
||||||
|
|||||||
+1
-1
@@ -258,7 +258,7 @@
|
|||||||
<input type="hidden" id="list-id">
|
<input type="hidden" id="list-id">
|
||||||
<input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name">
|
<input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name">
|
||||||
<textarea id="list-words" rows="8"
|
<textarea id="list-words" rows="8"
|
||||||
placeholder="One word per line. Add an example sentence with | like: receive | Did you receive my letter?"></textarea>
|
placeholder="One word per line. Optionally add sentence and definition with | like: receive | Did you receive my letter? | to get or accept something"></textarea>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button type="submit">Save list</button>
|
<button type="submit">Save list</button>
|
||||||
<button type="button" id="list-cancel" class="secondary">Clear</button>
|
<button type="button" id="list-cancel" class="secondary">Clear</button>
|
||||||
|
|||||||
+7
-3
@@ -897,7 +897,11 @@ function renderLists(lists) {
|
|||||||
const list = await api(`/api/lists/${btn.dataset.editList}`);
|
const list = await api(`/api/lists/${btn.dataset.editList}`);
|
||||||
$('#list-id').value = list.builtin ? '' : list.id;
|
$('#list-id').value = list.builtin ? '' : list.id;
|
||||||
$('#list-name').value = list.builtin ? `${list.name} (copy)` : list.name;
|
$('#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-title').textContent = list.builtin ? 'New list (from copy)' : `Editing: ${list.name}`;
|
||||||
$('#list-editor-details').open = true;
|
$('#list-editor-details').open = true;
|
||||||
$('#list-name').scrollIntoView({ behavior: 'smooth' });
|
$('#list-name').scrollIntoView({ behavior: 'smooth' });
|
||||||
@@ -920,8 +924,8 @@ $('#list-form').addEventListener('submit', async (e) => {
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
const [word, sentence = ''] = line.split('|').map((p) => p.trim());
|
const [word, sentence = '', definition = ''] = line.split('|').map((p) => p.trim());
|
||||||
return { word, sentence };
|
return { word, sentence, definition };
|
||||||
});
|
});
|
||||||
if (!name || words.length === 0) return msg('Give the list a name and at least one word.');
|
if (!name || words.length === 0) return msg('Give the list a name and at least one word.');
|
||||||
const id = $('#list-id').value;
|
const id = $('#list-id').value;
|
||||||
|
|||||||
+86
-15
@@ -96,17 +96,55 @@ $('#assignment-photo-input').addEventListener('change', async () => {
|
|||||||
|
|
||||||
// ---------- text to speech (spelling module) ----------
|
// ---------- text to speech (spelling module) ----------
|
||||||
|
|
||||||
function speak(text, rate = 0.85) {
|
// piperAvailable: null = not yet checked, true/false after first /api/tts/status call
|
||||||
const u = new SpeechSynthesisUtterance(text);
|
let piperAvailable = null;
|
||||||
u.rate = rate;
|
let ttsQueue = [];
|
||||||
u.lang = 'en-US';
|
let ttsCurrentAudio = null;
|
||||||
speechSynthesis.speak(u);
|
|
||||||
|
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();
|
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) ----------
|
// ---------- letter diff (LCS alignment, spelling module) ----------
|
||||||
@@ -612,7 +650,7 @@ async function finishFlashcards(nothingDue) {
|
|||||||
|
|
||||||
// ---------- spelling: practice ----------
|
// ---------- 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 } = {}) {
|
async function startPractice({ listId, itemId } = {}) {
|
||||||
const url = listId ? `/api/session/${currentStudent.id}?listId=${listId}` : `/api/session/${currentStudent.id}`;
|
const url = listId ? `/api/session/${currentStudent.id}?listId=${listId}` : `/api/session/${currentStudent.id}`;
|
||||||
@@ -625,7 +663,7 @@ async function startPractice({ listId, itemId } = {}) {
|
|||||||
show('practice');
|
show('practice');
|
||||||
return;
|
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-card').hidden = false;
|
||||||
$('#practice-done').hidden = true;
|
$('#practice-done').hidden = true;
|
||||||
show('practice');
|
show('practice');
|
||||||
@@ -661,12 +699,21 @@ $('#practice-form').addEventListener('submit', async (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.correct) {
|
if (result.correct) {
|
||||||
if (!practice.awaitingRetype) practice.firstTryCorrect++;
|
if (!practice.awaitingRetype) {
|
||||||
$('#feedback').innerHTML = `<div class="banner good">✅ ${practice.awaitingRetype ? 'You got it!' : 'Correct!'}</div>`;
|
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;
|
input.disabled = true;
|
||||||
setTimeout(nextPracticeWord, 900);
|
setTimeout(nextPracticeWord, practice.streak >= 2 ? 1300 : 900);
|
||||||
} else {
|
} 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);
|
showStudyThenRetype(typed, w);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -680,12 +727,14 @@ function showStudyThenRetype(typed, w) {
|
|||||||
const fb = $('#feedback');
|
const fb = $('#feedback');
|
||||||
let secs = 4;
|
let secs = 4;
|
||||||
|
|
||||||
|
const hint = `Hint: starts with <strong>${esc(w.word[0].toUpperCase())}</strong>`;
|
||||||
const render = () => {
|
const render = () => {
|
||||||
fb.innerHTML = `
|
fb.innerHTML = `
|
||||||
<div class="banner bad">Not quite — study it!</div>
|
<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">You typed</span>${d.typedHtml}</div>
|
||||||
<div class="diff-line"><span class="label">Correct</span>${d.correctHtml}</div>
|
<div class="diff-line"><span class="label">Correct</span>${d.correctHtml}</div>
|
||||||
<div class="study-word">${esc(w.word)}</div>
|
<div class="study-word">${esc(w.word)}</div>
|
||||||
|
<div class="hint-badge">${hint}</div>
|
||||||
<div class="countdown">Memorize it… ${secs}</div>`;
|
<div class="countdown">Memorize it… ${secs}</div>`;
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
@@ -711,16 +760,28 @@ async function nextPracticeWord() {
|
|||||||
if (practice.itemId) {
|
if (practice.itemId) {
|
||||||
await api(`/api/items/${practice.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
|
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
|
const missedHtml = practice.missed.length
|
||||||
? `<p>Words to keep working on:</p><ul>${practice.missed.map((m) => `<li>📌 ${esc(m)}</li>`).join('')}</ul>`
|
? `<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>`;
|
: `<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 = `
|
$('#practice-done').innerHTML = `
|
||||||
<div>🎉 Practice complete!</div>
|
<div>🎉 Practice complete!</div>
|
||||||
<div class="big-score">${practice.firstTryCorrect} / ${practice.words.length}</div>
|
<div class="big-score">${practice.firstTryCorrect} / ${practice.words.length}</div>
|
||||||
<p>right on the first try</p>
|
<p>right on the first try</p>
|
||||||
${missedHtml}
|
${missedHtml}
|
||||||
|
${drillBtn}
|
||||||
<button id="practice-home">Back</button>`;
|
<button id="practice-home">Back</button>`;
|
||||||
$('#practice-done').hidden = false;
|
$('#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());
|
$('#practice-home').addEventListener('click', () => backTarget());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,8 +805,18 @@ function presentTestWord() {
|
|||||||
$('#test-bar').style.width = `${(test.i / test.words.length) * 100}%`;
|
$('#test-bar').style.width = `${(test.i / test.words.length) * 100}%`;
|
||||||
const input = $('#test-input');
|
const input = $('#test-input');
|
||||||
input.value = '';
|
input.value = '';
|
||||||
input.focus();
|
input.disabled = true;
|
||||||
|
$('#test-listening').hidden = false;
|
||||||
|
$('#test-form-row').hidden = true;
|
||||||
speakWord(w);
|
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]));
|
$('#btn-test-speak').addEventListener('click', () => speakWord(test.words[test.i]));
|
||||||
|
|||||||
+10
-7
@@ -156,13 +156,16 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<div id="test-card" class="word-card">
|
<div id="test-card" class="word-card">
|
||||||
<button id="btn-test-speak" class="speak-btn">🔊 Say it again</button>
|
<p id="test-listening" class="test-listening-state">🔊 Listen carefully…</p>
|
||||||
<p class="prompt">Spell the word. No hints — you've got this!</p>
|
<div id="test-form-row" hidden>
|
||||||
<form id="test-form" autocomplete="off">
|
<button id="btn-test-speak" class="speak-btn speak-btn-prominent">🔊 Hear it again</button>
|
||||||
<input id="test-input" class="spell-input" type="text" autocomplete="off"
|
<p class="prompt">Now spell the word:</p>
|
||||||
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Spell the word">
|
<form id="test-form" autocomplete="off">
|
||||||
<button type="submit" class="check-btn">Next →</button>
|
<input id="test-input" class="spell-input" type="text" autocomplete="off"
|
||||||
</form>
|
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Spell the word">
|
||||||
|
<button type="submit" class="check-btn">Next →</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="test-done" class="done-card" hidden></div>
|
<div id="test-done" class="done-card" hidden></div>
|
||||||
|
|||||||
@@ -141,6 +141,34 @@ body {
|
|||||||
font-size: 2.6rem; font-weight: 800; letter-spacing: 6px; margin: 0.5rem 0;
|
font-size: 2.6rem; font-weight: 800; letter-spacing: 6px; margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
.countdown { color: var(--muted); }
|
.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 {
|
.done-card {
|
||||||
background: var(--card); border-radius: var(--radius);
|
background: var(--card); border-radius: var(--radius);
|
||||||
@@ -154,6 +182,10 @@ body {
|
|||||||
font-size: 1.2rem; font-weight: 700; font-family: inherit;
|
font-size: 1.2rem; font-weight: 700; font-family: inherit;
|
||||||
background: var(--accent); color: #fff; border: none;
|
background: var(--accent); color: #fff; border: none;
|
||||||
border-radius: 12px; padding: 0.7rem 2rem; cursor: pointer; margin-top: 1rem;
|
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 ---------- */
|
/* ---------- parent dashboard ---------- */
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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';
|
import { db, sha256 } from './db.js';
|
||||||
|
|
||||||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
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();
|
const app = express();
|
||||||
app.use(express.json({ limit: '15mb' })); // photos for page-scanning are base64 in the JSON body
|
app.use(express.json({ limit: '15mb' })); // photos for page-scanning are base64 in the JSON body
|
||||||
app.use(express.static(join(ROOT, 'public')));
|
app.use(express.static(join(ROOT, 'public')));
|
||||||
@@ -119,7 +129,7 @@ app.get('/api/session/:studentId', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const weekWords = db.prepare(`
|
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
|
FROM words w
|
||||||
LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ?
|
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}
|
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);
|
`).all(id, listId);
|
||||||
|
|
||||||
const reviews = db.prepare(`
|
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
|
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}
|
WHERE p.student_id = ? AND w.list_id <> ? AND p.box < ${MASTERED_BOX}
|
||||||
AND date(p.due) <= date('now')
|
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' });
|
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) });
|
res.json({ list, words: shuffle(words) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,7 +253,7 @@ app.get('/api/lists', (req, res) => {
|
|||||||
app.get('/api/lists/:id', (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);
|
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' });
|
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);
|
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' });
|
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 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 (?, ?, ?)`);
|
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());
|
for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim(), String(w.definition || '').trim());
|
||||||
res.json({ id });
|
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(`UPDATE lists SET name = ? WHERE id = ?`).run(String(name).trim(), id);
|
||||||
db.prepare(`DELETE FROM words WHERE list_id = ?`).run(id);
|
db.prepare(`DELETE FROM words WHERE list_id = ?`).run(id);
|
||||||
const ins = db.prepare(`INSERT INTO words (list_id, word, sentence) VALUES (?, ?, ?)`);
|
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());
|
for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim(), String(w.definition || '').trim());
|
||||||
res.json({ ok: true });
|
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 }));
|
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|||||||
Reference in new issue
Block a user