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

Only Dad's Voice Cuts Her Off, When the Ear Can Tell

~15 min · barge-in, speaker-verification, fail-safe, ducking

Level 0Muted
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"Cutting in used to work really well, but it feels a little oversensitive now. Seeing it get cut off by noise and things even when I didn't cut in…" — Dad

Back to the Detector

Track 7 built the barge-in detector, and it hears loudness: on the web, three 128-millisecond chunks in a row above the larger of 3.5 times the floor and 700 RMS. Its last lesson, Her Own Voice Is Not Dad's, gave it a pitch guard, and that guard turns away exactly one voice, hers. A door, dishes, a street, Mom or a stranger trips it as surely as Dad, and every trip used to stop her mid-sentence. This track has spent three lessons building an ear that knows whose voice it hears. Dad chose to put that ear in front of the detector.

A Trip Is a Hold, Not a Cut

A trip no longer cuts. Her take drops to a quarter of its volume, the open microphone keeps every chunk, and one second later (eight more chunks) the latest eighteen chunks, about 2.3 seconds, go to POST /api/conversations/{id}/cut-in-check: the second before the trip, the loud run, and the second after it. The speaker ear names the voices in that clip, and the policy rules on it. Ducking instead of stopping is the whole trick: she is quiet enough that Dad can talk over her, and still playing in case it wasn't him.

Only Dad Cuts, When the Ear Answers

The ruling is cut_in_decision. A voice the ear names as Dad's, sure or unsure, cuts. So does any voice that scores 0.20 or more against Dad's with no other enrolled voice scoring higher. No speech in the clip, Mom, a stranger and her own voice coming back all leave her reading at full volume again.

Why 0.20 and not the turn's 0.45? A second of Dad is too little for the bands a whole turn is ruled by. The line and the wait were measured on 132 past web cut-ins whose next turn kept the pre-roll:

Heard after the tripDad's lineDad's cut-ins caught on the first askHer echo left reading
0.5 s0.2556 of 8228 of 37
1.0 s0.2075 of 8222 of 37
1.5 s0.2077 of 8219 of 37

Her echo is often followed by Dad starting to talk, so a longer wait rightly cuts some of those for his voice. One second was the knee. And the Mom guard exists because of one measurement: Mom's own enrollment recording scored 0.20 against Dad's voice. Cut into 21 two-second windows, 7 of them reached Dad's line, so the line alone would let her cut her daughter off. With the guard, another enrolled voice scoring higher means not Dad, and all 21 left her reading.

Never Stranded

Dad must always be able to cut in, so every way the ear can fail falls back to what every trip did before the ear existed: cut. The server waits 2.0 seconds on Bellows and then answers cut with reason unchecked; the client stops waiting at 2.5 seconds and cuts with reason timeout; an unreachable ear, a check that's switched off, or a family with no Dad voice enrolled all cut too. A trip only releases the detector's run (release()) and keeps the floor it learned, because recalibrating there would learn the floor from Dad's own voice. After a not Dad, trips are ignored for one second, so a noisy room isn't asked about every half second and Dad talking on is asked again with more of his voice.

The price is honest and small: Dad's own cut-in now stops her about 1.5 seconds after he starts instead of about 0.4. His words aren't lost to it, because the open mic kept every chunk from before the trip and they're sent as his turn.

The Record

Every ruling writes a voice_cut_in_check line to the conversation's log: cut or not, the reason, the voices, how long the ear took, and where in the reply it happened. A private talk is checked too and keeps nothing. Building it exposed a second blind spot: some stops had no cut-in behind them at all. Those now leave a voice_reading_stopped line with the cause (audio_error, play_refused, left_conversation, stream_reset), and a cut-in on a reply still queued under its placeholder id is reported at once instead of waiting for a message change that never came.

Every Surface, One Kit

The web hold lives in lib/cutInHold.ts and runs in the main chat and every sidekick panel. The native talks share CwkKitVoice's CutInHold and CutInCheck: the phone, Pathfinder and Firekeeper. The kit counts seconds of samples, not chunks, and keeps 0.6 s before the trip where the web keeps a second, so the native ear hears about 1.6 s. Pathfinder drops a hold when one of its own guidance lines cuts her, because the road is not Dad talking. Firekeeper's voice cut-in stays off by default, and the hold runs only where Dad turns it on.

