Drop family framing from anki-progress; add anki-deck-*.py tools

anki-progress.sh and its embedded app.py assumed a family/kids use case
(dashboard title, ntfy topic default, Authelia warning text, comments)
that was never actually stated — nothing in this repo should assume who
the accounts belong to. Retitled to plain "Anki Progress" throughout,
default ntfy topic changed from family-anki to anki-progress, and every
"family member" reference reworded to "account".

Also adds tools/anki-deck-math.py, tools/anki-deck-periodic.py, and
tools/anki-deck-visual.py — the Anki deck-generation scripts developed
earlier in this session, now committed as standalone, self-documented
tools (same tools/*.{sh,py} convention as tools/dedupe-finder.py) rather
than living only in chat. Each script's own header docstring carries the
full one-time setup (venv, genanki + piper-tts, downloading a voice) and
usage — anki-deck-periodic.py and anki-deck-visual.py point back to
anki-deck-math.py's copy rather than repeating it three times. Content is
generic (multiplication/division/addition/subtraction/fractions/decimals,
the periodic table, shapes/clocks/coin-counting) — nothing here assumes
who's using it or why.

Re-verified after the rename: the embedded app.py still passes its full
logic test suite once written out by the installer, and all three
tools/anki-deck-*.py scripts still build correct decks under
--dry-run-tts after their docstrings were rewritten.

Adds a README.md section pointing at the three scripts, and updates the
anki-progress Services table entry to drop "family" from its wording.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
This commit is contained in:
Claude
2026-09-09 19:34:48 +00:00
parent 9c7a054d97
commit 102b0405b4
5 changed files with 1248 additions and 13 deletions
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env python3
"""tools/anki-deck-math.py — Generate math-fact Anki decks (.apkg) with a
vertical/stacked problem layout, Anki's built-in type-the-answer input, and
Piper (offline, local neural TTS) audio on both the question and answer
side of every card.
Standalone content-generation tool, unrelated to this repo's services/*.sh
installers — run it on any machine with Python (your desktop, laptop, or
the same box running services/anki-sync-server.sh), then import the
resulting .apkg into Anki (File -> Import) or push it into a sync-server
account with AnkiConnect's importPackage action. See services/anki-sync-server.sh
and services/anki-progress.sh for the actual self-hosted sync backend and
progress dashboard this content is meant to be studied through.
Decks:
multiplication 1-12, all 144 ordered pairs (a x b), shuffled
(not sequential — see the note near
random.shuffle(pairs) below for why)
division inverse of the multiplication deck (144 facts)
addsub --lo L --hi H addition + subtraction fact family for [L, H]
(subtraction facts derived from the addition
facts, e.g. 7+3=10 also gives 10-7=3 and
10-3=7 — never negative results)
fractions reducing fractions to lowest terms (denominators 2-12)
decimals fraction -> decimal conversion (only denominators
whose decimal expansion terminates: 2,4,5,8,10,20,25)
Setup (one time):
python3 -m venv ~/anki-deck-venv
source ~/anki-deck-venv/bin/activate
pip install genanki piper-tts
# Download at least one voice (one time per voice you want to try —
# download_voices saves into the CURRENT directory by default, so cd
# somewhere sensible first, e.g. your home directory):
python3 -m piper.download_voices en_US-lessac-medium
# Other options: en_US-amy-medium (warm/friendly), en_US-ryan-high
# (best-quality US male), en_US-libritts_r-medium (multi-speaker),
# en_GB-alba-medium / en_GB-cori-high (British accent). Tiers are
# low < medium < high — higher sounds more natural but is bigger/slower.
# Sanity-check the voice before generating a full deck's worth of clips:
echo "three times seven" | python3 -m piper -m en_US-lessac-medium.onnx -f /tmp/test.wav
# play /tmp/test.wav and confirm it sounds right first.
Usage (run with the venv activated):
python3 anki-deck-math.py --deck multiplication
python3 anki-deck-math.py --deck division
python3 anki-deck-math.py --deck addsub --lo 3 --hi 7
python3 anki-deck-math.py --deck addsub --lo 3 --hi 13
python3 anki-deck-math.py --deck addsub --lo 2 --hi 21
python3 anki-deck-math.py --deck fractions
python3 anki-deck-math.py --deck decimals
(add --voice en_US-amy-medium etc. to any of the above to use a voice other
than the default en_US-lessac-medium; --model-path to point at a voice
file directly if it's not found in any of the usual places checked
automatically; --dry-run-tts to test the deck-building logic itself
without Piper or any voice model at all, using silent placeholder audio)
The addsub --hi 21 deck generates ~1600 audio clips and will take noticeably
longer than the others — consider `nohup python3 anki-deck-math.py --deck
addsub --lo 2 --hi 21 > addsub.log 2>&1 &` if you don't want to wait on it.
"""
import argparse
import hashlib
import genanki
import math
import os
import random
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True,
choices=["multiplication", "division", "addsub", "fractions", "decimals"])
parser.add_argument("--lo", type=int, default=None, help="addsub only: low end of range")
parser.add_argument("--hi", type=int, default=None, help="addsub only: high end of range")
parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true",
help="Skip Piper entirely and write silent placeholder audio instead"
" (for testing the deck-building logic without a voice model).")
args = parser.parse_args()
if args.deck == "addsub":
if args.lo is None or args.hi is None:
raise SystemExit("--deck addsub requires --lo and --hi, e.g. --lo 3 --hi 7")
if args.lo >= args.hi:
raise SystemExit("--lo must be less than --hi")
SCRATCH = os.path.dirname(os.path.abspath(__file__))
# ─── Voice resolution (same search order as the multiplication script) ──────
_CANDIDATES = [
args.model_path,
f"{args.voice}.onnx",
os.path.join(SCRATCH, f"{args.voice}.onnx"),
os.path.expanduser(f"~/{args.voice}.onnx"),
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
]
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
if VOICE_MODEL is None and not args.dry_run_tts:
raise SystemExit(
f"Voice model for '{args.voice}' not found. Checked:\n"
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
)
def piper_tts(text: str, out_path: str) -> None:
if args.dry_run_tts:
# 44-byte minimal valid WAV header, zero samples — enough for genanki
# to accept it as a real media file without needing Piper installed.
with open(out_path, "wb") as f:
f.write(
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
)
return
subprocess.run(
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
input=text.encode("utf-8"),
check=True,
capture_output=True,
)
# ─── Number -> words ─────────────────────────────────────────────────────────
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def num2words(n):
if n < 0:
return "negative " + num2words(-n)
if n < 20:
return ONES[n]
if n < 100:
t, o = divmod(n, 10)
return TENS[t] + ("-" + ONES[o] if o else "")
h, rem = divmod(n, 100)
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
_NUM_CHECKS = {0: "zero", 9: "nine", 10: "ten", 13: "thirteen", 20: "twenty",
21: "twenty-one", 45: "forty-five", 99: "ninety-nine",
100: "one hundred", 110: "one hundred ten",
121: "one hundred twenty-one", 144: "one hundred forty-four",
441: "four hundred forty-one"}
for _n, _w in _NUM_CHECKS.items():
assert num2words(_n) == _w, f"num2words({_n}) = {num2words(_n)!r}, expected {_w!r}"
# Ordinal words, singular form, denominators 2-21 (covers every deck below).
# Irregular forms (half, third, fifth, eighth, ninth, twelfth) are real
# English irregularities, not a suffix rule, so this is a lookup table, not
# a formula — a formula would get exactly these wrong.
ORDINAL_SINGULAR = {
2: "half", 3: "third", 4: "fourth", 5: "fifth", 6: "sixth",
7: "seventh", 8: "eighth", 9: "ninth", 10: "tenth", 11: "eleventh",
12: "twelfth", 13: "thirteenth", 14: "fourteenth", 15: "fifteenth",
16: "sixteenth", 17: "seventeenth", 18: "eighteenth", 19: "nineteenth",
20: "twentieth", 21: "twenty-first", 25: "twenty-fifth", 50: "fiftieth",
100: "hundredth",
}
def ordinal_plural(n):
s = ORDINAL_SINGULAR[n]
return "halves" if s == "half" else s + "s"
def fraction_words(num, den):
"""'three fourths', 'one half', 'seven tenths'."""
ord_word = ORDINAL_SINGULAR[den] if num == 1 else ordinal_plural(den)
return f"{num2words(num)} {ord_word}"
_FRAC_CHECKS = {
(1, 2): "one half", (3, 4): "three fourths", (1, 4): "one fourth",
(7, 10): "seven tenths", (1, 3): "one third", (2, 3): "two thirds",
(5, 8): "five eighths", (1, 8): "one eighth",
}
for (_n, _d), _w in _FRAC_CHECKS.items():
assert fraction_words(_n, _d) == _w, f"fraction_words({_n},{_d}) = {fraction_words(_n, _d)!r}, expected {_w!r}"
def decimal_words(decimal_str):
"""'0.25' -> 'zero point two five' (each digit spoken individually,
avoids any ambiguity between e.g. 'point two five' vs 'twenty-five
hundredths')."""
whole, frac = decimal_str.split(".")
digit_words = " ".join(ONES[int(d)] for d in frac)
return f"{num2words(int(whole))} point {digit_words}"
assert decimal_words("0.25") == "zero point two five"
assert decimal_words("0.5") == "zero point five"
assert decimal_words("0.375") == "zero point three seven five"
# ─── Shared genanki model builder ────────────────────────────────────────────
# Every deck here renders as two stacked lines with a line under them (same
# visual language as the original multiplication deck): TOP over BOTTOM,
# with an optional prefix (operator) on the bottom line. Fractions/decimals
# reuse the exact same layout as numerator-over-denominator.
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_600_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Math Fact ({deck_key}, {args.voice})",
fields=[{"name": "Top"}, {"name": "Bottom"}, {"name": "Answer"},
{"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="problem">
<div class="line1">{{Top}}</div>
<div class="line2">{{Bottom}}</div>
<div class="rule"></div>
</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="problem">
<div class="line1">{{Top}}</div>
<div class="line2">{{Bottom}}</div>
<div class="rule"></div>
</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 28px; text-align: center; }
.problem { display: inline-block; text-align: right; margin: 20px auto; }
.line1, .line2 { font-size: 48px; padding: 2px 10px; }
.rule { border-top: 3px solid black; margin-top: 4px; width: 100%; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_000_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, top, bottom, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[top, bottom, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
# ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_multiplication():
deck_key = "multiplication"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Multiplication Facts (1-12)")
media_files = []
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
random.seed(42)
random.shuffle(pairs)
for a, b in pairs:
ans = a * b
add_note(deck, model, str(a), f"&times; {b}", str(ans),
f"{num2words(a)} times {num2words(b)}", num2words(ans),
media_files, f"mul_{a}_{b}")
return deck, media_files, len(pairs)
def gen_division():
deck_key = "division"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Division Facts (inverse of 1-12 times tables)")
media_files = []
# Same (a, b) pairs as multiplication: product / a = b. This is the
# direct inverse of every multiplication card in that deck.
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
random.seed(43)
random.shuffle(pairs)
for a, b in pairs:
product = a * b
add_note(deck, model, str(product), f"&divide; {a}", str(b),
f"{num2words(product)} divided by {num2words(a)}", num2words(b),
media_files, f"div_{a}_{b}")
return deck, media_files, len(pairs)
def gen_addsub(lo, hi):
deck_key = f"addsub_{lo}_{hi}"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, f"Addition & Subtraction Facts ({lo}-{hi})")
media_files = []
add_pairs = [(a, b) for a in range(lo, hi + 1) for b in range(lo, hi + 1)]
random.seed(hash((lo, hi)) & 0xFFFFFFFF)
random.shuffle(add_pairs)
sub_facts = [] # (minuend, subtrahend, answer)
seen = set()
for a, b in add_pairs:
c = a + b
for minuend, subtrahend, answer in ((c, a, b), (c, b, a)):
key = (minuend, subtrahend)
if key not in seen:
seen.add(key)
sub_facts.append((minuend, subtrahend, answer))
random.shuffle(sub_facts)
count = 0
for a, b in add_pairs:
ans = a + b
add_note(deck, model, str(a), f"+ {b}", str(ans),
f"{num2words(a)} plus {num2words(b)}", num2words(ans),
media_files, f"add_{lo}_{hi}_{a}_{b}")
count += 1
for minuend, subtrahend, answer in sub_facts:
add_note(deck, model, str(minuend), f"&minus; {subtrahend}", str(answer),
f"{num2words(minuend)} minus {num2words(subtrahend)}", num2words(answer),
media_files, f"sub_{lo}_{hi}_{minuend}_{subtrahend}")
count += 1
return deck, media_files, count
def gen_fractions():
deck_key = "fractions"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Reducing Fractions to Lowest Terms")
media_files = []
facts = []
for den in range(2, 13):
for num in range(1, den):
g = math.gcd(num, den)
if g > 1:
facts.append((num, den, num // g, den // g))
random.seed(44)
random.shuffle(facts)
for num, den, rnum, rden in facts:
answer = f"{rnum}/{rden}"
add_note(deck, model, str(num), f"&frasl; {den}", answer,
fraction_words(num, den), fraction_words(rnum, rden),
media_files, f"frac_{num}_{den}")
return deck, media_files, len(facts)
def gen_decimals():
deck_key = "decimals"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Fraction to Decimal Conversion")
media_files = []
# Only denominators whose only prime factors are 2 and 5 terminate in a
# finite decimal (1/3 = 0.333... never terminates) — restricting to
# these avoids ever needing to round/repeat.
facts = []
for den in (2, 4, 5, 8, 10, 20, 25):
for num in range(1, den):
if math.gcd(num, den) != 1:
continue # skip non-lowest-terms fractions (already covered by the fractions deck)
value = num / den
decimal_str = f"{value:.10f}".rstrip("0")
if decimal_str.endswith("."):
decimal_str += "0"
facts.append((num, den, decimal_str))
random.seed(45)
random.shuffle(facts)
for num, den, decimal_str in facts:
add_note(deck, model, str(num), f"&frasl; {den}", decimal_str,
fraction_words(num, den), decimal_words(decimal_str),
media_files, f"dec_{num}_{den}")
return deck, media_files, len(facts)
# ─── Dispatch ─────────────────────────────────────────────────────────────────
if args.deck == "addsub":
deck_key = f"addsub_{args.lo}_{args.hi}"
else:
deck_key = args.deck
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {
"multiplication": lambda: gen_multiplication(),
"division": lambda: gen_division(),
"addsub": lambda: gen_addsub(args.lo, args.hi),
"fractions": lambda: gen_fractions(),
"decimals": lambda: gen_decimals(),
}
deck, media_files, count = GENERATORS[args.deck]()
if count == 0:
raise SystemExit(f"No cards generated for --deck {args.deck} — check the range/args.")
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")