Browse Source

call/radio: mic AGC compressor + selectable input device; proximity lock

master
evgeny 3 days ago
parent
commit
556b0bc7b7
  1. 42
      src/call/call_audio.c
  2. 2
      src/chat/chat_setting.c
  3. 107
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  4. 56
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  5. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  6. 67
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt
  7. 58
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  8. 62
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  9. 102
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  10. 79
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

42
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 <pthread.h>
#include <string.h>
@ -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;
}
/* компрессор (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);

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

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

56
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 =

20
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

67
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<Boolean> = _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")
}
}

58
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<AudioRecord?, Int> {
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()

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

102
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,33 +193,14 @@ fun ChatScreen(
}
}
if (radioActive && !overlayShown) {
Column(
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(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
isTalking = localTalking,
onTalkBegin = { viewModel.radioTalkBegin() },
onTalkEnd = { viewModel.radioTalkEnd() }
)
}
}
}
InputBar(
text = text,
onTextChange = { text = it },
@ -322,23 +306,69 @@ private fun RadioPttButton(
}
}
/* Круглая кнопка выбора выхода рации (динамик/гарнитура). */
/* Строка аудиоисточников рации: вход (авто/встроенный/проводная/беспроводная) + выход. */
@Composable
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 RadioRouteButton(
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)
}

79
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<Boolean> = _radioHeadsetAvailable
private val _radioInputSource = MutableStateFlow(RadioAudioEngine.INPUT_AUTO)
val radioInputSource: StateFlow<Int> = _radioInputSource
private val _wiredInputAvailable = MutableStateFlow(false)
val wiredInputAvailable: StateFlow<Boolean> = _wiredInputAvailable
private val _btInputAvailable = MutableStateFlow(false)
val btInputAvailable: StateFlow<Boolean> = _btInputAvailable
private val _radioAuto = MutableStateFlow(false)
val radioAuto: StateFlow<Boolean> = _radioAuto
private var radioToggleCount = 0
/* рация: активный канал (null = выключена). Одна рация — один канал. */
@ -91,6 +105,9 @@ class ChatViewModel : ViewModel() {
private val _earpieceAvailable = MutableStateFlow(true)
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
private val _proximityNear = MutableStateFlow(false)
val proximityNear: StateFlow<Boolean> = _proximityNear
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _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()
}

Loading…
Cancel
Save