diff --git a/doc/Notes.md b/doc/Notes.md
index 5402d92..005ff8c 100644
--- a/doc/Notes.md
+++ b/doc/Notes.md
@@ -1,521 +1,1137 @@
-- [1. spanish-voice-trainer](#1-spanish-voice-trainer)
-- [2. Project Summary:](#2-project-summary)
- - [2.1. π οΈ The Core Technical Stack](#21-οΈ-the-core-technical-stack)
- - [2.2. π How the System Works](#22--how-the-system-works)
-- [3. Create Basic Structure as suggested](#3-create-basic-structure-as-suggested)
- - [3.1. Commands](#31-commands)
-- [4. use uv](#4-use-uv)
-- [5. Install Your Dependency Stack](#5-install-your-dependency-stack)
-- [6. checking the packages are installed](#6-checking-the-packages-are-installed)
-- [7. Running Scripts with uv Moving Forward](#7-running-scripts-with-uv-moving-forward)
-- [8. this is the basic](#8-this-is-the-basic)
-- [9. Developed sqllite schema](#9-developed-sqllite-schema)
- - [9.1. The Structure inside database/connection.py](#91-the-structure-inside-databaseconnectionpy)
- - [9.2. Triggering It inside main.py](#92-triggering-it-inside-mainpy)
- - [9.3. Why This Placement is Ideal](#93-why-this-placement-is-ideal)
- - [9.3.1. Idempotent Execution: Using CREATE TABLE IF NOT EXISTS means this code runs beautifully every time you start your app. If the database is already there, SQLite silently skips creation and proceeds to boot without overwriting your hard-earned Aula Internacional data.](#931-idempotent-execution-using-create-table-if-not-exists-means-this-code-runs-beautifully-every-time-you-start-your-app-if-the-database-is-already-there-sqlite-silently-skips-creation-and-proceeds-to-boot-without-overwriting-your-hard-earned-aula-internacional-data)
- - [9.3.2. Crash Prevention: By placing it at the absolute top of main(), you ensure that no other component (like a UI field trying to load your textbook list) can execute queries against a database that hasn't finished setting up its columns yet.](#932-crash-prevention-by-placing-it-at-the-absolute-top-of-main-you-ensure-that-no-other-component-like-a-ui-field-trying-to-load-your-textbook-list-can-execute-queries-against-a-database-that-hasnt-finished-setting-up-its-columns-yet)
- - [9.3.3. Clean Decoupling: Your root main.py handles the when (on boot), while database/connection.py holds the how (the specific table schemas).](#933-clean-decoupling-your-root-mainpy-handles-the-when-on-boot-while-databaseconnectionpy-holds-the-how-the-specific-table-schemas)
-- [10. It work 100%](#10-it-work-100)
-- [11. App to read sqlite](#11-app-to-read-sqlite)
-- [12. brew](#12-brew)
- - [12.1. Standard Formula (Default: No Flag)](#121-standard-formula-default-no-flag)
- - [12.2. Cask Extension (--cask)](#122-cask-extension---cask)
- - [12.3. Why This Is Useful](#123-why-this-is-useful)
-- [13. Beekeeper Studio](#13-beekeeper-studio)
-- [14. Does edge-tts always connect to the Microsoft Cloud ?](#14-does-edge-tts-always-connect-to-the-microsoft-cloud-)
- - [14.1. How edge-tts Works (The Cloud Pipeline)](#141-how-edge-tts-works-the-cloud-pipeline)
- - [14.2. Why This is Highly Advantageous for Phase 1](#142-why-this-is-highly-advantageous-for-phase-1)
- - [14.3. Future Proofing: Going Fully Offline Down the Track](#143-future-proofing-going-fully-offline-down-the-track)
-- [Steps of app creation](#steps-of-app-creation)
-- [Gemini Reposne](#gemini-reposne)
-- [So we fix the bulk upload file](#so-we-fix-the-bulk-upload-file)
-- [Fix up of data](#fix-up-of-data)
-- [Gemini initial response](#gemini-initial-response)
-- [Gemini Response](#gemini-response)
-- [SQLite3](#sqlite3)
- - [how to backup the sqlite3 database](#how-to-backup-the-sqlite3-database)
- - [how ro alter a table within the data base](#how-ro-alter-a-table-within-the-data-base)
- - [to examine the PRAGMA table](#to-examine-the-pragma-table)
- - [1. The Cleanest Output: .schema](#1-the-cleanest-output-schema)
- - [sqlite3 schema](#sqlite3-schema)
- - [sqlite3 fullschema](#sqlite3-fullschema)
- - [Pro-Tip: Running it Interactively](#pro-tip-running-it-interactively)
- - [how to add a new table via command line](#how-to-add-a-new-table-via-command-line)
-
-# 1. spanish-voice-trainer
-# 2. Project Summary:
-Custom Spanish Voice TrainerA high-performance, completely private desktop application built on macOS to accelerate Spanish language training through automated flashcard creation and intelligent pronunciation analysis.The application utilizes a local-first architecture to ensure complete data privacy, storing all configurations, historical student analytics, and multimedia binary files strictly on the userβs local drive.
-## 2.1. π οΈ The Core Technical Stack
-- Environment & Package Management: uv (Rust-based Python package manager) for ultra-fast, isolated virtual environments and dependency locking.
-- Database & Persistence: SQLite to map phrase metadata, local media file paths, and chronological user practice scores without external database infrastructure.
-- Audio & Signal Processing: sounddevice for hands-free, voice-activated microphone capture; librosa and fastdtw (Dynamic Time Warping) to extract phoneme features (MFCCs) and score user pronunciation accuracy against reference files.
-- Asset Generation: edge-tts to stream high-quality, neural text-to-speech Spanish audio clips, and Pillow (PIL) to auto-render widescreen flashcard JPEGs matching the phrases.
-- User Interface: Developed in two phasesβstarting as a clean, text-based Command Line Interface (CLI) before migrating to a dual-mode desktop GUI built with PyQt6.
-
-## 2.2. π How the System Works
-The application operates across two distinct, integrated operational frameworks managed via a unified QStackedWidget interface:
-1. Content Creator Mode
-The user inputs a Spanish phrase and its English translation. The system automatically triggers the asset generator to output a custom high-quality flashcard image and a native-sounding neural audio file. The localized text strings and file paths are instantly committed to the SQLite database.
-1. Student Training ModeThe system pulls cards from the database, displays the visual flashcard image, and plays the target Spanish pronunciation. The student speaks into their MacBook microphone. The system detects when the student begins and stops talking via a voice-activation threshold, records the sample, and runs a Dynamic Time Warping alignment algorithm to provide an objective pronunciation match score (e.g., 87% accuracy).
-2. π Long-Term Capability: Custom Video Compilations
-Because all image assets share standard HD video dimensions ($1280 X 720) and all audio samples are tracked deterministically in the database, the core engine can be commanded to interface with ffmpeg-python. It can seamlessly compile entire batches of database assets into standalone, continuous .mp4 video lessons complete with timed visual pauses and silent audio gaps, providing an additional passive learning medium for language immersion.
-
-# 3. Create Basic Structure as suggested
-```bash
-spanish-voice-trainer/
-βββ .gitignore
-βββ pyproject.toml
-βββ uv.lock
-βββ README.md
-βββ main.py # Entry point for the application
-βββ database/ # Storage & Data Layer
-β βββ __init__.py
-β βββ connection.py # SQLite schema initialization and CRUD queries
-βββ core/ # Pure Python Business Logic (The Engine)
-β βββ __init__.py
-β βββ audio_engine.py # Recording (sounddevice) and scoring (librosa/DTW)
-β βββ asset_generator.py # TTS generation (edge-tts) and flashcard creation (Pillow)
-βββ ui/ # Presentation Layer (Views)
-β βββ __init__.py
-β βββ cli/
-β β βββ interface.py # Simple command-line menus for Phase 1
-β βββ gui/
-β βββ interface.py # Main QMainWindow shell for Phase 2
-β βββ creator_mode.py # QWidget for content creation panel
-β βββ trainer_mode.py # QWidget for student practice panel + QThread workers
-βββ media/ # Local storage for physical binary files
- βββ .gitkeep # Keeps directory alive in Forgejo repo
-```
-## 3.1. Commands
-```zsh
-stephenlohning@Scotty 139_spanish-voice-trainer % mkdir doc
-stephenlohning@Scotty 139_spanish-voice-trainer % mkdir doc/images
-stephenlohning@Scotty 139_spanish-voice-trainer % touch .gitignore
-stephenlohning@Scotty 139_spanish-voice-trainer % git branch -M main
-stephenlohning@Scotty 139_spanish-voice-trainer % git remote -v
-origin https://dev.oxnee.com/stephen/139_spanish-voice-trainer.git (fetch)
-origin https://dev.oxnee.com/stephen/139_spanish-voice-trainer.git (push)
-stephenlohning@Scotty 139_spanish-voice-trainer % git config --global push.followTags true
-stephenlohning@Scotty 139_spanish-voice-trainer % touch main.py
-stephenlohning@Scotty 139_spanish-voice-trainer % mkdir database
-stephenlohning@Scotty 139_spanish-voice-trainer % touch database/__init__.py
-stephenlohning@Scotty 139_spanish-voice-trainer % touch database/connection.py
-stephenlohning@Scotty 139_spanish-voice-trainer % mkdir core
-stephenlohning@Scotty 139_spanish-voice-trainer % touch core/__init__.py
-stephenlohning@Scotty 139_spanish-voice-trainer % touch core/audio_engine.py
-stephenlohning@Scotty 139_spanish-voice-trainer % touch core/asset_generation.py
-stephenlohning@Scotty 139_spanish-voice-trainer % mkdir ui
-stephenlohning@Scotty 139_spanish-voice-trainer % touch ui/__init__.py
-stephenlohning@Scotty 139_spanish-voice-trainer % mkdir ui/cli
-stephenlohning@Scotty 139_spanish-voice-trainer % touch ui/cli/interface.py
-stephenlohning@Scotty 139_spanish-voice-trainer % mkdir ui/gui
-stephenlohning@Scotty 139_spanish-voice-trainer % touch ui/gui/interface.py
-stephenlohning@Scotty 139_spanish-voice-trainer % touch ui/gui/create_mode.py
-stephenlohning@Scotty 139_spanish-voice-trainer % touch ui/gui/trainer_mode.py
-stephenlohning@Scotty 139_spanish-voice-trainer % mkdir media
-stephenlohning@Scotty 139_spanish-voice-trainer % touch media/.gitkeep
-````
-# 4. use uv
-```zsh
-stephenlohning@Scotty 139_spanish-voice-trainer % uv venv
-Using CPython 3.13.5
-Creating virtual environment at: .venv
-Activate with: source .venv/bin/activate
-stephenlohning@Scotty 139_spanish-voice-trainer %
-```
-
-# 5. Install Your Dependency Stack
-Run the uv add commands to populate your environment lockfile:
-
-```zsh
-uv add sounddevice numpy scipy librosa fastdtw pillow edge-tts pyqt6
-```
-
-# 6. checking the packages are installed
-
-```zsh
-(139_spanish-voice-trainer) stephenlohning@Scotty 139_spanish-voice-trainer % uv pip list
-Package Version
------------------- ---------
-aiohappyeyeballs 2.6.2
-aiohttp 3.14.0
-aiosignal 1.4.0
-attrs 26.1.0
-audioop-lts 0.2.2
-audioread 3.1.0
-certifi 2026.5.20
-cffi 2.0.0
-charset-normalizer 3.4.7
-decorator 5.3.1
-edge-tts 7.2.8
-fastdtw 0.3.4
-frozenlist 1.8.0
-idna 3.18
-joblib 1.5.3
-lazy-loader 0.5
-librosa 0.11.0
-llvmlite 0.47.0
-msgpack 1.1.2
-multidict 6.7.1
-narwhals 2.22.1
-numba 0.65.1
-numpy 2.4.6
-packaging 26.2
-pillow 12.2.0
-platformdirs 4.10.0
-pooch 1.9.0
-propcache 0.5.2
-pycparser 3.0
-pyqt6 6.11.0
-pyqt6-qt6 6.11.1
-pyqt6-sip 13.11.1
-requests 2.34.2
-scikit-learn 1.9.0
-scipy 1.17.1
-sounddevice 0.5.5
-soundfile 0.14.0
-soxr 1.1.0
-standard-aifc 3.13.0
-standard-chunk 3.13.0
-standard-sunau 3.13.0
-tabulate 0.10.0
-threadpoolctl 3.6.0
-typing-extensions 4.15.0
-urllib3 2.7.0
-yarl 1.24.2
-```
-# 7. Running Scripts with uv Moving Forward
-One of the cleanest features of uv is that you don't necessarily have to remember to run source .venv/bin/activate every single time you open a new terminal window to write code.
-
-Instead, you can prefix your execution scripts using uv run. It automatically detects your local .venv, verifies that all dependencies in your lockfile are fully up to date, and runs the script seamlessly:
-
-```bash
-# Example: To execute your main script once you write it later today
-uv run main.py
-```
-# 8. this is the basic
-
-# 9. Developed sqllite schema
-
-This was easy I just discussed what I thought we should have and gemini suggested the following
-
-## 9.1. The Structure inside database/connection.py
-In this file, you define your schema and wrap the connection/creation logic inside a function called init_db().
-```python
-# database/connection.py
-import sqlite3
-import os
-
-DB_NAME = "spanish_trainer.db"
-
-def get_connection():
- """Returns a standard connection object to the SQLite database."""
- return sqlite3.connect(DB_NAME)
-
-def init_db():
- """
- Initializes the SQLite database tables if they do not exist.
- This safely runs on every boot without wiping your existing data.
- """
- print(f"ποΈ Checking database status for '{DB_NAME}'...")
-
- # The SQL schema we designed for your glossary, cross-references, and tracks
- schema = """
- CREATE TABLE IF NOT EXISTS phrases (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- text TEXT NOT NULL,
- language TEXT NOT NULL,
- textbook TEXT DEFAULT NULL,
- unit INTEGER DEFAULT NULL,
- source_context TEXT DEFAULT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE IF NOT EXISTS translations (
- source_phrase_id INTEGER,
- target_phrase_id INTEGER,
- PRIMARY KEY (source_phrase_id, target_phrase_id),
- FOREIGN KEY (source_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE,
- FOREIGN KEY (target_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS audio_tracks (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- phrase_id INTEGER NOT NULL,
- voice_gender TEXT NOT NULL,
- voice_name TEXT NOT NULL,
- file_path TEXT NOT NULL,
- is_reference INTEGER DEFAULT 1,
- FOREIGN KEY (phrase_id) REFERENCES phrases(id) ON DELETE CASCADE
- );
- """
-
- conn = get_connection()
- try:
- cursor = conn.cursor()
- # executescript allows running multiple CREATE TABLE statements at once
- cursor.executescript(schema)
- conn.commit()
- print("β
Database tables verified and initialized successfully.")
- except sqlite3.Error as e:
- print(f"β Database initialization failed: {e}")
- finally:
- conn.close()
-```
-
-## 9.2. Triggering It inside main.py
-Now, your root startup script handles execution order perfectly. Before launching the UI, it imports and fires init_db(). If the .db file doesn't exist, SQLite handles creating it seamlessly on the spot.
-
-```python
# main.py
import sys
-from database.connection import init_db
+import os
+import random
+import hashlib
+import subprocess
+import json
+import asyncio
+import shutil
+from PyQt6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout,
+ QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox,
+ QTableWidget, QTableWidgetItem, QSlider, QFormLayout, QTextEdit, QFrame, QMessageBox, QFileDialog
+)
+from PyQt6.QtCore import Qt, QUrl
+from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
+from PyQt6.QtGui import QFont
-def main():
- print("π Booting Castilian Voice Trainer...")
-
- # Step 1: Ensure infrastructure is present before doing anything else
- init_db()
-
- # Step 2: Initialize Core Engines (AudioEngine, AssetGenerator)
- # Step 3: Launch your PyQt6 Graphical User Interface
- print("π₯οΈ Launching GUI...")
-
- # (PyQt6 window exec loops will go here)
+# Third-Party Tooling
+import genanki
+import edge_tts
+from PIL import Image, ImageDraw, ImageFont
+
+# Internal Project Module Imports
+from database.connection import init_db, get_connection
+from core.bulk_importer import BulkImporter
+from core.clean_glossary import GlossaryCleaner
+
+class SpanishTrainerApp(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Castilian Voice Trainer Pro")
+ self.setMinimumSize(1200, 800)
+
+ # 1. Initialize schema structures and check ingestion status
+ self.ensure_database_populated()
+
+ # Load system persistent settings from DB (including sleep-learning fields)
+ self.load_system_settings()
+
+ # Audio Player Architecture Setup
+ self.media_player = QMediaPlayer()
+ self.audio_output = QAudioOutput()
+ self.media_player.setAudioOutput(self.audio_output)
+
+ self.current_flashcard_id = None
+ self.flashcard_ids_pool = [] # Tracks currently filtered study list IDs
+
+ # Central Main Window Tabs Interface
+ self.tabs = QTabWidget()
+ self.setCentralWidget(self.tabs)
+
+ self.init_phrase_sandbox_tab()
+ self.init_flashcard_reviewer_tab()
+ self.init_settings_tab()
+
+ # 2. Populate table grids on initialization
+ self.refresh_crud_table()
+ self.refresh_review_table()
+
+ def ensure_database_populated(self):
+ """Forces database configuration structure and triggers pipeline execution if empty."""
+ print("ποΈ Verification Pass: Running schema configuration scripts...")
+ init_db()
+
+ conn = get_connection()
+ 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);")
+
+ # 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:
+ cursor.execute("SELECT COUNT(*) FROM translations")
+ count = cursor.fetchone()[0]
+ print(f"π Current Translation Pairs found in database: {count}")
+ except Exception as e:
+ print(f"β οΈ Table check encountered an issue (likely empty tables): {e}")
+ count = 0
+ finally:
+ conn.close()
+
+ if count == 0:
+ print("ποΈ Database tables are empty. Triggering glossary reader pipeline...")
+ pdf_file = "aula_int_plus_1_glos_en_alfa.pdf"
+
+ if os.path.exists(pdf_file):
+ importer = BulkImporter()
+ importer.import_pdf_glossary(pdf_file, "Aula Internacional Plus 1")
+
+ cleaner = GlossaryCleaner()
+ cleaner.process_database_clean()
+ print("β¨ Ingestion pipeline processing sequence successfully completed.")
+ else:
+ print(f"β Error: Source document '{pdf_file}' is missing from the directory root.")
+
+ 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)"
+ self.video_repeats_count = "3"
+ self.video_pause_duration = "4.0"
+
+ conn = get_connection()
+ cursor = conn.cursor()
+ try:
+ cursor.execute("SELECT key, value FROM settings")
+ rows = cursor.fetchall()
+ for row in rows:
+ if row[0] == "anki_export_directory":
+ self.anki_export_dir = row[1]
+ elif row[0] == "video_export_directory":
+ 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:
+ print(f"β οΈ Failed to read application settings from database: {e}")
+ finally:
+ conn.close()
+
+ def save_setting_to_db(self, key, value):
+ """Updates or inserts a specific system runtime variable into the database."""
+ conn = get_connection()
+ cursor = conn.cursor()
+ try:
+ cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value))
+ conn.commit()
+ except Exception as e:
+ print(f"β Critical: Failed to save setting '{key}': {e}")
+ finally:
+ 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.
+ """
+ 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
+ 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
+ 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"πΎ Cached duration mapping: {duration}s -> 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)
+ # =====================================================================
+ def init_phrase_sandbox_tab(self):
+ tab = QWidget()
+ layout = QHBoxLayout(tab)
+
+ left_panel = QVBoxLayout()
+
+ filter_layout = QHBoxLayout()
+ filter_layout.addWidget(QLabel("π Text Filter:"))
+ self.search_text_input = QLineEdit()
+ self.search_text_input.setPlaceholderText("Search Spanish or English text blocks...")
+ self.search_text_input.textChanged.connect(self.refresh_crud_table)
+ filter_layout.addWidget(self.search_text_input)
+
+ filter_layout.addWidget(QLabel("π Context:"))
+ self.search_context_input = QLineEdit()
+ self.search_context_input.setPlaceholderText("e.g. U2")
+ self.search_context_input.setMaximumWidth(130)
+ self.search_context_input.textChanged.connect(self.refresh_crud_table)
+ filter_layout.addWidget(self.search_context_input)
+
+ left_panel.addLayout(filter_layout)
+
+ self.translation_table = QTableWidget()
+ self.translation_table.setColumnCount(6)
+ self.translation_table.setHorizontalHeaderLabels([
+ "TX ID", "Spanish Phrase", "English Translation", "Type", "Source Context", "Deck Assignment"
+ ])
+ self.translation_table.itemSelectionChanged.connect(self.handle_table_row_select)
+ left_panel.addWidget(self.translation_table)
+
+ nav_layout = QHBoxLayout()
+ self.btn_row_up = QPushButton("πΌ Previous Pair")
+ self.btn_row_down = QPushButton("π½ Next Pair")
+ self.btn_row_up.clicked.connect(lambda: self.step_table_row(-1))
+ self.btn_row_down.clicked.connect(lambda: self.step_table_row(1))
+ nav_layout.addWidget(self.btn_row_up)
+ nav_layout.addWidget(self.btn_row_down)
+ left_panel.addLayout(nav_layout)
+
+ right_panel = QVBoxLayout()
+ form_frame = QFrame()
+ form_frame.setFrameShape(QFrame.Shape.StyledPanel)
+ form_layout = QFormLayout(form_frame)
+
+ self.input_tx_id = QLineEdit()
+ self.input_tx_id.setReadOnly(True)
+ self.input_tx_id.setPlaceholderText("Auto-Increment ID")
+
+ self.input_text_es = QTextEdit()
+ self.input_text_es.setMaximumHeight(75)
+
+ self.input_text_en = QTextEdit()
+ self.input_text_en.setMaximumHeight(75)
+
+ self.combo_type = QComboBox()
+ self.combo_type.addItems(["phrase", "sentence", "noun", "verb", "adjective"])
+
+ self.input_context = QLineEdit()
+ self.input_context.setPlaceholderText("e.g., U8_5A")
+
+ self.input_tags = QLineEdit()
+ self.input_tags.setPlaceholderText("e.g., irregular_er boots_verb")
+
+ self.input_deck_tag = QLineEdit()
+ self.input_deck_tag.setPlaceholderText("Anki Sub-deck Hierarchy")
+
+ button_qss = """
+ QPushButton {
+ background-color: #f0f0f0;
+ border: 1px solid #c0c0c0;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: bold;
+ color: #333333;
+ }
+ QPushButton:hover {
+ background-color: #e0e0e0;
+ border: 1px solid #a0a0a0;
+ }
+ QPushButton:pressed {
+ background-color: #d0d0d0;
+ }
+ """
+
+ es_header_layout = QHBoxLayout()
+ es_header_layout.setContentsMargins(0, 5, 0, 5)
+ es_header_layout.addWidget(QLabel("πͺπΈ Castilian Spanish Text Element:"))
+
+ self.btn_play_sandbox_es = QPushButton("Play π")
+ self.btn_play_sandbox_es.setFixedWidth(75)
+ self.btn_play_sandbox_es.setFixedHeight(24)
+ self.btn_play_sandbox_es.setStyleSheet(button_qss)
+ self.btn_play_sandbox_es.clicked.connect(self.handle_sandbox_play_es)
+ es_header_layout.addWidget(self.btn_play_sandbox_es)
+
+ self.combo_speed_es = QComboBox()
+ self.combo_speed_es.addItems(["0.50x", "0.75x", "1.00x", "1.25x", "1.50x"])
+ self.combo_speed_es.setCurrentText("1.00x")
+ self.combo_speed_es.setFixedWidth(70)
+ self.combo_speed_es.setFixedHeight(24)
+ es_header_layout.addWidget(self.combo_speed_es)
+ es_header_layout.addStretch()
+
+ en_header_layout = QHBoxLayout()
+ en_header_layout.setContentsMargins(0, 5, 0, 5)
+ en_header_layout.addWidget(QLabel("π¬π§ English Target Translation:"))
+
+ self.btn_play_sandbox_en = QPushButton("Play π")
+ self.btn_play_sandbox_en.setFixedWidth(75)
+ self.btn_play_sandbox_en.setFixedHeight(24)
+ self.btn_play_sandbox_en.setStyleSheet(button_qss)
+ self.btn_play_sandbox_en.clicked.connect(self.handle_sandbox_play_en)
+ en_header_layout.addWidget(self.btn_play_sandbox_en)
+
+ self.combo_speed_en = QComboBox()
+ self.combo_speed_en.addItems(["0.50x", "0.75x", "1.00x", "1.25x", "1.50x"])
+ self.combo_speed_en.setCurrentText("1.00x")
+ self.combo_speed_en.setFixedWidth(70)
+ self.combo_speed_en.setFixedHeight(24)
+ en_header_layout.addWidget(self.combo_speed_en)
+ en_header_layout.addStretch()
+
+ grammar_header_layout = QHBoxLayout()
+ grammar_header_layout.setContentsMargins(0, 5, 0, 5)
+ grammar_header_layout.addWidget(QLabel("π Grammar / Usage Note:"))
+ grammar_header_layout.addStretch()
+
+ self.input_grammar_note = QTextEdit()
+ self.input_grammar_note.setMaximumHeight(75)
+ self.input_grammar_note.setPlaceholderText("e.g., feminine variant...")
+
+ form_layout.addRow("Translation Link ID:", self.input_tx_id)
+ form_layout.addRow(es_header_layout)
+ form_layout.addRow(self.input_text_es)
+ form_layout.addRow(en_header_layout)
+ form_layout.addRow(self.input_text_en)
+ form_layout.addRow(grammar_header_layout)
+ form_layout.addRow(self.input_grammar_note)
+ form_layout.addRow("Classification Profile:", self.combo_type)
+ form_layout.addRow("Source Context ID (Raw):", self.input_context)
+ form_layout.addRow("Anki Note Tags:", self.input_tags)
+ form_layout.addRow("Target Deck Scope:", self.input_deck_tag)
+
+ crud_buttons = QHBoxLayout()
+ self.btn_save = QPushButton("β Create Pair")
+ self.btn_update = QPushButton("πΎ Update Node")
+ self.btn_delete = QPushButton("ποΈ Sever Link")
+
+ self.btn_save.clicked.connect(self.crud_create_pair)
+ self.btn_update.clicked.connect(self.crud_update_pair)
+ self.btn_delete.clicked.connect(self.crud_delete_pair)
+
+ crud_buttons.addWidget(self.btn_save)
+ crud_buttons.addWidget(self.btn_update)
+ crud_buttons.addWidget(self.btn_delete)
+
+ right_panel.addWidget(QLabel("
Translation Node Management Matrix
"))
+ right_panel.addWidget(form_frame)
+ right_panel.addLayout(crud_buttons)
+ right_panel.addStretch()
+
+ layout.addLayout(left_panel, stretch=4)
+ layout.addLayout(right_panel, stretch=3)
+
+ self.tabs.addTab(tab, "ποΈ Phrase Sandbox (CRUD)")
+
+ # =====================================================================
+ # π TAB 2: FLASHCARD STUDY MODULE
+ # =====================================================================
+ def init_flashcard_reviewer_tab(self):
+ tab = QWidget()
+ layout = QHBoxLayout(tab)
+
+ left_panel = QVBoxLayout()
+
+ filter_layout = QHBoxLayout()
+ filter_layout.addWidget(QLabel("π Context:"))
+ self.review_context_filter = QLineEdit()
+ self.review_context_filter.setPlaceholderText("Filter Context...")
+ self.review_context_filter.textChanged.connect(self.refresh_review_table)
+ filter_layout.addWidget(self.review_context_filter)
+
+ filter_layout.addWidget(QLabel("π·οΈ Tag:"))
+ self.review_tag_filter = QLineEdit()
+ self.review_tag_filter.setPlaceholderText("Filter Tag...")
+ self.review_tag_filter.textChanged.connect(self.refresh_review_table)
+ filter_layout.addWidget(self.review_tag_filter)
+
+ left_panel.addLayout(filter_layout)
+
+ self.review_table = QTableWidget()
+ self.review_table.setColumnCount(4)
+ self.review_table.setHorizontalHeaderLabels(["Tx ID", "Spanish Phrase", "Context", "Tags"])
+ self.review_table.itemSelectionChanged.connect(self.handle_review_table_select)
+ left_panel.addWidget(self.review_table)
+
+ layout.addLayout(left_panel, stretch=4)
+
+ right_panel = QVBoxLayout()
+
+ card_frame = QFrame()
+ card_frame.setStyleSheet("background-color: #ffffff; border: 2px solid #bdc3c7; border-radius: 12px;")
+ card_layout = QVBoxLayout(card_frame)
+ card_frame.setMinimumHeight(280)
+
+ self.lbl_card_text = QLabel("Select a row or click 'Next Card' to initiate...")
+ self.lbl_card_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.lbl_card_text.setFont(QFont("Arial", 20, QFont.Weight.Bold))
+ self.lbl_card_text.setWordWrap(True)
+ self.lbl_card_text.setStyleSheet("color: #2c3e50; border: none; padding: 20px;")
+
+ self.lbl_card_meta = QLabel("")
+ self.lbl_card_meta.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.lbl_card_meta.setFont(QFont("Arial", 11))
+ self.lbl_card_meta.setStyleSheet("color: #7f8c8d; border: none;")
+
+ card_layout.addStretch()
+ card_layout.addWidget(self.lbl_card_text)
+ card_layout.addWidget(self.lbl_card_meta)
+ card_layout.addStretch()
+ right_panel.addWidget(card_frame, stretch=4)
+
+ playback_layout = QHBoxLayout()
+ playback_layout.addWidget(QLabel("π Voice Speed:"))
+ self.slider_review_speed = QSlider(Qt.Orientation.Horizontal)
+ self.slider_review_speed.setMinimum(50)
+ self.slider_review_speed.setMaximum(150)
+ self.slider_review_speed.setValue(100)
+ self.lbl_review_speed = QLabel("1.00x")
+ self.slider_review_speed.valueChanged.connect(self.handle_live_speed_change)
+ playback_layout.addWidget(self.slider_review_speed)
+ playback_layout.addWidget(self.lbl_review_speed)
+ right_panel.addLayout(playback_layout)
+
+ action_buttons = QHBoxLayout()
+ self.btn_play_voice = QPushButton("π£οΈ Play Voice Track")
+ self.btn_flip_card = QPushButton("ποΈ Reveal English Partner")
+
+ self.btn_play_voice.clicked.connect(self.handle_play_voice)
+ self.btn_flip_card.clicked.connect(self.handle_flip_card)
+
+ action_buttons.addWidget(self.btn_play_voice)
+ action_buttons.addWidget(self.btn_flip_card)
+ right_panel.addLayout(action_buttons)
+
+ right_panel.addSpacing(15)
+
+ bottom_utility_layout = QHBoxLayout()
+ self.btn_export_anki = QPushButton("π¦ Export Anki Deck")
+ self.btn_export_video = QPushButton("π¬ Export Video")
+ self.btn_load_next = QPushButton("β‘οΈ Next Card")
+
+ self.btn_export_anki.clicked.connect(self.handle_export_anki_deck)
+ self.btn_export_video.clicked.connect(self.handle_export_video_assets)
+ self.btn_load_next.clicked.connect(self.handle_load_next_card)
+
+ utility_qss = "QPushButton { font-weight: bold; background-color: #eaf2f8; padding: 6px; border-radius: 4px; }"
+ self.btn_export_anki.setStyleSheet(utility_qss)
+ self.btn_export_video.setStyleSheet(utility_qss)
+ self.btn_load_next.setStyleSheet("QPushButton { font-weight: bold; background-color: #d5f5e3; padding: 6px; border-radius: 4px; }")
+
+ bottom_utility_layout.addWidget(self.btn_export_anki)
+ bottom_utility_layout.addWidget(self.btn_export_video)
+ bottom_utility_layout.addStretch()
+ bottom_utility_layout.addWidget(self.btn_load_next)
+ right_panel.addLayout(bottom_utility_layout)
+
+ layout.addLayout(right_panel, stretch=3)
+ self.tabs.addTab(tab, "π Flashcard Review")
+
+ # =====================================================================
+ # βοΈ TAB 3: SYSTEM HARDWARE & EXPORT SETTINGS
+ # =====================================================================
+ def init_settings_tab(self):
+ tab = QWidget()
+ layout = QVBoxLayout(tab)
+
+ settings_frame = QFrame()
+ settings_frame.setFrameShape(QFrame.Shape.StyledPanel)
+ form_layout = QFormLayout(settings_frame)
+
+ anki_layout = QHBoxLayout()
+ self.line_anki_dir = QLineEdit(self.anki_export_dir)
+ self.line_anki_dir.setReadOnly(True)
+ btn_browse_anki = QPushButton("Browse π")
+ btn_browse_anki.clicked.connect(self.handle_browse_anki_directory)
+ anki_layout.addWidget(self.line_anki_dir)
+ anki_layout.addWidget(btn_browse_anki)
+
+ video_layout = QHBoxLayout()
+ self.line_video_dir = QLineEdit(self.video_export_dir)
+ self.line_video_dir.setReadOnly(True)
+ btn_browse_video = QPushButton("Browse π")
+ btn_browse_video.clicked.connect(self.handle_browse_video_directory)
+ 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)
+ 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("Anki Deck Export Destination:", anki_layout)
+ form_layout.addRow("Video Assembly Output Target:", video_layout)
+ form_layout.addRow("Introductory Anchor Audio Language:", self.combo_first_lang)
+ 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 {
+ 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("Application Preferences & Workspace Routing
"))
+ layout.addWidget(settings_frame)
+ layout.addWidget(self.btn_sync_cache)
+ layout.addStretch()
+
+ self.tabs.addTab(tab, "βοΈ Settings")
+
+ def handle_browse_anki_directory(self):
+ directory = QFileDialog.getExistingDirectory(self, "Select Anki Export Folder", self.anki_export_dir)
+ if directory:
+ self.anki_export_dir = directory
+ self.line_anki_dir.setText(directory)
+ self.save_setting_to_db("anki_export_directory", directory)
+
+ def handle_browse_video_directory(self):
+ directory = QFileDialog.getExistingDirectory(self, "Select Video Export Folder", self.video_export_dir)
+ if directory:
+ self.video_export_dir = directory
+ self.line_video_dir.setText(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
+ # =====================================================================
+ def refresh_crud_table(self):
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ text_filter = self.search_text_input.text().strip()
+ context_filter = self.search_context_input.text().strip()
+
+ query = """
+ SELECT t.translation_id, p1.text, p2.text, p1.word_type, p1.source_context, 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 p1.language = 'es' AND p2.language = 'en'
+ """
+ params = []
+
+ if text_filter:
+ query += " AND (p1.text LIKE ? OR p2.text LIKE ?)"
+ params.extend([f"%{text_filter}%", f"%{text_filter}%"])
+
+ if context_filter:
+ query += " AND p1.source_context LIKE ?"
+ params.append(f"%{context_filter}%")
+
+ query += " ORDER BY t.translation_id ASC LIMIT 250"
+
+ cursor.execute(query, params)
+ rows = cursor.fetchall()
+ conn.close()
+
+ self.translation_table.setRowCount(0)
+ for row_idx, row_data in enumerate(rows):
+ self.translation_table.insertRow(row_idx)
+ for col_idx in range(6):
+ val = row_data[col_idx]
+ self.translation_table.setItem(row_idx, col_idx, QTableWidgetItem(str(val if val is not None else "")))
+
+ def refresh_review_table(self):
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ context_filter = self.review_context_filter.text().strip()
+ tag_filter = self.review_tag_filter.text().strip()
+
+ query = """
+ SELECT t.translation_id, p1.text, p1.source_context, t.tags, p1.id
+ FROM translations t
+ JOIN phrases p1 ON t.source_phrase_id = p1.id
+ WHERE p1.language = 'es'
+ """
+ params = []
+ if context_filter:
+ query += " AND p1.source_context LIKE ?"
+ params.append(f"%{context_filter}%")
+ if tag_filter:
+ query += " AND t.tags LIKE ?"
+ params.append(f"%{tag_filter}%")
+
+ query += " ORDER BY t.translation_id ASC"
+
+ cursor.execute(query, params)
+ rows = cursor.fetchall()
+ conn.close()
+
+ self.review_table.setRowCount(0)
+ self.flashcard_ids_pool = []
+
+ for row_idx, row_data in enumerate(rows):
+ self.review_table.insertRow(row_idx)
+ self.flashcard_ids_pool.append(row_data[4])
+ 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 "")))
+
+ def handle_table_row_select(self):
+ selected_ranges = self.translation_table.selectedRanges()
+ if not selected_ranges:
+ return
+ row = selected_ranges[0].topRow()
+ tx_id_item = self.translation_table.item(row, 0)
+ if not tx_id_item:
+ return
+
+ tx_id = tx_id_item.text()
+
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ 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
+ JOIN phrases p1 ON t.source_phrase_id = p1.id
+ JOIN phrases p2 ON t.target_phrase_id = p2.id
+ WHERE t.translation_id = ?
+ """, (tx_id,))
+ record = cursor.fetchone()
+ conn.close()
+
+ if record:
+ self.input_tx_id.setText(str(record[0]))
+ self.input_text_es.setPlainText(str(record[1]))
+ self.input_text_en.setPlainText(str(record[2]))
+ self.combo_type.setCurrentText(str(record[3]) if record[3] else "phrase")
+ self.input_context.setText(str(record[4]) if record[4] 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_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):
+ selected_ranges = self.review_table.selectedRanges()
+ if not selected_ranges:
+ return
+ row = selected_ranges[0].topRow()
+ phrase_id = self.flashcard_ids_pool[row]
+ self.load_flashcard_by_id(phrase_id)
+
+ def step_table_row(self, direction):
+ current_row = self.translation_table.currentRow()
+ next_row = current_row + direction
+ 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()
+ 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()
+
+ 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()
+
+ # =====================================================================
+ # π 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
+
+ self.load_flashcard_by_id(target_id)
+
+ def handle_play_voice(self):
+ 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"
+
+ 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):
+ text_str = self.input_text_es.toPlainText().strip()
+ if not text_str or not hasattr(self, 'current_sandbox_es_id'):
+ return
+
+ 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"
+
+ 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):
+ text_str = self.input_text_en.toPlainText().strip()
+ if not text_str or not hasattr(self, 'current_sandbox_en_id'):
+ return
+
+ 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"
+
+ 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 structural sleep loops using dynamic timelines."""
+ if not self.flashcard_ids_pool:
+ QMessageBox.warning(self, "Video Generation Cancelled", "The active filter queue contains no records.")
+ return
+
+ try:
+ repeat_count = int(self.spin_video_repeats.text().strip())
+ pause_sec = float(self.spin_pause_duration.text().strip())
+ except ValueError:
+ QMessageBox.critical(self, "Configuration Error", "Check repeat multiplier numbers and decimal timing definitions.")
+ 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 timeline clips for {len(records)} study pairs...")
+ 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()
+
+ 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"
+
+ if "English First" in self.combo_first_lang.currentText():
+ 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
+
+ # --- Clip 1: Anchor Language Ingestion block ---
+ 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)
+
+ # --- Clip 2: Target Repeat Frame block ---
+ 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)
+
+ # --- Clip 3: Silent Practice Intermission frame 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)
+
+ # Loop target segments sequentially matching step 3 -> 4 -> 5 loops
+ 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: Merge all segments into a master movie file ---
+ 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__":
- main()
-```
-## 9.3. Why This Placement is Ideal
-### 9.3.1. Idempotent Execution: Using CREATE TABLE IF NOT EXISTS means this code runs beautifully every time you start your app. If the database is already there, SQLite silently skips creation and proceeds to boot without overwriting your hard-earned Aula Internacional data.
-
-### 9.3.2. Crash Prevention: By placing it at the absolute top of main(), you ensure that no other component (like a UI field trying to load your textbook list) can execute queries against a database that hasn't finished setting up its columns yet.
-
-### 9.3.3. Clean Decoupling: Your root main.py handles the when (on boot), while database/connection.py holds the how (the specific table schemas).
-
-# 10. It work 100%
-
-```zsh
-It Looks like it worked
-tephenlohning@Scotty 139_spanish-voice-trainer %Β source /Users/stephenlohning/Documents/01_Projects/139_spanish-voice-train
-er/.venv/bin/activate
-(139_spanish-voice-trainer) stephenlohning@Scotty 139_spanish-voice-trainer % uv run main.py
-π Booting Castilian Voice Trainer...
-ποΈ Checking database status for 'spanish_trainer.db'...
-β
Database tables verified and initialized successfully.
-π₯οΈ Launching GUI...
-
-Create database in root
-(139_spanish-voice-trainer) stephenlohning@Scotty 139_spanish-voice-trainer % ls -la *.db
--rw-r--r--Β 1 stephenlohningΒ staffΒ 24576 Jun 12 21:04 spanish_trainer.db
-(139_spanish-voice-trainer) stephenlohning@Scotty 139_spanish-voice-trainer %Β
-```
-
-# 11. App to read sqlite
-
-Gemini gave a few suggestions, I picked the second one "Bee Keeper Studio"
-
-I used brew to install it
-
-```zsh
-### Top Modern/Aesthetic Option: Beekeeper Studio (Community Edition)
-If you want something that matches modern macOS design language (with gorgeous dark mode formatting, clean autocomplete tabs, and high-DPI scaling), Beekeeper is phenomenal. It is fast, private, and highly visual.
-* **How to install via Homebrew:**
- ```bash
-brew install --cask beekeeper-studio
-```
-I did not know what --cask was doing so I asked, and I got a bit of info about brew the installation manager.
-
-# 12. brew
-In Homebrew, the --cask flag tells the package manager that you want to install a macOS desktop application that comes with a Graphical User Interface (GUI), rather than a command-line tool.
-
-Here is the breakdown of how Homebrew treats these two styles of software:
-
-## 12.1. Standard Formula (Default: No Flag)
-When you type brew install , Homebrew downloads, compiles, or unpacks command-line utilities or developer libraries. These programs do not have an icon in your Applications folder and run entirely inside your terminal.
-
-Examples: uv, git, sqlite3, ffmpeg
-
-## 12.2. Cask Extension (--cask)
-When you type brew install --cask , Homebrew downloads a pre-compiled macOS binary directly from the developer (usually packaged as a .dmg, .pkg, or .app file). It automatically runs the installer, bypasses the drag-and-drop step, and places the application straight into your /Applications folder.
-
-Examples: beekeeper-studio, visual-studio-code, db-browser-for-sqlite
-
-## 12.3. Why This Is Useful
-Instead of opening Safari, searching for Beekeeper Studio, downloading a disk image, opening it, dragging the icon to your Applications folder, and cleaning up the installer file, Homebrew does all of that for you behind the scenes in a single terminal line.
-
-Furthermore, whenever you run your system updates down the road using brew upgrade, Homebrew will automatically update your desktop apps right alongside your command-line tools!
-
-# 13. Beekeeper Studio
-Has an introduction YouTube video
-
-The main thing is you double click apon the data base.db we created.
-
-
-# 14. Does edge-tts always connect to the Microsoft Cloud ?
-
-The short answer is yes.
-
-edge-tts is a Python library that wraps around the communication protocol used by Microsoft Edge's built-in "Read Aloud" feature. Because it doesn't run neural voice models locally on your MacBook, it must make an active network connection to the Microsoft cloud servers every time you request audio synthesis.
-
-Here is a closer look at what is happening under the hood when you call await communicate.save() on Scotty:
-
-## 14.1. How edge-tts Works (The Cloud Pipeline)
-Instead of keeping massive gigabyte-sized neural networks running in your local system memory, edge-tts leverages Microsoftβs cloud infrastructure using secure WebSockets.
-
-The Request: Your Python script establishes an asymmetrical WebSocket connection to a Microsoft Azure speech endpoint.
-
-The Handshake: It passes your text string along with configuration meta-data (like requesting the specific es-ES-AlvaroNeural Castilian voice).
-
-The Synthesis: Microsoft's remote cloud supercomputers synthesize the text into human-like phonemes in real time.
-
-The Stream: The server streams raw audio binary packets back down to edge-tts, which chunks them cleanly together and writes them to disk as your target .mp3 file.
-
-Because this relies entirely on a remote API endpoint, if Scotty is completely offline or disconnected from Wi-Fi, the edge-tts script will throw a connection error and fail to generate audio.
-
-## 14.2. Why This is Highly Advantageous for Phase 1
-While running things offline is fantastic for complete privacy, using a cloud-backed pipeline for your audio assets right now offers three massive benefits for your project development:
-
-Zero Hardware Strain: Deep-learning voice synthesis is computationally heavy. Running a true high-fidelity audio model locally requires significant CPU/GPU processing. edge-tts offloads 100% of that heavy lifting to the cloud, meaning your script runs instantly in seconds without draining your MacBook's battery or spinning up the fans.
-
-Instant, Production-Ready Audio: You don't have to download, compile, or tune massive multi-gigabyte voice weights (like tortoise-tts or Bark) inside your Python environment. You get pristine, studio-quality Castilian speech with two lines of code.
-
-## 14.3. Future Proofing: Going Fully Offline Down the Track
-Because you prefer data privacy and local-first software engineering, it's worth noting that your current architecture is completely future-proofed for offline migration when you reach an advanced phase of development.
-
-Because we explicitly decoupled your codeβseparating the asset creation logic into core/asset_generator.py and the application logic into main.pyβyou can swap out the backend audio engine later without changing a single line of your GUI code.
-
-If you want to make your voice trainer run 100% locally in the future without hitting the cloud, you can replace the internal logic of AssetGenerator with an offline framework like:
-
-Piper TTS: A fast, local, neural text-to-speech system optimized to run beautifully on local hardware architectures.
-
-Kokoro: A highly lightweight, open-source local voice model that produces incredibly realistic human inflections.
-
-For now, leveraging Microsoft's cloud via edge-tts gives you native-speaker accuracy with zero setup friction, letting you focus entirely on mastering your Aula Internacional curriculum!
-
-I suppose this OK in phase 1 , I may want to migrate Kokoro next
-
-# Steps of app creation
-```zsh
-(139_spanish-voice-trainer) stephenlohning@Scotty 139_spanish-voice-trainer % git tag -n
-list basic framework created
-v1.0 Tested text speak with 2 files added to the root directory
-v2.0 added db creation, to project, also loaded installed Bee Keeper Studio on Scotty
-v3.0 phase manager added main modified to test this, bulk_importer drafted not tested
-v4.0 bulk importer did not work using docling, so we going to export the markdown into a text file as an intermediate step
-v5.0 modified to export the markdown file, to be used to modify bulk importer
-```
-v5.0 docling gave us
-```txt
-## alphabetical GLOSSARY
-
-| aΓ±os60 m | 1960s | U8_7A | baΓ± osmpl | bath | U1_1A |
-|---------------------|------------------|-------------|-----------------------------|-------------------------|-------------|
-| aparecer (zc) | toappear | U3_6A | bar m | bar | U1_LEX |
-| apasionado/a | passionate | U5_15C | barato/a | cheap | U4_6C |
-| apellido m | surname | U1_4A | barba f | beard | U5_9A |
-| aprender | tolearn | U2_2C | Barcelona | Barcelona | U3_8A |
-```
-
-# Gemini Reposne
-```zsh
-| Col 1 (ES) | Col 2 (EN) | Col 3 (Unit) | Col 4 (ES) | Col 5 (EN) | Col 6 (Unit) |
-```
-# So we fix the bulk upload file
-```zsh
-git tag -a v6.0 -m "working version of bulk importer, but it leaves gramar info"
-```
-# Fix up of data
-
-We should probably remove the grammar categorisation which is done.
-5 aΓ±os60 m es Aula Internacional Plus 1 87 U87A 2026-06-13 03:00:53
-The m
-9 aparecer (zc) es Aula Internacional Plus 1 36 U36A 2026-06-13 03:00:53
-The (zc)
-13 apasionado/a es Aula Internacional Plus 1 515 U515C 2026-06-13 03:00:53
-The /a maybe has be duplicated into two words
-
-# Gemini initial response
-
-Was was to make a module to clean up the data, I said that we should be keeping the data
-
-# Gemini Response
-
-You raise an exceptional database design point. You are absolutely right: knowing whether a word is a masculine noun, a feminine noun, or an irregular verb is vital linguistic data that we shouldn't just throw away.
-
-The issue isn't that the information is bad, but rather that we were storing it directly inside the text field itself. Keeping it there causes two major problems:
-
-Audio Synthesis Ruin: As mentioned, edge-tts will literally read aloud "aΓ±os sesenta eme" or "aparecer zeta ce".
-
-Search and Match Failure: If you query your database for the word "aparecer", a direct lookup will fail because the database thinks the word is literally "aparecer (zc)".
-
-The Relational Solution: Keep the Info, Just Move the Slot
-The cleanest engineering approach is to strip the grammar tags out of the conversational text field, but save them into dedicated metadata columns on the exact same row.
-
-Let's modify your SQLite phrases schema to add columns for word_type (noun, verb, adjective, phrase) and grammar_note (m, f, zc, ie). This keeps your data rich and informative, keeps your text search crisp, and lets us pass clean strings to the voice engine.
-
-Here is the plan to gracefully adjust your schema and run an updated, data-preserving migration:
-
-This requires adding another column to our database
-
-# SQLite3
-
-## how to backup the sqlite3 database
-```zsh
-sqlite3 database/trainer.db ".backup 'database/trainer_backup_$(date +%Y%m%d_%H%M%S).db'"
-```
-## how ro alter a table within the data base
-```zsh
-sqlite3 spanish_trainer.db "ALTER TABLE translations ADD COLUMN tags TEXT DEFAULT '';"
-```
-## to examine the PRAGMA table
-
-```zsh
-sqlite3 spanish_trainer.db "PRAGMA table_info(translations);"
-```
-
-## 1. The Cleanest Output: .schema
-The most precise command is the dot-command .schema. You can pass it directly into the CLI call:
-
-## sqlite3 schema
-```zsh
-sqlite3 spanish_trainer.db ".schema"
-```
-
-Why this is exactly what you need:
-
-It prints out the exact SQL CREATE TABLE statements used to build your entire database structure.
-
-It automatically includes any constraints (like PRIMARY KEY, FOREIGN KEY, or UNIQUE).
-
-It shows all other structural items, such as your indexes and triggers, giving you a complete blueprint of the file.
-
-2. The Comprehensive Alternative: .fullschema
-If you ever start implementing complex custom database views or virtual tables down the road, you can use:
-
-## sqlite3 fullschema
-
-```zsh
-sqlite3 spanish_trainer.db ".fullschema"
-```
-
-This does everything .schema does, but it also appends statistical metadata and structural configuration variables used to optimize the query planner.
-
-## Pro-Tip: Running it Interactively
-
-If you are already inside an active SQLite session using your terminal, you don't need to specify the database name or use quotation marks. You can just type the dot-command at the prompt:
-
-SQL
-sqlite> .schema
-If you want a more compact table checklist just to see what names are present before inspecting their structures, you can use:
-
-SQL
-sqlite> .tables
-
-
-## how to add a new table via command line
-
-```zsh
-sqlite3 spanish_trainer.db "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);"
-```
\ No newline at end of file
+ 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
diff --git a/doc/Notes.pdf b/doc/Notes.pdf
index 181613c..48f6899 100644
Binary files a/doc/Notes.pdf and b/doc/Notes.pdf differ
diff --git a/main.py b/main.py
index 8dc4bf6..cd04204 100644
--- a/main.py
+++ b/main.py
@@ -3,6 +3,10 @@ import sys
import os
import random
import hashlib
+import subprocess
+import json
+import asyncio
+import shutil
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox,
@@ -12,8 +16,10 @@ from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtGui import QFont
-# Third-Party Anki Generation Tooling
+# Third-Party Tooling
import genanki
+import edge_tts
+from PIL import Image, ImageDraw, ImageFont
# Internal Project Module Imports
from database.connection import init_db, get_connection
@@ -24,12 +30,12 @@ class SpanishTrainerApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Castilian Voice Trainer Pro")
- self.setMinimumSize(1200, 750)
+ self.setMinimumSize(1200, 800)
# 1. Initialize schema structures and check ingestion status
self.ensure_database_populated()
- # Load system persistent settings from DB
+ # Load system persistent settings from DB (including sleep-learning fields)
self.load_system_settings()
# Audio Player Architecture Setup
@@ -38,6 +44,8 @@ class SpanishTrainerApp(QMainWindow):
self.media_player.setAudioOutput(self.audio_output)
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
# Central Main Window Tabs Interface
@@ -60,8 +68,17 @@ class SpanishTrainerApp(QMainWindow):
conn = get_connection()
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);")
+ # 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:
cursor.execute("SELECT COUNT(*) FROM translations")
count = cursor.fetchone()[0]
@@ -87,9 +104,13 @@ class SpanishTrainerApp(QMainWindow):
print(f"β Error: Source document '{pdf_file}' is missing from the directory root.")
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.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()
cursor = conn.cursor()
@@ -101,6 +122,12 @@ class SpanishTrainerApp(QMainWindow):
self.anki_export_dir = row[1]
elif row[0] == "video_export_directory":
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:
print(f"β οΈ Failed to read application settings from database: {e}")
finally:
@@ -113,11 +140,72 @@ class SpanishTrainerApp(QMainWindow):
try:
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":
+ self.video_repeats_count = value
+ elif key == "video_pause_duration":
+ self.video_pause_duration = value
except Exception as e:
print(f"β Critical: Failed to save setting '{key}': {e}")
finally:
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)
# =====================================================================
@@ -418,11 +506,44 @@ 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)
+ 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("Anki Deck Export Destination:", anki_layout)
form_layout.addRow("Video Assembly Output Target:", video_layout)
+ form_layout.addRow("Introductory Anchor Audio Language:", self.combo_first_lang)
+ 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 {
+ 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("Application Preferences & Workspace Routing
"))
layout.addWidget(settings_frame)
+ layout.addWidget(self.btn_sync_cache)
layout.addStretch()
self.tabs.addTab(tab, "βοΈ Settings")
@@ -441,6 +562,31 @@ class SpanishTrainerApp(QMainWindow):
self.line_video_dir.setText(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
# =====================================================================
@@ -532,7 +678,7 @@ class SpanishTrainerApp(QMainWindow):
conn = get_connection()
cursor = conn.cursor()
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
JOIN phrases p1 ON t.source_phrase_id = p1.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_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.current_sandbox_es_id = record[8]
+ self.current_sandbox_en_id = record[9]
def handle_review_table_select(self):
selected_ranges = self.review_table.selectedRanges()
@@ -566,7 +714,7 @@ class SpanishTrainerApp(QMainWindow):
self.translation_table.setCurrentCell(next_row, 0)
# =====================================================================
- # β ENGINE ATOMIC OPERATIONS LOGIC (CRUD MODIFIERS)
+ # β CRUD ENGINE ATOMIC OPERATIONS LOGIC
# =====================================================================
def crud_create_pair(self):
conn = get_connection()
@@ -588,6 +736,11 @@ class SpanishTrainerApp(QMainWindow):
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.")
@@ -604,14 +757,17 @@ class SpanishTrainerApp(QMainWindow):
if 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))
- 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))
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()
@@ -639,6 +795,8 @@ class SpanishTrainerApp(QMainWindow):
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
@@ -676,6 +834,7 @@ class SpanishTrainerApp(QMainWindow):
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()
@@ -687,19 +846,10 @@ class SpanishTrainerApp(QMainWindow):
if row:
text_str, lang = row
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"
- if not os.path.exists(target_file):
- print(f"π Review Fallback: Synthesizing missing audio asset on the fly for '{text_str}'...")
- 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
+ # 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)))
@@ -728,48 +878,36 @@ class SpanishTrainerApp(QMainWindow):
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
- 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"
- if not 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
+ # 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"
- 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()
+ 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
- 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"
- if not 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
+ # 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"
- 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()
+ 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
@@ -786,11 +924,9 @@ class SpanishTrainerApp(QMainWindow):
QMessageBox.warning(self, "Export Cancelled", "The current study stack is empty. Verify your search filters.")
return
- # 1. Generate Stable Cryptographic Note Model ID
model_hash = hashlib.sha256(b"castilian_voice_trainer_model_v2").hexdigest()
model_id = int(model_hash[:13], 16)
- # Standardized Castilian Note Template Structure Definition (Includes English Audio)
spanish_note_model = genanki.Model(
model_id,
'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; }'
)
- # Determine dynamic manifest names based on runtime filter choices
context_txt = self.review_context_filter.text().strip()
tag_txt = self.review_tag_filter.text().strip()
@@ -832,22 +967,17 @@ class SpanishTrainerApp(QMainWindow):
else:
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()
destination_path = os.path.join(self.anki_export_dir, file_title)
decks_map = {}
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()
cursor = conn.cursor()
-
placeholders = ",".join(["?"] * len(self.flashcard_ids_pool))
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
JOIN phrases p1 ON t.source_phrase_id = p1.id
JOIN phrases p2 ON t.target_phrase_id = p2.id
@@ -857,94 +987,187 @@ class SpanishTrainerApp(QMainWindow):
records = cursor.fetchall()
conn.close()
- # 3. Iterate over records and construct notes
for row in records:
db_deck_name = row[0].strip() if row[0] else "Castilian Spanish Master"
- es_text = row[1].strip()
- en_text = row[2].strip()
- notes_text = row[3].strip() if row[3] else ""
- tags_string = row[4].strip() if row[4] else ""
+ 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]
- # --- Handle Spanish Audio Mapping ---
- safe_es_audio_name = "".join([c for c in es_text if c.isalnum() or c in (" ", "_")]).strip().replace(" ", "_").lower()
- relative_es_path = f"media/{safe_es_audio_name}_es_female.mp3"
- es_filename_only = f"{safe_es_audio_name}_es_female.mp3"
+ self.get_or_generate_audio_duration(es_id, es_text, "es")
+ self.get_or_generate_audio_duration(en_id, en_text, "en")
- if os.path.exists(relative_es_path):
- if relative_es_path not in media_files_manifest:
- 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 = ""
+ 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"
- # --- Handle English Audio Mapping ---
- 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_audio_name}_en_female.mp3"
- en_filename_only = f"{safe_en_audio_name}_en_female.mp3"
+ media_files_manifest.extend([relative_es_path, relative_en_path])
- if os.path.exists(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:
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)
- # Split tags string by spaces into an array list for genanki
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(
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
)
decks_map[db_deck_name].add_note(flash_note)
- # 4. Package all deck components into an .apkg container
try:
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)
-
- 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)
-
+ QMessageBox.information(self, "Export Complete", f"β¨ Packaged complete!\nOutput: {file_title}")
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):
- """Action handler loop for compiling video cards."""
- QMessageBox.information(
- self, "Video Synthesis Suite",
- f"Staging visual timeline render frames loop!\n\n"
- f"Target Directory: {self.video_export_dir}\n"
- f"Currently Filtered Scope count: {len(self.flashcard_ids_pool)} sequences."
- )
+ """
+ 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)
-# =====================================================================
-# π DIAGNOSTIC STARTUP FRAMEWORK WRAPPER
-# =====================================================================
if __name__ == "__main__":
- print("π Initializing PyQt6 Application Framework...")
+ print("π Launching Core PyQt6 Framework Threads...")
try:
app = QApplication(sys.argv)
window = SpanishTrainerApp()
@@ -952,6 +1175,5 @@ if __name__ == "__main__":
sys.exit(app.exec())
except Exception as fatal_error:
import traceback
- print("\nβ CRITICAL CRASH DETECTED ON CORE STARTUP THREAD!")
traceback.print_exc()
sys.exit(1)
\ No newline at end of file
diff --git a/spanish_trainer.db b/spanish_trainer.db
index bff953f..fc232cc 100644
Binary files a/spanish_trainer.db and b/spanish_trainer.db differ