139_spanish-voice-trainer/main.py

598 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# main.py
import sys
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
# 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(1150, 700)
# 1. Initialize schema structures and check ingestion status
self.ensure_database_populated()
# Audio Player Architecture Setup
self.media_player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output)
self.current_flashcard_id = None
# Central Main Window Tabs Interface
self.tabs = QTabWidget()
self.setCentralWidget(self.tabs)
self.init_phrase_sandbox_tab()
self.init_flashcard_reviewer_tab()
# 2. Populate unified database rows into interface layout grid
self.refresh_crud_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()
# Verify if the translations table has rows populated
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):
# Run the layout parser staging execution pass
importer = BulkImporter()
importer.import_pdf_glossary(pdf_file, "Aula Internacional Plus 1")
# Clean, pair up English/Spanish, and insert the final rows
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.")
# =====================================================================
# 🗄️ TAB 1: TRANSLATION-CENTRIC PHRASE SANDBOX (CRUD)
# =====================================================================
def init_phrase_sandbox_tab(self):
tab = QWidget()
layout = QHBoxLayout(tab)
# --- LEFT SIDE PANEL: Filter Controls & Unified Data Grid ---
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 or U8_5A")
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)
# Unified Translations Row Table Matrix
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)
# Row Pointer Navigation Steppers
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 SIDE PANEL: Side-by-Side Unified Twin Form Box Views ---
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_deck_tag = QLineEdit()
self.input_deck_tag.setPlaceholderText("Anki Sub-deck Hierarchy")
# Common layout stylesheet for the utility buttons
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;
}
"""
# 🔊 1. Spanish Header Layout with Speed Controls
es_header_layout = QHBoxLayout()
es_header_layout.setContentsMargins(0, 5, 0, 5)
es_header_layout.addWidget(QLabel("<b>🇪🇸 Castilian Spanish Text Element:</b>"))
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()
# 🔊 2. English Header Layout Activated with Speed Controls
en_header_layout = QHBoxLayout()
en_header_layout.setContentsMargins(0, 5, 0, 5)
en_header_layout.addWidget(QLabel("<b>🇬🇧 English Target Translation:</b>"))
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()
# Mount elements sequentially into the form frame mapping structure
form_layout.addRow("<b>Translation Link ID:</b>", 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("Classification Profile:", self.combo_type)
form_layout.addRow("Source Context ID (Raw):", self.input_context)
form_layout.addRow("<b>Target Deck Scope:</b>", 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("<h3>Translation Node Management Matrix</h3>"))
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 = 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("Select 'Next Card' to initiate study sequence...")
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: 25px;")
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("🔊 Voice Track 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")
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 English Partner")
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 Review")
# =====================================================================
# ⚡ ENGINE DATABASE LOGIC & FILTER COMPILATIONS
# =====================================================================
def refresh_crud_table(self):
"""Pulls unified translation nodes into pairs while enforcing context text matches."""
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 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
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_deck_tag.setText(str(record[5]) if record[5] else "General")
def step_table_row(self, direction):
"""Steps your focus row highlighting pointer index sequentially."""
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)
def crud_create_pair(self):
conn = get_connection()
cursor = conn.cursor()
# Write Spanish node entry
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
# Write English node entry
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
# Build cross-referencing translation relational binding matrix row
cursor.execute("""
INSERT INTO translations (source_phrase_id, target_phrase_id, deck_name) VALUES (?, ?, ?)
""", (es_id, en_id, self.input_deck_tag.text().strip() or "General"))
conn.commit()
conn.close()
self.refresh_crud_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
# Keep underscores exact in context updates
cursor.execute("UPDATE phrases SET text=?, word_type=?, source_context=? 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=?",
(self.input_text_en.toPlainText().strip(), self.combo_type.currentText(), self.input_context.text().strip(), en_id))
cursor.execute("UPDATE translations SET deck_name=? WHERE translation_id=?",
(self.input_deck_tag.text().strip() or "General", tx_id))
conn.commit()
conn.close()
self.refresh_crud_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.input_tx_id.clear()
self.input_text_es.clear()
self.input_text_en.clear()
# =====================================================================
# 🔊 AUDIO OPERATIONS & FLASHCARD CONTROL
# =====================================================================
def handle_sandbox_play_es(self):
"""Generates/plays the Spanish phrase from the Sandbox using the chosen speed rate."""
text_str = self.input_text_es.toPlainText().strip()
if not text_str:
QMessageBox.warning(self, "Empty Value", "Please select a valid translation pair or type a Spanish phrase to play.")
return
safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).rstrip().replace(" ", "_").lower()
os.makedirs("media", exist_ok=True)
target_file = f"media/{safe_name}_es_female.mp3"
if not os.path.exists(target_file):
print(f"🔊 Generating Neural Castilian Spanish track 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"Failed to synthesize Spanish voice:\n{tts_err}")
return
if os.path.exists(target_file):
speed_multiplier = float(self.combo_speed_es.currentText().replace("x", ""))
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file)))
self.media_player.setPlaybackRate(speed_multiplier)
self.media_player.play()
def handle_sandbox_play_en(self):
"""Generates/plays the English translation string from the Sandbox using its speed rate."""
text_str = self.input_text_en.toPlainText().strip()
if not text_str:
QMessageBox.warning(self, "Empty Value", "Please enter text inside the English target translation container.")
return
safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).rstrip().replace(" ", "_").lower()
os.makedirs("media", exist_ok=True)
target_file = f"media/{safe_name}_en_female.mp3"
if not os.path.exists(target_file):
print(f"🔊 Generating Neural English voice track for '{text_str}'...")
try:
import asyncio
import edge_tts
communicate = edge_tts.Communicate(text_str, "en-GB-SoniaNeural")
asyncio.run(communicate.save(target_file))
except Exception as tts_err:
QMessageBox.critical(self, "TTS Error", f"Failed to synthesize English voice:\n{tts_err}")
return
if os.path.exists(target_file):
speed_multiplier = float(self.combo_speed_en.currentText().replace("x", ""))
self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(target_file)))
self.media_player.setPlaybackRate(speed_multiplier)
self.media_player.play()
def handle_load_next_card(self):
"""Picks a random phrase node from the database, shifting state context to flashcard mode."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.translation_id, p1.text, p1.word_type, p1.source_context, t.deck_name, p1.id
FROM translations t
JOIN phrases p1 ON t.source_phrase_id = p1.id
WHERE p1.language = 'es'
ORDER BY RANDOM() LIMIT 1
""")
record = cursor.fetchone()
conn.close()
if record:
self.current_flashcard_id = record[5]
self.lbl_card_text.setText(record[1])
self.lbl_card_meta.setText(f"Tx Link node: {record[0]} • Context: {record[3]} • Subdeck: {record[4]}")
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,))
row = cursor.fetchone()
conn.close()
if row:
text_str, lang, gender = row
safe_name = "".join([c for c in text_str if c.isalnum() or c in (" ", "_")]).rstrip().replace(" ", "_").lower()
resolved_gender = gender if gender else "female"
target_file = f"media/{safe_name}_{lang}_{resolved_gender}.mp3"
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()
else:
QMessageBox.warning(self, "Asset Missing", f"Audio file not found at path location:\n{target_file}")
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 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]
self.lbl_card_text.setText(f"{clean_es}\n\n👉 [ {row[0]} ]")
# =====================================================================
# 🚀 DIAGNOSTIC STARTUP FRAMEWORK WRAPPER
# =====================================================================
if __name__ == "__main__":
print("🚀 Initializing PyQt6 Application Framework...")
try:
app = QApplication(sys.argv)
print("🔧 Spawning SpanishTrainerApp Instance...")
window = SpanishTrainerApp()
print("🖥️ Mounting User Interface Windows...")
window.show()
print("🎯 Event loop engaged. Handing over control thread...")
sys.exit(app.exec())
except Exception as fatal_error:
import traceback
print("\n❌ CRITICAL CRASH DETECTED ON CORE STARTUP THREAD!")
print("====================================================")
print(f"Error Type: {type(fatal_error).__name__}")
print(f"Error Message: {fatal_error}")
print("====================================================")
traceback.print_exc()
sys.exit(1)