diff --git a/electron/ipc/recording/windowsFallbacks.test.ts b/electron/ipc/recording/windowsFallbacks.test.ts index e1c56752..512a81be 100644 --- a/electron/ipc/recording/windowsFallbacks.test.ts +++ b/electron/ipc/recording/windowsFallbacks.test.ts @@ -7,9 +7,9 @@ import { } from "./windowsFallbacks"; describe("shouldUseWindowsBrowserMicrophoneFallback", () => { - it("defaults Windows microphone capture to the browser fallback path", () => { + it("defaults Windows microphone capture to native WASAPI", () => { expect(shouldStartWindowsBrowserMicrophoneFallback({ capturesMicrophone: true }, {})).toBe( - true, + false, ); }); @@ -22,7 +22,7 @@ describe("shouldUseWindowsBrowserMicrophoneFallback", () => { ).toBe(true); }); - it("allows lab runs to force native WASAPI microphone capture", () => { + it("keeps native WASAPI enabled when explicitly requested", () => { expect( shouldStartWindowsBrowserMicrophoneFallback( { capturesMicrophone: true }, @@ -37,6 +37,15 @@ describe("shouldUseWindowsBrowserMicrophoneFallback", () => { ).toBe(false); }); + it("uses native WASAPI for unknown mode values", () => { + expect( + shouldStartWindowsBrowserMicrophoneFallback( + { capturesMicrophone: true }, + { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "typo" }, + ), + ).toBe(false); + }); + it("does not force fallback when microphone capture was not requested", () => { expect( shouldStartWindowsBrowserMicrophoneFallback( @@ -55,6 +64,15 @@ describe("shouldUseWindowsBrowserMicrophoneFallback", () => { ).toBe(true); }); + it("returns true when the native helper reports its stable fallback marker", () => { + expect( + shouldUseWindowsBrowserMicrophoneFallback( + "MICROPHONE_CAPTURE_UNAVAILABLE\nRecording started", + { capturesMicrophone: true }, + ), + ).toBe(true); + }); + it("returns false when microphone capture was not requested", () => { expect( shouldUseWindowsBrowserMicrophoneFallback( @@ -64,12 +82,12 @@ describe("shouldUseWindowsBrowserMicrophoneFallback", () => { ).toBe(false); }); - it("returns false for a healthy native mic when lab mode forces native capture", () => { + it("returns false for a healthy native mic by default", () => { expect( shouldUseWindowsBrowserMicrophoneFallback( "Recording started", { capturesMicrophone: true }, - { [WINDOWS_MIC_CAPTURE_MODE_ENV]: "native" }, + {}, ), ).toBe(false); }); diff --git a/electron/ipc/recording/windowsFallbacks.ts b/electron/ipc/recording/windowsFallbacks.ts index 8a3f581c..f579e052 100644 --- a/electron/ipc/recording/windowsFallbacks.ts +++ b/electron/ipc/recording/windowsFallbacks.ts @@ -1,4 +1,7 @@ -const WINDOWS_MIC_CAPTURE_INIT_WARNING = "WARNING: Failed to initialize WASAPI mic capture"; +const WINDOWS_MIC_CAPTURE_UNAVAILABLE_MARKERS = [ + "MICROPHONE_CAPTURE_UNAVAILABLE", + "WARNING: Failed to initialize WASAPI mic capture", +]; export const WINDOWS_MIC_CAPTURE_MODE_ENV = "RECORDLY_WINDOWS_MIC_CAPTURE"; export function shouldStartWindowsBrowserMicrophoneFallback( @@ -10,14 +13,8 @@ export function shouldStartWindowsBrowserMicrophoneFallback( } const mode = env[WINDOWS_MIC_CAPTURE_MODE_ENV]?.trim().toLowerCase(); - if (mode === "native" || mode === "wasapi") { - return false; - } - - if (!mode) { - return true; - } - + // Native WASAPI is the normal Windows path. Keep the renderer path as an + // explicit escape hatch and as an automatic fallback when WASAPI cannot start. return mode === "browser" || mode === "fallback" || mode === "renderer"; } @@ -29,6 +26,8 @@ export function shouldUseWindowsBrowserMicrophoneFallback( return ( Boolean(options?.capturesMicrophone) && (shouldStartWindowsBrowserMicrophoneFallback(options, env) || - captureOutput.includes(WINDOWS_MIC_CAPTURE_INIT_WARNING)) + WINDOWS_MIC_CAPTURE_UNAVAILABLE_MARKERS.some((marker) => + captureOutput.includes(marker), + )) ); } diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 438b6eee..2c50e397 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -531,6 +531,9 @@ export function registerRecordingHandlers( ); config.captureMic = true; config.micOutputPath = tempMicPath; + if (options.microphoneDeviceId) { + config.micDeviceId = options.microphoneDeviceId; + } if (options.microphoneLabel) { config.micDeviceName = options.microphoneLabel; } diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index c5a463c6..68a408ef 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -24,12 +24,21 @@ static std::atomic g_accumulatedPausedHns{0}; static std::mutex g_stopMutex; static std::condition_variable g_stopCv; +static void reportMicrophoneCaptureUnavailable() { + std::cerr << "WARNING: Failed to initialize WASAPI mic capture" << std::endl; + // This stable stdout marker is ordered before "Recording started", allowing + // Electron to select its per-recording browser fallback without a pipe race. + std::cout << "MICROPHONE_CAPTURE_UNAVAILABLE" << std::endl; + std::cout.flush(); +} + struct CaptureConfig { int64_t displayId = 0; int64_t windowHandle = 0; std::string outputPath; std::string audioOutputPath; std::string micOutputPath; + std::string micDeviceId; std::string micDeviceName; int fps = 60; int width = 0; @@ -119,6 +128,7 @@ static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { config.audioOutputPath = findString("audioOutputPath"); config.micOutputPath = findString("micOutputPath"); + config.micDeviceId = findString("micDeviceId"); config.micDeviceName = findString("micDeviceName"); auto findBool = [&](const std::string& key) -> bool { @@ -342,6 +352,7 @@ int main(int argc, char* argv[]) { std::atomic frameCount{0}; std::atomic firstVideoTimestampHns{-1}; std::atomic recordingStartedAnnounced{false}; + std::atomic captureSetupComplete{false}; session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { g_lastFrameTimestampHns = timestampHns; int64_t expectedFirstVideoTimestampHns = -1; @@ -354,7 +365,7 @@ int main(int argc, char* argv[]) { if (encoder.writeFrame(texture, adjustedTimestampHns)) { const int64_t writtenFrames = frameCount.fetch_add(1) + 1; - if (writtenFrames == 1 && !recordingStartedAnnounced.exchange(true)) { + if (captureSetupComplete && writtenFrames >= 1 && !recordingStartedAnnounced.exchange(true)) { std::cout << "Recording started" << std::endl; std::cout.flush(); } @@ -381,9 +392,12 @@ int main(int argc, char* argv[]) { } if (config.captureMic && !config.micOutputPath.empty()) { - micInitialized = micCapture.initializeMic(config.micOutputPath, config.micDeviceName); + micInitialized = micCapture.initializeMic( + config.micOutputPath, + config.micDeviceId, + config.micDeviceName); if (!micInitialized) { - std::cerr << "WARNING: Failed to initialize WASAPI mic capture" << std::endl; + reportMicrophoneCaptureUnavailable(); } } @@ -398,7 +412,11 @@ int main(int argc, char* argv[]) { } if (micInitialized) { micActive = micCapture.start(); + if (!micActive) { + reportMicrophoneCaptureUnavailable(); + } } + captureSetupComplete = true; // Wait for stop signal while pausing/resuming audio tracks in lockstep. while (!g_stopRequested && !session.hasFatalError()) { diff --git a/electron/native/wgc-capture/src/wasapi_loopback.cpp b/electron/native/wgc-capture/src/wasapi_loopback.cpp index 2a8a5d17..34d8a5cb 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #pragma comment(lib, "ole32.lib") @@ -54,6 +55,64 @@ int16_t pcm24ToInt16(const BYTE* sample) { } return static_cast(value >> 8); } + +std::wstring normalizeDeviceName(const std::wstring& value) { + std::wstring result; + result.reserve(value.size()); + bool lastWasSpace = true; + for (const wchar_t character : value) { + if (std::iswalnum(character)) { + result.push_back(static_cast(std::towlower(character))); + lastWasSpace = false; + } else if (!lastWasSpace) { + result.push_back(L' '); + lastWasSpace = true; + } + } + if (!result.empty() && result.back() == L' ') result.pop_back(); + return result; +} + +bool containsAsWords(const std::wstring& haystack, const std::wstring& needle) { + if (haystack.empty() || needle.empty()) return false; + size_t position = haystack.find(needle); + while (position != std::wstring::npos) { + const bool startsOnBoundary = position == 0 || haystack[position - 1] == L' '; + const size_t after = position + needle.size(); + const bool endsOnBoundary = after == haystack.size() || haystack[after] == L' '; + if (startsOnBoundary && endsOnBoundary) return true; + position = haystack.find(needle, position + 1); + } + return false; +} + +int scoreDeviceName( + const std::wstring& candidateName, + const std::wstring& candidateId, + const std::wstring& requestedName) { + const std::wstring candidate = normalizeDeviceName(candidateName); + const std::wstring id = normalizeDeviceName(candidateId); + const std::wstring requested = normalizeDeviceName(requestedName); + if (requested.empty()) return 0; + if (candidate == requested) return 1000; + if (containsAsWords(candidate, requested) || containsAsWords(requested, candidate)) return 900; + if (containsAsWords(id, requested) || containsAsWords(requested, id)) return 800; + return 0; +} + +std::wstring getDeviceFriendlyName(IMMDevice* device) { + if (!device) return L""; + IPropertyStore* store = nullptr; + if (FAILED(device->OpenPropertyStore(STGM_READ, &store)) || !store) return L""; + PROPVARIANT value; + PropVariantInit(&value); + const HRESULT hr = store->GetValue(PKEY_Device_FriendlyName, &value); + std::wstring name; + if (SUCCEEDED(hr) && value.vt == VT_LPWSTR && value.pwszVal) name = value.pwszVal; + PropVariantClear(&value); + store->Release(); + return name; +} } static const CLSID CLSID_MMDeviceEnumerator_ = __uuidof(MMDeviceEnumerator); @@ -88,28 +147,31 @@ IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName UINT count = 0; collection->GetCount(&count); + IMMDevice* bestDevice = nullptr; + int bestScore = 0; for (UINT i = 0; i < count; i++) { IMMDevice* dev = nullptr; - collection->Item(i, &dev); + if (FAILED(collection->Item(i, &dev)) || !dev) continue; - IPropertyStore* store = nullptr; - dev->OpenPropertyStore(STGM_READ, &store); - PROPVARIANT pv; - PropVariantInit(&pv); - store->GetValue(PKEY_Device_FriendlyName, &pv); - std::wstring name = pv.pwszVal ? pv.pwszVal : L""; - PropVariantClear(&pv); - store->Release(); - - if (name.find(targetName) != std::wstring::npos || targetName.find(name) != std::wstring::npos) { - collection->Release(); - return dev; + LPWSTR rawId = nullptr; + std::wstring candidateId; + if (SUCCEEDED(dev->GetId(&rawId)) && rawId) { + candidateId = rawId; + CoTaskMemFree(rawId); + } + const std::wstring candidateName = getDeviceFriendlyName(dev); + const int score = scoreDeviceName(candidateName, candidateId, targetName); + if (score > bestScore) { + if (bestDevice) bestDevice->Release(); + bestDevice = dev; + bestScore = score; + } else { + dev->Release(); } - dev->Release(); } collection->Release(); - return nullptr; + return bestDevice; } bool WasapiCapture::initializeLoopback(const std::string& outputPath) { @@ -127,7 +189,10 @@ bool WasapiCapture::initializeLoopback(const std::string& outputPath) { return initializeCommon(); } -bool WasapiCapture::initializeMic(const std::string& outputPath, const std::string& deviceName) { +bool WasapiCapture::initializeMic( + const std::string& outputPath, + const std::string& deviceId, + const std::string& deviceName) { outputPath_ = outputPath; streamFlags_ = 0; @@ -136,15 +201,23 @@ bool WasapiCapture::initializeMic(const std::string& outputPath, const std::stri IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); if (FAILED(hr)) return false; - if (!deviceName.empty()) { + if (!deviceId.empty() && deviceId != "default") { + const std::wstring requestedId = utf8ToWide(deviceId); + hr = enumerator_->GetDevice(requestedId.c_str(), &device_); + if (FAILED(hr)) device_ = nullptr; + } + if (!device_ && !deviceName.empty() && deviceId != "default") { device_ = findCaptureDeviceByName(utf8ToWide(deviceName)); } if (!device_) { - hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eCommunications, &device_); - if (FAILED(hr)) { - hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eConsole, &device_); - if (FAILED(hr)) return false; + const bool wantedSpecificDevice = + deviceId != "default" && (!deviceId.empty() || !deviceName.empty()); + if (wantedSpecificDevice) { + std::cerr << "WARNING: Requested microphone unavailable; using default WASAPI input" + << std::endl; } + hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eConsole, &device_); + if (FAILED(hr)) return false; } return initializeCommon(); diff --git a/electron/native/wgc-capture/src/wasapi_loopback.h b/electron/native/wgc-capture/src/wasapi_loopback.h index bf88cb16..ab3cd7a6 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.h +++ b/electron/native/wgc-capture/src/wasapi_loopback.h @@ -14,7 +14,10 @@ public: ~WasapiCapture(); bool initializeLoopback(const std::string& outputPath); - bool initializeMic(const std::string& outputPath, const std::string& deviceName = ""); + bool initializeMic( + const std::string& outputPath, + const std::string& deviceId = "", + const std::string& deviceName = ""); bool start(); bool pause(); bool resume();