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

목소리 확인과 그 판정

~16 min · speaker-verification, whose-voice, embeddings, policy

Level 0음소거
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"어차피 파형으로 가려낼 수도 있을 텐데... 오디오적으로 처리가 가능할 거야." — 아빠

마이크는 모두를 들어

트랙 7은 음높이로 끝났어. 피파 음역에 든 큰 조각은 스피커에서 되돌아온 피파 목소리고, 그건 절대 아빠로 안 쳐. 그 장치가 아는 목소리는 하나뿐이야. 피파 자신. 엄마도, 관광 가이드도, 식당 직원도 몰라. 아빠가 엄마와 여행하면서 길에서, 카페에서, 투어 무리 속에서 피파한테 소리 내어 말하니까, 마이크는 그 사람들을 전부 들었어. 그리고 전사기가 적은 말은 하나도 빠짐없이 아빠 말로 들어왔지. 대화 안에선 가이드의 한 문장과 아빠의 승인이 똑같이 생겼어.

이건 전사기가 못 고쳐. Scribe 실시간 소켓엔 화자 구분이 아예 없고, 배치 호출의 화자 구분은 상대 라벨만 줘. 화자 1, 화자 2. 아빠라고는 절대 안 해. 글자만으론 누구 말인지 몰라. 아빠는 소리로는 알 수 있을 거라고 봤고, 그게 맞았어.

녹음은 이미 와 있어

서버에서 확인하는 게 싸게 먹힌 건 트랙 2에서 내린 결정, 아빠 목소리를 남긴다는 결정 덕이야. 받아쓴 턴은 전부 채팅 요청에 남겨 둔 녹음의 이름을 달고 오니까, 턴이 저장되기 전에 소리는 이미 office에 있어. 채팅 라우트 일곱 개(Claude와 변형 여섯)가 전부 사용자 행을 쓰기 전에 그 녹음을 목소리 귀로 보내. 듣기 소켓이 받은 16 kHz 테이크가 있으면 그걸 보내.

일은 두 집으로 나뉘어. 귀는 cwkBellows 거야. 모델, 화자 프로필(소리가 아니라 임베딩으로만 보관), 그리고 /api/speaker/ 아래 라우트들. 정책은 cwkPippa 거야. services/speaker_check.py에서 판정이 턴에 뭘 허락하는지, 저장되는 메시지가 뭐라고 적히는지, 소울이 뭘 듣는지 정해. 귀는 목소리에 이름을 붙일 뿐이야. 턴이 뭘 해도 되는지는 절대 안 정해.

귀가 듣는 법

녹음을 16 kHz 모노로 풀고 앞 120초만 봐. Silero VAD가 말소리를 찾고, 말소리를 2초 안팎의 조각으로 잘라. 조각마다 NVIDIA TitaNet-large가 임베딩을 만들어. torch 없이 sherpa-onnx로 CPU에서 돌아. 가장 가까운 등록 프로필과의 코사인 점수가 0.30에 닿으면 그 사람 이름을 붙여. 한 사람 조각 둘 사이에 낀 외톨이 조각은 그 사람 걸로 쳐. 다른 사람과 확실히 맞은 조각만 빼고. 같은 이름이 이어진 묶음은 목소리 하나로 쳐. 그 말소리를 최대 30초까지 모아 다시 임베딩하고 점수를 매겨. 0.45 이상이면 match, 0.25부터는 uncertain, 그 아래는 아무도 아니야. 말소리가 1초도 안 되면 0.60은 넘어야 match야.

모델은 아빠 데이터로 골랐어. 남겨 둔 아빠 턴 769개, 그리고 다른 사람 49명의 녹음 227개.

모델아빠 중앙값다른 목소리 최댓값
3D-Speaker CAM++0.790.40
WeSpeaker ResNet340.940.88
3D-Speaker ERes2NetV20.880.53
TitaNet-large0.840.21

봐야 할 건 제일 높은 중앙값이 아니야. 간격이야. TitaNet은 다른 목소리를 전부 0 근처에 뒀어. 확인은 공짜고 office 안에서 끝나. 4초 턴에 0.08초, 110초 턴에 2.1초. 처음 설계는 pyannote로 분할하고 군집을 돌렸는데, 재 보고 버렸어. 더 느렸고, 우리가 묻는 건 누구 목소리냐인데 그건 목소리가 몇 개냐에 답하거든.

판정 다섯 가지

