Browse Source

android: add level-triggered radio and inline activation settings

master
evgeny 4 days ago
parent
commit
fddbba217d
  1. 11
      doc/tasks.md
  2. 62
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  3. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  4. 143
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  5. 28
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAutoSettings.kt
  6. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  7. 55
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  8. 158
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  9. 9
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  10. 23
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  11. 23
      tools/chatgui-android/app/src/main/res/values/strings.xml
  12. 70
      tools/chatgui-android/app/src/test/java/com/utun/chat/data/RadioAutoSettingsTest.kt
  13. 100
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  14. 2
      tools/chatgui-android/jni_bridge/android_jni_bridge.h

11
doc/tasks.md

@ -3,6 +3,17 @@
Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ. Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ.
## Текущая задача ## Текущая задача
[+] **Android: автопередача рации по уровню сигнала** — 08.10.2026:
режим VAD/уровень, два порога dBFS, живой измеритель из общего radio_audio и prefetch 600мс.
Вкладка рации временно держит микрофон для измерения при выключенной автоматике; уход со вкладки/пауза освобождает этот захват.
Оба детектора используют прежнее окно foreground/screen/background; PTT имеет приоритет, RX-защита остаётся общей.
Настройки сохраняются одним DataStore edit и передаются одним заданием uasync с подтверждением до старта capture.
Кнопка панели показывает выбранный режим и сохранённое включение; JNI принимает явный OFF/VAD/LEVEL.
Для уровня на кнопке — значок эквалайзера; удержание VAD/уровня открывает мини-блок режима, измерителя и порогов прямо у кнопки.
Выбор режима — две кнопки VAD/уровень с областью нажатия от 48 dp, без вложенного выпадающего меню; выбор записывается в лог radio.
Добавлены русские/английские строки и диагностика. assembleDebug (arm64-v8a/x86_64), 21 JVM-тест — PASS.
На физическом устройстве новый режим пока не проверен.
[+] **ChatGUI: автопередача рации по уровню сигнала** — 08.10.2026: [+] **ChatGUI: автопередача рации по уровню сигнала** — 08.10.2026:
выбор VAD/уровень, RMS 20мс после AEC до компрессоров, два порога dBFS (-35/-45 по умолчанию), выбор VAD/уровень, RMS 20мс после AEC до компрессоров, два порога dBFS (-35/-45 по умолчанию),
подтверждение 40мс и общая задержка отключения. Измеритель работает при включённой рации даже без автоматики. подтверждение 40мс и общая задержка отключения. Измеритель работает при включённой рации даже без автоматики.

62
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt

