feat(nests): pulse the speaker ring with live audio level

Adds an end-to-end audio-amplitude pipeline so on-stage speakers'
green ring throbs in time with their voice (closer to Spaces /
Clubhouse than the previous binary "is in speakingNow" indicator).

NestPlayer.play() now takes an onLevel callback and computes the
normalized peak of each decoded 16-bit PCM frame. NestViewModel
exposes audioLevels: StateFlow<Map<String, Float>>; raw 50 Hz
updates from the decode loop are coalesced into a 10 Hz publish
tick (LEVEL_TICK_MS) so the StateFlow doesn't spam recompositions
across a busy stage. The map is cleared on speaker close, on the
speaking-timeout sweep, and on teardown.

In MemberCell the speaking ring's width animates between
AVATAR_RING_WIDTH (3 dp) and MAX_RING_WIDTH (7 dp) via
animateDpAsState, smoothing the tick into a continuous halo.
Muted-publisher and idle states are unchanged.

Adds NestPlayerTest coverage for the new callback (level math +
empty-PCM short-circuit).
This commit is contained in:
Claude
2026-04-28 12:03:13 +00:00
parent 154b3393a5
commit 04ee2c3106
5 changed files with 177 additions and 2 deletions
@@ -150,6 +150,50 @@ class NestPlayerTest {
sut.stop()
}
@Test
fun on_level_reports_normalized_peak_per_decoded_frame() =
runTest {
// Decode each input byte to a single 16-bit sample with a
// known peak so we can assert the level math directly.
// 0x01 -> 1/32768 (~0), 0x40 -> 0x4000 (0.5), 0x7F -> 0x7FFF (max).
val decoder = FakeOpusDecoder { bytes -> ShortArray(1) { (bytes[0].toInt() shl 8).toShort() } }
val player = FakeAudioPlayer()
val levels = mutableListOf<Float>()
val sut = NestPlayer(decoder, player, this)
sut.play(
objects =
flowOf(
moqObject(byteArrayOf(0x40)),
moqObject(byteArrayOf(0x7F)),
),
onLevel = { levels.add(it) },
)
testScheduler.advanceUntilIdle()
assertEquals(2, levels.size)
// 0x40 << 8 = 0x4000 = 16384 → 16384/32768 = 0.5
assertTrue(levels[0] in 0.49f..0.51f, "expected ~0.5, got ${levels[0]}")
// 0x7F << 8 = 0x7F00 → 0x7F00/32768 ≈ 0.992
assertTrue(levels[1] > 0.98f, "expected near-max, got ${levels[1]}")
sut.stop()
}
@Test
fun on_level_is_skipped_when_decoder_returns_empty_pcm() =
runTest {
val decoder = FakeOpusDecoder { ShortArray(0) }
val levels = mutableListOf<Float>()
val sut = NestPlayer(decoder, FakeAudioPlayer(), this)
sut.play(
objects = flowOf(moqObject(byteArrayOf(0x01))),
onLevel = { levels.add(it) },
)
testScheduler.advanceUntilIdle()
assertEquals(0, levels.size)
sut.stop()
}
@Test
fun objects_arriving_after_play_are_streamed_through_the_pipeline() =
runTest {