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

피파 목소리 위로 아빠 목소리 듣기

~14 min · barge-in, detection, thresholds, pre-roll

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"소울 말 위로 말하면 소울이 멈추고, 아빠가 어디까지 들었는지 알게 돼." — 음성 모드 구축 3단계, 2026-09-26

단어가 아니라 소리 크기

캔슬러가 마이크에서 소울 목소리를 대부분 지우고 나면, 소울이 말하는 동안 남는 건 낮은 바닥이야. 에코의 찌꺼기와 방 소리. 아빠가 그 위로 말하면 그 바닥보다 한참 위로 튀어 올라. 감지기는 단어가 아니라 소리 크기만 봐. 단어는 전사기에서 1초쯤 늦게 오고, 1초 기다리는 끼어들기는 무시당하는 느낌이거든.

웹 감지기

웹에선 마이크가 16 kHz로 2048 샘플짜리 조각을 줘. 하나에 128밀리초야. 감지기는 소울이 말하기 시작한 뒤 첫 다섯 조각으로 바닥을 배우고, 파열음 하나에 휘둘리지 않게 그 중앙값을 써. 그 뒤론 바닥의 3.5배와 절대값 700 RMS 중 큰 쪽을 연속 세 조각이 넘으면 아빠로 쳐. 약 0.4초 이어지는 소리야. 기침이나 달그락 소리는 무시할 만큼 길고, 바로 반응한다고 느낄 만큼 짧아. 바닥은 절대 굳지 않아. 조용한 조각마다 바닥을 지금 레벨 쪽으로 살짝 당겨서, 긴 답 도중에 소울 목소리가 커지든 작아지든 따라가. 폰도 같은 생각을 자기 값으로 돌려. 100밀리초 조각 넷.

끼어든 첫마디를 잃지 마

감지기가 확신할 즈음이면 아빠는 이미 한두 마디를 했고, 듣기 소켓은 아직 안 열렸어. 소울이 말하는 동안 소리는 감지기 말고는 아무 데도 안 가니까. 그래서 루프는 소울이 말하는 동안 1초쯤 되는 소리를 굴려 가며 붙잡아 두고, 소켓이 다시 열리는 동안 들어온 것도 다 챙겨. 끼어들기가 걸리면 그 소리가 먼저 전사기로 가서, 아빠의 첫마디가 안 사라져.

끼어드는 길은 하나

소울을 끊는 방법은 여럿이야. 위로 말하기, 얼굴 탭하기, 마이크 누르기. 클라이언트마다 이걸 끼어들기 함수 하나로 보내서, 어떤 방식으로 끊든 같은 일을 같은 순서로 해. 재생 멈추기, 아빠가 어디까지 들었는지 알리기, 듣기 시작하기. 답이 아직 흘러나오는 중에 끊으면, 들은 위치를 맞는 메시지에 적으려고 답의 진짜 메시지 id가 올 때까지 기다렸다가 알려.

설계엔 넷째 방법도 있어. 소울이 말하는 중에 메시지 보내기. 그런데 이 퀘스트를 쓰는 지금, 웹은 아직 그걸 그 길로 안 보내. 보내기 길은 소울을 멈추고 보내기만 하고 아무것도 안 알려서, 그 답의 기록엔 아빠가 전부 들은 걸로 남아. 공용 길로 들어오는 문 하나를 빠뜨리는 게 바로 규칙 하나가 동작 둘로 갈라지는 방식이야. 잡는 방법은 끊는 방법을 전부 돌려보고 하나하나가 위치를 알리는지 확인하는 테스트야.

기준값은 첫 짐작이야

위의 숫자는 전부 벤치에서 고른 거고, 설계도 그렇다고 말해. 아빠의 실제 스피커와 방이 다른 답을 주기 전까지 기준값은 첫 짐작이야. 그래서 클라이언트마다 끄는 스위치가 있어. 웹 얼굴 화면의 "Cut in" 토글, 그리고 폰의 '피파 말 위로 말해서 끼어들기' 설정. 자기 스피커에 반응하는 감지기는 없는 것만 못하고, 그걸 안고 사는 사람은 배포 없이 끌 수 있어야 해.

Code

움직이는 바닥을 가진 소리 크기 감지기, 그리고 첫마디를 지키는 미리 담기·python
from collections import deque
from dataclasses import dataclass, field
from statistics import median


@dataclass
class BargeInDetector:
    """Dad talking over her, heard as loudness on the open, echo-cancelled mic."""
    calibrate: int = 5        # chunks that learn the floor: her echo + the room
    sustain: int = 3          # loud chunks in a row that count as Dad
    ratio: float = 3.5        # how far above the floor
    minimum: float = 700.0    # and never below this RMS
    seen: list = field(default_factory=list)
    floor: float | None = None
    loud: int = 0

    def reset(self) -> None:
        self.seen, self.floor, self.loud = [], None, 0

    def feed(self, level: float) -> bool:
        if self.floor is None:
            self.seen.append(level)
            if len(self.seen) >= self.calibrate:
                self.floor = median(self.seen)
            return False
        if level > max(self.floor * self.ratio, self.minimum):
            self.loud += 1
            return self.loud >= self.sustain
        self.loud = 0
        self.floor = self.floor * 0.9 + level * 0.1   # keep following the quiet
        return False


PREROLL_CHUNKS = 8            # about a second at 128 ms per chunk
preroll: deque = deque(maxlen=PREROLL_CHUNKS)
detector = BargeInDetector()

her_echo = [320, 410, 380, 350, 400, 900, 360, 390]     # one loud blip: a plosive
dad = [2400, 2800, 2600, 2500]
for t, level in enumerate(her_echo + dad):
    preroll.append((t, level))                          # the words that cut in, kept
    if detector.feed(level):
        print(f"cut in at chunk {t} ({t * 0.128:.2f}s): stop her, report heard, listen")
        print("  preroll sent to the transcriber first:", [c for c, _ in preroll])
        break
else:
    print("no cut-in")

External links

Exercise

코드를 돌려서 끼어들기가 어디서 걸리고 미리 담기가 어느 조각을 넘기는지 봐. 그다음 에코를 더 크게 해서(her_echo 값을 전부 3배로) 다시 돌려. 감지기가 여전히 아빠에게 걸려? 소울에게 걸린 적은? ratio와 minimum을 조정해서 아빠에겐 0.5초 안에 걸리고 에코엔 절대 안 걸리게 만들고, 그 대가로 뭘 포기했는지 적어.
Hint
minimum을 올리면 큰 에코는 막지만 조용히 끼어드는 말은 잡기 어려워져. sustain을 올리면 튀는 소리는 막지만 모든 끼어들기가 늦어져. 어디서나 이기는 설정은 없어. 그래서 기준값은 첫 짐작이고, 방이 정하고, 끄는 스위치가 있는 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

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

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