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
This commit is contained in:
1 parent
9b6dd6476c
commit
1f8f41bda7
8 files changed
+462
-383
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+358
-383
@@ -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,78 +41,8 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
|
||||
val scope = rememberCoroutineScope()
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
|
||||
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("") }
|
||||
var logUdpPort by remember { mutableStateOf("") }
|
||||
val pubKey by vm.pubKey.collectAsState()
|
||||
val generatingKeys by vm.generatingKeys.collectAsState()
|
||||
|
||||
var showDebugCategories by remember { mutableStateOf(false) }
|
||||
var showOverwriteKeys by remember { mutableStateOf(false) }
|
||||
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()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
nodeName = provider.getString("node.name") ?: provider.getDeviceName()
|
||||
listenPort = provider.getInt("node.listen_port").toString()
|
||||
controlPort = provider.getInt("control.port").toString()
|
||||
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)
|
||||
}
|
||||
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()
|
||||
servers = provider.loadServers()
|
||||
availableIfaces = provider.getAvailableInterfaces()
|
||||
}
|
||||
|
||||
val save = { s: List<ServerEntry> ->
|
||||
dirty = 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
|
||||
}
|
||||
|
||||
val setCatLevel = { cat: String, lvl: String ->
|
||||
dirty = 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)
|
||||
}
|
||||
var tabIndex by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("Settings", "Audio")
|
||||
|
||||
val handleBack = {
|
||||
if (dirty) {
|
||||
@@ -132,346 +60,394 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.Default.ArrowBack, "Back") } }
|
||||
navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.AutoMirrored.Filled.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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
TabRow(selectedTabIndex = tabIndex) {
|
||||
tabs.forEachIndexed { idx, name ->
|
||||
Tab(selected = tabIndex == idx, onClick = { tabIndex = idx }, text = { Text(name) })
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
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.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")
|
||||
when (tabIndex) {
|
||||
0 -> GeneralSettingsTab(vm, dirty, onDirty = { dirty = it }, onExit)
|
||||
1 -> AudioSettingsTab(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
/* ── General Settings Tab ── */
|
||||
|
||||
// ── 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
|
||||
@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 debugLevel by remember { mutableStateOf("info") }
|
||||
var logFile by remember { mutableStateOf("") }
|
||||
var logUdpIp by remember { mutableStateOf("") }
|
||||
var logUdpPort by remember { mutableStateOf("") }
|
||||
val pubKey by vm.pubKey.collectAsState()
|
||||
val generatingKeys by vm.generatingKeys.collectAsState()
|
||||
|
||||
var showDebugCategories by remember { mutableStateOf(false) }
|
||||
var showOverwriteKeys by remember { mutableStateOf(false) }
|
||||
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()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
nodeName = provider.getString("node.name") ?: provider.getDeviceName()
|
||||
listenPort = provider.getInt("node.listen_port").toString()
|
||||
controlPort = provider.getInt("control.port").toString()
|
||||
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)"
|
||||
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)
|
||||
val stored = provider.getString("debug.categories") ?: ""
|
||||
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> -> onDirty(true); servers = s; scope.launch { provider.saveServers(s) } }
|
||||
val setDebug = { level: String ->
|
||||
onDirty(true); NativeLib.setDebugLevel("", level)
|
||||
scope.launch { provider.setValue("debug.console_level", level) }; debugLevel = level
|
||||
}
|
||||
val setCatLevel = { cat: String, lvl: String ->
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
/* 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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
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) }
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
/* 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))
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
/* 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)
|
||||
}
|
||||
}
|
||||
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(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(16.dp))
|
||||
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") }
|
||||
|
||||
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")
|
||||
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 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))
|
||||
|
||||
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")
|
||||
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))
|
||||
}
|
||||
|
||||
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") } })
|
||||
|
||||
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()
|
||||
|
||||
/* 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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user