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:
1 parent
de930b21a2
commit
2de554f8b9
29 files changed
+659
-756
No files matched your search
@@ -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-интерфейс
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user