this version replaced edge-tts with kokoro locally based tts model

This commit is contained in:
stephen 2026-10-17 12:13:03 +11:00
parent 4a4d16c093
commit 2346bacd5d
37 changed files with 383 additions and 1263 deletions

3
.gitignore vendored
View file

@ -26,3 +26,6 @@ media/*
.vscode/ .vscode/
.idea/ .idea/
.env .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,
)

View file

@ -1,32 +1,20 @@
# anki_exporter.py # anki_exporter.py
import os import os
import tempfile import tempfile
import asyncio
import time import time
import genanki import genanki
import edge_tts
import database import database
from tts_utils import parse_text_for_edgetts, get_configured_tts_rate from tts_utils import parse_text_for_edgetts
from kokoro_utils import generate_local_speech
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): def compile_anki_package(records, output_path, deck_name):
""" """
Compiles database records into a bidirectional card payload package. Compiles database records into a bidirectional card payload package.
Resolves voice models dynamically by gender selection parameters and applies Resolves voice models dynamically by gender selection parameters and applies
global speed coefficient rates from the active configurations. speed multipliers from active configurations using local Kokoro TTS.
Injects sort_order as field 0 for structured card sequencing. Injects sort_order as field 0 for structured card sequencing.
""" """
# Generate deterministic positive 32-bit integers from deck name and model name # Generate deterministic positive 32-bit integers from deck name and model name
# to avoid collisions across different decks while keeping imports stable
deck_id = abs(hash(deck_name)) % (2**31) deck_id = abs(hash(deck_name)) % (2**31)
model_id = abs(hash("Spanish Bidirectional Multi-Note HTML Model")) % (2**31) model_id = abs(hash("Spanish Bidirectional Multi-Note HTML Model")) % (2**31)
@ -35,7 +23,10 @@ def compile_anki_package(records, output_path, deck_name):
# Global Configuration Pace Resolver Mapping # Global Configuration Pace Resolver Mapping
settings = database.load_all_settings() or {} settings = database.load_all_settings() or {}
rate_string = get_configured_tts_rate(settings) try:
speed_multiplier = float(settings.get("tts_playback_speed", 1.0))
except (TypeError, ValueError):
speed_multiplier = 1.0
anki_model = genanki.Model( anki_model = genanki.Model(
model_id, model_id,
@ -52,8 +43,8 @@ def compile_anki_package(records, output_path, deck_name):
{ {
'name': 'Card 1: English ➔ Spanish', '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>' '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="font-family: Arial; font-size: 20px; text-align: center; color: #34495E;"><b>{{EnglishText}}</b></div><br>'
'<div style="display:none;">{{EnglishAudio}}</div>', '<div style="text-align: center;">{{EnglishAudio}}</div>',
'afmt': '{{FrontSide}}<hr id="answer">' 'afmt': '{{FrontSide}}<hr id="answer">'
'<div style="font-family: Arial; font-size: 28px; text-align: center; color: #2980B9; font-weight: bold;">{{SpanishText}}</div><br>' '<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>' '{{#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>'
@ -62,8 +53,8 @@ def compile_anki_package(records, output_path, deck_name):
{ {
'name': 'Card 2: Spanish ➔ English', '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>' '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="font-family: Arial; font-size: 26px; text-align: center; color: #2980B9; font-weight: bold;"><b>{{SpanishText}}</b></div><br>'
'<div style="display:none;">{{SpanishAudio}}</div>', '<div style="text-align: center;">{{SpanishAudio}}</div>',
'afmt': '{{FrontSide}}<hr id="answer">' 'afmt': '{{FrontSide}}<hr id="answer">'
'<div style="font-family: Arial; font-size: 22px; text-align: center; color: #2C3E50; font-weight: 500;">{{EnglishText}}</div><br>' '<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>' '{{#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>'
@ -75,12 +66,6 @@ def compile_anki_package(records, output_path, deck_name):
deck = genanki.Deck(deck_id, deck_name) deck = genanki.Deck(deck_id, deck_name)
media_files_to_pack = [] 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: with tempfile.TemporaryDirectory() as tmpdir:
for idx, record in enumerate(records): for idx, record in enumerate(records):
en_raw = record["en_text"] en_raw = record["en_text"]
@ -96,38 +81,57 @@ def compile_anki_package(records, output_path, deck_name):
en_tts_text = parse_text_for_edgetts(en_raw) en_tts_text = parse_text_for_edgetts(en_raw)
es_tts_text = parse_text_for_edgetts(es_raw) es_tts_text = parse_text_for_edgetts(es_raw)
# Map native neural voice files matching gender settings # Map Kokoro voices matching gender settings
spanish_voice = "es-ES-AlvaroNeural" if gender_flag == "Male" else "es-ES-ElviraNeural" spanish_voice = "em_alex" if gender_flag == "Male" else "ef_dora"
english_voice = "en-US-EmmaNeural" english_voice = "af_bella"
raw_tags = record.get("tags") or "" raw_tags = record.get("tags") or ""
note_tags = [t.strip().replace(" ", "_") for t in raw_tags.split(",") if t.strip()] 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 "" anki_notes_html = record['anki_notes'].strip() if record.get('anki_notes') else ""
# Unique Media Filenames combining execution timestamp and index # Unique Media Filenames combining execution timestamp and index (.wav output from soundfile)
en_audio_filename = f"edge_en_{run_prefix}_{idx}.mp3" en_audio_filename = f"kokoro_en_{run_prefix}_{idx}.wav"
es_audio_filename = f"edge_es_{run_prefix}_{idx}.mp3" es_audio_filename = f"kokoro_es_{run_prefix}_{idx}.wav"
en_audio_path = os.path.join(tmpdir, en_audio_filename) en_audio_path = os.path.join(tmpdir, en_audio_filename)
es_audio_path = os.path.join(tmpdir, es_audio_filename) es_audio_path = os.path.join(tmpdir, es_audio_filename)
# English Audio Synthesis # English Audio Synthesis via Kokoro
if en_tts_text.strip() and loop.run_until_complete(generate_edge_audio(en_tts_text, english_voice, en_audio_path, rate_string)): 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) media_files_to_pack.append(en_audio_path)
en_audio_field = f"[sound:{en_audio_filename}]" en_audio_field = f"[sound:{en_audio_filename}]"
else: else:
en_audio_field = "" 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 # Spanish Audio Synthesis via Kokoro
if es_tts_text.strip() and loop.run_until_complete(generate_edge_audio(es_tts_text, spanish_voice, es_audio_path, rate_string)): 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) media_files_to_pack.append(es_audio_path)
es_audio_field = f"[sound:{es_audio_filename}]" es_audio_field = f"[sound:{es_audio_filename}]"
else: else:
es_audio_field = "" 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 = ""
# Clean sequential matching fields array matching schema mapping above
note = genanki.Note( note = genanki.Note(
model=anki_model, model=anki_model,
fields=[sort_order_str, en_raw, es_raw, anki_notes_html, en_audio_field, es_audio_field], fields=[sort_order_str, en_raw, es_raw, anki_notes_html, en_audio_field, es_audio_field],
@ -135,7 +139,7 @@ def compile_anki_package(records, output_path, deck_name):
) )
deck.add_note(note) deck.add_note(note)
# Build Package while media assets are guaranteed contextually active inside tmpdir
package = genanki.Package(deck) package = genanki.Package(deck)
package.media_files = media_files_to_pack package.media_files = media_files_to_pack
package.write_to_file(output_path) package.write_to_file(output_path)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

Before

Width:  |  Height:  |  Size: 13 MiB

After

Width:  |  Height:  |  Size: 13 MiB

View file

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -65,3 +65,10 @@ tree --gitignore
* **Python Caches:** `__pycache__/`, `*.pyc` * **Python Caches:** `__pycache__/`, `*.pyc`
* **Static / Temp Media:** `*.png`, `*.jpg`, `*.pdf`, `app_icon.iconset/` * **Static / Temp Media:** `*.png`, `*.jpg`, `*.pdf`, `app_icon.iconset/`
* **Legacy / Backups:** `database_legacy/` (containing database backups like `*.db`) * **Legacy / Backups:** `database_legacy/` (containing database backups like `*.db`)
There is a problem with edge-tts
so we are going to install kokoro
39_spanish-voice-trainer % git tag -a V37.0 -m "this version replaced edge-tts with kokoro locally based tts model"
(139_spanish-voice-trainer) stephenlohning

Binary file not shown.

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

129
main.py
View file

@ -1,7 +1,12 @@
# main.py # main.py
import sys import sys
import os import os
from PyQt6.QtWidgets import QMainWindow, QTabWidget, QApplication, QMessageBox, QVBoxLayout, QWidget import shutil
from PyQt6.QtWidgets import (
QMainWindow, QTabWidget, QApplication, QMessageBox,
QVBoxLayout, QWidget, QFileDialog
)
from PyQt6.QtGui import QAction, QKeySequence
from PyQt6.QtCore import pyqtSlot from PyQt6.QtCore import pyqtSlot
# Import our unified data layer # Import our unified data layer
@ -19,7 +24,7 @@ import video_generator
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("Castilian Spanish Voice Trainer") self.setWindowTitle("Spanish Voice Trainer")
self.setMinimumSize(900, 650) self.setMinimumSize(900, 650)
# 1. Ensure database structure exists on startup # 1. Ensure database structure exists on startup
@ -47,9 +52,121 @@ class MainWindow(QMainWindow):
self.tab_widget.addTab(self.review_tab, "Flashcard Review") self.tab_widget.addTab(self.review_tab, "Flashcard Review")
self.tab_widget.addTab(self.settings_tab, "Configuration Settings") self.tab_widget.addTab(self.settings_tab, "Configuration Settings")
# 6. Wire up the cross-module communication channels (Signals) # 6. Build application Menu Bar
self.create_menu_bar()
# 7. Wire up the cross-module communication channels (Signals)
self.wire_application_signals() self.wire_application_signals()
def create_menu_bar(self):
"""Constructs native macOS Menu Bar integrating directly with the primary system process menu."""
menu_bar = self.menuBar()
# --- 1. APP MENU (Spanish Voice Trainer) ---
# An empty string title merges actions directly into the native primary macOS Application Menu
app_menu = menu_bar.addMenu("")
about_action = QAction("About Spanish Voice Trainer", self)
about_action.setMenuRole(QAction.MenuRole.AboutRole)
about_action.triggered.connect(self.show_about_dialog)
app_menu.addAction(about_action)
update_action = QAction("Check for Updates...", self)
update_action.setMenuRole(QAction.MenuRole.ApplicationSpecificRole)
update_action.triggered.connect(self.check_for_updates)
app_menu.addAction(update_action)
quit_action = QAction("Quit Spanish Voice Trainer", self)
quit_action.setMenuRole(QAction.MenuRole.QuitRole)
quit_action.setShortcut(QKeySequence.StandardKey.Quit)
quit_action.triggered.connect(QApplication.instance().quit)
app_menu.addAction(quit_action)
# --- 2. TOOLS MENU ---
tools_menu = menu_bar.addMenu("Tools")
backup_action = QAction("Backup Database...", self)
backup_action.triggered.connect(self.handle_backup_database)
tools_menu.addAction(backup_action)
# --- 3. HELP MENU ---
help_menu = menu_bar.addMenu("Help")
help_instructions_action = QAction("User Guide & Instructions", self)
help_instructions_action.setMenuRole(QAction.MenuRole.ApplicationSpecificRole)
help_instructions_action.setShortcut(QKeySequence.StandardKey.HelpContents)
help_instructions_action.triggered.connect(self.show_help_dialog)
help_menu.addAction(help_instructions_action)
# --- Menu Action Handlers ---
@pyqtSlot()
def show_about_dialog(self):
"""Displays application details and copyright information."""
QMessageBox.about(
self,
"About Spanish Voice Trainer",
"<h3>Spanish Voice Trainer</h3>"
"<p><b>Version:</b> 1.0.0</p>"
"<p>A local-first application for Castilian Spanish vocabulary practice, "
"neural TTS voice previewing, and Anki deck generation.</p>"
)
@pyqtSlot()
def check_for_updates(self):
"""Placeholder action for update checks."""
QMessageBox.information(
self,
"Check for Updates",
"Check for Updates is not implemented yet in this version."
)
@pyqtSlot()
def handle_backup_database(self):
"""Copies the primary SQLite database file to a user-selected location."""
db_path = database.DB_PATH if hasattr(database, 'DB_PATH') else "spanish_app.db"
if not os.path.exists(db_path):
QMessageBox.warning(self, "Backup Error", f"Database file '{db_path}' could not be located.")
return
save_path, _ = QFileDialog.getSaveFileName(
self,
"Save Database Backup",
os.path.expanduser("~/Desktop/spanish_app_backup.db"),
"SQLite Database (*.db *.sqlite);;All Files (*)"
)
if save_path:
try:
shutil.copy2(db_path, save_path)
QMessageBox.information(
self,
"Backup Complete",
f"Successfully created a backup copy of your database at:\n{save_path}"
)
except Exception as e:
QMessageBox.critical(self, "Backup Failed", f"Could not create database backup:\n{str(e)}")
@pyqtSlot()
def show_help_dialog(self):
"""Displays user guide and usage instructions."""
instructions_text = (
"<h3>Spanish Voice Trainer - Quick Start Guide</h3>"
"<ul>"
"<li><b>Database Sandbox:</b> Add, edit, sort, or tag your English/Spanish phrase translations. "
"Sort order dictates sequence in flashcards and exports.</li>"
"<li><b>Flashcard Review:</b> Practice interactive flashcards. Click <i>Flip Card</i> to see and automatically hear "
"the Spanish phrase. Click <i>Next Card</i> to advance and automatically hear the English prompt.</li>"
"<li><b>Filter Cards:</b> Use the top filter fields (Context or Tags) to isolate specific review topics or decks.</li>"
"<li><b>Generate Deck:</b> Click <i>Generate Deck</i> to compile your active phrases into a `.apkg` file ready for Anki import.</li>"
"<li><b>Configuration Settings:</b> Adjust default export folders, speech speed rate, and Anki deck hierarchy settings.</li>"
"</ul>"
)
QMessageBox.information(self, "Instructions & Help", instructions_text)
# --- Standard Application Signal & Cache Logic ---
def refresh_settings_cache(self): def refresh_settings_cache(self):
"""Loads all database settings into an easy-to-read dictionary.""" """Loads all database settings into an easy-to-read dictionary."""
self.app_settings = database.load_all_settings() self.app_settings = database.load_all_settings()
@ -97,7 +214,7 @@ class MainWindow(QMainWindow):
records = database.get_all_translations_explicit() records = database.get_all_translations_explicit()
if not records: if not records:
QMessageBox.warning(self, "Generation Failed", "No phrases available to construct a training video playlist loops.") QMessageBox.warning(self, "Generation Failed", "No phrases available to construct training video playlist loops.")
return return
# UI updates can block execution loops, so we notify before processing # UI updates can block execution loops, so we notify before processing
@ -121,6 +238,10 @@ class MainWindow(QMainWindow):
if __name__ == "__main__": if __name__ == "__main__":
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Explicitly set application identity for macOS menu bar and dock integration
app.setApplicationName("Spanish Voice Trainer")
app.setApplicationDisplayName("Spanish Voice Trainer")
# Modern clean styling choices for desktop applications # Modern clean styling choices for desktop applications
app.setStyle('Fusion') app.setStyle('Fusion')

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

View file

@ -4,7 +4,6 @@ import re
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
import asyncio
from datetime import datetime from datetime import datetime
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
@ -12,10 +11,10 @@ from PyQt6.QtWidgets import (
QHeaderView, QFormLayout, QMessageBox, QFrame, QStackedWidget QHeaderView, QFormLayout, QMessageBox, QFrame, QStackedWidget
) )
from PyQt6.QtCore import Qt, pyqtSlot from PyQt6.QtCore import Qt, pyqtSlot
import edge_tts
import database import database
import anki_exporter import anki_exporter
from tts_utils import parse_text_for_edgetts, get_configured_tts_rate from tts_utils import parse_text_for_edgetts
from kokoro_utils import generate_local_speech
class ReviewTab(QWidget): class ReviewTab(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
@ -277,99 +276,82 @@ class ReviewTab(QWidget):
# Automatically play English neural audio on advancing to next card # Automatically play English neural audio on advancing to next card
self.play_card_audio() self.play_card_audio()
def _async_edge_speech_worker(self, text, voice, rate_modifier): def _local_kokoro_speech_worker(self, text, voice_name, speed_multiplier):
"""Background thread worker to render neural speech with terminal debug logging.""" """Background thread worker to render local neural speech via Kokoro without network calls."""
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: try:
os.remove(temp_file) print(f"[Kokoro Debug] Generating local TTS -> Voice: {voice_name} | Speed: {speed_multiplier} | Text: '{text}'")
except Exception: temp_file = generate_local_speech(text, voice_name=voice_name, speed=speed_multiplier)
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: 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...") print(f"[Kokoro Debug] Audio ready. Playing via afplay...")
result = subprocess.run(["afplay", temp_file], capture_output=True, text=True) result = subprocess.run(["afplay", temp_file], capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
print(f"[TTS Debug] afplay failed: {result.stderr}") print(f"[Kokoro Debug] afplay failed: {result.stderr}")
else: else:
print("[TTS Debug] Playback finished successfully.") print("[Kokoro Debug] Playback finished successfully.")
else: else:
print("[TTS Debug] Error: Audio file was not created or is 0 bytes.") print("[Kokoro Debug] Error: Audio file was not created or is 0 bytes.")
except Exception as e: except Exception as e:
print(f"[TTS Debug] Exception during speech synthesis: {e}") print(f"[Kokoro Debug] Exception during local 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() @pyqtSlot()
def play_card_audio(self): def play_card_audio(self):
"""Auditions neural edge-tts voice based on active flashcard side.""" """Triggers local Kokoro neural voice generation based on active flashcard side."""
if not (0 <= self.current_index < len(self.filtered_review_pool)): if not (0 <= self.current_index < len(self.filtered_review_pool)):
return return
record = self.filtered_review_pool[self.current_index] record = self.filtered_review_pool[self.current_index]
settings = database.load_all_settings() or {} settings = database.load_all_settings() or {}
rate_string = get_configured_tts_rate(settings)
# 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: if not self.is_flipped:
raw_text = record.get("en_text", "") raw_text = record.get("en_text", "")
spoken_text = parse_text_for_edgetts(raw_text) spoken_text = parse_text_for_edgetts(raw_text)
voice = "en-US-EmmaNeural" #voice = "af_sarah" # English voice reference in Kokoro
#voice = "af_heart"
voice = "af_bella" # Default English voice for testing
else: else:
raw_text = record.get("es_text", "") raw_text = record.get("es_text", "")
spoken_text = parse_text_for_edgetts(raw_text) spoken_text = parse_text_for_edgetts(raw_text)
is_male = (record.get("gender") == "Male") is_male = (record.get("gender") == "Male")
voice = "es-ES-AlvaroNeural" if is_male else "es-ES-ElviraNeural" # Kokoro Spanish voices: 'ef_dora' (female) / 'em_alex' (male)
voice = "em_alex" if is_male else "ef_dora"
# Respect <meta sound-off> flags or empty entries
if not spoken_text.strip(): if not spoken_text.strip():
print("[TTS Debug] Skipped: Parsed text is empty or muted via sound-off tag.") print("[Kokoro Debug] Skipped: Parsed text is empty.")
return return
threading.Thread( threading.Thread(
target=self._async_edge_speech_worker, target=self._local_kokoro_speech_worker,
args=(spoken_text, voice, rate_string), args=(spoken_text, voice, speed_multiplier),
daemon=True daemon=True
).start() ).start()
@pyqtSlot() @pyqtSlot()
def generate_deck_action(self): def generate_deck_action(self):
"""Generates a specialized .apkg Anki deck matching active filter parameters, """Generates a specialized .apkg Anki deck matching active filter parameters,
prepending yyyy-mm-dd-hhmm timestamp and matching Sub-Deck-Namespace-Hierarchy without brackets/parentheses.""" prepressing yyyy-mm-dd-hhmm timestamp and matching Sub-Deck-Namespace-Hierarchy without brackets/parentheses."""
if not self.filtered_review_pool: if not self.filtered_review_pool:
QMessageBox.warning(self, "Export Aborted", "The current matching review deck queue is empty. Cannot compile an empty deck.") QMessageBox.warning(self, "Export Aborted", "The current matching review deck queue is empty. Cannot compile an empty deck.")
return return
try: try:
# Load active settings dictionary directly from database configurations
settings = database.load_all_settings() or {} settings = database.load_all_settings() or {}
# Extract configurations targeting exact database schema names found in settings
target_dir = settings.get("anki_export_directory") target_dir = settings.get("anki_export_directory")
root_deck_name = settings.get("anki_root_deck_name") root_deck_name = settings.get("anki_root_deck_name")
sub_deck_hierarchy = settings.get("anki_sub_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)): if not target_dir or not os.path.isdir(str(target_dir)):
target_dir = os.path.expanduser("~/Desktop") target_dir = os.path.expanduser("~/Desktop")
else: else:
target_dir = str(target_dir) target_dir = str(target_dir)
# --- Compile Full Namespace Tree Path ---
deck_tree_parts = [] deck_tree_parts = []
if root_deck_name and str(root_deck_name).strip(): if root_deck_name and str(root_deck_name).strip():
@ -381,23 +363,17 @@ class ReviewTab(QWidget):
if not deck_tree_parts: if not deck_tree_parts:
deck_tree_parts.append("DefaultDeck") deck_tree_parts.append("DefaultDeck")
# Join parts using Anki double-colon syntax (::) for internal Anki hierarchy
full_deck_namespace = "::".join(deck_tree_parts) full_deck_namespace = "::".join(deck_tree_parts)
# --- Format Timestamp and Clean Filename ---
# Format: YYYY-MM-DD-HHMM
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M") timestamp = datetime.now().strftime("%Y-%m-%d-%H%M")
# Explicitly strip out parentheses/brackets before regex normalization
clean_namespace = full_deck_namespace.replace('(', '').replace(')', '').replace('[', '').replace(']', '') 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'[^a-zA-Z0-9]', '-', clean_namespace)
clean_namespace = re.sub(r'-+', '-', clean_namespace).strip('-') clean_namespace = re.sub(r'-+', '-', clean_namespace).strip('-')
# Complete output filename pattern: yyyy-mm-dd-hhmm-Sub-Deck-Namespace-Hierarchy.apkg
filename = f"{timestamp}-{clean_namespace}.apkg" filename = f"{timestamp}-{clean_namespace}.apkg"
file_path = os.path.join(target_dir, filename) 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) anki_exporter.compile_anki_package(self.filtered_review_pool, file_path, full_deck_namespace)
QMessageBox.information( QMessageBox.information(

View file

@ -3,7 +3,6 @@ import subprocess
import tempfile import tempfile
import os import os
import threading import threading
import asyncio
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
@ -11,9 +10,9 @@ from PyQt6.QtWidgets import (
QRadioButton, QButtonGroup, QSpinBox QRadioButton, QButtonGroup, QSpinBox
) )
from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt
import edge_tts
import database import database
from tts_utils import parse_text_for_edgetts, get_configured_tts_rate from tts_utils import parse_text_for_edgetts
from kokoro_utils import generate_local_speech
class TextEditorDialog(QDialog): class TextEditorDialog(QDialog):
@ -120,7 +119,7 @@ class SandboxTab(QWidget):
self.txt_tags.setStyleSheet("padding: 6px; font-size: 14px;") self.txt_tags.setStyleSheet("padding: 6px; font-size: 14px;")
self.txt_tags.textChanged.connect(self.apply_live_grid_filter) self.txt_tags.textChanged.connect(self.apply_live_grid_filter)
# --- Sort Order Field (Added after Tags) --- # --- Sort Order Field ---
self.spn_sort_order = QSpinBox() self.spn_sort_order = QSpinBox()
self.spn_sort_order.setRange(0, 999999) self.spn_sort_order.setRange(0, 999999)
self.spn_sort_order.setValue(0) self.spn_sort_order.setValue(0)
@ -367,20 +366,21 @@ class SandboxTab(QWidget):
self.table.setItem(visible_row_idx, 6, QTableWidgetItem(r.get("gender", "Female"))) self.table.setItem(visible_row_idx, 6, QTableWidgetItem(r.get("gender", "Female")))
visible_row_idx += 1 visible_row_idx += 1
def _async_edge_speech_worker(self, text, voice, rate_modifier): def _local_kokoro_speech_worker(self, text, voice_name, speed_multiplier):
"""Background thread worker to download neural audio and play it without freezing the UI.""" """Background thread worker to render local neural speech via Kokoro without network calls."""
async def stream_audio():
temp_file = os.path.join(tempfile.gettempdir(), "sandbox_audition.mp3")
try: try:
communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier) print(f"[Kokoro Debug] Sandbox Audition -> Voice: {voice_name} | Speed: {speed_multiplier} | Text: '{text}'")
await communicate.save(temp_file) temp_file = generate_local_speech(text, voice_name=voice_name, speed=speed_multiplier)
if os.path.exists(temp_file):
subprocess.run(["afplay", temp_file]) 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: except Exception as e:
print(f"Sandbox Audition Error: {e}") print(f"Sandbox Audition Error: {e}")
asyncio.run(stream_audio())
@pyqtSlot() @pyqtSlot()
def audition_english(self): def audition_english(self):
raw_txt = self.txt_english.text().strip() raw_txt = self.txt_english.text().strip()
@ -390,11 +390,14 @@ class SandboxTab(QWidget):
return return
settings = database.load_all_settings() or {} settings = database.load_all_settings() or {}
rate_string = get_configured_tts_rate(settings) try:
speed_multiplier = float(settings.get("tts_playback_speed", 1.0))
except (TypeError, ValueError):
speed_multiplier = 1.0
threading.Thread( threading.Thread(
target=self._async_edge_speech_worker, target=self._local_kokoro_speech_worker,
args=(spoken_text, "en-US-EmmaNeural", rate_string), args=(spoken_text, "af_bella", speed_multiplier),
daemon=True daemon=True
).start() ).start()
@ -406,12 +409,15 @@ class SandboxTab(QWidget):
if not spoken_text.strip(): if not spoken_text.strip():
return return
voice = "es-ES-AlvaroNeural" if self.rb_male.isChecked() else "es-ES-ElviraNeural" voice = "em_alex" if self.rb_male.isChecked() else "ef_dora"
settings = database.load_all_settings() or {} settings = database.load_all_settings() or {}
rate_string = get_configured_tts_rate(settings) try:
speed_multiplier = float(settings.get("tts_playback_speed", 1.0))
except (TypeError, ValueError):
speed_multiplier = 1.0
threading.Thread( threading.Thread(
target=self._async_edge_speech_worker, target=self._local_kokoro_speech_worker,
args=(spoken_text, voice, rate_string), args=(spoken_text, voice, speed_multiplier),
daemon=True daemon=True
).start() ).start()

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.")

981
tree.txt
View file

@ -1,981 +0,0 @@
.
├── LICENSE
├── README.md
├── README.pdf
├── Santiago_cathedral_2021_Sunset.jpg
├── __pycache__
│   ├── anki_exporter.cpython-313.pyc
│   ├── database.cpython-313.pyc
│   ├── tts_utils.cpython-313.pyc
│   └── video_generator.cpython-313.pyc
├── 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
├── build
│   └── main
│   ├── Analysis-00.toc
│   ├── BUNDLE-00.toc
│   ├── COLLECT-00.toc
│   ├── EXE-00.toc
│   ├── PKG-00.toc
│   ├── PYZ-00.pyz
│   ├── PYZ-00.toc
│   ├── base_library.zip
│   ├── localpycs
│   │   ├── pyimod01_archive.pyc
│   │   ├── pyimod02_importers.pyc
│   │   ├── pyimod03_ctypes.pyc
│   │   └── struct.pyc
│   ├── main
│   ├── main.pkg
│   ├── warn-main.txt
│   └── xref-main.html
├── core
│   ├── __init__.py
│   ├── __pycache__
│   │   ├── __init__.cpython-313.pyc
│   │   ├── asset_generator.cpython-313.pyc
│   │   ├── bulk_importer.cpython-313.pyc
│   │   ├── clean_glossary.cpython-313.pyc
│   │   └── phrase_manager.cpython-313.pyc
│   ├── asset_generator.py
│   ├── audio_engine.py
│   ├── bulk_importer.py
│   ├── clean_glossary.py
│   └── phrase_manager.py
├── database.py
├── database_legacy
│   ├── __init__.py
│   ├── __pycache__
│   │   ├── __init__.cpython-313.pyc
│   │   └── connection.cpython-313.pyc
│   ├── connection.py
│   ├── trainer_backup_20260618_220043.db
│   └── trainer_backup_20260618_224811.db
├── dist
│   ├── main
│   │   ├── _internal
│   │   │   ├── 81d243bd2c585b0f4821__mypyc.cpython-313-darwin.so
│   │   │   ├── PIL
│   │   │   │   ├── _avif.cpython-313-darwin.so
│   │   │   │   ├── _imaging.cpython-313-darwin.so
│   │   │   │   ├── _imagingcms.cpython-313-darwin.so
│   │   │   │   ├── _imagingft.cpython-313-darwin.so
│   │   │   │   ├── _imagingmath.cpython-313-darwin.so
│   │   │   │   ├── _imagingtk.cpython-313-darwin.so
│   │   │   │   └── _webp.cpython-313-darwin.so
│   │   │   ├── PyQt6
│   │   │   │   ├── Qt6
│   │   │   │   │   ├── lib
│   │   │   │   │   │   ├── QtCore.framework
│   │   │   │   │   │   │   ├── QtCore -> Versions/Current/QtCore
│   │   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   │   └── Versions
│   │   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   │   ├── QtCore
│   │   │   │   │   │   │   │   └── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── Current -> A
│   │   │   │   │   │   ├── QtDBus.framework
│   │   │   │   │   │   │   ├── QtDBus -> Versions/Current/QtDBus
│   │   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   │   └── Versions
│   │   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   │   ├── QtDBus
│   │   │   │   │   │   │   │   └── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── Current -> A
│   │   │   │   │   │   ├── QtGui.framework
│   │   │   │   │   │   │   ├── QtGui -> Versions/Current/QtGui
│   │   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   │   └── Versions
│   │   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   │   ├── QtGui
│   │   │   │   │   │   │   │   └── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── Current -> A
│   │   │   │   │   │   ├── QtNetwork.framework
│   │   │   │   │   │   │   ├── QtNetwork -> Versions/Current/QtNetwork
│   │   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   │   └── Versions
│   │   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   │   ├── QtNetwork
│   │   │   │   │   │   │   │   └── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── Current -> A
│   │   │   │   │   │   ├── QtPdf.framework
│   │   │   │   │   │   │   ├── QtPdf -> Versions/Current/QtPdf
│   │   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   │   └── Versions
│   │   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   │   ├── QtPdf
│   │   │   │   │   │   │   │   └── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── Current -> A
│   │   │   │   │   │   ├── QtSvg.framework
│   │   │   │   │   │   │   ├── QtSvg -> Versions/Current/QtSvg
│   │   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   │   └── Versions
│   │   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   │   ├── QtSvg
│   │   │   │   │   │   │   │   └── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── Current -> A
│   │   │   │   │   │   └── QtWidgets.framework
│   │   │   │   │   │   ├── QtWidgets -> Versions/Current/QtWidgets
│   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   └── Versions
│   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   ├── QtWidgets
│   │   │   │   │   │   │   └── Resources
│   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   └── Current -> A
│   │   │   │   │   ├── plugins
│   │   │   │   │   │   ├── generic
│   │   │   │   │   │   │   └── libqtuiotouchplugin.dylib
│   │   │   │   │   │   ├── iconengines
│   │   │   │   │   │   │   └── libqsvgicon.dylib
│   │   │   │   │   │   ├── imageformats
│   │   │   │   │   │   │   ├── libqgif.dylib
│   │   │   │   │   │   │   ├── libqicns.dylib
│   │   │   │   │   │   │   ├── libqico.dylib
│   │   │   │   │   │   │   ├── libqjpeg.dylib
│   │   │   │   │   │   │   ├── libqmacheif.dylib
│   │   │   │   │   │   │   ├── libqmacjp2.dylib
│   │   │   │   │   │   │   ├── libqpdf.dylib
│   │   │   │   │   │   │   ├── libqsvg.dylib
│   │   │   │   │   │   │   ├── libqtga.dylib
│   │   │   │   │   │   │   ├── libqtiff.dylib
│   │   │   │   │   │   │   ├── libqwbmp.dylib
│   │   │   │   │   │   │   └── libqwebp.dylib
│   │   │   │   │   │   ├── platforms
│   │   │   │   │   │   │   ├── libqcocoa.dylib
│   │   │   │   │   │   │   ├── libqminimal.dylib
│   │   │   │   │   │   │   └── libqoffscreen.dylib
│   │   │   │   │   │   └── styles
│   │   │   │   │   │   └── libqmacstyle.dylib
│   │   │   │   │   └── translations
│   │   │   │   │   ├── qt_ar.qm
│   │   │   │   │   ├── qt_bg.qm
│   │   │   │   │   ├── qt_ca.qm
│   │   │   │   │   ├── qt_cs.qm
│   │   │   │   │   ├── qt_da.qm
│   │   │   │   │   ├── qt_de.qm
│   │   │   │   │   ├── qt_en.qm
│   │   │   │   │   ├── qt_es.qm
│   │   │   │   │   ├── qt_fa.qm
│   │   │   │   │   ├── qt_fi.qm
│   │   │   │   │   ├── qt_fr.qm
│   │   │   │   │   ├── qt_gd.qm
│   │   │   │   │   ├── qt_gl.qm
│   │   │   │   │   ├── qt_he.qm
│   │   │   │   │   ├── qt_help_ar.qm
│   │   │   │   │   ├── qt_help_bg.qm
│   │   │   │   │   ├── qt_help_ca.qm
│   │   │   │   │   ├── qt_help_cs.qm
│   │   │   │   │   ├── qt_help_da.qm
│   │   │   │   │   ├── qt_help_de.qm
│   │   │   │   │   ├── qt_help_en.qm
│   │   │   │   │   ├── qt_help_es.qm
│   │   │   │   │   ├── qt_help_fr.qm
│   │   │   │   │   ├── qt_help_gl.qm
│   │   │   │   │   ├── qt_help_hr.qm
│   │   │   │   │   ├── qt_help_hu.qm
│   │   │   │   │   ├── qt_help_it.qm
│   │   │   │   │   ├── qt_help_ja.qm
│   │   │   │   │   ├── qt_help_ka.qm
│   │   │   │   │   ├── qt_help_ko.qm
│   │   │   │   │   ├── qt_help_nl.qm
│   │   │   │   │   ├── qt_help_nn.qm
│   │   │   │   │   ├── qt_help_pl.qm
│   │   │   │   │   ├── qt_help_pt_BR.qm
│   │   │   │   │   ├── qt_help_ru.qm
│   │   │   │   │   ├── qt_help_sk.qm
│   │   │   │   │   ├── qt_help_sl.qm
│   │   │   │   │   ├── qt_help_sv.qm
│   │   │   │   │   ├── qt_help_tr.qm
│   │   │   │   │   ├── qt_help_uk.qm
│   │   │   │   │   ├── qt_help_zh_CN.qm
│   │   │   │   │   ├── qt_help_zh_TW.qm
│   │   │   │   │   ├── qt_hr.qm
│   │   │   │   │   ├── qt_hu.qm
│   │   │   │   │   ├── qt_it.qm
│   │   │   │   │   ├── qt_ja.qm
│   │   │   │   │   ├── qt_ka.qm
│   │   │   │   │   ├── qt_ko.qm
│   │   │   │   │   ├── qt_lg.qm
│   │   │   │   │   ├── qt_lt.qm
│   │   │   │   │   ├── qt_lv.qm
│   │   │   │   │   ├── qt_nl.qm
│   │   │   │   │   ├── qt_nn.qm
│   │   │   │   │   ├── qt_pl.qm
│   │   │   │   │   ├── qt_pt_BR.qm
│   │   │   │   │   ├── qt_pt_PT.qm
│   │   │   │   │   ├── qt_ru.qm
│   │   │   │   │   ├── qt_sk.qm
│   │   │   │   │   ├── qt_sl.qm
│   │   │   │   │   ├── qt_sv.qm
│   │   │   │   │   ├── qt_tr.qm
│   │   │   │   │   ├── qt_uk.qm
│   │   │   │   │   ├── qt_zh_CN.qm
│   │   │   │   │   ├── qt_zh_TW.qm
│   │   │   │   │   ├── qtbase_ar.qm
│   │   │   │   │   ├── qtbase_bg.qm
│   │   │   │   │   ├── qtbase_ca.qm
│   │   │   │   │   ├── qtbase_cs.qm
│   │   │   │   │   ├── qtbase_da.qm
│   │   │   │   │   ├── qtbase_de.qm
│   │   │   │   │   ├── qtbase_en.qm
│   │   │   │   │   ├── qtbase_es.qm
│   │   │   │   │   ├── qtbase_fa.qm
│   │   │   │   │   ├── qtbase_fi.qm
│   │   │   │   │   ├── qtbase_fr.qm
│   │   │   │   │   ├── qtbase_gd.qm
│   │   │   │   │   ├── qtbase_he.qm
│   │   │   │   │   ├── qtbase_hr.qm
│   │   │   │   │   ├── qtbase_hu.qm
│   │   │   │   │   ├── qtbase_it.qm
│   │   │   │   │   ├── qtbase_ja.qm
│   │   │   │   │   ├── qtbase_ka.qm
│   │   │   │   │   ├── qtbase_ko.qm
│   │   │   │   │   ├── qtbase_lg.qm
│   │   │   │   │   ├── qtbase_lv.qm
│   │   │   │   │   ├── qtbase_nl.qm
│   │   │   │   │   ├── qtbase_nn.qm
│   │   │   │   │   ├── qtbase_pl.qm
│   │   │   │   │   ├── qtbase_pt_BR.qm
│   │   │   │   │   ├── qtbase_ru.qm
│   │   │   │   │   ├── qtbase_sk.qm
│   │   │   │   │   ├── qtbase_sv.qm
│   │   │   │   │   ├── qtbase_tr.qm
│   │   │   │   │   ├── qtbase_uk.qm
│   │   │   │   │   ├── qtbase_zh_CN.qm
│   │   │   │   │   └── qtbase_zh_TW.qm
│   │   │   │   ├── QtCore.abi3.so
│   │   │   │   ├── QtDBus.abi3.so
│   │   │   │   ├── QtGui.abi3.so
│   │   │   │   ├── QtWidgets.abi3.so
│   │   │   │   └── sip.cpython-313-darwin.so
│   │   │   ├── QtCore -> PyQt6/Qt6/lib/QtCore.framework/Versions/A/QtCore
│   │   │   ├── QtDBus -> PyQt6/Qt6/lib/QtDBus.framework/Versions/A/QtDBus
│   │   │   ├── QtGui -> PyQt6/Qt6/lib/QtGui.framework/Versions/A/QtGui
│   │   │   ├── QtNetwork -> PyQt6/Qt6/lib/QtNetwork.framework/Versions/A/QtNetwork
│   │   │   ├── QtPdf -> PyQt6/Qt6/lib/QtPdf.framework/Versions/A/QtPdf
│   │   │   ├── QtSvg -> PyQt6/Qt6/lib/QtSvg.framework/Versions/A/QtSvg
│   │   │   ├── QtWidgets -> PyQt6/Qt6/lib/QtWidgets.framework/Versions/A/QtWidgets
│   │   │   ├── aiohttp
│   │   │   │   ├── _http_parser.cpython-313-darwin.so
│   │   │   │   ├── _http_writer.cpython-313-darwin.so
│   │   │   │   └── _websocket
│   │   │   │   ├── mask.cpython-313-darwin.so
│   │   │   │   └── reader_c.cpython-313-darwin.so
│   │   │   ├── attrs-26.1.0.dist-info
│   │   │   │   ├── INSTALLER
│   │   │   │   ├── METADATA
│   │   │   │   ├── RECORD
│   │   │   │   ├── REQUESTED
│   │   │   │   ├── WHEEL
│   │   │   │   └── licenses
│   │   │   │   └── LICENSE
│   │   │   ├── base_library.zip
│   │   │   ├── certifi
│   │   │   │   ├── cacert.pem
│   │   │   │   └── py.typed
│   │   │   ├── charset_normalizer
│   │   │   │   ├── cd.cpython-313-darwin.so
│   │   │   │   └── md.cpython-313-darwin.so
│   │   │   ├── frozenlist
│   │   │   │   └── _frozenlist.cpython-313-darwin.so
│   │   │   ├── libXau.6.dylib -> PIL/.dylibs/libXau.6.dylib
│   │   │   ├── libavif.16.4.1.dylib -> PIL/.dylibs/libavif.16.4.1.dylib
│   │   │   ├── libbrotlicommon.1.2.0.dylib -> PIL/.dylibs/libbrotlicommon.1.2.0.dylib
│   │   │   ├── libbrotlidec.1.2.0.dylib -> PIL/.dylibs/libbrotlidec.1.2.0.dylib
│   │   │   ├── libfreetype.6.dylib -> PIL/.dylibs/libfreetype.6.dylib
│   │   │   ├── libharfbuzz.0.dylib -> PIL/.dylibs/libharfbuzz.0.dylib
│   │   │   ├── libjpeg.62.4.0.dylib -> PIL/.dylibs/libjpeg.62.4.0.dylib
│   │   │   ├── liblcms2.2.dylib -> PIL/.dylibs/liblcms2.2.dylib
│   │   │   ├── liblzma.5.dylib -> PIL/.dylibs/liblzma.5.dylib
│   │   │   ├── libopenjp2.2.5.4.dylib -> PIL/.dylibs/libopenjp2.2.5.4.dylib
│   │   │   ├── libpng16.16.dylib -> PIL/.dylibs/libpng16.16.dylib
│   │   │   ├── libpython3.13.dylib
│   │   │   ├── libsharpyuv.0.dylib -> PIL/.dylibs/libsharpyuv.0.dylib
│   │   │   ├── libtiff.6.dylib -> PIL/.dylibs/libtiff.6.dylib
│   │   │   ├── libwebp.7.dylib -> PIL/.dylibs/libwebp.7.dylib
│   │   │   ├── libwebpdemux.2.dylib -> PIL/.dylibs/libwebpdemux.2.dylib
│   │   │   ├── libwebpmux.3.dylib -> PIL/.dylibs/libwebpmux.3.dylib
│   │   │   ├── libxcb.1.1.0.dylib -> PIL/.dylibs/libxcb.1.1.0.dylib
│   │   │   ├── libz.1.3.1.zlib-ng.dylib -> PIL/.dylibs/libz.1.3.1.zlib-ng.dylib
│   │   │   ├── lxml
│   │   │   │   ├── _elementpath.cpython-313-darwin.so
│   │   │   │   ├── builder.cpython-313-darwin.so
│   │   │   │   ├── etree.cpython-313-darwin.so
│   │   │   │   ├── html
│   │   │   │   │   ├── _difflib.cpython-313-darwin.so
│   │   │   │   │   └── diff.cpython-313-darwin.so
│   │   │   │   ├── isoschematron
│   │   │   │   │   └── resources
│   │   │   │   │   ├── rng
│   │   │   │   │   │   └── iso-schematron.rng
│   │   │   │   │   └── xsl
│   │   │   │   │   ├── RNG2Schtrn.xsl
│   │   │   │   │   ├── XSD2Schtrn.xsl
│   │   │   │   │   └── iso-schematron-xslt1
│   │   │   │   │   ├── iso_abstract_expand.xsl
│   │   │   │   │   ├── iso_dsdl_include.xsl
│   │   │   │   │   ├── iso_schematron_message.xsl
│   │   │   │   │   ├── iso_schematron_skeleton_for_xslt1.xsl
│   │   │   │   │   ├── iso_svrl_for_xslt1.xsl
│   │   │   │   │   └── readme.txt
│   │   │   │   ├── objectify.cpython-313-darwin.so
│   │   │   │   └── sax.cpython-313-darwin.so
│   │   │   ├── multidict
│   │   │   │   └── _multidict.cpython-313-darwin.so
│   │   │   ├── numpy
│   │   │   │   ├── _core
│   │   │   │   │   ├── _multiarray_tests.cpython-313-darwin.so
│   │   │   │   │   └── _multiarray_umath.cpython-313-darwin.so
│   │   │   │   ├── fft
│   │   │   │   │   └── _pocketfft_umath.cpython-313-darwin.so
│   │   │   │   ├── linalg
│   │   │   │   │   └── _umath_linalg.cpython-313-darwin.so
│   │   │   │   └── random
│   │   │   │   ├── _bounded_integers.cpython-313-darwin.so
│   │   │   │   ├── _common.cpython-313-darwin.so
│   │   │   │   ├── _generator.cpython-313-darwin.so
│   │   │   │   ├── _mt19937.cpython-313-darwin.so
│   │   │   │   ├── _pcg64.cpython-313-darwin.so
│   │   │   │   ├── _philox.cpython-313-darwin.so
│   │   │   │   ├── _sfc64.cpython-313-darwin.so
│   │   │   │   ├── bit_generator.cpython-313-darwin.so
│   │   │   │   └── mtrand.cpython-313-darwin.so
│   │   │   ├── numpy-2.4.6.dist-info
│   │   │   │   ├── INSTALLER
│   │   │   │   ├── METADATA
│   │   │   │   ├── RECORD
│   │   │   │   ├── REQUESTED
│   │   │   │   ├── WHEEL
│   │   │   │   ├── entry_points.txt
│   │   │   │   └── licenses
│   │   │   │   ├── LICENSE.txt
│   │   │   │   └── numpy
│   │   │   │   ├── _core
│   │   │   │   │   ├── include
│   │   │   │   │   │   └── numpy
│   │   │   │   │   │   └── libdivide
│   │   │   │   │   │   └── LICENSE.txt
│   │   │   │   │   └── src
│   │   │   │   │   ├── common
│   │   │   │   │   │   └── pythoncapi-compat
│   │   │   │   │   │   └── COPYING
│   │   │   │   │   ├── highway
│   │   │   │   │   │   └── LICENSE
│   │   │   │   │   ├── multiarray
│   │   │   │   │   │   └── dragon4_LICENSE.txt
│   │   │   │   │   ├── npysort
│   │   │   │   │   │   └── x86-simd-sort
│   │   │   │   │   │   └── LICENSE.md
│   │   │   │   │   └── umath
│   │   │   │   │   └── svml
│   │   │   │   │   └── LICENSE
│   │   │   │   ├── fft
│   │   │   │   │   └── pocketfft
│   │   │   │   │   └── LICENSE.md
│   │   │   │   ├── linalg
│   │   │   │   │   └── lapack_lite
│   │   │   │   │   └── LICENSE.txt
│   │   │   │   ├── ma
│   │   │   │   │   └── LICENSE
│   │   │   │   └── random
│   │   │   │   ├── LICENSE.md
│   │   │   │   └── src
│   │   │   │   ├── distributions
│   │   │   │   │   └── LICENSE.md
│   │   │   │   ├── mt19937
│   │   │   │   │   └── LICENSE.md
│   │   │   │   ├── pcg64
│   │   │   │   │   └── LICENSE.md
│   │   │   │   ├── philox
│   │   │   │   │   └── LICENSE.md
│   │   │   │   ├── sfc64
│   │   │   │   │   └── LICENSE.md
│   │   │   │   └── splitmix64
│   │   │   │   └── LICENSE.md
│   │   │   ├── propcache
│   │   │   │   └── _helpers_c.cpython-313-darwin.so
│   │   │   ├── psutil
│   │   │   │   └── _psutil_osx.abi3.so
│   │   │   ├── pydantic-2.13.4.dist-info
│   │   │   │   ├── INSTALLER
│   │   │   │   ├── METADATA
│   │   │   │   ├── RECORD
│   │   │   │   ├── REQUESTED
│   │   │   │   ├── WHEEL
│   │   │   │   └── licenses
│   │   │   │   └── LICENSE
│   │   │   ├── pydantic_core
│   │   │   │   └── _pydantic_core.cpython-313-darwin.so
│   │   │   ├── setuptools
│   │   │   │   └── _vendor
│   │   │   │   ├── importlib_metadata-8.7.1.dist-info
│   │   │   │   │   ├── INSTALLER
│   │   │   │   │   ├── METADATA
│   │   │   │   │   ├── RECORD
│   │   │   │   │   ├── REQUESTED
│   │   │   │   │   ├── WHEEL
│   │   │   │   │   ├── licenses
│   │   │   │   │   │   └── LICENSE
│   │   │   │   │   └── top_level.txt
│   │   │   │   └── jaraco
│   │   │   │   └── text
│   │   │   │   └── Lorem ipsum.txt
│   │   │   ├── yaml
│   │   │   │   └── _yaml.cpython-313-darwin.so
│   │   │   └── yarl
│   │   │   └── _quoting_c.cpython-313-darwin.so
│   │   └── main
│   └── main.app
│   └── Contents
│   ├── Frameworks
│   │   ├── 81d243bd2c585b0f4821__mypyc.cpython-313-darwin.so
│   │   ├── PIL
│   │   │   ├── __dot__dylibs
│   │   │   │   ├── libXau.6.dylib
│   │   │   │   ├── libavif.16.4.1.dylib
│   │   │   │   ├── libbrotlicommon.1.2.0.dylib
│   │   │   │   ├── libbrotlidec.1.2.0.dylib
│   │   │   │   ├── libfreetype.6.dylib
│   │   │   │   ├── libharfbuzz.0.dylib
│   │   │   │   ├── libjpeg.62.4.0.dylib
│   │   │   │   ├── liblcms2.2.dylib
│   │   │   │   ├── liblzma.5.dylib
│   │   │   │   ├── libopenjp2.2.5.4.dylib
│   │   │   │   ├── libpng16.16.dylib
│   │   │   │   ├── libsharpyuv.0.dylib
│   │   │   │   ├── libtiff.6.dylib
│   │   │   │   ├── libwebp.7.dylib
│   │   │   │   ├── libwebpdemux.2.dylib
│   │   │   │   ├── libwebpmux.3.dylib
│   │   │   │   ├── libxcb.1.1.0.dylib
│   │   │   │   └── libz.1.3.1.zlib-ng.dylib
│   │   │   ├── _avif.cpython-313-darwin.so
│   │   │   ├── _imaging.cpython-313-darwin.so
│   │   │   ├── _imagingcms.cpython-313-darwin.so
│   │   │   ├── _imagingft.cpython-313-darwin.so
│   │   │   ├── _imagingmath.cpython-313-darwin.so
│   │   │   ├── _imagingtk.cpython-313-darwin.so
│   │   │   └── _webp.cpython-313-darwin.so
│   │   ├── PyQt6
│   │   │   ├── Qt6
│   │   │   │   ├── lib
│   │   │   │   │   ├── QtCore.framework
│   │   │   │   │   │   ├── QtCore -> Versions/Current/QtCore
│   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   └── Versions
│   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   ├── QtCore
│   │   │   │   │   │   │   ├── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── _CodeSignature
│   │   │   │   │   │   │   └── CodeResources
│   │   │   │   │   │   └── Current -> A
│   │   │   │   │   ├── QtDBus.framework
│   │   │   │   │   │   ├── QtDBus -> Versions/Current/QtDBus
│   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   └── Versions
│   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   ├── QtDBus
│   │   │   │   │   │   │   ├── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── _CodeSignature
│   │   │   │   │   │   │   └── CodeResources
│   │   │   │   │   │   └── Current -> A
│   │   │   │   │   ├── QtGui.framework
│   │   │   │   │   │   ├── QtGui -> Versions/Current/QtGui
│   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   └── Versions
│   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   ├── QtGui
│   │   │   │   │   │   │   ├── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── _CodeSignature
│   │   │   │   │   │   │   └── CodeResources
│   │   │   │   │   │   └── Current -> A
│   │   │   │   │   ├── QtNetwork.framework
│   │   │   │   │   │   ├── QtNetwork -> Versions/Current/QtNetwork
│   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   └── Versions
│   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   ├── QtNetwork
│   │   │   │   │   │   │   ├── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── _CodeSignature
│   │   │   │   │   │   │   └── CodeResources
│   │   │   │   │   │   └── Current -> A
│   │   │   │   │   ├── QtPdf.framework
│   │   │   │   │   │   ├── QtPdf -> Versions/Current/QtPdf
│   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   └── Versions
│   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   ├── QtPdf
│   │   │   │   │   │   │   ├── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── _CodeSignature
│   │   │   │   │   │   │   └── CodeResources
│   │   │   │   │   │   └── Current -> A
│   │   │   │   │   ├── QtSvg.framework
│   │   │   │   │   │   ├── QtSvg -> Versions/Current/QtSvg
│   │   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   │   └── Versions
│   │   │   │   │   │   ├── A
│   │   │   │   │   │   │   ├── QtSvg
│   │   │   │   │   │   │   ├── Resources
│   │   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   │   └── _CodeSignature
│   │   │   │   │   │   │   └── CodeResources
│   │   │   │   │   │   └── Current -> A
│   │   │   │   │   └── QtWidgets.framework
│   │   │   │   │   ├── QtWidgets -> Versions/Current/QtWidgets
│   │   │   │   │   ├── Resources -> Versions/Current/Resources
│   │   │   │   │   └── Versions
│   │   │   │   │   ├── A
│   │   │   │   │   │   ├── QtWidgets
│   │   │   │   │   │   ├── Resources
│   │   │   │   │   │   │   └── Info.plist
│   │   │   │   │   │   └── _CodeSignature
│   │   │   │   │   │   └── CodeResources
│   │   │   │   │   └── Current -> A
│   │   │   │   ├── plugins
│   │   │   │   │   ├── generic
│   │   │   │   │   │   └── libqtuiotouchplugin.dylib
│   │   │   │   │   ├── iconengines
│   │   │   │   │   │   └── libqsvgicon.dylib
│   │   │   │   │   ├── imageformats
│   │   │   │   │   │   ├── libqgif.dylib
│   │   │   │   │   │   ├── libqicns.dylib
│   │   │   │   │   │   ├── libqico.dylib
│   │   │   │   │   │   ├── libqjpeg.dylib
│   │   │   │   │   │   ├── libqmacheif.dylib
│   │   │   │   │   │   ├── libqmacjp2.dylib
│   │   │   │   │   │   ├── libqpdf.dylib
│   │   │   │   │   │   ├── libqsvg.dylib
│   │   │   │   │   │   ├── libqtga.dylib
│   │   │   │   │   │   ├── libqtiff.dylib
│   │   │   │   │   │   ├── libqwbmp.dylib
│   │   │   │   │   │   └── libqwebp.dylib
│   │   │   │   │   ├── platforms
│   │   │   │   │   │   ├── libqcocoa.dylib
│   │   │   │   │   │   ├── libqminimal.dylib
│   │   │   │   │   │   └── libqoffscreen.dylib
│   │   │   │   │   └── styles
│   │   │   │   │   └── libqmacstyle.dylib
│   │   │   │   └── translations -> ../../../Resources/PyQt6/Qt6/translations
│   │   │   ├── QtCore.abi3.so
│   │   │   ├── QtDBus.abi3.so
│   │   │   ├── QtGui.abi3.so
│   │   │   ├── QtWidgets.abi3.so
│   │   │   └── sip.cpython-313-darwin.so
│   │   ├── QtCore -> PyQt6/Qt6/lib/QtCore.framework/Versions/A/QtCore
│   │   ├── QtDBus -> PyQt6/Qt6/lib/QtDBus.framework/Versions/A/QtDBus
│   │   ├── QtGui -> PyQt6/Qt6/lib/QtGui.framework/Versions/A/QtGui
│   │   ├── QtNetwork -> PyQt6/Qt6/lib/QtNetwork.framework/Versions/A/QtNetwork
│   │   ├── QtPdf -> PyQt6/Qt6/lib/QtPdf.framework/Versions/A/QtPdf
│   │   ├── QtSvg -> PyQt6/Qt6/lib/QtSvg.framework/Versions/A/QtSvg
│   │   ├── QtWidgets -> PyQt6/Qt6/lib/QtWidgets.framework/Versions/A/QtWidgets
│   │   ├── aiohttp
│   │   │   ├── _http_parser.cpython-313-darwin.so
│   │   │   ├── _http_writer.cpython-313-darwin.so
│   │   │   └── _websocket
│   │   │   ├── mask.cpython-313-darwin.so
│   │   │   └── reader_c.cpython-313-darwin.so
│   │   ├── attrs-26.1.0.dist-info -> ../Resources/attrs-26.1.0.dist-info
│   │   ├── base_library.zip -> ../Resources/base_library.zip
│   │   ├── certifi -> ../Resources/certifi
│   │   ├── charset_normalizer
│   │   │   ├── cd.cpython-313-darwin.so
│   │   │   └── md.cpython-313-darwin.so
│   │   ├── frozenlist
│   │   │   └── _frozenlist.cpython-313-darwin.so
│   │   ├── libXau.6.dylib -> PIL/.dylibs/libXau.6.dylib
│   │   ├── libavif.16.4.1.dylib -> PIL/.dylibs/libavif.16.4.1.dylib
│   │   ├── libbrotlicommon.1.2.0.dylib -> PIL/.dylibs/libbrotlicommon.1.2.0.dylib
│   │   ├── libbrotlidec.1.2.0.dylib -> PIL/.dylibs/libbrotlidec.1.2.0.dylib
│   │   ├── libfreetype.6.dylib -> PIL/.dylibs/libfreetype.6.dylib
│   │   ├── libharfbuzz.0.dylib -> PIL/.dylibs/libharfbuzz.0.dylib
│   │   ├── libjpeg.62.4.0.dylib -> PIL/.dylibs/libjpeg.62.4.0.dylib
│   │   ├── liblcms2.2.dylib -> PIL/.dylibs/liblcms2.2.dylib
│   │   ├── liblzma.5.dylib -> PIL/.dylibs/liblzma.5.dylib
│   │   ├── libopenjp2.2.5.4.dylib -> PIL/.dylibs/libopenjp2.2.5.4.dylib
│   │   ├── libpng16.16.dylib -> PIL/.dylibs/libpng16.16.dylib
│   │   ├── libpython3.13.dylib
│   │   ├── libsharpyuv.0.dylib -> PIL/.dylibs/libsharpyuv.0.dylib
│   │   ├── libtiff.6.dylib -> PIL/.dylibs/libtiff.6.dylib
│   │   ├── libwebp.7.dylib -> PIL/.dylibs/libwebp.7.dylib
│   │   ├── libwebpdemux.2.dylib -> PIL/.dylibs/libwebpdemux.2.dylib
│   │   ├── libwebpmux.3.dylib -> PIL/.dylibs/libwebpmux.3.dylib
│   │   ├── libxcb.1.1.0.dylib -> PIL/.dylibs/libxcb.1.1.0.dylib
│   │   ├── libz.1.3.1.zlib-ng.dylib -> PIL/.dylibs/libz.1.3.1.zlib-ng.dylib
│   │   ├── lxml
│   │   │   ├── _elementpath.cpython-313-darwin.so
│   │   │   ├── builder.cpython-313-darwin.so
│   │   │   ├── etree.cpython-313-darwin.so
│   │   │   ├── html
│   │   │   │   ├── _difflib.cpython-313-darwin.so
│   │   │   │   └── diff.cpython-313-darwin.so
│   │   │   ├── isoschematron -> ../../Resources/lxml/isoschematron
│   │   │   ├── objectify.cpython-313-darwin.so
│   │   │   └── sax.cpython-313-darwin.so
│   │   ├── multidict
│   │   │   └── _multidict.cpython-313-darwin.so
│   │   ├── numpy
│   │   │   ├── _core
│   │   │   │   ├── _multiarray_tests.cpython-313-darwin.so
│   │   │   │   └── _multiarray_umath.cpython-313-darwin.so
│   │   │   ├── fft
│   │   │   │   └── _pocketfft_umath.cpython-313-darwin.so
│   │   │   ├── linalg
│   │   │   │   └── _umath_linalg.cpython-313-darwin.so
│   │   │   └── random
│   │   │   ├── _bounded_integers.cpython-313-darwin.so
│   │   │   ├── _common.cpython-313-darwin.so
│   │   │   ├── _generator.cpython-313-darwin.so
│   │   │   ├── _mt19937.cpython-313-darwin.so
│   │   │   ├── _pcg64.cpython-313-darwin.so
│   │   │   ├── _philox.cpython-313-darwin.so
│   │   │   ├── _sfc64.cpython-313-darwin.so
│   │   │   ├── bit_generator.cpython-313-darwin.so
│   │   │   └── mtrand.cpython-313-darwin.so
│   │   ├── numpy-2.4.6.dist-info -> ../Resources/numpy-2.4.6.dist-info
│   │   ├── propcache
│   │   │   └── _helpers_c.cpython-313-darwin.so
│   │   ├── psutil
│   │   │   └── _psutil_osx.abi3.so
│   │   ├── pydantic-2.13.4.dist-info -> ../Resources/pydantic-2.13.4.dist-info
│   │   ├── pydantic_core
│   │   │   └── _pydantic_core.cpython-313-darwin.so
│   │   ├── setuptools -> ../Resources/setuptools
│   │   ├── yaml
│   │   │   └── _yaml.cpython-313-darwin.so
│   │   └── yarl
│   │   └── _quoting_c.cpython-313-darwin.so
│   ├── Info.plist
│   ├── MacOS
│   │   └── main
│   ├── Resources
│   │   ├── 81d243bd2c585b0f4821__mypyc.cpython-313-darwin.so -> ../Frameworks/81d243bd2c585b0f4821__mypyc.cpython-313-darwin.so
│   │   ├── PIL -> ../Frameworks/PIL
│   │   ├── PyQt6
│   │   │   ├── Qt6
│   │   │   │   ├── lib -> ../../../Frameworks/PyQt6/Qt6/lib
│   │   │   │   ├── plugins -> ../../../Frameworks/PyQt6/Qt6/plugins
│   │   │   │   └── translations
│   │   │   │   ├── qt_ar.qm
│   │   │   │   ├── qt_bg.qm
│   │   │   │   ├── qt_ca.qm
│   │   │   │   ├── qt_cs.qm
│   │   │   │   ├── qt_da.qm
│   │   │   │   ├── qt_de.qm
│   │   │   │   ├── qt_en.qm
│   │   │   │   ├── qt_es.qm
│   │   │   │   ├── qt_fa.qm
│   │   │   │   ├── qt_fi.qm
│   │   │   │   ├── qt_fr.qm
│   │   │   │   ├── qt_gd.qm
│   │   │   │   ├── qt_gl.qm
│   │   │   │   ├── qt_he.qm
│   │   │   │   ├── qt_help_ar.qm
│   │   │   │   ├── qt_help_bg.qm
│   │   │   │   ├── qt_help_ca.qm
│   │   │   │   ├── qt_help_cs.qm
│   │   │   │   ├── qt_help_da.qm
│   │   │   │   ├── qt_help_de.qm
│   │   │   │   ├── qt_help_en.qm
│   │   │   │   ├── qt_help_es.qm
│   │   │   │   ├── qt_help_fr.qm
│   │   │   │   ├── qt_help_gl.qm
│   │   │   │   ├── qt_help_hr.qm
│   │   │   │   ├── qt_help_hu.qm
│   │   │   │   ├── qt_help_it.qm
│   │   │   │   ├── qt_help_ja.qm
│   │   │   │   ├── qt_help_ka.qm
│   │   │   │   ├── qt_help_ko.qm
│   │   │   │   ├── qt_help_nl.qm
│   │   │   │   ├── qt_help_nn.qm
│   │   │   │   ├── qt_help_pl.qm
│   │   │   │   ├── qt_help_pt_BR.qm
│   │   │   │   ├── qt_help_ru.qm
│   │   │   │   ├── qt_help_sk.qm
│   │   │   │   ├── qt_help_sl.qm
│   │   │   │   ├── qt_help_sv.qm
│   │   │   │   ├── qt_help_tr.qm
│   │   │   │   ├── qt_help_uk.qm
│   │   │   │   ├── qt_help_zh_CN.qm
│   │   │   │   ├── qt_help_zh_TW.qm
│   │   │   │   ├── qt_hr.qm
│   │   │   │   ├── qt_hu.qm
│   │   │   │   ├── qt_it.qm
│   │   │   │   ├── qt_ja.qm
│   │   │   │   ├── qt_ka.qm
│   │   │   │   ├── qt_ko.qm
│   │   │   │   ├── qt_lg.qm
│   │   │   │   ├── qt_lt.qm
│   │   │   │   ├── qt_lv.qm
│   │   │   │   ├── qt_nl.qm
│   │   │   │   ├── qt_nn.qm
│   │   │   │   ├── qt_pl.qm
│   │   │   │   ├── qt_pt_BR.qm
│   │   │   │   ├── qt_pt_PT.qm
│   │   │   │   ├── qt_ru.qm
│   │   │   │   ├── qt_sk.qm
│   │   │   │   ├── qt_sl.qm
│   │   │   │   ├── qt_sv.qm
│   │   │   │   ├── qt_tr.qm
│   │   │   │   ├── qt_uk.qm
│   │   │   │   ├── qt_zh_CN.qm
│   │   │   │   ├── qt_zh_TW.qm
│   │   │   │   ├── qtbase_ar.qm
│   │   │   │   ├── qtbase_bg.qm
│   │   │   │   ├── qtbase_ca.qm
│   │   │   │   ├── qtbase_cs.qm
│   │   │   │   ├── qtbase_da.qm
│   │   │   │   ├── qtbase_de.qm
│   │   │   │   ├── qtbase_en.qm
│   │   │   │   ├── qtbase_es.qm
│   │   │   │   ├── qtbase_fa.qm
│   │   │   │   ├── qtbase_fi.qm
│   │   │   │   ├── qtbase_fr.qm
│   │   │   │   ├── qtbase_gd.qm
│   │   │   │   ├── qtbase_he.qm
│   │   │   │   ├── qtbase_hr.qm
│   │   │   │   ├── qtbase_hu.qm
│   │   │   │   ├── qtbase_it.qm
│   │   │   │   ├── qtbase_ja.qm
│   │   │   │   ├── qtbase_ka.qm
│   │   │   │   ├── qtbase_ko.qm
│   │   │   │   ├── qtbase_lg.qm
│   │   │   │   ├── qtbase_lv.qm
│   │   │   │   ├── qtbase_nl.qm
│   │   │   │   ├── qtbase_nn.qm
│   │   │   │   ├── qtbase_pl.qm
│   │   │   │   ├── qtbase_pt_BR.qm
│   │   │   │   ├── qtbase_ru.qm
│   │   │   │   ├── qtbase_sk.qm
│   │   │   │   ├── qtbase_sv.qm
│   │   │   │   ├── qtbase_tr.qm
│   │   │   │   ├── qtbase_uk.qm
│   │   │   │   ├── qtbase_zh_CN.qm
│   │   │   │   └── qtbase_zh_TW.qm
│   │   │   ├── QtCore.abi3.so -> ../../Frameworks/PyQt6/QtCore.abi3.so
│   │   │   ├── QtDBus.abi3.so -> ../../Frameworks/PyQt6/QtDBus.abi3.so
│   │   │   ├── QtGui.abi3.so -> ../../Frameworks/PyQt6/QtGui.abi3.so
│   │   │   ├── QtWidgets.abi3.so -> ../../Frameworks/PyQt6/QtWidgets.abi3.so
│   │   │   └── sip.cpython-313-darwin.so -> ../../Frameworks/PyQt6/sip.cpython-313-darwin.so
│   │   ├── QtCore -> PyQt6/Qt6/lib/QtCore.framework/Versions/A/QtCore
│   │   ├── QtDBus -> PyQt6/Qt6/lib/QtDBus.framework/Versions/A/QtDBus
│   │   ├── QtGui -> PyQt6/Qt6/lib/QtGui.framework/Versions/A/QtGui
│   │   ├── QtNetwork -> PyQt6/Qt6/lib/QtNetwork.framework/Versions/A/QtNetwork
│   │   ├── QtPdf -> PyQt6/Qt6/lib/QtPdf.framework/Versions/A/QtPdf
│   │   ├── QtSvg -> PyQt6/Qt6/lib/QtSvg.framework/Versions/A/QtSvg
│   │   ├── QtWidgets -> PyQt6/Qt6/lib/QtWidgets.framework/Versions/A/QtWidgets
│   │   ├── aiohttp -> ../Frameworks/aiohttp
│   │   ├── app.icns
│   │   ├── attrs-26.1.0.dist-info
│   │   │   ├── INSTALLER
│   │   │   ├── METADATA
│   │   │   ├── RECORD
│   │   │   ├── REQUESTED
│   │   │   ├── WHEEL
│   │   │   └── licenses
│   │   │   └── LICENSE
│   │   ├── base_library.zip
│   │   ├── certifi
│   │   │   ├── cacert.pem
│   │   │   └── py.typed
│   │   ├── charset_normalizer -> ../Frameworks/charset_normalizer
│   │   ├── frozenlist -> ../Frameworks/frozenlist
│   │   ├── libXau.6.dylib -> PIL/.dylibs/libXau.6.dylib
│   │   ├── libavif.16.4.1.dylib -> PIL/.dylibs/libavif.16.4.1.dylib
│   │   ├── libbrotlicommon.1.2.0.dylib -> PIL/.dylibs/libbrotlicommon.1.2.0.dylib
│   │   ├── libbrotlidec.1.2.0.dylib -> PIL/.dylibs/libbrotlidec.1.2.0.dylib
│   │   ├── libfreetype.6.dylib -> PIL/.dylibs/libfreetype.6.dylib
│   │   ├── libharfbuzz.0.dylib -> PIL/.dylibs/libharfbuzz.0.dylib
│   │   ├── libjpeg.62.4.0.dylib -> PIL/.dylibs/libjpeg.62.4.0.dylib
│   │   ├── liblcms2.2.dylib -> PIL/.dylibs/liblcms2.2.dylib
│   │   ├── liblzma.5.dylib -> PIL/.dylibs/liblzma.5.dylib
│   │   ├── libopenjp2.2.5.4.dylib -> PIL/.dylibs/libopenjp2.2.5.4.dylib
│   │   ├── libpng16.16.dylib -> PIL/.dylibs/libpng16.16.dylib
│   │   ├── libpython3.13.dylib -> ../Frameworks/libpython3.13.dylib
│   │   ├── libsharpyuv.0.dylib -> PIL/.dylibs/libsharpyuv.0.dylib
│   │   ├── libtiff.6.dylib -> PIL/.dylibs/libtiff.6.dylib
│   │   ├── libwebp.7.dylib -> PIL/.dylibs/libwebp.7.dylib
│   │   ├── libwebpdemux.2.dylib -> PIL/.dylibs/libwebpdemux.2.dylib
│   │   ├── libwebpmux.3.dylib -> PIL/.dylibs/libwebpmux.3.dylib
│   │   ├── libxcb.1.1.0.dylib -> PIL/.dylibs/libxcb.1.1.0.dylib
│   │   ├── libz.1.3.1.zlib-ng.dylib -> PIL/.dylibs/libz.1.3.1.zlib-ng.dylib
│   │   ├── lxml
│   │   │   ├── _elementpath.cpython-313-darwin.so -> ../../Frameworks/lxml/_elementpath.cpython-313-darwin.so
│   │   │   ├── builder.cpython-313-darwin.so -> ../../Frameworks/lxml/builder.cpython-313-darwin.so
│   │   │   ├── etree.cpython-313-darwin.so -> ../../Frameworks/lxml/etree.cpython-313-darwin.so
│   │   │   ├── html -> ../../Frameworks/lxml/html
│   │   │   ├── isoschematron
│   │   │   │   └── resources
│   │   │   │   ├── rng
│   │   │   │   │   └── iso-schematron.rng
│   │   │   │   └── xsl
│   │   │   │   ├── RNG2Schtrn.xsl
│   │   │   │   ├── XSD2Schtrn.xsl
│   │   │   │   └── iso-schematron-xslt1
│   │   │   │   ├── iso_abstract_expand.xsl
│   │   │   │   ├── iso_dsdl_include.xsl
│   │   │   │   ├── iso_schematron_message.xsl
│   │   │   │   ├── iso_schematron_skeleton_for_xslt1.xsl
│   │   │   │   ├── iso_svrl_for_xslt1.xsl
│   │   │   │   └── readme.txt
│   │   │   ├── objectify.cpython-313-darwin.so -> ../../Frameworks/lxml/objectify.cpython-313-darwin.so
│   │   │   └── sax.cpython-313-darwin.so -> ../../Frameworks/lxml/sax.cpython-313-darwin.so
│   │   ├── multidict -> ../Frameworks/multidict
│   │   ├── numpy -> ../Frameworks/numpy
│   │   ├── numpy-2.4.6.dist-info
│   │   │   ├── INSTALLER
│   │   │   ├── METADATA
│   │   │   ├── RECORD
│   │   │   ├── REQUESTED
│   │   │   ├── WHEEL
│   │   │   ├── entry_points.txt
│   │   │   └── licenses
│   │   │   ├── LICENSE.txt
│   │   │   └── numpy
│   │   │   ├── _core
│   │   │   │   ├── include
│   │   │   │   │   └── numpy
│   │   │   │   │   └── libdivide
│   │   │   │   │   └── LICENSE.txt
│   │   │   │   └── src
│   │   │   │   ├── common
│   │   │   │   │   └── pythoncapi-compat
│   │   │   │   │   └── COPYING
│   │   │   │   ├── highway
│   │   │   │   │   └── LICENSE
│   │   │   │   ├── multiarray
│   │   │   │   │   └── dragon4_LICENSE.txt
│   │   │   │   ├── npysort
│   │   │   │   │   └── x86-simd-sort
│   │   │   │   │   └── LICENSE.md
│   │   │   │   └── umath
│   │   │   │   └── svml
│   │   │   │   └── LICENSE
│   │   │   ├── fft
│   │   │   │   └── pocketfft
│   │   │   │   └── LICENSE.md
│   │   │   ├── linalg
│   │   │   │   └── lapack_lite
│   │   │   │   └── LICENSE.txt
│   │   │   ├── ma
│   │   │   │   └── LICENSE
│   │   │   └── random
│   │   │   ├── LICENSE.md
│   │   │   └── src
│   │   │   ├── distributions
│   │   │   │   └── LICENSE.md
│   │   │   ├── mt19937
│   │   │   │   └── LICENSE.md
│   │   │   ├── pcg64
│   │   │   │   └── LICENSE.md
│   │   │   ├── philox
│   │   │   │   └── LICENSE.md
│   │   │   ├── sfc64
│   │   │   │   └── LICENSE.md
│   │   │   └── splitmix64
│   │   │   └── LICENSE.md
│   │   ├── propcache -> ../Frameworks/propcache
│   │   ├── psutil -> ../Frameworks/psutil
│   │   ├── pydantic-2.13.4.dist-info
│   │   │   ├── INSTALLER
│   │   │   ├── METADATA
│   │   │   ├── RECORD
│   │   │   ├── REQUESTED
│   │   │   ├── WHEEL
│   │   │   └── licenses
│   │   │   └── LICENSE
│   │   ├── pydantic_core -> ../Frameworks/pydantic_core
│   │   ├── setuptools
│   │   │   └── _vendor
│   │   │   ├── importlib_metadata-8.7.1.dist-info
│   │   │   │   ├── INSTALLER
│   │   │   │   ├── METADATA
│   │   │   │   ├── RECORD
│   │   │   │   ├── REQUESTED
│   │   │   │   ├── WHEEL
│   │   │   │   ├── licenses
│   │   │   │   │   └── LICENSE
│   │   │   │   └── top_level.txt
│   │   │   └── jaraco
│   │   │   └── text
│   │   │   └── Lorem ipsum.txt
│   │   ├── yaml -> ../Frameworks/yaml
│   │   └── yarl -> ../Frameworks/yarl
│   └── _CodeSignature
│   └── CodeResources
├── doc
│   ├── Notes.md
│   ├── Notes.pdf
│   └── images
│   ├── image-01.png
│   ├── image-02.png
│   └── image-03.png
├── 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
│   ├── __pycache__
│   │   ├── __init__.cpython-313.pyc
│   │   ├── review_tab.cpython-313.pyc
│   │   ├── sandbox_tab.cpython-313.pyc
│   │   └── settings_tab.cpython-313.pyc
│   ├── review_tab.py
│   ├── sandbox_tab.py
│   └── settings_tab.py
├── test_dual.py
├── test_tts.py
├── tree.txt
├── tts_utils.py
├── ui
│   ├── __init__.py
│   ├── cli
│   │   └── interface.py
│   └── gui
│   ├── create_mode.py
│   ├── interface.py
│   └── trainer_mode.py
├── uv.lock
└── video_generator.py
289 directories, 690 files

View file

@ -3,24 +3,19 @@ import re
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
def get_configured_tts_rate(settings: dict) -> str: def get_configured_tts_rate(settings: dict) -> float:
"""Extracts speed multiplier from settings dict and converts to Edge TTS percentage string (e.g., '-25%').""" """Extracts speed multiplier from settings dict as a clean float (e.g., 0.75) for Kokoro TTS."""
# 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 raw_val = settings.get("tts_playback_speed", 1.0) if settings else 1.0
try: try:
raw_rate = float(raw_val) speed_rate = float(raw_val)
except (TypeError, ValueError): except (TypeError, ValueError):
raw_rate = 1.0 speed_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( print(
f"[TTS Debug] DB Key 'tts_playback_speed': {raw_val} -> Formatted Rate: {rate_str}" f"[TTS Debug] DB Key 'tts_playback_speed': {raw_val} -> Kokoro Speed Multiplier: {speed_rate}"
) )
return rate_str return speed_rate
def parse_text_for_edgetts(html_content: str) -> str: def parse_text_for_edgetts(html_content: str) -> str: