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.
 
 
 
 
 
 

36 KiB

Linux: диагностика и восстановление аудио

Проверка 2026-10-01: desktop chatgui, miniaudio/PulseAudio, общий голосовой стек, звонки, рация и запись сообщений. Исправления обработки PCM, владения устройствами и автоматическое восстановление реализованы. Ниже сохранены исходные результаты аудита; раздел «Реализация» описывает действующие контракты и проверки.

Вывод

До исправлений тракт был некорректен при сбоях. Самое существенное: воспроизведение звонка зависит от наличия микрофонных кадров в duplex-ring. Его опустошение останавливает вызов пользовательского callback, оставляя хвост выходного буфера незаполненным. Восстановление jitter-буфера не исправляет остановившееся устройство. Отдельного управляющего механизма восстановления устройств не было.

В реализации восстановлены три гарантии: каждый запрос output полностью заполнен; обработка одного callback всегда ограничена; остановка/пересоздание устройства выполняются вне аудиопотока и не уничтожают сетевую сессию звонка.

Что установлено по работающим клиентам

В локальном старом логе звонка 132e4a9698f47bdf с Samsung SM_A525F наблюдались паузы callback примерно 0,5–2,6 секунды при зарегистрированной обработке 0–2 мс. Входящий буфер desktop достигал 15 секунд, росли потери, включалось ускорение 160%. На Samsung захват продолжал давать около 50 кадров/с, playback регистрировал underrun и тоны разрыва. RTT порядка 1–4 мс не объясняет эти паузы сам по себе.

Это признаки проблемы обслуживания локального аудио, но ещё не доказательство конкретной причины зависания PulseAudio. Старое измерение исключало время синхронных DEBUG-вызовов между концом обработки и обновлением timestamp. Новая диагностика охватывает весь callback, включая эти логи. Работающий GUI не перезапускался, поэтому новый инструмент ещё не записал этот сбой на реальном звонке. Лог Samsung сохранён в /tmp/sm-audio-current.log.

Найденные дефекты и ограничения до исправлений

Место Поведение и последствия Необходимая доработка
lib/miniaudio.h, ma_device__handle_duplex_callback_playback() При пустом capture-ring acquire_read возвращает success с нулём кадров. Выполняется break, output остаётся частично незаполненным, call callback не вызывается. PulseAudio затем отправляет весь mapped buffer. Обеспечить полный output и продолжение playback при отсутствии capture. Нельзя делать commit_read для синтетических кадров.
ma_device_on_write__pulse() Успешная запись с нулём обработанных кадров не уменьшает остаток цикла. Возможно бесконечное вращение с блокированием того же PA mainloop, включая capture. Немедленно выходить при отсутствии продвижения, возвращать управление mainloop. Логировать состояние и запрос, не пытаться записывать до успеха в этом callback.
ma_device_on_read__pulse() PA hole отбрасывается без подачи соответствующего количества нулевых кадров. Из capture timeline исчезает интервал, duplex-ring может опустеть. Сохранять длительность hole тишиной либо явно обозначать discontinuity независимому capture-тракту.
lib/speex_aec.c, speex_aec_feed_playback() Обрабатывает только остаток одного 960-sample кадра, отбрасывая хвост входного блока. Контракт разрешает произвольный count. Циклом потреблять весь count, складывать все полные кадры, сохранять остаток.
CallAudioEngine::setPlaybackDeviceIndex() После неудачного пересоздания m_active остаётся true при отсутствии устройства; start того же звонка возвращает успех. Разделить желание продолжать звонок и работоспособность устройства. Ошибка запуска переводит устройство в recovering.
RadioAudioEngine::teardownDevice() Если playback init успешен, а start нет, uninit пропускается: он зависит от флага started. Объект удаляется вместе с незавершёнными ресурсами. Разделить initialized и started; каждый успешный init обязательно завершать uninit.
MainWindow::~MainWindow() / SoundManager::shutdown() Общий context освобождается вместе с engine, но звонок и рация здесь явно не останавливаются. Их singleton-объекты могут оставлять живые устройства. m_context также не обнуляется. Сначала остановить все зависимые устройства и дождаться callbacks, затем освобождать общий context.
AudioRecorder Callback выбирает глобальный g_recorder, игнорируя pUserData. Второй recorder, в том числе тест микрофона, заменяет получателя. Флаги/уровень/вектор waveform используются из GUI и аудиопотока без согласованной синхронизации. Использовать pUserData; передавать уровни атомарно, waveform — безопасным snapshot; буфер записи должен иметь однозначного владельца.

