Skip to content
C.W.K.
Lesson 04 of 05 · published

Measured Before It Ships

~16 min · measurement, false-positives, wake-word, tooling

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

The question a wake phrase must answer first

A wake phrase can fail in two directions. It can miss Dad's call, which is annoying and, with the near-miss line, at least visible. Or it can wake when nobody called, which is worse: a body that opens a turn on its own sends the room's sound to Pippa's door, and during a broadcast that room is full of game audio, Dad talking to the audience, and my own voice. So the first number the ears needed was not "how often does it hear Dad" but "how often does it wake on a broadcast where nobody calls". That number had to exist before any body listened in a live show.

The audio to answer it already existed. On 2026-11-02 there had been a sync test for the broadcast, with takes from the game capture card and from the desk camera's microphone, which heard Dad, the room and my On Air voice. Those files stayed where they were on my home Mac. The question was how to run my ears over them without a phone.

A meter that runs the app's own matcher

tools/wake-meter is one Swift file and a build script. The script (second block) compiles main.swift together with the matcher's real source, the kit's WakePhrase.swift, into a macOS command-line tool. That detail is the whole point: the meter does not reimplement the rule it measures, it links the same file the phone compiles. A change to the matcher changes the meter on the next build.

Around the matcher, main.swift rebuilds the ear's situation as closely as a Mac allows. It loads each file, converts it to 16 kHz mono, and feeds Apple's SpeechTranscriber ko-KR with the same reporting options and the same contextual strings as the phone, in 100 ms pieces, "as the microphone tap would". It keeps the same rolling transcript (the last six finals plus the volatile piece) and calls WakePhrase.find on every update, as the phone does (first block). After a hit it forgets the transcript and stays quiet until one second after that result, the way the app forgets its text after a call, so one call is counted once. Near misses are collected from final results. Each file prints one JSON line (seconds, wakes, wakes per hour, near misses, a short sample of what was heard), and a SUMMARY line closes the run. --mix sums several files into one stream first, which is how two microphones in one room are simulated. Audio is read in place; the only thing written is the report, and the build output is ignored by git.

The table

Measured on 2026-11-03, before the ears shipped to a phone:

