From 9c8f635e636f311c260a3a453eada59300995758 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 3 Oct 2026 01:34:27 +0300 Subject: [PATCH] Test VAD compression, quiet speech and independent prefetch audio --- tests/test_silero_vad.c | 142 ++++++++++++++++++ tools/chatgui/tests/test_voice_tx.cpp | 207 +++++++++++++++++++++++++- 2 files changed, 346 insertions(+), 3 deletions(-) diff --git a/tests/test_silero_vad.c b/tests/test_silero_vad.c index d4a134a8..0d3f8fd3 100644 --- a/tests/test_silero_vad.c +++ b/tests/test_silero_vad.c @@ -6,6 +6,7 @@ * - что на тишине вероятность речи близка к нулю (реальный инференс, не мусор); * - что путь pcm16 даёт тот же результат, что и float (конвертация /32768); * - что вероятность всегда лежит в [0;1]. + * - что речь запускает авто-PTT, тишина останавливает, reset воспроизводит тот же поток. * * Модель ищется в argv[1], по умолчанию "../lib/silero_vad.onnx" (запуск из tests/). */ @@ -14,7 +15,9 @@ #include #include "../lib/silero_vad.h" +#include "../lib/audio_compressor.h" #include "../lib/debug_config.h" +#include "../src/radio/radio_vad.h" #define PI 3.14159265358979323846 @@ -111,6 +114,7 @@ static int test_pcm16_matches_float(const char* model_path) { gen_vowel(samples, SILERO_VAD_WINDOW_SAMPLES, 0); for (int i = 0; i < SILERO_VAD_WINDOW_SAMPLES; i++) { pcm[i] = (int16_t)(samples[i] * 32768.0f); + samples[i] = (float)pcm[i] / 32768.0f; /* Сравниваем один сигнал после квантования. */ } rc = silero_vad_process(vad, samples, &prob_f); @@ -172,6 +176,139 @@ static int test_null_args(const char* model_path) { return 0; } +/* Речевая запись обязательна: тесты на тишине не замечают неверный вход модели. */ +static void test_speech(const char* model_path) { + enum { SPEECH_WINDOWS = 32, SILENCE_WINDOWS = 64 }; + uint8_t bytes[SPEECH_WINDOWS * SILERO_VAD_WINDOW_SAMPLES * 2]; + float reference[SPEECH_WINDOWS]; + int16_t pcm[SILERO_VAD_WINDOW_SAMPLES]; + float silence[SILERO_VAD_WINDOW_SAMPLES] = {0}; + printf("[silero_vad] speech -> auto-PTT -> silence -> reset (%s)\n", model_path ? "file" : "embedded"); + FILE* file = fopen(TEST_SILERO_SPEECH_PATH, "rb"); + CHECK(file != NULL, "cannot open speech fixture: %s", TEST_SILERO_SPEECH_PATH); + if (!file) return; + size_t size = fread(bytes, 1, sizeof(bytes), file); + int extra = fgetc(file); + fclose(file); + CHECK(size == sizeof(bytes) && extra == EOF, "invalid speech fixture size: %zu", size); + if (size != sizeof(bytes) || extra != EOF) return; + + silero_vad_t* vad = model_path ? silero_vad_create(model_path) : silero_vad_create_default(); + CHECK(vad != NULL, "speech detector create failed"); + if (!vad) return; + for (int pass = 0; pass < 2; pass++) { + silero_vad_reset(vad); + struct radio_vad_fsm fsm; + radio_vad_fsm_reset(&fsm); + uint64_t now = 1; + float max_prob = 0; + int starts = 0, stops = 0; + for (int window = 0; window < SPEECH_WINDOWS + SILENCE_WINDOWS; window++) { + float probability = -1; + int rc; + if (window < SPEECH_WINDOWS) { + for (int i = 0; i < SILERO_VAD_WINDOW_SAMPLES; i++) { + size_t offset = ((size_t)window * SILERO_VAD_WINDOW_SAMPLES + i) * 2; + int value = bytes[offset] | ((int)bytes[offset + 1] << 8); + pcm[i] = (int16_t)(value >= 32768 ? value - 65536 : value); + } + rc = silero_vad_process_pcm16(vad, pcm, &probability); + } else rc = silero_vad_process(vad, silence, &probability); + CHECK(rc == 0, "speech process pass=%d window=%d rc=%d", pass, window, rc); + if (rc != 0) { silero_vad_destroy(vad); return; } + CHECK(probability >= 0 && probability <= 1, "speech probability out of range: %f", probability); + if (window < SPEECH_WINDOWS) { + if (!pass) reference[window] = probability; + else CHECK(fabsf(reference[window] - probability) < 1e-5f, "reset changed speech window=%d", window); + if (probability > max_prob) max_prob = probability; + } + now += 320; + int transition = radio_vad_fsm_update(&fsm, probability, 0.5f, 2, 2000, 0, now); + if (transition == 1 && window < SPEECH_WINDOWS) starts++; + if (transition == -1 && window >= SPEECH_WINDOWS) stops++; + } + printf(" pass=%d speech max_prob=%.5f starts=%d silence_stops=%d talking=%d\n", pass, max_prob, starts, stops, fsm.talking); + CHECK(max_prob > 0.8f && starts > 0, "speech did not activate auto-PTT"); + CHECK(stops > 0 && !fsm.talking, "silence did not stop auto-PTT"); + } + silero_vad_destroy(vad); +} + +/* Реальная цепочка 48кГц/20мс AGC → mono 16кГц/512 → Silero → авто-PTT; фон не должен включать передачу. */ +static void test_compressed_speech(float amplitude, int max_gain_db) { + enum { SPEECH_SAMPLES = 16384, BLOCKS = 200, NOISE_FRAMES = 48000 }; + uint8_t bytes[SPEECH_SAMPLES * 2]; + FILE* file = fopen(TEST_SILERO_SPEECH_PATH, "rb"); + CHECK(file != NULL, "cannot open compressor speech fixture"); + if (!file) return; + size_t size = fread(bytes, 1, sizeof(bytes), file); + fclose(file); + CHECK(size == sizeof(bytes), "invalid compressor speech fixture size=%zu", size); + if (size != sizeof(bytes)) return; + struct audio_compressor* ac = audio_compressor_create(); + silero_vad_t* vad = silero_vad_create_default(); + CHECK(ac && vad, "compressed speech create failed"); + if (!ac || !vad) { audio_compressor_destroy(ac); silero_vad_destroy(vad); return; } + audio_compressor_config_t cfg = {0}; + cfg.sample_rate = 48000; cfg.channels = 1; cfg.block_duration_ms = 20; + cfg.lookback_ms = 200; cfg.max_gain_db = max_gain_db; cfg.rise_rate_per_sec = 2; cfg.target_level = 0.25f; + int rc = audio_compressor_configure(ac, &cfg); + CHECK(rc == 0, "compressed speech configure failed"); + if (rc != 0) { audio_compressor_destroy(ac); silero_vad_destroy(vad); return; } + float reference[BLOCKS], window[SILERO_VAD_WINDOW_SAMPLES]; + int16_t pcm[960]; + printf("[silero_vad] independent AGC speech amplitude=%.3f max_gain=%ddB + low noise\n", amplitude, max_gain_db); + for (int pass = 0; pass < 2; ++pass) { + audio_compressor_reset(ac); silero_vad_reset(vad); + struct radio_vad_fsm fsm; + radio_vad_fsm_reset(&fsm); + uint32_t noise = 1; + float peak = 0, noise_peak = 0; + int window_len = 0, window_idx = 0, starts = 0, stops = 0; + for (int block = 0; block < BLOCKS; ++block) { + for (int i = 0; i < 960; ++i) { + int offset = block * 960 + i - NOISE_FRAMES; + noise = noise * 1664525u + 1013904223u; + int sample = (int)(noise >> 27) - 16; + if (offset >= 0 && offset < SPEECH_SAMPLES * 3) { + int index = offset / 3 * 2; + int value = bytes[index] | ((int)bytes[index + 1] << 8); + sample += (int)((value >= 32768 ? value - 65536 : value) * amplitude); + } + if (sample > 32767) sample = 32767; + if (sample < -32768) sample = -32768; + pcm[i] = (int16_t)sample; + } + rc = audio_compressor_process_frame(ac, pcm, pcm, 960); + CHECK(rc == 0, "compressed speech frame failed block=%d", block); + for (int i = 0; i < 960; i += 3) { + window[window_len++] = ((float)pcm[i] + pcm[i + 1] + pcm[i + 2]) / (3.0f * 32768.0f); + if (window_len != SILERO_VAD_WINDOW_SAMPLES) continue; + float probability = -1; + rc = silero_vad_process(vad, window, &probability); + CHECK(rc == 0 && probability >= 0 && probability <= 1, "compressed speech process failed window=%d", window_idx); + if (!pass) reference[window_idx] = probability; + else CHECK(fabsf(reference[window_idx] - probability) < 1e-5f, "compressed reset changed window=%d", window_idx); + int end_frame = block * 960 + i + 3; + int speech = end_frame > NOISE_FRAMES && end_frame <= NOISE_FRAMES + SPEECH_SAMPLES * 3; + if (speech && probability > peak) peak = probability; + if (end_frame <= NOISE_FRAMES && probability > noise_peak) noise_peak = probability; + int transition = radio_vad_fsm_update(&fsm, probability, 0.5f, 2, 2000, 0, (uint64_t)(window_idx + 1) * 320); + if (transition > 0) starts++; + if (transition < 0) stops++; + window_len = 0; ++window_idx; + } + } + printf(" pass=%d speech_peak=%.5f noise_peak=%.5f starts=%d stops=%d gain=%.1fdB clips=%u\n", pass, + peak, noise_peak, starts, stops, audio_compressor_gain_db(ac), audio_compressor_clip_count(ac)); + CHECK(noise_peak < 0.5f, "background triggered compressed VAD"); + CHECK(peak > 0.8f && starts > 0, "compressed speech did not activate auto-PTT"); + CHECK(stops > 0 && !fsm.talking, "compressed background did not stop auto-PTT"); + CHECK(audio_compressor_gain_db(ac) <= max_gain_db + 0.001f, "compressor exceeded max gain"); + } + audio_compressor_destroy(ac); silero_vad_destroy(vad); +} + int main(int argc, char** argv) { const char* model_path = (argc > 1) ? argv[1] : "../lib/silero_vad.onnx"; @@ -186,6 +323,11 @@ int main(int argc, char** argv) { test_pcm16_matches_float(model_path); test_null_args(model_path); test_default_model(); + test_speech(model_path); + test_speech(NULL); + test_compressed_speech(1.0f, 15); + test_compressed_speech(0.03f, 15); + test_compressed_speech(0.03f, 30); if (g_failures == 0) { printf("TEST PASSED\n"); diff --git a/tools/chatgui/tests/test_voice_tx.cpp b/tools/chatgui/tests/test_voice_tx.cpp index ae689e87..61c2eecf 100644 --- a/tools/chatgui/tests/test_voice_tx.cpp +++ b/tools/chatgui/tests/test_voice_tx.cpp @@ -7,8 +7,11 @@ extern "C" { #include "debug_config.h" #include "u_async.h" #include "silero_vad.h" +#include "audio_compressor.h" +#include "chat/chat_setting.h" } #include +#include #include #include #include @@ -23,12 +26,16 @@ static std::vector events; static std::vector> packets; static float probability; static int vad_windows; +static int radio_compressor_enabled; +static bool observe_vad; +static std::vector vad_input; static std::atomic stall_vad{false}, vad_stalled{false}; extern "C" silero_vad_t* __wrap_silero_vad_create_default() { return reinterpret_cast(1); } extern "C" void __wrap_silero_vad_destroy(silero_vad_t*) {} extern "C" void __wrap_silero_vad_reset(silero_vad_t*) {} -extern "C" int __wrap_silero_vad_process(silero_vad_t*, const float*, float* result) { +extern "C" int __wrap_silero_vad_process(silero_vad_t*, const float* samples, float* result) { if (stall_vad) { vad_stalled = true; while (stall_vad) std::this_thread::sleep_for(std::chrono::milliseconds(1)); } + if (observe_vad) vad_input.insert(vad_input.end(), samples, samples + SILERO_VAD_WINDOW_SAMPLES); ++vad_windows; *result = probability; return 0; } extern "C" int __wrap_radio_talk_begin(UTUN_INSTANCE*, uint64_t) { events.push_back('B'); return 0; } @@ -41,15 +48,203 @@ extern "C" int __wrap_call_send_media(UTUN_INSTANCE*, uint64_t, const uint8_t* d } extern "C" int __real_chat_setting_get_int(UTUN_INSTANCE*, const char*, int); extern "C" int __wrap_chat_setting_get_int(UTUN_INSTANCE* inst, const char* key, int fallback) { - if (!strcmp(key, "radio_compressor_enabled") || !strcmp(key, "call_compressor_enabled")) return 0; + if (!strcmp(key, "radio_compressor_enabled")) return radio_compressor_enabled; + if (!strcmp(key, "call_compressor_enabled")) return 0; if (!strcmp(key, "radio_vad_hangover_ms")) return 0; return __real_chat_setting_get_int(inst, key, fallback); } -static void check_packet(opus_codec_encoder_t* encoder, const int16_t* pcm, size_t index) { +static void check_packet(opus_codec_encoder_t* encoder, const int16_t* pcm, size_t index, + struct audio_compressor* compressor = nullptr, int channels = 1) { + std::array amplified; + if (compressor) { + REQUIRE(audio_compressor_process_frame(compressor, pcm, amplified.data(), 960 * channels) == 0); + pcm = amplified.data(); + } uint8_t data[1024]; int size = opus_codec_encode(encoder, pcm, 960, data, sizeof(data)); REQUIRE(size > 0 && index < packets.size()); REQUIRE(packets[index].size() == (size_t)size && !memcmp(packets[index].data(), data, size)); } + +static struct audio_compressor* reference_compressor(int channels, int max_gain) { + auto* ac = audio_compressor_create(); REQUIRE(ac); + audio_compressor_config_t cfg{}; + cfg.sample_rate = 48000; cfg.channels = channels; cfg.block_duration_ms = 20; + cfg.lookback_ms = 200; cfg.max_gain_db = max_gain; cfg.rise_rate_per_sec = 2; cfg.target_level = 0.25f; + REQUIRE(audio_compressor_configure(ac, &cfg) == 0); + return ac; +} + +/* Проверяем фактический вход Silero; разбиение callback не должно менять AGC или ресемплер. */ +static void check_vad_pcm(const int16_t* pcm, int frames, int channels, int max_gain) { + auto* ac = reference_compressor(channels, max_gain); + std::array amplified; + std::vector expected; + for (int offset = 0; offset + 960 <= frames; offset += 960) { + REQUIRE(audio_compressor_process_frame(ac, pcm + offset * channels, amplified.data(), 960 * channels) == 0); + for (int i = 0; i < 960; i += 3) { + float sum = 0; + for (int j = 0; j < 3; ++j) { + const int16_t* sample = amplified.data() + (i + j) * channels; + sum += channels == 2 ? ((float)sample[0] + sample[1]) * 0.5f : sample[0]; + } + expected.push_back(sum / (3.0f * 32768.0f)); + } + } + expected.resize(expected.size() / 512 * 512); + REQUIRE(vad_input == expected); + REQUIRE(audio_compressor_gain_db(ac) <= max_gain + 0.001f); + audio_compressor_destroy(ac); +} + +static void test_vad_compressor(UTUN_INSTANCE* instance, int channels, int max_gain) { + debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_DEBUG); + REQUIRE(chat_setting_set(instance, "radio_compressor_max_gain_db", "6") == 0); + REQUIRE(chat_setting_set(instance, "radio_vad_compressor_max_gain_db", std::to_string(max_gain).c_str()) == 0); + REQUIRE(chat_setting_set(instance, "radio_vad_compressor_max_gain_db", "61") == -2); + REQUIRE(chat_setting_set(instance, "radio_compressor_max_gain_db", "61") == -2); + radio_compressor_enabled = 1; observe_vad = true; probability = 0; + packets.clear(); events.clear(); vad_input.clear(); + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, channels, 1) == 0); + const int idle = 55 * 960, live = 4 * 960; + std::vector pcm((idle + live) * channels); + for (size_t i = 0; i < pcm.size(); ++i) pcm[i] = (int16_t)(240 * sin(i * 0.07)); + auto feed_chunks = [&](const int16_t* samples, int frames) { + const int chunks[] = {1, 7, 65, 997, 1536, 11, 333}; + for (int offset = 0, chunk = 0; offset < frames; ++chunk) { + int count = std::min(chunks[chunk % 7], frames - offset); + REQUIRE(radio_audio_feed_pcm(7, samples + offset * channels, count * channels) == 0); + offset += count; + } + }; + feed_chunks(pcm.data(), idle); + REQUIRE(!radio_audio_transmitting()); + radio_audio_talk_begin(7); feed_chunks(pcm.data() + idle * channels, live); radio_audio_talk_end(7); + check_vad_pcm(pcm.data(), idle + live, channels, max_gain); + REQUIRE(*std::max_element(vad_input.begin(), vad_input.end()) > 240.0f / 32768.0f * 1.5f); + uasync_poll(instance->ua, 0); + REQUIRE(packets.size() == 54 && events.front() == 'B' && events.back() == 'F'); + auto* encoder = opus_codec_encoder_create(48000, channels); REQUIRE(encoder); + opus_codec_encoder_bitrate_set(encoder, 32000); + auto* tx = reference_compressor(channels, 6); + for (int i = 0; i < 54; ++i) check_packet(encoder, pcm.data() + (idle - 48000 + i * 960) * channels, i, tx, channels); + audio_compressor_destroy(tx); opus_codec_encoder_destroy(encoder); + // Разрыв и новый захват должны убрать и неполный блок 20мс, и историю усиления. + feed_chunks(pcm.data(), 800); + radio_audio_capture_discontinuity(7); vad_input.clear(); + feed_chunks(pcm.data(), 2897); check_vad_pcm(pcm.data(), 2897, channels, max_gain); + auto reset_input = vad_input; + radio_audio_capture_stop(); REQUIRE(radio_audio_capture_start(7, channels, 1) == 0); vad_input.clear(); + feed_chunks(pcm.data(), 2897); REQUIRE(vad_input == reset_input); + radio_audio_stop(); uasync_poll(instance->ua, 0); + observe_vad = false; radio_compressor_enabled = 0; + REQUIRE(chat_setting_set(instance, "radio_compressor_max_gain_db", "15") == 0); + REQUIRE(chat_setting_set(instance, "radio_vad_compressor_max_gain_db", "15") == 0); + printf("voice TX: separate VAD gain=%ddB ch=%d, exact Silero input, independent TX/prefetch, chunking/reset PASS\n", max_gain, channels); + debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_NONE); +} + +/* Полная секунда кольца, граница VAD внутри кадра и live должны совпасть с непрерывным Opus-потоком. */ +static void test_prefetch(UTUN_INSTANCE* instance, int channels, int compressor_enabled = 0) { + debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_INFO); + packets.clear(); events.clear(); probability = 0; radio_compressor_enabled = compressor_enabled; + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, channels, 1) == 0); + const int idle = 33 * 1536, confirm = 2 * 1536, live = 959; + std::vector pcm((idle + confirm + live) * channels); + for (size_t i = 0; i < pcm.size(); ++i) pcm[i] = (int16_t)(10000 * sin(i * 0.07)); + REQUIRE(radio_audio_feed_pcm(7, pcm.data(), idle * channels) == 0); + REQUIRE(!radio_audio_transmitting() && !instance->ua->posted_tasks_head); + probability = 0.9f; + int trigger = idle; + for (; trigger < idle + confirm; ++trigger) { + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + trigger * channels, channels) == 0); + if (radio_audio_transmitting()) break; + } + REQUIRE(radio_audio_transmitting()); + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + (trigger + 1) * channels, live * channels) == 0); + int pending = 0; + for (auto* task = instance->ua->posted_tasks_head; task; task = task->next) ++pending; + REQUIRE(pending == 3); // BEGIN + одна задача на 50 кадров prefetch + один live-кадр. + radio_audio_capture_stop(); uasync_poll(instance->ua, 0); + REQUIRE(packets.size() == 51 && events.size() == 53 && events.front() == 'B' && events.back() == 'F'); + auto* encoder = opus_codec_encoder_create(48000, channels); REQUIRE(encoder); + opus_codec_encoder_bitrate_set(encoder, 32000); + auto* tx = compressor_enabled ? reference_compressor(channels, 15) : nullptr; + const int first = trigger - 48000; + for (int i = 0; i < 51; ++i) check_packet(encoder, pcm.data() + (first + i * 960) * channels, i, tx, channels); + audio_compressor_destroy(tx); + opus_codec_encoder_destroy(encoder); + radio_compressor_enabled = 0; + + // Разрыв очищает историю; короткий prefetch не дополняется тишиной на границе с live. + packets.clear(); events.clear(); probability = 0; + REQUIRE(radio_audio_capture_start(7, channels, 1) == 0); + REQUIRE(radio_audio_feed_pcm(7, pcm.data(), 100 * channels) == 0); + radio_audio_capture_discontinuity(7); + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + 100 * channels, 240 * channels) == 0); + radio_audio_talk_begin(7); + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + 340 * channels, 120 * channels) == 0); + radio_audio_talk_end(7); uasync_poll(instance->ua, 0); + REQUIRE(events == std::vector({'B','M','F'})); + encoder = opus_codec_encoder_create(48000, channels); REQUIRE(encoder); + opus_codec_encoder_bitrate_set(encoder, 32000); + std::vector tail(960 * channels); + memcpy(tail.data(), pcm.data() + 100 * channels, 360 * channels * sizeof(int16_t)); + check_packet(encoder, tail.data(), 0); + opus_codec_encoder_destroy(encoder); + + // Следующая передача получает только новую паузу, а не уже переданный prefetch/хвост. + packets.clear(); events.clear(); + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + 460 * channels, 100 * channels) == 0); + radio_audio_talk_begin(7); + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + 560 * channels, 100 * channels) == 0); + radio_audio_talk_end(7); uasync_poll(instance->ua, 0); + REQUIRE(events == std::vector({'B','M','F'})); + // Эталонный encoder сохраняет ту же историю предыдущего пакета. + encoder = opus_codec_encoder_create(48000, channels); REQUIRE(encoder); + opus_codec_encoder_bitrate_set(encoder, 32000); + uint8_t unused[1024]; REQUIRE(opus_codec_encode(encoder, tail.data(), 960, unused, sizeof(unused)) > 0); + std::fill(tail.begin(), tail.end(), 0); + memcpy(tail.data(), pcm.data() + 460 * channels, 200 * channels * sizeof(int16_t)); + check_packet(encoder, tail.data(), 0); + opus_codec_encoder_destroy(encoder); + radio_audio_stop(); uasync_poll(instance->ua, 0); + printf("voice TX: prefetch 1000ms, exact ring/live order, short history, discontinuity, no replay ch=%d PASS\n", channels); + debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_NONE); +} + +static void test_prefetch_deferred(UTUN_INSTANCE* instance) { + debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_INFO); + packets.clear(); events.clear(); probability = 0; + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, 1, 0) == 0); + std::array queued{}; + radio_audio_talk_begin(7); REQUIRE(radio_audio_feed_pcm(7, queued.data(), queued.size()) == 0); + radio_audio_capture_stop(); REQUIRE(radio_audio_capture_start(7, 1, 1) == 0); + const int idle = 33 * 1536, confirm = 2 * 1536; + std::vector pcm(idle + confirm + 1); + for (size_t i = 0; i < pcm.size(); ++i) pcm[i] = (int16_t)(9000 * sin(i * 0.09)); + REQUIRE(radio_audio_feed_pcm(7, pcm.data(), idle) == 0); + probability = 0.9f; + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + idle, confirm) == 0); + REQUIRE(!radio_audio_transmitting()); // Media-очередь занята: BEGIN отложен, история продолжает копиться. + int pending = 0; + for (auto* task = instance->ua->posted_tasks_head; task; task = task->next) ++pending; + REQUIRE(pending == 10); // Только старые BEGIN + 8 media + FIN, новых задач пока нет. + uasync_poll(instance->ua, 0); REQUIRE(packets.size() == 8); + packets.clear(); events.clear(); + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + idle + confirm, 1) == 0 && radio_audio_transmitting()); + radio_audio_capture_stop(); uasync_poll(instance->ua, 0); + REQUIRE(packets.size() == 51 && events.front() == 'B' && events.back() == 'F'); + auto* encoder = opus_codec_encoder_create(48000, 1); REQUIRE(encoder); + opus_codec_encoder_bitrate_set(encoder, 32000); + const int first = idle + confirm - 48000; + for (int i = 0; i < 50; ++i) check_packet(encoder, pcm.data() + first + i * 960, i); + std::array tail{}; tail[0] = pcm.back(); check_packet(encoder, tail.data(), 50); + opus_codec_encoder_destroy(encoder); + radio_audio_stop(); uasync_poll(instance->ua, 0); + puts("voice TX: full media queue defers BEGIN and preserves complete prefetch PASS"); + debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_NONE); +} + int main() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); UTUN_INSTANCE instance{}; instance.ua = uasync_create(); REQUIRE(instance.ua); @@ -121,6 +316,12 @@ int main() { for (int i = 0; i < 200 && !rx_finished; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1)); REQUIRE(rx_finished); stall_vad = false; tx.join(); rx.join(); radio_audio_stop(); uasync_poll(instance.ua, 0); + test_prefetch(&instance, 1); + test_prefetch(&instance, 2); + test_prefetch(&instance, 1, 1); + test_prefetch(&instance, 2, 1); + test_prefetch_deferred(&instance); + for (int channels : {1, 2}) for (int gain : {6, 30}) test_vad_compressor(&instance, channels, gain); // Старый FIN не обращается к новому ядру, даже когда allocator повторно использовал тот же адрес. REQUIRE(radio_audio_start(7, 1) == 0); radio_audio_talk_begin(7); uasync_poll(instance.ua, 0);