diff --git a/assets/tone_presets.example.json b/assets/tone_presets.example.json new file mode 100644 index 0000000..1ceb9c9 --- /dev/null +++ b/assets/tone_presets.example.json @@ -0,0 +1,23 @@ +{ + "tone_presets": { + "Cheerful": "Voice Affect: Bright, uplifted, and energetic; project warmth and positivity in every word.\n\nTone: Light-hearted, friendly, and optimistic—maintain an upbeat quality that feels genuine and engaging.\n\nPacing: Moderately quick and buoyant; keep a lively rhythm that conveys enthusiasm without rushing.\n\nEmotion: Authentic happiness and joy; express delight through your voice, especially when emphasizing positive aspects.\n\nPronunciation: Clear with slightly emphasized inflections on positive words ('wonderful', 'great', 'exciting') to enhance the cheerful mood.\n\nPauses: Short and bouncy pauses that maintain energy while allowing the cheerful tone to resonate.", + + "Angry": "Voice Affect: Tense, sharp, and forceful; project intensity and frustration clearly.\n\nTone: Stern, irritated, and confrontational—allow controlled aggression to color your words.\n\nPacing: Somewhat rapid with emphasis on certain words; use quick bursts followed by deliberate slowdowns for effect.\n\nEmotion: Genuine frustration and indignation; channel controlled anger especially when emphasizing points of contention.\n\nPronunciation: Hard consonants and emphasized words, particularly on words expressing dissatisfaction ('unacceptable', 'wrong', 'failure').\n\nPauses: Abrupt, tension-filled pauses that heighten the sense of frustration and allow for dramatic emphasis.", + + "Bedtime Story": "Voice Affect: Soft, gentle, and nurturing; project a calming and protective presence.\n\nTone: Warm, soothing, and melodic—maintain a comforting quality that induces relaxation and sleepiness.\n\nPacing: Slow and rhythmic; allow each word to gently flow into the next like a lullaby.\n\nEmotion: Tender affection and tranquility; express gentle care through your voice, especially during descriptive passages.\n\nPronunciation: Soft consonants and elongated vowels, creating a melodic flow that's easy to listen to with slightly lowered volume.\n\nPauses: Long, peaceful pauses between phrases and sentences, allowing the listener to drift toward sleep.", + + "News Anchor": "Voice Affect: Authoritative, composed, and professional; project credibility and competence.\n\nTone: Clear, formal, and objective—maintain an impartial quality while delivering information with confidence.\n\nPacing: Measured and deliberate; maintain consistent timing with appropriate emphasis on key information.\n\nEmotion: Controlled neutrality with appropriate gravity; express seriousness for important matters while maintaining professional distance.\n\nPronunciation: Crisp and precise articulation of each word, especially names, locations, and technical terms, with perfect diction.\n\nPauses: Strategic pauses between topics and key points to allow information to register while maintaining audience attention.", + + "Excited": "Voice Affect: Highly energetic, animated, and dynamic; project unbridled enthusiasm and passion.\n\nTone: Exuberant, thrilled, and intensely positive—allow genuine excitement to amplify your vocal delivery.\n\nPacing: Quick and varied; use rapid delivery with dramatic variations in speed to convey heightened emotions.\n\nEmotion: Intense enthusiasm and amazement; express wonder and delight through vocal projection and emphasis.\n\nPronunciation: Emphatic with exaggerated inflection on exciting words ('amazing', 'incredible', 'unbelievable') with volume peaks on key points.\n\nPauses: Quick, anticipatory pauses before revealing exciting information, creating tension and release patterns.", + + "Whisper": "Voice Affect: Hushed, intimate, and secretive; project closeness and confidentiality.\n\nTone: Quiet, breathy, and slightly tense—maintain an air of secrecy and importance in each word.\n\nPacing: Slow and deliberate; give each word weight while maintaining the hushed quality throughout.\n\nEmotion: Conspiratorial intimacy and subtle urgency; express the importance of discretion through vocal restraint.\n\nPronunciation: Soft consonants with minimal mouth movement, creating the authentic sensation of whispering with slightly obscured clarity.\n\nPauses: Frequent, tension-filled pauses that enhance the secretive nature and build anticipation.", + + "Robotic": "Voice Affect: Mechanical, processed, and artificial; project a complete absence of human emotion.\n\nTone: Flat, monotonous, and computerized—maintain consistent pitch and eliminate natural vocal variations.\n\nPacing: Perfectly metered and rhythmic; deliver each syllable with machine-like precision and evenness.\n\nEmotion: Complete emotional neutrality; avoid any trace of human feeling or expression in your delivery.\n\nPronunciation: Precise and clipped articulation with equal stress on all syllables, avoiding contractions and using formal language patterns.\n\nPauses: Programmatic, equally-timed pauses between sentences and phrases that ignore natural speech patterns.", + + "ASMR": "Voice Affect: Extremely soft, gentle, and sensory-focused; project an intimate, calming presence.\n\nTone: Whispered, soothing, and hypnotic—maintain a delicate quality that triggers relaxation responses.\n\nPacing: Very slow and deliberate; allow ample time between words to emphasize mouth sounds and breathing.\n\nEmotion: Nurturing tranquility and attention to detail; express care through subtle vocal textures and breathing patterns.\n\nPronunciation: Crisp but gentle articulation that highlights mouth sounds, with attention to the auditory texture of consonants like 'k', 's', and 't'.\n\nPauses: Long, intentional pauses filled with gentle breathing sounds that create a rhythmic, relaxing pattern.", + + "Urgent": "Voice Affect: Alert, insistent, and high-energy; project immediacy and critical importance.\n\nTone: Pressing, serious, and attention-demanding—convey that immediate action is required.\n\nPacing: Rapid and emphatic; deliver information quickly while emphasizing critical points with intensity.\n\nEmotion: Controlled anxiety and heightened alertness; express the gravity of the situation through vocal tension.\n\nPronunciation: Sharp and distinct articulation with emphasis on action words ('now', 'immediately', 'crucial') and slightly raised volume.\n\nPauses: Minimal, tense pauses used only for dramatic emphasis on the most critical information.", + + "Storyteller": "Voice Affect: Engaging, dramatic, and captivating; project a sense of wonder and narrative mastery.\n\nTone: Dynamic, expressive, and varied—shift between mysterious, exciting, tender, or tense as the narrative requires.\n\nPacing: Varied and intentional; slow down for suspense or emotional moments, quicken for action or excitement.\n\nEmotion: Rich emotional range; express a full spectrum of feelings through vocal coloring appropriate to each part of the story.\n\nPronunciation: Vivid and theatrical articulation with character voices and sound effects where appropriate, using volume variation for emphasis.\n\nPauses: Strategic, dramatic pauses at key plot points, revelations, or transitions that build suspense and maintain audience engagement." + } +} \ No newline at end of file diff --git a/utils/__pycache__/hotkey_manager.cpython-312.pyc b/utils/__pycache__/hotkey_manager.cpython-312.pyc index db3080a..9af06f5 100644 Binary files a/utils/__pycache__/hotkey_manager.cpython-312.pyc and b/utils/__pycache__/hotkey_manager.cpython-312.pyc differ diff --git a/utils/__pycache__/text_to_mic.cpython-312.pyc b/utils/__pycache__/text_to_mic.cpython-312.pyc index a1daddc..0bf7163 100644 Binary files a/utils/__pycache__/text_to_mic.cpython-312.pyc and b/utils/__pycache__/text_to_mic.cpython-312.pyc differ diff --git a/utils/__pycache__/tone_presets_manager.cpython-312.pyc b/utils/__pycache__/tone_presets_manager.cpython-312.pyc new file mode 100644 index 0000000..8749146 Binary files /dev/null and b/utils/__pycache__/tone_presets_manager.cpython-312.pyc differ diff --git a/utils/hotkey_manager.py b/utils/hotkey_manager.py index aad20be..23abbd3 100644 --- a/utils/hotkey_manager.py +++ b/utils/hotkey_manager.py @@ -110,24 +110,35 @@ class HotkeyManager: messagebox.showinfo("Settings Updated", "Your hotkey settings have been saved successfully.") @staticmethod - def show_hotkey_instructions(app): - """Show hotkey instructions.""" - instruction_window = tk.Toplevel(app) + def show_hotkey_instructions(parent): + """Show instructions for using hotkeys.""" + instruction_window = tk.Toplevel(parent) instruction_window.title("Hotkey Instructions") - instruction_window.geometry("400x300") # Width x Height + instruction_window.geometry("600x400") # Width x Height - instructions = """How to use Hotkeys -ctrl+shift+0 -This starts a recording, then converts to text and plays when you press this hotkey again. + settings = parent.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"])) -ctrl+shift+9 -If you are recording, you can press this hotkey to stop recording without playing + instructions = f"""Available Hotkeys: -ctrl+shift+8 -This replays the last audio clip played +1. {record_shortcut} - Start/Stop Recording + This hotkey toggles recording from your selected input device. - """ - tk.Label(instruction_window, text=instructions, justify=tk.LEFT, wraplength=380).pack(padx=10, pady=10) +2. {play_shortcut} - Play Last Audio + Play the most recently generated audio. + +3. {stop_shortcut} - Stop Recording + Immediately stops any active recording. + +These hotkeys work globally across your system, even when the app is minimized. +You can customize these hotkeys in Settings → Hotkey Settings. + +You can also access tone presets from Settings → Manage Tone Presets to modify how your text is spoken. +""" + + 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)) \ No newline at end of file diff --git a/utils/text_to_mic.py b/utils/text_to_mic.py index 07584ea..41475f7 100644 --- a/utils/text_to_mic.py +++ b/utils/text_to_mic.py @@ -21,6 +21,7 @@ from pydub import AudioSegment 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 # Modify the load environment variables to load from config/.env def load_env_file(): @@ -33,7 +34,8 @@ class TextToMic(tk.Tk): super().__init__() self.title("Scorchsoft Text to Mic") - self.default_geometry = "590x750" + self.default_geometry = "590x770" + self.untoggled_geometry ="590x490" self.geometry(self.default_geometry) self.available_models = ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo"] @@ -95,6 +97,10 @@ class TextToMic(tk.Tk): 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() + self.create_menu() self.initialize_gui() @@ -128,7 +134,7 @@ class TextToMic(tk.Tk): settings_menu.add_command(label="Change API Key", command=self.change_api_key) settings_menu.add_command(label="ChatGPT Manipulation", command=self.chat_gpt_settings) settings_menu.add_command(label="Hotkey Settings", command=self.show_hotkey_settings) - + settings_menu.add_command(label="Manage Tone Presets", command=self.show_tone_presets_manager) # Playback menu playback_menu = Menu(self.menubar, tearoff=0) @@ -234,32 +240,40 @@ class TextToMic(tk.Tk): voice_menu = ttk.OptionMenu(main_frame, self.voice_var,'fable', *voices) voice_menu.grid(column=1, row=0, sticky=tk.W) + # Tone Preset Selection + self.tone_var = tk.StringVar(value=self.current_tone_name) + ttk.Label(main_frame, text="Tone Preset").grid(column=0, row=1, sticky=tk.W, pady=(5, 10)) + tone_options = ["None"] + list(self.tone_presets.keys()) + self.tone_menu = ttk.OptionMenu(main_frame, self.tone_var, self.tone_var.get(), *tone_options, + command=self.on_tone_change) + self.tone_menu.grid(column=1, row=1, sticky=tk.W) + # Microphone Selection Setup - ttk.Label(main_frame, text="Input Device (optional):").grid(column=0, row=1, sticky=tk.W, pady=(5, 10)) # Padding added + ttk.Label(main_frame, text="Input Device (optional):").grid(column=0, row=2, sticky=tk.W, pady=(5, 10)) # Padding added input_device_menu = ttk.OptionMenu(main_frame, self.input_device_index, "None", *self.available_input_devices.keys()) - input_device_menu.grid(column=1, row=1, sticky=tk.W) + input_device_menu.grid(column=1, row=2, sticky=tk.W) # Select Primary audio device - ttk.Label(main_frame, text="Primary Playback Device:").grid(column=0, row=2, sticky=tk.W, pady=(10, 10)) # Padding added + ttk.Label(main_frame, text="Primary Playback Device:").grid(column=0, row=3, sticky=tk.W, pady=(10, 10)) # Padding added primary_device_menu = ttk.OptionMenu(main_frame, self.device_index, *self.available_devices.keys()) - primary_device_menu.grid(column=1, row=2, sticky=tk.W) + primary_device_menu.grid(column=1, row=3, sticky=tk.W) # Select Secondary audio device - ttk.Label(main_frame, text="Secondary Playback Device (optional):").grid(column=0, row=3, sticky=tk.W, pady=(5, 10)) # Padding added + ttk.Label(main_frame, text="Secondary Playback Device (optional):").grid(column=0, row=4, sticky=tk.W, pady=(5, 10)) # Padding added secondary_device_menu = ttk.OptionMenu(main_frame, self.device_index_2, "None", *self.available_devices.keys()) - secondary_device_menu.grid(column=1, row=3, sticky=tk.W) + secondary_device_menu.grid(column=1, row=4, sticky=tk.W) spacer = ttk.Frame(main_frame, height=20) # Adjust height as needed - spacer.grid(column=0, row=4, columnspan=2) # Place spacer in the grid + spacer.grid(column=0, row=5, columnspan=2) # Place spacer in the grid # Text to Read Label - ttk.Label(main_frame, text="Text to Read:").grid(column=0, row=5, sticky=tk.W, pady=(10, 0)) + ttk.Label(main_frame, text="Text to Read:").grid(column=0, row=6, sticky=tk.W, pady=(10, 0)) # Create a frame to contain the dropdown and save button save_frame = ttk.Frame(main_frame) - save_frame.grid(column=1, row=5, sticky=tk.E, padx=(5, 0), pady=(5, 0)) # Align to the top right + save_frame.grid(column=1, row=6, sticky=tk.E, padx=(5, 0), pady=(5, 0)) # Align to the top right # Preset Category dropdown self.category_var = tk.StringVar(value="Select Category") @@ -278,11 +292,11 @@ class TextToMic(tk.Tk): bg_color = self.style.lookup('TFrame', 'background') text_color = self.style.lookup('TLabel', 'foreground') self.text_input.configure(bg=bg_color, fg=text_color, insertbackground=text_color) - self.text_input.grid(column=0, row=6, columnspan=2, pady=(0, 20), sticky="nsew") # Fill available width + self.text_input.grid(column=0, row=7, columnspan=2, pady=(0, 20), sticky="nsew") # Fill available width # Create a frame for the buttons to allow for better styling button_frame = ttk.Frame(main_frame) - button_frame.grid(column=0, row=7, columnspan=2, sticky="ew", pady=(0, 20)) + button_frame.grid(column=0, row=8, columnspan=2, sticky="ew", pady=(0, 20)) button_frame.columnconfigure(0, weight=1) button_frame.columnconfigure(1, weight=1) @@ -328,8 +342,11 @@ class TextToMic(tk.Tk): #Credits info_label = tk.Label(main_frame, text="Enjoying this free tool? Thank us by referring a business friend to Scorchsoft.com", fg="blue", cursor="hand2") - info_label.grid(column=0, row=10, columnspan=2, pady=(0, 0)) + info_label.grid(column=0, row=9, columnspan=2, pady=(0, 0)) info_label.bind("", lambda e: self.open_scorchsoft()) + + desc_label = tk.Label(main_frame, text="We build Apps, AI agents, voice/speech apps, and autonomous tools that work with or without human interaction", wraplength=500) + desc_label.grid(column=0, row=10, columnspan=2, pady=(5, 0)) @@ -441,7 +458,7 @@ class TextToMic(tk.Tk): def show_instructions(self): instruction_window = tk.Toplevel(self) instruction_window.title("How to Use") - instruction_window.geometry("600x680") # Width x Height + instruction_window.geometry("600x720") # Width x Height instructions = """How to Use Scorchsoft Text to Mic: @@ -459,15 +476,17 @@ OpenAI pricing: openai.com/pricing 3. Choose a voice that you prefer for the speech synthesis. -4. Select a playback device. I recommend you select one device to be your headphones, and the other the virtuall microphone installed above (Which is usually labelled "Cable Input (VB-Audio))" +4. (Optional) Select a Tone Preset to modify how the text is spoken. You can use the built-in presets or create your own under 'Settings > Manage Tone Presets'. Tone presets add special instructions to make the voice sound cheerful, angry, like a bedtime story, etc. -3. Enter the text in the provided text area that you want to convert to speech. +5. Select a playback device. I recommend you select one device to be your headphones, and the other the virtuall microphone installed above (Which is usually labelled "Cable Input (VB-Audio))" -4. Click 'Submit' to hear the spoken version of your text. +6. Enter the text in the provided text area that you want to convert to speech. -5. The 'Play Last Audio' button can be used to replay the last generated speech output. +7. Click 'Play Audio' to hear the spoken version of your text. -6. You can change the API key at any time under the 'Settings' menu. +8. The 'Record Mic' button can be used to record from your microphone and transcribe it to text, which can then be played back. + +9. You can change the API key at any time under the 'Settings' menu. This tool was brought to you by Scorchsoft - We build custom apps to your requirements. Please contact us if you have a requirement for a custom app project. @@ -649,6 +668,16 @@ Please also make sure you read the Terms of use and licence statement before usi selected_voice = self.voice_var.get() + # 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 @@ -658,13 +687,15 @@ Please also make sure you read the Terms of use and licence statement before usi return print(f"Primary Index: {primary_index}, Secondary Index: {secondary_index}") - + print(f"Selected Tone: {selected_tone_name}") + print(f"Tone Instructions: {tone_instructions}") try: response = self.client.audio.speech.create( - model="tts-1", + model="gpt-4o-mini-tts", voice=selected_voice, input=text, + instructions=tone_instructions, response_format='wav' ) @@ -1086,7 +1117,7 @@ Please also make sure you read the Terms of use and licence statement before usi else: self.presets_frame.grid_remove() self.presets_button.config(text="▶ Presets") - self.geometry("590x460") + self.geometry(self.untoggled_geometry) self.presets_collapsed = not self.presets_collapsed @@ -1316,6 +1347,7 @@ Please also make sure you read the Terms of use and licence statement before usi "model": self.default_model, "prompt": "", "auto_apply_ai_to_recording": False, + "current_tone": "None", "hotkeys": { "record_start_stop": ["ctrl", "shift", "0"], "stop_recording": ["ctrl", "shift", "9"], @@ -1338,4 +1370,43 @@ Please also make sure you read the Terms of use and licence statement before usi else: return filename # Default to current directory for non-macOS systems + # 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) + diff --git a/utils/tone_presets_manager.py b/utils/tone_presets_manager.py new file mode 100644 index 0000000..64f5717 --- /dev/null +++ b/utils/tone_presets_manager.py @@ -0,0 +1,391 @@ +import tkinter as tk +from tkinter import ttk, messagebox +import json +from pathlib import Path +import customtkinter as ctk +import os + +class TonePresetsManager: + def __init__(self, parent): + self.parent = parent + self.dialog = tk.Toplevel(parent) + self.dialog.title("Spoken Tone Presets") + self.dialog.geometry("800x650") + self.dialog.resizable(False, False) + self.dialog.transient(parent) + self.dialog.grab_set() + + # Center the dialog on the screen + self.center_dialog() + + # Add a variable to track the currently selected tone + self.current_selected_tone = None + + self.create_dialog() + self.parent.after(100, self.update_content) + + def center_dialog(self): + # Get the parent window position and dimensions + parent_x = self.parent.winfo_x() + parent_y = self.parent.winfo_y() + parent_width = self.parent.winfo_width() + parent_height = self.parent.winfo_height() + + # Calculate position for the dialog + dialog_width = 800 # Width from geometry + dialog_height = 650 # Height from geometry + position_x = parent_x + (parent_width - dialog_width) // 2 + position_y = parent_y + (parent_height - dialog_height) // 2 + + # Set the position + self.dialog.geometry(f"{dialog_width}x{dialog_height}+{position_x}+{position_y}") + + def create_dialog(self): + # Main container frame with padding + main_frame = ttk.Frame(self.dialog, padding="10") + main_frame.pack(fill=tk.BOTH, expand=True) + + # Create left panel (tone selection) + self.create_left_panel(main_frame) + + # Create right panel (tone content) + self.create_right_panel(main_frame) + + # Create bottom frame for main action button + self.create_bottom_frame() + + def create_left_panel(self, main_frame): + left_panel = ttk.Frame(main_frame, width=200) + left_panel.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) + left_panel.pack_propagate(False) + + select_frame = ttk.LabelFrame(left_panel, text="Tone Selection", padding="5") + select_frame.pack(fill=tk.BOTH, expand=True) + + # Listbox for tones with scrollbar + self.tone_list = tk.Listbox(select_frame, height=8, selectmode=tk.BROWSE) + tone_scrollbar = ttk.Scrollbar(select_frame, orient=tk.VERTICAL, command=self.tone_list.yview) + self.tone_list.config(yscrollcommand=tone_scrollbar.set) + + self.tone_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, pady=5) + tone_scrollbar.pack(side=tk.RIGHT, fill=tk.Y, pady=5) + + # Populate listbox with "None" option plus tone presets + all_tones = ["None"] + list(self.parent.tone_presets.keys()) + for tone in all_tones: + self.tone_list.insert(tk.END, tone) + if tone == self.parent.current_tone_name: + self.tone_list.selection_set(all_tones.index(tone)) + + # Button frame + button_frame = ttk.Frame(left_panel) + button_frame.pack(fill=tk.X, pady=5) + + # Action buttons + self.new_button = ttk.Button(button_frame, text="New Tone", command=self.create_new_tone) + self.delete_button = ttk.Button(button_frame, text="Delete", command=self.delete_current_tone) + + self.new_button.pack(side=tk.LEFT, padx=2) + self.delete_button.pack(side=tk.LEFT, padx=2) + + # Bind selection event + self.tone_list.bind('<>', self.on_tone_select) + + def on_tone_select(self, event): + """Handle tone selection and update the current selection.""" + selected_indices = self.tone_list.curselection() + if selected_indices: + self.current_selected_tone = self.tone_list.get(selected_indices[0]) + self.update_content() + + def create_right_panel(self, main_frame): + self.right_panel = ttk.Frame(main_frame) + self.right_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + content_frame = ttk.LabelFrame(self.right_panel, text="Tone Description", padding="5") + content_frame.pack(fill=tk.BOTH, expand=True) + + self.edit_status_label = ttk.Label(content_frame, foreground="blue") + self.edit_status_label.pack(anchor=tk.W, padx=5, pady=(5,0)) + + # Create a frame to contain the text widget and scrollbar + text_frame = ttk.Frame(content_frame) + text_frame.pack(fill=tk.BOTH, expand=True, pady=5) + + # Create vertical scrollbar only + v_scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL) + v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Create the text widget with word wrap and vertical scrollbar + self.content_text = tk.Text(text_frame, wrap=tk.WORD, + yscrollcommand=v_scrollbar.set) + self.content_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Configure the scrollbar + v_scrollbar.config(command=self.content_text.yview) + + self.save_changes_button = ttk.Button(self.right_panel, text="Save Changes", + command=self.save_content_changes, state='disabled') + self.save_changes_button.pack(pady=(5, 0), anchor=tk.E) + + def create_bottom_frame(self): + bottom_frame = ttk.Frame(self.dialog) + bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=10, padx=10) + + save_button = ctk.CTkButton( + bottom_frame, + text="Save Selection and Exit", + corner_radius=20, + height=35, + fg_color="#058705", + hover_color="#046a38", + font=("Arial", 13, "bold"), + command=self.save_and_exit + ) + save_button.pack(side=tk.BOTTOM, fill=tk.X) + + def update_content(self): + """Update the content area with the selected tone.""" + if not self.current_selected_tone: + selected_indices = self.tone_list.curselection() + if selected_indices: + self.current_selected_tone = self.tone_list.get(selected_indices[0]) + else: + return + + # Enable content_text before any operations + self.content_text.config(state='normal') + self.content_text.delete('1.0', tk.END) + + if self.current_selected_tone == "None": + self.content_text.insert('1.0', "No tone modifier will be applied to the speech.") + self.content_text.config(state='disabled') + self.save_changes_button.config(state='disabled') + self.delete_button.config(state='disabled') + self.edit_status_label.config( + text="(No tone modifier)", + foreground="gray" + ) + else: + self.content_text.insert('1.0', self.parent.tone_presets[self.current_selected_tone]) + self.content_text.config(state='normal') + self.save_changes_button.config(state='normal') + self.delete_button.config(state='normal') + self.edit_status_label.config( + text=f"{self.current_selected_tone}", + foreground="blue" + ) + + def save_content_changes(self): + """Save changes to the current tone content.""" + if not self.current_selected_tone or self.current_selected_tone == "None": + return + + new_content = self.content_text.get("1.0", tk.END).strip() + if not new_content: + messagebox.showerror("Error", "Tone description cannot be empty") + return + + self.parent.tone_presets[self.current_selected_tone] = new_content + self.parent.save_tone_presets(self.parent.tone_presets) + messagebox.showinfo("Success", "Tone changes saved successfully") + + def create_new_tone(self): + """Open dialog for creating a new tone preset.""" + tone_dialog = tk.Toplevel(self.dialog) + tone_dialog.title("Create New Tone Preset") + tone_dialog.geometry("600x400") + tone_dialog.transient(self.dialog) + tone_dialog.grab_set() + + # Center the new tone dialog + dialog_width = 600 + dialog_height = 400 + position_x = self.dialog.winfo_x() + (self.dialog.winfo_width() - dialog_width) // 2 + position_y = self.dialog.winfo_y() + (self.dialog.winfo_height() - dialog_height) // 2 + tone_dialog.geometry(f"{dialog_width}x{dialog_height}+{position_x}+{position_y}") + + # Name entry + name_frame = ttk.Frame(tone_dialog, padding="10") + name_frame.pack(fill=tk.X) + ttk.Label(name_frame, text="Tone Name:").pack(side=tk.LEFT) + name_entry = ttk.Entry(name_frame) + name_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 0)) + + # Tone description + desc_frame = ttk.LabelFrame(tone_dialog, text="Tone Description", padding="10") + desc_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + + # Create a frame for the text widget and scrollbar + text_frame = ttk.Frame(desc_frame) + text_frame.pack(fill=tk.BOTH, expand=True) + + # Create vertical scrollbar only + v_scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL) + v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Create the text widget with word wrap and vertical scrollbar + new_tone_text = tk.Text(text_frame, wrap=tk.WORD, height=15, + yscrollcommand=v_scrollbar.set) + new_tone_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Configure the scrollbar + v_scrollbar.config(command=new_tone_text.yview) + + def save_new_tone(): + new_name = name_entry.get().strip() + if not new_name: + messagebox.showerror("Error", "Please enter a tone name") + return + if len(new_name) > 25: + messagebox.showerror("Error", "Tone name must be 25 characters or less") + return + + new_content = new_tone_text.get("1.0", tk.END).strip() + if not new_content: + messagebox.showerror("Error", "Please enter a tone description") + return + + # Save tone + self.parent.tone_presets[new_name] = new_content + self.parent.save_tone_presets(self.parent.tone_presets) + + # Update listbox and select the new tone + self.tone_list.delete(0, tk.END) + all_tones = ["None"] + list(self.parent.tone_presets.keys()) + for t in all_tones: + self.tone_list.insert(tk.END, t) + + # Find and select the new tone + new_tone_index = all_tones.index(new_name) + self.tone_list.selection_clear(0, tk.END) + self.tone_list.selection_set(new_tone_index) + self.tone_list.see(new_tone_index) + + # Set the current selected tone and update content + self.current_selected_tone = new_name + self.update_content() + + tone_dialog.destroy() + + # Buttons + button_frame = ttk.Frame(tone_dialog) + button_frame.pack(fill=tk.X, pady=10, padx=10) + ttk.Button(button_frame, text="Save", + command=save_new_tone).pack(side=tk.RIGHT, padx=5) + ttk.Button(button_frame, text="Cancel", + command=tone_dialog.destroy).pack(side=tk.RIGHT) + + def delete_current_tone(self): + """Delete the currently selected tone.""" + selected_indices = self.tone_list.curselection() + if not selected_indices: + return + + tone_name = self.tone_list.get(selected_indices[0]) + if tone_name == "None": + return + + if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete '{tone_name}'?"): + if tone_name == self.parent.current_tone_name: + self.parent.current_tone_name = "None" + self.parent.save_current_tone_to_settings() + self.parent.update_tone_selection() + + del self.parent.tone_presets[tone_name] + self.parent.save_tone_presets(self.parent.tone_presets) + + # Update listbox + self.tone_list.delete(0, tk.END) + for t in ["None"] + list(self.parent.tone_presets.keys()): + self.tone_list.insert(tk.END, t) + + # Set current_selected_tone to "None" before selecting it in the listbox + self.current_selected_tone = "None" + self.tone_list.selection_set(0) + self.update_content() + + def save_and_exit(self): + """Save the selected tone and close the dialog.""" + if not self.current_selected_tone: + messagebox.showwarning("No Selection", "Please select a tone before saving") + return + + tone_name = self.current_selected_tone + + # First save any content changes if it's not "None" + if tone_name != "None": + new_content = self.content_text.get("1.0", tk.END).strip() + if new_content: + self.parent.tone_presets[tone_name] = new_content + self.parent.save_tone_presets(self.parent.tone_presets) + + # Then save the selection + self.parent.current_tone_name = tone_name + self.parent.save_current_tone_to_settings() + self.parent.update_tone_selection() + self.dialog.destroy() + messagebox.showinfo("Success", f"Now using tone: {tone_name}") + + @staticmethod + def load_tone_presets(app_instance): + """Load tone presets from JSON file, creating from template if needed.""" + # Create config directory if it doesn't exist + config_dir = Path("config") + config_dir.mkdir(parents=True, exist_ok=True) + + tone_presets_file = config_dir / "tone_presets.json" + example_path = app_instance.resource_path("assets/tone_presets.example.json") + + # If tone presets file doesn't exist, copy from example + if not tone_presets_file.exists(): + try: + # Check if example file exists + if not os.path.exists(example_path): + # Create default tone presets + default_presets = { + "Cheerful": "Speak in a cheerful, upbeat tone with enthusiasm.", + "Angry": "Sound like you're angry and frustrated.", + "Bedtime Story": "Say it like you're reading a bedtime story to a child, soft and soothing.", + "News Anchor": "Speak in a professional news anchor tone, clear and formal.", + "Excited": "Sound extremely excited and enthusiastic." + } + + # Create and save default presets + with open(tone_presets_file, "w", encoding="utf-8") as f: + json.dump({"tone_presets": default_presets}, f, indent=2) + else: + # Copy example presets to config directory + with open(example_path, "r", encoding="utf-8") as example_file: + with open(tone_presets_file, "w", encoding="utf-8") as config_file: + config_file.write(example_file.read()) + + except Exception as e: + print(f"Failed to create tone presets file: {e}") + return {} + + # Load presets from file + try: + with open(tone_presets_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("tone_presets", {}) + except Exception as e: + print(f"Error loading tone presets: {e}") + return {} + + @staticmethod + def save_tone_presets(app_instance, tone_presets): + """Save tone presets to JSON file.""" + config_dir = Path("config") + config_dir.mkdir(parents=True, exist_ok=True) + + tone_presets_file = config_dir / "tone_presets.json" + data = {"tone_presets": tone_presets} + + try: + with open(tone_presets_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + return True + except Exception as e: + print(f"Error saving tone presets: {e}") + return False \ No newline at end of file