import tkinter as tk import platform import os import threading import pyaudio import wave import webbrowser import json import sys import time import requests import pyttsx3 import tempfile from pystray import Icon as icon, MenuItem as item, Menu as menu from PIL import Image, ImageDraw, ImageTk from tkinter import ttk, messagebox, simpledialog, Menu, Frame, Canvas, Scrollbar import customtkinter as ctk from openai import OpenAI from dotenv import load_dotenv from pathlib import Path from pydub import AudioSegment # Import our refactored classes from utils.api_key_manager import APIKeyManager from utils.hotkey_manager import HotkeyManager from utils.resource_utils import ResourceUtils from utils.tone_presets_manager import TonePresetsManager from utils.presets_manager import PresetsManager from utils.ai_editor_manager import AIEditorManager from utils.settings_manager import SettingsManager from utils.app_text import AppText from utils.version_checker import VersionChecker # Modify the load environment variables to load from config/.env def load_env_file(): env_path = Path("config") / ".env" load_dotenv(dotenv_path=env_path) class TextToMic(tk.Tk): def __init__(self): super().__init__() self.version = "1.4.0" self.title(f"Text to Mic by Scorchsoft.com - v{self.version}") # Add these lines to set up the window icon icon_path = self.resource_path("assets/logo-circle-32.png") self.iconphoto(False, tk.PhotoImage(file=icon_path)) # For Windows compatibility, also set the iconbitmap try: self.iconbitmap(self.resource_path("assets/icon.ico")) except: # This might fail on Mac, which is fine as we have iconphoto as backup pass # Fixed window dimensions for all states - DEFINED ONCE as class constants # These are the ONLY values that should be used throughout the application self.BASE_WIDTH = 590 self.BASE_HEIGHT_WITH_BANNER = 890 self.BASE_HEIGHT_NO_BANNER = 730 self.COLLAPSED_HEIGHT_WITH_BANNER = 620 self.COLLAPSED_HEIGHT_NO_BANNER = 512 # Initial window geometry - start with base size self.geometry(f"{self.BASE_WIDTH}x{self.BASE_HEIGHT_WITH_BANNER}") # Center the window immediately before any popups appear self.center_window() # Withdraw window temporarily to prevent flashing before everything is ready self.withdraw() # Initialize system TTS engine self.engine = pyttsx3.init() self.engine.setProperty('rate', 150) self.system_voices = self.engine.getProperty('voices') self.available_models = ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo"] self.default_model = "gpt-4o-mini" # Cache for icons - will store loaded and resized icon images self.icon_cache = {} self.style = ttk.Style(self) if self.tk.call('tk', 'windowingsystem') == 'aqua': self.style.theme_use('aqua') else: # Create a custom theme instead of using 'clam' self.style.theme_use('clam') # Define a modern color scheme with clean light greys bg_color = "#f5f5f7" # Very light grey background accent_color = "#e0e0e4" # Slightly darker grey for accents text_color = "#333333" # Dark grey for text button_bg = "#e8e8ec" # Light grey for buttons # Configure default styles for various widgets self.style.configure('TFrame', background=bg_color) self.style.configure('TLabel', background=bg_color, foreground=text_color) self.style.configure('TButton', background=button_bg, foreground=text_color) self.style.configure('TCheckbutton', background=bg_color, foreground=text_color) self.style.configure('TRadiobutton', background=bg_color, foreground=text_color) self.style.configure('TMenubutton', background=button_bg, foreground=text_color) self.style.configure('TEntry', fieldbackground=bg_color, foreground=text_color) self.style.configure('TCombobox', fieldbackground=bg_color, foreground=text_color) # Override the background color of the main window self.configure(background=bg_color) #Define styles self.style.configure('Recording.TButton', background='red', foreground='white') self.style.configure("Green.TButton", background="green", foreground="white") # Ensure that the config directory exists self.ensure_config_directory() load_env_file() # Get API key using APIKeyManager self.api_key = APIKeyManager.get_api_key(self) self.has_api_key = bool(self.api_key) if self.has_api_key: self.client = OpenAI(api_key=self.api_key) # Initializing device index variables before they are used self.device_index = tk.StringVar(self) self.device_index_2 = tk.StringVar(self) self.available_devices = self.get_audio_devices() # Load audio devices self.available_input_devices = self.get_input_devices() # Load input devices # Load tone presets self.tone_presets = TonePresetsManager.load_tone_presets(self) self.current_tone_name = self.load_current_tone_from_settings() # Initialize settings before creating menu settings = self.load_settings() self.banner_var = tk.BooleanVar() self.banner_var.set(settings.get("hide_banner", False)) # Initialize auto_check_version before creating menu self.auto_check_version = tk.BooleanVar(value=settings.get("auto_check_version", True)) # Create the presets manager before initializing the GUI self.presets_manager = PresetsManager(self) # Create the AI Editor Manager self.ai_editor = AIEditorManager(self) # Store reference to presets state self.presets_collapsed = self.presets_manager.presets_collapsed # Initialize the main frame as a class variable for version notification to work self.main_frame = None # Create menu and initialize GUI after presets manager is created self.create_menu() self.initialize_gui() # Initialize our HotkeyManager self.hotkey_manager = HotkeyManager(self) # Initialize version checker self.version_checker = VersionChecker(self, self.version) # If banner should be hidden based on settings, hide it now if self.banner_var.get(): self.toggle_banner() # Center the window on the screen self.center_window() # Schedule version check after app is fully loaded # Only check automatically if the setting is enabled if self.auto_check_version.get(): # Delay the check to ensure UI is fully loaded self.after(2000, self.version_checker.check_version, False) # At the end of __init__, after all initialization: # Make the window visible again, now properly centered and with all elements loaded self.deiconify() # Set initial window height based on banner and presets state self.update_window_size() def center_window(self): """Center the window on the screen.""" self.update_idletasks() # Update window size info # Get screen width and height screen_width = self.winfo_screenwidth() screen_height = self.winfo_screenheight() # Get window width and height window_width = self.winfo_width() window_height = self.winfo_height() # Calculate position coordinates x = (screen_width - window_width) // 2 y = (screen_height - window_height) // 2 # Set the position self.geometry(f"+{x}+{y}") def ensure_config_directory(self): """Ensure the config directory exists.""" config_dir = Path("config") config_dir.mkdir(parents=True, exist_ok=True) def show_version(self): instruction_window = tk.Toplevel(self) instruction_window.title("App Version") instruction_window.geometry("300x150") # Width x Height instructions = f"""Version {self.version}\n\n App by Scorchsoft.com""" tk.Label(instruction_window, text=instructions, justify=tk.LEFT, wraplength=280).pack(padx=10, pady=10) # Add a button to close the window ttk.Button(instruction_window, text="Close", command=instruction_window.destroy).pack(pady=(10, 0)) def create_menu(self): self.menubar = Menu(self) self.config(menu=self.menubar) # Get current hotkey settings settings = self.load_settings() hotkey_manager = self.hotkey_manager if hasattr(self, 'hotkey_manager') else None # Format hotkeys for display in menus if hotkey_manager: replay_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["play_last_audio"]) record_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["record_start_stop"]) stop_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["stop_recording"]) cancel_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["cancel_operation"]) else: # Default values if hotkey_manager isn't available replay_shortcut = "Ctrl+Shift+8" record_shortcut = "Ctrl+Shift+0" stop_shortcut = "Ctrl+Shift+9" cancel_shortcut = "Ctrl+Shift+1" # File or settings menu settings_menu = Menu(self.menubar, tearoff=0) self.menubar.add_cascade(label="Settings", menu=settings_menu) settings_menu.add_command(label="API Key", command=self.change_api_key) settings_menu.add_command(label="AI Copyediting", command=self.show_ai_editor_settings) settings_menu.add_command(label="Keyboard Shortcuts", command=self.show_hotkey_settings) settings_menu.add_command(label="Manage Tones", command=self.show_tone_presets_manager) settings_menu.add_separator() # Add presets toggle with checkbox self.presets_visible_var = tk.BooleanVar(value=not self.presets_collapsed) settings_menu.add_checkbutton(label="Show Presets", variable=self.presets_visible_var, command=self.toggle_presets_from_menu) settings_menu.add_checkbutton(label="Auto Check for Updates", variable=self.auto_check_version, command=self.toggle_auto_version_check) settings_menu.add_checkbutton(label="Hide Scorchsoft Banner", variable=self.banner_var, command=self.toggle_banner) # Playback menu playback_menu = Menu(self.menubar, tearoff=0) self.menubar.add_cascade(label="Actions", menu=playback_menu) # Add keyboard shortcuts to menu items playback_menu.add_command(label=f"Replay [{replay_shortcut}]", command=self.play_last_audio) playback_menu.add_command(label="Apply AI Copyedit", command=self.apply_ai_to_input) playback_menu.add_separator() playback_menu.add_command(label=f"Start/Stop Recording [{record_shortcut}]", command=self.handle_record_button_click) playback_menu.add_command(label=f"Stop Recording [{stop_shortcut}]", command=lambda: self.stop_recording(auto_play=False)) playback_menu.add_command(label=f"Cancel Operation [{cancel_shortcut}]", command=self.stop_playback) # Help menu help_menu = Menu(self.menubar, tearoff=0) self.menubar.add_cascade(label="Help", menu=help_menu) help_menu.add_command(label="Check Version", command=self.check_version) help_menu.add_command(label="How to Use", command=self.show_instructions) help_menu.add_command(label="Terms of Use and Licence", command=self.show_terms_of_use) def show_hotkey_settings(self): """Show the hotkey settings dialog.""" HotkeyManager.hotkey_settings_dialog(self) def change_api_key(self): """Change the API key using APIKeyManager.""" new_key = APIKeyManager.change_api_key(self) if new_key: self.api_key = new_key self.client = OpenAI(api_key=self.api_key) def get_audio_file_path(self, filename): if platform.system() == 'Darwin': # Check if the OS is macOS mac_path = APIKeyManager.get_app_support_path_mac() return f"{mac_path}/{filename}" else: return Path(filename) # Default to current directory for non-macOS systems def play_sound(self, sound_file): """Play a sound file using ResourceUtils.""" ResourceUtils.play_sound(sound_file) def resource_path(self, relative_path): """Get the resource path using ResourceUtils.""" return ResourceUtils.resource_path(relative_path) def initialize_gui(self): # Get saved settings settings = self.load_settings() self.input_device_index = tk.StringVar(self) self.device_index = tk.StringVar(self) self.device_index_2 = tk.StringVar(self) # Set default values default_input = "Default" default_output = "Select Device" default_secondary = "None" # Get saved device names from settings saved_input_device = settings.get("input_device", default_input) saved_primary_device = settings.get("primary_device", default_output) saved_secondary_device = settings.get("secondary_device", default_secondary) # Validate that saved devices are still available, otherwise use defaults if saved_input_device in self.available_input_devices: self.input_device_index.set(saved_input_device) else: self.input_device_index.set(default_input) if saved_primary_device in self.available_devices: self.device_index.set(saved_primary_device) else: self.device_index.set(default_output) if saved_secondary_device in self.available_devices or saved_secondary_device == "None": self.device_index_2.set(saved_secondary_device) else: self.device_index_2.set(default_secondary) main_frame = ttk.Frame(self, padding="20") main_frame.grid(column=0, row=0, sticky=(tk.W, tk.E, tk.N, tk.S)) self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) # Configure columns in main_frame to expand properly main_frame.columnconfigure(0, weight=1) main_frame.columnconfigure(1, weight=1) # Store reference to main_frame for version notification self.main_frame = main_frame # Use the background color from our style for the text widget bg_color = self.style.lookup('TFrame', 'background') text_color = self.style.lookup('TLabel', 'foreground') # Define custom button styles for pill-shaped buttons self.style.configure('Pill.TButton', font=('Arial', 13, 'bold'), borderwidth=0, relief='flat', padding=(20, 8)) self.style.configure('RecordPill.TButton', font=('Arial', 13, 'bold'), background='#d32f2f', foreground='white', borderwidth=0, relief='flat', padding=(20, 8)) self.style.configure('PlayPill.TButton', font=('Arial', 13, 'bold'), background='#058705', foreground='white', borderwidth=0, relief='flat', padding=(20, 8)) # Create frames for better organization voice_frame = ttk.Frame(main_frame) voice_frame.grid(column=0, row=0, columnspan=2, sticky="ew") voice_frame.columnconfigure(0, weight=1) # Make the first column expandable voice_frame.columnconfigure(1, weight=3) # Make the second column expand more device_frame = ttk.Frame(main_frame) device_frame.grid(column=0, row=1, columnspan=2, sticky="ew", pady=(10, 0)) device_frame.columnconfigure(0, weight=1) # Make the first column expandable device_frame.columnconfigure(1, weight=3) # Make the second column expand more # Set fixed width for dropdown menus dropdown_width = 30 # Create a style for compact dropdowns self.style.configure('Compact.TMenubutton', padding=(5, 2)) # Voice and Tone Settings ttk.Label(voice_frame, text="Voice Settings", font=("Arial", 10, "bold")).grid(column=0, row=0, sticky=tk.W, pady=(0, 10), columnspan=2) # Make sure voice_frame columns expand properly voice_frame.columnconfigure(1, weight=1) # Set fixed width for all labels label_width = 35 # Adjust this value as needed for your UI # Initialize voice selection self.available_voices = self.get_available_voices() # Determine default voice based on whether API key is available default_voice = "fable" if self.has_api_key else self.available_voices[0] if self.available_voices else "[System] Default" self.voice_var = tk.StringVar(value=default_voice) voice_label = ttk.Label(voice_frame, text="Voice:", width=label_width) voice_label.grid(column=0, row=1, sticky=tk.W, pady=(0, 5)) voice_menu = ttk.OptionMenu(voice_frame, self.voice_var, self.voice_var.get(), *self.available_voices, command=self.on_voice_change) voice_menu.grid(column=1, row=1, sticky="ew", pady=(0, 5)) voice_menu.config(width=dropdown_width, style='Compact.TMenubutton') # Tone selection with warning for basic version self.tone_var = tk.StringVar(value=self.current_tone_name) tone_options = ["None"] + list(self.tone_presets.keys()) tone_label = ttk.Label(voice_frame, text="Tone Preset:", width=label_width) tone_label.grid(column=0, row=2, sticky=tk.W, pady=(0, 5)) self.tone_menu = ttk.OptionMenu(voice_frame, self.tone_var, self.tone_var.get(), *tone_options, command=self.on_tone_change) self.tone_menu.grid(column=1, row=2, sticky="ew", pady=(0, 5)) self.tone_menu.config(width=dropdown_width, style='Compact.TMenubutton') # Check if we should disable tone menu based on voice type if self.voice_var.get().startswith("[System]"): self.tone_menu.state(['disabled']) self.tone_var.set("None") # Add warning label for basic version if not self.has_api_key: warning_label = ttk.Label(voice_frame, text="⚠️ Basic Version - Add API Key in Settings for full features", foreground="orange", font=("Arial", 8, "italic")) warning_label.grid(column=0, row=3, columnspan=2, sticky=tk.W, pady=(5, 0)) # Separator between Voice Settings and Device Settings separator = ttk.Separator(main_frame, orient='horizontal') separator.grid(column=0, row=2, columnspan=2, sticky="ew", pady=10) # Device Settings ttk.Label(device_frame, text="Device Settings", font=("Arial", 10, "bold")).grid(column=0, row=0, sticky=tk.W, pady=(0, 10), columnspan=2) input_label = ttk.Label(device_frame, text="Input Device (optional):", width=label_width) input_label.grid(column=0, row=1, sticky=tk.W, pady=(0, 5)) input_device_menu = ttk.OptionMenu(device_frame, self.input_device_index, self.input_device_index.get(), *self.available_input_devices.keys(), command=self.on_input_device_change) input_device_menu.grid(column=1, row=1, sticky="ew", pady=(0, 5)) input_device_menu.config(width=dropdown_width, style='Compact.TMenubutton') primary_label = ttk.Label(device_frame, text="Primary Playback Device:", width=label_width) primary_label.grid(column=0, row=2, sticky=tk.W, pady=(0, 5)) primary_device_menu = ttk.OptionMenu(device_frame, self.device_index, self.device_index.get(), *self.available_devices.keys(), command=self.on_primary_device_change) primary_device_menu.grid(column=1, row=2, sticky="ew", pady=(0, 5)) primary_device_menu.config(width=dropdown_width, style='Compact.TMenubutton') secondary_label = ttk.Label(device_frame, text="Secondary Playback Device (optional):", width=label_width) secondary_label.grid(column=0, row=3, sticky=tk.W, pady=(0, 5)) secondary_device_menu = ttk.OptionMenu(device_frame, self.device_index_2, self.device_index_2.get(), "None", *self.available_devices.keys(), command=self.on_secondary_device_change) secondary_device_menu.grid(column=1, row=3, sticky="ew", pady=(0, 5)) secondary_device_menu.config(width=dropdown_width, style='Compact.TMenubutton') # Make sure device_frame columns expand properly device_frame.columnconfigure(1, weight=1) # Text to Read section with proper layout text_read_frame = ttk.Frame(main_frame) text_read_frame.grid(column=0, row=4, columnspan=2, sticky="ew", pady=(10, 0)) text_read_frame.columnconfigure(0, weight=1) # Left side expands text_read_frame.columnconfigure(1, weight=0) # Right side fixed width # Make sure text_read_frame columns expand properly text_read_frame.columnconfigure(0, weight=1) # Text to Read label - Updated to match other section titles ttk.Label(text_read_frame, text="Text to Read", font=("Arial", 10, "bold")).grid(column=0, row=0, sticky=tk.W, pady=(0, 10)) # Create a frame to contain the dropdown and save button save_frame = ttk.Frame(text_read_frame) save_frame.grid(column=1, row=0, sticky=tk.E) # Create a compact style for the button self.style.configure('Compact.TButton', padding=(2, 1)) save_as_preset_button = ttk.Button(save_frame, text="Save As Preset", width=15, style='Compact.TButton', command=self.show_save_preset_dialog) save_as_preset_button.grid(column=0, row=0, sticky=tk.E) # Text input area with proper spacing self.text_input = tk.Text(main_frame, height=5, width=68) # Use white background for text input instead of the system background color text_color = self.style.lookup('TLabel', 'foreground') self.text_input.configure(bg="white", fg=text_color, insertbackground=text_color, wrap=tk.WORD, font=("Arial", 10)) self.text_input.grid(column=0, row=5, columnspan=2, pady=(0, 20), sticky="nsew") # Proper spacing # Add a status frame at the bottom of the text input with white background status_frame = ttk.Frame(main_frame, style='White.TFrame') status_frame.grid(column=0, row=5, columnspan=2, sticky=(tk.S, tk.E), pady=(0, 25), padx=(0, 5)) # Add right padding to shift the frame inward # Create a custom style for the white frame self.style.configure('White.TFrame', background='white') # Status indicator showing if editing is enabled status_text = "AI Copyediting Disabled" if settings.get("chat_gpt_completion", False): if settings.get("auto_apply_ai_to_recording", False): status_text = f"AI Copyediting Enabled (Auto) - {settings.get('model', self.default_model)}" else: status_text = f"AI Copyediting Enabled (Manual) - {settings.get('model', self.default_model)}" self.editing_status = ttk.Label(status_frame, text=status_text, foreground="#888888", font=("Arial", 8, "italic"), background="white") self.editing_status.pack(side=tk.RIGHT, padx=5) # Create a frame for the buttons to allow for better styling button_frame = ttk.Frame(main_frame) button_frame.grid(column=0, row=6, columnspan=2, sticky="ew", pady=(0, 20)) button_frame.columnconfigure(0, weight=1) button_frame.columnconfigure(1, weight=1) # Get keyboard shortcuts from settings settings = self.load_settings() record_shortcut = "+".join(filter(None, settings["hotkeys"]["record_start_stop"])) play_shortcut = "+".join(filter(None, settings["hotkeys"]["play_last_audio"])) # Button configuration self.recording = False # State to check if currently recording self.is_playing = False # State to check if audio is playing # Create CTk buttons with proper rounded corners button_height = 35 button_width = 250 # Record button with CTkButton self.record_button = ctk.CTkButton( button_frame, text=f"Record Mic ({record_shortcut})", corner_radius=20, height=button_height, width=button_width, fg_color="#058705", font=("Arial", 13, "bold"), command=self.handle_record_button_click ) self.record_button.grid(row=0, column=0, sticky="ew", padx=(0, 10)) # Play button with CTkButton self.submit_button = ctk.CTkButton( button_frame, text=f"Play Audio ({play_shortcut})", corner_radius=20, height=button_height, width=button_width, fg_color="#058705", font=("Arial", 13, "bold"), command=self.handle_submit_button_click ) self.submit_button.grid(row=0, column=1, sticky="ew", padx=(10, 0)) #Credits # Banner image that links to Scorchsoft self.banner_frame = ttk.Frame(main_frame) self.banner_frame.grid(column=0, row=7, columnspan=2, pady=(10, 0)) banner_path = self.resource_path("assets/ss-banner-550.png") try: banner_img = tk.PhotoImage(file=banner_path) banner_label = tk.Label(self.banner_frame, image=banner_img, cursor="hand2") banner_label.image = banner_img # Keep a reference to prevent garbage collection banner_label.pack() banner_label.bind("", lambda e: self.open_scorchsoft()) except Exception as e: print(f"Error loading banner image: {e}") # Fallback to text if image fails to load info_label = tk.Label(self.banner_frame, text="Visit Scorchsoft.com for custom app development", fg="blue", cursor="hand2") info_label.pack() info_label.bind("", lambda e: self.open_scorchsoft()) # If the banner should be hidden based on settings, hide it now if self.banner_var.get(): self.toggle_banner() def open_scorchsoft(self, event=None): webbrowser.open('https://www.scorchsoft.com') def save_current_text_as_preset(self): """Forward the save request to the presets manager.""" self.show_save_preset_dialog() def show_instructions(self): instruction_window = tk.Toplevel(self) instruction_window.title("How to Use") instruction_window.geometry("600x720") # Width x Height instructions = AppText.INSTRUCTIONS tk.Label(instruction_window, text=instructions, justify=tk.LEFT, wraplength=580).pack(padx=10, pady=10) # Add a button to close the window ttk.Button(instruction_window, text="Close", command=instruction_window.destroy).pack(pady=(10, 0)) def show_terms_of_use(self): # Try multiple approaches to find the LICENSE.md file license_content = None possible_paths = [ "LICENSE.md", # Direct path when running as script self.resource_path("LICENSE.md"), # Using resource_path helper self.resource_path("assets/LICENSE.md"), # Check in assets folder Path("assets") / "LICENSE.md" # Alternative assets path ] # Try each path until we find the file for path in possible_paths: try: with open(path, "r", encoding="utf-8") as file: license_content = file.read() break except (FileNotFoundError, PermissionError, UnicodeDecodeError): continue # If we couldn't find the file in the filesystem, provide a fallback if license_content is None: license_content = AppText.DEFAULT_LICENSE # Create a new window to display the terms of use instruction_window = tk.Toplevel(self) instruction_window.title("Terms of Use") instruction_window.geometry("800x700") # Width x Height # Create a frame to contain the text widget and scrollbar frame = ttk.Frame(instruction_window) frame.pack(fill=tk.BOTH, expand=True) # Add a scrolling text widget to display the license content text_widget = tk.Text(frame, wrap=tk.WORD) text_widget.insert(tk.END, license_content) text_widget.config(state=tk.DISABLED) # Make the text read-only text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Add a vertical scrollbar scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=text_widget.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # Configure the scrollbar to work with the text widget text_widget.config(yscrollcommand=scrollbar.set) # Add a button to close the window ttk.Button(instruction_window, text="Close", command=instruction_window.destroy).pack(pady=(10, 0)) def get_app_support_path_mac(self): home = Path.home() app_support_path = home / 'Library' / 'Application Support' / 'scorchsoft-text-to-mic' app_support_path.mkdir(parents=True, exist_ok=True) # Ensure directory exists return app_support_path def save_api_key_mac(self, api_key): env_path = self.get_app_support_path_mac() / 'config' / '.env' with open(env_path, 'w') as f: f.write(f"OPENAI_API_KEY={api_key}\n") # Consider manually loading this .env file into your environment as needed def save_api_key(self, api_key): """Save the API key to the config/.env file.""" try: config_dir = Path("config") config_dir.mkdir(parents=True, exist_ok=True) # Ensure directory exists env_path = config_dir / ".env" with open(env_path, 'w') as f: f.write(f"OPENAI_API_KEY={api_key}\n") load_dotenv(dotenv_path=env_path) # Reload environment to include the new API key except Exception as e: messagebox.showerror("Error", f"Failed to save API key: {str(e)}") def load_api_key_mac(self): env_path = self.get_app_support_path_mac() / '.env' if env_path.exists(): with open(env_path, 'r') as f: for line in f: if line.startswith('OPENAI_API_KEY'): return line.strip().split('=')[1] return None def get_api_key(self): # First, try to load the API key from environment variables or local file api_key = os.getenv("OPENAI_API_KEY") if not api_key: # Check for macOS and use the macOS-specific method if platform.system() == 'Darwin': # Darwin is the system name for macOS api_key = self.load_api_key_mac() # If no API key is found, prompt the user if not api_key: # Check if this is a first-time run by checking for settings file settings_file = Path(SettingsManager.get_settings_file_path()) first_time_run = not settings_file.exists() # No longer show instructions automatically # if first_time_run: # self.show_instructions() # Show the "How to Use" modal for first-time users response = messagebox.askyesno( "API Key Required", "An OpenAI API Key is required for full functionality, such as speech to text and OpenAI voices.\n\n" "Without an API key, you can still use basic system voices with text to speech.\n\n" "Would you like to enter an API key now?", parent=self ) if response: # Show instructions only when user wants to add an API key if first_time_run: self.show_instructions() api_key = simpledialog.askstring("API Key", "Enter your OpenAI API Key:", parent=self) if api_key: try: if platform.system() == 'Darwin': self.save_api_key_mac(api_key) else: self.save_api_key(api_key) messagebox.showinfo("API Key Set", "The OpenAI API Key has been updated successfully.") except Exception as e: messagebox.showerror("Error", f"Failed to save API key: {str(e)}") else: messagebox.showinfo( "Limited Functionality", "You are using the basic version with system voices only.\n\n" "To access OpenAI voices and other features, you can add an API key later in Settings." ) return api_key def get_audio_devices(self): p = pyaudio.PyAudio() devices = {} for i in range(p.get_device_count()): info = p.get_device_info_by_index(i) if info['maxOutputChannels'] > 0: # Filter for output-capable devices devices[info['name']] = i p.terminate() return devices def get_input_devices(self): p = pyaudio.PyAudio() devices = {} for i in range(p.get_device_count()): info = p.get_device_info_by_index(i) if info['maxInputChannels'] > 0: # Filter for input-capable devices devices[info['name']] = i p.terminate() return devices def get_audio_file_path(self, filename): if platform.system() == 'Darwin': # Check if the OS is macOS mac_path = APIKeyManager.get_app_support_path_mac() return f"{mac_path}/{filename}" else: return Path(filename) # Default to current directory for non-macOS systems def submit_text(self, play_text = None): print(f"submit text self recording: {self.recording}") if self.recording: print("Stopping recording") self.stop_recording(auto_play = True) else: print("Submitting text") self.submit_text_helper(play_text = play_text) def submit_text_helper(self, play_text = None): if play_text is None: #Load from GUI if play text not set text = self.text_input.get("1.0", tk.END).strip() else: text = play_text if not text: messagebox.showinfo("Error", "Please enter some text to synthesize.") return selected_voice = self.voice_var.get() is_system_voice = selected_voice.startswith("[System]") if is_system_voice: # Use system TTS system_voice_name = selected_voice.replace("[System] ", "") for voice in self.system_voices: if voice.name == system_voice_name: self.engine.setProperty('voice', voice.id) break # Convert device names to indices primary_index = self.available_devices.get(self.device_index.get(), None) secondary_index = self.available_devices.get(self.device_index_2.get(), None) if self.device_index_2.get() != "None" else None if primary_index is None: messagebox.showerror("Error", "Primary device not selected or unavailable.") return try: # Create a proper temporary file with a simple name in current directory temp_filename = "temp_speech_output.wav" # Generate audio using system TTS self.engine.save_to_file(text, temp_filename) self.engine.runAndWait() # Store as last audio file for replay self.last_audio_file = temp_filename # Play the generated audio if primary_index and secondary_index != "None" and secondary_index is not None: self.play_audio_multiplexed([temp_filename, temp_filename], [primary_index, secondary_index]) else: self.play_audio_multiplexed([temp_filename], [primary_index]) # We'll leave the file for potential replay rather than deleting it immediately except Exception as e: messagebox.showerror("TTS Error", f"Failed to generate or play system voice: {str(e)}") else: # Use OpenAI TTS if not self.has_api_key: messagebox.showerror("API Key Required", "An OpenAI API Key is required for speech to text or to use OpenAI voices.\n\n" "Please add your API key in Settings.\n\n" "Note: You can still use text to speech with the system voices only.") return # Check if a tone preset is selected and add it to the text selected_tone_name = self.tone_var.get() # Get the actual tone instructions from the tone_presets dictionary tone_instructions = None if selected_tone_name != "None" and selected_tone_name in self.tone_presets: tone_instructions = self.tone_presets[selected_tone_name] else: tone_instructions = "" # Empty string if "None" or not found # Convert device names to indices primary_index = self.available_devices.get(self.device_index.get(), None) secondary_index = self.available_devices.get(self.device_index_2.get(), None) if self.device_index_2.get() != "None" else None if primary_index is None: messagebox.showerror("Error", "Primary device not selected or unavailable.") return try: response = self.client.audio.speech.create( model="gpt-4o-mini-tts", voice=selected_voice, input=text, instructions=tone_instructions, response_format='wav' ) self.last_audio_file = self.get_audio_file_path("last_output.wav") response.stream_to_file(str(self.last_audio_file)) #Play to either two or a single stream if primary_index and secondary_index != "None" and secondary_index is not None: self.play_audio_multiplexed([self.last_audio_file, self.last_audio_file], [primary_index, secondary_index]) else: self.play_audio_multiplexed([self.last_audio_file], [primary_index]) except Exception as e: messagebox.showerror("API Error", f"Failed to generate audio: {str(e)}") def resample_audio(self, file_path, target_sample_rate): sound = AudioSegment.from_file(file_path) resampled_sound = sound.set_frame_rate(target_sample_rate) resampled_file_path = "resampled_" + file_path resampled_sound.export(resampled_file_path, format="wav") return resampled_file_path def play_audio_multiplexed(self, file_paths, device_indices): """Play audio files to multiple devices with better cancellation handling.""" # Stop any existing playback first if hasattr(self, 'is_playing') and self.is_playing: self.stop_playback() # Add a small delay to ensure previous resources are cleaned up time.sleep(0.2) # Make p and streams accessible for stop_playback try: self.current_playback_p = pyaudio.PyAudio() self.current_playback_streams = [] self.is_playing = True # Update button text to show Stop with cancel operation shortcut self.update_buttons_for_playback(True) except Exception as e: print(f"Error initializing PyAudio: {e}") messagebox.showerror("Audio Error", f"Failed to initialize audio system: {e}") return try: # Open all files and start all streams for file_path, device_index in zip(file_paths, device_indices): if not self.is_playing: print("Playback canceled during initialization") break try: # Ensure the file_path is a string when opening the file file_path_str = str(file_path) print(f"Opening audio file: {file_path_str}") # Make sure file exists if not os.path.exists(file_path_str): messagebox.showerror("File Not Found", f"Could not find audio file: {file_path_str}") continue wf = wave.open(file_path_str, 'rb') except FileNotFoundError: messagebox.showerror("File Not Found", f"Could not find audio file: {file_path_str}") continue # Skip this iteration and proceed with other files if any except wave.Error as e: messagebox.showerror("Wave Error", f"Error reading audio file: {file_path_str}. Error: {str(e)}") continue except Exception as e: messagebox.showerror("File Error", f"Unexpected error with audio file: {str(e)}") continue try: # Get device info including default sample rate device_info = self.get_device_info(device_index) sample_rate = int(device_info['defaultSampleRate']) if device_info else 44100 wf_frame_rate = wf.getframerate() print(f"Device Sample Rate: {sample_rate}") print(f"Audio Sample Rate: {wf_frame_rate}") # Create a stream from our file with current frame rate (we'll handle resampling for mismatch later) stream = self.current_playback_p.open( format=self.current_playback_p.get_format_from_width(wf.getsampwidth()), channels=wf.getnchannels(), rate=wf_frame_rate, # Use audio file's rate for now output=True, output_device_index=int(device_index) ) except Exception as e: print(f"Stream creation error: {e}") messagebox.showerror("Stream Creation Error", f"Failed to create audio stream for device index {device_index}: {str(e)}") wf.close() continue self.current_playback_streams.append((stream, wf)) # Play interleaved using a more robust approach self._play_audio_streams() except Exception as e: print(f"Playback setup error: {e}") messagebox.showerror("Playback Error", f"Error setting up playback: {e}") self.stop_playback() def _play_audio_streams(self): """Handle the actual audio playback in chunks, with better error handling.""" if not hasattr(self, 'current_playback_streams') or not self.current_playback_streams: print("No streams to play") self.stop_playback() return try: finished_streams = [] # Process one chunk from each stream for stream, wf in self.current_playback_streams: if not self.is_playing: print("Playback canceled during streaming") break try: data = wf.readframes(1024) if data: stream.write(data) else: # Mark this stream as finished finished_streams.append((stream, wf)) except Exception as e: print(f"Error during stream playback: {e}") # Add to finished streams if there's an error finished_streams.append((stream, wf)) # Remove finished streams for stream_pair in finished_streams: try: stream, wf = stream_pair stream.stop_stream() stream.close() wf.close() self.current_playback_streams.remove(stream_pair) except Exception as e: print(f"Error cleaning up finished stream: {e}") # If we still have streams and playback is active, schedule the next chunk if self.current_playback_streams and self.is_playing: self.after(1, self._play_audio_streams) # Schedule next chunk processing else: # All done or canceled, clean up self.stop_playback() except Exception as e: print(f"Error in _play_audio_streams: {e}") self.stop_playback() def stop_playback(self): """Stop any active audio playback.""" print("Attempting to stop playback") # Set flag first to exit any playback loops self.is_playing = False # Revert buttons to normal state self.update_buttons_for_playback(False) try: # Close any active streams if hasattr(self, 'current_playback_streams'): # Make a copy of the list to safely iterate while potentially modifying streams_to_close = list(self.current_playback_streams) for stream, wf in streams_to_close: try: # Check if stream exists and is active before attempting to stop it if stream and stream.is_active(): stream.stop_stream() if stream: stream.close() if wf: wf.close() except Exception as e: print(f"Error closing stream: {e}") # Clear the list after processing all streams self.current_playback_streams = [] # Terminate PyAudio instance - do this last and carefully if hasattr(self, 'current_playback_p') and self.current_playback_p: try: # Add a small delay to ensure streams are properly closed before terminating self.after(100, self._complete_playback_termination) except Exception as e: print(f"Error scheduling PyAudio termination: {e}") except Exception as e: print(f"Error in stop_playback: {e}") # Don't reraise - we want to prevent crashes def _complete_playback_termination(self): """Complete the termination of PyAudio in a separate step to avoid crashes.""" try: if hasattr(self, 'current_playback_p') and self.current_playback_p: self.current_playback_p.terminate() self.current_playback_p = None print("PyAudio terminated successfully") except Exception as e: print(f"Error terminating PyAudio: {e}") # Still clear the reference even if termination fails self.current_playback_p = None def play_last_audio(self): if hasattr(self, 'last_audio_file'): primary_index = self.available_devices.get(self.device_index.get(), None) secondary_index = self.available_devices.get(self.device_index_2.get(), None) if self.device_index_2.get() != "None" else None # Check if a secondary device is selected if primary_index and secondary_index != "None" and secondary_index is not None: self.play_audio_multiplexed([self.last_audio_file, self.last_audio_file], [primary_index, secondary_index]) else: self.play_audio_multiplexed([self.last_audio_file], [primary_index]) else: messagebox.showinfo("No Audio", "No audio has been generated yet.") def play_saved_audio(self, file_path, device_name): device_index = self.available_devices.get(device_name, None) if device_index is None: messagebox.showerror("Error", "Selected audio device is not available.") return wf = wave.open(file_path, 'rb') p = pyaudio.PyAudio() try: stream = p.open(format=p.get_format_from_width(wf.getsampwidth()), channels=wf.getnchannels(), rate=wf.getframerate(), output=True, output_device_index=device_index) data = wf.readframes(1024) while data: stream.write(data) data = wf.readframes(1024) finally: stream.stop_stream() stream.close() wf.close() p.terminate() def show_ai_editor_settings(self): """Show the AI copy editing settings dialog""" self.ai_editor.show_settings() def apply_ai_to_input(self): """Apply AI to the current input text""" self.ai_editor.apply_ai() def chat_gpt_settings(self): """Delegate to AIEditorManager""" self.show_ai_editor_settings() def save_chat_gpt_settings(self, settings): """Delegate to AIEditorManager""" self.ai_editor.save_settings(settings) def apply_ai(self, input_text=None): """Delegate to AIEditorManager""" return self.ai_editor.apply_ai(input_text) def get_device_info(self, device_index): p = pyaudio.PyAudio() try: device_info = p.get_device_info_by_index(device_index) return device_info finally: p.terminate() def toggle_recording(self, auto_play=False): if not self.recording: self.start_recording() else: self.stop_recording(auto_play) def stop_recording_btn_change(self, btn_text): self.record_button.config(text=btn_text) def start_recording(self, play_confirm_sound=False): if not self.has_api_key: messagebox.showerror("API Key Required", "An OpenAI API Key is required for speech to text or to use OpenAI voices.\n\n" "Please add your API key in Settings.\n\n" "Note: You can still use text to speech with the system voices only.") return input_device_index = self.input_device_index.get() input_device_id = self.available_input_devices.get(input_device_index) if input_device_id is None: if play_confirm_sound: self.play_sound('assets/please-select-input.wav') else: messagebox.showerror("Error", "Selected audio device is not available.") return device_info = self.get_device_info(input_device_id) sample_rate = int(device_info['defaultSampleRate']) print(f"Device info: {device_info}") if sample_rate is None: sample_rate = 44100 if input_device_id is None: messagebox.showerror("Error", "Selected audio device is not available.") return try: self.recording = True # Get keyboard shortcuts from settings settings = self.load_settings() record_shortcut = "+".join(filter(None, settings["hotkeys"]["record_start_stop"])) play_shortcut = "+".join(filter(None, settings["hotkeys"]["play_last_audio"])) stop_shortcut = "+".join(filter(None, settings["hotkeys"]["stop_recording"])) cancel_shortcut = "+".join(filter(None, settings["hotkeys"]["cancel_operation"])) # Update CTkButton for recording state, keeping shortcuts visible self.record_button.configure(text=f"Stop and Insert", fg_color="#d32f2f") self.submit_button.configure(text=f"Stop and Play ({record_shortcut})", fg_color="#d32f2f") self.frames = [] self.p = pyaudio.PyAudio() self.stream = self.p.open(format=pyaudio.paInt16, channels=1, rate=sample_rate, input=True, frames_per_buffer=1024, input_device_index=input_device_id) if play_confirm_sound: self.play_sound('assets/pop.wav') def record(): while self.recording: data = self.stream.read(1024, exception_on_overflow=False) self.frames.append(data) self.record_thread = threading.Thread(target=record) self.record_thread.start() except Exception as e: messagebox.showerror("Recording Error", f"Failed to record audio: {str(e)}") self.stop_recording(True) def stop_recording(self, cancel_save=False, auto_play=False): self.recording = False if hasattr(self, 'record_thread') and self.record_thread: self.record_thread.join() if hasattr(self, 'stream') and self.stream: self.stream.stop_stream() self.stream.close() if hasattr(self, 'p') and self.p: self.p.terminate() if cancel_save==False: self.save_recording(auto_play=auto_play) # Get keyboard shortcuts from settings settings = self.load_settings() record_shortcut = "+".join(filter(None, settings["hotkeys"]["record_start_stop"])) play_shortcut = "+".join(filter(None, settings["hotkeys"]["play_last_audio"])) # Reset button appearance self.record_button.configure(text=f"Record Mic ({record_shortcut})", fg_color="#058705") self.submit_button.configure(text=f"Play Audio ({play_shortcut})", fg_color="#058705") def save_recording(self, auto_play = False): file_path = "output.wav" wf = wave.open(file_path, 'wb') wf.setnchannels(1) wf.setsampwidth(self.p.get_sample_size(pyaudio.paInt16)) wf.setframerate(44100) wf.writeframes(b''.join(self.frames)) wf.close() print("Recording saved.") # If auto_play is requested, we'll handle it through the transcribe_audio callback # This ensures proper button state updates regardless of how playback is triggered self.after(0, self.transcribe_audio, file_path, auto_play) def transcribe_audio(self, file_path, auto_play = False): try: with open(str(file_path), "rb") as audio_file: transcription = self.client.audio.transcriptions.create( file=audio_file, model="gpt-4o-transcribe", response_format="json" ) settings = self.load_settings() # Always update the text with the raw transcription first self.text_input.delete("1.0", tk.END) self.text_input.insert("1.0", transcription.text) if settings["chat_gpt_completion"] and settings["auto_apply_ai_to_recording"]: auto_apply_ai = settings["auto_apply_ai_to_recording"] else: auto_apply_ai = False print(f"auto_apply_ai: {auto_apply_ai}") # If AI processing is enabled, apply it and update the text input again if auto_apply_ai: print("applying ai") # Set the update_ui parameter to True to ensure the text gets updated play_text = self.ai_editor.apply_ai(transcription.text, update_ui=True) else: print("outputting without ai") play_text = transcription.text if auto_play: print(f"Triggering auto play with: {play_text} ") # Use a slight delay to allow UI to update before playback starts self.after(100, lambda: self.submit_text_helper(play_text = play_text)) print("Transcription Complete: The audio has been transcribed and the text has been placed in the input area.") except Exception as e: print(f"Transcription error: An error occurred during transcription: {str(e)}") def load_settings(self): """Load settings using the SettingsManager.""" return SettingsManager.load_settings() def save_settings_to_JSON(self, settings): """Save complete settings using the SettingsManager.""" SettingsManager.save_settings(settings) def update_settings(self, partial_settings): """Update specific settings without overwriting others.""" return SettingsManager.update_settings(partial_settings) def get_settings_file_path(self, filename): """Get the settings file path using SettingsManager.""" return SettingsManager.get_settings_file_path(filename) # Methods for tone preset management def show_tone_presets_manager(self): """Show the tone presets manager dialog.""" TonePresetsManager(self) def load_current_tone_from_settings(self): """Load the current tone preset from settings.""" settings = self.load_settings() return settings.get("current_tone", "None") def save_current_tone_to_settings(self): """Save the current tone preset to settings.""" settings = self.load_settings() settings["current_tone"] = self.current_tone_name self.save_settings_to_JSON(settings) def on_tone_change(self, event): """Handle tone selection change in the dropdown.""" self.current_tone_name = self.tone_var.get() self.save_current_tone_to_settings() def update_tone_selection(self): """Update the tone selection dropdown with current presets.""" # Update the variable self.tone_var.set(self.current_tone_name) # Rebuild the dropdown menu menu = self.tone_menu["menu"] menu.delete(0, "end") tone_options = ["None"] + list(self.tone_presets.keys()) for tone in tone_options: menu.add_command(label=tone, command=lambda value=tone: self.tone_var.set(value)) def save_tone_presets(self, tone_presets): """Save tone presets using the TonePresetsManager.""" return TonePresetsManager.save_tone_presets(self, tone_presets) # Add a new method to toggle banner visibility def toggle_banner(self): """Toggle the visibility of the banner image.""" # Store notification state before changes had_notification = False if hasattr(self, 'version_checker') and self.version_checker.notification_visible: had_notification = True # Toggle banner state settings = self.load_settings() hide_banner = self.banner_var.get() if hide_banner: # Hide the banner self.banner_frame.grid_remove() else: # Show the banner self.banner_frame.grid() # Update the settings settings["hide_banner"] = hide_banner self.save_settings_to_JSON(settings) # Update window size based on new banner state self.update_window_size() # Ensure input elements maintain consistent width self._maintain_consistent_width() # Make sure presets are laid out correctly if visible if not self.presets_collapsed and hasattr(self, 'presets_manager'): self.presets_manager.refresh_presets_display() # Ensure the presets button is correctly positioned using grid if hasattr(self, 'presets_manager') and hasattr(self.presets_manager, 'presets_button'): if self.presets_manager.presets_button.winfo_exists(): # Use grid (not pack) to ensure proper positioning self.presets_manager.presets_button.grid_configure(column=0, row=0, sticky=tk.W, padx=0, pady=2) # If we had a notification, make sure it's correctly positioned after layout changes if had_notification: # Need to schedule this after all geometry changes are complete self.after(100, self._reposition_version_notification) def toggle_presets(self): """Toggle the visibility of the presets panel.""" # Store notification state before changes had_notification = False if hasattr(self, 'version_checker') and self.version_checker.notification_visible: had_notification = True if hasattr(self, 'presets_manager'): # Toggle presets via presets manager self.presets_manager.toggle_presets() # Update our local tracking of presets state self.presets_collapsed = self.presets_manager.presets_collapsed # Update the menu checkbox state to match if hasattr(self, 'presets_visible_var'): self.presets_visible_var.set(not self.presets_collapsed) # Update window size based on new presets state self.update_window_size() # Refresh presets display if they're visible if not self.presets_collapsed: self.presets_manager.refresh_presets_display() # If we had a notification, make sure it's correctly positioned after layout changes if had_notification: # Need to schedule this after all geometry changes are complete self.after(100, self._reposition_version_notification) # Ensure input elements maintain consistent width self._maintain_consistent_width() def update_buttons_for_playback(self, is_playing): """Update button text based on playback state.""" try: # Get keyboard shortcuts from settings settings = self.load_settings() record_shortcut = "+".join(filter(None, settings["hotkeys"]["record_start_stop"])) play_shortcut = "+".join(filter(None, settings["hotkeys"]["play_last_audio"])) cancel_shortcut = "+".join(filter(None, settings["hotkeys"]["cancel_operation"])) if is_playing: # Set both buttons to show stop with cancel shortcut self.record_button.configure(text=f"Stop Audio ({cancel_shortcut})", fg_color="#d32f2f") self.submit_button.configure(text=f"Stop Audio ({cancel_shortcut})", fg_color="#d32f2f") else: # Reset buttons to normal state self.record_button.configure(text=f"Record Mic ({record_shortcut})", fg_color="#058705") self.submit_button.configure(text=f"Play Audio ({play_shortcut})", fg_color="#058705") except Exception as e: print(f"Error updating buttons: {e}") # Don't raise the exception further to prevent crashes def handle_record_button_click(self): """Handle clicks on the record button based on current state.""" if self.is_playing: # If audio is playing, stop it self.stop_playback() else: # Otherwise, toggle recording as before self.toggle_recording() def handle_submit_button_click(self, via_hotkey=False): """Handle clicks on the submit/play button based on current state.""" if self.is_playing: # If audio is playing, stop it self.stop_playback() elif self.recording and via_hotkey: # If recording and triggered via hotkey, stop recording and play self.recording = False self.stop_recording(auto_play=True) else: # Otherwise, submit text as before self.submit_text() def on_input_device_change(self, device_name): """Save the selected input device in settings.""" settings = self.load_settings() settings["input_device"] = device_name self.save_settings_to_JSON(settings) def on_primary_device_change(self, device_name): """Save the selected primary output device in settings.""" settings = self.load_settings() settings["primary_device"] = device_name self.save_settings_to_JSON(settings) def on_secondary_device_change(self, device_name): """Save the selected secondary output device in settings.""" settings = self.load_settings() settings["secondary_device"] = device_name self.save_settings_to_JSON(settings) def show_save_preset_dialog(self): """Show the save preset dialog.""" self.presets_manager.show_save_preset_dialog() def check_version(self): """Run the version checker and show the result""" self.version_checker.check_version(True) # True means show result even if no update available def toggle_auto_version_check(self): """Toggle automatic version checking and save the setting""" settings = self.load_settings() settings["auto_check_version"] = self.auto_check_version.get() self.save_settings_to_JSON(settings) def _reposition_version_notification(self): """Helper method to reposition version notification after layout changes""" if hasattr(self, 'version_checker') and self.version_checker.notification_visible: if hasattr(self.version_checker, 'notification_window') and self.version_checker.notification_window: # First ensure the notification window is visible self.version_checker.notification_window.deiconify() # Then reposition it self.version_checker._reposition_notification() def _maintain_consistent_width(self): """Ensure all input elements maintain consistent width after banner toggle.""" # Get the main frame width (accounting for padding) main_frame_width = self.winfo_width() - 40 # subtract padding (20px on each side) # Force update to ensure we have current dimensions self.update_idletasks() # Configure column weights for main components to ensure they expand properly for frame in [self.main_frame]: if hasattr(frame, 'columnconfigure'): # Make all columns in the frame expandable for i in range(frame.grid_size()[0]): # Get number of columns frame.columnconfigure(i, weight=1) # Ensure text input maintains its width if hasattr(self, 'text_input'): self.text_input.config(width=0) # Let it be sized by the grid # Update and refresh all frames to apply the new layout self.update_idletasks() def get_available_voices(self): """Get list of available voices, including system voices if no API key.""" voices = [] if self.has_api_key: # Add OpenAI voices voices.extend(['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']) # Add system voices with [System] prefix try: if hasattr(self, 'system_voices') and self.system_voices: for voice in self.system_voices: voices.append(f"[System] {voice.name}") # If no system voices were found, add a default system voice if not voices: voices.append("[System] Default") except Exception as e: print(f"Error loading system voices: {e}") # Ensure we have at least one voice option if not voices: voices.append("[System] Default") return voices def on_voice_change(self, *args): """Handle voice selection change.""" selected_voice = self.voice_var.get() is_system_voice = selected_voice.startswith("[System]") # Update tone menu state based on voice type if is_system_voice: self.tone_menu.state(['disabled']) self.tone_var.set("None") else: self.tone_menu.state(['!disabled']) def update_window_size(self): """Update window size based on current banner and presets state.""" # Calculate a width that preserves the current width if it's larger than default current_width = self.winfo_width() width_to_use = max(current_width, self.BASE_WIDTH) # Determine appropriate height based on current states banner_hidden = self.banner_var.get() if self.presets_collapsed: # Presets collapsed if banner_hidden: # Banner hidden, presets collapsed height = self.COLLAPSED_HEIGHT_NO_BANNER else: # Banner visible, presets collapsed height = self.COLLAPSED_HEIGHT_WITH_BANNER else: # Presets expanded if banner_hidden: # Banner hidden, presets expanded height = self.BASE_HEIGHT_NO_BANNER else: # Banner visible, presets expanded height = self.BASE_HEIGHT_WITH_BANNER # Update geometry and re-center self.geometry(f"{width_to_use}x{height}") self.center_window() def toggle_presets_from_menu(self): """Toggle presets visibility from menu, ensuring button state is updated.""" # Toggle presets self.toggle_presets() # Make sure the checkbox state matches the actual state # (in case toggling failed for some reason) self.presets_visible_var.set(not self.presets_collapsed)