chore(audio-rooms): post-moq-lite cleanup + KDoc + plan refresh
Tidy items now that the moq-lite swap is complete on both sides:
- Drop the no-op `supportedMoqVersions: List<Long>` 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.
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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<Long> = listOf(MoqVersion.DRAFT_17),
|
||||
): NestsListener {
|
||||
val state =
|
||||
MutableStateFlow<NestsListenerState>(
|
||||
@@ -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<Long> = listOf(MoqVersion.DRAFT_17),
|
||||
): NestsSpeaker {
|
||||
val state =
|
||||
MutableStateFlow<NestsSpeakerState>(
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+23
-18
@@ -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/<kind>:<host>:<room>`)
|
||||
* 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 = `/<moqNamespace>` + `?jwt=<token>` 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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user