Discord: Screen Share Hosting and Viewing. #14

Closed
opened 2026-08-23 23:55:54 -04:00 by Salastil · 9 comments
Owner

Discord is capable of screen sharing, both hosting and viewing in DM and multi viewer channels. We need to implement this.

Discord is capable of screen sharing, both hosting and viewing in DM and multi viewer channels. We need to implement this.
Salastil added the feature label 2026-08-23 23:55:54 -04:00
Author
Owner

Status after the voice work: half done.

Done — seeing who is sharing. VoiceFlags::from_voice_state reads self_stream and self_video off the voice state, listVoiceMembers/listVoiceChannels surface them, and CallView.tsx and VoiceChannels.tsx badge them (moho 9a56328).

Not done — hosting or watching an actual stream. This is blocked rather than merely unstarted: voice runs on Songbird, which is audio-only — no video codec, no video RTP, no video on Driver. Outgoing self_video is hard-coded false in all three op-4 sends. Carrying video means either a different voice stack or a second transport alongside Songbird, which is a materially bigger piece of work than the flag plumbing.

Suggest keeping this open and scoping the video half deliberately.

Filed from a protocol coverage survey.

Status after the voice work: **half done.** Done — *seeing* who is sharing. `VoiceFlags::from_voice_state` reads `self_stream` and `self_video` off the voice state, `listVoiceMembers`/`listVoiceChannels` surface them, and `CallView.tsx` and `VoiceChannels.tsx` badge them (moho `9a56328`). Not done — hosting or watching an actual stream. This is blocked rather than merely unstarted: voice runs on Songbird, which is audio-only — no video codec, no video RTP, no video on `Driver`. Outgoing `self_video` is hard-coded false in all three op-4 sends. Carrying video means either a different voice stack or a second transport alongside Songbird, which is a materially bigger piece of work than the flag plumbing. Suggest keeping this open and scoping the video half deliberately. _Filed from a protocol coverage survey._
Author
Owner

Progress, and a finding about what is left.

