Раздели аудиопотоки и привяжи AEC к фактическому общему выходу
This commit is contained in:
1 parent
d7fb0c83f9
commit
bd082a26bf
30 files changed
+1839
-412
No files matched your search
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Изолированный PipeWire/PulseAudio с виртуальным sink; системный сервер не затрагивается."""
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
@@ -54,14 +55,35 @@ def main():
|
||||
return subprocess.run(["pactl", "info"], env=env, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=2).returncode == 0
|
||||
|
||||
def pactl(*args):
|
||||
return subprocess.check_output(["pactl", *args], env=env, text=True, timeout=5)
|
||||
|
||||
def create_sinks():
|
||||
for name in ("utun_test_main", "utun_test_voice"):
|
||||
pactl("load-module", "module-null-sink", f"sink_name={name}")
|
||||
pactl("set-default-sink", "utun_test_main")
|
||||
pactl("set-default-source", "utun_test_main.monitor")
|
||||
|
||||
try:
|
||||
launch(["pipewire"], "pipewire")
|
||||
wait_for(lambda: (directory / "pipewire-0").exists())
|
||||
launch(["dbus-run-session", "--", "wireplumber"], "wireplumber")
|
||||
pulse = launch(["pipewire-pulse"], "pulse-before")
|
||||
wait_for(pulse_ready)
|
||||
# module-always-sink создаёт виртуальный dummy sink и его monitor source.
|
||||
create_sinks()
|
||||
test = launch([str(binary), "--pulse-integration", root], "client")
|
||||
wait_for(lambda: (directory / "routes_ready").exists() or test.poll() is not None)
|
||||
if test.poll() is not None:
|
||||
raise RuntimeError("client failed before route move")
|
||||
sinks = json.loads(pactl("--format=json", "list", "sinks"))
|
||||
main_sink = next(sink["index"] for sink in sinks if sink["name"] == "utun_test_main")
|
||||
streams = json.loads(pactl("--format=json", "list", "sink-inputs"))
|
||||
main_streams = [stream for stream in streams if stream["sink"] == main_sink]
|
||||
if len(main_streams) != 1:
|
||||
raise RuntimeError("expected one shared default stream")
|
||||
# Явно выбранный stream имеет DONT_MOVE; default stream разрешено перемещать сервером.
|
||||
pactl("move-sink-input", str(main_streams[0]["index"]), "utun_test_voice")
|
||||
(directory / "moved").touch()
|
||||
wait_for(lambda: (directory / "ready").exists() or test.poll() is not None)
|
||||
if test.poll() is not None:
|
||||
raise RuntimeError("client failed before server restart")
|
||||
@@ -69,6 +91,7 @@ def main():
|
||||
wait_for(lambda: (directory / "closed").exists() or test.poll() is not None)
|
||||
launch(["pipewire-pulse"], "pulse-after")
|
||||
wait_for(pulse_ready)
|
||||
create_sinks()
|
||||
if test.wait(timeout=15) != 0:
|
||||
raise RuntimeError("client recovery failed")
|
||||
logs[3].flush(); logs[3].seek(0)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* MPSC: резерв управляющих сообщений, публикация слота и порядок каждого producer. */
|
||||
#include "../src/audio_event_queue.h"
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#define REQUIRE(x) do { if (!(x)) { fprintf(stderr, "queue:%d %s\n", __LINE__, #x); abort(); } } while (0)
|
||||
|
||||
static std::atomic<bool> entered{false}, releaseCopy{false};
|
||||
struct Item {
|
||||
int producer = 0, sequence = 0;
|
||||
bool block = false;
|
||||
Item& operator=(const Item& other) {
|
||||
if (other.block) { entered = true; while (!releaseCopy) std::this_thread::yield(); }
|
||||
producer = other.producer; sequence = other.sequence; block = other.block; return *this;
|
||||
}
|
||||
};
|
||||
int main() {
|
||||
AudioEventQueue<Item, 16> queue;
|
||||
Item item{};
|
||||
for (int i = 0; i < 8; ++i) REQUIRE(queue.push(item));
|
||||
REQUIRE(!queue.push(item));
|
||||
for (int i = 0; i < 8; ++i) REQUIRE(queue.push(item, true));
|
||||
REQUIRE(!queue.push(item, true));
|
||||
for (int i = 0; i < 16; ++i) REQUIRE(queue.pop(item));
|
||||
REQUIRE(!queue.pop(item));
|
||||
std::thread first([&] { REQUIRE(queue.push({0, 123, true})); });
|
||||
while (!entered) std::this_thread::yield();
|
||||
REQUIRE(queue.push({1, 456, false}, true));
|
||||
REQUIRE(!queue.pop(item)); // Второй слот опубликован, первый ещё принадлежит producer.
|
||||
releaseCopy = true; first.join();
|
||||
REQUIRE(queue.pop(item) && item.sequence == 123);
|
||||
REQUIRE(queue.pop(item) && item.sequence == 456);
|
||||
queue.reset();
|
||||
constexpr int producers = 4, count = 20000;
|
||||
std::vector<std::thread> threads;
|
||||
for (int p = 0; p < producers; ++p) threads.emplace_back([&, p] {
|
||||
for (int i = 0; i < count; ++i) while (!queue.push({p, i, false}, i % 7 == 0)) std::this_thread::yield();
|
||||
});
|
||||
int expected[producers]{};
|
||||
for (int total = 0; total < producers * count;) {
|
||||
if (!queue.pop(item)) { std::this_thread::yield(); continue; }
|
||||
REQUIRE(queue.size() <= 16);
|
||||
REQUIRE(item.producer >= 0 && item.producer < producers);
|
||||
REQUIRE(item.sequence == expected[item.producer]++); ++total;
|
||||
}
|
||||
for (auto& thread : threads) thread.join();
|
||||
REQUIRE(queue.size() == 0);
|
||||
puts("audio queue: control reserve, unpublished slot, 80000 ordered messages PASS");
|
||||
}
|
||||
@@ -82,11 +82,40 @@ static void pump(int milliseconds) {
|
||||
|
||||
static void pulse_recovery(SoundManager* manager, const QString& control) {
|
||||
REQUIRE(manager->context()->backend == ma_backend_pulseaudio);
|
||||
instance.ua = uasync_create(); REQUIRE(instance.ua);
|
||||
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
|
||||
auto* call = CallAudioEngine::instance(); auto* radio = RadioAudioEngine::instance();
|
||||
REQUIRE(call->start(42) == 0 && radio->start(7) == 0);
|
||||
QString separate;
|
||||
for (const auto& output : manager->enumeratePlaybackDevices()) {
|
||||
ma_device_id id{};
|
||||
if (manager->deviceId(output.id, &id) && !strcmp(id.pulse, "utun_test_voice")) separate = output.id;
|
||||
}
|
||||
REQUIRE(!separate.isEmpty()); call->setPlaybackDevice(separate);
|
||||
AudioRecorder recorder; recorder.init(); recorder.startRecording();
|
||||
std::vector<int16_t> pcm(48000 * 20, 1000);
|
||||
auto pcmId = manager->playRawPcm(pcm.data(), (int)pcm.size(), 48000, 1);
|
||||
for (int i = 0; i < 100 && recorder.durationMs() < 300; ++i) pump(50);
|
||||
REQUIRE(recorder.durationMs() >= 300 && manager->pcmCursor(pcmId) > 0);
|
||||
QFile routesReady(control + "/routes_ready"); REQUIRE(routesReady.open(QIODevice::WriteOnly)); routesReady.close();
|
||||
for (int i = 0; i < 100 && !QFile::exists(control + "/moved"); ++i) pump(50);
|
||||
REQUIRE(QFile::exists(control + "/moved"));
|
||||
pump(500);
|
||||
int outputs = 0;
|
||||
for (auto* device : devices) if (device->type == ma_device_type_playback) ++outputs;
|
||||
REQUIRE(outputs == 1); // Сервер переместил default stream на voice sink: получился один микшер.
|
||||
qInfo("PulseAudio route move: call/radio/media merged into one output PASS");
|
||||
for (auto* device : devices) if (device->type == ma_device_type_playback) REQUIRE(ma_device_stop(device) == MA_SUCCESS);
|
||||
pump(1000); outputs = 0;
|
||||
uint64_t wantedRoute = UINT64_C(14695981039346656037);
|
||||
for (const char* name = "utun_test_voice"; *name; ++name) { wantedRoute ^= (unsigned char)*name; wantedRoute *= UINT64_C(1099511628211); }
|
||||
bool foundWanted = false;
|
||||
for (auto* device : devices) if (device->type == ma_device_type_playback) {
|
||||
++outputs;
|
||||
if (static_cast<AudioDevice*>(device->pUserData)->route() == wantedRoute) foundWanted = true;
|
||||
}
|
||||
REQUIRE(foundWanted && outputs >= 1 && outputs <= 2); // Сервер может восстановить прежний move для default stream.
|
||||
qInfo("PulseAudio output recovery: desired call route preserved outputs=%d PASS", outputs);
|
||||
int duration = recorder.durationMs();
|
||||
auto cursor = manager->pcmCursor(pcmId);
|
||||
QFile ready(control + "/ready"); REQUIRE(ready.open(QIODevice::WriteOnly)); ready.close();
|
||||
@@ -95,9 +124,12 @@ static void pulse_recovery(SoundManager* manager, const QString& control) {
|
||||
QFile closed(control + "/closed"); REQUIRE(closed.open(QIODevice::WriteOnly)); closed.close();
|
||||
for (int i = 0; i < 150 && (!manager->context() || recorder.durationMs() <= duration + 200); ++i) pump(50);
|
||||
REQUIRE(manager->context() && recorder.durationMs() > duration + 200 && manager->pcmCursor(pcmId) > cursor);
|
||||
REQUIRE(call->isActive() && call->callId() == 42 && radio->isActive() && radio->groupId() == 7);
|
||||
qInfo("PulseAudio progress: capture=%d -> %dms playback=%llu -> %llu frames", duration, recorder.durationMs(),
|
||||
(unsigned long long)cursor, (unsigned long long)manager->pcmCursor(pcmId));
|
||||
recorder.stopRecording(); manager->shutdown();
|
||||
call->stop(); radio->stop(); recorder.stopRecording();
|
||||
call_audio_destroy(&instance); radio_audio_destroy(&instance); uasync_destroy(instance.ua, 0); instance.ua = nullptr;
|
||||
manager->shutdown();
|
||||
REQUIRE(devices.empty());
|
||||
qInfo("real PulseAudio: capture/playback progress restored after server restart PASS");
|
||||
}
|
||||
@@ -144,6 +176,12 @@ int main(int argc, char** argv) {
|
||||
REQUIRE(a.pcm[0] == 1000 && b.pcm[0] == 1000); // pUserData маршрутизирует каждый callback своему recorder.
|
||||
a.pcm_release(a.pcm_owner); b.pcm_release(b.pcm_owner);
|
||||
}
|
||||
{
|
||||
AudioRecorder meter; meter.init(); meter.startRecording(true); pump(60); meter.stopRecording();
|
||||
attachment_send_req request{};
|
||||
REQUIRE(meter.durationMs() > 0 && !meter.takeRecording(&request));
|
||||
REQUIRE(!request.pcm && !request.pcm_owner && !request.compressor);
|
||||
}
|
||||
instance.ua = uasync_create(); REQUIRE(instance.ua);
|
||||
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
|
||||
auto* call = CallAudioEngine::instance();
|
||||
@@ -151,7 +189,7 @@ int main(int argc, char** argv) {
|
||||
pump(60);
|
||||
ma_device* playback = nullptr;
|
||||
for (auto* device : devices)
|
||||
if (device->type == ma_device_type_playback && device->playback.format == ma_format_s16 && device->playback.channels == 1)
|
||||
if (device->type == ma_device_type_playback && device->playback.format == ma_format_f32 && device->playback.channels == 2)
|
||||
playback = device;
|
||||
REQUIRE(playback);
|
||||
int previousCapture = captureStarts, previousPlayback = playbackStarts;
|
||||
@@ -203,10 +241,22 @@ int main(int argc, char** argv) {
|
||||
manager->pausePcm(player24);
|
||||
cursor24 = manager->pcmCursor(player24);
|
||||
pump(80);
|
||||
REQUIRE(manager->pcmCursor(player24) == cursor24 && manager->pcmCursor(player48) > cursor48);
|
||||
uint64_t pausedAfter = manager->pcmCursor(player24), neighbourAfter = manager->pcmCursor(player48);
|
||||
qInfo("PCM pause: paused=%llu->%llu neighbour=%llu->%llu", (unsigned long long)cursor24, (unsigned long long)pausedAfter,
|
||||
(unsigned long long)cursor48, (unsigned long long)neighbourAfter);
|
||||
REQUIRE(pausedAfter == cursor24 && neighbourAfter > cursor48);
|
||||
REQUIRE(manager->seekPcm(player24, 12000));
|
||||
REQUIRE(manager->pcmCursor(player24) == 12000 && !manager->isPcmPlaying(player24));
|
||||
manager->resumePcm(player24);
|
||||
// Пауза синхронизируется с уже начатым чтением графа, сосед продолжает играть.
|
||||
for (int i = 0; i < 50; ++i) {
|
||||
pump(1);
|
||||
manager->pausePcm(player24);
|
||||
uint64_t paused = manager->pcmCursor(player24);
|
||||
pump(1);
|
||||
REQUIRE(manager->pcmCursor(player24) == paused);
|
||||
manager->resumePcm(player24);
|
||||
}
|
||||
manager->stopPcm(player48);
|
||||
pump(60);
|
||||
REQUIRE(manager->pcmCursor(player24) > 12000 && manager->pcmCursor(player48) == 0);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/* PA snapshot без сервера: несколько converter callbacks принадлежат одному physical PCM-блоку. */
|
||||
#define MA_DATA_CONVERTER_STACK_BUFFER_SIZE 128
|
||||
#include "../transport/miniaudio_impl.c"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
static int64_t times[64];
|
||||
static unsigned counts[64], seen;
|
||||
static int capture_missing;
|
||||
static int fake_latency(const void* stream, uint64_t* latency, int* negative) {
|
||||
(void)stream; *latency = 100000; *negative = 0; return 0;
|
||||
}
|
||||
static void observe(ma_device* device, void* output, const void* input, ma_uint32 frames) {
|
||||
(void)output; (void)input;
|
||||
int measured = 0;
|
||||
assert(seen < 64);
|
||||
times[seen] = audio_stream_frame_time(device, frames, &measured);
|
||||
counts[seen++] = frames;
|
||||
assert(measured && seen < 64);
|
||||
assert(audio_stream_capture_missing(device) == capture_missing);
|
||||
}
|
||||
static int64_t time_us(void) {
|
||||
struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return now.tv_sec * INT64_C(1000000) + now.tv_nsec / 1000;
|
||||
}
|
||||
int main(void) {
|
||||
/* Только dispatch/converter: backend union не подменяется у живого null-device. */
|
||||
ma_context context = {0}; ma_device device = {0};
|
||||
device.pContext = &context; device.type = ma_device_type_capture;
|
||||
device.sampleRate = 48000; device.noFixedSizedCallback = MA_TRUE; device.onData = observe;
|
||||
device.capture.format = ma_format_s16; device.capture.channels = 1;
|
||||
device.capture.internalFormat = ma_format_f32; device.capture.internalChannels = 2;
|
||||
assert(ma_device_set_master_volume(&device, 1) == MA_SUCCESS);
|
||||
ma_data_converter_config converter = ma_data_converter_config_init(ma_format_f32, ma_format_s16, 2, 1, 48000, 48000);
|
||||
assert(ma_data_converter_init(&converter, NULL, &device.capture.converter) == MA_SUCCESS);
|
||||
device.capture.internalSampleRate = 48000;
|
||||
context.backend = ma_backend_pulseaudio;
|
||||
context.pulse.pa_stream_get_latency = (ma_proc)fake_latency;
|
||||
device.pulse.pStreamCapture = (void*)1;
|
||||
float input[180 * 2] = {0};
|
||||
int64_t now = time_us();
|
||||
assert(ma_device_handle_backend_data_callback(&device, NULL, input, 180) == MA_SUCCESS);
|
||||
int64_t after = time_us();
|
||||
assert(seen == 3 && counts[0] == 64 && counts[1] == 64 && counts[2] == 52);
|
||||
assert(times[0] >= now - 100000 && times[0] <= after - 100000);
|
||||
for (unsigned i = 1; i < seen; ++i) {
|
||||
int64_t difference = times[i] - times[0] - (int64_t)i * 64 * 1000000 / 48000;
|
||||
assert(difference >= -1 && difference <= 1);
|
||||
}
|
||||
capture_missing = 1; seen = 0; audio_capture_hole(120);
|
||||
now = time_us();
|
||||
assert(ma_device_handle_backend_data_callback(&device, NULL, input, 64) == MA_SUCCESS);
|
||||
after = time_us();
|
||||
assert(seen == 1 && times[0] >= now - 100000 + 2500 && times[0] <= after - 100000 + 2500);
|
||||
assert(!audio_stream_capture_missing(&device)); // Hint действует только внутри dispatch.
|
||||
ma_data_converter_uninit(&device.capture.converter, NULL);
|
||||
puts("audio timing: capture read index, converter offsets, explicit hole PASS");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* Настоящие workers/AEC; граница сервисного PCM заменена наблюдателями без сети/устройства. */
|
||||
#include "../src/voice_audio_io.h"
|
||||
extern "C" {
|
||||
#include "call/call_audio.h"
|
||||
#include "radio/radio_audio.h"
|
||||
#include "debug_config.h"
|
||||
}
|
||||
#include <vector>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
#define REQUIRE(x) do { if (!(x)) { fprintf(stderr, "voice-io:%d %s\n", __LINE__, #x); abort(); } } while (0)
|
||||
static std::mutex mutex;
|
||||
static std::vector<int16_t> capture, transmitted;
|
||||
static std::vector<uint8_t> masks;
|
||||
static bool talking;
|
||||
static std::atomic<int> pulls{0}, blocks{0};
|
||||
static std::atomic<bool> stall{false}, stalled{false};
|
||||
extern "C" int call_audio_pull_pcm(uint64_t, int16_t* out, int count) { ++pulls; std::fill_n(out, count, 1000); return count; }
|
||||
extern "C" int radio_audio_pull_pcm(uint64_t, int16_t* out, int count) {
|
||||
++pulls; for (int i = 0; i < count; ++i) out[i] = i % 2 ? 2000 : 1000; return count;
|
||||
}
|
||||
extern "C" void call_audio_reset_io(uint64_t, int) {}
|
||||
extern "C" void radio_audio_reset_playback(uint64_t) {}
|
||||
extern "C" void radio_audio_capture_discontinuity(uint64_t) {}
|
||||
extern "C" void radio_audio_talk_begin(uint64_t) { std::lock_guard lock(mutex); talking = true; }
|
||||
extern "C" void radio_audio_talk_end(uint64_t) { std::lock_guard lock(mutex); talking = false; }
|
||||
extern "C" int radio_audio_feed_pcm(uint64_t, const int16_t* pcm, int count) {
|
||||
if (stall) { stalled = true; while (stall) std::this_thread::sleep_for(std::chrono::milliseconds(1)); }
|
||||
std::lock_guard lock(mutex);
|
||||
capture.insert(capture.end(), pcm, pcm + count);
|
||||
if (talking) transmitted.insert(transmitted.end(), pcm, pcm + count);
|
||||
++blocks; return 0;
|
||||
}
|
||||
extern "C" int call_audio_feed_prepared_pcm(uint64_t, const int16_t* pcm, const uint8_t* mask) {
|
||||
std::lock_guard lock(mutex);
|
||||
capture.insert(capture.end(), pcm, pcm + 960); masks.insert(masks.end(), mask, mask + 960);
|
||||
++blocks; return 0;
|
||||
}
|
||||
static int64_t now_us() {
|
||||
return std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
template<class F> static void await(F ready) {
|
||||
for (int i = 0; i < 2000 && !ready(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
REQUIRE(ready());
|
||||
}
|
||||
static void reset() { std::lock_guard lock(mutex); capture.clear(); transmitted.clear(); masks.clear(); talking = false; blocks = 0; }
|
||||
int main() {
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
|
||||
VoiceAudioIo voice;
|
||||
std::array<int16_t, 960> pcm{}; pcm.fill(1234);
|
||||
voice.start(VoiceAudioIo::Kind::Radio, 7); REQUIRE(voice.startCapture(1, false));
|
||||
voice.capture(pcm.data(), 240, 0); voice.setPtt(true);
|
||||
voice.capture(pcm.data(), 360, 0); voice.setPtt(false); voice.capture(pcm.data(), 360, 0);
|
||||
await([] { return blocks.load() >= 3; }); voice.stopCapture();
|
||||
REQUIRE(capture.size() == 960 && transmitted.size() == 360 && !talking);
|
||||
reset(); REQUIRE(voice.startCapture(1, false));
|
||||
voice.setPtt(true); voice.capture(pcm.data(), 120, 0); voice.setPtt(false); voice.stopCapture();
|
||||
REQUIRE(capture.size() == 120 && transmitted.size() == 120 && !talking);
|
||||
reset(); REQUIRE(voice.startCapture(1, false)); stall = true;
|
||||
voice.capture(pcm.data(), 960, 0); await([] { return stalled.load(); });
|
||||
int before = pulls;
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
float output[1920]{}; voice.mix(output, 960);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(3));
|
||||
}
|
||||
REQUIRE(pulls > before + 3); stall = false; voice.stopCapture(); voice.stop();
|
||||
reset(); voice.start(VoiceAudioIo::Kind::Call, 42); REQUIRE(voice.startCapture(1, false));
|
||||
voice.capture(pcm.data(), 480, 0); voice.setMuted(true); voice.capture(pcm.data(), 480, 0);
|
||||
await([] { return blocks.load() == 1; }); voice.stopCapture();
|
||||
REQUIRE(masks.size() == 960);
|
||||
for (int i = 0; i < 960; ++i) REQUIRE(masks[i] == (i >= 480) && capture[i] == 1234);
|
||||
voice.stop(); reset();
|
||||
voice.start(VoiceAudioIo::Kind::Call, 42); REQUIRE(voice.startCapture(1, false));
|
||||
voice.capture(pcm.data(), 960, now_us() - 300000); // Backend latency не является возрастом worker-очереди.
|
||||
await([] { return blocks.load() == 1; }); voice.stopCapture(); voice.stop();
|
||||
REQUIRE(capture.size() == 960); reset();
|
||||
// Референс противофазный: mono downmix дал бы ноль и не смог подавить эхо.
|
||||
voice.start(VoiceAudioIo::Kind::Call, 43); REQUIRE(voice.startCapture(1, true));
|
||||
uint32_t rng = 1234567;
|
||||
double echoPower = 0, outputPower = 0;
|
||||
int64_t time = now_us();
|
||||
for (int frame = 0; frame < 240; ++frame) {
|
||||
float reference[1920];
|
||||
for (int i = 0; i < 960; ++i) {
|
||||
rng = rng * 1664525u + 1013904223u;
|
||||
int16_t sample = (int16_t)((rng >> 18) - 8192);
|
||||
reference[2*i] = sample / 32768.0f; reference[2*i+1] = -reference[2*i];
|
||||
pcm[i] = sample / 2;
|
||||
if (frame >= 180) echoPower += (double)pcm[i] * pcm[i];
|
||||
}
|
||||
voice.reference(reference, 960, time + frame * 20000);
|
||||
voice.capture(pcm.data(), 960, time + frame * 20000);
|
||||
await([&] { return blocks.load() == frame + 1; });
|
||||
}
|
||||
voice.stopCapture(); voice.stop();
|
||||
for (size_t i = 180 * 960; i < capture.size(); ++i) outputPower += (double)capture[i] * capture[i];
|
||||
printf("stereo reference residual ratio=%.6f\n", outputPower / echoPower);
|
||||
REQUIRE(outputPower < echoPower * 0.1);
|
||||
puts("voice workers: exact PTT/mute boundaries, short tail, independent RX, stereo AEC PASS");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Реальные codec/AGC/автоматы/очередь uasync; наблюдаем сетевую границу вместо отправки пакетов. */
|
||||
extern "C" {
|
||||
#include "utun_instance.h"
|
||||
#include "radio/radio_audio.h"
|
||||
#include "call/call_audio.h"
|
||||
#include "opus_codec.h"
|
||||
#include "debug_config.h"
|
||||
#include "u_async.h"
|
||||
#include "silero_vad.h"
|
||||
}
|
||||
#include <vector>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#define REQUIRE(x) do { if (!(x)) { fprintf(stderr, "voice-tx:%d %s\n", __LINE__, #x); abort(); } } while (0)
|
||||
static std::vector<char> events;
|
||||
static std::vector<std::vector<uint8_t>> packets;
|
||||
static float probability;
|
||||
static int vad_windows;
|
||||
static std::atomic<bool> stall_vad{false}, vad_stalled{false};
|
||||
extern "C" silero_vad_t* __wrap_silero_vad_create_default() { return reinterpret_cast<silero_vad_t*>(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) {
|
||||
if (stall_vad) { vad_stalled = true; while (stall_vad) std::this_thread::sleep_for(std::chrono::milliseconds(1)); }
|
||||
++vad_windows; *result = probability; return 0;
|
||||
}
|
||||
extern "C" int __wrap_radio_talk_begin(UTUN_INSTANCE*, uint64_t) { events.push_back('B'); return 0; }
|
||||
extern "C" int __wrap_radio_talk_end(UTUN_INSTANCE*, uint64_t) { events.push_back('F'); return 0; }
|
||||
extern "C" int __wrap_radio_talk_send(UTUN_INSTANCE*, uint64_t, const uint8_t* data, int len) {
|
||||
events.push_back('M'); packets.emplace_back(data, data + len); return 0;
|
||||
}
|
||||
extern "C" int __wrap_call_send_media(UTUN_INSTANCE*, uint64_t, const uint8_t* data, int len) {
|
||||
packets.emplace_back(data, data + len); return 0;
|
||||
}
|
||||
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_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) {
|
||||
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));
|
||||
}
|
||||
int main() {
|
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
|
||||
UTUN_INSTANCE instance{}; instance.ua = uasync_create(); REQUIRE(instance.ua);
|
||||
REQUIRE(radio_audio_init(&instance) == 0 && call_audio_init(&instance) == 0);
|
||||
REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, 1, 0) == 0);
|
||||
std::array<int16_t, 1920> input{};
|
||||
for (int i = 0; i < 1920; ++i) input[i] = (int16_t)(12000 * sin(i * 0.1));
|
||||
radio_audio_talk_begin(7); radio_audio_talk_begin(7);
|
||||
REQUIRE(radio_audio_feed_pcm(7, input.data(), 1200) == 0); radio_audio_talk_end(7);
|
||||
radio_audio_talk_begin(7); REQUIRE(radio_audio_feed_pcm(7, input.data() + 1200, 120) == 0); radio_audio_talk_end(7);
|
||||
uasync_poll(instance.ua, 0);
|
||||
REQUIRE(events == std::vector<char>({'B','M','M','F','B','M','F'}));
|
||||
auto* encoder = opus_codec_encoder_create(48000, 1); REQUIRE(encoder);
|
||||
check_packet(encoder, input.data(), 0);
|
||||
std::array<int16_t, 960> tail{}; memcpy(tail.data(), input.data() + 960, 240 * sizeof(int16_t));
|
||||
check_packet(encoder, tail.data(), 1);
|
||||
tail.fill(0); memcpy(tail.data(), input.data() + 1200, 120 * sizeof(int16_t)); check_packet(encoder, tail.data(), 2);
|
||||
// Разрыв не склеивает отсчёты до и после потери внутри одного Opus-кадра.
|
||||
packets.clear(); events.clear();
|
||||
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data(), 100);
|
||||
radio_audio_capture_discontinuity(7); radio_audio_feed_pcm(7, input.data() + 100, 100); radio_audio_talk_end(7);
|
||||
uasync_poll(instance.ua, 0);
|
||||
REQUIRE(events == std::vector<char>({'B','M','M','F'}));
|
||||
tail.fill(0); memcpy(tail.data(), input.data(), 100 * sizeof(int16_t)); check_packet(encoder, tail.data(), 0);
|
||||
tail.fill(0); memcpy(tail.data(), input.data() + 100, 100 * sizeof(int16_t)); check_packet(encoder, tail.data(), 1);
|
||||
opus_codec_encoder_destroy(encoder);
|
||||
// Capture teardown завершает принятую серию до FIN; следующее capture не получает старый хвост.
|
||||
packets.clear(); events.clear();
|
||||
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data(), 120); radio_audio_capture_stop();
|
||||
REQUIRE(radio_audio_capture_start(7, 1, 0) == 0);
|
||||
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data() + 120, 120); radio_audio_talk_end(7);
|
||||
uasync_poll(instance.ua, 0);
|
||||
REQUIRE(events == std::vector<char>({'B','M','F','B','M','F'}));
|
||||
// Старое прослушивание того же group ID отменено: старые BEGIN/media не попадают в новую сессию.
|
||||
packets.clear(); events.clear();
|
||||
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data(), 120); radio_audio_stop();
|
||||
REQUIRE(radio_audio_start(7, 1) == 0);
|
||||
uasync_poll(instance.ua, 0);
|
||||
REQUIRE(events.empty() && packets.empty());
|
||||
// У остановленного core ограничены не только media, но и быстрые PTT-команды.
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
radio_audio_talk_begin(7); radio_audio_feed_pcm(7, input.data(), 120); radio_audio_talk_end(7);
|
||||
}
|
||||
int pending = 0;
|
||||
for (auto* task = instance.ua->posted_tasks_head; task; task = task->next) ++pending;
|
||||
REQUIRE(pending <= 24); // 16 control + 8 media, FIN каждой принятой серии уже зарезервирован.
|
||||
uasync_poll(instance.ua, 0);
|
||||
packets.clear(); events.clear(); radio_audio_capture_stop();
|
||||
REQUIRE(radio_audio_capture_start(7, 1, 1) == 0 && radio_audio_vad_mode());
|
||||
auto feed = [&](int frames) {
|
||||
while (frames) { int count = std::min(frames, 1920); REQUIRE(radio_audio_feed_pcm(7, input.data(), count) == 0); frames -= count; }
|
||||
};
|
||||
probability = 0.9f; radio_audio_talk_begin(7); feed(3072); // Два окна VAD даже во время manual PTT.
|
||||
REQUIRE(vad_windows == 2); radio_audio_talk_end(7); REQUIRE(radio_audio_transmitting());
|
||||
probability = 0; feed(3072); REQUIRE(!radio_audio_transmitting()); // Первое окно начинает silence, второе завершает hangover.
|
||||
uasync_poll(instance.ua, 0);
|
||||
REQUIRE(std::count(events.begin(), events.end(), 'B') == 1 && std::count(events.begin(), events.end(), 'F') == 1);
|
||||
packets.clear(); events.clear();
|
||||
probability = 0.9f; feed(3072); REQUIRE(radio_audio_transmitting());
|
||||
radio_audio_talk_begin(7); probability = 0; feed(3072); REQUIRE(radio_audio_transmitting());
|
||||
radio_audio_talk_end(7); uasync_poll(instance.ua, 0);
|
||||
REQUIRE(std::count(events.begin(), events.end(), 'B') == 1 && std::count(events.begin(), events.end(), 'F') == 1);
|
||||
stall_vad = true;
|
||||
std::thread tx([&] { feed(1536); });
|
||||
for (int i = 0; i < 200 && !vad_stalled; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
REQUIRE(vad_stalled);
|
||||
std::atomic<bool> rx_finished{false};
|
||||
std::thread rx([&] { int16_t out[1920]; REQUIRE(radio_audio_pull_pcm(7, out, 1920) == 1920); rx_finished = true; });
|
||||
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);
|
||||
// Старый FIN не обращается к новому ядру, даже когда allocator повторно использовал тот же адрес.
|
||||
REQUIRE(radio_audio_start(7, 1) == 0);
|
||||
radio_audio_talk_begin(7); uasync_poll(instance.ua, 0);
|
||||
radio_audio_talk_end(7); radio_audio_stop(); radio_audio_destroy(&instance);
|
||||
REQUIRE(radio_audio_init(&instance) == 0);
|
||||
events.clear(); uasync_poll(instance.ua, 0); REQUIRE(events.empty());
|
||||
// Prepared policy исключает повторный core AEC; mute применяется только к нужной части кадра.
|
||||
packets.clear(); REQUIRE(call_audio_start_prepared(42) == 0);
|
||||
REQUIRE(call_audio_start(42) == -1);
|
||||
REQUIRE(call_audio_feed_pcm(42, input.data(), 960) == -1);
|
||||
std::array<uint8_t, 960> mask{};
|
||||
std::fill(mask.begin() + 300, mask.end(), 1);
|
||||
REQUIRE(call_audio_feed_prepared_pcm(42, input.data(), mask.data()) == 0);
|
||||
uasync_poll(instance.ua, 0);
|
||||
encoder = opus_codec_encoder_create(48000, 1); REQUIRE(encoder);
|
||||
tail.fill(0); memcpy(tail.data(), input.data(), 300 * sizeof(int16_t)); check_packet(encoder, tail.data(), 0);
|
||||
call_audio_stop(); opus_codec_encoder_destroy(encoder);
|
||||
call_audio_destroy(&instance); radio_audio_destroy(&instance); uasync_destroy(instance.ua, 0);
|
||||
puts("voice TX: exact Opus tail, rapid bursts, discontinuity, capture policy, final mute PASS");
|
||||
}
|
||||
Reference in new issue
Block a user