Improve Android VAD lifecycle, audio settings and diagnostics

This commit is contained in:
evgeny committed 2026-10-04 14:36:52 +02:00
1 parent 20a1211e09
commit 123b47c612
24 files changed
+490 -127

No files matched your search

@@ -3,7 +3,6 @@ package com.utun.chat
import android.app.Application
import android.content.res.Configuration
import com.utun.chat.data.AppLanguage
import android.util.Log
import android.os.Handler
import android.os.Looper
import com.utun.chat.data.LogManager
@@ -29,7 +28,6 @@ object AppEventHandler : ChatEventCallback {
if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) {
mainHandler.post { CallController.onEvent(type, data) }
}
Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}")
_events.tryEmit(type to data)
}
@@ -471,6 +471,7 @@ class MainActivity : AppCompatActivity() {
MicrophonePermission.requester = microphoneRequester
RadioOverlayService.overlayPermissionRequester = overlayPermissionRequester
super.onStart()
RadioAudioEngine.instance().setForeground(true)
if (intent.getBooleanExtra(MicrophonePermission.EXTRA_REQUEST, false)) {
intent.removeExtra(MicrophonePermission.EXTRA_REQUEST)
requestMicrophone("PTT") {
@@ -546,6 +547,7 @@ class MainActivity : AppCompatActivity() {
}
override fun onStop() {
RadioAudioEngine.instance().setForeground(false)
super.onStop()
if (MicrophonePermission.requester === microphoneRequester) MicrophonePermission.requester = null
if (RadioOverlayService.overlayPermissionRequester === overlayPermissionRequester) RadioOverlayService.overlayPermissionRequester = null
@@ -23,6 +23,16 @@ import java.util.concurrent.ThreadLocalRandom
private val Context.dataStore by preferencesDataStore("utun_config")
data class VoiceSettings(
val preset: Int = 1,
val compressorEnabled: Boolean = true,
val maxGainDb: Int = 15,
val lookbackMs: Int = 200,
val lookaheadMs: Int = 100,
val riseRate: Int = 6,
val targetLevelDb: Int = -3
)
class ConfigProvider(private val context: Context) {
private val nodePrivateKey = stringPreferencesKey("node_private_key")
private val nodePublicKey = stringPreferencesKey("node_public_key")
@@ -44,6 +54,9 @@ class ConfigProvider(private val context: Context) {
private val chatCompressorEnabled = intPreferencesKey("chat_compressor_enabled")
private val chatCompressorMaxGainDb = intPreferencesKey("chat_compressor_max_gain_db")
private val chatCompressorRiseRate = intPreferencesKey("chat_compressor_rise_rate")
private val chatCompressorLookbackMs = intPreferencesKey("chat_compressor_lookback_ms")
private val chatCompressorLookaheadMs = intPreferencesKey("chat_compressor_lookahead_ms")
private val chatCompressorTargetLevelDb = intPreferencesKey("chat_compressor_target_level_db")
private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers")
private val chatStandbyActiveSec = intPreferencesKey("chat_standby_active_sec")
private val chatStandbySleepSec = intPreferencesKey("chat_standby_sleep_sec")
@@ -142,7 +155,7 @@ class ConfigProvider(private val context: Context) {
key == "chat.call_jitter_max_reserve_ms" -> context.dataStore.data.first()[callJitterMaxReserveMs] ?: 1000
key == "chat.compressor_enabled" -> context.dataStore.data.first()[chatCompressorEnabled] ?: 1
key == "chat.compressor_max_gain_db" -> context.dataStore.data.first()[chatCompressorMaxGainDb] ?: 15
key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 2
key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 6
key == "chat.media_download_max_peers" -> context.dataStore.data.first()[chatMediaDownloadMaxPeers] ?: 3
key == "chat.standby_active_sec" -> context.dataStore.data.first()[chatStandbyActiveSec] ?: 2
key == "chat.standby_sleep_sec" -> context.dataStore.data.first()[chatStandbySleepSec] ?: 60
@@ -184,7 +197,7 @@ class ConfigProvider(private val context: Context) {
"chat.call_jitter_max_reserve_ms" -> prefs[callJitterMaxReserveMs] = (value.toIntOrNull() ?: 1000).coerceIn(500, 2000)
"chat.compressor_enabled" -> prefs[chatCompressorEnabled] = value.toIntOrNull() ?: 1
"chat.compressor_max_gain_db" -> prefs[chatCompressorMaxGainDb] = value.toIntOrNull() ?: 15
"chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 2
"chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 6
"chat.media_download_max_peers" -> prefs[chatMediaDownloadMaxPeers] = value.toIntOrNull() ?: 3
"chat.standby_active_sec" -> prefs[chatStandbyActiveSec] = value.toIntOrNull() ?: 2
"chat.standby_sleep_sec" -> prefs[chatStandbySleepSec] = value.toIntOrNull() ?: 60
@@ -204,6 +217,35 @@ class ConfigProvider(private val context: Context) {
}
}
fun getVoiceSettings(): VoiceSettings = runBlocking {
val prefs = context.dataStore.data.first()
val defaults = VoiceSettings()
val settings = VoiceSettings(
preset = prefs[chatOpusCodecPreset] ?: defaults.preset,
compressorEnabled = (prefs[chatCompressorEnabled] ?: 1) != 0,
maxGainDb = prefs[chatCompressorMaxGainDb] ?: defaults.maxGainDb,
lookbackMs = prefs[chatCompressorLookbackMs] ?: defaults.lookbackMs,
lookaheadMs = prefs[chatCompressorLookaheadMs] ?: defaults.lookaheadMs,
riseRate = prefs[chatCompressorRiseRate] ?: defaults.riseRate,
targetLevelDb = prefs[chatCompressorTargetLevelDb] ?: defaults.targetLevelDb
)
LogManager.addLog("INFO", "AudioSettings", "Loaded: $settings")
settings
}
suspend fun saveVoiceSettings(settings: VoiceSettings) {
context.dataStore.edit { prefs ->
prefs[chatOpusCodecPreset] = settings.preset
prefs[chatCompressorEnabled] = if (settings.compressorEnabled) 1 else 0
prefs[chatCompressorMaxGainDb] = settings.maxGainDb
prefs[chatCompressorLookbackMs] = settings.lookbackMs
prefs[chatCompressorLookaheadMs] = settings.lookaheadMs
prefs[chatCompressorRiseRate] = settings.riseRate
prefs[chatCompressorTargetLevelDb] = settings.targetLevelDb
}
LogManager.addLog("INFO", "AudioSettings", "Saved: $settings")
}
suspend fun generateKeys() {
/* Keys are generated in C (instance_lite via ensure_keys).
* They are saved back via NativeLib.start() → nativeGetGeneratedKeys(). */
@@ -16,11 +16,23 @@ object NativeLib {
/** Save Kotlin callbacks to C globals (log, config). Must be called before start(). */
fun init(dbPath: String, controlPort: Int): Boolean {
return nativeInit(
val provider = ChatApplication.instance.configProvider
val settings = provider.getVoiceSettings()
val ok = nativeInit(
dbPath, controlPort,
ChatApplication.instance.configProvider,
provider,
LogManager
)
if (!ok) {
LogManager.addLog("ERROR", "AudioSettings", "Native initialization failed; voice settings not applied")
return false
}
nativeVoiceSetPreset(settings.preset)
nativeVoiceSetCompressor(settings.compressorEnabled)
nativeVoiceSetCompressorConfig(settings.maxGainDb, settings.lookbackMs, settings.lookaheadMs,
settings.riseRate.toFloat(), settings.targetLevelDb.toFloat())
LogManager.addLog("INFO", "AudioSettings", "Applied at initialization: $settings")
return true
}
/** Start full uTun instance from INI config text */
@@ -43,7 +43,7 @@ class RadioAudioEngine {
const val PLAY_CHANNELS = 2
const val RELEASE_HOLD_MS = 250L
const val VAD_ACTIVE_MS = 15 * 60 * 1000L
const val VAD_BACKGROUND_MS = 15 * 60 * 1000L
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
@Volatile private var inst: RadioAudioEngine? = null
@@ -76,8 +76,7 @@ class RadioAudioEngine {
@Volatile private var talkRequestNs = 0L
@Volatile private var generation = 0L
private var audioGroup = 0L
@Volatile private var vadDeadline = 0L
private var vadConfigured = false
private val vadWindow = RadioVadWindow(VAD_BACKGROUND_MS)
private var screenReceiver: BroadcastReceiver? = null
@Volatile private var screenInteractive = false
private var configuredInput = INPUT_AUTO
@@ -102,8 +101,8 @@ class RadioAudioEngine {
private val releaseTalk = Runnable { finishTalk() }
private val vadTimeout = Runnable {
io.execute {
if (active && SystemClock.elapsedRealtime() >= vadDeadline) {
LogManager.addLog("INFO", "RadioAudio", "VAD window expired (15 minutes)")
if (active && vadWindow.deadlineMs > 0L && !vadWindow.isAllowed(SystemClock.elapsedRealtime())) {
LogManager.addLog("INFO", "debug", "VAD background window expired (15 minutes) group=$audioGroup")
updateCapture()
}
}
@@ -142,7 +141,7 @@ class RadioAudioEngine {
if (!active || generation != version) return@execute
releaseLocked()
audioGroup = group
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
vadWindow.restart(SystemClock.elapsedRealtime())
router.setAutoHeadset(true)
if (active && generation == version) {
try { setupAudio() } catch (e: Exception) {
@@ -164,17 +163,29 @@ class RadioAudioEngine {
io.execute { releaseLocked() }
}
/** Смена настроек перезапускает только захват, сохраняя приём и окно VAD. */
fun reconfigure() {
/** Смена настроек сохраняет окно VAD; явное включение открывает новое. */
fun reconfigure(restartVad: Boolean = false) {
val version = generation
io.execute {
if (!active || generation != version || audioTrack == null) return@execute
stopCapture()
loadCaptureSettings()
if (restartVad) vadWindow.restart(SystemClock.elapsedRealtime())
updateCapture()
}
}
/** Видимое приложение сбрасывает фоновое окно VAD и отключает отсчёт. */
fun setForeground(foreground: Boolean) {
val nowMs = SystemClock.elapsedRealtime()
io.execute {
vadWindow.setForeground(foreground, nowMs)
LogManager.addLog("DEBUG", "debug", "VAD application foreground=$foreground group=$audioGroup " +
"backgroundLeftMs=${vadWindow.timeLeftMs(nowMs)}")
if (active) updateCapture()
}
}
/* Приём создаётся независимо от микрофона (только io-поток). */
private fun setupAudio() {
loadCaptureSettings()
@@ -356,12 +367,12 @@ class RadioAudioEngine {
io.execute {
if (!active || generation != version) return@execute
if (intent.action == Intent.ACTION_SCREEN_OFF) {
vadDeadline = 0L
LogManager.addLog("INFO", "RadioAudio", "screen off: VAD window closed")
vadWindow.close()
LogManager.addLog("INFO", "debug", "VAD screen off: window closed group=$audioGroup")
}
if (intent.action == Intent.ACTION_SCREEN_ON) {
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
LogManager.addLog("INFO", "RadioAudio", "screen on: new VAD window (15 minutes)")
vadWindow.restart(SystemClock.elapsedRealtime())
LogManager.addLog("INFO", "debug", "VAD screen on: new background window (15 minutes) group=$audioGroup")
}
updateCapture()
}
@@ -379,14 +390,24 @@ class RadioAudioEngine {
screenInteractive = screenOn()
}
/** Выбор захвата: PTT либо включённый VAD в пределах окна при горящем экране. */
/** VAD без ограничения в приложении; после сворачивания — полные 15 минут при горящем экране. */
private fun updateCapture() {
mainHandler.removeCallbacks(vadTimeout)
val configured = configuredVad
if (configured && !vadConfigured) vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
vadConfigured = configured
val wantVad = active && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline && configured
if (wantVad) mainHandler.postDelayed(vadTimeout, (vadDeadline - SystemClock.elapsedRealtime()).coerceAtLeast(1L))
val now = SystemClock.elapsedRealtime()
vadWindow.setEnabled(configured, now)
val wantVad = active && screenInteractive && vadWindow.isAllowed(now) && configured
val reason = when {
!active -> "radio_off"
!configured -> "vad_disabled"
!screenInteractive -> "screen_off"
!vadWindow.isAllowed(now) -> "window_expired"
else -> "vad_allowed"
}
LogManager.addLog("DEBUG", "debug", "VAD capture gate group=$audioGroup reason=$reason requested=$configured " +
"screen=$screenInteractive foreground=${vadWindow.foreground} backgroundLeftMs=${vadWindow.timeLeftMs(now)} manual=$requestedTalk " +
"capture=$captureRunning nativeVad=$vadMode playback=${audioTrack != null}")
if (wantVad && !vadWindow.foreground) mainHandler.postDelayed(vadTimeout, vadWindow.timeLeftMs(now).coerceAtLeast(1L))
if (!active || (!requestedTalk && !wantVad)) {
stopCapture()
return
@@ -411,7 +432,7 @@ class RadioAudioEngine {
private fun startCapture(vad: Boolean): Boolean {
if (!MicrophonePermission.granted()) {
LogManager.addLog("WARN", "RadioAudio", "Capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad")
LogManager.addLog("WARN", "debug", "VAD/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad")
return false
}
val startNs = SystemClock.elapsedRealtimeNanos()
@@ -425,7 +446,7 @@ class RadioAudioEngine {
}
vadMode = NativeLib.radioVadMode()
if (vad && !vadMode) {
LogManager.addLog("ERROR", "RadioAudio", "VAD unavailable: capture cancelled")
LogManager.addLog("ERROR", "debug", "VAD unavailable: capture cancelled group=$audioGroup")
NativeLib.radioCaptureStop(); rec.release()
return false
}
@@ -465,7 +486,7 @@ class RadioAudioEngine {
}.apply {
name = "radio-capture"; priority = Thread.MAX_PRIORITY; start()
}
LogManager.addLog("INFO", "RadioAudio",
LogManager.addLog("INFO", "debug",
"capture started group=$audioGroup ch=$channels vad=$vadMode " +
"createMs=${(createdNs - startNs) / 1_000_000} prepareMs=${(preparedNs - createdNs) / 1_000_000} " +
"startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}")
@@ -485,7 +506,7 @@ class RadioAudioEngine {
rec.release()
audioRecord = null
NativeLib.radioCaptureStop()
LogManager.addLog("INFO", "RadioAudio", "capture released group=$audioGroup")
LogManager.addLog("INFO", "debug", "VAD/PTT capture released group=$audioGroup")
}
vadMode = false
talking = false
@@ -495,6 +516,7 @@ class RadioAudioEngine {
private fun releaseLocked() {
mainHandler.removeCallbacks(vadTimeout)
vadWindow.setEnabled(false, SystemClock.elapsedRealtime())
screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) }
screenReceiver = null
stopCapture()
@@ -573,6 +595,16 @@ class RadioAudioEngine {
val buf = ShortArray(frame)
var frameN = 0L
var reportedRequestNs = 0L
var statsMs = SystemClock.elapsedRealtime()
var statsFrames = 0
var fedFrames = 0
var skippedFrames = 0
var failedFrames = 0
var minRmsDb = 0
var maxRmsDb = -120
var maxReadNs = 0L
var maxFeedNs = 0L
var previousFeedAllowed: Boolean? = null
while (captureRunning) {
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
if (captureRunning) {
@@ -580,6 +612,7 @@ class RadioAudioEngine {
}
break
}
val readStartedNs = SystemClock.elapsedRealtimeNanos()
var read = 0
while (read < frame) {
if (!captureRunning) return
@@ -592,22 +625,45 @@ class RadioAudioEngine {
}
read += n
}
/* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */
if (frameN < 10 || frameN % 100L == 0L) {
LogManager.addLog("DEBUG", "RadioAudio",
"cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=$configuredInput route=${route()}")
}
maxReadNs = maxOf(maxReadNs, SystemClock.elapsedRealtimeNanos() - readStartedNs)
val inputDb = rmsDb(buf)
minRmsDb = minOf(minRmsDb, inputDb)
maxRmsDb = maxOf(maxRmsDb, inputDb)
statsFrames++
frameN++
val vadAllowed = vadMode && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline
if (active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)) {
val vadAllowed = vadMode && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime())
val feedAllowed = active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)
if (previousFeedAllowed != feedAllowed) {
LogManager.addLog("DEBUG", "debug", "VAD PCM gate group=$group frame=$frameN allowed=$feedAllowed " +
"active=$active currentGroup=$groupId capture=$captureRunning manual=$requestedTalk talking=$talking " +
"nativeVad=$vadMode screen=$screenInteractive backgroundDeadlineMs=${vadWindow.deadlineMs}")
previousFeedAllowed = feedAllowed
}
if (feedAllowed) {
val requestNs = talkRequestNs
val feedStartedNs = SystemClock.elapsedRealtimeNanos()
val fed = NativeLib.radioAudioFeed(group, buf)
if (!fed) LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false")
maxFeedNs = maxOf(maxFeedNs, SystemClock.elapsedRealtimeNanos() - feedStartedNs)
if (fed) fedFrames++ else {
failedFrames++
if (failedFrames == 1) LogManager.addLog("WARN", "debug", "VAD/PTT PCM rejected group=$group frame=$frameN")
}
if (fed && requestedTalk && requestNs != reportedRequestNs) {
reportedRequestNs = requestNs
LogManager.addLog("INFO", "RadioAudio", "PTT first frame queued elapsedMs=${(SystemClock.elapsedRealtimeNanos() - requestNs) / 1_000_000}")
}
if (captureRunning) _talkingFlow.value = requestedTalk || NativeLib.radioTransmitting()
} else skippedFrames++
val nowMs = SystemClock.elapsedRealtime()
if (nowMs - statsMs >= 1000L) {
LogManager.addLog("DEBUG", "debug", "VAD capture summary group=$group spanMs=${nowMs - statsMs} " +
"frames=$statsFrames audioMs=${statsFrames * FRAME_MS} fed=$fedFrames skipped=$skippedFrames rejected=$failedFrames " +
"rmsDb=$minRmsDb..$maxRmsDb readMaxMs=${maxReadNs / 1_000_000.0} feedMaxMs=${maxFeedNs / 1_000_000.0} " +
"source=${rec.audioSource} channels=$ch routed=${rec.routedDevice?.type ?: "-"} input=$configuredInput " +
"route=${route()} vad=$vadMode screen=$screenInteractive manual=$requestedTalk tx=${_talkingFlow.value}")
statsMs = nowMs
statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0
minRmsDb = 0; maxRmsDb = -120; maxReadNs = 0L; maxFeedNs = 0L
}
}
LogManager.addLog("DEBUG", "RadioAudio", "capture loop done")
@@ -0,0 +1,47 @@
package com.utun.chat.data
/** Окно VAD расходуется только в фоне; все изменения выполняются в radio-audio-io. */
class RadioVadWindow(private val durationMs: Long) {
@Volatile var deadlineMs = 0L
private set
var foreground = false
private set
private var enabled = false
private var remainingMs = durationMs
fun setForeground(value: Boolean, nowMs: Long) {
if (foreground == value) return
if (value) remainingMs = durationMs
foreground = value
updateDeadline(nowMs)
}
fun setEnabled(value: Boolean, nowMs: Long) {
if (enabled == value) return
enabled = value
if (value) restart(nowMs) else close()
}
fun restart(nowMs: Long) {
remainingMs = durationMs
updateDeadline(nowMs)
}
fun close() {
remainingMs = 0L
deadlineMs = 0L
}
fun isAllowed(nowMs: Long): Boolean = nowMs < deadlineMs
fun timeLeftMs(nowMs: Long): Long =
if (deadlineMs == Long.MAX_VALUE) remainingMs else (deadlineMs - nowMs).coerceAtLeast(0L)
private fun updateDeadline(nowMs: Long) {
deadlineMs = when {
!enabled -> 0L
foreground -> Long.MAX_VALUE
else -> nowMs + remainingMs
}
}
}
@@ -1,6 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import com.utun.chat.BuildConfig
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -28,6 +29,7 @@ import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
import com.utun.chat.data.DmConversation
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.LogManager
import com.utun.chat.ChatApplication
import kotlinx.coroutines.launch
@@ -53,7 +55,10 @@ fun ChannelListScreen(
var optionsChannel by remember { mutableStateOf<Channel?>(null) }
var deleteConfirmChannel by remember { mutableStateOf<Channel?>(null) }
LaunchedEffect(Unit) { onRefresh() }
LaunchedEffect(Unit) {
LogManager.addLog("INFO", "UI", "Channel list: version=${BuildConfig.VERSION_NAME} code=${BuildConfig.VERSION_CODE}")
onRefresh()
}
Scaffold(
topBar = {
@@ -72,8 +77,16 @@ fun ChannelListScreen(
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreateDialog = true }) {
Icon(Icons.Default.Add, stringResource(R.string.new_channel))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "v${BuildConfig.VERSION_NAME}",
modifier = Modifier.padding(end = 16.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FloatingActionButton(onClick = { showCreateDialog = true }) {
Icon(Icons.Default.Add, stringResource(R.string.new_channel))
}
}
}
) { padding ->
@@ -260,7 +260,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope()
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
val vadEnabled by provider.radioVadEnabled.collectAsState(initial = provider.getInt("radio.vad_enabled") != 0)
val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState()
val volumePttEnabled by provider.radioVolumePttEnabled.collectAsState(initial = provider.getRadioVolumePtt())
val overlayShown by RadioOverlayService.isShown.collectAsState()
val overlayStatus by RadioOverlayService.status.collectAsState()
@@ -305,9 +305,9 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
volumePttEnabled, volumeKeys = true) {
viewModel.setRadioVolumePtt(!volumePttEnabled)
}
RadioControlButton(null, stringResource(if (vadEnabled) R.string.disable_vad else R.string.enable_vad),
vadEnabled, text = stringResource(R.string.vad_button)) {
viewModel.setRadioVad(!vadEnabled)
RadioControlButton(null, stringResource(if (vadActive) R.string.disable_vad else R.string.enable_vad),
vadActive, text = stringResource(R.string.vad_button)) {
viewModel.setRadioVad(!vadActive)
}
RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) {
scope.launch {
@@ -32,6 +32,7 @@ import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.data.LogManager
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.data.VoiceSettings
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.components.BatteryOptimizationSetting
import com.utun.chat.ui.components.LanguageSetting
@@ -42,6 +43,7 @@ import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
@@ -49,6 +51,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.UUID
import java.io.IOException
private val LEVELS = listOf("NONE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE")
private val CATEGORIES = listOf(
@@ -773,28 +776,31 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
/* Codec preset */
val presets = listOf(stringResource(R.string.codec_low), stringResource(R.string.codec_standard), stringResource(R.string.codec_high))
var preset by remember { mutableStateOf(vm.getVoicePreset()) }
val savedVoiceSettings = remember { provider.getVoiceSettings() }
var preset by remember { mutableStateOf(savedVoiceSettings.preset) }
var presetExpanded by remember { mutableStateOf(false) }
var jitterReserveMs by remember { mutableStateOf(provider.getInt("chat.call_jitter_max_reserve_ms")) }
/* Compressor params with defaults from desktop */
var compressorOn by remember { mutableStateOf(vm.getVoiceCompressor()) }
var maxGainDb by remember { mutableStateOf(15) }
var lookbackMs by remember { mutableStateOf(200) }
var lookaheadMs by remember { mutableStateOf(100) }
var riseRate by remember { mutableStateOf(2) }
var targetLevelDb by remember { mutableStateOf(-12) }
/* Все параметры восстанавливаются из одного снимка DataStore. */
var compressorOn by remember { mutableStateOf(savedVoiceSettings.compressorEnabled) }
var maxGainDb by remember { mutableStateOf(savedVoiceSettings.maxGainDb) }
var lookbackMs by remember { mutableStateOf(savedVoiceSettings.lookbackMs) }
var lookaheadMs by remember { mutableStateOf(savedVoiceSettings.lookaheadMs) }
var riseRate by remember { mutableStateOf(savedVoiceSettings.riseRate) }
var targetLevelDb by remember { mutableStateOf(savedVoiceSettings.targetLevelDb) }
val apply = {
vm.setVoicePreset(preset)
vm.setVoiceCompressor(compressorOn)
vm.setCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate.toFloat(), targetLevelDb.toFloat())
scope.launch {
provider.setValue("chat.opus_codec_preset", preset.toString())
provider.setValue("chat.compressor_enabled", if (compressorOn) "1" else "0")
provider.setValue("chat.compressor_max_gain_db", maxGainDb.toString())
provider.setValue("chat.compressor_rise_rate", riseRate.toString())
val settings = VoiceSettings(preset, compressorOn, maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb)
vm.viewModelScope.launch {
try {
provider.saveVoiceSettings(settings)
} catch (e: IOException) {
LogManager.addLog("ERROR", "AudioSettings", "Save failed: $settings: $e")
}
}
NativeLib.setChatSetting("opus_codec_preset", preset.toString())
NativeLib.setChatSetting("compressor_enabled", if (compressorOn) "1" else "0")
@@ -915,15 +921,15 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
}
LabeledSlider(stringResource(R.string.max_gain), maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB",
{ maxGainDb = it.toInt(); apply() })
{ maxGainDb = it.toInt() }, { apply() })
LabeledSlider(stringResource(R.string.lookback_time), lookbackMs.toFloat(), 50f..500f, 45, stringResource(R.string.milliseconds, lookbackMs),
{ lookbackMs = it.toInt(); apply() })
{ lookbackMs = it.toInt() }, { apply() })
LabeledSlider(stringResource(R.string.lookahead_time), lookaheadMs.toFloat(), 20f..500f, 48, stringResource(R.string.milliseconds, lookaheadMs),
{ lookaheadMs = it.toInt(); apply() })
{ lookaheadMs = it.toInt() }, { apply() })
LabeledSlider(stringResource(R.string.rise_rate), riseRate.toFloat(), 2f..10f, 8, "${riseRate}x",
{ riseRate = it.toInt(); apply() })
{ riseRate = it.toInt() }, { apply() })
LabeledSlider(stringResource(R.string.target_level), targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS",
{ targetLevelDb = it.toInt(); apply() })
{ targetLevelDb = it.toInt() }, { apply() })
}
}
@@ -677,7 +677,7 @@ class ChatViewModel : ViewModel() {
NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0")
LogManager.addLog("INFO", "RadioAudio", "Panel VAD enabled=$enabled channel=$radioChannelId")
if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ctx)
radioAudio.reconfigure()
radioAudio.reconfigure(restartVad = enabled)
}
}