당신의 실시간 파이프라인에서, 하드웨어가 해 주는 처리를 어디까지 믿고 계십니까?
저는 에코 제거를 켜 뒀으니 됐다고 생각했습니다. 그건 켜져 있었고, 제대로 작동했고, 그래도 앱은 자기 목소리에 끊겼습니다.
신고
음성으로 대화하는 앱 세 개가 같은 오디오 코드를 씁니다. 마이크를 24kHz로 잡아 실시간 API로 흘리고, 돌아오는 소리를 재생합니다. 서버가 발화 시작을 감지해 턴을 바꿔 주기 때문에 클라이언트는 스트림만 이어 주면 됩니다.
신고는 이랬습니다. "아직도 대화 중에 끼어들고 반복하는 패턴이 가끔 보여."
"가끔"이 이 문제의 성격을 다 말해 줍니다. 항상 나면 배선이 틀린 거고, 가끔 나면 임계 근처의 문제입니다.
앱이 자기 목소리를 다시 듣고 있었습니다
에코 제거는 켜져 있었습니다. 오디오 엔진에 하드웨어 음향 에코 제거를 명시적으로 요청하는 호출이 들어 있고, 그건 제대로 작동합니다.
문제는 완벽하지 않다는 것이었습니다.
에코 제거는 원음 대비 20~35dB를 깎습니다. 스피커 볼륨이 크거나 특정 라우트에서는 남은 잔여가 서버의 발화 감지 임계를 가끔 넘습니다. 그러면:
- 서버가 "사용자가 말하기 시작했다"고 판단합니다.
- 생성 중이던 응답을 취소합니다. → 앱이 자기 목소리에 끊깁니다.
- 그 에코를 사용자 발화로 받아 다시 답합니다. → 같은 말을 반복합니다.
신고 문장 두 개가 정확히 같은 원인의 두 증상이었습니다.
코드를 보니 방어가 그 한 겹뿐이었습니다. 마이크 프레임은 무조건 전부 서버로 갔습니다. 소프트웨어 쪽 게이트가 아예 없었습니다.
두 번째 문제도 같이 나왔습니다. 사용자가 진짜로 끼어들면 서버는 응답 생성을 취소하지만, 클라이언트 재생 대기열에 이미 도착한 몇 초 분량은 계속 나갑니다. 사용자가 말하는 위로 앱이 계속 말합니다.
당신이라면 임계를 어디에 두시겠습니까
여기서 갈림길이 있습니다. 서버 쪽 발화 감지 임계를 올리는 방법이 제일 쉽습니다. 한 줄이면 됩니다.
쓰지 않았습니다. 그건 무딘 도구라 조용히 말하는 사용자까지 못 듣게 됩니다. 에코를 막으려고 진짜 사용자를 막는 건 교환이 나쁩니다.
대신 클라이언트에 게이트를 넣되, 고정 임계를 쓰지 않았습니다.
게이트
순수 판정 함수로 뺐습니다. 오디오 콜백에서 프레임마다 한 번 부릅니다.
재생중 = (마지막으로 들린 프레임 뒤 0.40초 안)
if not 재생중: 통과 # 조용한 화자를 막지 않는다
elif 피크 < 임계: 무음으로 채움 # 잔여 에코
else: 통과 # 진짜 끼어들기
연속 3회면 로컬 재생 큐를 비운다프레임을 버리지 않고 0으로 채워 보내는 것이 중요합니다. 서버 발화 감지기는 연속 스트림을 기대하고, 무음을 받으면 깨끗하게 리셋됩니다. 프레임을 그냥 빼면 타이밍이 어긋납니다.
임계는 고정값이 아니라 학습된 에코 바닥의 3배와 절대 하한 0.10 중 큰 쪽입니다.
바닥 = 지수이동평균(무음처리된 프레임의 피크, 계수 0.1) # 시상수 약 0.4초
임계 = clamp(바닥 * 3, 0.10, 0.30)여기서 중요한 규칙이 하나 있습니다. 통과시킨 프레임과 재생 중이 아닌 구간은 바닥에 반영하지 않습니다. 사용자 목소리나 방 소음을 에코로 배우면 임계가 폭주해서, 나중엔 아무도 끼어들 수 없게 됩니다. 상한 0.30을 둔 것도 같은 이유입니다 — 바닥 추정이 튀어도 보통 목소리로는 늘 끼어들 수 있어야 합니다.
함정 둘
하나: 재생 노드를 멈추면 아직 재생되지 않은 버퍼의 완료 콜백도 불릴 수 있습니다.
끼어들기로 큐를 비운 직후 그 콜백이 "방금 소리가 났다"고 시계를 찍으면, 게이트가 0.4초 다시 닫힙니다. 그러면 사용자의 첫 음절이 무음 처리됩니다. 끼어들 때 시계를 해제하고, 다음 응답이 스케줄될 때 다시 무장시켰습니다.
둘: 재생 시각은 메인 스레드로 넘기기 전에 찍습니다. 넘긴 뒤에 찍으면 그 디스패치 지연만큼 게이트가 늦게 열려, 그 틈으로 잔여 에코가 통과합니다.
검증
게이트가 순수 함수라 유닛 테스트로 21건 넣었습니다. 세 앱 전체 스위트도 통과시켰습니다.
그리고 게이트가 무음 처리한 횟수·끼어들기 횟수·학습된 바닥을 세션 종료 진단에 실어 보냅니다. 다음 조정의 근거를 실사용에서 받기 위해서입니다. 이게 이 작업에서 제일 중요한 부분일 수 있습니다 — 임계를 추정으로 정했으니, 그 추정을 채점할 데이터를 같이 내보내야 합니다.
자가진단 3줄
- 하드웨어 처리가 "완벽하다"고 가정한 곳이 있습니까? 에코 제거·노이즈 억제·자동 이득은 전부 확률적입니다. 남은 잔여가 임계를 넘는 순간이 옵니다.
- 고정 임계를 쓰고 있습니까? 기기·볼륨·라우트마다 바닥이 다릅니다. 고정값은 어떤 기기에서는 너무 세고 어떤 기기에서는 아무것도 안 막습니다.
- 당신의 적응형 추정이 자기가 막으려는 것 말고 다른 걸 배우고 있지 않습니까? 통과시킨 신호를 바닥에 넣으면 임계가 폭주합니다.
솔직한 부분
실기기로 검증하지 못했습니다. 이 문제는 시뮬레이터에서 재현되지 않습니다 — 실제 스피커와 마이크가 같은 공간에 있어야 잔여 에코가 생깁니다. 그래서 지금 들어간 임계값들은 추정치입니다. 0.10도 0.30도 3배도 제가 정한 값이고, 실측으로 뒷받침된 게 아닙니다.
임계를 앱이 스스로 학습하게 만든 것도 같은 이유인데, 그 학습이 실제로 수렴하는지도 실기기에서만 확인됩니다. 지금은 진단 텔레메트리를 붙여 두고 실사용 데이터를 기다리는 상태입니다.
아직 안 한 것도 하나 있습니다. 사용자가 끼어들었을 때 서버에 "사용자가 실제로 들은 지점"을 알려 주는 호출이 있는데, 그건 앱마다 코드가 갈라져 있어 별건으로 미뤘습니다. 지금은 로컬 재생만 끊고 서버 쪽 대화 기록은 그대로입니다 — 즉 서버는 사용자가 다 들었다고 믿습니다.
실시간 음성을 다루고 계시면, 재생 중인 동안 마이크 피크를 로그로 한번 찍어 보세요. 0이 아니면 그게 당신 앱의 목소리입니다.