feat(audio-rooms): M6 + M7 — AudioRoomBroadcaster + NestsSpeaker API

M6 (AudioRoomBroadcaster): Inverse of AudioRoomPlayer. Pulls PCM frames
from an AudioCapture, runs them through an OpusEncoder, and pushes the
resulting Opus packets into a MoqSession.TrackPublisher as
OBJECT_DATAGRAMs. setMuted keeps the capture + encoder running so unmute
is sample-accurate; encode failures are reported via onError but don't
tear the loop down.

M7 (NestsSpeaker): Mirror of NestsListener for the host / speaker path.
- `NestsSpeaker.startBroadcasting()` — sends ANNOUNCE for the room's
  namespace (`["nests", roomId]`), opens a TrackPublisher named after
  this user's pubkey hex, wires an AudioRoomBroadcaster onto it.
- `BroadcastHandle.setMuted` / `close()` — speaker-side equivalents of
  the listener's mute / disconnect controls.
- `NestsSpeakerState` sealed hierarchy (Idle / Connecting{step} /
  Connected / Broadcasting{isMuted} / Failed / Closed).
- `connectNestsSpeaker` orchestration mirrors `connectNestsListener`
  end-to-end (HTTP → WebTransport → MoQ setup), then returns a
  `DefaultNestsSpeaker` ready for `startBroadcasting`.

Tests:
- AudioRoomBroadcasterTest (5 tests): pcm-flow ordering, mute behavior,
  encoder warmup skip, encode-error tolerance, idempotent stop.
- NestsSpeakerTest (2 tests): start/mute/close state transitions,
  double-start rejection.

Bug caught + fixed during M7 testing: `DefaultNestsSpeaker.close()`
held its `gate` mutex while calling `handle.close()` which then called
back through `parent.broadcastClosed()` → self-deadlock. Fixed by
making `broadcastClosed` lockless (StateFlow updates are atomic; the
activeHandle compare-and-set is benign under the gate's exclusion of
concurrent startBroadcasting calls).

