diff --git a/native/binding.gyp b/native/binding.gyp index fe62ed3..b07cb7c 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -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"] }] diff --git a/native/src/main.cpp b/native/src/main.cpp index fa618c1..4ca598c 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -1,6 +1,7 @@ #include #include #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; } diff --git a/native/src/windows_capture.cpp b/native/src/windows_capture.cpp new file mode 100644 index 0000000..1e1d6ea --- /dev/null +++ b/native/src/windows_capture.cpp @@ -0,0 +1,872 @@ +#include "windows_capture.h" + +#if defined(_WIN32) + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 left; + std::vector 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(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(value.size()), nullptr, 0, nullptr, nullptr); + if (sizeNeeded <= 0) { + return {}; + } + + std::string result(static_cast(sizeNeeded), '\0'); + WideCharToMultiByte( + CP_UTF8, + 0, + value.c_str(), + static_cast(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(value.size()), nullptr, 0); + if (sizeNeeded <= 0) { + return {}; + } + + std::wstring result(static_cast(sizeNeeded), L'\0'); + MultiByteToWideChar( + CP_UTF8, + 0, + value.c_str(), + static_cast(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(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 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(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(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(bytesPerSample * 8); + const WORD validBits = static_cast( + std::max(1, std::min(validBitsPerSample == 0 ? totalBits : validBitsPerSample, 32))); + + uint32_t rawBits = 0; + for (WORD byteIndex = 0; byteIndex < bytesPerSample && byteIndex < 4; ++byteIndex) { + rawBits |= static_cast(data[byteIndex]) << (byteIndex * 8); + } + + const uint32_t validMask = + validBits >= 32 ? std::numeric_limits::max() : ((1u << validBits) - 1u); + rawBits &= validMask; + + int32_t signedValue = 0; + if (validBits == 32) { + signedValue = static_cast(rawBits); + } else { + const uint32_t signBit = 1u << (validBits - 1); + if ((rawBits & signBit) != 0) { + rawBits |= ~validMask; + } + signedValue = static_cast(rawBits); + } + + const double maxMagnitude = validBits == 32 + ? static_cast(std::numeric_limits::max()) + : static_cast((1ULL << (validBits - 1)) - 1ULL); + if (maxMagnitude <= 0.0) { + return 0.0f; + } + + return static_cast(static_cast(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(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(value); + } + } + + return decodeSignedIntegerSample( + samplePtr, format.bytesPerSample, format.validBitsPerSample); +} + +bool getDeviceMixFormat(IMMDevice* device, double* outSampleRate, UINT32* outChannelCount) { + if (device == nullptr) { + return false; + } + + ComPtr audioClient; + HRESULT hr = device->Activate( + __uuidof(IAudioClient), + CLSCTX_ALL, + nullptr, + reinterpret_cast(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(info.sampleRate); + } + if (outChannelCount != nullptr) { + *outChannelCount = info.channels; + } + + return true; +} + +std::vector enumerateOutputDevices() { + ScopedCoInit coInit; + if (!coInit.usable()) { + return {}; + } + + ComPtr enumerator; + HRESULT hr = CoCreateInstance( + __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, IID_PPV_ARGS(&enumerator)); + if (FAILED(hr) || !enumerator) { + return {}; + } + + std::string defaultDeviceId; + ComPtr defaultDevice; + hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &defaultDevice); + if (SUCCEEDED(hr) && defaultDevice) { + defaultDeviceId = getDeviceId(defaultDevice.Get()); + } + + ComPtr 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 devices; + devices.reserve(deviceCount); + + for (UINT index = 0; index < deviceCount; ++index) { + ComPtr 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(device.channelCount))); + result.Set(static_cast(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 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(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 drained; + uint64_t overwriteCount = 0; + size_t queueDepth = 0; + + { + std::lock_guard 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(chunk.channelCount))); + entry.Set( + "capturedAtMilliseconds", + Napi::Number::New(env, chunk.capturedAtMilliseconds)); + entry.Set( + "sequence", + Napi::Number::New(env, static_cast(chunk.sequence))); + chunks.Set(static_cast(index), entry); + } + + Napi::Object result = Napi::Object::New(env); + result.Set("chunks", chunks); + result.Set( + "overwriteCount", Napi::Number::New(env, static_cast(overwriteCount))); + result.Set("queueDepth", Napi::Number::New(env, static_cast(queueDepth))); + return result; + } + + double NowMilliseconds() const { + return monotonicMilliseconds(); + } + +private: + bool startInternal(const std::string& requestedDeviceId, std::string* outErrorMessage) { + stopInternal(); + + { + std::lock_guard 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 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 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 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 lock(chunkMutex_); + chunkQueue_.clear(); + overwriteCount_ = 0; + } + } + + void captureThreadMain(const std::string& requestedDeviceId) { + ScopedCoInit coInit; + if (!coInit.usable()) { + notifyStartFailure(hresultMessage("CoInitializeEx", coInit.result())); + return; + } + + ComPtr enumerator; + HRESULT hr = CoCreateInstance( + __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, IID_PPV_ARGS(&enumerator)); + if (FAILED(hr) || !enumerator) { + notifyStartFailure(hresultMessage("CoCreateInstance(MMDeviceEnumerator)", hr)); + return; + } + + ComPtr 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 audioClient; + hr = device->Activate( + __uuidof(IAudioClient), + CLSCTX_ALL, + nullptr, + reinterpret_cast(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( + std::max(2, std::min(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 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(format.sampleRate), + std::max(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(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(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 lock(stateMutex_); + active_ = false; + } + + void notifyStartSuccess(const std::string& deviceId, + const std::string& deviceLabel, + double sampleRate, + UINT32 channelCount) { + { + std::lock_guard lock(stateMutex_); + active_ = true; + activeDeviceId_ = deviceId; + activeDeviceLabel_ = deviceLabel; + sampleRate_ = sampleRate; + channelCount_ = channelCount; + sequence_ = 0; + startSucceeded_ = true; + startPending_ = false; + startError_.clear(); + } + + { + std::lock_guard lock(chunkMutex_); + chunkQueue_.clear(); + overwriteCount_ = 0; + } + + startCondition_.notify_all(); + } + + void notifyStartFailure(const std::string& message) { + { + std::lock_guard lock(stateMutex_); + startSucceeded_ = false; + startPending_ = false; + startError_ = message; + active_ = false; + } + startCondition_.notify_all(); + } + + void pushChunk(CapturedChunk chunk) { + { + std::lock_guard lock(stateMutex_); + chunk.sequence = ++sequence_; + } + + std::lock_guard 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 chunkQueue_; + std::thread captureThread_; + HANDLE stopEvent_ = nullptr; + std::atomic 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().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().Int64Value(); + if (requested > 0) { + maxChunks = static_cast(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) diff --git a/native/src/windows_capture.h b/native/src/windows_capture.h new file mode 100644 index 0000000..8c937c6 --- /dev/null +++ b/native/src/windows_capture.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +void RegisterWindowsCapture(Napi::Env env, Napi::Object exports); diff --git a/native/src/windows_capture_stub.cpp b/native/src/windows_capture_stub.cpp new file mode 100644 index 0000000..7bc180f --- /dev/null +++ b/native/src/windows_capture_stub.cpp @@ -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); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c07f1b7..e2f7ffb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index b96ffc0..0d1e7b7 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -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 { - 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 { this.stopPolling() - window.nativeCaptureAPI?.macosCapture.stop() + this.getNativeCaptureModule()?.stop() this.active = false } async listSources(): Promise { - 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 diff --git a/src/types/nativeCapture.ts b/src/types/nativeCapture.ts index 1736c26..28e03e0 100644 --- a/src/types/nativeCapture.ts +++ b/src/types/nativeCapture.ts @@ -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 }