diff --git a/src/call/voice_jitter.cpp b/src/call/voice_jitter.cpp index 2385a639..f9d7ad1c 100644 --- a/src/call/voice_jitter.cpp +++ b/src/call/voice_jitter.cpp @@ -19,7 +19,7 @@ #include #include -static const int VJ_MAX_ENC = 256; /* макс. размер кодированного кадра (Opus 20мс) */ +static const int VJ_MAX_ENC = 512; /* макс. размер кодированного кадра (Opus 20мс, стерео) */ static const double kDefaultTargetMs = 60.0; /* целевая глубина буфера (звонок) */ static const double kDefaultMinTempo = 1.0; static const double kDefaultMaxTempo = 1.6; @@ -126,8 +126,8 @@ struct vj* vj_create_cfg(const struct vj_cfg* cfg) { j->pre_roll = cfg->pre_roll; j->ring.assign((size_t)cfg->max_frames * VJ_MAX_ENC, 0); j->lens.assign(cfg->max_frames, 0); - j->pcm_buf.assign(cfg->frame_samples, 0); - j->fin.assign(cfg->frame_samples, 0.0f); + j->pcm_buf.assign((size_t)cfg->frame_samples * cfg->channels, 0); + j->fin.assign((size_t)cfg->frame_samples * cfg->channels, 0.0f); j->st.setSampleRate((uint)cfg->sample_rate); j->st.setChannels((uint)cfg->channels); @@ -249,30 +249,34 @@ int vj_pull(struct vj* j, int16_t* out, int max_samples) { j->st.setTempo(j->tempo); j->tempo_x100.store((int)std::lround(j->tempo * 100.0)); - /* 2) декодируем и кормим SoundTouch, пока не сможет выдать max_samples (или кольцо пусто) */ + /* 2) декодируем и кормим SoundTouch, пока не сможет выдать max_samples (или кольцо пусто). + * max_samples — суммарное число значений (интерлив); SoundTouch работает в слотах + * (слот = все каналы), поэтому max_slots = max_samples / channels. */ + int ch = j->ch; + uint max_slots = (uint)max_samples / (uint)ch; int guard = 0; - while (j->st.numSamples() < (uint)max_samples && guard++ < j->cap + 4) { + while (j->st.numSamples() < max_slots && guard++ < j->cap + 4) { int elen = 0; if (!vj_pop_enc(j, j->enc_buf, &elen)) break; int n = j->decode(j->decode_arg, j->enc_buf, elen, j->pcm_buf.data(), j->frame_samples); if (n <= 0) continue; /* битый/пустой кадр — пропускаем */ if (n > j->frame_samples) n = j->frame_samples; - for (int i = 0; i < n; i++) + for (int i = 0; i < n * ch; i++) j->fin[(size_t)i] = (float)j->pcm_buf[(size_t)i] / 32768.0f; j->st.putSamples(j->fin.data(), (uint)n); } /* 3) забираем готовое */ j->fout.resize((size_t)max_samples); - uint n = j->st.receiveSamples(j->fout.data(), (uint)max_samples); - for (uint i = 0; i < n; i++) { + uint n = j->st.receiveSamples(j->fout.data(), max_slots); + for (uint i = 0; i < n * (uint)ch; i++) { float v = j->fout[i] * 32767.0f; if (v > 32767.0f) v = 32767.0f; else if (v < -32768.0f) v = -32768.0f; out[i] = (int16_t)std::lrintf(v); } - if ((int)n < max_samples) j->underruns++; - return (int)n; + if ((int)(n * (uint)ch) < max_samples) j->underruns++; + return (int)(n * (uint)ch); } void vj_end(struct vj* j) { diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index 69de79dd..9a4492d5 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -36,6 +36,7 @@ static const struct chat_setting_def g_setting_defs[] = { {"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}, + {"radio_capture_stereo", CHAT_SETTING_INT, 0, 0, 1}, {"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 a9cb0a05..4825af85 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, radio_opus_mode, radio_opus_bitrate, 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, 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, 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_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_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.c b/src/radio/radio.c index 3a731a7f..a3e98510 100644 --- a/src/radio/radio.c +++ b/src/radio/radio.c @@ -123,17 +123,44 @@ static size_t radio_build(uint8_t* buf, uint64_t group_id, uint64_t src, uint16_ /* ── отправка ── */ +/* статистика отправки: раз в секунду — сколько кадров реально ушло в etcp_send и сколько упало. */ +static uint64_t s_send_stats_tb = 0; +static uint32_t s_send_ok = 0; +static uint32_t s_send_fail = 0; + static void radio_send_to(struct TOPO_GROUP* group, struct ETCP_CONN* conn, const uint8_t* payload, size_t payload_len) { struct ll_entry* ee = queue_entry_new(0); - if (!ee) return; + if (!ee) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: send: queue_entry_new OOM", RADIO_ID); + return; + } uint8_t* pkt = u_malloc(1 + payload_len); - if (!pkt) { queue_entry_free(ee); return; } + if (!pkt) { + queue_entry_free(ee); + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: send: u_malloc OOM len=%zu", RADIO_ID, payload_len); + return; + } pkt[0] = ETCP_ID_RADIO; memcpy(pkt + 1, payload, payload_len); ee->dgram = pkt; ee->len = (size_t)(1 + payload_len); - if (etcp_send(conn, ee) != 0) { u_free(pkt); queue_entry_free(ee); } + if (etcp_send(conn, ee) != 0) { + u_free(pkt); queue_entry_free(ee); + s_send_fail++; + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: send: etcp_send FAILED conn=%s len=%zu", + RADIO_ID, conn ? conn->log_name : "?", (size_t)(1 + payload_len)); + } else { + s_send_ok++; + } + + uint64_t now = get_time_tb(); + if (s_send_stats_tb == 0 || now - s_send_stats_tb >= 10000) { /* 1с (tb 0.1мс) */ + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: send stats ok=%u/s fail=%u", RADIO_ID, s_send_ok, s_send_fail); + s_send_stats_tb = now; + s_send_ok = 0; + s_send_fail = 0; + } } /* Есть ли за conn (не считая уже пройденных hop_list) хотя бы один подписчик рации. */ diff --git a/src/radio/radio.h b/src/radio/radio.h index 27221600..b2714bb0 100644 --- a/src/radio/radio.h +++ b/src/radio/radio.h @@ -9,6 +9,8 @@ #include +#include "radio_proto.h" + struct TOPO_GROUP; struct UTUN_INSTANCE; @@ -58,7 +60,7 @@ struct radio_media_arg { struct UTUN_INSTANCE* inst; uint64_t group_id; uint16_t len; - uint8_t opus[256]; + uint8_t opus[RADIO_MAX_OPUS]; }; struct radio_set_active_arg { diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index 8be1b058..292cf0e3 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -31,7 +31,8 @@ #define RADIO_AUDIO_BITRATE 32000 #define RADIO_AUDIO_MAX_SOURCES 16 #define RADIO_AUDIO_MAX_FRAMES 50 /* ~1000мс @20мс */ -#define RADIO_AUDIO_MIX_SAMPLES 960 /* максимум сэмплов за pull */ +#define RADIO_AUDIO_MIX_CHANNELS 2 /* воспроизведение всегда стерео (моно апмиксуется) */ +#define RADIO_AUDIO_MIX_SAMPLES (RADIO_AUDIO_FRAME_SAMPLES * RADIO_AUDIO_MIX_CHANNELS) /* 1920 значений за pull */ #define RADIO_AUDIO_SOURCE_TIMEOUT_TB 20000 /* 2с без кадров → источник завершён */ #define RADIO_AUDIO_REORDER_MAX 8 /* кадров в окне переупорядочивания (~160мс) */ #define RADIO_AUDIO_REORDER_TO_TB 1000 /* 100мс: force-flush pending, если gap не закрылся */ @@ -71,6 +72,7 @@ static int g_active = 0; static uint64_t g_group_id = 0; static opus_codec_encoder_t* g_encoder = NULL; static struct audio_compressor* g_compressor = NULL; +static int g_capture_channels = 1; /* каналов захвата: 1=моно, 2=стерео */ static struct radio_source g_sources[RADIO_AUDIO_MAX_SOURCES]; static int32_t g_mix[RADIO_AUDIO_MIX_SAMPLES]; static uint64_t g_last_stats_tb = 0; @@ -92,7 +94,7 @@ static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t static struct vj* radio_vj_create(opus_codec_decoder_t* dec) { struct vj_cfg cfg; cfg.sample_rate = RADIO_AUDIO_SAMPLE_RATE; - cfg.channels = 1; + cfg.channels = RADIO_AUDIO_MIX_CHANNELS; cfg.frame_samples = RADIO_AUDIO_FRAME_SAMPLES; cfg.frame_ms = RADIO_AUDIO_FRAME_MS; cfg.max_frames = RADIO_AUDIO_MAX_FRAMES; @@ -221,7 +223,7 @@ static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) { for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) { if (g_sources[i].used) continue; s = &g_sources[i]; - s->dec = opus_codec_decoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); + s->dec = opus_codec_decoder_create(RADIO_AUDIO_SAMPLE_RATE, RADIO_AUDIO_MIX_CHANNELS); if (!s->dec) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: decoder create failed", RADIO_AUDIO_ID); return NULL; } s->vj = radio_vj_create(s->dec); if (!s->vj) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } @@ -255,14 +257,15 @@ void radio_audio_destroy(struct UTUN_INSTANCE* inst) { DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: destroyed", RADIO_AUDIO_ID); } -int radio_audio_start(uint64_t group_id) { +int radio_audio_start(uint64_t group_id, int capture_channels) { if (group_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: start bad group_id=0", RADIO_AUDIO_ID); return -1; } + if (capture_channels != 1 && capture_channels != 2) capture_channels = 1; pthread_mutex_lock(&g_mtx); if (g_active && g_group_id == group_id) { pthread_mutex_unlock(&g_mtx); return 0; } if (g_active) { pthread_mutex_unlock(&g_mtx); radio_audio_stop(); pthread_mutex_lock(&g_mtx); } - opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); + opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, capture_channels); if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; } 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); @@ -275,7 +278,7 @@ int radio_audio_start(uint64_t group_id) { if (ac) { audio_compressor_config_t accfg = {0}; accfg.sample_rate = RADIO_AUDIO_SAMPLE_RATE; - accfg.channels = 1; + accfg.channels = capture_channels; accfg.block_duration_ms = RADIO_AUDIO_FRAME_MS; accfg.lookback_ms = 200; accfg.lookahead_ms = 0; @@ -288,13 +291,14 @@ int radio_audio_start(uint64_t group_id) { g_encoder = enc; g_compressor = ac; + g_capture_channels = capture_channels; g_group_id = group_id; g_active = 1; memset(g_sources, 0, sizeof(g_sources)); pthread_mutex_unlock(&g_mtx); - 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, + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx opus_mode=%s bitrate=%dkbps capture_ch=%d compressor=%s", RADIO_AUDIO_ID, + (unsigned long long)group_id, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps, capture_channels, ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable"); return 0; } @@ -354,16 +358,17 @@ void radio_audio_talk_end(uint64_t group_id) { /* ── TX (аудио-поток) ── */ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { - if (!pcm || count != RADIO_AUDIO_FRAME_SAMPLES) return -1; + pthread_mutex_lock(&g_mtx); + int frame_total = RADIO_AUDIO_FRAME_SAMPLES * g_capture_channels; + if (!pcm || count != frame_total) { pthread_mutex_unlock(&g_mtx); return -1; } enum { MAX_OUT_FRAMES = 4 }; - uint8_t opus[MAX_OUT_FRAMES][256]; + uint8_t opus[MAX_OUT_FRAMES][RADIO_MAX_OPUS]; int opus_len[MAX_OUT_FRAMES]; int n_frames = 0; struct UASYNC* ua = NULL; struct UTUN_INSTANCE* inst = NULL; - pthread_mutex_lock(&g_mtx); if (!g_active || group_id != g_group_id || !g_encoder) { pthread_mutex_unlock(&g_mtx); return -1; @@ -380,11 +385,11 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { } size_t off = 0; - while (off + (size_t)RADIO_AUDIO_FRAME_SAMPLES <= src_count && n_frames < MAX_OUT_FRAMES) { + while (off + (size_t)frame_total <= src_count && n_frames < MAX_OUT_FRAMES) { int l = opus_codec_encode(g_encoder, src + off, RADIO_AUDIO_FRAME_SAMPLES, opus[n_frames], (int)sizeof(opus[n_frames])); if (l > 0) { opus_len[n_frames] = l; n_frames++; } - off += RADIO_AUDIO_FRAME_SAMPLES; + off += (size_t)frame_total; } if (off != src_count) { g_tx_tail += (uint32_t)(src_count - off); diff --git a/src/radio/radio_audio.h b/src/radio/radio_audio.h index 03ee7a9d..66df00b3 100644 --- a/src/radio/radio_audio.h +++ b/src/radio/radio_audio.h @@ -32,8 +32,10 @@ extern "C" { int radio_audio_init(struct UTUN_INSTANCE* inst); void radio_audio_destroy(struct UTUN_INSTANCE* inst); -/* GUI-поток: начать прослушивание канала (encoder + реестр источников). */ -int radio_audio_start(uint64_t group_id); +/* GUI-поток: начать прослушивание канала (encoder + реестр источников). + * capture_channels — каналы захвата (1=моно, 2=стерео): задаёт GUI по фактической + * возможности устройства (стерео недоступно → откат на моно). RX всегда стерео. */ +int radio_audio_start(uint64_t group_id, int capture_channels); /* GUI-поток: прекратить прослушивание (освободить всё). */ void radio_audio_stop(void); diff --git a/src/radio/radio_proto.h b/src/radio/radio_proto.h index d80c94c8..89515354 100644 --- a/src/radio/radio_proto.h +++ b/src/radio/radio_proto.h @@ -21,7 +21,7 @@ extern "C" { #define RADIO_FLAG_FIN 0x01 #define RADIO_MAX_HOPS 16 -#define RADIO_MAX_OPUS 256 +#define RADIO_MAX_OPUS 512 /* макс. Opus-кадр (стерео 128кбит/с 20мс = 320 байт + запас) */ #define RADIO_HDR_SIZE 22 /* group(8)+src(8)+stream(2)+seq(2)+flags(1)+hop_count(1), без hop_list */ 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 8ad424bf..d3ddc6f7 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 @@ -59,6 +59,7 @@ class ConfigProvider(private val context: Context) { private val radioMicSourceKey = intPreferencesKey("radio_mic_source") private val radioOpusModeKey = intPreferencesKey("radio_opus_mode") private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate") + private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo") private fun keysFilePath(): String { val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath @@ -139,6 +140,7 @@ class ConfigProvider(private val context: Context) { 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 + key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0 else -> 0 } } @@ -173,6 +175,7 @@ class ConfigProvider(private val context: Context) { "chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1 "radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0 "radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32 + "radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0 } } } @@ -326,6 +329,23 @@ class ConfigProvider(private val context: Context) { context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source } } + @Volatile private var radioCaptureStereoCached = 0 + @Volatile private var radioCaptureStereoLoaded = false + + fun getRadioCaptureStereo(): Boolean { + if (!radioCaptureStereoLoaded) { + radioCaptureStereoCached = runBlocking { context.dataStore.data.first()[radioCaptureStereoKey] ?: 0 } + radioCaptureStereoLoaded = true + } + return radioCaptureStereoCached != 0 + } + + suspend fun setRadioCaptureStereo(enabled: Boolean) { + radioCaptureStereoCached = if (enabled) 1 else 0 + radioCaptureStereoLoaded = true + context.dataStore.edit { prefs -> prefs[radioCaptureStereoKey] = if (enabled) 1 else 0 } + } + /* ── Server entries ── */ suspend fun detectInterfaces(saved: List = emptyList()): List { @@ -456,6 +476,7 @@ class ConfigProvider(private val context: Context) { val chatOpusPreset = getInt("chat.opus_codec_preset") val radioOpusMode = getInt("radio.opus_mode") val radioOpusBitrate = getInt("radio.opus_bitrate") + val radioCaptureStereo = getInt("radio.capture_stereo") val chatCompEnabled = getInt("chat.compressor_enabled") val chatCompMaxGain = getInt("chat.compressor_max_gain_db") val chatCompRiseRate = getInt("chat.compressor_rise_rate") @@ -466,6 +487,7 @@ class ConfigProvider(private val context: Context) { appendLine("opus_codec_preset=$chatOpusPreset") appendLine("radio_opus_mode=$radioOpusMode") appendLine("radio_opus_bitrate=$radioOpusBitrate") + appendLine("radio_capture_stereo=$radioCaptureStereo") 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/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index de8d4a8f..38f5d5a2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -159,7 +159,7 @@ object NativeLib { /* ── Рация (PTT walkie-talkie) ── */ fun radioSetActive(groupId: Long, on: Boolean) { nativeRadioSetActive(groupId, on) } fun radioSubscribers(groupId: Long): Int = nativeRadioSubscribers(groupId) - fun radioAudioStart(groupId: Long): Boolean = nativeRadioAudioStart(groupId) + fun radioAudioStart(groupId: Long, channels: Int): Boolean = nativeRadioAudioStart(groupId, channels) fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples) fun radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out) fun radioAudioStop() { nativeRadioAudioStop() } @@ -266,7 +266,7 @@ object NativeLib { /* ── Рация JNI ── */ private external fun nativeRadioSetActive(groupId: Long, on: Boolean) private external fun nativeRadioSubscribers(groupId: Long): Int - private external fun nativeRadioAudioStart(groupId: Long): Boolean + private external fun nativeRadioAudioStart(groupId: Long, channels: Int): Boolean private external fun nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray): Int private external fun nativeRadioAudioStop() 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 6db0e15e..999b6ecb 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 @@ -30,7 +30,10 @@ class RadioAudioEngine { companion object { const val SAMPLE_RATE = 48000 const val FRAME_MS = 20 - const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 + const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 (на канал) + + /** Воспроизведение всегда стерео (моно-пиры апмиксуются Opus-декодером). */ + const val PLAY_CHANNELS = 2 /** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */ const val RELEASE_HOLD_MS = 200L @@ -53,6 +56,7 @@ class RadioAudioEngine { @Volatile private var groupId = 0L @Volatile private var talking = false @Volatile private var micDead = false + @Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */ private val _talkingFlow = MutableStateFlow(false) val talkingFlow: StateFlow = _talkingFlow @@ -118,12 +122,6 @@ class RadioAudioEngine { /* Инициализация 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") - return - } - val ctx = ChatApplication.instance val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager prevMode = am.mode @@ -135,18 +133,24 @@ class RadioAudioEngine { LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}") } - // ── AudioRecord (захват) ── - val recMin = AudioRecord.getMinBufferSize( - SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) - val rec = createAudioRecord((recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) + // ── AudioRecord (захват) с откатом стерео→моно ── + val (rec, capCh) = createAudioRecord() if (rec == null) { - NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return + restoreAudioMode(); active = false; return } + captureChannels = capCh - // ── AudioTrack (воспроизведение) ── + if (!NativeLib.radioAudioStart(group, capCh)) { + rec.release() + restoreAudioMode(); active = false + LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh") + return + } + + // ── AudioTrack (воспроизведение, всегда стерео) ── val trackMin = AudioTrack.getMinBufferSize( - SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) - val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4) + SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT) + val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * PLAY_CHANNELS * 4) val track = try { AudioTrack( AudioAttributes.Builder() @@ -154,7 +158,7 @@ class RadioAudioEngine { .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(), AudioFormat.Builder().setSampleRate(SAMPLE_RATE) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(), + .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build(), trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") @@ -175,37 +179,62 @@ class RadioAudioEngine { priority = Thread.MAX_PRIORITY; start() } - LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") + LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf") } - /* Создание AudioRecord по выбранному источнику; UNPROCESSED с fallback на MIC. */ - private fun createAudioRecord(bufferSize: Int): AudioRecord? { - val pref = ChatApplication.instance.configProvider.getRadioMicSource() + /* Создать AudioRecord: сначала желаемые каналы (стерео), при неудаче — откат на моно. + * Источник: выбранный в настройках, UNPROCESSED с fallback на MIC. Возвращает (record, каналы). */ + private fun createAudioRecord(): Pair { + val ctx = ChatApplication.instance + val pref = ctx.configProvider.getRadioMicSource() + val wantStereo = ctx.configProvider.getRadioCaptureStereo() 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 + 3 -> MediaRecorder.AudioSource.CAMCORDER 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) + val sources = if (wantStereo && pref == 0) { + /* voice-источник (AEC/NS/beamforming) коллапсирует в моно/один луч — + * для стерео используем CAMCORDER (тот же тракт, что у видеозаписи). */ + LogManager.addLog("WARN", "RadioAudio", "voice source не даёт стерео — использую CAMCORDER") + intArrayOf(MediaRecorder.AudioSource.CAMCORDER) + } else if (pref == 2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + intArrayOf(primary, MediaRecorder.AudioSource.MIC) + } else { + intArrayOf(primary) + } + val masks = if (wantStereo) + intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO) + else intArrayOf(AudioFormat.CHANNEL_IN_MONO) + + for (mask in masks) { + val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1 + val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT) + val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4) + for (src in sources) { + val rec = tryCreateRecord(src, bufSize, mask) + 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") + return rec to channels + } + } } - return null + return null to 1 } - private fun tryCreateRecord(source: Int, bufferSize: Int): AudioRecord? { + private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? { val rec = try { - AudioRecord(source, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize) + AudioRecord(source, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize) } catch (e: Exception) { - LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source): ${e.message}") + LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source chMask=$channelMask): ${e.message}") return null } if (rec.state != AudioRecord.STATE_INITIALIZED) { - LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source)") + LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source chMask=$channelMask)") rec.release() return null } @@ -272,7 +301,9 @@ class RadioAudioEngine { } private fun captureLoop() { - val buf = ShortArray(FRAME_SAMPLES) + val ch = captureChannels + val frame = FRAME_SAMPLES * ch + val buf = ShortArray(frame) while (active) { val rec = audioRecord ?: break if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) { @@ -283,9 +314,9 @@ class RadioAudioEngine { break } var read = 0 - while (read < FRAME_SAMPLES) { + while (read < frame) { if (!active) return - val n = rec.read(buf, read, FRAME_SAMPLES - read) + val n = rec.read(buf, read, frame - read) if (n <= 0) { if (active) { micDead = true @@ -303,15 +334,16 @@ class RadioAudioEngine { } private fun playLoop() { - val buf = ShortArray(FRAME_SAMPLES) - val silence = ShortArray(FRAME_SAMPLES) + val frame = FRAME_SAMPLES * PLAY_CHANNELS + val buf = ShortArray(frame) + val silence = ShortArray(frame) val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */ var nextNs = System.nanoTime() while (active) { val track = audioTrack ?: return val n = NativeLib.radioAudioPull(groupId, buf) if (n > 0) track.write(buf, 0, n) - if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n) + if (n < frame) track.write(silence, 0, frame - n) /* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф */ nextNs += frameNs 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 6579828a..297bbed0 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 @@ -535,7 +535,7 @@ private fun RadioSettingsTab() { /* ── Источник микрофона ── */ val micSources = listOf( "Голос (VOICE_COMMUNICATION)", "Микрофон (MIC)", - "Без обработки (UNPROCESSED)", "Распознавание (VOICE_RECOGNITION)") + "Без обработки (UNPROCESSED)", "Камера/видео (CAMCORDER)") var micSource by remember { mutableStateOf(provider.getRadioMicSource().coerceIn(0, micSources.size - 1)) } var micExpanded by remember { mutableStateOf(false) } Text("Источник микрофона", style = MaterialTheme.typography.titleMedium) @@ -557,6 +557,24 @@ private fun RadioSettingsTab() { HorizontalDivider(Modifier.padding(vertical = 4.dp)) + /* ── Захват стерео ── */ + var captureStereo by remember { mutableStateOf(provider.getRadioCaptureStereo()) } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Захват стерео") + Text("Воспроизведение всегда стерео; моно-пиры апмиксуются", + style = MaterialTheme.typography.bodySmall) + } + Switch(checked = captureStereo, onCheckedChange = { + captureStereo = it + scope.launch { provider.setRadioCaptureStereo(it) } + NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0") + RadioAudioEngine.instance().reconfigure() + }) + } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + /* ── Opus для рации: режим + битрейт ── */ val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)") val opusBitrates = listOf(16, 32, 64, 96, 128) diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index d4170811..9176893b 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1513,11 +1513,11 @@ int utun_bridge_radio_subscribers(uint64_t group_id) { return radio_subscribers(inst, group_id); } -int utun_bridge_radio_audio_start(uint64_t group_id) { +int utun_bridge_radio_audio_start(uint64_t group_id, int capture_channels) { struct UTUN_INSTANCE* inst = instance_lite_get_instance(); if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio_audio_start: no instance"); return -1; } if (radio_audio_init(inst) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio_audio_start: radio_audio_init failed"); return -1; } - return radio_audio_start(group_id); + return radio_audio_start(group_id, capture_channels); } int utun_bridge_radio_audio_feed(uint64_t group_id, const int16_t* pcm, int count) { @@ -2415,9 +2415,9 @@ JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioSubscribers( } JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioStart( - JNIEnv* env, jobject thiz, jlong groupId) { + JNIEnv* env, jobject thiz, jlong groupId, jint channels) { (void)env; (void)thiz; - return (utun_bridge_radio_audio_start((uint64_t)groupId) == 0) ? JNI_TRUE : JNI_FALSE; + return (utun_bridge_radio_audio_start((uint64_t)groupId, (int)channels) == 0) ? JNI_TRUE : JNI_FALSE; } JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioFeed( diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 4071b7eb..44b3f7ba 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -137,7 +137,7 @@ void utun_bridge_call_audio_stop(void); void utun_bridge_radio_set_active(uint64_t group_id, int on); int utun_bridge_radio_subscribers(uint64_t group_id); -int utun_bridge_radio_audio_start(uint64_t group_id); +int utun_bridge_radio_audio_start(uint64_t group_id, int capture_channels); int utun_bridge_radio_audio_feed(uint64_t group_id, const int16_t* pcm, int count); int utun_bridge_radio_audio_pull(uint64_t group_id, int16_t* out, int max_samples); void utun_bridge_radio_audio_stop(void); diff --git a/tools/chatgui/src/audiodevicesettingspage.cpp b/tools/chatgui/src/audiodevicesettingspage.cpp index 5f890f08..72842562 100644 --- a/tools/chatgui/src/audiodevicesettingspage.cpp +++ b/tools/chatgui/src/audiodevicesettingspage.cpp @@ -113,6 +113,39 @@ AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, AudioRecorder* r m_codecPresetCombo->setStyleSheet(m_outputCombo->styleSheet()); layout->addWidget(m_codecPresetCombo); + /* --- Separator --- */ + auto* sep5 = new QFrame(this); + sep5->setFrameShape(QFrame::HLine); + sep5->setStyleSheet("QFrame { color: palette(mid); }"); + layout->addWidget(sep5); + + /* --- Radio (PTT) Opus --- */ + auto* radioLabel = new QLabel("Radio (PTT) — Opus", this); + radioLabel->setStyleSheet("font-size: 13px; font-weight: bold;"); + layout->addWidget(radioLabel); + + auto* radioForm = new QFormLayout(); + radioForm->setContentsMargins(0, 0, 0, 0); + radioForm->setSpacing(10); + + m_radioOpusModeCombo = new QComboBox(this); + m_radioOpusModeCombo->addItem("Voice (VOIP)", 0); + m_radioOpusModeCombo->addItem("Music (AUDIO)", 1); + m_radioOpusModeCombo->setStyleSheet(m_outputCombo->styleSheet()); + radioForm->addRow(new QLabel("Mode:", this), m_radioOpusModeCombo); + + m_radioOpusBitrateCombo = new QComboBox(this); + for (int br : {16, 32, 64, 96, 128}) + m_radioOpusBitrateCombo->addItem(QString::number(br) + " kbps", br); + m_radioOpusBitrateCombo->setStyleSheet(m_outputCombo->styleSheet()); + radioForm->addRow(new QLabel("Bitrate:", this), m_radioOpusBitrateCombo); + + layout->addLayout(radioForm); + + m_radioCaptureStereoCb = new QCheckBox("Capture stereo (playback is always stereo)", this); + m_radioCaptureStereoCb->setStyleSheet("font-size: 12px;"); + layout->addWidget(m_radioCaptureStereoCb); + /* --- Separator --- */ auto* sep4 = new QFrame(this); sep4->setFrameShape(QFrame::HLine); @@ -158,6 +191,9 @@ void AudioDeviceSettingsPage::loadFromDb() { m_savedOutputIdx = m_db->getUiStateInt("audio_output_device", -1); m_savedInputIdx = m_db->getUiStateInt("audio_input_device", -1); m_savedCodecPreset = m_db->getUiStateInt("opus_codec_preset", 1); + m_savedRadioOpusMode = m_db->getUiStateInt("radio_opus_mode", 0); + m_savedRadioOpusBitrate = m_db->getUiStateInt("radio_opus_bitrate", 32); + m_savedRadioCaptureStereo = m_db->getUiStateInt("radio_capture_stereo", 0); m_savedCompressorEnabled = m_db->getUiStateInt("compressor_enabled", 0); m_savedCompressorMaxGainDb = m_db->getUiStateInt("compressor_max_gain_db", 25); m_savedCompressorRiseRate = m_db->getUiStateInt("compressor_rise_rate", 10); @@ -176,6 +212,15 @@ void AudioDeviceSettingsPage::loadFromDb() { if (m_codecPresetCombo->itemData(i).toInt() == m_savedCodecPreset) { m_codecPresetCombo->setCurrentIndex(i); break; } } + for (int i = 0; i < m_radioOpusModeCombo->count(); i++) { + if (m_radioOpusModeCombo->itemData(i).toInt() == m_savedRadioOpusMode) + { m_radioOpusModeCombo->setCurrentIndex(i); break; } + } + for (int i = 0; i < m_radioOpusBitrateCombo->count(); i++) { + if (m_radioOpusBitrateCombo->itemData(i).toInt() == m_savedRadioOpusBitrate) + { m_radioOpusBitrateCombo->setCurrentIndex(i); break; } + } + m_radioCaptureStereoCb->setChecked(m_savedRadioCaptureStereo != 0); m_compressorEnabledCb->setChecked(m_savedCompressorEnabled != 0); m_compressorMaxGainSlider->setValue(m_savedCompressorMaxGainDb); @@ -206,6 +251,14 @@ void AudioDeviceSettingsPage::applyAndSave() { VoiceEncoder::setPreset(codecPreset); + int radioOpusMode = m_radioOpusModeCombo->currentData().toInt(); + int radioOpusBitrate = m_radioOpusBitrateCombo->currentData().toInt(); + int radioCaptureStereo = m_radioCaptureStereoCb->isChecked() ? 1 : 0; + + saveKey("radio_opus_mode", radioOpusMode); + saveKey("radio_opus_bitrate", radioOpusBitrate); + saveKey("radio_capture_stereo", radioCaptureStereo); + int compEnabled = m_compressorEnabledCb->isChecked() ? 1 : 0; int compMaxGain = m_compressorMaxGainSlider->value(); int compRiseRate = m_compressorRiseRateSlider->value(); @@ -229,6 +282,9 @@ void AudioDeviceSettingsPage::applyAndSave() { gui_bridge_post_uasync_fn(chat_core_set_setting_trampoline, arg); }; postChat(m_configPath, "opus_codec_preset", codecPreset); + postChat(m_configPath, "radio_opus_mode", radioOpusMode); + postChat(m_configPath, "radio_opus_bitrate", radioOpusBitrate); + postChat(m_configPath, "radio_capture_stereo", radioCaptureStereo); postChat(m_configPath, "compressor_enabled", compEnabled); postChat(m_configPath, "compressor_max_gain_db", compMaxGain); postChat(m_configPath, "compressor_rise_rate", compRiseRate); @@ -237,11 +293,15 @@ void AudioDeviceSettingsPage::applyAndSave() { m_savedOutputIdx = outIdx; m_savedInputIdx = inIdx; m_savedCodecPreset = codecPreset; + m_savedRadioOpusMode = radioOpusMode; + m_savedRadioOpusBitrate = radioOpusBitrate; + m_savedRadioCaptureStereo = radioCaptureStereo; m_savedCompressorEnabled = compEnabled; m_savedCompressorMaxGainDb = compMaxGain; m_savedCompressorRiseRate = compRiseRate; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "saved output=%d input=%d codec=%d compressor=%d gain=%ddB rise=%.1fx", - outIdx, inIdx, codecPreset, compEnabled, compMaxGain, (float)compRiseRate); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "saved output=%d input=%d codec=%d radioOpus=%d/%dkbps stereo=%d compressor=%d gain=%ddB rise=%.1fx", + outIdx, inIdx, codecPreset, radioOpusMode, radioOpusBitrate, radioCaptureStereo, + compEnabled, compMaxGain, (float)compRiseRate); if (m_recorder) { m_recorder->setCompressorEnabled(compEnabled != 0); diff --git a/tools/chatgui/src/audiodevicesettingspage.h b/tools/chatgui/src/audiodevicesettingspage.h index 623edc81..ca749ccb 100644 --- a/tools/chatgui/src/audiodevicesettingspage.h +++ b/tools/chatgui/src/audiodevicesettingspage.h @@ -53,4 +53,11 @@ private: int m_savedCompressorEnabled = 0; int m_savedCompressorMaxGainDb = 25; int m_savedCompressorRiseRate = 10; + + QComboBox* m_radioOpusModeCombo; + QComboBox* m_radioOpusBitrateCombo; + QCheckBox* m_radioCaptureStereoCb; + int m_savedRadioOpusMode = 0; + int m_savedRadioOpusBitrate = 32; + int m_savedRadioCaptureStereo = 0; }; diff --git a/tools/chatgui/src/radio_audio_engine.cpp b/tools/chatgui/src/radio_audio_engine.cpp index 263f9d46..bc4db0bb 100644 --- a/tools/chatgui/src/radio_audio_engine.cpp +++ b/tools/chatgui/src/radio_audio_engine.cpp @@ -6,6 +6,7 @@ extern "C" { #include "radio/radio_audio.h" +#include "chat/chat_setting.h" #include "../../lib/debug_config.h" #include "../../lib/u_async.h" } @@ -14,9 +15,16 @@ extern "C" { static RadioAudioEngine* g_radioAudioEngine = nullptr; -static void radioAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) { +static void radioAudioCaptureCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) { + (void)out; RadioAudioEngine* e = (RadioAudioEngine*)dev->pUserData; - if (e) e->dataCallback(in, out, frames); + if (e) e->captureCallback(in, frames); +} + +static void radioAudioPlaybackCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) { + (void)in; + RadioAudioEngine* e = (RadioAudioEngine*)dev->pUserData; + if (e) e->playbackCallback(out, frames); } RadioAudioEngine* RadioAudioEngine::instance() { @@ -40,21 +48,27 @@ int RadioAudioEngine::start(quint64 groupId) { DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: radio_audio_init failed"); return -1; } - if (radio_audio_start(groupId) != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: radio_audio_start failed grp=%016llx", (unsigned long long)groupId); - return -1; - } - m_captureBuf.assign(RADIO_AUDIO_FRAME_SAMPLES, 0); - m_capturePos = 0; + /* желаемые каналы захвата из настройки; initDevice() при недоступности откатывает на моно */ + m_captureChannels = chat_setting_get_int(inst, "radio_capture_stereo", 0) ? 2 : 1; if (initDevice() != 0) { radio_audio_stop(); return -1; } + if (radio_audio_start(groupId, m_captureChannels) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: radio_audio_start failed grp=%016llx", (unsigned long long)groupId); + teardownDevice(); + radio_audio_stop(); + return -1; + } + + m_captureBuf.assign((size_t)RADIO_AUDIO_FRAME_SAMPLES * m_captureChannels, 0); + m_capturePos = 0; + m_active = true; - DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: started grp=%016llx", (unsigned long long)groupId); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: started grp=%016llx captureCh=%d", (unsigned long long)groupId, m_captureChannels); return 0; } @@ -65,48 +79,74 @@ int RadioAudioEngine::initDevice() { return -1; } - m_device = new ma_device; - std::memset(m_device, 0, sizeof(*m_device)); - ma_device_config config = ma_device_config_init(ma_device_type_duplex); - config.capture.format = ma_format_s16; - config.capture.channels = 1; - config.playback.format = ma_format_s16; - config.playback.channels = 1; - config.sampleRate = RADIO_AUDIO_SAMPLE_RATE; - config.periodSizeInFrames = 480; - config.dataCallback = radioAudioDataCallback; - config.pUserData = this; - - if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) { - DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: device init failed, trying default"); - config.playback.pDeviceID = nullptr; - config.capture.pDeviceID = nullptr; - if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) { - DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: default device init also failed"); - delete m_device; m_device = nullptr; - return -1; + /* ── capture-устройство (стерео → откат на моно → дефолтное устройство) ── */ + const int wantCh = m_captureChannels; + int capCh = 0; + for (int useDefault = 0; useDefault < 2 && capCh == 0; useDefault++) { + for (int ch = wantCh; ch >= 1; ch--) { + ma_device* dev = new ma_device; + std::memset(dev, 0, sizeof(*dev)); + ma_device_config config = ma_device_config_init(ma_device_type_capture); + config.capture.format = ma_format_s16; + config.capture.channels = ch; + config.sampleRate = RADIO_AUDIO_SAMPLE_RATE; + config.periodSizeInFrames = 480; + config.dataCallback = radioAudioCaptureCallback; + config.pUserData = this; + if (useDefault) config.capture.pDeviceID = nullptr; + + if (ma_device_init(ctx, &config, dev) != MA_SUCCESS) { delete dev; continue; } + if (ma_device_start(dev) != MA_SUCCESS) { ma_device_uninit(dev); delete dev; continue; } + m_captureDevice = dev; + m_captureStarted = true; + capCh = ch; + if (ch != wantCh) + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: stereo capture unavailable — fallback to mono"); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: capture started ch=%d cap='%s'", + ch, dev->capture.name ? dev->capture.name : "?"); + break; } } - if (ma_device_start(m_device) != MA_SUCCESS) { - DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: device start failed"); - ma_device_uninit(m_device); - delete m_device; m_device = nullptr; + if (capCh == 0) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: capture device init failed"); + teardownDevice(); return -1; } - m_deviceStarted = true; + m_captureChannels = capCh; + + /* ── playback-устройство (всегда стерео) ── */ + m_playbackDevice = new ma_device; + std::memset(m_playbackDevice, 0, sizeof(*m_playbackDevice)); + { + ma_device_config config = ma_device_config_init(ma_device_type_playback); + config.playback.format = ma_format_s16; + config.playback.channels = 2; + config.sampleRate = RADIO_AUDIO_SAMPLE_RATE; + config.periodSizeInFrames = 480; + config.dataCallback = radioAudioPlaybackCallback; + config.pUserData = this; + + if (ma_device_init(ctx, &config, m_playbackDevice) != MA_SUCCESS) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: playback device init failed"); + teardownDevice(); + return -1; + } + if (ma_device_start(m_playbackDevice) != MA_SUCCESS) { + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: playback device start failed"); + teardownDevice(); + return -1; + } + m_playbackStarted = true; + } + m_capturePos = 0; - DEBUG_INFO(DEBUG_CATEGORY_DEBUG, - "RadioAudio: device started sr=%u cap='%s' fmt=%d internalSr=%u pb='%s' fmt=%d internalSr=%u", - m_device->sampleRate, - m_device->capture.name ? m_device->capture.name : "?", - (int)m_device->capture.format, m_device->capture.internalSampleRate, - m_device->playback.name ? m_device->playback.name : "?", - (int)m_device->playback.format, m_device->playback.internalSampleRate); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: playback started ch=2 pb='%s'", + m_playbackDevice->playback.name ? m_playbackDevice->playback.name : "?"); return 0; } void RadioAudioEngine::stop() { - if (!m_active && !m_device && !m_deviceStarted) return; + if (!m_active && !m_captureDevice && !m_playbackDevice && !m_captureStarted && !m_playbackStarted) return; quint64 gid = m_groupId.load(); bool wasTalking = m_talking.exchange(false); if (wasTalking) radio_audio_talk_end(gid); /* честный FIN: пиры дренируют звук, а не ждут таймаут */ @@ -132,25 +172,43 @@ void RadioAudioEngine::talkEnd() { } void RadioAudioEngine::teardownDevice() { - if (m_deviceStarted) { - ma_device_stop(m_device); - ma_device_uninit(m_device); - m_deviceStarted = false; + if (m_captureStarted && m_captureDevice) { + ma_device_stop(m_captureDevice); + ma_device_uninit(m_captureDevice); + m_captureStarted = false; + } + if (m_captureDevice) { delete m_captureDevice; m_captureDevice = nullptr; } + if (m_playbackStarted && m_playbackDevice) { + ma_device_stop(m_playbackDevice); + ma_device_uninit(m_playbackDevice); + m_playbackStarted = false; } - if (m_device) { delete m_device; m_device = nullptr; } + if (m_playbackDevice) { delete m_playbackDevice; m_playbackDevice = nullptr; } } -void RadioAudioEngine::dataCallback(const void* in, void* out, unsigned int frames) { +void RadioAudioEngine::captureCallback(const void* in, unsigned int frames) { const int16_t* in16 = (const int16_t*)in; + if (!in16 || !m_talking.load()) return; + + /* захват: копим FRAME_SAMPLES * captureChannels → encode+send. Только когда PTT зажата. */ + int totalIn = (int)frames * m_captureChannels; + for (int i = 0; i < totalIn; i++) { + m_captureBuf[m_capturePos++] = in16[i]; + if (m_capturePos >= (int)m_captureBuf.size()) { + radio_audio_feed_pcm(m_groupId.load(), m_captureBuf.data(), (int)m_captureBuf.size()); + m_capturePos = 0; + } + } +} + +void RadioAudioEngine::playbackCallback(void* out, unsigned int frames) { int16_t* out16 = (int16_t*)out; - /* диагностика (DEBUG_CATEGORY_DEBUG): cadence коллбэка — gap между вызовами (снаружи) - * и длительность тела (внутри), чтобы отличить «девайс не дёргает» от «заблокирован внутри». */ + /* диагностика (DEBUG_CATEGORY_DEBUG): cadence playback-коллбэка */ uint64_t now = get_time_tb(); if (m_dbgFrames == 0) { m_dbgFrames = frames; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "RadioAudio: callback frames=%u in=%s out=%s", - frames, in16 ? "yes" : "no", out16 ? "yes" : "no"); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "RadioAudio: playback frames=%u", frames); } m_dbgCbCount++; if (m_dbgPrevCbTb != 0) { @@ -161,7 +219,7 @@ void RadioAudioEngine::dataCallback(const void* in, void* out, unsigned int fram if (m_dbgLastTb == 0) { m_dbgLastTb = now; } else if (now - m_dbgLastTb >= 10000) { - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "RadioAudio: cb=%u/s frames=%u gapMax=%.1fms bodyMax=%.1fms", + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "RadioAudio: pb cb=%u/s frames=%u gapMax=%.1fms bodyMax=%.1fms", m_dbgCbCount, frames, (double)m_dbgGapMax / 10.0, (double)m_dbgBodyMax / 10.0); m_dbgCbCount = 0; m_dbgLastTb = now; @@ -169,21 +227,11 @@ void RadioAudioEngine::dataCallback(const void* in, void* out, unsigned int fram m_dbgBodyMax = 0; } - /* захват: копим 960 samples → encode+send (сервисный слой). Только когда PTT зажата. */ - if (in16 && m_talking.load()) { - for (unsigned int i = 0; i < frames; i++) { - m_captureBuf[m_capturePos++] = in16[i]; - if (m_capturePos >= (int)m_captureBuf.size()) { - radio_audio_feed_pcm(m_groupId.load(), m_captureBuf.data(), (int)m_captureBuf.size()); - m_capturePos = 0; - } - } - } - - /* воспроизведение: микшированный PCM из сервисного слоя */ - int n = radio_audio_pull_pcm(m_groupId.load(), out16, (int)frames); - if (n < (int)frames) - std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t)); + /* воспроизведение: микшированный PCM из сервисного слоя (всегда стерео) */ + int total = (int)frames * 2; + int n = radio_audio_pull_pcm(m_groupId.load(), out16, total); + if (n < total) + std::memset(out16 + n, 0, (size_t)(total - n) * sizeof(int16_t)); uint64_t end = get_time_tb(); uint64_t body = end - now; diff --git a/tools/chatgui/src/radio_audio_engine.h b/tools/chatgui/src/radio_audio_engine.h index 48ff83ea..0c112c73 100644 --- a/tools/chatgui/src/radio_audio_engine.h +++ b/tools/chatgui/src/radio_audio_engine.h @@ -33,8 +33,9 @@ public: bool isActive() const { return m_active.load(); } quint64 groupId() const { return m_groupId.load(); } - /* аудио-поток: miniaudio data callback (захват + воспроизведение) */ - void dataCallback(const void* in, void* out, unsigned int frames); + /* аудио-поток: miniaudio callbacks (захват и воспроизведение раздельно) */ + void captureCallback(const void* in, unsigned int frames); + void playbackCallback(void* out, unsigned int frames); private: RadioAudioEngine() = default; @@ -45,13 +46,16 @@ private: std::atomic m_talking{false}; std::atomic m_groupId{0}; - ma_device* m_device = nullptr; - bool m_deviceStarted = false; + ma_device* m_captureDevice = nullptr; + ma_device* m_playbackDevice = nullptr; + bool m_captureStarted = false; + bool m_playbackStarted = false; - std::vector m_captureBuf; /* 960 samples, аудио-поток */ + std::vector m_captureBuf; /* FRAME_SAMPLES * m_captureChannels, аудио-поток */ int m_capturePos = 0; + int m_captureChannels = 1; /* 1=моно, 2=стерео — по настройке/возможности */ - /* диагностика (DEBUG_CATEGORY_DEBUG): фактический cadence miniaudio-коллбэка */ + /* диагностика (DEBUG_CATEGORY_DEBUG): фактический cadence playback-коллбэка */ unsigned int m_dbgFrames = 0; uint32_t m_dbgCbCount = 0; uint64_t m_dbgLastTb = 0; /* момент предыдущего сброса статистики (для cb/s) */ diff --git a/tools/chatgui/tests/test_radio_audio.cpp b/tools/chatgui/tests/test_radio_audio.cpp index 3ee35887..02df6c84 100644 --- a/tools/chatgui/tests/test_radio_audio.cpp +++ b/tools/chatgui/tests/test_radio_audio.cpp @@ -72,7 +72,7 @@ static void feed_frames(const uint8_t pkt[][256], const int* lens, const int* se /* прогнать burst и собрать npulls*960 сэмплов выхода */ static void run_burst(const uint8_t pkt[][256], const int* lens, const int* seqs, const int* idx, int n, int16_t* result, int npulls) { - radio_audio_start(GID); + radio_audio_start(GID, 1); feed_frames(pkt, lens, seqs, idx, n); int16_t out[960]; int pos = 0; @@ -102,7 +102,7 @@ int main() { printf("=== test_radio_audio ===\n"); fflush(stdout); TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); { - radio_audio_start(GID); + radio_audio_start(GID, 1); push_const(SRC_A, 1, 10000, 8); int mx = pull_max_abs(16); radio_audio_stop(); @@ -110,7 +110,7 @@ int main() { } TEST("mixing: два источника складываются (soft-clip)"); { - radio_audio_start(GID); + radio_audio_start(GID, 1); push_const(SRC_A, 1, 20000, 8); push_const(SRC_B, 1, 20000, 8); int mx = pull_max_abs(16);