Windows Audio & C++/JUCE DSP Engineering Postmortem

Why Windows WASAPI Audio Apps Start Crackling After 3.9 Hours
(And Why Duplicate Sliders Appear in Windows Volume Mixer)

Short 5-minute studio tests never catch marathon streaming bugs. When VTubers and karaoke streamers run a standalone vocal processor in Windows WASAPI Shared Mode for 4 to 5 hours straight—often pairing a USB microphone on one crystal oscillator with headphones on a separate DAC—two subtle bugs inside stock WASAPI wrappers surface. Here is how we reproduced both mathematically across 864 million samples and fixed them in VocalGzzio v5.4.1.

1. The 3.9-Hour +2.2 ppm Clock-Drift Reservoir Wedge

When an audio interface uses its own unified ASIO driver, the ADC (microphone input) and DAC (headphone output) are driven by the exact same hardware word clock. Zero drift ever occurs.

However, many streamers use a USB condenser/dynamic microphone (such as an AT2020USB-X, Shure MV7+, HyperX QuadCast, or Blue Yeti) for input while listening through motherboard audio, a gaming DAC, or a VR headset via Windows WASAPI Shared Mode. Even though both devices report 48,000 Hz to Windows, they run on two physically independent quartz crystals.

A completely normal crystal tolerance of just +2.2 ppm to +2.5 ppm means the microphone delivers roughly 48,000.11 samples per second while the output consumes 48,000.00 samples per second—an excess of 0.11 samples/sec.

What Happens at Hour 3.9 in Stock JUCE 8 WASAPI

In stock JUCE 8.0.12 (juce_WASAPI_windows.cpp), WASAPIInputDevice allocates a lock-free SPSC FIFO reservoir sized tightly around a single callback period:

const auto reservoirSize = nextPowerOfTwo ((int) (actualBufferSize + (UINT32) userBufferSizeIn));
// For 480-sample (10 ms @ 48 kHz) WASAPI packets: nextPowerOfTwo(960) == 1024 or 2048 samples

And inside handleDeviceBuffer(), whenever a new 480-sample packet arrives from IAudioCaptureClient::GetBuffer():

if (numSamplesAvailable > (UINT32) queue.getRemainingSpace())
{
    captureClient->ReleaseBuffer (0);
    return;
}

Look closely at what happens after 3.9 hours (~1.4 million 10ms callbacks) at +2.5 ppm drift:

  1. At +0.12 samples/sec, the unread cushion inside the 2,048-sample FIFO creeps upward by ~430 samples per hour.
  2. At hour 3.9, queue.getRemainingSpace() drops below 480 samples.
  3. handleDeviceBuffer() calls ReleaseBuffer(0) and returns early without draining the Windows capture packet, pinning the reservoir right at the ceiling so periodic capture packets are dropped—producing a continuous "chiri-chiri" crackle until the user restarts the audio device.
  4. Conversely, at -2.5 ppm (mic slightly slower than DAC), the reservoir drains to 0 after a few hours, and copyBuffersFromReservoir() repeatedly injects zeromem() silence gaps at the front of every underfilled block—creating sharp step discontinuities.

How We Fixed It (Verified Across 5 Hours / 864 Million Samples)

We added an automated build-time patch (patch_juce_wasapi.py) paired with a deterministic 5-hour stress test (test_4hour_crackle.cpp):

2. Why Duplicate Sliders Appear in Windows Volume Mixer (GUID session;)

A standalone streaming vocal studio actually opens multiple Windows audio streams simultaneously: your microphone capture stream, your headphone monitor output stream, and a secondary mirror output stream (StreamOut) feeding our WHQL-signed virtual cable (VocalGzzio Driver (WDM)) for OBS, VRChat, and Discord.

During testing on Windows 10/11, opening Volume Mixer (sndvol.exe or Win+G Game Bar) sometimes showed two or three separate VocalGzzio sliders.

Tracing IAudioClient::Initialize inside stock JUCE 8's juce_WASAPI_windows.cpp revealed the culprit immediately:

GUID session; // Uninitialized stack variable!
auto hr = client->Initialize (...,
                              getStreamFlags(),
                              defaultPeriod,
                              ...,
                              (WAVEFORMATEX*) &format,
                              &session);

In Microsoft's WASAPI specification, passing nullptr (or a zeroed GUID_NULL) for AudioSessionGuid instructs Windows to assign the stream to the process's default audio session so all streams in the app share a single Volume Mixer slider. Because GUID session; on the C++ stack was left uninitialized, every call to client->Initialize() passed 16 bytes of random stack bytes as a brand-new session GUID—forcing Windows Audio Endpoint Builder to spawn separate Volume Mixer entries!

Passing nullptr for AudioSessionGuid (and removing redundant secondary WASAPI session wrappers in StreamOut) immediately collapsed all streams into a single, clean Windows Volume Mixer entry.

📖 Explore VocalGzzio v5.4.1 (English Overview & 34s Audio Demo) ⬇ Direct Download: Windows Free Trial (.zip) 🛒 BOOTH Store ($0 Trial / Full Version)