Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70d9e012f6 | |||
| 3caa9f52cf | |||
| c6c2038d0f | |||
| 46e6a84c52 | |||
| 750365aa79 | |||
| 61acfe74d7 | |||
| 1cd6f62adc | |||
| 2390a6c738 | |||
| 23b4228b58 | |||
| aa614391b8 | |||
| dbb9449efa | |||
| c302e74c20 | |||
| 89e93dd467 | |||
| b5567bf498 | |||
| b61d72740b | |||
| 7203f0001c | |||
| e2a1991a59 |
21 changed files with 1588 additions and 2217 deletions
130
anki_exporter.py
Normal file
130
anki_exporter.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# anki_exporter.py
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import asyncio
|
||||||
|
import genanki
|
||||||
|
import edge_tts
|
||||||
|
import shutil
|
||||||
|
import database
|
||||||
|
from tts_utils import parse_text_for_edgetts, get_configured_tts_rate
|
||||||
|
|
||||||
|
async def generate_edge_audio(text, voice, output_path, rate_modifier="+0%"):
|
||||||
|
"""Asynchronously streams data packages via the Microsoft Edge API pipeline."""
|
||||||
|
try:
|
||||||
|
communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier)
|
||||||
|
await communicate.save(output_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Edge-TTS synthesis anomaly: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def compile_anki_package(records, output_path, deck_name):
|
||||||
|
"""
|
||||||
|
Compiles database records into a bidirectional card payload package.
|
||||||
|
Resolves voice models dynamically by gender selection parameters and applies
|
||||||
|
global speed coefficient rates from the active configurations.
|
||||||
|
"""
|
||||||
|
# Incremented IDs to force a fresh schema mapping without legacy 'Notes' fields
|
||||||
|
model_id = 1684329060
|
||||||
|
deck_id = 1684329060
|
||||||
|
|
||||||
|
# Global Configuration Pace Resolver Mapping
|
||||||
|
settings = database.load_all_settings() or {}
|
||||||
|
rate_string = get_configured_tts_rate(settings)
|
||||||
|
|
||||||
|
anki_model = genanki.Model(
|
||||||
|
model_id,
|
||||||
|
'Spanish Bidirectional Multi-Note HTML Model',
|
||||||
|
fields=[
|
||||||
|
{'name': 'EnglishText'},
|
||||||
|
{'name': 'SpanishText'},
|
||||||
|
{'name': 'AnkiNotes'},
|
||||||
|
{'name': 'EnglishAudio'},
|
||||||
|
{'name': 'SpanishAudio'}
|
||||||
|
],
|
||||||
|
templates=[
|
||||||
|
{
|
||||||
|
'name': 'Card 1: English ➔ Spanish',
|
||||||
|
'qfmt': '<div style="font-family: Arial; font-size: 13px; font-weight: bold; color: #BDC3C7; text-align: center; letter-spacing: 1px;">TRANSLATE TO SPANISH:</div><br>'
|
||||||
|
'<div style="font-family: Arial; font-size: 20px; text-align: center; color: #34495E;"><b>{{EnglishText}}</b></div>'
|
||||||
|
'<div style="display:none;">{{EnglishAudio}}</div>',
|
||||||
|
'afmt': '{{FrontSide}}<hr id="answer">'
|
||||||
|
'<div style="font-family: Arial; font-size: 28px; text-align: center; color: #2980B9; font-weight: bold;">{{SpanishText}}</div><br>'
|
||||||
|
'{{#AnkiNotes}}<div style="font-family: Arial; font-size: 13px; text-align: center; color: #8E44AD; border-top: 1px dashed #E5E7E9; padding-top: 6px; margin-top: 6px;"><b>Anki Meta:</b> {{{AnkiNotes}}}</div>{{/AnkiNotes}}<br>'
|
||||||
|
'<div style="text-align: center;">{{SpanishAudio}}</div>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Card 2: Spanish ➔ English',
|
||||||
|
'qfmt': '<div style="font-family: Arial; font-size: 13px; font-weight: bold; color: #E67E22; text-align: center; letter-spacing: 1px;">TRANSLATE TO ENGLISH:</div><br>'
|
||||||
|
'<div style="font-family: Arial; font-size: 26px; text-align: center; color: #2980B9; font-weight: bold;"><b>{{SpanishText}}</b></div>'
|
||||||
|
'<div style="display:none;">{{SpanishAudio}}</div>',
|
||||||
|
'afmt': '{{FrontSide}}<hr id="answer">'
|
||||||
|
'<div style="font-family: Arial; font-size: 22px; text-align: center; color: #2C3E50; font-weight: 500;">{{EnglishText}}</div><br>'
|
||||||
|
'{{#AnkiNotes}}<div style="font-family: Arial; font-size: 13px; text-align: center; color: #8E44AD; border-top: 1px dashed #E5E7E9; padding-top: 6px; margin-top: 6px;"><b>Anki Meta:</b> {{{AnkiNotes}}}</div>{{/AnkiNotes}}<br>'
|
||||||
|
'<div style="text-align: center;">{{EnglishAudio}}</div>',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
deck = genanki.Deck(deck_id, deck_name)
|
||||||
|
media_files_to_pack = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for idx, record in enumerate(records):
|
||||||
|
en_raw = record["en_text"]
|
||||||
|
es_raw = record["es_text"]
|
||||||
|
gender_flag = record.get("gender", "Female")
|
||||||
|
|
||||||
|
# Sanitize text payloads for TTS engine
|
||||||
|
en_tts_text = parse_text_for_edgetts(en_raw)
|
||||||
|
es_tts_text = parse_text_for_edgetts(es_raw)
|
||||||
|
|
||||||
|
# Map native neural voice files matching gender settings
|
||||||
|
spanish_voice = "es-ES-AlvaroNeural" if gender_flag == "Male" else "es-ES-ElviraNeural"
|
||||||
|
english_voice = "en-US-EmmaNeural"
|
||||||
|
|
||||||
|
raw_tags = record.get("tags") or ""
|
||||||
|
note_tags = [t.strip().replace(" ", "_") for t in raw_tags.split(",") if t.strip()]
|
||||||
|
|
||||||
|
# Safely isolate the raw text/HTML data string down to Anki notes field
|
||||||
|
anki_notes_html = record['anki_notes'].strip() if record.get('anki_notes') else ""
|
||||||
|
|
||||||
|
# Standard Unique Media Filenames
|
||||||
|
en_audio_filename = f"edge_en_{idx}_{model_id}.mp3"
|
||||||
|
es_audio_filename = f"edge_es_{idx}_{model_id}.mp3"
|
||||||
|
|
||||||
|
en_audio_path = os.path.join(tmpdir, en_audio_filename)
|
||||||
|
es_audio_path = os.path.join(tmpdir, es_audio_filename)
|
||||||
|
|
||||||
|
# English Audio Synthesis
|
||||||
|
if en_tts_text.strip() and loop.run_until_complete(generate_edge_audio(en_tts_text, english_voice, en_audio_path, rate_string)):
|
||||||
|
media_files_to_pack.append(en_audio_path)
|
||||||
|
en_audio_field = f"[sound:{en_audio_filename}]"
|
||||||
|
else:
|
||||||
|
en_audio_field = ""
|
||||||
|
|
||||||
|
# Spanish Audio Synthesis
|
||||||
|
if es_tts_text.strip() and loop.run_until_complete(generate_edge_audio(es_tts_text, spanish_voice, es_audio_path, rate_string)):
|
||||||
|
media_files_to_pack.append(es_audio_path)
|
||||||
|
es_audio_field = f"[sound:{es_audio_filename}]"
|
||||||
|
else:
|
||||||
|
es_audio_field = ""
|
||||||
|
|
||||||
|
# Clean sequential matching fields array matching schema mapping above
|
||||||
|
note = genanki.Note(
|
||||||
|
model=anki_model,
|
||||||
|
fields=[en_raw, es_raw, anki_notes_html, en_audio_field, es_audio_field],
|
||||||
|
tags=note_tags
|
||||||
|
)
|
||||||
|
deck.add_note(note)
|
||||||
|
|
||||||
|
# Build Package while media assets are guaranteed contextually active inside tmpdir
|
||||||
|
package = genanki.Package(deck)
|
||||||
|
package.media_files = media_files_to_pack
|
||||||
|
package.write_to_file(output_path)
|
||||||
194
database.py
Normal file
194
database.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# database.py
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_NAME = "spanish_trainer.db"
|
||||||
|
|
||||||
|
def get_connection():
|
||||||
|
"""Returns a connection to the SQLite database with row factory enabled."""
|
||||||
|
conn = sqlite3.connect(DB_NAME)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def ensure_database_populated():
|
||||||
|
"""
|
||||||
|
Creates empty tables using the unified schema if running
|
||||||
|
in a fresh environment without a database file.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
# 1. Unified translations table with dedicated anki_notes and gender tracks
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS translations (
|
||||||
|
translation_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
es_text TEXT NOT NULL,
|
||||||
|
en_text TEXT NOT NULL,
|
||||||
|
source_context TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
anki_notes TEXT DEFAULT '',
|
||||||
|
gender TEXT DEFAULT 'Female'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 2. Key-value configuration table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# SETTINGS CRUD FUNCTIONS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
def load_all_settings():
|
||||||
|
"""Fetches all system configuration properties into a flat Python dictionary."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
settings_dict = {}
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT settings.key, settings.value FROM settings;")
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
settings_dict[row["key"]] = row["value"]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return settings_dict
|
||||||
|
|
||||||
|
def save_setting_to_db(key, value):
|
||||||
|
"""Inserts or replaces an application configuration entry."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO settings (key, value)
|
||||||
|
VALUES (?, ?);
|
||||||
|
""", (key, value))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# TRANSLATIONS CRUD FUNCTIONS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
def get_all_translations_explicit():
|
||||||
|
"""
|
||||||
|
Retrieves all records using completely explicit,
|
||||||
|
table-qualified column declarations for the engines.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
translations.translation_id,
|
||||||
|
translations.es_text,
|
||||||
|
translations.en_text,
|
||||||
|
translations.source_context,
|
||||||
|
translations.tags,
|
||||||
|
translations.notes,
|
||||||
|
translations.anki_notes,
|
||||||
|
translations.gender
|
||||||
|
FROM translations
|
||||||
|
ORDER BY translations.translation_id ASC;
|
||||||
|
""")
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_translation_by_id(translation_id):
|
||||||
|
"""Loads a single unified record row for specific inspection or editing."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
translations.translation_id,
|
||||||
|
translations.es_text,
|
||||||
|
translations.en_text,
|
||||||
|
translations.source_context,
|
||||||
|
translations.tags,
|
||||||
|
translations.notes,
|
||||||
|
translations.anki_notes,
|
||||||
|
translations.gender
|
||||||
|
FROM translations
|
||||||
|
WHERE translations.translation_id = ?;
|
||||||
|
""", (translation_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_translation_record(translation_id, es_text, en_text, source_context, tags, notes, gender, anki_notes):
|
||||||
|
"""Saves sandbox interface edits directly back down into the table using named arguments."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
# The SQL uses :key syntax instead of ?
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE translations
|
||||||
|
SET
|
||||||
|
es_text = :es,
|
||||||
|
en_text = :en,
|
||||||
|
source_context = :ctx,
|
||||||
|
tags = :tags,
|
||||||
|
notes = :notes,
|
||||||
|
gender = :gender,
|
||||||
|
anki_notes = :anki
|
||||||
|
WHERE translation_id = :id;
|
||||||
|
""", {
|
||||||
|
# The order inside this dictionary does not matter at all!
|
||||||
|
"id": translation_id,
|
||||||
|
"es": es_text,
|
||||||
|
"en": en_text,
|
||||||
|
"ctx": source_context,
|
||||||
|
"tags": tags,
|
||||||
|
"notes": notes,
|
||||||
|
"gender": gender,
|
||||||
|
"anki": anki_notes
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_translation_record(translation_id):
|
||||||
|
"""Permanently drops a phrase card row from the data index."""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM translations
|
||||||
|
WHERE translations.translation_id = ?;
|
||||||
|
""", (translation_id,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def insert_translation_record(es_text, en_text, source_context, tags, notes, gender, anki_notes):
|
||||||
|
"""Inserts a new record using named arguments so positional order doesn't matter."""
|
||||||
|
conn = get_connection() # Corrected from get_db_connection
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO translations (es_text, en_text, source_context, tags, notes, gender, anki_notes)
|
||||||
|
VALUES (:es, :en, :ctx, :tags, :notes, :gender, :anki);
|
||||||
|
""", {
|
||||||
|
# SQLite maps these keys directly to the tokens above by name
|
||||||
|
"en": en_text,
|
||||||
|
"es": es_text,
|
||||||
|
"ctx": source_context,
|
||||||
|
"tags": tags,
|
||||||
|
"notes": notes,
|
||||||
|
"anki": anki_notes,
|
||||||
|
"gender": gender
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
1133
doc/Notes.md
1133
doc/Notes.md
File diff suppressed because it is too large
Load diff
BIN
doc/Notes.pdf
BIN
doc/Notes.pdf
Binary file not shown.
BIN
doc/images/image-02.png
Normal file
BIN
doc/images/image-02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
doc/images/image-03.png
Normal file
BIN
doc/images/image-03.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
114
migrate_database.py
Normal file
114
migrate_database.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# migrate_database.py
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
OLD_DB = "spanish_trainer_legacy.db" # Your existing database renamed
|
||||||
|
NEW_DB = "spanish_trainer.db" # The fresh, simplified target database
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
if not os.path.exists(OLD_DB):
|
||||||
|
print(f"❌ Error: Could not find legacy database file named '{OLD_DB}'")
|
||||||
|
print("Please rename your active database file to match before running this script.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("🚀 Initializing schema transformation...")
|
||||||
|
|
||||||
|
# Connect to both databases
|
||||||
|
conn_old = sqlite3.connect(OLD_DB)
|
||||||
|
conn_old.row_factory = sqlite3.Row
|
||||||
|
cursor_old = conn_old.cursor()
|
||||||
|
|
||||||
|
conn_new = sqlite3.connect(NEW_DB)
|
||||||
|
cursor_new = conn_new.cursor()
|
||||||
|
|
||||||
|
# 1. Provision the clean, simplified new tables
|
||||||
|
cursor_new.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS translations (
|
||||||
|
translation_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
es_text TEXT NOT NULL,
|
||||||
|
en_text TEXT NOT NULL,
|
||||||
|
source_context TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor_new.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 2. Extract and pair data using explicit, table-qualified SQL queries
|
||||||
|
print("📦 Extracting and consolidating relational text rows...")
|
||||||
|
migration_query = """
|
||||||
|
SELECT
|
||||||
|
t.translation_id,
|
||||||
|
p1.text AS spanish_phrase,
|
||||||
|
p2.text AS english_translation,
|
||||||
|
p1.source_context AS textbook_unit,
|
||||||
|
t.tags AS metadata_tags,
|
||||||
|
t.notes AS historical_notes
|
||||||
|
FROM translations t
|
||||||
|
JOIN phrases p1 ON t.source_phrase_id = p1.id
|
||||||
|
JOIN phrases p2 ON t.target_phrase_id = p2.id
|
||||||
|
WHERE p1.language = 'es'
|
||||||
|
AND p2.language = 'en'
|
||||||
|
ORDER BY t.translation_id ASC;
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor_old.execute(migration_query)
|
||||||
|
legacy_records = cursor_old.fetchall()
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"❌ Legacy structure lookup failed: {e}")
|
||||||
|
print("Verify your old table structures match the schema before running.")
|
||||||
|
conn_old.close()
|
||||||
|
conn_new.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Insert records into the new simplified table structure
|
||||||
|
inserted_count = 0
|
||||||
|
for row in legacy_records:
|
||||||
|
cursor_new.execute("""
|
||||||
|
INSERT INTO translations (
|
||||||
|
translation_id,
|
||||||
|
es_text,
|
||||||
|
en_text,
|
||||||
|
source_context,
|
||||||
|
tags,
|
||||||
|
notes
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?);
|
||||||
|
""", (
|
||||||
|
row["translation_id"],
|
||||||
|
row["spanish_phrase"],
|
||||||
|
row["english_translation"],
|
||||||
|
row["textbook_unit"],
|
||||||
|
row["metadata_tags"],
|
||||||
|
row["historical_notes"]
|
||||||
|
))
|
||||||
|
inserted_count += 1
|
||||||
|
|
||||||
|
# 4. Copy existing system configuration keys over safely
|
||||||
|
try:
|
||||||
|
cursor_old.execute("SELECT key, value FROM settings;")
|
||||||
|
settings_records = cursor_old.fetchall()
|
||||||
|
for setting in settings_records:
|
||||||
|
cursor_new.execute("""
|
||||||
|
INSERT OR REPLACE INTO settings (key, value)
|
||||||
|
VALUES (?, ?);
|
||||||
|
""", (setting["key"], setting["value"]))
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
print("⚠️ Warning: No legacy settings table found or could not read it. Skipping settings copy.")
|
||||||
|
|
||||||
|
# Commit changes and clean up connections
|
||||||
|
conn_new.commit()
|
||||||
|
conn_old.close()
|
||||||
|
conn_new.close()
|
||||||
|
|
||||||
|
print(f"✨ Migration complete! Successfully converted {inserted_count} text rows.")
|
||||||
|
print(f"💾 Fresh database engine ready at: {NEW_DB}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
Binary file not shown.
BIN
spanish_trainer_legacy.db
Normal file
BIN
spanish_trainer_legacy.db
Normal file
Binary file not shown.
5
tabs/__init__.py
Normal file
5
tabs/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# tabs/__init__.py
|
||||||
|
# Leave this file empty, or just expose the tabs like this:
|
||||||
|
from .sandbox_tab import SandboxTab
|
||||||
|
from .review_tab import ReviewTab
|
||||||
|
from .settings_tab import SettingsTab
|
||||||
400
tabs/review_tab.py
Normal file
400
tabs/review_tab.py
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
# tabs/review_tab.py
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import asyncio
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
|
||||||
|
QHeaderView, QFormLayout, QMessageBox, QFrame, QStackedWidget
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, pyqtSlot
|
||||||
|
import edge_tts
|
||||||
|
import database
|
||||||
|
import anki_exporter
|
||||||
|
from tts_utils import parse_text_for_edgetts, get_configured_tts_rate
|
||||||
|
|
||||||
|
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(2)
|
||||||
|
self.table.setHorizontalHeaderLabels(["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.Stretch)
|
||||||
|
header.setSectionResizeMode(1, 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 generates a randomized matching queue 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 = []
|
||||||
|
visible_row_index = 0
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.table.insertRow(visible_row_index)
|
||||||
|
self.table.setItem(visible_row_index, 0, QTableWidgetItem(row["en_text"]))
|
||||||
|
self.table.setItem(visible_row_index, 1, QTableWidgetItem(row["es_text"]))
|
||||||
|
visible_row_index += 1
|
||||||
|
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
|
||||||
|
# Reshuffle the active localized queue stack and reset card state tracking pointer
|
||||||
|
random.shuffle(self.filtered_review_pool)
|
||||||
|
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 or increments indices sequential steps."""
|
||||||
|
if not self.filtered_review_pool:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.is_flipped:
|
||||||
|
# Transition State: Front -> Back
|
||||||
|
self.is_flipped = True
|
||||||
|
self.display_current_card()
|
||||||
|
else:
|
||||||
|
# Transition State: Advance to next index item row
|
||||||
|
self.current_index += 1
|
||||||
|
if self.current_index >= len(self.filtered_review_pool):
|
||||||
|
self.current_index = 0
|
||||||
|
random.shuffle(self.filtered_review_pool) # Rescramble on completion pass loops
|
||||||
|
|
||||||
|
self.is_flipped = False
|
||||||
|
self.display_current_card()
|
||||||
|
|
||||||
|
def _async_edge_speech_worker(self, text, voice, rate_modifier):
|
||||||
|
"""Background thread worker to render neural speech with terminal debug logging."""
|
||||||
|
async def stream_audio():
|
||||||
|
temp_file = os.path.join(tempfile.gettempdir(), "review_card_audio.mp3")
|
||||||
|
|
||||||
|
# Clean up old file if present
|
||||||
|
if os.path.exists(temp_file):
|
||||||
|
try:
|
||||||
|
os.remove(temp_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"[TTS Debug] Generating TTS -> Voice: {voice} | Rate: {rate_modifier} | Text: '{text}'")
|
||||||
|
communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier)
|
||||||
|
await communicate.save(temp_file)
|
||||||
|
|
||||||
|
if os.path.exists(temp_file) and os.path.getsize(temp_file) > 0:
|
||||||
|
print(f"[TTS Debug] Audio ready ({os.path.getsize(temp_file)} bytes). Playing via afplay...")
|
||||||
|
result = subprocess.run(["afplay", temp_file], capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[TTS Debug] afplay failed: {result.stderr}")
|
||||||
|
else:
|
||||||
|
print("[TTS Debug] Playback finished successfully.")
|
||||||
|
else:
|
||||||
|
print("[TTS Debug] Error: Audio file was not created or is 0 bytes.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[TTS Debug] Exception during speech synthesis: {e}")
|
||||||
|
|
||||||
|
# Explicitly set up and run a clean event loop for this thread
|
||||||
|
try:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
loop.run_until_complete(stream_audio())
|
||||||
|
loop.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[TTS Debug] Event loop error: {e}")
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def play_card_audio(self):
|
||||||
|
"""Auditions neural edge-tts voice 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 {}
|
||||||
|
rate_string = get_configured_tts_rate(settings)
|
||||||
|
|
||||||
|
if not self.is_flipped:
|
||||||
|
raw_text = record.get("en_text", "")
|
||||||
|
spoken_text = parse_text_for_edgetts(raw_text)
|
||||||
|
voice = "en-US-EmmaNeural"
|
||||||
|
else:
|
||||||
|
raw_text = record.get("es_text", "")
|
||||||
|
spoken_text = parse_text_for_edgetts(raw_text)
|
||||||
|
is_male = (record.get("gender") == "Male")
|
||||||
|
voice = "es-ES-AlvaroNeural" if is_male else "es-ES-ElviraNeural"
|
||||||
|
|
||||||
|
# Respect <meta sound-off> flags or empty entries
|
||||||
|
if not spoken_text.strip():
|
||||||
|
print("[TTS Debug] Skipped: Parsed text is empty or muted via sound-off tag.")
|
||||||
|
return
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=self._async_edge_speech_worker,
|
||||||
|
args=(spoken_text, voice, rate_string),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def generate_deck_action(self):
|
||||||
|
"""Generates a specialized lightweight .apkg Anki deck matching active filter parameters,
|
||||||
|
respecting exact user database configuration keys for target folders and naming chains."""
|
||||||
|
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:
|
||||||
|
# Load active settings dictionary directly from your database configurations
|
||||||
|
settings = database.load_all_settings() or {}
|
||||||
|
|
||||||
|
# Extract configurations targeting exact database schema names found in settings
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Fallback handling to verify directories exist safely
|
||||||
|
if not target_dir or not os.path.isdir(str(target_dir)):
|
||||||
|
target_dir = os.path.expanduser("~/Desktop")
|
||||||
|
else:
|
||||||
|
target_dir = str(target_dir)
|
||||||
|
|
||||||
|
# --- Compile Full Namespace Tree Path ---
|
||||||
|
deck_tree_parts = []
|
||||||
|
|
||||||
|
if root_deck_name and str(root_deck_name).strip():
|
||||||
|
deck_tree_parts.append(str(root_deck_name).strip())
|
||||||
|
else:
|
||||||
|
deck_tree_parts.append("DefaultDeck") # Baseline structural root name fallback
|
||||||
|
|
||||||
|
if sub_deck_hierarchy and str(sub_deck_hierarchy).strip():
|
||||||
|
deck_tree_parts.append(str(sub_deck_hierarchy).strip())
|
||||||
|
|
||||||
|
# Join parts using Anki double-colon syntax (::)
|
||||||
|
full_deck_namespace = "::".join(deck_tree_parts)
|
||||||
|
|
||||||
|
# Establish absolute output filename file path anchor
|
||||||
|
filename = "Spanish_Filtered_Review.apkg"
|
||||||
|
file_path = os.path.join(target_dir, filename)
|
||||||
|
|
||||||
|
# Execute actual compilation algorithm pipeline mapping filtered records cleanly
|
||||||
|
anki_exporter.compile_anki_package(self.filtered_review_pool, file_path, full_deck_namespace)
|
||||||
|
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Export Complete",
|
||||||
|
f"Successfully exported Anki package to your configured target directory!\n\n"
|
||||||
|
f"<b>Full Namespace Tree:</b> {full_deck_namespace}\n"
|
||||||
|
f"<b>Destination 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."
|
||||||
|
)
|
||||||
396
tabs/sandbox_tab.py
Normal file
396
tabs/sandbox_tab.py
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
# 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
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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>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_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.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
|
||||||
|
|
||||||
|
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):
|
||||||
|
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()
|
||||||
145
tabs/settings_tab.py
Normal file
145
tabs/settings_tab.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# tabs/settings_tab.py
|
||||||
|
import os
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
|
QPushButton, QFileDialog, QGroupBox, QFormLayout,
|
||||||
|
QMessageBox, QFrame
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import pyqtSignal, pyqtSlot
|
||||||
|
import database
|
||||||
|
|
||||||
|
class SettingsTab(QWidget):
|
||||||
|
# Signals to communicate up to the centralized main.py loop coordinator
|
||||||
|
settings_changed = pyqtSignal(str, str) # Emits: (key, value)
|
||||||
|
export_anki_requested = pyqtSignal(str) # Emits: (full_deck_name)
|
||||||
|
generate_video_requested = pyqtSignal() # Emits: trigger
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
main_layout.setSpacing(15)
|
||||||
|
|
||||||
|
# --- SECTION 1: GLOBAL ANKI PACKAGING CONFIGURATIONS ---
|
||||||
|
anki_group = QGroupBox("Anki Compilation Settings")
|
||||||
|
anki_form = QFormLayout(anki_group)
|
||||||
|
anki_form.setSpacing(10)
|
||||||
|
|
||||||
|
self.txt_root_deck = QLineEdit()
|
||||||
|
self.txt_root_deck.setPlaceholderText("e.g., Spanish::CAE_Course")
|
||||||
|
self.txt_root_deck.textChanged.connect(lambda text: self.update_setting("anki_root_deck_name", text.strip()))
|
||||||
|
|
||||||
|
self.txt_sub_deck = QLineEdit()
|
||||||
|
self.txt_sub_deck.setPlaceholderText("e.g., Vocabulary::Unit_1")
|
||||||
|
self.txt_sub_deck.textChanged.connect(lambda text: self.update_setting("anki_sub_deck_name", text.strip()))
|
||||||
|
|
||||||
|
# Export Destination Directory Picker
|
||||||
|
dir_picker_layout = QHBoxLayout()
|
||||||
|
self.txt_export_dir = QLineEdit()
|
||||||
|
self.txt_export_dir.setReadOnly(True)
|
||||||
|
self.txt_export_dir.setStyleSheet("background-color: #F8F9F9; color: #34495E;")
|
||||||
|
|
||||||
|
btn_browse = QPushButton("Browse...")
|
||||||
|
btn_browse.clicked.connect(self.browse_export_directory)
|
||||||
|
dir_picker_layout.addWidget(self.txt_export_dir)
|
||||||
|
dir_picker_layout.addWidget(btn_browse)
|
||||||
|
|
||||||
|
anki_form.addRow("Root Deck Name:", self.txt_root_deck)
|
||||||
|
anki_form.addRow("Sub-Deck Namespace Hierarchy:", self.txt_sub_deck)
|
||||||
|
anki_form.addRow("Export Target Directory:", dir_picker_layout)
|
||||||
|
|
||||||
|
main_layout.addWidget(anki_group)
|
||||||
|
|
||||||
|
# --- SECTION 2: AUDIO ENGINE & COMPILATION OVERRIDES ---
|
||||||
|
engine_group = QGroupBox("Voice Synthesis & Training Configuration")
|
||||||
|
engine_form = QFormLayout(engine_group)
|
||||||
|
|
||||||
|
self.txt_tts_voice = QLineEdit()
|
||||||
|
self.txt_tts_voice.setPlaceholderText("Apple_Monica")
|
||||||
|
self.txt_tts_voice.textChanged.connect(lambda text: self.update_setting("tts_preferred_voice", text.strip()))
|
||||||
|
|
||||||
|
self.txt_tts_speed = QLineEdit()
|
||||||
|
self.txt_tts_speed.setPlaceholderText("1.15")
|
||||||
|
self.txt_tts_speed.textChanged.connect(lambda text: self.update_setting("tts_playback_speed", text.strip()))
|
||||||
|
|
||||||
|
engine_form.addRow("Fallback System Voice Name:", self.txt_tts_voice)
|
||||||
|
engine_form.addRow("Target Speech Playback Multiplier:", self.txt_tts_speed)
|
||||||
|
|
||||||
|
main_layout.addWidget(engine_group)
|
||||||
|
|
||||||
|
# Decorative divider line
|
||||||
|
divider = QFrame()
|
||||||
|
divider.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
divider.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
main_layout.addWidget(divider)
|
||||||
|
|
||||||
|
# --- SECTION 3: SYSTEM ACTION EXECUTION BAR ---
|
||||||
|
actions_group = QGroupBox("Execution Pipelines")
|
||||||
|
actions_layout = QHBoxLayout(actions_group)
|
||||||
|
actions_layout.setSpacing(20)
|
||||||
|
|
||||||
|
self.btn_export_anki = QPushButton("🚀 Compile Lightweight Anki APKG")
|
||||||
|
self.btn_export_anki.setStyleSheet("""
|
||||||
|
QPushButton { background-color: #2980B9; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; }
|
||||||
|
QPushButton:hover { background-color: #3498DB; }
|
||||||
|
""")
|
||||||
|
self.btn_export_anki.clicked.connect(self.dispatch_anki_export)
|
||||||
|
|
||||||
|
self.btn_gen_video = QPushButton("🎬 Generate MP4 Loop Playlists")
|
||||||
|
self.btn_gen_video.setStyleSheet("""
|
||||||
|
QPushButton { background-color: #8E44AD; color: white; font-weight: bold; font-size: 13px; padding: 10px; border-radius: 4px; }
|
||||||
|
QPushButton:hover { background-color: #9B59B6; }
|
||||||
|
""")
|
||||||
|
self.btn_gen_video.clicked.connect(self.generate_video_requested.emit)
|
||||||
|
|
||||||
|
actions_layout.addWidget(self.btn_export_anki)
|
||||||
|
actions_layout.addWidget(self.btn_gen_video)
|
||||||
|
|
||||||
|
main_layout.addWidget(actions_group)
|
||||||
|
main_layout.addStretch() # Push everything neatly to the top
|
||||||
|
|
||||||
|
# Load settings from database onto inputs on initialization
|
||||||
|
self.populate_fields_from_db_state()
|
||||||
|
|
||||||
|
def populate_fields_from_db_state(self):
|
||||||
|
"""Fetches stored parameters on view load initialization."""
|
||||||
|
# Block signals briefly so loading state doesn't trigger write-back loops
|
||||||
|
self.blockSignals(True)
|
||||||
|
|
||||||
|
stored_settings = database.load_all_settings()
|
||||||
|
|
||||||
|
self.txt_root_deck.setText(stored_settings.get("anki_root_deck_name", "Spanish"))
|
||||||
|
self.txt_sub_deck.setText(stored_settings.get("anki_sub_deck_name", ""))
|
||||||
|
self.txt_export_dir.setText(stored_settings.get("anki_export_directory", os.path.expanduser("~")))
|
||||||
|
self.txt_tts_voice.setText(stored_settings.get("tts_preferred_voice", "Apple_Monica"))
|
||||||
|
self.txt_tts_speed.setText(stored_settings.get("tts_playback_speed", "1.15"))
|
||||||
|
|
||||||
|
self.blockSignals(False)
|
||||||
|
|
||||||
|
def update_setting(self, key, value):
|
||||||
|
"""Internal helper to communicate state mutations instantly upward."""
|
||||||
|
self.settings_changed.emit(key, value)
|
||||||
|
|
||||||
|
def browse_export_directory(self):
|
||||||
|
"""Invokes a native macOS directory finder path browser window."""
|
||||||
|
current_dir = self.txt_export_dir.text() or os.path.expanduser("~")
|
||||||
|
selected_directory = QFileDialog.getExistingDirectory(
|
||||||
|
self, "Select Anki Export Target Location", current_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_directory:
|
||||||
|
self.txt_export_dir.setText(selected_directory)
|
||||||
|
self.update_setting("anki_export_directory", selected_directory)
|
||||||
|
|
||||||
|
def dispatch_anki_export(self):
|
||||||
|
"""Constructs and validates the structured deck names namespace before signaling main.py."""
|
||||||
|
root = self.txt_root_deck.text().strip()
|
||||||
|
sub = self.txt_sub_deck.text().strip()
|
||||||
|
|
||||||
|
if not root:
|
||||||
|
QMessageBox.warning(self, "Invalid Parameters", "A root deck namespace destination must be provided.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Combine hierarchy into standard Anki format: 'Root::SubDeck'
|
||||||
|
full_deck_name = f"{root}::{sub}" if sub else root
|
||||||
|
self.export_anki_requested.emit(full_deck_name)
|
||||||
77
test_tts.py
77
test_tts.py
|
|
@ -1,28 +1,55 @@
|
||||||
import asyncio
|
# tts_utils.py
|
||||||
import os
|
import re
|
||||||
import edge_tts
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
# 1. Define the phrase, output path, and target Castellano voice
|
|
||||||
SPANISH_TEXT = "¡Buenos días! ¿Cómo estás? Bienvenido a tu curso de español."
|
|
||||||
OUTPUT_FILE = "media/buenos_dias_castellano-Female.mp3"
|
|
||||||
#VOICE = "es-ES-AlvaroNeural" # Swap to "es-ES-ElviraNeural" if you prefer a female tone
|
|
||||||
VOICE = "es-ES-ElviraNeural" # Swap to "es-ES-ElviraNeural" if you prefer a female tone
|
|
||||||
|
|
||||||
async def generate_castilian_audio():
|
def get_configured_tts_rate(settings: dict) -> str:
|
||||||
# Ensure our target media folder exists locally
|
"""Converts a numerical speed multiplier (e.g., 0.75, 1.0, 1.25) from app settings
|
||||||
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
|
into Edge TTS percentage format string (e.g., '-25%', '+0%', '+25%').
|
||||||
|
"""
|
||||||
print(f"🔄 Synthesizing text using Castilian voice: {VOICE}...")
|
# Check the actual database key 'tts_playback_speed' with fallbacks
|
||||||
|
raw_val = (
|
||||||
# 2. Configure the Communicate engine
|
settings.get("tts_playback_speed")
|
||||||
communicate = edge_tts.Communicate(SPANISH_TEXT, VOICE)
|
or settings.get("tts_speed_multiplier")
|
||||||
|
or 1.0
|
||||||
# 3. Stream and write the data packets to disk
|
)
|
||||||
await communicate.save(OUTPUT_FILE)
|
|
||||||
|
|
||||||
print(f"✨ Success! MP3 file exported safely to: {OUTPUT_FILE}")
|
|
||||||
print(f"📂 File size: {os.path.getsize(OUTPUT_FILE)} bytes")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
try:
|
||||||
# Run the async loop loop natively
|
raw_rate = float(raw_val)
|
||||||
asyncio.run(generate_castilian_audio())
|
except (TypeError, ValueError):
|
||||||
|
raw_rate = 1.0
|
||||||
|
|
||||||
|
# Calculate percentage shift relative to baseline 1.0
|
||||||
|
pct = int(round((raw_rate - 1.0) * 100))
|
||||||
|
if pct >= 0:
|
||||||
|
return f"+{pct}%"
|
||||||
|
return f"{pct}%"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_text_for_edgetts(html_content: str) -> str:
|
||||||
|
"""Strips content between <meta sound-off> and <meta sound-on> tags,
|
||||||
|
handling optional whitespace inside the tag brackets (e.g., <meta sound-off >).
|
||||||
|
Converts remaining HTML tags to clean spoken text.
|
||||||
|
"""
|
||||||
|
if not html_content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Flexible regex to slice out everything from <meta sound-off ...> through <meta sound-on ...>
|
||||||
|
pattern = re.compile(
|
||||||
|
r"<meta\s+sound-off\s*\/?>.*?<meta\s+sound-on\s*\/?>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
cleaned_html = re.sub(pattern, "", html_content)
|
||||||
|
|
||||||
|
# Handle unclosed <meta sound-off> (mute rest of string from that point)
|
||||||
|
if re.search(r"<meta\s+sound-off\s*\/?>", cleaned_html, re.IGNORECASE):
|
||||||
|
cleaned_html = re.split(
|
||||||
|
r"<meta\s+sound-off\s*\/?>", cleaned_html, flags=re.IGNORECASE
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# Convert remaining HTML into plain text for speech
|
||||||
|
soup = BeautifulSoup(cleaned_html, "html.parser")
|
||||||
|
text = soup.get_text(separator=" ")
|
||||||
|
|
||||||
|
# Normalize extra whitespace
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
52
tts_utils.py
Normal file
52
tts_utils.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# tts_utils.py
|
||||||
|
import re
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
def get_configured_tts_rate(settings: dict) -> str:
|
||||||
|
"""Extracts speed multiplier from settings dict and converts to Edge TTS percentage string (e.g., '-25%')."""
|
||||||
|
# Query the exact key name shown in SQLite database: 'tts_playback_speed'
|
||||||
|
raw_val = settings.get("tts_playback_speed", 1.0) if settings else 1.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_rate = float(raw_val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raw_rate = 1.0
|
||||||
|
|
||||||
|
# Calculate percentage shift relative to baseline 1.0 (e.g., 0.75 -> -25%)
|
||||||
|
pct = int(round((raw_rate - 1.0) * 100))
|
||||||
|
rate_str = f"+{pct}%" if pct >= 0 else f"{pct}%"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[TTS Debug] DB Key 'tts_playback_speed': {raw_val} -> Formatted Rate: {rate_str}"
|
||||||
|
)
|
||||||
|
return rate_str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_text_for_edgetts(html_content: str) -> str:
|
||||||
|
"""Strips content between <meta sound-off> and <meta sound-on> tags,
|
||||||
|
handling optional whitespace inside the tag brackets (e.g., <meta sound-off >).
|
||||||
|
Converts remaining HTML tags to clean spoken text.
|
||||||
|
"""
|
||||||
|
if not html_content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Flexible regex to slice out everything from <meta sound-off ...> through <meta sound-on ...>
|
||||||
|
pattern = re.compile(
|
||||||
|
r"<meta\s+sound-off\s*\/?>.*?<meta\s+sound-on\s*\/?>",
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
cleaned_html = re.sub(pattern, "", html_content)
|
||||||
|
|
||||||
|
# Handle unclosed <meta sound-off> (mute rest of string from that point)
|
||||||
|
if re.search(r"<meta\s+sound-off\s*\/?>", cleaned_html, re.IGNORECASE):
|
||||||
|
cleaned_html = re.split(
|
||||||
|
r"<meta\s+sound-off\s*\/?>", cleaned_html, flags=re.IGNORECASE
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# Convert remaining HTML into plain text for speech
|
||||||
|
soup = BeautifulSoup(cleaned_html, "html.parser")
|
||||||
|
text = soup.get_text(separator=" ")
|
||||||
|
|
||||||
|
# Normalize extra whitespace
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
0
video_generator.py
Normal file
0
video_generator.py
Normal file
Loading…
Reference in a new issue