410 lines
No EOL
17 KiB
Python
410 lines
No EOL
17 KiB
Python
# 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()) |