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:
+2
@@ -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
-1
@@ -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) {
|
||||
|
||||
+85
-1
@@ -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.
|
||||
*
|
||||
|
||||
+44
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user