Browse Source

Add independent radio compressor and Android audio controls

proxy
evgeny 3 days ago
parent
commit
fdec5b65c5
  1. 2
      src/chat/chat_setting.c
  2. 4
      src/config_parser.c
  3. 4
      src/radio/radio_audio.c
  4. 51
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  5. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  6. 44
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  7. 55
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  8. 29
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  9. 24
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

2
src/chat/chat_setting.c

@ -40,6 +40,8 @@ static const struct chat_setting_def g_setting_defs[] = {
{"compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, {"compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1},
{"compressor_max_gain_db", CHAT_SETTING_INT, 15, 1, 100}, {"compressor_max_gain_db", CHAT_SETTING_INT, 15, 1, 100},
{"compressor_rise_rate", CHAT_SETTING_INT, 2, 1, 100}, {"compressor_rise_rate", CHAT_SETTING_INT, 2, 1, 100},
{"radio_compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1},
{"radio_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 100},
{"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16}, {"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16},
{"storage_backfill_days", CHAT_SETTING_INT, 7, 1, 365}, {"storage_backfill_days", CHAT_SETTING_INT, 7, 1, 365},
{"standby_active_sec", CHAT_SETTING_INT, 2, 1, 3600}, {"standby_active_sec", CHAT_SETTING_INT, 2, 1, 3600},

4
src/config_parser.c

@ -1243,7 +1243,7 @@ static struct utun_config* parse_config_internal(FILE *fp, const char *filename)
assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value);
} else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && } else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 &&
chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, radio_compressor_enabled, radio_compressor_max_gain_db, media_download_max_peers", filename, line_num, key);
} }
break; break;
case SECTION_GUI: case SECTION_GUI:
@ -1402,7 +1402,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha
break; break;
case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break; case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break;
case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break; case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break;
case SECTION_CHATSERVER: cfg->global.chatserver_enabled = 1; cfg->global.db_sync_enabled = 1; if (strcmp(key, "db_path") == 0) assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); } break; case SECTION_CHATSERVER: cfg->global.chatserver_enabled = 1; cfg->global.db_sync_enabled = 1; if (strcmp(key, "db_path") == 0) assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, radio_capture_stereo, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, radio_compressor_enabled, radio_compressor_max_gain_db, media_download_max_peers", filename, line_num, key); } break;
case SECTION_GUI: break; case SECTION_GUI: break;
case SECTION_REALITY: parse_reality(key, value, &cfg->global.reality, filename, line_num); break; case SECTION_REALITY: parse_reality(key, value, &cfg->global.reality, filename, line_num); break;
case SECTION_SOCKS: parse_socks(key, value, &cfg->global, filename, line_num); break; case SECTION_SOCKS: parse_socks(key, value, &cfg->global, filename, line_num); break;

4
src/radio/radio_audio.c

@ -282,11 +282,11 @@ int radio_audio_start(uint64_t group_id, int capture_channels) {
accfg.block_duration_ms = RADIO_AUDIO_FRAME_MS; accfg.block_duration_ms = RADIO_AUDIO_FRAME_MS;
accfg.lookback_ms = 200; accfg.lookback_ms = 200;
accfg.lookahead_ms = 0; accfg.lookahead_ms = 0;
accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "compressor_max_gain_db", 15); accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "radio_compressor_max_gain_db", 15);
accfg.rise_rate_per_sec = (float)chat_setting_get_int(g_inst, "compressor_rise_rate", 2); accfg.rise_rate_per_sec = (float)chat_setting_get_int(g_inst, "compressor_rise_rate", 2);
accfg.target_level = 0.25f; /* -12 dBFS — как у голосовых сообщений */ accfg.target_level = 0.25f; /* -12 dBFS — как у голосовых сообщений */
audio_compressor_configure(ac, &accfg); audio_compressor_configure(ac, &accfg);
audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "compressor_enabled", 1)); audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "radio_compressor_enabled", 1));
} }
g_encoder = enc; g_encoder = enc;

51
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt

