#!/usr/bin/env python3
"""tools/anki-deck-visual.py — Generate image-based Anki decks (.apkg) for
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
input and Piper (offline, local neural TTS) audio. See
tools/anki-deck-math.py's docstring for one-time setup (venv, genanki +
piper-tts, downloading a voice) — same steps apply here.
All images are drawn programmatically as SVG (regular-polygon geometry,
clock-hand trigonometry, coin layouts) rather than AI-generated — image
generation (local or cloud) is a poor fit for content that has to be
exactly correct (an exact clock time, an exact side count, an exact coin
total), not just plausible-looking. See this script's own point/angle
generation functions for how each shape's geometry is computed directly
rather than approximated.
Decks:
shapes regular polygons (3-10 sides, image->name and name->sides)
plus 5 quadrilateral types (image->name: square, rectangle,
rhombus, trapezoid, parallelogram — each one's geometry is
genuinely distinct, not just differently labeled)
clocks analog clock faces, all 144 hour/5-min combinations,
type the time as H:MM (the hour hand moves fractionally
with the minutes, e.g. 6:30 sits halfway between 6 and 7 —
a static hour hand is the most common "looks right but
teaches wrong" bug in generated clock faces)
currency US coins (nickel/dime/quarter — no pennies, since they're
barely used day to day at this point), 1-4 coins per card,
type the total in cents
Usage (run with the venv from anki-deck-math.py's docstring activated):
python3 anki-deck-visual.py --deck shapes
python3 anki-deck-visual.py --deck clocks
python3 anki-deck-visual.py --deck currency
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
automatically; --dry-run-tts to test the deck-building logic without any
voice model at all, using silent placeholder audio)
"""
import argparse
import hashlib
import genanki
import math
import os
import random
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True, choices=["shapes", "clocks", "currency"])
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")
args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__))
_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:
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,
)
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 < 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 "")
assert num2words(15) == "fifteen"
assert num2words(40) == "forty"
def time_words(hour, minute):
"""3, 5 -> 'three oh five'; 3, 15 -> 'three fifteen'; 3, 0 -> 'three o'clock'."""
if minute == 0:
return f"{num2words(hour)} o'clock"
if minute < 10:
return f"{num2words(hour)} oh {num2words(minute)}"
return f"{num2words(hour)} {num2words(minute)}"
assert time_words(3, 0) == "three o'clock"
assert time_words(3, 5) == "three oh five"
assert time_words(3, 15) == "three fifteen"
assert time_words(12, 45) == "twelve forty-five"
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_800_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Visual Fact ({deck_key}, {args.voice})",
fields=[{"name": "Image"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """