feat(#1247): live level meter on the voice recording strip (#1416)

This commit is contained in:
Nikhil Nair authored and GitHub committed 2026-09-30 21:14:27 +03:00
1 parent 5145a3354b
commit 9529a7e32c
4 files changed
+117 -12

No files matched your search

@@ -869,6 +869,7 @@ fun ChatScreen(
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
isRecordingVoice = mediaLaunchers.isRecordingVoice, isRecordingVoice = mediaLaunchers.isRecordingVoice,
isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked, isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
amplitudeProvider = mediaLaunchers.amplitudeProvider,
onStopGeneration = { viewModel.interruptSession() }, onStopGeneration = { viewModel.interruptSession() },
isAgentTyping = state.isAgentTyping, isAgentTyping = state.isAgentTyping,
canInterrupt = state.canInterrupt, canInterrupt = state.canInterrupt,
@@ -125,6 +125,7 @@ fun ChatInputBar(
onMicLock: () -> Unit = {}, onMicLock: () -> Unit = {},
isVoiceNoteLocked: Boolean = false, isVoiceNoteLocked: Boolean = false,
isRecordingVoice: Boolean = false, isRecordingVoice: Boolean = false,
amplitudeProvider: () -> Float = { 0f },
onStopGeneration: () -> Unit = {}, onStopGeneration: () -> Unit = {},
) { ) {
// Allow sending while the agent is mid-turn or awaiting approval: the // Allow sending while the agent is mid-turn or awaiting approval: the
@@ -440,6 +441,7 @@ fun ChatInputBar(
slideProgress = voiceSlideProgress, slideProgress = voiceSlideProgress,
locked = isVoiceNoteLocked, locked = isVoiceNoteLocked,
onCancel = onMicHoldCancel, onCancel = onMicHoldCancel,
amplitude = amplitudeProvider,
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
) )
} }
@@ -490,20 +492,21 @@ fun ChatInputBar(
} }
} }
} }
// Zero-height anchor so the tooltip and lock icon float above the // Anchor at the BOTTOM-end — directly above the mic button the thumb
// card, over the message list, like Telegram's lock control. It sits // is holding. The earlier top-of-card anchor sat ~130 dp away from the
// outside the animated card because AnimatedVisibility clips its // thumb and read as unrelated decoration, so the lock affordance went
// children to the animation bounds. // unnoticed. The content grows upward (wrapContentHeight(Bottom,
// unbounded)) and the offset lifts it clear of the button top.
Box(modifier = Modifier.fillMaxWidth().height(0.dp)) { Box(modifier = Modifier.fillMaxWidth().height(0.dp)) {
VoiceNoteLockHintOverlay( VoiceNoteLockHintOverlay(
visible = isRecordingVoice && !isVoiceNoteLocked, visible = isRecordingVoice && !isVoiceNoteLocked,
showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f, showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f,
modifier = modifier =
Modifier Modifier
.wrapContentHeight(align = Alignment.Top, unbounded = true) .wrapContentHeight(align = Alignment.Bottom, unbounded = true)
.align(Alignment.TopEnd) .align(Alignment.BottomEnd)
.offset(y = (-42).dp) .offset(y = (-54).dp)
.padding(end = 12.dp), .padding(end = 6.dp),
) )
} }
} }
@@ -543,6 +546,7 @@ private fun VoiceNoteLockHintOverlay(
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -557,6 +561,17 @@ private fun VoiceNoteLockHintOverlay(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
} }
// The track: a short line from the lock circle down toward the mic
// button — the affordance reads as "the thing above your thumb",
// and the direction of the gesture is implied by where it sits.
Box(
modifier =
Modifier
.width(1.5.dp)
.height(12.dp)
.background(palette.placeholder.copy(alpha = 0.6f)),
)
}
} }
} }
@@ -35,6 +35,7 @@ import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.sqrt
class ChatMediaLaunchers( class ChatMediaLaunchers(
val isListening: Boolean, val isListening: Boolean,
@@ -48,6 +49,8 @@ class ChatMediaLaunchers(
val onMicHoldEnd: () -> Unit = {}, val onMicHoldEnd: () -> Unit = {},
val onMicHoldCancel: () -> Unit = {}, val onMicHoldCancel: () -> Unit = {},
val onMicLock: () -> Unit = {}, val onMicLock: () -> Unit = {},
/** Normalized 0–1 mic level while recording; read fresh at draw time. */
val amplitudeProvider: () -> Float = { 0f },
) )
@Composable @Composable
@@ -334,6 +337,23 @@ fun rememberChatMediaLaunchers(
} }
} }
// Mic-level feed for the recording blob (VoiceMicBlob). MediaRecorder's
// maxAmplitude is the PEAK since the last read, so this poller must stay
// the ONLY caller while recording. sqrt compression spreads speech
// dynamics the way Telegram's RMS/1800 normalization does.
val recordingAmplitude = remember { mutableStateOf(0f) }
LaunchedEffect(isRecordingVoice) {
if (isRecordingVoice) {
while (isRecordingVoice) {
recordingAmplitude.value =
sqrt(voiceNoteRecorder.currentAmplitude().coerceAtLeast(0) / 32767f)
delay(AMPLITUDE_POLL_MS)
}
} else {
recordingAmplitude.value = 0f
}
}
val onCameraTap: () -> Unit = { val onCameraTap: () -> Unit = {
try { try {
val timeStamp = val timeStamp =
@@ -380,6 +400,10 @@ fun rememberChatMediaLaunchers(
onMicHoldEnd = onMicHoldEnd, onMicHoldEnd = onMicHoldEnd,
onMicHoldCancel = onMicHoldCancel, onMicHoldCancel = onMicHoldCancel,
onMicLock = onMicLock, onMicLock = onMicLock,
amplitudeProvider = { recordingAmplitude.value },
) )
} }
} }
/** Telegram samples their amplitude pipeline roughly this fast. */
private const val AMPLITUDE_POLL_MS = 60L
@@ -10,15 +10,21 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.minimumInteractiveComponentSize
@@ -26,13 +32,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment 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.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
@@ -62,7 +70,9 @@ import kotlin.math.roundToInt
* and forth, and slides left and fades as the gesture drags toward cancel * and forth, and slides left and fades as the gesture drags toward cancel
* ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to * ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to
* lock, the hint is replaced by a bold tappable "CANCEL" and the action button * lock, the hint is replaced by a bold tappable "CANCEL" and the action button
* submits the note. * submits the note. Behind the hint, a live mic-level meter (bars from the
* original #1250 build, restored) shows the recording level — louder speech
* makes the bars visibly climb; flat bars mean the mic hears silence.
*/ */
@Composable @Composable
internal fun VoiceNoteRecordingPanel( internal fun VoiceNoteRecordingPanel(
@@ -70,6 +80,7 @@ internal fun VoiceNoteRecordingPanel(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
locked: Boolean = false, locked: Boolean = false,
onCancel: () -> Unit = {}, onCancel: () -> Unit = {},
amplitude: () -> Float = { 0f },
) { ) {
val palette = composerPalette() val palette = composerPalette()
val density = LocalDensity.current val density = LocalDensity.current
@@ -82,6 +93,19 @@ internal fun VoiceNoteRecordingPanel(
delay(200) delay(200)
} }
} }
// Level meter: rolling window of the last [VOICE_NOTE_BARS] samples. The
// provider emits at the 60 ms poll cadence with Telegram's smoothing
// already applied; equal values do not re-emit, so silence writes nothing
// (Compose stays idle for instrumented tests).
val levels = remember { mutableStateListOf<Float>() }
LaunchedEffect(Unit) {
snapshotFlow { amplitude() }.collect { level ->
levels.add(level)
while (levels.size > VOICE_NOTE_BARS) {
levels.removeAt(0)
}
}
}
// Telegram blinks the record dot on a 1200 ms cycle. // Telegram blinks the record dot on a 1200 ms cycle.
val blink = rememberInfiniteTransition(label = "voice_dot") val blink = rememberInfiniteTransition(label = "voice_dot")
val dotAlpha by val dotAlpha by
@@ -126,11 +150,13 @@ internal fun VoiceNoteRecordingPanel(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = palette.placeholder, color = palette.placeholder,
) )
Spacer(modifier = Modifier.width(10.dp))
LevelMeter(levels = levels, tint = palette.placeholder)
Spacer(modifier = Modifier.weight(1f))
BoxWithConstraints( BoxWithConstraints(
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f),
.clipToBounds(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val containerWidthPx = with(density) { maxWidth.toPx() } val containerWidthPx = with(density) { maxWidth.toPx() }
@@ -149,15 +175,21 @@ internal fun VoiceNoteRecordingPanel(
}.testTag("voice_note_cancel_button"), }.testTag("voice_note_cancel_button"),
) )
} else { } else {
// requiredWidth(intrinsic): the hint lays out at its natural
// width even when the meter leaves it tight, and the removed
// border clip lets the nudge and the cancel drag overflow the
// box instead of being cut at its edges — the fade carries it.
Row( Row(
modifier = modifier =
Modifier Modifier
.requiredWidth(IntrinsicSize.Max)
.offset { .offset {
val shift = -containerWidthPx * 0.25f * (1f - progress) val shift = -containerWidthPx * 0.25f * (1f - progress)
val nudge = nudgeDp * density.density * progress val nudge = nudgeDp * density.density * progress
IntOffset((shift + nudge).roundToInt(), 0) IntOffset((shift + nudge).roundToInt(), 0)
}.alpha(progress), }.alpha(progress),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) { ) {
CancelChevron(tint = palette.placeholder) CancelChevron(tint = palette.placeholder)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
@@ -172,6 +204,39 @@ internal fun VoiceNoteRecordingPanel(
} }
} }
/**
* The live mic-level meter from the original #1250 build: a row of small
* vertical bars, oldest left, newest right; each bar's height tracks its
* sample (4–22 dp). Silences stay flat, loud speech climbs — the immediate
* level feedback the blob could not give.
*/
@Composable
private fun LevelMeter(
levels: SnapshotStateList<Float>,
tint: Color,
) {
Row(
modifier = Modifier.height(22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
val filler = VOICE_NOTE_BARS - levels.size
repeat(VOICE_NOTE_BARS) { index ->
val level = if (index < filler) 0f else levels[index - filler]
Box(
modifier =
Modifier
.width(3.dp)
.height((4f + 18f * level).dp)
.background(tint, RoundedCornerShape(2.dp)),
)
}
}
}
/** Number of level-meter bars in the recording panel. */
private const val VOICE_NOTE_BARS = 14
/** "Slide to cancel" with the cancel word carrying the visual emphasis. */ /** "Slide to cancel" with the cancel word carrying the visual emphasis. */
@Composable @Composable
private fun slideToCancelText(palette: ComposerPalette) = private fun slideToCancelText(palette: ComposerPalette) =