Table of Contents

Voice Architecture

DisCatSharp.Voice combines Discord gateway signaling, voice-gateway signaling, UDP media transport, Opus, Discord transport encryption, and DAVE end-to-end encryption (E2EE).

This article describes the DAVE 1.1.4 transition model. The important rule is that DAVE control-plane state and currently executing media transforms are related, but are not the same state.

Connection Lifecycle

sequenceDiagram
    participant Bot
    participant Gateway as Discord Gateway
    participant VoiceGateway as Voice Gateway
    participant UDP
    participant DAVE as DaveSession/libdave

    Bot->>Gateway: VOICE_STATE_UPDATE (join)
    Gateway-->>Bot: VOICE_STATE_UPDATE (session_id)
    Gateway-->>Bot: VOICE_SERVER_UPDATE (endpoint, token)
    Bot->>VoiceGateway: OP0 Identify
    VoiceGateway-->>Bot: OP2 Ready (ssrc, ip, port, modes)
    Bot->>UDP: IP Discovery
    UDP-->>Bot: External IP and port
    Bot->>VoiceGateway: OP1 Select Protocol
    VoiceGateway-->>Bot: OP4 Session Description
    opt negotiated DAVE protocol version is positive
        Bot->>DAVE: Initialize MLS group
        Bot->>VoiceGateway: OP26 MLS key package
    end
    Bot->>UDP: RTP media when the selected media policy allows it

A protocol version of 0 creates a protocol-0 DAVE coordinator in passthrough mode. This lets the same connection upgrade later without waiting for another OP4.

Media Pipeline

Both PCM and pre-encoded Opus sources pass through the same RTP, DAVE, and transport-encryption stages.

Send path

flowchart LR
    PCM["PCM S16LE"] --> Encode["Opus encode"]
    External["Pre-encoded Opus"] --> Frame["RTP header and Opus payload"]
    Encode --> Frame
    Frame --> Gate{"DAVE media ready?"}
    Gate -->|"No"| Policy["Apply DavePendingAudioBehavior"]
    Gate -->|"Yes, sender encrypting"| DaveEncrypt["DAVE encrypt encoded frame"]
    Gate -->|"Yes, protocol 0"| Transport["Discord transport encryption"]
    Policy -->|"PassThrough"| Transport
    Policy -->|"Drop"| Dropped["Drop frame"]
    Policy -->|"Throw"| Error["Throw InvalidOperationException"]
    DaveEncrypt --> Transport
    Transport --> UDP["UDP send"]

The RTP header is authenticated by the selected Discord transport mode. DAVE transforms the encoded media payload before Discord transport encryption is applied.

Receive path

flowchart LR
    UDP["UDP packet"] --> RTP["Parse RTP header and sender mapping"]
    RTP --> Transport["Discord transport decryption"]
    Transport --> Gate{"DAVE media ready?"}
    Gate -->|"No"| Drop["VoicePacketDropped: DavePending"]
    Gate -->|"Yes"| Dave["Per-user DAVE decrypt or passthrough"]
    Dave --> Extensions["Strip RFC 5285 RTP extensions"]
    Extensions --> Decode["Opus decode"]
    Decode --> Event["VoiceReceived"]

Incoming DAVE frames are selected by Discord user ID, not only by SSRC. A missing SSRC-to-user mapping or missing per-user decryptor is reported through VoicePacketDropped.

Control Plane and Media Plane

The public FSM describes negotiation work. Media properties describe what the connection can do right now.

Surface Meaning
DaveState Current DAVE control-plane state
DaveProtocolVersion Protocol version used by the currently executing sender transform
IsDaveNegotiated A DAVE coordinator exists, including protocol-0 passthrough
IsDaveActive The currently executing sender transform is applying DAVE encryption
IsE2eeUsableForSend Outbound media can be processed under the current transform and pending-audio policy
IsE2eeUsableForReceive Inbound media can currently be processed by the executing receive mode

For an established member, IsDaveActive remains true while DaveState is ReadyForTransition or Downgrading: receivers have prepared the next mode, but the sender continues using the old epoch until OP22. After a protocol-0 downgrade, DaveState is Inactive and IsDaveActive is false, while both media-usability properties remain true because plaintext passthrough is the executing mode.

DAVE Opcode Responsibilities

Opcode Direction Representation Responsibility
OP11 clients_connect Server → client JSON Replaces the recognized participant set
OP13 client_disconnect Server → client JSON Removes the participant and its decryptor
OP21 prepare_transition Server → client JSON Prepares receiver transforms for a transition ID and protocol version
OP22 execute_transition Server → client JSON Executes one staged transition on the local sender only
OP23 ready_for_transition Client → server JSON Reports that receiver transforms are ready for a nonzero transition ID
OP24 prepare_epoch Server → client JSON Announces epoch and protocol_version; epoch 1 reinitializes MLS and produces OP26
OP25 external_sender Server → client Binary Installs the MLS external sender package
OP26 key_package Client → server Binary Supplies a fresh MLS key package
OP27 proposals Server → client Binary Supplies MLS proposals for validation and commit creation
OP28 commit_welcome Client → server Binary Supplies a commit and, when required, a Welcome
OP29 announce_commit Server → client Binary Carries a transition-ID prefix and an MLS commit
OP30 welcome Server → client Binary Carries a transition-ID prefix and an MLS Welcome
OP31 invalid_commit_welcome Client → server JSON Reports the exact transition ID of a rejected OP29 or OP30

