From 70cefabd573d64ccaaf02f8fb7ec26dd79a6b60b Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:31:02 +1100 Subject: [PATCH] Revert "fix(recording): validate native captures and tighten mic menu spacing" This reverts commit 1900c0e737edb537763631d53d37adb440dbd1ab. --- electron/native/wgc-capture/src/main.cpp | 28 +- .../native/wgc-capture/src/mf_encoder.cpp | 2 - electron/native/wgc-capture/src/mf_encoder.h | 2 - .../wgc-capture/src/wasapi_loopback.cpp | 52 +-- .../native/wgc-capture/src/wasapi_loopback.h | 6 +- .../native/windows-capture/CMakeLists.txt | 27 ++ .../windows-capture/src/dxgi_session.cpp | 343 ++++++++++++++++++ .../native/windows-capture/src/dxgi_session.h | 62 ++++ electron/native/windows-capture/src/main.cpp | 324 +++++++++++++++++ .../native/windows-capture/src/mf_encoder.cpp | 205 +++++++++++ .../native/windows-capture/src/mf_encoder.h | 35 ++ .../windows-capture/src/monitor_utils.cpp | 61 ++++ .../windows-capture/src/monitor_utils.h | 19 + .../windows-capture/src/wasapi_loopback.cpp | 274 ++++++++++++++ .../windows-capture/src/wasapi_loopback.h | 44 +++ src/components/launch/LaunchWindow.module.css | 8 - src/components/launch/LaunchWindow.tsx | 18 +- 17 files changed, 1408 insertions(+), 102 deletions(-) create mode 100644 electron/native/windows-capture/CMakeLists.txt create mode 100644 electron/native/windows-capture/src/dxgi_session.cpp create mode 100644 electron/native/windows-capture/src/dxgi_session.h create mode 100644 electron/native/windows-capture/src/main.cpp create mode 100644 electron/native/windows-capture/src/mf_encoder.cpp create mode 100644 electron/native/windows-capture/src/mf_encoder.h create mode 100644 electron/native/windows-capture/src/monitor_utils.cpp create mode 100644 electron/native/windows-capture/src/monitor_utils.h create mode 100644 electron/native/windows-capture/src/wasapi_loopback.cpp create mode 100644 electron/native/windows-capture/src/wasapi_loopback.h diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 697b1ac0..8dd4f930 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -29,7 +29,6 @@ struct CaptureConfig { std::string outputPath; std::string audioOutputPath; std::string micOutputPath; - std::string micDeviceId; std::string micDeviceName; int fps = 60; int width = 0; @@ -114,7 +113,6 @@ 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 { @@ -228,7 +226,6 @@ int main(int argc, char* argv[]) { // Set up frame callback std::atomic frameCount{0}; - std::atomic frameWriteFailed{false}; session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { g_lastFrameTimestampHns = timestampHns; if (g_stopRequested) return; @@ -250,12 +247,7 @@ int main(int argc, char* argv[]) { if (encoder.writeFrame(texture, adjustedTimestampHns)) { frameCount++; - return; } - - frameWriteFailed = true; - g_stopRequested = true; - g_stopCv.notify_all(); }); // Start stdin listener @@ -278,10 +270,7 @@ int main(int argc, char* argv[]) { } if (config.captureMic && !config.micOutputPath.empty()) { - micInitialized = micCapture.initializeMic( - config.micOutputPath, - config.micDeviceId, - config.micDeviceName); + micInitialized = micCapture.initializeMic(config.micOutputPath, config.micDeviceName); if (!micInitialized) { std::cerr << "WARNING: Failed to initialize WASAPI mic capture" << std::endl; } @@ -321,20 +310,7 @@ int main(int argc, char* argv[]) { session.stopCapture(); if (audioActive) loopback.stop(); if (micActive) micCapture.stop(); - if (frameWriteFailed.load()) { - std::cerr << "ERROR: Failed to encode one or more video frames" << std::endl; - return 1; - } - - if (frameCount.load() <= 0) { - std::cerr << "ERROR: No video frames were written" << std::endl; - return 1; - } - - if (!encoder.finalize()) { - std::cerr << "ERROR: Failed to finalize Media Foundation encoder" << std::endl; - return 1; - } + encoder.finalize(); std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; if (audioActive) { diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 1adef1b6..a1474c20 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -124,7 +124,6 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height } bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { - std::lock_guard lock(writeMutex_); if (!initialized_ || !sinkWriter_) return false; context_->CopyResource(stagingTexture_.Get(), texture); @@ -191,7 +190,6 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { } bool MFEncoder::finalize() { - std::lock_guard lock(writeMutex_); if (!initialized_) return false; initialized_ = false; diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 11b9c774..7eb8e642 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -8,7 +8,6 @@ #include #include #include -#include using Microsoft::WRL::ComPtr; @@ -33,5 +32,4 @@ private: int height_ = 0; int fps_ = 60; bool initialized_ = false; - std::mutex writeMutex_; }; diff --git a/electron/native/wgc-capture/src/wasapi_loopback.cpp b/electron/native/wgc-capture/src/wasapi_loopback.cpp index b7b1cc06..8ae15ad1 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback.cpp @@ -29,36 +29,6 @@ static std::wstring utf8ToWide(const std::string& str) { return wstr; } -IMMDevice* WasapiCapture::findCaptureDeviceById(const std::wstring& targetId) { - IMMDeviceCollection* collection = nullptr; - HRESULT hr = enumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &collection); - if (FAILED(hr)) return nullptr; - - UINT count = 0; - collection->GetCount(&count); - - for (UINT i = 0; i < count; i++) { - IMMDevice* dev = nullptr; - collection->Item(i, &dev); - - LPWSTR deviceId = nullptr; - hr = dev->GetId(&deviceId); - if (SUCCEEDED(hr) && deviceId) { - const bool matches = targetId == deviceId; - CoTaskMemFree(deviceId); - if (matches) { - collection->Release(); - return dev; - } - } - - dev->Release(); - } - - collection->Release(); - return nullptr; -} - IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName) { IMMDeviceCollection* collection = nullptr; HRESULT hr = enumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &collection); @@ -67,8 +37,6 @@ IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName UINT count = 0; collection->GetCount(&count); - IMMDevice* partialMatch = nullptr; - for (UINT i = 0; i < count; i++) { IMMDevice* dev = nullptr; collection->Item(i, &dev); @@ -82,21 +50,15 @@ IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName PropVariantClear(&pv); store->Release(); - if (name == targetName) { + if (name.find(targetName) != std::wstring::npos || targetName.find(name) != std::wstring::npos) { collection->Release(); return dev; } - - if (!partialMatch && (name.find(targetName) != std::wstring::npos || targetName.find(name) != std::wstring::npos)) { - partialMatch = dev; - continue; - } - dev->Release(); } collection->Release(); - return partialMatch; + return nullptr; } bool WasapiCapture::initializeLoopback(const std::string& outputPath) { @@ -114,10 +76,7 @@ bool WasapiCapture::initializeLoopback(const std::string& outputPath) { return initializeCommon(); } -bool WasapiCapture::initializeMic( - const std::string& outputPath, - const std::string& deviceId, - const std::string& deviceName) { +bool WasapiCapture::initializeMic(const std::string& outputPath, const std::string& deviceName) { outputPath_ = outputPath; streamFlags_ = 0; @@ -126,10 +85,7 @@ bool WasapiCapture::initializeMic( IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); if (FAILED(hr)) return false; - if (!deviceId.empty()) { - device_ = findCaptureDeviceById(utf8ToWide(deviceId)); - } - if (!device_ && !deviceName.empty()) { + if (!deviceName.empty()) { device_ = findCaptureDeviceByName(utf8ToWide(deviceName)); } if (!device_) { diff --git a/electron/native/wgc-capture/src/wasapi_loopback.h b/electron/native/wgc-capture/src/wasapi_loopback.h index e9b9c21a..a4facf13 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.h +++ b/electron/native/wgc-capture/src/wasapi_loopback.h @@ -14,10 +14,7 @@ public: ~WasapiCapture(); bool initializeLoopback(const std::string& outputPath); - bool initializeMic( - const std::string& outputPath, - const std::string& deviceId = "", - const std::string& deviceName = ""); + bool initializeMic(const std::string& outputPath, const std::string& deviceName = ""); bool start(); bool pause(); bool resume(); @@ -27,7 +24,6 @@ private: bool initializeCommon(); void captureThread(); bool writeWavHeader(HANDLE file, DWORD dataSize); - IMMDevice* findCaptureDeviceById(const std::wstring& id); IMMDevice* findCaptureDeviceByName(const std::wstring& name); std::string outputPath_; diff --git a/electron/native/windows-capture/CMakeLists.txt b/electron/native/windows-capture/CMakeLists.txt new file mode 100644 index 00000000..773452c4 --- /dev/null +++ b/electron/native/windows-capture/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.20) +project(windows-capture LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_executable(windows-capture + src/main.cpp + src/dxgi_session.cpp + src/mf_encoder.cpp + src/monitor_utils.cpp + src/wasapi_loopback.cpp +) + +target_compile_options(windows-capture PRIVATE /EHsc /W3 /utf-8) + +target_link_libraries(windows-capture PRIVATE + d3d11 + dxgi + dwmapi + mfplat + mfreadwrite + mf + mfuuid + ole32 + shcore +) diff --git a/electron/native/windows-capture/src/dxgi_session.cpp b/electron/native/windows-capture/src/dxgi_session.cpp new file mode 100644 index 00000000..76c91402 --- /dev/null +++ b/electron/native/windows-capture/src/dxgi_session.cpp @@ -0,0 +1,343 @@ +#include "dxgi_session.h" + +#include + +#include +#include +#include + +namespace { + +bool intersectRectChecked(const RECT& lhs, const RECT& rhs, RECT& result) { + return IntersectRect(&result, &lhs, &rhs) != FALSE; +} + +int evenFloor(int value) { + return value > 1 ? (value & ~1) : value; +} + +RECT getExtendedWindowBounds(HWND hwnd) { + RECT bounds{}; + if (SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &bounds, sizeof(bounds)))) { + return bounds; + } + + GetWindowRect(hwnd, &bounds); + return bounds; +} + +} // namespace + +DxgiSession::DxgiSession() {} + +DxgiSession::~DxgiSession() { + stopCapture(); +} + +bool DxgiSession::findOutputForMonitor(HMONITOR monitor, ComPtr& adapter, ComPtr& output) { + ComPtr factory; + HRESULT hr = CreateDXGIFactory1(IID_PPV_ARGS(&factory)); + if (FAILED(hr)) { + std::cerr << "ERROR: CreateDXGIFactory1 failed: 0x" << std::hex << hr << std::endl; + return false; + } + + for (UINT adapterIndex = 0;; ++adapterIndex) { + ComPtr candidateAdapter; + hr = factory->EnumAdapters1(adapterIndex, &candidateAdapter); + if (hr == DXGI_ERROR_NOT_FOUND) { + break; + } + if (FAILED(hr)) { + continue; + } + + for (UINT outputIndex = 0;; ++outputIndex) { + ComPtr candidateOutput; + hr = candidateAdapter->EnumOutputs(outputIndex, &candidateOutput); + if (hr == DXGI_ERROR_NOT_FOUND) { + break; + } + if (FAILED(hr)) { + continue; + } + + DXGI_OUTPUT_DESC desc{}; + if (FAILED(candidateOutput->GetDesc(&desc))) { + continue; + } + + if (!desc.AttachedToDesktop || desc.Monitor != monitor) { + continue; + } + + ComPtr output1; + hr = candidateOutput.As(&output1); + if (FAILED(hr)) { + continue; + } + + adapter = candidateAdapter; + output = output1; + outputDesc_ = desc; + return true; + } + } + + return false; +} + +bool DxgiSession::createD3DDevice(IDXGIAdapter1* adapter) { + UINT creationFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; + D3D_FEATURE_LEVEL featureLevels[] = { + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + }; + + HRESULT hr = D3D11CreateDevice( + adapter, + D3D_DRIVER_TYPE_UNKNOWN, + nullptr, + creationFlags, + featureLevels, + ARRAYSIZE(featureLevels), + D3D11_SDK_VERSION, + &d3dDevice_, + nullptr, + &d3dContext_); + + if (FAILED(hr)) { + std::cerr << "ERROR: D3D11CreateDevice failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::createDuplication(IDXGIOutput1* output) { + HRESULT hr = output->DuplicateOutput(d3dDevice_.Get(), &duplication_); + if (FAILED(hr)) { + std::cerr << "ERROR: DuplicateOutput failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::createCaptureTexture() { + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = static_cast(captureWidth_); + desc.Height = static_cast(captureHeight_); + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + + HRESULT hr = d3dDevice_->CreateTexture2D(&desc, nullptr, &captureTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: CreateTexture2D failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::initializeFullMonitorRect() { + const int width = outputDesc_.DesktopCoordinates.right - outputDesc_.DesktopCoordinates.left; + const int height = outputDesc_.DesktopCoordinates.bottom - outputDesc_.DesktopCoordinates.top; + + captureWidth_ = evenFloor(width); + captureHeight_ = evenFloor(height); + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + captureRect_.left = 0; + captureRect_.top = 0; + captureRect_.right = captureWidth_; + captureRect_.bottom = captureHeight_; + return true; +} + +bool DxgiSession::updateWindowCaptureRect() { + if (!captureWindow_ || !windowHandle_) { + return false; + } + + RECT windowRect = getExtendedWindowBounds(windowHandle_); + RECT monitorRect = outputDesc_.DesktopCoordinates; + RECT clippedRect{}; + if (!intersectRectChecked(windowRect, monitorRect, clippedRect)) { + return false; + } + + int left = clippedRect.left - monitorRect.left; + int top = clippedRect.top - monitorRect.top; + const int monitorWidth = monitorRect.right - monitorRect.left; + const int monitorHeight = monitorRect.bottom - monitorRect.top; + + left = std::clamp(left, 0, std::max(0, monitorWidth - captureWidth_)); + top = std::clamp(top, 0, std::max(0, monitorHeight - captureHeight_)); + + captureRect_.left = left; + captureRect_.top = top; + captureRect_.right = left + captureWidth_; + captureRect_.bottom = top + captureHeight_; + return true; +} + +bool DxgiSession::initializeWindowRect(HWND hwnd) { + windowHandle_ = hwnd; + captureWindow_ = true; + + RECT windowRect = getExtendedWindowBounds(hwnd); + RECT monitorRect = outputDesc_.DesktopCoordinates; + RECT clippedRect{}; + if (!intersectRectChecked(windowRect, monitorRect, clippedRect)) { + return false; + } + + captureWidth_ = evenFloor(clippedRect.right - clippedRect.left); + captureHeight_ = evenFloor(clippedRect.bottom - clippedRect.top); + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + return updateWindowCaptureRect(); +} + +bool DxgiSession::initializeForMonitor(HMONITOR monitor, int fps) { + fps_ = fps; + frameIntervalHns_ = 10000000LL / fps_; + + ComPtr adapter; + ComPtr output; + if (!findOutputForMonitor(monitor, adapter, output)) { + std::cerr << "ERROR: Failed to find DXGI output for monitor" << std::endl; + return false; + } + + if (!createD3DDevice(adapter.Get())) { + return false; + } + + if (!createDuplication(output.Get())) { + return false; + } + + return true; +} + +bool DxgiSession::initialize(HMONITOR monitor, int fps) { + captureWindow_ = false; + windowHandle_ = nullptr; + + if (!initializeForMonitor(monitor, fps)) { + return false; + } + + return initializeFullMonitorRect() && createCaptureTexture(); +} + +bool DxgiSession::initialize(HWND hwnd, int fps) { + HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if (!monitor) { + std::cerr << "ERROR: MonitorFromWindow failed" << std::endl; + return false; + } + + if (!initializeForMonitor(monitor, fps)) { + return false; + } + + return initializeWindowRect(hwnd) && createCaptureTexture(); +} + +void DxgiSession::setFrameCallback(FrameCallback callback) { + frameCallback_ = std::move(callback); +} + +bool DxgiSession::startCapture() { + if (!duplication_ || !captureTexture_) { + return false; + } + + capturing_ = true; + lastFrameTimeHns_ = 0; + captureThread_ = std::thread(&DxgiSession::captureLoop, this); + return true; +} + +void DxgiSession::stopCapture() { + capturing_ = false; + + if (captureThread_.joinable()) { + captureThread_.join(); + } + + duplication_.Reset(); + captureTexture_.Reset(); +} + +int64_t DxgiSession::nowHns() const { + return std::chrono::duration_cast>>( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +void DxgiSession::captureLoop() { + while (capturing_) { + DXGI_OUTDUPL_FRAME_INFO frameInfo{}; + ComPtr desktopResource; + HRESULT hr = duplication_->AcquireNextFrame(100, &frameInfo, &desktopResource); + + if (hr == DXGI_ERROR_WAIT_TIMEOUT) { + continue; + } + + if (FAILED(hr)) { + if (hr != DXGI_ERROR_ACCESS_LOST) { + std::cerr << "ERROR: AcquireNextFrame failed: 0x" << std::hex << hr << std::endl; + } + break; + } + + ComPtr sourceTexture; + hr = desktopResource.As(&sourceTexture); + if (SUCCEEDED(hr) && sourceTexture) { + if (!captureWindow_ || updateWindowCaptureRect()) { + const int64_t timestampHns = nowHns(); + if (lastFrameTimeHns_ == 0 || (timestampHns - lastFrameTimeHns_) >= (frameIntervalHns_ * 7 / 10)) { + D3D11_BOX sourceBox{}; + sourceBox.left = static_cast(captureRect_.left); + sourceBox.top = static_cast(captureRect_.top); + sourceBox.front = 0; + sourceBox.right = static_cast(captureRect_.right); + sourceBox.bottom = static_cast(captureRect_.bottom); + sourceBox.back = 1; + + d3dContext_->CopySubresourceRegion( + captureTexture_.Get(), + 0, + 0, + 0, + 0, + sourceTexture.Get(), + 0, + &sourceBox); + + if (frameCallback_) { + lastFrameTimeHns_ = timestampHns; + frameCallback_(captureTexture_.Get(), timestampHns); + } + } + } + } + + duplication_->ReleaseFrame(); + } +} \ No newline at end of file diff --git a/electron/native/windows-capture/src/dxgi_session.h b/electron/native/windows-capture/src/dxgi_session.h new file mode 100644 index 00000000..37cd04b7 --- /dev/null +++ b/electron/native/windows-capture/src/dxgi_session.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +class DxgiSession { +public: + using FrameCallback = std::function; + + DxgiSession(); + ~DxgiSession(); + + bool initialize(HMONITOR monitor, int fps); + bool initialize(HWND hwnd, int fps); + void setFrameCallback(FrameCallback callback); + bool startCapture(); + void stopCapture(); + + int captureWidth() const { return captureWidth_; } + int captureHeight() const { return captureHeight_; } + ID3D11Device* device() const { return d3dDevice_.Get(); } + ID3D11DeviceContext* context() const { return d3dContext_.Get(); } + +private: + ComPtr d3dDevice_; + ComPtr d3dContext_; + ComPtr duplication_; + ComPtr captureTexture_; + + FrameCallback frameCallback_; + std::atomic capturing_{false}; + std::thread captureThread_; + + DXGI_OUTPUT_DESC outputDesc_{}; + RECT captureRect_{}; + HWND windowHandle_ = nullptr; + bool captureWindow_ = false; + int fps_ = 60; + int captureWidth_ = 0; + int captureHeight_ = 0; + int64_t frameIntervalHns_ = 0; + int64_t lastFrameTimeHns_ = 0; + + bool initializeForMonitor(HMONITOR monitor, int fps); + bool findOutputForMonitor(HMONITOR monitor, ComPtr& adapter, ComPtr& output); + bool createD3DDevice(IDXGIAdapter1* adapter); + bool createDuplication(IDXGIOutput1* output); + bool createCaptureTexture(); + bool initializeFullMonitorRect(); + bool initializeWindowRect(HWND hwnd); + bool updateWindowCaptureRect(); + void captureLoop(); + int64_t nowHns() const; +}; \ No newline at end of file diff --git a/electron/native/windows-capture/src/main.cpp b/electron/native/windows-capture/src/main.cpp new file mode 100644 index 00000000..2aac86a7 --- /dev/null +++ b/electron/native/windows-capture/src/main.cpp @@ -0,0 +1,324 @@ +#include "dxgi_session.h" +#include "mf_encoder.h" +#include "monitor_utils.h" +#include "wasapi_loopback.h" + +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_stopRequested{false}; +static std::atomic g_pauseRequested{false}; +static std::atomic g_resumePending{false}; +static std::atomic g_lastFrameTimestampHns{0}; +static std::atomic g_pauseStartTimestampHns{0}; +static std::atomic g_accumulatedPausedHns{0}; +static std::mutex g_stopMutex; +static std::condition_variable g_stopCv; + +struct CaptureConfig { + int64_t displayId = 0; + int64_t windowHandle = 0; + std::string outputPath; + std::string audioOutputPath; + std::string micOutputPath; + std::string micDeviceName; + int fps = 60; + int width = 0; + int height = 0; + bool captureSystemAudio = false; + bool captureMic = false; +}; + +static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { + auto findInt = [&](const std::string& key) -> int { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return -1; + pos = json.find(':', pos); + if (pos == std::string::npos) return -1; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + try { + return std::stoi(json.substr(pos)); + } catch (...) { + return -1; + } + }; + + auto findInt64 = [&](const std::string& key) -> int64_t { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return -1; + pos = json.find(':', pos); + if (pos == std::string::npos) return -1; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + try { + return std::stoll(json.substr(pos)); + } catch (...) { + return -1; + } + }; + + auto findString = [&](const std::string& key) -> std::string { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return ""; + pos = json.find(':', pos); + if (pos == std::string::npos) return ""; + pos++; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++; + if (pos >= json.size() || json[pos] != '"') return ""; + pos++; + std::string result; + while (pos < json.size() && json[pos] != '"') { + if (json[pos] == '\\' && pos + 1 < json.size()) { + pos++; + if (json[pos] == 'n') result += '\n'; + else if (json[pos] == 't') result += '\t'; + else if (json[pos] == '\\') result += '\\'; + else if (json[pos] == '"') result += '"'; + else if (json[pos] == '/') result += '/'; + else result += json[pos]; + } else { + result += json[pos]; + } + pos++; + } + return result; + }; + + config.outputPath = findString("outputPath"); + if (config.outputPath.empty()) return false; + + int64_t displayId = findInt64("displayId"); + if (displayId >= 0) config.displayId = displayId; + + int64_t windowHandle = findInt64("windowHandle"); + if (windowHandle > 0) config.windowHandle = windowHandle; + + int fps = findInt("fps"); + if (fps > 0) config.fps = fps; + + int width = findInt("width"); + if (width > 0) config.width = width; + + int height = findInt("height"); + if (height > 0) config.height = height; + + config.audioOutputPath = findString("audioOutputPath"); + config.micOutputPath = findString("micOutputPath"); + config.micDeviceName = findString("micDeviceName"); + + auto findBool = [&](const std::string& key) -> bool { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return false; + auto colonPos = json.find(':', pos); + if (colonPos == std::string::npos) return false; + auto valStart = json.find_first_not_of(" \t", colonPos + 1); + return valStart != std::string::npos && json.substr(valStart, 4) == "true"; + }; + + config.captureSystemAudio = findBool("captureSystemAudio"); + config.captureMic = findBool("captureMic"); + + return true; +} + +static std::wstring utf8ToWide(const std::string& str) { + if (str.empty()) return L""; + int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + std::wstring wstr(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), &wstr[0], len); + return wstr; +} + +static void stdinListenerThread() { + std::string line; + while (std::getline(std::cin, line)) { + // Trim whitespace + while (!line.empty() && (line.back() == '\r' || line.back() == '\n' || line.back() == ' ')) { + line.pop_back(); + } + + if (line == "pause") { + g_pauseRequested = true; + g_pauseStartTimestampHns = g_lastFrameTimestampHns.load(); + continue; + } + + if (line == "resume") { + g_pauseRequested = false; + g_resumePending = true; + continue; + } + + if (line == "stop") { + g_stopRequested = true; + g_stopCv.notify_all(); + return; + } + } + + // stdin closed (parent process died) + g_stopRequested = true; + g_stopCv.notify_all(); +} + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "ERROR: Missing JSON config argument" << std::endl; + return 1; + } + + CaptureConfig config; + if (!parseSimpleJson(argv[1], config)) { + std::cerr << "ERROR: Failed to parse config JSON" << std::endl; + return 1; + } + + DxgiSession session; + + if (config.windowHandle > 0) { + HWND hwnd = reinterpret_cast(static_cast(config.windowHandle)); + if (!IsWindow(hwnd)) { + std::cerr << "ERROR: Invalid window handle " << config.windowHandle << std::endl; + return 1; + } + if (!session.initialize(hwnd, config.fps)) { + std::cerr << "ERROR: Failed to initialize DXGI window capture session" << std::endl; + return 1; + } + } else { + HMONITOR monitor = findMonitorByDisplayId(config.displayId); + if (!monitor) { + std::cerr << "ERROR: Could not find monitor for displayId " << config.displayId << std::endl; + return 1; + } + if (!session.initialize(monitor, config.fps)) { + std::cerr << "ERROR: Failed to initialize DXGI capture session" << std::endl; + return 1; + } + } + + int captureWidth = config.width > 0 ? config.width : session.captureWidth(); + int captureHeight = config.height > 0 ? config.height : session.captureHeight(); + + // Ensure even dimensions for H.264 + captureWidth = (captureWidth / 2) * 2; + captureHeight = (captureHeight / 2) * 2; + + // Initialize encoder + MFEncoder encoder; + std::wstring outputPathW = utf8ToWide(config.outputPath); + if (!encoder.initialize(outputPathW, captureWidth, captureHeight, config.fps, + session.device(), session.context())) { + std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl; + return 1; + } + + // Set up frame callback + std::atomic frameCount{0}; + session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { + g_lastFrameTimestampHns = timestampHns; + if (g_stopRequested) return; + + if (g_pauseRequested) return; + + int64_t adjustedTimestampHns = timestampHns; + if (g_resumePending.exchange(false)) { + const int64_t pauseStart = g_pauseStartTimestampHns.load(); + if (pauseStart > 0 && timestampHns > pauseStart) { + g_accumulatedPausedHns += (timestampHns - pauseStart); + } + } + + adjustedTimestampHns -= g_accumulatedPausedHns.load(); + if (adjustedTimestampHns < 0) { + adjustedTimestampHns = 0; + } + + if (encoder.writeFrame(texture, adjustedTimestampHns)) { + frameCount++; + } + }); + + // Start stdin listener + std::thread stdinThread(stdinListenerThread); + stdinThread.detach(); + + // Initialize WASAPI captures (but don't start yet) + WasapiCapture loopback; + WasapiCapture micCapture; + bool audioActive = false; + bool audioInitialized = false; + bool micActive = false; + bool micInitialized = false; + + if (config.captureSystemAudio && !config.audioOutputPath.empty()) { + audioInitialized = loopback.initializeLoopback(config.audioOutputPath); + if (!audioInitialized) { + std::cerr << "WARNING: Failed to initialize WASAPI loopback" << std::endl; + } + } + + if (config.captureMic && !config.micOutputPath.empty()) { + micInitialized = micCapture.initializeMic(config.micOutputPath, config.micDeviceName); + if (!micInitialized) { + std::cerr << "WARNING: Failed to initialize WASAPI mic capture" << std::endl; + } + } + + // Start video capture, then audio immediately after for sync + if (!session.startCapture()) { + std::cerr << "ERROR: Failed to start DXGI capture" << std::endl; + return 1; + } + + if (audioInitialized) { + audioActive = loopback.start(); + } + if (micInitialized) { + micActive = micCapture.start(); + } + + std::cout << "Recording started" << std::endl; + std::cout.flush(); + + // Wait for stop signal + while (!g_stopRequested) { + if (g_pauseRequested) { + if (audioActive) loopback.pause(); + if (micActive) micCapture.pause(); + } else { + if (audioActive) loopback.resume(); + if (micActive) micCapture.resume(); + } + + std::unique_lock lock(g_stopMutex); + g_stopCv.wait_for(lock, std::chrono::milliseconds(20), [] { return g_stopRequested.load(); }); + } + + // Stop capture and finalize + session.stopCapture(); + if (audioActive) loopback.stop(); + if (micActive) micCapture.stop(); + encoder.finalize(); + + std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; + if (audioActive) { + std::cout << "Audio path: " << config.audioOutputPath << std::endl; + } + if (micActive) { + std::cout << "Mic path: " << config.micOutputPath << std::endl; + } + std::cout.flush(); + + // Allow pipe buffers to drain before forceful exit + Sleep(100); + + // Fast exit to avoid WinRT/COM teardown crashes during apartment cleanup + ExitProcess(0); +} diff --git a/electron/native/windows-capture/src/mf_encoder.cpp b/electron/native/windows-capture/src/mf_encoder.cpp new file mode 100644 index 00000000..a1474c20 --- /dev/null +++ b/electron/native/windows-capture/src/mf_encoder.cpp @@ -0,0 +1,205 @@ +#include "mf_encoder.h" +#include +#include +#include +#include +#include + +#pragma comment(lib, "mfplat.lib") +#pragma comment(lib, "mfreadwrite.lib") +#pragma comment(lib, "mf.lib") +#pragma comment(lib, "mfuuid.lib") + +static int clampByte(int v) { + return v < 0 ? 0 : (v > 255 ? 255 : v); +} + +MFEncoder::MFEncoder() {} + +MFEncoder::~MFEncoder() { + finalize(); +} + +bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height, int fps, + ID3D11Device* device, ID3D11DeviceContext* context) { + if (initialized_) return false; + + if (width % 2 != 0 || height % 2 != 0) { + std::cerr << "ERROR: Encoder dimensions must be even, got " << width << "x" << height << std::endl; + return false; + } + + width_ = width; + height_ = height; + fps_ = fps; + device_ = device; + context_ = context; + + HRESULT hr = MFStartup(MF_VERSION); + if (FAILED(hr)) { + std::cerr << "ERROR: MFStartup failed: 0x" << std::hex << hr << std::endl; + return false; + } + + // Output media type (H.264) + ComPtr outputType; + hr = MFCreateMediaType(&outputType); + if (FAILED(hr)) return false; + + outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264); + outputType->SetUINT32(MF_MT_AVG_BITRATE, 20000000); + MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, width_, height_); + MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1); + MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + + // Input media type (NV12) + ComPtr inputType; + hr = MFCreateMediaType(&inputType); + if (FAILED(hr)) return false; + + inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12); + MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, width_, height_); + MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, fps_, 1); + MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + + // Create SinkWriter with MPEG4 container + ComPtr writerAttrs; + hr = MFCreateAttributes(&writerAttrs, 1); + if (FAILED(hr)) return false; + + writerAttrs->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + + hr = MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, writerAttrs.Get(), &sinkWriter_); + if (FAILED(hr)) { + std::cerr << "ERROR: MFCreateSinkWriterFromURL failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->AddStream(outputType.Get(), &streamIndex_); + if (FAILED(hr)) { + std::cerr << "ERROR: AddStream failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->SetInputMediaType(streamIndex_, inputType.Get(), nullptr); + if (FAILED(hr)) { + std::cerr << "ERROR: SetInputMediaType failed: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = sinkWriter_->BeginWriting(); + if (FAILED(hr)) { + std::cerr << "ERROR: BeginWriting failed: 0x" << std::hex << hr << std::endl; + return false; + } + + // Pre-allocate staging texture + D3D11_TEXTURE2D_DESC stagingDesc = {}; + stagingDesc.Width = width_; + stagingDesc.Height = height_; + stagingDesc.MipLevels = 1; + stagingDesc.ArraySize = 1; + stagingDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + stagingDesc.SampleDesc.Count = 1; + stagingDesc.Usage = D3D11_USAGE_STAGING; + stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + + hr = device_->CreateTexture2D(&stagingDesc, nullptr, &stagingTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create staging texture: 0x" << std::hex << hr << std::endl; + return false; + } + + // Pre-allocate NV12 buffer + const int ySize = width_ * height_; + const int uvSize = (width_ / 2) * (height_ / 2) * 2; + nv12Buffer_.resize(ySize + uvSize); + + initialized_ = true; + return true; +} + +bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { + if (!initialized_ || !sinkWriter_) return false; + + context_->CopyResource(stagingTexture_.Get(), texture); + + D3D11_MAPPED_SUBRESOURCE mapped; + HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); + if (FAILED(hr)) return false; + + // Convert BGRA → NV12 + const uint8_t* bgra = static_cast(mapped.pData); + const int bgraPitch = static_cast(mapped.RowPitch); + + // Y plane + for (int y = 0; y < height_; y++) { + for (int x = 0; x < width_; x++) { + const uint8_t* pixel = bgra + y * bgraPitch + x * 4; + uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; + int yVal = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16; + nv12Buffer_[y * width_ + x] = static_cast(clampByte(yVal)); + } + } + + // UV plane (interleaved, subsampled 2x2) + const int ySize = width_ * height_; + uint8_t* uvPlane = nv12Buffer_.data() + ySize; + for (int y = 0; y < height_; y += 2) { + for (int x = 0; x < width_; x += 2) { + const uint8_t* pixel = bgra + y * bgraPitch + x * 4; + uint8_t b = pixel[0], g = pixel[1], r = pixel[2]; + int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; + int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; + int uvIdx = (y / 2) * width_ + (x / 2) * 2; + uvPlane[uvIdx] = static_cast(clampByte(u)); + uvPlane[uvIdx + 1] = static_cast(clampByte(v)); + } + } + + context_->Unmap(stagingTexture_.Get(), 0); + + // Create MF sample + DWORD bufferSize = static_cast(nv12Buffer_.size()); + ComPtr buffer; + hr = MFCreateMemoryBuffer(bufferSize, &buffer); + if (FAILED(hr)) return false; + + BYTE* bufferData = nullptr; + hr = buffer->Lock(&bufferData, nullptr, nullptr); + if (FAILED(hr)) return false; + + std::memcpy(bufferData, nv12Buffer_.data(), bufferSize); + buffer->Unlock(); + buffer->SetCurrentLength(bufferSize); + + ComPtr sample; + hr = MFCreateSample(&sample); + if (FAILED(hr)) return false; + + sample->AddBuffer(buffer.Get()); + sample->SetSampleTime(timestampHns); + sample->SetSampleDuration(10000000LL / fps_); + + hr = sinkWriter_->WriteSample(streamIndex_, sample.Get()); + return SUCCEEDED(hr); +} + +bool MFEncoder::finalize() { + if (!initialized_) return false; + initialized_ = false; + + stagingTexture_.Reset(); + nv12Buffer_.clear(); + nv12Buffer_.shrink_to_fit(); + + if (!sinkWriter_) return false; + HRESULT hr = sinkWriter_->Finalize(); + sinkWriter_.Reset(); + MFShutdown(); + return SUCCEEDED(hr); +} diff --git a/electron/native/windows-capture/src/mf_encoder.h b/electron/native/windows-capture/src/mf_encoder.h new file mode 100644 index 00000000..7eb8e642 --- /dev/null +++ b/electron/native/windows-capture/src/mf_encoder.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +class MFEncoder { +public: + MFEncoder(); + ~MFEncoder(); + + bool initialize(const std::wstring& outputPath, int width, int height, int fps, + ID3D11Device* device, ID3D11DeviceContext* context); + bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns); + bool finalize(); + +private: + ComPtr sinkWriter_; + ID3D11Device* device_ = nullptr; + ID3D11DeviceContext* context_ = nullptr; + ComPtr stagingTexture_; + std::vector nv12Buffer_; + DWORD streamIndex_ = 0; + int width_ = 0; + int height_ = 0; + int fps_ = 60; + bool initialized_ = false; +}; diff --git a/electron/native/windows-capture/src/monitor_utils.cpp b/electron/native/windows-capture/src/monitor_utils.cpp new file mode 100644 index 00000000..56962aeb --- /dev/null +++ b/electron/native/windows-capture/src/monitor_utils.cpp @@ -0,0 +1,61 @@ +#include "monitor_utils.h" +#include + +static BOOL CALLBACK enumMonitorCallback(HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) { + auto* monitors = reinterpret_cast*>(lParam); + + MONITORINFOEXW mi = {}; + mi.cbSize = sizeof(mi); + if (GetMonitorInfoW(hMonitor, &mi)) { + MonitorInfo info; + info.handle = hMonitor; + info.x = mi.rcMonitor.left; + info.y = mi.rcMonitor.top; + info.width = mi.rcMonitor.right - mi.rcMonitor.left; + info.height = mi.rcMonitor.bottom - mi.rcMonitor.top; + info.deviceName = mi.szDevice; + monitors->push_back(info); + } + + return TRUE; +} + +std::vector enumerateMonitors() { + std::vector monitors; + EnumDisplayMonitors(nullptr, nullptr, enumMonitorCallback, reinterpret_cast(&monitors)); + return monitors; +} + +// Electron uses the HMONITOR handle value cast to a number as the display ID. +HMONITOR findMonitorByDisplayId(int64_t displayId) { + auto monitors = enumerateMonitors(); + + for (const auto& m : monitors) { + if (static_cast(reinterpret_cast(m.handle)) == displayId) { + return m.handle; + } + } + + if (!monitors.empty()) { + return monitors[0].handle; + } + + return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY); +} + +MonitorInfo getMonitorInfo(HMONITOR monitor) { + MonitorInfo info; + info.handle = monitor; + + MONITORINFOEXW mi = {}; + mi.cbSize = sizeof(mi); + if (GetMonitorInfoW(monitor, &mi)) { + info.x = mi.rcMonitor.left; + info.y = mi.rcMonitor.top; + info.width = mi.rcMonitor.right - mi.rcMonitor.left; + info.height = mi.rcMonitor.bottom - mi.rcMonitor.top; + info.deviceName = mi.szDevice; + } + + return info; +} diff --git a/electron/native/windows-capture/src/monitor_utils.h b/electron/native/windows-capture/src/monitor_utils.h new file mode 100644 index 00000000..8889410d --- /dev/null +++ b/electron/native/windows-capture/src/monitor_utils.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +struct MonitorInfo { + HMONITOR handle; + int x; + int y; + int width; + int height; + std::wstring deviceName; +}; + +std::vector enumerateMonitors(); +HMONITOR findMonitorByDisplayId(int64_t displayId); +MonitorInfo getMonitorInfo(HMONITOR monitor); diff --git a/electron/native/windows-capture/src/wasapi_loopback.cpp b/electron/native/windows-capture/src/wasapi_loopback.cpp new file mode 100644 index 00000000..8ae15ad1 --- /dev/null +++ b/electron/native/windows-capture/src/wasapi_loopback.cpp @@ -0,0 +1,274 @@ +#include "wasapi_loopback.h" +#include +#include +#include + +#pragma comment(lib, "ole32.lib") + +static const CLSID CLSID_MMDeviceEnumerator_ = __uuidof(MMDeviceEnumerator); +static const IID IID_IMMDeviceEnumerator_ = __uuidof(IMMDeviceEnumerator); +static const IID IID_IAudioClient_ = __uuidof(IAudioClient); +static const IID IID_IAudioCaptureClient_ = __uuidof(IAudioCaptureClient); + +WasapiCapture::WasapiCapture() {} + +WasapiCapture::~WasapiCapture() { + stop(); + if (mixFormat_) CoTaskMemFree(mixFormat_); + if (captureClient_) captureClient_->Release(); + if (audioClient_) audioClient_->Release(); + if (device_) device_->Release(); + if (enumerator_) enumerator_->Release(); +} + +static std::wstring utf8ToWide(const std::string& str) { + if (str.empty()) return L""; + int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + std::wstring wstr(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), &wstr[0], len); + return wstr; +} + +IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName) { + IMMDeviceCollection* collection = nullptr; + HRESULT hr = enumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &collection); + if (FAILED(hr)) return nullptr; + + UINT count = 0; + collection->GetCount(&count); + + for (UINT i = 0; i < count; i++) { + IMMDevice* dev = nullptr; + collection->Item(i, &dev); + + 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; + } + dev->Release(); + } + + collection->Release(); + return nullptr; +} + +bool WasapiCapture::initializeLoopback(const std::string& outputPath) { + outputPath_ = outputPath; + streamFlags_ = AUDCLNT_STREAMFLAGS_LOOPBACK; + + HRESULT hr = CoCreateInstance( + CLSID_MMDeviceEnumerator_, nullptr, CLSCTX_ALL, + IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); + if (FAILED(hr)) return false; + + hr = enumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_); + if (FAILED(hr)) return false; + + return initializeCommon(); +} + +bool WasapiCapture::initializeMic(const std::string& outputPath, const std::string& deviceName) { + outputPath_ = outputPath; + streamFlags_ = 0; + + HRESULT hr = CoCreateInstance( + CLSID_MMDeviceEnumerator_, nullptr, CLSCTX_ALL, + IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); + if (FAILED(hr)) return false; + + if (!deviceName.empty()) { + 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; + } + } + + return initializeCommon(); +} + +bool WasapiCapture::initializeCommon() { + HRESULT hr = device_->Activate(IID_IAudioClient_, CLSCTX_ALL, nullptr, + reinterpret_cast(&audioClient_)); + if (FAILED(hr)) return false; + + hr = audioClient_->GetMixFormat(&mixFormat_); + if (FAILED(hr)) return false; + + REFERENCE_TIME bufferDuration = 200000; // 20ms + hr = audioClient_->Initialize( + AUDCLNT_SHAREMODE_SHARED, + streamFlags_, + bufferDuration, 0, mixFormat_, nullptr); + if (FAILED(hr)) return false; + + hr = audioClient_->GetBufferSize(&bufferFrameCount_); + if (FAILED(hr)) return false; + + hr = audioClient_->GetService(IID_IAudioCaptureClient_, + reinterpret_cast(&captureClient_)); + if (FAILED(hr)) return false; + + return true; +} + +bool WasapiCapture::start() { + if (capturing_) return true; + + outputFile_ = CreateFileA( + outputPath_.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (outputFile_ == INVALID_HANDLE_VALUE) { + std::cerr << "ERROR: Cannot create audio output file" << std::endl; + return false; + } + + totalDataBytes_ = 0; + writeWavHeader(outputFile_, 0); + + HRESULT hr = audioClient_->Start(); + if (FAILED(hr)) { + CloseHandle(outputFile_); + outputFile_ = INVALID_HANDLE_VALUE; + return false; + } + + capturing_ = true; + paused_ = false; + thread_ = std::thread(&WasapiCapture::captureThread, this); + return true; +} + +bool WasapiCapture::pause() { + if (!capturing_ || paused_) return true; + paused_ = true; + return audioClient_ ? SUCCEEDED(audioClient_->Stop()) : false; +} + +bool WasapiCapture::resume() { + if (!capturing_ || !paused_) return true; + HRESULT hr = audioClient_ ? audioClient_->Start() : E_FAIL; + if (FAILED(hr)) return false; + paused_ = false; + return true; +} + +void WasapiCapture::stop() { + if (!capturing_) return; + capturing_ = false; + if (thread_.joinable()) thread_.join(); + if (audioClient_) audioClient_->Stop(); + + if (outputFile_ != INVALID_HANDLE_VALUE) { + SetFilePointer(outputFile_, 0, nullptr, FILE_BEGIN); + writeWavHeader(outputFile_, totalDataBytes_); + CloseHandle(outputFile_); + outputFile_ = INVALID_HANDLE_VALUE; + } + + paused_ = false; +} + +static int16_t floatToInt16(float v) { + v = v < -1.0f ? -1.0f : (v > 1.0f ? 1.0f : v); + return static_cast(v * 32767.0f); +} + +bool WasapiCapture::writeWavHeader(HANDLE file, DWORD dataSize) { + WORD channels = static_cast(mixFormat_->nChannels); + DWORD sampleRate = mixFormat_->nSamplesPerSec; + WORD bitsPerSample = 16; + WORD blockAlign = channels * (bitsPerSample / 8); + DWORD byteRate = sampleRate * blockAlign; + + DWORD written; + WriteFile(file, "RIFF", 4, &written, nullptr); + DWORD chunkSize = 36 + dataSize; + WriteFile(file, &chunkSize, 4, &written, nullptr); + WriteFile(file, "WAVE", 4, &written, nullptr); + WriteFile(file, "fmt ", 4, &written, nullptr); + DWORD fmtSize = 16; + WriteFile(file, &fmtSize, 4, &written, nullptr); + WORD audioFormat = 1; + WriteFile(file, &audioFormat, 2, &written, nullptr); + WriteFile(file, &channels, 2, &written, nullptr); + WriteFile(file, &sampleRate, 4, &written, nullptr); + WriteFile(file, &byteRate, 4, &written, nullptr); + WriteFile(file, &blockAlign, 2, &written, nullptr); + WriteFile(file, &bitsPerSample, 2, &written, nullptr); + WriteFile(file, "data", 4, &written, nullptr); + WriteFile(file, &dataSize, 4, &written, nullptr); + return true; +} + +void WasapiCapture::captureThread() { + WORD channels = static_cast(mixFormat_->nChannels); + bool isFloat = (mixFormat_->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) || + (mixFormat_->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(mixFormat_)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT); + + std::vector pcmBuffer; + + DWORD sleepMs = static_cast((static_cast(bufferFrameCount_) / mixFormat_->nSamplesPerSec) * 500.0); + if (sleepMs < 5) sleepMs = 5; + + while (capturing_) { + if (paused_) { + Sleep(10); + continue; + } + + Sleep(sleepMs); + + UINT32 packetLength = 0; + HRESULT hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) break; + + while (packetLength > 0) { + BYTE* data = nullptr; + UINT32 numFrames = 0; + DWORD flags = 0; + + hr = captureClient_->GetBuffer(&data, &numFrames, &flags, nullptr, nullptr); + if (FAILED(hr)) break; + + UINT32 totalSamples = numFrames * channels; + + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) { + pcmBuffer.assign(totalSamples, 0); + } else if (isFloat) { + pcmBuffer.resize(totalSamples); + const float* src = reinterpret_cast(data); + for (UINT32 i = 0; i < totalSamples; i++) { + pcmBuffer[i] = floatToInt16(src[i]); + } + } else { + pcmBuffer.resize(totalSamples); + std::memcpy(pcmBuffer.data(), data, totalSamples * sizeof(int16_t)); + } + + captureClient_->ReleaseBuffer(numFrames); + + DWORD bytesToWrite = totalSamples * sizeof(int16_t); + DWORD written; + WriteFile(outputFile_, pcmBuffer.data(), bytesToWrite, &written, nullptr); + totalDataBytes_ += written; + + hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) break; + } + } +} diff --git a/electron/native/windows-capture/src/wasapi_loopback.h b/electron/native/windows-capture/src/wasapi_loopback.h new file mode 100644 index 00000000..a4facf13 --- /dev/null +++ b/electron/native/windows-capture/src/wasapi_loopback.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class WasapiCapture { +public: + WasapiCapture(); + ~WasapiCapture(); + + bool initializeLoopback(const std::string& outputPath); + bool initializeMic(const std::string& outputPath, const std::string& deviceName = ""); + bool start(); + bool pause(); + bool resume(); + void stop(); + +private: + bool initializeCommon(); + void captureThread(); + bool writeWavHeader(HANDLE file, DWORD dataSize); + IMMDevice* findCaptureDeviceByName(const std::wstring& name); + + std::string outputPath_; + std::thread thread_; + std::atomic capturing_{false}; + std::atomic paused_{false}; + HANDLE outputFile_ = INVALID_HANDLE_VALUE; + DWORD totalDataBytes_ = 0; + + IMMDeviceEnumerator* enumerator_ = nullptr; + IMMDevice* device_ = nullptr; + IAudioClient* audioClient_ = nullptr; + IAudioCaptureClient* captureClient_ = nullptr; + WAVEFORMATEX* mixFormat_ = nullptr; + DWORD streamFlags_ = 0; + + UINT32 bufferFrameCount_ = 0; +}; diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index c8770a92..bfa9de88 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -217,14 +217,6 @@ color: #6360f5; } -.ddSeparator { - width: 100%; - height: 1px; - margin: 4px 0; - background: rgba(255, 255, 255, 0.07); - flex-shrink: 0; -} - .recBtn { position: relative; width: 46px; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index b62ca488..7b54a332 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -111,14 +111,10 @@ function DropdownItem({ ); } -function BarSeparator() { +function Separator() { return
; } -function DropdownSeparator() { - return
; -} - function MicDeviceRow({ device, selected, @@ -639,7 +635,7 @@ export function LaunchWindow() { {formatTime(elapsed)} - + : } - + - + - + - + - + {microphoneEnabled && ( }