Files
amethyst/nestsClient/plans/2026-04-26-audio-rooms-completion.md
T
Claude 4338e5e6c4 docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.

quic/plans/2026-04-26-quic-stack-status.md:
  Post-mortem of the original docs/plans/2026-04-22 plan. Documents
  what shipped vs what was estimated, the actual package layout (~8.5k
  LoC, 39 test files, 5 audit rounds), the crypto delegation surface
  (Quartz only — no BouncyCastle, no JNI), interop verification status
  (aioquic + picoquic; nests not yet), and known deferred items
  (STREAM retransmit, Initial-key discard, etc.).

nestsClient/plans/2026-04-26-audio-rooms-completion.md:
  Punch list to ship audio rooms end-to-end:
    M1 Listener wire-up in Amethyst UI
    M2 Multi-speaker audience UX
    M3 Foreground service for backgrounded playback
    M4 Manual interop pass against nostrnests.com
    M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
    M6 Capture → encode → publish pipeline
    M7 NestsSpeaker API
    M8 App polish (reconnect, leave cleanup)
    M9 Foreground service for speakers
  ~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.

Inline doc cleanup:
  * Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
    from active code; replaced with "today" or pointers to the
    completion plan
  * Removed "Kwik-based stub" references; QuicWebTransportFactory and
    surrounding docs now describe :quic as the production path
  * TlsClient header reflects non-null certificateValidator + the
    JdkCertificateValidator / PermissiveCertificateValidator split
  * SendBuffer header documents the best-effort no-retransmit mode
    explicitly (was hidden behind a "Phase L will fix this" note)
  * MoqMessage / MoqObject / MoqSession reflect listener-side as
    shipped + publisher-side as Phase M5

CLAUDE.md:
  * Module list now includes :quic and :nestsClient (was 5 modules,
    now 7)
  * Architecture diagram + sharing philosophy explain what each new
    module owns

No production behaviour changes; doc + comment-only edits. Tests green.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-26 01:38:48 +00:00

9.9 KiB

Audio rooms — completion plan (2026-04-26)

What's left between today's code and shippable audio rooms in Amethyst.

Where we are

The transport stack is done and audited (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:

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:

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 expects 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/