From 4a2d72517f292a21c6fea099cd8cbdf7bbcd8859 Mon Sep 17 00:00:00 2001 From: Spencer Grimes Date: Sat, 31 Jan 2026 15:06:45 -0600 Subject: [PATCH] feat: add /voice preview command - Added 8 random preview sample lines for voice testing - New /voice preview command to hear voices before selecting - Previews play in queue like regular messages (no queue jumping) - Preview does NOT change user's active voice preference - Updated queue system to support voice override for previews - Added documentation for new command in README --- README.md | 1 + bot.py | 117 +++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d701c25..60270e7 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ A Discord bot that reads messages aloud using [Pocket TTS](https://github.com/ky - `/voice set ` - Change your personal TTS voice - `/voice current` - Shows your current voice - `/voice refresh` - Re-scan for new voice files (no restart needed) + - `/voice preview ` - Preview a voice before selecting it ### Test Mode diff --git a/bot.py b/bot.py index 62b07ac..47331e1 100644 --- a/bot.py +++ b/bot.py @@ -1,5 +1,6 @@ __version__ = "1.1.0" +import random import sys import os @@ -30,6 +31,18 @@ from voice_manager import VoiceManager # Inactivity timeout in seconds (10 minutes) INACTIVITY_TIMEOUT = 10 * 60 +# Sample lines for voice preview +PREVIEW_LINES = [ + "Hello! This is how I sound. Choose me as your voice with /voice set.", + "Testing, one, two, three! Can you hear me clearly?", + "Here's a preview of my voice. Pretty cool, right?", + "Greetings! I am ready to speak for you.", + "Voice check! This is what I sound like.", + "Audio test complete. This voice is ready to go!", + "Sample message incoming. How do I sound to you?", + "Preview mode activated. Testing speech synthesis.", +] + class TTSBot(commands.Bot): """Discord bot that reads messages aloud using Pocket TTS.""" @@ -41,7 +54,7 @@ class TTSBot(commands.Bot): super().__init__(command_prefix="!", intents=intents) self.voice_manager = VoiceManager(Config.VOICES_DIR, Config.DEFAULT_VOICE) - self.message_queue: asyncio.Queue[tuple[discord.Message, str]] = asyncio.Queue() + self.message_queue: asyncio.Queue[tuple[discord.Message, str] | tuple[discord.Message, str, str]] = asyncio.Queue() self.last_activity: float = 0.0 self._setup_slash_commands() @@ -59,6 +72,7 @@ class TTSBot(commands.Bot): app_commands.Choice(name="set", value="set"), app_commands.Choice(name="current", value="current"), app_commands.Choice(name="refresh", value="refresh"), + app_commands.Choice(name="preview", value="preview"), ]) async def voice_command( interaction: discord.Interaction, @@ -73,6 +87,8 @@ class TTSBot(commands.Bot): await self._handle_voice_current(interaction) elif action.value == "refresh": await self._handle_voice_refresh(interaction) + elif action.value == "preview": + await self._handle_voice_preview(interaction, voice_name) @voice_command.autocomplete("voice_name") async def voice_name_autocomplete( @@ -206,6 +222,66 @@ class TTSBot(commands.Bot): ephemeral=True ) + async def _handle_voice_preview(self, interaction: discord.Interaction, voice_name: str | None) -> None: + """Handle /voice preview command.""" + if not voice_name: + await interaction.response.send_message( + "❌ Please provide a voice name. Use `/voice list` to see available voices.", + ephemeral=True + ) + return + + # Check if user is in a voice channel + if interaction.user.voice is None: + await interaction.response.send_message( + "❌ You need to be in a voice channel to hear a preview!", + ephemeral=True + ) + return + + voice_name = voice_name.lower() + + # Validate voice exists + if not self.voice_manager.is_voice_available(voice_name): + voices = self.voice_manager.get_available_voices() + await interaction.response.send_message( + f"❌ Voice `{voice_name}` not found.\n" + f"Available voices: {', '.join(f'`{v}`' for v in voices)}", + ephemeral=True + ) + return + + # Select a random preview line + preview_text = random.choice(PREVIEW_LINES) + + # Create a preview message object with all necessary attributes + class PreviewMessage: + def __init__(self, user, channel, voice_channel): + self.author = user + self.channel = channel + self._voice_channel = voice_channel + + @property + def voice(self): + class VoiceState: + def __init__(self, channel): + self.channel = channel + return VoiceState(self._voice_channel) + + preview_message = PreviewMessage( + interaction.user, + interaction.channel, + interaction.user.voice.channel + ) + + # Queue the preview with voice override + await self.message_queue.put((preview_message, preview_text, voice_name)) + + await interaction.response.send_message( + f"⏳ Queued preview for `{voice_name}`. Sample: \"{preview_text[:50]}{'...' if len(preview_text) > 50 else ''}\"", + ephemeral=True + ) + async def setup_hook(self) -> None: """Called when the bot is starting up.""" print("Initializing TTS...") @@ -258,16 +334,23 @@ class TTSBot(commands.Bot): async def process_queue(self) -> None: """Process messages from the queue one at a time.""" while True: - message, text = await self.message_queue.get() + queue_item = await self.message_queue.get() + + # Handle both regular messages (message, text) and previews (message, text, voice_name) + if len(queue_item) == 3: + message, text, voice_override = queue_item + else: + message, text = queue_item + voice_override = None try: - await self.speak_message(message, text) + await self.speak_message(message, text, voice_override) except Exception as e: print(f"Error processing message: {e}") finally: self.message_queue.task_done() - async def speak_message(self, message: discord.Message, text: str) -> None: + async def speak_message(self, message: discord.Message, text: str, voice_override: str | None = None) -> None: """Generate TTS and play it in the user's voice channel.""" if message.author.voice is None: return @@ -280,18 +363,24 @@ class TTSBot(commands.Bot): print(f"Generating TTS for: {text[:50]}...") - # Get user's voice (loads on-demand if needed) - user_id = message.author.id + # Get voice state (use override for previews, otherwise user's voice) try: - voice_state = await asyncio.to_thread( - self.voice_manager.get_user_voice_state, user_id - ) + if voice_override: + voice_state = await asyncio.to_thread( + self.voice_manager.get_voice_state, voice_override + ) + else: + user_id = message.author.id + voice_state = await asyncio.to_thread( + self.voice_manager.get_user_voice_state, user_id + ) except Exception as e: - print(f"Error loading voice for user {user_id}: {e}") - await message.channel.send( - f"{message.author.mention}, failed to load your voice. Use `/voice set` to choose a voice.", - delete_after=5 - ) + print(f"Error loading voice: {e}") + if not voice_override: + await message.channel.send( + f"{message.author.mention}, failed to load your voice. Use `/voice set` to choose a voice.", + delete_after=5 + ) return wav_bytes = await asyncio.to_thread(