From 738cfe783580746c4b7dd6a9b472b1f97a2cf198 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 20 Sep 2026 14:39:25 +0300 Subject: [PATCH] =?UTF-8?q?radio:=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=B4=D0=B6=D0=B8=D1=82=D1=82=D0=B5?= =?UTF-8?q?=D1=80-=D0=B1=D1=83=D1=84=D0=B5=D1=80=20(pre-roll,=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=82=D0=B5=D0=BC=D0=BF,=20reorder)=20+=20AGC-=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D1=80=D0=B5=D1=81=D1=81=D0=BE=D1=80=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=BE=D1=84=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - voice_jitter: vj_create_cfg (target_ms/min_tempo/max_tempo/pre_roll), pre-roll с таймаутом, vj_end для дренажа остатка, глубина учитывает внутренний буфер SoundTouch - radio_audio: reorder по seq (mesh, out-of-order через соседей), force-flush pending по таймауту и FIN, компрессор микрофона (AGC, конфиг [chatserver]), статистика буфера - audio_compressor: clear_output для потокового слива без потери gain_smoothed - chatgui messagelist: fromUtf8 для emoji-строк - тесты: pre-roll/симметричный темп, reorder, late-drop --- lib/audio_compressor.c | 5 + lib/audio_compressor.h | 4 + src/call/voice_jitter.cpp | 137 ++++++++---- src/call/voice_jitter.h | 26 ++- src/radio/radio_audio.c | 247 ++++++++++++++++++++-- tools/chatgui/src/messagelist.cpp | 6 +- tools/chatgui/tests/test_radio_audio.cpp | 71 +++++++ tools/chatgui/tests/test_voice_jitter.cpp | 41 +++- 8 files changed, 474 insertions(+), 63 deletions(-) diff --git a/lib/audio_compressor.c b/lib/audio_compressor.c index b9ffb864..b24d907f 100644 --- a/lib/audio_compressor.c +++ b/lib/audio_compressor.c @@ -290,3 +290,8 @@ const int16_t* audio_compressor_output(const struct audio_compressor* ac) { size_t audio_compressor_output_size(const struct audio_compressor* ac) { return ac ? ac->output_size : 0; } + +void audio_compressor_clear_output(struct audio_compressor* ac) { + if (!ac) return; + ac->output_size = 0; +} diff --git a/lib/audio_compressor.h b/lib/audio_compressor.h index dfe10856..198eb6f7 100644 --- a/lib/audio_compressor.h +++ b/lib/audio_compressor.h @@ -36,6 +36,10 @@ void audio_compressor_flush(struct audio_compressor* ac); const int16_t* audio_compressor_output(const struct audio_compressor* ac); size_t audio_compressor_output_size(const struct audio_compressor* ac); +/* Сбросить накопленный выход (output_size=0) после слива — для потокового режима. + * НЕ трогает gain_smoothed (адаптивная громкость сохраняется). */ +void audio_compressor_clear_output(struct audio_compressor* ac); + #ifdef __cplusplus } #endif diff --git a/src/call/voice_jitter.cpp b/src/call/voice_jitter.cpp index 91ad6bab..7ef551b8 100644 --- a/src/call/voice_jitter.cpp +++ b/src/call/voice_jitter.cpp @@ -2,8 +2,10 @@ // // Кодированные кадры (Opus, до VJ_MAX_ENC байт) кладутся из uasync-потока в кольцо // и вытаскиваются аудио-потоком: кадр декодируется (decode-коллбэк) и подаётся в -// SoundTouch с плавно меняющимся темпом: глубина < target → 1.0x, глубина растёт → -// ускорение до 1.6x (догон live). Декодируется только то, что готово к воспроизведению. +// SoundTouch с плавно меняющимся темпом вокруг целевой глубины: мельче target — темп +// падает к min_tempo (накопление), глубже — растёт к max_tempo (догон live). При +// pre_roll=1 звук не отдаётся, пока глубина не достигнет target_ms. Декодируется +// только то, что готово к воспроизведению. // Кольцо защищено мьютексом (SPSC); decode и SoundTouch трогает только аудио-поток. #include "voice_jitter.h" @@ -11,18 +13,20 @@ #include "soundtouch/SoundTouch.h" #include +#include #include #include #include #include -static const int VJ_MAX_ENC = 256; /* макс. размер кодированного кадра (Opus 20мс) */ -static const double kTargetMs = 60.0; /* целевая глубина буфера */ -static const double kMaxTempo = 1.6; -static const double kMinTempo = 1.0; -static const double kMaxDepthMs = 1000.0; /* при этой глубине tempo == kMaxTempo */ -static const double kEmaAlpha = 0.2; /* сглаживание глубины */ -static const double kTempoStep = 0.01; /* макс. изменение темпа за один pull (без кликов) */ +static const int VJ_MAX_ENC = 256; /* макс. размер кодированного кадра (Opus 20мс) */ +static const double kDefaultTargetMs = 60.0; /* целевая глубина буфера (звонок) */ +static const double kDefaultMinTempo = 1.0; +static const double kDefaultMaxTempo = 1.6; +static const double kMaxDepthMs = 1000.0; /* при этой глубине tempo == max_tempo */ +static const double kEmaAlpha = 0.2; /* сглаживание глубины */ +static const double kTempoStep = 0.01; /* макс. изменение темпа за один pull (без кликов) */ +static const double kPrimeTimeoutMs = 200.0; /* pre-roll: принудительный prime через это время */ struct vj { int sr = 0; @@ -48,16 +52,31 @@ struct vj { std::vector fin; /* scratch: кадр в float */ std::vector fout; /* scratch: выход float */ + double target_ms = kDefaultTargetMs; + double min_tempo = kDefaultMinTempo; + double max_tempo = kDefaultMaxTempo; + int pre_roll = 0; + + int primed = 0; /* только аудио-поток */ + int prime_started = 0; /* только аудио-поток: первый непустой pull */ + std::chrono::steady_clock::time_point prime_t0; /* только аудио-поток: старт pre-roll */ + double ema_depth_ms = 0.0; double tempo = 1.0; std::atomic tempo_x100{100}; }; -static double vj_compute_tempo(double depth_ms) { - if (depth_ms <= kTargetMs) return kMinTempo; - double x = (depth_ms - kTargetMs) / (kMaxDepthMs - kTargetMs); +/* Симметричный целевой темп: глубина < target → min_tempo..1.0 (накопление), + * глубина > target → 1.0..max_tempo (догон). */ +static double vj_compute_tempo(double depth_ms, double target_ms, double min_tempo, double max_tempo) { + if (depth_ms <= target_ms) { + double x = depth_ms / target_ms; /* 0 → min, 1 → 1.0 */ + if (x < 0.0) x = 0.0; + return min_tempo + (1.0 - min_tempo) * x; + } + double x = (depth_ms - target_ms) / (kMaxDepthMs - target_ms); if (x > 1.0) x = 1.0; - return kMinTempo + (kMaxTempo - kMinTempo) * x; + return 1.0 + (max_tempo - 1.0) * x; } /* Вытащить один кодированный кадр из кольца (под мьютексом). Возвращает 0 если пусто. */ @@ -73,28 +92,33 @@ static int vj_pop_enc(struct vj* j, uint8_t* out, int* len_out) { extern "C" { -struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms, - int max_frames, vj_decode_fn decode, void* decode_arg) { - if (sample_rate <= 0 || channels <= 0 || frame_samples <= 0 || frame_ms <= 0 || - max_frames <= 0 || !decode) +struct vj* vj_create_cfg(const struct vj_cfg* cfg) { + if (!cfg || cfg->sample_rate <= 0 || cfg->channels <= 0 || cfg->frame_samples <= 0 || + cfg->frame_ms <= 0 || cfg->max_frames <= 0 || !cfg->decode) + return nullptr; + if (cfg->min_tempo <= 0.0 || cfg->max_tempo < cfg->min_tempo || cfg->target_ms <= 0.0) return nullptr; vj* j = new vj(); - j->sr = sample_rate; - j->ch = channels; - j->frame_samples = frame_samples; - j->frame_ms = frame_ms; - j->cap = max_frames; - j->decode = decode; - j->decode_arg = decode_arg; - j->ring.assign((size_t)max_frames * VJ_MAX_ENC, 0); - j->lens.assign(max_frames, 0); - j->pcm_buf.assign(frame_samples, 0); - j->fin.assign(frame_samples, 0.0f); - - j->st.setSampleRate((uint)sample_rate); - j->st.setChannels((uint)channels); - j->st.setTempo(kMinTempo); + j->sr = cfg->sample_rate; + j->ch = cfg->channels; + j->frame_samples = cfg->frame_samples; + j->frame_ms = cfg->frame_ms; + j->cap = cfg->max_frames; + j->decode = cfg->decode; + j->decode_arg = cfg->decode_arg; + j->target_ms = cfg->target_ms; + j->min_tempo = cfg->min_tempo; + j->max_tempo = cfg->max_tempo; + 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->st.setSampleRate((uint)cfg->sample_rate); + j->st.setChannels((uint)cfg->channels); + j->st.setTempo(cfg->min_tempo); j->st.setSetting(SETTING_USE_QUICKSEEK, 1); j->st.setSetting(SETTING_SEQUENCE_MS, 20); j->st.setSetting(SETTING_SEEKWINDOW_MS, 10); @@ -103,6 +127,23 @@ struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame return j; } +struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms, + int max_frames, vj_decode_fn decode, void* decode_arg) { + struct vj_cfg cfg; + cfg.sample_rate = sample_rate; + cfg.channels = channels; + cfg.frame_samples = frame_samples; + cfg.frame_ms = frame_ms; + cfg.max_frames = max_frames; + cfg.target_ms = kDefaultTargetMs; + cfg.min_tempo = kDefaultMinTempo; + cfg.max_tempo = kDefaultMaxTempo; + cfg.pre_roll = 0; + cfg.decode = decode; + cfg.decode_arg = decode_arg; + return vj_create_cfg(&cfg); +} + void vj_destroy(struct vj* j) { if (!j) return; delete j; @@ -125,15 +166,35 @@ void vj_push(struct vj* j, const uint8_t* enc, int len) { int vj_pull(struct vj* j, int16_t* out, int max_samples) { if (!j || !out || max_samples <= 0) return 0; - /* 1) глубина (сглаженная) → целевой темп (плавно) */ + /* 0) pre-roll: не отдаём звук, пока глубина кольца < target_ms (или не истёк таймаут). + * Состояние — только аудио-поток; кадры копятся в кольце продюсером (uasync). */ int cnt; { std::lock_guard lk(j->mtx); cnt = j->count; } - double depth_ms = (double)cnt * j->frame_ms; + if (j->pre_roll && !j->primed) { + if (cnt > 0 && !j->prime_started) { + j->prime_started = 1; + j->prime_t0 = std::chrono::steady_clock::now(); + } + double ring_ms = (double)cnt * j->frame_ms; + double waited_ms = 0.0; + if (j->prime_started) { + auto now = std::chrono::steady_clock::now(); + waited_ms = std::chrono::duration(now - j->prime_t0).count(); + } + if (ring_ms >= j->target_ms || waited_ms >= kPrimeTimeoutMs) { + j->primed = 1; + } else { + return 0; /* тишина, ждём накопления */ + } + } + + /* 1) глубина (кольцо + внутренний буфер SoundTouch, сглаженная) → целевой темп (плавно) */ + double depth_ms = (double)cnt * j->frame_ms + (double)j->st.numSamples() * 1000.0 / (double)j->sr; j->ema_depth_ms = kEmaAlpha * depth_ms + (1.0 - kEmaAlpha) * j->ema_depth_ms; - double target = vj_compute_tempo(j->ema_depth_ms); + double target = vj_compute_tempo(j->ema_depth_ms, j->target_ms, j->min_tempo, j->max_tempo); double d = target - j->tempo; if (d > kTempoStep) d = kTempoStep; else if (d < -kTempoStep) d = -kTempoStep; @@ -167,8 +228,12 @@ int vj_pull(struct vj* j, int16_t* out, int max_samples) { return (int)n; } -void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns) { +void vj_end(struct vj* j) { if (!j) return; + j->primed = 1; /* только аудио-поток: снять pre-roll и дренировать остаток */ +} + +void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns) { if (!j) return; if (depth_ms) { std::lock_guard lk(j->mtx); *depth_ms = j->count * j->frame_ms; diff --git a/src/call/voice_jitter.h b/src/call/voice_jitter.h index db9b6529..74057788 100644 --- a/src/call/voice_jitter.h +++ b/src/call/voice_jitter.h @@ -26,8 +26,26 @@ struct vj; /* decode: закодированный кадр → PCM. Возвращает число декодированных сэмплов. */ typedef int (*vj_decode_fn)(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples); -/* Создать буфер. frame_samples — сэмплов в кадре (960 @20мс/48k), frame_ms — - * длительность кадра в мс, max_frames — ёмкость кодированных кадров (50 = 1000мс). */ +/* Полная конфигурация адаптивного джиттер-буфера. */ +struct vj_cfg { + int sample_rate; + int channels; + int frame_samples; /* сэмплов в кадре (960 @20мс/48k) */ + int frame_ms; /* длительность кадра в мс */ + int max_frames; /* ёмкость кодированных кадров (50 = 1000мс) */ + double target_ms; /* целевая глубина буфера */ + double min_tempo; /* темп при пустом буфере (<1.0 = замедление, накопление) */ + double max_tempo; /* темп при глубоком буфере (>1.0 = догон live) */ + int pre_roll; /* 1 = не отдавать звук, пока глубина < target_ms */ + vj_decode_fn decode; + void* decode_arg; +}; + +/* Создать буфер с полной конфигурацией (pre-roll, симметричный темп). */ +struct vj* vj_create_cfg(const struct vj_cfg* cfg); + +/* Создать буфер с конфигурацией по умолчанию (совместимо со звонком: без pre-roll, + * темп 1.0..1.6, target 60мс). frame_samples — сэмплов в кадре (960 @20мс/48k). */ struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms, int max_frames, vj_decode_fn decode, void* decode_arg); @@ -40,6 +58,10 @@ void vj_push(struct vj* j, const uint8_t* enc, int len); * < max_samples при нехватке данных (андерфлоу). */ int vj_pull(struct vj* j, int16_t* out, int max_samples); +/* audio-поток: вход закончен (FIN) — принудительно снять pre-roll, чтобы дренировать + * накопленный остаток до конца. Безопасно вызывать многократно. */ +void vj_end(struct vj* j); + /* Диагностика: глубина буфера (мс), темп (×100, 100 = 1.0x), дропнуто кадров * при переполнении, число underrun-ов (pull вернул меньше запрошенного). */ void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns); diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index c4aee647..ea007e0b 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -13,9 +13,12 @@ #include "radio_audio.h" #include "radio.h" +#include "radio_proto.h" #include "../call/voice_jitter.h" +#include "../chat/chat_setting.h" #include "../utun_instance.h" #include "../../lib/opus_codec.h" +#include "../../lib/audio_compressor.h" #include "../../lib/debug_config.h" #include "../../lib/mem.h" #include "../../lib/u_async.h" @@ -30,6 +33,21 @@ #define RADIO_AUDIO_MAX_FRAMES 50 /* ~1000мс @20мс */ #define RADIO_AUDIO_MIX_SAMPLES 960 /* максимум сэмплов за 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 не закрылся */ +#define RADIO_AUDIO_STATS_TB 10000 /* 1с: период лога статистики буфера */ + +/* Параметры джиттер-буфера рации (адаптивный, симметричный темп + pre-roll). */ +#define RADIO_AUDIO_TARGET_MS 80.0 /* целевая глубина */ +#define RADIO_AUDIO_MIN_TEMPO 0.75 /* темп при пустом буфере (замедление) */ +#define RADIO_AUDIO_MAX_TEMPO 1.6 /* темп при глубоком буфере (догон) */ + +struct radio_pending { + uint8_t used; + uint16_t seq; + int len; + uint8_t opus[RADIO_MAX_OPUS]; +}; struct radio_source { uint64_t src_node_id; @@ -39,6 +57,12 @@ struct radio_source { uint64_t last_frame_tb; opus_codec_decoder_t* dec; /* decode-аргумент для vj (stateless per call) */ struct vj* vj; /* jitter-буфер с time-stretch */ + /* reorder по seq (mesh: кадры могут прийти не по порядку через разных соседей) */ + uint16_t next_seq; /* ожидаемый следующий seq */ + uint8_t have_seq; + uint64_t reorder_age_tb; /* момент появления первого gap (для таймаута) */ + int reorder_count; + struct radio_pending pending[RADIO_AUDIO_REORDER_MAX]; }; static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; @@ -46,8 +70,10 @@ static struct UTUN_INSTANCE* g_inst = NULL; 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 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; /* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */ static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { @@ -55,6 +81,106 @@ static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t return opus_codec_decode(dec, enc, len, pcm, max_samples); } +/* ── джиттер-буфер рации (адаптивный, симметричный темп + pre-roll) ── */ + +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.frame_samples = RADIO_AUDIO_FRAME_SAMPLES; + cfg.frame_ms = RADIO_AUDIO_FRAME_MS; + cfg.max_frames = RADIO_AUDIO_MAX_FRAMES; + cfg.target_ms = RADIO_AUDIO_TARGET_MS; + cfg.min_tempo = RADIO_AUDIO_MIN_TEMPO; + cfg.max_tempo = RADIO_AUDIO_MAX_TEMPO; + cfg.pre_roll = 1; + cfg.decode = radio_audio_decode_cb; + cfg.decode_arg = dec; + return vj_create_cfg(&cfg); +} + +/* ── reorder по seq (mesh: кадры одного источника могут прийти не по порядку) ── */ + +static void radio_src_reorder_reset(struct radio_source* s) { + s->next_seq = 0; + s->have_seq = 0; + s->reorder_age_tb = 0; + s->reorder_count = 0; + memset(s->pending, 0, sizeof(s->pending)); +} + +/* force-flush: выдать все pending в порядке seq и продвинуть next_seq. */ +static void radio_src_flush_pending(struct radio_source* s) { + while (s->reorder_count > 0) { + int mi = -1; + for (int i = 0; i < RADIO_AUDIO_REORDER_MAX; i++) { + if (!s->pending[i].used) continue; + if (mi < 0 || (int16_t)(s->pending[i].seq - s->pending[mi].seq) < 0) mi = i; + } + vj_push(s->vj, s->pending[mi].opus, s->pending[mi].len); + s->next_seq = (uint16_t)(s->pending[mi].seq + 1); + s->pending[mi].used = 0; + s->reorder_count--; + } + s->reorder_age_tb = 0; +} + +/* Выдать pending, ставшие contiguous (seq == next_seq). */ +static void radio_src_flush_contiguous(struct radio_source* s) { + int progressed = 1; + while (progressed) { + progressed = 0; + for (int i = 0; i < RADIO_AUDIO_REORDER_MAX; i++) { + if (!s->pending[i].used || s->pending[i].seq != s->next_seq) continue; + vj_push(s->vj, s->pending[i].opus, s->pending[i].len); + s->pending[i].used = 0; + s->reorder_count--; + s->next_seq++; + progressed = 1; + break; + } + } + if (s->reorder_count == 0) s->reorder_age_tb = 0; +} + +/* Положить кадр в источник с учётом seq: поздние дропаем, gap — в pending. */ +static void radio_src_push_frame(struct radio_source* s, uint16_t seq, + const uint8_t* opus, int len, uint64_t now) { + if (!s->have_seq) { s->next_seq = seq; s->have_seq = 1; } + + int16_t rel = (int16_t)(seq - s->next_seq); + if (rel < 0) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: late frame drop src=%016llx seq=%u next=%u", + RADIO_AUDIO_ID, (unsigned long long)s->src_node_id, seq, s->next_seq); + return; + } + if (rel > 0) { + if (s->reorder_count >= RADIO_AUDIO_REORDER_MAX) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: reorder overflow src=%016llx seq=%u next=%u (flush)", + RADIO_AUDIO_ID, (unsigned long long)s->src_node_id, seq, s->next_seq); + radio_src_flush_pending(s); + s->next_seq = seq; + } else { + for (int i = 0; i < RADIO_AUDIO_REORDER_MAX; i++) { + if (s->pending[i].used) continue; + memcpy(s->pending[i].opus, opus, (size_t)len); + s->pending[i].len = len; + s->pending[i].seq = seq; + s->pending[i].used = 1; + s->reorder_count++; + if (!s->reorder_age_tb) s->reorder_age_tb = now; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: reorder hold src=%016llx seq=%u next=%u", + RADIO_AUDIO_ID, (unsigned long long)s->src_node_id, seq, s->next_seq); + break; + } + return; + } + } + vj_push(s->vj, opus, len); + s->next_seq++; + radio_src_flush_contiguous(s); +} + /* ── таблица говорящих ── */ static struct radio_source* radio_src_find(uint64_t src) { @@ -67,6 +193,7 @@ static void radio_src_free(struct radio_source* s) { if (s->vj) { vj_destroy(s->vj); s->vj = NULL; } if (s->dec) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; } s->used = 0; s->ending = 0; s->last_frame_tb = 0; + radio_src_reorder_reset(s); } /* Получить/создать инстанс источника. Один пир — один инстанс: если stream_id @@ -77,9 +204,9 @@ static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) { if (s->stream_id != stream) { s->stream_id = stream; s->ending = 0; + radio_src_reorder_reset(s); vj_destroy(s->vj); - s->vj = vj_create(RADIO_AUDIO_SAMPLE_RATE, 1, RADIO_AUDIO_FRAME_SAMPLES, RADIO_AUDIO_FRAME_MS, - RADIO_AUDIO_MAX_FRAMES, radio_audio_decode_cb, s->dec); + s->vj = radio_vj_create(s->dec); if (!s->vj) { radio_src_free(s); return NULL; } DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source reset src=%016llx stream=%u", RADIO_AUDIO_ID, (unsigned long long)src, stream); } @@ -90,12 +217,12 @@ static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) { s = &g_sources[i]; s->dec = opus_codec_decoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); if (!s->dec) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: decoder create failed", RADIO_AUDIO_ID); return NULL; } - s->vj = vj_create(RADIO_AUDIO_SAMPLE_RATE, 1, RADIO_AUDIO_FRAME_SAMPLES, RADIO_AUDIO_FRAME_MS, - RADIO_AUDIO_MAX_FRAMES, radio_audio_decode_cb, s->dec); + s->vj = radio_vj_create(s->dec); if (!s->vj) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } s->src_node_id = src; s->stream_id = stream; s->used = 1; s->ending = 0; s->last_frame_tb = 0; + radio_src_reorder_reset(s); DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: new source src=%016llx stream=%u", RADIO_AUDIO_ID, (unsigned long long)src, stream); return s; } @@ -133,18 +260,38 @@ int radio_audio_start(uint64_t group_id) { if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; } opus_codec_encoder_bitrate_set(enc, RADIO_AUDIO_BITRATE); + /* компрессор микрофона (AGC): конфиг из [chatserver], lookahead=0 — без задержки и хвоста. + * gain_smoothed не сбрасывается между PTT — адаптивная громкость запоминается. */ + struct audio_compressor* ac = audio_compressor_create(); + if (ac) { + audio_compressor_config_t accfg = {0}; + accfg.sample_rate = RADIO_AUDIO_SAMPLE_RATE; + accfg.channels = 1; + accfg.block_duration_ms = RADIO_AUDIO_FRAME_MS; + accfg.lookback_ms = 200; + accfg.lookahead_ms = 0; + accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "compressor_max_gain_db", 25); + accfg.rise_rate_per_sec = (float)chat_setting_get_int(g_inst, "compressor_rise_rate", 10); + accfg.target_level = 1.0f; + audio_compressor_configure(ac, &accfg); + audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "compressor_enabled", 0)); + } + g_encoder = enc; + g_compressor = ac; 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", RADIO_AUDIO_ID, (unsigned long long)group_id); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx compressor=%s", RADIO_AUDIO_ID, + (unsigned long long)group_id, ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable"); return 0; } void radio_audio_stop(void) { opus_codec_encoder_t* enc = NULL; + struct audio_compressor* ac = NULL; int was_active = 0; pthread_mutex_lock(&g_mtx); @@ -153,6 +300,7 @@ void radio_audio_stop(void) { g_active = 0; g_group_id = 0; enc = g_encoder; g_encoder = NULL; + ac = g_compressor; g_compressor = NULL; for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) if (g_sources[i].used) radio_src_free(&g_sources[i]); } @@ -160,6 +308,7 @@ void radio_audio_stop(void) { if (was_active) { if (enc) opus_codec_encoder_destroy(enc); + if (ac) audio_compressor_destroy(ac); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: stopped", RADIO_AUDIO_ID); } } @@ -197,34 +346,59 @@ void radio_audio_talk_end(uint64_t group_id) { int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { if (!pcm || count != RADIO_AUDIO_FRAME_SAMPLES) return -1; - uint8_t opus[256]; + enum { MAX_OUT_FRAMES = 4 }; + uint8_t opus[MAX_OUT_FRAMES][256]; + int opus_len[MAX_OUT_FRAMES]; + int n_frames = 0; struct UASYNC* ua = NULL; struct UTUN_INSTANCE* inst = NULL; - int len = -1; pthread_mutex_lock(&g_mtx); if (!g_active || group_id != g_group_id || !g_encoder) { pthread_mutex_unlock(&g_mtx); return -1; } - len = opus_codec_encode(g_encoder, pcm, count, opus, (int)sizeof(opus)); + + /* компрессор микрофона: push → слив выходных блоков → encode каждого. + * lookahead=0 ⇒ ровно 1 frame-блок на вход; при disabled — passthrough. */ + const int16_t* src = pcm; + size_t src_count = (size_t)count; + if (g_compressor) { + audio_compressor_push(g_compressor, pcm, (size_t)count); + src = audio_compressor_output(g_compressor); + src_count = audio_compressor_output_size(g_compressor); + } + + size_t off = 0; + while (off + (size_t)RADIO_AUDIO_FRAME_SAMPLES <= 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; + } + if (off != src_count) + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: feed_pcm dropped %zu tail samples (compressor)", RADIO_AUDIO_ID, src_count - off); + if (g_compressor) audio_compressor_clear_output(g_compressor); + ua = g_inst ? g_inst->ua : NULL; inst = g_inst; pthread_mutex_unlock(&g_mtx); - if (len <= 0) return -1; + if (n_frames == 0) return -1; if (!ua || !inst) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: feed_pcm: instance not set", RADIO_AUDIO_ID); return -1; } - struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a)); - if (!a) return -1; - a->inst = inst; - a->group_id = group_id; - a->len = (uint16_t)len; - memcpy(a->opus, opus, (size_t)len); - uasync_post(ua, radio_talk_send_trampoline, a); + for (int i = 0; i < n_frames; i++) { + struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a)); + if (!a) return -1; + a->inst = inst; + a->group_id = group_id; + a->len = (uint16_t)opus_len[i]; + memcpy(a->opus, opus[i], (size_t)opus_len[i]); + uasync_post(ua, radio_talk_send_trampoline, a); + } return 0; } @@ -233,16 +407,21 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t src_node_id, uint16_t stream_id, uint16_t seq, uint8_t fin, const uint8_t* opus, int len, void* arg) { - (void)inst; (void)seq; (void)arg; - if (!opus || len <= 0) return; + (void)inst; (void)arg; + if (!fin && (!opus || len <= 0)) return; + uint64_t now = get_time_tb(); pthread_mutex_lock(&g_mtx); if (g_active && group_id == g_group_id) { struct radio_source* s = radio_src_acquire(src_node_id, stream_id); if (s) { - vj_push(s->vj, opus, len); - s->last_frame_tb = get_time_tb(); - if (fin) s->ending = 1; + if (len > 0) radio_src_push_frame(s, seq, opus, len, now); + s->last_frame_tb = now; + if (fin) { + /* FIN: выдать накопленное pending (не ждём закрытия gap) и дренируем. */ + if (s->reorder_count > 0) radio_src_flush_pending(s); + s->ending = 1; + } } } pthread_mutex_unlock(&g_mtx); @@ -263,6 +442,9 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { memset(g_mix, 0, (size_t)max_samples * sizeof(int32_t)); uint64_t now = get_time_tb(); + int do_stats = (g_last_stats_tb == 0 || now - g_last_stats_tb >= RADIO_AUDIO_STATS_TB); + struct { uint64_t src; uint16_t stream; int depth; int tempo; uint32_t dropped, under; int reorder; } st[RADIO_AUDIO_MAX_SOURCES]; + int nst = 0; pthread_mutex_lock(&g_mtx); for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) { @@ -275,6 +457,21 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { radio_src_free(s); continue; } + /* reorder: gap висит дольше таймаута — force-flush, чтобы не задерживать звук. */ + if (s->reorder_count > 0 && s->reorder_age_tb && (now - s->reorder_age_tb > RADIO_AUDIO_REORDER_TO_TB)) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: reorder timeout src=%016llx pending=%d (flush)", + RADIO_AUDIO_ID, (unsigned long long)s->src_node_id, s->reorder_count); + radio_src_flush_pending(s); + } + /* ending: снять pre-roll (если ещё не снят) и дренировать накопленный остаток. */ + if (s->ending) vj_end(s->vj); + if (do_stats && nst < RADIO_AUDIO_MAX_SOURCES) { + vj_get_stats(s->vj, &st[nst].depth, &st[nst].tempo, &st[nst].dropped, &st[nst].under); + st[nst].src = s->src_node_id; + st[nst].stream = s->stream_id; + st[nst].reorder = s->reorder_count; + nst++; + } int16_t tmp[RADIO_AUDIO_MIX_SAMPLES]; int n = vj_pull(s->vj, tmp, max_samples); for (int k = 0; k < n; k++) g_mix[k] += tmp[k]; @@ -288,6 +485,14 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { } pthread_mutex_unlock(&g_mtx); + if (do_stats) { + g_last_stats_tb = now; + for (int i = 0; i < nst; i++) + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: stats src=%016llx stream=%u depth=%dms tempo=%u.%02u dropped=%u underruns=%u reorder=%d", + RADIO_AUDIO_ID, (unsigned long long)st[i].src, st[i].stream, st[i].depth, + st[i].tempo / 100, st[i].tempo % 100, (unsigned)st[i].dropped, (unsigned)st[i].under, st[i].reorder); + } + /* насыщающее суммирование (soft-clip) в int16 */ for (int k = 0; k < max_samples; k++) { int32_t v = g_mix[k]; diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 4f5571a2..95f6071d 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -331,7 +331,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_talkingLabel->hide(); hl->addWidget(m_talkingLabel); - m_radioBtn = new QPushButton(QStringLiteral("\xF0\x9F\x93\xBB"), header); + m_radioBtn = new QPushButton(QString::fromUtf8("\xF0\x9F\x93\xBB"), header); m_radioBtn->setCheckable(true); m_radioBtn->setFixedSize(34, 30); m_radioBtn->setCursor(Qt::PointingHandCursor); @@ -367,7 +367,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent layout->addWidget(m_view, 1); /* ── плавающая PTT-кнопка поверх чата (видна при включённой рации) ── */ - m_pttBtn = new QPushButton(QStringLiteral("\xF0\x9F\x8E\xA4"), this); + m_pttBtn = new QPushButton(QString::fromUtf8("\xF0\x9F\x8E\xA4"), this); m_pttBtn->setFixedSize(96, 96); m_pttBtn->setCursor(Qt::PointingHandCursor); m_pttBtn->setToolTip(QStringLiteral("Держите и говорите")); @@ -1099,7 +1099,7 @@ void MessageList::onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on) { if (on) { QString name = authorName(srcNodeId); if (name.isEmpty()) name = QStringLiteral("0x%1").arg(srcNodeId, 0, 16); - m_talkingLabel->setText(QStringLiteral("\xF0\x9F\x8E\x99 %1 говорит").arg(name)); + m_talkingLabel->setText(QString::fromUtf8("\xF0\x9F\x8E\x99 %1 говорит").arg(name)); m_talkingLabel->show(); } else { m_talkingLabel->hide(); diff --git a/tools/chatgui/tests/test_radio_audio.cpp b/tools/chatgui/tests/test_radio_audio.cpp index d7f67e8b..3c2c964b 100644 --- a/tools/chatgui/tests/test_radio_audio.cpp +++ b/tools/chatgui/tests/test_radio_audio.cpp @@ -46,6 +46,43 @@ static void push_const(uint64_t src, uint16_t stream, int value, int nframes) { opus_codec_encoder_destroy(enc); } +/* закодировать n синусоидальных кадров (seq 0..n-1, freq[i]) одним encoder'ом. */ +static int encode_frames(const int* freqs, int n, uint8_t pkt[][256], int* lens) { + opus_codec_encoder_t* enc = opus_codec_encoder_create(48000, 1); + if (!enc) { FAIL("encoder create failed"); return 0; } + opus_codec_encoder_bitrate_set(enc, 32000); + int ok = 1; + for (int f = 0; f < n; f++) { + int16_t pcm[960]; + for (int i = 0; i < 960; i++) + pcm[i] = (int16_t)(sin(2.0 * M_PI * freqs[f] * (double)i / 48000.0) * 10000.0); + lens[f] = opus_codec_encode(enc, pcm, 960, pkt[f], 256); + if (lens[f] <= 0) { ok = 0; break; } + } + opus_codec_encoder_destroy(enc); + return ok; +} + +/* подать кадры pkt[idx[i]] с seq[i] через on_frame */ +static void feed_frames(const uint8_t pkt[][256], const int* lens, const int* seqs, const int* idx, int n) { + for (int i = 0; i < n; i++) + radio_audio_on_frame(NULL, GID, SRC_A, 1, (uint16_t)seqs[i], 0, pkt[idx[i]], lens[idx[i]], NULL); +} + +/* прогнать 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); + feed_frames(pkt, lens, seqs, idx, n); + int16_t out[960]; + int pos = 0; + for (int p = 0; p < npulls; p++) { + radio_audio_pull_pcm(GID, out, 960); + for (int i = 0; i < 960; i++) result[pos++] = out[i]; + } + radio_audio_stop(); +} + /* вытянуть pull-цикл, вернуть max |sample| */ static int pull_max_abs(int npulls) { int mx = 0; @@ -81,6 +118,40 @@ int main() { if (mx > 30000) OK(); else FAIL("max|pcm|=%d (ожидали > 30000 — сумма двух источников)", mx); } + TEST("reorder: seq 0,2,1,3 воспроизводятся в порядке 0,1,2,3"); { + const int npulls = 12; + int freqs[4] = {300, 1000, 2000, 3000}; + uint8_t pkt[4][256]; + int lens[4]; + int ok = 0; + if (encode_frames(freqs, 4, pkt, lens)) { + int seqs_in[4] = {0, 1, 2, 3}, idx_in[4] = {0, 1, 2, 3}; + int seqs_out[4] = {0, 2, 1, 3}, idx_out[4] = {0, 2, 1, 3}; + int16_t buf_in[npulls * 960], buf_out[npulls * 960]; + run_burst(pkt, lens, seqs_in, idx_in, 4, buf_in, npulls); + run_burst(pkt, lens, seqs_out, idx_out, 4, buf_out, npulls); + ok = (memcmp(buf_in, buf_out, sizeof(buf_in)) == 0); + } + if (ok) OK(); else FAIL("out-of-order должен дать тот же PCM, что и in-order (reorder не сработал)"); + } + + TEST("late seq: кадр с seq < next_seq дропается"); { + const int npulls = 12; + int freqs[4] = {300, 1000, 2000, 3000}; + uint8_t pkt[4][256]; + int lens[4]; + int ok = 0; + if (encode_frames(freqs, 4, pkt, lens)) { + int seqs_ref[4] = {0, 1, 2, 3}, idx_ref[4] = {0, 1, 2, 3}; + int seqs_late[5] = {0, 1, 2, 3, 0}, idx_late[5] = {0, 1, 2, 3, 0}; /* последний — поздний дубль seq=0 */ + int16_t buf_ref[npulls * 960], buf_late[npulls * 960]; + run_burst(pkt, lens, seqs_ref, idx_ref, 4, buf_ref, npulls); + run_burst(pkt, lens, seqs_late, idx_late, 5, buf_late, npulls); + ok = (memcmp(buf_ref, buf_late, sizeof(buf_ref)) == 0); + } + if (ok) OK(); else FAIL("поздний дубль seq=0 должен быть дропнут (PCM совпадает с эталоном)"); + } + printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); return G_FAILED > 0 ? 1 : 0; } diff --git a/tools/chatgui/tests/test_voice_jitter.cpp b/tools/chatgui/tests/test_voice_jitter.cpp index 2c4d4b25..59865e94 100644 --- a/tools/chatgui/tests/test_voice_jitter.cpp +++ b/tools/chatgui/tests/test_voice_jitter.cpp @@ -94,10 +94,49 @@ int main(void) { vj_destroy(j); } + /* ── 4. pre-roll: не отдаём звук, пока глубина < target_ms ── */ + { + struct vj_cfg cfg; + cfg.sample_rate = 48000; cfg.channels = 1; cfg.frame_samples = FRAME; cfg.frame_ms = 20; + cfg.max_frames = CAP; cfg.target_ms = 80.0; cfg.min_tempo = 1.0; cfg.max_tempo = 1.6; + cfg.pre_roll = 1; cfg.decode = sine_decode; cfg.decode_arg = nullptr; + vj* j = vj_create_cfg(&cfg); + CHECK(j != nullptr, "cfg create failed"); + + for (int i = 0; i < 3; i++) vj_push(j, marker, (int)sizeof(marker)); /* 60мс < 80мс */ + int n = vj_pull(j, out.data(), (int)out.size()); + printf(" pre-roll: 3 frames -> pull=%d (expect 0)\n", n); + CHECK(n == 0, "pre-roll должен отдавать 0 до накопления target, got %d", n); + + vj_push(j, marker, (int)sizeof(marker)); /* 80мс == target */ + n = vj_pull(j, out.data(), (int)out.size()); + printf(" pre-roll: 4 frames -> pull=%d (expect >0)\n", n); + CHECK(n > 0, "после достижения target буфер должен отдавать PCM, got %d", n); + vj_destroy(j); + } + + /* ── 5. симметричный темп: при depth < target темп < 1.0x ── */ + { + struct vj_cfg cfg; + cfg.sample_rate = 48000; cfg.channels = 1; cfg.frame_samples = FRAME; cfg.frame_ms = 20; + cfg.max_frames = CAP; cfg.target_ms = 80.0; cfg.min_tempo = 0.75; cfg.max_tempo = 1.6; + cfg.pre_roll = 0; cfg.decode = sine_decode; cfg.decode_arg = nullptr; + vj* j = vj_create_cfg(&cfg); + CHECK(j != nullptr, "cfg create failed (symmetric)"); + + vj_push(j, marker, (int)sizeof(marker)); /* 1 кадр = 20мс << 80мс */ + vj_pull(j, out.data(), (int)out.size()); + int tx100 = 100; + vj_get_stats(j, nullptr, &tx100, nullptr, nullptr); + printf(" symmetric: depth tempo_x100=%d (expect <100)\n", tx100); + CHECK(tx100 < 100, "при мелком буфере темп должен быть < 1.0x, got %d", tx100); + vj_destroy(j); + } + if (g_fail == 0) { printf("TEST PASSED\n"); return 0; } printf("TEST FAILED (%d checks)\n", g_fail); return 1; -} +} \ No newline at end of file