Skip to main content
The audio layer is opt-in. It needs the native media engine installed at an exact version and patched, which is a one-time setup step in your own app.
Do the runtime setup first. An unpatched engine cannot install a frame key, so a bot that skips the patch appears to work and publishes audio nobody can decrypt. There is no error and no event. The only symptom is silence.

A publish-only bot, end to end

The five steps, assuming a logged-in client. A complete runnable bot is further down the page.

VoiceMediaSession.attach

string
required
VoiceConnection.url.
string
required
VoiceConnection.token. A live credential. Never log it.
string
required
Your bot’s Cloak user id, from client.user.id. The media identity is derived from it, and attach() asserts the SFU agrees.
string
required
The server the voice channel belongs to. The key derivation binds it.
VoiceKey
required
The scope-tagged key from acquireVoiceKey(). Used for the scope and the 32-byte length check. Its bytes are not what gets installed.
VoiceKeyAccess
required
Where frame keys come from. Pass client.voiceKeys.
boolean
Whether the bot joined deafened. Purely diagnostic: it lets attach() warn about the one combination that silently breaks a listening bot.
number
default:"48000"
number
default:"2"
boolean
default:"false"
Logs which key fingerprint was installed under which identity. Never logs key material.

What attach enforces

Every step fails closed, because every failure mode in this path is otherwise silent.
1

Load the engine

An actionable environment error before anything else happens.
2

Validate the key

Exactly 32 bytes, and a scope that came from the authenticated tag. A key that carries no scope is refused rather than defaulted.
3

Connect

Subscription is derived from the scope, never from a free-standing flag.
4

Assert the identity

If the SFU knows this session by a different name than the one the key was sealed for, attach() fails. Publishing under a name nobody derives a key for produces audio nobody can decrypt.
5

Install the bot's own key first

In both scopes.
6

Room scope only: install every other identity

Everyone already in the channel, plus a hook so late joiners get one too.
7

Publish, only now

The local frame encryptor is created at publish time. With no key filed for the bot’s identity, frames are dropped rather than sent in the clear.

Session surface

Playing audio

play() resolves when the resource ends or when stop() is called, and rejects when the source throws. It refuses to start a second resource while one is playing: call stop() first to switch tracks.
Do not add a pacer. The player awaits each frame capture, and the native queue is the only scheduler. Do not add a 20 ms timer, a ring buffer, or an Opus encoder. The engine encodes internally, and a manual pacer racing the native queue produces stutter that only shows up on a real call.
Player events: start, end, and error.
A successful play() proves the bytes reached the encoder. It proves nothing about whether any member decrypted them. See the blind spot.

Audio resources

An AudioResource is any async iterable of interleaved 16-bit PCM, plus its format.
Each chunk must contain a whole number of frames. A partial frame would shift channel parity for the entire rest of the stream, so the player refuses it rather than truncating.

pcmChunks

Adapts a byte stream into frame-aligned chunks. Use it for anything that produces raw bytes, such as a text-to-speech process or a socket. It handles two silent corruption bugs for you: a chunk that ends mid-sample, and a buffer whose offset is not two-byte aligned.

ffmpegResource

Decodes anything ffmpeg understands. Synchronous: it spawns the decoder and hands back the resource immediately.
ffmpegResource takes a file path or a direct media URL. Hand it a YouTube, SoundCloud, or Bandcamp watch page and ffmpeg, which has no site extractors, decodes the HTML. This is the single commonest voice-bot mistake. Use ytdlpResource() instead.
A failed decode is an error, not a short song. An unreadable input makes ffmpeg write nothing to standard output and exit non-zero, which to a naive pump looks exactly like a clean end of stream. play() checks the exit code and throws a CloakEnvironmentError carrying the head of ffmpeg’s own stderr as its diagnosis. A deliberate stop() is exempt. The error also distinguishes “no audio at all” from “truncated N bytes in”, which usually means a dropped source rather than a wrong argument.

ytdlpResource and resolveMediaUrl

ytdlpResource() is resolveMediaUrl() followed by ffmpegResource(), so it accepts every FfmpegResourceOptions field plus:
The resolved URL is signed, expiring, and usually bound to this host’s IP address. Resolve at play time. Never persist it, and never hand it to another machine.
--no-playlist is passed by default, so a link to a track that happens to sit in a playlist resolves that one track. When yt-dlp fails, the error’s remedy names the usual suspects in order: it is out of date (it breaks whenever a site changes), the media is private, region-locked or age-gated, or the URL is not a media page at all.
Any http(s) URL can go through yt-dlp. Its generic extractor passes a direct media URL straight through, so you do not have to guess which kind you were handed.

Stopping and tearing down

Order matters. Detach the media before dropping the control-plane membership, or the SFU keeps a publisher for a member who has left.
detach() does not clear keys as a security measure. The process already held them, and forgetting a key it has already used proves nothing. Tearing the room down is, however, the only honest response to a voice_listen revoke, because the engine has no key-removal call.

Listening to members

When a server grants voice_listen, the key lane hands your bot the room key and attach() subscribes and installs a derived key for every participant. Two rules change:
  • Join non-deafened. The subscribe grant is fixed at join time, so a deafened listening bot hears silence regardless of its keys.
  • Handle the scope in your key update handler. A grant or revoke arrives at the same key version, and setVoiceKey() detaches and throws on a scope change.
Read Voice keys and scopes before you ship a bot that listens.

A complete music bot

A slash command that joins whichever voice channel the invoker is sitting in.
The SDK repo ships this as examples/dj-bot.ts. Run it with npx tsx examples/dj-bot.ts. The older examples/music-bot.ts (npm run example:music) predates client.voiceKeys and reaches the key lane through an internals seam that is explicitly not stable across versions. Prefer the shape above.

Next

Voice runtime

The engine patch, the executables, and a symptom-to-cause table.

Voice keys and scopes

Re-pinning, scope changes, and what the keys do not buy.

Voice reference

Every option type and constant on this page.

Joining voice

The control plane that produces conn.