584 lines
34 KiB
C++
584 lines
34 KiB
C++
/* Настоящие null/PulseAudio devices и Qt timers; wrappers инъецируют сбои либо параметры ручного замера. */
|
||
#include "../src/audio_device.h"
|
||
#include "../src/call_audio_engine.h"
|
||
#include "../src/radio_audio_engine.h"
|
||
#include "../src/audiorecorder.h"
|
||
#include "../src/sound_manager.h"
|
||
#include <QCoreApplication>
|
||
#include <QThread>
|
||
#include <QFile>
|
||
#include <cstring>
|
||
#include <algorithm>
|
||
#include <map>
|
||
#include <vector>
|
||
#include <mutex>
|
||
extern "C" {
|
||
#include "utun_instance.h"
|
||
#include "call/call_audio.h"
|
||
#include "radio/radio_audio.h"
|
||
#include "radio/radio.h"
|
||
#include "media_async/attachment_send.h"
|
||
#include "u_async.h"
|
||
#include "debug_config.h"
|
||
}
|
||
|
||
#define REQUIRE(condition) do { if (!(condition)) qFatal("audio recovery line %d: %s", __LINE__, #condition); } while (0)
|
||
static UTUN_INSTANCE instance{};
|
||
static std::map<ma_context*, int> live;
|
||
static std::vector<ma_device*> devices;
|
||
static int starts, captureStarts, playbackStarts, contextStarts, failContexts;
|
||
static bool failStart, failContext, failDevice;
|
||
static bool pulseIntegration;
|
||
static bool latencyProbe;
|
||
static unsigned probeCaptureFrames, probePlaybackFrames;
|
||
static std::mutex deviceMutex;
|
||
static std::map<ma_device*, ma_device_data_proc> captureCallbacks;
|
||
static std::atomic<int> microphoneFault{0}, captureCloses{0}; // 1: нет callback, 2: nullptr, 3: frames=0.
|
||
static std::atomic<bool> slowCaptureClose{false};
|
||
|
||
static void microphoneData(ma_device* device, void* out, const void* in, ma_uint32 frames) {
|
||
ma_device_data_proc callback;
|
||
{ std::lock_guard guard(deviceMutex); callback = captureCallbacks.at(device); }
|
||
int fault = microphoneFault.load();
|
||
if (fault == 1) return;
|
||
callback(device, out, fault == 2 ? nullptr : in, fault == 3 ? 0 : frames);
|
||
}
|
||
|
||
extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; }
|
||
extern "C" int gui_bridge_is_uasync_ready() { return instance.ua != nullptr; }
|
||
extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { uasync_post(instance.ua, fn, arg); }
|
||
extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*);
|
||
extern "C" void __real_ma_context_uninit(ma_context*);
|
||
extern "C" ma_result __real_ma_device_init(ma_context*, const ma_device_config*, ma_device*);
|
||
extern "C" ma_result __real_ma_device_start(ma_device*);
|
||
extern "C" void __real_ma_device_uninit(ma_device*);
|
||
extern "C" int __real_audio_context_failed(ma_context*);
|
||
extern "C" int __real_call_send_media(UTUN_INSTANCE*, uint64_t, const uint8_t*, int, const call_capture_time*);
|
||
extern "C" int __wrap_call_send_media(UTUN_INSTANCE* inst, uint64_t id, const uint8_t* data, int size, const call_capture_time* timing) {
|
||
return latencyProbe ? 0 : __real_call_send_media(inst, id, data, size, timing);
|
||
}
|
||
|
||
extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context* context) {
|
||
++contextStarts;
|
||
if (failContexts > 0) { --failContexts; return MA_ERROR; }
|
||
ma_backend backend = pulseIntegration ? ma_backend_pulseaudio : ma_backend_null;
|
||
return __real_ma_context_init(&backend, 1, nullptr, context);
|
||
}
|
||
extern "C" void __wrap_ma_context_uninit(ma_context* context) {
|
||
REQUIRE(live[context] == 0);
|
||
__real_ma_context_uninit(context);
|
||
}
|
||
extern "C" ma_result __wrap_ma_device_init(ma_context* context, const ma_device_config* config, ma_device* device) {
|
||
if (failDevice) { failDevice = false; return MA_ERROR; }
|
||
ma_device_config requested = *config;
|
||
if (config->deviceType == ma_device_type_capture) {
|
||
{ std::lock_guard guard(deviceMutex); captureCallbacks[device] = config->dataCallback; }
|
||
requested.dataCallback = microphoneData;
|
||
}
|
||
// Второй виртуальный вход: отдельный null device с тем же аппаратным драйвером.
|
||
if (context->backend == ma_backend_null && config->deviceType == ma_device_type_capture &&
|
||
config->capture.pDeviceID && config->capture.pDeviceID->nullbackend == 1) requested.capture.pDeviceID = nullptr;
|
||
if (latencyProbe) {
|
||
requested.periodSizeInMilliseconds = 0;
|
||
requested.periodSizeInFrames = config->deviceType == ma_device_type_capture ? probeCaptureFrames : probePlaybackFrames;
|
||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "latency-probe: override type=%d period_frames=%u rate=%u",
|
||
config->deviceType, requested.periodSizeInFrames, requested.sampleRate);
|
||
}
|
||
ma_result result = __real_ma_device_init(context, &requested, device);
|
||
{ std::lock_guard guard(deviceMutex);
|
||
if (result == MA_SUCCESS) { ++live[context]; devices.push_back(device); }
|
||
else captureCallbacks.erase(device); }
|
||
return result;
|
||
}
|
||
extern "C" ma_result __wrap_ma_device_start(ma_device* device) {
|
||
++starts;
|
||
if (device->type == ma_device_type_capture) {
|
||
++captureStarts;
|
||
// Некоторые backends вызывают data callback ещё до возврата start.
|
||
std::vector<int16_t> input(1200 * device->capture.channels, 1000);
|
||
if (!pulseIntegration) device->onData(device, nullptr, input.data(), 1200);
|
||
} else ++playbackStarts;
|
||
if (failStart) { failStart = false; return MA_ERROR; }
|
||
return __real_ma_device_start(device);
|
||
}
|
||
extern "C" void __wrap_ma_device_uninit(ma_device* device) {
|
||
auto context = device->pContext;
|
||
bool capture = device->type == ma_device_type_capture;
|
||
if (capture && slowCaptureClose) QThread::msleep(700);
|
||
__real_ma_device_uninit(device);
|
||
std::lock_guard guard(deviceMutex);
|
||
REQUIRE(live[context] > 0);
|
||
--live[context];
|
||
devices.erase(std::find(devices.begin(), devices.end(), device));
|
||
captureCallbacks.erase(device);
|
||
if (capture) captureCloses++;
|
||
}
|
||
extern "C" int __wrap_audio_context_failed(ma_context* context) {
|
||
if (failContext) { failContext = false; return 1; }
|
||
return __real_audio_context_failed(context);
|
||
}
|
||
|
||
static void pump(int milliseconds) {
|
||
QElapsedTimer timer; timer.start();
|
||
while (timer.elapsed() < milliseconds) {
|
||
QCoreApplication::processEvents();
|
||
if (latencyProbe && instance.ua) uasync_poll(instance.ua, 0);
|
||
QThread::msleep(1);
|
||
}
|
||
}
|
||
|
||
static void shared_capture(SoundManager* manager) {
|
||
auto inputs = manager->enumerateCaptureDevices();
|
||
REQUIRE(!inputs.empty());
|
||
QString selected;
|
||
for (const auto& input : inputs) if (input.isDefault) selected = input.id;
|
||
REQUIRE(!selected.isEmpty());
|
||
AudioCapture stream, radio;
|
||
std::atomic<unsigned> streamFrames{0}, radioFrames{0};
|
||
std::atomic<int64_t> streamTime{0};
|
||
std::atomic<bool> compareTime{false};
|
||
int streamReady = 0, radioReady = 0, streamStopped = 0, radioStopped = 0, radioChannels = 0;
|
||
auto startStream = [&] {
|
||
return stream.open(selected, AudioCapture::Role::Stream, 2, 1, [&](int channels, int) {
|
||
REQUIRE(channels == 2); ++streamReady; return true;
|
||
}, [&] { ++streamStopped; }, [&](const AudioCapture::Frame& frame) {
|
||
REQUIRE(frame.channels == 2 && frame.timeUs > 0 && frame.pcm);
|
||
streamTime = frame.timeUs; streamFrames += frame.frames;
|
||
});
|
||
};
|
||
auto startRadio = [&] {
|
||
return radio.open({}, AudioCapture::Role::Radio, 1, 2, [&](int channels, int) {
|
||
radioChannels = channels; ++radioReady; return true;
|
||
}, [&] { ++radioStopped; }, [&](const AudioCapture::Frame& frame) {
|
||
REQUIRE(frame.channels == radioChannels && frame.pcm);
|
||
if (compareTime) REQUIRE(frame.timeUs == streamTime.load());
|
||
radioFrames += frame.frames;
|
||
});
|
||
};
|
||
int before = captureStarts;
|
||
REQUIRE(startStream()); REQUIRE(startRadio()); compareTime = true; pump(80);
|
||
REQUIRE(captureStarts == before + 1 && radioChannels == 2 && streamReady == 1 && radioReady == 1);
|
||
REQUIRE(streamFrames > 0 && radioFrames > 0);
|
||
radio.close(); auto captured = streamFrames.load(); pump(60);
|
||
REQUIRE(streamFrames > captured && captureStarts == before + 1 && streamStopped == 0);
|
||
REQUIRE(startRadio()); REQUIRE(captureStarts == before + 1);
|
||
// Detach stream не закрывает устройство и не меняет формат оставшейся рации.
|
||
compareTime = false; stream.close(); streamTime = 0; captured = radioFrames.load(); pump(60);
|
||
REQUIRE(radio.running() && radioFrames > captured && radioChannels == 2 && captureStarts == before + 1);
|
||
radio.close(); REQUIRE(streamStopped == 1 && radioStopped == 2);
|
||
// Обратный порядок: новый stream заменяет mono-профиль рации на свой stereo.
|
||
before = captureStarts;
|
||
REQUIRE(startRadio()); REQUIRE(radioChannels == 1);
|
||
REQUIRE(startStream()); compareTime = true; pump(60);
|
||
REQUIRE(captureStarts == before + 2 && radioChannels == 2 && stream.running() && radio.running());
|
||
// Потеря устройства: обе подписки восстанавливаются одним открытием.
|
||
for (auto* device : devices) if (device->type == ma_device_type_capture) REQUIRE(ma_device_stop(device) == MA_SUCCESS);
|
||
stream.recover(); REQUIRE(!stream.running() && !radio.running());
|
||
pump(300); radio.recover();
|
||
REQUIRE(stream.running() && radio.running() && captureStarts == before + 3);
|
||
radio.close(); stream.close();
|
||
compareTime = false;
|
||
// Разные выбранные устройства сохраняют независимые форматы и жизненные циклы.
|
||
ma_device_id secondId{}; secondId.nullbackend = 1;
|
||
QString second = QString::fromLatin1(ma_get_backend_name(manager->context()->backend)) + '/' +
|
||
QByteArray(reinterpret_cast<const char*>(&secondId), sizeof(secondId)).toBase64();
|
||
before = captureStarts;
|
||
REQUIRE(startStream());
|
||
REQUIRE(radio.open(second, AudioCapture::Role::Radio, 1, 2, [](int channels, int) { REQUIRE(channels == 1); return true; },
|
||
[] {}, [](const AudioCapture::Frame& frame) { REQUIRE(frame.channels == 1); }));
|
||
REQUIRE(captureStarts == before + 2);
|
||
captured = streamFrames.load(); radio.close(); pump(60);
|
||
REQUIRE(streamFrames > captured && captureStarts == before + 2);
|
||
// Неверный явный выбор не заменяется default и не отбирает захват трансляции.
|
||
REQUIRE(!radio.open(QStringLiteral("invalid-input"), AudioCapture::Role::Radio, 1, 2, [](int, int) { return true; },
|
||
[] {}, [](const AudioCapture::Frame&) {}));
|
||
REQUIRE(stream.running() && captureStarts == before + 2);
|
||
radio.close(); stream.close();
|
||
qInfo("shared capture: default/explicit alias, stream priority, attach/detach without restart, recovery PASS");
|
||
}
|
||
|
||
static void pulse_recovery(SoundManager* manager, const QString& control) {
|
||
REQUIRE(manager->context()->backend == ma_backend_pulseaudio);
|
||
instance.ua = uasync_create(); REQUIRE(instance.ua);
|
||
REQUIRE(radio_init_instance(&instance) == 0);
|
||
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();
|
||
for (int i = 0; i < 100 && manager->context(); ++i) pump(50);
|
||
REQUIRE(!manager->context());
|
||
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));
|
||
call->stop(); radio->stop(); recorder.stopRecording();
|
||
call_audio_destroy(&instance); radio_audio_destroy(&instance); radio_destroy_instance(&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");
|
||
}
|
||
|
||
/* Ручной стенд: настоящий I/O и voice workers, без сети и сохранения микрофонного PCM.
|
||
* --latency capture_frames playback_frames seconds [call|radio|output] [log]; 0 означает backend default. */
|
||
static void measure_latency(SoundManager* manager, int seconds, const QString& kind) {
|
||
REQUIRE(manager->context()->backend == ma_backend_pulseaudio);
|
||
instance.ua = uasync_create(); REQUIRE(instance.ua);
|
||
REQUIRE(radio_init_instance(&instance) == 0);
|
||
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
|
||
auto* call = CallAudioEngine::instance(); auto* radio = RadioAudioEngine::instance();
|
||
if (kind == "call") REQUIRE(call->start(42) == 0);
|
||
if (kind == "radio") REQUIRE(radio->start(7) == 0);
|
||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "latency-probe: begin kind=%s seconds=%d capture_frames=%u playback_frames=%u",
|
||
qPrintable(kind), seconds, probeCaptureFrames, probePlaybackFrames);
|
||
pump(seconds * 1000);
|
||
DEBUG_INFO(DEBUG_CATEGORY_CALL, "latency-probe: end kind=%s", qPrintable(kind));
|
||
call->stop(); radio->stop();
|
||
call_audio_destroy(&instance); radio_audio_destroy(&instance); radio_destroy_instance(&instance);
|
||
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
|
||
manager->shutdown(); REQUIRE(devices.empty());
|
||
}
|
||
|
||
/* Null backend выдаёт цифровую тишину; отсутствие PCM инъецируется независимо от уровня сигнала. */
|
||
static void microphone_watchdog(SoundManager* manager) {
|
||
instance.ua = uasync_create(); REQUIRE(instance.ua);
|
||
REQUIRE(radio_init_instance(&instance) == 0);
|
||
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
|
||
auto* call = CallAudioEngine::instance(); auto* radio = RadioAudioEngine::instance();
|
||
int callErrors = 0, beats = 0;
|
||
QObject::connect(call, &CallAudioEngine::captureErrorChanged, [&] { if (!call->captureError().isEmpty()) callErrors++; });
|
||
QTimer heartbeat; heartbeat.setInterval(10); QObject::connect(&heartbeat, &QTimer::timeout, [&] { beats++; }); heartbeat.start();
|
||
for (int fault : {1, 2, 3}) {
|
||
microphoneFault = 0;
|
||
REQUIRE(call->start(42) == 0 && radio->start(7) == 0);
|
||
pump(1300); // Более секунды корректной тишины не вызывает ошибку.
|
||
REQUIRE(call->captureError().isEmpty() && radio->captureError().isEmpty());
|
||
REQUIRE(radio->captureAvailable()); radio->talkBegin();
|
||
int before = captureStarts, closed = captureCloses, previousBeats = beats;
|
||
slowCaptureClose = true; microphoneFault = fault;
|
||
pump(1400);
|
||
REQUIRE(!call->captureError().isEmpty() && !radio->captureError().isEmpty());
|
||
REQUIRE(call->isActive() && radio->isActive() && !radio->captureAvailable() && !radio->isTalking());
|
||
REQUIRE(beats - previousBeats >= 80); // uninit 700мс выполняется вне GUI.
|
||
radio->talkBegin(); radio->setHotkeyTalking(true); REQUIRE(!radio->isTalking());
|
||
if (fault == 3) {
|
||
QElapsedTimer elapsed; elapsed.start(); call->stop(); radio->stop();
|
||
REQUIRE(elapsed.elapsed() < 300); // Stop не ждёт уже закрывающийся неисправный backend.
|
||
AudioCaptureLease exclusive(AudioCaptureLease::Mode::Exclusive);
|
||
REQUIRE(!exclusive.acquire()); REQUIRE(call->start(44) == -1);
|
||
}
|
||
pump(800); REQUIRE(captureCloses == closed + 2);
|
||
slowCaptureClose = false; microphoneFault = 0; pump(1200);
|
||
REQUIRE(captureStarts == before); // Самопроизвольный возврат PCM не перезапускает отключённый вход.
|
||
call->stop(); radio->stop(); pump(50);
|
||
REQUIRE(callErrors == fault);
|
||
}
|
||
// Первого callback нет вообще: те же ошибка и остановка, без повторных открытий.
|
||
microphoneFault = 1;
|
||
REQUIRE(call->start(43) == 0 && radio->start(8) == 0);
|
||
int before = captureStarts, closed = captureCloses;
|
||
pump(1500);
|
||
REQUIRE(!call->captureError().isEmpty() && !radio->captureError().isEmpty());
|
||
REQUIRE(captureStarts == before && captureCloses == closed + 2);
|
||
call->stop(); radio->stop(); pump(50);
|
||
call_audio_destroy(&instance); radio_audio_destroy(&instance); radio_destroy_instance(&instance);
|
||
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
|
||
manager->shutdown(); REQUIRE(devices.empty());
|
||
qInfo("microphone watchdog: silence, no callbacks, null PCM, zero frames, no retry, nonblocking close, RX retained PASS");
|
||
}
|
||
|
||
/* Управляемое время проверяет длительные интервалы и предел без минут ожидания. */
|
||
static void buffer_tuning_policy() {
|
||
AudioBufferTuning tuning;
|
||
tuning.begin(0, 10, 3);
|
||
REQUIRE(tuning.observe(500, 100) == AudioBufferTuning::Unchanged && !tuning.requestedMs);
|
||
REQUIRE(tuning.observe(1500, 100) == AudioBufferTuning::Unchanged); // Одна пачка — один сбой.
|
||
REQUIRE(tuning.observe(7000, 1) == AudioBufferTuning::Unchanged); // Старый единичный сбой уже забыт.
|
||
REQUIRE(tuning.observe(7200, 1) == AudioBufferTuning::Increase && tuning.requestedMs == 15);
|
||
tuning.begin(7300, 15, 3);
|
||
REQUIRE(tuning.observe(7500, 100) == AudioBufferTuning::Unchanged);
|
||
REQUIRE(tuning.observe(8500, 1) == AudioBufferTuning::Unchanged);
|
||
REQUIRE(tuning.observe(8700, 1) == AudioBufferTuning::Unchanged && tuning.requestedMs == 15);
|
||
REQUIRE(tuning.observe(12200, 0) == AudioBufferTuning::Unchanged); // Один лишь таймер не запускает reopen.
|
||
REQUIRE(tuning.observe(12200, 1) == AudioBufferTuning::Increase && tuning.requestedMs == 20);
|
||
int64_t now = 12200;
|
||
for (unsigned expected : {25u, 30u, 33u}) {
|
||
tuning.begin(now + 100, tuning.requestedMs, 3); now += 5000;
|
||
REQUIRE(tuning.observe(now, 1) == AudioBufferTuning::Unchanged);
|
||
REQUIRE(tuning.observe(now + 200, 1) == AudioBufferTuning::Increase && tuning.requestedMs == expected);
|
||
REQUIRE(tuning.requestedMs * tuning.periods <= 100); now += 200;
|
||
}
|
||
tuning.begin(now + 100, 33, 3); now += 5000;
|
||
REQUIRE(tuning.observe(now, 1) == AudioBufferTuning::Unchanged);
|
||
REQUIRE(tuning.observe(now + 200, 1) == AudioBufferTuning::Limit && tuning.requestedMs == 33);
|
||
tuning = {}; tuning.baseMs = 20; tuning.begin(0, 10, 3); // Backend может округлить исходный запрос вниз.
|
||
REQUIRE(tuning.observe(1100, 1) == AudioBufferTuning::Unchanged);
|
||
REQUIRE(tuning.observe(1300, 1) == AudioBufferTuning::Increase && tuning.requestedMs == 25);
|
||
ma_context context{}; context.backend = ma_backend_pulseaudio;
|
||
auto config = ma_device_config_init(ma_device_type_capture); config.periodSizeInMilliseconds = 20;
|
||
AudioDevice::requestLowLatency(&context, config);
|
||
REQUIRE(config.periodSizeInMilliseconds == 10 && !config.periodSizeInFrames);
|
||
}
|
||
|
||
/* Та же notification, что у WASAPI/ALSA/PA; устройства и Qt recovery настоящие, backend виртуальный. */
|
||
static void inject_capture_glitch() {
|
||
std::lock_guard guard(deviceMutex);
|
||
for (auto* device : devices) if (device->type == ma_device_type_capture) {
|
||
ma_device_notification event{}; event.pDevice = device; event.type = ma_device_notification_type_capture_overrun;
|
||
device->onNotification(&event);
|
||
}
|
||
}
|
||
|
||
static void adaptive_buffers(SoundManager* manager) {
|
||
buffer_tuning_policy();
|
||
instance.ua = uasync_create(); REQUIRE(instance.ua);
|
||
REQUIRE(radio_init_instance(&instance) == 0);
|
||
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
|
||
auto* call = CallAudioEngine::instance(); auto* radio = RadioAudioEngine::instance();
|
||
REQUIRE(call->start(70) == 0 && radio->start(71) == 0);
|
||
pump(1300);
|
||
std::vector<unsigned> initial;
|
||
for (auto* device : devices) if (device->type == ma_device_type_capture) initial.push_back(device->capture.internalPeriodSizeInFrames);
|
||
std::sort(initial.begin(), initial.end()); REQUIRE(initial.size() == 2);
|
||
int before = captureStarts;
|
||
inject_capture_glitch(); pump(250); REQUIRE(captureStarts == before);
|
||
inject_capture_glitch(); pump(600); REQUIRE(captureStarts == before + 2);
|
||
std::vector<unsigned> grown;
|
||
for (auto* device : devices) if (device->type == ma_device_type_capture) grown.push_back(device->capture.internalPeriodSizeInFrames);
|
||
std::sort(grown.begin(), grown.end()); REQUIRE(grown.size() == 2);
|
||
REQUIRE(grown[0] == initial[0] + 240 && grown[1] == initial[1] + 240);
|
||
REQUIRE(call->captureError().isEmpty() && radio->captureError().isEmpty() && radio->captureAvailable());
|
||
pump(4500); slowCaptureClose = true;
|
||
inject_capture_glitch(); pump(250); inject_capture_glitch(); pump(250);
|
||
QElapsedTimer elapsed; elapsed.start();
|
||
AudioCapture incoming;
|
||
REQUIRE(!incoming.open({}, AudioCapture::Role::Stream, 2, 75, [](int, int) { return true; }, [] {}, [](const auto&) {}));
|
||
REQUIRE(!incoming.subscribed() && elapsed.elapsed() < 300);
|
||
elapsed.restart(); call->stop(); radio->stop();
|
||
REQUIRE(elapsed.elapsed() < 300); // Stop не ждёт выполняющееся закрытие для изменения буфера.
|
||
pump(900); slowCaptureClose = false;
|
||
REQUIRE(call->start(72) == 0 && radio->start(73) == 0); pump(100);
|
||
std::vector<unsigned> reset;
|
||
for (auto* device : devices) if (device->type == ma_device_type_capture) reset.push_back(device->capture.internalPeriodSizeInFrames);
|
||
std::sort(reset.begin(), reset.end()); REQUIRE(reset == initial);
|
||
call->stop(); radio->stop(); pump(50);
|
||
call_audio_destroy(&instance); radio_audio_destroy(&instance); radio_destroy_instance(&instance);
|
||
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
|
||
manager->shutdown(); REQUIRE(devices.empty());
|
||
qInfo("adaptive buffers: startup, isolated faults, cooldown, limit, actual reopen, async stop, new session baseline PASS");
|
||
}
|
||
|
||
int main(int argc, char** argv) {
|
||
QCoreApplication app(argc, argv);
|
||
latencyProbe = argc >= 2 && std::strcmp(argv[1], "--latency") == 0;
|
||
int seconds = 0;
|
||
QString kind = argc >= 6 ? QString::fromLocal8Bit(argv[5]) : "call";
|
||
if (latencyProbe) {
|
||
REQUIRE(argc >= 5 && argc <= 7);
|
||
bool captureOk, playbackOk, durationOk;
|
||
probeCaptureFrames = QString::fromLocal8Bit(argv[2]).toUInt(&captureOk);
|
||
probePlaybackFrames = QString::fromLocal8Bit(argv[3]).toUInt(&playbackOk);
|
||
seconds = QString::fromLocal8Bit(argv[4]).toInt(&durationOk);
|
||
REQUIRE(captureOk && playbackOk && durationOk && seconds > 0 && seconds <= 3600);
|
||
REQUIRE(probeCaptureFrames <= 4800 && probePlaybackFrames <= 4800);
|
||
REQUIRE(kind == "call" || kind == "radio" || kind == "output");
|
||
}
|
||
pulseIntegration = latencyProbe || (argc == 3 && std::strcmp(argv[1], "--pulse-integration") == 0);
|
||
debug_config_init(); debug_set_level(pulseIntegration ? DEBUG_LEVEL_INFO : DEBUG_LEVEL_WARN);
|
||
if (latencyProbe) {
|
||
debug_set_category_level(DEBUG_CATEGORY_CALL, DEBUG_LEVEL_TRACE);
|
||
debug_set_category_level(DEBUG_CATEGORY_AEC, DEBUG_LEVEL_DEBUG);
|
||
if (argc == 7) { debug_enable_file_output(argv[6], 1); REQUIRE(g_debug_config.file_output); }
|
||
}
|
||
auto* manager = SoundManager::instance();
|
||
REQUIRE(manager->init() && manager->context());
|
||
if (argc == 2 && std::strcmp(argv[1], "--microphone-watchdog") == 0) { microphone_watchdog(manager); return 0; }
|
||
if (argc == 2 && std::strcmp(argv[1], "--buffer-tuning") == 0) { adaptive_buffers(manager); return 0; }
|
||
if (latencyProbe) { measure_latency(manager, seconds, kind); return 0; }
|
||
if (pulseIntegration) { pulse_recovery(manager, QString::fromLocal8Bit(argv[2])); return 0; }
|
||
ma_device_id id{};
|
||
auto outputs = manager->enumeratePlaybackDevices();
|
||
REQUIRE(!outputs.empty() && SoundManager::deviceId(outputs.front().id, &id));
|
||
REQUIRE(!SoundManager::deviceId(outputs.front().id + '!', &id));
|
||
shared_capture(manager);
|
||
{
|
||
AudioDevice device;
|
||
auto config = ma_device_config_init(ma_device_type_capture);
|
||
config.capture.format = ma_format_s16; config.capture.channels = 1; config.sampleRate = 48000;
|
||
REQUIRE(device.init(manager->context(), config, "test-partial-start", 0, [](const void*, void*, unsigned int) {}));
|
||
int count = live[manager->context()];
|
||
failStart = true;
|
||
REQUIRE(!device.start() && !device.initialized());
|
||
REQUIRE(live[manager->context()] == count - 1);
|
||
failDevice = true;
|
||
REQUIRE(!device.init(manager->context(), config, "test-init-failure", 0, [](const void*, void*, unsigned int) {}));
|
||
REQUIRE(!device.initialized() && live[manager->context()] == count - 1);
|
||
}
|
||
{
|
||
AudioRecorder first, second;
|
||
first.init(); second.init();
|
||
auto inputs = manager->enumerateCaptureDevices();
|
||
REQUIRE(!inputs.empty());
|
||
first.setCaptureDevice(inputs.front().id);
|
||
int previous = captureStarts;
|
||
failStart = true;
|
||
first.startRecording(); second.startRecording();
|
||
REQUIRE(captureStarts == previous + 3); // Выбранный input не стартовал: default запущен после полного uninit.
|
||
pump(60);
|
||
first.stopRecording(); second.stopRecording();
|
||
attachment_send_req a{}, b{};
|
||
REQUIRE(first.takeRecording(&a) && second.takeRecording(&b));
|
||
REQUIRE(a.pcm_count >= 1200 && b.pcm_count >= 1200);
|
||
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(radio_init_instance(&instance) == 0);
|
||
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
|
||
auto* call = CallAudioEngine::instance();
|
||
REQUIRE(call->start(42) == 0);
|
||
pump(60);
|
||
ma_device* playback = nullptr;
|
||
for (auto* device : devices)
|
||
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;
|
||
REQUIRE(ma_device_stop(playback) == MA_SUCCESS);
|
||
pump(800);
|
||
REQUIRE(captureStarts == previousCapture && playbackStarts > previousPlayback);
|
||
REQUIRE(call->callId() == 42 && call->isActive());
|
||
auto* radio = RadioAudioEngine::instance();
|
||
REQUIRE(radio->start(7) == 0);
|
||
AudioRecorder recorder; recorder.init(); recorder.startRecording();
|
||
std::vector<int16_t> notification(48000 * 10, 1000);
|
||
auto pcmId = manager->playRawPcm(notification.data(), (int)notification.size(), 48000, 1);
|
||
pump(60);
|
||
auto cursor = manager->pcmCursor(pcmId);
|
||
REQUIRE(cursor > 0);
|
||
int previousContext = contextStarts;
|
||
failContext = true; failContexts = 2;
|
||
pump(1800);
|
||
REQUIRE(contextStarts >= previousContext + 3 && manager->context());
|
||
REQUIRE(call->callId() == 42 && radio->groupId() == 7 && recorder.isRecording());
|
||
REQUIRE(manager->pcmCursor(pcmId) > cursor && manager->isPcmPlaying(pcmId));
|
||
call->stop(); radio->stop(); recorder.stopRecording();
|
||
int previousStarts = starts;
|
||
pump(600);
|
||
REQUIRE(starts == previousStarts); // Отложенное recovery не воскрешает остановленную сессию.
|
||
REQUIRE(call->start(43) == 0);
|
||
call->beginEnd(43);
|
||
REQUIRE(call->start(43) == 0);
|
||
pump(600);
|
||
REQUIRE(call->isActive() && call->callId() == 43); // Старый end timer не закрывает новое поколение того же call ID.
|
||
call->stop();
|
||
failContext = true; failContexts = 1;
|
||
recorder.startRecording();
|
||
pump(220);
|
||
REQUIRE(!manager->context());
|
||
recorder.stopRecording();
|
||
previousCapture = captureStarts;
|
||
pump(1000);
|
||
REQUIRE(manager->context() && captureStarts == previousCapture);
|
||
|
||
// Частота источника и управление сессией независимы от соседнего проигрывателя.
|
||
std::vector<int16_t> pcm24(24000 * 2, 2000), pcm48(48000 * 2, -1000);
|
||
auto player24 = manager->playRawPcm(pcm24.data(), 48000, 24000, 1);
|
||
auto player48 = manager->playRawPcm(pcm48.data(), 96000, 48000, 1);
|
||
REQUIRE(player24 && player48 && player24 != player48);
|
||
pump(150);
|
||
uint64_t cursor24 = manager->pcmCursor(player24), cursor48 = manager->pcmCursor(player48);
|
||
REQUIRE(cursor24 > 1000 && cursor24 < 6000 && cursor48 > cursor24 * 1.8 && cursor48 < cursor24 * 2.2);
|
||
manager->pausePcm(player24);
|
||
cursor24 = manager->pcmCursor(player24);
|
||
pump(80);
|
||
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);
|
||
manager->stopPcm(player24);
|
||
|
||
// Предел TX действует до помещения задачи в uasync; старое поколение не отправляется после stop/start.
|
||
uasync_poll(instance.ua, 0);
|
||
REQUIRE(call_audio_start(99) == 0);
|
||
int16_t silence[960]{};
|
||
for (int i = 0; i < 100; ++i) call_audio_feed_pcm(99, silence, 960, INT64_MIN);
|
||
int pending = 0;
|
||
for (auto* task = instance.ua->posted_tasks_head; task; task = task->next) ++pending;
|
||
REQUIRE(pending == 8);
|
||
call_audio_stop();
|
||
REQUIRE(call_audio_start(100) == 0);
|
||
for (int i = 0; i < 100; ++i) call_audio_feed_pcm(100, silence, 960, INT64_MIN);
|
||
pending = 0;
|
||
for (auto* task = instance.ua->posted_tasks_head; task; task = task->next) ++pending;
|
||
REQUIRE(pending == 8); // Новый звонок также учитывает ещё не освобождённые задачи старого.
|
||
call_audio_stop();
|
||
uasync_poll(instance.ua, 0);
|
||
REQUIRE(!instance.ua->posted_tasks_head);
|
||
REQUIRE(radio_audio_start(8, 1) == 0);
|
||
radio_audio_talk_begin(8); radio_audio_talk_end(8); radio_audio_stop();
|
||
REQUIRE(instance.ua->posted_tasks_head);
|
||
// Завершение ядра освобождает ещё не выполненные PTT-задачи вместе с аргументами.
|
||
call_audio_destroy(&instance); radio_audio_destroy(&instance); radio_destroy_instance(&instance);
|
||
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
|
||
manager->shutdown();
|
||
REQUIRE(devices.empty());
|
||
qInfo("audio recovery: partial start, independent streams, two recorders, context restart, cancellation, bounded TX PASS");
|
||
return 0;
|
||
}
|