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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -553,32 +554,51 @@ fun MessageRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private const val WAVEFORM_BAR_COUNT = 32
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
@Composable
|
||||||
Modifier.fillMaxWidth().heightIn(min = 48.dp),
|
private fun VoiceWaveform(amplitude: Float) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
val accent = LocalKaizenAccent.current
|
||||||
horizontalArrangement = Arrangement.Center,
|
val isDark = isSystemInDarkTheme()
|
||||||
) {
|
|
||||||
Canvas(Modifier.size(10.dp)) {
|
val barHeights = remember { Array(WAVEFORM_BAR_COUNT) { mutableStateOf(0.08f) } }
|
||||||
drawCircle(Color(0xFFEF4444).copy(alpha = dotAlpha))
|
|
||||||
|
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))
|
barHeights[WAVEFORM_BAR_COUNT - 1].value = (amplitude * 0.85f + 0.08f).coerceIn(0.08f, 0.95f)
|
||||||
Text(
|
}
|
||||||
stringResource(R.string.chat_recording),
|
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.70f),
|
val barColor = accent.primary.copy(alpha = if (isDark) 0.75f else 0.65f)
|
||||||
fontSize = 15.sp,
|
val mutedColor = accent.primary.copy(alpha = if (isDark) 0.25f else 0.18f)
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
|
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
|
@Composable
|
||||||
|
|
@ -632,6 +652,7 @@ fun ChatInput(
|
||||||
isStreaming: Boolean = false,
|
isStreaming: Boolean = false,
|
||||||
onStop: () -> Unit = {},
|
onStop: () -> Unit = {},
|
||||||
isRecording: Boolean = false,
|
isRecording: Boolean = false,
|
||||||
|
recordingAmplitude: Float = 0f,
|
||||||
onMicClick: () -> Unit = {},
|
onMicClick: () -> Unit = {},
|
||||||
pendingFiles: List<PendingFile> = emptyList(),
|
pendingFiles: List<PendingFile> = emptyList(),
|
||||||
onAddClick: () -> Unit = {},
|
onAddClick: () -> Unit = {},
|
||||||
|
|
@ -658,8 +679,8 @@ fun ChatInput(
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) {
|
Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
// Recording state — replaces the text field with a recording indicator
|
// Recording state — live waveform replaces the text field
|
||||||
RecordingIndicator()
|
VoiceWaveform(amplitude = recordingAmplitude)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,16 @@ fun ChatScreen(
|
||||||
var isRecording by remember { mutableStateOf(false) }
|
var isRecording by remember { mutableStateOf(false) }
|
||||||
val recorderRef = remember { mutableStateOf<MediaRecorder?>(null) }
|
val recorderRef = remember { mutableStateOf<MediaRecorder?>(null) }
|
||||||
val audioFileRef = remember { mutableStateOf<File?>(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
|
// TTS playback state
|
||||||
var playingTtsForId by remember { mutableStateOf<Long?>(null) }
|
var playingTtsForId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
@ -1055,6 +1065,7 @@ fun ChatScreen(
|
||||||
isStreaming = isStreaming,
|
isStreaming = isStreaming,
|
||||||
onStop = { streamJob?.cancel() },
|
onStop = { streamJob?.cancel() },
|
||||||
isRecording = isRecording,
|
isRecording = isRecording,
|
||||||
|
recordingAmplitude = recordingAmplitude,
|
||||||
onMicClick = { toggleRecording() },
|
onMicClick = { toggleRecording() },
|
||||||
pendingFiles = pendingFiles,
|
pendingFiles = pendingFiles,
|
||||||
onAddClick = { showAttachMenu = !showAttachMenu },
|
onAddClick = { showAttachMenu = !showAttachMenu },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue