baff.gg Docs Developer Portal

Building bots

Voice for bots

Join voice channels and play audio, like a music bot. Bots can talk but never listen.

Your bot can join a voice channel and play audio into it: music, sound effects, text to speech. baff.gg voice runs on LiveKit, so the audio itself goes through LiveKit's SDK; the baff.gg API hands you the address and a ticket to get in.

What bots can and can't do

  • Speak only. A bot can send one audio track (its "microphone"). It can't turn on a camera or share a screen.
  • Always deafened. A bot never receives anyone's audio or video. This isn't a setting: the ticket it gets can't subscribe to anything, so LiveKit itself refuses. Everyone sees the bot with the deafened icon and the BOT tag.
  • One voice channel per server. A bot can be in calls in several servers at once, but only one channel in each. Joining another channel in the same server moves it.
  • Connected to the gateway. A bot must have its gateway WebSocket open to join, and it's taken out of every call when that connection closes. A bot that crashes never stays stuck in a channel.

People in the call can turn your bot down or mute it just for themselves (right-click it, User Volume), like anyone else.

Permissions

In the voice channel, your bot needs:

PermissionWhy
View ChannelTo see the channel at all
ConnectTo join it
SpeakTo play audio. Bots without it can't join (people without it join muted, but a bot has nothing else to do there)

Use Soundboard matters only if your bot plays Soundboard sounds. Video, Baffle and other permissions don't matter for bots in voice. See Permissions for the bits. Channel overrides apply as usual, so a server can keep your bot out of some channels.

Join a voice channel

