139_spanish-voice-trainer/tabs/review_tab.py

397 lines
No EOL
17 KiB
Python

# tabs/review_tab.py
import os
import re
import subprocess
import tempfile
import threading
from datetime import datetime
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QFormLayout, QMessageBox, QFrame, QStackedWidget
)
from PyQt6.QtCore import Qt, pyqtSlot
import database
import anki_exporter
from tts_utils import parse_text_for_edgetts
from kokoro_utils import generate_local_speech
class ReviewTab(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Core Review State Tracking
self.all_cached_records = []
self.filtered_review_pool = []
self.current_index = -1
self.is_flipped = False # Track front vs back state of the active flashcard
# Primary Main Layout
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(30, 20, 30, 20)
main_layout.setSpacing(15)
# --- SECTION 1: TOP REGION (Source Context & Tags Filters) ---
top_container = QWidget()
top_layout = QFormLayout(top_container)
top_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
top_layout.setSpacing(10)
self.txt_review_context = QLineEdit()
self.txt_review_context.setPlaceholderText("Filter deck by context (e.g., Camino 2027)...")
self.txt_review_context.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_review_context.textChanged.connect(self.handle_live_filter)
self.txt_review_tags = QLineEdit()
self.txt_review_tags.setPlaceholderText("Filter deck by tags (e.g., verb, greeting)...")
self.txt_review_tags.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_review_tags.textChanged.connect(self.handle_live_filter)
top_layout.addRow(QLabel("<b>Source Context:</b>"), self.txt_review_context)
top_layout.addRow(QLabel("<b>Tags:</b>"), self.txt_review_tags)
main_layout.addWidget(top_container)
# --- SECTION 2: MIDDLE REGION (Split Screen Workspace) ---
split_layout = QHBoxLayout()
split_layout.setSpacing(20)
# Left Half: Live Translation Grid View Table
self.table = QTableWidget()
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(["Sort", "English Phrase", "Spanish Translation"])
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
split_layout.addWidget(self.table, stretch=1)
# Right Half: Live Interactive Flashcard Review Panel container
card_container = QWidget()
card_vbox = QVBoxLayout(card_container)
card_vbox.setContentsMargins(0, 0, 0, 0)
card_vbox.setSpacing(12)
# The Card Visual Canvas Frame
self.card_frame = QFrame()
self.card_frame.setStyleSheet("""
QFrame {
background-color: #FAFAFA;
border: 2px solid #E5E7E9;
border-radius: 8px;
}
""")
card_frame_layout = QVBoxLayout(self.card_frame)
card_frame_layout.setContentsMargins(25, 25, 25, 25)
self.card_stack = QStackedWidget()
# Card Front View (English Prompt)
self.view_front = QWidget()
front_layout = QVBoxLayout(self.view_front)
front_prompt = QLabel("TRANSLATE TO SPANISH:")
front_prompt.setStyleSheet("font-size: 11px; font-weight: bold; color: #BDC3C7; letter-spacing: 1px;")
front_prompt.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_english = QLabel("No cards matching active filters.")
self.lbl_english.setStyleSheet("font-size: 20px; color: #34495E; font-weight: 500; margin-top: 15px;")
self.lbl_english.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_english.setWordWrap(True)
front_layout.addWidget(front_prompt)
front_layout.addWidget(self.lbl_english)
front_layout.addStretch()
# Card Back View (Spanish Answer Only)
self.view_back = QWidget()
back_layout = QVBoxLayout(self.view_back)
self.lbl_spanish = QLabel("Spanish Answer Text")
self.lbl_spanish.setStyleSheet("font-size: 24px; font-weight: bold; color: #2980B9; margin-top: 20px;")
self.lbl_spanish.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_spanish.setWordWrap(True)
back_layout.addWidget(self.lbl_spanish)
back_layout.addStretch()
self.card_stack.addWidget(self.view_front)
self.card_stack.addWidget(self.view_back)
card_frame_layout.addWidget(self.card_stack)
card_vbox.addWidget(self.card_frame, stretch=1)
# Buttons Row beneath the Flashcard
card_buttons_layout = QHBoxLayout()
card_buttons_layout.setSpacing(10)
self.btn_play_audio = QPushButton("🔊 Play Voice")
self.btn_play_audio.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_play_audio.setStyleSheet("""
QPushButton { background-color: #E67E22; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; }
QPushButton:hover { background-color: #D35400; }
""")
self.btn_play_audio.clicked.connect(self.play_card_audio)
self.btn_flip_next = QPushButton("Flip Card")
self.btn_flip_next.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_flip_next.setStyleSheet("""
QPushButton { background-color: #34495E; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; }
QPushButton:hover { background-color: #2C3E50; }
""")
self.btn_flip_next.clicked.connect(self.handle_card_interaction)
card_buttons_layout.addWidget(self.btn_play_audio, stretch=1)
card_buttons_layout.addWidget(self.btn_flip_next, stretch=2)
card_vbox.addLayout(card_buttons_layout)
split_layout.addWidget(card_container, stretch=1)
main_layout.addLayout(split_layout)
# --- SECTION 3: BOTTOM REGION (Action Control Panel) ---
bottom_layout = QHBoxLayout()
bottom_layout.setSpacing(15)
self.btn_generate_deck = QPushButton("🗂️ Generate Deck")
self.btn_generate_deck.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_generate_deck.setStyleSheet("""
QPushButton { background-color: #27AE60; color: white; font-weight: bold; font-size: 14px; padding: 10px 22px; border-radius: 5px; }
QPushButton:hover { background-color: #219653; }
""")
self.btn_generate_deck.clicked.connect(self.generate_deck_action)
self.btn_generate_video = QPushButton("🎬 Generate Video")
self.btn_generate_video.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_generate_video.setStyleSheet("""
QPushButton { background-color: #2980B9; color: white; font-weight: bold; font-size: 14px; padding: 10px 22px; border-radius: 5px; }
QPushButton:hover { background-color: #1F618D; }
""")
self.btn_generate_video.clicked.connect(self.generate_video_action)
bottom_layout.addWidget(self.btn_generate_deck)
bottom_layout.addWidget(self.btn_generate_video)
bottom_layout.addStretch()
main_layout.addLayout(bottom_layout)
# Populate initial states from backend
self.reload_review_pool()
@pyqtSlot()
def reload_review_pool(self):
"""Fetches consolidated text entries from the database and initializes cache."""
try:
self.all_cached_records = database.get_all_translations_explicit()
self.handle_live_filter()
except Exception as e:
print(f"Error initializing flashcard review workspace: {e}")
@pyqtSlot()
def handle_live_filter(self):
"""Filters grid contents and sorts by sort_order for the card engine."""
self.table.blockSignals(True)
self.table.setRowCount(0)
filter_ctx = self.txt_review_context.text().lower().strip()
filter_tag = self.txt_review_tags.text().lower().strip()
self.filtered_review_pool = []
for row in self.all_cached_records:
val_ctx = (row["source_context"] or "").lower()
val_tag = (row["tags"] or "").lower()
if (filter_ctx in val_ctx) and (filter_tag in val_tag):
self.filtered_review_pool.append(row)
# Sort sequentially by sort_order (fallback to translation_id if unset)
self.filtered_review_pool.sort(
key=lambda r: (r.get("sort_order") if r.get("sort_order") is not None else 999999, r.get("translation_id", 0))
)
visible_row_index = 0
for row in self.filtered_review_pool:
self.table.insertRow(visible_row_index)
self.table.setItem(visible_row_index, 0, QTableWidgetItem(str(row.get("sort_order", 0))))
self.table.setItem(visible_row_index, 1, QTableWidgetItem(row["en_text"]))
self.table.setItem(visible_row_index, 2, QTableWidgetItem(row["es_text"]))
visible_row_index += 1
self.table.blockSignals(False)
self.current_index = 0 if self.filtered_review_pool else -1
self.is_flipped = False
self.display_current_card()
def display_current_card(self):
"""Pushes current pool row data configurations to layout containers."""
if not (0 <= self.current_index < len(self.filtered_review_pool)):
self.lbl_english.setText("No phrases match current active criteria filters.")
self.lbl_spanish.setText("")
self.card_stack.setCurrentIndex(0)
self.btn_flip_next.setText("Flip Card")
self.btn_flip_next.setEnabled(False)
self.btn_play_audio.setEnabled(False)
return
self.btn_flip_next.setEnabled(True)
self.btn_play_audio.setEnabled(True)
record = self.filtered_review_pool[self.current_index]
# Setup front and back text labels
self.lbl_english.setText(record["en_text"])
self.lbl_spanish.setText(record["es_text"])
# Sync visual widget indexing configurations
if not self.is_flipped:
self.card_stack.setCurrentIndex(0)
self.btn_flip_next.setText("Flip Card")
else:
self.card_stack.setCurrentIndex(1)
self.btn_flip_next.setText("Next Card ➔")
@pyqtSlot()
def handle_card_interaction(self):
"""State machine cycling through card flipped values and automatically playing audio on both side transitions."""
if not self.filtered_review_pool:
return
if not self.is_flipped:
# Transition State: Front -> Back
self.is_flipped = True
self.display_current_card()
# Automatically play Spanish neural audio on flip
self.play_card_audio()
else:
# Transition State: Advance to next sequential item
self.current_index += 1
if self.current_index >= len(self.filtered_review_pool):
self.current_index = 0
self.is_flipped = False
self.display_current_card()
# Automatically play English neural audio on advancing to next card
self.play_card_audio()
def _local_kokoro_speech_worker(self, text, voice_name, speed_multiplier):
"""Background thread worker to render local neural speech via Kokoro without network calls."""
try:
print(f"[Kokoro Debug] Generating local TTS -> Voice: {voice_name} | Speed: {speed_multiplier} | Text: '{text}'")
temp_file = generate_local_speech(text, voice_name=voice_name, speed=speed_multiplier)
if os.path.exists(temp_file) and os.path.getsize(temp_file) > 0:
print(f"[Kokoro Debug] Audio ready. Playing via afplay...")
result = subprocess.run(["afplay", temp_file], capture_output=True, text=True)
if result.returncode != 0:
print(f"[Kokoro Debug] afplay failed: {result.stderr}")
else:
print("[Kokoro Debug] Playback finished successfully.")
else:
print("[Kokoro Debug] Error: Audio file was not created or is 0 bytes.")
except Exception as e:
print(f"[Kokoro Debug] Exception during local speech synthesis: {e}")
@pyqtSlot()
def play_card_audio(self):
"""Triggers local Kokoro neural voice generation based on active flashcard side."""
if not (0 <= self.current_index < len(self.filtered_review_pool)):
return
record = self.filtered_review_pool[self.current_index]
settings = database.load_all_settings() or {}
# Load playback speed setting from database cache (default to 1.0)
try:
speed_multiplier = float(settings.get("tts_playback_speed", 1.0))
except (TypeError, ValueError):
speed_multiplier = 1.0
if not self.is_flipped:
raw_text = record.get("en_text", "")
spoken_text = parse_text_for_edgetts(raw_text)
#voice = "af_sarah" # English voice reference in Kokoro
#voice = "af_heart"
voice = "af_bella" # Default English voice for testing
else:
raw_text = record.get("es_text", "")
spoken_text = parse_text_for_edgetts(raw_text)
is_male = (record.get("gender") == "Male")
# Kokoro Spanish voices: 'ef_dora' (female) / 'em_alex' (male)
voice = "em_alex" if is_male else "ef_dora"
if not spoken_text.strip():
print("[Kokoro Debug] Skipped: Parsed text is empty.")
return
threading.Thread(
target=self._local_kokoro_speech_worker,
args=(spoken_text, voice, speed_multiplier),
daemon=True
).start()
@pyqtSlot()
def generate_deck_action(self):
"""Generates a specialized .apkg Anki deck matching active filter parameters,
prepressing yyyy-mm-dd-hhmm timestamp and matching Sub-Deck-Namespace-Hierarchy without brackets/parentheses."""
if not self.filtered_review_pool:
QMessageBox.warning(self, "Export Aborted", "The current matching review deck queue is empty. Cannot compile an empty deck.")
return
try:
settings = database.load_all_settings() or {}
target_dir = settings.get("anki_export_directory")
root_deck_name = settings.get("anki_root_deck_name")
sub_deck_hierarchy = settings.get("anki_sub_deck_name")
if not target_dir or not os.path.isdir(str(target_dir)):
target_dir = os.path.expanduser("~/Desktop")
else:
target_dir = str(target_dir)
deck_tree_parts = []
if root_deck_name and str(root_deck_name).strip():
deck_tree_parts.append(str(root_deck_name).strip())
if sub_deck_hierarchy and str(sub_deck_hierarchy).strip():
deck_tree_parts.append(str(sub_deck_hierarchy).strip())
else:
if not deck_tree_parts:
deck_tree_parts.append("DefaultDeck")
full_deck_namespace = "::".join(deck_tree_parts)
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M")
clean_namespace = full_deck_namespace.replace('(', '').replace(')', '').replace('[', '').replace(']', '')
clean_namespace = re.sub(r'[^a-zA-Z0-9]', '-', clean_namespace)
clean_namespace = re.sub(r'-+', '-', clean_namespace).strip('-')
filename = f"{timestamp}-{clean_namespace}.apkg"
file_path = os.path.join(target_dir, filename)
anki_exporter.compile_anki_package(self.filtered_review_pool, file_path, full_deck_namespace)
QMessageBox.information(
self,
"Export Complete",
f"Successfully exported Anki package!\n\n"
f"<b>Deck Hierarchy:</b> {full_deck_namespace}\n"
f"<b>File Name:</b> {filename}\n"
f"<b>Path:</b> {file_path}"
)
except Exception as e:
QMessageBox.critical(self, "Compiler Fault Safeguard", f"An exception occurred building your deck container package:\n{str(e)}")
@pyqtSlot()
def generate_video_action(self):
"""Placeholder function execution trigger for media compiler production automation."""
QMessageBox.information(
self,
"Media Generator Active",
f"Initiating background video asset production using the {len(self.filtered_review_pool)} visible phrases."
)