45 KiB
Linux: диагностика и восстановление аудио
Проверка 2026-10-02: 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 не обновляет уже запущенный процесс.
Реализация (2026-10-02)
SoundManager владеет общим контекстом и конечными выходами. На каждый фактический
выход приходится один микшер: PCM сообщений/видео, заранее декодированные сигналы,
готовый голос звонка и рации. После суммирования float PCM ограничивается, и один
и тот же stereo буфер отправляется устройству и AEC соответствующего микрофона.
Звук другого выхода в референс не входит. Default/явный backend ID объединяются;
PulseAudio также публикует идентификатор фактического маршрута. После server move
совпавшие выходы объединяются в GUI только после подтверждения фактического
маршрута; предположение о default не используется вместо PA snapshot. При временной коллизии дополнительный выход
отдаёт тишину до объединения. Изменение списка источников выполняется после stop
callback, с сохранением backend stream и выбранного сервером маршрута.
Устройства сохраняются как backend ID вместо индекса. При init/start failure явного выбора пробуется default, причина логируется. Выбранный ID сохраняется; вернуться к нему можно повторным выбором или при следующем recovery. Автоматический опрос hotplug для возврата на первоначальный выбор не добавлен.
AudioDevice — владелец одного miniaudio device в GUI. Успешный init всегда имеет
парный uninit, включая ошибку start. Callback и его состояние готовятся до start.
Close останавливает/join-ит device worker перед освобождением callback. Отдельный
атомарный счётчик кадров контролирует прогресс. Таймер 200 мс проверяет stopped
и отсутствие продвижения 1 с; suspend продлевает grace, отдельный xrun не вызывает
restart. Backoff: 250/500/1000/2000 мс, сброс после продвижения.
Miniaudio не добавляет накопитель фиксированного периода: noFixedSizedCallback
включён, hardware chunks могут иметь любую длину. В PulseAudio физическое начало
блока определяется по timing snapshot с AUTO_TIMING_UPDATE/INTERPOLATE_TIMING.
Capture: monotonic now минус latency read index, без повторного вычитания длины
блока. Playback: now плюс latency write index. Учитываются задержка converter и
остающийся input cache. Один backend dispatch имеет один snapshot, позиции
последующих client callbacks продвигаются по числу кадров. В отсутствие snapshot
используется оценка по callback; источник времени и physical route логируются.
PA hole отдельно помечен как отсутствующий capture, а не как реальная тишина.
VoiceAudioIo создаётся только для звонка и рации. Capture и RX обрабатывают
независимые потоки. Hardware capture только копирует PCM в фиксированную MPSC-очередь;
RX worker выполняет decode/SoundTouch/тоны и поддерживает до 40 мс готового голоса.
В сервисном слое рации вычисление Silero держит только TX mutex: общий mutex
отпущен, поэтому VAD не блокирует RX; lifetime и порядок PCM/PTT сохранены.
Hardware output смешивает готовый PCM. Opus, AEC, VAD, AGC и растущие буферы записи
не исполняются в аппаратных callbacks. Обработка самого backend, PCM/resampler
узлов miniaudio и атомарные уведомления остаются в callback; строгая hard realtime
гарантия не заявляется.
Capture worker — единственный владелец AEC и порядка PCM/PTT/mute. Кадр AEC —
960 отсчётов на канал; 1/2 канала микрофона и 2 независимых канала референса.
Противофазное стерео не усредняется. История рендера содержит до 1,92 с PCM и
физические timestamps. Reference выбирается по времени микрофона; небольшое
различие часов компенсируется интерполяцией только референса. Реальная тишина
сохраняется как валидный PCM; при отсутствующем reference AEC пропускает микрофон
и не адаптируется. Потеря reference, смена маршрута/поколения и скачок часов
сбрасывают фильтр. Возраст capture-очереди ограничен 100 мс от enqueue, отдельно
от собственной задержки backend. Потери и очередь видны в сводках категории aec.
Mute применяется после AEC/AGC: фильтр продолжает получать настоящий микрофон,
а Opus получает нули только в отмеченной части кадра. Desktop запускает core
через call_audio_start_prepared: повторного core AEC нет. Raw API с core AEC
сохранён для Android/headless; смешивать политики в активном звонке нельзя.
У рации один автомат передачи: manual PTT или VAD. VAD читает непрерывный очищенный сигнал до AGC, включая время ручного PTT. Передача использует отдельный накопитель Opus-кадра; границы PTT внутри AEC-кадра сохраняются. При закрытии серии хвост дополняется нулями только перед Opus и помещается в очередь перед FIN. Короткий capture tail при stop проходит без AEC, а не дополняет непрерывный фильтр. Разрыв захвата завершает старый сетевой хвост и сбрасывает VAD, исключая склейку через потерянные отсчёты. Capture restart завершает принятую серию; новое прослушивание того же канала отменяет старые BEGIN/media. FIN проверяет токен своего capture/burst и поколение экземпляра ядра, поэтому не закрывает более новую серию и не обращается к освобождённому ядру даже при повторном использовании его адреса.
TX ограничен 8 media-задачами, возраст — 200 мс. У рации дополнительно ограничено 16 control-задач; FIN выделяется заранее вместе с BEGIN. Если control-очередь занята, новый BEGIN откладывается до освобождения; это логируется. Очередь PCM резервирует 8 слотов для управляющих сообщений; producer не меняет consumer и публикует слот только после копирования. Capture callback никогда не ждёт слот. Отказ постановки GUI-команды завершает capture; таймер запускает recovery.
AudioRecorder получает raw microphone без AEC. Его worker обрабатывает AGC,
накапливает запись и публикует атомарный уровень/длительность. Переполнение или
ошибка обработки отклоняют запись с логом. Meter-only использует тот же capture,
но не сохраняет PCM и не создаёт голосовое сообщение. Stop: close device, drain
и join worker, затем передача владельца записи задаче attachment. Настройки
компрессора в ходе записи применяются к следующей.
PCM воспроизводится через независимые handles. Сообщение, видео и тест динамика не останавливают чужой PCM; sample rate принадлежит источнику. Seek пересоздаёт узел и очищает его read/resampler cache, сохраняя handle и владельца PCM. Cursor вычисляется по реально обработанным графом кадрам, а не по prefetched datasource. Pause отсоединяет узел и ждёт начатое чтение графа; resume подключает тот же узел, сохраняя его PCM и cache. Соседние проигрыватели продолжают работать. У асинхронного открытия видео/голоса проверяется generation.
Потоковый AGC использует фиксированные кольца истории и PCM, нулевой lookahead и непосредственный process_frame без растущего output. У записи lookahead явно 100 мс; растёт только сохраняемый результат. Push/flush/configure возвращают ошибки; flush проверяется перед кодированием вложения.
Порядок остановки голосового направления: close capture → join capture worker → detach output → join RX worker → release core. При failed context синхронный сигнал сначала закрывает всех клиентов, затем освобождает context. Mixer, PCM handles, call ID и группа переживают recovery. Stop выключает recovery; таймер завершения звонка проверяет generation. MainWindow останавливает клиентов до SoundManager и сетевого ядра. Сетевые операции и регистрация audio ops выполняются в uasync.
PulseAudio control-waits ограничены 1 с на операцию; при timeout operation отменяется до возврата, исключая callback с просроченным stack pointer. Partial init освобождает контекст/mainloop/события. Failed stream/context содержит PA errno в логе. Zero writable/zero converter progress возвращают управление. Последовательность нескольких операций init/uninit всё ещё может задержать GUI дольше секунды; отдельный device owner-thread не добавлен.
Проверки реализации
vibechat и аудиотесты собираются через CMake. Набор проверки:
ctest --test-dir tools/chatgui/build --output-on-failure \
-R 'test_(linux_audio|audio_timing|audio_diagnostics|audio_recovery|audio_event_queue|voice_audio_io|voice_tx|call_jitter|radio_audio|radio_jitter|voice_file)$'
make -C tests test_audio_compressor test_speex_aec
./tests/test_audio_compressor
./tests/test_speex_aec
Добавлены отдельные проверки MPSC (80000 сообщений, reserve/control, слот ещё не опубликован), точных PCM/PTT/mute границ, короткого хвоста, независимого RX при задержке capture worker, противофазного stereo AEC, старого физического timestamp при свежем enqueue, совпадения реальных Opus-пакетов с правильно дополненным хвостом, rapid PTT, capture restart, отмены старого прослушивания и bounded control. Проверяются manual/VAD handover без второй серии и продвижение настоящего сервисного RX при заблокированном VAD. Timing test проверяет read-index timestamp, несколько converter callbacks одного блока и метку hole. Recorder тестирует raw PCM и отсутствие вложения в meter-only.
Speex проверяет подавление эха и сохранение double-talk также для двух микрофонов с двумя независимыми каналами reference. В детерминированном синтетическом тесте остаточная энергия эха — около 0,009 исходной; корреляция near-end — около 0,95. Эти величины относятся только к тесту, а не к реальным акустическим условиям.
В /tmp/utun-audio-asan проверяются пять тестов нового тракта с ASAN/UBSAN и
LeakSanitizer; LeakSanitizer запускается вне песочницы. MPSC отдельно проверяется
ThreadSanitizer. Изолированный интеграционный запуск:
python3 tools/chatgui/tests/run_pulse_recovery.py
Итоговый прогон 2026-10-02: сборка vibechat успешна, CTest — 11/11,
ASAN/UBSAN/LeakSanitizer — 5/5, компрессор — 479/479, Speex — PASS,
MPSC под ThreadSanitizer — PASS. Timing-стенд прошёл ещё 100 последовательных
повторов. Пауза PCM проверяется также серией pause/resume при работающем соседнем
проигрывателе; она ждёт чтение узла, а не только меняет его флаг.
Тест создаёт собственные PipeWire/PulseAudio/WirePlumber в /tmp, отключает
аппаратные monitors и использует два виртуальных sink. Проверяет объединение
voice с общим выходом после move-sink-input, сохранение desired выхода звонка
при recovery общего выхода (включая server stream-restore), затем восстановление capture/PCM
после остановки и запуска своего PulseAudio. Звонок и рация сохраняют идентичность;
recorder и медиа продолжают работу. Системный аудиосервер не затрагивается.
Исторические проверки исходного recovery: полный check.sh — 119 passed,
0 failed, 1 skipped; proxy/burst/load интеграции прошли. По указанию пользователя
async-тесты исключены из дальнейших запусков. APK ранее собран offline; установка
на телефон не выполнялась.
Физический USB/Bluetooth hotplug, сон ОС, внешние звуки других приложений и качество AEC в длительном реальном звонке этими проверками не подтверждены. Reference охватывает звук этого приложения на том же выходе, согласно выбранному контракту.
Референсы
Первичные исходники и документация просмотрены 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: обновление библиотеки само по себе не решает этот найденный дефект.