Remedia

Discord voice output

Send core Opus packets through the outbound Discord voice transport.

Use remedia-discord to send Opus packets through Discord voice transport. Rust callers provide fresh Gateway credentials and a packet source. Node.js callers should use the Gateway adapter, which gathers those credentials from the existing bot connection.

Connect with external Gateway information

The caller supplies the voice session information collected by its own bot Gateway integration. DAVE also needs the voice channel_id to bind the MLS group, in addition to the guild ID used by the voice Gateway Identify message. These values are connection credentials: VoiceConnectionInfo redacts session ID and token in its Debug output, and the library does not log or persist them.

The Node.js binding includes a small adapter for applications that already own a Discord Gateway client. It sends the required channel join request and collects the voice state/server dispatches. See the Node.js Gateway adapter guide. Rust callers continue to provide VoiceConnectionInfo directly. The repository's manual bot harness demonstrates this adapter with discord.js and app-level playback commands.

use std::error::Error;
use std::sync::Arc;

use remedia_core::{InputSource, OpusEncoderOutput, PlaybackSession, PlaybackState};
use remedia_discord::{DiscordVoiceConnection, VoiceConnectionInfo};
use remedia_observability::Observability;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let mut observability = Observability::from_env()?;
    let handle = observability.handle();
    let connection = DiscordVoiceConnection::connect_with_observability(VoiceConnectionInfo {
        guild_id: 123456789012345678,
        channel_id: 234567890123456789,
        user_id: 345678901234567890,
        session_id: std::env::var("DISCORD_VOICE_SESSION_ID")?,
        token: std::env::var("DISCORD_VOICE_TOKEN")?,
        endpoint: std::env::var("DISCORD_VOICE_ENDPOINT")?,
    }, handle.clone())
    .await?;

    let session = Arc::new(PlaybackSession::with_observability(handle.clone()));
    let source = InputSource::file("media/track.wav");
    let output = OpusEncoderOutput::new_with_observability(connection.opus_sender(), handle)?;

    // PlaybackSession methods are synchronous, so keep them off Tokio workers.
    let starting_session = Arc::clone(&session);
    tokio::task::spawn_blocking(move || starting_session.play(source, output)).await??;

    let waiting_session = Arc::clone(&session);
    let state = tokio::task::spawn_blocking(move || waiting_session.wait()).await?;
    if state != PlaybackState::Completed {
        if let Some(error) = session.last_error() {
            return Err(error.into());
        }
        return Err(format!("playback ended in state {state:?}").into());
    }

    session.stop()?;
    connection.disconnect().await?;
    observability.force_flush()?;
    observability.shutdown()?;
    Ok(())
}

connect performs Identify, Hello heartbeat setup, Ready parsing, UDP IP discovery, Select Protocol, Session Description, and initial DAVE MLS setup. It waits up to 20 seconds for the initial DAVE group to become media-ready. The API does not join a channel or request credentials on the caller's behalf. OpusEncoderOutput adapts normalized PCM to 20 ms stereo Opus packets at 48 kHz. A short final block is padded to a full Opus frame. Packet metadata records how much source audio the padding represents. The adapter performs no real-time sleeping.

The core may also automatically send compatible source Opus packets directly through this same playback-scoped sender. That optimization is limited to untrimmed, 48 kHz mono/stereo Opus packets accepted by the transport's duration and size checks. Inputs requiring skip-sample trimming, other codecs, active filtergraphs, and non-unity gain use the core's PCM decoder and Opus encoder. Enabling or clearing processing during a track changes the core path without constructing another Discord sender. RTP sequence/timestamp and DAVE state stay owned by the existing voice connection. See playback mode switching for the decoder warm-up, padding, fallback, and transition limitations.

For a manual sender example that reads connection information and an input source from environment variables, run:

cargo run -p remedia-discord --example play_file

The example expects DISCORD_GUILD_ID, DISCORD_CHANNEL_ID, DISCORD_USER_ID, DISCORD_VOICE_SESSION_ID, DISCORD_VOICE_TOKEN, DISCORD_VOICE_ENDPOINT, REMEDIA_SOURCE_TYPE (file or url), and REMEDIA_SOURCE. A separate Gateway client must already have joined the voice channel. Do not put credentials in command history, fixtures, or logs.

Output ownership and lifecycle

