Browse Source

android: voice messages recording + playback, file attachments

- C99 port of AudioCompressor (libutun_lite/audio_compressor.h/c)
- Voice recorder: PCM buffer + compressor + Opus encode + block splitting + uasync send
- Attachment sender: file splitting into blocks + uasync send
- Bridge API + JNI: voice start/feed/stop/cancel, attachment send, Opus decode for playback
- Kotlin: AudioRecorderManager (AudioRecord + JNI feed), AudioPlayer (AudioTrack + JNI decode)
- UI: PTT button with press-to-talk, attach button with file picker, recording overlay
- Message display: voice messages (play button), file attachments (icon + name)
- Settings: Opus preset selector (Low/Standard/High), compressor toggle
- AndroidManifest: RECORD_AUDIO, storage permissions
topo_upd
evgeny 2 months ago
parent
commit
5b6979da27
  1. 3
      tools/chatgui-android/app/src/main/AndroidManifest.xml
  2. 1
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  3. 189
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt
  4. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  5. 38
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  6. 130
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  7. 24
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  8. 31
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  9. 47
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  10. 266
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  11. 24
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  12. 123
      tools/chatgui-android/libutun_lite/attachment_sender.c
  13. 14
      tools/chatgui-android/libutun_lite/attachment_sender.h
  14. 321
      tools/chatgui-android/libutun_lite/audio_compressor.c
  15. 43
      tools/chatgui-android/libutun_lite/audio_compressor.h
  16. 3
      tools/chatgui-android/libutun_lite/utun_sources.cmake
  17. 448
      tools/chatgui-android/libutun_lite/voice_recorder.c
  18. 31
      tools/chatgui-android/libutun_lite/voice_recorder.h

3
tools/chatgui-android/app/src/main/AndroidManifest.xml

@ -3,8 +3,11 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<application
android:name=".ChatApplication"

1
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -67,6 +67,7 @@ class MainActivity : ComponentActivity() {
channel = channel,
messages = messages,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onBack = { screen = Screen.ChannelList }
)

189
tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt

@ -0,0 +1,189 @@
package com.utun.chat.data
import android.Manifest
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
class AudioRecorderManager {
companion object {
const val SAMPLE_RATE = 48000
const val CHANNELS = 1
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
const val MIN_DURATION_MS = 500
}
private var audioRecord: AudioRecord? = null
private var recordingThread: Thread? = null
@Volatile private var isRecording = false
fun hasPermission(): Boolean {
val ctx = ChatApplication.instance
return ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
}
fun startRecording(channelId: String): Boolean {
if (isRecording) return false
if (!hasPermission()) return false
val ok = NativeLib.voiceStart(channelId)
if (!ok) {
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed")
return false
}
val bufferSize = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
).coerceAtLeast(FRAME_SAMPLES * 2)
audioRecord = try {
AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize * 2
)
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord create failed: ${e.message}")
NativeLib.voiceCancel()
return false
}
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord not initialized")
audioRecord?.release()
audioRecord = null
NativeLib.voiceCancel()
return false
}
isRecording = true
LogManager.addLog("INFO", "AudioRecorder", "started ch=$channelId bufferSize=$bufferSize")
audioRecord?.startRecording()
recordingThread = Thread {
val buffer = ShortArray(FRAME_SAMPLES)
var totalRead = 0L
while (isRecording && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
val read = audioRecord?.read(buffer, 0, FRAME_SAMPLES) ?: break
if (read <= 0) break
NativeLib.voiceFeed(buffer.copyOf(read))
totalRead += read
}
LogManager.addLog("DEBUG", "AudioRecorder", "thread done totalSamples=$totalRead")
}.apply {
priority = Thread.MAX_PRIORITY
start()
}
return true
}
fun stopRecording(): Int {
if (!isRecording) return 0
isRecording = false
recordingThread?.join(1000)
recordingThread = null
audioRecord?.apply {
try { stop() } catch (_: Exception) {}
release()
}
audioRecord = null
val duration = NativeLib.voiceStop()
LogManager.addLog("INFO", "AudioRecorder", "stopped duration=${duration}ms")
return duration
}
fun cancelRecording() {
if (!isRecording) return
isRecording = false
recordingThread?.join(1000)
recordingThread = null
audioRecord?.apply {
try { stop() } catch (_: Exception) {}
release()
}
audioRecord = null
NativeLib.voiceCancel()
LogManager.addLog("INFO", "AudioRecorder", "cancelled")
}
fun isActive(): Boolean = isRecording
@Volatile private var playing = false
private var playThread: Thread? = null
fun playVoiceFile(filePath: String, onProgress: (Float) -> 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
}
}

7
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) =

38
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)
}

130
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

24
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<Message>,
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()
)
}

31
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)

47
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<Boolean> = _isStuck
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _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

266
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__ */

24
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);

123
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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include <unistd.h>
#include <libgen.h>
#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;
}

14
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

321
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 <math.h>
#include <string.h>
#include <stdlib.h>
#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;
}

43
tools/chatgui-android/libutun_lite/audio_compressor.h

@ -0,0 +1,43 @@
#ifndef AUDIO_COMPRESSOR_H
#define AUDIO_COMPRESSOR_H
#include <stdint.h>
#include <stddef.h>
#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

3
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 ──

448
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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <pthread.h>
#include <sys/stat.h>
#include <unistd.h>
#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;
}

31
tools/chatgui-android/libutun_lite/voice_recorder.h

@ -0,0 +1,31 @@
#ifndef VOICE_RECORDER_H
#define VOICE_RECORDER_H
#include <stdint.h>
#include <stddef.h>
#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
Loading…
Cancel
Save