McGuffey's Eclectic Primer (ABC Books rebuild guide) 10OCT2026

All 52 lessons of the 1881 Primer as a reading and handwriting game: listen, read aloud, trace print and cursive, write on a blackboard

Posted by on 10th Oct 2026

ABC Books turns the old McGuffey Readers into a game for the kids. This first book is McGuffey's Eclectic Primer, Revised Edition (1881), all 52 lessons. Play it at https://games.503mail.com/abcbooks/ or tap ABC BOOKS on https://games.503mail.com. It works on the kids' Fire tablets, iPhone, iPad, Android, and in Chrome, Edge, Safari or Firefox on Windows, Mac and Linux, and it can be installed like an app. This post is the complete rebuild guide; every block has a Copy button.

Update 11OCT2026: ABC Books now has its own address, https://abcbooks.fyi/ (the games.503mail.com/abcbooks/ address still works), and runs on its own containers: CT 306 (abc-web) serves the site and CT 307 (abc-speech) checks reading aloud. Build those first with the abcbooks.fyi production post; the steps below then fill CT 306 with the Primer.

What a lesson looks like

Each kid picks their name (Layla, Lavon or Lilly), then the book, then a lesson on the lesson map. A lesson unlocks when the one before it is done, and finished lessons show 1 to 3 stars. Every screen and every step starts with a spoken instruction, and the ๐Ÿ”Š button repeats it.

  1. New words: the lesson's new letters and words as big cards. Tap one to hear it. When every card has been heard, a "Find it!" game says a word and the kid taps the right card (6 rounds).
  2. Listen: the picture and the lesson. The app reads every line and lights up each word as it's said. Tapping any word says it again.
  3. Your turn to read: one line at a time. Where the browser allows the microphone, the kid taps ๐ŸŽค and reads; words turn green (heard) or red (missed), and the red words are read back for a second try. Where it doesn't (the Amazon Kids browser), a grown-up listens, taps any missed words, and taps Done.
  4. Trace: print and 5. Trace: cursive: first the lesson's letters, then its words, then each sentence. Each stroke must start at the green dot and follow the moving arrow; strokes come one at a time in the right order. Round letters (a, d, g, q) start with the round part, the way schools teach it. Lessons that have a script exercise in the book trace that exercise in cursive.
  5. Blackboard: the kid writes the sentence from memory in chalk, in print or cursive, with Peek (shows it for 2.5 seconds), Undo, Erase and Done. Done gives 1 to 3 stars by comparing the writing to the real letters, and saves a picture of the board. Blackboard pictures on the home screen shows each kid's saved boards.

The home screen also has Voice: normal / faster (two recorded speeds) and Tablet check (a page that tests the mic, the voice and the stylus).

The book

  • McGuffey's Eclectic Primer, Revised Edition, copyright 1881, 1896 and 1909, from Project Gutenberg #14642 (public domain). It's the edition most homeschool families use. 52 lessons, every fifth one a review, about six new words each, plus script (cursive) exercises.
  • I compared it with older editions on archive.org. Two scans labeled 1836 and 1838 turned out to be mislabeled (a modern reprint of this 1881 Primer, and the 1838 Fourth Reader). The 1867 Pictorial Primer is a different book: about 80 very short lessons in which the child spells each word out loud, starting with "is it an ox". It will become a second practice book, Spell & Read. An 1836-38 "Eclectic Primer, with Pictures" existed (it's in the publisher's 1838 ads), but I found no scan of it.
  • Gutenberg's copy has no pictures, only descriptions like "Cat watching moth", so each lesson shows an emoji for now plus the description.

The voice

The reading voice is ETI-Eloquence (the Reed voice), the classic screen-reader voice that's installed on my Windows PC for NVDA. Free voices that imitate it (eSpeak NG, including its Klatt variants) didn't sound close enough, so every line, word, letter and instruction is recorded once on the PC with the real engine and served as small MP3 files: 1,577 clips per speed, at Eloquence speed 60 ("normal") and 75 ("faster"), about 30 MB in total. While recording, the script asks the engine to report where each word starts, so the app can light up words in time with the voice.

Recording on the PC means the voice works everywhere, including the Amazon Kids browser, which has no built-in voice at all. It also needs a Windows PC with Eloquence to record new text. Eloquence is licensed software; these recordings are only for my own kids.

How it works

  1. A device opens https://abcbooks.fyi/ (or https://games.503mail.com/abcbooks/). CT 108 (Caddy reverse proxy) passes both to CT 306 (abc-web), which serves ABC Books from /var/www/abcbooks/. The edge routes are set up in the production post.
  2. The page loads app.js, trace.js, hershey.js (the letter shapes), prompts.json (the spoken instructions), books/primer1881.json (the 52 lessons, about 60 KB) and audio/primer1881.json (which recording goes with which text, and when each word starts).
  3. When the app speaks, it looks the text up, fetches the clip (audio/primer1881/s60/<id>.mp3, about 10 KB), plays it and lights up words from the recorded timings. If a recording is missing it plays one clip per word, and only then falls back to the device's own voice.
  4. Progress (stars per lesson per kid), the voice speed and the blackboard pictures are saved in the browser (localStorage), so each tablet or phone keeps its own. Nothing about the kids is sent to the server.
  5. sw.js makes it work offline once loaded: app files are network-first (updates show right away), and voice clips are saved the first time they play.

The tracing letters come from the Hershey fonts (public domain, made in 1967 at the U.S. National Bureau of Standards). They're single-stroke fonts: every letter is a pen path with a start, a direction and an order, which is exactly what tracing needs. "Sans 1-stroke" is the print style and "Script 1-stroke" is the cursive.

Containers involved

Container Role LAN IP
CT 108 (Caddy reverse proxy) abcbooks.fyi and games.503mail.com/abcbooks/ โ†’ CT 306 x.x.0.122
CT 306 (abc-web) serves ABC Books from /var/www/abcbooks/; lame turns the recordings into MP3 x.x.0.127 (DHCP)

Plus a Windows PC with ETI-Eloquence (C:\Windows\Speech\Eloq\eci.dll) and SSH access to the Proxmox host as proxmox. CT 306 itself is built in the production post.

Where to run each command

  • Proxmox host shell: in the Proxmox web UI click pve โ†’ Shell (or SSH to the host as root).
  • Inside CT 306 (abc-web): Proxmox web UI โ†’ 306 (abc-web) โ†’ Console, or pct enter 306 from the host shell.
  • PC: a normal Windows PowerShell window on the PC that has Eloquence.

Rebuild, step by step

1. Make a working folder on the host

Proxmox host shell:

mkdir -p /root/abcbooks-rebuild
cd /root/abcbooks-rebuild

2. Create every file

Paste each block into the Proxmox host shell. Each one writes one file into /root/abcbooks-rebuild/.

index.html: the page: home screen, lesson map, lesson screen, styles (dark mode).

