~16 min · speaker-verification, enrollment, data-poisoning, feedback-loops
Level 0음소거
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"오탐이 있으면 아빠가 얘기를 해줄 테니까 엄마 목소리가 녹음되는 걸 가지고도 학습을 할 수 있을 거고, 신뢰도를 높일 수 있을 거고. 만약에 신뢰도가 떨어지면 몇 번이고 엄마 목소리만 따로 녹음을 해서 그 신뢰도를 높일 수 있는 방향으로 나아가면 될 거고." — 아빠
샘플 하나면 그게 엄마의 절반이야
확인은 등록된 샘플로만 목소리를 알아. 아빠 프로필은 아빠가 남긴 턴으로 띄웠어. 스크립트가 녹음마다 귀한테 물어보고, 공통 목소리에 가까운 것만 골라서 640개를 등록했어. 조각끼리 서로 안 맞는 녹음(한 녹음에 두 사람)은 귀가 거절했고. 엄마는 정식 절차로 등록했어. 아빠가 "엄마 목소리 등록하자"라고 하면 엄마가 폰에 대고 혼자 10초 넘게 말해. 그다음 아빠가 "방금 그거 엄마야. 등록해."라고 하면 소울이 voice_check 문을 enroll, profile: mom, turns_back: 1로 불러. 깨끗한 녹음 하나, 19초쯤. 턴을 하나씩 주고받는 시험에선 둘을 잘 갈랐어.
일상은 더 어려워. 엄마는 문장 중간에 끼어들고, 피파한테 하는 말도 아닌데 마이크 가까이서 말하고, 주변의 낯선 목소리는 서로 비슷하게 들려. 게다가 Bellows 프로필은 샘플의 단순 평균이야. 샘플이 하나뿐이면 잘못 붙은 라벨 하나가 엄마의 절반이 돼.
정정은 라벨이야
아빠가 "방금 그거 엄마야"나 "그거 나야"라고 하면, 턴별 블록이 소울한테 그건 라벨이니까 바로 voice_checkcorrect를 부르라고 해. 문은 녹음에서 그 목소리 구간만 떼어서 올려. 그 턴에 남겨 둔 시간 구간으로 자르니까, 아빠도 같이 말한 턴에서도 돼. 그다음 이 라벨이 뭘 가르쳐도 되는지는 안전장치들이 정해.
확인이 다른 사람 걸로 확실히 판정한 목소리는 절대 라벨을 바꾸지 않아. 문은 들은 걸 아빠한테 말하고 판단은 아빠한테 넘겨.
다른 등록 목소리와 닮은 클립은 거절해. 그 목소리와 0.45 이상이고, 지목한 목소리보다 점수가 높은 클립 말이야. 그걸 올리면 확인한테 거짓말을 가르치는 거야.
지목한 목소리 쪽으로 이미 기운 클립(0.25 이상, 아니면 아직 등록 안 된 목소리)은 바로 들어가. 귀도 그쪽으로 기울었고, 아빠 말이 그걸 확정해.
나머지는 보류야. 혼자선 절대 안 들어가고, 다른 녹음에서 온 두 번째 정정이 임베딩 0.40 이상으로 맞아야 같이 들어가. 30일 지난 보류 클립은 더는 짝을 안 지어. 실수로 엄마 이름으로 올라간 낯선 사람은 기다리기만 하고 아무것도 못 가르쳐.
정정 하나엔 말소리 2초에서 30초가 실리고, 원래 녹음을 가리키는 표시가 붙어서 forget으로 다시 뺄 수 있어. 실제 귀로 재 봤어. 엄마 구간을 엄마로 올린 건 0.48, 0.41로 들어갔어. 아빠 구간을 엄마로, 엄마 구간을 아빠로 올린 건 거절됐어. 낯선 사람 넷을 엄마로 올린 건 0.07에서 0.24 사이로 보류됐고.
구간마다 신뢰도
귀는 외톨이 조각이 확실한 match가 아니면 둘레의 묶음에 접어 넣어. 그래서 아빠 문장 안에서 엄마가 한 세 마디는 아빠 목소리 하나로 돌아왔어. 그러니 정책이 한 번 더 봐야 해. 확인한 턴은 전부 시간 구간을 voice_meta.speaker에 남기고, 이름 붙은 목소리의 구간은 2초 안팎의 창으로 다시 점수를 매겨. 창이 확실하려면 자기 목소리에 0.25 이상, 다른 모든 등록 목소리보다 0.10 이상 앞서야 해. 확실하지 않은 창은 [unknown voice]로 읽히고 아무것도 못 정해. 다른 등록 목소리가 다투는 창(0.30 이상이면서 그 묶음 목소리와 0.10 이내)은 경합이고, 그게 1초 쌓이면 아빠 턴은 hidden_voice가 붙은 mixed가 돼. 글은 보낸 그대로 둬.
처음 버전은 확실하지 않을 뿐인 창도 2초 쌓이면 되묻게 했어. 아빠 자신의 턴 400개에 돌려 보니 368개 중 8개를 의심했고, 귀가 이미 갈라놓은 것 말고는 새로 잡은 게 없었어. 그래서 그 규칙은 버렸어. 창 점수는 16개, 3초까지만 매기고 끊어. 그렇게 끊고 나니 확인 전체에 걸린 시간은 중앙값 0.82초, 95번째 백분위 3.83초였어.
목소리 건강
아빠 아닌 목소리에 이름을 붙인 확인은 전부 그 점수를 남기고, 그 목소리로 들어온 정정은 하나하나 놓친 걸로 세. 최근 구간을 여덟 개까지 모아(셋은 있어야 해) 낸 중앙값이 0.45 밑이거나 일주일에 정정이 두 번 들어오면, 그 목소리는 흔들리는 중이야. 그러면 다음 음성 턴의 블록이 소울한테 일러. 그 얘기는 한 번만, 하루 한 번까지만 하고, 15초쯤 혼자 말하는 짧은 재등록을 권하라고. 정식으로 다시 등록하면 기록은 처음부터야. 엄마는 처음부터 지켜봐야 했어. 첫 실제 mixed 턴들에서 엄마 구간은 자기 목소리와 0.43에서 0.46, 딱 선 위였거든. 아빠 목소리는 이 알림에 절대 안 나와. 녹음 640개가 받치고 있으니까.
짧은 조각과 거두기
그러다 어떤 안전장치도 못 막는 빈틈이 보였어. 엄마가 끼어드는 말은 1초쯤인 경우가 많은데, 샘플엔 2초가 필요하니까 그런 걸 정정해 봤자 아무것도 못 가르쳤어. 이제 0.6초부터는 정정한 구간을 짧은 조각 모음에 보류해. 녹음 둘 이상에서 온 정정이 합쳐서 2초가 되면 샘플 하나로 같이 들어가.
확인은 시키지 않아도 배워. 거두기는 확인이 엄마 거라고 확신한 구간(엄마 목소리에 0.50 이상, 아빠에 0.25 이하, 확실한 말소리 2초)을 턴에 답한 뒤 후보로 보류해. 다른 녹음에서 온 두 번째 후보나 정정이 맞으면 둘 다 들어가고, 하루 넷까지야. 엄마 목소리는 아빠가 한마디 안 해도 엄마가 실제로 말하는 곳의 소리를 배워. 그래도 이상한 구간 하나와 엄마 프로필 사이엔 여전히 녹음 둘 규칙이 서 있어.
누가 가르칠 수 있어
아빠가 시키는 등록, 정정, 지우기는 전부 voice_check 문을 지나. 이 문이 거절하는 경우는 셋이야. 요청한 턴이 아빠 게 아닐 때(타이핑이거나 dad로 판정된 목소리만 돼), 비밀 대화일 때(목소리 지문이 대화보다 오래 남으니까), 그리고 확인이 다른 사람으로 맞춘 녹음을 이 사람 이름으로 올리라고 할 때. 거두기는 누구한테도 안 물어보는 대신 더 좁아. 확인이 확신한 구간만, 녹음 둘 규칙을 거쳐서만, 그리고 아무것도 안 남기는 비밀 대화에선 절대 안 해. 확인이 확신 못 하는 목소리는 확인이 뭘 믿을지에 표를 던질 수 없어.
Code
학습의 기준선, 원문 그대로 (cwkPippa backend/services/speaker_learning.py:66-98)·python
# The ear's bands (cwkBellows speaker.DEFAULT_THRESHOLDS); the identify answer
# carries the live ones, and these are the fallback.
MATCH_LINE = 0.45
UNCERTAIN_LINE = 0.25
# A correction is enrolled at once when the clip already scores this against
# the voice it is filed under: the ear leaned that way, Dad's word settles it.
AGREES_WITH_PROFILE = UNCERTAIN_LINE
# Two held clips are one voice when their embeddings agree this well. Dad's
# two-second chunks scored 0.38 against his whole profile at the fifth
# percentile and other voices 0.24 at most (PIPPA-SPEAKER-SEPARATION §7.2);
# a clip against a clip asks for the safe side of both.
CLIPS_AGREE = 0.40
# A clip that sounds this much like another enrolled voice is refused: filing
# it would teach the check a lie.
SOUNDS_LIKE_ANOTHER = MATCH_LINE
# A correction carries at most this much speech; Bellows needs 2 s.
CORRECTION_MAX_SECONDS = 30.0
CORRECTION_MIN_SECONDS = 2.0
# Held clips older than this no longer pair with a new one (they are kept).
HELD_PAIRING_DAYS = 30
# A corrected stretch this long or more is kept even when it is under a
# sample's two seconds: it waits in the short pool (see the module doc).
SHORT_CLIP_SECONDS = 0.6
# Harvest: a stretch the check named for someone other than Dad is a
# candidate when it scored at least HARVEST_LINE against that voice, at most
# HARVEST_DAD_CEILING against Dad, and spoke HARVEST_MIN_SECONDS. Mom's live
# stretches scored 0.52-0.54 in the 2026-10-09 test turns and Dad's
# stretches 0.15-0.23 against her; the match line is 0.45.
HARVEST_LINE = 0.50
HARVEST_DAD_CEILING = 0.25
HARVEST_MIN_SECONDS = 2.0
HARVEST_PER_DAY = 4
최소한의 스케치: speaker_learning.py에서 옮긴 correction_decision, 장난감 임베딩으로 보는 보류 클립 짝짓기, 그리고 건강 중앙값·python
# How a correction ("that was Mom") is filed: enroll, hold or refuse.
# correction_decision is cwkPippa's rule; the embeddings below are toys.
import math
from statistics import median
MATCH_LINE = 0.45
AGREES_WITH_PROFILE = 0.25 # the ear already leans this way: Dad's word settles it
SOUNDS_LIKE_ANOTHER = MATCH_LINE
CLIPS_AGREE = 0.40 # two held clips are one voice at this cosine
def correction_decision(profile, scores, enrolled):
own = scores.get(profile)
rival = max(((p, s) for p, s in scores.items() if p != profile), key=lambda item: item[1], default=None)
if rival and rival[1] >= SOUNDS_LIKE_ANOTHER and rival[1] > (own if own is not None else -1.0):
return "refuse", rival
if profile not in enrolled or (own is not None and own >= AGREES_WITH_PROFILE):
return "enroll", None
return "hold", None
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
enrolled = ["dad", "mom"]
cases = {
"Mom's stretch, filed as Mom": {"dad": 0.18, "mom": 0.48},
"Dad's stretch, filed as Mom": {"dad": 0.61, "mom": 0.21},
"a stranger, filed as Mom": {"dad": 0.05, "mom": 0.12},
"Mom far from the mic, filed as Mom": {"dad": 0.10, "mom": 0.19},
}
for name, scores in cases.items():
decision, rival = correction_decision("mom", scores, enrolled)
print(f"{name:<36} -> {decision}{f' (sounds like {rival[0]} {rival[1]:.2f})' if rival else ''}")
# Two held clips go in together only when they agree with each other.
held_stranger = [0.9, 0.1, 0.0, 0.4]
held_far_mom = [0.1, 0.8, 0.5, 0.0]
next_far_mom = [0.2, 0.7, 0.6, 0.1]
for name, clip in {"the held stranger": held_stranger, "the held far-away Mom": held_far_mom}.items():
score = cosine(clip, next_far_mom)
print(f"next correction vs {name:<22} cosine {score:.2f} -> {'both enrolled' if score >= CLIPS_AGREE else 'still held'}")
# Voice health: a median under the match line over three or more stretches.
recent = [0.52, 0.41, 0.39, 0.44, 0.47]
m = median(recent)
print(f"Mom's last {len(recent)} stretches: median {m:.2f} -> {'tell Dad, offer a re-enrollment' if m < MATCH_LINE else 'healthy'}")
# Output:
# Mom's stretch, filed as Mom -> enroll
# Dad's stretch, filed as Mom -> refuse (sounds like dad 0.61)
# a stranger, filed as Mom -> hold
# Mom far from the mic, filed as Mom -> hold
# next correction vs the held stranger cosine 0.31 -> still held
# next correction vs the held far-away Mom cosine 0.98 -> both enrolled
# Mom's last 5 stretches: median 0.44 -> tell Dad, offer a re-enrollment
스케치를 돌려 봐. 그다음 임베딩 벡터의 단순 평균으로 프로필을 만들어. 엄마한테 진짜 샘플 하나를 주고, 아무 안전장치 없이 낯선 사람 벡터 하나를 엄마 이름으로 올린 다음, 엄마 중심점이 얼마나 움직이는지 재 봐(예전 중심점과 새 중심점의 코사인). 진짜 샘플 열 개로 다시 해. 마지막으로 올리는 단계 앞에 correction_decision을 세우고, 낯선 사람 벡터 중 뭐가 보류됐을지 보여 줘.
Hint
진짜 샘플이 하나면 잘못된 샘플 하나가 평균을 낯선 사람 쪽으로 절반이나 끌고 가. 열 개면 10분의 1쯤. 그래서 안전장치는 목소리가 새로울 때 제일 중요해. 거두기와 짧은 조각 모음이 뭘 들이든 그 전에 따로 온 두 번째 녹음을 요구하는 것도 그래서야. 아무렇게나 만든 낯선 사람 벡터는 대개 엄마와 점수가 낮아서 enroll이 아니라 hold로 가.
Progress
Progress is local-only — sign in to sync across devices.