Skip to content
C.W.K.
Stream
Lesson 08 of 08 · published

The Phone Reads It to Your Ears

~13 min · audio-routing, watchos, ios, ownership

Level 0Muted
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"I can hear it, but the sound is coming from the watch. Not from the AirPods." — Dad

AirPods Have One Owner

A turn started on the wrist is a voice turn like any other: the phone sends it, the brain answers, and the phone gets her take from the voice engine. The old path then sent the take down to the watch, which played it. That worked on the watch's own speaker and in a pair of headphones connected to the watch. It failed in the most ordinary setup there is: AirPods in Dad's ears, connected to his iPhone. AirPods serve one device at a time, and the watch is not that device. The watch played her reply out of its own speaker, on his wrist, while his ears waited.

The first answers tried to win the AirPods from the wrist: a long-form audio session, a route switch, a "Try AirPods" button. Then a report from Dad's own watch settled it. The long-form session said yes and the route stayed on the watch's speaker, because watchOS now plays long-form audio on the watch's speaker too, and a pair connected to the phone can't be reached from the wrist at all.

Don't Solve It on the Watch

Dad's ruling came the same hour: "Don't try to solve it on the watch. Hand it to the iPhone and have the iPhone read it. Don't depend on automatic switching or anything like that; have the iPhone read it, when the AirPods are attached." It turns a hard question (how does a wrist reach headphones it doesn't own?) into an easy one (who owns them right now?), and the phone can answer that without asking anyone.

The Phone Reads, the Watch Keeps It Behind Play

Whenever the phone's output route is personal, meaning Bluetooth (A2DP, HFP or LE), wired headphones, USB audio or the car, the phone reads the wrist's reply itself, the way it reads its own. It does so whether or not a talk is open on the phone, and from the background too: there it takes a mixable session that ducks other audio, the only kind iOS lets a background app start, which matters because the phone is in a pocket. The phone's own speaker is not personal; a phone reading aloud in a pocket is not where Dad is listening, so then the wrist plays it as before.

The take still goes down to the watch, marked silent, so the watch keeps it behind its Play button for later instead of playing it over the phone. And the phone tells the wrist what it is doing, in three words: reading, read aloud, reading failed. The watch shows "Pippa is answering in your headphones, through your iPhone." while it waits, and the turn from the last lesson ends on the phone's word: read aloud is the end of the turn, and reading failed ends it with its reason on the screen, the words still there to read.

The phone declines in two situations, and both are about Dad, not the route. A phone already reading another reply leaves the new one to the wrist, talk or no talk. And while a talk is open on the phone, a phone in the middle of hearing him, recording or transcribing his words, leaves the reply to the wrist rather than cutting him off. A phone that was merely listening, with nothing heard yet, stops its ear the way a finished utterance does, reads, and listens again when the reading ends. Read the excerpt below for the order: with no talk open, the function returns before it ever looks at a recording or a transcription, so a clip Dad is dictating into the composer does not keep the reply off the phone.

No Second Path

One fix that evening added a safety net: if the phone's reading failed after the silent take had already gone down, the phone would send the take again with the voice on, and the watch would owe it. Dad's word was to drop the watch-to-AirPods path and add no new paths beyond that one experiment, and the net was exactly that, a new path. It went the same night. A reading that fails before the take is sent still leaves the take unsilenced, so the wrist plays it rather than nobody; that rule is older than the experiment and stays.

The words travel on a channel that can lose one, so the phone keeps its latest word about each wrist reply (the newest twenty) and says it again on every pull until the wrist stops asking. The turn ignores a reading it already knows of, so a repeated word never pushes the end of the turn later.

What Is Proven

The commits say it plainly: routing into AirPods needs Dad's devices, and the shipped shape was built from his device reports. The todos were closed on his word, the last one on the build that carries the one-turn watch. During bench runs a third claimant turned up, when a MacBook whose default output was the same AirPods played its simulator tests into them; that was not the phone, and it is a reminder that the AirPods have one owner at a time and that owner is not always the one you're testing.

Ask who owns the output, not how to reach it. When a device can't reach the speaker, the fix is often to hand the job to the device that already holds it.

