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:
+169
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user