From ae6df2db45b0cdf0b48185a07af4b533817d2075 Mon Sep 17 00:00:00 2001 From: John Wallace Date: Tue, 15 Sep 2026 18:22:27 -0400 Subject: [PATCH 1/2] Preserve Windows microphone volume with silent SDK capture --- CHANGELOG.md | 5 ++ CLAUDE.md | 18 ++--- CMakeLists.txt | 5 ++ engine/src/engine-silent-mic.h | 35 ++++++++ engine/src/engine-talkback.cpp | 33 ++------ engine/src/main.cpp | 134 +++++++++---------------------- tests/engine-silent-mic-test.cpp | 58 +++++++++++++ 7 files changed, 152 insertions(+), 136 deletions(-) create mode 100644 engine/src/engine-silent-mic.h create mode 100644 tests/engine-silent-mic-test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index b059512c..ab1c8370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ are tagged `vMAJOR.MINOR.PATCH` and published as ## [Unreleased] +### Fixed +- Windows startup no longer sets the system microphone volume to zero. The + Zoom engine uses a silent virtual microphone and disables automatic input + level adjustment; joining is blocked if the silent source cannot initialize. + ## [0.1.47] - 2026-09-15 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0b3e4a12..1d032f9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -425,17 +425,13 @@ Every one of these is documented at length where it lives; the list is the map. none, under a comment describing the code above. **The leak question, answered from the code rather than assumed**: `Join` sets `isAudioOff = false` / `isMyVoiceInMix = true` - (`engine/src/main.cpp`) and **nothing in this repository calls - `setExternalAudioSource()`** — the only `IZoomSDKAudioRawDataHelper` use - anywhere is `engine-audio.cpp`'s `subscribe()`/`unSubscribe()`, the - *receive* path — so a bare unmute would open the OBS machine's **default - capture device** live into the meeting. The insurance runs once at auth, - in `main.cpp`'s existing `CreateSettingService` block (stage - `mic_insurance`): `SelectMic()` onto a device id that matches nothing plus - `SetMicVol(0)`. **Weaker than ZComms's** never-fed virtual mic, and - deliberately so — theirs installs a virtual mic into the same helper our - show-critical receive subscribe uses. If a live gate ever hears the room - through this, `setExternalAudioSource()` is the escalation. + (`engine/src/main.cpp`). Authentication installs `EngineSilentMic` with + `setExternalAudioSource()` before reporting auth_ok. It never sends PCM, + never selects a physical mic, and outlives SDK cleanup. Registration + failure blocks Join. Both automatic microphone level controls are disabled. + Never use `SetMicVol` or a fake device selection as a silence safeguard: + Zoom may fall back to the Windows default mic and change its system level. + EngineAudio's receive subscription and talkback channel PCM stay separate. 2. **The rate limit is per membership CALL, and invites count** — see the next bullet, which this rewrote. 3. **A same-account host collision hangs the join forever unless answered.** diff --git a/CMakeLists.txt b/CMakeLists.txt index 79eb96fd..171cc370 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1335,6 +1335,11 @@ if(BUILD_TESTING) # tree does not, so the SDK-present check alone let this target register # there and fail at #include (PR #231 CI, 2026-08-27). if(WIN32 AND EXISTS "${ZOOM_SDK_INCLUDE_DIR}/meeting_service_components/meeting_talkback_ctrl_interface.h") + add_executable(CoreVideoEngineSilentMicTest tests/engine-silent-mic-test.cpp) + target_include_directories(CoreVideoEngineSilentMicTest PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/engine/src" "${ZOOM_SDK_INCLUDE_DIR}") + target_compile_definitions(CoreVideoEngineSilentMicTest PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN) + add_test(NAME CoreVideoEngineSilentMic COMMAND CoreVideoEngineSilentMicTest) add_executable(CoreVideoEngineTalkbackSelectTest tests/engine-talkback-select-test.cpp engine/src/engine-talkback.cpp diff --git a/engine/src/engine-silent-mic.h b/engine/src/engine-silent-mic.h new file mode 100644 index 00000000..8b16ffeb --- /dev/null +++ b/engine/src/engine-silent-mic.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#if defined(_WIN32) +#include +#endif +#if __has_include() +#include +#else +#include +#endif + +// SDK capture source with no physical device and no outgoing PCM. Keep alive +// until after CleanUPSDK; raw audio RECEIVE subscriptions are independent. +class EngineSilentMic final : public ZOOMSDK::IZoomSDKVirtualAudioMicEvent { +public: + ZOOMSDK::SDKError install(ZOOMSDK::IZoomSDKAudioRawDataHelper *helper) + { + reset(); + const auto result = helper ? helper->setExternalAudioSource(this) + : ZOOMSDK::SDKERR_UNINITIALIZE; + m_ready.store(result == ZOOMSDK::SDKERR_SUCCESS); + return result; + } + void reset() { m_ready.store(false); } + bool ready() const { return m_ready.load(); } + void onMicInitialize(ZOOMSDK::IZoomSDKAudioRawDataSender *) override {} + void onMicStartSend() override {} + void onMicStopSend() override {} + void onMicUninitialized() override {} + +private: + std::atomic m_ready{false}; +}; diff --git a/engine/src/engine-talkback.cpp b/engine/src/engine-talkback.cpp index f65c1a10..9f6a8cce 100644 --- a/engine/src/engine-talkback.cpp +++ b/engine/src/engine-talkback.cpp @@ -3608,33 +3608,12 @@ void EngineTalkback::debug_expire_pending_create_for_test() // client's mic put the ENGINE MACHINE'S microphone into the meeting? YES, it // could, and the insurance is therefore real rather than theoretical: // * engine/src/main.cpp's Join sets JoinParam4WithoutLogin::isAudioOff = -// false and isMyVoiceInMix = true, so the SDK connects VoIP audio on join -// with whatever capture device it picks. -// * NOTHING in this engine installs a virtual mic. The only -// IZoomSDKAudioRawDataHelper use in the whole repository is -// engine/src/engine-audio.cpp's subscribe()/unSubscribe(), which is the -// RECEIVE path; setExternalAudioSource() -- ZComms's never-fed virtual -// mic, and the airtight version of this insurance -- is called nowhere in -// engine/ or src/. -// * So a bare UnMuteAudio() would open the DEFAULT SYSTEM CAPTURE DEVICE of -// the machine running OBS, into a live meeting. That is a hot mic in a -// control room. -// The insurance is applied ONCE, at authentication, in main.cpp's existing -// CreateSettingService block (see "mic_insurance" there): the SDK's audio -// settings are pointed at a device that does not exist and the mic volume is -// set to zero, before any join. It lives there and not here for a hard -// reason as well as a tidy one -- CreateSettingService() is an SDK EXPORT, and -// engine-talkback.cpp is compiled into a test target that links no SDK library -// at all (tests/engine-talkback-select-test.cpp fakes pure-virtual interfaces -// only). Everything in THIS function goes through IMeetingService's virtual -// interfaces, which is exactly why the ordering below can be pinned. -// WEAKER THAN ZCOMMS'S, and stated rather than glossed: a never-fed virtual -// mic is silent by construction, while a dead device selection is silent -// because Zoom honours it. If a live gate ever hears the room, the escalation -// is setExternalAudioSource() with a never-fed source -- deliberately not -// taken here, because it would install a virtual mic into the same helper the -// engine's show-critical RECEIVE subscribe uses, and that interaction is -// untested. +// false and isMyVoiceInMix = true. Authentication installs EngineSilentMic +// as the SDK's external capture source before auth_ok. This never-fed +// virtual microphone cannot capture the physical input and never changes +// Windows endpoint volume. Join fails closed if registration fails. +// The microphone object outlives CleanUPSDK. Raw audio receive subscriptions +// remain owned by EngineAudio; talkback PCM uses its own channel sender. bool EngineTalkback::ensure_mic_open(const char *when) { const std::string when_field = R"(,"when":")" + std::string(when) + "\""; diff --git a/engine/src/main.cpp b/engine/src/main.cpp index 505dae84..c1c5cb53 100644 --- a/engine/src/main.cpp +++ b/engine/src/main.cpp @@ -6,6 +6,12 @@ #include "engine-audio.h" #include "engine-json.h" #include "engine-talkback.h" +#include "engine-silent-mic.h" +#if __has_include() +#include +#else +#include +#endif #include #include #include @@ -699,7 +705,10 @@ class EngineAuthEvent : public ZOOMSDK::IAuthServiceEvent { public: explicit EngineAuthEvent(IpcFd e2p) : m_e2p(e2p) {} + bool microphone_ready() const { return m_silent_mic.ready(); } + void reset_microphone() { m_silent_mic.reset(); } void onAuthenticationReturn(ZOOMSDK::AuthResult ret) override { + reset_microphone(); if (ret == ZOOMSDK::AUTHRET_SUCCESS) { // Opt into HD video so the meeting negotiates Group HD / 1080p // streams when the account is entitled. Defaults to off on @@ -717,104 +726,12 @@ class EngineAuthEvent : public ZOOMSDK::IAuthServiceEvent { std::string(vs->IsHDVideoEnabled() ? "true" : "false") + "}"); } - // TALKBACK DELIVERY LAW 1's INSURANCE (2026-08-29). Talkback - // only delivers while this client's meeting audio is OPEN - // (see EngineTalkback::ensure_mic_open()), so a key press now - // UNMUTES this client. Read the code before deciding that is - // safe: main.cpp's Join sets isAudioOff = false and - // isMyVoiceInMix = true, and nothing in this repository calls - // IZoomSDKAudioRawDataHelper::setExternalAudioSource() -- the - // only raw-audio-helper use anywhere is engine-audio.cpp's - // subscribe()/unSubscribe(), which is the RECEIVE path. So the - // SDK would open the DEFAULT SYSTEM CAPTURE DEVICE of the - // machine running OBS, live into the meeting. In a control - // room that is a hot mic on air. - // - // So the mic is made dead BEFORE any join, once, here: point - // the SDK at a device id that matches nothing (its own - // fallback is "the default mic if there is no mic selected via - // SelectMic()", which is precisely what must not happen) and - // set the mic volume to zero as the second, independent half - // -- SetMicVol() is documented to act on the selected mic and - // covers the case where SelectMic() is refused. - // - // Reported with both codes, never silently: this is the guard - // between a talkback key and the control room's own - // microphone, and a guard that fails quietly is worse than no - // guard, because the unmute happens either way. - // - // WEAKER THAN ZCOMMS'S, stated rather than glossed: theirs is - // a never-fed SDK virtual mic (setExternalAudioSource), silent - // by construction rather than by Zoom honouring a setting. - // Deliberately not taken here -- it installs a virtual mic - // into the same helper this engine's show-critical receive - // subscribe uses, an interaction nothing has tested. If a live - // gate ever hears the room through this, that is the - // escalation. - { - if (auto *as = settings->GetAudioSettings()) { -#if defined(WIN32) - const zchar_t *dead_id = L"corevideo-no-microphone"; - const zchar_t *dead_name = L"CoreVideo (no microphone)"; -#else - const zchar_t *dead_id = "corevideo-no-microphone"; - const zchar_t *dead_name = "CoreVideo (no microphone)"; -#endif - const ZOOMSDK::SDKError m_err = - as->SelectMic(dead_id, dead_name); - FLOAT silent = 0.0f; - const ZOOMSDK::SDKError v_err = as->SetMicVol(silent); - // REVIEW ROUND 1, m6: BOTH HALVES REFUSED IS A HOT MIC, - // and it used to be reported as a "debug" line with two - // numbers in it -- filtered by stage, read by nobody. - // These two calls are the only thing standing between a - // talkback key and the control room's own microphone - // going live into the meeting, so a failure has to be - // loud on the channel the operator actually sees, and - // has to say what to DO about it. Either half alone - // still silences the device, which is why this is an - // AND: SelectMic sends the SDK at a device that does not - // exist, SetMicVol zeroes whatever it settles on. - const bool insured = (m_err == ZOOMSDK::SDKERR_SUCCESS) || - (v_err == ZOOMSDK::SDKERR_SUCCESS); - EngineIpc::write( - std::string(R"({"cmd":"debug","stage":"mic_insurance","ok":)") + - (insured ? "true" : "false") + - R"(,"select_code":)" + - std::to_string(static_cast(m_err)) + - R"(,"volume_code":)" + - std::to_string(static_cast(v_err)) + "}"); - if (!insured) { - EngineIpc::write( - R"({"cmd":"error","msg":"mic_insurance_failed",)" - R"("reason":"hot_mic_risk","select_code":)" + - std::to_string(static_cast(m_err)) + - R"(,"volume_code":)" + - std::to_string(static_cast(v_err)) + - R"(,"action":"Zoom refused both attempts to )" - R"(silence this machine's microphone. A talkback )" - R"(key will unmute CoreVideo in the meeting, so )" - R"(the default capture device may be heard. Set )" - R"(Zoom's microphone to a disconnected device, )" - R"(or mute it at the OS, before keying."})"); - } - } else { - // Same severity, one door earlier: with no audio - // settings there is no insurance at all, and the unmute - // still happens on the first key. - EngineIpc::write( - R"({"cmd":"debug","stage":"mic_insurance","ok":false,)" - R"("reason":"no_audio_settings"})"); - EngineIpc::write( - R"({"cmd":"error","msg":"mic_insurance_failed",)" - R"("reason":"no_audio_settings",)" - R"("action":"Zoom exposed no audio settings, so )" - R"(CoreVideo could not silence this machine's )" - R"(microphone. A talkback key will unmute CoreVideo )" - R"(in the meeting. Set Zoom's microphone to a )" - R"(disconnected device, or mute it at the OS, before )" - R"(keying."})"); - } + // The SDK must never adjust the operator's physical input level. + if (auto *as = settings->GetAudioSettings()) { + const auto err = as->EnableAutoAdjustMic(false); + EngineIpc::write( + R"({"cmd":"debug","stage":"disable_mic_auto_adjust","code":)" + + std::to_string(static_cast(err)) + "}"); } ZOOMSDK::DestroySettingService(settings); } else { @@ -822,6 +739,18 @@ class EngineAuthEvent : public ZOOMSDK::IAuthServiceEvent { R"({"cmd":"debug","stage":"create_setting_service_failed","code":)" + std::to_string(static_cast(s_err)) + "}"); } + // Supply a silent SDK microphone instead of selecting a fake device or + // setting Windows input volume to zero. Fail closed before any join. + const auto mic_error = m_silent_mic.install(ZOOMSDK::GetAudioRawdataHelper()); + EngineIpc::write( + R"({"cmd":"debug","stage":"mic_insurance","mode":"virtual_silent","code":)" + + std::to_string(static_cast(mic_error)) + "}"); + if (!microphone_ready()) { + EngineIpc::write( + R"({"cmd":"auth_fail","stage":"silent_microphone","code":)" + + std::to_string(static_cast(mic_error)) + "}"); + return; + } EngineIpc::write( R"({"cmd":"auth_ok"})"); } else EngineIpc::write( R"({"cmd":"auth_fail","code":)" + @@ -844,6 +773,7 @@ class EngineAuthEvent : public ZOOMSDK::IAuthServiceEvent { #endif private: IpcFd m_e2p; + EngineSilentMic m_silent_mic; }; // ── Meeting event handler ───────────────────────────────────────────────────── @@ -1606,6 +1536,7 @@ int main() std::to_string(static_cast(err)) + "}"); continue; } + auth_event.reset_microphone(); auth_svc->SetEvent(&auth_event); ZOOMSDK::AuthContext ctx{}; if (!public_app_key.empty()) { @@ -1640,6 +1571,12 @@ int main() } } else if (command == IpcCommand::Join) { + if (!auth_event.microphone_ready()) { + EngineIpc::write( + R"({"cmd":"error","msg":"silent_microphone_unavailable",)" + R"("action":"Reconnect CoreVideo before joining. The silent meeting microphone could not be initialized."})"); + continue; + } std::string meeting_id = json_str(line, "meeting_id"); std::string passcode = json_str(line, "passcode"); std::string display_name = json_str(line, "display_name"); @@ -1671,6 +1608,7 @@ int main() if (meeting_svc) { auto *cfg = meeting_svc->GetMeetingConfiguration(); if (cfg) { + cfg->EnableAutoAdjustMicVolumeWhenJoinAudio(false); cfg->SetEvent(&meeting_event); } else { EngineIpc::write( diff --git a/tests/engine-silent-mic-test.cpp b/tests/engine-silent-mic-test.cpp new file mode 100644 index 00000000..84df19c7 --- /dev/null +++ b/tests/engine-silent-mic-test.cpp @@ -0,0 +1,58 @@ +#include "engine-silent-mic.h" +#include +#include + +using namespace ZOOMSDK; +static void require(bool value) +{ + if (!value) { + std::cerr << "Silent microphone regression failed\n"; + std::exit(1); + } +} +struct Sender : IZoomSDKAudioRawDataSender { + int sent = 0; + SDKError send(char *, unsigned int, int, ZoomSDKAudioChannel) override + { + ++sent; + return SDKERR_SUCCESS; + } +}; +struct Helper : IZoomSDKAudioRawDataHelper { + IZoomSDKVirtualAudioMicEvent *source = nullptr; + int receive_changes = 0; + SDKError result = SDKERR_SUCCESS; + SDKError subscribe(IZoomSDKAudioRawDataDelegate *, bool) override + { + ++receive_changes; + return SDKERR_SUCCESS; + } + SDKError unSubscribe() override { ++receive_changes; return SDKERR_SUCCESS; } + SDKError setExternalAudioSource(IZoomSDKVirtualAudioMicEvent *value) override + { + source = value; + return result; + } +}; +int main() +{ + EngineSilentMic mic; + Helper helper; + Sender sender; + require(!mic.ready()); + require(mic.install(nullptr) != SDKERR_SUCCESS && !mic.ready()); + require(mic.install(&helper) == SDKERR_SUCCESS && mic.ready()); + require(helper.source == &mic); + for (int session = 0; session < 2; ++session) { + helper.source->onMicInitialize(&sender); + helper.source->onMicStartSend(); + helper.source->onMicStopSend(); + helper.source->onMicUninitialized(); + } + require(sender.sent == 0 && helper.receive_changes == 0); + mic.reset(); + require(!mic.ready()); + require(mic.install(&helper) == SDKERR_SUCCESS && mic.ready()); + helper.result = SDKERR_UNINITIALIZE; + require(mic.install(&helper) != SDKERR_SUCCESS && !mic.ready()); +} From 1f1aaa3097443e29bcade51af3937fdc3c89986d Mon Sep 17 00:00:00 2001 From: John Wallace Date: Tue, 15 Sep 2026 18:27:59 -0400 Subject: [PATCH 2/2] Document v0.1.48 microphone volume fix --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1c8370..67348908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,18 @@ are tagged `vMAJOR.MINOR.PATCH` and published as ## [Unreleased] +## [0.1.48] - 2026-09-15 + ### Fixed - Windows startup no longer sets the system microphone volume to zero. The Zoom engine uses a silent virtual microphone and disables automatic input level adjustment; joining is blocked if the silent source cannot initialize. +### Validation +- Windows build and 75 regression tests pass, including silent microphone + lifecycle, failed registration, and independent receive-subscription checks. +- Live Zoom startup and talkback verification remains pending. + ## [0.1.47] - 2026-09-15 ### Added