Browse Source

android: add level-triggered radio and inline activation settings

master
evgeny 2 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:
выбор VAD/уровень, RMS 20мс после AEC до компрессоров, два порога dBFS (-35/-45 по умолчанию),
подтверждение 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 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")

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

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).
*
* Потоки:
* - 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

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()
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() {

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.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,

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 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(

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

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

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

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

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_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>

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);
}
int utun_bridge_radio_vad_mode(void) {
int utun_bridge_radio_auto_mode(void) {
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(
JNIEnv* env, jobject thiz, jlong groupId, jint channels, jboolean vad) {
JNIEnv* env, jobject thiz, jlong groupId, jint channels, jint mode) {
(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) {
@ -2728,10 +2818,10 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioTransmit
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) {
(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(

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_pull(uint64_t group_id, int16_t* out, int max_samples);
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_end(uint64_t group_id);

Loading…
Cancel
Save