android: add level-triggered radio and inline activation settings
This commit is contained in:
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,
|
||||
|
||||
+123
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user