#!/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": """
{{Top}}
{{Bottom}}
{{QSound}} {{type:Answer}} """, "afmt": """
{{Top}}
{{Bottom}}

{{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"× {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"÷ {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"− {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"⁄ {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"⁄ {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)")