Sign in

    discord_voice

    Experimental Discord voice for gaato/discord bots on MoonBit's native backend: voice gateway v8, Opus playback and receive, DAVE E2EE.

    discord
    voice
    dave
    opus
    native
    Download zip
    Author
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    14 hours ago
    Downloads
    9

    #gaato/discord_voice

    CI Voice shim License

    Experimental Discord voice for bots built with gaato/discord, on MoonBit's native backend: voice gateway v8, Discord's DAVE end-to-end encryption, Opus playback, and per-user Opus receive. The module lives in the discord_voice/ directory of gaato/discord.mbt.

    moon add gaato/discord_voice

    Import it next to gaato/discord, as @voice:

    import {
    "gaato/discord",
    "gaato/discord_voice" @voice,
    }

    Create one VoiceClient per bot, attach it to the builder, and join from a READY handler or a bot service:

    let voice = @voice.VoiceClient()
    voice.attach(bot)
    // later, with a GatewayCtx:
    let connection = voice.join(ctx, guild_id, channel_id)

    The voice guide covers playback, receive, reconnects, and the examples.

    #Native runtime

    DAVE MLS and media encryption come from gaato/dave, the binding to Discord's official libdave (pinned at v1.2.0/cpp). RTP transport AEAD comes from a small Rust library released as voice-shim-v0.1.0; its sources are in shim/. Both are loaded at run time, not linked or installed system-wide.

    A native build bootstraps and verifies both prebuilt runtimes when asked to:

    env DISCORD_VOICE_REQUIRE_SHIM=1 MBT_DAVE_REQUIRE_NATIVE=1 moon build --target native --release

    This module and gaato/dave declare prebuild hooks that Moon runs with node for native builds, so Node.js must be on PATH for every native moon build, moon test, and moon run of a program that depends on this module. Without the opt-in variables the hooks exit without downloading anything. Moon does not run them for moon check.

    #Design

    See DESIGN.md for the accepted design and its milestones.

    #License

    Apache-2.0

    AudioSource

    pub(open) trait AudioSource {
    async fn next_frame(Self) -> Bytes?
    }

    A source of pre-encoded 20 ms Opus frames. None marks end of stream.

    DaveBackend

    trait DaveBackend

    The private seam between gateway orchestration and the official libdave wrapper. Keeping this package-private lets white-box tests model protocol outcomes without making an alternate crypto provider part of the API.

    VoiceTransport

    pub(open) trait VoiceTransport {
    async fn recv(Self) -> VoiceFrame
    async fn send_text(Self, String) -> Unit
    async fn send_binary(Self, Bytes) -> Unit
    async fn close(Self, code~ : Int) -> Unit
    }

    The network seam used by VoiceGateway. Tests can provide an in-memory implementation without opening a WebSocket.

    VoiceUdp

    pub(open) trait VoiceUdp {
    async fn recv(Self, FixedArray[Byte]) -> Int
    async fn send(Self, Bytes) -> Unit
    async fn close(Self) -> Unit
    }

    UDP seam used by voice discovery and media transport.

    OggOpusError

    pub(all) suberror OggOpusError {
    InvalidOggPage(reason~ : String)
    InvalidOpusStream(reason~ : String)
    } derive(Eq,
    Debug
    )

    Errors raised while parsing Ogg pages and Opus stream headers.

    VoiceError

    pub(all) suberror VoiceError {
    MissingIntent(intent~ : String)
    JoinTimeout
    ShimUnavailable(reason~ : String)
    NoCompatibleMode(offered~ : Array[String])
    GatewayClosed(code~ : Int?)
    IpDiscoveryFailed(reason~ : String)
    Disconnected
    InvalidBacklog(value~ : Int)
    ClientNotRunning
    } derive(
    Debug
    )

    Errors surfaced while joining or operating a voice connection.

    VoiceTransportClosed

    pub(all) suberror VoiceTransportClosed {
    VoiceTransportClosed(code~ : Int?, reason~ : String)
    } derive(
    Debug
    )

    Raised when a voice transport is closed or cannot continue.

    DaveCommitOutcome

    type DaveCommitOutcome derive(Eq,
    Debug
    )

    DaveRosterChange

    type DaveRosterChange derive(Eq,
    Debug
    )

    DaveWelcomeOutcome

    type DaveWelcomeOutcome derive(Eq,
    Debug
    )

    OggOpusSource

    pub struct OggOpusSource {
    // private fields
    }

    In-memory Ogg/Opus source. Opus packets must already represent 20 ms audio frames; this demuxer does not decode or resample them.

    OggOpusSource::channels

    fn OggOpusSource::channels(self : OggOpusSource) -> Int

    The channel count declared by the stream's OpusHead header.

    OggOpusSource::from_bytes

    fn OggOpusSource::from_bytes(bytes : Bytes) -> OggOpusSource raise OggOpusError

    Parse a complete in-memory Ogg Opus file into an audio source, validating its OpusHead/OpusTags headers.

    OggOpusSource::from_reader

    Open an Ogg/Opus source backed by an asynchronous reader. The constructor consumes and validates the OpusHead and OpusTags packets; audio packets are read lazily by AudioSource::next_frame.

    Example

    async test {
    let file = @fs.open("voice.ogg")
    defer file.close()
    let source = @voice.OggOpusSource::from_reader(file)
    let audio : &@voice.AudioSource = source
    while audio.next_frame() is Some(frame) {
    play_opus_frame(frame)
    }
    }

    OggOpusSource::next_frame

    async fn OggOpusSource::next_frame(self : OggOpusSource) -> Bytes?

    OggOpusSource::pre_skip

    fn OggOpusSource::pre_skip(self : OggOpusSource) -> Int

    Samples (at 48 kHz) to drop before playback, from the OpusHead header.

    OggOpusWriter

    pub struct OggOpusWriter {
    // private fields
    }

    Pure in-memory Ogg/Opus muxer. Header pages are available immediately from take_output; audio pages become available as packet or lacing limits are reached, and finish emits the final EOS page.

    OggOpusWriter::finish

    fn OggOpusWriter::finish(self : OggOpusWriter) -> Unit

    Flush remaining packets in a final EOS page. Repeated calls are no-ops.

    OggOpusWriter::new

    fn OggOpusWriter::new(channels? : Int, pre_skip? : Int, serial? : UInt, max_packets_per_page? : Int) -> OggOpusWriter

    Create an Ogg/Opus muxer and queue its OpusHead and OpusTags pages.

    Example

    async test {
    let writer = @voice.OggOpusWriter::new(channels=2, pre_skip=312)
    for frame in recorded_opus_frames() {
    writer.write_frame(frame)
    }
    writer.finish()
    let file = @fs.create("recording.ogg")
    defer file.close()
    file.write(writer.take_output())
    }

    Panics

    Panics if channels or pre_skip cannot be represented in an Opus header, or if max_packets_per_page is not positive.

    OggOpusWriter::take_output

    fn OggOpusWriter::take_output(self : OggOpusWriter) -> Bytes

    Drain all complete Ogg pages generated so far.

    OggOpusWriter::write_frame

    fn OggOpusWriter::write_frame(self : OggOpusWriter, opus : Bytes, samples? : Int) -> Unit raise OggOpusError

    Queue one complete Opus packet and advance the 48 kHz granule position.

    PendingVoiceEvent

    pub(all) struct PendingVoiceEvent[T] {
    wait : async (Int?) -> T?
    cancel : () -> Unit
    }

    A main-gateway event the voice join waits for. wait returns it, or None after timeout_ms; cancel abandons it without waiting.

    ReceivedAudio

    pub(all) enum ReceivedAudio {
    Opus(sequence~ : Int, timestamp~ : UInt, opus~ : Bytes)
    Lost(count~ : Int)
    } derive(Eq,
    Debug
    )

    One item of a user's received audio, in RTP sequence order.

    VoiceClient

    pub struct VoiceClient {
    // private fields
    }

    A bot's voice connections: at most one per guild. Create one per bot, attach it to the bot's builder, and join from event or service handlers. The connections run in the client's own task group, which lives as long as run (the service that attach registers).

    VoiceClient::VoiceClient

    Create a client. clock times the joins' waits and every connection the client starts (see VoiceConnection::start).

    VoiceClient::attach

    Run the client as the service "voice" of the bot that builder builds.

    VoiceClient::connection

    The connection the client holds for guild_id, if any.

    VoiceClient::join

    Join a guild voice channel through ctx's bot and wait until its voice transport is ready. See join_via for the behavior.

    VoiceClient::join_via

    async fn VoiceClient::join_via(self : VoiceClient, port : VoiceGatewayPort, guild_id :
    Id
    [
    GuildMarker
    ], channel_id :
    Id
    [
    ChannelMarker
    ], self_mute? : Bool, self_deaf? : Bool, timeout_ms? : Int, telemetry? : (VoiceTelemetry) -> Unit) -> VoiceConnection

    Join a guild voice channel and wait until its voice transport is ready.

    A second call for the same guild returns the registered connection; moving an existing connection to another channel is outside this API. Discord may send no response events when the channel is full and the bot lacks MOVE_MEMBERS; that case raises VoiceError::JoinTimeout.

    The bot must connect the main Gateway with the GUILD_VOICE_STATES intent. This method raises VoiceError::MissingIntent before sending Opcode 4 when that intent is absent, and VoiceError::ClientNotRunning when run has not started within timeout_ms.

    Pass telemetry to observe non-fatal voice gateway and media diagnostics.

    VoiceClient::run

    async fn VoiceClient::run(self : VoiceClient) -> Unit

    Run the client's task group until cancelled. Connections and their background tasks run in it, so cancelling run closes every connection. attach runs it as a bot service; call it directly only when the client is not attached to a builder.

    VoiceConnection

    pub struct VoiceConnection {
    // private fields
    }

    High-level owner of voice gateway, UDP media, DAVE, sender, and receiver tasks. All background work is attached to the task group passed to start and ends when the connection closes.

    VoiceConnection::channel_id

    fn VoiceConnection::channel_id(self : VoiceConnection) -> UInt64

    The voice channel the bot is in, following moves made outside this API.

    VoiceConnection::disconnect

    async fn VoiceConnection::disconnect(self : VoiceConnection) -> Unit

    Leave the Discord voice state and tear down media transports, returning once that is done. Repeated and concurrent calls wait for the same teardown, including one the connection started itself after a fatal close.

    VoiceConnection::latency_ms

    fn VoiceConnection::latency_ms(self : VoiceConnection) -> Int64?

    Latest voice gateway heartbeat round-trip in milliseconds, if measured.

    VoiceConnection::next_event

    async fn VoiceConnection::next_event(self : VoiceConnection) -> VoiceEvent

    Pull the next membership or lifecycle event. At most event_backlog unread events are kept; older ones are discarded first. Received audio is read through subscribe.

    VoiceConnection::play

    async fn VoiceConnection::play(self : VoiceConnection, source : &AudioSource) -> Unit

    Replace the current source. The old sender is asked to stop and flush its five silence frames before the latest source begins.

    VoiceConnection::set_speaking

    async fn VoiceConnection::set_speaking(self : VoiceConnection, flags : Int) -> Unit

    Send a Speaking (opcode 5) frame with the given flag bits. The playback loop manages this automatically; call it directly only for custom speaking indicators.

    VoiceConnection::start

    fn[X] VoiceConnection::start(group :
    TaskGroup
    [X], credentials : VoiceCredentials, user_id~ : String, channel_id~ : UInt64, leave~ : async () -> Unit, rejoin~ : async () -> VoiceCredentials, next_server? : async () -> VoiceServer?, next_channel? : async () -> UInt64?, server_timeout_ms? : Int, connector? : async (String) -> &VoiceTransport, udp_opener? : async (String, Int) -> &VoiceUdp, receive? : Bool, event_backlog? : Int, telemetry? : (VoiceTelemetry) -> Unit, clock? : &
    Clock
    , rand? :
    Rand
    ) -> VoiceConnection raise VoiceError

    Start a voice connection and attach all of its tasks to group; they end when the connection closes. clock times everything the connection waits for: the gateway heartbeat and reconnect backoff, UDP keepalives, playback pacing, receive backoff and reordering, wait_ready, and subscription silence.

    next_server waits for the next server change (a later VOICE_SERVER_UPDATE for the guild) and returns None when there will be none. On a new endpoint the connection identifies there with the same session; on a null one it drops the current server and waits up to server_timeout_ms for the next, then calls rejoin. next_channel waits for the channel the bot's own VOICE_STATE_UPDATE puts it in, which DAVE uses from the next session on. Each is called again after every update, in a task that ends with the connection, so whatever it registers to wait should be released when the call is cancelled.

    VoiceConnection::state

    The current lifecycle state of this voice connection.

    VoiceConnection::stop

    async fn VoiceConnection::stop(self : VoiceConnection) -> Unit

    Stop the active source; the send loop emits five silence frames and clears the speaking flag.

    VoiceConnection::subscribe

    fn VoiceConnection::subscribe(self : VoiceConnection, user_id~ : String, end_after_silence_ms? : Int?, backlog? : Int) -> VoiceReceiveStream raise VoiceError

    Subscribe to received audio from one Discord user. Only subscribed users' packets are decrypted; everything else is discarded on arrival.

    With end_after_silence_ms, the first read waits indefinitely and later reads end the stream after the requested silence interval. At most backlog items wait unread; newer frames are dropped past that and reported as ReceivedAudio::Lost (and VoiceTelemetry::ReceiveDropped). Raises InvalidBacklog when backlog is below 2: after a drop, the next delivery needs one slot for the Lost marker and one for its frame, so a single slot would never let the stream recover.

    Example

    async test {
    // Echo one speaker until they are silent for one second.
    connection.play(
    connection.subscribe(user_id="1234", end_after_silence_ms=Some(1000)),
    )

    // Or record the same stream into an Ogg/Opus file.
    let stream = connection.subscribe(user_id="1234")
    let audio : &@voice.AudioSource = stream
    let writer = @voice.OggOpusWriter::new(channels=2)
    while audio.next_frame() is Some(frame) {
    writer.write_frame(frame)
    }
    writer.finish()
    }

    VoiceConnection::wait_ready

    async fn VoiceConnection::wait_ready(self : VoiceConnection, timeout_ms? : Int) -> Unit raise VoiceError

    Wait until the current connection handshake reaches Ready.

    VoiceConnectionState

    pub(all) enum VoiceConnectionState {
    Connecting
    Ready
    Reconnecting
    Rejoining
    Closed(code~ : Int?)
    } derive(Eq,
    Debug
    )

    Public voice connection lifecycle.

    VoiceCredentials

    pub(all) struct VoiceCredentials {
    server_id : String
    session_id : String
    token : String
    endpoint : String
    } derive(Eq,
    Debug
    )

    Credentials supplied by the main Discord gateway voice-state handshake.

    VoiceEvent

    pub(all) enum VoiceEvent {
    SpeakingChanged(user_id~ : String, ssrc~ : UInt, flags~ : Int)
    UserConnected(user_id~ : String)
    UserDisconnected(user_id~ : String)
    ConnectionReady
    ConnectionResumed
    } derive(Eq,
    Debug
    )

    Membership and lifecycle events of a live voice connection. Received audio is not an event: it is delivered per user through VoiceConnection::subscribe.

    VoiceFrame

    pub(all) enum VoiceFrame {
    Text(String)
    Binary(Bytes)
    } derive(Eq,
    Debug
    )

    A complete WebSocket message received from the voice gateway.

    VoiceGatewayEvent

    pub(all) enum VoiceGatewayEvent {
    ReadyReceived
    SessionEstablished(mode~ : String, secret_key~ : Bytes, dave_protocol_version~ : Int)
    Message(VoiceMessage)
    Connected(resumed~ : Bool)
    Disconnected(code~ : Int?, resuming~ : Bool)
    ConnectFailed(reason~ : String)
    NeedsRejoin(code~ : Int?)
    FatallyClosed(code~ : Int)
    } derive(Eq,
    Debug
    )

    Events surfaced by VoiceGateway::next.

    VoiceGatewayPort

    What joining voice needs from the main gateway, as values and functions. from_gateway builds one from a bot's GatewayCtx; a test can build its own to drive VoiceClient::join_via without a gateway connection.

    VoiceGatewayPort::from_gateway

    The gateway capabilities of ctx's bot. Opcode 4 goes to the shard that owns each guild, whichever shard ctx belongs to.

    VoiceMessage

    pub(all) enum VoiceMessage {
    Ready(ssrc~ : UInt, ip~ : String, port~ : Int, modes~ : Array[String])
    Hello(heartbeat_interval~ : Int)
    SessionDescription(mode~ : String, secret_key~ : Bytes, dave_protocol_version~ : Int)
    HeartbeatAck(nonce~ : Int64)
    Speaking(ssrc~ : UInt, user_id~ : String, flags~ : Int)
    ClientsConnect(user_ids~ : Array[String])
    ClientDisconnect(user_id~ : String)
    Resumed
    DavePrepareTransition(transition_id~ : Int, protocol_version~ : Int)
    DaveExecuteTransition(transition_id~ : Int)
    DavePrepareEpoch(epoch~ : Int, protocol_version~ : Int)
    DaveMlsExternalSender(payload~ : Bytes)
    DaveMlsProposals(payload~ : Bytes)
    DaveMlsAnnounceCommitTransition(transition_id~ : Int, commit~ : Bytes)
    DaveMlsWelcome(transition_id~ : Int, welcome~ : Bytes)
    Unknown(op~ : Int)
    } derive(Eq,
    Debug
    )

    A decoded voice gateway v8 message.

    VoiceReceiveStream

    pub struct VoiceReceiveStream {
    // private fields
    }

    A per-user stream of received audio. Streams are created with VoiceConnection::subscribe; read them with next_packet, or as an AudioSource (for play or recording) when only the Opus frames matter.

    VoiceReceiveStream::close

    fn VoiceReceiveStream::close(self : VoiceReceiveStream) -> Unit

    End this stream and unregister it from its connection. Repeated calls are harmless, and a blocked read wakes with None.

    VoiceReceiveStream::next_frame

    async fn VoiceReceiveStream::next_frame(self : VoiceReceiveStream) -> Bytes?

    VoiceReceiveStream::next_packet

    async fn VoiceReceiveStream::next_packet(self : VoiceReceiveStream) -> ReceivedAudio?

    Yield the subscribed user's next frame or loss marker; None after close, once the connection ends, or once the configured trailing silence elapses.

    VoiceServer

    pub(all) struct VoiceServer {
    token : String
    endpoint : String?
    } derive(Eq,
    Debug
    )

    A voice server change Discord reported after the session started: the token and endpoint of a VOICE_SERVER_UPDATE. A None endpoint means the server went away and another is being allocated.

    VoiceTelemetry

    pub(all) enum VoiceTelemetry {
    GatewayEvent(VoiceGatewayEvent)
    DaveMediaContextActivated(protocol_version~ : Int)
    DaveBinaryControlSent(opcode~ : Int, payload_bytes~ : Int)
    PacketDropped(reason~ : String)
    DaveEncryptDropped(reason~ : String)
    DaveDecryptDropped(user_id~ : String?, ssrc~ : UInt, count~ : Int, reason~ : String)
    EventDropped(backlog~ : Int)
    ReceiveDropped(user_id~ : String, backlog~ : Int)
    } derive(Eq,
    Debug
    )

    Non-fatal diagnostics from the connection's gateway and media paths.

    DEFAULT_RECEIVE_BACKLOG

    let DEFAULT_RECEIVE_BACKLOG : Int

    Default number of unread items a receive stream holds: five seconds of 20 ms Opus frames.

    DEFAULT_VOICE_EVENT_BACKLOG

    let DEFAULT_VOICE_EVENT_BACKLOG : Int

    Default number of unread VoiceEvents a connection keeps for next_event.

    SILENCE_FRAME

    let SILENCE_FRAME : Bytes

    Discord's canonical Opus silence frame.

    shim_available

    fn shim_available() -> Bool

    Whether a compatible transport-only ABI v3 voice shim is available.

    shim_unavailable_reason

    fn shim_unavailable_reason() -> String?

    Explain why the native voice shim could not be loaded.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io