판정언제턴은 어떻게 돼
dad1초 이상 말한 목소리가 전부 아빠고 그중 하나는 확실할 때그대로. 블록도 없어.
uncertain아빠 목소리 같은데 확실하진 않을 때, 아니면 아무도 판정 못 할 한두 마디일 때글은 그대로. 블록이 이렇게 일러. 답은 하되, 결정이나 승인이나 명령을 실행하기 전엔 "아빠가 말한 거 맞아?"부터 물어.
mixed아빠와 다른 누군가가 둘 다 말했을 때단어 타임스탬프를 받는 유료 Scribe 패스 한 번으로 줄마다 [Dad], [Mom], [someone else]를 붙여.
other아빠 목소리가 없고, 다른 목소리가 2초 이상이거나 다른 등록 목소리와 확실히 맞을 때저장되는 메시지가 [Mom] …이나 [someone else] …가 돼. 그 안의 말은 아무것도 못 정해.
unchecked녹음이 없거나, 말소리가 없거나, 귀가 대답을 안 했을 때확인이 생기기 전과 똑같이.

버리는 건 없어. 클라이언트가 보낸 원래 글은 voice_meta.speaker.original_text에 실리고, 라벨은 행 자체에 실려. 그래서 다시 보기도, 어느 브레인도, 검색 색인도 전부 같은 걸 읽어.

첫 실전이 써 준 규칙 둘

한두 마디는 절대 낯선 사람 걸로 판정하지 않아. 아빠의 한 단어짜리 턴은 중앙값이 0.37이었고, 아빠의 "응"을 남의 말로 치면 아빠 승인을 내다 버리는 거야. 그리고 모르는 목소리 혼자 2초가 안 되면 other가 아니라 uncertain이야. 길 위에서 아빠가 한 1.4초짜리 턴이 아빠 자신의 목소리와 비교해 0.20밖에 안 나왔거든. 되묻는 비용이 무시하는 비용보다 싸. 그때 같이 생긴 규칙이 하나 더 있어. 다른 목소리가 Scribe가 들은 단어를 하나도 안 남겼으면(웃음, 웅얼거림) 그 턴은 아빠 거야.

단단한 문은 어디 있어

되묻기는 턴별 블록에 든 지시일 뿐이야. Admin에서 고칠 수 있는 speaker_check_instruction이고, 코드로 잠근 문은 아니야. 코드로 잠근 문은 두 군데 있어. other로 판정된 턴은 뭔가를 바꾸는 도구를 전부 잃어(clamp_tools). 읽기, 찾기, 대답하기는 남아. uncertain은 일부러 도구를 그대로 둬. 딱 잘라 빼면, 그걸 확인해 주는 바로 그 턴에서 아빠의 "응"을 또 거절하게 되거든. 다른 하나는 voice_check 문이야. 확인이 누굴 믿는지를 바꾸는 유일한 문이라서, 요청한 턴이 타이핑이거나 dad로 판정된 목소리가 아니면 거절해.

같은 데이터로 다시 돌려 보니 아빠 턴 769개는 dad 709, uncertain 34, mixed 15, other 3, unchecked 8이 나왔어. LibriSpeech 녹음 200개에선 dad가 0. 다른 목소리를 아빠로 착각한 적은 한 번도 없었어.

못 하는 것

두 사람이 같은 순간에 말하면 못 갈라. 섞인 소리의 임베딩은 센 목소리를 따라가거든. 아빠가 엄마 위로 말한 녹음은 엄마가 두 배 크게 말해도 아빠 0.55, 엄마 0.09가 나왔어. office에 닿지 못하면 확인도 없어. Bellows가 죽으면 unchecked고, 채팅은 늘 하던 대로 돌아. PIPPA_SPEAKER_CHECK=0이면 확인이 꺼져. Bellows의 speaker_thresholds 설정으로는 배포 없이 어느 선이든 옮길 수 있어.

Code

