When a child gets stuck on a word, ABC Books now sounds it out the way I do it at home: it says the word, pauses, then says each phonics chunk of the word one at a time, "Stand... st... a... nd." The word is shown big on screen: letter teams that stay together (st, sh, igh, ing) are red, a silent e is grey, and a highlight moves slowly from chunk to chunk with the sound. It works in every book at https://games.503mail.com/abcbooks/: 16,859 different words are recorded. This post explains how it was worked out and how to rebuild it. Every block has a Copy button.
How a child uses it
- Press and hold any word (about half a second) in Listen, Your turn to read, Spell it, or on a New words card. A quick tap still does what it did before (say the word).
- Missed words sound out by themselves: in "Your turn to read", every word that turns red is sounded out before the second try, with the microphone and in grown-up check mode.
- Tap the sound-out screen to close it.
How it sounds: what we tried
This took a few rounds, and the order matters if you rebuild it:
- Cutting the sounds out of the word. Eloquence can report where each speech sound starts while it speaks. The first version said the word, cut it at those marks, and stretched the sounds you can hold ("sss", "aaa") by repeating a slice of them. It sounded shaky, like audio feedback: the repeated slices don't line up with the voice's vibration. Dropped.
- Saying each sound on its own. Eloquence takes exact speech sounds in its phonetic code (
`[.1kH]= "kuh"). Saying each sound cleanly, like the phonics letter cards, sounds natural. Kept. - Timing from a real parent. I recorded myself sounding out "cat" and "hat" with
record-mic.ps1(the PC's own microphone, no software to install) and measured it: the word, about 1.2 seconds of pause, then each sound with about 0.45 seconds between. The app uses that timing. - Chunks by spelling, not by sound. This is a learn-to-read-with-phonics book, so the chunks follow the spelling the child sees (st-a-nd), and each chunk says its phonics sound.
The phonics rules
make-soundout.ps1 splits each word by its spelling, longest pattern first:
- Endings (at the end of the word): -tion, -sion, -ture, -ing, consonant-le (ta-ble, lit-tle, ap-ple), -ed (jump-ed says "t", want-ed says "id", play-ed says "d").
- Vowel teams: ai, ay, ee, ea, ey, ie, oa, oe, ow, ou, oo, oi, oy, ew, eu, ue, ui, aw, au, igh, eigh, augh, ough.
- Vowels with r: ar, er, ir, or, ur, air, are, ear, eer, ore, our (but not "ver-y", "car-ry", where the r starts the next part).
- Magic e: c-a-k-(e), h-o-m-(e), c-u-b-(e), wr-i-t-(e): the e is silent and grey, and the vowel says its name.
- Consonant pairs: th, sh, ch, wh, ph, ck, ng, nk, qu, tch, dge; silent letters: kn (knee), wr (write), gn, mb (lamb), gh (night).
- Blends: at the start, before a vowel (stand, spring, frog, str...); at the end of the word (stand, best, jump...).
- Double letters say one sound (b-e-ll, h-a-pp-y).
- y as a vowel (happ-y, m-y).
Which sound each chunk makes comes from Eloquence reading the whole word: its speech sounds are lined up with the chunks in order. That's how "ea" says different things in br-ea-d and ea-t, "ow" in sn-ow and c-ow, "c" is soft in c-i-t-y, "g" in g-e-m, and "th" is voiced in th-is but not in th-in. If the line-up fails for a word, the chunk gets its usual phonics sound. Eloquence never reports an "h", so an h chunk is always "huh".
Spot checks from the books: gl-a-n-c-ing, str-o-k-(e), br-oa-d-l-y, sw-oo-n, v-ir-t-ue-s, pr-o-b-a-ble, n-a-tion, p-i-c-ture.
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). The app files (app.js, index.html) with the sound-out screen are in the Primer post.
mkdir -p /root/abcbooks-rebuild
make-soundout.ps1: the phonics engine. Talks to Eloquence directly (32-bit), chunks the word, picks each chunk's sound, and writes the clip plus a .chunks file (chunk | kind | start ms) for the highlight. Its other styles (stretch, slow, both, dad) are the experiments above.
cat > /root/abcbooks-rebuild/make-soundout.ps1 <<'EOF'
# "Sound it out" clips with the PC's ETI-Eloquence: the word's sounds one at a time, each held a little, with a short
# gap between them, then the whole word ("c... a... t... cat"). The sounds are cut out of Eloquence saying the word itself,
# using its phoneme marks (eciPhonemeIndexReply), so they blend back into the real word.
# 32-bit PowerShell: C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File make-soundout.ps1 -Jobs jobs.json
# jobs.json: [{ "word": "cat", "out": "C:\\...\\cat.wav", "style": "stretch" }, ...]
# style: stretch = held sounds + gaps + word | slow = the word said very slowly + word | both = stretch then slow then word
# dad = the word first, a pause, then its sounds one by one ("Cat... c... a... t"), timed like Dad's own recording
param([Parameter(Mandatory)] [string]$Jobs, [int]$Speed = 50, [int]$GapMs = 220, [int]$HoldMs = 380)
if ([IntPtr]::Size -ne 4) { throw 'Run this with 32-bit PowerShell (SysWOW64).' }
$env:PATH = 'C:\Windows\Speech\Eloq;' + $env:PATH
Add-Type -TypeDefinition @'
using System; using System.IO; using System.Linq; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices;
public static class EloqSound {
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, SR = 11025; public static bool dbg = false;
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;
}
static Dictionary<string, short[]> cache = new Dictionary<string, short[]>(); // chunk sounds repeat a lot (kuh, a, tuh)
static short[] Say(string text, int speed, bool marks) {
string key = speed + "|" + text;
if (!marks && text.StartsWith("`[") && cache.ContainsKey(key)) return (short[])cache[key].Clone();
var r = SayRaw(text, speed, marks);
if (!marks && text.StartsWith("`[")) cache[key] = (short[])r.Clone();
return r;
}
static short[] SayRaw(string text, int speed, bool marks) {
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); if (marks) eciSetParam(h, 7, 1); eciCopyVoice(h, 1, 0); eciSetVoiceParam(h, 0, 6, speed);
eciAddText(h, text); eciSynthesize(h); eciSynchronize(h); eciDelete(h); Marshal.FreeHGlobal(buf);
return Trim(pcm.ToArray());
}
static short[] Trim(short[] p) { int a = 0, b = p.Length; while (a < b && Math.Abs(p[a]) < 150) a++; while (b > a && Math.Abs(p[b - 1]) < 150) b--; return p.Skip(a).Take(b - a).ToArray(); }
// stop consonants are short bursts; everything else (vowels, s, m, f, th, sh, l, r, n...) can be held
static readonly string[] Stops = { "p", "b", "t", "d", "k", "g", "tS", "dZ", "?" };
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 s = o.Count - x; for (int i = 0; i < x; i++) o[s + i] = (short)((o[s + 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();
}
static void WriteWav(string wav, List<short> o) {
using (var w = new BinaryWriter(File.Create(wav))) {
int n = o.Count * 2;
w.Write(new[] { 'R','I','F','F' }); w.Write(36 + n); w.Write(new[] { 'W','A','V','E','f','m','t',' ' });
w.Write(16); w.Write((short)1); w.Write((short)1); w.Write(SR); w.Write(SR * 2); w.Write((short)2); w.Write((short)16);
w.Write(new[] { 'd','a','t','a' }); w.Write(n); foreach (var s in o) w.Write(s);
}
}
static void Fade(short[] p) { int f = Math.Min(220, p.Length / 4); for (int i = 0; i < f; i++) { p[i] = (short)(p[i] * i / f); p[p.Length - 1 - i] = (short)(p[p.Length - 1 - i] * i / f); } }
// returns the phonemes found; writes the clip
static readonly string[] Vowels = { "A", "E", "I", "H", "a", "c", "o", "u", "U", "R", "e", "i", "x" };
// The word's sounds as Eloquence phonetic syllables, said one by one like the phonics cards: consonants get a short
// "uh" (kuh, tuh), vowels are said alone. Nothing is cut or stretched, so there's no wobble.
static List<string> Syllables(string word, int speed) {
Say(word, speed, true);
var n = ph.Where(p => p != "?" && p != "").ToList();
var lw = word.ToLower();
var outp = new List<string>();
// Eloquence never marks an "h"
if (lw.StartsWith("h") && !(lw.StartsWith("hour") || lw.StartsWith("honest") || lw.StartsWith("honor") || lw.StartsWith("heir")) && n.Count > 0 && Vowels.Contains(n[0])) outp.Add("hH");
for (int i = 0; i < n.Count; i++) {
string p = n[i], next = i + 1 < n.Count ? n[i + 1] : "";
if ((p == "a" || p == "c") && (next == "y" || next == "w")) { outp.Add(p + next); i++; continue; } // ice, out, oil
if ((p == "t" && next == "S") || (p == "d" && next == "Z")) { outp.Add(p + next + "H"); i++; continue; } // ch, j
if (p == "x" && i == n.Count - 2 && (next == "l" || next == "n" || next == "m")) continue; // little: no extra "uh"
if (p == "x") { outp.Add("H"); continue; }
if (Vowels.Contains(p)) { outp.Add(p); continue; }
if (p == "F") p = "t"; // little: the flapped t is a t
if (p == "G") { outp.Add("HG"); continue; } // ng
outp.Add(p + "H");
}
return outp;
}
// ---- PHONICS CHUNKS -------------------------------------------------------------------------------------------
// Spelling rules decide how a word is chunked, like a phonics lesson; Eloquence's own reading of the word decides
// which sound each chunk makes (bread vs eat, snow vs cow, city = soft c, jumped = t).
// Chunk kinds: V = vowel (single, team, r-controlled, y as vowel), C = consonant (letter, digraph, blend), E = ending
// (-ing, -tion, -sion, -ture, consonant-le, -ed), S = silent (the e of a magic e).
static readonly string[] Endings = { "tion", "sion", "ture", "ing", "ble", "tle", "dle", "ple", "gle", "kle", "zle", "fle", "cle", "ed" };
static readonly string[] VTeams3 = { "eigh", "augh", "ough", "igh", "air", "ear", "eer", "ore", "our", "are" };
static readonly string[] CTeams3 = { "tch", "dge", "str", "spl", "spr", "scr", "squ", "thr", "shr", "sch" };
static readonly string[] Digraphs = { "th", "sh", "ch", "wh", "ph", "ck", "ng", "nk", "qu", "kn", "wr", "gn", "mb", "gh" };
static readonly string[] StartBlends = { "bl", "cl", "fl", "gl", "pl", "sl", "br", "cr", "dr", "fr", "gr", "pr", "tr", "sc", "sk", "sm", "sn", "sp", "st", "sw", "tw", "dw" };
static readonly string[] EndBlends = { "nd", "nt", "mp", "ft", "lt", "ld", "lk", "lp", "sk", "sp", "st", "ct", "pt", "lf", "lm" };
static readonly string[] VTeams2 = { "ai", "ay", "ee", "ea", "ey", "ie", "oa", "oe", "ow", "ou", "oo", "oi", "oy", "ew", "eu", "ue", "ui", "aw", "au" };
static readonly string[] RVowels = { "ar", "er", "ir", "or", "ur" };
static readonly string[] Doubles = { "ll", "ss", "ff", "zz", "tt", "dd", "pp", "gg", "nn", "mm", "bb", "rr", "cc" };
static bool IsV(char c) { return "aeiou".IndexOf(c) >= 0; }
static bool At(string w, int i, string t) { return i + t.Length <= w.Length && string.CompareOrdinal(w, i, t, 0, t.Length) == 0; }
public class Chunk { public string T; public char K; public string Sound = ""; }
public static List<Chunk> Chunks(string w) {
var r = new List<Chunk>();
// magic e: ...V C e at the end (cake, time, home, cube, these); the e is silent and the vowel says its name
int magicV = -1;
if (w.Length >= 4 && w[w.Length - 1] == 'e' && !IsV(w[w.Length - 2]) && IsV(w[w.Length - 3]) && !IsV(w[w.Length - 4]) && w[w.Length - 2] != 'r') magicV = w.Length - 3;
if (w.Length == 3 && w[2] == 'e' && !IsV(w[1]) && IsV(w[0])) magicV = 0; // ate, ice, use
int i = 0;
while (i < w.Length) {
string hit = null; char kind = 'C';
if (magicV >= 0 && i == w.Length - 1) { r.Add(new Chunk { T = "e", K = 'S' }); break; }
foreach (var t in Endings) if (i > 0 && i + t.Length == w.Length && At(w, i, t) && (t != "ed" || !IsV(w[i - 1]))) { hit = t; kind = 'E'; break; } // ta-ble, lit-tle
if (hit == null && i != magicV) foreach (var t in VTeams3) if (At(w, i, t)) { hit = t; kind = 'V'; break; }
if (hit == null) foreach (var t in CTeams3) if (At(w, i, t) && (t == "tch" || t == "dge" || (i + t.Length < w.Length && IsV(w[i + t.Length])))) { hit = t; break; }
if (hit == null) foreach (var t in Digraphs) if (At(w, i, t) && !(t == "mb" && i + 2 != w.Length) && !(t == "gn" && i != 0 && i + 2 != w.Length)) { hit = t; break; }
if (hit == null && (i == 0 || !IsV(w[i - 1]) || true)) foreach (var t in StartBlends) if (At(w, i, t) && i + 2 < w.Length && IsV(w[i + 2]) && (i == 0 || IsV(w[i - 1]) || w[i - 1] == 'y')) { hit = t; break; }
if (hit == null) foreach (var t in EndBlends) if (At(w, i, t) && i + 2 == w.Length && i > 0) { hit = t; break; }
if (hit == null && i != magicV) foreach (var t in VTeams2) if (At(w, i, t)) { hit = t; kind = 'V'; break; }
if (hit == null && i != magicV) foreach (var t in RVowels) if (At(w, i, t) && !(i + 2 < w.Length && (IsV(w[i + 2]) || w[i + 2] == 'y' || w[i + 2] == 'r'))) { hit = t; kind = 'V'; break; } // her, bird; not "very", "carry"
if (hit == null) foreach (var t in Doubles) if (At(w, i, t) && !(i + 4 == w.Length && w.EndsWith("le"))) { hit = t; break; } // lit-tle: the 2nd t belongs to "tle"
if (hit == null) { hit = w.Substring(i, 1); kind = IsV(hit[0]) || (hit == "y" && i > 0 && (i + 1 == w.Length || !IsV(w[i + 1]))) ? 'V' : 'C'; }
r.Add(new Chunk { T = hit, K = kind }); i += hit.Length;
}
return r;
}
// how many consonant sounds a consonant chunk usually makes
static int ConsCount(string t) {
switch (t) { case "x": case "qu": case "squ": return t == "squ" ? 3 : 2; case "h": return 0; case "gh": return -1; }
if (Digraphs.Contains(t) || Doubles.Contains(t) || t == "tch" || t == "dge") return 1;
int n = 0; for (int k = 0; k < t.Length; k++) { if (k + 1 < t.Length && (t.Substring(k, 2) == "th" || t.Substring(k, 2) == "sh" || t.Substring(k, 2) == "ch")) k++; n++; }
return n;
}
static readonly Dictionary<string, string> RuleSound = new Dictionary<string, string> {
{ "a", "A" }, { "e", "E" }, { "i", "I" }, { "o", "a" }, { "u", "H" }, { "y", "i" },
{ "ee", "i" }, { "ea", "i" }, { "ey", "i" }, { "ie", "ay" }, { "oo", "u" }, { "ou", "aw" }, { "ow", "aw" }, { "oi", "cy" }, { "oy", "cy" }, { "ai", "e" }, { "ay", "e" },
{ "oa", "o" }, { "oe", "o" }, { "ew", "u" }, { "eu", "u" }, { "ue", "u" }, { "ui", "u" }, { "aw", "c" }, { "au", "c" }, { "igh", "ay" }, { "eigh", "e" }, { "augh", "c" }, { "ough", "c" },
{ "ar", "ar" }, { "er", "R" }, { "ir", "R" }, { "ur", "R" }, { "or", "cr" }, { "air", "Er" }, { "are", "Er" }, { "ear", "ir" }, { "eer", "ir" }, { "ore", "cr" }, { "our", "awr" },
{ "c", "kH" }, { "ck", "kH" }, { "k", "kH" }, { "qu", "kwH" }, { "x", "Hks" }, { "j", "dZH" }, { "dge", "dZH" }, { "ph", "fH" }, { "sh", "SH" }, { "ch", "tSH" }, { "tch", "tSH" },
{ "wh", "wH" }, { "ng", "HG" }, { "nk", "HGk" }, { "th", "TH" }, { "kn", "nH" }, { "wr", "rH" }, { "gn", "nH" }, { "mb", "mH" }, { "gh", "" }, { "h", "hH" },
{ "ing", "IG" }, { "tion", "SHn" }, { "sion", "ZHn" }, { "ture", "tSR" }, { "ed", "dH" } };
// Each chunk with its sound, as Eloquence phonetic code ("kH" = kuh, "A" = short a, "" = silent)
public static List<Chunk> PhonicsParts(string word, int speed) {
var w = word.ToLower().Replace("'", "");
var ch = Chunks(w);
Say(word, speed, true);
// Eloquence's sounds, with ch/j and vowel glides joined, flapped t = t, schwa = short u
var p = ph.Where(x => x != "?" && x != "").Select(x => x == "F" ? "t" : (x == "x" || x == "X") ? "H" : x).ToList();
var tok = new List<string>();
for (int k = 0; k < p.Count; k++) {
string a = p[k], b = k + 1 < p.Count ? p[k + 1] : "";
if ((a == "t" && b == "S") || (a == "d" && b == "Z")) { tok.Add(a + b); k++; continue; }
if ((a == "a" || a == "c") && (b == "y" || b == "w")) { tok.Add(a + b); k++; continue; }
tok.Add(a);
}
int q = 0;
Func<string, bool> isVow = s => Vowels.Contains(s) || s == "ay" || s == "aw" || s == "cy";
foreach (var c in ch) {
string snd = null;
if (c.K == 'S') { c.Sound = ""; continue; }
if (c.T == "h") { c.Sound = "hH"; continue; } // Eloquence never marks an h
if (c.K == 'V') {
if (q < tok.Count && tok[q] == "y" && q + 1 < tok.Count && tok[q + 1] == "u") { snd = "yu"; q += 2; } // cube, use
else if (q < tok.Count && isVow(tok[q])) {
snd = tok[q]; q++;
bool rc = c.T.Contains("r") && snd != "R";
if (rc && q < tok.Count && tok[q] == "r") { snd += "r"; q++; }
if (c.T.EndsWith("gh") && q < tok.Count && tok[q] == "f") { snd += "f"; q++; } // laugh, enough: gh says f
}
} else if (c.K == 'E') {
// endings take their sounds up to the end of the word: ing = I G, tion = S H n, jumped = t, wanted = I d
var e = tok.Skip(q).ToList(); q = tok.Count;
if (e.Count > 0) snd = string.Join("", e.Select(x => x == "G" ? "G" : x));
if (snd != null && !e.Any(x => isVow(x))) snd += "H";
if (c.T.Length == 3 && c.T.EndsWith("le")) snd = (c.T[0] == 'c' ? "k" : c.T.Substring(0, 1)) + "Hl"; // ble, tle, ple: buhl, tuhl, puhl
} else {
int n = ConsCount(c.T);
if (n == -1) { if (q < tok.Count && tok[q] == "f") { snd = "fH"; q++; } else snd = ""; } // laugh vs night
else if (n > 0 && q + n <= tok.Count && tok.Skip(q).Take(n).All(x => !isVow(x))) {
var e = tok.Skip(q).Take(n).ToList(); q += n;
snd = e.Count == 1 && e[0] == "G" ? "HG" : string.Join("", e) + "H";
if (c.T == "nk" && e.Count == 1) snd = "HGk";
}
}
c.Sound = snd ?? (RuleSound.ContainsKey(c.T) ? RuleSound[c.T] : (c.K == 'V' ? "H" : c.T + "H"));
}
return ch;
}
public static string Make(string word, string wav, string style, int speed, int gapMs, int holdMs) {
var o = new List<short>(); var gap = new short[gapMs * SR / 1000]; var sounds = new List<string>();
if (style == "phonics") {
// Dad's way with the phonics sounds: the word, ~1.2 s pause, then each spelling chunk's sound with ~0.45 s between
var parts = PhonicsParts(word, speed);
var g = new short[450 * SR / 1000];
o.AddRange(Say(word, speed, false)); o.AddRange(new short[1200 * SR / 1000]);
var timing = new List<string>(); // chunk|kind|start ms, for the slow highlight (multi-letter chunks shown in red)
foreach (var c in parts) {
if (c.Sound == "") { sounds.Add(c.T + "=(silent)"); timing.Add(c.T + "|" + c.K + "|-1"); continue; }
timing.Add(c.T + "|" + c.K + "|" + (o.Count * 1000L / SR));
var part = Say("`[.1" + c.Sound + "]", speed, false); Fade(part); o.AddRange(part); o.AddRange(g);
sounds.Add(c.T + "=" + c.Sound);
}
WriteWav(wav, o);
File.WriteAllText(wav + ".chunks", string.Join("\n", timing));
return string.Join(" ", sounds);
}
if (style == "dad" || style == "clean") {
// dad: the word, ~1.2 s pause, then each sound with ~0.45 s between. clean: the sounds, then the word.
var syl = Syllables(word, speed);
var g = new short[(style == "dad" ? 450 : 300) * SR / 1000];
if (style == "dad") { o.AddRange(Say(word, speed, false)); o.AddRange(new short[1200 * SR / 1000]); }
foreach (var s in syl) { var part = Say("`[.1" + s + "]", speed, false); Fade(part); o.AddRange(part); o.AddRange(g); sounds.Add(s); }
if (style == "clean") { o.AddRange(g); o.AddRange(Say(word, speed, false)); }
WriteWav(wav, o);
return string.Join(" ", sounds);
}
if (style == "dad") { // word, ~1.2 s pause, then each sound ~0.35 s (stops shorter) with ~0.45 s between
o.AddRange(Say(word, speed, false)); o.AddRange(new short[1200 * SR / 1000]);
gap = new short[450 * SR / 1000]; holdMs = 350;
}
if (style != "slow") {
// say the word with phoneme marks, then cut it at the marks (consonant pairs like "t S" are kept together by Eloquence)
IntPtr h = IntPtr.Zero; var full = Say(word, speed, true);
var marks = at.ToList(); var names = ph.ToList(); var raw = pcm.ToArray();
// Eloquence doesn't mark an "h": sound before the first mark ("h" in hat) becomes its own piece
int lead = 0; while (lead < raw.Length && Math.Abs(raw[lead]) < 150) lead++;
if (dbg) sounds.Add("[lead " + lead + " mark0 " + (marks.Count > 0 ? marks[0] : -1) + " of " + raw.Length + "]");
if (marks.Count > 0 && marks[0] - lead > 400) {
var hpart = Hold(raw.Skip(lead).Take(marks[0] - lead).ToArray(), holdMs * SR / 1000);
Fade(hpart); o.AddRange(hpart); o.AddRange(gap); sounds.Add("h");
}
bool firstDone = false;
for (int i = 0; i < names.Count; i++) {
if (names[i] == "?" || names[i] == "") continue;
int a = marks[i], b = i + 1 < marks.Count ? marks[i + 1] : raw.Length;
if (b - a < 40) continue;
var part = raw.Skip(a).Take(b - a).ToArray();
// "hat", "hen": the h is hidden at the start of the vowel's piece. It's a quiet breath; the vowel starts where
// the loudness jumps (first 10 ms window over 35% of the loudest), so split it there.
if (!firstDone && word.ToLower().StartsWith("h") && !word.ToLower().StartsWith("ho") && part.Length > 1000) {
int w10 = SR / 100; double max = 0; var env = new List<double>();
for (int s = 0; s + w10 <= part.Length; s += w10) { double e = 0; for (int q = s; q < s + w10; q++) e += Math.Abs(part[q]); env.Add(e / w10); max = Math.Max(max, e / w10); }
int on = env.FindIndex(e => e > 0.35 * max) * w10;
if (on > 3 * w10) {
var hp = Hold(part.Take(on).ToArray(), holdMs * SR / 1000); Fade(hp); o.AddRange(hp); o.AddRange(gap); sounds.Add("h");
part = part.Skip(on).ToArray();
}
}
if (!Stops.Contains(names[i])) part = Hold(part, holdMs * SR / 1000); else part = Hold(part, 90 * SR / 1000);
Fade(part); o.AddRange(part); o.AddRange(gap); sounds.Add(names[i]); firstDone = true;
}
}
if (style == "slow" || style == "both") { o.AddRange(Say(word, 8, false)); o.AddRange(gap); o.AddRange(gap); }
if (style != "dad") { o.AddRange(gap); o.AddRange(Say(word, speed, false)); }
using (var w = new BinaryWriter(File.Create(wav))) {
int n = o.Count * 2;
w.Write(new[] { 'R','I','F','F' }); w.Write(36 + n); w.Write(new[] { 'W','A','V','E','f','m','t',' ' });
w.Write(16); w.Write((short)1); w.Write((short)1); w.Write(SR); w.Write(SR * 2); w.Write((short)2); w.Write((short)16);
w.Write(new[] { 'd','a','t','a' }); w.Write(n); foreach (var s in o) w.Write(s);
}
return string.Join(" ", sounds) + (dbg ? "" : "");
}
}
'@
foreach ($j in (Get-Content $Jobs -Raw | ConvertFrom-Json)) {
if ($j.skip -and (Test-Path ($j.out + ".chunks"))) { continue }
"{0,-10} {1,-7} {2}" -f $j.word, $j.style, [EloqSound]::Make($j.word, $j.out, $j.style, $Speed, $GapMs, $HoldMs)
}
EOF
make-soundouts.ps1: records every word of a book that isn't recorded yet (words are shared between books) and writes public\audio\<book>.soundout.json for the app.
cat > /root/abcbooks-rebuild/make-soundouts.ps1 <<'EOF'
# "Sound it out" for every word of a book: records each word the phonics way with the PC's ETI-Eloquence
# (make-soundout.ps1, style "phonics": the word, a pause, then each spelling chunk's sound) and writes
# public/audio/<book>.soundout.json = { word: { id, ch: [[chunk, kind, ms], ...] } } for the app.
# Words are shared between books: a word is recorded once into tools/out/so/s60/<id>.wav.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-soundouts.ps1 -Book primer1881
# Then push-audio.ps1 -Book so uploads the new clips.
param([Parameter(Mandatory)] [string]$Book)
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$data = Get-Content "$root\public\books\$Book.json" -Raw -Encoding UTF8 | ConvertFrom-Json
$dir = "$root\tools\out\so\s60"; New-Item -ItemType Directory -Force $dir | Out-Null
$words = New-Object 'System.Collections.Generic.SortedSet[string]' ([StringComparer]::Ordinal)
foreach ($L in $data.lessons) {
foreach ($s in @($L.lines) + @($L.words) + @($L.script)) {
foreach ($m in [regex]::Matches("$s", "[A-Za-z]+(?:'[A-Za-z]+)?")) { [void]$words.Add($m.Value.ToLower()) }
}
}
$sha = [Security.Cryptography.SHA1]::Create()
function Id($s) { (($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("so:$s")) | Select-Object -First 6 | ForEach-Object { $_.ToString('x2') }) -join '') }
$jobs = @(); $ids = @{}
foreach ($w in $words) {
$id = Id $w; $ids[$w] = $id
if (-not (Test-Path "$dir\$id.wav.chunks")) { $jobs += [ordered]@{ word = $w; style = 'phonics'; out = "$dir\$id.wav"; skip = $true } }
}
"$Book : $($words.Count) words, $($jobs.Count) to record"
if ($jobs.Count) {
$jf = "$dir\jobs-$Book.json"; ConvertTo-Json @($jobs) | Set-Content -Encoding utf8 $jf
& 'C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe' -NoProfile -ExecutionPolicy Bypass -File "$root\tools\make-soundout.ps1" -Jobs $jf -Speed 60 | Out-Null
}
# the app's list: each chunk with its kind (V vowel, C consonant, E ending, S silent) and when its sound starts (ms)
$sb = New-Object Text.StringBuilder; [void]$sb.Append('{"speed":60,"words":{'); $first = $true
foreach ($w in $words) {
$cf = "$dir\$($ids[$w]).wav.chunks"; if (-not (Test-Path $cf)) { continue }
$ch = @([IO.File]::ReadAllLines($cf) | Where-Object { $_ } | ForEach-Object { $p = $_ -split '\|'; '[' + (ConvertTo-Json $p[0]) + ',"' + $p[1] + '",' + $p[2] + ']' })
if (-not $first) { [void]$sb.Append(',') }; $first = $false
[void]$sb.Append((ConvertTo-Json $w) + ':{"id":"' + $ids[$w] + '","ch":[' + ($ch -join ',') + ']}')
}
[void]$sb.Append('}}')
[IO.File]::WriteAllText("$root\public\audio\$Book.soundout.json", $sb.ToString(), (New-Object Text.UTF8Encoding $false))
"wrote public\audio\$Book.soundout.json"
EOF
record-mic.ps1: records the PC microphone to a WAV (one beep = talk, two beeps = done). Used to measure the parent timing.
cat > /root/abcbooks-rebuild/record-mic.ps1 <<'EOF'
# Records the PC's default microphone to a WAV file (Windows MCI, nothing to install).
# One beep = start talking, two beeps = stopped.
# Usage: powershell -ExecutionPolicy Bypass -File record-mic.ps1 -Out C:\path\take.wav -Seconds 12 -DelaySeconds 5
param([Parameter(Mandatory)] [string]$Out, [int]$Seconds = 12, [int]$DelaySeconds = 5)
Add-Type -Namespace Win -Name Mci -MemberDefinition '[DllImport("winmm.dll", CharSet = CharSet.Unicode)] public static extern int mciSendString(string cmd, System.Text.StringBuilder ret, int len, System.IntPtr cb);'
function Mci($cmd) { $r = [Win.Mci]::mciSendString($cmd, $null, 0, [IntPtr]::Zero); if ($r -ne 0) { throw "MCI error $r on: $cmd" } }
Start-Sleep -Seconds $DelaySeconds
Mci 'open new type waveaudio alias abcrec'
Mci 'set abcrec bitspersample 16 channels 1 samplespersec 22050'
[console]::Beep(880, 300)
Mci 'record abcrec'
Start-Sleep -Seconds $Seconds
Mci 'stop abcrec'
Mci "save abcrec `"$Out`""
Mci 'close abcrec'
[console]::Beep(660, 200); Start-Sleep -Milliseconds 120; [console]::Beep(660, 200)
"saved $Out ({0:N0} bytes)" -f (Get-Item $Out).Length
EOF
push-via-share.ps1: uploads through the PC share the Proxmox host already mounts for backups, for days when plain ssh/scp from the PC keep resetting.
cat > /root/abcbooks-rebuild/push-via-share.ps1 <<'EOF'
# Fallback when plain ssh/scp from this PC keep resetting ("client_loop: send disconnect: Connection reset"):
# the files go through the PC share the Proxmox host already mounts for backups (D:\Proxmox-backups =
# /mnt/pve/pc-backups on the host). One short `ssh -tt` starts proxmox/upload-from-share.sh on the host as a
# background service (systemd-run), so a dropped session can't stop it; this script then follows its log on the share.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\push-via-share.ps1 -What site
# powershell -ExecutionPolicy Bypass -File ABCBooks\tools\push-via-share.ps1 -What audio -Book so
# powershell -ExecutionPolicy Bypass -File ABCBooks\tools\push-via-share.ps1 -What both -Book so
param([ValidateSet('site', 'audio', 'both')] [string]$What = 'site', [string]$Book = '*')
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$share = 'D:\Proxmox-backups\abc-transfer'
New-Item -ItemType Directory -Force $share | Out-Null
Get-ChildItem $share -Filter *.tar | Remove-Item -Confirm:$false
foreach ($s in 'push.sh', 'convert-audio.sh', 'upload-from-share.sh') { # Linux line endings
[IO.File]::WriteAllText("$share\$s", ([IO.File]::ReadAllText("$root\proxmox\$s") -replace "`r`n", "`n"))
}
if ($What -ne 'audio') { tar -cf "$share\site.tar" -C "$root\public" . }
if ($What -ne 'site') {
# every WAV of the book(s); CT 306 converts only the ones without an MP3 yet
Push-Location "$root\tools\out"; tar -cf "$share\audio.tar" --exclude '*.json' --exclude '*.chunks' (Get-ChildItem -Directory -Filter $Book | ForEach-Object Name); Pop-Location
}
"ready in the share: {0:N0} MB" -f ((Get-ChildItem $share -Filter *.tar | Measure-Object Length -Sum).Sum / 1MB)
Remove-Item "$share\upload.log" -ErrorAction SilentlyContinue
"true`nsystemd-run --unit abc-upload-$(Get-Date -Format HHmmss) bash /mnt/pve/pc-backups/abc-transfer/upload-from-share.sh`nexit`n" |
ssh -o BatchMode=yes -o ConnectTimeout=15 -tt proxmox 2>&1 | Select-String 'Running as unit|Failed' | ForEach-Object { $_.Line.Trim() }
$seen = 0
for ($i = 0; $i -lt 720; $i++) { # follow the log for up to 2 hours
Start-Sleep 10
if (-not (Test-Path "$share\upload.log")) { continue }
$log = @(Get-Content "$share\upload.log")
$log | Select-Object -Skip $seen; $seen = $log.Count
if ($log -match '^ABC-DONE') { break }
}
Get-ChildItem $share -Filter *.tar | Remove-Item -Confirm:$false
EOF
upload-from-share.sh (host): run by push-via-share.ps1 as a background service (systemd-run) on the Proxmox host, because container commands can stall inside an interactive SSH session. It logs to the share, where the PC follows it.
cat > /root/abcbooks-rebuild/upload-from-share.sh <<'EOF'
#!/bin/bash
# Runs on the Proxmox host as a background service (systemd-run), so it doesn't depend on an SSH session:
# systemd-run --unit abc-upload bash /mnt/pve/pc-backups/abc-transfer/upload-from-share.sh
# Takes audio.tar and/or site.tar from the PC share, puts them into CT 306 (abc-web) and converts new clips to MP3.
# Progress goes to upload.log next to it, which the PC can read straight from D:\Proxmox-backups\abc-transfer.
S=/mnt/pve/pc-backups/abc-transfer
LOG=$S/upload.log
exec >"$LOG" 2>&1
echo "start $(date)"
set -e
if [ -f $S/site.tar ]; then
mkdir -p /root/abcbooks
cp $S/site.tar /root/abcbooks/site.tar
bash $S/push.sh | tail -3
unlink /root/abcbooks/site.tar
echo "site done"
fi
if [ -f $S/audio.tar ]; then
pct push 306 $S/convert-audio.sh /root/convert-audio.sh
echo "copying audio.tar into CT 306"
pct push 306 $S/audio.tar /tmp/abcaudio.tar
pct exec 306 -- mkdir -p /tmp/abcaudio
echo "unpacking"
pct exec 306 -- tar -xf /tmp/abcaudio.tar -C /tmp/abcaudio --no-same-owner
pct exec 306 -- unlink /tmp/abcaudio.tar
echo "converting"
pct exec 306 -- bash /root/convert-audio.sh | tail -12
fi
pct exec 306 -- df -h /
echo "ABC-DONE $(date)"
EOF
publish-via-share.ps1: the same trick for publishing blog posts to CT 112 (Grav blog).
cat > /root/abcbooks-rebuild/publish-via-share.ps1 <<'EOF'
# Publishes built blog posts (item.md) to the Grav blog on CT 112 through the PC share, for days when plain scp
# from this PC keeps resetting. Same result as the blog-post skill's publish-post.ps1.
# Container commands (pct exec/push) can stall inside an interactive `ssh -tt` session, so the work runs on the host
# as a background service (systemd-run) and this script reads its log from the share.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\publish-via-share.ps1 -Posts "blog\item.md=mcguffeys-eclectic-primer-10oct2026;blog\soundout\item.md=sound-it-out-phonics-chunking-11oct2026"
param([Parameter(Mandatory)] [string]$Posts, [string]$Section = '04.abcbooks') # "path=slug;path=slug"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$share = 'D:\Proxmox-backups\abc-transfer'; $h = '/mnt/pve/pc-backups/abc-transfer'
New-Item -ItemType Directory -Force "$share\posts" | Out-Null
$sh = "#!/bin/bash`nexec >$h/publish.log 2>&1`n"
foreach ($p in ($Posts -split ';' | Where-Object { $_ })) {
$md, $slug = $p -split '=', 2
Copy-Item (Join-Path $root $md) "$share\posts\$slug.md" -Force
$dir = "/var/www/html/grav/user/pages/$Section/$slug"
$sh += "pct exec 112 -- install -d -o www-data -g www-data -m 755 $dir`npct push 112 $h/posts/$slug.md $dir/item.md --user 33 --group 33 --perms 644 && echo ok $slug`n"
}
$sh += "echo PUBLISH-DONE`n"
[IO.File]::WriteAllText("$share\publish.sh", $sh)
Remove-Item "$share\publish.log" -ErrorAction SilentlyContinue
"true`nsystemd-run --unit abc-publish-$(Get-Date -Format HHmmss) bash $h/publish.sh`nexit`n" |
ssh -o BatchMode=yes -o ConnectTimeout=15 -tt proxmox 2>&1 | Select-String 'Running as unit' | ForEach-Object { $_.Line.Trim() }
for ($i = 0; $i -lt 36; $i++) { Start-Sleep 5; if ((Test-Path "$share\publish.log") -and (Select-String -Path "$share\publish.log" -Pattern 'PUBLISH-DONE' -Quiet)) { break } }
Get-Content "$share\publish.log"
Get-ChildItem "$share\posts" -Filter *.md | Remove-Item -Confirm:$false
EOF
soundout.html: the sample page used to compare styles.
cat > /root/abcbooks-rebuild/soundout.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 sound it out</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:4.5em;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>Sound it out: pick a style</h1>
<p>For a word a child is stuck on. All in Eloquence (Reed). Each word has four buttons:</p>
<ol>
<li><b>Stretch</b>: each sound on its own, held a little, then the whole word ("c... a... t... cat").</li>
<li><b>Slow</b>: the word said very slowly, then normally.</li>
<li><b>Both</b>: stretched sounds, then slow, then normal.</li>
<li><b>Your style</b>: the word first, then its sounds one by one ("Cat... c... a... t"), timed like your own recording.</li>
</ol>
<div id="list"></div>
<script>
const audio = new Audio();
['cat', 'hat', 'ship', 'frog', 'sun', 'fish', 'stand', 'sunset', 'little'].forEach(w => {
const row = document.createElement('div'); row.className = 'row';
const h = document.createElement('b'); h.textContent = w; row.appendChild(h);
[['1. Stretch', 'stretch'], ['2. Slow', 'slow'], ['3. Both', 'both'], ['4. Your style', 'dad']].forEach(([label, st]) => {
const b = document.createElement('button'); b.textContent = `${label}: ${w}`;
b.onclick = () => { audio.src = `voices/soundout/${w}-${st}.wav`; audio.play(); };
row.appendChild(b);
});
document.getElementById('list').appendChild(row);
});
</script></body></html>
EOF
Rebuild, step by step
1. Record every book
PC (PowerShell). Run it for each book; the first one prints e.g. "primer1881 : 359 words, 359 to record". All eight books: 16,859 words, about 17 minutes here (the chunk sounds are cached, so it's fast).
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-soundouts.ps1 -Book primer1881
The ids: primer1881, first1879, second1879, third1879, fourth1879, fifth1879, sixth1879, spell1867.
2. Upload
PC (PowerShell). The clips are about 2 GB of WAV; CT 306 (abc-web) turns them into MP3s in audio/so/s60/:
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\push-audio.ps1 -Book so
If plain SSH keeps resetting, use the share instead:
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\push-via-share.ps1 -What audio -Book so
3. Put the app on the server and check
PC (PowerShell):
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\deploy.ps1
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\check-audio.ps1
check-audio.ps1 ends with a "sound it out: N clips needed, 0 missing" line.
Troubleshooting
- A word is chunked wrong: test it alone. PC (PowerShell), it prints each chunk and its sound code:
'[{"word":"probable","style":"phonics","out":"C:\\ABCBooks\\test.wav"}]' | Set-Content -Encoding utf8 C:\ABCBooks\test.json
C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-soundout.ps1 -Jobs C:\ABCBooks\test.json
Fix the rule lists at the top of the PHONICS CHUNKS part of make-soundout.ps1, delete that word's .wav.chunks file in tools\out\so\s60\ and run step 1 again.
- A re-record silently does nothing: don't record while an upload is packing the WAV files (the files are locked); run step 1 again afterwards.
- Holding a word does nothing: the book's
audio/<book>.soundout.jsonis missing on the server (step 3), or that word has no recording (check-audio.ps1).
Notes
- Words with two syllables are chunked by phonics rules, not split into syllables; long words in the later Readers are long to sound out, which is fine for the younger readers this is for.
- Next idea: a "sound it out" game where the child hears the chunks and builds the word.