diff --git a/src/call/call_audio.c b/src/call/call_audio.c index 9f55e119..e2707456 100644 --- a/src/call/call_audio.c +++ b/src/call/call_audio.c @@ -18,6 +18,7 @@ #include "../../lib/mem.h" #include "../../lib/u_async.h" #include "../../lib/speex_aec.h" +#include "../../lib/audio_compressor.h" #include #include @@ -43,6 +44,7 @@ static struct call_tones* g_tones = NULL; static speex_aec_t* g_aec = NULL; static int g_aec_delay_frames = CALL_AEC_DELAY_FRAMES; static int g_aec_enabled = 1; /* по умолчанию: настройка aec_enabled */ +static struct audio_compressor* g_compressor = NULL; static const struct call_audio_ops g_ops = { .on_media = call_audio_on_media, @@ -153,11 +155,31 @@ int call_audio_start(uint64_t call_id) { } } + /* компрессор микрофона (AGC) — как в рации: выравнивает тихий проводной микрофон + * (HAL не применяет AGC на проводном входе). lookahead=0 — без задержки. */ + struct audio_compressor* ac = audio_compressor_create(); + if (ac) { + audio_compressor_config_t accfg = {0}; + accfg.sample_rate = CALL_AUDIO_SAMPLE_RATE; + accfg.channels = 1; + accfg.block_duration_ms = CALL_AUDIO_FRAME_MS; + accfg.lookback_ms = 200; + accfg.lookahead_ms = 0; + accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "call_compressor_max_gain_db", 15); + accfg.rise_rate_per_sec = (float)chat_setting_get_int(g_inst, "compressor_rise_rate", 2); + accfg.target_level = 0.25f; /* -12 dBFS — как у голосовых сообщений */ + audio_compressor_configure(ac, &accfg); + audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "call_compressor_enabled", 1)); + } else { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: compressor create failed — call without AGC", CALL_AUDIO_ID); + } + g_encoder = enc; g_decoder = dec; g_vj = j; g_tones = tones; g_aec = aec; + g_compressor = ac; g_call_id = call_id; g_active = 1; g_ending = 0; @@ -165,8 +187,10 @@ int call_audio_start(uint64_t call_id) { g_last_media_tb = 0; pthread_mutex_unlock(&g_mtx); - DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx aec=%d delay=%d", CALL_AUDIO_ID, - (unsigned long long)call_id, g_aec ? 1 : 0, g_aec_delay_frames); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx aec=%d delay=%d compressor=%s gain=%d", CALL_AUDIO_ID, + (unsigned long long)call_id, g_aec ? 1 : 0, g_aec_delay_frames, + ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable", + chat_setting_get_int(g_inst, "call_compressor_max_gain_db", 15)); return 0; } @@ -185,6 +209,7 @@ void call_audio_release(uint64_t call_id) { struct vj* j = NULL; struct call_tones* tones = NULL; speex_aec_t* aec = NULL; + struct audio_compressor* ac = NULL; int was_active = 0; pthread_mutex_lock(&g_mtx); @@ -200,6 +225,7 @@ void call_audio_release(uint64_t call_id) { j = g_vj; g_vj = NULL; tones = g_tones; g_tones = NULL; aec = g_aec; g_aec = NULL; + ac = g_compressor; g_compressor = NULL; } pthread_mutex_unlock(&g_mtx); @@ -211,6 +237,7 @@ void call_audio_release(uint64_t call_id) { if (j) vj_destroy(j); if (tones) call_tones_destroy(tones); if (aec) speex_aec_destroy(aec); + if (ac) audio_compressor_destroy(ac); DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: released call=0x%016llx", CALL_AUDIO_ID, (unsigned long long)call_id); } @@ -281,7 +308,18 @@ int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) { if (speex_aec_process_capture(g_aec, pcm, count, cleaned) == count) tx = cleaned; } - len = opus_codec_encode(g_encoder, tx, count, opus, (int)sizeof(opus)); + /* компрессор (AGC): push → кодируем прямо из выхода до clear_output. + * lookahead=0 ⇒ выход ровно count сэмплов. */ + if (g_compressor) { + audio_compressor_push(g_compressor, tx, (size_t)count); + const int16_t* out = audio_compressor_output(g_compressor); + if (out && audio_compressor_output_size(g_compressor) >= (size_t)count) + tx = out; + len = opus_codec_encode(g_encoder, tx, count, opus, (int)sizeof(opus)); + audio_compressor_clear_output(g_compressor); + } else { + len = opus_codec_encode(g_encoder, tx, count, opus, (int)sizeof(opus)); + } ua = g_inst ? g_inst->ua : NULL; inst = g_inst; pthread_mutex_unlock(&g_mtx); diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index 53cfb8c0..ced5fe7a 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -57,6 +57,8 @@ static const struct chat_setting_def g_setting_defs[] = { {"whisper_model_path", CHAT_SETTING_STRING, 0, 0, 0}, {"group_autoconnect", CHAT_SETTING_BOOL, 1, 0, 1}, {"aec_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, /* AEC (SpeexDSP) в звонке, desktop GUI */ + {"call_compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, + {"call_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 100}, }; #define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0])) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt index 95d599ed..c98749c4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt @@ -52,6 +52,11 @@ class CallAudioEngine { @Volatile private var muted = false + /* Переключение источника захвата: проводная гарнитура → MIC + wired-устройство, + * иначе VOICE_COMMUNICATION (дефолт). captureLoop сам меняет запись при рассинхроне. */ + @Volatile private var wantedWired = false + @Volatile private var wiredActive = false + /* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */ @Volatile private var playFramesWritten = 0L @Volatile private var capFramesRead = 0L @@ -87,11 +92,14 @@ class CallAudioEngine { prevSpeaker = am.isSpeakerphoneOn try { am.mode = AudioManager.MODE_IN_COMMUNICATION - am.isSpeakerphoneOn = true } catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") } + /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, + * вынули → сохранённый маршрут, по умолчанию тихая связь). */ + router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE + router.setAutoHeadset(true) router.start() /* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном @@ -101,20 +109,10 @@ class CallAudioEngine { syncAecForRoute(router.route) // ── AudioRecord (захват) ── - val recMin = AudioRecord.getMinBufferSize( - SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) - audioRecord = try { - AudioRecord( - MediaRecorder.AudioSource.VOICE_COMMUNICATION, - SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, - (recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) - } catch (e: Exception) { - LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed: ${e.message}") - NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false - } - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { - LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized") - audioRecord?.release(); audioRecord = null + wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null + wantedWired = wiredActive + audioRecord = createAudioRecord(wiredActive) + if (audioRecord == null) { NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false } @@ -143,6 +141,11 @@ class CallAudioEngine { router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } applyTrackOutputDevice(router.currentOutputDevice) + /* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */ + router.onInputDevice = { _ -> + wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null + } + active = true audioRecord?.startRecording() audioTrack?.play() @@ -157,7 +160,7 @@ class CallAudioEngine { /* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */ mainHandler.postDelayed(aecDelayRunnable, 300) - LogManager.addLog("INFO", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf") + LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf") return true } @@ -176,6 +179,7 @@ class CallAudioEngine { NativeLib.callAudioStop() router.onOutputDevice = null + router.onInputDevice = null router.stop() restoreAudioMode() @@ -187,14 +191,37 @@ class CallAudioEngine { private fun captureLoop() { val buf = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES) - while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + var rec = audioRecord + var frame = 0L + var sinceSwap = 0 + while (active) { + /* проводная гарнитура появилась/исчезла — меняем источник захвата */ + if (wantedWired != wiredActive) { + audioRecord = null /* защита measureAecDelay от UAF на освобождаемой записи */ + rec?.apply { try { stop() } catch (_: Exception) {}; release() } + wiredActive = wantedWired + rec = createAudioRecord(wiredActive) + if (rec == null) return + audioRecord = rec + rec.startRecording() + sinceSwap = 0 + } + val r = rec ?: return + if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) return var read = 0 while (read < FRAME_SAMPLES) { if (!active) return - val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return + val n = r.read(buf, read, FRAME_SAMPLES - read) if (n <= 0) return read += n } + /* диагностика: уровень захваченного сигнала (тишина vs звук) */ + if (sinceSwap < 10 || frame % 100L == 0L) { + LogManager.addLog("WARN", "CallAudio", + "cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}") + } + sinceSwap++ + frame++ val out = if (muted) silence else buf capFramesRead += FRAME_SAMPLES if (!NativeLib.callAudioFeed(callId, out)) { @@ -204,6 +231,50 @@ class CallAudioEngine { LogManager.addLog("DEBUG", "CallAudio", "capture loop done") } + /** RMS захваченного кадра в dBFS (0 = полная шкала, ниже = тише; тишина ~ -90). */ + private fun rmsDb(buf: ShortArray): Int { + var sum = 0L + for (s in buf) sum += s.toLong() * s.toLong() + val rms = Math.sqrt(sum.toDouble() / buf.size) + return if (rms < 1.0) -120 else (20.0 * Math.log10(rms / 32768.0)).toInt() + } + + /** Создать AudioRecord: всегда VOICE_COMMUNICATION (звонковый тракт «запитывает» + * микрофон проводной гарнитуры; AudioSource.MIC на ней даёт тишину). Для проводной + * гарнитуры дополнительно роутим setPreferredDevice на её микрофон. */ + private fun createAudioRecord(wired: Boolean): AudioRecord? { + val recMin = AudioRecord.getMinBufferSize( + SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) + val rec = try { + AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, + (recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) + } catch (e: Exception) { + LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed (wired=$wired): ${e.message}") + return null + } + if (rec.state != AudioRecord.STATE_INITIALIZED) { + LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized (wired=$wired)") + rec.release() + return null + } + if (wired) { + val dev = router.wiredInputDevice() + val ok = try { + rec.setPreferredDevice(dev) + } catch (e: Exception) { + LogManager.addLog("ERROR", "CallAudio", "record setPreferredDevice(wired) threw: ${e.message}") + false + } + LogManager.addLog("WARN", "CallAudio", + "record input -> VOICE_COMMUNICATION dev=${dev?.type ?: "-"} setPreferredDevice=$ok") + } else { + LogManager.addLog("WARN", "CallAudio", "record input -> VOICE_COMMUNICATION (default)") + } + return rec + } + private fun playLoop() { val buf = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt index 0754d50c..3a872703 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt @@ -42,6 +42,12 @@ class CallAudioRouter { @Volatile var hasEarpiece = true private set + /* Доступность входных устройств захвата (для строки аудиоисточников рации). */ + @Volatile var wiredInputAvailable = false + private set + @Volatile var btInputAvailable = false + private set + /** Куда откатываться при потере гарнитуры (звонок — EARPIECE, рация — SPEAKER). */ @Volatile var fallbackRoute = AudioRoute.EARPIECE @@ -53,12 +59,22 @@ class CallAudioRouter { /** Уведомление об изменении маршрута или доступности гарнитуры. */ var onChanged: ((AudioRoute, Boolean) -> Unit)? = null + /** Уведомление об изменении доступности входных устройств (проводная, BT). */ + var onInputDevicesChanged: ((Boolean, Boolean) -> Unit)? = null + /** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */ var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null @Volatile var currentOutputDevice: AudioDeviceInfo? = null private set + /** Целевое устройство захвата (для прямого роутирования AudioRecord через setPreferredDevice). + * non-null только для гарнитуры (проводная/USB/BT-SCO); для динамика/громкой — null (дефолт). */ + var onInputDevice: ((AudioDeviceInfo?) -> Unit)? = null + + @Volatile var currentInputDevice: AudioDeviceInfo? = null + private set + private val am: AudioManager = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager @@ -131,10 +147,17 @@ class CallAudioRouter { LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled") } + /** Немедленно переоценить маршрут (например, после включения автоследования). */ + fun reapply() { + if (!started) return + refresh() + } + /* ── применение маршрута ── */ private fun applyRoute(r: AudioRoute) { try { var dev: AudioDeviceInfo? = null + var inDev: AudioDeviceInfo? = null when (r) { AudioRoute.EARPIECE -> { stopBluetoothSco() @@ -168,6 +191,7 @@ class CallAudioRouter { startBluetoothSco() } dev = bt + inDev = btInputDevice() } else if (isBtHeadsetProfileConnected()) { /* классический BT: SCO ещё не поднят — запускаем и ждём * AudioDeviceCallback, который пере-применит маршрут */ @@ -181,13 +205,16 @@ class CallAudioRouter { am.isSpeakerphoneOn = false } dev = wired + inDev = wiredInputDevice() } } } currentOutputDevice = dev onOutputDevice?.invoke(dev) + currentInputDevice = inDev + onInputDevice?.invoke(inDev) LogManager.addLog("INFO", "CallAudioRouter", - "route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}") + "route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}") } catch (e: Exception) { LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}") } @@ -203,6 +230,32 @@ class CallAudioRouter { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE } + /* ── входные устройства захвата (для строки аудиоисточников рации) ── */ + fun builtinMicDevice(): AudioDeviceInfo? = + am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull { + it.type == AudioDeviceInfo.TYPE_BUILTIN_MIC + } + + fun wiredInputDevice(): AudioDeviceInfo? = + am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull { isWiredType(it.type) } + + fun btInputDevice(): AudioDeviceInfo? = + am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull { + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + } + + private fun refreshInputDevices() { + val wired = wiredInputDevice() != null + val bt = btInputDevice() != null || isBtHeadsetProfileConnected() + val changed = wired != wiredInputAvailable || bt != btInputAvailable + wiredInputAvailable = wired + btInputAvailable = bt + if (changed) { + LogManager.addLog("INFO", "CallAudioRouter", "inputDevices wired=$wired bt=$bt") + onInputDevicesChanged?.invoke(wired, bt) + } + } + /* ── мониторинг гарнитуры ── */ private fun onDevicesChanged() { /* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут, @@ -243,6 +296,7 @@ class CallAudioRouter { if (now != prev || route == AudioRoute.HEADSET) { onChanged?.invoke(route, now) } + refreshInputDevices() } private fun isHeadsetConnected(): Boolean = 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 7bb9f422..e40fd01c 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 @@ -57,6 +57,7 @@ class ConfigProvider(private val context: Context) { private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt") private val radioOverlayKey = booleanPreferencesKey("radio_overlay") private val radioMicSourceKey = intPreferencesKey("radio_mic_source") + private val radioInputSourceKey = intPreferencesKey("radio_input_source") private val radioOpusModeKey = intPreferencesKey("radio_opus_mode") private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate") private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo") @@ -273,7 +274,7 @@ class ConfigProvider(private val context: Context) { fun getCallAudioRoute(): AudioRoute = runBlocking { val s = context.dataStore.data.first()[callAudioRouteKey] - AudioRoute.entries.firstOrNull { it.name == s } ?: AudioRoute.SPEAKER + AudioRoute.entries.firstOrNull { it.name == s } ?: AudioRoute.EARPIECE } suspend fun setCallAudioRoute(r: AudioRoute) { @@ -335,6 +336,23 @@ class ConfigProvider(private val context: Context) { context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source } } + @Volatile private var radioInputSourceCached = 0 + @Volatile private var radioInputSourceLoaded = false + + fun getRadioInputSource(): Int { + if (!radioInputSourceLoaded) { + radioInputSourceCached = runBlocking { context.dataStore.data.first()[radioInputSourceKey] ?: 0 } + radioInputSourceLoaded = true + } + return radioInputSourceCached + } + + suspend fun setRadioInputSource(source: Int) { + radioInputSourceCached = source + radioInputSourceLoaded = true + context.dataStore.edit { prefs -> prefs[radioInputSourceKey] = source } + } + @Volatile private var radioCaptureStereoCached = 0 @Volatile private var radioCaptureStereoLoaded = false diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt new file mode 100644 index 00000000..90a1c3eb --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt @@ -0,0 +1,67 @@ +package com.utun.chat.data + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Датчик приближения: отслеживает «поднесли к уху» (near) / «отвели» (far). + * + * Используется во время звонка на разговорном динамике (EARPIECE), чтобы заблокировать + * экран, когда телефон у уха, и разблокировать, когда его отвели. Не требует + * runtime-разрешений. На устройствах без датчика — тихий no-op (WARN в лог). + */ +class ProximityManager(private val context: Context) { + + private val sensorManager: SensorManager = + context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY) + + private val _near = MutableStateFlow(false) + val near: StateFlow = _near + + private var listener: SensorEventListener? = null + private var started = false + + /** Возвращает true, если датчик присутствует и удалось зарегистрировать слушатель. */ + val available: Boolean + get() = sensor != null + + fun start() { + if (started) return + started = true + val s = sensor + if (s == null) { + LogManager.addLog("WARN", "Proximity", "no proximity sensor on this device") + return + } + val l = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + val nearNow = event.values[0] < event.sensor.maximumRange + if (_near.value != nearNow) { + _near.value = nearNow + LogManager.addLog("INFO", "Proximity", + "${if (nearNow) "NEAR" else "FAR"} v=${event.values[0]} max=${event.sensor.maximumRange}") + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + } + listener = l + sensorManager.registerListener(l, s, SensorManager.SENSOR_DELAY_NORMAL) + LogManager.addLog("INFO", "Proximity", "started (maxRange=${s.maximumRange})") + } + + fun stop() { + if (!started) return + started = false + listener?.let { sensorManager.unregisterListener(it) } + listener = null + _near.value = false + LogManager.addLog("INFO", "Proximity", "stopped") + } +} 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 7faa6fa1..7ad470ce 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 @@ -44,6 +44,12 @@ class RadioAudioEngine { fun instance(): RadioAudioEngine = inst ?: synchronized(this) { inst ?: RadioAudioEngine().also { inst = it } } + + /* Входное устройство захвата (значение radio_input_source). */ + const val INPUT_AUTO = 0 + const val INPUT_BUILTIN = 1 + const val INPUT_WIRED = 2 + const val INPUT_BT = 3 } @Volatile private var audioRecord: AudioRecord? = null @@ -82,6 +88,9 @@ class RadioAudioEngine { /** Текущий маршрут выхода рации (SPEAKER/HEADSET). */ fun route(): AudioRoute = router.route + /** Текущее входное устройство захвата (INPUT_AUTO/BUILTIN/WIRED/BT). */ + fun inputSource(): Int = ChatApplication.instance.configProvider.getRadioInputSource() + /** Доступна ли гарнитура (проводная/USB/BT). */ fun headsetAvailable(): Boolean = router.headsetAvailable @@ -197,6 +206,11 @@ class RadioAudioEngine { router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } applyTrackOutputDevice(router.currentOutputDevice) + /* AUTO-вход: смена маршрута выхода меняет и входное устройство — пересоздаём захват. */ + router.onInputDevice = { _ -> + if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure() + } + rec.startRecording() track.play() @@ -210,12 +224,28 @@ class RadioAudioEngine { LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf") } - /* Создать AudioRecord: сначала желаемые каналы (стерео), при неудаче — откат на моно. - * Источник: выбранный в настройках, UNPROCESSED с fallback на MIC. Возвращает (record, каналы). */ + /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон + * (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC), + * авто — системный дефолт. Возвращает (record, каналы). */ private fun createAudioRecord(): Pair { val ctx = ChatApplication.instance + val inputSource = ctx.configProvider.getRadioInputSource() val pref = ctx.configProvider.getRadioMicSource() val wantStereo = ctx.configProvider.getRadioCaptureStereo() + + /* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает» + * микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за + * маршрутом выхода (currentInputDevice из applyRoute). */ + val autoDev = router.currentInputDevice + val headsetInput = inputSource == INPUT_WIRED || inputSource == INPUT_BT || + (inputSource == INPUT_AUTO && autoDev != null) + val preferredDev = when (inputSource) { + INPUT_BUILTIN -> router.builtinMicDevice() + INPUT_WIRED -> router.wiredInputDevice() + INPUT_BT -> router.btInputDevice() + else -> autoDev + } + val primary = when (pref) { 1 -> MediaRecorder.AudioSource.MIC 2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) @@ -223,7 +253,9 @@ class RadioAudioEngine { 3 -> MediaRecorder.AudioSource.CAMCORDER else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION } - val sources = if (wantStereo && pref == 0) { + val sources = if (headsetInput) { + intArrayOf(MediaRecorder.AudioSource.VOICE_COMMUNICATION) + } else if (wantStereo && pref == 0) { /* voice-источник (AEC/NS/beamforming) коллапсирует в моно/один луч — * для стерео используем CAMCORDER (тот же тракт, что у видеозаписи). */ LogManager.addLog("WARN", "RadioAudio", "voice source не даёт стерео — использую CAMCORDER") @@ -233,7 +265,7 @@ class RadioAudioEngine { } else { intArrayOf(primary) } - val masks = if (wantStereo) + val masks = if (!headsetInput && wantStereo) intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO) else intArrayOf(AudioFormat.CHANNEL_IN_MONO) @@ -246,7 +278,9 @@ class RadioAudioEngine { if (rec != null) { if (wantStereo && channels == 1) LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono") - LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$src ch=$channels") + applyRecordPreferredDevice(rec, preferredDev) + LogManager.addLog("INFO", "RadioAudio", + "AudioRecord source=$src ch=$channels input=$inputSource dev=${preferredDev?.type ?: "default"}") return rec to channels } } @@ -254,6 +288,19 @@ class RadioAudioEngine { return null to 1 } + /* Прямое роутирование захвата на выбранное входное устройство (обход глючной + * глобальной маршрутизации); null — системный дефолт. */ + private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?) { + if (dev == null) return + val ok = try { + rec.setPreferredDevice(dev) + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}") + false + } + LogManager.addLog("WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok") + } + private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? { val rec = try { AudioRecord(source, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize) @@ -285,6 +332,7 @@ class RadioAudioEngine { NativeLib.radioAudioStop() router.onOutputDevice = null + router.onInputDevice = null router.stop() restoreAudioMode() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index cafb7894..a31b0c2f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt @@ -1,8 +1,12 @@ package com.utun.chat.ui.screens import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.content.pm.PackageManager import android.os.Build +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -13,26 +17,39 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.Headset +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.PhoneInTalk import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import com.utun.chat.data.AudioRoute +import com.utun.chat.data.LogManager import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel.CallPhase +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + private fun fmtDuration(sec: Int): String { val m = sec / 60 val s = sec % 60 @@ -82,12 +99,15 @@ fun CallScreen( val route by viewModel.audioRoute.collectAsState() val headsetAvailable by viewModel.headsetAvailable.collectAsState() val earpieceAvailable by viewModel.earpieceAvailable.collectAsState() + val proximityNear by viewModel.proximityNear.collectAsState() val phase = st?.phase ?: CallPhase.NONE val duration = st?.durationMs ?: 0 val peerNodeId = st?.peerNodeId ?: 0L val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: "" + val locked = phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE && proximityNear + val context = LocalContext.current val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == @@ -98,6 +118,26 @@ fun CallScreen( if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET) } + /* Пока телефон у уха (proximity) — глотаем Back, чтобы не свернуть звонок. */ + BackHandler(enabled = locked) {} + + /* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */ + DisposableEffect(locked) { + val act = context.findActivity() + val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) } + if (locked) { + controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller?.hide(WindowInsetsCompat.Type.systemBars()) + LogManager.addLog("INFO", "CallScreen", "system bars hidden (proximity lock)") + } + onDispose { + if (locked) { + controller?.show(WindowInsetsCompat.Type.systemBars()) + LogManager.addLog("INFO", "CallScreen", "system bars restored (proximity unlock)") + } + } + } + Box( modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)), contentAlignment = Alignment.Center @@ -210,5 +250,27 @@ fun CallScreen( } } } + + if (locked) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF0F1B22)) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) awaitPointerEvent() + } + }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Filled.Lock, "Экран заблокирован", tint = Color.White, modifier = Modifier.size(48.dp)) + Spacer(Modifier.height(16.dp)) + Text("Экран заблокирован", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(8.dp)) + Text("Отведите телефон от уха, чтобы разблокировать", color = Color(0xFF9BA6AD), fontSize = 14.sp) + } + } + } } } 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 9608f66c..6c9863be 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 @@ -7,15 +7,16 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Headset import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.People import androidx.compose.material3.* @@ -24,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -72,8 +74,6 @@ fun ChatScreen( val radioTalking by viewModel.radioTalking.collectAsState() val overlayShown by RadioOverlayService.isShown.collectAsState() val localTalking by RadioAudioEngine.instance().talkingFlow.collectAsState() - val radioRoute by viewModel.radioRoute.collectAsState() - val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState() /* звук сообщений канала */ val soundOn by viewModel.soundOn.collectAsState() @@ -158,6 +158,9 @@ fun ChatScreen( IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") } } ) + if (radioActive) { + RadioSourceBar(viewModel) + } if (radioTalking != null) { Surface(color = MaterialTheme.colorScheme.primaryContainer) { Text( @@ -190,31 +193,12 @@ fun ChatScreen( } } if (radioActive && !overlayShown) { - Column( + RadioPttButton( modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - RadioRouteButton( - active = radioRoute == AudioRoute.SPEAKER, - icon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, "Динамик", tint = Color.White, modifier = Modifier.size(22.dp)) }, - onClick = { viewModel.setRadioRoute(AudioRoute.SPEAKER) } - ) - if (radioHeadsetAvailable) { - RadioRouteButton( - active = radioRoute == AudioRoute.HEADSET, - icon = { Icon(Icons.Filled.Headset, "Гарнитура", tint = Color.White, modifier = Modifier.size(22.dp)) }, - onClick = { viewModel.setRadioRoute(AudioRoute.HEADSET) } - ) - } - } - Spacer(Modifier.height(12.dp)) - RadioPttButton( - isTalking = localTalking, - onTalkBegin = { viewModel.radioTalkBegin() }, - onTalkEnd = { viewModel.radioTalkEnd() } - ) - } + isTalking = localTalking, + onTalkBegin = { viewModel.radioTalkBegin() }, + onTalkEnd = { viewModel.radioTalkEnd() } + ) } } InputBar( @@ -322,23 +306,69 @@ private fun RadioPttButton( } } -/* Круглая кнопка выбора выхода рации (динамик/гарнитура). */ +/* Строка аудиоисточников рации: вход (авто/встроенный/проводная/беспроводная) + выход. */ @Composable -private fun RadioRouteButton( +private fun RadioSourceBar(viewModel: ChatViewModel) { + val radioAuto by viewModel.radioAuto.collectAsState() + val radioInputSource by viewModel.radioInputSource.collectAsState() + val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState() + val btInputAvailable by viewModel.btInputAvailable.collectAsState() + val radioRoute by viewModel.radioRoute.collectAsState() + val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState() + + Surface(color = Color(0xFF0F1B22)) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioSourceGroupLabel("Вход") + RadioSourceChip("Авто", radioAuto, { viewModel.setRadioAuto() }) + RadioSourceChip("Встроенный", radioInputSource == RadioAudioEngine.INPUT_BUILTIN, + { viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) }) + if (wiredInputAvailable) { + RadioSourceChip("Проводная", radioInputSource == RadioAudioEngine.INPUT_WIRED, + { viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) }) + } + if (btInputAvailable) { + RadioSourceChip("Беспроводная", radioInputSource == RadioAudioEngine.INPUT_BT, + { viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) }) + } + RadioSourceGroupLabel("Выход") + RadioSourceChip("Динамик", radioRoute == AudioRoute.SPEAKER, + { viewModel.setRadioRoute(AudioRoute.SPEAKER) }) + if (radioHeadsetAvailable) { + RadioSourceChip("Гарнитура", radioRoute == AudioRoute.HEADSET, + { viewModel.setRadioRoute(AudioRoute.HEADSET) }) + } + } + } +} + +@Composable +private fun RadioSourceChip( + label: String, active: Boolean, - icon: @Composable () -> Unit, onClick: () -> Unit ) { Surface( - modifier = Modifier - .size(44.dp) - .clickable(onClick = onClick), - shape = CircleShape, - color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D), - shadowElevation = 3.dp + modifier = Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D) ) { - Box(contentAlignment = Alignment.Center) { - icon() - } + Text( + label, + color = if (active) Color.White else Color(0xFF9BA6AD), + fontSize = 12.sp, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) } } + +@Composable +private fun RadioSourceGroupLabel(text: String) { + Text(text, color = Color(0xFF5A6A75), fontSize = 11.sp, fontWeight = FontWeight.Bold) +} 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 3899b4b2..e7bfa1d4 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 @@ -56,6 +56,8 @@ class ChatViewModel : ViewModel() { val callAudio = CallAudioEngine() + val proximity = ProximityManager(ChatApplication.instance) + val radioAudio = RadioAudioEngine.instance() /* рация: активна ли для текущего канала, кто говорит (src node_id, null = никто) */ @@ -71,6 +73,18 @@ class ChatViewModel : ViewModel() { private val _radioHeadsetAvailable = MutableStateFlow(false) val radioHeadsetAvailable: StateFlow = _radioHeadsetAvailable + private val _radioInputSource = MutableStateFlow(RadioAudioEngine.INPUT_AUTO) + val radioInputSource: StateFlow = _radioInputSource + + private val _wiredInputAvailable = MutableStateFlow(false) + val wiredInputAvailable: StateFlow = _wiredInputAvailable + + private val _btInputAvailable = MutableStateFlow(false) + val btInputAvailable: StateFlow = _btInputAvailable + + private val _radioAuto = MutableStateFlow(false) + val radioAuto: StateFlow = _radioAuto + private var radioToggleCount = 0 /* рация: активный канал (null = выключена). Одна рация — один канал. */ @@ -91,6 +105,9 @@ class ChatViewModel : ViewModel() { private val _earpieceAvailable = MutableStateFlow(true) val earpieceAvailable: StateFlow = _earpieceAvailable + private val _proximityNear = MutableStateFlow(false) + val proximityNear: StateFlow = _proximityNear + private val _playbackState = MutableStateFlow(PlaybackState()) val playbackState: StateFlow = _playbackState @@ -291,6 +308,20 @@ class ChatViewModel : ViewModel() { delay(1000) } } + viewModelScope.launch { + combine(_callState, _audioRoute) { st, route -> st to route }.collect { (st, route) -> + val monitor = st != null && st.phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE + if (monitor) { + proximity.start() + } else { + proximity.stop() + _proximityNear.value = false + } + } + } + viewModelScope.launch { + proximity.near.collect { _proximityNear.value = it } + } } private fun handleEvent(type: Int, data: ByteArray?) { @@ -775,8 +806,16 @@ class ChatViewModel : ViewModel() { _radioRoute.value = r _radioHeadsetAvailable.value = headset } + radioAudio.router.onInputDevicesChanged = { wired, bt -> + _wiredInputAvailable.value = wired + _btInputAvailable.value = bt + } _radioRoute.value = radioAudio.route() _radioHeadsetAvailable.value = radioAudio.headsetAvailable() + _wiredInputAvailable.value = radioAudio.router.wiredInputAvailable + _btInputAvailable.value = radioAudio.router.btInputAvailable + _radioInputSource.value = radioAudio.inputSource() + _radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO } else { radioChannelId = null NativeLib.radioSetActive(groupId, false) @@ -784,6 +823,10 @@ class ChatViewModel : ViewModel() { _radioTalking.value = null _radioRoute.value = AudioRoute.SPEAKER _radioHeadsetAvailable.value = false + _wiredInputAvailable.value = false + _btInputAvailable.value = false + _radioInputSource.value = RadioAudioEngine.INPUT_AUTO + _radioAuto.value = false } _radioActive.value = next syncRadioOverlay() @@ -803,11 +846,43 @@ class ChatViewModel : ViewModel() { /** Ручной выбор выхода рации (динамик/гарнитура). */ fun setRadioRoute(r: AudioRoute) { if (!_radioActive.value) return + _radioAuto.value = false radioAudio.setRoute(r) _radioRoute.value = radioAudio.route() LogManager.addLog("INFO", "VM", "setRadioRoute -> ${radioAudio.route()}") } + /** Ручной выбор входного устройства рации (выключает авто). */ + fun setRadioInputSource(src: Int) { + if (!_radioActive.value) return + val clamped = src.coerceIn(RadioAudioEngine.INPUT_AUTO, RadioAudioEngine.INPUT_BT) + if (clamped == _radioInputSource.value) return + _radioAuto.value = false + _radioInputSource.value = clamped + viewModelScope.launch { + ChatApplication.instance.configProvider.setRadioInputSource(clamped) + radioAudio.reconfigure() + } + LogManager.addLog("INFO", "VM", "setRadioInputSource -> $clamped") + } + + /** Авто: выход следует за гарнитурой, вход — автовыбор. */ + fun setRadioAuto() { + if (!_radioActive.value) return + _radioAuto.value = true + radioAudio.router.setAutoHeadset(true) + radioAudio.router.reapply() + _radioRoute.value = radioAudio.route() + if (_radioInputSource.value != RadioAudioEngine.INPUT_AUTO) { + _radioInputSource.value = RadioAudioEngine.INPUT_AUTO + viewModelScope.launch { + ChatApplication.instance.configProvider.setRadioInputSource(RadioAudioEngine.INPUT_AUTO) + radioAudio.reconfigure() + } + } + LogManager.addLog("INFO", "VM", "setRadioAuto -> route=${radioAudio.route()}") + } + /* Полностью выключить рацию (используется при удалении активного канала). */ private fun turnRadioOff() { val chId = radioChannelId ?: return @@ -817,6 +892,10 @@ class ChatViewModel : ViewModel() { _radioTalking.value = null _radioRoute.value = AudioRoute.SPEAKER _radioHeadsetAvailable.value = false + _wiredInputAvailable.value = false + _btInputAvailable.value = false + _radioInputSource.value = RadioAudioEngine.INPUT_AUTO + _radioAuto.value = false radioAudio.stop() syncRadioOverlay() }