voice: waveform recording + display, playback wiring

- voice_recorder: waveform_levels[100] accumulated during feed, normalized to 100 values in stop()
  stored as 'wf=...;dur=...;' in chat_msg_submit data
- bridge: filePath in get_messages_json from local_attrs.fp, g_db_path moved global
- Message model: waveform: List<Float>, voiceDurationMs: Int, filePath: String
- VoiceBubble: Canvas waveform (100 bars, log scale), play button, duration
- ChatViewModel.playVoiceMessage → AudioRecorderManager.playVoiceFile
This commit is contained in:
evgeny committed 2026-07-29 01:15:57 +03:00
1 parent 39d6f19d97
commit 94a537ebdd
6 files changed
+167 -30

No files matched your search

@@ -7,7 +7,9 @@ import org.json.JSONObject
data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "",
val peersOnline: Int = 0)
data class Message(val id: Long, val author: String, val text: String, val ts: Long,
val isOutgoing: Boolean = false, val contentType: String = "text/plain")
val isOutgoing: Boolean = false, val contentType: String = "text/plain",
val filePath: String = "", val waveform: List<Float> = emptyList(),
val voiceDurationMs: Int = 0)
class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId()
@@ -47,13 +49,35 @@ class ChatRepository {
val arr = JSONArray(NativeLib.getMessages(channelId, limit))
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
val text = obj.optString("text", "")
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;") */
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 durPart = text.substring(wfEnd + 5).takeWhile { it != ';' }
voiceDurationMs = durPart.toIntOrNull() ?: 0
}
} catch (_: Exception) {}
}
list.add(Message(
id = obj.getLong("id"),
author = obj.getString("author"),
text = obj.getString("text"),
text = text,
ts = obj.getLong("ts"),
isOutgoing = obj.optBoolean("isOutgoing", false),
contentType = obj.optString("contentType", "text/plain")
contentType = contentType,
filePath = filePath,
waveform = waveform,
voiceDurationMs = voiceDurationMs
))
}
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }
@@ -4,6 +4,7 @@ import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -14,6 +15,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
@@ -22,13 +25,14 @@ 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) {
fun MessageBubble(message: Message, onPlayVoice: (String) -> 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
@@ -48,21 +52,15 @@ fun MessageBubble(message: Message) {
.background(bgColor)
.padding(12.dp)
) {
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
if (!isVoice) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
Spacer(Modifier.height(2.dp))
when {
isVoice -> {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("\u25B6", fontSize = 20.sp, color = textColor)
Spacer(Modifier.width(8.dp))
Text("Voice message", fontSize = 15.sp, color = textColor)
}
}
isMedia -> {
Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor)
}
isVoice -> VoiceBubble(message, textColor, onPlayVoice)
isMedia -> Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor)
else -> {
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
Spacer(Modifier.height(2.dp))
Text(message.text, fontSize = 17.sp, color = textColor)
}
}
@@ -78,6 +76,62 @@ fun MessageBubble(message: Message) {
}
}
@Composable
private fun VoiceBubble(message: Message, textColor: Color, onPlay: (String) -> Unit) {
val durationSec = message.voiceDurationMs / 1000f
val wf = message.waveform
val wfCount = wf.size
Row(verticalAlignment = Alignment.CenterVertically) {
/* Play button */
Box(
modifier = Modifier.size(32.dp).background(Color(0xFF3390EC), CircleShape)
.clickable { if (message.filePath.isNotEmpty()) onPlay(message.filePath) },
contentAlignment = Alignment.Center
) {
Text("\u25B6", fontSize = 14.sp, color = Color.White)
}
Spacer(Modifier.width(8.dp))
/* Waveform */
if (wfCount >= 2) {
Box(modifier = Modifier.weight(1f).height(32.dp)) {
Canvas(modifier = Modifier.fillMaxSize()) {
val barW = size.width / wfCount
val ph = size.height.toFloat()
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 x = i.toFloat() * barW
val y = ph - barH
drawRect(
color = Color(0xFF3390EC).copy(alpha = 0.5f),
topLeft = Offset(x, y),
size = Size(barW * 0.8f, barH)
)
}
}
}
Spacer(Modifier.width(4.dp))
}
/* Duration */
Text(
formatDurationMs(message.voiceDurationMs),
fontSize = 13.sp,
color = textColor.copy(alpha = 0.7f),
modifier = Modifier.padding(start = 4.dp)
)
}
}
private fun formatDurationMs(ms: Int): String {
val secs = ms / 1000
return "%d:%02d".format(secs / 60, secs % 60)
}
private val LOCK_SWIPE_THRESHOLD_DP = 80f
@Composable
@@ -63,7 +63,9 @@ fun ChatScreen(
state = listState
) {
items(messages.size) { idx ->
MessageBubble(messages[idx])
MessageBubble(messages[idx], onPlayVoice = { path ->
viewModel.playVoiceMessage(path)
})
}
item { Spacer(Modifier.height(8.dp)) }
}
@@ -264,6 +264,11 @@ class ChatViewModel : ViewModel() {
NativeLib.voiceSetCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb)
}
fun playVoiceMessage(filePath: String) {
LogManager.addLog("INFO", "VM", "play voice: $filePath")
audioRecorder.playVoiceFile(filePath)
}
fun clearState() {
repo?.close()
repo = null