# 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 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(1000, 650) # 1. Initialize and Seed Database if Empty self.ensure_database_populated() # 2. Initialize PyQt Multimedia Audio Engine Components self.media_player = QMediaPlayer() self.audio_output = QAudioOutput() self.media_player.setAudioOutput(self.audio_output) self.current_flashcard_id = None # 3. Build UI Components self.tabs = QTabWidget() self.setCentralWidget(self.tabs) self.init_phrase_sandbox_tab() self.init_flashcard_reviewer_tab() # 4. Initial Load of Database Data into Grid View self.refresh_crud_table() def ensure_database_populated(self): """Verifies if the database exists and has records; seeds it from the PDF if empty.""" init_db() conn = get_connection() cursor = conn.cursor() try: cursor.execute("SELECT COUNT(*) FROM phrases") count = cursor.fetchone()[0] except Exception: count = 0 conn.close() if count == 0: print("šŸ—„ļø Database appears empty. Running the structural reconstruction pipeline...") pdf_file = "aula_int_plus_1_glos_en_alfa.pdf" textbook = "Aula Internacional Plus 1" if os.path.exists(pdf_file): importer = BulkImporter() importer.import_pdf_glossary(pdf_file, textbook) cleaner = GlossaryCleaner() cleaner.process_database_clean() print("✨ Database successfully seeded with metadata-parsed entries.") else: print(f"āš ļø Warning: Could not find '{pdf_file}' to automatically seed records.") # ===================================================================== # šŸ—„ļø TAB 1: PHRASE SANDBOX (CRUD Panel) # ===================================================================== def init_phrase_sandbox_tab(self): tab = QWidget() layout = QHBoxLayout(tab) left_panel = QVBoxLayout() search_layout = QHBoxLayout() search_layout.addWidget(QLabel("šŸ” Filter text:")) self.search_input = QLineEdit() self.search_input.textChanged.connect(self.refresh_crud_table) search_layout.addWidget(self.search_input) left_panel.addLayout(search_layout) self.phrase_table = QTableWidget() self.phrase_table.setColumnCount(6) self.phrase_table.setHorizontalHeaderLabels(["ID", "Text", "Lang", "Type", "Voice Gender", "Deck Tag"]) self.phrase_table.itemSelectionChanged.connect(self.handle_table_row_select) left_panel.addWidget(self.phrase_table) right_panel = QVBoxLayout() form_frame = QFrame() form_frame.setFrameShape(QFrame.Shape.StyledPanel) form_layout = QFormLayout(form_frame) self.input_id = QLineEdit() self.input_id.setReadOnly(True) self.input_id.setPlaceholderText("Auto-assigned ID") self.input_text = QTextEdit() self.input_text.setMaximumHeight(60) self.combo_lang = QComboBox() self.combo_lang.addItems(["es", "en"]) self.combo_type = QComboBox() self.combo_type.addItems(["sentence", "phrase", "noun", "verb", "adjective"]) self.combo_voice_gender = QComboBox() self.combo_voice_gender.addItems(["female", "male"]) self.slider_base_speed = QSlider(Qt.Orientation.Horizontal) self.slider_base_speed.setMinimum(50) self.slider_base_speed.setMaximum(150) self.slider_base_speed.setValue(100) self.lbl_base_speed = QLabel("1.00x") self.slider_base_speed.valueChanged.connect(lambda v: self.lbl_base_speed.setText(f"{v/100:.2f}x")) speed_box = QHBoxLayout() speed_box.addWidget(self.slider_base_speed) speed_box.addWidget(self.lbl_base_speed) self.input_deck_tag = QLineEdit() self.input_deck_tag.setPlaceholderText("e.g., Anki_Unit_1") form_layout.addRow("Phrase ID Resource:", self.input_id) form_layout.addRow(QLabel("Target Conversational Text:")) form_layout.addRow(self.input_text) form_layout.addRow("Language Accent:", self.combo_lang) form_layout.addRow("Grammar Type Classification:", self.combo_type) form_layout.addRow("Preferred Voice Gender:", self.combo_voice_gender) form_layout.addRow("Default Playback Speed:", speed_box) form_layout.addRow("Deck / Group Identifier Tag:", self.input_deck_tag) crud_buttons = QHBoxLayout() self.btn_save = QPushButton("āž• Save New") self.btn_update = QPushButton("šŸ’¾ Update Entry") self.btn_delete = QPushButton("šŸ—‘ļø Delete") self.btn_save.clicked.connect(self.crud_create) self.btn_update.clicked.connect(self.crud_update) self.btn_delete.clicked.connect(self.crud_delete) crud_buttons.addWidget(self.btn_save) crud_buttons.addWidget(self.btn_update) crud_buttons.addWidget(self.btn_delete) right_panel.addWidget(QLabel("

Configure Phrase & Voice Variables

