AI 음성 밀림 복구 절차 설계
서버 발화 중단 · 재attach · 재구독 설계 제안 · 미구현
마지막 업데이트 2026-09-11
외부 리뷰가 제안한 3부 구성(OFF 노드 우회 / 경량 모드 / 복구 절차) 중 이미 밀린 소리를 끊는 복구 절차만이 원인층과 무관하게 효과를 낼 수 있는 층이라고 판단했다. 이 문서는 그 판단 근거, 5단계 복구 절차의 단계별 목적·기존 코드·신규 필요분·리스크, 그리고 “언제 돌리나”의 판단 기준이 아직 확정될 수 없는 이유와 확정 절차를 정리한다. LiveKit·VP 두 모드는 브라우저 재생 경로가 같아 판단이 동일하다.
한 줄 결론
복구 절차(③)를 진행자 수동 버튼으로 먼저 만들어 실기기에서 효과를 검증하고, OFF 노드 우회(①)는 저렴한 상시 절감으로 붙이되, 경량 모드(②)는 dry-run 로그로 상관이 확인되기 전까지 투자하지 않는다. 자동 트리거는 현재 감시가 출력단을 못 보므로 판단 기준을 로그로 확정한 뒤에만 붙인다.
주장 등급: A 코드로 확인 / B 코드 구조 근거 추론 / C 실기기로 확인해야 할 가설.
1. 3부 제안 개선 가능성 판단
| 제안 | 개선 가능성 | 근거 |
|---|---|---|
| ① OFF 노드 실제 우회 (마이크 입력 체인) | 낮~중 | A LiveKit 프로필에서 OFF여도 EQ 3·컴프레서·리미터가 모두 연결된다(livekit-audio-chain-processor.ts:339~346, 리미터는 ratio=1로만 무력화). DynamicsCompressor 2개는 파라미터가 중립이어도 envelope 연산을 하므로 렌더 절감은 실제(B). 그러나 하네스가 150% 부하를 걸어야 재현된 밀림에 비해 절감 폭이 작아 이것만으로 밀림이 사라진다는 근거는 없다(C). 리스크 낮고 저렴 — “하면 좋은 것”. outputGain → agentInputGain / relayDestination 분기는 유지해야 AI 듣기 OFF·진행자 청취·녹음 계약이 깨지지 않는다. |
| ② 경량 모드 (진단 프로브·아바타 RMS 해제) | 낮음 | A 진단 프로브는 호출마다 ctx+analyser를 새로 만들지만(audio-level-observer.ts:29~32) 원격 프로브는 AI 발화 시작 후 1.5초 창에서만 산다(livekit-client-session.ts:2278). 아바타 analyser는 렌더 스레드에선 링버퍼 유지만 하고 FFT는 메인스레드에서 계산한다. 제거해도 렌더 스레드 여유가 거의 늘지 않는다(B). 진입 판정도 문제 — 현재 감시는 출력단 밀림을 못 보고, 진행자는 아동 단말의 로컬 재생 지연을 모니터로 들을 수 없다(모니터는 별도 릴레이 스트림). 마이크 입력 체인 상세는 최소 변경 완화 설계. |
| ③ 복구 절차 (서버 중단 → 재attach/재구독 → 정책 재적용) | 중~높음 | 원인층과 무관하게 누적 지연을 끊는 유일한 층. A 수동 중단 RPC는 이미 있다(livekit-client-session.ts:2395 → agent.py _handle_manual_interrupt). 재구독(setSubscribed)은 코드베이스에 사용처가 없어 신규지만 수신기를 재생성하므로 재attach가 못 비우는 NetEQ 층까지 리셋된다(B). iPad 출력단 하드웨어 버퍼까지 비워지는지는 실기기 확인 필요(C). 비용은 AI 발화 1회 손실 + 진행자 재개. |
2. 복구 절차 5단계
[밀림 확정]
↓ ① 보류 자동 응답·자동 전환 잠시 OFF (복구 중 스텝이 넘어가거나 AI가 새 발화를 시작하지 않게)
↓ ② 턴 보존 아동 발화 중이면 대기 또는 현재 user turn 보존 (복구가 아동 말을 지우지 않게)
↓ ③ 서버 수동 중단 RPC → agent session.interrupt(force) → 완료 이벤트 확인 (새 오디오 유입 차단)
↓ ④ 클라 <audio> 재attach → 효과 없으면 트랙 재구독 setSubscribed(false→true) (누적 버퍼 폐기)
↓ ⑤ 재적용 AI 듣기·진행자 음소거 정책 재적용 후 진행자가 재개
| 단계 | 목적 | 기존 코드 | 신규 필요분 | 빠뜨리면 |
|---|---|---|---|---|
| ① 보류 | 복구 중 부작용 차단 | autoResponseEnabled(use-guest-page-session.ts:376)·자동 전환 토글 | 복구 시작 시 끄고 종료 시 원복하는 래핑 | ③의 중단이 “AI 발화 완료”로 해석돼 종료 멘트 스텝이 자동 전환되거나, 침묵 필러가 새 발화를 시작해 방금 비운 버퍼를 다시 채운다 |
| ② 턴 보존 | 아동 발화 유실 방지 | 서버 핸들러가 turn_guard.manual_interrupt_preserved_turn_id가 있으면 그 턴을 지우지 않고 stale epoch만 정리(agent.py _handle_manual_interrupt) | aiSession.isUserTalking이면 복구를 미루거나 보존 경로로 호출하는 시점 판단 | 중단 RPC가 진행 중인 아동 턴을 함께 지운다 |
| ③ 서버 발화 중단 | 새 오디오 유입 차단 | sendManualInterruptRpc(livekit-client-session.ts:2395) → 서버 manual_interrupt_requested 이벤트 발행 → 미해결 턴 정리 → session.interrupt(force=True) | RPC 반환 또는 agent 발화 stop 이벤트로 “완료” 확인하는 대기 | ④만 하면 서버가 같은 발화의 나머지를 계속 밀어 넣어 버퍼가 다시 쌓인다 |
| ④ 출력 복구 | 누적 버퍼 폐기 | 재attach = 현재 LIVEKIT_PLAYOUT 리셋(detach→attach→게이트 재적용, livekit-client-session.ts:3153) | 재구독 RemoteTrackPublication.setSubscribed(false→true). 재구독 후 TrackSubscribed가 다시 오므로 지금의 attach·감시 시작 경로가 재실행된다 | 재attach는 출력 싱크 층만, 재구독은 NetEQ 층까지. 둘 다 못 비우는 것은 iPad CoreAudio 출력단 하드웨어 버퍼(C) |
| ⑤ 정책 재적용·재개 | 원래 계약 복원 | applyLiveKitRemoteAudioPlaybackGate(muted·play), applyGuestVoiceControlPolicyAtomically(use-guest-page-session.ts:1146, AI 듣기·진행자 음소거) | ①에서 끈 토글 복원, 진행자 재개 신호 | 재구독 뒤 muted 상태나 듣기 의도가 어긋난다. 재구독은 원격 트랙만 건드리므로 마이크 송신·릴레이·녹음 트랙은 영향이 없어야 하지만 실기기 확인 항목(C) |
리스크 요약. AI 발화 1회 손실(의도된 비용) / 재구독 중 무음 수백 ms / ①을 빠뜨리면 스텝 중복 진행 / ②를 빠뜨리면 아동 발화 유실 / CoreAudio 출력단 잔여 버퍼는 이 절차로 못 비울 수 있음(C).
3. 판단 기준 — “언제 돌리나”는 아직 확정할 수 없다
현재 코드에 있는 판단 기준은 하나뿐이고, 그것도 출력단을 못 본다.
| 기준 후보 | 무엇을 보나 | 현재 상태 | 신뢰도 |
|---|---|---|---|
LIVEKIT_PLAYOUT 판정식 | driftMs(엘리먼트 currentTime 결손 + NetEQ 평균 지연) ≥ 400ms가 3샘플 연속 | 구현됨, dry-run(off) | 수신 지터·엘리먼트 클럭 층만 관측(coverage.speakerOutput=false). WebKit MediaStream currentTime이 재생 클럭이면 출력단 밀림에 무반응 가능 C |
inputContextOutputLatencyMs 증가 | 단말 오디오 출력 계층 I/O 버퍼 확대 | 샘플 로그에 동봉만 | 저전력 시 CoreAudio 버퍼 확대 가설과 대응 C. 판정에 안 씀 |
LOW_POWER_PROBE 의심 | rAF 30fps 캡 | 구현됨, 로그만 | 밀림 “조건”(증폭기)이지 밀림 자체가 아님. 단독 트리거 부적합 |
| 진행자·아동 신고 | 사람 청감 | 채널 없음 | 가장 확실하지만 진행자는 아동 단말 로컬 지연을 모니터로 듣지 못해 아동·양육자 신고 의존 |
확정 절차
- 밀림 신고가 있는 실기기 세션에서 신고 시각과 세 지표(
renderDeficitMs·jitterBufferAvgMs·inputContextOutputLatencyMs)를 대조한다. - 반응하는 지표가 있으면 그 지표·임계·지속 시간이 판정 기준이 된다.
renderDeficitMs가 반응하면 지금 판정식 그대로?livekitPlayoutReset=on으로 켜면 되고,inputContextOutputLatencyMs만 반응하면 판정식에 그 항을 넣어야 한다. - 셋 다 무반응이면 브라우저에서 잡을 지표가 없다는 뜻이라 자동 복구는 포기하고 진행자 수동 복구 버튼만 둔다.
4. 권장 구현 순서
- 진행자 수동 복구 버튼 (진행자 채팅 패널 → 소켓 → 게스트가 ①~⑤ 실행). 판단 기준이 없어도 절차의 효과를 실기기에서 검증할 수 있고, “재attach로 풀리는가 / 재구독까지 필요한가 / 둘 다 안 되는가”를 가려 자동화 여부까지 함께 결정한다.
- 효과가 확인되면 OFF 노드 우회(①)를 저렴한 상시 절감으로 추가한다.
- dry-run 로그에서 가청 밀림과 상관이 확인된 지표가 있을 때만 자동 트리거를 붙이고, 경량 모드(②)도 그때 재검토한다.
검증 합격 기준 (두 모드 모두). 밀림 감소 · AI 입력 차단 유지 · 진행자 청취 및 녹음 유지 · 스텝 중복 진행 없음 · 아동 발화 유실 없음. 하나라도 깨지면 자동화하지 않는다.
관련 문서 · 코드
- PPI-1290 · iPad AI 음성 밀림 1차 대응 — 이 문서의 ④ 재attach와 판단 기준 후보(
LIVEKIT_PLAYOUT·LOW_POWER_PROBE)가 구현된 곳. - PPI-1290 · 최소 변경 완화 설계 — 제안 ①·②(마이크 입력 체인 OFF 노드 우회, RMS 간소화)의 상세 설계.
- iPad AI 음성 밀림 재현 — 저전력 모드 × WebAudio 렌더 부하 — 재현 조건과 “현장 부하 원천 미확정”의 출발점.
- 게스트 AI 출력·마이크 입력 파이프라인 — ①에서 유지해야 할 분기 지도.
- apps/web/lib/voice-agent/livekit-client-session.ts(
sendManualInterruptRpc, attach/detach) · apps/livekit-agent/agent.py(_handle_manual_interrupt) · apps/web/lib/voice-agent/livekit-playout-drift-monitor.ts