OP23 is sent after successful receiver preparation and before OP22. OP22 never receives an OP23 acknowledgement.

Initial Group Creation and Member Join

OP29 and OP30 are two recipient perspectives on the same epoch transition: established members process the announced commit, while a pending member processes its Welcome.

sequenceDiagram
    participant VG as Voice Gateway
    participant VC as VoiceConnection
    participant MLS as DaveSession/libdave
    participant RX as Remote-user decryptors
    participant TX as Local encryptor

    VG-->>VC: OP4 protocol version > 0
    VC->>MLS: Init(version, channel ID, self ID)
    VC->>VG: OP26 key package
    VG-->>VC: OP25 external sender
    VG-->>VC: OP27 proposals
    VC->>MLS: Validate proposals and create commit/Welcome
    VC->>VG: OP28 commit/Welcome

    alt Established member
        VG-->>VC: OP29 transition ID + commit
        VC->>MLS: Process commit
    else Pending member
        VG-->>VC: OP30 transition ID + Welcome
        VC->>MLS: Process Welcome
    end

    VC->>RX: Prepare new receive ratchets in existing decryptors
    alt transition ID is 0
        VC->>TX: Install local sender ratchet immediately
        Note over VC,VG: No OP23 and no OP22 are required
    else transition ID is nonzero
        VC->>VG: OP23 ready for the same transition ID
        Note over TX: Existing member keeps old sender active, joining sender remains inactive
        VG-->>VC: OP22 execute the same transition ID
        VC->>TX: Install only the local sender ratchet
    Note over VC,MLS: MLS group is retained, no OP23 is sent here
    end

An unknown or duplicate OP22 transition ID is ignored with a warning. It does not change protocol version, FSM state, MLS state, or media ratchets.

Initialization Transition ID 0

Transition ID 0 is reserved for initialization and reinitialization. OP21, OP29, or OP30 with ID 0 prepares receivers and immediately switches the local sender. DisCatSharp does not stage the ID or send OP23.

This is also used by the sole-member reset flow:

sequenceDiagram
    participant VG as Voice Gateway
    participant VC as VoiceConnection
    participant MLS as DaveSession/libdave

    VG-->>VC: OP24 epoch=1, protocol_version=N
    VC->>MLS: Reset and initialize the new MLS group with version N
    VC->>VG: OP26 fresh key package
    VG-->>VC: OP21 transition_id=0, protocol_version=N
    VC->>VC: Prepare receivers and execute sender immediately
    Note over VC,VG: No OP23

OP24 does not contain a transition ID and does not create a staged transition by itself.

Established Epoch Transition

For a nonzero OP29 transition, receiving moves first and sending moves later:

sequenceDiagram
    participant VG as Voice Gateway
    participant VC as VoiceConnection
    participant RX as Existing per-user decryptors
    participant TX as Local encryptor

    VG-->>VC: OP29 transition_id=28 + commit
    VC->>RX: Add new receive ratchets, retain old epoch overlap
    VC->>VG: OP23 transition_id=28
    Note over TX: Continue encrypting with the old epoch
    VG-->>VC: OP22 transition_id=28
    VC->>TX: Install new local sender ratchet
    Note over RX,TX: Transition complete, no MLS reset and no OP23 after OP22

The same staging rule applies to a nonzero OP30 Welcome. A newly joining member has receiver ratchets prepared after OP30 but does not activate its local sender until matching OP22.

Downgrade to Protocol 0

sequenceDiagram
    participant VG as Voice Gateway
    participant VC as VoiceConnection
    participant RX as Remote-user decryptors
    participant TX as Local encryptor

    VG-->>VC: OP21 transition_id=10, protocol_version=0
    VC->>RX: Enable plaintext passthrough, retain encrypted ratchets for 10 seconds
    VC->>VG: OP23 transition_id=10
    Note over TX: Continue encrypting with the old epoch
    VG-->>VC: OP22 transition_id=10
    VC->>TX: Enable sender passthrough
    VC->>VC: Set executing version 0 and reset MLS control state
    Note over VC: DaveState=Inactive, IsDaveActive=false, media remains usable

Preparing a downgrade does not pause old-epoch audio. DavePendingAudioBehavior.Drop and Throw apply only when media is genuinely unusable, not merely because the FSM says Downgrading.

Invalid Commit or Welcome Recovery

sequenceDiagram
    participant VG as Voice Gateway
    participant VC as VoiceConnection
    participant MLS as DaveSession/libdave
    participant Media as Executing media transforms

    VG-->>VC: OP29 or OP30 with transition_id=44
    VC->>MLS: Processing fails
    VC->>VG: OP31 transition_id=44
    VC->>MLS: Reset preparation state and generate a new key package
    VC->>VG: OP26 fresh key package
    Note over Media: Existing sender and receiver transforms remain installed

