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
@@ -37,6 +37,7 @@ import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
/**
* Drives a [MoqLiteSession] from the listener side against a fake peer
@@ -99,7 +100,7 @@ class MoqLiteSessionTest {
assertEquals(0L, handle.id, "first subscribe id is 0")
// Now push one group with two frames from the server side.
val uni = serverSide.openPeerUniStream()
val uni = serverSide.openUniStream()
uni.write(Varint.encode(MoqLiteDataType.Group.code))
uni.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId = handle.id, sequence = 7L)))
uni.write(framePayload(byteArrayOf(0x10, 0x11)))
@@ -226,13 +227,13 @@ class MoqLiteSessionTest {
assertEquals(1L, handleB.id)
// Push one group for A with payload "a", one for B with payload "b".
val uniB = serverSide.openPeerUniStream()
val uniB = serverSide.openUniStream()
uniB.write(Varint.encode(MoqLiteDataType.Group.code))
uniB.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId = handleB.id, sequence = 0L)))
uniB.write(framePayload("b".encodeToByteArray()))
uniB.finish()
val uniA = serverSide.openPeerUniStream()
val uniA = serverSide.openUniStream()
uniA.write(Varint.encode(MoqLiteDataType.Group.code))
uniA.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId = handleA.id, sequence = 0L)))
uniA.write(framePayload("a".encodeToByteArray()))
@@ -248,6 +249,139 @@ class MoqLiteSessionTest {
session.close()
}
@Test
fun publisher_replies_to_announcePlease_with_active_announce() =
runBlocking {
val (clientSide, serverSide) = FakeWebTransport.pair()
val session = MoqLiteSession.client(clientSide, pumpScope)
val publisher = session.publish(broadcastSuffix = "speakerPubkey")
// Relay (serverSide) opens an Announce bidi to us with
// AnnouncePlease(prefix="").
val relayBidi = serverSide.openBidiStream()
relayBidi.write(Varint.encode(MoqLiteControlType.Announce.code))
relayBidi.write(MoqLiteCodec.encodeAnnouncePlease(MoqLiteAnnouncePlease(prefix = "")))
// We reply on the same bidi with Announce(active=true,
// suffix="speakerPubkey").
val resp = withTimeout(2_000) { relayBidi.incoming().first() }
val announce = MoqLiteCodec.decodeAnnounce(MoqLiteFrameBuffer().apply { push(resp) }.readSizePrefixed()!!)
assertEquals(MoqLiteAnnounceStatus.Active, announce.status)
assertEquals("speakerPubkey", announce.suffix)
publisher.close()
session.close()
}
@Test
fun publisher_acks_subscribe_and_pushes_group_data_on_uni_stream() =
runBlocking {
val (clientSide, serverSide) = FakeWebTransport.pair()
val session = MoqLiteSession.client(clientSide, pumpScope)
val publisher = session.publish(broadcastSuffix = "speakerPubkey")
// Step 1: relay opens Subscribe bidi.
val subBidi = serverSide.openBidiStream()
subBidi.write(Varint.encode(MoqLiteControlType.Subscribe.code))
subBidi.write(
MoqLiteCodec.encodeSubscribe(
MoqLiteSubscribe(
id = 7L,
broadcast = "speakerPubkey",
track = "audio/data",
priority = 0x80,
ordered = true,
maxLatencyMillis = 0L,
startGroup = null,
endGroup = null,
),
),
)
// Step 2: we reply SubscribeOk.
val ackChunk = withTimeout(2_000) { subBidi.incoming().first() }
val resp =
MoqLiteCodec.decodeSubscribeResponse(
MoqLiteFrameBuffer().apply { push(ackChunk) }.readSizePrefixed()!!,
)
assertIs<MoqLiteCodec.SubscribeResponse.Ok>(resp)
// Step 3: publisher pushes one frame, which opens a uni
// stream with DataType=Group + group header + frame.
assertEquals(true, publisher.send("opus-1".encodeToByteArray()))
publisher.endGroup()
// The uni stream surfaces on the relay side via incomingUniStreams.
val relayUni = withTimeout(2_000) { serverSide.incomingUniStreams().first() }
val uniChunks = relayUni.incoming().toList()
// Concatenate all chunks then parse: type + group header +
// first frame. The buffer reader handles arbitrary chunk
// boundaries.
val buf = MoqLiteFrameBuffer()
uniChunks.forEach { buf.push(it) }
assertEquals(MoqLiteDataType.Group.code, buf.readVarint(), "uni stream starts with Group type byte")
val header =
MoqLiteCodec.decodeGroupHeader(
buf.readSizePrefixed() ?: error("group header missing"),
)
assertEquals(7L, header.subscribeId)
assertEquals(0L, header.sequence, "first group is sequence 0")
val firstFrame =
buf.readSizePrefixed()
?: error("first frame missing")
assertContentEquals("opus-1".encodeToByteArray(), firstFrame)
publisher.close()
session.close()
}
@Test
fun publisher_send_returns_false_when_no_inbound_subscriber() =
runBlocking {
val (clientSide, _) = FakeWebTransport.pair()
val session = MoqLiteSession.client(clientSide, pumpScope)
val publisher = session.publish(broadcastSuffix = "speakerPubkey")
// No relay-opened Subscribe bidi → no subscribers → send is
// a silent no-op (returns false), matching the listener
// semantics where the speaker keeps capturing audio even
// when nobody is listening.
assertEquals(false, publisher.send("ignored".encodeToByteArray()))
publisher.close()
session.close()
}
@Test
fun publisher_close_emits_ended_announce() =
runBlocking {
val (clientSide, serverSide) = FakeWebTransport.pair()
val session = MoqLiteSession.client(clientSide, pumpScope)
val publisher = session.publish(broadcastSuffix = "speakerPubkey")
// Relay opens an announce bidi.
val relayBidi = serverSide.openBidiStream()
relayBidi.write(Varint.encode(MoqLiteControlType.Announce.code))
relayBidi.write(MoqLiteCodec.encodeAnnouncePlease(MoqLiteAnnouncePlease(prefix = "")))
// Drain the Active announce so the next .first() picks up Ended.
withTimeout(2_000) { relayBidi.incoming().first() }
publisher.close()
val endedChunk = withTimeout(2_000) { relayBidi.incoming().first() }
val ended =
MoqLiteCodec.decodeAnnounce(
MoqLiteFrameBuffer().apply { push(endedChunk) }.readSizePrefixed()!!,
)
assertEquals(MoqLiteAnnounceStatus.Ended, ended.status)
assertEquals("speakerPubkey", ended.suffix)
session.close()
}
@Test
fun unsubscribe_FINs_the_subscribe_bidi() =
runBlocking {