")) right_panel.addWidget(form_frame) right_panel.addLayout(crud_buttons) right_panel.addStretch() layout.addLayout(left_panel, stretch=3) layout.addLayout(right_panel, stretch=2) self.tabs.addTab(tab, "šŸ—„ļø Phrase Sandbox (CRUD)") # ===================================================================== # šŸƒ TAB 2: FLASHCARD STUDY PLAYER # ===================================================================== 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("Click 'Load Next' to study sentences...") 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: 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() playback_layout = QHBoxLayout() playback_layout.addWidget(QLabel("šŸ”Š Fine-Tune Study 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 (Normal)") 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 Translation") 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 Study") # ===================================================================== # ⚔ ENGINE BUSINESS LOGIC OPERATIONS & DATABASE MAPPINGS # ===================================================================== def refresh_crud_table(self): conn = get_connection() cursor = conn.cursor() filter_text = self.search_input.text() if filter_text: cursor.execute(""" SELECT id, text, language, word_type, voice_gender, deck_name FROM phrases WHERE text LIKE ? ORDER BY id DESC LIMIT 100 """, (f"%{filter_text}%",)) else: cursor.execute(""" SELECT id, text, language, word_type, voice_gender, deck_name FROM phrases ORDER BY id DESC LIMIT 100 """) rows = cursor.fetchall() conn.close() self.phrase_table.setRowCount(0) for row_idx, row_data in enumerate(rows): self.phrase_table.insertRow(row_idx) for col_idx, value in enumerate(row_data): self.phrase_table.setItem(row_idx, col_idx, QTableWidgetItem(str(value if value is not None else ""))) def handle_table_row_select(self): selected_ranges = self.phrase_table.selectedRanges() if not selected_ranges: return row = selected_ranges[0].topRow() item = self.phrase_table.item(row, 0) if not item: return phrase_id = item.text() conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT id, text, language, word_type, voice_gender, base_speed, deck_name FROM phrases WHERE id = ?", (phrase_id,)) record = cursor.fetchone() conn.close() if record: self.input_id.setText(str(record[0])) self.input_text.setPlainText(str(record[1])) self.combo_lang.setCurrentText(str(record[2])) self.combo_type.setCurrentText(str(record[3]) if record[3] else "sentence") self.combo_voice_gender.setCurrentText(str(record[4]) if record[4] else "female") speed_val = int((record[5] if record[5] else 1.0) * 100) self.slider_base_speed.setValue(speed_val) self.input_deck_tag.setText(str(record[6]) if record[6] else "General") def crud_create(self): conn = get_connection() cursor = conn.cursor() cursor.execute(""" INSERT INTO phrases (text, language, word_type, voice_gender, base_speed, deck_name) VALUES (?, ?, ?, ?, ?, ?) """, ( self.input_text.toPlainText().strip(), self.combo_lang.currentText(), self.combo_type.currentText(), self.combo_voice_gender.currentText(), self.slider_base_speed.value() / 100.0, # Fixed typo: changed from .setValue() to .value() self.input_deck_tag.text().strip() or "General" )) conn.commit() conn.close() self.refresh_crud_table() QMessageBox.information(self, "Success", "Phrase generated into database index store successfully.") def crud_update(self): pid = self.input_id.text() if not pid: return conn = get_connection() cursor = conn.cursor() cursor.execute(""" UPDATE phrases SET text=?, language=?, word_type=?, voice_gender=?, base_speed=?, deck_name=? WHERE id=? """, ( self.input_text.toPlainText().strip(), self.combo_lang.currentText(), self.combo_type.currentText(), self.combo_voice_gender.currentText(), self.slider_base_speed.value() / 100.0, self.input_deck_tag.text().strip() or "General", pid )) conn.commit() conn.close() self.refresh_crud_table() QMessageBox.information(self, "Success", "Database record fields updated cleanly.") def crud_delete(self): pid = self.input_id.text() if not pid: return conn = get_connection() cursor = conn.cursor() cursor.execute("DELETE FROM phrases WHERE id=?", (pid,)) conn.commit() conn.close() self.input_id.clear() self.input_text.clear() self.refresh_crud_table() # ===================================================================== # šŸ”Š AUDIO PLAYER SYSTEM # ===================================================================== def handle_load_next_card(self): conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT id, text, language, word_type, voice_gender, base_speed FROM phrases ORDER BY RANDOM() LIMIT 1") record = cursor.fetchone() conn.close() if record: self.current_flashcard_id = record[0] self.lbl_card_text.setText(record[1]) lang_lbl = "Spanish Accent" if record[2] == "es" else "English Accent" gender_lbl = str(record[4]).capitalize() if record[4] else "Female" self.lbl_card_meta.setText(f"Classification: {record[3]} • Configured Voice: {lang_lbl} ({gender_lbl})") card_saved_speed = int((record[5] if record[5] else 1.0) * 100) self.slider_review_speed.setValue(card_saved_speed) 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,)) phrase_row = cursor.fetchone() conn.close() if not phrase_row: return text_str, lang_code, voice_gender = phrase_row safe_filename = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).rstrip().replace(" ", "_").lower() target_audio_file = f"media/{safe_filename}_{lang_code}_{voice_gender}.mp3" os.makedirs("media", exist_ok=True) if not os.path.exists(target_audio_file): QMessageBox.warning(self, "Audio Track Missing", f"Audio track asset file not found in directory:\n'{target_audio_file}'\n\nRun the background Edge-TTS batch generator script next to download this track automatically.") return self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_audio_file))) current_rate = self.slider_review_speed.value() / 100.0 self.media_player.setPlaybackRate(current_rate) self.media_player.play() 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 FROM translations t JOIN phrases p2 ON t.target_phrase_id = p2.id WHERE t.source_phrase_id = ? """, (self.current_flashcard_id,)) row = cursor.fetchone() conn.close() if row: current_es = self.lbl_card_text.text().split("\n\nšŸ‘‰")[0] self.lbl_card_text.setText(f"{current_es}\n\nšŸ‘‰ [ {row[0]} ]") if __name__ == "__main__": app = QApplication(sys.argv) window = SpanishTrainerApp() window.show() sys.exit(app.exec())