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