본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

피파 목소리는 아빠가 아니야

~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

External links

Exercise

스케치를 돌려서(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.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.