http
POST https://baff.gg/api/channels/{channelId}/voice
Authorization: Bot bfx_4f1c...
json
{
	"url": "wss://baff.gg/livekit",
	"token": "eyJhbGciOiJIUzI1NiIs...",
	"canSpeak": true,
	"canVideo": false,
	"deafened": true,
	"room": "voice:103700000000000005",
	"identity": "103712345678901248",
	"serverId": "103700000000000000",
	"channelId": "103700000000000005"
}
FieldWhat it is
urlThe LiveKit server to connect to
tokenYour ticket into this channel's room. It must be used within 10 minutes; the call itself can last as long as you like. Ask again for a new one if you reconnect later
canSpeakAlways true for bots (they can't join without Speak)
canVideoAlways false for bots
deafenedAlways true for bots
room, identityThe LiveKit room name and your bot's identity in it (its user id)
serverId, channelIdWhere you joined

Everyone who can see the channel, your bot included, gets a VOICE_STATE_UPDATE on the gateway:

json
{
	"t": "VOICE_STATE_UPDATE",
	"d": {
		"userId": "103712345678901248",
		"serverId": "103700000000000000",
		"channelId": "103700000000000005",
		"muted": false,
		"deafened": true,
		"selfVideo": false,
		"selfStream": false
	}
}

channelId is null when someone leaves. You also get these for people joining and leaving channels you can see, so a bot can tell when its channel empties out. GET https://baff.gg/api/servers/{serverId}/voice-states lists who's in which channel right now.

Errors

StatusCodeWhen
400not_voiceThe channel isn't a voice channel
403missing_permissionNo Connect or no Speak in that channel
404unknown_channelThe channel doesn't exist or your bot can't see it
409gateway_requiredYour bot isn't connected to the gateway
503voice_disabledVoice isn't set up on this baff.gg server

Mute and unmute

http
PATCH https://baff.gg/api/voice
Authorization: Bot bfx_4f1c...
Content-Type: application/json

{ "muted": true, "serverId": "103700000000000000" }

This only changes the icon others see (stop sending audio to actually go quiet). Leave out serverId to change every call your bot is in. {"deafened": false} answers 403 bots_always_deafened, and selfVideo / selfStream answer 403 bots_cant_use_video.

Soundboard

People in a call can play short Soundboard sounds for everyone in it. Sounds never travel through LiveKit: when someone plays one, everyone in that call (your bot too) gets a gateway event and each app plays the file itself.

json
{
	"t": "SOUNDBOARD_PLAY",
	"d": {
		"channelId": "103700000000000010",
		"serverId": "103700000000000000",
		"userId": "103700000000000042",
		"sound": { "id": "104200000000000000", "serverId": "103700000000000000", "name": "Air horn", "emoji": null, "volume": 1, "durationSecs": 1.8, "url": "https://cdn.baff.gg/soundboard/104200000000000000.mp3", "uploader": "Ana" }
	}
}

Your bot can play one too, while it's in the call (it needs Speak and Use Soundboard in the channel):

http
POST https://baff.gg/api/channels/{channelId}/soundboard
Authorization: Bot bfx_4f1c...
Content-Type: application/json

{ "soundId": "104200000000000000" }

Answers 204. The sound must belong to a server your bot is in (any server, not only this one). Each player gets one sound per 3 seconds (429 soundboard_cooldown). Other errors: 409 not_in_call, 404 unknown_sound, 403 missing_permission.

Sounds are MP3, at most 5 seconds, all brought to the same loudness when they're uploaded; volume (0 to 1) is set per sound on top of that. Each server has up to 24:

RouteDoes
GET /api/servers/{serverId}/soundboard-soundsThe server's sounds
GET /api/users/@me/soundboard-soundsSounds from every server you're in
POST /api/servers/{serverId}/soundboard-soundsUpload one: multipart/form-data with name (2 to 32 characters), optional emoji (Unicode, or name:id), optional volume (0 to 1, default 1) and file (MP3, OGG or WAV, up to 512 KB and 5 seconds). Needs Manage Expressions
PATCH /api/servers/{serverId}/soundboard-sounds/{soundId}Change name, emoji (null removes it) or volume. Needs Manage Expressions
DELETE /api/servers/{serverId}/soundboard-sounds/{soundId}Delete it. Needs Manage Expressions

Members get SERVER_SOUNDBOARD_UPDATE ({ serverId, sounds }) whenever the list changes.

Leave

http
DELETE https://baff.gg/api/voice?serverId=103700000000000000
Authorization: Bot bfx_4f1c...

Answers 204. baff.gg ends your bot's LiveKit connection for you (your SDK gets a "Disconnected" event) and tells everyone it left. Leave out serverId to leave every call at once.

Your bot is also taken out of a call when:

  • its gateway connection closes,
  • it's kicked or banned from the server, or removed from it,
  • its token is reset on the Developer Portal,
  • it joins another voice channel in the same server.

Example: play a WAV file

This Node bot connects to the gateway, joins a voice channel, plays a WAV file and leaves. It uses LiveKit's Node SDK:

bash
npm install @livekit/rtc-node
BOT_TOKEN=bfx_... node play.mjs 103700000000000005 song.wav

The WAV must be 16-bit PCM (the most common kind). See the next section for anything else.

js
// play.mjs: node play.mjs <voiceChannelId> <file.wav>
import { readFileSync } from 'node:fs';
import { AudioFrame, AudioSource, LocalAudioTrack, Room, RoomEvent, TrackPublishOptions, TrackSource } from '@livekit/rtc-node';

const API = 'https://baff.gg/api';
const GATEWAY = 'wss://baff.gg/api/gateway';
const TOKEN = process.env.BOT_TOKEN;
const auth = { Authorization: `Bot ${TOKEN}` };
const [channelId, file] = process.argv.slice(2);

// Reads a 16-bit PCM WAV: its sample rate, channel count and samples (interleaved).
function readWav(path) {
	const buf = readFileSync(path);
	if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') throw new Error('Not a WAV file');
	let format;
	for (let at = 12; at + 8 <= buf.length; ) {
		const id = buf.toString('ascii', at, at + 4);
		const size = buf.readUInt32LE(at + 4);
		const body = at + 8;
		if (id === 'fmt ') {
			format = { pcm: buf.readUInt16LE(body) === 1, channels: buf.readUInt16LE(body + 2), sampleRate: buf.readUInt32LE(body + 4), bits: buf.readUInt16LE(body + 14) };
		} else if (id === 'data') {
			if (!format?.pcm || format.bits !== 16) throw new Error('Only 16-bit PCM WAV files are supported');
			const data = buf.subarray(body, body + size);
			const samples = new Int16Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.length - (data.length % 2)));
			return { ...format, samples };
		}
		at = body + size + (size % 2);
	}
	throw new Error('No audio in this WAV file');
}

