139_spanish-voice-trainer/main.py

1071 lines
No EOL
47 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# main.py
import sys
import os
import random
import hashlib
import subprocess
import json
import asyncio
import shutil
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox,
QTableWidget, QTableWidgetItem, QSlider, QFormLayout, QTextEdit, QFrame, QMessageBox, QFileDialog
)
from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtGui import QFont
# Third-Party Tooling
import genanki
import edge_tts
from PIL import Image, ImageDraw, ImageFont
# Internal Project Module Imports
from database.connection import init_db, get_connection
from core.bulk_importer import BulkImporter
from core.clean_glossary import GlossaryCleaner
class SpanishTrainerApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Castilian Voice Trainer Pro")
self.setMinimumSize(1200, 800)
# 1. Initialize schema structures and check ingestion status
self.ensure_database_populated()
# Load system persistent settings from DB (including sleep-learning fields)
self.load_system_settings()
# Audio Player Architecture Setup
self.media_player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output)
# 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
self.current_sandbox_es_id = None
self.current_sandbox_en_id = None
# Central Main Window Tabs Interface
self.tabs = QTabWidget()
self.setCentralWidget(self.tabs)
self.init_phrase_sandbox_tab()
self.init_flashcard_reviewer_tab()
self.init_settings_tab()
# 2. Populate table grids on initialization
self.refresh_crud_table()
self.refresh_review_table()
def ensure_database_populated(self):
"""Forces database configuration structure and triggers pipeline execution if empty."""
print("🗄️ Verification Pass: Running schema configuration scripts...")
init_db()
conn = get_connection()
cursor = conn.cursor()
# Ensure our settings table and key columns are structurally sound
cursor.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);")
# 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()
try:
cursor.execute("SELECT COUNT(*) FROM translations")
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}")
count = 0
finally:
conn.close()
if count == 0:
print("🗄️ Database tables are empty. Triggering glossary reader pipeline...")
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")
cleaner = GlossaryCleaner()
cleaner.process_database_clean()
print("✨ Ingestion pipeline processing sequence successfully completed.")
else:
print(f"❌ Error: Source document '{pdf_file}' is missing from the directory root.")
def load_system_settings(self):
"""Loads persistent variables from the key-value settings table."""
self.anki_export_dir = os.getcwd()
self.video_export_dir = os.getcwd()
self.video_first_lang = "English First (en -> es)"
self.video_repeats_count = "3"
self.video_pause_duration = "4.0"
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]
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]
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()
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
except Exception as e:
print(f"❌ Critical: Failed to save setting '{key}': {e}")
finally:
conn.close()
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.
"""
if not text_str.strip():
return 2.5
safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
if not safe_name:
safe_name = hashlib.sha256(text_str.encode('utf-8')).hexdigest()[:16]
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
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])
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'])
if phrase_id:
cursor.execute("UPDATE phrases SET duration = ? WHERE id = ?", (duration, phrase_id))
conn.commit()
except Exception as e:
print(f"⚠️ Track structure analysis warning for {target_file}: {e}")
duration = 2.5
finally:
if phrase_id and 'conn' in locals() and conn:
conn.close()
return duration
# =====================================================================
# 🗄️ TAB 1: TRANSLATION-CENTRIC PHRASE SANDBOX (CRUD)
# =====================================================================
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)
filter_layout.addWidget(QLabel("📂 Context:"))
self.search_context_input = QLineEdit()
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)
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")
self.input_text_es = QTextEdit()
self.input_text_es.setMaximumHeight(75)
self.input_text_es.textChanged.connect(self.clear_id_if_new_entry)
self.input_text_en = QTextEdit()
self.input_text_en.setMaximumHeight(75)
self.input_text_en.textChanged.connect(self.clear_id_if_new_entry)
self.combo_type = QComboBox()
self.combo_type.addItems(["phrase", "sentence", "noun", "verb", "adjective"])
self.input_context = QLineEdit()
self.input_context.setPlaceholderText("e.g., U8_5A")
self.input_tags = QLineEdit()
self.input_tags.setPlaceholderText("e.g., irregular_er boots_verb")
self.input_deck_tag = QLineEdit()
self.input_deck_tag.setPlaceholderText("Anki Sub-deck Hierarchy")
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()
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)
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)
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)
form_layout.addRow("Anki Note Tags:", self.input_tags)
form_layout.addRow("<b>Target Deck Scope:</b>", self.input_deck_tag)
crud_buttons = QHBoxLayout()
self.btn_save = QPushButton(" Create Pair")
self.btn_update = QPushButton("💾 Update Node")
self.btn_delete = QPushButton("🗑️ Sever Link")
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)
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>"))
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)
self.tabs.addTab(tab, "🗄️ Phrase Sandbox (CRUD)")
# =====================================================================
# 🃏 TAB 2: FLASHCARD STUDY MODULE
# =====================================================================
def init_flashcard_reviewer_tab(self):
tab = QWidget()
layout = QHBoxLayout(tab)
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()
card_frame = QFrame()
card_frame.setStyleSheet("background-color: #ffffff; border: 2px solid #bdc3c7; border-radius: 12px;")
card_layout = QVBoxLayout(card_frame)
card_frame.setMinimumHeight(280)
self.lbl_card_text = QLabel("Select a row or click 'Next Card' to initiate...")
self.lbl_card_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_card_text.setFont(QFont("Arial", 20, QFont.Weight.Bold))
self.lbl_card_text.setWordWrap(True)
self.lbl_card_text.setStyleSheet("color: #2c3e50; border: none; padding: 20px;")
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()
right_panel.addWidget(card_frame, stretch=4)
playback_layout = QHBoxLayout()
playback_layout.addWidget(QLabel("🔊 Voice Speed:"))
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)
self.lbl_review_speed = QLabel("1.00x")
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)
right_panel.addLayout(playback_layout)
action_buttons = QHBoxLayout()
self.btn_play_voice = QPushButton("🗣️ Play Voice Track")
self.btn_flip_card = QPushButton("👁️ Reveal Translation")
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)
right_panel.addLayout(action_buttons)
right_panel.addSpacing(15)
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")
# =====================================================================
# ⚙️ 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)
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))
form_layout.addRow("<b>Anki Deck Export Destination:</b>", anki_layout)
form_layout.addRow("<b>Video Assembly Output Target:</b>", video_layout)
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)
layout.addWidget(QLabel("<h2>Application Preferences & Workspace Routing</h2>"))
layout.addWidget(settings_frame)
layout.addWidget(self.btn_sync_cache)
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)
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.")
# =====================================================================
# 💡 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())
# =====================================================================
# ⚡ DATA MATRIX CONTROL & SYNCHRONIZATION VIEWS
# =====================================================================
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 = """
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}%"])
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)
rows = cursor.fetchall()
conn.close()
self.translation_table.setRowCount(0)
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 "")))
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 = """
SELECT t.translation_id, p1.text, p1.source_context, t.tags
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)
self.flashcard_ids_pool.append(row_data[0])
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 "")))
def handle_table_row_select(self):
selected_ranges = self.translation_table.selectedRanges()
if not selected_ranges:
return
row = selected_ranges[0].topRow()
tx_id_item = self.translation_table.item(row, 0)
if not tx_id_item:
return
tx_id = tx_id_item.text()
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
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,))
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 "")
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")
self.input_grammar_note.setPlainText(str(record[6]) if record[6] is not None else "")
self.current_sandbox_es_id = record[8]
self.current_sandbox_en_id = record[9]
def handle_review_table_select(self):
selected_ranges = self.review_table.selectedRanges()
if not selected_ranges:
return
row = selected_ranges[0].topRow()
if row < len(self.flashcard_ids_pool):
translation_id = self.flashcard_ids_pool[row]
self.load_flashcard_by_id(translation_id)
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)
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()):
pass
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setApplicationName("Spanish Voice Trainer")
app.setOrganizationName("Oxnee Pty. Ltd.")
window = SpanishTrainerApp()
window.show()
sys.exit(app.exec())