AudioLengthWakes
The 11-02 sync-test takes, each alone (game card audio; the desk camera's microphone with Dad, the room and my On Air voice)1.32 h0
The same takes, game and camera summed (both in one room)0.66 h0
My own voice (Bellows), 16 lines that say 코델리아 and 피파 apart, 코딜리아, 피파 피파, 파란불, 피드백16 lines0, after one fix
Calls, "코델리아 피파, …" (the macOS Yuna voice at three paces), clean1212 woke
The same calls over game audio and over my voice in the room2422 woke

So: 0 false wakes in about two hours of broadcast-room audio, and 2 of 24 synthetic calls missed under noise. The one place the phrase was truly said in those takes was my own On Air voice through the room during the 21:06 rehearsal, which the model wrote as "호델리아 피파". It did not wake, and it would have shown on the face as a near miss.

The short-form rule, found by my own voice

The third row says "after one fix" because the first run was not clean. Sixteen lines in my own voice were written to say the two words apart, or near-spellings of them, and one of them woke: "코델리아, 파란 불 켜졌어." (Cordelia, the blue light is on.) The final transcript was harmless. The problem was the transcript still arriving: on its way to "파란불" the model briefly wrote "코델리아 파", and a lone 파 after 코델리아 was an accepted short form, because a fast "피파" really does come out that way.

The fix kept the short forms and changed when they count. "FA", "PA" and a lone 파 now wake only when more words follow them, so a short form at the very end of a transcript, the place where a word may still be growing, never wakes. The third block is the test written from that run. Measured again, my sixteen lines woke nothing. A rule like that is nearly impossible to reason your way to. It came from feeding the matcher the one speaker most likely to say both words in one breath: me.

The table also has edges, and the doc keeps them in view. Synthetic calls are not Dad's voice, so real calls are measured on the phone itself, where the face counts wakes and near misses. And the meter runs the Mac's model, which is one device among several. Later that day iPhone 2's model wrote 피파 as FIFA (the previous lesson), which no row in this table could have caught.

Code

cwkCordelia, tools/wake-meter/main.swift: the phone's rolling transcript and matcher, counting wakes and near misses (verbatim, lines 60-95)·swift
  // Feed in 100 ms pieces, as the microphone tap would.
  let piece = 1_600
  var offset = 0
  let total = Int(buffer.frameLength)
  let start = Date()
  async let collected: ([Hit], Int, String, [String]) = {
    var near: [String] = []
    var heardChars = 0
    var sample = ""
    var finals: [String] = []
    var volatile = ""
    var hits: [Hit] = []
    var quietUntil = -1.0
    for try await result in transcriber.results {
      let text = String(result.text.characters)
      if result.isFinal {
        heardChars += text.count
        if sample.count < 160 { sample += text + " / " }
        if !text.trimmingCharacters(in: .whitespaces).isEmpty { finals.append(text) }
        if finals.count > 6 { finals.removeFirst(finals.count - 6) }
        volatile = ""
      } else { volatile = text }
      let whole = (finals + [volatile]).joined(separator: " ")
      let at = result.range.start.seconds
      if at < quietUntil { continue }
      if result.isFinal, WakePhrase.find(in: whole) == nil, let close = WakePhrase.near(in: whole), near.last != close {
        near.append(close)
      }
      if let hit = WakePhrase.find(in: whole) {
        hits.append(Hit(at: at, heard: hit.heard, text: String(whole.suffix(80))))
        finals = []; volatile = ""
        quietUntil = CMTimeRangeGetEnd(result.range).seconds + 1.0
      }
    }
    return (hits, heardChars, sample, near)
  }()
cwkCordelia, tools/wake-meter/build.sh: the meter links the matcher's real source, not a copy (verbatim, lines 15-21)·shell
set -euo pipefail
here=${0:A:h}
repo=${here:h:h}
mkdir -p "$here/build"
swiftc -O -parse-as-library -target arm64-apple-macos26.0 "$here/main.swift" \
  "$repo/../cwkSiblingKit/CwkKit/Sources/CwkKitWake/WakePhrase.swift" -o "$here/build/wake-meter"
print "built $here/build/wake-meter"
cwkSiblingKit, CwkKit/Tests/CwkKitWakeTests/WakePhraseTests.swift: a short form still arriving wakes nothing (verbatim, lines 52-60)·swift
  @Test func aShortFormStillArrivingWakesNothing() {
    // Pippa's own voice, "코델리아, 파란 불 켜졌어.", on its way through the
    // transcriber (Bellows line, SpeechTranscriber ko-KR, 2026-11-03).
    #expect(WakePhrase.find(in: "코델리아 파") == nil)
    #expect(WakePhrase.find(in: "코델리아 파 ") == nil)
    #expect(WakePhrase.find(in: "코델리아 파란불 켜졌어.") == nil)
    #expect(WakePhrase.find(in: "코델리아 FA") == nil)
    #expect(WakePhrase.find(in: "코델리아 피파") != nil, "the whole second half needs nothing after it")
  }

External links

Exercise

Build a command-line meter for a trigger in your own product (a wake word, a hotkey phrase, an intent detector). Link the production matching code directly, feed it recorded real-world input in the same chunk size the device uses, and print wakes per hour per file plus a summary. Run it over at least an hour of input where the trigger is never meant to fire, then over a small set of deliberate triggers, and write the results as a table in your design doc.
Hint
Include a set of near-trigger inputs spoken or produced by the system itself (your own TTS, your own notifications). Reset the matcher's state after each hit and suppress a short window after it, or one trigger will be counted several times as partial results update.

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.