From 29f6d06d5cec6e7fad6bc27e9327c79cd1e76143 Mon Sep 17 00:00:00 2001 From: Ed Chan Date: Sat, 12 Sep 2026 00:19:11 -0400 Subject: [PATCH 1/4] Improve preview responsiveness and accessibility --- config.json | 53 +++++++++++++- gui.py | 127 ++++++++++++++++++++++----------- kokoro_engine.py | 11 +-- tests/test_generate_preview.py | 12 ++++ tests/test_gui_callbacks.py | 11 +++ tests/test_gui_settings.py | 31 ++++++++ 6 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 tests/test_gui_callbacks.py diff --git a/config.json b/config.json index 13a8a80..d2f6fc7 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,10 @@ { "appearance": "Dark", "scaling": "100%", - "voice": "af_heart", + "lang_code": "b", + "voice": "bf_alice", "filename": "output", + "format": "mp3", "out_dir": "audio_output", "speed": 1.0, "volume": 1.0, @@ -12,6 +14,53 @@ "separate": false, "combine": true, "export_subtitles": false, + "caching": true, + "jit_enabled": false, "normalize": false, - "trim": 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": {} } \ No newline at end of file diff --git a/gui.py b/gui.py index b47e7cd..88e5274 100644 --- a/gui.py +++ b/gui.py @@ -2,6 +2,7 @@ import time import json import re +import queue import playback import customtkinter as ctk from tkinter import filedialog, messagebox @@ -11,6 +12,10 @@ # Set Default Appearance (will be overridden by settings) ctk.set_appearance_mode("Dark") ctk.set_default_color_theme("blue") +ctk.DrawEngine.preferred_drawing_method = "polygon_shapes" +ctk.ThemeManager.theme["CTkFont"]["size"] = 18 + +FONT_SCALE = 1.4 CONFIG_FILE = "config.json" PRESETS_DIR = "presets" @@ -19,6 +24,8 @@ class TTSApp(ctk.CTk): def __init__(self): super().__init__() + self._ui_callbacks = queue.Queue() + self.after(50, self._process_ui_callbacks) self.title("Kokoro TTS GUI") self.geometry("700x900") @@ -181,7 +188,38 @@ def __init__(self): # Init Pipeline self.status_label.configure(text="Initializing engine...") - self.engine.worker.run_coro(self.engine.init_pipeline_async(self.lang_var.get())) + self._pipeline_init_future = self.engine.worker.run_coro( + self.engine.init_pipeline_async(self.lang_var.get(), notify=False) + ) + self.after(100, self._poll_pipeline_initialization) + + def _post_to_ui(self, callback): + self._ui_callbacks.put(callback) + + def _process_ui_callbacks(self): + while True: + try: + callback = self._ui_callbacks.get_nowait() + except queue.Empty: + break + callback() + self.after(50, self._process_ui_callbacks) + + def _poll_pipeline_initialization(self): + if not self._pipeline_init_future.done(): + self.after(100, self._poll_pipeline_initialization) + return + + try: + ready = self._pipeline_init_future.result() + except Exception as exc: + self.status_label.configure(text=f"Engine initialization failed: {exc}", text_color="#ff5555") + return + + if ready: + self.status_label.configure(text="Ready", text_color="gray") + else: + self.status_label.configure(text="Engine initialization failed.", text_color="#ff5555") def get_all_voices(self, lang_code=None): if lang_code is None: @@ -434,6 +472,10 @@ def apply_settings(self): except Exception: ctk.set_widget_scaling(1.0) + @staticmethod + def ui_font(family, size, weight="normal"): + return ctk.CTkFont(family=family, size=round(size * FONT_SCALE), weight=weight) + # --- Preset Management --- def refresh_presets(self): @@ -753,12 +795,14 @@ 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) + def _play_preview(): + self.mix_status_label.configure(text="Playing preview...", text_color="green") + playback.play(tmp_audio_path) + self._post_to_ui(_play_preview) else: - self.after(0, lambda: self.mix_status_label.configure(text=f"Preview failed: {err}", text_color="red")) + self._post_to_ui(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")) + self._post_to_ui(lambda error=str(e): self.mix_status_label.configure(text=f"Error: {error}", text_color="red")) future = self.engine.worker.run_coro(_run_preview()) future.add_done_callback(_on_done) @@ -786,16 +830,16 @@ def mix_voice_action(self): self.set_ui_state(True) # Reuse existing lock def _done(future): - self.after(0, lambda: self.set_ui_state(False)) + self._post_to_ui(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) + self._post_to_ui(lambda: self.mix_status_label.configure(text=f"Saved: {name}", text_color="green")) + self._post_to_ui(self.refresh_voice_lists) else: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {msg}", text_color="red")) + self._post_to_ui(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")) + self._post_to_ui(lambda error=str(e): self.mix_status_label.configure(text=f"Error: {error}", text_color="red")) future = self.engine.worker.run_coro(self.engine.mix_voices(v1, v2, ratio, name, op=op)) future.add_done_callback(_done) @@ -887,7 +931,7 @@ def on_prev_lang_ui(c): self.preview_lang_var.set(self.LANGUAGES[c]) 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)) + ctk.CTkLabel(parent, text="Custom Voices:", font=self.ui_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) @@ -925,12 +969,12 @@ def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_at 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) + ctk.CTkLabel(dyn_frame, text="Dynamics", font=self.ui_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") + ctk.CTkCheckBox(c_head, text="Compressor", variable=self.comp_enabled, font=self.ui_font("Roboto", 12, "bold")).pack(side="left") c_body = ctk.CTkFrame(dyn_frame) c_body.pack(fill="x", padx=10, pady=2) @@ -940,7 +984,7 @@ def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_at # 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") + ctk.CTkCheckBox(l_head, text="Limiter", variable=self.limiter_enabled, font=self.ui_font("Roboto", 12, "bold")).pack(side="left") l_body = ctk.CTkFrame(dyn_frame) l_body.pack(fill="x", padx=10, pady=2) @@ -949,13 +993,13 @@ def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_at # 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") + ctk.CTkCheckBox(g_head, text="Gain", variable=self.gain_enabled, font=self.ui_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) + ctk.CTkLabel(eq_frame, text="EQ & Filters", font=self.ui_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') @@ -975,12 +1019,12 @@ def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_at # --- 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) + ctk.CTkLabel(sp_frame, text="Spatial & Time", font=self.ui_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") + ctk.CTkCheckBox(r_head, text="Reverb", variable=self.reverb_enabled, font=self.ui_font("Roboto", 12, "bold")).pack(side="left") r_body = ctk.CTkFrame(sp_frame) r_body.pack(fill="x", padx=10, pady=2) @@ -992,7 +1036,7 @@ def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_at # 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") + ctk.CTkCheckBox(d_head, text="Delay", variable=self.delay_enabled, font=self.ui_font("Roboto", 12, "bold")).pack(side="left") d_body = ctk.CTkFrame(sp_frame) d_body.pack(fill="x", padx=10, pady=2) @@ -1003,7 +1047,7 @@ def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_at # --- 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) + ctk.CTkLabel(mod_frame, text="Guitar / Modulation", font=self.ui_font("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) # Chorus ch_head = ctk.CTkFrame(mod_frame, fg_color="transparent") @@ -1033,7 +1077,7 @@ def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_at # --- 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) + ctk.CTkLabel(q_frame, text="Quality / Pitch", font=self.ui_font("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) # Pitch Shift ps_head = ctk.CTkFrame(q_frame, fg_color="transparent") @@ -1067,7 +1111,7 @@ def build_generation_tab(self, parent): 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) + ctk.CTkLabel(input_frame, text="Input Source", font=self.ui_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) @@ -1094,7 +1138,7 @@ def build_generation_tab(self, parent): 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) + ctk.CTkLabel(config_frame, text="Configuration", font=self.ui_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") @@ -1183,7 +1227,7 @@ def on_lang_ui_change(choice): 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) + ctk.CTkLabel(audio_frame, text="Audio Control", font=self.ui_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%") @@ -1221,7 +1265,7 @@ def on_lang_ui_change(choice): 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) + ctk.CTkLabel(adv_frame, text="Processing Options", font=self.ui_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) @@ -1309,9 +1353,9 @@ def refresh_lexicon_list(self): 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=orig, width=150, anchor="w", font=self.ui_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.CTkLabel(row, text=rep, width=150, anchor="w", font=self.ui_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) @@ -1324,7 +1368,7 @@ 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.CTkLabel(header_frame, text="Kokoro TTS", font=self.ui_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") # Main Tabs @@ -1350,7 +1394,7 @@ def create_widgets(self): 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 = ctk.CTkLabel(action_frame, text="...", font=self.ui_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) @@ -1367,7 +1411,7 @@ def create_widgets(self): 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 = ctk.CTkButton(btn_frame, text=btn_txt, command=self.start_conversion, height=40, font=self.ui_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") @@ -1389,27 +1433,25 @@ def open_settings(self): 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)) + ctk.CTkLabel(frame, text="Appearance Mode:", font=self.ui_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) + ctk.CTkLabel(frame, text="UI Scaling:", font=self.ui_font("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) + scale_menu = ctk.CTkOptionMenu(frame, values=["80%", "90%", "100%", "110%", "120%", "150%", "200%", "250%", "300%"], 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.CTkLabel(frame, text="Generation Cache:", font=self.ui_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.CTkLabel(frame, text="Real-time / JIT:", font=self.ui_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): @@ -1491,11 +1533,10 @@ def browse_file(self): 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)) + self._post_to_ui(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)) + self._post_to_ui(lambda: messagebox.showerror("Missing Dependencies", msg)) def on_engine_progress(self, percent, elapsed, eta, detail): # Schedule update @@ -1504,10 +1545,10 @@ def _update(): 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) + self._post_to_ui(_update) def on_engine_finish(self): - self.after(0, lambda: self.set_ui_state(False)) + self._post_to_ui(lambda: self.set_ui_state(False)) def set_ui_state(self, is_running): state = "disabled" if is_running else "normal" @@ -1629,7 +1670,7 @@ def _ui_update(): except Exception as e: self.status_label.configure(text=f"Preview error: {e}", text_color="red") - self.after(0, _ui_update) + self._post_to_ui(_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) diff --git a/kokoro_engine.py b/kokoro_engine.py index cd3764d..5a4eac5 100644 --- a/kokoro_engine.py +++ b/kokoro_engine.py @@ -261,10 +261,10 @@ def process_audio(self, audio, sr, config): return audio - async def init_pipeline_async(self, lang_code="a"): + async def init_pipeline_async(self, lang_code="a", notify=True): try: self.pipeline = await asyncio.to_thread(KPipeline, lang_code=lang_code) - if self.on_status: self.on_status(f"Pipeline Initialized ({lang_code}).", False) + if notify and self.on_status: self.on_status(f"Pipeline Initialized ({lang_code}).", False) return True except Exception as e: msg = f"Pipeline Init Failed: {e}" @@ -274,7 +274,7 @@ async def init_pipeline_async(self, lang_code="a"): elif lang_code == 'z' and "pypinyin" in err_str: msg += "\n(Try: pip install pypinyin)" - if self.on_status: self.on_status(msg, True) + if notify and self.on_status: self.on_status(msg, True) return False async def mix_voices(self, v1_name, v2_name, ratio, new_name, op='mix'): @@ -338,8 +338,9 @@ def _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) + # Reuse the initialized pipeline when it serves the selected language. + # Creating another KPipeline reloads the model and rechecks Hugging Face. + p = self.pipeline if getattr(self.pipeline, "lang_code", None) == lang_code else get_thread_pipeline(lang_code) if not p: return False try: diff --git a/tests/test_generate_preview.py b/tests/test_generate_preview.py index d4e4780..1a12b21 100644 --- a/tests/test_generate_preview.py +++ b/tests/test_generate_preview.py @@ -14,6 +14,18 @@ def test_generate_preview_writes_wav_file(engine, fake_pipeline, tmp_path): assert os.path.getsize(out_path) > 0 +def test_generate_preview_reuses_initialized_pipeline(engine, fake_pipeline, tmp_path, monkeypatch): + engine.pipeline = fake_pipeline + + def _boom(lang_code="a"): + raise AssertionError("preview should reuse the initialized pipeline") + + monkeypatch.setattr("kokoro_engine.get_thread_pipeline", _boom) + out_path = str(tmp_path / "preview.wav") + + assert asyncio.run(engine.generate_preview("Hello.", "af_heart", 1.0, out_path)) is True + + def test_generate_preview_truncates_multispeaker_to_two_segments(engine, fake_pipeline, tmp_path, monkeypatch): calls = [] orig_call = fake_pipeline.__call__ diff --git a/tests/test_gui_callbacks.py b/tests/test_gui_callbacks.py new file mode 100644 index 0000000..c6ae366 --- /dev/null +++ b/tests/test_gui_callbacks.py @@ -0,0 +1,11 @@ +"""Tests for worker-to-Tk callback dispatch.""" + + +def test_post_to_ui_runs_callback_on_main_loop(tts_app): + called = [] + + tts_app._post_to_ui(lambda: called.append(True)) + assert called == [] + + tts_app._process_ui_callbacks() + assert called == [True] diff --git a/tests/test_gui_settings.py b/tests/test_gui_settings.py index fb6d20a..507d132 100644 --- a/tests/test_gui_settings.py +++ b/tests/test_gui_settings.py @@ -1,6 +1,7 @@ """Tests for TTSApp.load_settings/save_settings/apply_settings (gui.py:263-436).""" import json +from unittest.mock import call, MagicMock def test_load_settings_defaults_when_no_config_file(tts_app): @@ -47,3 +48,33 @@ def test_change_appearance_and_scaling_persist_to_settings(tts_app): assert tts_app.settings["appearance"] == "Light" assert tts_app.settings["scaling"] == "120%" + + +def test_change_scaling_uses_requested_widget_scale_without_cap(tts_app, monkeypatch): + import gui + set_widget_scaling = MagicMock() + monkeypatch.setattr(gui.ctk, "set_widget_scaling", set_widget_scaling) + + tts_app.change_scaling("250%") + tts_app.change_scaling("300%") + + assert set_widget_scaling.call_args_list == [call(2.5), call(3.0)] + + +def test_apply_settings_falls_back_to_default_for_invalid_scale(tts_app, monkeypatch): + import gui + set_widget_scaling = MagicMock() + monkeypatch.setattr(gui.ctk, "set_widget_scaling", set_widget_scaling) + tts_app.settings["scaling"] = "invalid" + + tts_app.apply_settings() + + set_widget_scaling.assert_called_once_with(1.0) + + +def test_accessibility_fonts_are_larger_than_control_base_fonts(tts_app): + import gui + + assert gui.ctk.ThemeManager.theme["CTkFont"]["size"] == 18 + assert tts_app.ui_font("Roboto", 14, "bold").cget("size") == 20 + assert gui.ctk.DrawEngine.preferred_drawing_method == "polygon_shapes" From f3845c1c58a5eb85fcd400b87871a8a5fdd6b5b8 Mon Sep 17 00:00:00 2001 From: Ed Chan Date: Sat, 12 Sep 2026 00:44:42 -0400 Subject: [PATCH 2/4] Fix font sizing on Linux --- gui.py | 79 ++++++++++++++++++++++++++++++++++---- tests/test_gui_settings.py | 44 ++++++++++++++++++++- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/gui.py b/gui.py index 88e5274..e2ef192 100644 --- a/gui.py +++ b/gui.py @@ -5,7 +5,7 @@ import queue import playback import customtkinter as ctk -from tkinter import filedialog, messagebox +from tkinter import TclError, filedialog, messagebox import threading from kokoro_engine import KokoroEngine @@ -13,9 +13,13 @@ ctk.set_appearance_mode("Dark") ctk.set_default_color_theme("blue") ctk.DrawEngine.preferred_drawing_method = "polygon_shapes" -ctk.ThemeManager.theme["CTkFont"]["size"] = 18 -FONT_SCALE = 1.4 +BASE_FONT_SIZE = 13 +DEFAULT_FONT_SIZE = 24 +UI_FONT_FAMILY = "Liberation Sans" +MONOSPACE_FONT_FAMILY = "Liberation Mono" +ctk.ThemeManager.theme["CTkFont"]["family"] = UI_FONT_FAMILY +ctk.ThemeManager.theme["CTkFont"]["size"] = DEFAULT_FONT_SIZE CONFIG_FILE = "config.json" PRESETS_DIR = "presets" @@ -39,6 +43,8 @@ def __init__(self): # Load Settings self.settings = self.load_settings() + self.font_size = self._parse_font_size(self.settings["font_size"]) + self._font_size_ratios = {} self.apply_settings() # Initialize Engine @@ -185,6 +191,7 @@ def __init__(self): self.setup_autosave() self.create_widgets() + self._set_rendered_font_size(self.font_size) # Init Pipeline self.status_label.configure(text="Initializing engine...") @@ -302,6 +309,7 @@ def load_settings(self): defaults = { "appearance": "Dark", "scaling": "100%", + "font_size": DEFAULT_FONT_SIZE, "lang_code": "a", "voice": "af_heart", "filename": "output", @@ -368,7 +376,12 @@ def load_settings(self): if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: - return {**defaults, **json.load(f)} + loaded = json.load(f) + if "font_size" not in loaded and "font_scaling" in loaded: + scale = float(str(loaded["font_scaling"]).replace("%", "")) / 100 + loaded["font_size"] = round(BASE_FONT_SIZE * scale / 2) * 2 + loaded.pop("font_scaling", None) + return {**defaults, **loaded} except Exception: pass return defaults @@ -463,6 +476,8 @@ def save_settings(self): def apply_settings(self): ctk.set_appearance_mode(self.settings["appearance"]) + self.font_size = self._parse_font_size(self.settings["font_size"]) + ctk.ThemeManager.theme["CTkFont"]["size"] = self.font_size # Parse scaling scale_str = self.settings["scaling"].replace("%", "") @@ -473,8 +488,42 @@ def apply_settings(self): ctk.set_widget_scaling(1.0) @staticmethod - def ui_font(family, size, weight="normal"): - return ctk.CTkFont(family=family, size=round(size * FONT_SCALE), weight=weight) + def _parse_font_size(value): + try: + size = int(str(value).replace("px", "").strip()) + return size if size > 0 else DEFAULT_FONT_SIZE + except (TypeError, ValueError): + return DEFAULT_FONT_SIZE + + def ui_font(self, family, size, weight="normal"): + if family == "Roboto": + family = UI_FONT_FAMILY + elif family == "Consolas": + family = MONOSPACE_FONT_FAMILY + return ctk.CTkFont(family=family, size=round(size * self.font_size / BASE_FONT_SIZE), weight=weight) + + def _set_rendered_font_size(self, font_size): + seen_fonts = set() + + def scale_fonts(widget): + fonts = [getattr(widget, "_font", None)] + try: + fonts.append(widget.cget("font")) + except (AttributeError, TclError, ValueError): + pass + + for font in fonts: + if isinstance(font, ctk.CTkFont) and id(font) not in seen_fonts: + seen_fonts.add(id(font)) + ratio = self._font_size_ratios.setdefault(id(font), font.cget("size") / self.font_size) + widget_scale = widget._get_widget_scaling() + font.configure(size=round(font_size * ratio / widget_scale)) + + for child in widget.winfo_children(): + scale_fonts(child) + + scale_fonts(self) + self.update_idletasks() # --- Preset Management --- @@ -1420,14 +1469,14 @@ def create_widgets(self): def open_settings(self): toplevel = ctk.CTkToplevel(self) toplevel.title("Settings") - toplevel.geometry("400x380") + toplevel.geometry("400x455") 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}") + toplevel.geometry(f"400x455+{x}+{y}") frame = ctk.CTkFrame(toplevel) frame.pack(fill="both", expand=True, padx=20, pady=20) @@ -1443,6 +1492,12 @@ def open_settings(self): scale_menu = ctk.CTkOptionMenu(frame, values=["80%", "90%", "100%", "110%", "120%", "150%", "200%", "250%", "300%"], command=self.change_scaling) scale_menu.set(self.settings["scaling"]) scale_menu.pack(fill="x", pady=5) + + # CustomTkinter sizes fonts in pixels; this does not alter widget or window dimensions. + ctk.CTkLabel(frame, text="Font Size:", font=self.ui_font("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) + font_menu = ctk.CTkOptionMenu(frame, values=["14 px", "16 px", "18 px", "20 px", "22 px", "24 px", "26 px", "28 px", "32 px", "36 px", "40 px"], command=self.change_font_size) + font_menu.set(f"{self.font_size} px") + font_menu.pack(fill="x", pady=5) # Caching ctk.CTkLabel(frame, text="Generation Cache:", font=self.ui_font("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) @@ -1465,6 +1520,14 @@ def change_scaling(self, new_val): ctk.set_widget_scaling(scale_float) self.save_settings() + def change_font_size(self, new_val): + new_size = self._parse_font_size(new_val) + self.font_size = new_size + self.settings["font_size"] = new_size + ctk.ThemeManager.theme["CTkFont"]["size"] = new_size + self._set_rendered_font_size(new_size) + self.save_settings() + def on_jit_toggle(self): if self.jit_enabled.get(): self.start_btn.configure(text="Start Real-time JIT") diff --git a/tests/test_gui_settings.py b/tests/test_gui_settings.py index 507d132..f72e2fc 100644 --- a/tests/test_gui_settings.py +++ b/tests/test_gui_settings.py @@ -22,6 +22,17 @@ def test_load_settings_merges_existing_config_json(tts_app): assert settings["format"] == "wav" # untouched default still present +def test_load_settings_migrates_legacy_font_scaling(tts_app): + import gui + with open(gui.CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump({"font_scaling": "300%"}, f) + + settings = tts_app.load_settings() + + assert settings["font_size"] == 40 + assert "font_scaling" not in settings + + 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: @@ -61,6 +72,35 @@ def test_change_scaling_uses_requested_widget_scale_without_cap(tts_app, monkeyp assert set_widget_scaling.call_args_list == [call(2.5), call(3.0)] +def test_change_font_size_resizes_text_without_widget_scaling(tts_app, monkeypatch): + import gui + set_widget_scaling = MagicMock() + monkeypatch.setattr(gui.ctk, "set_widget_scaling", set_widget_scaling) + status_font = tts_app.status_label.cget("font") + rendered_widgets = [ + tts_app.status_label._label, + tts_app.preview_btn._text_label, + tts_app.text_entry._textbox, + ] + initial_sizes = [ + abs(int(widget.tk.call("font", "actual", widget.cget("font"), "-size"))) + for widget in rendered_widgets + ] + + tts_app.change_font_size("32 px") + + assert tts_app.settings["font_size"] == 32 + assert status_font.cget("size") == 32 + assert gui.ctk.ThemeManager.theme["CTkFont"]["size"] == 32 + assert tts_app.ui_font("Roboto", 14, "bold").cget("size") == 34 + for widget, initial_size in zip(rendered_widgets, initial_sizes): + rendered_size = widget.tk.call("font", "actual", widget.cget("font"), "-size") + rendered_family = widget.tk.call("font", "actual", widget.cget("font"), "-family") + assert rendered_family.casefold() == "liberation sans" + assert abs(int(rendered_size)) > initial_size + set_widget_scaling.assert_not_called() + + def test_apply_settings_falls_back_to_default_for_invalid_scale(tts_app, monkeypatch): import gui set_widget_scaling = MagicMock() @@ -75,6 +115,6 @@ def test_apply_settings_falls_back_to_default_for_invalid_scale(tts_app, monkeyp def test_accessibility_fonts_are_larger_than_control_base_fonts(tts_app): import gui - assert gui.ctk.ThemeManager.theme["CTkFont"]["size"] == 18 - assert tts_app.ui_font("Roboto", 14, "bold").cget("size") == 20 + assert gui.ctk.ThemeManager.theme["CTkFont"]["size"] == 24 + assert tts_app.ui_font("Roboto", 14, "bold").cget("size") == 26 assert gui.ctk.DrawEngine.preferred_drawing_method == "polygon_shapes" From 923cdf204770f5f03fa4902299d4a2e5d8f4a90b Mon Sep 17 00:00:00 2001 From: Ed Chan Date: Sat, 12 Sep 2026 00:47:05 -0400 Subject: [PATCH 3/4] Keep runtime configuration local --- config.json | 55 +++-------------------------------------------------- 1 file changed, 3 insertions(+), 52 deletions(-) diff --git a/config.json b/config.json index d2f6fc7..d5a2449 100644 --- a/config.json +++ b/config.json @@ -1,10 +1,8 @@ { "appearance": "Dark", "scaling": "100%", - "lang_code": "b", - "voice": "bf_alice", + "voice": "af_heart", "filename": "output", - "format": "mp3", "out_dir": "audio_output", "speed": 1.0, "volume": 1.0, @@ -14,53 +12,6 @@ "separate": false, "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": {} -} \ No newline at end of file + "trim": false +} From aa406fe9b7fa815802b7923d3dee779636e4da54 Mon Sep 17 00:00:00 2001 From: Ed Chan Date: Sat, 12 Sep 2026 00:47:41 -0400 Subject: [PATCH 4/4] Match upstream configuration --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index d5a2449..13a8a80 100644 --- a/config.json +++ b/config.json @@ -14,4 +14,4 @@ "export_subtitles": false, "normalize": false, "trim": false -} +} \ No newline at end of file