From bc4316803211eeff9c9d0f7cb70bfb24bf408f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 18:18:33 +0000 Subject: [PATCH] chore(audio-rooms): post-moq-lite cleanup + KDoc + plan refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tidy items now that the moq-lite swap is complete on both sides: - Drop the no-op `supportedMoqVersions: List` parameter from `connectNestsListener` / `connectNestsSpeaker`. moq-lite negotiates via ALPN, no caller passed a value, and the project rule forbids no-op back-compat shims. - `NostrNestsRoundTripInteropTest` KDoc + comments now describe the moq-lite framing path (one Subscribe bidi for `audio/data`, group uni streams with `DataType=0` + GroupHeader + size-prefixed frames) instead of the stale IETF "OBJECT_DATAGRAMs / SETUP" framing. - `DefaultNestsListener` / `DefaultNestsSpeaker` KDoc now flags them as IETF MoQ-transport reference impls — production uses `MoqLiteNests*`. They stay around for the IETF unit-test suite. - `audio-rooms-completion.md` Phase M5 / M6 / M7 marked **DONE** — the plan was written before the moq-lite gap was discovered, so it described the work as IETF-MoQ-publisher additions; the moq-lite path lands the same outcome via a different protocol. --- .../2026-04-26-audio-rooms-completion.md | 28 +++++++++++-- .../vitorpamplona/nestsclient/NestsConnect.kt | 16 ++------ .../nestsclient/NestsListener.kt | 14 ++++--- .../vitorpamplona/nestsclient/NestsSpeaker.kt | 13 ++++-- .../interop/NostrNestsRoundTripInteropTest.kt | 41 +++++++++++-------- 5 files changed, 69 insertions(+), 43 deletions(-) diff --git a/nestsClient/plans/2026-04-26-audio-rooms-completion.md b/nestsClient/plans/2026-04-26-audio-rooms-completion.md index 41df2580c..9c497b675 100644 --- a/nestsClient/plans/2026-04-26-audio-rooms-completion.md +++ b/nestsClient/plans/2026-04-26-audio-rooms-completion.md @@ -96,7 +96,17 @@ This is the proof-of-life step before any speaker work. - 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) +## 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. @@ -152,7 +162,12 @@ Tests: - 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) +## 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`: @@ -165,7 +180,14 @@ The inverse of `AudioRoomPlayer`: - 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) +## 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: diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt index 34a71ddb5..d2eccef33 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.nestsclient import com.vitorpamplona.nestsclient.audio.AudioCapture import com.vitorpamplona.nestsclient.audio.OpusEncoder -import com.vitorpamplona.nestsclient.moq.MoqVersion import com.vitorpamplona.nestsclient.moq.SubscribeHandle import com.vitorpamplona.nestsclient.transport.WebTransportException import com.vitorpamplona.nestsclient.transport.WebTransportFactory @@ -55,10 +54,6 @@ import kotlinx.coroutines.flow.MutableStateFlow * @param signer NIP-98 signer for the mintToken HTTP call. * @param scope where the moq-lite pumps live (typically the caller's * ViewModel scope so they cancel when the screen leaves). - * @param supportedMoqVersions retained for source-compatible callers but - * currently a no-op — moq-lite negotiates via ALPN, not an in-band - * SETUP message. Will be repurposed to drive ALPN preference once a - * second moq-lite version ships. */ suspend fun connectNestsListener( httpClient: NestsClient, @@ -66,7 +61,6 @@ suspend fun connectNestsListener( scope: CoroutineScope, room: NestsRoomConfig, signer: NostrSigner, - supportedMoqVersions: List = listOf(MoqVersion.DRAFT_17), ): NestsListener { val state = MutableStateFlow( @@ -112,10 +106,8 @@ suspend fun connectNestsListener( state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.MoqHandshake) // moq-lite Lite-03 has NO setup message — the WebTransport handshake - // itself is the handshake, version is selected by ALPN. The - // `supportedMoqVersions` parameter is retained for backward compat - // with callers that may want to gate on a version mismatch in - // future, but it is currently a no-op. + // itself is the handshake, version is selected by the ALPN + // `moq-lite-03`. val moq = try { com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession @@ -185,7 +177,6 @@ suspend fun connectNestsSpeaker( speakerPubkeyHex: String, captureFactory: () -> AudioCapture, encoderFactory: () -> OpusEncoder, - supportedMoqVersions: List = listOf(MoqVersion.DRAFT_17), ): NestsSpeaker { val state = MutableStateFlow( @@ -231,8 +222,7 @@ suspend fun connectNestsSpeaker( state.value = NestsSpeakerState.Connecting(NestsSpeakerState.Connecting.ConnectStep.MoqHandshake) // moq-lite Lite-03 has NO setup message. Same logic as the listener - // path — `supportedMoqVersions` retained for backward compat but - // currently a no-op because version is selected by ALPN. + // path — version is selected by the `moq-lite-03` ALPN. val moq = try { com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt index 956137943..1719f6e16 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt @@ -99,12 +99,16 @@ sealed class NestsListenerState { } /** - * Default [NestsListener] implementation that delegates to a [MoqSession] - * already set up on a [com.vitorpamplona.nestsclient.transport.WebTransportSession]. + * IETF `draft-ietf-moq-transport-17` [NestsListener] reference + * implementation. **Not used in production** — the production listener + * path uses [MoqLiteNestsListener] over moq-lite Lite-03 (see + * `nestsClient/plans/2026-04-26-moq-lite-gap.md`). Kept for the IETF + * unit-test suite (`MoqSession`, `MoqCodec`) and for any future IETF + * MoQ-transport target. * - * Construction does NOT open the transport — call [connectNestsListener] to - * walk the full HTTP + transport + MoQ handshake; this class just owns the - * resulting session and exposes the listener API on top. + * Delegates to a [MoqSession] already set up on a + * [com.vitorpamplona.nestsclient.transport.WebTransportSession]; + * construction does NOT open the transport. */ class DefaultNestsListener internal constructor( private val session: MoqSession, diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt index 09eee8b86..ab0a42468 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt @@ -115,11 +115,16 @@ sealed class NestsSpeakerState { } /** - * Default [NestsSpeaker] that wraps a connected [MoqSession] and plumbs an - * [AudioRoomBroadcaster] through it on [startBroadcasting]. + * IETF `draft-ietf-moq-transport-17` [NestsSpeaker] reference + * implementation. **Not used in production** — the production speaker + * path uses [MoqLiteNestsSpeaker] over moq-lite Lite-03 (see + * `nestsClient/plans/2026-04-26-moq-lite-gap.md`). Kept for the IETF + * unit-test suite (`NestsSpeakerTest`) and for any future IETF + * MoQ-transport target. * - * Construction does NOT open the transport — call [connectNestsSpeaker] to - * walk the full HTTP + transport + MoQ handshake. + * Wraps a connected [MoqSession] and plumbs an [AudioRoomBroadcaster] + * through it on [startBroadcasting]. Construction does NOT open the + * transport. */ class DefaultNestsSpeaker internal constructor( private val session: MoqSession, diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsRoundTripInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsRoundTripInteropTest.kt index d21dceddb..3bf21fd3d 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsRoundTripInteropTest.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsRoundTripInteropTest.kt @@ -52,29 +52,34 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * Phase-3 interop test. Drives the production [connectNestsSpeaker] + - * [connectNestsListener] entry points end-to-end against the real - * nostrnests Docker stack: the speaker announces a track, the listener - * subscribes by speaker pubkey, the speaker pushes deterministic Opus- - * shaped payloads through the [com.vitorpamplona.nestsclient.audio.AudioRoomBroadcaster] - * pipeline, and the listener collects them via the [com.vitorpamplona.nestsclient.moq.SubscribeHandle.objects] - * flow. + * End-to-end interop test. Drives the production [connectNestsSpeaker] + * + [connectNestsListener] entry points against the real nostrnests + * Docker stack: the speaker claims a moq-lite broadcast suffix + * (`MoqLitePublisherHandle`), the listener subscribes by speaker + * pubkey (`broadcast=pubkey`, `track="audio/data"`), the speaker + * pushes deterministic Opus-shaped payloads through the + * [com.vitorpamplona.nestsclient.audio.AudioRoomMoqLiteBroadcaster] + * pipeline, and the listener collects them via + * [com.vitorpamplona.nestsclient.moq.SubscribeHandle.objects] (which + * the moq-lite adapter wraps over `MoqLiteFrame` so existing decoder + * / player code keeps working). * * Verifies: * - QuicWebTransportFactory + PermissiveCertificateValidator can speak * to the relay's self-signed cert - * - JWT minting + WebTransport CONNECT + MoQ SETUP all succeed against - * the reference relay (not a unit-test mock) - * - The single-segment TrackNamespace shape (`nests/::`) - * matches the relay's `root` JWT claim — chosen in Phase 2 without - * wire confirmation; this test is the confirmation. - * - OBJECT_DATAGRAMs round-trip with payload integrity + monotonic - * object ids. + * - JWT minting + WebTransport CONNECT succeed against the reference + * relay (moq-lite Lite-03 has no in-band SETUP — the WT handshake + * itself is the handshake) + * - The WT path = `/` + `?jwt=` matches the + * relay's `claims.root` exactly + * - moq-lite group uni streams (`DataType=0` + GroupHeader + + * `varint(size)+payload` frames) round-trip with payload integrity + * and monotonic synthesised object ids on the listener adapter * * One keypair drives both the speaker and the listener — that sidesteps * the question of how nostrnests's auth sidecar gates speaker vs. audience * (kind 30312 hostlist, NIP-65, etc.) so this test focuses on the - * transport + MoQ protocol, not the authorisation policy. A future + * transport + moq-lite protocol, not the authorisation policy. A future * dual-keypair test can layer permission checks on top. * * Skipped by default — set `-DnestsInterop=true` to enable. @@ -152,8 +157,8 @@ class NostrNestsRoundTripInteropTest { val subscription = listener.subscribeSpeaker(pubkey) // Start collecting before the speaker publishes anything — - // OBJECT_DATAGRAMs that arrive before .objects.collect runs - // are dropped by the per-subscription buffer. + // moq-lite group frames that arrive before .objects.collect + // runs are dropped by the per-subscription buffer. val received = async(pumpScope.coroutineContext) { withTimeoutOrNull(RECEIVE_TIMEOUT_MS) { @@ -177,7 +182,7 @@ class NostrNestsRoundTripInteropTest { val datagrams = received.await() assertNotNull( datagrams, - "Did not receive $N_FRAMES OBJECT_DATAGRAMs within ${RECEIVE_TIMEOUT_MS}ms", + "Did not receive $N_FRAMES moq-lite frames within ${RECEIVE_TIMEOUT_MS}ms", ) assertEquals(N_FRAMES, datagrams.size, "expected exactly $N_FRAMES objects")