From 2de554f8b9c2240d187fe268993f83cb3850cadd Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 16 Sep 2026 16:55:28 +0300 Subject: [PATCH] =?UTF-8?q?call:=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=20(libutun=5Fvoice)=20+=20SoundTouch=20?= =?UTF-8?q?=D0=BD=D0=B0=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - voice_jitter (кодированные Opus-кадры + decode-on-pull + SoundTouch time-stretch) вместо PCM-буфера audio_jitter; излишек держится до декодирования - call_audio.c/.h — единая реализация для desktop/Android/headless (C-интерфейс) - libutun_voice (voice_sources.cmake): SoundTouch + voice_jitter + call_audio + call_tones - Android: pull-модель (nativeCallAudioPull), убран Kotlin FIFO, ANDROID_STL=c++_static - headless audio-сокет: PCM-протокол (decode-only, без тактирования), daemon остаётся C-only - CALL_STATS расширен dropped/underruns; удалены audio_jitter.cpp и libutun_lite/call_audio.c - тест test_voice_jitter (ёмкость/догон/стационар), test_call_headless переведён на PCM --- doc/tasks.md | 13 ++ src/call/audio_jitter.cpp | 180 ----------------- src/call/audio_jitter.h | 51 ----- src/call/call.c | 9 +- src/call/call.h | 6 +- src/call/call_audio.c | 67 ++++--- src/call/call_audio.h | 28 ++- src/call/call_headless.c | 96 ++++++++- src/call/call_headless.h | 12 +- src/call/voice_jitter.cpp | 187 ++++++++++++++++++ src/call/voice_jitter.h | 54 +++++ src/call/voice_sources.cmake | 52 +++++ tests/Makefile.am | 2 +- tests/test_call_headless.c | 13 +- tools/chatgui-android/AGENTS.md | 4 +- tools/chatgui-android/app/build.gradle.kts | 2 +- .../app/src/main/cpp/CMakeLists.txt | 16 +- .../com/utun/chat/data/CallAudioEngine.kt | 59 +----- .../main/java/com/utun/chat/data/NativeLib.kt | 14 +- .../jni_bridge/android_jni_bridge.c | 58 ++---- .../jni_bridge/android_jni_bridge.h | 5 +- .../chatgui-android/libutun_lite/call_audio.c | 156 --------------- .../chatgui-android/libutun_lite/call_audio.h | 52 ----- .../libutun_lite/utun_sources.cmake | 5 +- tools/chatgui/CMakeLists.txt | 58 ++---- tools/chatgui/libutun/CMakeLists.txt | 2 +- tools/chatgui/tests/test_audio_jitter.cpp | 101 ---------- tools/chatgui/tests/test_voice_jitter.cpp | 103 ++++++++++ tools/chatgui/transport/gui_bridge_impl.cpp | 10 +- 29 files changed, 659 insertions(+), 756 deletions(-) delete mode 100644 src/call/audio_jitter.cpp delete mode 100644 src/call/audio_jitter.h create mode 100644 src/call/voice_jitter.cpp create mode 100644 src/call/voice_jitter.h create mode 100644 src/call/voice_sources.cmake delete mode 100644 tools/chatgui-android/libutun_lite/call_audio.c delete mode 100644 tools/chatgui-android/libutun_lite/call_audio.h delete mode 100644 tools/chatgui/tests/test_audio_jitter.cpp create mode 100644 tools/chatgui/tests/test_voice_jitter.cpp diff --git a/doc/tasks.md b/doc/tasks.md index 01598aa9..d5bd91d5 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -41,6 +41,19 @@ (CallAudioEngine), роутинг источника (CallAudioRouter: EARPIECE/SPEAKER/HEADSET, BT SCO), CallScreen + IncomingCallDialog + RingtonePlayer + IncomingCallManager, `libutun_lite/call_audio.c` (собственный аудио-контур Opus 48kHz/20мс). + [+] **Единый голосовой стек (voice stack) + SoundTouch на Android** — сделано. + Унификация аудио-контура звонка: `libutun_voice` (статическая C++-либа с + C-интерфейсом `call_audio.h` = SoundTouch + `voice_jitter.cpp` + `call_audio.c` + + `call_tones.c`), собирается через `src/call/voice_sources.cmake` и линкуется + desktop/Android/headless-CLI. Джиттер-буфер держит КОДИРОВАННЫЕ (Opus) кадры, + декодирует только на pull (непосредственно перед воспроизведением) — память ~48x + меньше и uasync-поток разгружен. Android переведён с push-FIFO (Kotlin ArrayDeque) + на pull-модель (`nativeCallAudioPull`), своего джиттера больше нет → убрана + асимметрия задержки (desktop↔Android). Headless-аудио-сокет отдаёт готовый PCM + (декод Opus→PCM, без тактирования; клиент сам задаёт темп). Диагностика: + `CALL_STATS` расширен [dropped, underruns] (раз в 1с), счётчики headless. + Удалены `audio_jitter.cpp` и `libutun_lite/call_audio.c`. Тест `test_voice_jitter` + (ёмкость/догон/стационар). Android — `ANDROID_STL=c++_static`. [ ] **DM-звонки** (fallback `group_id==0`) — не сделано: `call_is_online`/`call_start` требуют реальную группу (`topo_groups_find`), для DM (group_id==0) маршрута нет. diff --git a/src/call/audio_jitter.cpp b/src/call/audio_jitter.cpp deleted file mode 100644 index 36257f5f..00000000 --- a/src/call/audio_jitter.cpp +++ /dev/null @@ -1,180 +0,0 @@ -// audio_jitter.cpp — адаптивный джиттер-буфер с time-stretch (SoundTouch). -// -// PCM-кадры (int16, 960 сэмплов @20мс) кладутся из uasync-потока в кольцо и -// вытаскиваются аудио-потоком через SoundTouch с плавно меняющимся темпом: -// глубина < target → 1.0x, глубина растёт → ускорение до 1.6x (догон live). -// Кольцо защищено мьютексом (SPSC); SoundTouch трогает только аудио-поток. - -#include "audio_jitter.h" - -#include "soundtouch/SoundTouch.h" - -#include -#include -#include -#include -#include - -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 (без кликов) - -struct ajb { - int sr = 0; - int ch = 0; - int frame_samples = 0; - int frame_ms = 0; - int cap = 0; - - std::mutex mtx; - std::vector ring; // cap * frame_samples - int head = 0; - int count = 0; - uint32_t dropped = 0; - - soundtouch::SoundTouch st; // только аудио-поток - std::vector frame_buf; // scratch: вытащенный кадр - std::vector fin; // scratch: кадр в float - std::vector fout; // scratch: выход float - - double ema_depth_ms = 0.0; - double tempo = 1.0; - std::atomic tempo_x100{100}; -}; - -static double ajb_compute_tempo(double depth_ms) { - if (depth_ms <= kTargetMs) return kMinTempo; - double x = (depth_ms - kTargetMs) / (kMaxDepthMs - kTargetMs); - if (x > 1.0) x = 1.0; - return kMinTempo + (kMaxTempo - kMinTempo) * x; -} - -/* Вытащить один кадр из кольца (под мьютексом). Возвращает 0 если пусто. */ -static int ajb_pop_frame(struct ajb* j, int16_t* out) { - std::lock_guard lk(j->mtx); - if (j->count == 0) return 0; - std::memcpy(out, &j->ring[(size_t)j->head * j->frame_samples], - (size_t)j->frame_samples * sizeof(int16_t)); - j->head = (j->head + 1) % j->cap; - j->count--; - return 1; -} - -extern "C" { - -struct ajb* ajb_create(int sample_rate, int channels, int frame_samples, int frame_ms, int max_frames) { - if (sample_rate <= 0 || channels <= 0 || frame_samples <= 0 || frame_ms <= 0 || max_frames <= 0) - return nullptr; - - ajb* j = new ajb(); - j->sr = sample_rate; - j->ch = channels; - j->frame_samples = frame_samples; - j->frame_ms = frame_ms; - j->cap = max_frames; - j->ring.assign((size_t)max_frames * frame_samples, 0); - j->frame_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->st.setSetting(SETTING_USE_QUICKSEEK, 1); - j->st.setSetting(SETTING_SEQUENCE_MS, 20); - j->st.setSetting(SETTING_SEEKWINDOW_MS, 10); - j->st.setSetting(SETTING_OVERLAP_MS, 4); - j->st.setSetting(SETTING_USE_AA_FILTER, 0); - return j; -} - -void ajb_destroy(struct ajb* j) { - if (!j) return; - delete j; -} - -void ajb_reset(struct ajb* j) { - if (!j) return; - { - std::lock_guard lk(j->mtx); - j->head = 0; - j->count = 0; - } - j->st.clear(); - j->st.setTempo(kMinTempo); - j->tempo = kMinTempo; - j->ema_depth_ms = 0.0; - j->tempo_x100.store(100); -} - -void ajb_push(struct ajb* j, const int16_t* pcm, int samples) { - if (!j || !pcm || samples != j->frame_samples) return; - std::lock_guard lk(j->mtx); - if (j->count == j->cap) { - j->head = (j->head + 1) % j->cap; // дроп старейшего - j->count--; - j->dropped++; - } - int idx = (j->head + j->count) % j->cap; - std::memcpy(&j->ring[(size_t)idx * j->frame_samples], pcm, (size_t)samples * sizeof(int16_t)); - j->count++; -} - -int ajb_pull(struct ajb* j, int16_t* out, int max_samples) { - if (!j || !out || max_samples <= 0) return 0; - - /* 1) глубина (сглаженная) → целевой темп (плавно) */ - int cnt; - { - std::lock_guard lk(j->mtx); - cnt = j->count; - } - double depth_ms = (double)cnt * j->frame_ms; - j->ema_depth_ms = kEmaAlpha * depth_ms + (1.0 - kEmaAlpha) * j->ema_depth_ms; - double target = ajb_compute_tempo(j->ema_depth_ms); - double d = target - j->tempo; - if (d > kTempoStep) d = kTempoStep; - else if (d < -kTempoStep) d = -kTempoStep; - j->tempo += d; - j->st.setTempo(j->tempo); - j->tempo_x100.store((int)std::lround(j->tempo * 100.0)); - - /* 2) кормим SoundTouch, пока он не сможет выдать max_samples (или кольцо пусто) */ - int guard = 0; - while (j->st.numSamples() < (uint)max_samples && guard++ < j->cap + 4) { - if (!ajb_pop_frame(j, j->frame_buf.data())) break; - for (int i = 0; i < j->frame_samples; i++) - j->fin[(size_t)i] = (float)j->frame_buf[(size_t)i] / 32768.0f; - j->st.putSamples(j->fin.data(), (uint)j->frame_samples); - } - - /* 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++) { - 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); - } - return (int)n; -} - -void ajb_get_stats(struct ajb* j, int* buffer_ms, int* tempo_x100) { - if (!j) return; - if (buffer_ms) { - std::lock_guard lk(j->mtx); - *buffer_ms = j->count * j->frame_ms; - } - if (tempo_x100) *tempo_x100 = j->tempo_x100.load(); -} - -uint32_t ajb_dropped(struct ajb* j) { - if (!j) return 0; - std::lock_guard lk(j->mtx); - return j->dropped; -} - -} // extern "C" diff --git a/src/call/audio_jitter.h b/src/call/audio_jitter.h deleted file mode 100644 index 4cc882d8..00000000 --- a/src/call/audio_jitter.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef AUDIO_JITTER_H -#define AUDIO_JITTER_H - -// audio_jitter.h — адаптивный джиттер-буфер с time-stretch (SoundTouch), C API. -// -// Хранит декодированные PCM-кадры (по frame_samples сэмплов). На pull применяет -// tempo: при росте глубины буфера (сетевая пауза → burst) воспроизведение плавно -// ускоряется (до 1.6x) и догоняет live, не теряя данные (до max_frames кадров). -// -// Потоки: -// - ajb_push — uasync-поток (producer); -// - ajb_pull — аудио-поток (consumer, единственный владелец SoundTouch); -// - ajb_get_stats — любой поток. -// Кольцо кадров защищено мьютексом (SPSC, дёшево: ~50 кадров/с). - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -struct ajb; - -/* Создать буфер. frame_samples — сэмплов в кадре (960 @20мс/48k), frame_ms — - * длительность кадра в мс, max_frames — ёмкость (50 = 1000мс). */ -struct ajb* ajb_create(int sample_rate, int channels, int frame_samples, int frame_ms, int max_frames); - -void ajb_destroy(struct ajb* j); - -/* Сброс: очистить кольцо и внутреннее состояние SoundTouch (темп → 1.0). */ -void ajb_reset(struct ajb* j); - -/* producer: положить декодированный PCM-кадр (ровно frame_samples сэмплов). - * При переполнении дропает старейший кадр (счётчик внутри). */ -void ajb_push(struct ajb* j, const int16_t* pcm, int samples); - -/* consumer: выдать до max_samples PCM с учётом текущего темпа. Возвращает число - * выданных сэмплов; может быть < max_samples при нехватке данных (андерфлоу). */ -int ajb_pull(struct ajb* j, int16_t* out, int max_samples); - -/* Диагностика: текущая глубина буфера (мс) и темп (×100, 100 = 1.0x). */ -void ajb_get_stats(struct ajb* j, int* buffer_ms, int* tempo_x100); - -/* Число дропнутых кадров при переполнении (для диагностики/тестов). */ -uint32_t ajb_dropped(struct ajb* j); - -#ifdef __cplusplus -} -#endif - -#endif /* AUDIO_JITTER_H */ diff --git a/src/call/call.c b/src/call/call.c index 24a36b66..4607f4a8 100644 --- a/src/call/call.c +++ b/src/call/call.c @@ -339,15 +339,16 @@ static void call_stats_timer_cb(void* arg) { if (s->state != CALL_ST_ACTIVE) return; int buffer_ms = 0, tempo_x100 = 100; + uint32_t dropped = 0, underruns = 0; if (ctx->audio_ops && ctx->audio_ops->get_stats) - ctx->audio_ops->get_stats(s->call_id, &buffer_ms, &tempo_x100); + ctx->audio_ops->get_stats(s->call_id, &buffer_ms, &tempo_x100, &dropped, &underruns); uint16_t rtt = 0; struct ETCP_ROUTER_CONN* rconn = etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL); if (rconn && rconn->recv_conn) rtt = rconn->recv_conn->rtt_last; - uint8_t buf[14]; + uint8_t buf[18]; size_t off = 0; memcpy(buf + off, &s->call_id, 8); off += 8; buf[off++] = (uint8_t)(rtt >> 8); buf[off++] = (uint8_t)(rtt & 0xFF); @@ -355,6 +356,10 @@ static void call_stats_timer_cb(void* arg) { buf[off++] = (uint8_t)(bms >> 8); buf[off++] = (uint8_t)(bms & 0xFF); uint16_t t = (uint16_t)(tempo_x100 < 0 ? 0 : (tempo_x100 > 0xFFFF ? 0xFFFF : tempo_x100)); buf[off++] = (uint8_t)(t >> 8); buf[off++] = (uint8_t)(t & 0xFF); + uint16_t dr = (uint16_t)(dropped > 0xFFFF ? 0xFFFF : dropped); + buf[off++] = (uint8_t)(dr >> 8); buf[off++] = (uint8_t)(dr & 0xFF); + uint16_t un = (uint16_t)(underruns > 0xFFFF ? 0xFFFF : underruns); + buf[off++] = (uint8_t)(un >> 8); buf[off++] = (uint8_t)(un & 0xFF); chat_event_post(ctx->inst, CHAT_EVT_CALL_STATS, buf, (int)off); s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats"); diff --git a/src/call/call.h b/src/call/call.h index f6ad19c2..abf6c329 100644 --- a/src/call/call.h +++ b/src/call/call.h @@ -57,8 +57,10 @@ struct call_audio_ops { /* uasync-поток: принятый Opus-кадр пира (декод + jitter-буфер). */ void (*on_media)(struct UTUN_INSTANCE* inst, uint64_t call_id, uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len); - /* uasync-поток: текущие метрики джиттер-буфера (buffer_ms, tempo_x100). */ - int (*get_stats)(uint64_t call_id, int* buffer_ms, int* tempo_x100); + /* uasync-поток: текущие метрики джиттер-буфера (buffer_ms, tempo_x100, + * dropped/underruns — опционально, NULL допустим). */ + int (*get_stats)(uint64_t call_id, int* buffer_ms, int* tempo_x100, + uint32_t* dropped, uint32_t* underruns); }; void call_set_audio_ops(struct UTUN_INSTANCE* inst, const struct call_audio_ops* ops); diff --git a/src/call/call_audio.c b/src/call/call_audio.c index 60a20b98..ea15e62a 100644 --- a/src/call/call_audio.c +++ b/src/call/call_audio.c @@ -1,15 +1,15 @@ // call_audio.c — сервисный аудио-движок P2P-звонка (Opus + джиттер + тоны). // -// Один активный звонок: encoder (аудио-поток), decoder (uasync-поток), -// адаптивный джиттер-буфер ajb (SoundTouch) и генератор тонов. +// Один активный звонок: encoder (аудио-поток), decoder + адаптивный джиттер-буфер +// vj (кодированные кадры + SoundTouch stretch на pull) и генератор тонов. // -// RX: call.c → call_audio_on_media (uasync) → decode → ajb_push; -// аудио-поток call_audio_pull_pcm → ajb_pull (+ глитч/завершение). +// RX: call.c → call_audio_on_media (uasync) → vj_push (кодированный кадр); +// аудио-поток call_audio_pull_pcm → vj_pull (декод+stretch) (+ глитч/завершение). // TX: аудио-поток call_audio_feed_pcm → encode → uasync_post(call_send_media). #include "call_audio.h" #include "call.h" -#include "audio_jitter.h" +#include "voice_jitter.h" #include "call_tones.h" #include "../utun_instance.h" #include "../../lib/opus_codec.h" @@ -34,7 +34,7 @@ static uint64_t g_call_id = 0; static uint64_t g_last_media_tb = 0; static opus_codec_encoder_t* g_encoder = NULL; static opus_codec_decoder_t* g_decoder = NULL; -static struct ajb* g_ajb = NULL; +static struct vj* g_vj = NULL; static struct call_tones* g_tones = NULL; static const struct call_audio_ops g_ops = { @@ -42,6 +42,12 @@ static const struct call_audio_ops g_ops = { .get_stats = call_audio_get_stats, }; +/* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */ +static int call_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { + opus_codec_decoder_t* dec = (opus_codec_decoder_t*)arg; + return opus_codec_decode(dec, enc, len, pcm, max_samples); +} + int call_audio_init(struct UTUN_INSTANCE* inst) { if (!inst) return -1; if (g_inst == inst) return 0; @@ -58,21 +64,16 @@ void call_audio_destroy(struct UTUN_INSTANCE* inst) { DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: destroyed", CALL_AUDIO_ID); } -/* ── uasync-поток: приём медиа (декод + ajb_push) ── */ +/* ── uasync-поток: приём медиа (кладём кодированный кадр в jitter-буфер) ── */ void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id, uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len) { (void)inst; (void)seq; (void)ts_ms; - int16_t pcm[CALL_AUDIO_FRAME_SAMPLES]; - int n = 0; pthread_mutex_lock(&g_mtx); - if (g_active && call_id == g_call_id && g_decoder && opus && len > 0) { - n = opus_codec_decode(g_decoder, opus, len, pcm, CALL_AUDIO_FRAME_SAMPLES); - if (n > 0 && g_ajb) { - ajb_push(g_ajb, pcm, n); - g_last_media_tb = get_time_tb(); - } + if (g_active && call_id == g_call_id && g_vj && opus && len > 0) { + vj_push(g_vj, opus, len); + g_last_media_tb = get_time_tb(); } pthread_mutex_unlock(&g_mtx); } @@ -112,18 +113,19 @@ int call_audio_start(uint64_t call_id) { return -1; } - struct ajb* j = ajb_create(CALL_AUDIO_SAMPLE_RATE, 1, CALL_AUDIO_FRAME_SAMPLES, CALL_AUDIO_FRAME_MS, CALL_AJ_MAX_FRAMES); + struct vj* j = vj_create(CALL_AUDIO_SAMPLE_RATE, 1, CALL_AUDIO_FRAME_SAMPLES, CALL_AUDIO_FRAME_MS, + CALL_AJ_MAX_FRAMES, call_audio_decode_cb, dec); if (!j) { opus_codec_decoder_destroy(dec); opus_codec_encoder_destroy(enc); pthread_mutex_unlock(&g_mtx); - DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: ajb create failed", CALL_AUDIO_ID); + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: vj create failed", CALL_AUDIO_ID); return -1; } struct call_tones* tones = call_tones_create(); if (!tones) { - ajb_destroy(j); + vj_destroy(j); opus_codec_decoder_destroy(dec); opus_codec_encoder_destroy(enc); pthread_mutex_unlock(&g_mtx); @@ -133,7 +135,7 @@ int call_audio_start(uint64_t call_id) { g_encoder = enc; g_decoder = dec; - g_ajb = j; + g_vj = j; g_tones = tones; g_call_id = call_id; g_active = 1; @@ -158,7 +160,7 @@ void call_audio_begin_end(uint64_t call_id) { void call_audio_release(uint64_t call_id) { opus_codec_encoder_t* enc = NULL; opus_codec_decoder_t* dec = NULL; - struct ajb* j = NULL; + struct vj* j = NULL; struct call_tones* tones = NULL; int was_active = 0; @@ -172,7 +174,7 @@ void call_audio_release(uint64_t call_id) { g_last_media_tb = 0; enc = g_encoder; g_encoder = NULL; dec = g_decoder; g_decoder = NULL; - j = g_ajb; g_ajb = NULL; + j = g_vj; g_vj = NULL; tones = g_tones; g_tones = NULL; } pthread_mutex_unlock(&g_mtx); @@ -182,11 +184,19 @@ void call_audio_release(uint64_t call_id) { /* дальше on_media не тронет объекты (active=0, указатели NULL) */ if (enc) opus_codec_encoder_destroy(enc); if (dec) opus_codec_decoder_destroy(dec); - if (j) ajb_destroy(j); + if (j) vj_destroy(j); if (tones) call_tones_destroy(tones); DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: released call=0x%016llx", CALL_AUDIO_ID, (unsigned long long)call_id); } +void call_audio_stop(void) { + uint64_t cid = 0; + pthread_mutex_lock(&g_mtx); + if (g_active) cid = g_call_id; + pthread_mutex_unlock(&g_mtx); + if (cid) call_audio_release(cid); +} + /* ── аудио-поток: TX / RX ── */ int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) { @@ -223,7 +233,7 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { if (!out || max_samples <= 0) return 0; int active = 0, ending = 0; - struct ajb* j = NULL; + struct vj* j = NULL; struct call_tones* tones = NULL; uint64_t last_media = 0; @@ -231,7 +241,7 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { if (g_active && call_id == g_call_id) { active = 1; ending = g_ending; - j = g_ajb; + j = g_vj; tones = g_tones; last_media = g_last_media_tb; } @@ -252,7 +262,7 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { return 0; } - int n = ajb_pull(j, out, max_samples); + int n = vj_pull(j, out, max_samples); if (n >= max_samples) return n; /* недобор: либо сталь (глитч-тон), либо тишина */ @@ -271,11 +281,12 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { /* ── uasync-поток: метрики ── */ -int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100) { +int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100, + uint32_t* dropped, uint32_t* underruns) { int ok = 0; pthread_mutex_lock(&g_mtx); - if (g_active && call_id == g_call_id && g_ajb) { - ajb_get_stats(g_ajb, buffer_ms, tempo_x100); + if (g_active && call_id == g_call_id && g_vj) { + vj_get_stats(g_vj, buffer_ms, tempo_x100, dropped, underruns); ok = 1; } pthread_mutex_unlock(&g_mtx); diff --git a/src/call/call_audio.h b/src/call/call_audio.h index a941cb70..50a81e3c 100644 --- a/src/call/call_audio.h +++ b/src/call/call_audio.h @@ -1,19 +1,22 @@ #ifndef CALL_AUDIO_H #define CALL_AUDIO_H -// call_audio.h — сервисный аудио-движок P2P-звонка (GUI-сборки: desktop/Android). +// call_audio.h — сервисный аудио-движок P2P-звонка (единый для desktop/Android/headless). // -// Владеет Opus codec, адаптивным джиттер-буфером (SoundTouch time-stretch) и -// тонами (глитч при сталле / завершение). Потребитель (desktop miniaudio или -// Android AudioTrack) получает финальный PCM через call_audio_pull_pcm и отдаёт -// захваченный PCM через call_audio_feed_pcm. +// Владеет Opus codec, адаптивным джиттер-буфером (voice_jitter: кодированные +// кадры + SoundTouch time-stretch на pull) и тонами (глитч при сталле / завершение). +// Излишек голоса держится до декодирования — декодируется только то, что готово +// к воспроизведению. Потребитель (desktop miniaudio / Android AudioTrack) получает +// финальный PCM через call_audio_pull_pcm и отдаёт захваченный PCM через +// call_audio_feed_pcm. // // Потоки: // - call_audio_start / begin_end / release — GUI-поток (по ACCEPTED/ENDED); // - call_audio_feed_pcm / pull_pcm — аудио-поток (single-writer); // - call_audio_on_media / get_stats — uasync-поток (из call.c). -// Общие указатели (encoder/decoder/ajb/tones) защищены мьютексом; encoder — -// только аудио-поток, decoder+ajb_push — только uasync-поток. +// Общие указатели (encoder/decoder/vj/tones) защищены мьютексом; encoder и +// decoder — только аудио-поток (decoder дёргается из vj_pull через коллбэк), +// vj_push — только uasync-поток. #include @@ -40,6 +43,9 @@ void call_audio_begin_end(uint64_t call_id); /* GUI-поток (после остановки аудиоустройства): освободить все объекты. */ void call_audio_release(uint64_t call_id); +/* GUI-поток: остановить текущий активный звонок (удобная обёртка release). */ +void call_audio_stop(void); + /* аудио-поток: закодировать PCM-кадр (ровно CALL_AUDIO_FRAME_SAMPLES) и отправить пиру. */ int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count); @@ -47,12 +53,14 @@ int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count); * Возвращает число сэмплов (может быть < max — андерфлоу/конец тона). */ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples); -/* uasync-поток (из call.c): принятый Opus-кадр — декод → ajb_push. */ +/* uasync-поток (из call.c): принятый Opus-кадр — кладём в джиттер-буфер (vj_push, + * декодирование произойдёт на pull в аудио-потоке). */ void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id, uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len); -/* uasync-поток: метрики джиттер-буфера для CALL_STATS. */ -int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100); +/* uasync-поток: метрики джиттер-буфера для CALL_STATS (dropped/underruns — необязательные). */ +int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100, + uint32_t* dropped, uint32_t* underruns); #ifdef __cplusplus } diff --git a/src/call/call_headless.c b/src/call/call_headless.c index 9fe5a4d3..7ce02f4d 100644 --- a/src/call/call_headless.c +++ b/src/call/call_headless.c @@ -1,11 +1,15 @@ -// call_headless.c — headless-аудио-сокет для P2P-звонков +// call_headless.c — headless-аудио-сокет для P2P-звонков (PCM, без тактирования) // // TCP-сокет с бинарным фреймингом [type:1][call_id:8][len:2][data:len]. -// Клиент привязывается к звонку (HELLO) и шлёт/принимает Opus-кадры (FRAME). +// Клиент привязывается к звонку (HELLO) и обменивается ГОТОВЫМ PCM (int16 mono 48k): +// клиент → ядро: PCM-кадр (960 сэмплов) — кодируется в Opus и уходит пиру; +// ядро → клиент: Opus пира декодируется в PCM и отдаётся клиенту. +// Тактирование/джиттер — на стороне клиента (ядро лишь кодирует/декодирует). // Сигналинг — на control-сокете (chat_headless_control), медиа — здесь. #include "call_headless.h" #include "call.h" #include "../utun_instance.h" +#include "../../lib/opus_codec.h" #include "../../lib/u_async.h" #include "../../lib/mem.h" #include "../../lib/socket_compat.h" @@ -24,6 +28,11 @@ #define AUDIO_MAX_CLIENTS 16 #define AUDIO_MAX_FRAME 2048 +#define CA_SAMPLE_RATE 48000 +#define CA_FRAME_SAMPLES 960 /* 20мс @48k */ +#define CA_PCM_FRAME_BYTES (CA_FRAME_SAMPLES * (int)sizeof(int16_t)) /* 1920 */ +#define CA_BITRATE 32000 + struct call_audio_client { socket_t fd; void* socket_id; @@ -46,6 +55,16 @@ struct call_audio { socket_t listen_fd; void* listen_sock_id; int running; + /* Opus-кодеки (одна активная звонка за раз — busy в call.c) */ + uint64_t enc_call_id; + opus_codec_encoder_t* enc; + uint64_t dec_call_id; + opus_codec_decoder_t* dec; + /* диагностика */ + uint32_t c_enc_frames; + uint32_t c_dec_frames; + uint32_t c_enc_dropped; + uint32_t c_dec_dropped; }; static struct call_audio* ca_of(struct UTUN_INSTANCE* inst) { @@ -88,16 +107,57 @@ static void ca_write_frame(struct call_audio_client* cli, uint8_t type, uint64_t if (len) ca_send_raw(cli, data, len); } -/* ── приём медиа от пира → клиенту ── */ +/* ── Opus-кодеки (одна активная звонка за раз) ── */ + +static opus_codec_encoder_t* ca_encoder(struct call_audio* ca, uint64_t call_id) { + if (ca->enc && ca->enc_call_id == call_id) return ca->enc; + if (ca->enc) { opus_codec_encoder_destroy(ca->enc); ca->enc = NULL; } + ca->enc = opus_codec_encoder_create(CA_SAMPLE_RATE, 1); + if (ca->enc) { + opus_codec_encoder_bitrate_set(ca->enc, CA_BITRATE); + ca->enc_call_id = call_id; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: encoder created for call_id=%016llx", CH_ID, (unsigned long long)call_id); + } else { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: encoder create failed", CH_ID); + } + return ca->enc; +} + +static opus_codec_decoder_t* ca_decoder(struct call_audio* ca, uint64_t call_id) { + if (ca->dec && ca->dec_call_id == call_id) return ca->dec; + if (ca->dec) { opus_codec_decoder_destroy(ca->dec); ca->dec = NULL; } + ca->dec = opus_codec_decoder_create(CA_SAMPLE_RATE, 1); + if (ca->dec) { + ca->dec_call_id = call_id; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: decoder created for call_id=%016llx", CH_ID, (unsigned long long)call_id); + } else { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: decoder create failed", CH_ID); + } + return ca->dec; +} + +/* ── приём медиа от пира → PCM клиенту ── */ static void ca_media_recv(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len, void* arg) { struct call_audio* ca = (struct call_audio*)arg; (void)inst; - if (!ca) return; + if (!ca || !opus || len <= 0) return; + + opus_codec_decoder_t* dec = ca_decoder(ca, call_id); + if (!dec) return; + + int16_t pcm[CA_FRAME_SAMPLES]; + int n = opus_codec_decode(dec, opus, len, pcm, CA_FRAME_SAMPLES); + if (n <= 0) { + ca->c_dec_dropped++; + return; + } + ca->c_dec_frames++; + for (struct call_audio_client* c = ca->clients; c; c = c->next) { if (c->call_id == call_id && !c->closing) { - ca_write_frame(c, CALL_AUDIO_FRAME, call_id, opus, (uint16_t)len); + ca_write_frame(c, CALL_AUDIO_FRAME, call_id, (const uint8_t*)pcm, (uint16_t)(n * (int)sizeof(int16_t))); break; } } @@ -127,8 +187,25 @@ static void ca_parse_frames(struct call_audio_client* cli) { cli->call_id = call_id; DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio client bound to call_id=%016llx", CH_ID, (unsigned long long)call_id); } else if (type == CALL_AUDIO_FRAME) { - if (call_send_media(ca->inst, call_id, data, flen) != 0) - DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: send_media failed call_id=%016llx len=%u", CH_ID, (unsigned long long)call_id, flen); + if (flen != CA_PCM_FRAME_BYTES) { + ca->c_enc_dropped++; + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: bad PCM frame size=%u (expect %d) — dropped", + CH_ID, flen, CA_PCM_FRAME_BYTES); + } else { + opus_codec_encoder_t* enc = ca_encoder(ca, call_id); + uint8_t opus[256]; + int olen = enc ? opus_codec_encode(enc, (const int16_t*)data, CA_FRAME_SAMPLES, opus, (int)sizeof(opus)) : -1; + if (olen <= 0) { + ca->c_enc_dropped++; + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: encode failed (olen=%d) call_id=%016llx", + CH_ID, olen, (unsigned long long)call_id); + } else { + ca->c_enc_frames++; + if (call_send_media(ca->inst, call_id, opus, olen) != 0) + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: send_media failed call_id=%016llx len=%d", + CH_ID, (unsigned long long)call_id, olen); + } + } } else { DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: unknown frame type=0x%02x — closing", CH_ID, type); ca_close_client(cli); @@ -266,6 +343,8 @@ void call_headless_destroy(struct UTUN_INSTANCE* inst) { call_set_media_recv_cb(inst, NULL, NULL); if (ca->listen_sock_id && ca->ua) uasync_remove_socket_t(ca->ua, ca->listen_fd); if (ca->listen_fd != SOCKET_INVALID) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; } + if (ca->enc) { opus_codec_encoder_destroy(ca->enc); ca->enc = NULL; } + if (ca->dec) { opus_codec_decoder_destroy(ca->dec); ca->dec = NULL; } struct call_audio_client* c = ca->clients; while (c) { struct call_audio_client* n = c->next; @@ -274,5 +353,6 @@ void call_headless_destroy(struct UTUN_INSTANCE* inst) { c = n; } ca->clients = NULL; ca->client_count = 0; - DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket destroyed", CH_ID); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket destroyed (enc=%u dec=%u enc_drop=%u dec_drop=%u)", + CH_ID, ca->c_enc_frames, ca->c_dec_frames, ca->c_enc_dropped, ca->c_dec_dropped); } diff --git a/src/call/call_headless.h b/src/call/call_headless.h index 520f2a1b..7cd59139 100644 --- a/src/call/call_headless.h +++ b/src/call/call_headless.h @@ -1,13 +1,16 @@ -// call_headless.h — headless-аудио-сокет для P2P-звонков +// call_headless.h — headless-аудио-сокет для P2P-звонков (PCM, без тактирования) // // Отдельный TCP-сокет, через который headless-клиент (CLI/скрипт) обменивается -// Opus-кадрами с ядром звонка. Сигналинг идёт по control-сокету (chat_headless_control), -// аудио — по этому сокету. +// ГОТОВЫМ PCM с ядром звонка. Ядро кодирует PCM→Opus (TX) и декодирует Opus→PCM +// (RX); тактирование и джиттер — на стороне клиента. Сигналинг идёт по +// control-сокету (chat_headless_control), аудио — по этому сокету. // // Бинарный фрейм (обе стороны): // [type:1][call_id:8][len:2][data:len] // type = 0x01 HELLO (клиент → ядро: привязать соединение к call_id, data пусто) -// type = 0x02 FRAME (Opus-кадр: клиент → ядро = отправить пиру; ядро → клиент = кадр от пира) +// type = 0x02 FRAME (PCM int16 mono 48k, 960 сэмплов = 1920 байт) +// клиент → ядро = PCM захвата (кодируется и шлётся пиру); +// ядро → клиент = PCM воспроизведения (декодированный Opus пира) #ifndef CALL_HEADLESS_H #define CALL_HEADLESS_H @@ -19,6 +22,7 @@ struct UTUN_INSTANCE; #define CALL_AUDIO_HELLO 0x01 #define CALL_AUDIO_FRAME 0x02 #define CALL_AUDIO_HDR_SIZE 11 /* type(1) + call_id(8) + len(2) */ +#define CALL_AUDIO_PCM_FRAME_BYTES (960 * (int)sizeof(int16_t)) /* 1920 — PCM int16 mono 48k 20мс */ #ifdef __cplusplus extern "C" { diff --git a/src/call/voice_jitter.cpp b/src/call/voice_jitter.cpp new file mode 100644 index 00000000..91ad6bab --- /dev/null +++ b/src/call/voice_jitter.cpp @@ -0,0 +1,187 @@ +// voice_jitter.cpp — адаптивный джиттер-буфер голоса с time-stretch (SoundTouch). +// +// Кодированные кадры (Opus, до VJ_MAX_ENC байт) кладутся из uasync-потока в кольцо +// и вытаскиваются аудио-потоком: кадр декодируется (decode-коллбэк) и подаётся в +// SoundTouch с плавно меняющимся темпом: глубина < target → 1.0x, глубина растёт → +// ускорение до 1.6x (догон live). Декодируется только то, что готово к воспроизведению. +// Кольцо защищено мьютексом (SPSC); decode и SoundTouch трогает только аудио-поток. + +#include "voice_jitter.h" + +#include "soundtouch/SoundTouch.h" + +#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 (без кликов) */ + +struct vj { + int sr = 0; + int ch = 0; + int frame_samples = 0; + int frame_ms = 0; + int cap = 0; + + vj_decode_fn decode = nullptr; + void* decode_arg = nullptr; + + std::mutex mtx; + std::vector ring; /* cap * VJ_MAX_ENC — кодированные кадры */ + std::vector lens; /* cap — длины кадров */ + int head = 0; + int count = 0; + uint32_t dropped = 0; + uint32_t underruns = 0; + + soundtouch::SoundTouch st; /* только аудио-поток */ + uint8_t enc_buf[VJ_MAX_ENC]; /* scratch: вытащенный кодированный кадр */ + std::vector pcm_buf; /* scratch: декодированный кадр */ + std::vector fin; /* scratch: кадр в float */ + std::vector fout; /* scratch: выход float */ + + 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); + if (x > 1.0) x = 1.0; + return kMinTempo + (kMaxTempo - kMinTempo) * x; +} + +/* Вытащить один кодированный кадр из кольца (под мьютексом). Возвращает 0 если пусто. */ +static int vj_pop_enc(struct vj* j, uint8_t* out, int* len_out) { + std::lock_guard lk(j->mtx); + if (j->count == 0) return 0; + *len_out = j->lens[(size_t)j->head]; + std::memcpy(out, &j->ring[(size_t)j->head * VJ_MAX_ENC], (size_t)*len_out); + j->head = (j->head + 1) % j->cap; + j->count--; + return 1; +} + +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) + 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->st.setSetting(SETTING_USE_QUICKSEEK, 1); + j->st.setSetting(SETTING_SEQUENCE_MS, 20); + j->st.setSetting(SETTING_SEEKWINDOW_MS, 10); + j->st.setSetting(SETTING_OVERLAP_MS, 4); + j->st.setSetting(SETTING_USE_AA_FILTER, 0); + return j; +} + +void vj_destroy(struct vj* j) { + if (!j) return; + delete j; +} + +void vj_push(struct vj* j, const uint8_t* enc, int len) { + if (!j || !enc || len <= 0 || len > VJ_MAX_ENC) return; + std::lock_guard lk(j->mtx); + if (j->count == j->cap) { + j->head = (j->head + 1) % j->cap; /* дроп старейшего */ + j->count--; + j->dropped++; + } + int idx = (j->head + j->count) % j->cap; + std::memcpy(&j->ring[(size_t)idx * VJ_MAX_ENC], enc, (size_t)len); + j->lens[(size_t)idx] = len; + j->count++; +} + +int vj_pull(struct vj* j, int16_t* out, int max_samples) { + if (!j || !out || max_samples <= 0) return 0; + + /* 1) глубина (сглаженная) → целевой темп (плавно) */ + int cnt; + { + std::lock_guard lk(j->mtx); + cnt = j->count; + } + double depth_ms = (double)cnt * j->frame_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 d = target - j->tempo; + if (d > kTempoStep) d = kTempoStep; + else if (d < -kTempoStep) d = -kTempoStep; + j->tempo += d; + j->st.setTempo(j->tempo); + j->tempo_x100.store((int)std::lround(j->tempo * 100.0)); + + /* 2) декодируем и кормим SoundTouch, пока не сможет выдать max_samples (или кольцо пусто) */ + int guard = 0; + while (j->st.numSamples() < (uint)max_samples && 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++) + 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++) { + 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; +} + +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; + } + if (tempo_x100) *tempo_x100 = j->tempo_x100.load(); + if (dropped) *dropped = j->dropped; + if (underruns) *underruns = j->underruns; +} + +uint32_t vj_dropped(struct vj* j) { + if (!j) return 0; + std::lock_guard lk(j->mtx); + return j->dropped; +} + +} // extern "C" diff --git a/src/call/voice_jitter.h b/src/call/voice_jitter.h new file mode 100644 index 00000000..db9b6529 --- /dev/null +++ b/src/call/voice_jitter.h @@ -0,0 +1,54 @@ +#ifndef VOICE_JITTER_H +#define VOICE_JITTER_H + +// voice_jitter.h — адаптивный джиттер-буфер голоса с time-stretch (SoundTouch), C API. +// +// Хранит КОДИРОВАННЫЕ (Opus) кадры: uasync-поток кладёт кодированные кадры, +// аудио-поток на pull декодирует их (через коллбэк decode) и пропускает через +// SoundTouch с плавно меняющимся темпом. Излишек голоса держится до декодирования — +// декодируется только то, что готово к воспроизведению (экономия памяти ~48x и +// разгрузка uasync-потока). +// +// Потоки: +// - vj_push — uasync-поток (producer); +// - vj_pull — аудио-поток (consumer; единственный владелец decode/SoundTouch); +// - vj_get_stats — любой поток. +// Кольцо кодированных кадров защищено мьютексом (SPSC, дёшево: ~50 кадров/с). + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +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* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms, + int max_frames, vj_decode_fn decode, void* decode_arg); + +void vj_destroy(struct vj* j); + +/* producer: положить кодированный кадр (копируется). При переполнении дропает старейший. */ +void vj_push(struct vj* j, const uint8_t* enc, int len); + +/* consumer: выдать до max_samples декодированного+растянутого PCM. Может вернуть + * < max_samples при нехватке данных (андерфлоу). */ +int vj_pull(struct vj* j, int16_t* out, int max_samples); + +/* Диагностика: глубина буфера (мс), темп (×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); + +/* Число дропнутых кадров при переполнении (для диагностики/тестов). */ +uint32_t vj_dropped(struct vj* j); + +#ifdef __cplusplus +} +#endif + +#endif /* VOICE_JITTER_H */ diff --git a/src/call/voice_sources.cmake b/src/call/voice_sources.cmake new file mode 100644 index 00000000..d844d2f7 --- /dev/null +++ b/src/call/voice_sources.cmake @@ -0,0 +1,52 @@ +# voice_sources.cmake — голосовой стек звонка (единый для desktop/Android/headless). +# +# SoundTouch (time-stretch) + voice_jitter (кодированные кадры + decode-on-pull) +# + call_audio (Opus/тоны) + call_tones. C++ внутри, наружу — C-интерфейс +# (call_audio.h). Экспортирует UTUN_VOICE_SOURCES и UTUN_VOICE_INCLUDE_DIRS. +# +# Использование: +# set(LIB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../lib) +# set(CALL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../src/call) +# include(${CALL_DIR}/voice_sources.cmake) +# utun_setup_voice("${LIB_DIR}" "${CALL_DIR}") +# add_library(utun_voice STATIC ${UTUN_VOICE_SOURCES}) +# target_include_directories(utun_voice PUBLIC ${UTUN_VOICE_INCLUDE_DIRS}) +# target_link_libraries(utun_voice PUBLIC opus) # opus_codec + +function(utun_setup_voice LIB_DIR CALL_DIR) + set(_st_dir "${LIB_DIR}/soundtouch-master") + set(_src_dir "${CALL_DIR}/..") + set(UTUN_VOICE_SOURCES + ${CALL_DIR}/call_audio.c + ${CALL_DIR}/call_tones.c + ${CALL_DIR}/voice_jitter.cpp + ${_st_dir}/source/SoundTouch/SoundTouch.cpp + ${_st_dir}/source/SoundTouch/TDStretch.cpp + ${_st_dir}/source/SoundTouch/RateTransposer.cpp + ${_st_dir}/source/SoundTouch/FIFOSampleBuffer.cpp + ${_st_dir}/source/SoundTouch/AAFilter.cpp + ${_st_dir}/source/SoundTouch/FIRFilter.cpp + ${_st_dir}/source/SoundTouch/InterpolateLinear.cpp + ${_st_dir}/source/SoundTouch/InterpolateCubic.cpp + ${_st_dir}/source/SoundTouch/InterpolateShannon.cpp + ${_st_dir}/source/SoundTouch/cpu_detect_x86.cpp + ${_st_dir}/source/SoundTouch/mmx_optimized.cpp + ${_st_dir}/source/SoundTouch/sse_optimized.cpp + PARENT_SCOPE + ) + set(UTUN_VOICE_INCLUDE_DIRS + ${CALL_DIR} + ${_src_dir} + ${_src_dir}/transport_layer + ${_src_dir}/routing_layer + ${_src_dir}/chat + ${_src_dir}/proxy + ${_src_dir}/media_delivery + ${_src_dir}/media_async + ${LIB_DIR} + ${LIB_DIR}/libopus/include + ${_st_dir}/include + ${_st_dir}/include/soundtouch + PARENT_SCOPE + ) +endfunction() diff --git a/tests/Makefile.am b/tests/Makefile.am index 59134a73..86afeee7 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -115,7 +115,7 @@ endif CRYPTO_LIBS = -lcrypto # Common libraries (lib/dns.c собирается с -DDNS_RANDOM=RAND_bytes → нужен -lcrypto) -COMMON_LIBS = $(top_builddir)/lib/libuasync.a $(CRYPTO_LIBS) -lpthread $(WIN_LIBS) @FFMPEG_LIBS@ +COMMON_LIBS = $(top_builddir)/lib/libuasync.a $(CRYPTO_LIBS) -lpthread $(WIN_LIBS) @FFMPEG_LIBS@ $(top_builddir)/lib/libopus/libopus_internal.a -lm # Test definitions test_etcp_bbr_SOURCES = test_etcp_bbr.c diff --git a/tests/test_call_headless.c b/tests/test_call_headless.c index 903bf314..8ed24e58 100644 --- a/tests/test_call_headless.c +++ b/tests/test_call_headless.c @@ -380,18 +380,19 @@ static void hs_tick(void* arg) { break; } case P_SEND_MEDIA: { - static const uint8_t opus[16] = { 0xf8,0xff,0xfe,1,2,3,4,5,6,7,8,9,0x0a,0x0b,0x0c,0x0d }; - uint8_t frm[CALL_AUDIO_HDR_SIZE + sizeof(opus)]; + /* A: отправляем PCM-кадр (тишина) — ядро кодирует в Opus и шлёт пиру */ + uint8_t frm[CALL_AUDIO_HDR_SIZE + CALL_AUDIO_PCM_FRAME_BYTES]; frm[0] = CALL_AUDIO_FRAME; memcpy(frm + 1, &t->call_id, 8); - frm[9] = 0; frm[10] = (uint8_t)sizeof(opus); - memcpy(frm + CALL_AUDIO_HDR_SIZE, opus, sizeof(opus)); + frm[9] = (uint8_t)(CALL_AUDIO_PCM_FRAME_BYTES >> 8); + frm[10] = (uint8_t)(CALL_AUDIO_PCM_FRAME_BYTES & 0xFF); + memset(frm + CALL_AUDIO_HDR_SIZE, 0, CALL_AUDIO_PCM_FRAME_BYTES); hs_send(&t->aud[IDX_A], frm, sizeof(frm)); t->phase = P_WAIT_MEDIA; break; } case P_WAIT_MEDIA: { - /* ищем FRAME от пира в буфере audio-клиента B: type=0x02 + call_id */ + /* ищем PCM-фрейм от пира в буфере audio-клиента B: type=0x02 + call_id */ const char* buf = t->aud[IDX_B].buf; int len = t->aud[IDX_B].len; if (len >= CALL_AUDIO_HDR_SIZE) { @@ -399,7 +400,7 @@ static void hs_tick(void* arg) { if ((uint8_t)buf[i] != CALL_AUDIO_FRAME) continue; uint64_t cid; memcpy(&cid, buf + i + 1, 8); uint16_t flen = (uint16_t)(((uint8_t)buf[i+9] << 8) | (uint8_t)buf[i+10]); - if (cid == t->call_id && flen == 16 && i + CALL_AUDIO_HDR_SIZE + flen <= len) { + if (cid == t->call_id && flen == CALL_AUDIO_PCM_FRAME_BYTES && i + CALL_AUDIO_HDR_SIZE + flen <= len) { t->phase = P_HANGUP; break; } diff --git a/tools/chatgui-android/AGENTS.md b/tools/chatgui-android/AGENTS.md index 4a3355ae..6c832074 100644 --- a/tools/chatgui-android/AGENTS.md +++ b/tools/chatgui-android/AGENTS.md @@ -252,13 +252,13 @@ log_udp_port = 9999 - `libutun_lite/invite_link_c.h/c` — Кодирование и декодирование invite-ссылок `utun://` в бинарный формат. Совместим с десктопной версией - `libutun_lite/utun_config_api.h/c` — Поставщик конфигурации из Kotlin в C-ядро через callback-интерфейс (get_string, get_int64, get_int) - `libutun_lite/voice_recorder.h/c` — Запись голосовых сообщений: накопление PCM-сэмплов с компрессором, кодирование в Opus-файл, отправка в канал через chat_core -- `libutun_lite/call_audio.h/c` — Аудио-движок P2P-звонка: Opus encode (PCM→peer через call_send_media) и decode (peer→PCM→коллбэк). Аудио I/O в Kotlin (AudioRecord/AudioTrack), кодеки в C +- `src/call/call_audio.h/c` (+ `voice_jitter.cpp`, `call_tones.c`, SoundTouch) — единый голосовой стек звонка (`libutun_voice`), собирается через `src/call/voice_sources.cmake`; Opus encode (PCM→peer) и decode + адаптивный джиттер-буфер + time-stretch (PCM отдаётся через `nativeCallAudioPull`). Аудио I/O в Kotlin (AudioRecord/AudioTrack) - `libutun_lite/attachment_sender.h/c` — Отправка файлов в канал: копирование в media-директорию и регистрация через chat_core (media_index → db_sync) - `libutun_lite/utun_sources.cmake` — Список всех .c файлов, компилируемых в libutun_lite. Новые файлы добавлять сюда ### Kotlin-слой - `data/NativeLib.kt` — Kotlin-обёртка над C-библиотекой: все вызовы из Kotlin транслируются в JNI-функции -- `data/CallAudioEngine.kt` — Аудио-движок звонка: AudioRecord/AudioTrack, jitter-буфер, audio-focus, потоки захвата/воспроизведения +- `data/CallAudioEngine.kt` — Аудио-движок звонка: AudioRecord/AudioTrack, audio-focus, потоки захвата (feed) / воспроизведения (pull из C-стека) - `data/ChatRepository.kt` — Хранилище данных: буферизация сообщений и каналов между C-ядром и UI через StateFlow - `data/InviteLink.kt` — Разбор invite-ссылок: извлекает ID канала, публичный ключ, адреса для подключения - `data/ConfigProvider.kt` — Поставщик конфигурации из Android DataStore в C-ядро через callback-интерфейс diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index 386540b5..64d55fcf 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { externalNativeBuild { cmake { - arguments("-DANDROID_STL=none") + arguments("-DANDROID_STL=c++_static") } } ndk { diff --git a/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt b/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt index 3a1139c5..ae2385d9 100644 --- a/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt +++ b/tools/chatgui-android/app/src/main/cpp/CMakeLists.txt @@ -28,6 +28,20 @@ list(APPEND UTUN_INCLUDE_DIRS "${LIB_DIR}/libopus" "${LIB_DIR}/libopus/include" # Opus requires these to be defined (or HAVE_CONFIG_H + config.h with VAR_ARRAYS) list(APPEND UTUN_DEFINES OPUS_BUILD VAR_ARRAYS USE_ALLOCA) +# Голосовой стек звонка (SoundTouch + voice_jitter + call_audio + call_tones), +# C++ внутри, C-интерфейс наружу (call_audio.h). +set(CALL_DIR ${SRC_DIR}/call) +include(${CALL_DIR}/voice_sources.cmake) +utun_setup_voice("${LIB_DIR}" "${CALL_DIR}") +add_library(utun_voice STATIC ${UTUN_VOICE_SOURCES}) +target_include_directories(utun_voice PUBLIC ${UTUN_VOICE_INCLUDE_DIRS}) +set_source_files_properties(${CALL_DIR}/call_audio.c ${CALL_DIR}/call_tones.c PROPERTIES LANGUAGE C) +set_target_properties(utun_voice PROPERTIES + C_STANDARD 99 C_STANDARD_REQUIRED ON + CXX_STANDARD 11 CXX_STANDARD_REQUIRED ON + POSITION_INDEPENDENT_CODE ON +) + # Prebuilt OpenSSL (cross-compiled for Android) set(OPENSSL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/openssl/${ANDROID_ABI}) add_library(openssl_crypto STATIC IMPORTED) @@ -57,4 +71,4 @@ target_compile_definitions(utun PRIVATE ${UTUN_DEFINES} __ANDROID__) set_target_properties(utun PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON) find_library(log-lib log) -target_link_libraries(utun ${log-lib} openssl_crypto) +target_link_libraries(utun ${log-lib} openssl_crypto utun_voice) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt index eb95603f..372d4c06 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt @@ -15,11 +15,11 @@ import com.utun.chat.ChatApplication * * Потоки: * - capture-поток: AudioRecord.read(960) → NativeLib.callAudioFeed (C: Opus encode → peer) - * - play-поток: jitter-буфер → AudioTrack.write (кадры декодированы в C, приходят через onCallPcm) - * - uasync-поток: onCallPcm(callId, PCM) ← C (call_audio media_recv) + * - play-поток: NativeLib.callAudioPull (C: джиттер-буфер → декод → SoundTouch) → AudioTrack.write * - * Jitter-буфер: очередь PCM-кадров (~50 кадров = 1с), pre-buffer 3 кадра (60мс), - * при опустошении — тишина (комфортный шум) + счётчик gap для диагностики. + * Воспроизведение тянет PCM из единого голосового стека в C (call_audio): адаптивный + * джиттер-буфер (кодированные кадры) + time-stretch держат задержку ~60мс и догоняют + * после сетевых бёрстов. На Kotlin-стороне своего джиттера больше нет. */ class CallAudioEngine { @@ -27,8 +27,6 @@ class CallAudioEngine { const val SAMPLE_RATE = 48000 const val FRAME_MS = 20 const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 - const val JITTER_TARGET_FRAMES = 3 // pre-buffer 60мс - const val JITTER_MAX_FRAMES = 50 // ~1с } private var audioRecord: AudioRecord? = null @@ -41,16 +39,11 @@ class CallAudioEngine { /** Маршрутизация аудио-выхода (динамик/громкая/гарнитура). */ val router = CallAudioRouter() - private val jitter = ArrayDeque() - private val lock = Object() - @Volatile private var active = false @Volatile private var callId = 0L @Volatile private var muted = false - @Volatile private var callbackRegistered = false - fun isActive(): Boolean = active /** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */ @@ -66,13 +59,6 @@ class CallAudioEngine { fun start(id: Long): Boolean { stop() - if (!callbackRegistered) { - NativeLib.setCallAudioCallback(object : CallAudioCallback { - override fun onCallPcm(callId: Long, samples: ShortArray) = onPcm(callId, samples) - }) - callbackRegistered = true - } - if (!NativeLib.callAudioStart(id)) { LogManager.addLog("ERROR", "CallAudio", "native callAudioStart failed id=$id") return false @@ -132,8 +118,6 @@ class CallAudioEngine { NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false } - synchronized(lock) { jitter.clear() } - /* Прямое роутирование трека на выбранное устройство вывода (обход глючной глобальной маршрутизации на ряде устройств). */ router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } @@ -166,8 +150,6 @@ class CallAudioEngine { audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() } audioTrack = null - synchronized(lock) { jitter.clear() } - NativeLib.callAudioStop() router.onOutputDevice = null router.stop() @@ -178,15 +160,6 @@ class CallAudioEngine { LogManager.addLog("INFO", "CallAudio", "stopped") } - /** uasync-поток: принятый и декодированный в C PCM-кадр пира. */ - private fun onPcm(cid: Long, samples: ShortArray) { - if (!active || cid != callId) return - synchronized(lock) { - if (jitter.size >= JITTER_MAX_FRAMES) jitter.removeFirst() // дроп старейшего - jitter.addLast(samples) - } - } - private fun captureLoop() { val buf = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES) @@ -207,30 +180,14 @@ class CallAudioEngine { } private fun playLoop() { + val buf = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES) - var gaps = 0 - - // pre-buffer: ждём накопления jitter (до 250мс), чтобы сгладить стартовую задержку - val deadline = System.currentTimeMillis() + 250 - while (active && jitter.size < JITTER_TARGET_FRAMES && System.currentTimeMillis() < deadline) { - try { Thread.sleep(10) } catch (_: InterruptedException) { return } - } - while (active) { val t0 = System.nanoTime() - val frame = synchronized(lock) { if (jitter.isNotEmpty()) jitter.removeFirst() else null } val track = audioTrack ?: return - if (frame != null) { - track.write(frame, 0, frame.size) - if (gaps > 0) { - LogManager.addLog("DEBUG", "CallAudio", "jitter recovered after $gaps gaps") - gaps = 0 - } - } else { - track.write(silence, 0, silence.size) - gaps++ - if (gaps == 1) LogManager.addLog("WARN", "CallAudio", "playback underrun (jitter empty)") - } + val n = NativeLib.callAudioPull(callId, buf) + if (n > 0) track.write(buf, 0, n) + if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n) val elapsedMs = (System.nanoTime() - t0) / 1_000_000 val sleepMs = FRAME_MS - elapsedMs if (sleepMs > 0) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index ac135062..1c2f6b79 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -6,11 +6,6 @@ interface ChatEventCallback { fun onEvent(type: Int, data: ByteArray?) } -/** Приём декодированного PCM-кадра пира (вызывается из uasync-потока). */ -interface CallAudioCallback { - fun onCallPcm(callId: Long, samples: ShortArray) -} - object NativeLib { init { @@ -43,12 +38,6 @@ object NativeLib { }) } - fun setCallAudioCallback(cb: CallAudioCallback) { - nativeSetCallAudioCallback(object : CallAudioCallback { - override fun onCallPcm(callId: Long, samples: ShortArray) { cb.onCallPcm(callId, samples) } - }) - } - fun destroy() { nativeDestroy() } fun restart(dbPath: String, controlPort: Int, configText: String): Boolean { @@ -160,6 +149,7 @@ object NativeLib { /* ── P2P аудио-звонок (медиа) ── */ fun callAudioStart(callId: Long): Boolean = nativeCallAudioStart(callId) fun callAudioFeed(callId: Long, samples: ShortArray): Boolean = nativeCallAudioFeed(callId, samples) + fun callAudioPull(callId: Long, out: ShortArray): Int = nativeCallAudioPull(callId, out) fun callAudioStop() { nativeCallAudioStop() } /* ── DM (личные сообщения) ── */ @@ -247,7 +237,6 @@ object NativeLib { private external fun nativeVoiceDecodeClose(handle: Long) /* ── P2P аудио-звонок JNI ── */ - private external fun nativeSetCallAudioCallback(cb: Any) private external fun nativeCallStart(channelId: String, peerNodeId: Long, callId: Long) private external fun nativeCallAccept(callId: Long) private external fun nativeCallDecline(callId: Long) @@ -255,6 +244,7 @@ object NativeLib { private external fun nativeCallIsOnline(channelId: String, peerNodeId: Long): Boolean private external fun nativeCallAudioStart(callId: Long): Boolean private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray): Boolean + private external fun nativeCallAudioPull(callId: Long, out: ShortArray): Int private external fun nativeCallAudioStop() /* ── DM JNI ── */ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 3f698422..8457d0cd 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -16,7 +16,7 @@ #include "../libutun_lite/video_sender.h" #include "../libutun_lite/photo_sender.h" #include "../libutun_lite/standby.h" -#include "../libutun_lite/call_audio.h" +#include "../../../src/call/call_audio.h" #include "../../../src/call/call.h" #include "../../../src/dm/dm_core.h" #include "../../../lib/debug_config.h" @@ -1443,6 +1443,10 @@ void utun_bridge_call_audio_stop(void) { call_audio_stop(); } +int utun_bridge_call_audio_pull(uint64_t call_id, int16_t* out, int max_samples) { + return call_audio_pull_pcm(call_id, out, max_samples); +} + /* ────────────────────────────────────────────────────────────────── * JNI functions (compiled only for Android via NDK) * ────────────────────────────────────────────────────────────────── */ @@ -1454,8 +1458,6 @@ static jobject g_config_callback = NULL; static jobject g_log_callback = NULL; static jobject g_event_callback = NULL; static jmethodID g_event_mid = NULL; -static jobject g_call_audio_callback = NULL; -static jmethodID g_call_audio_mid = NULL; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { (void)reserved; @@ -1521,28 +1523,7 @@ void instance_event_handler(int type, const uint8_t* data, int len) { jni_on_event(type, data, len); } -/* ── JNI call-audio PCM callback → CallAudioEngine.onCallPcm() ── - * Вызывается из uasync-потока (call.c media_recv → call_audio_media_recv). */ -static void jni_call_audio_pcm(uint64_t call_id, const int16_t* pcm, int count, void* arg) { - (void)arg; - if (!g_jvm || !g_call_audio_callback || !g_call_audio_mid || !pcm || count <= 0) return; - JNIEnv* env = NULL; - int attached = 0; - jint res = (*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6); - if (res == JNI_EDETACHED) { - if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) return; - attached = 1; - } else if (res != JNI_OK) { - return; - } - jshortArray jpcm = (*env)->NewShortArray(env, count); - if (jpcm) { - (*env)->SetShortArrayRegion(env, jpcm, 0, count, (const jshort*)pcm); - (*env)->CallVoidMethod(env, g_call_audio_callback, g_call_audio_mid, (jlong)call_id, jpcm); - (*env)->DeleteLocalRef(env, jpcm); - } - if (attached) (*g_jvm)->DetachCurrentThread(g_jvm); -} +/* ── JNI call-audio: PCM отдаётся через nativeCallAudioPull (см. ниже) ── */ static jstring jni_get_string_callback(JNIEnv* env, const char* key) { if (!g_config_callback) return NULL; @@ -1593,7 +1574,6 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStart( /* Set up event forwarder (bridge config/log are set by nativeInit) */ instance_lite_set_event_handler(instance_event_handler); - call_audio_set_pcm_recv_cb(jni_call_audio_pcm, NULL); int rc = utun_bridge_start(cfg); (*env)->ReleaseStringUTFChars(env, configText, cfg); @@ -1728,8 +1708,6 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDestroy( if (g_config_callback) { (*env)->DeleteGlobalRef(env, g_config_callback); g_config_callback = NULL; } if (g_log_callback) { (*env)->DeleteGlobalRef(env, g_log_callback); g_log_callback = NULL; } if (g_event_callback) { (*env)->DeleteGlobalRef(env, g_event_callback); g_event_callback = NULL; } - if (g_call_audio_callback) { (*env)->DeleteGlobalRef(env, g_call_audio_callback); g_call_audio_callback = NULL; } - g_call_audio_mid = NULL; } JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSendMessage( @@ -2298,20 +2276,18 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioStop( utun_bridge_call_audio_stop(); } -/* ── nativeSetCallAudioCallback(cb) ── */ - -JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetCallAudioCallback( - JNIEnv* env, jobject thiz, jobject callback) { +/* ── nativeCallAudioPull(callId, out) → возвращает число сэмплов ── + * out — предвыделенный ShortArray (960), заполняется из аудио-потока Kotlin. */ +JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioPull( + JNIEnv* env, jobject thiz, jlong callId, jshortArray out) { (void)thiz; - if (g_call_audio_callback) (*env)->DeleteGlobalRef(env, g_call_audio_callback); - g_call_audio_callback = callback ? (*env)->NewGlobalRef(env, callback) : NULL; - if (g_call_audio_callback) { - jclass cls = (*env)->GetObjectClass(env, g_call_audio_callback); - g_call_audio_mid = (*env)->GetMethodID(env, cls, "onCallPcm", "(J[S)V"); - if (!g_call_audio_mid) bridge_log(BLEV_ERROR, "onCallPcm method not found on callback"); - } else { - g_call_audio_mid = NULL; - } + if (!out) return 0; + jsize len = (*env)->GetArrayLength(env, out); + jshort* data = (*env)->GetShortArrayElements(env, out, NULL); + if (!data) return 0; + int n = utun_bridge_call_audio_pull((uint64_t)callId, (int16_t*)data, (int)len); + (*env)->ReleaseShortArrayElements(env, out, data, 0); + return (jint)n; } #endif /* __ANDROID__ */ diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 6763180b..c20aa45f 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -126,10 +126,11 @@ void utun_bridge_call_decline(uint64_t call_id); void utun_bridge_call_hangup(uint64_t call_id); int utun_bridge_call_is_online(const char* ch_id, uint64_t peer_node_id); -/* Аудио-движок звонка (call_audio.c): PCM-кадры из AudioRecord → Opus → peer; - * принятый Opus → PCM → коллбэк (настраивается через call_audio_set_pcm_recv_cb). */ +/* Аудио-движок звонка (call_audio.c, единый голосовой стек): PCM из AudioRecord → + * Opus → peer (feed); принятый Opus → джиттер+декод+stretch → PCM (pull). */ int utun_bridge_call_audio_start(uint64_t call_id); int utun_bridge_call_audio_feed(uint64_t call_id, const int16_t* pcm, int count); +int utun_bridge_call_audio_pull(uint64_t call_id, int16_t* out, int max_samples); void utun_bridge_call_audio_stop(void); /* ── Invite link (for sharing) — асинхронно через uasync-поток. diff --git a/tools/chatgui-android/libutun_lite/call_audio.c b/tools/chatgui-android/libutun_lite/call_audio.c deleted file mode 100644 index 206981c3..00000000 --- a/tools/chatgui-android/libutun_lite/call_audio.c +++ /dev/null @@ -1,156 +0,0 @@ -#include "call_audio.h" -#include "instance_lite.h" -#include "../../../lib/opus_codec.h" -#include "../../../lib/debug_config.h" -#include "../../../lib/mem.h" -#include "../../../lib/u_async.h" -#include "../../../src/call/call.h" -#include -#include - -#define CALL_AUDIO_BITRATE 32000 - -static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; -static int g_active = 0; -static uint64_t g_call_id = 0; -static opus_codec_encoder_t* g_encoder = NULL; -static opus_codec_decoder_t* g_decoder = NULL; -static call_audio_pcm_recv_fn g_pcm_fn = NULL; -static void* g_pcm_arg = NULL; - -void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg) { - pthread_mutex_lock(&g_mtx); - g_pcm_fn = fn; - g_pcm_arg = arg; - pthread_mutex_unlock(&g_mtx); -} - -/* uasync-поток: принятый Opus-кадр пира (из call.c media_recv). */ -static void call_audio_media_recv(struct UTUN_INSTANCE* inst, uint64_t call_id, - const uint8_t* opus, int len, void* arg) { - (void)inst; (void)arg; - int16_t pcm[CALL_AUDIO_FRAME_SAMPLES]; - call_audio_pcm_recv_fn fn = NULL; - void* fn_arg = NULL; - int n = 0; - - pthread_mutex_lock(&g_mtx); - if (g_active && call_id == g_call_id && g_decoder && opus && len > 0) { - n = opus_codec_decode(g_decoder, opus, len, pcm, CALL_AUDIO_FRAME_SAMPLES); - fn = g_pcm_fn; - fn_arg = g_pcm_arg; - } - pthread_mutex_unlock(&g_mtx); - - if (n > 0 && fn) - fn(call_id, pcm, n, fn_arg); -} - -int call_audio_start(uint64_t call_id) { - if (call_id == 0) { - DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: bad call_id=0"); - return -1; - } - - pthread_mutex_lock(&g_mtx); - if (g_active && g_call_id == call_id) { - pthread_mutex_unlock(&g_mtx); - return 0; /* уже запущен для этого звонка */ - } - if (g_active) { - /* другой звонок активен — сбрасываем его */ - pthread_mutex_unlock(&g_mtx); - call_audio_stop(); - pthread_mutex_lock(&g_mtx); - } - - opus_codec_encoder_t* enc = opus_codec_encoder_create(CALL_AUDIO_SAMPLE_RATE, 1); - if (!enc) { - pthread_mutex_unlock(&g_mtx); - DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: encoder create failed"); - return -1; - } - opus_codec_encoder_bitrate_set(enc, CALL_AUDIO_BITRATE); - - opus_codec_decoder_t* dec = opus_codec_decoder_create(CALL_AUDIO_SAMPLE_RATE, 1); - if (!dec) { - opus_codec_encoder_destroy(enc); - pthread_mutex_unlock(&g_mtx); - DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: decoder create failed"); - return -1; - } - - g_encoder = enc; - g_decoder = dec; - g_call_id = call_id; - g_active = 1; - pthread_mutex_unlock(&g_mtx); - - call_set_media_recv_cb(instance_lite_get_instance(), call_audio_media_recv, NULL); - DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_start: ok call=0x%016llx", - (unsigned long long)call_id); - return 0; -} - -int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) { - if (!pcm || count != CALL_AUDIO_FRAME_SAMPLES) - return -1; - - uint8_t opus[256]; - struct UASYNC* ua = NULL; - struct UTUN_INSTANCE* inst = NULL; - int len = -1; - - pthread_mutex_lock(&g_mtx); - if (!g_active || call_id != g_call_id || !g_encoder) { - pthread_mutex_unlock(&g_mtx); - return -1; - } - len = opus_codec_encode(g_encoder, pcm, count, opus, (int)sizeof(opus)); - ua = instance_lite_get_uasync(); - inst = instance_lite_get_instance(); - pthread_mutex_unlock(&g_mtx); - - if (len <= 0 || !ua || !uasync_is_running(ua) || !inst) - return -1; - - struct call_media_arg* a = (struct call_media_arg*)u_malloc(sizeof(*a)); - if (!a) return -1; - a->inst = inst; - a->call_id = call_id; - a->len = (uint16_t)len; - memcpy(a->opus, opus, (size_t)len); - uasync_post(ua, call_send_media_trampoline, a); - return 0; -} - -void call_audio_stop(void) { - opus_codec_encoder_t* enc = NULL; - opus_codec_decoder_t* dec = NULL; - int was_active = 0; - - pthread_mutex_lock(&g_mtx); - if (g_active) { - was_active = 1; - g_active = 0; - g_call_id = 0; - enc = g_encoder; g_encoder = NULL; - dec = g_decoder; g_decoder = NULL; - } - pthread_mutex_unlock(&g_mtx); - - if (!was_active) - return; - - call_set_media_recv_cb(instance_lite_get_instance(), NULL, NULL); - if (enc) opus_codec_encoder_destroy(enc); - if (dec) opus_codec_decoder_destroy(dec); - DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_stop"); -} - -int call_audio_is_active(uint64_t call_id) { - pthread_mutex_lock(&g_mtx); - int active = g_active && call_id == g_call_id; - pthread_mutex_unlock(&g_mtx); - return active; -} diff --git a/tools/chatgui-android/libutun_lite/call_audio.h b/tools/chatgui-android/libutun_lite/call_audio.h deleted file mode 100644 index 539e07c7..00000000 --- a/tools/chatgui-android/libutun_lite/call_audio.h +++ /dev/null @@ -1,52 +0,0 @@ -#ifndef CALL_AUDIO_H -#define CALL_AUDIO_H - -/* call_audio.h — аудио-движок P2P-звонка для Android (аналог десктопного CallAudioEngine) - * - * Держит один активный звонок: Opus encoder (PCM→Opus, отправка пиру) и - * decoder (Opus→PCM, приём от пира). Аудио I/O живёт в Kotlin (AudioRecord/ - * AudioTrack), кодеки — здесь (Opus есть только в C). - * - * Потоки: - * - feed_pcm: из AudioRecord-потока Kotlin (кодирование + uasync_post) - * - media_recv: из uasync-потока (call.c) — декодирование + коллбэк PCM - * - start/stop: из GUI-потока Kotlin (по ACCEPTED/ENDED) - * Все общие поля защищены мьютексом; encoder/decoder используются строго - * одним потоком каждый, но освобождаются в stop под тем же мьютексом. */ -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* Параметры аудио: Opus 48kHz mono, кадр 20мс = 960 сэмплов. */ -#define CALL_AUDIO_SAMPLE_RATE 48000 -#define CALL_AUDIO_FRAME_MS 20 -#define CALL_AUDIO_FRAME_SAMPLES (CALL_AUDIO_SAMPLE_RATE * CALL_AUDIO_FRAME_MS / 1000) - -/* Запустить полнодуплексное аудио для активного звонка (после ACCEPTED). - * Создаёт encoder+decoder, регистрирует приём медиа в call.c. - * Повторный start с тем же call_id — no-op; с другим — сперва stop. - * Возвращает 0 при успехе, <0 при ошибке. */ -int call_audio_start(uint64_t call_id); - -/* Отправить один кадр PCM (ровно CALL_AUDIO_FRAME_SAMPLES) пиру. - * Кодируется в Opus и уходит через call_send_media (uasync-пост). - * Можно звать из любого потока. 0 — успех, <0 — звонок не активен/ошибка. */ -int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count); - -/* Остановить аудио: снять recv-cb, освободить encoder/decoder. */ -void call_audio_stop(void); - -/* Активен ли звонок с данным call_id (для логов/диагностики). */ -int call_audio_is_active(uint64_t call_id); - -/* Коллбэк приёма декодированного PCM пира (960 сэмплов/кадр). - * Вызывается в uasync-потоке на каждый принятый Opus-кадр. */ -typedef void (*call_audio_pcm_recv_fn)(uint64_t call_id, const int16_t* pcm, int count, void* arg); -void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg); - -#ifdef __cplusplus -} -#endif -#endif /* CALL_AUDIO_H */ diff --git a/tools/chatgui-android/libutun_lite/utun_sources.cmake b/tools/chatgui-android/libutun_lite/utun_sources.cmake index 5f84bbb0..ccc535a5 100644 --- a/tools/chatgui-android/libutun_lite/utun_sources.cmake +++ b/tools/chatgui-android/libutun_lite/utun_sources.cmake @@ -29,8 +29,8 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR) list(FILTER _src_src EXCLUDE REGEX "/lwip_orig/") list(FILTER _src_src EXCLUDE REGEX "/uip/") list(FILTER _src_src EXCLUDE REGEX "/transport_layer/BBR/") - # аудио-контур звонка (call_audio/call_tones/audio_jitter) — отдельная C++ задача, - # Android использует собственный libutun_lite/call_audio.c + # голосовой стек (call_audio/call_tones/voice_jitter+SoundTouch) собирается отдельной + # C++ библиотекой utun_voice (см. src/call/voice_sources.cmake) list(FILTER _src_src EXCLUDE REGEX "/call/call_audio\\.c$") list(FILTER _src_src EXCLUDE REGEX "/call/call_tones\\.c$") list(REMOVE_ITEM _src_src "${SRC_DIR}/utun.c") @@ -44,7 +44,6 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR) "${CONFIG_DIR}/video_sender.c" "${CONFIG_DIR}/photo_sender.c" "${CONFIG_DIR}/standby.c" - "${CONFIG_DIR}/call_audio.c" ) # ── Export sources ── diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 7f1ff4e8..607f4252 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -83,28 +83,16 @@ endif() # uTun core library (C, built from ../src/ and ../lib/) add_subdirectory(libutun) -# SoundTouch (time-stretch) — статическая C++ либа для джиттер-буфера звонка -set(SOUNDTOUCH_DIR "${CMAKE_SOURCE_DIR}/../../lib/soundtouch-master") -set(SOUNDTOUCH_SOURCES - ${SOUNDTOUCH_DIR}/source/SoundTouch/SoundTouch.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/TDStretch.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/RateTransposer.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/FIFOSampleBuffer.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/AAFilter.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/FIRFilter.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateLinear.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateCubic.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateShannon.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/cpu_detect_x86.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/mmx_optimized.cpp - ${SOUNDTOUCH_DIR}/source/SoundTouch/sse_optimized.cpp -) -add_library(soundtouch STATIC ${SOUNDTOUCH_SOURCES}) -target_include_directories(soundtouch PUBLIC - ${SOUNDTOUCH_DIR}/include - ${SOUNDTOUCH_DIR}/include/soundtouch -) -set_target_properties(soundtouch PROPERTIES POSITION_INDEPENDENT_CODE ON) +# Голосовой стек звонка (SoundTouch + voice_jitter + call_audio + call_tones), +# C++ внутри, C-интерфейс наружу (call_audio.h). +set(LIB_DIR "${CMAKE_SOURCE_DIR}/../../lib") +set(CALL_DIR "${CMAKE_SOURCE_DIR}/../../src/call") +include(${CALL_DIR}/voice_sources.cmake) +utun_setup_voice("${LIB_DIR}" "${CALL_DIR}") +add_library(utun_voice STATIC ${UTUN_VOICE_SOURCES}) +target_include_directories(utun_voice PUBLIC ${UTUN_VOICE_INCLUDE_DIRS}) +set_source_files_properties(${CALL_DIR}/call_audio.c ${CALL_DIR}/call_tones.c PROPERTIES LANGUAGE C) +set_target_properties(utun_voice PROPERTIES POSITION_INDEPENDENT_CODE ON) add_executable(vibechat @@ -153,9 +141,6 @@ add_executable(vibechat src/connmonitorwindow.cpp src/callwindow.cpp src/call_audio_engine.cpp - ../../src/call/call_audio.c - ../../src/call/call_tones.c - ../../src/call/audio_jitter.cpp transport/utun_node.cpp transport/node_config.cpp transport/config_updater.cpp @@ -171,11 +156,11 @@ target_compile_definitions(vibechat PRIVATE SQLITE_THREADSAFE=1 USE_SQLITE) if(FFMPEG_FOUND) target_compile_definitions(vibechat PRIVATE HAVE_FFMPEG) endif() -set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c ../../src/call/call_audio.c ../../src/call/call_tones.c PROPERTIES LANGUAGE C) +set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C) if(WIN32) - target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun soundtouch pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) + target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) else() - target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun soundtouch pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) + target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) endif() # ============================================================================ @@ -212,18 +197,13 @@ target_include_directories(test_config_updater PRIVATE ${CMAKE_SOURCE_DIR}) target_link_libraries(test_config_updater PRIVATE ${QT_CORE} pthread) add_test(NAME test_config_updater COMMAND test_config_updater) -# тест адаптивного джиттер-буфера (SoundTouch time-stretch) -add_executable(test_audio_jitter - tests/test_audio_jitter.cpp - ../../src/call/audio_jitter.cpp -) -target_include_directories(test_audio_jitter PRIVATE - ${CMAKE_SOURCE_DIR}/../../src - ${SOUNDTOUCH_DIR}/include - ${SOUNDTOUCH_DIR}/include/soundtouch +# тест адаптивного джиттер-буфера голоса (кодированные кадры + SoundTouch time-stretch) +add_executable(test_voice_jitter + tests/test_voice_jitter.cpp ) -target_link_libraries(test_audio_jitter PRIVATE soundtouch pthread) -add_test(NAME test_audio_jitter COMMAND test_audio_jitter) +target_include_directories(test_voice_jitter PRIVATE ${CMAKE_SOURCE_DIR}/../../src) +target_link_libraries(test_voice_jitter PRIVATE utun_voice pthread) +add_test(NAME test_voice_jitter COMMAND test_voice_jitter) # smoke-тест видео-декодера (FFmpeg). Ручной запуск: test_video_engine add_executable(test_video_engine diff --git a/tools/chatgui/libutun/CMakeLists.txt b/tools/chatgui/libutun/CMakeLists.txt index abd113b7..8ef437d6 100644 --- a/tools/chatgui/libutun/CMakeLists.txt +++ b/tools/chatgui/libutun/CMakeLists.txt @@ -77,7 +77,7 @@ file(GLOB_RECURSE UTUN_COMMON_SOURCES CONFIGURE_DEPENDS "${SRC_DIR}/*.c") list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/lwip_orig/") list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/uip/") list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/transport_layer/BBR/") -# аудио-контур звонка зависит от C++ (SoundTouch/audio_jitter) — собирается в vibechat +# аудио-контур звонка зависит от C++ (SoundTouch/voice_jitter) — собирается в libutun_voice list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/call/call_audio\\.c$") list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/call/call_tones\\.c$") list(REMOVE_ITEM UTUN_COMMON_SOURCES "${SRC_DIR}/utun.c") diff --git a/tools/chatgui/tests/test_audio_jitter.cpp b/tools/chatgui/tests/test_audio_jitter.cpp deleted file mode 100644 index 72e88b0e..00000000 --- a/tools/chatgui/tests/test_audio_jitter.cpp +++ /dev/null @@ -1,101 +0,0 @@ -// test_audio_jitter.cpp — проверка адаптивного джиттер-буфера (SoundTouch). -// -// 1. Нет потерь до ёмкости (50 кадров = 1000мс), дроп после переполнения. -// 2. Догон: глубокий буфер → темп растёт выше 1.0x (ускоренное воспроизведение). -// 3. Стационар: при неглубоком буфере темп остаётся ~1.0x (без runaway). - -#include "call/audio_jitter.h" - -#include -#include -#include -#include - -static int g_fail = 0; - -#define CHECK(cond, ...) do { \ - if (!(cond)) { g_fail++; printf("FAIL: " __VA_ARGS__); printf("\n"); } \ -} while (0) - -static void make_sine(std::vector& frame, double freq, double phase0) { - for (size_t i = 0; i < frame.size(); i++) - frame[i] = (int16_t)(sin(phase0 + 2.0 * M_PI * freq * (double)i / 48000.0) * 8000.0); -} - -int main(void) { - const int FRAME = 960; - const int CAP = 50; - std::vector frame(FRAME, 0); - std::vector out(480, 0); - int buffer_ms = 0, tempo_x100 = 0; - - /* ── 1. ёмкость и дроп ── */ - { - ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); - CHECK(j != nullptr, "create failed"); - - make_sine(frame, 440.0, 0.0); - for (int i = 0; i < CAP; i++) ajb_push(j, frame.data(), FRAME); - ajb_get_stats(j, &buffer_ms, &tempo_x100); - printf(" capacity: pushed %d frames, buffer_ms=%d dropped=%u\n", CAP, buffer_ms, (unsigned)ajb_dropped(j)); - CHECK(ajb_dropped(j) == 0, "expected no drop at capacity, dropped=%u", (unsigned)ajb_dropped(j)); - CHECK(buffer_ms == CAP * 20, "expected buffer_ms=%d, got %d", CAP * 20, buffer_ms); - - for (int i = 0; i < 5; i++) ajb_push(j, frame.data(), FRAME); - printf(" overflow: dropped=%u (expect 5)\n", (unsigned)ajb_dropped(j)); - CHECK(ajb_dropped(j) == 5, "expected 5 drops, got %u", (unsigned)ajb_dropped(j)); - ajb_destroy(j); - } - - /* ── 2. догон: глубокий буфер → tempo > 1.0x ── */ - { - ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); - make_sine(frame, 440.0, 0.0); - for (int i = 0; i < 40; i++) ajb_push(j, frame.data(), FRAME); /* 800мс burst */ - - int max_tempo = 100; - int total_out = 0; - for (int it = 0; it < 200; it++) { - ajb_get_stats(j, &buffer_ms, &tempo_x100); - if (buffer_ms == 0) break; - if (tempo_x100 > max_tempo) max_tempo = tempo_x100; - int n = ajb_pull(j, out.data(), (int)out.size()); - total_out += n; - } - ajb_get_stats(j, &buffer_ms, &tempo_x100); - printf(" catch-up: max_tempo_x100=%d total_out=%d final_buffer_ms=%d\n", - max_tempo, total_out, buffer_ms); - CHECK(max_tempo > 110, "expected tempo to exceed 1.10x during deep buffer, got %d", max_tempo); - CHECK(total_out < 40 * FRAME, "expected output < input (stretch), out=%d in=%d", total_out, 40 * FRAME); - CHECK(buffer_ms == 0, "expected drained buffer, buffer_ms=%d", buffer_ms); - ajb_destroy(j); - } - - /* ── 3. стационар: feed 1 frame → pull 1 frame, tempo ~1.0x ── */ - { - ajb* j = ajb_create(48000, 1, FRAME, 20, CAP); - make_sine(frame, 440.0, 0.0); - int max_tempo = 100; - for (int i = 0; i < 60; i++) { - ajb_push(j, frame.data(), FRAME); - int got = 0; - while (got < FRAME) { - int n = ajb_pull(j, out.data(), (int)out.size()); - if (n <= 0) break; - got += n; - } - ajb_get_stats(j, &buffer_ms, &tempo_x100); - if (tempo_x100 > max_tempo) max_tempo = tempo_x100; - } - printf(" steady-state: max_tempo_x100=%d (expect ~100)\n", max_tempo); - CHECK(max_tempo <= 110, "tempo should stay ~1.0x in steady state, got %d", max_tempo); - ajb_destroy(j); - } - - if (g_fail == 0) { - printf("TEST PASSED\n"); - return 0; - } - printf("TEST FAILED (%d checks)\n", g_fail); - return 1; -} diff --git a/tools/chatgui/tests/test_voice_jitter.cpp b/tools/chatgui/tests/test_voice_jitter.cpp new file mode 100644 index 00000000..2c4d4b25 --- /dev/null +++ b/tools/chatgui/tests/test_voice_jitter.cpp @@ -0,0 +1,103 @@ +// test_voice_jitter.cpp — проверка адаптивного джиттер-буфера голоса (SoundTouch). +// +// Декодер подменяется генератором синуса (кодированный кадр — маленький маркер), +// чтобы проверить буфер без Opus: 1) ёмкость/дроп, 2) догон (глубокий буфер → +// темп > 1.0x), 3) стационар (темп ~1.0x без runaway). + +#include "call/voice_jitter.h" + +#include +#include +#include +#include + +static int g_fail = 0; + +#define CHECK(cond, ...) do { \ + if (!(cond)) { g_fail++; printf("FAIL: " __VA_ARGS__); printf("\n"); } \ +} while (0) + +/* фейковый декодер: генерирует кадр синуса (960 сэмплов) из любого «кодированного» маркера. */ +static int sine_decode(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { + (void)arg; (void)enc; (void)len; + for (int i = 0; i < max_samples; i++) + pcm[i] = (int16_t)(sin(2.0 * M_PI * 440.0 * (double)i / 48000.0) * 8000.0); + return max_samples; +} + +int main(void) { + const int FRAME = 960; + const int CAP = 50; + uint8_t marker[8] = {0}; + std::vector out(480, 0); + int buffer_ms = 0, tempo_x100 = 0; + uint32_t dropped = 0, underruns = 0; + + /* ── 1. ёмкость и дроп ── */ + { + vj* j = vj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); + CHECK(j != nullptr, "create failed"); + + for (int i = 0; i < CAP; i++) vj_push(j, marker, (int)sizeof(marker)); + vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + printf(" capacity: pushed %d frames, buffer_ms=%d dropped=%u\n", CAP, buffer_ms, (unsigned)dropped); + CHECK(dropped == 0, "expected no drop at capacity, dropped=%u", (unsigned)dropped); + CHECK(buffer_ms == CAP * 20, "expected buffer_ms=%d, got %d", CAP * 20, buffer_ms); + + for (int i = 0; i < 5; i++) vj_push(j, marker, (int)sizeof(marker)); + vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + printf(" overflow: dropped=%u (expect 5)\n", (unsigned)dropped); + CHECK(dropped == 5, "expected 5 drops, got %u", (unsigned)dropped); + vj_destroy(j); + } + + /* ── 2. догон: глубокий буфер → tempo > 1.0x ── */ + { + vj* j = vj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); + for (int i = 0; i < 40; i++) vj_push(j, marker, (int)sizeof(marker)); /* 800мс burst */ + + int max_tempo = 100; + int total_out = 0; + for (int it = 0; it < 200; it++) { + vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + if (buffer_ms == 0) break; + if (tempo_x100 > max_tempo) max_tempo = tempo_x100; + int n = vj_pull(j, out.data(), (int)out.size()); + total_out += n; + } + vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + printf(" catch-up: max_tempo_x100=%d total_out=%d final_buffer_ms=%d\n", + max_tempo, total_out, buffer_ms); + CHECK(max_tempo > 110, "expected tempo to exceed 1.10x during deep buffer, got %d", max_tempo); + CHECK(total_out < 40 * FRAME, "expected output < input (stretch), out=%d in=%d", total_out, 40 * FRAME); + CHECK(buffer_ms == 0, "expected drained buffer, buffer_ms=%d", buffer_ms); + vj_destroy(j); + } + + /* ── 3. стационар: feed 1 frame → pull 1 frame, tempo ~1.0x ── */ + { + vj* j = vj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); + int max_tempo = 100; + for (int i = 0; i < 60; i++) { + vj_push(j, marker, (int)sizeof(marker)); + int got = 0; + while (got < FRAME) { + int n = vj_pull(j, out.data(), (int)out.size()); + if (n <= 0) break; + got += n; + } + vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + if (tempo_x100 > max_tempo) max_tempo = tempo_x100; + } + printf(" steady-state: max_tempo_x100=%d (expect ~100)\n", max_tempo); + CHECK(max_tempo <= 110, "tempo should stay ~1.0x in steady state, got %d", max_tempo); + vj_destroy(j); + } + + if (g_fail == 0) { + printf("TEST PASSED\n"); + return 0; + } + printf("TEST FAILED (%d checks)\n", g_fail); + return 1; +} diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index b0b37dcb..d579b452 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -311,10 +311,16 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { uint16_t rtt = (uint16_t)((d[8] << 8) | d[9]); uint16_t buf = (uint16_t)((d[10] << 8) | d[11]); uint16_t tempo = (uint16_t)((d[12] << 8) | d[13]); + uint16_t dropped = 0, underruns = 0; + if (dlen >= 18) { + dropped = (uint16_t)((d[14] << 8) | d[15]); + underruns = (uint16_t)((d[16] << 8) | d[17]); + } DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, - "gui_bridge: CALL_STATS id=0x%016llx rtt=%ums buffer=%ums tempo=%u.%02ux", + "gui_bridge: CALL_STATS id=0x%016llx rtt=%ums buffer=%ums tempo=%u.%02ux dropped=%u underruns=%u", (unsigned long long)callId, (unsigned)rtt, (unsigned)buf, - (unsigned)(tempo / 100), (unsigned)(tempo % 100)); + (unsigned)(tempo / 100), (unsigned)(tempo % 100), + (unsigned)dropped, (unsigned)underruns); } break; default: