diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 15351201..be269dc5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -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() 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 } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index 9d1cd257..c652c7b9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -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 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) ) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 6ba73670..239517d0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -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)) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index f774461c..f0d6dca0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -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() { diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.c b/tools/chatgui-android/libutun_lite/voice_recorder.c index 0bdebc6b..f1b71dc9 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.c +++ b/tools/chatgui-android/libutun_lite/voice_recorder.c @@ -396,26 +396,26 @@ int voice_recorder_stop(int* out_duration_ms) { fclose(src); unlink(temp_path); - /* build and post chat_msg_submit with waveform data */ - char wf_buf[1024]; - int wf_off = 0; + /* build compact waveform: 100 bytes base64 (A-Za-z0-9+/), log scale */ + static const char b64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + char wf_data[256]; + snprintf(wf_data, sizeof(wf_data), "wf="); + int wf_off = 3; { int wfN = g_rec->waveform_count > 0 ? g_rec->waveform_count : 1; - int step100 = wfN > 100 ? wfN : 100; for (int i = 0; i < 100; i++) { int s = i * wfN / 100, e = (i + 1) * wfN / 100; if (s >= wfN) s = wfN - 1; if (e <= s) e = s + 1; if (e > wfN) e = wfN; float sum = 0; int cnt = 0; for (int j = s; j < e; j++) { sum += g_rec->waveform_levels[j]; cnt++; } float v = cnt > 0 ? sum / cnt : 0; - if (i > 0) wf_off += snprintf(wf_buf + wf_off, sizeof(wf_buf) - wf_off, ","); - wf_off += snprintf(wf_buf + wf_off, sizeof(wf_buf) - wf_off, "%.3f", v); + float db = v > 0.00001f ? 20.0f * log10f(v) : -100.0f; + int idx = (int)((db + 60.0f) / 60.0f * 64.0f); + if (idx < 0) idx = 0; if (idx > 63) idx = 63; + wf_data[wf_off++] = b64_table[idx]; } } - - char wf_data[1280]; - int wf_data_len = snprintf(wf_data, sizeof(wf_data), "wf=%s;dur=%lld;", wf_buf, (long long)elapsed_ms); - - size_t total_data_len = (size_t)wf_data_len; + wf_off += snprintf(wf_data + wf_off, sizeof(wf_data) - wf_off, ";dur=%lld;", (long long)elapsed_ms); + size_t total_data_len = (size_t)(wf_off); struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + total_data_len + 1); if (!req) { voice_cleanup_locked(g_rec); pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; }