맥미니 통화 에코를 잡는 데 하루가 걸린 과정
목차
노트북에서 저절로 되던 것이 맥미니에서 안 됐습니다. 스피커와 마이크가 한 장치가 아니었기 때문입니다.
마이크를 달았으니 전화도 받아 보자 했습니다
맥미니에 마이크를 달았으니 이제 맥용 전화 앱으로도 직접 받을 수 있겠다 싶었습니다. 가족에게 걸었습니다. 목소리가 전달되지 않고 있었습니다.
거기서부터 하나씩 걸렸습니다. 상대 목소리도 안 들렸고, 전화 앱은 아예 안 뜰 때가 있었습니다. AI 에이전트에게 진단을 맡겼더니 원인이 셋 다 다르다고 나왔습니다.
- 볼륨을 조절하려고 깔아 둔 앱이 전화 프로세스를 음소거하고 있었다
- 통화 오디오를 맡은 시스템 프로그램이 내가 지정한 마이크를 무시하고, 소리가 나지 않는 가상 마이크를 자기 마음대로 골라 쓰고 있었다
- macOS에서 오디오 장치를 관리하는 시스템 프로그램이 멈춰 있었다
셋을 걷어내고 나니 통화가 되기 시작했고, 그제야 상대가 에코를 듣고 있다는 것이 드러났습니다.
에코는 이렇게 생깁니다. 상대 목소리가 스피커로 나오고, 그 소리가 공기를 타고 마이크로 들어가고, 통화 회선을 타고 상대에게 되돌아갑니다. 상대는 자기가 방금 한 말을 몇백 밀리초 뒤에 다시 듣습니다.
듣는 쪽은 상대이지 내가 아닙니다. 내 귀로는 확인할 수 없고 통화할 때마다 상대에게 물어봐야 합니다. 이 조건이 하루 종일 발목을 잡았습니다.
에코를 지우려면 소리가 한 프로그램을 지나가야 합니다
에코를 지우는 원리는 뺄셈이라고 합니다. 스피커로 내보낸 소리를 알고 있으면 마이크로 들어온 소리에서 그것을 빼면 됩니다.
그러려면 조건이 하나 붙습니다. 나가는 소리와 들어오는 소리가 같은 프로그램을 지나가야 합니다. 내보낸 소리를 모르면 뺄 대상을 모르니 아무것도 못 지웁니다.
노트북에서는 이게 저절로 됩니다. 내장 스피커와 내장 마이크가 사실상 한 장치라, 통화 앱이 소리를 내보내는 그 자리에서 마이크 소리도 함께 잡힙니다.
맥미니는 다릅니다. 마이크는 USB로 들어오고 스피커는 HDMI 모니터로 나갑니다. 들어오는 길과 나가는 길이 서로 다른 장치이고, 전화 앱은 모니터로 소리를 곧장 보냅니다. 그 소리는 중간에서 아무도 볼 수 없습니다. 에코를 지울 수 있는 자리 자체가 없었습니다.
장치를 묶는 방법이 있다는 건 알았고, 묶어도 안 됐습니다
macOS에는 장치 여럿을 하나로 묶는 기능이 있습니다. 마이크와 모니터를 묶으면 한 장치처럼 다뤄질 테니 되지 않을까 싶어 이 작업을 시작하기 전에 이미 해봤습니다. 에코가 그대로였습니다. 장치가 따로 놀아서 생기는 문제라는 짐작은 그때 빗나갔습니다.
애플에도 에코를 지우는 기능이 있습니다. 개발자가 자기 프로그램에 붙여 쓰라고 내놓은 것인데, 이걸 어떻게 붙일지 물었더니 세 가지 안이 나왔습니다. 순서대로 해봤고 셋 다 막혔습니다.
| 시도 | 결과 |
|---|---|
| 마이크만 물리고 시스템이 내는 소리를 참고하게 하기 | 참고할 소리가 한 번도 안 들어옴 |
| 상대 목소리를 이 기능으로 내보내고 그걸 참고하게 하기 | 마이크에서 전화 앱으로 가는 길이 끊김 |
| 묶은 장치를 이 기능에 물리기 | 거부당함 |
첫 번째가 막힌 이유가 핵심입니다. 애플의 이 기능은 자기가 직접 내보낸 소리만 참고합니다. 전화 앱이 모니터로 보낸 소리는 이 기능을 거치지 않았으니 참고 대상이 아닙니다.
표 밖에서 하나 더 시도했습니다. 다른 프로그램이 내는 소리를 시스템 수준에서 가로채는 방법인데, 등록까지는 됐고 소리가 한 번도 넘어오지 않았습니다. 이런 캡처에는 별도 권한이 필요한데 직접 만든 실행 파일이라 그 권한이 없어서로 분석됐습니다.
애플이 주는 것으로는 두 소리를 같은 프로그램에 통과시킬 수 없다는 결론이었습니다.
Krisp이 되는 걸 보고 방향을 정했습니다
여기서 Krisp을 설치해 봤습니다. 화상회의용 잡음 제거 프로그램인데, 이 상황에 맞는 이유가 있습니다. Krisp은 자기 자신을 오디오 장치인 것처럼 등록합니다. 공식 안내도 이렇게 쓰라고 합니다.
마이크를 Krisp Microphone으로, 스피커를 Krisp Speaker로 지정하십시오.
전화 앱이 그렇게 잡으면 나가는 소리와 들어오는 소리가 둘 다 Krisp 안을 지나갑니다. 뺄셈에 필요한 조건이 그 안에서 갖춰집니다. 배경 잡음뿐 아니라 에코 제거도 기능 목록에 들어 있습니다.
깔았더니 에코가 제거됐습니다. 정확히 잘 동작하는 걸 확인했고, 맘에 들었지만 돈을 내고 싶지는 않았습니다. 그렇지만 소프트웨어로 된다는 걸 알았으니 더더욱 만들거나 조치할 수 있겠다 싶었습니다.
같은 마이크, 같은 모니터, 같은 배치에서 제거됐습니다. 여기까지가 확인의 전부입니다.
값은 7일 체험 뒤 연간 결제 기준 월 8달러, 월 결제로는 16달러입니다. Krisp은 마지막에 기능 개발이 끝난 뒤에 지웠습니다.
소리가 지나갈 길을 직접 깔았습니다
가상 오디오 장치라는 것이 있습니다. 실제 스피커나 마이크가 아닌데 macOS가 장치로 인식하는 것입니다. 출력으로 쓰면 소리를 삼키고, 입력으로 쓰면 방금 삼킨 소리를 그대로 내놓습니다. BlackHole이 그런 프로그램이고 오픈소스입니다.
전화 앱의 출력을 BlackHole로 돌리면 그 소리를 읽을 수 있습니다. 읽은 것을 둘로 나눕니다. 하나는 실제 모니터로 보내 사람이 듣게 하고, 하나는 뺄 때 참고할 원본으로 씁니다. 마이크에서 에코를 뺀 결과는 또 다른 BlackHole로 내보내 전화 앱이 그걸 마이크로 쓰게 합니다.
이 중간 프로그램을 릴레이라고 부르겠습니다. Krisp이 자기 안에서 하던 일을 BlackHole 두 벌과 릴레이로 나눠 만든 것입니다.
에코를 빼는 계산은 speexdsp에 맡겼습니다. 오픈소스이고 애플 것과 달리 참고할 원본을 밖에서 직접 넣어 줄 수 있습니다. 그 원본을 이쪽이 쥐고 있으니 이 방식이 맞습니다.
장치를 묶는 대신 소리가 지나가는 길을 갈아 끼운 것입니다.
BlackHole이 두 벌 필요한데 brew에 같은 것을 두 벌 설치하는 방법이 없어서 2채널과 16채널을 하나씩 깔았습니다. 16채널이 필요해서 고른 것이 아닙니다. 이 선택이 나중에 문제를 하나 만듭니다.
여기서부터 나오는 dB를 읽는 법
아래로 측정값이 계속 나옵니다. 읽는 법을 먼저 정리합니다.
dB는 상대값이 맞습니다. 여기서 기준은 그 오디오 장치가 표현할 수 있는 최대 크기이고, 그것이 0입니다. 실제 소리는 항상 그보다 작으니 값이 전부 마이너스로 나옵니다. 0에 가까울수록 크고, 숫자가 작아질수록 조용합니다.
장치마다 최대 크기가 다르지 않냐는 물음이 있을 수 있는데, 여기 나오는 값은 스피커에서 실제로 울린 음압이 아니라 마이크가 받아 디지털로 바꾼 신호의 크기입니다. 디지털로 표현할 수 있는 최댓값이 0이고 그 아래를 재는 것이라 장치가 바뀌어도 기준은 같습니다. 다만 같은 소리라도 마이크 감도와 거리에 따라 값이 달라지니, 다른 날 잰 값끼리 비교하면 안 되고 같은 조건에서 잰 것끼리만 비교합니다.
20씩 내려갈 때마다 진폭이 10분의 1이 됩니다. -13에서 -33으로 가면 10분의 1, -53까지 가면 100분의 1입니다. 이 글에 나오는 값은 두 종류입니다.
- 소리 크기. “마이크 출력 -64”처럼 그 지점의 신호가 얼마나 작은지
- 줄어든 폭. “8 dB 줄었다”처럼 에코를 빼기 전후의 차이. 뺄셈 결과라 부호 없이 씁니다
목표는 처리 후 마이크로 나가는 에코를 -50 아래로 내리는 것이었습니다. 그 아래면 상대가 못 듣는다고 보고 시작했습니다.
스피커가 조용한 것과 에코가 제거된 것은 측정값이 같습니다
여기서부터 오판이 시작됩니다.
에코가 -59로 측정되면 둘 중 하나입니다. 에코를 잘 지웠거나, 스피커가 아예 안 울렸거나. 숫자만 보면 구분이 안 됩니다.
한 번은 오디오 유틸리티를 강제 종료하면서 음소거 상태가 남아 스피커가 무음이 됐고 그것을 성공으로 읽었습니다. 또 한 번은 특정 장치로 소리를 보내는 명령이라고 믿고 쓴 옵션이 실은 그런 기능이 아니었습니다. 소리는 조용히 기본 출력으로 나갔고 측정은 무효였습니다.
답은 같은 조건에서 에코 제거만 끄고 한 번 더 재는 것입니다. 끈 쪽의 마이크 입력이 조용한 방의 바닥 소음보다 확실히 높으면 스피커가 실제로 울린 것이고, 그때 두 값의 차이가 진짜 줄어든 폭입니다. 양쪽이 다 조용하면 애초에 소리가 안 난 것입니다. 지금 검증 스크립트는 늘 두 번 잽니다.
16채널을 고른 대가
16채널 장치는 채널 16개를 보고하는데 전화 앱은 앞 2개에만 씁니다. 릴레이는 들어온 채널을 모두 더해 채널 수로 나누는 방식으로 하나의 소리를 만들고 있었습니다. 신호가 두 채널에만 있는데 16으로 나누니 진폭이 8분의 1이 됐습니다. 정확히 -18에 해당합니다.
증상은 “상대 목소리가 작게 들린다”였습니다.
그리고 이것을 에코가 사라진 것으로 읽었습니다. 스피커가 조용해지니 마이크에 들어가는 에코도 같이 줄었기 때문입니다.
그래서 이날 “에코가 사라졌다”는 판정이 두 번 나왔습니다. Krisp을 깔았을 때가 한 번, 채널 평균이 소리를 깎았을 때가 한 번. 앞의 것은 진짜였고 뒤의 것은 스피커가 조용해진 것이었습니다. 뒤늦게 이 둘을 섞어 기억하고 있었는데, 다시 짚어 보니 Krisp 쓸 때가 품질이 제일 좋았던 것은 그대로 맞았습니다.
소리가 있는 채널만 골라 평균 내도록 고쳤고 상대 목소리 크기는 돌아왔습니다.
지연을 줄이면 에코가 다시 들립니다
실제 통화를 해보니 상대 목소리가 조금 늦게 들렸습니다. 그렇다고 에이전트에게 전했더니 버퍼를 줄였습니다. 버퍼는 잠시 소리를 담아 두는 크기이다 보니, 지연이 신경 쓰이면 여기부터 손대게 됩니다.
그런데 줄일 때마다 에코가 덜 지워졌습니다.
| 참고용 버퍼 / 스피커 버퍼 상한 | 에코가 줄어든 폭 |
|---|---|
| 100ms / 100ms | 8 - 14 dB |
| 50ms / 40ms | 1 - 3 |
| 100ms / 40ms | 3 - 7 |
스피커 버퍼에서 버리는 것이 곧 스피커로 나가는 소리이고, 그것이 마이크로 되돌아오는 에코의 원본입니다. 버리면 뺄셈에 쓸 원본이 어긋납니다. 체감 지연을 줄이려면 처리 결과가 전화 앱으로 나가는 쪽을 건드려야 하고 그쪽은 에코 제거와 무관합니다.
실제 통화에서 시각 보정이 항상 실패하던 것도 이 무렵에 잡았습니다. 참고할 원본과 마이크 소리가 몇 밀리초 어긋나 있는지 재서 맞추는 단계인데, 원래 구현은 시작 후 3초 안에 한 번만 시도하고 신호가 약하면 포기했습니다. 측정할 때는 테스트 음성이 즉시 나오니 성공하는데, 실제 통화는 전화를 거는 순간 상대가 말을 안 하니 매번 실패했습니다. 최대 90초 재시도하도록 고쳤고 통화 기록에서 17초에 보정이 붙는 것을 확인했습니다.
측정에 더블토크가 없었습니다
통화가 길어지면 에코가 느껴진다고 전했더니 에이전트가 원인을 둘로 좁혀 왔습니다. 참고용 버퍼의 지연이 통화 중에 움직이는 것, 그리고 참고할 소리가 없는 구간에도 계산을 돌려 “원본이 없으니 에코도 없다”를 학습해 버리는 것.
이 둘을 세 가지 조합으로 고쳐 봤고 셋 다 나빠졌습니다.
| 시도 | 에코가 줄어든 폭 |
|---|---|
| 손대기 전 | 5.6 - 9.4 dB |
| 시각 맞추기에 학습 초기화를 더함 | 3.4 - 5.6 |
| 초기화만 제거 | 1.8 - 5.8 |
| 에코 꼬리 기다린 뒤 무음 구간 건너뛰기 | 0.8 - 4.8 |
전부 되돌렸습니다. 손대기 전 값이 앞 표의 8-14와 다른데, 통화할 때마다 상대 목소리 크기와 거리가 달라 같은 코드에서도 이만큼 흔들립니다. 그래서 한 번의 측정 안에서만 비교합니다.
세 번 실패한 뒤에야 측정 조건을 의심했습니다. 35초짜리 실제 음성으로 재보니 47 dB가 줄었습니다. 실제 통화의 8-14와 너무 다릅니다.
측정에 더블토크(둘이 동시에 말하는 상황)가 없었습니다. 기존 측정은 상대 목소리만 울리고 이쪽은 가만히 있는 조건입니다. 실제 대화는 말이 겹치고, 그때 에코 제거가 내 목소리를 에코로 착각해 어긋납니다. 측정이 실제 통화를 흉내 내지 못하고 있었던 것입니다. 고칠 대상은 코드가 아니라 측정이었습니다.
더블토크를 재현하는 스크립트를 새로 만들었습니다. 상대 목소리는 릴레이를 거쳐 모니터로 내보내 참고 대상에 들어가게 하고, 내 목소리는 기본 출력을 맥미니 내장 스피커로 돌려 냅니다. 후자는 참고 대상에 없으니 릴레이가 모르는 소리이고, 마이크가 둘 다 주워담아 말이 겹치는 상황이 됩니다.
speexdsp에는 지우고 남은 에코를 한 번 더 누르는 후처리가 붙어 있고 그 세기를 바꿀 수 있습니다. 기본값과 두 배 세게 준 값 모두 겹치는 구간의 측정치가 그대로였습니다. 이 값은 원인이 아닙니다. 남은 병목은 speexdsp 자체가 겹치는 소리를 다루는 방식이고 설정으로는 안 풀렸습니다.
마무리 단계
릴레이가 완성된 뒤에 두 가지를 더 붙였습니다. 하나는 로그인할 때 자동으로 뜨도록 등록하는 것이고, 하나는 통화 시작을 감지해 릴레이를 켜고 끄는 것입니다.
뒤쪽은 실패했습니다. 전화 앱이 오디오 장치를 잡는 순간을 1초마다 확인하는 방식으로는 못 잡았고, 통화 중에도 아무것도 감지하지 못했습니다. 자동 실행 등록으로 끝내고 릴레이를 상시 켜 두기로 했습니다.
이 작업은 처음에 Hermes 칸반 카드로 역할별 에이전트에게 위임하는 구조로 시작했습니다. 중간에 그만두게 했습니다. 이유는 하나입니다. 중간중간 너무 오래 걸려서.
지금 상태는 이렇습니다. 상대만 말하는 구간에서 8-14 dB 줄었고, 처리 후 마이크로 나가는 에코는 실제 통화에서 -64에서 -71까지 갔습니다. 목표였던 -50 아래입니다. 짧게 주고받는 통화에서는 상대가 에코를 못 느낀다고 했고, 길게 이어지면 조금 남습니다.
그런데 이 -50이라는 목표치를 처음 잡을 때 기준으로 삼은 것이 상대만 말하는 구간의 측정값이었습니다. 말이 겹치는 구간은 그때 재본 적이 없었고, 지금 남은 에코가 정확히 거기 있습니다. 더블토크 측정을 나중에 만든 것도 그래서입니다.
마지막 타협점이라고 생각합니다. 다음에 손댄다면 speexdsp를 WebRTC AEC3로 교체하는 쪽인데, 말이 겹칠 때 더 낫다고 알려져 있지만 라이브러리를 가져와 붙이는 일이라 새로 만드는 규모입니다.
하루 대부분을 에코 없애는 데 쓰긴 했지만, 새로운 분야의 새로운 기법과 기술을 AI 에이전트 덕분에 좀 더 쉽게 이해했고 직접 조치해 볼 수 있게 됐습니다.