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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user