The shared half is built and proven — on Matrix, where the transport allows it (see #81): the screen picker (Electron's own list of screens and windows, each with a still, because two browser windows are named almost the same), the call stage with video tiles, adding a screen as its own track to a call in progress, renegotiating for it, and drawing it at the far end. Verified live: a screen shared from one account arrived at the other as a live video track.

What blocks the Discord half is the transport, and only the transport. Discord's voice connection is raw UDP with their own encryption and their own RTP; video rides that same connection, with op 12 to allocate video SSRCs. Two consequences:

  1. moho's Discord voice is built on Songbird, which has no video path at all — grepping the crate for "video" finds only audio extraction from video files and a YouTube search helper. Hosting or viewing a Discord screen share means implementing Discord's video protocol in the daemon: SSRC allocation, their RTP shape, H.264/VP8 encode for hosting, decode plus a frame pipe to the renderer for viewing.
  2. The trick that made Matrix work — hand the media to the renderer, which is a browser and already has a complete WebRTC stack — does not transfer. A renderer cannot open a raw UDP socket or speak Discord's encryption, so the connection has to stay in the daemon, which is the process least able to carry pictures.

So this is not blocked on UI or on design any more; the call surface it needed now exists and is shared. It is blocked on writing Discord's video protocol in Rust, which is a large piece of work on its own, and one that cannot be exercised without risking the account (a test account was deauthed for two ordinary API sends earlier).

Leaving open rather than closing: the want is real and the remaining work is now precisely described.

Progress, and a finding about what is left. **The shared half is built and proven** — on Matrix, where the transport allows it (see #81): the screen picker (Electron's own list of screens and windows, each with a still, because two browser windows are named almost the same), the call stage with video tiles, adding a screen as its own track to a call in progress, renegotiating for it, and drawing it at the far end. Verified live: a screen shared from one account arrived at the other as a live video track. **What blocks the Discord half is the transport, and only the transport.** Discord's voice connection is raw UDP with their own encryption and their own RTP; video rides that same connection, with op 12 to allocate video SSRCs. Two consequences: 1. moho's Discord voice is built on **Songbird**, which has no video path at all — grepping the crate for "video" finds only audio extraction from video *files* and a YouTube search helper. Hosting or viewing a Discord screen share means implementing Discord's video protocol in the daemon: SSRC allocation, their RTP shape, H.264/VP8 encode for hosting, decode plus a frame pipe to the renderer for viewing. 2. The trick that made Matrix work — hand the media to the renderer, which is a browser and already has a complete WebRTC stack — **does not transfer**. A renderer cannot open a raw UDP socket or speak Discord's encryption, so the connection has to stay in the daemon, which is the process least able to carry pictures. So this is not blocked on UI or on design any more; the call surface it needed now exists and is shared. It is blocked on writing Discord's video protocol in Rust, which is a large piece of work on its own, and one that cannot be exercised without risking the account (a test account was deauthed for two ordinary API sends earlier). Leaving open rather than closing: the want is real and the remaining work is now precisely described.
Salastil added this to the Protocols project 2026-09-11 14:57:39 -04:00
Salastil moved this to Intermediate Pass in Protocols on 2026-09-11 14:58:02 -04:00
Salastil removed this from the Protocols project 2026-09-11 14:59:17 -04:00
Author
Owner

The UI arrangement for calls is now settled by #189, and this should follow it rather than invent its own.

One row of controls on CallStage, in this order: microphone, camera, screen. Each button is offered on every call whatever shape it is, and each does one thing — the component is already service-agnostic ("it draws two streams and a row of buttons, which is what a call is on any service").

The camera contract that came out of #189 and is worth copying exactly:

  • Turning a camera off removes the track and renegotiates, rather than setting enabled = false. A disabled track keeps sending a black rectangle, which the far end cannot tell from a frozen camera.
  • cameraOn is asked of the call, never held as a fixed value — the phase-change handler runs repeatedly and a fixed answer puts the button back to "off" the moment a call connects.
  • Renegotiations are serialised; camera-on followed a second later by screen-share is two changes to one connection.
  • A remote track that goes away is dropped from its stream, so the tile stops drawing its last frame.

Discord's side is different in one way that matters: its audio lives in the daemon, because the connection is a bespoke protocol no browser speaks. So whichever of these two is done first has to decide where Discord's video is decoded before the buttons can be wired up — that is the real work here, not the controls.

scripts/callcheck/run.sh is a loopback harness that proves the camera half end to end with no hardware and no second person; it is worth pointing at whatever Discord ends up using.

The UI arrangement for calls is now settled by #189, and this should follow it rather than invent its own. One row of controls on `CallStage`, in this order: **microphone, camera, screen**. Each button is offered on every call whatever shape it is, and each does one thing — the component is already service-agnostic ("it draws two streams and a row of buttons, which is what a call is on any service"). The camera contract that came out of #189 and is worth copying exactly: - Turning a camera **off removes the track and renegotiates**, rather than setting `enabled = false`. A disabled track keeps sending a black rectangle, which the far end cannot tell from a frozen camera. - `cameraOn` is **asked of the call**, never held as a fixed value — the phase-change handler runs repeatedly and a fixed answer puts the button back to "off" the moment a call connects. - Renegotiations are **serialised**; camera-on followed a second later by screen-share is two changes to one connection. - A remote track that goes away is **dropped from its stream**, so the tile stops drawing its last frame. Discord's side is different in one way that matters: its audio lives in the daemon, because the connection is a bespoke protocol no browser speaks. So whichever of these two is done first has to decide where Discord's video is decoded before the buttons can be wired up — that is the real work here, not the controls. `scripts/callcheck/run.sh` is a loopback harness that proves the camera half end to end with no hardware and no second person; it is worth pointing at whatever Discord ends up using.
Author
Owner

Investigated properly before building, and there is a hard blocker in the middle. Recording it so it is not rediscovered.

Songbird has no video path at all. The voice connection is Songbird's precisely because hand-rolling one stopped being reasonable (websocket + UDP hole punching + Opus + DAVE). Checked against songbird 0.6.0 as vendored: the only occurrences of "video" in its whole source are YouTube URLs in constants.rs and ytdl.rs, and Driver's public API is connect/leave/mute/play*/set_bitrate/stop/config/events/queue — no raw packet send, no UDP socket, no SSRC control. So no video can go out over the connection we already have.

What Go Live actually needs. A stream is a second, separate voice connection: gateway op 18 creates it and Discord answers with a stream key (guild:<guild>:<channel>:<user> or call:<channel>:<user>), an RTC server, and a STREAM_SERVER_UPDATE carrying its own endpoint and token. Video then goes out as RTP on that connection with its own SSRC. Op 19 watches somebody else's, op 20 pauses, op 22 ends it.

So hosting means hand-rolling that entire second voice connection outside Songbird — the exact work Songbird exists here to avoid — and watching means receiving and decoding VP8/H.264 RTP, which Songbird's receive path (Opus only) also cannot do.

The encoder half is fine. Measured in Chromium: VideoEncoder.isConfigSupported reports vp8, vp09.00.10.08 and avc1.42001f all supported, and 90 frames of 1280x720 encode to ~200 KB at 2.5 Mbit. So the architecture that fits this codebase is the one the Matrix calls already imply — the window encodes (WebCodecs), the daemon packetises and sends — since the browser has the encoders and the protocol is one no browser speaks.

One measured detail that matters: the largest encoded chunk was 4409 bytes, against an RTP payload budget of roughly 1150. Fragmentation is mandatory, not an optimisation.

(The fps figure my harness printed measures its own canvas frame generator, not the encoder, and is not worth quoting.)

Testing is blocked too. Op 18 is a write to Discord with a real account. Salastil is receive-only, and An Person is in lockdown until the end of 2026-09-15 — so none of this can be exercised against Discord for another two days regardless.

What is left, in order: a hand-rolled stream voice connection (ws + UDP + encryption); a VP8 RTP packetiser; a WebCodecs encoder in the renderer feeding it over the existing socket; then the receive-and-decode half for watching. Days of work, and the middle two are the only parts testable without Discord.

Nothing shipped. A "Share your screen" button that creates a stream sending no pixels would show every viewer a black rectangle, which is worse than no button.

Investigated properly before building, and there is a hard blocker in the middle. Recording it so it is not rediscovered. **Songbird has no video path at all.** The voice connection is Songbird's precisely because hand-rolling one stopped being reasonable (websocket + UDP hole punching + Opus + DAVE). Checked against songbird 0.6.0 as vendored: the only occurrences of "video" in its whole source are YouTube URLs in `constants.rs` and `ytdl.rs`, and `Driver`'s public API is `connect/leave/mute/play*/set_bitrate/stop/config/events/queue` — no raw packet send, no UDP socket, no SSRC control. So no video can go out over the connection we already have. **What Go Live actually needs.** A stream is a *second, separate* voice connection: gateway op 18 creates it and Discord answers with a stream key (`guild:<guild>:<channel>:<user>` or `call:<channel>:<user>`), an RTC server, and a `STREAM_SERVER_UPDATE` carrying its own endpoint and token. Video then goes out as RTP on that connection with its own SSRC. Op 19 watches somebody else's, op 20 pauses, op 22 ends it. So hosting means hand-rolling that entire second voice connection outside Songbird — the exact work Songbird exists here to avoid — and watching means receiving and decoding VP8/H.264 RTP, which Songbird's receive path (Opus only) also cannot do. **The encoder half is fine.** Measured in Chromium: `VideoEncoder.isConfigSupported` reports **vp8, vp09.00.10.08 and avc1.42001f all supported**, and 90 frames of 1280x720 encode to ~200 KB at 2.5 Mbit. So the architecture that fits this codebase is the one the Matrix calls already imply — the window encodes (WebCodecs), the daemon packetises and sends — since the browser has the encoders and the protocol is one no browser speaks. One measured detail that matters: **the largest encoded chunk was 4409 bytes**, against an RTP payload budget of roughly 1150. Fragmentation is mandatory, not an optimisation. (The fps figure my harness printed measures its own canvas frame generator, not the encoder, and is not worth quoting.) **Testing is blocked too.** Op 18 is a write to Discord with a real account. Salastil is receive-only, and An Person is in lockdown until the end of 2026-09-15 — so none of this can be exercised against Discord for another two days regardless. **What is left, in order:** a hand-rolled stream voice connection (ws + UDP + encryption); a VP8 RTP packetiser; a WebCodecs encoder in the renderer feeding it over the existing socket; then the receive-and-decode half for watching. Days of work, and the middle two are the only parts testable without Discord. Nothing shipped. A "Share your screen" button that creates a stream sending no pixels would show every viewer a black rectangle, which is worse than no button.
Author
Owner

Hosting is built, end to end, hand-rolled. Watching is not.

Songbird is kept, and that turned out to be the pragmatic answer rather than a compromise. A Go Live stream is a separate voice connection whatever else is true — Discord answers the request with a different RTC server, token and session — so the video never had any business on the channel connection. Replacing songbird would have bought nothing for this feature and put working audio at risk. (Camera, #180, is the opposite case: it shares the channel connection, so it is the one that would need the replacement.)

What is on the wire now:

  • golive — the gateway half: asking for a stream, ending one, pausing, watching, and taking in the dispatches. The stream key has two exact shapes and anything else is refused rather than half-read, since a key with an empty field would otherwise parse as a stream in a channel called "".
  • rtp — VP8 packetisation. The S bit on the first packet and the marker on the last are the whole of how a receiver reassembles; a frame goes out and comes back byte-for-byte in the test. Sequence numbers are reserved before any packet is built, because two frames handed in at once would interleave and the far end reads that as loss.
  • voicecrypto — Discord's packet sealing, both modes, written against songbird's own implementation of the same two rather than from a description: where the tag goes, which bytes are the additional data, and whether the counter is big-endian each produce a packet the far end silently drops.
  • streamconn — the connection: websocket handshake, UDP IP discovery, mode negotiated from what the server offers, RTP out. The discovery request's length field counts the seventy bytes after itself, and the answer's address is a C string read to its terminator rather than trimmed.
  • discordscreen.ts — the window encodes. Chromium has the encoders and the daemon has none, which is the division the Matrix calls already draw from the other side.

The UI is Matrix's, unchanged. One screen button, same place on the call header, same gesture — toggleScreenShare answers Discord first because a Discord call has no activeCall behind it.

What is not verified. The send opcodes and the handshake sequence. Discord documents none of it and the user gateway ignores an opcode it does not know without an error, so a wrong number is a button that does nothing and explains nothing. Salastil is receive-only and An Person is in lockdown until the end of 2026-09-15, so the first real run cannot happen before then. The opcode constants carry a comment saying they are the first place to look.

The pieces that can be checked offline are: 22 tests across the four modules, and the VP8 encoder measured in Chromium (vp8/vp9/avc1 all supported; 1280x720 chunks up to 4409 bytes against an ~1100-byte RTP budget, which is why fragmentation exists).

Watching somebody else's screen is still not offered and the tooltip says so rather than opening something permanently black. It needs the far end's video received and decoded — the receive path, a jitter buffer, and a decoder in the window. That is the remaining half of this issue.

Hosting is built, end to end, hand-rolled. Watching is not. **Songbird is kept, and that turned out to be the pragmatic answer rather than a compromise.** A Go Live stream is a *separate* voice connection whatever else is true — Discord answers the request with a different RTC server, token and session — so the video never had any business on the channel connection. Replacing songbird would have bought nothing for this feature and put working audio at risk. (Camera, #180, is the opposite case: it shares the channel connection, so it is the one that would need the replacement.) **What is on the wire now:** - `golive` — the gateway half: asking for a stream, ending one, pausing, watching, and taking in the dispatches. The stream key has two exact shapes and anything else is refused rather than half-read, since a key with an empty field would otherwise parse as a stream in a channel called `""`. - `rtp` — VP8 packetisation. The S bit on the first packet and the marker on the last are the whole of how a receiver reassembles; a frame goes out and comes back byte-for-byte in the test. Sequence numbers are reserved before any packet is built, because two frames handed in at once would interleave and the far end reads that as loss. - `voicecrypto` — Discord's packet sealing, both modes, written against songbird's own implementation of the same two rather than from a description: where the tag goes, which bytes are the additional data, and whether the counter is big-endian each produce a packet the far end silently drops. - `streamconn` — the connection: websocket handshake, UDP IP discovery, mode negotiated from what the server offers, RTP out. The discovery request's length field counts the seventy bytes *after* itself, and the answer's address is a C string read to its terminator rather than trimmed. - `discordscreen.ts` — the window encodes. Chromium has the encoders and the daemon has none, which is the division the Matrix calls already draw from the other side. **The UI is Matrix's, unchanged.** One screen button, same place on the call header, same gesture — `toggleScreenShare` answers Discord first because a Discord call has no `activeCall` behind it. **What is not verified.** The send opcodes and the handshake sequence. Discord documents none of it and the user gateway ignores an opcode it does not know without an error, so a wrong number is a button that does nothing and explains nothing. Salastil is receive-only and An Person is in lockdown until the end of 2026-09-15, so the first real run cannot happen before then. The opcode constants carry a comment saying they are the first place to look. The pieces that *can* be checked offline are: 22 tests across the four modules, and the VP8 encoder measured in Chromium (vp8/vp9/avc1 all supported; 1280x720 chunks up to 4409 bytes against an ~1100-byte RTP budget, which is why fragmentation exists). **Watching somebody else's screen is still not offered** and the tooltip says so rather than opening something permanently black. It needs the far end's video received and decoded — the receive path, a jitter buffer, and a decoder in the window. That is the remaining half of this issue.
Author
Owner

Blocked on DAVE, and now confirmed on the wire rather than reasoned about.

Live runs against a real DM call between An Person and Salastil. The signalling half works end to end:

STREAM_CREATE  {"paused":false,"region":"newark",
                "stream_key":"call:1540954302632300564:1339667204756475924",
                "rtc_server_id":"1548848737588219954","viewer_ids":[]}
STREAM_SERVER_UPDATE {"endpoint":"c-ewr11-3726d82e.discord.media:2053","token":"<redacted>"}
STREAM_UPDATE  {"viewer_ids":["167790743988076545"]}

So op 18 is right, the call:<channel>:<user> key shape is right, both handshake halves arrive and parse, and Salastil's own client joined as a viewer. Everything up to the media connection is verified.

The media connection is refused with close code 4017, "E2EE protocol required".

Two attempts, two answers:

  1. Identify with no max_dave_protocol_version → 4017. A voice server reads the silence as a client claiming the current version and then holds it to a handshake it never performs.
  2. Identify with max_dave_protocol_version: 0 — the spec's way of saying "not this one" → also 4017. This call insists.

That settles it: DAVE is mandatory here, not optional. It also explains why the channel audio works while the stream does not — songbird's identify sends max_dave_protocol_version: Some(davey::DAVE_PROTOCOL_VERSION) and performs the MLS handshake. Same server, same call, different answer, because one connection speaks DAVE and the other does not. voice.rs's own module comment said this in 2026 and I read it as applying to songbird's connection rather than to the connection.

What implementing it takes. davey is already in the tree as songbird's dependency and is a complete MLS implementation — DaveSession with create_key_package, set_external_sender, process_proposals, process_commit, process_welcome, plus a media cryptor. The cryptography is not ours to write. What is ours:

  • binary websocket frames on the voice socket (opcode + sequence prefix) alongside the JSON ones
  • the DAVE opcodes: external sender, key package, proposals, commit/welcome, announce-commit-transition, welcome, prepare/execute transition, transition-ready, invalid-commit-welcome
  • MLS group state across epoch transitions, including passthrough mode during one
  • encrypting each video frame with the DAVE cryptor before the transport sealing already written in voicecrypto

songbird's driver/tasks/ws.rs is a working reference for all of it, which makes this a matter of following an implementation that demonstrably works against Discord today rather than guessing at an undocumented protocol — which is what the last three attempts were, and they cost three live round trips to find three separate mistakes.

This is also exactly what #180's camera needs, since a camera shares the channel connection that already speaks DAVE via songbird — so whichever is done first should put the DAVE session somewhere both can reach.

Nothing is regressed. The failure is reported in plain words now ("this call requires Discord's end-to-end encryption (DAVE), which moho's stream connection does not speak yet") rather than as a close code, and the screen-share button is honest about stopping there.

Unrelated, found while testing and worth its own issue: this machine's EGL/dmabuf path is broken under Wayland — EGL_BAD_MATCH breaks both window rendering and PipeWire screen capture. --ozone-platform=x11 clears both. Not moho's bug, but it blocks anybody testing screen share on a similar setup.

**Blocked on DAVE, and now confirmed on the wire rather than reasoned about.** Live runs against a real DM call between An Person and Salastil. The signalling half works end to end: ``` STREAM_CREATE {"paused":false,"region":"newark", "stream_key":"call:1540954302632300564:1339667204756475924", "rtc_server_id":"1548848737588219954","viewer_ids":[]} STREAM_SERVER_UPDATE {"endpoint":"c-ewr11-3726d82e.discord.media:2053","token":"<redacted>"} STREAM_UPDATE {"viewer_ids":["167790743988076545"]} ``` So **op 18 is right**, the `call:<channel>:<user>` key shape is right, both handshake halves arrive and parse, and Salastil's own client joined as a viewer. Everything up to the media connection is verified. **The media connection is refused with close code 4017, "E2EE protocol required".** Two attempts, two answers: 1. Identify with no `max_dave_protocol_version` → 4017. A voice server reads the silence as a client claiming the current version and then holds it to a handshake it never performs. 2. Identify with `max_dave_protocol_version: 0` — the spec's way of saying "not this one" → **also 4017**. This call insists. That settles it: DAVE is mandatory here, not optional. It also explains why the channel *audio* works while the stream does not — songbird's identify sends `max_dave_protocol_version: Some(davey::DAVE_PROTOCOL_VERSION)` and performs the MLS handshake. Same server, same call, different answer, because one connection speaks DAVE and the other does not. `voice.rs`'s own module comment said this in 2026 and I read it as applying to songbird's connection rather than to the connection. **What implementing it takes.** `davey` is already in the tree as songbird's dependency and is a complete MLS implementation — `DaveSession` with `create_key_package`, `set_external_sender`, `process_proposals`, `process_commit`, `process_welcome`, plus a media cryptor. The cryptography is not ours to write. What is ours: - binary websocket frames on the voice socket (opcode + sequence prefix) alongside the JSON ones - the DAVE opcodes: external sender, key package, proposals, commit/welcome, announce-commit-transition, welcome, prepare/execute transition, transition-ready, invalid-commit-welcome - MLS group state across epoch transitions, including passthrough mode during one - encrypting each video frame with the DAVE cryptor *before* the transport sealing already written in `voicecrypto` songbird's `driver/tasks/ws.rs` is a working reference for all of it, which makes this a matter of following an implementation that demonstrably works against Discord today rather than guessing at an undocumented protocol — which is what the last three attempts were, and they cost three live round trips to find three separate mistakes. **This is also exactly what #180's camera needs**, since a camera shares the channel connection that already speaks DAVE via songbird — so whichever is done first should put the DAVE session somewhere both can reach. Nothing is regressed. The failure is reported in plain words now ("this call requires Discord's end-to-end encryption (DAVE), which moho's stream connection does not speak yet") rather than as a close code, and the screen-share button is honest about stopping there. **Unrelated, found while testing and worth its own issue:** this machine's EGL/dmabuf path is broken under Wayland — `EGL_BAD_MATCH` breaks both window rendering and PipeWire screen capture. `--ozone-platform=x11` clears both. Not moho's bug, but it blocks anybody testing screen share on a similar setup.
Author
Owner

Hosting works. Verified on the wire, with a real Discord client watching a real screen.

The whole path, hand-rolled, none of it songbird's:

  • op 18 creates the stream; STREAM_CREATE / STREAM_SERVER_UPDATE carry the two halves of the handshake, and the call:<channel>:<user> key shape parses
  • a second voice connection of its own - websocket, UDP IP discovery, aead_aes256_gcm_rtpsize negotiated, VP8 agreed as the video codec
  • DAVE end-to-end encryption: key package up, external sender down, proposals answered with a commit, a welcome back, and the group at epoch 1 - genuinely inside Discord's MLS group rather than one this client invented for itself
  • VP8 encoding in the window via WebCodecs, because Chromium has the encoders and the daemon has none
  • RTP packetisation and double encryption - DAVE for the group, AES-GCM for the wire
  • frames arriving and decoding in Salastil's own Discord client

Four real bugs were found by making the failure speak rather than by guessing, and the last two are worth recording because they are the same mistake twice:

  1. A second connection per stream. Both halves of a handshake stay in pending, so every later STREAM_UPDATE - one arrives each time somebody starts or stops watching - opened another connection on the same voice session. Discord answers that by invalidating one.
  2. max_dave_protocol_version absent. A voice server reads silence as a client claiming the current version, then holds it to a handshake it never performs: close code 4017. Declaring 0 is refused just as firmly, so DAVE is mandatory rather than optional.
  3. Binary frames. Under voice gateway v8 a server-to-client binary frame is [seq: u16][opcode: u8][payload]. songbird speaks v4, where there is no sequence. Reading a v8 frame the v4 way takes half the sequence for an opcode.
  4. The heartbeat. v4's is a bare nonce, {"op":3,"d":<n>}. v8 takes {"t":<nonce>,"seq_ack":<seq>}, because v8 added buffered resume. Sending v4's shape on a v8 socket is a protocol violation answered with 4006, Session is no longer valid - and because the interval fires immediately, that close always landed beside whatever had just been sent, which made it look like the key package and then like the video announcement. It was neither.

songbird is kept. A Go Live stream is a separate connection whatever else is true, so replacing it would have bought nothing here and risked working audio. The camera (#180) is the opposite case - it shares the channel connection - and that is the one that would need it.

What is still missing, and this issue is not closed for it: watching somebody else's stream. That needs the receive path, VP8 depacketisation, a reorder window and a decoder in the window. The Live badge says so rather than opening something permanently black.

Also unfinished: no self-preview of what is being shared.

Launch note for anyone testing on Wayland. This machine's EGL/dmabuf path is broken - EGL_BAD_MATCH breaks window rendering and screen capture, and the capture failure presents as Discord error 2012 on the viewer's side, since a live stream that sends no frames is what 2012 means. Both are cleared with:

moho.AppImage --ozone-platform=x11 --disable-features=WebRTCPipeWireCapturer

Not moho's bug, but it blocked every test until it was separated from the protocol work.

**Hosting works. Verified on the wire, with a real Discord client watching a real screen.** The whole path, hand-rolled, none of it songbird's: - **op 18** creates the stream; `STREAM_CREATE` / `STREAM_SERVER_UPDATE` carry the two halves of the handshake, and the `call:<channel>:<user>` key shape parses - a **second voice connection** of its own - websocket, UDP IP discovery, `aead_aes256_gcm_rtpsize` negotiated, VP8 agreed as the video codec - **DAVE end-to-end encryption**: key package up, external sender down, proposals answered with a commit, a welcome back, and the group at **epoch 1** - genuinely inside Discord's MLS group rather than one this client invented for itself - **VP8 encoding in the window** via WebCodecs, because Chromium has the encoders and the daemon has none - **RTP packetisation** and double encryption - DAVE for the group, AES-GCM for the wire - frames arriving and decoding in Salastil's own Discord client Four real bugs were found by making the failure speak rather than by guessing, and the last two are worth recording because they are the same mistake twice: 1. **A second connection per stream.** Both halves of a handshake stay in `pending`, so every later `STREAM_UPDATE` - one arrives each time somebody starts or stops watching - opened another connection on the same voice session. Discord answers that by invalidating one. 2. **`max_dave_protocol_version` absent.** A voice server reads silence as a client claiming the current version, then holds it to a handshake it never performs: close code 4017. Declaring `0` is refused just as firmly, so DAVE is mandatory rather than optional. 3. **Binary frames.** Under voice gateway **v8** a server-to-client binary frame is `[seq: u16][opcode: u8][payload]`. songbird speaks **v4**, where there is no sequence. Reading a v8 frame the v4 way takes half the sequence for an opcode. 4. **The heartbeat.** v4's is a bare nonce, `{"op":3,"d":<n>}`. v8 takes `{"t":<nonce>,"seq_ack":<seq>}`, because v8 added buffered resume. Sending v4's shape on a v8 socket is a protocol violation answered with 4006, *Session is no longer valid* - and because the interval fires immediately, that close always landed beside whatever had just been sent, which made it look like the key package and then like the video announcement. It was neither. **songbird is kept.** A Go Live stream is a separate connection whatever else is true, so replacing it would have bought nothing here and risked working audio. The camera (#180) is the opposite case - it shares the channel connection - and that is the one that would need it. **What is still missing, and this issue is not closed for it:** watching somebody else's stream. That needs the receive path, VP8 depacketisation, a reorder window and a decoder in the window. The Live badge says so rather than opening something permanently black. Also unfinished: no self-preview of what is being shared. **Launch note for anyone testing on Wayland.** This machine's EGL/dmabuf path is broken - `EGL_BAD_MATCH` breaks window rendering *and* screen capture, and the capture failure presents as Discord error 2012 on the viewer's side, since a live stream that sends no frames is what 2012 means. Both are cleared with: ``` moho.AppImage --ozone-platform=x11 --disable-features=WebRTCPipeWireCapturer ``` Not moho's bug, but it blocked every test until it was separated from the protocol work.
Author
Owner

Hosting has now been run for real end to end, and on Electron 44 (Chromium 152)
rather than the version it was written against - picture arrives, a Discord
client decodes it.

This stays open for the viewing half, which is unchanged: the receive path, VP8
depacketisation, a reorder window and a decoder in the window.

What running it turned up, filed separately rather than piled in here:

  • #231 - the stream is silent. audio_ssrc is announced as 0, so viewers get
    picture only. Worth closing before viewing: a silent stream and a broken one
    look identical from the far side.
  • #232 - there is no echo cancellation on a Discord call at all. Not specific
    to Go Live, but it is what a stream test sounds like.
  • #233 - no local preview of what is being shared.
  • #234 - the stream is fixed at 720p30.
  • #235 - no volume control, per person or overall.
Hosting has now been run for real end to end, and on Electron 44 (Chromium 152) rather than the version it was written against - picture arrives, a Discord client decodes it. This stays open for the viewing half, which is unchanged: the receive path, VP8 depacketisation, a reorder window and a decoder in the window. What running it turned up, filed separately rather than piled in here: - #231 - the stream is silent. `audio_ssrc` is announced as 0, so viewers get picture only. Worth closing before viewing: a silent stream and a broken one look identical from the far side. - #232 - there is no echo cancellation on a Discord call at all. Not specific to Go Live, but it is what a stream test sounds like. - #233 - no local preview of what is being shared. - #234 - the stream is fixed at 720p30. - #235 - no volume control, per person or overall.
Author
Owner
Finished https://git.salastil.com/Salastil/nobilis/commit/996e0c11f101c86f9df7b662c408741fe92dc364 https://git.salastil.com/Salastil/nobilis/commit/efba07039dca901745e1d278c27b4edec164a17f https://git.salastil.com/Salastil/nobilis/commit/9d4e425ebbf3c755271a7efc116043fddc28882b https://git.salastil.com/Salastil/nobilis/commit/92dffca011ca3f2140e62058a45aa1fa0d6b71ad https://git.salastil.com/Salastil/moho/commit/871d64655ced843ce907a8ff8aff8b4c1914422b https://git.salastil.com/Salastil/moho/commit/1a241e140368e79946c48e0dc6f32e28d3c7d9c5 https://git.salastil.com/Salastil/moho/commit/c67cbde8dd03d899e683dcc9ee559ff87264234f
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Salastil/moho#14