From d4eb1da233c49fbe9ee54d1b4a296a528c3b0e85 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 16 Oct 2026 01:06:59 +0200 Subject: [PATCH] Test shared microphone lifecycle and stream priority --- tools/chatgui/src/audio_capture.cpp | 14 +++--- tools/chatgui/tests/test_audio_recovery.cpp | 53 +++++++++++++++++++++ tools/chatgui/tests/test_stream_publish.cpp | 6 +-- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/tools/chatgui/src/audio_capture.cpp b/tools/chatgui/src/audio_capture.cpp index 4a3e29fa..fdfe7686 100644 --- a/tools/chatgui/src/audio_capture.cpp +++ b/tools/chatgui/src/audio_capture.cpp @@ -15,7 +15,7 @@ struct AudioCapture::Session { }; AudioDevice device; QString key; - std::array slots; + std::array consumers; bool streamProfile = false; int requestedChannels = 1, channels = 1; uint64_t tag = 0; @@ -36,12 +36,12 @@ struct AudioCapture::Session { } void stop() { device.close(); // join до stopped: workers больше не получат PCM старого формата. - for (auto& slot : slots) if (slot.client) detach(slot); + for (auto& slot : consumers) if (slot.client) detach(slot); } void prepare() { if (clock.elapsed() < prepareAt) return; prepareAt = clock.elapsed() + 250; - for (auto& slot : slots) { + for (auto& slot : consumers) { auto* client = slot.client; if (!client || client->m_prepared) continue; if (!client->m_ready(channels, device.device()->capture.internalChannels)) { @@ -75,7 +75,7 @@ struct AudioCapture::Session { Frame frame{static_cast(in), frames, channels, device.callbackTimeUs(), device.route(), device.timingMeasured(), device.captureMissing()}; // Трансляция получает исходный PCM первой; оба callback только копируют данные в свои очереди. - for (auto& slot : slots) { + for (auto& slot : consumers) { slot.readers.fetch_add(1); if (auto* client = slot.active.load()) client->m_callback(frame); slot.readers.fetch_sub(1); @@ -112,7 +112,7 @@ bool AudioCapture::open(const QString& input, Role role, int channels, uint64_t } if (!m_session) { m_session = std::make_shared(key); sessions.push_back(m_session); } auto& session = *m_session; - auto& slot = session.slots[role == Role::Stream ? 0 : 1]; + auto& slot = session.consumers[role == Role::Stream ? 0 : 1]; if (slot.client) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "capture: duplicate consumer role=%s", role == Role::Stream ? "stream" : "radio"); m_session.reset(); return false; @@ -131,10 +131,10 @@ bool AudioCapture::open(const QString& input, Role role, int channels, uint64_t void AudioCapture::close() { if (!m_session) return; - auto& slot = m_session->slots[m_role == Role::Stream ? 0 : 1]; + auto& slot = m_session->consumers[m_role == Role::Stream ? 0 : 1]; m_session->detach(slot); slot.client = nullptr; DEBUG_INFO(DEBUG_CATEGORY_CALL, "capture: detached role=%s remaining=%d", m_role == Role::Stream ? "stream" : "radio", - int(m_session->slots[0].client != nullptr) + int(m_session->slots[1].client != nullptr)); + int(m_session->consumers[0].client != nullptr) + int(m_session->consumers[1].client != nullptr)); m_session.reset(); m_callback = {}; m_ready = {}; m_stopped = {}; } diff --git a/tools/chatgui/tests/test_audio_recovery.cpp b/tools/chatgui/tests/test_audio_recovery.cpp index df83e98b..7efb0ee4 100644 --- a/tools/chatgui/tests/test_audio_recovery.cpp +++ b/tools/chatgui/tests/test_audio_recovery.cpp @@ -100,6 +100,58 @@ static void pump(int milliseconds) { } } +static void shared_capture(SoundManager* manager) { + auto inputs = manager->enumerateCaptureDevices(); + REQUIRE(!inputs.empty()); + QString selected; + for (const auto& input : inputs) if (input.isDefault) selected = input.id; + REQUIRE(!selected.isEmpty()); + AudioCapture stream, radio; + std::atomic streamFrames{0}, radioFrames{0}; + std::atomic streamTime{0}; + 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) { + REQUIRE(channels == 2); ++streamReady; return true; + }, [&] { ++streamStopped; }, [&](const AudioCapture::Frame& frame) { + REQUIRE(frame.channels == 2 && frame.timeUs > 0 && frame.pcm); + streamTime = frame.timeUs; streamFrames += frame.frames; + }); + }; + auto startRadio = [&] { + return radio.open({}, AudioCapture::Role::Radio, 1, 2, [&](int channels, int) { + radioChannels = channels; ++radioReady; return true; + }, [&] { ++radioStopped; }, [&](const AudioCapture::Frame& frame) { + REQUIRE(frame.channels == radioChannels && frame.pcm); + if (streamTime.load()) REQUIRE(frame.timeUs == streamTime.load()); + radioFrames += frame.frames; + }); + }; + int before = captureStarts; + REQUIRE(startStream()); REQUIRE(startRadio()); 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); + 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(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); + stream.recover(); REQUIRE(!stream.running() && !radio.running()); + pump(300); radio.recover(); + REQUIRE(stream.running() && radio.running() && captureStarts == before + 3); + radio.close(); stream.close(); + qInfo("shared capture: default/explicit alias, stream priority, attach/detach without restart, recovery PASS"); +} + static void pulse_recovery(SoundManager* manager, const QString& control) { REQUIRE(manager->context()->backend == ma_backend_pulseaudio); instance.ua = uasync_create(); REQUIRE(instance.ua); @@ -206,6 +258,7 @@ int main(int argc, char** argv) { auto outputs = manager->enumeratePlaybackDevices(); REQUIRE(!outputs.empty() && SoundManager::deviceId(outputs.front().id, &id)); REQUIRE(!SoundManager::deviceId(outputs.front().id + '!', &id)); + shared_capture(manager); { AudioDevice device; auto config = ma_device_config_init(ma_device_type_capture); diff --git a/tools/chatgui/tests/test_stream_publish.cpp b/tools/chatgui/tests/test_stream_publish.cpp index a3f7dc89..f081a868 100644 --- a/tools/chatgui/tests/test_stream_publish.cpp +++ b/tools/chatgui/tests/test_stream_publish.cpp @@ -117,11 +117,11 @@ private slots: sqlite3_finalize(statement); QVERIFY(group); QVERIFY(gui_stream_attach(inst)); uasync_mark_running(ua); connect(&poll, &QTimer::timeout, this, [this] { uasync_poll(ua, 0); }); poll.start(1); } - void exclusiveBroadcastMicrophone() { - AudioCaptureLease call, radio, recorder, publisher(AudioCaptureLease::Mode::Exclusive); + void broadcastAllowsRadioMicrophone() { + AudioCaptureLease call, radio(AudioCaptureLease::Mode::Radio), recorder, publisher(AudioCaptureLease::Mode::Exclusive); QVERIFY(call.acquire()); QVERIFY(radio.acquire()); QVERIFY(recorder.acquire()); QVERIFY(!publisher.acquire()); call.release(); radio.release(); QVERIFY(!publisher.acquire()); recorder.release(); - QVERIFY(publisher.acquire()); QVERIFY(!call.acquire()); QVERIFY(!radio.acquire()); QVERIFY(!recorder.acquire()); + QVERIFY(publisher.acquire()); QVERIFY(!call.acquire()); QVERIFY(radio.acquire()); QVERIFY(!recorder.acquire()); QVERIFY(publisher.acquire()); publisher.release(); QVERIFY(call.acquire()); } void encoderRoundTrip_data() {