mirror of
https://github.com/System-End/tts.git
synced 2026-04-19 16:28:24 +00:00
a
This commit is contained in:
commit
ed7d5672f1
8 changed files with 1333 additions and 0 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Get your token from: https://discord.com/developers/applications
|
||||
DISCORD_TOKEN=your_discord_bot_token_here
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
.env
|
||||
|
||||
audio_cache/
|
||||
*.mp3
|
||||
*.wav
|
||||
*.ogg
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
*.log
|
||||
logs/
|
||||
91
README.txt
Normal file
91
README.txt
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
Discord TTS Bot
|
||||
===============
|
||||
|
||||
Simple TTS bot with multiple languages and crisp audio quality.
|
||||
|
||||
Setup
|
||||
-----
|
||||
|
||||
1. Install FFmpeg
|
||||
- Windows: Download from ffmpeg.org and add to PATH
|
||||
- Mac: brew install ffmpeg
|
||||
- Linux: sudo apt install ffmpeg
|
||||
|
||||
2. Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
3. Create .env file
|
||||
cp .env.example .env
|
||||
|
||||
4. Add your Discord bot token to .env
|
||||
DISCORD_TOKEN=your_token_here
|
||||
|
||||
5. Run the bot
|
||||
python bot.py
|
||||
|
||||
|
||||
Getting Bot Token
|
||||
-----------------
|
||||
|
||||
1. Go to https://discord.com/developers/applications
|
||||
2. Create New Application
|
||||
3. Go to Bot section
|
||||
4. Add Bot
|
||||
5. Copy token
|
||||
6. Paste in .env file
|
||||
|
||||
|
||||
Invite Bot
|
||||
----------
|
||||
|
||||
Use this URL (replace YOUR_CLIENT_ID):
|
||||
https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=36719616&scope=bot%20applications.commands
|
||||
|
||||
Permissions needed: Connect, Speak, Use Slash Commands
|
||||
|
||||
|
||||
Commands
|
||||
--------
|
||||
|
||||
/tts <text> - Speak text (auto-joins voice channel)
|
||||
/voice <lang> <voice> - Change voice
|
||||
/stop - Disconnect from voice
|
||||
|
||||
|
||||
Supported Languages
|
||||
-------------------
|
||||
|
||||
English, Spanish, French, German, Japanese, Korean, Chinese, Italian,
|
||||
Portuguese, Russian, Dutch, Polish, Turkish, Arabic, Hindi, Swedish,
|
||||
Norwegian, Danish, Finnish, Greek, Czech, Hungarian, Thai, Vietnamese,
|
||||
Indonesian, Ukrainian, Romanian, Bulgarian, Slovak, Croatian
|
||||
|
||||
Over 100 voices total.
|
||||
|
||||
|
||||
Audio Quality
|
||||
-------------
|
||||
|
||||
- 48kHz sample rate
|
||||
- 128kbps bitrate (configurable in config.json)
|
||||
- Audio normalization enabled
|
||||
- Dynamic compression for clarity
|
||||
|
||||
To increase quality, edit config.json and change bitrate to 192 or 256.
|
||||
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
Bot won't start:
|
||||
- Check DISCORD_TOKEN is set in .env
|
||||
- Make sure FFmpeg is installed
|
||||
|
||||
No audio:
|
||||
- Install FFmpeg
|
||||
- Check bot has Connect and Speak permissions
|
||||
- Join a voice channel first
|
||||
|
||||
Commands don't show:
|
||||
- Wait 1-2 minutes for Discord to sync
|
||||
- Re-invite the bot
|
||||
573
bot.py
Normal file
573
bot.py
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
import discord
|
||||
import edge_tts
|
||||
from discord import app_commands
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with open("config.json", "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
with open("voices.json", "r", encoding="utf-8") as f:
|
||||
voices_data = json.load(f)
|
||||
|
||||
Path("audio_cache").mkdir(exist_ok=True)
|
||||
|
||||
SETTINGS_FILE = "guild_settings.json"
|
||||
|
||||
|
||||
def load_guild_settings() -> dict:
|
||||
if os.path.exists(SETTINGS_FILE):
|
||||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def save_guild_settings():
|
||||
saveable = {}
|
||||
for gid, data in guild_settings.items():
|
||||
saveable[gid] = {
|
||||
"tts_channels": data.get("tts_channels", []),
|
||||
"bound_vc": data.get("bound_vc", None),
|
||||
}
|
||||
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(saveable, f, indent=2)
|
||||
|
||||
|
||||
guild_settings: dict = load_guild_settings()
|
||||
|
||||
voice_clients: dict[int, discord.VoiceClient] = {}
|
||||
last_activity: dict[int, datetime] = {}
|
||||
user_voices: dict[int, dict[int, str]] = {}
|
||||
tts_queues: dict[int, asyncio.Queue] = {}
|
||||
tts_enabled: dict[int, set[int]] = {}
|
||||
last_speaker: dict[int, int] = {}
|
||||
user_display_names: dict[int, dict[int, str]] = {}
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.voice_states = True
|
||||
bot = commands.Bot(command_prefix="\x00", intents=intents)
|
||||
|
||||
|
||||
def get_guild(guild_id: int) -> dict:
|
||||
key = str(guild_id)
|
||||
if key not in guild_settings:
|
||||
guild_settings[key] = {"tts_channels": [], "bound_vc": None}
|
||||
return guild_settings[key]
|
||||
|
||||
|
||||
def get_display_name(guild_id: int, member: discord.Member) -> str:
|
||||
return user_display_names.get(guild_id, {}).get(member.id, member.display_name)
|
||||
|
||||
|
||||
def get_user_voice(guild_id: int, user_id: int) -> str:
|
||||
return user_voices.get(guild_id, {}).get(user_id, config["tts"]["default_voice"])
|
||||
|
||||
|
||||
def set_user_voice(guild_id: int, user_id: int, voice_id: str):
|
||||
user_voices.setdefault(guild_id, {})[user_id] = voice_id
|
||||
|
||||
|
||||
def find_voice(name: str):
|
||||
for lang, voices in voices_data["voices"].items():
|
||||
for v in voices:
|
||||
if v["name"].lower() == name.lower() or v["id"].lower() == name.lower():
|
||||
return v, lang
|
||||
return None, None
|
||||
|
||||
|
||||
def ffmpeg_options() -> dict:
|
||||
return {
|
||||
"options": (
|
||||
f"-vn -b:a {config['audio']['bitrate']}k "
|
||||
f"-ar {config['audio']['sample_rate']} -ac 2 "
|
||||
f'-af "loudnorm=I=-16:TP=-1.5:LRA=11,'
|
||||
f'acompressor=threshold=-20dB:ratio=4:attack=5:release=50"'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def generate_tts(text: str, voice_id: str, path: str) -> bool:
|
||||
try:
|
||||
comm = edge_tts.Communicate(
|
||||
text, voice_id, rate=config["tts"]["rate"], pitch=config["tts"]["pitch"]
|
||||
)
|
||||
await comm.save(path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"TTS error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def cleanup(path: str, delay: int = 8):
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def ensure_connected(
|
||||
guild: discord.Guild, member: discord.Member
|
||||
) -> discord.VoiceClient | None:
|
||||
if not member.voice or not member.voice.channel:
|
||||
return None
|
||||
|
||||
target = member.voice.channel
|
||||
vc = voice_clients.get(guild.id)
|
||||
|
||||
if vc and vc.is_connected():
|
||||
if vc.channel.id != target.id:
|
||||
await vc.move_to(target)
|
||||
return vc
|
||||
|
||||
try:
|
||||
vc = await target.connect()
|
||||
voice_clients[guild.id] = vc
|
||||
return vc
|
||||
except Exception as e:
|
||||
logger.error(f"VC connect error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def enqueue(
|
||||
guild: discord.Guild, member: discord.Member, text: str, voice_id: str
|
||||
):
|
||||
q = tts_queues.setdefault(guild.id, asyncio.Queue())
|
||||
await q.put((text, voice_id, member))
|
||||
if q.qsize() == 1:
|
||||
asyncio.create_task(queue_worker(guild))
|
||||
|
||||
|
||||
async def queue_worker(guild: discord.Guild):
|
||||
q = tts_queues.get(guild.id)
|
||||
if not q:
|
||||
return
|
||||
|
||||
while not q.empty():
|
||||
text, voice_id, member = await q.get()
|
||||
|
||||
vc = await ensure_connected(guild, member)
|
||||
if vc is None:
|
||||
await member.send(
|
||||
"Could not join a voice channel. Make sure you are in a voice channel (or the bound VC if one is set)."
|
||||
)
|
||||
q.task_done()
|
||||
continue
|
||||
|
||||
last_activity[guild.id] = datetime.now()
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
path = f"audio_cache/{guild.id}_{ts}.mp3"
|
||||
|
||||
ok = await generate_tts(text, voice_id, path)
|
||||
|
||||
if not ok:
|
||||
await member.send(
|
||||
"Failed to generate TTS audio. The TTS service may be unavailable."
|
||||
)
|
||||
q.task_done()
|
||||
continue
|
||||
|
||||
while vc.is_playing():
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
try:
|
||||
source = discord.FFmpegPCMAudio(path, **ffmpeg_options())
|
||||
vc.play(source)
|
||||
asyncio.create_task(cleanup(path, delay=12))
|
||||
except Exception as e:
|
||||
logger.error(f"Playback error: {e}")
|
||||
await member.send("Failed to play audio. Check that ffmpeg is installed.")
|
||||
|
||||
q.task_done()
|
||||
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
logger.info(f"Logged in as {bot.user}")
|
||||
check_inactivity.start()
|
||||
try:
|
||||
synced = await bot.tree.sync()
|
||||
logger.info(f"Synced {len(synced)} commands")
|
||||
except Exception as e:
|
||||
logger.error(f"Sync error: {e}")
|
||||
|
||||
|
||||
@bot.event
|
||||
async def on_message(message: discord.Message):
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
gs = get_guild(message.guild.id)
|
||||
|
||||
if message.channel.id not in gs.get("tts_channels", []):
|
||||
return
|
||||
|
||||
text = message.content.strip()
|
||||
if not text or text.startswith("/"):
|
||||
return
|
||||
|
||||
enabled = tts_enabled.get(message.guild.id, set())
|
||||
if message.author.id not in enabled:
|
||||
return
|
||||
|
||||
if not message.author.voice or not message.author.voice.channel:
|
||||
await message.reply("You need to be in a voice channel.", mention_author=False)
|
||||
return
|
||||
|
||||
if len(text) > config["tts"]["max_length"]:
|
||||
text = text[: config["tts"]["max_length"]]
|
||||
|
||||
voice_id = get_user_voice(message.guild.id, message.author.id)
|
||||
|
||||
prev = last_speaker.get(message.guild.id)
|
||||
last_speaker[message.guild.id] = message.author.id
|
||||
if prev != message.author.id:
|
||||
name = get_display_name(message.guild.id, message.author)
|
||||
text = f"{name}: {text}"
|
||||
|
||||
await enqueue(message.guild, message.author, text, voice_id)
|
||||
|
||||
|
||||
@bot.event
|
||||
async def on_voice_state_update(member: discord.Member, before, after):
|
||||
if member.bot:
|
||||
return
|
||||
|
||||
vc = voice_clients.get(member.guild.id)
|
||||
if vc and vc.is_connected():
|
||||
if all(m.bot for m in vc.channel.members):
|
||||
await vc.disconnect()
|
||||
voice_clients.pop(member.guild.id, None)
|
||||
last_activity.pop(member.guild.id, None)
|
||||
logger.info(f"Auto-disconnected from {member.guild.name}")
|
||||
|
||||
|
||||
@tasks.loop(seconds=30)
|
||||
async def check_inactivity():
|
||||
now = datetime.now()
|
||||
timeout = timedelta(seconds=config["bot"]["inactivity_timeout"])
|
||||
stale = [gid for gid, t in last_activity.items() if now - t > timeout]
|
||||
|
||||
for gid in stale:
|
||||
vc = voice_clients.get(gid)
|
||||
if vc:
|
||||
try:
|
||||
await vc.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
voice_clients.pop(gid, None)
|
||||
last_activity.pop(gid, None)
|
||||
logger.info(f"Inactivity disconnect: {gid}")
|
||||
|
||||
|
||||
def admin_only():
|
||||
return app_commands.checks.has_permissions(manage_guild=True)
|
||||
|
||||
|
||||
@bot.tree.command(
|
||||
name="setup-channel", description="Add or remove a TTS text channel (admin)"
|
||||
)
|
||||
@app_commands.describe(channel="Text channel", action="add or remove")
|
||||
@app_commands.choices(
|
||||
action=[
|
||||
app_commands.Choice(name="add", value="add"),
|
||||
app_commands.Choice(name="remove", value="remove"),
|
||||
]
|
||||
)
|
||||
@admin_only()
|
||||
async def setup_channel(
|
||||
interaction: discord.Interaction, channel: discord.TextChannel, action: str
|
||||
):
|
||||
gs = get_guild(interaction.guild_id)
|
||||
ids: list = gs.setdefault("tts_channels", [])
|
||||
|
||||
if action == "add":
|
||||
if channel.id in ids:
|
||||
await interaction.response.send_message(
|
||||
f"#{channel.name} is already a TTS channel.", ephemeral=True
|
||||
)
|
||||
return
|
||||
ids.append(channel.id)
|
||||
save_guild_settings()
|
||||
await interaction.response.send_message(
|
||||
f"#{channel.name} set as TTS channel.", ephemeral=True
|
||||
)
|
||||
else:
|
||||
if channel.id not in ids:
|
||||
await interaction.response.send_message(
|
||||
f"#{channel.name} is not a TTS channel.", ephemeral=True
|
||||
)
|
||||
return
|
||||
ids.remove(channel.id)
|
||||
save_guild_settings()
|
||||
await interaction.response.send_message(
|
||||
f"#{channel.name} removed from TTS channels.", ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@bot.tree.command(
|
||||
name="setup-vc", description="Bind or unbind the bot to a voice channel (admin)"
|
||||
)
|
||||
@app_commands.describe(channel="Voice channel to bind to", action="bind or unbind")
|
||||
@app_commands.choices(
|
||||
action=[
|
||||
app_commands.Choice(name="bind", value="bind"),
|
||||
app_commands.Choice(name="unbind", value="unbind"),
|
||||
]
|
||||
)
|
||||
@admin_only()
|
||||
async def setup_vc(
|
||||
interaction: discord.Interaction, action: str, channel: discord.VoiceChannel = None
|
||||
):
|
||||
gs = get_guild(interaction.guild_id)
|
||||
|
||||
if action == "bind":
|
||||
if channel is None:
|
||||
await interaction.response.send_message(
|
||||
"Provide a voice channel to bind to.", ephemeral=True
|
||||
)
|
||||
|
||||
return
|
||||
gs["bound_vc"] = channel.id
|
||||
save_guild_settings()
|
||||
await interaction.response.send_message(
|
||||
f"Bot will always join {channel.name} for TTS.", ephemeral=True
|
||||
)
|
||||
else:
|
||||
gs["bound_vc"] = None
|
||||
save_guild_settings()
|
||||
await interaction.response.send_message(
|
||||
"VC binding removed. Bot will follow the user's VC.", ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@bot.tree.command(name="setup-status", description="Show current TTS config (admin)")
|
||||
@admin_only()
|
||||
async def setup_status(interaction: discord.Interaction):
|
||||
gs = get_guild(interaction.guild_id)
|
||||
|
||||
tts_channels = gs.get("tts_channels", [])
|
||||
bound_vc = gs.get("bound_vc")
|
||||
|
||||
channel_mentions = [f"<#{c}>" for c in tts_channels] or ["none"]
|
||||
vc_mention = f"<#{bound_vc}>" if bound_vc else "none (follows user)"
|
||||
|
||||
embed = discord.Embed(title="TTS Config", color=discord.Color.blurple())
|
||||
embed.add_field(
|
||||
name="TTS Channels", value="\n".join(channel_mentions), inline=False
|
||||
)
|
||||
embed.add_field(name="Bound VC", value=vc_mention, inline=False)
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
|
||||
@bot.tree.command(name="voice", description="Set your TTS voice")
|
||||
@app_commands.describe(language="Language", voice="Voice name")
|
||||
async def voice_cmd(interaction: discord.Interaction, language: str, voice: str):
|
||||
if language not in voices_data["voices"]:
|
||||
langs = ", ".join(voices_data["voices"].keys())
|
||||
await interaction.response.send_message(
|
||||
f"Unknown language. Options: {langs}", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
v, lang = find_voice(voice)
|
||||
if v is None:
|
||||
available = ", ".join(x["name"] for x in voices_data["voices"][language])
|
||||
await interaction.response.send_message(
|
||||
f"Unknown voice. Options for {language}: {available}", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
set_user_voice(interaction.guild_id, interaction.user.id, v["id"])
|
||||
await interaction.response.send_message(
|
||||
f"Voice set to {v['name']} ({lang})", ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@voice_cmd.autocomplete("language")
|
||||
async def autocomplete_language(interaction: discord.Interaction, current: str):
|
||||
return [
|
||||
app_commands.Choice(name=lang, value=lang)
|
||||
for lang in voices_data["voices"]
|
||||
if current.lower() in lang.lower()
|
||||
][:25]
|
||||
|
||||
|
||||
@voice_cmd.autocomplete("voice")
|
||||
async def autocomplete_voice(interaction: discord.Interaction, current: str):
|
||||
language = next(
|
||||
(
|
||||
o["value"]
|
||||
for o in interaction.data.get("options", [])
|
||||
if o["name"] == "language"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not language or language not in voices_data["voices"]:
|
||||
return []
|
||||
return [
|
||||
app_commands.Choice(name=v["name"], value=v["name"])
|
||||
for v in voices_data["voices"][language]
|
||||
if current.lower() in v["name"].lower()
|
||||
][:25]
|
||||
|
||||
|
||||
@bot.tree.command(name="stop", description="Stop TTS and disconnect")
|
||||
async def stop(interaction: discord.Interaction):
|
||||
gid = interaction.guild_id
|
||||
vc = voice_clients.get(gid)
|
||||
|
||||
if not vc or not vc.is_connected():
|
||||
await interaction.response.send_message(
|
||||
"Not in a voice channel.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
if vc.is_playing():
|
||||
vc.stop()
|
||||
|
||||
q = tts_queues.get(gid)
|
||||
if q:
|
||||
while not q.empty():
|
||||
try:
|
||||
q.get_nowait()
|
||||
q.task_done()
|
||||
except Exception:
|
||||
break
|
||||
|
||||
await vc.disconnect()
|
||||
voice_clients.pop(gid, None)
|
||||
last_activity.pop(gid, None)
|
||||
|
||||
await interaction.response.send_message("Stopped and disconnected.", ephemeral=True)
|
||||
|
||||
|
||||
@bot.tree.command(
|
||||
name="tts-enable", description="Enable TTS for your messages in this server"
|
||||
)
|
||||
async def tts_enable(interaction: discord.Interaction):
|
||||
gs = get_guild(interaction.guild_id)
|
||||
if not gs.get("tts_channels"):
|
||||
await interaction.response.send_message(
|
||||
"No TTS channels have been configured for this server yet.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
enabled = tts_enabled.setdefault(interaction.guild_id, set())
|
||||
if interaction.user.id in enabled:
|
||||
await interaction.response.send_message(
|
||||
"TTS is already enabled for you.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
enabled.add(interaction.user.id)
|
||||
await interaction.response.send_message(
|
||||
"TTS enabled. Your messages in TTS channels will now be spoken.", ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@bot.tree.command(name="tts-name", description="Set the name TTS reads out for you")
|
||||
@app_commands.describe(
|
||||
name="Name to be read out (leave blank to reset to your display name)"
|
||||
)
|
||||
async def tts_name(interaction: discord.Interaction, name: str = None):
|
||||
names = user_display_names.setdefault(interaction.guild_id, {})
|
||||
if name is None:
|
||||
names.pop(interaction.user.id, None)
|
||||
await interaction.response.send_message(
|
||||
"Name reset to your display name.", ephemeral=True
|
||||
)
|
||||
else:
|
||||
names[interaction.user.id] = name
|
||||
await interaction.response.send_message(
|
||||
f'TTS will now call you "{name}".', ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@bot.tree.command(
|
||||
name="tts-disable", description="Disable TTS for your messages in this server"
|
||||
)
|
||||
async def tts_disable(interaction: discord.Interaction):
|
||||
enabled = tts_enabled.get(interaction.guild_id, set())
|
||||
if interaction.user.id not in enabled:
|
||||
await interaction.response.send_message(
|
||||
"TTS is not enabled for you.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
enabled.discard(interaction.user.id)
|
||||
await interaction.response.send_message("TTS disabled.", ephemeral=True)
|
||||
|
||||
|
||||
@bot.tree.command(name="join", description="Join your current voice channel")
|
||||
async def join(interaction: discord.Interaction):
|
||||
if not interaction.user.voice or not interaction.user.voice.channel:
|
||||
await interaction.response.send_message(
|
||||
"You are not in a voice channel.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
target = interaction.user.voice.channel
|
||||
vc = voice_clients.get(interaction.guild_id)
|
||||
|
||||
if vc and vc.is_connected():
|
||||
if vc.channel.id == target.id:
|
||||
await interaction.response.send_message(
|
||||
"Already in your voice channel.", ephemeral=True
|
||||
)
|
||||
return
|
||||
await vc.move_to(target)
|
||||
await interaction.response.send_message(
|
||||
f"Moved to {target.name}.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
vc = await target.connect()
|
||||
voice_clients[interaction.guild_id] = vc
|
||||
last_activity[interaction.guild_id] = datetime.now()
|
||||
await interaction.response.send_message(
|
||||
f"Joined {target.name}.", ephemeral=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"VC connect error: {e}")
|
||||
await interaction.response.send_message(
|
||||
f"Could not join {target.name}: {e}", ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@bot.tree.command(name="skip", description="Skip the current TTS message")
|
||||
async def skip(interaction: discord.Interaction):
|
||||
vc = voice_clients.get(interaction.guild_id)
|
||||
if vc and vc.is_playing():
|
||||
vc.stop()
|
||||
await interaction.response.send_message("Skipped.", ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message("Nothing is playing.", ephemeral=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
token = os.getenv("DISCORD_TOKEN")
|
||||
if not token:
|
||||
logger.error("DISCORD_TOKEN not set")
|
||||
exit(1)
|
||||
bot.run(token)
|
||||
23
config.json
Normal file
23
config.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"bot": {
|
||||
"inactivity_timeout": 300,
|
||||
"command_prefix": "!"
|
||||
},
|
||||
"audio": {
|
||||
"bitrate": 128,
|
||||
"sample_rate": 48000,
|
||||
"channels": 2
|
||||
},
|
||||
"tts": {
|
||||
"default_voice": "en-US-AvaMultilingualNeural",
|
||||
"rate": "+0%",
|
||||
"pitch": "+0Hz",
|
||||
"max_length": 500
|
||||
},
|
||||
"ffmpeg": {
|
||||
"normalization": true,
|
||||
"compression": true,
|
||||
"target_loudness": -16,
|
||||
"true_peak": -1.5
|
||||
}
|
||||
}
|
||||
8
guild_settings.json
Normal file
8
guild_settings.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"1361918605624868894": {
|
||||
"tts_channels": [
|
||||
1366810442768322621
|
||||
],
|
||||
"bound_vc": 1361918607357120606
|
||||
}
|
||||
}
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
discord.py @ git+https://github.com/Rapptz/discord.py.git
|
||||
edge-tts
|
||||
aiohttp==3.9.1
|
||||
PyNaCl==1.5.0
|
||||
python-dotenv==1.0.0
|
||||
davey
|
||||
ruff==0.4.4
|
||||
598
voices.json
Normal file
598
voices.json
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
{
|
||||
"voices": {
|
||||
"English": [
|
||||
{
|
||||
"id": "en-US-AvaMultilingualNeural",
|
||||
"name": "Ava",
|
||||
"gender": "Female",
|
||||
"description": "Warm, clear, natural - excellent for general use"
|
||||
},
|
||||
{
|
||||
"id": "en-US-AndrewMultilingualNeural",
|
||||
"name": "Andrew",
|
||||
"gender": "Male",
|
||||
"description": "Professional, crisp, clear tone"
|
||||
},
|
||||
{
|
||||
"id": "en-US-EmmaMultilingualNeural",
|
||||
"name": "Emma",
|
||||
"gender": "Female",
|
||||
"description": "Smooth, articulate, highly natural"
|
||||
},
|
||||
{
|
||||
"id": "en-US-BrianMultilingualNeural",
|
||||
"name": "Brian",
|
||||
"gender": "Male",
|
||||
"description": "Deep, authoritative, crystal clear"
|
||||
},
|
||||
{
|
||||
"id": "en-GB-SoniaNeural",
|
||||
"name": "Sonia",
|
||||
"gender": "Female",
|
||||
"description": "British English - Professional and clear"
|
||||
},
|
||||
{
|
||||
"id": "en-GB-RyanNeural",
|
||||
"name": "Ryan",
|
||||
"gender": "Male",
|
||||
"description": "British English - Natural and warm"
|
||||
},
|
||||
{
|
||||
"id": "en-AU-NatashaNeural",
|
||||
"name": "Natasha",
|
||||
"gender": "Female",
|
||||
"description": "Australian English - Friendly and clear"
|
||||
},
|
||||
{
|
||||
"id": "en-AU-WilliamNeural",
|
||||
"name": "William",
|
||||
"gender": "Male",
|
||||
"description": "Australian English - Professional"
|
||||
}
|
||||
],
|
||||
"Spanish": [
|
||||
{
|
||||
"id": "es-ES-ElviraNeural",
|
||||
"name": "Elvira",
|
||||
"gender": "Female",
|
||||
"description": "Clear, natural Spanish (Spain)"
|
||||
},
|
||||
{
|
||||
"id": "es-ES-AlvaroNeural",
|
||||
"name": "Alvaro",
|
||||
"gender": "Male",
|
||||
"description": "Professional, crisp Spanish (Spain)"
|
||||
},
|
||||
{
|
||||
"id": "es-MX-DaliaNeural",
|
||||
"name": "Dalia",
|
||||
"gender": "Female",
|
||||
"description": "Mexican Spanish - Natural and warm"
|
||||
},
|
||||
{
|
||||
"id": "es-MX-JorgeNeural",
|
||||
"name": "Jorge",
|
||||
"gender": "Male",
|
||||
"description": "Mexican Spanish - Clear and friendly"
|
||||
},
|
||||
{
|
||||
"id": "es-AR-ElenaNeural",
|
||||
"name": "Elena",
|
||||
"gender": "Female",
|
||||
"description": "Argentinian Spanish - Natural"
|
||||
},
|
||||
{
|
||||
"id": "es-AR-TomasNeural",
|
||||
"name": "Tomas",
|
||||
"gender": "Male",
|
||||
"description": "Argentinian Spanish - Professional"
|
||||
}
|
||||
],
|
||||
"French": [
|
||||
{
|
||||
"id": "fr-FR-DeniseNeural",
|
||||
"name": "Denise",
|
||||
"gender": "Female",
|
||||
"description": "Elegant, clear French"
|
||||
},
|
||||
{
|
||||
"id": "fr-FR-HenriNeural",
|
||||
"name": "Henri",
|
||||
"gender": "Male",
|
||||
"description": "Warm, natural French"
|
||||
},
|
||||
{
|
||||
"id": "fr-CA-SylvieNeural",
|
||||
"name": "Sylvie",
|
||||
"gender": "Female",
|
||||
"description": "Canadian French - Clear and natural"
|
||||
},
|
||||
{
|
||||
"id": "fr-CA-AntoineNeural",
|
||||
"name": "Antoine",
|
||||
"gender": "Male",
|
||||
"description": "Canadian French - Professional"
|
||||
}
|
||||
],
|
||||
"German": [
|
||||
{
|
||||
"id": "de-DE-KatjaNeural",
|
||||
"name": "Katja",
|
||||
"gender": "Female",
|
||||
"description": "Clear, professional German"
|
||||
},
|
||||
{
|
||||
"id": "de-DE-ConradNeural",
|
||||
"name": "Conrad",
|
||||
"gender": "Male",
|
||||
"description": "Deep, authoritative German"
|
||||
},
|
||||
{
|
||||
"id": "de-AT-IngridNeural",
|
||||
"name": "Ingrid",
|
||||
"gender": "Female",
|
||||
"description": "Austrian German - Natural"
|
||||
},
|
||||
{
|
||||
"id": "de-AT-JonasNeural",
|
||||
"name": "Jonas",
|
||||
"gender": "Male",
|
||||
"description": "Austrian German - Professional"
|
||||
}
|
||||
],
|
||||
"Japanese": [
|
||||
{
|
||||
"id": "ja-JP-NanamiNeural",
|
||||
"name": "Nanami",
|
||||
"gender": "Female",
|
||||
"description": "Natural, expressive Japanese"
|
||||
},
|
||||
{
|
||||
"id": "ja-JP-KeitaNeural",
|
||||
"name": "Keita",
|
||||
"gender": "Male",
|
||||
"description": "Clear, warm Japanese"
|
||||
},
|
||||
{
|
||||
"id": "ja-JP-AoiNeural",
|
||||
"name": "Aoi",
|
||||
"gender": "Female",
|
||||
"description": "Young, energetic Japanese"
|
||||
},
|
||||
{
|
||||
"id": "ja-JP-DaichiNeural",
|
||||
"name": "Daichi",
|
||||
"gender": "Male",
|
||||
"description": "Professional Japanese"
|
||||
}
|
||||
],
|
||||
"Korean": [
|
||||
{
|
||||
"id": "ko-KR-SunHiNeural",
|
||||
"name": "SunHi",
|
||||
"gender": "Female",
|
||||
"description": "Clear, natural Korean"
|
||||
},
|
||||
{
|
||||
"id": "ko-KR-InJoonNeural",
|
||||
"name": "InJoon",
|
||||
"gender": "Male",
|
||||
"description": "Professional, crisp Korean"
|
||||
},
|
||||
{
|
||||
"id": "ko-KR-JiMinNeural",
|
||||
"name": "JiMin",
|
||||
"gender": "Female",
|
||||
"description": "Young, friendly Korean"
|
||||
},
|
||||
{
|
||||
"id": "ko-KR-BongJinNeural",
|
||||
"name": "BongJin",
|
||||
"gender": "Male",
|
||||
"description": "Warm Korean voice"
|
||||
}
|
||||
],
|
||||
"Chinese": [
|
||||
{
|
||||
"id": "zh-CN-XiaoxiaoNeural",
|
||||
"name": "Xiaoxiao",
|
||||
"gender": "Female",
|
||||
"description": "Warm, natural Mandarin"
|
||||
},
|
||||
{
|
||||
"id": "zh-CN-YunxiNeural",
|
||||
"name": "Yunxi",
|
||||
"gender": "Male",
|
||||
"description": "Clear, professional Mandarin"
|
||||
},
|
||||
{
|
||||
"id": "zh-CN-YunyangNeural",
|
||||
"name": "Yunyang",
|
||||
"gender": "Male",
|
||||
"description": "News anchor style Mandarin"
|
||||
},
|
||||
{
|
||||
"id": "zh-CN-XiaochenNeural",
|
||||
"name": "Xiaochen",
|
||||
"gender": "Female",
|
||||
"description": "Cheerful Mandarin"
|
||||
},
|
||||
{
|
||||
"id": "zh-HK-HiuGaaiNeural",
|
||||
"name": "HiuGaai",
|
||||
"gender": "Female",
|
||||
"description": "Natural Cantonese"
|
||||
},
|
||||
{
|
||||
"id": "zh-HK-WanLungNeural",
|
||||
"name": "WanLung",
|
||||
"gender": "Male",
|
||||
"description": "Professional Cantonese"
|
||||
}
|
||||
],
|
||||
"Italian": [
|
||||
{
|
||||
"id": "it-IT-ElsaNeural",
|
||||
"name": "Elsa",
|
||||
"gender": "Female",
|
||||
"description": "Natural Italian voice"
|
||||
},
|
||||
{
|
||||
"id": "it-IT-DiegoNeural",
|
||||
"name": "Diego",
|
||||
"gender": "Male",
|
||||
"description": "Professional Italian voice"
|
||||
},
|
||||
{
|
||||
"id": "it-IT-IsabellaNeural",
|
||||
"name": "Isabella",
|
||||
"gender": "Female",
|
||||
"description": "Warm, expressive Italian"
|
||||
}
|
||||
],
|
||||
"Portuguese": [
|
||||
{
|
||||
"id": "pt-BR-FranciscaNeural",
|
||||
"name": "Francisca",
|
||||
"gender": "Female",
|
||||
"description": "Brazilian Portuguese - Natural"
|
||||
},
|
||||
{
|
||||
"id": "pt-BR-AntonioNeural",
|
||||
"name": "Antonio",
|
||||
"gender": "Male",
|
||||
"description": "Brazilian Portuguese - Professional"
|
||||
},
|
||||
{
|
||||
"id": "pt-PT-RaquelNeural",
|
||||
"name": "Raquel",
|
||||
"gender": "Female",
|
||||
"description": "European Portuguese - Clear"
|
||||
},
|
||||
{
|
||||
"id": "pt-PT-DuarteNeural",
|
||||
"name": "Duarte",
|
||||
"gender": "Male",
|
||||
"description": "European Portuguese - Professional"
|
||||
}
|
||||
],
|
||||
"Russian": [
|
||||
{
|
||||
"id": "ru-RU-SvetlanaNeural",
|
||||
"name": "Svetlana",
|
||||
"gender": "Female",
|
||||
"description": "Natural Russian voice"
|
||||
},
|
||||
{
|
||||
"id": "ru-RU-DmitryNeural",
|
||||
"name": "Dmitry",
|
||||
"gender": "Male",
|
||||
"description": "Professional Russian voice"
|
||||
},
|
||||
{
|
||||
"id": "ru-RU-DariyaNeural",
|
||||
"name": "Dariya",
|
||||
"gender": "Female",
|
||||
"description": "Clear Russian voice"
|
||||
}
|
||||
],
|
||||
"Dutch": [
|
||||
{
|
||||
"id": "nl-NL-ColetteNeural",
|
||||
"name": "Colette",
|
||||
"gender": "Female",
|
||||
"description": "Natural Dutch voice"
|
||||
},
|
||||
{
|
||||
"id": "nl-NL-MaartenNeural",
|
||||
"name": "Maarten",
|
||||
"gender": "Male",
|
||||
"description": "Professional Dutch voice"
|
||||
},
|
||||
{
|
||||
"id": "nl-NL-FennaNeural",
|
||||
"name": "Fenna",
|
||||
"gender": "Female",
|
||||
"description": "Warm Dutch voice"
|
||||
}
|
||||
],
|
||||
"Polish": [
|
||||
{
|
||||
"id": "pl-PL-ZofiaNeural",
|
||||
"name": "Zofia",
|
||||
"gender": "Female",
|
||||
"description": "Natural Polish voice"
|
||||
},
|
||||
{
|
||||
"id": "pl-PL-MarekNeural",
|
||||
"name": "Marek",
|
||||
"gender": "Male",
|
||||
"description": "Professional Polish voice"
|
||||
}
|
||||
],
|
||||
"Turkish": [
|
||||
{
|
||||
"id": "tr-TR-EmelNeural",
|
||||
"name": "Emel",
|
||||
"gender": "Female",
|
||||
"description": "Natural Turkish voice"
|
||||
},
|
||||
{
|
||||
"id": "tr-TR-AhmetNeural",
|
||||
"name": "Ahmet",
|
||||
"gender": "Male",
|
||||
"description": "Professional Turkish voice"
|
||||
}
|
||||
],
|
||||
"Arabic": [
|
||||
{
|
||||
"id": "ar-SA-ZariyahNeural",
|
||||
"name": "Zariyah",
|
||||
"gender": "Female",
|
||||
"description": "Natural Arabic voice (Saudi)"
|
||||
},
|
||||
{
|
||||
"id": "ar-SA-HamedNeural",
|
||||
"name": "Hamed",
|
||||
"gender": "Male",
|
||||
"description": "Professional Arabic voice (Saudi)"
|
||||
},
|
||||
{
|
||||
"id": "ar-EG-SalmaNeural",
|
||||
"name": "Salma",
|
||||
"gender": "Female",
|
||||
"description": "Egyptian Arabic - Natural"
|
||||
},
|
||||
{
|
||||
"id": "ar-EG-ShakirNeural",
|
||||
"name": "Shakir",
|
||||
"gender": "Male",
|
||||
"description": "Egyptian Arabic - Professional"
|
||||
}
|
||||
],
|
||||
"Hindi": [
|
||||
{
|
||||
"id": "hi-IN-SwaraNeural",
|
||||
"name": "Swara",
|
||||
"gender": "Female",
|
||||
"description": "Natural Hindi voice"
|
||||
},
|
||||
{
|
||||
"id": "hi-IN-MadhurNeural",
|
||||
"name": "Madhur",
|
||||
"gender": "Male",
|
||||
"description": "Professional Hindi voice"
|
||||
}
|
||||
],
|
||||
"Swedish": [
|
||||
{
|
||||
"id": "sv-SE-SofieNeural",
|
||||
"name": "Sofie",
|
||||
"gender": "Female",
|
||||
"description": "Natural Swedish voice"
|
||||
},
|
||||
{
|
||||
"id": "sv-SE-MattiasNeural",
|
||||
"name": "Mattias",
|
||||
"gender": "Male",
|
||||
"description": "Professional Swedish voice"
|
||||
}
|
||||
],
|
||||
"Norwegian": [
|
||||
{
|
||||
"id": "nb-NO-PernilleNeural",
|
||||
"name": "Pernille",
|
||||
"gender": "Female",
|
||||
"description": "Natural Norwegian voice"
|
||||
},
|
||||
{
|
||||
"id": "nb-NO-FinnNeural",
|
||||
"name": "Finn",
|
||||
"gender": "Male",
|
||||
"description": "Professional Norwegian voice"
|
||||
}
|
||||
],
|
||||
"Danish": [
|
||||
{
|
||||
"id": "da-DK-ChristelNeural",
|
||||
"name": "Christel",
|
||||
"gender": "Female",
|
||||
"description": "Natural Danish voice"
|
||||
},
|
||||
{
|
||||
"id": "da-DK-JeppeNeural",
|
||||
"name": "Jeppe",
|
||||
"gender": "Male",
|
||||
"description": "Professional Danish voice"
|
||||
}
|
||||
],
|
||||
"Finnish": [
|
||||
{
|
||||
"id": "fi-FI-NooraNeural",
|
||||
"name": "Noora",
|
||||
"gender": "Female",
|
||||
"description": "Natural Finnish voice"
|
||||
},
|
||||
{
|
||||
"id": "fi-FI-HarriNeural",
|
||||
"name": "Harri",
|
||||
"gender": "Male",
|
||||
"description": "Professional Finnish voice"
|
||||
}
|
||||
],
|
||||
"Greek": [
|
||||
{
|
||||
"id": "el-GR-AthinaNeural",
|
||||
"name": "Athina",
|
||||
"gender": "Female",
|
||||
"description": "Natural Greek voice"
|
||||
},
|
||||
{
|
||||
"id": "el-GR-NestorasNeural",
|
||||
"name": "Nestoras",
|
||||
"gender": "Male",
|
||||
"description": "Professional Greek voice"
|
||||
}
|
||||
],
|
||||
"Czech": [
|
||||
{
|
||||
"id": "cs-CZ-VlastaNeural",
|
||||
"name": "Vlasta",
|
||||
"gender": "Female",
|
||||
"description": "Natural Czech voice"
|
||||
},
|
||||
{
|
||||
"id": "cs-CZ-AntoninNeural",
|
||||
"name": "Antonin",
|
||||
"gender": "Male",
|
||||
"description": "Professional Czech voice"
|
||||
}
|
||||
],
|
||||
"Hungarian": [
|
||||
{
|
||||
"id": "hu-HU-NoemiNeural",
|
||||
"name": "Noemi",
|
||||
"gender": "Female",
|
||||
"description": "Natural Hungarian voice"
|
||||
},
|
||||
{
|
||||
"id": "hu-HU-TamasNeural",
|
||||
"name": "Tamas",
|
||||
"gender": "Male",
|
||||
"description": "Professional Hungarian voice"
|
||||
}
|
||||
],
|
||||
"Thai": [
|
||||
{
|
||||
"id": "th-TH-PremwadeeNeural",
|
||||
"name": "Premwadee",
|
||||
"gender": "Female",
|
||||
"description": "Natural Thai voice"
|
||||
},
|
||||
{
|
||||
"id": "th-TH-NiwatNeural",
|
||||
"name": "Niwat",
|
||||
"gender": "Male",
|
||||
"description": "Professional Thai voice"
|
||||
}
|
||||
],
|
||||
"Vietnamese": [
|
||||
{
|
||||
"id": "vi-VN-HoaiMyNeural",
|
||||
"name": "HoaiMy",
|
||||
"gender": "Female",
|
||||
"description": "Natural Vietnamese voice"
|
||||
},
|
||||
{
|
||||
"id": "vi-VN-NamMinhNeural",
|
||||
"name": "NamMinh",
|
||||
"gender": "Male",
|
||||
"description": "Professional Vietnamese voice"
|
||||
}
|
||||
],
|
||||
"Indonesian": [
|
||||
{
|
||||
"id": "id-ID-GadisNeural",
|
||||
"name": "Gadis",
|
||||
"gender": "Female",
|
||||
"description": "Natural Indonesian voice"
|
||||
},
|
||||
{
|
||||
"id": "id-ID-ArdiNeural",
|
||||
"name": "Ardi",
|
||||
"gender": "Male",
|
||||
"description": "Professional Indonesian voice"
|
||||
}
|
||||
],
|
||||
"Ukrainian": [
|
||||
{
|
||||
"id": "uk-UA-PolinaNeural",
|
||||
"name": "Polina",
|
||||
"gender": "Female",
|
||||
"description": "Natural Ukrainian voice"
|
||||
},
|
||||
{
|
||||
"id": "uk-UA-OstapNeural",
|
||||
"name": "Ostap",
|
||||
"gender": "Male",
|
||||
"description": "Professional Ukrainian voice"
|
||||
}
|
||||
],
|
||||
"Romanian": [
|
||||
{
|
||||
"id": "ro-RO-AlinaNeural",
|
||||
"name": "Alina",
|
||||
"gender": "Female",
|
||||
"description": "Natural Romanian voice"
|
||||
},
|
||||
{
|
||||
"id": "ro-RO-EmilNeural",
|
||||
"name": "Emil",
|
||||
"gender": "Male",
|
||||
"description": "Professional Romanian voice"
|
||||
}
|
||||
],
|
||||
"Bulgarian": [
|
||||
{
|
||||
"id": "bg-BG-KalinaNeural",
|
||||
"name": "Kalina",
|
||||
"gender": "Female",
|
||||
"description": "Natural Bulgarian voice"
|
||||
},
|
||||
{
|
||||
"id": "bg-BG-BorislavNeural",
|
||||
"name": "Borislav",
|
||||
"gender": "Male",
|
||||
"description": "Professional Bulgarian voice"
|
||||
}
|
||||
],
|
||||
"Slovak": [
|
||||
{
|
||||
"id": "sk-SK-ViktoriaNeural",
|
||||
"name": "Viktoria",
|
||||
"gender": "Female",
|
||||
"description": "Natural Slovak voice"
|
||||
},
|
||||
{
|
||||
"id": "sk-SK-LukasNeural",
|
||||
"name": "Lukas",
|
||||
"gender": "Male",
|
||||
"description": "Professional Slovak voice"
|
||||
}
|
||||
],
|
||||
"Croatian": [
|
||||
{
|
||||
"id": "hr-HR-GabrijelaNeural",
|
||||
"name": "Gabrijela",
|
||||
"gender": "Female",
|
||||
"description": "Natural Croatian voice"
|
||||
},
|
||||
{
|
||||
"id": "hr-HR-SreckoNeural",
|
||||
"name": "Srecko",
|
||||
"gender": "Male",
|
||||
"description": "Professional Croatian voice"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue