Un bot TTS per Discord entra in un canale vocale e legge il testo ad alta voce, così chi non può parlare al microfono, o semplicemente scrive più velocemente di quanto parli, si fa comunque sentire. Ci sono due modi per averne uno: invitare un bot ospitato che qualcun altro mantiene, oppure creare il tuo. Questa guida ne crea uno con discord.js e Cartesia in circa 80 righe di JavaScript, e poi esamina cosa controllare se preferisci invitare quello di qualcun altro.
Se ti serve solo la sintesi vocale integrata di Discord, quella è un’altra funzione: legge i messaggi nei canali di testo con la voce di sistema e non coinvolge nessun bot. Come usare la sintesi vocale su Discord la spiega. Il comando integrato non può parlare nei canali vocali né cambiare voce, ed è proprio il vuoto che un bot riempie.
Cosa costruirai
Un bot con due comandi slash: /tts entra nel tuo canale vocale e legge il tuo testo con una voce di Cartesia, e /stop lo disconnette. Ogni file qui sotto è stato eseguito con gli SDK attuali a ottobre 2026: la richiesta all’API di Cartesia, la conversione audio, la codifica Opus e la registrazione dei comandi sono stati eseguiti e verificati su Node 24 con discord.js 14.27, @discordjs/voice 0.19.2 e @cartesia/cartesia-js 4.2.0.
Prerequisiti
- Node.js 20 o successivo.
- Un account Discord e un server in cui hai il permesso Manage Server per aggiungere un bot.
- Una chiave API di Cartesia dalla dashboard. Il piano gratuito va bene.
- Un ID voce dalla Voice Library. Apri una voce che ti piace e copia il suo ID.
1. Crea l’applicazione Discord
- Apri il Discord Developer Portal e scegli New Application. Il nome che inserisci è quello che le persone vedranno quando il bot entra.
- Nella pagina Bot, scegli Reset Token e copia il token. Il token è la password del bot: tienilo in una variabile d’ambiente, fuori dal codice e fuori da git.
- Questo bot non ha bisogno di intent privilegiati. I comandi slash e lo stato vocale funzionano senza gli interruttori Presence, Server Members o Message Content, quindi lasciali spenti.
- Invita il bot nel tuo server. Sotto OAuth2, apri l’URL Generator, seleziona gli scope
boteapplications.commands, poi i permessi Connect e Speak. Apri l’URL generato e scegli il tuo server.
2. Installa il progetto
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
Cosa fa ogni pacchetto: discord.js parla con l’API di Discord. @discordjs/voice gestisce la connessione vocale. @discordjs/opus trasforma l’audio grezzo nei frame Opus che Discord trasmette. libsodium-wrappers cifra i pacchetti vocali. @cartesia/cartesia-js è l’SDK di Cartesia.
Nella lista non c’è ffmpeg. Il bot produce esattamente il formato PCM che Discord si aspetta, quindi @discordjs/opus gestisce la codifica da solo. ffmpeg entra in gioco solo quando dai a @discordjs/voice un formato che deve transcodificare, come un file MP3.
3. Registra i comandi slash
Discord deve conoscere /tts e /stop prima che qualcuno possa digitarli. Salva questo come 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.`);
Eseguilo una volta con le credenziali del bot nell’ambiente:
export DISCORD_TOKEN="your-bot-token"
export DISCORD_CLIENT_ID="your-application-id"
export DISCORD_GUILD_ID="your-server-id"
node deploy-commands.js
Lo script registra comandi di gilda, che si aggiornano all’istante, quindi i comandi funzionano nel tuo server appena termina. DISCORD_CLIENT_ID è l’Application ID nella pagina General Information dell’app, e DISCORD_GUILD_ID è l’ID del tuo server; attiva la Modalità sviluppatore nelle impostazioni di Discord, poi fai clic destro sul server per copiarlo.
4. Scrivi il bot
Salva questo come 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);
}
Tre cose in quel file meritano uno sguardo più attento:
Il formato audio. La voce di Discord è Opus stereo a 48 kHz. La chiamata tts.generate chiede a Sonic container: "raw", encoding: "pcm_s16le" e sample_rate: 48000, che è il formato PCM nativo di Discord, meno un canale. MonoToStereo duplica ogni campione a 16 bit in entrambi i canali, e @discordjs/voice codifica il risultato in Opus con @discordjs/opus. Chiedere 48 kHz fin dall’inizio evita qualsiasi ricampionamento lungo il percorso.
La coda. Due persone che eseguono /tts contemporaneamente sovrapporrebbero l’audio, quindi ogni server ha una coda. playNext estrae una riga, la sintetizza e la riproduce; AudioPlayerStatus.Idle scatta quando una clip finisce e prende la riga successiva. /stop funziona solo da dentro il canale del bot: ferma il player e distrugge la connessione. Una riga che fallisce a metà dello stream raggiunge il listener di errore del player e viene saltata, e una coda sostituita mentre Sonic sta ancora lavorando viene scartata invece di essere riprodotta sopra la successiva.
Dove va il tempo. Il time to first audio di Sonic è sotto i 90 ms, e synthesize restituisce uno stream, quindi la riproduzione parte dai primi chunk invece di aspettare l’intera clip. Per una frase o due, il ritardo tra il comando e la voce è per lo più il round trip di rete.
5. Eseguilo
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
Entra in un canale vocale del tuo server e digita /tts seguito dal tuo testo. Il bot entra, legge e aspetta il comando successivo. Fermalo con Ctrl+C. Per un bot che resta online quando chiudi il portatile, eseguilo su un piccolo host sempre attivo; va bene qualsiasi cosa mantenga vivo un processo Node.js.
Se preferisci invitare un bot ospitato
Esistono bot TTS pubblici che puoi invitare senza eseguire nulla. Giudicali su tre cose: come suonano le voci, quali permessi chiede l’invito (a un bot TTS servono Connect e Speak, e poco altro) e quanto costano oltre il livello gratuito. Il rovescio della medaglia è il controllo. Un bot ospitato legge il testo del tuo server sull’infrastruttura di qualcun altro, e i suoi comandi e le sue voci sono quelli scelti dal suo autore. Se ti sta stretto, le 80 righe qui sopra sono la via d’uscita.
Portalo oltre
- Una voce per persona: mantieni una mappa da ID utente a ID voce, così i messaggi di ogni membro vengono letti con la sua voce.
- Altre lingue: Sonic 3.6 supporta 44 lingue con un solo modello. Passa un codice
languagee una voce corrispondenti. - Leggi un canale automaticamente: ascolta
messageCreatein un canale designato invece di un comando slash. Serve l’intent privilegiato Message Content, attivato nella pagina Bot del Developer Portal. - Un bot che risponde: questo legge ciò che digiti, non conversa. Per un agente vocale che ascolta, pensa e risponde, vedi Cartesia Managed Agents.