diff --git a/lib/opus_codec.c b/lib/opus_codec.c index 2cd5a003..da07813d 100644 --- a/lib/opus_codec.c +++ b/lib/opus_codec.c @@ -146,6 +146,11 @@ int opus_codec_encoder_signal_set(opus_codec_encoder_t* enc, int signal_type) { return opus_encoder_ctl(enc->opus_enc, OPUS_SET_SIGNAL(signal_type)); } +int opus_codec_encoder_application_set(opus_codec_encoder_t* enc, int application) { + if (!enc || !enc->opus_enc) return -1; + return opus_encoder_ctl(enc->opus_enc, OPUS_SET_APPLICATION(application)); +} + const char* opus_codec_strerror(int err) { switch (err) { case OPUS_OK: return "OK"; diff --git a/lib/opus_codec.h b/lib/opus_codec.h index bc552461..73aa6521 100644 --- a/lib/opus_codec.h +++ b/lib/opus_codec.h @@ -10,6 +10,10 @@ extern "C" { typedef struct opus_codec_encoder opus_codec_encoder_t; typedef struct opus_codec_decoder opus_codec_decoder_t; +/* Режим кодирования Opus: голос (VOIP, default) или музыка (AUDIO). */ +#define OPUS_CODEC_APP_VOIP 2048 /* OPUS_APPLICATION_VOIP */ +#define OPUS_CODEC_APP_AUDIO 2049 /* OPUS_APPLICATION_AUDIO */ + opus_codec_encoder_t* opus_codec_encoder_create(int sample_rate, int channels); void opus_codec_encoder_destroy(opus_codec_encoder_t* enc); int opus_codec_encode(opus_codec_encoder_t* enc, const int16_t* pcm, int frame_samples, @@ -23,6 +27,7 @@ int opus_codec_decode(opus_codec_decoder_t* dec, const uint8_t* data, int len, int opus_codec_encoder_bitrate_set(opus_codec_encoder_t* enc, int bitrate_bps); int opus_codec_encoder_complexity_set(opus_codec_encoder_t* enc, int complexity); int opus_codec_encoder_signal_set(opus_codec_encoder_t* enc, int signal_type); +int opus_codec_encoder_application_set(opus_codec_encoder_t* enc, int application); const char* opus_codec_strerror(int err); diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index 73481726..69de79dd 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -34,6 +34,8 @@ struct chat_setting_def { static const struct chat_setting_def g_setting_defs[] = { {"storage_autoload", CHAT_SETTING_BOOL, 1, 0, 1}, {"opus_codec_preset", CHAT_SETTING_INT, 1, 0, 4}, + {"radio_opus_mode", CHAT_SETTING_INT, 0, 0, 1}, + {"radio_opus_bitrate", CHAT_SETTING_INT, 32, 16, 128}, {"compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, {"compressor_max_gain_db", CHAT_SETTING_INT, 15, 1, 100}, {"compressor_rise_rate", CHAT_SETTING_INT, 2, 1, 100}, diff --git a/src/config_parser.c b/src/config_parser.c index 172db13d..a9cb0a05 100644 --- a/src/config_parser.c +++ b/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); } 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, 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, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); } break; case SECTION_GUI: @@ -1402,7 +1402,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha 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_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, 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, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); } break; case SECTION_GUI: 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; diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index e3553fb9..8be1b058 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -264,7 +264,10 @@ int radio_audio_start(uint64_t group_id) { opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; } - opus_codec_encoder_bitrate_set(enc, RADIO_AUDIO_BITRATE); + int opus_mode = chat_setting_get_int(g_inst, "radio_opus_mode", 0); + int opus_bitrate_kbps = chat_setting_get_int(g_inst, "radio_opus_bitrate", RADIO_AUDIO_BITRATE / 1000); + opus_codec_encoder_application_set(enc, opus_mode ? OPUS_CODEC_APP_AUDIO : OPUS_CODEC_APP_VOIP); + opus_codec_encoder_bitrate_set(enc, opus_bitrate_kbps * 1000); /* компрессор микрофона (AGC): конфиг из [chatserver], lookahead=0 — без задержки и хвоста. * gain_smoothed не сбрасывается между PTT — адаптивная громкость запоминается. */ @@ -290,8 +293,9 @@ int radio_audio_start(uint64_t group_id) { memset(g_sources, 0, sizeof(g_sources)); pthread_mutex_unlock(&g_mtx); - DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx compressor=%s", RADIO_AUDIO_ID, - (unsigned long long)group_id, ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable"); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx opus_mode=%s bitrate=%dkbps compressor=%s", RADIO_AUDIO_ID, + (unsigned long long)group_id, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps, + ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable"); return 0; } 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 0fe0d296..8ad424bf 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 @@ -56,6 +56,9 @@ class ConfigProvider(private val context: Context) { private val callAudioRouteKey = stringPreferencesKey("call_audio_route") private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt") private val radioOverlayKey = booleanPreferencesKey("radio_overlay") + private val radioMicSourceKey = intPreferencesKey("radio_mic_source") + private val radioOpusModeKey = intPreferencesKey("radio_opus_mode") + private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate") private fun keysFilePath(): String { val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath @@ -134,6 +137,8 @@ class ConfigProvider(private val context: Context) { key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15 key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000 key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1 + key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0 + key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32 else -> 0 } } @@ -166,6 +171,8 @@ class ConfigProvider(private val context: Context) { "chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15 "chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000 "chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1 + "radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0 + "radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32 } } } @@ -302,6 +309,23 @@ class ConfigProvider(private val context: Context) { context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled } } + @Volatile private var radioMicSourceCached = 0 + @Volatile private var radioMicSourceLoaded = false + + fun getRadioMicSource(): Int { + if (!radioMicSourceLoaded) { + radioMicSourceCached = runBlocking { context.dataStore.data.first()[radioMicSourceKey] ?: 0 } + radioMicSourceLoaded = true + } + return radioMicSourceCached + } + + suspend fun setRadioMicSource(source: Int) { + radioMicSourceCached = source + radioMicSourceLoaded = true + context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source } + } + /* ── Server entries ── */ suspend fun detectInterfaces(saved: List = emptyList()): List { @@ -430,6 +454,8 @@ class ConfigProvider(private val context: Context) { val chatAutoloadMaxMb = getInt("chat.storage_autoload_maxsize_mb") val chatMaxGb = getInt("chat.storage_maxsize_gb") val chatOpusPreset = getInt("chat.opus_codec_preset") + val radioOpusMode = getInt("radio.opus_mode") + val radioOpusBitrate = getInt("radio.opus_bitrate") val chatCompEnabled = getInt("chat.compressor_enabled") val chatCompMaxGain = getInt("chat.compressor_max_gain_db") val chatCompRiseRate = getInt("chat.compressor_rise_rate") @@ -438,6 +464,8 @@ class ConfigProvider(private val context: Context) { appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb") appendLine("storage_maxsize_gb=$chatMaxGb") appendLine("opus_codec_preset=$chatOpusPreset") + appendLine("radio_opus_mode=$radioOpusMode") + appendLine("radio_opus_bitrate=$radioOpusBitrate") appendLine("compressor_enabled=$chatCompEnabled") appendLine("compressor_max_gain_db=$chatCompMaxGain") appendLine("compressor_rise_rate=$chatCompRiseRate") 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 7be1a082..6db0e15e 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 @@ -7,6 +7,9 @@ import android.media.AudioManager import android.media.AudioRecord import android.media.AudioTrack import android.media.MediaRecorder +import android.os.Build +import android.os.Handler +import android.os.Looper import com.utun.chat.ChatApplication import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,6 +32,9 @@ class RadioAudioEngine { const val FRAME_MS = 20 const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 + /** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */ + const val RELEASE_HOLD_MS = 200L + /** Процессный синглтон: доступен из Activity и оверлей-сервиса. */ @Volatile private var inst: RadioAudioEngine? = null fun instance(): RadioAudioEngine = inst ?: synchronized(this) { @@ -56,6 +62,10 @@ class RadioAudioEngine { Thread(r, "radio-audio-io").apply { isDaemon = true } } + /* Отложенный FIN после релиза PTT (все talkBegin/talkEnd зовутся с main-потока). */ + private val mainHandler = Handler(Looper.getMainLooper()) + private val releaseRunnable = Runnable { finishTalk() } + fun isActive(): Boolean = active fun isTalking(): Boolean = talking @@ -75,6 +85,7 @@ class RadioAudioEngine { active = false talking = false _talkingFlow.value = false + mainHandler.removeCallbacks(releaseRunnable) io.execute { stopBlocking() } } @@ -88,7 +99,25 @@ class RadioAudioEngine { active = false releaseLocked(false) active = true + setupAudio() + } + + /* Полный перезапуск аудио на том же канале — применение новых настроек на лету. */ + fun reconfigure() { + val g = groupId + if (g == 0L) return + io.execute { + if (groupId != g) return@execute + if (talking) finishTalk() + if (active) { active = false; releaseLocked(false) } + active = true + setupAudio() + } + } + /* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */ + private fun setupAudio() { + val group = groupId if (!NativeLib.radioAudioStart(group)) { active = false LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") @@ -109,18 +138,8 @@ class RadioAudioEngine { // ── 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", "RadioAudio", "AudioRecord create failed: ${e.message}") - NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return - } - if (rec.state != AudioRecord.STATE_INITIALIZED) { - LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized") - rec.release() + val rec = createAudioRecord((recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) + if (rec == null) { NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return } @@ -159,6 +178,40 @@ class RadioAudioEngine { LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") } + /* Создание AudioRecord по выбранному источнику; UNPROCESSED с fallback на MIC. */ + private fun createAudioRecord(bufferSize: Int): AudioRecord? { + val pref = ChatApplication.instance.configProvider.getRadioMicSource() + val primary = when (pref) { + 1 -> MediaRecorder.AudioSource.MIC + 2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + MediaRecorder.AudioSource.UNPROCESSED else MediaRecorder.AudioSource.MIC + 3 -> MediaRecorder.AudioSource.VOICE_RECOGNITION + else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION + } + val rec = tryCreateRecord(primary, bufferSize) + if (rec != null) { LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$primary"); return rec } + if (pref == 2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + LogManager.addLog("WARN", "RadioAudio", "UNPROCESSED unavailable — fallback to MIC") + return tryCreateRecord(MediaRecorder.AudioSource.MIC, bufferSize) + } + return null + } + + private fun tryCreateRecord(source: Int, bufferSize: Int): AudioRecord? { + val rec = try { + AudioRecord(source, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize) + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source): ${e.message}") + return null + } + if (rec.state != AudioRecord.STATE_INITIALIZED) { + LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source)") + rec.release() + return null + } + return rec + } + private fun stopBlocking() { releaseLocked(true) } @@ -184,7 +237,9 @@ class RadioAudioEngine { /** PTT зажата — начинаем передачу. */ fun talkBegin() { - if (!active || talking) return + if (!active) return + mainHandler.removeCallbacks(releaseRunnable) + if (talking) return if (micDead) { LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)") } @@ -193,12 +248,27 @@ class RadioAudioEngine { NativeLib.radioTalkBegin(groupId) } - /** PTT отпущена — завершаем передачу (FIN). */ + /** PTT отпущена — добиваем хвост RELEASE_HOLD_MS, затем завершаем (FIN). */ fun talkEnd() { if (!active || !talking) return + mainHandler.removeCallbacks(releaseRunnable) + mainHandler.postDelayed(releaseRunnable, RELEASE_HOLD_MS) + LogManager.addLog("DEBUG", "RadioAudio", "talk release scheduled +${RELEASE_HOLD_MS}ms") + } + + /** Немедленное завершение передачи без хвоста (drag кнопки рации). */ + fun talkEndImmediate() { + if (!active || !talking) return + mainHandler.removeCallbacks(releaseRunnable) + finishTalk() + } + + private fun finishTalk() { + if (!talking) return talking = false _talkingFlow.value = false NativeLib.radioTalkEnd(groupId) + LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)") } private fun captureLoop() { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt index b64cbae6..01cd1062 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt @@ -185,7 +185,7 @@ class RadioOverlayService : Service() { dragAllowed = false } else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) { dragging = true - endTalk() + endTalkImmediate() lp.x = startX + (event.rawX - touchX).toInt() lp.y = startY + (event.rawY - touchY).toInt() wm.updateViewLayout(v, lp) @@ -216,10 +216,19 @@ class RadioOverlayService : Service() { } private fun endTalk() { + endTalkInternal(immediate = false) + } + + private fun endTalkImmediate() { + endTalkInternal(immediate = true) + } + + private fun endTalkInternal(immediate: Boolean) { if (!talking) return talking = false updateButtonColor() - RadioAudioEngine.instance().talkEnd() + if (immediate) RadioAudioEngine.instance().talkEndImmediate() + else RadioAudioEngine.instance().talkEnd() } private fun updateButtonColor() { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 909fb502..6579828a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -471,6 +471,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool /* ── Radio Settings Tab ── */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RadioSettingsTab() { val provider = ChatApplication.instance.configProvider @@ -528,6 +529,74 @@ private fun RadioSettingsTab() { RadioOverlayService.stop(ChatApplication.instance) }) } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + /* ── Источник микрофона ── */ + val micSources = listOf( + "Голос (VOICE_COMMUNICATION)", "Микрофон (MIC)", + "Без обработки (UNPROCESSED)", "Распознавание (VOICE_RECOGNITION)") + var micSource by remember { mutableStateOf(provider.getRadioMicSource().coerceIn(0, micSources.size - 1)) } + var micExpanded by remember { mutableStateOf(false) } + Text("Источник микрофона", style = MaterialTheme.typography.titleMedium) + ExposedDropdownMenuBox(expanded = micExpanded, onExpandedChange = { micExpanded = it }) { + OutlinedTextField(micSources[micSource], {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(micExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(micExpanded, onDismissRequest = { micExpanded = false }) { + micSources.forEachIndexed { i, n -> + DropdownMenuItem(text = { Text(n) }, onClick = { + micSource = i + scope.launch { provider.setRadioMicSource(i) } + RadioAudioEngine.instance().reconfigure() + micExpanded = false + }) + } + } + } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + /* ── Opus для рации: режим + битрейт ── */ + val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)") + val opusBitrates = listOf(16, 32, 64, 96, 128) + var opusMode by remember { mutableStateOf(provider.getInt("radio.opus_mode").coerceIn(0, 1)) } + var opusModeExpanded by remember { mutableStateOf(false) } + var opusBitrate by remember { mutableStateOf(provider.getInt("radio.opus_bitrate")) } + var opusBitrateExpanded by remember { mutableStateOf(false) } + Text("Opus (рация)", style = MaterialTheme.typography.titleMedium) + ExposedDropdownMenuBox(expanded = opusModeExpanded, onExpandedChange = { opusModeExpanded = it }) { + OutlinedTextField(opusModes[opusMode], {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusModeExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(opusModeExpanded, onDismissRequest = { opusModeExpanded = false }) { + opusModes.forEachIndexed { i, n -> + DropdownMenuItem(text = { Text(n) }, onClick = { + opusMode = i + scope.launch { provider.setValue("radio.opus_mode", i.toString()) } + NativeLib.setChatSetting("radio_opus_mode", i.toString()) + RadioAudioEngine.instance().reconfigure() + opusModeExpanded = false + }) + } + } + } + ExposedDropdownMenuBox(expanded = opusBitrateExpanded, onExpandedChange = { opusBitrateExpanded = it }) { + OutlinedTextField("${opusBitrate} kbps", {}, readOnly = true, singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusBitrateExpanded) }, + modifier = Modifier.menuAnchor().heightIn(min = 40.dp)) + ExposedDropdownMenu(opusBitrateExpanded, onDismissRequest = { opusBitrateExpanded = false }) { + opusBitrates.forEach { b -> + DropdownMenuItem(text = { Text("$b kbps") }, onClick = { + opusBitrate = b + scope.launch { provider.setValue("radio.opus_bitrate", b.toString()) } + NativeLib.setChatSetting("radio_opus_bitrate", b.toString()) + RadioAudioEngine.instance().reconfigure() + opusBitrateExpanded = false + }) + } + } + } } } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 1b38fd16..d4170811 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -710,11 +710,11 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) { if (member_count == 0) return u_strdup("[]"); snprintf(sql, sizeof(sql), - "SELECT p.node_id, COALESCE(n.online,0), COALESCE(n.name,'')," + "SELECT p.node_id, COALESCE(n.name,'')," " COALESCE(p.local_nick,''), COALESCE(p.adm_tags,'')," " COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0)" " FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id" - " ORDER BY n.online DESC, p.node_id ASC", peers_tbl); + " ORDER BY p.node_id ASC", peers_tbl); sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { @@ -733,13 +733,12 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) { while (sqlite3_step(st) == SQLITE_ROW) { uint64_t node_id = (uint64_t)sqlite3_column_int64(st, 0); - int online = sqlite3_column_int(st, 1); - const char* name = (const char*)sqlite3_column_text(st, 2); - const char* local_nick = (const char*)sqlite3_column_text(st, 3); - const char* adm_tags = (const char*)sqlite3_column_text(st, 4); - int node_type = sqlite3_column_int(st, 5); - int node_rtt = sqlite3_column_int(st, 6); - int storage_flag = sqlite3_column_int(st, 7); + const char* name = (const char*)sqlite3_column_text(st, 1); + const char* local_nick = (const char*)sqlite3_column_text(st, 2); + const char* adm_tags = (const char*)sqlite3_column_text(st, 3); + int node_type = sqlite3_column_int(st, 4); + int node_rtt = sqlite3_column_int(st, 5); + int storage_flag = sqlite3_column_int(st, 6); int is_admin = 0, is_super = 0, is_moder = 0, is_verified = 0, is_deleted = 0; if (adm_tags) { @@ -763,18 +762,18 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) { first = 0; size_t needed = snprintf(NULL, 0, - "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," + "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," "\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d}", - sep, esc_name, (unsigned long long)node_id, online, + sep, esc_name, (unsigned long long)node_id, is_admin, is_super, is_moder, storage_flag, is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0); while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } if (pos + needed + 2 <= cap) pos += (size_t)snprintf(json + pos, cap - pos, - "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," + "%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d," "\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d}", - sep, esc_name, (unsigned long long)node_id, online, + sep, esc_name, (unsigned long long)node_id, is_admin, is_super, is_moder, storage_flag, is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0); u_free(esc_name); @@ -796,19 +795,18 @@ char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_i sqlite3_stmt* st = NULL; if (sqlite3_prepare_v2(db, - "SELECT COALESCE(name,''), COALESCE(online,0)," + "SELECT COALESCE(name,'')," " COALESCE(last_seen_at,0), COALESCE(created_at,0)" " FROM nodes WHERE node_id=?", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); if (sqlite3_step(st) == SQLITE_ROW) { const char* name = (const char*)sqlite3_column_text(st, 0); - int online = sqlite3_column_int(st, 1); - int64_t last_seen = sqlite3_column_int64(st, 2); - int64_t created = sqlite3_column_int64(st, 3); + int64_t last_seen = sqlite3_column_int64(st, 1); + int64_t created = sqlite3_column_int64(st, 2); char* esc_name = json_escape_alloc(name && name[0] ? name : ""); pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, - ",\"n\":\"%s\",\"on\":%d,\"ls\":%lld,\"cr\":%lld", - esc_name, online, (long long)last_seen, (long long)created); + ",\"n\":\"%s\",\"on\":0,\"ls\":%lld,\"cr\":%lld", + esc_name, (long long)last_seen, (long long)created); u_free(esc_name); } sqlite3_finalize(st);