diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index 0091834e1..d79d7eb29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.pluralStringResource @@ -420,28 +421,18 @@ private fun MemberCell( }, label = "speaker-outer-ring-width", ) + // Reserve enough space around the avatar Box to fit the outer ring + // and glow halo. Without this padding the rings clip against the + // surrounding Surface / LazyVerticalGrid bounds (most visibly at + // the top edge for the first row, where the stage card's rounded + // corner cuts into the glow). The glow extends up to MAX_GLOW_RADIUS + // past the avatar; the outer ring extends OUTER_RING_GAP + + // OUTER_RING_MAX_WIDTH past it. + val ringPadding = + maxOf(MAX_GLOW_RADIUS.value, (OUTER_RING_GAP + OUTER_RING_MAX_WIDTH).value).dp val avatarModifier = Modifier - .drawBehind { - if (animatedGlowAlpha > 0.001f) { - val baseRadius = size.minDimension / 2f - val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel - drawCircle( - color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha), - radius = baseRadius + extra, - ) - } - if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) { - val baseRadius = size.minDimension / 2f - val strokePx = animatedOuterRingWidth.toPx() - val ringRadius = baseRadius + OUTER_RING_GAP.toPx() + strokePx / 2f - drawCircle( - color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha), - radius = ringRadius, - style = Stroke(width = strokePx), - ) - } - }.border(animatedRingWidth, animatedRingColor, CircleShape) + .border(animatedRingWidth, animatedRingColor, CircleShape) .let { if (member.absent) it.alpha(0.5f) else it } val user = remember(member.pubkey) { @@ -480,58 +471,53 @@ private fun MemberCell( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), ) { - Box(contentAlignment = Alignment.Center) { - ClickableUserPicture( - baseUserHex = member.pubkey, - size = avatarSize, - accountViewModel = accountViewModel, - modifier = avatarModifier, - onClick = onClick, - onLongClick = onLongClick, - ) - if (isConnecting) { - CircularProgressIndicator( - modifier = Modifier.size(avatarSize - 8.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - } - val role = member.role - if (role == ROLE.HOST || role == ROLE.MODERATOR) { - RoleBadge( - role = role, - modifier = Modifier.align(Alignment.TopStart), - ) - } - if (member.handRaised) { - HandRaiseBadge( - modifier = Modifier.align(Alignment.TopEnd), - ) - } - // Show the mic badge for any on-stage speaker that has - // an audio state to surface — currently broadcasting - // (`publishing=1`) OR mic-muted (`muted=1, publishing=0`). - // Gating only on `publishing` would hide the muted icon - // the moment the user mutes, which is exactly when it's - // supposed to appear. - if (showMicBadge && (member.publishing || member.muted == true)) { - MicStateBadge( + // Outer Box paints the glow halo + detached outer ring on a + // canvas that's bigger than the avatar by [ringPadding]. The + // inner Box keeps its tight-to-avatar bounds so badge corner + // alignment (TopStart, TopEnd, BottomCenter, BottomEnd) still + // tracks the avatar circle, not the padded outer area. + Box( + modifier = + Modifier.drawBehind { + val avatarRadiusPx = avatarSize.toPx() / 2f + val cx = size.width / 2f + val cy = size.height / 2f + if (animatedGlowAlpha > 0.001f) { + val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel + drawCircle( + color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha), + radius = avatarRadiusPx + extra, + center = Offset(cx, cy), + ) + } + if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) { + val strokePx = animatedOuterRingWidth.toPx() + val ringRadius = avatarRadiusPx + OUTER_RING_GAP.toPx() + strokePx / 2f + drawCircle( + color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha), + radius = ringRadius, + center = Offset(cx, cy), + style = Stroke(width = strokePx), + ) + } + }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.padding(ringPadding), + contentAlignment = Alignment.Center, + ) { + AvatarAndBadges( + member = member, + avatarSize = avatarSize, + accountViewModel = accountViewModel, + avatarModifier = avatarModifier, + onClick = onClick, + onLongClick = onLongClick, + isConnecting = isConnecting, + showMicBadge = showMicBadge, isSpeaking = isSpeaking, - isMuted = member.muted == true, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } - // Reactions float over the avatar's bottom-right corner so - // a 👏 burst no longer pushes the username down and reflows - // neighbouring cells. The mic badge sits at BottomCenter, - // so BottomEnd + a small outward offset keeps them clear. - if (reactions.isNotEmpty()) { - SpeakerReactionOverlay( reactions = reactions, - modifier = - Modifier - .align(Alignment.BottomEnd) - .offset(x = 6.dp, y = 6.dp), ) } } @@ -545,6 +531,76 @@ private fun MemberCell( } } +@Composable +private fun AvatarAndBadges( + member: RoomMember, + avatarSize: Dp, + accountViewModel: AccountViewModel, + avatarModifier: Modifier, + onClick: ((String) -> Unit)?, + onLongClick: ((String) -> Unit)?, + isConnecting: Boolean, + showMicBadge: Boolean, + isSpeaking: Boolean, + reactions: List, +) { + Box(contentAlignment = Alignment.Center) { + ClickableUserPicture( + baseUserHex = member.pubkey, + size = avatarSize, + accountViewModel = accountViewModel, + modifier = avatarModifier, + onClick = onClick, + onLongClick = onLongClick, + ) + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(avatarSize - 8.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + val role = member.role + if (role == ROLE.HOST || role == ROLE.MODERATOR) { + RoleBadge( + role = role, + modifier = Modifier.align(Alignment.TopStart), + ) + } + if (member.handRaised) { + HandRaiseBadge( + modifier = Modifier.align(Alignment.TopEnd), + ) + } + // Show the mic badge for any on-stage speaker that has + // an audio state to surface — currently broadcasting + // (`publishing=1`) OR mic-muted (`muted=1, publishing=0`). + // Gating only on `publishing` would hide the muted icon + // the moment the user mutes, which is exactly when it's + // supposed to appear. + if (showMicBadge && (member.publishing || member.muted == true)) { + MicStateBadge( + isSpeaking = isSpeaking, + isMuted = member.muted == true, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + // Reactions float over the avatar's bottom-right corner so + // a 👏 burst no longer pushes the username down and reflows + // neighbouring cells. The mic badge sits at BottomCenter, + // so BottomEnd + a small outward offset keeps them clear. + if (reactions.isNotEmpty()) { + SpeakerReactionOverlay( + reactions = reactions, + modifier = + Modifier + .align(Alignment.BottomEnd) + .offset(x = 6.dp, y = 6.dp), + ) + } + } +} + /** * Hand-raise indicator overlaid on the avatar — yellow circle with * a hand glyph at the top-right, animated in a subtle vertical diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 8ecc9ace6..2fd58dfa0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -1494,9 +1494,17 @@ class NestViewModel( } /** - * Mark [pubkey] as currently speaking and (re)arm a [SPEAKING_TIMEOUT_MS] - * coroutine that clears it once they go quiet. Called once per - * MoQ object received on the speaker's track. + * Per-frame heartbeat. Called once per MoQ object received on the + * speaker's track — i.e. once per ~20 ms regardless of whether + * that frame contains actual speech, silence, or background noise. + * + * Bumps the cliff-detector timestamp so an active stream keeps + * resetting the relay-forward-queue stall watchdog, and clears + * the per-speaker buffering overlay the first time a frame lands. + * + * NB: this does NOT mark the speaker as "speaking right now". + * That signal is energy-gated and lives in [onAudioLevel] — + * mic-on with no voice MUST NOT light up the green ring. */ private fun onSpeakerActivity(pubkey: String) { if (closed) return @@ -1513,12 +1521,22 @@ class NestViewModel( // future re-stall starts from attempt 0 (immediate-fire) rather // than inheriting a long backoff from the prior streak. if (consecutiveCliffRecycles != 0) consecutiveCliffRecycles = 0 - speakingExpiryJobs[pubkey]?.cancel() // First frame for this subscription — clear the buffering // overlay. Subsequent frames are no-ops here. if (_uiState.value.connectingSpeakers.contains(pubkey)) { _uiState.update { it.copy(connectingSpeakers = (it.connectingSpeakers - pubkey).toPersistentSet()) } } + } + + /** + * Mark [pubkey] as currently speaking and (re)arm a + * [SPEAKING_TIMEOUT_MS] coroutine that clears the flag once their + * audio drops back below the threshold for that long. Called from + * [onAudioLevel] only when the decoded peak is loud enough to + * read as speech (see [SPEAKING_LEVEL_THRESHOLD]). + */ + private fun markSpeaking(pubkey: String) { + speakingExpiryJobs[pubkey]?.cancel() if (!_uiState.value.speakingNow.contains(pubkey)) { _uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) } } @@ -1681,6 +1699,20 @@ class NestViewModel( ) { if (closed) return rawAudioLevels[pubkey] = level + // Energy-gated speaking detector. The MoQ track delivers a + // frame every ~20 ms while the mic is open, even when the + // speaker is silent or only picking up room noise — gating the + // green ring on "frame arrived" therefore lights it up the + // moment the mic is unmuted, not when there's actually a voice + // on it. The decoded peak amplitude (`peakAmplitude` in + // nestsClient/audio/Amplitude.kt) gives us the signal we need: + // background noise / breath stays under a few percent of full + // scale, while even a quiet voice clears [SPEAKING_LEVEL_THRESHOLD]. + // The 250 ms expiry already wired up in [markSpeaking] gives + // the indicator natural hysteresis between syllables. + if (level >= SPEAKING_LEVEL_THRESHOLD) { + markSpeaking(pubkey) + } startLevelEmitter() } @@ -1839,12 +1871,29 @@ sealed class BroadcastUiState { } /** - * How long a speaker stays "speaking" after their last received MoQ object. - * Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the - * indicator flicker. + * How long a speaker stays "speaking" after their last decoded frame + * over the [SPEAKING_LEVEL_THRESHOLD]. Roughly 12 × the 20 ms Opus + * frame so brief packet jitter and inter-syllable pauses don't make + * the indicator flicker between adjacent words. */ const val SPEAKING_TIMEOUT_MS: Long = 250L +/** + * Minimum decoded peak amplitude (normalized to `[0, 1]`) that counts + * as "this person is actually speaking right now". Frames whose peak + * lands below this threshold are treated as silence / room tone / + * breath — they keep the per-speaker subscription healthy (the cliff + * heartbeat in [NestViewModel.onSpeakerActivity] still fires) but do + * NOT light up the green speaking ring. + * + * 0.06 ≈ -24 dBFS, comfortably above the typical residential-mic + * noise floor (~-40 to -30 dBFS) while still tripping on a quiet + * voice. Tuned in conjunction with [SPEAKING_TIMEOUT_MS]: a single + * loud frame is enough to arm the indicator; ≥ 250 ms below the + * threshold drops it. + */ +const val SPEAKING_LEVEL_THRESHOLD: Float = 0.06f + /** * How long [NestViewModel.openSubscription] waits for the publisher's * `catalog.json` to land before constructing the decoder + AudioTrack.