From 79b06846c622c93f3f28b74204c6ffdb98415fa0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 16:54:00 +0100 Subject: [PATCH] Test Android bridge mute after real AEC and AGC across unmute --- tools/chatgui/CMakeLists.txt | 16 ++ .../chatgui/tests/test_android_call_mute.cpp | 160 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tools/chatgui/tests/test_android_call_mute.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 5c00672f..68428f8f 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -677,6 +677,22 @@ if(UNIX AND NOT APPLE) add_test(NAME test_call_audio_tx_${scenario} COMMAND test_call_audio_tx ${scenario}) set_tests_properties(test_call_audio_tx_${scenario} PROPERTIES TIMEOUT 15) endforeach() + # Настоящий host bridge; неиспользуемые Android-сервисы исключаются, как в test_android_replies. + add_executable(test_android_call_mute tests/test_android_call_mute.cpp ../chatgui-android/jni_bridge/android_jni_bridge.c) + target_include_directories(test_android_call_mute PRIVATE ${UTUN_INCLUDE_DIRS}) + target_compile_options(test_android_call_mute PRIVATE -ffunction-sections -fdata-sections) + target_link_libraries(test_android_call_mute PRIVATE utun_voice utun pthread) + target_link_options(test_android_call_mute PRIVATE "-Wl,--gc-sections" "-Wl,--wrap=get_time_tb" + "-Wl,--wrap=chat_setting_get_int" "-Wl,--wrap=call_send_media" "-Wl,--wrap=opus_codec_encode" + "-Wl,--wrap=speex_aec_process_capture" "-Wl,--wrap=audio_compressor_process_frame") + foreach(aec 0 1) + foreach(agc 0 1) + add_test(NAME test_android_call_mute_aec${aec}_agc${agc} COMMAND test_android_call_mute ${aec} ${agc}) + set_tests_properties(test_android_call_mute_aec${aec}_agc${agc} PROPERTIES TIMEOUT 15) + endforeach() + endforeach() + add_test(NAME test_android_call_mute_legacy COMMAND test_android_call_mute legacy) + set_tests_properties(test_android_call_mute_legacy PROPERTIES TIMEOUT 15) endif() # smoke-тест видео-декодера (FFmpeg). Ручной запуск: test_video_engine diff --git a/tools/chatgui/tests/test_android_call_mute.cpp b/tools/chatgui/tests/test_android_call_mute.cpp new file mode 100644 index 00000000..993cd36f --- /dev/null +++ b/tools/chatgui/tests/test_android_call_mute.cpp @@ -0,0 +1,160 @@ +/* Настоящие C bridge/Opus/AEC/AGC/uasync; Android runtime и сетевой звонок не создаются. + * Wrapper читает входы/выходы DSP, не меняя их. Проверяем mute и следующий unmute по точному PCM до Opus. */ +#include +#include +#include +#include +#include +extern "C" { +#include "../../chatgui-android/jni_bridge/android_jni_bridge.h" +#include "utun_instance.h" +#include "call/call_audio.h" +#include "audio_compressor.h" +#include "opus_codec.h" +#include "speex_aec.h" +#include "debug_config.h" +#include "u_async.h" +#include "mem.h" +} + +#define CHECK(x) do { if (!(x)) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "Android mute line=%d check=%s", __LINE__, #x); abort(); } } while (0) +static constexpr unsigned Samples = 960, Warmup = 300, PhaseFrames = 20, Total = Warmup + 3 * PhaseFrames; +using Pcm = std::array; +struct pcm_stats { unsigned frames = 0, nonzero = 0; int peak = 0; double energy = 0; }; +struct phase_stats { pcm_stats raw, aec, agc, opus; unsigned mismatches = 0; }; +static std::array phases; +static uint64_t time_tb = 100000000; +static int aec_enabled, agc_enabled, phase = -1, codec_delay; +static unsigned sent; +static bool feeding, muted_frame, saw_aec, saw_agc, saw_encode; +static Pcm expected_raw, processed; +static int64_t expected_pts; +static opus_codec_encoder_t* remote_encoder; + +/* Только числовой снимок, без логов/выделений памяти на каждом DSP-вызове. */ +static void observe(pcm_stats& stats, const int16_t* pcm) { + ++stats.frames; + for (unsigned i = 0; i < Samples; ++i) { + stats.nonzero += pcm[i] != 0; + stats.peak = std::max(stats.peak, std::abs(int(pcm[i]))); + stats.energy += double(pcm[i]) * pcm[i]; + } +} + +extern "C" uint64_t __wrap_get_time_tb() { return time_tb; } +extern "C" int __wrap_chat_setting_get_int(UTUN_INSTANCE*, const char* name, int fallback) { + if (!strcmp(name, "aec_enabled")) return aec_enabled; + if (!strcmp(name, "call_compressor_enabled")) return agc_enabled; + return fallback; +} +extern "C" int __wrap_call_send_media(UTUN_INSTANCE*, uint64_t id, const uint8_t* data, int len, const call_capture_time* timing) { + CHECK(id == 42 && data && len > 0 && timing); + CHECK(timing->pts_us == expected_pts && timing->epoch == 1 && timing->delay_samples == unsigned(codec_delay)); + ++sent; return 0; +} + +/* AEC получает оригинальный raw-вход даже во время mute; настоящий фильтр продолжает работать. */ +extern "C" int __real_speex_aec_process_capture(speex_aec_t*, const int16_t*, int, int16_t*); +extern "C" int __wrap_speex_aec_process_capture(speex_aec_t* aec, const int16_t* pcm, int count, int16_t* out) { + CHECK(feeding && !saw_aec && count == int(Samples) && !memcmp(pcm, expected_raw.data(), sizeof(expected_raw))); + saw_aec = true; + int result = __real_speex_aec_process_capture(aec, pcm, count, out); CHECK(result == count); + std::copy_n(out, Samples, processed.begin()); + if (phase >= 0) observe(phases[phase].aec, out); + return result; +} + +/* AGC обрабатывает выход AEC до mute; проверяем также реально выбранное состояние compressor. */ +extern "C" int __real_audio_compressor_process_frame(audio_compressor*, const int16_t*, int16_t*, size_t); +extern "C" int __wrap_audio_compressor_process_frame(audio_compressor* ac, const int16_t* pcm, int16_t* out, size_t count) { + CHECK(feeding && !saw_agc && count == Samples && audio_compressor_is_enabled(ac) == agc_enabled); + CHECK(!memcmp(pcm, processed.data(), sizeof(processed))); + saw_agc = true; + int result = __real_audio_compressor_process_frame(ac, pcm, out, count); CHECK(result == 0); + std::copy_n(out, Samples, processed.begin()); + if (phase >= 0) observe(phases[phase].agc, out); + return result; +} + +/* Несовпадения копим до cleanup: прежний bridge даёт диагностируемый FAIL с освобождением ресурсов. */ +extern "C" int __real_opus_codec_encode(opus_codec_encoder_t*, const int16_t*, int, uint8_t*, int); +extern "C" int __wrap_opus_codec_encode(opus_codec_encoder_t* encoder, const int16_t* pcm, int count, uint8_t* out, int capacity) { + if (encoder != remote_encoder) { + CHECK(feeding && !saw_encode && saw_agc && count == int(Samples)); saw_encode = true; + CHECK(opus_codec_encoder_delay(encoder, &codec_delay) == 0); + if (phase >= 0) { + observe(phases[phase].opus, pcm); + for (unsigned i = 0; i < Samples; ++i) + phases[phase].mismatches += pcm[i] != (muted_frame ? 0 : processed[i]); + } + } + return __real_opus_codec_encode(encoder, pcm, count, out, capacity); +} + +int main(int argc, char** argv) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + debug_set_category_level(DEBUG_CATEGORY_CALL, DEBUG_LEVEL_INFO); + bool legacy = argc == 2 && !strcmp(argv[1], "legacy"); + if (legacy) { aec_enabled = 1; agc_enabled = 0; } + else { + CHECK(argc == 3 && (!strcmp(argv[1], "0") || !strcmp(argv[1], "1")) && + (!strcmp(argv[2], "0") || !strcmp(argv[2], "1"))); + aec_enabled = argv[1][0] == '1'; agc_enabled = argv[2][0] == '1'; + } + size_t allocated = u_get_allocated_count(); + UTUN_INSTANCE inst{}; inst.ua = uasync_create(); CHECK(inst.ua); + CHECK(call_audio_init(&inst) == 0); + call_audio_set_aec_delay_frames(1); CHECK(call_audio_start(42) == 0); + remote_encoder = opus_codec_encoder_create(48000, 1); CHECK(remote_encoder); + Pcm remote{}, rendered{}, microphone{}, silence{}; + uint32_t rng = 1234567; + uint16_t seq = 0; + auto incoming = [&](uint32_t timestamp) { + for (auto& sample : remote) { rng = rng * 1664525u + 1013904223u; sample = int(rng >> 18) - 8192; } + uint8_t opus[256]; int len = opus_codec_encode(remote_encoder, remote.data(), Samples, opus, sizeof(opus)); CHECK(len > 0); + call_audio_on_media(&inst, 42, seq++, timestamp, opus, len, nullptr); + }; + incoming(0); incoming(0); // Настоящий стартовый запас FIFO, приватное состояние не подменяется. + for (unsigned frame = 0; frame < Total; ++frame) { + time_tb = 100000000 + frame * 200; + incoming(frame * 20); CHECK(call_audio_pull_pcm(42, rendered.data(), Samples) == int(Samples)); + for (unsigned i = 0; i < Samples; ++i) microphone[i] = rendered[i] / 2; + phase = frame < Warmup ? -1 : int((frame - Warmup) / PhaseFrames); + bool mute = phase == 1; + expected_raw = legacy && mute ? silence : microphone; + processed = expected_raw; muted_frame = mute && !legacy; + expected_pts = 1000000 + int64_t(frame) * 20000; + saw_aec = saw_agc = saw_encode = false; + if (phase >= 0) observe(phases[phase].raw, expected_raw.data()); + feeding = true; + CHECK(utun_bridge_call_audio_feed(42, expected_raw.data(), Samples, muted_frame, expected_pts) == 0); + feeding = false; + CHECK(saw_aec == bool(aec_enabled) && saw_agc && saw_encode); + CHECK(expected_raw == (legacy && mute ? silence : microphone)); // Mute не изменяет массив вызывающего. + uasync_poll(inst.ua, 0); + } + CHECK(sent == Total && !inst.ua->posted_tasks_head); + call_audio_stop(); call_audio_destroy(&inst); opus_codec_encoder_destroy(remote_encoder); + CHECK(inst.ua->timer_alloc_count == inst.ua->timer_free_count && inst.ua->socket_alloc_count == inst.ua->socket_free_count); + uasync_destroy(inst.ua, 0); + CHECK(u_get_allocated_count() == allocated); + bool ok = true; + const char* names[] = {"before", "muted", "after"}; + for (unsigned i = 0; i < phases.size(); ++i) { + const auto& stats = phases[i]; + CHECK(stats.raw.frames == PhaseFrames && stats.agc.frames == PhaseFrames && stats.opus.frames == PhaseFrames); + CHECK(stats.aec.frames == (aec_enabled ? PhaseFrames : 0)); + bool valid = !stats.mismatches && (i == 1 && !legacy ? !stats.opus.nonzero : stats.opus.nonzero > 0); + valid = valid && (legacy && i == 1 ? !stats.raw.nonzero : stats.raw.nonzero > 0); + ok = ok && valid; + DEBUG_INFO(DEBUG_CATEGORY_CALL, + "Android mute aec=%d agc=%d legacy=%d phase=%s frames=%u raw_nonzero=%u aec_nonzero=%u agc_nonzero=%u " + "opus_nonzero=%u peak=%d rms=%.3f mismatches=%u result=%s", aec_enabled, agc_enabled, legacy, names[i], PhaseFrames, + stats.raw.nonzero, stats.aec.nonzero, stats.agc.nonzero, stats.opus.nonzero, stats.opus.peak, + std::sqrt(stats.opus.energy / (PhaseFrames * Samples)), stats.mismatches, valid ? "PASS" : "FAIL"); + } + DEBUG_INFO(DEBUG_CATEGORY_CALL, "Android mute cleanup sent=%u PTS_epoch=1 allocations=%zu->%zu result=%s", + sent, allocated, u_get_allocated_count(), ok ? "PASS" : "FAIL"); + if (!ok) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "Android mute PCM ordering regression failed aec=%d agc=%d", aec_enabled, agc_enabled); + return ok ? 0 : 1; +}