Files
amethyst/nestsClient/specs/EGG-03.md
Claude bd12d5ef72 docs(nestsClient/specs): close interop gaps surfaced in spec review
Edits across all 13 EGGs to remove implementer guesswork. After this an
implementer can build a listener and speaker without reading our source.

README:
- Conventions section: hex casing rule (lowercase, 64 chars, no 0x),
  NIP-01 foundations, `a`-tag form, created_at tie-break, JSON / time.
- Joining sequence: numbered end-to-end walkthrough from `kind:30312`
  to first audio frame, referencing each EGG.

EGG-01 (room event):
- `relays` tag is one tag with multiple values (not multi-tag).
- `service` URL trailing-slash normalization.
- `private` status: gate is implementation-defined, not "render as open".
- `d`-tag charset locked to [A-Za-z0-9._-] so it interpolates safely
  into the moq-auth namespace.
- Relay-discovery rule: publish to `relays` tag ∪ NIP-65 outbox.
- Tie-break on identical created_at via smallest event id.

EGG-02 (auth):
- JWT signing pinned: ES256 over P-256, JWKS at /.well-known/jwks.json,
  5-minute relay cache.
- NIP-98 tags pinned: u / method / payload, base64 RFC 4648 standard
  (not base64url).
- Error taxonomy: full HTTP status + `error` slug table for /auth, plus
  WebTransport CONNECT 200/401/403/404 table.

EGG-03 (audio):
- Pin moq-lite Lite-03 to kixelated/moq-rs `rs/moq-lite/src/lite/`.
- One Opus packet per moq Frame (no container, no timestamp).
- Pubkey hex casing reaffirmed at the suffix / broadcast slots.
- AnnouncePlease prefix="" for speaker discovery.
- Mute = stop publishing (not silence frames, not Announce Ended).
- Mid-stream join: discard pre-skip per RFC 7845.

EGG-04 (presence):
- Heartbeat jitter ±5 s required (anti-thundering-herd).
- "0"/"1" are strings, not booleans.
- Single-room rule via replaceable-event semantics.

EGG-05 (chat): 8 KB suggested, 64 KB hard cap, 3 msg/s render rate.
EGG-06 (reactions): 30s window measured against created_at, drop-on-arrival
                    if already stale.
EGG-07 (moderation): replay protection — dedupe kicks by id within 120 s.
EGG-08 (scheduling): planned rooms MUST 403 at the auth sidecar.
EGG-10 (theming): bg image caps (1 MB / 4096 px soft, 8 MB / 8192 px hard).

Deferred (per review): EGG-00 (Conventions as a standalone spec), EGG-13
(capability advertisement), EGG-14 (discovery), test vectors corpus.
The "Conventions" section in README covers EGG-00's most urgent content
inline.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
2026-04-27 12:31:19 +00:00

6.0 KiB

EGG-03: Audio plane (moq-lite)

status: draft requires: EGG-02 category: required

Summary

Audio is carried over moq-lite Lite-03 on top of the WebTransport session opened in EGG-02. Each speaker publishes one broadcast keyed by their pubkey hex, on a single track named audio/data, encoded as Opus.

This EGG defines the broadcast/track naming convention and the audio codec parameters. It does not redefine moq-lite framing — refer to the upstream spec for the wire layout of Subscribe, Announce, Group, and Frame control messages.

Normative reference for moq-lite Lite-03

The wire formats for AnnouncePlease, Announce, Subscribe, Group header, and Frame are defined by the Rust reference implementation at:

https://github.com/kixelated/moq-rs
  rs/moq-lite/src/lite/{announce,subscribe,group,setup,session}.rs

Implementers MUST track Lite-03 (NOT the IETF moq-transport draft, which is incompatible). Cross-version interop tests MUST pin a specific moq-rs commit hash for reproducibility.

All path / broadcast / track strings on the moq-lite wire are length-prefixed UTF-8. All integers are RFC 9000 §16 varints unless the upstream codec says otherwise. Path components on this wire MUST contain only the characters allowed in a URL path segment per RFC 3986; relays typically reject / inside a single path component.

Wire format

Speaker → relay (publish)

A speaker MUST send a moq-lite Announce with:

