Discord TTS bot: add one or build your own

Rene, Kabir Goel 
Discord TTS bot: add one or build your own

A Discord TTS bot joins a voice channel and reads text aloud, so people who can’t talk on mic, or just type faster than they talk, still get heard. There are two ways to get one: invite a hosted bot that someone else runs, or build your own. This guide builds one with discord.js and Cartesia in about 80 lines of JavaScript, then covers what to check if you’d rather invite somebody else’s.

If you only need Discord’s built-in text-to-speech, that’s a different feature: it reads messages in text channels with the system voice, and no bot is involved. How to use text-to-speech on Discord covers it. The built-in command can’t speak in voice channels and can’t change its voice, which is the gap a bot fills.

What you’ll build

A bot with two slash commands: /tts joins your voice channel and reads your text with a Cartesia voice, and /stop disconnects it. Every file below ran against the current SDKs in October 2026: the Cartesia API request, the audio conversion, the Opus encoding and the command registration were all executed and checked on Node 24 with discord.js 14.27, @discordjs/voice 0.19.2 and @cartesia/cartesia-js 4.2.0.

Prerequisites

  1. Node.js 20 or later.
  2. A Discord account, and a server where you have the Manage Server permission so you can add a bot.
  3. A Cartesia API key from the dashboard. The free plan works.
  4. A voice ID from the Voice Library. Open a voice you like and copy its ID.

1. Create the Discord application

  1. Open the Discord Developer Portal and choose New Application. The name you enter is the name people see when the bot joins.
  2. On the Bot page, choose Reset Token and copy the token. The token is the bot’s password: keep it in an environment variable, out of code and out of git.
  3. This bot needs no privileged intents. Slash commands and voice state work without the Presence, Server Members or Message Content toggles, so leave them off.
  4. Invite the bot to your server. Under OAuth2, open the URL Generator, select the bot and applications.commands scopes, then the Connect and Speak permissions. Open the generated URL and pick your server.

2. Install the project

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

What each package does: discord.js talks to the Discord API. @discordjs/voice manages the voice connection. @discordjs/opus turns raw audio into the Opus frames Discord streams. libsodium-wrappers encrypts the voice packets. @cartesia/cartesia-js is the Cartesia SDK.

There is no ffmpeg in the list. The bot produces exactly the PCM format Discord expects, so @discordjs/opus handles encoding on its own. ffmpeg only enters the picture when you hand @discordjs/voice a format it has to transcode, like an MP3 file.

3. Register the slash commands

Discord has to know about /tts and /stop before anyone can type them. Save this as 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.`);

Run it once with the bot’s credentials in the environment:

export DISCORD_TOKEN="your-bot-token"
export DISCORD_CLIENT_ID="your-application-id"
export DISCORD_GUILD_ID="your-server-id"
node deploy-commands.js

The script registers guild commands, which update immediately, so the commands work in your server as soon as it finishes. DISCORD_CLIENT_ID is the Application ID on the app’s General Information page, and DISCORD_GUILD_ID is your server’s ID; turn on Developer Mode in Discord’s settings, then right-click the server to copy it.

4. Write the bot

Save this as 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);
}

Three things in that file earn a closer look:

The audio format. Discord voice is 48 kHz stereo Opus. The tts.generate call asks Sonic for container: "raw", encoding: "pcm_s16le" and sample_rate: 48000, which is Discord’s own PCM layout, one channel short. MonoToStereo duplicates each 16-bit sample into both channels, and @discordjs/voice encodes the result to Opus with @discordjs/opus. Asking for 48 kHz up front means nothing has to resample on the way.

The queue. Two people running /tts at once would overlap audio, so each guild gets a queue. playNext shifts one line off, synthesizes it, and plays it; AudioPlayerStatus.Idle fires when a clip ends and pulls the next line. /stop works only from inside the bot’s channel: it stops the player and destroys the connection. A line that fails mid-stream reaches the player’s error listener and is skipped, and a queue replaced while Sonic is still working is dropped instead of played over the next one.

Where the time goes. Sonic’s time to first audio is under 90 ms, and synthesize returns a stream, so playback starts on the first chunks instead of waiting for the whole clip. For a sentence or two, the delay between the command and the voice is mostly the network round trip.

5. Run it

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

Join a voice channel in your server and type /tts, then your text. The bot joins, reads, and waits for the next command. Stop it with Ctrl+C. For a bot that stays online when your laptop closes, run it on a small always-on host; anything that keeps a Node.js process alive works.

If you’d rather invite a hosted bot

Public TTS bots exist that you can invite without running anything. Judge them on three things: how the voices sound, what permissions the invite asks for (a TTS bot needs Connect and Speak, and not much else), and what they cost past the free tier. The tradeoff is control. A hosted bot reads your server’s text on someone else’s infrastructure, and its commands and voices are whatever its author picked. If you outgrow it, the 80 lines above are the way out.

Take it further

  • A voice per person: keep a map from user ID to voice ID, so each member’s messages read in their own voice.
  • Other languages: Sonic 3.6 supports 44 languages from one model. Pass a matching language code and voice.
  • Read a channel automatically: listen for messageCreate in one designated channel instead of a slash command. That needs the privileged Message Content intent, enabled on the Bot page of the Developer Portal.
  • A bot that talks back: this one reads what you type, it doesn’t converse. For a voice agent that listens, thinks and answers, see Cartesia Managed Agents.

FAQs

What is a Discord TTS bot?

A Discord TTS bot is a bot account that joins a voice channel and reads text from chat aloud. Discord's built-in /tts command reads messages in text channels with the system voice; a bot can speak in voice channels and use any voice its code supports, including custom cloned voices.

How do I add a TTS bot to my Discord server?

Either invite a hosted bot through its invite link, or build and host your own. For a hosted bot, check that it only asks for the Connect and Speak permissions. For your own bot, the steps are creating an application in the Discord Developer Portal, inviting it with the bot and applications.commands scopes, and running the code in this guide.

Do I have to host the bot myself?

If you build it, yes. Discord delivers the commands to your code and plays the audio your code sends back, but the code has to run somewhere. A laptop is fine for testing. For a bot that stays online, run it on a small always-on host; any provider that keeps a Node.js process alive works.

Can the bot read every message in a channel automatically?

Yes. Listen for the messageCreate event in one designated channel instead of, or alongside, the /tts command. Reading message content requires the privileged Message Content intent, which you enable on the Bot page of the Discord Developer Portal.

Which voices can a Discord TTS bot use?

Any voice in the Cartesia Voice Library, including a voice you clone from a short sample. Copy the voice's ID into the CARTESIA_VOICE_ID environment variable and restart the bot.

Is a Discord TTS bot free?

Discord charges nothing for bots. Cartesia's free plan includes 20,000 credits a month, which covers regular use in a typical server. Heavier use bills per credit on the paid plans.