#!/usr/bin/env python3 """tools/anki-deck-periodic.py — Generate periodic table Anki decks (.apkg) with Anki's built-in type-the-answer input and Piper (offline, local neural TTS) audio on both sides. See tools/anki-deck-math.py's docstring for one-time setup (venv, genanki + piper-tts, downloading a voice) — same steps apply here, this is a standalone, self-contained script otherwise. Decks: prehs symbol<->name, elements 1-36 (H through Kr) hs symbol<->name plus number->symbol, all 118 elements category element category as multiple choice (A/B/C/D shown as plain text options — not a clickable UI, since that needs a desktop-only Anki add-on and would break on AnkiDroid/AnkiMobile), type the letter — only elements with a confirmed category (excludes 8 very recent superheavy elements whose category is still officially unconfirmed) Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively maintained public dataset), fetched and spot-checked against known facts before being embedded below — not typed from memory. Usage (run with the venv from anki-deck-math.py's docstring activated): python3 anki-deck-periodic.py --deck prehs python3 anki-deck-periodic.py --deck hs python3 anki-deck-periodic.py --deck category (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 os import random import subprocess parser = argparse.ArgumentParser() parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"]) 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(1) == "one" assert num2words(26) == "twenty-six" assert num2words(118) == "one hundred eighteen" # ─── Element data: (atomic_number, symbol, name, category-or-None) ───────── # category is None for the 8 most recently synthesized superheavy elements # whose chemical category is still officially unconfirmed (excluded from # the category deck below, still included in prehs/hs symbol/name/number). ELEMENTS = [ (1, 'H', 'Hydrogen', 'diatomic nonmetal'), (2, 'He', 'Helium', 'noble gas'), (3, 'Li', 'Lithium', 'alkali metal'), (4, 'Be', 'Beryllium', 'alkaline earth metal'), (5, 'B', 'Boron', 'metalloid'), (6, 'C', 'Carbon', 'polyatomic nonmetal'), (7, 'N', 'Nitrogen', 'diatomic nonmetal'), (8, 'O', 'Oxygen', 'diatomic nonmetal'), (9, 'F', 'Fluorine', 'diatomic nonmetal'), (10, 'Ne', 'Neon', 'noble gas'), (11, 'Na', 'Sodium', 'alkali metal'), (12, 'Mg', 'Magnesium', 'alkaline earth metal'), (13, 'Al', 'Aluminium', 'post-transition metal'), (14, 'Si', 'Silicon', 'metalloid'), (15, 'P', 'Phosphorus', 'polyatomic nonmetal'), (16, 'S', 'Sulfur', 'polyatomic nonmetal'), (17, 'Cl', 'Chlorine', 'diatomic nonmetal'), (18, 'Ar', 'Argon', 'noble gas'), (19, 'K', 'Potassium', 'alkali metal'), (20, 'Ca', 'Calcium', 'alkaline earth metal'), (21, 'Sc', 'Scandium', 'transition metal'), (22, 'Ti', 'Titanium', 'transition metal'), (23, 'V', 'Vanadium', 'transition metal'), (24, 'Cr', 'Chromium', 'transition metal'), (25, 'Mn', 'Manganese', 'transition metal'), (26, 'Fe', 'Iron', 'transition metal'), (27, 'Co', 'Cobalt', 'transition metal'), (28, 'Ni', 'Nickel', 'transition metal'), (29, 'Cu', 'Copper', 'transition metal'), (30, 'Zn', 'Zinc', 'transition metal'), (31, 'Ga', 'Gallium', 'post-transition metal'), (32, 'Ge', 'Germanium', 'metalloid'), (33, 'As', 'Arsenic', 'metalloid'), (34, 'Se', 'Selenium', 'polyatomic nonmetal'), (35, 'Br', 'Bromine', 'diatomic nonmetal'), (36, 'Kr', 'Krypton', 'noble gas'), (37, 'Rb', 'Rubidium', 'alkali metal'), (38, 'Sr', 'Strontium', 'alkaline earth metal'), (39, 'Y', 'Yttrium', 'transition metal'), (40, 'Zr', 'Zirconium', 'transition metal'), (41, 'Nb', 'Niobium', 'transition metal'), (42, 'Mo', 'Molybdenum', 'transition metal'), (43, 'Tc', 'Technetium', 'transition metal'), (44, 'Ru', 'Ruthenium', 'transition metal'), (45, 'Rh', 'Rhodium', 'transition metal'), (46, 'Pd', 'Palladium', 'transition metal'), (47, 'Ag', 'Silver', 'transition metal'), (48, 'Cd', 'Cadmium', 'transition metal'), (49, 'In', 'Indium', 'post-transition metal'), (50, 'Sn', 'Tin', 'post-transition metal'), (51, 'Sb', 'Antimony', 'metalloid'), (52, 'Te', 'Tellurium', 'metalloid'), (53, 'I', 'Iodine', 'diatomic nonmetal'), (54, 'Xe', 'Xenon', 'noble gas'), (55, 'Cs', 'Cesium', 'alkali metal'), (56, 'Ba', 'Barium', 'alkaline earth metal'), (57, 'La', 'Lanthanum', 'lanthanide'), (58, 'Ce', 'Cerium', 'lanthanide'), (59, 'Pr', 'Praseodymium', 'lanthanide'), (60, 'Nd', 'Neodymium', 'lanthanide'), (61, 'Pm', 'Promethium', 'lanthanide'), (62, 'Sm', 'Samarium', 'lanthanide'), (63, 'Eu', 'Europium', 'lanthanide'), (64, 'Gd', 'Gadolinium', 'lanthanide'), (65, 'Tb', 'Terbium', 'lanthanide'), (66, 'Dy', 'Dysprosium', 'lanthanide'), (67, 'Ho', 'Holmium', 'lanthanide'), (68, 'Er', 'Erbium', 'lanthanide'), (69, 'Tm', 'Thulium', 'lanthanide'), (70, 'Yb', 'Ytterbium', 'lanthanide'), (71, 'Lu', 'Lutetium', 'lanthanide'), (72, 'Hf', 'Hafnium', 'transition metal'), (73, 'Ta', 'Tantalum', 'transition metal'), (74, 'W', 'Tungsten', 'transition metal'), (75, 'Re', 'Rhenium', 'transition metal'), (76, 'Os', 'Osmium', 'transition metal'), (77, 'Ir', 'Iridium', 'transition metal'), (78, 'Pt', 'Platinum', 'transition metal'), (79, 'Au', 'Gold', 'transition metal'), (80, 'Hg', 'Mercury', 'transition metal'), (81, 'Tl', 'Thallium', 'post-transition metal'), (82, 'Pb', 'Lead', 'post-transition metal'), (83, 'Bi', 'Bismuth', 'post-transition metal'), (84, 'Po', 'Polonium', 'post-transition metal'), (85, 'At', 'Astatine', 'diatomic nonmetal'), (86, 'Rn', 'Radon', 'noble gas'), (87, 'Fr', 'Francium', 'alkali metal'), (88, 'Ra', 'Radium', 'alkaline earth metal'), (89, 'Ac', 'Actinium', 'actinide'), (90, 'Th', 'Thorium', 'actinide'), (91, 'Pa', 'Protactinium', 'actinide'), (92, 'U', 'Uranium', 'actinide'), (93, 'Np', 'Neptunium', 'actinide'), (94, 'Pu', 'Plutonium', 'actinide'), (95, 'Am', 'Americium', 'actinide'), (96, 'Cm', 'Curium', 'actinide'), (97, 'Bk', 'Berkelium', 'actinide'), (98, 'Cf', 'Californium', 'actinide'), (99, 'Es', 'Einsteinium', 'actinide'), (100, 'Fm', 'Fermium', 'actinide'), (101, 'Md', 'Mendelevium', 'actinide'), (102, 'No', 'Nobelium', 'actinide'), (103, 'Lr', 'Lawrencium', 'actinide'), (104, 'Rf', 'Rutherfordium', 'transition metal'), (105, 'Db', 'Dubnium', 'transition metal'), (106, 'Sg', 'Seaborgium', 'transition metal'), (107, 'Bh', 'Bohrium', 'transition metal'), (108, 'Hs', 'Hassium', 'transition metal'), (109, 'Mt', 'Meitnerium', None), (110, 'Ds', 'Darmstadtium', None), (111, 'Rg', 'Roentgenium', None), (112, 'Cn', 'Copernicium', None), (113, 'Nh', 'Nihonium', 'post-transition metal'), (114, 'Fl', 'Flerovium', 'post-transition metal'), (115, 'Mc', 'Moscovium', None), (116, 'Lv', 'Livermorium', None), (117, 'Ts', 'Tennessine', None), (118, 'Og', 'Oganesson', None), ] assert len(ELEMENTS) == 118 assert [e[0] for e in ELEMENTS] == list(range(1, 119)) assert ELEMENTS[0] == (1, 'H', 'Hydrogen', 'diatomic nonmetal') assert ELEMENTS[25] == (26, 'Fe', 'Iron', 'transition metal') assert ELEMENTS[-1] == (118, 'Og', 'Oganesson', None) ALL_CATEGORIES = sorted({e[3] for e in ELEMENTS if e[3] is not None}) def build_model(deck_key): voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16) model_id = 1_700_000_000 + (voice_hash % 90_000_000) return model_id, genanki.Model( model_id, f"Periodic Table ({deck_key}, {args.voice})", fields=[{"name": "Prompt"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}], templates=[{ "name": "Card", "qfmt": """
{{Prompt}}
{{QSound}} {{type:Answer}} """, "afmt": """
{{Prompt}}

