You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
580 lines
34 KiB
580 lines
34 KiB
/* Настоящие 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); |
|
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; |
|
}
|
|
|