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_fileThe 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.