All field notes

Playing silence: keeping a run/walk coach talking without stomping your music

A Couch-to-5K app has to say 'Run' and 'Walk' on time with the screen off and the phone in your pocket — while your music keeps playing. Here's the iOS audio-session gauntlet behind that, and the two ways we accidentally paused everyone's Spotify.

RunWalk is a Couch-to-5K coach. The whole job is small: every 60 or 90 seconds, a voice says “Run” or “Walk.” The catch is when it has to say it — screen off, phone in your pocket, Spotify playing, you not looking. That one sentence hides most of the engineering, and almost all of it is in the audio session. Here's what it actually takes.

Problem one: staying alive

iOS suspends a backgrounded app within seconds. Your timer stops with it. There's exactly one sanctioned way for a fitness app to keep running with the screen off — the audio background mode — and it only counts while audio is actually playing.

So we play nothing. An inaudible WAV, looped forever.

// iOS keeps a backgrounded app running only while audio is "playing."
// So we play silence.
func startKeepAlive() {
    if keepAlive == nil,
       let url = Bundle.main.url(forResource: "silence", withExtension: "wav") {
        keepAlive = try? AVAudioPlayer(contentsOf: url)
        keepAlive?.numberOfLoops = -1   // loop the silence forever
    }
    keepAlive?.play()
}

Pair that with UIBackgroundModes: [audio, location] in the Info.plist — audio so the silent loop keeps us alive, location so a phone-only run can still trace your GPS route while the screen is dark — and the interval timer keeps firing in your pocket. It feels like a hack because it is one, but it's the hack Apple documents.

Problem two: not stomping the music

Playing silence is easy. Playing silence next to Spotify without touching it is where the afternoons go.

The baseline is a .playback session with .mixWithOthers, in .default mode — not .voicePrompt. Mode matters: activating a .voicePrompt session politely ducks other audio for you, which sounds right until you realize it dips the user's music for the entire 30-minute run.

func activateSession() {
    try? AVAudioSession.sharedInstance()
        .setCategory(.playback, mode: .default, options: [.mixWithOthers])
    try? AVAudioSession.sharedInstance().setActive(true)
}

Ducking belongs around the cue, not the session. We turn .duckOthers on just before an utterance and take it back off the moment it finishes — so your music dips for the word “Run,” not for the run.

func speak(_ cue: Cue) {
    setDuck(true)                       // [.mixWithOthers, .duckOthers]
    synth.speak(utterance(for: cue))
}
 
func speechSynthesizer(_ s: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) {
    guard !s.isSpeaking else { return } // more cues queued? keep ducking
    setDuck(false)                      // restore the music
}

The isSpeaking guard is load-bearing: back-to-back cues (“Halfway there,” then “Walk”) shouldn't un-duck and re-duck between words.

The two ways we paused everyone's Spotify

Both were about acquiring the audio hardware at the wrong moment.

AVAudioEngine. The obvious tool for the keep-alive loop is AVAudioEngine. But engine.start() interrupts other audio when it spins up — so every single time you pressed Start, Spotify paused. AVAudioPlayer honors .mixWithOthers and just plays its silent loop without interrupting anything. We switched, and the pause went away.

Preparing too early. Even with AVAudioPlayer, constructing the keep-alive player at launch paused Spotify — the instant you opened RunWalk, before you'd started anything. The reason: prepareToPlay() grabs the audio hardware, and at launch the session is still its default, non-mixing category. Just readying the player was enough to stomp the music. The fix is unglamorous: create the keep-alive player lazily, in startKeepAlive(), after the session has already been set to .mixWithOthers.

Neither of these throws an error. Neither shows up in a unit test. You find them by running with Spotify on and watching it pause, which is exactly the thing a simulator won't do for you.

When the world interrupts

A phone call, Siri, or an alarm deactivates your session and pauses the silent loop — which means the timer would quietly stall mid-run. So we listen for it, and when the interruption ends and the system says we may resume, we bring everything back and re-announce where you are:

case .ended:
    guard opts.contains(.shouldResume) else { return }
    activateSession()
    startKeepAlive()          // resume the silence, or the timer stalls
    reannounceCurrentPhase()  // you were mid-interval — say the cue again

That last line matters more than it looks. If a call swallowed your “Run” cue, you come back to the app not knowing whether you should be running or walking. Re-speaking the current phase on resume is the difference between a bug report and a non-event.

A small courtesy

Not everyone wants a synthetic voice barking “Walk” out of the phone speaker on a quiet street. So there's a headphones-only option, which just asks the session what it's routed to:

var headphonesConnected: Bool {
    let ok: Set<AVAudioSession.Port> = [.headphones, .bluetoothA2DP,
        .bluetoothHFP, .bluetoothLE, .airPlay, .carAudio, .usbAudio]
    return AVAudioSession.sharedInstance().currentRoute.outputs
        .contains { ok.contains($0.portType) }
}

Recorded coaches ride the same path — a bundled clip per cue, falling back to the synthesizer if a clip is missing, so the app never goes silent at the one moment it's supposed to talk.

The payoff

Screen dark, phone in your pocket, music playing: a voice still says “Walk” at the exact second the interval flips, your music dips for that one word and comes right back, and a phone call halfway through doesn't leave you stranded between intervals. None of it is visible, which is the point.

Keeping the timer alive and talking is only half of it, though. Keeping it correct — dead-on across a crash, a relaunch, or a Watch that reconnects mid-run — is a different trick entirely: one wall-clock anchor both devices derive from, so nothing can drift.