From a2ddf4c7cb21b224f4b23a560685eb5cb21c4fb2 Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Fri, 21 Aug 2026 20:51:11 -0600 Subject: [PATCH 01/44] Split gui.py and kokoro_engine.py into kokoro_gui/ package kokoro_engine.py (1062 -> 100 lines) and gui.py (1759 -> 833 lines) are now slim core modules composing their classes from mixins in a new kokoro_gui/ package: - kokoro_gui/engine/: audio_fx, lexicon, srt, text_extraction, voices, presets, caching, conversion, jit - kokoro_gui/ui/: lexicon_tab, fx_tab, mixing_tab, generation_tab kokoro_engine.py and gui.py keep every module-level name the test suite monkeypatches (CACHE_DIR, CUSTOM_VOICES_DIR, playback, get_thread_pipeline, KPipeline, pypdf/ebooklib/epub, CONFIG_FILE, PRESETS_DIR, FX_PRESETS_DIR, KokoroEngine, messagebox, filedialog, ctk). Extracted mixins that need one of these import the owning module and access the name qualified, at call time (e.g. `kokoro_engine.CACHE_DIR`), never via `from module import name`, so monkeypatch.setattr(...) in tests keeps working unchanged. Verified by running the full test suite after each extraction step (109/109 passing throughout) and checking for MRO/method-name collisions across both mixin sets (none found). --- .gitignore | 3 + README.md | 3 +- gui.py | 932 +------------------------ kokoro_engine.py | 987 +-------------------------- kokoro_gui/__init__.py | 0 kokoro_gui/engine/__init__.py | 21 + kokoro_gui/engine/audio_fx.py | 152 +++++ kokoro_gui/engine/caching.py | 157 +++++ kokoro_gui/engine/conversion.py | 293 ++++++++ kokoro_gui/engine/jit.py | 183 +++++ kokoro_gui/engine/lexicon.py | 27 + kokoro_gui/engine/presets.py | 34 + kokoro_gui/engine/srt.py | 26 + kokoro_gui/engine/text_extraction.py | 103 +++ kokoro_gui/engine/voices.py | 86 +++ kokoro_gui/ui/__init__.py | 6 + kokoro_gui/ui/fx_tab.py | 368 ++++++++++ kokoro_gui/ui/generation_tab.py | 288 ++++++++ kokoro_gui/ui/lexicon_tab.py | 80 +++ kokoro_gui/ui/mixing_tab.py | 250 +++++++ 20 files changed, 2095 insertions(+), 1904 deletions(-) create mode 100644 kokoro_gui/__init__.py create mode 100644 kokoro_gui/engine/__init__.py create mode 100644 kokoro_gui/engine/audio_fx.py create mode 100644 kokoro_gui/engine/caching.py create mode 100644 kokoro_gui/engine/conversion.py create mode 100644 kokoro_gui/engine/jit.py create mode 100644 kokoro_gui/engine/lexicon.py create mode 100644 kokoro_gui/engine/presets.py create mode 100644 kokoro_gui/engine/srt.py create mode 100644 kokoro_gui/engine/text_extraction.py create mode 100644 kokoro_gui/engine/voices.py create mode 100644 kokoro_gui/ui/__init__.py create mode 100644 kokoro_gui/ui/fx_tab.py create mode 100644 kokoro_gui/ui/generation_tab.py create mode 100644 kokoro_gui/ui/lexicon_tab.py create mode 100644 kokoro_gui/ui/mixing_tab.py diff --git a/.gitignore b/.gitignore index eb6e99e..97013ef 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,6 @@ cython_debug/ /ROADMAP.md /CLAUDE.md /tests/output/ +/custom_voices/ +/cache/ +/presets/ diff --git a/README.md b/README.md index 6ca8e1e..bacd301 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ A modern, high-quality Text-to-Speech (TTS) application built with Python, featu https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e -## New in 3.2.0 +## New in Beta 3.2.0 +- **Modular codebase:** `gui.py` and `kokoro_engine.py` are now split into a `kokoro_gui/engine/` and `kokoro_gui/ui/` package by feature area (text extraction, caching, lexicon, presets, voice mixing, per-tab UI builders), making the codebase easier to navigate and extend. No user-facing behavior change. - **Cross-Platform Audio Playback:** Preview and JIT playback now go through `sounddevice`/`soundfile` instead of the Windows-only `winsound` module, removing a hard Windows dependency from `kokoro_engine.py`/`gui.py`. ## New in 3.1.0 diff --git a/gui.py b/gui.py index b47e7cd..80f56f4 100644 --- a/gui.py +++ b/gui.py @@ -1,13 +1,14 @@ import os import time import json -import re import playback import customtkinter as ctk from tkinter import filedialog, messagebox import threading from kokoro_engine import KokoroEngine +from kokoro_gui.ui import FXTabMixin, GenerationTabMixin, LexiconTabMixin, MixingTabMixin + # Set Default Appearance (will be overridden by settings) ctk.set_appearance_mode("Dark") ctk.set_default_color_theme("blue") @@ -16,7 +17,7 @@ PRESETS_DIR = "presets" FX_PRESETS_DIR = os.path.join(PRESETS_DIR, "fx") -class TTSApp(ctk.CTk): +class TTSApp(FXTabMixin, GenerationTabMixin, LexiconTabMixin, MixingTabMixin, ctk.CTk): def __init__(self): super().__init__() @@ -240,21 +241,6 @@ def on_lang_change(self, *args): if self.VOICE_DB.get(code, []): self.voice_var.set(self.VOICE_DB[code][0]) - def _update_mix_voice_list(self, lang_var, combo_attr, voice_var): - code = lang_var.get() - if hasattr(self, combo_attr): - combo = getattr(self, combo_attr) - voices = self.get_all_voices(code) - combo.configure(values=voices) - if voice_var.get() not in voices: - voice_var.set(voices[0]) - - def on_mix_lang_a_change(self, *args): - self._update_mix_voice_list(self.mix_lang_a_var, 'mix_combo_a', self.mix_voice_a_var) - - def on_mix_lang_b_change(self, *args): - self._update_mix_voice_list(self.mix_lang_b_var, 'mix_combo_b', self.mix_voice_b_var) - def schedule_save(self, *args): if self.save_timer: self.after_cancel(self.save_timer) @@ -434,887 +420,6 @@ def apply_settings(self): except Exception: ctk.set_widget_scaling(1.0) - # --- Preset Management --- - - def refresh_presets(self): - presets = ["Select Preset..."] - if os.path.exists(PRESETS_DIR): - files = [f for f in os.listdir(PRESETS_DIR) if f.endswith(".json")] - presets.extend([f[:-5] for f in files]) # Remove .json - - self.preset_combo.configure(values=presets) - self.preset_combo.set("Select Preset...") - - def save_preset_dialog(self): - dialog = ctk.CTkInputDialog(text="Enter preset name:", title="Save Preset") - name = dialog.get_input() - if name: - name = re.sub(r'[<>:"/\\|?*]', '', name).strip() # Sanitize - if not name: return - - data = { - "voice": self.voice_var.get(), - "speed": self.speed_var.get(), - "volume": self.volume_var.get(), - "pitch": self.pitch_var.get(), - "split_pattern": self.split_pattern_var.get(), - "normalize": self.normalize_audio.get(), - "trim": self.trim_silence.get(), - "format": self.output_format_var.get(), - "apply_fx": self.apply_fx_var.get(), - "fx_preset": self.gen_fx_combo.get() - } - - fpath = os.path.join(PRESETS_DIR, f"{name}.json") - try: - with open(fpath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - messagebox.showinfo("Saved", f"Preset '{name}' saved successfully.") - self.refresh_presets() - self.preset_combo.set(name) - except Exception as e: - messagebox.showerror("Error", f"Failed to save preset: {e}") - - def load_preset(self, name): - if name == "Select Preset...": return - - fpath = os.path.join(PRESETS_DIR, f"{name}.json") - if os.path.exists(fpath): - try: - with open(fpath, "r", encoding="utf-8") as f: - data = json.load(f) - - if "voice" in data: self.voice_var.set(data["voice"]) - if "speed" in data: self.speed_var.set(data["speed"]) - if "volume" in data: self.volume_var.set(data["volume"]) - if "pitch" in data: self.pitch_var.set(data["pitch"]) - if "split_pattern" in data: self.split_pattern_var.set(data["split_pattern"]) - if "normalize" in data: self.normalize_audio.set(data["normalize"]) - if "trim" in data: self.trim_silence.set(data["trim"]) - if "format" in data: self.output_format_var.set(data["format"]) - if "apply_fx" in data: self.apply_fx_var.set(data["apply_fx"]) - - if "fx_preset" in data: - fx_name = data["fx_preset"] - if fx_name and fx_name != "Select FX Preset...": - self.load_fx_preset(fx_name) - # Ensure combo is updated (load_fx_preset does this, but being safe) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(fx_name) - - # Update UI labels manually since setting var triggers trace but maybe not UI update logic dependent on callbacks - self.update_audio_labels(0) - self.update_speed_label(self.speed_var.get()) - - # Update split combo logic - target_pat = self.split_pattern_var.get() - for k, v in self.split_map.items(): - if v == target_pat: - self.split_combo.set(k) - break - - except Exception as e: - messagebox.showerror("Error", f"Failed to load preset: {e}") - - # --- FX Preset Management --- - - def refresh_fx_presets(self): - presets = ["Select FX Preset..."] - if os.path.exists(FX_PRESETS_DIR): - files = [f for f in os.listdir(FX_PRESETS_DIR) if f.endswith(".json")] - presets.extend([f[:-5] for f in files]) # Remove .json - - # Update FX Tab Combo - if hasattr(self, 'fx_preset_combo'): - self.fx_preset_combo.configure(values=presets) - self.fx_preset_combo.set("Select FX Preset...") - - # Update Gen Tab Combo - if hasattr(self, 'gen_fx_combo'): - self.gen_fx_combo.configure(values=presets) - self.gen_fx_combo.set("Select FX Preset...") - - def save_fx_preset_dialog(self): - dialog = ctk.CTkInputDialog(text="Enter FX preset name:", title="Save FX Preset") - name = dialog.get_input() - if name: - name = re.sub(r'[<>:"/\\|?*]', '', name).strip() - if not name: return - - data = { - "reverb_enabled": self.reverb_enabled.get(), - "reverb_room_size": self.reverb_room_size.get(), - "reverb_wet_level": self.reverb_wet_level.get(), - "reverb_damping": self.reverb_damping.get(), - "reverb_dry_level": self.reverb_dry_level.get(), - "reverb_width": self.reverb_width.get(), - "eq_bass": self.eq_bass.get(), - "eq_treble": self.eq_treble.get(), - "comp_enabled": self.comp_enabled.get(), - "comp_threshold": self.comp_threshold.get(), - "comp_ratio": self.comp_ratio.get(), - "comp_attack": self.comp_attack.get(), - "comp_release": self.comp_release.get(), - "distortion_enabled": self.distortion_enabled.get(), - "distortion_drive": self.distortion_drive.get(), - "chorus_enabled": self.chorus_enabled.get(), - "chorus_rate": self.chorus_rate.get(), - "chorus_depth": self.chorus_depth.get(), - "chorus_mix": self.chorus_mix.get(), - "phaser_enabled": self.phaser_enabled.get(), - "phaser_rate": self.phaser_rate.get(), - "phaser_depth": self.phaser_depth.get(), - "phaser_mix": self.phaser_mix.get(), - "clipping_enabled": self.clipping_enabled.get(), - "clipping_thresh": self.clipping_thresh.get(), - "bitcrush_enabled": self.bitcrush_enabled.get(), - "bitcrush_depth": self.bitcrush_depth.get(), - "gsm_enabled": self.gsm_enabled.get(), - "highpass_enabled": self.highpass_enabled.get(), - "highpass_freq": self.highpass_freq.get(), - "lowpass_enabled": self.lowpass_enabled.get(), - "lowpass_freq": self.lowpass_freq.get(), - "delay_enabled": self.delay_enabled.get(), - "delay_time": self.delay_time.get(), - "delay_feedback": self.delay_feedback.get(), - "delay_mix": self.delay_mix.get(), - "pitch_shift_enabled": self.pitch_shift_enabled.get(), - "pitch_shift_semitones": self.pitch_shift_semitones.get(), - "limiter_enabled": self.limiter_enabled.get(), - "limiter_threshold": self.limiter_threshold.get(), - "limiter_release": self.limiter_release.get(), - "gain_enabled": self.gain_enabled.get(), - "gain_db": self.gain_db.get() - } - - fpath = os.path.join(FX_PRESETS_DIR, f"{name}.json") - try: - with open(fpath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - messagebox.showinfo("Saved", f"FX Preset '{name}' saved.") - self.refresh_fx_presets() - if hasattr(self, 'fx_preset_combo'): self.fx_preset_combo.set(name) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(name) - except Exception as e: - messagebox.showerror("Error", f"Failed to save FX preset: {e}") - - def load_fx_preset(self, name): - if name == "Select FX Preset...": return - - safe_name = os.path.basename(name) - if not safe_name: return - fpath = os.path.join(FX_PRESETS_DIR, f"{safe_name}.json") - if os.path.exists(fpath): - try: - with open(fpath, "r", encoding="utf-8") as f: - data = json.load(f) - - if "reverb_enabled" in data: self.reverb_enabled.set(data["reverb_enabled"]) - if "reverb_room_size" in data: self.reverb_room_size.set(data["reverb_room_size"]) - if "reverb_wet_level" in data: self.reverb_wet_level.set(data["reverb_wet_level"]) - if "reverb_damping" in data: self.reverb_damping.set(data["reverb_damping"]) - if "reverb_dry_level" in data: self.reverb_dry_level.set(data["reverb_dry_level"]) - if "reverb_width" in data: self.reverb_width.set(data["reverb_width"]) - - if "eq_bass" in data: self.eq_bass.set(data["eq_bass"]) - if "eq_treble" in data: self.eq_treble.set(data["eq_treble"]) - - if "comp_enabled" in data: self.comp_enabled.set(data["comp_enabled"]) - if "comp_threshold" in data: self.comp_threshold.set(data["comp_threshold"]) - if "comp_ratio" in data: self.comp_ratio.set(data["comp_ratio"]) - if "comp_attack" in data: self.comp_attack.set(data["comp_attack"]) - if "comp_release" in data: self.comp_release.set(data["comp_release"]) - - if "distortion_enabled" in data: self.distortion_enabled.set(data["distortion_enabled"]) - if "distortion_drive" in data: self.distortion_drive.set(data["distortion_drive"]) - - if "chorus_enabled" in data: self.chorus_enabled.set(data["chorus_enabled"]) - if "chorus_rate" in data: self.chorus_rate.set(data["chorus_rate"]) - if "chorus_depth" in data: self.chorus_depth.set(data["chorus_depth"]) - if "chorus_mix" in data: self.chorus_mix.set(data["chorus_mix"]) - - if "phaser_enabled" in data: self.phaser_enabled.set(data["phaser_enabled"]) - if "phaser_rate" in data: self.phaser_rate.set(data["phaser_rate"]) - if "phaser_depth" in data: self.phaser_depth.set(data["phaser_depth"]) - if "phaser_mix" in data: self.phaser_mix.set(data["phaser_mix"]) - - if "clipping_enabled" in data: self.clipping_enabled.set(data["clipping_enabled"]) - if "clipping_thresh" in data: self.clipping_thresh.set(data["clipping_thresh"]) - - if "bitcrush_enabled" in data: self.bitcrush_enabled.set(data["bitcrush_enabled"]) - if "bitcrush_depth" in data: self.bitcrush_depth.set(data["bitcrush_depth"]) - - if "gsm_enabled" in data: self.gsm_enabled.set(data["gsm_enabled"]) - - if "highpass_enabled" in data: self.highpass_enabled.set(data["highpass_enabled"]) - if "highpass_freq" in data: self.highpass_freq.set(data["highpass_freq"]) - - if "lowpass_enabled" in data: self.lowpass_enabled.set(data["lowpass_enabled"]) - if "lowpass_freq" in data: self.lowpass_freq.set(data["lowpass_freq"]) - - if "delay_enabled" in data: self.delay_enabled.set(data["delay_enabled"]) - if "delay_time" in data: self.delay_time.set(data["delay_time"]) - if "delay_feedback" in data: self.delay_feedback.set(data["delay_feedback"]) - if "delay_mix" in data: self.delay_mix.set(data["delay_mix"]) - - if "pitch_shift_enabled" in data: self.pitch_shift_enabled.set(data["pitch_shift_enabled"]) - if "pitch_shift_semitones" in data: self.pitch_shift_semitones.set(data["pitch_shift_semitones"]) - - if "limiter_enabled" in data: self.limiter_enabled.set(data["limiter_enabled"]) - if "limiter_threshold" in data: self.limiter_threshold.set(data["limiter_threshold"]) - if "limiter_release" in data: self.limiter_release.set(data["limiter_release"]) - - if "gain_enabled" in data: self.gain_enabled.set(data["gain_enabled"]) - if "gain_db" in data: self.gain_db.set(data["gain_db"]) - - self.update_fx_labels() - - # Sync Combos - if hasattr(self, 'fx_preset_combo'): self.fx_preset_combo.set(name) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(name) - - except Exception as e: - messagebox.showerror("Error", f"Failed to load FX preset: {e}") - - def refresh_voice_lists(self): - # Update Gen Tab Combo - if hasattr(self, 'voice_combo'): - self.voice_combo.configure(values=self.get_all_voices(self.lang_var.get())) - - # Update Mix Tab Combos - self.on_mix_lang_a_change() - self.on_mix_lang_b_change() - - # Update Custom List - if hasattr(self, 'custom_list_frame'): - for widget in self.custom_list_frame.winfo_children(): - widget.destroy() - - all_voices = self.get_all_voices(self.lang_var.get()) - custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] - if not custom: - ctk.CTkLabel(self.custom_list_frame, text="No custom voices found.", text_color="gray").pack(pady=5) - else: - for cv in sorted(custom): - row = ctk.CTkFrame(self.custom_list_frame) - row.pack(fill="x", pady=2) - ctk.CTkLabel(row, text=cv).pack(side="left", padx=5) - ctk.CTkButton(row, text="X", width=30, fg_color="#c42b1c", command=lambda v=cv: self.delete_custom_voice(v)).pack(side="right", padx=5) - - def delete_custom_voice(self, name): - if messagebox.askyesno("Confirm", f"Delete voice '{name}'?"): - try: - path = os.path.join("custom_voices", f"{name}.pt") - if os.path.exists(path): - os.remove(path) - self.refresh_voice_lists() - except Exception as e: - messagebox.showerror("Error", f"Failed to delete: {e}") - - def preview_mix(self): - v1 = self.mix_voice_a_var.get() - v2 = self.mix_voice_b_var.get() - ratio = self.mix_ratio_var.get() - op = self.mix_op_var.get() - preview_lang = self.preview_lang_var.get() - - preview_text = "This is a preview of your custom mixed voice." - if preview_lang == 'f': preview_text = "Ceci est un aperçu de votre voix personnalisée." - elif preview_lang == 'e': preview_text = "Esta es una vista previa de su voz personalizada." - elif preview_lang == 'i': preview_text = "Questa è un'anteprima della tua voce personalizzata." - elif preview_lang == 'p': preview_text = "Esta é uma prévia da sua voz personalizada." - elif preview_lang == 'j': preview_text = "これはカスタム合成音声のプレビューです。" - elif preview_lang == 'z': preview_text = "这是您的自定义混合语音预览。" - - # Temp voice name and file - import tempfile - tmp_voice_name = "_tmp_mix_preview" - tmp_audio_path = os.path.join(tempfile.gettempdir(), "kokoro_mix_preview.wav") - - self.mix_status_label.configure(text="Generating preview...", text_color="blue") - - async def _run_preview(): - # 1. Mix to a temporary file (we ignore the file for preview, use tensor) - success, msg, tensor = await self.engine.mix_voices(v1, v2, ratio, tmp_voice_name, op=op) - if not success: - return False, msg - - # 2. Generate audio using that mixed voice tensor and target preview language - success = await self.engine.generate_preview(preview_text, tmp_voice_name, 1.0, tmp_audio_path, voice_tensor=tensor, lang_code=preview_lang) - - # 3. Cleanup temp voice file - try: - p = os.path.join("custom_voices", f"{tmp_voice_name}.pt") - if os.path.exists(p): os.remove(p) - except Exception: pass - - return success, "" - - def _on_done(future): - try: - success, err = future.result() - if success: - self.after(0, lambda: self.mix_status_label.configure(text="Playing preview...", text_color="green")) - playback.play(tmp_audio_path) - else: - self.after(0, lambda: self.mix_status_label.configure(text=f"Preview failed: {err}", text_color="red")) - except Exception as e: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {e}", text_color="red")) - - future = self.engine.worker.run_coro(_run_preview()) - future.add_done_callback(_on_done) - - def mix_voice_action(self): - v1 = self.mix_voice_a_var.get() - v2 = self.mix_voice_b_var.get() - ratio = self.mix_ratio_var.get() - op = self.mix_op_var.get() - name = self.mix_name_var.get().strip() - - if not name: - messagebox.showwarning("Error", "Please enter a name for the new voice.") - return - - if not re.match(r'^[a-zA-Z0-9_-]+$', name): - messagebox.showwarning("Error", "Invalid name. Use alphanumeric, _, - only.") - return - - if name in self.get_all_voices(): - if not messagebox.askyesno("Overwrite", f"Voice '{name}' exists. Overwrite?"): - return - - self.mix_status_label.configure(text="Mixing...", text_color="blue") - self.set_ui_state(True) # Reuse existing lock - - def _done(future): - self.after(0, lambda: self.set_ui_state(False)) - try: - success, msg, _ = future.result() - if success: - self.after(0, lambda: self.mix_status_label.configure(text=f"Saved: {name}", text_color="green")) - self.after(0, self.refresh_voice_lists) - else: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {msg}", text_color="red")) - except Exception as e: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {e}", text_color="red")) - - future = self.engine.worker.run_coro(self.engine.mix_voices(v1, v2, ratio, name, op=op)) - future.add_done_callback(_done) - - def build_mixing_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - lang_display_map = {v: k for k, v in self.LANGUAGES.items()} - - # 1. Selection - sel_frame = ctk.CTkFrame(parent) - sel_frame.pack(fill="x", padx=10, pady=10) - sel_frame.grid_columnconfigure(1, weight=1) - sel_frame.grid_columnconfigure(2, weight=1) - - # Voice A Row - ctk.CTkLabel(sel_frame, text="Voice A:").grid(row=0, column=0, padx=10, pady=5) - - def on_lang_a_ui(c): self.mix_lang_a_var.set(self.LANGUAGES[c]) - mix_lang_a_combo = ctk.CTkComboBox(sel_frame, values=list(self.LANGUAGES.keys()), command=on_lang_a_ui, width=150) - mix_lang_a_combo.set(lang_display_map.get(self.mix_lang_a_var.get(), "American English")) - mix_lang_a_combo.grid(row=0, column=1, padx=5, pady=5, sticky="ew") - - self.mix_combo_a = ctk.CTkComboBox(sel_frame, variable=self.mix_voice_a_var) - self.mix_combo_a.grid(row=0, column=2, sticky="ew", padx=5, pady=5) - - # Voice B Row - ctk.CTkLabel(sel_frame, text="Voice B:").grid(row=1, column=0, padx=10, pady=5) - - def on_lang_b_ui(c): self.mix_lang_b_var.set(self.LANGUAGES[c]) - mix_lang_b_combo = ctk.CTkComboBox(sel_frame, values=list(self.LANGUAGES.keys()), command=on_lang_b_ui, width=150) - mix_lang_b_combo.set(lang_display_map.get(self.mix_lang_b_var.get(), "American English")) - mix_lang_b_combo.grid(row=1, column=1, padx=5, pady=5, sticky="ew") - - self.mix_combo_b = ctk.CTkComboBox(sel_frame, variable=self.mix_voice_b_var) - self.mix_combo_b.grid(row=1, column=2, sticky="ew", padx=5, pady=5) - - # 2. Ratio & Operation - ratio_frame = ctk.CTkFrame(parent) - ratio_frame.pack(fill="x", padx=10, pady=10) - - op_frame = ctk.CTkFrame(ratio_frame, fg_color="transparent") - op_frame.pack(fill="x", padx=20, pady=(10, 0)) - ctk.CTkLabel(op_frame, text="Operation:").pack(side="left", padx=5) - - def update_ratio_label(val=None): - if val is None: val = self.mix_ratio_var.get() - p = int(float(val) * 100) - op = self.mix_op_var.get() - if op == 'mix': - self.ratio_label.configure(text=f"Mix: {100-p}% A / {p}% B", text_color=("black", "white")) - elif op == 'divide': - self.ratio_label.configure(text=f"Op: Divide | Influence: {p}%\n(Results are more likely to be unstable and VERY LOUD)", text_color="#E57373") - else: - self.ratio_label.configure(text=f"Op: {op.capitalize()} | Influence: {p}%", text_color=("black", "white")) - - ctk.CTkComboBox(op_frame, values=["mix", "add", "subtract", "multiply", "divide"], variable=self.mix_op_var, command=lambda _: update_ratio_label()).pack(side="left", padx=5) - - self.ratio_label = ctk.CTkLabel(ratio_frame, text="Mix: 50% A / 50% B") - self.ratio_label.pack(pady=5) - - slider = ctk.CTkSlider(ratio_frame, from_=0.0, to=1.0, number_of_steps=100, variable=self.mix_ratio_var, command=update_ratio_label) - slider.pack(fill="x", padx=20, pady=10) - - update_ratio_label() - - # 3. Preview Lang & Actions - act_frame = ctk.CTkFrame(parent) - act_frame.pack(fill="x", padx=10, pady=10) - - ctk.CTkLabel(act_frame, text="Preview Language:").grid(row=0, column=0, padx=10, pady=5) - - def on_prev_lang_ui(c): self.preview_lang_var.set(self.LANGUAGES[c]) - prev_lang_combo = ctk.CTkComboBox(act_frame, values=list(self.LANGUAGES.keys()), command=on_prev_lang_ui, width=150) - prev_lang_combo.set(lang_display_map.get(self.preview_lang_var.get(), "American English")) - prev_lang_combo.grid(row=0, column=1, padx=5, pady=5) - - ctk.CTkButton(act_frame, text="🔊 Preview", width=100, fg_color="#2B719E", command=self.preview_mix).grid(row=0, column=2, padx=10) - - # Save Row - save_frame = ctk.CTkFrame(parent) - save_frame.pack(fill="x", padx=10, pady=10) - - ctk.CTkLabel(save_frame, text="New Voice Name:").pack(side="left", padx=10) - ctk.CTkEntry(save_frame, textvariable=self.mix_name_var).pack(side="left", fill="x", expand=True, padx=5) - ctk.CTkButton(save_frame, text="Create & Save", command=self.mix_voice_action).pack(side="left", padx=10) - - self.mix_status_label = ctk.CTkLabel(parent, text="", text_color="gray") - self.mix_status_label.pack(pady=5) - - # 4. List - ctk.CTkLabel(parent, text="Custom Voices:", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=(20,5)) - self.custom_list_frame = ctk.CTkScrollableFrame(parent, height=200) - self.custom_list_frame.pack(fill="x", padx=10, pady=5) - - self.refresh_voice_lists() - - def build_fx_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - # --- Preset Controls --- - pre_frame = ctk.CTkFrame(parent, fg_color="transparent") - pre_frame.pack(fill="x", padx=10, pady=(10,5)) - - self.fx_preset_combo = ctk.CTkComboBox(pre_frame, values=["Select FX Preset..."], command=self.load_fx_preset, width=200) - self.fx_preset_combo.pack(side="left", padx=(0,5)) - - ctk.CTkButton(pre_frame, text="💾 Save", width=60, command=self.save_fx_preset_dialog).pack(side="left", padx=2) - ctk.CTkButton(pre_frame, text="🔄", width=30, command=self.refresh_fx_presets).pack(side="left", padx=2) - - scroll = ctk.CTkScrollableFrame(parent) - scroll.pack(fill="both", expand=True, padx=5, pady=5) - scroll.grid_columnconfigure(0, weight=1) - - # Helper to create rows - def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_attr=None): - row = ctk.CTkFrame(parent, fg_color="transparent") - row.pack(fill="x", padx=5, pady=2) - lbl = ctk.CTkLabel(row, text=label_text, width=120, anchor="w") - lbl.pack(side="left") - if label_attr: setattr(self, label_attr, lbl) - - ctk.CTkSlider(row, from_=from_, to=to_, number_of_steps=steps, variable=variable, - command=lambda v: self.update_fx_labels()).pack(side="left", fill="x", expand=True, padx=5) - - # --- 1. Dynamics --- - dyn_frame = ctk.CTkFrame(scroll) - dyn_frame.pack(fill="x", padx=5, pady=5) - - ctk.CTkLabel(dyn_frame, text="Dynamics", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Compressor - c_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - c_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(c_head, text="Compressor", variable=self.comp_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - c_body = ctk.CTkFrame(dyn_frame) - c_body.pack(fill="x", padx=10, pady=2) - _create_slider(c_body, "Threshold", self.comp_threshold, -60, 0, 60, 'comp_thresh_label') - _create_slider(c_body, "Ratio", self.comp_ratio, 1, 20, 19, 'comp_ratio_label') - - # Limiter - l_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - l_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(l_head, text="Limiter", variable=self.limiter_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - l_body = ctk.CTkFrame(dyn_frame) - l_body.pack(fill="x", padx=10, pady=2) - _create_slider(l_body, "Threshold", self.limiter_threshold, -12, 0, 24, 'lim_thresh_label') - - # Gain - g_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - g_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(g_head, text="Gain", variable=self.gain_enabled, font=("Roboto", 12, "bold")).pack(side="left") - _create_slider(dyn_frame, "dB", self.gain_db, -20, 20, 80, 'gain_label') - - # --- 2. EQ & Filters --- - eq_frame = ctk.CTkFrame(scroll) - eq_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(eq_frame, text="EQ & Filters", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - _create_slider(eq_frame, "Bass (LowShelf)", self.eq_bass, -20, 20, 40, 'bass_label') - _create_slider(eq_frame, "Treble (HighShelf)", self.eq_treble, -20, 20, 40, 'treble_label') - - # HPF - h_head = ctk.CTkFrame(eq_frame, fg_color="transparent") - h_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(h_head, text="HighPass Filter", variable=self.highpass_enabled).pack(side="left") - _create_slider(eq_frame, "Freq (Hz)", self.highpass_freq, 20, 1000, 100, 'hpf_label') - - # LPF - lpf_head = ctk.CTkFrame(eq_frame, fg_color="transparent") - lpf_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(lpf_head, text="LowPass Filter", variable=self.lowpass_enabled).pack(side="left") - _create_slider(eq_frame, "Freq (Hz)", self.lowpass_freq, 1000, 20000, 100, 'lpf_label') - - # --- 3. Spatial & Time --- - sp_frame = ctk.CTkFrame(scroll) - sp_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(sp_frame, text="Spatial & Time", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Reverb - r_head = ctk.CTkFrame(sp_frame, fg_color="transparent") - r_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(r_head, text="Reverb", variable=self.reverb_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - r_body = ctk.CTkFrame(sp_frame) - r_body.pack(fill="x", padx=10, pady=2) - _create_slider(r_body, "Room Size", self.reverb_room_size, 0, 1, 100, 'rev_room_label') - _create_slider(r_body, "Wet Level", self.reverb_wet_level, 0, 1, 100, 'rev_wet_label') - _create_slider(r_body, "Damping", self.reverb_damping, 0, 1, 100, None) - _create_slider(r_body, "Width", self.reverb_width, 0, 1, 100, None) - - # Delay - d_head = ctk.CTkFrame(sp_frame, fg_color="transparent") - d_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(d_head, text="Delay", variable=self.delay_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - d_body = ctk.CTkFrame(sp_frame) - d_body.pack(fill="x", padx=10, pady=2) - _create_slider(d_body, "Time (s)", self.delay_time, 0, 2, 100, 'dly_time_label') - _create_slider(d_body, "Feedback", self.delay_feedback, 0, 1, 100, None) - _create_slider(d_body, "Mix", self.delay_mix, 0, 1, 100, 'dly_mix_label') - - # --- 4. Guitar / Modulation --- - mod_frame = ctk.CTkFrame(scroll) - mod_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(mod_frame, text="Guitar / Modulation", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Chorus - ch_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - ch_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(ch_head, text="Chorus", variable=self.chorus_enabled).pack(side="left") - _create_slider(mod_frame, "Rate (Hz)", self.chorus_rate, 0.1, 10, 50, 'chorus_rate_label') - _create_slider(mod_frame, "Depth", self.chorus_depth, 0, 1, 50, None) - - # Distortion - di_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - di_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(di_head, text="Distortion", variable=self.distortion_enabled).pack(side="left") - _create_slider(mod_frame, "Drive (dB)", self.distortion_drive, 0, 60, 60, 'dist_drive_label') - - # Phaser - ph_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - ph_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(ph_head, text="Phaser", variable=self.phaser_enabled).pack(side="left") - _create_slider(mod_frame, "Rate (Hz)", self.phaser_rate, 0.1, 10, 50, 'phaser_rate_label') - - # Clipping - cl_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - cl_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(cl_head, text="Clipping", variable=self.clipping_enabled).pack(side="left") - _create_slider(mod_frame, "Threshold (dB)", self.clipping_thresh, -20, 0, 40, 'clip_thresh_label') - - # --- 5. Quality & Pitch --- - q_frame = ctk.CTkFrame(scroll) - q_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(q_frame, text="Quality / Pitch", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Pitch Shift - ps_head = ctk.CTkFrame(q_frame, fg_color="transparent") - ps_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(ps_head, text="Pitch Shift (High Quality)", variable=self.pitch_shift_enabled).pack(side="left") - _create_slider(q_frame, "Semitones", self.pitch_shift_semitones, -12, 12, 48, 'pitch_shift_label') - - # Bitcrush - bc_head = ctk.CTkFrame(q_frame, fg_color="transparent") - bc_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(bc_head, text="Bitcrush", variable=self.bitcrush_enabled).pack(side="left") - _create_slider(q_frame, "Bit Depth", self.bitcrush_depth, 2, 16, 28, 'bit_depth_label') - - # GSM - ctk.CTkCheckBox(q_frame, text="GSM Compressor (Phone Quality)", variable=self.gsm_enabled).pack(anchor="w", padx=10, pady=5) - - # Init labels - self.update_fx_labels() - self.refresh_fx_presets() - - def build_generation_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - # Move existing logic here - main_frame = ctk.CTkScrollableFrame(parent) - main_frame.pack(fill="both", expand=True, padx=5, pady=5) - main_frame.grid_columnconfigure(0, weight=1) - - # --- 1. Input Section --- - input_frame = ctk.CTkFrame(main_frame) - input_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10)) - input_frame.grid_columnconfigure(0, weight=1) - - ctk.CTkLabel(input_frame, text="Input Source", font=("Roboto", 16, "bold")).grid(row=0, column=0, sticky="w", padx=10, pady=5) - - self.tab_view = ctk.CTkTabview(input_frame, height=150) - self.tab_view.grid(row=1, column=0, sticky="ew", padx=10, pady=5) - - # Text Tab - tab_text = self.tab_view.add("Direct Text") - tab_text.grid_columnconfigure(0, weight=1) - tab_text.grid_rowconfigure(0, weight=1) - - self.text_entry = ctk.CTkTextbox(tab_text, wrap="word") - self.text_entry.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - - # File Tab - tab_file = self.tab_view.add("Load File") - tab_file.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel(tab_file, text="File Path:").grid(row=0, column=0, padx=10, pady=20) - ctk.CTkEntry(tab_file, textvariable=self.file_path_var).grid(row=0, column=1, sticky="ew", padx=5) - ctk.CTkButton(tab_file, text="Browse", width=80, command=self.browse_file).grid(row=0, column=2, padx=10) - ctk.CTkLabel(tab_file, text="Supported: .txt, .pdf, .epub", text_color="gray").grid(row=1, column=1, sticky="w", padx=5) - - # --- 2. Configuration --- - config_frame = ctk.CTkFrame(main_frame) - config_frame.grid(row=1, column=0, sticky="ew", pady=10) - config_frame.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel(config_frame, text="Configuration", font=("Roboto", 16, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", padx=10, pady=5) - - # Presets Row - preset_frame = ctk.CTkFrame(config_frame, fg_color="transparent") - preset_frame.grid(row=0, column=1, sticky="ew", padx=10, pady=5) - - self.preset_combo = ctk.CTkComboBox(preset_frame, values=["Select Preset..."], command=self.load_preset, width=150) - self.preset_combo.pack(side="left", padx=(0,5)) - - ctk.CTkButton(preset_frame, text="💾", width=30, command=self.save_preset_dialog).pack(side="left", padx=2) - ctk.CTkButton(preset_frame, text="🔄", width=30, command=self.refresh_presets).pack(side="left", padx=2) - - self.refresh_presets() - - # Language Selection - ctk.CTkLabel(config_frame, text="Language:").grid(row=1, column=0, sticky="w", padx=10, pady=5) - # Reverse map for display - lang_display_map = {v: k for k, v in self.LANGUAGES.items()} - current_lang_code = self.lang_var.get() - - def on_lang_ui_change(choice): - self.lang_var.set(self.LANGUAGES[choice]) - - self.lang_combo = ctk.CTkComboBox(config_frame, values=list(self.LANGUAGES.keys()), command=on_lang_ui_change) - - # Set initial value - if current_lang_code in lang_display_map: - self.lang_combo.set(lang_display_map[current_lang_code]) - else: - self.lang_combo.set("American English") - - self.lang_combo.grid(row=1, column=1, sticky="ew", padx=10) - - # Voice Selection - ctk.CTkLabel(config_frame, text="Voice:").grid(row=2, column=0, sticky="w", padx=10, pady=5) - self.voice_combo = ctk.CTkComboBox(config_frame, values=self.get_all_voices(), variable=self.voice_var) - self.voice_combo.grid(row=2, column=1, sticky="ew", padx=10) - - # Output Dir - ctk.CTkLabel(config_frame, text="Output Folder:").grid(row=3, column=0, sticky="w", padx=10, pady=5) - dir_row = ctk.CTkFrame(config_frame, fg_color="transparent") - dir_row.grid(row=3, column=1, sticky="ew", padx=10) - dir_row.grid_columnconfigure(0, weight=1) - ctk.CTkEntry(dir_row, textvariable=self.output_dir_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) - ctk.CTkButton(dir_row, text="...", width=40, command=self.browse_directory).grid(row=0, column=1) - - # Filename - ctk.CTkLabel(config_frame, text="Base Filename:").grid(row=4, column=0, sticky="w", padx=10, pady=5) - - file_row = ctk.CTkFrame(config_frame, fg_color="transparent") - file_row.grid(row=4, column=1, sticky="ew", padx=10) - file_row.grid_columnconfigure(0, weight=1) - - ctk.CTkEntry(file_row, textvariable=self.filename_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) - - self.format_combo = ctk.CTkComboBox(file_row, values=["wav", "flac", "mp3", "ogg"], width=70, variable=self.output_format_var) - self.format_combo.grid(row=0, column=1) - - # Speed - self.speed_label = ctk.CTkLabel(config_frame, text="Speed: 1.0x") - self.speed_label.grid(row=5, column=0, sticky="w", padx=10, pady=5) - self.speed_slider = ctk.CTkSlider(config_frame, from_=0.5, to=2.0, number_of_steps=15, variable=self.speed_var, command=self.update_speed_label) - self.speed_slider.grid(row=5, column=1, sticky="ew", padx=10) - - # Split Pattern - ctk.CTkLabel(config_frame, text="Split By:").grid(row=6, column=0, sticky="w", padx=10, pady=5) - self.split_map = { - "Natural (Newlines)": r"\n+", - "Paragraphs (Double Newline)": r"\n\n+", - "Sentences (.!?)": r"(?", width=30).pack(side="left") - ctk.CTkLabel(row, text=rep, width=150, anchor="w", font=("Consolas", 12)).pack(side="left", padx=10) - - ctk.CTkButton(row, text="X", width=30, fg_color="#c42b1c", command=lambda k=orig: self.delete_lexicon_rule(k)).pack(side="right", padx=5) - def create_widgets(self): # Header self.grid_columnconfigure(0, weight=1) @@ -1439,37 +544,6 @@ def update_audio_labels(self, value): def update_speed_label(self, value): self.speed_label.configure(text=f"Speed: {value:.1f}x") - def update_fx_labels(self): - # EQ - if hasattr(self, 'bass_label'): self.bass_label.configure(text=f"Bass: {self.eq_bass.get():.1f} dB") - if hasattr(self, 'treble_label'): self.treble_label.configure(text=f"Treble: {self.eq_treble.get():.1f} dB") - if hasattr(self, 'hpf_label'): self.hpf_label.configure(text=f"Freq: {int(self.highpass_freq.get())} Hz") - if hasattr(self, 'lpf_label'): self.lpf_label.configure(text=f"Freq: {int(self.lowpass_freq.get())} Hz") - - # Comp / Dynamics - if hasattr(self, 'comp_thresh_label'): self.comp_thresh_label.configure(text=f"Thresh: {self.comp_threshold.get():.1f} dB") - if hasattr(self, 'comp_ratio_label'): self.comp_ratio_label.configure(text=f"Ratio: {self.comp_ratio.get():.1f}:1") - if hasattr(self, 'lim_thresh_label'): self.lim_thresh_label.configure(text=f"Thresh: {self.limiter_threshold.get():.1f} dB") - if hasattr(self, 'gain_label'): self.gain_label.configure(text=f"Gain: {self.gain_db.get():.1f} dB") - - # Reverb - if hasattr(self, 'rev_room_label'): self.rev_room_label.configure(text=f"Size: {self.reverb_room_size.get():.2f}") - if hasattr(self, 'rev_wet_label'): self.rev_wet_label.configure(text=f"Wet: {self.reverb_wet_level.get():.2f}") - - # Delay - if hasattr(self, 'dly_time_label'): self.dly_time_label.configure(text=f"Time: {self.delay_time.get():.2f} s") - if hasattr(self, 'dly_mix_label'): self.dly_mix_label.configure(text=f"Mix: {self.delay_mix.get():.2f}") - - # Guitar - if hasattr(self, 'dist_drive_label'): self.dist_drive_label.configure(text=f"Drive: {self.distortion_drive.get():.1f} dB") - if hasattr(self, 'chorus_rate_label'): self.chorus_rate_label.configure(text=f"Rate: {self.chorus_rate.get():.1f} Hz") - if hasattr(self, 'phaser_rate_label'): self.phaser_rate_label.configure(text=f"Rate: {self.phaser_rate.get():.1f} Hz") - if hasattr(self, 'clip_thresh_label'): self.clip_thresh_label.configure(text=f"Thresh: {self.clipping_thresh.get():.1f} dB") - - # Quality / Pitch - if hasattr(self, 'bit_depth_label'): self.bit_depth_label.configure(text=f"Depth: {self.bitcrush_depth.get():.1f}") - if hasattr(self, 'pitch_shift_label'): self.pitch_shift_label.configure(text=f"Shift: {self.pitch_shift_semitones.get():.1f} st") - def change_threads(self, delta): try: current = int(self.num_threads_var.get()) diff --git a/kokoro_engine.py b/kokoro_engine.py index cd3764d..802070c 100644 --- a/kokoro_engine.py +++ b/kokoro_engine.py @@ -2,30 +2,18 @@ import threading import asyncio import time -import concurrent.futures -import soundfile as sf -import torch -import numpy as np -import scipy.signal -import hashlib -from pedalboard import ( - Pedalboard, Reverb, Compressor, HighShelfFilter, LowShelfFilter, - Chorus, Distortion, Phaser, Clipping, Gain, Limiter, - HighpassFilter, LowpassFilter, LadderFilter, Delay, PitchShift, - GSMFullRateCompressor, Bitcrush -) -from pedalboard.io import AudioFile import pypdf import ebooklib from ebooklib import epub -from bs4 import BeautifulSoup import warnings -import re -import json import playback -import tempfile from kokoro import KPipeline +from kokoro_gui.engine import ( + AudioFXMixin, CachingMixin, ConversionMixin, JITMixin, LexiconMixin, + PresetsMixin, SrtMixin, TextExtractionMixin, VoiceMixingMixin, +) + # Suppress ebooklib warnings warnings.filterwarnings("ignore", category=UserWarning, module='ebooklib') warnings.filterwarnings("ignore", category=FutureWarning, module='ebooklib') @@ -64,19 +52,22 @@ def stop(self): def run_coro(self, coro): return asyncio.run_coroutine_threadsafe(coro, self.loop) -class KokoroEngine: +class KokoroEngine( + AudioFXMixin, CachingMixin, ConversionMixin, JITMixin, LexiconMixin, + PresetsMixin, SrtMixin, TextExtractionMixin, VoiceMixingMixin, +): def __init__(self): self.worker = AsyncLoopThread() self.worker.start() self.cancel_event = threading.Event() self.pipeline = None # Main pipeline for single thread check or init - + if not os.path.exists(CUSTOM_VOICES_DIR): os.makedirs(CUSTOM_VOICES_DIR) - + if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) - + # Callbacks self.on_progress = None # func(percentage, time_elapsed, eta, detail_text) self.on_status = None # func(msg, is_error) @@ -84,183 +75,6 @@ def __init__(self): self._lexicon_cache = {} # Cache for compiled regexes - def apply_lexicon(self, text, lexicon): - """ - Applies a dictionary of replacements to the text. - Case-insensitive finding, preserves case of replacement. - """ - if not lexicon: - return text - - for src, dest in lexicon.items(): - if not src: continue - try: - # Use cached pattern if available to avoid repeated recompilation overhead - if src not in self._lexicon_cache: - # Escape the search term to treat it as literal text - self._lexicon_cache[src] = re.compile(re.escape(src), re.IGNORECASE) - - pattern = self._lexicon_cache[src] - text = pattern.sub(dest, text) - except Exception as e: - print(f"Lexicon error for '{src}': {e}") - - return text - - def resolve_voice_path(self, voice_name): - """ - Returns the absolute path if it's a custom voice, - otherwise returns the name as-is (for standard voices). - """ - # Sanitize voice_name to prevent path traversal - safe_voice_name = os.path.basename(voice_name) - # Check if it's a custom voice file - custom_path = os.path.join(CUSTOM_VOICES_DIR, f"{safe_voice_name}.pt") - if os.path.exists(custom_path): - return os.path.abspath(custom_path) - return voice_name - - def process_audio(self, audio, sr, config): - """ - Apply post-processing: Pitch (Resample), Volume, FX (Reverb, EQ, Comp), Normalize, Trim. - Returns: (processed_audio, new_sr) - """ - # 1. Trim Silence (Simple threshold) - if config.get('trim_silence', False): - threshold = 0.01 - # Find first index > threshold - mask = np.abs(audio) > threshold - if np.any(mask): - start = np.argmax(mask) - end = len(audio) - np.argmax(mask[::-1]) - audio = audio[start:end] - - # 2. Volume / Gain - vol = config.get('volume', 1.0) - if vol != 1.0: - audio = audio * vol - - # 3. Pitch Shift (Resampling) - pitch_semitones = config.get('pitch', 0.0) - if pitch_semitones != 0.0: - factor = 2 ** (pitch_semitones / 12.0) - new_len = int(len(audio) / factor) - if new_len > 0: - try: - audio = scipy.signal.resample(audio, new_len) - except Exception as e: - print(f"Resample failed: {e}") - - # 4. Pedalboard FX - fx_chain = [] - - if config.get('apply_fx', True): - # --- Guitar / Modulation --- - if config.get('distortion_enabled', False): - drive = config.get('distortion_drive', 25.0) - fx_chain.append(Distortion(drive_db=drive)) - - if config.get('chorus_enabled', False): - fx_chain.append(Chorus( - rate_hz=config.get('chorus_rate', 1.0), - depth=config.get('chorus_depth', 0.25), - mix=config.get('chorus_mix', 0.5) - )) - - if config.get('phaser_enabled', False): - fx_chain.append(Phaser( - rate_hz=config.get('phaser_rate', 1.0), - depth=config.get('phaser_depth', 0.5), - mix=config.get('phaser_mix', 0.5) - )) - - if config.get('clipping_enabled', False): - fx_chain.append(Clipping(threshold_db=config.get('clipping_thresh', -6.0))) - - if config.get('bitcrush_enabled', False): - fx_chain.append(Bitcrush(bit_depth=config.get('bitcrush_depth', 8.0))) - - if config.get('gsm_enabled', False): - fx_chain.append(GSMFullRateCompressor()) - - # --- Filters / EQ --- - # HighPass - if config.get('highpass_enabled', False): - fx_chain.append(HighpassFilter(cutoff_frequency_hz=config.get('highpass_freq', 50.0))) - - # LowPass - if config.get('lowpass_enabled', False): - fx_chain.append(LowpassFilter(cutoff_frequency_hz=config.get('lowpass_freq', 10000.0))) - - # Shelves (Bass/Treble) - Simple EQ - bass_db = config.get('eq_bass', 0.0) - if bass_db != 0.0: - fx_chain.append(LowShelfFilter(cutoff_frequency_hz=250, gain_db=bass_db)) - - treble_db = config.get('eq_treble', 0.0) - if treble_db != 0.0: - fx_chain.append(HighShelfFilter(cutoff_frequency_hz=4000, gain_db=treble_db)) - - # --- Spatial / Time --- - if config.get('pitch_shift_enabled', False): - # High quality pitch shifting without duration change - semitones = config.get('pitch_shift_semitones', 0.0) - if semitones != 0: - fx_chain.append(PitchShift(semitones=semitones)) - - if config.get('delay_enabled', False): - fx_chain.append(Delay( - delay_seconds=config.get('delay_time', 0.5), - feedback=config.get('delay_feedback', 0.0), - mix=config.get('delay_mix', 0.5) - )) - - if config.get('reverb_enabled', False): - fx_chain.append(Reverb( - room_size=config.get('reverb_room_size', 0.5), - damping=config.get('reverb_damping', 0.5), - wet_level=config.get('reverb_wet_level', 0.3), - dry_level=config.get('reverb_dry_level', 1.0), - width=config.get('reverb_width', 1.0) - )) - - # --- Dynamics --- - if config.get('comp_enabled', False): - fx_chain.append(Compressor( - threshold_db=config.get('comp_threshold', -20), - ratio=config.get('comp_ratio', 4), - attack_ms=config.get('comp_attack', 1.0), - release_ms=config.get('comp_release', 100.0) - )) - - if config.get('limiter_enabled', False): - fx_chain.append(Limiter( - threshold_db=config.get('limiter_threshold', -1.0), - release_ms=config.get('limiter_release', 100.0) - )) - - if config.get('gain_enabled', False): - db = config.get('gain_db', 0.0) - if db != 0.0: - fx_chain.append(Gain(gain_db=db)) - - if fx_chain: - try: - board = Pedalboard(fx_chain) - # Pedalboard expects float32 - audio = board(audio, sr) - except Exception as e: - print(f"Pedalboard FX failed: {e}") - - # 5. Normalization - if config.get('normalize', False): - peak = np.max(np.abs(audio)) - if peak > 0: - target_peak = 0.98 - audio = audio / peak * target_peak - - return audio - async def init_pipeline_async(self, lang_code="a"): try: self.pipeline = await asyncio.to_thread(KPipeline, lang_code=lang_code) @@ -273,463 +87,10 @@ async def init_pipeline_async(self, lang_code="a"): msg += "\n(Try: pip install fugashi unidic-lite)" elif lang_code == 'z' and "pypinyin" in err_str: msg += "\n(Try: pip install pypinyin)" - - if self.on_status: self.on_status(msg, True) - return False - - async def mix_voices(self, v1_name, v2_name, ratio, new_name, op='mix'): - def _mix(): - try: - # Ensure we have a pipeline to load voices - # Use 'a' as default for mixing if main pipeline is not ready - p = self.pipeline - if not p: - p = get_thread_pipeline('a') - if not p: raise RuntimeError("No pipeline available for mixing") - - # Resolve inputs (handle custom vs standard) - v1_arg = self.resolve_voice_path(v1_name) - v2_arg = self.resolve_voice_path(v2_name) - - # Load tensors - # KPipeline.load_voice returns a tensor - t1 = p.load_voice(v1_arg) - t2 = p.load_voice(v2_arg) - - if t1 is None or t2 is None: - raise ValueError("Failed to load one of the voices.") - - # Ensure they are on CPU for mixing - if isinstance(t1, torch.Tensor): t1 = t1.cpu() - if isinstance(t2, torch.Tensor): t2 = t2.cpu() - - # Check shapes - if t1.shape != t2.shape: - # Try to align? Usually kokoro voices are fixed size [510, 1, 256] - # If different, we might fail or warn. - print(f"Warning: Voice shapes differ {t1.shape} vs {t2.shape}. Mixing might fail or produce garbage.") - - # Apply operation - if op == 'add': - mixed = t1 + t2 * ratio - elif op == 'subtract': - mixed = t1 - t2 * ratio - elif op == 'multiply': - # Lerp between t1 and t1*t2 - mixed = t1 * (1.0 - ratio) + (t1 * t2) * ratio - elif op == 'divide': - # Lerp between t1 and t1/t2 - mixed = t1 * (1.0 - ratio) + (t1 / (t2 + 1e-6)) * ratio - else: # Default: mix (Linear Interpolation) - # mixed = v1 * (1 - ratio) + v2 * ratio - # ratio is mix of B. If ratio 0, full A. If ratio 1, full B. - mixed = t1 * (1.0 - ratio) + t2 * ratio - - # Save - # Sanitize new_name to prevent path traversal - safe_new_name = os.path.basename(new_name) - out_path = os.path.join(CUSTOM_VOICES_DIR, f"{safe_new_name}.pt") - torch.save(mixed, out_path) - return True, out_path, mixed - except Exception as e: - return False, str(e), None - - return await asyncio.to_thread(_mix) - - async def generate_preview(self, text, voice, speed, output_path, extra_config=None, voice_tensor=None, lang_code='a'): - def _gen(): - # Use specific lang code for preview - p = get_thread_pipeline(lang_code) - if not p: return False - - try: - ms_segments = self.parse_multispeaker_text(text) - # Truncate to first 2 segments for preview if many - if len(ms_segments) > 2: - ms_segments = ms_segments[:2] - - all_pieces = [] - - for speaker_name, fx_name, segment_text in ms_segments: - # Apply Lexicon if provided in extra_config - if extra_config and 'lexicon' in extra_config: - segment_text = self.apply_lexicon(segment_text, extra_config['lexicon']) - - # Truncate segment text if too long for preview - if len(segment_text) > 500: - segment_text = segment_text[:500] - - target_voice = voice - target_speed = speed - target_extra = extra_config.copy() if extra_config else {} - - if speaker_name: - preset = self.load_preset(speaker_name) - if preset: - target_voice = preset.get('voice', target_voice) - target_speed = preset.get('speed', target_speed) - if 'volume' in preset: target_extra['volume'] = preset['volume'] - if 'pitch' in preset: target_extra['pitch'] = preset['pitch'] - if 'normalize' in preset: target_extra['normalize'] = preset['normalize'] - if 'trim' in preset: target_extra['trim_silence'] = preset['trim'] - # If speaker preset has an FX preset, it can be overridden by the colon syntax - if 'fx_preset' in preset: - target_extra['fx_preset'] = preset['fx_preset'] - if 'apply_fx' in preset: - target_extra['apply_fx'] = preset['apply_fx'] - - if fx_name: - fx_preset = self.load_fx_preset(fx_name) - if fx_preset: - target_extra.update(fx_preset) - target_extra['apply_fx'] = True - target_extra['fx_preset'] = fx_name - - # Resolve voice - if voice_tensor is not None and not speaker_name: - # Only use voice_tensor if no speaker name (direct preview of mix) - actual_voice = "_preview_temp" - p.voices[actual_voice] = voice_tensor - else: - actual_voice = self.resolve_voice_path(target_voice) - - # Pitch Compensation - eff_speed = target_speed - pitch_st = target_extra.get('pitch', 0.0) - if pitch_st != 0.0: - factor = 2 ** (pitch_st / 12.0) - eff_speed = target_speed / factor - - # Generate - generator = p(segment_text, voice=actual_voice, speed=eff_speed, split_pattern=r"\n+") - for _, _, audio in generator: - if isinstance(audio, torch.Tensor): - audio = audio.cpu().numpy() - # Post Process - audio = self.process_audio(audio, 24000, target_extra) - all_pieces.append(audio) - - if not all_pieces: - return False - - full_audio = np.concatenate(all_pieces) - - try: - with AudioFile(output_path, 'w', samplerate=24000, num_channels=1) as f: - f.write(full_audio) - return True - except Exception as e: - print(f"Preview write error: {e}") - # Fallback - sf.write(output_path, full_audio, 24000) - return True - except Exception as e: - print(f"Preview error: {e}") - return False - return await asyncio.to_thread(_gen) - - def extract_text_from_file(self, fpath): - if not os.path.exists(fpath): - raise FileNotFoundError("File does not exist.") - - text_data = "" - lower_path = fpath.lower() - - if lower_path.endswith(".pdf"): - reader = pypdf.PdfReader(fpath) - for page in reader.pages: - extracted = page.extract_text() - if extracted: - text_data += extracted + "\n\n" - - elif lower_path.endswith(".epub"): - book = epub.read_epub(fpath, options={'ignore_ncx': True}) - for item in book.get_items(): - if item.get_type() == ebooklib.ITEM_DOCUMENT: - soup = BeautifulSoup(item.get_content(), 'html.parser') - text_data += soup.get_text(separator='\n\n') + "\n\n" - else: - # Assume text based - with open(fpath, "r", encoding="utf-8") as f: - text_data = f.read() - - return text_data - - def parse_multispeaker_text(self, text): - """ - Parses text for [PresetName]: or [PresetName:FXPresetName]: syntax. - Returns a list of (speaker_name, fx_name, text_segment) - """ - # Regex to find [Name]: or [Name:FX]: - - pattern = r"\[([^\]\n]{1,100})\]:\s*" - matches = list(re.finditer(pattern, text)) - - if not matches: - return [(None, None, text)] - - segments = [] - for i in range(len(matches)): - raw_name = matches[i].group(1) - speaker_name = raw_name - fx_name = None - - if ":" in raw_name: - parts = raw_name.split(":", 1) - speaker_name = parts[0].strip() - fx_name = parts[1].strip() - - start = matches[i].end() - end = matches[i+1].start() if i+1 < len(matches) else len(text) - segment_text = text[start:end].strip() - if segment_text: - segments.append((speaker_name, fx_name, segment_text)) - - return segments - - def load_preset(self, name): - """Loads a preset from the presets directory.""" - # Sanitize name to prevent path traversal - safe_name = os.path.basename(name) - preset_path = os.path.join("presets", f"{safe_name}.json") - if os.path.exists(preset_path): - try: - with open(preset_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - print(f"Error loading preset {name}: {e}") - return None - - def load_fx_preset(self, name): - """Loads an FX preset from the presets/fx directory.""" - # Sanitize name to prevent path traversal - safe_name = os.path.basename(name) - fx_path = os.path.join("presets", "fx", f"{safe_name}.json") - if os.path.exists(fx_path): - try: - with open(fx_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - print(f"Error loading FX preset {name}: {e}") - return None - - def smart_split(self, text, chunk_size=3000): - chunks = [] - current_chunk = [] - current_len = 0 - paragraphs = text.split('\n\n') - - for para in paragraphs: - if len(para) > chunk_size: - lines = para.split('\n') - for line in lines: - if current_len + len(line) > chunk_size and current_chunk: - chunks.append("\n".join(current_chunk)) - current_chunk = [] - current_len = 0 - current_chunk.append(line) - current_len += len(line) - else: - if current_len + len(para) > chunk_size and current_chunk: - chunks.append("\n\n".join(current_chunk)) - current_chunk = [] - current_len = 0 - current_chunk.append(para) - current_len += len(para) - - if current_chunk: - chunks.append("\n\n".join(current_chunk)) - return [c for c in chunks if c.strip()] - - def generate_srt(self, segments, output_path): - def format_time(seconds): - millis = int((seconds - int(seconds)) * 1000) - seconds = int(seconds) - minutes, seconds = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - return f"{hours:02}:{minutes:02}:{seconds:02},{millis:03}" - - try: - with open(output_path, "w", encoding="utf-8") as f: - current_time = 0.0 - for i, seg in enumerate(segments): - start = current_time - end = current_time + seg['duration'] - f.write(f"{i+1}\n") - f.write(f"{format_time(start)} --> {format_time(end)}\n") - f.write(f"{seg['text'].strip()}\n\n") - current_time = end - return True - except Exception as e: - print(f"Failed to generate SRT: {e}") + if self.on_status: self.on_status(msg, True) return False - def process_chunk_task(self, chunk_data, progress_callback): - index, text, config = chunk_data - if self.cancel_event.is_set(): return [] - - # Use lang_code from config, default to 'a' - lang_code = config.get('lang_code', 'a') - - # Speed Adjustment for Pitch Compensation - eff_speed = config['speed'] - pitch_semitones = config.get('pitch', 0.0) - if pitch_semitones != 0.0: - factor = 2 ** (pitch_semitones / 12.0) - eff_speed = eff_speed / factor - - # --- Caching Check (WAV only) --- - use_cache = config.get('caching', True) - cache_hash = None - cached_segments = [] - - if use_cache: - to_hash = f"{text}|{config['voice']}|{eff_speed}|{lang_code}" - cache_hash = hashlib.md5(to_hash.encode('utf-8')).hexdigest() - - # Predict segments to verify cache integrity - try: - # Mimic KPipeline splitting logic roughly to align with file indices - # Note: KPipeline might strip whitespace or handle things slightly differently. - # This is a heuristic. If file count matches segment count, we assume cache is valid. - split_pat = config.get('split_pattern', r"\n+") - predicted_texts = [t.strip() for t in re.split(split_pat, text) if t.strip()] - - if not predicted_texts: - # If text is empty/whitespace but passed here, treat as single empty? - # Usually smart_split handles this. - predicted_texts = [] - - all_exist = True - loaded_data = [] - - if predicted_texts: - for i, seg_text in enumerate(predicted_texts): - f_name = f"{cache_hash}_{i}.wav" - f_path = os.path.join(CACHE_DIR, f_name) - if not os.path.exists(f_path): - all_exist = False - break - # Load raw audio - audio_data, _ = sf.read(f_path) - loaded_data.append((seg_text, '', audio_data)) # phonemes empty - - # Ensure no extra files (e.g. from a previous run with same hash but more splits?) - # Hash includes text, so split count shouldn't change unless split_pattern changes. - # If split_pattern changes, hash logic might not capture it unless we add pattern to hash. - # Ideally we should add split_pattern to hash, but current requirement is simpler. - # For now, if we found all expected parts, we accept it. - else: - all_exist = False # Empty text logic usually handled before - - if all_exist and loaded_data: - cached_segments = loaded_data - except Exception as e: - print(f"Cache check error: {e}") - cached_segments = [] - - chunk_files = [] - sub_idx = 0 - base_name = f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_part{index}" - - # Function to process raw audio (from cache or gen) into final output - def process_and_save(graphemes, raw_audio): - nonlocal sub_idx - - # Post Process - processed_audio = self.process_audio(raw_audio, 24000, config) - - # Determine format - fmt = config.get('format', 'wav').lower() - if fmt not in ['wav', 'flac', 'mp3', 'ogg']: fmt = 'wav' - - file_name = f"{base_name}_{sub_idx}.{fmt}" - path = os.path.join(config['out_dir'], file_name) - - try: - # Use Pedalboard AudioFile for writing - with AudioFile(path, 'w', samplerate=24000, num_channels=1) as f: - f.write(processed_audio) - except Exception as e: - print(f"Pedalboard write failed: {e}. Fallback to soundfile.") - sf.write(path, processed_audio, 24000) - - return { - "path": path, - "text": graphemes, - "duration": len(processed_audio) / 24000.0, - "seg_idx": index - } - - if cached_segments: - # Use Cache - for graphemes, phonemes, audio in cached_segments: - if self.cancel_event.is_set(): break - if progress_callback: progress_callback(len(graphemes), graphemes) - - res = process_and_save(graphemes, audio) - chunk_files.append(res) - sub_idx += 1 - else: - # Generate - pipeline = get_thread_pipeline(lang_code) - if not pipeline: raise RuntimeError(f"Failed to initialize pipeline ({lang_code}) in thread.") - - generator = pipeline(text, voice=config['voice'], speed=eff_speed, split_pattern=config['split_pattern']) - - for graphemes, phonemes, audio in generator: - if self.cancel_event.is_set(): break - - # Notify progress - if progress_callback: - progress_callback(len(graphemes), graphemes) - - if isinstance(audio, torch.Tensor): - audio = audio.cpu().numpy() - - # Save to Cache if enabled - if use_cache and cache_hash: - cache_filename = f"{cache_hash}_{sub_idx}.wav" - cache_path = os.path.join(CACHE_DIR, cache_filename) - try: - sf.write(cache_path, audio, 24000) - except Exception as e: - print(f"Cache write error: {e}") - - # Process for output - res = process_and_save(graphemes, audio) - chunk_files.append(res) - sub_idx += 1 - - return chunk_files - - async def smart_combine(self, file_paths, output_path, update_callback): - def combine_worker(): - total_files = len(file_paths) - try: - # Use Pedalboard AudioFile - with AudioFile(output_path, 'w', samplerate=24000, num_channels=1) as out_f: - for i, fp in enumerate(file_paths): - if self.cancel_event.is_set(): break - try: - # Read with SoundFile (reliable for reading various formats) - data, _ = sf.read(fp) - out_f.write(data) - if update_callback: update_callback((i + 1) / total_files) - except Exception as e: - print(f"Failed to read segment {fp}: {e}") - except Exception as e: - print(f"Combine failed: {e}") - await asyncio.to_thread(combine_worker) - - def start_conversion(self, text, config): - # Resolve voice path once before distribution - config['voice'] = self.resolve_voice_path(config['voice']) - - self.cancel_event.clear() - self.worker.run_coro(self._process_text_async(text, config)) - def cancel(self): self.cancel_event.set() try: @@ -737,325 +98,3 @@ def cancel(self): playback.stop() except Exception: pass - - def start_jit_conversion(self, text, config): - """Starts real-time generation and playback.""" - config['voice'] = self.resolve_voice_path(config['voice']) - self.cancel_event.clear() - self.worker.run_coro(self._process_jit_async(text, config)) - - async def _process_jit_async(self, text, config): - """ - JIT Logic: - 1. Parse text into segments. - 2. Generation thread fills a queue. - 3. Playback thread consumes the queue. - 4. Buffer management (2 mins ahead). - """ - try: - if self.on_status: self.on_status("JIT: Preparing...", False) - os.makedirs(config['out_dir'], exist_ok=True) - - # 1. Parse segments - ms_segments = self.parse_multispeaker_text(text) - all_text_segments = [] - lexicon = config.get('lexicon', {}) - - for speaker_name, fx_name, segment_text in ms_segments: - segment_text = self.apply_lexicon(segment_text, lexicon) - seg_config = config.copy() - seg_config['format'] = 'wav' # Force wav for JIT playback compatibility - if speaker_name: - preset = self.load_preset(speaker_name) - if preset: - seg_config.update(preset) - if 'trim' in preset: - seg_config['trim_silence'] = preset['trim'] - seg_config['format'] = 'wav' # Ensure preset doesn't override format to non-wav - seg_config['voice'] = self.resolve_voice_path(seg_config['voice']) - - if fx_name: - fx_preset = self.load_fx_preset(fx_name) - if fx_preset: - seg_config.update(fx_preset) - seg_config['apply_fx'] = True - seg_config['fx_preset'] = fx_name - - # Split into smaller chunks for JIT (sentences/short paragraphs) - chunks = self.smart_split(segment_text, chunk_size=500) # Small chunks for fast start - for c in chunks: - all_text_segments.append((c, seg_config)) - - if not all_text_segments: - if self.on_status: self.on_status("No text for JIT.", False) - if self.on_finish: self.on_finish() - return - - # Queues and State - audio_queue = asyncio.Queue() - played_segments = [] - generated_but_unplayed = [] - total_segments = len(all_text_segments) - - playback_finished_event = asyncio.Event() - - # --- Generation Loop --- - async def generation_loop(): - nonlocal total_segments - try: - for i, (seg_text, seg_config) in enumerate(all_text_segments): - if self.cancel_event.is_set(): break - - while audio_queue.qsize() > 10 and not self.cancel_event.is_set(): - await asyncio.sleep(0.5) - - if self.cancel_event.is_set(): break - - if self.on_status: - self.on_status(f"JIT: Generating chunk {i+1}/{total_segments}...", False) - - chunk_files = await asyncio.to_thread(self.process_chunk_task, (i, seg_text, seg_config), None) - - for cf in chunk_files: - await audio_queue.put(cf) - generated_but_unplayed.append(cf) - except Exception as e: - print(f"JIT Gen Error: {e}") - finally: - # Always signal end - await audio_queue.put(None) - - # --- Playback Loop --- - async def playback_loop(): - nonlocal played_segments - start_time = time.time() - try: - idx = 0 - while not self.cancel_event.is_set(): - # Use wait_for to allow checking cancel_event periodically - try: - item = await asyncio.wait_for(audio_queue.get(), timeout=1.0) - except asyncio.TimeoutError: - continue - - if item is None: break # End of stream - - idx += 1 - if self.on_status: - self.on_status(f"JIT: Playing chunk {idx}...", False) - - clean_snip = item['text'].replace("\n", " ").strip() - if len(clean_snip) > 40: clean_snip = clean_snip[:37] + "..." - - elapsed = time.time() - start_time - if self.on_progress: - percent = (idx / total_segments) * 100 - self.on_progress(percent, elapsed, "--:--", f"Playing: {clean_snip}") - - # Play audio (Synchronously in thread) - await asyncio.to_thread(playback.play, item['path'], True) - - played_segments.append(item) - if item in generated_but_unplayed: - generated_but_unplayed.remove(item) - - except Exception as e: - print(f"JIT Playback Error: {e}") - finally: - playback_finished_event.set() - - # Start loops - gen_task = asyncio.create_task(generation_loop()) - play_task = asyncio.create_task(playback_loop()) - - await playback_finished_event.wait() - - # --- Cleanup and Save State --- - if self.cancel_event.is_set(): - if self.on_status: self.on_status("JIT Stopped. Saving state...", False) - else: - if self.on_status: self.on_status("JIT Finished.", False) - - # Combine what was played/generated so far - all_work_so_far = played_segments + generated_but_unplayed - if all_work_so_far: - combined_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_jit_output.wav") - await self.smart_combine([s['path'] for s in all_work_so_far], combined_path, None) - if self.on_status: self.on_status(f"JIT Output saved: {combined_path}", False) - - # Save remaining text - if generated_but_unplayed: - first_remaining_idx = generated_but_unplayed[0]['seg_idx'] - elif played_segments: - first_remaining_idx = played_segments[-1]['seg_idx'] + 1 - else: - first_remaining_idx = 0 - - remaining_text = "" - for i in range(first_remaining_idx, total_segments): - remaining_text += all_text_segments[i][0] + "\n\n" - - if remaining_text: - rem_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_remaining.txt") - with open(rem_path, "w", encoding="utf-8") as f: - f.write(remaining_text) - if self.on_status: self.on_status(f"Remaining text saved: {rem_path}", False) - - except Exception as e: - print(f"JIT Critical Error: {e}") - if self.on_status: self.on_status(f"JIT Error: {e}", True) - finally: - if self.on_finish: self.on_finish() - - async def _process_text_async(self, text, config): - try: - if self.on_status: self.on_status("Preparing text...", False) - os.makedirs(config['out_dir'], exist_ok=True) - - num_workers = config.get('num_threads', 1) - - # Multispeaker Support - ms_segments = self.parse_multispeaker_text(text) - tasks_data = [] - - lexicon = config.get('lexicon', {}) - - for speaker_name, fx_name, segment_text in ms_segments: - # Apply Lexicon - segment_text = self.apply_lexicon(segment_text, lexicon) - - seg_config = config.copy() - if speaker_name: - preset = self.load_preset(speaker_name) - if preset: - seg_config.update(preset) - if 'trim' in preset: - seg_config['trim_silence'] = preset['trim'] - # Resolve voice path for the new voice - seg_config['voice'] = self.resolve_voice_path(seg_config['voice']) - else: - if self.on_status: self.on_status(f"Warning: Preset '{speaker_name}' not found.", False) - - if fx_name: - fx_preset = self.load_fx_preset(fx_name) - if fx_preset: - seg_config.update(fx_preset) - seg_config['apply_fx'] = True - seg_config['fx_preset'] = fx_name - else: - if self.on_status: self.on_status(f"Warning: FX Preset '{fx_name}' not found.", False) - - # Split this segment into sub-chunks for parallel processing - # Use same character limit as original - seg_chunks = self.smart_split(segment_text, chunk_size=5000 if num_workers > 1 else 1000000) - for chunk in seg_chunks: - # (index, text, config) - tasks_data.append((len(tasks_data), chunk, seg_config)) - - total_chunks = len(tasks_data) - if total_chunks == 0: - if self.on_status: self.on_status("No text to process.", False) - if self.on_finish: self.on_finish() - return - - total_chars = sum(len(d[1]) for d in tasks_data) - processed_chars = 0 - start_time = time.time() - phase_weight = 0.9 if config.get('combine', True) else 1.0 - - if self.on_status: self.on_status(f"Queued {total_chunks} blocks. Starting {num_workers} workers...", False) - - # Progress tracker - progress_lock = threading.Lock() - - def on_chunk_progress(char_count, snippet): - nonlocal processed_chars - with progress_lock: - processed_chars += char_count - - # Calculate progress and call main callback - elapsed = time.time() - start_time - gen_fraction = min(processed_chars / total_chars, 1.0) - total_fraction = gen_fraction * phase_weight - - # Estimate ETA - eta_str = "--:--" - if total_fraction > 0.01: - total_est = elapsed / total_fraction - rem = max(0, total_est - elapsed) - eta_str = time.strftime('%M:%S', time.gmtime(rem)) - - clean_snip = snippet.replace("\n", " ").strip() - if len(clean_snip) > 40: clean_snip = clean_snip[:37] + "..." - - if self.on_progress: - self.on_progress(total_fraction * 100, elapsed, eta_str, f"Processing: {clean_snip}") - - # All generated files list - all_generated_files = [None] * total_chunks - - loop = asyncio.get_running_loop() - - with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: - futures = [] - for i, data in enumerate(tasks_data): - fut = loop.run_in_executor(executor, self.process_chunk_task, data, on_chunk_progress) - futures.append(fut) - - results = await asyncio.gather(*futures, return_exceptions=True) - - for i, result in enumerate(results): - if isinstance(result, Exception): - print(f"Chunk {i} failed: {result}") - if self.on_status: self.on_status(f"Error in chunk {i}", True) - else: - all_generated_files[i] = result - - if self.cancel_event.is_set(): - if self.on_status: self.on_status("Conversion Cancelled.", False) - if self.on_finish: self.on_finish() - return - - final_segment_list = [] - for sublist in all_generated_files: - if sublist: final_segment_list.extend(sublist) - - final_file_paths = [seg['path'] for seg in final_segment_list] - - if self.on_status: self.on_status(f"Generated {len(final_segment_list)} segments. Processing outputs...", False) - - if config.get('export_subtitles', False) and final_segment_list: - srt_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_combined.srt") - self.generate_srt(final_segment_list, srt_path) - - if config.get('combine', True) and final_file_paths: - if self.on_status: self.on_status("Merging audio files...", False) - - fmt = config.get('format', 'wav').lower() - combine_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_combined.{fmt}") - - def on_merge_progress(frac): - total_fraction = (1.0 * phase_weight) + (frac * (1.0 - phase_weight)) - elapsed = time.time() - start_time - if self.on_progress: - self.on_progress(total_fraction * 100, elapsed, "00:00", f"Merging... {int(frac*100)}%") - - await self.smart_combine(final_file_paths, combine_path, on_merge_progress) - - if not config.get('separate', True): - for p in final_file_paths: - try: os.remove(p) - except Exception: pass - - if self.on_status: self.on_status(f"Done! Saved: {combine_path}", False) - else: - if self.on_status: self.on_status("Conversion Complete!", False) - - if self.on_progress: - self.on_progress(100, time.time() - start_time, "00:00", "Completed") - - except Exception as e: - print(e) - if self.on_status: self.on_status(f"Critical Error: {e}", True) - finally: - if self.on_finish: self.on_finish() diff --git a/kokoro_gui/__init__.py b/kokoro_gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kokoro_gui/engine/__init__.py b/kokoro_gui/engine/__init__.py new file mode 100644 index 0000000..1f85bcb --- /dev/null +++ b/kokoro_gui/engine/__init__.py @@ -0,0 +1,21 @@ +from .audio_fx import AudioFXMixin +from .caching import CachingMixin +from .conversion import ConversionMixin +from .jit import JITMixin +from .lexicon import LexiconMixin +from .presets import PresetsMixin +from .srt import SrtMixin +from .text_extraction import TextExtractionMixin +from .voices import VoiceMixingMixin + +__all__ = [ + "AudioFXMixin", + "CachingMixin", + "ConversionMixin", + "JITMixin", + "LexiconMixin", + "PresetsMixin", + "SrtMixin", + "TextExtractionMixin", + "VoiceMixingMixin", +] diff --git a/kokoro_gui/engine/audio_fx.py b/kokoro_gui/engine/audio_fx.py new file mode 100644 index 0000000..996fbe2 --- /dev/null +++ b/kokoro_gui/engine/audio_fx.py @@ -0,0 +1,152 @@ +"""Post-processing audio FX chain (pitch, volume, Pedalboard FX, normalize, trim).""" +import numpy as np +import scipy.signal +from pedalboard import ( + Pedalboard, Reverb, Compressor, HighShelfFilter, LowShelfFilter, + Chorus, Distortion, Phaser, Clipping, Gain, Limiter, + HighpassFilter, LowpassFilter, LadderFilter, Delay, PitchShift, + GSMFullRateCompressor, Bitcrush +) + + +class AudioFXMixin: + def process_audio(self, audio, sr, config): + """ + Apply post-processing: Pitch (Resample), Volume, FX (Reverb, EQ, Comp), Normalize, Trim. + Returns: (processed_audio, new_sr) + """ + # 1. Trim Silence (Simple threshold) + if config.get('trim_silence', False): + threshold = 0.01 + # Find first index > threshold + mask = np.abs(audio) > threshold + if np.any(mask): + start = np.argmax(mask) + end = len(audio) - np.argmax(mask[::-1]) + audio = audio[start:end] + + # 2. Volume / Gain + vol = config.get('volume', 1.0) + if vol != 1.0: + audio = audio * vol + + # 3. Pitch Shift (Resampling) + pitch_semitones = config.get('pitch', 0.0) + if pitch_semitones != 0.0: + factor = 2 ** (pitch_semitones / 12.0) + new_len = int(len(audio) / factor) + if new_len > 0: + try: + audio = scipy.signal.resample(audio, new_len) + except Exception as e: + print(f"Resample failed: {e}") + + # 4. Pedalboard FX + fx_chain = [] + + if config.get('apply_fx', True): + # --- Guitar / Modulation --- + if config.get('distortion_enabled', False): + drive = config.get('distortion_drive', 25.0) + fx_chain.append(Distortion(drive_db=drive)) + + if config.get('chorus_enabled', False): + fx_chain.append(Chorus( + rate_hz=config.get('chorus_rate', 1.0), + depth=config.get('chorus_depth', 0.25), + mix=config.get('chorus_mix', 0.5) + )) + + if config.get('phaser_enabled', False): + fx_chain.append(Phaser( + rate_hz=config.get('phaser_rate', 1.0), + depth=config.get('phaser_depth', 0.5), + mix=config.get('phaser_mix', 0.5) + )) + + if config.get('clipping_enabled', False): + fx_chain.append(Clipping(threshold_db=config.get('clipping_thresh', -6.0))) + + if config.get('bitcrush_enabled', False): + fx_chain.append(Bitcrush(bit_depth=config.get('bitcrush_depth', 8.0))) + + if config.get('gsm_enabled', False): + fx_chain.append(GSMFullRateCompressor()) + + # --- Filters / EQ --- + # HighPass + if config.get('highpass_enabled', False): + fx_chain.append(HighpassFilter(cutoff_frequency_hz=config.get('highpass_freq', 50.0))) + + # LowPass + if config.get('lowpass_enabled', False): + fx_chain.append(LowpassFilter(cutoff_frequency_hz=config.get('lowpass_freq', 10000.0))) + + # Shelves (Bass/Treble) - Simple EQ + bass_db = config.get('eq_bass', 0.0) + if bass_db != 0.0: + fx_chain.append(LowShelfFilter(cutoff_frequency_hz=250, gain_db=bass_db)) + + treble_db = config.get('eq_treble', 0.0) + if treble_db != 0.0: + fx_chain.append(HighShelfFilter(cutoff_frequency_hz=4000, gain_db=treble_db)) + + # --- Spatial / Time --- + if config.get('pitch_shift_enabled', False): + # High quality pitch shifting without duration change + semitones = config.get('pitch_shift_semitones', 0.0) + if semitones != 0: + fx_chain.append(PitchShift(semitones=semitones)) + + if config.get('delay_enabled', False): + fx_chain.append(Delay( + delay_seconds=config.get('delay_time', 0.5), + feedback=config.get('delay_feedback', 0.0), + mix=config.get('delay_mix', 0.5) + )) + + if config.get('reverb_enabled', False): + fx_chain.append(Reverb( + room_size=config.get('reverb_room_size', 0.5), + damping=config.get('reverb_damping', 0.5), + wet_level=config.get('reverb_wet_level', 0.3), + dry_level=config.get('reverb_dry_level', 1.0), + width=config.get('reverb_width', 1.0) + )) + + # --- Dynamics --- + if config.get('comp_enabled', False): + fx_chain.append(Compressor( + threshold_db=config.get('comp_threshold', -20), + ratio=config.get('comp_ratio', 4), + attack_ms=config.get('comp_attack', 1.0), + release_ms=config.get('comp_release', 100.0) + )) + + if config.get('limiter_enabled', False): + fx_chain.append(Limiter( + threshold_db=config.get('limiter_threshold', -1.0), + release_ms=config.get('limiter_release', 100.0) + )) + + if config.get('gain_enabled', False): + db = config.get('gain_db', 0.0) + if db != 0.0: + fx_chain.append(Gain(gain_db=db)) + + if fx_chain: + try: + board = Pedalboard(fx_chain) + # Pedalboard expects float32 + audio = board(audio, sr) + except Exception as e: + print(f"Pedalboard FX failed: {e}") + + # 5. Normalization + if config.get('normalize', False): + peak = np.max(np.abs(audio)) + if peak > 0: + target_peak = 0.98 + audio = audio / peak * target_peak + + return audio diff --git a/kokoro_gui/engine/caching.py b/kokoro_gui/engine/caching.py new file mode 100644 index 0000000..222fa43 --- /dev/null +++ b/kokoro_gui/engine/caching.py @@ -0,0 +1,157 @@ +"""Per-chunk generation with WAV segment caching, keyed on text|voice|speed|lang_code. + +Reads `kokoro_engine.CACHE_DIR` and calls `kokoro_engine.get_thread_pipeline` +qualified, at call time, so tests can keep monkeypatching those names on the +`kokoro_engine` module (e.g. the `isolated_dirs`/`make_config` fixtures and the +`_boom` sentinel used in `test_caching.py`/`test_mix_voices.py`). +""" +import hashlib +import os +import re + +import soundfile as sf +import torch +from pedalboard.io import AudioFile + +import kokoro_engine + + +class CachingMixin: + def process_chunk_task(self, chunk_data, progress_callback): + index, text, config = chunk_data + if self.cancel_event.is_set(): return [] + + # Use lang_code from config, default to 'a' + lang_code = config.get('lang_code', 'a') + + # Speed Adjustment for Pitch Compensation + eff_speed = config['speed'] + pitch_semitones = config.get('pitch', 0.0) + if pitch_semitones != 0.0: + factor = 2 ** (pitch_semitones / 12.0) + eff_speed = eff_speed / factor + + # --- Caching Check (WAV only) --- + use_cache = config.get('caching', True) + cache_hash = None + cached_segments = [] + + if use_cache: + to_hash = f"{text}|{config['voice']}|{eff_speed}|{lang_code}" + cache_hash = hashlib.md5(to_hash.encode('utf-8')).hexdigest() + + # Predict segments to verify cache integrity + try: + # Mimic KPipeline splitting logic roughly to align with file indices + # Note: KPipeline might strip whitespace or handle things slightly differently. + # This is a heuristic. If file count matches segment count, we assume cache is valid. + split_pat = config.get('split_pattern', r"\n+") + predicted_texts = [t.strip() for t in re.split(split_pat, text) if t.strip()] + + if not predicted_texts: + # If text is empty/whitespace but passed here, treat as single empty? + # Usually smart_split handles this. + predicted_texts = [] + + all_exist = True + loaded_data = [] + + if predicted_texts: + for i, seg_text in enumerate(predicted_texts): + f_name = f"{cache_hash}_{i}.wav" + f_path = os.path.join(kokoro_engine.CACHE_DIR, f_name) + if not os.path.exists(f_path): + all_exist = False + break + # Load raw audio + audio_data, _ = sf.read(f_path) + loaded_data.append((seg_text, '', audio_data)) # phonemes empty + + # Ensure no extra files (e.g. from a previous run with same hash but more splits?) + # Hash includes text, so split count shouldn't change unless split_pattern changes. + # If split_pattern changes, hash logic might not capture it unless we add pattern to hash. + # Ideally we should add split_pattern to hash, but current requirement is simpler. + # For now, if we found all expected parts, we accept it. + else: + all_exist = False # Empty text logic usually handled before + + if all_exist and loaded_data: + cached_segments = loaded_data + except Exception as e: + print(f"Cache check error: {e}") + cached_segments = [] + + chunk_files = [] + sub_idx = 0 + base_name = f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_part{index}" + + # Function to process raw audio (from cache or gen) into final output + def process_and_save(graphemes, raw_audio): + nonlocal sub_idx + + # Post Process + processed_audio = self.process_audio(raw_audio, 24000, config) + + # Determine format + fmt = config.get('format', 'wav').lower() + if fmt not in ['wav', 'flac', 'mp3', 'ogg']: fmt = 'wav' + + file_name = f"{base_name}_{sub_idx}.{fmt}" + path = os.path.join(config['out_dir'], file_name) + + try: + # Use Pedalboard AudioFile for writing + with AudioFile(path, 'w', samplerate=24000, num_channels=1) as f: + f.write(processed_audio) + except Exception as e: + print(f"Pedalboard write failed: {e}. Fallback to soundfile.") + sf.write(path, processed_audio, 24000) + + return { + "path": path, + "text": graphemes, + "duration": len(processed_audio) / 24000.0, + "seg_idx": index + } + + if cached_segments: + # Use Cache + for graphemes, phonemes, audio in cached_segments: + if self.cancel_event.is_set(): break + if progress_callback: progress_callback(len(graphemes), graphemes) + + res = process_and_save(graphemes, audio) + chunk_files.append(res) + sub_idx += 1 + else: + # Generate + pipeline = kokoro_engine.get_thread_pipeline(lang_code) + if not pipeline: raise RuntimeError(f"Failed to initialize pipeline ({lang_code}) in thread.") + + generator = pipeline(text, voice=config['voice'], speed=eff_speed, split_pattern=config['split_pattern']) + + for graphemes, phonemes, audio in generator: + if self.cancel_event.is_set(): break + + # Notify progress + if progress_callback: + progress_callback(len(graphemes), graphemes) + + if isinstance(audio, torch.Tensor): + audio = audio.cpu().numpy() + + # Save to Cache if enabled + if use_cache and cache_hash: + cache_filename = f"{cache_hash}_{sub_idx}.wav" + cache_path = os.path.join(kokoro_engine.CACHE_DIR, cache_filename) + try: + sf.write(cache_path, audio, 24000) + except Exception as e: + print(f"Cache write error: {e}") + + # Process for output + res = process_and_save(graphemes, audio) + chunk_files.append(res) + sub_idx += 1 + + return chunk_files diff --git a/kokoro_gui/engine/conversion.py b/kokoro_gui/engine/conversion.py new file mode 100644 index 0000000..e2dc5f6 --- /dev/null +++ b/kokoro_gui/engine/conversion.py @@ -0,0 +1,293 @@ +"""Batch conversion lifecycle: single-clip preview generation, the parallel +chunked "Standard" batch pipeline (`start_conversion` -> `_process_text_async`), +and the WAV-segment combiner shared with JIT mode. + +`generate_preview` calls `kokoro_engine.get_thread_pipeline` qualified, at call +time, so tests can keep monkeypatching that name on the `kokoro_engine` module. +""" +import asyncio +import concurrent.futures +import os +import threading +import time + +import numpy as np +import soundfile as sf +import torch +from pedalboard.io import AudioFile + +import kokoro_engine + + +class ConversionMixin: + async def generate_preview(self, text, voice, speed, output_path, extra_config=None, voice_tensor=None, lang_code='a'): + def _gen(): + # Use specific lang code for preview + p = kokoro_engine.get_thread_pipeline(lang_code) + if not p: return False + + try: + ms_segments = self.parse_multispeaker_text(text) + # Truncate to first 2 segments for preview if many + if len(ms_segments) > 2: + ms_segments = ms_segments[:2] + + all_pieces = [] + + for speaker_name, fx_name, segment_text in ms_segments: + # Apply Lexicon if provided in extra_config + if extra_config and 'lexicon' in extra_config: + segment_text = self.apply_lexicon(segment_text, extra_config['lexicon']) + + # Truncate segment text if too long for preview + if len(segment_text) > 500: + segment_text = segment_text[:500] + + target_voice = voice + target_speed = speed + target_extra = extra_config.copy() if extra_config else {} + + if speaker_name: + preset = self.load_preset(speaker_name) + if preset: + target_voice = preset.get('voice', target_voice) + target_speed = preset.get('speed', target_speed) + if 'volume' in preset: target_extra['volume'] = preset['volume'] + if 'pitch' in preset: target_extra['pitch'] = preset['pitch'] + if 'normalize' in preset: target_extra['normalize'] = preset['normalize'] + if 'trim' in preset: target_extra['trim_silence'] = preset['trim'] + # If speaker preset has an FX preset, it can be overridden by the colon syntax + if 'fx_preset' in preset: + target_extra['fx_preset'] = preset['fx_preset'] + if 'apply_fx' in preset: + target_extra['apply_fx'] = preset['apply_fx'] + + if fx_name: + fx_preset = self.load_fx_preset(fx_name) + if fx_preset: + target_extra.update(fx_preset) + target_extra['apply_fx'] = True + target_extra['fx_preset'] = fx_name + + # Resolve voice + if voice_tensor is not None and not speaker_name: + # Only use voice_tensor if no speaker name (direct preview of mix) + actual_voice = "_preview_temp" + p.voices[actual_voice] = voice_tensor + else: + actual_voice = self.resolve_voice_path(target_voice) + + # Pitch Compensation + eff_speed = target_speed + pitch_st = target_extra.get('pitch', 0.0) + if pitch_st != 0.0: + factor = 2 ** (pitch_st / 12.0) + eff_speed = target_speed / factor + + # Generate + generator = p(segment_text, voice=actual_voice, speed=eff_speed, split_pattern=r"\n+") + for _, _, audio in generator: + if isinstance(audio, torch.Tensor): + audio = audio.cpu().numpy() + # Post Process + audio = self.process_audio(audio, 24000, target_extra) + all_pieces.append(audio) + + if not all_pieces: + return False + + full_audio = np.concatenate(all_pieces) + + try: + with AudioFile(output_path, 'w', samplerate=24000, num_channels=1) as f: + f.write(full_audio) + return True + except Exception as e: + print(f"Preview write error: {e}") + # Fallback + sf.write(output_path, full_audio, 24000) + return True + except Exception as e: + print(f"Preview error: {e}") + return False + + return await asyncio.to_thread(_gen) + + async def smart_combine(self, file_paths, output_path, update_callback): + def combine_worker(): + total_files = len(file_paths) + try: + # Use Pedalboard AudioFile + with AudioFile(output_path, 'w', samplerate=24000, num_channels=1) as out_f: + for i, fp in enumerate(file_paths): + if self.cancel_event.is_set(): break + try: + # Read with SoundFile (reliable for reading various formats) + data, _ = sf.read(fp) + out_f.write(data) + if update_callback: update_callback((i + 1) / total_files) + except Exception as e: + print(f"Failed to read segment {fp}: {e}") + except Exception as e: + print(f"Combine failed: {e}") + await asyncio.to_thread(combine_worker) + + def start_conversion(self, text, config): + # Resolve voice path once before distribution + config['voice'] = self.resolve_voice_path(config['voice']) + + self.cancel_event.clear() + self.worker.run_coro(self._process_text_async(text, config)) + + async def _process_text_async(self, text, config): + try: + if self.on_status: self.on_status("Preparing text...", False) + os.makedirs(config['out_dir'], exist_ok=True) + + num_workers = config.get('num_threads', 1) + + # Multispeaker Support + ms_segments = self.parse_multispeaker_text(text) + tasks_data = [] + + lexicon = config.get('lexicon', {}) + + for speaker_name, fx_name, segment_text in ms_segments: + # Apply Lexicon + segment_text = self.apply_lexicon(segment_text, lexicon) + + seg_config = config.copy() + if speaker_name: + preset = self.load_preset(speaker_name) + if preset: + seg_config.update(preset) + if 'trim' in preset: + seg_config['trim_silence'] = preset['trim'] + # Resolve voice path for the new voice + seg_config['voice'] = self.resolve_voice_path(seg_config['voice']) + else: + if self.on_status: self.on_status(f"Warning: Preset '{speaker_name}' not found.", False) + + if fx_name: + fx_preset = self.load_fx_preset(fx_name) + if fx_preset: + seg_config.update(fx_preset) + seg_config['apply_fx'] = True + seg_config['fx_preset'] = fx_name + else: + if self.on_status: self.on_status(f"Warning: FX Preset '{fx_name}' not found.", False) + + # Split this segment into sub-chunks for parallel processing + # Use same character limit as original + seg_chunks = self.smart_split(segment_text, chunk_size=5000 if num_workers > 1 else 1000000) + for chunk in seg_chunks: + # (index, text, config) + tasks_data.append((len(tasks_data), chunk, seg_config)) + + total_chunks = len(tasks_data) + if total_chunks == 0: + if self.on_status: self.on_status("No text to process.", False) + if self.on_finish: self.on_finish() + return + + total_chars = sum(len(d[1]) for d in tasks_data) + processed_chars = 0 + start_time = time.time() + phase_weight = 0.9 if config.get('combine', True) else 1.0 + + if self.on_status: self.on_status(f"Queued {total_chunks} blocks. Starting {num_workers} workers...", False) + + # Progress tracker + progress_lock = threading.Lock() + + def on_chunk_progress(char_count, snippet): + nonlocal processed_chars + with progress_lock: + processed_chars += char_count + + # Calculate progress and call main callback + elapsed = time.time() - start_time + gen_fraction = min(processed_chars / total_chars, 1.0) + total_fraction = gen_fraction * phase_weight + + # Estimate ETA + eta_str = "--:--" + if total_fraction > 0.01: + total_est = elapsed / total_fraction + rem = max(0, total_est - elapsed) + eta_str = time.strftime('%M:%S', time.gmtime(rem)) + + clean_snip = snippet.replace("\n", " ").strip() + if len(clean_snip) > 40: clean_snip = clean_snip[:37] + "..." + + if self.on_progress: + self.on_progress(total_fraction * 100, elapsed, eta_str, f"Processing: {clean_snip}") + + # All generated files list + all_generated_files = [None] * total_chunks + + loop = asyncio.get_running_loop() + + with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = [] + for i, data in enumerate(tasks_data): + fut = loop.run_in_executor(executor, self.process_chunk_task, data, on_chunk_progress) + futures.append(fut) + + results = await asyncio.gather(*futures, return_exceptions=True) + + for i, result in enumerate(results): + if isinstance(result, Exception): + print(f"Chunk {i} failed: {result}") + if self.on_status: self.on_status(f"Error in chunk {i}", True) + else: + all_generated_files[i] = result + + if self.cancel_event.is_set(): + if self.on_status: self.on_status("Conversion Cancelled.", False) + if self.on_finish: self.on_finish() + return + + final_segment_list = [] + for sublist in all_generated_files: + if sublist: final_segment_list.extend(sublist) + + final_file_paths = [seg['path'] for seg in final_segment_list] + + if self.on_status: self.on_status(f"Generated {len(final_segment_list)} segments. Processing outputs...", False) + + if config.get('export_subtitles', False) and final_segment_list: + srt_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_combined.srt") + self.generate_srt(final_segment_list, srt_path) + + if config.get('combine', True) and final_file_paths: + if self.on_status: self.on_status("Merging audio files...", False) + + fmt = config.get('format', 'wav').lower() + combine_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_combined.{fmt}") + + def on_merge_progress(frac): + total_fraction = (1.0 * phase_weight) + (frac * (1.0 - phase_weight)) + elapsed = time.time() - start_time + if self.on_progress: + self.on_progress(total_fraction * 100, elapsed, "00:00", f"Merging... {int(frac*100)}%") + + await self.smart_combine(final_file_paths, combine_path, on_merge_progress) + + if not config.get('separate', True): + for p in final_file_paths: + try: os.remove(p) + except Exception: pass + + if self.on_status: self.on_status(f"Done! Saved: {combine_path}", False) + else: + if self.on_status: self.on_status("Conversion Complete!", False) + + if self.on_progress: + self.on_progress(100, time.time() - start_time, "00:00", "Completed") + + except Exception as e: + print(e) + if self.on_status: self.on_status(f"Critical Error: {e}", True) + finally: + if self.on_finish: self.on_finish() diff --git a/kokoro_gui/engine/jit.py b/kokoro_gui/engine/jit.py new file mode 100644 index 0000000..85f17e1 --- /dev/null +++ b/kokoro_gui/engine/jit.py @@ -0,0 +1,183 @@ +"""Real-time ("JIT") generation and playback: a generation thread fills a queue +while a playback thread drains it, with buffer management for immediate streaming. + +Calls `kokoro_engine.playback.play` qualified, at call time, so tests can keep +monkeypatching `kokoro_engine.playback` to a `MagicMock()` (see the `engine` +fixture in `tests/conftest.py`) without a real audio device ever being touched. +""" +import asyncio +import os +import time + +import kokoro_engine + + +class JITMixin: + def start_jit_conversion(self, text, config): + """Starts real-time generation and playback.""" + config['voice'] = self.resolve_voice_path(config['voice']) + self.cancel_event.clear() + self.worker.run_coro(self._process_jit_async(text, config)) + + async def _process_jit_async(self, text, config): + """ + JIT Logic: + 1. Parse text into segments. + 2. Generation thread fills a queue. + 3. Playback thread consumes the queue. + 4. Buffer management (2 mins ahead). + """ + try: + if self.on_status: self.on_status("JIT: Preparing...", False) + os.makedirs(config['out_dir'], exist_ok=True) + + # 1. Parse segments + ms_segments = self.parse_multispeaker_text(text) + all_text_segments = [] + lexicon = config.get('lexicon', {}) + + for speaker_name, fx_name, segment_text in ms_segments: + segment_text = self.apply_lexicon(segment_text, lexicon) + seg_config = config.copy() + seg_config['format'] = 'wav' # Force wav for JIT playback compatibility + if speaker_name: + preset = self.load_preset(speaker_name) + if preset: + seg_config.update(preset) + if 'trim' in preset: + seg_config['trim_silence'] = preset['trim'] + seg_config['format'] = 'wav' # Ensure preset doesn't override format to non-wav + seg_config['voice'] = self.resolve_voice_path(seg_config['voice']) + + if fx_name: + fx_preset = self.load_fx_preset(fx_name) + if fx_preset: + seg_config.update(fx_preset) + seg_config['apply_fx'] = True + seg_config['fx_preset'] = fx_name + + # Split into smaller chunks for JIT (sentences/short paragraphs) + chunks = self.smart_split(segment_text, chunk_size=500) # Small chunks for fast start + for c in chunks: + all_text_segments.append((c, seg_config)) + + if not all_text_segments: + if self.on_status: self.on_status("No text for JIT.", False) + if self.on_finish: self.on_finish() + return + + # Queues and State + audio_queue = asyncio.Queue() + played_segments = [] + generated_but_unplayed = [] + total_segments = len(all_text_segments) + + playback_finished_event = asyncio.Event() + + # --- Generation Loop --- + async def generation_loop(): + nonlocal total_segments + try: + for i, (seg_text, seg_config) in enumerate(all_text_segments): + if self.cancel_event.is_set(): break + + while audio_queue.qsize() > 10 and not self.cancel_event.is_set(): + await asyncio.sleep(0.5) + + if self.cancel_event.is_set(): break + + if self.on_status: + self.on_status(f"JIT: Generating chunk {i+1}/{total_segments}...", False) + + chunk_files = await asyncio.to_thread(self.process_chunk_task, (i, seg_text, seg_config), None) + + for cf in chunk_files: + await audio_queue.put(cf) + generated_but_unplayed.append(cf) + except Exception as e: + print(f"JIT Gen Error: {e}") + finally: + # Always signal end + await audio_queue.put(None) + + # --- Playback Loop --- + async def playback_loop(): + nonlocal played_segments + start_time = time.time() + try: + idx = 0 + while not self.cancel_event.is_set(): + # Use wait_for to allow checking cancel_event periodically + try: + item = await asyncio.wait_for(audio_queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + + if item is None: break # End of stream + + idx += 1 + if self.on_status: + self.on_status(f"JIT: Playing chunk {idx}...", False) + + clean_snip = item['text'].replace("\n", " ").strip() + if len(clean_snip) > 40: clean_snip = clean_snip[:37] + "..." + + elapsed = time.time() - start_time + if self.on_progress: + percent = (idx / total_segments) * 100 + self.on_progress(percent, elapsed, "--:--", f"Playing: {clean_snip}") + + # Play audio (Synchronously in thread) + await asyncio.to_thread(kokoro_engine.playback.play, item['path'], True) + + played_segments.append(item) + if item in generated_but_unplayed: + generated_but_unplayed.remove(item) + + except Exception as e: + print(f"JIT Playback Error: {e}") + finally: + playback_finished_event.set() + + # Start loops + gen_task = asyncio.create_task(generation_loop()) + play_task = asyncio.create_task(playback_loop()) + + await playback_finished_event.wait() + + # --- Cleanup and Save State --- + if self.cancel_event.is_set(): + if self.on_status: self.on_status("JIT Stopped. Saving state...", False) + else: + if self.on_status: self.on_status("JIT Finished.", False) + + # Combine what was played/generated so far + all_work_so_far = played_segments + generated_but_unplayed + if all_work_so_far: + combined_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_jit_output.wav") + await self.smart_combine([s['path'] for s in all_work_so_far], combined_path, None) + if self.on_status: self.on_status(f"JIT Output saved: {combined_path}", False) + + # Save remaining text + if generated_but_unplayed: + first_remaining_idx = generated_but_unplayed[0]['seg_idx'] + elif played_segments: + first_remaining_idx = played_segments[-1]['seg_idx'] + 1 + else: + first_remaining_idx = 0 + + remaining_text = "" + for i in range(first_remaining_idx, total_segments): + remaining_text += all_text_segments[i][0] + "\n\n" + + if remaining_text: + rem_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_remaining.txt") + with open(rem_path, "w", encoding="utf-8") as f: + f.write(remaining_text) + if self.on_status: self.on_status(f"Remaining text saved: {rem_path}", False) + + except Exception as e: + print(f"JIT Critical Error: {e}") + if self.on_status: self.on_status(f"JIT Error: {e}", True) + finally: + if self.on_finish: self.on_finish() diff --git a/kokoro_gui/engine/lexicon.py b/kokoro_gui/engine/lexicon.py new file mode 100644 index 0000000..71e1ce6 --- /dev/null +++ b/kokoro_gui/engine/lexicon.py @@ -0,0 +1,27 @@ +"""Lexicon (find/replace) substitution, applied to text before synthesis.""" +import re + + +class LexiconMixin: + def apply_lexicon(self, text, lexicon): + """ + Applies a dictionary of replacements to the text. + Case-insensitive finding, preserves case of replacement. + """ + if not lexicon: + return text + + for src, dest in lexicon.items(): + if not src: continue + try: + # Use cached pattern if available to avoid repeated recompilation overhead + if src not in self._lexicon_cache: + # Escape the search term to treat it as literal text + self._lexicon_cache[src] = re.compile(re.escape(src), re.IGNORECASE) + + pattern = self._lexicon_cache[src] + text = pattern.sub(dest, text) + except Exception as e: + print(f"Lexicon error for '{src}': {e}") + + return text diff --git a/kokoro_gui/engine/presets.py b/kokoro_gui/engine/presets.py new file mode 100644 index 0000000..960ada5 --- /dev/null +++ b/kokoro_gui/engine/presets.py @@ -0,0 +1,34 @@ +"""Loading speaker presets (`presets/*.json`) and FX presets (`presets/fx/*.json`) +used by multi-speaker script parsing. Directory names are fixed constants, not +monkeypatched by any test, so no `import kokoro_engine` qualification is needed here. +""" +import json +import os + + +class PresetsMixin: + def load_preset(self, name): + """Loads a preset from the presets directory.""" + # Sanitize name to prevent path traversal + safe_name = os.path.basename(name) + preset_path = os.path.join("presets", f"{safe_name}.json") + if os.path.exists(preset_path): + try: + with open(preset_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"Error loading preset {name}: {e}") + return None + + def load_fx_preset(self, name): + """Loads an FX preset from the presets/fx directory.""" + # Sanitize name to prevent path traversal + safe_name = os.path.basename(name) + fx_path = os.path.join("presets", "fx", f"{safe_name}.json") + if os.path.exists(fx_path): + try: + with open(fx_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"Error loading FX preset {name}: {e}") + return None diff --git a/kokoro_gui/engine/srt.py b/kokoro_gui/engine/srt.py new file mode 100644 index 0000000..34b9617 --- /dev/null +++ b/kokoro_gui/engine/srt.py @@ -0,0 +1,26 @@ +"""SRT subtitle file generation from a list of generated segments.""" + + +class SrtMixin: + def generate_srt(self, segments, output_path): + def format_time(seconds): + millis = int((seconds - int(seconds)) * 1000) + seconds = int(seconds) + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + return f"{hours:02}:{minutes:02}:{seconds:02},{millis:03}" + + try: + with open(output_path, "w", encoding="utf-8") as f: + current_time = 0.0 + for i, seg in enumerate(segments): + start = current_time + end = current_time + seg['duration'] + f.write(f"{i+1}\n") + f.write(f"{format_time(start)} --> {format_time(end)}\n") + f.write(f"{seg['text'].strip()}\n\n") + current_time = end + return True + except Exception as e: + print(f"Failed to generate SRT: {e}") + return False diff --git a/kokoro_gui/engine/text_extraction.py b/kokoro_gui/engine/text_extraction.py new file mode 100644 index 0000000..fbff96e --- /dev/null +++ b/kokoro_gui/engine/text_extraction.py @@ -0,0 +1,103 @@ +"""Text extraction from source files (.txt/.pdf/.epub), multi-speaker script +parsing, and long-text splitting into synthesis-sized chunks. + +`extract_text_from_file` reads `pypdf`/`ebooklib`/`epub` via `kokoro_engine.pypdf` +/`.ebooklib`/`.epub` (qualified, at call time) rather than importing those names +directly, so that tests can keep monkeypatching them on the `kokoro_engine` module +(e.g. `monkeypatch.setattr(kokoro_engine.pypdf, "PdfReader", FakeReader)`). +""" +import os +import re + +from bs4 import BeautifulSoup + +import kokoro_engine + + +class TextExtractionMixin: + def extract_text_from_file(self, fpath): + if not os.path.exists(fpath): + raise FileNotFoundError("File does not exist.") + + text_data = "" + lower_path = fpath.lower() + + if lower_path.endswith(".pdf"): + reader = kokoro_engine.pypdf.PdfReader(fpath) + for page in reader.pages: + extracted = page.extract_text() + if extracted: + text_data += extracted + "\n\n" + + elif lower_path.endswith(".epub"): + book = kokoro_engine.epub.read_epub(fpath, options={'ignore_ncx': True}) + for item in book.get_items(): + if item.get_type() == kokoro_engine.ebooklib.ITEM_DOCUMENT: + soup = BeautifulSoup(item.get_content(), 'html.parser') + text_data += soup.get_text(separator='\n\n') + "\n\n" + else: + # Assume text based + with open(fpath, "r", encoding="utf-8") as f: + text_data = f.read() + + return text_data + + def parse_multispeaker_text(self, text): + """ + Parses text for [PresetName]: or [PresetName:FXPresetName]: syntax. + Returns a list of (speaker_name, fx_name, text_segment) + """ + # Regex to find [Name]: or [Name:FX]: + + pattern = r"\[([^\]\n]{1,100})\]:\s*" + matches = list(re.finditer(pattern, text)) + + if not matches: + return [(None, None, text)] + + segments = [] + for i in range(len(matches)): + raw_name = matches[i].group(1) + speaker_name = raw_name + fx_name = None + + if ":" in raw_name: + parts = raw_name.split(":", 1) + speaker_name = parts[0].strip() + fx_name = parts[1].strip() + + start = matches[i].end() + end = matches[i+1].start() if i+1 < len(matches) else len(text) + segment_text = text[start:end].strip() + if segment_text: + segments.append((speaker_name, fx_name, segment_text)) + + return segments + + def smart_split(self, text, chunk_size=3000): + chunks = [] + current_chunk = [] + current_len = 0 + paragraphs = text.split('\n\n') + + for para in paragraphs: + if len(para) > chunk_size: + lines = para.split('\n') + for line in lines: + if current_len + len(line) > chunk_size and current_chunk: + chunks.append("\n".join(current_chunk)) + current_chunk = [] + current_len = 0 + current_chunk.append(line) + current_len += len(line) + else: + if current_len + len(para) > chunk_size and current_chunk: + chunks.append("\n\n".join(current_chunk)) + current_chunk = [] + current_len = 0 + current_chunk.append(para) + current_len += len(para) + + if current_chunk: + chunks.append("\n\n".join(current_chunk)) + return [c for c in chunks if c.strip()] diff --git a/kokoro_gui/engine/voices.py b/kokoro_gui/engine/voices.py new file mode 100644 index 0000000..9e0b859 --- /dev/null +++ b/kokoro_gui/engine/voices.py @@ -0,0 +1,86 @@ +"""Custom-voice path resolution and voice-tensor mixing. + +Reads `kokoro_engine.CUSTOM_VOICES_DIR` and calls `kokoro_engine.get_thread_pipeline` +qualified, at call time, so tests can keep monkeypatching those names on the +`kokoro_engine` module (e.g. via the `isolated_dirs` fixture). +""" +import asyncio +import os + +import torch + +import kokoro_engine + + +class VoiceMixingMixin: + def resolve_voice_path(self, voice_name): + """ + Returns the absolute path if it's a custom voice, + otherwise returns the name as-is (for standard voices). + """ + # Sanitize voice_name to prevent path traversal + safe_voice_name = os.path.basename(voice_name) + # Check if it's a custom voice file + custom_path = os.path.join(kokoro_engine.CUSTOM_VOICES_DIR, f"{safe_voice_name}.pt") + if os.path.exists(custom_path): + return os.path.abspath(custom_path) + return voice_name + + async def mix_voices(self, v1_name, v2_name, ratio, new_name, op='mix'): + def _mix(): + try: + # Ensure we have a pipeline to load voices + # Use 'a' as default for mixing if main pipeline is not ready + p = self.pipeline + if not p: + p = kokoro_engine.get_thread_pipeline('a') + if not p: raise RuntimeError("No pipeline available for mixing") + + # Resolve inputs (handle custom vs standard) + v1_arg = self.resolve_voice_path(v1_name) + v2_arg = self.resolve_voice_path(v2_name) + + # Load tensors + # KPipeline.load_voice returns a tensor + t1 = p.load_voice(v1_arg) + t2 = p.load_voice(v2_arg) + + if t1 is None or t2 is None: + raise ValueError("Failed to load one of the voices.") + + # Ensure they are on CPU for mixing + if isinstance(t1, torch.Tensor): t1 = t1.cpu() + if isinstance(t2, torch.Tensor): t2 = t2.cpu() + + # Check shapes + if t1.shape != t2.shape: + # Try to align? Usually kokoro voices are fixed size [510, 1, 256] + # If different, we might fail or warn. + print(f"Warning: Voice shapes differ {t1.shape} vs {t2.shape}. Mixing might fail or produce garbage.") + + # Apply operation + if op == 'add': + mixed = t1 + t2 * ratio + elif op == 'subtract': + mixed = t1 - t2 * ratio + elif op == 'multiply': + # Lerp between t1 and t1*t2 + mixed = t1 * (1.0 - ratio) + (t1 * t2) * ratio + elif op == 'divide': + # Lerp between t1 and t1/t2 + mixed = t1 * (1.0 - ratio) + (t1 / (t2 + 1e-6)) * ratio + else: # Default: mix (Linear Interpolation) + # mixed = v1 * (1 - ratio) + v2 * ratio + # ratio is mix of B. If ratio 0, full A. If ratio 1, full B. + mixed = t1 * (1.0 - ratio) + t2 * ratio + + # Save + # Sanitize new_name to prevent path traversal + safe_new_name = os.path.basename(new_name) + out_path = os.path.join(kokoro_engine.CUSTOM_VOICES_DIR, f"{safe_new_name}.pt") + torch.save(mixed, out_path) + return True, out_path, mixed + except Exception as e: + return False, str(e), None + + return await asyncio.to_thread(_mix) diff --git a/kokoro_gui/ui/__init__.py b/kokoro_gui/ui/__init__.py new file mode 100644 index 0000000..0e138d5 --- /dev/null +++ b/kokoro_gui/ui/__init__.py @@ -0,0 +1,6 @@ +from .fx_tab import FXTabMixin +from .generation_tab import GenerationTabMixin +from .lexicon_tab import LexiconTabMixin +from .mixing_tab import MixingTabMixin + +__all__ = ["FXTabMixin", "GenerationTabMixin", "LexiconTabMixin", "MixingTabMixin"] diff --git a/kokoro_gui/ui/fx_tab.py b/kokoro_gui/ui/fx_tab.py new file mode 100644 index 0000000..898c56a --- /dev/null +++ b/kokoro_gui/ui/fx_tab.py @@ -0,0 +1,368 @@ +"""Audio FX tab: builds the FX sliders/toggles and loads/saves FX presets under +`presets/fx/`. + +Calls `gui.messagebox`, `gui.ctk.CTkInputDialog`, and reads `gui.FX_PRESETS_DIR` +qualified, at call time, so tests can keep monkeypatching those names on the +`gui` module (the `tts_app` fixture redirects `FX_PRESETS_DIR` into a tmp_path +and replaces `messagebox` with a `MagicMock()`; `test_gui_handlers.py` patches +`gui.ctk.CTkInputDialog` with a fake dialog). +""" +import json +import os +import re + +import customtkinter as ctk + +import gui + + +class FXTabMixin: + def build_fx_tab(self, parent): + parent.grid_columnconfigure(0, weight=1) + + # --- Preset Controls --- + pre_frame = ctk.CTkFrame(parent, fg_color="transparent") + pre_frame.pack(fill="x", padx=10, pady=(10,5)) + + self.fx_preset_combo = ctk.CTkComboBox(pre_frame, values=["Select FX Preset..."], command=self.load_fx_preset, width=200) + self.fx_preset_combo.pack(side="left", padx=(0,5)) + + ctk.CTkButton(pre_frame, text="💾 Save", width=60, command=self.save_fx_preset_dialog).pack(side="left", padx=2) + ctk.CTkButton(pre_frame, text="🔄", width=30, command=self.refresh_fx_presets).pack(side="left", padx=2) + + scroll = ctk.CTkScrollableFrame(parent) + scroll.pack(fill="both", expand=True, padx=5, pady=5) + scroll.grid_columnconfigure(0, weight=1) + + # Helper to create rows + def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_attr=None): + row = ctk.CTkFrame(parent, fg_color="transparent") + row.pack(fill="x", padx=5, pady=2) + lbl = ctk.CTkLabel(row, text=label_text, width=120, anchor="w") + lbl.pack(side="left") + if label_attr: setattr(self, label_attr, lbl) + + ctk.CTkSlider(row, from_=from_, to=to_, number_of_steps=steps, variable=variable, + command=lambda v: self.update_fx_labels()).pack(side="left", fill="x", expand=True, padx=5) + + # --- 1. Dynamics --- + dyn_frame = ctk.CTkFrame(scroll) + dyn_frame.pack(fill="x", padx=5, pady=5) + + ctk.CTkLabel(dyn_frame, text="Dynamics", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) + + # Compressor + c_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") + c_head.pack(fill="x", padx=5) + ctk.CTkCheckBox(c_head, text="Compressor", variable=self.comp_enabled, font=("Roboto", 12, "bold")).pack(side="left") + + c_body = ctk.CTkFrame(dyn_frame) + c_body.pack(fill="x", padx=10, pady=2) + _create_slider(c_body, "Threshold", self.comp_threshold, -60, 0, 60, 'comp_thresh_label') + _create_slider(c_body, "Ratio", self.comp_ratio, 1, 20, 19, 'comp_ratio_label') + + # Limiter + l_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") + l_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(l_head, text="Limiter", variable=self.limiter_enabled, font=("Roboto", 12, "bold")).pack(side="left") + + l_body = ctk.CTkFrame(dyn_frame) + l_body.pack(fill="x", padx=10, pady=2) + _create_slider(l_body, "Threshold", self.limiter_threshold, -12, 0, 24, 'lim_thresh_label') + + # Gain + g_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") + g_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(g_head, text="Gain", variable=self.gain_enabled, font=("Roboto", 12, "bold")).pack(side="left") + _create_slider(dyn_frame, "dB", self.gain_db, -20, 20, 80, 'gain_label') + + # --- 2. EQ & Filters --- + eq_frame = ctk.CTkFrame(scroll) + eq_frame.pack(fill="x", padx=5, pady=5) + ctk.CTkLabel(eq_frame, text="EQ & Filters", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) + + _create_slider(eq_frame, "Bass (LowShelf)", self.eq_bass, -20, 20, 40, 'bass_label') + _create_slider(eq_frame, "Treble (HighShelf)", self.eq_treble, -20, 20, 40, 'treble_label') + + # HPF + h_head = ctk.CTkFrame(eq_frame, fg_color="transparent") + h_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(h_head, text="HighPass Filter", variable=self.highpass_enabled).pack(side="left") + _create_slider(eq_frame, "Freq (Hz)", self.highpass_freq, 20, 1000, 100, 'hpf_label') + + # LPF + lpf_head = ctk.CTkFrame(eq_frame, fg_color="transparent") + lpf_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(lpf_head, text="LowPass Filter", variable=self.lowpass_enabled).pack(side="left") + _create_slider(eq_frame, "Freq (Hz)", self.lowpass_freq, 1000, 20000, 100, 'lpf_label') + + # --- 3. Spatial & Time --- + sp_frame = ctk.CTkFrame(scroll) + sp_frame.pack(fill="x", padx=5, pady=5) + ctk.CTkLabel(sp_frame, text="Spatial & Time", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) + + # Reverb + r_head = ctk.CTkFrame(sp_frame, fg_color="transparent") + r_head.pack(fill="x", padx=5) + ctk.CTkCheckBox(r_head, text="Reverb", variable=self.reverb_enabled, font=("Roboto", 12, "bold")).pack(side="left") + + r_body = ctk.CTkFrame(sp_frame) + r_body.pack(fill="x", padx=10, pady=2) + _create_slider(r_body, "Room Size", self.reverb_room_size, 0, 1, 100, 'rev_room_label') + _create_slider(r_body, "Wet Level", self.reverb_wet_level, 0, 1, 100, 'rev_wet_label') + _create_slider(r_body, "Damping", self.reverb_damping, 0, 1, 100, None) + _create_slider(r_body, "Width", self.reverb_width, 0, 1, 100, None) + + # Delay + d_head = ctk.CTkFrame(sp_frame, fg_color="transparent") + d_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(d_head, text="Delay", variable=self.delay_enabled, font=("Roboto", 12, "bold")).pack(side="left") + + d_body = ctk.CTkFrame(sp_frame) + d_body.pack(fill="x", padx=10, pady=2) + _create_slider(d_body, "Time (s)", self.delay_time, 0, 2, 100, 'dly_time_label') + _create_slider(d_body, "Feedback", self.delay_feedback, 0, 1, 100, None) + _create_slider(d_body, "Mix", self.delay_mix, 0, 1, 100, 'dly_mix_label') + + # --- 4. Guitar / Modulation --- + mod_frame = ctk.CTkFrame(scroll) + mod_frame.pack(fill="x", padx=5, pady=5) + ctk.CTkLabel(mod_frame, text="Guitar / Modulation", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) + + # Chorus + ch_head = ctk.CTkFrame(mod_frame, fg_color="transparent") + ch_head.pack(fill="x", padx=5) + ctk.CTkCheckBox(ch_head, text="Chorus", variable=self.chorus_enabled).pack(side="left") + _create_slider(mod_frame, "Rate (Hz)", self.chorus_rate, 0.1, 10, 50, 'chorus_rate_label') + _create_slider(mod_frame, "Depth", self.chorus_depth, 0, 1, 50, None) + + # Distortion + di_head = ctk.CTkFrame(mod_frame, fg_color="transparent") + di_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(di_head, text="Distortion", variable=self.distortion_enabled).pack(side="left") + _create_slider(mod_frame, "Drive (dB)", self.distortion_drive, 0, 60, 60, 'dist_drive_label') + + # Phaser + ph_head = ctk.CTkFrame(mod_frame, fg_color="transparent") + ph_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(ph_head, text="Phaser", variable=self.phaser_enabled).pack(side="left") + _create_slider(mod_frame, "Rate (Hz)", self.phaser_rate, 0.1, 10, 50, 'phaser_rate_label') + + # Clipping + cl_head = ctk.CTkFrame(mod_frame, fg_color="transparent") + cl_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(cl_head, text="Clipping", variable=self.clipping_enabled).pack(side="left") + _create_slider(mod_frame, "Threshold (dB)", self.clipping_thresh, -20, 0, 40, 'clip_thresh_label') + + # --- 5. Quality & Pitch --- + q_frame = ctk.CTkFrame(scroll) + q_frame.pack(fill="x", padx=5, pady=5) + ctk.CTkLabel(q_frame, text="Quality / Pitch", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) + + # Pitch Shift + ps_head = ctk.CTkFrame(q_frame, fg_color="transparent") + ps_head.pack(fill="x", padx=5) + ctk.CTkCheckBox(ps_head, text="Pitch Shift (High Quality)", variable=self.pitch_shift_enabled).pack(side="left") + _create_slider(q_frame, "Semitones", self.pitch_shift_semitones, -12, 12, 48, 'pitch_shift_label') + + # Bitcrush + bc_head = ctk.CTkFrame(q_frame, fg_color="transparent") + bc_head.pack(fill="x", padx=5, pady=(5,0)) + ctk.CTkCheckBox(bc_head, text="Bitcrush", variable=self.bitcrush_enabled).pack(side="left") + _create_slider(q_frame, "Bit Depth", self.bitcrush_depth, 2, 16, 28, 'bit_depth_label') + + # GSM + ctk.CTkCheckBox(q_frame, text="GSM Compressor (Phone Quality)", variable=self.gsm_enabled).pack(anchor="w", padx=10, pady=5) + + # Init labels + self.update_fx_labels() + self.refresh_fx_presets() + + def refresh_fx_presets(self): + presets = ["Select FX Preset..."] + if os.path.exists(gui.FX_PRESETS_DIR): + files = [f for f in os.listdir(gui.FX_PRESETS_DIR) if f.endswith(".json")] + presets.extend([f[:-5] for f in files]) # Remove .json + + # Update FX Tab Combo + if hasattr(self, 'fx_preset_combo'): + self.fx_preset_combo.configure(values=presets) + self.fx_preset_combo.set("Select FX Preset...") + + # Update Gen Tab Combo + if hasattr(self, 'gen_fx_combo'): + self.gen_fx_combo.configure(values=presets) + self.gen_fx_combo.set("Select FX Preset...") + + def save_fx_preset_dialog(self): + dialog = gui.ctk.CTkInputDialog(text="Enter FX preset name:", title="Save FX Preset") + name = dialog.get_input() + if name: + name = re.sub(r'[<>:"/\\|?*]', '', name).strip() + if not name: return + + data = { + "reverb_enabled": self.reverb_enabled.get(), + "reverb_room_size": self.reverb_room_size.get(), + "reverb_wet_level": self.reverb_wet_level.get(), + "reverb_damping": self.reverb_damping.get(), + "reverb_dry_level": self.reverb_dry_level.get(), + "reverb_width": self.reverb_width.get(), + "eq_bass": self.eq_bass.get(), + "eq_treble": self.eq_treble.get(), + "comp_enabled": self.comp_enabled.get(), + "comp_threshold": self.comp_threshold.get(), + "comp_ratio": self.comp_ratio.get(), + "comp_attack": self.comp_attack.get(), + "comp_release": self.comp_release.get(), + "distortion_enabled": self.distortion_enabled.get(), + "distortion_drive": self.distortion_drive.get(), + "chorus_enabled": self.chorus_enabled.get(), + "chorus_rate": self.chorus_rate.get(), + "chorus_depth": self.chorus_depth.get(), + "chorus_mix": self.chorus_mix.get(), + "phaser_enabled": self.phaser_enabled.get(), + "phaser_rate": self.phaser_rate.get(), + "phaser_depth": self.phaser_depth.get(), + "phaser_mix": self.phaser_mix.get(), + "clipping_enabled": self.clipping_enabled.get(), + "clipping_thresh": self.clipping_thresh.get(), + "bitcrush_enabled": self.bitcrush_enabled.get(), + "bitcrush_depth": self.bitcrush_depth.get(), + "gsm_enabled": self.gsm_enabled.get(), + "highpass_enabled": self.highpass_enabled.get(), + "highpass_freq": self.highpass_freq.get(), + "lowpass_enabled": self.lowpass_enabled.get(), + "lowpass_freq": self.lowpass_freq.get(), + "delay_enabled": self.delay_enabled.get(), + "delay_time": self.delay_time.get(), + "delay_feedback": self.delay_feedback.get(), + "delay_mix": self.delay_mix.get(), + "pitch_shift_enabled": self.pitch_shift_enabled.get(), + "pitch_shift_semitones": self.pitch_shift_semitones.get(), + "limiter_enabled": self.limiter_enabled.get(), + "limiter_threshold": self.limiter_threshold.get(), + "limiter_release": self.limiter_release.get(), + "gain_enabled": self.gain_enabled.get(), + "gain_db": self.gain_db.get() + } + + fpath = os.path.join(gui.FX_PRESETS_DIR, f"{name}.json") + try: + with open(fpath, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + gui.messagebox.showinfo("Saved", f"FX Preset '{name}' saved.") + self.refresh_fx_presets() + if hasattr(self, 'fx_preset_combo'): self.fx_preset_combo.set(name) + if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(name) + except Exception as e: + gui.messagebox.showerror("Error", f"Failed to save FX preset: {e}") + + def load_fx_preset(self, name): + if name == "Select FX Preset...": return + + safe_name = os.path.basename(name) + if not safe_name: return + fpath = os.path.join(gui.FX_PRESETS_DIR, f"{safe_name}.json") + if os.path.exists(fpath): + try: + with open(fpath, "r", encoding="utf-8") as f: + data = json.load(f) + + if "reverb_enabled" in data: self.reverb_enabled.set(data["reverb_enabled"]) + if "reverb_room_size" in data: self.reverb_room_size.set(data["reverb_room_size"]) + if "reverb_wet_level" in data: self.reverb_wet_level.set(data["reverb_wet_level"]) + if "reverb_damping" in data: self.reverb_damping.set(data["reverb_damping"]) + if "reverb_dry_level" in data: self.reverb_dry_level.set(data["reverb_dry_level"]) + if "reverb_width" in data: self.reverb_width.set(data["reverb_width"]) + + if "eq_bass" in data: self.eq_bass.set(data["eq_bass"]) + if "eq_treble" in data: self.eq_treble.set(data["eq_treble"]) + + if "comp_enabled" in data: self.comp_enabled.set(data["comp_enabled"]) + if "comp_threshold" in data: self.comp_threshold.set(data["comp_threshold"]) + if "comp_ratio" in data: self.comp_ratio.set(data["comp_ratio"]) + if "comp_attack" in data: self.comp_attack.set(data["comp_attack"]) + if "comp_release" in data: self.comp_release.set(data["comp_release"]) + + if "distortion_enabled" in data: self.distortion_enabled.set(data["distortion_enabled"]) + if "distortion_drive" in data: self.distortion_drive.set(data["distortion_drive"]) + + if "chorus_enabled" in data: self.chorus_enabled.set(data["chorus_enabled"]) + if "chorus_rate" in data: self.chorus_rate.set(data["chorus_rate"]) + if "chorus_depth" in data: self.chorus_depth.set(data["chorus_depth"]) + if "chorus_mix" in data: self.chorus_mix.set(data["chorus_mix"]) + + if "phaser_enabled" in data: self.phaser_enabled.set(data["phaser_enabled"]) + if "phaser_rate" in data: self.phaser_rate.set(data["phaser_rate"]) + if "phaser_depth" in data: self.phaser_depth.set(data["phaser_depth"]) + if "phaser_mix" in data: self.phaser_mix.set(data["phaser_mix"]) + + if "clipping_enabled" in data: self.clipping_enabled.set(data["clipping_enabled"]) + if "clipping_thresh" in data: self.clipping_thresh.set(data["clipping_thresh"]) + + if "bitcrush_enabled" in data: self.bitcrush_enabled.set(data["bitcrush_enabled"]) + if "bitcrush_depth" in data: self.bitcrush_depth.set(data["bitcrush_depth"]) + + if "gsm_enabled" in data: self.gsm_enabled.set(data["gsm_enabled"]) + + if "highpass_enabled" in data: self.highpass_enabled.set(data["highpass_enabled"]) + if "highpass_freq" in data: self.highpass_freq.set(data["highpass_freq"]) + + if "lowpass_enabled" in data: self.lowpass_enabled.set(data["lowpass_enabled"]) + if "lowpass_freq" in data: self.lowpass_freq.set(data["lowpass_freq"]) + + if "delay_enabled" in data: self.delay_enabled.set(data["delay_enabled"]) + if "delay_time" in data: self.delay_time.set(data["delay_time"]) + if "delay_feedback" in data: self.delay_feedback.set(data["delay_feedback"]) + if "delay_mix" in data: self.delay_mix.set(data["delay_mix"]) + + if "pitch_shift_enabled" in data: self.pitch_shift_enabled.set(data["pitch_shift_enabled"]) + if "pitch_shift_semitones" in data: self.pitch_shift_semitones.set(data["pitch_shift_semitones"]) + + if "limiter_enabled" in data: self.limiter_enabled.set(data["limiter_enabled"]) + if "limiter_threshold" in data: self.limiter_threshold.set(data["limiter_threshold"]) + if "limiter_release" in data: self.limiter_release.set(data["limiter_release"]) + + if "gain_enabled" in data: self.gain_enabled.set(data["gain_enabled"]) + if "gain_db" in data: self.gain_db.set(data["gain_db"]) + + self.update_fx_labels() + + # Sync Combos + if hasattr(self, 'fx_preset_combo'): self.fx_preset_combo.set(name) + if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(name) + + except Exception as e: + gui.messagebox.showerror("Error", f"Failed to load FX preset: {e}") + + def update_fx_labels(self): + # EQ + if hasattr(self, 'bass_label'): self.bass_label.configure(text=f"Bass: {self.eq_bass.get():.1f} dB") + if hasattr(self, 'treble_label'): self.treble_label.configure(text=f"Treble: {self.eq_treble.get():.1f} dB") + if hasattr(self, 'hpf_label'): self.hpf_label.configure(text=f"Freq: {int(self.highpass_freq.get())} Hz") + if hasattr(self, 'lpf_label'): self.lpf_label.configure(text=f"Freq: {int(self.lowpass_freq.get())} Hz") + + # Comp / Dynamics + if hasattr(self, 'comp_thresh_label'): self.comp_thresh_label.configure(text=f"Thresh: {self.comp_threshold.get():.1f} dB") + if hasattr(self, 'comp_ratio_label'): self.comp_ratio_label.configure(text=f"Ratio: {self.comp_ratio.get():.1f}:1") + if hasattr(self, 'lim_thresh_label'): self.lim_thresh_label.configure(text=f"Thresh: {self.limiter_threshold.get():.1f} dB") + if hasattr(self, 'gain_label'): self.gain_label.configure(text=f"Gain: {self.gain_db.get():.1f} dB") + + # Reverb + if hasattr(self, 'rev_room_label'): self.rev_room_label.configure(text=f"Size: {self.reverb_room_size.get():.2f}") + if hasattr(self, 'rev_wet_label'): self.rev_wet_label.configure(text=f"Wet: {self.reverb_wet_level.get():.2f}") + + # Delay + if hasattr(self, 'dly_time_label'): self.dly_time_label.configure(text=f"Time: {self.delay_time.get():.2f} s") + if hasattr(self, 'dly_mix_label'): self.dly_mix_label.configure(text=f"Mix: {self.delay_mix.get():.2f}") + + # Guitar + if hasattr(self, 'dist_drive_label'): self.dist_drive_label.configure(text=f"Drive: {self.distortion_drive.get():.1f} dB") + if hasattr(self, 'chorus_rate_label'): self.chorus_rate_label.configure(text=f"Rate: {self.chorus_rate.get():.1f} Hz") + if hasattr(self, 'phaser_rate_label'): self.phaser_rate_label.configure(text=f"Rate: {self.phaser_rate.get():.1f} Hz") + if hasattr(self, 'clip_thresh_label'): self.clip_thresh_label.configure(text=f"Thresh: {self.clipping_thresh.get():.1f} dB") + + # Quality / Pitch + if hasattr(self, 'bit_depth_label'): self.bit_depth_label.configure(text=f"Depth: {self.bitcrush_depth.get():.1f}") + if hasattr(self, 'pitch_shift_label'): self.pitch_shift_label.configure(text=f"Shift: {self.pitch_shift_semitones.get():.1f} st") diff --git a/kokoro_gui/ui/generation_tab.py b/kokoro_gui/ui/generation_tab.py new file mode 100644 index 0000000..356ea00 --- /dev/null +++ b/kokoro_gui/ui/generation_tab.py @@ -0,0 +1,288 @@ +"""Generation tab: input source, voice/speed/output config, and the speaker +presets (`presets/*.json`) that snapshot that config. + +Calls `gui.messagebox`, `gui.ctk.CTkInputDialog`, and reads `gui.PRESETS_DIR` +qualified, at call time, so tests can keep monkeypatching those names on the +`gui` module (the `tts_app` fixture redirects `PRESETS_DIR` into a tmp_path and +replaces `messagebox` with a `MagicMock()`). +""" +import json +import os +import re + +import customtkinter as ctk + +import gui + + +class GenerationTabMixin: + def refresh_presets(self): + presets = ["Select Preset..."] + if os.path.exists(gui.PRESETS_DIR): + files = [f for f in os.listdir(gui.PRESETS_DIR) if f.endswith(".json")] + presets.extend([f[:-5] for f in files]) # Remove .json + + self.preset_combo.configure(values=presets) + self.preset_combo.set("Select Preset...") + + def save_preset_dialog(self): + dialog = gui.ctk.CTkInputDialog(text="Enter preset name:", title="Save Preset") + name = dialog.get_input() + if name: + name = re.sub(r'[<>:"/\\|?*]', '', name).strip() # Sanitize + if not name: return + + data = { + "voice": self.voice_var.get(), + "speed": self.speed_var.get(), + "volume": self.volume_var.get(), + "pitch": self.pitch_var.get(), + "split_pattern": self.split_pattern_var.get(), + "normalize": self.normalize_audio.get(), + "trim": self.trim_silence.get(), + "format": self.output_format_var.get(), + "apply_fx": self.apply_fx_var.get(), + "fx_preset": self.gen_fx_combo.get() + } + + fpath = os.path.join(gui.PRESETS_DIR, f"{name}.json") + try: + with open(fpath, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + gui.messagebox.showinfo("Saved", f"Preset '{name}' saved successfully.") + self.refresh_presets() + self.preset_combo.set(name) + except Exception as e: + gui.messagebox.showerror("Error", f"Failed to save preset: {e}") + + def load_preset(self, name): + if name == "Select Preset...": return + + fpath = os.path.join(gui.PRESETS_DIR, f"{name}.json") + if os.path.exists(fpath): + try: + with open(fpath, "r", encoding="utf-8") as f: + data = json.load(f) + + if "voice" in data: self.voice_var.set(data["voice"]) + if "speed" in data: self.speed_var.set(data["speed"]) + if "volume" in data: self.volume_var.set(data["volume"]) + if "pitch" in data: self.pitch_var.set(data["pitch"]) + if "split_pattern" in data: self.split_pattern_var.set(data["split_pattern"]) + if "normalize" in data: self.normalize_audio.set(data["normalize"]) + if "trim" in data: self.trim_silence.set(data["trim"]) + if "format" in data: self.output_format_var.set(data["format"]) + if "apply_fx" in data: self.apply_fx_var.set(data["apply_fx"]) + + if "fx_preset" in data: + fx_name = data["fx_preset"] + if fx_name and fx_name != "Select FX Preset...": + self.load_fx_preset(fx_name) + # Ensure combo is updated (load_fx_preset does this, but being safe) + if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(fx_name) + + # Update UI labels manually since setting var triggers trace but maybe not UI update logic dependent on callbacks + self.update_audio_labels(0) + self.update_speed_label(self.speed_var.get()) + + # Update split combo logic + target_pat = self.split_pattern_var.get() + for k, v in self.split_map.items(): + if v == target_pat: + self.split_combo.set(k) + break + + except Exception as e: + gui.messagebox.showerror("Error", f"Failed to load preset: {e}") + + def build_generation_tab(self, parent): + parent.grid_columnconfigure(0, weight=1) + + # Move existing logic here + main_frame = ctk.CTkScrollableFrame(parent) + main_frame.pack(fill="both", expand=True, padx=5, pady=5) + main_frame.grid_columnconfigure(0, weight=1) + + # --- 1. Input Section --- + input_frame = ctk.CTkFrame(main_frame) + input_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + input_frame.grid_columnconfigure(0, weight=1) + + ctk.CTkLabel(input_frame, text="Input Source", font=("Roboto", 16, "bold")).grid(row=0, column=0, sticky="w", padx=10, pady=5) + + self.tab_view = ctk.CTkTabview(input_frame, height=150) + self.tab_view.grid(row=1, column=0, sticky="ew", padx=10, pady=5) + + # Text Tab + tab_text = self.tab_view.add("Direct Text") + tab_text.grid_columnconfigure(0, weight=1) + tab_text.grid_rowconfigure(0, weight=1) + + self.text_entry = ctk.CTkTextbox(tab_text, wrap="word") + self.text_entry.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + # File Tab + tab_file = self.tab_view.add("Load File") + tab_file.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(tab_file, text="File Path:").grid(row=0, column=0, padx=10, pady=20) + ctk.CTkEntry(tab_file, textvariable=self.file_path_var).grid(row=0, column=1, sticky="ew", padx=5) + ctk.CTkButton(tab_file, text="Browse", width=80, command=self.browse_file).grid(row=0, column=2, padx=10) + ctk.CTkLabel(tab_file, text="Supported: .txt, .pdf, .epub", text_color="gray").grid(row=1, column=1, sticky="w", padx=5) + + # --- 2. Configuration --- + config_frame = ctk.CTkFrame(main_frame) + config_frame.grid(row=1, column=0, sticky="ew", pady=10) + config_frame.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(config_frame, text="Configuration", font=("Roboto", 16, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", padx=10, pady=5) + + # Presets Row + preset_frame = ctk.CTkFrame(config_frame, fg_color="transparent") + preset_frame.grid(row=0, column=1, sticky="ew", padx=10, pady=5) + + self.preset_combo = ctk.CTkComboBox(preset_frame, values=["Select Preset..."], command=self.load_preset, width=150) + self.preset_combo.pack(side="left", padx=(0,5)) + + ctk.CTkButton(preset_frame, text="💾", width=30, command=self.save_preset_dialog).pack(side="left", padx=2) + ctk.CTkButton(preset_frame, text="🔄", width=30, command=self.refresh_presets).pack(side="left", padx=2) + + self.refresh_presets() + + # Language Selection + ctk.CTkLabel(config_frame, text="Language:").grid(row=1, column=0, sticky="w", padx=10, pady=5) + # Reverse map for display + lang_display_map = {v: k for k, v in self.LANGUAGES.items()} + current_lang_code = self.lang_var.get() + + def on_lang_ui_change(choice): + self.lang_var.set(self.LANGUAGES[choice]) + + self.lang_combo = ctk.CTkComboBox(config_frame, values=list(self.LANGUAGES.keys()), command=on_lang_ui_change) + + # Set initial value + if current_lang_code in lang_display_map: + self.lang_combo.set(lang_display_map[current_lang_code]) + else: + self.lang_combo.set("American English") + + self.lang_combo.grid(row=1, column=1, sticky="ew", padx=10) + + # Voice Selection + ctk.CTkLabel(config_frame, text="Voice:").grid(row=2, column=0, sticky="w", padx=10, pady=5) + self.voice_combo = ctk.CTkComboBox(config_frame, values=self.get_all_voices(), variable=self.voice_var) + self.voice_combo.grid(row=2, column=1, sticky="ew", padx=10) + + # Output Dir + ctk.CTkLabel(config_frame, text="Output Folder:").grid(row=3, column=0, sticky="w", padx=10, pady=5) + dir_row = ctk.CTkFrame(config_frame, fg_color="transparent") + dir_row.grid(row=3, column=1, sticky="ew", padx=10) + dir_row.grid_columnconfigure(0, weight=1) + ctk.CTkEntry(dir_row, textvariable=self.output_dir_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) + ctk.CTkButton(dir_row, text="...", width=40, command=self.browse_directory).grid(row=0, column=1) + + # Filename + ctk.CTkLabel(config_frame, text="Base Filename:").grid(row=4, column=0, sticky="w", padx=10, pady=5) + + file_row = ctk.CTkFrame(config_frame, fg_color="transparent") + file_row.grid(row=4, column=1, sticky="ew", padx=10) + file_row.grid_columnconfigure(0, weight=1) + + ctk.CTkEntry(file_row, textvariable=self.filename_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) + + self.format_combo = ctk.CTkComboBox(file_row, values=["wav", "flac", "mp3", "ogg"], width=70, variable=self.output_format_var) + self.format_combo.grid(row=0, column=1) + + # Speed + self.speed_label = ctk.CTkLabel(config_frame, text="Speed: 1.0x") + self.speed_label.grid(row=5, column=0, sticky="w", padx=10, pady=5) + self.speed_slider = ctk.CTkSlider(config_frame, from_=0.5, to=2.0, number_of_steps=15, variable=self.speed_var, command=self.update_speed_label) + self.speed_slider.grid(row=5, column=1, sticky="ew", padx=10) + + # Split Pattern + ctk.CTkLabel(config_frame, text="Split By:").grid(row=6, column=0, sticky="w", padx=10, pady=5) + self.split_map = { + "Natural (Newlines)": r"\n+", + "Paragraphs (Double Newline)": r"\n\n+", + "Sentences (.!?)": r"(?", width=30).pack(side="left") + ctk.CTkLabel(row, text=rep, width=150, anchor="w", font=("Consolas", 12)).pack(side="left", padx=10) + + ctk.CTkButton(row, text="X", width=30, fg_color="#c42b1c", command=lambda k=orig: self.delete_lexicon_rule(k)).pack(side="right", padx=5) diff --git a/kokoro_gui/ui/mixing_tab.py b/kokoro_gui/ui/mixing_tab.py new file mode 100644 index 0000000..5d05ec0 --- /dev/null +++ b/kokoro_gui/ui/mixing_tab.py @@ -0,0 +1,250 @@ +"""Custom Voice (mixing) tab: blends two voice tensors via `self.engine.mix_voices` +and previews/saves the result. + +Calls `gui.messagebox` qualified, at call time, so tests can keep monkeypatching +that name on the `gui` module (the `tts_app` fixture replaces it with a +`MagicMock()`). `playback` is not a `gui`-level monkeypatch target in the test +suite, so it's imported normally here, same as the original `gui.py`. +""" +import os +import re +import tempfile + +import customtkinter as ctk +import playback + +import gui + + +class MixingTabMixin: + def _update_mix_voice_list(self, lang_var, combo_attr, voice_var): + code = lang_var.get() + if hasattr(self, combo_attr): + combo = getattr(self, combo_attr) + voices = self.get_all_voices(code) + combo.configure(values=voices) + if voice_var.get() not in voices: + voice_var.set(voices[0]) + + def on_mix_lang_a_change(self, *args): + self._update_mix_voice_list(self.mix_lang_a_var, 'mix_combo_a', self.mix_voice_a_var) + + def on_mix_lang_b_change(self, *args): + self._update_mix_voice_list(self.mix_lang_b_var, 'mix_combo_b', self.mix_voice_b_var) + + def refresh_voice_lists(self): + # Update Gen Tab Combo + if hasattr(self, 'voice_combo'): + self.voice_combo.configure(values=self.get_all_voices(self.lang_var.get())) + + # Update Mix Tab Combos + self.on_mix_lang_a_change() + self.on_mix_lang_b_change() + + # Update Custom List + if hasattr(self, 'custom_list_frame'): + for widget in self.custom_list_frame.winfo_children(): + widget.destroy() + + all_voices = self.get_all_voices(self.lang_var.get()) + custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] + if not custom: + ctk.CTkLabel(self.custom_list_frame, text="No custom voices found.", text_color="gray").pack(pady=5) + else: + for cv in sorted(custom): + row = ctk.CTkFrame(self.custom_list_frame) + row.pack(fill="x", pady=2) + ctk.CTkLabel(row, text=cv).pack(side="left", padx=5) + ctk.CTkButton(row, text="X", width=30, fg_color="#c42b1c", command=lambda v=cv: self.delete_custom_voice(v)).pack(side="right", padx=5) + + def delete_custom_voice(self, name): + if gui.messagebox.askyesno("Confirm", f"Delete voice '{name}'?"): + try: + path = os.path.join("custom_voices", f"{name}.pt") + if os.path.exists(path): + os.remove(path) + self.refresh_voice_lists() + except Exception as e: + gui.messagebox.showerror("Error", f"Failed to delete: {e}") + + def preview_mix(self): + v1 = self.mix_voice_a_var.get() + v2 = self.mix_voice_b_var.get() + ratio = self.mix_ratio_var.get() + op = self.mix_op_var.get() + preview_lang = self.preview_lang_var.get() + + preview_text = "This is a preview of your custom mixed voice." + if preview_lang == 'f': preview_text = "Ceci est un aperçu de votre voix personnalisée." + elif preview_lang == 'e': preview_text = "Esta es una vista previa de su voz personalizada." + elif preview_lang == 'i': preview_text = "Questa è un'anteprima della tua voce personalizzata." + elif preview_lang == 'p': preview_text = "Esta é uma prévia da sua voz personalizada." + elif preview_lang == 'j': preview_text = "これはカスタム合成音声のプレビューです。" + elif preview_lang == 'z': preview_text = "这是您的自定义混合语音预览。" + + # Temp voice name and file + tmp_voice_name = "_tmp_mix_preview" + tmp_audio_path = os.path.join(tempfile.gettempdir(), "kokoro_mix_preview.wav") + + self.mix_status_label.configure(text="Generating preview...", text_color="blue") + + async def _run_preview(): + # 1. Mix to a temporary file (we ignore the file for preview, use tensor) + success, msg, tensor = await self.engine.mix_voices(v1, v2, ratio, tmp_voice_name, op=op) + if not success: + return False, msg + + # 2. Generate audio using that mixed voice tensor and target preview language + success = await self.engine.generate_preview(preview_text, tmp_voice_name, 1.0, tmp_audio_path, voice_tensor=tensor, lang_code=preview_lang) + + # 3. Cleanup temp voice file + try: + p = os.path.join("custom_voices", f"{tmp_voice_name}.pt") + if os.path.exists(p): os.remove(p) + except Exception: pass + + return success, "" + + def _on_done(future): + try: + success, err = future.result() + if success: + self.after(0, lambda: self.mix_status_label.configure(text="Playing preview...", text_color="green")) + playback.play(tmp_audio_path) + else: + self.after(0, lambda: self.mix_status_label.configure(text=f"Preview failed: {err}", text_color="red")) + except Exception as e: + self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {e}", text_color="red")) + + future = self.engine.worker.run_coro(_run_preview()) + future.add_done_callback(_on_done) + + def mix_voice_action(self): + v1 = self.mix_voice_a_var.get() + v2 = self.mix_voice_b_var.get() + ratio = self.mix_ratio_var.get() + op = self.mix_op_var.get() + name = self.mix_name_var.get().strip() + + if not name: + gui.messagebox.showwarning("Error", "Please enter a name for the new voice.") + return + + if not re.match(r'^[a-zA-Z0-9_-]+$', name): + gui.messagebox.showwarning("Error", "Invalid name. Use alphanumeric, _, - only.") + return + + if name in self.get_all_voices(): + if not gui.messagebox.askyesno("Overwrite", f"Voice '{name}' exists. Overwrite?"): + return + + self.mix_status_label.configure(text="Mixing...", text_color="blue") + self.set_ui_state(True) # Reuse existing lock + + def _done(future): + self.after(0, lambda: self.set_ui_state(False)) + try: + success, msg, _ = future.result() + if success: + self.after(0, lambda: self.mix_status_label.configure(text=f"Saved: {name}", text_color="green")) + self.after(0, self.refresh_voice_lists) + else: + self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {msg}", text_color="red")) + except Exception as e: + self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {e}", text_color="red")) + + future = self.engine.worker.run_coro(self.engine.mix_voices(v1, v2, ratio, name, op=op)) + future.add_done_callback(_done) + + def build_mixing_tab(self, parent): + parent.grid_columnconfigure(0, weight=1) + + lang_display_map = {v: k for k, v in self.LANGUAGES.items()} + + # 1. Selection + sel_frame = ctk.CTkFrame(parent) + sel_frame.pack(fill="x", padx=10, pady=10) + sel_frame.grid_columnconfigure(1, weight=1) + sel_frame.grid_columnconfigure(2, weight=1) + + # Voice A Row + ctk.CTkLabel(sel_frame, text="Voice A:").grid(row=0, column=0, padx=10, pady=5) + + def on_lang_a_ui(c): self.mix_lang_a_var.set(self.LANGUAGES[c]) + mix_lang_a_combo = ctk.CTkComboBox(sel_frame, values=list(self.LANGUAGES.keys()), command=on_lang_a_ui, width=150) + mix_lang_a_combo.set(lang_display_map.get(self.mix_lang_a_var.get(), "American English")) + mix_lang_a_combo.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + self.mix_combo_a = ctk.CTkComboBox(sel_frame, variable=self.mix_voice_a_var) + self.mix_combo_a.grid(row=0, column=2, sticky="ew", padx=5, pady=5) + + # Voice B Row + ctk.CTkLabel(sel_frame, text="Voice B:").grid(row=1, column=0, padx=10, pady=5) + + def on_lang_b_ui(c): self.mix_lang_b_var.set(self.LANGUAGES[c]) + mix_lang_b_combo = ctk.CTkComboBox(sel_frame, values=list(self.LANGUAGES.keys()), command=on_lang_b_ui, width=150) + mix_lang_b_combo.set(lang_display_map.get(self.mix_lang_b_var.get(), "American English")) + mix_lang_b_combo.grid(row=1, column=1, padx=5, pady=5, sticky="ew") + + self.mix_combo_b = ctk.CTkComboBox(sel_frame, variable=self.mix_voice_b_var) + self.mix_combo_b.grid(row=1, column=2, sticky="ew", padx=5, pady=5) + + # 2. Ratio & Operation + ratio_frame = ctk.CTkFrame(parent) + ratio_frame.pack(fill="x", padx=10, pady=10) + + op_frame = ctk.CTkFrame(ratio_frame, fg_color="transparent") + op_frame.pack(fill="x", padx=20, pady=(10, 0)) + ctk.CTkLabel(op_frame, text="Operation:").pack(side="left", padx=5) + + def update_ratio_label(val=None): + if val is None: val = self.mix_ratio_var.get() + p = int(float(val) * 100) + op = self.mix_op_var.get() + if op == 'mix': + self.ratio_label.configure(text=f"Mix: {100-p}% A / {p}% B", text_color=("black", "white")) + elif op == 'divide': + self.ratio_label.configure(text=f"Op: Divide | Influence: {p}%\n(Results are more likely to be unstable and VERY LOUD)", text_color="#E57373") + else: + self.ratio_label.configure(text=f"Op: {op.capitalize()} | Influence: {p}%", text_color=("black", "white")) + + ctk.CTkComboBox(op_frame, values=["mix", "add", "subtract", "multiply", "divide"], variable=self.mix_op_var, command=lambda _: update_ratio_label()).pack(side="left", padx=5) + + self.ratio_label = ctk.CTkLabel(ratio_frame, text="Mix: 50% A / 50% B") + self.ratio_label.pack(pady=5) + + slider = ctk.CTkSlider(ratio_frame, from_=0.0, to=1.0, number_of_steps=100, variable=self.mix_ratio_var, command=update_ratio_label) + slider.pack(fill="x", padx=20, pady=10) + + update_ratio_label() + + # 3. Preview Lang & Actions + act_frame = ctk.CTkFrame(parent) + act_frame.pack(fill="x", padx=10, pady=10) + + ctk.CTkLabel(act_frame, text="Preview Language:").grid(row=0, column=0, padx=10, pady=5) + + def on_prev_lang_ui(c): self.preview_lang_var.set(self.LANGUAGES[c]) + prev_lang_combo = ctk.CTkComboBox(act_frame, values=list(self.LANGUAGES.keys()), command=on_prev_lang_ui, width=150) + prev_lang_combo.set(lang_display_map.get(self.preview_lang_var.get(), "American English")) + prev_lang_combo.grid(row=0, column=1, padx=5, pady=5) + + ctk.CTkButton(act_frame, text="🔊 Preview", width=100, fg_color="#2B719E", command=self.preview_mix).grid(row=0, column=2, padx=10) + + # Save Row + save_frame = ctk.CTkFrame(parent) + save_frame.pack(fill="x", padx=10, pady=10) + + ctk.CTkLabel(save_frame, text="New Voice Name:").pack(side="left", padx=10) + ctk.CTkEntry(save_frame, textvariable=self.mix_name_var).pack(side="left", fill="x", expand=True, padx=5) + ctk.CTkButton(save_frame, text="Create & Save", command=self.mix_voice_action).pack(side="left", padx=10) + + self.mix_status_label = ctk.CTkLabel(parent, text="", text_color="gray") + self.mix_status_label.pack(pady=5) + + # 4. List + ctk.CTkLabel(parent, text="Custom Voices:", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=(20,5)) + self.custom_list_frame = ctk.CTkScrollableFrame(parent, height=200) + self.custom_list_frame.pack(fill="x", padx=10, pady=5) + + self.refresh_voice_lists() From a85b4a9a96a009dcfcbbe13e2533c1a5114e03ec Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 14:28:23 -0600 Subject: [PATCH 02/44] =?UTF-8?q?New:=20kokoro=5Fgui/engines/=20base.py=20?= =?UTF-8?q?=E2=80=94=20ConfigField/ConfigFieldType,=20EngineCapabilities,?= =?UTF-8?q?=20VoiceInfo,=20and=20the=20TTSEngineBackend=20/=20SupportsVoic?= =?UTF-8?q?eMixing=20Protocols.=20I=20deliberately=20left=20generate()/sta?= =?UTF-8?q?rt=5Fjit()=20out=20of=20the=20Protocol=20(documented=20in=20the?= =?UTF-8?q?=20module=20docstring)=20rather=20than=20inventing=20a=20unifor?= =?UTF-8?q?m=20async=20surface=20KokoroEngine's=20callback-driven=20AsyncL?= =?UTF-8?q?oopThread=20doesn't=20actually=20have=20=E2=80=94=20per=20the?= =?UTF-8?q?=20plan's=20own=20step=205,=20that's=20premature=20until=20a=20?= =?UTF-8?q?second=20backend=20exists=20to=20validate=20it=20against.=20reg?= =?UTF-8?q?istry.py=20=E2=80=94=20register=5Fengine/get=5Fengine/list=5Fen?= =?UTF-8?q?gines.=20kokoro.py=20=E2=80=94=20KokoroBackendAdapter,=20a=20th?= =?UTF-8?q?in=20composition=20wrapper=20around=20an=20existing=20KokoroEng?= =?UTF-8?q?ine=20instance.=20get=5Fconfig=5Fschema()=20reflects=20today's?= =?UTF-8?q?=20actual=20fields=20(voice,=20speed,=20pitch,=20lang=5Fcode,?= =?UTF-8?q?=20split=5Fpattern,=20format,=20num=5Fthreads,=20caching,=20lex?= =?UTF-8?q?icon);=20get=5Fvoices()=20covers=20the=20genuinely-engine-owned?= =?UTF-8?q?=20part=20of=20the=20voice=20catalog=20(custom=20.pt=20files);?= =?UTF-8?q?=20mix=5Fvoices/cancel=20delegate=20straight=20through.=20Regis?= =?UTF-8?q?ters=20itself=20as=20"kokoro"=20on=20import.=20Changed=20gui.py?= =?UTF-8?q?=20=E2=80=94=20constructs=20self.backend=20right=20after=20self?= =?UTF-8?q?.engine,=20and=20the=20Mixing=20tab=20(raw=20voice-tensor=20mat?= =?UTF-8?q?h,=20Kokoro-specific)=20is=20now=20gated=20on=20self.backend.ca?= =?UTF-8?q?pabilities.supports=5Fvoice=5Fmixing=20instead=20of=20always=20?= =?UTF-8?q?shown=20=E2=80=94=20a=20no-op=20today=20since=20Kokoro=20suppor?= =?UTF-8?q?ts=20it,=20but=20ready=20for=20a=20future=20non-mixing=20backen?= =?UTF-8?q?d.=20kokoro=5Fgui/ui/generation=5Ftab.py=20=E2=80=94=20split-pa?= =?UTF-8?q?ttern=20presets,=20output-format=20choices,=20and=20the=20speed?= =?UTF-8?q?=20slider's=20bounds=20now=20come=20from=20the=20adapter's=20sc?= =?UTF-8?q?hema=20instead=20of=20being=20hand-typed=20a=20second=20time=20?= =?UTF-8?q?in=20the=20widget-building=20code.=20voice/lang=5Fcode=20intent?= =?UTF-8?q?ionally=20stay=20GUI-resolved=20(still=20GUI=20display=20data,?= =?UTF-8?q?=20not=20engine=20data=20=E2=80=94=20documented=20in=20the=20sc?= =?UTF-8?q?hema's=20docstring);=20pitch/volume/num=5Fthreads=20are=20left?= =?UTF-8?q?=20hand-built=20for=20now=20as=20a=20smaller,=20low-risk=20foll?= =?UTF-8?q?ow-on.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + gui.py | 19 ++++- kokoro_gui/engines/__init__.py | 10 +++ kokoro_gui/engines/base.py | 113 +++++++++++++++++++++++++++++ kokoro_gui/engines/kokoro.py | 117 ++++++++++++++++++++++++++++++ kokoro_gui/engines/registry.py | 44 +++++++++++ kokoro_gui/ui/generation_tab.py | 24 ++++-- tests/test_engine_backend.py | 103 ++++++++++++++++++++++++++ tests/test_gui_config_assembly.py | 13 ++++ 9 files changed, 434 insertions(+), 10 deletions(-) create mode 100644 kokoro_gui/engines/__init__.py create mode 100644 kokoro_gui/engines/base.py create mode 100644 kokoro_gui/engines/kokoro.py create mode 100644 kokoro_gui/engines/registry.py create mode 100644 tests/test_engine_backend.py diff --git a/.gitignore b/.gitignore index 97013ef..83bb464 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,4 @@ cython_debug/ /custom_voices/ /cache/ /presets/ +/PLAN_qt_and_engine_abstraction.md diff --git a/gui.py b/gui.py index 80f56f4..35d0a54 100644 --- a/gui.py +++ b/gui.py @@ -7,6 +7,7 @@ import threading from kokoro_engine import KokoroEngine +from kokoro_gui.engines import registry as engine_registry from kokoro_gui.ui import FXTabMixin, GenerationTabMixin, LexiconTabMixin, MixingTabMixin # Set Default Appearance (will be overridden by settings) @@ -40,6 +41,13 @@ def __init__(self): self.engine.on_progress = self.on_engine_progress self.engine.on_status = self.on_engine_status self.engine.on_finish = self.on_engine_finish + + # Backend abstraction (PLAN_qt_and_engine_abstraction.md workstream 1): + # a thin, engine-agnostic wrapper around self.engine used for its + # config schema and capability flags. It doesn't yet replace any of + # the direct self.engine.* calls below - those keep talking to + # KokoroEngine exactly as before. + self.backend = engine_registry.get_engine("kokoro", engine=self.engine) # Auto-save timer self.save_timer = None @@ -438,9 +446,14 @@ def create_widgets(self): gen_tab = self.main_tabs.add("Generate Audio") self.build_generation_tab(gen_tab) - - mix_tab = self.main_tabs.add("Custom Voice") - self.build_mixing_tab(mix_tab) + + # Mixing is an optional, Kokoro-shaped capability (raw voice-tensor + # math - see kokoro_gui/engine/voices.py) - gate the whole tab on it + # instead of always showing it, so a future backend without local + # voice tensors doesn't get an unusable "Custom Voice" tab. + if self.backend.capabilities.supports_voice_mixing: + mix_tab = self.main_tabs.add("Custom Voice") + self.build_mixing_tab(mix_tab) fx_tab = self.main_tabs.add("Audio FX") self.build_fx_tab(fx_tab) diff --git a/kokoro_gui/engines/__init__.py b/kokoro_gui/engines/__init__.py new file mode 100644 index 0000000..e2797e5 --- /dev/null +++ b/kokoro_gui/engines/__init__.py @@ -0,0 +1,10 @@ +"""Engine backend abstraction (PLAN_qt_and_engine_abstraction.md workstream 1). + +Importing this package registers the built-in "kokoro" backend as a side +effect (`from kokoro_gui.engines.kokoro import KokoroBackendAdapter` below), +mirroring how `kokoro_gui/ui/__init__.py` collects the Tk tab mixins. +""" +from kokoro_gui.engines import base, registry +from kokoro_gui.engines.kokoro import KokoroBackendAdapter + +__all__ = ["base", "registry", "KokoroBackendAdapter"] diff --git a/kokoro_gui/engines/base.py b/kokoro_gui/engines/base.py new file mode 100644 index 0000000..c5bef30 --- /dev/null +++ b/kokoro_gui/engines/base.py @@ -0,0 +1,113 @@ +"""Cross-engine surface for TTS backends. + +This is workstream 1 of PLAN_qt_and_engine_abstraction.md ("Abstract the +model-specific parts"): a thin, engine-agnostic description of what a TTS +backend *is* (id, display name, capability flags) and what settings it takes +(`get_config_schema()`), so the GUI can eventually render per-engine panels +and gate engine-specific tabs/features without hard-coding "Kokoro" anywhere. + +Deliberately thin. `KokoroEngine`'s actual generation/streaming entry points +(`start_conversion`, `start_jit_conversion`, `generate_preview`) stay +callback-driven and scheduled onto `AsyncLoopThread` - per the plan's +"Explicitly out of scope" note, that execution model stays Kokoro-backend- +private for now. A uniform async `generate()`/`start_jit()` request/response +surface every backend implements the same way is real design work that's +premature until a second backend actually exists to validate it against +(see migration step 5); inventing it here, unvalidated, is exactly the kind +of over-fit-to-Kokoro abstraction the plan warns against for voice mixing. +So this Protocol only covers what's true for *any* backend today: identity, +capabilities, its config schema, its voice list, and cancellation. +""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional, Protocol, runtime_checkable + + +class ConfigFieldType(str, Enum): + """Widget shape a GUI should render for a `ConfigField`.""" + FLOAT = "float" + INT = "int" + BOOL = "bool" + TEXT = "text" + FILE = "file" + CHOICE = "choice" + SLIDER = "slider" + + +@dataclass(frozen=True) +class ConfigField: + """One entry in a backend's `get_config_schema()`. + + `choices`, when set, is a list of `(label, value)` pairs for CHOICE/SLIDER + fields with a fixed, known set of options (e.g. split-pattern presets, + output format). Fields whose options are only known at runtime by the GUI + (e.g. "voice", "lang_code" - today's Kokoro voice catalog is GUI display + data, not engine data; see kokoro.py's `get_config_schema` docstring) + leave `choices=None` and the GUI resolves them dynamically. + """ + key: str + label: str + type: ConfigFieldType + default: Any = None + min: Optional[float] = None + max: Optional[float] = None + step: Optional[float] = None + choices: Optional[list] = None + group: str = "General" + + +@dataclass(frozen=True) +class EngineCapabilities: + """Flags the GUI uses to show/hide whole panels rather than special- + casing engine names/ids.""" + supports_voice_mixing: bool = False # show the Mixing tab at all + supports_voice_cloning: bool = False # show an upload-a-sample panel + supports_multi_speaker_script: bool = False # [Speaker:FX]: syntax + is_local_model: bool = True # device/GPU picker vs API-key field + supports_jit_streaming: bool = True + + +@dataclass(frozen=True) +class VoiceInfo: + """One selectable voice, as reported by a backend's `get_voices()`.""" + id: str + display_name: str + lang_code: Optional[str] = None + is_custom: bool = False + + +@runtime_checkable +class TTSEngineBackend(Protocol): + id: str + display_name: str + capabilities: EngineCapabilities + + def get_config_schema(self) -> list: + """Return this backend's `ConfigField` list, describing the settings + a generic GUI panel would need to render it.""" + ... + + def get_voices(self, lang_code: Optional[str] = None) -> list: + """Return this backend's known `VoiceInfo` list, optionally filtered + to a language code.""" + ... + + def cancel(self) -> None: + """Cancel any in-flight generation.""" + ... + + +@runtime_checkable +class SupportsVoiceMixing(Protocol): + """Optional extension for backends whose voices are locally-loadable + tensors that can be blended (`capabilities.supports_voice_mixing=True`). + Not part of `TTSEngineBackend` itself - per the plan, mixing has no + equivalent in a cloud TTS API or a differently-shaped local model, so it + is fenced off as an opt-in capability instead of forced into the shared + protocol.""" + + async def mix_voices(self, v1_name: str, v2_name: str, ratio: float, + new_name: str, op: str = "mix"): + ... diff --git a/kokoro_gui/engines/kokoro.py b/kokoro_gui/engines/kokoro.py new file mode 100644 index 0000000..8579ea7 --- /dev/null +++ b/kokoro_gui/engines/kokoro.py @@ -0,0 +1,117 @@ +"""Adapts the existing `KokoroEngine` to the `TTSEngineBackend` surface +(kokoro_gui/engines/base.py). + +Composition, not rewrite: `KokoroBackendAdapter` wraps a `KokoroEngine` +instance built and driven exactly as before - `kokoro_engine.py`'s +`AsyncLoopThread`/thread-pool internals, and `gui.py`'s callback wiring +(`on_progress`/`on_status`/`on_finish`) are untouched. This module changes no +behavior; it only describes that existing surface through the schema/ +capabilities contract so a schema-driven GUI panel and, eventually, a second +backend have something concrete to target (PLAN_qt_and_engine_abstraction.md +workstream 1). + +Reads `kokoro_engine.CUSTOM_VOICES_DIR` qualified, at call time (not via +`from kokoro_engine import CUSTOM_VOICES_DIR`), so tests can keep +monkeypatching that name on the `kokoro_engine` module - same convention +`kokoro_gui/engine/voices.py` already uses. +""" +from __future__ import annotations + +import os +from typing import Optional + +import kokoro_engine +from kokoro_gui.engines.base import ConfigField, ConfigFieldType, EngineCapabilities, VoiceInfo +from kokoro_gui.engines.registry import register_engine + +# Single source of truth for the split-pattern presets the Generation tab +# offers - moved here (out of kokoro_gui/ui/generation_tab.py's widget- +# building code) so the GUI consumes it from the schema instead of hard- +# coding it a second time. Keys are what the GUI shows; values are what +# KokoroEngine actually splits on. +SPLIT_PATTERN_CHOICES = [ + ("Natural (Newlines)", r"\n+"), + ("Paragraphs (Double Newline)", r"\n\n+"), + ("Sentences (.!?)", r"(? list: + """Reflects today's actual KokoroEngine config-dict fields (per + CLAUDE.md: "Config dicts, not typed objects" - this schema describes + that dict, it doesn't replace it). + + "voice" and "lang_code" deliberately leave `choices=None`: the voice + catalog (`TTSApp.VOICE_DB`/`LANGUAGES`) is still GUI-owned display + data as of this workstream, not engine data - `get_voices()` below + only covers the part of the catalog that *is* genuinely engine/ + filesystem state (custom voice files). Migrating the built-in voice + table itself behind the adapter is follow-on work, not required to + make the Generation tab's other fields (split pattern, format, + speed) schema-driven. + """ + return [ + ConfigField("lang_code", "Language", ConfigFieldType.CHOICE, + default="a", group="Generation"), + ConfigField("voice", "Voice", ConfigFieldType.CHOICE, + default="af_heart", group="Generation"), + ConfigField("speed", "Speed", ConfigFieldType.SLIDER, + default=1.0, min=0.5, max=2.0, step=0.1, group="Generation"), + ConfigField("pitch", "Pitch", ConfigFieldType.SLIDER, + default=0.0, min=-12, max=12, step=1, group="Audio"), + ConfigField("split_pattern", "Split By", ConfigFieldType.CHOICE, + default=r"\n+", choices=list(SPLIT_PATTERN_CHOICES), group="Generation"), + ConfigField("format", "Output Format", ConfigFieldType.CHOICE, + default="wav", choices=list(OUTPUT_FORMAT_CHOICES), group="Generation"), + ConfigField("num_threads", "Parallel Threads", ConfigFieldType.INT, + default=1, min=1, max=32, step=1, group="Advanced"), + ConfigField("caching", "Enable Segment Cache", ConfigFieldType.BOOL, + default=True, group="Advanced"), + ConfigField("lexicon", "Lexicon Substitutions", ConfigFieldType.TEXT, + default={}, group="Advanced"), + ] + + def get_voices(self, lang_code: Optional[str] = None) -> list: + """Custom voices discovered under `CUSTOM_VOICES_DIR` - the built-in + named voices (af_heart, bm_daniel, ...) aren't listed here; see the + `get_config_schema` docstring for why.""" + custom_dir = kokoro_engine.CUSTOM_VOICES_DIR + if not os.path.isdir(custom_dir): + return [] + return [ + VoiceInfo(id=f[:-3], display_name=f[:-3], lang_code=None, is_custom=True) + for f in sorted(os.listdir(custom_dir)) + if f.endswith(".pt") + ] + + async def mix_voices(self, v1_name: str, v2_name: str, ratio: float, + new_name: str, op: str = "mix"): + """`SupportsVoiceMixing` extension - delegates straight to the + wrapped engine's tensor math (kokoro_gui/engine/voices.py), which + stays exactly where it is per the plan.""" + return await self._engine.mix_voices(v1_name, v2_name, ratio, new_name, op) + + def cancel(self) -> None: + self._engine.cancel() + + +register_engine("kokoro", KokoroBackendAdapter, display_name=KokoroBackendAdapter.display_name) diff --git a/kokoro_gui/engines/registry.py b/kokoro_gui/engines/registry.py new file mode 100644 index 0000000..b360b2d --- /dev/null +++ b/kokoro_gui/engines/registry.py @@ -0,0 +1,44 @@ +"""Backend registry: `register_engine`/`get_engine`/`list_engines`. + +A "factory" here is any callable that returns a `TTSEngineBackend` instance +- for the built-in `kokoro.py` adapter that's the `KokoroBackendAdapter` +class itself, called with the already-constructed `KokoroEngine` it wraps +(`get_engine("kokoro", engine=some_kokoro_engine)`), since the adapter is +composition over an existing engine instance, not a from-scratch factory. +""" +from __future__ import annotations + +from typing import Callable, Dict + +_registry: Dict[str, Callable[..., object]] = {} +_display_names: Dict[str, str] = {} + + +def register_engine(engine_id: str, factory: Callable[..., object], display_name: str = None) -> None: + """Register `factory` under `engine_id`. Re-registering the same id + overwrites the previous factory (useful for tests that register a fake + backend under a throwaway id).""" + _registry[engine_id] = factory + if display_name is not None: + _display_names[engine_id] = display_name + + +def get_engine(engine_id: str, *args, **kwargs): + """Construct and return the backend registered under `engine_id`.""" + if engine_id not in _registry: + raise KeyError( + f"No engine backend registered under {engine_id!r}. " + f"Known engines: {sorted(_registry)}" + ) + return _registry[engine_id](*args, **kwargs) + + +def list_engines() -> list: + """Return the sorted list of registered engine ids.""" + return sorted(_registry) + + +def unregister_engine(engine_id: str) -> None: + """Remove a registered engine id (mainly for test teardown).""" + _registry.pop(engine_id, None) + _display_names.pop(engine_id, None) diff --git a/kokoro_gui/ui/generation_tab.py b/kokoro_gui/ui/generation_tab.py index 356ea00..da1e592 100644 --- a/kokoro_gui/ui/generation_tab.py +++ b/kokoro_gui/ui/generation_tab.py @@ -98,6 +98,14 @@ def load_preset(self, name): def build_generation_tab(self, parent): parent.grid_columnconfigure(0, weight=1) + # Schema-driven fields (PLAN_qt_and_engine_abstraction.md workstream + # 1): split-pattern presets, output-format choices, and the speed + # slider's bounds come from the active backend's config schema + # (kokoro_gui/engines/kokoro.py) instead of being hard-coded a + # second time here. "voice"/"lang_code" stay GUI-resolved below - + # see that schema's docstring for why. + schema_fields = {f.key: f for f in self.backend.get_config_schema()} + # Move existing logic here main_frame = ctk.CTkScrollableFrame(parent) main_frame.pack(fill="both", expand=True, padx=5, pady=5) @@ -190,22 +198,24 @@ def on_lang_ui_change(choice): ctk.CTkEntry(file_row, textvariable=self.filename_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) - self.format_combo = ctk.CTkComboBox(file_row, values=["wav", "flac", "mp3", "ogg"], width=70, variable=self.output_format_var) + format_field = schema_fields["format"] + self.format_combo = ctk.CTkComboBox(file_row, values=[label for label, _ in format_field.choices], width=70, variable=self.output_format_var) self.format_combo.grid(row=0, column=1) # Speed + speed_field = schema_fields["speed"] self.speed_label = ctk.CTkLabel(config_frame, text="Speed: 1.0x") self.speed_label.grid(row=5, column=0, sticky="w", padx=10, pady=5) - self.speed_slider = ctk.CTkSlider(config_frame, from_=0.5, to=2.0, number_of_steps=15, variable=self.speed_var, command=self.update_speed_label) + self.speed_slider = ctk.CTkSlider( + config_frame, from_=speed_field.min, to=speed_field.max, + number_of_steps=round((speed_field.max - speed_field.min) / speed_field.step), + variable=self.speed_var, command=self.update_speed_label, + ) self.speed_slider.grid(row=5, column=1, sticky="ew", padx=10) # Split Pattern ctk.CTkLabel(config_frame, text="Split By:").grid(row=6, column=0, sticky="w", padx=10, pady=5) - self.split_map = { - "Natural (Newlines)": r"\n+", - "Paragraphs (Double Newline)": r"\n\n+", - "Sentences (.!?)": r"(? Date: Sat, 22 Aug 2026 14:50:00 -0600 Subject: [PATCH 03/44] New: Dummy engine backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kokoro_gui/engines/dummy.py — DummyEngine + DummyBackendAdapter, registered as "dummy". It generates short sine tones instead of real speech, but reuses every mixin that turned out to be genuinely model-agnostic (FX chain, batch/JIT conversion orchestration, lexicon, presets, SRT export, text extraction) unmodified — it's real proof the workstream-1 abstraction isn't over-fit to Kokoro, not just a mock. It deliberately skips the shared segment cache (writing dummy tones into CACHE_DIR under the same text|voice|speed|lang_code hash the real engine uses would let a later Kokoro run collide with a cached tone — the exact cross-engine cache bug workstream 2 exists to fix). Enabling change: one polymorphic pipeline hook The only truly Kokoro-specific call inside the generic mixins was kokoro_engine.get_thread_pipeline(lang_code). I added KokoroEngine.get_thread_pipeline() (kokoro_engine.py) and switched the two hard-coded call sites in caching.py and conversion.py to self.get_thread_pipeline(...) — zero behavior change for Kokoro (still resolves via the same monkeypatchable module-level function), but now any backend can supply its own. GUI: engine switching gui.py gained an "Engine:" dropdown in the header, listing everything in kokoro_gui/engines/registry.py. Selecting one calls switch_engine(), which: refuses (with a warning) if a job is currently running, builds a fresh instance of the chosen backend, rewires on_progress/on_status/on_finish onto it, stops the old backend's worker thread, re-syncs the Mixing tab via _sync_mixing_tab() (added/removed live using CTkTabview.add/.delete) based on the new backend's capabilities.supports_voice_mixing — switching to "dummy" now visibly makes the Custom Voice tab disappear. It does not re-render the Generation tab's schema-driven fields for the newly active backend — that's real Qt-migration territory per the plan, noted explicitly in the code and the plan doc. --- gui.py | 83 +++++++++++-- kokoro_engine.py | 12 ++ kokoro_gui/engine/caching.py | 15 ++- kokoro_gui/engine/conversion.py | 14 ++- kokoro_gui/engines/__init__.py | 9 +- kokoro_gui/engines/base.py | 14 +++ kokoro_gui/engines/dummy.py | 214 ++++++++++++++++++++++++++++++++ kokoro_gui/engines/kokoro.py | 39 +++--- kokoro_gui/engines/registry.py | 6 + tests/test_engine_backend.py | 66 ++++++++++ 10 files changed, 432 insertions(+), 40 deletions(-) create mode 100644 kokoro_gui/engines/dummy.py diff --git a/gui.py b/gui.py index 35d0a54..a3720c7 100644 --- a/gui.py +++ b/gui.py @@ -436,24 +436,41 @@ def create_widgets(self): header_frame = ctk.CTkFrame(self, fg_color="transparent") header_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=(10,0)) - + ctk.CTkLabel(header_frame, text="Kokoro TTS", font=("Roboto", 20, "bold")).pack(side="left", padx=5) ctk.CTkButton(header_frame, text="⚙ Settings", width=80, height=28, command=self.open_settings).pack(side="right") + # Engine picker (PLAN_qt_and_engine_abstraction.md workstream 1) - + # lists every backend registered in kokoro_gui/engines/registry.py + # (built-in: "kokoro", "dummy") and swaps the active self.engine/ + # self.backend on selection. Mainly a testing aid for now, ahead of + # the Qt migration's real per-engine settings panels. + engine_picker_frame = ctk.CTkFrame(header_frame, fg_color="transparent") + engine_picker_frame.pack(side="right", padx=10) + ctk.CTkLabel(engine_picker_frame, text="Engine:").pack(side="left", padx=(0, 5)) + self._engine_ids_by_display_name = { + engine_registry.get_display_name(eid): eid for eid in engine_registry.list_engines() + } + self.engine_picker = ctk.CTkComboBox( + engine_picker_frame, values=list(self._engine_ids_by_display_name.keys()), + width=200, command=self.on_engine_picker_change, + ) + self.engine_picker.set(engine_registry.get_display_name(self.backend.id)) + self.engine_picker.pack(side="left") + # Main Tabs self.main_tabs = ctk.CTkTabview(self) self.main_tabs.grid(row=1, column=0, sticky="nsew", padx=10, pady=10) - + gen_tab = self.main_tabs.add("Generate Audio") self.build_generation_tab(gen_tab) # Mixing is an optional, Kokoro-shaped capability (raw voice-tensor # math - see kokoro_gui/engine/voices.py) - gate the whole tab on it - # instead of always showing it, so a future backend without local - # voice tensors doesn't get an unusable "Custom Voice" tab. - if self.backend.capabilities.supports_voice_mixing: - mix_tab = self.main_tabs.add("Custom Voice") - self.build_mixing_tab(mix_tab) + # instead of always showing it, so a backend without local voice + # tensors (e.g. "dummy") doesn't get an unusable "Custom Voice" tab. + self._mixing_tab_built = False + self._sync_mixing_tab() fx_tab = self.main_tabs.add("Audio FX") self.build_fx_tab(fx_tab) @@ -491,6 +508,58 @@ def create_widgets(self): self.cancel_btn = ctk.CTkButton(btn_frame, text="Cancel", command=self.cancel_conversion, height=40, fg_color="#c42b1c", hover_color="#8a1f14", state="disabled") self.cancel_btn.pack(side="left", fill="x", expand=True, padx=5) + def _sync_mixing_tab(self): + """Add/remove the "Custom Voice" tab to match the active backend's + `capabilities.supports_voice_mixing`. Called once from create_widgets + and again from switch_engine whenever the flag changes.""" + wants_mixing = self.backend.capabilities.supports_voice_mixing + if wants_mixing and not self._mixing_tab_built: + mix_tab = self.main_tabs.add("Custom Voice") + self.build_mixing_tab(mix_tab) + self._mixing_tab_built = True + elif not wants_mixing and self._mixing_tab_built: + self.main_tabs.delete("Custom Voice") + self._mixing_tab_built = False + + def on_engine_picker_change(self, display_name): + engine_id = self._engine_ids_by_display_name.get(display_name) + if engine_id is None or engine_id == self.backend.id: + return + self.switch_engine(engine_id) + + def switch_engine(self, engine_id): + """Swap the active self.engine/self.backend to a freshly-constructed + instance of the backend registered under `engine_id` (kokoro_gui/ + engines/registry.py), rewiring callbacks and re-syncing the Mixing + tab. Mainly a testing aid for the engine abstraction (workstream 1) + ahead of the Qt migration's real per-engine settings panels - it does + NOT re-render the Generation tab's schema-driven fields (split + pattern/format/speed bounds) for the new backend's schema; those stay + whatever they were built from at startup.""" + if self.cancel_btn.cget("state") == "normal": + messagebox.showwarning("Busy", "Cancel the current job before switching engines.") + self.engine_picker.set(engine_registry.get_display_name(self.backend.id)) + return + + old_engine = self.engine + new_backend = engine_registry.get_engine(engine_id) + new_engine = new_backend.engine + new_engine.on_progress = self.on_engine_progress + new_engine.on_status = self.on_engine_status + new_engine.on_finish = self.on_engine_finish + + self.engine = new_engine + self.backend = new_backend + self._sync_mixing_tab() + + try: + old_engine.worker.stop() + except Exception: + pass + + self.status_label.configure(text=f"Switched engine to {new_backend.display_name}. Initializing...", text_color="gray") + self.engine.worker.run_coro(self.engine.init_pipeline_async(self.lang_var.get())) + def open_settings(self): toplevel = ctk.CTkToplevel(self) toplevel.title("Settings") diff --git a/kokoro_engine.py b/kokoro_engine.py index 802070c..07a43ea 100644 --- a/kokoro_engine.py +++ b/kokoro_engine.py @@ -75,6 +75,18 @@ def __init__(self): self._lexicon_cache = {} # Cache for compiled regexes + def get_thread_pipeline(self, lang_code="a"): + """Instance-method indirection to the module-level thread-local + KPipeline getter, so the generic mixins (caching.py, conversion.py) + can call `self.get_thread_pipeline(...)` polymorphically instead of + hard-coding `kokoro_engine.get_thread_pipeline` - the one piece of + that shared pipeline that's genuinely Kokoro-specific (see + kokoro_gui/engines/dummy.py for a from-scratch, non-Kokoro backend + built on the same generic mixins). Calls the free function by name + (not a direct reference) so `monkeypatch.setattr(kokoro_engine, + "get_thread_pipeline", ...)` in tests still takes effect.""" + return get_thread_pipeline(lang_code) + async def init_pipeline_async(self, lang_code="a"): try: self.pipeline = await asyncio.to_thread(KPipeline, lang_code=lang_code) diff --git a/kokoro_gui/engine/caching.py b/kokoro_gui/engine/caching.py index 222fa43..a945d26 100644 --- a/kokoro_gui/engine/caching.py +++ b/kokoro_gui/engine/caching.py @@ -1,9 +1,14 @@ """Per-chunk generation with WAV segment caching, keyed on text|voice|speed|lang_code. -Reads `kokoro_engine.CACHE_DIR` and calls `kokoro_engine.get_thread_pipeline` -qualified, at call time, so tests can keep monkeypatching those names on the -`kokoro_engine` module (e.g. the `isolated_dirs`/`make_config` fixtures and the -`_boom` sentinel used in `test_caching.py`/`test_mix_voices.py`). +Reads `kokoro_engine.CACHE_DIR` qualified, at call time, so tests can keep +monkeypatching that name on the `kokoro_engine` module (e.g. the +`isolated_dirs`/`make_config` fixtures and the `_boom` sentinel used in +`test_caching.py`/`test_mix_voices.py`). The actual synthesis call goes +through `self.get_thread_pipeline(lang_code)` rather than +`kokoro_engine.get_thread_pipeline` directly - that's the one genuinely +model-specific piece of this otherwise-generic pipeline, and going through +`self` lets a non-Kokoro backend (kokoro_gui/engines/dummy.py) reuse this +whole mixin by supplying its own `get_thread_pipeline`. """ import hashlib import os @@ -125,7 +130,7 @@ def process_and_save(graphemes, raw_audio): sub_idx += 1 else: # Generate - pipeline = kokoro_engine.get_thread_pipeline(lang_code) + pipeline = self.get_thread_pipeline(lang_code) if not pipeline: raise RuntimeError(f"Failed to initialize pipeline ({lang_code}) in thread.") generator = pipeline(text, voice=config['voice'], speed=eff_speed, split_pattern=config['split_pattern']) diff --git a/kokoro_gui/engine/conversion.py b/kokoro_gui/engine/conversion.py index e2dc5f6..442cbbd 100644 --- a/kokoro_gui/engine/conversion.py +++ b/kokoro_gui/engine/conversion.py @@ -2,8 +2,14 @@ chunked "Standard" batch pipeline (`start_conversion` -> `_process_text_async`), and the WAV-segment combiner shared with JIT mode. -`generate_preview` calls `kokoro_engine.get_thread_pipeline` qualified, at call -time, so tests can keep monkeypatching that name on the `kokoro_engine` module. +`generate_preview` calls `self.get_thread_pipeline(lang_code)` rather than +`kokoro_engine.get_thread_pipeline` directly - that's the one genuinely +model-specific piece of this otherwise-generic mixin, and going through +`self` lets a non-Kokoro backend (kokoro_gui/engines/dummy.py) reuse this +whole mixin by supplying its own `get_thread_pipeline`. `KokoroEngine.get_thread_pipeline` +(kokoro_engine.py) itself still calls the module-level thread-local getter by +name, so `monkeypatch.setattr(kokoro_engine, "get_thread_pipeline", ...)` in +tests still takes effect. """ import asyncio import concurrent.futures @@ -16,14 +22,12 @@ import torch from pedalboard.io import AudioFile -import kokoro_engine - class ConversionMixin: async def generate_preview(self, text, voice, speed, output_path, extra_config=None, voice_tensor=None, lang_code='a'): def _gen(): # Use specific lang code for preview - p = kokoro_engine.get_thread_pipeline(lang_code) + p = self.get_thread_pipeline(lang_code) if not p: return False try: diff --git a/kokoro_gui/engines/__init__.py b/kokoro_gui/engines/__init__.py index e2797e5..1c6a4a4 100644 --- a/kokoro_gui/engines/__init__.py +++ b/kokoro_gui/engines/__init__.py @@ -1,10 +1,11 @@ """Engine backend abstraction (PLAN_qt_and_engine_abstraction.md workstream 1). -Importing this package registers the built-in "kokoro" backend as a side -effect (`from kokoro_gui.engines.kokoro import KokoroBackendAdapter` below), -mirroring how `kokoro_gui/ui/__init__.py` collects the Tk tab mixins. +Importing this package registers the built-in "kokoro" and "dummy" backends +as a side effect (the `kokoro`/`dummy` submodule imports below), mirroring +how `kokoro_gui/ui/__init__.py` collects the Tk tab mixins. """ from kokoro_gui.engines import base, registry +from kokoro_gui.engines.dummy import DummyBackendAdapter from kokoro_gui.engines.kokoro import KokoroBackendAdapter -__all__ = ["base", "registry", "KokoroBackendAdapter"] +__all__ = ["base", "registry", "KokoroBackendAdapter", "DummyBackendAdapter"] diff --git a/kokoro_gui/engines/base.py b/kokoro_gui/engines/base.py index c5bef30..730cbc6 100644 --- a/kokoro_gui/engines/base.py +++ b/kokoro_gui/engines/base.py @@ -69,6 +69,20 @@ class EngineCapabilities: supports_jit_streaming: bool = True +# Choice presets for schema fields whose *meaning* isn't actually +# model-specific, just conventionally offered by more than one backend: how +# raw input text gets split into chunks before parallel processing, and +# which container formats get written to disk. Backends are free to ignore +# these or offer their own instead - they're shared defaults, not part of +# the Protocol. +COMMON_SPLIT_PATTERN_CHOICES = [ + ("Natural (Newlines)", r"\n+"), + ("Paragraphs (Double Newline)", r"\n\n+"), + ("Sentences (.!?)", r"(? different pitch + tone = (0.2 * np.sin(2 * np.pi * freq * t)).astype(np.float32) + fade = min(200, n // 4) + if fade > 0: + env = np.ones(n, dtype=np.float32) + env[:fade] = np.linspace(0.0, 1.0, fade, dtype=np.float32) + env[-fade:] = np.linspace(1.0, 0.0, fade, dtype=np.float32) + tone = tone * env + return tone + + +class DummyEngine( + AudioFXMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, + SrtMixin, TextExtractionMixin, +): + """KokoroEngine-shaped enough for gui.py to drive directly (same + `worker`/`cancel_event`/`pipeline`/`on_progress`/`on_status`/`on_finish`/ + `start_conversion`/`start_jit_conversion`/`generate_preview`/`cancel` + surface), but with no real synthesis or caching underneath.""" + + def __init__(self): + self.worker = AsyncLoopThread() + self.worker.start() + self.cancel_event = threading.Event() + self.pipeline = True # no model to load - "ready" immediately + + self.on_progress = None + self.on_status = None + self.on_finish = None + + self._lexicon_cache = {} + + async def init_pipeline_async(self, lang_code="a"): + self.pipeline = True + if self.on_status: + self.on_status(f"Dummy pipeline ready ({lang_code}).", False) + return True + + def get_thread_pipeline(self, lang_code="a"): + return DummyPipeline(lang_code) + + def resolve_voice_path(self, voice_name): + # No custom-voice directory concept for the dummy backend - voice + # names are just labels that pick a tone pitch (see _tone_for). + return voice_name + + def process_chunk_task(self, chunk_data, progress_callback): + """Same shape as CachingMixin.process_chunk_task, deliberately + without the cache - see this module's docstring for why.""" + index, text, config = chunk_data + if self.cancel_event.is_set(): + return [] + + lang_code = config.get('lang_code', 'a') + pipeline = self.get_thread_pipeline(lang_code) + generator = pipeline( + text, voice=config['voice'], speed=config['speed'], + split_pattern=config.get('split_pattern', r"\n+"), + ) + + chunk_files = [] + sub_idx = 0 + base_name = f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_part{index}" + + for graphemes, phonemes, audio in generator: + if self.cancel_event.is_set(): + break + if progress_callback: + progress_callback(len(graphemes), graphemes) + + processed_audio = self.process_audio(audio, SAMPLE_RATE, config) + + fmt = config.get('format', 'wav').lower() + if fmt not in ('wav', 'flac', 'mp3', 'ogg'): + fmt = 'wav' + file_name = f"{base_name}_{sub_idx}.{fmt}" + path = os.path.join(config['out_dir'], file_name) + + try: + with AudioFile(path, 'w', samplerate=SAMPLE_RATE, num_channels=1) as f: + f.write(processed_audio) + except Exception as e: + print(f"Dummy engine write failed: {e}. Fallback to soundfile.") + sf.write(path, processed_audio, SAMPLE_RATE) + + chunk_files.append({ + "path": path, "text": graphemes, + "duration": len(processed_audio) / SAMPLE_RATE, "seg_idx": index, + }) + sub_idx += 1 + + return chunk_files + + def cancel(self): + self.cancel_event.set() + + +class DummyBackendAdapter: + id = "dummy" + display_name = "Dummy (offline test tone)" + capabilities = EngineCapabilities( + supports_voice_mixing=False, + supports_voice_cloning=False, + supports_multi_speaker_script=True, + is_local_model=True, + supports_jit_streaming=True, + ) + + def __init__(self, engine=None): + """Same convention as `KokoroBackendAdapter`: wraps an existing + `DummyEngine` when given (tests), otherwise builds its own - used + when the GUI switches its active backend at runtime.""" + self._engine = engine if engine is not None else DummyEngine() + + @property + def engine(self): + return self._engine + + def get_config_schema(self) -> list: + return [ + ConfigField("lang_code", "Language", ConfigFieldType.CHOICE, + default="a", group="Generation"), + ConfigField("voice", "Voice", ConfigFieldType.CHOICE, + default="dummy", group="Generation"), + ConfigField("speed", "Speed", ConfigFieldType.SLIDER, + default=1.0, min=0.5, max=2.0, step=0.1, group="Generation"), + ConfigField("pitch", "Pitch", ConfigFieldType.SLIDER, + default=0.0, min=-12, max=12, step=1, group="Audio"), + ConfigField("split_pattern", "Split By", ConfigFieldType.CHOICE, + default=r"\n+", choices=list(COMMON_SPLIT_PATTERN_CHOICES), group="Generation"), + ConfigField("format", "Output Format", ConfigFieldType.CHOICE, + default="wav", choices=list(COMMON_OUTPUT_FORMAT_CHOICES), group="Generation"), + ConfigField("num_threads", "Parallel Threads", ConfigFieldType.INT, + default=1, min=1, max=32, step=1, group="Advanced"), + ConfigField("caching", "Enable Segment Cache", ConfigFieldType.BOOL, + default=False, group="Advanced"), + ] + + def get_voices(self, lang_code=None) -> list: + return [VoiceInfo(id="dummy", display_name="Dummy Tone", lang_code=None, is_custom=False)] + + def cancel(self) -> None: + self._engine.cancel() + + +register_engine("dummy", DummyBackendAdapter, display_name=DummyBackendAdapter.display_name) diff --git a/kokoro_gui/engines/kokoro.py b/kokoro_gui/engines/kokoro.py index 8579ea7..e364093 100644 --- a/kokoro_gui/engines/kokoro.py +++ b/kokoro_gui/engines/kokoro.py @@ -21,22 +21,13 @@ from typing import Optional import kokoro_engine -from kokoro_gui.engines.base import ConfigField, ConfigFieldType, EngineCapabilities, VoiceInfo +from kokoro_gui.engines.base import ( + ConfigField, ConfigFieldType, EngineCapabilities, VoiceInfo, + COMMON_SPLIT_PATTERN_CHOICES as SPLIT_PATTERN_CHOICES, + COMMON_OUTPUT_FORMAT_CHOICES as OUTPUT_FORMAT_CHOICES, +) from kokoro_gui.engines.registry import register_engine -# Single source of truth for the split-pattern presets the Generation tab -# offers - moved here (out of kokoro_gui/ui/generation_tab.py's widget- -# building code) so the GUI consumes it from the schema instead of hard- -# coding it a second time. Keys are what the GUI shows; values are what -# KokoroEngine actually splits on. -SPLIT_PATTERN_CHOICES = [ - ("Natural (Newlines)", r"\n+"), - ("Paragraphs (Double Newline)", r"\n\n+"), - ("Sentences (.!?)", r"(? list: """Reflects today's actual KokoroEngine config-dict fields (per diff --git a/kokoro_gui/engines/registry.py b/kokoro_gui/engines/registry.py index b360b2d..ccc151d 100644 --- a/kokoro_gui/engines/registry.py +++ b/kokoro_gui/engines/registry.py @@ -38,6 +38,12 @@ def list_engines() -> list: return sorted(_registry) +def get_display_name(engine_id: str) -> str: + """Human-readable name for `engine_id`, falling back to the id itself if + none was given at registration time.""" + return _display_names.get(engine_id, engine_id) + + def unregister_engine(engine_id: str) -> None: """Remove a registered engine id (mainly for test teardown).""" _registry.pop(engine_id, None) diff --git a/tests/test_engine_backend.py b/tests/test_engine_backend.py index 019cf9f..007b248 100644 --- a/tests/test_engine_backend.py +++ b/tests/test_engine_backend.py @@ -5,8 +5,10 @@ import pytest +import gui from kokoro_gui.engines import registry from kokoro_gui.engines.base import ConfigField, EngineCapabilities, VoiceInfo +from kokoro_gui.engines.dummy import DummyBackendAdapter, DummyEngine from kokoro_gui.engines.kokoro import KokoroBackendAdapter, OUTPUT_FORMAT_CHOICES, SPLIT_PATTERN_CHOICES @@ -93,6 +95,70 @@ def test_cancel_delegates_to_wrapped_engine(engine): engine.cancel.assert_called_once_with() +def test_dummy_registered_and_shaped_like_a_real_backend(): + assert "dummy" in registry.list_engines() + assert DummyBackendAdapter.capabilities.supports_voice_mixing is False + + backend = registry.get_engine("dummy") + keys = {f.key for f in backend.get_config_schema()} + assert keys == { + "lang_code", "voice", "speed", "pitch", "split_pattern", + "format", "num_threads", "caching", + } + assert backend.get_voices() == [VoiceInfo(id="dummy", display_name="Dummy Tone", lang_code=None, is_custom=False)] + + +def test_dummy_engine_produces_real_nonsilent_audio(tmp_path): + """Sanity check that DummyEngine's fake pipeline actually writes audible + (non-silent) audio through the same process_chunk_task shape as + CachingMixin, exercising the generic FX/write path with no cache.""" + import numpy as np + import soundfile as sf + + engine = DummyEngine() + try: + config = { + "lang_code": "a", "voice": "dummy", "speed": 1.0, "split_pattern": r"\n+", + "filename": "out", "time_id": "1", "out_dir": str(tmp_path), "format": "wav", + "apply_fx": False, + } + files = engine.process_chunk_task((0, "Hello there.", config), None) + assert len(files) == 1 + data, sr = sf.read(files[0]["path"]) + assert sr == 24000 + assert np.max(np.abs(data)) > 0.01 + finally: + engine.worker.stop() + + +def test_switch_engine_to_dummy_updates_engine_backend_and_mixing_tab(tts_app): + assert tts_app.backend.id == "kokoro" + assert tts_app._mixing_tab_built is True + + tts_app.switch_engine("dummy") + + assert tts_app.backend.id == "dummy" + assert isinstance(tts_app.engine, DummyEngine) + assert tts_app.engine.on_progress == tts_app.on_engine_progress + assert tts_app.engine.on_status == tts_app.on_engine_status + assert tts_app.engine.on_finish == tts_app.on_engine_finish + assert tts_app._mixing_tab_built is False + + +def test_on_engine_picker_change_maps_display_name_to_id(tts_app): + tts_app.on_engine_picker_change("Dummy (offline test tone)") + assert tts_app.backend.id == "dummy" + + +def test_switch_engine_refuses_while_a_job_is_running(tts_app): + tts_app.cancel_btn.configure(state="normal") # simulate an in-flight job + + tts_app.switch_engine("dummy") + + assert tts_app.backend.id == "kokoro" + assert gui.messagebox.showwarning.called + + def test_tts_app_wires_a_backend_and_shows_mixing_tab_when_capable(tts_app): """Mixing tab in create_widgets (gui.py) is gated on backend.capabilities.supports_voice_mixing - kokoro supports it, so the From 78ea26fac99cfa65a1a75a6642bef8f336d539d2 Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 15:12:30 -0600 Subject: [PATCH 04/44] =?UTF-8?q?kokoro=5Fgui/engine/caching.py=20CACHE=5F?= =?UTF-8?q?SCHEMA=5FVERSION=20=3D=202=20=E2=80=94=20bump=20this=20on=20any?= =?UTF-8?q?=20future=20change=20to=20the=20hash=20composition;=20old=20ent?= =?UTF-8?q?ries=20just=20stop=20matching.=20compute=5Fcache=5Fkey(text,=20?= =?UTF-8?q?config,=20eff=5Fspeed,=20lang=5Fcode,=20engine=5Fid=3D"kokoro",?= =?UTF-8?q?=20engine=5Fversion=3DNone)=20=E2=80=94=20a=20standalone,=20uni?= =?UTF-8?q?t-testable=20SHA-256=20replacement=20for=20the=20old=20MD5=20te?= =?UTF-8?q?xt|voice|speed|lang=5Fcode=20scheme.=20Composition:=20schema=5F?= =?UTF-8?q?version|engine=5Fid|engine=5Fversion|text|voice|voice=5Ffingerp?= =?UTF-8?q?rint|speed|lang=5Fcode.=20split=5Fpattern/FX/format/normalize/t?= =?UTF-8?q?rim=20stay=20out=20of=20the=20hash=20exactly=20as=20before.=20v?= =?UTF-8?q?oice=5Ffingerprint(voice=5Fref)=20=E2=80=94=20standard=20voices?= =?UTF-8?q?=20are=20fingerprinted=20by=20name=20(they=20don't=20change);?= =?UTF-8?q?=20custom=20voices=20(resolved=20to=20an=20absolute=20.pt=20pat?= =?UTF-8?q?h=20under=20CUSTOM=5FVOICES=5FDIR)=20are=20fingerprinted=20by?= =?UTF-8?q?=20SHA-256=20file=20content,=20cached=20per-mtime=20so=20a=20ba?= =?UTF-8?q?tch=20run=20doesn't=20re-hash=20the=20same=20file=20per=20chunk?= =?UTF-8?q?.=20This=20is=20the=20fix=20for=20the=20"remix=20and=20re-save?= =?UTF-8?q?=20a=20custom=20voice=20under=20the=20same=20name"=20staleness?= =?UTF-8?q?=20case=20the=20plan=20called=20out.=20get=5Fengine=5Fversion(e?= =?UTF-8?q?ngine=5Fid)=20=E2=80=94=20importlib.metadata.version("kokoro")?= =?UTF-8?q?=20for=20"kokoro",=20"unknown"=20for=20anything=20else=20(hones?= =?UTF-8?q?t=20about=20what's=20actually=20implemented=20today).=20gui.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_conversion's config dict now sets config['engine_id'] = self.backend.id, so the cache key can actually tell backends apart. process_chunk_task falls back to "kokoro" when the key is absent (e.g. tests/callers predating this). --- gui.py | 1 + kokoro_gui/engine/caching.py | 125 +++++++++++++++++++++++++--- tests/test_caching.py | 130 ++++++++++++++++++++++++++++-- tests/test_gui_config_assembly.py | 2 +- 4 files changed, 237 insertions(+), 21 deletions(-) diff --git a/gui.py b/gui.py index a3720c7..7580fb0 100644 --- a/gui.py +++ b/gui.py @@ -826,6 +826,7 @@ def start_conversion(self): # 2. Config config = { + 'engine_id': self.backend.id, 'lang_code': self.lang_var.get(), 'voice': self.voice_var.get(), 'speed': self.speed_var.get(), diff --git a/kokoro_gui/engine/caching.py b/kokoro_gui/engine/caching.py index a945d26..1f4ca88 100644 --- a/kokoro_gui/engine/caching.py +++ b/kokoro_gui/engine/caching.py @@ -1,16 +1,19 @@ -"""Per-chunk generation with WAV segment caching, keyed on text|voice|speed|lang_code. - -Reads `kokoro_engine.CACHE_DIR` qualified, at call time, so tests can keep -monkeypatching that name on the `kokoro_engine` module (e.g. the -`isolated_dirs`/`make_config` fixtures and the `_boom` sentinel used in -`test_caching.py`/`test_mix_voices.py`). The actual synthesis call goes -through `self.get_thread_pipeline(lang_code)` rather than -`kokoro_engine.get_thread_pipeline` directly - that's the one genuinely -model-specific piece of this otherwise-generic pipeline, and going through -`self` lets a non-Kokoro backend (kokoro_gui/engines/dummy.py) reuse this -whole mixin by supplying its own `get_thread_pipeline`. +"""Per-chunk generation with WAV segment caching, keyed on +schema_version|engine_id|engine_version|text|voice|voice_fingerprint|speed|lang_code +(see `compute_cache_key` - PLAN_qt_and_engine_abstraction.md workstream 2). + +Reads `kokoro_engine.CACHE_DIR`/`kokoro_engine.CUSTOM_VOICES_DIR` qualified, at +call time, so tests can keep monkeypatching those names on the +`kokoro_engine` module (e.g. the `isolated_dirs`/`make_config` fixtures and +the `_boom` sentinel used in `test_caching.py`/`test_mix_voices.py`). The +actual synthesis call goes through `self.get_thread_pipeline(lang_code)` +rather than `kokoro_engine.get_thread_pipeline` directly - that's the one +genuinely model-specific piece of this otherwise-generic pipeline, and going +through `self` lets a non-Kokoro backend (kokoro_gui/engines/dummy.py) reuse +this whole mixin by supplying its own `get_thread_pipeline`. """ import hashlib +import importlib.metadata import os import re @@ -20,6 +23,102 @@ import kokoro_engine +# Bump whenever compute_cache_key's composition or logic changes. Old cache +# entries simply stop matching (new hash algorithm -> new filenames) and +# become dead weight for whatever eventually implements cache eviction +# (ROADMAP Phase 2) - no explicit migration/cleanup needed, but a bump does +# mean the first run after upgrading regenerates the whole cache. +CACHE_SCHEMA_VERSION = 2 + +# path -> (mtime, fingerprint): avoids re-hashing the same custom-voice file +# on every chunk in a batch run. Mirrors the `self._lexicon_cache` compiled- +# regex cache pattern (the lexicon perf fix) but keyed on filesystem content +# rather than an engine instance, since voice files are process-wide state. +_voice_fingerprint_cache = {} + + +def get_engine_version(engine_id="kokoro"): + """Best-effort version/identity string for `engine_id`, folded into the + cache key so an upgrade that changes model output invalidates stale + entries instead of silently serving old audio under it. Only "kokoro" + has a concrete answer today (the installed `kokoro` package version) - + any other engine_id (e.g. a future cloud backend, or "dummy") falls back + to a constant so its cache entries are at least self-consistent.""" + if engine_id == "kokoro": + try: + return importlib.metadata.version("kokoro") + except importlib.metadata.PackageNotFoundError: + return "unknown" + return "unknown" + + +def voice_fingerprint(voice_ref): + """Identity string for `voice_ref` (already resolved by + `resolve_voice_path` - a bare name for a standard voice, or an absolute + path for a custom one). + + Standard voices are fingerprinted by name alone - they're built into the + model and don't change. Custom voices are fingerprinted by *content*: + remixing and re-saving a `.pt` file under the same name (a real + workflow - see `VoiceMixingMixin.mix_voices`) changes what the voice + sounds like without changing its name, and a name-only key can't tell + the difference. The content hash is cached per-file-mtime so a batch run + doesn't re-read/re-hash the same file for every chunk. + """ + if not (os.path.isabs(voice_ref) and os.path.isfile(voice_ref)): + return voice_ref + + try: + mtime = os.path.getmtime(voice_ref) + except OSError: + return voice_ref + + cached = _voice_fingerprint_cache.get(voice_ref) + if cached is not None and cached[0] == mtime: + return cached[1] + + try: + with open(voice_ref, "rb") as f: + fp = hashlib.sha256(f.read()).hexdigest()[:16] + except OSError: + return voice_ref + + _voice_fingerprint_cache[voice_ref] = (mtime, fp) + return fp + + +def compute_cache_key(text, voice, eff_speed, lang_code, engine_id="kokoro", engine_version=None): + """The segment-cache hash: schema_version, engine identity/version, text, + voice (name + content fingerprint), effective speed, and language code. + + Takes exactly those five inputs, not a whole config dict - a config dict + also carries `out_dir`/`filename`/`format`/`normalize`/`trim_silence`/the + FX chain/`num_threads`/etc., none of which affect what gets cached (they + apply in `process_and_save` *after* cache read/generation, to the same + raw segment - that's the whole point of caching pre-FX audio). Keeping + those out of the signature, not just out of the hash, makes that + boundary the type checker/reader can see rather than something you have + to trust the implementation not to violate. `split_pattern` is excluded + for the same reason: only the text used to generate a segment determines + its content - splitting is an internal detail of how a chunk gets + divided for parallel processing. + """ + if engine_version is None: + engine_version = get_engine_version(engine_id) + + cache_key_parts = { + "schema_version": CACHE_SCHEMA_VERSION, + "engine_id": engine_id, + "engine_version": engine_version, + "text": text, + "voice": voice, + "voice_fingerprint": voice_fingerprint(voice), + "speed": eff_speed, + "lang_code": lang_code, + } + to_hash = "|".join(f"{k}={v}" for k, v in cache_key_parts.items()) + return hashlib.sha256(to_hash.encode("utf-8")).hexdigest() + class CachingMixin: def process_chunk_task(self, chunk_data, progress_callback): @@ -42,8 +141,8 @@ def process_chunk_task(self, chunk_data, progress_callback): cached_segments = [] if use_cache: - to_hash = f"{text}|{config['voice']}|{eff_speed}|{lang_code}" - cache_hash = hashlib.md5(to_hash.encode('utf-8')).hexdigest() + engine_id = config.get('engine_id', 'kokoro') + cache_hash = compute_cache_key(text, config['voice'], eff_speed, lang_code, engine_id) # Predict segments to verify cache integrity try: diff --git a/tests/test_caching.py b/tests/test_caching.py index dc69309..71e812d 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -1,9 +1,10 @@ -"""Tests for process_chunk_task's caching logic (kokoro_engine.py:568-705). +"""Tests for process_chunk_task's caching logic (kokoro_gui/engine/caching.py) +and the compute_cache_key helper it's built on +(PLAN_qt_and_engine_abstraction.md workstream 2). This is the ONLY test module allowed to pass caching=True - see tests/test_meta_caching_policy.py for the enforced guard. """ -import hashlib import os import numpy as np @@ -11,17 +12,23 @@ import soundfile as sf import kokoro_engine +from kokoro_gui.engine.caching import CACHE_SCHEMA_VERSION, compute_cache_key -def _hash(text, voice, speed, lang_code): - return hashlib.md5(f"{text}|{voice}|{speed}|{lang_code}".encode("utf-8")).hexdigest() +def _hash(text, config, eff_speed=None, lang_code=None, engine_id="kokoro"): + return compute_cache_key( + text, config["voice"], + eff_speed if eff_speed is not None else config["speed"], + lang_code if lang_code is not None else config["lang_code"], + engine_id, + ) def test_cache_miss_writes_raw_pre_fx_audio(engine, fake_pipeline, isolated_dirs, make_config): config = make_config(caching=True, volume=0.5) results = engine.process_chunk_task((0, "Hello world.", config), None) - h = _hash("Hello world.", config["voice"], config["speed"], config["lang_code"]) + h = _hash("Hello world.", config) cache_file = isolated_dirs.cache_dir / f"{h}_0.wav" assert cache_file.exists() @@ -36,7 +43,7 @@ def test_cache_miss_writes_raw_pre_fx_audio(engine, fake_pipeline, isolated_dirs def test_cache_hit_skips_pipeline_call(engine, isolated_dirs, make_config, monkeypatch): config = make_config(caching=True) text = "Hello world." - h = _hash(text, config["voice"], config["speed"], config["lang_code"]) + h = _hash(text, config) audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1200) / 24000)).astype(np.float32) sf.write(str(isolated_dirs.cache_dir / f"{h}_0.wav"), audio, 24000) @@ -74,7 +81,7 @@ def _boom(lang_code="a"): def test_cache_partial_files_missing_forces_regeneration(engine, fake_pipeline, isolated_dirs, make_config): text = "Seg one.\n\nSeg two." config = make_config(caching=True) - h = _hash(text, config["voice"], config["speed"], config["lang_code"]) + h = _hash(text, config) # Only the first of the two expected segments is cached. audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1200) / 24000)).astype(np.float32) @@ -109,3 +116,112 @@ def test_speed_affects_cache_key(engine, fake_pipeline, isolated_dirs, make_conf cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) assert len(cache_files) == 2 + + +# --- Workstream 2 hardening: compute_cache_key in isolation ----------------- + +def test_compute_cache_key_is_deterministic_and_sha256(): + h1 = compute_cache_key("Hello.", "af_heart", 1.0, "a") + h2 = compute_cache_key("Hello.", "af_heart", 1.0, "a") + + assert h1 == h2 + assert len(h1) == 64 # sha256 hex digest, not md5's 32 + assert all(c in "0123456789abcdef" for c in h1) + + +def test_compute_cache_key_takes_only_what_it_needs(): + # Not a whole config dict - just the five inputs that actually determine + # a segment's content. out_dir/filename/format/normalize/trim/the FX + # chain/num_threads/etc. never even get a chance to leak into the hash, + # because the function has nowhere to read them from. + import inspect + + params = list(inspect.signature(compute_cache_key).parameters) + assert params == ["text", "voice", "eff_speed", "lang_code", "engine_id", "engine_version"] + + +def test_compute_cache_key_differs_by_each_input(): + base = compute_cache_key("Hello.", "af_heart", 1.0, "a") + + assert compute_cache_key("Goodbye.", "af_heart", 1.0, "a") != base + assert compute_cache_key("Hello.", "af_bella", 1.0, "a") != base + assert compute_cache_key("Hello.", "af_heart", 1.5, "a") != base + assert compute_cache_key("Hello.", "af_heart", 1.0, "b") != base + assert compute_cache_key("Hello.", "af_heart", 1.0, "a", engine_id="dummy") != base + assert compute_cache_key("Hello.", "af_heart", 1.0, "a", engine_version="1.2.3") != \ + compute_cache_key("Hello.", "af_heart", 1.0, "a", engine_version="1.2.4") + + +# --- Workstream 2 hardening: cache invalidation through process_chunk_task -- + +def test_custom_voice_content_change_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config): + """Remixing and re-saving a custom voice under the same name (a real + workflow - VoiceMixingMixin.mix_voices) must invalidate old cache + entries for that name, since the name alone no longer identifies what + was actually generated.""" + voice_path = isolated_dirs.custom_voices / "MyMix.pt" + voice_path.write_bytes(b"tensor-content-v1") + resolved = engine.resolve_voice_path("MyMix") + + text = "Hello world." + config = make_config(caching=True, voice=resolved) + + engine.process_chunk_task((0, text, config), None) + files_v1 = set(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(files_v1) == 1 + + # Re-save under the same path/name with different content - and force a + # different mtime so the in-memory fingerprint cache can't coast on a + # coarse filesystem timestamp resolution masking the change. + voice_path.write_bytes(b"tensor-content-v2-longer-and-different") + future = os.path.getmtime(voice_path) + 5 + os.utime(voice_path, (future, future)) + + engine.process_chunk_task((0, text, config), None) + files_v2 = set(isolated_dirs.cache_dir.glob("*_0.wav")) + + assert len(files_v2) == 2 + assert files_v1 < files_v2 # old entry untouched, a new one was added + + +def test_engine_id_change_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config): + text = "Hello world." + config_a = make_config(caching=True, engine_id="kokoro") + config_b = make_config(caching=True, engine_id="some-other-engine") + + engine.process_chunk_task((0, text, config_a), None) + engine.process_chunk_task((0, text, config_b), None) + + cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(cache_files) == 2 + + +def test_engine_version_change_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config, monkeypatch): + import kokoro_gui.engine.caching as caching_mod + + text = "Hello world." + config = make_config(caching=True) + + monkeypatch.setattr(caching_mod, "get_engine_version", lambda engine_id="kokoro": "1.0.0") + engine.process_chunk_task((0, text, config), None) + + monkeypatch.setattr(caching_mod, "get_engine_version", lambda engine_id="kokoro": "2.0.0") + engine.process_chunk_task((0, text, config), None) + + cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(cache_files) == 2 + + +def test_schema_version_bump_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config, monkeypatch): + import kokoro_gui.engine.caching as caching_mod + + text = "Hello world." + config = make_config(caching=True) + + engine.process_chunk_task((0, text, config), None) + + monkeypatch.setattr(caching_mod, "CACHE_SCHEMA_VERSION", CACHE_SCHEMA_VERSION + 1) + engine.process_chunk_task((0, text, config), None) + + cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(cache_files) == 2 diff --git a/tests/test_gui_config_assembly.py b/tests/test_gui_config_assembly.py index 398c37c..b3d606d 100644 --- a/tests/test_gui_config_assembly.py +++ b/tests/test_gui_config_assembly.py @@ -11,7 +11,7 @@ def _set_text(app, text): BASE_KEYS = { - "lang_code", "voice", "speed", "split_pattern", "filename", "format", + "engine_id", "lang_code", "voice", "speed", "split_pattern", "filename", "format", "out_dir", "separate", "combine", "export_subtitles", "caching", "time_id", "num_threads", "volume", "pitch", "normalize", "trim_silence", "lexicon", From 171bc1590b56e2319ccc7bdb0b43ee5514025194 Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 16:15:16 -0600 Subject: [PATCH 05/44] What was built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parallel Qt frontend under kokoro_gui/qt/ that talks to the exact same KokoroEngine/backend registry as the Tk app — zero edits to gui.py, kokoro_gui/ui/*.py, or kokoro_gui/engine*/: kokoro_gui/qt/spec.py — pure-data constants mirroring Tk's field lists (BASE_KEYS, the 43-key FX preset schema, voice/language tables), cross-checked against Tk at runtime by tests rather than shared code kokoro_gui/qt/app.py — QtTTSApp(QMainWindow): engine/backend construction, config assembly, preview/start/cancel lifecycle, autosave to config_qt.json kokoro_gui/qt/docks/ — Generation (schema-driven via SchemaFormWidget), FX, Mixing (capability-gated), Lexicon docks kokoro_gui/qt/signals.py — cross-thread callback bridge replacing Tk's self.after(0, ...) pattern main_qt.py — entry point (python main_qt.py, ships alongside python main.py) One deliberate improvement over Tk: engine switching now actually rebuilds the Generation dock's schema fields and toggles the Mixing dock — fixing the gap gui.py's own switch_engine docstring names Presets (presets/*.json, presets/fx/*.json) are shared between both frontends by design; app settings are not (separate config_qt.json). Testing tests/gui_qt/ — 31 new tests (self-skip via pytest.importorskip if PySide6 isn't installed), including a test that captures Tk's live assembled config dict and asserts it's identical to Qt's, and cross-frontend preset load/save tests. PySide6/pytest-qt are optional installs (requirements-qt.txt/requirements-qt-test.txt), not forced on Tk-only users. --- .github/workflows/tests.yml | 15 +- README.md | 1 + config_qt.json | 67 ++++ kokoro_gui/qt/__init__.py | 10 + kokoro_gui/qt/app.py | 442 ++++++++++++++++++++++++ kokoro_gui/qt/docks/__init__.py | 6 + kokoro_gui/qt/docks/fx_dock.py | 198 +++++++++++ kokoro_gui/qt/docks/generation_dock.py | 374 ++++++++++++++++++++ kokoro_gui/qt/docks/lexicon_dock.py | 88 +++++ kokoro_gui/qt/docks/mixing_dock.py | 273 +++++++++++++++ kokoro_gui/qt/schema_form.py | 187 ++++++++++ kokoro_gui/qt/settings.py | 68 ++++ kokoro_gui/qt/signals.py | 47 +++ kokoro_gui/qt/spec.py | 232 +++++++++++++ main_qt.py | 16 + requirements-qt-test.txt | 1 + requirements-qt.txt | 1 + tests/gui_qt/__init__.py | 0 tests/gui_qt/conftest.py | 63 ++++ tests/gui_qt/test_qt_config_assembly.py | 66 ++++ tests/gui_qt/test_qt_engine_backend.py | 53 +++ tests/gui_qt/test_qt_lexicon.py | 44 +++ tests/gui_qt/test_qt_presets.py | 107 ++++++ tests/gui_qt/test_qt_settings.py | 68 ++++ 24 files changed, 2426 insertions(+), 1 deletion(-) create mode 100644 config_qt.json create mode 100644 kokoro_gui/qt/__init__.py create mode 100644 kokoro_gui/qt/app.py create mode 100644 kokoro_gui/qt/docks/__init__.py create mode 100644 kokoro_gui/qt/docks/fx_dock.py create mode 100644 kokoro_gui/qt/docks/generation_dock.py create mode 100644 kokoro_gui/qt/docks/lexicon_dock.py create mode 100644 kokoro_gui/qt/docks/mixing_dock.py create mode 100644 kokoro_gui/qt/schema_form.py create mode 100644 kokoro_gui/qt/settings.py create mode 100644 kokoro_gui/qt/signals.py create mode 100644 kokoro_gui/qt/spec.py create mode 100644 main_qt.py create mode 100644 requirements-qt-test.txt create mode 100644 requirements-qt.txt create mode 100644 tests/gui_qt/__init__.py create mode 100644 tests/gui_qt/conftest.py create mode 100644 tests/gui_qt/test_qt_config_assembly.py create mode 100644 tests/gui_qt/test_qt_engine_backend.py create mode 100644 tests/gui_qt/test_qt_lexicon.py create mode 100644 tests/gui_qt/test_qt_presets.py create mode 100644 tests/gui_qt/test_qt_settings.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c984fc6..8c97f7e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,17 +35,30 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-test.txt + pip install -r requirements-qt.txt + pip install -r requirements-qt-test.txt + # requirements-qt*.txt (PySide6/pytest-qt) are optional for a local + # dev/user who only wants the Tk app (tests/gui_qt/conftest.py + # self-skips via pytest.importorskip when they're absent), but CI + # always installs them so the Qt suite (workstream 3a of + # PLAN_qt_and_engine_abstraction.md) actually runs on every PR. - name: Run fast test suite (Linux) if: runner.os == 'Linux' + env: + QT_QPA_PLATFORM: offscreen run: xvfb-run -a pytest # Runs the mocked-pipeline suite only (pytest.ini already sets # `-m "not integration"` by default). No eSpeak NG or model # download needed. The real-synthesis integration suite # (`pytest -m integration tests/integration`) is intentionally # left out of CI - it's slow and pulls model weights. Wrapped in - # xvfb-run so the Tk-based GUI tests have a display to attach to. + # xvfb-run so the Tk-based GUI tests have a display to attach to; + # QT_QPA_PLATFORM=offscreen makes the Qt suite not need one (Qt's + # offscreen platform plugin works with or without Xvfb present). - name: Run fast test suite (Windows) if: runner.os != 'Linux' + env: + QT_QPA_PLATFORM: offscreen run: pytest diff --git a/README.md b/README.md index bacd301..bce72a4 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e ## New in Beta 3.2.0 +- **Experimental Qt frontend:** `python main_qt.py` launches a PySide6-based dockable-panel shell alongside the existing CustomTkinter app (`python main.py`/`run.bat`, still the default). Optional install: `pip install -r requirements-qt.txt`. Presets (`presets/*.json`, `presets/fx/*.json`) are shared between both frontends; app settings are not (`config_qt.json` vs. `config.json`). See [PLAN_qt_and_engine_abstraction.md](PLAN_qt_and_engine_abstraction.md) for the roadmap this is part of. - **Modular codebase:** `gui.py` and `kokoro_engine.py` are now split into a `kokoro_gui/engine/` and `kokoro_gui/ui/` package by feature area (text extraction, caching, lexicon, presets, voice mixing, per-tab UI builders), making the codebase easier to navigate and extend. No user-facing behavior change. - **Cross-Platform Audio Playback:** Preview and JIT playback now go through `sounddevice`/`soundfile` instead of the Windows-only `winsound` module, removing a hard Windows dependency from `kokoro_engine.py`/`gui.py`. diff --git a/config_qt.json b/config_qt.json new file mode 100644 index 0000000..8e4e027 --- /dev/null +++ b/config_qt.json @@ -0,0 +1,67 @@ +{ + "lang_code": "a", + "voice": "af_heart", + "filename": "output", + "format": "mp3", + "out_dir": "audio_output", + "speed": 1.0, + "volume": 1.0, + "pitch": 0.0, + "num_threads": 1, + "split_pattern": "\\n+", + "separate": true, + "combine": true, + "export_subtitles": false, + "caching": true, + "jit_enabled": false, + "normalize": false, + "trim": false, + "apply_fx": true, + "reverb_enabled": false, + "reverb_room_size": 0.5, + "reverb_wet_level": 0.3, + "reverb_damping": 0.5, + "reverb_dry_level": 1.0, + "reverb_width": 1.0, + "eq_bass": 0.0, + "eq_treble": 0.0, + "comp_enabled": false, + "comp_threshold": -20.0, + "comp_ratio": 4.0, + "comp_attack": 1.0, + "comp_release": 100.0, + "distortion_enabled": false, + "distortion_drive": 25.0, + "chorus_enabled": false, + "chorus_rate": 1.0, + "chorus_depth": 0.25, + "chorus_mix": 0.5, + "phaser_enabled": false, + "phaser_rate": 1.0, + "phaser_depth": 0.5, + "phaser_mix": 0.5, + "clipping_enabled": false, + "clipping_thresh": -6.0, + "bitcrush_enabled": false, + "bitcrush_depth": 8.0, + "gsm_enabled": false, + "highpass_enabled": false, + "highpass_freq": 50.0, + "lowpass_enabled": false, + "lowpass_freq": 9240.0, + "delay_enabled": false, + "delay_time": 0.5, + "delay_feedback": 0.0, + "delay_mix": 0.5, + "pitch_shift_enabled": false, + "pitch_shift_semitones": 0.0, + "limiter_enabled": false, + "limiter_threshold": -1.0, + "limiter_release": 100.0, + "gain_enabled": false, + "gain_db": 0.0, + "engine_id": "kokoro", + "lexicon": {}, + "dock_state": "AAAA/wAAAAD9AAAAAQAAAAIAAAeAAAADJfwBAAAAAvsAAAAeAGQAbwBjAGsAXwBnAGUAbgBlAHIAYQB0AGkAbwBuAQAAAAAAAASXAAAAUgD////8AAAEmwAAAuUAAAF6AP////oAAAAAAgAAAAP7AAAAFgBkAG8AYwBrAF8AbQBpAHgAaQBuAGcBAAAAAP////8AAAHqAP////sAAAAOAGQAbwBjAGsAXwBmAHgBAAAAAP////8AAACEAP////sAAAAYAGQAbwBjAGsAXwBsAGUAeABpAGMAbwBuAQAAAB4AAAMlAAAAmgD///8AAAeAAAAAqgAAAAQAAAAEAAAACAAAAAj8AAAAAQAAAAIAAAAB/////wEAAAAA/////wAAAAAAAAAA", + "geometry": "AdnQywADAAAAAAAA////+AAAB38AAAQHAAAB/QAAAHcAAAZIAAADlgAAAAACAAAAB4AAAAAAAAAAFwAAB38AAAQH" +} \ No newline at end of file diff --git a/kokoro_gui/qt/__init__.py b/kokoro_gui/qt/__init__.py new file mode 100644 index 0000000..6a4cdbe --- /dev/null +++ b/kokoro_gui/qt/__init__.py @@ -0,0 +1,10 @@ +"""PySide6 (Qt) frontend — Workstream 3a of PLAN_qt_and_engine_abstraction.md. + +This package is an alternative presentation layer that talks to the exact same +`KokoroEngine` / `kokoro_gui.engines` backend registry the CustomTkinter `gui.py` +app uses. Nothing here imports from `gui.py` or `kokoro_gui/ui/*.py` (the Tk +tab-builder mixins), and nothing in those Tk files imports from here — the two +frontends are independent and ship side by side (`python main.py` vs +`python main_qt.py`) until this one reaches parity, per the plan's own risk +mitigation ("no forced cutover"). +""" diff --git a/kokoro_gui/qt/app.py b/kokoro_gui/qt/app.py new file mode 100644 index 0000000..971f020 --- /dev/null +++ b/kokoro_gui/qt/app.py @@ -0,0 +1,442 @@ +"""QtTTSApp: the PySide6 shell (workstream 3a of PLAN_qt_and_engine_abstraction.md). + +Mirrors gui.py's `TTSApp` behavior 1:1 (engine/backend construction, config +assembly, preview/start/cancel lifecycle, autosave) but as a `QMainWindow` + +`QDockWidget` shell instead of a `CTkTabview`. Talks to `KokoroEngine` and the +`kokoro_gui.engines` registry through the exact same interface gui.py uses - +nothing here imports from gui.py or kokoro_gui/ui/*.py, and nothing there +imports from here (see this package's `__init__.py`). + +`CONFIG_FILE`/`PRESETS_DIR`/`FX_PRESETS_DIR` are defined here, at module +level, before the `kokoro_gui.qt.docks` import below - the dock modules do +`import kokoro_gui.qt.app as qt_app_module` and read `qt_app_module.PRESETS_DIR` +etc. qualified at call time (same convention kokoro_gui/ui/*.py uses for +`gui.PRESETS_DIR`), which makes this a circular import; defining these names +before triggering that import keeps it safe (Python binds the dock modules' +`qt_app_module` name to this already-partially-initialized module, and by the +time any dock function actually reads `qt_app_module.PRESETS_DIR` the whole +package has finished importing anyway). +""" +from __future__ import annotations + +import os +import tempfile +import time + +import playback +from PySide6.QtCore import QTimer, Qt, Signal +from PySide6.QtWidgets import ( + QCheckBox, QDialog, QLabel, QMainWindow, QMessageBox, + QProgressBar, QPushButton, QVBoxLayout, QWidget, +) + +from kokoro_engine import KokoroEngine +from kokoro_gui.engines import registry as engine_registry +from kokoro_gui.qt import spec +from kokoro_gui.qt import settings as qt_settings +from kokoro_gui.qt.signals import EngineSignalBridge, wire_engine + +CONFIG_FILE = "config_qt.json" +PRESETS_DIR = "presets" +FX_PRESETS_DIR = os.path.join(PRESETS_DIR, "fx") + +from kokoro_gui.qt.docks import FXDock, GenerationDock, LexiconDock, MixingDock # noqa: E402 + + +class QtTTSApp(QMainWindow): + previewFinished = Signal(bool, str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Kokoro TTS (Qt)") + self.resize(1100, 800) + + os.makedirs(PRESETS_DIR, exist_ok=True) + os.makedirs(FX_PRESETS_DIR, exist_ok=True) + + self.settings = qt_settings.load_settings(CONFIG_FILE) + self.jit_enabled = self.settings.get("jit_enabled", False) + self.timecode_format = "%Y%m%d%H%M%S" + + self._save_timer = QTimer(self) + self._save_timer.setSingleShot(True) + self._save_timer.timeout.connect(self.save_settings) + + self.mixing_dock: MixingDock | None = None + self.generation_dock: GenerationDock | None = None + + # --- Engine / backend (mirrors gui.py:40-50) --- + self.engine = KokoroEngine() + self.bridge = EngineSignalBridge() + wire_engine(self.engine, self.bridge) + self.backend = engine_registry.get_engine("kokoro", engine=self.engine) + self._connect_bridge(self.bridge) + + self.previewFinished.connect(self._on_preview_finished) + + self._build_toolbar() + self._build_docks() + self._build_action_bar() + + qt_settings.restore_window_state(self, self.settings) + + self.status_label.setText("Initializing engine...") + self.engine.worker.run_coro(self.engine.init_pipeline_async(self.settings.get("lang_code", "a"))) + + # --- construction ----------------------------------------------------- + + def _connect_bridge(self, bridge: EngineSignalBridge) -> None: + bridge.status.connect(self.on_engine_status) + bridge.progress.connect(self.on_engine_progress) + bridge.finished.connect(self.on_engine_finish) + + def _disconnect_bridge(self, bridge: EngineSignalBridge) -> None: + try: + bridge.status.disconnect(self.on_engine_status) + bridge.progress.disconnect(self.on_engine_progress) + bridge.finished.disconnect(self.on_engine_finish) + except Exception: + pass + + def _build_toolbar(self) -> None: + toolbar = self.addToolBar("Main") + toolbar.setMovable(False) + toolbar.addWidget(QLabel(" Engine: ")) + + self._engine_ids_by_display_name = { + engine_registry.get_display_name(eid): eid for eid in engine_registry.list_engines() + } + from PySide6.QtWidgets import QComboBox + self.engine_picker = QComboBox() + self.engine_picker.addItems(list(self._engine_ids_by_display_name.keys())) + self.engine_picker.setCurrentText(engine_registry.get_display_name(self.backend.id)) + self.engine_picker.currentTextChanged.connect(self.on_engine_picker_change) + toolbar.addWidget(self.engine_picker) + + settings_btn = QPushButton("⚙ Settings") + settings_btn.clicked.connect(self.open_settings_dialog) + toolbar.addWidget(settings_btn) + + def _build_docks(self) -> None: + self.generation_dock = GenerationDock(self) + self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.generation_dock) + + self.fx_dock = FXDock(self) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.fx_dock) + + self.lexicon_dock = LexiconDock(self) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.lexicon_dock) + self.tabifyDockWidget(self.fx_dock, self.lexicon_dock) + + self._sync_mixing_dock() + + def _build_action_bar(self) -> None: + central = QWidget() + layout = QVBoxLayout(central) + + self.status_label = QLabel("Ready") + self.status_label.setStyleSheet("color: gray;") + layout.addWidget(self.status_label) + + self.detail_label = QLabel("...") + self.detail_label.setStyleSheet("color: gray;") + layout.addWidget(self.detail_label) + + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + layout.addWidget(self.progress_bar) + + self.info_label = QLabel("Time: 00:00 / ETA: --:-- | 0%") + layout.addWidget(self.info_label) + + from PySide6.QtWidgets import QHBoxLayout + btn_row = QHBoxLayout() + self.preview_btn = QPushButton("Preview Audio") + self.preview_btn.clicked.connect(self.preview_conversion) + self.start_btn = QPushButton("Start Real-time JIT" if self.jit_enabled else "Start Generation") + self.start_btn.clicked.connect(self.start_conversion) + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.cancel_conversion) + self.cancel_btn.setEnabled(False) + btn_row.addWidget(self.preview_btn) + btn_row.addWidget(self.start_btn) + btn_row.addWidget(self.cancel_btn) + layout.addLayout(btn_row) + + layout.addStretch(1) + self.setCentralWidget(central) + + # --- voice listing (mirrors gui.py:195-203, hardcoded relative path) -- + + def get_all_voices(self, lang_code: str | None = None) -> list: + if lang_code is None: + lang_code = self.settings.get("lang_code", "a") + standard = spec.VOICE_DB.get(lang_code, []) + custom = [] + if os.path.exists("custom_voices"): + custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] + return sorted(standard + custom) + + # --- settings persistence (mirrors gui.py's schedule_save/save_settings) - + + def schedule_save(self) -> None: + self._save_timer.start(1000) + + def save_settings(self) -> None: + self._save_timer.stop() + + if self.generation_dock is not None: + gen_state = self.generation_dock.get_state() + self.settings["lang_code"] = gen_state["lang_code"] + self.settings["voice"] = gen_state["voice"] + self.settings["filename"] = gen_state["filename"] + self.settings["format"] = gen_state["format"] + self.settings["out_dir"] = gen_state["out_dir"] + self.settings["speed"] = gen_state["speed"] + self.settings["volume"] = gen_state["volume"] + self.settings["pitch"] = gen_state["pitch"] + self.settings["num_threads"] = gen_state["num_threads"] + self.settings["split_pattern"] = gen_state["split_pattern"] + self.settings["separate"] = gen_state["separate"] + self.settings["combine"] = gen_state["combine"] + self.settings["export_subtitles"] = gen_state["export_subtitles"] + self.settings["caching"] = gen_state["caching"] + self.settings["normalize"] = gen_state["normalize"] + self.settings["trim"] = gen_state["trim_silence"] + self.settings["apply_fx"] = self.generation_dock.apply_fx_enabled() + self.settings["jit_enabled"] = self.jit_enabled + self.settings["engine_id"] = self.backend.id + self.settings.update(self.fx_dock.get_state()) + qt_settings.save_window_state(self, self.settings) + + qt_settings.save_settings(CONFIG_FILE, self.settings) + + # --- config assembly (mirrors gui.py:828-895 / 714-767) ---------------- + + def _assemble_config(self) -> dict: + gen_state = self.generation_dock.get_state() + config = { + "engine_id": self.backend.id, + "lang_code": gen_state["lang_code"], + "voice": gen_state["voice"], + "speed": gen_state["speed"], + "split_pattern": gen_state["split_pattern"], + "filename": gen_state["filename"], + "format": gen_state["format"], + "out_dir": gen_state["out_dir"], + "separate": gen_state["separate"], + "combine": gen_state["combine"], + "export_subtitles": gen_state["export_subtitles"], + "caching": gen_state["caching"], + "time_id": time.strftime(self.timecode_format), + "num_threads": gen_state["num_threads"], + "volume": gen_state["volume"], + "pitch": gen_state["pitch"], + "normalize": gen_state["normalize"], + "trim_silence": gen_state["trim_silence"], + "lexicon": self.settings.get("lexicon", {}), + } + if self.generation_dock.apply_fx_enabled(): + config.update(self.fx_dock.get_state()) + return config + + # --- engine picker / switch (mirrors gui.py:524-561) -------------------- + + def on_engine_picker_change(self, display_name: str) -> None: + engine_id = self._engine_ids_by_display_name.get(display_name) + if engine_id is None or engine_id == self.backend.id: + return + self.switch_engine(engine_id) + + def switch_engine(self, engine_id: str) -> None: + if self.cancel_btn.isEnabled(): + QMessageBox.warning(self, "Busy", "Cancel the current job before switching engines.") + self.engine_picker.setCurrentText(engine_registry.get_display_name(self.backend.id)) + return + + old_engine = self.engine + self._disconnect_bridge(self.bridge) + + new_backend = engine_registry.get_engine(engine_id) + new_engine = new_backend.engine + new_bridge = EngineSignalBridge() + wire_engine(new_engine, new_bridge) + self._connect_bridge(new_bridge) + + self.engine = new_engine + self.backend = new_backend + self.bridge = new_bridge + + # Rebuild the Generation dock's schema-driven fields for the new + # backend and show/hide the Mixing dock - this is the fix for + # gui.py's own switch_engine docstring gap (gui.py:530-538). + self.generation_dock.rebuild_schema_form() + self._sync_mixing_dock() + + try: + old_engine.worker.stop() + except Exception: + pass + + self.status_label.setText(f"Switched engine to {new_backend.display_name}. Initializing...") + self.status_label.setStyleSheet("color: gray;") + self.engine.worker.run_coro(self.engine.init_pipeline_async(self.settings.get("lang_code", "a"))) + + def _sync_mixing_dock(self) -> None: + wants = self.backend.capabilities.supports_voice_mixing + if wants and self.mixing_dock is None: + self.mixing_dock = MixingDock(self) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.mixing_dock) + self.tabifyDockWidget(self.fx_dock, self.mixing_dock) + elif not wants and self.mixing_dock is not None: + self.removeDockWidget(self.mixing_dock) + self.mixing_dock.deleteLater() + self.mixing_dock = None + + # --- settings dialog (mirrors gui.py:563-618, minus CTk-only appearance/scaling) - + + def open_settings_dialog(self) -> None: + dialog = QDialog(self) + dialog.setWindowTitle("Settings") + layout = QVBoxLayout(dialog) + jit_check = QCheckBox("Enable JIT Generation (Streaming)") + jit_check.setChecked(self.jit_enabled) + layout.addWidget(jit_check) + close_btn = QPushButton("Close") + close_btn.clicked.connect(dialog.accept) + layout.addWidget(close_btn) + dialog.exec() + + self.jit_enabled = jit_check.isChecked() + self.start_btn.setText("Start Real-time JIT" if self.jit_enabled else "Start Generation") + self.save_settings() + + # --- engine callbacks (queued automatically across threads - see signals.py) - + + def on_engine_status(self, msg: str, is_error: bool) -> None: + self.status_label.setText(msg.split("\n")[0]) + self.status_label.setStyleSheet(f"color: {'#ff5555' if is_error else 'gray'};") + if is_error and "pip install" in msg: + QMessageBox.critical(self, "Missing Dependencies", msg) + + def on_engine_progress(self, percent: float, elapsed: float, eta: str, detail: str) -> None: + self.progress_bar.setValue(int(percent)) + elapsed_str = time.strftime("%M:%S", time.gmtime(elapsed)) + self.info_label.setText(f"Time: {elapsed_str} / ETA: {eta} | {int(percent)}%") + self.detail_label.setText(detail) + + def on_engine_finish(self) -> None: + self.set_ui_state(False) + + def set_ui_state(self, is_running: bool) -> None: + self.start_btn.setEnabled(not is_running) + self.preview_btn.setEnabled(not is_running) + self.cancel_btn.setEnabled(is_running) + self.generation_dock.threads_spin.setEnabled(not is_running) + self.generation_dock.volume_spin.setEnabled(not is_running) + self.generation_dock.pitch_spin.setEnabled(not is_running) + if not is_running: + self.progress_bar.setValue(0 if self.engine.cancel_event.is_set() else 100) + + # --- preview (mirrors gui.py:684-791) ----------------------------------- + + def preview_conversion(self) -> None: + if not self.engine.pipeline: + QMessageBox.information(self, "Wait", "Engine is initializing... please wait 2 seconds and try again.") + return + + text_data = self.generation_dock.get_text() + if not text_data: + text_data = ("This is a sample audio preview using the Koh-koh-ro Tea-Tea-S engine. " + "It demonstrates the voice quality and speed settings.") + preview_text = text_data[:1000] + + state = self.generation_dock.get_state() + extra_config = { + "volume": state["volume"], + "pitch": state["pitch"], + "normalize": state["normalize"], + "trim_silence": state["trim_silence"], + "lexicon": self.settings.get("lexicon", {}), + } + if self.generation_dock.apply_fx_enabled(): + extra_config.update(self.fx_dock.get_state()) + + tmp_path = os.path.join(tempfile.gettempdir(), "kokoro_preview.wav") + self.status_label.setText("Generating preview...") + self.status_label.setStyleSheet("color: blue;") + + def _done(future): + try: + success = future.result() + payload = tmp_path if success else "Preview failed." + except Exception as e: + success = False + payload = f"Preview error: {e}" + self.previewFinished.emit(success, payload) + + future = self.engine.worker.run_coro( + self.engine.generate_preview(preview_text, state["voice"], state["speed"], tmp_path, + extra_config, lang_code=state["lang_code"]) + ) + future.add_done_callback(_done) + + def _on_preview_finished(self, success: bool, payload: str) -> None: + if success: + self.status_label.setText("Playing preview...") + self.status_label.setStyleSheet("color: green;") + playback.play(payload) + QTimer.singleShot(3000, lambda: (self.status_label.setText("Ready"), self.status_label.setStyleSheet("color: gray;"))) + else: + self.status_label.setText(payload) + self.status_label.setStyleSheet("color: red;") + + # --- start/cancel (mirrors gui.py:793-908) ------------------------------ + + def start_conversion(self) -> None: + threads = self.generation_dock.threads_spin.value() + if threads < 1: + self.generation_dock.threads_spin.setValue(1) + + if self.generation_dock.using_file_tab(): + fpath = self.generation_dock.get_file_path() + if not os.path.exists(fpath): + QMessageBox.critical(self, "Error", "File not found.") + return + try: + text_data = self.engine.extract_text_from_file(fpath) + except Exception as e: + QMessageBox.critical(self, "Error", f"Read failed: {e}") + return + else: + text_data = self.generation_dock.get_text() + + if not text_data: + QMessageBox.warning(self, "Empty", "No text to process.") + return + + if not self.engine.pipeline: + QMessageBox.information(self, "Wait", "Engine is initializing... please wait 2 seconds and try again.") + return + + config = self._assemble_config() + + self.set_ui_state(True) + self.progress_bar.setValue(0) + + if self.jit_enabled: + self.engine.start_jit_conversion(text_data, config) + else: + self.engine.start_conversion(text_data, config) + + def cancel_conversion(self) -> None: + self.engine.cancel() + self.status_label.setText("Cancelling... waiting for workers...") + self.status_label.setStyleSheet("color: orange;") + + # --- lifecycle ----------------------------------------------------------- + + def closeEvent(self, event) -> None: + self.save_settings() + super().closeEvent(event) diff --git a/kokoro_gui/qt/docks/__init__.py b/kokoro_gui/qt/docks/__init__.py new file mode 100644 index 0000000..6c82377 --- /dev/null +++ b/kokoro_gui/qt/docks/__init__.py @@ -0,0 +1,6 @@ +from .generation_dock import GenerationDock +from .fx_dock import FXDock +from .mixing_dock import MixingDock +from .lexicon_dock import LexiconDock + +__all__ = ["GenerationDock", "FXDock", "MixingDock", "LexiconDock"] diff --git a/kokoro_gui/qt/docks/fx_dock.py b/kokoro_gui/qt/docks/fx_dock.py new file mode 100644 index 0000000..2d5b8ca --- /dev/null +++ b/kokoro_gui/qt/docks/fx_dock.py @@ -0,0 +1,198 @@ +"""Audio FX dock: builds the FX controls from `kokoro_gui.qt.spec.FX_FIELD_SPECS` +and loads/saves FX presets under `presets/fx/` (shared with the Tk frontend). +Mirrors kokoro_gui/ui/fx_tab.py. + +Seven FX_PRESET_KEYS fields have no widget here (see spec.py's docstring) - +their values are tracked in `self._hidden_values` and only ever change via +preset load, exactly matching the Tk frontend's real behavior (those fields +have no `_create_slider` call in fx_tab.py either). +""" +from __future__ import annotations + +import json +import os +import re + +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDockWidget, QDoubleSpinBox, QFormLayout, + QGroupBox, QHBoxLayout, QInputDialog, QLabel, QMessageBox, QPushButton, + QScrollArea, QVBoxLayout, QWidget, +) + +import kokoro_gui.qt.app as qt_app_module +from kokoro_gui.qt import spec + + +class FXDock(QDockWidget): + def __init__(self, app, parent=None): + super().__init__("Audio FX", parent) + self.setObjectName("dock_fx") + self.app = app + + self._value_widgets: dict[str, QDoubleSpinBox] = {} + self._enabled_checks: dict[str, QCheckBox] = {} + self._hidden_values: dict[str, float] = { + k: spec.SETTINGS_DEFAULTS[k] for k in spec.FX_KEYS_WITHOUT_WIDGET + } + + content = QWidget() + outer = QVBoxLayout(content) + + preset_row = QHBoxLayout() + self.preset_combo = QComboBox() + self.preset_combo.currentTextChanged.connect(self._on_preset_selected) + save_btn = QPushButton("Save FX Preset...") + save_btn.clicked.connect(self._save_preset_dialog) + refresh_btn = QPushButton("Refresh") + refresh_btn.clicked.connect(self.refresh_presets) + preset_row.addWidget(QLabel("FX Preset:")) + preset_row.addWidget(self.preset_combo, 1) + preset_row.addWidget(save_btn) + preset_row.addWidget(refresh_btn) + outer.addLayout(preset_row) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + inner = QWidget() + inner_layout = QVBoxLayout(inner) + scroll.setWidget(inner) + outer.addWidget(scroll) + + specs_by_group: dict[str, list[spec.FXSliderSpec]] = {g: [] for g in spec.FX_GROUP_ORDER} + for s in spec.FX_FIELD_SPECS: + specs_by_group[s.group].append(s) + + for group in spec.FX_GROUP_ORDER: + box = QGroupBox(group) + box_layout = QVBoxLayout(box) + self._build_group(box_layout, specs_by_group[group]) + for key, label, toggle_group in spec.FX_STANDALONE_TOGGLES: + if toggle_group == group: + check = QCheckBox(label) + check.setChecked(self.app.settings.get(key, False)) + check.toggled.connect(lambda _v: self.app.schedule_save()) + self._enabled_checks[key] = check + box_layout.addWidget(check) + inner_layout.addWidget(box) + + inner_layout.addStretch(1) + self.setWidget(content) + + self.set_values(self.app.settings) + self.refresh_presets() + + def _build_group(self, box_layout: QVBoxLayout, specs: list) -> None: + sections: dict[str, list] = {} + section_order: list[str] = [] + for s in specs: + if s.section not in sections: + sections[s.section] = [] + section_order.append(s.section) + sections[s.section].append(s) + + for section in section_order: + section_specs = sections[section] + enabled_key = section_specs[0].enabled_key + if enabled_key: + header = QCheckBox(section) + header.setChecked(self.app.settings.get(enabled_key, False)) + header.toggled.connect(lambda _v: self.app.schedule_save()) + self._enabled_checks[enabled_key] = header + box_layout.addWidget(header) + else: + box_layout.addWidget(QLabel(f"{section}")) + + form = QFormLayout() + for s in section_specs: + spin = QDoubleSpinBox() + spin.setRange(s.minimum, s.maximum) + span = s.maximum - s.minimum + spin.setSingleStep(span / s.steps if s.steps else 0.1) + spin.setDecimals(s.decimals) + spin.setSuffix(f" {s.unit}" if s.unit else "") + spin.setValue(self.app.settings.get(s.key, s.minimum)) + spin.valueChanged.connect(lambda _v: self.app.schedule_save()) + form.addRow(s.label + ":", spin) + self._value_widgets[s.key] = spin + box_layout.addLayout(form) + + # --- state ----------------------------------------------------------- + + def get_state(self) -> dict: + state = dict(self._hidden_values) + for key, spin in self._value_widgets.items(): + state[key] = spin.value() + for key, check in self._enabled_checks.items(): + state[key] = check.isChecked() + return state + + def set_values(self, data: dict) -> None: + for key, spin in self._value_widgets.items(): + if key in data: + spin.setValue(data[key]) + for key, check in self._enabled_checks.items(): + if key in data: + check.setChecked(bool(data[key])) + for key in self._hidden_values: + if key in data: + self._hidden_values[key] = data[key] + + # --- presets (presets/fx/*.json, shared with Tk) ---------------------- + + def refresh_presets(self) -> None: + presets = ["Select FX Preset..."] + if os.path.exists(qt_app_module.FX_PRESETS_DIR): + files = [f for f in os.listdir(qt_app_module.FX_PRESETS_DIR) if f.endswith(".json")] + presets.extend(f[:-5] for f in files) + self.preset_combo.blockSignals(True) + self.preset_combo.clear() + self.preset_combo.addItems(presets) + self.preset_combo.setCurrentText("Select FX Preset...") + self.preset_combo.blockSignals(False) + if hasattr(self.app, "generation_dock") and self.app.generation_dock is not None: + self.app.generation_dock.refresh_fx_presets() + + def _save_preset_dialog(self) -> None: + name, ok = QInputDialog.getText(self, "Save FX Preset", "Enter FX preset name:") + if not ok or not name: + return + name = re.sub(r'[<>:"/\\|?*]', "", name).strip() + if not name: + return + data = {k: self.get_state()[k] for k in spec.FX_PRESET_KEYS} + fpath = os.path.join(qt_app_module.FX_PRESETS_DIR, f"{name}.json") + try: + os.makedirs(qt_app_module.FX_PRESETS_DIR, exist_ok=True) + with open(fpath, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=4) + QMessageBox.information(self, "Saved", f"FX Preset '{name}' saved.") + self.refresh_presets() + self.preset_combo.setCurrentText(name) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to save FX preset: {e}") + + def load_preset(self, name: str) -> None: + if not name or name == "Select FX Preset...": + return + safe_name = os.path.basename(name) + if not safe_name: + return + fpath = os.path.join(qt_app_module.FX_PRESETS_DIR, f"{safe_name}.json") + if not os.path.exists(fpath): + return + try: + with open(fpath, "r", encoding="utf-8") as fh: + data = json.load(fh) + self.set_values(data) + self.preset_combo.blockSignals(True) + self.preset_combo.setCurrentText(safe_name) + self.preset_combo.blockSignals(False) + if hasattr(self.app, "generation_dock") and self.app.generation_dock is not None: + self.app.generation_dock.fx_preset_combo.blockSignals(True) + self.app.generation_dock.fx_preset_combo.setCurrentText(safe_name) + self.app.generation_dock.fx_preset_combo.blockSignals(False) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to load FX preset: {e}") + + def _on_preset_selected(self, name: str) -> None: + self.load_preset(name) diff --git a/kokoro_gui/qt/docks/generation_dock.py b/kokoro_gui/qt/docks/generation_dock.py new file mode 100644 index 0000000..d44d469 --- /dev/null +++ b/kokoro_gui/qt/docks/generation_dock.py @@ -0,0 +1,374 @@ +"""Generation dock: input source, voice/speed/output config, and the speaker +presets (`presets/*.json`, shared with the Tk frontend) that snapshot that +config. Mirrors kokoro_gui/ui/generation_tab.py + the relevant slice of +gui.py's `start_conversion`/`preview_conversion` config assembly. + +Reads `kokoro_gui.qt.app.PRESETS_DIR` qualified at call time (not imported by +name) so tests can monkeypatch it into a tmp_path, same convention +generation_tab.py already uses for `gui.PRESETS_DIR`. +""" +from __future__ import annotations + +import json +import os +import re + +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDockWidget, QDoubleSpinBox, QFileDialog, + QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, + QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSpinBox, + QTabWidget, QVBoxLayout, QWidget, +) + +import kokoro_gui.qt.app as qt_app_module +from kokoro_gui.qt import spec +from kokoro_gui.qt.schema_form import SchemaFormWidget + + +class GenerationDock(QDockWidget): + def __init__(self, app, parent=None): + super().__init__("Generate Audio", parent) + self.setObjectName("dock_generation") + self.app = app + + content = QWidget() + outer = QVBoxLayout(content) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + inner = QWidget() + layout = QVBoxLayout(inner) + scroll.setWidget(inner) + outer.addWidget(scroll) + + # --- Input source --- + input_group = QGroupBox("Input Source") + input_layout = QVBoxLayout(input_group) + self.tabs = QTabWidget() + input_layout.addWidget(self.tabs) + + self.text_entry = QPlainTextEdit() + self.tabs.addTab(self.text_entry, "Direct Text") + + file_tab = QWidget() + file_layout = QHBoxLayout(file_tab) + self.file_path_edit = QLineEdit() + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse_file) + file_layout.addWidget(QLabel("File Path:")) + file_layout.addWidget(self.file_path_edit) + file_layout.addWidget(browse_btn) + self.tabs.addTab(file_tab, "Load File") + layout.addWidget(input_group) + + # --- Presets row --- + preset_row = QHBoxLayout() + self.preset_combo = QComboBox() + self.preset_combo.currentTextChanged.connect(self._on_preset_selected) + save_btn = QPushButton("Save Preset...") + save_btn.clicked.connect(self._save_preset_dialog) + refresh_btn = QPushButton("Refresh") + refresh_btn.clicked.connect(self.refresh_presets) + preset_row.addWidget(QLabel("Preset:")) + preset_row.addWidget(self.preset_combo, 1) + preset_row.addWidget(save_btn) + preset_row.addWidget(refresh_btn) + layout.addLayout(preset_row) + + # --- Schema-driven config (workstream 1's payoff) --- + self.schema_group = QGroupBox("Configuration") + self.schema_layout = QVBoxLayout(self.schema_group) + self.schema_form: SchemaFormWidget | None = None + layout.addWidget(self.schema_group) + self._build_schema_form() + + # --- Output (not schema-covered, hand-built same as gui.py/generation_tab.py) --- + out_group = QGroupBox("Output") + out_form = QFormLayout(out_group) + dir_row = QWidget() + dir_layout = QHBoxLayout(dir_row) + dir_layout.setContentsMargins(0, 0, 0, 0) + self.out_dir_edit = QLineEdit(self.app.settings.get("out_dir", "audio_output")) + dir_browse = QPushButton("...") + dir_browse.clicked.connect(self._browse_dir) + dir_layout.addWidget(self.out_dir_edit) + dir_layout.addWidget(dir_browse) + out_form.addRow("Output Folder:", dir_row) + + self.filename_edit = QLineEdit(self.app.settings.get("filename", "output")) + out_form.addRow("Base Filename:", self.filename_edit) + layout.addWidget(out_group) + + # --- Audio control (volume/pitch - not schema-covered, matches gui.py) --- + audio_group = QGroupBox("Audio Control") + audio_form = QFormLayout(audio_group) + self.volume_spin = QDoubleSpinBox() + self.volume_spin.setRange(0.1, 2.0) + self.volume_spin.setSingleStep(0.1) + self.volume_spin.setValue(self.app.settings.get("volume", 1.0)) + audio_form.addRow("Volume:", self.volume_spin) + + self.pitch_spin = QDoubleSpinBox() + self.pitch_spin.setRange(-12, 12) + self.pitch_spin.setSingleStep(1) + self.pitch_spin.setValue(self.app.settings.get("pitch", 0.0)) + audio_form.addRow("Pitch (st):", self.pitch_spin) + + fx_row = QWidget() + fx_row_layout = QHBoxLayout(fx_row) + fx_row_layout.setContentsMargins(0, 0, 0, 0) + self.fx_preset_combo = QComboBox() + self.fx_preset_combo.currentTextChanged.connect(self._on_fx_preset_selected) + self.apply_fx_check = QCheckBox("Apply") + self.apply_fx_check.setChecked(self.app.settings.get("apply_fx", True)) + fx_row_layout.addWidget(self.fx_preset_combo, 1) + fx_row_layout.addWidget(self.apply_fx_check) + audio_form.addRow("FX Preset:", fx_row) + + self.normalize_check = QCheckBox("Normalize") + self.normalize_check.setChecked(self.app.settings.get("normalize", False)) + self.trim_check = QCheckBox("Trim Silence") + self.trim_check.setChecked(self.app.settings.get("trim", False)) + toggles_row = QWidget() + toggles_layout = QHBoxLayout(toggles_row) + toggles_layout.setContentsMargins(0, 0, 0, 0) + toggles_layout.addWidget(self.normalize_check) + toggles_layout.addWidget(self.trim_check) + audio_form.addRow("", toggles_row) + layout.addWidget(audio_group) + + # --- Processing options --- + proc_group = QGroupBox("Processing Options") + proc_layout = QVBoxLayout(proc_group) + chk_row = QHBoxLayout() + self.separate_check = QCheckBox("Keep Segments") + self.separate_check.setChecked(self.app.settings.get("separate", True)) + self.combine_check = QCheckBox("Combine Output") + self.combine_check.setChecked(self.app.settings.get("combine", True)) + self.subtitles_check = QCheckBox("Export Subtitles (.srt)") + self.subtitles_check.setChecked(self.app.settings.get("export_subtitles", False)) + chk_row.addWidget(self.separate_check) + chk_row.addWidget(self.combine_check) + chk_row.addWidget(self.subtitles_check) + proc_layout.addLayout(chk_row) + + thread_row = QHBoxLayout() + thread_row.addWidget(QLabel("Parallel Threads:")) + self.threads_spin = QSpinBox() + self.threads_spin.setRange(1, 16) + self.threads_spin.setValue(self.app.settings.get("num_threads", 1)) + thread_row.addWidget(self.threads_spin) + thread_row.addWidget(QLabel("(More threads = High RAM usage)")) + thread_row.addStretch(1) + proc_layout.addLayout(thread_row) + layout.addWidget(proc_group) + + layout.addStretch(1) + self.setWidget(content) + + for w in (self.out_dir_edit, self.filename_edit): + w.textChanged.connect(lambda _v: self.app.schedule_save()) + for w in (self.volume_spin, self.pitch_spin, self.threads_spin): + w.valueChanged.connect(lambda _v: self.app.schedule_save()) + for w in (self.apply_fx_check, self.normalize_check, self.trim_check, + self.separate_check, self.combine_check, self.subtitles_check): + w.toggled.connect(lambda _v: self.app.schedule_save()) + + self.refresh_presets() + self.refresh_fx_presets() + + # --- schema form (rebuilt on engine switch) ------------------------ + + def _build_schema_form(self) -> None: + if self.schema_form is not None: + self.schema_layout.removeWidget(self.schema_form) + self.schema_form.deleteLater() + + schema = self.app.backend.get_config_schema() + lang_code = self.app.settings.get("lang_code", "a") + voice_choices = [(v, v) for v in self.app.get_all_voices(lang_code)] + lang_choices = [(label, code) for label, code in spec.LANGUAGES.items()] + values = { + "lang_code": self.app.settings.get("lang_code", "a"), + "voice": self.app.settings.get("voice", "af_heart"), + "speed": self.app.settings.get("speed", 1.0), + "split_pattern": self.app.settings.get("split_pattern", r"\n+"), + "format": self.app.settings.get("format", "wav"), + "num_threads": self.app.settings.get("num_threads", 1), + "caching": self.app.settings.get("caching", True), + } + self.schema_form = SchemaFormWidget( + schema, values, + choices_overrides={"voice": voice_choices, "lang_code": lang_choices}, + skip_keys={"lexicon"}, + on_change=self._on_schema_field_changed, + ) + self.schema_layout.addWidget(self.schema_form) + + def rebuild_schema_form(self) -> None: + """Called by app.py's switch_engine - re-renders this dock's schema + fields for the newly-active backend. This is the fix for gui.py's + own switch_engine docstring gap (see gui.py:530-538).""" + self._build_schema_form() + + def refresh_voice_choices(self) -> None: + lang_code = self.schema_form.values().get("lang_code", "a") + voices = self.app.get_all_voices(lang_code) + current = self.schema_form.values().get("voice") + self.schema_form.set_choices("voice", [(v, v) for v in voices], current) + + def _on_schema_field_changed(self, key: str, _value) -> None: + if key == "lang_code": + self.refresh_voice_choices() + self.app.schedule_save() + + # --- output/text helpers -------------------------------------------- + + def _browse_dir(self) -> None: + d = QFileDialog.getExistingDirectory(self, "Select output folder") + if d: + self.out_dir_edit.setText(d) + + def _browse_file(self) -> None: + f, _ = QFileDialog.getOpenFileName(self, "Select input file", filter="Documents (*.txt *.pdf *.epub)") + if f: + self.file_path_edit.setText(f) + + def get_text(self) -> str: + if self.tabs.currentIndex() == 0: + return self.text_entry.toPlainText().strip() + fpath = self.file_path_edit.text() + if os.path.exists(fpath): + try: + return self.app.engine.extract_text_from_file(fpath) + except Exception: + return "" + return "" + + def get_file_path(self) -> str: + return self.file_path_edit.text() + + def using_file_tab(self) -> bool: + return self.tabs.currentIndex() == 1 + + # --- state (feeds app._assemble_config) ------------------------------ + + def get_state(self) -> dict: + state = dict(self.schema_form.values()) + state.update({ + "out_dir": self.out_dir_edit.text(), + "filename": self.filename_edit.text(), + "volume": self.volume_spin.value(), + "pitch": self.pitch_spin.value(), + "normalize": self.normalize_check.isChecked(), + "trim_silence": self.trim_check.isChecked(), + "separate": self.separate_check.isChecked(), + "combine": self.combine_check.isChecked(), + "export_subtitles": self.subtitles_check.isChecked(), + }) + return state + + def apply_fx_enabled(self) -> bool: + return self.apply_fx_check.isChecked() + + # --- presets (presets/*.json, shared with Tk) ------------------------- + + def refresh_presets(self) -> None: + presets = ["Select Preset..."] + if os.path.exists(qt_app_module.PRESETS_DIR): + files = [f for f in os.listdir(qt_app_module.PRESETS_DIR) if f.endswith(".json")] + presets.extend(f[:-5] for f in files) + self.preset_combo.blockSignals(True) + self.preset_combo.clear() + self.preset_combo.addItems(presets) + self.preset_combo.setCurrentText("Select Preset...") + self.preset_combo.blockSignals(False) + + def _save_preset_dialog(self) -> None: + name, ok = QInputDialog.getText(self, "Save Preset", "Enter preset name:") + if not ok or not name: + return + name = re.sub(r'[<>:"/\\|?*]', "", name).strip() + if not name: + return + + state = self.get_state() + data = { + "voice": state.get("voice"), + "speed": state.get("speed"), + "volume": state.get("volume"), + "pitch": state.get("pitch"), + "split_pattern": state.get("split_pattern"), + "normalize": state.get("normalize"), + "trim": state.get("trim_silence"), + "format": state.get("format"), + "apply_fx": self.apply_fx_enabled(), + "fx_preset": self.fx_preset_combo.currentText(), + } + fpath = os.path.join(qt_app_module.PRESETS_DIR, f"{name}.json") + try: + os.makedirs(qt_app_module.PRESETS_DIR, exist_ok=True) + with open(fpath, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=4) + QMessageBox.information(self, "Saved", f"Preset '{name}' saved successfully.") + self.refresh_presets() + self.preset_combo.setCurrentText(name) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to save preset: {e}") + + def _on_preset_selected(self, name: str) -> None: + if not name or name == "Select Preset...": + return + fpath = os.path.join(qt_app_module.PRESETS_DIR, f"{name}.json") + if not os.path.exists(fpath): + return + try: + with open(fpath, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to load preset: {e}") + return + + values = {} + if "voice" in data: + values["voice"] = data["voice"] + if "speed" in data: + values["speed"] = data["speed"] + if "split_pattern" in data: + values["split_pattern"] = data["split_pattern"] + if "format" in data: + values["format"] = data["format"] + if values: + self.schema_form.set_values(values) + if "volume" in data: + self.volume_spin.setValue(data["volume"]) + if "pitch" in data: + self.pitch_spin.setValue(data["pitch"]) + if "normalize" in data: + self.normalize_check.setChecked(data["normalize"]) + if "trim" in data: + self.trim_check.setChecked(data["trim"]) + if "apply_fx" in data: + self.apply_fx_check.setChecked(data["apply_fx"]) + fx_name = data.get("fx_preset") + if fx_name and fx_name != "Select FX Preset...": + self.app.fx_dock.load_preset(fx_name) + self.fx_preset_combo.setCurrentText(fx_name) + + # --- FX preset combo mirror (kept in sync with the FX dock's own combo) -- + + def refresh_fx_presets(self) -> None: + presets = ["Select FX Preset..."] + if os.path.exists(qt_app_module.FX_PRESETS_DIR): + files = [f for f in os.listdir(qt_app_module.FX_PRESETS_DIR) if f.endswith(".json")] + presets.extend(f[:-5] for f in files) + self.fx_preset_combo.blockSignals(True) + self.fx_preset_combo.clear() + self.fx_preset_combo.addItems(presets) + self.fx_preset_combo.setCurrentText("Select FX Preset...") + self.fx_preset_combo.blockSignals(False) + + def _on_fx_preset_selected(self, name: str) -> None: + if not name or name == "Select FX Preset...": + return + self.app.fx_dock.load_preset(name) diff --git a/kokoro_gui/qt/docks/lexicon_dock.py b/kokoro_gui/qt/docks/lexicon_dock.py new file mode 100644 index 0000000..3d6d32f --- /dev/null +++ b/kokoro_gui/qt/docks/lexicon_dock.py @@ -0,0 +1,88 @@ +"""Lexicon dock: find/replace rules stored in `self.app.settings["lexicon"]`. +Mirrors kokoro_gui/ui/lexicon_tab.py, including its eager-save behavior +(bypasses the debounced autosave every other field uses).""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDockWidget, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox, + QPushButton, QScrollArea, QVBoxLayout, QWidget, +) + + +class LexiconDock(QDockWidget): + def __init__(self, app, parent=None): + super().__init__("Lexicon", parent) + self.setObjectName("dock_lexicon") + self.app = app + + content = QWidget() + layout = QVBoxLayout(content) + + add_row = QHBoxLayout() + add_row.addWidget(QLabel("Original Text:")) + self.orig_edit = QLineEdit() + add_row.addWidget(self.orig_edit) + add_row.addWidget(QLabel("Replacement:")) + self.replace_edit = QLineEdit() + add_row.addWidget(self.replace_edit) + add_btn = QPushButton("Add Rule") + add_btn.clicked.connect(self.add_rule) + add_row.addWidget(add_btn) + layout.addLayout(add_row) + + self.list_scroll = QScrollArea() + self.list_scroll.setWidgetResizable(True) + self._list_container = QWidget() + self._list_layout = QVBoxLayout(self._list_container) + self.list_scroll.setWidget(self._list_container) + layout.addWidget(self.list_scroll, 1) + + layout.addWidget(QLabel("Note: Replacements are case-insensitive. Applied before generation.")) + + self.setWidget(content) + self.refresh_list() + + def add_rule(self) -> None: + orig = self.orig_edit.text().strip() + rep = self.replace_edit.text().strip() + if not orig: + QMessageBox.warning(self, "Error", "Original text cannot be empty.") + return + + if "lexicon" not in self.app.settings: + self.app.settings["lexicon"] = {} + self.app.settings["lexicon"][orig] = rep + self.orig_edit.clear() + self.replace_edit.clear() + self.app.save_settings() + self.refresh_list() + + def delete_rule(self, key: str) -> None: + if key in self.app.settings.get("lexicon", {}): + del self.app.settings["lexicon"][key] + self.app.save_settings() + self.refresh_list() + + def refresh_list(self) -> None: + while self._list_layout.count(): + item = self._list_layout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + lexicon = self.app.settings.get("lexicon", {}) + if not lexicon: + self._list_layout.addWidget(QLabel("No rules defined.")) + return + + for orig, rep in lexicon.items(): + row = QFrame() + row_layout = QHBoxLayout(row) + row_layout.addWidget(QLabel(orig)) + row_layout.addWidget(QLabel("->")) + row_layout.addWidget(QLabel(rep)) + row_layout.addStretch(1) + del_btn = QPushButton("X") + del_btn.clicked.connect(lambda _c=False, k=orig: self.delete_rule(k)) + row_layout.addWidget(del_btn) + self._list_layout.addWidget(row) diff --git a/kokoro_gui/qt/docks/mixing_dock.py b/kokoro_gui/qt/docks/mixing_dock.py new file mode 100644 index 0000000..3a5d9c9 --- /dev/null +++ b/kokoro_gui/qt/docks/mixing_dock.py @@ -0,0 +1,273 @@ +"""Custom Voice (mixing) dock: blends two voice tensors via +`self.app.engine.mix_voices` and previews/saves the result. Shown only when +`app.backend.capabilities.supports_voice_mixing` is true - see app.py's +`_sync_mixing_dock`. Mirrors kokoro_gui/ui/mixing_tab.py. + +Uses the literal relative "custom_voices" path, same as mixing_tab.py/gui.py +(not `kokoro_engine.CUSTOM_VOICES_DIR`) - both frontends rely on the process +cwd for this, which is why the test fixtures `monkeypatch.chdir(tmp_path)`. +""" +from __future__ import annotations + +import os +import re +import tempfile + +import playback +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QComboBox, QDockWidget, QFrame, QGridLayout, QHBoxLayout, + QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSlider, + QVBoxLayout, QWidget, +) + +from kokoro_gui.qt import spec + +CUSTOM_VOICES_DIR = "custom_voices" + + +class MixingDock(QDockWidget): + previewFinished = Signal(bool, str) + mixFinished = Signal(bool, str) + + def __init__(self, app, parent=None): + super().__init__("Custom Voice", parent) + self.setObjectName("dock_mixing") + self.app = app + self.previewFinished.connect(self._on_preview_finished) + self.mixFinished.connect(self._on_mix_finished) + + content = QWidget() + layout = QVBoxLayout(content) + + lang_items = list(spec.LANGUAGES.items()) + + sel_grid = QGridLayout() + sel_grid.addWidget(QLabel("Voice A:"), 0, 0) + self.lang_a_combo = QComboBox() + self.voice_a_combo = QComboBox() + for label, code in lang_items: + self.lang_a_combo.addItem(label, code) + self.lang_a_combo.currentIndexChanged.connect(lambda _i: self._refresh_voice_list(self.lang_a_combo, self.voice_a_combo)) + sel_grid.addWidget(self.lang_a_combo, 0, 1) + sel_grid.addWidget(self.voice_a_combo, 0, 2) + + sel_grid.addWidget(QLabel("Voice B:"), 1, 0) + self.lang_b_combo = QComboBox() + self.voice_b_combo = QComboBox() + for label, code in lang_items: + self.lang_b_combo.addItem(label, code) + self.lang_b_combo.setCurrentIndex(0) + self.lang_b_combo.currentIndexChanged.connect(lambda _i: self._refresh_voice_list(self.lang_b_combo, self.voice_b_combo)) + sel_grid.addWidget(self.lang_b_combo, 1, 1) + sel_grid.addWidget(self.voice_b_combo, 1, 2) + layout.addLayout(sel_grid) + self._refresh_voice_list(self.lang_a_combo, self.voice_a_combo) + self._refresh_voice_list(self.lang_b_combo, self.voice_b_combo) + if self.voice_b_combo.count() > 1: + self.voice_b_combo.setCurrentIndex(1) + + op_row = QHBoxLayout() + op_row.addWidget(QLabel("Operation:")) + self.op_combo = QComboBox() + self.op_combo.addItems(["mix", "add", "subtract", "multiply", "divide"]) + self.op_combo.currentTextChanged.connect(self._update_ratio_label) + op_row.addWidget(self.op_combo) + op_row.addStretch(1) + layout.addLayout(op_row) + + self.ratio_label = QLabel("Mix: 50% A / 50% B") + layout.addWidget(self.ratio_label) + self.ratio_slider = QSlider(Qt.Orientation.Horizontal) + self.ratio_slider.setRange(0, 100) + self.ratio_slider.setValue(50) + self.ratio_slider.valueChanged.connect(self._update_ratio_label) + layout.addWidget(self.ratio_slider) + self._update_ratio_label() + + prev_row = QHBoxLayout() + prev_row.addWidget(QLabel("Preview Language:")) + self.preview_lang_combo = QComboBox() + for label, code in lang_items: + self.preview_lang_combo.addItem(label, code) + prev_row.addWidget(self.preview_lang_combo) + preview_btn = QPushButton("\U0001F50A Preview") + preview_btn.clicked.connect(self.preview_mix) + prev_row.addWidget(preview_btn) + prev_row.addStretch(1) + layout.addLayout(prev_row) + + save_row = QHBoxLayout() + save_row.addWidget(QLabel("New Voice Name:")) + self.mix_name_edit = QLineEdit() + save_row.addWidget(self.mix_name_edit, 1) + save_btn = QPushButton("Create && Save") + save_btn.clicked.connect(self.mix_voice_action) + save_row.addWidget(save_btn) + layout.addLayout(save_row) + + self.mix_status_label = QLabel("") + layout.addWidget(self.mix_status_label) + + layout.addWidget(QLabel("Custom Voices:")) + self.list_scroll = QScrollArea() + self.list_scroll.setWidgetResizable(True) + self.list_scroll.setFixedHeight(200) + self._list_container = QWidget() + self._list_layout = QVBoxLayout(self._list_container) + self.list_scroll.setWidget(self._list_container) + layout.addWidget(self.list_scroll) + + layout.addStretch(1) + self.setWidget(content) + self.refresh_voice_lists() + + def _ratio_value(self) -> float: + return self.ratio_slider.value() / 100.0 + + def _update_ratio_label(self, *_args) -> None: + p = self.ratio_slider.value() + op = self.op_combo.currentText() + if op == "mix": + self.ratio_label.setText(f"Mix: {100 - p}% A / {p}% B") + elif op == "divide": + self.ratio_label.setText(f"Op: Divide | Influence: {p}% (unstable and VERY LOUD)") + else: + self.ratio_label.setText(f"Op: {op.capitalize()} | Influence: {p}%") + + def _refresh_voice_list(self, lang_combo: QComboBox, voice_combo: QComboBox) -> None: + code = lang_combo.currentData() + voices = self.app.get_all_voices(code) + current = voice_combo.currentText() + voice_combo.blockSignals(True) + voice_combo.clear() + voice_combo.addItems(voices) + if current in voices: + voice_combo.setCurrentText(current) + elif voices: + voice_combo.setCurrentIndex(0) + voice_combo.blockSignals(False) + + def refresh_voice_lists(self) -> None: + if hasattr(self.app, "generation_dock") and self.app.generation_dock is not None: + self.app.generation_dock.refresh_voice_choices() + self._refresh_voice_list(self.lang_a_combo, self.voice_a_combo) + self._refresh_voice_list(self.lang_b_combo, self.voice_b_combo) + + while self._list_layout.count(): + item = self._list_layout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + custom = [] + if os.path.exists(CUSTOM_VOICES_DIR): + custom = sorted(f[:-3] for f in os.listdir(CUSTOM_VOICES_DIR) if f.endswith(".pt")) + if not custom: + self._list_layout.addWidget(QLabel("No custom voices found.")) + else: + for cv in custom: + row = QFrame() + row_layout = QHBoxLayout(row) + row_layout.addWidget(QLabel(cv)) + row_layout.addStretch(1) + del_btn = QPushButton("X") + del_btn.clicked.connect(lambda _c=False, v=cv: self.delete_custom_voice(v)) + row_layout.addWidget(del_btn) + self._list_layout.addWidget(row) + + def delete_custom_voice(self, name: str) -> None: + if QMessageBox.question(self, "Confirm", f"Delete voice '{name}'?") != QMessageBox.StandardButton.Yes: + return + try: + path = os.path.join(CUSTOM_VOICES_DIR, f"{name}.pt") + if os.path.exists(path): + os.remove(path) + self.refresh_voice_lists() + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete: {e}") + + def preview_mix(self) -> None: + v1 = self.voice_a_combo.currentText() + v2 = self.voice_b_combo.currentText() + ratio = self._ratio_value() + op = self.op_combo.currentText() + preview_lang = self.preview_lang_combo.currentData() + + preview_text = spec.MIX_PREVIEW_TEXT.get(preview_lang, spec.MIX_PREVIEW_TEXT_DEFAULT) + tmp_voice_name = "_tmp_mix_preview" + tmp_audio_path = os.path.join(tempfile.gettempdir(), "kokoro_mix_preview.wav") + + self.mix_status_label.setText("Generating preview...") + + async def _run_preview(): + success, msg, tensor = await self.app.engine.mix_voices(v1, v2, ratio, tmp_voice_name, op=op) + if not success: + return False, msg + success = await self.app.engine.generate_preview( + preview_text, tmp_voice_name, 1.0, tmp_audio_path, voice_tensor=tensor, lang_code=preview_lang, + ) + try: + p = os.path.join(CUSTOM_VOICES_DIR, f"{tmp_voice_name}.pt") + if os.path.exists(p): + os.remove(p) + except Exception: + pass + return success, "" + + def _done(future): + try: + success, err = future.result() + except Exception as e: + success, err = False, str(e) + self.previewFinished.emit(success, err) + if success: + playback.play(tmp_audio_path) + + future = self.app.engine.worker.run_coro(_run_preview()) + future.add_done_callback(_done) + + def _on_preview_finished(self, success: bool, err: str) -> None: + if success: + self.mix_status_label.setText("Playing preview...") + else: + self.mix_status_label.setText(f"Preview failed: {err}") + + def mix_voice_action(self) -> None: + v1 = self.voice_a_combo.currentText() + v2 = self.voice_b_combo.currentText() + ratio = self._ratio_value() + op = self.op_combo.currentText() + name = self.mix_name_edit.text().strip() + + if not name: + QMessageBox.warning(self, "Error", "Please enter a name for the new voice.") + return + if not re.match(r"^[a-zA-Z0-9_-]+$", name): + QMessageBox.warning(self, "Error", "Invalid name. Use alphanumeric, _, - only.") + return + if name in self.app.get_all_voices(): + if QMessageBox.question(self, "Overwrite", f"Voice '{name}' exists. Overwrite?") != QMessageBox.StandardButton.Yes: + return + + self.mix_status_label.setText("Mixing...") + self.app.set_ui_state(True) + self._pending_name = name + + def _done(future): + try: + success, msg, _tensor = future.result() + except Exception as e: + success, msg = False, str(e) + self.mixFinished.emit(success, msg) + + future = self.app.engine.worker.run_coro(self.app.engine.mix_voices(v1, v2, ratio, name, op=op)) + future.add_done_callback(_done) + + def _on_mix_finished(self, success: bool, msg: str) -> None: + self.app.set_ui_state(False) + if success: + self.mix_status_label.setText(f"Saved: {self._pending_name}") + self.refresh_voice_lists() + else: + self.mix_status_label.setText(f"Error: {msg}") diff --git a/kokoro_gui/qt/schema_form.py b/kokoro_gui/qt/schema_form.py new file mode 100644 index 0000000..b990f07 --- /dev/null +++ b/kokoro_gui/qt/schema_form.py @@ -0,0 +1,187 @@ +"""Generic renderer that walks a backend's `list[ConfigField]` +(kokoro_gui/engines/base.py) into Qt form rows. + +This is the concrete payoff of workstream 1 for workstream 3a: the Qt +Generation dock's schema-covered fields (lang_code/voice/speed/split_pattern/ +format/num_threads/caching) are built by walking whatever +`backend.get_config_schema()` returns, not hard-coded per engine. Rebuilding +this widget from a new backend's schema is what makes engine-switching +actually swap the visible fields (see docks/generation_dock.py and app.py's +`switch_engine`), fixing the gap gui.py's own `switch_engine` docstring names. +""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, + QLineEdit, QPushButton, QSpinBox, QVBoxLayout, QWidget, QFileDialog, +) + +from kokoro_gui.engines.base import ConfigField, ConfigFieldType + + +class SchemaFormWidget(QWidget): + """Renders `schema` (a `list[ConfigField]`) as `QFormLayout` rows grouped + by `field.group` into `QGroupBox`es. + + `choices_overrides`: {key: [(label, value), ...]} for fields whose schema + `choices` is `None` because the option set is GUI-resolved rather than + engine data (today: "voice", "lang_code" - see + kokoro_gui/engines/kokoro.py's `get_config_schema` docstring). + + `skip_keys`: field keys to omit entirely - the caller owns a dedicated + widget for them instead (today: "lexicon", rendered as a CRUD list by + docks/lexicon_dock.py rather than a single TEXT field). + + `on_change(key, value)`: called whenever a rendered field's value changes, + so the owning dock can drive autosave the same way Tk's ctk var traces do. + """ + + def __init__( + self, + schema: list[ConfigField], + values: dict[str, Any], + choices_overrides: Optional[dict[str, list[tuple[str, Any]]]] = None, + skip_keys: Optional[set[str]] = None, + on_change: Optional[Callable[[str, Any], None]] = None, + parent: Optional[QWidget] = None, + ): + super().__init__(parent) + self._schema = schema + self._choices_overrides = choices_overrides or {} + self._skip_keys = skip_keys or set() + self._on_change = on_change + self._widgets: dict[str, QWidget] = {} + self._getters: dict[str, Callable[[], Any]] = {} + self._setters: dict[str, Callable[[Any], None]] = {} + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + groups: dict[str, QFormLayout] = {} + group_order: list[str] = [] + for f in schema: + if f.key in self._skip_keys: + continue + if f.group not in groups: + box = QGroupBox(f.group) + form = QFormLayout() + box.setLayout(form) + groups[f.group] = form + group_order.append(f.group) + outer.addWidget(box) + self._add_field(groups[f.group], f) + + outer.addStretch(1) + self.set_values(values) + + def _add_field(self, form: QFormLayout, f: ConfigField) -> None: + choices = self._choices_overrides.get(f.key, f.choices) + + if f.type in (ConfigFieldType.CHOICE,) or choices is not None: + combo = QComboBox() + for label, value in (choices or []): + combo.addItem(str(label), value) + combo.currentIndexChanged.connect(lambda _i, k=f.key: self._emit_change(k)) + form.addRow(f.label, combo) + self._widgets[f.key] = combo + self._getters[f.key] = lambda c=combo: c.currentData() + self._setters[f.key] = lambda v, c=combo: self._set_combo(c, v) + + elif f.type == ConfigFieldType.BOOL: + box = QCheckBox() + box.toggled.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, box) + self._widgets[f.key] = box + self._getters[f.key] = box.isChecked + self._setters[f.key] = box.setChecked + + elif f.type == ConfigFieldType.INT: + spin = QSpinBox() + spin.setRange(int(f.min if f.min is not None else 0), int(f.max if f.max is not None else 100)) + spin.setSingleStep(int(f.step or 1)) + spin.valueChanged.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, spin) + self._widgets[f.key] = spin + self._getters[f.key] = spin.value + self._setters[f.key] = spin.setValue + + elif f.type in (ConfigFieldType.FLOAT, ConfigFieldType.SLIDER): + spin = QDoubleSpinBox() + spin.setRange(float(f.min if f.min is not None else 0.0), float(f.max if f.max is not None else 1.0)) + spin.setSingleStep(float(f.step or 0.1)) + spin.setDecimals(3) + spin.valueChanged.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, spin) + self._widgets[f.key] = spin + self._getters[f.key] = spin.value + self._setters[f.key] = spin.setValue + + elif f.type == ConfigFieldType.FILE: + row = QWidget() + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 0, 0, 0) + edit = QLineEdit() + browse = QPushButton("Browse...") + + def _browse(_checked=False, e=edit): + path, _ = QFileDialog.getOpenFileName(self, "Select file") + if path: + e.setText(path) + + browse.clicked.connect(_browse) + layout.addWidget(edit) + layout.addWidget(browse) + edit.textChanged.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, row) + self._widgets[f.key] = row + self._getters[f.key] = edit.text + self._setters[f.key] = edit.setText + + else: # TEXT, or any future type - plain line edit fallback + edit = QLineEdit() + edit.textChanged.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, edit) + self._widgets[f.key] = edit + self._getters[f.key] = edit.text + self._setters[f.key] = edit.setText + + @staticmethod + def _set_combo(combo: QComboBox, value: Any) -> None: + idx = combo.findData(value) + if idx < 0 and combo.count() > 0: + idx = 0 + if idx >= 0: + combo.setCurrentIndex(idx) + + def _emit_change(self, key: str) -> None: + if self._on_change is not None and key in self._getters: + self._on_change(key, self._getters[key]()) + + def widget_for(self, key: str) -> Optional[QWidget]: + return self._widgets.get(key) + + def values(self) -> dict[str, Any]: + return {k: getter() for k, getter in self._getters.items()} + + def set_values(self, values: dict[str, Any]) -> None: + for k, setter in self._setters.items(): + if k in values: + setter(values[k]) + + def set_choices(self, key: str, choices: list[tuple[str, Any]], current: Any = None) -> None: + """Repopulate a CHOICE field's options at runtime (used for "voice" + when the active language or backend changes).""" + combo = self._widgets.get(key) + if not isinstance(combo, QComboBox): + return + combo.blockSignals(True) + combo.clear() + for label, value in choices: + combo.addItem(str(label), value) + combo.blockSignals(False) + if current is not None: + self._set_combo(combo, current) + elif combo.count() > 0: + combo.setCurrentIndex(0) diff --git a/kokoro_gui/qt/settings.py b/kokoro_gui/qt/settings.py new file mode 100644 index 0000000..33f3fea --- /dev/null +++ b/kokoro_gui/qt/settings.py @@ -0,0 +1,68 @@ +"""Load/save `config_qt.json` (the Qt frontend's own app-settings file - see +spec.py's module docstring for why it's separate from Tk's `config.json`), +plus `QMainWindow` dock-layout persistence. + +Pure functions (no `QMainWindow`/app-instance state held here) so they're +easy to unit test in isolation - `app.py` calls these and owns the debounce +timer (`QTimer.singleShot`, mirroring gui.py's `schedule_save`/`after(1000, ...)`). +""" +from __future__ import annotations + +import base64 +import copy +import json +import os + +from kokoro_gui.qt import spec + + +def load_settings(config_file: str) -> dict: + # deepcopy, not dict(...): SETTINGS_DEFAULTS["lexicon"] is a mutable {} + # shared across every call - a shallow copy would let one instance's + # in-place `settings["lexicon"][k] = v` (lexicon_dock.py's add_rule) + # leak into every other instance/test that reads the same defaults. + defaults = copy.deepcopy(spec.SETTINGS_DEFAULTS) + if os.path.exists(config_file): + try: + with open(config_file, "r", encoding="utf-8") as f: + return {**defaults, **json.load(f)} + except Exception: + pass + return defaults + + +def save_settings(config_file: str, settings: dict) -> None: + try: + with open(config_file, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=4) + except Exception as e: + print(f"Failed to save Qt settings: {e}") + + +def encode_bytes(qbytearray) -> str: + return base64.b64encode(bytes(qbytearray)).decode("ascii") + + +def decode_bytes(b64_str: str): + from PySide6.QtCore import QByteArray + return QByteArray(base64.b64decode(b64_str.encode("ascii"))) + + +def save_window_state(main_window, settings: dict) -> None: + settings["dock_state"] = encode_bytes(main_window.saveState()) + settings["geometry"] = encode_bytes(main_window.saveGeometry()) + + +def restore_window_state(main_window, settings: dict) -> None: + dock_state = settings.get("dock_state") + geometry = settings.get("geometry") + if geometry: + try: + main_window.restoreGeometry(decode_bytes(geometry)) + except Exception: + pass + if dock_state: + try: + main_window.restoreState(decode_bytes(dock_state)) + except Exception: + pass diff --git a/kokoro_gui/qt/signals.py b/kokoro_gui/qt/signals.py new file mode 100644 index 0000000..0620801 --- /dev/null +++ b/kokoro_gui/qt/signals.py @@ -0,0 +1,47 @@ +"""Cross-thread callback marshalling for the Qt frontend. + +`KokoroEngine` calls `self.on_status`/`self.on_progress`/`self.on_finish` from +its own background `AsyncLoopThread` (see kokoro_engine.py's `AsyncLoopThread` +and kokoro_gui/engine/conversion.py's call sites), same as it always has for +the Tk frontend - Tk marshals those calls onto its main thread with +`self.after(0, ...)`. Qt's equivalent is a `QObject` living on the main +thread whose signals are emitted from the worker thread: PySide6 detects the +emitting thread differs from the receiving QObject's thread and automatically +queues the connected slot call onto that thread's event loop (a +`Qt.QueuedConnection`), with no `after()`-style boilerplate needed - this +works for any Python thread that emits into a QObject with a running event +loop, not just a `QThread`. + +The same trick applies to the `concurrent.futures.Future.add_done_callback(...)` +pattern `preview_conversion`/mixing use (mirrors gui.py's `_on_preview_done`/ +`mixing_tab.py`'s `_on_done`, both of which wrap the UI touch in `self.after(0, ...)`): +any Qt widget/dock is itself a `QObject` that was constructed on the main +thread, so defining a small `Signal` directly on that dock/window class and +emitting it from inside the done-callback (which runs on the worker thread) +is enough - no dedicated bridge object needed for those one-off cases. See +docks/mixing_dock.py's `previewFinished`/`mixFinished` and app.py's +`previewFinished` for examples. +""" +from PySide6.QtCore import QObject, Signal + + +class EngineSignalBridge(QObject): + """One instance per active engine. Reconnected (not reused) across + `switch_engine` calls so a stale engine's callbacks never emit into a + dock the app has already rebuilt for a different backend.""" + + # func(msg: str, is_error: bool) - kokoro_engine.py:72 + status = Signal(str, bool) + # func(percentage: float, time_elapsed: float, eta: str, detail_text: str) - kokoro_engine.py:73 + progress = Signal(float, float, str, str) + # func() - kokoro_engine.py:74 + finished = Signal() + + +def wire_engine(engine, bridge: EngineSignalBridge) -> None: + """Point `engine`'s callback attributes at `bridge`'s signals. Mirrors + gui.py:41-43 / gui.py:547-549's `engine.on_progress = self.on_engine_progress` + wiring, just emitting a signal instead of calling a bound method directly.""" + engine.on_status = bridge.status.emit + engine.on_progress = bridge.progress.emit + engine.on_finish = bridge.finished.emit diff --git a/kokoro_gui/qt/spec.py b/kokoro_gui/qt/spec.py new file mode 100644 index 0000000..2f299bf --- /dev/null +++ b/kokoro_gui/qt/spec.py @@ -0,0 +1,232 @@ +"""Pure-data constants mirroring the Tk frontend's hard-coded field lists. + +Deliberately **mirrored**, not imported from `gui.py`/`kokoro_gui/ui/*.py` — +see the workstream 3a plan's "Zero edits to gui.py" note. This module has no +Tk or Qt imports so both `kokoro_gui/qt/*` and the test suite can import it +standalone, and `tests/gui_qt/test_qt_config_assembly.py` cross-checks these +constants against what the Tk `StubEngine`/`tts_app` fixture actually +captures at runtime, so drift between the two frontends is caught by a test +rather than prevented by shared code (safer than refactoring working Tk +internals just for this). + +Source of truth for each constant, as of this module's creation: +- GENERATION_BASE_KEYS: gui.py's `start_conversion` config dict (gui.py:828-848). +- FX_PRESET_KEYS: kokoro_gui/ui/fx_tab.py's `save_fx_preset_dialog` (fx_tab.py:204-248) — + the same 43 keys are merged into both `start_conversion` (gui.py:851-895) and + `preview_conversion`'s extra_config (gui.py:722-767) when FX is applied. +- FX_FIELD_SPECS: kokoro_gui/ui/fx_tab.py's `_create_slider(...)` call sites + (fx_tab.py:61-172). Seven FX_PRESET_KEYS fields have no matching entry here + because the Tk UI itself never built a widget for them (reverb_dry_level, + chorus_mix, phaser_depth, phaser_mix, comp_attack, comp_release, + limiter_release) — they + still round-trip through settings/presets/config assembly, just not via any + user-facing control in either frontend. That's an existing Tk gap, not + something workstream 3a is scoped to fix (see the plan's "preserve every + existing behavior 1:1" note). +- LANGUAGES / VOICE_DB: gui.py:58-78. +- SETTINGS_DEFAULTS: gui.py's `load_settings` defaults dict (gui.py:258-323), + minus "appearance"/"scaling" (Tk-specific CTk theming, no Qt equivalent). +""" +from dataclasses import dataclass +from typing import Optional + +# --- Generation config dict (non-FX keys) -------------------------------- + +GENERATION_BASE_KEYS = [ + "engine_id", "lang_code", "voice", "speed", "split_pattern", "filename", + "format", "out_dir", "separate", "combine", "export_subtitles", "caching", + "time_id", "num_threads", "volume", "pitch", "normalize", "trim_silence", + "lexicon", +] + +# --- FX preset / config-merge keys ---------------------------------------- + +FX_PRESET_KEYS = [ + "reverb_enabled", "reverb_room_size", "reverb_wet_level", "reverb_damping", + "reverb_dry_level", "reverb_width", + "eq_bass", "eq_treble", + "comp_enabled", "comp_threshold", "comp_ratio", "comp_attack", "comp_release", + "distortion_enabled", "distortion_drive", + "chorus_enabled", "chorus_rate", "chorus_depth", "chorus_mix", + "phaser_enabled", "phaser_rate", "phaser_depth", "phaser_mix", + "clipping_enabled", "clipping_thresh", + "bitcrush_enabled", "bitcrush_depth", + "gsm_enabled", + "highpass_enabled", "highpass_freq", + "lowpass_enabled", "lowpass_freq", + "delay_enabled", "delay_time", "delay_feedback", "delay_mix", + "pitch_shift_enabled", "pitch_shift_semitones", + "limiter_enabled", "limiter_threshold", "limiter_release", + "gain_enabled", "gain_db", +] + + +@dataclass(frozen=True) +class FXSliderSpec: + """One numeric FX field the UI exposes a control for. `steps` mirrors the + Tk `CTkSlider(number_of_steps=...)` value so `(maximum - minimum) / steps` + reproduces the same granularity in a QDoubleSpinBox's singleStep.""" + key: str + label: str + minimum: float + maximum: float + steps: int + group: str # dock section heading, e.g. "Spatial & Time" + section: str # sub-heading, e.g. "Reverb" + enabled_key: Optional[str] = None # bool field this is gated under, if any + unit: str = "" + decimals: int = 2 + + +FX_FIELD_SPECS = [ + # --- Dynamics --- + FXSliderSpec("comp_threshold", "Threshold", -60, 0, 60, "Dynamics", "Compressor", "comp_enabled", "dB", 1), + FXSliderSpec("comp_ratio", "Ratio", 1, 20, 19, "Dynamics", "Compressor", "comp_enabled", ":1", 1), + FXSliderSpec("limiter_threshold", "Threshold", -12, 0, 24, "Dynamics", "Limiter", "limiter_enabled", "dB", 1), + FXSliderSpec("gain_db", "dB", -20, 20, 80, "Dynamics", "Gain", "gain_enabled", "dB", 1), + # --- EQ & Filters --- + FXSliderSpec("eq_bass", "Bass (LowShelf)", -20, 20, 40, "EQ & Filters", "EQ", None, "dB", 1), + FXSliderSpec("eq_treble", "Treble (HighShelf)", -20, 20, 40, "EQ & Filters", "EQ", None, "dB", 1), + FXSliderSpec("highpass_freq", "Freq", 20, 1000, 100, "EQ & Filters", "HighPass Filter", "highpass_enabled", "Hz", 0), + FXSliderSpec("lowpass_freq", "Freq", 1000, 20000, 100, "EQ & Filters", "LowPass Filter", "lowpass_enabled", "Hz", 0), + # --- Spatial & Time --- + FXSliderSpec("reverb_room_size", "Room Size", 0, 1, 100, "Spatial & Time", "Reverb", "reverb_enabled", "", 2), + FXSliderSpec("reverb_wet_level", "Wet Level", 0, 1, 100, "Spatial & Time", "Reverb", "reverb_enabled", "", 2), + FXSliderSpec("reverb_damping", "Damping", 0, 1, 100, "Spatial & Time", "Reverb", "reverb_enabled", "", 2), + FXSliderSpec("reverb_width", "Width", 0, 1, 100, "Spatial & Time", "Reverb", "reverb_enabled", "", 2), + FXSliderSpec("delay_time", "Time", 0, 2, 100, "Spatial & Time", "Delay", "delay_enabled", "s", 2), + FXSliderSpec("delay_feedback", "Feedback", 0, 1, 100, "Spatial & Time", "Delay", "delay_enabled", "", 2), + FXSliderSpec("delay_mix", "Mix", 0, 1, 100, "Spatial & Time", "Delay", "delay_enabled", "", 2), + # --- Guitar / Modulation --- + FXSliderSpec("chorus_rate", "Rate", 0.1, 10, 50, "Guitar / Modulation", "Chorus", "chorus_enabled", "Hz", 1), + FXSliderSpec("chorus_depth", "Depth", 0, 1, 50, "Guitar / Modulation", "Chorus", "chorus_enabled", "", 2), + FXSliderSpec("distortion_drive", "Drive", 0, 60, 60, "Guitar / Modulation", "Distortion", "distortion_enabled", "dB", 1), + FXSliderSpec("phaser_rate", "Rate", 0.1, 10, 50, "Guitar / Modulation", "Phaser", "phaser_enabled", "Hz", 1), + FXSliderSpec("clipping_thresh", "Threshold", -20, 0, 40, "Guitar / Modulation", "Clipping", "clipping_enabled", "dB", 1), + # --- Quality / Pitch --- + FXSliderSpec("pitch_shift_semitones", "Semitones", -12, 12, 48, "Quality / Pitch", "Pitch Shift (High Quality)", "pitch_shift_enabled", "st", 1), + FXSliderSpec("bitcrush_depth", "Bit Depth", 2, 16, 28, "Quality / Pitch", "Bitcrush", "bitcrush_enabled", "", 1), +] + +# Standalone checkbox with no slider at all (Quality / Pitch group). +FX_STANDALONE_TOGGLES = [ + ("gsm_enabled", "GSM Compressor (Phone Quality)", "Quality / Pitch"), +] + +# FX_PRESET_KEYS entries with no matching widget in either frontend today +# (see module docstring) - still valid dict keys, just not user-editable. +FX_KEYS_WITHOUT_WIDGET = {"reverb_dry_level", "chorus_mix", "phaser_depth", "phaser_mix", "comp_attack", "comp_release", "limiter_release"} + +FX_GROUP_ORDER = ["Dynamics", "EQ & Filters", "Spatial & Time", "Guitar / Modulation", "Quality / Pitch"] + +# --- Voice / language display data ---------------------------------------- + +LANGUAGES = { + "American English": "a", + "British English": "b", + "Spanish": "e", + "French": "f", + "Italian": "i", + "Portuguese": "p", + "Japanese": "j", + "Chinese": "z", +} + +VOICE_DB = { + "a": ["af_heart", "af_alloy", "af_aoede", "af_bella", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", "af_sky", "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_michael", "am_onyx", "am_puck", "am_santa"], + "b": ["bf_alice", "bf_emma", "bf_isabella", "bf_lily", "bm_daniel", "bm_fable", "bm_george", "bm_lewis"], + "e": ["ef_dora", "em_alex", "em_santa"], + "f": ["ff_siwis"], + "i": ["if_sara", "im_nicola"], + "p": ["pf_dora", "pm_alex"], + "j": ["jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro"], + "z": ["zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zm_yunjian"], +} + +MIX_PREVIEW_TEXT = { + "f": "Ceci est un aperçu de votre voix personnalisée.", + "e": "Esta es una vista previa de su voz personalizada.", + "i": "Questa è un'anteprima della tua voce personalizzata.", + "p": "Esta é uma prévia da sua voz personalizada.", + "j": "これはカスタム合成音声のプレビューです。", + "z": "这是您的自定义混合语音预览。", +} +MIX_PREVIEW_TEXT_DEFAULT = "This is a preview of your custom mixed voice." + +# --- App-settings defaults (config_qt.json) -------------------------------- +# Mirrors gui.py's load_settings() defaults minus "appearance"/"scaling" +# (CTk-specific theming with no Qt equivalent here). + +SETTINGS_DEFAULTS = { + "lang_code": "a", + "voice": "af_heart", + "filename": "output", + "format": "wav", + "out_dir": "audio_output", + "speed": 1.0, + "volume": 1.0, + "pitch": 0.0, + "num_threads": 1, + "split_pattern": r"\n+", + "separate": True, + "combine": True, + "export_subtitles": False, + "caching": True, + "jit_enabled": False, + "normalize": False, + "trim": False, + "apply_fx": True, + "reverb_enabled": False, + "reverb_room_size": 0.5, + "reverb_wet_level": 0.3, + "reverb_damping": 0.5, + "reverb_dry_level": 1.0, + "reverb_width": 1.0, + "eq_bass": 0.0, + "eq_treble": 0.0, + "comp_enabled": False, + "comp_threshold": -20.0, + "comp_ratio": 4.0, + "comp_attack": 1.0, + "comp_release": 100.0, + "distortion_enabled": False, + "distortion_drive": 25.0, + "chorus_enabled": False, + "chorus_rate": 1.0, + "chorus_depth": 0.25, + "chorus_mix": 0.5, + "phaser_enabled": False, + "phaser_rate": 1.0, + "phaser_depth": 0.5, + "phaser_mix": 0.5, + "clipping_enabled": False, + "clipping_thresh": -6.0, + "bitcrush_enabled": False, + "bitcrush_depth": 8.0, + "gsm_enabled": False, + "highpass_enabled": False, + "highpass_freq": 50.0, + "lowpass_enabled": False, + "lowpass_freq": 10000.0, + "delay_enabled": False, + "delay_time": 0.5, + "delay_feedback": 0.0, + "delay_mix": 0.5, + "pitch_shift_enabled": False, + "pitch_shift_semitones": 0.0, + "limiter_enabled": False, + "limiter_threshold": -1.0, + "limiter_release": 100.0, + "gain_enabled": False, + "gain_db": 0.0, + "engine_id": "kokoro", + "lexicon": {}, + "dock_state": None, # base64 QMainWindow.saveState() bytes, set at runtime + "geometry": None, # base64 QMainWindow.saveGeometry() bytes, set at runtime +} + +_FX_ENABLED_KEYS = {s.enabled_key for s in FX_FIELD_SPECS if s.enabled_key} +assert set(FX_PRESET_KEYS) == ( + {s.key for s in FX_FIELD_SPECS} | FX_KEYS_WITHOUT_WIDGET + | {t[0] for t in FX_STANDALONE_TOGGLES} | _FX_ENABLED_KEYS +) diff --git a/main_qt.py b/main_qt.py new file mode 100644 index 0000000..930b9c5 --- /dev/null +++ b/main_qt.py @@ -0,0 +1,16 @@ +"""Entry point for the PySide6 (Qt) frontend - ships alongside main.py's Tk +frontend during the workstream 3a transition (see +PLAN_qt_and_engine_abstraction.md). Requires the optional `requirements-qt.txt` +extras (`pip install -r requirements-qt.txt`). +""" +import sys + +from PySide6.QtWidgets import QApplication + +from kokoro_gui.qt.app import QtTTSApp + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = QtTTSApp() + window.show() + sys.exit(app.exec()) diff --git a/requirements-qt-test.txt b/requirements-qt-test.txt new file mode 100644 index 0000000..2476d15 --- /dev/null +++ b/requirements-qt-test.txt @@ -0,0 +1 @@ +pytest-qt==4.5.0 diff --git a/requirements-qt.txt b/requirements-qt.txt new file mode 100644 index 0000000..27c14d2 --- /dev/null +++ b/requirements-qt.txt @@ -0,0 +1 @@ +PySide6==6.11.2 diff --git a/tests/gui_qt/__init__.py b/tests/gui_qt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gui_qt/conftest.py b/tests/gui_qt/conftest.py new file mode 100644 index 0000000..db3425d --- /dev/null +++ b/tests/gui_qt/conftest.py @@ -0,0 +1,63 @@ +"""Fixtures for the Qt (PySide6) frontend test suite - workstream 3a of +PLAN_qt_and_engine_abstraction.md. + +`pytest.importorskip` at the top means this whole tree self-skips when the +optional `requirements-qt.txt`/`requirements-qt-test.txt` extras aren't +installed, same pattern `tests/conftest.py`'s `espeak_available()` uses for +the integration suite - `pytest` (no args) stays runnable for Tk-only +contributors who never `pip install`ed PySide6. +""" +import os + +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("pytestqt") + +# Qt needs a platform plugin even to construct widgets; "offscreen" needs no +# real display, so the suite runs the same way in this sandbox, in CI, and on +# a dev machine with no monitor attached. Set before any PySide6 import. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +# tests/ has an __init__.py (package import mode), so this is tests/conftest.py's +# StubEngine, not a name collision with this file (also called conftest.py). +from tests.conftest import StubEngine # noqa: E402 + + +@pytest.fixture +def qt_app(tmp_path, monkeypatch, qtbot): + import kokoro_gui.qt.app as qt_app_module + from PySide6.QtWidgets import QFileDialog, QInputDialog, QMessageBox + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(qt_app_module, "CONFIG_FILE", str(tmp_path / "config_qt.json")) + monkeypatch.setattr(qt_app_module, "PRESETS_DIR", str(tmp_path / "presets")) + monkeypatch.setattr(qt_app_module, "FX_PRESETS_DIR", str(tmp_path / "presets" / "fx")) + monkeypatch.setattr(qt_app_module, "KokoroEngine", StubEngine) + # exist_ok: a test combining this fixture with tests/conftest.py's + # tts_app fixture shares the same tmp_path (pytest's tmp_path is + # function-scoped, so both fixtures see the identical directory) and + # tts_app also creates this dir - whichever fixture runs second must not + # fail on it already existing. That existing fixture can't be edited + # (see the plan's "zero edits to any existing test file"), so this side + # tolerates it instead. + (tmp_path / "custom_voices").mkdir(exist_ok=True) + + # Modal dialogs (QMessageBox.exec/QInputDialog.exec/...) block on the + # "offscreen" platform exactly like they would on a real display - patch + # the statics globally (same class object every dock module imports) so + # no test hangs waiting for a click that can never happen. Individual + # tests can re-monkeypatch a specific return value (e.g. a preset name) + # on top of this, since `monkeypatch` is shared across one test's fixtures. + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: None)) + monkeypatch.setattr(QMessageBox, "critical", staticmethod(lambda *a, **k: None)) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: None)) + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)) + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("", False))) + monkeypatch.setattr(QFileDialog, "getOpenFileName", staticmethod(lambda *a, **k: ("", ""))) + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: "")) + + app = qt_app_module.QtTTSApp() + qtbot.addWidget(app) + yield app + app.close() diff --git a/tests/gui_qt/test_qt_config_assembly.py b/tests/gui_qt/test_qt_config_assembly.py new file mode 100644 index 0000000..0d44247 --- /dev/null +++ b/tests/gui_qt/test_qt_config_assembly.py @@ -0,0 +1,66 @@ +"""Config-dict contract parity between the Qt and Tk frontends. + +`tests/test_gui_config_assembly.py` is the existing source of truth for what +gui.py's `start_conversion` actually assembles. This file asserts Qt's +`_assemble_config()` produces the identical key set - both against the +mirrored constants in kokoro_gui/qt/spec.py *and* against a live capture of +Tk's own assembled dict via the existing `tts_app` fixture - so a future edit +to either frontend's config dict that isn't mirrored in the other is a test +failure, not a silent drift. +""" +from kokoro_gui.qt import spec + + +def test_assembled_config_matches_mirrored_spec_keys(qt_app): + config = qt_app._assemble_config() + expected = set(spec.GENERATION_BASE_KEYS) | set(spec.FX_PRESET_KEYS) + assert set(config.keys()) == expected + + +def test_assembled_config_omits_fx_keys_when_apply_fx_off(qt_app): + qt_app.generation_dock.apply_fx_check.setChecked(False) + config = qt_app._assemble_config() + assert set(config.keys()) == set(spec.GENERATION_BASE_KEYS) + assert "reverb_enabled" not in config + assert "gain_db" not in config + + +def test_assembled_config_time_id_is_timecode(qt_app): + import re + config = qt_app._assemble_config() + assert re.match(r"^\d{14}$", config["time_id"]) + + +def test_assembled_config_matches_tk_live_capture(tts_app, qt_app): + # tts_app must be resolved before qt_app: both share one tmp_path (pytest's + # tmp_path fixture is function-scoped) and each fixture creates + # "custom_voices" in it - tts_app's own mkdir() (unmodifiable, see + # tests/conftest.py) has no exist_ok, so it must run first; qt_app's does + # tolerate the directory already existing (see tests/gui_qt/conftest.py). + """Cross-frontend parity: whatever key set gui.py's start_conversion + actually builds (captured live through the Tk `tts_app` fixture + + StubEngine, exactly like tests/test_gui_config_assembly.py does) must + equal the key set Qt assembles - not just what spec.py claims.""" + tts_app.text_entry.insert("1.0", "Hello world") + tts_app.start_conversion() + + assert tts_app.engine.start_conversion.called + tk_text, tk_config = tts_app.engine.start_conversion.call_args[0] + + qt_config = qt_app._assemble_config() + + assert set(tk_config.keys()) == set(qt_config.keys()) + + +def test_generation_dock_state_covers_base_keys_minus_settings_owned(qt_app): + """Everything _assemble_config adds on top of the Generation dock's own + get_state() (engine_id/time_id/lexicon) is intentionally settings-owned, + not dock-owned - see app.py's _assemble_config.""" + state = qt_app.generation_dock.get_state() + settings_owned = {"engine_id", "time_id", "lexicon"} + assert set(state.keys()) | settings_owned == set(spec.GENERATION_BASE_KEYS) + + +def test_fx_dock_state_covers_all_fx_preset_keys(qt_app): + state = qt_app.fx_dock.get_state() + assert set(state.keys()) == set(spec.FX_PRESET_KEYS) diff --git a/tests/gui_qt/test_qt_engine_backend.py b/tests/gui_qt/test_qt_engine_backend.py new file mode 100644 index 0000000..b502cbf --- /dev/null +++ b/tests/gui_qt/test_qt_engine_backend.py @@ -0,0 +1,53 @@ +"""Engine-picker switch behavior - including the fix for gui.py's known gap +(gui.py:530-538: switch_engine doesn't re-render the Generation tab's +schema-driven fields). See app.py's `switch_engine` docstring.""" +from kokoro_gui.engines import registry as engine_registry + + +def test_dummy_and_kokoro_both_registered(): + assert {"kokoro", "dummy"} <= set(engine_registry.list_engines()) + + +def test_kokoro_backend_shows_mixing_dock(qt_app): + assert qt_app.backend.id == "kokoro" + assert qt_app.mixing_dock is not None + + +def test_switch_to_dummy_hides_mixing_dock(qt_app): + qt_app.switch_engine("dummy") + assert qt_app.backend.id == "dummy" + assert qt_app.mixing_dock is None + + +def test_switch_back_to_kokoro_shows_mixing_dock_again(qt_app): + qt_app.switch_engine("dummy") + qt_app.switch_engine("kokoro") + assert qt_app.backend.id == "kokoro" + assert qt_app.mixing_dock is not None + + +def test_switch_engine_rebuilds_schema_form_for_new_backend(qt_app): + """The actual Qt-specific improvement over Tk: the Generation dock's + schema-driven fields must reflect the newly-active backend's schema, not + stay frozen at whatever the first backend built (gui.py's own gap).""" + original_form = qt_app.generation_dock.schema_form + qt_app.switch_engine("dummy") + assert qt_app.generation_dock.schema_form is not original_form + + dummy_schema_keys = {f.key for f in qt_app.backend.get_config_schema()} + assert "lexicon" not in dummy_schema_keys # dummy backend has no lexicon field + rendered_keys = set(qt_app.generation_dock.schema_form.values().keys()) + assert rendered_keys == dummy_schema_keys - {"lexicon"} + + +def test_switch_engine_refused_while_job_running(qt_app): + qt_app.cancel_btn.setEnabled(True) # simulate a job in flight + original_backend_id = qt_app.backend.id + qt_app.switch_engine("dummy") + assert qt_app.backend.id == original_backend_id + + +def test_engine_picker_combo_lists_all_registered_engines(qt_app): + items = [qt_app.engine_picker.itemText(i) for i in range(qt_app.engine_picker.count())] + expected = {engine_registry.get_display_name(eid) for eid in engine_registry.list_engines()} + assert set(items) == expected diff --git a/tests/gui_qt/test_qt_lexicon.py b/tests/gui_qt/test_qt_lexicon.py new file mode 100644 index 0000000..6f3fff7 --- /dev/null +++ b/tests/gui_qt/test_qt_lexicon.py @@ -0,0 +1,44 @@ +"""Lexicon CRUD roundtrips into settings["lexicon"] with eager save (bypasses +the debounced autosave, same as kokoro_gui/ui/lexicon_tab.py).""" +import json + + +def test_add_rule_updates_settings_and_saves_eagerly(qt_app): + import kokoro_gui.qt.app as qt_app_module + qt_app.lexicon_dock.orig_edit.setText("API") + qt_app.lexicon_dock.replace_edit.setText("A P I") + qt_app.lexicon_dock.add_rule() + + assert qt_app.settings["lexicon"] == {"API": "A P I"} + # Eager save - config_qt.json exists immediately, no need to advance a timer. + with open(qt_app_module.CONFIG_FILE, encoding="utf-8") as f: + data = json.load(f) + assert data["lexicon"] == {"API": "A P I"} + + +def test_add_rule_rejects_empty_original(qt_app): + qt_app.lexicon_dock.orig_edit.setText("") + qt_app.lexicon_dock.replace_edit.setText("something") + qt_app.lexicon_dock.add_rule() + assert qt_app.settings.get("lexicon", {}) == {} + + +def test_delete_rule_removes_entry(qt_app): + qt_app.settings["lexicon"] = {"foo": "bar", "baz": "qux"} + qt_app.lexicon_dock.refresh_list() + qt_app.lexicon_dock.delete_rule("foo") + assert qt_app.settings["lexicon"] == {"baz": "qux"} + + +def test_add_rule_clears_input_fields(qt_app): + qt_app.lexicon_dock.orig_edit.setText("x") + qt_app.lexicon_dock.replace_edit.setText("y") + qt_app.lexicon_dock.add_rule() + assert qt_app.lexicon_dock.orig_edit.text() == "" + assert qt_app.lexicon_dock.replace_edit.text() == "" + + +def test_lexicon_feeds_into_assembled_config(qt_app): + qt_app.settings["lexicon"] = {"TTS": "Tee Tee Ess"} + config = qt_app._assemble_config() + assert config["lexicon"] == {"TTS": "Tee Tee Ess"} diff --git a/tests/gui_qt/test_qt_presets.py b/tests/gui_qt/test_qt_presets.py new file mode 100644 index 0000000..6b88d21 --- /dev/null +++ b/tests/gui_qt/test_qt_presets.py @@ -0,0 +1,107 @@ +"""Generation + FX preset save/load (presets/*.json, presets/fx/*.json - +shared with the Tk frontend, see spec.py's module docstring), and the +cross-frontend load that proves that sharing actually holds.""" +import json +import os + +from PySide6.QtWidgets import QInputDialog + + +def _stub_get_text(monkeypatch, value): + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: (value, True))) + + +def test_save_generation_preset_writes_expected_keys(qt_app, monkeypatch): + _stub_get_text(monkeypatch, "MyPreset") + qt_app.generation_dock.schema_form.set_values({"voice": "af_bella", "speed": 1.4}) + qt_app.generation_dock._save_preset_dialog() + + import kokoro_gui.qt.app as qt_app_module + fpath = os.path.join(qt_app_module.PRESETS_DIR, "MyPreset.json") + assert os.path.exists(fpath) + with open(fpath, encoding="utf-8") as f: + data = json.load(f) + assert data["voice"] == "af_bella" + assert data["speed"] == 1.4 + assert set(data.keys()) == {"voice", "speed", "volume", "pitch", "split_pattern", + "normalize", "trim", "format", "apply_fx", "fx_preset"} + + +def test_load_generation_preset_applies_values(qt_app, monkeypatch): + _stub_get_text(monkeypatch, "SpeedyBella") + qt_app.generation_dock.schema_form.set_values({"voice": "af_bella", "speed": 1.6}) + qt_app.generation_dock._save_preset_dialog() + + qt_app.generation_dock.schema_form.set_values({"voice": "af_heart", "speed": 1.0}) + qt_app.generation_dock.refresh_presets() + qt_app.generation_dock._on_preset_selected("SpeedyBella") + + state = qt_app.generation_dock.get_state() + assert state["voice"] == "af_bella" + assert state["speed"] == 1.6 + + +def test_save_fx_preset_writes_all_43_keys(qt_app, monkeypatch): + from kokoro_gui.qt import spec + _stub_get_text(monkeypatch, "MyFX") + qt_app.fx_dock._value_widgets["gain_db"].setValue(3.0) + qt_app.fx_dock._save_preset_dialog() + + import kokoro_gui.qt.app as qt_app_module + fpath = os.path.join(qt_app_module.FX_PRESETS_DIR, "MyFX.json") + assert os.path.exists(fpath) + with open(fpath, encoding="utf-8") as f: + data = json.load(f) + assert set(data.keys()) == set(spec.FX_PRESET_KEYS) + assert data["gain_db"] == 3.0 + + +def test_load_fx_preset_applies_values_and_syncs_gen_combo(qt_app, monkeypatch): + _stub_get_text(monkeypatch, "LoudFX") + qt_app.fx_dock._value_widgets["gain_db"].setValue(9.0) + qt_app.fx_dock._save_preset_dialog() + + qt_app.fx_dock._value_widgets["gain_db"].setValue(0.0) + qt_app.fx_dock.load_preset("LoudFX") + + assert qt_app.fx_dock._value_widgets["gain_db"].value() == 9.0 + assert qt_app.generation_dock.fx_preset_combo.currentText() == "LoudFX" + + +def test_preset_saved_by_tk_loads_correctly_in_qt(tts_app, qt_app, monkeypatch): + """The shared-presets design decision (see the plan): a preset written by + the Tk frontend must load correctly through Qt, and vice versa, since + both point at the same presets/*.json directory for a shared tmp_path. + + tts_app must be requested before qt_app - see the comment in + test_qt_config_assembly.py's equivalent test.""" + import gui + + class FakeDialog: + def __init__(self, *a, **kw): + pass + + def get_input(self): + return "FromTk" + + monkeypatch.setattr(gui.ctk, "CTkInputDialog", FakeDialog) + + tts_app.voice_var.set("af_bella") + tts_app.speed_var.set(1.25) + tts_app.save_preset_dialog() + + qt_app.generation_dock.refresh_presets() + qt_app.generation_dock._on_preset_selected("FromTk") + + state = qt_app.generation_dock.get_state() + assert state["voice"] == "af_bella" + assert state["speed"] == 1.25 + + +def test_fx_preset_saved_by_qt_loads_correctly_in_tk(tts_app, qt_app, monkeypatch): + _stub_get_text(monkeypatch, "FromQt") + qt_app.fx_dock._value_widgets["gain_db"].setValue(4.5) + qt_app.fx_dock._save_preset_dialog() + + tts_app.load_fx_preset("FromQt") + assert tts_app.gain_db.get() == 4.5 diff --git a/tests/gui_qt/test_qt_settings.py b/tests/gui_qt/test_qt_settings.py new file mode 100644 index 0000000..3f8214e --- /dev/null +++ b/tests/gui_qt/test_qt_settings.py @@ -0,0 +1,68 @@ +"""config_qt.json load/save roundtrip, debounce, and dock-state persistence.""" +import json +import os + +from kokoro_gui.qt import settings as qt_settings + + +def test_save_settings_writes_config_qt_json(qt_app): + import kokoro_gui.qt.app as qt_app_module + qt_app.generation_dock.filename_edit.setText("my_output") + qt_app.save_settings() + + assert os.path.exists(qt_app_module.CONFIG_FILE) + with open(qt_app_module.CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + assert data["filename"] == "my_output" + + +def test_save_settings_persists_fx_state(qt_app): + import kokoro_gui.qt.app as qt_app_module + qt_app.fx_dock._value_widgets["gain_db"].setValue(6.5) + qt_app.save_settings() + + with open(qt_app_module.CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + assert data["gain_db"] == 6.5 + + +def test_save_settings_stores_dock_state_and_geometry(qt_app): + qt_app.save_settings() + assert qt_app.settings["dock_state"] + assert qt_app.settings["geometry"] + + +def test_schedule_save_debounces(qt_app, qtbot): + calls = [] + qt_app.save_settings = lambda: calls.append(1) + qt_app._save_timer.timeout.disconnect() + qt_app._save_timer.timeout.connect(qt_app.save_settings) + + qt_app.schedule_save() + qt_app.schedule_save() # restarting the timer shouldn't double-fire + qtbot.wait(1300) + assert calls == [1] + + +def test_load_settings_defaults_when_no_file(tmp_path): + cfg = str(tmp_path / "does_not_exist.json") + settings = qt_settings.load_settings(cfg) + from kokoro_gui.qt import spec + assert settings == spec.SETTINGS_DEFAULTS + + +def test_load_settings_merges_over_defaults(tmp_path): + cfg = tmp_path / "config_qt.json" + cfg.write_text(json.dumps({"voice": "af_bella"}), encoding="utf-8") + settings = qt_settings.load_settings(str(cfg)) + assert settings["voice"] == "af_bella" + assert settings["speed"] == 1.0 # untouched default survives the merge + + +def test_save_then_load_roundtrip(tmp_path): + cfg = str(tmp_path / "config_qt.json") + data = {"voice": "af_bella", "speed": 1.3} + qt_settings.save_settings(cfg, data) + loaded = qt_settings.load_settings(cfg) + assert loaded["voice"] == "af_bella" + assert loaded["speed"] == 1.3 From 9c4c4241828d134540c89362d3073a0485512db5 Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 16:33:20 -0600 Subject: [PATCH 06/44] The Tk (CustomTkinter) frontend has been fully removed; the app is now Qt-only. Summary: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed gui.py, main.py (old Tk entry point), kokoro_gui/ui/ (fx/generation/lexicon/mixing tab builders) config.json (Tk's autosave file — dead now) requirements-qt.txt / requirements-qt-test.txt (merged into the main requirement files, see below) Tk-only tests: tests/test_gui_config_assembly.py, tests/test_gui_handlers.py, tests/test_gui_settings.py Cross-frontend parity tests that only made sense with two GUIs (test_assembled_config_matches_tk_live_capture, the Tk↔Qt preset round-trip tests, four tts_app-based tests in tests/test_engine_backend.py) — all fully superseded by tests/gui_qt/'s own coverage Renamed / promoted main_qt.py → main.py — the sole entry point now (python main.py / run.bat) PySide6 moved from optional requirements-qt.txt into requirements.txt as a regular dependency; pytest-qt likewise into requirements-test.txt Updated tests/conftest.py — dropped the tts_app/Tcl-Tk fixture plumbing, kept StubEngine (still used by the Qt test suite) .github/workflows/tests.yml — no more Xvfb install/wrapping (Qt's QT_QPA_PLATFORM=offscreen never needed a display; Xvfb was only for the old Tk windows) README.md, ROADMAP.md, CLAUDE.md, PLAN_qt_and_engine_abstraction.md — rewritten to describe Qt as the only frontend, with the plan's checklist recording the retirement Stale gui.py/CustomTkinter comments and docstrings cleaned up across kokoro_gui/qt/*.py and kokoro_gui/engines/*.py Verified Full pytest suite: 131 passed, 0 failed main.py launches QtTTSApp correctly with the real KokoroEngine (smoke-tested headless) --- .github/workflows/tests.yml | 39 +- README.md | 14 +- gui.py | 916 ------------------------ kokoro_gui/engines/__init__.py | 3 +- kokoro_gui/engines/dummy.py | 7 +- kokoro_gui/engines/kokoro.py | 8 +- kokoro_gui/qt/__init__.py | 11 +- kokoro_gui/qt/app.py | 42 +- kokoro_gui/qt/docks/fx_dock.py | 6 +- kokoro_gui/qt/docks/generation_dock.py | 14 +- kokoro_gui/qt/docks/lexicon_dock.py | 3 +- kokoro_gui/qt/docks/mixing_dock.py | 8 +- kokoro_gui/qt/schema_form.py | 2 +- kokoro_gui/qt/settings.py | 7 +- kokoro_gui/qt/signals.py | 34 +- kokoro_gui/qt/spec.py | 47 +- kokoro_gui/ui/__init__.py | 6 - kokoro_gui/ui/fx_tab.py | 368 ---------- kokoro_gui/ui/generation_tab.py | 298 -------- kokoro_gui/ui/lexicon_tab.py | 80 --- kokoro_gui/ui/mixing_tab.py | 250 ------- main.py | 16 +- main_qt.py | 16 - requirements-qt-test.txt | 1 - requirements-qt.txt | 1 - requirements-test.txt | 1 + requirements.txt | 3 +- tests/conftest.py | 55 +- tests/gui_qt/conftest.py | 7 - tests/gui_qt/test_qt_config_assembly.py | 33 +- tests/gui_qt/test_qt_engine_backend.py | 12 +- tests/gui_qt/test_qt_lexicon.py | 2 +- tests/gui_qt/test_qt_presets.py | 43 +- tests/test_engine_backend.py | 41 +- tests/test_gui_config_assembly.py | 130 ---- tests/test_gui_handlers.py | 118 --- tests/test_gui_settings.py | 49 -- 37 files changed, 136 insertions(+), 2555 deletions(-) delete mode 100644 gui.py delete mode 100644 kokoro_gui/ui/__init__.py delete mode 100644 kokoro_gui/ui/fx_tab.py delete mode 100644 kokoro_gui/ui/generation_tab.py delete mode 100644 kokoro_gui/ui/lexicon_tab.py delete mode 100644 kokoro_gui/ui/mixing_tab.py delete mode 100644 main_qt.py delete mode 100644 requirements-qt-test.txt delete mode 100644 requirements-qt.txt delete mode 100644 tests/test_gui_config_assembly.py delete mode 100644 tests/test_gui_handlers.py delete mode 100644 tests/test_gui_settings.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8c97f7e..5208b50 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,11 +8,11 @@ on: jobs: test: # Playback goes through playback.py (sounddevice/PortAudio) instead of - # the Windows-only `winsound` module, so kokoro_engine.py/gui.py no - # longer force Windows-only. ubuntu-latest additionally needs: - # - libportaudio2 (system PortAudio lib `sounddevice` dlopens) - # - Xvfb (the GUI suite builds real Tk windows - tests/conftest.py's - # `tts_app` fixture - which needs a display on headless Linux) + # the Windows-only `winsound` module, so kokoro_engine.py no longer + # forces Windows-only. ubuntu-latest additionally needs libportaudio2 + # (system PortAudio lib `sounddevice` dlopens). The GUI suite is Qt-only + # now (kokoro_gui/qt/) and runs headless via QT_QPA_PLATFORM=offscreen, + # so no Xvfb/virtual display is needed on either OS. # macos-latest is left out for now (unverified) - see ROADMAP.md's # "CI expansion" item. strategy: @@ -27,38 +27,23 @@ jobs: with: python-version: "3.11" - - name: Install PortAudio + Xvfb (Linux) + - name: Install PortAudio (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libportaudio2 xvfb + run: sudo apt-get update && sudo apt-get install -y libportaudio2 - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-test.txt - pip install -r requirements-qt.txt - pip install -r requirements-qt-test.txt - # requirements-qt*.txt (PySide6/pytest-qt) are optional for a local - # dev/user who only wants the Tk app (tests/gui_qt/conftest.py - # self-skips via pytest.importorskip when they're absent), but CI - # always installs them so the Qt suite (workstream 3a of - # PLAN_qt_and_engine_abstraction.md) actually runs on every PR. - - name: Run fast test suite (Linux) - if: runner.os == 'Linux' + - name: Run fast test suite env: QT_QPA_PLATFORM: offscreen - run: xvfb-run -a pytest + run: pytest # Runs the mocked-pipeline suite only (pytest.ini already sets # `-m "not integration"` by default). No eSpeak NG or model # download needed. The real-synthesis integration suite # (`pytest -m integration tests/integration`) is intentionally - # left out of CI - it's slow and pulls model weights. Wrapped in - # xvfb-run so the Tk-based GUI tests have a display to attach to; - # QT_QPA_PLATFORM=offscreen makes the Qt suite not need one (Qt's - # offscreen platform plugin works with or without Xvfb present). - - - name: Run fast test suite (Windows) - if: runner.os != 'Linux' - env: - QT_QPA_PLATFORM: offscreen - run: pytest + # left out of CI - it's slow and pulls model weights. + # QT_QPA_PLATFORM=offscreen lets the Qt suite (tests/gui_qt/) build + # real widgets with no display attached. diff --git a/README.md b/README.md index bce72a4..899e911 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e ## New in Beta 3.2.0 -- **Experimental Qt frontend:** `python main_qt.py` launches a PySide6-based dockable-panel shell alongside the existing CustomTkinter app (`python main.py`/`run.bat`, still the default). Optional install: `pip install -r requirements-qt.txt`. Presets (`presets/*.json`, `presets/fx/*.json`) are shared between both frontends; app settings are not (`config_qt.json` vs. `config.json`). See [PLAN_qt_and_engine_abstraction.md](PLAN_qt_and_engine_abstraction.md) for the roadmap this is part of. -- **Modular codebase:** `gui.py` and `kokoro_engine.py` are now split into a `kokoro_gui/engine/` and `kokoro_gui/ui/` package by feature area (text extraction, caching, lexicon, presets, voice mixing, per-tab UI builders), making the codebase easier to navigate and extend. No user-facing behavior change. -- **Cross-Platform Audio Playback:** Preview and JIT playback now go through `sounddevice`/`soundfile` instead of the Windows-only `winsound` module, removing a hard Windows dependency from `kokoro_engine.py`/`gui.py`. +- **Qt frontend, now the only frontend:** `python main.py`/`run.bat` launches a PySide6-based dockable-panel shell (`kokoro_gui/qt/`). The previous CustomTkinter app (`gui.py`) has been retired now that Qt reached parity — see [PLAN_qt_and_engine_abstraction.md](PLAN_qt_and_engine_abstraction.md) for the migration this completed. PySide6 is a regular dependency in `requirements.txt`. +- **Modular codebase:** `kokoro_engine.py` is a slim core module backed by a `kokoro_gui/engine/` package split out by feature area (text extraction, caching, lexicon, presets, voice mixing), making the codebase easier to navigate and extend. +- **Cross-Platform Audio Playback:** Preview and JIT playback now go through `sounddevice`/`soundfile` instead of the Windows-only `winsound` module, removing a hard Windows dependency from `kokoro_engine.py`. ## New in 3.1.0 @@ -85,6 +85,8 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e - **Windows:** Double-click `run.bat` or run `python main.py` - **Other:** Run `python main.py` + This launches the PySide6 (Qt) frontend — a dockable-panel shell with a Generation, FX, Mixing, and Lexicon dock, plus an engine picker in the toolbar. + 2. **Configure your conversion:** - Choose your input method (Direct Text or Load File). - Select a voice and language from the dropdown menus. @@ -99,7 +101,7 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e ## Running Tests -The project has a `pytest` suite under `tests/` covering both `gui.py` and `kokoro_engine.py`. Playback no longer forces Windows-only (see [`playback.py`](playback.py)), and CI (`.github/workflows/tests.yml`) now runs the suite on both `windows-latest` and `ubuntu-latest` (the Linux leg installs `libportaudio2` for `sounddevice` and runs under `xvfb-run` since the GUI tests build real Tk windows). `macos-latest` isn't set up yet. +The project has a `pytest` suite under `tests/` covering both the Qt frontend (`tests/gui_qt/`) and `kokoro_engine.py`. Playback no longer forces Windows-only (see [`playback.py`](playback.py)), and CI (`.github/workflows/tests.yml`) now runs the suite on both `windows-latest` and `ubuntu-latest` (the Linux leg installs `libportaudio2` for `sounddevice`; the Qt suite runs headless via `QT_QPA_PLATFORM=offscreen`, no virtual display needed). `macos-latest` isn't set up yet. 1. **Install test dependencies** (on top of `requirements.txt`): ```bash @@ -120,13 +122,13 @@ The project has a `pytest` suite under `tests/` covering both `gui.py` and `koko ### CI -[.github/workflows/tests.yml](.github/workflows/tests.yml) runs step 2 above (`pytest`) on push/PR against `windows-latest` and `ubuntu-latest` (the Linux leg additionally installs `libportaudio2` and runs under `xvfb-run`, as noted above) after installing `requirements.txt` + `requirements-test.txt`. The fast suite needs no eSpeak NG or model download, so it's safe to run on every push/PR. The integration suite is slow and pulls model weights, so it's intentionally left out as a manual/opt-in run rather than part of the default pipeline. +[.github/workflows/tests.yml](.github/workflows/tests.yml) runs step 2 above (`pytest`) on push/PR against `windows-latest` and `ubuntu-latest` (the Linux leg additionally installs `libportaudio2`, as noted above) after installing `requirements.txt` + `requirements-test.txt`. The fast suite needs no eSpeak NG or model download, so it's safe to run on every push/PR. The integration suite is slow and pulls model weights, so it's intentionally left out as a manual/opt-in run rather than part of the default pipeline. ## Technologies Used - **[Kokoro](https://github.com/hexgrad/kokoro):** The core TTS engine. - **[Pedalboard](https://github.com/spotify/pedalboard):** Audio effects processing. -- **Customtkinter:** For the graphical user interface. +- **[PySide6](https://doc.qt.io/qtforpython/):** For the graphical user interface. - **PyTorch:** Deep learning backend. - **SoundFile:** For writing high-quality audio files. - **PyPDF & EbookLib:** For parsing documents. diff --git a/gui.py b/gui.py deleted file mode 100644 index 7580fb0..0000000 --- a/gui.py +++ /dev/null @@ -1,916 +0,0 @@ -import os -import time -import json -import playback -import customtkinter as ctk -from tkinter import filedialog, messagebox -import threading -from kokoro_engine import KokoroEngine - -from kokoro_gui.engines import registry as engine_registry -from kokoro_gui.ui import FXTabMixin, GenerationTabMixin, LexiconTabMixin, MixingTabMixin - -# Set Default Appearance (will be overridden by settings) -ctk.set_appearance_mode("Dark") -ctk.set_default_color_theme("blue") - -CONFIG_FILE = "config.json" -PRESETS_DIR = "presets" -FX_PRESETS_DIR = os.path.join(PRESETS_DIR, "fx") - -class TTSApp(FXTabMixin, GenerationTabMixin, LexiconTabMixin, MixingTabMixin, ctk.CTk): - def __init__(self): - super().__init__() - - self.title("Kokoro TTS GUI") - self.geometry("700x900") - self.protocol("WM_DELETE_WINDOW", self.on_close) - - # Ensure presets dirs exist - if not os.path.exists(PRESETS_DIR): - os.makedirs(PRESETS_DIR) - if not os.path.exists(FX_PRESETS_DIR): - os.makedirs(FX_PRESETS_DIR) - - # Load Settings - self.settings = self.load_settings() - self.apply_settings() - - # Initialize Engine - self.engine = KokoroEngine() - self.engine.on_progress = self.on_engine_progress - self.engine.on_status = self.on_engine_status - self.engine.on_finish = self.on_engine_finish - - # Backend abstraction (PLAN_qt_and_engine_abstraction.md workstream 1): - # a thin, engine-agnostic wrapper around self.engine used for its - # config schema and capability flags. It doesn't yet replace any of - # the direct self.engine.* calls below - those keep talking to - # KokoroEngine exactly as before. - self.backend = engine_registry.get_engine("kokoro", engine=self.engine) - - # Auto-save timer - self.save_timer = None - - # Variables - self.file_path_var = ctk.StringVar() - - self.LANGUAGES = { - "American English": "a", - "British English": "b", - "Spanish": "e", - "French": "f", - "Italian": "i", - "Portuguese": "p", - "Japanese": "j", - "Chinese": "z", - } - - self.VOICE_DB = { - "a": ["af_heart", "af_alloy", "af_aoede", "af_bella", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", "af_sky", "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_michael", "am_onyx", "am_puck", "am_santa"], - "b": ["bf_alice", "bf_emma", "bf_isabella", "bf_lily", "bm_daniel", "bm_fable", "bm_george", "bm_lewis"], - "e": ["ef_dora", "em_alex", "em_santa"], - "f": ["ff_siwis"], - "i": ["if_sara", "im_nicola"], - "p": ["pf_dora", "pm_alex"], - "j": ["jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro"], - "z": ["zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zm_yunjian"] - } - - self.lang_var = ctk.StringVar(value=self.settings.get("lang_code", "a")) - - # Determine initial standard voices based on lang - self.standard_voices = self.VOICE_DB.get(self.lang_var.get(), []) - if not self.standard_voices: # Fallback - self.standard_voices = self.VOICE_DB["a"] - - self.voice_var = ctk.StringVar(value=self.settings.get("voice", "af_heart")) - self.filename_var = ctk.StringVar(value=self.settings.get("filename", "output")) - self.output_format_var = ctk.StringVar(value=self.settings.get("format", "wav")) - self.output_dir_var = ctk.StringVar(value=self.settings.get("out_dir", "audio_output")) - self.speed_var = ctk.DoubleVar(value=self.settings.get("speed", 1.0)) - self.volume_var = ctk.DoubleVar(value=self.settings.get("volume", 1.0)) - self.pitch_var = ctk.DoubleVar(value=self.settings.get("pitch", 0.0)) - self.num_threads_var = ctk.IntVar(value=self.settings.get("num_threads", 1)) - self.split_pattern_var = ctk.StringVar(value=self.settings.get("split_pattern", r"\n+")) - - self.separate_files = ctk.BooleanVar(value=self.settings.get("separate", True)) - self.combine_post = ctk.BooleanVar(value=self.settings.get("combine", True)) - self.export_subtitles = ctk.BooleanVar(value=self.settings.get("export_subtitles", False)) - self.caching_enabled = ctk.BooleanVar(value=self.settings.get("caching", True)) - self.jit_enabled = ctk.BooleanVar(value=self.settings.get("jit_enabled", False)) - self.normalize_audio = ctk.BooleanVar(value=self.settings.get("normalize", False)) - self.trim_silence = ctk.BooleanVar(value=self.settings.get("trim", False)) - self.apply_fx_var = ctk.BooleanVar(value=self.settings.get("apply_fx", True)) - self.timecode_format = "%Y%m%d%H%M%S" - - # FX Variables - self.reverb_enabled = ctk.BooleanVar(value=self.settings.get("reverb_enabled", False)) - self.reverb_room_size = ctk.DoubleVar(value=self.settings.get("reverb_room_size", 0.5)) - self.reverb_wet_level = ctk.DoubleVar(value=self.settings.get("reverb_wet_level", 0.3)) - - self.eq_bass = ctk.DoubleVar(value=self.settings.get("eq_bass", 0.0)) - self.eq_treble = ctk.DoubleVar(value=self.settings.get("eq_treble", 0.0)) - - self.comp_enabled = ctk.BooleanVar(value=self.settings.get("comp_enabled", False)) - self.comp_threshold = ctk.DoubleVar(value=self.settings.get("comp_threshold", -20.0)) - self.comp_ratio = ctk.DoubleVar(value=self.settings.get("comp_ratio", 4.0)) - self.comp_attack = ctk.DoubleVar(value=self.settings.get("comp_attack", 1.0)) - self.comp_release = ctk.DoubleVar(value=self.settings.get("comp_release", 100.0)) - - # Reverb Extended - self.reverb_damping = ctk.DoubleVar(value=self.settings.get("reverb_damping", 0.5)) - self.reverb_dry_level = ctk.DoubleVar(value=self.settings.get("reverb_dry_level", 1.0)) - self.reverb_width = ctk.DoubleVar(value=self.settings.get("reverb_width", 1.0)) - - # New FX - # Guitar - self.distortion_enabled = ctk.BooleanVar(value=self.settings.get("distortion_enabled", False)) - self.distortion_drive = ctk.DoubleVar(value=self.settings.get("distortion_drive", 25.0)) - - self.chorus_enabled = ctk.BooleanVar(value=self.settings.get("chorus_enabled", False)) - self.chorus_rate = ctk.DoubleVar(value=self.settings.get("chorus_rate", 1.0)) - self.chorus_depth = ctk.DoubleVar(value=self.settings.get("chorus_depth", 0.25)) - self.chorus_mix = ctk.DoubleVar(value=self.settings.get("chorus_mix", 0.5)) - - self.phaser_enabled = ctk.BooleanVar(value=self.settings.get("phaser_enabled", False)) - self.phaser_rate = ctk.DoubleVar(value=self.settings.get("phaser_rate", 1.0)) - self.phaser_depth = ctk.DoubleVar(value=self.settings.get("phaser_depth", 0.5)) - self.phaser_mix = ctk.DoubleVar(value=self.settings.get("phaser_mix", 0.5)) - - self.clipping_enabled = ctk.BooleanVar(value=self.settings.get("clipping_enabled", False)) - self.clipping_thresh = ctk.DoubleVar(value=self.settings.get("clipping_thresh", -6.0)) - - # Quality - self.bitcrush_enabled = ctk.BooleanVar(value=self.settings.get("bitcrush_enabled", False)) - self.bitcrush_depth = ctk.DoubleVar(value=self.settings.get("bitcrush_depth", 8.0)) - - self.gsm_enabled = ctk.BooleanVar(value=self.settings.get("gsm_enabled", False)) - - # Filters - self.highpass_enabled = ctk.BooleanVar(value=self.settings.get("highpass_enabled", False)) - self.highpass_freq = ctk.DoubleVar(value=self.settings.get("highpass_freq", 50.0)) - - self.lowpass_enabled = ctk.BooleanVar(value=self.settings.get("lowpass_enabled", False)) - self.lowpass_freq = ctk.DoubleVar(value=self.settings.get("lowpass_freq", 10000.0)) - - # Spatial - self.delay_enabled = ctk.BooleanVar(value=self.settings.get("delay_enabled", False)) - self.delay_time = ctk.DoubleVar(value=self.settings.get("delay_time", 0.5)) - self.delay_feedback = ctk.DoubleVar(value=self.settings.get("delay_feedback", 0.0)) - self.delay_mix = ctk.DoubleVar(value=self.settings.get("delay_mix", 0.5)) - - # Pitch - self.pitch_shift_enabled = ctk.BooleanVar(value=self.settings.get("pitch_shift_enabled", False)) - self.pitch_shift_semitones = ctk.DoubleVar(value=self.settings.get("pitch_shift_semitones", 0.0)) - - # Dynamics - self.limiter_enabled = ctk.BooleanVar(value=self.settings.get("limiter_enabled", False)) - self.limiter_threshold = ctk.DoubleVar(value=self.settings.get("limiter_threshold", -1.0)) - self.limiter_release = ctk.DoubleVar(value=self.settings.get("limiter_release", 100.0)) - - self.gain_enabled = ctk.BooleanVar(value=self.settings.get("gain_enabled", False)) - self.gain_db = ctk.DoubleVar(value=self.settings.get("gain_db", 0.0)) - - # Mixing Variables - self.mix_lang_a_var = ctk.StringVar(value="a") - self.mix_lang_b_var = ctk.StringVar(value="a") - self.preview_lang_var = ctk.StringVar(value="a") - - self.mix_voice_a_var = ctk.StringVar(value=self.VOICE_DB["a"][0]) - self.mix_voice_b_var = ctk.StringVar(value=self.VOICE_DB["a"][1]) - self.mix_ratio_var = ctk.DoubleVar(value=0.5) - self.mix_op_var = ctk.StringVar(value="mix") - self.mix_name_var = ctk.StringVar() - - # Setup Auto-save Traces - self.setup_autosave() - - self.create_widgets() - - # Init Pipeline - self.status_label.configure(text="Initializing engine...") - self.engine.worker.run_coro(self.engine.init_pipeline_async(self.lang_var.get())) - - def get_all_voices(self, lang_code=None): - if lang_code is None: - lang_code = self.lang_var.get() - - standard = self.VOICE_DB.get(lang_code, []) - custom = [] - if os.path.exists("custom_voices"): - custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] - return sorted(standard + custom) - - - def setup_autosave(self): - vars_to_trace = [ - self.lang_var, - self.voice_var, self.filename_var, self.output_format_var, self.output_dir_var, - self.speed_var, self.volume_var, self.pitch_var, - self.num_threads_var, self.split_pattern_var, - self.separate_files, self.combine_post, self.export_subtitles, self.caching_enabled, - self.normalize_audio, self.trim_silence, self.apply_fx_var, - self.reverb_enabled, self.reverb_room_size, self.reverb_wet_level, self.reverb_damping, self.reverb_dry_level, self.reverb_width, - self.eq_bass, self.eq_treble, - self.comp_enabled, self.comp_threshold, self.comp_ratio, self.comp_attack, self.comp_release, - self.distortion_enabled, self.distortion_drive, - self.chorus_enabled, self.chorus_rate, self.chorus_depth, self.chorus_mix, - self.phaser_enabled, self.phaser_rate, self.phaser_depth, self.phaser_mix, - self.clipping_enabled, self.clipping_thresh, - self.bitcrush_enabled, self.bitcrush_depth, - self.gsm_enabled, - self.highpass_enabled, self.highpass_freq, - self.lowpass_enabled, self.lowpass_freq, - self.delay_enabled, self.delay_time, self.delay_feedback, self.delay_mix, - self.pitch_shift_enabled, self.pitch_shift_semitones, - self.limiter_enabled, self.limiter_threshold, self.limiter_release, - self.gain_enabled, self.gain_db - ] - for v in vars_to_trace: - v.trace_add("write", self.schedule_save) - - # Also trigger voice list update when lang changes - self.lang_var.trace_add("write", self.on_lang_change) - - # Mix tab traces - self.mix_lang_a_var.trace_add("write", self.on_mix_lang_a_change) - self.mix_lang_b_var.trace_add("write", self.on_mix_lang_b_change) - - def on_lang_change(self, *args): - code = self.lang_var.get() - self.standard_voices = self.VOICE_DB.get(code, self.VOICE_DB["a"]) - # Update generation combo - if hasattr(self, 'voice_combo'): - self.voice_combo.configure(values=self.get_all_voices(code)) - - # Set default voice for this language if current voice is invalid - if self.voice_var.get() not in self.VOICE_DB.get(code, []): - if self.VOICE_DB.get(code, []): - self.voice_var.set(self.VOICE_DB[code][0]) - - def schedule_save(self, *args): - if self.save_timer: - self.after_cancel(self.save_timer) - self.save_timer = self.after(1000, self.save_settings) - - def load_settings(self): - defaults = { - "appearance": "Dark", - "scaling": "100%", - "lang_code": "a", - "voice": "af_heart", - "filename": "output", - "format": "wav", - "out_dir": "audio_output", - "speed": 1.0, - "volume": 1.0, - "pitch": 0.0, - "num_threads": 1, - "split_pattern": r"\n+", - "separate": True, - "combine": True, - "export_subtitles": False, - "caching": True, - "jit_enabled": False, - "normalize": False, - "trim": False, - "apply_fx": True, - "reverb_enabled": False, - "reverb_room_size": 0.5, - "reverb_wet_level": 0.3, - "reverb_damping": 0.5, - "reverb_dry_level": 1.0, - "reverb_width": 1.0, - "eq_bass": 0.0, - "eq_treble": 0.0, - "comp_enabled": False, - "comp_threshold": -20.0, - "comp_ratio": 4.0, - "comp_attack": 1.0, - "comp_release": 100.0, - "distortion_enabled": False, - "distortion_drive": 25.0, - "chorus_enabled": False, - "chorus_rate": 1.0, - "chorus_depth": 0.25, - "chorus_mix": 0.5, - "phaser_enabled": False, - "phaser_rate": 1.0, - "phaser_depth": 0.5, - "phaser_mix": 0.5, - "clipping_enabled": False, - "clipping_thresh": -6.0, - "bitcrush_enabled": False, - "bitcrush_depth": 8.0, - "gsm_enabled": False, - "highpass_enabled": False, - "highpass_freq": 50.0, - "lowpass_enabled": False, - "lowpass_freq": 10000.0, - "delay_enabled": False, - "delay_time": 0.5, - "delay_feedback": 0.0, - "delay_mix": 0.5, - "pitch_shift_enabled": False, - "pitch_shift_semitones": 0.0, - "limiter_enabled": False, - "limiter_threshold": -1.0, - "limiter_release": 100.0, - "gain_enabled": False, - "gain_db": 0.0, - "lexicon": {} - } - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - return {**defaults, **json.load(f)} - except Exception: - pass - return defaults - - def save_settings(self): - if self.save_timer: - self.after_cancel(self.save_timer) - self.save_timer = None - - if hasattr(self, 'voice_var'): - self.settings['lang_code'] = self.lang_var.get() - self.settings['voice'] = self.voice_var.get() - self.settings['filename'] = self.filename_var.get() - self.settings['format'] = self.output_format_var.get() - self.settings['out_dir'] = self.output_dir_var.get() - self.settings['speed'] = self.speed_var.get() - self.settings['volume'] = self.volume_var.get() - self.settings['pitch'] = self.pitch_var.get() - self.settings['num_threads'] = self.num_threads_var.get() - self.settings['split_pattern'] = self.split_pattern_var.get() - self.settings['separate'] = self.separate_files.get() - self.settings['combine'] = self.combine_post.get() - self.settings['export_subtitles'] = self.export_subtitles.get() - self.settings['caching'] = self.caching_enabled.get() - self.settings['jit_enabled'] = self.jit_enabled.get() - self.settings['normalize'] = self.normalize_audio.get() - self.settings['trim'] = self.trim_silence.get() - self.settings['apply_fx'] = self.apply_fx_var.get() - self.settings['reverb_enabled'] = self.reverb_enabled.get() - self.settings['reverb_room_size'] = self.reverb_room_size.get() - self.settings['reverb_wet_level'] = self.reverb_wet_level.get() - self.settings['reverb_damping'] = self.reverb_damping.get() - self.settings['reverb_dry_level'] = self.reverb_dry_level.get() - self.settings['reverb_width'] = self.reverb_width.get() - - self.settings['eq_bass'] = self.eq_bass.get() - self.settings['eq_treble'] = self.eq_treble.get() - - self.settings['comp_enabled'] = self.comp_enabled.get() - self.settings['comp_threshold'] = self.comp_threshold.get() - self.settings['comp_ratio'] = self.comp_ratio.get() - self.settings['comp_attack'] = self.comp_attack.get() - self.settings['comp_release'] = self.comp_release.get() - - self.settings['distortion_enabled'] = self.distortion_enabled.get() - self.settings['distortion_drive'] = self.distortion_drive.get() - - self.settings['chorus_enabled'] = self.chorus_enabled.get() - self.settings['chorus_rate'] = self.chorus_rate.get() - self.settings['chorus_depth'] = self.chorus_depth.get() - self.settings['chorus_mix'] = self.chorus_mix.get() - - self.settings['phaser_enabled'] = self.phaser_enabled.get() - self.settings['phaser_rate'] = self.phaser_rate.get() - self.settings['phaser_depth'] = self.phaser_depth.get() - self.settings['phaser_mix'] = self.phaser_mix.get() - - self.settings['clipping_enabled'] = self.clipping_enabled.get() - self.settings['clipping_thresh'] = self.clipping_thresh.get() - - self.settings['bitcrush_enabled'] = self.bitcrush_enabled.get() - self.settings['bitcrush_depth'] = self.bitcrush_depth.get() - - self.settings['gsm_enabled'] = self.gsm_enabled.get() - - self.settings['highpass_enabled'] = self.highpass_enabled.get() - self.settings['highpass_freq'] = self.highpass_freq.get() - - self.settings['lowpass_enabled'] = self.lowpass_enabled.get() - self.settings['lowpass_freq'] = self.lowpass_freq.get() - - self.settings['delay_enabled'] = self.delay_enabled.get() - self.settings['delay_time'] = self.delay_time.get() - self.settings['delay_feedback'] = self.delay_feedback.get() - self.settings['delay_mix'] = self.delay_mix.get() - - self.settings['pitch_shift_enabled'] = self.pitch_shift_enabled.get() - self.settings['pitch_shift_semitones'] = self.pitch_shift_semitones.get() - - self.settings['limiter_enabled'] = self.limiter_enabled.get() - self.settings['limiter_threshold'] = self.limiter_threshold.get() - self.settings['limiter_release'] = self.limiter_release.get() - - self.settings['gain_enabled'] = self.gain_enabled.get() - self.settings['gain_db'] = self.gain_db.get() - - try: - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(self.settings, f, indent=4) - except Exception as e: - print(f"Failed to save settings: {e}") - - def apply_settings(self): - ctk.set_appearance_mode(self.settings["appearance"]) - - # Parse scaling - scale_str = self.settings["scaling"].replace("%", "") - try: - scale_float = float(scale_str) / 100 - ctk.set_widget_scaling(scale_float) - except Exception: - ctk.set_widget_scaling(1.0) - - def create_widgets(self): - # Header - self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(1, weight=1) - self.grid_rowconfigure(2, weight=0) - - header_frame = ctk.CTkFrame(self, fg_color="transparent") - header_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=(10,0)) - - ctk.CTkLabel(header_frame, text="Kokoro TTS", font=("Roboto", 20, "bold")).pack(side="left", padx=5) - ctk.CTkButton(header_frame, text="⚙ Settings", width=80, height=28, command=self.open_settings).pack(side="right") - - # Engine picker (PLAN_qt_and_engine_abstraction.md workstream 1) - - # lists every backend registered in kokoro_gui/engines/registry.py - # (built-in: "kokoro", "dummy") and swaps the active self.engine/ - # self.backend on selection. Mainly a testing aid for now, ahead of - # the Qt migration's real per-engine settings panels. - engine_picker_frame = ctk.CTkFrame(header_frame, fg_color="transparent") - engine_picker_frame.pack(side="right", padx=10) - ctk.CTkLabel(engine_picker_frame, text="Engine:").pack(side="left", padx=(0, 5)) - self._engine_ids_by_display_name = { - engine_registry.get_display_name(eid): eid for eid in engine_registry.list_engines() - } - self.engine_picker = ctk.CTkComboBox( - engine_picker_frame, values=list(self._engine_ids_by_display_name.keys()), - width=200, command=self.on_engine_picker_change, - ) - self.engine_picker.set(engine_registry.get_display_name(self.backend.id)) - self.engine_picker.pack(side="left") - - # Main Tabs - self.main_tabs = ctk.CTkTabview(self) - self.main_tabs.grid(row=1, column=0, sticky="nsew", padx=10, pady=10) - - gen_tab = self.main_tabs.add("Generate Audio") - self.build_generation_tab(gen_tab) - - # Mixing is an optional, Kokoro-shaped capability (raw voice-tensor - # math - see kokoro_gui/engine/voices.py) - gate the whole tab on it - # instead of always showing it, so a backend without local voice - # tensors (e.g. "dummy") doesn't get an unusable "Custom Voice" tab. - self._mixing_tab_built = False - self._sync_mixing_tab() - - fx_tab = self.main_tabs.add("Audio FX") - self.build_fx_tab(fx_tab) - - lex_tab = self.main_tabs.add("Lexicon") - self.build_lexicon_tab(lex_tab) - - # Actions (Global) - action_frame = ctk.CTkFrame(self) - action_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=10) - - self.status_label = ctk.CTkLabel(action_frame, text="Ready", text_color="gray", anchor="w") - self.status_label.pack(fill="x", padx=10, pady=(5,0)) - - self.detail_label = ctk.CTkLabel(action_frame, text="...", font=("Consolas", 10), text_color="gray", anchor="w") - self.detail_label.pack(fill="x", padx=10, pady=(0,5)) - - self.progress_bar = ctk.CTkProgressBar(action_frame) - self.progress_bar.set(0) - self.progress_bar.pack(fill="x", padx=10, pady=5) - - self.info_label = ctk.CTkLabel(action_frame, text="Time: 00:00 / ETA: --:-- | 0%") - self.info_label.pack(pady=2) - - btn_frame = ctk.CTkFrame(action_frame, fg_color="transparent") - btn_frame.pack(fill="x", pady=10) - - self.preview_btn = ctk.CTkButton(btn_frame, text="Preview Audio", command=self.preview_conversion, height=40, fg_color="#2B719E", hover_color="#205578") - self.preview_btn.pack(side="left", fill="x", expand=True, padx=5) - - btn_txt = "Start Real-time JIT" if self.jit_enabled.get() else "Start Generation" - self.start_btn = ctk.CTkButton(btn_frame, text=btn_txt, command=self.start_conversion, height=40, font=("Roboto", 14, "bold")) - self.start_btn.pack(side="left", fill="x", expand=True, padx=5) - - self.cancel_btn = ctk.CTkButton(btn_frame, text="Cancel", command=self.cancel_conversion, height=40, fg_color="#c42b1c", hover_color="#8a1f14", state="disabled") - self.cancel_btn.pack(side="left", fill="x", expand=True, padx=5) - - def _sync_mixing_tab(self): - """Add/remove the "Custom Voice" tab to match the active backend's - `capabilities.supports_voice_mixing`. Called once from create_widgets - and again from switch_engine whenever the flag changes.""" - wants_mixing = self.backend.capabilities.supports_voice_mixing - if wants_mixing and not self._mixing_tab_built: - mix_tab = self.main_tabs.add("Custom Voice") - self.build_mixing_tab(mix_tab) - self._mixing_tab_built = True - elif not wants_mixing and self._mixing_tab_built: - self.main_tabs.delete("Custom Voice") - self._mixing_tab_built = False - - def on_engine_picker_change(self, display_name): - engine_id = self._engine_ids_by_display_name.get(display_name) - if engine_id is None or engine_id == self.backend.id: - return - self.switch_engine(engine_id) - - def switch_engine(self, engine_id): - """Swap the active self.engine/self.backend to a freshly-constructed - instance of the backend registered under `engine_id` (kokoro_gui/ - engines/registry.py), rewiring callbacks and re-syncing the Mixing - tab. Mainly a testing aid for the engine abstraction (workstream 1) - ahead of the Qt migration's real per-engine settings panels - it does - NOT re-render the Generation tab's schema-driven fields (split - pattern/format/speed bounds) for the new backend's schema; those stay - whatever they were built from at startup.""" - if self.cancel_btn.cget("state") == "normal": - messagebox.showwarning("Busy", "Cancel the current job before switching engines.") - self.engine_picker.set(engine_registry.get_display_name(self.backend.id)) - return - - old_engine = self.engine - new_backend = engine_registry.get_engine(engine_id) - new_engine = new_backend.engine - new_engine.on_progress = self.on_engine_progress - new_engine.on_status = self.on_engine_status - new_engine.on_finish = self.on_engine_finish - - self.engine = new_engine - self.backend = new_backend - self._sync_mixing_tab() - - try: - old_engine.worker.stop() - except Exception: - pass - - self.status_label.configure(text=f"Switched engine to {new_backend.display_name}. Initializing...", text_color="gray") - self.engine.worker.run_coro(self.engine.init_pipeline_async(self.lang_var.get())) - - def open_settings(self): - toplevel = ctk.CTkToplevel(self) - toplevel.title("Settings") - toplevel.geometry("400x380") - toplevel.grab_set() # Modal - - # Center the window - toplevel.update_idletasks() - x = self.winfo_x() + (self.winfo_width() // 2) - (toplevel.winfo_width() // 2) - y = self.winfo_y() + (self.winfo_height() // 2) - (toplevel.winfo_height() // 2) - toplevel.geometry(f"400x380+{x}+{y}") - - frame = ctk.CTkFrame(toplevel) - frame.pack(fill="both", expand=True, padx=20, pady=20) - - # Appearance - ctk.CTkLabel(frame, text="Appearance Mode:", font=("Roboto", 14, "bold")).pack(anchor="w", pady=(10, 5)) - app_menu = ctk.CTkOptionMenu(frame, values=["System", "Dark", "Light"], command=self.change_appearance) - app_menu.set(self.settings["appearance"]) - app_menu.pack(fill="x", pady=5) - - # Scaling - ctk.CTkLabel(frame, text="UI Scaling:", font=("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) - scale_menu = ctk.CTkOptionMenu(frame, values=["80%", "90%", "100%", "110%", "120%"], command=self.change_scaling) - scale_menu.set(self.settings["scaling"]) - scale_menu.pack(fill="x", pady=5) - - # Caching - ctk.CTkLabel(frame, text="Generation Cache:", font=("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) - ctk.CTkCheckBox(frame, text="Enable Generation Caching", variable=self.caching_enabled).pack(anchor="w", pady=5) - - # JIT - ctk.CTkLabel(frame, text="Real-time / JIT:", font=("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) - ctk.CTkCheckBox(frame, text="Enable JIT Generation (Streaming)", variable=self.jit_enabled, command=self.on_jit_toggle).pack(anchor="w", pady=5) - - ctk.CTkLabel(frame, text="Note: Restart may be required for optimal scaling.", text_color="gray", font=("Arial", 10)).pack(pady=20) - - ctk.CTkButton(frame, text="Close", command=toplevel.destroy).pack(side="bottom", pady=10) - - def change_appearance(self, new_val): - self.settings["appearance"] = new_val - ctk.set_appearance_mode(new_val) - self.save_settings() - - def change_scaling(self, new_val): - self.settings["scaling"] = new_val - scale_float = float(new_val.replace("%", "")) / 100 - ctk.set_widget_scaling(scale_float) - self.save_settings() - - def on_jit_toggle(self): - if self.jit_enabled.get(): - self.start_btn.configure(text="Start Real-time JIT") - else: - self.start_btn.configure(text="Start Generation") - self.save_settings() - - # --- Logic --- - - def update_audio_labels(self, value): - self.vol_label.configure(text=f"Volume: {int(self.volume_var.get() * 100)}%") - self.pitch_label.configure(text=f"Pitch: {int(self.pitch_var.get())} st") - - def update_speed_label(self, value): - self.speed_label.configure(text=f"Speed: {value:.1f}x") - - def change_threads(self, delta): - try: - current = int(self.num_threads_var.get()) - except Exception: - current = 1 - new_val = max(1, min(16, current + delta)) - self.num_threads_var.set(new_val) - - def update_split_pattern(self, choice): - self.split_pattern_var.set(self.split_map[choice]) - - def browse_directory(self): - d = filedialog.askdirectory() - if d: self.output_dir_var.set(d) - - def browse_file(self): - f = filedialog.askopenfilename(filetypes=[("Documents", "*.txt *.pdf *.epub")]) - if f: self.file_path_var.set(f) - - def on_engine_status(self, msg, is_error): - color = "#ff5555" if is_error else "gray" # Red or Gray - # Schedule update on main thread - self.after(0, lambda: self.status_label.configure(text=msg.split('\n')[0], text_color=color)) - - if is_error and "pip install" in msg: - self.after(0, lambda: messagebox.showerror("Missing Dependencies", msg)) - - def on_engine_progress(self, percent, elapsed, eta, detail): - # Schedule update - def _update(): - self.progress_bar.set(percent / 100.0) - elapsed_str = time.strftime('%M:%S', time.gmtime(elapsed)) - self.info_label.configure(text=f"Time: {elapsed_str} / ETA: {eta} | {int(percent)}%") - self.detail_label.configure(text=detail) - self.after(0, _update) - - def on_engine_finish(self): - self.after(0, lambda: self.set_ui_state(False)) - - def set_ui_state(self, is_running): - state = "disabled" if is_running else "normal" - cancel_state = "normal" if is_running else "disabled" - - self.start_btn.configure(state=state) - self.preview_btn.configure(state=state) - self.cancel_btn.configure(state=cancel_state) - self.thread_minus_btn.configure(state=state) - self.thread_plus_btn.configure(state=state) - self.thread_entry.configure(state=state) - self.vol_slider.configure(state=state) - self.pitch_slider.configure(state=state) - - if not is_running: - self.progress_bar.set(0 if self.engine.cancel_event.is_set() else 1) - - def preview_conversion(self): - if not self.engine.pipeline: - messagebox.showinfo("Wait", "Engine is initializing... please wait 2 seconds and try again.") - return - - # 1. Get Text - current_tab = self.tab_view.get() - text_data = "" - - if current_tab == "Direct Text": - text_data = self.text_entry.get("1.0", "end").strip() - else: - fpath = self.file_path_var.get() - if os.path.exists(fpath): - try: - text_data = self.engine.extract_text_from_file(fpath) - except Exception: - pass - - if not text_data: - text_data = "This is a sample audio preview using the Koh-koh-ro Tea-Tea-S engine. It demonstrates the voice quality and speed settings." - - preview_text = text_data - if len(preview_text) > 1000: # Slightly larger cap for raw text before engine handles it - preview_text = preview_text[:1000] - - # Config - voice = self.voice_var.get() - speed = self.speed_var.get() - - extra_config = { - 'volume': self.volume_var.get(), - 'pitch': self.pitch_var.get(), - 'normalize': self.normalize_audio.get(), - 'trim_silence': self.trim_silence.get(), - 'lexicon': self.settings.get('lexicon', {}) - } - - if self.apply_fx_var.get(): - extra_config.update({ - 'reverb_enabled': self.reverb_enabled.get(), - 'reverb_room_size': self.reverb_room_size.get(), - 'reverb_wet_level': self.reverb_wet_level.get(), - 'reverb_damping': self.reverb_damping.get(), - 'reverb_dry_level': self.reverb_dry_level.get(), - 'reverb_width': self.reverb_width.get(), - 'eq_bass': self.eq_bass.get(), - 'eq_treble': self.eq_treble.get(), - 'comp_enabled': self.comp_enabled.get(), - 'comp_threshold': self.comp_threshold.get(), - 'comp_ratio': self.comp_ratio.get(), - 'comp_attack': self.comp_attack.get(), - 'comp_release': self.comp_release.get(), - 'distortion_enabled': self.distortion_enabled.get(), - 'distortion_drive': self.distortion_drive.get(), - 'chorus_enabled': self.chorus_enabled.get(), - 'chorus_rate': self.chorus_rate.get(), - 'chorus_depth': self.chorus_depth.get(), - 'chorus_mix': self.chorus_mix.get(), - 'phaser_enabled': self.phaser_enabled.get(), - 'phaser_rate': self.phaser_rate.get(), - 'phaser_depth': self.phaser_depth.get(), - 'phaser_mix': self.phaser_mix.get(), - 'clipping_enabled': self.clipping_enabled.get(), - 'clipping_thresh': self.clipping_thresh.get(), - 'bitcrush_enabled': self.bitcrush_enabled.get(), - 'bitcrush_depth': self.bitcrush_depth.get(), - 'gsm_enabled': self.gsm_enabled.get(), - 'highpass_enabled': self.highpass_enabled.get(), - 'highpass_freq': self.highpass_freq.get(), - 'lowpass_enabled': self.lowpass_enabled.get(), - 'lowpass_freq': self.lowpass_freq.get(), - 'delay_enabled': self.delay_enabled.get(), - 'delay_time': self.delay_time.get(), - 'delay_feedback': self.delay_feedback.get(), - 'delay_mix': self.delay_mix.get(), - 'pitch_shift_enabled': self.pitch_shift_enabled.get(), - 'pitch_shift_semitones': self.pitch_shift_semitones.get(), - 'limiter_enabled': self.limiter_enabled.get(), - 'limiter_threshold': self.limiter_threshold.get(), - 'limiter_release': self.limiter_release.get(), - 'gain_enabled': self.gain_enabled.get(), - 'gain_db': self.gain_db.get() - }) - - # Temp file - import tempfile - tmp_path = os.path.join(tempfile.gettempdir(), "kokoro_preview.wav") - - self.status_label.configure(text="Generating preview...", text_color="blue") - - def _on_preview_done(future): - def _ui_update(): - try: - success = future.result() - if success: - self.status_label.configure(text="Playing preview...", text_color="green") - playback.play(tmp_path) - self.after(3000, lambda: self.status_label.configure(text="Ready", text_color="gray")) - else: - self.status_label.configure(text="Preview failed.", text_color="red") - except Exception as e: - self.status_label.configure(text=f"Preview error: {e}", text_color="red") - - self.after(0, _ui_update) - - future = self.engine.worker.run_coro(self.engine.generate_preview(preview_text, voice, speed, tmp_path, extra_config, lang_code=self.lang_var.get())) - future.add_done_callback(_on_preview_done) - - def start_conversion(self): - # 0. Validate Threads - try: - val = int(self.num_threads_var.get()) - if val < 1: val = 1 - self.num_threads_var.set(val) - except Exception: - self.num_threads_var.set(1) - - # 1. Get Text - current_tab = self.tab_view.get() - text_data = "" - - if current_tab == "Direct Text": - text_data = self.text_entry.get("1.0", "end").strip() - else: - fpath = self.file_path_var.get() - if not os.path.exists(fpath): - messagebox.showerror("Error", "File not found.") - return - try: - text_data = self.engine.extract_text_from_file(fpath) - except Exception as e: - messagebox.showerror("Error", f"Read failed: {e}") - return - - if not text_data: - messagebox.showwarning("Empty", "No text to process.") - return - - if not self.engine.pipeline: - messagebox.showinfo("Wait", "Engine is initializing... please wait 2 seconds and try again.") - return - - # 2. Config - config = { - 'engine_id': self.backend.id, - 'lang_code': self.lang_var.get(), - 'voice': self.voice_var.get(), - 'speed': self.speed_var.get(), - 'split_pattern': self.split_pattern_var.get(), - 'filename': self.filename_var.get(), - 'format': self.output_format_var.get(), - 'out_dir': self.output_dir_var.get(), - 'separate': self.separate_files.get(), - 'combine': self.combine_post.get(), - 'export_subtitles': self.export_subtitles.get(), - 'caching': self.caching_enabled.get(), - 'time_id': time.strftime(self.timecode_format), - 'num_threads': self.num_threads_var.get(), - 'volume': self.volume_var.get(), - 'pitch': self.pitch_var.get(), - 'normalize': self.normalize_audio.get(), - 'trim_silence': self.trim_silence.get(), - 'lexicon': self.settings.get('lexicon', {}) - } - - if self.apply_fx_var.get(): - config.update({ - 'reverb_enabled': self.reverb_enabled.get(), - 'reverb_room_size': self.reverb_room_size.get(), - 'reverb_wet_level': self.reverb_wet_level.get(), - 'reverb_damping': self.reverb_damping.get(), - 'reverb_dry_level': self.reverb_dry_level.get(), - 'reverb_width': self.reverb_width.get(), - 'eq_bass': self.eq_bass.get(), - 'eq_treble': self.eq_treble.get(), - 'comp_enabled': self.comp_enabled.get(), - 'comp_threshold': self.comp_threshold.get(), - 'comp_ratio': self.comp_ratio.get(), - 'comp_attack': self.comp_attack.get(), - 'comp_release': self.comp_release.get(), - 'distortion_enabled': self.distortion_enabled.get(), - 'distortion_drive': self.distortion_drive.get(), - 'chorus_enabled': self.chorus_enabled.get(), - 'chorus_rate': self.chorus_rate.get(), - 'chorus_depth': self.chorus_depth.get(), - 'chorus_mix': self.chorus_mix.get(), - 'phaser_enabled': self.phaser_enabled.get(), - 'phaser_rate': self.phaser_rate.get(), - 'phaser_depth': self.phaser_depth.get(), - 'phaser_mix': self.phaser_mix.get(), - 'clipping_enabled': self.clipping_enabled.get(), - 'clipping_thresh': self.clipping_thresh.get(), - 'bitcrush_enabled': self.bitcrush_enabled.get(), - 'bitcrush_depth': self.bitcrush_depth.get(), - 'gsm_enabled': self.gsm_enabled.get(), - 'highpass_enabled': self.highpass_enabled.get(), - 'highpass_freq': self.highpass_freq.get(), - 'lowpass_enabled': self.lowpass_enabled.get(), - 'lowpass_freq': self.lowpass_freq.get(), - 'delay_enabled': self.delay_enabled.get(), - 'delay_time': self.delay_time.get(), - 'delay_feedback': self.delay_feedback.get(), - 'delay_mix': self.delay_mix.get(), - 'pitch_shift_enabled': self.pitch_shift_enabled.get(), - 'pitch_shift_semitones': self.pitch_shift_semitones.get(), - 'limiter_enabled': self.limiter_enabled.get(), - 'limiter_threshold': self.limiter_threshold.get(), - 'limiter_release': self.limiter_release.get(), - 'gain_enabled': self.gain_enabled.get(), - 'gain_db': self.gain_db.get() - }) - - # 3. Start - self.set_ui_state(True) - self.progress_bar.set(0) - - if self.jit_enabled.get(): - self.engine.start_jit_conversion(text_data, config) - else: - self.engine.start_conversion(text_data, config) - - def cancel_conversion(self): - self.engine.cancel() - self.status_label.configure(text="Cancelling... waiting for workers...", text_color="orange") - - def on_close(self): - self.save_settings() - self.destroy() - -if __name__ == "__main__": - app = TTSApp() - app.mainloop() diff --git a/kokoro_gui/engines/__init__.py b/kokoro_gui/engines/__init__.py index 1c6a4a4..a84ef74 100644 --- a/kokoro_gui/engines/__init__.py +++ b/kokoro_gui/engines/__init__.py @@ -1,8 +1,7 @@ """Engine backend abstraction (PLAN_qt_and_engine_abstraction.md workstream 1). Importing this package registers the built-in "kokoro" and "dummy" backends -as a side effect (the `kokoro`/`dummy` submodule imports below), mirroring -how `kokoro_gui/ui/__init__.py` collects the Tk tab mixins. +as a side effect (the `kokoro`/`dummy` submodule imports below). """ from kokoro_gui.engines import base, registry from kokoro_gui.engines.dummy import DummyBackendAdapter diff --git a/kokoro_gui/engines/dummy.py b/kokoro_gui/engines/dummy.py index c484a9a..95dca95 100644 --- a/kokoro_gui/engines/dummy.py +++ b/kokoro_gui/engines/dummy.py @@ -16,8 +16,9 @@ early). No `VoiceMixingMixin` - `capabilities.supports_voice_mixing=False`, so the -Mixing tab is not shown while this backend is active (see gui.py's -`create_widgets` gating), demonstrating that gate actually works. +Mixing dock is not shown while this backend is active (see the Qt frontend's +`kokoro_gui/qt/app.py`'s `_sync_mixing_dock`), demonstrating that gate +actually works. """ from __future__ import annotations @@ -81,7 +82,7 @@ class DummyEngine( AudioFXMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, SrtMixin, TextExtractionMixin, ): - """KokoroEngine-shaped enough for gui.py to drive directly (same + """KokoroEngine-shaped enough for the GUI to drive directly (same `worker`/`cancel_event`/`pipeline`/`on_progress`/`on_status`/`on_finish`/ `start_conversion`/`start_jit_conversion`/`generate_preview`/`cancel` surface), but with no real synthesis or caching underneath.""" diff --git a/kokoro_gui/engines/kokoro.py b/kokoro_gui/engines/kokoro.py index e364093..1e18a3c 100644 --- a/kokoro_gui/engines/kokoro.py +++ b/kokoro_gui/engines/kokoro.py @@ -3,7 +3,7 @@ Composition, not rewrite: `KokoroBackendAdapter` wraps a `KokoroEngine` instance built and driven exactly as before - `kokoro_engine.py`'s -`AsyncLoopThread`/thread-pool internals, and `gui.py`'s callback wiring +`AsyncLoopThread`/thread-pool internals and the GUI's callback wiring (`on_progress`/`on_status`/`on_finish`) are untouched. This module changes no behavior; it only describes that existing surface through the schema/ capabilities contract so a schema-driven GUI panel and, eventually, a second @@ -45,13 +45,13 @@ def __init__(self, engine=None): adapter wraps rather than constructing its own (used by the GUI at startup and by tests). When omitted, the adapter builds a fresh `KokoroEngine()` itself - used when switching the GUI's active - backend at runtime (see `gui.py`'s `switch_engine`), where nothing - already owns an engine instance to hand in.""" + backend at runtime (see the Qt frontend's `switch_engine`), where + nothing already owns an engine instance to hand in.""" self._engine = engine if engine is not None else kokoro_engine.KokoroEngine() @property def engine(self): - """The wrapped `KokoroEngine` instance - `gui.py` re-points + """The wrapped `KokoroEngine` instance - the GUI re-points `self.engine` at this on every backend switch so the many existing `self.engine.*` call sites keep working unchanged.""" return self._engine diff --git a/kokoro_gui/qt/__init__.py b/kokoro_gui/qt/__init__.py index 6a4cdbe..6a19084 100644 --- a/kokoro_gui/qt/__init__.py +++ b/kokoro_gui/qt/__init__.py @@ -1,10 +1,7 @@ """PySide6 (Qt) frontend — Workstream 3a of PLAN_qt_and_engine_abstraction.md. -This package is an alternative presentation layer that talks to the exact same -`KokoroEngine` / `kokoro_gui.engines` backend registry the CustomTkinter `gui.py` -app uses. Nothing here imports from `gui.py` or `kokoro_gui/ui/*.py` (the Tk -tab-builder mixins), and nothing in those Tk files imports from here — the two -frontends are independent and ship side by side (`python main.py` vs -`python main_qt.py`) until this one reaches parity, per the plan's own risk -mitigation ("no forced cutover"). +This is the sole GUI frontend (`python main.py`). It talks to `KokoroEngine` / +the `kokoro_gui.engines` backend registry through the same interface the +retired Tk frontend (`gui.py`, `kokoro_gui/ui/*.py`) used to — nothing here +depends on anything Tk-specific. """ diff --git a/kokoro_gui/qt/app.py b/kokoro_gui/qt/app.py index 971f020..fced2a5 100644 --- a/kokoro_gui/qt/app.py +++ b/kokoro_gui/qt/app.py @@ -1,21 +1,18 @@ -"""QtTTSApp: the PySide6 shell (workstream 3a of PLAN_qt_and_engine_abstraction.md). +"""QtTTSApp: the PySide6 shell (workstream 3a of PLAN_qt_and_engine_abstraction.md), +now the sole GUI frontend - the former CustomTkinter app (`gui.py`, +`kokoro_gui/ui/*.py`) was retired once this reached parity. -Mirrors gui.py's `TTSApp` behavior 1:1 (engine/backend construction, config -assembly, preview/start/cancel lifecycle, autosave) but as a `QMainWindow` + -`QDockWidget` shell instead of a `CTkTabview`. Talks to `KokoroEngine` and the -`kokoro_gui.engines` registry through the exact same interface gui.py uses - -nothing here imports from gui.py or kokoro_gui/ui/*.py, and nothing there -imports from here (see this package's `__init__.py`). +A `QMainWindow` + `QDockWidget` shell. Talks to `KokoroEngine` and the +`kokoro_gui.engines` registry the same way the retired Tk frontend did. `CONFIG_FILE`/`PRESETS_DIR`/`FX_PRESETS_DIR` are defined here, at module level, before the `kokoro_gui.qt.docks` import below - the dock modules do `import kokoro_gui.qt.app as qt_app_module` and read `qt_app_module.PRESETS_DIR` -etc. qualified at call time (same convention kokoro_gui/ui/*.py uses for -`gui.PRESETS_DIR`), which makes this a circular import; defining these names -before triggering that import keeps it safe (Python binds the dock modules' -`qt_app_module` name to this already-partially-initialized module, and by the -time any dock function actually reads `qt_app_module.PRESETS_DIR` the whole -package has finished importing anyway). +etc. qualified at call time, which makes this a circular import; defining +these names before triggering that import keeps it safe (Python binds the +dock modules' `qt_app_module` name to this already-partially-initialized +module, and by the time any dock function actually reads +`qt_app_module.PRESETS_DIR` the whole package has finished importing anyway). """ from __future__ import annotations @@ -65,7 +62,7 @@ def __init__(self, parent=None): self.mixing_dock: MixingDock | None = None self.generation_dock: GenerationDock | None = None - # --- Engine / backend (mirrors gui.py:40-50) --- + # --- Engine / backend --- self.engine = KokoroEngine() self.bridge = EngineSignalBridge() wire_engine(self.engine, self.bridge) @@ -167,7 +164,7 @@ def _build_action_bar(self) -> None: layout.addStretch(1) self.setCentralWidget(central) - # --- voice listing (mirrors gui.py:195-203, hardcoded relative path) -- + # --- voice listing (hardcoded relative path) -- def get_all_voices(self, lang_code: str | None = None) -> list: if lang_code is None: @@ -178,7 +175,7 @@ def get_all_voices(self, lang_code: str | None = None) -> list: custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] return sorted(standard + custom) - # --- settings persistence (mirrors gui.py's schedule_save/save_settings) - + # --- settings persistence - def schedule_save(self) -> None: self._save_timer.start(1000) @@ -212,7 +209,7 @@ def save_settings(self) -> None: qt_settings.save_settings(CONFIG_FILE, self.settings) - # --- config assembly (mirrors gui.py:828-895 / 714-767) ---------------- + # --- config assembly ---------------- def _assemble_config(self) -> dict: gen_state = self.generation_dock.get_state() @@ -241,7 +238,7 @@ def _assemble_config(self) -> dict: config.update(self.fx_dock.get_state()) return config - # --- engine picker / switch (mirrors gui.py:524-561) -------------------- + # --- engine picker / switch -------------------- def on_engine_picker_change(self, display_name: str) -> None: engine_id = self._engine_ids_by_display_name.get(display_name) @@ -269,8 +266,7 @@ def switch_engine(self, engine_id: str) -> None: self.bridge = new_bridge # Rebuild the Generation dock's schema-driven fields for the new - # backend and show/hide the Mixing dock - this is the fix for - # gui.py's own switch_engine docstring gap (gui.py:530-538). + # backend and show/hide the Mixing dock. self.generation_dock.rebuild_schema_form() self._sync_mixing_dock() @@ -294,7 +290,7 @@ def _sync_mixing_dock(self) -> None: self.mixing_dock.deleteLater() self.mixing_dock = None - # --- settings dialog (mirrors gui.py:563-618, minus CTk-only appearance/scaling) - + # --- settings dialog - def open_settings_dialog(self) -> None: dialog = QDialog(self) @@ -339,7 +335,7 @@ def set_ui_state(self, is_running: bool) -> None: if not is_running: self.progress_bar.setValue(0 if self.engine.cancel_event.is_set() else 100) - # --- preview (mirrors gui.py:684-791) ----------------------------------- + # --- preview ----------------------------------- def preview_conversion(self) -> None: if not self.engine.pipeline: @@ -392,7 +388,7 @@ def _on_preview_finished(self, success: bool, payload: str) -> None: self.status_label.setText(payload) self.status_label.setStyleSheet("color: red;") - # --- start/cancel (mirrors gui.py:793-908) ------------------------------ + # --- start/cancel ------------------------------ def start_conversion(self) -> None: threads = self.generation_dock.threads_spin.value() diff --git a/kokoro_gui/qt/docks/fx_dock.py b/kokoro_gui/qt/docks/fx_dock.py index 2d5b8ca..4f1f188 100644 --- a/kokoro_gui/qt/docks/fx_dock.py +++ b/kokoro_gui/qt/docks/fx_dock.py @@ -1,11 +1,9 @@ """Audio FX dock: builds the FX controls from `kokoro_gui.qt.spec.FX_FIELD_SPECS` -and loads/saves FX presets under `presets/fx/` (shared with the Tk frontend). -Mirrors kokoro_gui/ui/fx_tab.py. +and loads/saves FX presets under `presets/fx/`. Seven FX_PRESET_KEYS fields have no widget here (see spec.py's docstring) - their values are tracked in `self._hidden_values` and only ever change via -preset load, exactly matching the Tk frontend's real behavior (those fields -have no `_create_slider` call in fx_tab.py either). +preset load. """ from __future__ import annotations diff --git a/kokoro_gui/qt/docks/generation_dock.py b/kokoro_gui/qt/docks/generation_dock.py index d44d469..6125edc 100644 --- a/kokoro_gui/qt/docks/generation_dock.py +++ b/kokoro_gui/qt/docks/generation_dock.py @@ -1,11 +1,8 @@ """Generation dock: input source, voice/speed/output config, and the speaker -presets (`presets/*.json`, shared with the Tk frontend) that snapshot that -config. Mirrors kokoro_gui/ui/generation_tab.py + the relevant slice of -gui.py's `start_conversion`/`preview_conversion` config assembly. +presets (`presets/*.json`) that snapshot that config. Reads `kokoro_gui.qt.app.PRESETS_DIR` qualified at call time (not imported by -name) so tests can monkeypatch it into a tmp_path, same convention -generation_tab.py already uses for `gui.PRESETS_DIR`. +name) so tests can monkeypatch it into a tmp_path. """ from __future__ import annotations @@ -82,7 +79,7 @@ def __init__(self, app, parent=None): layout.addWidget(self.schema_group) self._build_schema_form() - # --- Output (not schema-covered, hand-built same as gui.py/generation_tab.py) --- + # --- Output (not schema-covered, hand-built) --- out_group = QGroupBox("Output") out_form = QFormLayout(out_group) dir_row = QWidget() @@ -99,7 +96,7 @@ def __init__(self, app, parent=None): out_form.addRow("Base Filename:", self.filename_edit) layout.addWidget(out_group) - # --- Audio control (volume/pitch - not schema-covered, matches gui.py) --- + # --- Audio control (volume/pitch - not schema-covered) --- audio_group = QGroupBox("Audio Control") audio_form = QFormLayout(audio_group) self.volume_spin = QDoubleSpinBox() @@ -207,8 +204,7 @@ def _build_schema_form(self) -> None: def rebuild_schema_form(self) -> None: """Called by app.py's switch_engine - re-renders this dock's schema - fields for the newly-active backend. This is the fix for gui.py's - own switch_engine docstring gap (see gui.py:530-538).""" + fields for the newly-active backend.""" self._build_schema_form() def refresh_voice_choices(self) -> None: diff --git a/kokoro_gui/qt/docks/lexicon_dock.py b/kokoro_gui/qt/docks/lexicon_dock.py index 3d6d32f..7554360 100644 --- a/kokoro_gui/qt/docks/lexicon_dock.py +++ b/kokoro_gui/qt/docks/lexicon_dock.py @@ -1,6 +1,5 @@ """Lexicon dock: find/replace rules stored in `self.app.settings["lexicon"]`. -Mirrors kokoro_gui/ui/lexicon_tab.py, including its eager-save behavior -(bypasses the debounced autosave every other field uses).""" +Saves eagerly (bypasses the debounced autosave every other field uses).""" from __future__ import annotations from PySide6.QtWidgets import ( diff --git a/kokoro_gui/qt/docks/mixing_dock.py b/kokoro_gui/qt/docks/mixing_dock.py index 3a5d9c9..0c84306 100644 --- a/kokoro_gui/qt/docks/mixing_dock.py +++ b/kokoro_gui/qt/docks/mixing_dock.py @@ -1,11 +1,11 @@ """Custom Voice (mixing) dock: blends two voice tensors via `self.app.engine.mix_voices` and previews/saves the result. Shown only when `app.backend.capabilities.supports_voice_mixing` is true - see app.py's -`_sync_mixing_dock`. Mirrors kokoro_gui/ui/mixing_tab.py. +`_sync_mixing_dock`. -Uses the literal relative "custom_voices" path, same as mixing_tab.py/gui.py -(not `kokoro_engine.CUSTOM_VOICES_DIR`) - both frontends rely on the process -cwd for this, which is why the test fixtures `monkeypatch.chdir(tmp_path)`. +Uses the literal relative "custom_voices" path (not +`kokoro_engine.CUSTOM_VOICES_DIR`) - relies on the process cwd for this, +which is why the test fixtures `monkeypatch.chdir(tmp_path)`. """ from __future__ import annotations diff --git a/kokoro_gui/qt/schema_form.py b/kokoro_gui/qt/schema_form.py index b990f07..9fc54d7 100644 --- a/kokoro_gui/qt/schema_form.py +++ b/kokoro_gui/qt/schema_form.py @@ -7,7 +7,7 @@ `backend.get_config_schema()` returns, not hard-coded per engine. Rebuilding this widget from a new backend's schema is what makes engine-switching actually swap the visible fields (see docks/generation_dock.py and app.py's -`switch_engine`), fixing the gap gui.py's own `switch_engine` docstring names. +`switch_engine`). """ from __future__ import annotations diff --git a/kokoro_gui/qt/settings.py b/kokoro_gui/qt/settings.py index 33f3fea..206f6d8 100644 --- a/kokoro_gui/qt/settings.py +++ b/kokoro_gui/qt/settings.py @@ -1,10 +1,9 @@ -"""Load/save `config_qt.json` (the Qt frontend's own app-settings file - see -spec.py's module docstring for why it's separate from Tk's `config.json`), -plus `QMainWindow` dock-layout persistence. +"""Load/save `config_qt.json` (the Qt frontend's app-settings file), plus +`QMainWindow` dock-layout persistence. Pure functions (no `QMainWindow`/app-instance state held here) so they're easy to unit test in isolation - `app.py` calls these and owns the debounce -timer (`QTimer.singleShot`, mirroring gui.py's `schedule_save`/`after(1000, ...)`). +timer (`QTimer.singleShot`). """ from __future__ import annotations diff --git a/kokoro_gui/qt/signals.py b/kokoro_gui/qt/signals.py index 0620801..caa5541 100644 --- a/kokoro_gui/qt/signals.py +++ b/kokoro_gui/qt/signals.py @@ -2,25 +2,21 @@ `KokoroEngine` calls `self.on_status`/`self.on_progress`/`self.on_finish` from its own background `AsyncLoopThread` (see kokoro_engine.py's `AsyncLoopThread` -and kokoro_gui/engine/conversion.py's call sites), same as it always has for -the Tk frontend - Tk marshals those calls onto its main thread with -`self.after(0, ...)`. Qt's equivalent is a `QObject` living on the main -thread whose signals are emitted from the worker thread: PySide6 detects the -emitting thread differs from the receiving QObject's thread and automatically -queues the connected slot call onto that thread's event loop (a -`Qt.QueuedConnection`), with no `after()`-style boilerplate needed - this -works for any Python thread that emits into a QObject with a running event -loop, not just a `QThread`. +and kokoro_gui/engine/conversion.py's call sites). Qt's answer to that is a +`QObject` living on the main thread whose signals are emitted from the worker +thread: PySide6 detects the emitting thread differs from the receiving +QObject's thread and automatically queues the connected slot call onto that +thread's event loop (a `Qt.QueuedConnection`), with no `after()`-style +boilerplate needed - this works for any Python thread that emits into a +QObject with a running event loop, not just a `QThread`. The same trick applies to the `concurrent.futures.Future.add_done_callback(...)` -pattern `preview_conversion`/mixing use (mirrors gui.py's `_on_preview_done`/ -`mixing_tab.py`'s `_on_done`, both of which wrap the UI touch in `self.after(0, ...)`): -any Qt widget/dock is itself a `QObject` that was constructed on the main -thread, so defining a small `Signal` directly on that dock/window class and -emitting it from inside the done-callback (which runs on the worker thread) -is enough - no dedicated bridge object needed for those one-off cases. See -docks/mixing_dock.py's `previewFinished`/`mixFinished` and app.py's -`previewFinished` for examples. +pattern `preview_conversion`/mixing use: any Qt widget/dock is itself a +`QObject` that was constructed on the main thread, so defining a small +`Signal` directly on that dock/window class and emitting it from inside the +done-callback (which runs on the worker thread) is enough - no dedicated +bridge object needed for those one-off cases. See docks/mixing_dock.py's +`previewFinished`/`mixFinished` and app.py's `previewFinished` for examples. """ from PySide6.QtCore import QObject, Signal @@ -39,8 +35,8 @@ class EngineSignalBridge(QObject): def wire_engine(engine, bridge: EngineSignalBridge) -> None: - """Point `engine`'s callback attributes at `bridge`'s signals. Mirrors - gui.py:41-43 / gui.py:547-549's `engine.on_progress = self.on_engine_progress` + """Point `engine`'s callback attributes at `bridge`'s signals - the Qt + equivalent of a plain `engine.on_progress = self.on_engine_progress` wiring, just emitting a signal instead of calling a bound method directly.""" engine.on_status = bridge.status.emit engine.on_progress = bridge.progress.emit diff --git a/kokoro_gui/qt/spec.py b/kokoro_gui/qt/spec.py index 2f299bf..63861d7 100644 --- a/kokoro_gui/qt/spec.py +++ b/kokoro_gui/qt/spec.py @@ -1,31 +1,20 @@ -"""Pure-data constants mirroring the Tk frontend's hard-coded field lists. - -Deliberately **mirrored**, not imported from `gui.py`/`kokoro_gui/ui/*.py` — -see the workstream 3a plan's "Zero edits to gui.py" note. This module has no -Tk or Qt imports so both `kokoro_gui/qt/*` and the test suite can import it -standalone, and `tests/gui_qt/test_qt_config_assembly.py` cross-checks these -constants against what the Tk `StubEngine`/`tts_app` fixture actually -captures at runtime, so drift between the two frontends is caught by a test -rather than prevented by shared code (safer than refactoring working Tk -internals just for this). - -Source of truth for each constant, as of this module's creation: -- GENERATION_BASE_KEYS: gui.py's `start_conversion` config dict (gui.py:828-848). -- FX_PRESET_KEYS: kokoro_gui/ui/fx_tab.py's `save_fx_preset_dialog` (fx_tab.py:204-248) — - the same 43 keys are merged into both `start_conversion` (gui.py:851-895) and - `preview_conversion`'s extra_config (gui.py:722-767) when FX is applied. -- FX_FIELD_SPECS: kokoro_gui/ui/fx_tab.py's `_create_slider(...)` call sites - (fx_tab.py:61-172). Seven FX_PRESET_KEYS fields have no matching entry here - because the Tk UI itself never built a widget for them (reverb_dry_level, - chorus_mix, phaser_depth, phaser_mix, comp_attack, comp_release, - limiter_release) — they - still round-trip through settings/presets/config assembly, just not via any - user-facing control in either frontend. That's an existing Tk gap, not - something workstream 3a is scoped to fix (see the plan's "preserve every - existing behavior 1:1" note). -- LANGUAGES / VOICE_DB: gui.py:58-78. -- SETTINGS_DEFAULTS: gui.py's `load_settings` defaults dict (gui.py:258-323), - minus "appearance"/"scaling" (Tk-specific CTk theming, no Qt equivalent). +"""Pure-data constants for the Qt frontend's field lists (generation config +keys, FX preset keys/slider specs, language/voice tables, settings defaults). + +This module has no Qt imports so both `kokoro_gui/qt/*` and the test suite can +import it standalone. + +These constants originated as a mirror of the now-retired Tk frontend's +(`gui.py`, `kokoro_gui/ui/*.py`) hard-coded field lists, kept in sync via +`tests/gui_qt/test_qt_config_assembly.py`'s cross-frontend check during the +migration (see PLAN_qt_and_engine_abstraction.md, workstream 3a). Now that Tk +has been removed, this module is simply the canonical source of truth for the +Qt frontend. + +- FX_FIELD_SPECS has no widget for seven FX_PRESET_KEYS fields + (reverb_dry_level, chorus_mix, phaser_depth, phaser_mix, comp_attack, + comp_release, limiter_release) — a pre-existing gap inherited from Tk, not + yet closed (see ROADMAP.md). """ from dataclasses import dataclass from typing import Optional @@ -154,8 +143,6 @@ class FXSliderSpec: MIX_PREVIEW_TEXT_DEFAULT = "This is a preview of your custom mixed voice." # --- App-settings defaults (config_qt.json) -------------------------------- -# Mirrors gui.py's load_settings() defaults minus "appearance"/"scaling" -# (CTk-specific theming with no Qt equivalent here). SETTINGS_DEFAULTS = { "lang_code": "a", diff --git a/kokoro_gui/ui/__init__.py b/kokoro_gui/ui/__init__.py deleted file mode 100644 index 0e138d5..0000000 --- a/kokoro_gui/ui/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .fx_tab import FXTabMixin -from .generation_tab import GenerationTabMixin -from .lexicon_tab import LexiconTabMixin -from .mixing_tab import MixingTabMixin - -__all__ = ["FXTabMixin", "GenerationTabMixin", "LexiconTabMixin", "MixingTabMixin"] diff --git a/kokoro_gui/ui/fx_tab.py b/kokoro_gui/ui/fx_tab.py deleted file mode 100644 index 898c56a..0000000 --- a/kokoro_gui/ui/fx_tab.py +++ /dev/null @@ -1,368 +0,0 @@ -"""Audio FX tab: builds the FX sliders/toggles and loads/saves FX presets under -`presets/fx/`. - -Calls `gui.messagebox`, `gui.ctk.CTkInputDialog`, and reads `gui.FX_PRESETS_DIR` -qualified, at call time, so tests can keep monkeypatching those names on the -`gui` module (the `tts_app` fixture redirects `FX_PRESETS_DIR` into a tmp_path -and replaces `messagebox` with a `MagicMock()`; `test_gui_handlers.py` patches -`gui.ctk.CTkInputDialog` with a fake dialog). -""" -import json -import os -import re - -import customtkinter as ctk - -import gui - - -class FXTabMixin: - def build_fx_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - # --- Preset Controls --- - pre_frame = ctk.CTkFrame(parent, fg_color="transparent") - pre_frame.pack(fill="x", padx=10, pady=(10,5)) - - self.fx_preset_combo = ctk.CTkComboBox(pre_frame, values=["Select FX Preset..."], command=self.load_fx_preset, width=200) - self.fx_preset_combo.pack(side="left", padx=(0,5)) - - ctk.CTkButton(pre_frame, text="💾 Save", width=60, command=self.save_fx_preset_dialog).pack(side="left", padx=2) - ctk.CTkButton(pre_frame, text="🔄", width=30, command=self.refresh_fx_presets).pack(side="left", padx=2) - - scroll = ctk.CTkScrollableFrame(parent) - scroll.pack(fill="both", expand=True, padx=5, pady=5) - scroll.grid_columnconfigure(0, weight=1) - - # Helper to create rows - def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_attr=None): - row = ctk.CTkFrame(parent, fg_color="transparent") - row.pack(fill="x", padx=5, pady=2) - lbl = ctk.CTkLabel(row, text=label_text, width=120, anchor="w") - lbl.pack(side="left") - if label_attr: setattr(self, label_attr, lbl) - - ctk.CTkSlider(row, from_=from_, to=to_, number_of_steps=steps, variable=variable, - command=lambda v: self.update_fx_labels()).pack(side="left", fill="x", expand=True, padx=5) - - # --- 1. Dynamics --- - dyn_frame = ctk.CTkFrame(scroll) - dyn_frame.pack(fill="x", padx=5, pady=5) - - ctk.CTkLabel(dyn_frame, text="Dynamics", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Compressor - c_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - c_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(c_head, text="Compressor", variable=self.comp_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - c_body = ctk.CTkFrame(dyn_frame) - c_body.pack(fill="x", padx=10, pady=2) - _create_slider(c_body, "Threshold", self.comp_threshold, -60, 0, 60, 'comp_thresh_label') - _create_slider(c_body, "Ratio", self.comp_ratio, 1, 20, 19, 'comp_ratio_label') - - # Limiter - l_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - l_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(l_head, text="Limiter", variable=self.limiter_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - l_body = ctk.CTkFrame(dyn_frame) - l_body.pack(fill="x", padx=10, pady=2) - _create_slider(l_body, "Threshold", self.limiter_threshold, -12, 0, 24, 'lim_thresh_label') - - # Gain - g_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - g_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(g_head, text="Gain", variable=self.gain_enabled, font=("Roboto", 12, "bold")).pack(side="left") - _create_slider(dyn_frame, "dB", self.gain_db, -20, 20, 80, 'gain_label') - - # --- 2. EQ & Filters --- - eq_frame = ctk.CTkFrame(scroll) - eq_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(eq_frame, text="EQ & Filters", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - _create_slider(eq_frame, "Bass (LowShelf)", self.eq_bass, -20, 20, 40, 'bass_label') - _create_slider(eq_frame, "Treble (HighShelf)", self.eq_treble, -20, 20, 40, 'treble_label') - - # HPF - h_head = ctk.CTkFrame(eq_frame, fg_color="transparent") - h_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(h_head, text="HighPass Filter", variable=self.highpass_enabled).pack(side="left") - _create_slider(eq_frame, "Freq (Hz)", self.highpass_freq, 20, 1000, 100, 'hpf_label') - - # LPF - lpf_head = ctk.CTkFrame(eq_frame, fg_color="transparent") - lpf_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(lpf_head, text="LowPass Filter", variable=self.lowpass_enabled).pack(side="left") - _create_slider(eq_frame, "Freq (Hz)", self.lowpass_freq, 1000, 20000, 100, 'lpf_label') - - # --- 3. Spatial & Time --- - sp_frame = ctk.CTkFrame(scroll) - sp_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(sp_frame, text="Spatial & Time", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Reverb - r_head = ctk.CTkFrame(sp_frame, fg_color="transparent") - r_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(r_head, text="Reverb", variable=self.reverb_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - r_body = ctk.CTkFrame(sp_frame) - r_body.pack(fill="x", padx=10, pady=2) - _create_slider(r_body, "Room Size", self.reverb_room_size, 0, 1, 100, 'rev_room_label') - _create_slider(r_body, "Wet Level", self.reverb_wet_level, 0, 1, 100, 'rev_wet_label') - _create_slider(r_body, "Damping", self.reverb_damping, 0, 1, 100, None) - _create_slider(r_body, "Width", self.reverb_width, 0, 1, 100, None) - - # Delay - d_head = ctk.CTkFrame(sp_frame, fg_color="transparent") - d_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(d_head, text="Delay", variable=self.delay_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - d_body = ctk.CTkFrame(sp_frame) - d_body.pack(fill="x", padx=10, pady=2) - _create_slider(d_body, "Time (s)", self.delay_time, 0, 2, 100, 'dly_time_label') - _create_slider(d_body, "Feedback", self.delay_feedback, 0, 1, 100, None) - _create_slider(d_body, "Mix", self.delay_mix, 0, 1, 100, 'dly_mix_label') - - # --- 4. Guitar / Modulation --- - mod_frame = ctk.CTkFrame(scroll) - mod_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(mod_frame, text="Guitar / Modulation", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Chorus - ch_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - ch_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(ch_head, text="Chorus", variable=self.chorus_enabled).pack(side="left") - _create_slider(mod_frame, "Rate (Hz)", self.chorus_rate, 0.1, 10, 50, 'chorus_rate_label') - _create_slider(mod_frame, "Depth", self.chorus_depth, 0, 1, 50, None) - - # Distortion - di_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - di_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(di_head, text="Distortion", variable=self.distortion_enabled).pack(side="left") - _create_slider(mod_frame, "Drive (dB)", self.distortion_drive, 0, 60, 60, 'dist_drive_label') - - # Phaser - ph_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - ph_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(ph_head, text="Phaser", variable=self.phaser_enabled).pack(side="left") - _create_slider(mod_frame, "Rate (Hz)", self.phaser_rate, 0.1, 10, 50, 'phaser_rate_label') - - # Clipping - cl_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - cl_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(cl_head, text="Clipping", variable=self.clipping_enabled).pack(side="left") - _create_slider(mod_frame, "Threshold (dB)", self.clipping_thresh, -20, 0, 40, 'clip_thresh_label') - - # --- 5. Quality & Pitch --- - q_frame = ctk.CTkFrame(scroll) - q_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(q_frame, text="Quality / Pitch", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Pitch Shift - ps_head = ctk.CTkFrame(q_frame, fg_color="transparent") - ps_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(ps_head, text="Pitch Shift (High Quality)", variable=self.pitch_shift_enabled).pack(side="left") - _create_slider(q_frame, "Semitones", self.pitch_shift_semitones, -12, 12, 48, 'pitch_shift_label') - - # Bitcrush - bc_head = ctk.CTkFrame(q_frame, fg_color="transparent") - bc_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(bc_head, text="Bitcrush", variable=self.bitcrush_enabled).pack(side="left") - _create_slider(q_frame, "Bit Depth", self.bitcrush_depth, 2, 16, 28, 'bit_depth_label') - - # GSM - ctk.CTkCheckBox(q_frame, text="GSM Compressor (Phone Quality)", variable=self.gsm_enabled).pack(anchor="w", padx=10, pady=5) - - # Init labels - self.update_fx_labels() - self.refresh_fx_presets() - - def refresh_fx_presets(self): - presets = ["Select FX Preset..."] - if os.path.exists(gui.FX_PRESETS_DIR): - files = [f for f in os.listdir(gui.FX_PRESETS_DIR) if f.endswith(".json")] - presets.extend([f[:-5] for f in files]) # Remove .json - - # Update FX Tab Combo - if hasattr(self, 'fx_preset_combo'): - self.fx_preset_combo.configure(values=presets) - self.fx_preset_combo.set("Select FX Preset...") - - # Update Gen Tab Combo - if hasattr(self, 'gen_fx_combo'): - self.gen_fx_combo.configure(values=presets) - self.gen_fx_combo.set("Select FX Preset...") - - def save_fx_preset_dialog(self): - dialog = gui.ctk.CTkInputDialog(text="Enter FX preset name:", title="Save FX Preset") - name = dialog.get_input() - if name: - name = re.sub(r'[<>:"/\\|?*]', '', name).strip() - if not name: return - - data = { - "reverb_enabled": self.reverb_enabled.get(), - "reverb_room_size": self.reverb_room_size.get(), - "reverb_wet_level": self.reverb_wet_level.get(), - "reverb_damping": self.reverb_damping.get(), - "reverb_dry_level": self.reverb_dry_level.get(), - "reverb_width": self.reverb_width.get(), - "eq_bass": self.eq_bass.get(), - "eq_treble": self.eq_treble.get(), - "comp_enabled": self.comp_enabled.get(), - "comp_threshold": self.comp_threshold.get(), - "comp_ratio": self.comp_ratio.get(), - "comp_attack": self.comp_attack.get(), - "comp_release": self.comp_release.get(), - "distortion_enabled": self.distortion_enabled.get(), - "distortion_drive": self.distortion_drive.get(), - "chorus_enabled": self.chorus_enabled.get(), - "chorus_rate": self.chorus_rate.get(), - "chorus_depth": self.chorus_depth.get(), - "chorus_mix": self.chorus_mix.get(), - "phaser_enabled": self.phaser_enabled.get(), - "phaser_rate": self.phaser_rate.get(), - "phaser_depth": self.phaser_depth.get(), - "phaser_mix": self.phaser_mix.get(), - "clipping_enabled": self.clipping_enabled.get(), - "clipping_thresh": self.clipping_thresh.get(), - "bitcrush_enabled": self.bitcrush_enabled.get(), - "bitcrush_depth": self.bitcrush_depth.get(), - "gsm_enabled": self.gsm_enabled.get(), - "highpass_enabled": self.highpass_enabled.get(), - "highpass_freq": self.highpass_freq.get(), - "lowpass_enabled": self.lowpass_enabled.get(), - "lowpass_freq": self.lowpass_freq.get(), - "delay_enabled": self.delay_enabled.get(), - "delay_time": self.delay_time.get(), - "delay_feedback": self.delay_feedback.get(), - "delay_mix": self.delay_mix.get(), - "pitch_shift_enabled": self.pitch_shift_enabled.get(), - "pitch_shift_semitones": self.pitch_shift_semitones.get(), - "limiter_enabled": self.limiter_enabled.get(), - "limiter_threshold": self.limiter_threshold.get(), - "limiter_release": self.limiter_release.get(), - "gain_enabled": self.gain_enabled.get(), - "gain_db": self.gain_db.get() - } - - fpath = os.path.join(gui.FX_PRESETS_DIR, f"{name}.json") - try: - with open(fpath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - gui.messagebox.showinfo("Saved", f"FX Preset '{name}' saved.") - self.refresh_fx_presets() - if hasattr(self, 'fx_preset_combo'): self.fx_preset_combo.set(name) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(name) - except Exception as e: - gui.messagebox.showerror("Error", f"Failed to save FX preset: {e}") - - def load_fx_preset(self, name): - if name == "Select FX Preset...": return - - safe_name = os.path.basename(name) - if not safe_name: return - fpath = os.path.join(gui.FX_PRESETS_DIR, f"{safe_name}.json") - if os.path.exists(fpath): - try: - with open(fpath, "r", encoding="utf-8") as f: - data = json.load(f) - - if "reverb_enabled" in data: self.reverb_enabled.set(data["reverb_enabled"]) - if "reverb_room_size" in data: self.reverb_room_size.set(data["reverb_room_size"]) - if "reverb_wet_level" in data: self.reverb_wet_level.set(data["reverb_wet_level"]) - if "reverb_damping" in data: self.reverb_damping.set(data["reverb_damping"]) - if "reverb_dry_level" in data: self.reverb_dry_level.set(data["reverb_dry_level"]) - if "reverb_width" in data: self.reverb_width.set(data["reverb_width"]) - - if "eq_bass" in data: self.eq_bass.set(data["eq_bass"]) - if "eq_treble" in data: self.eq_treble.set(data["eq_treble"]) - - if "comp_enabled" in data: self.comp_enabled.set(data["comp_enabled"]) - if "comp_threshold" in data: self.comp_threshold.set(data["comp_threshold"]) - if "comp_ratio" in data: self.comp_ratio.set(data["comp_ratio"]) - if "comp_attack" in data: self.comp_attack.set(data["comp_attack"]) - if "comp_release" in data: self.comp_release.set(data["comp_release"]) - - if "distortion_enabled" in data: self.distortion_enabled.set(data["distortion_enabled"]) - if "distortion_drive" in data: self.distortion_drive.set(data["distortion_drive"]) - - if "chorus_enabled" in data: self.chorus_enabled.set(data["chorus_enabled"]) - if "chorus_rate" in data: self.chorus_rate.set(data["chorus_rate"]) - if "chorus_depth" in data: self.chorus_depth.set(data["chorus_depth"]) - if "chorus_mix" in data: self.chorus_mix.set(data["chorus_mix"]) - - if "phaser_enabled" in data: self.phaser_enabled.set(data["phaser_enabled"]) - if "phaser_rate" in data: self.phaser_rate.set(data["phaser_rate"]) - if "phaser_depth" in data: self.phaser_depth.set(data["phaser_depth"]) - if "phaser_mix" in data: self.phaser_mix.set(data["phaser_mix"]) - - if "clipping_enabled" in data: self.clipping_enabled.set(data["clipping_enabled"]) - if "clipping_thresh" in data: self.clipping_thresh.set(data["clipping_thresh"]) - - if "bitcrush_enabled" in data: self.bitcrush_enabled.set(data["bitcrush_enabled"]) - if "bitcrush_depth" in data: self.bitcrush_depth.set(data["bitcrush_depth"]) - - if "gsm_enabled" in data: self.gsm_enabled.set(data["gsm_enabled"]) - - if "highpass_enabled" in data: self.highpass_enabled.set(data["highpass_enabled"]) - if "highpass_freq" in data: self.highpass_freq.set(data["highpass_freq"]) - - if "lowpass_enabled" in data: self.lowpass_enabled.set(data["lowpass_enabled"]) - if "lowpass_freq" in data: self.lowpass_freq.set(data["lowpass_freq"]) - - if "delay_enabled" in data: self.delay_enabled.set(data["delay_enabled"]) - if "delay_time" in data: self.delay_time.set(data["delay_time"]) - if "delay_feedback" in data: self.delay_feedback.set(data["delay_feedback"]) - if "delay_mix" in data: self.delay_mix.set(data["delay_mix"]) - - if "pitch_shift_enabled" in data: self.pitch_shift_enabled.set(data["pitch_shift_enabled"]) - if "pitch_shift_semitones" in data: self.pitch_shift_semitones.set(data["pitch_shift_semitones"]) - - if "limiter_enabled" in data: self.limiter_enabled.set(data["limiter_enabled"]) - if "limiter_threshold" in data: self.limiter_threshold.set(data["limiter_threshold"]) - if "limiter_release" in data: self.limiter_release.set(data["limiter_release"]) - - if "gain_enabled" in data: self.gain_enabled.set(data["gain_enabled"]) - if "gain_db" in data: self.gain_db.set(data["gain_db"]) - - self.update_fx_labels() - - # Sync Combos - if hasattr(self, 'fx_preset_combo'): self.fx_preset_combo.set(name) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(name) - - except Exception as e: - gui.messagebox.showerror("Error", f"Failed to load FX preset: {e}") - - def update_fx_labels(self): - # EQ - if hasattr(self, 'bass_label'): self.bass_label.configure(text=f"Bass: {self.eq_bass.get():.1f} dB") - if hasattr(self, 'treble_label'): self.treble_label.configure(text=f"Treble: {self.eq_treble.get():.1f} dB") - if hasattr(self, 'hpf_label'): self.hpf_label.configure(text=f"Freq: {int(self.highpass_freq.get())} Hz") - if hasattr(self, 'lpf_label'): self.lpf_label.configure(text=f"Freq: {int(self.lowpass_freq.get())} Hz") - - # Comp / Dynamics - if hasattr(self, 'comp_thresh_label'): self.comp_thresh_label.configure(text=f"Thresh: {self.comp_threshold.get():.1f} dB") - if hasattr(self, 'comp_ratio_label'): self.comp_ratio_label.configure(text=f"Ratio: {self.comp_ratio.get():.1f}:1") - if hasattr(self, 'lim_thresh_label'): self.lim_thresh_label.configure(text=f"Thresh: {self.limiter_threshold.get():.1f} dB") - if hasattr(self, 'gain_label'): self.gain_label.configure(text=f"Gain: {self.gain_db.get():.1f} dB") - - # Reverb - if hasattr(self, 'rev_room_label'): self.rev_room_label.configure(text=f"Size: {self.reverb_room_size.get():.2f}") - if hasattr(self, 'rev_wet_label'): self.rev_wet_label.configure(text=f"Wet: {self.reverb_wet_level.get():.2f}") - - # Delay - if hasattr(self, 'dly_time_label'): self.dly_time_label.configure(text=f"Time: {self.delay_time.get():.2f} s") - if hasattr(self, 'dly_mix_label'): self.dly_mix_label.configure(text=f"Mix: {self.delay_mix.get():.2f}") - - # Guitar - if hasattr(self, 'dist_drive_label'): self.dist_drive_label.configure(text=f"Drive: {self.distortion_drive.get():.1f} dB") - if hasattr(self, 'chorus_rate_label'): self.chorus_rate_label.configure(text=f"Rate: {self.chorus_rate.get():.1f} Hz") - if hasattr(self, 'phaser_rate_label'): self.phaser_rate_label.configure(text=f"Rate: {self.phaser_rate.get():.1f} Hz") - if hasattr(self, 'clip_thresh_label'): self.clip_thresh_label.configure(text=f"Thresh: {self.clipping_thresh.get():.1f} dB") - - # Quality / Pitch - if hasattr(self, 'bit_depth_label'): self.bit_depth_label.configure(text=f"Depth: {self.bitcrush_depth.get():.1f}") - if hasattr(self, 'pitch_shift_label'): self.pitch_shift_label.configure(text=f"Shift: {self.pitch_shift_semitones.get():.1f} st") diff --git a/kokoro_gui/ui/generation_tab.py b/kokoro_gui/ui/generation_tab.py deleted file mode 100644 index da1e592..0000000 --- a/kokoro_gui/ui/generation_tab.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Generation tab: input source, voice/speed/output config, and the speaker -presets (`presets/*.json`) that snapshot that config. - -Calls `gui.messagebox`, `gui.ctk.CTkInputDialog`, and reads `gui.PRESETS_DIR` -qualified, at call time, so tests can keep monkeypatching those names on the -`gui` module (the `tts_app` fixture redirects `PRESETS_DIR` into a tmp_path and -replaces `messagebox` with a `MagicMock()`). -""" -import json -import os -import re - -import customtkinter as ctk - -import gui - - -class GenerationTabMixin: - def refresh_presets(self): - presets = ["Select Preset..."] - if os.path.exists(gui.PRESETS_DIR): - files = [f for f in os.listdir(gui.PRESETS_DIR) if f.endswith(".json")] - presets.extend([f[:-5] for f in files]) # Remove .json - - self.preset_combo.configure(values=presets) - self.preset_combo.set("Select Preset...") - - def save_preset_dialog(self): - dialog = gui.ctk.CTkInputDialog(text="Enter preset name:", title="Save Preset") - name = dialog.get_input() - if name: - name = re.sub(r'[<>:"/\\|?*]', '', name).strip() # Sanitize - if not name: return - - data = { - "voice": self.voice_var.get(), - "speed": self.speed_var.get(), - "volume": self.volume_var.get(), - "pitch": self.pitch_var.get(), - "split_pattern": self.split_pattern_var.get(), - "normalize": self.normalize_audio.get(), - "trim": self.trim_silence.get(), - "format": self.output_format_var.get(), - "apply_fx": self.apply_fx_var.get(), - "fx_preset": self.gen_fx_combo.get() - } - - fpath = os.path.join(gui.PRESETS_DIR, f"{name}.json") - try: - with open(fpath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - gui.messagebox.showinfo("Saved", f"Preset '{name}' saved successfully.") - self.refresh_presets() - self.preset_combo.set(name) - except Exception as e: - gui.messagebox.showerror("Error", f"Failed to save preset: {e}") - - def load_preset(self, name): - if name == "Select Preset...": return - - fpath = os.path.join(gui.PRESETS_DIR, f"{name}.json") - if os.path.exists(fpath): - try: - with open(fpath, "r", encoding="utf-8") as f: - data = json.load(f) - - if "voice" in data: self.voice_var.set(data["voice"]) - if "speed" in data: self.speed_var.set(data["speed"]) - if "volume" in data: self.volume_var.set(data["volume"]) - if "pitch" in data: self.pitch_var.set(data["pitch"]) - if "split_pattern" in data: self.split_pattern_var.set(data["split_pattern"]) - if "normalize" in data: self.normalize_audio.set(data["normalize"]) - if "trim" in data: self.trim_silence.set(data["trim"]) - if "format" in data: self.output_format_var.set(data["format"]) - if "apply_fx" in data: self.apply_fx_var.set(data["apply_fx"]) - - if "fx_preset" in data: - fx_name = data["fx_preset"] - if fx_name and fx_name != "Select FX Preset...": - self.load_fx_preset(fx_name) - # Ensure combo is updated (load_fx_preset does this, but being safe) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(fx_name) - - # Update UI labels manually since setting var triggers trace but maybe not UI update logic dependent on callbacks - self.update_audio_labels(0) - self.update_speed_label(self.speed_var.get()) - - # Update split combo logic - target_pat = self.split_pattern_var.get() - for k, v in self.split_map.items(): - if v == target_pat: - self.split_combo.set(k) - break - - except Exception as e: - gui.messagebox.showerror("Error", f"Failed to load preset: {e}") - - def build_generation_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - # Schema-driven fields (PLAN_qt_and_engine_abstraction.md workstream - # 1): split-pattern presets, output-format choices, and the speed - # slider's bounds come from the active backend's config schema - # (kokoro_gui/engines/kokoro.py) instead of being hard-coded a - # second time here. "voice"/"lang_code" stay GUI-resolved below - - # see that schema's docstring for why. - schema_fields = {f.key: f for f in self.backend.get_config_schema()} - - # Move existing logic here - main_frame = ctk.CTkScrollableFrame(parent) - main_frame.pack(fill="both", expand=True, padx=5, pady=5) - main_frame.grid_columnconfigure(0, weight=1) - - # --- 1. Input Section --- - input_frame = ctk.CTkFrame(main_frame) - input_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10)) - input_frame.grid_columnconfigure(0, weight=1) - - ctk.CTkLabel(input_frame, text="Input Source", font=("Roboto", 16, "bold")).grid(row=0, column=0, sticky="w", padx=10, pady=5) - - self.tab_view = ctk.CTkTabview(input_frame, height=150) - self.tab_view.grid(row=1, column=0, sticky="ew", padx=10, pady=5) - - # Text Tab - tab_text = self.tab_view.add("Direct Text") - tab_text.grid_columnconfigure(0, weight=1) - tab_text.grid_rowconfigure(0, weight=1) - - self.text_entry = ctk.CTkTextbox(tab_text, wrap="word") - self.text_entry.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - - # File Tab - tab_file = self.tab_view.add("Load File") - tab_file.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel(tab_file, text="File Path:").grid(row=0, column=0, padx=10, pady=20) - ctk.CTkEntry(tab_file, textvariable=self.file_path_var).grid(row=0, column=1, sticky="ew", padx=5) - ctk.CTkButton(tab_file, text="Browse", width=80, command=self.browse_file).grid(row=0, column=2, padx=10) - ctk.CTkLabel(tab_file, text="Supported: .txt, .pdf, .epub", text_color="gray").grid(row=1, column=1, sticky="w", padx=5) - - # --- 2. Configuration --- - config_frame = ctk.CTkFrame(main_frame) - config_frame.grid(row=1, column=0, sticky="ew", pady=10) - config_frame.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel(config_frame, text="Configuration", font=("Roboto", 16, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", padx=10, pady=5) - - # Presets Row - preset_frame = ctk.CTkFrame(config_frame, fg_color="transparent") - preset_frame.grid(row=0, column=1, sticky="ew", padx=10, pady=5) - - self.preset_combo = ctk.CTkComboBox(preset_frame, values=["Select Preset..."], command=self.load_preset, width=150) - self.preset_combo.pack(side="left", padx=(0,5)) - - ctk.CTkButton(preset_frame, text="💾", width=30, command=self.save_preset_dialog).pack(side="left", padx=2) - ctk.CTkButton(preset_frame, text="🔄", width=30, command=self.refresh_presets).pack(side="left", padx=2) - - self.refresh_presets() - - # Language Selection - ctk.CTkLabel(config_frame, text="Language:").grid(row=1, column=0, sticky="w", padx=10, pady=5) - # Reverse map for display - lang_display_map = {v: k for k, v in self.LANGUAGES.items()} - current_lang_code = self.lang_var.get() - - def on_lang_ui_change(choice): - self.lang_var.set(self.LANGUAGES[choice]) - - self.lang_combo = ctk.CTkComboBox(config_frame, values=list(self.LANGUAGES.keys()), command=on_lang_ui_change) - - # Set initial value - if current_lang_code in lang_display_map: - self.lang_combo.set(lang_display_map[current_lang_code]) - else: - self.lang_combo.set("American English") - - self.lang_combo.grid(row=1, column=1, sticky="ew", padx=10) - - # Voice Selection - ctk.CTkLabel(config_frame, text="Voice:").grid(row=2, column=0, sticky="w", padx=10, pady=5) - self.voice_combo = ctk.CTkComboBox(config_frame, values=self.get_all_voices(), variable=self.voice_var) - self.voice_combo.grid(row=2, column=1, sticky="ew", padx=10) - - # Output Dir - ctk.CTkLabel(config_frame, text="Output Folder:").grid(row=3, column=0, sticky="w", padx=10, pady=5) - dir_row = ctk.CTkFrame(config_frame, fg_color="transparent") - dir_row.grid(row=3, column=1, sticky="ew", padx=10) - dir_row.grid_columnconfigure(0, weight=1) - ctk.CTkEntry(dir_row, textvariable=self.output_dir_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) - ctk.CTkButton(dir_row, text="...", width=40, command=self.browse_directory).grid(row=0, column=1) - - # Filename - ctk.CTkLabel(config_frame, text="Base Filename:").grid(row=4, column=0, sticky="w", padx=10, pady=5) - - file_row = ctk.CTkFrame(config_frame, fg_color="transparent") - file_row.grid(row=4, column=1, sticky="ew", padx=10) - file_row.grid_columnconfigure(0, weight=1) - - ctk.CTkEntry(file_row, textvariable=self.filename_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) - - format_field = schema_fields["format"] - self.format_combo = ctk.CTkComboBox(file_row, values=[label for label, _ in format_field.choices], width=70, variable=self.output_format_var) - self.format_combo.grid(row=0, column=1) - - # Speed - speed_field = schema_fields["speed"] - self.speed_label = ctk.CTkLabel(config_frame, text="Speed: 1.0x") - self.speed_label.grid(row=5, column=0, sticky="w", padx=10, pady=5) - self.speed_slider = ctk.CTkSlider( - config_frame, from_=speed_field.min, to=speed_field.max, - number_of_steps=round((speed_field.max - speed_field.min) / speed_field.step), - variable=self.speed_var, command=self.update_speed_label, - ) - self.speed_slider.grid(row=5, column=1, sticky="ew", padx=10) - - # Split Pattern - ctk.CTkLabel(config_frame, text="Split By:").grid(row=6, column=0, sticky="w", padx=10, pady=5) - self.split_map = dict(schema_fields["split_pattern"].choices) - self.split_combo = ctk.CTkComboBox(config_frame, values=list(self.split_map.keys()), command=self.update_split_pattern) - - # Determine initial selection based on loaded variable - initial_pattern = self.split_pattern_var.get() - initial_key = "Natural (Newlines)" # Default - for k, v in self.split_map.items(): - if v == initial_pattern: - initial_key = k - break - self.split_combo.set(initial_key) - - self.split_combo.grid(row=6, column=1, sticky="ew", padx=10, pady=5) - - # --- 3. Audio Control --- - audio_frame = ctk.CTkFrame(main_frame) - audio_frame.grid(row=2, column=0, sticky="ew", pady=10) - audio_frame.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel(audio_frame, text="Audio Control", font=("Roboto", 16, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", padx=10, pady=5) - - # Volume - self.vol_label = ctk.CTkLabel(audio_frame, text="Volume: 100%") - self.vol_label.grid(row=1, column=0, sticky="w", padx=10, pady=5) - self.vol_slider = ctk.CTkSlider(audio_frame, from_=0.1, to=2.0, number_of_steps=19, variable=self.volume_var, command=self.update_audio_labels) - self.vol_slider.grid(row=1, column=1, sticky="ew", padx=10) - - # Pitch - self.pitch_label = ctk.CTkLabel(audio_frame, text="Pitch: 0 st") - self.pitch_label.grid(row=2, column=0, sticky="w", padx=10, pady=5) - self.pitch_slider = ctk.CTkSlider(audio_frame, from_=-12, to=12, number_of_steps=24, variable=self.pitch_var, command=self.update_audio_labels) - self.pitch_slider.grid(row=2, column=1, sticky="ew", padx=10) - - # FX Preset - ctk.CTkLabel(audio_frame, text="FX Preset:").grid(row=3, column=0, sticky="w", padx=10, pady=5) - fx_row = ctk.CTkFrame(audio_frame, fg_color="transparent") - fx_row.grid(row=3, column=1, sticky="ew", padx=10) - fx_row.grid_columnconfigure(0, weight=1) - - self.gen_fx_combo = ctk.CTkComboBox(fx_row, values=["Select FX Preset..."], command=self.load_fx_preset) - self.gen_fx_combo.pack(side="left", fill="x", expand=True) - ctk.CTkCheckBox(fx_row, text="Apply", variable=self.apply_fx_var, width=60).pack(side="left", padx=5) - - self.refresh_fx_presets() # Ensure values are populated - - # Toggles - toggle_frame = ctk.CTkFrame(audio_frame, fg_color="transparent") - toggle_frame.grid(row=4, column=0, columnspan=2, sticky="ew", padx=10, pady=5) - - ctk.CTkCheckBox(toggle_frame, text="Normalize", variable=self.normalize_audio).pack(side="left", padx=5) - ctk.CTkCheckBox(toggle_frame, text="Trim Silence", variable=self.trim_silence).pack(side="left", padx=5) - - - # --- 4. Advanced Options --- - adv_frame = ctk.CTkFrame(main_frame) - adv_frame.grid(row=3, column=0, sticky="ew", pady=10) - - ctk.CTkLabel(adv_frame, text="Processing Options", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - chk_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") - chk_frame.pack(fill="x", padx=10, pady=5) - - ctk.CTkCheckBox(chk_frame, text="Keep Segments", variable=self.separate_files).pack(side="left", padx=5) - ctk.CTkCheckBox(chk_frame, text="Combine Output", variable=self.combine_post).pack(side="left", padx=5) - ctk.CTkCheckBox(chk_frame, text="Export Subtitles (.srt)", variable=self.export_subtitles).pack(side="left", padx=5) - - # Threads - thread_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") - thread_frame.pack(fill="x", padx=10, pady=5) - ctk.CTkLabel(thread_frame, text="Parallel Threads:").pack(side="left", padx=(5, 10)) - - self.thread_minus_btn = ctk.CTkButton(thread_frame, text="-", width=30, command=lambda: self.change_threads(-1)) - self.thread_minus_btn.pack(side="left", padx=2) - - self.thread_entry = ctk.CTkEntry(thread_frame, textvariable=self.num_threads_var, width=50, justify="center") - self.thread_entry.pack(side="left", padx=2) - - self.thread_plus_btn = ctk.CTkButton(thread_frame, text="+", width=30, command=lambda: self.change_threads(1)) - self.thread_plus_btn.pack(side="left", padx=2) - - ctk.CTkLabel(thread_frame, text="(More threads = High RAM usage)", text_color="orange").pack(side="left", padx=10) diff --git a/kokoro_gui/ui/lexicon_tab.py b/kokoro_gui/ui/lexicon_tab.py deleted file mode 100644 index 5b5ca32..0000000 --- a/kokoro_gui/ui/lexicon_tab.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Lexicon tab: find/replace rules stored in `self.settings["lexicon"]`. - -Calls `gui.messagebox` qualified, at call time, so tests can keep monkeypatching -that name on the `gui` module (the `tts_app` fixture replaces it with a -`MagicMock()`). -""" -import customtkinter as ctk - -import gui - - -class LexiconTabMixin: - def build_lexicon_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - parent.grid_rowconfigure(1, weight=1) # List area expands - - # 1. Add New Entry - add_frame = ctk.CTkFrame(parent) - add_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=10) - - ctk.CTkLabel(add_frame, text="Original Text:").pack(side="left", padx=5) - self.lex_orig_var = ctk.StringVar() - ctk.CTkEntry(add_frame, textvariable=self.lex_orig_var, width=150).pack(side="left", padx=5) - - ctk.CTkLabel(add_frame, text="Replacement:").pack(side="left", padx=5) - self.lex_replace_var = ctk.StringVar() - ctk.CTkEntry(add_frame, textvariable=self.lex_replace_var, width=150).pack(side="left", padx=5) - - ctk.CTkButton(add_frame, text="Add Rule", command=self.add_lexicon_rule).pack(side="left", padx=10) - - # 2. List - self.lex_list_frame = ctk.CTkScrollableFrame(parent) - self.lex_list_frame.grid(row=1, column=0, sticky="nsew", padx=10, pady=5) - - self.refresh_lexicon_list() - - # 3. Help Text - ctk.CTkLabel(parent, text="Note: Replacements are case-insensitive. Applied before generation.", text_color="gray").grid(row=2, column=0, pady=5) - - def add_lexicon_rule(self): - orig = self.lex_orig_var.get().strip() - rep = self.lex_replace_var.get().strip() - - if not orig: - gui.messagebox.showwarning("Error", "Original text cannot be empty.") - return - - if "lexicon" not in self.settings: - self.settings["lexicon"] = {} - - self.settings["lexicon"][orig] = rep - self.lex_orig_var.set("") - self.lex_replace_var.set("") - self.save_settings() - self.refresh_lexicon_list() - - def delete_lexicon_rule(self, key): - if key in self.settings.get("lexicon", {}): - del self.settings["lexicon"][key] - self.save_settings() - self.refresh_lexicon_list() - - def refresh_lexicon_list(self): - for widget in self.lex_list_frame.winfo_children(): - widget.destroy() - - lexicon = self.settings.get("lexicon", {}) - if not lexicon: - ctk.CTkLabel(self.lex_list_frame, text="No rules defined.", text_color="gray").pack(pady=10) - return - - for i, (orig, rep) in enumerate(lexicon.items()): - row = ctk.CTkFrame(self.lex_list_frame) - row.pack(fill="x", pady=2) - - ctk.CTkLabel(row, text=orig, width=150, anchor="w", font=("Consolas", 12)).pack(side="left", padx=10) - ctk.CTkLabel(row, text="->", width=30).pack(side="left") - ctk.CTkLabel(row, text=rep, width=150, anchor="w", font=("Consolas", 12)).pack(side="left", padx=10) - - ctk.CTkButton(row, text="X", width=30, fg_color="#c42b1c", command=lambda k=orig: self.delete_lexicon_rule(k)).pack(side="right", padx=5) diff --git a/kokoro_gui/ui/mixing_tab.py b/kokoro_gui/ui/mixing_tab.py deleted file mode 100644 index 5d05ec0..0000000 --- a/kokoro_gui/ui/mixing_tab.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Custom Voice (mixing) tab: blends two voice tensors via `self.engine.mix_voices` -and previews/saves the result. - -Calls `gui.messagebox` qualified, at call time, so tests can keep monkeypatching -that name on the `gui` module (the `tts_app` fixture replaces it with a -`MagicMock()`). `playback` is not a `gui`-level monkeypatch target in the test -suite, so it's imported normally here, same as the original `gui.py`. -""" -import os -import re -import tempfile - -import customtkinter as ctk -import playback - -import gui - - -class MixingTabMixin: - def _update_mix_voice_list(self, lang_var, combo_attr, voice_var): - code = lang_var.get() - if hasattr(self, combo_attr): - combo = getattr(self, combo_attr) - voices = self.get_all_voices(code) - combo.configure(values=voices) - if voice_var.get() not in voices: - voice_var.set(voices[0]) - - def on_mix_lang_a_change(self, *args): - self._update_mix_voice_list(self.mix_lang_a_var, 'mix_combo_a', self.mix_voice_a_var) - - def on_mix_lang_b_change(self, *args): - self._update_mix_voice_list(self.mix_lang_b_var, 'mix_combo_b', self.mix_voice_b_var) - - def refresh_voice_lists(self): - # Update Gen Tab Combo - if hasattr(self, 'voice_combo'): - self.voice_combo.configure(values=self.get_all_voices(self.lang_var.get())) - - # Update Mix Tab Combos - self.on_mix_lang_a_change() - self.on_mix_lang_b_change() - - # Update Custom List - if hasattr(self, 'custom_list_frame'): - for widget in self.custom_list_frame.winfo_children(): - widget.destroy() - - all_voices = self.get_all_voices(self.lang_var.get()) - custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] - if not custom: - ctk.CTkLabel(self.custom_list_frame, text="No custom voices found.", text_color="gray").pack(pady=5) - else: - for cv in sorted(custom): - row = ctk.CTkFrame(self.custom_list_frame) - row.pack(fill="x", pady=2) - ctk.CTkLabel(row, text=cv).pack(side="left", padx=5) - ctk.CTkButton(row, text="X", width=30, fg_color="#c42b1c", command=lambda v=cv: self.delete_custom_voice(v)).pack(side="right", padx=5) - - def delete_custom_voice(self, name): - if gui.messagebox.askyesno("Confirm", f"Delete voice '{name}'?"): - try: - path = os.path.join("custom_voices", f"{name}.pt") - if os.path.exists(path): - os.remove(path) - self.refresh_voice_lists() - except Exception as e: - gui.messagebox.showerror("Error", f"Failed to delete: {e}") - - def preview_mix(self): - v1 = self.mix_voice_a_var.get() - v2 = self.mix_voice_b_var.get() - ratio = self.mix_ratio_var.get() - op = self.mix_op_var.get() - preview_lang = self.preview_lang_var.get() - - preview_text = "This is a preview of your custom mixed voice." - if preview_lang == 'f': preview_text = "Ceci est un aperçu de votre voix personnalisée." - elif preview_lang == 'e': preview_text = "Esta es una vista previa de su voz personalizada." - elif preview_lang == 'i': preview_text = "Questa è un'anteprima della tua voce personalizzata." - elif preview_lang == 'p': preview_text = "Esta é uma prévia da sua voz personalizada." - elif preview_lang == 'j': preview_text = "これはカスタム合成音声のプレビューです。" - elif preview_lang == 'z': preview_text = "这是您的自定义混合语音预览。" - - # Temp voice name and file - tmp_voice_name = "_tmp_mix_preview" - tmp_audio_path = os.path.join(tempfile.gettempdir(), "kokoro_mix_preview.wav") - - self.mix_status_label.configure(text="Generating preview...", text_color="blue") - - async def _run_preview(): - # 1. Mix to a temporary file (we ignore the file for preview, use tensor) - success, msg, tensor = await self.engine.mix_voices(v1, v2, ratio, tmp_voice_name, op=op) - if not success: - return False, msg - - # 2. Generate audio using that mixed voice tensor and target preview language - success = await self.engine.generate_preview(preview_text, tmp_voice_name, 1.0, tmp_audio_path, voice_tensor=tensor, lang_code=preview_lang) - - # 3. Cleanup temp voice file - try: - p = os.path.join("custom_voices", f"{tmp_voice_name}.pt") - if os.path.exists(p): os.remove(p) - except Exception: pass - - return success, "" - - def _on_done(future): - try: - success, err = future.result() - if success: - self.after(0, lambda: self.mix_status_label.configure(text="Playing preview...", text_color="green")) - playback.play(tmp_audio_path) - else: - self.after(0, lambda: self.mix_status_label.configure(text=f"Preview failed: {err}", text_color="red")) - except Exception as e: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {e}", text_color="red")) - - future = self.engine.worker.run_coro(_run_preview()) - future.add_done_callback(_on_done) - - def mix_voice_action(self): - v1 = self.mix_voice_a_var.get() - v2 = self.mix_voice_b_var.get() - ratio = self.mix_ratio_var.get() - op = self.mix_op_var.get() - name = self.mix_name_var.get().strip() - - if not name: - gui.messagebox.showwarning("Error", "Please enter a name for the new voice.") - return - - if not re.match(r'^[a-zA-Z0-9_-]+$', name): - gui.messagebox.showwarning("Error", "Invalid name. Use alphanumeric, _, - only.") - return - - if name in self.get_all_voices(): - if not gui.messagebox.askyesno("Overwrite", f"Voice '{name}' exists. Overwrite?"): - return - - self.mix_status_label.configure(text="Mixing...", text_color="blue") - self.set_ui_state(True) # Reuse existing lock - - def _done(future): - self.after(0, lambda: self.set_ui_state(False)) - try: - success, msg, _ = future.result() - if success: - self.after(0, lambda: self.mix_status_label.configure(text=f"Saved: {name}", text_color="green")) - self.after(0, self.refresh_voice_lists) - else: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {msg}", text_color="red")) - except Exception as e: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {e}", text_color="red")) - - future = self.engine.worker.run_coro(self.engine.mix_voices(v1, v2, ratio, name, op=op)) - future.add_done_callback(_done) - - def build_mixing_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - lang_display_map = {v: k for k, v in self.LANGUAGES.items()} - - # 1. Selection - sel_frame = ctk.CTkFrame(parent) - sel_frame.pack(fill="x", padx=10, pady=10) - sel_frame.grid_columnconfigure(1, weight=1) - sel_frame.grid_columnconfigure(2, weight=1) - - # Voice A Row - ctk.CTkLabel(sel_frame, text="Voice A:").grid(row=0, column=0, padx=10, pady=5) - - def on_lang_a_ui(c): self.mix_lang_a_var.set(self.LANGUAGES[c]) - mix_lang_a_combo = ctk.CTkComboBox(sel_frame, values=list(self.LANGUAGES.keys()), command=on_lang_a_ui, width=150) - mix_lang_a_combo.set(lang_display_map.get(self.mix_lang_a_var.get(), "American English")) - mix_lang_a_combo.grid(row=0, column=1, padx=5, pady=5, sticky="ew") - - self.mix_combo_a = ctk.CTkComboBox(sel_frame, variable=self.mix_voice_a_var) - self.mix_combo_a.grid(row=0, column=2, sticky="ew", padx=5, pady=5) - - # Voice B Row - ctk.CTkLabel(sel_frame, text="Voice B:").grid(row=1, column=0, padx=10, pady=5) - - def on_lang_b_ui(c): self.mix_lang_b_var.set(self.LANGUAGES[c]) - mix_lang_b_combo = ctk.CTkComboBox(sel_frame, values=list(self.LANGUAGES.keys()), command=on_lang_b_ui, width=150) - mix_lang_b_combo.set(lang_display_map.get(self.mix_lang_b_var.get(), "American English")) - mix_lang_b_combo.grid(row=1, column=1, padx=5, pady=5, sticky="ew") - - self.mix_combo_b = ctk.CTkComboBox(sel_frame, variable=self.mix_voice_b_var) - self.mix_combo_b.grid(row=1, column=2, sticky="ew", padx=5, pady=5) - - # 2. Ratio & Operation - ratio_frame = ctk.CTkFrame(parent) - ratio_frame.pack(fill="x", padx=10, pady=10) - - op_frame = ctk.CTkFrame(ratio_frame, fg_color="transparent") - op_frame.pack(fill="x", padx=20, pady=(10, 0)) - ctk.CTkLabel(op_frame, text="Operation:").pack(side="left", padx=5) - - def update_ratio_label(val=None): - if val is None: val = self.mix_ratio_var.get() - p = int(float(val) * 100) - op = self.mix_op_var.get() - if op == 'mix': - self.ratio_label.configure(text=f"Mix: {100-p}% A / {p}% B", text_color=("black", "white")) - elif op == 'divide': - self.ratio_label.configure(text=f"Op: Divide | Influence: {p}%\n(Results are more likely to be unstable and VERY LOUD)", text_color="#E57373") - else: - self.ratio_label.configure(text=f"Op: {op.capitalize()} | Influence: {p}%", text_color=("black", "white")) - - ctk.CTkComboBox(op_frame, values=["mix", "add", "subtract", "multiply", "divide"], variable=self.mix_op_var, command=lambda _: update_ratio_label()).pack(side="left", padx=5) - - self.ratio_label = ctk.CTkLabel(ratio_frame, text="Mix: 50% A / 50% B") - self.ratio_label.pack(pady=5) - - slider = ctk.CTkSlider(ratio_frame, from_=0.0, to=1.0, number_of_steps=100, variable=self.mix_ratio_var, command=update_ratio_label) - slider.pack(fill="x", padx=20, pady=10) - - update_ratio_label() - - # 3. Preview Lang & Actions - act_frame = ctk.CTkFrame(parent) - act_frame.pack(fill="x", padx=10, pady=10) - - ctk.CTkLabel(act_frame, text="Preview Language:").grid(row=0, column=0, padx=10, pady=5) - - def on_prev_lang_ui(c): self.preview_lang_var.set(self.LANGUAGES[c]) - prev_lang_combo = ctk.CTkComboBox(act_frame, values=list(self.LANGUAGES.keys()), command=on_prev_lang_ui, width=150) - prev_lang_combo.set(lang_display_map.get(self.preview_lang_var.get(), "American English")) - prev_lang_combo.grid(row=0, column=1, padx=5, pady=5) - - ctk.CTkButton(act_frame, text="🔊 Preview", width=100, fg_color="#2B719E", command=self.preview_mix).grid(row=0, column=2, padx=10) - - # Save Row - save_frame = ctk.CTkFrame(parent) - save_frame.pack(fill="x", padx=10, pady=10) - - ctk.CTkLabel(save_frame, text="New Voice Name:").pack(side="left", padx=10) - ctk.CTkEntry(save_frame, textvariable=self.mix_name_var).pack(side="left", fill="x", expand=True, padx=5) - ctk.CTkButton(save_frame, text="Create & Save", command=self.mix_voice_action).pack(side="left", padx=10) - - self.mix_status_label = ctk.CTkLabel(parent, text="", text_color="gray") - self.mix_status_label.pack(pady=5) - - # 4. List - ctk.CTkLabel(parent, text="Custom Voices:", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=(20,5)) - self.custom_list_frame = ctk.CTkScrollableFrame(parent, height=200) - self.custom_list_frame.pack(fill="x", padx=10, pady=5) - - self.refresh_voice_lists() diff --git a/main.py b/main.py index 61b9438..3b0a9b7 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,15 @@ -import gui +"""Entry point for the PySide6 (Qt) frontend - the sole GUI frontend since the +Tk frontend (gui.py) was retired (see PLAN_qt_and_engine_abstraction.md, +workstream 3a). PySide6 is a regular dependency in `requirements.txt`. +""" +import sys + +from PySide6.QtWidgets import QApplication + +from kokoro_gui.qt.app import QtTTSApp if __name__ == "__main__": - app = gui.TTSApp() - app.mainloop() \ No newline at end of file + app = QApplication(sys.argv) + window = QtTTSApp() + window.show() + sys.exit(app.exec()) diff --git a/main_qt.py b/main_qt.py deleted file mode 100644 index 930b9c5..0000000 --- a/main_qt.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Entry point for the PySide6 (Qt) frontend - ships alongside main.py's Tk -frontend during the workstream 3a transition (see -PLAN_qt_and_engine_abstraction.md). Requires the optional `requirements-qt.txt` -extras (`pip install -r requirements-qt.txt`). -""" -import sys - -from PySide6.QtWidgets import QApplication - -from kokoro_gui.qt.app import QtTTSApp - -if __name__ == "__main__": - app = QApplication(sys.argv) - window = QtTTSApp() - window.show() - sys.exit(app.exec()) diff --git a/requirements-qt-test.txt b/requirements-qt-test.txt deleted file mode 100644 index 2476d15..0000000 --- a/requirements-qt-test.txt +++ /dev/null @@ -1 +0,0 @@ -pytest-qt==4.5.0 diff --git a/requirements-qt.txt b/requirements-qt.txt deleted file mode 100644 index 27c14d2..0000000 --- a/requirements-qt.txt +++ /dev/null @@ -1 +0,0 @@ -PySide6==6.11.2 diff --git a/requirements-test.txt b/requirements-test.txt index 039d26e..713af92 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1 +1,2 @@ pytest>=8.0 +pytest-qt==4.5.0 diff --git a/requirements.txt b/requirements.txt index ea0d45b..5b417f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,5 +9,4 @@ pedalboard soundfile==0.13.1 sounddevice torch==2.13.0 -customtkinter -packaging \ No newline at end of file +PySide6==6.11.2 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 712fbd3..a916dc4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,6 @@ """ import os import re -import sys import time import threading import concurrent.futures @@ -25,20 +24,8 @@ import kokoro_engine from kokoro_engine import KokoroEngine -# On some Windows Store ("WindowsApps") Python installs, Tcl/Tk's own -# init.tcl discovery intermittently fails against the package-virtualized -# path when many Tk() roots are created/destroyed across a test session -# (each GUI test builds a real TTSApp). Pointing TCL_LIBRARY/TK_LIBRARY at -# the known-good path once avoids repeated, occasionally-flaky rediscovery. -_tcl_dir = os.path.join(sys.base_prefix, "tcl", "tcl8.6") -_tk_dir = os.path.join(sys.base_prefix, "tcl", "tk8.6") -if os.path.isdir(_tcl_dir): - os.environ.setdefault("TCL_LIBRARY", _tcl_dir) -if os.path.isdir(_tk_dir): - os.environ.setdefault("TK_LIBRARY", _tk_dir) - -# One shared timestamp per pytest invocation, mirroring gui.py's -# self.timecode_format = "%Y%m%d%H%M%S" convention (gui.py:96). +# One shared timestamp per pytest invocation, mirroring the Qt frontend's +# "%Y%m%d%H%M%S" timecode convention (kokoro_gui/qt/app.py). _RUN_TS = time.strftime("%Y%m%d%H%M%S") @@ -185,6 +172,11 @@ def espeak_available(): # --------------------------------------------------------------------------- # GUI-level fixtures # --------------------------------------------------------------------------- +# +# The Tk frontend (gui.py, kokoro_gui/ui/) has been retired now that the Qt +# frontend (kokoro_gui/qt/) reached parity - see PLAN_qt_and_engine_abstraction.md. +# StubEngine stays here (not moved into tests/gui_qt/) because it's imported +# by tests/gui_qt/conftest.py's `qt_app` fixture too. class StubEngine: """Drop-in replacement for KokoroEngine used by GUI tests - never touches @@ -204,36 +196,3 @@ def __init__(self): self.mix_voices = MagicMock() self.extract_text_from_file = MagicMock(return_value="") self.cancel = MagicMock() - - -@pytest.fixture -def tts_app(tmp_path, monkeypatch): - import gui - import tkinter - - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(gui, "CONFIG_FILE", str(tmp_path / "config.json")) - monkeypatch.setattr(gui, "PRESETS_DIR", str(tmp_path / "presets")) - monkeypatch.setattr(gui, "FX_PRESETS_DIR", str(tmp_path / "presets" / "fx")) - monkeypatch.setattr(gui, "KokoroEngine", StubEngine) - monkeypatch.setattr(gui, "messagebox", MagicMock()) - monkeypatch.setattr(gui, "filedialog", MagicMock()) - (tmp_path / "custom_voices").mkdir() - - # Creating many real Tk() interpreters across a test session intermittently - # hits the same WindowsApps init.tcl read glitch as above - retry a few - # times rather than failing the whole test on a transient hiccup. - app = None - last_err = None - for _ in range(5): - try: - app = gui.TTSApp() - break - except tkinter.TclError as e: - last_err = e - time.sleep(0.2) - if app is None: - raise last_err - - yield app - app.destroy() diff --git a/tests/gui_qt/conftest.py b/tests/gui_qt/conftest.py index db3425d..54140e9 100644 --- a/tests/gui_qt/conftest.py +++ b/tests/gui_qt/conftest.py @@ -34,13 +34,6 @@ def qt_app(tmp_path, monkeypatch, qtbot): monkeypatch.setattr(qt_app_module, "PRESETS_DIR", str(tmp_path / "presets")) monkeypatch.setattr(qt_app_module, "FX_PRESETS_DIR", str(tmp_path / "presets" / "fx")) monkeypatch.setattr(qt_app_module, "KokoroEngine", StubEngine) - # exist_ok: a test combining this fixture with tests/conftest.py's - # tts_app fixture shares the same tmp_path (pytest's tmp_path is - # function-scoped, so both fixtures see the identical directory) and - # tts_app also creates this dir - whichever fixture runs second must not - # fail on it already existing. That existing fixture can't be edited - # (see the plan's "zero edits to any existing test file"), so this side - # tolerates it instead. (tmp_path / "custom_voices").mkdir(exist_ok=True) # Modal dialogs (QMessageBox.exec/QInputDialog.exec/...) block on the diff --git a/tests/gui_qt/test_qt_config_assembly.py b/tests/gui_qt/test_qt_config_assembly.py index 0d44247..d46737a 100644 --- a/tests/gui_qt/test_qt_config_assembly.py +++ b/tests/gui_qt/test_qt_config_assembly.py @@ -1,13 +1,5 @@ -"""Config-dict contract parity between the Qt and Tk frontends. - -`tests/test_gui_config_assembly.py` is the existing source of truth for what -gui.py's `start_conversion` actually assembles. This file asserts Qt's -`_assemble_config()` produces the identical key set - both against the -mirrored constants in kokoro_gui/qt/spec.py *and* against a live capture of -Tk's own assembled dict via the existing `tts_app` fixture - so a future edit -to either frontend's config dict that isn't mirrored in the other is a test -failure, not a silent drift. -""" +"""Config-dict assembly contract for the Qt frontend's `_assemble_config()`, +checked against the mirrored constants in kokoro_gui/qt/spec.py.""" from kokoro_gui.qt import spec @@ -31,27 +23,6 @@ def test_assembled_config_time_id_is_timecode(qt_app): assert re.match(r"^\d{14}$", config["time_id"]) -def test_assembled_config_matches_tk_live_capture(tts_app, qt_app): - # tts_app must be resolved before qt_app: both share one tmp_path (pytest's - # tmp_path fixture is function-scoped) and each fixture creates - # "custom_voices" in it - tts_app's own mkdir() (unmodifiable, see - # tests/conftest.py) has no exist_ok, so it must run first; qt_app's does - # tolerate the directory already existing (see tests/gui_qt/conftest.py). - """Cross-frontend parity: whatever key set gui.py's start_conversion - actually builds (captured live through the Tk `tts_app` fixture + - StubEngine, exactly like tests/test_gui_config_assembly.py does) must - equal the key set Qt assembles - not just what spec.py claims.""" - tts_app.text_entry.insert("1.0", "Hello world") - tts_app.start_conversion() - - assert tts_app.engine.start_conversion.called - tk_text, tk_config = tts_app.engine.start_conversion.call_args[0] - - qt_config = qt_app._assemble_config() - - assert set(tk_config.keys()) == set(qt_config.keys()) - - def test_generation_dock_state_covers_base_keys_minus_settings_owned(qt_app): """Everything _assemble_config adds on top of the Generation dock's own get_state() (engine_id/time_id/lexicon) is intentionally settings-owned, diff --git a/tests/gui_qt/test_qt_engine_backend.py b/tests/gui_qt/test_qt_engine_backend.py index b502cbf..1dfda2e 100644 --- a/tests/gui_qt/test_qt_engine_backend.py +++ b/tests/gui_qt/test_qt_engine_backend.py @@ -1,6 +1,6 @@ -"""Engine-picker switch behavior - including the fix for gui.py's known gap -(gui.py:530-538: switch_engine doesn't re-render the Generation tab's -schema-driven fields). See app.py's `switch_engine` docstring.""" +"""Engine-picker switch behavior, including re-rendering the Generation +dock's schema-driven fields for the newly-active backend. See app.py's +`switch_engine` docstring.""" from kokoro_gui.engines import registry as engine_registry @@ -27,9 +27,9 @@ def test_switch_back_to_kokoro_shows_mixing_dock_again(qt_app): def test_switch_engine_rebuilds_schema_form_for_new_backend(qt_app): - """The actual Qt-specific improvement over Tk: the Generation dock's - schema-driven fields must reflect the newly-active backend's schema, not - stay frozen at whatever the first backend built (gui.py's own gap).""" + """The Generation dock's schema-driven fields must reflect the + newly-active backend's schema, not stay frozen at whatever the first + backend built.""" original_form = qt_app.generation_dock.schema_form qt_app.switch_engine("dummy") assert qt_app.generation_dock.schema_form is not original_form diff --git a/tests/gui_qt/test_qt_lexicon.py b/tests/gui_qt/test_qt_lexicon.py index 6f3fff7..0532fa0 100644 --- a/tests/gui_qt/test_qt_lexicon.py +++ b/tests/gui_qt/test_qt_lexicon.py @@ -1,5 +1,5 @@ """Lexicon CRUD roundtrips into settings["lexicon"] with eager save (bypasses -the debounced autosave, same as kokoro_gui/ui/lexicon_tab.py).""" +the debounced autosave).""" import json diff --git a/tests/gui_qt/test_qt_presets.py b/tests/gui_qt/test_qt_presets.py index 6b88d21..2264987 100644 --- a/tests/gui_qt/test_qt_presets.py +++ b/tests/gui_qt/test_qt_presets.py @@ -1,6 +1,4 @@ -"""Generation + FX preset save/load (presets/*.json, presets/fx/*.json - -shared with the Tk frontend, see spec.py's module docstring), and the -cross-frontend load that proves that sharing actually holds.""" +"""Generation + FX preset save/load (presets/*.json, presets/fx/*.json).""" import json import os @@ -66,42 +64,3 @@ def test_load_fx_preset_applies_values_and_syncs_gen_combo(qt_app, monkeypatch): assert qt_app.fx_dock._value_widgets["gain_db"].value() == 9.0 assert qt_app.generation_dock.fx_preset_combo.currentText() == "LoudFX" - - -def test_preset_saved_by_tk_loads_correctly_in_qt(tts_app, qt_app, monkeypatch): - """The shared-presets design decision (see the plan): a preset written by - the Tk frontend must load correctly through Qt, and vice versa, since - both point at the same presets/*.json directory for a shared tmp_path. - - tts_app must be requested before qt_app - see the comment in - test_qt_config_assembly.py's equivalent test.""" - import gui - - class FakeDialog: - def __init__(self, *a, **kw): - pass - - def get_input(self): - return "FromTk" - - monkeypatch.setattr(gui.ctk, "CTkInputDialog", FakeDialog) - - tts_app.voice_var.set("af_bella") - tts_app.speed_var.set(1.25) - tts_app.save_preset_dialog() - - qt_app.generation_dock.refresh_presets() - qt_app.generation_dock._on_preset_selected("FromTk") - - state = qt_app.generation_dock.get_state() - assert state["voice"] == "af_bella" - assert state["speed"] == 1.25 - - -def test_fx_preset_saved_by_qt_loads_correctly_in_tk(tts_app, qt_app, monkeypatch): - _stub_get_text(monkeypatch, "FromQt") - qt_app.fx_dock._value_widgets["gain_db"].setValue(4.5) - qt_app.fx_dock._save_preset_dialog() - - tts_app.load_fx_preset("FromQt") - assert tts_app.gain_db.get() == 4.5 diff --git a/tests/test_engine_backend.py b/tests/test_engine_backend.py index 007b248..859fffd 100644 --- a/tests/test_engine_backend.py +++ b/tests/test_engine_backend.py @@ -5,7 +5,6 @@ import pytest -import gui from kokoro_gui.engines import registry from kokoro_gui.engines.base import ConfigField, EngineCapabilities, VoiceInfo from kokoro_gui.engines.dummy import DummyBackendAdapter, DummyEngine @@ -131,39 +130,7 @@ def test_dummy_engine_produces_real_nonsilent_audio(tmp_path): engine.worker.stop() -def test_switch_engine_to_dummy_updates_engine_backend_and_mixing_tab(tts_app): - assert tts_app.backend.id == "kokoro" - assert tts_app._mixing_tab_built is True - - tts_app.switch_engine("dummy") - - assert tts_app.backend.id == "dummy" - assert isinstance(tts_app.engine, DummyEngine) - assert tts_app.engine.on_progress == tts_app.on_engine_progress - assert tts_app.engine.on_status == tts_app.on_engine_status - assert tts_app.engine.on_finish == tts_app.on_engine_finish - assert tts_app._mixing_tab_built is False - - -def test_on_engine_picker_change_maps_display_name_to_id(tts_app): - tts_app.on_engine_picker_change("Dummy (offline test tone)") - assert tts_app.backend.id == "dummy" - - -def test_switch_engine_refuses_while_a_job_is_running(tts_app): - tts_app.cancel_btn.configure(state="normal") # simulate an in-flight job - - tts_app.switch_engine("dummy") - - assert tts_app.backend.id == "kokoro" - assert gui.messagebox.showwarning.called - - -def test_tts_app_wires_a_backend_and_shows_mixing_tab_when_capable(tts_app): - """Mixing tab in create_widgets (gui.py) is gated on - backend.capabilities.supports_voice_mixing - kokoro supports it, so the - tab and its widgets (mixing_tab.py's build_mixing_tab) must still exist.""" - assert tts_app.backend.id == "kokoro" - assert tts_app.backend.capabilities.supports_voice_mixing is True - assert hasattr(tts_app, "mix_combo_a") - assert hasattr(tts_app, "mix_combo_b") +# Engine-picker switch behavior (switch to dummy, mixing-dock visibility, +# refuse-while-job-running, etc.) is covered by +# tests/gui_qt/test_qt_engine_backend.py now that the Tk frontend has been +# retired - see PLAN_qt_and_engine_abstraction.md. diff --git a/tests/test_gui_config_assembly.py b/tests/test_gui_config_assembly.py deleted file mode 100644 index b3d606d..0000000 --- a/tests/test_gui_config_assembly.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Tests for the config-dict assembly contract in start_conversion/ -preview_conversion (gui.py:1528-1747).""" -import os -import re -import tempfile - - -def _set_text(app, text): - app.text_entry.delete("1.0", "end") - app.text_entry.insert("1.0", text) - - -BASE_KEYS = { - "engine_id", "lang_code", "voice", "speed", "split_pattern", "filename", "format", - "out_dir", "separate", "combine", "export_subtitles", "caching", - "time_id", "num_threads", "volume", "pitch", "normalize", - "trim_silence", "lexicon", -} - -FX_KEYS = { - "reverb_enabled", "reverb_room_size", "reverb_wet_level", "reverb_damping", - "reverb_dry_level", "reverb_width", "eq_bass", "eq_treble", - "comp_enabled", "comp_threshold", "comp_ratio", "comp_attack", "comp_release", - "distortion_enabled", "distortion_drive", - "chorus_enabled", "chorus_rate", "chorus_depth", "chorus_mix", - "phaser_enabled", "phaser_rate", "phaser_depth", "phaser_mix", - "clipping_enabled", "clipping_thresh", - "bitcrush_enabled", "bitcrush_depth", "gsm_enabled", - "highpass_enabled", "highpass_freq", "lowpass_enabled", "lowpass_freq", - "delay_enabled", "delay_time", "delay_feedback", "delay_mix", - "pitch_shift_enabled", "pitch_shift_semitones", - "limiter_enabled", "limiter_threshold", "limiter_release", - "gain_enabled", "gain_db", -} - - -def test_start_conversion_assembles_full_key_set(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.apply_fx_var.set(True) - tts_app.start_conversion() - - assert tts_app.engine.start_conversion.called - text_arg, config = tts_app.engine.start_conversion.call_args[0] - assert text_arg == "Hello world." - assert BASE_KEYS <= config.keys() - assert FX_KEYS <= config.keys() - assert re.fullmatch(r"\d{14}", config["time_id"]) - - -def test_assembled_config_covers_active_backend_schema_keys(tts_app): - """PLAN_qt_and_engine_abstraction.md workstream 1: every field the active - backend's schema declares (kokoro_gui/engines/kokoro.py) must actually be - assembled into the config dict start_conversion sends the engine - the - schema is meant to describe that dict, not drift from it.""" - _set_text(tts_app, "Hello world.") - tts_app.start_conversion() - - _, config = tts_app.engine.start_conversion.call_args[0] - schema_keys = {f.key for f in tts_app.backend.get_config_schema()} - assert schema_keys <= config.keys() - - -def test_start_conversion_apply_fx_false_omits_fx_keys(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.apply_fx_var.set(False) - tts_app.start_conversion() - - _, config = tts_app.engine.start_conversion.call_args[0] - assert "reverb_enabled" not in config - assert "gain_db" not in config - - -def test_start_conversion_jit_enabled_routes_to_start_jit_conversion(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.jit_enabled.set(True) - tts_app.start_conversion() - - assert tts_app.engine.start_jit_conversion.called - assert not tts_app.engine.start_conversion.called - - -def test_start_conversion_blocks_when_pipeline_not_ready(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.engine.pipeline = None - tts_app.start_conversion() - - assert not tts_app.engine.start_conversion.called - assert not tts_app.engine.start_jit_conversion.called - - -def test_start_conversion_empty_text_shows_warning(tts_app): - _set_text(tts_app, "") - tts_app.start_conversion() - - assert not tts_app.engine.start_conversion.called - assert not tts_app.engine.start_jit_conversion.called - - -def test_preview_conversion_assembles_smaller_extra_config(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.apply_fx_var.set(False) - tts_app.preview_conversion() - - assert tts_app.engine.generate_preview.called - args, kwargs = tts_app.engine.generate_preview.call_args - preview_text, voice, speed, out_path, extra_config = args[:5] - assert set(extra_config.keys()) == {"volume", "pitch", "normalize", "trim_silence", "lexicon"} - assert voice == tts_app.voice_var.get() - assert speed == tts_app.speed_var.get() - - -def test_preview_conversion_apply_fx_true_adds_fx_keys(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.apply_fx_var.set(True) - tts_app.preview_conversion() - - args, kwargs = tts_app.engine.generate_preview.call_args - extra_config = args[4] - assert FX_KEYS <= extra_config.keys() - assert "voice" not in extra_config - assert "out_dir" not in extra_config - - -def test_preview_conversion_uses_tempdir_wav_path(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.preview_conversion() - - args, kwargs = tts_app.engine.generate_preview.call_args - out_path = args[3] - assert out_path == os.path.join(tempfile.gettempdir(), "kokoro_preview.wav") diff --git a/tests/test_gui_handlers.py b/tests/test_gui_handlers.py deleted file mode 100644 index 50bbca3..0000000 --- a/tests/test_gui_handlers.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for assorted GUI event handlers: lexicon add/delete, thread-count -clamp, mix-name validation, preset save-dialog sanitization, load_fx_preset -safety, and a documented existing bug in refresh_voice_lists.""" -import json -import os - -import pytest - - -def test_add_lexicon_rule_persists_and_refreshes(tts_app): - import gui - tts_app.lex_orig_var.set("hello") - tts_app.lex_replace_var.set("hi") - tts_app.add_lexicon_rule() - - assert tts_app.settings["lexicon"]["hello"] == "hi" - with open(gui.CONFIG_FILE, "r", encoding="utf-8") as f: - saved = json.load(f) - assert saved["lexicon"]["hello"] == "hi" - - -def test_add_lexicon_rule_empty_original_shows_warning(tts_app): - import gui - tts_app.lex_orig_var.set("") - tts_app.lex_replace_var.set("hi") - tts_app.add_lexicon_rule() - - assert tts_app.settings.get("lexicon", {}) == {} - assert gui.messagebox.showwarning.called - - -def test_delete_lexicon_rule_removes_key(tts_app): - tts_app.settings["lexicon"] = {"hello": "hi"} - tts_app.delete_lexicon_rule("hello") - - assert "hello" not in tts_app.settings["lexicon"] - - -@pytest.mark.parametrize("start,delta,expected", [ - (1, -5, 1), - (16, 5, 16), - (5, 2, 7), -]) -def test_change_threads_clamps_1_to_16(tts_app, start, delta, expected): - tts_app.num_threads_var.set(start) - tts_app.change_threads(delta) - assert tts_app.num_threads_var.get() == expected - - -def test_mix_voice_action_rejects_invalid_name_chars(tts_app): - tts_app.mix_name_var.set("bad name!") - tts_app.mix_voice_action() - - assert not tts_app.engine.mix_voices.called - - -def test_mix_voice_action_prompts_overwrite_confirmation(tts_app): - import gui - existing = tts_app.get_all_voices()[0] - tts_app.mix_name_var.set(existing) - gui.messagebox.askyesno.return_value = False - - tts_app.mix_voice_action() - - assert not tts_app.engine.mix_voices.called - - -def test_save_preset_dialog_sanitizes_name(tts_app, monkeypatch): - import gui - - class FakeDialog: - def __init__(self, *a, **kw): - pass - - def get_input(self): - return 'Bad/Na:me' - - monkeypatch.setattr(gui.ctk, "CTkInputDialog", FakeDialog) - tts_app.save_preset_dialog() - - assert os.path.exists(os.path.join(gui.PRESETS_DIR, "BadName.json")) - - -def test_save_fx_preset_dialog_sanitizes_name(tts_app, monkeypatch): - import gui - - class FakeDialog: - def __init__(self, *a, **kw): - pass - - def get_input(self): - return 'Weird?Nam*e' - - monkeypatch.setattr(gui.ctk, "CTkInputDialog", FakeDialog) - tts_app.save_fx_preset_dialog() - - assert os.path.exists(os.path.join(gui.FX_PRESETS_DIR, "WeirdName.json")) - - -def test_load_fx_preset_basename_sanitized(tts_app): - import gui - os.makedirs(gui.FX_PRESETS_DIR, exist_ok=True) - with open(os.path.join(gui.FX_PRESETS_DIR, "real.json"), "w", encoding="utf-8") as f: - json.dump({"gain_db": 3.0}, f) - - tts_app.load_fx_preset("../../real") - - assert tts_app.gain_db.get() == 3.0 - - -def test_refresh_voice_lists_crashes_if_custom_voices_dir_missing(tts_app): - # Documents an existing asymmetry at gui.py:693 (os.listdir with no - # os.path.exists guard), unlike get_all_voices (gui.py:192) which does - # guard. Pins current behavior - do not silently "fix" by changing this - # assertion; if the guard is added, update this test deliberately. - os.rmdir("custom_voices") - with pytest.raises(FileNotFoundError): - tts_app.refresh_voice_lists() diff --git a/tests/test_gui_settings.py b/tests/test_gui_settings.py deleted file mode 100644 index fb6d20a..0000000 --- a/tests/test_gui_settings.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Tests for TTSApp.load_settings/save_settings/apply_settings -(gui.py:263-436).""" -import json - - -def test_load_settings_defaults_when_no_config_file(tts_app): - settings = tts_app.load_settings() - assert settings["voice"] == "af_heart" - assert settings["lexicon"] == {} - assert settings["caching"] is True - - -def test_load_settings_merges_existing_config_json(tts_app): - import gui - with open(gui.CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump({"voice": "am_adam"}, f) - - settings = tts_app.load_settings() - - assert settings["voice"] == "am_adam" - assert settings["format"] == "wav" # untouched default still present - - -def test_load_settings_corrupt_json_falls_back_to_defaults(tts_app): - import gui - with open(gui.CONFIG_FILE, "w", encoding="utf-8") as f: - f.write("{not valid json") - - settings = tts_app.load_settings() - - assert settings["voice"] == "af_heart" - - -def test_save_settings_writes_json_with_current_vars(tts_app): - import gui - tts_app.voice_var.set("am_liam") - tts_app.save_settings() - - with open(gui.CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - assert data["voice"] == "am_liam" - - -def test_change_appearance_and_scaling_persist_to_settings(tts_app): - tts_app.change_appearance("Light") - tts_app.change_scaling("120%") - - assert tts_app.settings["appearance"] == "Light" - assert tts_app.settings["scaling"] == "120%" From 85d8ce0afe356f5077666fbbc8c55154c70a61a4 Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 16:42:02 -0600 Subject: [PATCH 07/44] What I changed (generation_dock.py, app.py): MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted the dead thread_row/self.threads_spin QSpinBox from "Processing Options" and its valueChanged wiring. Fixed set_ui_state() in app.py:332, which referenced threads_spin.setEnabled(...) — now disables the real control via self.generation_dock.schema_form.widget_for("num_threads"). Removed a start_conversion() guard in app.py:395-398 that clamped threads_spin.value() to ≥1 — redundant even before, since the schema QSpinBox already has setRange(min=1, max=32) baked in from the ConfigField, so it can never go below 1 anyway. Dropped the now-unused QSpinBox import. --- kokoro_gui/qt/app.py | 8 +++----- kokoro_gui/qt/docks/generation_dock.py | 14 ++------------ 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/kokoro_gui/qt/app.py b/kokoro_gui/qt/app.py index fced2a5..1ee040a 100644 --- a/kokoro_gui/qt/app.py +++ b/kokoro_gui/qt/app.py @@ -329,7 +329,9 @@ def set_ui_state(self, is_running: bool) -> None: self.start_btn.setEnabled(not is_running) self.preview_btn.setEnabled(not is_running) self.cancel_btn.setEnabled(is_running) - self.generation_dock.threads_spin.setEnabled(not is_running) + threads_widget = self.generation_dock.schema_form.widget_for("num_threads") + if threads_widget is not None: + threads_widget.setEnabled(not is_running) self.generation_dock.volume_spin.setEnabled(not is_running) self.generation_dock.pitch_spin.setEnabled(not is_running) if not is_running: @@ -391,10 +393,6 @@ def _on_preview_finished(self, success: bool, payload: str) -> None: # --- start/cancel ------------------------------ def start_conversion(self) -> None: - threads = self.generation_dock.threads_spin.value() - if threads < 1: - self.generation_dock.threads_spin.setValue(1) - if self.generation_dock.using_file_tab(): fpath = self.generation_dock.get_file_path() if not os.path.exists(fpath): diff --git a/kokoro_gui/qt/docks/generation_dock.py b/kokoro_gui/qt/docks/generation_dock.py index 6125edc..a6d1db5 100644 --- a/kokoro_gui/qt/docks/generation_dock.py +++ b/kokoro_gui/qt/docks/generation_dock.py @@ -13,7 +13,7 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDockWidget, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, - QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSpinBox, + QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QTabWidget, QVBoxLayout, QWidget, ) @@ -148,16 +148,6 @@ def __init__(self, app, parent=None): chk_row.addWidget(self.combine_check) chk_row.addWidget(self.subtitles_check) proc_layout.addLayout(chk_row) - - thread_row = QHBoxLayout() - thread_row.addWidget(QLabel("Parallel Threads:")) - self.threads_spin = QSpinBox() - self.threads_spin.setRange(1, 16) - self.threads_spin.setValue(self.app.settings.get("num_threads", 1)) - thread_row.addWidget(self.threads_spin) - thread_row.addWidget(QLabel("(More threads = High RAM usage)")) - thread_row.addStretch(1) - proc_layout.addLayout(thread_row) layout.addWidget(proc_group) layout.addStretch(1) @@ -165,7 +155,7 @@ def __init__(self, app, parent=None): for w in (self.out_dir_edit, self.filename_edit): w.textChanged.connect(lambda _v: self.app.schedule_save()) - for w in (self.volume_spin, self.pitch_spin, self.threads_spin): + for w in (self.volume_spin, self.pitch_spin): w.valueChanged.connect(lambda _v: self.app.schedule_save()) for w in (self.apply_fx_check, self.normalize_check, self.trim_check, self.separate_check, self.combine_check, self.subtitles_check): From 0e7ed66f12c480806226b785761ce7a2a37dbded Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 17:25:55 -0600 Subject: [PATCH 08/44] =?UTF-8?q?New=20Audio8=20TTS=20engine=20(kokoro=5Fg?= =?UTF-8?q?ui/engines/audio8=5Ftts.py)=20=E2=80=94=20a=20real,=20non-Kokor?= =?UTF-8?q?o=20backend=20for=20Audio8-TTS-Preview-0.6b,=20registered=20alo?= =?UTF-8?q?ngside=20Kokoro/Dummy=20and=20selectable=20from=20the=20existin?= =?UTF-8?q?g=20engine=20picker.=20Handles=20the=20two=20genuine=20differen?= =?UTF-8?q?ces=20from=20Kokoro:=2044.1kHz=20output=20(generalized=20Conver?= =?UTF-8?q?sionMixin/CachingMixin=20call=20sites=20that=20used=20to=20hard?= =?UTF-8?q?code=2024000)=20and=20a=20shared,=20lock-serialized=20model=20s?= =?UTF-8?q?ingleton=20instead=20of=20one=20model=20per=20worker=20thread?= =?UTF-8?q?=20(a=200.6B=20model=20per=20thread=20would=20be=20wasteful).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice Reference tab (kokoro_gui/qt/docks/voice_clone_dock.py) — wired up the previously-unused supports_voice_cloning capability flag to show/hide a new dock (mirroring how the Mixing dock already does this for supports_voice_mixing). Lets you browse a reference WAV, auto-transcribe it, edit the transcript, and save it under a name that then shows up in the normal Voice dropdown. Auto-transcript module (kokoro_gui/engine/asr.py) — wraps Audio8-ASR-0.1B, runnable both from the dock's button and standalone via python -m kokoro_gui.engine.asr . Supporting changes: extended compute_cache_key with a backward-compatible extra param so a reference's transcript is part of the cache key; fixed the Generation dock's language/voice dropdowns to be backend-aware instead of hardcoded to Kokoro's tables; gated the JIT-streaming toggle on the active backend's capability; added transformers/torchaudio/safetensors to requirements.txt. Testing: 27 new tests across engine-contract, ASR, caching (including a multi-segment cache-correctness regression test I caught and fixed during implementation), and GUI dock behavior — all mocking the real models, never touching the network. --- README.md | 4 + kokoro_gui/engine/asr.py | 104 +++++ kokoro_gui/engine/caching.py | 23 +- kokoro_gui/engine/conversion.py | 14 +- kokoro_gui/engines/__init__.py | 7 +- kokoro_gui/engines/audio8_tts.py | 494 +++++++++++++++++++++++ kokoro_gui/qt/app.py | 46 ++- kokoro_gui/qt/docks/__init__.py | 3 +- kokoro_gui/qt/docks/generation_dock.py | 14 +- kokoro_gui/qt/docks/voice_clone_dock.py | 197 +++++++++ requirements.txt | 5 +- tests/gui_qt/test_qt_voice_clone_dock.py | 165 ++++++++ tests/test_asr.py | 100 +++++ tests/test_caching.py | 128 +++++- tests/test_engines_audio8.py | 201 +++++++++ 15 files changed, 1477 insertions(+), 28 deletions(-) create mode 100644 kokoro_gui/engine/asr.py create mode 100644 kokoro_gui/engines/audio8_tts.py create mode 100644 kokoro_gui/qt/docks/voice_clone_dock.py create mode 100644 tests/gui_qt/test_qt_voice_clone_dock.py create mode 100644 tests/test_asr.py create mode 100644 tests/test_engines_audio8.py diff --git a/README.md b/README.md index 899e911..f2aabaa 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ A modern, high-quality Text-to-Speech (TTS) application built with Python, featu https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e +## New in Beta 3.3.0 + +- **Second TTS engine — Audio8 (voice cloning):** [Audio8-TTS-Preview-0.6b](https://huggingface.co/Audio8/Audio8-TTS-Preview-0.6b), a zero-shot voice-cloning model, is now selectable from the engine picker alongside Kokoro. Unlike Kokoro's named voices, it clones a voice from a **reference WAV + a transcript of what's said in it** — a new "Voice Reference" dock (shown only for engines that support this) lets you browse a WAV, auto-transcribe it via [Audio8-ASR-0.1B](https://huggingface.co/Audio8/Audio8-ASR-0.1B) (`kokoro_gui/engine/asr.py`, also runnable standalone as `python -m kokoro_gui.engine.asr `), edit the transcript, and save it under a name that then shows up in the normal Voice dropdown. Both models pull in `transformers`/`torchaudio` (new `requirements.txt` entries) and load with `trust_remote_code=True`; the ASR model is CC-BY-NC-4.0 (non-commercial) — worth knowing if you build on this fork commercially. First use of either model downloads it from Hugging Face. + ## New in Beta 3.2.0 - **Qt frontend, now the only frontend:** `python main.py`/`run.bat` launches a PySide6-based dockable-panel shell (`kokoro_gui/qt/`). The previous CustomTkinter app (`gui.py`) has been retired now that Qt reached parity — see [PLAN_qt_and_engine_abstraction.md](PLAN_qt_and_engine_abstraction.md) for the migration this completed. PySide6 is a regular dependency in `requirements.txt`. diff --git a/kokoro_gui/engine/asr.py b/kokoro_gui/engine/asr.py new file mode 100644 index 0000000..74e632f --- /dev/null +++ b/kokoro_gui/engine/asr.py @@ -0,0 +1,104 @@ +"""Auto-transcription helper for reference audio, built on +https://huggingface.co/Audio8/Audio8-ASR-0.1B - used by the Voice Reference +dock (kokoro_gui/qt/docks/voice_clone_dock.py) to pre-fill an editable +transcript for a WAV a user is about to use as an Audio8-TTS voice reference. + +Audio8-TTS-Preview-0.6b's zero-shot voice cloning needs a transcript of the +reference audio, not just the audio itself - getting that by hand is tedious, +so this is a starting point the user reviews/corrects, not a ground-truth +oracle. `Audio8/Audio8-ASR-0.1B` is licensed CC-BY-NC-4.0 (non-commercial); +fine for this project's own use, but worth knowing if you fork this for +something commercial. + +`transformers` is only imported inside `_get_model()`, not at this module's +top level - though note `transformers` itself is already an indirect hard +dependency of this app (the `kokoro` package imports it internally), so this +isn't about avoiding the `transformers` import itself. What *is* still +lazy, and is the actual cost worth deferring: `AutoModel.from_pretrained(...)` +/`AutoProcessor.from_pretrained(...)` - the network fetch (first run) and +the model weights actually landing in memory - only happens on first call to +`transcribe_wav`/`_get_model`, not merely by importing this module (e.g. +whenever the Voice Reference dock is built). Loads with +`trust_remote_code=True`, which executes Python code shipped in the model's +HF repo the first time it's loaded - inherent to how this model is +distributed, not something this module can avoid while still using it. +""" +from __future__ import annotations + +import sys +import threading + +ASR_MODEL_ID = "Audio8/Audio8-ASR-0.1B" + +_model_lock = threading.Lock() +_model = None +_processor = None + + +def _get_model(): + """Lazily loads and caches the ASR model/processor as a process-wide + singleton (guarded by a lock so two near-simultaneous "Auto-Transcribe" + clicks - or a batch run alongside one - don't each start their own + multi-GB download/load).""" + global _model, _processor + + with _model_lock: + if _model is not None: + return _model, _processor + + try: + from transformers import AutoModelForCausalLM, AutoProcessor + except ImportError as e: + raise RuntimeError( + "Auto-transcription needs the 'transformers' package " + "(pip install -r requirements.txt)." + ) from e + + try: + processor = AutoProcessor.from_pretrained(ASR_MODEL_ID, trust_remote_code=True) + model = AutoModelForCausalLM.from_pretrained(ASR_MODEL_ID, trust_remote_code=True) + except Exception as e: + raise RuntimeError(f"Failed to load {ASR_MODEL_ID}: {e}") from e + + _model, _processor = model, processor + return _model, _processor + + +def transcribe_wav(wav_path: str, max_new_tokens: int = 128) -> str: + """Transcribes `wav_path` (16kHz mono expected; the model resamples if + needed per its model card) to plain text via Audio8-ASR-0.1B's + chat-template audio interface. Blocking/CPU-or-GPU-bound - callers from + the GUI should run this off the main thread (see + `VoiceCloneDock._on_transcribe_clicked`, which schedules it via + `asyncio.to_thread` on the active engine's worker).""" + model, processor = _get_model() + + conversation = [ + { + "role": "user", + "content": [{"type": "audio", "path": wav_path}], + } + ] + + try: + inputs = processor.apply_chat_template( + conversation, + add_generation_prompt=True, + tokenize=True, + return_dict=True, + return_tensors="pt", + ) + output_ids = model.generate(**inputs, max_new_tokens=max_new_tokens) + # Only decode the newly-generated tokens, not the echoed prompt. + prompt_len = inputs["input_ids"].shape[-1] + text = processor.decode(output_ids[0][prompt_len:], skip_special_tokens=True) + return text.strip() + except Exception as e: + raise RuntimeError(f"Transcription failed: {e}") from e + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python -m kokoro_gui.engine.asr ") + raise SystemExit(1) + print(transcribe_wav(sys.argv[1])) diff --git a/kokoro_gui/engine/caching.py b/kokoro_gui/engine/caching.py index 1f4ca88..a10ef1c 100644 --- a/kokoro_gui/engine/caching.py +++ b/kokoro_gui/engine/caching.py @@ -65,7 +65,7 @@ def voice_fingerprint(voice_ref): the difference. The content hash is cached per-file-mtime so a batch run doesn't re-read/re-hash the same file for every chunk. """ - if not (os.path.isabs(voice_ref) and os.path.isfile(voice_ref)): + if not voice_ref or not (os.path.isabs(voice_ref) and os.path.isfile(voice_ref)): return voice_ref try: @@ -87,11 +87,13 @@ def voice_fingerprint(voice_ref): return fp -def compute_cache_key(text, voice, eff_speed, lang_code, engine_id="kokoro", engine_version=None): +def compute_cache_key(text, voice, eff_speed, lang_code, engine_id="kokoro", engine_version=None, extra=None): """The segment-cache hash: schema_version, engine identity/version, text, - voice (name + content fingerprint), effective speed, and language code. + voice (name + content fingerprint), effective speed, language code, and + an optional `extra` dict of engine-specific inputs that also affect what + gets generated. - Takes exactly those five inputs, not a whole config dict - a config dict + Takes exactly those inputs, not a whole config dict - a config dict also carries `out_dir`/`filename`/`format`/`normalize`/`trim_silence`/the FX chain/`num_threads`/etc., none of which affect what gets cached (they apply in `process_and_save` *after* cache read/generation, to the same @@ -102,6 +104,16 @@ def compute_cache_key(text, voice, eff_speed, lang_code, engine_id="kokoro", eng for the same reason: only the text used to generate a segment determines its content - splitting is an internal detail of how a chunk gets divided for parallel processing. + + `extra` exists for a backend whose "voice" isn't fully described by a + name + resolved-file fingerprint alone - e.g. Audio8Engine's zero-shot + voice cloning also takes a reference *transcript*, which changes what + gets generated even when the reference wav and its name are unchanged. + Left as `None` (the default), it's omitted from `cache_key_parts` + entirely rather than hashed as an empty/`None` value, so Kokoro's and + the dummy backend's existing call sites - and every cache key they've + already written to disk - are byte-for-byte unaffected by this + parameter's addition. """ if engine_version is None: engine_version = get_engine_version(engine_id) @@ -116,6 +128,9 @@ def compute_cache_key(text, voice, eff_speed, lang_code, engine_id="kokoro", eng "speed": eff_speed, "lang_code": lang_code, } + if extra: + for k in sorted(extra): + cache_key_parts[f"extra_{k}"] = extra[k] to_hash = "|".join(f"{k}={v}" for k, v in cache_key_parts.items()) return hashlib.sha256(to_hash.encode("utf-8")).hexdigest() diff --git a/kokoro_gui/engine/conversion.py b/kokoro_gui/engine/conversion.py index 442cbbd..9d16f81 100644 --- a/kokoro_gui/engine/conversion.py +++ b/kokoro_gui/engine/conversion.py @@ -30,6 +30,11 @@ def _gen(): p = self.get_thread_pipeline(lang_code) if not p: return False + # Kokoro/Dummy both output 24000Hz; a backend whose model outputs a + # different rate (e.g. Audio8Engine's 44100Hz) sets an instance + # `SAMPLE_RATE` attribute to override this default. + sr = getattr(self, "SAMPLE_RATE", 24000) + try: ms_segments = self.parse_multispeaker_text(text) # Truncate to first 2 segments for preview if many @@ -94,7 +99,7 @@ def _gen(): if isinstance(audio, torch.Tensor): audio = audio.cpu().numpy() # Post Process - audio = self.process_audio(audio, 24000, target_extra) + audio = self.process_audio(audio, sr, target_extra) all_pieces.append(audio) if not all_pieces: @@ -103,13 +108,13 @@ def _gen(): full_audio = np.concatenate(all_pieces) try: - with AudioFile(output_path, 'w', samplerate=24000, num_channels=1) as f: + with AudioFile(output_path, 'w', samplerate=sr, num_channels=1) as f: f.write(full_audio) return True except Exception as e: print(f"Preview write error: {e}") # Fallback - sf.write(output_path, full_audio, 24000) + sf.write(output_path, full_audio, sr) return True except Exception as e: print(f"Preview error: {e}") @@ -120,9 +125,10 @@ def _gen(): async def smart_combine(self, file_paths, output_path, update_callback): def combine_worker(): total_files = len(file_paths) + sr = getattr(self, "SAMPLE_RATE", 24000) try: # Use Pedalboard AudioFile - with AudioFile(output_path, 'w', samplerate=24000, num_channels=1) as out_f: + with AudioFile(output_path, 'w', samplerate=sr, num_channels=1) as out_f: for i, fp in enumerate(file_paths): if self.cancel_event.is_set(): break try: diff --git a/kokoro_gui/engines/__init__.py b/kokoro_gui/engines/__init__.py index a84ef74..7cd351f 100644 --- a/kokoro_gui/engines/__init__.py +++ b/kokoro_gui/engines/__init__.py @@ -1,10 +1,11 @@ """Engine backend abstraction (PLAN_qt_and_engine_abstraction.md workstream 1). -Importing this package registers the built-in "kokoro" and "dummy" backends -as a side effect (the `kokoro`/`dummy` submodule imports below). +Importing this package registers the built-in "kokoro", "dummy", and +"audio8" backends as a side effect (the submodule imports below). """ from kokoro_gui.engines import base, registry +from kokoro_gui.engines.audio8_tts import Audio8BackendAdapter from kokoro_gui.engines.dummy import DummyBackendAdapter from kokoro_gui.engines.kokoro import KokoroBackendAdapter -__all__ = ["base", "registry", "KokoroBackendAdapter", "DummyBackendAdapter"] +__all__ = ["base", "registry", "KokoroBackendAdapter", "DummyBackendAdapter", "Audio8BackendAdapter"] diff --git a/kokoro_gui/engines/audio8_tts.py b/kokoro_gui/engines/audio8_tts.py new file mode 100644 index 0000000..8574024 --- /dev/null +++ b/kokoro_gui/engines/audio8_tts.py @@ -0,0 +1,494 @@ +"""A real (non-Kokoro) second backend: +https://huggingface.co/Audio8/Audio8-TTS-Preview-0.6b - a 0.6B DualAR +zero-shot voice-cloning model. Unlike Kokoro's named/mixed `.pt` voice +embeddings, Audio8 clones a voice from a *reference WAV + a transcript of +what's said in that WAV* (`capabilities.supports_voice_cloning=True` - see +kokoro_gui/qt/docks/voice_clone_dock.py, the "Voice Reference" tab shown +only for a backend with this capability). + +Follows the `dummy.py`/`kokoro.py` contract (TTSEngineBackend, base.py) and +reuses the same model-agnostic mixins `DummyEngine` does +(AudioFXMixin/ConversionMixin/JITMixin/LexiconMixin/PresetsMixin/SrtMixin/ +TextExtractionMixin from kokoro_gui/engine/__init__.py) - but two things are +genuinely different from both existing backends, both explained where they +happen below: + +1. Output is 44.1kHz, not Kokoro/Dummy's 24000Hz. `ConversionMixin` reads an + instance `self.SAMPLE_RATE` (defaulting to 24000 via `getattr` when a + backend doesn't set one) instead of a hardcoded literal, specifically so + this engine can override it - see kokoro_gui/engine/conversion.py. +2. `get_thread_pipeline` does *not* hand out one model per worker thread the + way Kokoro's `KPipeline` does - see `_Audio8Pipeline` and `_get_model` + below for why and how the model is shared instead. + +`transformers` itself is only imported inside `_get_model()`, not at this +module's top level - though it's already an indirect hard dependency of +this app regardless (the `kokoro` package imports it internally), so that +isn't actually deferring much on its own. What genuinely stays deferred +until `init_pipeline_async`/first generation: `AutoModel.from_pretrained(...)` +actually running - the network fetch (first run) and the model weights +landing in memory - so a user who never switches to this engine never pays +that cost merely by the `kokoro_gui.engines` package registering it at +startup. Loads with `trust_remote_code=True` (the model ships custom +modeling code in its HF repo) - see this module's sibling +kokoro_gui/engine/asr.py for the same note about what that means. +""" +from __future__ import annotations + +import asyncio +import os +import re +import shutil +import threading +from typing import Optional + +import numpy as np +import soundfile as sf +from pedalboard.io import AudioFile + +import kokoro_engine +from kokoro_engine import AsyncLoopThread +from kokoro_gui.engine import ( + AudioFXMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, + SrtMixin, TextExtractionMixin, +) +from kokoro_gui.engine.caching import compute_cache_key +from kokoro_gui.engines.base import ( + ConfigField, ConfigFieldType, EngineCapabilities, VoiceInfo, + COMMON_SPLIT_PATTERN_CHOICES, COMMON_OUTPUT_FORMAT_CHOICES, +) +from kokoro_gui.engines.registry import register_engine + +TTS_MODEL_ID = "Audio8/Audio8-TTS-Preview-0.6b" +SAMPLE_RATE = 44100 + +# Saved wav+transcript voice references live as sidecar file pairs here: +# /.wav and /.txt. Mirrors +# kokoro_engine.CUSTOM_VOICES_DIR's flat, name-keyed convention, just with +# two files per entry instead of one .pt. Read qualified (module-global, not +# rebound to a local default) so tests can monkeypatch +# `kokoro_gui.engines.audio8_tts.AUDIO8_REFS_DIR` the same way +# `isolated_dirs` monkeypatches `kokoro_engine.CUSTOM_VOICES_DIR`. +AUDIO8_REFS_DIR = os.path.join("custom_voices", "audio8_refs") + +# The model's supported languages (per its model card) - passed through as +# plain strings to whatever `language=` argument the processor expects. +# There's no published short-code table for this model the way Kokoro has +# single-letter lang codes, so the value *is* the display label; worth +# double-checking against the processor's actual accepted values on first +# real run. +AUDIO8_LANGUAGE_CHOICES = [ + (name, name) for name in ( + "English", "Chinese", "Cantonese", "French", "German", "Italian", + "Japanese", "Korean", "Dutch", "Polish", "Spanish", + ) +] + + +class Audio8ReferenceStore: + """CRUD over the saved wav+transcript voice-reference pairs under + `AUDIO8_REFS_DIR`. Plain functions, not a mixin - unlike custom-voice + resolution (which needs a live pipeline to load a `.pt` tensor through), + saving/listing/deleting these sidecar files needs no model, so there's + no reason to route it through `Audio8Engine`.""" + + @staticmethod + def _safe_name(name: str) -> str: + # Same path-traversal guard as VoiceMixingMixin.resolve_voice_path/ + # mix_voices (kokoro_gui/engine/voices.py). + return os.path.basename(name) + + @staticmethod + def save_reference(name: str, wav_path: str, transcript: str) -> str: + """Copies `wav_path` and writes `transcript` under a sanitized + `name`, creating `AUDIO8_REFS_DIR` if needed. Returns the saved wav's + absolute path.""" + safe_name = Audio8ReferenceStore._safe_name(name) + if not safe_name: + raise ValueError("Reference name must not be empty.") + os.makedirs(AUDIO8_REFS_DIR, exist_ok=True) + out_wav = os.path.join(AUDIO8_REFS_DIR, f"{safe_name}.wav") + out_txt = os.path.join(AUDIO8_REFS_DIR, f"{safe_name}.txt") + shutil.copyfile(wav_path, out_wav) + with open(out_txt, "w", encoding="utf-8") as f: + f.write(transcript.strip()) + return os.path.abspath(out_wav) + + @staticmethod + def list_references() -> list: + """Returns sorted `[name, ...]` for every wav+txt sidecar pair found + (a lone `.wav` or `.txt` without its partner is skipped - an + incomplete/interrupted save, not a usable reference).""" + if not os.path.isdir(AUDIO8_REFS_DIR): + return [] + names = [] + for f in os.listdir(AUDIO8_REFS_DIR): + if not f.endswith(".wav"): + continue + name = f[:-4] + if os.path.isfile(os.path.join(AUDIO8_REFS_DIR, f"{name}.txt")): + names.append(name) + return sorted(names) + + @staticmethod + def get_transcript(name: str) -> str: + safe_name = Audio8ReferenceStore._safe_name(name) + txt_path = os.path.join(AUDIO8_REFS_DIR, f"{safe_name}.txt") + if not os.path.isfile(txt_path): + return "" + with open(txt_path, "r", encoding="utf-8") as f: + return f.read().strip() + + @staticmethod + def delete_reference(name: str) -> None: + safe_name = Audio8ReferenceStore._safe_name(name) + for ext in (".wav", ".txt"): + path = os.path.join(AUDIO8_REFS_DIR, f"{safe_name}{ext}") + if os.path.exists(path): + os.remove(path) + + +# --- Shared model singleton ------------------------------------------------- +# +# A 0.6B-parameter model loaded once per worker thread (Kokoro's KPipeline +# convention) would multiply GPU/RAM use by `num_threads` for zero benefit - +# unlike KPipeline, nothing about loading this model is thread-specific. +# Instead it's loaded once, process-wide, and every thread's generation call +# is serialized through `_model_lock` - safe (no concurrent `.generate()` +# calls into one model instance) at the cost of chunk generation itself not +# actually parallelizing across `num_threads` (I/O and pre/post-processing +# still overlap). See `Audio8BackendAdapter.get_config_schema`'s lower +# `num_threads` max, which reflects that. +_model_lock = threading.Lock() +_model = None +_processor = None + + +def _get_model(): + global _model, _processor + with _model_lock: + if _model is not None: + return _model, _processor + try: + from transformers import AutoModel, AutoProcessor + except ImportError as e: + raise RuntimeError( + "The Audio8 engine needs the 'transformers' package " + "(pip install -r requirements.txt)." + ) from e + try: + processor = AutoProcessor.from_pretrained(TTS_MODEL_ID, trust_remote_code=True) + model = AutoModel.from_pretrained(TTS_MODEL_ID, trust_remote_code=True) + except Exception as e: + raise RuntimeError(f"Failed to load {TTS_MODEL_ID}: {e}") from e + _model, _processor = model, processor + return _model, _processor + + +class _Audio8Pipeline: + """Presents the shared singleton model as a `kokoro.KPipeline`-shaped + callable-generator (`pipeline(text, voice=, speed=, split_pattern=)` -> + `(graphemes, phonemes, audio)` triples, `audio` mono float32 at + `SAMPLE_RATE`), the same convention `DummyPipeline` mimics + (kokoro_gui/engines/dummy.py), so this engine's own `process_chunk_task` + and the generic `ConversionMixin.generate_preview`/`smart_combine` can + all drive it uniformly. + + `voice` here is always an already-*resolved* reference wav path (by the + time any of the generic mixins call a pipeline, `config['voice']` has + already been run through `Audio8Engine.resolve_voice_path` - see + `ConversionMixin.start_conversion`/`generate_preview`) - the matching + transcript sidecar is looked up from that path here, once per call, + rather than needing a separate "voice name" threaded through everywhere. + """ + + def __init__(self, engine: "Audio8Engine", lang_code: str = "English"): + self._engine = engine + self.lang_code = lang_code + + def __call__(self, text, voice=None, speed=1.0, split_pattern=r"\n+"): + try: + segments = [s.strip() for s in re.split(split_pattern, text) if s.strip()] + except re.error: + segments = [] + if not segments and text.strip(): + segments = [text.strip()] + + ref_transcript = self._engine.resolve_voice_transcript(voice) + for seg in segments: + audio = self._engine.generate_segment(seg, voice, ref_transcript, speed, self.lang_code) + yield seg, "", audio + + +class Audio8Engine( + AudioFXMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, + SrtMixin, TextExtractionMixin, +): + """KokoroEngine-shaped enough for the GUI to drive directly - same + required surface as `DummyEngine` (worker/cancel_event/pipeline/ + on_progress/on_status/on_finish/init_pipeline_async/get_thread_pipeline/ + resolve_voice_path/cancel), plus `SAMPLE_RATE=44100` and its own + `process_chunk_task` (see module docstring for both).""" + + SAMPLE_RATE = SAMPLE_RATE + + def __init__(self): + self.worker = AsyncLoopThread() + self.worker.start() + self.cancel_event = threading.Event() + self.pipeline = False # not ready until init_pipeline_async loads the model + + self.on_progress = None + self.on_status = None + self.on_finish = None + + self._lexicon_cache = {} + + os.makedirs(AUDIO8_REFS_DIR, exist_ok=True) + + async def init_pipeline_async(self, lang_code="a"): + if self.on_status: + self.on_status("Loading Audio8 TTS model (first use downloads it)...", False) + try: + await asyncio.to_thread(_get_model) + except Exception as e: + self.pipeline = False + if self.on_status: + self.on_status(f"Audio8 model load failed: {e}", True) + return False + self.pipeline = True + if self.on_status: + self.on_status("Audio8 TTS ready.", False) + return True + + def get_thread_pipeline(self, lang_code="English"): + return _Audio8Pipeline(self, lang_code) + + def resolve_voice_path(self, voice_name: str) -> str: + """Resolves a saved reference name to its absolute wav path + (sanitized-basename convention, matching + `VoiceMixingMixin.resolve_voice_path`). Falls back to treating + `voice_name` as a literal existing file path (a wav dropped straight + into the Voice Reference dock and generated with before ever being + saved under a name), and finally to returning it unchanged (will + fail clearly at generation time rather than silently). Tolerates a + falsy `voice_name` (e.g. the Voice dropdown is empty because no + reference has been saved yet) by returning it as-is rather than + raising here - the resulting generation failure is reported through + the normal per-chunk error path (`_process_text_async`'s + `asyncio.gather(..., return_exceptions=True)`) instead of crashing + synchronously on the Qt main thread inside `start_conversion`.""" + if not voice_name: + return voice_name + safe_name = os.path.basename(voice_name) + saved_path = os.path.join(AUDIO8_REFS_DIR, f"{safe_name}.wav") + if os.path.exists(saved_path): + return os.path.abspath(saved_path) + if os.path.isabs(voice_name) and os.path.isfile(voice_name): + return voice_name + return voice_name + + def resolve_voice_transcript(self, resolved_voice_path: str) -> str: + """Given an already-*resolved* reference wav path (see + `resolve_voice_path`), returns the transcript from its sidecar + `.txt` file (same base name, `.wav` -> `.txt`), or `""` if none + exists (including when `resolved_voice_path` itself is falsy).""" + if not resolved_voice_path: + return "" + txt_path = os.path.splitext(resolved_voice_path)[0] + ".txt" + if os.path.isfile(txt_path): + with open(txt_path, "r", encoding="utf-8") as f: + return f.read().strip() + return "" + + def generate_segment(self, text: str, ref_wav_path: str, ref_transcript: str, + speed: float, lang_code: str) -> np.ndarray: + """Runs one segment through the shared model, serialized via + `_model_lock` (see module docstring). Returns mono float32 audio at + `SAMPLE_RATE`. + + The exact processor/generate call shape below is a best-effort + reading of the model card (processor takes text + a reference audio + path + a reference transcript; generation takes + max_new_tokens/temperature/top_p/top_k) - worth a one-time check + against the installed model's actual API on first real run, same as + wrapping any new HF model sight-unseen. + """ + model, processor = _get_model() + with _model_lock: + inputs = processor( + text=text, + ref_audio=ref_wav_path, + ref_text=ref_transcript, + language=lang_code, + speed=speed, + return_tensors="pt", + ) + output = model.generate( + **inputs, max_new_tokens=4096, temperature=0.7, top_p=0.9, top_k=50, + ) + audio = processor.decode(output, output_type="audio") + + audio = np.asarray(audio, dtype=np.float32).reshape(-1) + return audio + + def process_chunk_task(self, chunk_data, progress_callback): + """Same shape/return contract, and the same "predict the segment + split, check every expected file exists" cache-validity check, as + `CachingMixin.process_chunk_task` (kokoro_gui/engine/caching.py) - + hand-rolled rather than inherited because that mixin hardcodes + 24000Hz in several places and this engine outputs 44100Hz. Calls + `compute_cache_key` directly as a library function instead, passing + `extra={"ref_transcript": ...}` so a reference's transcript is part + of the cache key too - changing just the transcript for the same wav + (a real "the auto-transcript was wrong, I fixed it" workflow) + correctly invalidates old cache entries. + """ + index, text, config = chunk_data + if self.cancel_event.is_set(): + return [] + + lang_code = config.get('lang_code', 'English') + eff_speed = config['speed'] + ref_wav = config['voice'] # already resolved by ConversionMixin.start_conversion + ref_transcript = self.resolve_voice_transcript(ref_wav) + split_pattern = config.get('split_pattern', r"\n+") + + use_cache = config.get('caching', False) + cache_hash = None + cached_segments = [] # [(graphemes, audio), ...] + + if use_cache: + cache_hash = compute_cache_key( + text, ref_wav, eff_speed, lang_code, engine_id="audio8", + extra={"ref_transcript": ref_transcript}, + ) + try: + predicted_texts = [t.strip() for t in re.split(split_pattern, text) if t.strip()] + except re.error: + predicted_texts = [] + if not predicted_texts and text.strip(): + predicted_texts = [text.strip()] + + if predicted_texts: + loaded = [] + all_exist = True + for i, seg_text in enumerate(predicted_texts): + f_path = os.path.join(kokoro_engine.CACHE_DIR, f"{cache_hash}_{i}.wav") + if not os.path.exists(f_path): + all_exist = False + break + try: + audio_data, _ = sf.read(f_path) + except Exception as e: + print(f"Audio8 cache read error: {e}") + all_exist = False + break + loaded.append((seg_text, audio_data)) + if all_exist: + cached_segments = loaded + + chunk_files = [] + base_name = f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_part{index}" + + def write_output(graphemes, audio, sub_idx): + processed = self.process_audio(audio, self.SAMPLE_RATE, config) + fmt = config.get('format', 'wav').lower() + if fmt not in ('wav', 'flac', 'mp3', 'ogg'): + fmt = 'wav' + file_name = f"{base_name}_{sub_idx}.{fmt}" + path = os.path.join(config['out_dir'], file_name) + try: + with AudioFile(path, 'w', samplerate=self.SAMPLE_RATE, num_channels=1) as f: + f.write(processed) + except Exception as e: + print(f"Audio8 write failed: {e}. Fallback to soundfile.") + sf.write(path, processed, self.SAMPLE_RATE) + return { + "path": path, "text": graphemes, + "duration": len(processed) / self.SAMPLE_RATE, "seg_idx": index, + } + + if cached_segments: + for sub_idx, (graphemes, audio) in enumerate(cached_segments): + if self.cancel_event.is_set(): + break + if progress_callback: + progress_callback(len(graphemes), graphemes) + chunk_files.append(write_output(graphemes, audio, sub_idx)) + else: + pipeline = self.get_thread_pipeline(lang_code) + generator = pipeline(text, voice=ref_wav, speed=eff_speed, split_pattern=split_pattern) + for sub_idx, (graphemes, _phonemes, audio) in enumerate(generator): + if self.cancel_event.is_set(): + break + if progress_callback: + progress_callback(len(graphemes), graphemes) + + if use_cache and cache_hash: + try: + sf.write( + os.path.join(kokoro_engine.CACHE_DIR, f"{cache_hash}_{sub_idx}.wav"), + audio, self.SAMPLE_RATE, + ) + except Exception as e: + print(f"Audio8 cache write error: {e}") + + chunk_files.append(write_output(graphemes, audio, sub_idx)) + + return chunk_files + + def cancel(self) -> None: + self.cancel_event.set() + + +class Audio8BackendAdapter: + id = "audio8" + display_name = "Audio8 TTS (voice cloning)" + capabilities = EngineCapabilities( + supports_voice_mixing=False, + supports_voice_cloning=True, + supports_multi_speaker_script=True, + is_local_model=True, + supports_jit_streaming=False, + ) + + def __init__(self, engine: Optional[Audio8Engine] = None): + self._engine = engine if engine is not None else Audio8Engine() + + @property + def engine(self): + return self._engine + + def get_config_schema(self) -> list: + return [ + ConfigField("lang_code", "Language", ConfigFieldType.CHOICE, + default="English", choices=list(AUDIO8_LANGUAGE_CHOICES), group="Generation"), + ConfigField("voice", "Voice Reference", ConfigFieldType.CHOICE, + default=None, group="Generation"), + ConfigField("speed", "Speed", ConfigFieldType.SLIDER, + default=1.0, min=0.5, max=2.0, step=0.1, group="Generation"), + ConfigField("split_pattern", "Split By", ConfigFieldType.CHOICE, + default=r"\n+", choices=list(COMMON_SPLIT_PATTERN_CHOICES), group="Generation"), + ConfigField("format", "Output Format", ConfigFieldType.CHOICE, + default="wav", choices=list(COMMON_OUTPUT_FORMAT_CHOICES), group="Generation"), + ConfigField("num_threads", "Parallel Threads", ConfigFieldType.INT, + default=1, min=1, max=4, step=1, group="Advanced"), + ConfigField("caching", "Enable Segment Cache", ConfigFieldType.BOOL, + default=True, group="Advanced"), + ] + + def get_voices(self, lang_code: Optional[str] = None) -> list: + """Saved wav+transcript references - see `Audio8ReferenceStore`. + Unlike Kokoro, there are no built-in named voices at all; every + selectable "voice" here is a user-saved reference.""" + return [ + VoiceInfo(id=name, display_name=name, lang_code=None, is_custom=True) + for name in Audio8ReferenceStore.list_references() + ] + + def cancel(self) -> None: + self._engine.cancel() + + +register_engine("audio8", Audio8BackendAdapter, display_name=Audio8BackendAdapter.display_name) diff --git a/kokoro_gui/qt/app.py b/kokoro_gui/qt/app.py index 1ee040a..39afa39 100644 --- a/kokoro_gui/qt/app.py +++ b/kokoro_gui/qt/app.py @@ -37,7 +37,7 @@ PRESETS_DIR = "presets" FX_PRESETS_DIR = os.path.join(PRESETS_DIR, "fx") -from kokoro_gui.qt.docks import FXDock, GenerationDock, LexiconDock, MixingDock # noqa: E402 +from kokoro_gui.qt.docks import FXDock, GenerationDock, LexiconDock, MixingDock, VoiceCloneDock # noqa: E402 class QtTTSApp(QMainWindow): @@ -60,6 +60,7 @@ def __init__(self, parent=None): self._save_timer.timeout.connect(self.save_settings) self.mixing_dock: MixingDock | None = None + self.voice_clone_dock: VoiceCloneDock | None = None self.generation_dock: GenerationDock | None = None # --- Engine / backend --- @@ -126,6 +127,7 @@ def _build_docks(self) -> None: self.tabifyDockWidget(self.fx_dock, self.lexicon_dock) self._sync_mixing_dock() + self._sync_voice_clone_dock() def _build_action_bar(self) -> None: central = QWidget() @@ -151,8 +153,9 @@ def _build_action_bar(self) -> None: btn_row = QHBoxLayout() self.preview_btn = QPushButton("Preview Audio") self.preview_btn.clicked.connect(self.preview_conversion) - self.start_btn = QPushButton("Start Real-time JIT" if self.jit_enabled else "Start Generation") + self.start_btn = QPushButton("Start Generation") self.start_btn.clicked.connect(self.start_conversion) + self._update_start_btn_text() self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.cancel_conversion) self.cancel_btn.setEnabled(False) @@ -164,16 +167,21 @@ def _build_action_bar(self) -> None: layout.addStretch(1) self.setCentralWidget(central) - # --- voice listing (hardcoded relative path) -- + # --- voice listing -- def get_all_voices(self, lang_code: str | None = None) -> list: + """`spec.VOICE_DB` is Kokoro's built-in named-voice table specifically + (empty for any lang_code Kokoro doesn't define, e.g. Audio8's + language names) - the "custom"/backend-provided half comes from + `self.backend.get_voices(...)` generically, so this works for + whichever engine is active rather than always scanning Kokoro's + `.pt` directory (see `Audio8BackendAdapter.get_voices`, which lists + saved wav+transcript references instead).""" if lang_code is None: lang_code = self.settings.get("lang_code", "a") standard = spec.VOICE_DB.get(lang_code, []) - custom = [] - if os.path.exists("custom_voices"): - custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] - return sorted(standard + custom) + custom = [v.id for v in self.backend.get_voices(lang_code)] + return sorted(set(standard + custom)) # --- settings persistence - @@ -269,6 +277,8 @@ def switch_engine(self, engine_id: str) -> None: # backend and show/hide the Mixing dock. self.generation_dock.rebuild_schema_form() self._sync_mixing_dock() + self._sync_voice_clone_dock() + self._update_start_btn_text() try: old_engine.worker.stop() @@ -279,6 +289,10 @@ def switch_engine(self, engine_id: str) -> None: self.status_label.setStyleSheet("color: gray;") self.engine.worker.run_coro(self.engine.init_pipeline_async(self.settings.get("lang_code", "a"))) + def _update_start_btn_text(self) -> None: + will_stream = self.jit_enabled and self.backend.capabilities.supports_jit_streaming + self.start_btn.setText("Start Real-time JIT" if will_stream else "Start Generation") + def _sync_mixing_dock(self) -> None: wants = self.backend.capabilities.supports_voice_mixing if wants and self.mixing_dock is None: @@ -290,6 +304,17 @@ def _sync_mixing_dock(self) -> None: self.mixing_dock.deleteLater() self.mixing_dock = None + def _sync_voice_clone_dock(self) -> None: + wants = self.backend.capabilities.supports_voice_cloning + if wants and self.voice_clone_dock is None: + self.voice_clone_dock = VoiceCloneDock(self) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.voice_clone_dock) + self.tabifyDockWidget(self.fx_dock, self.voice_clone_dock) + elif not wants and self.voice_clone_dock is not None: + self.removeDockWidget(self.voice_clone_dock) + self.voice_clone_dock.deleteLater() + self.voice_clone_dock = None + # --- settings dialog - def open_settings_dialog(self) -> None: @@ -298,6 +323,9 @@ def open_settings_dialog(self) -> None: layout = QVBoxLayout(dialog) jit_check = QCheckBox("Enable JIT Generation (Streaming)") jit_check.setChecked(self.jit_enabled) + if not self.backend.capabilities.supports_jit_streaming: + jit_check.setEnabled(False) + layout.addWidget(QLabel(f"({self.backend.display_name} doesn't support streaming - runs as Standard.)")) layout.addWidget(jit_check) close_btn = QPushButton("Close") close_btn.clicked.connect(dialog.accept) @@ -305,7 +333,7 @@ def open_settings_dialog(self) -> None: dialog.exec() self.jit_enabled = jit_check.isChecked() - self.start_btn.setText("Start Real-time JIT" if self.jit_enabled else "Start Generation") + self._update_start_btn_text() self.save_settings() # --- engine callbacks (queued automatically across threads - see signals.py) - @@ -419,7 +447,7 @@ def start_conversion(self) -> None: self.set_ui_state(True) self.progress_bar.setValue(0) - if self.jit_enabled: + if self.jit_enabled and self.backend.capabilities.supports_jit_streaming: self.engine.start_jit_conversion(text_data, config) else: self.engine.start_conversion(text_data, config) diff --git a/kokoro_gui/qt/docks/__init__.py b/kokoro_gui/qt/docks/__init__.py index 6c82377..f64332b 100644 --- a/kokoro_gui/qt/docks/__init__.py +++ b/kokoro_gui/qt/docks/__init__.py @@ -2,5 +2,6 @@ from .fx_dock import FXDock from .mixing_dock import MixingDock from .lexicon_dock import LexiconDock +from .voice_clone_dock import VoiceCloneDock -__all__ = ["GenerationDock", "FXDock", "MixingDock", "LexiconDock"] +__all__ = ["GenerationDock", "FXDock", "MixingDock", "LexiconDock", "VoiceCloneDock"] diff --git a/kokoro_gui/qt/docks/generation_dock.py b/kokoro_gui/qt/docks/generation_dock.py index a6d1db5..4ac00d9 100644 --- a/kokoro_gui/qt/docks/generation_dock.py +++ b/kokoro_gui/qt/docks/generation_dock.py @@ -174,7 +174,6 @@ def _build_schema_form(self) -> None: schema = self.app.backend.get_config_schema() lang_code = self.app.settings.get("lang_code", "a") voice_choices = [(v, v) for v in self.app.get_all_voices(lang_code)] - lang_choices = [(label, code) for label, code in spec.LANGUAGES.items()] values = { "lang_code": self.app.settings.get("lang_code", "a"), "voice": self.app.settings.get("voice", "af_heart"), @@ -184,9 +183,20 @@ def _build_schema_form(self) -> None: "num_threads": self.app.settings.get("num_threads", 1), "caching": self.app.settings.get("caching", True), } + # "voice" is always GUI-resolved (app.get_all_voices, above) since no + # backend's schema declares a fixed voice list. "lang_code" is only + # GUI-resolved for a backend that leaves it choices=None (today: + # Kokoro/Dummy, whose language table - spec.LANGUAGES - is display + # data owned by this frontend, not engine data); a backend whose + # schema already declares its own lang_code choices (e.g. Audio8's + # 11-language list) keeps those instead of being overridden here. + overrides = {"voice": voice_choices} + lang_field = next((f for f in schema if f.key == "lang_code"), None) + if lang_field is not None and lang_field.choices is None: + overrides["lang_code"] = [(label, code) for label, code in spec.LANGUAGES.items()] self.schema_form = SchemaFormWidget( schema, values, - choices_overrides={"voice": voice_choices, "lang_code": lang_choices}, + choices_overrides=overrides, skip_keys={"lexicon"}, on_change=self._on_schema_field_changed, ) diff --git a/kokoro_gui/qt/docks/voice_clone_dock.py b/kokoro_gui/qt/docks/voice_clone_dock.py new file mode 100644 index 0000000..10db028 --- /dev/null +++ b/kokoro_gui/qt/docks/voice_clone_dock.py @@ -0,0 +1,197 @@ +"""Voice Reference dock: browse a reference WAV, get (and edit) an +auto-transcript of it via kokoro_gui/engine/asr.py, and save it under a name +so it shows up as a selectable "voice" for any backend whose +`capabilities.supports_voice_cloning` is true (today: Audio8BackendAdapter - +kokoro_gui/engines/audio8_tts.py). Shown only for such a backend - see +app.py's `_sync_voice_clone_dock`, the same show/hide-on-engine-switch +pattern `_sync_mixing_dock` uses for the Mixing dock. + +Saving is required before a reference can be used for generation - there is +no "generate with an unsaved wav" path, deliberately: the Generation dock's +Voice dropdown is the single source of truth for which reference gets used +(populated from `Audio8ReferenceStore.list_references()` via +`app.backend.get_voices()`), so there is never a question of whether a +freshly-browsed-but-unsaved wav or the dropdown's selection "wins". +""" +from __future__ import annotations + +import asyncio +import os + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QDockWidget, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QVBoxLayout, QWidget, +) + +from kokoro_gui.engine.asr import transcribe_wav +from kokoro_gui.engines import audio8_tts +from kokoro_gui.engines.audio8_tts import Audio8ReferenceStore + + +class VoiceCloneDock(QDockWidget): + transcribeFinished = Signal(bool, str) + saveFinished = Signal(bool, str) + + def __init__(self, app, parent=None): + super().__init__("Voice Reference", parent) + self.setObjectName("dock_voice_clone") + self.app = app + self.transcribeFinished.connect(self._on_transcribe_finished) + self.saveFinished.connect(self._on_save_finished) + + content = QWidget() + layout = QVBoxLayout(content) + + layout.addWidget(QLabel("Reference Audio")) + wav_row = QHBoxLayout() + self.wav_path_edit = QLineEdit() + wav_row.addWidget(self.wav_path_edit, 1) + browse_btn = QPushButton("Browse...") + browse_btn.clicked.connect(self._browse_wav) + wav_row.addWidget(browse_btn) + layout.addLayout(wav_row) + + layout.addWidget(QLabel("Transcript (what's said in the audio)")) + self.transcript_edit = QPlainTextEdit() + self.transcript_edit.setFixedHeight(100) + layout.addWidget(self.transcript_edit) + + self.transcribe_btn = QPushButton("\U0001F3A4 Auto-Transcribe") + self.transcribe_btn.clicked.connect(self._on_transcribe_clicked) + layout.addWidget(self.transcribe_btn) + + self.status_label = QLabel("") + layout.addWidget(self.status_label) + + save_row = QHBoxLayout() + save_row.addWidget(QLabel("Save As:")) + self.name_edit = QLineEdit() + save_row.addWidget(self.name_edit, 1) + save_btn = QPushButton("Save Reference") + save_btn.clicked.connect(self._on_save_clicked) + save_row.addWidget(save_btn) + layout.addLayout(save_row) + + layout.addWidget(QLabel("Saved References:")) + self.list_scroll = QScrollArea() + self.list_scroll.setWidgetResizable(True) + self.list_scroll.setFixedHeight(180) + self._list_container = QWidget() + self._list_layout = QVBoxLayout(self._list_container) + self.list_scroll.setWidget(self._list_container) + layout.addWidget(self.list_scroll) + + layout.addStretch(1) + self.setWidget(content) + self.refresh_list() + + # --- reference audio / transcript ----------------------------------- + + def _browse_wav(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Select reference audio", filter="Audio (*.wav)") + if path: + self.wav_path_edit.setText(path) + + def _on_transcribe_clicked(self) -> None: + wav_path = self.wav_path_edit.text().strip() + if not wav_path or not os.path.exists(wav_path): + QMessageBox.warning(self, "Error", "Select a reference audio file first.") + return + + self.transcribe_btn.setEnabled(False) + self.status_label.setText("Transcribing...") + + def _done(future): + try: + text = future.result() + self.transcribeFinished.emit(True, text) + except Exception as e: + self.transcribeFinished.emit(False, str(e)) + + future = self.app.engine.worker.run_coro(asyncio.to_thread(transcribe_wav, wav_path)) + future.add_done_callback(_done) + + def _on_transcribe_finished(self, success: bool, payload: str) -> None: + self.transcribe_btn.setEnabled(True) + if success: + self.transcript_edit.setPlainText(payload) + self.status_label.setText("Transcribed - review/edit before saving.") + else: + self.status_label.setText(f"Transcription failed: {payload}") + + # --- saved references (name -> wav+transcript sidecar pair) --------- + + def _on_save_clicked(self) -> None: + name = self.name_edit.text().strip() + wav_path = self.wav_path_edit.text().strip() + transcript = self.transcript_edit.toPlainText().strip() + + if not name: + QMessageBox.warning(self, "Error", "Enter a name for this voice reference.") + return + if not wav_path or not os.path.exists(wav_path): + QMessageBox.warning(self, "Error", "Select a reference audio file first.") + return + if not transcript: + QMessageBox.warning(self, "Error", "Enter or auto-transcribe a transcript first.") + return + if name in Audio8ReferenceStore.list_references(): + if QMessageBox.question(self, "Overwrite", f"Reference '{name}' exists. Overwrite?") != QMessageBox.StandardButton.Yes: + return + + try: + Audio8ReferenceStore.save_reference(name, wav_path, transcript) + self.saveFinished.emit(True, name) + except Exception as e: + self.saveFinished.emit(False, str(e)) + + def _on_save_finished(self, success: bool, payload: str) -> None: + if success: + self.status_label.setText(f"Saved: {payload}") + self.refresh_list() + else: + self.status_label.setText(f"Save failed: {payload}") + + def _load_reference(self, name: str) -> None: + """Loads a saved reference back into the editable fields above, for + review/edit/re-save (the user's "edit after if needed" path).""" + self.name_edit.setText(name) + self.wav_path_edit.setText(os.path.abspath(os.path.join(audio8_tts.AUDIO8_REFS_DIR, f"{name}.wav"))) + self.transcript_edit.setPlainText(Audio8ReferenceStore.get_transcript(name)) + + def refresh_list(self) -> None: + if hasattr(self.app, "generation_dock") and self.app.generation_dock is not None: + self.app.generation_dock.refresh_voice_choices() + + while self._list_layout.count(): + item = self._list_layout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + names = Audio8ReferenceStore.list_references() + if not names: + self._list_layout.addWidget(QLabel("No saved voice references yet.")) + return + + for name in names: + row = QFrame() + row_layout = QHBoxLayout(row) + load_btn = QPushButton(name) + load_btn.setFlat(True) + load_btn.clicked.connect(lambda _c=False, n=name: self._load_reference(n)) + row_layout.addWidget(load_btn, 1) + del_btn = QPushButton("✕") + del_btn.clicked.connect(lambda _c=False, n=name: self.delete_reference(n)) + row_layout.addWidget(del_btn) + self._list_layout.addWidget(row) + + def delete_reference(self, name: str) -> None: + if QMessageBox.question(self, "Confirm", f"Delete voice reference '{name}'?") != QMessageBox.StandardButton.Yes: + return + try: + Audio8ReferenceStore.delete_reference(name) + self.refresh_list() + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete: {e}") diff --git a/requirements.txt b/requirements.txt index 5b417f7..3d18166 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,7 @@ pedalboard soundfile==0.13.1 sounddevice torch==2.13.0 -PySide6==6.11.2 \ No newline at end of file +PySide6==6.11.2 +transformers>=4.57.0,<5 +torchaudio>=2.5.0 +safetensors>=0.4 \ No newline at end of file diff --git a/tests/gui_qt/test_qt_voice_clone_dock.py b/tests/gui_qt/test_qt_voice_clone_dock.py new file mode 100644 index 0000000..542ec25 --- /dev/null +++ b/tests/gui_qt/test_qt_voice_clone_dock.py @@ -0,0 +1,165 @@ +"""Voice Reference dock: shown/hidden per-engine (supports_voice_cloning), +save/delete of wav+transcript references, and the auto-transcribe button. + +Switching to the "audio8" engine builds a *real* `Audio8Engine` (the +registry factory has no stub-swapping hook the way `qt_app`'s fixture +patches `KokoroEngine` -> `StubEngine`), so its model load +(`kokoro_gui.engines.audio8_tts._get_model`) is monkeypatched to a fast fake +before every switch - never touches `transformers`/downloads a model. +""" +import numpy as np +import soundfile as sf + +from kokoro_gui.engines import audio8_tts +from kokoro_gui.engines.audio8_tts import Audio8ReferenceStore + +# `kokoro_gui.qt.docks.voice_clone_dock` is deliberately never imported at +# this module's top level - `kokoro_gui.qt.app`/`kokoro_gui.qt.docks` have a +# documented circular-import relationship (see app.py's module docstring) +# that only resolves when `kokoro_gui.qt.app` is imported first, which the +# `qt_app` fixture guarantees but a bare top-level import here would not. +# `monkeypatch.setattr("module.path.attr", ...)` (string target) below +# imports the module lazily, at test-run time, after `qt_app` has already +# done so. +_TRANSCRIBE_TARGET = "kokoro_gui.qt.docks.voice_clone_dock.transcribe_wav" + + +def _switch_to_audio8(qt_app, monkeypatch): + monkeypatch.setattr(audio8_tts, "_get_model", lambda: (object(), object())) + qt_app.switch_engine("audio8") + + +def _write_wav(path): + audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(path), audio, 16000) + return str(path) + + +# --- show/hide on engine switch --------------------------------------------- + +def test_audio8_backend_shows_voice_clone_dock_and_hides_mixing(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + assert qt_app.backend.id == "audio8" + assert qt_app.voice_clone_dock is not None + assert qt_app.mixing_dock is None + + +def test_switch_back_to_kokoro_hides_voice_clone_dock_and_restores_mixing(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + qt_app.switch_engine("kokoro") + assert qt_app.voice_clone_dock is None + assert qt_app.mixing_dock is not None + + +def test_jit_streaming_disabled_falls_back_to_standard_start(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + qt_app.jit_enabled = True + qt_app._update_start_btn_text() + assert qt_app.start_btn.text() == "Start Generation" + + +# --- save / delete reference ------------------------------------------------- + +def test_save_reference_appears_in_generation_voice_dropdown(qt_app, monkeypatch, tmp_path): + _switch_to_audio8(qt_app, monkeypatch) + wav_path = _write_wav(tmp_path / "ref.wav") + + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(wav_path) + dock.transcript_edit.setPlainText("Hello world reference.") + dock.name_edit.setText("Fred") + dock._on_save_clicked() + + assert Audio8ReferenceStore.list_references() == ["Fred"] + + combo = qt_app.generation_dock.schema_form.widget_for("voice") + items = [combo.itemData(i) for i in range(combo.count())] + assert "Fred" in items + + +def test_save_reference_rejects_missing_name(qt_app, monkeypatch, tmp_path): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + dock.transcript_edit.setPlainText("Some transcript.") + dock.name_edit.setText("") + + dock._on_save_clicked() + + assert Audio8ReferenceStore.list_references() == [] + + +def test_delete_reference_removes_it_and_dropdown_entry(qt_app, monkeypatch, tmp_path): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + dock.transcript_edit.setPlainText("Some transcript.") + dock.name_edit.setText("Ghost") + dock._on_save_clicked() + assert Audio8ReferenceStore.list_references() == ["Ghost"] + + # qt_app fixture patches QMessageBox.question -> Yes globally. + dock.delete_reference("Ghost") + + assert Audio8ReferenceStore.list_references() == [] + combo = qt_app.generation_dock.schema_form.widget_for("voice") + items = [combo.itemData(i) for i in range(combo.count())] + assert "Ghost" not in items + + +def test_load_reference_populates_editable_fields(qt_app, monkeypatch, tmp_path): + _switch_to_audio8(qt_app, monkeypatch) + wav_path = _write_wav(tmp_path / "ref.wav") + Audio8ReferenceStore.save_reference("Loaded", wav_path, "Original text.") + + dock = qt_app.voice_clone_dock + dock.refresh_list() + dock._load_reference("Loaded") + + assert dock.name_edit.text() == "Loaded" + assert dock.transcript_edit.toPlainText() == "Original text." + assert dock.wav_path_edit.text().endswith("Loaded.wav") + + +# --- auto-transcribe ---------------------------------------------------- + +def test_auto_transcribe_populates_transcript(qt_app, monkeypatch, tmp_path, qtbot): + _switch_to_audio8(qt_app, monkeypatch) + monkeypatch.setattr(_TRANSCRIBE_TARGET, lambda path: "Fake transcript text.") + + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + + dock._on_transcribe_clicked() + + qtbot.waitUntil(lambda: dock.transcript_edit.toPlainText() != "", timeout=5000) + assert dock.transcript_edit.toPlainText() == "Fake transcript text." + assert dock.transcribe_btn.isEnabled() + + +def test_auto_transcribe_failure_shows_status_and_reenables_button(qt_app, monkeypatch, tmp_path, qtbot): + _switch_to_audio8(qt_app, monkeypatch) + + def _boom(path): + raise RuntimeError("model unavailable") + + monkeypatch.setattr(_TRANSCRIBE_TARGET, _boom) + + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + + dock._on_transcribe_clicked() + + qtbot.waitUntil(lambda: dock.transcribe_btn.isEnabled(), timeout=5000) + assert "failed" in dock.status_label.text().lower() + assert dock.transcript_edit.toPlainText() == "" + + +def test_auto_transcribe_without_wav_selected_is_a_noop(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText("") + + dock._on_transcribe_clicked() + + assert dock.transcript_edit.toPlainText() == "" diff --git a/tests/test_asr.py b/tests/test_asr.py new file mode 100644 index 0000000..ade1e88 --- /dev/null +++ b/tests/test_asr.py @@ -0,0 +1,100 @@ +"""Tests for the auto-transcription helper (kokoro_gui/engine/asr.py) built on +Audio8/Audio8-ASR-0.1B. Never touches the real model - `_get_model` is +monkeypatched to a fake model/processor pair in every test that exercises +`transcribe_wav`. +""" +import subprocess +import sys +from pathlib import Path + +import pytest + +import kokoro_gui.engine.asr as asr + + +class _FakeInputs(dict): + def __init__(self): + super().__init__(input_ids=_FakeTensor()) + + +class _FakeTensor: + shape = (1, 7) + + +class _FakeProcessor: + def apply_chat_template(self, conversation, **kwargs): + assert conversation[0]["content"][0]["type"] == "audio" + return _FakeInputs() + + def decode(self, token_ids, skip_special_tokens=True): + return " hello from the fake model " + + +class _FakeModel: + def generate(self, **kwargs): + return [[0] * 7 + [1, 2, 3]] # prompt tokens + 3 "generated" tokens + + +@pytest.fixture(autouse=True) +def _reset_singleton(monkeypatch): + # _get_model caches a process-wide singleton - make sure one test's fake + # model doesn't leak into another's assertions about load behavior. + monkeypatch.setattr(asr, "_model", None) + monkeypatch.setattr(asr, "_processor", None) + + +def test_transcribe_wav_returns_stripped_decoded_text(monkeypatch, tmp_path): + monkeypatch.setattr(asr, "_get_model", lambda: (_FakeModel(), _FakeProcessor())) + + wav_path = str(tmp_path / "ref.wav") + result = asr.transcribe_wav(wav_path) + + assert result == "hello from the fake model" + + +def test_transcribe_wav_wraps_generation_errors(monkeypatch, tmp_path): + class _BoomModel: + def generate(self, **kwargs): + raise RuntimeError("out of memory") + + monkeypatch.setattr(asr, "_get_model", lambda: (_BoomModel(), _FakeProcessor())) + + with pytest.raises(RuntimeError, match="Transcription failed"): + asr.transcribe_wav(str(tmp_path / "ref.wav")) + + +def test_get_model_raises_clear_error_without_transformers(monkeypatch): + import builtins + real_import = builtins.__import__ + + def _no_transformers(name, *args, **kwargs): + if name == "transformers": + raise ImportError("no module named transformers") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _no_transformers) + + with pytest.raises(RuntimeError, match="transformers"): + asr._get_model() + + +def test_importing_module_does_not_load_the_model(): + """Importing this module (which happens whenever the Voice Reference + dock is built) must not trigger `AutoModel.from_pretrained`/download - + only calling `transcribe_wav` (or `_get_model`) does. (`transformers` + itself is already an indirect hard dependency via the `kokoro` package, + so the meaningful guarantee here is "no model load", not "no + transformers import" - see this module's docstring.) Checked in a fresh + interpreter, importing `kokoro_engine` first to match real app startup + order (kokoro_gui/engine/__init__.py's own transitive import chain back + to kokoro_engine.py means importing any of its submodules cold, without + kokoro_engine already in sys.modules, hits an unrelated pre-existing + circular-import ordering requirement).""" + code = ( + "import kokoro_engine, kokoro_gui.engine.asr as asr; " + "print(asr._model is None and asr._processor is None)" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, + cwd=str(Path(__file__).resolve().parent.parent)) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "True" diff --git a/tests/test_caching.py b/tests/test_caching.py index 71e812d..10635ec 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -13,6 +13,8 @@ import kokoro_engine from kokoro_gui.engine.caching import CACHE_SCHEMA_VERSION, compute_cache_key +from kokoro_gui.engines import audio8_tts +from kokoro_gui.engines.audio8_tts import Audio8Engine, Audio8ReferenceStore def _hash(text, config, eff_speed=None, lang_code=None, engine_id="kokoro"): @@ -130,14 +132,15 @@ def test_compute_cache_key_is_deterministic_and_sha256(): def test_compute_cache_key_takes_only_what_it_needs(): - # Not a whole config dict - just the five inputs that actually determine - # a segment's content. out_dir/filename/format/normalize/trim/the FX + # Not a whole config dict - just the inputs that actually determine a + # segment's content. out_dir/filename/format/normalize/trim/the FX # chain/num_threads/etc. never even get a chance to leak into the hash, - # because the function has nowhere to read them from. + # because the function has nowhere to read them from. `extra` is the one + # deliberate escape hatch - see test_compute_cache_key_extra_* below. import inspect params = list(inspect.signature(compute_cache_key).parameters) - assert params == ["text", "voice", "eff_speed", "lang_code", "engine_id", "engine_version"] + assert params == ["text", "voice", "eff_speed", "lang_code", "engine_id", "engine_version", "extra"] def test_compute_cache_key_differs_by_each_input(): @@ -212,6 +215,31 @@ def test_engine_version_change_invalidates_cache(engine, fake_pipeline, isolated assert len(cache_files) == 2 +def test_compute_cache_key_extra_none_matches_no_extra_arg(): + """`extra` is additive - omitting it entirely and passing `extra=None` + must hash identically, and both must match what the function produced + before `extra` existed (Kokoro's/Dummy's call sites never pass it).""" + without_arg = compute_cache_key("Hello.", "af_heart", 1.0, "a") + with_none = compute_cache_key("Hello.", "af_heart", 1.0, "a", extra=None) + with_empty = compute_cache_key("Hello.", "af_heart", 1.0, "a", extra={}) + assert without_arg == with_none == with_empty + + +def test_compute_cache_key_extra_dict_changes_hash(): + """Audio8Engine folds a reference transcript into `extra` so editing the + transcript for the same reference wav (same name, same content hash) + still invalidates the cache - see kokoro_gui/engines/audio8_tts.py's + process_chunk_task.""" + base = compute_cache_key("Hello.", "/refs/alice.wav", 1.0, "English", engine_id="audio8", + extra={"ref_transcript": "Hi there."}) + changed = compute_cache_key("Hello.", "/refs/alice.wav", 1.0, "English", engine_id="audio8", + extra={"ref_transcript": "Hi there!"}) + same = compute_cache_key("Hello.", "/refs/alice.wav", 1.0, "English", engine_id="audio8", + extra={"ref_transcript": "Hi there."}) + assert base != changed + assert base == same + + def test_schema_version_bump_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config, monkeypatch): import kokoro_gui.engine.caching as caching_mod @@ -225,3 +253,95 @@ def test_schema_version_bump_invalidates_cache(engine, fake_pipeline, isolated_d cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) assert len(cache_files) == 2 + + +# --- Audio8Engine: its own hand-rolled caching (kokoro_gui/engines/audio8_tts.py) -- +# +# Audio8Engine doesn't use CachingMixin (see that module's docstring - 24000Hz +# hardcoding vs. its own 44100Hz), but still participates in the same +# CACHE_DIR via compute_cache_key directly, with a reference transcript +# folded in through the new `extra` parameter above. + +def test_audio8_process_chunk_task_caching_keys_on_transcript(isolated_dirs, tmp_path, monkeypatch): + """Same reference wav, different transcript (the 'auto-transcript was + wrong, I fixed it' workflow) must be treated as a cache miss.""" + import numpy as np + + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(tmp_path / "audio8_refs")) + + wav_path = tmp_path / "ref.wav" + ref_audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(wav_path), ref_audio, 16000) + Audio8ReferenceStore.save_reference("Eve", str(wav_path), "Original transcript.") + + engine = Audio8Engine() + try: + def _fake_segment(text, ref_wav_path, ref_transcript, speed, lang_code): + t = np.arange(2200) / 44100 + return (0.1 * np.sin(2 * np.pi * 220 * t)).astype(np.float32) + monkeypatch.setattr(engine, "generate_segment", _fake_segment) + + voice_path = engine.resolve_voice_path("Eve") + config = { + "lang_code": "English", "voice": voice_path, "speed": 1.0, "split_pattern": r"\n+", + "filename": "out", "time_id": "1", "out_dir": str(isolated_dirs.out_dir), + "format": "wav", "caching": True, "apply_fx": False, + } + engine.process_chunk_task((0, "Hello there.", config), None) + first_cache_files = set(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(first_cache_files) == 1 + + # Re-save the same name with a different transcript (same wav content). + Audio8ReferenceStore.save_reference("Eve", str(wav_path), "Corrected transcript!") + engine.process_chunk_task((0, "Hello there.", config), None) + second_cache_files = set(isolated_dirs.cache_dir.glob("*_0.wav")) + + assert len(second_cache_files) == 2 + assert first_cache_files < second_cache_files + finally: + engine.worker.stop() + + +def test_audio8_process_chunk_task_caches_every_segment_in_a_multi_segment_chunk(isolated_dirs, tmp_path, monkeypatch): + """A chunk that splits into more than one segment (split_pattern + matching within one chunk's text, e.g. two newline-separated lines) must + cache/read *every* segment - not just the first - on both the write and + the cache-hit path.""" + import numpy as np + + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(tmp_path / "audio8_refs")) + + wav_path = tmp_path / "ref.wav" + ref_audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(wav_path), ref_audio, 16000) + Audio8ReferenceStore.save_reference("Zoe", str(wav_path), "Zoe's reference line.") + + engine = Audio8Engine() + try: + def _fake_segment(text, ref_wav_path, ref_transcript, speed, lang_code): + t = np.arange(2200) / 44100 + return (0.1 * np.sin(2 * np.pi * 220 * t)).astype(np.float32) + monkeypatch.setattr(engine, "generate_segment", _fake_segment) + + voice_path = engine.resolve_voice_path("Zoe") + config = { + "lang_code": "English", "voice": voice_path, "speed": 1.0, "split_pattern": r"\n+", + "filename": "out", "time_id": "1", "out_dir": str(isolated_dirs.out_dir), + "format": "wav", "caching": True, "apply_fx": False, + } + text = "Segment one.\nSegment two." + + files = engine.process_chunk_task((0, text, config), None) + assert len(files) == 2 + cache_files = set(isolated_dirs.cache_dir.glob("*.wav")) + assert len(cache_files) == 2 # _0.wav and _1.wav + + # Cache hit path must also produce both segments, not just the first. + def _boom(*a, **k): + raise AssertionError("generate_segment should not be called on a cache hit") + monkeypatch.setattr(engine, "generate_segment", _boom) + + files_hit = engine.process_chunk_task((0, text, config), None) + assert len(files_hit) == 2 + finally: + engine.worker.stop() diff --git a/tests/test_engines_audio8.py b/tests/test_engines_audio8.py new file mode 100644 index 0000000..e66f47e --- /dev/null +++ b/tests/test_engines_audio8.py @@ -0,0 +1,201 @@ +"""Tests for the Audio8 TTS engine backend (kokoro_gui/engines/audio8_tts.py) - +a real, non-Kokoro second backend built on the same TTSEngineBackend contract +`tests/test_engine_backend.py` covers for Kokoro/Dummy. + +Never touches the real `transformers`/Audio8 model - `Audio8Engine.generate_segment` +(the one method that would load/call it) is monkeypatched in every test that +exercises generation, mirroring how `fake_pipeline` keeps Kokoro's tests off +the real `kokoro.KPipeline`/eSpeak NG. +""" +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +import kokoro_engine +from kokoro_gui.engines import audio8_tts, registry +from kokoro_gui.engines.audio8_tts import ( + Audio8BackendAdapter, Audio8Engine, Audio8ReferenceStore, +) +from kokoro_gui.engines.base import ConfigField, EngineCapabilities, VoiceInfo + + +@pytest.fixture +def isolated_audio8_refs(tmp_path, monkeypatch): + refs_dir = tmp_path / "audio8_refs" + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(refs_dir)) + return refs_dir + + +@pytest.fixture +def audio8_engine(isolated_audio8_refs, isolated_dirs): + e = Audio8Engine() + yield e + e.worker.stop() + + +@pytest.fixture +def a_wav(tmp_path): + path = tmp_path / "sample.wav" + audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(path), audio, 16000) + return str(path) + + +# --- registration / capabilities / schema ----------------------------------- + +def test_audio8_registered_and_shaped_like_a_real_backend(): + assert "audio8" in registry.list_engines() + caps = Audio8BackendAdapter.capabilities + assert isinstance(caps, EngineCapabilities) + assert caps.supports_voice_mixing is False + assert caps.supports_voice_cloning is True + assert caps.supports_multi_speaker_script is True + assert caps.supports_jit_streaming is False + + +def test_get_engine_wraps_the_given_engine_instance(audio8_engine): + backend = registry.get_engine("audio8", engine=audio8_engine) + assert isinstance(backend, Audio8BackendAdapter) + assert backend.engine is audio8_engine + + +def test_config_schema_shape(audio8_engine): + backend = registry.get_engine("audio8", engine=audio8_engine) + schema = backend.get_config_schema() + assert all(isinstance(f, ConfigField) for f in schema) + + keys = {f.key for f in schema} + assert keys == {"lang_code", "voice", "speed", "split_pattern", "format", "num_threads", "caching"} + + by_key = {f.key: f for f in schema} + # Fixed, engine-declared language list - NOT GUI-resolved like Kokoro's. + assert by_key["lang_code"].choices is not None + assert ("English", "English") in by_key["lang_code"].choices + # Voice IS GUI-resolved (from saved references), same convention as Kokoro. + assert by_key["voice"].choices is None + # Parallelism is capped low - see module docstring on the shared model lock. + assert by_key["num_threads"].max == 4 + + +def test_importing_module_does_not_load_the_model(): + """Importing this module (which happens at every app startup, to + register the backend) must not trigger `AutoModel.from_pretrained`/a + download - only `init_pipeline_async`/first generation does. + (`transformers` itself is already an indirect hard dependency via the + `kokoro` package, so the meaningful guarantee is "no model load", not + "no transformers import" - see this module's docstring.)""" + code = ( + "import kokoro_engine, kokoro_gui.engines.audio8_tts as a8; " + "print(a8._model is None and a8._processor is None)" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, + cwd=str(Path(__file__).resolve().parent.parent)) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "True" + + +# --- Audio8ReferenceStore ---------------------------------------------------- + +def test_reference_store_round_trip(isolated_audio8_refs, a_wav): + assert Audio8ReferenceStore.list_references() == [] + + saved_path = Audio8ReferenceStore.save_reference("Alice", a_wav, "Hello, this is Alice speaking.") + assert os.path.exists(saved_path) + assert Audio8ReferenceStore.list_references() == ["Alice"] + assert Audio8ReferenceStore.get_transcript("Alice") == "Hello, this is Alice speaking." + + Audio8ReferenceStore.delete_reference("Alice") + assert Audio8ReferenceStore.list_references() == [] + + +def test_reference_store_sanitizes_path_traversal_name(isolated_audio8_refs, a_wav): + saved_path = Audio8ReferenceStore.save_reference("../../evil", a_wav, "transcript") + + # Must land inside AUDIO8_REFS_DIR under the basename, not escape it - + # same pattern as tests/test_resolve_voice_path.py's traversal guard. + assert os.path.exists(saved_path) + assert os.path.dirname(saved_path) == str(isolated_audio8_refs) + assert os.path.basename(saved_path) == "evil.wav" + + +def test_reference_store_ignores_incomplete_pairs(isolated_audio8_refs): + os.makedirs(str(isolated_audio8_refs), exist_ok=True) + (isolated_audio8_refs / "orphan.wav").write_bytes(b"not a real wav") + assert Audio8ReferenceStore.list_references() == [] + + +def test_get_voices_reflects_saved_references(isolated_audio8_refs, a_wav): + backend = Audio8BackendAdapter() + try: + assert backend.get_voices() == [] + Audio8ReferenceStore.save_reference("Bob", a_wav, "This is Bob.") + assert backend.get_voices() == [VoiceInfo(id="Bob", display_name="Bob", lang_code=None, is_custom=True)] + finally: + backend.cancel() + backend.engine.worker.stop() + + +# --- Audio8Engine: resolve_voice_path / resolve_voice_transcript ------------ + +def test_resolve_voice_path_and_transcript_for_saved_reference(audio8_engine, isolated_audio8_refs, a_wav): + Audio8ReferenceStore.save_reference("Carol", a_wav, "Carol's voice sample.") + + resolved = audio8_engine.resolve_voice_path("Carol") + assert os.path.isabs(resolved) + assert resolved.endswith("Carol.wav") + + assert audio8_engine.resolve_voice_transcript(resolved) == "Carol's voice sample." + + +def test_resolve_voice_path_falls_back_to_literal_file(audio8_engine, a_wav): + # Not a saved reference name, but an existing absolute wav path - usable + # ad-hoc even before being saved under a name. + assert audio8_engine.resolve_voice_path(a_wav) == a_wav + + +def test_resolve_voice_transcript_empty_when_no_sidecar(audio8_engine, a_wav): + assert audio8_engine.resolve_voice_transcript(a_wav) == "" + + +# --- Audio8Engine.process_chunk_task ----------------------------------------- + +def _fake_segment(monkeypatch, engine, freq=220.0, sr=44100, n=2200): + def _gen(text, ref_wav_path, ref_transcript, speed, lang_code): + t = np.arange(n) / sr + return (0.1 * np.sin(2 * np.pi * freq * t)).astype(np.float32) + monkeypatch.setattr(engine, "generate_segment", _gen) + + +def test_process_chunk_task_writes_44100hz_audio(audio8_engine, isolated_audio8_refs, isolated_dirs, a_wav, monkeypatch): + Audio8ReferenceStore.save_reference("Dana", a_wav, "Dana's reference line.") + _fake_segment(monkeypatch, audio8_engine) + + config = { + "lang_code": "English", "voice": audio8_engine.resolve_voice_path("Dana"), + "speed": 1.0, "split_pattern": r"\n+", "filename": "out", "time_id": "1", + "out_dir": str(isolated_dirs.out_dir), "format": "wav", "caching": False, + "apply_fx": False, + } + files = audio8_engine.process_chunk_task((0, "Hello there.", config), None) + + assert len(files) == 1 + data, sr = sf.read(files[0]["path"]) + assert sr == 44100 + assert np.max(np.abs(data)) > 0.01 + + +# process_chunk_task's own segment-cache-enabled integration test lives in +# tests/test_caching.py (the only module allowed to enable that setting - +# see tests/test_meta_caching_policy.py) as +# test_audio8_process_chunk_task_caching_keys_on_transcript. + + +def test_cancel_sets_cancel_event(audio8_engine): + assert not audio8_engine.cancel_event.is_set() + audio8_engine.cancel() + assert audio8_engine.cancel_event.is_set() From 23791202a72c77406f7874a2bfa0a8572e46501a Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 19:38:33 -0600 Subject: [PATCH 09/44] fixed some errors --- kokoro_gui/engines/audio8_tts.py | 37 +++++++++++++++++++++----------- kokoro_gui/qt/app.py | 18 ++++++++++++++-- requirements.txt | 5 +++-- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/kokoro_gui/engines/audio8_tts.py b/kokoro_gui/engines/audio8_tts.py index 8574024..ce23e3f 100644 --- a/kokoro_gui/engines/audio8_tts.py +++ b/kokoro_gui/engines/audio8_tts.py @@ -307,27 +307,40 @@ def generate_segment(self, text: str, ref_wav_path: str, ref_transcript: str, `_model_lock` (see module docstring). Returns mono float32 audio at `SAMPLE_RATE`. - The exact processor/generate call shape below is a best-effort - reading of the model card (processor takes text + a reference audio - path + a reference transcript; generation takes - max_new_tokens/temperature/top_p/top_k) - worth a one-time check - against the installed model's actual API on first real run, same as - wrapping any new HF model sight-unseen. + Checked against the installed model's actual `processing_arktts.py`/ + `modeling_arktts.py` (the model card guess this originally shipped + with was wrong on every point below): + + - The processor's real kwargs are `reference_audio`/`reference_text`, + not `ref_audio`/`ref_text`. + - Neither `ArkttsProcessor.__call__` nor `ArkttsModel.generate` take + a `language` or `speed` argument at all - both raise `TypeError` + on any kwarg they don't recognize, which is what surfaced as + "Unexpected processor arguments: [...]". `speed`/`lang_code` stay + in this method's signature only so it keeps matching + `_Audio8Pipeline`/`process_chunk_task`'s generic + `(text, voice, speed, lang_code)` shape shared with Kokoro/Dummy - + the model always synthesizes at its own pace and infers language + from the text itself, so both are accepted here and silently + unused rather than forwarded. + - `processor.decode(...)` is just `tokenizer.decode` (text token + decoding) - it was never how to get audio out. The real path is + `model.generate(**inputs)` -> codes -> `model.decode_audio(codes)`, + or the combined `model.generate_audio(**inputs, ...)` used below, + which returns `(waveforms, lengths, codes)` directly. """ model, processor = _get_model() with _model_lock: inputs = processor( text=text, - ref_audio=ref_wav_path, - ref_text=ref_transcript, - language=lang_code, - speed=speed, + reference_audio=ref_wav_path or None, + reference_text=ref_transcript or None, return_tensors="pt", ) - output = model.generate( + waveforms, lengths, _codes = model.generate_audio( **inputs, max_new_tokens=4096, temperature=0.7, top_p=0.9, top_k=50, ) - audio = processor.decode(output, output_type="audio") + audio = waveforms[0, : lengths[0]].detach().cpu().numpy() audio = np.asarray(audio, dtype=np.float32).reshape(-1) return audio diff --git a/kokoro_gui/qt/app.py b/kokoro_gui/qt/app.py index 39afa39..6e37e53 100644 --- a/kokoro_gui/qt/app.py +++ b/kokoro_gui/qt/app.py @@ -79,7 +79,16 @@ def __init__(self, parent=None): qt_settings.restore_window_state(self, self.settings) self.status_label.setText("Initializing engine...") - self.engine.worker.run_coro(self.engine.init_pipeline_async(self.settings.get("lang_code", "a"))) + # Read back through the Generation dock rather than raw + # self.settings["lang_code"]: that setting is shared across engine + # backends whose lang_code value spaces don't overlap (Kokoro's + # single-letter codes vs. e.g. Audio8's full language names), and a + # value saved while a different backend was active would otherwise + # be fed straight into this (now-Kokoro) pipeline init unvalidated. + # The schema form's combo already reconciled it to a valid default + # for the active backend when it was built in _build_docks() above. + init_lang_code = self.generation_dock.get_state().get("lang_code", "a") + self.engine.worker.run_coro(self.engine.init_pipeline_async(init_lang_code)) # --- construction ----------------------------------------------------- @@ -287,7 +296,12 @@ def switch_engine(self, engine_id: str) -> None: self.status_label.setText(f"Switched engine to {new_backend.display_name}. Initializing...") self.status_label.setStyleSheet("color: gray;") - self.engine.worker.run_coro(self.engine.init_pipeline_async(self.settings.get("lang_code", "a"))) + # Same reasoning as __init__: read the value rebuild_schema_form() + # just reconciled for new_backend, not the raw (possibly + # foreign-format, e.g. Audio8's "English") self.settings value. + new_lang_code = self.generation_dock.get_state().get("lang_code", "a") + self.settings["lang_code"] = new_lang_code + self.engine.worker.run_coro(self.engine.init_pipeline_async(new_lang_code)) def _update_start_btn_text(self) -> None: will_stream = self.jit_enabled and self.backend.capabilities.supports_jit_streaming diff --git a/requirements.txt b/requirements.txt index 3d18166..35d962d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ soundfile==0.13.1 sounddevice torch==2.13.0 PySide6==6.11.2 -transformers>=4.57.0,<5 +transformers==4.57.6 torchaudio>=2.5.0 -safetensors>=0.4 \ No newline at end of file +safetensors>=0.4 +librosa==0.11.0 \ No newline at end of file From ec072928964f2653bf7a5b71f9fd69bb3d01728f Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 19:57:41 -0600 Subject: [PATCH 10/44] cacheing --- kokoro_gui/engines/audio8_tts.py | 122 ++++++++++++++++++++++++++++-- tests/test_engines_audio8.py | 126 ++++++++++++++++++++++++++++++- 2 files changed, 240 insertions(+), 8 deletions(-) diff --git a/kokoro_gui/engines/audio8_tts.py b/kokoro_gui/engines/audio8_tts.py index ce23e3f..268e623 100644 --- a/kokoro_gui/engines/audio8_tts.py +++ b/kokoro_gui/engines/audio8_tts.py @@ -52,7 +52,7 @@ AudioFXMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, SrtMixin, TextExtractionMixin, ) -from kokoro_gui.engine.caching import compute_cache_key +from kokoro_gui.engine.caching import compute_cache_key, voice_fingerprint from kokoro_gui.engines.base import ( ConfigField, ConfigFieldType, EngineCapabilities, VoiceInfo, COMMON_SPLIT_PATTERN_CHOICES, COMMON_OUTPUT_FORMAT_CHOICES, @@ -71,6 +71,28 @@ # `isolated_dirs` monkeypatches `kokoro_engine.CUSTOM_VOICES_DIR`. AUDIO8_REFS_DIR = os.path.join("custom_voices", "audio8_refs") + +def _ref_codes_cache_dir() -> str: + """Persisted cache of *encoded* reference audio ("reference codes" - see + `Audio8Engine._reference_codes_path`/module docstring below): one `.npy` + per distinct reference wav's content, named by its `voice_fingerprint` + (sha256-based, mtime-cached) rather than by reference name, so re-saving + a reference under a new name (or two references sharing identical + audio) reuses the same cache entry, and re-saving one *name* with + different audio correctly misses. Gated by the "cache_reference_codes" + config field (Audio8BackendAdapter.get_config_schema) - default on. Like + `CACHE_DIR`, this grows unbounded; no eviction policy yet (ROADMAP). + + Nested under the *current* `AUDIO8_REFS_DIR`, resolved fresh on every + call (not baked in as a module-level constant at import time) so that + tests monkeypatching `AUDIO8_REFS_DIR` (see `isolated_audio8_refs` in + tests/test_engines_audio8.py) redirect this cache too, the same way + they already redirect reference wav/transcript storage - otherwise this + would keep writing into the real `custom_voices/audio8_refs/` regardless + of that patch. + """ + return os.path.join(AUDIO8_REFS_DIR, ".ref_codes_cache") + # The model's supported languages (per its model card) - passed through as # plain strings to whatever `language=` argument the processor expects. # There's no published short-code table for this model the way Kokoro has @@ -242,6 +264,14 @@ def __init__(self): self.on_status = None self.on_finish = None + # Whether `generate_segment` should reuse a persisted, content-keyed + # encoding of the reference wav instead of re-running the model's + # audio encoder on every segment (see `_reference_codes_path`). + # `process_chunk_task` overwrites this from `config['cache_reference_codes']` + # each run - the `True` here only matters for callers that skip + # `process_chunk_task` (e.g. calling `generate_segment` directly). + self.cache_reference_codes = True + self._lexicon_cache = {} os.makedirs(AUDIO8_REFS_DIR, exist_ok=True) @@ -301,6 +331,58 @@ def resolve_voice_transcript(self, resolved_voice_path: str) -> str: return f.read().strip() return "" + def _reference_codes_path(self, ref_wav_path: str, ref_transcript: str) -> Optional[str]: + """Returns the path to a persisted `.npy` of `ref_wav_path`'s + *encoded* reference ("reference codes" - `ArkttsModel.encode_audio`'s + output), computing and caching it on first use under + `_ref_codes_cache_dir()`. Returns `None` when caching isn't + applicable (no on-disk wav to fingerprint, no transcript to run the + one-off encode with) or if the encode itself fails - the caller + falls back to passing raw `reference_audio` on every call in that + case, exactly like before this cache existed. + + This is the one genuine "reference audio -> tensor" step: the model + encodes `reference_audio_values` into `reference_codes` via its own + audio codec (`ArkttsModel.encode_audio`, a real forward pass through + `ArkttsCodec` - not free) inside `_prepare_prompt` on *every* + `generate`/`generate_audio` call that's given raw audio. Passing + `reference_codes=` instead (which `ArkttsProcessor.__call__` accepts + as a path it `np.load`s itself, per `processing_arktts.py`) skips + that re-encode entirely - the same reference wav produces identical + codes every time, so encoding it once and reusing the codes across + every segment/chunk that shares a voice reference is a correctness- + preserving cache, not an approximation. + """ + if not ref_wav_path: + return None + fp = voice_fingerprint(ref_wav_path) + if fp == ref_wav_path: + return None # not an existing absolute file - can't fingerprint/cache it + cache_dir = _ref_codes_cache_dir() + cache_path = os.path.join(cache_dir, f"{fp}.npy") + if os.path.isfile(cache_path): + return cache_path + if not ref_transcript: + return None # a reference-conditioned encode requires reference_text too + + try: + model, processor = _get_model() + with _model_lock: + probe = processor( + text="x", reference_audio=ref_wav_path, reference_text=ref_transcript, + return_tensors="pt", + ) + codes, code_lengths = model.encode_audio( + probe["reference_audio_values"], probe["reference_audio_lengths"], + ) + trimmed = codes[0, :, : int(code_lengths[0])].detach().cpu().numpy().astype(np.int64) + os.makedirs(cache_dir, exist_ok=True) + np.save(cache_path, trimmed) + except Exception as e: + print(f"Audio8 reference-codes cache write error: {e}") + return None + return cache_path + def generate_segment(self, text: str, ref_wav_path: str, ref_transcript: str, speed: float, lang_code: str) -> np.ndarray: """Runs one segment through the shared model, serialized via @@ -328,15 +410,36 @@ def generate_segment(self, text: str, ref_wav_path: str, ref_transcript: str, `model.generate(**inputs)` -> codes -> `model.decode_audio(codes)`, or the combined `model.generate_audio(**inputs, ...)` used below, which returns `(waveforms, lengths, codes)` directly. + + When `self.cache_reference_codes` is on (see `process_chunk_task`), + looks up/populates a persisted reference-codes cache first (see + `_reference_codes_path`) and passes `reference_codes=` instead of + `reference_audio=`/`reference_text=` on a hit - same output, skips + re-encoding the reference wav through the model's audio codec. """ model, processor = _get_model() + cached_codes_path = ( + self._reference_codes_path(ref_wav_path, ref_transcript) + if self.cache_reference_codes else None + ) with _model_lock: - inputs = processor( - text=text, - reference_audio=ref_wav_path or None, - reference_text=ref_transcript or None, - return_tensors="pt", - ) + if cached_codes_path: + # `reference_text` isn't only an input to the audio encode - + # `ArkttsProcessor._prompt_segments` bakes it into the *text* + # prompt tokens whenever `has_reference` is True (set by + # either `reference_audio` or `reference_codes`), so it's + # still required here even though the audio side is cached. + inputs = processor( + text=text, reference_codes=cached_codes_path, + reference_text=ref_transcript or None, return_tensors="pt", + ) + else: + inputs = processor( + text=text, + reference_audio=ref_wav_path or None, + reference_text=ref_transcript or None, + return_tensors="pt", + ) waveforms, lengths, _codes = model.generate_audio( **inputs, max_new_tokens=4096, temperature=0.7, top_p=0.9, top_k=50, ) @@ -366,6 +469,9 @@ def process_chunk_task(self, chunk_data, progress_callback): ref_wav = config['voice'] # already resolved by ConversionMixin.start_conversion ref_transcript = self.resolve_voice_transcript(ref_wav) split_pattern = config.get('split_pattern', r"\n+") + # See `_reference_codes_path`/module docstring - independent of the + # per-segment WAV cache below (`use_cache`/`caching`). + self.cache_reference_codes = config.get('cache_reference_codes', True) use_cache = config.get('caching', False) cache_hash = None @@ -489,6 +595,8 @@ def get_config_schema(self) -> list: default=1, min=1, max=4, step=1, group="Advanced"), ConfigField("caching", "Enable Segment Cache", ConfigFieldType.BOOL, default=True, group="Advanced"), + ConfigField("cache_reference_codes", "Cache Reference Encoding", ConfigFieldType.BOOL, + default=True, group="Advanced"), ] def get_voices(self, lang_code: Optional[str] = None) -> list: diff --git a/tests/test_engines_audio8.py b/tests/test_engines_audio8.py index e66f47e..6d900a9 100644 --- a/tests/test_engines_audio8.py +++ b/tests/test_engines_audio8.py @@ -10,6 +10,7 @@ import os import subprocess import sys +import types from pathlib import Path import numpy as np @@ -70,7 +71,10 @@ def test_config_schema_shape(audio8_engine): assert all(isinstance(f, ConfigField) for f in schema) keys = {f.key for f in schema} - assert keys == {"lang_code", "voice", "speed", "split_pattern", "format", "num_threads", "caching"} + assert keys == { + "lang_code", "voice", "speed", "split_pattern", "format", "num_threads", + "caching", "cache_reference_codes", + } by_key = {f.key: f for f in schema} # Fixed, engine-declared language list - NOT GUI-resolved like Kokoro's. @@ -195,6 +199,126 @@ def test_process_chunk_task_writes_44100hz_audio(audio8_engine, isolated_audio8_ # test_audio8_process_chunk_task_caching_keys_on_transcript. +# --- Audio8Engine: reference-codes cache (_reference_codes_path) ------------ +# +# `_get_model` is monkeypatched to a lightweight fake model/processor pair, +# same "never touch the real model" convention as `_fake_segment` above, but +# one level lower - these tests exercise `_reference_codes_path`/ +# `generate_segment`'s branching itself, not just its caller. + +def _make_fake_model_and_processor(monkeypatch): + import torch + + calls = {"processor": [], "encode_audio": 0} + + def fake_processor(text, reference_audio=None, reference_text=None, + reference_codes=None, return_tensors="pt"): + calls["processor"].append({ + "text": text, "reference_audio": reference_audio, + "reference_text": reference_text, "reference_codes": reference_codes, + }) + # Mirrors the real `ArkttsProcessor._prompt_segments` constraint: + # `reference_text` is required whenever *either* reference kwarg is + # given - it's baked into the text prompt tokens, not just an audio- + # encode input. Enforcing it here is what caught the real bug where + # the reference_codes branch dropped reference_text entirely. + if (reference_audio is not None or reference_codes is not None) and not reference_text: + raise ValueError("reference_text is required when a reference voice is provided") + return { + "reference_audio_values": torch.zeros((1, 1, 4)), + "reference_audio_lengths": torch.tensor([4]), + } + + def fake_encode_audio(audio_values, audio_lengths): + calls["encode_audio"] += 1 + return torch.arange(30, dtype=torch.long).reshape(1, 10, 3), torch.tensor([3]) + + def fake_generate_audio(**kwargs): + return torch.zeros((1, 100)), torch.tensor([100]), None + + fake_model = types.SimpleNamespace(encode_audio=fake_encode_audio, generate_audio=fake_generate_audio) + monkeypatch.setattr(audio8_tts, "_get_model", lambda: (fake_model, fake_processor)) + return calls + + +def test_reference_codes_path_none_without_a_fingerprintable_file(audio8_engine): + assert audio8_engine._reference_codes_path("", "some transcript") is None + assert audio8_engine._reference_codes_path("not-a-real-path.wav", "some transcript") is None + + +def test_reference_codes_path_none_without_a_transcript(audio8_engine, a_wav): + # A fresh (uncached) reference can't be encoded without reference_text - + # `ArkttsProcessor._prompt_segments` requires it whenever reference audio + # is given (see generate_segment's docstring). + assert audio8_engine._reference_codes_path(a_wav, "") is None + + +def test_reference_codes_path_computes_and_persists_on_first_call(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + + path = audio8_engine._reference_codes_path(a_wav, "A reference transcript.") + + assert path is not None + assert os.path.commonpath([path, str(isolated_audio8_refs)]) == str(isolated_audio8_refs) + assert calls["encode_audio"] == 1 + loaded = np.load(path) + assert loaded.shape == (10, 3) + assert loaded.dtype == np.int64 + + +def test_reference_codes_path_reuses_cache_without_recomputing(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + first = audio8_engine._reference_codes_path(a_wav, "A reference transcript.") + assert calls["encode_audio"] == 1 + + second = audio8_engine._reference_codes_path(a_wav, "A reference transcript.") + assert second == first + assert calls["encode_audio"] == 1 # not called again - served from disk + + +def test_generate_segment_passes_reference_codes_when_cache_enabled(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + audio8_engine.cache_reference_codes = True + + audio8_engine.generate_segment("Hello.", a_wav, "A reference transcript.", 1.0, "English") + + # First call: the one-off probe encode. Second call: the real generation + # call, which must use reference_codes now that the cache is populated. + assert len(calls["processor"]) == 2 + gen_call = calls["processor"][-1] + assert gen_call["reference_codes"] is not None + assert gen_call["reference_audio"] is None + assert gen_call["reference_text"] == "A reference transcript." + + +def test_generate_segment_uses_raw_reference_audio_when_cache_disabled(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + audio8_engine.cache_reference_codes = False + + audio8_engine.generate_segment("Hello.", a_wav, "A reference transcript.", 1.0, "English") + + assert len(calls["processor"]) == 1 # no probe encode - never even fingerprinted + assert calls["encode_audio"] == 0 + gen_call = calls["processor"][-1] + assert gen_call["reference_audio"] == a_wav + assert gen_call["reference_codes"] is None + + +def test_process_chunk_task_reads_cache_reference_codes_from_config(audio8_engine, isolated_audio8_refs, isolated_dirs, a_wav, monkeypatch): + Audio8ReferenceStore.save_reference("Dana", a_wav, "Dana's reference line.") + _fake_segment(monkeypatch, audio8_engine) + assert audio8_engine.cache_reference_codes is True # __init__ default + + config = { + "lang_code": "English", "voice": audio8_engine.resolve_voice_path("Dana"), + "speed": 1.0, "split_pattern": r"\n+", "filename": "out", "time_id": "1", + "out_dir": str(isolated_dirs.out_dir), "format": "wav", "caching": False, + "apply_fx": False, "cache_reference_codes": False, + } + audio8_engine.process_chunk_task((0, "Hello there.", config), None) + assert audio8_engine.cache_reference_codes is False + + def test_cancel_sets_cancel_event(audio8_engine): assert not audio8_engine.cancel_event.is_set() audio8_engine.cancel() From 6aabef55f5f1f8b9776de84b4d759796367b76a7 Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sat, 22 Aug 2026 20:13:09 -0600 Subject: [PATCH 11/44] =?UTF-8?q?What=20changed=20(audio8=5Ftts.py)=20New?= =?UTF-8?q?=20"Model"=20config=20group=20with=20max=5Fnew=5Ftokens=20(defa?= =?UTF-8?q?ult=201024,=20capped=20at=202048=20to=20match=20the=20model's?= =?UTF-8?q?=20real=20max=5Fseq=5Flen=20=E2=80=94=20anything=20higher=20was?= =?UTF-8?q?=20already=20being=20silently=20clamped),=20temperature=20(0.8)?= =?UTF-8?q?,=20top=5Fp=20(0.95),=20top=5Fk=20(50)=20=E2=80=94=20your=20req?= =?UTF-8?q?uested=20values=20are=20now=20the=20defaults,=20and=20all=20fou?= =?UTF-8?q?r=20are=20user-adjustable=20in=20the=20Generation=20dock=20(sch?= =?UTF-8?q?ema-driven,=20no=20GUI=20code=20needed).=20generate=5Fsegment?= =?UTF-8?q?=20no=20longer=20hardcodes=20these=20in=20the=20model.generate?= =?UTF-8?q?=5Faudio(...)=20call=20=E2=80=94=20it=20reads=20instance=20attr?= =?UTF-8?q?ibutes=20(self.max=5Fnew=5Ftokens/temperature/top=5Fp/top=5Fk),?= =?UTF-8?q?=20same=20pattern=20as=20the=20existing=20cache=5Freference=5Fc?= =?UTF-8?q?odes=20toggle.=20process=5Fchunk=5Ftask=20sets=20those=20attrib?= =?UTF-8?q?utes=20from=20config=20each=20run,=20with=20the=20same=20defaul?= =?UTF-8?q?ts=20as=20a=20fallback=20for=20direct=20calls=20that=20bypass?= =?UTF-8?q?=20it.=20Also=20folded=20them=20into=20the=20segment-cache=20ke?= =?UTF-8?q?y=20(compute=5Fcache=5Fkey's=20extra=3D)=20=E2=80=94=20these=20?= =?UTF-8?q?params=20change=20what=20actually=20gets=20generated,=20so=20if?= =?UTF-8?q?=20segment=20caching=20is=20on=20and=20you=20change=20temperatu?= =?UTF-8?q?re/top=5Fp/etc.,=20it=20now=20correctly=20regenerates=20instead?= =?UTF-8?q?=20of=20silently=20serving=20audio=20produced=20under=20old=20s?= =?UTF-8?q?ettings.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kokoro_gui/engines/audio8_tts.py | 46 ++++++++++++++++++++++++++++++-- tests/test_caching.py | 41 ++++++++++++++++++++++++++++ tests/test_engines_audio8.py | 45 ++++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/kokoro_gui/engines/audio8_tts.py b/kokoro_gui/engines/audio8_tts.py index 268e623..4e621b7 100644 --- a/kokoro_gui/engines/audio8_tts.py +++ b/kokoro_gui/engines/audio8_tts.py @@ -272,6 +272,19 @@ def __init__(self): # `process_chunk_task` (e.g. calling `generate_segment` directly). self.cache_reference_codes = True + # `ArkttsModel.generate`/`generate_audio` sampling knobs, exposed as + # config fields (Audio8BackendAdapter.get_config_schema, "Generation" + # group) rather than hardcoded - `process_chunk_task` overwrites + # these from `config` each run, same pattern as `cache_reference_codes` + # above. Defaults match this engine's original hardcoded values, + # except `max_new_tokens` (was 4096, clamped internally to whatever + # room is left under the model's `max_seq_len=2048` anyway - 1024 + # is a more honest default that still leaves prompt room). + self.max_new_tokens = 1024 + self.temperature = 0.8 + self.top_p = 0.95 + self.top_k = 50 + self._lexicon_cache = {} os.makedirs(AUDIO8_REFS_DIR, exist_ok=True) @@ -441,7 +454,8 @@ def generate_segment(self, text: str, ref_wav_path: str, ref_transcript: str, return_tensors="pt", ) waveforms, lengths, _codes = model.generate_audio( - **inputs, max_new_tokens=4096, temperature=0.7, top_p=0.9, top_k=50, + **inputs, max_new_tokens=self.max_new_tokens, temperature=self.temperature, + top_p=self.top_p, top_k=self.top_k, ) audio = waveforms[0, : lengths[0]].detach().cpu().numpy() @@ -472,6 +486,14 @@ def process_chunk_task(self, chunk_data, progress_callback): # See `_reference_codes_path`/module docstring - independent of the # per-segment WAV cache below (`use_cache`/`caching`). self.cache_reference_codes = config.get('cache_reference_codes', True) + # `ArkttsModel.generate` sampling knobs - see `__init__`'s docstring + # on these same attributes. Read into `extra` below too: they change + # what gets generated, so a stale segment cached under old values + # must miss rather than silently keep serving old audio. + self.max_new_tokens = config.get('max_new_tokens', 1024) + self.temperature = config.get('temperature', 0.8) + self.top_p = config.get('top_p', 0.95) + self.top_k = config.get('top_k', 50) use_cache = config.get('caching', False) cache_hash = None @@ -480,7 +502,11 @@ def process_chunk_task(self, chunk_data, progress_callback): if use_cache: cache_hash = compute_cache_key( text, ref_wav, eff_speed, lang_code, engine_id="audio8", - extra={"ref_transcript": ref_transcript}, + extra={ + "ref_transcript": ref_transcript, + "max_new_tokens": self.max_new_tokens, "temperature": self.temperature, + "top_p": self.top_p, "top_k": self.top_k, + }, ) try: predicted_texts = [t.strip() for t in re.split(split_pattern, text) if t.strip()] @@ -597,6 +623,22 @@ def get_config_schema(self) -> list: default=True, group="Advanced"), ConfigField("cache_reference_codes", "Cache Reference Encoding", ConfigFieldType.BOOL, default=True, group="Advanced"), + # `ArkttsModel.generate`/`generate_audio` sampling knobs (see + # `Audio8Engine.__init__`/`process_chunk_task`/`generate_segment`) + # - model-specific, unlike everything above, so broken out into + # their own group rather than folded into "Generation"/"Advanced". + # `max_new_tokens` above `max_seq_len - ` (2048 + # total, per the model's config) is clamped internally by + # `ArkttsModel.generate` - the 2048 ceiling here just matches + # that reality instead of offering a value that's silently capped. + ConfigField("max_new_tokens", "Max New Tokens", ConfigFieldType.INT, + default=1024, min=64, max=2048, step=64, group="Model"), + ConfigField("temperature", "Temperature", ConfigFieldType.SLIDER, + default=0.8, min=0.1, max=2.0, step=0.05, group="Model"), + ConfigField("top_p", "Top P", ConfigFieldType.SLIDER, + default=0.95, min=0.0, max=1.0, step=0.01, group="Model"), + ConfigField("top_k", "Top K", ConfigFieldType.INT, + default=50, min=0, max=200, step=1, group="Model"), ] def get_voices(self, lang_code: Optional[str] = None) -> list: diff --git a/tests/test_caching.py b/tests/test_caching.py index 10635ec..b633e11 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -302,6 +302,47 @@ def _fake_segment(text, ref_wav_path, ref_transcript, speed, lang_code): engine.worker.stop() +def test_audio8_process_chunk_task_caching_keys_on_sampling_knobs(isolated_dirs, tmp_path, monkeypatch): + """Changing a sampling knob (temperature/top_p/top_k/max_new_tokens) + changes what the model would generate, so it must be a cache miss too - + same reasoning as the transcript test above, folded into `extra` the + same way.""" + import numpy as np + + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(tmp_path / "audio8_refs")) + + wav_path = tmp_path / "ref.wav" + ref_audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(wav_path), ref_audio, 16000) + Audio8ReferenceStore.save_reference("Faye", str(wav_path), "Faye's reference line.") + + engine = Audio8Engine() + try: + def _fake_segment(text, ref_wav_path, ref_transcript, speed, lang_code): + t = np.arange(2200) / 44100 + return (0.1 * np.sin(2 * np.pi * 220 * t)).astype(np.float32) + monkeypatch.setattr(engine, "generate_segment", _fake_segment) + + voice_path = engine.resolve_voice_path("Faye") + config = { + "lang_code": "English", "voice": voice_path, "speed": 1.0, "split_pattern": r"\n+", + "filename": "out", "time_id": "1", "out_dir": str(isolated_dirs.out_dir), + "format": "wav", "caching": True, "apply_fx": False, "temperature": 0.8, + } + engine.process_chunk_task((0, "Hello there.", config), None) + first_cache_files = set(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(first_cache_files) == 1 + + config["temperature"] = 1.2 # only the sampling knob changes + engine.process_chunk_task((0, "Hello there.", config), None) + second_cache_files = set(isolated_dirs.cache_dir.glob("*_0.wav")) + + assert len(second_cache_files) == 2 + assert first_cache_files < second_cache_files + finally: + engine.worker.stop() + + def test_audio8_process_chunk_task_caches_every_segment_in_a_multi_segment_chunk(isolated_dirs, tmp_path, monkeypatch): """A chunk that splits into more than one segment (split_pattern matching within one chunk's text, e.g. two newline-separated lines) must diff --git a/tests/test_engines_audio8.py b/tests/test_engines_audio8.py index 6d900a9..885120a 100644 --- a/tests/test_engines_audio8.py +++ b/tests/test_engines_audio8.py @@ -74,6 +74,7 @@ def test_config_schema_shape(audio8_engine): assert keys == { "lang_code", "voice", "speed", "split_pattern", "format", "num_threads", "caching", "cache_reference_codes", + "max_new_tokens", "temperature", "top_p", "top_k", } by_key = {f.key: f for f in schema} @@ -84,6 +85,11 @@ def test_config_schema_shape(audio8_engine): assert by_key["voice"].choices is None # Parallelism is capped low - see module docstring on the shared model lock. assert by_key["num_threads"].max == 4 + # Model-specific sampling knobs get their own group, not Generation/Advanced. + assert by_key["max_new_tokens"].group == "Model" + assert by_key["temperature"].group == "Model" + assert by_key["top_p"].group == "Model" + assert by_key["top_k"].group == "Model" def test_importing_module_does_not_load_the_model(): @@ -209,7 +215,7 @@ def test_process_chunk_task_writes_44100hz_audio(audio8_engine, isolated_audio8_ def _make_fake_model_and_processor(monkeypatch): import torch - calls = {"processor": [], "encode_audio": 0} + calls = {"processor": [], "encode_audio": 0, "generate_audio": []} def fake_processor(text, reference_audio=None, reference_text=None, reference_codes=None, return_tensors="pt"): @@ -234,6 +240,7 @@ def fake_encode_audio(audio_values, audio_lengths): return torch.arange(30, dtype=torch.long).reshape(1, 10, 3), torch.tensor([3]) def fake_generate_audio(**kwargs): + calls["generate_audio"].append(kwargs) return torch.zeros((1, 100)), torch.tensor([100]), None fake_model = types.SimpleNamespace(encode_audio=fake_encode_audio, generate_audio=fake_generate_audio) @@ -304,6 +311,42 @@ def test_generate_segment_uses_raw_reference_audio_when_cache_disabled(audio8_en assert gen_call["reference_codes"] is None +def test_generate_segment_forwards_sampling_knob_defaults(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + + audio8_engine.generate_segment("Hello.", a_wav, "A reference transcript.", 1.0, "English") + + assert len(calls["generate_audio"]) == 1 + kwargs = calls["generate_audio"][0] + assert kwargs["max_new_tokens"] == 1024 + assert kwargs["temperature"] == 0.8 + assert kwargs["top_p"] == 0.95 + assert kwargs["top_k"] == 50 + + +def test_process_chunk_task_reads_sampling_knobs_from_config(audio8_engine, isolated_audio8_refs, isolated_dirs, a_wav, monkeypatch): + Audio8ReferenceStore.save_reference("Dana", a_wav, "Dana's reference line.") + calls = _make_fake_model_and_processor(monkeypatch) + + config = { + "lang_code": "English", "voice": audio8_engine.resolve_voice_path("Dana"), + "speed": 1.0, "split_pattern": r"\n+", "filename": "out", "time_id": "1", + "out_dir": str(isolated_dirs.out_dir), "format": "wav", "caching": False, + "apply_fx": False, "max_new_tokens": 256, "temperature": 1.1, "top_p": 0.5, "top_k": 10, + } + audio8_engine.process_chunk_task((0, "Hello there.", config), None) + + assert audio8_engine.max_new_tokens == 256 + assert audio8_engine.temperature == 1.1 + assert audio8_engine.top_p == 0.5 + assert audio8_engine.top_k == 10 + kwargs = calls["generate_audio"][0] + assert kwargs["max_new_tokens"] == 256 + assert kwargs["temperature"] == 1.1 + assert kwargs["top_p"] == 0.5 + assert kwargs["top_k"] == 10 + + def test_process_chunk_task_reads_cache_reference_codes_from_config(audio8_engine, isolated_audio8_refs, isolated_dirs, a_wav, monkeypatch): Audio8ReferenceStore.save_reference("Dana", a_wav, "Dana's reference line.") _fake_segment(monkeypatch, audio8_engine) From ebf5f2201a7ee9d40088ed692e946b15510fa8e1 Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Sun, 23 Aug 2026 10:36:30 -0600 Subject: [PATCH 12/44] What's new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/index.html — a single self-contained GitHub Pages landing page. No build step, no dependencies beyond a Google Fonts link, so it just works once GitHub Pages is pointed at /docs. --- docs/WEBSITE_ROADMAP.md | 96 ++++++ docs/index.html | 721 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 817 insertions(+) create mode 100644 docs/WEBSITE_ROADMAP.md create mode 100644 docs/index.html diff --git a/docs/WEBSITE_ROADMAP.md b/docs/WEBSITE_ROADMAP.md new file mode 100644 index 0000000..37f35ae --- /dev/null +++ b/docs/WEBSITE_ROADMAP.md @@ -0,0 +1,96 @@ +# Website roadmap + +Planning notes for [`docs/index.html`](index.html), the GitHub Pages landing page for the +project. This file is tracked in git, unlike the local-only `ROADMAP.md`/`PLAN_*.md` at the repo +root, because the site is a public-facing deliverable. It's fine for contributors to read where +it's headed. + +## Where it stands today + +One self-contained page: `docs/index.html`, no build step, no dependencies beyond a Google Fonts +link. It covers the hero pitch, the five Qt docks, the Kokoro/Audio8 engine comparison, the +audio-processing signal chain, generation modes, a feature strip, and an install guide. Content was +pulled from `README.md` and `CLAUDE.md` as of Beta 3.3.0. It will drift as the app gains features, +so treat "New in X.Y.Z" entries in the README as the trigger to revisit this page, the same way +CLAUDE.md already asks for ROADMAP.md. + +## Turning the repo on for Pages + +Nothing is wired up to actually serve this yet. Two options, in order of effort: + +1. **Repo settings only.** Settings → Pages → Source: "Deploy from a branch" → `main` / `docs`. No + workflow file needed, GitHub rebuilds on every push to `main` that touches `docs/`. Fastest + option, start here. +2. **GitHub Actions workflow.** Add `.github/workflows/pages.yml` using `actions/deploy-pages` so + the site can later include a build step (a Jekyll pass, or a bundler, if the page stops being a + single static file). Worth doing once the site needs something option 1 can't do, not before; + it's otherwise unnecessary CI surface. + +Revisit once one of the phases below actually needs a build step. + +## Phase 1: make the static page earn its keep + +Low effort, no new infrastructure: + +- **Open Graph and Twitter card meta tags.** `og:title`, `og:description`, `og:image`, so a link to + the site renders a real preview card when shared instead of a bare URL. Needs a dedicated + 1200×630 social image, not a cropped screenshot. +- **Real audio samples.** The page currently only describes the difference between Kokoro and + Audio8. A handful of short, pre-rendered `.wav`/`.mp3` clips checked into `docs/assets/audio/`, + the same line read by a Kokoro voice, an Audio8 clone, and both generation modes, would turn the + engine-comparison section into something a visitor can actually listen to via `