Files
amethyst/nestsClient/plans/2026-04-26-audio-rooms-completion.md
T
Claude 7f48e52541 docs(audio-rooms): full moq-lite wire spec + IETF gap call-outs
Background research turned up the complete moq-lite (Lite-03) wire
format from kixelated/moq-rs and @moq/lite v0.1.7. Folded the spec
into nestsClient/plans/2026-04-26-moq-lite-gap.md as a phase-5
implementation plan:

  - ALPN ("moq-lite-03"); no SETUP/control message in Lite-03 (the WT
    handshake IS the handshake)
  - Per-request bidi streams keyed by ControlType varint (Announce=1,
    Subscribe=2, Fetch=3, Probe=4)
  - AnnouncePlease(prefix) / Announce(status, suffix, hops) shape
  - Subscribe with priority (raw u8), ordered, maxLatency (ms),
    startGroup/endGroup (off-by-one None-encoded), reply Ok/Drop
  - Group = uni stream with (DataType=0, subscribeId, sequence)
    header followed by varint-length frames until QUIC FIN
  - No datagrams; no per-frame envelope beyond size
  - Mandatory path normalisation; FIN-as-unsubscribe; RESET_STREAM
    for errors

Phase-5a..e implementation plan included (~1 week scope).

Doc + KDoc updates so the IETF MoQ-transport code is correctly
labelled and the moq-lite gap is discoverable from every entry point:

  - .claude/CLAUDE.md project description and architecture diagram
  - nestsClient/plans/2026-04-26-audio-rooms-completion.md status block
  - MoqSession.kt, MoqMessage.kt, MoqObject.kt, MoqCodec.kt KDoc — flag
    these as "IETF draft-ietf-moq-transport-17, NOT moq-lite", with
    pointers to the gap doc
  - NestsConnect.kt — note that step 3 of the listener handshake
    (SETUP) does NOT match nostrnests's relay framing
2026-04-26 16:39:44 +00:00

259 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Audio rooms — completion plan (2026-04-26)
What's left between today's code and shippable audio rooms in Amethyst.
> **STATUS UPDATE (2026-04-26 PM):** the interop test suite (phases 14 of
> the nostrnests work, see commits `3283d30` → `1887bd1`) uncovered that
> nostrnests's stack runs on **moq-lite** (kixelated's variant), NOT IETF
> `draft-ietf-moq-transport-17` — which is what `:nestsClient`'s MoQ layer
> currently implements. The two are wire-incompatible. Concrete impact:
> the listener path described under "Where we are" below works against
> any IETF MoQ-transport server but cannot exchange a single MoQ message
> with the real nostrnests relay until a moq-lite codec lands. Tracking
> doc: [`2026-04-26-moq-lite-gap.md`](2026-04-26-moq-lite-gap.md). Phase
> M1 below should be re-read with that gap in mind — manual validation
> against `nostrnests.com` is on hold until the moq-lite phase ships.
## Where we are
The transport stack is **done** and audited
([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)
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 |
| ANNOUNCE_OK / ANNOUNCE_ERROR | server → client | decode + match-by-namespace |
| ANNOUNCE_CANCEL | server → client | decode + signal publisher to stop |
| UNANNOUNCE | client → server | encode |
| SUBSCRIBE | server → client (we're publisher) | accept + map to our track sink |
| 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`:
```kotlin
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)
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)
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 |
| M2 Multi-speaker | 3 | 8 |
| M3 Foreground listener | 2 | 10 |
| M4 Real-server interop | 3 | 13 |
| M5 MoQ publisher | 5 | 18 |
| M6 Capture + encode | 3 | 21 |
| M7 NestsSpeaker | 2 | 23 |
| M8 Polish | 4 | 27 |
| M9 Foreground speaker | 2 | 29 |
**6 weeks** to ship full audio rooms (listener + speaker + Android polish).
**2 weeks** to ship listener-only (M1+M3+M4) which is the 95% case for
audience members.
## Stop conditions
- **M4 reveals the QUIC stack can't reach `nostrnests.com`** — drop into a
protocol-comparison pass (likely a draft-version mismatch or a small
framing bug). Up to 1 wk of `:quic` adjustment, otherwise we ship behind
a feature flag and chase interop async.
- **MediaCodec Opus is missing on a target device.** Android 10+ ships the
decoder; for older devices we'd need a software Opus, which is out of
scope.
## Pointers
- 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`
- Original (frozen) QUIC plan: `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`
- Existing listener entry point: `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt`
- App-side audio-room screen: `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/`