Browse Source

Radio: stereo capture/playback with mono fallback; CAMCORDER source; bump RADIO_MAX_OPUS

- stereo capture: Android AudioRecord stereo (CAMCORDER source for stereo,
  voice sources fall back to CAMCORDER) + desktop separate miniaudio
  capture/playback devices (no asymmetric duplex)
- Opus stereo encode/decode, voice_jitter stereo (per-channel frame_size,
  slot-based SoundTouch)
- radio_audio_start takes explicit capture_channels (stereo->mono fallback)
- RADIO_MAX_OPUS 256->512 (128kbps stereo frames up to 320B)
- radio_capture_stereo setting + desktop radio settings UI (opus mode/bitrate,
  capture stereo)
- radio_send_to send stats logging (etcp_send ok/fail per second)
proxy
evgeny 3 days ago
parent
commit
ce1f23fe46
  1. 24
      src/call/voice_jitter.cpp
  2. 1
      src/chat/chat_setting.c
  3. 4
      src/config_parser.c
  4. 33
      src/radio/radio.c
  5. 4
      src/radio/radio.h
  6. 31
      src/radio/radio_audio.c
  7. 6
      src/radio/radio_audio.h
  8. 2
      src/radio/radio_proto.h
  9. 22
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  10. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  11. 106
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  12. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  13. 8
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  14. 2
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  15. 64
      tools/chatgui/src/audiodevicesettingspage.cpp
  16. 7
      tools/chatgui/src/audiodevicesettingspage.h
  17. 174
      tools/chatgui/src/radio_audio_engine.cpp
  18. 16
      tools/chatgui/src/radio_audio_engine.h
  19. 6
      tools/chatgui/tests/test_radio_audio.cpp

24
src/call/voice_jitter.cpp

@ -19,7 +19,7 @@
#include <mutex>
#include <vector>
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) {

1
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},

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

33
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) хотя бы один подписчик рации. */

4
src/radio/radio.h

@ -9,6 +9,8 @@
#include <stdint.h>
#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 {

31
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);

6
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);

2
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 */

22
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<ServerEntry> = emptyList()): List<ServerEntry> {
@ -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")

4
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()

106
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<Boolean> = _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<AudioRecord?, Int> {
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)
}
return null
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 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

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

8
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(

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

64
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);

7
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;
};

174
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);
/* ── 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 = 1;
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 (capCh == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: capture device init failed");
teardownDevice();
return -1;
}
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 = 1;
config.playback.channels = 2;
config.sampleRate = RADIO_AUDIO_SAMPLE_RATE;
config.periodSizeInFrames = 480;
config.dataCallback = radioAudioDataCallback;
config.dataCallback = radioAudioPlaybackCallback;
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;
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_device) != MA_SUCCESS) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "RadioAudio: device start failed");
ma_device_uninit(m_device);
delete m_device; m_device = nullptr;
if (ma_device_start(m_playbackDevice) != MA_SUCCESS) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "RadioAudio: playback device start failed");
teardownDevice();
return -1;
}
m_deviceStarted = true;
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_device) { delete m_device; m_device = nullptr; }
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_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;

16
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<bool> m_talking{false};
std::atomic<uint64_t> 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<int16_t> m_captureBuf; /* 960 samples, аудио-поток */
std::vector<int16_t> 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) */

6
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);

Loading…
Cancel
Save