@ -42,6 +42,14 @@ class CallAudioRouter {
@Volatile var hasEarpiece = true @Volatile var hasEarpiece = true
private set private set
/** Куда откатываться при потере гарнитуры (звонок — EARPIECE, рация — SPEAKER). */
@Volatile var fallbackRoute = AudioRoute.EARPIECE
/** Автоматически следовать за гарнитурой: появилась → HEADSET, исчезла → fallbackRoute.
* Ручной setRoute() сбрасывает в false. */
@Volatile var autoHeadset = false
private set
/** Уведомление об изменении маршрута или доступности гарнитуры. */ /** Уведомление об изменении маршрута или доступности гарнитуры. */
var onChanged: ((AudioRoute, Boolean) -> Unit)? = null var onChanged: ((AudioRoute, Boolean) -> Unit)? = null
@ -73,10 +81,10 @@ class CallAudioRouter {
bindBtHeadsetProfile() bindBtHeadsetProfile()
hasEarpiece = findBuiltinEarpiece() != null hasEarpiece = findBuiltinEarpiece() != null
route = preferredRoute route = if (autoHeadset) (if (isHeadsetConnected()) AudioRoute.HEADSET else fallbackRoute) else preferredRoute
applyRoute(route) applyRoute(route)
refresh() refresh()
LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece)") LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute)")
} }
fun stop() { fun stop() {
@ -102,6 +110,7 @@ class CallAudioRouter {
LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available") LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available")
return return
} }
autoHeadset = false
if (route == r) return if (route == r) return
route = r route = r
preferredRoute = r preferredRoute = r
@ -116,6 +125,12 @@ class CallAudioRouter {
LogManager.addLog("INFO", "CallAudioRouter", "preferredRoute -> $r") LogManager.addLog("INFO", "CallAudioRouter", "preferredRoute -> $r")
} }
/** Включить/выключить автоследование за гарнитурой (рация включает, звонок — нет). */
fun setAutoHeadset(enabled: Boolean) {
autoHeadset = enabled
LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled")
}
/* ── применение маршрута ── */ /* ── применение маршрута ── */
private fun applyRoute(r: AudioRoute) { private fun applyRoute(r: AudioRoute) {
try { try {
@ -203,17 +218,27 @@ class CallAudioRouter {
if (now != prev) { if (now != prev) {
LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now") LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now")
} }
/* гарнитуру отключили — откатываемся на динамик */ /* авторежим: маршрут следует за гарнитурой */
if (!now && route == AudioRoute.HEADSET) { if (autoHeadset) {
route = AudioRoute.EARPIECE val want = if (now) AudioRoute.HEADSET else fallbackRoute
applyRoute(route) if (route != want) {
LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> EARPIECE") route = want
} applyRoute(route)
/* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */ LogManager.addLog("INFO", "CallAudioRouter", "auto route -> $want")
if (now && !prev && preferredRoute == AudioRoute.HEADSET && route != AudioRoute.HEADSET) { }
route = AudioRoute.HEADSET } else {
applyRoute(route) /* гарнитуру отключили — откатываемся на fallback-маршрут */
LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET") if (!now && route == AudioRoute.HEADSET) {
route = fallbackRoute
applyRoute(route)
LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> $fallbackRoute")
}
/* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */
if (now && !prev && preferredRoute == AudioRoute.HEADSET && route != AudioRoute.HEADSET) {
route = AudioRoute.HEADSET
applyRoute(route)
LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET")
}
} }
if (now != prev || route == AudioRoute.HEADSET) { if (now != prev || route == AudioRoute.HEADSET) {
onChanged?.invoke(route, now) onChanged?.invoke(route, now)

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

@ -60,6 +60,8 @@ class ConfigProvider(private val context: Context) {
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode") private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate") private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo") private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo")
private val radioCompressorEnabledKey = intPreferencesKey("radio_compressor_enabled")
private val radioCompressorMaxGainDbKey = intPreferencesKey("radio_compressor_max_gain_db")
private fun keysFilePath(): String { private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@ -141,6 +143,8 @@ class ConfigProvider(private val context: Context) {
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0 key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32 key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0 key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0
key == "radio.compressor_enabled" -> context.dataStore.data.first()[radioCompressorEnabledKey] ?: 1
key == "radio.compressor_max_gain_db" -> context.dataStore.data.first()[radioCompressorMaxGainDbKey] ?: 15
else -> 0 else -> 0
} }
} }
@ -176,6 +180,8 @@ class ConfigProvider(private val context: Context) {
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0 "radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32 "radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
"radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0 "radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0
"radio.compressor_enabled" -> prefs[radioCompressorEnabledKey] = value.toIntOrNull() ?: 1
"radio.compressor_max_gain_db" -> prefs[radioCompressorMaxGainDbKey] = value.toIntOrNull() ?: 15
} }
} }
} }
@ -477,6 +483,8 @@ class ConfigProvider(private val context: Context) {
val radioOpusMode = getInt("radio.opus_mode") val radioOpusMode = getInt("radio.opus_mode")
val radioOpusBitrate = getInt("radio.opus_bitrate") val radioOpusBitrate = getInt("radio.opus_bitrate")
val radioCaptureStereo = getInt("radio.capture_stereo") val radioCaptureStereo = getInt("radio.capture_stereo")
val radioCompEnabled = getInt("radio.compressor_enabled")
val radioCompMaxGain = getInt("radio.compressor_max_gain_db")
val chatCompEnabled = getInt("chat.compressor_enabled") val chatCompEnabled = getInt("chat.compressor_enabled")
val chatCompMaxGain = getInt("chat.compressor_max_gain_db") val chatCompMaxGain = getInt("chat.compressor_max_gain_db")
val chatCompRiseRate = getInt("chat.compressor_rise_rate") val chatCompRiseRate = getInt("chat.compressor_rise_rate")
@ -488,6 +496,8 @@ class ConfigProvider(private val context: Context) {
appendLine("radio_opus_mode=$radioOpusMode") appendLine("radio_opus_mode=$radioOpusMode")
appendLine("radio_opus_bitrate=$radioOpusBitrate") appendLine("radio_opus_bitrate=$radioOpusBitrate")
appendLine("radio_capture_stereo=$radioCaptureStereo") appendLine("radio_capture_stereo=$radioCaptureStereo")
appendLine("radio_compressor_enabled=$radioCompEnabled")
appendLine("radio_compressor_max_gain_db=$radioCompMaxGain")
appendLine("compressor_enabled=$chatCompEnabled") appendLine("compressor_enabled=$chatCompEnabled")
appendLine("compressor_max_gain_db=$chatCompMaxGain") appendLine("compressor_max_gain_db=$chatCompMaxGain")
appendLine("compressor_rise_rate=$chatCompRiseRate") appendLine("compressor_rise_rate=$chatCompRiseRate")

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

@ -2,6 +2,7 @@ package com.utun.chat.data
import android.content.Context import android.content.Context
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioManager import android.media.AudioManager
import android.media.AudioRecord import android.media.AudioRecord
@ -58,6 +59,9 @@ class RadioAudioEngine {
@Volatile private var micDead = false @Volatile private var micDead = false
@Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */ @Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */
/** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */
val router = CallAudioRouter()
private val _talkingFlow = MutableStateFlow(false) private val _talkingFlow = MutableStateFlow(false)
val talkingFlow: StateFlow<Boolean> = _talkingFlow val talkingFlow: StateFlow<Boolean> = _talkingFlow
@ -74,6 +78,15 @@ class RadioAudioEngine {
fun isTalking(): Boolean = talking fun isTalking(): Boolean = talking
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */
fun route(): AudioRoute = router.route
/** Доступна ли гарнитура (проводная/USB/BT). */
fun headsetAvailable(): Boolean = router.headsetAvailable
/** Ручной выбор выхода (отключает автоследование за гарнитурой). */
fun setRoute(r: AudioRoute) = router.setRoute(r)
/** Начать прослушивание канала (переключатель рации вкл). /** Начать прослушивание канала (переключатель рации вкл).
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
fun start(group: Long) { fun start(group: Long) {
@ -128,21 +141,25 @@ class RadioAudioEngine {
prevSpeaker = am.isSpeakerphoneOn prevSpeaker = am.isSpeakerphoneOn
try { try {
am.mode = AudioManager.MODE_IN_COMMUNICATION am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = true
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}") LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}")
} }
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, вынули → SPEAKER). */
router.fallbackRoute = AudioRoute.SPEAKER
router.setAutoHeadset(true)
router.start()
// ── AudioRecord (захват) с откатом стерео→моно ── // ── AudioRecord (захват) с откатом стерео→моно ──
val (rec, capCh) = createAudioRecord() val (rec, capCh) = createAudioRecord()
if (rec == null) { if (rec == null) {
restoreAudioMode(); active = false; return router.stop(); restoreAudioMode(); active = false; return
} }
captureChannels = capCh captureChannels = capCh
if (!NativeLib.radioAudioStart(group, capCh)) { if (!NativeLib.radioAudioStart(group, capCh)) {
rec.release() rec.release()
restoreAudioMode(); active = false router.stop(); restoreAudioMode(); active = false
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh") LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh")
return return
} }
@ -163,12 +180,18 @@ class RadioAudioEngine {
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
rec.release() rec.release()
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return
} }
audioRecord = rec audioRecord = rec
audioTrack = track audioTrack = track
micDead = false micDead = false
/* Прямое роутирование трека на выбранное устройство (обход глючной глобальной
* маршрутизации), как в CallAudioEngine. */
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
applyTrackOutputDevice(router.currentOutputDevice)
rec.startRecording() rec.startRecording()
track.play() track.play()
@ -256,6 +279,8 @@ class RadioAudioEngine {
audioTrack = null audioTrack = null
NativeLib.radioAudioStop() NativeLib.radioAudioStop()
router.onOutputDevice = null
router.stop()
restoreAudioMode() restoreAudioMode()
if (resetGroup) { if (resetGroup) {
@ -367,4 +392,15 @@ class RadioAudioEngine {
LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}") LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}")
} }
} }
private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) {
val track = audioTrack ?: return
if (dev == null) return
try {
track.setPreferredDevice(dev)
LogManager.addLog("DEBUG", "RadioAudio", "track preferred device -> type=${dev.type}")
} catch (e: Exception) {
LogManager.addLog("WARN", "RadioAudio", "setPreferredDevice failed: ${e.message}")
}
}
} }

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

