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.
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.
- Asking for memory. The allocator may take a lock, or ask the system for more.
- Freeing memory, for the same reason.
- Taking a lock that another thread holds. If that thread is the one drawing the screen, the audio now waits for the screen.
- Writing a log line, reading a file, or sending a message to an object whose memory is counted.
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.
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
- Set aside all memory before the sound starts.
- On the audio thread: no allocation, no free, no lock, no log, no file, no object with a reference count.
- Talk to it through a queue with one writer and one reader.
- Send used memory back the same way.
- Count time in samples made, never by a clock.
- Plant the fault and check the test can see it.
Sources
- The buffer of 120 samples, the longest render of 0.054 milliseconds, the 1.2 millisecond median and the 48 breaks: Beep Boop's timing results for the iPhone 14 Pro Max, 3 October 2026.
- The queues, their 1,024 places and their atomic counters, and the song handed back to be freed: the engine's C source.
- The render block that holds only a C pointer, and why it is built outside the main thread's code: the engine's Swift source, where the reason is written above the function.
More sheets
- Sample-accurate timing on an iPhoneSteps counted in samples, and how that was measured.
- A kick drum is twelve numbersHow a drum machine's kit is built.
- A beat in 40 bytesTempo, swing, eight voices and a check byte.
- An instrument in SwiftUIOne accent, five layers, nothing unneeded.
- PatternsDrum patterns drawn on sixteen steps.
- Tempo by genreBPM for twelve genres, on one scale.