Her reading stopped three times in five minutes in Cinder's voice mode, and once or twice in that morning's broadcast in a Chromium browser. Each time the talk went straight to listening, as if Dad had cut in, and each time he hadn't said a word. Sometimes a turn went out anyway, in his name, with words he never said.
Dad suspected the newest change: his voice had just started being kept at the microphone's own rate as well as at 16 kHz. It was a fair guess and a wrong one. That copy is a second audio context that only fills a buffer; the detector still hears the same 16 kHz chunks it always did, and the broadcast's cuts came about four hours before that commit. A suspect with an alibi is still useful. It tells you where not to look.
The Evidence Was Already Kept
Where to look was in the record. Track 2 keeps every dictated turn's recording, and this track's second lesson sends the pre-roll to the transcriber first when a cut fires. Put together, the kept recording of the turn after a cut starts with exactly what tripped the detector. Played back, each one started with her voice. In Cinder the burst that cut her was 0.8 seconds of her own next sentence, and the transcriber wrote it down as Dad's turn: "책상 위에는...". The broadcast's "네." and "응." were her voice too.
And it was not new. Replayed over every kept recording of a cut since 2026-09-27, the same phantom showed on 2026-09-28 eleven times and on most days after. It had been cutting her off for a week before anyone could name it.
Where the Grace Doesn't Reach
The grace window (this track's third lesson) covers her first seconds. These leaks came later: where she starts again after a pause, at a sentence or paragraph break, the browser's echo cancellation let her through at full loudness, as loud as Dad, for half a second or so. Chromium does it now and then; the WebKit view that Cinder and Ariandel embed the panel in does it far more often. Half a second is about four chunks at 128 milliseconds, and the detector needs three. Loudness alone can't tell her from him, and a longer grace would only move the edge.
Ask the Pitch
What does tell them apart is pitch. Every phantom whose recording was kept is her voice at 216 to 333 Hz, her onsets reaching 330; Dad speaks at 100 to 180. So the detector now asks the pitch of each loud chunk, and only of a loud one: a Hann-windowed autocorrelation, the strongest lag between 70 and 400 Hz, counted as voiced only when it holds more than 0.35 of the chunk's energy. Her register is 205 to 360 Hz.
A loud chunk in her register neither counts nor breaks the run.
Once one of hers is in the run, a cut-in also needs a chunk at Dad's pitch, below her register. Without that, her unvoiced chunks (a breath, a consonant) could add up to a cut on their own.
Why not simply reset the run at her chunk? Because Dad talking over her leaks her into his chunks. On one real cut-in his chunks alternated with hers, 119 to 176 Hz with 267 to 286, and a reset there held his cut-in back two seconds. Skipping her chunk keeps his count going.
Measured Before It Shipped
The rule was replayed before it shipped. Over the 116 kept web recordings of a cut since 2026-09-27: 19 runs of her voice turned away, the nine whose transcript proves it among them, and none of Dad's cut-ins lost. Seventeen of his land later, ten of those by one chunk (0.13 seconds), none by more than 0.77. A ceiling of 300 Hz instead of 360 would have lost six of the turn-aways, because her onsets reach 330. On the phone, with its 100-millisecond chunks, four in a row, over its 27 kept recordings of a cut by voice: both phantoms turned away, none of Dad's lost, six later by 0.1 to 0.4 seconds and one by 0.9, where his raised call to her sat at 210 to 225 Hz and a breath ended the run one chunk short.
Evidence on Every Cut
The investigation needed a replay script because the live record didn't say what the detector heard. Now it does. Every cut by talking carries the run's levels, pitches and floor into its voice_heard line, and every run turned away writes a voice_echo_turned_away line of its own (POST /api/conversations/{cid}/voice-echo), so the phantoms that did not happen can be counted too. A private talk sends neither. The rule itself lives in the shared voice kit as well (VoicePitch.herRegister, the same 205 to 360), so the phone, Pathfinder and Firekeeper judge her voice by the same numbers as the web.
What Pitch Can't Tell
The guard is honest about its edges. A soul with a low voice gets no help from it, and loses nothing. Firekeeper's room microphone keeps its own rule, which already judges in Dad's band. Headphones remove the echo altogether. And pitch can tell her from Dad only because her voice sits where his rarely goes: he speaks at 100 to 180 Hz, and when his raised call on the phone climbed to 210 to 225, inside her register, the guard skipped those chunks as hers, and with a breath ending the run one chunk short, his cut-in landed 0.9 seconds late. Mom talking over her, a stranger at the next table, a door, a street: the detector still trips on all of them, and pitch has nothing to say. Telling everyone else apart takes an ear that knows voices, not registers, and that is the next track, Whose Voice. It begins with The Speaker Check and Its Verdict, and it comes back to this exact moment in its last lesson, Only Dad's Voice Cuts Her Off, When the Ear Can Tell, where a trip stops being a cut and becomes a question.
When a guard is fooled, look at what the guard actually heard. The kept recordings already held the answer; the fix was the second step. Keep the evidence the detector acts on, and the next phantom names itself.
Code
Her register and the numbers behind it (verbatim excerpt)·typescript
// frontend/src/lib/voiceTurn.ts:403-417 (cwkPippa)
/** Her voice's pitch range (todo #314, 2026-10-04). Echo cancellation lets her
* through at full loudness for half a second or so where she starts again
* after a pause: now and then in Chromium, often in a WebKit host (Cinder,
* Ariandel). Every phantom cut-in whose recording was kept is her own voice
* at 216-333 Hz (her onsets reach 330), Dad speaks at 100-180 Hz, and Scribe
* then wrote her words as his turn ("책상 위에는...", "응.", "네.", "안녕히
* 계세요."). Replayed over the 116 kept recordings of a web cut since
* 2026-09-27 (§10.3): 19 runs of her voice are turned away, the nine whose
* transcript proves it among them; no cut-in of Dad's is lost, and 17 land
* later, 10 of them by one chunk (0.13 s), none by more than 0.77 s. A soul
* with a low voice gets no help from this, and loses nothing. */
export const HER_REGISTER_HZ: readonly [number, number] = [205, 360];
/** Web chunks are 2048 samples at 16 kHz (128 ms): ~0.4 s of speech. */
export const WEB_BARGE_IN: BargeInOptions = { calibrate: 5, sustain: 3, ratio: 3.5, minimum: 700, register: HER_REGISTER_HZ };
Her chunk neither counts nor breaks the run; with her in it, a cut needs Dad's pitch (verbatim excerpt)·typescript
// frontend/src/lib/voiceTurn.ts:501-526, inside createBargeInDetector().feed (cwkPippa)
if (level > Math.max(floor * options.ratio, options.minimum)) {
const register = options.register;
const hz = register && pitch ? pitch() : null;
if (run.levels.length < HEARD_RUN_LIMIT) {
run.levels.push(Math.round(level));
run.pitches.push(hz === null ? null : Math.round(hz));
}
// Her voice neither counts nor breaks the run: Dad talking over
// her leaks her into his chunks (2026-10-02, alternating 119-176 Hz
// with 267-286 Hz), and a reset there held his cut-in back 2 s.
if (register && hz !== null && hz >= register[0] && hz <= register[1]) {
hers = true;
return false;
}
loud += 1;
if (register && hz !== null && hz < register[0]) low = true;
// With her voice in the run, its unvoiced chunks could add up to a
// cut-in on their own: it takes a chunk at Dad's pitch.
if (loud < options.sustain || (hers && !low)) return false;
tripped = true;
return true;
}
endRun();
floor = floor * 0.9 + level * 0.1;
return false;
},
A minimal sketch: autocorrelation pitch and the register rule on synthetic chunks (python3 pitch.py)·python
from __future__ import annotations
import math
import random
RATE = 16000
CHUNK = 2048 # 128 ms, the web's chunk
HER_REGISTER = (205.0, 360.0) # Hz: her voice coming back from the speaker
def chunk_pitch(samples: list[float]) -> float | None:
"""Hann-windowed autocorrelation's strongest lag between 70 and 400 Hz,
or None when it holds no more than 0.35 of the energy (unvoiced, noise)."""
n = len(samples)
shortest, longest = RATE // 400, RATE // 70
if n <= longest:
return None
mean = sum(samples) / n
x = [(s - mean) * (0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1))) for i, s in enumerate(samples)]
energy = sum(v * v for v in x)
if energy <= 0:
return None
best, best_lag = -math.inf, shortest
for lag in range(shortest, longest):
total = sum(x[i] * x[i + lag] for i in range(n - lag))
if total > best:
best, best_lag = total, lag
return RATE / best_lag if best / energy > 0.35 else None
def rms(samples: list[float]) -> float:
return math.sqrt(sum(s * s for s in samples) / len(samples))
class Detector:
def __init__(self, register: tuple[float, float] | None,
calibrate: int = 5, sustain: int = 3, ratio: float = 3.5, minimum: float = 700.0) -> None:
self.register, self.calibrate, self.sustain = register, calibrate, sustain
self.ratio, self.minimum = ratio, minimum
self.seen: list[float] = []
self.floor: float | None = None
self.end_run()
def end_run(self) -> None:
self.loud, self.low, self.hers = 0, False, False
def feed(self, samples: list[float]) -> bool:
level = rms(samples)
if self.floor is None:
self.seen.append(level)
if len(self.seen) >= self.calibrate:
self.floor = sorted(self.seen)[len(self.seen) // 2]
return False
if level > max(self.floor * self.ratio, self.minimum):
hz = chunk_pitch(samples) if self.register else None
if self.register and hz is not None and self.register[0] <= hz <= self.register[1]:
self.hers = True # her voice: neither counts nor breaks the run
return False
self.loud += 1
if self.register and hz is not None and hz < self.register[0]:
self.low = True # a chunk at Dad's pitch
return self.loud >= self.sustain and (not self.hers or self.low)
self.end_run()
self.floor = self.floor * 0.9 + level * 0.1
return False
rng = random.Random(7)
def voice(hz: float, amplitude: float) -> list[float]:
"""A voiced chunk: a fundamental and two harmonics, a little noise."""
return [amplitude * (math.sin(2 * math.pi * hz * t / RATE) + 0.5 * math.sin(4 * math.pi * hz * t / RATE)
+ 0.25 * math.sin(6 * math.pi * hz * t / RATE)) + rng.gauss(0, amplitude * 0.05)
for t in range(CHUNK)]
def noise(amplitude: float) -> list[float]:
return [rng.gauss(0, amplitude) for _ in range(CHUNK)]
quiet = [noise(150) for _ in range(6)] # her echo and the room, cancelled
her = voice(270, 2500) # she starts again after a pause
dad = voice(140, 2500)
hiss = noise(2500) # loud, but no pitch at all
print("pitch of her leak:", round(chunk_pitch(her) or 0), "Hz | Dad:", round(chunk_pitch(dad) or 0),
"Hz | hiss:", chunk_pitch(hiss))
runs = {
"her voice leaks for half a second": [her] * 4,
"Dad talks over her, her leak between": [dad, her, dad, her, dad],
"her leak, then loud hiss with no pitch": [her, hiss, hiss, hiss],
"Dad alone": [dad] * 3,
}
for name, run in runs.items():
results = []
for register in (None, HER_REGISTER):
detector = Detector(register)
cut = None
for i, chunk in enumerate(quiet + run):
if detector.feed(chunk):
cut = i
break
results.append("no cut" if cut is None else f"cut at chunk {cut}")
print(f"{name:40} loudness only: {results[0]:14} with her register: {results[1]}")
# Output:
# pitch of her leak: 271 Hz | Dad: 140 Hz | hiss: None
# her voice leaks for half a second loudness only: cut at chunk 8 with her register: no cut
# Dad talks over her, her leak between loudness only: cut at chunk 8 with her register: cut at chunk 10
# her leak, then loud hiss with no pitch loudness only: cut at chunk 8 with her register: no cut
# Dad alone loudness only: cut at chunk 8 with her register: cut at chunk 8
Run the sketch (python3 pitch.py) and read the four runs. Then move Dad up: give his voice 220 Hz, a raised call, and run 'Dad talks over her' again. What happens, and why? Finally, record yourself and a speaker playing a voice on your own machine, measure the pitch of each with chunk_pitch, and decide whether a register rule could work for that pair.
Hint
At 220 Hz Dad's chunks fall inside her register, so they are skipped like hers, the run never gets its low chunk, and in the sketch he never cuts her off at all. That is the edge the phone's replay found: his raised call at 210 to 225 Hz still cut her off there, but 0.9 seconds late, because a real voice doesn't hold one pitch the way a synthetic one does. A register rule works only when the two voices you need to tell apart don't overlap; measure both before you trust it, and keep a non-voice way to interrupt for the case where they do.
Progress
Progress is local-only — sign in to sync across devices.