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
@@ -56,10 +56,19 @@ class NestPlayer(
*
* Decoder errors are reported via [onError] but do NOT stop the loop —
* one bad packet shouldn't tear down the room. Player errors are fatal.
*
* [onLevel] receives the peak amplitude of each successfully decoded
* frame, normalized to `[0, 1]`. Default no-op so callers that don't
* care about levels (tests, audio-only consumers) pay zero cost.
* Invoked on the same dispatcher as the decode loop — typically
* `viewModelScope`'s Main, so the consumer must keep its handler
* lightweight (e.g. a HashMap put followed by a coalesced StateFlow
* emission).
*/
fun play(
objects: Flow<MoqObject>,
onError: (AudioException) -> Unit = { /* swallow */ },
onLevel: (Float) -> Unit = { /* no-op */ },
) {
check(!stopped) { "NestPlayer already stopped" }
check(job == null) { "NestPlayer.play already called" }
@@ -85,6 +94,7 @@ class NestPlayer(
return@collect
}
if (pcm.isNotEmpty()) {
onLevel(peakAmplitude(pcm))
player.enqueue(pcm)
}
}
@@ -102,6 +112,20 @@ class NestPlayer(
}
}
/**
* Peak amplitude of a 16-bit PCM frame, normalized to `[0, 1]`. Peak
* (vs RMS) is jittery on its own but responds instantly to onsets,
* which suits a visual ring that the UI smooths via animateDpAsState.
*/
private fun peakAmplitude(pcm: ShortArray): Float {
var maxAbs = 0
for (s in pcm) {
val abs = if (s.toInt() < 0) -s.toInt() else s.toInt()
if (abs > maxAbs) maxAbs = abs
}
return (maxAbs / 32768f).coerceIn(0f, 1f)
}
/**
* Stop playback, cancel the decode loop, release the decoder. Idempotent.
*