@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material3.* import androidx.compose.material3.*
@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.utun.chat.data.AudioRoute
import com.utun.chat.data.Channel import com.utun.chat.data.Channel
import com.utun.chat.data.Message import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
@ -69,6 +72,8 @@ fun ChatScreen(
val radioTalking by viewModel.radioTalking.collectAsState() val radioTalking by viewModel.radioTalking.collectAsState()
val overlayShown by RadioOverlayService.isShown.collectAsState() val overlayShown by RadioOverlayService.isShown.collectAsState()
val localTalking by RadioAudioEngine.instance().talkingFlow.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() val soundOn by viewModel.soundOn.collectAsState()
@ -185,12 +190,31 @@ fun ChatScreen(
} }
} }
if (radioActive && !overlayShown) { if (radioActive && !overlayShown) {
RadioPttButton( Column(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
isTalking = localTalking, horizontalAlignment = Alignment.CenterHorizontally
onTalkBegin = { viewModel.radioTalkBegin() }, ) {
onTalkEnd = { viewModel.radioTalkEnd() } 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() }
)
}
} }
} }
InputBar( InputBar(
@ -297,3 +321,24 @@ private fun RadioPttButton(
} }
} }
} }
/* Круглая кнопка выбора выхода рации (динамик/гарнитура). */
@Composable
private fun RadioRouteButton(
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
) {
Box(contentAlignment = Alignment.Center) {
icon()
}
}
}

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

