discord-php-helpers/voice
Helpers for using DiscordPHP voice: connect to voice channels, handle voice gateway/UDP, and stream audio from PHP 8+ CLI apps. Built on ReactPHP/event loops with promises; intended for bot developers needing voice support.
This document provides visual diagrams of how DiscordPHP-Voice implements the DAVE E2EE protocol. All diagrams use Mermaid syntax, which GitHub renders natively.
For the authoritative protocol specification, see the DAVE Protocol Whitepaper and libdave.
How the DAVE-related classes relate to each other within the library.
graph TD
Manager["Manager<br/><small>Entry point — validates libdave<br/>on construction</small>"]
VoiceClient["VoiceClient<br/><small>Playback/recording state machine.<br/>Owns encryptDaveFrame() /<br/>decryptDaveFrame()</small>"]
Client["Client<br/><small>Backwards-compat subclass<br/>of VoiceClient</small>"]
WS["Client\WS<br/><small>Voice gateway connection.<br/>Handles all DAVE opcodes (21–31).<br/>Owns DaveState instance.</small>"]
UDP["Client\UDP<br/><small>IP discovery, UDP heartbeats,<br/>RTP send/receive</small>"]
Packet["Client\Packet<br/><small>RTP header + libsodium<br/>transport encryption.<br/>Injects DAVE frame callbacks.</small>"]
DaveState["Dave\State<br/><small>Per-connection MLS state:<br/>protocol version, epoch,<br/>transitions, recognized users,<br/>encryptor/decryptors</small>"]
DaveRuntime["Dave\Runtime<br/><small>FFI singleton wrapping libdave.<br/>Session, encryptor, decryptor,<br/>key ratchet, MLS operations.</small>"]
SessionH["Dave\SessionHandle"]
EncryptorH["Dave\EncryptorHandle"]
DecryptorH["Dave\DecryptorHandle"]
KeyRatchetH["Dave\KeyRatchetHandle"]
NativeHandle["Dave\NativeHandle<br/><small>Abstract base — destroy()<br/>via Runtime</small>"]
BinaryFrame["Dave\BinaryFrame<br/><small>Parse/serialise binary<br/>DAVE gateway frames</small>"]
Manager -->|creates| VoiceClient
Client -.->|extends| VoiceClient
VoiceClient -->|owns| WS
VoiceClient -->|owns| UDP
UDP -->|uses| Packet
WS -->|owns| DaveState
WS -->|uses| DaveRuntime
WS -->|uses| BinaryFrame
VoiceClient -->|calls| DaveRuntime
DaveState -->|holds| SessionH
DaveState -->|holds| EncryptorH
DaveState -->|holds per-user| DecryptorH
DaveRuntime -->|creates| SessionH
DaveRuntime -->|creates| EncryptorH
DaveRuntime -->|creates| DecryptorH
DaveRuntime -->|creates| KeyRatchetH
SessionH -.->|extends| NativeHandle
EncryptorH -.->|extends| NativeHandle
DecryptorH -.->|extends| NativeHandle
KeyRatchetH -.->|extends| NativeHandle
Packet -->|outbound callback| VoiceClient
Packet -->|inbound callback| VoiceClient
style Manager fill:#4a9eff,color:#fff
style WS fill:#ff9f43,color:#fff
style DaveState fill:#ee5a24,color:#fff
style DaveRuntime fill:#6c5ce7,color:#fff
style VoiceClient fill:#00b894,color:#fff
The sequence from joining a voice channel to having DAVE fully initialized.
sequenceDiagram
autonumber
participant App as Application
participant Mgr as Manager
participant VC as VoiceClient
participant WS as Client\WS
participant GW as Voice Gateway
participant RT as Dave\Runtime
participant ST as Dave\State
App->>Mgr: joinChannel(channel)
Note over Mgr: Validates libdave is available<br/>(throws LibDaveNotFoundException if not)
Mgr->>VC: create VoiceClient
VC->>WS: new WS(vc, discord, data)
Note over WS: Checks DaveRuntime::isAvailable()<br/>Initializes DaveState with identity<br/>Caps maxDaveProtocolVersion
WS->>GW: Op 0 Identify<br/>{ max_dave_protocol_version: 1 }
Note over WS,GW: First voice WebSocket connections send Identify.<br/>True reconnects send Op 7 Resume<br/>with seq_ack when available.
GW-->>WS: Op 8 Hello<br/>{ heartbeat_interval }
Note over WS: Starts heartbeat timer
GW-->>WS: Op 2 Ready<br/>{ ssrc, ip, port, modes }
Note over WS: Creates UDP client,<br/>starts IP discovery
WS->>GW: Op 1 Select Protocol<br/>{ mode: "aead_aes256_gcm_rtpsize" }
GW-->>WS: Op 4 Session Description<br/>{ secret_key, dave_protocol_version: 1 }
WS->>WS: initializeDaveRuntimeState(protocolVersion)
WS->>RT: createSession()
RT-->>WS: SessionHandle
WS->>ST: replaceSession(session)
WS->>RT: createEncryptor()
RT-->>WS: EncryptorHandle
WS->>ST: replaceEncryptor(encryptor)
WS->>RT: initializeSession(session, version, groupId, selfUserId)
WS->>RT: getMarshalledKeyPackage(session)
RT-->>WS: keyPackage bytes
WS->>GW: Op 26 MLS Key Package<br/>(binary WebSocket frame)
Note over WS: DAVE runtime is now initialized.<br/>Waiting for MLS group formation.
WS->>VC: emit('ready')
When a new MLS group is being formed (e.g. the first two members join a call).
sequenceDiagram
autonumber
participant WS as Client\WS
participant GW as Voice Gateway
participant RT as Dave\Runtime
participant ST as Dave\State
GW-->>WS: Op 24 Prepare Epoch<br/>{ epoch: 1, protocol_version, transition_id }
WS->>ST: prepareEpoch(1)
WS->>ST: prepareTransition(transitionId, protocolVersion)
WS->>WS: initializeDaveRuntimeState(pv, resetState=true)
Note over WS: Creates/resets session,<br/>creates encryptor,<br/>initializes MLS session
WS->>WS: sendDaveKeyPackage()
alt key package not already sent
WS->>RT: getMarshalledKeyPackage(session)
RT-->>WS: keyPackage bytes
WS->>GW: Op 26 MLS Key Package (binary)<br/>[key package]
else already sent by Session Description
Note over WS,GW: Opcode 26 was already sent.
end
Note over GW,WS: Op 25 may arrive at any time —<br/>before, during, or after key package exchange.
GW-->>WS: Op 25 MLS External Sender (binary)<br/>[external sender credential]
WS->>ST: store externalSenderPackage
WS->>RT: setExternalSender(session, package)
Note over WS: If external sender arrived earlier,<br/>it was stored and applied during init.
GW-->>WS: Op 27 MLS Proposals (binary)<br/>[add proposals for other members]
WS->>RT: buildMlsCommitWelcomeWithSession(session, proposals, recognizedUsers)
RT-->>WS: commit+welcome payload
WS->>GW: Op 28 MLS Commit Welcome (binary)<br/>[commit + welcome]
Note over GW: Gateway selects first<br/>valid commit as "winner"
GW-->>WS: Op 29 MLS Announce Commit Transition (binary)<br/>[transitionId + commit]
WS->>RT: processCommit(session, commit)
RT-->>WS: { failed: false, ignored: false }
WS->>WS: prepareDaveMediaTransition(transitionId, pv)
Note over WS: Prepares decryptors for<br/>all recognized users;<br/>stores key ratchets and uses<br/>passthrough grace during setup
WS->>GW: Op 23 Transition Ready<br/>{ transition_id }
GW-->>WS: Op 22 Execute Transition<br/>{ transition_id }
WS->>WS: applySelfDaveEncryptor(pv)
WS->>ST: executeTransition(transitionId)
Note over ST: passthroughMode = false<br/>DAVE E2EE is now active!
When a new member is being added to an existing MLS group.
sequenceDiagram
autonumber
participant WS as Client\WS (new member)
participant GW as Voice Gateway
participant RT as Dave\Runtime
participant ST as Dave\State
Note over WS: After connection + Session Description,<br/>DAVE runtime is initialized and<br/>the initial key package has been sent.
GW-->>WS: Op 30 MLS Welcome (binary)<br/>[transitionId + welcome message]
WS->>WS: splitTransitionPayload(payload)
WS->>RT: processWelcome(session, welcome, recognizedUsers)
RT-->>WS: true (joined group)
WS->>WS: prepareDaveMediaTransition(transitionId, pv)
Note over WS: Creates decryptors for<br/>all recognized users;<br/>stores key ratchets and uses<br/>passthrough grace during setup
WS->>GW: Op 23 Transition Ready<br/>{ transition_id }
GW-->>WS: Op 22 Execute Transition<br/>{ transition_id }
WS->>WS: applySelfDaveEncryptor(pv)
WS->>ST: executeTransition(transitionId)
Note over ST: E2EE active for new member
When a member joins or leaves, existing group members receive a commit to advance the MLS epoch.
sequenceDiagram
autonumber
participant WS as Client\WS (existing member)
participant GW as Voice Gateway
participant RT as Dave\Runtime
participant ST as Dave\State
GW-->>WS: Op 11 Client Connect<br/>{ user_ids: [...] }
WS->>ST: addRecognizedUsers(userIds)
Note over WS: When a member leaves:
GW-->>WS: Op 13 Client Disconnect<br/>{ user_id }
WS->>ST: removeRecognizedUser(userId)
Note over ST: Clears decryptor for<br/>disconnected user
GW-->>WS: Op 29 MLS Announce Commit (binary)<br/>[transitionId + commit]
WS->>WS: splitTransitionPayload(payload)
WS->>RT: processCommit(session, commit)
RT-->>WS: { failed: false, ignored: false }
WS->>WS: prepareDaveMediaTransition(transitionId, pv)
Note over WS: Creates/updates decryptors<br/>for all recognized users<br/>with retained key ratchets and<br/>passthrough grace during setup
WS->>GW: Op 23 Transition Ready<br/>{ transition_id }
GW-->>WS: Op 22 Execute Transition<br/>{ transition_id }
WS->>WS: applySelfDaveEncryptor(pv)
WS->>ST: executeTransition(transitionId)
Note over ST: New key ratchet in effect
For non-zero transition IDs, Client\WS sends Opcode 23 and waits for Opcode 22 before applying the local media transition. Transition ID 0 is the Discord gateway's immediate-transition shortcut: the client executes it locally without sending Opcode 23 or waiting for Opcode 22.
When E2EE must be disabled (e.g. a client without DAVE support joins during the transition phase).
sequenceDiagram
autonumber
participant WS as Client\WS
participant GW as Voice Gateway
participant ST as Dave\State
participant RT as Dave\Runtime
GW-->>WS: Op 21 Prepare Transition<br/>{ transition_id, protocol_version: 0 }
WS->>WS: prepareDaveMediaTransition(transitionId, 0)
Note over WS: Clears all decryptors<br/>(protocol version ≤ 0)
WS->>GW: Op 23 Transition Ready<br/>{ transition_id }
GW-->>WS: Op 22 Execute Transition<br/>{ transition_id }
WS->>RT: resetSession(session)
WS->>ST: resetProtocolState()
WS->>ST: setProtocolVersion(0)
Note over ST: passthroughMode = true<br/>E2EE disabled, transport-only
When a commit or welcome message can't be processed.
sequenceDiagram
autonumber
participant WS as Client\WS
participant GW as Voice Gateway
participant RT as Dave\Runtime
participant ST as Dave\State
GW-->>WS: Op 29 or Op 30 (commit/welcome)
WS->>RT: processCommit() or processWelcome()
RT-->>WS: failed / null
WS->>GW: Op 31 MLS Invalid Commit Welcome (binary)
Note over WS: Signals to gateway:<br/>"Please re-add me to the group"
WS->>WS: initializeDaveRuntimeState(pv, resetState=true)
Note over WS: Resets local MLS session,<br/>creates fresh session
WS->>RT: getMarshalledKeyPackage(session)
WS->>GW: Op 26 MLS Key Package (binary)<br/>[fresh key package]
Note over GW: Gateway proposes removal<br/>and re-addition of this client
How audio frames are encrypted (outbound) and decrypted (inbound) through the two encryption layers: DAVE E2EE (frame-level) and transport encryption (packet-level).
flowchart LR
A["🎤 Raw PCM"] --> B["Ffmpeg::encode()<br/><small>PCM → Opus</small>"]
B --> C["OggStream<br/><small>Ogg container parsing</small>"]
C --> D["playOggStream()<br/><small>Buffers frames,<br/>manages timing</small>"]
D --> E{"DAVE<br/>Active?"}
E -->|Yes| F["VoiceClient::<br/>encryptDaveFrame()"]
E -->|No / Passthrough| G["Raw Opus frame"]
F --> H["Runtime::<br/>encryptWithEncryptor()<br/><small>AES-128-GCM E2EE</small>"]
H --> H2{"Encryption<br/>OK?"}
H2 -->|Yes| I["Packet::encrypt()<br/><small>AES-256-GCM transport<br/>+ RTP header</small>"]
H2 -->|No + Active| X["❌ Drop frame<br/><small>Preserves E2EE integrity</small>"]
H2 -->|No + Passthrough| G
G --> I
I --> J["UDP::sendBuffer()<br/><small>→ Discord SFU</small>"]
style E fill:#ff9f43,color:#fff
style F fill:#6c5ce7,color:#fff
style H fill:#6c5ce7,color:#fff
style I fill:#00b894,color:#fff
flowchart RL
A["Discord SFU<br/>→ UDP"] --> B["Packet::decrypt()<br/><small>AES-256-GCM transport<br/>strip RTP header</small>"]
B --> B2["Strip RTP extension payload<br/><small>after transport decrypt,<br/>before DAVE frame decrypt</small>"]
B2 --> C{"DAVE<br/>Active?"}
C -->|Yes| D["VoiceClient::<br/>decryptDaveFrame()"]
C -->|No / Passthrough| E["Raw Opus frame"]
D --> F["Resolve SSRC<br/>→ userId<br/>→ DecryptorHandle"]
F --> G["Runtime::<br/>decryptWithDecryptor()<br/><small>AES-128-GCM E2EE</small>"]
G --> H{"Decryption<br/>OK?"}
H -->|Yes| I["Opus frame"]
H -->|No| J["❌ Drop frame"]
E --> I
I --> K["Opus decoder<br/><small>→ PCM</small>"]
K --> L["🔊 ReceiveStream"]
style C fill:#ff9f43,color:#fff
style D fill:#6c5ce7,color:#fff
style G fill:#6c5ce7,color:#fff
style B fill:#00b894,color:#fff
style B2 fill:#00b894,color:#fff
Inbound RTP header extensions are removed only after transport decryption succeeds and before decryptDaveFrame() runs. This keeps Discord's RTP extension bytes out of the DAVE media frame while preserving normal RTP authentication.
┌─────────────────────────────────────────────────────────────────────┐
│ UDP Packet (wire) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Transport Encryption (Packet) │ │
│ │ AES-256-GCM • Key from Session Description │ │
│ │ ┌─────────────┬─────────────────────────────────────────┐ │ │
│ │ │ RTP Header │ Encrypted Payload │ │ │
│ │ │ (12 bytes) │ ┌───────────────────────────────────┐ │ │ │
│ │ │ │ │ DAVE E2EE (Runtime) │ │ │ │
│ │ │ │ │ AES-128-GCM • Per-sender key │ │ │ │
│ │ │ │ │ ┌───────────────────────────┐ │ │ │ │
│ │ │ │ │ │ Opus Audio Frame │ │ │ │ │
│ │ │ │ │ └───────────────────────────┘ │ │ │ │
│ │ │ │ │ + Auth Tag (8B) + Nonce + Magic │ │ │ │
│ │ │ │ └───────────────────────────────────┘ │ │ │
│ │ └─────────────┴─────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
The lifecycle of Dave\State through protocol transitions.
stateDiagram-v2
[*] --> Unavailable: libdave not loaded
[*] --> Passthrough: WS constructed<br/>protocolVersion = 0
Passthrough --> Initializing: Session Description<br/>dave_protocol_version > 0
Initializing --> AwaitingGroup: initializeDaveRuntimeState()<br/>Session + Encryptor created
AwaitingGroup --> AwaitingGroup: Session Description / Prepare Epoch<br/>Send Key Package (Op 26)
AwaitingGroup --> TransitionPending: Commit/Welcome received<br/>prepareTransition()
TransitionPending --> Active: executeTransition()<br/>or transition_id 0 local completion<br/>passthroughMode = false
Active --> TransitionPending: New Commit/Welcome<br/>(member join/leave)<br/>prepareTransition()
Active --> Downgrading: Prepare Transition<br/>protocol_version = 0
Downgrading --> Passthrough: Execute Transition<br/>resetProtocolState()
Active --> ErrorRecovery: Invalid commit/welcome
ErrorRecovery --> AwaitingGroup: Reset session<br/>Send new Key Package
TransitionPending --> Active: Execute Transition<br/>applySelfDaveEncryptor()
state Active {
direction LR
Encrypting: encryptDaveFrame()<br/>per outbound frame
Decrypting: decryptDaveFrame()<br/>per inbound frame
note right of Encrypting: Drops frame on failure<br/>to preserve E2EE integrity
}
This section describes each DAVE opcode handler in Client\WS at the code level — where state is mutated, what is sent over the wire, and how the transition-readiness check gates E2EE activation.
handleSessionDescription() extracts dave_protocol_version and calls initializeDaveRuntimeState(), which:
SessionHandle via DaveRuntime::createSession() and stores it in State::$session.EncryptorHandle and stores it in State::$encryptor.DaveRuntime::initializeSession() with selfUserId and the negotiated groupId.externalSenderPackage to the session if one has already arrived via Op 25.sendDaveKeyPackage().passthroughMode stays true until the first successful executeTransition().
handleDavePrepareEpoch() reads epoch, optional transition_id, and protocol_version from the JSON payload:
State::prepareEpoch($epoch) to record the current epoch number.transition_id is present, calls State::prepareTransition() to record it; otherwise updates latestPreparedTransitionVersion.protocol_version > 0, calls initializeDaveRuntimeState() (with resetState=true when epoch === 1).sendDaveKeyPackage().A session initialization failure here is fatal: the socket is closed immediately (fail-closed security policy).
handleDaveMlsExternalSender() receives a binary frame containing the gateway's MLS external sender credential:
State::recordExternalSender() to persist the credential for replay inside initializeDaveRuntimeState().SessionHandle is already present, calls DaveRuntime::setExternalSender() immediately.sendDaveKeyPackage() — if the key package was not yet sent, this emits Op 26.Op 25 may arrive before, during, or after the Op 4 session setup; storing the credential and replaying it inside init makes the ordering safe.
handleDaveMlsProposals() receives a binary frame with MLS Add/Remove proposals from the external sender:
DaveRuntime::buildMlsCommitWelcomeWithSession() to let libdave build a commit (and optional welcome messages) from the proposals.State::$proposalFailureCount. Three consecutive failures trigger a socket close so the client reconnects and obtains a fresh DAVE epoch.handleDaveMlsCommitWelcome() receives the winning commit/welcome from the gateway:
DaveRuntime::processCommit() on the stored session. On success (and not ignored), calls prepareDaveMediaTransition() then completeDaveMediaTransition().processCommit fails, falls through to DaveRuntime::processWelcome() — the path taken by the client that is joining an existing group.handleInvalidDaveTransition() to begin error recovery (Op 31 + session reset + fresh Op 26).handleDaveMlsAnnounceCommitTransition() is the server-broadcast path for existing group members:
TransitionPayload::parse() extracts the 2-byte big-endian transition_id from bytes 0–1 of the payload; the remainder is the raw MLS commit.DaveRuntime::processCommit().prepareDaveMediaTransition() then completeDaveMediaTransition(), which branches on transition_id:
transition_id — executes the media transition immediately and locally without a gateway round-trip.transition_id — sends Op 23 (Transition Ready) and waits for the gateway to confirm with Op 22 (Execute Transition).handleDaveExecuteTransition() → executeDaveMediaTransition($transitionId):
State::$pendingTransitionId; mismatches are silently ignored.DaveRuntime::resetSession(), then State::resetProtocolState() + State::setProtocolVersion(0) — restoring full passthrough.applySelfDaveEncryptor() to bind the new MLS epoch's key ratchet to State::$encryptor, then State::executeTransition() which...How can I help you explore Laravel packages today?