139_spanish-voice-trainer/main.py

1071 lines
47 KiB
Python
Raw Normal View History

2026-06-12 11:58:47 +00:00
# main.py
2026-06-14 09:56:43 +00:00
import sys
import os
2026-06-19 05:30:59 +00:00
import random
import hashlib
2026-06-20 01:55:05 +00:00
import subprocess
import json
import asyncio
import shutil
2026-06-14 09:56:43 +00:00
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox,
2026-06-19 05:09:05 +00:00
QTableWidget, QTableWidgetItem, QSlider, QFormLayout, QTextEdit, QFrame, QMessageBox, QFileDialog
2026-06-14 09:56:43 +00:00
)
from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtGui import QFont
2026-06-20 01:55:05 +00:00
# Third-Party Tooling
2026-06-19 05:30:59 +00:00
import genanki
2026-06-20 01:55:05 +00:00
import edge_tts
from PIL import Image, ImageDraw, ImageFont
2026-06-19 05:30:59 +00:00
# Internal Project Module Imports
2026-06-14 09:56:43 +00:00
from database.connection import init_db, get_connection
2026-06-13 07:02:06 +00:00
from core.bulk_importer import BulkImporter
2026-06-13 09:59:02 +00:00
from core.clean_glossary import GlossaryCleaner
2026-06-12 11:58:47 +00:00
2026-06-14 09:56:43 +00:00
class SpanishTrainerApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Castilian Voice Trainer Pro")
2026-06-20 01:55:05 +00:00
self.setMinimumSize(1200, 800)
2026-06-14 09:56:43 +00:00
# 1. Initialize schema structures and check ingestion status
2026-06-14 09:56:43 +00:00
self.ensure_database_populated()
2026-06-20 01:55:05 +00:00
# Load system persistent settings from DB (including sleep-learning fields)
2026-06-19 05:09:05 +00:00
self.load_system_settings()
# Audio Player Architecture Setup
2026-06-14 09:56:43 +00:00
self.media_player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output)
2026-06-19 03:42:28 +00:00
2026-06-21 13:54:17 +00:00
# Flashcard Core State Variables
self.current_flashcard_id = None # Tracks the translation_id currently being reviewed
self.current_card_is_flipped = False # False = Front, True = Back
self.current_active_es_text = "" # Caches active Spanish string
self.current_active_en_text = "" # Caches active English string
self.flashcard_ids_pool = [] # Tracks currently filtered list of translation_ids
2026-06-20 01:55:05 +00:00
self.current_sandbox_es_id = None
self.current_sandbox_en_id = None
2026-06-14 09:56:43 +00:00
# Central Main Window Tabs Interface
2026-06-14 09:56:43 +00:00
self.tabs = QTabWidget()
self.setCentralWidget(self.tabs)
self.init_phrase_sandbox_tab()
self.init_flashcard_reviewer_tab()
2026-06-19 05:30:59 +00:00
self.init_settings_tab()
2026-06-14 09:56:43 +00:00
2026-06-19 05:09:05 +00:00
# 2. Populate table grids on initialization
2026-06-14 09:56:43 +00:00
self.refresh_crud_table()
2026-06-19 03:42:28 +00:00
self.refresh_review_table()
2026-06-14 09:56:43 +00:00
def ensure_database_populated(self):
"""Forces database configuration structure and triggers pipeline execution if empty."""
print("🗄️ Verification Pass: Running schema configuration scripts...")
2026-06-14 09:56:43 +00:00
init_db()
conn = get_connection()
cursor = conn.cursor()
2026-06-20 01:55:05 +00:00
# Ensure our settings table and key columns are structurally sound
2026-06-19 05:09:05 +00:00
cursor.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);")
2026-06-20 01:55:05 +00:00
# Seamlessly inject duration column into phrases if it doesn't exist
cursor.execute("PRAGMA table_info(phrases);")
columns = [row[1] for row in cursor.fetchall()]
if "duration" not in columns:
print("🔄 Modifying phrases schema to support floating-point duration tracking...")
cursor.execute("ALTER TABLE phrases ADD COLUMN duration REAL;")
conn.commit()
2026-06-14 09:56:43 +00:00
try:
cursor.execute("SELECT COUNT(*) FROM translations")
2026-06-14 09:56:43 +00:00
count = cursor.fetchone()[0]
print(f"📊 Current Translation Pairs found in database: {count}")
except Exception as e:
print(f"⚠️ Table check encountered an issue (likely empty tables): {e}")
2026-06-14 09:56:43 +00:00
count = 0
finally:
conn.close()
2026-06-14 09:56:43 +00:00
if count == 0:
print("🗄️ Database tables are empty. Triggering glossary reader pipeline...")
2026-06-14 09:56:43 +00:00
pdf_file = "aula_int_plus_1_glos_en_alfa.pdf"
if os.path.exists(pdf_file):
importer = BulkImporter()
importer.import_pdf_glossary(pdf_file, "Aula Internacional Plus 1")
2026-06-14 09:56:43 +00:00
cleaner = GlossaryCleaner()
cleaner.process_database_clean()
print("✨ Ingestion pipeline processing sequence successfully completed.")
2026-06-14 09:56:43 +00:00
else:
print(f"❌ Error: Source document '{pdf_file}' is missing from the directory root.")
2026-06-14 09:56:43 +00:00
2026-06-19 05:09:05 +00:00
def load_system_settings(self):
2026-06-20 01:55:05 +00:00
"""Loads persistent variables from the key-value settings table."""
2026-06-19 05:09:05 +00:00
self.anki_export_dir = os.getcwd()
self.video_export_dir = os.getcwd()
2026-06-20 01:55:05 +00:00
self.video_first_lang = "English First (en -> es)"
self.video_repeats_count = "3"
self.video_pause_duration = "4.0"
2026-06-19 05:09:05 +00:00
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("SELECT key, value FROM settings")
rows = cursor.fetchall()
for row in rows:
if row[0] == "anki_export_directory":
self.anki_export_dir = row[1]
elif row[0] == "video_export_directory":
self.video_export_dir = row[1]
2026-06-20 01:55:05 +00:00
elif row[0] == "video_first_language":
self.video_first_lang = row[1]
elif row[0] == "video_repeats_count":
self.video_repeats_count = row[1]
elif row[0] == "video_pause_duration":
self.video_pause_duration = row[1]
2026-06-19 05:09:05 +00:00
except Exception as e:
print(f"⚠️ Failed to read application settings from database: {e}")
finally:
conn.close()
def save_setting_to_db(self, key, value):
"""Updates or inserts a specific system runtime variable into the database."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value))
conn.commit()
2026-06-20 01:55:05 +00:00
if key == "video_first_language":
self.video_first_lang = value
elif key == "video_repeats_count":
self.video_repeats_count = value
elif key == "video_pause_duration":
self.video_pause_duration = value
2026-06-19 05:09:05 +00:00
except Exception as e:
print(f"❌ Critical: Failed to save setting '{key}': {e}")
finally:
conn.close()
2026-06-20 01:55:05 +00:00
def get_or_generate_audio_duration(self, phrase_id, text_str, lang):
"""
Ensures a target audio track exists on disk, reads its run length
via ffprobe, caches the duration field inside SQLite, and returns the float timing block.
"""
2026-06-21 13:54:17 +00:00
if not text_str.strip():
2026-06-20 01:55:05 +00:00
return 2.5
safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
2026-06-21 13:54:17 +00:00
if not safe_name:
safe_name = hashlib.sha256(text_str.encode('utf-8')).hexdigest()[:16]
2026-06-20 01:55:05 +00:00
os.makedirs("media", exist_ok=True)
target_file = f"media/{safe_name}_{lang}_female.mp3"
if not os.path.exists(target_file):
try:
voice = "es-ES-ElviraNeural" if lang == "es" else "en-GB-SoniaNeural"
communicate = edge_tts.Communicate(text_str, voice)
asyncio.run(communicate.save(target_file))
except Exception as tts_err:
print(f"❌ Core TTS System Exception: {tts_err}")
return 2.5
2026-06-21 13:54:17 +00:00
if phrase_id:
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT duration FROM phrases WHERE id = ?", (phrase_id,))
cached_row = cursor.fetchone()
if cached_row and cached_row[0] is not None:
conn.close()
return float(cached_row[0])
2026-06-20 01:55:05 +00:00
try:
cmd = [
'ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_entries', 'format=duration', target_file
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
data = json.loads(result.stdout)
duration = float(data['format']['duration'])
2026-06-21 13:54:17 +00:00
if phrase_id:
cursor.execute("UPDATE phrases SET duration = ? WHERE id = ?", (duration, phrase_id))
conn.commit()
2026-06-20 01:55:05 +00:00
except Exception as e:
print(f"⚠️ Track structure analysis warning for {target_file}: {e}")
duration = 2.5
finally:
2026-06-21 13:54:17 +00:00
if phrase_id and 'conn' in locals() and conn:
conn.close()
2026-06-20 01:55:05 +00:00
return duration
2026-06-14 09:56:43 +00:00
# =====================================================================
# 🗄️ TAB 1: TRANSLATION-CENTRIC PHRASE SANDBOX (CRUD)
2026-06-14 09:56:43 +00:00
# =====================================================================
def init_phrase_sandbox_tab(self):
tab = QWidget()
layout = QHBoxLayout(tab)
left_panel = QVBoxLayout()
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("🔍 Text Filter:"))
self.search_text_input = QLineEdit()
self.search_text_input.setPlaceholderText("Search Spanish or English text blocks...")
self.search_text_input.textChanged.connect(self.refresh_crud_table)
filter_layout.addWidget(self.search_text_input)
2026-06-14 09:56:43 +00:00
filter_layout.addWidget(QLabel("📂 Context:"))
self.search_context_input = QLineEdit()
2026-06-19 03:42:28 +00:00
self.search_context_input.setPlaceholderText("e.g. U2")
self.search_context_input.setMaximumWidth(130)
self.search_context_input.textChanged.connect(self.refresh_crud_table)
filter_layout.addWidget(self.search_context_input)
left_panel.addLayout(filter_layout)
self.translation_table = QTableWidget()
self.translation_table.setColumnCount(6)
self.translation_table.setHorizontalHeaderLabels([
"TX ID", "Spanish Phrase", "English Translation", "Type", "Source Context", "Deck Assignment"
])
self.translation_table.itemSelectionChanged.connect(self.handle_table_row_select)
left_panel.addWidget(self.translation_table)
nav_layout = QHBoxLayout()
self.btn_row_up = QPushButton("🔼 Previous Pair")
self.btn_row_down = QPushButton("🔽 Next Pair")
self.btn_row_up.clicked.connect(lambda: self.step_table_row(-1))
self.btn_row_down.clicked.connect(lambda: self.step_table_row(1))
nav_layout.addWidget(self.btn_row_up)
nav_layout.addWidget(self.btn_row_down)
left_panel.addLayout(nav_layout)
2026-06-14 09:56:43 +00:00
right_panel = QVBoxLayout()
form_frame = QFrame()
form_frame.setFrameShape(QFrame.Shape.StyledPanel)
form_layout = QFormLayout(form_frame)
self.input_tx_id = QLineEdit()
self.input_tx_id.setReadOnly(True)
self.input_tx_id.setPlaceholderText("Auto-Increment ID")
2026-06-14 09:56:43 +00:00
self.input_text_es = QTextEdit()
self.input_text_es.setMaximumHeight(75)
2026-06-21 13:54:17 +00:00
self.input_text_es.textChanged.connect(self.clear_id_if_new_entry)
2026-06-14 09:56:43 +00:00
self.input_text_en = QTextEdit()
self.input_text_en.setMaximumHeight(75)
2026-06-21 13:54:17 +00:00
self.input_text_en.textChanged.connect(self.clear_id_if_new_entry)
2026-06-14 09:56:43 +00:00
self.combo_type = QComboBox()
self.combo_type.addItems(["phrase", "sentence", "noun", "verb", "adjective"])
2026-06-14 09:56:43 +00:00
self.input_context = QLineEdit()
self.input_context.setPlaceholderText("e.g., U8_5A")
2026-06-14 09:56:43 +00:00
2026-06-18 12:47:29 +00:00
self.input_tags = QLineEdit()
2026-06-19 03:42:28 +00:00
self.input_tags.setPlaceholderText("e.g., irregular_er boots_verb")
2026-06-18 12:47:29 +00:00
2026-06-14 09:56:43 +00:00
self.input_deck_tag = QLineEdit()
self.input_deck_tag.setPlaceholderText("Anki Sub-deck Hierarchy")
2026-06-14 09:56:43 +00:00
button_qss = """
QPushButton {
background-color: #f0f0f0;
border: 1px solid #c0c0c0;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
color: #333333;
}
QPushButton:hover {
background-color: #e0e0e0;
border: 1px solid #a0a0a0;
}
QPushButton:pressed {
background-color: #d0d0d0;
}
"""
es_header_layout = QHBoxLayout()
es_header_layout.setContentsMargins(0, 5, 0, 5)
es_header_layout.addWidget(QLabel("<b>🇪🇸 Castilian Spanish Text Element:</b>"))
self.btn_play_sandbox_es = QPushButton("Play 🔊")
self.btn_play_sandbox_es.setFixedWidth(75)
self.btn_play_sandbox_es.setFixedHeight(24)
self.btn_play_sandbox_es.setStyleSheet(button_qss)
self.btn_play_sandbox_es.clicked.connect(self.handle_sandbox_play_es)
es_header_layout.addWidget(self.btn_play_sandbox_es)
self.combo_speed_es = QComboBox()
self.combo_speed_es.addItems(["0.50x", "0.75x", "1.00x", "1.25x", "1.50x"])
self.combo_speed_es.setCurrentText("1.00x")
self.combo_speed_es.setFixedWidth(70)
self.combo_speed_es.setFixedHeight(24)
es_header_layout.addWidget(self.combo_speed_es)
es_header_layout.addStretch()
en_header_layout = QHBoxLayout()
en_header_layout.setContentsMargins(0, 5, 0, 5)
en_header_layout.addWidget(QLabel("<b>🇬🇧 English Target Translation:</b>"))
self.btn_play_sandbox_en = QPushButton("Play 🔊")
self.btn_play_sandbox_en.setFixedWidth(75)
self.btn_play_sandbox_en.setFixedHeight(24)
self.btn_play_sandbox_en.setStyleSheet(button_qss)
self.btn_play_sandbox_en.clicked.connect(self.handle_sandbox_play_en)
en_header_layout.addWidget(self.btn_play_sandbox_en)
self.combo_speed_en = QComboBox()
self.combo_speed_en.addItems(["0.50x", "0.75x", "1.00x", "1.25x", "1.50x"])
self.combo_speed_en.setCurrentText("1.00x")
self.combo_speed_en.setFixedWidth(70)
self.combo_speed_en.setFixedHeight(24)
en_header_layout.addWidget(self.combo_speed_en)
en_header_layout.addStretch()
2026-06-18 12:23:47 +00:00
grammar_header_layout = QHBoxLayout()
grammar_header_layout.setContentsMargins(0, 5, 0, 5)
grammar_header_layout.addWidget(QLabel("<b>📝 Grammar / Usage Note:</b>"))
grammar_header_layout.addStretch()
self.input_grammar_note = QTextEdit()
self.input_grammar_note.setMaximumHeight(75)
2026-06-19 03:42:28 +00:00
self.input_grammar_note.setPlaceholderText("e.g., feminine variant...")
form_layout.addRow("<b>Translation Link ID:</b>", self.input_tx_id)
form_layout.addRow(es_header_layout)
form_layout.addRow(self.input_text_es)
form_layout.addRow(en_header_layout)
form_layout.addRow(self.input_text_en)
2026-06-18 12:23:47 +00:00
form_layout.addRow(grammar_header_layout)
form_layout.addRow(self.input_grammar_note)
form_layout.addRow("Classification Profile:", self.combo_type)
form_layout.addRow("Source Context ID (Raw):", self.input_context)
2026-06-18 12:47:29 +00:00
form_layout.addRow("Anki Note Tags:", self.input_tags)
form_layout.addRow("<b>Target Deck Scope:</b>", self.input_deck_tag)
2026-06-14 09:56:43 +00:00
crud_buttons = QHBoxLayout()
self.btn_save = QPushButton(" Create Pair")
self.btn_update = QPushButton("💾 Update Node")
self.btn_delete = QPushButton("🗑️ Sever Link")
2026-06-14 09:56:43 +00:00
self.btn_save.clicked.connect(self.crud_create_pair)
self.btn_update.clicked.connect(self.crud_update_pair)
self.btn_delete.clicked.connect(self.crud_delete_pair)
2026-06-14 09:56:43 +00:00
crud_buttons.addWidget(self.btn_save)
crud_buttons.addWidget(self.btn_update)
crud_buttons.addWidget(self.btn_delete)
right_panel.addWidget(QLabel("<h3>Translation Node Management Matrix</h3>"))
2026-06-14 09:56:43 +00:00
right_panel.addWidget(form_frame)
right_panel.addLayout(crud_buttons)
right_panel.addStretch()
layout.addLayout(left_panel, stretch=4)
layout.addLayout(right_panel, stretch=3)
2026-06-14 09:56:43 +00:00
self.tabs.addTab(tab, "🗄️ Phrase Sandbox (CRUD)")
# =====================================================================
2026-06-19 05:09:05 +00:00
# 🃏 TAB 2: FLASHCARD STUDY MODULE
2026-06-14 09:56:43 +00:00
# =====================================================================
def init_flashcard_reviewer_tab(self):
tab = QWidget()
2026-06-19 03:42:28 +00:00
layout = QHBoxLayout(tab)
2026-06-14 09:56:43 +00:00
2026-06-19 03:42:28 +00:00
left_panel = QVBoxLayout()
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("📂 Context:"))
self.review_context_filter = QLineEdit()
self.review_context_filter.setPlaceholderText("Filter Context...")
self.review_context_filter.textChanged.connect(self.refresh_review_table)
filter_layout.addWidget(self.review_context_filter)
filter_layout.addWidget(QLabel("🏷️ Tag:"))
self.review_tag_filter = QLineEdit()
self.review_tag_filter.setPlaceholderText("Filter Tag...")
self.review_tag_filter.textChanged.connect(self.refresh_review_table)
filter_layout.addWidget(self.review_tag_filter)
left_panel.addLayout(filter_layout)
self.review_table = QTableWidget()
self.review_table.setColumnCount(4)
self.review_table.setHorizontalHeaderLabels(["Tx ID", "Spanish Phrase", "Context", "Tags"])
self.review_table.itemSelectionChanged.connect(self.handle_review_table_select)
left_panel.addWidget(self.review_table)
layout.addLayout(left_panel, stretch=4)
right_panel = QVBoxLayout()
2026-06-14 09:56:43 +00:00
card_frame = QFrame()
card_frame.setStyleSheet("background-color: #ffffff; border: 2px solid #bdc3c7; border-radius: 12px;")
card_layout = QVBoxLayout(card_frame)
2026-06-19 03:42:28 +00:00
card_frame.setMinimumHeight(280)
2026-06-14 09:56:43 +00:00
2026-06-19 03:42:28 +00:00
self.lbl_card_text = QLabel("Select a row or click 'Next Card' to initiate...")
2026-06-14 09:56:43 +00:00
self.lbl_card_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
2026-06-19 03:42:28 +00:00
self.lbl_card_text.setFont(QFont("Arial", 20, QFont.Weight.Bold))
2026-06-14 09:56:43 +00:00
self.lbl_card_text.setWordWrap(True)
2026-06-19 03:42:28 +00:00
self.lbl_card_text.setStyleSheet("color: #2c3e50; border: none; padding: 20px;")
2026-06-14 09:56:43 +00:00
self.lbl_card_meta = QLabel("")
self.lbl_card_meta.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_card_meta.setFont(QFont("Arial", 11))
self.lbl_card_meta.setStyleSheet("color: #7f8c8d; border: none;")
card_layout.addStretch()
card_layout.addWidget(self.lbl_card_text)
card_layout.addWidget(self.lbl_card_meta)
card_layout.addStretch()
2026-06-19 03:42:28 +00:00
right_panel.addWidget(card_frame, stretch=4)
2026-06-14 09:56:43 +00:00
playback_layout = QHBoxLayout()
2026-06-19 03:42:28 +00:00
playback_layout.addWidget(QLabel("🔊 Voice Speed:"))
2026-06-14 09:56:43 +00:00
self.slider_review_speed = QSlider(Qt.Orientation.Horizontal)
self.slider_review_speed.setMinimum(50)
self.slider_review_speed.setMaximum(150)
self.slider_review_speed.setValue(100)
2026-06-19 03:42:28 +00:00
self.lbl_review_speed = QLabel("1.00x")
2026-06-14 09:56:43 +00:00
self.slider_review_speed.valueChanged.connect(self.handle_live_speed_change)
playback_layout.addWidget(self.slider_review_speed)
playback_layout.addWidget(self.lbl_review_speed)
2026-06-19 03:42:28 +00:00
right_panel.addLayout(playback_layout)
2026-06-14 09:56:43 +00:00
action_buttons = QHBoxLayout()
self.btn_play_voice = QPushButton("🗣️ Play Voice Track")
2026-06-21 13:54:17 +00:00
self.btn_flip_card = QPushButton("👁️ Reveal Translation")
2026-06-14 09:56:43 +00:00
self.btn_play_voice.clicked.connect(self.handle_play_voice)
self.btn_flip_card.clicked.connect(self.handle_flip_card)
action_buttons.addWidget(self.btn_play_voice)
action_buttons.addWidget(self.btn_flip_card)
2026-06-19 03:42:28 +00:00
right_panel.addLayout(action_buttons)
2026-06-14 09:56:43 +00:00
2026-06-19 03:42:28 +00:00
right_panel.addSpacing(15)
2026-06-14 09:56:43 +00:00
2026-06-19 03:42:28 +00:00
bottom_utility_layout = QHBoxLayout()
self.btn_export_anki = QPushButton("📦 Export Anki Deck")
self.btn_export_video = QPushButton("🎬 Export Video")
self.btn_load_next = QPushButton("➡️ Next Card")
self.btn_export_anki.clicked.connect(self.handle_export_anki_deck)
self.btn_export_video.clicked.connect(self.handle_export_video_assets)
self.btn_load_next.clicked.connect(self.handle_load_next_card)
utility_qss = "QPushButton { font-weight: bold; background-color: #eaf2f8; padding: 6px; border-radius: 4px; }"
self.btn_export_anki.setStyleSheet(utility_qss)
self.btn_export_video.setStyleSheet(utility_qss)
self.btn_load_next.setStyleSheet("QPushButton { font-weight: bold; background-color: #d5f5e3; padding: 6px; border-radius: 4px; }")
bottom_utility_layout.addWidget(self.btn_export_anki)
bottom_utility_layout.addWidget(self.btn_export_video)
bottom_utility_layout.addStretch()
bottom_utility_layout.addWidget(self.btn_load_next)
right_panel.addLayout(bottom_utility_layout)
layout.addLayout(right_panel, stretch=3)
self.tabs.addTab(tab, "🃏 Flashcard Review")
2026-06-14 09:56:43 +00:00
2026-06-19 05:09:05 +00:00
# =====================================================================
# ⚙️ TAB 3: SYSTEM HARDWARE & EXPORT SETTINGS
# =====================================================================
def init_settings_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
settings_frame = QFrame()
settings_frame.setFrameShape(QFrame.Shape.StyledPanel)
form_layout = QFormLayout(settings_frame)
anki_layout = QHBoxLayout()
self.line_anki_dir = QLineEdit(self.anki_export_dir)
self.line_anki_dir.setReadOnly(True)
btn_browse_anki = QPushButton("Browse 📂")
btn_browse_anki.clicked.connect(self.handle_browse_anki_directory)
anki_layout.addWidget(self.line_anki_dir)
anki_layout.addWidget(btn_browse_anki)
video_layout = QHBoxLayout()
self.line_video_dir = QLineEdit(self.video_export_dir)
self.line_video_dir.setReadOnly(True)
btn_browse_video = QPushButton("Browse 📂")
btn_browse_video.clicked.connect(self.handle_browse_video_directory)
video_layout.addWidget(self.line_video_dir)
video_layout.addWidget(btn_browse_video)
2026-06-20 01:55:05 +00:00
self.combo_first_lang = QComboBox()
self.combo_first_lang.addItems(["English First (en -> es)", "Spanish First (es -> en)"])
self.combo_first_lang.setCurrentText(self.video_first_lang)
self.combo_first_lang.currentTextChanged.connect(lambda v: self.save_setting_to_db("video_first_language", v))
self.spin_video_repeats = QLineEdit(self.video_repeats_count)
self.spin_video_repeats.setFixedWidth(60)
self.spin_video_repeats.textChanged.connect(lambda v: self.save_setting_to_db("video_repeats_count", v))
self.spin_pause_duration = QLineEdit(self.video_pause_duration)
self.spin_pause_duration.setFixedWidth(60)
self.spin_pause_duration.textChanged.connect(lambda v: self.save_setting_to_db("video_pause_duration", v))
2026-06-19 05:09:05 +00:00
form_layout.addRow("<b>Anki Deck Export Destination:</b>", anki_layout)
form_layout.addRow("<b>Video Assembly Output Target:</b>", video_layout)
2026-06-20 01:55:05 +00:00
form_layout.addRow("<b>Introductory Anchor Audio Language:</b>", self.combo_first_lang)
form_layout.addRow("<b>Target Translation Loop Multiplier (Repeats):</b>", self.spin_video_repeats)
form_layout.addRow("<b>User Recall Repetition Frame Intermission (Seconds):</b>", self.spin_pause_duration)
self.btn_sync_cache = QPushButton("⚡ Populate Audio & Timings Cache")
self.btn_sync_cache.setStyleSheet("""
QPushButton {
font-weight: bold;
background-color: #e67e22;
color: white;
padding: 10px;
border-radius: 5px;
font-size: 13px;
}
QPushButton:hover { background-color: #d35400; }
""")
self.btn_sync_cache.clicked.connect(self.handle_bulk_populate_audio_cache)
2026-06-19 05:09:05 +00:00
layout.addWidget(QLabel("<h2>Application Preferences & Workspace Routing</h2>"))
layout.addWidget(settings_frame)
2026-06-20 01:55:05 +00:00
layout.addWidget(self.btn_sync_cache)
2026-06-19 05:09:05 +00:00
layout.addStretch()
self.tabs.addTab(tab, "⚙️ Settings")
def handle_browse_anki_directory(self):
directory = QFileDialog.getExistingDirectory(self, "Select Anki Export Folder", self.anki_export_dir)
if directory:
self.anki_export_dir = directory
self.line_anki_dir.setText(directory)
self.save_setting_to_db("anki_export_directory", directory)
def handle_browse_video_directory(self):
directory = QFileDialog.getExistingDirectory(self, "Select Video Export Folder", self.video_export_dir)
if directory:
self.video_export_dir = directory
self.line_video_dir.setText(directory)
self.save_setting_to_db("video_export_directory", directory)
2026-06-20 01:55:05 +00:00
def handle_bulk_populate_audio_cache(self):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT p1.id, p1.text, p2.id, p2.text
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
""")
records = cursor.fetchall()
conn.close()
if not records:
QMessageBox.information(self, "Cache Synchronizer", "No valid translation pairs exist inside the database to process.")
return
print(f"⚡ Processing structural cache updates for {len(records)} node linkages...")
for row in records:
es_id, es_text, en_id, en_text = row[0], row[1].strip(), row[2], row[3].strip()
self.get_or_generate_audio_duration(en_id, en_text, "en")
self.get_or_generate_audio_duration(es_id, es_text, "es")
QMessageBox.information(self, "Cache Processing Complete", "All missing speech segments successfully written. Timings cached safely.")
2026-06-21 13:54:17 +00:00
# =====================================================================
# 💡 FLASHCARD OPERATIONS LOGIC COUPLING
# =====================================================================
def handle_live_speed_change(self):
val = self.slider_review_speed.value()
self.lbl_review_speed.setText(f"{val / 100:.2f}x")
def load_flashcard_by_id(self, translation_id):
"""Loads a translation node into memory and targets local text widgets without notes clutter."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.translation_id, p1.text, p2.text, p1.source_context, t.tags, t.notes
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE t.translation_id = ?
""", (translation_id,))
record = cursor.fetchone()
conn.close()
if record:
self.current_flashcard_id = record[0]
self.current_active_es_text = str(record[1])
self.current_active_en_text = str(record[2])
self.current_card_is_flipped = False
self.lbl_card_text.setText(self.current_active_en_text)
self.btn_flip_card.setText("👁️ Reveal Translation")
# --- t.notes (record[5]) has been intentionally omitted from this layout context ---
meta_str = f"Link ID: {record[0]} | Context: {record[3] or 'N/A'}"
if record[4]:
meta_str += f" | Tags: {record[4]}"
self.lbl_card_meta.setText(meta_str)
self.handle_play_voice()
def handle_flip_card(self):
if not self.current_flashcard_id:
return
if not self.current_card_is_flipped:
self.lbl_card_text.setText(self.current_active_es_text)
self.btn_flip_card.setText("👁️ Return to Prompt")
self.current_card_is_flipped = True
else:
self.lbl_card_text.setText(self.current_active_en_text)
self.btn_flip_card.setText("👁️ Reveal Translation")
self.current_card_is_flipped = False
def handle_play_voice(self):
if not self.current_flashcard_id:
return
text_target = self.current_active_es_text if self.current_card_is_flipped else self.current_active_en_text
lang_target = "es" if self.current_card_is_flipped else "en"
speed_target = self.lbl_review_speed.text()
self.execute_playback(text_target, lang_target, speed_target)
def handle_load_next_card(self):
if not self.flashcard_ids_pool:
QMessageBox.information(self, "Pool Empty", "No flashcards found in the matrix matching current criteria filters.")
return
next_tx_id = random.choice(self.flashcard_ids_pool)
for row in range(self.review_table.rowCount()):
if int(self.review_table.item(row, 0).text()) == next_tx_id:
self.review_table.setCurrentCell(row, 0)
break
self.load_flashcard_by_id(next_tx_id)
# =====================================================================
# 📦 GENANKI EXPORT ENGINE (RESTRUCTURED PURE TRANSLATION FLOW)
# =====================================================================
def handle_export_anki_deck(self):
"""
Gathers selected records from the matching criteria pool view, builds
a dual card template layout mapping English->Spanish (Card 1) and Spanish->English
(Card 2) forward-reverse pairs cleanly. Explicitly maps 4 fields: EnglishText,
EnglishAudio, SpanishText, SpanishAudio. Context and notes are fully removed.
"""
targets = self.flashcard_ids_pool
if not targets:
QMessageBox.warning(self, "Export Aborted", "The active flashcard pool filter is completely empty. Nothing to export.")
return
conn = get_connection()
cursor = conn.cursor()
placeholders = ",".join("?" for _ in targets)
cursor.execute(f"""
SELECT t.translation_id, p1.text, p2.text, p1.source_context, t.tags, t.notes, t.deck_name
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE t.translation_id IN ({placeholders})
""", targets)
records = cursor.fetchall()
conn.close()
if not records:
QMessageBox.information(self, "Export Processing", "No structured database entries matched your criteria indices parameters.")
return
# Unique Identification Anchor Codes for Anki Database Integrity
MODEL_ID = 1684321095
DECK_ID = 2026062011
# New Model structure aligning English Text/Audio with Spanish Text/Audio sequences
spanish_model = genanki.Model(
MODEL_ID,
'Castilian Learning Model (Pure Text & Audio Alignment)',
fields=[
{'name': 'EnglishText'},
{'name': 'EnglishAudio'},
{'name': 'SpanishText'},
{'name': 'SpanishAudio'}
],
templates=[
{
'name': 'Card 1: English -> Spanish',
'qfmt': (
'<div style="font-size: 13px; color: #2980b9; font-weight: bold; margin-bottom: 12px; letter-spacing: 1px;">🇬🇧 ENGLISH COMPREHENSION</div>'
'<div style="font-size: 25px; text-align: center; color: #2c3e50; font-family: Arial;">{{EnglishText}}</div>'
'<br><div style="text-align:center;">{{EnglishAudio}}</div>'
),
'afmt': (
'{{FrontSide}}<hr id="answer">'
'<div style="font-size: 13px; color: #e74c3c; font-weight: bold; margin-bottom: 12px; letter-spacing: 1px;">🇪🇸 SPANISH PRODUCTION</div>'
'<div style="font-size: 25px; text-align: center; color: #16a085; font-family: Arial; font-weight: bold;">{{SpanishText}}</div>'
'<br><div style="text-align:center;">{{SpanishAudio}}</div>'
),
},
{
'name': 'Card 2: Spanish -> English',
'qfmt': (
'<div style="font-size: 13px; color: #e74c3c; font-weight: bold; margin-bottom: 12px; letter-spacing: 1px;">🇪🇸 SPANISH PRODUCTION</div>'
'<div style="font-size: 25px; text-align: center; color: #2c3e50; font-family: Arial;">{{SpanishText}}</div>'
'<br><div style="text-align:center;">{{SpanishAudio}}</div>'
),
'afmt': (
'{{FrontSide}}<hr id="answer">'
'<div style="font-size: 13px; color: #2980b9; font-weight: bold; margin-bottom: 12px; letter-spacing: 1px;">🇬🇧 ENGLISH COMPREHENSION</div>'
'<div style="font-size: 25px; text-align: center; color: #16a085; font-family: Arial; font-weight: bold;">{{EnglishText}}</div>'
'<br><div style="text-align:center;">{{EnglishAudio}}</div>'
),
},
],
css='.card { font-family: arial; font-size: 20px; text-align: center; background-color: #fafafa; padding: 25px; border-radius: 8px; }'
)
deck_name_fallback = records[0][6] if records[0][6] else "Castilian Voice Trainer"
anki_deck = genanki.Deck(DECK_ID, f"Spanish::{deck_name_fallback}")
media_files_bundle = []
print(f"📦 Assembling Anki audio package for {len(records)} notes...")
for row in records:
tx_id, es_text, en_text, context, tags, notes, deck_group = row
es_clean = es_text.strip()
en_clean = en_text.strip()
# --- Spanish Media Track Setup ---
safe_es_name = "".join([c for c in es_clean if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
if not safe_es_name:
safe_es_name = hashlib.sha256(es_clean.encode('utf-8')).hexdigest()[:16]
audio_es_filename = f"{safe_es_name}_es_female.mp3"
full_es_path = f"media/{audio_es_filename}"
if not os.path.exists(full_es_path):
self.get_or_generate_audio_duration(None, es_clean, "es")
if os.path.exists(full_es_path):
media_files_bundle.append(full_es_path)
es_audio_tag = f"[sound:{audio_es_filename}]"
else:
es_audio_tag = ""
# --- English Media Track Setup ---
safe_en_name = "".join([c for c in en_clean if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
if not safe_en_name:
safe_en_name = hashlib.sha256(en_clean.encode('utf-8')).hexdigest()[:16]
audio_en_filename = f"{safe_en_name}_en_female.mp3"
full_en_path = f"media/{audio_en_filename}"
if not os.path.exists(full_en_path):
self.get_or_generate_audio_duration(None, en_clean, "en")
if os.path.exists(full_en_path):
media_files_bundle.append(full_en_path)
en_audio_tag = f"[sound:{audio_en_filename}]"
else:
en_audio_tag = ""
# Meta and Category Tag Construction
tag_list = str(tags).split() if tags else []
if context:
tag_list.append(str(context).replace(" ", "_").replace(".", "_"))
# Populate note fields sequentially matching model specification:
# EnglishText, EnglishAudio, SpanishText, SpanishAudio
anki_note = genanki.Note(
model=spanish_model,
fields=[
en_text,
en_audio_tag,
es_text,
es_audio_tag
],
tags=tag_list
)
anki_deck.add_note(anki_note)
# Output compilation assembly
export_output_path = os.path.join(self.anki_export_dir, f"{deck_name_fallback.replace('::', '_')}.apkg")
package = genanki.Package(anki_deck)
package.media_files = list(set(media_files_bundle)) # Excludes duplicate track instances
package.write_to_file(export_output_path)
print(f"✅ Success! Balanced text-audio cards exported cleanly: {export_output_path}")
QMessageBox.information(
self,
"Anki Package Compiled",
f"Successfully compiled {len(records)} balanced text-audio translation flashcard nodes.\n\nDestination:\n{export_output_path}"
)
def handle_export_video_assets(self):
pass
# =====================================================================
# CRUD ENGINE ATOMIC OPERATIONS LOGIC
# =====================================================================
def crud_create_pair(self):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO phrases (text, language, word_type, source_context) VALUES (?, 'es', ?, ?)
""", (self.input_text_es.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip()))
es_id = cursor.lastrowid
cursor.execute("""
INSERT INTO phrases (text, language, word_type, source_context) VALUES (?, 'en', ?, ?)
""", (self.input_text_en.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip()))
en_id = cursor.lastrowid
cursor.execute("""
INSERT INTO translations (source_phrase_id, target_phrase_id, deck_name, notes, tags) VALUES (?, ?, ?, ?, ?)
""", (es_id, en_id, self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip(), self.input_tags.text().strip()))
tx_id = cursor.lastrowid
conn.commit()
conn.close()
self.input_tx_id.setText(str(tx_id))
self.current_sandbox_es_id = es_id
self.current_sandbox_en_id = en_id
self.refresh_crud_table()
self.refresh_review_table()
QMessageBox.information(self, "Success", f"Isolated phrase pairs created and bound to Translation ID {tx_id}.")
def crud_update_pair(self):
tx_id_str = self.input_tx_id.text().strip()
if not tx_id_str:
QMessageBox.warning(self, "Update Target Missing", "No Translation Link ID found. Select an existing record node or create a fresh link pair first.")
return
if self.current_sandbox_es_id is None or self.current_sandbox_en_id is None:
QMessageBox.warning(self, "Phrase Nodes Untracked", "Underlying unique identifiers for individual language components are missing. Reselect the row from the left panel matrix grid.")
return
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
UPDATE phrases
SET text = ?, word_type = ?, source_context = ?, duration = NULL
WHERE id = ?
""", (self.input_text_es.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), self.current_sandbox_es_id))
cursor.execute("""
UPDATE phrases
SET text = ?, word_type = ?, source_context = ?, duration = NULL
WHERE id = ?
""", (self.input_text_en.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), self.current_sandbox_en_id))
cursor.execute("""
UPDATE translations
SET deck_name = ?, notes = ?, tags = ?
WHERE translation_id = ?
""", (self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip(), self.input_tags.text().strip(), int(tx_id_str)))
conn.commit()
except Exception as e:
QMessageBox.critical(self, "Database Error", f"Failed to execute field modifications inside SQL engine: {e}")
finally:
conn.close()
self.refresh_crud_table()
self.refresh_review_table()
QMessageBox.information(self, "Success", f"Node structural fields updated successfully. Translation Link ID {tx_id_str} remains active.")
def crud_delete_pair(self):
pass
def execute_playback(self, text_str, lang, speed_text):
txt = text_str.strip()
if not txt:
return
self.get_or_generate_audio_duration(None, txt, lang)
safe_name = "".join([c for c in txt if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
if not safe_name:
safe_name = hashlib.sha256(txt.encode('utf-8')).hexdigest()[:16]
target_file = f"media/{safe_name}_{lang}_female.mp3"
if os.path.exists(target_file):
try:
multiplier = float(speed_text.replace("x", ""))
except ValueError:
multiplier = 1.0
self.media_player.stop()
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file)))
self.media_player.setLoops(1)
self.media_player.setPlaybackRate(multiplier)
self.media_player.play()
def handle_sandbox_play_es(self):
self.execute_playback(self.input_text_es.toPlainText(), "es", self.combo_speed_es.currentText())
def handle_sandbox_play_en(self):
self.execute_playback(self.input_text_en.toPlainText(), "en", self.combo_speed_en.currentText())
2026-06-14 09:56:43 +00:00
# =====================================================================
2026-06-19 03:42:28 +00:00
# ⚡ DATA MATRIX CONTROL & SYNCHRONIZATION VIEWS
2026-06-14 09:56:43 +00:00
# =====================================================================
def refresh_crud_table(self):
conn = get_connection()
cursor = conn.cursor()
text_filter = self.search_text_input.text().strip()
context_filter = self.search_context_input.text().strip()
query = """
2026-06-19 03:42:28 +00:00
SELECT t.translation_id, p1.text, p2.text, p1.word_type, p1.source_context, t.deck_name
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE p1.language = 'es' AND p2.language = 'en'
"""
params = []
if text_filter:
query += " AND (p1.text LIKE ? OR p2.text LIKE ?)"
params.extend([f"%{text_filter}%", f"%{text_filter}%"])
2026-06-14 09:56:43 +00:00
if context_filter:
query += " AND p1.source_context LIKE ?"
params.append(f"%{context_filter}%")
query += " ORDER BY t.translation_id ASC LIMIT 250"
cursor.execute(query, params)
2026-06-14 09:56:43 +00:00
rows = cursor.fetchall()
conn.close()
self.translation_table.setRowCount(0)
2026-06-14 09:56:43 +00:00
for row_idx, row_data in enumerate(rows):
self.translation_table.insertRow(row_idx)
for col_idx in range(6):
val = row_data[col_idx]
self.translation_table.setItem(row_idx, col_idx, QTableWidgetItem(str(val if val is not None else "")))
2026-06-14 09:56:43 +00:00
2026-06-19 03:42:28 +00:00
def refresh_review_table(self):
conn = get_connection()
cursor = conn.cursor()
context_filter = self.review_context_filter.text().strip()
tag_filter = self.review_tag_filter.text().strip()
query = """
2026-06-21 13:54:17 +00:00
SELECT t.translation_id, p1.text, p1.source_context, t.tags
2026-06-19 03:42:28 +00:00
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
WHERE p1.language = 'es'
"""
params = []
if context_filter:
query += " AND p1.source_context LIKE ?"
params.append(f"%{context_filter}%")
if tag_filter:
query += " AND t.tags LIKE ?"
params.append(f"%{tag_filter}%")
query += " ORDER BY t.translation_id ASC"
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
self.review_table.setRowCount(0)
self.flashcard_ids_pool = []
for row_idx, row_data in enumerate(rows):
self.review_table.insertRow(row_idx)
2026-06-21 13:54:17 +00:00
self.flashcard_ids_pool.append(row_data[0])
2026-06-19 03:42:28 +00:00
for col_idx in range(4):
val = row_data[col_idx]
self.review_table.setItem(row_idx, col_idx, QTableWidgetItem(str(val if val is not None else "")))
2026-06-14 09:56:43 +00:00
def handle_table_row_select(self):
selected_ranges = self.translation_table.selectedRanges()
2026-06-14 09:56:43 +00:00
if not selected_ranges:
return
row = selected_ranges[0].topRow()
tx_id_item = self.translation_table.item(row, 0)
if not tx_id_item:
2026-06-14 09:56:43 +00:00
return
tx_id = tx_id_item.text()
2026-06-14 09:56:43 +00:00
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
2026-06-20 01:55:05 +00:00
SELECT t.translation_id, p1.text, p2.text, p1.word_type, p1.source_context, t.deck_name, t.notes, t.tags, p1.id, p2.id
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE t.translation_id = ?
""", (tx_id,))
2026-06-14 09:56:43 +00:00
record = cursor.fetchone()
conn.close()
if record:
self.input_tx_id.setText(str(record[0]))
self.input_text_es.setPlainText(str(record[1]))
self.input_text_en.setPlainText(str(record[2]))
self.combo_type.setCurrentText(str(record[3]) if record[3] else "phrase")
self.input_context.setText(str(record[4]) if record[4] else "")
2026-06-18 12:47:29 +00:00
self.input_tags.setText(str(record[7]) if record[7] is not None else "")
self.input_deck_tag.setText(str(record[5]) if record[5] else "General")
2026-06-18 12:23:47 +00:00
self.input_grammar_note.setPlainText(str(record[6]) if record[6] is not None else "")
2026-06-20 01:55:05 +00:00
self.current_sandbox_es_id = record[8]
self.current_sandbox_en_id = record[9]
2026-06-19 03:42:28 +00:00
def handle_review_table_select(self):
selected_ranges = self.review_table.selectedRanges()
if not selected_ranges:
return
row = selected_ranges[0].topRow()
2026-06-21 13:54:17 +00:00
if row < len(self.flashcard_ids_pool):
translation_id = self.flashcard_ids_pool[row]
self.load_flashcard_by_id(translation_id)
2026-06-19 03:42:28 +00:00
def step_table_row(self, direction):
current_row = self.translation_table.currentRow()
next_row = current_row + direction
if 0 <= next_row < self.translation_table.rowCount():
self.translation_table.setCurrentCell(next_row, 0)
2026-06-21 13:54:17 +00:00
def clear_id_if_new_entry(self):
if self.input_tx_id.text() and not (self.input_text_es.hasFocus() or self.input_text_en.hasFocus()):
2026-06-19 03:42:28 +00:00
pass
2026-06-12 11:58:47 +00:00
if __name__ == "__main__":
2026-06-21 13:54:17 +00:00
app = QApplication(sys.argv)
app.setApplicationName("Spanish Voice Trainer")
app.setOrganizationName("Oxnee Pty. Ltd.")
window = SpanishTrainerApp()
window.show()
sys.exit(app.exec())