Browse Source

Опиши контракты аудиотракта и результаты проверок

master
evgeny 1 day ago
parent
commit
01bc6e4cf0
  1. 227
      doc/linux_audio_recovery.md

227
doc/linux_audio_recovery.md

@ -1,6 +1,6 @@
# Linux: диагностика и восстановление аудио
Проверка 2026-10-01: desktop chatgui, miniaudio/PulseAudio, общий голосовой стек,
Проверка 2026-10-02: desktop chatgui, miniaudio/PulseAudio, общий голосовой стек,
звонки, рация и запись сообщений. Исправления обработки PCM, владения устройствами
и автоматическое восстановление реализованы. Ниже сохранены исходные результаты
аудита; раздел «Реализация» описывает действующие контракты и проверки.
@ -238,95 +238,174 @@ uasync вне аудиомьютекса. Часть существующих л
Новый 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.
## Реализация (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 не добавлен. Recorder использует pUserData своего устройства,
компрессор настраивается до записи, UI получает атомарный уровень и длительность
по записанным кадрам. Настройки компрессора в ходе записи применяются к следующей.
вернуться к нему можно повторным выбором или при следующем 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 не добавлен.
## Проверки реализации
`cmake --build tools/chatgui/build -j4` — успешно.
`vibechat` и аудиотесты собираются через CMake. Набор проверки:
```sh
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)$'
-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
```
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 для проверки потоков.
Реальный перезапуск сервера:
Добавлены отдельные проверки 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. Изолированный интеграционный запуск:
```sh
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-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
охватывает звук этого приложения на том же выходе, согласно выбранному контракту.
## Референсы

Loading…
Cancel
Save