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
@@ -134,6 +134,7 @@ internal fun NestFullScreen(
val presences by viewModel.presences.collectAsState()
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
val audioLevels by viewModel.audioLevels.collectAsState()
val onStageKeys = remember(onStage) { onStage.map { it.pubKey }.toSet() }
val participantGrid =
@@ -225,6 +226,7 @@ internal fun NestFullScreen(
StageGrid(
members = participantGrid.onStage,
speakingNow = ui.speakingNow,
audioLevels = audioLevels,
accountViewModel = accountViewModel,
reactionsByPubkey = reactionsByPubkey,
connectingSpeakers = ui.connectingSpeakers,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
@@ -74,6 +75,11 @@ private val AUDIENCE_AVATAR = 100.dp
private val GRID_SPACING = 6.dp
private val AVATAR_RING_WIDTH = 3.dp
// Upper bound for the live "voice" halo. Speaking with a peak audio
// level of 1.0 grows the ring from AVATAR_RING_WIDTH to this value;
// quieter voices land somewhere in between.
private val MAX_RING_WIDTH = 7.dp
// Cap badge sizes so they stay legible without dominating the avatar
// at 100.dp. The 0.42 ratio was tuned for ~48.dp avatars (giving
// ~20.dp badges); without a cap, scaling to 100.dp produces 42.dp
@@ -109,6 +115,7 @@ internal fun StageGrid(
speakingNow: ImmutableSet<String>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
audioLevels: Map<String, Float> = emptyMap(),
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
onLongPressParticipant: ((String) -> Unit)? = null,
@@ -141,6 +148,7 @@ internal fun StageGrid(
member = member,
avatarSize = STAGE_AVATAR,
isSpeaking = member.pubkey in speakingNow,
audioLevel = audioLevels[member.pubkey] ?: 0f,
isConnecting = member.pubkey in connectingSpeakers,
showMicBadge = true,
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
@@ -205,6 +213,7 @@ internal fun AudienceGrid(
member = member,
avatarSize = AUDIENCE_AVATAR,
isSpeaking = false,
audioLevel = 0f,
isConnecting = false,
showMicBadge = false,
reactions = emptyList(),
@@ -221,6 +230,7 @@ private fun MemberCell(
member: RoomMember,
avatarSize: Dp,
isSpeaking: Boolean,
audioLevel: Float,
isConnecting: Boolean,
showMicBadge: Boolean,
reactions: List<RoomReaction>,
@@ -239,9 +249,20 @@ private fun MemberCell(
showMicBadge && member.publishing && member.muted == true -> mutedRingColor
else -> null
}
// Throb the ring width with the live peak amplitude — quiet voices
// sit at the base width, loud ones widen the halo. animateDpAsState
// smooths the 10 Hz raw signal into a continuous animation. The
// muted ring stays at base width because no audio is being decoded.
val targetRingWidth =
when {
isSpeaking -> AVATAR_RING_WIDTH + (audioLevel.coerceIn(0f, 1f) * (MAX_RING_WIDTH - AVATAR_RING_WIDTH).value).dp
ringColor != null -> AVATAR_RING_WIDTH
else -> 0.dp
}
val animatedRingWidth by animateDpAsState(targetValue = targetRingWidth, label = "speaker-ring-width")
val avatarModifier =
Modifier
.let { if (ringColor != null) it.border(AVATAR_RING_WIDTH, ringColor, CircleShape) else it }
.let { if (ringColor != null) it.border(animatedRingWidth, ringColor, CircleShape) else it }
.let { if (member.absent) it.alpha(0.5f) else it }
val user =
remember(member.pubkey) {
@@ -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
@@ -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.
*
@@ -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 {