From d28946627674f2b012c03dc9f178f14601c08bd7 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Tue, 23 Jun 2026 01:15:51 +0200 Subject: [PATCH] feat: live voice waveform replaces static recording indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 32-bar waveform visualization that reacts to microphone amplitude in real-time. MediaRecorder.getMaxAmplitude() polled every 60ms, normalized to 0-1 range, fed into a scrolling bar chart (new bars enter right, old shift left). Bars use accent color — louder = taller + more opaque, quiet = short + muted. Replaces the static "Aufnahme läuft…" text. --- .../dev/kaizen/app/chat/ChatComponents.kt | 71 ++++++++++++------- .../java/dev/kaizen/app/chat/ChatScreen.kt | 11 +++ 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index 565f0ad..644e51a 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -553,31 +554,50 @@ fun MessageRow( } } -@Composable -private fun RecordingIndicator() { - val cs = MaterialTheme.colorScheme - val transition = rememberInfiniteTransition(label = "rec") - val dotAlpha by transition.animateFloat( - initialValue = 1f, targetValue = 0.3f, - animationSpec = infiniteRepeatable(tween(800, easing = FastOutSlowInEasing), RepeatMode.Reverse), - label = "dot", - ) +private const val WAVEFORM_BAR_COUNT = 32 - Row( - Modifier.fillMaxWidth().heightIn(min = 48.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Canvas(Modifier.size(10.dp)) { - drawCircle(Color(0xFFEF4444).copy(alpha = dotAlpha)) +@Composable +private fun VoiceWaveform(amplitude: Float) { + val accent = LocalKaizenAccent.current + val isDark = isSystemInDarkTheme() + + val barHeights = remember { Array(WAVEFORM_BAR_COUNT) { mutableStateOf(0.08f) } } + + LaunchedEffect(amplitude) { + // Shift bars left, add new amplitude on the right + for (i in 0 until WAVEFORM_BAR_COUNT - 1) { + barHeights[i].value = barHeights[i + 1].value + } + barHeights[WAVEFORM_BAR_COUNT - 1].value = (amplitude * 0.85f + 0.08f).coerceIn(0.08f, 0.95f) + } + + val barColor = accent.primary.copy(alpha = if (isDark) 0.75f else 0.65f) + val mutedColor = accent.primary.copy(alpha = if (isDark) 0.25f else 0.18f) + + Canvas( + Modifier + .fillMaxWidth() + .height(48.dp) + ) { + val totalWidth = size.width + val barWidth = totalWidth / (WAVEFORM_BAR_COUNT * 2f) + val gap = barWidth + val centerY = size.height / 2f + val maxBarHeight = size.height * 0.8f + + for (i in 0 until WAVEFORM_BAR_COUNT) { + val h = barHeights[i].value + val barH = maxBarHeight * h + val x = i * (barWidth + gap) + gap / 2f + val color = if (h > 0.12f) barColor else mutedColor + + drawRoundRect( + color = color, + topLeft = Offset(x, centerY - barH / 2f), + size = androidx.compose.ui.geometry.Size(barWidth, barH), + cornerRadius = CornerRadius(barWidth / 2f, barWidth / 2f), + ) } - Spacer(Modifier.width(10.dp)) - Text( - stringResource(R.string.chat_recording), - color = cs.onSurfaceVariant.copy(alpha = 0.70f), - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - ) } } @@ -632,6 +652,7 @@ fun ChatInput( isStreaming: Boolean = false, onStop: () -> Unit = {}, isRecording: Boolean = false, + recordingAmplitude: Float = 0f, onMicClick: () -> Unit = {}, pendingFiles: List = emptyList(), onAddClick: () -> Unit = {}, @@ -658,8 +679,8 @@ fun ChatInput( ) { Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) { if (isRecording) { - // Recording state — replaces the text field with a recording indicator - RecordingIndicator() + // Recording state — live waveform replaces the text field + VoiceWaveform(amplitude = recordingAmplitude) Spacer(Modifier.height(10.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.weight(1f)) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index b30d1cb..f01286a 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -122,6 +122,16 @@ fun ChatScreen( var isRecording by remember { mutableStateOf(false) } val recorderRef = remember { mutableStateOf(null) } val audioFileRef = remember { mutableStateOf(null) } + var recordingAmplitude by remember { mutableStateOf(0f) } + + LaunchedEffect(isRecording) { + if (!isRecording) { recordingAmplitude = 0f; return@LaunchedEffect } + while (isRecording) { + val amp = try { recorderRef.value?.maxAmplitude ?: 0 } catch (_: Exception) { 0 } + recordingAmplitude = (amp / 32768f).coerceIn(0f, 1f) + kotlinx.coroutines.delay(60) + } + } // TTS playback state var playingTtsForId by remember { mutableStateOf(null) } @@ -1055,6 +1065,7 @@ fun ChatScreen( isStreaming = isStreaming, onStop = { streamJob?.cancel() }, isRecording = isRecording, + recordingAmplitude = recordingAmplitude, onMicClick = { toggleRecording() }, pendingFiles = pendingFiles, onAddClick = { showAttachMenu = !showAttachMenu },