@ -575,6 +575,31 @@ private fun RadioSettingsTab() {
HorizontalDivider(Modifier.padding(vertical = 4.dp)) HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Компрессор (AGC) рации ── */
var radioCompOn by remember { mutableStateOf(provider.getInt("radio.compressor_enabled") != 0) }
var radioCompMaxGain by remember { mutableStateOf(provider.getInt("radio.compressor_max_gain_db")) }
Text("Компрессор (AGC) рации", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Enable compressor", modifier = Modifier.weight(1f))
Switch(checked = radioCompOn, onCheckedChange = {
radioCompOn = it
scope.launch { provider.setValue("radio.compressor_enabled", if (it) "1" else "0") }
NativeLib.setChatSetting("radio_compressor_enabled", if (it) "1" else "0")
RadioAudioEngine.instance().reconfigure()
})
}
LabeledSlider("Max gain:", radioCompMaxGain.toFloat(), 1f..45f, 44, "${radioCompMaxGain} dB",
{ v ->
radioCompMaxGain = v.toInt()
NativeLib.setChatSetting("radio_compressor_max_gain_db", radioCompMaxGain.toString())
},
onValueChangeFinished = {
scope.launch { provider.setValue("radio.compressor_max_gain_db", radioCompMaxGain.toString()) }
RadioAudioEngine.instance().reconfigure()
})
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Opus для рации: режим + битрейт ── */ /* ── Opus для рации: режим + битрейт ── */
val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)") val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)")
val opusBitrates = listOf(16, 32, 64, 96, 128) val opusBitrates = listOf(16, 32, 64, 96, 128)
@ -833,7 +858,8 @@ private fun StorageSettingsTab() {
@Composable @Composable
private fun LabeledSlider( private fun LabeledSlider(
label: String, value: Float, range: ClosedFloatingPointRange<Float>, label: String, value: Float, range: ClosedFloatingPointRange<Float>,
steps: Int, displayValue: String, onValueChange: (Float) -> Unit steps: Int, displayValue: String, onValueChange: (Float) -> Unit,
onValueChangeFinished: (() -> Unit)? = null
) { ) {
Column { Column {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@ -842,6 +868,7 @@ private fun LabeledSlider(
} }
Slider( Slider(
value = value, onValueChange = onValueChange, value = value, onValueChange = onValueChange,
onValueChangeFinished = onValueChangeFinished,
valueRange = range, steps = steps, valueRange = range, steps = steps,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )

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

@ -65,6 +65,12 @@ class ChatViewModel : ViewModel() {
private val _radioTalking = MutableStateFlow<Long?>(null) private val _radioTalking = MutableStateFlow<Long?>(null)
val radioTalking: StateFlow<Long?> = _radioTalking val radioTalking: StateFlow<Long?> = _radioTalking
private val _radioRoute = MutableStateFlow(AudioRoute.SPEAKER)
val radioRoute: StateFlow<AudioRoute> = _radioRoute
private val _radioHeadsetAvailable = MutableStateFlow(false)
val radioHeadsetAvailable: StateFlow<Boolean> = _radioHeadsetAvailable
private var radioToggleCount = 0 private var radioToggleCount = 0
/* рация: активный канал (null = выключена). Одна рация — один канал. */ /* рация: активный канал (null = выключена). Одна рация — один канал. */
@ -764,11 +770,19 @@ class ChatViewModel : ViewModel() {
radioChannelId = ch.id radioChannelId = ch.id
NativeLib.radioSetActive(groupId, true) NativeLib.radioSetActive(groupId, true)
radioAudio.start(groupId) radioAudio.start(groupId)
radioAudio.router.onChanged = { r, headset ->
_radioRoute.value = r
_radioHeadsetAvailable.value = headset
}
_radioRoute.value = radioAudio.route()
_radioHeadsetAvailable.value = radioAudio.headsetAvailable()
} else { } else {
radioChannelId = null radioChannelId = null
NativeLib.radioSetActive(groupId, false) NativeLib.radioSetActive(groupId, false)
radioAudio.stop() radioAudio.stop()
_radioTalking.value = null _radioTalking.value = null
_radioRoute.value = AudioRoute.SPEAKER
_radioHeadsetAvailable.value = false
} }
_radioActive.value = next _radioActive.value = next
syncRadioOverlay() syncRadioOverlay()
@ -785,6 +799,14 @@ class ChatViewModel : ViewModel() {
radioAudio.talkEnd() radioAudio.talkEnd()
} }
/** Ручной выбор выхода рации (динамик/гарнитура). */
fun setRadioRoute(r: AudioRoute) {
if (!_radioActive.value) return
radioAudio.setRoute(r)
_radioRoute.value = radioAudio.route()
LogManager.addLog("INFO", "VM", "setRadioRoute -> ${radioAudio.route()}")
}
/* Полностью выключить рацию (используется при удалении активного канала). */ /* Полностью выключить рацию (используется при удалении активного канала). */
private fun turnRadioOff() { private fun turnRadioOff() {
val chId = radioChannelId ?: return val chId = radioChannelId ?: return
@ -792,6 +814,8 @@ class ChatViewModel : ViewModel() {
radioChannelId = null radioChannelId = null
_radioActive.value = false _radioActive.value = false
_radioTalking.value = null _radioTalking.value = null
_radioRoute.value = AudioRoute.SPEAKER
_radioHeadsetAvailable.value = false
radioAudio.stop() radioAudio.stop()
syncRadioOverlay() syncRadioOverlay()
} }

Loading…
Cancel
Save