The fifth book in ABC Books is McGuffey's Fourth Eclectic Reader, Revised Edition (1879), all 90 lessons. It's the gold book on the shelf at https://games.503mail.com/abcbooks/. This book brings two new things to the app: word meanings (tap a new word and hear what it means, straight from McGuffey's definitions) and a "Talk about it" step that asks the book's own questions. The app itself is rebuilt in the Primer post. Every block has a Copy button.
The book
- McGuffey's Fourth Eclectic Reader, Revised Edition, copyright 1879, from Project Gutenberg #14880 (public domain).
- 90 lessons of stories, poems and short histories with their authors: "Perseverance" (Charlotte Elizabeth), "Try, Try Again", "Why the Sea is Salt" (Mary Howitt), "Meddlesome Matty", Robinson Crusoe's house and dress (Daniel DeFoe), "The Wreck of the Hesperus" (Longfellow), "Hugh Idle and Mr. Toil" (Hawthorne), "The Best Capital" (Louisa M. Alcott), "The Inchcape Rock" (Southey) and more. Many lessons start with a short note about the author.
- Every lesson ends with DEFINITIONS (the hard words and what they mean) and most with EXERCISES (questions about the lesson).
- About 15,300 things to say per speed, recorded in ETI-Eloquence.
What's new in the app
- Word meanings: the New words step uses the book's own word list. Tapping a word card says the word and its meaning ("entangled: twisted in, disordered") and shows the meaning under the cards. Only books with definitions do this; the other books' cards still just say the word.
- Talk about it: after "Your turn to read", lessons with questions get a step that shows and reads one question at a time ("Why was John discouraged in his attempts to fly his kite?"). The kid answers out loud to a grown-up and taps Next question. There's no scoring; it's for talking.
- Authors: the lesson keeps its author, taken from the book's table of contents.
What the converter had to handle
make-book.ps1 got a mode for headings that carry the lesson title (headRx), used by the Fourth, Fifth and Sixth Readers:
- Headings look like "I. PERSEVERANCE. (25)": a Roman numeral, the title in capitals, and the page number. The title becomes the lesson title.
- A lesson's own numbered parts ("II. SUMMER.", "III. AUTUMN." inside "The Seasons") are not lessons. A heading counts only if it is the next lesson number (up to two may be missing) or has a page number and a numeral close to where it belongs.
- Misprinted numerals: "XVV." for XXV, a second "LXX." for LIX, a second "LXXVII." for LXXVIII. Those have page numbers, so they are taken as the next lesson; lessons are numbered in order.
- Splitting the text into lessons: .NET's
Regex.Splitalso returns the parts a pattern captures, which shifted every lesson by one; the text is now cut by each heading's position instead. - DEFINITIONS like "4. En-tan'gled, twisted in, disordered. 5. As-sist'-ance, help, aid." become word/meaning pairs (accent marks and syllable dashes removed). EXERCISES become the question list. NOTES are left out.
- Authors come from the table of contents ("87. The Best Capital Louisa M. Alcott 245"), only from the contents page itself (between "SELECTIONS IN PROSE AND POETRY." and "LIST OF ILLUSTRATIONS"), and never from a line that holds two entries.
- The first four books still come out exactly the same; that's checked after every converter change.
Add this book to ABC Books
You need the app from the Primer post and its C:\ABCBooks folder on the PC (with ETI-Eloquence installed). Copy the files below the same way as in the Primer post (the scp and arrange-rebuild.ps1 steps).
1. Files
Proxmox host shell:
mkdir -p /root/abcbooks-rebuild
make-book.ps1: the converter, with the Fourth, Fifth and Sixth Reader settings.
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-audio.ps1: also records each word with its meaning (def:entangled) and every question.
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
app.js: the app with word meanings and the Talk about it step.
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
prompts.json: the spoken instructions, including the new "Talk about it" one.
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
2. Make the lesson file
PC (PowerShell). It prints "90 lessons":
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-book.ps1 -Book fourth1879
3. Record it with Eloquence
PC (PowerShell). About 30,600 recordings (both speeds), about 25 minutes here:
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-audio.ps1 -Book fourth1879
Send them to CT 306 (abc-web), then check nothing is missing:
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\push-audio.ps1
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\check-audio.ps1 -Book fourth1879
4. Put it on the server
PC (PowerShell):
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\deploy.ps1
Open https://games.503mail.com/abcbooks/, pick a name, and the gold McGuffey's Fourth Eclectic Reader opens a map of 90 lessons.
Troubleshooting
The logs and the Tablet check page are the same as in the Primer post.
- A lesson has the wrong title or two lessons are stuck together: run
make-book.ps1 -Book fourth1879and read its list. Every lesson prints its number, new words, the number of lines and the first line; a number missing or repeated points at a heading the converter didn't recognise. Look the heading up insource\fourth.txt. - An author looks wrong: authors come from the contents page; check that line in
source\fourth.txt(lines starting "1." to "90." under "SELECTIONS IN PROSE AND POETRY."). - No "Talk about it" step: that lesson has no EXERCISES in the book (many lessons only have DEFINITIONS).
Notes
- Disk: these longer books need more room. CT 306 (abc-web) has 8 GB, and all 8 books use about 2.7 GB. (When ABC Books still lived on CT 125 (nibbles), that disk had to be grown from 2 GB to 12 GB first.)
- Not done: NOTES (author biographies at the end of some lessons) are not shown, and the pictures are still a guess from the title (mostly a book π).