Hola, adjunto enlaces de una imagen y un script en Python que genera mazos con audio para Anki. Me funciona perfectamente en Ubuntu 24.04.
El único problema que he tenido es que cuando se pasa del anverso al reverso, el audio del anverso desaparece. Lo he ‘solucionado’ permitiendo tener los dos audios en el reverso.
Nota: no soy programador pero si un fan de Anki por todos los años compartidos. He utilizado ChatGPT, por lo que si necesitas aprender más sobre este script, indícaselo a una Inteligencia Artificial. Un saludo desde Perillo (A Coruña), España.
#!/usr/bin/env python3
# Script que genera mazos Anki desde CSV/TXT con interfaz gráfica personalizable, audio automático multilenguaje, gestión inteligente de archivos y caché, y máxima claridad y control para el usuario avanzado
# Compatible con Python 3.8+ y Windows/macOS/Linux
# ------------------ IMPORTACIONES INICIALES ------------------
from __future__ import annotations
import importlib
import json
import os
import random
import re
import subprocess
import sys
import hashlib
import shutil
from concurrent.futures import ThreadPoolExecutor
import tkinter as tk
import tkinter.font as tkfont
from pathlib import Path
from tkinter import filedialog, ttk
from typing import Any, Dict, Optional
# ------------------ CONFIGURACIÓN GLOBAL ------------------
AUDIO_FOLDER = "Anki_Audio"
CACHE_AUDIO = ".Anki_Caché"
VENV_NAME = "Entorno_Virtual_Anki"
REQUIRED_PACKAGES = ["pandas", "gtts", "genanki"]
SEPARATORS = {
"Coma (,)": ",",
"Punto y coma (;)": ";",
"Tabulador (TAB)": "\t",
"Cambio de párrafo": "\n",
}
CONFIG_FILE: Path = Path("Anki_Configuración.json")
LOG_FILE = "Anki_Log.txt"
color_map = {
"Negro": "black", "Rojo": "red", "Azul": "blue", "Verde": "green", "Morado": "purple",
"Naranja": "orange", "Cian": "cyan", "Rosa": "pink", "Marrón": "brown", "Gris": "gray",
"Lima": "lime", "Oro": "goldenrod", "Turquesa": "turquoise", "Oliva": "olive", "Coral": "coral",
"Chocolate": "chocolate", "Indigo": "indigo", "Tomate": "tomato", "Plata": "silver",
}
# ------------------ RECURSOS Y CARPETAS ------------------
os.makedirs(AUDIO_FOLDER, exist_ok=True)
os.makedirs(CACHE_AUDIO, exist_ok=True)
# ------------------ UTILIDADES GENERALES ------------------
def _log_to_file(msg: str) -> None:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(msg + "\n")
def _load_settings() -> Dict[str, Any]:
try:
if CONFIG_FILE.exists():
with CONFIG_FILE.open(encoding="utf-8") as fh:
return json.load(fh)
except Exception as exc:
_log_to_file(f"[WARN] Configuración inválida: {exc}")
return {}
def _save_settings(data: Dict[str, Any]) -> None:
try:
with CONFIG_FILE.open("w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
except Exception as exc:
_log_to_file(f"[ERROR] No se pudo guardar la configuración: {exc}")
# ------------------ GESTIÓN DEL ENTORNO VIRTUAL ------------------
def _ensure_venv() -> None:
if sys.prefix == sys.base_prefix:
if not os.path.isdir(VENV_NAME):
print("\n[INFO] Creando entorno virtual...")
subprocess.run([sys.executable, "-m", "venv", VENV_NAME], check=True)
python_exec = (
os.path.join(VENV_NAME, "bin", "python3")
if os.name == "posix"
else os.path.join(VENV_NAME, "Scripts", "python.exe")
)
subprocess.check_call([python_exec, *sys.argv])
sys.exit()
# ------------------ COMPROBAR DEPENDENCIAS ------------------
def _check_and_install_packages() -> None:
missing = []
for package in REQUIRED_PACKAGES:
try:
importlib.import_module(package)
except ImportError:
missing.append(package)
if missing:
print("\n[INFO] Instalando paquetes faltantes:", ", ".join(missing))
subprocess.check_call([sys.executable, "-m", "pip", "install", *missing])
_ensure_venv()
_check_and_install_packages()
# ------------------ IMPORTACIONES POSTERIORES AL ENTORNO ------------------
import pandas as pd # noqa: E402
from gtts import gTTS # noqa: E402
import genanki # noqa: E402
# ------------------ FUNCIONES AUXILIARES Y DE AUDIO ------------------
def aplicar_formato(texto: Any, formato: str) -> str:
if pd.isnull(texto):
return ""
texto = str(texto).strip()
if formato == "Mayúsculas":
return texto.upper()
if formato == "Oración":
texto = texto.lower().capitalize()
texto = re.sub(
r"(?:[\/.!?¿¡:])\s*([a-z])",
lambda m: m.group(0)[:-1] + m.group(1).upper(),
texto,
)
return texto
def hash_audio(texto: str, lang: str) -> str:
h = hashlib.sha1()
h.update(f"{texto.strip()}||{lang}".encode("utf-8"))
return h.hexdigest()
def obtener_audio(texto: str, lang: str, destino: str) -> Optional[str]:
h = hash_audio(texto, lang)
cache_path = os.path.join(CACHE_AUDIO, f"{h}.mp3")
if not os.path.isfile(cache_path):
try:
gTTS(text=texto.strip(), lang=lang).save(cache_path)
except Exception as exc:
_log_to_file(f"[ERROR] Error generando audio: {exc} ({texto[:30]}...)")
return None
try:
shutil.copy(cache_path, destino)
except Exception as exc:
_log_to_file(f"[ERROR] Error copiando audio caché: {exc}")
return None
return destino
def generar_nombre_apkg(base_apkg: str) -> str:
contador = 1
nombre, extension = os.path.splitext(base_apkg)
nuevo_nombre = base_apkg
while os.path.exists(nuevo_nombre):
nuevo_nombre = f"{nombre} ({contador}){extension}"
contador += 1
return nuevo_nombre
# ------------------ INTERFAZ GRÁFICA ------------------
class AnkiGeneratorApp:
def __init__(self, master: tk.Tk) -> None:
self.master = master
self.master.title("FlashCards with Anki v4.8 @JJPV")
self.master.geometry("750x650")
self.master.configure(bg="#F0F0F0")
self.system_fonts = sorted(tkfont.families())
default_fonts = ["KoHo"] if "KoHo" in self.system_fonts else []
self.fuentes_disponibles = default_fonts + [f for f in self.system_fonts if f != "KoHo"]
self.df: Optional[pd.DataFrame] = None
self.file_path: str = ""
self.create_widgets()
self._apply_previous_settings()
# ---------- Construcción de la GUI ----------
def create_widgets(self) -> None:
global_frame = tk.Frame(self.master, bg="#F0F0F0")
global_frame.pack(pady=10)
archivo_frame = tk.Frame(global_frame, bg="#F0F0F0")
archivo_frame.pack(pady=5)
tk.Label(
archivo_frame,
text="ARCHIVO CSV/TXT:",
bg="#F0F0F0",
font=("KoHo", 12, "bold"),
).grid(row=0, column=0, sticky="w")
self.file_label = tk.Label(
archivo_frame,
text="",
bg="#F0F0F0",
font=("KoHo", 12, "bold"),
fg="blue",
)
self.file_label.grid(row=0, column=1, sticky="w", padx=5)
self.file_path_entry = tk.Entry(global_frame, width=90, font=("KoHo", 10))
self.file_path_entry.pack(pady=2)
tk.Button(
global_frame,
text="SELECCIONAR",
font=("KoHo", 12, "bold"),
bg="gray25",
fg="white",
command=self.seleccionar_archivo,
activebackground="gray25",
activeforeground="white",
).pack(pady=5)
sep_frame = tk.Frame(global_frame, bg="#F0F0F0")
sep_frame.pack(pady=3)
tk.Label(sep_frame, text="Separador de columnas:", bg="#F0F0F0").pack(side="left")
self.sep_combo = ttk.Combobox(
sep_frame, values=list(SEPARATORS.keys()), state="readonly", width=22
)
self.sep_combo.current(0)
self.sep_combo.pack(side="left", padx=5)
frame_options = tk.Frame(global_frame, bg="#F0F0F0")
frame_options.pack(pady=3)
frame_anverso = tk.LabelFrame(
frame_options,
text="ANVERSO",
font=("KoHo", 12, "bold"),
bg="#F0F0F0",
)
frame_anverso.pack(side="left", padx=20) #Modificar la separación entre cuadrados
frame_reverso = tk.LabelFrame(
frame_options,
text="REVERSO",
font=("KoHo", 12, "bold"),
bg="#F0F0F0",
)
frame_reverso.pack(side="left", padx=20) #Modificar la separación entre cuadrados
self.combo_anverso_col = self.create_dropdown(frame_anverso, "Columna:", [])
self.combo_anverso_color = self.create_dropdown(
frame_anverso, "Color:", list(color_map.keys()), default=0
)
self.combo_anverso_formato = self.create_dropdown(
frame_anverso,
"Formato:",
["Ninguno", "Oración", "Mayúsculas"],
default=0,
)
self.combo_anverso_audio = self.create_dropdown(
frame_anverso, "Audio:", ["Ninguno", "Español", "Inglés"], default=0
)
self.combo_reverso_col = self.create_dropdown(frame_reverso, "Columna:", [])
self.combo_reverso_color = self.create_dropdown(
frame_reverso, "Color:", list(color_map.keys()), default=0
)
self.combo_reverso_formato = self.create_dropdown(
frame_reverso,
"Formato:",
["Ninguno", "Oración", "Mayúsculas"],
default=0,
)
self.combo_reverso_audio = self.create_dropdown(
frame_reverso, "Audio:", ["Ninguno", "Español", "Inglés"], default=0
)
fuente_frame = tk.Frame(global_frame, bg="#F0F0F0")
fuente_frame.pack(pady=3)
tk.Label(fuente_frame, text="Fuente de tarjeta:", bg="#F0F0F0").pack(side="left")
self.fuente_combo = ttk.Combobox(
fuente_frame, values=self.fuentes_disponibles, state="readonly", width=22
)
self.fuente_combo.current(0)
self.fuente_combo.pack(side="left", padx=5)
size_frame = tk.Frame(global_frame, bg="#F0F0F0")
size_frame.pack(pady=3)
tk.Label(size_frame, text="Tamaño fuente (px):", bg="#F0F0F0").pack(side="left")
self.size_combo = ttk.Combobox(
size_frame,
values=[str(s) for s in range(20, 55, 2)],
state="readonly",
width=5,
)
self.size_combo.current(5)
self.size_combo.pack(side="left", padx=5)
self.dual_audio = tk.BooleanVar(value=False)
tk.Checkbutton(
global_frame,
text="Mostrar ambos iconos de audio en reverso",
variable=self.dual_audio,
bg="#F0F0F0",
).pack(pady=3)
tk.Button(
global_frame,
text="GENERAR MAZO",
font=("KoHo", 12, "bold"),
bg="#4CAF50",
fg="white",
command=self.procesar,
activebackground="#4CAF50",
activeforeground="white",
).pack(pady=5)
self.progress = ttk.Progressbar(
global_frame, orient="horizontal", length=725, mode="determinate" # Barra de tiempo
)
style = ttk.Style()
style.theme_use("default")
style.configure("TProgressbar", troughcolor="#F0F0F0", background="#4CAF50")
self.progress.pack(pady=5)
self.log_text = tk.Text(
global_frame, height=10, width=90, font=("KoHo", 10) # Dimensiones del cajetín de logs
)
self.log_text.pack(pady=3)
# ---------- Combos y controles auxiliares ----------
def create_dropdown(
self,
parent: tk.Misc,
label: str,
values: list[str],
*,
default: Optional[int] = None,
) -> ttk.Combobox:
frame = tk.Frame(parent, bg="#F0F0F0")
frame.pack(anchor="w", pady=2)
tk.Label(frame, text=label, bg="#F0F0F0").pack(side="left")
combo = ttk.Combobox(frame, values=values, state="readonly", width=17)
combo.pack(side="left", padx=5)
if default is not None:
combo.current(default)
return combo
# ---------- Área de logs en la interfaz ----------
def log(self, mensaje: str) -> None:
self.log_text.insert(tk.END, mensaje + "\n")
self.log_text.see(tk.END)
self.master.update_idletasks()
_log_to_file(mensaje)
# ---------- Preferencias previas ----------
def _apply_previous_settings(self) -> None:
cfg = _load_settings()
if not cfg:
return
fichero = cfg.get("file_path")
if fichero and Path(fichero).is_file():
self.file_path = fichero
self.file_label.config(text=os.path.basename(fichero))
self.file_path_entry.insert(0, fichero)
self.sep_combo.set(cfg.get("separator", self.sep_combo.get()))
self.leer_columnas()
def _safe_set(combo: ttk.Combobox, key: str) -> None:
if key in cfg and cfg[key] in combo["values"]:
combo.set(cfg[key])
_safe_set(self.combo_anverso_col, "anverso_col")
_safe_set(self.combo_reverso_col, "reverso_col")
_safe_set(self.combo_anverso_color, "anverso_color")
_safe_set(self.combo_reverso_color, "reverso_color")
_safe_set(self.combo_anverso_formato, "anverso_fmt")
_safe_set(self.combo_reverso_formato, "reverso_fmt")
_safe_set(self.combo_anverso_audio, "anverso_audio")
_safe_set(self.combo_reverso_audio, "reverso_audio")
_safe_set(self.fuente_combo, "font")
_safe_set(self.size_combo, "font_size")
self.dual_audio.set(cfg.get("dual_audio", self.dual_audio.get()))
# ---------- Acciones de usuario ----------
def seleccionar_archivo(self) -> None:
path = filedialog.askopenfilename(
filetypes=[("Archivos CSV/TXT", "*.csv *.txt")]
)
if path:
self.file_path = path
self.file_label.config(text=os.path.basename(path))
self.file_path_entry.delete(0, tk.END)
self.file_path_entry.insert(0, path)
self.leer_columnas()
def leer_columnas(self) -> None:
try:
sep = SEPARATORS[self.sep_combo.get()]
self.df = pd.read_csv(self.file_path, sep=sep, encoding="utf-8")
columnas = self.df.columns.tolist()
if len(columnas) < 2:
self.log("El archivo debe tener al menos dos columnas, escoge el separador de columnas adecuado")
return
for combo in (self.combo_anverso_col, self.combo_reverso_col):
combo["values"] = columnas
self.combo_anverso_col.current(0)
self.combo_reverso_col.current(1 if len(columnas) > 1 else 0)
self.log(
f"Archivo {os.path.basename(self.file_path)} cargado correctamente - Total tarjetas: {len(self.df)}"
)
except Exception as exc:
self.log(f"Error leyendo el archivo: {exc}")
# ---------- Lógica principal y generación de tarjetas ----------
def procesar(self) -> None:
# Limpia audios previos para evitar residuos y solapes
for f in os.listdir(AUDIO_FOLDER):
if f.endswith(".mp3"):
os.remove(os.path.join(AUDIO_FOLDER, f))
if self.df is None or self.df.empty:
self.log("Debe seleccionar primero un archivo válido")
return
# Parámetros seleccionados
col_a = self.combo_anverso_col.get()
col_r = self.combo_reverso_col.get()
color_a = color_map[self.combo_anverso_color.get()]
color_r = color_map[self.combo_reverso_color.get()]
fmt_a = self.combo_anverso_formato.get()
fmt_r = self.combo_reverso_formato.get()
aud_a = self.combo_anverso_audio.get()
aud_r = self.combo_reverso_audio.get()
fuente = self.fuente_combo.get()
tamano = self.size_combo.get()
# ---- Guardar preferencias ----
_save_settings(
{
"file_path": self.file_path,
"separator": self.sep_combo.get(),
"anverso_col": col_a,
"reverso_col": col_r,
"anverso_color": self.combo_anverso_color.get(),
"reverso_color": self.combo_reverso_color.get(),
"anverso_fmt": fmt_a,
"reverso_fmt": fmt_r,
"anverso_audio": aud_a,
"reverso_audio": aud_r,
"font": fuente,
"font_size": tamano,
"dual_audio": self.dual_audio.get(),
}
)
base_name = os.path.splitext(os.path.basename(self.file_path))[0]
output_apkg = f"{base_name}.apkg"
output_apkg = generar_nombre_apkg(output_apkg)
if output_apkg != f"{base_name}.apkg":
self.log(f"Aviso: El fichero '{base_name}.apkg' ya existía. El mazo se guardará como '{output_apkg}'.")
deck_id = random.randrange(1 << 30, 1 << 31)
model_id = random.randrange(1 << 30, 1 << 31)
# Plantillas (simples, sin condicional mustache)
if self.dual_audio.get():
afmt_template = "{{AnversoTexto}}<hr>{{ReversoTexto}}<br>{{AudioAnverso}}{{AudioReverso}}"
else:
afmt_template = "{{AnversoTexto}}<hr>{{ReversoTexto}}<br>{{AudioReverso}}"
qfmt_template = "{{AnversoTexto}}<br>{{AudioAnverso}}"
my_model = genanki.Model(
model_id,
"Modelo Flashcards Profesional",
fields=[
{"name": "AnversoTexto"},
{"name": "AudioAnverso"},
{"name": "ReversoTexto"},
{"name": "AudioReverso"},
],
templates=[
{
"name": "Tarjeta completa",
"qfmt": qfmt_template,
"afmt": afmt_template,
}
],
)
my_deck = genanki.Deck(deck_id, f"Mazo generado desde {base_name}")
media_files: list[str] = []
total = len(self.df)
self.progress["maximum"] = total
self.log("Procesando tarjetas... (caché y concurrencia robustas)")
tarjetas = []
audio_tasks = []
with ThreadPoolExecutor(max_workers=6) as executor:
for idx, row in self.df.iterrows():
texto_a = aplicar_formato(row[col_a], fmt_a)
texto_r = aplicar_formato(row[col_r], fmt_r)
html_a = (
f"<div style='font-family:{fuente}; font-size:{tamano}px; color:{color_a};'>{texto_a}</div>"
)
html_r = (
f"<div style='font-family:{fuente}; font-size:{tamano}px; color:{color_r};'>{texto_r}</div>"
)
tarjetas.append({"idx": idx, "html_a": html_a, "html_r": html_r})
if aud_a != "Ninguno" and texto_a:
lang_a = "es" if aud_a == "Español" else "en"
filename_a = f"{AUDIO_FOLDER}/front_{idx}.mp3"
audio_tasks.append((executor.submit(obtener_audio, texto_a, lang_a, filename_a), "a", idx, filename_a))
if aud_r != "Ninguno" and texto_r:
lang_r = "es" if aud_r == "Español" else "en"
filename_r = f"{AUDIO_FOLDER}/back_{idx}.mp3"
audio_tasks.append((executor.submit(obtener_audio, texto_r, lang_r, filename_r), "r", idx, filename_r))
audios_ready = {}
for fut, lado, idx, path in audio_tasks:
result = fut.result()
if result:
audios_ready[(lado, idx)] = f"[sound:{os.path.basename(path)}]"
for tarjeta in tarjetas:
idx = tarjeta["idx"]
html_a = tarjeta["html_a"]
html_r = tarjeta["html_r"]
audio_a_ref = audios_ready.get(("a", idx), "")
audio_r_ref = audios_ready.get(("r", idx), "")
note = genanki.Note(
model=my_model,
fields=[html_a, audio_a_ref, html_r, audio_r_ref],
)
my_deck.add_note(note)
self.progress["value"] = idx + 1
self.log(f"Procesada tarjeta {idx + 1}/{total}")
if audio_a_ref:
media_files.append(f"{AUDIO_FOLDER}/front_{idx}.mp3")
if audio_r_ref:
media_files.append(f"{AUDIO_FOLDER}/back_{idx}.mp3")
genanki.Package(my_deck, media_files).write_to_file(output_apkg)
self.log(f"Mazo generado: {output_apkg}")
self.progress["value"] = 0
# ------------------ EJECUCIÓN PRINCIPAL ------------------
if __name__ == "__main__":
root = tk.Tk()
app = AnkiGeneratorApp(root)
root.mainloop()