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:
@@ -109,6 +109,7 @@ A Discord bot that reads messages aloud using [Pocket TTS](https://github.com/ky
|
|||||||
- `/voice set <name>` - Change your personal TTS voice
|
- `/voice set <name>` - Change your personal TTS voice
|
||||||
- `/voice current` - Shows your current voice
|
- `/voice current` - Shows your current voice
|
||||||
- `/voice refresh` - Re-scan for new voice files (no restart needed)
|
- `/voice refresh` - Re-scan for new voice files (no restart needed)
|
||||||
|
- `/voice preview <name>` - Preview a voice before selecting it
|
||||||
|
|
||||||
### Test Mode
|
### Test Mode
|
||||||
|
|
||||||
|
|||||||
117
bot.py
117
bot.py
@@ -1,5 +1,6 @@
|
|||||||
__version__ = "1.1.0"
|
__version__ = "1.1.0"
|
||||||
|
|
||||||
|
import random
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -30,6 +31,18 @@ from voice_manager import VoiceManager
|
|||||||
# Inactivity timeout in seconds (10 minutes)
|
# Inactivity timeout in seconds (10 minutes)
|
||||||
INACTIVITY_TIMEOUT = 10 * 60
|
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):
|
class TTSBot(commands.Bot):
|
||||||
"""Discord bot that reads messages aloud using Pocket TTS."""
|
"""Discord bot that reads messages aloud using Pocket TTS."""
|
||||||
@@ -41,7 +54,7 @@ class TTSBot(commands.Bot):
|
|||||||
super().__init__(command_prefix="!", intents=intents)
|
super().__init__(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
self.voice_manager = VoiceManager(Config.VOICES_DIR, Config.DEFAULT_VOICE)
|
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.last_activity: float = 0.0
|
||||||
|
|
||||||
self._setup_slash_commands()
|
self._setup_slash_commands()
|
||||||
@@ -59,6 +72,7 @@ class TTSBot(commands.Bot):
|
|||||||
app_commands.Choice(name="set", value="set"),
|
app_commands.Choice(name="set", value="set"),
|
||||||
app_commands.Choice(name="current", value="current"),
|
app_commands.Choice(name="current", value="current"),
|
||||||
app_commands.Choice(name="refresh", value="refresh"),
|
app_commands.Choice(name="refresh", value="refresh"),
|
||||||
|
app_commands.Choice(name="preview", value="preview"),
|
||||||
])
|
])
|
||||||
async def voice_command(
|
async def voice_command(
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
@@ -73,6 +87,8 @@ class TTSBot(commands.Bot):
|
|||||||
await self._handle_voice_current(interaction)
|
await self._handle_voice_current(interaction)
|
||||||
elif action.value == "refresh":
|
elif action.value == "refresh":
|
||||||
await self._handle_voice_refresh(interaction)
|
await self._handle_voice_refresh(interaction)
|
||||||
|
elif action.value == "preview":
|
||||||
|
await self._handle_voice_preview(interaction, voice_name)
|
||||||
|
|
||||||
@voice_command.autocomplete("voice_name")
|
@voice_command.autocomplete("voice_name")
|
||||||
async def voice_name_autocomplete(
|
async def voice_name_autocomplete(
|
||||||
@@ -206,6 +222,66 @@ class TTSBot(commands.Bot):
|
|||||||
ephemeral=True
|
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:
|
async def setup_hook(self) -> None:
|
||||||
"""Called when the bot is starting up."""
|
"""Called when the bot is starting up."""
|
||||||
print("Initializing TTS...")
|
print("Initializing TTS...")
|
||||||
@@ -258,16 +334,23 @@ class TTSBot(commands.Bot):
|
|||||||
async def process_queue(self) -> None:
|
async def process_queue(self) -> None:
|
||||||
"""Process messages from the queue one at a time."""
|
"""Process messages from the queue one at a time."""
|
||||||
while True:
|
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:
|
try:
|
||||||
await self.speak_message(message, text)
|
await self.speak_message(message, text, voice_override)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error processing message: {e}")
|
print(f"Error processing message: {e}")
|
||||||
finally:
|
finally:
|
||||||
self.message_queue.task_done()
|
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."""
|
"""Generate TTS and play it in the user's voice channel."""
|
||||||
if message.author.voice is None:
|
if message.author.voice is None:
|
||||||
return
|
return
|
||||||
@@ -280,18 +363,24 @@ class TTSBot(commands.Bot):
|
|||||||
|
|
||||||
print(f"Generating TTS for: {text[:50]}...")
|
print(f"Generating TTS for: {text[:50]}...")
|
||||||
|
|
||||||
# Get user's voice (loads on-demand if needed)
|
# Get voice state (use override for previews, otherwise user's voice)
|
||||||
user_id = message.author.id
|
|
||||||
try:
|
try:
|
||||||
voice_state = await asyncio.to_thread(
|
if voice_override:
|
||||||
self.voice_manager.get_user_voice_state, user_id
|
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:
|
except Exception as e:
|
||||||
print(f"Error loading voice for user {user_id}: {e}")
|
print(f"Error loading voice: {e}")
|
||||||
await message.channel.send(
|
if not voice_override:
|
||||||
f"{message.author.mention}, failed to load your voice. Use `/voice set` to choose a voice.",
|
await message.channel.send(
|
||||||
delete_after=5
|
f"{message.author.mention}, failed to load your voice. Use `/voice set` to choose a voice.",
|
||||||
)
|
delete_after=5
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
wav_bytes = await asyncio.to_thread(
|
wav_bytes = await asyncio.to_thread(
|
||||||
|
|||||||
Reference in New Issue
Block a user