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

374 lines
No EOL
16 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
)
from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt
import edge_tts
import database
class TextEditorDialog(QDialog):
"""A pop-up modal containing a large text field workspace for copy-pasting extra text blocks."""
def __init__(self, title, initial_text="", parent=None):
super().__init__(parent)
self.setWindowTitle(title)
self.resize(500, 350)
layout = QVBoxLayout(self)
self.editor = QTextEdit()
self.editor.setPlainText(initial_text)
self.editor.setStyleSheet("font-family: Arial; font-size: 14px; padding: 5px;")
layout.addWidget(self.editor)
btn_layout = QHBoxLayout()
self.btn_save = QPushButton("Save / Apply")
self.btn_save.clicked.connect(self.accept)
self.btn_cancel = QPushButton("Cancel")
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)
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.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.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)
# Modal Editor Row Buttons
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)
form_layout.addRow(QLabel("<b>English Phrase:</b>"), self.txt_english)
form_layout.addRow(QLabel("<b>Spanish Translation:</b>"), self.txt_spanish)
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>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(6)
self.table.setHorizontalHeaderLabels(["ID", "English", "Spanish", "Context", "Tags", "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)
main_layout.addWidget(self.table)
self.reload_table_display()
@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()
# print(f"[DEBUG DIALOG CLOSE] Notes Block updated in memory: {self.current_notes_content}")
@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()
#print(f"[DEBUG DIALOG CLOSE] Anki Notes Block updated in memory: {self.current_anki_notes_content}")
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.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()
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
print(f"\n[DEBUG DATABASE WRITE]")
print(f" ID Selected: {self.selected_translation_id}")
print(f" English: {en_t}")
print(f" Spanish: {es_t}")
print(f" Context: {ctx_t}")
print(f" Tags: {tag_t}")
print(f" Notes: {self.current_notes_content}")
print(f" Anki Notes: {self.current_anki_notes_content}")
print(f" Gender: {gender_t}\n")
# Fixed argument sequence to match Beekeeper schema (gender position 7, anki_notes position 8)
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)
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)
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.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(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):
txt = self.txt_english.text().strip()
if not txt:
return
settings = database.load_all_settings() or {}
# Fixed: Changed lookup from "playback_speed_multiplier" to "tts_playback_speed"
config_speed = settings.get("tts_playback_speed", "1.0")
try:
pct = int((float(config_speed) - 1.0) * 100)
rate_string = f"{'+' if pct >= 0 else ''}{pct}%"
except Exception:
rate_string = "+0%"
threading.Thread(
target=self._async_edge_speech_worker,
args=(txt, "en-US-EmmaNeural", rate_string),
daemon=True
).start()
@pyqtSlot()
def audition_spanish(self):
txt = self.txt_spanish.text().strip()
if not txt:
return
# Dynamically switch between neural voices depending on the active form state radio selection
voice = "es-ES-AlvaroNeural" if self.rb_male.isChecked() else "es-ES-ElviraNeural"
settings = database.load_all_settings() or {}
config_speed = settings.get("tts_playback_speed", "1.0")
try:
pct = int((float(config_speed) - 1.0) * 100)
print(f"[DEBUG Sandbox Speech] config_speed (Raw String): {config_speed}")
print(f"[DEBUG Sandbox Speech] Calculated pct (Integer): {pct}")
rate_string = f"{'+' if pct >= 0 else ''}{pct}%"
except Exception:
print(f"[DEBUG Sandbox Speech] Failed to parse speed calculation")
rate_string = "+0%"
threading.Thread(
target=self._async_edge_speech_worker,
args=(txt, voice, rate_string),
daemon=True
).start()