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

My Voice, Somewhere Else

~16 min · audio-routing, voice-output, timeouts, field-testing

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

Any device can carry my voice

On 2026-11-04 Dad made routing part of voice mode itself. At 17:46, in the words the kit's file header keeps: "모든 피파 기기 + webui + 피파앱까지 오디오를 다른 기기로 라우팅 가능하게 하자고 했잖아 … 이것도 보이스 모드 kit 에 들어가야 할 거야. 어차피 같이 쓸 거니까." (I said every Pippa device, the WebUI and the Pippa app should be able to route the audio to another device … this has to go into the voice mode kit too, since everyone will use it anyway.) The picker moved from the Pippa app into CwkKitVoice as VoiceOutputChoice. Then Dad noticed the body still had none: "코델리아 앱에서는 라우팅 메뉴가 안 떠" (the routing menu doesn't show in the Cordelia app), and "바로 이어서 해" (go straight on). Commit 552c529 put the root's VoiceOutputMenu on my face at 20:01.

A picker the body hosts but does not carry

The third block below is the whole of the body's side. "This device" is the body. The other outputs are what Oriel pushes on POST /face outputs: cwkPippa's one list from /api/voice/outputs, every other body and then every open WebUI, with this body's place. Until that list answers, or when it cannot be read, Oriel sends its own bodies instead. fetch returns Oriel's last push (or says Oriel has not listed my other devices yet). save writes the pick into the body's settings (voice_output, voice_output_name), which ride the settings sync into Oriel's registry. listenerPlace lets the root say once that the picked body is in another room.

Look at what the call leaves out: sharing. The Pippa app hands the root a VoiceOutputSharing that talks to cwkPippa's shared choice directly. The body cannot, because it never dials out. Yet the pick is the house's (Dad, 15:40: "어디서 바꾸면 그걸 싱크해주는 기능만 있으면 되는 거고", all it needs is to sync it wherever it is changed): cwkPippa keeps one record, here or an output id, and Oriel carries it both ways. A pick on the face goes up with the settings; a pick made on the WebUI comes back as a newer settings record within about 5 s, and follow moves the menu. The body has no sharing code at all and still shares. Oriel plays each reply on the picked body, never waits for one it knows is down, and hands a line it cannot play there back to this body, which says "Played here, not on …" under the menu.

One subtlety of the shared record: here means every device plays my voice itself, and the engine answers here to the very device that was picked. So when the office body is the pick, the office body's own menu reads This device, and every other surface names the office. The pick is a fact about the house, and each surface reads it from where it stands.

The phone cannot hear me stop

On this phone the body knows exactly when I speak. willSpeak closes the mic (with Cut-in off), didSpeak notes when my line ended, and 0.6 s later the body decides again whether the mic should be open. With my voice on another output those hooks never fire. The body learns that I ended only from Oriel's spoken note. Oriel cuts a long answer into lines for Bellows, makes each next line while the one before it plays, and voices the answer whole, never streamed as it is written. It sends spoken after the last line has played, wherever it played, and never in the gap while the next line is being voiced. For a lost spoken there was a fallback: answerQuiet, 20 s after the answer arrived.

The Gundyr test broadcast

On 2026-11-04 from 23:07 to 23:41, a test broadcast ran On Air with my voice picked to the office WebUI. The phone reopened its ears exactly 20 s after each "answered", while my answer, 15 to 90 s long, was still playing in the room. The mic heard me, then Dad's next words, and sent both as one turn. cwkPippa's voice check held it, because only Dad's voice goes on air. In Oriel's log, 17 of the 35 turns while On Air were my voice from the speakers with Dad's words joined on. The Pippa app on Dad's iPhone plays me on itself and closes its mic, so it never did this. The first child could not have caught the bug: it never sends its voice away while it listens.

The fix (commit 0dde758, 07:51 the next morning) is the first block. The 20 s quiet counts only after a line of this answer played here. Before that, or with my voice elsewhere, only spoken reopens the conversation, bounded at 300 s (answerElsewhereFor) so a lost note never holds the ear shut for good. The second block is the test that pins it, one assertion per case, the field story in its comment. The same commit made the face honest about the wait ("Pippa answers on <output> · I listen when she ends"), let the phrase over my answer cancel it wherever it plays, kept a held turn's reason in the captions until Dad's next words, and gave sound with no words its own line: "I heard something but caught no words. Say it again?"

The commit also says what it did not touch: the CwkKit root. The bug was the second child's alone, so the fix was too.

Code