Two gaps are still written down: the phone still drops the heard report of a reply read under its request id before the server id arrives, and no native talk reports voice_reading_stopped yet; the web closed both. And two people talking at once still aren't separated.

That closes the track. The microphone hears everyone, and it always will. What changed is who it believes: pitch turns away her own voice, the ear names everyone else, Dad's corrections and harvested stretches teach it Mom, a noisy-place mode peels the room apart when he asks, and now only one voice decides a turn, and when the ear answers in time, only one voice cuts her off.

Code

The cut-in ruling, verbatim (cwkPippa backend/services/speaker_check.py:944-984)·python
CUT_IN_TIMEOUT_S = 2.0
# A cut-in is about a second of Dad's speech, too little for the bands a whole
# turn is ruled by: on 132 past web cut-ins (§7.5) his voice scored 0.20 or
# more on 91% of them with a second heard after the trip, while other voices
# topped out near 0.24 on two-second chunks. Missing Dad costs more than a
# rare stranger cutting her off, as every loud run did before.
CUT_IN_DAD_LINE = 0.20


def cut_in_decision(heard: Mapping[str, Any] | None) -> tuple[bool, str]:
    """Whether a loud run on the open mic while she spoke may cut her off
    (Dad, 2026-10-08: noise was cutting her off). Only Dad's voice cuts:
    named his, sure or unsure, or scoring at least ``CUT_IN_DAD_LINE``
    against his voice. A stranger, Mom, her own voice coming back and a room
    with no speech in it leave her reading. Without Dad's enrolled voice the
    ear cannot tell, and every trip cuts, as before."""
    if not heard or DAD_PROFILE not in (heard.get("profiles") or []):
        return True, "unchecked"
    voices = [v for v in heard.get("voices") or [] if isinstance(v, Mapping)]
    if not voices:
        return False, "no_speech"
    if any(_is_dad(v) or _leans_dad(v) for v in voices):
        return True, "dad"
    return False, "not_dad"


def _leans_dad(voice: Mapping[str, Any]) -> bool:
    """Dad's score reaches the cut-in line and no other enrolled voice scores
    higher. Mom's own recording scored 0.20 against Dad's voice (2026-10-08),
    so the line alone would let her cut her daughter off."""
    scores = voice.get("scores")
    if not isinstance(scores, Mapping):
        return False
    dad = scores.get(DAD_PROFILE)
    if not isinstance(dad, int | float) or dad < CUT_IN_DAD_LINE:
        return False
    return all(
        not isinstance(score, int | float) or score <= dad
        for profile, score in scores.items()
        if profile != DAD_PROFILE
    )
The web hold's timings, verbatim (cwkPippa frontend/src/lib/cutInHold.ts:28-41)·typescript
/** Measured on 132 web cut-ins since 2026-09-27 (docs/PIPPA-SPEAKER-SEPARATION.md
 *  §7.5): with half a second after the trip the ear caught 68% of Dad's real
 *  cut-ins on the first ask, with a full second 91%. A miss is asked again
 *  when he keeps talking. */
export const CUT_IN_HOLD = {
  /** Chunks heard after the trip before the ear is asked: 1 s at 128 ms. */
  afterChunks: 8,
  /** What the ear is sent: the latest chunks, the run among them, ~2.3 s. */
  sendChunks: 18,
  /** An ear slower than this cuts. */
  timeoutMs: 2500,
  /** After a "not Dad", trips are ignored this long. */
  cooldownMs: 1000,
} as const;
A minimal sketch: one trip held, asked and settled, with the ruling copied from cut_in_decision (type checks trimmed), the timeout and the cooldown·python
# A barge-in trip held and asked, in miniature. The ruling is cwkPippa's
# cut_in_decision; the hold follows the web's cutInHold timings.
DAD_PROFILE = "dad"
CUT_IN_DAD_LINE = 0.20
TIMEOUT_S = 2.5        # the client cuts if the ear is slower than this
COOLDOWN_S = 1.0       # after "not Dad", trips are ignored this long