cat > /root/abcbooks-rebuild/index.html <<'EOF'
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#121212">
<title>ABC Books</title>
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="ABC Books">
<style>
:root{color-scheme:dark;--paper:#121212;--ink:#ececec;--soft:#a8a8a8;--accent:#d9733f;--good:#3fae6e;--bad:#ff6b5e;--board:#1d3527;--card:#1e1e1e;--line:#6a6a6a;--hi:#3a3220}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);font-family:Georgia,"Times New Roman",serif;overflow:hidden;user-select:none;-webkit-user-select:none}
.screen{position:absolute;inset:0;display:none;flex-direction:column;padding:12px 16px}
.screen.on{display:flex}
h1{margin:4px 0 10px;font-size:clamp(26px,6vmin,44px);text-align:center;letter-spacing:.04em}
h2{margin:6px 0;font-size:clamp(20px,4.5vmin,32px);text-align:center}
button{font:inherit;font-size:clamp(18px,4vmin,26px);border:3px solid var(--line);background:var(--card);color:var(--ink);border-radius:14px;padding:10px 18px;min-height:52px;cursor:pointer}
button:active{transform:scale(.96)}
button.big{background:var(--accent);color:#fff;border-color:#6b2e14;font-size:clamp(22px,5vmin,32px);padding:14px 28px}
button.ghost{border-color:transparent;background:transparent}
button:disabled{opacity:.35}
.row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;align-items:center}
.grow{flex:1;min-height:0;position:relative}
.kids button{min-width:30vmin;font-size:clamp(24px,6vmin,40px)}
.kids button.sel{background:var(--accent);border-color:var(--accent);color:#fff}
.shelf{display:flex;gap:3vmin;justify-content:center;flex-wrap:wrap;margin-top:3vmin;padding:8px}
#home{overflow-y:auto}
.book{width:min(30vmin,190px);aspect-ratio:3/4;border-radius:6px 14px 14px 6px;color:#fff;padding:14px;display:flex;flex-direction:column;justify-content:space-between;box-shadow:inset 10px 0 0 rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.25);border:none;text-align:left}
.book b{font-size:clamp(18px,4vmin,26px)}.book small{font-size:clamp(13px,2.6vmin,17px);opacity:.9}
.book.soon{filter:grayscale(.7);opacity:.6}
.book .bp{font-size:clamp(14px,3vmin,19px);font-weight:bold;background:rgba(0,0,0,.35);border-radius:8px;padding:4px 8px;align-self:flex-start}
.book.cur{outline:5px solid #ffd166;outline-offset:3px;animation:pulse 1.6s infinite}
.map{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(22vmin,120px),1fr));grid-auto-rows:min(24vmin,120px);gap:12px;overflow-y:auto;padding:6px;align-content:start}
.tile{height:100%;min-height:0;border-radius:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:clamp(22px,6vmin,38px);padding:4px}
.tile .st{font-size:clamp(14px,3vmin,20px);color:#e0a000;letter-spacing:2px}
.tile.next{background:var(--hi);border-color:var(--accent);animation:pulse 1.4s infinite}
.tile.locked{opacity:.35}
.tile.rev{font-size:clamp(16px,3.6vmin,24px)}
@keyframes pulse{50%{transform:scale(1.05)}}
.top{display:flex;align-items:center;gap:8px}
.top .t{flex:1;text-align:center;font-size:clamp(18px,4vmin,28px)}
.steps{display:flex;gap:6px;justify-content:center;margin:4px 0}
.steps i{width:clamp(18px,4vmin,28px);height:clamp(18px,4vmin,28px);border-radius:50%;border:3px solid var(--soft);font-style:normal;font-size:12px;display:flex;align-items:center;justify-content:center}
.steps i.done{background:var(--good);border-color:var(--good)}.steps i.cur{border-color:var(--accent);background:var(--hi)}
.cards{display:flex;flex-wrap:wrap;gap:12px;justify-content:center;align-content:center;height:100%}
.card{min-width:20vmin;font-size:clamp(34px,10vmin,72px);padding:8px 18px;border-radius:16px;background:var(--card)}
.card.letter{color:var(--accent)}
.card.ok{background:var(--good);color:#fff;border-color:var(--good)}.card.no{animation:shake .4s}
@keyframes shake{25%{transform:translateX(-8px)}75%{transform:translateX(8px)}}
.pic{font-size:clamp(40px,12vmin,90px);text-align:center;line-height:1.1}
.pic small{display:block;font-size:14px;color:var(--soft)}
.text{display:flex;flex-direction:column;align-items:center;gap:1.4vmin;overflow-y:auto;height:100%;justify-content:center}
.ln{font-size:clamp(26px,7vmin,52px);line-height:1.2;padding:2px 10px;border-radius:10px}
.ln.cur{background:var(--hi)}
.w{display:inline-block;padding:0 4px;border-radius:8px}
.w.hi{background:var(--accent);color:#fff}.w.ok{color:var(--good);text-decoration:underline 4px}.w.miss{color:var(--bad);text-decoration:underline wavy 3px}
canvas{position:absolute;inset:0;width:100%;height:100%;touch-action:none;border-radius:14px}
.paper{background:#1a1a1a;border:3px solid var(--line);border-radius:14px}
.hint{font-size:clamp(15px,3.2vmin,20px);color:var(--soft);text-align:center;min-height:1.4em}
.toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#2a2a2a;border:3px solid var(--accent);color:#fff;padding:18px 28px;border-radius:18px;font-size:clamp(24px,6vmin,40px);display:none;z-index:9;text-align:center}
.stars{font-size:clamp(40px,12vmin,90px);color:#e0a000;text-align:center;letter-spacing:6px}
.gallery{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;overflow-y:auto}
.gallery img{width:min(44vw,260px);border-radius:8px;border:3px solid var(--board)}
.mic{width:clamp(80px,20vmin,130px);height:clamp(80px,20vmin,130px);border-radius:50%;font-size:clamp(36px,9vmin,60px);background:var(--card)}
.mic.live{background:var(--bad);color:#fff;animation:pulse 1s infinite}
.parent{font-size:15px;color:var(--soft)}
.so{position:fixed;inset:0;background:rgba(10,10,10,.92);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:30;gap:4vmin}
.sow{display:flex;gap:1.5vmin;flex-wrap:wrap;justify-content:center}
.sow span{font-size:clamp(56px,16vmin,140px);font-family:Georgia,serif;color:var(--ink);padding:0 .12em;border-radius:14px;border:4px solid transparent;transition:background .45s,border-color .45s,transform .45s}
.sow span.multi{color:#ff5a5a}
.sow span.silent{color:#777}
.sow span.on{background:var(--hi);border-color:#ffd166;transform:scale(1.12)}
.w{-webkit-touch-callout:none}
</style>
</head><body>

<section id="home" class="screen on">
  <h1>ABC Books</h1>
  <h2>Who is reading?</h2>
  <div class="row kids" id="kids"></div>
  <div class="shelf" id="shelf"></div>
  <div class="row" style="margin-top:auto"><button class="ghost parent" id="speedBtn">Voice: normal</button><button class="ghost parent" id="toGallery">Blackboard pictures</button><a class="parent" href="check.html">Tablet check</a></div>
</section>

<section id="map" class="screen">
  <div class="top"><button onclick="App.go('home')">โ—€</button><div class="t" id="mapTitle"></div></div>
  <div class="map grow" id="tiles"></div>
</section>

<section id="lesson" class="screen">
  <div class="top"><button onclick="App.leave()">โ—€</button><div class="t" id="lesTitle"></div><button id="sayBtn" title="Say it again">๐Ÿ”Š</button></div>
  <div class="steps" id="steps"></div>
  <h2 id="stepTitle"></h2>
  <div class="grow" id="stage"></div>
  <div class="hint" id="hint"></div>
  <div class="row" id="controls"></div>
</section>

<section id="gallery" class="screen">
  <div class="top"><button onclick="App.go('home')">โ—€</button><div class="t">Blackboard pictures</div></div>
  <div class="gallery grow" id="gal"></div>
</section>

<div class="toast" id="toast"></div>
<script src="hershey.js"></script>
<script src="trace.js"></script>
<script src="app.js"></script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});</script>
</body></html>
EOF

app.js: everything the kid sees and hears: kid picker, lesson map, the six lesson steps, the recorded voice with word highlighting, speech recognition with a grown-up fallback, scoring and saving.

cat > /root/abcbooks-rebuild/app.js <<'EOF'
// ABC Books: gamified McGuffey lessons. All progress is saved per kid in localStorage ('abcbooks').
const BOOKS = {};
const App = (() => {
  const $ = id => document.getElementById(id);
  const KIDS = ['Layla', 'Lavon', 'Lilly'];
  let P = {};   // spoken instructions (prompts.json, also recorded by tools/make-audio.ps1)
  const SHELF = [
    { id: 'primer1881' },
    { id: 'first1879' },
    { id: 'second1879' },
    { id: 'third1879' },
    { id: 'fourth1879' },
    { id: 'fifth1879' },
    { id: 'sixth1879' },
    { id: 'spell1867' }
  ];
  const STEPS = [
    { id: 'words', name: 'New words', icon: 'A' },
    { id: 'spell', name: 'Spell it', icon: '๐Ÿ”ค' },
    { id: 'listen', name: 'Listen', icon: '๐Ÿ‘‚' },
    { id: 'read', name: 'Your turn to read', icon: '๐ŸŽค' },
    { id: 'talk', name: 'Talk about it', icon: '๐Ÿ’ฌ' },
    { id: 'print', name: 'Trace: print', icon: 'a' },
    { id: 'script', name: 'Trace: cursive', icon: '๐’ถ' },
    { id: 'board', name: 'Blackboard', icon: 'โœŽ' }
  ];

  // ---------- storage ----------
  let S = load();
  function load() {
    let s = null; try { s = JSON.parse(localStorage.getItem('abcbooks')); } catch (e) {}
    s = s || { kid: null, progress: {}, boards: {} };
    if (!s.speed) s.speed = 60;
    if (!s.kid) { try { const c = JSON.parse(localStorage.getItem('blockcraft_cfg')); if (c && KIDS.includes(c.name)) s.kid = c.name; } catch (e) {} }
    return s;
  }
  function save() { try { localStorage.setItem('abcbooks', JSON.stringify(S)); } catch (e) { trimBoards(); try { localStorage.setItem('abcbooks', JSON.stringify(S)); } catch (e2) {} } }
  function trimBoards() { for (const k in S.boards) S.boards[k] = S.boards[k].slice(-12); }
  const prog = (book) => ((S.progress[S.kid] = S.progress[S.kid] || {})[book] = (S.progress[S.kid] || {})[book] || {});

  // ---------- sound + voice ----------
  let AC;
  function beep(good) {
    try {
      AC = AC || new (window.AudioContext || window.webkitAudioContext)();
      const o = AC.createOscillator(), g = AC.createGain(), t = AC.currentTime;
      o.type = 'triangle'; o.connect(g); g.connect(AC.destination);
      const notes = good ? [523, 659, 784] : [220, 180];
      notes.forEach((f, i) => o.frequency.setValueAtTime(f, t + i * 0.1));
      g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.001, t + notes.length * 0.1 + 0.2);
      o.start(t); o.stop(t + notes.length * 0.1 + 0.25);
    } catch (e) {}
  }
  const hasTTS = 'speechSynthesis' in window;
  let voice = null;
  function pickVoice() {
    if (!hasTTS) return;
    const v = speechSynthesis.getVoices().filter(v => /^en/i.test(v.lang));
    voice = v.find(v => /female|samantha|zira|aria|jenny|google us/i.test(v.name)) || v[0] || null;
  }
  if (hasTTS) { pickVoice(); speechSynthesis.onvoiceschanged = pickVoice; }
  // Recorded Eloquence clips (tools/make-audio.ps1): audio/<book>.json maps text -> clip id + word start times per speed.
  const AUDIO = {};
  const clip = new Audio(); clip.preload = 'auto';
  let clipDone = null, clipRaf = 0, unlocked = false, seqTok = 0, playTok = 0;
  function stopAll() { seqTok++; stopSpeech(); }   // stop anything playing, including a word-by-word sequence

  // ---------- sound it out (phonics chunks, recorded by tools/make-soundouts.ps1) ----------
  // The word big on screen, letter teams (st, sh, igh, ing...) in red, a silent e grey; Eloquence says the word, then each
  // chunk's phonics sound while the highlight moves slowly from chunk to chunk.
  const SO = {};
  const loadSO = b => (b in SO) ? SO[b] : (SO[b] = fetch(`audio/${b}.soundout.json`).then(r => r.ok ? r.json() : null).catch(() => null));
  async function soundOut(word) {
    const w = word.replace(/[^A-Za-z']/g, '').toLowerCase(); if (!w) return;
    const m = await loadSO(bookId), e = m && m.words[w];
    if (!e) return speak(word.replace(/[^A-Za-z']/g, ''), null, 0.7);
    stopAll();
    const ov = el('div', 'so'), row = el('div', 'sow'); ov.appendChild(row);
    const spans = e.ch.map(([t, k]) => { const s = el('span', (t.length > 1 ? 'multi' : '') + (k === 'S' ? ' silent' : ''), t); row.appendChild(s); return s; });
    ov.appendChild(el('div', 'hint', 'Listen: the word, then each sound. Tap to close.'));
    ov.onclick = () => stopAll();
    document.body.appendChild(ov);
    const my = ++playTok;
    try {
      const u = await clipURL(`audio/so/s${m.speed}/${e.id}.mp3`);
      if (my !== playTok) return;
      await new Promise(res => {
        clipDone = res; clip.muted = false; clip.src = u;
        const tick = () => {
          const ms = clip.currentTime * 1000; let cur = -1;
          e.ch.forEach(([, , t], i) => { if (t >= 0 && ms >= t - 80) cur = i; });
          spans.forEach((s, i) => s.classList.toggle('on', i === cur));
          clipRaf = setTimeout(tick, 60);   // a timer, not animation frames: keeps stepping even on a busy tablet
        };
        clip.onended = clip.onerror = () => { clearTimeout(clipRaf); if (clipDone) { const d = clipDone; clipDone = null; d(); } };
        clip.play().then(tick).catch(() => clip.onended());
      });
    } catch (err) { } finally { setTimeout(() => ov.remove(), 500); }
  }
  // press and hold a word (about half a second) to sound it out; a quick tap still does what it did before
  function holdToSound(node, word) {
    let t = 0, held = false;
    node.addEventListener('pointerdown', () => { held = false; clearTimeout(t); t = setTimeout(() => { held = true; soundOut(word); }, 550); });
    ['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => node.addEventListener(ev, () => clearTimeout(t)));
    node.addEventListener('click', e => { if (held) { e.stopImmediatePropagation(); e.preventDefault(); held = false; } }, true);
    node.addEventListener('contextmenu', e => e.preventDefault());
  }
  function stopSpeech() {
    cancelAnimationFrame(clipRaf); clearTimeout(clipRaf); clip.pause();
    if (clipDone) { const d = clipDone; clipDone = null; d(); }
    if (hasTTS) speechSynthesis.cancel();
  }
  // look in the open book's recordings first, then any other book's (the home screen has no book open yet)
  function clipFor(key) {
    for (const b of [bookId, ...Object.keys(AUDIO)]) {
      const m = AUDIO[b]; if (!m) continue;
      const c = m.items[key] || (!/\s/.test(key) && m.items[key.toLowerCase()]);
      if (c) return { ...c, book: b };
    }
  }
  const speedOf = b => AUDIO[b].speeds.includes(S.speed) ? S.speed : AUDIO[b].speeds[0];
  function playClip(c, onWord) {
    stopSpeech();
    const sp = speedOf(c.book), t = c['t' + sp] || [];
    const my = ++playTok;
    return new Promise(res => {
      clipDone = () => { onWord && onWord(-1); res(); };
      clipURL(`audio/${c.book}/s${sp}/${c.id}.mp3`).then(u => {
      if (my !== playTok) return;
      clip.muted = false; clip.src = u;
      const tick = () => {
        const ms = clip.currentTime * 1000 + 40; let i = 0;
        for (let k = 0; k < t.length - 1; k++) if (t[k] >= 0 && ms >= t[k]) i = k;
        onWord(i); clipRaf = requestAnimationFrame(tick);
      };
      clip.onended = clip.onerror = () => { cancelAnimationFrame(clipRaf); if (clipDone) { const d = clipDone; clipDone = null; d(); } };
      clip.play().then(() => { if (onWord) tick(); }).catch(() => clip.onended());
      }).catch(() => { if (my === playTok && clipDone) { const d = clipDone; clipDone = null; d(); } });
    });
  }
  // Clips are fetched whole and played from memory: works with the offline cache on every browser (Safari needs this).
  const blobs = new Map();
  function clipURL(url) {
    if (!blobs.has(url)) blobs.set(url, fetch(url).then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); }).then(b => URL.createObjectURL(b)));
    const p = blobs.get(url); p.catch(() => blobs.delete(url)); return p;
  }
  // warm up a lesson's clips so the first tap answers right away
  function preload(L) {
    if (!AUDIO[bookId]) return;
    [...L.lines, ...L.words, ...L.letters.map(t => 'letter:' + t)].forEach(k => { const c = clipFor(k); if (c) clipURL(`audio/${c.book}/s${speedOf(c.book)}/${c.id}.mp3`).catch(() => {}); });
  }
  // Safari/iOS only allows audio that starts inside a tap: play a muted clip on the first tap to unlock it.
  window.addEventListener('pointerdown', () => {
    const c = clipFor('Yes!'); if (unlocked || !c) return; unlocked = true;
    const src = `audio/${c.book}/s${AUDIO[c.book].speeds[0]}/${c.id}.mp3`;
    if (!clip.src) { clip.muted = true; clip.src = src; clip.play().then(() => { if (clip.src.endsWith(src)) { clip.pause(); } clip.muted = false; }).catch(() => { clip.muted = false; }); }
  }, { passive: true });
  // speak(text, onWord(i)) -> Promise: recorded clip if there is one, else one clip per word, else the device voice.
  async function speak(text, onWord, rate) {
    seqTok++;
    const c = clipFor(text);
    if (c) return playClip(c, onWord);
    const parts = text.split(/[\s,]+/).map(w => w.replace(/[^A-Za-z']/g, '')).filter(Boolean);
    if (parts.length && parts.every(w => clipFor(w))) {
      const my = seqTok;
      for (let i = 0; i < parts.length && my === seqTok; i++) { await playClip(clipFor(parts[i]), onWord ? j => onWord(j < 0 ? -1 : i) : null); }
      return;
    }
    return ttsSpeak(text, onWord, rate);
  }
  // device voice fallback. Word highlight uses boundary events, or a timing guess when the device has none.
  function ttsSpeak(text, onWord, rate) {
    return new Promise(res => {
      const words = text.split(/\s+/);
      if (!hasTTS) { // no voice: still step through the words so the highlight works
        let i = 0; const t = setInterval(() => { onWord && onWord(i); if (++i > words.length) { clearInterval(t); res(); } }, 550); return;
      }
      speechSynthesis.cancel();
      const u = new SpeechSynthesisUtterance(text.replace(/;/g, ','));
      if (voice) u.voice = voice; u.rate = rate || 0.8; u.pitch = 1.05;
      let gotBoundary = false, guess;
      const starts = []; let p = 0; for (const w of words) { starts.push(p); p += w.length + 1; }
      u.onboundary = e => {
        if (e.name && e.name !== 'word') return; gotBoundary = true; clearInterval(guess);
        let i = starts.findIndex((s, k) => e.charIndex >= s && (k === starts.length - 1 || e.charIndex < starts[k + 1]));
        onWord && i >= 0 && onWord(i);
      };
      u.onstart = () => {
        if (!onWord) return; let i = 0; onWord(0);
        guess = setInterval(() => { if (gotBoundary) return clearInterval(guess); if (++i < words.length) onWord(i); }, 620 / (rate || 0.8) * 0.8);
      };
      u.onend = u.onerror = () => { clearInterval(guess); onWord && onWord(-1); res(); };
      speechSynthesis.speak(u);
    });
  }

  // ---------- speech recognition ----------
  // First choice: record the line and let our Vosk server (CT 307, api/recognize) check it. It only
  // listens for the line's words, so misreads are caught instead of auto-corrected. Fallback: the browser's own.
  const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
  const GUM = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
  let useMic = GUM || !!SR;   // turned off for good if the browser blocks the mic (Amazon Kids browser says not-allowed)
  let useServer = GUM, lastUnknown = [], stopListening = () => {};
  const SOUNDS_LIKE = { a: ['uh', 'ah', 'the'], ann: ['an', 'anne', 'and'], nat: ['gnat', 'matt', 'nate'], rat: ['ratt'], cat: ['kat'], rab: ['rob', 'rabb'] };
  const norm = w => w.toLowerCase().replace(/[^a-z']/g, '');
  function same(a, b) { return a === b || (SOUNDS_LIKE[a] || []).includes(b); }
  // which expected words were heard, in order (LCS alignment)
  function matchWords(expected, heard) {
    const E = expected.map(norm), H = heard.map(norm).filter(Boolean);
    const m = E.length, n = H.length, d = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
    for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) d[i][j] = same(E[i], H[j]) ? d[i + 1][j + 1] + 1 : Math.max(d[i + 1][j], d[i][j + 1]);
    const ok = new Array(m).fill(false); let i = 0, j = 0;
    while (i < m && j < n) { if (same(E[i], H[j])) { ok[i] = true; i++; j++; } else if (d[i + 1][j] >= d[i][j + 1]) i++; else j++; }
    return ok;
  }
  async function listen(timeoutMs, line) {
    lastUnknown = [];
    if (useServer) {
      try { return await listenServer(timeoutMs, line); }
      catch (e) { useServer = false; if (!SR) { useMic = false; return []; } }   // server down: browser recognizer if any
    }
    return listenBrowser(timeoutMs);
  }
  // Records until the kid stops talking (1.3 s quiet), the time runs out, or the mic is tapped again.
  async function listenServer(timeoutMs, line) {
    let stream;
    try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); }
    catch (e) { useMic = false; return []; }
    const ctx = new (window.AudioContext || window.webkitAudioContext)();
    try { await ctx.resume(); } catch (e) {}
    const src = ctx.createMediaStreamSource(stream), proc = ctx.createScriptProcessor(4096, 1, 1);
    const chunks = []; let spoke = false, quiet = 0; const t0 = performance.now();
    await new Promise(res => {
      let done = false; const fin = () => { if (!done) { done = true; res(); } };
      stopListening = fin;
      proc.onaudioprocess = e => {
        const d = e.inputBuffer.getChannelData(0); chunks.push(new Float32Array(d));
        let s = 0; for (let i = 0; i < d.length; i++) s += d[i] * d[i];
        if (Math.sqrt(s / d.length) > 0.015) { spoke = true; quiet = 0; } else if (spoke) quiet += d.length / ctx.sampleRate * 1000;
        if ((spoke && quiet > 1300) || performance.now() - t0 > timeoutMs) fin();
      };
      src.connect(proc); proc.connect(ctx.destination);
    });
    stopListening = () => {};
    proc.disconnect(); src.disconnect(); stream.getTracks().forEach(t => t.stop());
    const rate = ctx.sampleRate; ctx.close();
    if (!spoke) return [];
    const r = await fetch('api/recognize?words=' + encodeURIComponent(line), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: to16k(chunks, rate) });
    if (!r.ok) throw new Error('speech ' + r.status);
    const j = await r.json(); lastUnknown = j.unknown || [];
    return [j.words || []];
  }
  // Float32 at the device rate -> 16 kHz 16-bit PCM (what Vosk wants)
  function to16k(chunks, rate) {
    const n = chunks.reduce((a, c) => a + c.length, 0), all = new Float32Array(n); let o = 0;
    for (const c of chunks) { all.set(c, o); o += c.length; }
    const ratio = rate / 16000, m = Math.floor(n / ratio), out = new Int16Array(m);
    for (let i = 0; i < m; i++) {
      const a = Math.floor(i * ratio), b = Math.min(n, Math.max(a + 1, Math.floor((i + 1) * ratio))); let s = 0;
      for (let k = a; k < b; k++) s += all[k];
      out[i] = Math.max(-1, Math.min(1, s / (b - a))) * 32767;
    }
    return out.buffer;
  }
  function listenBrowser(timeoutMs) {
    return new Promise(res => {
      const r = new SR(); r.lang = 'en-US'; r.interimResults = false; r.maxAlternatives = 3;
      let best = [], done = false;
      const fin = () => { if (done) return; done = true; try { r.stop(); } catch (e) {} res(best); };
      r.onresult = e => { const alts = [...e.results[0]].map(a => a.transcript.split(/\s+/)); best = alts; fin(); };
      r.onerror = e => { if (/not-allowed/.test(e.error || '')) useMic = false; fin(); };
      r.onend = fin;
      try { r.start(); } catch (e) { useMic = false; return fin(); }
      setTimeout(fin, timeoutMs || 9000);
    });
  }

  // ---------- helpers ----------
  function toast(t, ms) { const el = $('toast'); el.textContent = t; el.style.display = 'block'; clearTimeout(toast.t); toast.t = setTimeout(() => el.style.display = 'none', ms || 1200); }
  function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
  function btn(label, fn, cls) { const b = el('button', cls, label); b.onclick = fn; return b; }
  const shuffle = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
  const starStr = n => 'โ˜…'.repeat(n) + 'โ˜†'.repeat(3 - n);
  function go(id) { document.querySelectorAll('.screen').forEach(s => s.classList.toggle('on', s.id === id)); if (id === 'home') home(); }

  // ---------- home + map ----------
  function home() {
    const k = $('kids'); k.innerHTML = '';
    KIDS.forEach(n => k.appendChild(btn(n, () => { S.kid = n; save(); home(); speak(P.greet.replace('{kid}', n)); }, S.kid === n ? 'sel' : '')));
    const sb = $('speedBtn'); sb.textContent = 'Voice: ' + (S.speed === 75 ? 'faster' : 'normal');
    sb.onclick = () => { S.speed = S.speed === 75 ? 60 : 75; save(); home(); bookId = bookId || 'primer1881'; speak('Great job!'); };
    // The books follow the kids as they grow: each shows this kid's progress, and the one they're on glows.
    // Every book stays open, so an older kid can start at their own level.
    const sh = $('shelf'); sh.innerHTML = '';
    const doneIn = id => { const p = (S.progress[S.kid] || {})[id] || {}; return Object.values(p).filter(x => x && x.stars).length; };
    const open = SHELF.filter(b => !b.soon && BOOKS[b.id]);
    const cur = S.kid && (open.slice().reverse().find(b => doneIn(b.id) > 0 && doneIn(b.id) < BOOKS[b.id].lessons.length)
      || open.find(b => doneIn(b.id) < BOOKS[b.id].lessons.length));
    SHELF.forEach(b => {
      const B = BOOKS[b.id] || b;
      const n = BOOKS[b.id] ? BOOKS[b.id].lessons.length : 0, d = S.kid && n ? doneIn(b.id) : 0;
      const prog = n && S.kid ? (d >= n ? 'โ˜… Finished!' : d ? `Lesson ${d + 1} of ${n}` : `Lessons: ${n}`) : '';
      const e = btn(`<b>${B.title}</b><span class="bp">${prog}</span><small>${B.sub}</small>`, () => {
        if (!S.kid) return toast('Pick your name first!');
        if (b.soon || !BOOKS[b.id]) return toast('Coming soon!');
        openBook(b.id);
      }, 'book' + (b.soon ? ' soon' : '') + (cur && cur.id === b.id ? ' cur' : ''));
      e.style.background = B.color; sh.appendChild(e);
    });
  }
  let book, bookId;
  function openBook(id) {
    loadSO(id);
    bookId = id; book = BOOKS[id]; $('mapTitle').textContent = `${S.kid}: ${book.title}`;
    const t = $('tiles'); t.innerHTML = ''; const p = prog(id);
    let nextSet = false;
    book.lessons.forEach((L, i) => {
      const st = (p[L.n] || {}).stars || 0, prevDone = i === 0 || (p[book.lessons[i - 1].n] || {}).stars;
      const isNext = !st && prevDone && !nextSet; if (isNext) nextSet = true;
      const tile = btn(`${L.review ? 'Review<br>' + L.n : L.n}<span class="st">${st ? starStr(st) : ''}</span>`, () => {
        if (!prevDone && !st) return toast('Finish lesson ' + book.lessons[i - 1].n + ' first');
        startLesson(L);
      }, 'tile' + (isNext ? ' next' : '') + (!prevDone && !st ? ' locked' : '') + (L.review ? ' rev' : ''));
      t.appendChild(tile);
    });
    go('map'); speak(P.map);
  }

  // ---------- lesson flow ----------
  let L, stepIdx, scores, cleanup = null, sayAgain = null;
  function stepsFor(L) { return STEPS.filter(s => (s.id !== 'words' || (L.words.length + L.letters.length)) && (s.id !== 'talk' || (L.questions && L.questions.length)) && (s.id !== 'spell' || book.spell)); }
  function startLesson(lesson) {
    L = lesson; scores = {}; stepIdx = 0; preload(L);
    $('lesTitle').textContent = (L.review ? 'Review ' : 'Lesson ') + L.n;
    go('lesson'); runStep();
  }
  function leave() { stopStep(); openBook(bookId); }
  function stopStep() { stopAll(); if (cleanup) { cleanup(); cleanup = null; } }
  function runStep() {
    stopStep();
    const steps = stepsFor(L), s = steps[stepIdx];
    const dots = $('steps'); dots.innerHTML = '';
    steps.forEach((x, i) => { const d = el('i', i < stepIdx ? 'done' : i === stepIdx ? 'cur' : '', x.icon); dots.appendChild(d); });
    if (!s) return finish();
    $('stepTitle').textContent = s.name; $('stage').innerHTML = ''; $('controls').innerHTML = ''; $('hint').textContent = '';
    sayAgain = null; stepTok++;
    const instr = s.id === 'read' ? (useMic ? P.readMic : P.readHelper) : P[s.id];
    $('hint').textContent = instr;
    introP = (stepIdx === 0 ? speak('lesson:' + L.n) : Promise.resolve()).then(() => stepTok === myTok && speak(instr));
    const myTok = stepTok;
    $('sayBtn').onclick = () => speak(instr).then(() => sayAgain && sayAgain());
    ({ words: stepWords, listen: stepListen, read: stepRead, talk: stepTalk, spell: stepSpell, print: () => stepTrace('print'), script: () => stepTrace('script'), board: stepBoard })[s.id]();
  }
  let introP = Promise.resolve(), stepTok = 0;
  const afterIntro = fn => { const t = stepTok; introP.then(() => { if (t === stepTok) fn(); }); };
  function next(stars) { scores[stepsFor(L)[stepIdx].id] = stars; stepIdx++; runStep(); }

  // 1. New words: hear each card, then "find it" game
  function stepWords() {
    const items = [...L.letters.map(t => ({ t, letter: true })), ...L.words.map(t => ({ t }))];
    const box = el('div', 'cards'); $('stage').appendChild(box);
    const cards = items.map(it => { const c = btn(it.t, () => onTap(it, c), 'card' + (it.letter ? ' letter' : '')); if (!it.letter) holdToSound(c, it.t); box.appendChild(c); return c; });
    let mode = 'learn', heardAll = new Set(), target = null, round = 0, misses = 0, busy = false;
    const rounds = shuffle(L.words.length ? L.words : L.letters).concat(shuffle(L.words.length ? L.words : L.letters)).slice(0, 6);
    $('hint').textContent = 'Tap each card to hear it.';
    // a word with a meaning from the book (Fourth Reader DEFINITIONS) says the word and what it means
    const def = it => !it.letter && L.defs && L.defs[it.t];
    function say(it) { if (def(it)) $('hint').textContent = `${it.t}: ${def(it)}`; return speak(it.letter ? 'letter:' + it.t : def(it) ? 'def:' + it.t : it.t, null, 0.7); }
    function onTap(it, c) {
      if (mode === 'learn') { say(it); heardAll.add(it); c.classList.add('ok'); setTimeout(() => c.classList.remove('ok'), 500); if (heardAll.size === items.length) startGame(); return; }
      if (busy) return;
      if (it.t === target) { busy = true; beep(true); c.classList.add('ok'); setTimeout(() => { busy = false; c.classList.remove('ok'); nextRound(); }, 700); }
      else { beep(false); misses++; c.classList.remove('no'); void c.offsetWidth; c.classList.add('no'); }
    }
    function startGame() {
      $('controls').innerHTML = '';
      $('controls').appendChild(btn('Play: Find it!', () => { mode = 'game'; $('controls').innerHTML = ''; speak(P.game).then(nextRound); }, 'big'));
      $('hint').textContent = 'Good! Now play the game.'; speak('Good! Now play the game.');
    }
    function nextRound() {
      if (round >= rounds.length) { busy = true; toast('Great job! โญ'); return setTimeout(() => next(misses === 0 ? 3 : misses < 3 ? 2 : 1), 900); }
      target = rounds[round++]; $('hint').textContent = `Find the word (${round} of ${rounds.length})`;
      sayAgain = () => speak('Find ' + target, null, 0.75); sayAgain();
      shuffle(cards).forEach(c => box.appendChild(c));
    }
  }

  // 2. Listen: the book is read to you with each word lit up
  function stepListen() {
    const st = $('stage'); const wrap = el('div', 'text');
    wrap.appendChild(el('div', 'pic', `${L.pic}<small>${L.picText}</small>`));
    if (L.title) wrap.appendChild(el('h2', '', L.title));
    if (L.author) { const by = el('div', 'parent', 'by ' + L.author); if (L.about) by.title = L.about; wrap.appendChild(by); }
    const lines = L.lines.map(t => { const d = el('div', 'ln'); t.split(' ').forEach(w => { const s = el('span', 'w', w); s.onclick = () => speak(w.replace(/[^A-Za-z']/g, ''), null, 0.7); holdToSound(s, w); d.appendChild(s); d.append(' '); }); wrap.appendChild(d); return d; });
    st.appendChild(wrap);
    let stop = false;
    async function playAll() {
      playB.disabled = true; stop = false;
      for (const d of lines) {
        if (stop) break;
        d.classList.add('cur'); d.scrollIntoView({ block: 'nearest' });
        const ws = [...d.querySelectorAll('.w')];
        await speak(d.textContent.trim(), i => ws.forEach((w, k) => w.classList.toggle('hi', k === i)));
        d.classList.remove('cur'); await new Promise(r => setTimeout(r, 350));
      }
      if (stop) return;
      playB.disabled = false; nextB.disabled = false; $('hint').textContent = 'Tap a word to hear it. Press and hold a word to sound it out.';
    }
    const playB = btn('โ–ถ Read to me', playAll, 'big'), nextB = btn('Next โ–ถ', () => next(3));
    nextB.disabled = true; $('controls').append(playB, nextB);
    sayAgain = playAll; cleanup = () => { stop = true; stopAll(); };
    if (!AUDIO[bookId] && !hasTTS) $('hint').textContent = 'This device has no reading voice. A grown-up can read it.';
    afterIntro(playAll);
  }

  // 3. Your turn: kid reads each line; the app listens (or a grown-up marks it)
  function stepRead() {
    const st = $('stage'); const wrap = el('div', 'text'); st.appendChild(wrap);
    let li = 0, total = 0, right = 0, tries = 0;
    const lineEl = el('div', 'ln cur'); wrap.appendChild(lineEl);
    const mic = btn('๐ŸŽค', go, 'mic');
    function show() {
      lineEl.innerHTML = '';
      L.lines[li].split(' ').forEach(w => { const s = el('span', 'w', w); holdToSound(s, w); lineEl.append(s, ' '); });
      $('hint').textContent = useMic ? `Line ${li + 1} of ${L.lines.length}: tap the microphone, then read it out loud.` : `Line ${li + 1} of ${L.lines.length}: read it out loud. Grown-up: tap words that were missed, then โœ“.`;
      $('controls').innerHTML = '';
      if (useMic) $('controls').appendChild(mic);
      else {
        [...lineEl.querySelectorAll('.w')].forEach(w => { w.classList.add('ok'); w.onclick = () => { w.classList.toggle('ok'); w.classList.toggle('miss'); }; });
        $('controls').appendChild(btn('โœ“ Done', () => grade([...lineEl.querySelectorAll('.w')].map(w => w.classList.contains('ok'))), 'big'));
      }
      $('controls').appendChild(btn('๐Ÿ”Š Help', () => speak(L.lines[li])));
      $('controls').appendChild(btn('Skip', () => next(1), 'ghost parent'));
    }
    async function go() {
      if (mic.classList.contains('live')) return stopListening();   // second tap = "I'm done"
      mic.classList.add('live');
      const words = L.lines[li].split(' ');
      const alts = await listen(Math.min(25000, 4000 + 700 * words.length), L.lines[li]);
      mic.classList.remove('live');
      if (!useMic) { $('hint').textContent = P.micBlocked; speak(P.micBlocked); return show(); }
      let ok = new Array(words.length).fill(false);
      for (const a of alts) { const m = matchWords(words, a); if (m.filter(Boolean).length > ok.filter(Boolean).length) ok = m; }
      if (alts.length) ok = ok.map((v, i) => v || lastUnknown.includes(norm(words[i])));   // words the checker can't hear count as read
      [...lineEl.querySelectorAll('.w')].forEach((w, i) => { w.classList.toggle('ok', ok[i]); w.classList.toggle('miss', !ok[i]); });
      if (!alts.length) { toast('I did not hear you. Try again!', 1500); return; }
      grade(ok);
    }
    function grade(ok) {
      const n = ok.filter(Boolean).length; tries++;
      if (n === ok.length || tries >= 2) {
        total += ok.length; right += n; tries = 0;
        if (n === ok.length) { beep(true); toast('Yes! โญ', 900); } else toast('Good try!', 900);
        setTimeout(() => { if (++li >= L.lines.length) next(right / total > 0.9 ? 3 : right / total > 0.7 ? 2 : 1); else show(); }, 1000);
      } else {
        beep(false); $('hint').textContent = 'Listen to the red words sounded out, then try again.';
        const words = L.lines[li].split(' ').filter((w, i) => !ok[i]);
        (async () => {   // each missed word sounded out, the phonics way
          for (const w of words) { await soundOut(w); await new Promise(r => setTimeout(r, 400)); }
          if (!useMic) show();
        })();
      }
    }
    sayAgain = () => speak(L.lines[li]);
    show();
  }

  // 1b. Spell it (Spell & Read, 1867): "Let the child spell each word in the line, then read the line."
  // Tapping a word plays its spelling ("I, S. is.") so a grown-up can check.
  function stepSpell() {
    const wrap = el('div', 'text'); $('stage').appendChild(wrap);
    const box = el('div', 'cards'); box.style.height = 'auto'; wrap.appendChild(box);
    let i = 0;
    function show() {
      box.innerHTML = '';
      L.lines[i].split(' ').forEach(w => {
        // a joined word ("new-cut", "lap-dog") is spelled one part after the other
        const parts = w.split('-').map(p => p.replace(/[^A-Za-z']/g, '')).filter(Boolean);
        if (!parts.length) return;
        const sb = btn(w, async () => { for (const p of parts) await speak('spell:' + (p === 'I' ? p : p.toLowerCase()), null, 0.7); }, 'card');
        holdToSound(sb, w); box.appendChild(sb);
      });
      $('hint').textContent = `Line ${i + 1} of ${L.lines.length}: spell each word out loud, then read the line.`;
    }
    $('controls').append(btn('๐Ÿ”Š Line', () => speak(L.lines[i])), btn('Next line โ–ถ', () => { if (++i < L.lines.length) show(); else next(3); }, 'big'));
    sayAgain = () => speak(L.lines[i]);
    show();
  }

  // 3b. Talk about it: the book's own questions (Fourth Reader EXERCISES), read one at a time; the kid answers out loud
  function stepTalk() {
    const wrap = el('div', 'text'); $('stage').appendChild(wrap);
    const q = el('div', 'ln cur'); wrap.appendChild(q);
    let i = 0;
    function show() {
      q.textContent = L.questions[i];
      $('hint').textContent = `Question ${i + 1} of ${L.questions.length}: answer it out loud.`;
      sayAgain = () => speak(L.questions[i]); sayAgain();
    }
    $('controls').append(btn('Next question โ–ถ', () => { if (++i < L.questions.length) show(); else next(3); }, 'big'));
    afterIntro(show);
  }

  // 4/5. Tracing pages: letters, words, then each sentence
  // Copy work stays a sensible length in the long Readers: new words 6 to a page, up to 6 short sentences
  // (tools/make-audio.ps1 records the same word pages).
  const WORDS_PER_PAGE = 6;
  function copySentences(max) {
    const seen = new Set(), all = [];
    L.lines.filter(t => t.split(' ').length > 2).forEach(t => { const k = t.toLowerCase(); if (!seen.has(k)) { seen.add(k); all.push(t); } });
    const short = all.filter(t => t.length <= 70);
    return (short.length ? short : all.slice(0, 1)).slice(0, max);
  }
  function copyItems(style) {
    if (style === 'script' && L.script) return L.script.slice(0, 6);
    const out = [];
    if (L.letters.length) out.push(L.letters.join(' '));
    for (let i = 0; i < L.words.length; i += WORDS_PER_PAGE) out.push(L.words.slice(i, i + WORDS_PER_PAGE).join(' '));
    return out.concat(copySentences(6));
  }
  function stepTrace(style) {
    const items = copyItems(style); let i = 0, misses = 0, tracer;
    const box = el('div', 'grow paper'); box.style.height = '100%'; $('stage').appendChild(box);
    const cv = el('canvas'); box.appendChild(cv);
    ABCTrace.Tracer.onMiss = why => { misses++; beep(false); $('hint').textContent = why === 'start' ? 'Start at the green dot!' : 'Follow the arrow all the way.'; };
    function page() {
      if (tracer) tracer.stop();
      $('hint').textContent = `Page ${i + 1} of ${items.length}: start at the green dot and follow the arrow.`;
      sayAgain = () => speak(items[i]); cv.dataset.text = items[i];
      if (i === 0) afterIntro(sayAgain); else sayAgain();
      tracer =ABCTrace.Tracer(cv, items[i], style, () => {
        beep(true); toast('โญ', 700);
        setTimeout(() => { if (++i < items.length) page(); else next(misses < 3 ? 3 : misses < 8 ? 2 : 1); }, 800);
      });
    }
    $('controls').appendChild(btn('Skip page', () => { if (++i < items.length) page(); else next(1); }, 'ghost parent'));
    setTimeout(page, 50);
    const onRes = () => page(); window.addEventListener('resize', onRes);
    cleanup = () => { tracer && tracer.stop(); window.removeEventListener('resize', onRes); };
  }

  // 6. Blackboard: write it yourself in chalk
  function stepBoard() {
    const items = copySentences(3);
    if (!items.length) items.push(L.lines[0]);
    let i = 0, style = 'print', bb, total = 0;
    const box = el('div', 'grow'); box.style.height = '100%'; $('stage').appendChild(box);
    const cv = el('canvas'); cv.style.background = 'var(--board)'; box.appendChild(cv);
    const styleB = btn('Print', () => { style = style === 'print' ? 'script' : 'print'; styleB.textContent = style === 'print' ? 'Print' : 'Cursive'; page(); });
    $('controls').append(styleB, btn('๐Ÿ‘€ Peek', () => bb.peek(2500)), btn('โ†ถ', () => bb.undo()), btn('Erase', () => bb.erase()), btn('Done โœ“', done, 'big'));
    function page() {
      if (bb) bb.stop();
      bb = ABCTrace.Blackboard(cv, items[i], style);
      $('hint').textContent = `Write: "${items[i]}" (${i + 1} of ${items.length})`;
      sayAgain = () => speak('Write: ' + items[i]);
      if (i === 0) afterIntro(sayAgain); else sayAgain();
    }
    function done() {
      if (bb.empty()) return toast('Write it on the board first!');
      const s = bb.score(); total += s;
      const list = (S.boards[S.kid] = S.boards[S.kid] || []);
      list.push({ book: bookId, n: L.n, text: items[i], style, img: bb.snapshot(), at: Date.now() });
      if (list.length > 40) list.splice(0, list.length - 40);
      save(); beep(true); toast(starStr(s), 1100);
      setTimeout(() => { if (++i < items.length) page(); else next(Math.round(total / items.length)); }, 1200);
    }
    setTimeout(page, 50);
    cleanup = () => bb && bb.stop();
  }

  function finish() {
    const vals = Object.values(scores), stars = Math.max(1, Math.round(vals.reduce((a, b) => a + b, 0) / vals.length));
    const p = prog(bookId), old = (p[L.n] || {}).stars || 0;
    p[L.n] = { stars: Math.max(old, stars), steps: scores, at: Date.now() }; save();
    $('stepTitle').textContent = `${S.kid}, you finished ${L.review ? 'Review' : 'Lesson'} ${L.n}!`;
    $('stage').innerHTML = `<div class="text"><div class="stars">${starStr(stars)}</div></div>`;
    $('hint').textContent = ''; $('controls').innerHTML = '';
    $('controls').append(btn('Lessons', () => openBook(bookId), 'big'));
    beep(true); speak(`Great job, ${S.kid}!`).then(() => speak(P.done));
  }

  // ---------- gallery ----------
  $('toGallery').onclick = () => {
    const g = $('gal'); g.innerHTML = '';
    const list = (S.boards[S.kid] || []).slice().reverse();
    if (!list.length) g.innerHTML = '<p>No blackboard pictures yet.</p>';
    list.forEach(b => { const f = el('figure'); f.style.margin = 0; const im = el('img'); im.src = b.img; f.append(im, el('figcaption', 'parent', `Lesson ${b.n} ยท ${b.style} ยท ${new Date(b.at).toLocaleDateString()}`)); g.appendChild(f); });
    go('gallery');
  };

  // books + their recorded voice are JSON files, so the same lesson data feeds the app and the audio recorder
  Promise.all([fetch('prompts.json').then(r => r.json()).then(j => { P = j; })].concat(SHELF.filter(b => !b.soon).map(b => Promise.all([
    fetch(`books/${b.id}.json`).then(r => r.json()).then(j => { BOOKS[b.id] = j; }),
    fetch(`audio/${b.id}.json`).then(r => r.ok ? r.json() : null).then(j => { if (j) AUDIO[b.id] = j; }).catch(() => {})
  ])))).then(home, () => toast('Could not load the books. Check the internet.', 4000));
  window.addEventListener('pointerdown', () => { if (AC && AC.state === 'suspended') AC.resume(); }, { passive: true });
  return { go, leave, debug: () => ({ S, L, stepIdx, scores }) };
})();
EOF

trace.js: lays out text in print or cursive, guided tracing (start dot, arrow, order and direction) and the chalk blackboard with scoring.

cat > /root/abcbooks-rebuild/trace.js <<'EOF'
// Tracing + blackboard engine. Uses window.HERSHEY (single-stroke fonts, y-up, baseline 0, 1000 units/em).
(function () {
  const glyphCache = {};
  function glyph(style, ch) {
    const key = style + ch;
    if (glyphCache[key]) return glyphCache[key];
    const g = HERSHEY[style][ch] || HERSHEY[style]['?'];
    const strokes = []; let cur = null;
    const t = (g[1] || '').trim().split(/\s+/);
    for (let i = 0; i < t.length;) {
      const c = t[i++];
      if (c === 'M') { cur = [{ x: +t[i++], y: +t[i++] }]; strokes.push(cur); }
      else if (c === 'L') cur.push({ x: +t[i++], y: +t[i++] });
      else i++;
    }
    // school order: the round part before the stem (font draws the stem first)
    if (style === 'print' && 'adgq'.includes(ch) && strokes.length > 1) strokes.push(strokes.shift());
    return (glyphCache[key] = { adv: +g[0], strokes });
  }
  function wordWidth(style, w) { let x = 0; for (const ch of w) x += glyph(style, ch).adv; return x; }

  // Lay out text into rows that fit maxW x maxH. Returns strokes in canvas px plus ruling lines.
  function layout(text, style, maxW, maxH, maxScale) {
    const words = text.split(/\s+/).filter(Boolean);
    const space = glyph(style, ' ').adv * (style === 'script' ? 0.8 : 1);
    const lineH = 1150; // units per row (ascent 800 + descent 200 + gap)
    let s = maxScale, rows;
    for (; s > 0.05; s *= 0.92) {
      rows = []; let row = [], w = 0;
      for (const wd of words) {
        const ww = wordWidth(style, wd);
        if (row.length && (w + space + ww) * s > maxW) { rows.push({ words: row, w }); row = []; w = 0; }
        w += (row.length ? space : 0) + ww; row.push(wd);
      }
      if (row.length) rows.push({ words: row, w });
      if (rows.length * lineH * s <= maxH && rows.every(r => r.w * s <= maxW)) break;
    }
    const strokes = [], rules = [];
    const top = (maxH - rows.length * lineH * s) / 2;
    rows.forEach((r, ri) => {
      const base = top + (ri * lineH + 870) * s;
      let x = (maxW - r.w * s) / 2;
      rules.push({ base, x0: x - 20, x1: x + r.w * s + 20 });
      r.words.forEach((wd, wi) => {
        if (wi) x += space * s;
        let wordStrokes = [];
        for (const ch of wd) {
          const g = glyph(style, ch);
          for (const st of g.strokes) wordStrokes.push(st.map(p => ({ x: x + p.x * s, y: base - p.y * s })));
          x += g.adv * s;
        }
        if (style === 'script') wordStrokes = joinStrokes(wordStrokes, 60 * s);
        strokes.push(...wordStrokes);
      });
    });
    return { strokes: strokes.map(st => resample(st, 6)), rules, scale: s, xh: 300 * s, cap: 640 * s };
  }
  // Cursive: glue a letter's exit stroke to the next letter's entry so a word is one pen line.
  function joinStrokes(strokes, gap) {
    const out = [];
    for (const st of strokes) {
      const last = out[out.length - 1];
      if (last) { const a = last[last.length - 1], b = st[0]; if (Math.hypot(a.x - b.x, a.y - b.y) < gap) { last.push(...st.slice(1)); continue; } }
      out.push(st.slice());
    }
    return out;
  }
  function resample(pts, step) {
    if (pts.length < 2) return pts.slice();
    const out = [pts[0]]; let carry = 0;
    for (let i = 1; i < pts.length; i++) {
      const a = pts[i - 1], b = pts[i], d = Math.hypot(b.x - a.x, b.y - a.y);
      let t = step - carry;
      while (t <= d) { out.push({ x: a.x + (b.x - a.x) * t / d, y: a.y + (b.y - a.y) * t / d }); t += step; }
      carry = d - (t - step);
    }
    const e = pts[pts.length - 1], l = out[out.length - 1];
    if (Math.hypot(e.x - l.x, e.y - l.y) > 1) out.push(e);
    return out;
  }

  function coalesced(e) { const c = e.getCoalescedEvents ? e.getCoalescedEvents() : null; return c && c.length ? c : [e]; }
  function setupCanvas(cv) {
    const r = cv.getBoundingClientRect(), dpr = Math.min(2, window.devicePixelRatio || 1);
    cv.width = Math.round(r.width * dpr); cv.height = Math.round(r.height * dpr);
    const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    return { ctx, w: r.width, h: r.height };
  }
  function drawRules(ctx, L, color) {
    ctx.save(); ctx.lineWidth = 2;
    for (const r of L.rules) {
      ctx.strokeStyle = color; ctx.setLineDash([]);
      line(ctx, r.x0, r.base, r.x1, r.base); line(ctx, r.x0, r.base - L.cap, r.x1, r.base - L.cap);
      ctx.setLineDash([10, 10]); line(ctx, r.x0, r.base - L.xh, r.x1, r.base - L.xh);
    }
    ctx.restore();
  }
  function line(ctx, a, b, c, d) { ctx.beginPath(); ctx.moveTo(a, b); ctx.lineTo(c, d); ctx.stroke(); }
  function poly(ctx, pts, upto) {
    ctx.beginPath(); const n = upto == null ? pts.length : upto + 1;
    pts.slice(0, n).forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke();
  }
  function arrow(ctx, pts, idx, size) {
    const a = pts[Math.max(0, idx)], b = pts[Math.min(pts.length - 1, idx + 3)];
    const ang = Math.atan2(b.y - a.y, b.x - a.x);
    ctx.beginPath(); ctx.moveTo(a.x + Math.cos(ang) * size, a.y + Math.sin(ang) * size);
    ctx.lineTo(a.x + Math.cos(ang + 2.5) * size, a.y + Math.sin(ang + 2.5) * size);
    ctx.lineTo(a.x + Math.cos(ang - 2.5) * size, a.y + Math.sin(ang - 2.5) * size); ctx.closePath(); ctx.fill();
  }

  // Guided tracing: one stroke at a time, must start at the green dot and follow the arrow.
  function Tracer(cv, text, style, onDone) {
    let C, L, done = 0, idx = -1, ink = [], drawing = false, tol, lw, anim = 0, raf;
    function init() {
      C = setupCanvas(cv);
      L = layout(text, style, C.w - 40, C.h - 30, 0.32);
      L.strokes.forEach(st => st.forEach(p => { p.x += 20; p.y += 15; }));
      L.rules.forEach(r => { r.base += 15; r.x0 += 20; r.x1 += 20; });
      lw = Math.max(8, 70 * L.scale); tol = Math.max(22, lw * 1.6);
      draw();
    }
    function draw() {
      const ctx = C.ctx; ctx.clearRect(0, 0, C.w, C.h);
      drawRules(ctx, L, 'rgba(140,170,220,.45)');
      ctx.lineCap = ctx.lineJoin = 'round';
      L.strokes.forEach((st, i) => {
        ctx.lineWidth = lw;
        if (i < done) { ctx.strokeStyle = '#3fae6e'; poly(ctx, st); }
        else { ctx.strokeStyle = i === done ? '#5a5a5a' : '#3a3a3a'; ctx.setLineDash(i === done ? [] : [2, lw * 0.9]); poly(ctx, st); ctx.setLineDash([]); }
      });
      const cur = L.strokes[done];
      if (cur) {
        if (idx >= 0) { ctx.strokeStyle = '#5aa2ff'; ctx.lineWidth = lw; poly(ctx, cur, idx); }
        // moving guide dot shows where to go
        const k = idx >= 0 ? idx : Math.floor(anim) % cur.length;
        ctx.fillStyle = '#f0a020'; arrow(ctx, cur, Math.min(k, cur.length - 2), lw * 1.1);
        ctx.fillStyle = '#20b050'; ctx.beginPath(); ctx.arc(cur[0].x, cur[0].y, lw * 0.9, 0, 7); ctx.fill();
      }
      ctx.strokeStyle = 'rgba(120,180,255,.55)'; ctx.lineWidth = Math.max(4, lw * 0.5);
      if (ink.length) poly(ctx, ink);
    }
    function tick() { anim += 0.6; if (idx < 0) draw(); raf = requestAnimationFrame(tick); }
    function pos(e) { const r = cv.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
    function down(e) {
      e.preventDefault(); const p = pos(e), cur = L.strokes[done]; if (!cur) return;
      try { cv.setPointerCapture(e.pointerId); } catch (_) {}
      drawing = true; ink = [p];
      idx = Math.hypot(p.x - cur[0].x, p.y - cur[0].y) < tol * 1.3 ? 0 : -2; // -2 = started in wrong place
      draw();
    }
    function move(e) {
      if (!drawing) return; e.preventDefault();
      const evs = coalesced(e);
      const cur = L.strokes[done];
      for (const ev of evs) {
        const p = pos(ev); ink.push(p);
        if (idx >= 0) for (let j = 1; j <= 4 && idx + 1 < cur.length; j++) {
          const q = cur[idx + 1]; if (Math.hypot(p.x - q.x, p.y - q.y) < tol) idx++; else break;
        }
      }
      draw();
    }
    function up() {
      if (!drawing) return; drawing = false;
      const cur = L.strokes[done];
      const ok = idx >= 0 && idx >= cur.length - 2 - Math.floor(cur.length * 0.08);
      const wrongStart = idx === -2;
      ink = []; idx = -1;
      if (ok) { done++; if (done >= L.strokes.length) { draw(); cancelAnimationFrame(raf); onDone && onDone(); return; } }
      draw();
      if (!ok && Tracer.onMiss) Tracer.onMiss(wrongStart ? 'start' : 'follow');
    }
    cv.onpointerdown = down; cv.onpointermove = move; cv.onpointerup = cv.onpointercancel = up;
    init(); tick();
    return { stop() { cancelAnimationFrame(raf); cv.onpointerdown = cv.onpointermove = cv.onpointerup = null; }, progress: () => done / L.strokes.length };
  }

  // Blackboard: free writing in chalk; score by comparing ink to the target text laid out in the same box.
  function Blackboard(cv, text, style) {
    let C, strokes = [], cur = null, L;
    function init() { C = setupCanvas(cv); L = layout(text, style, C.w - 40, C.h - 30, 0.32); redraw(); }
    function chalk(ctx, pts) {
      for (let k = 0; k < 3; k++) {
        ctx.strokeStyle = 'rgba(245,245,235,' + (0.5 - k * 0.12) + ')'; ctx.lineWidth = 7 - k * 2;
        ctx.beginPath(); pts.forEach((p, i) => { const jx = Math.sin(i * 12.99 + k * 7.1) * k * 0.8, jy = Math.cos(i * 78.23 + k * 3.7) * k * 0.8; i ? ctx.lineTo(p.x + jx, p.y + jy) : ctx.moveTo(p.x + jx, p.y + jy); }); ctx.stroke();
      }
    }
    function redraw(peek) {
      const ctx = C.ctx; ctx.fillStyle = '#23402f'; ctx.fillRect(0, 0, C.w, C.h);
      ctx.lineCap = ctx.lineJoin = 'round';
      drawRules(ctx, shift(L), 'rgba(255,255,255,.12)');
      if (peek) { ctx.strokeStyle = 'rgba(255,255,160,.5)'; ctx.lineWidth = 5; shift(L).strokes.forEach(st => poly(ctx, st)); }
      strokes.forEach(st => chalk(ctx, st));
    }
    function shift(L0) {
      if (L0._s) return L0._s;
      return (L0._s = { ...L0, strokes: L0.strokes.map(st => st.map(p => ({ x: p.x + 20, y: p.y + 15 }))), rules: L0.rules.map(r => ({ base: r.base + 15, x0: r.x0 + 20, x1: r.x1 + 20 })) });
    }
    function pos(e) { const r = cv.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
    cv.onpointerdown = e => { e.preventDefault(); try { cv.setPointerCapture(e.pointerId); } catch (_) {} cur = [pos(e)]; strokes.push(cur); };
    cv.onpointermove = e => { if (!cur) return; e.preventDefault(); coalesced(e).forEach(ev => cur.push(pos(ev))); redraw(); };
    cv.onpointerup = cv.onpointercancel = () => { cur = null; };
    init();
    return {
      erase() { strokes = []; redraw(); },
      undo() { strokes.pop(); redraw(); },
      peek(ms) { redraw(true); setTimeout(() => redraw(), ms || 2500); },
      empty: () => !strokes.length,
      // 0..3 stars: how much of the target is covered and how much ink is on target, after fitting ink box to target box
      score() {
        const ink = strokes.flat(); if (ink.length < 5) return 0;
        const tgt = shift(L).strokes.flat();
        const bb = pts => pts.reduce((b, p) => ({ x0: Math.min(b.x0, p.x), y0: Math.min(b.y0, p.y), x1: Math.max(b.x1, p.x), y1: Math.max(b.y1, p.y) }), { x0: 1e9, y0: 1e9, x1: -1e9, y1: -1e9 });
        const a = bb(ink), b = bb(tgt);
        const sx = (b.x1 - b.x0) / Math.max(1, a.x1 - a.x0), sy = (b.y1 - b.y0) / Math.max(1, a.y1 - a.y0);
        const fit = ink.map(p => ({ x: b.x0 + (p.x - a.x0) * sx, y: b.y0 + (p.y - a.y0) * sy }));
        const tol = Math.max(18, 110 * L.scale);
        const near = (p, set) => set.some(q => Math.abs(q.x - p.x) < tol && Math.abs(q.y - p.y) < tol);
        const fitS = fit.filter((_, i) => i % 2 === 0), tgtS = tgt.filter((_, i) => i % 2 === 0);
        const cover = tgtS.filter(p => near(p, fitS)).length / tgtS.length;
        const onTarget = fitS.filter(p => near(p, tgtS)).length / fitS.length;
        const v = Math.min(cover, onTarget);
        return v > 0.8 ? 3 : v > 0.6 ? 2 : 1;
      },
      snapshot() { const t = document.createElement('canvas'); t.width = 320; t.height = Math.round(320 * C.h / C.w); t.getContext('2d').drawImage(cv, 0, 0, t.width, t.height); return t.toDataURL('image/jpeg', 0.6); },
      stop() { cv.onpointerdown = cv.onpointermove = cv.onpointerup = null; }
    };
  }
  window.ABCTrace = { Tracer, Blackboard, layout };
})();
EOF

prompts.json: every spoken instruction. Change the wording here, then re-record (step 6).

cat > /root/abcbooks-rebuild/prompts.json <<'EOF'
{
  "home": "Who is reading? Tap your name. Then tap a book.",
  "map": "Pick a lesson. The glowing one is next.",
  "words": "New words. Tap each card to hear it.",
  "game": "Now play the game. Listen, then tap the word you hear.",
  "spell": "Spell it. Spell each word out loud, then read the line. Tap a word to hear how it is spelled.",
  "listen": "Listen. I will read the lesson to you. Watch the words light up.",
  "readMic": "Your turn to read. Tap the microphone, then read the line out loud.",
  "readHelper": "Your turn to read. Read each line out loud to a grown-up. Grown-up, tap any word that was missed, then tap done.",
  "micBlocked": "This device cannot listen. Read it to a grown-up instead.",
  "print": "Time to trace. Start at the green dot, and follow the arrow.",
  "script": "Now trace in cursive. Start at the green dot, and follow the arrow.",
  "talk": "Talk about it. Listen to each question, and answer it out loud to a grown-up.",
  "board": "Blackboard time. Write it yourself. Tap peek if you need help. Tap done when you finish.",
  "done": "You finished the lesson!",
  "greet": "Hi, {kid}! Tap a book to start."
}
EOF

phonics.json: the phonics sound each letter card says, in Eloquence's phonetic code (see the phonics post).

cat > /root/abcbooks-rebuild/phonics.json <<'EOF'
{
  "_about": "Phonics sounds for the letter cards: sound -> [Eloquence phonetic syllable, example word]. Consonants get a short 'uh' (H), as the user picked (style 2). Symbols from tools/eloq-phonemes.ps1.",
  "a":  ["A",    "cat"],
  "b":  ["bH",   "bat"],
  "c":  ["kH",   "cat"],
  "ch": ["tSH",  "chat"],
  "ck": ["kH",   "duck"],
  "d":  ["dH",   "dog"],
  "e":  ["E",    "bed"],
  "ew": ["u",    "new"],
  "f":  ["fH",   "fan"],
  "g":  ["gH",   "gum"],
  "gh": ["fH",   "laugh"],
  "h":  ["hH",   "hat"],
  "i":  ["I",    "sit"],
  "j":  ["dZH",  "jug"],
  "k":  ["kH",   "kit"],
  "l":  ["lH",   "lip"],
  "m":  ["mH",   "man"],
  "n":  ["nH",   "nest"],
  "ng": ["HG",   "sing"],
  "o":  ["a",    "odd"],
  "oi": ["cy",   "oil"],
  "oo": ["u",    "moon"],
  "ou": ["aw",   "out"],
  "ow": ["aw",   "cow"],
  "oy": ["cy",   "boy"],
  "p":  ["pH",   "pen"],
  "qu": ["kwH",  "quit"],
  "r":  ["rH",   "red"],
  "s":  ["sH",   "sun"],
  "sh": ["SH",   "she"],
  "sk": ["skH",  "ask"],
  "t":  ["tH",   "top"],
  "th": ["TH",   "thin"],
  "u":  ["H",    "up"],
  "v":  ["vH",   "van"],
  "w":  ["wH",   "web"],
  "wh": ["wH",   "what"],
  "x":  ["Hks",  "box"],
  "y":  ["yH",   "yes"],
  "z":  ["zH",   "zoo"]
}
EOF

phonics.html: a page that plays every phonics sound.

cat > /root/abcbooks-rebuild/phonics.html <<'EOF'
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ABC Books phonics sounds</title>
<style>
body{background:#121212;color:#e8e8e8;font:20px/1.4 system-ui,sans-serif;margin:16px;max-width:760px}
h1{font-size:26px}
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;border-top:1px solid #444;padding:10px 0}
.row b{min-width:3em;font-size:24px}
button{font-size:19px;padding:12px 14px;border-radius:12px;border:2px solid #6a6a6a;background:#1e1e1e;color:#fff}
button:focus,button:active{border-color:#ffd166;outline:none}
</style></head><body>
<h1>Phonics sounds</h1>
<p>Every sound the letter cards say, in Eloquence (Reed). Tap a sound to hear it, or "with word" to hear the sound and then an example word.</p>
<div id="list"></div>
<script>
// sounds come from phonics.json; the recordings are in the Primer's voice files (tools/make-audio.ps1 records letter:X and example:X)
const audio = new Audio();
function play(files) {
  let i = 0;
  const next = () => { if (i < files.length) { audio.src = files[i++]; audio.play(); } };
  audio.onended = () => setTimeout(next, 350); next();
}
Promise.all([fetch('phonics.json').then(r => r.json()), fetch('audio/primer1881.json').then(r => r.json())]).then(([ph, m]) => {
  const url = k => m.items[k] ? `audio/primer1881/s${m.speeds[0]}/${m.items[k].id}.mp3` : null;
  Object.keys(ph).filter(s => !s.startsWith('_')).forEach(s => {
    const row = document.createElement('div'); row.className = 'row';
    const h = document.createElement('b'); h.textContent = s; row.appendChild(h);
    const a = url('letter:' + s), w = url('example:' + s);
    const b1 = document.createElement('button'); b1.textContent = `Sound: ${s}`; b1.onclick = () => play([a]);
    const b2 = document.createElement('button'); b2.textContent = `${s}, with word ${ph[s][1]}`; b2.onclick = () => play([a, w]);
    row.append(b1, b2); document.getElementById('list').appendChild(row);
  });
});
</script></body></html>
EOF

sw.js: offline support. Bump the SHELL version whenever app files change, so installed copies update.

cat > /root/abcbooks-rebuild/sw.js <<'EOF'
// Offline support. App files: network first (so updates show), cached copy when offline.
// Voice clips (audio/*.mp3) never change for a given name: cache first, saved the first time they play.
// Other apps share this origin (Ray's Arithmetic at /rays/ with its own 'rays-*' caches and its own SW),
// so only touch 'abc-' caches and leave /rays/ requests alone.
const SHELL = 'abc-shell-v20', CLIPS = 'abc-clips-v1';
const FILES = ['./', 'index.html', 'app.js', 'prompts.json', 'trace.js', 'hershey.js', 'books/primer1881.json', 'audio/primer1881.json', 'books/first1879.json', 'audio/first1879.json', 'books/second1879.json', 'audio/second1879.json', 'books/third1879.json', 'audio/third1879.json', 'books/fourth1879.json', 'audio/fourth1879.json', 'books/fifth1879.json', 'audio/fifth1879.json', 'books/sixth1879.json', 'audio/sixth1879.json', 'books/spell1867.json', 'audio/spell1867.json', 'phonics.json', 'icon-192.png', 'manifest.webmanifest'];
self.addEventListener('install', e => { e.waitUntil(caches.open(SHELL).then(c => c.addAll(FILES)).then(() => self.skipWaiting())); });
self.addEventListener('activate', e => {
  e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k.startsWith('abc-') && k !== SHELL && k !== CLIPS).map(k => caches.delete(k)))).then(() => self.clients.claim()));
});
self.addEventListener('fetch', e => {
  const r = e.request, u = new URL(r.url);
  if (r.method !== 'GET' || u.origin !== location.origin || /\/rays(\/|$)/.test(u.pathname)) return;
  if (r.url.endsWith('.mp3')) {
    e.respondWith(caches.open(CLIPS).then(c => c.match(r).then(hit => hit || fetch(r).then(res => { if (res.ok && res.status === 200) c.put(r, res.clone()); return res; }))));
    return;
  }
  e.respondWith(fetch(r).then(res => { if (res.ok) caches.open(SHELL).then(c => c.put(r, res.clone())); return res; }).catch(() => caches.match(r)));
});
EOF

manifest.webmanifest: lets phones, tablets and computers install it as an app.

cat > /root/abcbooks-rebuild/manifest.webmanifest <<'EOF'
{
  "name": "ABC Books",
  "short_name": "ABC Books",
  "description": "McGuffey reading lessons: listen, read aloud, trace and write.",
  "start_url": "./",
  "scope": "./",
  "display": "standalone",
  "orientation": "any",
  "background_color": "#121212",
  "theme_color": "#121212",
  "icons": [
    { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
    { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ]
}
EOF

check.html: the "Tablet check" page: tests the microphone, the built-in voice, speech recognition and the stylus.

cat > /root/abcbooks-rebuild/check.html <<'EOF'
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ABC Books - tablet check</title>
<style>
body{background:#1b2a22;color:#f2f2e8;font:20px/1.4 system-ui,sans-serif;margin:16px}
h1{font-size:26px}button{font-size:22px;padding:14px 22px;margin:6px 0;border-radius:12px;border:2px solid #9c9;background:#2e4a3a;color:#fff}
li{margin:6px 0}.ok{color:#8f8}.bad{color:#f88}#pad{background:#24382d;border:2px dashed #9c9;height:160px;touch-action:none;margin-top:8px}
</style></head><body>
<h1>ABC Books: tablet check</h1>
<p>Tap each button. Tell Dad what the list says.</p>
<ul id="out"></ul>
<button id="mic">1. Test microphone (talk for 3 seconds)</button><br>
<button id="tts">2. Test talking voice</button><br>
<button id="sr">4. Test listening: tap, then say "a cat and a rat"</button>
<p>3. Draw in the box with the stylus:</p>
<div id="pad"></div>
<script>
const out=document.getElementById('out');
function line(t,ok){const li=document.createElement('li');li.className=ok?'ok':'bad';li.textContent=(ok?'YES ':'NO ')+t;out.appendChild(li);}
line('getUserMedia',!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia));
line('MediaRecorder',typeof MediaRecorder!=='undefined');
line('speechSynthesis',typeof speechSynthesis!=='undefined');
line('SpeechRecognition',!!(window.SpeechRecognition||window.webkitSpeechRecognition));
line('AudioContext',!!(window.AudioContext||window.webkitAudioContext));
line('PointerEvent',typeof PointerEvent!=='undefined');
document.getElementById('mic').onclick=async()=>{
  try{
    const s=await navigator.mediaDevices.getUserMedia({audio:true});
    line('mic allowed',true);
    const AC=window.AudioContext||window.webkitAudioContext,ac=new AC(),an=ac.createAnalyser();
    ac.createMediaStreamSource(s).connect(an);const buf=new Float32Array(an.fftSize);let peak=0;
    const t0=Date.now();
    await new Promise(r=>{(function tick(){an.getFloatTimeDomainData(buf);for(const v of buf)peak=Math.max(peak,Math.abs(v));Date.now()-t0<3000?setTimeout(tick,50):r();})();});
    line('heard sound (level '+peak.toFixed(2)+')',peak>0.02);
    s.getTracks().forEach(t=>t.stop());
  }catch(e){line('mic: '+e.name+' '+e.message,false);}
};
document.getElementById('tts').onclick=()=>{
  try{
    const v=speechSynthesis.getVoices();line('voices: '+v.length,v.length>0);
    const u=new SpeechSynthesisUtterance('A cat and a rat.');u.onend=()=>line('voice finished',true);
    u.onerror=e=>line('voice error '+e.error,false);speechSynthesis.speak(u);
  }catch(e){line('voice: '+e.message,false);}
};
document.getElementById('sr').onclick=()=>{
  const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
  if(!SR)return line('no SpeechRecognition',false);
  try{
    const r=new SR();r.lang='en-US';line('listening...',true);
    r.onresult=e=>line('heard: "'+e.results[0][0].transcript+'"',true);
    r.onerror=e=>line('listen error: '+e.error,false);
    r.onend=()=>line('listening ended',true);r.start();
  }catch(e){line('listen: '+e.message,false);}
};
let seen=false;
document.getElementById('pad').addEventListener('pointermove',e=>{
  if(seen)return;seen=true;line('drawing works, type='+e.pointerType+', pressure='+e.pressure,true);
});
</script></body></html>
EOF

deploy.ps1 (PC): packs public/ and unpacks it into CT 306.

cat > /root/abcbooks-rebuild/deploy.ps1 <<'EOF'
# Deploy ABC Books to CT 306. Run from anywhere: powershell -ExecutionPolicy Bypass -File ABCBooks\deploy.ps1
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$tar = Join-Path $env:TEMP 'abcbooks-site.tar'
tar -cf $tar -C (Join-Path $here 'public') .
ssh -o BatchMode=yes proxmox "mkdir -p /root/abcbooks"
scp -o BatchMode=yes $tar proxmox:/root/abcbooks/site.tar
scp -o BatchMode=yes (Join-Path $here 'proxmox\push.sh') proxmox:/root/abcbooks/push.sh
ssh -o BatchMode=yes proxmox "bash /root/abcbooks/push.sh"
EOF

push.sh (host): run by deploy.ps1; unpacks the site into CT 306.

cat > /root/abcbooks-rebuild/push.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host after copying site.tar + this script to /root/abcbooks/.
# Unpacks ABC Books into the site on CT 306 (served as abcbooks.fyi and games.503mail.com/abcbooks/).
set -e
CT=306
DST=/var/www/abcbooks
pct push $CT /root/abcbooks/site.tar /tmp/abcbooks.tar
pct exec $CT -- mkdir -p $DST
pct exec $CT -- tar -xf /tmp/abcbooks.tar -C $DST --no-same-owner
pct exec $CT -- chmod -R u=rwX,go=rX $DST
pct exec $CT -- ls -lR $DST
EOF

make-book.ps1 (PC): downloads a book's Gutenberg text and turns it into books/<book>.json (primer1881 = this Primer, first1879 = the First Reader). It cleans up the book's markup: new-word lists, letter sounds, accent marks and syllable breaks (cun'ning, be cause'), pronunciation notes, pictures, poems, story titles and script exercises.

cat > /root/abcbooks-rebuild/make-book.ps1 <<'EOF'
# Builds public/books/<book>.json from a Project Gutenberg text of a McGuffey Reader.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-book.ps1 -Book primer1881
#        powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-book.ps1 -Book first1879
param([ValidateSet('primer1881', 'first1879', 'second1879', 'third1879', 'fourth1879', 'fifth1879', 'sixth1879', 'spell1867')] [string]$Book = 'primer1881')
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$books = @{
  # Spell & Read: the 1867 Pictorial Primer, hand-corrected from a scan into source/spell1867.txt (no Gutenberg text exists)
  spell1867  = @{ hand = $true; file = 'spell1867.txt'; title = 'Spell & Read'; sub = 'Pictorial Primer, 1867'; color = '#5b6f3a'
                  source = 'McGuffey''s Pictorial Eclectic Primer, 1867 (archive.org mcguffeyspictori00mcgu, public domain), hand-corrected' }
  primer1881 = @{ gutenberg = 14642; file = 'primer.txt'; title = "McGuffey's Eclectic Primer"; sub = 'Revised Edition, 1881'; color = '#b5562c'
                  end = 'SLATE EXERCISES'; pics = @{ 27 = '๐Ÿž๏ธ'; 46 = 'โœ๏ธ' } }
  first1879  = @{ gutenberg = 14640; file = 'first.txt'; title = "McGuffey's First Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#2c6fb5'
                  end = 'PHONIC CHART.'; pics = @{} }
  second1879 = @{ gutenberg = 14668; file = 'second.txt'; title = "McGuffey's Second Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#3f8f4f'
                  end = '*** END OF THE PROJECT GUTENBERG'; pics = @{} }
  # prose = later Readers: no word lists in Gutenberg's copy (new words are worked out), printed page headers to remove,
  # numbered paragraphs that run on without blank lines, plays ("Rose. ...") and an author line under poems
  third1879  = @{ gutenberg = 14766; file = 'third.txt'; title = "McGuffey's Third Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#7b4fb5'
                  end = '*** END OF THE PROJECT GUTENBERG'; pics = @{}; prose = $true; pageRx = 'ECLECTIC SERIES|THIRD READER'
                  after = @('primer1881', 'first1879', 'second1879') }
  # Fourth Reader: headings carry the title ("I. PERSEVERANCE. (25)"), and each lesson ends with DEFINITIONS (the book's
  # own new words, with meanings), EXERCISES (questions to talk about) and sometimes NOTES
  fourth1879 = @{ gutenberg = 14880; file = 'fourth.txt'; title = "McGuffey's Fourth Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#b5892c'
                  end = '*** END OF THE PROJECT GUTENBERG'; pics = @{}; prose = $true; pageRx = 'ECLECTIC SERIES|FOURTH READER'
                  start = 'I. PERSEVERANCE. (25)'; tocStart = 'SELECTIONS IN PROSE AND POETRY.'; tocEnd = 'LIST OF ILLUSTRATIONS'
                  headRx = '(?m)^([IVXLCl]+)[.:][ \t]+([A-Z][^a-z\n]*?(?:\((?:Concluded|Continued)\.?\))?[^a-z\n]*?)[ \t]*(?:\(\d+\))?[ \t]*$'
                  after = @('primer1881', 'first1879', 'second1879', 'third1879') }
  # Fifth Reader: like the Fourth, but headings have no page number ("II. THE BLUEBELL.") and the first one is scanned as "1."
  fifth1879  = @{ gutenberg = 15040; file = 'fifth.txt'; title = "McGuffey's Fifth Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#2c9bb5'
                  end = '*** END OF THE PROJECT GUTENBERG'; pics = @{}; prose = $true; pageRx = 'ECLECTIC SERIES|FIFTH READER'
                  start = '1. THE GOOD READER.'
                  headRx = '(?m)^(\d+|[IVXLCl]+)[.:][ \t]+(["\u201c]?[A-Z][^a-z\n]*?(?:\((?:Concluded|Continued)\.?\))?[^a-z\n]*?)[ \t]*(?:\(\d+\))?[ \t]*$'
                  tocEnd = 'LIST OF ILLUSTRATIONS'
                  after = @('primer1881', 'first1879', 'second1879', 'third1879', 'fourth1879') }
  # Sixth Reader: headings with page numbers like the Fourth; the lessons start after the chapters on reading aloud
  sixth1879  = @{ gutenberg = 16751; file = 'sixth.txt'; title = "McGuffey's Sixth Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#8f3f5f'
                  end = '*** END OF THE PROJECT GUTENBERG'; pics = @{}; prose = $true; pageRx = 'ECLECTIC SERIES|SIXTH READER'
                  start = 'I. ANECDOTE OF THE DUKE OF NEWCASTLE.'
                  headRx = '(?m)^(\d+|[IVXLCl]+)[.:][ \t]+(["\u201c]?[A-Z][^a-z\n]*?(?:\((?:Concluded|Continued)\.?\))?[^a-z\n]*?)[ \t]*(?:\(\d+\))?[ \t]*$'
                  tocEnd = 'LIST OF ILLUSTRATIONS'
                  after = @('primer1881', 'first1879', 'second1879', 'third1879', 'fourth1879', 'fifth1879') }
}
$cfg = $books[$Book]
$pics = [ordered]@{ 'rat'='๐Ÿ€'; 'cat'='๐Ÿˆ'; 'kitten'='๐Ÿฑ'; 'dog'='๐Ÿ•'; 'frog'='๐Ÿธ'; 'chick'='๐Ÿค'; 'hen'='๐Ÿ”'; 'fox'='๐ŸฆŠ'; 'duck'='๐Ÿฆ†'; 'owl'='๐Ÿฆ‰'; 'bird'='๐Ÿฆ'; 'cow'='๐Ÿ„';
  'rabbit'='๐Ÿ‡'; 'pig'='๐Ÿ–'; 'lamb'='๐Ÿ‘'; 'kite'='๐Ÿช'; 'sled'='๐Ÿ›ท'; 'top'='๐ŸŽฉ'; 'ball'='โšฝ'; 'squirrel'='๐Ÿฟ๏ธ'; 'bee'='๐Ÿ'; 'boat'='โ›ต';
  'ship'='โ›ต'; 'skat'='โ›ธ๏ธ'; 'horse'='๐ŸŽ'; 'train'='๐Ÿš‚'; 'goat'='๐Ÿ'; 'burning'='๐Ÿ”ฅ'; 'school'='๐Ÿซ'; 'quail'='๐Ÿฆ'; 'baby'='๐Ÿ‘ถ'; 'lighthouse'='๐Ÿ—ผ';
  'sheep'='๐Ÿ‘'; 'snow'='โ˜ƒ๏ธ'; 'fishing'='๐ŸŽฃ'; 'doll'='๐Ÿช†'; 'sawing'='๐Ÿชš'; 'lamp'='๐Ÿช”'; 'seashore'='๐Ÿ–๏ธ'; 'blind'='๐Ÿฆฏ'; 'mill'='๐Ÿž๏ธ';
  'sunset'='๐ŸŒ…'; 'woman'='๐Ÿ‘ฉ'; 'wagon'='๐ŸŽ'; 'acorn'='๐ŸŒฐ'; 'drum'='๐Ÿฅ'; 'lake'='๐ŸŒŠ'; 'writing'='โœ๏ธ'; 'man'='๐Ÿ‘ด'; 'girl'='๐Ÿ‘ง'; 'boy'='๐Ÿ‘ฆ'; 'children'='๐Ÿ‘ง' }
if ($cfg.hand) {
  # Hand-corrected book (source/<file>): "= n" starts a lesson, "words:" is its spelling list, "?" lines are notes, "#" comments
  $lessons = @(); $o = $null
  foreach ($ln in [IO.File]::ReadAllLines("$root\source\$($cfg.file)")) {
    $l = $ln.Trim()
    if (-not $l -or $l.StartsWith('#')) { continue }
    if ($l -match '^=\s*(\d+)') { if ($o) { $lessons += $o }; $o = [ordered]@{ n = [int]$Matches[1]; pic = '๐Ÿ“–'; picText = ''; words = @(); letters = @(); lines = @() }; continue }
    if ($l -match '^words:\s*(.*)$') { $o.words = @($Matches[1] -split '\s+' | Where-Object { $_ }); continue }
    if ($l.StartsWith('?')) { $o.note = $l.TrimStart('?', ' '); continue }
    $o.lines += $l
  }
  if ($o) { $lessons += $o }
  foreach ($o in $lessons) {
    if (-not $o.words.Count) {   # no list printed: spell the lesson's own words
      $w = @(); foreach ($m in [regex]::Matches(($o.lines -join ' '), "[A-Za-z]+(?:'[A-Za-z]+)?")) { $x = if ($m.Value -ceq 'I') { 'I' } else { $m.Value.ToLower() }; if ($w -notcontains $x) { $w += $x } }
      $o.words = @($w | Select-Object -First 12)
    }
    $txt0 = $o.lines -join ' '
    foreach ($key in $pics.Keys) { if ($txt0 -match "(?i)\b$key(?:s|es)?\b") { $o.pic = $pics[$key]; break } }
  }
  $out = [ordered]@{ title = $cfg.title; sub = $cfg.sub; color = $cfg.color; spell = $true; source = $cfg.source; lessons = $lessons }
  [IO.File]::WriteAllText("$root\public\books\$Book.json", ($out | ConvertTo-Json -Depth 6), (New-Object Text.UTF8Encoding $false))
  "$($lessons.Count) lessons"
  $lessons | ForEach-Object { "{0,2} {1} w[{2}] lines={3} {4}" -f $_.n, $_.pic, ($_.words -join ' '), $_.lines.Count, $(if ($_.note) { '? ' + $_.note } else { '' }) }
  return
}
if (-not (Test-Path "$root\source\$($cfg.file)")) {
  New-Item -ItemType Directory -Force "$root\source" | Out-Null
  Invoke-WebRequest "https://www.gutenberg.org/cache/epub/$($cfg.gutenberg)/pg$($cfg.gutenberg).txt" -OutFile "$root\source\$($cfg.file)" -UseBasicParsing
}
$txt = [IO.File]::ReadAllText("$root\source\$($cfg.file)") -replace "`r", ''
$headRx = '(?m)^(?:Lesson 1|LESSON [IVXLC]+\.{0,2}(?:\s*-*\s*REVIEW\.?)?)\s*$'   # Third Reader has one "LESSON XXX.."
if ($cfg.headRx) { $headRx = $cfg.headRx }
$body = if ($cfg.start) { $txt.Substring($txt.LastIndexOf($cfg.start)) } else { $txt.Substring([regex]::Match($txt, $headRx).Index) }
$body = $body.Substring(0, $body.IndexOf($cfg.end))
$body = $body -replace '(?m)^SLATE WORK\.?\s*$', ''
if ($cfg.prose) {
  # printed page headers ("18  ECLECTIC SERIES.") and page numbers, with the blank lines around them, so sentences join across pages
  $body = [regex]::Replace($body, "\n[ \t]*\n?[ \t]*\d*[ \t]*(?:$($cfg.pageRx))\.?[ \t]*\d*[ \t]*\n(?:[ \t]*\n)*", "`n")
  $body = $body -replace '(?m)^[ \t]*\(\d+\)[ \t]*\n', '' -replace '(?m)^[ \t]*\d+[,.]?(?:[ \t]*\d+[,.]?)*[ \t]*\n', ''
  $body = $body -creplace "(?m)^[ \t]*\d*[ \t]*(?:$($cfg.pageRx))[.,]?[ \t]*\d*[ \t]*\n", ''           # leftover headers ("62  ECLECTIC SERIES,")
  $body = $body -creplace "(\w) n't\b", '$1n''t' -creplace "'T is\b", "'Tis"                    # old spelling: "do n't" -> "don't"
}
$heads = [regex]::Matches($body, $headRx)
# the text of each lesson runs from the end of its heading to the next heading (Regex.Split would also return captured groups)
$parts = @($body.Substring(0, $heads[0].Index))
for ($i = 0; $i -lt $heads.Count; $i++) { $s = $heads[$i].Index + $heads[$i].Length; $e = if ($i + 1 -lt $heads.Count) { $heads[$i + 1].Index } else { $body.Length }; $parts += $body.Substring($s, $e - $s) }
$sounds = 'th','sh','ch','ng','ew','oy','ow','wh','ou','oo','sk','qu','oi','gh','ck'
$romanVal = @{ I=1; V=5; X=10; L=50; C=100 }
# scanned "LVll." = LVII, so a lowercase l counts as I
function Roman($r) { if ($r -match '^\d+$') { return [int]$r }; $r = $r -creplace 'l', 'I'; $t = 0; for ($i = 0; $i -lt $r.Length; $i++) { $v = $romanVal["$($r[$i])"]; if ($i + 1 -lt $r.Length -and $romanVal["$($r[$i+1])"] -gt $v) { $t -= $v } else { $t += $v } }; $t }
function Sentences($para) {
  # split a paragraph into kid-sized sentences; keep quotes and "--" asides with their sentence
  $p = (($para -replace '\s+', ' ').Trim()) -creplace '^(?:\d+|I)\.\s+(?=["A-Z])', ''
  $out = [regex]::Split($p, '(?<!\b(?:Mr|Mrs|Dr|St)\.)(?<=[.!?]"?)\s+(?=["A-Z])') | Where-Object { $_ }
  # re-join pieces that split inside a quotation
  $merged = @(); $buf = ''
  foreach ($s in $out) { $buf = if ($buf) { "$buf $s" } else { $s }; if ((($buf -split '"').Count - 1) % 2 -eq 0) { $merged += $buf; $buf = '' } }
  if ($buf) { $merged += $buf }
  $merged | ForEach-Object { $s = $_.Trim(); if ($s -match '[A-Za-z]$') { $s += '.' }; $s }
}
# vocabulary words carry accent marks and syllable breaks in the later lessons: cun'ning, be cause', did n't
function IsPoem($para) {
  $ls = @($para.Trim() -split '\n' | Where-Object { $_.Trim() })
  # poem lines are short and each starts with a capital; wrapped prose goes on in lowercase
  if ($para -match '\n\s+\S') { return $true }
  if ($ls.Count -lt 2 -or ($ls | Measure-Object -Property Length -Maximum).Maximum -gt 55) { return $false }
  $low = @($ls | Where-Object { $_.Trim() -cnotmatch '^["''\u2018\u201c(]{0,2}[A-Z]' }).Count
  ($low -eq 0) -or ($ls.Count -ge 4 -and $low -le 1)
}
function CleanWord($t) {
  $t = $t -replace '\s+', ''
  if ($t -match "(n't|'s|'ll|'ve|'re|'d|'m)$" -or $t -match "^'tis$") { return ($t -replace "'(?=.*')", '') }   # keep a real contraction's apostrophe
  $keep = $t.TrimEnd("'")
  if ($keep -match "^(o'clock|o'er|ne'er|e'er|e'en)$") { return $keep }   # old poetic words keep their apostrophe
  $t -replace "'", ''
}
function VocabTokens($ln) {
  $ln = $ln -replace '\s*\([^)]*\)', ''                                         # pronunciation notes: (wuns), (a gen'), (as f)
  $cols = if ($ln -match '\S\s{2,}\S') { $ln.Trim() -split '\s{2,}' } else { $ln.Trim() -split '\s+' }
  foreach ($c in $cols) {
    if (-not $c) { continue }
    if ($c -match '\s') {
      if ($c -match "'") { CleanWord $c }                                         # syllables of one word: be cause'
      elseif ($c -match '^(a|an|the)\s') { }                                      # phrase like "a black hen": not a new word
      else { $c -split '\s+' | ForEach-Object { CleanWord $_ } }                  # two words in one column: night owl
    } else { CleanWord $c }
  }
}
$nums = @()
if ($cfg.headRx) {
  # A real lesson heading has a page number ("(74)") or is the next number in order. That skips a lesson's own
  # numbered parts ("II. SUMMER.") and survives misprinted numerals ("XVV.", a second "LXX."): lessons are numbered in order.
  $keep = @(); $prev = 0
  # up to 2 lessons may be missing between two headings; a heading with a page number but a misprinted numeral is the next lesson
  foreach ($m in $heads) {
    $v = Roman $m.Groups[1].Value
    if ($v -ge $prev + 1 -and $v -le $prev + 3) { $keep += $m; $prev = $v; $nums += $v }
    # a misprinted numeral is still near its place (XVV for XXV); a lesson's own small parts ("III. A GOOD NAME. (95)" inside lesson 12) are not
    elseif ($m.Value -match '\(\d+\)\s*$' -and $v -ge $prev - 5) { $keep += $m; $prev++; $nums += $prev }
  }
  $heads = $keep
  $parts = @($body.Substring(0, $heads[0].Index))
  for ($i = 0; $i -lt $heads.Count; $i++) { $s = $heads[$i].Index + $heads[$i].Length; $e = if ($i + 1 -lt $heads.Count) { $heads[$i + 1].Index } else { $body.Length }; $parts += $body.Substring($s, $e - $s) }
}
# authors from the table of contents: "87.  The Best Capital      Louisa M. Alcott     245"
$tocAuthors = @{}
# (page column optional: the Fifth Reader's contents have none); the first entry for a number wins, which is the contents page
$tocText = $txt.Substring(0, $txt.LastIndexOf($(if ($cfg.start) { $cfg.start } else { 'x' })) + 1)
if ($cfg.tocEnd) { $ci = $tocText.IndexOf($(if ($cfg.tocStart) { $cfg.tocStart } else { 'CONTENTS' })); $tocText = $tocText.Substring($ci, $tocText.IndexOf($cfg.tocEnd, $ci) - $ci) }   # only the contents page, not the chapters on reading aloud
foreach ($m in [regex]::Matches($tocText, '(?m)^\s*(\d+)\.\s+(\S.*?\S)\s{2,}(\S.*?\S)(?:\s{2,}(\d+))?\s*$')) {
  $a = (($m.Groups[3].Value -split '\s{2,}')[-1] -replace '\.?\s*\d+$', '').TrimEnd('.')   # "Longfellow.290": page number stuck to the name
  if ($a -notmatch '^\d' -and -not $tocAuthors.ContainsKey([int]$m.Groups[1].Value)) { $tocAuthors[[int]$m.Groups[1].Value] = $a }   # skip a line that holds two entries
}
$lessons = @()
for ($k = 0; $k -lt $heads.Count; $k++) {
  $h = $heads[$k].Value.Trim()
  $n = if ($h -eq 'Lesson 1') { 1 } else { Roman(([regex]::Match($h, 'LESSON ([IVXLC]+)')).Groups[1].Value) }
  $headTitle = ''
  if ($cfg.headRx) {
    $hm = [regex]::Match($h, $cfg.headRx); $n = $nums[$k]
    $headTitle = (Get-Culture).TextInfo.ToTitleCase($hm.Groups[2].Value.Trim().TrimEnd('.').Trim('"', [char]0x201c, [char]0x201d).TrimEnd('.').ToLower()) -creplace "(\w) N't", '$1n''t' -replace '\(Concluded\.?\)', '(concluded)' -replace '\(Continued\.?\)', '(continued)'
  }
  $review = $h -match 'REVIEW'
  $block = $parts[$k + 1]
  $block = $block -replace '\[Footnote:[^\]]*\]', '' -replace '(?m)^\s*(\d+|[IVX]+)\.\s*$', ''
  # a title line glued to its poem ("ALICE'S SUPPER." then the verses) gets its own paragraph
  $block = $block -creplace '(?m)^([A-Z][A-Z .,''\u2019!-]{3,})$\n(?=\S)', "`$1`n`n"
  $author = ''; $defs = @(); $questions = @()
  # DEFINITIONS / EXERCISES / NOTES at the end of a lesson (Fourth Reader)
  $tm = [regex]::Match($block, '(?m)^[ \t]*(?:DEFINITIONS|EXERCISES|NOTES?)\b')
  if ($tm.Success) {
    $tail = $block.Substring($tm.Index); $block = $block.Substring(0, $tm.Index)
    foreach ($sec in [regex]::Matches($tail, '(?s)(DEFINITIONS|EXERCISES|NOTES?)\.?\s*-*(.*?)(?=(?:\n[ \t]*(?:DEFINITIONS|EXERCISES|NOTES?)\b)|\z)')) {
      $txt2 = ($sec.Groups[2].Value -replace '\s+', ' ').Trim()
      if ($sec.Groups[1].Value -eq 'DEFINITIONS') {
        $txt2 = $txt2 -replace '^In defining words, that meaning is given which is appropriate to them in the connection in which they are used\.\s*', ''
        foreach ($ln in (($txt2 -replace '(?:^|\s)\d+\.\s+', "`n") -split "`n")) {
          foreach ($entry in [regex]::Split($ln.Trim(), '(?<=\.)\s+(?=[A-Z][\w''/\-]*(?: [\w''/\-]+){0,2},\s)')) {
            $cm = [regex]::Match($entry, '^([A-Z][\w''/\-]*(?: [\w''/\-]+){0,2}),\s+(.+?)\.?\s*$')
            if ($cm.Success) {
              $w = $cm.Groups[1].Value -replace '[''/\-]', ''
              $d = ($cm.Groups[2].Value -replace '(?<=[a-z])-(?=[a-z])', '').Trim()
              $defs += ,@($w, $d)
            }
          }
        }
      } elseif ($sec.Groups[1].Value -eq 'EXERCISES') {
        $questions += @([regex]::Split($txt2, '(?<=\?)\s+') | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '\?$' })
      }
    }
  }
  if ($cfg.prose) {
    # the author's name under a poem: "John G. Whittier.", "Mrs. Follen."
    $am = [regex]::Match($block, '\n[ \t]*((?:Mrs?\.\s+)?(?:[A-Z]\.\s*|[A-Z][a-z]+\s+){0,3}[A-Z][a-z]+\.)\s*$')
    if ($am.Success) { $author = $am.Groups[1].Value.TrimEnd('.'); $block = $block.Substring(0, $am.Index) }
    $block = $block -creplace '(?m)^(?=[ \t]*\d+\.\s+\S)', "`n"                                   # each numbered paragraph is its own paragraph
    foreach ($sp in ([regex]::Matches($block, '(?m)^([A-Z][a-z]+)\.\s+(?=["A-Z])') | Group-Object { $_.Groups[1].Value } | Where-Object { $_.Count -ge 2 -and $_.Name -notmatch '^(Mr|Mrs|Dr|St)$' })) {
      $block = $block -creplace "(?m)^$($sp.Name)\.\s+", "`n$($sp.Name): "                       # a play: "Rose. See how..." -> "Rose: See how..."
    }
  }
  # pull out illustrations (may span lines); script exercises become the cursive copy work
  $script = @(); $picText = ''
  foreach ($m in [regex]::Matches($block, '\[Illustration:([^\]]*)\]')) {
    $c = $m.Groups[1].Value
    if ($c -match '^\s*Script [Ee]xercise:') {
      $c = $c -replace '^\s*Script [Ee]xercise:', ''
      $paras = [regex]::Split($c.Trim(), '\n\s*\n')
      foreach ($pp in $paras) {
        if (IsPoem $pp) { $script += ($pp -split '\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }   # poem: keep lines
        else { $script += Sentences $pp }
      }
    } elseif (-not $picText) { $picText = ($c -replace '\s+', ' ').Trim().TrimEnd('.') }
  }
  $block = [regex]::Replace($block, '\[Illustration:[^\]]*\]', "`n`n@@PIC@@`n`n")
  $paras = [regex]::Split($block.Trim(), '\n\s*\n') | ForEach-Object { $_.TrimEnd() } | Where-Object { $_.Trim() }
  $words = @(); $letters = @(); $lines = @(); $inText = $false; $first = $true; $title = ''; $about = ''
  foreach ($p0 in $paras) {
    if ($p0.Trim() -eq '@@PIC@@') { if ($words.Count -or $letters.Count) { $inText = $true }; continue }
    $p = $p0 -replace '\s*\([^)]*\)', ''
    $isVocab = -not $cfg.prose -and -not $inText -and (($p -replace '\b(Mr|Mrs|Dr|St)\.', '$1') -notmatch '[.!?;,"]') -and ($first -or $p -match '\S\s{2,}\S' -or $p -match '^\S+$')
    # phrase rows like "a rat    a cat" come after the picture, not before it
    if ($isVocab -and $words.Count -and $p -match '^\s*(a|the|an)\s\w+\s{3,}') { $isVocab = $false }
    if ($isVocab) {
      foreach ($ln in ($p0 -split '\n')) {
        foreach ($t in (VocabTokens $ln)) {
          if (-not $t) { continue }
          $isSound = ($sounds -contains $t) -or ($t.Length -eq 1 -and -not ($first -and ($t -ceq 'a' -or $t -cmatch '^[A-Z]$')) -and $t -cne 'I')
          if ($isSound) { if ($letters -notcontains $t) { $letters += $t } } elseif ($words -notcontains $t) { $words += $t }
        }
        $first = $false
      }
      continue
    }
    $inText = $true
    # a note about the author before the selection ("By Celia Thaxter, who was born...", "Hester Lynch Thrale. 1739--1821, ...")
    if ($cfg.headRx -and -not $lines.Count -and -not $about -and $p -match '\bborn\b|\d{4}\s*-{1,2}\s*\d{2,4}') {
      $ta = if ($tocAuthors.ContainsKey([int]$n)) { ($tocAuthors[[int]$n] -split '\s+')[-1] } else { '' }
      if ($p -match '^\s*By\s' -or ($ta -and $p -match "\b$([regex]::Escape($ta))\b")) { $about = ($p -replace '\s+', ' ').Trim(); continue }
    }
    if ($p.Trim() -cmatch '^[A-Z][A-Za-z'' ]{2,40}$' -and -not $lines.Count -and $p.Trim() -cmatch '^(?:(?:[A-Z][a-z'']*|a|an|and|the|of|in|on|at|to|for|with|by)\s?)+$' -and $p.Trim() -match '\s') { $title = $p.Trim(); continue }
    if ($p.Trim() -cmatch '^[A-Z][A-Z .,''\u2019!?-]+$' -and ($cfg.prose -or $p -notmatch '\?')) { $title = (Get-Culture).TextInfo.ToTitleCase($p.Trim().TrimEnd('.').ToLower()); continue }   # story title: THE WHITE KITTEN.
    if (IsPoem $p) { $lines += ($p -split '\n' | ForEach-Object { ($_.Trim()) -replace '^\d+\.\s+', '' } | Where-Object { $_ }) }          # poem
    elseif ($p -notmatch '[.!?;,"]' -and $p -match '\S\s{3,}\S') { $lines += ($p.Trim() -split '\s{3,}') }                 # phrase row
    else { $lines += Sentences $p }
  }
  if (-not $lines.Count) { $lines = $script }
  $pic = ''; foreach ($key in $pics.Keys) { if ($picText -match "(?i)$key") { $pic = $pics[$key]; break } }
  if ($cfg.pics.ContainsKey([int]$n)) { $pic = $cfg.pics[[int]$n] }
  if (-not $pic -and $cfg.prose) { foreach ($key in $pics.Keys) { if ("$title $(($lines | Select-Object -First 3) -join ' ')" -match "(?i)\b$key(?:s|es)?\b") { $pic = $pics[$key]; break } } }   # no picture notes: guess from the title
  if (-not $pic) { $pic = if ($review -or $cfg.prose) { '๐Ÿ“–' } else { '๐Ÿ–ผ๏ธ' } }
  $o = [ordered]@{ n = $n; pic = $pic; picText = $(if ($picText) { $picText } elseif ($review) { 'Review' } else { '' }); words = @($words); letters = @($letters); lines = @($lines) }
  if ($review) { $o.review = $true }
  if ($headTitle) { $title = $headTitle }   # the heading's title wins over part titles inside the lesson ("IV. WINTER.")
  if ($defs.Count) {
    # the book's own new words: shown in lowercase unless the lesson only ever writes them with a capital (names)
    $alltext = $lines -join ' '
    $o.defs = [ordered]@{}
    foreach ($dd in $defs) {
      $w = $dd[0]; if ($alltext -cmatch "\b$([regex]::Escape($w.ToLower()))\b" -or -not ($alltext -cmatch "\b$([regex]::Escape($w))\b")) { $w = $w.ToLower() }
      if (-not $o.defs.Contains($w)) { $o.defs[$w] = $dd[1] }
    }
    $o.words = @($o.defs.Keys | Select-Object -First 12)
  }
  if ($questions.Count) { $o.questions = @($questions) }
  if ($title) { $o.title = $title }
  if ($cfg.headRx -and $tocAuthors.ContainsKey([int]$n)) { $author = $tocAuthors[[int]$n] }   # the contents page knows best
  if ($author -match 'Reader|EXAMPLES') { $author = '' }
  if ($author) { $o.author = $author }
  if ($about) { $o.about = $about }
  if ($script.Count) { $o.script = @($script) }
  if (-not $lines.Count) { Write-Host "Lesson $n has no text in Gutenberg's copy ($title), skipped" -ForegroundColor Yellow; continue }
  $lessons += $o
}
if ($cfg.prose) {
  # New words = words not met in the earlier books or earlier lessons, in reading order, up to 10 per lesson
  $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
  $wordRx = "[A-Za-z]+(?:'[A-Za-z]+)?"
  foreach ($prev in $cfg.after) {
    $pj = Get-Content "$root\public\books\$prev.json" -Raw -Encoding UTF8 | ConvertFrom-Json
    foreach ($PL in $pj.lessons) { foreach ($s in @($PL.lines) + @($PL.words) + @($PL.script)) { foreach ($m in [regex]::Matches("$s", $wordRx)) { [void]$seen.Add($m.Value) } } }
  }
  foreach ($o in $lessons) {
    $text = (@($o.lines) + @($o.script)) -join ' '
    $new = @()
    foreach ($m in [regex]::Matches($text, $wordRx)) {
      $w = $m.Value
      if ($w.Length -lt 2 -or $w -match "^n'" -or -not $seen.Add($w)) { continue }
      # names keep their capital (capitalized in the middle of a line); other words are shown in lowercase
      if (-not ($text -cmatch "(?:[a-z]|\b(?:Mr|Mrs|Miss|Dr)\.)\s+$([regex]::Escape($w))\b") -or ($text -cmatch "\b$([regex]::Escape($w.ToLower()))\b")) { $w = $w.ToLower() }
      $new += $w
    }
    if (-not @($o.words).Count) { $o.words = @($new | Select-Object -First 10) }   # the book's own word list wins when it has one
  }
}
# Last clean-up of every line: printed page headers that ended up inside a sentence ("... ECLECTIC SERIES. ..."),
# footnote/section marks (#, *, [ ]), and scanned digits ("l0" = 10, "I1" = 11)
function CleanLine($s) {
  $s = $s -creplace '\d*\s*\b(?:ECLECTIC (?:SERIES|READER)|(?:FIRST|SECOND|THIRD|FOURTH|FIFTH|SIXTH) READER)\b[.,]?\s*\d*', ' ' -replace '[#*\[\]]', ''
  $s = $s -creplace '\bI1\b', 'I' -creplace '(?<=\d)l|l(?=\d)', '1' -creplace '^\d+\s+(?=[a-z])', '' -creplace '(?<=[a-z]{3})\d\b', ''   # footnote number on a word ("orthography1"); "I1 will" = I; "l0" = 10; a page number before the text
  ($s -replace '\s{2,}', ' ').Trim()
}
foreach ($o in $lessons) {
  $o.lines = @($o.lines | ForEach-Object { CleanLine $_ } | Where-Object { $_ -match '[A-Za-z]' })
  if ($o.script) { $o.script = @($o.script | ForEach-Object { CleanLine $_ } | Where-Object { $_ -match '[A-Za-z]' }) }
}
$out = [ordered]@{ title = $cfg.title; sub = $cfg.sub; color = $cfg.color; source = "Project Gutenberg #$($cfg.gutenberg) (public domain)"; lessons = $lessons }
$json = $out | ConvertTo-Json -Depth 6
New-Item -ItemType Directory -Force "$root\public\books" | Out-Null
[IO.File]::WriteAllText("$root\public\books\$Book.json", $json, (New-Object Text.UTF8Encoding $false))
"$($lessons.Count) lessons"
$lessons | ForEach-Object { "{0,2} {1} w[{2}] l[{3}] lines={4} script={5} | {6}" -f $_.n, $_.pic, ($_.words -join ' '), ($_.letters -join ' '), $_.lines.Count, $(if ($_.script) { $_.script.Count } else { 0 }), $_.lines[0] }
EOF

make-hershey.ps1 (PC): downloads the Hershey fonts and writes hershey.js.

cat > /root/abcbooks-rebuild/make-hershey.ps1 <<'EOF'
# Downloads the single-stroke Hershey fonts (Sans 1-stroke for print, Script 1-stroke for cursive)
# and writes public/hershey.js (glyph -> [advance width, SVG path]) for the tracing engine.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-hershey.ps1
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$src = "$root\source"; New-Item -ItemType Directory -Force $src | Out-Null
$cdn = 'https://cdn.jsdelivr.net/npm/hersheytext@1.1.0'
foreach ($f in 'HersheySans1', 'HersheyScript1') {
  if (-not (Test-Path "$src\$f.svg")) { Invoke-WebRequest "$cdn/svg_fonts/$f.svg" -OutFile "$src\$f.svg" -UseBasicParsing }
}
Invoke-WebRequest "$cdn/LICENSE" -OutFile "$root\public\hershey-LICENSE.txt" -UseBasicParsing
$sb = New-Object Text.StringBuilder
[void]$sb.Append("// Hershey fonts (Sans 1-stroke, Script 1-stroke). See hershey-LICENSE.txt.`n// The Hershey Fonts were originally created by Dr. A. V. Hershey while working at the U. S. National Bureau of Standards.`nwindow.HERSHEY={")
foreach ($f in @(@('print', 'HersheySans1'), @('script', 'HersheyScript1'))) {
  [xml]$x = Get-Content "$src\$($f[1]).svg" -Raw
  $ns = New-Object Xml.XmlNamespaceManager($x.NameTable); $ns.AddNamespace('s', 'http://www.w3.org/2000/svg')
  [void]$sb.Append("$($f[0]):{")
  foreach ($g in $x.SelectNodes('//s:glyph', $ns)) {
    $u = $g.unicode; if ($u.Length -ne 1 -or [int][char]$u -gt 126) { continue }
    [void]$sb.Append(($u | ConvertTo-Json) + ':[' + $g.'horiz-adv-x' + ',"' + $g.d + '"],')
  }
  [void]$sb.Append('},')
}
[void]$sb.Append("};`n")
[IO.File]::WriteAllText("$root\public\hershey.js", $sb.ToString())
"wrote $root\public\hershey.js"
EOF

make-icons.ps1 (PC): draws the app icons.

cat > /root/abcbooks-rebuild/make-icons.ps1 <<'EOF'
# Draws the ABC Books app icons (public/icon-192.png, icon-512.png) with Windows' System.Drawing.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-icons.ps1
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Add-Type -AssemblyName System.Drawing
foreach ($s in 192, 512) {
  $b = New-Object Drawing.Bitmap $s, $s; $g = [Drawing.Graphics]::FromImage($b)
  $g.SmoothingMode = 'AntiAlias'; $g.TextRenderingHint = 'AntiAlias'
  $g.Clear([Drawing.Color]::FromArgb(35, 64, 47))
  $pen = New-Object Drawing.Pen ([Drawing.Color]::FromArgb(181, 86, 44)), ($s * 0.05)
  $g.DrawRectangle($pen, $s * 0.03, $s * 0.03, $s * 0.94, $s * 0.94)
  $sf = New-Object Drawing.StringFormat; $sf.Alignment = 'Center'; $sf.LineAlignment = 'Center'
  $f = New-Object Drawing.Font 'Georgia', ($s * 0.30), ([Drawing.FontStyle]::Bold), ([Drawing.GraphicsUnit]::Pixel)
  $g.DrawString('ABC', $f, [Drawing.Brushes]::WhiteSmoke, (New-Object Drawing.RectangleF 0, ($s * -0.06), $s, $s), $sf)
  $f2 = New-Object Drawing.Font 'Georgia', ($s * 0.11), ([Drawing.FontStyle]::Italic), ([Drawing.GraphicsUnit]::Pixel)
  $g.DrawString('books', $f2, (New-Object Drawing.SolidBrush ([Drawing.Color]::FromArgb(255, 233, 168))), (New-Object Drawing.RectangleF 0, ($s * 0.22), $s, $s), $sf)
  $b.Save("$root\public\icon-$s.png", [Drawing.Imaging.ImageFormat]::Png); $g.Dispose(); $b.Dispose()
}
"wrote icons"
EOF

eloq-render.ps1 (PC): talks to the Eloquence engine directly and writes a WAV file plus the start time of every word. It must run in 32-bit PowerShell because eci.dll is 32-bit.

cat > /root/abcbooks-rebuild/eloq-render.ps1 <<'EOF'
# Renders text with the PC's own ETI-Eloquence (C:\Windows\Speech\Eloq\eci.dll, 32-bit) to WAV
# plus word start times (ms). Must run in 32-bit PowerShell:
#   C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File eloq-render.ps1 -Jobs jobs.json
# jobs.json: [{ "text": "A cat and a rat.", "out": "C:\\...\\l1-5.wav" }, ...]  -> writes out + out.json (word times)
param(
  [Parameter(Mandatory)] [string]$Jobs,
  [int]$Voice = 1,        # 1 = Reed (Eloquence default male)
  [int]$Speed = 50,       # eciSpeed 0-250
  [int]$Pitch = -1,       # eciPitchBaseline 0-100, -1 = voice default
  [int]$Rate = 1          # eciSampleRate: 0=8000, 1=11025, 2=22050
)
if ([IntPtr]::Size -ne 4) { throw 'Run this with 32-bit PowerShell (SysWOW64).' }
$env:PATH = 'C:\Windows\Speech\Eloq;' + $env:PATH
Add-Type -TypeDefinition @'
using System; using System.IO; using System.Collections.Generic; using System.Runtime.InteropServices;
public static class Eloq {
  public delegate int Cb(IntPtr h, int msg, int lParam, IntPtr data);
  [DllImport("eci.dll")] static extern IntPtr eciNew();
  [DllImport("eci.dll")] static extern IntPtr eciDelete(IntPtr h);
  [DllImport("eci.dll")] static extern int eciSetParam(IntPtr h, int p, int v);
  [DllImport("eci.dll")] static extern int eciCopyVoice(IntPtr h, int from, int to);
  [DllImport("eci.dll")] static extern int eciGetVoiceParam(IntPtr h, int voice, int p);
  [DllImport("eci.dll")] static extern int eciSetVoiceParam(IntPtr h, int voice, int p, int v);
  [DllImport("eci.dll", CharSet = CharSet.Ansi)] static extern int eciAddText(IntPtr h, string t);
  [DllImport("eci.dll")] static extern int eciInsertIndex(IntPtr h, int i);
  [DllImport("eci.dll")] static extern int eciSynthesize(IntPtr h);
  [DllImport("eci.dll")] static extern int eciSynchronize(IntPtr h);
  [DllImport("eci.dll")] static extern void eciRegisterCallback(IntPtr h, Cb cb, IntPtr data);
  [DllImport("eci.dll")] static extern int eciSetOutputBuffer(IntPtr h, int size, IntPtr buf);
  const int BUF = 256;  // small, so word marks line up with the audio (marks arrive before the buffer is flushed)
  static IntPtr buf; static List<short> pcm; static List<int> marks; static Cb keep;
  static int OnMsg(IntPtr h, int msg, int lp, IntPtr d) {
    if (msg == 0) { var s = new short[lp]; Marshal.Copy(buf, s, 0, lp); pcm.AddRange(s); }   // eciWaveformBuffer
    else if (msg == 2) { while (marks.Count <= lp) marks.Add(-1); marks[lp] = pcm.Count; }     // eciIndexReply
    return 1;                                                                                    // eciDataProcessed
  }
  public static int SampleRate(int r) { return r == 0 ? 8000 : r == 2 ? 22050 : 11025; }
  // returns word start times in ms; writes a 16-bit mono WAV
  public static int[] Render(string text, string wav, int voice, int speed, int pitch, int rate) {
    IntPtr h = eciNew(); if (h == IntPtr.Zero) throw new Exception("eciNew failed (eci.dll / enu.syn not found?)");
    buf = Marshal.AllocHGlobal(BUF * 2); pcm = new List<short>(); marks = new List<int>(); keep = OnMsg;
    eciRegisterCallback(h, keep, IntPtr.Zero); eciSetOutputBuffer(h, BUF, buf);
    eciSetParam(h, 5, rate);           // eciSampleRate
    eciSetParam(h, 1, 1);              // eciInputType: annotated text
    eciCopyVoice(h, voice, 0);
    eciSetVoiceParam(h, 0, 6, speed);  // eciSpeed
    if (pitch >= 0) eciSetVoiceParam(h, 0, 2, pitch); // eciPitchBaseline
    string[] words = text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
    for (int i = 0; i < words.Length; i++) { eciInsertIndex(h, i); eciAddText(h, words[i] + " "); }
    eciInsertIndex(h, words.Length);
    eciSynthesize(h); eciSynchronize(h); eciDelete(h); Marshal.FreeHGlobal(buf);
    int sr = SampleRate(rate);
    using (var w = new BinaryWriter(File.Create(wav))) {
      int n = pcm.Count * 2;
      w.Write(new[] { 'R','I','F','F' }); w.Write(36 + n); w.Write(new[] { 'W','A','V','E','f','m','t',' ' });
      w.Write(16); w.Write((short)1); w.Write((short)1); w.Write(sr); w.Write(sr * 2); w.Write((short)2); w.Write((short)16);
      w.Write(new[] { 'd','a','t','a' }); w.Write(n); foreach (var s in pcm) w.Write(s);
    }
    var t = new int[words.Length + 1];
    for (int i = 0; i <= words.Length; i++) t[i] = i < marks.Count && marks[i] >= 0 ? (int)(marks[i] * 1000L / sr) : -1;
    return t;
  }
}
'@
$list = Get-Content $Jobs -Raw | ConvertFrom-Json
foreach ($j in $list) {
  $t = [Eloq]::Render($j.text, $j.out, $Voice, $Speed, $Pitch, $Rate)
  ConvertTo-Json @{ text = $j.text; words = $t } -Compress | Set-Content -Encoding utf8 ($j.out + '.json')
  "$($j.out)  words(ms): $($t -join ',')"
}
EOF

make-audio.ps1 (PC): collects every text the app can say (lessons, words, letters, instructions) and records it at both speeds. Only new text is recorded.

cat > /root/abcbooks-rebuild/make-audio.ps1 <<'EOF'
# Records every spoken text of a book with the PC's ETI-Eloquence (Reed) at each speed, then
# builds public/audio/<book>.json (text -> clip id + word start times). WAVs land in tools/out/<book>/s<speed>/.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-audio.ps1 [-Book primer1881]
# Then: ABCBooks\tools\push-audio.ps1 converts to MP3 on CT 306.
param([string]$Book = 'primer1881', [int[]]$Speeds = @(60, 75))
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$data = Get-Content "$root\public\books\$Book.json" -Raw -Encoding UTF8 | ConvertFrom-Json
$kids = 'Layla', 'Lavon', 'Lilly'

# key = what the app asks for; value = what Eloquence should say
$say = New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal)   # case matters: "a cat" and "A cat" are different lines
function Add($key, $spoken) { if ($key -and -not $say.Contains($key)) { $say[$key] = $(if ($spoken) { $spoken } else { $key }) } }
# letter cards say the phonics sound (public/phonics.json, Eloquence phonetic code), not the letter name
$phonics = Get-Content "$root\public\phonics.json" -Raw -Encoding UTF8 | ConvertFrom-Json
function LetterName($t) {
  $p = $phonics.PSObject.Properties[$t.ToLower()]
  if ($p) { return ('`[.1' + $p.Value[0] + ']') }   # single quotes: keep Eloquence's backquote
  (($t.ToCharArray() | ForEach-Object { "$_".ToUpper() }) -join ', ') + '.'
}
foreach ($p in $phonics.PSObject.Properties) { if ($p.Name -notlike '_*') { Add "letter:$($p.Name)" (LetterName $p.Name); Add "example:$($p.Name)" $p.Value[1] } }   # for phonics.html
foreach ($p in 'Tap each card to hear it.', 'Good! Now play the game.', 'Yes!', 'Good try!', 'Listen to the red words, then try again.',
               'I did not hear you. Try again!', 'Start at the green dot!', 'Follow the arrow all the way.', 'Great job!', 'Write it on the board first!') { Add $p }
foreach ($k in $kids) { Add "Great job, $k!" }
# spoken instructions shared with the app (public/prompts.json)
$prompts = Get-Content "$root\public\prompts.json" -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($pr in $prompts.PSObject.Properties) {
  if ($pr.Value -like '*{kid}*') { foreach ($k in $kids) { Add ($pr.Value -replace '\{kid\}', $k) } } else { Add $pr.Value }
}
foreach ($L in $data.lessons) { Add "lesson:$($L.n)" $(if ($L.review) { "Review. Lesson $($L.n)." } else { "Lesson $($L.n)." }) }
foreach ($L in $data.lessons) {
  foreach ($t in $L.letters) { Add "letter:$t" (LetterName $t) }
  foreach ($w in $L.words) { Add $w; Add "Find $w" "Find the word, $w." }
  $copy = @()
  if ($L.letters.Count) { $copy += ($L.letters -join ' '); Add ($L.letters -join ' ') ((($L.letters | ForEach-Object { LetterName $_ }) -join ', ')) }
  if ($L.words.Count) { $copy += ($L.words -join ' ') }
  $wl = @($L.words); for ($i = 0; $i -lt $wl.Count; $i += 6) { $copy += ($wl[$i..([Math]::Min($i + 5, $wl.Count - 1))] -join ' ') }   # tracing pages, 6 words each (app.js WORDS_PER_PAGE)
  foreach ($ln in $L.lines) {
    Add $ln
    foreach ($w in ($ln -split '\s+')) { $c = $w -replace '[^A-Za-z'']', ''; if ($c) { Add $c } }   # single words for tap-to-hear + retries
    if (($ln -split ' ').Count -gt 2) { Add "Write: $ln" }
  }
  foreach ($ln in $L.script) { Add $ln }
  if ($L.defs) { foreach ($d in $L.defs.PSObject.Properties) { Add "def:$($d.Name)" "$($d.Name). $($d.Value)." } }   # word + its meaning (Fourth Reader)
  foreach ($q in @($L.questions)) { if ($q) { Add $q } }
  if ($data.spell) {   # Spell & Read: each word spelled with letter names, then said: "I, S. is."
    foreach ($m in [regex]::Matches(($L.lines -join ' '), "[A-Za-z]+(?:'[A-Za-z]+)?")) {
      $w = if ($m.Value -ceq 'I') { 'I' } else { $m.Value.ToLower() }
      Add "spell:$w" ((($w.ToCharArray() | Where-Object { $_ -ne [char]39 } | ForEach-Object { "$_".ToUpper() }) -join ', ') + ". $w.")
    }
  }                                                    # Talk about it questions
  foreach ($c in $copy) { Add $c }
}
"$($say.Count) clips per speed"

$sha = [Security.Cryptography.SHA1]::Create()
function Id($s) { (($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($s)) | Select-Object -First 6 | ForEach-Object { $_.ToString('x2') }) -join '') }
$items = New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal)
$ps32 = 'C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe'
foreach ($sp in $Speeds) {
  $dir = "$root\tools\out\$Book\s$sp"; New-Item -ItemType Directory -Force $dir | Out-Null
  $jobs = @()
  foreach ($k in $say.Keys) {
    $id = Id $say[$k]
    if (-not $items.Contains($k)) { $items[$k] = [ordered]@{ id = $id } }
    if (-not (Test-Path "$dir\$id.wav.json")) { $jobs += [ordered]@{ text = $say[$k]; out = "$dir\$id.wav" } }
  }
  "speed $sp : $($jobs.Count) to record"
  if ($jobs.Count) {
    $jf = "$dir\jobs.json"; ConvertTo-Json @($jobs) | Set-Content -Encoding utf8 $jf
    & $ps32 -NoProfile -ExecutionPolicy Bypass -File "$root\tools\eloq-render.ps1" -Jobs $jf -Speed $sp | Out-Null
  }
  foreach ($k in $say.Keys) {
    $j = Get-Content "$dir\$($items[$k].id).wav.json" -Raw | ConvertFrom-Json
    $items[$k]["t$sp"] = @($j.words)
  }
}
$out = [ordered]@{ speeds = $Speeds; items = $items }
New-Item -ItemType Directory -Force "$root\public\audio" | Out-Null
[IO.File]::WriteAllText("$root\public\audio\$Book.json", ($out | ConvertTo-Json -Depth 5 -Compress), (New-Object Text.UTF8Encoding $false))
"manifest: $root\public\audio\$Book.json"
EOF

push-audio.ps1 (PC): sends the recordings to CT 306 and converts them to MP3 there.

cat > /root/abcbooks-rebuild/push-audio.ps1 <<'EOF'
# Sends recordings (tools/out/<book>/s<speed>/*.wav) that CT 306 doesn't have as MP3 yet, in batches,
# and converts each batch there (CT 306 has a small disk, so never more than one batch of WAVs at a time).
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\push-audio.ps1 [-Book fourth1879]   (default: every book)
param([string]$Book = '*')
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$batch = 1500
ssh -o BatchMode=yes proxmox "mkdir -p /root/abcbooks"
scp -o BatchMode=yes "$root\proxmox\convert-audio.sh" proxmox:/root/abcbooks/convert-audio.sh
ssh -o BatchMode=yes proxmox "pct push 306 /root/abcbooks/convert-audio.sh /root/convert-audio.sh"
foreach ($dir in Get-ChildItem "$root\tools\out\$Book\s*" -Directory) {
  $rel = "$($dir.Parent.Name)/$($dir.Name)"
  $have = @(ssh -o BatchMode=yes proxmox "pct exec 306 -- ls /var/www/abcbooks/audio/$rel" 2>$null) | ForEach-Object { $_ -replace '\.mp3$', '' }
  $haveSet = New-Object 'System.Collections.Generic.HashSet[string]'
  foreach ($h in $have) { [void]$haveSet.Add($h) }
  $todo = @(Get-ChildItem $dir.FullName -Filter *.wav | Where-Object { -not $haveSet.Contains($_.BaseName) })
  "$rel : $($todo.Count) to send"
  for ($i = 0; $i -lt $todo.Count; $i += $batch) {
    $part = $todo[$i..([Math]::Min($i + $batch, $todo.Count) - 1)]
    $list = Join-Path $env:TEMP 'abcaudio-list.txt'
    [IO.File]::WriteAllLines($list, [string[]]($part | ForEach-Object { "$rel/$($_.Name)" }))
    $tar = Join-Path $env:TEMP 'abcaudio.tar'
    tar -cf $tar -C "$root\tools\out" -T $list
    scp -o BatchMode=yes $tar proxmox:/root/abcbooks/audio.tar
    ssh -o BatchMode=yes proxmox "pct push 306 /root/abcbooks/audio.tar /tmp/abcaudio.tar"
    ssh -o BatchMode=yes proxmox "unlink /root/abcbooks/audio.tar"
    ssh -o BatchMode=yes proxmox "pct exec 306 -- mkdir -p /tmp/abcaudio"
    ssh -o BatchMode=yes proxmox "pct exec 306 -- tar -xf /tmp/abcaudio.tar -C /tmp/abcaudio --no-same-owner"
    ssh -o BatchMode=yes proxmox "pct exec 306 -- unlink /tmp/abcaudio.tar"
    ssh -o BatchMode=yes proxmox "pct exec 306 -- bash /root/convert-audio.sh"
    Remove-Item $tar, $list
  }
}
ssh -o BatchMode=yes proxmox "pct exec 306 -- df -h /"
EOF

check-audio.ps1 (PC): checks that every recording each book needs is on the server.

cat > /root/abcbooks-rebuild/check-audio.ps1 <<'EOF'
# Checks that every clip a book's voice list (public/audio/<book>.json) points to exists as an MP3 on CT 306.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\check-audio.ps1            (all books)
#        powershell -ExecutionPolicy Bypass -File ABCBooks\tools\check-audio.ps1 -Book third1879
param([string]$Book = '*')
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$bad = 0
foreach ($mf in Get-ChildItem "$root\public\audio\$Book.json" | Where-Object { $_.Name -notlike '*.soundout.json' }) {
  $b = $mf.BaseName
  $raw = [IO.File]::ReadAllText($mf.FullName)
  $ids = New-Object 'System.Collections.Generic.HashSet[string]'
  foreach ($m in [regex]::Matches($raw, '"id":"(\w+)"')) { [void]$ids.Add($m.Groups[1].Value) }
  $speeds = ([regex]::Match($raw, '"speeds":\[([\d,]+)\]').Groups[1].Value) -split ','
  foreach ($sp in $speeds) {
    $have = New-Object 'System.Collections.Generic.HashSet[string]'
    foreach ($f in @(ssh -o BatchMode=yes proxmox "pct exec 306 -- ls /var/www/abcbooks/audio/$b/s$sp" 2>$null)) { [void]$have.Add(($f -replace '\.mp3$', '')) }
    $missing = @($ids | Where-Object { -not $have.Contains($_) })
    $bad += $missing.Count
    "{0} s{1}: {2} clips needed, {3} missing" -f $b, $sp, $ids.Count, $missing.Count
  }
}
# sound-it-out clips (shared by all books) in audio/so/s60/
$soIds = New-Object 'System.Collections.Generic.HashSet[string]'
foreach ($mf in Get-ChildItem "$root\public\audio\$Book.soundout.json") { foreach ($m in [regex]::Matches([IO.File]::ReadAllText($mf.FullName), '"id":"(\w+)"')) { [void]$soIds.Add($m.Groups[1].Value) } }
if ($soIds.Count) {
  $have = New-Object 'System.Collections.Generic.HashSet[string]'
  foreach ($f2 in @(ssh -o BatchMode=yes proxmox "pct exec 306 -- ls /var/www/abcbooks/audio/so/s60" 2>$null)) { [void]$have.Add(($f2 -replace '\.mp3$', '')) }
  $missing = @($soIds | Where-Object { -not $have.Contains($_) }); $bad += $missing.Count
  "sound it out: {0} clips needed, {1} missing" -f $soIds.Count, $missing.Count
}
if ($bad) { "MISSING $bad clips: run tools\push-audio.ps1"; exit 1 } else { 'OK: every clip is on the server' }
EOF

convert-audio.sh (CT 306): run by push-audio.ps1; WAV to MP3 with lame.

cat > /root/abcbooks-rebuild/convert-audio.sh <<'EOF'
#!/bin/bash
# Runs INSIDE CT 306: converts the recorded WAV clips in /tmp/abcaudio/<book>/s<speed>/ to MP3
# under /var/www/abcbooks/audio/<book>/s<speed>/ (skips clips already converted).
set -e
SRC=/tmp/abcaudio
DST=/var/www/abcbooks/audio
n=0
for d in $SRC/*/s*/; do
  rel=${d#$SRC/}
  mkdir -p "$DST/$rel"
  for w in "$d"*.wav; do
    m="$DST/$rel$(basename "$w" .wav).mp3"
    if [ ! -f "$m" ]; then lame --quiet -V 6 "$w" "$m"; n=$((n+1)); fi
  done
done
chmod -R u=rwX,go=rX $DST
rm -r $SRC
echo "converted $n clips"
du -sh $DST/*/*
EOF

arrange-rebuild.ps1 (PC): sorts this flat folder into the project layout on the PC.

cat > /root/abcbooks-rebuild/arrange-rebuild.ps1 <<'EOF'
# Rebuild helper: sorts the flat folder of files copied from the blog post into the ABCBooks layout.
# Usage: powershell -ExecutionPolicy Bypass -File <flat folder>\arrange-rebuild.ps1 -From <flat folder> -To C:\ABCBooks
param([Parameter(Mandatory)] [string]$From, [Parameter(Mandatory)] [string]$To)
foreach ($d in 'public\books', 'public\audio', 'tools', 'proxmox', 'source') { New-Item -ItemType Directory -Force "$To\$d" | Out-Null }
Get-ChildItem $From -File | ForEach-Object {
  $dest = switch -Regex ($_.Name) {
    '^(deploy\.ps1)$' { $To; break }
    '\.ps1$' { "$To\tools"; break }
    '\.sh$' { "$To\proxmox"; break }
    '^spell1867\.txt$' { "$To\source"; break }   # hand-corrected 1867 book text
    default { "$To\public" }
  }
  Copy-Item $_.FullName $dest -Force
}
# the scripts use emoji/curly quotes: PowerShell 5.1 needs a UTF-8 BOM to read them right
Get-ChildItem "$To\tools\*.ps1", "$To\deploy.ps1" | ForEach-Object {
  $t = [IO.File]::ReadAllText($_.FullName, [Text.Encoding]::UTF8); [IO.File]::WriteAllText($_.FullName, $t, (New-Object Text.UTF8Encoding $true))
}
# .sh files must have Linux line endings
Get-ChildItem "$To\proxmox\*.sh" | ForEach-Object { $t = [IO.File]::ReadAllText($_.FullName) -replace "`r`n", "`n"; [IO.File]::WriteAllText($_.FullName, $t) }
Get-ChildItem $To -Recurse -File | Select-Object @{ n = 'File'; e = { $_.FullName.Substring($To.Length + 1) } }, Length
EOF

3. Install the MP3 encoder in CT 306

Proxmox host shell:

pct exec 306 -- apt-get update
pct exec 306 -- apt-get install -y lame

4. Copy the files to the PC

PC (PowerShell). This copies the folder from the host to C:\ABCBooks-files:

scp -o BatchMode=yes -r proxmox:/root/abcbooks-rebuild C:\ABCBooks-files

Sort them into the project layout at C:\ABCBooks:

powershell -ExecutionPolicy Bypass -File C:\ABCBooks-files\arrange-rebuild.ps1 -From C:\ABCBooks-files -To C:\ABCBooks

5. Build the book, the letter shapes and the icons

PC (PowerShell). Each takes a few seconds; the first prints "52 lessons".

powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-book.ps1 -Book primer1881
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-hershey.ps1
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-icons.ps1

6. Record the voice

PC (PowerShell). This records every clip at speeds 60 and 75 with Eloquence (about 3,150 clips; about 2 minutes here) and writes public\audio\primer1881.json:

powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-audio.ps1

Send the recordings to CT 306 and convert them to MP3, in batches of 1,500 (it prints "converted N clips" and the folder sizes, about 16 MB and 14 MB):

powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\push-audio.ps1

Check that nothing is missing (it ends with "OK: every clip is on the server"):

powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\check-audio.ps1

7. Put the app on the server

PC (PowerShell):

powershell -ExecutionPolicy Bypass -File C:\ABCBooks\deploy.ps1

It ends with a listing of /var/www/abcbooks/ in CT 306. Open https://games.503mail.com/abcbooks/ to check.

8. Add the tile to the games menu

In the games menu file (index.html of the games site, see the Nibbles post), add this line inside <div class="games">, then push the menu the usual way:

    <a class="game" href="/abcbooks/"><img src="/abcbooks/icon-192.png" alt="">ABC BOOKS</a>

9. Kids' Fire tablets

Nothing to approve: games.503mail.com is already an approved website in each kid's Amazon Kids profile, and ABC Books is a folder on it. After an update, pull down on the page (or reload) to get the new version.

10. Installing it on phones, tablets and computers

  • iPhone / iPad: open the address in Safari, tap Share, then Add to Home Screen.
  • Android: in Chrome, open the โ‹ฎ menu, then Add to Home screen or Install app.
  • Windows, Mac, Linux: Chrome and Edge show an install icon at the right end of the address bar. Firefox runs it as a normal page.

Troubleshooting: where the logs are

The web server in CT 306

  • Where: Caddy's journal in CT 306 (abc-web).
  • Open: Proxmox host shell.
pct exec 306 -- journalctl -u caddy -n 50 --no-pager
  • Look for: serving initial configuration after a restart is healthy. Lines with "level":"error" are real problems. Caddy on CT 306 doesn't log every request, so a missing file shows up in the browser, not here.

Files on the server

  • Open: Proxmox host shell.
pct exec 306 -- ls -l /var/www/abcbooks /var/www/abcbooks/books /var/www/abcbooks/audio
pct exec 306 -- du -sh /var/www/abcbooks/audio/primer1881/s60 /var/www/abcbooks/audio/primer1881/s75
  • Look for: files owned by root and readable by everyone (-rw-r--r--). Two folders of about 15 MB and 13 MB of clips.

In the browser

  • Open: on a computer, Chrome or Edge โ†’ F12 โ†’ Console.
  • Look for: a red "Could not load the books" message on the page means books/primer1881.json or prompts.json failed to load. No voice but no error means audio/primer1881.json is missing (the app then falls back to the device voice, and the Amazon Kids browser has none).

The Tablet check page

Open https://games.503mail.com/abcbooks/check.html on the device (the Tablet check link at the bottom of the home screen). Tap each button.

  • On the kids' Fire tablets (Amazon Kids browser) expect: NO speechSynthesis (no built-in voice; the recordings cover this), listen error: not-allowed (the kids' browser app isn't allowed to use the microphone at all; the app switches to grown-up check by itself), drawing works, type=touch (the simple stylus counts as a finger, which is fine).
  • In Silk on an adult profile everything says YES once the microphone is allowed.

Quick health check

PC (PowerShell): the page, the book and the voice list should all print 200.

"/abcbooks/","/abcbooks/books/primer1881.json","/abcbooks/audio/primer1881.json" | % { (Invoke-WebRequest "https://games.503mail.com$_" -UseBasicParsing).StatusCode }

Notes

  • Gotcha, capital letters: PowerShell compares text without caring about capitals, so the first recording run treated "a cat" and "A cat" as the same line and 72 lines had no recording. make-audio.ps1 now uses a case-sensitive list. If a line is ever silent on the kids' tablets, re-run make-audio and push-audio: they only record what's missing.
  • Gotcha, word timings: the 22,050 Hz sample-rate setting of this Eloquence version is ignored (it still makes 11,025 Hz), so the WAV header must say 11,025 or clips play too fast and too high.
  • Gotcha, Safari audio: browsers ask for audio in pieces (HTTP 206), and Safari won't play a piece handed back whole by the offline cache. The app therefore downloads each clip whole with fetch and plays it from memory. Safari also only plays sound that starts from a tap, so the first tap anywhere unlocks the voice.
  • Gotcha, tracing on test robots: fake pen events (from test scripts) make setPointerCapture throw and return no coalesced points; trace.js handles both, which also protects odd tablets.
  • The Amazon Kids browser can't use the microphone. Its app (com.amazon.cloud9.kids) doesn't ask Android for microphone permission, so no setting can turn it on. Reading aloud there is grown-up checked. The plan is an adult "Reading" profile on the tablets with a small locked app that only opens the games site and can use the mic, plus a reading-check server on my own network.
  • Speed: Eloquence speed 60 reads "A cat and a rat." in about 0.8 seconds. Speed 75 is the "faster" button.
  • Changing the lessons or instructions: edit prompts.json (or re-run make-book.ps1 after changing it), then re-run make-audio.ps1, push-audio.ps1 and deploy.ps1, and bump SHELL in sw.js.
  • Next books: the First Eclectic Reader (its own post; the files above already include it), then Spell & Read from the 1867 Pictorial Primer.