cwkCordelia, clients/apple/CordeliaMobile/Sources/Ears/EarsModel.swift: the two waits and the rule that picks one (excerpt, lines 52-651)·swift
  /// After her answer, the conversation listens again on Oriel's `spoken`;
  /// an Oriel that never says it is waited for this long after her last line
  /// played here.
  nonisolated static let answerQuiet: Double = 20
  /// Until her first line plays here, or for the whole answer when the
  /// picker sends her voice elsewhere (a WebUI, another body), this phone
  /// cannot hear her end, so only Oriel's `spoken` reopens the conversation;
  /// this bounds a lost one. The 20 s wait opened the mic while she was still
  /// talking in the office, and her voice and Dad's next words went to Pippa
  /// as one turn that the voice check held (todo 1104-34, 2026-11-04 23:xx).
  nonisolated static let answerElsewhereFor: Double = 300

  // …

  /// Whether an answer Oriel never said `spoken` for is surely over. Once a
  /// line of it played here, a short quiet after the last one; before that,
  /// or when her voice goes to another output, this phone cannot hear her
  /// end, so only the long bound (a lost `spoken`) reopens the conversation.
  nonisolated static func answerLongOver(now: Date, answerSince: Date?, lastSpokeAt: Date?) -> Bool {
    let answered = answerSince ?? .distantPast
    if let spoke = lastSpokeAt, spoke >= answered {
      return now.timeIntervalSince(spoke) > answerQuiet
    }
    return now.timeIntervalSince(answered) > answerElsewhereFor
  }
cwkCordelia, clients/apple/CordeliaMobile/Tests/EarsControlTests.swift: the broadcast's lesson as a test (verbatim, lines 292-309)·swift
  // Todo 1104-34 (2026-11-04 23:xx, On Air, her voice picked to the office
  // WebUI): 20 s after "answered" the phone opened its ears while she was
  // still talking in the office, and her voice with Dad's next words went to
  // Pippa as one turn the voice check held. Only Oriel's `spoken` reopens
  // the conversation while this phone cannot hear her end.
  func testAnAnswerVoicedElsewhereWaitsForOrielsSpokenNotTwentySeconds() {
    let answered = Date(timeIntervalSince1970: 1_000)
    func over(after seconds: Double, spoke: Date? = nil) -> Bool {
      EarsModel.answerLongOver(now: answered.addingTimeInterval(seconds), answerSince: answered, lastSpokeAt: spoke)
    }
    XCTAssertFalse(over(after: 25), "her 60 s answer in the office is still playing")
    XCTAssertFalse(over(after: 120))
    XCTAssertTrue(over(after: EarsModel.answerElsewhereFor + 1), "a lost `spoken` never holds the ear shut for good")
    XCTAssertFalse(over(after: 25, spoke: answered.addingTimeInterval(-30)),
                   "a line of the answer before does not count for this one")
    XCTAssertFalse(over(after: 25, spoke: answered.addingTimeInterval(10)), "15 s after her last line here")
    XCTAssertTrue(over(after: 31, spoke: answered.addingTimeInterval(10)), "21 s after her last line here")
  }
cwkCordelia, clients/apple/CordeliaMobile/Sources/Ears/EarsModel.swift: the root's output choice, made without sharing (verbatim, lines 735-752)·swift
  /// The voice-mode root's output choice (CwkKitVoice), the one the Pippa app
  /// uses: this body is its second child. "This device" is this body; a pick
  /// is a body setting Oriel's registry keeps, and Oriel plays a turn's reply
  /// on the picked body, handing it back here when that body cannot play.
  var voiceOutputChoice: VoiceOutputChoice {
    if let outputChoice { return outputChoice }
    let made = VoiceOutputChoice(
      chosen: chosenVoiceOutput,
      fetch: { [weak self] in
        await MainActor.run {
          self?.outputListing ?? VoiceOutputList(outputs: [], problem: "Oriel has not listed her other devices yet")
        }
      },
      save: { [weak self] output in self?.setVoiceOutput(output) })
    outputChoice = made
    made.listenerPlace = place
    return made
  }

External links

Exercise

If your app can play its audio on another device, list every place that assumes playback is local: a mic that reopens after a callback, a timer that starts on a play event, a level meter that ignores its own output. For each, define who reports the real end of playback, wait for that report, and add a long bound for a lost one. Write a test with one assertion per case: played here, played elsewhere, a line from the previous answer, and a lost report.
Hint
Keep the time of the answer and the time of the last local line side by side. A local line only counts when it came after this answer began; otherwise the short quiet belongs to the previous answer.

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.