OP31 is client-to-server recovery. DisCatSharp retains the currently executing media transforms until a later valid transition replaces them.

Public DAVE State Model

The following is a representative control-plane graph, not a media-availability graph:

stateDiagram-v2
    [*] --> Inactive: executing protocol 0
    [*] --> Pending: positive protocol selected
    Pending --> AwaitingResponse: OP26 or OP28 sent
    AwaitingResponse --> Active: OP29 or OP30, transition ID 0
    AwaitingResponse --> ReadyForTransition: OP29 or OP30, nonzero positive transition
    AwaitingResponse --> Downgrading: nonzero transition to protocol 0
    Active --> ReadyForTransition: next positive transition prepared
    Active --> Downgrading: protocol-0 transition prepared
    ReadyForTransition --> Active: matching OP22
    Downgrading --> Inactive: matching OP22 to protocol 0
    ReadyForTransition --> AwaitingResponse: invalid later transition recovery and OP26
    Downgrading --> AwaitingResponse: invalid later transition recovery and OP26

ReadyForTransition and Downgrading can both coexist with active old-epoch media. Use the media-readiness properties instead of deriving usability from the enum value.

Ratchet Overlap

Per-user decryptor objects are reused across epoch changes so their overlap windows survive:

  • A transition to protocol 0 enables plaintext passthrough indefinitely and retains old encrypted ratchets for ten seconds.
  • A transition back to E2EE allows plaintext for ten seconds and retains previous encrypted-epoch ratchets for ten seconds.
  • A newly recognized participant is initialized with the latest prepared receive mode.

These windows allow in-flight media from the previous mode or epoch to be processed during the transition.

Move and Reconnect Workflow

Moving the bot or changing the voice-server context requires a fresh voice session and DAVE coordinator.

sequenceDiagram
    participant Gateway as Discord Gateway
    participant VC as VoiceConnection
    participant VG as Voice Gateway
    participant DAVE as DaveSession/libdave

    Gateway-->>VC: VOICE_STATE_UPDATE for new channel
    Gateway-->>VC: VOICE_SERVER_UPDATE with endpoint and token
    VC->>VC: Select fresh identify path
    VC->>VG: OP0 Identify
    VG-->>VC: OP2 Ready and OP4 Session Description
    VC->>DAVE: Dispose old session and create coordinator for new channel ID
    alt positive protocol version
        VC->>VG: OP26 key package
        VG-->>VC: OP25/OP27 followed by OP29 or OP30
    else protocol version 0
        VC->>DAVE: Enter passthrough mode
    end

The MLS group ID is the voice channel ID. State from the previous endpoint or channel is not reused.

Playback Decision

flowchart TD
    Frame["Encoded frame ready"] --> Session{"DAVE coordinator exists?"}
    Session -->|"No"| Plain["Transport-encrypted media"]
    Session -->|"Yes"| Ready{"Current media mode ready?"}
    Ready -->|"Yes"| Active{"Sender DAVE encryption active?"}
    Active -->|"Yes"| E2EE["DAVE encrypt using executing epoch"]
    Active -->|"No, protocol 0"| Plain
    Ready -->|"No"| Policy{"DavePendingAudioBehavior"}
    Policy -->|"PassThrough"| Plain
    Policy -->|"Drop"| Drop["Drop frame"]
    Policy -->|"Throw"| Throw["Throw InvalidOperationException"]
    E2EE --> Transport["Discord transport encryption and UDP send"]
    Plain --> Transport

Prepared transitions follow the Ready → Active path because the old sender epoch is still usable. Protocol-0 passthrough follows the Ready → not active path.

Audio Output Pipeline

VoiceOutputController provides direct Opus passthrough and serialized PCM overlays. Both modes enter the same DAVE-aware packet preparation path:

flowchart LR
    Music["Lavalink bridge: Opus"] -->|"SetMusicSourceAsync"| Controller["VoiceOutputController"]
    Overlay["TTS/system audio: PCM"] -->|"QueuePcmOverlayAsync"| Controller
    Controller -->|"Opus frames"| Connection["VoiceConnection"]
    Connection --> Gate["Media-readiness gate"]
    Gate --> Packet["RTP + DAVE mode + transport encryption"]
    Packet --> Discord

VoiceConfiguration.EnableExternalOpus must be true before binding a VoiceOutputController or another IExternalOpusSource.

See Audio Output for usage details.

Runtime Signals

Use these for application-level gating and diagnostics:

  • IsDaveNegotiated
  • IsDaveActive
  • IsE2eeUsableForSend
  • IsE2eeUsableForReceive
  • DaveProtocolVersion
  • DaveState
  • WaitForDaveActiveAsync(...)
  • DaveStateChanged
  • DaveOpcodeObserved
  • EnableDebugLogging

WaitForDaveActiveAsync waits for an encrypting sender transform. It is not a generic media-readiness wait; protocol-0 passthrough is usable but never DAVE-active.

See Also