diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml
index c8c87e7c..c3dd3b73 100644
--- a/tools/chatgui-android/app/src/main/AndroidManifest.xml
+++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml
@@ -3,8 +3,11 @@
+
+
+
Unit = {}, onComplete: () -> Unit = {}) {
+ if (playing) stopPlayback()
+ playing = true
+
+ playThread = Thread {
+ try {
+ val info = IntArray(3)
+ val handle = NativeLib.voiceDecodeOpen(filePath, info)
+ if (handle == 0L) {
+ LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath")
+ playing = false; return@Thread
+ }
+ val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2]
+
+ val bufSize = AudioTrack.getMinBufferSize(
+ sampleRate,
+ if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
+ AudioFormat.ENCODING_PCM_16BIT
+ )
+
+ val track = AudioTrack(
+ AudioManager.STREAM_MUSIC,
+ sampleRate,
+ if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
+ AudioFormat.ENCODING_PCM_16BIT,
+ bufSize,
+ AudioTrack.MODE_STREAM
+ )
+ track.play()
+
+ val pcm = ShortArray(FRAME_SAMPLES * channels)
+ var readTotal = 0L
+ var read: Int
+ while (playing) {
+ read = NativeLib.voiceDecodeRead(handle, pcm)
+ if (read <= 0) break
+ track.write(pcm, 0, read)
+ readTotal += read
+ if (totalSamples > 0) onProgress(readTotal.toFloat() / totalSamples.toFloat())
+ }
+ track.stop()
+ track.release()
+ NativeLib.voiceDecodeClose(handle)
+ } catch (e: Exception) {
+ LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}")
+ }
+ playing = false
+ onComplete()
+ }.apply { start() }
+ }
+
+ fun stopPlayback() {
+ playing = false
+ playThread?.join(500)
+ playThread = null
+ }
+}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
index 052a7a91..c9130a8c 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
@@ -6,7 +6,8 @@ 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)
+data class Message(val id: Long, val author: String, val text: String, val ts: Long,
+ val isOutgoing: Boolean = false, val contentType: String = "text/plain")
class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId()
@@ -51,7 +52,8 @@ class ChatRepository {
author = obj.getString("author"),
text = obj.getString("text"),
ts = obj.getLong("ts"),
- isOutgoing = obj.optBoolean("isOutgoing", false)
+ isOutgoing = obj.optBoolean("isOutgoing", false),
+ contentType = obj.optString("contentType", "text/plain")
))
}
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }
@@ -59,6 +61,7 @@ class ChatRepository {
}
fun sendMessage(channelId: String, text: String) = NativeLib.sendMessage(channelId, text)
+ fun sendAttachment(channelId: String, filePath: String) = NativeLib.attachmentSend(channelId, filePath)
fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId)
fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex)
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) =
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
index 95c925fc..8cb88e5f 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
@@ -77,6 +77,25 @@ object NativeLib {
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
fun regenerateKeys() { nativeRegenerateKeys() }
+ /* ── Voice recording ── */
+ fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId)
+ fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
+ fun voiceStop(): Int = nativeVoiceStop()
+ fun voiceCancel() { nativeVoiceCancel() }
+ fun voiceIsActive(): Boolean = nativeVoiceIsActive()
+ fun voiceSetPreset(preset: Int) { nativeVoiceSetPreset(preset) }
+ fun voiceGetPreset(): Int = nativeVoiceGetPreset()
+ fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) }
+ fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor()
+
+ /* ── Attachment ── */
+ fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath)
+
+ /* ── Voice playback ── */
+ fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo)
+ fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf)
+ fun voiceDecodeClose(handle: Long) { nativeVoiceDecodeClose(handle) }
+
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
private external fun nativeStop()
@@ -98,4 +117,23 @@ object NativeLib {
private external fun nativeRestart(configText: String): Boolean
private external fun nativePing()
private external fun nativeIsResponsive(): Boolean
+
+ /* ── Voice recording JNI ── */
+ private external fun nativeVoiceStart(channelId: String): Boolean
+ private external fun nativeVoiceFeed(samples: ShortArray): Boolean
+ private external fun nativeVoiceStop(): Int
+ private external fun nativeVoiceCancel()
+ private external fun nativeVoiceIsActive(): Boolean
+ private external fun nativeVoiceSetPreset(preset: Int)
+ private external fun nativeVoiceGetPreset(): Int
+ private external fun nativeVoiceSetCompressor(enabled: Boolean)
+ private external fun nativeVoiceGetCompressor(): Boolean
+
+ /* ── Attachment JNI ── */
+ private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean
+
+ /* ── Voice playback JNI ── */
+ private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
+ private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int
+ private external fun nativeVoiceDecodeClose(handle: Long)
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
index 0eea27c2..7d3587c9 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
@@ -1,19 +1,28 @@
package com.utun.chat.ui.components
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
-import androidx.compose.runtime.Composable
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
import com.utun.chat.data.Message
+import kotlinx.coroutines.delay
@Composable
fun MessageBubble(message: Message) {
@@ -22,6 +31,9 @@ fun MessageBubble(message: Message) {
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
val minBubbleWidth = (LocalConfiguration.current.screenWidthDp / 3).dp
+ val isVoice = message.contentType == "audio/opus"
+ val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain"
+
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = alignment
@@ -35,7 +47,23 @@ fun MessageBubble(message: Message) {
) {
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)
+
+ 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)
+ }
+ else -> {
+ Text(message.text, fontSize = 17.sp, color = textColor)
+ }
+ }
+
Spacer(Modifier.height(2.dp))
Text(
formatTime(message.ts),
@@ -53,27 +81,107 @@ fun InputBar(
onTextChange: (String) -> Unit,
onSend: () -> Unit,
enabled: Boolean = true,
+ isRecording: Boolean = false,
+ recordingDurationMs: Int = 0,
+ onPttPressed: () -> Unit = {},
+ onPttReleased: () -> Unit = {},
+ onAttachClicked: () -> Unit = {},
modifier: Modifier = Modifier
) {
+ val ctx = LocalContext.current
+
+ var hasMicPerm by remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ )
+ }
+ val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ hasMicPerm = granted
+ }
+
+ val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ uri?.let { onAttachClicked() }
+ }
+
Row(
modifier = modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
- OutlinedTextField(
- value = text,
- onValueChange = onTextChange,
- enabled = enabled,
- modifier = Modifier.weight(1f),
- placeholder = { Text("Message...") },
- maxLines = 4
- )
- Spacer(Modifier.width(8.dp))
+ /* Attach button */
+ IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) {
+ Text("\uD83D\uDCCE", fontSize = 20.sp)
+ }
+
+ if (isRecording) {
+ /* Recording overlay */
+ Surface(
+ modifier = Modifier.weight(1f).height(48.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = Color(0xFFE53935)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}",
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ )
+ /* Cancel button — tap to cancel recording */
+ IconButton(onClick = onPttReleased) {
+ Text("\u2715", color = Color.White, fontSize = 16.sp)
+ }
+ }
+ }
+ } else {
+ /* Text input */
+ OutlinedTextField(
+ value = text,
+ onValueChange = onTextChange,
+ enabled = enabled,
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Message...") },
+ maxLines = 4
+ )
+ }
+
+ Spacer(Modifier.width(4.dp))
+
+ /* PTT button — press to talk */
+ IconButton(
+ onClick = {
+ if (!hasMicPerm) permLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ },
+ enabled = enabled && hasMicPerm,
+ modifier = Modifier.pointerInput(Unit) {
+ detectTapGestures(
+ onPress = {
+ onPttPressed()
+ tryAwaitRelease()
+ onPttReleased()
+ }
+ )
+ }
+ ) {
+ Text("\uD83C\uDFA4", fontSize = 20.sp)
+ }
+
+ /* Send button */
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
Text("Send")
}
}
}
+private fun formatDuration(ms: Int): String {
+ val secs = ms / 1000
+ return "%d:%02d".format(secs / 60, secs % 60)
+}
+
private fun formatTime(ts: Long): String {
if (ts == 0L) return ""
val s = ts / 1_000_000_000 // nanoseconds -> seconds
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
index 8c80a3e4..af2d1541 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
@@ -1,5 +1,8 @@
package com.utun.chat.ui.screens
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -9,11 +12,14 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.utun.chat.data.Channel
import com.utun.chat.data.Message
import com.utun.chat.ui.components.InputBar
import com.utun.chat.ui.components.MessageBubble
+import com.utun.chat.viewmodel.ChatViewModel
+import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -21,11 +27,21 @@ fun ChatScreen(
channel: Channel,
messages: List,
connected: Boolean,
+ viewModel: ChatViewModel,
onSendMessage: (String) -> Unit,
onBack: () -> Unit
) {
var text by remember { mutableStateOf("") }
val listState = rememberLazyListState()
+ val isRecording by viewModel.isRecording.collectAsState()
+
+ val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ uri?.let {
+ val ctx = LocalContext.current
+ val path = it.path ?: return@let
+ viewModel.sendAttachment(path)
+ }
+ }
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
@@ -57,6 +73,14 @@ fun ChatScreen(
text = ""
},
enabled = true,
+ isRecording = isRecording,
+ recordingDurationMs = 0,
+ onPttPressed = { viewModel.startVoiceRecording() },
+ onPttReleased = {
+ val dur = viewModel.stopVoiceRecording()
+ /* duration handled internally */
+ },
+ onAttachClicked = { filePicker.launch("*/*") },
modifier = Modifier.navigationBarsPadding()
)
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
index 5e38581a..ac6ca5bf 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
@@ -294,6 +294,37 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
+ // ── Voice Messages ──
+ Text("Voice Messages", style = MaterialTheme.typography.titleMedium)
+ val voicePreset by remember { mutableStateOf(vm.getVoicePreset()) }
+ var presetExpanded by remember { mutableStateOf(false) }
+ val presets = listOf("Low (16 kbps)", "Standard (32 kbps)", "High (64 kbps)")
+ ExposedDropdownMenuBox(expanded = presetExpanded, onExpandedChange = { presetExpanded = it }) {
+ OutlinedTextField(
+ value = presets[voicePreset], onValueChange = {},
+ readOnly = true, singleLine = true, label = { Text("Opus Preset") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(presetExpanded) },
+ modifier = Modifier.menuAnchor().heightIn(min = 40.dp)
+ )
+ ExposedDropdownMenu(presetExpanded, onDismissRequest = { presetExpanded = false }) {
+ presets.forEachIndexed { i, name ->
+ DropdownMenuItem(text = { Text(name) }, onClick = {
+ vm.setVoicePreset(i); presetExpanded = false
+ })
+ }
+ }
+ }
+ val compressorEnabled = remember { mutableStateOf(vm.getVoiceCompressor()) }
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ Text("Compressor", modifier = Modifier.weight(1f))
+ Switch(
+ checked = compressorEnabled.value,
+ onCheckedChange = { v -> compressorEnabled.value = v; vm.setVoiceCompressor(v) }
+ )
+ }
+
+ HorizontalDivider(Modifier.padding(vertical = 8.dp))
+
// ── Debug ──
Text("Debug", style = MaterialTheme.typography.titleMedium)
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
index ea7cfee4..a1e31614 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
@@ -36,6 +36,11 @@ class ChatViewModel : ViewModel() {
private val _isStuck = MutableStateFlow(false)
val isStuck: StateFlow = _isStuck
+ val audioRecorder = AudioRecorderManager()
+
+ private val _isRecording = MutableStateFlow(false)
+ val isRecording: StateFlow = _isRecording
+
init {
viewModelScope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
@@ -172,6 +177,48 @@ class ChatViewModel : ViewModel() {
r.joinChannel(channelId, nodeId, pubkey, addrs, addrCount)
}
+ fun startVoiceRecording() {
+ val ch = _currentChannel.value ?: return
+ _isRecording.value = true
+ audioRecorder.startRecording(ch.id)
+ }
+
+ fun stopVoiceRecording(): Int {
+ if (!_isRecording.value) return 0
+ val duration = audioRecorder.stopRecording()
+ _isRecording.value = false
+ val ch = _currentChannel.value
+ if (ch != null) {
+ viewModelScope.launch {
+ delay(500)
+ refreshMessages(ch.id)
+ refreshChannels()
+ }
+ }
+ return duration
+ }
+
+ fun cancelVoiceRecording() {
+ audioRecorder.cancelRecording()
+ _isRecording.value = false
+ }
+
+ fun sendAttachment(filePath: String) {
+ val r = repo ?: return
+ val ch = _currentChannel.value ?: return
+ r.sendAttachment(ch.id, filePath)
+ viewModelScope.launch {
+ delay(500)
+ refreshMessages(ch.id)
+ refreshChannels()
+ }
+ }
+
+ fun getVoicePreset(): Int = NativeLib.voiceGetPreset()
+ fun setVoicePreset(preset: Int) { NativeLib.voiceSetPreset(preset) }
+ fun getVoiceCompressor(): Boolean = NativeLib.voiceGetCompressor()
+ fun setVoiceCompressor(enabled: Boolean) { NativeLib.voiceSetCompressor(enabled) }
+
fun clearState() {
repo?.close()
repo = null
diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c
index 5a44d74b..9a379ad7 100644
--- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c
+++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c
@@ -370,12 +370,15 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit) {
const uint8_t* dptr = sqlite3_column_blob(st, 2);
int dlen = sqlite3_column_bytes(st, 2);
- char txt[256] = "";
+ char txt[256] = "", ct[64] = "text/plain";
if (dptr && dlen > 0) {
const char* ds = strstr((const char*)dptr, "\"d\":\"");
if (ds) { ds += 5; char* de = strchr((char*)ds, '"'); if (de) { size_t tl = (size_t)(de - ds); if (tl > 250) tl = 250; memcpy(txt, ds, tl); txt[tl] = '\0'; } }
+ const char* cts = strstr((const char*)dptr, "\"ct\":\"");
+ if (cts) { cts += 6; char* cte = strchr((char*)cts, '"'); if (cte) { size_t tlc = (size_t)(cte - cts); if (tlc < sizeof(ct)) { memcpy(ct, cts, tlc); ct[tlc] = '\0'; } } }
}
char* esc_txt = json_escape_alloc(txt);
+ char* esc_ct = json_escape_alloc(ct);
int is_out = (node_id == (int64_t)my_id) ? 1 : 0;
const char* sep = first ? "" : ",";
first = 0;
@@ -385,12 +388,13 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit) {
if (!author_name[0])
snprintf(author_name, sizeof(author_name), "0x%016llx", (unsigned long long)node_id);
- size_t needed = snprintf(NULL, 0, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s}",
- sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false");
+ size_t needed = snprintf(NULL, 0, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"}",
+ sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct);
while (pos + needed + 2 > cap) { cap *= 2; char* tmp = u_realloc(json, cap); if (!tmp) break; json = tmp; }
if (pos + needed + 2 <= cap)
- pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s}",
- sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false");
+ pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"}",
+ sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct);
+ u_free(esc_ct);
u_free(esc_txt);
}
sqlite3_finalize(st);
@@ -420,6 +424,145 @@ int utun_bridge_is_responsive(void) {
return instance_lite_is_responsive();
}
+/* ──────────────────────────────────────────────────────────────────
+ * Voice recording
+ * ────────────────────────────────────────────────────────────────── */
+
+#include "../libutun_lite/voice_recorder.h"
+#include "../libutun_lite/attachment_sender.h"
+
+int utun_bridge_voice_start(const char* channel_id) {
+ return voice_recorder_start(channel_id, 48000, 1);
+}
+
+int utun_bridge_voice_feed(const int16_t* samples, int count) {
+ return voice_recorder_feed(samples, count);
+}
+
+int utun_bridge_voice_stop(int* out_duration_ms) {
+ return voice_recorder_stop(out_duration_ms);
+}
+
+void utun_bridge_voice_cancel(void) {
+ voice_recorder_cancel();
+}
+
+int utun_bridge_voice_is_active(void) {
+ return voice_recorder_is_active();
+}
+
+void utun_bridge_voice_set_preset(int preset) {
+ voice_recorder_set_preset(preset);
+}
+
+int utun_bridge_voice_get_preset(void) {
+ return voice_recorder_get_preset();
+}
+
+void utun_bridge_voice_set_compressor(int enabled) {
+ voice_recorder_set_compressor_enabled(enabled);
+}
+
+int utun_bridge_voice_get_compressor(void) {
+ return voice_recorder_is_compressor_enabled();
+}
+
+int utun_bridge_attachment_send(const char* channel_id, const char* file_path) {
+ return attachment_send(channel_id, file_path, g_db_path);
+}
+
+/* ──────────────────────────────────────────────────────────────────
+ * Voice playback (Opus decode)
+ * ────────────────────────────────────────────────────────────────── */
+
+#include "../../../lib/opus_codec.h"
+
+struct voice_decode_handle {
+ opus_codec_decoder_t* dec;
+ FILE* file;
+ int sample_rate;
+ int channels;
+ int frame_samples;
+ int total_samples;
+ int pcm_consumed;
+};
+
+#define OPUS_MAGIC_D 0x5355504F
+
+void* utun_bridge_voice_decode_open(const char* file_path,
+ int* out_sample_rate, int* out_channels,
+ int* out_total_samples) {
+ if (!file_path || !out_sample_rate || !out_channels || !out_total_samples) return NULL;
+
+ FILE* f = fopen(file_path, "rb");
+ if (!f) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_decode: cannot open %s", file_path); return NULL; }
+
+ uint32_t magic = 0; uint32_t sr = 0; uint16_t ch = 0, fs = 0;
+ if (fread(&magic, 4, 1, f) != 1 || magic != OPUS_MAGIC_D) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_decode: bad magic"); fclose(f); return NULL; }
+ if (fread(&sr, 4, 1, f) != 1) { fclose(f); return NULL; }
+ if (fread(&ch, 2, 1, f) != 1) { fclose(f); return NULL; }
+ if (fread(&fs, 2, 1, f) != 1) { fclose(f); return NULL; }
+
+ opus_codec_decoder_t* dec = opus_codec_decoder_create((int)sr, (int)ch);
+ if (!dec) { fclose(f); return NULL; }
+
+ struct voice_decode_handle* h = u_calloc(1, sizeof(*h));
+ if (!h) { opus_codec_decoder_destroy(dec); fclose(f); return NULL; }
+
+ h->dec = dec; h->file = f;
+ h->sample_rate = (int)sr; h->channels = (int)ch; h->frame_samples = (int)fs;
+
+ /* count total frames/samples */
+ long pos = ftell(f);
+ h->total_samples = 0;
+ for (;;) {
+ uint16_t plen;
+ if (fread(&plen, 2, 1, f) != 1 || plen == 0) break;
+ fseek(f, plen, SEEK_CUR);
+ h->total_samples += (int)fs * (int)ch;
+ }
+ fseek(f, pos, SEEK_SET);
+
+ *out_sample_rate = h->sample_rate;
+ *out_channels = h->channels;
+ *out_total_samples = h->total_samples;
+
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_decode_open: %s rate=%d ch=%d fs=%d total=%d",
+ file_path, h->sample_rate, h->channels, h->frame_samples, h->total_samples);
+ return h;
+}
+
+int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples) {
+ struct voice_decode_handle* h = (struct voice_decode_handle*)handle;
+ if (!h || !buf || max_samples <= 0) return -1;
+ if (h->pcm_consumed >= h->total_samples) return 0;
+
+ uint8_t packet[4096];
+ int written = 0;
+
+ while (written + h->frame_samples * h->channels <= max_samples) {
+ uint16_t plen;
+ if (fread(&plen, 2, 1, h->file) != 1 || plen == 0) break;
+ if (plen > sizeof(packet)) { fseek(h->file, plen, SEEK_CUR); break; }
+ if (fread(packet, 1, plen, h->file) != plen) break;
+
+ int decoded = opus_codec_decode(h->dec, packet, plen, buf + written, h->frame_samples);
+ if (decoded < 0) { DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "voice_decode_read: error %d", decoded); break; }
+ written += decoded * h->channels;
+ }
+ h->pcm_consumed += written;
+ return written;
+}
+
+void utun_bridge_voice_decode_close(void* handle) {
+ if (!handle) return;
+ struct voice_decode_handle* h = (struct voice_decode_handle*)handle;
+ if (h->dec) opus_codec_decoder_destroy(h->dec);
+ if (h->file) fclose(h->file);
+ u_free(h);
+ DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "voice_decode_close");
+}
+
/* ──────────────────────────────────────────────────────────────────
* JNI functions (compiled only for Android via NDK)
* ────────────────────────────────────────────────────────────────── */
@@ -777,4 +920,117 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeClearDatabase
return JNI_TRUE;
}
+/* ── Voice recording JNI ── */
+
+JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceStart(
+ JNIEnv* env, jobject thiz, jstring channelId) {
+ (void)thiz;
+ const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
+ int rc = utun_bridge_voice_start(ch);
+ if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
+ return (rc == 0) ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceFeed(
+ JNIEnv* env, jobject thiz, jshortArray samples) {
+ (void)env; (void)thiz;
+ if (!samples) return JNI_FALSE;
+ jsize len = (*env)->GetArrayLength(env, samples);
+ jshort* data = (*env)->GetShortArrayElements(env, samples, NULL);
+ int rc = utun_bridge_voice_feed((const int16_t*)data, (int)len);
+ (*env)->ReleaseShortArrayElements(env, samples, data, JNI_ABORT);
+ return (rc == 0) ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceStop(
+ JNIEnv* env, jobject thiz) {
+ (void)env; (void)thiz;
+ int duration_ms = 0;
+ utun_bridge_voice_stop(&duration_ms);
+ return (jint)duration_ms;
+}
+
+JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceCancel(
+ JNIEnv* env, jobject thiz) {
+ (void)env; (void)thiz;
+ utun_bridge_voice_cancel();
+}
+
+JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceIsActive(
+ JNIEnv* env, jobject thiz) {
+ (void)env; (void)thiz;
+ return utun_bridge_voice_is_active() ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceSetPreset(
+ JNIEnv* env, jobject thiz, jint preset) {
+ (void)env; (void)thiz;
+ utun_bridge_voice_set_preset((int)preset);
+}
+
+JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceGetPreset(
+ JNIEnv* env, jobject thiz) {
+ (void)env; (void)thiz;
+ return (jint)utun_bridge_voice_get_preset();
+}
+
+JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceSetCompressor(
+ JNIEnv* env, jobject thiz, jboolean enabled) {
+ (void)env; (void)thiz;
+ utun_bridge_voice_set_compressor(enabled ? 1 : 0);
+}
+
+JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceGetCompressor(
+ JNIEnv* env, jobject thiz) {
+ (void)env; (void)thiz;
+ return utun_bridge_voice_get_compressor() ? JNI_TRUE : JNI_FALSE;
+}
+
+/* ── Attachment JNI ── */
+
+JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeAttachmentSend(
+ JNIEnv* env, jobject thiz, jstring channelId, jstring filePath) {
+ (void)thiz;
+ const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
+ const char* fp = (*env)->GetStringUTFChars(env, filePath, NULL);
+ int rc = utun_bridge_attachment_send(ch, fp);
+ if (fp) (*env)->ReleaseStringUTFChars(env, filePath, fp);
+ if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
+ return (rc == 0) ? JNI_TRUE : JNI_FALSE;
+}
+
+/* ── Voice playback JNI ── */
+
+JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceDecodeOpen(
+ JNIEnv* env, jobject thiz, jstring filePath, jintArray outInfo) {
+ (void)thiz;
+ if (!filePath || !outInfo) return 0;
+ const char* fp = (*env)->GetStringUTFChars(env, filePath, NULL);
+ int sr = 0, ch = 0, total = 0;
+ void* h = utun_bridge_voice_decode_open(fp, &sr, &ch, &total);
+ if (h) {
+ jint info[3] = {sr, ch, total};
+ (*env)->SetIntArrayRegion(env, outInfo, 0, 3, info);
+ }
+ if (fp) (*env)->ReleaseStringUTFChars(env, filePath, fp);
+ return (jlong)(intptr_t)h;
+}
+
+JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceDecodeRead(
+ JNIEnv* env, jobject thiz, jlong handle, jshortArray buf) {
+ (void)thiz;
+ if (handle == 0 || !buf) return -1;
+ jsize max = (*env)->GetArrayLength(env, buf);
+ jshort* data = (*env)->GetShortArrayElements(env, buf, NULL);
+ int read = utun_bridge_voice_decode_read((void*)(intptr_t)handle, (int16_t*)data, (int)max);
+ (*env)->ReleaseShortArrayElements(env, buf, data, 0);
+ return (jint)read;
+}
+
+JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceDecodeClose(
+ JNIEnv* env, jobject thiz, jlong handle) {
+ (void)env; (void)thiz;
+ utun_bridge_voice_decode_close((void*)(intptr_t)handle);
+}
+
#endif /* __ANDROID__ */
diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h
index 90314012..38332c4a 100644
--- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h
+++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h
@@ -74,6 +74,30 @@ void utun_bridge_restart(const char* config_text);
void utun_bridge_ping(void);
int utun_bridge_is_responsive(void);
+/* ── Voice recording ── */
+
+int utun_bridge_voice_start(const char* channel_id);
+int utun_bridge_voice_feed(const int16_t* samples, int count);
+int utun_bridge_voice_stop(int* out_duration_ms);
+void utun_bridge_voice_cancel(void);
+int utun_bridge_voice_is_active(void);
+void utun_bridge_voice_set_preset(int preset);
+int utun_bridge_voice_get_preset(void);
+void utun_bridge_voice_set_compressor(int enabled);
+int utun_bridge_voice_get_compressor(void);
+
+/* ── Attachment ── */
+
+int utun_bridge_attachment_send(const char* channel_id, const char* file_path);
+
+/* ── Voice playback (Opus decode) ── */
+
+void* utun_bridge_voice_decode_open(const char* file_path,
+ int* out_sample_rate, int* out_channels,
+ int* out_total_samples);
+int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples);
+void utun_bridge_voice_decode_close(void* handle);
+
/* ── Debug ── */
void utun_bridge_set_debug_level(const char* category, const char* level);
diff --git a/tools/chatgui-android/libutun_lite/attachment_sender.c b/tools/chatgui-android/libutun_lite/attachment_sender.c
new file mode 100644
index 00000000..cfdc5440
--- /dev/null
+++ b/tools/chatgui-android/libutun_lite/attachment_sender.c
@@ -0,0 +1,123 @@
+#include "attachment_sender.h"
+#include "instance_lite.h"
+#include "../../../lib/debug_config.h"
+#include "../../../lib/mem.h"
+#include "../../../lib/u_async.h"
+#include "../../../src/chat/chat_core.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define MEDIA_BLOCK_MIN (10 * 1024 * 1024)
+#define MEDIA_BLOCK_MAX (25 * 1024 * 1024)
+#define MEDIA_BLOCK_TARGET 30
+
+static uint32_t media_calc_block_size(uint64_t file_size) {
+ if (file_size == 0) return 0;
+ uint64_t target = (file_size + MEDIA_BLOCK_TARGET - 1) / MEDIA_BLOCK_TARGET;
+ if (target < MEDIA_BLOCK_MIN) target = MEDIA_BLOCK_MIN;
+ if (target > MEDIA_BLOCK_MAX) target = MEDIA_BLOCK_MAX;
+ return (uint32_t)target;
+}
+
+int attachment_send(const char* channel_id, const char* src_file_path, const char* db_path) {
+ if (!channel_id || !src_file_path || !db_path) {
+ DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: invalid args");
+ return -1;
+ }
+
+ struct UASYNC* ua = instance_lite_get_uasync();
+ if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: no uasync"); return -1; }
+
+ FILE* src = fopen(src_file_path, "rb");
+ if (!src) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: cannot open %s", src_file_path); return -1; }
+
+ fseek(src, 0, SEEK_END);
+ uint64_t file_size = (uint64_t)ftell(src);
+ fseek(src, 0, SEEK_SET);
+
+ if (file_size == 0) { DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "attachment_send: empty file %s", src_file_path); fclose(src); return -1; }
+
+ /* extract basename and ext from path */
+ char path_copy[1024];
+ snprintf(path_copy, sizeof(path_copy), "%s", src_file_path);
+ char* fname = basename(path_copy);
+ char* dot = strrchr(fname, '.');
+ char ext[32] = "";
+ if (dot) {
+ snprintf(ext, sizeof(ext), "%s", dot + 1);
+ *dot = '\0';
+ }
+
+ /* generate dt/suffix */
+ time_t t = time(NULL);
+ struct tm tm_buf; localtime_r(&t, &tm_buf);
+ char dt[32]; snprintf(dt, sizeof(dt), "%04d%02d%02d-%02d%02d%02d",
+ tm_buf.tm_year + 1900, tm_buf.tm_mon + 1, tm_buf.tm_mday,
+ tm_buf.tm_hour, tm_buf.tm_min, tm_buf.tm_sec);
+ int rnd = rand() & 0xFFFF;
+ char suffix[8]; snprintf(suffix, sizeof(suffix), "%04x", rnd);
+
+ uint32_t block_size = media_calc_block_size(file_size);
+ int num_blocks = (int)((file_size + block_size - 1) / block_size);
+
+ /* ensure media dir */
+ char media_dir[1024];
+ snprintf(media_dir, sizeof(media_dir), "%s/media/%s", db_path, channel_id);
+ {
+ char tmp[1024]; size_t off = 0;
+ for (size_t i = 0; media_dir[i] && off < sizeof(tmp) - 1; i++) {
+ tmp[off++] = media_dir[i];
+ if (media_dir[i] == '/' && off > 1) { tmp[off] = '\0'; mkdir(tmp, 0755); }
+ }
+ mkdir(tmp, 0755);
+ }
+
+ /* write blocks */
+ for (int n = 0; n < num_blocks; n++) {
+ char block_path[1280];
+ snprintf(block_path, sizeof(block_path), "%s/%s_%d_%s_%s.%s",
+ media_dir, dt, n, fname, suffix, ext[0] ? ext : "bin");
+ FILE* dst = fopen(block_path, "wb");
+ if (!dst) {
+ DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: cannot create block %s", block_path);
+ continue;
+ }
+ uint8_t buf[65536];
+ uint64_t remaining = block_size;
+ while (remaining > 0) {
+ size_t to_read = remaining < sizeof(buf) ? (size_t)remaining : sizeof(buf);
+ size_t rd = fread(buf, 1, to_read, src);
+ if (rd == 0) break;
+ fwrite(buf, 1, rd, dst);
+ remaining -= rd;
+ }
+ fclose(dst);
+ }
+ fclose(src);
+
+ /* post chat_msg_submit */
+ struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + 1);
+ if (!req) return -1;
+
+ snprintf(req->channel_id, sizeof(req->channel_id), "%s", channel_id);
+ snprintf(req->content_type, sizeof(req->content_type), "application/octet-stream");
+ snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt);
+ snprintf(req->media_basename, sizeof(req->media_basename), "%s", fname);
+ snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix);
+ snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext[0] ? ext : "bin");
+ req->media_num_blocks = (uint32_t)num_blocks;
+ req->data = (uint8_t*)(req + 1);
+ req->data_len = 0;
+ req->timestamp = 0;
+
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s file=%s blocks=%d size=%llu",
+ channel_id, src_file_path, num_blocks, (unsigned long long)file_size);
+
+ uasync_post(ua, chat_core_submit_trampoline, req);
+ return 0;
+}
diff --git a/tools/chatgui-android/libutun_lite/attachment_sender.h b/tools/chatgui-android/libutun_lite/attachment_sender.h
new file mode 100644
index 00000000..c58b032a
--- /dev/null
+++ b/tools/chatgui-android/libutun_lite/attachment_sender.h
@@ -0,0 +1,14 @@
+#ifndef ATTACHMENT_SENDER_H
+#define ATTACHMENT_SENDER_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+int attachment_send(const char* channel_id, const char* src_file_path, const char* db_path);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/tools/chatgui-android/libutun_lite/audio_compressor.c b/tools/chatgui-android/libutun_lite/audio_compressor.c
new file mode 100644
index 00000000..c09a1669
--- /dev/null
+++ b/tools/chatgui-android/libutun_lite/audio_compressor.c
@@ -0,0 +1,321 @@
+#include "audio_compressor.h"
+#include "../../../lib/debug_config.h"
+#include "../../../lib/mem.h"
+#include
+#include
+#include
+
+#define BLOCK_LEVELS_CHUNK 64
+#define PENDING_CHUNK 32
+
+struct pending_block {
+ int16_t* samples;
+ size_t count;
+ size_t level_index;
+};
+
+struct audio_compressor {
+ audio_compressor_config_t cfg;
+ int enabled;
+
+ int block_samples;
+ int lookback_blocks;
+ int lookahead_blocks;
+ float rise_factor_per_block;
+ float max_gain;
+ float gain_smoothed;
+
+ float* block_levels;
+ size_t block_levels_count;
+ size_t block_levels_cap;
+
+ struct pending_block* pending;
+ size_t pending_head;
+ size_t pending_tail;
+ size_t pending_cap;
+
+ int16_t* accumulator;
+ size_t accum_count;
+
+ int16_t* output;
+ size_t output_size;
+ size_t output_cap;
+
+ int dbg_counter;
+};
+
+struct audio_compressor* audio_compressor_create(void) {
+ struct audio_compressor* ac = u_calloc(1, sizeof(*ac));
+ if (!ac) {
+ DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "audio_compressor_create: OOM");
+ return NULL;
+ }
+ ac->enabled = 1;
+ ac->gain_smoothed = 1.0f;
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "audio_compressor_create: ok");
+ return ac;
+}
+
+void audio_compressor_destroy(struct audio_compressor* ac) {
+ if (!ac) return;
+ for (size_t i = ac->pending_head; i != ac->pending_tail; i = (i + 1) % ac->pending_cap)
+ u_free(ac->pending[i].samples);
+ u_free(ac->pending);
+ u_free(ac->block_levels);
+ u_free(ac->accumulator);
+ u_free(ac->output);
+ u_free(ac);
+ DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "audio_compressor_destroy");
+}
+
+void audio_compressor_configure(struct audio_compressor* ac, const audio_compressor_config_t* cfg) {
+ if (!ac || !cfg) return;
+ ac->cfg = *cfg;
+ if (ac->cfg.sample_rate <= 0) ac->cfg.sample_rate = 48000;
+ if (ac->cfg.channels <= 0) ac->cfg.channels = 1;
+ if (ac->cfg.block_duration_ms <= 0) ac->cfg.block_duration_ms = 20;
+ if (ac->cfg.lookback_ms <= 0) ac->cfg.lookback_ms = 200;
+ if (ac->cfg.lookahead_ms <= 0) ac->cfg.lookahead_ms = 100;
+ if (ac->cfg.target_level <= 0.0f) ac->cfg.target_level = 0.25f;
+
+ ac->block_samples = ac->cfg.sample_rate * ac->cfg.block_duration_ms / 1000 * ac->cfg.channels;
+ ac->lookback_blocks = ac->cfg.lookback_ms / ac->cfg.block_duration_ms;
+ ac->lookahead_blocks = ac->cfg.lookahead_ms / ac->cfg.block_duration_ms;
+ ac->rise_factor_per_block = powf(ac->cfg.rise_rate_per_500ms, (float)ac->cfg.block_duration_ms / 500.0f);
+ ac->max_gain = powf(10.0f, ac->cfg.max_gain_db / 20.0f);
+
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL,
+ "audio_compressor_configure: rate=%d ch=%d block=%dms lookback=%dms lookahead=%dms maxGain=%.0fdB riseRate=%.1f/500ms target=%.0fdBFS",
+ ac->cfg.sample_rate, ac->cfg.channels, ac->cfg.block_duration_ms,
+ ac->cfg.lookback_ms, ac->cfg.lookahead_ms, ac->cfg.max_gain_db,
+ ac->cfg.rise_rate_per_500ms, 20.0f * log10f(ac->cfg.target_level));
+}
+
+void audio_compressor_reset(struct audio_compressor* ac) {
+ if (!ac) return;
+ for (size_t i = ac->pending_head; i != ac->pending_tail; i = (i + 1) % ac->pending_cap)
+ u_free(ac->pending[i].samples);
+ ac->pending_head = 0;
+ ac->pending_tail = 0;
+ ac->block_levels_count = 0;
+ ac->accum_count = 0;
+ ac->output_size = 0;
+ ac->gain_smoothed = 1.0f;
+ ac->dbg_counter = 0;
+}
+
+void audio_compressor_set_enabled(struct audio_compressor* ac, int enabled) {
+ if (!ac) return;
+ ac->enabled = enabled ? 1 : 0;
+}
+
+int audio_compressor_is_enabled(const struct audio_compressor* ac) {
+ return ac ? ac->enabled : 0;
+}
+
+static float compute_block_level(const int16_t* samples, size_t count) {
+ double sum_sq = 0.0;
+ float peak = 0.0f;
+ for (size_t i = 0; i < count; i++) {
+ float v = (float)samples[i] / 32768.0f;
+ sum_sq += (double)v * v;
+ float av = fabsf(v);
+ if (av > peak) peak = av;
+ }
+ float rms = (float)sqrt(sum_sq / (double)count);
+ return (rms * 2.0f + peak) / 2.0f;
+}
+
+static void ensure_output_cap(struct audio_compressor* ac, size_t needed) {
+ size_t want = ac->output_size + needed;
+ if (want <= ac->output_cap) return;
+ size_t new_cap = ac->output_cap ? ac->output_cap * 2 : 4096;
+ while (new_cap < want) new_cap *= 2;
+ int16_t* tmp = u_realloc(ac->output, new_cap * sizeof(int16_t));
+ if (!tmp) return;
+ ac->output = tmp;
+ ac->output_cap = new_cap;
+}
+
+static void append_output(struct audio_compressor* ac, const int16_t* samples, size_t count) {
+ ensure_output_cap(ac, count);
+ memcpy(ac->output + ac->output_size, samples, count * sizeof(int16_t));
+ ac->output_size += count;
+}
+
+static void ensure_block_levels_cap(struct audio_compressor* ac) {
+ if (ac->block_levels_count < ac->block_levels_cap) return;
+ size_t new_cap = ac->block_levels_cap ? ac->block_levels_cap * 2 : BLOCK_LEVELS_CHUNK;
+ float* tmp = u_realloc(ac->block_levels, new_cap * sizeof(float));
+ if (!tmp) return;
+ ac->block_levels = tmp;
+ ac->block_levels_cap = new_cap;
+}
+
+static void add_block_level(struct audio_compressor* ac, float level) {
+ ensure_block_levels_cap(ac);
+ ac->block_levels[ac->block_levels_count++] = level;
+}
+
+static void process_pending_block(struct audio_compressor* ac);
+
+void audio_compressor_push(struct audio_compressor* ac, const int16_t* samples, size_t count) {
+ if (!ac || !samples || count == 0) return;
+
+ if (!ac->enabled) {
+ append_output(ac, samples, count);
+ return;
+ }
+
+ size_t remaining = count;
+ const int16_t* src = samples;
+
+ while (remaining > 0) {
+ size_t need = (size_t)ac->block_samples - ac->accum_count;
+ size_t take = remaining < need ? remaining : need;
+
+ size_t new_cnt = ac->accum_count + take;
+ int16_t* tmp = u_realloc(ac->accumulator, new_cnt * sizeof(int16_t));
+ if (!tmp) return;
+ ac->accumulator = tmp;
+ memcpy(ac->accumulator + ac->accum_count, src, take * sizeof(int16_t));
+ ac->accum_count = new_cnt;
+
+ src += take;
+ remaining -= take;
+
+ if (ac->accum_count == (size_t)ac->block_samples) {
+ float level = compute_block_level(ac->accumulator, ac->accum_count);
+ add_block_level(ac, level);
+
+ struct pending_block pb;
+ pb.samples = ac->accumulator;
+ pb.count = ac->accum_count;
+ pb.level_index = ac->block_levels_count - 1;
+
+ /* ring buffer enqueue */
+ if ((ac->pending_tail + 1) % ac->pending_cap == ac->pending_head) {
+ size_t new_cap = ac->pending_cap ? ac->pending_cap * 2 : PENDING_CHUNK;
+ struct pending_block* tmp2 = u_realloc(ac->pending, new_cap * sizeof(struct pending_block));
+ if (!tmp2) { u_free(pb.samples); return; }
+ if (ac->pending && ac->pending_head > ac->pending_tail) {
+ size_t wrap = ac->pending_cap - ac->pending_head;
+ memmove(tmp2 + new_cap - wrap, tmp2 + ac->pending_head, wrap * sizeof(struct pending_block));
+ ac->pending_head = new_cap - wrap;
+ }
+ ac->pending = tmp2;
+ ac->pending_cap = new_cap;
+ }
+ ac->pending[ac->pending_tail] = pb;
+ ac->pending_tail = (ac->pending_tail + 1) % ac->pending_cap;
+
+ ac->accumulator = NULL;
+ ac->accum_count = 0;
+ }
+ }
+
+ while (ac->pending_head != ac->pending_tail) {
+ size_t pending_cnt = (ac->pending_tail >= ac->pending_head)
+ ? (ac->pending_tail - ac->pending_head)
+ : (ac->pending_cap - ac->pending_head + ac->pending_tail);
+ if (pending_cnt <= (size_t)ac->lookahead_blocks) break;
+ process_pending_block(ac);
+ }
+}
+
+static void process_pending_block(struct audio_compressor* ac) {
+ if (ac->pending_head == ac->pending_tail) return;
+
+ struct pending_block* block = &ac->pending[ac->pending_head];
+ size_t idx = block->level_index;
+
+ size_t win_start = idx >= (size_t)ac->lookback_blocks ? idx - (size_t)ac->lookback_blocks : 0;
+ size_t win_end = idx + (size_t)ac->lookahead_blocks;
+ if (win_end >= ac->block_levels_count) win_end = ac->block_levels_count - 1;
+
+ float envelope = 0.0f;
+ for (size_t i = win_start; i <= win_end; i++) {
+ if (ac->block_levels[i] > envelope) envelope = ac->block_levels[i];
+ }
+ if (envelope < 0.0001f) envelope = 0.0001f;
+
+ float G_raw = ac->cfg.target_level / envelope;
+ float G_prev = ac->gain_smoothed;
+
+ if (G_raw > ac->gain_smoothed) {
+ float max_rise = ac->gain_smoothed * ac->rise_factor_per_block;
+ ac->gain_smoothed = G_raw < max_rise ? G_raw : max_rise;
+ } else {
+ ac->gain_smoothed = G_raw;
+ }
+ if (ac->gain_smoothed > ac->max_gain) ac->gain_smoothed = ac->max_gain;
+
+ float gain_db = 20.0f * log10f(ac->gain_smoothed > 0.0001f ? ac->gain_smoothed : 0.0001f);
+ float prev_db = 20.0f * log10f(G_prev > 0.0001f ? G_prev : 0.0001f);
+ if (ac->dbg_counter % 25 == 0 || fabsf(gain_db - prev_db) > 3.0f) {
+ DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "audio_compressor: block %zu level=%.4f envelope=%.4f gainRaw=%.1fdB gain=%.1fdB",
+ idx, ac->block_levels[idx], envelope, 20.0f * log10f(G_raw), gain_db);
+ }
+ ac->dbg_counter++;
+
+ for (size_t i = 0; i < block->count; i++) {
+ float v = (float)block->samples[i] * ac->gain_smoothed;
+ if (v > 32767.0f) v = 32767.0f;
+ if (v < -32768.0f) v = -32768.0f;
+ block->samples[i] = (int16_t)(int)v;
+ }
+ append_output(ac, block->samples, block->count);
+ u_free(block->samples);
+
+ ac->pending_head = (ac->pending_head + 1) % ac->pending_cap;
+}
+
+void audio_compressor_flush(struct audio_compressor* ac) {
+ if (!ac) return;
+ if (!ac->enabled) return;
+
+ if (ac->accum_count > 0) {
+ float level = compute_block_level(ac->accumulator, ac->accum_count);
+ add_block_level(ac, level);
+
+ struct pending_block pb;
+ pb.samples = ac->accumulator;
+ pb.count = ac->accum_count;
+ pb.level_index = ac->block_levels_count - 1;
+
+ if ((ac->pending_tail + 1) % ac->pending_cap == ac->pending_head) {
+ size_t new_cap = ac->pending_cap ? ac->pending_cap * 2 : PENDING_CHUNK;
+ struct pending_block* tmp = u_realloc(ac->pending, new_cap * sizeof(struct pending_block));
+ if (!tmp) { u_free(pb.samples); return; }
+ if (ac->pending && ac->pending_head > ac->pending_tail) {
+ size_t wrap = ac->pending_cap - ac->pending_head;
+ memmove(tmp + new_cap - wrap, tmp + ac->pending_head, wrap * sizeof(struct pending_block));
+ ac->pending_head = new_cap - wrap;
+ }
+ ac->pending = tmp;
+ ac->pending_cap = new_cap;
+ }
+ ac->pending[ac->pending_tail] = pb;
+ ac->pending_tail = (ac->pending_tail + 1) % ac->pending_cap;
+
+ ac->accumulator = NULL;
+ ac->accum_count = 0;
+ }
+
+ int remain = (int)((ac->pending_tail >= ac->pending_head)
+ ? (ac->pending_tail - ac->pending_head)
+ : (ac->pending_cap - ac->pending_head + ac->pending_tail));
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "audio_compressor_flush: %d pending blocks (gainSmoothed=%.1fdB)",
+ remain, 20.0f * log10f(ac->gain_smoothed > 0.0001f ? ac->gain_smoothed : 0.0001f));
+
+ while (ac->pending_head != ac->pending_tail)
+ process_pending_block(ac);
+}
+
+const int16_t* audio_compressor_output(const struct audio_compressor* ac) {
+ return ac ? ac->output : NULL;
+}
+
+size_t audio_compressor_output_size(const struct audio_compressor* ac) {
+ return ac ? ac->output_size : 0;
+}
diff --git a/tools/chatgui-android/libutun_lite/audio_compressor.h b/tools/chatgui-android/libutun_lite/audio_compressor.h
new file mode 100644
index 00000000..5d589bab
--- /dev/null
+++ b/tools/chatgui-android/libutun_lite/audio_compressor.h
@@ -0,0 +1,43 @@
+#ifndef AUDIO_COMPRESSOR_H
+#define AUDIO_COMPRESSOR_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct audio_compressor;
+
+typedef struct {
+ int sample_rate;
+ int channels;
+ int block_duration_ms;
+ int lookback_ms;
+ int lookahead_ms;
+ float max_gain_db;
+ float rise_rate_per_500ms;
+ float target_level;
+} audio_compressor_config_t;
+
+struct audio_compressor* audio_compressor_create(void);
+void audio_compressor_destroy(struct audio_compressor* ac);
+
+void audio_compressor_configure(struct audio_compressor* ac, const audio_compressor_config_t* cfg);
+void audio_compressor_reset(struct audio_compressor* ac);
+
+void audio_compressor_set_enabled(struct audio_compressor* ac, int enabled);
+int audio_compressor_is_enabled(const struct audio_compressor* ac);
+
+void audio_compressor_push(struct audio_compressor* ac, const int16_t* samples, size_t count);
+void audio_compressor_flush(struct audio_compressor* ac);
+
+const int16_t* audio_compressor_output(const struct audio_compressor* ac);
+size_t audio_compressor_output_size(const struct audio_compressor* ac);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/tools/chatgui-android/libutun_lite/utun_sources.cmake b/tools/chatgui-android/libutun_lite/utun_sources.cmake
index 238ebff3..c3da7bd7 100644
--- a/tools/chatgui-android/libutun_lite/utun_sources.cmake
+++ b/tools/chatgui-android/libutun_lite/utun_sources.cmake
@@ -36,6 +36,9 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
"${CONFIG_DIR}/utun_config_api.c"
"${CONFIG_DIR}/invite_link_c.c"
"${CONFIG_DIR}/instance_lite.c"
+ "${CONFIG_DIR}/audio_compressor.c"
+ "${CONFIG_DIR}/voice_recorder.c"
+ "${CONFIG_DIR}/attachment_sender.c"
)
# ── Export sources ──
diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.c b/tools/chatgui-android/libutun_lite/voice_recorder.c
new file mode 100644
index 00000000..70987701
--- /dev/null
+++ b/tools/chatgui-android/libutun_lite/voice_recorder.c
@@ -0,0 +1,448 @@
+#include "voice_recorder.h"
+#include "audio_compressor.h"
+#include "instance_lite.h"
+#include "../../../lib/opus_codec.h"
+#include "../../../lib/debug_config.h"
+#include "../../../lib/mem.h"
+#include "../../../lib/platform_compat.h"
+#include "../../../lib/u_async.h"
+#include "../../../src/chat/chat_core.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define MEDIA_BLOCK_MIN (10 * 1024 * 1024)
+#define MEDIA_BLOCK_MAX (25 * 1024 * 1024)
+#define MEDIA_BLOCK_TARGET 30
+#define OPUS_MAGIC 0x5355504F
+#define FRAME_MS 20
+#define MAX_PACKET 4000
+
+static const int s_bitrates[] = {16000, 32000, 64000};
+static const int s_complexities[] = {2, 5, 10};
+
+struct voice_recorder {
+ pthread_mutex_t mtx;
+ int active;
+
+ char channel_id[64];
+ int sample_rate;
+ int channels;
+ int frame_samples;
+
+ int16_t* pcm_buffer;
+ size_t pcm_count;
+ size_t pcm_cap;
+
+ struct audio_compressor* compressor;
+ int compressor_enabled;
+
+ char db_path[512];
+ int preset;
+
+ int64_t start_time_ms;
+};
+
+static struct voice_recorder* g_rec = NULL;
+static pthread_mutex_t g_init_mtx = PTHREAD_MUTEX_INITIALIZER;
+
+int voice_recorder_init(const char* db_path) {
+ pthread_mutex_lock(&g_init_mtx);
+ if (g_rec) { pthread_mutex_unlock(&g_init_mtx); return 0; }
+
+ g_rec = u_calloc(1, sizeof(*g_rec));
+ if (!g_rec) {
+ DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_recorder_init: OOM");
+ pthread_mutex_unlock(&g_init_mtx);
+ return -1;
+ }
+ pthread_mutex_init(&g_rec->mtx, NULL);
+ snprintf(g_rec->db_path, sizeof(g_rec->db_path), "%s", db_path ? db_path : "");
+ g_rec->preset = 1;
+ g_rec->compressor_enabled = 1;
+
+ g_rec->compressor = audio_compressor_create();
+ if (g_rec->compressor) {
+ audio_compressor_config_t cfg = {0};
+ cfg.sample_rate = 48000;
+ cfg.channels = 1;
+ cfg.block_duration_ms = 20;
+ cfg.lookback_ms = 200;
+ cfg.lookahead_ms = 100;
+ cfg.max_gain_db = 30.0f;
+ cfg.rise_rate_per_500ms = 2.0f;
+ cfg.target_level = 0.25f;
+ audio_compressor_configure(g_rec->compressor, &cfg);
+ }
+
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_init: db=%s preset=%d compressor=%d",
+ g_rec->db_path, g_rec->preset, g_rec->compressor_enabled);
+ pthread_mutex_unlock(&g_init_mtx);
+ return 0;
+}
+
+void voice_recorder_deinit(void) {
+ pthread_mutex_lock(&g_init_mtx);
+ if (!g_rec) { pthread_mutex_unlock(&g_init_mtx); return; }
+ if (g_rec->active) {
+ u_free(g_rec->pcm_buffer);
+ g_rec->pcm_buffer = NULL;
+ g_rec->pcm_count = 0;
+ g_rec->pcm_cap = 0;
+ g_rec->active = 0;
+ }
+ audio_compressor_destroy(g_rec->compressor);
+ pthread_mutex_destroy(&g_rec->mtx);
+ u_free(g_rec);
+ g_rec = NULL;
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_deinit");
+ pthread_mutex_unlock(&g_init_mtx);
+}
+
+int voice_recorder_start(const char* channel_id, int sample_rate, int channels) {
+ pthread_mutex_lock(&g_init_mtx);
+ if (!g_rec) { pthread_mutex_unlock(&g_init_mtx); return -1; }
+ pthread_mutex_lock(&g_rec->mtx);
+ if (g_rec->active) {
+ DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "voice_recorder_start: already active");
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return -1;
+ }
+
+ if (sample_rate <= 0) sample_rate = 48000;
+ if (channels <= 0) channels = 1;
+ g_rec->sample_rate = sample_rate;
+ g_rec->channels = channels;
+ g_rec->frame_samples = sample_rate * FRAME_MS / 1000;
+ snprintf(g_rec->channel_id, sizeof(g_rec->channel_id), "%s", channel_id ? channel_id : "");
+
+ g_rec->pcm_count = 0;
+ g_rec->active = 1;
+
+ struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
+ g_rec->start_time_ms = ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
+
+ if (g_rec->compressor && g_rec->compressor_enabled) {
+ audio_compressor_reset(g_rec->compressor);
+ audio_compressor_set_enabled(g_rec->compressor, 1);
+ }
+
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_start: ch=%s rate=%d chans=%d frame=%d",
+ g_rec->channel_id, g_rec->sample_rate, g_rec->channels, g_rec->frame_samples);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return 0;
+}
+
+int voice_recorder_feed(const int16_t* samples, int count) {
+ if (!samples || count <= 0) return -1;
+ pthread_mutex_lock(&g_init_mtx);
+ if (!g_rec) { pthread_mutex_unlock(&g_init_mtx); return -1; }
+ pthread_mutex_lock(&g_rec->mtx);
+ if (!g_rec->active) { pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; }
+
+ if (g_rec->compressor && g_rec->compressor_enabled) {
+ audio_compressor_push(g_rec->compressor, samples, (size_t)count);
+ } else {
+ size_t new_cnt = g_rec->pcm_count + (size_t)count;
+ if (new_cnt > g_rec->pcm_cap) {
+ size_t new_cap = g_rec->pcm_cap ? g_rec->pcm_cap * 2 : (size_t)count * 2;
+ while (new_cap < new_cnt) new_cap *= 2;
+ int16_t* tmp = u_realloc(g_rec->pcm_buffer, new_cap * sizeof(int16_t));
+ if (!tmp) { pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; }
+ g_rec->pcm_buffer = tmp;
+ g_rec->pcm_cap = new_cap;
+ }
+ memcpy(g_rec->pcm_buffer + g_rec->pcm_count, samples, (size_t)count * sizeof(int16_t));
+ g_rec->pcm_count = new_cnt;
+ }
+
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return 0;
+}
+
+static uint32_t media_calc_block_size(uint64_t file_size) {
+ if (file_size == 0) return 0;
+ uint64_t target = (file_size + MEDIA_BLOCK_TARGET - 1) / MEDIA_BLOCK_TARGET;
+ if (target < MEDIA_BLOCK_MIN) target = MEDIA_BLOCK_MIN;
+ if (target > MEDIA_BLOCK_MAX) target = MEDIA_BLOCK_MAX;
+ return (uint32_t)target;
+}
+
+static int64_t now_ms(void) {
+ struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
+ return ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
+}
+
+static void voice_cleanup_locked(struct voice_recorder* rec) {
+ u_free(rec->pcm_buffer); rec->pcm_buffer = NULL;
+ rec->pcm_count = 0; rec->pcm_cap = 0;
+ rec->active = 0;
+}
+
+int voice_recorder_stop(int* out_duration_ms) {
+ pthread_mutex_lock(&g_init_mtx);
+ if (!g_rec) { pthread_mutex_unlock(&g_init_mtx); return -1; }
+ pthread_mutex_lock(&g_rec->mtx);
+ if (!g_rec->active) {
+ DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: not active");
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return -1;
+ }
+
+ int64_t elapsed_ms = now_ms() - g_rec->start_time_ms;
+ if (out_duration_ms) *out_duration_ms = (int)elapsed_ms;
+
+ struct UASYNC* ua = instance_lite_get_uasync();
+ if (!ua) {
+ DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: no uasync, discarding");
+ voice_cleanup_locked(g_rec);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return -1;
+ }
+
+ if (elapsed_ms < 500) {
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: too short %lldms, discarding", (long long)elapsed_ms);
+ voice_cleanup_locked(g_rec);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return 0;
+ }
+
+ int16_t* final_pcm = NULL;
+ size_t final_pcm_count = 0;
+
+ if (g_rec->compressor && g_rec->compressor_enabled) {
+ audio_compressor_flush(g_rec->compressor);
+ final_pcm = (int16_t*)audio_compressor_output(g_rec->compressor);
+ final_pcm_count = audio_compressor_output_size(g_rec->compressor);
+ } else {
+ final_pcm = g_rec->pcm_buffer;
+ final_pcm_count = g_rec->pcm_count;
+ }
+
+ if (!final_pcm || final_pcm_count == 0) {
+ DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: no PCM data");
+ voice_cleanup_locked(g_rec);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return -1;
+ }
+
+ /* generate file name parts */
+ time_t t = time(NULL);
+ struct tm tm_buf; localtime_r(&t, &tm_buf);
+ char dt[32], basename[256], suffix[8];
+ snprintf(dt, sizeof(dt), "%04d%02d%02d-%02d%02d%02d",
+ tm_buf.tm_year + 1900, tm_buf.tm_mon + 1, tm_buf.tm_mday,
+ tm_buf.tm_hour, tm_buf.tm_min, tm_buf.tm_sec);
+ int rnd = rand() & 0xFFFF;
+ snprintf(basename, sizeof(basename), "voice_%s_%04x", dt, rnd);
+ snprintf(suffix, sizeof(suffix), "%04x", rnd);
+
+ /* ensure media dir exists */
+ char media_dir[1024];
+ snprintf(media_dir, sizeof(media_dir), "%s/media/%s", g_rec->db_path, g_rec->channel_id);
+ {
+ char tmp[1024]; size_t off = 0;
+ for (size_t i = 0; media_dir[i] && off < sizeof(tmp) - 1; i++) {
+ tmp[off++] = media_dir[i];
+ if (media_dir[i] == '/' && off > 1) { tmp[off] = '\0'; mkdir(tmp, 0755); }
+ }
+ mkdir(tmp, 0755);
+ }
+
+ /* encode to Opus and write blocks */
+ opus_codec_encoder_t* enc = opus_codec_encoder_create(g_rec->sample_rate, g_rec->channels);
+ if (!enc) {
+ DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: encoder create failed");
+ voice_cleanup_locked(g_rec);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return -1;
+ }
+ opus_codec_encoder_bitrate_set(enc, s_bitrates[g_rec->preset]);
+ opus_codec_encoder_complexity_set(enc, s_complexities[g_rec->preset]);
+
+ char temp_path[1280];
+ snprintf(temp_path, sizeof(temp_path), "%s/%s_%s_%s.opus", media_dir, dt, basename, suffix);
+ FILE* of = fopen(temp_path, "wb");
+ if (!of) {
+ DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: cannot create %s", temp_path);
+ opus_codec_encoder_destroy(enc);
+ voice_cleanup_locked(g_rec);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return -1;
+ }
+
+ /* Opus header */
+ uint32_t magic = OPUS_MAGIC;
+ uint32_t sr = (uint32_t)g_rec->sample_rate;
+ uint16_t ch = (uint16_t)g_rec->channels;
+ uint16_t fs = (uint16_t)g_rec->frame_samples;
+ fwrite(&magic, 4, 1, of);
+ fwrite(&sr, 4, 1, of);
+ fwrite(&ch, 2, 1, of);
+ fwrite(&fs, 2, 1, of);
+
+ uint8_t packet[MAX_PACKET];
+ int frame_count = 0;
+ size_t pcm_off = 0;
+ size_t total_samples = g_rec->channels == 1 ? final_pcm_count : final_pcm_count / g_rec->channels;
+
+ while (pcm_off + (size_t)g_rec->frame_samples * g_rec->channels <= final_pcm_count) {
+ int len = opus_codec_encode(enc, final_pcm + pcm_off, g_rec->frame_samples, packet, MAX_PACKET);
+ pcm_off += (size_t)g_rec->frame_samples * g_rec->channels;
+ if (len > 0) {
+ uint16_t plen = (uint16_t)len;
+ fwrite(&plen, 2, 1, of);
+ fwrite(packet, 1, (size_t)len, of);
+ frame_count++;
+ } else if (len < 0) {
+ DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: encode error frame %d: %d", frame_count, len);
+ }
+ }
+ uint16_t zero = 0;
+ fwrite(&zero, 2, 1, of);
+ fclose(of);
+
+ opus_codec_encoder_destroy(enc);
+
+ float duration_sec = (float)frame_count * FRAME_MS / 1000.0f;
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: %d frames (%.1fs) dur=%lldms compressor=%d",
+ frame_count, duration_sec, (long long)elapsed_ms, g_rec->compressor_enabled);
+
+ if (frame_count <= 0) {
+ unlink(temp_path);
+ voice_cleanup_locked(g_rec);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return -1;
+ }
+
+ /* get file size and calculate blocks */
+ FILE* fsize = fopen(temp_path, "rb"); uint64_t file_size = 0;
+ if (fsize) { fseek(fsize, 0, SEEK_END); file_size = (uint64_t)ftell(fsize); fclose(fsize); }
+
+ uint32_t block_size = media_calc_block_size(file_size);
+ int num_blocks = (int)((file_size + block_size - 1) / block_size);
+
+ /* split file into blocks */
+ FILE* src = fopen(temp_path, "rb");
+ if (!src) { unlink(temp_path); voice_cleanup_locked(g_rec); pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; }
+
+ for (int n = 0; n < num_blocks; n++) {
+ char block_path[1280];
+ snprintf(block_path, sizeof(block_path), "%s/%s_%d_%s_%s.opus",
+ media_dir, dt, n, basename, suffix);
+ FILE* dst = fopen(block_path, "wb");
+ if (!dst) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: cannot create block %s", block_path); continue; }
+ uint8_t buf[65536];
+ uint64_t remaining = block_size;
+ while (remaining > 0) {
+ size_t rd = fread(buf, 1, remaining < sizeof(buf) ? (size_t)remaining : sizeof(buf), src);
+ if (rd == 0) break;
+ fwrite(buf, 1, rd, dst);
+ remaining -= rd;
+ }
+ fclose(dst);
+ }
+ fclose(src);
+ unlink(temp_path);
+
+ /* build and post chat_msg_submit */
+ struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + 1);
+ if (!req) { voice_cleanup_locked(g_rec); pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; }
+
+ snprintf(req->channel_id, sizeof(req->channel_id), "%s", g_rec->channel_id);
+ snprintf(req->content_type, sizeof(req->content_type), "audio/opus");
+ snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt);
+ snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename);
+ snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix);
+ snprintf(req->media_ext, sizeof(req->media_ext), "opus");
+ req->media_num_blocks = (uint32_t)num_blocks;
+ req->data = (uint8_t*)(req + 1);
+ req->data_len = 0;
+ req->timestamp = 0;
+
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: posting media ch=%s dt=%s stem=%s blocks=%d",
+ req->channel_id, dt, basename, num_blocks);
+
+ uasync_post(ua, chat_core_submit_trampoline, req);
+
+ voice_cleanup_locked(g_rec);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+ return 0;
+}
+
+void voice_recorder_cancel(void) {
+ pthread_mutex_lock(&g_init_mtx);
+ if (!g_rec) { pthread_mutex_unlock(&g_init_mtx); return; }
+ pthread_mutex_lock(&g_rec->mtx);
+ if (!g_rec->active) { pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return; }
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_cancel");
+ voice_cleanup_locked(g_rec);
+ pthread_mutex_unlock(&g_rec->mtx);
+ pthread_mutex_unlock(&g_init_mtx);
+}
+
+int voice_recorder_is_active(void) {
+ int active = 0;
+ pthread_mutex_lock(&g_init_mtx);
+ if (g_rec) {
+ pthread_mutex_lock(&g_rec->mtx);
+ active = g_rec->active;
+ pthread_mutex_unlock(&g_rec->mtx);
+ }
+ pthread_mutex_unlock(&g_init_mtx);
+ return active;
+}
+
+void voice_recorder_set_preset(int preset) {
+ if (preset < 0) preset = 0; if (preset > 2) preset = 2;
+ pthread_mutex_lock(&g_init_mtx);
+ if (g_rec) {
+ pthread_mutex_lock(&g_rec->mtx);
+ g_rec->preset = preset;
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_set_preset: %d (%d kbps)", preset, s_bitrates[preset] / 1000);
+ pthread_mutex_unlock(&g_rec->mtx);
+ }
+ pthread_mutex_unlock(&g_init_mtx);
+}
+
+int voice_recorder_get_preset(void) {
+ int p = 1;
+ pthread_mutex_lock(&g_init_mtx);
+ if (g_rec) { pthread_mutex_lock(&g_rec->mtx); p = g_rec->preset; pthread_mutex_unlock(&g_rec->mtx); }
+ pthread_mutex_unlock(&g_init_mtx);
+ return p;
+}
+
+void voice_recorder_set_compressor_enabled(int enabled) {
+ pthread_mutex_lock(&g_init_mtx);
+ if (g_rec) {
+ pthread_mutex_lock(&g_rec->mtx);
+ g_rec->compressor_enabled = enabled ? 1 : 0;
+ DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_set_compressor: %s", g_rec->compressor_enabled ? "on" : "off");
+ pthread_mutex_unlock(&g_rec->mtx);
+ }
+ pthread_mutex_unlock(&g_init_mtx);
+}
+
+int voice_recorder_is_compressor_enabled(void) {
+ int e = 1;
+ pthread_mutex_lock(&g_init_mtx);
+ if (g_rec) { pthread_mutex_lock(&g_rec->mtx); e = g_rec->compressor_enabled; pthread_mutex_unlock(&g_rec->mtx); }
+ pthread_mutex_unlock(&g_init_mtx);
+ return e;
+}
diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.h b/tools/chatgui-android/libutun_lite/voice_recorder.h
new file mode 100644
index 00000000..6404d3cf
--- /dev/null
+++ b/tools/chatgui-android/libutun_lite/voice_recorder.h
@@ -0,0 +1,31 @@
+#ifndef VOICE_RECORDER_H
+#define VOICE_RECORDER_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct voice_recorder;
+
+int voice_recorder_init(const char* db_path);
+void voice_recorder_deinit(void);
+
+int voice_recorder_start(const char* channel_id, int sample_rate, int channels);
+int voice_recorder_feed(const int16_t* samples, int count);
+int voice_recorder_stop(int* out_duration_ms);
+void voice_recorder_cancel(void);
+int voice_recorder_is_active(void);
+
+void voice_recorder_set_preset(int preset);
+int voice_recorder_get_preset(void);
+void voice_recorder_set_compressor_enabled(int enabled);
+int voice_recorder_is_compressor_enabled(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif