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
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient
import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.AudioRoomMoqLiteBroadcaster
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Moq-lite-backed [NestsSpeaker]. Mirrors [MoqLiteNestsListener] on the
* publish side: takes a connected [MoqLiteSession] and exposes the
* existing [NestsSpeaker] API so [connectNestsSpeaker] can swap the
* framing layer without changing any downstream consumers.
*
* Wire-flow per [MoqLiteSession.publish]:
* - the session opens a publisher state when [startBroadcasting] is
* called, then services every relay-opened Announce / Subscribe
* bidi automatically.
* - frames pushed via [MoqLitePublisherHandle.send] go on a fresh
* uni stream per group, framed as `varint(size) + payload`.
*/
class MoqLiteNestsSpeaker internal constructor(
private val session: MoqLiteSession,
private val speakerPubkeyHex: String,
private val captureFactory: () -> AudioCapture,
private val encoderFactory: () -> OpusEncoder,
private val scope: CoroutineScope,
private val mutableState: MutableStateFlow<NestsSpeakerState>,
) : NestsSpeaker {
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
private val gate = Mutex()
private var activeHandle: MoqLiteBroadcastHandle? = null
override suspend fun startBroadcasting(): BroadcastHandle {
gate.withLock {
val current = state.value
check(current is NestsSpeakerState.Connected) {
"startBroadcasting requires Connected state, was $current"
}
check(activeHandle == null) { "speaker is already broadcasting" }
// Per the audio-rooms NIP draft + JS reference
// (`@moq/publish/screen-B680RFft.js:5641`), publishers
// claim a broadcast suffix equal to their pubkey hex.
val publisher =
try {
session.publish(broadcastSuffix = speakerPubkeyHex)
} catch (t: Throwable) {
throw t
}
val broadcaster =
AudioRoomMoqLiteBroadcaster(
capture = captureFactory(),
encoder = encoderFactory(),
publisher = publisher,
scope = scope,
)
broadcaster.start()
mutableState.value =
NestsSpeakerState.Broadcasting(
room = current.room,
negotiatedMoqVersion = current.negotiatedMoqVersion,
isMuted = false,
)
val handle =
MoqLiteBroadcastHandle(
broadcaster = broadcaster,
publisher = publisher,
parent = this,
)
activeHandle = handle
return handle
}
}
/**
* Compare-and-clear that runs from inside [close] (already holds
* [gate]) and from [MoqLiteBroadcastHandle.close] (doesn't).
* Mirrors [DefaultNestsSpeaker.broadcastClosed].
*/
internal fun broadcastClosed(handle: MoqLiteBroadcastHandle) {
if (activeHandle !== handle) return
activeHandle = null
val current = mutableState.value
if (current is NestsSpeakerState.Broadcasting) {
mutableState.value =
NestsSpeakerState.Connected(current.room, current.negotiatedMoqVersion)
}
}
internal fun reportMuteState(muted: Boolean) {
val current = mutableState.value
if (current is NestsSpeakerState.Broadcasting) {
mutableState.value = current.copy(isMuted = muted)
}
}
override suspend fun close() {
// Take + clear under [gate] so a concurrent `startBroadcasting`
// can't observe a half-closed state, then run the long-running
// suspends (handle.close + session.close) outside the lock.
val handle: MoqLiteBroadcastHandle?
gate.withLock {
if (state.value is NestsSpeakerState.Closed) return
handle = activeHandle
activeHandle = null
mutableState.value = NestsSpeakerState.Closed
}
handle?.runCatching { close() }
runCatching { session.close() }
}
}
internal class MoqLiteBroadcastHandle(
private val broadcaster: AudioRoomMoqLiteBroadcaster,
private val publisher: MoqLitePublisherHandle,
private val parent: MoqLiteNestsSpeaker,
) : BroadcastHandle {
@Volatile private var muted: Boolean = false
@Volatile private var closed: Boolean = false
override val isMuted: Boolean get() = muted
override suspend fun setMuted(muted: Boolean) {
if (closed) return
this.muted = muted
broadcaster.setMuted(muted)
parent.reportMuteState(muted)
}
override suspend fun close() {
if (closed) return
closed = true
runCatching { broadcaster.stop() }
// broadcaster.stop() already calls publisher.close(); call again
// defensively to make this method idempotent against partial
// failures on the broadcaster.stop path.
runCatching { publisher.close() }
parent.broadcastClosed(this)
}
}