Each DiscordVoiceConnection owns one transport task and remains independent of individual playback sessions. Calling opus_sender() creates a bounded backpressured output for one playback. A newer output supersedes the prior one. Finishing or cancelling an output does not disconnect the voice connection. Call disconnect().await to close the connection and release its worker, socket, and DAVE state. The Node and remote server adapters place a stable DiscordOpusSenderRouter between playback and the current connection, so they can replace a transport without replacing the playback output.

Voice server and region migration

The generic Rust transport does not own the bot Gateway and therefore cannot request new session credentials itself. Node and remote-client integrations do: they detect fresh VOICE_SERVER_UPDATE credentials or a changed VOICE_STATE_UPDATE session, send OP 4 through the caller's Gateway adapter, and replace the transport after receiving a fresh pair. A stable packet router detaches the old sender before it is closed and attaches the new sender only after its DAVE setup is ready. Playback state, source position, filter and volume configuration, and pause state remain owned by the existing core session. Ordinary voice WebSocket Resume stays in this crate and does not send Gateway OP 4.

The router has no unbounded handoff queue. The dedicated playback worker waits at the output boundary during recovery. Accepted packets held only by the old transport can be lost when it closes. No packet is reused with stale transport encryption. This can create an audible gap, but does not restart or rewind the source.

Files, HTTP inputs, buffers, and producer-backed Readables remain open in the same decoder. One-shot streams are not replayed. If bounded recovery fails, the wrapper reports a terminal voice failure and the routed output fails. Stop cancels playback independently from voice recovery, and a stopped source is not restarted when a replacement transport attaches. Explicit disconnect, session disposal, and application shutdown cancel pending recovery.

Sender generations

Each output has a monotonically increasing generation. Cancellation applies only while its generation is still current, so a delayed stop from an older playback cannot deactivate a replacement track. Packets are checked against the latest published generation before delivery, including when several generation changes arrive before the transport worker observes them.

Pause, finish, stop, and seek

The sender sends Speaking before the first RTP packet. Pause drains packets already accepted by the sender, sends the protocol's five 20 ms silence frames, and turns Speaking off. Resume enables Speaking again and continues the same voice connection. End-of-stream uses the same drain and silence behavior before the core session becomes Completed. Stop cancels pending delivery and releases only that playback output. A seek resets the core Opus encoder, increments the output generation, and drops queued pre-seek packets. Packets already sent cannot be recalled. RTP sequence and timestamp counters remain continuous across seek and source replacement.

Packet pacing and backpressure

Packets are paced from their encoded sample duration using a monotonic clock. The sender does not burst packets after a delay: packets more than one frame late are dropped and the schedule is rebased. During an active playback underrun, it sends silence on the normal cadence. The queue is bounded, so a slow transport applies backpressure to the core playback worker. The decoder and Opus encoder themselves remain unpaced.

PlaybackSession::position().submitted reports the PCM timeline accepted by the output. Discord does not report an audible sample position through this transport, so played is None. It must not be treated as sample-accurate playback time.

Connection errors and limits

Connection setup and packet operations use VoiceError for endpoint, WebSocket, UDP, encryption, DAVE, packet, timeout, and lifecycle failures. The latest background transport failure is available from connection.failure() and changes the state to Failed. No Opus packet is sent until DAVE media encryption is ready.

The transport attempts Voice Gateway v8 Resume after an unexpected WebSocket closure or a missed heartbeat acknowledgment when the close condition permits Resume. Resume keeps the voice connection's UDP and DAVE state. Close codes that require fresh Gateway credentials instead park output and wait for the Node or remote Gateway adapter to send a new OP 4 request and credential pair. Expired voice sessions become Failed and follow the same bounded recovery policy.

The adapters retain the latest valid voice-state session ID while they apply the newest server token, including when its endpoint is unchanged. DAVE protocol version 1 is the only version supported by the selected davey release. Live playback through a configured Discord bot has been exercised. Repository CI remains credential-free and does not open a live Discord connection. Use the manual voice region recovery procedure to check real credential rotation and transport replacement.

This crate does not include a bot Gateway or REST client, channel-join commands, audio receiving, RTCP, video, remote binary uploads, DAVE cryptography, or Opus encoding. Opus encoding remains in remedia-core. DAVE/MLS and network transport remain in remedia-discord.

Connection and sender lifecycle events use an internal voice_connection_id. They never log the Discord voice session ID or token. Pass the same ObservabilityHandle to the playback session, Opus encoder output, and connect_with_observability() to route their diagnostics together. See the observability guide for event fields, filters, metrics, and provider shutdown behavior.

On this page