before adding Deck name to settings

This commit is contained in:
stephen 2026-06-21 23:54:17 +10:00
parent 904557b127
commit 2ab5a69160
2 changed files with 381 additions and 489 deletions

868
main.py
View file

@ -43,10 +43,15 @@ class SpanishTrainerApp(QMainWindow):
self.audio_output = QAudioOutput() self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output) 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_es_id = None
self.current_sandbox_en_id = None self.current_sandbox_en_id = None
self.flashcard_ids_pool = [] # Tracks currently filtered study list IDs
# Central Main Window Tabs Interface # Central Main Window Tabs Interface
self.tabs = QTabWidget() self.tabs = QTabWidget()
@ -105,7 +110,6 @@ class SpanishTrainerApp(QMainWindow):
def load_system_settings(self): def load_system_settings(self):
"""Loads persistent variables from the key-value settings table.""" """Loads persistent variables from the key-value settings table."""
# Baseline internal fallback defaults
self.anki_export_dir = os.getcwd() self.anki_export_dir = os.getcwd()
self.video_export_dir = os.getcwd() self.video_export_dir = os.getcwd()
self.video_first_lang = "English First (en -> es)" 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)) cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value))
conn.commit() conn.commit()
# Sync the application runtime settings instantly back to memory variables
if key == "video_first_language": if key == "video_first_language":
self.video_first_lang = value self.video_first_lang = value
elif key == "video_repeats_count": elif key == "video_repeats_count":
@ -158,14 +161,16 @@ class SpanishTrainerApp(QMainWindow):
Ensures a target audio track exists on disk, reads its run length 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. 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 return 2.5
safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() 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) os.makedirs("media", exist_ok=True)
target_file = f"media/{safe_name}_{lang}_female.mp3" target_file = f"media/{safe_name}_{lang}_female.mp3"
# 1. Generate audio track dynamically if missing
if not os.path.exists(target_file): if not os.path.exists(target_file):
try: try:
voice = "es-ES-ElviraNeural" if lang == "es" else "en-GB-SoniaNeural" 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}") print(f"❌ Core TTS System Exception: {tts_err}")
return 2.5 return 2.5
# 2. Return cached value from DB if it exists and isn't null if phrase_id:
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT duration FROM phrases WHERE id = ?", (phrase_id,)) cursor.execute("SELECT duration FROM phrases WHERE id = ?", (phrase_id,))
cached_row = cursor.fetchone() cached_row = cursor.fetchone()
if cached_row and cached_row[0] is not None: if cached_row and cached_row[0] is not None:
conn.close() conn.close()
return float(cached_row[0]) return float(cached_row[0])
# 3. Calculate audio duration via ffprobe and store it permanently
try: try:
cmd = [ cmd = [
'ffprobe', '-v', 'quiet', '-print_format', 'json', 'ffprobe', '-v', 'quiet', '-print_format', 'json',
@ -195,14 +199,15 @@ class SpanishTrainerApp(QMainWindow):
data = json.loads(result.stdout) data = json.loads(result.stdout)
duration = float(data['format']['duration']) duration = float(data['format']['duration'])
cursor.execute("UPDATE phrases SET duration = ? WHERE id = ?", (duration, phrase_id)) if phrase_id:
conn.commit() cursor.execute("UPDATE phrases SET duration = ? WHERE id = ?", (duration, phrase_id))
print(f"💾 Track length calculated and stored globally: {duration}s -> Phrase ID {phrase_id}") conn.commit()
except Exception as e: except Exception as e:
print(f"⚠️ Track structure analysis warning for {target_file}: {e}") print(f"⚠️ Track structure analysis warning for {target_file}: {e}")
duration = 2.5 duration = 2.5
finally: finally:
conn.close() if phrase_id and 'conn' in locals() and conn:
conn.close()
return duration return duration
@ -259,9 +264,11 @@ class SpanishTrainerApp(QMainWindow):
self.input_text_es = QTextEdit() self.input_text_es = QTextEdit()
self.input_text_es.setMaximumHeight(75) 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 = QTextEdit()
self.input_text_en.setMaximumHeight(75) 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 = QComboBox()
self.combo_type.addItems(["phrase", "sentence", "noun", "verb", "adjective"]) self.combo_type.addItems(["phrase", "sentence", "noun", "verb", "adjective"])
@ -445,7 +452,7 @@ class SpanishTrainerApp(QMainWindow):
action_buttons = QHBoxLayout() action_buttons = QHBoxLayout()
self.btn_play_voice = QPushButton("🗣️ Play Voice Track") 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_play_voice.clicked.connect(self.handle_play_voice)
self.btn_flip_card.clicked.connect(self.handle_flip_card) 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(self.line_video_dir)
video_layout.addWidget(btn_browse_video) video_layout.addWidget(btn_browse_video)
# UI Sleep Learning Configuration Fields
self.combo_first_lang = QComboBox() self.combo_first_lang = QComboBox()
self.combo_first_lang.addItems(["English First (en -> es)", "Spanish First (es -> en)"]) self.combo_first_lang.addItems(["English First (en -> es)", "Spanish First (es -> en)"])
self.combo_first_lang.setCurrentText(self.video_first_lang) self.combo_first_lang.setCurrentText(self.video_first_lang)
@ -526,7 +532,6 @@ class SpanishTrainerApp(QMainWindow):
form_layout.addRow("<b>Target Translation Loop Multiplier (Repeats):</b>", self.spin_video_repeats) form_layout.addRow("<b>Target Translation Loop Multiplier (Repeats):</b>", self.spin_video_repeats)
form_layout.addRow("<b>User Recall Repetition Frame Intermission (Seconds):</b>", self.spin_pause_duration) form_layout.addRow("<b>User Recall Repetition Frame Intermission (Seconds):</b>", 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 = QPushButton("⚡ Populate Audio & Timings Cache")
self.btn_sync_cache.setStyleSheet(""" self.btn_sync_cache.setStyleSheet("""
QPushButton { QPushButton {
@ -563,7 +568,6 @@ class SpanishTrainerApp(QMainWindow):
self.save_setting_to_db("video_export_directory", directory) self.save_setting_to_db("video_export_directory", directory)
def handle_bulk_populate_audio_cache(self): def handle_bulk_populate_audio_cache(self):
"""Iterates through all relational links, runs dynamic downloads, analyzes audio runtime lengths via ffprobe."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
@ -587,6 +591,346 @@ class SpanishTrainerApp(QMainWindow):
QMessageBox.information(self, "Cache Processing Complete", "All missing speech segments successfully written. Timings cached safely.") QMessageBox.information(self, "Cache Processing Complete", "All missing speech segments successfully written. Timings cached safely.")
# =====================================================================
# 💡 FLASHCARD OPERATIONS LOGIC COUPLING
# =====================================================================
def handle_live_speed_change(self):
val = self.slider_review_speed.value()
self.lbl_review_speed.setText(f"{val / 100:.2f}x")
def load_flashcard_by_id(self, translation_id):
"""Loads a translation node into memory and targets local text widgets without notes clutter."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.translation_id, p1.text, p2.text, p1.source_context, t.tags, t.notes
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE t.translation_id = ?
""", (translation_id,))
record = cursor.fetchone()
conn.close()
if record:
self.current_flashcard_id = record[0]
self.current_active_es_text = str(record[1])
self.current_active_en_text = str(record[2])
self.current_card_is_flipped = False
self.lbl_card_text.setText(self.current_active_en_text)
self.btn_flip_card.setText("👁️ Reveal Translation")
# --- t.notes (record[5]) has been intentionally omitted from this layout context ---
meta_str = f"Link ID: {record[0]} | Context: {record[3] or 'N/A'}"
if record[4]:
meta_str += f" | Tags: {record[4]}"
self.lbl_card_meta.setText(meta_str)
self.handle_play_voice()
def handle_flip_card(self):
if not self.current_flashcard_id:
return
if not self.current_card_is_flipped:
self.lbl_card_text.setText(self.current_active_es_text)
self.btn_flip_card.setText("👁️ Return to Prompt")
self.current_card_is_flipped = True
else:
self.lbl_card_text.setText(self.current_active_en_text)
self.btn_flip_card.setText("👁️ Reveal Translation")
self.current_card_is_flipped = False
def handle_play_voice(self):
if not self.current_flashcard_id:
return
text_target = self.current_active_es_text if self.current_card_is_flipped else self.current_active_en_text
lang_target = "es" if self.current_card_is_flipped else "en"
speed_target = self.lbl_review_speed.text()
self.execute_playback(text_target, lang_target, speed_target)
def handle_load_next_card(self):
if not self.flashcard_ids_pool:
QMessageBox.information(self, "Pool Empty", "No flashcards found in the matrix matching current criteria filters.")
return
next_tx_id = random.choice(self.flashcard_ids_pool)
for row in range(self.review_table.rowCount()):
if int(self.review_table.item(row, 0).text()) == next_tx_id:
self.review_table.setCurrentCell(row, 0)
break
self.load_flashcard_by_id(next_tx_id)
# =====================================================================
# 📦 GENANKI EXPORT ENGINE (RESTRUCTURED PURE TRANSLATION FLOW)
# =====================================================================
def handle_export_anki_deck(self):
"""
Gathers selected records from the matching criteria pool view, builds
a dual card template layout mapping English->Spanish (Card 1) and Spanish->English
(Card 2) forward-reverse pairs cleanly. Explicitly maps 4 fields: EnglishText,
EnglishAudio, SpanishText, SpanishAudio. Context and notes are fully removed.
"""
targets = self.flashcard_ids_pool
if not targets:
QMessageBox.warning(self, "Export Aborted", "The active flashcard pool filter is completely empty. Nothing to export.")
return
conn = get_connection()
cursor = conn.cursor()
placeholders = ",".join("?" for _ in targets)
cursor.execute(f"""
SELECT t.translation_id, p1.text, p2.text, p1.source_context, t.tags, t.notes, t.deck_name
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE t.translation_id IN ({placeholders})
""", targets)
records = cursor.fetchall()
conn.close()
if not records:
QMessageBox.information(self, "Export Processing", "No structured database entries matched your criteria indices parameters.")
return
# Unique Identification Anchor Codes for Anki Database Integrity
MODEL_ID = 1684321095
DECK_ID = 2026062011
# New Model structure aligning English Text/Audio with Spanish Text/Audio sequences
spanish_model = genanki.Model(
MODEL_ID,
'Castilian Learning Model (Pure Text & Audio Alignment)',
fields=[
{'name': 'EnglishText'},
{'name': 'EnglishAudio'},
{'name': 'SpanishText'},
{'name': 'SpanishAudio'}
],
templates=[
{
'name': 'Card 1: English -> Spanish',
'qfmt': (
'<div style="font-size: 13px; color: #2980b9; font-weight: bold; margin-bottom: 12px; letter-spacing: 1px;">🇬🇧 ENGLISH COMPREHENSION</div>'
'<div style="font-size: 25px; text-align: center; color: #2c3e50; font-family: Arial;">{{EnglishText}}</div>'
'<br><div style="text-align:center;">{{EnglishAudio}}</div>'
),
'afmt': (
'{{FrontSide}}<hr id="answer">'
'<div style="font-size: 13px; color: #e74c3c; font-weight: bold; margin-bottom: 12px; letter-spacing: 1px;">🇪🇸 SPANISH PRODUCTION</div>'
'<div style="font-size: 25px; text-align: center; color: #16a085; font-family: Arial; font-weight: bold;">{{SpanishText}}</div>'
'<br><div style="text-align:center;">{{SpanishAudio}}</div>'
),
},
{
'name': 'Card 2: Spanish -> English',
'qfmt': (
'<div style="font-size: 13px; color: #e74c3c; font-weight: bold; margin-bottom: 12px; letter-spacing: 1px;">🇪🇸 SPANISH PRODUCTION</div>'
'<div style="font-size: 25px; text-align: center; color: #2c3e50; font-family: Arial;">{{SpanishText}}</div>'
'<br><div style="text-align:center;">{{SpanishAudio}}</div>'
),
'afmt': (
'{{FrontSide}}<hr id="answer">'
'<div style="font-size: 13px; color: #2980b9; font-weight: bold; margin-bottom: 12px; letter-spacing: 1px;">🇬🇧 ENGLISH COMPREHENSION</div>'
'<div style="font-size: 25px; text-align: center; color: #16a085; font-family: Arial; font-weight: bold;">{{EnglishText}}</div>'
'<br><div style="text-align:center;">{{EnglishAudio}}</div>'
),
},
],
css='.card { font-family: arial; font-size: 20px; text-align: center; background-color: #fafafa; padding: 25px; border-radius: 8px; }'
)
deck_name_fallback = records[0][6] if records[0][6] else "Castilian Voice Trainer"
anki_deck = genanki.Deck(DECK_ID, f"Spanish::{deck_name_fallback}")
media_files_bundle = []
print(f"📦 Assembling Anki audio package for {len(records)} notes...")
for row in records:
tx_id, es_text, en_text, context, tags, notes, deck_group = row
es_clean = es_text.strip()
en_clean = en_text.strip()
# --- Spanish Media Track Setup ---
safe_es_name = "".join([c for c in es_clean if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
if not safe_es_name:
safe_es_name = hashlib.sha256(es_clean.encode('utf-8')).hexdigest()[:16]
audio_es_filename = f"{safe_es_name}_es_female.mp3"
full_es_path = f"media/{audio_es_filename}"
if not os.path.exists(full_es_path):
self.get_or_generate_audio_duration(None, es_clean, "es")
if os.path.exists(full_es_path):
media_files_bundle.append(full_es_path)
es_audio_tag = f"[sound:{audio_es_filename}]"
else:
es_audio_tag = ""
# --- English Media Track Setup ---
safe_en_name = "".join([c for c in en_clean if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
if not safe_en_name:
safe_en_name = hashlib.sha256(en_clean.encode('utf-8')).hexdigest()[:16]
audio_en_filename = f"{safe_en_name}_en_female.mp3"
full_en_path = f"media/{audio_en_filename}"
if not os.path.exists(full_en_path):
self.get_or_generate_audio_duration(None, en_clean, "en")
if os.path.exists(full_en_path):
media_files_bundle.append(full_en_path)
en_audio_tag = f"[sound:{audio_en_filename}]"
else:
en_audio_tag = ""
# Meta and Category Tag Construction
tag_list = str(tags).split() if tags else []
if context:
tag_list.append(str(context).replace(" ", "_").replace(".", "_"))
# Populate note fields sequentially matching model specification:
# EnglishText, EnglishAudio, SpanishText, SpanishAudio
anki_note = genanki.Note(
model=spanish_model,
fields=[
en_text,
en_audio_tag,
es_text,
es_audio_tag
],
tags=tag_list
)
anki_deck.add_note(anki_note)
# Output compilation assembly
export_output_path = os.path.join(self.anki_export_dir, f"{deck_name_fallback.replace('::', '_')}.apkg")
package = genanki.Package(anki_deck)
package.media_files = list(set(media_files_bundle)) # Excludes duplicate track instances
package.write_to_file(export_output_path)
print(f"✅ Success! Balanced text-audio cards exported cleanly: {export_output_path}")
QMessageBox.information(
self,
"Anki Package Compiled",
f"Successfully compiled {len(records)} balanced text-audio translation flashcard nodes.\n\nDestination:\n{export_output_path}"
)
def handle_export_video_assets(self):
pass
# =====================================================================
# CRUD ENGINE ATOMIC OPERATIONS LOGIC
# =====================================================================
def crud_create_pair(self):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO phrases (text, language, word_type, source_context) VALUES (?, 'es', ?, ?)
""", (self.input_text_es.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip()))
es_id = cursor.lastrowid
cursor.execute("""
INSERT INTO phrases (text, language, word_type, source_context) VALUES (?, 'en', ?, ?)
""", (self.input_text_en.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip()))
en_id = cursor.lastrowid
cursor.execute("""
INSERT INTO translations (source_phrase_id, target_phrase_id, deck_name, notes, tags) VALUES (?, ?, ?, ?, ?)
""", (es_id, en_id, self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip(), self.input_tags.text().strip()))
tx_id = cursor.lastrowid
conn.commit()
conn.close()
self.input_tx_id.setText(str(tx_id))
self.current_sandbox_es_id = es_id
self.current_sandbox_en_id = en_id
self.refresh_crud_table()
self.refresh_review_table()
QMessageBox.information(self, "Success", f"Isolated phrase pairs created and bound to Translation ID {tx_id}.")
def crud_update_pair(self):
tx_id_str = self.input_tx_id.text().strip()
if not tx_id_str:
QMessageBox.warning(self, "Update Target Missing", "No Translation Link ID found. Select an existing record node or create a fresh link pair first.")
return
if self.current_sandbox_es_id is None or self.current_sandbox_en_id is None:
QMessageBox.warning(self, "Phrase Nodes Untracked", "Underlying unique identifiers for individual language components are missing. Reselect the row from the left panel matrix grid.")
return
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
UPDATE phrases
SET text = ?, word_type = ?, source_context = ?, duration = NULL
WHERE id = ?
""", (self.input_text_es.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), self.current_sandbox_es_id))
cursor.execute("""
UPDATE phrases
SET text = ?, word_type = ?, source_context = ?, duration = NULL
WHERE id = ?
""", (self.input_text_en.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), self.current_sandbox_en_id))
cursor.execute("""
UPDATE translations
SET deck_name = ?, notes = ?, tags = ?
WHERE translation_id = ?
""", (self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip(), self.input_tags.text().strip(), int(tx_id_str)))
conn.commit()
except Exception as e:
QMessageBox.critical(self, "Database Error", f"Failed to execute field modifications inside SQL engine: {e}")
finally:
conn.close()
self.refresh_crud_table()
self.refresh_review_table()
QMessageBox.information(self, "Success", f"Node structural fields updated successfully. Translation Link ID {tx_id_str} remains active.")
def crud_delete_pair(self):
pass
def execute_playback(self, text_str, lang, speed_text):
txt = text_str.strip()
if not txt:
return
self.get_or_generate_audio_duration(None, txt, lang)
safe_name = "".join([c for c in txt if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
if not safe_name:
safe_name = hashlib.sha256(txt.encode('utf-8')).hexdigest()[:16]
target_file = f"media/{safe_name}_{lang}_female.mp3"
if os.path.exists(target_file):
try:
multiplier = float(speed_text.replace("x", ""))
except ValueError:
multiplier = 1.0
self.media_player.stop()
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file)))
self.media_player.setLoops(1)
self.media_player.setPlaybackRate(multiplier)
self.media_player.play()
def handle_sandbox_play_es(self):
self.execute_playback(self.input_text_es.toPlainText(), "es", self.combo_speed_es.currentText())
def handle_sandbox_play_en(self):
self.execute_playback(self.input_text_en.toPlainText(), "en", self.combo_speed_en.currentText())
# ===================================================================== # =====================================================================
# ⚡ DATA MATRIX CONTROL & SYNCHRONIZATION VIEWS # ⚡ DATA MATRIX CONTROL & SYNCHRONIZATION VIEWS
# ===================================================================== # =====================================================================
@ -635,7 +979,7 @@ class SpanishTrainerApp(QMainWindow):
tag_filter = self.review_tag_filter.text().strip() tag_filter = self.review_tag_filter.text().strip()
query = """ 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 FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id JOIN phrases p1 ON t.source_phrase_id = p1.id
WHERE p1.language = 'es' WHERE p1.language = 'es'
@ -659,7 +1003,7 @@ class SpanishTrainerApp(QMainWindow):
for row_idx, row_data in enumerate(rows): for row_idx, row_data in enumerate(rows):
self.review_table.insertRow(row_idx) 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): for col_idx in range(4):
val = row_data[col_idx] val = row_data[col_idx]
self.review_table.setItem(row_idx, col_idx, QTableWidgetItem(str(val if val is not None else ""))) 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: if not selected_ranges:
return return
row = selected_ranges[0].topRow() row = selected_ranges[0].topRow()
phrase_id = self.flashcard_ids_pool[row] if row < len(self.flashcard_ids_pool):
self.load_flashcard_by_id(phrase_id) translation_id = self.flashcard_ids_pool[row]
self.load_flashcard_by_id(translation_id)
def step_table_row(self, direction): def step_table_row(self, direction):
current_row = self.translation_table.currentRow() current_row = self.translation_table.currentRow()
@ -713,467 +1058,14 @@ class SpanishTrainerApp(QMainWindow):
if 0 <= next_row < self.translation_table.rowCount(): if 0 <= next_row < self.translation_table.rowCount():
self.translation_table.setCurrentCell(next_row, 0) self.translation_table.setCurrentCell(next_row, 0)
# ===================================================================== def clear_id_if_new_entry(self):
# CRUD ENGINE ATOMIC OPERATIONS LOGIC if self.input_tx_id.text() and not (self.input_text_es.hasFocus() or self.input_text_en.hasFocus()):
# =====================================================================
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:
pass 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': (
'<div style="font-family: Arial; font-size: 24px; text-align: center; color: #2c3e50;">{{SpanishPhrase}}</div>'
'<br><div style="text-align: center;">{{SpanishAudio}}</div>'
),
'afmt': (
'{{FrontSide}}<hr id="answer">'
'<div style="font-family: Arial; font-size: 20px; text-align: center; color: #27ae60; font-weight: bold;">{{EnglishTranslation}}</div>'
'<div style="text-align: center; margin-top: 5px;">{{EnglishAudio}}</div><br>'
'<div style="font-family: Arial; font-size: 14px; text-align: center; color: #7f8c8d; font-style: italic;">{{GrammarNotes}}</div>'
),
},
],
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__": if __name__ == "__main__":
print("🚀 Launching Core PyQt6 Framework Threads...") app = QApplication(sys.argv)
try: app.setApplicationName("Spanish Voice Trainer")
app = QApplication(sys.argv) app.setOrganizationName("Oxnee Pty. Ltd.")
window = SpanishTrainerApp() window = SpanishTrainerApp()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
except Exception as fatal_error:
import traceback
traceback.print_exc()
sys.exit(1)

Binary file not shown.