139_spanish-voice-trainer/main.py

410 lines
No EOL
17 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
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(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
# =====================================================================
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__":
app = QApplication(sys.argv)
window = SpanishTrainerApp()
window.show()
sys.exit(app.exec())