Browse Source

Document shared microphone ownership and test distinct desktop inputs

master
evgeny 4 days ago
parent
commit
829e4d41fa
  1. 16
      doc/service_lifecycle.md
  2. 23
      doc/stream.txt
  3. 29
      tools/chatgui/tests/test_audio_recovery.cpp

16
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.

23
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.

29
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<unsigned> streamFrames{0}, radioFrames{0};
std::atomic<int64_t> streamTime{0};
std::atomic<bool> 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<const char*>(&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");
}

Loading…
Cancel
Save