До исправлений первые два дефекта воспроизводились стендом без аппаратуры: duplex оставлял sentinel в output при starvation, а PA-write без продвижения требовал внешней остановки. AEC из 1200 + 720 samples сохранял один кадр вместо двух. Теперь эти проверки требуют полного output, самостоятельного возврата из callback и двух сохранённых AEC-кадров; результаты приведены ниже.

Capture-AEC также обрабатывает не более одного кадра за вызов. В звонке его вызывают ровно с 960 samples, поэтому текущий call feed этот случай не нарушает. При расширении API нельзя объявлять произвольный count без согласованного размера выходного буфера. speex_aec_reset() уже существует: новый API для сброса самого AEC не нужен, но нужен согласованный сброс всего локального voice-тракта.

Чистота реализации

Этот раздел фиксирует исходный аудит; изменения владения и ограничение работы callback описаны в разделе «Реализация».

Нормальная структура уже есть: GUI владеет устройствами, сервисный слой кодирует Opus и обрабатывает jitter, сетевой поток доставляет пакеты. Нет необходимости переписывать весь стек или заводить новый backend.

Однако границы владения не завершены: context фактически принадлежит engine звуковых уведомлений, а используется независимыми устройствами звонка/рации. Флаги active/started/initialized местами смешивают три разных состояния. Выбор input применяется к recorder, звонок использует default input, рация — default устройства. Output звонка и SoundManager тоже выбираются отдельно. Индекс перечисления не является устойчивой идентичностью устройства после hotplug. В radio capture fallback useDefault фактически повторяет ту же попытку: pDeviceID и так NULL в обеих ветках. Callbacks рации запускаются раньше подготовки capture-буфера и установки VAD; порядок запуска нужно привести к «сначала состояние и буферы, затем устройства», чтобы исключить гонки при старте.

Callback не является строго realtime-safe. Capture удерживает g_mtx во время AEC/AGC/Opus; отправка выделяет память и вызывает uasync_post. Playback вызывает SoundTouch и код с возможным расширением контейнеров, mutex и синхронными логами. Приём пакетов тоже может держать mutex при работе с jitter и логировании. Плохие Opus-кадры могут последовательно выбираться из большой очереди в одном pull: нужен предел работы на callback. Новая диагностика покажет, где это действительно нарушает deadline. Перенос всего DSP в ещё один worker заранее увеличит число очередей и точек синхронизации без установленной необходимости.

AEC получает reference только звонка. Звуки отдельного SoundManager в него не попадают; delay по умолчанию фиксирован, не вычисляется по фактической задержке устройств. Это ограничение качества эхоподавления, а не установленная причина текущего зависания.

Jitter умеет повторно накопить резерв после сетевого underrun, но диапазон tempo ограничен 1,0–1,6. Ускорение убирает избыток, а замедления для компенсации более медленных часов отправителя нет. При устойчивом отрицательном дрейфе резерв будет снова опустошаться. Это отдельная задача длительных звонков: после восстановления устройств проверить наклон глубины буфера и при необходимости добавить небольшую компенсацию дрейфа в обе стороны, не смешивая её с быстрым catch-up после паузы.

Принудительные 480 frames задают период 10 мс при 48 кГц. Собственный PA default miniaudio — 25 мс с комментарием о проблемах PipeWire при периодах меньше ~20 мс. Период backend и Opus-кадр 20 мс должны оставаться независимыми. Увеличивать период до исправления AEC нельзя: 1200-sample блоки уже воспроизводят потерю хвоста. После исправления разумно сначала проверить default backend и измерить задержку.

Предлагаемое восстановление без большого усложнения

1. Сначала восстановить инварианты обработки и владения

Исправить полный output, zero-progress, AEC accumulation и uninit при неудачном start. Перед освобождением context явно остановить recorder, звонок, рацию и прочие его устройства. До остановки отменять recovery timer; после join можно сбрасывать voice-ресурсы. Проверять возвраты feed/start/init и логировать причину.

Самый небольшой вариант — оставить один duplex-device: при нехватке capture подать в call callback нулевой input, полностью сформировать playback и не продвигать реальный capture-ring за синтетические samples. Это чинит непосредственный дефект, но сохраняет общий жизненный цикл input/output и синтетический TX по playback-clock.

