Browse Source

Reduce Linux voice capture latency and verify device timing

master
evgeny 5 days ago
parent
commit
7477da42a5
  1. 97
      doc/linux_audio_recovery.md
  2. 3
      tools/chatgui/CMakeLists.txt
  3. 14
      tools/chatgui/src/audio_device.cpp
  4. 1
      tools/chatgui/src/audio_device.h
  5. 4
      tools/chatgui/src/call_audio_engine.cpp
  6. 1
      tools/chatgui/src/radio_audio_engine.cpp
  7. 14
      tools/chatgui/tests/test_audio_diagnostics.cpp
  8. 72
      tools/chatgui/tests/test_audio_recovery.cpp
  9. 89
      tools/chatgui/tests/test_voice_audio_io.cpp
  10. 53
      tools/chatgui/transport/audio_diagnostics.cpp
  11. 32
      tools/chatgui/transport/miniaudio_impl.c

97
doc/linux_audio_recovery.md

@ -382,7 +382,7 @@ LeakSanitizer; LeakSanitizer запускается вне песочницы. M
ThreadSanitizer. Изолированный интеграционный запуск: ThreadSanitizer. Изолированный интеграционный запуск:
```sh ```sh
python3 tools/chatgui/tests/run_pulse_recovery.py dbus-run-session -- python3 tools/chatgui/tests/run_pulse_recovery.py
``` ```
Итоговый прогон 2026-10-02: сборка `vibechat` успешна, CTest — 11/11, Итоговый прогон 2026-10-02: сборка `vibechat` успешна, CTest — 11/11,
@ -407,6 +407,101 @@ async-тесты исключены из дальнейших запусков.
AEC в длительном реальном звонке этими проверками не подтверждены. Reference AEC в длительном реальном звонке этими проверками не подтверждены. Reference
охватывает звук этого приложения на том же выходе, согласно выбранному контракту. охватывает звук этого приложения на том же выходе, согласно выбранному контракту.
## Период Linux/PulseAudio и сравнительный замер
`AudioDevice::requestLowLatency()` задаёт запрос 10 мс для capture звонка/рации.
Он применяется при каждом открытии, включая recovery и fallback.
Остальные backends сохраняют прежние параметры.
Общий и отдельный voice output сохраняют прежний период: запрос 10 мс
на проверенной системе не снизил измеренную задержку воспроизведения.
Кадры AEC/кодека остаются 20 мс; `noFixedSizedCallback` позволяет обслуживать
фактические порции backend без дополнительного накопления в miniaudio.
Это запрос задержки, а не гарантия размера callback. PulseAudio согласует
`tlength`, `fragsize`, `minreq`, `maxlength`, `prebuf`; PipeWire также ограничивает
quantum и минимальные порции. `AudioDiag` при init выводит внутренний PCM-формат
и согласованные параметры в миллисекундах (`-1` — значение по умолчанию).
Пересчёт использует внутренние rate/channels/format, а не формат приложения.
Секундные clock-отчёты содержат `latency_us`/`backend_us` и `block_us` в виде
`min/avg/max`. В задержку включены только измеренные timing snapshots;
`estimated_blocks`/`measured` позволяют отличить их отсутствие от нулевой задержки.
Для времени исполнения callback включать `call=trace`, для состояния AEC —
`aec=debug`. Логи агрегируются вне аппаратного callback.
Ручной режим существующего стенда использует настоящие устройства и голосовые
workers, не сохраняет микрофонный PCM и не отправляет его по сети. Выход звонка
без удалённого собеседника обслуживается тишиной; акустическое подавление эха
этот режим не измеряет. Аргументы: capture frames, playback frames, секунды,
необязательный режим `call` (по умолчанию), `radio` или `output`, затем путь лога.
Ноль frames оставляет стандартный период backend; override применяется только
в тестовом executable, до настоящего `ma_device_init`.
```sh
cmake --build tools/chatgui/build --target test_audio_recovery -j4
tools/chatgui/build/test_audio_recovery --latency 960 0 60 > /tmp/audio-baseline.log 2>&1
tools/chatgui/build/test_audio_recovery --latency 480 0 60 > /tmp/audio-capture10.log 2>&1
tools/chatgui/build/test_audio_recovery --latency 960 480 60 > /tmp/audio-playback10.log 2>&1
tools/chatgui/build/test_audio_recovery --latency 480 480 60 > /tmp/audio-both10.log 2>&1
```
Запуски последовательные: несколько потоков могут менять общий quantum сервера.
Сравнивать одинаковые маршруты и нагрузку, отдельно от старта/остановки и после
стабилизации адаптивного PCM-резерва. Проверять backend latency, возраст первого
сэмпла перед готовым TX-кадром, очередь после mix, starvation, holes, потери PCM,
AEC reference и событий диагностики. Затем повторить baseline и проверить
выбранный вариант под нагрузкой. Меньший возраст PCM в capture callback сам по
себе не доказывает такой же выигрыш до кодирования: worker собирает кадры 20 мс.
`test_voice_audio_io` дополнительно проверяет порции по 256 samples: порядок PCM,
временные метки, границы mute/PTT и позицию частичного playback. AEC проверяется
при разных порциях capture/reference (256/480 и 480/256) с противофазным stereo
reference. Очереди по-прежнему ограничены числом событий; уменьшение периода
не увеличивает их ёмкость в миллисекундах.
Сравнение 2026-10-15 на встроенном HDA, PipeWire 1.6.2, 48 кГц. Каждый вариант
работал 60 секунд; таблица содержит средние по callback/кадрам после 45 секунд
прогрева (PCM-резерв к этому моменту уменьшился до одного блока).
| Запрос capture / playback | Capture backend, мс | Первый сэмпл готового TX-кадра, мс | Playback backend, мс | PCM после mix, мс |
| --- | ---: | ---: | ---: | ---: |
| 20 мс / default 25 мс | 30,69 | 34,53 | 16,32 | 18,67 |
| 10 мс / default 25 мс | 16,02 | 29,79 | 16,15 | 18,67 |
| 20 мс / 10 мс | 31,51 | 34,97 | 18,09 | 24,00 |
| 10 мс / 10 мс | 16,13 | 29,66 | 18,46 | 24,00 |
Изменение capture даёт около 14,7 мс на границе backend и около 4,7 мс перед
передачей полного кадра в голосовой стек. Для playback сервер согласовал
`tlength=16ms`, `minreq=5.333ms` при запросе 10 мс; callback уменьшился с 512
до 256 samples, но выигрыша по задержке не получилось. Поэтому в рабочем коде
изменён только capture. Значения в столбцах не следует складывать как независимые
участки общей задержки: capture backend входит в возраст TX-кадра, а очередь после
mix снята уже после извлечения текущего выходного блока из очереди.
В этих четырёх прогретых окнах не было потерь capture/reference PCM, выходного
starvation, backend underrun/overflow или потери событий диагностики.
Отдельные отсутствующие AEC references были и в baseline (4 кадра за окно),
и в остальных вариантах (4–5). Это существующее ограничение привязки reference,
а не проверка акустического качества. Первоначальный output starvation и
временное увеличение PCM-резерва учитываются отдельно от стабильного окна.
Повтор baseline дал 30,76 мс capture backend и 34,99 мс возраста TX-кадра.
Двухминутный прогон выбранного capture 10 мс под нагрузкой четырёх CPU-процессов
дал после прогрева 15,81 и 28,55 мс соответственно; PCM после mix — 18,67 мс.
Потерь PCM, backend underrun/overflow и потери событий диагностики в этом окне
не было. За 75 секунд окна зарегистрировано 25 отсутствующих AEC references.
Отдельный минутный прогон рации: capture backend в среднем 15,48 мс, без потерь PCM.
Сборка `vibechat` и 6 аудиотестов прошли. Изолированный интеграционный тест
подтвердил объединение выходных маршрутов и восстановление capture/playback после
перезапуска тестового PulseAudio. Запускать весь стенд через `dbus-run-session`,
чтобы имя его PulseAudio не конфликтовало с сервером пользовательской сессии.
В fixture перед `radio_audio_init` создаётся `radio_instance`, освобождаемый после
`radio_audio_destroy`; это исключает ложную ошибку регистрации mute callback.
Полный `check.sh` вне sandbox: 156/156, 9/9 proxy-интеграций, burst и load — PASS.
В sandbox сетевые тесты не проходят из-за запрета сокетов; этот запуск не является
проверкой сетевых регрессий.
## Референсы ## Референсы
Первичные исходники и документация просмотрены 2026-10-01; ссылки на master могут Первичные исходники и документация просмотрены 2026-10-01; ссылки на master могут

