#!/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": """