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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -396,4 +396,27 @@
|
||||
<string name="message_actions">Действия с сообщением</string>
|
||||
<string name="message_copy">Копировать текст</string>
|
||||
<string name="message_send_failed">Не удалось сохранить сообщение. Попробуйте ещё раз.</string>
|
||||
<string name="radio_automatic">"Автоматическая передача рации"</string>
|
||||
<string name="radio_auto_enabled_label">"Автоматическая передача"</string>
|
||||
<string name="radio_activation_mode">"Режим активации"</string>
|
||||
<string name="radio_level_mode">"По уровню сигнала"</string>
|
||||
<string name="radio_level_button">"Уровень"</string>
|
||||
<string name="radio_auto_enable">"Включить автоматическую передачу"</string>
|
||||
<string name="radio_auto_disable">"Выключить автоматическую передачу"</string>
|
||||
<string name="radio_level_enable_radio">"Включите рацию для измерения уровня микрофона"</string>
|
||||
<string name="radio_level_allow_mic">"Разрешить доступ к микрофону"</string>
|
||||
<string name="radio_level_unavailable">"Уровень микрофона недоступен"</string>
|
||||
<string name="radio_level_current">"Текущий уровень: %1$.1f dBFS"</string>
|
||||
<string name="radio_level_on">"Порог включения (оранжевый):"</string>
|
||||
<string name="radio_level_off">"Порог отключения (синий):"</string>
|
||||
<string name="radio_level_invalid">"Порог отключения должен быть ниже порога включения"</string>
|
||||
<string name="radio_auto_save_failed">"Не удалось сохранить настройки рации"</string>
|
||||
<string name="radio_auto_apply">"Применить"</string>
|
||||
<string name="radio_auto_transmitting">"Передача"</string>
|
||||
<string name="radio_auto_busy">"Эфир занят"</string>
|
||||
<string name="radio_auto_listening">"Ожидание речи или сигнала"</string>
|
||||
<string name="radio_auto_inactive">"Автопередача недоступна или приостановлена"</string>
|
||||
<string name="radio_auto_disabled">"Автопередача выключена"</string>
|
||||
<string name="radio_level_hint">"В обоих режимах передаётся до 0,6 с звука перед срабатыванием. Уровень измеряется до компрессора рации. Для включения нужны 40 мс на пороге включения или выше. Передача заканчивается после заданной паузы ниже порога отключения. Измеритель работает и без автопередачи, пока открыта эта вкладка."</string>
|
||||
<string name="radio_auto_quick_settings">"Удерживайте для настройки режима и порогов"</string>
|
||||
</resources>
|
||||
@@ -395,4 +395,27 @@
|
||||
<string name="message_actions">Message actions</string>
|
||||
<string name="message_copy">Copy text</string>
|
||||
<string name="message_send_failed">Could not save the message. Please try again.</string>
|
||||
<string name="radio_automatic">"Automatic radio transmission"</string>
|
||||
<string name="radio_auto_enabled_label">"Enable automatic transmission"</string>
|
||||
<string name="radio_activation_mode">"Activation mode"</string>
|
||||
<string name="radio_level_mode">"By signal level"</string>
|
||||
<string name="radio_level_button">"Level"</string>
|
||||
<string name="radio_auto_enable">"Enable automatic transmission"</string>
|
||||
<string name="radio_auto_disable">"Disable automatic transmission"</string>
|
||||
<string name="radio_level_enable_radio">"Enable radio to measure the microphone level"</string>
|
||||
<string name="radio_level_allow_mic">"Allow microphone access"</string>
|
||||
<string name="radio_level_unavailable">"Microphone level unavailable"</string>
|
||||
<string name="radio_level_current">"Current level: %1$.1f dBFS"</string>
|
||||
<string name="radio_level_on">"Start threshold (orange):"</string>
|
||||
<string name="radio_level_off">"Stop threshold (blue):"</string>
|
||||
<string name="radio_level_invalid">"The stop threshold must be lower than the start threshold"</string>
|
||||
<string name="radio_auto_save_failed">"Failed to save radio settings"</string>
|
||||
<string name="radio_auto_apply">"Apply"</string>
|
||||
<string name="radio_auto_transmitting">"Transmitting"</string>
|
||||
<string name="radio_auto_busy">"Channel busy"</string>
|
||||
<string name="radio_auto_listening">"Waiting for speech or signal"</string>
|
||||
<string name="radio_auto_inactive">"Automatic transmission unavailable or suspended"</string>
|
||||
<string name="radio_auto_disabled">"Automatic transmission disabled"</string>
|
||||
<string name="radio_level_hint">"Both modes include 0.6 s of prebuffer. Level is measured before the radio compressor. Start requires 40 ms at or above the start threshold. Transmission ends after the configured silence below the stop threshold. The meter also works with automatic transmission disabled while this tab is visible."</string>
|
||||
<string name="radio_auto_quick_settings">"Hold to adjust activation mode and thresholds"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class RadioAutoSettingsTest {
|
||||
@Test fun validatesWholeThresholdPairAndBothModes() {
|
||||
for (mode in listOf(RadioAutoSettings.VAD, RadioAutoSettings.LEVEL)) {
|
||||
val settings = RadioAutoSettings(mode = mode)
|
||||
assertTrue(settings.valid())
|
||||
assertTrue(settings.copy(levelOn = 0, levelOff = -90).valid())
|
||||
assertFalse(settings.copy(levelOn = -45, levelOff = -45).valid())
|
||||
assertFalse(settings.copy(levelOn = -50, levelOff = -40).valid())
|
||||
assertFalse(settings.copy(levelOff = -91).valid())
|
||||
assertFalse(settings.copy(levelOn = 1).valid())
|
||||
assertFalse(settings.copy(hangoverMs = 49).valid())
|
||||
assertFalse(settings.copy(hangoverMs = 2001).valid())
|
||||
assertFalse(settings.copy(vadThreshold = 96).valid())
|
||||
assertFalse(settings.copy(vadGain = 0).valid())
|
||||
}
|
||||
assertFalse(RadioAutoSettings(mode = RadioAutoSettings.OFF).valid())
|
||||
assertFalse(RadioAutoSettings(mode = 3).valid())
|
||||
}
|
||||
|
||||
@Test fun nativeSnapshotHasExplicitModeAndSignedThresholds() {
|
||||
val settings = RadioAutoSettings(true, RadioAutoSettings.LEVEL, -40, -60, 500, 70, 20)
|
||||
assertArrayEquals(intArrayOf(1, 2, -40, -60, 500, 70, 20), settings.nativeValues())
|
||||
assertEquals(0, settings.copy(enabled = false).nativeValues()[0])
|
||||
}
|
||||
|
||||
@Test fun monitorNeverEnablesTransmissionAndReleasesCaptureWhenHidden() {
|
||||
for (mode in listOf(RadioAutoSettings.VAD, RadioAutoSettings.LEVEL)) {
|
||||
val settings = RadioAutoSettings(mode = mode)
|
||||
assertNull(radioCaptureMode(true, true, true, settings, manual = false, monitor = false))
|
||||
assertEquals(RadioAutoSettings.OFF, radioCaptureMode(true, true, true, settings, manual = false, monitor = true))
|
||||
assertNull(radioCaptureMode(true, false, true, settings, manual = false, monitor = true))
|
||||
assertNull(radioCaptureMode(false, true, true, settings, manual = true, monitor = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun bothDetectorsShareScreenAndBackgroundGatesWithManualOverride() {
|
||||
for (mode in listOf(RadioAutoSettings.VAD, RadioAutoSettings.LEVEL)) {
|
||||
val settings = RadioAutoSettings(enabled = true, mode = mode)
|
||||
assertEquals(mode, radioCaptureMode(true, true, true, settings, manual = false, monitor = false))
|
||||
assertNull(radioCaptureMode(true, true, false, settings, manual = false, monitor = false))
|
||||
assertNull(radioCaptureMode(true, false, true, settings, manual = false, monitor = false))
|
||||
assertEquals(RadioAutoSettings.OFF, radioCaptureMode(true, false, false, settings, manual = true, monitor = false))
|
||||
assertEquals(RadioAutoSettings.OFF, radioCaptureMode(true, true, false, settings, manual = false, monitor = true))
|
||||
assertEquals(mode, radioCaptureMode(true, true, true, settings, manual = true, monitor = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun foregroundIsUnlimitedAndEveryBackgroundPeriodGetsNewWindow() {
|
||||
val duration = 15 * 60 * 1000L
|
||||
val window = RadioVadWindow(duration)
|
||||
window.setForeground(true, 100)
|
||||
window.setEnabled(true, 100)
|
||||
assertTrue(window.isAllowed(2 * 60 * 60 * 1000L))
|
||||
window.setForeground(false, 8_000_000)
|
||||
assertTrue(window.isAllowed(8_000_000 + duration - 1))
|
||||
assertFalse(window.isAllowed(8_000_000 + duration))
|
||||
window.setForeground(true, 9_000_000)
|
||||
window.setForeground(false, 10_000_000)
|
||||
assertEquals(duration, window.timeLeftMs(10_000_000))
|
||||
window.close()
|
||||
assertFalse(window.isAllowed(10_000_001))
|
||||
window.restart(10_000_100)
|
||||
assertTrue(window.isAllowed(10_000_101))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user