Skip to content
NuclearMeltdown edited this page Oct 20, 2026 · 8 revisions

Audio

src/audio/audio_engine.cpp, src/audio/dshow_audio_capture.cpp, src/audio/source_capture.cpp, src/audio/audio_ring.cpp

The path is: capture device → ring buffer → WASAPI render endpoint.

Routing the card's audio through qBlank's own ring is what makes the delay adjustable at all. A DirectShow audio renderer would hand you whatever buffering it feels like, typically over a hundred milliseconds; here the target fill of the ring is the latency.

Two capture backends

WASAPI for anything Windows exposes as a recording device — which is most cards' embedded audio.

DirectShow (dshow_audio_capture.cpp) for cards whose embedded audio Windows does not expose as a sound device. That builds a small graph with a sample-grab sink of the same shape as the video one.

Playback is always WASAPI, so the output device picker and the exclusive-mode option work either way.

OnCapturedAudio() is the single entry point for captured audio, whichever backend produced it. It feeds the playback ring and, when recording, the tap.

Drift correction

The card's sample clock and the sound device's sample clock are not the same clock and never will be. Over an hour they separate by enough to matter.

The correction is a playback rate nudge, computed each callback from how full the ring is against where it should be:

double ratio = (double)captureRate / (double)fmt.sampleRate;
const double err = ((double)available - (double)targetFrames) / (double)targetFrames;
ratio *= Clamp(1.0 + 0.05 * err, 0.997, 1.003);

Three tenths of one per cent at the extreme — well under the threshold of audibility for pitch, and it absorbs the drift without ever cutting the stream. No resampler, no dropped blocks, no clicks.

The resampling itself is linear interpolation between the two neighbouring frames, with prev holding the last frame of the previous callback so the interpolation is continuous across callback boundaries.

const double endPos = srcFrac + ratio * frames;
const size_t shift = (size_t)std::floor(endPos);

Everything up to shift is consumed; the rest stays in pending for the next callback, so no sample is ever silently dropped.

Priming and starvation

Priming: nothing is played until the ring holds a full target's worth. Until then, silence.

Starvation: if the ring falls below a quarter of the target, the engine un-primes and writes a clean gap of silence while it refills.

} else if (available * 4 < targetFrames) {
  primed = false;
  writeSilence(frames);
  continue;
}

That is what a card with no signal locked does — it delivers less than real time. One clean gap beats grinding along on held samples.

Underrun: when the ring runs short mid-callback, the last known sample value is held rather than dropping to silence. Far less clicky, and a short hold is nearly inaudible. underruns_ counts it, and the statistics overlay reports it.

The recording tap

A second ring receives the captured audio exactly as it arrived: no resampling, no drift correction, no volume, no mute.

The playback ring is deliberately not usable for this. It nudges the playback rate by a fraction of a per cent to hold its target fill, which is right for listening and wrong for a file that has to stay in sync over an hour — and it would bake the volume slider into the recording.

tapOverruns() counts how often the tap ring overflowed, which is the recorder falling behind.

Latency settings

Buffer target is the fill the ring is held at, in milliseconds, and is therefore the latency. AudioEngine raises what it actually aims for above the configured value to clear the playback device's own buffer — asking for 10 ms when the endpoint's own period is 10 ms would leave nothing to absorb a single late callback.

Exclusive mode takes the endpoint away from the rest of Windows, which removes the mixer from the path and lowers the floor. Nothing else on the machine can make a sound while it is held.

A/V offset shifts the audio against the picture, in milliseconds, either way. The card's audio and video paths do not necessarily have the same delay, and no card reports what the difference is.

Level meters

float inputPeak() const;   // 0..1, with a decay

Measured before volume and mute, so the meter shows what the card is delivering rather than how loud you have it. A silent meter therefore means no signal, not a muted output — which is the question you actually want answered when nothing is coming out.

Both the capture input and every recording source have one.

Recording sources

source_capture.cpp. Any number of extra sounds for the recording, each with its own gain, added and removed on the audio tab.

Kind Records
Input a microphone, or any other input device
Output device what plays on that device (WASAPI loopback)
System sound everything Windows plays except qBlank (process loopback, Windows 10 2004+)
  • Never played back and never mixed into what you hear.
  • System sound leaves qBlank out, so the game is not in the file twice. An output device has no such filter: if the card plays there too, the settings warn that it would be recorded twice.
  • An idle output delivers nothing: a loopback on a device that plays nothing gets no packets at all. qBlank plays silence on it while the source is open. Process loopback delivers silence by itself.
  • Open only while used: while recording, and while the settings are open so the meter means something. Longer would light the Windows privacy indicator for nothing.
  • The card's sound heads the list, fixed, with its meter; it is picked under Source. The rest starts empty and is added by hand below it. Settings from before the list keep their microphone if it was used.
  • During a recording only the gain can change: the tracks are laid out.

See Recording for the track layout.

Device loss

AudioEngine sets a flag when a device disappears (unplugged, driver reload, default device changed). The app notices and offers a restart rather than silently running with no sound.

Clone this wiki locally