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
+129 -24

No files matched your search

@@ -869,6 +869,7 @@ fun ChatScreen(
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
isRecordingVoice = mediaLaunchers.isRecordingVoice,
isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
amplitudeProvider = mediaLaunchers.amplitudeProvider,
onStopGeneration = { viewModel.interruptSession() },
isAgentTyping = state.isAgentTyping,
canInterrupt = state.canInterrupt,
@@ -125,6 +125,7 @@ fun ChatInputBar(
onMicLock: () -> Unit = {},
isVoiceNoteLocked: Boolean = false,
isRecordingVoice: Boolean = false,
amplitudeProvider: () -> Float = { 0f },
onStopGeneration: () -> Unit = {},
) {
// Allow sending while the agent is mid-turn or awaiting approval: the
@@ -440,6 +441,7 @@ fun ChatInputBar(
slideProgress = voiceSlideProgress,
locked = isVoiceNoteLocked,
onCancel = onMicHoldCancel,
amplitude = amplitudeProvider,
modifier = Modifier.matchParentSize(),
)
}
@@ -490,20 +492,21 @@ fun ChatInputBar(
}
}
}
// Zero-height anchor so the tooltip and lock icon float above the
// card, over the message list, like Telegram's lock control. It sits
// outside the animated card because AnimatedVisibility clips its
// children to the animation bounds.
// Anchor at the BOTTOM-end — directly above the mic button the thumb
// is holding. The earlier top-of-card anchor sat ~130 dp away from the
// thumb and read as unrelated decoration, so the lock affordance went
// 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)) {
VoiceNoteLockHintOverlay(
visible = isRecordingVoice && !isVoiceNoteLocked,
showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f,
modifier =
Modifier
.wrapContentHeight(align = Alignment.Top, unbounded = true)
.align(Alignment.TopEnd)
.offset(y = (-42).dp)
.padding(end = 12.dp),
.wrapContentHeight(align = Alignment.Bottom, unbounded = true)
.align(Alignment.BottomEnd)
.offset(y = (-54).dp)
.padding(end = 6.dp),
)
}
}
@@ -543,18 +546,30 @@ private fun VoiceNoteLockHintOverlay(
)
Spacer(modifier = Modifier.width(6.dp))
}
Box(
modifier =
Modifier
.size(36.dp)
.background(palette.control, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = palette.placeholder,
modifier = Modifier.size(18.dp),
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier =
Modifier
.size(36.dp)
.background(palette.control, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = palette.placeholder,
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.util.Date
import java.util.Locale
import kotlin.math.sqrt
class ChatMediaLaunchers(
val isListening: Boolean,
@@ -48,6 +49,8 @@ class ChatMediaLaunchers(
val onMicHoldEnd: () -> Unit = {},
val onMicHoldCancel: () -> Unit = {},
val onMicLock: () -> Unit = {},
/** Normalized 0–1 mic level while recording; read fresh at draw time. */
val amplitudeProvider: () -> Float = { 0f },
)
@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 = {
try {
val timeStamp =
@@ -380,6 +400,10 @@ fun rememberChatMediaLaunchers(
onMicHoldEnd = onMicHoldEnd,
onMicHoldCancel = onMicHoldCancel,
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
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.offset
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
@@ -26,13 +32,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
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
* ([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
* 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
internal fun VoiceNoteRecordingPanel(
@@ -70,6 +80,7 @@ internal fun VoiceNoteRecordingPanel(
modifier: Modifier = Modifier,
locked: Boolean = false,
onCancel: () -> Unit = {},
amplitude: () -> Float = { 0f },
) {
val palette = composerPalette()
val density = LocalDensity.current
@@ -82,6 +93,19 @@ internal fun VoiceNoteRecordingPanel(
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.
val blink = rememberInfiniteTransition(label = "voice_dot")
val dotAlpha by
@@ -126,11 +150,13 @@ internal fun VoiceNoteRecordingPanel(
fontWeight = FontWeight.Bold,
color = palette.placeholder,
)
Spacer(modifier = Modifier.width(10.dp))
LevelMeter(levels = levels, tint = palette.placeholder)
Spacer(modifier = Modifier.weight(1f))
BoxWithConstraints(
modifier =
Modifier
.weight(1f)
.clipToBounds(),
.weight(1f),
contentAlignment = Alignment.Center,
) {
val containerWidthPx = with(density) { maxWidth.toPx() }
@@ -149,15 +175,21 @@ internal fun VoiceNoteRecordingPanel(
}.testTag("voice_note_cancel_button"),
)
} 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(
modifier =
Modifier
.requiredWidth(IntrinsicSize.Max)
.offset {
val shift = -containerWidthPx * 0.25f * (1f - progress)
val nudge = nudgeDp * density.density * progress
IntOffset((shift + nudge).roundToInt(), 0)
}.alpha(progress),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
CancelChevron(tint = palette.placeholder)
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. */
@Composable
private fun slideToCancelText(palette: ComposerPalette) =