From 1f8f41bda7eb5495fe0f4dbc196dced9e2619a72 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 28 Jul 2026 23:33:19 +0300 Subject: [PATCH] android: tabbed settings (Settings/Audio) with mic test, codec preset, compressor sliders - voice_recorder: compressor config API (max_gain, lookback, lookahead, rise_rate, target_level) - bridge/JNI: compressor_config export - AudioRecorderManager: startMicTest/stopMicTest for level preview - SettingsScreen: TabRow with Settings + Audio tabs - Audio tab: mic test with live level bar, Opus preset selector, 5 compressor sliders matching desktop --- .../utun/chat/data/AudioRecorderManager.kt | 53 ++ .../main/java/com/utun/chat/data/NativeLib.kt | 8 + .../utun/chat/ui/screens/SettingsScreen.kt | 679 +++++++++--------- .../com/utun/chat/viewmodel/ChatViewModel.kt | 4 + .../jni_bridge/android_jni_bridge.c | 13 + .../jni_bridge/android_jni_bridge.h | 2 + .../libutun_lite/voice_recorder.c | 22 + .../libutun_lite/voice_recorder.h | 2 + 8 files changed, 431 insertions(+), 352 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index 9dd6d177..d9cc22f5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -23,6 +23,11 @@ class AudioRecorderManager { private var recordingThread: Thread? = null @Volatile private var isRecording = false + private var testRecord: AudioRecord? = null + private var testThread: Thread? = null + @Volatile var isTesting = false + private set + fun hasPermission(): Boolean { val ctx = ChatApplication.instance return ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) == @@ -127,6 +132,54 @@ class AudioRecorderManager { fun isActive(): Boolean = isRecording + fun startMicTest(): Boolean { + if (isTesting) return true + if (!hasPermission()) return false + + NativeLib.voiceStart("__test__") + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ).coerceAtLeast(FRAME_SAMPLES * 2) + + testRecord = try { + AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize * 2 + ) + } catch (e: Exception) { + NativeLib.voiceCancel(); return false + } + if (testRecord?.state != AudioRecord.STATE_INITIALIZED) { + testRecord?.release(); testRecord = null; NativeLib.voiceCancel(); return false + } + + isTesting = true + testRecord?.startRecording() + + testThread = Thread { + val buffer = ShortArray(FRAME_SAMPLES) + while (isTesting && testRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + val read = testRecord?.read(buffer, 0, FRAME_SAMPLES) ?: break + if (read <= 0) break + NativeLib.voiceFeed(buffer.copyOf(read)) + } + }.apply { priority = Thread.MAX_PRIORITY; start() } + return true + } + + fun stopMicTest() { + isTesting = false + testThread?.join(1000); testThread = null + testRecord?.apply { try { stop() } catch (_: Exception) {}; release() } + testRecord = null + NativeLib.voiceCancel() + } + @Volatile private var playing = false private var playThread: Thread? = null 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 b22258bb..7c966f23 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 @@ -88,6 +88,10 @@ object NativeLib { fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) } fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor() fun voiceGetPeakLevel(): Float = nativeVoiceGetPeakLevel() + fun voiceSetCompressorConfig(maxGainDb: Int, lookbackMs: Int, lookaheadMs: Int, + riseRate: Float, targetLevelDb: Float) { + nativeVoiceSetCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb) + } /* ── Attachment ── */ fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath) @@ -130,6 +134,10 @@ object NativeLib { private external fun nativeVoiceSetCompressor(enabled: Boolean) private external fun nativeVoiceGetCompressor(): Boolean private external fun nativeVoiceGetPeakLevel(): Float + private external fun nativeVoiceSetCompressorConfig( + maxGainDb: Int, lookbackMs: Int, lookaheadMs: Int, + riseRate: Float, targetLevelDb: Float + ) /* ── Attachment JNI ── */ private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean 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 ac6ca5bf..ec00fa39 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 @@ -6,16 +6,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.PowerSettingsNew -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.utun.chat.ChatApplication @@ -23,6 +20,7 @@ import com.utun.chat.data.NativeLib import com.utun.chat.data.ServerEntry import com.utun.chat.viewmodel.ChatViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID @@ -43,12 +41,57 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { val scope = rememberCoroutineScope() var dirty by remember { mutableStateOf(false) } + var tabIndex by remember { mutableStateOf(0) } + val tabs = listOf("Settings", "Audio") + + val handleBack = { + if (dirty) { + scope.launch(Dispatchers.IO) { + val path = provider.getString("db.path") ?: "utun_chat.db" + val port = provider.getInt("control.port") + val configText = provider.buildConfigText() + NativeLib.restart(path, port, configText) + } + } + onBack() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + TabRow(selectedTabIndex = tabIndex) { + tabs.forEachIndexed { idx, name -> + Tab(selected = tabIndex == idx, onClick = { tabIndex = idx }, text = { Text(name) }) + } + } + when (tabIndex) { + 0 -> GeneralSettingsTab(vm, dirty, onDirty = { dirty = it }, onExit) + 1 -> AudioSettingsTab(vm) + } + } + } +} + +/* ── General Settings Tab ── */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Boolean) -> Unit, + onExit: () -> Unit) { + val provider = ChatApplication.instance.configProvider + val scope = rememberCoroutineScope() + var nodeName by remember { mutableStateOf("") } var listenPort by remember { mutableStateOf("12345") } var controlPort by remember { mutableStateOf("9999") } var controlBindIp by remember { mutableStateOf("127.0.0.1") } var ctrlBindLabel by remember { mutableStateOf("lo (127.0.0.1)") } - var dbPath by remember { mutableStateOf("") } var debugLevel by remember { mutableStateOf("info") } var logFile by remember { mutableStateOf("") } var logUdpIp by remember { mutableStateOf("") } @@ -61,7 +104,6 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { var showClearDb by remember { mutableStateOf(false) } var clearingDb by remember { mutableStateOf(false) } var catLevels by remember { mutableStateOf>(emptyMap()) } - var servers by remember { mutableStateOf>(emptyList()) } var availableIfaces by remember { mutableStateOf>>(emptyList()) } @@ -72,406 +114,340 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = { controlBindIp = provider.getString("control.bind_ip") ?: "127.0.0.1" val allIface = listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() ctrlBindLabel = allIface.firstOrNull { it.second == controlBindIp }?.let { "${it.first} (${it.second})" } ?: "lo (127.0.0.1)" - dbPath = provider.getString("db.path") ?: "utun_chat.db" debugLevel = provider.getString("debug.console_level") ?: "info" logFile = provider.getString("debug.log_file") ?: "utun.log" logUdpIp = provider.getString("log_udp.ip") ?: "192.168.40.250" logUdpPort = provider.getInt("log_udp.port").toString().let { if (it == "0") "9999" else it } val port = logUdpPort.toIntOrNull() ?: 0 - if (logUdpIp.isNotEmpty() && port > 0) { - NativeLib.setUdpLogTarget(logUdpIp, port) - } + if (logUdpIp.isNotEmpty() && port > 0) NativeLib.setUdpLogTarget(logUdpIp, port) val stored = provider.getString("debug.categories") ?: "" - catLevels = if (stored.isNotEmpty()) { - stored.split(",").associate { pair -> - val (k, v) = pair.split("=", limit = 2) - k to v - } - } else emptyMap() + catLevels = if (stored.isNotEmpty()) stored.split(",").associate { it.split("=", limit = 2).let { (k, v) -> k to v } } else emptyMap() servers = provider.loadServers() availableIfaces = provider.getAvailableInterfaces() } - val save = { s: List -> - dirty = true - servers = s - scope.launch { provider.saveServers(s) } - } - + val save = { s: List -> onDirty(true); servers = s; scope.launch { provider.saveServers(s) } } val setDebug = { level: String -> - dirty = true - NativeLib.setDebugLevel("", level) - scope.launch { provider.setValue("debug.console_level", level) } - debugLevel = level + onDirty(true); NativeLib.setDebugLevel("", level) + scope.launch { provider.setValue("debug.console_level", level) }; debugLevel = level } - val setCatLevel = { cat: String, lvl: String -> - dirty = true + onDirty(true) val updated = catLevels.toMutableMap() if (lvl == "") updated.remove(cat) else updated[cat] = lvl catLevels = updated val csv = updated.entries.joinToString(",") { "${it.key}=${it.value}" } scope.launch { provider.setValue("debug.categories", csv) } - if (lvl.isEmpty()) NativeLib.setDebugLevel(cat, "NONE") - else NativeLib.setDebugLevel(cat, lvl) + if (lvl.isEmpty()) NativeLib.setDebugLevel(cat, "NONE") else NativeLib.setDebugLevel(cat, lvl) } - val handleBack = { - if (dirty) { - scope.launch(Dispatchers.IO) { - val path = provider.getString("db.path") ?: "utun_chat.db" - val port = provider.getInt("control.port") - val configText = provider.buildConfigText() - NativeLib.restart(path, port, configText) - } - } - onBack() - } + Column( + modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + /* Node */ + Text("Node", style = MaterialTheme.typography.titleMedium) + OutlinedTextField(nodeName, { v -> onDirty(true); nodeName = v; scope.launch { provider.setValue("node.name", v) } }, + label = { Text("Node Name") }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) + OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } }, + label = { Text("Listen Port") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, modifier = Modifier.heightIn(min = 40.dp)) - Scaffold( - topBar = { - TopAppBar( - title = { Text("Settings") }, - navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.Default.ArrowBack, "Back") } } - ) - } - ) { padding -> - Column( - modifier = Modifier.padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text("Node", style = MaterialTheme.typography.titleMedium) - OutlinedTextField(nodeName, { v -> dirty = true; nodeName = v; scope.launch { provider.setValue("node.name", v) } }, - label = { Text("Node Name") }, singleLine = true, - modifier = Modifier.heightIn(min = 40.dp)) - OutlinedTextField(listenPort, { v -> dirty = true; listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } }, - label = { Text("Listen Port") }, - modifier = Modifier.heightIn(min = 40.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) - // Control socket interface (including lo0) - var ctrlExpanded by remember { mutableStateOf(false) } - var ctrlBindLabel by remember { mutableStateOf("lo (127.0.0.1)") } - val ctrlIfaces = remember { listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() } - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, - modifier = Modifier.weight(1f)) { - OutlinedTextField( - value = ctrlBindLabel, onValueChange = {}, - readOnly = true, singleLine = true, textStyle = MaterialTheme.typography.bodyLarge, - label = { Text("Control Bind IP") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) }, - modifier = Modifier.menuAnchor().heightIn(min = 40.dp) - ) - ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) { - ctrlIfaces.forEach { (name, ip) -> - val label = "$name ($ip)" - DropdownMenuItem(text = { Text(label) }, onClick = { - controlBindIp = ip - ctrlBindLabel = label - dirty = true - scope.launch { provider.setValue("control.bind_ip", ip) } - ctrlExpanded = false - }) - } + /* Control interface */ + var ctrlExpanded by remember { mutableStateOf(false) } + val ctrlIfaces = remember { listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() } + Row(verticalAlignment = Alignment.CenterVertically) { + ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, modifier = Modifier.weight(1f)) { + OutlinedTextField(ctrlBindLabel, {}, readOnly = true, singleLine = true, + label = { Text("Control Bind IP") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) { + ctrlIfaces.forEach { (n, ip) -> + DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = { + controlBindIp = ip; ctrlBindLabel = "$n ($ip)"; onDirty(true) + scope.launch { provider.setValue("control.bind_ip", ip) }; ctrlExpanded = false + }) } } - Spacer(Modifier.width(8.dp)) - OutlinedTextField(controlPort, { v -> dirty = true; controlPort = v; scope.launch { provider.setValue("control.port", v) } }, - label = { Text("Port") }, - modifier = Modifier.width(80.dp).heightIn(min = 40.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) } + Spacer(Modifier.width(8.dp)) + OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } }, + label = { Text("Port") }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + } - Text("Keys", style = MaterialTheme.typography.titleMedium) - OutlinedTextField(pubKey, {}, label = { Text("Public Key") }, readOnly = true, singleLine = true) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - if (pubKey.length == 64) showOverwriteKeys = true - else { dirty = true; vm.requestRegenerateKeys() } - }, enabled = !generatingKeys) { - Text(if (generatingKeys) "Generating..." else "Generate Keys") - } - Button(onClick = { showClearDb = true }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { - Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(4.dp)) - Text("Clear DB") - } + /* Keys */ + Text("Keys", style = MaterialTheme.typography.titleMedium) + OutlinedTextField(pubKey, {}, label = { Text("Public Key") }, readOnly = true, singleLine = true) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { if (pubKey.length == 64) showOverwriteKeys = true else { onDirty(true); vm.requestRegenerateKeys() } }, + enabled = !generatingKeys) { Text(if (generatingKeys) "Generating..." else "Generate Keys") } + Button(onClick = { showClearDb = true }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Clear DB") } + } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - // ── Network Interfaces ── - Text("Network Interfaces", style = MaterialTheme.typography.titleMedium) - servers.forEachIndexed { idx, srv -> - Column(modifier = Modifier.fillMaxWidth()) { - // Line 1: [ifName ▾] IP [✕] - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - var ifExpanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it }, - modifier = Modifier.weight(1f)) { - OutlinedTextField( - value = srv.ifName, onValueChange = {}, - readOnly = true, singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) }, - modifier = Modifier.menuAnchor().heightIn(min = 40.dp) - ) - ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) { - availableIfaces.forEach { (name, ip) -> - DropdownMenuItem(text = { Text("$name ($ip)") }, onClick = { - save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = name, bindIp = ip) }) - ifExpanded = false - }) - } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + /* Network Interfaces */ + Text("Network Interfaces", style = MaterialTheme.typography.titleMedium) + servers.forEachIndexed { idx, srv -> + Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + var ifExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it }, modifier = Modifier.weight(1f)) { + OutlinedTextField(srv.ifName, {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) { + availableIfaces.forEach { (n, ip) -> + DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = { + save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = n, bindIp = ip) }); ifExpanded = false + }) } } - Spacer(Modifier.width(4.dp)) - Text(srv.bindIp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - modifier = Modifier.width(105.dp)) - IconButton(onClick = { - save(servers.toMutableList().also { it.removeAt(idx) }) - }, modifier = Modifier.size(28.dp)) { - Icon(Icons.Default.Close, "Remove", modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.error) - } } - // Line 2: [TCP ▾] port - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - var protoExpanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it }, - modifier = Modifier.width(100.dp)) { - OutlinedTextField( - value = srv.protocol.uppercase(), onValueChange = {}, - readOnly = true, singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(protoExpanded) }, - modifier = Modifier.menuAnchor().heightIn(min = 40.dp) - ) - ExposedDropdownMenu(protoExpanded, onDismissRequest = { protoExpanded = false }) { - PROTOCOLS.forEach { p -> - DropdownMenuItem(text = { Text(p.uppercase()) }, onClick = { - save(servers.toMutableList().also { it[idx] = srv.copy(protocol = p) }) - protoExpanded = false - }) - } + Spacer(Modifier.width(4.dp)) + Text(srv.bindIp, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, + maxLines = 1, modifier = Modifier.width(105.dp)) + IconButton(onClick = { save(servers.toMutableList().also { it.removeAt(idx) }) }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, "Remove", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + var protoExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it }, modifier = Modifier.width(100.dp)) { + OutlinedTextField(srv.protocol.uppercase(), {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(protoExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(protoExpanded, onDismissRequest = { protoExpanded = false }) { + PROTOCOLS.forEach { p -> + DropdownMenuItem(text = { Text(p.uppercase()) }, onClick = { + save(servers.toMutableList().also { it[idx] = srv.copy(protocol = p) }); protoExpanded = false + }) } } - Spacer(Modifier.width(4.dp)) - var portText by remember(srv.id) { mutableStateOf(srv.port.toString()) } - OutlinedTextField( - value = portText, singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - onValueChange = { v -> - portText = v - val p = v.toIntOrNull() - if (p != null && p in 1..65535) - save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) - }, - modifier = Modifier.width(80.dp).heightIn(min = 40.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) } + Spacer(Modifier.width(4.dp)) + var portText by remember(srv.id) { mutableStateOf(srv.port.toString()) } + OutlinedTextField(portText, { v -> portText = v; val p = v.toIntOrNull(); if (p != null && p in 1..65535) + save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) }, + singleLine = true, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) } - Spacer(Modifier.height(2.dp)) - } - // Add button - TextButton( - onClick = { - val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1") - val rndPort = (2000..9999).random() - save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort)) - }, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(4.dp)) - Text("Add interface") } + Spacer(Modifier.height(2.dp)) + } + TextButton(onClick = { + val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1") + val rndPort = (2000..9999).random() + save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort)) + }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Add interface") } - 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)) - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - // ── Debug ── - Text("Debug", style = MaterialTheme.typography.titleMedium) - DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug) - OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } }, - label = { Text("Log File") }, singleLine = true) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(logUdpIp, { v -> - logUdpIp = v - scope.launch { provider.setValue("log_udp.ip", v) } - val port = logUdpPort.toIntOrNull() ?: 0 - if (v.isNotEmpty() && port > 0) NativeLib.setUdpLogTarget(v, port) - else NativeLib.setUdpLogTarget("", 0) - }, - label = { Text("UDP Log IP") }, - modifier = Modifier.weight(1f).heightIn(min = 40.dp), - singleLine = true) - OutlinedTextField(logUdpPort, { v -> - logUdpPort = v - val port = v.toIntOrNull() ?: 0 - scope.launch { provider.setValue("log_udp.port", v) } - if (logUdpIp.isNotEmpty() && port > 0 && port <= 65535) - NativeLib.setUdpLogTarget(logUdpIp, port) - else - NativeLib.setUdpLogTarget("", 0) - }, - label = { Text("Port") }, - modifier = Modifier.width(80.dp).heightIn(min = 40.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true) + /* Debug */ + Text("Debug", style = MaterialTheme.typography.titleMedium) + DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug) + OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } }, + label = { Text("Log File") }, singleLine = true) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(logUdpIp, { v -> + logUdpIp = v; scope.launch { provider.setValue("log_udp.ip", v) } + val p = logUdpPort.toIntOrNull() ?: 0 + if (v.isNotEmpty() && p > 0) NativeLib.setUdpLogTarget(v, p) else NativeLib.setUdpLogTarget("", 0) + }, label = { Text("UDP Log IP") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true) + OutlinedTextField(logUdpPort, { v -> + logUdpPort = v; val p = v.toIntOrNull() ?: 0; scope.launch { provider.setValue("log_udp.port", v) } + if (logUdpIp.isNotEmpty() && p > 0 && p <= 65535) NativeLib.setUdpLogTarget(logUdpIp, p) else NativeLib.setUdpLogTarget("", 0) + }, label = { Text("Port") }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) + } + Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories }, + horizontalArrangement = Arrangement.SpaceBetween) { + Text("Categories", style = MaterialTheme.typography.titleMedium) + Icon(Icons.Default.ArrowDropDown, if (showDebugCategories) "Collapse" else "Expand", modifier = Modifier.padding(end = 4.dp)) + } + if (showDebugCategories) { + CATEGORIES.forEach { cat -> + val current = catLevels[cat] ?: "" + DebugLevelDropdown(label = cat, current = current.ifEmpty { "(global)" }, + onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true) } + } + Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories }, - horizontalArrangement = Arrangement.SpaceBetween) { - Text("Categories", style = MaterialTheme.typography.titleMedium) - Icon(Icons.Default.ArrowDropDown, - contentDescription = if (showDebugCategories) "Collapse" else "Expand", - modifier = Modifier.padding(end = 4.dp)) - } - if (showDebugCategories) { - CATEGORIES.forEach { cat -> - val current = catLevels[cat] ?: "" - DebugLevelDropdown(label = cat, current = current.ifEmpty { "(global)" }, - onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true) - } - } - Spacer(Modifier.height(16.dp)) - - Button( - onClick = { - scope.launch(Dispatchers.IO) { - val path = provider.getString("db.path") ?: "" - val port = provider.getInt("control.port") - val configText = provider.buildConfigText() - NativeLib.restart(path, port, configText) - } - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) - ) { - Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Restart instance") + Button(onClick = { + scope.launch(Dispatchers.IO) { + val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") + NativeLib.restart(path, port, provider.buildConfigText()) } + }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) { + Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Restart instance") + } + Spacer(Modifier.height(8.dp)) + Button(onClick = { NativeLib.stop(); onExit() }, modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Stop All & Exit") + } + Spacer(Modifier.height(16.dp)) + } + + /* Dialogs */ + if (showOverwriteKeys) AlertDialog( + onDismissRequest = { showOverwriteKeys = false }, title = { Text("Overwrite Keys?") }, + text = { Text("Keys already exist. Generating new keys will invalidate all existing connections and channels.") }, + confirmButton = { Button(onClick = { showOverwriteKeys = false; onDirty(true); vm.requestRegenerateKeys() }) { Text("Overwrite") } }, + dismissButton = { TextButton(onClick = { showOverwriteKeys = false }) { Text("Cancel") } }) - Spacer(Modifier.height(8.dp)) - - Button( - onClick = { - NativeLib.stop() - onExit() - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) - ) { - Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Stop All & Exit") + if (showClearDb) AlertDialog( + onDismissRequest = { showClearDb = false }, title = { Text("Clear Database?") }, + text = { Text("This will delete all channels, messages, and peers.\nThis cannot be undone.") }, + confirmButton = { Button(onClick = { + showClearDb = false; clearingDb = true + scope.launch(Dispatchers.IO) { + vm.clearState(); NativeLib.stop(); NativeLib.clearDatabase() + val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") + NativeLib.restart(path, port, provider.buildConfigText()) + withContext(Dispatchers.Main) { clearingDb = false } } + }, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Delete All") } }, + dismissButton = { TextButton(onClick = { showClearDb = false }) { Text("Cancel") } }) +} + +/* ── Audio Settings Tab ── */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AudioSettingsTab(vm: ChatViewModel) { + val scope = rememberCoroutineScope() - Spacer(Modifier.height(16.dp)) + /* Mic test state */ + var micTesting by remember { mutableStateOf(false) } + var micLevel by remember { mutableStateOf(0f) } + + LaunchedEffect(micTesting) { + if (micTesting) { + while (true) { micLevel = NativeLib.voiceGetPeakLevel(); delay(25) } } } - if (showOverwriteKeys) { - AlertDialog( - onDismissRequest = { showOverwriteKeys = false }, - title = { Text("Overwrite Keys?") }, - text = { Text("Keys already exist. Generating new keys will invalidate all existing connections and channels.") }, - confirmButton = { - Button(onClick = { - showOverwriteKeys = false - dirty = true - vm.requestRegenerateKeys() - }) { Text("Overwrite") } - }, - dismissButton = { - TextButton(onClick = { showOverwriteKeys = false }) { Text("Cancel") } - } - ) + /* Codec preset */ + val presets = listOf("Low (16 kbps)", "Standard (32 kbps)", "High (64 kbps)") + var preset by remember { mutableStateOf(vm.getVoicePreset()) } + var presetExpanded by remember { mutableStateOf(false) } + + /* Compressor params with defaults from desktop */ + var compressorOn by remember { mutableStateOf(vm.getVoiceCompressor()) } + var maxGainDb by remember { mutableStateOf(30) } + var lookbackMs by remember { mutableStateOf(200) } + var lookaheadMs by remember { mutableStateOf(100) } + var riseRateTenths by remember { mutableStateOf(20) } + var targetLevelDb by remember { mutableStateOf(-12) } + + val apply = { + vm.setVoicePreset(preset) + vm.setVoiceCompressor(compressorOn) + vm.setCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRateTenths / 10f, targetLevelDb.toFloat()) } - if (showClearDb) { - AlertDialog( - onDismissRequest = { showClearDb = false }, - title = { Text("Clear Database?") }, - text = { Text("This will delete all channels, messages, and peers.\nThis cannot be undone.") }, - confirmButton = { - Button(onClick = { - showClearDb = false - clearingDb = true - scope.launch(Dispatchers.IO) { - vm.clearState() - NativeLib.stop() - NativeLib.clearDatabase() - val path = provider.getString("db.path") ?: "" - val port = provider.getInt("control.port") - val configText = provider.buildConfigText() - NativeLib.restart(path, port, configText) - withContext(Dispatchers.Main) { clearingDb = false } - } - }, enabled = !clearingDb, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { - Text("Delete All") + Column( + modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + /* ── Microphone test ── */ + Text("Microphone test", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val lvlPct = micLevel.coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { lvlPct }, + modifier = Modifier.weight(1f).height(16.dp), + color = when { lvlPct < 0.6f -> Color(0xFF27AE60); lvlPct < 0.85f -> Color(0xFFF1C40F); else -> Color(0xFFE53935) }, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + Button(onClick = { + if (micTesting) { vm.audioRecorder.stopMicTest(); micTesting = false; micLevel = 0f } + else { + if (vm.audioRecorder.startMicTest()) { micTesting = true; micLevel = 0f } + } + apply() + }) { Text(if (micTesting) "Stop" else "Test") } + } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + /* ── Voice codec quality ── */ + Text("Voice codec quality", style = MaterialTheme.typography.titleMedium) + ExposedDropdownMenuBox(expanded = presetExpanded, onExpandedChange = { presetExpanded = it }) { + OutlinedTextField(presets[preset], {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(presetExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(presetExpanded, onDismissRequest = { presetExpanded = false }) { + presets.forEachIndexed { i, n -> + DropdownMenuItem(text = { Text(n) }, onClick = { preset = i; apply(); presetExpanded = false }) } - }, - dismissButton = { - TextButton(onClick = { showClearDb = false }) { Text("Cancel") } } + } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + /* ── Voice Compressor ── */ + Text("Voice Compressor", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Enable compressor", modifier = Modifier.weight(1f)) + Switch(checked = compressorOn, onCheckedChange = { compressorOn = it; apply() }) + } + + LabeledSlider("Max gain:", maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB", + { maxGainDb = it.toInt(); apply() }) + LabeledSlider("Lookback time:", lookbackMs.toFloat(), 50f..500f, 45, "${lookbackMs} ms", + { lookbackMs = it.toInt(); apply() }) + LabeledSlider("Lookahead time:", lookaheadMs.toFloat(), 20f..500f, 48, "${lookaheadMs} ms", + { lookaheadMs = it.toInt(); apply() }) + LabeledSlider("Rise rate (x/500ms):", riseRateTenths.toFloat(), 11f..100f, 89, + String.format("%.1fx", riseRateTenths / 10f), + { riseRateTenths = it.toInt(); apply() }) + LabeledSlider("Target level:", targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS", + { targetLevelDb = it.toInt(); apply() }) + } +} + +/* ── Labeled Slider ── */ + +@Composable +private fun LabeledSlider( + label: String, value: Float, range: ClosedFloatingPointRange, + steps: Int, displayValue: String, onValueChange: (Float) -> Unit +) { + Column { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(displayValue, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Slider( + value = value, onValueChange = onValueChange, + valueRange = range, steps = steps, + modifier = Modifier.fillMaxWidth() ) } } +/* ── Debug Level Dropdown ── */ + @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DebugLevelDropdown( - label: String, current: String, onSelect: (String) -> Unit, compact: Boolean = false -) { +private fun DebugLevelDropdown(label: String, current: String, onSelect: (String) -> Unit, compact: Boolean = false) { var expanded by remember { mutableStateOf(false) } if (compact) { - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically) { Text(label, modifier = Modifier.width(120.dp), style = MaterialTheme.typography.labelMedium) ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { - OutlinedTextField(value = current, onValueChange = {}, readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + OutlinedTextField(current, {}, readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor().height(48.dp).weight(1f), singleLine = true) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + ExposedDropdownMenu(expanded, onDismissRequest = { expanded = false }) { LEVELS.forEach { lvl -> DropdownMenuItem(text = { Text(lvl) }, onClick = { onSelect(lvl); expanded = false }) } @@ -480,11 +456,10 @@ private fun DebugLevelDropdown( } } else { ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { - OutlinedTextField(value = current, onValueChange = {}, readOnly = true, - label = { Text("$label level") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + OutlinedTextField(current, {}, readOnly = true, label = { Text("$label level") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(), singleLine = true) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + ExposedDropdownMenu(expanded, onDismissRequest = { expanded = false }) { LEVELS.forEach { lvl -> DropdownMenuItem(text = { Text(lvl) }, onClick = { onSelect(lvl); expanded = false }) } 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 43e87b33..353d80c9 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 @@ -256,6 +256,10 @@ class ChatViewModel : ViewModel() { fun setVoicePreset(preset: Int) { NativeLib.voiceSetPreset(preset) } fun getVoiceCompressor(): Boolean = NativeLib.voiceGetCompressor() fun setVoiceCompressor(enabled: Boolean) { NativeLib.voiceSetCompressor(enabled) } + fun setCompressorConfig(maxGainDb: Int, lookbackMs: Int, lookaheadMs: Int, + riseRate: Float, targetLevelDb: Float) { + NativeLib.voiceSetCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb) + } fun clearState() { repo?.close() diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 118d6cef..ddfb1140 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -467,6 +467,11 @@ int utun_bridge_voice_get_compressor(void) { return voice_recorder_is_compressor_enabled(); } +void utun_bridge_voice_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms, + float rise_rate_per_500ms, float target_level_db) { + voice_recorder_set_compressor_config(max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_500ms, target_level_db); +} + float utun_bridge_voice_get_peak_level(void) { return voice_recorder_get_peak_level(); } @@ -990,6 +995,14 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceGetCompr return utun_bridge_voice_get_compressor() ? JNI_TRUE : JNI_FALSE; } +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceSetCompressorConfig( + JNIEnv* env, jobject thiz, jint maxGainDb, jint lookbackMs, jint lookaheadMs, + jfloat riseRate, jfloat targetLevelDb) { + (void)env; (void)thiz; + utun_bridge_voice_set_compressor_config((int)maxGainDb, (int)lookbackMs, (int)lookaheadMs, + (float)riseRate, (float)targetLevelDb); +} + JNIEXPORT jfloat JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceGetPeakLevel( JNIEnv* env, jobject thiz) { (void)env; (void)thiz; diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 9800bb89..ea35c8e4 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -85,6 +85,8 @@ 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); +void utun_bridge_voice_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms, + float rise_rate_per_500ms, float target_level_db); float utun_bridge_voice_get_peak_level(void); /* ── Attachment ── */ diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.c b/tools/chatgui-android/libutun_lite/voice_recorder.c index 9110322b..fc12038d 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.c +++ b/tools/chatgui-android/libutun_lite/voice_recorder.c @@ -459,6 +459,28 @@ int voice_recorder_is_compressor_enabled(void) { return e; } +void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms, + float rise_rate_per_500ms, float target_level_db) { + pthread_mutex_lock(&g_init_mtx); + if (g_rec && g_rec->compressor) { + pthread_mutex_lock(&g_rec->mtx); + audio_compressor_config_t cfg = {0}; + cfg.sample_rate = 48000; + cfg.channels = 1; + cfg.block_duration_ms = 20; + cfg.lookback_ms = lookback_ms; + cfg.lookahead_ms = lookahead_ms; + cfg.max_gain_db = (float)max_gain_db; + cfg.rise_rate_per_500ms = rise_rate_per_500ms; + cfg.target_level = powf(10.0f, target_level_db / 20.0f); + audio_compressor_configure(g_rec->compressor, &cfg); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder compressor config: gain=%ddB lookback=%d lookahead=%d rise=%.1f target=%.0fdBFS", + max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_500ms, target_level_db); + pthread_mutex_unlock(&g_rec->mtx); + } + pthread_mutex_unlock(&g_init_mtx); +} + float voice_recorder_get_peak_level(void) { float v = 0.0f; pthread_mutex_lock(&g_init_mtx); diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.h b/tools/chatgui-android/libutun_lite/voice_recorder.h index 9618aeed..0f57a98a 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.h +++ b/tools/chatgui-android/libutun_lite/voice_recorder.h @@ -23,6 +23,8 @@ 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); +void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms, + float rise_rate_per_500ms, float target_level_db); float voice_recorder_get_peak_level(void); #ifdef __cplusplus