We'll initialize a simple, low-latency audio output device which will call our provided function to request blocks of bytes representing any sound. It also automatically handles device switching when the default device changes, and recovers from general errors. Running the code below gives a console program which outputs a square wave sound. Press up/down on your keyboard to adjust the frequency, and escape/q/k to quit the program.
Note: Due to the use of defer, you need at least clang-22 to compile this code.
Build with:
clang main.m -fdefer-ts -framework AudioUnit -framework CoreAudio
bool quit = false;
bool restart_multiple_attempts = false;
while (restart_audio && !quit) {
if (restart_multiple_attempts > 0) sleep (1);
restart_multiple_attempts = true;
I've wrapped the audio startup code in this loop so that it can easily be reinitialized upon any failure. If the first attempt at reinitialization fails, we sleep for 1 second before trying again. This prevents the program from saturating a CPU core trying to restart when the system doesn't have a valid audio output for us.
AudioComponent output;
{ output = AudioComponentFindNext(NULL,
&(AudioComponentDescription){
.componentType = kAudioUnitType_Output,
.componentSubType = kAudioUnitSubType_DefaultOutput,
}); assert (output); if (!output) { LOG("Can't find default output"); continue; } }
AudioUnit tone_unit;
DO_OR_RETRY (AudioComponentInstanceNew(output, &tone_unit), "Error creating audio unit");
defer { DO_OR_LOG (AudioComponentInstanceDispose(tone_unit), "Error disposing audio unit"); }
First, we must find an audio component to use. We're simply requesting the default output. It's important to dispose of the unit when finished with it (defer is convenient for this) since we may rerun this code many times if the audio device gets invalidated.
DO_OR_RETRY (AudioUnitSetProperty(tone_unit, kAudioUnitProperty_SetRenderCallback, kAudioUnitScope_Input, 0, &(AURenderCallbackStruct){.inputProc = SoundCallback}, sizeof(AURenderCallbackStruct)), "Error setting render callback");
In order to send signals to the Audio output unit, we need to register a callback function which MacOS will call into.
AudioStreamBasicDescription asbd = {
.mFormatID = kAudioFormatLinearPCM,
.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved,
.mSampleRate = SAMPLING_RATE,
.mBitsPerChannel = 16,
.mChannelsPerFrame = 1,
.mFramesPerPacket = 1,
.mBytesPerFrame = 2,
.mBytesPerPacket = 2,
};
DO_OR_RETRY (AudioUnitSetProperty(tone_unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Input, 0, &asbd, sizeof(asbd)), "Error setting stream format");
Here we set the properties of the audio stream we want to output. I've gone with 48KHz 16-bit signed integer single-channel.
DO_OR_LOG (AudioUnitSetProperty(tone_unit, kAudioDevicePropertyBufferFrameSize, kAudioUnitScope_Input, 0, &(UInt32){PERIOD_SIZE}, sizeof(UInt32)), "Error setting buffer size");
The buffer size has a direct effect on the latency of our audio playback. Larger buffers mean more delay, but smaller buffers require tighter timing to prevent buffer underrun. I've requested a 5ms buffer size.
AudioObjectPropertyAddress property_address = {
.mSelector = kAudioHardwarePropertyDefaultOutputDevice,
.mScope = kAudioObjectPropertyScopeGlobal,
.mElement = kAudioObjectPropertyElementMain
};
DO_OR_LOG (AudioObjectAddPropertyListener (kAudioObjectSystemObject, &property_address, deviceChangedCallback, NULL), "Failed to add property listener for default device change");
defer { DO_OR_LOG (AudioObjectRemovePropertyListener (kAudioObjectSystemObject, &property_address, deviceChangedCallback, NULL), "Failed to remove property listener for default device change"); }
We can set a callback function for various properties. We only need to monitor the default device in order to reinitialize whenever it changes. If you remove this default device-detecting code, you may find that MacOS automatically reroutes our audio to the new default device. However, the audio unit will not keep our previously set properties, particularly buffer size, so it's best to detect the change and reinitialize it ourselves.
DO_OR_RETRY (AudioUnitInitialize(tone_unit), "Error initializing unit");
defer { DO_OR_LOG (AudioUnitUninitialize(tone_unit), "Error uninitializing unit"); }
DO_OR_RETRY (AudioOutputUnitStart(tone_unit), "Error starting unit");
defer { DO_OR_LOG (AudioOutputUnitStop(tone_unit), "Error stopping unit"); }
Finally, we initialize and start the audio output unit.
static OSStatus SoundCallback (void *inRefCon, AudioUnitRenderActionFlags *ioActionFlags, const AudioTimeStamp *inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames, AudioBufferList *ioData) {
assert (ioData->mNumberBuffers == 1);
SInt16 *data = (SInt16*)ioData->mBuffers[0].mData;
for (UInt32 frame = 0; frame < inNumberFrames; ++frame) {
static uint16_t sound_counter = 0;
*(data++) = sound_counter < wavelength/2 ? INT16_MIN : INT16_MAX;
if (++sound_counter >= wavelength) sound_counter = 0;
}
return noErr;
}
This is the callback function to fill buffers with audio samples. The key variables passed in are inNumberFrames and ioData. inNumberFrames is the number of audio samples requested - at 48000Hz, each sample is 1/48000th of a second of audio. ioData is a pointer to a number of buffers to fill. This will be one buffer per sound channel, so we expect just one buffer. To produce a square wave, we just swap between the minimum and maximum values every wavelength/2 samples. The wavelength is calculated in the CalculateWavelength() function.
while (!quit && !restart_audio) {
UInt32 is_running = 0;
if (AudioUnitGetProperty(tone_unit, kAudioOutputUnitProperty_IsRunning, kAudioUnitScope_Global, 0, &is_running, &(UInt32){sizeof(is_running)}) != noErr || !is_running) {
LOG ("Device not running");
restart_audio = true;
break;
}
After calling AudioUnitOutputStart, the audio will run on its own thread, but our main thread needs to keep running to keep the program open. In the main loop, I check for the IsRunning property to detect if the audio unit has stopped for any reason, then restart the audio from scratch.