def is_dad(v):
    return v.get("profile_id") == DAD_PROFILE and v.get("band") in ("match", "uncertain")


def leans_dad(v):
    scores = v.get("scores") or {}
    dad = scores.get(DAD_PROFILE)
    if dad is None or dad < CUT_IN_DAD_LINE:
        return False
    return all(s <= dad for p, s in scores.items() if p != DAD_PROFILE)


def cut_in_decision(heard):
    if not heard or DAD_PROFILE not in heard.get("profiles", []):
        return True, "unchecked"
    voices = heard.get("voices") or []
    if not voices:
        return False, "no_speech"
    if any(is_dad(v) or leans_dad(v) for v in voices):
        return True, "dad"
    return False, "not_dad"


def hold(trip_at, answer, answer_after_s, quiet_until=0.0):
    """One trip: duck to a quarter, hear one more second, ask, settle."""
    if trip_at < quiet_until:
        return "ignored (cooling down)", quiet_until
    if answer_after_s > TIMEOUT_S:
        cut, reason = True, "timeout"
    else:
        cut, reason = cut_in_decision(answer)
    settled = trip_at + 1.0 + min(answer_after_s, TIMEOUT_S)
    if cut:
        return f"cut at {settled:.1f}s ({reason})", 0.0
    return f"reads on at full volume at {settled:.1f}s ({reason})", settled + COOLDOWN_S


profiles = ["dad", "mom"]
cases = [
    ("Dad, a short unsure cut-in", {"profiles": profiles, "voices": [{"profile_id": None, "band": "none", "scores": {"dad": 0.23, "mom": 0.08}}]}, 0.3),
    ("Mom, near Dad's line", {"profiles": profiles, "voices": [{"profile_id": None, "band": "none", "scores": {"dad": 0.20, "mom": 0.24}}]}, 0.3),
    ("a door slams", {"profiles": profiles, "voices": []}, 0.2),
    ("the ear is slow", {"profiles": profiles, "voices": []}, 4.0),
    ("no Dad voice enrolled", {"profiles": ["mom"], "voices": []}, 0.2),
]
for name, answer, after in cases:
    outcome, _ = hold(10.0, answer, after)
    print(f"{name:<28} -> {outcome}")

# Dad keeps talking after a "not Dad": the next trip inside the second waits.
_, quiet = hold(10.0, cases[2][1], 0.2)
print(f"{'a trip 0.5 s after it':<28} -> {hold(quiet - 0.5, cases[0][1], 0.3, quiet)[0]}")
print(f"{'a trip 1.5 s after it':<28} -> {hold(quiet + 0.5, cases[0][1], 0.3, quiet)[0]}")

# Output:
# Dad, a short unsure cut-in   -> cut at 11.3s (dad)
# Mom, near Dad's line         -> reads on at full volume at 11.3s (not_dad)
# a door slams                 -> reads on at full volume at 11.2s (no_speech)
# the ear is slow              -> cut at 13.5s (timeout)
# no Dad voice enrolled        -> cut at 11.2s (unchecked)
# a trip 0.5 s after it        -> ignored (cooling down)
# a trip 1.5 s after it        -> cut at 14.0s (dad)

External links

Exercise

Run the sketch, then add your own barge-in to it. Take the detector from Track 7 (or your own), and instead of cutting on a trip, call hold() with a stub ear that answers after a delay you control. Replay three runs: a door slam, Dad cutting in, and Dad cutting in right after a not-Dad on a slammed door. Measure how long each takes to cut or to resume full volume, and find the delay at which Dad's cut-in becomes a timeout cut instead of a dad cut.
Hint
Anything slower than the 2.5 s timeout becomes a cut with reason timeout: Dad still gets through, he just loses the ear's record of who it was. The cooldown is what keeps a noisy room from being asked about over and over; the cost is that Dad talking into that second waits for the next trip, which is why the detector keeps its floor and his continued voice trips it again moments later.

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.