# main.py import sys import os from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox, QTableWidget, QTableWidgetItem, QSlider, QFormLayout, QTextEdit, QFrame, QMessageBox ) from PyQt6.QtCore import Qt, QUrl from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtGui import QFont # 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(1150, 700) # 1. Initialize schema structures and check ingestion status self.ensure_database_populated() # Audio Player Architecture Setup self.media_player = QMediaPlayer() self.audio_output = QAudioOutput() # Set the audio output routing to the player instance self.media_player.setAudioOutput(self.audio_output) self.current_flashcard_id = None # Central Main Window Tabs Interface self.tabs = QTabWidget() self.setCentralWidget(self.tabs) self.init_phrase_sandbox_tab() self.init_flashcard_reviewer_tab() # 2. Populate unified database rows into interface layout grid self.refresh_crud_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() # Verify if the translations table has rows populated 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): # Run the layout parser staging execution pass importer = BulkImporter() importer.import_pdf_glossary(pdf_file, "Aula Internacional Plus 1") # Clean, pair up English/Spanish, and insert the final rows 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.") # ===================================================================== # šŸ—„ļø TAB 1: TRANSLATION-CENTRIC PHRASE SANDBOX (CRUD) # ===================================================================== def init_phrase_sandbox_tab(self): tab = QWidget() layout = QHBoxLayout(tab) # --- LEFT SIDE PANEL: Filter Controls & Unified Data Grid --- 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 or U8_5A") 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) # Unified Translations Row Table Matrix 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) # Row Pointer Navigation Steppers 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 SIDE PANEL: Side-by-Side Unified Twin Form Box Views --- 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_en = QTextEdit() self.input_text_en.setMaximumHeight(75) 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_deck_tag = QLineEdit() self.input_deck_tag.setPlaceholderText("Anki Sub-deck Hierarchy") # Common layout stylesheet for the utility buttons 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; } """ # šŸ”Š 1. Spanish Header Layout with Speed Controls es_header_layout = QHBoxLayout() es_header_layout.setContentsMargins(0, 5, 0, 5) es_header_layout.addWidget(QLabel("šŸ‡ŖšŸ‡ø Castilian Spanish Text Element:")) 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() # šŸ”Š 2. English Header Layout Activated with Speed Controls en_header_layout = QHBoxLayout() en_header_layout.setContentsMargins(0, 5, 0, 5) en_header_layout.addWidget(QLabel("šŸ‡¬šŸ‡§ English Target Translation:")) 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() # šŸ“ 3. Symmetrical Grammar/Usage Note Header Layout grammar_header_layout = QHBoxLayout() grammar_header_layout.setContentsMargins(0, 5, 0, 5) grammar_header_layout.addWidget(QLabel("šŸ“ Grammar / Usage Note:")) grammar_header_layout.addStretch() self.input_grammar_note = QTextEdit() self.input_grammar_note.setMaximumHeight(75) self.input_grammar_note.setPlaceholderText("e.g., feminine variant, irregular radical change, requires subjunctive...") # Mount elements sequentially into the form frame mapping structure form_layout.addRow("Translation Link ID:", 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("Target Deck Scope:", 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("

Translation Node Management Matrix

