produces mp4 file

This commit is contained in:
stephen 2026-06-20 11:55:05 +10:00
parent 1315678bd3
commit fff7a31891
4 changed files with 1475 additions and 637 deletions

File diff suppressed because it is too large Load diff

Binary file not shown.

446
main.py
View file

@ -3,6 +3,10 @@ import sys
import os import os
import random import random
import hashlib import hashlib
import subprocess
import json
import asyncio
import shutil
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout, QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox, QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox,
@ -12,8 +16,10 @@ from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
# Third-Party Anki Generation Tooling # Third-Party Tooling
import genanki import genanki
import edge_tts
from PIL import Image, ImageDraw, ImageFont
# Internal Project Module Imports # Internal Project Module Imports
from database.connection import init_db, get_connection from database.connection import init_db, get_connection
@ -24,12 +30,12 @@ class SpanishTrainerApp(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("Castilian Voice Trainer Pro") self.setWindowTitle("Castilian Voice Trainer Pro")
self.setMinimumSize(1200, 750) self.setMinimumSize(1200, 800)
# 1. Initialize schema structures and check ingestion status # 1. Initialize schema structures and check ingestion status
self.ensure_database_populated() self.ensure_database_populated()
# Load system persistent settings from DB # Load system persistent settings from DB (including sleep-learning fields)
self.load_system_settings() self.load_system_settings()
# Audio Player Architecture Setup # Audio Player Architecture Setup
@ -38,6 +44,8 @@ class SpanishTrainerApp(QMainWindow):
self.media_player.setAudioOutput(self.audio_output) self.media_player.setAudioOutput(self.audio_output)
self.current_flashcard_id = None self.current_flashcard_id = None
self.current_sandbox_es_id = None
self.current_sandbox_en_id = None
self.flashcard_ids_pool = [] # Tracks currently filtered study list IDs self.flashcard_ids_pool = [] # Tracks currently filtered study list IDs
# Central Main Window Tabs Interface # Central Main Window Tabs Interface
@ -60,8 +68,17 @@ class SpanishTrainerApp(QMainWindow):
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
# Ensure our settings table and key columns are structurally sound
cursor.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);") cursor.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);")
# Seamlessly inject duration column into phrases if it doesn't exist
cursor.execute("PRAGMA table_info(phrases);")
columns = [row[1] for row in cursor.fetchall()]
if "duration" not in columns:
print("🔄 Modifying phrases schema to support floating-point duration tracking...")
cursor.execute("ALTER TABLE phrases ADD COLUMN duration REAL;")
conn.commit()
try: try:
cursor.execute("SELECT COUNT(*) FROM translations") cursor.execute("SELECT COUNT(*) FROM translations")
count = cursor.fetchone()[0] count = cursor.fetchone()[0]
@ -87,9 +104,13 @@ class SpanishTrainerApp(QMainWindow):
print(f"❌ Error: Source document '{pdf_file}' is missing from the directory root.") print(f"❌ Error: Source document '{pdf_file}' is missing from the directory root.")
def load_system_settings(self): def load_system_settings(self):
"""Loads persistent path directories 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_repeats_count = "3"
self.video_pause_duration = "4.0"
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -101,6 +122,12 @@ class SpanishTrainerApp(QMainWindow):
self.anki_export_dir = row[1] self.anki_export_dir = row[1]
elif row[0] == "video_export_directory": elif row[0] == "video_export_directory":
self.video_export_dir = row[1] self.video_export_dir = row[1]
elif row[0] == "video_first_language":
self.video_first_lang = row[1]
elif row[0] == "video_repeats_count":
self.video_repeats_count = row[1]
elif row[0] == "video_pause_duration":
self.video_pause_duration = row[1]
except Exception as e: except Exception as e:
print(f"⚠️ Failed to read application settings from database: {e}") print(f"⚠️ Failed to read application settings from database: {e}")
finally: finally:
@ -113,11 +140,72 @@ class SpanishTrainerApp(QMainWindow):
try: try:
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":
self.video_first_lang = value
elif key == "video_repeats_count":
self.video_repeats_count = value
elif key == "video_pause_duration":
self.video_pause_duration = value
except Exception as e: except Exception as e:
print(f"❌ Critical: Failed to save setting '{key}': {e}") print(f"❌ Critical: Failed to save setting '{key}': {e}")
finally: finally:
conn.close() conn.close()
def get_or_generate_audio_duration(self, phrase_id, text_str, lang):
"""
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:
return 2.5
safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
os.makedirs("media", exist_ok=True)
target_file = f"media/{safe_name}_{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"
communicate = edge_tts.Communicate(text_str, voice)
asyncio.run(communicate.save(target_file))
except Exception as tts_err:
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])
# 3. Calculate audio duration via ffprobe and store it permanently
try:
cmd = [
'ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_entries', 'format=duration', target_file
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
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}")
except Exception as e:
print(f"⚠️ Track structure analysis warning for {target_file}: {e}")
duration = 2.5
finally:
conn.close()
return duration
# ===================================================================== # =====================================================================
# 🗄️ TAB 1: TRANSLATION-CENTRIC PHRASE SANDBOX (CRUD) # 🗄️ TAB 1: TRANSLATION-CENTRIC PHRASE SANDBOX (CRUD)
# ===================================================================== # =====================================================================
@ -418,11 +506,44 @@ 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.addItems(["English First (en -> es)", "Spanish First (es -> en)"])
self.combo_first_lang.setCurrentText(self.video_first_lang)
self.combo_first_lang.currentTextChanged.connect(lambda v: self.save_setting_to_db("video_first_language", v))
self.spin_video_repeats = QLineEdit(self.video_repeats_count)
self.spin_video_repeats.setFixedWidth(60)
self.spin_video_repeats.textChanged.connect(lambda v: self.save_setting_to_db("video_repeats_count", v))
self.spin_pause_duration = QLineEdit(self.video_pause_duration)
self.spin_pause_duration.setFixedWidth(60)
self.spin_pause_duration.textChanged.connect(lambda v: self.save_setting_to_db("video_pause_duration", v))
form_layout.addRow("<b>Anki Deck Export Destination:</b>", anki_layout) form_layout.addRow("<b>Anki Deck Export Destination:</b>", anki_layout)
form_layout.addRow("<b>Video Assembly Output Target:</b>", video_layout) form_layout.addRow("<b>Video Assembly Output Target:</b>", video_layout)
form_layout.addRow("<b>Introductory Anchor Audio Language:</b>", self.combo_first_lang)
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)
# 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 {
font-weight: bold;
background-color: #e67e22;
color: white;
padding: 10px;
border-radius: 5px;
font-size: 13px;
}
QPushButton:hover { background-color: #d35400; }
""")
self.btn_sync_cache.clicked.connect(self.handle_bulk_populate_audio_cache)
layout.addWidget(QLabel("<h2>Application Preferences & Workspace Routing</h2>")) layout.addWidget(QLabel("<h2>Application Preferences & Workspace Routing</h2>"))
layout.addWidget(settings_frame) layout.addWidget(settings_frame)
layout.addWidget(self.btn_sync_cache)
layout.addStretch() layout.addStretch()
self.tabs.addTab(tab, "⚙️ Settings") self.tabs.addTab(tab, "⚙️ Settings")
@ -441,6 +562,31 @@ class SpanishTrainerApp(QMainWindow):
self.line_video_dir.setText(directory) self.line_video_dir.setText(directory)
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):
"""Iterates through all relational links, runs dynamic downloads, analyzes audio runtime lengths via ffprobe."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
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
""")
records = cursor.fetchall()
conn.close()
if not records:
QMessageBox.information(self, "Cache Synchronizer", "No valid translation pairs exist inside the database to process.")
return
print(f"⚡ Processing structural cache updates for {len(records)} node linkages...")
for row in records:
es_id, es_text, en_id, en_text = row[0], row[1].strip(), row[2], row[3].strip()
self.get_or_generate_audio_duration(en_id, en_text, "en")
self.get_or_generate_audio_duration(es_id, es_text, "es")
QMessageBox.information(self, "Cache Processing Complete", "All missing speech segments successfully written. Timings cached safely.")
# ===================================================================== # =====================================================================
# ⚡ DATA MATRIX CONTROL & SYNCHRONIZATION VIEWS # ⚡ DATA MATRIX CONTROL & SYNCHRONIZATION VIEWS
# ===================================================================== # =====================================================================
@ -532,7 +678,7 @@ class SpanishTrainerApp(QMainWindow):
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT t.translation_id, p1.text, p2.text, p1.word_type, p1.source_context, t.deck_name, t.notes, t.tags SELECT t.translation_id, p1.text, p2.text, p1.word_type, p1.source_context, t.deck_name, t.notes, t.tags, p1.id, p2.id
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
JOIN phrases p2 ON t.target_phrase_id = p2.id JOIN phrases p2 ON t.target_phrase_id = p2.id
@ -550,6 +696,8 @@ class SpanishTrainerApp(QMainWindow):
self.input_tags.setText(str(record[7]) if record[7] is not None else "") self.input_tags.setText(str(record[7]) if record[7] is not None else "")
self.input_deck_tag.setText(str(record[5]) if record[5] else "General") self.input_deck_tag.setText(str(record[5]) if record[5] else "General")
self.input_grammar_note.setPlainText(str(record[6]) if record[6] is not None else "") self.input_grammar_note.setPlainText(str(record[6]) if record[6] is not None else "")
self.current_sandbox_es_id = record[8]
self.current_sandbox_en_id = record[9]
def handle_review_table_select(self): def handle_review_table_select(self):
selected_ranges = self.review_table.selectedRanges() selected_ranges = self.review_table.selectedRanges()
@ -566,7 +714,7 @@ class SpanishTrainerApp(QMainWindow):
self.translation_table.setCurrentCell(next_row, 0) self.translation_table.setCurrentCell(next_row, 0)
# ===================================================================== # =====================================================================
# ENGINE ATOMIC OPERATIONS LOGIC (CRUD MODIFIERS) # CRUD ENGINE ATOMIC OPERATIONS LOGIC
# ===================================================================== # =====================================================================
def crud_create_pair(self): def crud_create_pair(self):
conn = get_connection() conn = get_connection()
@ -588,6 +736,11 @@ class SpanishTrainerApp(QMainWindow):
conn.commit() conn.commit()
conn.close() 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_crud_table()
self.refresh_review_table() self.refresh_review_table()
QMessageBox.information(self, "Success", "Isolated phrase pairs created and relational link bound.") QMessageBox.information(self, "Success", "Isolated phrase pairs created and relational link bound.")
@ -604,14 +757,17 @@ class SpanishTrainerApp(QMainWindow):
if ids: if ids:
es_id, en_id = ids es_id, en_id = ids
cursor.execute("UPDATE phrases SET text=?, word_type=?, source_context=? WHERE id=?", 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)) (self.input_text_es.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), es_id))
cursor.execute("UPDATE phrases SET text=?, word_type=?, source_context=? WHERE id=?", 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)) (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=?", 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)) (self.input_deck_tag.text().strip() or "General", self.input_grammar_note.toPlainText().strip(), self.input_tags.text().strip(), tx_id))
conn.commit() conn.commit()
self.current_sandbox_es_id = es_id
self.current_sandbox_en_id = en_id
conn.close() conn.close()
self.refresh_crud_table() self.refresh_crud_table()
self.refresh_review_table() self.refresh_review_table()
@ -639,6 +795,8 @@ class SpanishTrainerApp(QMainWindow):
self.input_text_en.clear() self.input_text_en.clear()
self.input_grammar_note.clear() self.input_grammar_note.clear()
self.input_tags.clear() self.input_tags.clear()
self.current_sandbox_es_id = None
self.current_sandbox_en_id = None
# ===================================================================== # =====================================================================
# 🔊 AUDIO ENGINE & EXPANDED FLASHCARD ACTIONS # 🔊 AUDIO ENGINE & EXPANDED FLASHCARD ACTIONS
@ -676,6 +834,7 @@ class SpanishTrainerApp(QMainWindow):
self.load_flashcard_by_id(target_id) self.load_flashcard_by_id(target_id)
def handle_play_voice(self): def handle_play_voice(self):
"""Processes audio and calculates/caches duration via Flashcard Review pane."""
if not self.current_flashcard_id: if not self.current_flashcard_id:
return return
conn = get_connection() conn = get_connection()
@ -687,19 +846,10 @@ class SpanishTrainerApp(QMainWindow):
if row: if row:
text_str, lang = row text_str, lang = row
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()
os.makedirs("media", exist_ok=True)
target_file = f"media/{safe_name}_{lang}_female.mp3" target_file = f"media/{safe_name}_{lang}_female.mp3"
if not os.path.exists(target_file): # Ensures voice is synthesized AND duration is analyzed/cached instantly
print(f"🔊 Review Fallback: Synthesizing missing audio asset on the fly for '{text_str}'...") self.get_or_generate_audio_duration(self.current_flashcard_id, text_str, lang)
try:
import asyncio
import edge_tts
communicate = edge_tts.Communicate(text_str, "es-ES-ElviraNeural")
asyncio.run(communicate.save(target_file))
except Exception as tts_err:
QMessageBox.critical(self, "TTS Error", f"Review pipeline failed to synthesize track:\n{tts_err}")
return
if os.path.exists(target_file): if os.path.exists(target_file):
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file))) self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file)))
@ -728,45 +878,33 @@ class SpanishTrainerApp(QMainWindow):
self.lbl_card_text.setText(display_text) self.lbl_card_text.setText(display_text)
def handle_sandbox_play_es(self): 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() text_str = self.input_text_es.toPlainText().strip()
if not text_str: if not text_str:
return 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() safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
os.makedirs("media", exist_ok=True)
target_file = f"media/{safe_name}_es_female.mp3" target_file = f"media/{safe_name}_es_female.mp3"
if not os.path.exists(target_file): if os.path.exists(target_file):
try:
import asyncio
import edge_tts
communicate = edge_tts.Communicate(text_str, "es-ES-ElviraNeural")
asyncio.run(communicate.save(target_file))
except Exception as e:
QMessageBox.critical(self, "TTS Error", str(e))
return
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(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.setPlaybackRate(float(self.combo_speed_es.currentText().replace("x", "")))
self.media_player.play() self.media_player.play()
def handle_sandbox_play_en(self): 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() text_str = self.input_text_en.toPlainText().strip()
if not text_str: if not text_str:
return 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() safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
os.makedirs("media", exist_ok=True)
target_file = f"media/{safe_name}_en_female.mp3" target_file = f"media/{safe_name}_en_female.mp3"
if not os.path.exists(target_file): if os.path.exists(target_file):
try:
import asyncio
import edge_tts
communicate = edge_tts.Communicate(text_str, "en-GB-SoniaNeural")
asyncio.run(communicate.save(target_file))
except Exception as e:
QMessageBox.critical(self, "TTS Error", str(e))
return
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(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.setPlaybackRate(float(self.combo_speed_en.currentText().replace("x", "")))
self.media_player.play() self.media_player.play()
@ -786,11 +924,9 @@ class SpanishTrainerApp(QMainWindow):
QMessageBox.warning(self, "Export Cancelled", "The current study stack is empty. Verify your search filters.") QMessageBox.warning(self, "Export Cancelled", "The current study stack is empty. Verify your search filters.")
return return
# 1. Generate Stable Cryptographic Note Model ID
model_hash = hashlib.sha256(b"castilian_voice_trainer_model_v2").hexdigest() model_hash = hashlib.sha256(b"castilian_voice_trainer_model_v2").hexdigest()
model_id = int(model_hash[:13], 16) model_id = int(model_hash[:13], 16)
# Standardized Castilian Note Template Structure Definition (Includes English Audio)
spanish_note_model = genanki.Model( spanish_note_model = genanki.Model(
model_id, model_id,
'Castilian Audio Flashcard Model v2', 'Castilian Audio Flashcard Model v2',
@ -819,7 +955,6 @@ class SpanishTrainerApp(QMainWindow):
css='.card { font-family: arial; font-size: 20px; text-align: center; background-color: #f8f9fa; }' css='.card { font-family: arial; font-size: 20px; text-align: center; background-color: #f8f9fa; }'
) )
# Determine dynamic manifest names based on runtime filter choices
context_txt = self.review_context_filter.text().strip() context_txt = self.review_context_filter.text().strip()
tag_txt = self.review_tag_filter.text().strip() tag_txt = self.review_tag_filter.text().strip()
@ -832,22 +967,17 @@ class SpanishTrainerApp(QMainWindow):
else: else:
file_title = "Spanish_Master_Deck.apkg" file_title = "Spanish_Master_Deck.apkg"
# Clean filename characters for safety across platforms
file_title = "".join([c for c in file_title if c.isalnum() or c in (".", "_", "-")]).strip() 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) destination_path = os.path.join(self.anki_export_dir, file_title)
decks_map = {} decks_map = {}
media_files_manifest = [] media_files_manifest = []
missing_es_audio = 0
missing_en_audio = 0
# 2. Fetch specific database rows matching the current runtime array pool
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
placeholders = ",".join(["?"] * len(self.flashcard_ids_pool)) placeholders = ",".join(["?"] * len(self.flashcard_ids_pool))
query = f""" query = f"""
SELECT t.deck_name, p1.text, p2.text, t.notes, t.tags SELECT t.deck_name, p1.text, p2.text, t.notes, t.tags, p1.id, p2.id
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
JOIN phrases p2 ON t.target_phrase_id = p2.id JOIN phrases p2 ON t.target_phrase_id = p2.id
@ -857,94 +987,187 @@ class SpanishTrainerApp(QMainWindow):
records = cursor.fetchall() records = cursor.fetchall()
conn.close() conn.close()
# 3. Iterate over records and construct notes
for row in records: for row in records:
db_deck_name = row[0].strip() if row[0] else "Castilian Spanish Master" db_deck_name = row[0].strip() if row[0] else "Castilian Spanish Master"
es_text = row[1].strip() es_text, en_text = row[1].strip(), row[2].strip()
en_text = row[2].strip() notes_text, tags_string = row[3].strip() if row[3] else "", row[4].strip() if row[4] else ""
notes_text = row[3].strip() if row[3] else "" es_id, en_id = row[5], row[6]
tags_string = row[4].strip() if row[4] else ""
# --- Handle Spanish Audio Mapping --- self.get_or_generate_audio_duration(es_id, es_text, "es")
safe_es_audio_name = "".join([c for c in es_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() self.get_or_generate_audio_duration(en_id, en_text, "en")
relative_es_path = f"media/{safe_es_audio_name}_es_female.mp3"
es_filename_only = f"{safe_es_audio_name}_es_female.mp3"
if os.path.exists(relative_es_path): safe_es = "".join([c for c in es_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
if relative_es_path not in media_files_manifest: safe_en = "".join([c for c in en_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
media_files_manifest.append(relative_es_path)
anki_es_audio_field = f"[sound:{es_filename_only}]"
else:
missing_es_audio += 1
anki_es_audio_field = ""
# --- Handle English Audio Mapping --- relative_es_path = f"media/{safe_es}_es_female.mp3"
safe_en_audio_name = "".join([c for c in en_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower() relative_en_path = f"media/{safe_en}_en_female.mp3"
relative_en_path = f"media/{safe_en_audio_name}_en_female.mp3"
en_filename_only = f"{safe_en_audio_name}_en_female.mp3"
if os.path.exists(relative_en_path): media_files_manifest.extend([relative_es_path, relative_en_path])
if relative_en_path not in media_files_manifest:
media_files_manifest.append(relative_en_path)
anki_en_audio_field = f"[sound:{en_filename_only}]"
else:
missing_en_audio += 1
anki_en_audio_field = ""
# Handle dynamic deck assignment structure
if db_deck_name not in decks_map: if db_deck_name not in decks_map:
deck_hash = hashlib.sha256(db_deck_name.encode('utf-8')).hexdigest() deck_hash = hashlib.sha256(db_deck_name.encode('utf-8')).hexdigest()
deck_id = int(deck_hash[:13], 16) deck_id = int(deck_hash[:13], 16)
decks_map[db_deck_name] = genanki.Deck(deck_id, db_deck_name) decks_map[db_deck_name] = genanki.Deck(deck_id, db_deck_name)
# Split tags string by spaces into an array list for genanki
parsed_tags = [t for t in tags_string.replace(",", " ").split(" ") if t] parsed_tags = [t for t in tags_string.replace(",", " ").split(" ") if t]
# Construct unique note entry template matching updated fields array
flash_note = genanki.Note( flash_note = genanki.Note(
model=spanish_note_model, model=spanish_note_model,
fields=[es_text, en_text, notes_text, anki_es_audio_field, anki_en_audio_field], fields=[es_text, en_text, notes_text, f"[sound:{safe_es}_es_female.mp3]", f"[sound:{safe_en}_en_female.mp3]"],
tags=parsed_tags tags=parsed_tags
) )
decks_map[db_deck_name].add_note(flash_note) decks_map[db_deck_name].add_note(flash_note)
# 4. Package all deck components into an .apkg container
try: try:
package = genanki.Package(list(decks_map.values())) package = genanki.Package(list(decks_map.values()))
package.media_files = media_files_manifest package.media_files = [m for m in set(media_files_manifest) if os.path.exists(m)]
package.write_to_file(destination_path) package.write_to_file(destination_path)
QMessageBox.information(self, "Export Complete", f"✨ Packaged complete!\nOutput: {file_title}")
success_msg = (
f"✨ Packaging complete!\n\n"
f"File Output: {file_title}\n"
f"Destination: {self.anki_export_dir}\n"
f"Total Cards Built: {len(records)}\n"
f"Decks Created: {len(decks_map)}"
)
if missing_es_audio > 0 or missing_en_audio > 0:
success_msg += f"\n\n⚠️ Note: Missing files detected (Spanish: {missing_es_audio}, English: {missing_en_audio}). " \
f"Cards were bundled without corresponding audio fields if they hadn't been triggered in the review window yet."
QMessageBox.information(self, "Export Complete", success_msg)
except Exception as export_error: except Exception as export_error:
QMessageBox.critical(self, "Export Failed", f"Genanki package compression pipeline failure:\n{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): def handle_export_video_assets(self):
"""Action handler loop for compiling video cards.""" """
QMessageBox.information( Compiles filtered translation pairs into a structural sleep loop video.
self, "Video Synthesis Suite", Explicitly honors all parameters from the settings table:
f"Staging visual timeline render frames loop!\n\n" 1) Select first language (Self-configuring anchor)
f"Target Directory: {self.video_export_dir}\n" 2) Measure dynamic anchor track duration & print frame
f"Currently Filtered Scope count: {len(self.flashcard_ids_pool)} sequences." 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)
# =====================================================================
# 🚀 DIAGNOSTIC STARTUP FRAMEWORK WRAPPER
# =====================================================================
if __name__ == "__main__": if __name__ == "__main__":
print("🚀 Initializing PyQt6 Application Framework...") print("🚀 Launching Core PyQt6 Framework Threads...")
try: try:
app = QApplication(sys.argv) app = QApplication(sys.argv)
window = SpanishTrainerApp() window = SpanishTrainerApp()
@ -952,6 +1175,5 @@ if __name__ == "__main__":
sys.exit(app.exec()) sys.exit(app.exec())
except Exception as fatal_error: except Exception as fatal_error:
import traceback import traceback
print("\n❌ CRITICAL CRASH DETECTED ON CORE STARTUP THREAD!")
traceback.print_exc() traceback.print_exc()
sys.exit(1) sys.exit(1)

Binary file not shown.