abcbooks.fyi: own containers, a reading check that listens, and a Fire tablet app (ABC Books rebuild guide) 11OCT2026

ABC Books moves to its own domain and containers, gets a self-hosted Vosk server that hears kids read aloud (and catches misreads), and becomes a real Fire tablet app

Posted by on 11th Oct 2026

ABC Books now lives at its own address, https://abcbooks.fyi/, on two new containers: one serves the site, and one listens to a child reading a line out loud and says which words were read right, so the 🎤 Your turn to read step works on Fire tablets. It also became a small Fire tablet app that opens ABC Books full screen with the microphone allowed, ready for the Amazon Appstore. This post is the complete rebuild guide; every block has a Copy button.

Why

  • The mic didn't work on the tablets. The reading check used the browser's built-in speech recognition. Fire tablets have none at all (no Google services; cmd package query-services -a android.speech.RecognitionService says "No services found"), and the Amazon Kids browser blocks the microphone anyway. So the check now runs on my own server.
  • Production. ABC Books shared CT 125 with the games site. It now has its own containers, its own domain and its own backups, so an update to one can't break the other.
  • A real app. A kid profile on a Fire tablet only shows Amazon Appstore apps, and the Kids browser can't use the mic. A tiny app that opens ABC Books with the mic allowed fixes both once it's in the Appstore.

How it works

  1. A tablet, phone or PC opens https://abcbooks.fyi/ (the old https://games.503mail.com/abcbooks/ still works). CT 108 (Caddy reverse proxy) sends the page and its files to CT 306 (abc-web) and anything under /api/ to CT 307 (abc-speech).
  2. In Your turn to read, the kid taps 🎤. The page records with the microphone (getUserMedia), and stops by itself 1.3 seconds after the kid stops talking (or when 🎤 is tapped again).
  3. The page turns the recording into plain 16 kHz, 16-bit sound and posts it to api/recognize?words=<the line>. A short line is about 60 KB.
  4. CT 307 runs Vosk (an offline speech recognizer) with its small English model, but it only lets Vosk choose from the line's own words plus near-miss words: every word of 3+ letters brings up to 12 words from the books that differ by one letter (rat → mat, bat, hat, sat). A misread lands on a near miss (or "unknown") instead of being "corrected" to the right word. It answers with the words it heard, in well under a second, and throws the sound away. Nothing is saved or logged.
  5. The page lines the heard words up with the line: green = read, red = missed (then sounded out for a second try). If the server can't be reached, the page falls back to the browser's own recognizer, and then to grown-up marking.
  6. The Fire app is a 37 KB Android app: a full-screen web view locked to abcbooks.fyi, with permission to use the microphone. Progress is saved inside the app.

Containers involved

