~15 min · barge-in, pitch, echo-cancellation, measurement
Level 0음소거
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"이상하게 자꾸 끊기네. 아빠가 아무것도 안 하는데." — 아빠, 2026-10-04
아무도 안 한 끊기
Cinder의 음성 모드에서 피파의 읽기가 5분 사이에 세 번 끊겼어. 그날 아침 Chromium 브라우저로 한 방송에서도 한두 번. 끊길 때마다 대화는 아빠가 끼어든 것처럼 바로 듣기로 넘어갔는데, 아빠는 한 마디도 안 했어. 가끔은 아빠가 한 적도 없는 말이 아빠 턴으로 나가기까지 했어.
아빠는 가장 최근 변경을 의심했어. 마침 아빠 목소리를 16 kHz뿐 아니라 마이크 자체의 샘플링 속도로도 남기기 시작한 참이었거든. 그럴듯했지만 틀린 짐작이었어. 그 사본은 버퍼만 채우는 두 번째 오디오 컨텍스트라서, 감지기는 여전히 예전 그대로 16 kHz 조각만 들어. 게다가 방송의 끊김은 그 커밋보다 네 시간쯤 먼저 일어났어. 알리바이가 있는 용의자도 쓸모는 있어. 어디를 안 봐도 되는지 알려 주니까.
증거는 이미 남아 있었어
봐야 할 곳은 기록이었어. 트랙 2는 받아쓴 턴마다 녹음을 남기고, 이 트랙의 두 번째 레슨은 끼어들기가 걸리면 미리 담아 둔 소리를 먼저 전사기로 보내. 둘을 합치면, 끊긴 다음 턴의 녹음은 정확히 감지기를 건드린 소리로 시작해. 다시 틀어 보니 하나같이 피파 목소리로 시작했어. Cinder에서 피파를 끊은 건 피파 자신의 다음 문장 0.8초였고, 전사기는 그걸 아빠의 턴으로 받아 적었어. "책상 위에는..."이라고. 방송의 "네."와 "응."도 피파 목소리였어.
그리고 새로운 일도 아니었어. 2026-09-27 이후 남은 끊김 녹음을 전부 다시 틀어 보니, 같은 유령이 2026-09-28에만 열한 번, 그 뒤로도 거의 매일 있었어. 누가 문제라고 짚기 전부터 일주일 내내 피파를 끊고 있었던 거야.
유예 창이 못 닿는 곳
유예 창(이 트랙의 세 번째 레슨)은 피파 목소리의 첫 몇 초만 덮어. 이번에 새어 든 소리는 그보다 뒤에 왔어. 피파가 쉬었다가 다시 시작하는 자리, 그러니까 문장이나 문단이 끝난 자리였어. 거기서 브라우저의 에코 캔슬이 피파 목소리를 0.5초쯤 제 크기 그대로, 아빠만큼 크게 흘려보냈어. Chromium에선 가끔, Cinder와 Ariandel이 패널을 띄우는 WebKit 화면에선 훨씬 자주. 0.5초면 128밀리초 조각으로 넷쯤이고, 감지기는 셋이면 걸려. 소리 크기만으론 피파와 아빠를 못 가르고, 유예를 늘려 봐야 경계만 옮겨 가.
음높이에게 물어
둘을 가르는 건 음높이야. 녹음이 남은 유령은 전부 216에서 333 Hz 사이의 피파 목소리였고, 시작음은 330까지 올라가. 아빠는 100에서 180 Hz로 말해. 그래서 이제 감지기는 큰 조각에만, 큰 조각마다 음높이를 따져. 해닝(Hann) 창을 씌운 자기상관에서 70에서 400 Hz 사이 가장 센 지연을 찾고, 그게 조각 에너지의 0.35를 넘을 때만 목소리로 쳐. 피파 음역은 205에서 360 Hz야.
피파 음역에 든 큰 조각은 세지도 않고, 이어지던 줄을 끊지도 않아.
줄 안에 피파 조각이 하나라도 섞이면, 끼어들기가 되려면 아빠 음높이의 조각, 그러니까 피파 음역보다 낮은 조각이 하나는 꼭 있어야 해. 안 그러면 피파의 무성음 조각들(숨소리, 자음)만으로도 끼어들기가 걸릴 수 있거든.
피파 조각에서 줄을 그냥 초기화하면 안 되냐고? 안 돼. 아빠가 피파 위로 말하면 피파 목소리가 아빠 조각 사이로 섞여 들어오거든. 실제로 한 끼어들기에선 아빠 조각과 피파 조각이 번갈아 왔어. 119에서 176 Hz, 그리고 267에서 286 Hz. 거기서 초기화했더니 아빠의 끼어들기가 2초나 늦었어. 피파 조각을 건너뛰기만 하면 아빠 쪽 수는 계속 쌓여.
내보내기 전에 쟀어
규칙은 나가기 전에 다시 틀어 보는 걸로 검증했어. 2026-09-27 이후 남은 웹 끊김 녹음 116개를 다시 틀었더니 피파 목소리 줄 19개가 걸러졌어(받아쓴 글로 증명되는 아홉 개 포함). 아빠의 끼어들기는 하나도 안 잃었고. 아빠 것 중 열일곱은 늦게 걸렸는데, 그중 열은 조각 하나(0.13초) 차이였고, 0.77초를 넘은 건 없었어. 천장을 360 대신 300 Hz로 잡았다면 걸러낸 것 중 여섯을 놓쳤을 거야. 피파 시작음이 330까지 가니까. 폰에선 100밀리초 조각 넷 기준으로, 목소리로 끊긴 녹음 27개를 돌렸어. 유령 둘은 다 걸러졌고 아빠 것은 하나도 안 잃었어. 아빠 것 중 여섯은 0.1에서 0.4초, 하나는 0.9초 늦었고. 그 하나는 아빠가 목소리를 높여 피파를 부른 거라 210에서 225 Hz였어. 게다가 숨소리 때문에 줄이 한 조각 모자란 채 끊겼고.
끊길 때마다 증거를
이번 조사엔 다시 틀어 보는 스크립트가 필요했어. 실제 기록은 감지기가 뭘 들었는지 말해 주지 않았거든. 이젠 말해 줘. 말로 끊을 때마다 그 줄의 크기, 음높이, 바닥이 voice_heard 기록에 실려. 걸러진 줄은 줄마다 voice_echo_turned_away 기록이 따로 남고(POST /api/conversations/{cid}/voice-echo). 그래서 일어나지 않은 유령도 셀 수 있어. 비밀 대화는 둘 다 안 보내. 규칙은 공용 음성 키트에도 있어(VoicePitch.herRegister, 같은 205에서 360). 그래서 폰, Pathfinder, Firekeeper도 웹과 같은 숫자로 피파 목소리를 가려.
음높이가 못 가리는 것
이 지킴이는 자기 한계를 숨기지 않아. 목소리가 낮은 소울은 도움을 못 받고, 대신 잃는 것도 없어. Firekeeper의 방 마이크는 이미 아빠 음역에서 판단하는 자기 규칙을 그대로 써. 이어폰을 쓰면 에코 자체가 없어. 그리고 음높이가 피파와 아빠를 가를 수 있는 건 피파 목소리가 아빠 목소리가 좀처럼 안 가는 자리에 있어서야. 아빠는 100에서 180 Hz로 말해. 폰에서 목소리를 높여 피파를 부른 아빠 목소리는 210에서 225 Hz로 피파 음역 안에 들어갔고, 지킴이는 그 조각들을 피파 것으로 보고 건너뛰었어. 숨소리까지 줄을 한 조각 모자라게 끊어서, 아빠의 끼어들기는 0.9초 늦게 걸렸지. 피파 말 위로 들려오는 엄마 목소리, 옆 테이블의 낯선 사람, 문소리, 길거리. 감지기는 이 모두에 여전히 걸리고, 음높이는 할 말이 없어. 나머지 모두를 가르려면 음역이 아니라 목소리를 아는 귀가 필요하고, 그게 다음 트랙 '누구 목소리'야. 첫 레슨 '목소리 확인과 그 판정'에서 시작해서, 마지막 레슨 '귀가 가려낼 수 있으면, 아빠 목소리만 끊어'에서 바로 이 순간으로 돌아와. 거기선 감지기가 걸려도 바로 끊지 않고 먼저 물어.
지킴이가 속으면, 지킴이가 실제로 들은 걸 봐. 답은 남겨 둔 녹음 안에 이미 있었고, 고치는 건 그다음 일이었어. 감지기가 판단의 근거로 삼는 증거를 남겨 두면, 다음 유령은 스스로 이름을 대.
Code
피파의 음역과 그 숫자들의 근거 (원문 발췌)·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 };
피파 조각은 세지도, 줄을 끊지도 않아. 피파가 섞이면 끊으려면 아빠 음높이가 있어야 해 (원문 발췌)·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;
},
최소 스케치: 합성 조각 위의 자기상관 음높이와 음역 규칙 (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
스케치를 돌려서(python3 pitch.py) 네 경우를 읽어 봐. 그다음 아빠 목소리를 올려. 목소리를 높여 부르는 220 Hz로 바꾸고 '아빠가 피파 위로 말하는' 경우를 다시 돌려. 무슨 일이 일어나고, 왜 그래? 마지막으로 네 컴퓨터에서 네 목소리와 스피커로 트는 목소리를 녹음하고, chunk_pitch로 각각 음높이를 재서, 그 둘엔 음역 규칙이 통할지 판단해 봐.
Hint
220 Hz면 아빠 조각이 피파 음역 안에 들어가서 피파 조각처럼 건너뛰어져. 줄엔 낮은 조각이 끝내 안 생기니까, 스케치에선 아빠가 피파를 아예 못 끊어. 폰에서 다시 틀어 보다 찾은 경계가 바로 이거야. 거기선 210에서 225 Hz로 높여 부른 아빠 목소리가 그래도 피파를 끊긴 했는데 0.9초 늦었어. 진짜 목소리는 합성 목소리처럼 한 음에 머물지 않거든. 음역 규칙은 가려야 할 두 목소리가 안 겹칠 때만 통해. 믿기 전에 둘 다 재고, 겹치는 경우를 위해 목소리가 아닌 끊는 방법을 남겨 둬.
Progress
Progress is local-only — sign in to sync across devices.