diff --git a/doc/linux_audio_recovery.md b/doc/linux_audio_recovery.md index 88fa4896..e7e92856 100644 --- a/doc/linux_audio_recovery.md +++ b/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 +охватывает звук этого приложения на том же выходе, согласно выбранному контракту. ## Референсы