Container Role LAN IP
CT 108 (Caddy reverse proxy) abcbooks.fyi → CT 306, /api/* → CT 307; www.abcbooks.fyi redirects x.x.0.122
CT 306 (abc-web) Debian 12, Caddy serves /var/www/abcbooks (all 8 books + 2.7 GB of voice clips); lame for new recordings. 1 core, 512 MB, 8 GB x.x.0.127 (DHCP)
CT 307 (abc-speech) Debian 12, Python + Vosk (abc-speech service on port 8000), small English model (40 MB). 2 cores, 1.5 GB, 4 GB x.x.0.166 (DHCP)

DNS: abcbooks.fyi is in Amazon Route 53 with A records for abcbooks.fyi, *.abcbooks.fyi and www.abcbooks.fyi pointing to the home IP. Caddy gets the certificates by itself.

Plus the Windows PC from the Primer post (the project folder C:\ABCBooks, SSH to the host as proxmox).

Where to run each command

  • Proxmox host shell: in the Proxmox web UI click pve → Shell (or SSH to the host as root).
  • Inside a container: Proxmox web UI → 306 (abc-web) or 307 (abc-speech) → Console, or pct enter 306 from the host shell.
  • PC: a normal Windows PowerShell window.

Rebuild, step by step

1. Make a working folder on the host

Proxmox host shell:

mkdir -p /root/abc300 && cd /root/abc300

2. Create every file

Paste each block into the Proxmox host shell. Each one writes one file into /root/abc300/.

create-300s.sh: creates both containers, runs their setup scripts, and (one time) copies the old site over from CT 125 (nibbles) if it's still there.

cat > /root/abc300/create-300s.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host (as a systemd-run unit, logs to /root/abc300/create.log).
# Creates CT 306 abc-web + CT 307 abc-speech and copies the live ABC Books site from CT 125 to CT 306.
# CT 125 keeps its copy until the Caddy switch is verified.
exec > /root/abc300/create.log 2>&1
set -ex
cd /root/abc300
T=local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst
COMMON="--net0 name=eth0,bridge=vmbr0,ip=dhcp --unprivileged 1 --features nesting=1 --onboot 1 --ostype debian --timezone America/New_York"
pct status 306 || pct create 306 $T --hostname abc-web --cores 1 --memory 512 --swap 512 --rootfs local-lvm:8 $COMMON
pct status 307 || pct create 307 $T --hostname abc-speech --cores 2 --memory 1536 --swap 512 --rootfs local-lvm:4 $COMMON
pct start 306 || true
pct start 307 || true
sleep 15

pct push 306 /root/abc300/setup-306.sh /root/setup-306.sh
pct exec 306 -- bash /root/setup-306.sh
# one-time move of the old copy; on a fresh rebuild the site comes from deploy.ps1 + push-audio.ps1 instead
if pct exec 125 -- test -d /var/www/nibbles/abcbooks; then
  pct exec 125 -- tar -C /var/www/nibbles/abcbooks -cf - . > /root/abc300/site.tar
  pct push 306 /root/abc300/site.tar /root/site.tar
  pct exec 306 -- tar -xf /root/site.tar -C /var/www/abcbooks --no-same-owner
  pct exec 306 -- unlink /root/site.tar
  unlink /root/abc300/site.tar
  pct exec 306 -- chmod -R u=rwX,go=rX /var/www/abcbooks
  pct exec 306 -- du -sh /var/www/abcbooks
fi

pct push 307 /root/abc300/abc_speech.py /root/abc_speech.py
pct push 307 /root/abc300/vocab.txt /root/vocab.txt
pct push 307 /root/abc300/setup-307.sh /root/setup-307.sh
pct exec 307 -- bash /root/setup-307.sh

echo "IP306 $(pct exec 306 -- hostname -I)"
echo "IP307 $(pct exec 307 -- hostname -I)"
echo ABC-DONE
EOF

setup-306.sh (runs inside CT 306): Caddy + lame, serves /var/www/abcbooks. uri strip_prefix /abcbooks lets the same files answer at abcbooks.fyi/ and games.503mail.com/abcbooks/.

cat > /root/abc300/setup-306.sh <<'EOF_OUTER1'
#!/bin/bash
# Runs INSIDE CT 306 (abc-web): Caddy serving ABC Books from /var/www/abcbooks on :80.
# The same files answer at abcbooks.fyi/ and games.503mail.com/abcbooks/ (prefix stripped).
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y -q caddy lame   # lame: convert-audio.sh turns new WAV recordings into MP3 here
mkdir -p /var/www/abcbooks
cat > /etc/caddy/Caddyfile <<'EOF'
:80 {
    root * /var/www/abcbooks
    uri strip_prefix /abcbooks
    encode gzip
    header Cache-Control "no-cache"
    file_server
}
EOF
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl enable caddy
systemctl reload caddy || systemctl restart caddy
EOF_OUTER1

setup-307.sh (runs inside CT 307): Python, Vosk, the small English model, and the abc-speech service.

cat > /root/abc300/setup-307.sh <<'EOF_OUTER1'
#!/bin/bash
# Runs INSIDE CT 307 (abc-speech): Vosk reading-check service on :8000.
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y -q python3 python3-venv unzip curl
id abc >/dev/null 2>&1 || useradd --system --home /opt/abc-speech --shell /usr/sbin/nologin abc
mkdir -p /opt/abc-speech
python3 -m venv /opt/abc-speech/venv
/opt/abc-speech/venv/bin/pip install -q vosk
if [ ! -d /opt/abc-speech/model ]; then
  curl -fsSL -o /tmp/model.zip https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip
  unzip -q /tmp/model.zip -d /opt/abc-speech
  mv /opt/abc-speech/vosk-model-small-en-us-0.15 /opt/abc-speech/model
  unlink /tmp/model.zip
fi
install -m 644 /root/abc_speech.py /opt/abc-speech/abc_speech.py
install -m 644 /root/vocab.txt /opt/abc-speech/vocab.txt
chown -R abc:abc /opt/abc-speech
cat > /etc/systemd/system/abc-speech.service <<'EOF'
[Unit]
Description=ABC Books reading check (Vosk)
After=network-online.target

[Service]
User=abc
ExecStart=/opt/abc-speech/venv/bin/python /opt/abc-speech/abc_speech.py
Restart=always
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now abc-speech
systemctl restart abc-speech
sleep 6
curl -fsS http://127.0.0.1:8000/health
echo
EOF_OUTER1

abc_speech.py: the reading check itself. Python's built-in web server, one recognizer per request, near-miss words from vocab.txt. Add &near=0 to listen for only the given words (Ray's Arithmetic uses that for number answers, so "ten" can't turn into "hen").

cat > /root/abc300/abc_speech.py <<'EOF'
#!/usr/bin/env python3
# ABC Books reading check (CT 307). The app records the kid reading one line and posts it here.
#   POST /recognize?words=the+cat+is+on+the+mat[&near=0]   body = 16 kHz mono 16-bit little-endian PCM
#   -> {"words": ["the", "cat", ...], "unknown": [...]}   (unknown = lesson words the model can't hear)
# Vosk only listens for the line's own words (+ [unk]), so a misread word comes back as nothing
# instead of being "corrected" to the right word. Audio is never written to disk or logged.
import json, os, re, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from vosk import Model, KaldiRecognizer, SetLogLevel

SetLogLevel(-1)
MODEL = Model(os.environ.get('VOSK_MODEL', '/opt/abc-speech/model'))
# Near-miss words: listening for only the line's words would turn a misread "rat" into the expected
# "mat". So every expected word also brings its one-letter neighbours (bat, hat, ma, mats...) from
# the books' own vocabulary (vocab.txt), and a misread lands on one of those instead.
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vocab.txt')) as f:
    VOCAB = [w for w in f.read().split() if MODEL.vosk_model_find_word(w) != -1]
BY_LEN = {}
for w in VOCAB:
    BY_LEN.setdefault(len(w), []).append(w)

def neighbours(word, limit=12):
    """Same-length words with one letter changed (rat -> mat, bat). Short words (a, is, the) get none:
    their near misses (as, he) were stealing correctly read words in testing."""
    if len(word) < 3:
        return []
    near = [w for w in BY_LEN.get(len(word), ()) if w != word and sum(x != y for x, y in zip(word, w)) == 1]
    return near[:limit]
RATE = 16000
MAX_BYTES = RATE * 2 * 30   # 30 seconds

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if urllib.parse.urlparse(self.path).path in ('/', '/health'):
            self.reply(200, {'ok': True})
        else:
            self.reply(404, {'error': 'not found'})

    def do_POST(self):
        url = urllib.parse.urlparse(self.path)
        if url.path != '/recognize':
            return self.reply(404, {'error': 'not found'})
        size = int(self.headers.get('Content-Length') or 0)
        if size <= 0 or size > MAX_BYTES:
            return self.reply(413, {'error': 'audio missing or too long'})
        pcm = self.rfile.read(size)
        query = urllib.parse.parse_qs(url.query)
        raw = query.get('words', [''])[0].lower().split()
        near = query.get('near', ['1'])[0] != '0'   # near=0: only the given words (Ray's number answers)
        words = sorted({w for w in (re.sub(r"[^a-z']", '', w).strip("'") for w in raw) if w})[:300]
        known = [w for w in words if MODEL.vosk_model_find_word(w) != -1]   # words the model can hear
        grammar = set(known)
        for w in known if near else ():
            grammar.update(neighbours(w))
        rec = KaldiRecognizer(MODEL, RATE, json.dumps(sorted(grammar) + ['[unk]'])) if known else KaldiRecognizer(MODEL, RATE)
        rec.SetWords(True)
        rec.AcceptWaveform(pcm)
        result = json.loads(rec.FinalResult())
        heard = [w['word'] for w in result.get('result', []) if w['word'] != '[unk]']
        self.reply(200, {'words': heard, 'unknown': [w for w in words if w not in known]})

    def reply(self, code, obj):
        body = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        pass   # no request logs: these are kids' voices

if __name__ == '__main__':
    ThreadingHTTPServer(('0.0.0.0', 8000), Handler).serve_forever()
EOF

apply-108.sh: checks a new Caddyfile on CT 108 before swapping it in, then reloads Caddy.

cat > /root/abc300/apply-108.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host: validate /root/abc300/108_Caddyfile on CT 108, then swap it in and reload.
# The old file stays as /etc/caddy/Caddyfile.bak-abc300.
set -e
pct push 108 /root/abc300/108_Caddyfile /etc/caddy/Caddyfile.new
pct exec 108 -- caddy validate --config /etc/caddy/Caddyfile.new --adapter caddyfile
pct exec 108 -- cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak-abc300
pct exec 108 -- mv /etc/caddy/Caddyfile.new /etc/caddy/Caddyfile
pct exec 108 -- systemctl reload caddy
echo APPLIED
EOF

add-edge-routes.sh: adds the abcbooks.fyi site to CT 108 with the containers' addresses looked up live, and prints the lines for games.503mail.com/abcbooks/.

cat > /root/abc300/add-edge-routes.sh <<'EOF_OUTER1'
#!/bin/bash
# Run on the Proxmox host: adds abcbooks.fyi (+ www redirect) to CT 108's Caddyfile, with the LAN
# addresses of CT 306 (abc-web) and CT 307 (abc-speech) looked up live. Validates before swapping it
# in, keeps the old file as /etc/caddy/Caddyfile.bak-abc300, then reloads Caddy.
set -e
cd /root/abc300
WEB=$(pct exec 306 -- hostname -I | awk '{print $1}')
SPEECH=$(pct exec 307 -- hostname -I | awk '{print $1}')
pct pull 108 /etc/caddy/Caddyfile /root/abc300/108_Caddyfile
if grep -q '^abcbooks.fyi {' /root/abc300/108_Caddyfile; then
  echo "abcbooks.fyi is already in the Caddyfile, not adding it again"
else
  cat >> /root/abc300/108_Caddyfile <<EOF

# ABC Books (CT 306 abc-web, CT 307 abc-speech)
www.abcbooks.fyi {
    redir https://abcbooks.fyi{uri} permanent
}
abcbooks.fyi {
    handle_path /api/* {
        request_body {
            max_size 2MB
        }
        reverse_proxy $SPEECH:8000
    }
    handle {
        reverse_proxy $WEB:80
    }
}
EOF
fi
bash /root/abc300/apply-108.sh
echo
echo "Optional: to keep games.503mail.com/abcbooks/ working too, paste these lines inside the"
echo "games.503mail.com { } block of CT 108's Caddyfile, above its last 'handle {', then run apply-108.sh again:"
cat <<EOF
    handle_path /abcbooks/api/* {
        request_body {
            max_size 2MB
        }
        reverse_proxy $SPEECH:8000
    }
    handle /abcbooks/* {
        reverse_proxy $WEB:80
    }
    handle /abcbooks {
        redir /abcbooks/ permanent
    }
EOF
EOF_OUTER1

test-speech.sh and test-speech.py: decode one recorded Primer line ("The cat has a rat.") and ask the reading check about four different lines.

cat > /root/abc300/test-speech.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host: decodes one recorded Primer clip on CT 306 (lame), resamples it to 16 kHz
# on CT 307 and asks the reading check twice: the right line, and a line with one different word.
set -e
ID=${1:-9dd4d686e02a}
F=$(pct exec 306 -- find /var/www/abcbooks/audio/primer1881/s60 -name "$ID.mp3" | head -n 1)
pct exec 306 -- lame --quiet --decode "$F" /tmp/t.wav
pct pull 306 /tmp/t.wav /root/abc300/t.wav
pct exec 306 -- unlink /tmp/t.wav
pct push 307 /root/abc300/t.wav /tmp/t.wav
pct push 307 /root/abc300/test-speech.py /tmp/test-speech.py
pct exec 307 -- python3 /tmp/test-speech.py /tmp/t.wav "The cat has a rat." "The cat has a mat." "The dog has a rat." "A cat had the rat."
unlink /root/abc300/t.wav
EOF
cat > /root/abc300/test-speech.py <<'EOF'
# Resample a WAV to 16 kHz mono 16-bit and POST it to the local reading check once per expected line.
import array, json, sys, urllib.parse, urllib.request, wave

w = wave.open(sys.argv[1])
rate, ch = w.getframerate(), w.getnchannels()
s = array.array('h', w.readframes(w.getnframes()))
if ch == 2:
    s = array.array('h', ((s[i] + s[i + 1]) // 2 for i in range(0, len(s), 2)))
ratio = rate / 16000
out = array.array('h', (s[min(len(s) - 1, int(i * ratio))] for i in range(int(len(s) / ratio))))
for line in sys.argv[2:]:
    url = 'http://127.0.0.1:8000/recognize?words=' + urllib.parse.quote(line)
    req = urllib.request.Request(url, data=out.tobytes(), method='POST')
    print(line, '->', json.loads(urllib.request.urlopen(req).read()))
EOF

test-numbers.py: checks that the number words are all known to the model.

cat > /root/abc300/test-numbers.py <<'EOF'
# Run inside CT 307: checks that every number word Ray's Arithmetic sends is known to the model (near=0).
import json, urllib.parse, urllib.request
words = 'zero oh one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty thirty forty fifty sixty seventy eighty ninety hundred thousand and'
url = 'http://127.0.0.1:8000/recognize?near=0&words=' + urllib.parse.quote(words)
req = urllib.request.Request(url, data=b'\0\0' * 8000, method='POST')   # half a second of silence
print(json.loads(urllib.request.urlopen(req).read()))
EOF

The PC-side files (the Fire app) are in this folder too, and step 8 moves them to the PC:

AndroidManifest.xml: package com.mail503.abcbooks, Android 5.1 and newer, internet + microphone.

cat > /root/abc300/AndroidManifest.xml <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.mail503.abcbooks"
    android:versionCode="1"
    android:versionName="1.0">

    <uses-sdk android:minSdkVersion="22" android:targetSdkVersion="34" />
    <uses-permission android:name="android.permission.INTERNET" />
    <!-- the reading check records the kid reading a line (sent to our own server, never stored) -->
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

    <application
        android:label="ABC Books"
        android:icon="@mipmap/ic_launcher"
        android:allowBackup="false"
        android:theme="@style/AppTheme">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|uiMode">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
EOF

styles.xml: dark window, no title bar.

cat > /root/abc300/styles.xml <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="AppTheme" parent="android:Theme.Material.NoActionBar">
        <item name="android:windowBackground">@color/bg</item>
        <item name="android:colorPrimaryDark">@color/bg</item>
        <item name="android:statusBarColor">@color/bg</item>
        <item name="android:navigationBarColor">@color/bg</item>
    </style>
    <color name="bg">#121212</color>
</resources>
EOF

MainActivity.java: the whole app.

cat > /root/abc300/MainActivity.java <<'EOF'
package com.mail503.abcbooks;

import android.Manifest;
import android.app.Activity;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;

// ABC Books for Fire tablets: a full-screen WebView locked to ABC Books.
// Progress lives in the page's localStorage, so it stays inside this app (and is tied to HOME's domain).
public class MainActivity extends Activity {
    static final String HOME = "https://abcbooks.fyi/";   // never change: kids' progress is tied to this domain
    static final String OFFLINE = "<html><body style='background:#121212;color:#eee;font:28px sans-serif;"
        + "text-align:center;padding-top:20vh'><p>No internet right now.</p>"
        + "<p><a style='color:#8cf' href='" + HOME + "'>Try again</a></p></body></html>";
    static final int MIC = 1;

    private WebView web;
    private PermissionRequest pendingMic;   // page asked for the mic while Android was asking the user

    @Override
    protected void onCreate(Bundle state) {
        super.onCreate(state);
        web = new WebView(this);
        web.setBackgroundColor(0xFF121212);
        setContentView(web);

        WebSettings s = web.getSettings();
        s.setJavaScriptEnabled(true);
        s.setDomStorageEnabled(true);
        s.setMediaPlaybackRequiresUserGesture(false);
        s.setAllowFileAccess(false);
        s.setTextZoom(100); // ignore the tablet's font size; the page sizes its own text

        web.setWebChromeClient(new WebChromeClient() {
            @Override
            public void onPermissionRequest(PermissionRequest req) {
                // only the mic, and only for ABC Books pages
                if (!allowed(req.getOrigin().toString() + "abcbooks/") && !allowed(req.getOrigin().toString())) { req.deny(); return; }
                for (String r : req.getResources()) if (!PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(r)) { req.deny(); return; }
                if (hasMic()) { req.grant(req.getResources()); return; }
                pendingMic = req;
                requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, MIC);
            }
        });
        web.setWebViewClient(new WebViewClient() {
            @Override
            public boolean shouldOverrideUrlLoading(WebView v, String url) {
                return !allowed(url); // links that leave ABC Books do nothing
            }

            @Override
            public void onReceivedError(WebView v, WebResourceRequest req, WebResourceError err) {
                if (req.isForMainFrame()) v.loadDataWithBaseURL(HOME, OFFLINE, "text/html", "utf-8", null);
            }
        });

        if (state == null || web.restoreState(state) == null) web.loadUrl(HOME);
    }

    // abcbooks.fyi, plus the old games.503mail.com/abcbooks/ address (same files)
    static boolean allowed(String url) {
        Uri u = Uri.parse(url);
        String host = u.getHost(), path = u.getPath();
        if (!"https".equals(u.getScheme()) || host == null) return false;
        if (host.equals("abcbooks.fyi")) return true;
        return host.equals("games.503mail.com") && path != null && path.startsWith("/abcbooks/");
    }

    boolean hasMic() {
        return Build.VERSION.SDK_INT < 23 || checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
    }

    @Override
    public void onRequestPermissionsResult(int code, String[] perms, int[] results) {
        if (code != MIC || pendingMic == null) return;
        if (results.length > 0 && results[0] == PackageManager.PERMISSION_GRANTED) pendingMic.grant(pendingMic.getResources());
        else pendingMic.deny();   // the page falls back to grown-up marking
        pendingMic = null;
    }

    @Override
    protected void onSaveInstanceState(Bundle out) {
        super.onSaveInstanceState(out);
        web.saveState(out);
    }

    @Override
    public void onBackPressed() {
        if (web.canGoBack()) web.goBack(); else super.onBackPressed();
    }

    @Override
    protected void onPause() { web.onPause(); super.onPause(); }

    @Override
    protected void onResume() { super.onResume(); web.onResume(); }
}
EOF

build.ps1: builds and signs the APK without Gradle or Android Studio.

cat > /root/abc300/build.ps1 <<'EOF'
# Builds the signed ABC Books APK for Fire tablets / Amazon Appstore without Gradle.
# Run: powershell -ExecutionPolicy Bypass -File C:\Users\Dietrich\Downloads\creator_platform\ABCBooks\android\build.ps1
# Output: ABCBooks\android\out\abcbooks.apk
# Bump versionCode + versionName in AndroidManifest.xml before every Appstore update.
$ErrorActionPreference = 'Stop'
$sdk  = 'C:\Users\Dietrich\Downloads\android-sdk'
$bt   = "$sdk\build-tools\34.0.0"
$jar  = "$sdk\platforms\android-34\android.jar"
$env:JAVA_HOME = (Get-ChildItem $sdk -Directory -Filter 'jdk-17*' | Select-Object -First 1).FullName   # Temurin 17 zip unpacked here
$env:Path = "$env:JAVA_HOME\bin;$env:Path"
$here = $PSScriptRoot
$out  = "$here\out"
$key  = "$here\abcbooks-release.jks"
$pwFile = "$here\signing-password.txt"   # BACK UP the .jks + this file: Appstore updates must use the same key

Remove-Item -Recurse -Force $out -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force "$out\res","$out\classes","$out\gen" | Out-Null

# Launcher icons from the PWA icon
Add-Type -AssemblyName System.Drawing
$src = [Drawing.Image]::FromFile("$here\..\public\icon-512.png")
foreach ($p in @{mdpi=48; hdpi=72; xhdpi=96; xxhdpi=144; xxxhdpi=192}.GetEnumerator()) {
    $dir = "$here\res\mipmap-$($p.Key)"; New-Item -ItemType Directory -Force $dir | Out-Null
    $bmp = New-Object Drawing.Bitmap $p.Value, $p.Value
    $g = [Drawing.Graphics]::FromImage($bmp); $g.InterpolationMode = 'HighQualityBicubic'
    $g.DrawImage($src, 0, 0, $p.Value, $p.Value); $g.Dispose()
    $bmp.Save("$dir\ic_launcher.png", [Drawing.Imaging.ImageFormat]::Png); $bmp.Dispose()
}
$src.Dispose()

# Signing key (created once)
if (-not (Test-Path $key)) {
    $pw = -join ((48..57 + 65..90 + 97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
    Set-Content -Path $pwFile -Value $pw -Encoding ascii
    & keytool -genkeypair -keystore $key -alias abcbooks -keyalg RSA -keysize 2048 -validity 10000 `
        -storepass $pw -keypass $pw -dname "CN=Dietrich Eherenman, O=503mail, C=US"
    if ($LASTEXITCODE) { throw 'keytool failed' }
}
$pw = (Get-Content $pwFile -Raw).Trim()

& "$bt\aapt2.exe" compile --dir "$here\res" -o "$out\res.zip";                                   if ($LASTEXITCODE) { throw 'aapt2 compile' }
& "$bt\aapt2.exe" link -o "$out\base.apk" -I $jar --manifest "$here\AndroidManifest.xml" `
    --java "$out\gen" "$out\res.zip";                                                           if ($LASTEXITCODE) { throw 'aapt2 link' }
$java = @(Get-ChildItem -Recurse "$here\src","$out\gen" -Filter *.java | ForEach-Object FullName)
& javac -source 8 -target 8 -nowarn -Xlint:-options -classpath $jar -d "$out\classes" @java;    if ($LASTEXITCODE) { throw 'javac' }
$classes = @(Get-ChildItem -Recurse "$out\classes" -Filter *.class | ForEach-Object FullName)
& "$bt\d8.bat" --release --min-api 22 --lib $jar --output $out @classes;                         if ($LASTEXITCODE) { throw 'd8' }

Copy-Item "$out\base.apk" "$out\unsigned.apk"
Push-Location $out; & "$bt\aapt2.exe" version | Out-Null
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::Open("$out\unsigned.apk", 'Update')
[IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, "$out\classes.dex", 'classes.dex') | Out-Null
$zip.Dispose(); Pop-Location

& "$bt\zipalign.exe" -f -p 4 "$out\unsigned.apk" "$out\aligned.apk";                             if ($LASTEXITCODE) { throw 'zipalign' }
& "$bt\apksigner.bat" sign --ks $key --ks-key-alias abcbooks --ks-pass "pass:$pw" --key-pass "pass:$pw" `
    --out "$out\abcbooks.apk" "$out\aligned.apk";                                               if ($LASTEXITCODE) { throw 'apksigner' }
& "$bt\apksigner.bat" verify "$out\abcbooks.apk";                                               if ($LASTEXITCODE) { throw 'verify' }
Write-Host "OK: $out\abcbooks.apk ($((Get-Item "$out\abcbooks.apk").Length) bytes)"
EOF

arrange-android.ps1: puts the four app files into C:\ABCBooks\android.

cat > /root/abc300/arrange-android.ps1 <<'EOF'
# Puts the Fire app files from the rebuild folder (copied from the host) into ABCBooks\android.
# Run on the PC: powershell -ExecutionPolicy Bypass -File C:\ABCBooks-prod\arrange-android.ps1 -Project C:\ABCBooks
param([string]$Project = 'C:\ABCBooks', [string]$From = $PSScriptRoot)
$a = "$Project\android"
New-Item -ItemType Directory -Force "$a\src\com\mail503\abcbooks", "$a\res\values" | Out-Null
Copy-Item "$From\AndroidManifest.xml" "$a\AndroidManifest.xml" -Force
Copy-Item "$From\styles.xml" "$a\res\values\styles.xml" -Force
Copy-Item "$From\MainActivity.java" "$a\src\com\mail503\abcbooks\MainActivity.java" -Force
Copy-Item "$From\build.ps1" "$a\build.ps1" -Force
Get-ChildItem -Recurse $a -File | Select-Object -ExpandProperty FullName
EOF

make-vocab.ps1 (PC): builds speech\vocab.txt, the 17,628 words of all 8 books, for the near-miss words.

cat > /root/abc300/make-vocab.ps1 <<'EOF'
# Builds speech\vocab.txt: every word in every book (public\books\*.json), one per line, sorted.
# The reading check (CT 307) uses it to find near-miss words (rat -> mat, bat) so misreads are caught.
# Run on the PC: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-vocab.ps1
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$set = New-Object 'System.Collections.Generic.HashSet[string]'
foreach ($f in Get-ChildItem "$root\public\books\*.json") {
  foreach ($m in [regex]::Matches(([IO.File]::ReadAllText($f.FullName)).ToLower(), "[a-z]+(?:'[a-z]+)?")) {
    if ($m.Value.Length -ge 2 -or $m.Value -in 'a', 'i', 'o') { [void]$set.Add($m.Value) }
  }
}
[void]$set.Add('zero')   # Ray's Arithmetic number answers
$words = [string[]]$set; [Array]::Sort($words, [StringComparer]::Ordinal)
New-Item -ItemType Directory -Force "$root\speech" | Out-Null
[IO.File]::WriteAllText("$root\speech\vocab.txt", ($words -join "`n") + "`n")
"vocab.txt: $($words.Count) words"
EOF

3. Make the word list on the PC and send it to the host

The word list comes from the books, so it's made on the PC from C:\ABCBooks\public\books\*.json (built in the book posts). First copy the script over. PC (PowerShell):

scp -o BatchMode=yes proxmox:/root/abc300/make-vocab.ps1 C:\ABCBooks\tools\make-vocab.ps1
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-vocab.ps1

It prints vocab.txt: 17628 words. Send it to the host:

scp -o BatchMode=yes C:\ABCBooks\speech\vocab.txt proxmox:/root/abc300/vocab.txt

4. Create CT 306 and CT 307

Proxmox host shell. It runs as a background job (so a closed browser tab can't stop it) and writes its log to create.log; it took about 5 minutes:

systemd-run --unit abc300-create bash /root/abc300/create-300s.sh

Follow it until the last line says ABC-DONE (Ctrl+C stops following, not the job):

tail -f /root/abc300/create.log

The two lines before ABC-DONE show the containers' addresses (IP306 ..., IP307 ...). Just above them, the speech setup prints {"ok": true}.

5. Point abcbooks.fyi at them

Proxmox host shell:

bash /root/abc300/add-edge-routes.sh

It should print Valid configuration and APPLIED. If games.503mail.com/abcbooks/ should keep working too, paste the lines it prints into the games.503mail.com { } block of CT 108's /etc/caddy/Caddyfile (above its last handle {), then run bash /root/abc300/apply-108.sh again.

6. Put the site on CT 306

PC (PowerShell), from the Primer post layout. This sends the app files:

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

Then the voice clips of every book (repeat with each book id; see the book posts):

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

7. Test the reading check

Proxmox host shell:

bash /root/abc300/test-speech.sh

Healthy output, measured on 11OCT2026 (the recording says "The cat has a rat."):

The cat has a rat. -> {'words': ['cat', 'has', 'a', 'rat'], 'unknown': []}
The cat has a mat. -> {'words': ['cat', 'has'], 'unknown': []}
The dog has a rat. -> {'words': ['cat', 'has', 'a', 'rat'], 'unknown': []}
A cat had the rat. -> {'words': ['cat', 'has', 'a', 'rat'], 'unknown': []}

The right line gets 4 of 5 (the first "the" of a computer voice is easy to miss), and all three wrong lines are caught: "mat" is missing, "dog" comes back as "cat", "had" comes back as "has". From any browser, https://abcbooks.fyi/api/health should say {"ok": true}.

8. Build the Fire tablet app on the PC

8a. Tools (one time, about 600 MB in C:\Users\<you>\Downloads\android-sdk). PC (PowerShell). Make the folders:

New-Item -ItemType Directory -Force "$env:USERPROFILE\Downloads\android-sdk\dl", "$env:USERPROFILE\Downloads\android-sdk\cmdline-tools"

Download Google's Android command-line tools (154 MB):

curl.exe -L -o "$env:USERPROFILE\Downloads\android-sdk\dl\cmdline.zip" https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip

Download Java 17 (Temurin, 191 MB; the Android tools need 17 or newer):

curl.exe -L -o "$env:USERPROFILE\Downloads\android-sdk\dl\jdk17.zip" "https://api.adoptium.net/v3/binary/latest/17/ga/windows/x64/jdk/hotspot/normal/eclipse"

Unpack both:

Expand-Archive "$env:USERPROFILE\Downloads\android-sdk\dl\cmdline.zip" "$env:USERPROFILE\Downloads\android-sdk\tmp"; Move-Item "$env:USERPROFILE\Downloads\android-sdk\tmp\cmdline-tools" "$env:USERPROFILE\Downloads\android-sdk\cmdline-tools\latest"; Expand-Archive "$env:USERPROFILE\Downloads\android-sdk\dl\jdk17.zip" "$env:USERPROFILE\Downloads\android-sdk"

Install build-tools 34 and the Android 34 platform. This shows Google's Android SDK license; read it and type y to accept each one:

$env:JAVA_HOME = (Get-ChildItem "$env:USERPROFILE\Downloads\android-sdk" -Directory -Filter 'jdk-17*').FullName; & "$env:USERPROFILE\Downloads\android-sdk\cmdline-tools\latest\bin\sdkmanager.bat" "--sdk_root=$env:USERPROFILE\Downloads\android-sdk" "build-tools;34.0.0" "platforms;android-34"

8b. The app files. Copy the folder from the host to the PC, then sort the app files into the project:

scp -o BatchMode=yes -r proxmox:/root/abc300 C:\ABCBooks-prod
powershell -ExecutionPolicy Bypass -File C:\ABCBooks-prod\arrange-android.ps1 -Project C:\ABCBooks

build.ps1 expects the SDK in C:\Users\Dietrich\Downloads\android-sdk; if yours is elsewhere, change the $sdk = ... line at its top.

8c. Build. The first build also makes the signing key, abcbooks-release.jks, with a random password in signing-password.txt, next to build.ps1. Back both up. The Appstore only accepts updates signed with the same key. It takes a few seconds and ends with OK: ...abcbooks.apk (37673 bytes):

powershell -ExecutionPolicy Bypass -File C:\ABCBooks\android\build.ps1

8d. Try it on a tablet. Turn on Settings → Device Options → Developer Options → Wireless debugging on the tablet (in the grown-up profile), then find it from the PC:

C:\Users\Dietrich\Downloads\platform-tools\adb.exe mdns services

Install it only in the grown-up profile (user 0), replacing <device> with the name the last command printed (it starts with adb-):

C:\Users\Dietrich\Downloads\platform-tools\adb.exe -s <device> install --user 0 -r C:\ABCBooks\android\out\abcbooks.apk

"ABC Books" now shows at the end of the app list. The first time 🎤 is tapped, Android asks to allow the microphone; While using the app is right.

Troubleshooting: where the logs are

The reading check (CT 307)

  • Where: the abc-speech service journal in CT 307 (abc-speech).
  • Open: Proxmox host shell.
pct exec 307 -- journalctl -u abc-speech -n 50 --no-pager
  • Look for: a healthy service logs nothing at all (requests are never logged, on purpose: they're kids' voices). Started abc-speech.service after a restart is normal. A Python traceback means a bug; the request that hit it gets "connection closed". AttributeError: 'Model' object has no attribute 'find_word' is the bug from the first version (the Vosk call is vosk_model_find_word). A missing /opt/abc-speech/vocab.txt stops the service from starting.

Quick check from the host (should print {"ok": true}):

pct exec 307 -- curl -s http://127.0.0.1:8000/health

The site (CT 306)

  • Where: Caddy's journal in CT 306 (abc-web).
  • Open: Proxmox host shell.
pct exec 306 -- journalctl -u caddy -n 50 --no-pager
  • Look for: serving initial configuration after a restart is healthy. "level":"error" lines are real problems. The warning server is listening only on the HTTP port, so no automatic HTTPS is expected: CT 108 does the HTTPS.

Disk use (8 GB disk; all 8 books use about 2.7 GB):

pct exec 306 -- df -h /

The edge (CT 108)

  • Where: Caddy's journal in CT 108 (Caddy reverse proxy).
  • Open: Proxmox host shell.
pct exec 108 -- journalctl -u caddy -n 80 --no-pager
  • Look for: certificate obtained successfully for abcbooks.fyi the first time. no such host or NXDOMAIN for www.abcbooks.fyi means its DNS record is missing or wrong. A CNAME pointing at an IP address is wrong (a CNAME must point at a name); use an A record. dial tcp ... connection refused means CT 306 or CT 307 is down or changed its address (they use DHCP; re-run add-edge-routes.sh after removing the old abcbooks.fyi block).

The Fire app

  • Where: the tablet's log, read from the PC.
  • Open: PC (PowerShell), with wireless debugging on.
C:\Users\Dietrich\Downloads\platform-tools\adb.exe -s <device> logcat -d -t 200 chromium:I *:S
  • Look for: Uncaught lines are page errors. Requested internal only, but not enough space when installing means the tablet is too full (it failed with 480 MB free; Android keeps about 500 MB back). Activity class ... does not exist from am start means a kid profile is in front; the app is only in the grown-up profile.

Notes and gotchas

  • The near-miss words matter. With only the line's own words, Vosk "heard" the line perfectly even when it was wrong: a recording of "rat" checked against "mat" came back as "mat". Adding neighbours that differ by a letter added, removed or changed made it far too strict ("has" turned into "as", "the" and "a" vanished). What works: only same-length words with one letter changed, only for words of 3+ letters, at most 12 per word.
  • The small Vosk model has no word list file (no graph/words.txt), so the server asks the model for each word (vosk_model_find_word). Lesson words the model doesn't know come back in unknown, and the page counts them as read.
  • Pick the domain before kids use the app. Progress is saved per web address, so switching the app from games.503mail.com to abcbooks.fyi later would have reset everyone's stars. The app opened abcbooks.fyi before any kid used it.
  • Big fonts break the layout in the app. The grown-up profile uses 1.5x text, and the web view copied it, so book titles were cut off. setTextZoom(100) fixed it.
  • The microphone needs three things in the app: RECORD_AUDIO and MODIFY_AUDIO_SETTINGS in the manifest, Android's own permission prompt, and the web view's onPermissionRequest granting audio, but only on ABC Books pages.
  • Shared site, shared offline cache. Ray's Arithmetic lives on the same site at abcbooks.fyi/rays/. ABC Books' offline helper (sw.js) used to delete every cache that wasn't its own; it now only touches caches named abc-* and leaves /rays/ alone.
  • On the PC: adb exec-out screencap -p > file.png in PowerShell corrupts the picture (PowerShell rewrites the bytes). Save it on the tablet (screencap -p /sdcard/s.png) and adb pull it instead. Piping y into sdkmanager --licenses from PowerShell doesn't work either; run it with cmd /c ... < yes.txt, or just type y.
  • Sizes and times (11OCT2026): creating both containers with the site copy took about 5 minutes; a reading check answers in well under a second (about 0.15 s for a silent clip through the internet); the app builds in a few seconds; the speech service uses about 150 MB of RAM (CT 307 about 180 MB in all).