docs(audio-rooms): refresh against shipped state + nostrnests gap audit
Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.
- 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
layout: implementation table for every protocol/transport/UI
surface, "pending" table for the remaining items (reconnect,
level meters, Desktop / iOS, Nests parity), pointers section
refreshed.
- 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
that landed it (fb47a4c → 71cf99d → 015b0d7); "When picking up"
section now points at the shipped surface first, raw protocol
references second.
- 2026-04-22-nip-audio-rooms-draft.md — major surgery to match
today's nostrnests reality:
* status banner up top calling out the revision
* dependencies dropped IETF MoQ-transport, added moq-lite
Lite-03 + ALPN "moq-lite-03"
* HTTP control plane: GET <service>/<room-d-tag> → POST /auth
with {namespace, publish}, returning {token}; documented the
JWT claim shape (root, get, put), 600 s lifetime, regex
on `namespace`, JWKS endpoint, error matrix
* Audio transport: replaced IETF SETUP / TrackNamespace tuples
/ OBJECT_DATAGRAM with moq-lite Lite-03 (ControlType varint,
per-bidi message types, group uni streams, audio/data track,
no in-band SETUP, FIN-as-unsubscribe semantics)
* New event-kind sections: kind 4312 (admin command / kick),
kind 10112 (audio-room server list)
* Reconciliation section explaining what changed from the
original draft and why
- NEW: 2026-04-26-nostrnests-integration-audit.md — punchlist of
every nostrnests/NestsUI feature we don't yet ship, sourced from
a code-walk of the React app + moq-auth + API.md (which is
LiveKit-era and dead). Tier 1 (low-effort, visible): chat,
reactions, role parsing + promotion, hand-raise queue, kick
(kind 4312), edit/close room, scheduled rooms, listener counter.
Tier 2: participant grid, augmented presence tags
(publishing/onstage), per-avatar context menu + zap, share via
naddr. Tier 3: room theming. Tier 4: token-refresh +
Connection.Reload sanity checks.
Verified `:nestsClient:jvmTest` + `:amethyst:compilePlayDebugKotlin`
both still green after the doc changes (no code touched).
This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
`draft` `optional`
|
`draft` `optional`
|
||||||
|
|
||||||
|
> **Revision 2026-04-26.** Replaces the IETF MoQ-transport / `GET <service>/<room-d-tag>`
|
||||||
|
> sketch from the original draft. The wire described here matches the
|
||||||
|
> `nostrnests/nests` + `kixelated/moq` reference deployment running in
|
||||||
|
> production today, and is what `amethyst/nestsClient` implements.
|
||||||
|
> See "Reconciliation with previous spec drafts" near the bottom.
|
||||||
|
|
||||||
## Abstract
|
## Abstract
|
||||||
|
|
||||||
This NIP specifies the client/server control plane and real-time audio
|
This NIP specifies the client/server control plane and real-time audio
|
||||||
@@ -10,9 +16,10 @@ or speaker. It closes the gap between NIP-53's room discovery (which
|
|||||||
defines only *what* a room is) and what an audio-capable client must do
|
defines only *what* a room is) and what an audio-capable client must do
|
||||||
to actually hear and speak in it.
|
to actually hear and speak in it.
|
||||||
|
|
||||||
A standard, vendor-neutral profile lets multiple server implementations
|
The transport is **moq-lite Lite-03** over WebTransport with a thin
|
||||||
interoperate with multiple clients. A client that follows this NIP will
|
NIP-98-authenticated HTTP `/auth` endpoint that mints an ES256 JWT. A
|
||||||
work against any server that advertises compliance, and vice versa.
|
client that follows this NIP will work against any server speaking the
|
||||||
|
same wire shape.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
@@ -21,9 +28,12 @@ work against any server that advertises compliance, and vice versa.
|
|||||||
and kind `1311` (Live Activity Chat).
|
and kind `1311` (Live Activity Chat).
|
||||||
- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md)
|
- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md)
|
||||||
defines kind `27235` and the `Authorization: Nostr <base64-event>` header.
|
defines kind `27235` and the `Authorization: Nostr <base64-event>` header.
|
||||||
- [IETF `moq-transport`](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/)
|
- [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)
|
||||||
and [IETF `webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)
|
carries the audio transport.
|
||||||
provide the audio transport substrate.
|
- **moq-lite Lite-03** (kixelated/moq) — the application-level framing
|
||||||
|
on top of WebTransport. Selected by ALPN `"moq-lite-03"`. Wire spec:
|
||||||
|
`kixelated/moq-rs/rs/moq-lite/src/`. NOT to be confused with IETF
|
||||||
|
`draft-ietf-moq-transport`, which is wire-incompatible.
|
||||||
|
|
||||||
## Terminology
|
## Terminology
|
||||||
|
|
||||||
@@ -72,27 +82,62 @@ publish audio via the MoQ session.
|
|||||||
Used per NIP-53 with no changes. Each room's chat is scoped by the `a` tag
|
Used per NIP-53 with no changes. Each room's chat is scoped by the `a` tag
|
||||||
pointing at the `30312` event.
|
pointing at the `30312` event.
|
||||||
|
|
||||||
|
### Kind 4312 (Admin Command) — moderation
|
||||||
|
|
||||||
|
Ephemeral event used by hosts and admins to issue moderation actions:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "kind": 4312, "content": "",
|
||||||
|
"tags": [
|
||||||
|
["a", "30312:<host-pubkey>:<room-d-tag>"],
|
||||||
|
["p", "<target-pubkey>"],
|
||||||
|
["action", "kick" | ...] ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Recipients matching the `p` tag MUST verify the signer is a `host` or
|
||||||
|
`admin` per the room's current `30312` event before acting. The
|
||||||
|
target self-disconnects on `action=kick`. The host SHOULD also
|
||||||
|
re-publish the `30312` with the target's `p`-tag removed so any
|
||||||
|
future joiner sees the updated roster.
|
||||||
|
|
||||||
|
### Kind 10112 (Audio-room server list)
|
||||||
|
|
||||||
|
Replaceable event listing a user's preferred MoQ host servers. Wire
|
||||||
|
shape mirrors NIP-B7's BlossomServersEvent (kind 10063):
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "kind": 10112,
|
||||||
|
"tags": [
|
||||||
|
["alt", "Audio-room (nests) MoQ servers used by the author"],
|
||||||
|
["server", "https://moq.nostrnests.com"],
|
||||||
|
["server", "https://moq.example.org"],
|
||||||
|
... ],
|
||||||
|
"content": "" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `["server", <baseUrl>]` URL is a moq-auth + moq-relay base URL.
|
||||||
|
Clients SHOULD consume this event when "starting a new space" to
|
||||||
|
default the `service` / `endpoint` tag fields on the kind-30312
|
||||||
|
event they're about to publish.
|
||||||
|
|
||||||
## HTTP control plane
|
## HTTP control plane
|
||||||
|
|
||||||
### Base URL
|
### Base URL
|
||||||
|
|
||||||
The `service` tag from the kind `30312` event is the base URL. All control-
|
The `service` tag from the kind `30312` event is the base URL of the
|
||||||
plane requests are constructed as:
|
auth sidecar (a.k.a. `moq-auth`). It exposes exactly two routes:
|
||||||
|
|
||||||
```
|
```
|
||||||
GET <service>/<room-d-tag> — room-info / join
|
POST <service>/auth — mint a JWT for one room+role
|
||||||
|
GET <service>/.well-known/jwks.json — public keys for JWT verification
|
||||||
```
|
```
|
||||||
|
|
||||||
where `<room-d-tag>` is the `d` tag value of the kind `30312` event,
|
The server SHOULD also expose `GET <service>/health` returning
|
||||||
URL-path-encoded.
|
`{"status":"ok"}` for liveness probes. Anything else SHOULD return 404.
|
||||||
|
|
||||||
The server MAY expose additional paths under the same base (e.g.
|
|
||||||
`<service>/` for server metadata, `<service>/.well-known/nostr-audio-rooms`
|
|
||||||
for discovery). This NIP only specifies `<service>/<room-d-tag>`.
|
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Every request MUST carry a NIP-98 `Authorization` header:
|
Every `POST /auth` request MUST carry a NIP-98 `Authorization` header:
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Nostr <base64(kind-27235-event)>
|
Authorization: Nostr <base64(kind-27235-event)>
|
||||||
@@ -101,155 +146,191 @@ Authorization: Nostr <base64(kind-27235-event)>
|
|||||||
The kind `27235` event MUST have:
|
The kind `27235` event MUST have:
|
||||||
|
|
||||||
- `["u", "<fully-qualified-URL-being-requested>"]`
|
- `["u", "<fully-qualified-URL-being-requested>"]`
|
||||||
- `["method", "GET"]` (or `POST`, `DELETE`, etc. matching the request verb)
|
- `["method", "POST"]`
|
||||||
|
- `["payload", "<sha256-hex of the request body>"]` (NIP-98 §2.2)
|
||||||
- `created_at` within 60 s of the server's clock
|
- `created_at` within 60 s of the server's clock
|
||||||
- A valid signature
|
- A valid signature
|
||||||
|
|
||||||
The server SHOULD reject requests older than 60 s with `401 Unauthorized`.
|
The server MUST reject requests older than 60 s with `401 Unauthorized`.
|
||||||
Servers MAY also reject requests whose signer isn't allowed in the room
|
|
||||||
(e.g. the room is `private` and the signer isn't on the allow-list).
|
|
||||||
|
|
||||||
### Join / room-info response
|
### Join / room-info response
|
||||||
|
|
||||||
`GET <service>/<room-d-tag>` with a valid NIP-98 header returns a JSON body
|
`POST <service>/auth` body:
|
||||||
with `Content-Type: application/json`:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "namespace": "nests/30312:<host-pubkey>:<room-d-tag>", "publish": false }
|
||||||
"endpoint": "https://relay.example.com:4443/moq",
|
|
||||||
"token": "eyJhbGciOi…",
|
|
||||||
"transport": "webtransport",
|
|
||||||
"codec": "opus",
|
|
||||||
"sample_rate": 48000,
|
|
||||||
"frame_duration_ms": 20,
|
|
||||||
"moq_version": "draft-17"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Fields:
|
The `namespace` MUST match the regex
|
||||||
|
`^nests/\d+:[0-9a-f]{64}:[a-zA-Z0-9._-]+$`
|
||||||
|
(`<event-kind>:<host-pubkey>:<d-tag>`, where `<event-kind>` is `30312`
|
||||||
|
for now). `publish` is `true` for a host/speaker minting a publish
|
||||||
|
token, `false` (or omitted) for a listener.
|
||||||
|
|
||||||
| Field | Type | Required | Meaning |
|
Response on success:
|
||||||
|---|---|---|---|
|
|
||||||
| `endpoint` | string (https URL) | yes | WebTransport URL the client connects to. |
|
|
||||||
| `token` | string | yes | Opaque bearer token the client passes to the WebTransport layer (see below). |
|
|
||||||
| `transport` | string | no, defaults to `"webtransport"` | Reserved for future transports. Clients MUST fail closed on unknown values. |
|
|
||||||
| `codec` | string | no, defaults to `"opus"` | Audio codec name. Reserved for future codecs. Clients MUST fail closed on unknown values. |
|
|
||||||
| `sample_rate` | integer | no, defaults to `48000` | Samples per second. |
|
|
||||||
| `frame_duration_ms` | integer | no, defaults to `20` | Audio frame duration. |
|
|
||||||
| `moq_version` | string | no, defaults to server's preferred | Identifier of the MoQ-transport draft the server speaks (e.g. `"draft-17"`). Clients MUST include this version (and nothing else) in CLIENT_SETUP. |
|
|
||||||
|
|
||||||
Unknown fields MUST be ignored by the client to allow forward-compatible
|
```json
|
||||||
server extensions.
|
{ "token": "eyJhbGciOi…" }
|
||||||
|
```
|
||||||
|
|
||||||
Error responses are standard HTTP status codes with an optional JSON body
|
The `token` is an ES256 JWT signed by the `service` server's keypair;
|
||||||
`{"error": "<short-code>", "reason": "<human-readable>"}`:
|
its public key is available at `<service>/.well-known/jwks.json`. The
|
||||||
|
relay (`moq-relay`) refreshes the JWKS every 30 s.
|
||||||
|
|
||||||
|
JWT claims:
|
||||||
|
|
||||||
|
| Claim | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `root` | Echoed `namespace` value. The relay matches this against the WT URL path. |
|
||||||
|
| `get` | `[""]` — listener may subscribe to anything under `root`. |
|
||||||
|
| `put` | `[<requester's pubkey>]` — publisher may only `ANNOUNCE` under `root/<pubkey>`. Present only when `publish: true`. |
|
||||||
|
| `iat` / `exp` | Standard. Token lifetime is **600 s**; clients re-mint on expiry. |
|
||||||
|
|
||||||
|
Error responses:
|
||||||
|
|
||||||
| Status | When |
|
| Status | When |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `401` | NIP-98 missing, expired, or signature invalid. |
|
| `400` | Body missing / malformed JSON / `namespace` fails the regex. |
|
||||||
| `403` | Signer isn't allowed in this room (e.g. room `closed`, signer blocked). |
|
| `401` | Authorization missing, signature invalid, `u`/`method`/`payload` mismatch, or `created_at` outside ±60 s. |
|
||||||
| `404` | Room `d` tag unknown to this server. |
|
| `429` | Rate-limited (≥ 20 mint requests per IP per 60 s). |
|
||||||
| `410` | Room has ended. |
|
|
||||||
| `503` | Server is healthy but audio backend is unavailable. |
|
There is **no per-room HTTP info endpoint, no `/permissions`, no
|
||||||
|
`/recording*`** — the only mutable per-room state lives in Nostr
|
||||||
|
events (kind 30312 for the room, 10312 for presence, 1311 for chat,
|
||||||
|
etc.).
|
||||||
|
|
||||||
## Audio transport
|
## Audio transport
|
||||||
|
|
||||||
### WebTransport
|
### WebTransport
|
||||||
|
|
||||||
Clients open a WebTransport session against the `endpoint` URL using the
|
Clients open a WebTransport session against the `endpoint` URL using
|
||||||
Extended CONNECT handshake ([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220)
|
the Extended CONNECT handshake
|
||||||
+ the [WebTransport-HTTP/3 draft](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)).
|
([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) +
|
||||||
The HTTP CONNECT request MUST carry:
|
[`webtransport-http3`](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/)):
|
||||||
|
|
||||||
```
|
```
|
||||||
:method: CONNECT
|
:method: CONNECT
|
||||||
:protocol: webtransport
|
:protocol: webtransport
|
||||||
:scheme: https
|
:scheme: https
|
||||||
:authority: <host[:port] from endpoint>
|
:authority: <host[:port] from endpoint>
|
||||||
:path: <path from endpoint>
|
:path: /<namespace>?jwt=<token>
|
||||||
Authorization: Bearer <token from room-info response>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Servers MUST:
|
The path component is the **`namespace`** the JWT was minted for —
|
||||||
|
i.e. exactly the value sent in the `POST /auth` body. The relay
|
||||||
|
matches it against `claims.root` and rejects any mismatch with HTTP
|
||||||
|
401 (`IncorrectRoot`). The token is delivered as the **`?jwt=`** query
|
||||||
|
parameter; the relay does **not** inspect the `Authorization` header.
|
||||||
|
|
||||||
- advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1` ([RFC 8441](https://www.rfc-editor.org/rfc/rfc8441)),
|
Servers MUST advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL = 1`,
|
||||||
- advertise `SETTINGS_ENABLE_WEBTRANSPORT = 1`,
|
`SETTINGS_ENABLE_WEBTRANSPORT = 1`, and `SETTINGS_H3_DATAGRAM = 1`.
|
||||||
- advertise `SETTINGS_H3_DATAGRAM = 1` ([RFC 9297](https://www.rfc-editor.org/rfc/rfc9297)).
|
|
||||||
|
|
||||||
### MoQ session
|
### moq-lite session
|
||||||
|
|
||||||
After the WebTransport session is open, the client opens its first
|
The application-level framing is **moq-lite Lite-03** (kixelated/moq).
|
||||||
bidirectional stream as the MoQ control stream and sends `CLIENT_SETUP`
|
The variant is selected by the WebTransport ALPN — clients SHOULD
|
||||||
advertising the `moq_version` from the room-info response. The server replies
|
advertise `"moq-lite-03"` (and MAY include the legacy `"moql"`).
|
||||||
with `SERVER_SETUP` selecting that version, or closes the session.
|
|
||||||
|
|
||||||
### Track namespace + name
|
There is **no in-band SETUP message** in Lite-03 — the WebTransport
|
||||||
|
handshake itself is the handshake. After the WT session opens, both
|
||||||
|
sides go straight to opening per-purpose streams.
|
||||||
|
|
||||||
A speaker's audio track is published by the server and subscribed-to by
|
### Per-bidi `ControlType` discriminator
|
||||||
clients under:
|
|
||||||
|
Every client-initiated bidi opens with a single varint
|
||||||
|
`ControlType` byte that selects the message family:
|
||||||
|
|
||||||
|
| Code | Name |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Announce (subscriber-of-announces ↔ publisher) |
|
||||||
|
| 2 | Subscribe (subscriber → publisher) |
|
||||||
|
| 3 | Fetch (decl. only; not used for live audio) |
|
||||||
|
| 4 | Probe (bitrate hint) |
|
||||||
|
|
||||||
|
Body framing on every bidi/uni stream is `varint(size) + payload bytes`.
|
||||||
|
Strings are `varint(length) + UTF-8`. Integers are RFC 9000 §16
|
||||||
|
varints.
|
||||||
|
|
||||||
|
### Speaker — publisher path
|
||||||
|
|
||||||
|
The relay opens both `Announce` and `Subscribe` bidis **to** the
|
||||||
|
publisher (publisher accepts inbound bidis). For each:
|
||||||
|
|
||||||
|
- `Announce` body: `AnnouncePlease { prefix: string }`. The publisher
|
||||||
|
replies `Announce { status: u8 (0=Ended,1=Active), suffix: string,
|
||||||
|
hops: u62 }` with `status=1`, `suffix=<own pubkey>`. Publishers
|
||||||
|
MUST emit `status=0` on graceful shutdown.
|
||||||
|
- `Subscribe` body: `Subscribe { id: u62, broadcast: string,
|
||||||
|
track: string, priority: u8, ordered: u8, maxLatencyMillis: varint,
|
||||||
|
startGroup: varint, endGroup: varint }`. The publisher replies
|
||||||
|
`SubscribeOk { priority, ordered, maxLatencyMillis, startGroup,
|
||||||
|
endGroup }`.
|
||||||
|
|
||||||
|
Per-group audio bytes flow on **client-initiated uni streams** the
|
||||||
|
publisher opens. Each uni stream has the layout:
|
||||||
|
|
||||||
```
|
```
|
||||||
track_namespace = [ <room-d-tag> ]
|
DataType varint = 0 (Group)
|
||||||
track_name = <speaker-pubkey-hex> (64 lowercase hex chars)
|
GroupHeader (size-prefixed): { subscribe: u62, sequence: u62 }
|
||||||
|
frames until QUIC FIN: { size: varint, payload: <Opus packet> }
|
||||||
```
|
```
|
||||||
|
|
||||||
The namespace is a **one-element tuple** containing the room's `d` tag. It is
|
### Listener — subscriber path
|
||||||
intentionally vendor-neutral: there is no `"nests"` or server-brand prefix.
|
|
||||||
|
|
||||||
Rationale: the namespace is uniquely keyed by the NIP-53 room id and is
|
A listener opens its own client-initiated bidis to the relay:
|
||||||
sufficient for a client to subscribe without knowing anything about the
|
|
||||||
server's brand. Multiple servers hosting rooms with the same `d` tag is
|
|
||||||
already not a concern — a `d` tag is unique under a host pubkey, and the
|
|
||||||
room-info response identifies exactly which server's MoQ endpoint the
|
|
||||||
client must connect to.
|
|
||||||
|
|
||||||
### Audio objects
|
- `Announce` (ControlType=1) with
|
||||||
|
`AnnouncePlease { prefix: <empty or namespace prefix> }` to receive
|
||||||
|
a flow of `Announce` updates from the relay (one per active
|
||||||
|
publisher).
|
||||||
|
- `Subscribe` (ControlType=2) per `(broadcast, track)` pair the listener
|
||||||
|
wants. The relay replies `SubscribeOk` and forwards group uni
|
||||||
|
streams as the publisher emits them.
|
||||||
|
|
||||||
Each OBJECT on a speaker's track carries one Opus packet as its payload:
|
For a nests audio room the listener's wire usage per speaker is:
|
||||||
|
|
||||||
- Opus encapsulated in **raw packet form** (no Ogg / no TOC-prefix), as
|
| Field | Value |
|
||||||
produced by `libopus` / Android `MediaCodec("audio/opus")` encoder output.
|
|---|---|
|
||||||
- `sample_rate` from the room-info response (default 48 000 Hz).
|
| `broadcast` | `<speaker-pubkey-hex>` (single string, no `nests/` prefix) |
|
||||||
- `frame_duration_ms` from the room-info response (default 20 ms).
|
| `track` | `"audio/data"` |
|
||||||
- Mono (single channel), signed 16-bit PCM domain, VBR encoding.
|
| Optional metadata track | `"catalog.json"` (JSON description of the broadcast — clients MAY skip and just subscribe to `audio/data`). |
|
||||||
|
|
||||||
Object delivery MAY use either:
|
Path normalisation (strip leading/trailing/duplicate `/`) is
|
||||||
|
**mandatory** on every wire boundary; `"/foo//bar/"` and `"foo/bar"`
|
||||||
|
MUST round-trip identically.
|
||||||
|
|
||||||
- **OBJECT_DATAGRAM** (lowest latency, lossy) — recommended for live audio.
|
### Audio frame format
|
||||||
- **STREAM_HEADER_SUBGROUP** uni-streams (reliable) — MAY be used by servers
|
|
||||||
that need delivery guarantees; clients MUST support receiving both.
|
|
||||||
|
|
||||||
`group_id` on each object is the speaker's monotonic group counter (one
|
Each frame payload on the `audio/data` track is one **Opus packet** in
|
||||||
group per speaker session). `object_id` is the zero-based Opus packet index
|
raw form (no Ogg, no TOC prefix), as produced by `libopus` /
|
||||||
within the group. `publisher_priority` is `0x80` unless the server has
|
Android `MediaCodec("audio/opus")` output:
|
||||||
reason to vary it.
|
|
||||||
|
|
||||||
### Server-to-client vs. client-to-server audio
|
- 48 000 Hz, mono, signed-16-bit PCM domain, 20 ms frame duration, VBR.
|
||||||
|
|
||||||
A **listener** SUBSCRIBEs to each speaker's track namespace + track name.
|
There is **no per-frame envelope** beyond the size varint — no
|
||||||
The server MUST accept SUBSCRIBEs from any authenticated client (subject to
|
timestamp, no codec config, no flags. Receivers reconstruct timing
|
||||||
its access control).
|
from frame arrival + the group sequence.
|
||||||
|
|
||||||
A **speaker** ANNOUNCEs `[ <room-d-tag> ]` and publishes objects on track
|
### Unsubscribe / close
|
||||||
name `<speaker-pubkey-hex>`. The server MUST verify that the announcing
|
|
||||||
pubkey is listed in the room's `p` tag set with role `host` or `speaker`
|
|
||||||
**at the current moment** (the room event is replaceable — the set can
|
|
||||||
change). On role revocation, the server MUST close the publisher's track.
|
|
||||||
|
|
||||||
### Leaving
|
There is **no UNSUBSCRIBE message**: a subscriber FINs the send side
|
||||||
|
of the subscribe bidi and the relay tears down. A publisher closes by
|
||||||
|
emitting `Announce { status: 0=Ended }` on every active announce bidi
|
||||||
|
and FINing the current group's uni stream.
|
||||||
|
|
||||||
To leave, the client SHOULD:
|
Errors on any stream are conveyed by `RESET_STREAM` with a u32 error
|
||||||
|
code; there is no SUBSCRIBE_ERROR / ANNOUNCE_ERROR message.
|
||||||
|
|
||||||
1. UNSUBSCRIBE every track it had open.
|
### Leaving the room (Nostr-side)
|
||||||
2. If it was a speaker, send UNANNOUNCE + `SubscribeDone` for its own track.
|
|
||||||
3. Publish a final kind `10312` presence event (optional, improves UX for
|
In addition to the wire-level cleanup above, a leaving client SHOULD:
|
||||||
other peers) with `["muted","1"]` and without `["hand","1"]`.
|
|
||||||
4. Close the WebTransport session with capsule type `0x2843`
|
1. Publish a final kind `10312` presence event with
|
||||||
|
`["muted","1"]`, `["onstage","0"]`, no `["hand","1"]` to flush
|
||||||
|
stale UI on other peers.
|
||||||
|
2. Close the WebTransport session with capsule type `0x2843`
|
||||||
(`WT_CLOSE_SESSION`, code `0`).
|
(`WT_CLOSE_SESSION`, code `0`).
|
||||||
|
|
||||||
The server SHOULD treat 30 s without a kind `10312` refresh (per NIP-53) as
|
|
||||||
"left" for UI purposes.
|
|
||||||
|
|
||||||
## Chat
|
## Chat
|
||||||
|
|
||||||
Per NIP-53 kind `1311`. No additions.
|
Per NIP-53 kind `1311`. No additions.
|
||||||
@@ -291,26 +372,31 @@ A client claiming this NIP MUST:
|
|||||||
|
|
||||||
## Reference implementation
|
## Reference implementation
|
||||||
|
|
||||||
A compliant Kotlin/Android reference client is in development at
|
A compliant Kotlin/Android reference client ships in
|
||||||
[`amethyst/nestsClient`](../..) — see `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`
|
[`amethyst/nestsClient`](../..). The reference server is the
|
||||||
for the transport work-in-progress.
|
nostrnests stack:
|
||||||
|
[`nostrnests/nests`](https://github.com/nostrnests/nests) (auth
|
||||||
|
sidecar) +
|
||||||
|
[`kixelated/moq`](https://github.com/kixelated/moq) (relay).
|
||||||
|
|
||||||
## Known divergences from current nostrnests/nests servers
|
This NIP describes the wire as the reference server speaks it — there
|
||||||
|
is no "vendor-neutral" alternative being trialled. Any server claiming
|
||||||
|
compliance MUST speak the moq-lite Lite-03 framing detailed above and
|
||||||
|
the `POST /auth` HTTP shape verbatim.
|
||||||
|
|
||||||
At the time of writing, existing `nostrnests/nests` deployments use:
|
## Reconciliation with previous spec drafts
|
||||||
|
|
||||||
- MoQ track namespace `[ "nests", <d-tag> ]` (two elements, `"nests"` prefix).
|
Earlier drafts of this NIP described the audio transport in terms of
|
||||||
This NIP specifies `[ <d-tag> ]` (one element). Existing nests servers
|
IETF `draft-ietf-moq-transport` (CLIENT_SETUP / SERVER_SETUP, namespace
|
||||||
SHOULD accept both for a transition period; new deployments SHOULD use the
|
tuples, OBJECT_DATAGRAM) and a `GET <service>/<room-d-tag>` HTTP
|
||||||
one-element form.
|
control plane returning `{endpoint, token, codec, sample_rate, …}`.
|
||||||
- A `/api/v1/nests/<d-tag>` path convention. This NIP leaves the path
|
|
||||||
entirely to the `service` tag — servers are free to pick any path.
|
|
||||||
|
|
||||||
A "compliance" phase is proposed where a server can advertise
|
Both have been **superseded**. nostrnests's reference deployment uses
|
||||||
`"nip_xx": true` in its room-info JSON response to signal it implements
|
`POST /auth` with a `{namespace, publish}` body returning `{token}`,
|
||||||
this NIP verbatim. Clients MAY use that flag to choose the vendor-neutral
|
and runs moq-lite Lite-03 (single-string broadcast/track names, group-
|
||||||
namespace when present, and fall back to the legacy nests convention when
|
per-uni-stream framing, no in-band SETUP). IETF MoQ-transport remains
|
||||||
absent.
|
a possible future ALPN, but no audio-rooms server implements it today
|
||||||
|
and this NIP no longer specifies it.
|
||||||
|
|
||||||
## Security considerations
|
## Security considerations
|
||||||
|
|
||||||
|
|||||||
@@ -1,278 +1,72 @@
|
|||||||
# Audio rooms — completion plan (2026-04-26)
|
# Audio rooms — completion plan
|
||||||
|
|
||||||
What's left between today's code and shippable audio rooms in Amethyst.
|
> **STATUS (2026-04-26 PM):** Listener and speaker paths are both live
|
||||||
|
> end-to-end on moq-lite Lite-03, the create-space flow ships, and the
|
||||||
|
> kind-10112 host-server list lives in Settings. What remains is
|
||||||
|
> hardening (reconnect / leave cleanup / per-speaker level metering),
|
||||||
|
> Nests-feature parity (chat, hand-raise approval, recordings — see
|
||||||
|
> the integration audit), and Desktop / iOS targets.
|
||||||
|
> `:nestsClient:jvmTest` (~140 unit tests) is green; integration tests
|
||||||
|
> behind `-DnestsInterop=true` work against a real Docker'd nostrnests
|
||||||
|
> stack.
|
||||||
|
|
||||||
> **STATUS UPDATE (2026-04-26 PM):** the interop test suite (phases 1–5
|
## Implementation state
|
||||||
> of the nostrnests work) uncovered that nostrnests runs on **moq-lite**
|
|
||||||
> (kixelated's variant), not IETF `draft-ietf-moq-transport-17`. Both
|
|
||||||
> the listener and speaker sides are now wired through moq-lite
|
|
||||||
> (`MoqLiteNestsListener` / `MoqLiteNestsSpeaker`), the WebTransport
|
|
||||||
> abstraction grew `incomingBidiStreams` + `openUniStream`, and Phase M1
|
|
||||||
> manual validation against `nostrnests.com` should work end-to-end.
|
|
||||||
> See [`2026-04-26-moq-lite-gap.md`](2026-04-26-moq-lite-gap.md) for
|
|
||||||
> the exact wire spec + landing summary.
|
|
||||||
|
|
||||||
## Where we are
|
### Transport + protocol
|
||||||
|
|
||||||
The transport stack is **done** and audited
|
| Surface | Status | Notes |
|
||||||
([quic/plans/2026-04-26-quic-stack-status.md](../../quic/plans/2026-04-26-quic-stack-status.md)).
|
|
||||||
On top of it, `:nestsClient` already has:
|
|
||||||
|
|
||||||
- HTTP control plane (`NestsClient.resolveRoom` — NIP-98 auth → room info)
|
|
||||||
- WebTransport adapter (`QuicWebTransportFactory` wires `:quic` into the
|
|
||||||
`WebTransportSession` interface)
|
|
||||||
- MoQ session — listener side: `MoqSession.client(...)` + `setup()` +
|
|
||||||
`subscribe(namespace, trackName, filter)` + control + datagram pumps
|
|
||||||
- Opus decode + audio playback chain: `MediaCodecOpusDecoder`,
|
|
||||||
`AudioTrackPlayer`, `AudioRoomPlayer`
|
|
||||||
- `NestsListener` API + `connectNestsListener` orchestration
|
|
||||||
- Audio capture primitives (`AudioRecordCapture`, `MediaCodecOpusEncoder`)
|
|
||||||
exist but are not wired into a publisher path
|
|
||||||
|
|
||||||
Amethyst's `audiorooms/` UI parses NIP-53 events and renders rooms +
|
|
||||||
participant chips. It does NOT call `NestsListener` — there's no Connect
|
|
||||||
button, no audio output, no mute control wired.
|
|
||||||
|
|
||||||
So the punch list is: app-side wiring → manual interop validation → speaker
|
|
||||||
path → backgrounding & polish.
|
|
||||||
|
|
||||||
## Phase M1 — Listener-only MVP (1 week)
|
|
||||||
|
|
||||||
**Goal:** open a real audio room from the Amethyst UI, hear one speaker.
|
|
||||||
|
|
||||||
- Wire `connectNestsListener` into a `RememberRoomConnection` composable in
|
|
||||||
`amethyst/.../audiorooms/room/`. Lifecycle tied to `DisposableEffect`;
|
|
||||||
cancels on screen exit.
|
|
||||||
- Surface `NestsListenerState` in the UI:
|
|
||||||
- `Idle` / `Connecting` → show a spinner or chip "Connecting…"
|
|
||||||
- `Connected` → show "Audio connected" chip + auto-subscribe to the host's
|
|
||||||
speaker track (NIP-53 room's `p` tag with role `host`)
|
|
||||||
- `Failed(reason, cause)` → toast / inline message
|
|
||||||
- `AudioRoomPlayer` per subscription. Per the audio-rooms NIP draft
|
|
||||||
(`docs/plans/2026-04-22-nip-audio-rooms-draft.md`) one speaker = one track
|
|
||||||
name = `<speaker-pubkey-hex>`; one `AudioRoomPlayer` per speaker.
|
|
||||||
- Mute toggle drives `AudioPlayer.setVolume(0f / 1f)` on the active player.
|
|
||||||
Mute at the player keeps the network running so unmute is instant.
|
|
||||||
- Backed by an `AudioRoomViewModel` in `commons/.../viewmodels/` so desktop
|
|
||||||
can reuse the orchestration once it gets WT.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
- Manual: connect to `nostrnests.com`, open a known room, hear audio.
|
|
||||||
- Unit: `AudioRoomViewModel` state-flow transitions on
|
|
||||||
`NestsListenerState` updates.
|
|
||||||
|
|
||||||
## Phase M2 — Multi-speaker + audience UX (3 days)
|
|
||||||
|
|
||||||
- Subscribe to every `host` + `speaker` `p` tag, not just the first one.
|
|
||||||
Mix at the audio side (Android `AudioTrack` accepts multiple writers if
|
|
||||||
we use one shared track + downmix; cleaner: one `AudioTrack` per
|
|
||||||
subscription and let the OS mix).
|
|
||||||
- Show per-speaker level meters (if the encoder exposes RMS) or just a
|
|
||||||
speaking indicator driven by "objects received in last 200 ms".
|
|
||||||
- React to NIP-53 room event updates: a new speaker added to `p` →
|
|
||||||
open a subscription; a speaker removed → close one.
|
|
||||||
|
|
||||||
## Phase M3 — Foreground service (2 days)
|
|
||||||
|
|
||||||
- Android `MediaSessionService` with a media-style notification so
|
|
||||||
playback continues when the app backgrounds.
|
|
||||||
- Stop the service on:
|
|
||||||
- screen exit AND no other audio-room-screen is alive
|
|
||||||
- user dismisses the notification
|
|
||||||
- underlying `NestsListener` enters `Failed` or `Closed`
|
|
||||||
- Permission shim: `RECORD_AUDIO` is NOT needed for listener-only.
|
|
||||||
|
|
||||||
## Phase M4 — Manual interop pass against `nostrnests.com` (3 days)
|
|
||||||
|
|
||||||
This is the proof-of-life step before any speaker work.
|
|
||||||
|
|
||||||
- Build a debug build with the listener flow above.
|
|
||||||
- Open one of the long-running test rooms hosted by nests.
|
|
||||||
- Confirm: connect succeeds; SUBSCRIBE_OK arrives; OBJECT_DATAGRAMs
|
|
||||||
decode through MediaCodec into audible audio.
|
|
||||||
- Anything that surfaces here goes into a follow-up audit / fix pass on
|
|
||||||
`:quic` or `:nestsClient`. We expect one or two issues — protocol drafts
|
|
||||||
drift, and we've only verified against aioquic, not a real MoQ relay.
|
|
||||||
- Capture a packet trace if anything fails so we can compare on-the-wire
|
|
||||||
bytes against a known-working JS client.
|
|
||||||
|
|
||||||
## Phase M5 — Speaker path: MoQ publisher (1 week) — **DONE (via moq-lite, not IETF MoQ)**
|
|
||||||
|
|
||||||
> **Update:** the original plan called for ANNOUNCE / OBJECT emission on
|
|
||||||
> the IETF `MoqSession`. Once the moq-lite gap was discovered, the
|
|
||||||
> speaker path was implemented on the `MoqLiteSession` instead — see
|
|
||||||
> [`2026-04-26-moq-lite-gap.md`](2026-04-26-moq-lite-gap.md) phase
|
|
||||||
> 5c-speaker. The wire shape is different (single-string broadcast +
|
|
||||||
> `audio/data` track, group-per-uni-stream framing) but the
|
|
||||||
> [NestsSpeaker] / [BroadcastHandle] surface is unchanged.
|
|
||||||
|
|
||||||
Original IETF MoQ design (kept as reference):
|
|
||||||
|
|
||||||
The big one. `MoqSession` only does subscribe today; it needs ANNOUNCE +
|
|
||||||
OBJECT emission.
|
|
||||||
|
|
||||||
Required MoQ messages to encode + decode:
|
|
||||||
|
|
||||||
| Message | Direction | Status |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ANNOUNCE | client → server | not implemented |
|
| `:quic` v1 + HTTP/3 + WebTransport | ✅ done | RFC 9000 + 9220; auditor passes |
|
||||||
| ANNOUNCE_OK / ANNOUNCE_ERROR | server → client | decode + match-by-namespace |
|
| moq-lite Lite-03 codec (announce / subscribe / group) | ✅ done | `moq/lite/`, full unit suite |
|
||||||
| ANNOUNCE_CANCEL | server → client | decode + signal publisher to stop |
|
| moq-lite session (listener + publisher) | ✅ done | `MoqLiteSession.client(...)` |
|
||||||
| UNANNOUNCE | client → server | encode |
|
| `WebTransportSession.incomingBidiStreams` + `openUniStream` | ✅ done | needed for publisher |
|
||||||
| SUBSCRIBE | server → client (we're publisher) | accept + map to our track sink |
|
| IETF `draft-ietf-moq-transport-17` codec | ✅ done | reference impl, no production caller |
|
||||||
| SUBSCRIBE_OK / SUBSCRIBE_ERROR | client → server | encode |
|
|
||||||
| SUBSCRIBE_DONE | client → server | encode on track end |
|
|
||||||
| OBJECT_DATAGRAM (publish-side) | client → server | encode + emit |
|
|
||||||
|
|
||||||
API we need on `MoqSession`:
|
### `:nestsClient`
|
||||||
|
|
||||||
```kotlin
|
| Surface | Status | Notes |
|
||||||
suspend fun announce(
|
|
||||||
namespace: TrackNamespace,
|
|
||||||
parameters: List<TrackParameter> = emptyList(),
|
|
||||||
): AnnounceHandle
|
|
||||||
|
|
||||||
interface AnnounceHandle {
|
|
||||||
/** New publisher per track name we serve under this namespace. */
|
|
||||||
suspend fun openTrack(name: ByteArray): TrackPublisher
|
|
||||||
/** Stop announcing; sends UNANNOUNCE + closes any open track publishers. */
|
|
||||||
suspend fun unannounce()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrackPublisher {
|
|
||||||
/** Push one OBJECT_DATAGRAM. group/objectId are managed internally
|
|
||||||
* per the audio-rooms NIP. */
|
|
||||||
suspend fun send(payload: ByteArray)
|
|
||||||
suspend fun close()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Internal additions:
|
|
||||||
- `pendingAnnounces` keyed by namespace, like the existing
|
|
||||||
`pendingSubscribes`
|
|
||||||
- inbound-SUBSCRIBE routing: when the server SUBSCRIBEs, we look up the
|
|
||||||
publisher by namespace+name and start delivering its objects with the
|
|
||||||
server-assigned subscribeId/trackAlias
|
|
||||||
- group/object id management: monotonic group per
|
|
||||||
`TrackPublisher`, object id zero-reset per group; reflect this in the
|
|
||||||
emitted `OBJECT_DATAGRAM` header
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
- `MoqSession` unit tests for ANNOUNCE round-trip via `FakeWebTransport`
|
|
||||||
- Integration: a publisher sends 100 Opus-shaped payloads through to a
|
|
||||||
matching subscriber, all received with intact group/object ids
|
|
||||||
|
|
||||||
## Phase M6 — Capture → encode → publish (3 days) — **DONE**
|
|
||||||
|
|
||||||
> Both `AudioRoomBroadcaster` (drives the IETF `TrackPublisher`) and
|
|
||||||
> `AudioRoomMoqLiteBroadcaster` (drives `MoqLitePublisherHandle`)
|
|
||||||
> exist. The Android actuals (`AudioRecordCapture`,
|
|
||||||
> `MediaCodecOpusEncoder`) are wired into the production speaker path.
|
|
||||||
|
|
||||||
The inverse of `AudioRoomPlayer`:
|
|
||||||
|
|
||||||
- `AudioCaptureSource` (commonMain interface) with platform actuals on
|
|
||||||
`AudioRecordCapture` (Android) and a desktop one later
|
|
||||||
- `AudioRoomBroadcaster` orchestrates: pull PCM frames from the capture →
|
|
||||||
feed `MediaCodecOpusEncoder` → push the resulting Opus packet into
|
|
||||||
`TrackPublisher.send`
|
|
||||||
- `RECORD_AUDIO` permission gate — surface on first-tap of the talk button
|
|
||||||
- Push-to-talk vs always-on toggle: at the API level, just `start()` /
|
|
||||||
`stop()` on the broadcaster; the UI decides
|
|
||||||
|
|
||||||
## Phase M7 — `NestsSpeaker` API (2 days) — **DONE**
|
|
||||||
|
|
||||||
> `NestsSpeaker` interface + `MoqLiteNestsSpeaker` implementation +
|
|
||||||
> `connectNestsSpeaker` orchestration are all live and reach the
|
|
||||||
> `Connected` state against a connected moq-lite session. The Compose
|
|
||||||
> "Talk" button + mute UI in `AudioRoomActivityContent` calls
|
|
||||||
> `viewModel.startBroadcasting()` / `setMuted(...)` which routes to
|
|
||||||
> `BroadcastHandle.setMuted` and the moq-lite publisher.
|
|
||||||
|
|
||||||
Mirror of `NestsListener` for hosts/speakers:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
interface NestsSpeaker {
|
|
||||||
val state: StateFlow<NestsSpeakerState>
|
|
||||||
suspend fun startBroadcasting(): BroadcastHandle
|
|
||||||
suspend fun close()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BroadcastHandle {
|
|
||||||
suspend fun setMuted(muted: Boolean)
|
|
||||||
suspend fun close()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Same `connectNestsSpeaker` orchestration as `connectNestsListener` but the
|
|
||||||
post-`setup` step is `announce(...)` instead of `subscribe(...)`.
|
|
||||||
|
|
||||||
UI:
|
|
||||||
- Talk button only enabled when our pubkey is in the room's `p` tags with
|
|
||||||
role `host` or `speaker`
|
|
||||||
- "Live" indicator while broadcasting, level meter from the encoder
|
|
||||||
- Mute / unmute drives `BroadcastHandle.setMuted`
|
|
||||||
|
|
||||||
## Phase M8 — App polish (3-5 days)
|
|
||||||
|
|
||||||
- Connection-recovery: `NestsListener` exposes `reconnect()`; the screen
|
|
||||||
retries on `Failed` after a short backoff
|
|
||||||
- Room-leave cleanup: on screen exit, send UNSUBSCRIBE + UNANNOUNCE before
|
|
||||||
closing the WT session (audit-4 / 5 already wired the
|
|
||||||
`WtCloseSession` capsule emit on `close()`)
|
|
||||||
- Surface server `peerGoawayProtocolError` and the various
|
|
||||||
`NestsListenerState.Failed` reasons as user-readable messages
|
|
||||||
- iOS: stub everything in `iosMain` with `expect`s that error cleanly until
|
|
||||||
iOS audio capture/playback land
|
|
||||||
|
|
||||||
## Phase M9 — Backgrounding for speakers (2 days)
|
|
||||||
|
|
||||||
Different from M3 because capture has stricter Android rules:
|
|
||||||
- Foreground service type `microphone` (Android 14+ requires this)
|
|
||||||
- Notification with prominent "Speaking" indicator + mute action
|
|
||||||
|
|
||||||
## Out of scope for this plan
|
|
||||||
|
|
||||||
- **Recording / saving** room audio.
|
|
||||||
- **Server-mixed audio.** Each speaker is a separate track per the NIP
|
|
||||||
draft; mixing is client-side.
|
|
||||||
- **Video.** We support audio only.
|
|
||||||
- **Accessibility transcription.**
|
|
||||||
- **Desktop audio capture** (until Compose Desktop has a stable
|
|
||||||
`AudioInput` API; today's options are JNA-heavy).
|
|
||||||
|
|
||||||
## Timeline
|
|
||||||
|
|
||||||
| Phase | Days | Cumulative |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| M1 Listener wire-up | 5 | 5 |
|
| HTTP `/auth` JWT mint (NIP-98 → ES256 JWT) | ✅ done | `OkHttpNestsClient.mintToken` |
|
||||||
| M2 Multi-speaker | 3 | 8 |
|
| `connectNestsListener` → `MoqLiteNestsListener` | ✅ done | path = `/<namespace>?jwt=<token>` |
|
||||||
| M3 Foreground listener | 2 | 10 |
|
| `connectNestsSpeaker` → `MoqLiteNestsSpeaker` | ✅ done | publishes `audio/data` track |
|
||||||
| M4 Real-server interop | 3 | 13 |
|
| `AudioRecordCapture` + `MediaCodecOpusEncoder` | ✅ done | Android actuals |
|
||||||
| M5 MoQ publisher | 5 | 18 |
|
| `MediaCodecOpusDecoder` + `AudioTrackPlayer` | ✅ done | Android actuals |
|
||||||
| M6 Capture + encode | 3 | 21 |
|
| `AudioRoomMoqLiteBroadcaster` + `AudioRoomPlayer` | ✅ done | encode / decode pumps |
|
||||||
| M7 NestsSpeaker | 2 | 23 |
|
| Interop harness (Docker compose nostrnests stack) | ✅ done | `NostrNestsHarness`, 4 test classes |
|
||||||
| M8 Polish | 4 | 27 |
|
|
||||||
| M9 Foreground speaker | 2 | 29 |
|
|
||||||
|
|
||||||
≈ **6 weeks** to ship full audio rooms (listener + speaker + Android polish).
|
### Amethyst (Android UI)
|
||||||
≈ **2 weeks** to ship listener-only (M1+M3+M4) which is the 95% case for
|
|
||||||
audience members.
|
|
||||||
|
|
||||||
## Stop conditions
|
| Surface | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `AudioRoomsScreen` feed of kind-30312 events | ✅ done | from followed users |
|
||||||
|
| `AudioRoomJoinCard` → launches `AudioRoomActivity` | ✅ done | passes service / endpoint / hostPubkey |
|
||||||
|
| `AudioRoomActivity` (full screen + PIP) | ✅ done | lifecycle anchor |
|
||||||
|
| `AudioRoomViewModel` (listener + speaker state) | ✅ done | `commons/.../viewmodels/` |
|
||||||
|
| Foreground service (`AudioRoomForegroundService`) | ✅ done | listening + microphone variants |
|
||||||
|
| Mute / unmute / hand-raise UI | ✅ done | drives `BroadcastHandle.setMuted` + presence |
|
||||||
|
| Kind-10312 presence heartbeat | ✅ done | 30 s + debounced state-change publish |
|
||||||
|
| **"Start space" FAB → `CreateAudioRoomSheet`** | ✅ done | publishes kind-30312 with caller as host |
|
||||||
|
| **Settings → Audio-room servers (kind 10112)** | ✅ done | replaceable list, defaults to `nostrnests.com` |
|
||||||
|
|
||||||
- **M4 reveals the QUIC stack can't reach `nostrnests.com`** — drop into a
|
### Pending
|
||||||
protocol-comparison pass (likely a draft-version mismatch or a small
|
|
||||||
framing bug). Up to 1 wk of `:quic` adjustment, otherwise we ship behind
|
| Item | Phase | Effort |
|
||||||
a feature flag and chase interop async.
|
|---|---|---|
|
||||||
- **MediaCodec Opus is missing on a target device.** Android 10+ ships the
|
| Reconnect / retry on listener Failed | M8 | 1 d |
|
||||||
decoder; for older devices we'd need a software Opus, which is out of
|
| Per-speaker level meters (RMS from decoder output) | M8 | 1 d |
|
||||||
scope.
|
| Connection-failure UX copy (typed reasons → strings) | M8 | 0.5 d |
|
||||||
|
| Desktop audio capture + playback | future | gated on Compose Desktop AudioInput API |
|
||||||
|
| iOS audio capture + playback | future | new `iosMain` actuals |
|
||||||
|
| Nests-feature parity (chat, mod, recordings, …) | see integration audit | varies |
|
||||||
|
|
||||||
## Pointers
|
## Pointers
|
||||||
|
|
||||||
|
- moq-lite wire spec + IETF gap: `nestsClient/plans/2026-04-26-moq-lite-gap.md`
|
||||||
|
- Nostrnests integration audit (gaps + roadmap): see most recent doc in `nestsClient/plans/`
|
||||||
- QUIC stack status: `quic/plans/2026-04-26-quic-stack-status.md`
|
- QUIC stack status: `quic/plans/2026-04-26-quic-stack-status.md`
|
||||||
- Audio-rooms NIP draft: `docs/plans/2026-04-22-nip-audio-rooms-draft.md`
|
- Audio-rooms NIP draft (needs refresh after the moq-lite findings):
|
||||||
- Original (frozen) QUIC plan: `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`
|
`docs/plans/2026-04-22-nip-audio-rooms-draft.md`
|
||||||
- Existing listener entry point: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt`
|
- Listener entry: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt`
|
||||||
- App-side audio-room screen: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/`
|
- Speaker entry: same file (`connectNestsSpeaker`)
|
||||||
|
- Create-space sheet: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/create/`
|
||||||
|
- Servers settings: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/nestsServers/`
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# Plan: bridge the moq-lite protocol gap
|
# Plan: bridge the moq-lite protocol gap
|
||||||
|
|
||||||
**Status:** **listener AND speaker sides done** (phases 5a → 5d, commit
|
**Status:** ✅ **DONE** — listener + speaker on moq-lite Lite-03 against
|
||||||
range `fb47a4c` → `5914e9e` + the speaker phase 5c-speaker landing in
|
the real nostrnests stack. Phases 5a → 5d landed (`fb47a4c` →
|
||||||
this PR). Default `:nestsClient:jvmTest` suite passes; downstream
|
`71cf99d`); follow-up cleanup + harness fixes shipped through `015b0d7`.
|
||||||
`:commons` and `:amethyst` compile clean.
|
Default `:nestsClient:jvmTest` passes; integration tests gated by
|
||||||
|
`-DnestsInterop=true` work end-to-end against a Docker'd nostrnests
|
||||||
|
deployment.
|
||||||
|
|
||||||
**Origin:** discovered while writing the nostrnests interop test suite (phases 1–4).
|
**Origin:** discovered while writing the nostrnests interop test suite (phases 1–4).
|
||||||
|
|
||||||
@@ -299,9 +301,20 @@ host runs them with `-DnestsInterop=true`.
|
|||||||
|
|
||||||
## When picking up
|
## When picking up
|
||||||
|
|
||||||
- Read `~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/`
|
This doc captures the wire spec used during implementation. For the
|
||||||
for the JS reference.
|
shipped surface, start from:
|
||||||
- Read `kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version,
|
|
||||||
path}.rs` for the canonical Rust implementation.
|
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/`
|
||||||
- The nests-side `claims.put = [pubkey]` rule is in
|
— `MoqLiteCodec`, `MoqLiteSession`, `MoqLitePath`, `MoqLiteFraming`.
|
||||||
`moq-auth/src/index.ts:160-166`.
|
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/`
|
||||||
|
`MoqLiteNestsListener.kt` / `MoqLiteNestsSpeaker.kt`.
|
||||||
|
- `nestsClient/src/jvmTest/.../interop/` — Docker-driven interop tests
|
||||||
|
(auth, round-trip, multi-peer, endpoint smoke).
|
||||||
|
|
||||||
|
For raw protocol reference:
|
||||||
|
|
||||||
|
- `~/.cache/amethyst-nests-interop/nests/NestsUI-v2/node_modules/@moq/lite/`
|
||||||
|
— JS reference once the harness has run once.
|
||||||
|
- `kixelated/moq-rs/rs/moq-lite/src/{lite,coding,client,version,path}.rs`
|
||||||
|
— canonical Rust implementation.
|
||||||
|
- `moq-auth/src/index.ts:160-166` — `claims.put = [pubkey]` rule.
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Nostrnests integration audit (2026-04-26)
|
||||||
|
|
||||||
|
What `nostrnests/NestsUI-v2` does that Amethyst doesn't yet — sourced
|
||||||
|
from a code-walk of the React app, the moq-auth sidecar, and the
|
||||||
|
moq-lite reference. Use this as the punchlist for the next chunks of
|
||||||
|
audio-room work.
|
||||||
|
|
||||||
|
> **Already shipped** (don't re-add to the gap list):
|
||||||
|
> HTTP `/auth` JWT mint, moq-lite Lite-03 listener + speaker, kind
|
||||||
|
> 30312 with title/summary/image/status/service/endpoint + single host
|
||||||
|
> participant, kind 10312 presence with `hand` + `muted` + final
|
||||||
|
> "leaving" emit, kind 10112 host-server list (Settings UI), foreground
|
||||||
|
> service, PIP, mute, "Start space" sheet that publishes the kind-30312
|
||||||
|
> with the user as host.
|
||||||
|
|
||||||
|
## What `API.md` is **NOT**
|
||||||
|
|
||||||
|
`nostrnests/API.md` documents a LiveKit-era HTTP surface
|
||||||
|
(`PUT /api/v1/nests`, `/permissions`, `/recording*`, `/info`, `/guest`).
|
||||||
|
**That whole file is dead** — the moq-lite refactor dropped every
|
||||||
|
endpoint except `POST /auth` and `GET /.well-known/jwks.json`. There
|
||||||
|
is no recording surface, no permissions endpoint, no `/info`, no
|
||||||
|
`/guest`. Don't waste implementation time on those.
|
||||||
|
|
||||||
|
## Tier 1 — Visible, low-effort (ship first)
|
||||||
|
|
||||||
|
### 1. Live chat (kind 1311) — ~1 day, **user-visible**
|
||||||
|
NestsUI publishes `{kind:1311, content, tags:[["a", roomATag]]}` and
|
||||||
|
subscribes to `{kinds:[1311], "#a":[roomATag]}` for the in-room chat
|
||||||
|
panel. Quartz already has `LiveActivitiesChatMessageEvent` at
|
||||||
|
`quartz/.../nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt:132`
|
||||||
|
— just not wired into `AudioRoomFullScreen`. Need a chat pane +
|
||||||
|
`RoomChatViewModel` reading `LocalCache` filtered by `#a` and a
|
||||||
|
`AudioRoomsSubAssembler` subscription.
|
||||||
|
|
||||||
|
Source: `NestsUI-v2/src/components/WriteMessage.tsx:31-37`,
|
||||||
|
`hooks/useChatMessages.ts:31-66`.
|
||||||
|
|
||||||
|
### 2. Reactions (kind 7 + custom emoji) — ~1 day, **user-visible**
|
||||||
|
Emoji reactions tagged `["a", roomATag]`; NIP-30
|
||||||
|
`["emoji", shortcode, url]` for custom. Floating overlay shows last
|
||||||
|
30 s of reactions on the avatar. `ReactionEvent` already in Quartz at
|
||||||
|
`quartz/.../nip25Reactions/ReactionEvent.kt:73`. Need a small picker +
|
||||||
|
per-avatar overlay.
|
||||||
|
|
||||||
|
Source: `components/ReactionsButton.tsx:31-53`,
|
||||||
|
`hooks/useRoomReactions.ts:14-22` (queries kinds 7 + 9735),
|
||||||
|
`components/ReactionOverlay.tsx`,
|
||||||
|
`RoomContextProvider.tsx:105` (30-second visibility window).
|
||||||
|
|
||||||
|
### 3. Speaker / admin promotion via `p`-tag role markers — 2 days, **user-visible**
|
||||||
|
Role lives in the `p` tag's 4th element:
|
||||||
|
`["p", pubkey, "<relay>", "host"|"admin"|"speaker"]`. Promotion =
|
||||||
|
host/admin re-publishes the kind-30312 with the target's role updated;
|
||||||
|
demotion / "remove from stage" = drop the role.
|
||||||
|
|
||||||
|
Amethyst today only emits a single host. Need to:
|
||||||
|
1. Parse `admin` and `speaker` roles on incoming events
|
||||||
|
2. Add a host UI "edit room → set role" path that re-publishes 30312
|
||||||
|
3. Gate "publish microphone" on having `host|admin|speaker` (a plain
|
||||||
|
listener should NOT auto-publish unless promoted)
|
||||||
|
|
||||||
|
Source: `lib/const.ts:27-31` (ParticipantRole), `lib/room.ts:66-74`
|
||||||
|
(parser), `components/ProfileCard.tsx:106-119` (`updateRoomParticipant`),
|
||||||
|
`hooks/useIsAdmin.ts:25-31`.
|
||||||
|
|
||||||
|
### 4. Hand-raise queue surface for hosts — ~0.5 day after #3, **user-visible**
|
||||||
|
There is **no separate "approval" event**. Host inspects the kind-10312
|
||||||
|
presence list, sees `["hand","1"]`, and uses the same role re-publish
|
||||||
|
flow above to promote. Just a "raised hands" section in the host's
|
||||||
|
participant grid. Source: `hooks/useRoomPresence.ts:14-39`,
|
||||||
|
`ParticipantsGrid.tsx:69`.
|
||||||
|
|
||||||
|
### 5. Kick (kind 4312 admin command) — ~1 day, **user-visible**
|
||||||
|
Ephemeral event:
|
||||||
|
```
|
||||||
|
{ kind: 4312, content: "",
|
||||||
|
tags: [["a", roomATag], ["p", target], ["action", "kick"]] }
|
||||||
|
```
|
||||||
|
Targets watch for it; if signed by host/admin in last 60 s, they
|
||||||
|
self-disconnect. Host also drops the target's `p`-tag from the 30312.
|
||||||
|
|
||||||
|
Need a new Quartz `AdminCommandEvent` (kind 4312, currently absent),
|
||||||
|
client subscription, and auto-disconnect handler.
|
||||||
|
|
||||||
|
Source: `lib/const.ts:18` (`ADMIN_COMMAND = 4312`),
|
||||||
|
`components/ProfileCard.tsx:121-134`, `hooks/useAdminCommands.ts:38-74`.
|
||||||
|
|
||||||
|
### 6. Edit room / close room — ~0.5 day, **user-visible**
|
||||||
|
Host re-publishes the same `d`-tag 30312 with new title/summary/image,
|
||||||
|
or with `["status","ended"]` to close. Amethyst's CreateAudioRoomSheet
|
||||||
|
covers create; need a host-only "edit" sheet for an existing room.
|
||||||
|
|
||||||
|
Source: `components/EditRoomDialog.tsx:125-213`. Theme tags (`c`, `f`,
|
||||||
|
`bg`) can be ignored for now (see #14).
|
||||||
|
|
||||||
|
### 7. Scheduled / planned rooms — 2-4 hours, **user-visible**
|
||||||
|
`["status","planned"]` + `["starts","<unix>"]`. `StatusTag.STATUS` and
|
||||||
|
`starts` already exist in Quartz; the create-sheet just doesn't expose
|
||||||
|
a date/time picker yet.
|
||||||
|
|
||||||
|
Source: `pages/NewRoom.tsx:50-58`.
|
||||||
|
|
||||||
|
### 8. Listener counter ("N listening") — ~2 hours, **user-visible**
|
||||||
|
Subscribe to `{kinds:[10312], "#a":[roomATag], since: now-300}`,
|
||||||
|
dedupe by pubkey, render `presenceList.length`. Currently Amethyst
|
||||||
|
emits its own presence but doesn't read others'. Unblocks #4 and #9.
|
||||||
|
|
||||||
|
Source: `pages/RoomPage.tsx:67-68`, `hooks/useRoomPresence.ts:14-37`.
|
||||||
|
|
||||||
|
## Tier 2 — Visible, medium-effort
|
||||||
|
|
||||||
|
### 9. Participant grid (speakers vs listeners) — 2-3 days, **user-visible**
|
||||||
|
Render every pubkey from
|
||||||
|
1. MeetingSpace `p` tags (with their role)
|
||||||
|
2. moq-lite announcements (active publishers, surfaced via the listener
|
||||||
|
session's announce flow)
|
||||||
|
3. Recent kind-10312 presence
|
||||||
|
|
||||||
|
Speakers = `host|admin|speaker` minus presences with `["onstage","0"]`.
|
||||||
|
|
||||||
|
Source: `components/ParticipantsGrid.tsx:75-101`. Note new presence
|
||||||
|
tags Amethyst doesn't emit yet — `["publishing","0|1"]`,
|
||||||
|
`["onstage","0|1"]` — see #10.
|
||||||
|
|
||||||
|
### 10. Augment kind-10312 with `publishing` + `onstage` tags — ~1 hour, infrastructure
|
||||||
|
`usePresence.ts:33-41` emits
|
||||||
|
```
|
||||||
|
["a", roomATag], ["hand","0|1"], ["publishing","0|1"], ["muted","0|1"], ["onstage","0|1"]
|
||||||
|
```
|
||||||
|
"Leave the stage" UX hinges on `onstage=0`. Extend the existing 10312
|
||||||
|
emitter in `AudioRoomActivityContent.publishPresence`.
|
||||||
|
|
||||||
|
### 11. Per-participant context menu — 1 day, **user-visible**
|
||||||
|
Tap an avatar → View profile, Follow (kind 3), Mute (kind 10000), Zap.
|
||||||
|
All exist elsewhere in Amethyst — assemble in a room-scoped sheet.
|
||||||
|
|
||||||
|
Source: `components/ProfileCard.tsx:177-258`.
|
||||||
|
|
||||||
|
### 12. Zap support inside the room — ~0.5 day, **user-visible**
|
||||||
|
Standard NIP-57 zap of the room event itself or a specific speaker.
|
||||||
|
Reactions hook also pulls `kind:9735` so paid zaps appear in the
|
||||||
|
reaction stream. Reuse Amethyst's existing zap UI.
|
||||||
|
|
||||||
|
Source: `components/ZapDialog.tsx`, `hooks/useZaps.ts`.
|
||||||
|
|
||||||
|
### 13. Share room (`naddr` deep link) — ~0.5 day, **user-visible**
|
||||||
|
`ShareDialog` builds an `naddr` from the room event and offers
|
||||||
|
"share to Nostr" (publishes a kind 1 referencing the naddr) plus
|
||||||
|
clipboard. Quartz already has `NAddress.create`.
|
||||||
|
|
||||||
|
Source: `components/ShareDialog.tsx:45`,
|
||||||
|
`lib/room.ts:77-83` (`buildRoomNaddr`).
|
||||||
|
|
||||||
|
## Tier 3 — Larger / lower priority
|
||||||
|
|
||||||
|
### 14. Room theming (tags `c`, `f`, `bg` + kinds 36767 / 16767) — 3-5 days
|
||||||
|
Inline color triplet `["c", hex, "background|text|primary"]`, font
|
||||||
|
`["f", family, url]`, background `["bg", "url <u>", "mode <tile|cover>"]`.
|
||||||
|
Optional `a`-tag pointing to a kind-36767 Ditto theme; per-user
|
||||||
|
kind-16767 profile theme.
|
||||||
|
|
||||||
|
Effort range: full theming is 3-5 days; just *parsing* the tags so
|
||||||
|
themed rooms don't render badly is ~0.5 day. Compose Multiplatform
|
||||||
|
can pull dynamic colors but font loading is platform-specific.
|
||||||
|
|
||||||
|
Source: `lib/const.ts:21-24`, `lib/ditto-theme.ts`,
|
||||||
|
`components/ThemeChooser.tsx`, `pages/NewRoom.tsx:69-88`.
|
||||||
|
|
||||||
|
### 15. Background audio + wake-lock audit — ~2 hours
|
||||||
|
NestsUI uses `useWakeLock`, `useAudioKeepAlive`, `useBackgroundAudio`.
|
||||||
|
Amethyst already has `AudioRoomForegroundService` + PIP; just confirm
|
||||||
|
`PARTIAL_WAKE_LOCK` is acquired during a broadcast. Probably already
|
||||||
|
covered.
|
||||||
|
|
||||||
|
## Tier 4 — Infrastructure (mostly already correct)
|
||||||
|
|
||||||
|
### 16. moq-auth token lifetime — ~2 hours
|
||||||
|
`/auth` JWT lives 600 s (10 min). No refresh endpoint — re-mint on
|
||||||
|
expiry. Confirm Amethyst re-mints before the token's `exp` on long
|
||||||
|
sessions.
|
||||||
|
|
||||||
|
JWT claims emitted by the sidecar:
|
||||||
|
- `root` = the `namespace` from the request body
|
||||||
|
- `get: [""]` (read-anything-under-root, for subscribers)
|
||||||
|
- `put: [<pubkey>]` (publish only your own sub-namespace, for `publish:true`)
|
||||||
|
- `iat` / `exp`
|
||||||
|
|
||||||
|
Source: `moq-auth/src/index.ts:160-166`.
|
||||||
|
|
||||||
|
### 17. moq-lite features unused (mostly intentional)
|
||||||
|
| Feature | NestsUI | Amethyst | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Multi-track / video | not used (audio only) | not used | — |
|
||||||
|
| Fetch / replay | not used | not used | — |
|
||||||
|
| Per-track priority / bitrate probes | not used | not used | — |
|
||||||
|
| `Connection.Reload` auto-reconnect (1s→2s→…→30s backoff) | yes | **MAYBE GAP** | confirm `MoqLiteSession` reconnects with backoff on transport failure; if not, add ~0.5 day. |
|
||||||
|
| WebSocket transport fallback | yes (browser) | no (WebTransport only over QUIC) | not a gap on Android |
|
||||||
|
| Announcement-driven participant discovery | yes | needs to expose announce flow on `MoqLiteNestsListener` | small wire-up; covered by #9 |
|
||||||
|
|
||||||
|
Source: `transport/moq-transport.ts:145-151, 257-265, 386-422`.
|
||||||
|
|
||||||
|
### 18. NIP-71 / hashtags / spotlight / `ends` — **not a gap**
|
||||||
|
None of these are emitted by NestsUI on kind 30312. The only tags it
|
||||||
|
writes: `d, title, summary, status, starts, color, image, streaming,
|
||||||
|
auth, relays, p`, plus theme tags. No NIP-71 streaming tags, no
|
||||||
|
hashtags, no spotlight, no `ends`. Don't chase parity here.
|
||||||
|
|
||||||
|
## Recommended punchlist order
|
||||||
|
|
||||||
|
1. **Listener-side presence aggregation + listener counter** (#8, #10)
|
||||||
|
2. **Live chat panel** (#1)
|
||||||
|
3. **Reactions** (#2)
|
||||||
|
4. **Participant grid + per-avatar context menu + zap** (#9, #11, #12)
|
||||||
|
5. **Role parsing + hand-raise queue + promote / demote / kick** (#3, #4, #5)
|
||||||
|
6. **Edit room / close room** (#6) + **scheduled rooms** (#7)
|
||||||
|
7. **Share via naddr** (#13)
|
||||||
|
8. **moq-auth token refresh sanity check** (#16) + **`Connection.Reload`
|
||||||
|
backoff confirmation** (#17)
|
||||||
|
9. **Room theme parsing — graceful fallback only** (#14)
|
||||||
|
|
||||||
|
Items 1-3 give you "feels like a real audio room" inside ~3 days.
|
||||||
|
Items 4-5 unlock the full host workflow inside another ~1 week.
|
||||||
|
Everything else is polish.
|
||||||
|
|
||||||
|
## Key files for the implementer
|
||||||
|
|
||||||
|
- Listener: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsListener.kt`
|
||||||
|
- MeetingSpace event: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEvent.kt`
|
||||||
|
- Live-chat event (kind 1311): `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt`
|
||||||
|
- Full-screen room UI: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt`
|
||||||
|
- Subscriptions for the rooms feed: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt`
|
||||||
Reference in New Issue
Block a user