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