귀의 기준선, 원문 그대로 (cwkBellows backend/bellows/speaker.py:112-121)·python
# Cosine similarity to a profile's centroid, calibrated on Dad's own 769 kept
# turns against 227 recordings of 49 other voices (cwkPippa doc §7). A voice
# at or above ``match`` is the profile's; below ``uncertain`` it is nobody's;
# between, nobody can say. Measured: other voices topped out at 0.21 (a
# whole turn) and 0.24 (a two-second chunk); 95% of Dad's turns scored above
# 0.49 and 95% of his chunks above 0.38. A chunk is named at ``chunk``. Less
# than ``MIN_IDENTITY_SECONDS`` of speech is a match only at ``short_match``
# (Dad's one-word turns scored 0.37 at the median). The
# ``speaker_thresholds`` setting overrides any of them without a deploy.
DEFAULT_THRESHOLDS = {"match": 0.45, "uncertain": 0.25, "chunk": 0.30, "short_match": 0.60}
최소한의 스케치: speaker_check.verdict_of의 판정 규칙(타입 검사만 덜어냄)을 턴 아홉 개에 돌려 보기·python
# The verdict rule, as verdict_of in cwkPippa's speaker check rules it.
DAD_PROFILE = "dad"
SIGNIFICANT_SECONDS = 1.0     # less speech than this never changes a verdict alone
SURE_STRANGER_SECONDS = 2.0   # an unknown voice alone needs this much to be "other"


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


def verdict_of(voices):
    if not voices:
        return "unchecked"
    significant = [v for v in voices if v["seconds"] >= SIGNIFICANT_SECONDS]
    if not significant:
        if any(is_dad(v) and v["band"] == "match" for v in voices):
            return "dad"
        if any(v.get("profile_id") not in (None, DAD_PROFILE) and v["band"] == "match" for v in voices):
            return "other"
        return "uncertain"
    dad = [v for v in significant if is_dad(v)]
    others = [v for v in significant if not is_dad(v)]
    if not others:
        return "dad" if any(v["band"] == "match" for v in dad) else "uncertain"
    if dad:
        return "mixed"
    enrolled = any(v.get("profile_id") not in (None, DAD_PROFILE) and v["band"] == "match" for v in others)
    if enrolled or sum(v["seconds"] for v in others) >= SURE_STRANGER_SECONDS:
        return "other"
    return "uncertain"


def voice(profile, band, seconds):
    return {"profile_id": profile, "band": band, "seconds": seconds}


turns = {
    "an ordinary turn of his": [voice("dad", "match", 6.2)],
    "his voice through wind": [voice("dad", "uncertain", 4.0)],
    "a one-word yes, unsure": [voice("dad", "uncertain", 0.6)],
    "he talks, Mom answers": [voice("dad", "match", 3.1), voice("mom", "match", 2.4)],
    "a guide, 5 s, nobody known": [voice(None, "none", 5.0)],
    "1.4 s of nobody known": [voice(None, "none", 1.4)],
    "Mom alone, short but sure": [voice("mom", "match", 0.8)],
    "a cough beside his sentence": [voice("dad", "match", 5.0), voice(None, "none", 0.4)],
    "no speech found": [],
}
for name, voices in turns.items():
    print(f"{name:<30} -> {verdict_of(voices)}")

# Output:
# an ordinary turn of his        -> dad
# his voice through wind         -> uncertain
# a one-word yes, unsure         -> uncertain
# he talks, Mom answers          -> mixed
# a guide, 5 s, nobody known     -> other
# 1.4 s of nobody known          -> uncertain
# Mom alone, short but sure      -> other
# a cough beside his sentence    -> dad
# no speech found                -> unchecked

External links

Exercise

스케치를 돌려서 판정 아홉 개를 읽어 봐. 그다음 턴을 셋 더 넣어. 아빠 없이 엄마(확실한 match)와 낯선 사람이 1.5초씩, 확실하지 않은 아빠 3초 옆에 아무도 아닌 1.2초, 그리고 아빠와 확실히 맞는 0.9초. 돌리기 전에 판정을 먼저 맞혀 봐. 마지막으로 verdict_of에서 제일 바꾸고 싶은 줄 하나를 골라서, 그걸 바꾸면 어떤 실수를 덜고 어떤 실수를 새로 사는지 적어.
Hint
엄마와 낯선 사람은 둘 다 '다른 사람'이야. 그중에 등록 목소리와 확실히 맞는 게 있으면 other, 없으면 둘의 말소리를 합쳐 2초는 돼야 해. 확실하지 않은 아빠 옆에 의미 있는 낯선 목소리가 있으면 mixed야. 아빠 목소리가 의미 있는 길이로 있으면 대역과 상관없이 mixed거든. 짧지만 확실한 match는 dad야. 여기 모든 줄은 낯선 사람이 빠져나가는 실수와 아빠를 의심하는 실수를 맞바꿔. 코드는 아빠를 무시하기보다 되묻는 쪽으로 기울어 있어.

Progress

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

댓글 0

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

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