139_spanish-voice-trainer/main.py

410 lines
17 KiB
Python
Raw Normal View History

2026-06-12 11:58:47 +00:00
# main.py
2026-06-14 09:56:43 +00:00
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
from database.connection import init_db, get_connection
2026-06-13 07:02:06 +00:00
from core.bulk_importer import BulkImporter
2026-06-13 09:59:02 +00:00
from core.clean_glossary import GlossaryCleaner
2026-06-12 11:58:47 +00:00
2026-06-14 09:56:43 +00:00
class SpanishTrainerApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Castilian Voice Trainer Pro")
self.setMinimumSize(1000, 650)
# 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()
conn = get_connection()
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"
textbook = "Aula Internacional Plus 1"
if os.path.exists(pdf_file):
importer = BulkImporter()
importer.import_pdf_glossary(pdf_file, textbook)
cleaner = GlossaryCleaner()
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.")
# =====================================================================
# 🗄️ 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
# =====================================================================
2026-06-13 09:59:02 +00:00
2026-06-14 09:56:43 +00:00
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]} ]")
2026-06-12 11:58:47 +00:00
if __name__ == "__main__":
2026-06-14 09:56:43 +00:00
app = QApplication(sys.argv)
window = SpanishTrainerApp()
window.show()
sys.exit(app.exec())