feat(audio-rooms): moq-lite speaker side end-to-end (phase 5c-speaker)

Production speaker path now runs on moq-lite, so connectNestsSpeaker
exchanges real moq-lite framing with the nostrnests reference relay.

Transport layer:
  - WebTransportSession.incomingBidiStreams() — peer-initiated bidi
    flow. moq-lite publishers receive Announce + Subscribe bidis from
    the relay (rs/moq-lite/src/lite/publisher.rs:40 uses
    Stream::accept(session)), so the abstraction grew the
    accept-bidi-from-peer surface.
  - WebTransportSession.openUniStream() — locally-opened uni stream
    for group push (rs/moq-lite/src/lite/publisher.rs:338 uses
    session.open_uni()).
  - :quic WtPeerStreamDemux StrippedWtStream now carries optional
    send/finish closures. The demux takes the QuicConnectionDriver
    so wakeups fire after each app-level write on a peer-initiated
    bidi.
  - FakeWebTransport now exposes incomingBidiStreams + openUniStream
    directly; the openPeerUniStream test helper went away (production
    flow covers it).

Session layer:
  - MoqLiteSession.publish(suffix) — claims a broadcast suffix and
    lazily launches a relay→us bidi pump. ControlType=Announce reads
    AnnouncePlease, replies Active(suffix). ControlType=Subscribe reads
    body, replies SubscribeOk, registers the inbound subscription.
  - MoqLitePublisherHandle — startGroup / send / endGroup / close
    semantics. send opens a uni stream per group with DataType=0 +
    GroupHeader and pushes varint(size)+payload frames. close emits
    Announce(Ended) on every active announce bidi, FINs the uni.

Application layer:
  - AudioRoomMoqLiteBroadcaster — sibling of AudioRoomBroadcaster but
    drives MoqLitePublisherHandle (keeps IETF broadcaster intact for
    its unit tests).
  - MoqLiteNestsSpeaker — NestsSpeaker adapter, mirror of
    MoqLiteNestsListener on the publish side.
  - connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and
    returns MoqLiteNestsSpeaker.

Tests:
  - 4 new MoqLiteSessionTest cases:
    publisher_replies_to_announcePlease_with_active_announce,
    publisher_acks_subscribe_and_pushes_group_data_on_uni_stream,
    publisher_send_returns_false_when_no_inbound_subscriber,
    publisher_close_emits_ended_announce.

Verified :commons:compileKotlinJvm + :amethyst:compilePlayDebugKotlin
both still compile against the swap.

Docs (plans + CLAUDE.md) refreshed to reflect speaker-side landing.
This commit is contained in:
Claude
2026-04-26 18:01:00 +00:00
parent 5914e9e9fc
commit 71cf99dc22
13 changed files with 1052 additions and 89 deletions
@@ -22,10 +22,8 @@ package com.vitorpamplona.nestsclient
import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.moq.MoqSession
import com.vitorpamplona.nestsclient.moq.MoqVersion
import com.vitorpamplona.nestsclient.moq.SubscribeHandle
import com.vitorpamplona.nestsclient.moq.TrackNamespace
import com.vitorpamplona.nestsclient.transport.WebTransportException
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -163,12 +161,16 @@ private fun failedListener(state: MutableStateFlow<NestsListenerState>): NestsLi
/**
* Speaker / host counterpart of [connectNestsListener]. Walks the same
* HTTP → WebTransport → MoQ handshake; the difference is the post-setup
* step is `announce(...)` (driven by [NestsSpeaker.startBroadcasting])
* instead of `subscribe(...)`.
* three-step HTTP → WebTransport → moq-lite session sequence the
* listener does; the difference is downstream: [NestsSpeaker.startBroadcasting]
* claims a broadcast suffix on the moq-lite session and starts pumping
* Opus frames out as one uni stream per group.
*
* @param speakerPubkeyHex this user's pubkey hex, used as the MoQ track
* name when we ANNOUNCE — listeners look us up by exactly that name.
* @param speakerPubkeyHex this user's pubkey hex. Used as the moq-lite
* broadcast suffix the relay routes to subscribers
* (`MoqLiteSubscribe.broadcast == speakerPubkeyHex`); the JS reference
* mints the same value via `Path.from(identity)`
* (`@moq/publish/screen-B680RFft.js`).
* @param captureFactory builds an [AudioCapture] (one per broadcast).
* Android passes `{ AudioRecordCapture() }`.
* @param encoderFactory builds an [OpusEncoder] (one per broadcast).
@@ -228,28 +230,23 @@ 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.
val moq =
try {
MoqSession.client(webTransport, scope).also { it.setup(supportedMoqVersions) }
com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
.client(webTransport, scope)
} catch (t: Throwable) {
runCatching { webTransport.close(0, "moq setup failed") }
state.value = NestsSpeakerState.Failed("MoQ handshake failed: ${t.message}", t)
runCatching { webTransport.close(0, "moq-lite session init failed") }
state.value = NestsSpeakerState.Failed("moq-lite session init failed: ${t.message}", t)
return failedSpeaker(state)
}
val negotiatedVersion =
moq.selectedVersion ?: run {
runCatching { moq.close() }
state.value = NestsSpeakerState.Failed("MoQ session reported no negotiated version")
return failedSpeaker(state)
}
state.value = NestsSpeakerState.Connected(room, negotiatedVersion)
return DefaultNestsSpeaker(
state.value = NestsSpeakerState.Connected(room, MOQ_LITE_03_VERSION)
return MoqLiteNestsSpeaker(
session = moq,
// Same single-segment shape as the listener path; see comment there.
roomNamespace = TrackNamespace.of(room.moqNamespace()),
speakerTrackName = speakerPubkeyHex.encodeToByteArray(),
speakerPubkeyHex = speakerPubkeyHex,
captureFactory = captureFactory,
encoderFactory = encoderFactory,
scope = scope,