Для устойчивости к исчезновению микрофона предпочтительнее два устройства capture/playback внутри существующего CallAudioEngine, как уже сделано в рации. Capture только накапливает 960 samples и вызывает feed; playback независимо вызывает pull и всегда заполняет output. Это два handle с одним владельцем, без нового DSP-worker или дополнительной PCM-очереди. Потребуется уточнить потоковый контракт voice-слоя: один capture-writer и один playback-writer, согласованный доступ к AEC, остановка обоих до release. Текущий g_mtx уже защищает AEC, но остальные общие поля нужно проверить перед разделением.

2. Разделить краткий underrun и неисправность устройства

Событие Действие
Нет сетевого PCM, callback продолжает приходить Тишина/существующий gap tone; jitter автоматически накапливает данные. Устройство не пересоздавать.
Один backend underrun, новые кадры принимаются Подать следующий полностью заполненный буфер; собрать счётчик. Не сбрасывать AEC и не запускать restart на каждый xrun.
Hole/переполнение capture Учесть потерянную длительность; краткую потерю пережить, длительную отметить discontinuity для AEC.
Поток failed/stopped или нет продвижения кадров длительное время Перейти в recovering и пересоздать затронутое устройство вне callback.
Suspend/resume ОС Учитывать suspend как отдельную причину, восстановление проверять после resume; не запускать частый restart-loop во время сна.
Потерян общий backend/context Однократно остановить всех его пользователей, восстановить владельца, затем требуемые устройства.

Один owner timer и состояния Stopped / Running / Recovering достаточны. wanted хранит желание пользователя продолжать; generation меняется при stop, новом звонке и смене устройства. Каждая отложенная попытка проверяет generation и wanted — законченный звонок никогда не возрождается.

Callbacks только обновляют атомарные счётчики успешно обработанных кадров и признак backend failure. Watchdog проверяет фактический прогресс отдельно по направлениям. Тишина в PCM не означает остановку устройства. Reporter диагностической очереди не должен становиться watchdog: его события могут теряться.

Начальные значения для проверки: timer 200 мс, подозрение при отсутствии прогресса 500 мс после startup grace; повторные попытки через 250/500/1000 мс с потолком 2 секунды. Это предлагаемые настройки, не доказанные универсальные пределы. Suspend и фактический согласованный backend period должны учитываться отдельно. Нельзя обещать bounded recovery, пока init/stop/uninit сами могут зависнуть: сначала устранить spin-loop и проверить время backend-операций. Если они реально блокируют GUI, вынести только управление устройствами в один owner-thread, сохранив тот же автомат состояний.

На пересоздании сбросить capture-accumulator, AEC delay/filter и локальный PCM/ stretch, сохранив call ID и сетевую сессию. После длительной аппаратной паузы устаревшую речь нужно явно отбросить до актуального резерва jitter, иначе 15 секунд накопленного звука при скорости 160% разгружаются примерно 25 секунд. Этот сброс применять только при аппаратной discontinuity; обычный сетевой jitter не должен автоматически приводить к отбрасыванию очереди.

Устройство хранить по backend ID либо как default, не по индексу. При его исчезновении попробовать default и явно сообщить о смене; выбранное устройство можно вернуть при появлении. Нужны ошибки с ID/именем/направлением и результатом попытки. Для PA-ошибок следующий уровень диагностики — stream/context state и pa_context_errno, потому что один -1 от API не объясняет причину.

3. Проверить сценарии, а не только успешный старт

После реализации добавить проверки: capture starvation 20/500 мс и возврат; hole; AEC блоки, пересекающие границу кадра; zero writable; ошибка записи; исчезновение выбранного устройства и появление default; suspend/resume; перезапуск звукового сервера; stop во время backoff; закрытие GUI при активном звонке/рации. Проверять полный output, bounded очереди и работу callback, возобновление прогресса, отсутствие callbacks после release и отсутствие повторного старта после пользовательского stop.

Как читать добавленную диагностику

В GUI-категориях включить call=debug либо задать отдельную строку в INI:

[debug]
call=debug

Обычные сводки идут на DEBUG, аномалии — WARN/ERROR; miniaudio info перенесён на DEBUG, подробный backend log — TRACE. Все устройства используют категорию call, включая recorder, radio и sound-engine; role и ID различают их в строках. Не включать глобальный trace для всего проекта при измерении аудио.

Reporter раз в секунду печатает stage, tid, callbacks/s, requested frames/s, максимальный интервал между входами, полное wall-time обработки и thread CPU-time, незавершённый вход и время с последнего входа. work_max и cpu_max — отдельные максимумы за окно, они не обязательно относятся к одному вызову.

