Browse Source

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
topo_upd
evgeny 2 months ago
parent
commit
b65a091b00
  1. 26
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  2. 51
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  3. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  4. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  5. 22
      tools/chatgui-android/libutun_lite/voice_recorder.c

26
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<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
}

51
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<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)
)

4
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)) }

4
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() {

22
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; }

Loading…
Cancel
Save