The McGuffey Readers teach reading by sounds: every early lesson lists its new letters and letter pairs ("a e d n r t", "th", "sh", "oo"...). In ABC Books those letter cards now say the phonics sound, in the same ETI-Eloquence voice as the rest of the app, instead of the letter name. A "th" card says "thuh", not "T, H". This works in every book. Hear every sound at https://games.503mail.com/abcbooks/phonics.html. The app is rebuilt in the Primer post; this post covers the sounds. Every block has a Copy button.
What changed in the app
- New-word cards: tapping a letter card (orange) plays its sound. Word cards (white) still say the word.
- Tracing pages: the first tracing page of a lesson ("a e d n r t") is read as sounds too: "a, eh, duh, nuh, ruh, tuh".
- Phonics page: https://games.503mail.com/abcbooks/phonics.html lists all 40 sounds. Each has a button for the sound alone, and one for the sound and then an example word ("th... thin").
The sounds
Consonants are said with a short "uh" after them ("buh", "thuh"), which is how many teachers say them; that was the style picked after listening to three versions (sound only, with "uh", and sound then word). Vowels are the short vowel on its own.
- Short vowels: a (cat), e (bed), i (sit), o (odd), u (up)
- Consonants: b, c (cat), d, f, g (gum), h, j, k, l, m, n, p, r, s, t, v, w, y, z, x ("uks", as in box)
- Letter pairs: th (thin), sh (she), ch (chat), wh (what), ng (sing, said "ung"), ck (duck), qu (quit), sk (ask), gh (laugh, said like f)
- Vowel pairs: oo (moon), ou and ow (out, cow), oi and oy (oil, boy), ew (new)
The full list, with Eloquence's spelling of each sound, is the file phonics.json below. Change a sound there and re-record (step 3).
How Eloquence says a sound
Eloquence can be given exact speech sounds in its own phonetic code: `[.1TIn] says "thin" (.1 marks the stressed syllable). Three things had to be found out first:
- Which symbols it uses. The engine can report the sounds it uses for any word.
eloq-phonemes.ps1asks it, for examplecat=k A t,thin=T I n,she=S i,chat=t S A t,sing=s I G,duck=d H k,oil=c y l,out=a w t. (It doesn't list "h", but a word withhdoes start with an h sound.) - A sound needs a vowel.
`[.1T]on its own isn't a syllable, and Eloquence then reads the code itself out loud ("back quote, left bracket..."). So every consonant gets the short "uh" vowel (H):`[.1TH]= "thuh". - Sound only, without "uh".
make-phonics.ps1can also cut one sound out of a real word: it has Eloquence say "thin", notes where each sound starts, keeps just the "th", and stretches sounds you can hold (th, sh, s, vowels) to about half a second. That made the "sound only" samples. They sounded fine, but the "uh" style was picked, so the app doesn't use this tool; it's kept for trying other styles.
Files
Proxmox host shell. Each block writes one file into /root/abcbooks-rebuild/; copy them to the PC and sort them as in the Primer post (the scp and arrange-rebuild.ps1 steps).
mkdir -p /root/abcbooks-rebuild
phonics.json: every sound: Eloquence's phonetic spelling and an example word.
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: the page that plays every 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
make-audio.ps1: records everything a book says. Letter cards (letter:th) now get the phonetic sound from phonics.json, and every book also gets example:th clips for the phonics page.
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
eloq-phonemes.ps1: asks Eloquence which sound symbols it uses for some words (32-bit PowerShell, like eloq-render).
cat > /root/abcbooks-rebuild/eloq-phonemes.ps1 <<'EOF'
# Asks the PC's ETI-Eloquence which phoneme symbols it uses for some words (to write phonics sounds in its phonetic code).
# 32-bit PowerShell: C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File eloq-phonemes.ps1 cat thin she
param([Parameter(ValueFromRemainingArguments)] [string[]]$Words)
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.Text; using System.Collections.Generic; using System.Runtime.InteropServices;
public static class EloqPh {
public delegate int Cb(IntPtr h, int msg, IntPtr 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", CharSet = CharSet.Ansi)] static extern int eciAddText(IntPtr h, string t);
[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);
static List<string> ph; static Cb keep;
static int OnMsg(IntPtr h, int msg, IntPtr lp, IntPtr d) {
if (msg == 3) { // eciPhonemeIndexReply: lParam -> ECIMouthData, phoneme name in the first bytes
var b = new byte[5]; Marshal.Copy(lp, b, 0, 5);
ph.Add(Encoding.ASCII.GetString(b).TrimEnd('\0').Split('\0')[0]);
}
return 1;
}
public static string Phonemes(string text) {
IntPtr h = eciNew(); IntPtr buf = Marshal.AllocHGlobal(4096);
ph = new List<string>(); keep = OnMsg;
eciRegisterCallback(h, keep, IntPtr.Zero); eciSetOutputBuffer(h, 2048, buf);
eciSetParam(h, 1, 1); // eciInputType: annotated
eciSetParam(h, 7, 1); // eciWantPhonemeIndices
eciAddText(h, text); eciSynthesize(h); eciSynchronize(h); eciDelete(h); Marshal.FreeHGlobal(buf);
return string.Join(" ", ph.ToArray());
}
}
'@
foreach ($w in $Words) { "{0,-14} {1}" -f $w, [EloqPh]::Phonemes($w) }
EOF
make-phonics.ps1: cuts one sound out of a word and holds it (the "sound only" style; not used by the app now).
cat > /root/abcbooks-rebuild/make-phonics.ps1 <<'EOF'
# Makes phonics sound clips with the PC's ETI-Eloquence. Eloquence won't say a lone consonant, so each sound is cut
# out of a real word said in its phonetic code (eciPhonemeIndexReply tells where each phoneme starts).
# 32-bit PowerShell: C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File make-phonics.ps1 -Jobs jobs.json
# jobs.json: [{ "phon": "TIn", "from": 0, "to": 1, "hold": 450, "out": "C:\\...\\th.wav" }, ...]
# from/to = phoneme positions (to = -1 for the end), hold = stretch a sound you can hold (th, s, a...) to this many ms (0 = leave short: t, k, ch)
param([Parameter(Mandatory)] [string]$Jobs, [int]$Speed = 50)
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.Linq; using System.IO; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices;
public static class EloqCut {
public delegate int Cb(IntPtr h, int msg, IntPtr 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 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 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 = 64; // small, so phoneme marks line up closely with the audio
static IntPtr buf; static List<short> pcm; static List<int> at; static List<string> ph; static Cb keep;
static int OnMsg(IntPtr h, int msg, IntPtr lp, IntPtr d) {
if (msg == 0) { int n = lp.ToInt32(); var s = new short[n]; Marshal.Copy(buf, s, 0, n); pcm.AddRange(s); }
else if (msg == 3) { var b = new byte[5]; Marshal.Copy(lp, b, 0, 5); ph.Add(Encoding.ASCII.GetString(b).Split('\0')[0]); at.Add(pcm.Count); }
return 1;
}
// Teachers hold a sound ("thhhh", "aaa"): repeat the steady middle third of the sound, cross-faded, until it lasts `want` samples.
static short[] Hold(short[] p, int want) {
if (p.Length >= want || p.Length < 60) return p;
int a = p.Length / 3, b = 2 * p.Length / 3, seg = b - a, x = Math.Min(40, seg / 3);
var o = new List<short>(p.Take(b));
while (o.Count + (p.Length - b) < want) {
int start = o.Count - x;
for (int i = 0; i < x; i++) o[start + i] = (short)((o[start + i] * (x - i) + p[a + i] * i) / x);
for (int i = x; i < seg; i++) o.Add(p[a + i]);
}
o.AddRange(p.Skip(b));
return o.ToArray();
}
// returns "phonemes" and writes the cut-out part [from, to) of the word as a WAV
public static string Cut(string phon, int from, int to, string wav, int speed, int holdMs) {
IntPtr h = eciNew(); buf = Marshal.AllocHGlobal(BUF * 2); pcm = new List<short>(); at = new List<int>(); ph = new List<string>(); keep = OnMsg;
eciRegisterCallback(h, keep, IntPtr.Zero); eciSetOutputBuffer(h, BUF, buf);
eciSetParam(h, 1, 1); eciSetParam(h, 7, 1); eciCopyVoice(h, 1, 0); eciSetVoiceParam(h, 0, 6, speed);
eciAddText(h, "`[.1" + phon + "]"); eciSynthesize(h); eciSynchronize(h); eciDelete(h); Marshal.FreeHGlobal(buf);
int a = from < at.Count ? at[from] : 0;
int b = (to < 0 || to >= at.Count) ? pcm.Count : at[to];
if (to < 0) { int e = pcm.Count; while (e > a && Math.Abs(pcm[e - 1]) < 200) e--; b = Math.Min(pcm.Count, e + 400); } // trim trailing silence
var part = pcm.GetRange(a, Math.Max(0, b - a)).ToArray();
if (holdMs > 0) part = Hold(part, holdMs * 11025 / 1000);
int f = Math.Min(220, part.Length / 4); // 20 ms fade in/out so the cut doesn't click
for (int i = 0; i < f; i++) { part[i] = (short)(part[i] * i / f); part[part.Length - 1 - i] = (short)(part[part.Length - 1 - i] * i / f); }
using (var w = new BinaryWriter(File.Create(wav))) {
int n = part.Length * 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(11025); w.Write(22050); w.Write((short)2); w.Write((short)16);
w.Write(new[] { 'd','a','t','a' }); w.Write(n); foreach (var s in part) w.Write(s);
}
return string.Join(" ", ph.ToArray()) + " [" + a + ".." + b + " of " + pcm.Count + "]";
}
}
'@
foreach ($j in (Get-Content $Jobs -Raw | ConvertFrom-Json)) {
"{0,-10} {1}" -f $j.phon, [EloqCut]::Cut($j.phon, [int]$j.from, [int]$j.to, $j.out, $Speed, [int]$j.hold)
}
EOF
check-audio.ps1: 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
Re-record the sounds
1. Look up a sound's symbols (only when adding or changing a sound)
PC (PowerShell). Prints each word's sounds in Eloquence's code:
C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File C:\ABCBooks\tools\eloq-phonemes.ps1 cat thin she chat sing
2. Edit the list
Change or add a line in C:\ABCBooks\public\phonics.json, for example "th": ["TH", "thin"].
3. Record and upload
PC (PowerShell). Only sounds that changed are recorded (a few seconds per book):
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-audio.ps1 -Book primer1881
Do the same for each book that has letter cards (first1879, second1879, third1879), then upload and publish:
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\push-audio.ps1
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\deploy.ps1
4. Check
PC (PowerShell). Prints each book and speed with "0 missing", then "OK":
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\check-audio.ps1
Troubleshooting
- A card reads out "back quote, left bracket...": the phonetic code lost its backquote. In PowerShell, a backquote inside double quotes is an escape character, so
make-audio.ps1builds the code with single quotes. Also check the sound has a vowel in it. - A sound is missing on the tablet: run
check-audio.ps1. An earlier version ofpush-audio.ps1built its list of files already on the server wrong, so it skipped new recordings in books that already had some; it was fixed on 11 Oct 2026, and the check script catches anything like it. - A sound is too long or too short: clips are recorded at the book's two speeds like everything else; a consonant with "uh" is about 0.6 seconds.
Notes
- The sound list follows McGuffey's own phonic chart (printed at the end of the First Reader), but uses one common sound per letter. The books mark long and short vowels with accent marks that Gutenberg's text doesn't keep, so "a" is always the short a in "cat".
- Ideas for later: a long-vowel set (a as in ate, o as in ode), and a phonics game (hear a sound, tap the letters).