A tiny macOS menu bar app that delays all system audio by an adjustable 0–500 ms, so sound stays in sync when video goes through a high-latency path (wireless HDMI → projector) while audio plays through a low-latency one (USB interface → speakers).
Everything the Mac plays — Plex, browsers, anything — is delayed. You tune the amount by ear with a slider until lips match voices.
macOS system output is pointed at BlackHole, a free virtual audio device. AudioDelay reads the audio back out of BlackHole, holds it in a ring buffer for the delay you set, and plays it out your USB interface. Both devices are wrapped in one Aggregate Device so a single real-time IO proc handles the whole path.
System audio → BlackHole (virtual) → AudioDelay ring buffer → USB interface → speakers
brew install blackhole-2ch(or use the installer from existential.audio — either way macOS will ask for your password because it installs an audio driver).
- Open Audio MIDI Setup (in /Applications/Utilities).
- Click + in the bottom-left → Create Aggregate Device.
- In the device list on the right, check BlackHole 2ch and your USB interface.
- Set the Clock Source to your USB interface.
- Enable Drift Correction on BlackHole 2ch. Don't skip this — the two devices have independent clocks and will drift audibly apart over a two-hour movie without it.
- Rename the aggregate to something identifiable, e.g. Delay Bridge (double-click the name in the sidebar).
System Settings → Sound → Output → BlackHole 2ch.
(Or hold ⌥ and click the volume icon in the menu bar.)
./build.sh
open build/AudioDelay.appClick the ⏱ in the menu bar:
- Pick your aggregate device under Aggregate Device (it auto-picks an aggregate containing BlackHole if you don't).
- Click Enable Delay. macOS will ask for microphone access — this is BlackHole presenting as an input device; the app reads system audio from it and records nothing. If you deny it, the app gets silence; there's a menu item to reopen the privacy setting.
- Drag the delay slider (or type a value) while a video plays until lips sync. Steps of 1 ms, changes are live and click-free.
- The Volume slider below it controls loudness in the delay path (squared taper, smoothed so it never clicks) — handy since the keyboard volume keys don't work with BlackHole.
The delay value, chosen device, and enabled state persist across relaunches.
- Your keyboard volume keys will stop working while output is set to BlackHole — it has no hardware volume, and macOS routes those keys to the output device. Use the Volume slider in the app's menu instead (applied in the delay path, click-free, persisted) or the physical knob on your interface.
- To get normal audio back (e.g. laptop speakers), just switch Sound Output back to your usual device. No need to quit the app, though disabling it releases the audio hardware entirely.
- Sleep/wake is handled — the app re-establishes the audio path after the Mac wakes.
- If the USB interface is unplugged, the app stops cleanly and shows an error in the menu; re-enable after replugging.
- Sample rate changes are handled by restarting the path automatically.
Print every audio device, its streams, and how the app would map an aggregate's channels:
.build/release/AudioDelay --list-devicesRuntime events (device loss, sample-rate changes, processor overloads) go to the unified log:
log stream --predicate 'process == "AudioDelay"'Requires macOS 13+ and the Xcode Command Line Tools (xcode-select --install). No full Xcode, no dependencies.
./build.sh # → build/AudioDelay.appTo keep it around, copy build/AudioDelay.app to /Applications and add it to Login Items if you want it at startup.
- Pure CoreAudio (
AudioDeviceCreateIOProcID) — no AVAudioEngine, so the latency path is explicit. - The IO proc is real-time-safe: preallocated ring buffer, no allocation/locks/ObjC in the callback; the delay value crosses threads through a C11 atomic.
- Delay changes crossfade over ~15 ms between the old and new read positions, so adjusting during playback never clicks.
- Channel mapping is discovered from the aggregate's sub-device list at start, never hardcoded, and logged so it can be verified.