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