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
This commit is contained in:
evgeny committed 2026-09-16 20:26:55 +03:00
1 parent de930b21a2
commit 2de554f8b9
29 files changed
+659 -756

No files matched your search

+2 -2
View File
@@ -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-интерфейс
+1 -1
View File
@@ -26,7 +26,7 @@ android {
externalNativeBuild {
cmake {
arguments("-DANDROID_STL=none")
arguments("-DANDROID_STL=c++_static")
}
}
ndk {
@@ -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)
@@ -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) {
@@ -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 ── */
@@ -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__ */
@@ -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-поток.
@@ -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;
}
@@ -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 */
@@ -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 ──
+19 -39
View File
@@ -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
# тест адаптивного джиттер-буфера голоса (кодированные кадры + SoundTouch time-stretch)
add_executable(test_voice_jitter
tests/test_voice_jitter.cpp
)
target_include_directories(test_audio_jitter PRIVATE
${CMAKE_SOURCE_DIR}/../../src
${SOUNDTOUCH_DIR}/include
${SOUNDTOUCH_DIR}/include/soundtouch
)
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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+8 -2
View File
@@ -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: