before redesign of the GUI

This commit is contained in:
stephen 2026-06-14 19:56:43 +10:00
parent ae1c382c3c
commit b3104d66a5
3 changed files with 530 additions and 134 deletions

View file

@ -4,59 +4,93 @@ from database.connection import get_connection
class GlossaryCleaner: class GlossaryCleaner:
def __init__(self): def __init__(self):
# Captures trailing markers: " m", " f", " m, pl", " f, pl", " pl"
self.gender_pattern = re.compile(r'\s+\b(m|f|m,\s*pl|f,\s*pl|pl)\b\s*$')
# Captures verb irregular brackets: " (zc)", " (ie)", etc. # Captures verb irregular brackets: " (zc)", " (ie)", etc.
self.verb_pattern = re.compile(r'\s*\(([a-z\s]+)\)') self.verb_pattern = re.compile(r'\s*\(([a-z\s]+)\)')
def extract_metadata(self, text: str) -> tuple[str, str, str]: # Aggressive character-level match for broken trailing gender tags
""" # Tracks variations like: " m", " f", "mpl", "fpl", "smpl", "osmpl" at the end of a string
Parses text to isolate the clean conversational string, self.broken_gender_pattern = re.compile(r'[\s]*\b(m|f|pl)\b$|[\s\w]*(m|f|pl|mpl|fpl|smpl|osmpl)$', re.IGNORECASE)
the word classification type, and specific grammatical notes.
""" def clean_and_expand_spanish(self, text: str) -> tuple[list[str], str, str]:
"""Parses and strips layout noise from Spanish strings, extracting rich metadata."""
word_type = "phrase" word_type = "phrase"
grammar_note = None grammar_note = None
clean_text = text.strip() clean_text = text.strip()
# Check for verb present-tense irregular markers # 1. Extract verb irregularities if present
verb_match = self.verb_pattern.search(clean_text) verb_match = self.verb_pattern.search(clean_text)
if verb_match: if verb_match:
word_type = "verb" word_type = "verb"
grammar_note = verb_match.group(1) # e.g., 'zc', 'ie' grammar_note = verb_match.group(1).strip()
clean_text = self.verb_pattern.sub('', clean_text).strip() clean_text = self.verb_pattern.sub('', clean_text).strip()
return clean_text, word_type, grammar_note
# Check for noun gender indicators # 2. Extract and remove sticky gender notations (e.g., "bañ osmpl" -> "baños")
gender_match = self.gender_pattern.search(clean_text) # Check if text ends with common markers
if gender_match: lower_text = clean_text.lower()
if lower_text.endswith(('mpl', 'fpl', 'smpl', 'osmpl')):
word_type = "noun" word_type = "noun"
grammar_note = gender_match.group(1).strip() # e.g., 'm', 'f' if 'f' in lower_text:
clean_text = self.gender_pattern.sub('', clean_text).strip() grammar_note = "f, pl"
return clean_text, word_type, grammar_note else:
grammar_note = "m, pl"
# If it's a single word without tags, check if it's likely an adjective/noun split # Reconstruct the original word base before the layout break
if ' ' not in clean_text and '/' in clean_text: # e.g., "bañ osmpl" -> remove "osmpl" and append "os" to restore "baños"
if lower_text.endswith('osmpl') and clean_text.lower().endswith('osmpl'):
clean_text = clean_text[:-5] + "os"
else:
# General strip of trailing garbage characters
clean_text = re.sub(r'\s*[a-zA-Z\s]*$', '', clean_text)
# Standard boundary check for clean tags (e.g., "años60 m")
elif re.search(r'\s+\b(m|f|pl|m,\s*pl|f,\s*pl)\b\s*$', clean_text):
word_type = "noun"
match = re.search(r'\b(m|f|pl|m,\s*pl|f,\s*pl)\b\s*$', clean_text)
if match:
grammar_note = match.group(1).strip()
clean_text = re.sub(r'\s+\b(m|f|pl|m,\s*pl|f,\s*pl)\b\s*$', '', clean_text).strip()
clean_text = clean_text.strip()
# 3. Expand dual-gender adjectives (e.g., "apasionado/a")
variants = []
if '/' in clean_text and ' ' not in clean_text:
word_type = "adjective" word_type = "adjective"
base, suffix = clean_text.split('/', 1)
base = base.strip()
suffix = suffix.strip()
return clean_text, word_type, grammar_note if suffix == 'a' and base.endswith('o'):
variants.append((base, word_type, "m"))
variants.append((base[:-1] + 'a', word_type, "f"))
elif suffix == 'ra' and base.endswith('r'):
variants.append((base, word_type, "m"))
variants.append((base + 'a', word_type, "f"))
else:
variants.append((clean_text, word_type, grammar_note))
else:
variants.append((clean_text, word_type, grammar_note))
def ensure_columns_exist(self, cursor): return variants
"""Dynamically appends schema metadata columns to phrases table if missing."""
try: def clean_english_text(self, text: str) -> str:
cursor.execute("ALTER TABLE phrases ADD COLUMN word_type TEXT") """Fixes layout spacing issues on English infinitive verbs."""
cursor.execute("ALTER TABLE phrases ADD COLUMN grammar_note TEXT") cleaned = text.strip()
except Exception: # Ensure duplicate internal spacing is compressed
# Columns already exist, skip safe alert safely cleaned = re.sub(r'\s+', ' ', cleaned)
pass
# Intercept smashed English infinitives (e.g., "toappear" -> "to appear")
if cleaned.startswith("to") and len(cleaned) > 2 and not cleaned.startswith("to "):
cleaned = re.sub(r'^to([a-z])', r'to \1', cleaned)
return cleaned
def process_database_clean(self): def process_database_clean(self):
"""Processes and normalizes raw table entries into pristine structures."""
conn = get_connection() conn = get_connection()
cursor = conn.cursor() cursor = conn.cursor()
# Ensure our rich metadata slots exist in SQLite print("🧹 Extracting raw dataset for deep metadata extraction...")
self.ensure_columns_exist(cursor)
print("🧹 Extracting raw glossary dataset for metadata preservation...")
cursor.execute(""" cursor.execute("""
SELECT t.source_phrase_id, p1.text as es_text, p2.text as en_text, SELECT t.source_phrase_id, p1.text as es_text, p2.text as en_text,
@ -73,9 +107,9 @@ class GlossaryCleaner:
conn.close() conn.close()
return return
print(f"⚙️ Migrating {len(raw_rows)} rows into a structured format...") print(f"⚙️ Migrating {len(raw_rows)} rows into corrected schemas...")
# Clear out current tables to run a fresh, structured reload # Clear out previous passes completely
cursor.execute("DELETE FROM translations") cursor.execute("DELETE FROM translations")
cursor.execute("DELETE FROM phrases") cursor.execute("DELETE FROM phrases")
cursor.execute("DELETE FROM audio_tracks") cursor.execute("DELETE FROM audio_tracks")
@ -83,44 +117,28 @@ class GlossaryCleaner:
inserted_count = 0 inserted_count = 0
for _, es_raw, en_raw, textbook, unit, context in raw_rows: for _, es_raw, en_raw, textbook, unit, context in raw_rows:
# Isolate text from technical indicators # Process Spanish layout text elements
clean_es, word_type, grammar_note = self.extract_metadata(es_raw) es_variants = self.clean_and_expand_spanish(es_raw)
# Process and restore spaces to English text elements
en_clean = self.clean_english_text(en_raw)
# Handle dual-gender expansions like "apasionado/a" for es_clean, w_type, g_note in es_variants:
variants = []
if '/' in clean_es:
base, suffix = clean_es.split('/', 1)
base = base.strip()
suffix = suffix.strip()
if suffix == 'a' and base.endswith('o'):
variants.append((base, "adjective", "m"))
variants.append((base[:-1] + 'a', "adjective", "f"))
elif suffix == 'ra' and base.endswith('r'):
variants.append((base, "adjective", "m"))
variants.append((base + 'a', "adjective", "f"))
else:
variants.append((clean_es, word_type, grammar_note))
else:
variants.append((clean_es, word_type, grammar_note))
for es_variant, w_type, g_note in variants:
try: try:
# 1. Insert Spanish entry with structural metadata columns filled # Insert pristine Spanish entry
cursor.execute(""" cursor.execute("""
INSERT INTO phrases (text, language, textbook, unit, source_context, word_type, grammar_note) INSERT INTO phrases (text, language, textbook, unit, source_context, word_type, grammar_note)
VALUES (?, 'es', ?, ?, ?, ?, ?) VALUES (?, 'es', ?, ?, ?, ?, ?)
""", (es_variant, textbook, unit, context, w_type, g_note)) """, (es_clean, textbook, unit, context, w_type, g_note))
es_id = cursor.lastrowid es_id = cursor.lastrowid
# 2. Insert English entry # Insert spaced English entry
cursor.execute(""" cursor.execute("""
INSERT INTO phrases (text, language, textbook, unit, source_context, word_type, grammar_note) INSERT INTO phrases (text, language, textbook, unit, source_context, word_type, grammar_note)
VALUES (?, 'en', ?, ?, ?, ?, NULL) VALUES (?, 'en', ?, ?, ?, ?, NULL)
""", (en_raw, textbook, unit, context, w_type)) """, (en_clean, textbook, unit, context, w_type))
en_id = cursor.lastrowid en_id = cursor.lastrowid
# 3. Create Bidirectional Cross-References # Re-map relations
cursor.execute("INSERT INTO translations (source_phrase_id, target_phrase_id) VALUES (?, ?)", (es_id, en_id)) cursor.execute("INSERT INTO translations (source_phrase_id, target_phrase_id) VALUES (?, ?)", (es_id, en_id))
cursor.execute("INSERT INTO translations (source_phrase_id, target_phrase_id) VALUES (?, ?)", (en_id, es_id)) cursor.execute("INSERT INTO translations (source_phrase_id, target_phrase_id) VALUES (?, ?)", (en_id, es_id))
@ -130,4 +148,4 @@ class GlossaryCleaner:
conn.commit() conn.commit()
conn.close() conn.close()
print(f"🎉 Metadata-aware normalization complete! Saved {inserted_count} structured phrases.") print(f"🎉 Schema extraction pipeline completed! Saved {inserted_count} pristine phrase definitions.")

View file

@ -1,59 +1,54 @@
# database/connection.py # database/connection.py
import sqlite3 import sqlite3
import os
DB_NAME = "spanish_trainer.db"
def get_connection(): def get_connection():
"""Returns a standard connection object to the SQLite database.""" return sqlite3.connect("spanish_trainer.db")
return sqlite3.connect(DB_NAME)
def init_db(): def init_db():
""" conn = get_connection()
Initializes the SQLite database tables if they do not exist. cursor = conn.cursor()
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 # 1. Main Phrases Table
schema = """ cursor.execute("""
CREATE TABLE IF NOT EXISTS phrases ( CREATE TABLE IF NOT EXISTS phrases (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL, text TEXT NOT NULL,
language TEXT NOT NULL, language TEXT NOT NULL,
textbook TEXT DEFAULT NULL, textbook TEXT,
unit INTEGER DEFAULT NULL, unit INTEGER,
source_context TEXT DEFAULT NULL, source_context TEXT,
word_type TEXT,
grammar_note TEXT,
voice_gender TEXT DEFAULT 'female',
base_speed REAL DEFAULT 1.0,
deck_name TEXT DEFAULT 'General',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); )
""")
# 2. Translations Cross-Reference Table
cursor.execute("""
CREATE TABLE IF NOT EXISTS translations ( CREATE TABLE IF NOT EXISTS translations (
source_phrase_id INTEGER, source_phrase_id INTEGER,
target_phrase_id INTEGER, target_phrase_id INTEGER,
PRIMARY KEY (source_phrase_id, target_phrase_id), PRIMARY KEY (source_phrase_id, target_phrase_id),
FOREIGN KEY (source_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE, FOREIGN KEY (source_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE,
FOREIGN KEY (target_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE FOREIGN KEY (target_phrase_id) REFERENCES phrases(id) ON DELETE CASCADE
); )
""")
# 3. Audio Tracks Metadata Table (Required by the glossary cleaner)
cursor.execute("""
CREATE TABLE IF NOT EXISTS audio_tracks ( CREATE TABLE IF NOT EXISTS audio_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
phrase_id INTEGER NOT NULL, phrase_id INTEGER,
voice_gender TEXT NOT NULL,
voice_name TEXT NOT NULL,
file_path TEXT NOT NULL, file_path TEXT NOT NULL,
is_reference INTEGER DEFAULT 1, sample_rate INTEGER,
duration REAL,
FOREIGN KEY (phrase_id) REFERENCES phrases(id) ON DELETE CASCADE 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() conn.commit()
print("✅ Database tables verified and initialized successfully.")
except sqlite3.Error as e:
print(f"❌ Database initialization failed: {e}")
finally:
conn.close() conn.close()
print("✅ Rich metadata database schema initialized successfully.")

403
main.py
View file

@ -1,27 +1,410 @@
# main.py # main.py
import asyncio import sys
from database.connection import init_db import os
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QTabWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox,
QTableWidget, QTableWidgetItem, QSlider, QFormLayout, QTextEdit, QFrame, QMessageBox
)
from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtGui import QFont
from database.connection import init_db, get_connection
from core.bulk_importer import BulkImporter from core.bulk_importer import BulkImporter
from core.clean_glossary import GlossaryCleaner from core.clean_glossary import GlossaryCleaner
async def rebuild_pipeline(): class SpanishTrainerApp(QMainWindow):
print("🚀 Initiating Clean Reconstruction Pipeline...") def __init__(self):
super().__init__()
self.setWindowTitle("Castilian Voice Trainer Pro")
self.setMinimumSize(1000, 650)
# 1. This will automatically recreate the blank .db file and all tables # 1. Initialize and Seed Database if Empty
self.ensure_database_populated()
# 2. Initialize PyQt Multimedia Audio Engine Components
self.media_player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output)
self.current_flashcard_id = None
# 3. Build UI Components
self.tabs = QTabWidget()
self.setCentralWidget(self.tabs)
self.init_phrase_sandbox_tab()
self.init_flashcard_reviewer_tab()
# 4. Initial Load of Database Data into Grid View
self.refresh_crud_table()
def ensure_database_populated(self):
"""Verifies if the database exists and has records; seeds it from the PDF if empty."""
init_db() init_db()
# 2. Run the layout-aware bulk import from the PDF layout text conn = get_connection()
importer = BulkImporter() cursor = conn.cursor()
try:
cursor.execute("SELECT COUNT(*) FROM phrases")
count = cursor.fetchone()[0]
except Exception:
count = 0
conn.close()
if count == 0:
print("🗄️ Database appears empty. Running the structural reconstruction pipeline...")
pdf_file = "aula_int_plus_1_glos_en_alfa.pdf" pdf_file = "aula_int_plus_1_glos_en_alfa.pdf"
textbook = "Aula Internacional Plus 1" textbook = "Aula Internacional Plus 1"
if os.path.exists(pdf_file):
importer = BulkImporter()
importer.import_pdf_glossary(pdf_file, textbook) importer.import_pdf_glossary(pdf_file, textbook)
# 3. Immediately run the metadata preservation and text cleaning pass
cleaner = GlossaryCleaner() cleaner = GlossaryCleaner()
cleaner.process_database_clean() cleaner.process_database_clean()
print("✨ Database successfully seeded with metadata-parsed entries.")
else:
print(f"⚠️ Warning: Could not find '{pdf_file}' to automatically seed records.")
print("\n✨ Database completely rebuilt with pristine structured data!") # =====================================================================
# 🗄️ TAB 1: PHRASE SANDBOX (CRUD Panel)
# =====================================================================
def init_phrase_sandbox_tab(self):
tab = QWidget()
layout = QHBoxLayout(tab)
left_panel = QVBoxLayout()
search_layout = QHBoxLayout()
search_layout.addWidget(QLabel("🔍 Filter text:"))
self.search_input = QLineEdit()
self.search_input.textChanged.connect(self.refresh_crud_table)
search_layout.addWidget(self.search_input)
left_panel.addLayout(search_layout)
self.phrase_table = QTableWidget()
self.phrase_table.setColumnCount(6)
self.phrase_table.setHorizontalHeaderLabels(["ID", "Text", "Lang", "Type", "Voice Gender", "Deck Tag"])
self.phrase_table.itemSelectionChanged.connect(self.handle_table_row_select)
left_panel.addWidget(self.phrase_table)
right_panel = QVBoxLayout()
form_frame = QFrame()
form_frame.setFrameShape(QFrame.Shape.StyledPanel)
form_layout = QFormLayout(form_frame)
self.input_id = QLineEdit()
self.input_id.setReadOnly(True)
self.input_id.setPlaceholderText("Auto-assigned ID")
self.input_text = QTextEdit()
self.input_text.setMaximumHeight(60)
self.combo_lang = QComboBox()
self.combo_lang.addItems(["es", "en"])
self.combo_type = QComboBox()
self.combo_type.addItems(["sentence", "phrase", "noun", "verb", "adjective"])
self.combo_voice_gender = QComboBox()
self.combo_voice_gender.addItems(["female", "male"])
self.slider_base_speed = QSlider(Qt.Orientation.Horizontal)
self.slider_base_speed.setMinimum(50)
self.slider_base_speed.setMaximum(150)
self.slider_base_speed.setValue(100)
self.lbl_base_speed = QLabel("1.00x")
self.slider_base_speed.valueChanged.connect(lambda v: self.lbl_base_speed.setText(f"{v/100:.2f}x"))
speed_box = QHBoxLayout()
speed_box.addWidget(self.slider_base_speed)
speed_box.addWidget(self.lbl_base_speed)
self.input_deck_tag = QLineEdit()
self.input_deck_tag.setPlaceholderText("e.g., Anki_Unit_1")
form_layout.addRow("Phrase ID Resource:", self.input_id)
form_layout.addRow(QLabel("<b>Target Conversational Text:</b>"))
form_layout.addRow(self.input_text)
form_layout.addRow("Language Accent:", self.combo_lang)
form_layout.addRow("Grammar Type Classification:", self.combo_type)
form_layout.addRow("<b>Preferred Voice Gender:</b>", self.combo_voice_gender)
form_layout.addRow("<b>Default Playback Speed:</b>", speed_box)
form_layout.addRow("Deck / Group Identifier Tag:", self.input_deck_tag)
crud_buttons = QHBoxLayout()
self.btn_save = QPushButton(" Save New")
self.btn_update = QPushButton("💾 Update Entry")
self.btn_delete = QPushButton("🗑️ Delete")
self.btn_save.clicked.connect(self.crud_create)
self.btn_update.clicked.connect(self.crud_update)
self.btn_delete.clicked.connect(self.crud_delete)
crud_buttons.addWidget(self.btn_save)
crud_buttons.addWidget(self.btn_update)
crud_buttons.addWidget(self.btn_delete)
right_panel.addWidget(QLabel("<h3>Configure Phrase & Voice Variables</h3>"))
right_panel.addWidget(form_frame)
right_panel.addLayout(crud_buttons)
right_panel.addStretch()
layout.addLayout(left_panel, stretch=3)
layout.addLayout(right_panel, stretch=2)
self.tabs.addTab(tab, "🗄️ Phrase Sandbox (CRUD)")
# =====================================================================
# 🃏 TAB 2: FLASHCARD STUDY PLAYER
# =====================================================================
def init_flashcard_reviewer_tab(self):
tab = QWidget()
layout = QVBoxLayout(tab)
card_frame = QFrame()
card_frame.setStyleSheet("background-color: #ffffff; border: 2px solid #bdc3c7; border-radius: 12px;")
card_layout = QVBoxLayout(card_frame)
self.lbl_card_text = QLabel("Click 'Load Next' to study sentences...")
self.lbl_card_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_card_text.setFont(QFont("Arial", 22, 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()
playback_layout = QHBoxLayout()
playback_layout.addWidget(QLabel("🔊 Fine-Tune Study 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.0x (Normal)")
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)
action_buttons = QHBoxLayout()
self.btn_play_voice = QPushButton("🗣️ Play Voice Track")
self.btn_flip_card = QPushButton("👁️ Reveal Translation")
self.btn_load_next = QPushButton("➡️ Next Card")
self.btn_play_voice.clicked.connect(self.handle_play_voice)
self.btn_flip_card.clicked.connect(self.handle_flip_card)
self.btn_load_next.clicked.connect(self.handle_load_next_card)
action_buttons.addWidget(self.btn_play_voice)
action_buttons.addWidget(self.btn_flip_card)
action_buttons.addStretch()
action_buttons.addWidget(self.btn_load_next)
layout.addWidget(card_frame, stretch=1)
layout.addLayout(playback_layout)
layout.addLayout(action_buttons)
self.tabs.addTab(tab, "🃏 Flashcard Study")
# =====================================================================
# ⚡ ENGINE BUSINESS LOGIC OPERATIONS & DATABASE MAPPINGS
# =====================================================================
def refresh_crud_table(self):
conn = get_connection()
cursor = conn.cursor()
filter_text = self.search_input.text()
if filter_text:
cursor.execute("""
SELECT id, text, language, word_type, voice_gender, deck_name
FROM phrases WHERE text LIKE ? ORDER BY id DESC LIMIT 100
""", (f"%{filter_text}%",))
else:
cursor.execute("""
SELECT id, text, language, word_type, voice_gender, deck_name
FROM phrases ORDER BY id DESC LIMIT 100
""")
rows = cursor.fetchall()
conn.close()
self.phrase_table.setRowCount(0)
for row_idx, row_data in enumerate(rows):
self.phrase_table.insertRow(row_idx)
for col_idx, value in enumerate(row_data):
self.phrase_table.setItem(row_idx, col_idx, QTableWidgetItem(str(value if value is not None else "")))
def handle_table_row_select(self):
selected_ranges = self.phrase_table.selectedRanges()
if not selected_ranges:
return
row = selected_ranges[0].topRow()
item = self.phrase_table.item(row, 0)
if not item:
return
phrase_id = item.text()
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT id, text, language, word_type, voice_gender, base_speed, deck_name FROM phrases WHERE id = ?", (phrase_id,))
record = cursor.fetchone()
conn.close()
if record:
self.input_id.setText(str(record[0]))
self.input_text.setPlainText(str(record[1]))
self.combo_lang.setCurrentText(str(record[2]))
self.combo_type.setCurrentText(str(record[3]) if record[3] else "sentence")
self.combo_voice_gender.setCurrentText(str(record[4]) if record[4] else "female")
speed_val = int((record[5] if record[5] else 1.0) * 100)
self.slider_base_speed.setValue(speed_val)
self.input_deck_tag.setText(str(record[6]) if record[6] else "General")
def crud_create(self):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO phrases (text, language, word_type, voice_gender, base_speed, deck_name)
VALUES (?, ?, ?, ?, ?, ?)
""", (
self.input_text.toPlainText().strip(),
self.combo_lang.currentText(),
self.combo_type.currentText(),
self.combo_voice_gender.currentText(),
self.slider_base_speed.value() / 100.0, # Fixed typo: changed from .setValue() to .value()
self.input_deck_tag.text().strip() or "General"
))
conn.commit()
conn.close()
self.refresh_crud_table()
QMessageBox.information(self, "Success", "Phrase generated into database index store successfully.")
def crud_update(self):
pid = self.input_id.text()
if not pid:
return
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE phrases SET text=?, language=?, word_type=?, voice_gender=?, base_speed=?, deck_name=?
WHERE id=?
""", (
self.input_text.toPlainText().strip(),
self.combo_lang.currentText(),
self.combo_type.currentText(),
self.combo_voice_gender.currentText(),
self.slider_base_speed.value() / 100.0,
self.input_deck_tag.text().strip() or "General",
pid
))
conn.commit()
conn.close()
self.refresh_crud_table()
QMessageBox.information(self, "Success", "Database record fields updated cleanly.")
def crud_delete(self):
pid = self.input_id.text()
if not pid:
return
conn = get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM phrases WHERE id=?", (pid,))
conn.commit()
conn.close()
self.input_id.clear()
self.input_text.clear()
self.refresh_crud_table()
# =====================================================================
# 🔊 AUDIO PLAYER SYSTEM
# =====================================================================
def handle_load_next_card(self):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT id, text, language, word_type, voice_gender, base_speed FROM phrases ORDER BY RANDOM() LIMIT 1")
record = cursor.fetchone()
conn.close()
if record:
self.current_flashcard_id = record[0]
self.lbl_card_text.setText(record[1])
lang_lbl = "Spanish Accent" if record[2] == "es" else "English Accent"
gender_lbl = str(record[4]).capitalize() if record[4] else "Female"
self.lbl_card_meta.setText(f"Classification: {record[3]} • Configured Voice: {lang_lbl} ({gender_lbl})")
card_saved_speed = int((record[5] if record[5] else 1.0) * 100)
self.slider_review_speed.setValue(card_saved_speed)
def handle_play_voice(self):
if not self.current_flashcard_id:
return
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT text, language, voice_gender FROM phrases WHERE id = ?", (self.current_flashcard_id,))
phrase_row = cursor.fetchone()
conn.close()
if not phrase_row:
return
text_str, lang_code, voice_gender = phrase_row
safe_filename = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).rstrip().replace(" ", "_").lower()
target_audio_file = f"media/{safe_filename}_{lang_code}_{voice_gender}.mp3"
os.makedirs("media", exist_ok=True)
if not os.path.exists(target_audio_file):
QMessageBox.warning(self, "Audio Track Missing",
f"Audio track asset file not found in directory:\n'{target_audio_file}'\n\nRun the background Edge-TTS batch generator script next to download this track automatically.")
return
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_audio_file)))
current_rate = self.slider_review_speed.value() / 100.0
self.media_player.setPlaybackRate(current_rate)
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)
def handle_flip_card(self):
if not self.current_flashcard_id:
return
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT p2.text FROM translations t
JOIN phrases p2 ON t.target_phrase_id = p2.id
WHERE t.source_phrase_id = ?
""", (self.current_flashcard_id,))
row = cursor.fetchone()
conn.close()
if row:
current_es = self.lbl_card_text.text().split("\n\n👉")[0]
self.lbl_card_text.setText(f"{current_es}\n\n👉 [ {row[0]} ]")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(rebuild_pipeline()) app = QApplication(sys.argv)
window = SpanishTrainerApp()
window.show()
sys.exit(app.exec())