mcap/modules/Screen_Record/audio_source.jai
Musa Mahmood 72f2754226 [0.1.8] Video capture phase 5: sound, from the computer and the microphone, in sync
- modules/Screen_Record/audio_source.jai: WASAPI, in loopback from the default output device
  (what the computer plays) and from the default input device (the microphone), asked for as
  48 kHz stereo float, which Windows converts any device to; a device that will not convert is
  taken as it is when it gives 48 kHz float. Packets carry the time they were heard. A device
  lost (unplugged, default changed) is opened again from the new default once a second.
- An audio thread mixes them onto the video's own timeline: the same time 0, less the same
  pauses (record_pause now keeps one account of time paused for both threads). A packet within
  30 ms of where its source left off follows on exactly; a source that sends nothing is silence,
  passed 200 ms late. Each 1024 samples every source has covered become an AAC frame.
- encoder.jai: an AAC stream (FFmpeg's aac, 160 kb/s) beside the video, written from the audio
  thread; both threads' packets meet in the file under a lock. The remux keeps it.
- [video] system_audio (true) and microphone (false). capture.json: Audio, AudioSources. A toast
  when a source asked for could not be opened.
- record-probe: -audio system|mic|both, the sound read back (length, level), and -sync-test,
  which flashes a white square and beeps together every 2 s, then finds both in the file.
- Measured: 20 s, beeps 0-21 ms from their flashes, no drift; both sources mixed, 5-18 ms;
  a 6 s pause cuts the same 6 s from sound and video; 3 min, 85 of 90 beeps within 3-24 ms and
  -14 ms from first to last, with 5 outliers (53-147 ms) where two beeps went missing. Tests:
  an MP4 with sound keeps its AAC stream through the remux into the library (30 checks).
2026-10-10 08:08:47 +01:00

264 lines
12 KiB
Plaintext

// Sound for a recording, with WASAPI: what the computer plays (the default output device, in loopback)
// and the microphone (the default input device). Each is asked for in shared mode as 48 kHz stereo
// float, which Windows converts any device's own format to (AUTOCONVERTPCM), so nothing here
// resamples. Where a device will not convert, its own format is taken if it is 48 kHz float, and its
// channels are made stereo here.
//
// Each packet comes with the QueryPerformanceCounter time its first sample was heard, which places it
// on the recording's timeline (module.jai's audio thread mixes them). Loopback sends nothing while
// nothing plays: those gaps are silence. A device unplugged mid-recording (a headset) is opened
// again, from the new default, once a second.
AUDIO_RATE :: 48000;
AUDIO_CHANNELS :: 2;
Audio_Source_Kind :: enum u8 { SYSTEM; MICROPHONE; }
Audio_Source :: struct {
kind: Audio_Source_Kind;
name: string; // "Speakers (Realtek(R) Audio)"; heap
client: *Audio_Client;
capture: *Audio_Capture_Client;
channels: s32; // as the device gives them: 2, unless it would not convert
open: bool;
lost_at: s64; // when it was lost (ticks), to open it again a second on; 0 while open
error: string; // why it could not be opened; heap
}
Audio_Packet :: struct {
samples: [] float; // interleaved stereo, AUDIO_CHANNELS a frame; empty for silence
frames: s64;
ticks: s64; // when its first frame was heard, in QueryPerformanceCounter ticks
silent: bool;
}
// The default device of its kind, started. False with why: none, or it will not open.
audio_open :: (s: *Audio_Source, kind: Audio_Source_Kind) -> ok: bool, error: string {
s.kind = kind;
enumerator: *Device_Enumerator;
hr := audio_CoCreateInstance(*CLSID_MM_DEVICE_ENUMERATOR, null, CLSCTX_ALL, *IID_MM_DEVICE_ENUMERATOR, xx *enumerator);
if FAILED(hr) return false, hresult_text("the audio device list", hr);
defer com_release(*enumerator);
device: *Audio_Device;
flow: s32 = ifx kind == .SYSTEM then E_RENDER else E_CAPTURE;
hr = enumerator.vtable.GetDefaultAudioEndpoint(enumerator, flow, E_CONSOLE, *device);
if FAILED(hr) return false, ifx kind == .SYSTEM then "no sound output device" else "no microphone";
defer com_release(*device);
free(s.name);
s.name = copy_string(audio_device_name(device));
hr = device.vtable.Activate(device, *IID_AUDIO_CLIENT, CLSCTX_ALL, null, xx *s.client);
if FAILED(hr) return false, hresult_text(tprint("% (%)", s.name, "IAudioClient"), hr);
flags: u32 = AUTOCONVERTPCM | SRC_DEFAULT_QUALITY;
if kind == .SYSTEM flags |= STREAMFLAGS_LOOPBACK;
wanted := WAVEFORMATEX.{wFormatTag = WAVE_FORMAT_IEEE_FLOAT, nChannels = AUDIO_CHANNELS, nSamplesPerSec = AUDIO_RATE,
nAvgBytesPerSec = AUDIO_RATE * AUDIO_CHANNELS * 4, nBlockAlign = AUDIO_CHANNELS * 4, wBitsPerSample = 32};
BUFFER :: 2_000_000; // 200 ms, in 100 ns: room for the audio thread being late
hr = s.client.vtable.Initialize(s.client, SHAREMODE_SHARED, flags, BUFFER, 0, *wanted, null);
s.channels = AUDIO_CHANNELS;
if FAILED(hr) {
// No conversion here: the device's own format, if it is 48 kHz float, its channels made stereo after.
mix: *WAVEFORMATEX;
if FAILED(s.client.vtable.GetMixFormat(s.client, *mix)) return false, hresult_text(tprint("% will not give 48 kHz stereo", s.name), hr);
defer audio_CoTaskMemFree(mix);
is_float := mix.wFormatTag == WAVE_FORMAT_IEEE_FLOAT || (mix.wFormatTag == WAVE_FORMAT_EXTENSIBLE && mix.wBitsPerSample == 32);
if !is_float || mix.nSamplesPerSec != AUDIO_RATE
return false, tprint("% gives % Hz %, not 48 kHz float, and will not convert", s.name, mix.nSamplesPerSec, ifx is_float then "float" else "PCM");
com_release(*s.client);
hr = device.vtable.Activate(device, *IID_AUDIO_CLIENT, CLSCTX_ALL, null, xx *s.client);
if FAILED(hr) return false, hresult_text(s.name, hr);
hr = s.client.vtable.Initialize(s.client, SHAREMODE_SHARED, flags & ~(AUTOCONVERTPCM | SRC_DEFAULT_QUALITY), BUFFER, 0, mix, null);
if FAILED(hr) return false, hresult_text(tprint("% (Initialize)", s.name), hr);
s.channels = mix.nChannels;
}
hr = s.client.vtable.GetService(s.client, *IID_AUDIO_CAPTURE_CLIENT, xx *s.capture);
if FAILED(hr) return false, hresult_text(tprint("% (IAudioCaptureClient)", s.name), hr);
hr = s.client.vtable.Start(s.client);
if FAILED(hr) return false, hresult_text(tprint("% (Start)", s.name), hr);
s.open = true;
s.lost_at = 0;
return true, "";
}
// The packets waiting, each as stereo float (temp), oldest first. lost: the device went away
// (unplugged, or the default changed): audio_close, and open it again later.
audio_take :: (s: *Audio_Source, frequency: s64) -> [] Audio_Packet, lost: bool {
packets: [..] Audio_Packet;
packets.allocator = temp;
while true {
next: u32;
hr := s.capture.vtable.GetNextPacketSize(s.capture, *next);
if hresult_is(hr, AUDCLNT_E_DEVICE_INVALIDATED) return packets, true;
if FAILED(hr) || !next break;
data: *u8;
frames, flags: u32;
device_position, qpc_position: u64; // qpc_position: QueryPerformanceCounter time, in 100 ns
hr = s.capture.vtable.GetBuffer(s.capture, *data, *frames, *flags, *device_position, *qpc_position);
if hresult_is(hr, AUDCLNT_E_DEVICE_INVALIDATED) return packets, true;
if FAILED(hr) || hresult_is(hr, AUDCLNT_S_BUFFER_EMPTY) break;
packet: Audio_Packet;
packet.frames = frames;
packet.ticks = cast(s64) (cast(float64) qpc_position * frequency / 10_000_000.0);
packet.silent = (flags & BUFFERFLAGS_SILENT) != 0;
if !packet.silent && frames {
packet.samples = NewArray(cast(s64) frames * AUDIO_CHANNELS, float, initialized = false,, temp);
input := cast(*float) data;
if s.channels == AUDIO_CHANNELS {
memcpy(packet.samples.data, input, cast(s64) frames * AUDIO_CHANNELS * size_of(float));
} else {
// Mono to both sides; more channels than two: the first two (front left and right).
channels := cast(s64) s.channels;
for f: 0..cast(s64) frames - 1 {
left := input[f * channels];
right := ifx channels > 1 then input[f * channels + 1] else left;
packet.samples[f * 2] = left;
packet.samples[f * 2 + 1] = right;
}
}
}
s.capture.vtable.ReleaseBuffer(s.capture, frames);
array_add(*packets, packet);
}
return packets, false;
}
audio_close :: (s: *Audio_Source) {
if s.client && s.open s.client.vtable.Stop(s.client);
com_release(*s.capture);
com_release(*s.client);
s.open = false;
}
audio_free :: (s: *Audio_Source) {
audio_close(s);
free(s.name);
free(s.error);
s.name, s.error = "", "";
}
// COM on this thread, in the multithreaded apartment, as WASAPI's objects are used from it only.
audio_thread_start :: () {
audio_CoInitializeEx(null, COINIT_MULTITHREADED);
}
audio_thread_end :: () {
audio_CoUninitialize();
}
#scope_file
audio_device_name :: (device: *Audio_Device) -> string {
store: *Property_Store;
if FAILED(device.vtable.OpenPropertyStore(device, STGM_READ, *store)) return "";
defer com_release(*store);
value: PROPVARIANT;
if FAILED(store.vtable.GetValue(store, *PKEY_DEVICE_FRIENDLY_NAME, *value)) return "";
defer audio_PropVariantClear(*value);
VT_LPWSTR :: 31;
if value.vt != VT_LPWSTR || !value.pointer return "";
name, ok := wide_to_utf8(cast(*u16) value.pointer,, temp);
return ifx ok then name else "";
}
// ---------------------------------------------------------------- bindings
ole32_dll :: #library,system "ole32";
audio_CoInitializeEx :: (reserved: *void, flags: u32) -> HRESULT #foreign ole32_dll "CoInitializeEx";
audio_CoUninitialize :: () #foreign ole32_dll "CoUninitialize";
audio_CoCreateInstance :: (clsid: *GUID, outer: *void, class_context: u32, iid: *GUID, out: **void) -> HRESULT #foreign ole32_dll "CoCreateInstance";
audio_CoTaskMemFree :: (memory: *void) #foreign ole32_dll "CoTaskMemFree";
audio_PropVariantClear :: (value: *PROPVARIANT) -> HRESULT #foreign ole32_dll "PropVariantClear";
COINIT_MULTITHREADED : u32 : 0;
CLSCTX_ALL : u32 : 0x17;
E_RENDER : s32 : 0;
E_CAPTURE : s32 : 1;
E_CONSOLE : s32 : 0;
STGM_READ : u32 : 0;
SHAREMODE_SHARED : s32 : 0;
STREAMFLAGS_LOOPBACK : u32 : 0x00020000;
AUTOCONVERTPCM : u32 : 0x80000000;
SRC_DEFAULT_QUALITY : u32 : 0x08000000;
BUFFERFLAGS_SILENT : u32 : 0x2;
WAVE_FORMAT_IEEE_FLOAT : u16 : 3;
WAVE_FORMAT_EXTENSIBLE : u16 : 0xFFFE;
AUDCLNT_S_BUFFER_EMPTY :: 0x08890001;
AUDCLNT_E_DEVICE_INVALIDATED :: 0x88890004;
CLSID_MM_DEVICE_ENUMERATOR := #run guid("BCDE0395-E52F-467C-8E3D-C4579291692E");
IID_MM_DEVICE_ENUMERATOR := #run guid("A95664D2-9614-4F35-A746-DE8DB63617E6");
IID_AUDIO_CLIENT := #run guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2");
IID_AUDIO_CAPTURE_CLIENT := #run guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317");
PROPERTYKEY :: struct { fmtid: GUID; pid: u32; }
PKEY_DEVICE_FRIENDLY_NAME := PROPERTYKEY.{#run guid("A45C254E-DF1C-4EFD-8020-67D146A850E0"), 14};
// A PROPVARIANT, as far as a string in it: its type, then (after 6 reserved bytes) the pointer.
PROPVARIANT :: struct {
vt: u16;
reserved: [3] u16;
pointer: *void;
padding: *void;
}
WAVEFORMATEX :: struct {
wFormatTag: u16;
nChannels: u16;
nSamplesPerSec: u32;
nAvgBytesPerSec: u32;
nBlockAlign: u16;
wBitsPerSample: u16;
cbSize: u16;
}
Unknown_Methods :: struct {
QueryInterface: (this: *void, iid: *GUID, out: **void) -> HRESULT #c_call;
AddRef: (this: *void) -> u32 #c_call;
Release: (this: *void) -> u32 #c_call;
}
Device_Enumerator :: struct { vtable: *struct {
using base: Unknown_Methods;
EnumAudioEndpoints: *void;
GetDefaultAudioEndpoint: (this: *Device_Enumerator, flow: s32, role: s32, out: **Audio_Device) -> HRESULT #c_call;
}; }
Audio_Device :: struct { vtable: *struct {
using base: Unknown_Methods;
Activate: (this: *Audio_Device, iid: *GUID, class_context: u32, parameters: *void, out: **void) -> HRESULT #c_call;
OpenPropertyStore: (this: *Audio_Device, access: u32, out: **Property_Store) -> HRESULT #c_call;
}; }
Property_Store :: struct { vtable: *struct {
using base: Unknown_Methods;
GetCount: *void;
GetAt: *void;
GetValue: (this: *Property_Store, key: *PROPERTYKEY, out: *PROPVARIANT) -> HRESULT #c_call;
}; }
Audio_Client :: struct { vtable: *struct {
using base: Unknown_Methods;
Initialize: (this: *Audio_Client, share_mode: s32, flags: u32, buffer_duration: s64, periodicity: s64, format: *WAVEFORMATEX, session: *GUID) -> HRESULT #c_call;
GetBufferSize: *void;
GetStreamLatency: *void;
GetCurrentPadding: *void;
IsFormatSupported: *void;
GetMixFormat: (this: *Audio_Client, out: **WAVEFORMATEX) -> HRESULT #c_call;
GetDevicePeriod: *void;
Start: (this: *Audio_Client) -> HRESULT #c_call;
Stop: (this: *Audio_Client) -> HRESULT #c_call;
Reset: *void;
SetEventHandle: *void;
GetService: (this: *Audio_Client, iid: *GUID, out: **void) -> HRESULT #c_call;
}; }
Audio_Capture_Client :: struct { vtable: *struct {
using base: Unknown_Methods;
GetBuffer: (this: *Audio_Capture_Client, data: **u8, frames: *u32, flags: *u32, device_position: *u64, qpc_position: *u64) -> HRESULT #c_call;
ReleaseBuffer: (this: *Audio_Capture_Client, frames: u32) -> HRESULT #c_call;
GetNextPacketSize: (this: *Audio_Capture_Client, out: *u32) -> HRESULT #c_call;
}; }