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.
Microphone admission. A current Cloak backend mints a server-voice credential with the microphone withheld. The SFU grants it only after the bot reports its connection up, which attach() does through connectionReporter before it publishes, then waits for the grant to land. Leave the reporter out and there is no wait: the publish goes straight out, succeeds locally, and the SFU forwards nothing. Silence, no error, and only a warning under debug. Pass connectionReporter: client.voiceConnectionReporter() on every attach. It is a no-op when the bot is not in voice, so it is always safe. See Microphone admission.

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"
The publish format: what session.player expects from an AudioResource.
number
default:"2"
The publish channel count.
number
default:"48000"
Room scope only. The sample rate every received PcmFrame arrives at. The engine resamples. Fixed at attach. See Receiving audio.
number
default:"1"
Room scope only. The channel count of every received PcmFrame. The engine downmixes or upmixes. Fixed at attach. Mono is the default because a voice channel is mono at the source and speech-to-text input wants mono.
(state: 0 | 1) => void
The connection-state sender. Pass client.voiceConnectionReporter(). Not optional in practice: without it a server-voice credential stays microphone-clamped on a current backend. attach() calls it with 0 before the publish, once the identity and key checks have passed, re-sends it every second until the admission lands or admissionTimeoutMs runs out, and calls it with 1 then 0 around an engine reconnect. Never from detach(). A plain function, so the media module never imports Client.
number
default:"5000"
How long attach() waits for the microphone admission after reporting the connection up. It polls the local participant’s canPublish permission every 100 ms, the only signal the engine exposes. On timeout it still publishes once, because an older single-phase backend never flips the flag but its credential already allows publishing; if that publish rejects, attach() fails closed with an error naming the two-phase admission, connectionReporter, and this option. Ignored without a connectionReporter.
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

Report the connection up and wait for admission

With a connectionReporter. The report goes out through it, the reconnect bracket is wired, and attach() polls the local participant’s canPublish permission every 100 ms, re-sending the report every second, until it flips or admissionTimeoutMs (default 5 s) runs out. On a current backend this is what admits the microphone: the credential was minted without it, and the backend re-applies the bot’s durable grant when it sees the report. Without a reporter there is no wait, and attach() warns under debug.
8

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. After a timed-out wait the publish is still attempted once, because an older single-phase backend never flips the flag but its credential already allows it. If that publish rejects, attach() fails closed with an error naming the two-phase admission, connectionReporter, and admissionTimeoutMs. A publish that rejects after a successful admission is rethrown as is.

Microphone admission

A current Cloak backend admits a server-voice microphone in two phases. The join credential from joinVoice() carries no publish grant. After the media connection is up, the bot reports it ([621, 0] on the wire), and the backend re-resolves the bot’s durable grant and applies it through the media control plane. Only then does the SFU forward the bot’s audio. attach() does the reporting for you when you pass connectionReporter: client.voiceConnectionReporter(). The order is fixed, and it is the desktop client’s order: connect, install keys, report 0, wait for the admission, then publish. The wait polls the local participant’s canPublish permission every 100 ms (the engine exposes no permissions event, so polling is the honest option), re-sends the report every second while the flag stays false, and gives up after admissionTimeoutMs (default 5 s). Around an engine reconnect the session reports 1 (reconnecting) then 0 (recovered), which re-arms the backend’s reconnect grace and re-admits the microphone. detach() reports nothing; leaveVoice() is what tells the backend the session is gone. What happens at the end of the wait:
The wait is a bounded best effort, not a health check. Nothing confirms the admission reached the SFU beyond the canPublish flag, and a missing reporter produces the same silence as every other failure on this path. A bot that passes the reporter works on both a current two-phase backend and an older single-phase one.
The reporter is a no-op when the bot is not in voice, and in a DM call, which the backend does not clamp. Sending the report twice is harmless: the backend re-applies the same grant.

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() reports nothing to the backend about the connection; leaveVoice() is what ends the session server-side. 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.

Receiving audio

In room scope, the session hands decrypted member audio out as PCM frames. The security property, stated in full: a bot without voice_listen holds only its own frame key and is cryptographically unable to decrypt any member; a bot granted voice_listen can decrypt everyone in the channel, by design. Never state the first clause without the second. Every receive entry point is room scope only. In participant scope, session.on('audio', ...), session.audio(), and session.setSubscribed() all throw with the message “this session holds a participant-scope key and can decrypt nobody; receive needs voice_listen”. They never install a listener that quietly never fires.

Per participant

One audio event fires per subscribed remote audio track. Video tracks are ignored. The payload is a ParticipantAudio: the participant’s identity and a frames iterable of PcmFrame.
A PcmFrame is { data: Int16Array, sampleRate, channels, samplesPerChannel }, already resampled to the session’s receive format. data is a copy out of the engine, so it is safe to keep after the next frame arrives. When several consumers share a session, they all see the same data: read it, or convert it with pcmFrameBytes(), but do not write into it.

Everyone at once

session.audio() merges every live participant into one iterable, tagged by identity. Participants who arrive later join the merge, and one who leaves drops out of it. Any number of concurrent audio() consumers may run, and each sees every frame.

Opt out of one participant

In room scope every remote track is subscribed at connect. setSubscribed(identity, false) is how a bot stops hearing one person, and true brings them back.
It rejects when no participant with that identity is in the room. An identity is the participant’s normalized Cloak user id, which is what voiceJoin and voiceRoster() report as userId.

Piping to ffmpeg

pcmFrameBytes(frame) returns the frame as interleaved little-endian signed 16-bit bytes, which is the layout ffmpeg -f s16le, a WAV data chunk, and raw-PCM speech-to-text inputs expect. Here it feeds ffmpeg over stdin to write one file per participant.
The SDK ships no recorder, no file sink, and no transcription helper. pcmFrameBytes() is the whole of what it does with received audio. What a bot does with decrypted member audio is your business and your users’ consent problem, and the second clause of the security property still holds: a voice_listen bot decrypts everyone, by design.

The rules

  • Every iterable ends with a normal return, never a throw. That is true on track unsubscribe, on setSubscribed(identity, false), on detach(), and on the detach a scope change forces. A muted track yields nothing and stays open.
  • At most one live iterable per identity. A later re-subscribe for the same identity fires a new audio event with a new iterable, and a duplicate subscribe ends the old one first.
  • Pull-driven and bounded. Nothing is read from the engine until a consumer asks. The underlying stream opens on first demand and is released when the last consumer leaves. A consumer slower than real time is capped at 500 frames (5 seconds at the engine’s 10 ms frame size) before the oldest frame is dropped, so a slow consumer hears a gap rather than a growing delay.
  • Break out of loops you are done with. An iterator you hold open without reading leaves frames in the engine’s own queue, which the engine does not bound. return(), which is what break does, releases it.
  • The receive format is fixed at attach. The engine does the resampling. The SDK never touches the samples.
  • Join non-deafened. The subscribe grant is baked into the credential at join time. A deafened voice_listen bot hears silence no matter what, and attach() warns about exactly that combination.

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.