BEEP BOOP
Public beta

The audio thread never waits

On an iPhone, Beep Boop is asked for a new buffer of sound about 400 times a second. Each one must be ready in 2.5 milliseconds. If one is late, the speaker has nothing to play and the listener hears a click. So the code that fills the buffer follows one rule: it never waits for anything.

Fig. 1
Two audio buffers of 2.5 milliseconds each, to scale. The engine's longest measured render, 0.054 milliseconds, fills about 2 percent of one buffer. The planted overrun holds the thread for 5 milliseconds, the length of two buffers.THE ENGINE'S LONGEST RENDER0.054 MS OF 2.5THE PLANTED OVERRUN5.0 MS: TWO BUFFERS LOST02.5 MS5 MSBUFFER 1BUFFER 2
Two buffers, to scale. The orange sliver at the left of the first row is the longest the engine took to fill one. The black bar is the fault planted to prove the test: twice a buffer.

The deadline

Sound leaves a phone as samples, 48,000 a second. iOS collects them from an app in buffers. Beep Boop asks for buffers of 2.5 milliseconds and is given 120 samples at a time. The system calls the app's render function on a thread of its own, the audio thread, and expects 120 samples back before the next call.

Most code can afford to be slow now and then. This code cannot. It is not the average time that matters but the worst one, because a single late buffer is heard.

What waiting means

Many ordinary things a program does can wait for an unknown time.

Each is usually fast. None is always fast. The audio thread does none of them.

How Beep Boop keeps the rule

The render code is written in C, in one small library. It uses memory that was set aside before PLAY: the voices, the patterns and every sound in the kit are already there. A change of sound in the middle of a bar is a change of an index, so nothing is loaded while music plays.

The rest of the app is Swift. The render block that iOS calls holds only a pointer to the C engine, and no Swift object. That matters twice. Touching a Swift object can change its reference count, which the rule forbids. And in Swift 6, a block made inside code that belongs to the main thread is checked to be on the main thread when it runs. On the audio thread that check stops the app, so the block is built outside it on purpose.

One queue in, one queue out

The panel still has to tell the engine things: a step switched on, a tempo turned, a key played. It does so through a queue of 1,024 commands with exactly one writer, the main thread, and one reader, the audio thread. Each side moves only its own counter, and the counters are atomic, so neither side ever takes a lock.

Fig. 2
Two threads and two queues. The main thread, which runs the panel and may allocate and free memory, puts commands into a queue of 1,024 places. The audio thread, which is C only and never waits, takes them out at the start of each buffer. Memory the audio thread has finished with travels back through a second queue, to be freed on the main thread.MAIN THREADTHE PANELTOUCHESALLOCATES, FREESAUDIO THREADC ONLYCOUNTS SAMPLESNEVER WAITSCOMMANDS, 1,024 PLACESUSED MEMORY, HANDED BACK
Commands go one way and used memory comes back the other. Each queue has one writer and one reader, so neither thread ever waits for the other.

At the start of every buffer the audio thread empties the queue, then renders. A played key therefore waits for the next buffer and no longer: measured on an iPhone, a median of 1.2 milliseconds.

If the queue is ever full, the command is refused and counted. The writer is never made to wait, and the count is one of the things a timing run checks is zero.

Freeing memory needs the same care in the other direction. When a new song replaces an old one, the audio thread does not free the old one. It hands it back through a second queue, and the main thread frees it later.

The measure of it

In the timing runs on an iPhone 14 Pro Max, the longest the engine took to fill a buffer was 0.054 milliseconds, about 2 percent of the time allowed. That run played one test voice, so it is a floor, not the figure for a full kit.

To be sure a late buffer would be seen, one was made on purpose. A test mode holds the audio thread for 5 milliseconds, twice a buffer, once every thousand buffers. Over 120 seconds the engine's own counters recorded 48 breaks, one for each. How the timing was measured has the rest.

The rule, in short

  1. Set aside all memory before the sound starts.
  2. On the audio thread: no allocation, no free, no lock, no log, no file, no object with a reference count.
  3. Talk to it through a queue with one writer and one reader.
  4. Send used memory back the same way.
  5. Count time in samples made, never by a clock.
  6. Plant the fault and check the test can see it.

Sources

More sheets

  1. Sample-accurate timing on an iPhoneSteps counted in samples, and how that was measured.
  2. A kick drum is twelve numbersHow a drum machine's kit is built.
  3. A beat in 40 bytesTempo, swing, eight voices and a check byte.
  4. An instrument in SwiftUIOne accent, five layers, nothing unneeded.
  5. PatternsDrum patterns drawn on sixteen steps.
  6. Tempo by genreBPM for twelve genres, on one scale.