diff --git a/main.py b/main.py index cd04204..317bea5 100644 --- a/main.py +++ b/main.py @@ -43,10 +43,15 @@ class SpanishTrainerApp(QMainWindow): self.audio_output = QAudioOutput() self.media_player.setAudioOutput(self.audio_output) - self.current_flashcard_id = None + # 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 - self.flashcard_ids_pool = [] # Tracks currently filtered study list IDs # Central Main Window Tabs Interface self.tabs = QTabWidget() @@ -105,7 +110,6 @@ class SpanishTrainerApp(QMainWindow): def load_system_settings(self): """Loads persistent variables from the key-value settings table.""" - # Baseline internal fallback defaults self.anki_export_dir = os.getcwd() self.video_export_dir = os.getcwd() self.video_first_lang = "English First (en -> es)" @@ -141,7 +145,6 @@ class SpanishTrainerApp(QMainWindow): cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)) conn.commit() - # Sync the application runtime settings instantly back to memory variables if key == "video_first_language": self.video_first_lang = value elif key == "video_repeats_count": @@ -158,14 +161,16 @@ class SpanishTrainerApp(QMainWindow): 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 phrase_id: + 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" - # 1. Generate audio track dynamically if missing if not os.path.exists(target_file): try: voice = "es-ES-ElviraNeural" if lang == "es" else "en-GB-SoniaNeural" @@ -175,17 +180,16 @@ class SpanishTrainerApp(QMainWindow): print(f"āŒ Core TTS System Exception: {tts_err}") return 2.5 - # 2. Return cached value from DB if it exists and isn't null - 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]) + 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]) - # 3. Calculate audio duration via ffprobe and store it permanently try: cmd = [ 'ffprobe', '-v', 'quiet', '-print_format', 'json', @@ -195,14 +199,15 @@ class SpanishTrainerApp(QMainWindow): data = json.loads(result.stdout) duration = float(data['format']['duration']) - cursor.execute("UPDATE phrases SET duration = ? WHERE id = ?", (duration, phrase_id)) - conn.commit() - print(f"šŸ’¾ Track length calculated and stored globally: {duration}s -> Phrase ID {phrase_id}") + 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: - conn.close() + if phrase_id and 'conn' in locals() and conn: + conn.close() return duration @@ -259,9 +264,11 @@ class SpanishTrainerApp(QMainWindow): 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"]) @@ -445,7 +452,7 @@ class SpanishTrainerApp(QMainWindow): action_buttons = QHBoxLayout() self.btn_play_voice = QPushButton("šŸ—£ļø Play Voice Track") - self.btn_flip_card = QPushButton("šŸ‘ļø Reveal English Partner") + 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) @@ -506,7 +513,6 @@ class SpanishTrainerApp(QMainWindow): video_layout.addWidget(self.line_video_dir) video_layout.addWidget(btn_browse_video) - # UI Sleep Learning Configuration Fields 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) @@ -526,7 +532,6 @@ class SpanishTrainerApp(QMainWindow): form_layout.addRow("Target Translation Loop Multiplier (Repeats):", self.spin_video_repeats) form_layout.addRow("User Recall Repetition Frame Intermission (Seconds):", self.spin_pause_duration) - # High-visibility sync button to calculate missing timings and rebuild metadata cache self.btn_sync_cache = QPushButton("⚔ Populate Audio & Timings Cache") self.btn_sync_cache.setStyleSheet(""" QPushButton { @@ -563,7 +568,6 @@ class SpanishTrainerApp(QMainWindow): self.save_setting_to_db("video_export_directory", directory) def handle_bulk_populate_audio_cache(self): - """Iterates through all relational links, runs dynamic downloads, analyzes audio runtime lengths via ffprobe.""" conn = get_connection() cursor = conn.cursor() cursor.execute(""" @@ -587,6 +591,346 @@ class SpanishTrainerApp(QMainWindow): 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': ( + '
šŸ‡¬šŸ‡§ ENGLISH COMPREHENSION
' + '
{{EnglishText}}
' + '
{{EnglishAudio}}
' + ), + 'afmt': ( + '{{FrontSide}}
' + '
šŸ‡ŖšŸ‡ø SPANISH PRODUCTION
' + '
{{SpanishText}}
' + '
{{SpanishAudio}}
' + ), + }, + { + 'name': 'Card 2: Spanish -> English', + 'qfmt': ( + '
šŸ‡ŖšŸ‡ø SPANISH PRODUCTION
' + '
{{SpanishText}}
' + '
{{SpanishAudio}}
' + ), + 'afmt': ( + '{{FrontSide}}
' + '
šŸ‡¬šŸ‡§ ENGLISH COMPREHENSION
' + '
{{EnglishText}}
' + '
{{EnglishAudio}}
' + ), + }, + ], + 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 # ===================================================================== @@ -635,7 +979,7 @@ class SpanishTrainerApp(QMainWindow): tag_filter = self.review_tag_filter.text().strip() query = """ - SELECT t.translation_id, p1.text, p1.source_context, t.tags, p1.id + 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' @@ -659,7 +1003,7 @@ class SpanishTrainerApp(QMainWindow): for row_idx, row_data in enumerate(rows): self.review_table.insertRow(row_idx) - self.flashcard_ids_pool.append(row_data[4]) + 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 ""))) @@ -704,8 +1048,9 @@ class SpanishTrainerApp(QMainWindow): if not selected_ranges: return row = selected_ranges[0].topRow() - phrase_id = self.flashcard_ids_pool[row] - self.load_flashcard_by_id(phrase_id) + 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() @@ -713,467 +1058,14 @@ class SpanishTrainerApp(QMainWindow): if 0 <= next_row < self.translation_table.rowCount(): self.translation_table.setCurrentCell(next_row, 0) - # ===================================================================== - # āž• 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())) - - conn.commit() - conn.close() - - # Sync structural pointers instantly down to the Sandbox class variable state - 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", "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 - 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(), 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(), 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(), tx_id)) - conn.commit() - - self.current_sandbox_es_id = es_id - self.current_sandbox_en_id = en_id - - conn.close() - self.refresh_crud_table() - self.refresh_review_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.refresh_review_table() - self.input_tx_id.clear() - self.input_text_es.clear() - self.input_text_en.clear() - self.input_grammar_note.clear() - self.input_tags.clear() - self.current_sandbox_es_id = None - self.current_sandbox_en_id = None - - # ===================================================================== - # šŸ”Š AUDIO ENGINE & EXPANDED FLASHCARD ACTIONS - # ===================================================================== - def load_flashcard_by_id(self, phrase_id): - conn = get_connection() - cursor = conn.cursor() - cursor.execute(""" - SELECT t.translation_id, p1.text, p1.source_context, t.deck_name, p1.id, t.tags - FROM translations t - JOIN phrases p1 ON t.source_phrase_id = p1.id - WHERE p1.id = ? - """, (phrase_id,)) - record = cursor.fetchone() - conn.close() - - if record: - self.current_flashcard_id = record[4] - self.lbl_card_text.setText(record[1]) - self.lbl_card_meta.setText(f"Link ID: {record[0]} • Context: {record[2]} • Tag: {record[5]} • Deck: {record[3]}") - - def handle_load_next_card(self): - if not self.flashcard_ids_pool: - QMessageBox.information(self, "Empty Pool", "No flashcards match your selected filter configurations.") - return - - target_id = random.choice(self.flashcard_ids_pool) - - try: - matched_idx = self.flashcard_ids_pool.index(target_id) - self.review_table.setCurrentCell(matched_idx, 0) - except ValueError: + 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 - - self.load_flashcard_by_id(target_id) - - def handle_play_voice(self): - """Processes audio and calculates/caches duration via Flashcard Review pane.""" - if not self.current_flashcard_id: - return - conn = get_connection() - cursor = conn.cursor() - cursor.execute("SELECT text, language FROM phrases WHERE id = ?", (self.current_flashcard_id,)) - row = cursor.fetchone() - conn.close() - - if row: - text_str, lang = row - safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() - target_file = f"media/{safe_name}_{lang}_female.mp3" - - # Ensures voice is synthesized AND duration is analyzed/cached instantly - self.get_or_generate_audio_duration(self.current_flashcard_id, text_str, lang) - - 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() - - 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 row[1]: - display_text += f"\n\nšŸ’” Note: {row[1]}" - self.lbl_card_text.setText(display_text) - - def handle_sandbox_play_es(self): - """Processes audio and calculates/caches duration via Sandbox (CRUD) Spanish Play button.""" - text_str = self.input_text_es.toPlainText().strip() - if not text_str: - return - - # Unifies behavior: forces verification tracking down to the database row item - self.get_or_generate_audio_duration(self.current_sandbox_es_id, text_str, "es") - safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() - target_file = f"media/{safe_name}_es_female.mp3" - - if os.path.exists(target_file): - self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file))) - self.media_player.setPlaybackRate(float(self.combo_speed_es.currentText().replace("x", ""))) - self.media_player.play() - - def handle_sandbox_play_en(self): - """Processes audio and calculates/caches duration via Sandbox (CRUD) English Play button.""" - text_str = self.input_text_en.toPlainText().strip() - if not text_str: - return - - # Unifies behavior: forces verification tracking down to the database row item - self.get_or_generate_audio_duration(self.current_sandbox_en_id, text_str, "en") - safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() - target_file = f"media/{safe_name}_en_female.mp3" - - if os.path.exists(target_file): - self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file))) - self.media_player.setPlaybackRate(float(self.combo_speed_en.currentText().replace("x", ""))) - 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) - - # ===================================================================== - # šŸ“¦ ARTIFACT EXPORT GATEWAYS (GENANKI LIVE ENGINE WITH DUAL AUDIO) - # ===================================================================== - def handle_export_anki_deck(self): - """Compiles active subset into functional .apkg with bundled Spanish and English audio tracks.""" - if not self.flashcard_ids_pool: - QMessageBox.warning(self, "Export Cancelled", "The current study stack is empty. Verify your search filters.") - return - - model_hash = hashlib.sha256(b"castilian_voice_trainer_model_v2").hexdigest() - model_id = int(model_hash[:13], 16) - - spanish_note_model = genanki.Model( - model_id, - 'Castilian Audio Flashcard Model v2', - fields=[ - {'name': 'SpanishPhrase'}, - {'name': 'EnglishTranslation'}, - {'name': 'GrammarNotes'}, - {'name': 'SpanishAudio'}, - {'name': 'EnglishAudio'} - ], - templates=[ - { - 'name': 'Card 1: Auditory Identification', - 'qfmt': ( - '
{{SpanishPhrase}}
' - '
{{SpanishAudio}}
' - ), - 'afmt': ( - '{{FrontSide}}
' - '
{{EnglishTranslation}}
' - '
{{EnglishAudio}}

