"어차피 파형으로 가려낼 수도 있을 텐데... 오디오적으로 처리가 가능할 거야." — 아빠
마이크는 모두를 들어
트랙 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.79 | 0.40 |
| WeSpeaker ResNet34 | 0.94 | 0.88 |
| 3D-Speaker ERes2NetV2 | 0.88 | 0.53 |
| TitaNet-large | 0.84 | 0.21 |
봐야 할 건 제일 높은 중앙값이 아니야. 간격이야. TitaNet은 다른 목소리를 전부 0 근처에 뒀어. 확인은 공짜고 office 안에서 끝나. 4초 턴에 0.08초, 110초 턴에 2.1초. 처음 설계는 pyannote로 분할하고 군집을 돌렸는데, 재 보고 버렸어. 더 느렸고, 우리가 묻는 건 누구 목소리냐인데 그건 목소리가 몇 개냐에 답하거든.
판정 다섯 가지
| 판정 | 언제 | 턴은 어떻게 돼 |
|---|---|---|
dad | 1초 이상 말한 목소리가 전부 아빠고 그중 하나는 확실할 때 | 그대로. 블록도 없어. |
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 설정으로는 배포 없이 어느 선이든 옮길 수 있어.