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:
@@ -0,0 +1,408 @@
|
||||
#!/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": """
|
||||
<div class="imgwrap">{{Image}}</div>
|
||||
{{QSound}}
|
||||
{{type:Answer}}
|
||||
""",
|
||||
"afmt": """
|
||||
<div class="imgwrap">{{Image}}</div>
|
||||
<hr id="answer">
|
||||
{{type:Answer}}
|
||||
{{ASound}}
|
||||
""",
|
||||
}],
|
||||
css="""
|
||||
.card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; }
|
||||
.imgwrap { margin: 10px auto; }
|
||||
.imgwrap img { max-width: 260px; max-height: 260px; }
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def build_deck(deck_key, deck_title):
|
||||
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
|
||||
deck_id = 2_200_000_000 + (voice_hash % 90_000_000)
|
||||
return genanki.Deck(deck_id, deck_title)
|
||||
|
||||
|
||||
def add_note(deck, model, image_html, 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=[image_html, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
|
||||
))
|
||||
|
||||
|
||||
def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
|
||||
"""For directions that don't need an image (e.g. name -> number of sides)."""
|
||||
add_note(deck, model, f'<div style="font-size:36px;">{text}</div>', answer,
|
||||
qtext, atext, media_files, tag)
|
||||
|
||||
|
||||
# ─── SVG generation ───────────────────────────────────────────────────────────
|
||||
def save_svg(svg_body, filename, viewbox="0 0 200 200"):
|
||||
path = os.path.join(MEDIA_DIR, filename)
|
||||
with open(path, "w") as f:
|
||||
f.write(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{viewbox}" '
|
||||
f'width="200" height="200">{svg_body}</svg>'
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
|
||||
points = []
|
||||
# Start pointing up (-90deg) so shapes sit "upright" rather than vertex-right.
|
||||
start_angle = -90
|
||||
for i in range(n_sides):
|
||||
angle_deg = start_angle + i * (360 / n_sides)
|
||||
angle_rad = math.radians(angle_deg)
|
||||
x = cx + r * math.cos(angle_rad)
|
||||
y = cy + r * math.sin(angle_rad)
|
||||
points.append((round(x, 1), round(y, 1)))
|
||||
return points
|
||||
|
||||
|
||||
def polygon_svg(points):
|
||||
pts_str = " ".join(f"{x},{y}" for x, y in points)
|
||||
return f'<polygon points="{pts_str}" fill="#6fa8dc" stroke="#1c4587" stroke-width="4"/>'
|
||||
|
||||
|
||||
POLYGON_NAMES = {
|
||||
3: "triangle", 4: "square", 5: "pentagon", 6: "hexagon", 7: "heptagon",
|
||||
8: "octagon", 9: "nonagon", 10: "decagon",
|
||||
}
|
||||
|
||||
QUADRILATERALS = {
|
||||
"square": [(50, 50), (150, 50), (150, 150), (50, 150)],
|
||||
"rectangle": [(30, 60), (170, 60), (170, 140), (30, 140)],
|
||||
"rhombus": [(100, 20), (170, 100), (100, 180), (30, 100)],
|
||||
"trapezoid": [(60, 60), (140, 60), (170, 140), (30, 140)],
|
||||
"parallelogram": [(60, 60), (160, 60), (140, 140), (40, 140)],
|
||||
}
|
||||
|
||||
|
||||
def clock_svg(hour, minute):
|
||||
cx, cy, r = 100, 100, 90
|
||||
minute_angle = minute * 6 - 90
|
||||
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
|
||||
|
||||
def hand(angle_deg, length, width, color):
|
||||
rad = math.radians(angle_deg)
|
||||
x2 = cx + length * math.cos(rad)
|
||||
y2 = cy + length * math.sin(rad)
|
||||
return f'<line x1="{cx}" y1="{cy}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{color}" stroke-width="{width}" stroke-linecap="round"/>'
|
||||
|
||||
ticks = []
|
||||
numerals = []
|
||||
for h in range(1, 13):
|
||||
angle = math.radians(h * 30 - 90)
|
||||
tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle)
|
||||
tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle)
|
||||
ticks.append(f'<line x1="{tx1:.1f}" y1="{ty1:.1f}" x2="{tx2:.1f}" y2="{ty2:.1f}" stroke="black" stroke-width="2"/>')
|
||||
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
|
||||
numerals.append(f'<text x="{nx:.1f}" y="{ny:.1f}" font-size="14" text-anchor="middle" dominant-baseline="middle">{h}</text>')
|
||||
|
||||
body = (
|
||||
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="white" stroke="black" stroke-width="3"/>'
|
||||
+ "".join(ticks) + "".join(numerals)
|
||||
+ hand(hour_angle, 45, 6, "black")
|
||||
+ hand(minute_angle, 70, 4, "black")
|
||||
+ f'<circle cx="{cx}" cy="{cy}" r="4" fill="black"/>'
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
COIN_INFO = {5: ("#c0c0c0", "5¢"), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
|
||||
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
|
||||
|
||||
|
||||
def coins_svg(coin_values):
|
||||
n = len(coin_values)
|
||||
spacing = 200 // (n + 1)
|
||||
parts = []
|
||||
for i, v in enumerate(coin_values):
|
||||
cx = spacing * (i + 1)
|
||||
color, label = COIN_INFO[v]
|
||||
radius = 30 if v == 25 else (26 if v == 10 else 28)
|
||||
parts.append(
|
||||
f'<circle cx="{cx}" cy="100" r="{radius}" fill="{color}" stroke="#444" stroke-width="2"/>'
|
||||
f'<text x="{cx}" y="105" font-size="14" text-anchor="middle">{label}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def coin_list_words(coin_values):
|
||||
names = [COIN_NAMES[v] for v in coin_values]
|
||||
if len(names) == 1:
|
||||
return f"a {names[0]}"
|
||||
if len(names) == 2:
|
||||
return f"a {names[0]} and a {names[1]}"
|
||||
return ", ".join(f"a {n}" for n in names[:-1]) + f", and a {names[-1]}"
|
||||
|
||||
|
||||
# ─── Per-deck generators ─────────────────────────────────────────────────────
|
||||
def gen_shapes():
|
||||
deck_key = "shapes"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals")
|
||||
media_files = []
|
||||
|
||||
jobs = []
|
||||
for n in range(3, 11):
|
||||
jobs.append(("polygon_image", n))
|
||||
jobs.append(("polygon_sides", n))
|
||||
for qname in QUADRILATERALS:
|
||||
jobs.append(("quad_image", qname))
|
||||
random.seed(60)
|
||||
random.shuffle(jobs)
|
||||
|
||||
for kind, val in jobs:
|
||||
if kind == "polygon_image":
|
||||
n = val
|
||||
name = POLYGON_NAMES[n]
|
||||
svg_path = save_svg(polygon_svg(regular_polygon_points(n)), f"poly_{n}.svg")
|
||||
media_files.append(svg_path)
|
||||
add_note(deck, model, f'<img src="poly_{n}.svg">', name,
|
||||
"What shape is this?", name, media_files, f"shape_img_{n}")
|
||||
elif kind == "polygon_sides":
|
||||
n = val
|
||||
name = POLYGON_NAMES[n]
|
||||
add_text_note(deck, model, name.capitalize(), str(n),
|
||||
f"How many sides does a {name} have?", num2words(n),
|
||||
media_files, f"shape_sides_{n}")
|
||||
else:
|
||||
qname = val
|
||||
svg_path = save_svg(polygon_svg(QUADRILATERALS[qname]), f"quad_{qname}.svg")
|
||||
media_files.append(svg_path)
|
||||
add_note(deck, model, f'<img src="quad_{qname}.svg">', qname,
|
||||
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
|
||||
return deck, media_files, len(jobs)
|
||||
|
||||
|
||||
def gen_clocks():
|
||||
deck_key = "clocks"
|
||||
model_id, model = build_model(deck_key)
|
||||
deck = build_deck(deck_key, "Telling Time: Analog Clocks")
|
||||
media_files = []
|
||||
|
||||
times = [(h, m) for h in range(1, 13) for m in range(0, 60, 5)]
|
||||
random.seed(61)
|
||||
random.shuffle(times)
|
||||
|
||||
# The question prompt ("What time is it?") is identical for every card —
|
||||
# generate it once instead of 144 times.
|
||||
shared_qfile = "q_clock_prompt.wav"
|
||||
piper_tts("What time is it?", os.path.join(MEDIA_DIR, shared_qfile))
|
||||
media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
|
||||
|
||||
for hour, minute in times:
|
||||
svg_path = save_svg(clock_svg(hour, minute), f"clock_{hour}_{minute:02d}.svg")
|
||||
media_files.append(svg_path)
|
||||
answer = f"{hour}:{minute:02d}"
|
||||
afile = f"a_clock_{hour}_{minute:02d}.wav"
|
||||
apath = os.path.join(MEDIA_DIR, afile)
|
||||
piper_tts(time_words(hour, minute), apath)
|
||||
media_files.append(apath)
|
||||
deck.add_note(genanki.Note(
|
||||
model=model,
|
||||
fields=[f'<img src="clock_{hour}_{minute:02d}.svg">', answer,
|
||||
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
|
||||
))
|
||||
return deck, media_files, len(times)
|
||||
|
||||
|
||||
def gen_currency():
|
||||
deck_key = "currency"
|
||||
model_id, model = build_model(deck_key)
|
||||
# Deliberately nickel/dime/quarter only, no pennies — pennies are barely
|
||||
# used day to day at this point, and skipping them keeps every total a
|
||||
# multiple of 5 cents, which is a cleaner first pass at coin counting.
|
||||
deck = build_deck(deck_key, "Counting Coins (nickels, dimes, quarters)")
|
||||
media_files = []
|
||||
denoms = [5, 10, 25]
|
||||
|
||||
combos = set()
|
||||
for count in range(1, 5):
|
||||
def rec(remaining, current):
|
||||
if remaining == 0:
|
||||
combos.add(tuple(sorted(current)))
|
||||
return
|
||||
for d in denoms:
|
||||
if not current or d >= current[-1]:
|
||||
rec(remaining - 1, current + [d])
|
||||
rec(count, [])
|
||||
combos = sorted(combos)
|
||||
random.seed(62)
|
||||
random.shuffle(combos)
|
||||
|
||||
for coin_values in combos:
|
||||
total = sum(coin_values)
|
||||
svg_path = save_svg(coins_svg(list(coin_values)), f"coins_{'_'.join(map(str, coin_values))}.svg")
|
||||
media_files.append(svg_path)
|
||||
qtext = f"How much money is {coin_list_words(list(coin_values))}?"
|
||||
atext = f"{num2words(total)} cents"
|
||||
add_note(deck, model, f'<img src="coins_{"_".join(map(str, coin_values))}.svg">',
|
||||
str(total), qtext, atext, media_files, f"coins_{'_'.join(map(str, coin_values))}")
|
||||
return deck, media_files, len(combos)
|
||||
|
||||
|
||||
if args.deck == "shapes":
|
||||
deck_key = "shapes"
|
||||
elif args.deck == "clocks":
|
||||
deck_key = "clocks"
|
||||
else:
|
||||
deck_key = "currency"
|
||||
|
||||
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
|
||||
GENERATORS = {"shapes": gen_shapes, "clocks": gen_clocks, "currency": gen_currency}
|
||||
deck, media_files, count = GENERATORS[args.deck]()
|
||||
|
||||
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)} media files)")
|
||||
Reference in New Issue
Block a user