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
@@ -234,6 +234,11 @@ class QuicWebTransportSession(
return QuicBidiStreamAdapter(s, state.driver)
}
override suspend fun openUniStream(): WebTransportWriteStream {
val s = state.openUniStream()
return QuicUniWriteStreamAdapter(s, state.driver)
}
override fun incomingUniStreams(): Flow<WebTransportReadStream> =
flow {
// Surface only unidirectional WT streams whose prefix bytes
@@ -247,6 +252,18 @@ class QuicWebTransportSession(
}
}
override fun incomingBidiStreams(): Flow<WebTransportBidiStream> =
flow {
// Surface only peer-initiated bidi streams whose WT_BIDI_STREAM
// prefix has been stripped by the demux. send/finish are
// wired through the driver wakeup.
state.incomingStrippedStreams.collect { stripped ->
if (!stripped.isUnidirectional) {
emit(StrippedWtBidiStreamAdapter(stripped))
}
}
}
override suspend fun sendDatagram(payload: ByteArray): Boolean {
state.sendDatagram(payload)
return true
@@ -292,9 +309,62 @@ private class QuicReadStreamAdapter(
override fun incoming(): Flow<ByteArray> = stream.incoming
}
/**
* Write-only adapter over a locally-opened uni QUIC stream. The
* underlying [QuicWebTransportSessionState.openUniStream] has already
* pushed the WT framing prefix (0x54 + quarter session id), so the
* caller's [write] payload goes straight onto the wire.
*/
private class QuicUniWriteStreamAdapter(
private val stream: QuicStream,
private val driver: com.vitorpamplona.quic.connection.QuicConnectionDriver,
) : WebTransportWriteStream {
override suspend fun write(chunk: ByteArray) {
stream.send.enqueue(chunk)
driver.wakeup()
}
override suspend fun finish() {
stream.send.finish()
driver.wakeup()
}
}
/** Adapter for a WT peer-initiated uni stream whose prefix has been stripped. */
private class StrippedWtReadStreamAdapter(
private val stripped: com.vitorpamplona.quic.webtransport.StrippedWtStream,
) : WebTransportReadStream {
override fun incoming(): Flow<ByteArray> = stripped.data
}
/**
* Adapter for a peer-initiated bidi WT stream whose WT_BIDI_STREAM prefix
* has been stripped. Routes [write] / [finish] through the demux's
* driver-aware closures so application bytes actually leave the
* connection.
*/
private class StrippedWtBidiStreamAdapter(
private val stripped: com.vitorpamplona.quic.webtransport.StrippedWtStream,
) : WebTransportBidiStream {
init {
check(!stripped.isUnidirectional) {
"StrippedWtBidiStreamAdapter requires a bidi stream, got uni"
}
}
override fun incoming(): Flow<ByteArray> = stripped.data
override suspend fun write(chunk: ByteArray) {
val send =
stripped.send
?: error("peer-initiated bidi stream has no send half — demux didn't wire one")
send(chunk)
}
override suspend fun finish() {
val finish =
stripped.finish
?: error("peer-initiated bidi stream has no finish — demux didn't wire one")
finish()
}
}