voice: base64 compact waveform (100 bytes), progress bar, play/pause auto-reset

- voice_recorder: 100-char base64 (A-Za-z0-9+/) log-scale waveform, ~120 bytes total vs ~600 CSV
- ChatRepository: parse base64 'wf=<100 chars>;dur=NNNN;' → normalized 0..1 List<Float>
  CSV fallback for old messages (length < 50 or contains comma)
- VoiceBubble: progress bar (blue played / gray unplayed), ▶/⏸ toggle, elapsed timer
- onComplete callback wired through ViewModel → AudioRecorderManager → VoiceBubble auto-reset
This commit is contained in:
evgeny committed 2026-07-29 01:36:43 +03:00
1 parent 3c75c8f687
commit b65a091b00
5 files changed
+73 -34

No files matched your search

@@ -53,15 +53,35 @@ class ChatRepository {
val contentType = obj.optString("contentType", "text/plain")
val filePath = obj.optString("filePath", "")
/* Parse waveform from voice message text (format: "wf=0.1,0.2,...;dur=1234;") */
/* Parse base64 waveform from voice message text (format: "wf=<100 chars A-Za-z0-9+/>;dur=1234;") */
var waveform = emptyList<Float>()
var voiceDurationMs = 0
if (contentType == "audio/opus" && text.startsWith("wf=")) {
try {
val wfEnd = text.indexOf(";dur=")
if (wfEnd > 0) {
val wfPart = text.substring(3, wfEnd)
waveform = wfPart.split(",").map { it.toFloat() }
val wfRaw = text.substring(3, wfEnd) // 100 base64 chars
waveform = if (wfRaw.length >= 50) {
wfRaw.map { c ->
val idx = when (c) {
in 'A'..'Z' -> c - 'A'
in 'a'..'z' -> c - 'a' + 26
in '0'..'9' -> c - '0' + 52
'+' -> 62; '/' -> 63; else -> 0
}
(idx.toFloat() / 63f).coerceIn(0f, 1f)
}
} else {
/* old CSV format fallback */
val parts = wfRaw.split(",")
if (parts.size >= 2) {
parts.map { s ->
val v = s.toFloatOrNull() ?: 0f
val db = if (v > 0.00001f) 20 * kotlin.math.log10(v.toDouble()).toFloat() else -100f
((db + 60f) / 60f).coerceIn(0f, 1f)
}
} else emptyList()
}
val durPart = text.substring(wfEnd + 5).takeWhile { it != ';' }
voiceDurationMs = durPart.toIntOrNull() ?: 0
}
@@ -2,6 +2,7 @@ package com.utun.chat.ui.components
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
@@ -25,14 +26,12 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.log10
import androidx.core.content.ContextCompat
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import kotlinx.coroutines.delay
@Composable
fun MessageBubble(message: Message, onPlayVoice: (String) -> Unit = {}) {
fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit) -> Unit = { _, _ -> }) {
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
@@ -78,15 +77,29 @@ fun MessageBubble(message: Message, onPlayVoice: (String) -> Unit = {}) {
}
}
}
@Composable
private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) -> Unit) {
val wf = message.waveform
private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String, () -> Unit) -> Unit) {
val wf = message.waveform // List<Float> 0..1, 100 elements
val wfCount = wf.size
val durMs = message.voiceDurationMs
val durSec = durMs / 1000f
var playing by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0) }
var progress by remember { mutableStateOf(0f) }
/* auto-reset on complete */
val onDone = remember { { playing = false; elapsedMs = 0; progress = 0f } }
/* elapsed timer while playing */
LaunchedEffect(playing) {
if (playing) {
val start = System.currentTimeMillis()
while (playing) {
elapsedMs = (System.currentTimeMillis() - start).toInt()
if (durMs > 0) progress = (elapsedMs.toFloat() / durMs.toFloat()).coerceIn(0f, 1f)
delay(80)
}
}
}
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -95,9 +108,10 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) ->
modifier = Modifier.size(32.dp).background(Color(0xFF3390EC), CircleShape)
.clickable {
if (!playing && message.filePath.isNotEmpty()) {
playing = true; elapsedMs = 0; onPlay(message.filePath)
playing = true; elapsedMs = 0; progress = 0f
onPlay(message.filePath, onDone)
} else {
playing = false
playing = false; onDone()
}
},
contentAlignment = Alignment.Center
@@ -111,15 +125,19 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) ->
Canvas(modifier = Modifier.weight(1f).height(28.dp)) {
val barW = size.width / wfCount
val ph = size.height
val playedIdx = if (progress > 0f) (progress * wfCount).toInt() else 0
for (i in 0 until wfCount) {
val v = wf[i]
val db = if (v > 0.00001f) 20f * kotlin.math.log10(v.toDouble()).toFloat() else -100f
val logV = ((db + 60f) / 60f).coerceIn(0f, 1f)
val barH = (2f + logV * (ph - 2f)).coerceAtMost(ph)
val level = wf[i].coerceIn(0f, 1f)
val barH = (2f + level * (ph - 2f)).coerceAtMost(ph)
val x = i.toFloat() * barW
val y = ph - barH
val played = i < playedIdx
drawRect(
color = Color(0xFF3390EC).copy(alpha = 0.35f),
color = if (playing) {
if (played) Color(0xFF3390EC) else Color(0xFF3390EC).copy(alpha = 0.25f)
} else {
Color(0xFF888899).copy(alpha = 0.45f)
},
topLeft = Offset(x, y),
size = Size(barW * 0.75f, barH)
)
@@ -130,14 +148,15 @@ private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) ->
Spacer(Modifier.height(4.dp))
/* Duration + timestamp */
/* Duration / elapsed + timestamp */
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(
formatDurationMs(durMs),
if (playing) "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}"
else formatDurationMs(durMs),
fontSize = 12.sp,
color = textColor.copy(alpha = 0.55f)
)
@@ -63,8 +63,8 @@ fun ChatScreen(
state = listState
) {
items(messages.size) { idx ->
MessageBubble(messages[idx], onPlayVoice = { path ->
viewModel.playVoiceMessage(path)
MessageBubble(messages[idx], onPlayVoice = { path, onDone ->
viewModel.playVoiceMessage(path, onDone)
})
}
item { Spacer(Modifier.height(8.dp)) }
@@ -264,9 +264,9 @@ class ChatViewModel : ViewModel() {
NativeLib.voiceSetCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb)
}
fun playVoiceMessage(filePath: String) {
fun playVoiceMessage(filePath: String, onComplete: () -> Unit) {
LogManager.addLog("INFO", "VM", "play voice: $filePath")
audioRecorder.playVoiceFile(filePath)
audioRecorder.playVoiceFile(filePath, onComplete = onComplete)
}
fun clearState() {