3
tools/chatgui/CMakeLists.txt

@ -620,7 +620,8 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_include_directories(test_audio_recovery PRIVATE ${UTUN_INCLUDE_DIRS}) target_include_directories(test_audio_recovery PRIVATE ${UTUN_INCLUDE_DIRS})
target_link_libraries(test_audio_recovery PRIVATE ${QT_LIBS} utun_voice utun pthread dl) target_link_libraries(test_audio_recovery PRIVATE ${QT_LIBS} utun_voice utun pthread dl)
target_link_options(test_audio_recovery PRIVATE "-Wl,--wrap=ma_context_init" "-Wl,--wrap=ma_context_uninit" target_link_options(test_audio_recovery PRIVATE "-Wl,--wrap=ma_context_init" "-Wl,--wrap=ma_context_uninit"
"-Wl,--wrap=ma_device_init" "-Wl,--wrap=ma_device_start" "-Wl,--wrap=ma_device_uninit" "-Wl,--wrap=audio_context_failed") "-Wl,--wrap=ma_device_init" "-Wl,--wrap=ma_device_start" "-Wl,--wrap=ma_device_uninit" "-Wl,--wrap=audio_context_failed"
"-Wl,--wrap=call_send_media")
add_test(NAME test_audio_recovery COMMAND test_audio_recovery) add_test(NAME test_audio_recovery COMMAND test_audio_recovery)
set_tests_properties(test_audio_recovery PROPERTIES TIMEOUT 15) set_tests_properties(test_audio_recovery PROPERTIES TIMEOUT 15)
add_executable(test_radio_panel tests/test_radio_panel.cpp src/radiopanel.cpp src/radio_audio_engine.cpp add_executable(test_radio_panel tests/test_radio_panel.cpp src/radiopanel.cpp src/radio_audio_engine.cpp

14
tools/chatgui/src/audio_device.cpp

@ -12,6 +12,18 @@ extern "C" void audio_stream_log_route(ma_device* device, int capture);
AudioDevice::AudioDevice() { m_clock.start(); } AudioDevice::AudioDevice() { m_clock.start(); }
AudioDevice::~AudioDevice() { close(); } AudioDevice::~AudioDevice() { close(); }
void AudioDevice::requestLowLatency(ma_context* context, ma_device_config& config) {
#ifdef __linux__
if (context && context->backend == ma_backend_pulseaudio) {
// Период I/O не зависит от размера кадра AEC/кодека; сервер согласует фактический буфер.
config.periodSizeInFrames = 0;
config.periodSizeInMilliseconds = 10;
}
#else
(void)context; (void)config;
#endif
}
bool AudioDevice::init(ma_context* context, ma_device_config config, const char* role, uint64_t session, Callback callback) { bool AudioDevice::init(ma_context* context, ma_device_config config, const char* role, uint64_t session, Callback callback) {
close(); close();
m_role = role; m_role = role;
@ -22,6 +34,8 @@ bool AudioDevice::init(ma_context* context, ma_device_config config, const char*
config.noFixedSizedCallback = MA_TRUE; // Аппаратные чанки не требуют скрытого накопителя miniaudio. config.noFixedSizedCallback = MA_TRUE; // Аппаратные чанки не требуют скрытого накопителя miniaudio.
config.notificationCallback = notification; config.notificationCallback = notification;
config.pUserData = this; config.pUserData = this;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio: %s requested backend=%s rate=%u period_frames=%u period_ms=%u",
role, ma_get_backend_name(context->backend), config.sampleRate, config.periodSizeInFrames, config.periodSizeInMilliseconds);
ma_result result = ma_device_init(context, &config, device); ma_result result = ma_device_init(context, &config, device);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio: %s init failed result=%d", role, result); DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio: %s init failed result=%d", role, result);

1
tools/chatgui/src/audio_device.h

@ -14,6 +14,7 @@ public:
~AudioDevice(); ~AudioDevice();
AudioDevice(const AudioDevice&) = delete; AudioDevice(const AudioDevice&) = delete;
AudioDevice& operator=(const AudioDevice&) = delete; AudioDevice& operator=(const AudioDevice&) = delete;
static void requestLowLatency(ma_context* context, ma_device_config& config);
bool init(ma_context* context, ma_device_config config, const char* role, uint64_t session, Callback callback); bool init(ma_context* context, ma_device_config config, const char* role, uint64_t session, Callback callback);
bool start(); bool start();
void pause(); // GUI: завершает текущий callback, сохраняя backend stream и его физический route. void pause(); // GUI: завершает текущий callback, сохраняя backend stream и его физический route.

4
tools/chatgui/src/call_audio_engine.cpp

@ -59,8 +59,8 @@ bool CallAudioEngine::openCapture() {
if (!context) return false; if (!context) return false;
ma_device_config config = ma_device_config_init(ma_device_type_capture); ma_device_config config = ma_device_config_init(ma_device_type_capture);
config.capture.format = ma_format_s16; config.capture.channels = 1; config.sampleRate = CALL_AUDIO_SAMPLE_RATE; config.capture.format = ma_format_s16; config.capture.channels = 1; config.sampleRate = CALL_AUDIO_SAMPLE_RATE;
config.periodSizeInMilliseconds = CALL_AUDIO_FRAME_MS; config.periodSizeInMilliseconds = 20; // Прежний запрос для остальных платформ/backends.
DEBUG_INFO(DEBUG_CATEGORY_CALL, "CallAudio: capture requested period=%dms; actual period reported by device", CALL_AUDIO_FRAME_MS); AudioDevice::requestLowLatency(context, config);
ma_device_id id; ma_device_id id;
bool selected = SoundManager::deviceId(m_captureId, &id); bool selected = SoundManager::deviceId(m_captureId, &id);
if (selected) config.capture.pDeviceID = &id; if (selected) config.capture.pDeviceID = &id;

1
tools/chatgui/src/radio_audio_engine.cpp

@ -232,6 +232,7 @@ void RadioAudioEngine::openCapture() {
config.capture.format = ma_format_s16; config.capture.format = ma_format_s16;
config.capture.channels = channels; config.capture.channels = channels;
config.sampleRate = RADIO_AUDIO_SAMPLE_RATE; config.sampleRate = RADIO_AUDIO_SAMPLE_RATE;
AudioDevice::requestLowLatency(context, config);
auto callback = [this](const void* in, void*, unsigned int frames) { captureCallback(in, frames); }; auto callback = [this](const void* in, void*, unsigned int frames) { captureCallback(in, frames); };
ma_device_id id; ma_device_id id;
bool selected = SoundManager::deviceId(m_captureId, &id); bool selected = SoundManager::deviceId(m_captureId, &id);

14
tools/chatgui/tests/test_audio_diagnostics.cpp

@ -32,13 +32,16 @@ int main() {
assert(capture.captureSequence == 1440 && capture.captureFrames == 1440 && capture.captureBlocks == 3); assert(capture.captureSequence == 1440 && capture.captureFrames == 1440 && capture.captureBlocks == 3);
assert(capture.captureJumps == 1 && capture.captureMaxDelta == 4000 && capture.captureMinDelta == 0); assert(capture.captureJumps == 1 && capture.captureMaxDelta == 4000 && capture.captureMinDelta == 0);
assert(capture.captureMissing == 480 && capture.captureEstimated == 0); assert(capture.captureMissing == 480 && capture.captureEstimated == 0);
assert(capture.captureMinLatency == 96000 && capture.captureMaxLatency == 100000); assert(capture.captureLatency.low == 96000 && capture.captureLatency.high == 100000);
assert(capture.captureLatency.count == 3 && capture.captureLatency.average() == 97333);
assert(capture.captureBlock.low == 10000 && capture.captureBlock.high == 10000);
report(devices, now(CLOCK_MONOTONIC), 1.0); report(devices, now(CLOCK_MONOTONIC), 1.0);
timing.first_us = 1034000; timing.missing = 0; timing.measured = 0; timing.first_us = 1034000; timing.missing = 0; timing.measured = 0;
audio_diag_capture_clock(nullptr, &timing); audio_diag_capture_clock(nullptr, &timing);
assert(take(e)); consume(e, devices); assert(take(e)); consume(e, devices);
assert(capture.captureJumps == 0 && capture.captureMissing == 0 && capture.captureEstimated == 1); assert(capture.captureJumps == 0 && capture.captureMissing == 0 && capture.captureEstimated == 1);
assert(capture.captureSequence == 1920 && capture.captureBlocks == 1 && capture.captureFrames == 480); assert(capture.captureSequence == 1920 && capture.captureBlocks == 1 && capture.captureFrames == 480);
assert(capture.captureLatency.count == 0 && capture.captureBlock.count == 1);
// Отсутствующий timestamp не превращается в измеренную нулевую задержку. // Отсутствующий timestamp не превращается в измеренную нулевую задержку.
audio_playback_timing output{30000, 1000, 1200, 48000, 1}; audio_playback_timing output{30000, 1000, 1200, 48000, 1};
audio_diag_playback_clock(nullptr, &output); audio_diag_playback_clock(nullptr, &output);
@ -47,11 +50,12 @@ int main() {
output.latency_us = 999999; output.measured = 0; output.latency_us = 999999; output.measured = 0;
audio_diag_playback_clock(nullptr, &output); audio_diag_playback_clock(nullptr, &output);
while (take(e)) consume(e, devices); while (take(e)) consume(e, devices);
assert(capture.playbackBlocks == 3 && capture.playbackFrames == 3120 && capture.playbackMeasured == 2); assert(capture.playbackBlocks == 3 && capture.playbackFrames == 3120 && capture.playbackLatency.count == 2);
assert(capture.playbackMinLatency == -1000 && capture.playbackMaxLatency == 30000); assert(capture.playbackLatency.low == -1000 && capture.playbackLatency.high == 30000);
assert(capture.playbackMaxBlock == 25000 && capture.playbackMaxConverter == 1000); assert(capture.playbackBlock.high == 25000 && capture.playbackMaxConverter == 1000);
assert(capture.playbackLatency.average() == 14500 && capture.playbackBlock.low == 20000);
report(devices, now(CLOCK_MONOTONIC), 1.0); report(devices, now(CLOCK_MONOTONIC), 1.0);
assert(capture.playbackBlocks == 0 && capture.playbackMeasured == 0 && capture.playbackMaxBlock == 0); assert(capture.playbackBlocks == 0 && capture.playbackLatency.count == 0 && capture.playbackBlock.high == 0);
std::vector<std::thread> workers; std::vector<std::thread> workers;
for (int p = 0; p < producers; ++p) workers.emplace_back([p, &done]() { for (int p = 0; p < producers; ++p) workers.emplace_back([p, &done]() {
for (int i = 0; i < perProducer; ++i) for (int i = 0; i < perProducer; ++i)

72
tools/chatgui/tests/test_audio_recovery.cpp

@ -1,4 +1,4 @@
/* Настоящие null devices и Qt timers; linker wrappers инъецируют только сбои lifecycle. */ /* Настоящие null/PulseAudio devices и Qt timers; wrappers инъецируют сбои либо параметры ручного замера. */
#include "../src/audio_device.h" #include "../src/audio_device.h"
#include "../src/call_audio_engine.h" #include "../src/call_audio_engine.h"
#include "../src/radio_audio_engine.h" #include "../src/radio_audio_engine.h"
@ -15,6 +15,7 @@ extern "C" {
#include "utun_instance.h" #include "utun_instance.h"
#include "call/call_audio.h" #include "call/call_audio.h"
#include "radio/radio_audio.h" #include "radio/radio_audio.h"
#include "radio/radio.h"
#include "media_async/attachment_send.h" #include "media_async/attachment_send.h"
#include "u_async.h" #include "u_async.h"
#include "debug_config.h" #include "debug_config.h"
@ -27,6 +28,8 @@ static std::vector<ma_device*> devices;
static int starts, captureStarts, playbackStarts, contextStarts, failContexts; static int starts, captureStarts, playbackStarts, contextStarts, failContexts;
static bool failStart, failContext, failDevice; static bool failStart, failContext, failDevice;
static bool pulseIntegration; static bool pulseIntegration;
static bool latencyProbe;
static unsigned probeCaptureFrames, probePlaybackFrames;
extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; } extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; }
extern "C" int gui_bridge_is_uasync_ready() { return instance.ua != nullptr; } extern "C" int gui_bridge_is_uasync_ready() { return instance.ua != nullptr; }
@ -37,6 +40,10 @@ extern "C" ma_result __real_ma_device_init(ma_context*, const ma_device_config*,
extern "C" ma_result __real_ma_device_start(ma_device*); extern "C" ma_result __real_ma_device_start(ma_device*);
extern "C" void __real_ma_device_uninit(ma_device*); extern "C" void __real_ma_device_uninit(ma_device*);
extern "C" int __real_audio_context_failed(ma_context*); 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) { extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context* context) {
++contextStarts; ++contextStarts;
@ -50,7 +57,14 @@ extern "C" void __wrap_ma_context_uninit(ma_context* context) {
} }
extern "C" ma_result __wrap_ma_device_init(ma_context* context, const ma_device_config* config, ma_device* device) { 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; } if (failDevice) { failDevice = false; return MA_ERROR; }
ma_result result = __real_ma_device_init(context, config, device); ma_device_config requested = *config;
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);
if (result == MA_SUCCESS) { ++live[context]; devices.push_back(device); } if (result == MA_SUCCESS) { ++live[context]; devices.push_back(device); }
return result; return result;
} }
@ -79,12 +93,17 @@ extern "C" int __wrap_audio_context_failed(ma_context* context) {
static void pump(int milliseconds) { static void pump(int milliseconds) {
QElapsedTimer timer; timer.start(); QElapsedTimer timer; timer.start();
while (timer.elapsed() < milliseconds) { QCoreApplication::processEvents(); QThread::msleep(1); } while (timer.elapsed() < milliseconds) {
QCoreApplication::processEvents();
if (latencyProbe && instance.ua) uasync_poll(instance.ua, 0);
QThread::msleep(1);
}
} }
static void pulse_recovery(SoundManager* manager, const QString& control) { static void pulse_recovery(SoundManager* manager, const QString& control) {
REQUIRE(manager->context()->backend == ma_backend_pulseaudio); REQUIRE(manager->context()->backend == ma_backend_pulseaudio);
instance.ua = uasync_create(); REQUIRE(instance.ua); instance.ua = uasync_create(); REQUIRE(instance.ua);
REQUIRE(radio_init_instance(&instance) == 0);
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0); REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
auto* call = CallAudioEngine::instance(); auto* radio = RadioAudioEngine::instance(); auto* call = CallAudioEngine::instance(); auto* radio = RadioAudioEngine::instance();
REQUIRE(call->start(42) == 0 && radio->start(7) == 0); REQUIRE(call->start(42) == 0 && radio->start(7) == 0);
@ -130,18 +149,58 @@ static void pulse_recovery(SoundManager* manager, const QString& control) {
qInfo("PulseAudio progress: capture=%d -> %dms playback=%llu -> %llu frames", duration, recorder.durationMs(), qInfo("PulseAudio progress: capture=%d -> %dms playback=%llu -> %llu frames", duration, recorder.durationMs(),
(unsigned long long)cursor, (unsigned long long)manager->pcmCursor(pcmId)); (unsigned long long)cursor, (unsigned long long)manager->pcmCursor(pcmId));
call->stop(); radio->stop(); recorder.stopRecording(); call->stop(); radio->stop(); recorder.stopRecording();
call_audio_destroy(&instance); radio_audio_destroy(&instance); uasync_destroy(instance.ua, 0); instance.ua = nullptr; call_audio_destroy(&instance); radio_audio_destroy(&instance); radio_destroy_instance(&instance);
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
manager->shutdown(); manager->shutdown();
REQUIRE(devices.empty()); REQUIRE(devices.empty());
qInfo("real PulseAudio: capture/playback progress restored after server restart PASS"); 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());
}
int main(int argc, char** argv) { int main(int argc, char** argv) {
QCoreApplication app(argc, argv); QCoreApplication app(argc, argv);
pulseIntegration = argc == 3 && std::strcmp(argv[1], "--pulse-integration") == 0; 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); 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(); auto* manager = SoundManager::instance();
REQUIRE(manager->init() && manager->context()); REQUIRE(manager->init() && manager->context());
if (latencyProbe) { measure_latency(manager, seconds, kind); return 0; }
if (pulseIntegration) { pulse_recovery(manager, QString::fromLocal8Bit(argv[2])); return 0; } if (pulseIntegration) { pulse_recovery(manager, QString::fromLocal8Bit(argv[2])); return 0; }
ma_device_id id{}; ma_device_id id{};
auto outputs = manager->enumeratePlaybackDevices(); auto outputs = manager->enumeratePlaybackDevices();
@ -185,6 +244,7 @@ int main(int argc, char** argv) {
REQUIRE(!request.pcm && !request.pcm_owner && !request.compressor); REQUIRE(!request.pcm && !request.pcm_owner && !request.compressor);
} }
instance.ua = uasync_create(); REQUIRE(instance.ua); instance.ua = uasync_create(); REQUIRE(instance.ua);
REQUIRE(radio_init_instance(&instance) == 0);
REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0); REQUIRE(call_audio_init(&instance) == 0 && radio_audio_init(&instance) == 0);
auto* call = CallAudioEngine::instance(); auto* call = CallAudioEngine::instance();
REQUIRE(call->start(42) == 0); REQUIRE(call->start(42) == 0);
@ -285,7 +345,7 @@ int main(int argc, char** argv) {
radio_audio_talk_begin(8); radio_audio_talk_end(8); radio_audio_stop(); radio_audio_talk_begin(8); radio_audio_talk_end(8); radio_audio_stop();
REQUIRE(instance.ua->posted_tasks_head); REQUIRE(instance.ua->posted_tasks_head);
// Завершение ядра освобождает ещё не выполненные PTT-задачи вместе с аргументами. // Завершение ядра освобождает ещё не выполненные PTT-задачи вместе с аргументами.
call_audio_destroy(&instance); radio_audio_destroy(&instance); call_audio_destroy(&instance); radio_audio_destroy(&instance); radio_destroy_instance(&instance);
uasync_destroy(instance.ua, 0); instance.ua = nullptr; uasync_destroy(instance.ua, 0); instance.ua = nullptr;
manager->shutdown(); manager->shutdown();
REQUIRE(devices.empty()); REQUIRE(devices.empty());

89
tools/chatgui/tests/test_voice_audio_io.cpp

@ -31,10 +31,15 @@ static void capture_diagnostic(int, const char*, const char* message) {
if (std::strstr(message, "voice-io: capture stalled")) idleReported = true; if (std::strstr(message, "voice-io: capture stalled")) idleReported = true;
} }
static std::atomic<uint64_t> output_position{0}, output_delay{0}; static std::atomic<uint64_t> output_position{0}, output_delay{0};
static bool rampPlayback;
static unsigned playbackSource;
extern "C" void radio_audio_playback_progress(uint64_t, uint64_t position, uint64_t delay) { extern "C" void radio_audio_playback_progress(uint64_t, uint64_t position, uint64_t delay) {
output_delay = delay; output_position = position; output_delay = delay; output_position = position;
} }
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 call_audio_pull_pcm(uint64_t, int16_t* out, int count) {
for (int i = 0; i < count; ++i) out[i] = rampPlayback ? int(playbackSource++ % 16000) - 8000 : 1000;
++pulls; return count;
}
extern "C" int radio_audio_pull_pcm(uint64_t, int16_t* out, int 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; ++pulls; for (int i = 0; i < count; ++i) out[i] = i % 2 ? 2000 : 1000; return count;
} }
@ -73,6 +78,85 @@ static void reset() {
std::lock_guard lock(mutex); std::lock_guard lock(mutex);
capture.clear(); transmitted.clear(); masks.clear(); captureTimes.clear(); captureResets = 0; talking = false; blocks = 0; capture.clear(); transmitted.clear(); masks.clear(); captureTimes.clear(); captureResets = 0; talking = false; blocks = 0;
} }
static void small_chunks() {
constexpr unsigned chunk = 256, total = chunk * 15;
std::array<int16_t, total> input{};
for (unsigned i = 0; i < total; ++i) input[i] = int(i) - 1920;
for (auto kind : {VoiceAudioIo::Kind::Call, VoiceAudioIo::Kind::Radio}) {
reset();
VoiceAudioIo voice;
REQUIRE(voice.start(kind, 45) && voice.startCapture(1, false));
int64_t base = now_us();
for (unsigned offset = 0; offset < total; offset += chunk) {
if (offset == chunk || offset == 5 * chunk) {
if (kind == VoiceAudioIo::Kind::Call) voice.setMuted(offset == chunk);
else voice.setPtt(offset == chunk);
}
voice.capture(input.data() + offset, chunk, base + int64_t(offset) * 1000000 / 48000, 1);
await([&] { std::lock_guard lock(mutex); return capture.size() >= (offset + chunk) / 960 * 960; });
}
voice.stop();
REQUIRE(captureResets == 0 && capture.size() == total && std::equal(capture.begin(), capture.end(), input.begin()));
if (kind == VoiceAudioIo::Kind::Call) {
for (unsigned i = 0; i < total; ++i) REQUIRE(masks[i] == (i >= chunk && i < 5 * chunk));
for (size_t i = 0; i < captureTimes.size(); ++i) REQUIRE(std::abs(captureTimes[i] - base - int64_t(i) * 20000) <= 1);
} else {
REQUIRE(transmitted.size() == 4 * chunk && !talking);
REQUIRE(std::equal(transmitted.begin(), transmitted.end(), input.begin() + chunk));
}
}
reset(); pulls = 0; output_position = 0; playbackSource = 0; rampPlayback = true;
VoiceAudioIo voice;
REQUIRE(voice.start(VoiceAudioIo::Kind::Call, 46));
for (unsigned offset = 0; offset < total; offset += chunk) {
await([&] { return unsigned(pulls.load()) * 960 >= offset + chunk + 960; });
float output[chunk * 2]{};
voice.mix(output, chunk, now_us() + 10000, true);
await([&] { return output_position.load() == offset + chunk; });
for (unsigned i = 0; i < chunk; ++i) {
float expected = (int(offset + i) - 8000) / 32768.0f;
REQUIRE(output[2*i] == expected && output[2*i+1] == expected);
}
}
voice.stop(); rampPlayback = false;
puts("256-frame chunks: PCM order, timestamps, mute/PTT boundaries and playback position PASS");
}
static void small_chunks_aec(unsigned captureChunk, unsigned renderChunk) {
reset();
constexpr unsigned total = 240 * 960, warmup = 180 * 960;
std::vector<int16_t> input(total);
std::vector<float> reference(total * 2);
uint32_t rng = 1234567;
double echoPower = 0, outputPower = 0;
for (unsigned i = 0; i < total; ++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];
input[i] = sample / 2;
if (i >= warmup) echoPower += double(input[i]) * input[i];
}
VoiceAudioIo voice;
REQUIRE(voice.start(VoiceAudioIo::Kind::Call, 47) && voice.startCapture(1, true));
int64_t base = now_us();
unsigned rendered = 0;
for (unsigned offset = 0; offset < total; offset += captureChunk) {
unsigned count = std::min(captureChunk, total - offset);
while (rendered < offset + count) {
unsigned frames = std::min(renderChunk, total - rendered);
voice.reference(reference.data() + 2 * rendered, frames, base + int64_t(rendered) * 1000000 / 48000);
rendered += frames;
}
voice.capture(input.data() + offset, count, base + int64_t(offset) * 1000000 / 48000, 1);
await([&] { return unsigned(blocks.load()) == (offset + count) / 960; });
}
voice.stop();
REQUIRE(capture.size() == total && captureResets == 0);
for (unsigned i = warmup; i < total; ++i) outputPower += double(capture[i]) * capture[i];
printf("small-chunk stereo AEC: capture=%u render=%u residual=%.6f\n", captureChunk, renderChunk, outputPower / echoPower);
REQUIRE(outputPower < echoPower * 0.1);
}
int main() { int main() {
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
// Резерв уменьшается только при стабильной работе; крупный callback и starvation повышают минимум. // Резерв уменьшается только при стабильной работе; крупный callback и starvation повышают минимум.
@ -213,5 +297,8 @@ int main() {
for (size_t i = 180 * 960; i < capture.size(); ++i) outputPower += (double)capture[i] * capture[i]; 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); printf("stereo reference residual ratio=%.6f\n", outputPower / echoPower);
REQUIRE(outputPower < echoPower * 0.1); REQUIRE(outputPower < echoPower * 0.1);
small_chunks();
small_chunks_aec(256, 480);
small_chunks_aec(480, 256);
puts("voice workers: exact PTT/mute boundaries, short tail, independent RX, stereo AEC PASS"); puts("voice workers: exact PTT/mute boundaries, short tail, independent RX, stereo AEC PASS");
} }

53
tools/chatgui/transport/audio_diagnostics.cpp

@ -96,6 +96,15 @@ struct Stage {
long tid = 0; long tid = 0;
bool delayed = false; bool delayed = false;
}; };
struct TimingRange {
uint64_t count = 0;
int64_t low = 0, high = 0, sum = 0;
void add(int64_t value) {
if (!count) low = high = value;
low = std::min(low, value); high = std::max(high, value); sum += value; ++count;
}
int64_t average() const { return count ? sum / (int64_t)count : 0; }
};
struct Device { struct Device {
std::string label = "unlabelled"; std::string label = "unlabelled";
uint64_t id = 0; uint64_t id = 0;
@ -103,9 +112,10 @@ struct Device {
std::array<uint64_t, AUDIO_DIAG_LOG + 1> faults{}; std::array<uint64_t, AUDIO_DIAG_LOG + 1> faults{};
audio_capture_timing capture{}; audio_capture_timing capture{};
uint64_t captureBlocks = 0, captureFrames = 0, captureSequence = 0, captureJumps = 0, captureMissing = 0, captureEstimated = 0; uint64_t captureBlocks = 0, captureFrames = 0, captureSequence = 0, captureJumps = 0, captureMissing = 0, captureEstimated = 0;
int64_t captureMinDelta = 0, captureMaxDelta = 0, captureMinLatency = 0, captureMaxLatency = 0; int64_t captureMinDelta = 0, captureMaxDelta = 0, captureMaxConverter = 0;
uint64_t playbackBlocks = 0, playbackFrames = 0, playbackMeasured = 0; TimingRange captureLatency, captureBlock, playbackLatency, playbackBlock;
int64_t playbackMinLatency = 0, playbackMaxLatency = 0, playbackMaxConverter = 0, playbackMaxBlock = 0; uint64_t playbackBlocks = 0, playbackFrames = 0;
int64_t playbackMaxConverter = 0;
}; };
void consume(const Event& e, std::map<uintptr_t, Device>& devices) { void consume(const Event& e, std::map<uintptr_t, Device>& devices) {
@ -133,10 +143,11 @@ void consume(const Event& e, std::map<uintptr_t, Device>& devices) {
const int64_t delta = c.first_us - expected; const int64_t delta = c.first_us - expected;
if (!d.captureBlocks) { if (!d.captureBlocks) {
d.captureMinDelta = d.captureMaxDelta = delta; d.captureMinDelta = d.captureMaxDelta = delta;
d.captureMinLatency = d.captureMaxLatency = c.latency_us;
} }
d.captureMinDelta = std::min(d.captureMinDelta, delta); d.captureMaxDelta = std::max(d.captureMaxDelta, delta); d.captureMinDelta = std::min(d.captureMinDelta, delta); d.captureMaxDelta = std::max(d.captureMaxDelta, delta);
d.captureMinLatency = std::min(d.captureMinLatency, c.latency_us); d.captureMaxLatency = std::max(d.captureMaxLatency, c.latency_us); if (c.measured) d.captureLatency.add(c.latency_us);
if (c.rate) d.captureBlock.add(int64_t(c.frames) * 1000000 / c.rate);
d.captureMaxConverter = std::max(d.captureMaxConverter, c.converter_us);
if (delta < -2000 || delta > 2000) { if (delta < -2000 || delta > 2000) {
if (++d.captureJumps == 1) if (++d.captureJumps == 1)
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "AudioDiag: capture clock jump dev=%p role=%s sequence=%llu frames=%u rate=%u " DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "AudioDiag: capture clock jump dev=%p role=%s sequence=%llu frames=%u rate=%u "
@ -154,14 +165,9 @@ void consume(const Event& e, std::map<uintptr_t, Device>& devices) {
} else if (e.kind == AUDIO_DIAG_PLAYBACK_CLOCK) { } else if (e.kind == AUDIO_DIAG_PLAYBACK_CLOCK) {
const auto& p = e.playback; const auto& p = e.playback;
++d.playbackBlocks; d.playbackFrames += p.frames; ++d.playbackBlocks; d.playbackFrames += p.frames;
if (p.rate) d.playbackMaxBlock = std::max(d.playbackMaxBlock, int64_t(p.frames) * 1000000 / p.rate); if (p.rate) d.playbackBlock.add(int64_t(p.frames) * 1000000 / p.rate);
d.playbackMaxConverter = std::max(d.playbackMaxConverter, p.converter_us); d.playbackMaxConverter = std::max(d.playbackMaxConverter, p.converter_us);
if (p.measured) { if (p.measured) d.playbackLatency.add(p.latency_us);
if (!d.playbackMeasured) d.playbackMinLatency = d.playbackMaxLatency = p.latency_us;
++d.playbackMeasured;
d.playbackMinLatency = std::min(d.playbackMinLatency, p.latency_us);
d.playbackMaxLatency = std::max(d.playbackMaxLatency, p.latency_us);
}
} else if (e.kind == AUDIO_DIAG_LABEL) { } else if (e.kind == AUDIO_DIAG_LABEL) {
d.label = e.text; d.id = uint64_t(e.value); d.label = e.text; d.id = uint64_t(e.value);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "AudioDiag: label dev=%p role=%s id=%016llx", (void*)e.device, e.text, (unsigned long long)d.id); DEBUG_INFO(DEBUG_CATEGORY_CALL, "AudioDiag: label dev=%p role=%s id=%016llx", (void*)e.device, e.text, (unsigned long long)d.id);
@ -209,22 +215,27 @@ void report(std::map<uintptr_t, Device>& devices, uint64_t wall, double elapsed)
for (auto& [key, d] : devices) { for (auto& [key, d] : devices) {
if (d.playbackBlocks) { if (d.playbackBlocks) {
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "AudioDiag: playback clock dev=%p role=%s blocks=%llu frames=%llu measured=%llu " DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "AudioDiag: playback clock dev=%p role=%s blocks=%llu frames=%llu measured=%llu "
"backend_us=%lld..%lld converter_max_us=%lld block_max_us=%lld", "backend_us=%lld/%lld/%lld converter_max_us=%lld block_us=%lld/%lld/%lld",
(void*)key, d.label.c_str(), (unsigned long long)d.playbackBlocks, (unsigned long long)d.playbackFrames, (void*)key, d.label.c_str(), (unsigned long long)d.playbackBlocks, (unsigned long long)d.playbackFrames,
(unsigned long long)d.playbackMeasured, (long long)d.playbackMinLatency, (long long)d.playbackMaxLatency, (unsigned long long)d.playbackLatency.count, (long long)d.playbackLatency.low,
(long long)d.playbackMaxConverter, (long long)d.playbackMaxBlock); (long long)d.playbackLatency.average(), (long long)d.playbackLatency.high, (long long)d.playbackMaxConverter,
d.playbackBlocks = d.playbackFrames = d.playbackMeasured = 0; (long long)d.playbackBlock.low, (long long)d.playbackBlock.average(), (long long)d.playbackBlock.high);
d.playbackMinLatency = d.playbackMaxLatency = d.playbackMaxConverter = d.playbackMaxBlock = 0; d.playbackBlocks = d.playbackFrames = 0;
d.playbackLatency = {}; d.playbackBlock = {}; d.playbackMaxConverter = 0;
} }
if (d.captureBlocks) { if (d.captureBlocks) {
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "AudioDiag: capture clock dev=%p role=%s id=%016llx blocks=%llu frames=%llu " DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "AudioDiag: capture clock dev=%p role=%s id=%016llx blocks=%llu frames=%llu "
"sequence=%llu delta_us=%lld..%lld latency_us=%lld..%lld jumps=%llu missing_frames=%llu estimated_blocks=%llu", "sequence=%llu delta_us=%lld..%lld latency_us=%lld/%lld/%lld jumps=%llu missing_frames=%llu estimated_blocks=%llu "
"converter_max_us=%lld block_us=%lld/%lld/%lld",
(void*)key, d.label.c_str(), (unsigned long long)d.id, (unsigned long long)d.captureBlocks, (void*)key, d.label.c_str(), (unsigned long long)d.id, (unsigned long long)d.captureBlocks,
(unsigned long long)d.captureFrames, (unsigned long long)d.captureSequence, (unsigned long long)d.captureFrames, (unsigned long long)d.captureSequence,
(long long)d.captureMinDelta, (long long)d.captureMaxDelta, (long long)d.captureMinDelta, (long long)d.captureMaxDelta,
(long long)d.captureMinLatency, (long long)d.captureMaxLatency, (long long)d.captureLatency.low, (long long)d.captureLatency.average(), (long long)d.captureLatency.high,
(unsigned long long)d.captureJumps, (unsigned long long)d.captureMissing, (unsigned long long)d.captureEstimated); (unsigned long long)d.captureJumps, (unsigned long long)d.captureMissing, (unsigned long long)d.captureEstimated,
(long long)d.captureMaxConverter, (long long)d.captureBlock.low,
(long long)d.captureBlock.average(), (long long)d.captureBlock.high);
d.captureBlocks = d.captureFrames = d.captureJumps = d.captureMissing = d.captureEstimated = 0; d.captureBlocks = d.captureFrames = d.captureJumps = d.captureMissing = d.captureEstimated = 0;
d.captureLatency = {}; d.captureBlock = {}; d.captureMaxConverter = 0;
} }
for (int i = 0; i < AUDIO_DIAG_STAGE_COUNT; ++i) { for (int i = 0; i < AUDIO_DIAG_STAGE_COUNT; ++i) {
auto& s = d.stages[i]; auto& s = d.stages[i];
@ -300,7 +311,7 @@ void audio_diag_start(void) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "AudioDiag: reporter start failed: %s", e.what()); DEBUG_ERROR(DEBUG_CATEGORY_CALL, "AudioDiag: reporter start failed: %s", e.what());
return; return;
} }
DEBUG_INFO(DEBUG_CATEGORY_CALL, "AudioDiag: started capacity=%llu check_interval=1s category=call normal=trace faults=warn; timestamps=CLOCK_MONOTONIC", DEBUG_INFO(DEBUG_CATEGORY_CALL, "AudioDiag: started capacity=%llu check_interval=1s category=call normal=trace faults=warn; ranges=min/avg/max timestamps=CLOCK_MONOTONIC",
(unsigned long long)kCapacity); (unsigned long long)kCapacity);
} }
void audio_diag_stop(void) { void audio_diag_stop(void) {

32
tools/chatgui/transport/miniaudio_impl.c

@ -30,6 +30,33 @@ static void audio_diag_pulse_started(ma_pa_stream* stream, void* userdata) {
(void)stream; (void)stream;
audio_diag_event(userdata, AUDIO_DIAG_BACKEND_STARTED, AUDIO_DIAG_WRITE, 1, 0); audio_diag_event(userdata, AUDIO_DIAG_BACKEND_STARTED, AUDIO_DIAG_WRITE, 1, 0);
} }
static double audio_buffer_ms(ma_uint32 bytes, ma_uint32 bytes_per_second) {
return bytes == (ma_uint32)-1 ? -1.0 : (double)bytes * 1000 / bytes_per_second;
}
/* После init, до запуска worker: размеры PA относятся к внутреннему PCM, не к client PCM. */
static void audio_diag_pulse_buffer(ma_device* device, int capture) {
ma_pa_stream* stream = capture ? device->pulse.pStreamCapture : device->pulse.pStreamPlayback;
if (!stream) return;
const ma_pa_buffer_attr* attr = ((ma_pa_stream_get_buffer_attr_proc)device->pContext->pulse.pa_stream_get_buffer_attr)(stream);
ma_format format = capture ? device->capture.internalFormat : device->playback.internalFormat;
ma_uint32 channels = capture ? device->capture.internalChannels : device->playback.internalChannels;
ma_uint32 rate = capture ? device->capture.internalSampleRate : device->playback.internalSampleRate;
ma_uint32 bpf = ma_get_bytes_per_frame(format, channels);
if (!attr || !rate || !bpf) {
audio_diag_log(ma_device_get_log(device), MA_LOG_LEVEL_WARNING, "PulseAudio buffer snapshot unavailable");
return;
}
ma_log_postf(ma_device_get_log(device), MA_LOG_LEVEL_INFO,
"[PulseAudio] dev=%p %s PCM format=%s channels=%u rate=%u bpf=%u\n",
(void*)device, capture ? "capture" : "playback", ma_get_format_name(format), channels, rate, bpf);
ma_log_postf(ma_device_get_log(device), MA_LOG_LEVEL_INFO,
"[PulseAudio] dev=%p %s buffer_ms tlength=%.3f fragsize=%.3f minreq=%.3f maxlength=%.3f prebuf=%.3f (-1=default)\n",
(void*)device, capture ? "capture" : "playback", audio_buffer_ms(attr->tlength, rate * bpf),
audio_buffer_ms(attr->fragsize, rate * bpf), audio_buffer_ms(attr->minreq, rate * bpf),
audio_buffer_ms(attr->maxlength, rate * bpf), audio_buffer_ms(attr->prebuf, rate * bpf));
}
#endif #endif
/* Только init-поток: backend-callbacks остаются короткими наблюдателями. */ /* Только init-поток: backend-callbacks остаются короткими наблюдателями. */
@ -37,7 +64,10 @@ static void audio_diag_pulse_init(void* device) {
#ifdef MA_SUPPORT_PULSEAUDIO #ifdef MA_SUPPORT_PULSEAUDIO
ma_device* dev = (ma_device*)device; ma_device* dev = (ma_device*)device;
ma_pa_stream_set_suspended_callback_proc underflow, overflow, started; ma_pa_stream_set_suspended_callback_proc underflow, overflow, started;
if (dev->pContext->backend != ma_backend_pulseaudio || !dev->pulse.pStreamPlayback) return; if (dev->pContext->backend != ma_backend_pulseaudio) return;
audio_diag_pulse_buffer(dev, 1);
audio_diag_pulse_buffer(dev, 0);
if (!dev->pulse.pStreamPlayback) return;
#ifndef MA_NO_RUNTIME_LINKING #ifndef MA_NO_RUNTIME_LINKING
underflow = (ma_pa_stream_set_suspended_callback_proc)dlsym(dev->pContext->pulse.pulseSO, "pa_stream_set_underflow_callback"); underflow = (ma_pa_stream_set_suspended_callback_proc)dlsym(dev->pContext->pulse.pulseSO, "pa_stream_set_underflow_callback");
overflow = (ma_pa_stream_set_suspended_callback_proc)dlsym(dev->pContext->pulse.pulseSO, "pa_stream_set_overflow_callback"); overflow = (ma_pa_stream_set_suspended_callback_proc)dlsym(dev->pContext->pulse.pulseSO, "pa_stream_set_overflow_callback");

Loading…
Cancel
Save