isper
← All posts
July 15, 2026·Lizhaowen

I Added 150ms of Latency on Purpose. It Fixed the Start Sound.

macOSAudioUXTiming

Xisper plays a short "Tink" sound when recording starts. It's an NSSound—the same system sound macOS plays for volume changes. It tells the user "I heard you, I'm recording now."

There's a problem: the app also mutes the system audio during recording, so the microphone doesn't pick up speaker output. Both the sound and the mute target the same output device. If the mute fires immediately, it cuts off the "Tink" before the user hears it. The user presses the button, hears silence, and doesn't know if recording started.

The fix was adding 150 milliseconds of delay before the mute:

if config.muteSystemDuringRecording {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
        AudioController.muteSystem()
    }
}

The interesting part isn't the code—it's one line. The interesting part is why 150ms and not some other number.


Why not 50ms?

The "Tink" sound is about 80ms long. A 50ms delay means the mute fires while the sound is still playing. The trailing decay gets clipped. Human ears are sensitive to this—it sounds like a truncated click, not a confirmation chime.

Why not 300ms?

300ms works. The sound plays fully, the mute fires cleanly, no clipping. But 300ms is perceptible. Between the button press and the mute, the user notices a gap. In a voice input app, the expectation is "I press the button, I start talking." A 300ms gap before the system goes silent feels like lag. The user has already started speaking by the time the mute kicks in.

Why 150ms?

I started at 300ms, confirmed the sound played cleanly, then binary-searched downward. 200ms—still clean. 150ms—still clean. 100ms—occasional clipping on the decay tail, about 15% of the time. The "Tink" sound's exact WAV file is 1,654 samples at 44.1kHz, which is ~37.5ms. But NSSound.play() has scheduling overhead: the sound is queued to the CoreAudio output unit, which has a render cycle. Between play() returning and the actual first sample hitting the DAC, there's typically 10–30ms of buffering. The 150ms accounts for 37.5ms of audio + 30ms of scheduling + ~80ms of safety margin.

Could I measure the exact output latency and set the delay dynamically? Yes, with AudioObjectGetPropertyData on the output device's kAudioDevicePropertyLatency. But the latency varies by device—Bluetooth headsets have 50–100ms of additional transport delay. A hardcoded 150ms covers all current hardware configurations and doesn't break if the latency changes.


The UX trade-off

Adding deliberate latency feels wrong as an engineer. Every instinct says "remove delay, optimize, make it faster." But this delay is about coordination, not speed. The mute and the sound share a resource (the output device). Without coordination, one wins and the other loses. With coordination, both work.

The user doesn't perceive the 150ms. What they perceive is the confirmation sound, followed by a silent recording environment. The alternative—immediate mute with no sound—feels broken. The delay makes it feel right.


The commit is in the Xisper repository: July 15, 2026. One line in RecordingCoordinator.swift. It's the smallest fix in this entire blog series, and it generated the most disproportionately positive user feedback of any change I made.

Try the feature this post describes

Xisper is a voice input app for macOS. Download it and see the result of everything we fixed.

Download Free