Code

Where only Dad hears it: the personal routes (verbatim excerpt)·swift
// clients/apple/PippaMobile/Sources/PhoneAudioRoute.swift:1-21 (cwkPippa)
import AVFoundation

/// Whether the phone's sound goes somewhere only 아빠 hears it: headphones of
/// any kind, or the car. A voice-door reply from the wrist is read by the
/// phone only then (todo #356); the phone's own speaker in a pocket is not
/// where he is listening, and the wrist plays it instead.
enum PhoneAudioRoute {
    static let personal: Set<AVAudioSession.Port> = [
        .bluetoothA2DP, .bluetoothHFP, .bluetoothLE, .headphones, .carAudio, .usbAudio,
    ]

    static func isPersonal(_ outputs: [AVAudioSession.Port]) -> Bool {
        outputs.contains { personal.contains($0) }
    }

    /// The route right now. Readable without activating the session: it is
    /// where the phone's sound would go, AirPods in 아빠's ears included.
    static var headphonesNow: Bool {
        isPersonal(AVAudioSession.sharedInstance().currentRoute.outputs.map(\.portType))
    }
}
The phone takes the wrist's reply, or leaves it to the wrist (verbatim excerpt)·swift
// clients/apple/PippaMobile/Sources/AppModel.swift:3549-3583 (cwkPippa)
    func readWristReplyHere(replyId: String, wristId: String, text: String) -> Bool {
        guard PhoneAudioRoute.headphonesNow, !SpokenReply.spokenWords(text).isEmpty else { return false }
        guard voiceAudio.isOpen else {
            guard !voice.isReading else { return false }
            voiceNote = nil
            readForWrist(text: text, replyId: replyId, wristId: wristId)
            return true
        }
        guard !(speech.isRecording || transcribing) else { return false }
        switch handsFree {
        case .listening:
            guard heardSegments.isEmpty, liveTranscript.isEmpty else { return false }
            voiceAudio.forward = nil
            turnWindowWait?.cancel()
            turnWindowWait = nil
            quietWatch?.cancel()
            quietWatch = nil
            resumeCheck?.cancel()
            resumeCheck = nil
            resumeWatch.disarm()
            listener?.stop()
            listener = nil
            listeningLevel = 0
            handsFree = .waiting
        case .waiting:
            guard !voice.isReading else { return false }
        case .paused, .stopped:
            break
        case .off, .connecting:
            return false
        }
        voiceNote = nil
        readForWrist(text: text, replyId: replyId, wristId: wristId)
        return true
    }
The wrist hears the phone's word, and a repeat never moves the end (verbatim excerpt)·swift
// clients/apple/PippaMobile/Wrist/WristVoiceTurn.swift:149-170 (cwkPippa)
    /// The phone's word about its reading of a reply (todo #392). The phone
    /// says it again in every pull's answer until the wrist stops asking
    /// (todo #392, second pass), so a word that was lost the first time
    /// arrives on the next pull; a reading the turn already knows of is not
    /// started again, so the repeats never push its end later.
    mutating func phoneSaid(_ state: WristTurnState.State, turn: String) -> PhoneWord {
        guard phase == .thinking, turn == pending else { return .ignore }
        switch state {
        case .reading:
            guard !phoneReading else { return .ignore }
            phoneReading = true
            return .reading
        case .readAloud:
            end()
            return .over
        case .readingFailed:
            halt("The iPhone could not read her answer aloud; the words are on the screen.")
            return .over
        case .working, .queued, .unreachable, .answered:
            return .ignore
        }
    }

External links

Exercise

On an iPhone project of your own, print AVAudioSession.sharedInstance().currentRoute.outputs with each pair of headphones you have, with the car if you can, and with nothing connected. Write isPersonal for that list. Then design the three words a helper device should hear from the device that took its job, and decide what the helper does if it hears none of them for a minute.
Hint
The route can be read without activating the session, which is what lets the phone decide before it plays anything. For the silence case, never make the helper start playing on a timeout: the reading may simply have taken long. Ask again (a pull), and end the turn with a reason on the screen if the answer never comes.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.