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