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:
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
|
||||
}
|
||||
|
||||
+35
-16
@@ -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() {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user