Browse Source

radio: адаптивный джиттер-буфер (pre-roll, симметричный темп, reorder) + AGC-компрессор микрофона

- 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
proxy
evgeny 2 weeks ago
parent
commit
738cfe7835
  1. 5
      lib/audio_compressor.c
  2. 4
      lib/audio_compressor.h
  3. 137
      src/call/voice_jitter.cpp
  4. 26
      src/call/voice_jitter.h
  5. 247
      src/radio/radio_audio.c
  6. 6
      tools/chatgui/src/messagelist.cpp
  7. 71
      tools/chatgui/tests/test_radio_audio.cpp
  8. 41
      tools/chatgui/tests/test_voice_jitter.cpp

5
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) { size_t audio_compressor_output_size(const struct audio_compressor* ac) {
return ac ? ac->output_size : 0; return ac ? ac->output_size : 0;
} }
void audio_compressor_clear_output(struct audio_compressor* ac) {
if (!ac) return;
ac->output_size = 0;
}

4
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); const int16_t* audio_compressor_output(const struct audio_compressor* ac);
size_t audio_compressor_output_size(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 #ifdef __cplusplus
} }
#endif #endif

137
src/call/voice_jitter.cpp

@ -2,8 +2,10 @@
// //
// Кодированные кадры (Opus, до VJ_MAX_ENC байт) кладутся из uasync-потока в кольцо // Кодированные кадры (Opus, до VJ_MAX_ENC байт) кладутся из uasync-потока в кольцо
// и вытаскиваются аудио-потоком: кадр декодируется (decode-коллбэк) и подаётся в // и вытаскиваются аудио-потоком: кадр декодируется (decode-коллбэк) и подаётся в
// SoundTouch с плавно меняющимся темпом: глубина < target → 1.0x, глубина растёт → // SoundTouch с плавно меняющимся темпом вокруг целевой глубины: мельче target — темп
// ускорение до 1.6x (догон live). Декодируется только то, что готово к воспроизведению. // падает к min_tempo (накопление), глубже — растёт к max_tempo (догон live). При
// pre_roll=1 звук не отдаётся, пока глубина не достигнет target_ms. Декодируется
// только то, что готово к воспроизведению.
// Кольцо защищено мьютексом (SPSC); decode и SoundTouch трогает только аудио-поток. // Кольцо защищено мьютексом (SPSC); decode и SoundTouch трогает только аудио-поток.
#include "voice_jitter.h" #include "voice_jitter.h"
@ -11,18 +13,20 @@
#include "soundtouch/SoundTouch.h" #include "soundtouch/SoundTouch.h"
#include <atomic> #include <atomic>
#include <chrono>
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include <mutex> #include <mutex>
#include <vector> #include <vector>
static const int VJ_MAX_ENC = 256; /* макс. размер кодированного кадра (Opus 20мс) */ static const int VJ_MAX_ENC = 256; /* макс. размер кодированного кадра (Opus 20мс) */
static const double kTargetMs = 60.0; /* целевая глубина буфера */ static const double kDefaultTargetMs = 60.0; /* целевая глубина буфера (звонок) */
static const double kMaxTempo = 1.6; static const double kDefaultMinTempo = 1.0;
static const double kMinTempo = 1.0; static const double kDefaultMaxTempo = 1.6;
static const double kMaxDepthMs = 1000.0; /* при этой глубине tempo == kMaxTempo */ static const double kMaxDepthMs = 1000.0; /* при этой глубине tempo == max_tempo */
static const double kEmaAlpha = 0.2; /* сглаживание глубины */ static const double kEmaAlpha = 0.2; /* сглаживание глубины */
static const double kTempoStep = 0.01; /* макс. изменение темпа за один pull (без кликов) */ static const double kTempoStep = 0.01; /* макс. изменение темпа за один pull (без кликов) */
static const double kPrimeTimeoutMs = 200.0; /* pre-roll: принудительный prime через это время */
struct vj { struct vj {
int sr = 0; int sr = 0;
@ -48,16 +52,31 @@ struct vj {
std::vector<float> fin; /* scratch: кадр в float */ std::vector<float> fin; /* scratch: кадр в float */
std::vector<float> fout; /* scratch: выход float */ std::vector<float> 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 ema_depth_ms = 0.0;
double tempo = 1.0; double tempo = 1.0;
std::atomic<int> tempo_x100{100}; std::atomic<int> tempo_x100{100};
}; };
static double vj_compute_tempo(double depth_ms) { /* Симметричный целевой темп: глубина < target → min_tempo..1.0 (накопление),
if (depth_ms <= kTargetMs) return kMinTempo; * глубина > target → 1.0..max_tempo (догон). */
double x = (depth_ms - kTargetMs) / (kMaxDepthMs - kTargetMs); 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; if (x > 1.0) x = 1.0;
return kMinTempo + (kMaxTempo - kMinTempo) * x; return 1.0 + (max_tempo - 1.0) * x;
} }
/* Вытащить один кодированный кадр из кольца (под мьютексом). Возвращает 0 если пусто. */ /* Вытащить один кодированный кадр из кольца (под мьютексом). Возвращает 0 если пусто. */
@ -73,28 +92,33 @@ static int vj_pop_enc(struct vj* j, uint8_t* out, int* len_out) {
extern "C" { extern "C" {
struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms, struct vj* vj_create_cfg(const struct vj_cfg* cfg) {
int max_frames, vj_decode_fn decode, void* decode_arg) { if (!cfg || cfg->sample_rate <= 0 || cfg->channels <= 0 || cfg->frame_samples <= 0 ||
if (sample_rate <= 0 || channels <= 0 || frame_samples <= 0 || frame_ms <= 0 || cfg->frame_ms <= 0 || cfg->max_frames <= 0 || !cfg->decode)
max_frames <= 0 || !decode) return nullptr;
if (cfg->min_tempo <= 0.0 || cfg->max_tempo < cfg->min_tempo || cfg->target_ms <= 0.0)
return nullptr; return nullptr;
vj* j = new vj(); vj* j = new vj();
j->sr = sample_rate; j->sr = cfg->sample_rate;
j->ch = channels; j->ch = cfg->channels;
j->frame_samples = frame_samples; j->frame_samples = cfg->frame_samples;
j->frame_ms = frame_ms; j->frame_ms = cfg->frame_ms;
j->cap = max_frames; j->cap = cfg->max_frames;
j->decode = decode; j->decode = cfg->decode;
j->decode_arg = decode_arg; j->decode_arg = cfg->decode_arg;
j->ring.assign((size_t)max_frames * VJ_MAX_ENC, 0); j->target_ms = cfg->target_ms;
j->lens.assign(max_frames, 0); j->min_tempo = cfg->min_tempo;
j->pcm_buf.assign(frame_samples, 0); j->max_tempo = cfg->max_tempo;
j->fin.assign(frame_samples, 0.0f); j->pre_roll = cfg->pre_roll;
j->ring.assign((size_t)cfg->max_frames * VJ_MAX_ENC, 0);
j->st.setSampleRate((uint)sample_rate); j->lens.assign(cfg->max_frames, 0);
j->st.setChannels((uint)channels); j->pcm_buf.assign(cfg->frame_samples, 0);
j->st.setTempo(kMinTempo); 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_USE_QUICKSEEK, 1);
j->st.setSetting(SETTING_SEQUENCE_MS, 20); j->st.setSetting(SETTING_SEQUENCE_MS, 20);
j->st.setSetting(SETTING_SEEKWINDOW_MS, 10); 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; 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) { void vj_destroy(struct vj* j) {
if (!j) return; if (!j) return;
delete j; 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) { int vj_pull(struct vj* j, int16_t* out, int max_samples) {
if (!j || !out || max_samples <= 0) return 0; if (!j || !out || max_samples <= 0) return 0;
/* 1) глубина (сглаженная) → целевой темп (плавно) */ /* 0) pre-roll: не отдаём звук, пока глубина кольца < target_ms (или не истёк таймаут).
* Состояние — только аудио-поток; кадры копятся в кольце продюсером (uasync). */
int cnt; int cnt;
{ {
std::lock_guard<std::mutex> lk(j->mtx); std::lock_guard<std::mutex> lk(j->mtx);
cnt = j->count; 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<double, std::milli>(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; 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; double d = target - j->tempo;
if (d > kTempoStep) d = kTempoStep; if (d > kTempoStep) d = kTempoStep;
else 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; 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; 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) { if (depth_ms) {
std::lock_guard<std::mutex> lk(j->mtx); std::lock_guard<std::mutex> lk(j->mtx);
*depth_ms = j->count * j->frame_ms; *depth_ms = j->count * j->frame_ms;

26
src/call/voice_jitter.h

@ -26,8 +26,26 @@ struct vj;
/* decode: закодированный кадр → PCM. Возвращает число декодированных сэмплов. */ /* decode: закодированный кадр → PCM. Возвращает число декодированных сэмплов. */
typedef int (*vj_decode_fn)(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples); 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, 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); 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 при нехватке данных (андерфлоу). */ * < max_samples при нехватке данных (андерфлоу). */
int vj_pull(struct vj* j, int16_t* out, int 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), дропнуто кадров /* Диагностика: глубина буфера (мс), темп (×100, 100 = 1.0x), дропнуто кадров
* при переполнении, число underrun-ов (pull вернул меньше запрошенного). */ * при переполнении, число underrun-ов (pull вернул меньше запрошенного). */
void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns); void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns);

247
src/radio/radio_audio.c

@ -13,9 +13,12 @@
#include "radio_audio.h" #include "radio_audio.h"
#include "radio.h" #include "radio.h"
#include "radio_proto.h"
#include "../call/voice_jitter.h" #include "../call/voice_jitter.h"
#include "../chat/chat_setting.h"
#include "../utun_instance.h" #include "../utun_instance.h"
#include "../../lib/opus_codec.h" #include "../../lib/opus_codec.h"
#include "../../lib/audio_compressor.h"
#include "../../lib/debug_config.h" #include "../../lib/debug_config.h"
#include "../../lib/mem.h" #include "../../lib/mem.h"
#include "../../lib/u_async.h" #include "../../lib/u_async.h"
@ -30,6 +33,21 @@
#define RADIO_AUDIO_MAX_FRAMES 50 /* ~1000мс @20мс */ #define RADIO_AUDIO_MAX_FRAMES 50 /* ~1000мс @20мс */
#define RADIO_AUDIO_MIX_SAMPLES 960 /* максимум сэмплов за pull */ #define RADIO_AUDIO_MIX_SAMPLES 960 /* максимум сэмплов за pull */
#define RADIO_AUDIO_SOURCE_TIMEOUT_TB 20000 /* 2с без кадров → источник завершён */ #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 { struct radio_source {
uint64_t src_node_id; uint64_t src_node_id;
@ -39,6 +57,12 @@ struct radio_source {
uint64_t last_frame_tb; uint64_t last_frame_tb;
opus_codec_decoder_t* dec; /* decode-аргумент для vj (stateless per call) */ opus_codec_decoder_t* dec; /* decode-аргумент для vj (stateless per call) */
struct vj* vj; /* jitter-буфер с time-stretch */ 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; 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 int g_active = 0;
static uint64_t g_group_id = 0; static uint64_t g_group_id = 0;
static opus_codec_encoder_t* g_encoder = NULL; 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 struct radio_source g_sources[RADIO_AUDIO_MAX_SOURCES];
static int32_t g_mix[RADIO_AUDIO_MIX_SAMPLES]; static int32_t g_mix[RADIO_AUDIO_MIX_SAMPLES];
static uint64_t g_last_stats_tb = 0;
/* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */ /* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */
static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { 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); 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) { 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->vj) { vj_destroy(s->vj); s->vj = NULL; }
if (s->dec) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; } if (s->dec) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; }
s->used = 0; s->ending = 0; s->last_frame_tb = 0; s->used = 0; s->ending = 0; s->last_frame_tb = 0;
radio_src_reorder_reset(s);
} }
/* Получить/создать инстанс источника. Один пир — один инстанс: если stream_id /* Получить/создать инстанс источника. Один пир — один инстанс: если stream_id
@ -77,9 +204,9 @@ static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) {
if (s->stream_id != stream) { if (s->stream_id != stream) {
s->stream_id = stream; s->stream_id = stream;
s->ending = 0; s->ending = 0;
radio_src_reorder_reset(s);
vj_destroy(s->vj); vj_destroy(s->vj);
s->vj = vj_create(RADIO_AUDIO_SAMPLE_RATE, 1, RADIO_AUDIO_FRAME_SAMPLES, RADIO_AUDIO_FRAME_MS, s->vj = radio_vj_create(s->dec);
RADIO_AUDIO_MAX_FRAMES, radio_audio_decode_cb, s->dec);
if (!s->vj) { radio_src_free(s); return NULL; } 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); 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 = &g_sources[i];
s->dec = opus_codec_decoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); 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; } 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, s->vj = radio_vj_create(s->dec);
RADIO_AUDIO_MAX_FRAMES, radio_audio_decode_cb, s->dec);
if (!s->vj) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } if (!s->vj) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; }
s->src_node_id = src; s->src_node_id = src;
s->stream_id = stream; s->stream_id = stream;
s->used = 1; s->ending = 0; s->last_frame_tb = 0; 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); DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: new source src=%016llx stream=%u", RADIO_AUDIO_ID, (unsigned long long)src, stream);
return s; 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; } 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); 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_encoder = enc;
g_compressor = ac;
g_group_id = group_id; g_group_id = group_id;
g_active = 1; g_active = 1;
memset(g_sources, 0, sizeof(g_sources)); memset(g_sources, 0, sizeof(g_sources));
pthread_mutex_unlock(&g_mtx); 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; return 0;
} }
void radio_audio_stop(void) { void radio_audio_stop(void) {
opus_codec_encoder_t* enc = NULL; opus_codec_encoder_t* enc = NULL;
struct audio_compressor* ac = NULL;
int was_active = 0; int was_active = 0;
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
@ -153,6 +300,7 @@ void radio_audio_stop(void) {
g_active = 0; g_active = 0;
g_group_id = 0; g_group_id = 0;
enc = g_encoder; g_encoder = NULL; enc = g_encoder; g_encoder = NULL;
ac = g_compressor; g_compressor = NULL;
for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++)
if (g_sources[i].used) radio_src_free(&g_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 (was_active) {
if (enc) opus_codec_encoder_destroy(enc); if (enc) opus_codec_encoder_destroy(enc);
if (ac) audio_compressor_destroy(ac);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: stopped", RADIO_AUDIO_ID); 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) { int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) {
if (!pcm || count != RADIO_AUDIO_FRAME_SAMPLES) return -1; 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 UASYNC* ua = NULL;
struct UTUN_INSTANCE* inst = NULL; struct UTUN_INSTANCE* inst = NULL;
int len = -1;
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
if (!g_active || group_id != g_group_id || !g_encoder) { if (!g_active || group_id != g_group_id || !g_encoder) {
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
return -1; 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; ua = g_inst ? g_inst->ua : NULL;
inst = g_inst; inst = g_inst;
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
if (len <= 0) return -1; if (n_frames == 0) return -1;
if (!ua || !inst) { if (!ua || !inst) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: feed_pcm: instance not set", RADIO_AUDIO_ID); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: feed_pcm: instance not set", RADIO_AUDIO_ID);
return -1; return -1;
} }
struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a)); for (int i = 0; i < n_frames; i++) {
if (!a) return -1; struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a));
a->inst = inst; if (!a) return -1;
a->group_id = group_id; a->inst = inst;
a->len = (uint16_t)len; a->group_id = group_id;
memcpy(a->opus, opus, (size_t)len); a->len = (uint16_t)opus_len[i];
uasync_post(ua, radio_talk_send_trampoline, a); memcpy(a->opus, opus[i], (size_t)opus_len[i]);
uasync_post(ua, radio_talk_send_trampoline, a);
}
return 0; 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, 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, uint64_t src_node_id, uint16_t stream_id, uint16_t seq,
uint8_t fin, const uint8_t* opus, int len, void* arg) { uint8_t fin, const uint8_t* opus, int len, void* arg) {
(void)inst; (void)seq; (void)arg; (void)inst; (void)arg;
if (!opus || len <= 0) return; if (!fin && (!opus || len <= 0)) return;
uint64_t now = get_time_tb();
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
if (g_active && group_id == g_group_id) { if (g_active && group_id == g_group_id) {
struct radio_source* s = radio_src_acquire(src_node_id, stream_id); struct radio_source* s = radio_src_acquire(src_node_id, stream_id);
if (s) { if (s) {
vj_push(s->vj, opus, len); if (len > 0) radio_src_push_frame(s, seq, opus, len, now);
s->last_frame_tb = get_time_tb(); s->last_frame_tb = now;
if (fin) s->ending = 1; if (fin) {
/* FIN: выдать накопленное pending (не ждём закрытия gap) и дренируем. */
if (s->reorder_count > 0) radio_src_flush_pending(s);
s->ending = 1;
}
} }
} }
pthread_mutex_unlock(&g_mtx); 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)); memset(g_mix, 0, (size_t)max_samples * sizeof(int32_t));
uint64_t now = get_time_tb(); 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); pthread_mutex_lock(&g_mtx);
for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) { 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); radio_src_free(s);
continue; 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]; int16_t tmp[RADIO_AUDIO_MIX_SAMPLES];
int n = vj_pull(s->vj, tmp, max_samples); int n = vj_pull(s->vj, tmp, max_samples);
for (int k = 0; k < n; k++) g_mix[k] += tmp[k]; 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); 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 */ /* насыщающее суммирование (soft-clip) в int16 */
for (int k = 0; k < max_samples; k++) { for (int k = 0; k < max_samples; k++) {
int32_t v = g_mix[k]; int32_t v = g_mix[k];

6
tools/chatgui/src/messagelist.cpp

@ -331,7 +331,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
m_talkingLabel->hide(); m_talkingLabel->hide();
hl->addWidget(m_talkingLabel); 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->setCheckable(true);
m_radioBtn->setFixedSize(34, 30); m_radioBtn->setFixedSize(34, 30);
m_radioBtn->setCursor(Qt::PointingHandCursor); m_radioBtn->setCursor(Qt::PointingHandCursor);
@ -367,7 +367,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
layout->addWidget(m_view, 1); layout->addWidget(m_view, 1);
/* ── плавающая PTT-кнопка поверх чата (видна при включённой рации) ── */ /* ── плавающая 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->setFixedSize(96, 96);
m_pttBtn->setCursor(Qt::PointingHandCursor); m_pttBtn->setCursor(Qt::PointingHandCursor);
m_pttBtn->setToolTip(QStringLiteral("Держите и говорите")); m_pttBtn->setToolTip(QStringLiteral("Держите и говорите"));
@ -1099,7 +1099,7 @@ void MessageList::onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on) {
if (on) { if (on) {
QString name = authorName(srcNodeId); QString name = authorName(srcNodeId);
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(srcNodeId, 0, 16); 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(); m_talkingLabel->show();
} else { } else {
m_talkingLabel->hide(); m_talkingLabel->hide();

71
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); 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| */ /* вытянуть pull-цикл, вернуть max |sample| */
static int pull_max_abs(int npulls) { static int pull_max_abs(int npulls) {
int mx = 0; int mx = 0;
@ -81,6 +118,40 @@ int main() {
if (mx > 30000) OK(); else FAIL("max|pcm|=%d (ожидали > 30000 — сумма двух источников)", mx); 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); printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED);
return G_FAILED > 0 ? 1 : 0; return G_FAILED > 0 ? 1 : 0;
} }

41
tools/chatgui/tests/test_voice_jitter.cpp

@ -94,10 +94,49 @@ int main(void) {
vj_destroy(j); 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<target -> tempo_x100=%d (expect <100)\n", tx100);
CHECK(tx100 < 100, "при мелком буфере темп должен быть < 1.0x, got %d", tx100);
vj_destroy(j);
}
if (g_fail == 0) { if (g_fail == 0) {
printf("TEST PASSED\n"); printf("TEST PASSED\n");
return 0; return 0;
} }
printf("TEST FAILED (%d checks)\n", g_fail); printf("TEST FAILED (%d checks)\n", g_fail);
return 1; return 1;
} }
Loading…
Cancel
Save