Un bot TTS Discord rejoint un salon vocal et lit du texte à voix haute, pour que les personnes qui ne peuvent pas parler au micro, ou qui écrivent simplement plus vite qu’elles ne parlent, se fassent quand même entendre. Il y a deux façons d’en obtenir un : inviter un bot hébergé qu’une autre personne fait tourner, ou créer le vôtre. Ce guide en crée un avec discord.js et Cartesia en environ 80 lignes de JavaScript, puis passe en revue ce qu’il faut vérifier si vous préférez inviter celui de quelqu’un d’autre.
Si vous n’avez besoin que de la synthèse vocale intégrée de Discord, c’est une autre fonctionnalité : elle lit les messages dans les salons textuels avec la voix du système, sans aucun bot. Comment utiliser la synthèse vocale sur Discord l’explique. La commande intégrée ne peut pas parler dans les salons vocaux ni changer de voix, et c’est précisément le vide qu’un bot comble.
Ce que vous allez construire
Un bot avec deux commandes slash : /tts rejoint votre salon vocal et lit votre texte avec une voix Cartesia, et /stop le déconnecte. Chaque fichier ci-dessous a été exécuté avec les SDK actuels en octobre 2026 : la requête vers l’API Cartesia, la conversion audio, l’encodage Opus et l’enregistrement des commandes ont tous été exécutés et vérifiés sous Node 24 avec discord.js 14.27, @discordjs/voice 0.19.2 et @cartesia/cartesia-js 4.2.0.
Prérequis
- Node.js 20 ou plus récent.
- Un compte Discord, et un serveur où vous avez la permission Manage Server pour ajouter un bot.
- Une clé d’API Cartesia depuis le dashboard. Le plan gratuit convient.
- Un ID de voix depuis la bibliothèque de voix. Ouvrez une voix qui vous plaît et copiez son ID.
1. Créez l’application Discord
- Ouvrez le Discord Developer Portal et choisissez New Application. Le nom saisi est celui que les gens verront quand le bot rejoindra.
- Sur la page Bot, choisissez Reset Token et copiez le token. Le token est le mot de passe du bot : gardez-le dans une variable d’environnement, hors du code et hors de git.
- Ce bot n’a besoin d’aucun intent privilégié. Les commandes slash et l’état vocal fonctionnent sans les interrupteurs Presence, Server Members ou Message Content, alors laissez-les désactivés.
- Invitez le bot sur votre serveur. Sous OAuth2, ouvrez l’URL Generator, sélectionnez les portées
botetapplications.commands, puis les permissions Connect et Speak. Ouvrez l’URL générée et choisissez votre serveur.
2. Installez le projet
mkdir discord-tts-bot && cd discord-tts-bot
npm init -y
npm pkg set type=module
npm install discord.js @discordjs/voice @discordjs/opus libsodium-wrappers @cartesia/cartesia-js
Le rôle de chaque paquet : discord.js parle à l’API Discord. @discordjs/voice gère la connexion vocale. @discordjs/opus transforme l’audio brut en trames Opus que Discord diffuse. libsodium-wrappers chiffre les paquets vocaux. @cartesia/cartesia-js est le SDK Cartesia.
Il n’y a pas de ffmpeg dans la liste. Le bot produit exactement le format PCM que Discord attend, donc @discordjs/opus gère l’encodage tout seul. ffmpeg n’entre en scène que lorsque vous donnez à @discordjs/voice un format qu’il doit transcoder, comme un fichier MP3.
3. Enregistrez les commandes slash
Discord doit connaître /tts et /stop avant que quiconque puisse les taper. Enregistrez ceci sous deploy-commands.js :
// Registers the bot's slash commands with Discord.
// Run once after creating the bot, and again whenever the commands change:
// node deploy-commands.js
import { REST, Routes, SlashCommandBuilder } from "discord.js";
const commands = [
new SlashCommandBuilder()
.setName("tts")
.setDescription("Read text aloud in your voice channel")
.addStringOption((option) =>
option
.setName("text")
.setDescription("What the bot should say")
.setRequired(true)
.setMaxLength(1000),
),
new SlashCommandBuilder()
.setName("stop")
.setDescription("Stop playback and leave the voice channel"),
].map((command) => command.toJSON());
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
const registration = await rest.put(
Routes.applicationGuildCommands(
process.env.DISCORD_CLIENT_ID,
process.env.DISCORD_GUILD_ID,
),
{ body: commands },
);
console.log(`Registered ${registration.length} slash commands.`);
Exécutez-le une fois avec les identifiants du bot dans l’environnement :
export DISCORD_TOKEN="your-bot-token"
export DISCORD_CLIENT_ID="your-application-id"
export DISCORD_GUILD_ID="your-server-id"
node deploy-commands.js
Le script enregistre des commandes de guilde, qui se mettent à jour immédiatement, donc les commandes fonctionnent dans votre serveur dès qu’il se termine. DISCORD_CLIENT_ID est l’Application ID de la page General Information de l’app, et DISCORD_GUILD_ID est l’ID de votre serveur ; activez le Mode développeur dans les paramètres de Discord, puis faites un clic droit sur le serveur pour le copier.
4. Écrivez le bot
Enregistrez ceci sous index.js :
// A Discord TTS bot: /tts reads a message aloud in the caller's voice channel.
// Needs DISCORD_TOKEN, CARTESIA_API_KEY and CARTESIA_VOICE_ID in the environment.
import {
AudioPlayerStatus,
StreamType,
createAudioPlayer,
createAudioResource,
getVoiceConnection,
joinVoiceChannel,
} from "@discordjs/voice";
import Cartesia from "@cartesia/cartesia-js";
import { Client, Events, GatewayIntentBits, MessageFlags } from "discord.js";
import { Readable, Transform, pipeline } from "node:stream";
import { pathToFileURL } from "node:url";
const cartesia = new Cartesia({ apiKey: process.env.CARTESIA_API_KEY });
const VOICE_ID = process.env.CARTESIA_VOICE_ID; // pick one at play.cartesia.ai/voices
// Sonic's raw output is mono; Discord voice expects stereo, so duplicate each
// 16-bit sample into both channels.
class MonoToStereo extends Transform {
#pending = null;
_transform(chunk, _encoding, callback) {
let buffer = this.#pending ? Buffer.concat([this.#pending, chunk]) : chunk;
if (buffer.length % 2 === 1) {
this.#pending = buffer.subarray(buffer.length - 1);
buffer = buffer.subarray(0, buffer.length - 1);
} else {
this.#pending = null;
}
const stereo = Buffer.allocUnsafe(buffer.length * 2);
for (let i = 0, j = 0; i < buffer.length; i += 2, j += 4) {
stereo[j] = buffer[i];
stereo[j + 1] = buffer[i + 1];
stereo[j + 2] = buffer[i];
stereo[j + 3] = buffer[i + 1];
}
this.push(stereo);
callback();
}
_flush(callback) {
callback();
}
}
// Calls Sonic and returns a stream of stereo 48 kHz PCM, ready for Discord.
async function synthesize(text) {
const response = await cartesia.tts.generate({
model_id: "sonic-3.6",
transcript: text,
voice: { id: VOICE_ID },
language: "en",
output_format: {
container: "raw",
encoding: "pcm_s16le",
sample_rate: 48000,
},
});
// pipeline, not .pipe: a mid-stream Cartesia error reaches the destination
// stream (and with it the player's "error" listener) instead of crashing.
return pipeline(
Readable.fromWeb(response.body),
new MonoToStereo(),
() => {},
);
}
// One queue per guild, so a second /tts waits for the first to finish.
const queues = new Map();
function getQueue(guildId, channel) {
let queue = queues.get(guildId);
if (!queue) {
const connection = joinVoiceChannel({
channelId: channel.id,
guildId,
adapterCreator: channel.guild.voiceAdapterCreator,
selfDeaf: false,
});
const player = createAudioPlayer();
connection.subscribe(player);
queue = {
connection,
player,
lines: [],
busy: false,
channelId: channel.id,
};
queues.set(guildId, queue);
player.on(AudioPlayerStatus.Idle, () => {
void playNext(guildId);
});
player.on("error", (error) => {
console.error(error);
void playNext(guildId);
});
}
return queue;
}
async function playNext(guildId) {
const queue = queues.get(guildId);
if (!queue) return;
const text = queue.lines.shift();
if (text === undefined) {
queue.busy = false;
return;
}
queue.busy = true;
try {
const audio = await synthesize(text);
// /stop may have torn down this queue while Sonic was working.
if (queues.get(guildId) !== queue) return;
queue.player.play(
createAudioResource(audio, { inputType: StreamType.Raw }),
);
} catch (error) {
console.error("Cartesia TTS failed:", error);
void playNext(guildId);
}
}
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
});
client.once(Events.ClientReady, (readyClient) => {
console.log(`Logged in as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === "tts") {
const channel = interaction.member?.voice?.channel;
if (!channel) {
await interaction.reply({
content: "Join a voice channel first, then run /tts.",
flags: MessageFlags.Ephemeral,
});
return;
}
const queue = getQueue(interaction.guildId, channel);
queue.lines.push(interaction.options.getString("text", true));
await interaction.reply("On it.");
if (!queue.busy) void playNext(interaction.guildId);
}
if (interaction.commandName === "stop") {
const queue = queues.get(interaction.guildId);
if (queue && interaction.member?.voice?.channelId !== queue.channelId) {
await interaction.reply({
content: "Join the bot's voice channel to stop it.",
flags: MessageFlags.Ephemeral,
});
return;
}
queue?.player.stop();
getVoiceConnection(interaction.guildId)?.destroy();
queues.delete(interaction.guildId);
await interaction.reply("Stopped and left the voice channel.");
}
});
// Exported for tests; the bot only logs in when run directly.
export { MonoToStereo, synthesize };
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
client.login(process.env.DISCORD_TOKEN);
}
Trois points de ce fichier méritent un examen plus attentif :
Le format audio. La voix de Discord est de l’Opus stéréo 48 kHz. L’appel tts.generate demande à Sonic container: "raw", encoding: "pcm_s16le" et sample_rate: 48000, soit le format PCM natif de Discord, à un canal près. MonoToStereo duplique chaque échantillon 16 bits dans les deux canaux, et @discordjs/voice encode le résultat en Opus avec @discordjs/opus. Demander 48 kHz dès le départ évite tout rééchantillonnage en route.
La file d’attente. Deux personnes lançant /tts en même temps superposeraient l’audio, donc chaque serveur a sa file. playNext retire une ligne, la synthétise et la joue ; AudioPlayerStatus.Idle se déclenche à la fin d’un clip et prend la ligne suivante. /stop ne fonctionne que depuis le salon du bot : il arrête le lecteur et détruit la connexion. Une ligne qui échoue au milieu du flux atteint le gestionnaire d’erreur du lecteur et est ignorée, et une file remplacée pendant que Sonic travaille encore est abandonnée au lieu d’être jouée par-dessus la suivante.
Où passe le temps. Le délai avant le premier audio de Sonic est inférieur à 90 ms, et synthesize renvoie un flux, donc la lecture démarre sur les premiers morceaux au lieu d’attendre le clip entier. Pour une phrase ou deux, le délai entre la commande et la voix tient surtout à l’aller-retour réseau.
5. Exécutez-le
export DISCORD_TOKEN="your-bot-token"
export CARTESIA_API_KEY="your-cartesia-key"
export CARTESIA_VOICE_ID="the-voice-id-you-copied"
node index.js
Rejoignez un salon vocal de votre serveur et tapez /tts suivi de votre texte. Le bot rejoint, lit, et attend la commande suivante. Arrêtez-le avec Ctrl+C. Pour un bot qui reste en ligne quand l’ordinateur portable se ferme, exécutez-le sur un petit hébergeur toujours actif ; tout ce qui maintient un processus Node.js en vie convient.
Si vous préférez inviter un bot hébergé
Il existe des bots TTS publics que vous pouvez inviter sans rien exécuter. Jugez-les sur trois points : le rendu des voix, les permissions demandées par l’invitation (un bot TTS a besoin de Connect et Speak, et pas grand-chose d’autre), et leur coût au-delà de l’offre gratuite. La contrepartie, c’est le contrôle. Un bot hébergé lit le texte de votre serveur sur l’infrastructure d’un tiers, et ses commandes et ses voix sont celles choisies par son auteur. Si vous le dépassez, les 80 lignes ci-dessus sont la porte de sortie.
Pour aller plus loin
- Une voix par personne : gardez une correspondance entre l’ID utilisateur et l’ID de voix, pour que les messages de chaque membre soient lus avec sa propre voix.
- D’autres langues : Sonic 3.6 prend en charge 44 langues avec un seul modèle. Passez un code
languageet une voix assortis. - Lire un salon automatiquement : écoutez
messageCreatedans un salon désigné au lieu d’une commande slash. Cela exige l’intent privilégié Message Content, activé sur la page Bot du Developer Portal. - Un bot qui répond : celui-ci lit ce que vous tapez, il ne converse pas. Pour un agent vocal qui écoute, réfléchit et répond, voir Cartesia Managed Agents.