mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
Use native WASAPI for Windows microphone recording
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user