Browse Source

call: единый голосовой стек (libutun_voice) + SoundTouch на Android

- 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
router-recv-conn-uaf
evgeny 2 weeks ago
parent
commit
2de554f8b9
  1. 13
      doc/tasks.md
  2. 180
      src/call/audio_jitter.cpp
  3. 51
      src/call/audio_jitter.h
  4. 9
      src/call/call.c
  5. 6
      src/call/call.h
  6. 67
      src/call/call_audio.c
  7. 28
      src/call/call_audio.h
  8. 96
      src/call/call_headless.c
  9. 12
      src/call/call_headless.h
  10. 187
      src/call/voice_jitter.cpp
  11. 54
      src/call/voice_jitter.h
  12. 52
      src/call/voice_sources.cmake
  13. 2
      tests/Makefile.am
  14. 13
      tests/test_call_headless.c
  15. 4
      tools/chatgui-android/AGENTS.md
  16. 2
      tools/chatgui-android/app/build.gradle.kts
  17. 16
      tools/chatgui-android/app/src/main/cpp/CMakeLists.txt
  18. 59
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  19. 14
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  20. 58
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  21. 5
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  22. 156
      tools/chatgui-android/libutun_lite/call_audio.c
  23. 52
      tools/chatgui-android/libutun_lite/call_audio.h
  24. 5
      tools/chatgui-android/libutun_lite/utun_sources.cmake
  25. 58
      tools/chatgui/CMakeLists.txt
  26. 2
      tools/chatgui/libutun/CMakeLists.txt
  27. 101
      tools/chatgui/tests/test_audio_jitter.cpp
  28. 103
      tools/chatgui/tests/test_voice_jitter.cpp
  29. 10
      tools/chatgui/transport/gui_bridge_impl.cpp

13
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) маршрута нет.

180
src/call/audio_jitter.cpp

@ -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 <atomic>
#include <cmath>
#include <cstring>
#include <mutex>
#include <vector>
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<int16_t> ring; // cap * frame_samples
int head = 0;
int count = 0;
uint32_t dropped = 0;
soundtouch::SoundTouch st; // только аудио-поток
std::vector<int16_t> frame_buf; // scratch: вытащенный кадр
std::vector<float> fin; // scratch: кадр в float
std::vector<float> fout; // scratch: выход float
double ema_depth_ms = 0.0;
double tempo = 1.0;
std::atomic<int> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lk(j->mtx);
return j->dropped;
}
} // extern "C"

51
src/call/audio_jitter.h

@ -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 <stdint.h>
#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 */

9
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");

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

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

28
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 <stdint.h>
@ -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
}

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

12
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" {

187
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 <atomic>
#include <cmath>
#include <cstring>
#include <mutex>
#include <vector>
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<uint8_t> ring; /* cap * VJ_MAX_ENC — кодированные кадры */
std::vector<int> 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<int16_t> pcm_buf; /* scratch: декодированный кадр */
std::vector<float> fin; /* scratch: кадр в float */
std::vector<float> fout; /* scratch: выход float */
double ema_depth_ms = 0.0;
double tempo = 1.0;
std::atomic<int> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lk(j->mtx);
return j->dropped;
}
} // extern "C"

54
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 <stdint.h>
#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 */

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

2
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

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

4
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-интерфейс

2
tools/chatgui-android/app/build.gradle.kts

@ -26,7 +26,7 @@ android {
externalNativeBuild {
cmake {
arguments("-DANDROID_STL=none")
arguments("-DANDROID_STL=c++_static")
}
}
ndk {

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

59
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<ShortArray>()
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) {

14
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 ── */

58
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__ */

5
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-поток.

156
tools/chatgui-android/libutun_lite/call_audio.c

@ -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 <string.h>
#include <pthread.h>
#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;
}

52
tools/chatgui-android/libutun_lite/call_audio.h

@ -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 <stdint.h>
#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 */

5
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 ──

58
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 <mp4>
add_executable(test_video_engine

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

101
tools/chatgui/tests/test_audio_jitter.cpp

@ -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 <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
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<int16_t>& 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<int16_t> frame(FRAME, 0);
std::vector<int16_t> 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;
}

103
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 <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
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<int16_t> 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;
}

10
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:

Loading…
Cancel
Save