Discord TTS 봇: 추가하거나 직접 만들기

Rene, Kabir Goel 
Discord TTS 봇: 추가하거나 직접 만들기

Discord TTS 봇은 음성 채널에 참여해 텍스트를 소리 내어 읽어주므로, 마이크로 말할 수 없는 사람이나 말보다 타이핑이 빠른 사람도 목소리를 전할 수 있습니다. 봇을 얻는 방법은 두 가지입니다. 다른 사람이 운영하는 호스팅된 봇을 초대하거나, 직접 만드는 것입니다. 이 가이드에서는 discord.js와 Cartesia로 약 80줄의 JavaScript 봇을 만들고, 이어서 남의 봇을 초대하고 싶은 경우에 확인할 것을 정리합니다.

Discord 내장 텍스트 음성 변환만 필요하다면, 그것은 별개의 기능입니다. 텍스트 채널의 메시지를 시스템 음성으로 읽는 기능으로 봇은 관여하지 않습니다. Discord에서 텍스트 음성 변환을 사용하는 방법에서 다룹니다. 내장 명령은 음성 채널에서 말할 수도 없고 음성을 바꿀 수도 없는데, 봇이 바로 그 빈틈을 메웁니다.

만들 것

슬래시 명령 두 개를 가진 봇입니다. /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);
}

이 파일에서 특히 살펴볼 부분은 세 가지입니다.

오디오 형식. Discord 음성은 48 kHz 스테레오 Opus입니다. tts.generate 호출은 Sonic에 container: "raw", encoding: "pcm_s16le", sample_rate: 48000을 요청하는데, 이는 Discord 고유의 PCM 레이아웃에서 채널 하나가 빠진 형태입니다. MonoToStereo가 각 16비트 샘플을 양쪽 채널로 복제하고, @discordjs/voice가 @discordjs/opus로 그 결과를 Opus로 인코딩합니다. 처음부터 48 kHz를 요청하므로 중간에 리샘플링이 일어나지 않습니다.

큐. 두 사람이 동시에 /tts를 실행하면 오디오가 겹치므로, 길드마다 큐를 둡니다. playNext는 한 줄을 꺼내 합성하고 재생합니다. AudioPlayerStatus.Idle은 클립이 끝날 때 발생해 다음 줄을 가져옵니다. /stop은 봇의 채널 안에서만 동작하며, 플레이어를 멈추고 연결을 파괴합니다. 스트림 중간에 실패한 줄은 플레이어의 오류 리스너에 전달되어 건너뛰고, Sonic이 아직 작업하는 동안 교체된 큐는 다음 줄에 겹쳐 재생되지 않고 버려집니다.

시간은 어디에 쓰이는가. Sonic의 첫 오디오까지의 시간은 90ms 미만이고 synthesize는 스트림을 반환하므로, 전체 클립을 기다리지 않고 첫 청크부터 재생이 시작됩니다. 한두 문장이면 명령과 음성 사이의 지연은 대부분 네트워크 왕복입니다.

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로 중지합니다. 노트북을 닫아도 온라인 상태를 유지하려면 상시 가동되는 작은 호스트에서 실행하세요. Node.js 프로세스를 유지할 수 있는 곳이면 어디든 됩니다.

호스팅된 봇을 초대하고 싶다면

아무것도 실행하지 않고 초대할 수 있는 공개 TTS 봇도 있습니다. 세 가지로 판단하세요. 음성이 어떻게 들리는지, 초대가 요청하는 권한(TTS 봇에는 Connect와 Speak이 필요하고 그 외는 거의 필요 없음), 무료 등급 이후의 비용입니다. 그 대가는 제어권입니다. 호스팅된 봇은 다른 사람의 인프라에서 서버의 텍스트를 읽고, 명령과 음성은 작성자가 정한 것입니다. 부족해지면 위의 80줄이 출구입니다.

더 발전시키기

  • 사람별 음성: 사용자 ID에서 음성 ID로의 맵을 두면 각 멤버의 메시지를 각자의 음성으로 읽을 수 있습니다.
  • 다른 언어: Sonic 3.6은 하나의 모델로 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는 명령을 코드에 전달하고 코드가 반환하는 오디오를 재생하지만, 그 코드는 어딘가에서 실행되어야 합니다. 테스트용이라면 노트북으로 충분합니다. 항상 온라인인 봇을 원하면 상시 가동되는 작은 호스트에서 실행하세요. 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크레딧이 포함되어 있어 일반적인 서버 사용은 충분히 커버됩니다. 더 많은 사용량은 유료 플랜에서 크레딧 단위로 청구됩니다.