# Life on the audio thread > Real-time audio has hard deadlines. The rules Estua follows so sleep sounds never click at 3 AM. 2025-08-02 · 21 min read · estua, dsp · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/dsp-audio-thread.html --- Real-time audio on a phone is a scheduling problem with human consequences. The operating system calls your render function every N samples and expects a full buffer of PCM floats back before the hardware drains what it already has. At 48 kHz with a 512-sample block, the deadline lands every 10.67 ms. All night. A few million times per sleep session. Miss it once and the listener hears a click or a pop. Miss it repeatedly and Estua's sleep sound turns unusable at 3 AM, which becomes a one-star review, which becomes a real problem for a studio with five apps and no venture capital cushion. [Sonarish](https://uranashel.com/apps/sonarish.html) runs the same gauntlet in the opposite direction. Its microphone capture path receives samples from the audio HAL on a high-priority thread, and whatever happens inside that callback decides whether the noise meter reads truth or garbage. Two apps, one discipline. This post writes the discipline down. ## The deadline, derived Start with arithmetic. One sample period at 48 kHz lasts `1/48000 s ≈ 20.8 µs`. A 512-sample block therefore spans `512/48000 ≈ 10.67 ms`, and that interval is the ceiling for everything the app does per callback: every noise generator, every filter, every mix stage, plus whatever the OS itself burns on resampling and the Bluetooth stack before your floats reach a speaker. We aim to use well under half of it. Bench note: on an iPhone 12 and a Pixel 6a we logged callback wake-up timestamps through one 8-hour Estua session per device; median wake-up jitter stayed under 0.3 ms, but Bluetooth reconnects produced isolated wake-ups arriving 2 ms late. A render chain that needs 9 ms of the block has no armor against a late wake-up. One that needs 1 ms shrugs. On iOS, Estua uses AVAudioEngine with a manual source node callback. On Android it uses AudioTrack with ENCODING_PCM_FLOAT in low-latency mode when the OEM supports it. Both platforms invoke the render function on a real-time priority thread that bypasses the normal UI run loop. The scheduler promises to wake that thread on time. What happens if the thread then waits on something slow is nobody's promise, and every rule below exists because of that gap. ## Why malloc is the enemy The audio thread must never wait on contended locks, never allocate from the heap, never perform I/O, and never call code that might do any of those things transitively. The problem is variance. A malloc on a fragmented heap might take 200 microseconds on a good day and 5 milliseconds on a bad day, and 5 ms is half the block gone before a single sample gets rendered. Bench note from the same Pixel 6a: after fragmenting a test heap with 1 million mixed-size allocations, we timed 100,000 further small mallocs. Median 0.1 µs. Worst single call 4.1 ms. That tail is the click. Locks fail differently. A real-time thread that blocks on a mutex held by a background thread inherits that background thread's scheduling luck, and on a loaded phone the holder may not run again for several milliseconds. Priority inheritance rescues you on some kernel versions and silently does nothing on others. The transitive clause is the sneaky part: an innocent call into a string formatter, a Swift protocol witness, or a convenience API from the OS can allocate on your behalf three stack frames down. If we cannot read the code path to the bottom, it stays off the audio thread. ## Rules we enforce in code review These are review blockers, not guidelines. ktuyen files violations as bugs and atuan will not tag a release while one stays open. - No heap allocation in the callback. No Swift Array append, no Kotlin list growth, no std::vector push_back, no NSString formatting. Every buffer is preallocated at session start on the UI thread and reaches the callback as a pointer in its context struct. - No locks that can block. Data crosses between the audio thread and the UI thread through lock-free single-producer single-consumer ring buffers, in both directions. - No logging, file writes, or network calls, even guarded by debug flags. Debug builds ship to ktuyen's overnight soak tests, and one forgotten print statement that allocated a string has caused more clicks than any DSP bug we have written. - On iOS, no Objective-C message sends on the hot path. The callback stays in C or in Swift struct code the compiler can inline. - Parameters are computed off-thread. Biquad filter coefficients, LFO rates, and scene parameters are derived on the UI thread when the user changes a setting; the callback reads only const structs that were written once and are never mutated concurrently. The last rule deserves its own section, because it is the one engineers get wrong in the most interesting ways. ## Publishing parameters without locks When you drag Estua's ocean scene toward heavier surf, the app has to hand new filter cutoffs to a thread it is forbidden to lock against. The pattern we use is a two-slot swap. Parameters live in two preallocated slots, an atomic integer names the slot the audio thread may read, and the UI thread always writes the spare slot before publishing it. ``` // two preallocated slots, one atomic index struct Params { cutoffHz, gain, lfoRate[4] } // plain floats only slots = [Params, Params] // filled at session start activeIdx = AtomicInt(0) // slot the audio thread reads // UI thread, when a setting changes: spare = 1 - activeIdx.load(acquire) slots[spare] = computeParams() // biquad coeffs, LFO rates, scene activeIdx.store(spare, release) // publish // audio callback, once per block: p = slots[activeIdx.load(acquire)] // copy by value, tens of bytes renderBlock(out, 512, p) ``` The release/acquire pair guarantees the callback never observes a half-written slot, and the copy costs tens of bytes. Stretch the same idea around a power-of-2 array with separate read and write indices and you get the SPSC ring buffers that carry Sonarish's captured PCM out and Estua's metering data up to the UI. No mutex appears anywhere in either app's audio path. One honest caveat: two settings changes in very quick succession could in principle reuse a slot mid-read. The per-block copy is fast enough that we have never observed it, and a triple buffer would close the gap if we ever did. ## How Estua fits inside the budget Estua's synthesis chain, described end to end in [our non-repeating audio post](https://uranashel.com/blog/estua-non-repeating-audio.html), generates pink noise with Paul Kellet's economical recursive filter: three one-pole state updates of the form `b0 = 0.99765·b0 + w·0.0990460` plus a weighted sum, a handful of multiplies and adds per sample with no FFT per block. Slow amplitude movement comes from one-pole low-pass filters on the absolute value of the signal envelope, `y[n] = y[n−1] + α·(x[n] − y[n−1])`, running at the full sample rate with precomputed coefficients. Multiple uncorrelated LFOs modulate filter cutoff and gain at 0.02–0.15 Hz, so the output never repeats on timescales a human can track. The product reasoning for synthesis over loops is in our [sleep and waves post](https://uranashel.com/blog/estua-sleep-and-waves.html). All of it fits in roughly 3–8% of one CPU core on modern phones, comfortably inside the 10.67 ms block, leaving headroom for Bluetooth audio output and for the moments when the OS briefly starves a backgrounded app. Headroom is the feature. A chain that saturated the block would pass a bench test and fail on a random Tuesday night. ## Buffer sizes OEMs actually hand you Not every Android device wants 512 samples. Some OEM audio paths request 192 or 256 for their DSP route, and fighting the platform's preferred size costs you the low-latency path entirely. Estua adapts the requested buffer size at session start and keeps every piece of internal DSP state, filter memories and LFO phases included, continuous across the change, so a resize never produces a discontinuity click. Only the per-block bookkeeping changes. The synthesis state carries straight through. On the capture side, Sonarish's callback does one job: copy incoming PCM into the ring buffer and return. The FFT engine consumes that ring on a background queue and never runs on the audio thread, which is how the app computes a 4096-point transform while the meter updates at 30 Hz without glitches. Transform details live in [the FFT post](https://uranashel.com/blog/fft-made-readable.html). The architectural sentence worth memorizing is short: the audio thread copies samples and returns, and everything else happens elsewhere. ## Debugging the clicks that still happen Clicks still happen. When one appears we profile the audio thread directly: Instruments Time Profiler with the audio thread marked on iOS, Systrace with Trace.beginSection markers on Android. In our experience 99% of production clicks trace to debug-only code that leaked into the hot path — a String format for logging, a guard assertion that allocates, an accidental Swift optional unwrap that lands on a slow path. The DSP math is almost never the culprit. The scaffolding around it usually is. So every piece of debug instrumentation sits behind compile-time flags that we verify are off in release builds, and the final gate is ktuyen's overnight Estua soak test: 8 hours of continuous playback in airplane mode on real hardware before atuan tags any release. A click that survives review, the static checks, and a full night of playback has earned a bug report with its name on it. --- uranashel · [Home](https://uranashel.com/) · [Apps](https://uranashel.com/apps.html) · [Lab](https://uranashel.com/lab.html) · [About](https://uranashel.com/about.html) · [Blog](https://uranashel.com/blog/) · [Developers](https://uranashel.com/developers/) · [API docs](https://uranashel.com/docs/) · [Privacy](https://uranashel.com/privacy.html) Machine-readable: [llms.txt](https://uranashel.com/llms.txt) · [sitemap.xml](https://uranashel.com/sitemap.xml) · [openapi.json](https://uranashel.com/openapi.json) · [API](https://uranashel.com/api/v1/)