diff --git a/doc/tasks.md b/doc/tasks.md index d3098ffa..652a2df7 100644 --- a/doc/tasks.md +++ b/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мс и общая задержка отключения. Измеритель работает при включённой рации даже без автоматики. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index f88a9d4a..1058a01f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/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 = 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 = context.dataStore.data.map { it[radioVolumePttKey] ?: false } val radioOverlayEnabled: Flow = context.dataStore.data.map { it[radioOverlayKey] ?: false } - val radioVadEnabled: Flow = context.dataStore.data.map { (it[radioVadEnabledKey] ?: 0) != 0 } + val radioAutoEnabled: Flow = 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") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 0834e5af..1f6f679e 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/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) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index e0b45dc6..65758da1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/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 = _talkingFlow - /** Фактический TX (PTT/VAD), привязанный к каналу. Capture публикует, io сбрасывает после join. */ + /** Фактический TX (PTT/автоматика), привязанный к каналу. Capture публикует, io сбрасывает после join. */ private val _transmittingGroup = MutableStateFlow(null) val transmittingGroup: StateFlow = _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 = _vadActiveFlow + private val _autoActiveFlow = MutableStateFlow(false) + val autoActiveFlow: StateFlow = _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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAutoSettings.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAutoSettings.kt new file mode 100644 index 00000000..386cba15 --- /dev/null +++ b/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 +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt index 006a627f..aaabaf53 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt +++ b/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() { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 1952df32..a47114ff 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/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, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 7a8e492f..d04df3e3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/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(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( diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index a62bd6bc..350414f1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/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) } } diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 0c91e145..dafdb770 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -396,4 +396,27 @@ Действия с сообщением Копировать текст Не удалось сохранить сообщение. Попробуйте ещё раз. + "Автоматическая передача рации" + "Автоматическая передача" + "Режим активации" + "По уровню сигнала" + "Уровень" + "Включить автоматическую передачу" + "Выключить автоматическую передачу" + "Включите рацию для измерения уровня микрофона" + "Разрешить доступ к микрофону" + "Уровень микрофона недоступен" + "Текущий уровень: %1$.1f dBFS" + "Порог включения (оранжевый):" + "Порог отключения (синий):" + "Порог отключения должен быть ниже порога включения" + "Не удалось сохранить настройки рации" + "Применить" + "Передача" + "Эфир занят" + "Ожидание речи или сигнала" + "Автопередача недоступна или приостановлена" + "Автопередача выключена" + "В обоих режимах передаётся до 0,6 с звука перед срабатыванием. Уровень измеряется до компрессора рации. Для включения нужны 40 мс на пороге включения или выше. Передача заканчивается после заданной паузы ниже порога отключения. Измеритель работает и без автопередачи, пока открыта эта вкладка." + "Удерживайте для настройки режима и порогов" diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index c3ce3e4e..0f2bdc22 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -395,4 +395,27 @@ Message actions Copy text Could not save the message. Please try again. + "Automatic radio transmission" + "Enable automatic transmission" + "Activation mode" + "By signal level" + "Level" + "Enable automatic transmission" + "Disable automatic transmission" + "Enable radio to measure the microphone level" + "Allow microphone access" + "Microphone level unavailable" + "Current level: %1$.1f dBFS" + "Start threshold (orange):" + "Stop threshold (blue):" + "The stop threshold must be lower than the start threshold" + "Failed to save radio settings" + "Apply" + "Transmitting" + "Channel busy" + "Waiting for speech or signal" + "Automatic transmission unavailable or suspended" + "Automatic transmission disabled" + "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." + "Hold to adjust activation mode and thresholds" diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/RadioAutoSettingsTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/RadioAutoSettingsTest.kt new file mode 100644 index 00000000..614ee150 --- /dev/null +++ b/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)) + } +} diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 34e62151..f4cba85a 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/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( diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 43c3d286..7f23b9f3 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/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);