@ -4,6 +4,7 @@ import android.content.Context
import android.os.Build import android.os.Build
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
@ -70,9 +71,12 @@ class ConfigProvider(private val context: Context) {
private val soundEnabledKey = booleanPreferencesKey("sound_enabled") private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels") private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route") 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 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 radioVadCompressorMaxGainDbKey = intPreferencesKey("radio_vad_compressor_max_gain_db")
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt") private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay") 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_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000 key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1 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_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.vad_compressor_max_gain_db" -> context.dataStore.data.first()[radioVadCompressorMaxGainDbKey] ?: 15
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0 key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32 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_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000 "chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1 "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_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.vad_compressor_max_gain_db" -> prefs[radioVadCompressorMaxGainDbKey] = (value.toIntOrNull() ?: 15).coerceIn(1, 60)
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0 "radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32 "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 { fun getVoiceSettings(): VoiceSettings = runBlocking {
val prefs = context.dataStore.data.first() val prefs = context.dataStore.data.first()
val defaults = VoiceSettings() 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 radioVolumePttEnabled: Flow<Boolean> = context.dataStore.data.map { it[radioVolumePttKey] ?: false }
val radioOverlayEnabled: Flow<Boolean> = context.dataStore.data.map { it[radioOverlayKey] ?: 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 radioOverlayCached = false
@Volatile private var radioOverlayLoaded = false @Volatile private var radioOverlayLoaded = false
@ -609,10 +645,14 @@ class ConfigProvider(private val context: Context) {
appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb") appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb")
appendLine("storage_maxsize_gb=$chatMaxGb") appendLine("storage_maxsize_gb=$chatMaxGb")
appendLine("opus_codec_preset=$chatOpusPreset") appendLine("opus_codec_preset=$chatOpusPreset")
appendLine("radio_auto_enabled=${getInt("radio.vad_enabled")}") val radioAuto = getRadioAutoSettings()
appendLine("radio_vad_threshold=${getInt("radio.vad_threshold")}") appendLine("radio_auto_enabled=${if (radioAuto.enabled) 1 else 0}")
appendLine("radio_auto_hangover_ms=${getInt("radio.vad_hangover_ms")}") appendLine("radio_auto_mode=${radioAuto.mode}")
appendLine("radio_vad_compressor_max_gain_db=${getInt("radio.vad_compressor_max_gain_db")}") 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_mode=$radioOpusMode")
appendLine("radio_opus_bitrate=$radioOpusBitrate") appendLine("radio_opus_bitrate=$radioOpusBitrate")
appendLine("radio_capture_stereo=$radioCaptureStereo") appendLine("radio_capture_stereo=$radioCaptureStereo")

12
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -212,10 +212,12 @@ object NativeLib {
fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples) fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples)
fun radioAudioPull(groupId: Long, out: ShortArray, playedFrames: Long): Int = nativeRadioAudioPull(groupId, out, playedFrames) fun radioAudioPull(groupId: Long, out: ShortArray, playedFrames: Long): Int = nativeRadioAudioPull(groupId, out, playedFrames)
fun radioAudioStop() { nativeRadioAudioStop() } 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 radioCaptureStop() { nativeRadioCaptureStop() }
fun radioTransmitting(): Boolean = nativeRadioTransmitting() fun radioTransmitting(): Boolean = nativeRadioTransmitting()
fun radioVadMode(): Boolean = nativeRadioVadMode() fun radioAutoMode(): Int = nativeRadioAutoMode()
fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) } fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) }
fun radioTalkEnd(groupId: Long) { nativeRadioTalkEnd(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 nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean
private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray, playedFrames: Long): Int private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray, playedFrames: Long): Int
private external fun nativeRadioAudioStop() 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 nativeRadioCaptureStop()
private external fun nativeRadioTransmitting(): Boolean private external fun nativeRadioTransmitting(): Boolean
private external fun nativeRadioVadMode(): Boolean private external fun nativeRadioAutoMode(): Int
private external fun nativeRadioTalkBegin(groupId: Long) private external fun nativeRadioTalkBegin(groupId: Long)
private external fun nativeRadioTalkEnd(groupId: Long) private external fun nativeRadioTalkEnd(groupId: Long)

143
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt

