update to enable tone of voice playback

This commit is contained in:
Andrew Ward
2025-03-21 11:09:42 +00:00
parent c353e79b6a
commit b42b267141
7 changed files with 532 additions and 36 deletions

View File

@@ -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."
}
}

Binary file not shown.

View File

@@ -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))

View File

@@ -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("<Button-1>", 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)

View File

@@ -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('<<ListboxSelect>>', 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