From 9529a7e32c8a03cf3328a6a42a2b14eb5ecd6169 Mon Sep 17 00:00:00 2001 From: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:14:27 -0400 Subject: [PATCH] feat(#1247): live level meter on the voice recording strip (#1416) --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 1 + .../ui/chat/components/ChatComposer.kt | 55 +++++++++----- .../ui/chat/components/ChatMediaLaunchers.kt | 24 ++++++ .../components/VoiceNoteRecordingPanel.kt | 73 ++++++++++++++++++- 4 files changed, 129 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index dcd8fbcf..b0b53a94 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index b73833f3..16a91ddf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -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)), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt index 1d8aa22f..fb013b82 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt index a214b443..a9ce4ecb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt @@ -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() } + 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, + 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) =