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
@@ -189,6 +189,24 @@ class NestViewModel(
private val _announcedSpeakers = MutableStateFlow<Set<String>>(emptySet())
val announcedSpeakers: StateFlow<Set<String>> = _announcedSpeakers.asStateFlow()
/**
* Per-speaker peak audio amplitude for the most recent decoded
* frame, normalized to `[0, 1]`. Drives the live "voice ring"
* around speaker avatars: while a pubkey is in [NestUiState.speakingNow],
* the UI reads this map to throb the green border in time with
* the voice.
*
* Updated by [NestPlayer]'s `onLevel` callback at ~50 Hz per speaker
* (one frame per 20 ms Opus packet); the VM coalesces those raw
* updates into a single StateFlow emission every [LEVEL_TICK_MS]
* via [levelEmitterJob]. Empty when no speaker is being decoded;
* an entry drops when its subscription closes.
*/
private val rawAudioLevels = mutableMapOf<String, Float>()
private val _audioLevels = MutableStateFlow<Map<String, Float>>(emptyMap())
val audioLevels: StateFlow<Map<String, Float>> = _audioLevels.asStateFlow()
private var levelEmitterJob: Job? = null
/**
* `true` once the local user has been kicked (#5) — the platform
* layer flips this on a valid kind-4312 from a host/moderator and
@@ -638,6 +656,7 @@ class NestViewModel(
listener = l
observeListenerState(l)
observeAnnounces(l)
startLevelEmitter()
} catch (ce: CancellationException) {
throw ce
} catch (t: Throwable) {
@@ -695,6 +714,9 @@ class NestViewModel(
if (_speakerCatalogs.value.containsKey(slot.pubkey)) {
_speakerCatalogs.update { it - slot.pubkey }
}
if (rawAudioLevels.remove(slot.pubkey) != null) {
_audioLevels.value = rawAudioLevels.toMap()
}
if (roomPlayer != null || handle != null) {
viewModelScope.launch {
roomPlayer?.runCatching { stop() }
@@ -773,7 +795,11 @@ class NestViewModel(
// Tap the object flow to drive the speaking-now indicator before
// the decoder consumes it.
val instrumented = handle.objects.onEach { onSpeakerActivity(pubkey) }
roomPlayer.play(instrumented, onError = { /* swallow per-packet decoder errors */ })
roomPlayer.play(
instrumented,
onError = { /* swallow per-packet decoder errors */ },
onLevel = { onAudioLevel(pubkey, it) },
)
slot.attach(handle, roomPlayer, player)
publishActiveSpeakers()
// Enter the buffering window — UI renders a spinner
@@ -817,6 +843,12 @@ class NestViewModel(
stateObserverJob = null
announcesJob?.cancel()
announcesJob = null
levelEmitterJob?.cancel()
levelEmitterJob = null
rawAudioLevels.clear()
if (_audioLevels.value.isNotEmpty()) {
_audioLevels.value = emptyMap()
}
if (_announcedSpeakers.value.isNotEmpty()) {
_announcedSpeakers.value = emptySet()
}
@@ -906,6 +938,48 @@ class NestViewModel(
if (_uiState.value.speakingNow.contains(pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow - pubkey).toPersistentSet()) }
}
// Drop the latest level too — when the speaker goes quiet the
// ring should fall back to the static "in speakingNow" colour
// rather than freezing at the last loud peak.
if (rawAudioLevels.remove(pubkey) != null) {
_audioLevels.value = rawAudioLevels.toMap()
}
}
/**
* Record the latest decoded peak amplitude for [pubkey]. Called
* from the [NestPlayer] decode loop on the same dispatcher as the
* VM, so plain map mutation is safe. The actual StateFlow emission
* is coalesced by [startLevelEmitter] so a 50 Hz packet rate
* doesn't translate into 50 Hz recompositions.
*/
private fun onAudioLevel(
pubkey: String,
level: Float,
) {
if (closed) return
rawAudioLevels[pubkey] = level
}
/**
* Tick every [LEVEL_TICK_MS] and publish the current map of
* per-speaker levels. Coalesces the high-frequency raw updates
* into ~10 Hz UI state so the speaking-ring animation has a
* smooth, lightweight signal to follow.
*/
private fun startLevelEmitter() {
if (levelEmitterJob?.isActive == true) return
levelEmitterJob =
viewModelScope.launch {
while (true) {
delay(LEVEL_TICK_MS)
if (closed) return@launch
val snapshot = if (rawAudioLevels.isEmpty()) emptyMap() else rawAudioLevels.toMap()
if (snapshot != _audioLevels.value) {
_audioLevels.value = snapshot
}
}
}
}
private class ActiveSubscription private constructor(
@@ -1074,6 +1148,16 @@ sealed class BroadcastUiState {
*/
const val SPEAKING_TIMEOUT_MS: Long = 250L
/**
* Coalescing interval for [NestViewModel.audioLevels]. The decode loop
* pushes a fresh peak every ~20 ms (one per Opus frame); we publish to
* the StateFlow at this cadence instead so the UI ring animates ~10 Hz
* instead of recomposing every frame. 100 ms is fast enough that the
* eye still reads the throb as live, slow enough that the cost across
* a busy stage stays trivial.
*/
const val LEVEL_TICK_MS: Long = 100L
/**
* How long a kind-7 reaction stays in
* [NestViewModel.recentReactions] before the eviction sweep