Compare commits

..

21 commits

Author SHA1 Message Date
2346bacd5d this version replaced edge-tts with kokoro locally based tts model 2026-10-17 12:13:03 +11:00
4a4d16c093 sort order to Flashcard Review 2026-08-29 11:17:53 +10:00
ed1c6b9d4e sort order added to deck 2026-08-28 10:27:21 +10:00
2c0c63036d fixed anki deck name 2026-08-21 22:24:50 +10:00
70d9e012f6 html added and working 2026-07-22 10:26:25 +10:00
3caa9f52cf fixing HTML and audio 2026-07-21 22:03:22 +10:00
c6c2038d0f removed some prints statements 2026-07-20 21:53:45 +10:00
46e6a84c52 removed Notes,source_context from the Anki cards 2026-07-02 09:46:20 +10:00
750365aa79 before removing Notes,source_context from the Anki cards 2026-07-01 20:24:22 +10:00
61acfe74d7 before removing Notes,source_context from the Anki cards 2026-07-01 20:22:04 +10:00
1cd6f62adc ankinotes working except an } 2026-06-30 17:57:41 +10:00
2390a6c738 added tage 2026-06-29 22:08:39 +10:00
23b4228b58 Change of anki template to get formating in anki to work 2026-06-29 22:07:07 +10:00
aa614391b8 fixed export of decks & removed our notes from cards 2026-06-26 23:06:10 +10:00
dbb9449efa putting the flashcard back into review_tab 2026-06-26 16:20:45 +10:00
c302e74c20 added Source Context and Tags input to review_tab 2026-06-25 23:54:30 +10:00
89e93dd467 added tag to sandbox_tab 2026-06-25 23:02:42 +10:00
b5567bf498 fixed store translation 2026-06-25 22:23:11 +10:00
b61d72740b partically working refactoring version 2026-06-24 15:37:22 +10:00
7203f0001c start refactoring 2026-06-24 13:41:04 +10:00
e2a1991a59 start refactoring 2026-06-22 21:52:00 +10:00
61 changed files with 2251 additions and 2322 deletions

7
.gitignore vendored
View file

