Voice
Bots can join voice channels to play audio and, if they're allowed, to hear the channel.
Voicaw's voice runs on LiveKit,
not Discord's voice protocol, so a bot asks the bot API for a LiveKit token and connects
with one of LiveKit's own SDKs. Discord voice libraries (@discordjs/voice,
discord.py's VoiceClient) don't work here yet.
Speaking and listening
Sending audio and hearing the others are separate permissions, and LiveKit enforces both:
- Connect to join the channel at all.
- Speak (Discord's bit 21) to send audio. Bots get it the usual way, @everyone included, as on Discord.
- Listen (Voicaw's own bit 61,
1 << 61) to hear the others. People have it through @everyone by default, but @everyone's Listen never counts for a bot: a bot hears a channel only when Listen comes from its own role, another role it has, or a channel override for one of those or for the bot itself. Tick "Listen (hear voice channels)" in the invite link builder to ask for it.
Everyone in the channel can see which bots can hear it: they have an ear next to their name. If a bot's permissions change while it's connected, LiveKit applies the change at once (it stops sending it audio when Listen is taken away). Bots send audio only: no video or screen sharing.
Joining
| Endpoint | Notes |
|---|---|
POST /channels/{channel}/voice | Voicaw's own. Needs View Channel and Connect in that voice channel. Optional body { "self_mute": false, "self_deaf": false }. Answers { url, token, room, identity, expires_in, speak, listen }: connect to url with token within expires_in seconds (600; once connected, LiveKit keeps the connection's token fresh). speak and listen say what the bot may do there. Joining another channel leaves the first. |
GET /guilds/{guild}/voice-states/@me | The bot's voice state (Discord's shape, plus listening). A user id instead of @me gives theirs, in channels the bot can see. |
PATCH /guilds/{guild}/voice-states/@me | self_mute, self_deaf (shown to everyone, like a person's), or channel_id: null to leave. |
DELETE /guilds/{guild}/voice-states/@me | Leaves the guild's voice channel. |
Once connected the bot is in the channel like anyone: bots with the Guild Voice States
intent get VOICE_STATE_UPDATE, and GUILD_CREATE's
voice_states lists who's in the voice channels the bot can see. In the
LiveKit room, each participant's identity is their Voicaw user id, so a track's
participant tells you who is speaking. Gateway opcode 4 (Discord's way to join voice) is
ignored.
Node (@livekit/rtc-node)
Playing a tone, and hearing the others (with Listen). Checked with @livekit/rtc-node against Voicaw:
import { Room, RoomEvent, AudioSource, AudioFrame, AudioStream, LocalAudioTrack,
TrackPublishOptions, TrackSource } from '@livekit/rtc-node';
const API = 'https://api.voicaw.com/api/v10';
const res = await fetch(`${API}/channels/${CHANNEL_ID}/voice`, {
method: 'POST',
headers: { Authorization: `Bot ${process.env.TOKEN}` },
});
const { url, token, listen } = await res.json();
const room = new Room();
// Others' audio (only arrives if the bot has Listen).
room.on(RoomEvent.TrackSubscribed, async (track, publication, participant) => {
for await (const frame of new AudioStream(track, 48000, 1)) {
// frame.data: 16-bit samples from participant.identity (a Voicaw user id)
}
});
await room.connect(url, token, { autoSubscribe: true });
// Sending audio (Speak): 10 ms frames of 48 kHz mono.
const source = new AudioSource(48000, 1);
const track = LocalAudioTrack.createAudioTrack('music', source);
const options = new TrackPublishOptions();
options.source = TrackSource.SOURCE_MICROPHONE;
await room.localParticipant.publishTrack(track, options);
let phase = 0;
setInterval(() => {
const samples = new Int16Array(480);
for (let i = 0; i < 480; i++) samples[i] = Math.sin((phase += 2 * Math.PI * 440 / 48000)) * 12000;
source.captureFrame(new AudioFrame(samples, 48000, 1, 480));
}, 10);
Python (livekit)
Listening, with LiveKit's livekit package. Checked against Voicaw:
import asyncio, os, aiohttp
from livekit import rtc
API = 'https://api.voicaw.com/api/v10'
async def main():
async with aiohttp.ClientSession() as http:
async with http.post(f'{API}/channels/{CHANNEL_ID}/voice',
headers={'Authorization': f"Bot {os.environ['TOKEN']}"}) as res:
info = await res.json()
room = rtc.Room()
async def hear(track, participant):
async for event in rtc.AudioStream(track):
... # event.frame.data: 16-bit samples from participant.identity
@room.on('track_subscribed')
def on_track(track, publication, participant):
if track.kind == rtc.TrackKind.KIND_AUDIO:
asyncio.ensure_future(hear(track, participant))
await room.connect(info['url'], info['token'])
try:
await asyncio.sleep(3600)
finally:
await room.disconnect() # disconnect before the program ends
asyncio.run(main())
LiveKit also has SDKs for Go, Rust and others; any of them can connect with the same
url and token.