prefix     = ""           (empty — relative to the JWT's `root` namespace)
suffix     = <pubkey hex>      (lowercase, 64 chars, see Conventions in README)
status     = Active            (status byte 0x01 per Lite-03)
hops       = 0

After the announce, the speaker opens a unidirectional QUIC stream per group, prefixed with a moq-lite Group header followed by Frames carrying Opus payloads.

Each moq-lite Frame carries exactly one Opus packet (one 20 ms frame). Producers MUST NOT pack multiple Opus packets into a single Frame, MUST NOT prefix the payload with an Ogg page or any container, and MUST NOT carry an explicit timestamp inside the Frame — the receive order plus the fixed 20 ms cadence is the timing contract.

Listener → relay (subscribe)

A listener MUST send a moq-lite Subscribe with:

broadcast  = <speaker pubkey hex>     (lowercase, 64 chars)
track      = "audio/data"
priority   = 128            (recommended)
ordered    = true
maxLatency = 0              (unlimited)

To learn which broadcasts exist on this session, a listener sends a moq-lite AnnouncePlease with prefix="" once after the moq-lite Setup handshake completes. The relay then streams Announce frames for every active speaker; the listener subscribes to each per the form above.

The relay forwards each subsequent Group and its Frames to the listener in subscribe-id order.

Codec

Parameter Value
Codec Opus (RFC 6716)
Sample rate 48 kHz
Channels 1 (mono)
Frame duration 20 ms (960 samples)
Bit-rate target 32 kbit/s (configurable, no upper cap)
VBR Allowed

A new Group MUST be opened on every Opus reset (e.g. mute/unmute).

Behavior

  1. A speaker MUST emit an Announce with status=Active immediately after the WebTransport session reaches a usable state, and emit status=Ended when stopping the broadcast cleanly.
  2. A speaker MUST cap their broadcast to one concurrent audio/data track per session. Multi-quality stacks are reserved for a future EGG.
  3. Listeners MUST tolerate gaps in the group sequence (frames dropped due to network loss). They MUST NOT request retransmits.
  4. Listeners SHOULD subscribe with ordered=true. Out-of-order delivery would re-introduce reorderable jitter the Opus decoder is not equipped to handle.
  5. A speaker MUST encode in mono. Stereo broadcasts are reserved for a future EGG.
  6. Listeners MUST NOT subscribe to their own broadcast (would create an audio loopback through the relay).
  7. The relay MUST drop a publishing peer's Announce and any subsequent stream data if the JWT's put claim does not contain <pubkey hex>.
  8. Mute behavior. Muting MUST be implemented as "stop publishing" — the speaker closes their currently-open Group stream and does NOT open a new one until unmute. Speakers MUST NOT push silence frames to fake continuity, and MUST NOT send Announce status=Ended on mute (mute is a transient state; ending the broadcast is reserved for leaving the stage). The presence event's muted flag (EGG-04) communicates the intent to listeners that lack the audio plane.
  9. Joining mid-stream. Listeners MUST tolerate joining at any point inside a Group; the first received Frame is independently decodable (Opus is a self-synchronising codec). The first decoded sample MAY contain a few ms of pre-roll noise; receivers SHOULD discard the first decoded packet's pre-skip samples per RFC 7845 §4.2.

Example

A two-speaker room:

A's session:  ANNOUNCE  prefix="" suffix="speakerA" status=Active
              GROUP     subscribe-id=…, sequence=0
              FRAME     <opus payload, 20ms>
              FRAME     <opus payload, 20ms>
              …

Listener's session:
  SUBSCRIBE  broadcast="speakerA" track="audio/data"
  ←  GROUP   sequence=0
  ←  FRAME   <opus payload, 20ms>
  ←  …
  SUBSCRIBE  broadcast="speakerB" track="audio/data"
  ←  GROUP   sequence=0
  ←  FRAME   <opus payload, 20ms>
  ←  …

Compatibility

EGG-12 adds an OPTIONAL catalog.json track per broadcast carrying codec metadata. EGG-12 MUST NOT change the audio/data track parameters defined here.

A future "video plane" EGG MAY introduce additional tracks; their names MUST NOT collide with audio/data or catalog.json.