android: add level-triggered radio and inline activation settings

This commit is contained in:
evgeny committed 2026-10-09 00:06:55 +02:00
1 parent 44af542f61
commit fddbba217d
14 files changed
+575 -127

No files matched your search

@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Build
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
@@ -70,9 +71,12 @@ class ConfigProvider(private val context: Context) {
private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val radioVadEnabledKey = intPreferencesKey("radio_auto_enabled")
private val radioAutoEnabledKey = intPreferencesKey("radio_auto_enabled")
private val radioAutoModeKey = intPreferencesKey("radio_auto_mode")
private val radioLevelOnKey = intPreferencesKey("radio_level_on_dbfs")
private val radioLevelOffKey = intPreferencesKey("radio_level_off_dbfs")
private val radioVadThresholdKey = intPreferencesKey("radio_vad_threshold")
private val radioVadHangoverMsKey = intPreferencesKey("radio_auto_hangover_ms")
private val radioAutoHangoverMsKey = intPreferencesKey("radio_auto_hangover_ms")
private val radioVadCompressorMaxGainDbKey = intPreferencesKey("radio_vad_compressor_max_gain_db")
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
@@ -162,9 +166,9 @@ class ConfigProvider(private val context: Context) {
key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1
key == "radio.vad_enabled" -> context.dataStore.data.first()[radioVadEnabledKey] ?: 0
key == "radio.auto_enabled" -> context.dataStore.data.first()[radioAutoEnabledKey] ?: 0
key == "radio.vad_threshold" -> context.dataStore.data.first()[radioVadThresholdKey] ?: 50
key == "radio.vad_hangover_ms" -> context.dataStore.data.first()[radioVadHangoverMsKey] ?: 200
key == "radio.auto_hangover_ms" -> context.dataStore.data.first()[radioAutoHangoverMsKey] ?: 200
key == "radio.vad_compressor_max_gain_db" -> context.dataStore.data.first()[radioVadCompressorMaxGainDbKey] ?: 15
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
@@ -204,9 +208,9 @@ class ConfigProvider(private val context: Context) {
"chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1
"radio.vad_enabled" -> prefs[radioVadEnabledKey] = value.toIntOrNull() ?: 0
"radio.auto_enabled" -> prefs[radioAutoEnabledKey] = value.toIntOrNull() ?: 0
"radio.vad_threshold" -> prefs[radioVadThresholdKey] = value.toIntOrNull() ?: 50
"radio.vad_hangover_ms" -> prefs[radioVadHangoverMsKey] = value.toIntOrNull() ?: 200
"radio.auto_hangover_ms" -> prefs[radioAutoHangoverMsKey] = value.toIntOrNull() ?: 200
"radio.vad_compressor_max_gain_db" -> prefs[radioVadCompressorMaxGainDbKey] = (value.toIntOrNull() ?: 15).coerceIn(1, 60)
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
@@ -217,6 +221,38 @@ class ConfigProvider(private val context: Context) {
}
}
private fun radioAutoSettings(prefs: Preferences) = RadioAutoSettings(
enabled = (prefs[radioAutoEnabledKey] ?: 0) != 0,
mode = prefs[radioAutoModeKey] ?: RadioAutoSettings.VAD,
levelOn = prefs[radioLevelOnKey] ?: -35, levelOff = prefs[radioLevelOffKey] ?: -45,
hangoverMs = prefs[radioAutoHangoverMsKey] ?: 200,
vadThreshold = prefs[radioVadThresholdKey] ?: 50, vadGain = prefs[radioVadCompressorMaxGainDbKey] ?: 15
)
fun getRadioAutoSettings(): RadioAutoSettings = runBlocking { radioAutoSettings(context.dataStore.data.first()) }
val radioAutoSettings: Flow<RadioAutoSettings> = context.dataStore.data.map { radioAutoSettings(it) }
suspend fun saveRadioAutoSettings(settings: RadioAutoSettings) {
if (!settings.valid()) {
LogManager.addLog("ERROR", "radio", "Invalid automatic transmission settings: $settings")
throw IllegalArgumentException("Invalid automatic transmission settings")
}
context.dataStore.edit { prefs ->
prefs[radioAutoEnabledKey] = if (settings.enabled) 1 else 0
prefs[radioAutoModeKey] = settings.mode
prefs[radioLevelOnKey] = settings.levelOn; prefs[radioLevelOffKey] = settings.levelOff
prefs[radioAutoHangoverMsKey] = settings.hangoverMs
prefs[radioVadThresholdKey] = settings.vadThreshold; prefs[radioVadCompressorMaxGainDbKey] = settings.vadGain
}
LogManager.addLog("INFO", "radio", "Saved automatic transmission settings: $settings")
}
// JNI подтверждает применение в uasync; захват возобновляется только после этого callback.
@Suppress("unused")
fun onRadioSettingsApplied(requestId: Long, success: Boolean) {
RadioAudioEngine.instance().onSettingsApplied(requestId, success)
}
fun getVoiceSettings(): VoiceSettings = runBlocking {
val prefs = context.dataStore.data.first()
val defaults = VoiceSettings()
@@ -398,7 +434,7 @@ class ConfigProvider(private val context: Context) {
val radioVolumePttEnabled: Flow<Boolean> = context.dataStore.data.map { it[radioVolumePttKey] ?: false }
val radioOverlayEnabled: Flow<Boolean> = context.dataStore.data.map { it[radioOverlayKey] ?: false }
val radioVadEnabled: Flow<Boolean> = context.dataStore.data.map { (it[radioVadEnabledKey] ?: 0) != 0 }
val radioAutoEnabled: Flow<Boolean> = context.dataStore.data.map { (it[radioAutoEnabledKey] ?: 0) != 0 }
@Volatile private var radioOverlayCached = false
@Volatile private var radioOverlayLoaded = false
@@ -609,10 +645,14 @@ class ConfigProvider(private val context: Context) {
appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb")
appendLine("storage_maxsize_gb=$chatMaxGb")
appendLine("opus_codec_preset=$chatOpusPreset")
appendLine("radio_auto_enabled=${getInt("radio.vad_enabled")}")
appendLine("radio_vad_threshold=${getInt("radio.vad_threshold")}")
appendLine("radio_auto_hangover_ms=${getInt("radio.vad_hangover_ms")}")
appendLine("radio_vad_compressor_max_gain_db=${getInt("radio.vad_compressor_max_gain_db")}")
val radioAuto = getRadioAutoSettings()
appendLine("radio_auto_enabled=${if (radioAuto.enabled) 1 else 0}")
appendLine("radio_auto_mode=${radioAuto.mode}")
appendLine("radio_level_on_dbfs=${radioAuto.levelOn}")
appendLine("radio_level_off_dbfs=${radioAuto.levelOff}")
appendLine("radio_auto_hangover_ms=${radioAuto.hangoverMs}")
appendLine("radio_vad_threshold=${radioAuto.vadThreshold}")
appendLine("radio_vad_compressor_max_gain_db=${radioAuto.vadGain}")
appendLine("radio_opus_mode=$radioOpusMode")
appendLine("radio_opus_bitrate=$radioOpusBitrate")
appendLine("radio_capture_stereo=$radioCaptureStereo")
@@ -212,10 +212,12 @@ object NativeLib {
fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples)
fun radioAudioPull(groupId: Long, out: ShortArray, playedFrames: Long): Int = nativeRadioAudioPull(groupId, out, playedFrames)
fun radioAudioStop() { nativeRadioAudioStop() }
fun radioCaptureStart(groupId: Long, channels: Int, vad: Boolean): Boolean = nativeRadioCaptureStart(groupId, channels, vad)
fun radioCaptureStart(groupId: Long, channels: Int, mode: Int): Boolean = nativeRadioCaptureStart(groupId, channels, mode)
fun radioConfigure(requestId: Long, settings: RadioAutoSettings): Boolean = nativeRadioConfigure(requestId, settings.nativeValues())
fun radioInputStatus(): FloatArray = nativeRadioInputStatus() // уровень dBFS, valid, RX-блокировка
fun radioCaptureStop() { nativeRadioCaptureStop() }
fun radioTransmitting(): Boolean = nativeRadioTransmitting()
fun radioVadMode(): Boolean = nativeRadioVadMode()
fun radioAutoMode(): Int = nativeRadioAutoMode()
fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) }
fun radioTalkEnd(groupId: Long) { nativeRadioTalkEnd(groupId) }
@@ -336,10 +338,12 @@ object NativeLib {
private external fun nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean
private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray, playedFrames: Long): Int
private external fun nativeRadioAudioStop()
private external fun nativeRadioCaptureStart(groupId: Long, channels: Int, vad: Boolean): Boolean
private external fun nativeRadioCaptureStart(groupId: Long, channels: Int, mode: Int): Boolean
private external fun nativeRadioConfigure(requestId: Long, settings: IntArray): Boolean
private external fun nativeRadioInputStatus(): FloatArray
private external fun nativeRadioCaptureStop()
private external fun nativeRadioTransmitting(): Boolean
private external fun nativeRadioVadMode(): Boolean
private external fun nativeRadioAutoMode(): Int
private external fun nativeRadioTalkBegin(groupId: Long)
private external fun nativeRadioTalkEnd(groupId: Long)
@@ -26,7 +26,7 @@ import java.util.concurrent.Executors
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT).
*
* Потоки:
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (PTT либо активный VAD)
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (PTT, автоматический детектор либо измеритель)
* - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write
*
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека
@@ -82,15 +82,19 @@ class RadioAudioEngine {
private var configuredInput = INPUT_AUTO
private var configuredMicSource = 0
private var configuredStereo = false
private var configuredVad = false
@Volatile private var vadMode = false /* VAD авто-PTT: фидим PCM постоянно */
private var autoSettings = RadioAutoSettings()
private var settingsReady = false
private var settingsRequest = 0L
private var captureRequestedMode = RadioAutoSettings.OFF
@Volatile private var autoMode = RadioAutoSettings.OFF
@Volatile private var levelMonitoring = false
/** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */
val router = CallAudioRouter()
private val _talkingFlow = MutableStateFlow(false)
val talkingFlow: StateFlow<Boolean> = _talkingFlow
/** Фактический TX (PTT/VAD), привязанный к каналу. Capture публикует, io сбрасывает после join. */
/** Фактический TX (PTT/автоматика), привязанный к каналу. Capture публикует, io сбрасывает после join. */
private val _transmittingGroup = MutableStateFlow<Long?>(null)
val transmittingGroup: StateFlow<Long?> = _transmittingGroup
@@ -99,19 +103,19 @@ class RadioAudioEngine {
Thread(r, "radio-audio-io").apply { isDaemon = true }
}
/* Таймер окна VAD; управление ресурсами всегда на io-потоке. */
/* Таймер окна автоматики; управление ресурсами всегда на io-потоке. */
private val mainHandler = Handler(Looper.getMainLooper())
private val releaseTalk = Runnable { finishTalk() }
private val vadTimeout = Runnable {
io.execute {
if (active && vadWindow.deadlineMs > 0L && !vadWindow.isAllowed(SystemClock.elapsedRealtime())) {
LogManager.addLog("INFO", "debug", "VAD background window expired (15 minutes) group=$audioGroup")
LogManager.addLog("INFO", "debug", "Auto background window expired (15 minutes) group=$audioGroup")
updateCapture()
}
}
}
private val _vadActiveFlow = MutableStateFlow(false)
val vadActiveFlow: StateFlow<Boolean> = _vadActiveFlow
private val _autoActiveFlow = MutableStateFlow(false)
val autoActiveFlow: StateFlow<Boolean> = _autoActiveFlow
fun isActive(): Boolean = active
@@ -166,24 +170,24 @@ class RadioAudioEngine {
io.execute { releaseLocked() }
}
/** Смена настроек сохраняет окно VAD; явное включение открывает новое. */
fun reconfigure(restartVad: Boolean = false) {
/** Смена настроек сохраняет окно автоматики; явное включение открывает новое. */
fun reconfigure(restartAuto: Boolean = false) {
val version = generation
io.execute {
if (!active || generation != version || audioTrack == null) return@execute
stopCapture()
loadCaptureSettings()
if (restartVad) vadWindow.restart(SystemClock.elapsedRealtime())
if (restartAuto) 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 " +
LogManager.addLog("DEBUG", "debug", "Auto application foreground=$foreground group=$audioGroup " +
"backgroundLeftMs=${vadWindow.timeLeftMs(nowMs)}")
if (active) updateCapture()
}
@@ -261,7 +265,35 @@ class RadioAudioEngine {
configuredInput = provider.getRadioInputSource()
configuredMicSource = provider.getRadioMicSource()
configuredStereo = provider.getRadioCaptureStereo()
configuredVad = provider.getInt("radio.vad_enabled") != 0
autoSettings = provider.getRadioAutoSettings()
settingsReady = false
val request = ++settingsRequest
if (!autoSettings.valid() || !NativeLib.radioConfigure(request, autoSettings)) {
LogManager.addLog("ERROR", "radio", "Cannot apply automatic transmission settings request=$request settings=$autoSettings")
} else mainHandler.postDelayed({
io.execute {
if (active && request == settingsRequest && !settingsReady)
LogManager.addLog("ERROR", "radio", "Automatic transmission settings acknowledgement timeout request=$request group=$audioGroup")
}
}, 5000)
}
internal fun onSettingsApplied(request: Long, success: Boolean) {
io.execute {
if (!active || request != settingsRequest) return@execute
settingsReady = success
LogManager.addLog(if (success) "INFO" else "ERROR", "radio", "Automatic settings applied request=$request success=$success")
if (success) updateCapture()
}
}
/** Только видимая вкладка настроек владеет дополнительным захватом для измерителя. */
fun setLevelMonitoring(enabled: Boolean) {
levelMonitoring = enabled
io.execute {
LogManager.addLog("DEBUG", "radio", "Level monitoring=$levelMonitoring active=$active group=$audioGroup")
if (active) updateCapture()
}
}
/* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон
@@ -371,11 +403,11 @@ class RadioAudioEngine {
if (!active || generation != version) return@execute
if (intent.action == Intent.ACTION_SCREEN_OFF) {
vadWindow.close()
LogManager.addLog("INFO", "debug", "VAD screen off: window closed group=$audioGroup")
LogManager.addLog("INFO", "debug", "Auto screen off: window closed group=$audioGroup")
}
if (intent.action == Intent.ACTION_SCREEN_ON) {
vadWindow.restart(SystemClock.elapsedRealtime())
LogManager.addLog("INFO", "debug", "VAD screen on: new background window (15 minutes) group=$audioGroup")
LogManager.addLog("INFO", "debug", "Auto screen on: new background window (15 minutes) group=$audioGroup")
}
updateCapture()
}
@@ -393,31 +425,23 @@ class RadioAudioEngine {
screenInteractive = screenOn()
}
/** VAD без ограничения в приложении; после сворачивания — полные 15 минут при горящем экране. */
/** Оба детектора используют одно окно по экрану/фону; измеритель не продлевает это окно. */
private fun updateCapture() {
mainHandler.removeCallbacks(vadTimeout)
val configured = configuredVad
if (!settingsReady) return
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
}
if (audioRecord != null && vadMode != wantVad) stopCapture()
vadWindow.setEnabled(autoSettings.enabled, now)
val mode = radioCaptureMode(active, screenInteractive, vadWindow.isAllowed(now), autoSettings, requestedTalk, levelMonitoring)
LogManager.addLog("DEBUG", "radio", "Auto capture gate group=$audioGroup desired=$mode requested=${autoSettings.mode} " +
"enabled=${autoSettings.enabled} screen=$screenInteractive foreground=${vadWindow.foreground} " +
"backgroundLeftMs=${vadWindow.timeLeftMs(now)} manual=$requestedTalk monitor=$levelMonitoring capture=$captureRunning")
if (mode != null && mode != RadioAutoSettings.OFF && !vadWindow.foreground)
mainHandler.postDelayed(vadTimeout, vadWindow.timeLeftMs(now).coerceAtLeast(1L))
if (mode == null) { stopCapture(); return }
val detectorFailed = mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF
if (audioRecord != null && (captureRequestedMode != mode || (detectorFailed && !requestedTalk && !levelMonitoring))) stopCapture()
if (audioTrack == null) return
if (audioRecord == null && !startCapture(wantVad)) {
if (audioRecord == null && !startCapture(mode)) {
requestedTalk = false
_talkingFlow.value = false
return
@@ -433,23 +457,24 @@ class RadioAudioEngine {
}
}
private fun startCapture(vad: Boolean): Boolean {
private fun startCapture(mode: Int): Boolean {
if (!MicrophonePermission.granted()) {
LogManager.addLog("WARN", "debug", "VAD/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad")
LogManager.addLog("WARN", "debug", "Auto/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup mode=$mode")
return false
}
val startNs = SystemClock.elapsedRealtimeNanos()
val (rec, channels) = createAudioRecord()
if (rec == null) return false
val createdNs = SystemClock.elapsedRealtimeNanos()
if (!NativeLib.radioCaptureStart(audioGroup, channels, vad)) {
LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels vad=$vad")
if (!NativeLib.radioCaptureStart(audioGroup, channels, mode)) {
LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels mode=$mode")
rec.release()
return false
}
vadMode = NativeLib.radioVadMode()
if (vad && !vadMode) {
LogManager.addLog("ERROR", "debug", "VAD unavailable: capture cancelled group=$audioGroup")
autoMode = NativeLib.radioAutoMode()
captureRequestedMode = mode
if (mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF && !requestedTalk && !levelMonitoring) {
LogManager.addLog("ERROR", "debug", "Automatic transmission unavailable: capture cancelled group=$audioGroup")
NativeLib.radioCaptureStop(); rec.release()
return false
}
@@ -460,13 +485,13 @@ class RadioAudioEngine {
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "capture start failed: ${e.message}")
NativeLib.radioCaptureStop(); rec.release()
vadMode = false
autoMode = RadioAutoSettings.OFF
return false
}
val recordingNs = SystemClock.elapsedRealtimeNanos()
audioRecord = rec
captureRunning = true
_vadActiveFlow.value = vadMode
_autoActiveFlow.value = autoMode != RadioAutoSettings.OFF
// BEGIN должен попасть в uasync раньше первого кадра ручной передачи.
if (requestedTalk) {
NativeLib.radioTalkBegin(audioGroup)
@@ -490,7 +515,7 @@ class RadioAudioEngine {
name = "radio-capture"; priority = Thread.MAX_PRIORITY; start()
}
LogManager.addLog("INFO", "debug",
"capture started group=$audioGroup ch=$channels vad=$vadMode " +
"capture started group=$audioGroup ch=$channels mode=$autoMode " +
"createMs=${(createdNs - startNs) / 1_000_000} prepareMs=${(preparedNs - createdNs) / 1_000_000} " +
"startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}")
return true
@@ -509,20 +534,21 @@ class RadioAudioEngine {
rec.release()
audioRecord = null
NativeLib.radioCaptureStop()
LogManager.addLog("INFO", "debug", "VAD/PTT capture released group=$audioGroup")
LogManager.addLog("INFO", "debug", "Auto/PTT capture released group=$audioGroup")
}
vadMode = false
autoMode = RadioAutoSettings.OFF
talking = false
if (_transmittingGroup.value != null) {
LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}")
_transmittingGroup.value = null
}
_talkingFlow.value = requestedTalk
_vadActiveFlow.value = false
_autoActiveFlow.value = false
}
private fun releaseLocked() {
mainHandler.removeCallbacks(vadTimeout)
settingsReady = false; ++settingsRequest
vadWindow.setEnabled(false, SystemClock.elapsedRealtime())
screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) }
screenReceiver = null
@@ -638,12 +664,13 @@ class RadioAudioEngine {
maxRmsDb = maxOf(maxRmsDb, inputDb)
statsFrames++
frameN++
val vadAllowed = vadMode && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime())
val feedAllowed = active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)
val autoAllowed = autoMode != RadioAutoSettings.OFF && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime())
val feedAllowed = active && groupId == group && captureRunning &&
((talking && requestedTalk) || autoAllowed || (levelMonitoring && screenInteractive))
if (previousFeedAllowed != feedAllowed) {
LogManager.addLog("DEBUG", "debug", "VAD PCM gate group=$group frame=$frameN allowed=$feedAllowed " +
LogManager.addLog("DEBUG", "debug", "Auto 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}")
"nativeMode=$autoMode screen=$screenInteractive backgroundDeadlineMs=${vadWindow.deadlineMs}")
previousFeedAllowed = feedAllowed
}
if (feedAllowed) {
@@ -653,7 +680,7 @@ class RadioAudioEngine {
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 (failedFrames == 1) LogManager.addLog("WARN", "debug", "Auto/PTT PCM rejected group=$group frame=$frameN")
}
if (fed && requestedTalk && requestNs != reportedRequestNs) {
reportedRequestNs = requestNs
@@ -664,15 +691,15 @@ class RadioAudioEngine {
val transmitting = if (captureRunning && active && groupId == group && NativeLib.radioTransmitting()) group else null
if (_transmittingGroup.value != transmitting) {
_transmittingGroup.value = transmitting
LogManager.addLog("DEBUG", "radio", "TX state group=$group on=${transmitting != null} manual=$requestedTalk vad=$vadMode")
LogManager.addLog("DEBUG", "radio", "TX state group=$group on=${transmitting != null} manual=$requestedTalk mode=$autoMode")
}
val nowMs = SystemClock.elapsedRealtime()
if (nowMs - statsMs >= 1000L) {
LogManager.addLog("DEBUG", "debug", "VAD capture summary group=$group spanMs=${nowMs - statsMs} " +
LogManager.addLog("DEBUG", "debug", "Auto 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}")
"route=${route()} mode=$autoMode 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
@@ -0,0 +1,28 @@
package com.utun.chat.data
/** Один снимок режима и связанных порогов; значения режимов совпадают с radio_audio.h. */
data class RadioAutoSettings(
val enabled: Boolean = false,
val mode: Int = VAD,
val levelOn: Int = -35,
val levelOff: Int = -45,
val hangoverMs: Int = 200,
val vadThreshold: Int = 50,
val vadGain: Int = 15
) {
companion object { const val OFF = 0; const val VAD = 1; const val LEVEL = 2 }
fun valid(): Boolean = mode in VAD..LEVEL && levelOn in -90..0 && levelOff in -90..0 && levelOff < levelOn &&
hangoverMs in 50..2000 && vadThreshold in 10..95 && vadGain in 1..60
// Порядок фиксирован контрактом nativeRadioConfigure; вся группа применяется одним заданием uasync.
fun nativeValues(): IntArray = intArrayOf(if (enabled) 1 else 0, mode, levelOn, levelOff, hangoverMs, vadThreshold, vadGain)
}
/** Решение о захвате одинаково для VAD/уровня; измеритель не включает автопередачу. */
internal fun radioCaptureMode(active: Boolean, screenOn: Boolean, autoAllowed: Boolean,
settings: RadioAutoSettings, manual: Boolean, monitor: Boolean): Int? {
if (!active) return null
val automatic = screenOn && autoAllowed && settings.enabled
if (!manual && !automatic && !(screenOn && monitor)) return null
return if (automatic) settings.mode else RadioAutoSettings.OFF
}
@@ -127,7 +127,7 @@ class RadioOverlayService : Service() {
request()
return
}
if ((provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0) && !MicrophonePermission.granted()) {
if ((provider.getRadioOverlay() || provider.getInt("radio.auto_enabled") != 0) && !MicrophonePermission.granted()) {
setStatus(R.string.microphone_access_required)
MicrophonePermission.request("Floating PTT / VAD") { sync(ctx) }
return
@@ -150,7 +150,7 @@ class RadioOverlayService : Service() {
val provider = ChatApplication.instance.configProvider
val radio = RadioAudioEngine.instance()
val enabled = provider.getRadioOverlay()
val vad = provider.getInt("radio.vad_enabled") != 0
val vad = provider.getInt("radio.auto_enabled") != 0
val overlayAllowed = canDrawOverlays(ctx)
val micAllowed = MicrophonePermission.granted()
when {
@@ -280,7 +280,7 @@ class RadioOverlayService : Service() {
}
if (!wantOverlay) removeOverlay()
if (overlayView != null) setStatus(R.string.overlay_active)
if (!wantOverlay && provider.getInt("radio.vad_enabled") == 0) stopSelf()
if (!wantOverlay && provider.getInt("radio.auto_enabled") == 0) stopSelf()
}
private fun removeOverlay() {
@@ -9,6 +9,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
@@ -18,6 +20,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AutoMode
import androidx.compose.material.icons.filled.Equalizer
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.HeadsetMic
import androidx.compose.material.icons.automirrored.filled.VolumeUp
@@ -38,6 +41,10 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
@@ -50,6 +57,8 @@ import com.utun.chat.data.AudioRoute
import com.utun.chat.data.Channel
import com.utun.chat.data.Message
import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.data.RadioAutoSettings
import com.utun.chat.data.LogManager
import com.utun.chat.data.RadioOverlayService
import com.utun.chat.ui.components.ConversationMessages
import com.utun.chat.ui.components.CreatePollDialog
@@ -315,7 +324,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope()
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState()
val autoSettings by provider.radioAutoSettings.collectAsState(initial = remember { provider.getRadioAutoSettings() })
val volumePttEnabled by provider.radioVolumePttEnabled.collectAsState(initial = provider.getRadioVolumePtt())
val overlayShown by RadioOverlayService.isShown.collectAsState()
val overlayStatus by RadioOverlayService.status.collectAsState()
@@ -360,10 +369,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
volumePttEnabled, volumeKeys = true) {
viewModel.setRadioVolumePtt(!volumePttEnabled)
}
RadioControlButton(null, stringResource(if (vadActive) R.string.disable_vad else R.string.enable_vad),
vadActive, text = stringResource(R.string.vad_button)) {
viewModel.setRadioVad(!vadActive)
}
RadioAutomaticButton(autoSettings) { viewModel.setRadioAutomatic(!autoSettings.enabled) }
RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) {
scope.launch {
if (overlayEnabled && !overlayShown) RadioOverlayService.requestPermissions()
@@ -379,6 +385,45 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
}
}
/* Один обработчик жестов: удержание открывает мини-блок и не переключает автопередачу. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RadioAutomaticButton(settings: RadioAutoSettings, onClick: () -> Unit) {
var expanded by remember { mutableStateOf(false) }
val label = stringResource(if (settings.enabled) R.string.radio_auto_disable else R.string.radio_auto_enable)
val settingsLabel = stringResource(R.string.radio_auto_quick_settings)
val modeLabel = stringResource(if (settings.mode == RadioAutoSettings.LEVEL) R.string.radio_level_mode else R.string.vad_button)
val foreground = if (settings.enabled) Color.White else Color(0xFFAEBCC8)
Box {
Box(
Modifier.size(48.dp).clip(CircleShape)
.background(if (settings.enabled) Color(0xFF1F6FEB) else Color(0xFF24323D))
.combinedClickable(role = Role.Switch, onLongClickLabel = settingsLabel,
onLongClick = {
LogManager.addLog("DEBUG", "radio", "Open automatic transmission quick settings mode=${settings.mode}")
expanded = true
}, onClick = onClick)
.semantics {
contentDescription = "$modeLabel. $label. $settingsLabel"
toggleableState = ToggleableState(settings.enabled)
},
contentAlignment = Alignment.Center
) {
if (settings.mode == RadioAutoSettings.LEVEL)
Icon(Icons.Default.Equalizer, contentDescription = null, tint = foreground, modifier = Modifier.size(28.dp))
else Text(stringResource(R.string.vad_button), color = foreground, fontSize = 12.sp, maxLines = 1)
}
DropdownMenu(expanded = expanded, onDismissRequest = {
expanded = false
LogManager.addLog("DEBUG", "radio", "Close automatic transmission quick settings")
}, modifier = Modifier.width(320.dp).heightIn(max = 480.dp)) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
RadioAutomaticSettings(ChatApplication.instance.configProvider, compact = true)
}
}
}
}
@Composable
private fun RadioControlButton(
icon: ImageVector?, label: String, active: Boolean,
@@ -3,6 +3,9 @@ package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -30,6 +33,9 @@ import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.RadioOverlayService
import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.data.RadioAutoSettings
import com.utun.chat.data.ConfigProvider
import com.utun.chat.data.MicrophonePermission
import com.utun.chat.data.LogManager
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
@@ -578,6 +584,121 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit,
dismissButton = { TextButton(onClick = { showClearDb = false }) { Text(stringResource(R.string.cancel)) } })
}
/* Настройка детектора использует тот же native RMS, что и фактическая передача. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun RadioAutomaticSettings(provider: ConfigProvider, compact: Boolean = false) {
val engine = remember { RadioAudioEngine.instance() }
val scope = rememberCoroutineScope()
val saved by provider.radioAutoSettings.collectAsState(initial = remember { provider.getRadioAutoSettings() })
var draft by remember(saved) { mutableStateOf(saved) }
var saving by remember { mutableStateOf(false) }
var saveError by remember { mutableStateOf(false) }
var resumed by remember { mutableStateOf(false) }
var level by remember { mutableStateOf<Float?>(null) }
var blocked by remember { mutableStateOf(false) }
var active by remember { mutableStateOf(engine.isActive()) }
var micPermission by remember { mutableStateOf(MicrophonePermission.granted()) }
val autoActive by engine.autoActiveFlow.collectAsState()
val transmitting by engine.transmittingGroup.collectAsState()
LifecycleResumeEffect(engine) {
resumed = true
engine.setLevelMonitoring(true)
onPauseOrDispose { resumed = false; engine.setLevelMonitoring(false) }
}
LaunchedEffect(resumed) {
if (!resumed) { level = null; return@LaunchedEffect }
while (true) {
val status = NativeLib.radioInputStatus()
active = engine.isActive()
micPermission = MicrophonePermission.granted()
level = if (active && status[1] != 0f) status[0] else null
blocked = status[2] != 0f
delay(100)
}
}
if (!compact) Text(stringResource(R.string.radio_automatic), style = MaterialTheme.typography.titleMedium)
if (!compact) Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.radio_auto_enabled_label), Modifier.weight(1f))
Switch(draft.enabled, { draft = draft.copy(enabled = it) }, enabled = !saving)
}
Text(stringResource(R.string.radio_activation_mode), style = MaterialTheme.typography.bodyMedium)
// Прямой выбор без второго popup внутри мини-блока настроек.
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
listOf(RadioAutoSettings.VAD, RadioAutoSettings.LEVEL).forEachIndexed { index, mode ->
SegmentedButton(selected = draft.mode == mode, enabled = !saving,
shape = SegmentedButtonDefaults.itemShape(index, 2), modifier = Modifier.heightIn(min = 48.dp), onClick = {
LogManager.addLog("INFO", "radio", "Select automatic transmission mode: ${draft.mode} -> $mode (pending Apply)")
draft = draft.copy(mode = mode)
}) {
Text(stringResource(if (mode == RadioAutoSettings.VAD) R.string.vad_button else R.string.radio_level_mode))
}
}
}
Text(stringResource(when {
!active -> R.string.radio_level_enable_radio
!micPermission -> R.string.microphone_access_required
transmitting != null -> R.string.radio_auto_transmitting
saved.enabled && autoActive && blocked -> R.string.radio_auto_busy
saved.enabled && autoActive -> R.string.radio_auto_listening
saved.enabled -> R.string.radio_auto_inactive
else -> R.string.radio_auto_disabled
}), style = MaterialTheme.typography.bodySmall)
if (active && !micPermission) TextButton(onClick = {
MicrophonePermission.request("Radio level meter") { engine.setLevelMonitoring(resumed) }
}) { Text(stringResource(R.string.radio_level_allow_mic)) }
Text(if (level == null) stringResource(R.string.radio_level_unavailable) else
stringResource(R.string.radio_level_current, level!!), style = MaterialTheme.typography.bodySmall)
val background = MaterialTheme.colorScheme.surfaceVariant
Canvas(Modifier.fillMaxWidth().height(28.dp)) {
fun x(db: Float) = ((db + 90f) / 90f).coerceIn(0f, 1f) * size.width
drawRect(background)
level?.let { drawRect(Color(0xFF27AE60), size = Size(x(it), size.height)) }
drawLine(Color(0xFF3390EC), Offset(x(draft.levelOff.toFloat()), 0f), Offset(x(draft.levelOff.toFloat()), size.height), 3.dp.toPx())
drawLine(Color(0xFFE67E22), Offset(x(draft.levelOn.toFloat()), 0f), Offset(x(draft.levelOn.toFloat()), size.height), 3.dp.toPx())
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("−90 dBFS", style = MaterialTheme.typography.bodySmall)
Text("0", style = MaterialTheme.typography.bodySmall)
}
if (draft.mode == RadioAutoSettings.LEVEL) {
LabeledSlider(stringResource(R.string.radio_level_on), draft.levelOn.toFloat(), -90f..0f, 89, "${draft.levelOn} dBFS",
{ if (!saving) draft = draft.copy(levelOn = it.toInt()) })
LabeledSlider(stringResource(R.string.radio_level_off), draft.levelOff.toFloat(), -90f..0f, 89, "${draft.levelOff} dBFS",
{ if (!saving) draft = draft.copy(levelOff = it.toInt()) })
} else {
LabeledSlider(stringResource(R.string.speech_threshold), draft.vadThreshold.toFloat(), 10f..95f, 84, "${draft.vadThreshold}%",
{ if (!saving) draft = draft.copy(vadThreshold = it.toInt()) })
if (!compact) LabeledSlider(stringResource(R.string.vad_max_gain), draft.vadGain.toFloat(), 1f..60f, 58, "${draft.vadGain} dB",
{ if (!saving) draft = draft.copy(vadGain = it.toInt()) })
if (!compact) Text(stringResource(R.string.vad_compressor_hint), style = MaterialTheme.typography.bodySmall)
}
if (!compact) LabeledSlider(stringResource(R.string.silence_delay), draft.hangoverMs.toFloat(), 50f..2000f, 38,
stringResource(R.string.milliseconds, draft.hangoverMs), { if (!saving) draft = draft.copy(hangoverMs = it.toInt()) })
if (draft.levelOff >= draft.levelOn) Text(stringResource(R.string.radio_level_invalid), color = MaterialTheme.colorScheme.error)
if (saveError) Text(stringResource(R.string.radio_auto_save_failed), color = MaterialTheme.colorScheme.error)
Button(enabled = !saving && draft.valid() && draft != saved, onClick = {
val next = draft
val restart = next.enabled && !saved.enabled
saving = true; saveError = false
scope.launch {
try {
provider.saveRadioAutoSettings(next)
engine.reconfigure(restartAuto = restart)
if (next.enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ChatApplication.instance)
} catch (e: CancellationException) { throw e }
catch (e: Exception) {
saveError = true
LogManager.addLog("ERROR", "radio", "Cannot save automatic transmission settings: $e")
} finally { saving = false }
}
}) { Text(stringResource(R.string.radio_auto_apply)) }
if (!compact) {
Text(stringResource(R.string.radio_level_hint), style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.vad_screen_hint), style = MaterialTheme.typography.bodySmall)
}
}
/* ── Radio Settings Tab ── */
@OptIn(ExperimentalMaterial3Api::class)
@@ -590,40 +711,7 @@ private fun RadioSettingsTab() {
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
var vadThreshold by remember { mutableStateOf(provider.getInt("radio.vad_threshold")) }
var vadHangover by remember { mutableStateOf(provider.getInt("radio.vad_hangover_ms")) }
var vadCompMaxGain by remember { mutableStateOf(provider.getInt("radio.vad_compressor_max_gain_db")) }
val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState()
Text(stringResource(R.string.radio_vad), style = MaterialTheme.typography.titleMedium)
Text(stringResource(if (vadActive) R.string.vad_active else R.string.vad_inactive),
style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.vad_screen_hint),
style = MaterialTheme.typography.bodySmall)
LabeledSlider(stringResource(R.string.speech_threshold), vadThreshold.toFloat(), 10f..95f, 84, "$vadThreshold%",
{ vadThreshold = it.toInt() }, onValueChangeFinished = {
scope.launch {
provider.setValue("radio.vad_threshold", vadThreshold.toString())
NativeLib.setChatSetting("radio_vad_threshold", vadThreshold.toString())
RadioAudioEngine.instance().reconfigure()
}
})
LabeledSlider(stringResource(R.string.silence_delay), vadHangover.toFloat(), 50f..2000f, 38, stringResource(R.string.milliseconds, vadHangover),
{ vadHangover = it.toInt() }, onValueChangeFinished = {
scope.launch {
provider.setValue("radio.vad_hangover_ms", vadHangover.toString())
NativeLib.setChatSetting("radio_auto_hangover_ms", vadHangover.toString())
RadioAudioEngine.instance().reconfigure()
}
})
LabeledSlider(stringResource(R.string.vad_max_gain), vadCompMaxGain.toFloat(), 1f..60f, 58, "${vadCompMaxGain} dB",
{ vadCompMaxGain = it.toInt() }, onValueChangeFinished = {
scope.launch {
provider.setValue("radio.vad_compressor_max_gain_db", vadCompMaxGain.toString())
NativeLib.setChatSetting("radio_vad_compressor_max_gain_db", vadCompMaxGain.toString())
RadioAudioEngine.instance().reconfigure()
}
})
Text(stringResource(R.string.vad_compressor_hint), style = MaterialTheme.typography.bodySmall)
RadioAutomaticSettings(provider)
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Volume-кнопки PTT ── */
@@ -1011,7 +1099,7 @@ private fun LabeledSlider(
) {
Column {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(label, modifier = Modifier.weight(1f).padding(end = 8.dp), style = MaterialTheme.typography.bodyMedium)
Text(displayValue, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Slider(
@@ -940,15 +940,14 @@ class ChatViewModel : ViewModel() {
}
/** Переключатель авто-PTT на панели рации: сохранить выбор и перенастроить захват. */
fun setRadioVad(enabled: Boolean) {
fun setRadioAutomatic(enabled: Boolean) {
if (!_radioActive.value) return
viewModelScope.launch {
val ctx = ChatApplication.instance
ctx.configProvider.setValue("radio.vad_enabled", if (enabled) "1" else "0")
NativeLib.setChatSetting("radio_auto_enabled", if (enabled) "1" else "0")
LogManager.addLog("INFO", "RadioAudio", "Panel VAD enabled=$enabled channel=$radioChannelId")
ctx.configProvider.setValue("radio.auto_enabled", if (enabled) "1" else "0")
LogManager.addLog("INFO", "RadioAudio", "Panel automatic transmission enabled=$enabled channel=$radioChannelId")
if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ctx)
radioAudio.reconfigure(restartVad = enabled)
radioAudio.reconfigure(restartAuto = enabled)
}
}