fix(nests): green speaking ring no longer cropped + gates on actual speech
The full-screen Nest stage's green speaking indicator had two issues: 1. **Cropping at the top.** The glow halo (drawBehind, up to MAX_GLOW_RADIUS past the avatar) and detached outer ring rendered onto the avatar's own draw layer, which only had the avatar's bounds (75dp circle). The surrounding stage Surface (RoundedCornerShape(20dp)) clipped the topmost pixels of the first row's glow. Wrap the avatar+badges in an outer Box that reserves [ringPadding] (max of MAX_GLOW_RADIUS / OUTER_RING_GAP + OUTER_RING_MAX_WIDTH) and move the drawBehind there, drawing relative to the inner avatar circle. Badge corner-alignment stays on the inner Box so role/hand/mic badges still anchor to the avatar. 2. **Lit up on mic-on, not on actual speech.** `onSpeakerActivity` was called once per MoQ object received — i.e. every ~20 ms while the mic was open, regardless of whether the frame contained voice, silence, or room noise. That meant the green ring was effectively a "mic is on" indicator. Split the heartbeat (kept in `onSpeakerActivity`, which still drives the cliff detector and clears the connecting overlay) from the speaking detector (now in `onAudioLevel`, gated on the decoded peak amplitude clearing SPEAKING_LEVEL_THRESHOLD = 0.06, ~-24 dBFS). The existing 250 ms expiry job gives natural hysteresis between syllables.
This commit is contained in:
+126
-70
@@ -57,6 +57,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
@@ -420,28 +421,18 @@ private fun MemberCell(
|
|||||||
},
|
},
|
||||||
label = "speaker-outer-ring-width",
|
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 =
|
val avatarModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.drawBehind {
|
.border(animatedRingWidth, animatedRingColor, CircleShape)
|
||||||
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)
|
|
||||||
.let { if (member.absent) it.alpha(0.5f) else it }
|
.let { if (member.absent) it.alpha(0.5f) else it }
|
||||||
val user =
|
val user =
|
||||||
remember(member.pubkey) {
|
remember(member.pubkey) {
|
||||||
@@ -480,58 +471,53 @@ private fun MemberCell(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
// Outer Box paints the glow halo + detached outer ring on a
|
||||||
ClickableUserPicture(
|
// canvas that's bigger than the avatar by [ringPadding]. The
|
||||||
baseUserHex = member.pubkey,
|
// inner Box keeps its tight-to-avatar bounds so badge corner
|
||||||
size = avatarSize,
|
// alignment (TopStart, TopEnd, BottomCenter, BottomEnd) still
|
||||||
accountViewModel = accountViewModel,
|
// tracks the avatar circle, not the padded outer area.
|
||||||
modifier = avatarModifier,
|
Box(
|
||||||
onClick = onClick,
|
modifier =
|
||||||
onLongClick = onLongClick,
|
Modifier.drawBehind {
|
||||||
)
|
val avatarRadiusPx = avatarSize.toPx() / 2f
|
||||||
if (isConnecting) {
|
val cx = size.width / 2f
|
||||||
CircularProgressIndicator(
|
val cy = size.height / 2f
|
||||||
modifier = Modifier.size(avatarSize - 8.dp),
|
if (animatedGlowAlpha > 0.001f) {
|
||||||
strokeWidth = 2.dp,
|
val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel
|
||||||
color = MaterialTheme.colorScheme.primary,
|
drawCircle(
|
||||||
)
|
color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha),
|
||||||
}
|
radius = avatarRadiusPx + extra,
|
||||||
val role = member.role
|
center = Offset(cx, cy),
|
||||||
if (role == ROLE.HOST || role == ROLE.MODERATOR) {
|
)
|
||||||
RoleBadge(
|
}
|
||||||
role = role,
|
if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) {
|
||||||
modifier = Modifier.align(Alignment.TopStart),
|
val strokePx = animatedOuterRingWidth.toPx()
|
||||||
)
|
val ringRadius = avatarRadiusPx + OUTER_RING_GAP.toPx() + strokePx / 2f
|
||||||
}
|
drawCircle(
|
||||||
if (member.handRaised) {
|
color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha),
|
||||||
HandRaiseBadge(
|
radius = ringRadius,
|
||||||
modifier = Modifier.align(Alignment.TopEnd),
|
center = Offset(cx, cy),
|
||||||
)
|
style = Stroke(width = strokePx),
|
||||||
}
|
)
|
||||||
// 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`).
|
contentAlignment = Alignment.Center,
|
||||||
// Gating only on `publishing` would hide the muted icon
|
) {
|
||||||
// the moment the user mutes, which is exactly when it's
|
Box(
|
||||||
// supposed to appear.
|
modifier = Modifier.padding(ringPadding),
|
||||||
if (showMicBadge && (member.publishing || member.muted == true)) {
|
contentAlignment = Alignment.Center,
|
||||||
MicStateBadge(
|
) {
|
||||||
|
AvatarAndBadges(
|
||||||
|
member = member,
|
||||||
|
avatarSize = avatarSize,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
avatarModifier = avatarModifier,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
isConnecting = isConnecting,
|
||||||
|
showMicBadge = showMicBadge,
|
||||||
isSpeaking = isSpeaking,
|
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,
|
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<RoomReaction>,
|
||||||
|
) {
|
||||||
|
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
|
* Hand-raise indicator overlaid on the avatar — yellow circle with
|
||||||
* a hand glyph at the top-right, animated in a subtle vertical
|
* a hand glyph at the top-right, animated in a subtle vertical
|
||||||
|
|||||||
+56
-7
@@ -1469,9 +1469,17 @@ class NestViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark [pubkey] as currently speaking and (re)arm a [SPEAKING_TIMEOUT_MS]
|
* Per-frame heartbeat. Called once per MoQ object received on the
|
||||||
* coroutine that clears it once they go quiet. Called once per
|
* speaker's track — i.e. once per ~20 ms regardless of whether
|
||||||
* MoQ object received on the speaker's track.
|
* 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) {
|
private fun onSpeakerActivity(pubkey: String) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
@@ -1483,12 +1491,22 @@ class NestViewModel(
|
|||||||
lastFrameAt[pubkey] =
|
lastFrameAt[pubkey] =
|
||||||
kotlin.time.TimeSource.Monotonic
|
kotlin.time.TimeSource.Monotonic
|
||||||
.markNow()
|
.markNow()
|
||||||
speakingExpiryJobs[pubkey]?.cancel()
|
|
||||||
// First frame for this subscription — clear the buffering
|
// First frame for this subscription — clear the buffering
|
||||||
// overlay. Subsequent frames are no-ops here.
|
// overlay. Subsequent frames are no-ops here.
|
||||||
if (_uiState.value.connectingSpeakers.contains(pubkey)) {
|
if (_uiState.value.connectingSpeakers.contains(pubkey)) {
|
||||||
_uiState.update { it.copy(connectingSpeakers = (it.connectingSpeakers - pubkey).toPersistentSet()) }
|
_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)) {
|
if (!_uiState.value.speakingNow.contains(pubkey)) {
|
||||||
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
|
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
|
||||||
}
|
}
|
||||||
@@ -1641,6 +1659,20 @@ class NestViewModel(
|
|||||||
) {
|
) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
rawAudioLevels[pubkey] = level
|
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()
|
startLevelEmitter()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1799,12 +1831,29 @@ sealed class BroadcastUiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a speaker stays "speaking" after their last received MoQ object.
|
* How long a speaker stays "speaking" after their last decoded frame
|
||||||
* Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the
|
* over the [SPEAKING_LEVEL_THRESHOLD]. Roughly 12 × the 20 ms Opus
|
||||||
* indicator flicker.
|
* 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
|
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
|
* How long [NestViewModel.openSubscription] waits for the publisher's
|
||||||
* `catalog.json` to land before constructing the decoder + AudioTrack.
|
* `catalog.json` to land before constructing the decoder + AudioTrack.
|
||||||
|
|||||||
Reference in New Issue
Block a user