' - '
{{GrammarNotes}}
' - ), - }, - ], - css='.card { font-family: arial; font-size: 20px; text-align: center; background-color: #f8f9fa; }' - ) - - context_txt = self.review_context_filter.text().strip() - tag_txt = self.review_tag_filter.text().strip() - - if context_txt and tag_txt: - file_title = f"Spanish_Export_Context_{context_txt}_Tag_{tag_txt}.apkg" - elif context_txt: - file_title = f"Spanish_Export_Context_{context_txt}.apkg" - elif tag_txt: - file_title = f"Spanish_Export_Tag_{tag_txt}.apkg" - else: - file_title = "Spanish_Master_Deck.apkg" - - file_title = "".join([c for c in file_title if c.isalnum() or c in (".", "_", "-")]).strip() - destination_path = os.path.join(self.anki_export_dir, file_title) - - decks_map = {} - media_files_manifest = [] - - conn = get_connection() - cursor = conn.cursor() - placeholders = ",".join(["?"] * len(self.flashcard_ids_pool)) - query = f""" - SELECT t.deck_name, p1.text, p2.text, 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 p1.id IN ({placeholders}) - """ - cursor.execute(query, self.flashcard_ids_pool) - records = cursor.fetchall() - conn.close() - - for row in records: - db_deck_name = row[0].strip() if row[0] else "Castilian Spanish Master" - es_text, en_text = row[1].strip(), row[2].strip() - notes_text, tags_string = row[3].strip() if row[3] else "", row[4].strip() if row[4] else "" - es_id, en_id = row[5], row[6] - - self.get_or_generate_audio_duration(es_id, es_text, "es") - self.get_or_generate_audio_duration(en_id, en_text, "en") - - safe_es = "".join([c for c in es_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() - safe_en = "".join([c for c in en_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() - - relative_es_path = f"media/{safe_es}_es_female.mp3" - relative_en_path = f"media/{safe_en}_en_female.mp3" - - media_files_manifest.extend([relative_es_path, relative_en_path]) - - if db_deck_name not in decks_map: - deck_hash = hashlib.sha256(db_deck_name.encode('utf-8')).hexdigest() - deck_id = int(deck_hash[:13], 16) - decks_map[db_deck_name] = genanki.Deck(deck_id, db_deck_name) - - parsed_tags = [t for t in tags_string.replace(",", " ").split(" ") if t] - flash_note = genanki.Note( - model=spanish_note_model, - fields=[es_text, en_text, notes_text, f"[sound:{safe_es}_es_female.mp3]", f"[sound:{safe_en}_en_female.mp3]"], - tags=parsed_tags - ) - decks_map[db_deck_name].add_note(flash_note) - - try: - package = genanki.Package(list(decks_map.values())) - package.media_files = [m for m in set(media_files_manifest) if os.path.exists(m)] - package.write_to_file(destination_path) - QMessageBox.information(self, "Export Complete", f"✨ Packaged complete!\nOutput: {file_title}") - except Exception as export_error: - QMessageBox.critical(self, "Export Failed", f"Genanki failure:\n{export_error}") - - # ===================================================================== - # šŸŽ¬ DYNAMIC SLEEP-LEARNING VIDEO GENERATION LAYER - # ===================================================================== - def create_video_frame_image(self, text, output_path): - """Renders a visual slide text frame optimized for dark sleep study rooms.""" - img = Image.new('RGB', (1920, 1080), color='#111a24') - canvas = ImageDraw.Draw(img) - try: - font = ImageFont.load_default() - except: - font = None - - canvas.text((960, 540), text, fill="#e2e8f0", anchor="mm") - img.save(output_path) - - def handle_export_video_assets(self): - """ - Compiles filtered translation pairs into a structural sleep loop video. - Explicitly honors all parameters from the settings table: - 1) Select first language (Self-configuring anchor) - 2) Measure dynamic anchor track duration & print frame - 3) Print target translation image & sync text timeline - 4) Wait duration for user recall repetition (Configurable intermission) - 5) Target translation sequence repeat count loop (Configurable loop index) - """ - if not self.flashcard_ids_pool: - QMessageBox.warning(self, "Video Generation Cancelled", "The active filter queue contains no records.") - return - - try: - is_english_first = "English First" in self.video_first_lang - repeat_count = int(self.video_repeats_count) if str(self.video_repeats_count).isdigit() else 3 - pause_sec = float(self.video_pause_duration) - except ValueError: - QMessageBox.critical(self, "Configuration Error", "Check your settings table values for repeat multipliers and decimal pause seconds.") - return - - temp_dir = os.path.join(os.getcwd(), "video_scratch_pad") - os.makedirs(temp_dir, exist_ok=True) - - conn = get_connection() - cursor = conn.cursor() - placeholders = ",".join(["?"] * len(self.flashcard_ids_pool)) - query = f""" - 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 - WHERE p1.id IN ({placeholders}) - """ - cursor.execute(query, self.flashcard_ids_pool) - records = cursor.fetchall() - conn.close() - - print(f"šŸŽ¬ Compiling sleep loop timeline matching exact preferences ({self.video_first_lang}, Loops: {repeat_count}, Pause: {pause_sec}s)...") - video_segment_paths = [] - - try: - for idx, row in enumerate(records): - es_id, es_text = row[0], row[1].strip() - en_id, en_text = row[2], row[3].strip() - - # 2. How we know the length: Read cached DB duration or call zero-dependency ffprobe instantly - es_duration = self.get_or_generate_audio_duration(es_id, es_text, "es") - en_duration = self.get_or_generate_audio_duration(en_id, en_text, "en") - - safe_es = "".join([c for c in es_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() - safe_en = "".join([c for c in en_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() - - es_audio_path = f"media/{safe_es}_es_female.mp3" - en_audio_path = f"media/{safe_en}_en_female.mp3" - - # Condition 1: Evaluate selection setting matrix to establish Anchor vs Target translation flow - if is_english_first: - prime_text, prime_audio, prime_dur = en_text, en_audio_path, en_duration - target_text, target_audio, target_dur = es_text, es_audio_path, es_duration - else: - prime_text, prime_audio, prime_dur = es_text, es_audio_path, es_duration - target_text, target_audio, target_dur = en_text, en_audio_path, en_duration - - # Condition 2: Produce frame image with Anchor phrase and map audio file to exact track duration length - img_prime = os.path.join(temp_dir, f"frame_prime_{idx}.png") - self.create_video_frame_image(prime_text, img_prime) - clip_prime_path = os.path.join(temp_dir, f"chunk_prime_{idx}.mp4") - - subprocess.run([ - 'ffmpeg', '-y', '-loop', '1', '-i', img_prime, '-i', prime_audio, - '-c:v', 'libx264', '-t', str(prime_dur), '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '192k', clip_prime_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - video_segment_paths.append(clip_prime_path) - - # Condition 3: Produce translation text slide image frame and compute matching voice track length - img_target = os.path.join(temp_dir, f"frame_target_{idx}.png") - self.create_video_frame_image(target_text, img_target) - clip_target_path = os.path.join(temp_dir, f"chunk_target_{idx}.mp4") - - subprocess.run([ - 'ffmpeg', '-y', '-loop', '1', '-i', img_target, '-i', target_audio, - '-c:v', 'libx264', '-t', str(target_dur), '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '192k', clip_target_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - # Condition 4: Structural wait time gap for user replacement repetition frame (Silent intermission video block) - clip_silent_path = os.path.join(temp_dir, f"chunk_silent_{idx}.mp4") - subprocess.run([ - 'ffmpeg', '-y', '-f', 'lavfi', '-i', f'color=c=#111a24:s=1920x1080:d={pause_sec}', - '-f', 'lavfi', '-i', 'anullsrc=cl=stereo:r=44100', - '-t', str(pause_sec), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', clip_silent_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - # Condition 5: Loop execution cycle pattern back to Condition 3 (Repeats exact translation target X times) - for _ in range(repeat_count): - video_segment_paths.append(clip_target_path) - video_segment_paths.append(clip_silent_path) - - if not video_segment_paths: - QMessageBox.warning(self, "Export Error", "Timeline compilation matrix is empty.") - return - - # --- Concat Loop: Assembly sequence processing layer --- - manifest_path = os.path.join(temp_dir, "manifest.txt") - with open(manifest_path, "w", encoding="utf-8") as f: - for path in video_segment_paths: - f.write(f"file '{os.path.abspath(path)}'\n") - - output_file = os.path.join(self.video_export_dir, "Spanish_Sleep_Learning_Master.mp4") - subprocess.run([ - 'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', manifest_path, - '-c', 'copy', output_file - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - QMessageBox.information(self, "Success", f"Sleep Learning compilation track generated successfully!\nLocation: {output_file}") - - except Exception as e: - QMessageBox.critical(self, "Video Synthesis Suite Error", f"Timeline compiler hit a hitch:\n{e}") - finally: - if os.path.exists(temp_dir): - shutil.rmtree(temp_dir) if __name__ == "__main__": - print("šŸš€ Launching Core PyQt6 Framework Threads...") - try: - app = QApplication(sys.argv) - window = SpanishTrainerApp() - window.show() - sys.exit(app.exec()) - except Exception as fatal_error: - import traceback - traceback.print_exc() - sys.exit(1) \ No newline at end of file + app = QApplication(sys.argv) + app.setApplicationName("Spanish Voice Trainer") + app.setOrganizationName("Oxnee Pty. Ltd.") + window = SpanishTrainerApp() + window.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/spanish_trainer.db b/spanish_trainer.db index fc232cc..7234a38 100644 Binary files a/spanish_trainer.db and b/spanish_trainer.db differ