@ -5,6 +5,10 @@ __pycache__/
.venv/
.uv/
dist/*
build/*
# Local SQLite Databases
# *.db
# *.db-journal
@ -22,3 +26,6 @@ media/*
.vscode/
.idea/
.env
*.onnx
*.bin

View file

@ -0,0 +1,51 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='Spanish Voice Trainer',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['app.icns'],
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='Spanish Voice Trainer',
)
app = BUNDLE(
coll,
name='Spanish Voice Trainer.app',
icon='app.icns',
bundle_identifier=None,
)

145
anki_exporter.py Normal file
View file

@ -0,0 +1,145 @@
# anki_exporter.py
import os
import tempfile
import time
import genanki
import database
from tts_utils import parse_text_for_edgetts
from kokoro_utils import generate_local_speech
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
speed multipliers from active configurations using local Kokoro TTS.
Injects sort_order as field 0 for structured card sequencing.
"""
# Generate deterministic positive 32-bit integers from deck name and model name
deck_id = abs(hash(deck_name)) % (2**31)
model_id = abs(hash("Spanish Bidirectional Multi-Note HTML Model")) % (2**31)
# Unique timestamp prefix for media files to prevent overwriting prior exports in Anki
run_prefix = int(time.time())
# Global Configuration Pace Resolver Mapping
settings = database.load_all_settings() or {}
try:
speed_multiplier = float(settings.get("tts_playback_speed", 1.0))
except (TypeError, ValueError):
speed_multiplier = 1.0
anki_model = genanki.Model(
model_id,
'Spanish Bidirectional Multi-Note HTML Model',
fields=[
{'name': 'SortOrder'}, # Field 0: Anki primary sort field
{'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><br>'
'<div style="text-align: center;">{{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><br>'
'<div style="text-align: center;">{{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 = []
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")
# Read sort_order from database record (fallback to loop index if unset)
raw_order = record.get("sort_order")
order_val = int(raw_order) if raw_order is not None else (idx + 1)
sort_order_str = f"{order_val:04d}" # Zero-padded integer string for exact sorting
# 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 Kokoro voices matching gender settings
spanish_voice = "em_alex" if gender_flag == "Male" else "ef_dora"
english_voice = "af_bella"
raw_tags = record.get("tags") or ""
note_tags = [t.strip().replace(" ", "_") for t in raw_tags.split(",") if t.strip()]
anki_notes_html = record['anki_notes'].strip() if record.get('anki_notes') else ""
# Unique Media Filenames combining execution timestamp and index (.wav output from soundfile)
en_audio_filename = f"kokoro_en_{run_prefix}_{idx}.wav"
es_audio_filename = f"kokoro_es_{run_prefix}_{idx}.wav"
en_audio_path = os.path.join(tmpdir, en_audio_filename)
es_audio_path = os.path.join(tmpdir, es_audio_filename)
# English Audio Synthesis via Kokoro
try:
if en_tts_text.strip():
generated_path = generate_local_speech(en_tts_text, voice_name=english_voice, speed=speed_multiplier)
if os.path.exists(generated_path):
# Copy temp file to designated exporter temp directory path
import shutil
shutil.copyfile(generated_path, en_audio_path)
media_files_to_pack.append(en_audio_path)
en_audio_field = f"[sound:{en_audio_filename}]"
else:
en_audio_field = ""
else:
en_audio_field = ""
except Exception as e:
print(f"[Anki Exporter] English TTS error for card {idx}: {e}")
en_audio_field = ""
# Spanish Audio Synthesis via Kokoro
try:
if es_tts_text.strip():
generated_path = generate_local_speech(es_tts_text, voice_name=spanish_voice, speed=speed_multiplier)
if os.path.exists(generated_path):
import shutil
shutil.copyfile(generated_path, es_audio_path)
media_files_to_pack.append(es_audio_path)
es_audio_field = f"[sound:{es_audio_filename}]"
else:
es_audio_field = ""
else:
es_audio_field = ""
except Exception as e:
print(f"[Anki Exporter] Spanish TTS error for card {idx}: {e}")
es_audio_field = ""
note = genanki.Note(
model=anki_model,
fields=[sort_order_str, en_raw, es_raw, anki_notes_html, en_audio_field, es_audio_field],
tags=note_tags
)
deck.add_note(note)
package = genanki.Package(deck)
package.media_files = media_files_to_pack
package.write_to_file(output_path)

BIN
app.icns Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

BIN
base-icons/base_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

290
database.py Normal file
View file

@ -0,0 +1,290 @@
# database.py
import os
import sys
import sqlite3
from PyQt6.QtCore import QSettings
APP_NAME = "SpanishVoiceTrainer"
DEFAULT_DB_FILENAME = "spanish_trainer.db"
def get_default_db_path() -> str:
"""Returns standard macOS Application Support path:
~/Library/Application Support/SpanishVoiceTrainer/spanish_trainer.db
"""
app_support_dir = os.path.expanduser(
f"~/Library/Application Support/{APP_NAME}"
)
os.makedirs(app_support_dir, exist_ok=True)
return os.path.join(app_support_dir, DEFAULT_DB_FILENAME)
def get_db_path() -> str:
"""Retrieves database path cleanly based on execution environment.
- In packaged app mode (sys.frozen): strictly isolates data inside
Application Support unless a valid custom production path is chosen.
Heals stale settings pointing to local dev source paths.
- In dev mode: allows fallback to local project directory.
"""
qs = QSettings(APP_NAME, "Settings")
custom_path = qs.value("database_path", type=str)
# 1. Check custom path saved in QSettings
if custom_path and os.path.exists(custom_path):
# Safeguard for packaged production app:
# Ignore custom paths that point back into local development source folders
if getattr(sys, "frozen", False) and "01_Projects" in custom_path:
default_path = get_default_db_path()
qs.setValue("database_path", default_path) # Repair stale setting
return default_path
return custom_path
# 2. Development mode fallback (uncompiled python runtime)
if not getattr(sys, "frozen", False):
local_dev_db = os.path.join(
os.path.dirname(os.path.abspath(__file__)), DEFAULT_DB_FILENAME
)
if os.path.exists(local_dev_db):
return local_dev_db
# 3. Default production fallback
default_path = get_default_db_path()
qs.setValue("database_path", default_path)
return default_path
def set_db_path(new_path: str):
"""Updates active database path in user preferences."""
qs = QSettings(APP_NAME, "Settings")
qs.setValue("database_path", new_path)
def get_connection():
"""Establishes connection to the active SQLite database."""
db_path = get_db_path()
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def ensure_database_populated():
"""Initializes tables and migrates schemas if running against a new or existing database file."""
conn = get_connection()
cursor = conn.cursor()
try:
# 1. Unified translations table
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',
sort_order INTEGER DEFAULT 0
);
""")
# Safely migrate existing databases that do not yet have the sort_order column
cursor.execute("PRAGMA table_info(translations);")
columns = [row["name"] for row in cursor.fetchall()]
if "sort_order" not in columns:
cursor.execute("ALTER TABLE translations ADD COLUMN sort_order INTEGER DEFAULT 0;")
# 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 explicit, table-qualified column declarations sorted by sort_order."""
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,
translations.sort_order
FROM translations
ORDER BY translations.sort_order ASC, 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,
translations.sort_order
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,
sort_order=0,
):
"""Saves interface edits directly back into the table using named arguments."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
UPDATE translations
SET
es_text = :es,
en_text = :en,
source_context = :ctx,
tags = :tags,
notes = :notes,
gender = :gender,
anki_notes = :anki,
sort_order = :sort_order
WHERE translation_id = :id;
""",
{
"id": translation_id,
"es": es_text,
"en": en_text,
"ctx": source_context,
"tags": tags,
"notes": notes,
"gender": gender,
"anki": anki_notes,
"sort_order": sort_order,
},
)
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, sort_order=0
):
"""Inserts a new record using named arguments."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
INSERT INTO translations (es_text, en_text, source_context, tags, notes, gender, anki_notes, sort_order)
VALUES (:es, :en, :ctx, :tags, :notes, :gender, :anki, :sort_order);
""",
{
"en": en_text,
"es": es_text,
"ctx": source_context,
"tags": tags,
"notes": notes,
"anki": anki_notes,
"gender": gender,
"sort_order": sort_order,
},
)
conn.commit()
finally:
conn.close()

View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

69
doc/tree2.txt Normal file
View file

@ -0,0 +1,69 @@
.
├── LICENSE
├── README.md
├── README.pdf
├── Santiago_cathedral_2021_Sunset.jpg
├── anki_exporter.py
├── app.icns
├── app_icon.iconset
│   ├── icon_128x128.png
│   ├── icon_128x128@2x.png
│   ├── icon_16x16.png
│   ├── icon_16x16@2x.png
│   ├── icon_256x256.png
│   ├── icon_256x256@2x.png
│   ├── icon_32x32.png
│   ├── icon_32x32@2x.png
│   ├── icon_512x512.png
│   └── icon_512x512@2x.png
├── aula_int_plus_1_glos_en_alfa.pdf
├── base_icon.png
├── core
│   ├── __init__.py
│   ├── asset_generator.py
│   ├── audio_engine.py
│   ├── bulk_importer.py
│   ├── clean_glossary.py
│   └── phrase_manager.py
├── database.py
├── doc
│   ├── Notes.md
│   ├── Notes.pdf
│   └── images
├── docling_output.md
├── exported_phases.csv
├── main.py
├── main.spec
├── media
├── migrate_database.py
├── pyproject.toml
├── requirements.txt
├── spanish_alphabetical.txt
├── spanish_glossary_sorted.txt
├── spanish_glossary_sorted.xlsx
├── spanish_glossary_sorted_B.pdf
├── spanish_glossary_sorted_B.txt
├── spanish_glossary_sorted_B.xlsx
├── spanish_trainer-26-08-21.db
├── spanish_trainer-backup.db
├── spanish_trainer.db
├── spanish_trainer_backup.sql
├── spanish_trainer_legacy.db
├── tabs
│   ├── __init__.py
│   ├── review_tab.py
│   ├── sandbox_tab.py
│   └── settings_tab.py
├── test_dual.py
├── test_tts.py
├── tree.txt
├── tree2.txt
├── tts_utils.py
├── ui
│   ├── __init__.py
│   ├── cli
│   └── gui
├── uv.lock
└── video_generator.py
10 directories, 57 files

47
kokoro_utils.py Normal file
View file

@ -0,0 +1,47 @@
# kokoro_utils.py
import os
import tempfile
import soundfile as sf
from kokoro_onnx import Kokoro
# Resolve paths relative to project root
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "kokoro-v1.0.onnx")
VOICES_PATH = os.path.join(BASE_DIR, "voices-v1.0.bin")
_kokoro_instance = None
def get_kokoro_engine():
global _kokoro_instance
if _kokoro_instance is None:
if os.path.exists(MODEL_PATH) and os.path.exists(VOICES_PATH):
_kokoro_instance = Kokoro(MODEL_PATH, VOICES_PATH)
return _kokoro_instance
def generate_local_speech(text: str, voice_name: str = "af_bella", speed: float = 1.0) -> str:
"""
Synthesizes speech locally using Kokoro and saves to a temp file,
returning the path to the generated audio file with correct language routing.
"""
engine = get_kokoro_engine()
if not engine:
raise FileNotFoundError(
f"Kokoro model files not found at root directory.\nExpected:\n- {MODEL_PATH}\n- {VOICES_PATH}"
)
# Route language code explicitly based on voice prefix
if voice_name.startswith("es") or voice_name.startswith("ef") or voice_name.startswith("em"):
lang_code = "es"
else:
lang_code = "en-us"
samples, sample_rate = engine.create(
text,
voice=voice_name,
speed=speed,
lang=lang_code
)
temp_file = os.path.join(tempfile.gettempdir(), "kokoro_review_audio.wav")
sf.write(temp_file, samples, sample_rate)
return temp_file

1280
main.py

File diff suppressed because it is too large Load diff

51
main.spec Normal file
View file

@ -0,0 +1,51 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[()],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='Spanish Voice Trainer',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['app.icns'],
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='main',
)
app = BUNDLE(
coll,
name='main.app',
icon='app.icns',
bundle_identifier=None,
)

View file

@ -12,6 +12,7 @@ dependencies = [
"librosa>=0.11.0",
"numpy>=2.4.6",
"pillow>=12.2.0",
"pyinstaller>=6.21.0",
"pyqt6>=6.11.0",
"scipy>=1.17.1",
"sounddevice>=0.5.5",

View file

@ -1,138 +0,0 @@
accelerate==1.14.0
aiohappyeyeballs==2.6.2
aiohttp==3.14.0
aiosignal==1.4.0
annotated-doc==0.0.4
annotated-types==0.7.0
antlr4-python3-runtime==4.9.3
anyio==4.13.0
attrs==26.1.0
audioop-lts==0.2.2
audioread==3.1.0
beautifulsoup4==4.15.0
cached-property==2.0.1
certifi==2026.5.20
cffi==2.0.0
charset-normalizer==3.4.7
chevron==0.14.0
click==8.4.1
colorlog==6.10.1
decorator==5.3.1
defusedxml==0.7.1
dill==0.4.1
docling==2.102.1
docling-core==2.82.0
docling-ibm-models==3.13.3
docling-parse==6.2.0
docling-slim==2.102.1
edge-tts==7.2.8
et-xmlfile==2.0.0
faker==40.23.0
fastdtw==0.3.4
filelock==3.29.3
filetype==1.2.0
frozendict==2.4.7
frozenlist==1.8.0
fsspec==2026.4.0
genanki==0.13.1
h11==0.16.0
hf-xet==1.5.1
httpcore==1.0.9
httpx==0.28.1
huggingface-hub==1.19.0
idna==3.18
jinja2==3.1.6
joblib==1.5.3
jsonlines==4.0.0
jsonref==1.1.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
latex2mathml==3.81.0
lazy-loader==0.5
librosa==0.11.0
llvmlite==0.47.0
lxml==6.1.1
mail-parser==4.4.0
markdown-it-py==4.2.0
marko==2.2.3
markupsafe==3.0.3
mdurl==0.1.2
mpire==2.10.2
mpmath==1.3.0
msgpack==1.1.2
multidict==6.7.1
multiprocess==0.70.19
narwhals==2.22.1
networkx==3.6.1
numba==0.65.1
numpy==2.4.6
omegaconf==2.3.1
opencv-python==4.13.0.92
openpyxl==3.1.5
packaging==26.2
pandas==3.0.3
pillow==12.2.0
platformdirs==4.10.0
pluggy==1.6.0
polyfactory==3.3.0
pooch==1.9.0
propcache==0.5.2
psutil==7.2.2
pyclipper==1.4.0
pycparser==3.0
pydantic==2.13.4
pydantic-core==2.46.4
pydantic-settings==2.14.1
pygments==2.20.0
pylatexenc==2.10
pypdfium2==5.9.0
pyqt6==6.11.0
pyqt6-qt6==6.11.1
pyqt6-sip==13.11.1
python-dateutil==2.9.0.post0
python-docx==1.2.0
python-dotenv==1.2.2
python-pptx==1.0.2
pyyaml==6.0.3
rapidocr==3.8.3
referencing==0.37.0
regex==2026.5.9
requests==2.34.2
rich==15.0.0
rpds-py==2026.5.1
rtree==1.4.1
safetensors==0.8.0
scikit-learn==1.9.0
scipy==1.17.1
semchunk==3.2.5
setuptools==81.0.0
shapely==2.1.2
shellingham==1.5.4
six==1.17.0
sounddevice==0.5.5
soundfile==0.14.0
soupsieve==2.8.4
soxr==1.1.0
standard-aifc==3.13.0
standard-chunk==3.13.0
standard-sunau==3.13.0
sympy==1.14.0
tabulate==0.10.0
threadpoolctl==3.6.0
tokenizers==0.22.2
torch==2.12.0
torchvision==0.27.0
tqdm==4.68.2
transformers==5.8.1
tree-sitter==0.25.2
tree-sitter-c==0.24.2
tree-sitter-javascript==0.25.0
tree-sitter-python==0.25.0
tree-sitter-typescript==0.23.2
typer==0.21.2
typing-extensions==4.15.0
typing-inspection==0.4.2
urllib3==2.7.0
websockets==16.0
xlsxwriter==3.2.9
yarl==1.24.2

5
tabs/__init__.py Normal file
View 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

397
tabs/review_tab.py Normal file
View file

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

423
tabs/sandbox_tab.py Normal file
View file

@ -0,0 +1,423 @@
# tabs/sandbox_tab.py
import subprocess
import tempfile
import os
import threading
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 database
from tts_utils import parse_text_for_edgetts
from kokoro_utils import generate_local_speech
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 ---
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 _local_kokoro_speech_worker(self, text, voice_name, speed_multiplier):
"""Background thread worker to render local neural speech via Kokoro without network calls."""
try:
print(f"[Kokoro Debug] Sandbox Audition -> Voice: {voice_name} | Speed: {speed_multiplier} | Text: '{text}'")
temp_file = generate_local_speech(text, voice_name=voice_name, speed=speed_multiplier)
if os.path.exists(temp_file) and os.path.getsize(temp_file) > 0:
result = subprocess.run(["afplay", temp_file], capture_output=True, text=True)
if result.returncode != 0:
print(f"[Kokoro Debug] afplay failed: {result.stderr}")
else:
print("[Kokoro Debug] Error: Audio file was not created or is 0 bytes.")
except Exception as e:
print(f"Sandbox Audition Error: {e}")
@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 {}
try:
speed_multiplier = float(settings.get("tts_playback_speed", 1.0))
except (TypeError, ValueError):
speed_multiplier = 1.0
threading.Thread(
target=self._local_kokoro_speech_worker,
args=(spoken_text, "af_bella", speed_multiplier),
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 = "em_alex" if self.rb_male.isChecked() else "ef_dora"
settings = database.load_all_settings() or {}
try:
speed_multiplier = float(settings.get("tts_playback_speed", 1.0))
except (TypeError, ValueError):
speed_multiplier = 1.0
threading.Thread(
target=self._local_kokoro_speech_worker,
args=(spoken_text, voice, speed_multiplier),
daemon=True
).start()

202
tabs/settings_tab.py Normal file
View file

@ -0,0 +1,202 @@
# 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 0: DATABASE FILE CONFIGURATION ---
db_group = QGroupBox("Database Storage Configuration")
db_form = QFormLayout(db_group)
db_form.setSpacing(10)
db_picker_layout = QHBoxLayout()
self.txt_db_path = QLineEdit()
self.txt_db_path.setReadOnly(True)
self.txt_db_path.setStyleSheet("background-color: #F8F9F9; color: #34495E;")
btn_browse_db = QPushButton("Browse...")
btn_browse_db.clicked.connect(self.browse_database_file)
btn_reset_db = QPushButton("Reset Default")
btn_reset_db.clicked.connect(self.reset_default_database)
db_picker_layout.addWidget(self.txt_db_path)
db_picker_layout.addWidget(btn_browse_db)
db_picker_layout.addWidget(btn_reset_db)
db_form.addRow("Active Database File:", db_picker_layout)
main_layout.addWidget(db_group)
# --- 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("0.75")
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)
# Populate active database path
self.txt_db_path.setText(database.get_db_path())
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", "0.75"))
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_database_file(self):
"""Allows user to choose an existing SQLite database file or create a new one."""
current_db = self.txt_db_path.text()
file_path, _ = QFileDialog.getOpenFileName(
self,
"Select SQLite Database File",
current_db,
"SQLite Database (*.db *.sqlite *.sqlite3);;All Files (*)",
)
if file_path:
database.set_db_path(file_path)
database.ensure_database_populated()
self.populate_fields_from_db_state()
QMessageBox.information(
self,
"Database Switched",
f"Active database switched to:\n{file_path}",
)
def reset_default_database(self):
"""Resets the database path back to macOS Application Support default directory."""
default_path = database.get_default_db_path()
database.set_db_path(default_path)
database.ensure_database_populated()
self.populate_fields_from_db_state()
QMessageBox.information(
self,
"Database Reset",
f"Reset database path to default location:\n{default_path}",
)
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)

55
test-scripts/test_tts.py Normal file
View file

@ -0,0 +1,55 @@
# tts_utils.py
import re
from bs4 import BeautifulSoup
def get_configured_tts_rate(settings: dict) -> str:
"""Converts a numerical speed multiplier (e.g., 0.75, 1.0, 1.25) from app settings
into Edge TTS percentage format string (e.g., '-25%', '+0%', '+25%').
"""
# Check the actual database key 'tts_playback_speed' with fallbacks
raw_val = (
settings.get("tts_playback_speed")
or settings.get("tts_speed_multiplier")
or 1.0
)
try:
raw_rate = float(raw_val)
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()

View file

@ -0,0 +1,29 @@
# test_voices.py
import soundfile as sf
from kokoro_onnx import Kokoro
kokoro = Kokoro("kokoro-v1.0.onnx", "voices-v1.0.bin")
# Test phrases
#english_text = "Welcome to the Spanish voice trainer. Let's practice key phrases together."
#spanish_text = "Bienvenido al entrenador de voz en español. Practiquemos frases clave juntos."
english_text = "surname"
spanish_text = "apellido"
# Voices to test (English and Spanish presets)
voices_to_test = [
("af_bella", "en-us", english_text),
("af_nicole", "en-us", english_text),
("af_sky", "en-us", english_text),
("ef_dora", "es", spanish_text),
("em_alex", "es", spanish_text),
]
for voice, lang, text in voices_to_test:
print(f"Generating sample for {voice} ({lang})...")
samples, sample_rate = kokoro.create(text, voice=voice, speed=1.0, lang=lang)
filename = f"sample_{voice}.wav"
sf.write(filename, samples, sample_rate)
print("Done! You can play sample_af_bella.wav, sample_ef_dora.wav, etc. using afplay.")

View file

@ -1,28 +0,0 @@
import asyncio
import os
import edge_tts
# 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():
# Ensure our target media folder exists locally
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
print(f"🔄 Synthesizing text using Castilian voice: {VOICE}...")
# 2. Configure the Communicate engine
communicate = edge_tts.Communicate(SPANISH_TEXT, VOICE)
# 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__":
# Run the async loop loop natively
asyncio.run(generate_castilian_audio())

47
tts_utils.py Normal file
View file

@ -0,0 +1,47 @@
# tts_utils.py
import re
from bs4 import BeautifulSoup
def get_configured_tts_rate(settings: dict) -> float:
"""Extracts speed multiplier from settings dict as a clean float (e.g., 0.75) for Kokoro TTS."""
raw_val = settings.get("tts_playback_speed", 1.0) if settings else 1.0
try:
speed_rate = float(raw_val)
except (TypeError, ValueError):
speed_rate = 1.0
print(
f"[TTS Debug] DB Key 'tts_playback_speed': {raw_val} -> Kokoro Speed Multiplier: {speed_rate}"
)
return speed_rate
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()

81
uv.lock
View file

@ -24,6 +24,7 @@ dependencies = [
{ name = "librosa" },
{ name = "numpy" },
{ name = "pillow" },
{ name = "pyinstaller" },
{ name = "pyqt6" },
{ name = "scipy" },
{ name = "sounddevice" },
@ -38,6 +39,7 @@ requires-dist = [
{ name = "librosa", specifier = ">=0.11.0" },
{ name = "numpy", specifier = ">=2.4.6" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "pyinstaller", specifier = ">=6.21.0" },
{ name = "pyqt6", specifier = ">=6.11.0" },
{ name = "scipy", specifier = ">=1.17.1" },
{ name = "sounddevice", specifier = ">=0.5.5" },
@ -163,6 +165,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "altgraph"
version = "0.17.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@ -1152,6 +1163,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
]
[[package]]
name = "macholib"
version = "1.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph", marker = "sys_platform == 'darwin'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
]
[[package]]
name = "mail-parser"
version = "4.4.0"
@ -1744,6 +1767,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" },
]
[[package]]
name = "pefile"
version = "2024.8.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
@ -2079,6 +2111,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyinstaller"
version = "6.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" },
{ url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" },
{ url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" },
{ url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" },
{ url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" },
{ url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" },
{ url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" },
{ url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" },
]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2026.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" },
]
[[package]]
name = "pylatexenc"
version = "2.10"
@ -2227,6 +2299,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"

0
video_generator.py Normal file
View file