feat: live voice waveform replaces static recording indicator

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.
This commit is contained in:
Bruno Deanoz 2026-06-23 01:15:51 +02:00
parent 20a71adb99
commit d289466276
2 changed files with 57 additions and 25 deletions

View file

@ -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,33 +554,52 @@ 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
}
Spacer(Modifier.width(10.dp))
Text(
stringResource(R.string.chat_recording),
color = cs.onSurfaceVariant.copy(alpha = 0.70f),
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
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),
)
}
}
}
@Composable
private fun MessageActionButton(icon: ImageVector, tint: Color, onClick: () -> Unit) {
@ -632,6 +652,7 @@ fun ChatInput(
isStreaming: Boolean = false,
onStop: () -> Unit = {},
isRecording: Boolean = false,
recordingAmplitude: Float = 0f,
onMicClick: () -> Unit = {},
pendingFiles: List<PendingFile> = 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))

View file

@ -122,6 +122,16 @@ fun ChatScreen(
var isRecording by remember { mutableStateOf(false) }
val recorderRef = remember { mutableStateOf<MediaRecorder?>(null) }
val audioFileRef = remember { mutableStateOf<File?>(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<Long?>(null) }
@ -1055,6 +1065,7 @@ fun ChatScreen(
isStreaming = isStreaming,
onStop = { streamJob?.cancel() },
isRecording = isRecording,
recordingAmplitude = recordingAmplitude,
onMicClick = { toggleRecording() },
pendingFiles = pendingFiles,
onAddClick = { showAttachMenu = !showAttachMenu },