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:
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) }
|
||||
|
||||
+66
-12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user