@ -26,7 +26,7 @@ import java.util.concurrent.Executors
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). * Полудуплексный аудио-движок рации (аналог 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 * - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write
* *
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека * Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека
@ -82,15 +82,19 @@ class RadioAudioEngine {
private var configuredInput = INPUT_AUTO private var configuredInput = INPUT_AUTO
private var configuredMicSource = 0 private var configuredMicSource = 0
private var configuredStereo = false private var configuredStereo = false
private var configuredVad = false private var autoSettings = RadioAutoSettings()
@Volatile private var vadMode = false /* VAD авто-PTT: фидим PCM постоянно */ 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() val router = CallAudioRouter()
private val _talkingFlow = MutableStateFlow(false) private val _talkingFlow = MutableStateFlow(false)
val talkingFlow: StateFlow<Boolean> = _talkingFlow val talkingFlow: StateFlow<Boolean> = _talkingFlow
/** Фактический TX (PTT/VAD), привязанный к каналу. Capture публикует, io сбрасывает после join. */ /** Фактический TX (PTT/автоматика), привязанный к каналу. Capture публикует, io сбрасывает после join. */
private val _transmittingGroup = MutableStateFlow<Long?>(null) private val _transmittingGroup = MutableStateFlow<Long?>(null)
val transmittingGroup: StateFlow<Long?> = _transmittingGroup val transmittingGroup: StateFlow<Long?> = _transmittingGroup
@ -99,19 +103,19 @@ class RadioAudioEngine {
Thread(r, "radio-audio-io").apply { isDaemon = true } Thread(r, "radio-audio-io").apply { isDaemon = true }
} }
/* Таймер окна VAD; управление ресурсами всегда на io-потоке. */ /* Таймер окна автоматики; управление ресурсами всегда на io-потоке. */
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val releaseTalk = Runnable { finishTalk() } private val releaseTalk = Runnable { finishTalk() }
private val vadTimeout = Runnable { private val vadTimeout = Runnable {
io.execute { io.execute {
if (active && vadWindow.deadlineMs > 0L && !vadWindow.isAllowed(SystemClock.elapsedRealtime())) { 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() updateCapture()
} }
} }
} }
private val _vadActiveFlow = MutableStateFlow(false) private val _autoActiveFlow = MutableStateFlow(false)
val vadActiveFlow: StateFlow<Boolean> = _vadActiveFlow val autoActiveFlow: StateFlow<Boolean> = _autoActiveFlow
fun isActive(): Boolean = active fun isActive(): Boolean = active
@ -166,24 +170,24 @@ class RadioAudioEngine {
io.execute { releaseLocked() } io.execute { releaseLocked() }
} }
/** Смена настроек сохраняет окно VAD; явное включение открывает новое. */ /** Смена настроек сохраняет окно автоматики; явное включение открывает новое. */
fun reconfigure(restartVad: Boolean = false) { fun reconfigure(restartAuto: Boolean = false) {
val version = generation val version = generation
io.execute { io.execute {
if (!active || generation != version || audioTrack == null) return@execute if (!active || generation != version || audioTrack == null) return@execute
stopCapture() stopCapture()
loadCaptureSettings() loadCaptureSettings()
if (restartVad) vadWindow.restart(SystemClock.elapsedRealtime()) if (restartAuto) vadWindow.restart(SystemClock.elapsedRealtime())
updateCapture() updateCapture()
} }
} }
/** Видимое приложение сбрасывает фоновое окно VAD и отключает отсчёт. */ /** Видимое приложение сбрасывает фоновое окно автоматики и отключает отсчёт. */
fun setForeground(foreground: Boolean) { fun setForeground(foreground: Boolean) {
val nowMs = SystemClock.elapsedRealtime() val nowMs = SystemClock.elapsedRealtime()
io.execute { io.execute {
vadWindow.setForeground(foreground, nowMs) 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)}") "backgroundLeftMs=${vadWindow.timeLeftMs(nowMs)}")
if (active) updateCapture() if (active) updateCapture()
} }
@ -261,7 +265,35 @@ class RadioAudioEngine {
configuredInput = provider.getRadioInputSource() configuredInput = provider.getRadioInputSource()
configuredMicSource = provider.getRadioMicSource() configuredMicSource = provider.getRadioMicSource()
configuredStereo = provider.getRadioCaptureStereo() 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 с маршрутизацией входного устройства: встроенный микрофон /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон
@ -371,11 +403,11 @@ class RadioAudioEngine {
if (!active || generation != version) return@execute if (!active || generation != version) return@execute
if (intent.action == Intent.ACTION_SCREEN_OFF) { if (intent.action == Intent.ACTION_SCREEN_OFF) {
vadWindow.close() 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) { if (intent.action == Intent.ACTION_SCREEN_ON) {
vadWindow.restart(SystemClock.elapsedRealtime()) 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() updateCapture()
} }
@ -393,31 +425,23 @@ class RadioAudioEngine {
screenInteractive = screenOn() screenInteractive = screenOn()
} }
/** VAD без ограничения в приложении; после сворачивания — полные 15 минут при горящем экране. */ /** Оба детектора используют одно окно по экрану/фону; измеритель не продлевает это окно. */
private fun updateCapture() { private fun updateCapture() {
mainHandler.removeCallbacks(vadTimeout) mainHandler.removeCallbacks(vadTimeout)
val configured = configuredVad if (!settingsReady) return
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
vadWindow.setEnabled(configured, now) vadWindow.setEnabled(autoSettings.enabled, now)
val wantVad = active && screenInteractive && vadWindow.isAllowed(now) && configured val mode = radioCaptureMode(active, screenInteractive, vadWindow.isAllowed(now), autoSettings, requestedTalk, levelMonitoring)
val reason = when { LogManager.addLog("DEBUG", "radio", "Auto capture gate group=$audioGroup desired=$mode requested=${autoSettings.mode} " +
!active -> "radio_off" "enabled=${autoSettings.enabled} screen=$screenInteractive foreground=${vadWindow.foreground} " +
!configured -> "vad_disabled" "backgroundLeftMs=${vadWindow.timeLeftMs(now)} manual=$requestedTalk monitor=$levelMonitoring capture=$captureRunning")
!screenInteractive -> "screen_off" if (mode != null && mode != RadioAutoSettings.OFF && !vadWindow.foreground)
!vadWindow.isAllowed(now) -> "window_expired" mainHandler.postDelayed(vadTimeout, vadWindow.timeLeftMs(now).coerceAtLeast(1L))
else -> "vad_allowed" if (mode == null) { stopCapture(); return }
} val detectorFailed = mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF
LogManager.addLog("DEBUG", "debug", "VAD capture gate group=$audioGroup reason=$reason requested=$configured " + if (audioRecord != null && (captureRequestedMode != mode || (detectorFailed && !requestedTalk && !levelMonitoring))) stopCapture()
"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()
if (audioTrack == null) return if (audioTrack == null) return
if (audioRecord == null && !startCapture(wantVad)) { if (audioRecord == null && !startCapture(mode)) {
requestedTalk = false requestedTalk = false
_talkingFlow.value = false _talkingFlow.value = false
return return
@ -433,23 +457,24 @@ class RadioAudioEngine {
} }
} }
private fun startCapture(vad: Boolean): Boolean { private fun startCapture(mode: Int): Boolean {
if (!MicrophonePermission.granted()) { 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 return false
} }
val startNs = SystemClock.elapsedRealtimeNanos() val startNs = SystemClock.elapsedRealtimeNanos()
val (rec, channels) = createAudioRecord() val (rec, channels) = createAudioRecord()
if (rec == null) return false if (rec == null) return false
val createdNs = SystemClock.elapsedRealtimeNanos() val createdNs = SystemClock.elapsedRealtimeNanos()
if (!NativeLib.radioCaptureStart(audioGroup, channels, vad)) { if (!NativeLib.radioCaptureStart(audioGroup, channels, mode)) {
LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels vad=$vad") LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels mode=$mode")
rec.release() rec.release()
return false return false
} }
vadMode = NativeLib.radioVadMode() autoMode = NativeLib.radioAutoMode()
if (vad && !vadMode) { captureRequestedMode = mode
LogManager.addLog("ERROR", "debug", "VAD unavailable: capture cancelled group=$audioGroup") if (mode != RadioAutoSettings.OFF && autoMode == RadioAutoSettings.OFF && !requestedTalk && !levelMonitoring) {
LogManager.addLog("ERROR", "debug", "Automatic transmission unavailable: capture cancelled group=$audioGroup")
NativeLib.radioCaptureStop(); rec.release() NativeLib.radioCaptureStop(); rec.release()
return false return false
} }
@ -460,13 +485,13 @@ class RadioAudioEngine {
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "capture start failed: ${e.message}") LogManager.addLog("ERROR", "RadioAudio", "capture start failed: ${e.message}")
NativeLib.radioCaptureStop(); rec.release() NativeLib.radioCaptureStop(); rec.release()
vadMode = false autoMode = RadioAutoSettings.OFF
return false return false
} }
val recordingNs = SystemClock.elapsedRealtimeNanos() val recordingNs = SystemClock.elapsedRealtimeNanos()
audioRecord = rec audioRecord = rec
captureRunning = true captureRunning = true
_vadActiveFlow.value = vadMode _autoActiveFlow.value = autoMode != RadioAutoSettings.OFF
// BEGIN должен попасть в uasync раньше первого кадра ручной передачи. // BEGIN должен попасть в uasync раньше первого кадра ручной передачи.
if (requestedTalk) { if (requestedTalk) {
NativeLib.radioTalkBegin(audioGroup) NativeLib.radioTalkBegin(audioGroup)
@ -490,7 +515,7 @@ class RadioAudioEngine {
name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() name = "radio-capture"; priority = Thread.MAX_PRIORITY; start()
} }
LogManager.addLog("INFO", "debug", 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} " + "createMs=${(createdNs - startNs) / 1_000_000} prepareMs=${(preparedNs - createdNs) / 1_000_000} " +
"startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}") "startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}")
return true return true
@ -509,20 +534,21 @@ class RadioAudioEngine {
rec.release() rec.release()
audioRecord = null audioRecord = null
NativeLib.radioCaptureStop() 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 talking = false
if (_transmittingGroup.value != null) { if (_transmittingGroup.value != null) {
LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}") LogManager.addLog("DEBUG", "radio", "TX cleared after capture stop group=${_transmittingGroup.value}")
_transmittingGroup.value = null _transmittingGroup.value = null
} }
_talkingFlow.value = requestedTalk _talkingFlow.value = requestedTalk
_vadActiveFlow.value = false _autoActiveFlow.value = false
} }
private fun releaseLocked() { private fun releaseLocked() {
mainHandler.removeCallbacks(vadTimeout) mainHandler.removeCallbacks(vadTimeout)
settingsReady = false; ++settingsRequest
vadWindow.setEnabled(false, SystemClock.elapsedRealtime()) vadWindow.setEnabled(false, SystemClock.elapsedRealtime())
screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) } screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) }
screenReceiver = null screenReceiver = null
@ -638,12 +664,13 @@ class RadioAudioEngine {
maxRmsDb = maxOf(maxRmsDb, inputDb) maxRmsDb = maxOf(maxRmsDb, inputDb)
statsFrames++ statsFrames++
frameN++ frameN++
val vadAllowed = vadMode && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime()) val autoAllowed = autoMode != RadioAutoSettings.OFF && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime())
val feedAllowed = active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed) val feedAllowed = active && groupId == group && captureRunning &&
((talking && requestedTalk) || autoAllowed || (levelMonitoring && screenInteractive))
if (previousFeedAllowed != feedAllowed) { 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 " + "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 previousFeedAllowed = feedAllowed
} }
if (feedAllowed) { if (feedAllowed) {
@ -653,7 +680,7 @@ class RadioAudioEngine {
maxFeedNs = maxOf(maxFeedNs, SystemClock.elapsedRealtimeNanos() - feedStartedNs) maxFeedNs = maxOf(maxFeedNs, SystemClock.elapsedRealtimeNanos() - feedStartedNs)
if (fed) fedFrames++ else { if (fed) fedFrames++ else {
failedFrames++ 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) { if (fed && requestedTalk && requestNs != reportedRequestNs) {
reportedRequestNs = requestNs reportedRequestNs = requestNs
@ -664,15 +691,15 @@ class RadioAudioEngine {
val transmitting = if (captureRunning && active && groupId == group && NativeLib.radioTransmitting()) group else null val transmitting = if (captureRunning && active && groupId == group && NativeLib.radioTransmitting()) group else null
if (_transmittingGroup.value != transmitting) { if (_transmittingGroup.value != transmitting) {
_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() val nowMs = SystemClock.elapsedRealtime()
if (nowMs - statsMs >= 1000L) { 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 " + "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} " + "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 " + "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 statsMs = nowMs
statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0 statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0
minRmsDb = 0; maxRmsDb = -120; maxReadNs = 0L; maxFeedNs = 0L minRmsDb = 0; maxRmsDb = -120; maxReadNs = 0L; maxFeedNs = 0L

28
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAutoSettings.kt

@ -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
}

6
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt

@ -127,7 +127,7 @@ class RadioOverlayService : Service() {
request() request()
return 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) setStatus(R.string.microphone_access_required)
MicrophonePermission.request("Floating PTT / VAD") { sync(ctx) } MicrophonePermission.request("Floating PTT / VAD") { sync(ctx) }
return return
@ -150,7 +150,7 @@ class RadioOverlayService : Service() {
val provider = ChatApplication.instance.configProvider val provider = ChatApplication.instance.configProvider
val radio = RadioAudioEngine.instance() val radio = RadioAudioEngine.instance()
val enabled = provider.getRadioOverlay() 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 overlayAllowed = canDrawOverlays(ctx)
val micAllowed = MicrophonePermission.granted() val micAllowed = MicrophonePermission.granted()
when { when {
@ -280,7 +280,7 @@ class RadioOverlayService : Service() {
} }
if (!wantOverlay) removeOverlay() if (!wantOverlay) removeOverlay()
if (overlayView != null) setStatus(R.string.overlay_active) 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() { private fun removeOverlay() {

55
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -9,6 +9,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AutoMode 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.Bluetooth
import androidx.compose.material.icons.filled.HeadsetMic import androidx.compose.material.icons.filled.HeadsetMic
import androidx.compose.material.icons.automirrored.filled.VolumeUp 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.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.Modifier 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.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap 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.Channel
import com.utun.chat.data.Message import com.utun.chat.data.Message
import com.utun.chat.data.RadioAudioEngine 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.data.RadioOverlayService
import com.utun.chat.ui.components.ConversationMessages import com.utun.chat.ui.components.ConversationMessages
import com.utun.chat.ui.components.CreatePollDialog import com.utun.chat.ui.components.CreatePollDialog
@ -315,7 +324,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
val provider = ChatApplication.instance.configProvider val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay()) 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 volumePttEnabled by provider.radioVolumePttEnabled.collectAsState(initial = provider.getRadioVolumePtt())
val overlayShown by RadioOverlayService.isShown.collectAsState() val overlayShown by RadioOverlayService.isShown.collectAsState()
val overlayStatus by RadioOverlayService.status.collectAsState() val overlayStatus by RadioOverlayService.status.collectAsState()
@ -360,10 +369,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
volumePttEnabled, volumeKeys = true) { volumePttEnabled, volumeKeys = true) {
viewModel.setRadioVolumePtt(!volumePttEnabled) viewModel.setRadioVolumePtt(!volumePttEnabled)
} }
RadioControlButton(null, stringResource(if (vadActive) R.string.disable_vad else R.string.enable_vad), RadioAutomaticButton(autoSettings) { viewModel.setRadioAutomatic(!autoSettings.enabled) }
vadActive, text = stringResource(R.string.vad_button)) {
viewModel.setRadioVad(!vadActive)
}
RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) { RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) {
scope.launch { scope.launch {
if (overlayEnabled && !overlayShown) RadioOverlayService.requestPermissions() 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 @Composable
private fun RadioControlButton( private fun RadioControlButton(
icon: ImageVector?, label: String, active: Boolean, icon: ImageVector?, label: String, active: Boolean,

158
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -3,6 +3,9 @@ package com.utun.chat.ui.screens
import com.utun.chat.R import com.utun.chat.R
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.clickable 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.IncomingCallManager
import com.utun.chat.data.RadioOverlayService import com.utun.chat.data.RadioOverlayService
import com.utun.chat.data.RadioAudioEngine 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.LogManager
import com.utun.chat.data.ServerEntry import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState 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)) } }) 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 ── */ /* ── Radio Settings Tab ── */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -590,40 +711,7 @@ private fun RadioSettingsTab() {
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
var vadThreshold by remember { mutableStateOf(provider.getInt("radio.vad_threshold")) } RadioAutomaticSettings(provider)
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)
HorizontalDivider(Modifier.padding(vertical = 4.dp)) HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Volume-кнопки PTT ── */ /* ── Volume-кнопки PTT ── */
@ -1011,7 +1099,7 @@ private fun LabeledSlider(
) { ) {
Column { Column {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { 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) Text(displayValue, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
Slider( Slider(

9
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -940,15 +940,14 @@ class ChatViewModel : ViewModel() {
} }
/** Переключатель авто-PTT на панели рации: сохранить выбор и перенастроить захват. */ /** Переключатель авто-PTT на панели рации: сохранить выбор и перенастроить захват. */
fun setRadioVad(enabled: Boolean) { fun setRadioAutomatic(enabled: Boolean) {
if (!_radioActive.value) return if (!_radioActive.value) return
viewModelScope.launch { viewModelScope.launch {
val ctx = ChatApplication.instance val ctx = ChatApplication.instance
ctx.configProvider.setValue("radio.vad_enabled", if (enabled) "1" else "0") ctx.configProvider.setValue("radio.auto_enabled", if (enabled) "1" else "0")
NativeLib.setChatSetting("radio_auto_enabled", if (enabled) "1" else "0") LogManager.addLog("INFO", "RadioAudio", "Panel automatic transmission enabled=$enabled channel=$radioChannelId")
LogManager.addLog("INFO", "RadioAudio", "Panel VAD enabled=$enabled channel=$radioChannelId")
if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ctx) if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ctx)
radioAudio.reconfigure(restartVad = enabled) radioAudio.reconfigure(restartAuto = enabled)
} }
} }

23
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -396,4 +396,27 @@
<string name="message_actions">Действия с сообщением</string> <string name="message_actions">Действия с сообщением</string>
<string name="message_copy">Копировать текст</string> <string name="message_copy">Копировать текст</string>
<string name="message_send_failed">Не удалось сохранить сообщение. Попробуйте ещё раз.</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> </resources>

23
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -395,4 +395,27 @@
<string name="message_actions">Message actions</string> <string name="message_actions">Message actions</string>
<string name="message_copy">Copy text</string> <string name="message_copy">Copy text</string>
<string name="message_send_failed">Could not save the message. Please try again.</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> </resources>

70
tools/chatgui-android/app/src/test/java/com/utun/chat/data/RadioAutoSettingsTest.kt

@ -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))
}
}

100
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -1638,7 +1638,7 @@ int utun_bridge_radio_audio_pull(uint64_t group_id, int16_t* out, int max_sample
return radio_audio_pull_pcm(group_id, out, max_samples); return radio_audio_pull_pcm(group_id, out, max_samples);
} }
int utun_bridge_radio_vad_mode(void) { int utun_bridge_radio_auto_mode(void) {
return radio_audio_auto_mode(); return radio_audio_auto_mode();
} }
@ -2696,9 +2696,99 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioSta
} }
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStart( JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStart(
JNIEnv* env, jobject thiz, jlong groupId, jint channels, jboolean vad) { JNIEnv* env, jobject thiz, jlong groupId, jint channels, jint mode) {
(void)env; (void)thiz; (void)env; (void)thiz;
return radio_audio_capture_start((uint64_t)groupId, (int)channels, vad ? 1 : 0) == 0 ? JNI_TRUE : JNI_FALSE; return radio_audio_capture_start((uint64_t)groupId, (int)channels, (int)mode) == 0 ? JNI_TRUE : JNI_FALSE;
}
/* Снимок настроек применяется целиком в потоке ядра; callback разрешает новый capture. */
struct radio_config_request {
struct posted_task task;
struct UTUN_INSTANCE* inst;
jlong request_id;
jint values[7];
};
static void radio_config_applied(jlong request_id, int success) {
JNIEnv* env = NULL;
int attached = 0;
if (!g_jvm || !g_config_callback) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio config callback unavailable request=%lld", (long long)request_id);
return;
}
jint rc = (*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6);
if (rc == JNI_EDETACHED) {
if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio config: attach JVM failed");
return;
}
attached = 1;
} else if (rc != JNI_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio config: GetEnv failed rc=%d", rc);
return;
}
jclass cls = (*env)->GetObjectClass(env, g_config_callback);
jmethodID method = cls ? (*env)->GetMethodID(env, cls, "onRadioSettingsApplied", "(JZ)V") : NULL;
if (method) (*env)->CallVoidMethod(env, g_config_callback, method, request_id, success ? JNI_TRUE : JNI_FALSE);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env);
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio config callback failed request=%lld", (long long)request_id);
}
if (cls) (*env)->DeleteLocalRef(env, cls);
if (attached) (*g_jvm)->DetachCurrentThread(g_jvm);
}
static void radio_config_apply(void* arg) {
struct radio_config_request* req = arg;
static const char* keys[] = {"radio_auto_enabled", "radio_auto_mode", "radio_level_on_dbfs", "radio_level_off_dbfs",
"radio_auto_hangover_ms", "radio_vad_threshold", "radio_vad_compressor_max_gain_db"};
int success = req->inst == instance_lite_get_instance();
if (success) {
struct chat_setting_state next = req->inst->chat_settings;
for (int i = 0; i < 7; ++i) {
char value[16]; snprintf(value, sizeof(value), "%d", req->values[i]);
if (chat_setting_set_state(&next, keys[i], value) != 0) success = 0;
}
if (req->values[3] >= req->values[2]) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio config: invalid thresholds on=%d off=%d", req->values[2], req->values[3]);
success = 0;
}
if (success) req->inst->chat_settings = next;
} else DEBUG_WARN(DEBUG_CATEGORY_RADIO, "Android radio config: stale instance request=%lld", (long long)req->request_id);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Android radio config request=%lld success=%d enabled=%d mode=%d on=%d off=%d hangover=%dms",
(long long)req->request_id, success, req->values[0], req->values[1], req->values[2], req->values[3], req->values[4]);
radio_config_applied(req->request_id, success);
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioConfigure(
JNIEnv* env, jobject thiz, jlong request_id, jintArray settings) {
(void)thiz;
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
struct UASYNC* ua = instance_lite_get_uasync();
if (!inst || !uasync_is_running(ua) || !settings || (*env)->GetArrayLength(env, settings) != 7) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio config: core unavailable or invalid snapshot request=%lld", (long long)request_id);
return JNI_FALSE;
}
struct radio_config_request* req = u_calloc(1, sizeof(*req));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio config allocation failed"); return JNI_FALSE; }
(*env)->GetIntArrayRegion(env, settings, 0, 7, req->values);
if ((*env)->ExceptionCheck(env)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio config: cannot copy snapshot"); u_free(req); return JNI_FALSE;
}
req->inst = inst; req->request_id = request_id;
req->task.callback = radio_config_apply; req->task.arg = req;
uasync_post_reserved(ua, &req->task); // Очередь освобождает снимок, в том числе при shutdown.
return JNI_TRUE;
}
JNIEXPORT jfloatArray JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioInputStatus(JNIEnv* env, jobject thiz) {
(void)thiz;
struct radio_audio_input_status status = radio_audio_get_input_status();
jfloat values[] = {status.level_dbfs, (float)status.valid, (float)status.blocked};
jfloatArray result = (*env)->NewFloatArray(env, 3);
if (!result) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio input status allocation failed"); return NULL; }
(*env)->SetFloatArrayRegion(env, result, 0, 3, values);
return result;
} }
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStop(JNIEnv* env, jobject thiz) { JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStop(JNIEnv* env, jobject thiz) {
@ -2728,10 +2818,10 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioTransmit
return radio_audio_transmitting() ? JNI_TRUE : JNI_FALSE; return radio_audio_transmitting() ? JNI_TRUE : JNI_FALSE;
} }
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioVadMode( JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAutoMode(
JNIEnv* env, jobject thiz) { JNIEnv* env, jobject thiz) {
(void)env; (void)thiz; (void)env; (void)thiz;
return utun_bridge_radio_vad_mode() ? JNI_TRUE : JNI_FALSE; return (jint)utun_bridge_radio_auto_mode();
} }
JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioPull( JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioPull(

2
tools/chatgui-android/jni_bridge/android_jni_bridge.h

@ -144,7 +144,7 @@ int utun_bridge_radio_audio_start(uint64_t group_id, int capture_channels);
int utun_bridge_radio_audio_feed(uint64_t group_id, const int16_t* pcm, int count); int utun_bridge_radio_audio_feed(uint64_t group_id, const int16_t* pcm, int count);
int utun_bridge_radio_audio_pull(uint64_t group_id, int16_t* out, int max_samples); int utun_bridge_radio_audio_pull(uint64_t group_id, int16_t* out, int max_samples);
void utun_bridge_radio_audio_stop(void); void utun_bridge_radio_audio_stop(void);
int utun_bridge_radio_vad_mode(void); int utun_bridge_radio_auto_mode(void);
void utun_bridge_radio_talk_begin(uint64_t group_id); void utun_bridge_radio_talk_begin(uint64_t group_id);
void utun_bridge_radio_talk_end(uint64_t group_id); void utun_bridge_radio_talk_end(uint64_t group_id);

Loading…
Cancel
Save