Discord TTSボット:追加するか、自分で作るか

Rene, Kabir Goel 
Discord TTSボット:追加するか、自分で作るか

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で実行・検証済みです。

前提条件

  1. Node.js 20以降。
  2. Discordアカウントと、ボットを追加できるManage Server権限のあるサーバー。
  3. ダッシュボードで取得するCartesia APIキー。無料プランで構いません。
  4. Voice Libraryの音声ID。好きな音声を開いてIDをコピーしてください。

1. Discordアプリケーションを作成する

  1. Discord Developer Portalを開き、New Applicationを選びます。入力した名前が、ボット参加時に表示される名前になります。
  2. BotページでReset Tokenを選び、トークンをコピーします。トークンはボットのパスワードです。環境変数に入れ、コードにもgitにも書かないでください。
  3. このボットに特権インテントは不要です。スラッシュコマンドとボイスステートは、Presence、Server Members、Message Contentの各トグルなしで動くので、オフのままにします。
  4. ボットをサーバーに招待します。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をどうぞ。

関連ドキュメント

よくある質問

Discord TTSボットとは?

Discord TTSボットは、ボイスチャンネルに参加してチャットのテキストを音声で読み上げるボットアカウントです。Discord標準の/ttsコマンドは、テキストチャンネルのメッセージをシステム音声で読み上げます。一方ボットは、ボイスチャンネルで話すことができ、カスタムクローン音声を含め、コードが対応する任意の音声を使えます。

自分のDiscordサーバーにTTSボットを追加するには?

ホスト済みボットを招待リンクから追加するか、自分で作ってホストします。ホスト済みボットの場合は、ConnectとSpeakの権限だけを求めているか確認してください。自分のボットの場合は、Discord Developer Portalでアプリケーションを作成し、botとapplications.commandsのスコープで招待し、このガイドのコードを実行します。

ボットは自分でホストする必要がある?

自分で作る場合は、はい。Discordはコマンドをあなたのコードに届け、コードが返す音声を再生しますが、そのコードはどこかで動いている必要があります。テストならノートPCで十分です。常時オンラインのボットにするには、常時稼働する小さなホストで実行します。Node.jsプロセスを生かし続けられるプロバイダならどれでも使えます。

チャンネルの全メッセージを自動で読み上げさせられる?

はい。/ttsコマンドの代わりに、あるいは併用して、指定したチャンネルでmessageCreateイベントを監視します。メッセージ内容の読み取りには特権インテントのMessage Contentが必要で、Discord Developer PortalのBotページで有効にします。

Discord TTSボットはどの音声を使える?

Cartesia Voice Libraryにある任意の音声が使えます。短いサンプルからクローンした音声も含みます。音声のIDを環境変数CARTESIA_VOICE_IDにコピーして、ボットを再起動してください。

Discord TTSボットは無料?

Discordはボットに料金を課しません。Cartesiaの無料プランには月20,000クレジットが含まれ、一般的なサーバーの通常利用はそれでまかなえます。それ以上の利用は、有料プランでクレジット単位の課金になります。