From b42b2671412cac3cddedab40a66d38aa46e09530 Mon Sep 17 00:00:00 2001 From: Andrew Ward Date: Fri, 21 Mar 2025 11:09:42 +0000 Subject: [PATCH] update to enable tone of voice playback --- assets/tone_presets.example.json | 23 ++ .../hotkey_manager.cpython-312.pyc | Bin 10047 -> 10778 bytes utils/__pycache__/text_to_mic.cpython-312.pyc | Bin 73127 -> 77774 bytes .../tone_presets_manager.cpython-312.pyc | Bin 0 -> 23399 bytes utils/hotkey_manager.py | 37 +- utils/text_to_mic.py | 117 ++++-- utils/tone_presets_manager.py | 391 ++++++++++++++++++ 7 files changed, 532 insertions(+), 36 deletions(-) create mode 100644 assets/tone_presets.example.json create mode 100644 utils/__pycache__/tone_presets_manager.cpython-312.pyc create mode 100644 utils/tone_presets_manager.py 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 db3080ada3e886cb591ca15a4211dd06a94b0bdf..9af06f5544022d746e423f59536d0ea9fdff2e79 100644 GIT binary patch delta 1461 zcmb7E&u<$=6yCMhKjPYP?M+fAb;%@kk_Le>1$w9u$R!96rHyJAD3z$T-if`=ddHfX zjk}Q}_k;`=1baXMpIHG(!X=guRH+aZe}SQukT?Ku{DZm?myYCb_q~1Z zoA13hp6@SwnIHct5(!DLzFGODh-bbXZ_LF4V0z*AV8tUzUr1bbSHns7&fKa}m&#*q z%s126^61|35s~-!;*p`PD|L_Vn+8+&3)}-PvG(NtbIbb#41q%ojULCA@&pvYLlk`m zpFURiekJLlx&nBl?gI>e9oX{oflNs%kM{+SqQ5TJ{akW?h$hGNNNapg>3=P&ZgxzK zMfGUw=&)c};*;+2qshg(tjFqdIWbDqe{aS2M89G630_iL6T?2qlA|n7Zl-c2QbUHt zIJFDhAQnSwgd&?6RuPp5U&oI7&-`@bOeS;V#Y~3Afb0UD0;IXY#z&QXojg> zH!*s%m&eYALeHns=7;E6l;axZ&*eC&qD#0ys1EcYgiyX@u)#KnlVZ`t4AEf#TBD?b z9AZ<{VA#Y3j&+b)wK>x9hEc%jQ0O#la1J(DH8lsF*BD1xTQ`W{?zwmgqY42xuwsjw z4k}^`Q%z85A`&=WNF)0!%%!LEu)?=06|5T#NPt`h1u&#p4$`1m8~Bk{pvjjo!-HZN zsu8-5iYB?PnUJUzC}FH$3v(F9m1hv%z!s{N0E9@bT1C){ieVWQV-xG?&=q2%f@YzD z&3J03`-|9x+L0`*`KI*HFFl2Ju5Mr5o$8#(xY`10+*hZ6R+rl9Qb%2G z2JgeiXv)a~GR)9uV_`z!CYGw*j!Ug^wy(2U+q9N!6U2X9U7D!ZlIPCK35 zKbLE#^PRcNoy5D%$p3x6)K2F*bNNoCZJW1ugqV9Vqx{`NN=_0@6i{E?|3{~k4 z-vg@LjYShmGyHJg{bb>e{IUD`q8*;~neqd#N9eWhKTk{oBF>@e~nqa70rjM1s#L|y?s z)5`6ix#N*8^a#I)dJqSU)6n6V1Z;i3w3$@5W~$@#rFwnuV!duxB+sTC&A|U8D!k1oIx8>G;j!YanL#sxrYMikl+D|dXj^qJr3=_g&p7SG1!gz zJrcqWMN9%BqKQenW#eGom{q!BY&B!FO_7v2QHcl~J2)X3Oj)EP|hFrsdlfZqsAWNe+<@U$vcE~txibiciYgmSO zQLBhgwR3A4%|`9w%;N8Map02f*qo^`7X3zXWBGRZnM@vmBORQDZ+BnqeklD|Todn? zVfI(bSTLWMN2di^IZz%8XoY5Z&Q}gKpcj9uW%16sPy-(cGM;r9H S_-R!O*Hpz<{($(3VgCTF*wHQk diff --git a/utils/__pycache__/text_to_mic.cpython-312.pyc b/utils/__pycache__/text_to_mic.cpython-312.pyc index a1daddc4f62fa8f56299dbe48728ef6054e25a63..0bf7163433e7529a94f2f1b86a3e3da42cd3f745 100644 GIT binary patch delta 16937 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%0A*d&j(~6gG)imQYw@zoyT83d7=k|Uz8gI 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 N*h~X&MW0mi{{XgMtttQj literal 0 HcmV?d00001 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