Stage Что измеряется
loop Один pa_mainloop_iterate, включая ожидание и вложенные callbacks. Долгое время само по себе не доказывает тяжёлый DSP.
read / write Полный backend read/write callback. Frames/s здесь — запрошенные, не гарантированно обработанные кадры.
map writable_size и begin_write; пустой/ошибочный выход тоже закрывает измерение.
submit Вызов pa_stream_write.
duplex Перенос capture-ring в duplex-output; detail содержит глубину capture-ring.
callback Полный miniaudio data callback, включая код приложения и его синхронные логи.
feed / pull Capture DSP/encode/send и playback jitter/stretch/tone в звонке.

unfilled_frames — незаполненный output, hole_frames — потеря capture timeline, capture_drop_frames — переполнение capture-ring, no_progress — PA-write без продвижения. backend_underruns — отдельный callback самого PA-stream; это не то же самое, что пустой сетевой jitter. backend playback started/resumed показывает фактическое начало/возобновление playback. Notification содержит числовые miniaudio type/state.

Первое событие каждого типа за окно имеет tid и CLOCK_MONOTONIC timestamp; повторения суммируются. Для задержавшихся потоков reporter читает /proc wchan и накопленные schedstat CPU/ready-wait/slices. Сравнивать соседние snapshots: сами totals не являются длительностью текущей паузы. CPU-time намного меньше wall-time означает ожидание/вытеснение; wchan помогает отличить poll от mutex/ I/O wait. Это снимок в момент отчёта, не исторический стек зависшего вызова.

Producer не выделяет память, не ждёт mutex reporter и не пишет диагностические логи: ограниченная MPSC-очередь 8192 события, не более четырёх CAS-попыток. Reporter не разыменовывает ma_device/pa_stream. Переполнение логируется как diagnostic events lost; такое окно нельзя считать полным. Инструментация добавляет получение часов/TID и атомарные операции, поэтому overhead ненулевой. Частые события call jitter/AEC/TX теперь публикуются счётчиками и выводятся из uasync вне аудиомьютекса. Часть существующих логов рации остаётся синхронной; тракт не объявляется строго realtime-safe. После close устройство удаляется из reporter: для устройств короче секунды доступны lifecycle и первые аномалии, но не полная секундная сводка.

Новый binary собран в tools/chatgui/build/vibechat; для нового измерения нужен его обычный перезапуск. Изменение файла binary не обновляет уже запущенный процесс.

Реализация

AudioDevice — небольшой владелец одного miniaudio device в GUI-потоке. Успешный init всегда имеет парный uninit, включая ошибку start. Callback и его данные готовятся до start. Close останавливает/join-ит worker перед очисткой callback. Прогресс — атомарный счётчик завершённых кадров, независимый от reporter. Каждый владелец проверяет свои устройства Qt-таймером 200 мс: stopped либо 1 с без продвижения требуют пересоздания. Suspend обновляет grace; единичный xrun не запускает restart. Backoff: 250/500/1000/2000 мс, сброс после прогресса.

Capture и playback звонка теперь независимы. Период выбирает backend; аппаратный чанк не обязан совпадать с 20-мс Opus-кадром. Рация заполняет большой output несколькими pulls по максимум 20 мс. AEC сохраняет весь render, включая хвосты 1200/720 samples, а capture принимает только полный кадр. При аппаратном recovery после join сбрасываются capture accumulator, AEC и локальный jitter/stretch; из encoded backlog сохраняется только свежий резерв. Call ID и группа сохраняются.

SoundManager отдельно владеет общим контекстом и устройством вывода. ma_engine работает без своего устройства: mixer, звуки и PCM cursor переживают recovery. При failed context синхронный сигнал сначала закрывает всех клиентов, затем context освобождается. После повторного init клиенты открываются по desired state. Stop выключает recovery; таймер завершения звонка проверяет generation. MainWindow останавливает звонок/рацию/recorder до SoundManager и сетевого ядра. Регистрация desktop voice ops выполняется в потоке ядра.

PulseAudio control-waits ограничены 1 с на операцию, mainloop iterates неблокирующие. При timeout pending operation отменяется до возврата из функции, чтобы callback не получил просроченный stack pointer. Partial init закрывает PA context/mainloop и события miniaudio. Device worker распознаёт failed context/stream; лог содержит состояния и PA errno. Capture hole подаётся как тишина той же длительности; zero writable/zero converter progress возвращают управление. Duplex output заранее полностью инициализируется, starvation не выключает playback callback.

