Browse Source

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
topo_upd
evgeny 2 months ago
parent
commit
1f8f41bda7
  1. 53
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt
  2. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  3. 679
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  4. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  5. 13
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  6. 2
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  7. 22
      tools/chatgui-android/libutun_lite/voice_recorder.c
  8. 2
      tools/chatgui-android/libutun_lite/voice_recorder.h

53
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

8
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

679
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<Map<String, String>>(emptyMap()) }
var servers by remember { mutableStateOf<List<ServerEntry>>(emptyList()) }
var availableIfaces by remember { mutableStateOf<List<Pair<String, String>>>(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<ServerEntry> ->
dirty = true
servers = s
scope.launch { provider.saveServers(s) }
}
val save = { s: List<ServerEntry> -> 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<Float>,
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 })
}

4
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()

13
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;

2
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 ── */

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

2
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

Loading…
Cancel
Save