")) 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 = QVBoxLayout(tab) card_frame = QFrame() card_frame.setStyleSheet("background-color: #ffffff; border: 2px solid #bdc3c7; border-radius: 12px;") card_layout = QVBoxLayout(card_frame) self.lbl_card_text = QLabel("Select 'Next Card' to initiate study sequence...") self.lbl_card_text.setAlignment(Qt.AlignmentFlag.AlignCenter) self.lbl_card_text.setFont(QFont("Arial", 22, QFont.Weight.Bold)) self.lbl_card_text.setWordWrap(True) self.lbl_card_text.setStyleSheet("color: #2c3e50; border: none; padding: 25px;") 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() playback_layout = QHBoxLayout() playback_layout.addWidget(QLabel("šŸ”Š Voice Track 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.0x") 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) action_buttons = QHBoxLayout() self.btn_play_voice = QPushButton("šŸ—£ļø Play Voice Track") self.btn_flip_card = QPushButton("šŸ‘ļø Reveal English Partner") self.btn_load_next = QPushButton("āž”ļø Next Card") self.btn_play_voice.clicked.connect(self.handle_play_voice) self.btn_flip_card.clicked.connect(self.handle_flip_card) self.btn_load_next.clicked.connect(self.handle_load_next_card) action_buttons.addWidget(self.btn_play_voice) action_buttons.addWidget(self.btn_flip_card) action_buttons.addStretch() action_buttons.addWidget(self.btn_load_next) layout.addWidget(card_frame, stretch=1) layout.addLayout(playback_layout) layout.addLayout(action_buttons) self.tabs.addTab(tab, "šŸƒ Flashcard Review") # ===================================================================== # ⚔ ENGINE DATABASE LOGIC & FILTER COMPILATIONS # ===================================================================== def refresh_crud_table(self): """Pulls unified translation nodes into pairs while enforcing context text matches.""" 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, 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 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 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 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_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 "") def step_table_row(self, direction): """Steps your focus row highlighting pointer index sequentially.""" 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 crud_create_pair(self): conn = get_connection() cursor = conn.cursor() # Write Spanish node entry 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 # Write English node entry 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 # Build cross-referencing translation relational binding matrix row cursor.execute(""" INSERT INTO translations (source_phrase_id, target_phrase_id, deck_name, notes) VALUES (?, ?, ?, ?) """, (es_id, en_id, self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip())) conn.commit() conn.close() self.refresh_crud_table() QMessageBox.information(self, "Success", "Isolated phrase pairs created and relational link bound.") def crud_update_pair(self): tx_id = self.input_tx_id.text() if not tx_id: return conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT source_phrase_id, target_phrase_id FROM translations WHERE translation_id = ?", (tx_id,)) ids = cursor.fetchone() if ids: es_id, en_id = ids # Keep underscores exact in context updates cursor.execute("UPDATE phrases SET text=?, word_type=?, source_context=? WHERE id=?", (self.input_text_es.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), es_id)) cursor.execute("UPDATE phrases SET text=?, word_type=?, source_context=? WHERE id=?", (self.input_text_en.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), en_id)) cursor.execute("UPDATE translations SET deck_name=?, notes=? WHERE translation_id=?", (self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip(), tx_id)) conn.commit() conn.close() self.refresh_crud_table() QMessageBox.information(self, "Success", "Relational node structural update complete.") def crud_delete_pair(self): tx_id = self.input_tx_id.text() if not tx_id: return conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT source_phrase_id, target_phrase_id FROM translations WHERE translation_id = ?", (tx_id,)) ids = cursor.fetchone() if ids: es_id, en_id = ids cursor.execute("DELETE FROM translations WHERE translation_id=?", (tx_id,)) cursor.execute("DELETE FROM phrases WHERE id=?", (es_id,)) cursor.execute("DELETE FROM phrases WHERE id=?", (en_id,)) conn.commit() conn.close() self.refresh_crud_table() self.input_tx_id.clear() self.input_text_es.clear() self.input_text_en.clear() self.input_grammar_note.clear() # ===================================================================== # šŸ”Š AUDIO OPERATIONS & FLASHCARD CONTROL # ===================================================================== def handle_sandbox_play_es(self): """Generates/plays the Spanish phrase from the Sandbox using the chosen speed rate.""" text_str = self.input_text_es.toPlainText().strip() if not text_str: QMessageBox.warning(self, "Empty Value", "Please select a valid translation pair or type a Spanish phrase to play.") return safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() os.makedirs("media", exist_ok=True) target_file = f"media/{safe_name}_es_female.mp3" if not os.path.exists(target_file): print(f"šŸ”Š Generating Neural Castilian Spanish track for '{text_str}'...") try: import asyncio import edge_tts communicate = edge_tts.Communicate(text_str, "es-ES-ElviraNeural") asyncio.run(communicate.save(target_file)) except Exception as tts_err: QMessageBox.critical(self, "TTS Error", f"Failed to synthesize Spanish voice:\n{tts_err}") return if os.path.exists(target_file): speed_multiplier = float(self.combo_speed_es.currentText().replace("x", "")) self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file))) self.media_player.setPlaybackRate(speed_multiplier) self.media_player.play() def handle_sandbox_play_en(self): """Generates/plays the English translation string from the Sandbox using its speed rate.""" text_str = self.input_text_en.toPlainText().strip() if not text_str: QMessageBox.warning(self, "Empty Value", "Please enter text inside the English target translation container.") return safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() os.makedirs("media", exist_ok=True) target_file = f"media/{safe_name}_en_female.mp3" if not os.path.exists(target_file): print(f"šŸ”Š Generating Neural English voice track for '{text_str}'...") try: import asyncio import edge_tts communicate = edge_tts.Communicate(text_str, "en-GB-SoniaNeural") asyncio.run(communicate.save(target_file)) except Exception as tts_err: QMessageBox.critical(self, "TTS Error", f"Failed to synthesize English voice:\n{tts_err}") return if os.path.exists(target_file): speed_multiplier = float(self.combo_speed_en.currentText().replace("x", "")) self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file))) self.media_player.setPlaybackRate(speed_multiplier) self.media_player.play() def handle_load_next_card(self): """Picks a random phrase node from the database, shifting state context to flashcard mode.""" conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT t.translation_id, p1.text, p1.word_type, p1.source_context, t.deck_name, p1.id FROM translations t JOIN phrases p1 ON t.source_phrase_id = p1.id WHERE p1.language = 'es' ORDER BY RANDOM() LIMIT 1 """) record = cursor.fetchone() conn.close() if record: self.current_flashcard_id = record[5] self.lbl_card_text.setText(record[1]) self.lbl_card_meta.setText(f"Tx Link node: {record[0]} • Context: {record[3]} • Subdeck: {record[4]}") def handle_play_voice(self): if not self.current_flashcard_id: return conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT text, language, voice_gender FROM phrases WHERE id = ?", (self.current_flashcard_id,)) row = cursor.fetchone() conn.close() if row: text_str, lang, gender = row safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() resolved_gender = gender if gender else "female" target_file = f"media/{safe_name}_{lang}_{resolved_gender}.mp3" if os.path.exists(target_file): self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file))) self.media_player.setPlaybackRate(self.slider_review_speed.value() / 100.0) self.media_player.play() else: QMessageBox.warning(self, "Asset Missing", f"Audio file not found at path location:\n{target_file}") def handle_live_speed_change(self, value): rate = value / 100.0 self.lbl_review_speed.setText(f"{rate:.2f}x") if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: self.media_player.setPlaybackRate(rate) def handle_flip_card(self): if not self.current_flashcard_id: return conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT p2.text, 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 p1.id = ? """, (self.current_flashcard_id,)) row = cursor.fetchone() conn.close() if row: clean_es = self.lbl_card_text.text().split("\n\nšŸ‘‰")[0] display_text = f"{clean_es}\n\nšŸ‘‰ [ {row[0]} ]" # If a note exists for the card, append it dynamically to the reveal layout if row[1]: display_text += f"\n\nšŸ’” Note: {row[1]}" self.lbl_card_text.setText(display_text) # ===================================================================== # šŸš€ DIAGNOSTIC STARTUP FRAMEWORK WRAPPER # ===================================================================== if __name__ == "__main__": print("šŸš€ Initializing PyQt6 Application Framework...") try: app = QApplication(sys.argv) print("šŸ”§ Spawning SpanishTrainerApp Instance...") window = SpanishTrainerApp() print("šŸ–„ļø Mounting User Interface Windows...") window.show() print("šŸŽÆ Event loop engaged. Handing over control thread...") sys.exit(app.exec()) except Exception as fatal_error: import traceback print("\nāŒ CRITICAL CRASH DETECTED ON CORE STARTUP THREAD!") print("====================================================") print(f"Error Type: {type(fatal_error).__name__}") print(f"Error Message: {fatal_error}") print("====================================================") traceback.print_exc() sys.exit(1)