From 829e4d41faa6f00c687c71483619f71519967566 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 16 Oct 2026 01:29:55 +0200 Subject: [PATCH] Document shared microphone ownership and test distinct desktop inputs --- doc/service_lifecycle.md | 16 +++++++++--- doc/stream.txt | 23 ++++++++++++---- tools/chatgui/tests/test_audio_recovery.cpp | 29 ++++++++++++++++++--- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index 392aa205..4b32f33d 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -145,12 +145,22 @@ Qt capture callback хранит только последний кадр, video вне GUI и hardware callbacks. Отдельные поколения источника и encoder позволяют менять видеокодек/bitrate/rotation без повторного выбора экрана в портале. Ошибки Qt capture обрабатываются через queued callback, чтобы не уничтожать backend из собственного callback. -Сначала stop запрещает producers, закрывает Qt capture и audio device, затем join workers, +Сначала stop запрещает producers, закрывает Qt capture и подписку на аудиозахват, затем join workers, освобождает microphone lease и завершает публикацию через FIN с drain 2s. При завершении приложения engine уничтожается до SoundManager и ядра; detach в uasync закрывает оставшийся publisher. Workers никогда не держат borrowed instance/handle. -Microphone lease публикации эксклюзивен; прежние call/radio/recorder leases совместные. -Восстановление устройства сохраняет владение и не разрешает другой capture до stop/failure. +Microphone lease публикации исключает call/recorder; рация согласует вход через `AudioCapture`. +Одинаковый выбранный вход имеет одну аппаратную сессию и профиль трансляции. Разные входы независимы. +Подключение/отключение рации не останавливает устройство трансляции: атомарный слот и счётчик readers +дожидаются только callback удаляемого потребителя. Последняя подписка закрывает устройство. +Если первой работала рация, публикация заменяет профиль захвата своим; после остановки публикации +оставшаяся рация продолжает тот же захват. Recovery останавливает callbacks, workers TX и один раз +открывает общий вход; RX рации сохраняется. Каждый потребитель обрабатывает собственную копию PCM. +На Android `MicrophoneCapture` владеет AudioRecord и reader-потоком, подписчики — ограниченными очередями. +Stop публикации удаляет её подписку до join Opus worker и FIN; AudioRecord остаётся живым для рации. +PTT и PCM рации проходят через одну очередь. Последний подписчик делает stop/read join/release; +приоритет глобального AudioManager сохраняется до освобождения трансляционного захвата. +Ограничения фонового захвата рации, её разрешения и foreground-сервис продолжают действовать. Fatal ошибка audio worker передаётся в GUI с session ID и завершает всю публикацию; откат видеоконфигурации применяется только к video/capture. Поздний отказ старого publisher не завершает новую сессию. Причина ошибки сохраняется в окне после окончания drain. diff --git a/doc/stream.txt b/doc/stream.txt index f23bec77..1a9a8b31 100644 --- a/doc/stream.txt +++ b/doc/stream.txt @@ -791,7 +791,7 @@ Desktop и Android открывают просмотр через stream_media_o Android: - Один foreground publisher на процесс, один viewer. Каждая операция имеет новый session ID. -- StreamService владеет камерой, микрофоном и PARTIAL_WAKE_LOCK; пересоздание Activity не завершает источник. +- StreamService владеет камерой, подпиской на микрофон и PARTIAL_WAKE_LOCK; пересоздание Activity не завершает источник. Сервис camera|microphone запускается из видимого приложения после runtime-разрешений; без микрофона — camera. - Camera2 TEMPLATE_RECORD пишет в Surface encoder. Кодеки перечисляются в порядке H.264/H.265/VP9/AV1/H.266; видны только поддержанные encoder/profile/Surface/size/FPS сочетания, аппаратные encoder имеют приоритет. @@ -913,8 +913,17 @@ Android: Это ограниченный drain, не подтверждение воспроизведения всеми viewers. Abort/core restart очищает очередь сразу. Завершение логирует срок drain, оставшиеся RS-байты выдач и локальных readers; RS=0 не означает подтверждение доставки ETCP. Поздние команды старого session ID не меняют новый запуск. -- Звонок, рация и voice recording не начинают новый capture во время трансляции; - трансляция также отвергается при активном звонке/рации/voice recording. +- Рация работает одновременно с публикацией. При совпадении выбранного входа она получает копию + PCM CAMCORDER 48kHz stereo с исходными timestamp; её source/stereo предпочтения не меняют трансляцию. + Другой явно выбранный вход открывается отдельно; отказ не заменяет его системным default. + Трансляция имеет приоритет глобального AudioManager: рация не включает разговорный режим и не меняет SCO + общего тракта. Перед запуском трансляции ранее работавшая рация освобождает capture и затем подключается + к фактическому входу трансляции либо повторно открывает свой отдельный вход. + Очереди потребителей ограничены; PTT упорядочен с PCM, разрыв сбрасывает историю детектора рации. + Последняя подписка закрывает AudioRecord; stop рации/трансляции не закрывает захват другого потребителя. + Проверяется фактический Android route и системное silencing: конфликтующий отдельный capture рации + завершается с диагностикой, приём рации сохраняется. Окно VAD/экран/разрешения не обходятся общим захватом. + Звонок и voice recording по-прежнему исключают публикацию в обе стороны. Логи: stream — epoch/owners, очереди, GAP и teardown; video — codec/config, KEY, PTS и декодер. Kotlin progress — DEBUG раз в секунду; C packet-level диагностика — TRACE по запросу. @@ -976,8 +985,12 @@ Desktop: сбрасываются только неполный PCM-пакет и история компрессора. Mute передаёт тишину. Fatal ошибка audio encoder завершает публикацию по session ID, без отката видеонастроек; причина сохраняется в окне управления после FIN. Поздняя ошибка старой сессии игнорируется. - Публикация эксклюзивно занимает микрофон: звонок/рация/запись и публикация запрещают друг другу новый - capture. Прежнее совместное владение микрофоном голосовыми функциями сохраняется. + Публикация и рация сравнивают выбранные входы: разные устройства независимы; один вход открывается + общей сессией AudioCapture с параметрами трансляции. Default разрешается в ID устройства перед сравнением. + При старте публикации после рации общий вход переоткрывается с профилем трансляции; дальнейшие + attach/detach рации не перезапускают захват. После stop публикации рация сохраняет общий вход и его формат. + Рация выполняет свой AEC/VAD/компрессию/Opus после копирования PCM, не изменяя ветвь трансляции. + Звонок/запись и публикация по-прежнему запрещают друг другу новый capture. - transport/stream_bridge владеет независимым stream_publisher; shared C actor выполняет uAV2/RS, admission, KEY recovery, FIN и TX snapshot в uasync. Очереди/pressure/drain совпадают с Android. UI показывает video queue, суммарный inflight, send buffer и ACK_1s каждые 100ms. diff --git a/tools/chatgui/tests/test_audio_recovery.cpp b/tools/chatgui/tests/test_audio_recovery.cpp index 7efb0ee4..7c031218 100644 --- a/tools/chatgui/tests/test_audio_recovery.cpp +++ b/tools/chatgui/tests/test_audio_recovery.cpp @@ -58,6 +58,9 @@ 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) { if (failDevice) { failDevice = false; return MA_ERROR; } ma_device_config requested = *config; + // Второй виртуальный вход: отдельный null device с тем же аппаратным драйвером. + if (context->backend == ma_backend_null && config->deviceType == ma_device_type_capture && + config->capture.pDeviceID && config->capture.pDeviceID->nullbackend == 1) requested.capture.pDeviceID = nullptr; if (latencyProbe) { requested.periodSizeInMilliseconds = 0; requested.periodSizeInFrames = config->deviceType == ma_device_type_capture ? probeCaptureFrames : probePlaybackFrames; @@ -109,6 +112,7 @@ static void shared_capture(SoundManager* manager) { AudioCapture stream, radio; std::atomic streamFrames{0}, radioFrames{0}; std::atomic streamTime{0}; + std::atomic compareTime{false}; int streamReady = 0, radioReady = 0, streamStopped = 0, radioStopped = 0, radioChannels = 0; auto startStream = [&] { return stream.open(selected, AudioCapture::Role::Stream, 2, 1, [&](int channels, int) { @@ -123,25 +127,25 @@ static void shared_capture(SoundManager* manager) { radioChannels = channels; ++radioReady; return true; }, [&] { ++radioStopped; }, [&](const AudioCapture::Frame& frame) { REQUIRE(frame.channels == radioChannels && frame.pcm); - if (streamTime.load()) REQUIRE(frame.timeUs == streamTime.load()); + if (compareTime) REQUIRE(frame.timeUs == streamTime.load()); radioFrames += frame.frames; }); }; int before = captureStarts; - REQUIRE(startStream()); REQUIRE(startRadio()); pump(80); + REQUIRE(startStream()); REQUIRE(startRadio()); compareTime = true; pump(80); REQUIRE(captureStarts == before + 1 && radioChannels == 2 && streamReady == 1 && radioReady == 1); REQUIRE(streamFrames > 0 && radioFrames > 0); radio.close(); auto captured = streamFrames.load(); pump(60); REQUIRE(streamFrames > captured && captureStarts == before + 1 && streamStopped == 0); REQUIRE(startRadio()); REQUIRE(captureStarts == before + 1); // Detach stream не закрывает устройство и не меняет формат оставшейся рации. - stream.close(); streamTime = 0; captured = radioFrames.load(); pump(60); + compareTime = false; stream.close(); streamTime = 0; captured = radioFrames.load(); pump(60); REQUIRE(radio.running() && radioFrames > captured && radioChannels == 2 && captureStarts == before + 1); radio.close(); REQUIRE(streamStopped == 1 && radioStopped == 2); // Обратный порядок: новый stream заменяет mono-профиль рации на свой stereo. before = captureStarts; REQUIRE(startRadio()); REQUIRE(radioChannels == 1); - REQUIRE(startStream()); pump(60); + REQUIRE(startStream()); compareTime = true; pump(60); REQUIRE(captureStarts == before + 2 && radioChannels == 2 && stream.running() && radio.running()); // Потеря устройства: обе подписки восстанавливаются одним открытием. for (auto* device : devices) if (device->type == ma_device_type_capture) REQUIRE(ma_device_stop(device) == MA_SUCCESS); @@ -149,6 +153,23 @@ static void shared_capture(SoundManager* manager) { pump(300); radio.recover(); REQUIRE(stream.running() && radio.running() && captureStarts == before + 3); radio.close(); stream.close(); + compareTime = false; + // Разные выбранные устройства сохраняют независимые форматы и жизненные циклы. + ma_device_id secondId{}; secondId.nullbackend = 1; + QString second = QString::fromLatin1(ma_get_backend_name(manager->context()->backend)) + '/' + + QByteArray(reinterpret_cast(&secondId), sizeof(secondId)).toBase64(); + before = captureStarts; + REQUIRE(startStream()); + REQUIRE(radio.open(second, AudioCapture::Role::Radio, 1, 2, [](int channels, int) { REQUIRE(channels == 1); return true; }, + [] {}, [](const AudioCapture::Frame& frame) { REQUIRE(frame.channels == 1); })); + REQUIRE(captureStarts == before + 2); + captured = streamFrames.load(); radio.close(); pump(60); + REQUIRE(streamFrames > captured && captureStarts == before + 2); + // Неверный явный выбор не заменяется default и не отбирает захват трансляции. + REQUIRE(!radio.open(QStringLiteral("invalid-input"), AudioCapture::Role::Radio, 1, 2, [](int, int) { return true; }, + [] {}, [](const AudioCapture::Frame&) {})); + REQUIRE(stream.running() && captureStarts == before + 2); + radio.close(); stream.close(); qInfo("shared capture: default/explicit alias, stream priority, attach/detach without restart, recovery PASS"); }