LiveKit 방 전환 레이스 — 아동 마이크·AI 오디오 무음 송출 원인 분석

마지막 업데이트 2026-09-12

쉬운 설명 한 장 보기 · 사전지식 없이 읽는 요약

2026-08-13 최초 작성 · 2026-08-14 정정 · 세션 로그 2건(2026-08-09 · 2026-08-12, 모두 Android Chrome) · 브랜치 PPI-1196(계측 적용, 원인 미확정)

결론

아동 마이크가 진행자 모니터에 전달되지 않은 사건은 SFU 연결 문제가 아니라 오디오 소스가 무음이었다. 그리고 마이크만의 문제가 아니라 같은 방에서 AI 수신 오디오도 함께 죽어 있었다 — 상향·하향이 동시에 멈춘다. 발생 조건은 AI 활동이 연속으로 이어져 방 전환이 2초 안에 끝난 경우로 10/10 재현됐다. 관측 전부를 하나로 설명하는 지점은 LiveKit Room이 송신 처리 그래프와 수신 재생에 공유하는 AudioContext이며, 이 컨텍스트가 렌더링을 멈춘 것으로 보인다. DataChannel은 AudioContext와 무관하므로 에이전트 이벤트는 계속 수신됐고, 그래서 "연결은 살아 있는데 소리만 없는" 모습이 됐다.

확정 범위: 발생 조건, 상향·하향 동시 무음, 그리고 DataChannel 생존은 로그로 확정이다. AudioContext가 원인이라는 것은 아직 증명되지 않았다 — 컨텍스트 상태를 관측한 로그가 없어 PPI-1196에 계측을 넣은 단계다.

2026-08-14 정정: 최초 작성 시 원인 후보로 지목한 disconnect() 재진입 가드 레이스는 실제로는 발생하지 않았다. 상세는 아래 "정정 — disconnect 레이스는 원인이 아니다" 절 참고. 그리고 무음 범위를 "마이크 처리 그래프"로 좁혔던 것도 정정한다.

관측 — 전환 간격이 유일한 판별자

전환 유형건수스톨(0 packet) 발생
disconnect → connect 2초 미만1010 / 10
disconnect → connect 8초 이상80 / 8

두 세션(019fe408 4/4·0/5, 019ff548 6/6·0/3)에서 예외가 없다. 다만 느린 전환 8건 중 5~6건은 진행자 kick 뒤 페이지 리로드라 전체가 재초기화된 오염 표본이다. 간격만 다르고 코드 경로가 같은 순수 비교군은 영상 스텝이 끼어든 3건뿐이며, 3/3 정상이었다.

빠른 전환의 정체는 full:aisplit:image 처럼 세션이 유지되는 스텝끼리의 직결 이동이다. 느린 전환은 full:video 스텝이 통째로 끼어 non_ai_step_session_blocked로 세션이 멈춘 경우다.

연결 문제가 아니라 소스 무음인 근거

스톨이 발생한 방에서는 예외 없이 에이전트의 user_turn_committed·user_stt_segment0건이었다. 그 사이에도 에이전트는 계속 말하고 있었다.

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 d3cb607a43초160 (에이전트는 4회 발화)
STALL 4537037657초00
정상 6dabb23a195초9937
정상 7ff2a4bf297초9954

audio element cleanup 레이스는 배제된다. 이전 세션 정리가 늦게 실행돼 스트림을 떼었다면 srcKindnone이어야 하고, pause()가 늦게 실행됐다면 pausedtrue여야 한다. 둘 다 반대다. 로그 순서도 정상이다 — 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);
Room AudioContext 정지
송신 그래프 출력 0에이전트 user_turn 0 · mediasoup mic 0 packets
수신 재생 정지currentTime 정지 · AI 캡처 0 · mediasoup ai 0 packets
DataChannel 무관agent-event-log 정상 수신

왜 빠른 전환에서만인지도 이 그림과 맞는다. LiveKit acquireAudioContext()는 방마다 새 컨텍스트를 만들고, suspended면 Promise.race([resume(), sleep(200)])200ms만 기다리고 진행한다. 직전 방의 audioContext.close()가 아직 진행 중일수록 새 컨텍스트의 resume이 늦어질 여지가 있다.

정정 — disconnect 레이스는 원인이 아니다

최초 작성 시 stopSessionabort()abortHandlervoid 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 공유는 연결 실패 구간에만 의미가 있는 구조적 방어이며, 이번 무음의 원인 수정이 아니다.

인과 체인

활동 전환(AI → AI)Room A 정리 완료Room A AudioContext close() 호출
0.2~0.7초 뒤 Room B connect새 AudioContext, suspended면 200ms만 대기
컨텍스트 렌더링 정지송신 그래프 0 · 수신 재생 0양방향 무음

로그상 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 이전 단계의 문제다

남은 후보와 미확정 지점

  1. (유력) Room AudioContext가 suspended인 채 통과acquireAudioContext()Promise.race([resume(), sleep(200)])로 200ms만 기다린다. 송신 그래프와 수신 재생이 이 컨텍스트를 공유하므로 양방향 동시 무음이 한 번에 설명된다
  2. 새 그래프의 소스 노드가 무음livekit-audio-chain-processor.ts:135 buildGraph()가 직전 컨텍스트 close() 완료 전에 같은 트랙으로 createMediaStreamSource를 만든다. 다만 이 가설만으로는 수신 재생 정지를 설명하지 못한다
  3. 확인 필요 — health 로그의 muted:false / volume:1은 앱 audioElement 기준이라, LiveKit이 connectWebAudiomuted = true로 만드는 엘리먼트와 동일한지 확인해야 재생 경로 해석이 단단해진다

기존 계측의 한계(PPI-1196에서 보완): outbound 통계는 producer 생성 +5초 1회뿐이라 자가 회복 여부를 알 수 없었고, LiveKit의 1초 주기 client_audio_levelemitDebug 경로여서 세션 로그에 0건이다. AI 재생 health 로그도 speech-start 트리거 단발이다.

재발 시 판별법

  1. 게스트 로그에서 Audio producer outbound stalled(bytesSentDelta 0)를 찾는다
  2. 직전 LiveKit room disconnected → 다음 Published LiveKit local audio track 간격을 잰다. 2초 미만이면 이 건
  3. 같은 방 구간의 agent-event-log에서 user_turn_committed가 0건인지 확인한다. 0건이면 mediasoup 레그가 아니라 소스 무음 — 모니터 연결을 의심할 필요가 없다
  4. 하향도 함께 죽었는지 확인한다AI audio element healthcurrentTimeAdvanced가 false이거나 같은 구간 AI 오디오 캡처 청크가 0이면 양방향이다. 이때 agent_response_started는 계속 찍히는데(DataChannel 경유) 소리만 없다
  5. PPI-1196 배포 후에는 Audio chain not renderingstate·currentTimeAdvanced로 컨텍스트 원인을 직접 확인한다

PPI-1196에 적용한 것 — 계측 우선

원인이 확정되지 않았으므로 동작을 바꾸는 수정은 최소로 두고 진단 수단을 먼저 넣었다. 시간 기반 대기는 넣지 않았다 — 정상 케이스 간격이 20초대라 값의 근거가 없고, 실패 조건이 시간이 아니라 상태(suspended)라면 대기로는 고쳐지지 않는다.

변경성격
livekit-audio-chain-processor.tsbuildGraph 직후 · 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 renderingstate 값. 자동 검증(타입체크·테스트)만 통과한 상태다.

관련 문서

조사 대상 코드: 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