diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index 3e34d6ed..dcc4ca13 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -1,35 +1,35 @@ { - "version": 1, - "platform": "win32", - "arch": "x64", - "helpers": { - "wgc-capture": { - "binaryName": "wgc-capture.exe", - "binarySha256": "31beb9528e79262da6aa31fef09b6125153a838f957060ccc1ec5d4da844516c", - "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "4bb09b770a21eb32daf6325d46d73d0522f20a4cbce9dda8b3df2277f8cfd0be", - "updatedAt": "2026-05-05T18:41:50.078Z" - }, - "cursor-monitor": { - "binaryName": "cursor-monitor.exe", - "binarySha256": "6ae6d91103b6e891a851e8ea5791e1c1f9aaab700134c18bc4c46cfffd7fdd12", - "sourceDir": "electron/native/cursor-monitor", - "sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e", - "updatedAt": "2026-05-05T18:42:26.007Z" - }, - "recordly-gpu-export": { - "binaryName": "recordly-gpu-export.exe", - "binarySha256": "e79cb823dd2cfade857db67696ae6456ae0ba3ae9381b6792c948ac648521bd4", - "sourceDir": "electron/native/gpu-export-probe", - "sourceFingerprint": "45aaa4c0cbeb45ecaa9ff0a87b870c33bd0d5b09fb5b3acef45b246970a12e7f", - "updatedAt": "2026-05-05T18:41:54.966Z" - }, - "recordly-nvidia-cuda-compositor": { - "binaryName": "recordly-nvidia-cuda-compositor.exe", - "binarySha256": "2913d78e2c1114d32c92fb4238749f7ff46131ec92cf3ad0b55af910c7cdbb07", - "sourceDir": "electron/native/nvidia-cuda-compositor", - "sourceFingerprint": "a60538981da7dd3d9645f59926d42f4f2f0f1f0663d3905e0416fdff8becb314", - "updatedAt": "2026-05-05T18:42:20.123Z" - } - } + "version": 1, + "platform": "win32", + "arch": "x64", + "helpers": { + "wgc-capture": { + "binaryName": "wgc-capture.exe", + "binarySha256": "b016da7435ba68b9c235e85818b5f4e8c1647f2386b4f2205e9a690cd057f908", + "sourceDir": "electron/native/wgc-capture", + "sourceFingerprint": "7ef55b0d4bb9674de2b263e9dad2de2a593d0cb6aa09aea9ab3dc1832010a9d0", + "updatedAt": "2026-05-06T11:48:48.808Z" + }, + "cursor-monitor": { + "binaryName": "cursor-monitor.exe", + "binarySha256": "6ae6d91103b6e891a851e8ea5791e1c1f9aaab700134c18bc4c46cfffd7fdd12", + "sourceDir": "electron/native/cursor-monitor", + "sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e", + "updatedAt": "2026-05-05T18:42:26.007Z" + }, + "recordly-gpu-export": { + "binaryName": "recordly-gpu-export.exe", + "binarySha256": "e79cb823dd2cfade857db67696ae6456ae0ba3ae9381b6792c948ac648521bd4", + "sourceDir": "electron/native/gpu-export-probe", + "sourceFingerprint": "45aaa4c0cbeb45ecaa9ff0a87b870c33bd0d5b09fb5b3acef45b246970a12e7f", + "updatedAt": "2026-05-05T18:41:54.966Z" + }, + "recordly-nvidia-cuda-compositor": { + "binaryName": "recordly-nvidia-cuda-compositor.exe", + "binarySha256": "2913d78e2c1114d32c92fb4238749f7ff46131ec92cf3ad0b55af910c7cdbb07", + "sourceDir": "electron/native/nvidia-cuda-compositor", + "sourceFingerprint": "a60538981da7dd3d9645f59926d42f4f2f0f1f0663d3905e0416fdff8becb314", + "updatedAt": "2026-05-05T18:42:20.123Z" + } + } } diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe index bdbbdcc4..54477a6c 100644 Binary files a/electron/native/bin/win32-x64/wgc-capture.exe and b/electron/native/bin/win32-x64/wgc-capture.exe differ diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 5a7d0d48..80e94c74 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -225,6 +225,10 @@ static void writeCompanionAudioTimingMetadata( metadataFile << "{\"startDelayMs\":" << startDelayMs; metadataFile << ",\"capturedDurationMs\":" << capture.capturedDurationMs(); metadataFile << ",\"dataBytes\":" << capture.totalDataBytes(); + metadataFile << ",\"sampleRate\":" << capture.sampleRate(); + metadataFile << ",\"channels\":" << capture.channelCount(); + metadataFile << ",\"gapFillCount\":" << capture.gapFillCount(); + metadataFile << ",\"insertedSilenceFrames\":" << capture.insertedSilenceFrames(); const uint32_t discontinuityCount = capture.dataDiscontinuityCount(); if (discontinuityCount > 0) { metadataFile << ",\"dataDiscontinuityCount\":" << discontinuityCount; @@ -438,18 +442,32 @@ int main(int argc, char* argv[]) { std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; if (audioActive) { - if (loopback.dataDiscontinuityCount() > 0 || loopback.timestampErrorCount() > 0) { + if ( + loopback.dataDiscontinuityCount() > 0 || + loopback.timestampErrorCount() > 0 || + loopback.gapFillCount() > 0 + ) { std::cerr << "WARNING: System audio timing metadata includes discontinuities=" << loopback.dataDiscontinuityCount() - << " timestampErrors=" << loopback.timestampErrorCount() << std::endl; + << " timestampErrors=" << loopback.timestampErrorCount() + << " gapFills=" << loopback.gapFillCount() + << " insertedSilenceFrames=" << loopback.insertedSilenceFrames() + << std::endl; } std::cout << "Audio path: " << config.audioOutputPath << std::endl; } if (micActive) { - if (micCapture.dataDiscontinuityCount() > 0 || micCapture.timestampErrorCount() > 0) { + if ( + micCapture.dataDiscontinuityCount() > 0 || + micCapture.timestampErrorCount() > 0 || + micCapture.gapFillCount() > 0 + ) { std::cerr << "WARNING: Microphone timing metadata includes discontinuities=" << micCapture.dataDiscontinuityCount() - << " timestampErrors=" << micCapture.timestampErrorCount() << std::endl; + << " timestampErrors=" << micCapture.timestampErrorCount() + << " gapFills=" << micCapture.gapFillCount() + << " insertedSilenceFrames=" << micCapture.insertedSilenceFrames() + << std::endl; } std::cout << "Mic path: " << config.micOutputPath << std::endl; } diff --git a/electron/native/wgc-capture/src/wasapi_loopback.cpp b/electron/native/wgc-capture/src/wasapi_loopback.cpp index 745f86e3..f7565ec7 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback.cpp @@ -3,12 +3,16 @@ #include #include #include +#include #pragma comment(lib, "ole32.lib") namespace { constexpr int64_t kHundredNanosecondsPerSecond = 10000000; constexpr uint64_t kSilenceWriteChunkFrames = 4096; +constexpr uint32_t kMinimumGapFillThresholdMs = 50; +constexpr uint32_t kDiscontinuityGapFillThresholdMs = 10; +constexpr uint32_t kBoundaryFadeInMs = 5; int64_t queryPerformanceCounterHns() { LARGE_INTEGER counter; @@ -187,6 +191,9 @@ bool WasapiCapture::start() { accumulatedPausedQpcHns_ = 0; dataDiscontinuityCount_ = 0; timestampErrorCount_ = 0; + gapFillCount_ = 0; + insertedSilenceFrames_ = 0; + fadeInFramesRemaining_ = 0; writeWavHeader(outputFile_, 0); HRESULT hr = audioClient_->Start(); @@ -220,6 +227,7 @@ bool WasapiCapture::resume() { } pauseStartQpcHns_ = 0; paused_ = false; + fadeInFramesRemaining_ = boundaryFadeInFrameCount(); return true; } @@ -242,6 +250,7 @@ void WasapiCapture::stop() { paused_ = false; pauseStartQpcHns_ = 0; accumulatedPausedQpcHns_ = 0; + fadeInFramesRemaining_ = 0; } static int16_t floatToInt16(float v) { @@ -282,6 +291,34 @@ void WasapiCapture::writePcmFrames(const int16_t* samples, UINT32 frameCount, WO } const DWORD bytesToWrite = frameCount * channels * sizeof(int16_t); + const uint32_t fadeFrames = fadeInFramesRemaining_.load(); + if (fadeFrames > 0) { + const uint32_t totalFadeFrames = boundaryFadeInFrameCount(); + const uint32_t remainingFadeFrames = (std::min)(fadeFrames, totalFadeFrames); + const uint32_t framesToFade = + (std::min)(static_cast(frameCount), remainingFadeFrames); + const uint32_t completedFadeFrames = totalFadeFrames - remainingFadeFrames; + std::vector faded(samples, samples + frameCount * channels); + for (uint32_t frame = 0; frame < framesToFade; frame++) { + const double scale = + static_cast(completedFadeFrames + frame + 1) / + static_cast(totalFadeFrames); + for (WORD channel = 0; channel < channels; channel++) { + const size_t index = static_cast(frame) * channels + channel; + const long scaled = std::lround(static_cast(faded[index]) * scale); + faded[index] = static_cast( + std::clamp(scaled, -32768, 32767)); + } + } + fadeInFramesRemaining_ = remainingFadeFrames - framesToFade; + + DWORD written = 0; + WriteFile(outputFile_, faded.data(), bytesToWrite, &written, nullptr); + totalDataBytes_.fetch_add(written); + framesWritten_.fetch_add(written / (channels * sizeof(int16_t))); + return; + } + DWORD written = 0; WriteFile(outputFile_, samples, bytesToWrite, &written, nullptr); totalDataBytes_.fetch_add(written); @@ -293,12 +330,15 @@ void WasapiCapture::writeSilenceFrames(uint64_t frameCount, WORD channels) { return; } + gapFillCount_.fetch_add(1); + insertedSilenceFrames_.fetch_add(frameCount); std::vector silence(static_cast(kSilenceWriteChunkFrames * channels), 0); while (frameCount > 0) { - const uint64_t chunkFrames = std::min(frameCount, kSilenceWriteChunkFrames); + const uint64_t chunkFrames = (std::min)(frameCount, kSilenceWriteChunkFrames); writePcmFrames(silence.data(), static_cast(chunkFrames), channels); frameCount -= chunkFrames; } + fadeInFramesRemaining_ = boundaryFadeInFrameCount(); } uint64_t WasapiCapture::capturedDurationMs() const { @@ -309,6 +349,23 @@ uint64_t WasapiCapture::capturedDurationMs() const { return (framesWritten_.load() * 1000) / mixFormat_->nSamplesPerSec; } +uint32_t WasapiCapture::sampleRate() const { + return mixFormat_ ? mixFormat_->nSamplesPerSec : 0; +} + +uint16_t WasapiCapture::channelCount() const { + return mixFormat_ ? mixFormat_->nChannels : 0; +} + +uint32_t WasapiCapture::boundaryFadeInFrameCount() const { + const uint32_t rate = sampleRate(); + if (rate == 0) { + return 1; + } + const uint32_t fadeFrames = (rate * kBoundaryFadeInMs) / 1000; + return fadeFrames > 0 ? fadeFrames : 1; +} + void WasapiCapture::captureThread() { // COM must be initialized on every thread that uses COM objects. // The main thread calls winrt::init_apartment(MTA) but that only covers @@ -362,7 +419,9 @@ void WasapiCapture::captureThread() { break; } - if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0) { + const bool hasDataDiscontinuity = + (flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0; + if (hasDataDiscontinuity) { dataDiscontinuityCount_.fetch_add(1); } if ((flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) != 0) { @@ -385,15 +444,22 @@ void WasapiCapture::captureThread() { ) { const int64_t elapsedHns = static_cast(qpcPosition) - firstPacketQpcHns; - const int64_t adjustedElapsedHns = std::max( - 0, - elapsedHns - accumulatedPausedQpcHns_.load()); + const int64_t adjustedElapsedHns = + elapsedHns > accumulatedPausedQpcHns_.load() + ? elapsedHns - accumulatedPausedQpcHns_.load() + : 0; const uint64_t expectedStartFrame = (static_cast(adjustedElapsedHns) * mixFormat_->nSamplesPerSec + kHundredNanosecondsPerSecond / 2) / kHundredNanosecondsPerSecond; const uint64_t writtenFrames = framesWritten_.load(); - const uint64_t gapThresholdFrames = mixFormat_->nSamplesPerSec / 100; + const uint32_t gapThresholdMs = hasDataDiscontinuity + ? kDiscontinuityGapFillThresholdMs + : kMinimumGapFillThresholdMs; + const uint32_t gapThresholdFrameCount = + (mixFormat_->nSamplesPerSec * gapThresholdMs) / 1000; + const uint64_t gapThresholdFrames = + gapThresholdFrameCount > 0 ? gapThresholdFrameCount : 1; if (expectedStartFrame > writtenFrames + gapThresholdFrames) { writeSilenceFrames(expectedStartFrame - writtenFrames, channels); diff --git a/electron/native/wgc-capture/src/wasapi_loopback.h b/electron/native/wgc-capture/src/wasapi_loopback.h index 1c5555e9..1a513446 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.h +++ b/electron/native/wgc-capture/src/wasapi_loopback.h @@ -22,8 +22,12 @@ public: int64_t firstPacketQpcHns() const { return firstPacketQpcHns_.load(); } uint64_t capturedDurationMs() const; uint64_t totalDataBytes() const { return totalDataBytes_.load(); } + uint32_t sampleRate() const; + uint16_t channelCount() const; uint32_t dataDiscontinuityCount() const { return dataDiscontinuityCount_.load(); } uint32_t timestampErrorCount() const { return timestampErrorCount_.load(); } + uint32_t gapFillCount() const { return gapFillCount_.load(); } + uint64_t insertedSilenceFrames() const { return insertedSilenceFrames_.load(); } private: bool initializeCommon(); @@ -31,6 +35,7 @@ private: bool writeWavHeader(HANDLE file, DWORD dataSize); void writePcmFrames(const int16_t* samples, UINT32 frameCount, WORD channels); void writeSilenceFrames(uint64_t frameCount, WORD channels); + uint32_t boundaryFadeInFrameCount() const; IMMDevice* findCaptureDeviceByName(const std::wstring& name); std::string outputPath_; @@ -54,4 +59,7 @@ private: std::atomic accumulatedPausedQpcHns_{0}; std::atomic dataDiscontinuityCount_{0}; std::atomic timestampErrorCount_{0}; + std::atomic gapFillCount_{0}; + std::atomic insertedSilenceFrames_{0}; + std::atomic fadeInFramesRemaining_{0}; };