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() {
|
||||
|
||||
Reference in new issue
Block a user