LiveKit 방 전환 레이스 — 아동 마이크·AI 오디오 무음 송출 원인 분석
마지막 업데이트 2026-09-12
2026-08-13 최초 작성 · 2026-08-14 정정 · 세션 로그 2건(2026-08-09 · 2026-08-12, 모두 Android Chrome) · 브랜치 PPI-1196(계측 적용, 원인 미확정)
결론
확정 범위: 발생 조건, 상향·하향 동시 무음, 그리고 DataChannel 생존은 로그로 확정이다. AudioContext가 원인이라는 것은 아직 증명되지 않았다 — 컨텍스트 상태를 관측한 로그가 없어 PPI-1196에 계측을 넣은 단계다.
2026-08-14 정정: 최초 작성 시 원인 후보로 지목한 disconnect() 재진입 가드 레이스는 실제로는 발생하지 않았다. 상세는 아래 "정정 — disconnect 레이스는 원인이 아니다" 절 참고. 그리고 무음 범위를 "마이크 처리 그래프"로 좁혔던 것도 정정한다.
관측 — 전환 간격이 유일한 판별자
| 전환 유형 | 건수 | 스톨(0 packet) 발생 |
|---|---|---|
| disconnect → connect 2초 미만 | 10 | 10 / 10 |
| disconnect → connect 8초 이상 | 8 | 0 / 8 |
두 세션(019fe408 4/4·0/5, 019ff548 6/6·0/3)에서 예외가 없다. 다만 느린 전환 8건 중 5~6건은 진행자 kick 뒤 페이지 리로드라 전체가 재초기화된 오염 표본이다. 간격만 다르고 코드 경로가 같은 순수 비교군은 영상 스텝이 끼어든 3건뿐이며, 3/3 정상이었다.
빠른 전환의 정체는 full:ai ↔ split:image 처럼 세션이 유지되는 스텝끼리의 직결 이동이다. 느린 전환은 full:video 스텝이 통째로 끼어 non_ai_step_session_blocked로 세션이 멈춘 경우다.
연결 문제가 아니라 소스 무음인 근거
스톨이 발생한 방에서는 예외 없이 에이전트의 user_turn_committed·user_stt_segment가 0건이었다. 그 사이에도 에이전트는 계속 말하고 있었다.
10:05:50 방 45370376 접속 → 10:06:48 이탈 (58초)
agent_response_started ×3 (핑퐁이는 정상 발화)
user_turn_committed ×0 (아동 목소리 도달 0)
mediasoup mic-audio bytesSent 0 / packetsSent 0
track=live, muted=false, transport=connected
mediasoup 레그만의 문제라면 에이전트는 정상으로 들었어야 한다. 같은 방 안에서 자가 회복은 없었고, 다음 전환이 느리게 일어날 때만 복구됐다.
운영 임팩트: 진행자가 무음을 인지하고 아동을 수동으로 강제 퇴장·재입장시킨 것이 두 세션 합계 6회다. 매번 스톨 발생 30~50초 뒤였다.
상향만이 아니다 — AI 수신 오디오도 함께 죽는다
AI 재생 엘리먼트 health 로그가 스톨 방에서 재생 시계 정지를 보여준다. 엘리먼트 자체는 멀쩡하다.
10:00:29 (정상 방) currentTime 4.121 currentTimeAdvanced true
10:01:36 (스톨 방) currentTime 0.01 currentTimeAdvanced false
paused false · muted false · volume 1 · readyState 4 · srcKind srcObject
AI 오디오를 외부 STT로 흘려보내는 캡처량도 같은 방향이다.
| 방 | 길이 | AI 오디오 캡처 청크 | AI 전사 |
|---|---|---|---|
| STALL d3cb607a | 43초 | 16 | 0 (에이전트는 4회 발화) |
| STALL 45370376 | 57초 | 0 | 0 |
| 정상 6dabb23a | 195초 | 99 | 37 |
| 정상 7ff2a4bf | 297초 | 99 | 54 |
audio element cleanup 레이스는 배제된다. 이전 세션 정리가 늦게 실행돼 스트림을 떼었다면 srcKind가 none이어야 하고, pause()가 늦게 실행됐다면 paused가 true여야 한다. 둘 다 반대다. 로그 순서도 정상이다 — AI audio capture stopped(10:01:18.339) → track subscribed(10:01:19.915) → capture started(10:01:19.928).
통합 설명 후보 — 송수신이 공유하는 AudioContext
LiveKit은 수신 트랙에도 Room의 AudioContext를 조건 없이 넘기고, attach 시 WebAudio를 경유해 재생한다. 송신 쪽 setProcessor가 받는 컨텍스트도 같은 Room 인스턴스의 것이다.
// livekit-client.esm.mjs:29468 — 수신
track = new RemoteAudioTrack(mediaTrack, sid, receiver, this.audioContext, this.audioOutput);
// :26448 — attach 시 WebAudio 경유
if (this.audioContext && needsNewWebAudioConnection) {
this.connectWebAudio(this.audioContext, element);
}
// :31383 — 송신(setProcessor의 buildGraph가 쓰는 컨텍스트)
this.localParticipant.setAudioContext(this.audioContext);
왜 빠른 전환에서만인지도 이 그림과 맞는다. LiveKit acquireAudioContext()는 방마다 새 컨텍스트를 만들고, suspended면 Promise.race([resume(), sleep(200)])로 200ms만 기다리고 진행한다. 직전 방의 audioContext.close()가 아직 진행 중일수록 새 컨텍스트의 resume이 늦어질 여지가 있다.
정정 — disconnect 레이스는 원인이 아니다
최초 작성 시 stopSession의 abort()가 abortHandler의 void disconnect()를 통해 정리를 기다리지 않고 시작시킨다고 판단했다. 로그를 정밀 대조한 결과 이 레이스는 발생하지 않았다. 스톨이 난 10건을 포함해 모든 전환에서 순서가 지켜졌다.
unpublishing track → room disconnected → Session stopped → stop_session_end → livekit_connect_begin
이유는 livekit-client-session.ts:1907이다. connectLiveKitBrowserSession이 세션 객체를 return할 때 finally가 실행되며 abortHandler를 제거하므로, 연결에 성공한 세션에서는 abort()가 disconnect()를 트리거하지 않는다.
} finally {
args.signal?.removeEventListener("abort", abortHandler);
}
따라서 PPI-1196에 적용한 disconnect in-flight promise 공유는 연결 실패 구간에만 의미가 있는 구조적 방어이며, 이번 무음의 원인 수정이 아니다.
인과 체인
로그상 Room A 정리 완료부터 Room B의 새 처리 그래프 생성까지 약 480ms다. 영상 스텝이 끼면 이 간격이 20초 이상 벌어지고 증상도 사라진다.
이 체인의 마지막 두 칸은 아직 관측되지 않았다. 컨텍스트 상태를 남기는 로그가 없어서다. PPI-1196의 계측이 이 구간을 채우기 위한 것이다.
배제한 가설
| 가설 | 판정 |
|---|---|
| raw 마이크 트랙 자체가 죽음 | 배제 — 방이 바뀌어도 동일 trackId이고, 같은 트랙으로 직전·직후 방에서는 정상 동작 |
게이트(gateGain)가 닫혀 감쇠 | 배제 — 게이트가 닫혀도 인코더는 프레임을 받으므로 packetsSent가 0이 될 수 없다 |
| AudioContext가 닫히지 않고 방마다 누적 | 배제 — webAudioMix 기본값이 false(boolean)라 handleDisconnect()에서 close()가 실제 실행된다 |
| 스톨 때문에 진행자가 스텝을 빨리 넘김(역인과) | 배제 — 빠른 전환은 영상 종료·auto-finish로 stop/start가 같은 tick에서 연속 호출된 것 |
disconnect() 재진입 가드 레이스 | 배제 — 모든 전환에서 정리가 새 방 시작보다 먼저 완료됐다. abortHandler는 연결 성공 시 :1907 finally에서 제거된다 |
| audio element cleanup 레이스 | 배제 — 관측값이 반대다(srcObject 유지, paused false) |
| ai-audio만의 clone 무음 | 배제 — AI 수신 자체가 죽어 있어(재생 시계 정지·캡처 0) clone 이전 단계의 문제다 |
남은 후보와 미확정 지점
- (유력) Room AudioContext가 suspended인 채 통과 —
acquireAudioContext()가Promise.race([resume(), sleep(200)])로 200ms만 기다린다. 송신 그래프와 수신 재생이 이 컨텍스트를 공유하므로 양방향 동시 무음이 한 번에 설명된다 - 새 그래프의 소스 노드가 무음 —
livekit-audio-chain-processor.ts:135buildGraph()가 직전 컨텍스트close()완료 전에 같은 트랙으로createMediaStreamSource를 만든다. 다만 이 가설만으로는 수신 재생 정지를 설명하지 못한다 - 확인 필요 — health 로그의
muted:false / volume:1은 앱 audioElement 기준이라, LiveKit이connectWebAudio후muted = true로 만드는 엘리먼트와 동일한지 확인해야 재생 경로 해석이 단단해진다
기존 계측의 한계(PPI-1196에서 보완): outbound 통계는 producer 생성 +5초 1회뿐이라 자가 회복 여부를 알 수 없었고, LiveKit의 1초 주기 client_audio_level은 emitDebug 경로여서 세션 로그에 0건이다. AI 재생 health 로그도 speech-start 트리거 단발이다.
재발 시 판별법
- 게스트 로그에서
Audio producer outbound stalled(bytesSentDelta 0)를 찾는다 - 직전
LiveKit room disconnected→ 다음Published LiveKit local audio track간격을 잰다. 2초 미만이면 이 건 - 같은 방 구간의
agent-event-log에서user_turn_committed가 0건인지 확인한다. 0건이면 mediasoup 레그가 아니라 소스 무음 — 모니터 연결을 의심할 필요가 없다 - 하향도 함께 죽었는지 확인한다 —
AI audio element health의currentTimeAdvanced가 false이거나 같은 구간 AI 오디오 캡처 청크가 0이면 양방향이다. 이때agent_response_started는 계속 찍히는데(DataChannel 경유) 소리만 없다 - PPI-1196 배포 후에는
Audio chain not rendering의state·currentTimeAdvanced로 컨텍스트 원인을 직접 확인한다
PPI-1196에 적용한 것 — 계측 우선
원인이 확정되지 않았으므로 동작을 바꾸는 수정은 최소로 두고 진단 수단을 먼저 넣었다. 시간 기반 대기는 넣지 않았다 — 정상 케이스 간격이 20초대라 값의 근거가 없고, 실패 조건이 시간이 아니라 상태(suspended)라면 대기로는 고쳐지지 않는다.
| 변경 | 성격 |
|---|---|
livekit-audio-chain-processor.ts — buildGraph 직후 · statechange · 5초 주기로 state/currentTime/currentTimeAdvanced/lastLevelDb 기록. 미전진이면 Audio chain not rendering warn | 진단 — 송수신이 공유하는 컨텍스트라 한 곳에서 양방향 모두 판별된다 |
use-mediasoup-producer.ts — 스톨 감지 15초 뒤 같은 producer 재probe(1회) | 진단 — 자가 회복 여부 확인 |
livekit-client-session.ts — disconnect in-flight promise 공유 + 3초 상한 + cleanup completed 로그 | 구조적 방어 — 이번 무음의 원인 수정이 아니다(위 정정 절) |
currentTime 전진 여부가 핵심 지표다. suspended면 멈추므로 아동 발화 여부와 무관하게 판별된다. lastLevelDb 단발은 발화가 없으면 무음으로 나와 해석되지 않는다.
실기기 확인 대기: ① AI 연속 전환에서 양방향 오디오 정상 여부 ② cleanup completed가 항상 다음 livekit_connect_begin보다 먼저 찍히는지 ③ cleanup elapsedMs가 3초 상한에 걸려 전환 지연이 체감되는지 ④ 재발 시 Audio chain not rendering의 state 값. 자동 검증(타입체크·테스트)만 통과한 상태다.
관련 문서
- PPI-1192 — iPad LiveKit AI 발화 무음과 setSinkId 사용자 제스처 수정 (AI 발화만 무음이고 마이크는 정상이었던 사례 — 이번 건은 양방향이 함께 죽어 대비된다)
- iOS WebKit clone track 무음 (PPI-1121) (clone 트랙 무음 — 이번 건에서는 배제됐으나 판별 대조군)
- 김민호 17회기 AI 발화 토막 — TTS 공급 붕괴와 재생 버퍼 부재 (같은 "연결은 살아 있는데 소리가 이상한" 계열이지만 그쪽은 완전 무음, 이번 문서는 간헐 토막이다. 이 문서의 판별 지표
currentTimeAdvanced가 그쪽에서는false, 토막 사건에서는true로 갈린다) - 아동측 미디어·세션 이상 전반 — 원인 판별용 로깅 추가 범위
- LiveKit 음성 에이전트 흐름과 두 SFU 구조
조사 대상 코드: apps/web/lib/voice-agent/livekit-client-session.ts · apps/web/lib/voice-agent/livekit-audio-chain-processor.ts · apps/web/entities/guest-session/model/use-ai-session.ts · apps/web/hooks/mediasoup/use-mediasoup-producer.ts · apps/web/entities/guest-page-session/model/use-step-transition.ts