~18 min · on-the-wrist, audio, state-machine, privacy, airpods
Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"핸즈프리를 워치로 쓴다는 게 진짜 핸드폰에서 쓰듯이 그렇게 되지를 않으니까 이걸 수동으로 그냥 한 턴씩 끊는 걸로 만드는 게 맞는 것 같아."
받아쓰기 화면이 맞지 않는 문일 때
받아쓰기 레슨의 문은 담기엔 딱 맞아. 시스템 화면이 열리고, 말하면, 글자로 돌려받아. 손을 안 쓰고 말로 대화하려면 얘기가 달라. watchOS엔 그 화면 바깥에서 앱이 직접 돌릴 수 있는 음성 인식기가 없고, 워치엔 Speech 프레임워크도 없고, 어차피 워치는 엔진에 닿지도 못해. 그래서 대화 앱은 일을 나눠. 손목은 녹음하고, 폰은 들어. 워치는 AVAudioRecorder로 녹음하고(AAC, 16kHz 모노, 레벨 측정 켬), 말이 끝났는지도 스스로 판단해. 처음 0.5초는 방 소음을 재는 데 써. 그 바닥보다 10dB 넘게 크고 -45dB보다 큰 소리면 말로 쳐. 말이 들린 뒤 3초 조용하면 발화가 끝난 거야. 말소리가 0.4초도 안 되면 "보낼 게 없음"이야. 파일은 transferFile에 초인종을 곁들여 폰으로 가고, 폰은 손목에서 고른 언어로 받아 적어. 답장은 다른 답장이랑 똑같이 돌아와.
루프는 저절로 열리는 문이야
처음 만든 핸즈프리는 폰 걸 그대로 따라 했어. 듣고, 보내고, 답을 듣고, 다시 들어. 그런데 손목에선 마이크로 돌아가는 길이 하나하나 엉뚱한 말이 들어오는 길이었어. 세비야의 어느 아침, 루프가 6분 동안 턴 다섯 개를 녹음해서 보냈는데 그중 둘은 주인 말이 아니었어. 578자짜리 답장을 폰이 2분쯤 읽어줬는데, 폰이 읽기 시작했다고 한 지 90초 만에 워치가 다시 듣기 시작했고, 피파 답장의 둘째 문단을 주인의 턴으로 보내버렸어. 나중엔 근처에 있던 누군가의 질문도 보냈고. 다른 날엔 "Thinking"에서 아예 돌아오지 않았어. 주인은 핸즈프리가 꺼져 있다고 믿고 있었어. 그래서 나온 결정은 주인 몫이었어. 한 번 누르면 한 턴.
이건 버그 수정이 아니라 설계 결정이야. 혼자 다시 열리는 마이크는 언젠가 방금 자기가 틀어준 답장을 듣고, 옆 사람 말을 듣고, 있어선 안 될 자리의 소리를 들어. 루프를 빼면 셋이 한꺼번에 사라져. 루프를 아무리 다듬어도 그건 못 해.
어떻게 끝나든 쉬는 상태로
턴은 작고 순수한 상태 기계야. 폰 테스트 묶음에도 같이 들어가서, 워치 없이도 규칙을 붙잡아 둬. 시작(동작 버튼의 컨트롤, 컴플리케이션, 탭)은 토큰을 하나 달라고 해. 마이크를 여는 건 그 토큰뿐이고, 그것도 오디오 세션이 실제로 올라왔을 때 내밀어야 해. 발화가 폰으로 떠나면 그 턴에선 마이크가 아주 닫혀. 답장이 재생되고 나면 턴은 off로 돌아가. 다른 식으로 끝나도 마찬가지야. 답장이 실패하든, 목소리가 늦든, 폰의 읽기가 끝나든, Stop을 누르든, 앱을 떠나든 결과는 off나 stopped(이유)이고, 절대 listening이 아니야. 끝날 때마다 세대 번호가 올라가니까, 권한이나 오디오를 기다리던 시작이 뒤늦게 도착해도 아무것도 안 열려. 턴이 진행 중일 때 들어온 시작은 무시해. 꺼져 있던 앱이 켜질 땐 컨트롤, 실행 요청, URL이 한꺼번에 시작을 요청할 수 있어서 이게 중요해. 그리고 피파가 생각하는 동안엔 탭해도 아무 일도 없어. 다음 턴은 다시 누르는 거야.
루프 갈래는 플래그 뒤에 숨긴 게 아니라 지웠어. 플래그를 두면 문이 코드에 남고, 다음 변경이 언젠가 그 문을 찾아내거든.
끄면 꺼진 거야
턴은 시작부터 끝까지 앱의 런타임 세션 하나를 쥐고 있어서, 손목을 내려도 살아남아. 앱을 떠나는 건 달라. 앱이 더 이상 맨 앞이 아니라서 시스템이 세션을 끝냈거나, 세션 없이 앱이 백그라운드로 갔으면 턴이 끝나. 앱이 다시 켜질 때 같이 깨어나는 마이크는 착용자가 참여한 대화가 아니니까. 폰도 같은 의무를 져. 핸즈프리를 멈추면, 읽기가 끝날 때 다시 듣게 하는 연결 고리도 같이 지워. 그래서 대화가 닫힌 뒤에 끝난 읽기는 아무것도 열지 않아.
누가 말할지는 귀가 정해
AirPods는 한 번에 기기 하나에서만 재생해. 주인이 폰에서 음성 대화를 열어둔 동안엔 AirPods가 폰 거였고, 그래서 워치에서 재생한 답장은 워치 스피커로 나왔어. 이제는 폰이 개인 경로(블루투스, 유선 이어폰, 자동차)를 쥐고 있으면 폰이 손목의 답장을 직접 읽어줘. 워치한텐 읽는 중이라고 알리고, 워치로 가는 목소리 사본엔 '조용히' 표시를 붙여. 워치는 그걸 Play 버튼 뒤에 둬. 턴은 폰이 읽기가 끝났다고 말할 때 끝나. 그 말은 워치가 그만 물을 때까지 폰이 되풀이해. 아니면 읽기 길이에 맞춘 대비 타이머가 다 됐을 때 끝나. 폰 쪽엔 플랫폼 규칙 두 개가 걸려 있었어. 백그라운드에서 읽기를 시작하려면 앱이 아직 돌고 있어야 하고, iOS는 안 돌던 앱의 시작을 거절했어("no assertions to start mixable playback"). 또 내비게이션 안내가 .spokenAudio 세션을 끊어버려서, 이제 백그라운드 읽기는 다른 소리와 섞이고 안내가 나올 땐 소리를 줄여 그 밑으로 깔려. 워치에서 폰의 AirPods로 답장을 틀어보려던 두 번째 길은 주인이 막았어. 길은 하나, 귀를 쥔 기기야.
순수 상태 기계로 쓴 음성 턴 하나: 살아 있는 시작만 마이크를 열고, 어떻게 끝나든 쉬는 상태·swift
import Foundation
/// One voice turn on the wrist. Nothing opens the microphone but `audioUp` with the token a
/// `begin` handed out, and every way a turn ends is .off or .stopped, never .listening.
struct VoiceTurn: Equatable {
enum Phase: Equatable { case off, listening, thinking, speaking, stopped(String) }
private(set) var phase: Phase = .off
private(set) var pending: String? // the utterance this turn waits on
private var generation = 0 // every end voids every start still on its way
private var launch: Int?
var micOpen: Bool { phase == .listening }
private var idle: Bool {
if case .stopped = phase { return true }
return phase == .off
}
/// A press. nil while a turn is already up or starting: on a cold launch the control,
/// a launch request and a URL can all ask at once.
mutating func begin() -> Int? {
guard launch == nil, idle else { return nil }
generation += 1
launch = generation
return generation
}
/// The audio session is up. False if the turn was stopped meanwhile: then nothing opens.
mutating func audioUp(_ token: Int) -> Bool {
guard launch == token, token == generation else { return false }
launch = nil
phase = .listening
return true
}
/// The utterance left for the phone: the microphone is closed for the rest of this turn.
mutating func sent(_ id: String) {
guard phase == .listening else { return }
pending = id
phase = .thinking
}
mutating func replyPlaying(_ id: String) {
guard phase == .thinking, id == pending else { return }
phase = .speaking // pending keeps the id: only this reply can end the turn
}
/// This turn's reply played out, the phone finished reading it, or a fallback timer ran out.
/// An older reply's end, arriving late, is not this turn's end.
mutating func replyOver(_ id: String) {
guard id == pending, phase == .thinking || phase == .speaking else { return }
end()
}
/// Stop, Close, or leaving the app.
mutating func stop() { end() }
mutating func halt(_ reason: String) {
end()
phase = .stopped(reason)
}
private mutating func end() {
generation += 1
launch = nil
pending = nil
phase = .off // never .listening: the next turn is another press
}
}
손목이 직접 판단하는 말끝: 방 소음을 재고, 조용한 3초를 기다리기·swift
import Foundation
/// Fed one meter reading (in dB) every 100 ms while the turn records.
struct EndOfSpeech {
var quiet: TimeInterval = 3 // silence after speech that ends the utterance
var calibrate = 5 // the first half second measures the room
var margin = 10.0 // speech is this far above the room...
var minimum = -45.0 // ...and never quieter than this
private(set) var speechSeconds: TimeInterval = 0
private var readings: [Double] = []
private var floor: Double?
private var lastSpeech: TimeInterval?
private var lastReading: TimeInterval?
/// true when the utterance is over. Under 0.4 s of speech, the caller sends nothing.
mutating func feed(_ db: Double, at t: TimeInterval) -> Bool {
let step = lastReading.map { max(0, t - $0) } ?? 0
lastReading = t
guard let room = floor else {
readings.append(db)
if readings.count >= calibrate { floor = readings.sorted()[readings.count / 2] }
return false
}
if db > max(room + margin, minimum) {
speechSeconds += step
lastSpeech = t
return false
}
floor = room * 0.95 + db * 0.05 // quiet readings keep the floor honest as the room drifts
if let last = lastSpeech, t - last >= quiet - 0.001 { return true } // 100 ms steps sum to 2.999...
return false
}
}
폰 테스트가 닿는 타깃에 VoiceTurn을 넣고 규칙 넷을 증명해. 첫째, 어떤 끝 경로든 .off나 .stopped로 끝나. 둘째, begin()과 audioUp 사이에 stop()이 오면 아무것도 안 열려. 셋째, 턴 도중의 두 번째 begin()은 nil을 돌려줘. 넷째, sent 뒤엔 어떤 호출을 이어 붙여도 micOpen이 다시 true가 되지 않아. 녹음해둔 레벨 기록을 EndOfSpeech에 먹여서 어디서 끝나는지도 확인해. 그다음 진짜 워치에서 턴 하나를 끝내고, 라디오나 옆 사람 대화를 2분 동안 틀어둬. 아무것도 녹음되거나 보내지지 않았는지 확인해.
Hint
넷째 규칙엔 속성 기반 테스트가 좋아. 바꾸는 호출들을 무작위로 이어 붙이고, 새 begin과 audioUp 없이 micOpen이 true가 되는 일이 없는지 단언해. 기기 확인에선 워치 화면만 보지 말고, 폰 로그에 들어온 파일이 있는지 읽어.
Progress
Progress is local-only — sign in to sync across devices.