initial commit for windows wasapi native capture support

This commit is contained in:
Boof2015
2026-03-21 19:28:05 -04:00
parent 8d05fc3dd4
commit d6f623ce87
8 changed files with 1064 additions and 40 deletions
+15 -3
View File
@@ -20,7 +20,8 @@
"conditions": [
["OS=='mac'", {
"sources": [
"src/macos_capture.mm"
"src/macos_capture.mm",
"src/windows_capture_stub.cpp"
],
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
@@ -36,7 +37,17 @@
}],
["OS=='win'", {
"sources": [
"src/macos_capture_stub.cpp"
"src/macos_capture_stub.cpp",
"src/windows_capture.cpp"
],
"defines": [
"WIN32_LEAN_AND_MEAN",
"NOMINMAX"
],
"libraries": [
"ole32.lib",
"avrt.lib",
"uuid.lib"
],
"msvs_settings": {
"VCCLCompilerTool": {
@@ -47,7 +58,8 @@
}],
["OS=='linux'", {
"sources": [
"src/macos_capture_stub.cpp"
"src/macos_capture_stub.cpp",
"src/windows_capture_stub.cpp"
],
"cflags_cc": ["-std=c++17", "-O3", "-ffast-math", "-fPIC"]
}]
+2
View File
@@ -1,6 +1,7 @@
#include <napi.h>
#include <cstring>
#include "macos_capture.h"
#include "windows_capture.h"
#include "oscilloscope.h"
#include "spectrum.h"
#include "vectorscope.h"
@@ -296,6 +297,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("vectorscope", vecExports);
RegisterMacOSCapture(env, exports);
RegisterWindowsCapture(env, exports);
return exports;
}
+872
View File
@@ -0,0 +1,872 @@
#include "windows_capture.h"
#if defined(_WIN32)
#include <Audioclient.h>
#include <Functiondiscoverykeys_devpkey.h>
#include <Mmdeviceapi.h>
#include <ksmedia.h>
#include <mmreg.h>
#include <propidl.h>
#include <avrt.h>
#include <wrl/client.h>
#include <windows.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <deque>
#include <limits>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace {
using Microsoft::WRL::ComPtr;
constexpr size_t kMaxQueuedChunks = 256;
constexpr size_t kDefaultDrainChunkLimit = 64;
struct OutputDeviceInfo {
std::string id;
std::string label;
double sampleRate;
UINT32 channelCount;
bool isDefault;
};
struct CapturedChunk {
std::vector<float> left;
std::vector<float> right;
UINT32 channelCount = 2;
double capturedAtMilliseconds = 0.0;
uint64_t sequence = 0;
};
struct AudioFormatInfo {
bool valid = false;
bool isFloat = false;
WORD channels = 0;
DWORD sampleRate = 48000;
WORD bitsPerSample = 0;
WORD validBitsPerSample = 0;
WORD bytesPerFrame = 0;
WORD bytesPerSample = 0;
};
double monotonicMilliseconds() {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
return std::chrono::duration<double, std::milli>(now).count();
}
std::string wideToUtf8(const std::wstring& value) {
if (value.empty()) {
return {};
}
const int sizeNeeded = WideCharToMultiByte(
CP_UTF8, 0, value.c_str(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
if (sizeNeeded <= 0) {
return {};
}
std::string result(static_cast<size_t>(sizeNeeded), '\0');
WideCharToMultiByte(
CP_UTF8,
0,
value.c_str(),
static_cast<int>(value.size()),
result.data(),
sizeNeeded,
nullptr,
nullptr);
return result;
}
std::wstring utf8ToWide(const std::string& value) {
if (value.empty()) {
return {};
}
const int sizeNeeded =
MultiByteToWideChar(CP_UTF8, 0, value.c_str(), static_cast<int>(value.size()), nullptr, 0);
if (sizeNeeded <= 0) {
return {};
}
std::wstring result(static_cast<size_t>(sizeNeeded), L'\0');
MultiByteToWideChar(
CP_UTF8,
0,
value.c_str(),
static_cast<int>(value.size()),
result.data(),
sizeNeeded);
return result;
}
std::string hresultMessage(const char* operation, HRESULT hr) {
std::ostringstream stream;
stream << operation << " failed (0x" << std::hex << std::uppercase
<< static_cast<unsigned long>(hr) << ")";
return stream.str();
}
class ScopedCoInit {
public:
ScopedCoInit()
: hr_(CoInitializeEx(nullptr, COINIT_MULTITHREADED)),
usable_(SUCCEEDED(hr_) || hr_ == RPC_E_CHANGED_MODE) {}
~ScopedCoInit() {
if (SUCCEEDED(hr_)) {
CoUninitialize();
}
}
bool usable() const {
return usable_;
}
HRESULT result() const {
return hr_;
}
private:
HRESULT hr_;
bool usable_;
};
std::string getDeviceId(IMMDevice* device) {
if (device == nullptr) {
return {};
}
LPWSTR id = nullptr;
const HRESULT hr = device->GetId(&id);
if (FAILED(hr) || id == nullptr) {
return {};
}
std::wstring wideId(id);
CoTaskMemFree(id);
return wideToUtf8(wideId);
}
std::string getDeviceFriendlyName(IMMDevice* device) {
if (device == nullptr) {
return {};
}
ComPtr<IPropertyStore> properties;
HRESULT hr = device->OpenPropertyStore(STGM_READ, &properties);
if (FAILED(hr) || !properties) {
return {};
}
PROPVARIANT value;
PropVariantInit(&value);
hr = properties->GetValue(PKEY_Device_FriendlyName, &value);
if (FAILED(hr)) {
PropVariantClear(&value);
return {};
}
std::string label;
if (value.vt == VT_LPWSTR && value.pwszVal != nullptr) {
label = wideToUtf8(value.pwszVal);
}
PropVariantClear(&value);
return label;
}
AudioFormatInfo getFormatInfo(const WAVEFORMATEX* format) {
AudioFormatInfo info;
if (format == nullptr || format->nChannels == 0 || format->nBlockAlign == 0) {
return info;
}
info.valid = true;
info.channels = format->nChannels;
info.sampleRate = format->nSamplesPerSec;
info.bitsPerSample = format->wBitsPerSample;
info.validBitsPerSample = format->wBitsPerSample;
info.bytesPerFrame = format->nBlockAlign;
info.bytesPerSample = static_cast<WORD>(format->nBlockAlign / format->nChannels);
info.isFloat = format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT;
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
const auto* extensible = reinterpret_cast<const WAVEFORMATEXTENSIBLE*>(format);
info.isFloat = IsEqualGUID(extensible->SubFormat, KSDATAFORMAT_SUBTYPE_IEEE_FLOAT);
if (extensible->Samples.wValidBitsPerSample != 0) {
info.validBitsPerSample = extensible->Samples.wValidBitsPerSample;
}
}
return info;
}
float decodeSignedIntegerSample(const BYTE* data, WORD bytesPerSample, WORD validBitsPerSample) {
if (data == nullptr || bytesPerSample == 0) {
return 0.0f;
}
const WORD totalBits = static_cast<WORD>(bytesPerSample * 8);
const WORD validBits = static_cast<WORD>(
std::max<WORD>(1, std::min<WORD>(validBitsPerSample == 0 ? totalBits : validBitsPerSample, 32)));
uint32_t rawBits = 0;
for (WORD byteIndex = 0; byteIndex < bytesPerSample && byteIndex < 4; ++byteIndex) {
rawBits |= static_cast<uint32_t>(data[byteIndex]) << (byteIndex * 8);
}
const uint32_t validMask =
validBits >= 32 ? std::numeric_limits<uint32_t>::max() : ((1u << validBits) - 1u);
rawBits &= validMask;
int32_t signedValue = 0;
if (validBits == 32) {
signedValue = static_cast<int32_t>(rawBits);
} else {
const uint32_t signBit = 1u << (validBits - 1);
if ((rawBits & signBit) != 0) {
rawBits |= ~validMask;
}
signedValue = static_cast<int32_t>(rawBits);
}
const double maxMagnitude = validBits == 32
? static_cast<double>(std::numeric_limits<int32_t>::max())
: static_cast<double>((1ULL << (validBits - 1)) - 1ULL);
if (maxMagnitude <= 0.0) {
return 0.0f;
}
return static_cast<float>(static_cast<double>(signedValue) / maxMagnitude);
}
float readFrameSample(const BYTE* frameData, UINT32 channelIndex, const AudioFormatInfo& format) {
if (frameData == nullptr || !format.valid || format.bytesPerSample == 0 ||
channelIndex >= format.channels) {
return 0.0f;
}
const BYTE* samplePtr = frameData + static_cast<size_t>(channelIndex) * format.bytesPerSample;
if (format.isFloat) {
if (format.bitsPerSample == 32 && format.bytesPerSample >= sizeof(float)) {
float value = 0.0f;
std::memcpy(&value, samplePtr, sizeof(float));
return value;
}
if (format.bitsPerSample == 64 && format.bytesPerSample >= sizeof(double)) {
double value = 0.0;
std::memcpy(&value, samplePtr, sizeof(double));
return static_cast<float>(value);
}
}
return decodeSignedIntegerSample(
samplePtr, format.bytesPerSample, format.validBitsPerSample);
}
bool getDeviceMixFormat(IMMDevice* device, double* outSampleRate, UINT32* outChannelCount) {
if (device == nullptr) {
return false;
}
ComPtr<IAudioClient> audioClient;
HRESULT hr = device->Activate(
__uuidof(IAudioClient),
CLSCTX_ALL,
nullptr,
reinterpret_cast<void**>(audioClient.ReleaseAndGetAddressOf()));
if (FAILED(hr) || !audioClient) {
return false;
}
WAVEFORMATEX* mixFormat = nullptr;
hr = audioClient->GetMixFormat(&mixFormat);
if (FAILED(hr) || mixFormat == nullptr) {
return false;
}
const AudioFormatInfo info = getFormatInfo(mixFormat);
CoTaskMemFree(mixFormat);
if (!info.valid) {
return false;
}
if (outSampleRate != nullptr) {
*outSampleRate = static_cast<double>(info.sampleRate);
}
if (outChannelCount != nullptr) {
*outChannelCount = info.channels;
}
return true;
}
std::vector<OutputDeviceInfo> enumerateOutputDevices() {
ScopedCoInit coInit;
if (!coInit.usable()) {
return {};
}
ComPtr<IMMDeviceEnumerator> enumerator;
HRESULT hr = CoCreateInstance(
__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, IID_PPV_ARGS(&enumerator));
if (FAILED(hr) || !enumerator) {
return {};
}
std::string defaultDeviceId;
ComPtr<IMMDevice> defaultDevice;
hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &defaultDevice);
if (SUCCEEDED(hr) && defaultDevice) {
defaultDeviceId = getDeviceId(defaultDevice.Get());
}
ComPtr<IMMDeviceCollection> collection;
hr = enumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &collection);
if (FAILED(hr) || !collection) {
return {};
}
UINT deviceCount = 0;
hr = collection->GetCount(&deviceCount);
if (FAILED(hr) || deviceCount == 0) {
return {};
}
std::vector<OutputDeviceInfo> devices;
devices.reserve(deviceCount);
for (UINT index = 0; index < deviceCount; ++index) {
ComPtr<IMMDevice> device;
hr = collection->Item(index, &device);
if (FAILED(hr) || !device) {
continue;
}
const std::string deviceId = getDeviceId(device.Get());
if (deviceId.empty()) {
continue;
}
std::string label = getDeviceFriendlyName(device.Get());
if (label.empty()) {
label = deviceId;
}
double sampleRate = 48000.0;
UINT32 channelCount = 2;
getDeviceMixFormat(device.Get(), &sampleRate, &channelCount);
devices.push_back(OutputDeviceInfo{
deviceId,
label,
sampleRate,
channelCount,
deviceId == defaultDeviceId,
});
}
return devices;
}
class WindowsNativeCaptureEngine {
public:
Napi::Object GetSupport(Napi::Env env) {
Napi::Object support = Napi::Object::New(env);
support.Set("available", Napi::Boolean::New(env, true));
support.Set("reason", env.Null());
return support;
}
Napi::Array ListOutputDevices(Napi::Env env) {
const auto devices = enumerateOutputDevices();
Napi::Array result = Napi::Array::New(env, devices.size());
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.channelCount)));
result.Set(static_cast<uint32_t>(index), entry);
}
return result;
}
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceId) {
std::string errorMessage;
if (!startInternal(requestedDeviceId, &errorMessage)) {
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
return Napi::Object::New(env);
}
std::lock_guard<std::mutex> lock(stateMutex_);
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set(
"channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
}
void Stop() {
stopInternal();
}
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
const size_t drainLimit =
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
uint64_t overwriteCount = 0;
size_t queueDepth = 0;
{
std::lock_guard<std::mutex> lock(chunkMutex_);
overwriteCount = overwriteCount_;
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
queueDepth = chunkQueue_.size();
}
Napi::Array chunks = Napi::Array::New(env, drained.size());
for (size_t index = 0; index < drained.size(); ++index) {
auto& chunk = drained[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
return result;
}
double NowMilliseconds() const {
return monotonicMilliseconds();
}
private:
bool startInternal(const std::string& requestedDeviceId, std::string* outErrorMessage) {
stopInternal();
{
std::lock_guard<std::mutex> lock(stateMutex_);
startPending_ = true;
startSucceeded_ = false;
startError_.clear();
stopRequested_.store(false);
if (stopEvent_ != nullptr) {
CloseHandle(stopEvent_);
stopEvent_ = nullptr;
}
stopEvent_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (stopEvent_ == nullptr) {
startPending_ = false;
startError_ = "CreateEventW failed for Windows loopback capture.";
if (outErrorMessage != nullptr) {
*outErrorMessage = startError_;
}
return false;
}
}
captureThread_ = std::thread(
[this, requestedDeviceId]() { this->captureThreadMain(requestedDeviceId); });
std::unique_lock<std::mutex> lock(stateMutex_);
startCondition_.wait(lock, [this]() { return !startPending_; });
if (!startSucceeded_) {
const std::string errorMessage = startError_.empty()
? "Native Windows loopback capture failed to start."
: startError_;
lock.unlock();
stopInternal();
if (outErrorMessage != nullptr) {
*outErrorMessage = errorMessage;
}
return false;
}
return true;
}
void stopInternal() {
std::thread captureThread;
{
std::lock_guard<std::mutex> lock(stateMutex_);
stopRequested_.store(true);
if (stopEvent_ != nullptr) {
SetEvent(stopEvent_);
}
if (captureThread_.joinable()) {
captureThread = std::move(captureThread_);
}
}
if (captureThread.joinable()) {
captureThread.join();
}
{
std::lock_guard<std::mutex> lock(stateMutex_);
if (stopEvent_ != nullptr) {
CloseHandle(stopEvent_);
stopEvent_ = nullptr;
}
active_ = false;
startPending_ = false;
startSucceeded_ = false;
activeDeviceId_.clear();
activeDeviceLabel_.clear();
sampleRate_ = 48000.0;
channelCount_ = 2;
sequence_ = 0;
}
{
std::lock_guard<std::mutex> lock(chunkMutex_);
chunkQueue_.clear();
overwriteCount_ = 0;
}
}
void captureThreadMain(const std::string& requestedDeviceId) {
ScopedCoInit coInit;
if (!coInit.usable()) {
notifyStartFailure(hresultMessage("CoInitializeEx", coInit.result()));
return;
}
ComPtr<IMMDeviceEnumerator> enumerator;
HRESULT hr = CoCreateInstance(
__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, IID_PPV_ARGS(&enumerator));
if (FAILED(hr) || !enumerator) {
notifyStartFailure(hresultMessage("CoCreateInstance(MMDeviceEnumerator)", hr));
return;
}
ComPtr<IMMDevice> device;
if (!requestedDeviceId.empty()) {
const std::wstring requestedWide = utf8ToWide(requestedDeviceId);
hr = enumerator->GetDevice(requestedWide.c_str(), &device);
} else {
hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device);
}
if (FAILED(hr) || !device) {
notifyStartFailure(hresultMessage("Get audio endpoint", hr));
return;
}
const std::string deviceId = getDeviceId(device.Get());
std::string deviceLabel = getDeviceFriendlyName(device.Get());
if (deviceLabel.empty()) {
deviceLabel = deviceId.empty() ? "Windows Output Device" : deviceId;
}
ComPtr<IAudioClient> audioClient;
hr = device->Activate(
__uuidof(IAudioClient),
CLSCTX_ALL,
nullptr,
reinterpret_cast<void**>(audioClient.ReleaseAndGetAddressOf()));
if (FAILED(hr) || !audioClient) {
notifyStartFailure(hresultMessage("IMMDevice::Activate(IAudioClient)", hr));
return;
}
WAVEFORMATEX* mixFormat = nullptr;
hr = audioClient->GetMixFormat(&mixFormat);
if (FAILED(hr) || mixFormat == nullptr) {
notifyStartFailure(hresultMessage("IAudioClient::GetMixFormat", hr));
return;
}
const AudioFormatInfo format = getFormatInfo(mixFormat);
if (!format.valid) {
CoTaskMemFree(mixFormat);
notifyStartFailure("Unsupported WASAPI mix format for Windows loopback capture.");
return;
}
REFERENCE_TIME defaultPeriod = 0;
REFERENCE_TIME minimumPeriod = 0;
audioClient->GetDevicePeriod(&defaultPeriod, &minimumPeriod);
const DWORD sleepMilliseconds = static_cast<DWORD>(
std::max<LONG64>(2, std::min<LONG64>(10, defaultPeriod / 10000 / 2)));
hr = audioClient->Initialize(
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK, 0, 0, mixFormat, nullptr);
if (FAILED(hr)) {
CoTaskMemFree(mixFormat);
notifyStartFailure(hresultMessage("IAudioClient::Initialize", hr));
return;
}
ComPtr<IAudioCaptureClient> captureClient;
hr = audioClient->GetService(IID_PPV_ARGS(&captureClient));
if (FAILED(hr) || !captureClient) {
CoTaskMemFree(mixFormat);
notifyStartFailure(hresultMessage("IAudioClient::GetService(IAudioCaptureClient)", hr));
return;
}
hr = audioClient->Start();
if (FAILED(hr)) {
CoTaskMemFree(mixFormat);
notifyStartFailure(hresultMessage("IAudioClient::Start", hr));
return;
}
notifyStartSuccess(
deviceId,
deviceLabel,
static_cast<double>(format.sampleRate),
std::max<UINT32>(1, format.channels));
DWORD taskIndex = 0;
HANDLE mmcssHandle = AvSetMmThreadCharacteristicsW(L"Audio", &taskIndex);
while (!stopRequested_.load()) {
UINT32 packetFrames = 0;
hr = captureClient->GetNextPacketSize(&packetFrames);
if (FAILED(hr)) {
break;
}
while (packetFrames > 0 && !stopRequested_.load()) {
BYTE* data = nullptr;
UINT32 framesToRead = 0;
DWORD flags = 0;
hr = captureClient->GetBuffer(&data, &framesToRead, &flags, nullptr, nullptr);
if (FAILED(hr)) {
break;
}
if (framesToRead > 0) {
CapturedChunk chunk;
chunk.channelCount = std::max<UINT32>(1, format.channels);
chunk.capturedAtMilliseconds = monotonicMilliseconds();
chunk.left.resize(framesToRead);
chunk.right.resize(framesToRead);
if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0 && data != nullptr) {
for (UINT32 frameIndex = 0; frameIndex < framesToRead; ++frameIndex) {
const BYTE* frameData =
data + static_cast<size_t>(frameIndex) * format.bytesPerFrame;
const float left = readFrameSample(frameData, 0, format);
const float right = format.channels > 1
? readFrameSample(frameData, 1, format)
: left;
chunk.left[frameIndex] = left;
chunk.right[frameIndex] = right;
}
} else {
std::fill(chunk.left.begin(), chunk.left.end(), 0.0f);
std::fill(chunk.right.begin(), chunk.right.end(), 0.0f);
}
pushChunk(std::move(chunk));
}
captureClient->ReleaseBuffer(framesToRead);
hr = captureClient->GetNextPacketSize(&packetFrames);
if (FAILED(hr)) {
break;
}
}
if (FAILED(hr) || stopRequested_.load()) {
break;
}
if (WaitForSingleObject(stopEvent_, sleepMilliseconds) == WAIT_OBJECT_0) {
break;
}
}
if (mmcssHandle != nullptr) {
AvRevertMmThreadCharacteristics(mmcssHandle);
}
audioClient->Stop();
CoTaskMemFree(mixFormat);
std::lock_guard<std::mutex> lock(stateMutex_);
active_ = false;
}
void notifyStartSuccess(const std::string& deviceId,
const std::string& deviceLabel,
double sampleRate,
UINT32 channelCount) {
{
std::lock_guard<std::mutex> lock(stateMutex_);
active_ = true;
activeDeviceId_ = deviceId;
activeDeviceLabel_ = deviceLabel;
sampleRate_ = sampleRate;
channelCount_ = channelCount;
sequence_ = 0;
startSucceeded_ = true;
startPending_ = false;
startError_.clear();
}
{
std::lock_guard<std::mutex> lock(chunkMutex_);
chunkQueue_.clear();
overwriteCount_ = 0;
}
startCondition_.notify_all();
}
void notifyStartFailure(const std::string& message) {
{
std::lock_guard<std::mutex> lock(stateMutex_);
startSucceeded_ = false;
startPending_ = false;
startError_ = message;
active_ = false;
}
startCondition_.notify_all();
}
void pushChunk(CapturedChunk chunk) {
{
std::lock_guard<std::mutex> lock(stateMutex_);
chunk.sequence = ++sequence_;
}
std::lock_guard<std::mutex> lock(chunkMutex_);
if (chunkQueue_.size() >= kMaxQueuedChunks) {
chunkQueue_.pop_front();
++overwriteCount_;
}
chunkQueue_.push_back(std::move(chunk));
}
std::mutex stateMutex_;
std::condition_variable startCondition_;
std::mutex chunkMutex_;
std::deque<CapturedChunk> chunkQueue_;
std::thread captureThread_;
HANDLE stopEvent_ = nullptr;
std::atomic<bool> stopRequested_{false};
uint64_t overwriteCount_ = 0;
uint64_t sequence_ = 0;
bool active_ = false;
bool startPending_ = false;
bool startSucceeded_ = false;
std::string startError_;
std::string activeDeviceId_;
std::string activeDeviceLabel_;
double sampleRate_ = 48000.0;
UINT32 channelCount_ = 2;
};
WindowsNativeCaptureEngine& engine() {
static WindowsNativeCaptureEngine instance;
return instance;
}
Napi::Value WindowsGetSupport(const Napi::CallbackInfo& info) {
return engine().GetSupport(info.Env());
}
Napi::Value WindowsListOutputDevices(const Napi::CallbackInfo& info) {
return engine().ListOutputDevices(info.Env());
}
Napi::Value WindowsStart(const Napi::CallbackInfo& info) {
std::string requestedDeviceId;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
return engine().Start(info.Env(), requestedDeviceId);
}
Napi::Value WindowsStop(const Napi::CallbackInfo& info) {
engine().Stop();
return info.Env().Undefined();
}
Napi::Value WindowsDrain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = static_cast<size_t>(requested);
}
}
return engine().Drain(info.Env(), maxChunks);
}
Napi::Value WindowsNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
}
} // namespace
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, WindowsGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, WindowsListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, WindowsStart));
captureExports.Set("stop", Napi::Function::New(env, WindowsStop));
captureExports.Set("drain", Napi::Function::New(env, WindowsDrain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, WindowsNowMilliseconds));
exports.Set("windowsCapture", captureExports);
}
#endif // defined(_WIN32)
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <napi.h>
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports);
+54
View File
@@ -0,0 +1,54 @@
#include "windows_capture.h"
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native Windows output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native Windows output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
} // namespace
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("windowsCapture", captureExports);
}
+31 -12
View File
@@ -60,25 +60,43 @@ try {
function resolveNativeCaptureSupport(
fallbackEntry: CaptureBackendSupportEntry,
): CaptureBackendSupportEntry {
if (process.platform !== 'darwin') {
return fallbackEntry
}
if (process.platform === 'darwin') {
const macosCapture = nativeAddonModule?.macosCapture
if (!macosCapture) {
return {
kind: 'native-macos',
available: false,
reason: 'Native capture module is not available in this build.',
}
}
const macosCapture = nativeAddonModule?.macosCapture
if (!macosCapture) {
const support = macosCapture.getSupport()
return {
kind: 'native-macos',
available: false,
reason: 'Native capture module is not available in this build.',
available: support.available,
reason: support.reason,
}
}
const support = macosCapture.getSupport()
return {
kind: 'native-macos',
available: support.available,
reason: support.reason,
if (process.platform === 'win32') {
const windowsCapture = nativeAddonModule?.windowsCapture
if (!windowsCapture) {
return {
kind: 'native-windows',
available: false,
reason: 'Native capture module is not available in this build.',
}
}
const support = windowsCapture.getSupport()
return {
kind: 'native-windows',
available: support.available,
reason: support.reason,
}
}
return fallbackEntry
}
const visualizerAPI = nativeAddonModule
@@ -92,6 +110,7 @@ const visualizerAPI = nativeAddonModule
const nativeCaptureAPI = nativeAddonModule
? {
macosCapture: nativeAddonModule.macosCapture,
windowsCapture: nativeAddonModule.windowsCapture,
}
: null
+59 -14
View File
@@ -13,7 +13,11 @@ import type {
CaptureMode,
CaptureSourceDescriptor,
} from '../../types/capture'
import type { NativeMacOSCaptureDrainResult, NativeMacOSCaptureStartResult } from '../../types/nativeCapture'
import type {
NativeCaptureDrainResult,
NativeCaptureStartResult,
NativeSystemCaptureAPI,
} from '../../types/nativeCapture'
export type { CaptureMode } from '../../types/capture'
@@ -338,8 +342,8 @@ class ElectronDeviceCaptureBackend implements CaptureBackend {
}
}
class NativeMacOSCaptureBackend implements CaptureBackend {
readonly kind = 'native-macos' as const
abstract class NativePolledCaptureBackend implements CaptureBackend {
abstract readonly kind: CaptureBackendKind
private readonly chunkListeners = new Set<(chunk: CaptureChunk) => void>()
private pollTimer: number | null = null
@@ -354,14 +358,14 @@ class NativeMacOSCaptureBackend implements CaptureBackend {
}
async start(request?: CaptureBackendStartRequest): Promise<void> {
const nativeCapture = window.nativeCaptureAPI?.macosCapture
const nativeCapture = this.getNativeCaptureModule()
if (!nativeCapture) {
throw new Error('Native macOS capture module is not available in this build.')
throw new Error(`${this.getBackendLabel()} capture module is not available in this build.`)
}
const support = nativeCapture.getSupport()
if (!support.available) {
throw new Error(support.reason ?? 'Native macOS capture is unavailable.')
throw new Error(support.reason ?? `${this.getBackendLabel()} capture is unavailable.`)
}
const nativeNow = nativeCapture.nowMilliseconds()
@@ -371,7 +375,7 @@ class NativeMacOSCaptureBackend implements CaptureBackend {
request?.deviceId && request.deviceId !== DEFAULT_SYSTEM_SOURCE_ID
? request.deviceId
: undefined,
) as NativeMacOSCaptureStartResult
) as NativeCaptureStartResult
this.sampleRate = Math.max(1, Math.floor(startResult.sampleRate) || 48000)
this.channelCount = Math.max(1, Math.floor(startResult.channelCount) || 2)
@@ -382,12 +386,12 @@ class NativeMacOSCaptureBackend implements CaptureBackend {
async stop(): Promise<void> {
this.stopPolling()
window.nativeCaptureAPI?.macosCapture.stop()
this.getNativeCaptureModule()?.stop()
this.active = false
}
async listSources(): Promise<CaptureSourceDescriptor[]> {
const nativeCapture = window.nativeCaptureAPI?.macosCapture
const nativeCapture = this.getNativeCaptureModule()
if (!nativeCapture) {
return [getDefaultSystemSourceDescriptor()]
}
@@ -437,7 +441,7 @@ class NativeMacOSCaptureBackend implements CaptureBackend {
if (!this.active) return
try {
const result = window.nativeCaptureAPI?.macosCapture.drain(32) as NativeMacOSCaptureDrainResult | undefined
const result = this.getNativeCaptureModule()?.drain(32) as NativeCaptureDrainResult | undefined
if (result) {
for (const chunk of result.chunks) {
const routedChunk: CaptureChunk = {
@@ -454,7 +458,7 @@ class NativeMacOSCaptureBackend implements CaptureBackend {
}
}
} catch (error) {
console.error('Native macOS capture poll failed:', error)
console.error(`${this.getBackendLabel()} capture poll failed:`, error)
this.active = false
this.stopPolling()
return
@@ -472,6 +476,33 @@ class NativeMacOSCaptureBackend implements CaptureBackend {
this.pollTimer = null
}
}
protected abstract getNativeCaptureModule(): NativeSystemCaptureAPI | null
protected abstract getBackendLabel(): string
}
class NativeMacOSCaptureBackend extends NativePolledCaptureBackend {
readonly kind = 'native-macos' as const
protected getNativeCaptureModule(): NativeSystemCaptureAPI | null {
return window.nativeCaptureAPI?.macosCapture ?? null
}
protected getBackendLabel(): string {
return 'Native macOS'
}
}
class NativeWindowsCaptureBackend extends NativePolledCaptureBackend {
readonly kind = 'native-windows' as const
protected getNativeCaptureModule(): NativeSystemCaptureAPI | null {
return window.nativeCaptureAPI?.windowsCapture ?? null
}
protected getBackendLabel(): string {
return 'Native Windows'
}
}
class NativeUnavailableCaptureBackend implements CaptureBackend {
@@ -731,9 +762,23 @@ class AudioCapture {
}
private createNativeBackend(supportEntry: CaptureBackendSupportEntry): CaptureBackend {
const backend = supportEntry.kind === 'native-macos' && supportEntry.available
? new NativeMacOSCaptureBackend(supportEntry)
: new NativeUnavailableCaptureBackend(supportEntry)
let backend: CaptureBackend
switch (supportEntry.kind) {
case 'native-macos':
backend = supportEntry.available
? new NativeMacOSCaptureBackend(supportEntry)
: new NativeUnavailableCaptureBackend(supportEntry)
break
case 'native-windows':
backend = supportEntry.available
? new NativeWindowsCaptureBackend(supportEntry)
: new NativeUnavailableCaptureBackend(supportEntry)
break
default:
backend = new NativeUnavailableCaptureBackend(supportEntry)
break
}
backend.subscribe((chunk) => this.handleChunk(backend.kind, chunk))
return backend
+26 -11
View File
@@ -1,9 +1,9 @@
export interface NativeMacOSCaptureSupport {
export interface NativeCaptureSupport {
available: boolean
reason: string | null
}
export interface NativeMacOSCaptureSource {
export interface NativeCaptureSource {
id: string
label: string
kind: 'system'
@@ -12,14 +12,14 @@ export interface NativeMacOSCaptureSource {
channelCount: number
}
export interface NativeMacOSCaptureStartResult {
export interface NativeCaptureStartResult {
sampleRate: number
channelCount: number
deviceId: string
deviceLabel: string
}
export interface NativeMacOSCapturedChunk {
export interface NativeCapturedChunk {
left: Float32Array
right: Float32Array
channelCount: number
@@ -27,21 +27,36 @@ export interface NativeMacOSCapturedChunk {
sequence: number
}
export interface NativeMacOSCaptureDrainResult {
chunks: NativeMacOSCapturedChunk[]
export interface NativeCaptureDrainResult {
chunks: NativeCapturedChunk[]
overwriteCount: number
queueDepth: number
}
export interface NativeMacOSCaptureAPI {
getSupport: () => NativeMacOSCaptureSupport
listOutputDevices: () => NativeMacOSCaptureSource[]
start: (deviceId?: string) => NativeMacOSCaptureStartResult
export interface NativeSystemCaptureAPI {
getSupport: () => NativeCaptureSupport
listOutputDevices: () => NativeCaptureSource[]
start: (deviceId?: string) => NativeCaptureStartResult
stop: () => void
drain: (maxChunks?: number) => NativeMacOSCaptureDrainResult
drain: (maxChunks?: number) => NativeCaptureDrainResult
nowMilliseconds: () => number
}
export type NativeMacOSCaptureSupport = NativeCaptureSupport
export type NativeMacOSCaptureSource = NativeCaptureSource
export type NativeMacOSCaptureStartResult = NativeCaptureStartResult
export type NativeMacOSCapturedChunk = NativeCapturedChunk
export type NativeMacOSCaptureDrainResult = NativeCaptureDrainResult
export type NativeMacOSCaptureAPI = NativeSystemCaptureAPI
export type NativeWindowsCaptureSupport = NativeCaptureSupport
export type NativeWindowsCaptureSource = NativeCaptureSource
export type NativeWindowsCaptureStartResult = NativeCaptureStartResult
export type NativeWindowsCapturedChunk = NativeCapturedChunk
export type NativeWindowsCaptureDrainResult = NativeCaptureDrainResult
export type NativeWindowsCaptureAPI = NativeSystemCaptureAPI
export interface NativeCaptureAPI {
macosCapture: NativeMacOSCaptureAPI
windowsCapture: NativeWindowsCaptureAPI
}