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