// 1. Connect to the gateway. A bot has to be connected to join voice, and closing this socket
//    takes it out of its calls.
const gateway = new WebSocket(GATEWAY, { headers: auth });
await new Promise((resolve, reject) => {
	gateway.addEventListener('message', (e) => JSON.parse(e.data).t === 'READY' && resolve());
	gateway.addEventListener('error', () => reject(new Error('Gateway connection failed')));
});

// 2. Ask baff.gg for a ticket into the channel.
const res = await fetch(`${API}/channels/${channelId}/voice`, { method: 'POST', headers: auth });
if (!res.ok) throw new Error(`Join failed: ${res.status} ${await res.text()}`);
const { url, token, serverId } = await res.json();

// 3. Connect to LiveKit. The ticket can't receive anything, so there's nothing to subscribe to.
const room = new Room();
let leaving = false;
room.on(RoomEvent.Disconnected, (reason) => {
	// Kicked from the server, moved, token reset, or we left: stop playing and quit.
	if (!leaving) console.log('Disconnected from voice, reason', reason);
	gateway.close();
	process.exit(0);
});
await room.connect(url, token, { autoSubscribe: false, dynacast: false });

// 4. Publish one audio track, fed from an AudioSource.
const wav = readWav(file);
const source = new AudioSource(wav.sampleRate, wav.channels);
const track = LocalAudioTrack.createAudioTrack('music', source);
await room.localParticipant.publishTrack(track, new TrackPublishOptions({ source: TrackSource.SOURCE_MICROPHONE }));

// 5. Feed it 10 ms at a time. captureFrame waits while its buffer is full, so this plays in
//    real time instead of all at once.
const perFrame = wav.sampleRate / 100;
const step = perFrame * wav.channels;
for (let i = 0; i + step <= wav.samples.length; i += step) {
	await source.captureFrame(new AudioFrame(wav.samples.slice(i, i + step), wav.sampleRate, wav.channels, perFrame));
}
await source.waitForPlayout();

// 6. Done: leave. baff.gg ends the LiveKit connection, which fires Disconnected above.
leaving = true;
await fetch(`${API}/voice?serverId=${serverId}`, { method: 'DELETE', headers: auth });
await room.disconnect();

A real bot keeps one gateway connection for everything (commands, events, voice) instead of opening one per song.

Making a music bot

Songs usually come as MP3, AAC, Opus or FLAC, not WAV. Let ffmpeg decode them into raw 48 kHz, 16-bit stereo PCM (s16le) and feed that to the AudioSource as it arrives:

js
import { spawn } from 'node:child_process';

const RATE = 48000;
const CHANNELS = 2;
const STEP = (RATE / 100) * CHANNELS * 2; // bytes in 10 ms

const source = new AudioSource(RATE, CHANNELS);
// ...publish a LocalAudioTrack made from it, as above...

async function play(input) {
	const ffmpeg = spawn('ffmpeg', ['-loglevel', 'error', '-i', input, '-f', 's16le', '-ar', String(RATE), '-ac', String(CHANNELS), 'pipe:1']);
	let pending = Buffer.alloc(0);
	for await (const chunk of ffmpeg.stdout) {
		pending = Buffer.concat([pending, chunk]);
		while (pending.length >= STEP) {
			const frame = pending.subarray(0, STEP);
			pending = pending.subarray(STEP);
			const samples = new Int16Array(frame.buffer.slice(frame.byteOffset, frame.byteOffset + STEP));
			await source.captureFrame(new AudioFrame(samples, RATE, CHANNELS, RATE / 100));
		}
	}
	await source.waitForPlayout();
}

Some tips:

  • Keep one AudioSource and one track for the whole session and play songs into it one after another. Skipping a song is ffmpeg.kill() plus source.clearQueue().
  • Volume: scale the samples before captureFrame (multiply and clamp to -32768..32767), or let listeners use User Volume.
  • Commands: register /play, /skip and /leave slash commands. The interaction tells you who asked; check GET https://baff.gg/api/servers/{serverId}/voice-states to join the channel they're in.
  • Only play what you're allowed to. Streaming music you don't have the rights to can break its license.