fix(recording): soften native mic gap boundaries

This commit is contained in:
wiiiii123
2026-05-06 18:50:06 +07:00
parent ec6fb0d05d
commit f8df2d6ae9
5 changed files with 135 additions and 43 deletions
@@ -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"
}
}
}
Binary file not shown.
+22 -4
View File
@@ -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;
}
@@ -3,12 +3,16 @@
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
#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<uint32_t>(frameCount), remainingFadeFrames);
const uint32_t completedFadeFrames = totalFadeFrames - remainingFadeFrames;
std::vector<int16_t> faded(samples, samples + frameCount * channels);
for (uint32_t frame = 0; frame < framesToFade; frame++) {
const double scale =
static_cast<double>(completedFadeFrames + frame + 1) /
static_cast<double>(totalFadeFrames);
for (WORD channel = 0; channel < channels; channel++) {
const size_t index = static_cast<size_t>(frame) * channels + channel;
const long scaled = std::lround(static_cast<double>(faded[index]) * scale);
faded[index] = static_cast<int16_t>(
std::clamp<long>(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<int16_t> silence(static_cast<size_t>(kSilenceWriteChunkFrames * channels), 0);
while (frameCount > 0) {
const uint64_t chunkFrames = std::min<uint64_t>(frameCount, kSilenceWriteChunkFrames);
const uint64_t chunkFrames = (std::min)(frameCount, kSilenceWriteChunkFrames);
writePcmFrames(silence.data(), static_cast<UINT32>(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<int64_t>(qpcPosition) - firstPacketQpcHns;
const int64_t adjustedElapsedHns = std::max<int64_t>(
0,
elapsedHns - accumulatedPausedQpcHns_.load());
const int64_t adjustedElapsedHns =
elapsedHns > accumulatedPausedQpcHns_.load()
? elapsedHns - accumulatedPausedQpcHns_.load()
: 0;
const uint64_t expectedStartFrame =
(static_cast<uint64_t>(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);
@@ -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<int64_t> accumulatedPausedQpcHns_{0};
std::atomic<uint32_t> dataDiscontinuityCount_{0};
std::atomic<uint32_t> timestampErrorCount_{0};
std::atomic<uint32_t> gapFillCount_{0};
std::atomic<uint64_t> insertedSilenceFrames_{0};
std::atomic<uint32_t> fadeInFramesRemaining_{0};
};