{{type:Answer}} {{ASound}} """, }], css=""" .card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; } .prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; } """, ) def build_deck(deck_key, deck_title): voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16) deck_id = 2_100_000_000 + (voice_hash % 90_000_000) return genanki.Deck(deck_id, deck_title) def add_note(deck, model, prompt, 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=[prompt, answer, f"[sound:{qfile}]", f"[sound:{afile}]"], )) def gen_prehs(): deck_key = "periodic_prehs" model_id, model = build_model(deck_key) deck = build_deck(deck_key, "Periodic Table: Symbols & Names (1-36)") media_files = [] subset = [e for e in ELEMENTS if e[0] <= 36] cards = [] for number, symbol, name, category in subset: cards.append(("symbol_to_name", number, symbol, name)) cards.append(("name_to_symbol", number, symbol, name)) random.seed(50) random.shuffle(cards) for kind, number, symbol, name in cards: if kind == "symbol_to_name": add_note(deck, model, symbol, name, f"What element has the symbol {symbol}?", name, media_files, f"prehs_s2n_{number}") else: add_note(deck, model, name, symbol, f"What is the symbol for {name}?", symbol, media_files, f"prehs_n2s_{number}") return deck, media_files, len(cards) def gen_hs(): deck_key = "periodic_hs" model_id, model = build_model(deck_key) deck = build_deck(deck_key, "Periodic Table: Symbols, Names & Numbers (1-118)") media_files = [] cards = [] for number, symbol, name, category in ELEMENTS: cards.append(("symbol_to_name", number, symbol, name)) cards.append(("name_to_symbol", number, symbol, name)) cards.append(("number_to_symbol", number, symbol, name)) random.seed(51) random.shuffle(cards) for kind, number, symbol, name in cards: if kind == "symbol_to_name": add_note(deck, model, symbol, name, f"What element has the symbol {symbol}?", name, media_files, f"hs_s2n_{number}") elif kind == "name_to_symbol": add_note(deck, model, name, symbol, f"What is the symbol for {name}?", symbol, media_files, f"hs_n2s_{number}") else: add_note(deck, model, f"Element #{number}", symbol, f"What is the symbol for element number {num2words(number)}?", symbol, media_files, f"hs_num2s_{number}") return deck, media_files, len(cards) def gen_category(): deck_key = "periodic_category" model_id, model = build_model(deck_key) deck = build_deck(deck_key, "Periodic Table: Element Categories (multiple choice)") media_files = [] subset = [e for e in ELEMENTS if e[3] is not None] random.seed(52) shuffled = subset[:] random.shuffle(shuffled) letters = ["A", "B", "C", "D"] for number, symbol, name, category in shuffled: distractor_pool = [c for c in ALL_CATEGORIES if c != category] distractors = random.sample(distractor_pool, 3) choices = distractors + [category] random.shuffle(choices) correct_letter = letters[choices.index(category)] prompt_lines = [f"{name} ({symbol})", ""] for letter, choice in zip(letters, choices): prompt_lines.append(f"{letter}) {choice}") prompt = "\n".join(prompt_lines) qtext = f"What category is {name}?" atext = f"{category}" add_note(deck, model, prompt, correct_letter, qtext, atext, media_files, f"cat_{number}") return deck, media_files, len(subset) if args.deck == "prehs": deck_key = "periodic_prehs" elif args.deck == "hs": deck_key = "periodic_hs" else: deck_key = "periodic_category" MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}") os.makedirs(MEDIA_DIR, exist_ok=True) GENERATORS = {"prehs": gen_prehs, "hs": gen_hs, "category": gen_category} 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)} audio clips)")