Discord TTSボットはボイスチャンネルに参加してテキストを読み上げるので、マイクで話せない人や、話すより打つのが速い人でも、ちゃんと声を届けられます。手に入れる方法は2つあります。誰かが運用しているホスト済みボットを招待するか、自分で作るかです。このガイドでは、discord.jsとCartesiaで約80行のJavaScriptのボットを作り、最後に、他人のボットを招待したい場合の確認点をまとめます。
Discord標準のテキスト読み上げだけで足りるなら、それは別の機能です。テキストチャンネルのメッセージをシステム音声で読み上げるもので、ボットは関係ありません。Discordでテキスト読み上げを使う方法で説明しています。標準コマンドはボイスチャンネルで話せず、音声も変えられません。ボットはまさにその隙間を埋めます。
作るもの
スラッシュコマンドが2つあるボットです。/ttsはあなたのボイスチャンネルに参加してCartesiaの音声でテキストを読み上げ、/stopは切断します。以下のファイルはすべて、2026年10月に現行のSDKで実行しました。Cartesia APIリクエスト、音声変換、Opusエンコード、コマンド登録は、Node 24上のdiscord.js 14.27、@discordjs/voice 0.19.2、@cartesia/cartesia-js 4.2.0で実行・検証済みです。
前提条件
- Node.js 20以降。
- Discordアカウントと、ボットを追加できるManage Server権限のあるサーバー。
- ダッシュボードで取得するCartesia APIキー。無料プランで構いません。
- Voice Libraryの音声ID。好きな音声を開いてIDをコピーしてください。
1. Discordアプリケーションを作成する
- Discord Developer Portalを開き、New Applicationを選びます。入力した名前が、ボット参加時に表示される名前になります。
- BotページでReset Tokenを選び、トークンをコピーします。トークンはボットのパスワードです。環境変数に入れ、コードにもgitにも書かないでください。
- このボットに特権インテントは不要です。スラッシュコマンドとボイスステートは、Presence、Server Members、Message Contentの各トグルなしで動くので、オフのままにします。
- ボットをサーバーに招待します。OAuth2のURL Generatorを開き、
botとapplications.commandsのスコープを選び、次にConnectとSpeakの権限を選びます。生成されたURLを開いてサーバーを選びます。
2. プロジェクトをインストールする
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
各パッケージの役割:discord.jsはDiscord APIとやり取りします。@discordjs/voiceはボイス接続を管理します。@discordjs/opusは生の音声をDiscordが流すOpusフレームに変換します。libsodium-wrappersはボイスパケットを暗号化します。@cartesia/cartesia-jsはCartesia SDKです。
リストにffmpegはありません。このボットはDiscordが期待するPCM形式をそのまま作るので、@discordjs/opusだけでエンコードできます。ffmpegが必要になるのは、MP3ファイルのようなトランスコードが必要な形式を@discordjs/voiceに渡すときだけです。
3. スラッシュコマンドを登録する
誰かが/ttsや/stopを入力する前に、Discordがそれらを知っている必要があります。これを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.`);
ボットの認証情報を環境変数に入れて、一度実行します:
export DISCORD_TOKEN="your-bot-token"
export DISCORD_CLIENT_ID="your-application-id"
export DISCORD_GUILD_ID="your-server-id"
node deploy-commands.js
このスクリプトはギルドコマンドを登録します。ギルドコマンドは即時反映されるので、完了した時点でサーバー内でコマンドが使えます。DISCORD_CLIENT_IDはアプリのGeneral InformationページにあるApplication ID、DISCORD_GUILD_IDはサーバーのIDです。Discordの設定でDeveloper Modeを有効にし、サーバーを右クリックしてコピーします。
4. ボットを書く
これを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);
}
このファイルで特に見るべき点は3つあります:
音声フォーマット。 Discordのボイスは48 kHzステレオのOpusです。tts.generateの呼び出しはSonicにcontainer: "raw"、encoding: "pcm_s16le"、sample_rate: 48000を要求します。これはDiscord固有のPCM形式で、チャンネルが1つ足りません。MonoToStereoが16ビットサンプルを両チャンネルに複製し、@discordjs/voiceが@discordjs/opusでOpusにエンコードします。最初から48 kHzを要求するので、途中でリサンプリングは起きません。
キュー。 2人が同時に/ttsを実行すると音声が重なるので、ギルドごとにキューを持ちます。playNextは1行取り出して合成し、再生します。AudioPlayerStatus.Idleはクリップ終了時に発火して次の行を取り出します。/stopはボットのチャンネル内からだけ使え、プレーヤーを停止して接続を破棄します。ストリームの途中で失敗した行はプレーヤーのエラーリスナーに届いてスキップされ、Sonicの処理中に差し替わったキューは、次の行に重ねて再生されず破棄されます。
時間はどこに消えるか。 Sonicの最初の音声までの時間は90 ms未満で、synthesizeはストリームを返すので、クリップ全体を待たずに最初のチャンクから再生が始まります。1、2文なら、コマンドから音声までの遅延はほぼネットワークの往復分だけです。
5. 実行する
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
サーバーのボイスチャンネルに参加して、/ttsに続けてテキストを入力します。ボットが参加して読み上げ、次のコマンドを待ちます。Ctrl+Cで停止します。ノートPCを閉じてもオンラインのままにするには、常時稼働の小さなホストで実行します。Node.jsプロセスを生かし続けられるものなら何でも構いません。
ホスト済みボットを招待したい場合
何も実行せずに招待できる公開のTTSボットもあります。判断基準は3つです。音声の聞こえ方、招待が求める権限(TTSボットに必要なのはConnectとSpeakで、それ以外はほぼ不要)、無料枠を超えたときの料金です。引き換えになるのはコントロールです。ホスト済みボットは他人のインフラでサーバーのテキストを読み、コマンドと音声は作者が選んだものです。物足りなくなったら、上の80行が抜け道です。
さらに発展させる
- 人ごとの音声:ユーザーIDから音声IDへのマップを用意すれば、メンバーごとのメッセージをそれぞれの音声で読めます。
- 他の言語:Sonic 3.6は1つのモデルで44言語に対応しています。合う
languageコードと音声を渡してください。 - チャンネルの自動読み上げ:スラッシュコマンドの代わりに、指定チャンネルで
messageCreateを監視します。Developer PortalのBotページで有効にする、特権インテントのMessage Contentが必要です。 - 会話するボット:このボットは入力を読み上げるだけで、会話はしません。聞いて、考えて、答える音声エージェントにはCartesia Managed Agentsをどうぞ。