TX ограничен восемью задачами до помещения в uasync, возраст — максимум 200 мс. Generation исключает передачу кадров старого звонка/захвата. Ещё не обработанные старые задачи учитываются и при повторном start, поэтому очередь ограничена между поколениями. Task и аргументы имеют одну аллокацию; destroy очереди освобождает их и для media, и для PTT control. Bad Opus в звонке учитывается в бюджете decode на pull. Ошибки encode/очереди/выделения памяти/возраста различаются в сводке TX.

Устройства сохраняются как backend ID вместо индекса. При init/start failure явного выбора пробуется default, причина логируется. Выбранный ID сохраняется; вернуться к нему можно повторным выбором или при следующем recovery, автоматический опрос hotplug не добавлен. Recorder использует pUserData своего устройства, компрессор настраивается до записи, UI получает атомарный уровень и длительность по записанным кадрам. Настройки компрессора в ходе записи применяются к следующей.

Проверки реализации

cmake --build tools/chatgui/build -j4 — успешно.

ctest --test-dir tools/chatgui/build --output-on-failure \
  -R 'test_(linux_audio|audio_diagnostics|audio_recovery|call_jitter|radio_audio|radio_jitter|voice_file)$'

7/7 успешно. Проверены целостность MPSC, bounded overflow/restart reporter, duplex starvation, PA zero-progress, capture hole, deadline/cancel, сохранение AEC render, bad Opus budget, partial start/init, две записи одновременно, восстановление только playback, потеря общего context и повторные отказы init, stop во время backoff, старый end timer, сохранение PCM cursor, selected→default, TX limit между поколениями и освобождение pending PTT при destroy.

Изолированная Debug-сборка в /tmp/utun-audio-asan: четыре теста audio_recovery/linux_audio/call_jitter/radio_audio проходят с ASAN/UBSAN и LeakSanitizer. Запуск вне песочницы нужен LeakSanitizer для проверки потоков.

Реальный перезапуск сервера:

python3 tools/chatgui/tests/run_pulse_recovery.py

Тест создаёт собственные PipeWire, PulseAudio и WirePlumber в /tmp, отключает аппаратные monitors, использует виртуальный sink/monitor source. После остановки PulseAudio проверяет освобождение контекста; после запуска — новый прогресс capture и прежнего PCM sound. Проверка прошла: capture 325→575 мс, playback 16384→29184 frames. Системный звуковой сервер не перезапускается.

Полный check.sh до исключения async-тестов: 119 passed, 0 failed, 1 skipped; proxy/burst/load интеграции прошли. По последующему указанию пользователя async тесты исключены из дальнейших запусков. ./gradlew --offline assembleDebug — успешно, APK не установлен на телефон.

Физический USB/Bluetooth hotplug, сон ОС и качество длительного реального звонка этими проверками не подтверждены. AEC reference отдельных уведомлений и компенсация дрейфа часов звонка остаются ограничениями исходного DSP. Граница 1 с относится к отдельной PA-операции: последовательность init/uninit нескольких устройств может задержать GUI дольше. Дополнительный owner-thread/DSP-worker не добавлен.

Референсы

Первичные исходники и документация просмотрены 2026-10-01; ссылки на master могут изменяться. Используем принципы, а не копируем целиком архитектуру библиотеки.

  • PipeWire audio-src-ring.c: callback читает доступные кадры, заполняет остаток тишиной и возвращает полный буфер; пополнение инициирует через событие управляющего потока. Хороший минимальный пример поведения при пустой PCM-очереди.
  • PulseAudio stream API: отдельные underflow/overflow/started/suspended/moved callbacks; peek различает пустую очередь и hole с ненулевой длиной. Нужно разделять эти состояния в логах.
  • PulseAudio pacat.c: underflow callback сообщает событие без пересоздания stream. При failed context утилита завершает работу — это пример диагностики, не готовая self-healing система.
  • Mozilla cubeb PulseAudio backend: явные context/stream states, пересоздание ошибочного context при инициализации, согласованное отключение callbacks и доступ под mainloop lock; minimum latency 25 мс. Это полезный референс владения и границ управления, а не основание автоматически заимствовать всю библиотеку.
  • miniaudio: init/start/stop/uninit запрещены внутри data callback из-за deadlock. Просмотренный upstream тоже содержит break при пустом duplex-ring: обновление библиотеки само по себе не решает этот найденный дефект.