Verified: `:nestsClient:jvmTest` (78 tests, all green) +
`./gradlew spotlessApply` clean.
This commit is contained in:
Claude
2026-04-26 03:22:02 +00:00
parent 5c32996f93
commit 0afde79afd
5 changed files with 868 additions and 0 deletions
@@ -0,0 +1,170 @@
/*
* 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.OpusEncoder
import com.vitorpamplona.nestsclient.moq.Announce
import com.vitorpamplona.nestsclient.moq.AnnounceOk
import com.vitorpamplona.nestsclient.moq.ClientSetup
import com.vitorpamplona.nestsclient.moq.MoqCodec
import com.vitorpamplona.nestsclient.moq.MoqSession
import com.vitorpamplona.nestsclient.moq.MoqVersion
import com.vitorpamplona.nestsclient.moq.ServerSetup
import com.vitorpamplona.nestsclient.moq.TrackNamespace
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class NestsSpeakerTest {
@Test
fun startBroadcasting_announces_then_state_is_Broadcasting() =
runTest {
val (speakerSide, peerSide) = FakeWebTransport.pair()
val speakerSession = MoqSession.client(speakerSide, backgroundScope)
val ns = TrackNamespace.of("nests", "test-room")
val peer =
async {
val ctrl = peerSide.peerOpenedBidiStreams().first()
val cs = MoqCodec.decode(ctrl.incoming().first())!!.message as ClientSetup
ctrl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
val ann = MoqCodec.decode(ctrl.incoming().first())!!.message as Announce
ctrl.write(MoqCodec.encode(AnnounceOk(ann.namespace)))
}
speakerSession.setup(listOf(MoqVersion.DRAFT_17))
val state =
MutableStateFlow<NestsSpeakerState>(
NestsSpeakerState.Connected(
roomInfo = NestsRoomInfo(endpoint = "https://relay.example/moq"),
negotiatedMoqVersion = speakerSession.selectedVersion!!,
),
)
val speaker =
DefaultNestsSpeaker(
session = speakerSession,
roomNamespace = ns,
speakerTrackName = "test-pubkey".encodeToByteArray(),
captureFactory = { ConstantCapture() },
encoderFactory = { ConstantEncoder() },
scope = backgroundScope,
mutableState = state,
)
val handle = speaker.startBroadcasting()
peer.await()
val now = speaker.state.value
assertIs<NestsSpeakerState.Broadcasting>(now)
assertEquals(false, now.isMuted)
// setMuted reflects through into the public state.
handle.setMuted(true)
assertEquals(true, (speaker.state.value as NestsSpeakerState.Broadcasting).isMuted)
assertEquals(true, handle.isMuted)
handle.close()
// Should fall back to Connected after broadcast ends.
assertIs<NestsSpeakerState.Connected>(speaker.state.value)
speaker.close()
assertEquals(NestsSpeakerState.Closed, speaker.state.value)
}
@Test
fun startBroadcasting_twice_throws_IllegalStateException() =
runTest {
val (speakerSide, peerSide) = FakeWebTransport.pair()
val speakerSession = MoqSession.client(speakerSide, backgroundScope)
val ns = TrackNamespace.of("nests", "test-room")
val peer =
async {
val ctrl = peerSide.peerOpenedBidiStreams().first()
val cs = MoqCodec.decode(ctrl.incoming().first())!!.message as ClientSetup
ctrl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
val ann = MoqCodec.decode(ctrl.incoming().first())!!.message as Announce
ctrl.write(MoqCodec.encode(AnnounceOk(ann.namespace)))
}
speakerSession.setup(listOf(MoqVersion.DRAFT_17))
val state =
MutableStateFlow<NestsSpeakerState>(
NestsSpeakerState.Connected(
roomInfo = NestsRoomInfo(endpoint = "https://relay.example/moq"),
negotiatedMoqVersion = speakerSession.selectedVersion!!,
),
)
val speaker =
DefaultNestsSpeaker(
session = speakerSession,
roomNamespace = ns,
speakerTrackName = "test-pubkey".encodeToByteArray(),
captureFactory = { ConstantCapture() },
encoderFactory = { ConstantEncoder() },
scope = backgroundScope,
mutableState = state,
)
speaker.startBroadcasting()
peer.await()
val ex =
runCatching { speaker.startBroadcasting() }.exceptionOrNull()
assertTrue(ex is IllegalStateException, "second startBroadcasting should throw IllegalStateException, got $ex")
speaker.close()
}
// ----- Fakes -----
private class ConstantCapture : AudioCapture {
// Yields a single dummy frame then drains; per-instance so tests
// don't share state through a singleton.
private val ch = Channel<ShortArray>(capacity = 1)
init {
ch.trySend(ShortArray(960) { 0 })
ch.close()
}
override fun start() {}
override suspend fun readFrame(): ShortArray? = ch.receiveCatching().getOrNull()
override fun stop() {}
}
private class ConstantEncoder : OpusEncoder {
override fun encode(pcm: ShortArray): ByteArray = byteArrayOf(0xAA.toByte(), 0xBB.toByte())
override fun release() {}
}
}
@@ -0,0 +1,210 @@
/*
* 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.audio
import com.vitorpamplona.nestsclient.moq.MoqSession
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AudioRoomBroadcasterTest {
@Test
fun pcm_frames_flow_through_encoder_into_publisher_in_order() =
runTest {
val capture = ScriptedCapture(listOf(shortArrayOf(1), shortArrayOf(2), shortArrayOf(3)))
val encoder = ScriptedEncoder(prefix = byteArrayOf(0xAA.toByte()))
val publisher = RecordingPublisher("track")
val broadcaster = AudioRoomBroadcaster(capture, encoder, publisher, backgroundScope)
broadcaster.start()
// Capture exhausts after 3 frames (readFrame returns null) so the
// loop terminates on its own; broadcaster.stop() cleans up the
// resources but the wire results are observable now.
capture.awaitDrained()
assertEquals(
listOf(byteArrayOf(0xAA.toByte(), 1).toList(), byteArrayOf(0xAA.toByte(), 2).toList(), byteArrayOf(0xAA.toByte(), 3).toList()),
publisher.sent.map { it.toList() },
)
broadcaster.stop()
assertTrue(capture.startedFlag, "capture should have started")
assertTrue(capture.stopCount > 0, "capture should have stopped")
assertTrue(encoder.released, "encoder should have been released")
assertTrue(publisher.closed, "publisher should have been closed")
}
@Test
fun setMuted_true_keeps_capture_running_but_no_frames_published() =
runTest {
val capture = ScriptedCapture(listOf(shortArrayOf(1), shortArrayOf(2), shortArrayOf(3)))
val encoder = ScriptedEncoder(prefix = byteArrayOf(0xBB.toByte()))
val publisher = RecordingPublisher("track")
val broadcaster = AudioRoomBroadcaster(capture, encoder, publisher, backgroundScope)
broadcaster.setMuted(true)
broadcaster.start()
capture.awaitDrained()
// Mic was open + encoder ran; publisher saw nothing.
assertTrue(capture.startedFlag)
assertEquals(3, encoder.encodeCalls, "encoder still ran while muted (state preserved)")
assertTrue(publisher.sent.isEmpty(), "no frames should reach the wire while muted")
broadcaster.stop()
}
@Test
fun encoder_returning_empty_array_is_silently_skipped() =
runTest {
val capture = ScriptedCapture(listOf(shortArrayOf(1), shortArrayOf(2)))
val encoder = ScriptedEncoder(prefix = byteArrayOf(0xCC.toByte()), warmupSkips = 1)
val publisher = RecordingPublisher("track")
val broadcaster = AudioRoomBroadcaster(capture, encoder, publisher, backgroundScope)
broadcaster.start()
capture.awaitDrained()
// First frame was the warmup → encoder returned empty → skipped.
// Second frame should reach the publisher.
assertEquals(1, publisher.sent.size)
assertContentEquals(byteArrayOf(0xCC.toByte(), 2), publisher.sent.single())
broadcaster.stop()
}
@Test
fun encoder_failure_does_not_stop_the_loop() =
runTest {
val capture = ScriptedCapture(listOf(shortArrayOf(1), shortArrayOf(2), shortArrayOf(3)))
val encoder = ScriptedEncoder(prefix = byteArrayOf(0xDD.toByte()), failOnNthCall = 1)
val publisher = RecordingPublisher("track")
val broadcaster = AudioRoomBroadcaster(capture, encoder, publisher, backgroundScope)
val errors = mutableListOf<AudioException>()
broadcaster.start { errors.add(it) }
capture.awaitDrained()
assertEquals(1, errors.size, "exactly one encode error should have been reported")
assertEquals(2, publisher.sent.size, "two non-failed frames should have reached the publisher")
broadcaster.stop()
}
@Test
fun stop_is_idempotent() =
runTest {
val capture = ScriptedCapture(listOf(shortArrayOf(1)))
val encoder = ScriptedEncoder(prefix = byteArrayOf(0xEE.toByte()))
val publisher = RecordingPublisher("track")
val broadcaster = AudioRoomBroadcaster(capture, encoder, publisher, backgroundScope)
broadcaster.start()
capture.awaitDrained()
broadcaster.stop()
broadcaster.stop()
broadcaster.stop()
assertEquals(1, capture.stopCount)
assertTrue(encoder.released)
assertTrue(publisher.closed)
}
// ---------- Fakes ----------
private class ScriptedCapture(
frames: List<ShortArray>,
) : AudioCapture {
private val queue = Channel<ShortArray>(capacity = frames.size + 1)
private val drainSignal = kotlinx.coroutines.CompletableDeferred<Unit>()
var startedFlag = false
private set
var stopCount = 0
private set
init {
frames.forEach { queue.trySend(it) }
queue.close()
}
override fun start() {
startedFlag = true
}
override suspend fun readFrame(): ShortArray? {
val next = queue.receiveCatching().getOrNull()
if (next == null && !drainSignal.isCompleted) drainSignal.complete(Unit)
return next
}
override fun stop() {
stopCount++
}
suspend fun awaitDrained() {
drainSignal.await()
}
}
private class ScriptedEncoder(
private val prefix: ByteArray,
private val warmupSkips: Int = 0,
private val failOnNthCall: Int = -1, // -1 = never fail
) : OpusEncoder {
var encodeCalls: Int = 0
private set
var released: Boolean = false
private set
override fun encode(pcm: ShortArray): ByteArray {
val callIndex = encodeCalls
encodeCalls += 1
if (callIndex == failOnNthCall) error("scripted encoder failure at call $callIndex")
if (callIndex < warmupSkips) return ByteArray(0)
// Append the first PCM sample byte so tests can verify ordering.
return prefix + byteArrayOf(pcm.first().toByte())
}
override fun release() {
released = true
}
}
private class RecordingPublisher(
nameStr: String,
) : MoqSession.TrackPublisher {
override val name: ByteArray = nameStr.encodeToByteArray()
val sent: MutableList<ByteArray> = mutableListOf()
var closed: Boolean = false
private set
override suspend fun send(payload: ByteArray): Boolean {
sent.add(payload)
return true
}
override suspend fun close() {
closed = true
}
}
}