Use native WASAPI for Windows microphone recording

This commit is contained in:
webadderall
2026-09-13 10:41:30 +10:00
parent 7512ef1518
commit a1b48500b6
6 changed files with 154 additions and 40 deletions
@@ -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);
});
+9 -10
View File
@@ -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),
))
);
}
+3
View File
@@ -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;
}
+21 -3
View File
@@ -24,12 +24,21 @@ static std::atomic<int64_t> 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<int64_t> frameCount{0};
std::atomic<int64_t> firstVideoTimestampHns{-1};
std::atomic<bool> recordingStartedAnnounced{false};
std::atomic<bool> 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()) {
@@ -4,6 +4,7 @@
#include <cstring>
#include <algorithm>
#include <cmath>
#include <cwctype>
#pragma comment(lib, "ole32.lib")
@@ -54,6 +55,64 @@ int16_t pcm24ToInt16(const BYTE* sample) {
}
return static_cast<int16_t>(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<wchar_t>(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<void**>(&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();
@@ -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();