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:
parent
20a71adb99
commit
d289466276
2 changed files with 57 additions and 25 deletions
|
|
@ -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,32 +554,51 @@ 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
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue