feat: add /voice preview command

- Added 8 random preview sample lines for voice testing
- New /voice preview <name> 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
This commit is contained in:
2026-01-31 15:06:45 -06:00
parent 2403b431e9
commit 4a2d72517f
2 changed files with 104 additions and 14 deletions

117
bot.py
View File

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