139_spanish-voice-trainer/tabs/sandbox_tab.py
2026-08-28 10:27:21 +10:00

417 lines
No EOL
18 KiB
Python

# tabs/sandbox_tab.py
import subprocess
import tempfile
import os
import threading
import asyncio
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QMessageBox, QFormLayout, QDialog, QTextEdit,
QRadioButton, QButtonGroup, QSpinBox
)
from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt
import edge_tts
import database
from tts_utils import parse_text_for_edgetts, get_configured_tts_rate
class TextEditorDialog(QDialog):
"""A pop-up modal containing a large text field workspace for copy-pasting extra text blocks or drafting HTML content."""
def __init__(self, title, initial_text="", parent=None):
super().__init__(parent)
self.setWindowTitle(title)
self.resize(650, 450)
layout = QVBoxLayout(self)
info_label = QLabel("Edit text or raw HTML below (supports tables, lists, and inline styles):")
info_label.setStyleSheet("color: #7F8C8D; font-size: 12px;")
layout.addWidget(info_label)
self.editor = QTextEdit()
self.editor.setPlainText(initial_text)
# Monospaced font for clean HTML readability
self.editor.setStyleSheet("font-family: monospace; font-size: 13px; background-color: #2C3E50; color: #ECF0F1; padding: 8px;")
layout.addWidget(self.editor)
btn_layout = QHBoxLayout()
self.btn_save = QPushButton("Save / Apply")
self.btn_save.setStyleSheet("background-color: #27AE60; color: white; font-weight: bold; padding: 6px 14px;")
self.btn_save.clicked.connect(self.accept)
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setStyleSheet("padding: 6px 14px;")
self.btn_cancel.clicked.connect(self.reject)
btn_layout.addStretch()
btn_layout.addWidget(self.btn_cancel)
btn_layout.addWidget(self.btn_save)
layout.addLayout(btn_layout)
def get_text(self):
return self.editor.toPlainText().strip()
class SandboxTab(QWidget):
data_mutated = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.selected_translation_id = None
# Local item memory caching for instant search lookups
self.cached_records = []
self.current_notes_content = ""
self.current_anki_notes_content = ""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(30, 20, 30, 20)
main_layout.setSpacing(15)
# --- SECTION 1: FORM INPUT CRADLE ---
form_container = QWidget()
form_layout = QFormLayout(form_container)
form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
form_layout.setSpacing(10)
# --- English Input Row ---
english_widget = QWidget()
english_layout = QHBoxLayout(english_widget)
english_layout.setContentsMargins(0, 0, 0, 0)
english_layout.setSpacing(8)
self.txt_english = QLineEdit()
self.txt_english.setPlaceholderText("Enter English phrase (filters grid real-time)...")
self.txt_english.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_english.textChanged.connect(self.apply_live_grid_filter)
self.btn_edit_english = QPushButton("✏️ Edit English Block")
self.btn_edit_english.setStyleSheet("padding: 6px 12px; font-weight: bold; background-color: #34495E; color: white; border-radius: 4px;")
self.btn_edit_english.clicked.connect(self.open_english_editor)
english_layout.addWidget(self.txt_english, stretch=1)
english_layout.addWidget(self.btn_edit_english, stretch=0)
# --- Spanish Input Row ---
spanish_widget = QWidget()
spanish_layout = QHBoxLayout(spanish_widget)
spanish_layout.setContentsMargins(0, 0, 0, 0)
spanish_layout.setSpacing(8)
self.txt_spanish = QLineEdit()
self.txt_spanish.setPlaceholderText("Enter Spanish phrase (filters grid real-time)...")
self.txt_spanish.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_spanish.textChanged.connect(self.apply_live_grid_filter)
self.btn_edit_spanish = QPushButton("✏️ Edit Spanish Block")
self.btn_edit_spanish.setStyleSheet("padding: 6px 12px; font-weight: bold; background-color: #34495E; color: white; border-radius: 4px;")
self.btn_edit_spanish.clicked.connect(self.open_spanish_editor)
spanish_layout.addWidget(self.txt_spanish, stretch=1)
spanish_layout.addWidget(self.btn_edit_spanish, stretch=0)
self.txt_context = QLineEdit()
self.txt_context.setPlaceholderText("Context e.g., Camino 2027 (filters grid real-time)...")
self.txt_context.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_context.textChanged.connect(self.apply_live_grid_filter)
self.txt_tags = QLineEdit()
self.txt_tags.setPlaceholderText("Comma separated tags (filters grid real-time)...")
self.txt_tags.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_tags.textChanged.connect(self.apply_live_grid_filter)
# --- Sort Order Field (Added after Tags) ---
self.spn_sort_order = QSpinBox()
self.spn_sort_order.setRange(0, 999999)
self.spn_sort_order.setValue(0)
self.spn_sort_order.setStyleSheet("padding: 6px; font-size: 14px;")
# Modal Editor Row Buttons for Extended Notes
editor_buttons_layout = QHBoxLayout()
self.btn_edit_notes = QPushButton("📝 Edit Notes Block")
self.btn_edit_notes.clicked.connect(self.open_notes_editor)
self.btn_edit_anki_notes = QPushButton("🗂️ Edit Anki Notes Block")
self.btn_edit_anki_notes.clicked.connect(self.open_anki_notes_editor)
editor_buttons_layout.addWidget(self.btn_edit_notes)
editor_buttons_layout.addWidget(self.btn_edit_anki_notes)
# Add container widgets to QFormLayout rows
form_layout.addRow(QLabel("<b>English Phrase:</b>"), english_widget)
form_layout.addRow(QLabel("<b>Spanish Translation:</b>"), spanish_widget)
form_layout.addRow(QLabel("<b>Source Context:</b>"), self.txt_context)
form_layout.addRow(QLabel("<b>Tags:</b>"), self.txt_tags)
form_layout.addRow(QLabel("<b>Sort Order:</b>"), self.spn_sort_order)
form_layout.addRow(QLabel("<b>Extended Data Fields:</b>"), editor_buttons_layout)
main_layout.addWidget(form_container)
# --- INLINE AUDIO CONTROL + GENDER SELECTION PANEL ---
audio_panel = QHBoxLayout()
audio_panel.setSpacing(15)
self.btn_test_en = QPushButton("🔊 Test English Voice")
self.btn_test_en.clicked.connect(self.audition_english)
self.btn_test_es = QPushButton("🔊 Test Spanish Voice")
self.btn_test_es.clicked.connect(self.audition_spanish)
gender_label = QLabel("<b>Speaker Gender:</b>")
self.rb_female = QRadioButton("Female")
self.rb_male = QRadioButton("Male")
self.rb_female.setChecked(True)
self.gender_group = QButtonGroup(self)
self.gender_group.addButton(self.rb_female)
self.gender_group.addButton(self.rb_male)
audio_panel.addWidget(self.btn_test_en)
audio_panel.addWidget(self.btn_test_es)
audio_panel.addSpacing(20)
audio_panel.addWidget(gender_label)
audio_panel.addWidget(self.rb_female)
audio_panel.addWidget(self.rb_male)
audio_panel.addStretch()
main_layout.addLayout(audio_panel)
# --- ACTION CONTROL BAR ---
actions_layout = QHBoxLayout()
self.btn_save_record = QPushButton("📥 Save Transaction")
self.btn_save_record.clicked.connect(self.commit_form_entry)
self.btn_save_record.setStyleSheet("background-color: #27AE60; color: white; font-weight: bold; padding: 8px 16px;")
self.btn_clear_form = QPushButton("🧹 Reset Fields")
self.btn_clear_form.clicked.connect(self.clear_form_fields)
self.btn_delete_record = QPushButton("🗑️ Delete Selected")
self.btn_delete_record.clicked.connect(self.remove_target_record)
self.btn_delete_record.setStyleSheet("background-color: #C0392B; color: white;")
actions_layout.addWidget(self.btn_save_record)
actions_layout.addWidget(self.btn_clear_form)
actions_layout.addWidget(self.btn_delete_record)
actions_layout.addStretch()
main_layout.addLayout(actions_layout)
# --- VIEWPORT GRID TABLE ---
self.table = QTableWidget()
self.table.setColumnCount(7)
self.table.setHorizontalHeaderLabels(["ID", "English", "Spanish", "Context", "Tags", "Sort Order", "Gender"])
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.cellClicked.connect(self.populate_form_from_grid)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
main_layout.addWidget(self.table)
self.reload_table_display()
@pyqtSlot()
def open_english_editor(self):
dlg = TextEditorDialog("Edit English Phrase / HTML Block", self.txt_english.text(), self)
if dlg.exec():
self.txt_english.setText(dlg.get_text())
@pyqtSlot()
def open_spanish_editor(self):
dlg = TextEditorDialog("Edit Spanish Translation / HTML Block", self.txt_spanish.text(), self)
if dlg.exec():
self.txt_spanish.setText(dlg.get_text())
@pyqtSlot()
def open_notes_editor(self):
dlg = TextEditorDialog("Edit Grammar / Core Notes Block", self.current_notes_content, self)
if dlg.exec():
self.current_notes_content = dlg.get_text()
@pyqtSlot()
def open_anki_notes_editor(self):
dlg = TextEditorDialog("Edit Anki Specialized Meta Field", self.current_anki_notes_content, self)
if dlg.exec():
self.current_anki_notes_content = dlg.get_text()
def clear_form_fields(self):
self.txt_english.blockSignals(True)
self.txt_spanish.blockSignals(True)
self.txt_context.blockSignals(True)
self.txt_tags.blockSignals(True)
self.selected_translation_id = None
self.txt_english.clear()
self.txt_spanish.clear()
self.txt_context.clear()
self.txt_tags.clear()
self.spn_sort_order.setValue(0)
self.current_notes_content = ""
self.current_anki_notes_content = ""
self.rb_female.setChecked(True)
self.txt_english.blockSignals(False)
self.txt_spanish.blockSignals(False)
self.txt_context.blockSignals(False)
self.txt_tags.blockSignals(False)
self.apply_live_grid_filter()
@pyqtSlot()
def commit_form_entry(self):
en_t = self.txt_english.text().strip()
es_t = self.txt_spanish.text().strip()
ctx_t = self.txt_context.text().strip()
tag_t = self.txt_tags.text().strip()
sort_val = self.spn_sort_order.value()
gender_t = "Male" if self.rb_male.isChecked() else "Female"
if not en_t or not es_t:
QMessageBox.warning(self, "Validation Alert", "English and Spanish phrase properties cannot remain blank.")
return
if self.selected_translation_id is None:
database.insert_translation_record(
es_t, en_t, ctx_t, tag_t,
self.current_notes_content, gender_t,
self.current_anki_notes_content, sort_val
)
else:
database.update_translation_record(
self.selected_translation_id, es_t, en_t, ctx_t, tag_t,
self.current_notes_content, gender_t,
self.current_anki_notes_content, sort_val
)
self.clear_form_fields()
self.reload_table_display()
self.data_mutated.emit()
@pyqtSlot()
def remove_target_record(self):
if self.selected_translation_id is None:
QMessageBox.warning(self, "Selection Missing", "Please select a row from the grid viewport before attempting deletion.")
return
confirm = QMessageBox.question(
self,
"Confirm Deletion",
"Are you sure you want to permanently delete this translation record?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm == QMessageBox.StandardButton.Yes:
database.delete_translation_record(self.selected_translation_id)
self.clear_form_fields()
self.reload_table_display()
self.data_mutated.emit()
def populate_form_from_grid(self, row, col):
self.selected_translation_id = int(self.table.item(row, 0).text())
record = database.get_translation_by_id(self.selected_translation_id)
if record:
self.txt_english.blockSignals(True)
self.txt_spanish.blockSignals(True)
self.txt_context.blockSignals(True)
self.txt_tags.blockSignals(True)
self.txt_english.setText(record["en_text"])
self.txt_spanish.setText(record["es_text"])
self.txt_context.setText(record.get("source_context", ""))
self.txt_tags.setText(record.get("tags", ""))
self.spn_sort_order.setValue(record.get("sort_order", 0))
self.current_notes_content = record.get("notes", "")
self.current_anki_notes_content = record.get("anki_notes", "")
if record.get("gender") == "Male":
self.rb_male.setChecked(True)
else:
self.rb_female.setChecked(True)
self.txt_english.blockSignals(False)
self.txt_spanish.blockSignals(False)
self.txt_context.blockSignals(False)
self.txt_tags.blockSignals(False)
def reload_table_display(self):
self.cached_records = database.get_all_translations_explicit()
self.apply_live_grid_filter()
@pyqtSlot()
def apply_live_grid_filter(self):
filter_en = self.txt_english.text().lower().strip()
filter_es = self.txt_spanish.text().lower().strip()
filter_ctx = self.txt_context.text().lower().strip()
filter_tags = self.txt_tags.text().lower().strip()
self.table.setRowCount(0)
visible_row_idx = 0
for r in self.cached_records:
match_en = filter_en in (r.get("en_text") or "").lower()
match_es = filter_es in (r.get("es_text") or "").lower()
match_ctx = filter_ctx in (r.get("source_context") or "").lower()
match_tags = filter_tags in (r.get("tags") or "").lower()
if match_en and match_es and match_ctx and match_tags:
self.table.insertRow(visible_row_idx)
self.table.setItem(visible_row_idx, 0, QTableWidgetItem(str(r["translation_id"])))
self.table.setItem(visible_row_idx, 1, QTableWidgetItem(r["en_text"]))
self.table.setItem(visible_row_idx, 2, QTableWidgetItem(r["es_text"]))
self.table.setItem(visible_row_idx, 3, QTableWidgetItem(r.get("source_context", "")))
self.table.setItem(visible_row_idx, 4, QTableWidgetItem(r.get("tags", "")))
self.table.setItem(visible_row_idx, 5, QTableWidgetItem(str(r.get("sort_order", 0))))
self.table.setItem(visible_row_idx, 6, QTableWidgetItem(r.get("gender", "Female")))
visible_row_idx += 1
def _async_edge_speech_worker(self, text, voice, rate_modifier):
"""Background thread worker to download neural audio and play it without freezing the UI."""
async def stream_audio():
temp_file = os.path.join(tempfile.gettempdir(), "sandbox_audition.mp3")
try:
communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier)
await communicate.save(temp_file)
if os.path.exists(temp_file):
subprocess.run(["afplay", temp_file])
except Exception as e:
print(f"Sandbox Audition Error: {e}")
asyncio.run(stream_audio())
@pyqtSlot()
def audition_english(self):
raw_txt = self.txt_english.text().strip()
spoken_text = parse_text_for_edgetts(raw_txt)
if not spoken_text.strip():
return
settings = database.load_all_settings() or {}
rate_string = get_configured_tts_rate(settings)
threading.Thread(
target=self._async_edge_speech_worker,
args=(spoken_text, "en-US-EmmaNeural", rate_string),
daemon=True
).start()
@pyqtSlot()
def audition_spanish(self):
raw_txt = self.txt_spanish.text().strip()
spoken_text = parse_text_for_edgetts(raw_txt)
if not spoken_text.strip():
return
voice = "es-ES-AlvaroNeural" if self.rb_male.isChecked() else "es-ES-ElviraNeural"
settings = database.load_all_settings() or {}
rate_string = get_configured_tts_rate(settings)
threading.Thread(
target=self._async_edge_speech_worker,
args=(spoken_text, voice, rate_string),
daemon=True
).start()