마지막 업데이트 2026-10-14
사건 ① 아동·진행자 모두 AI 소리를 못 들음 (21:05:52~21:07:09): 아동 브라우저는 AI 음성 패킷을 계속 받았다(packetsReceived 597→1043). 그런데 재생 샘플(totalSamplesReceived)이 960에서 멈췄다. 진행자에게 AI 음성을 넘기는 중계 송출(ai-audio)도 0바이트였다.
사건 ② 진행자는 들리고 아동만 못 들음 (21:07:21~21:07:45): 수신·재생 샘플·중계 송출이 모두 정상이었다. 진행자가 들은 소리는 아동 브라우저를 거쳐 전달되므로 AI 음성은 아동 기기까지 왔다. 남는 곳은 로그 밖의 단말 출력(기기 볼륨·스피커 경로)이다.
이전 문서와의 관계: 변유나 6회기는 packetsReceived가 없어 "SFU가 안 보냈나 / 브라우저가 재생을 못 했나"를 가르지 못했다. 이번 건은 LogRocket webrtc stats에 그 값이 있다. 패킷은 도착했으므로 SFU 미전달은 이 사건에서 기각된다. 디코더 고장도 아니다(손실 보정 0). 남는 것은 브라우저 재생 쪽이 소리를 꺼내 가지 않은 것이고, 이 앱에서 AI 트랙을 꺼내 가는 쪽은 audio 요소 하나다(LiveKit webAudioMix 미사용).
대처: 재현이 안 되므로 원인 확정 대신 감지(패킷 증가·샘플 정지) → 단계 복구 → 단계별 결과 로그로 고객 로그에서 효과와 원인을 함께 검증하는 방안을 제안한다(5절).
패턴: 같은 페이지 안의 AI→AI 활동 전환으로 시작된 세션 5개 중 4개가 무음이었다. 페이지를 새로 로드한 뒤의 첫 세션 3개는 모두 정상이었다. 다만 "새로 로드"는 오디오 요소와 다른 페이지 상태를 함께 초기화하므로, 원인이 요소 재사용인지 다른 상태인지는 아직 가르지 못했다.
// 2026-10-13 KST · 아동 단말 Android 10, Chrome 154 (모바일) · 진행자 세션 로그 없음(아카이브에 guest만) 21:05:52 AI 세션 c0738878 시작 (같은 페이지, 활동 전환) 에이전트 패킷 수신 ○ 597 → 1043 재생 샘플 960 고정 → 아동 스피커 ✗ 중계 송출 0bf2 0바이트 (21:05:58, 재확인 21:06:14) → 진행자 ✗ = 사건 ① 그 사이 AI 발화 6회 (AI_SPEAKING_START) 21:07:04 페이지 로드 (재입장) → 21:07:08 warm-up → AI 세션 5e8d3238 재생 샘플 1,416,960 → 2,856,960, 중계 송출 정상 21:07:21 AI "아니, 막 괴롭힐 마음은…" — 아동 못 들음 21:07:35 아동 발화 "소리가 안나." 21:07:45 도움 요청 "소리가 안 들려요" (ISSUE_DIAG: 출력 probe 정상) 21:07:46 중계 송출 audioLevel 0.23 → 진행자는 들음 = 사건 ② 21:08:14 AI→AI 전환 → 세션 5f7688f6, 중계 송출 7469 0바이트 21:08:25 세션 종료 → 21:08:31 페이지 로드 → 세션 309529c4 정상 21:09:54 AI→AI 전환 → 세션 13e4981a, 재생 샘플 0, 중계 07f8 0바이트
totalSamplesReceived는 재생 쪽이 꺼내 간 디코딩 샘플 누적이다. 48kHz라 1초에 48,000씩, 30초면 약 1,440,000 늘어난다. 960은 20ms, Opus 프레임 하나다. 첫 프레임만 꺼내고 그 뒤로 꺼내지 않았다는 뜻이다.
| AI 세션 | 시각 | 시작 방식 | packetsReceived | totalSamplesReceived | 중계 송출 |
|---|---|---|---|---|---|
| 4cc162bc | 21:00:43~ | 페이지 로드 | 523→2009 | 1.4M→4.3M | f03e 정상 |
| 15d14065 | 21:02~21:04 | AI→AI | 146→1510 | 960 고정 | af46 0 |
| 790f14ce | 21:05:10~ | AI→AI | 692→1339 | 1.4M→2.9M | 1a47 정상 |
| c0738878 | 21:06 | AI→AI | 597→1043 | 960 고정 | 0bf2 0 |
| 5e8d3238 | 21:07:39~ | 페이지 로드 | 680→1307 | 1.4M→2.9M | dba7 정상 |
| 5f7688f6 | 21:08:14~25 | AI→AI | 11초라 통계 미기록 | 7469 0 | |
| 309529c4 | 21:09:05~ | 페이지 로드 | 856→1628 | 1.4M→2.9M | 5be2 정상 |
| 13e4981a | 21:10 | AI→AI | 489→1316 | 0 | 07f8 0 |
6-01a13519-…_0_82e946dd-….json, 20:50:35~21:11:18) — webrtc stats 항목 inbound[0]. c0738878: 줄 89367/89374, 94019/94026 · 13e4981a: 149818/149824, 157555/157561 · 정상 309529c4: 128307/128314lesson-log-20261013.jsonl.gz — Audio producer outbound stalled (ai-audio): af46 줄 1666·1673, 0bf2 4086·4307, 7469 5714, 07f8 7102·7345mediaTrack published. "no worker available" 뒤 곧바로 assigned job to worker라 배정 실패 아님paused:false, muted:false, volume:1, currentTimeAdvanced:true). MediaStream 재생은 소리가 없어도 currentTime이 진행할 수 있어 이 로그로는 렌더 정지를 볼 수 없다.inbound는 아동 브라우저가 LiveKit에서 받는 쪽 AI 트랙의 통계(RTCInboundRtpStreamStats)다. 보내는 쪽은 outbound에 따로 있다.
| 필드 | 값 | 의미 |
|---|---|---|
packetsReceived / bytesReceived | 1043 / 199,608 | 약 21초 분량(패킷당 20ms) 도착 → SFU 전달 정상. 바이트는 압축(Opus) 데이터 크기로 패킷당 약 190바이트 |
packetsLost / jitter | 0 / 0.001 | 네트워크 정상 |
totalSamplesReceived | 960 | 재생 쪽이 꺼내 간 샘플 개수(바이트 아님). 48kHz라 960 = 20ms = Opus 패킷 1개 분량. 재생 쪽은 10ms(480샘플)씩 요청하므로 요청 2번 뒤 멈춤. 0이면 요청이 아예 없었던 것(13e4981a) |
concealedSamples | 0 | 재생 쪽이 계속 요청하는데 디코딩만 실패했다면 손실 보정 샘플이 늘어야 한다. 0이므로 디코더 고장이 아니라 요청 자체가 없었다 |
packetsDiscarded | 1829 | 아무도 꺼내 가지 않아 버려진 패킷. 받은 수보다 큰 것은 audio/red(중복 전송) 조각이 각각 세어진 것으로 추정 |
audioLevel | 0 | 재생으로 나간 소리 없음 |
LiveKit ──패킷──> [수신 ○] → [지터버퍼] ──✗ 요청 없음──> 디코딩·출력 없음 SFU ○ 네트워크 ○ 디코더 ○(보정 0) 렌더러 꺼내가기 ✗ 출력 장치 ○(앞뒤 세션 정상)
ChannelReceive::OnReceivedPayloadData에서 버린다(WebRTC dea374a). "폐기 > 수신, 샘플 정지"와 맞는다.webAudioMix 없이 기본값으로 만든다(livekit-client-session.ts:2179-2182).livekit-client 2.22.4는 webAudioMix일 때만 원격 참가자에게 AudioContext를 넘긴다(livekit-client.esm.mjs:31380-31381, :31416-31417). 그래서 AI 트랙 attach는 WebAudio 연결(:26448)을 건너뛰고 <audio>.srcObject로만 재생된다.muted:false, volume:1도 이와 맞다(WebAudio 경로였다면 LiveKit이 요소를 muted:true, volume:0으로 바꾼다).요소 상태(paused:false, 재생 중)와 내부 렌더러 상태(요청 없음)가 어긋난 것이 관측의 전부다. 무엇이 어긋남을 만들었는지는 미확인이다.
develop 1b9925e3(10/14) 기준. 사건 당일 배포 버전과 다를 수 있다.
stopSession (releaseAudioElement 기본 false) use-ai-session.ts:1361 ├ liveKitSession.disconnect() :1441 │ └ TrackUnsubscribed → track.detach(el) livekit-client-session.ts:3432 └ pauseAndDetachAudio(): el.pause(); el.srcObject = null use-ai-audio-output.ts:169-175 startSession ├ ensureAudioElement() → 같은 요소 반환 use-ai-audio-output.ts:132-150 └ TrackSubscribed → audioTrack.attach(el) → play gate livekit-client-session.ts:3322, :3373 └ onRemoteAudioStream(new MediaStream([track])) → 진행자 중계 producer :3402
| 요소를 새로 만드는 경로 | 재사용하는 경로 |
|---|---|
페이지 로드 후 "준비 완료" 제스처의 warmUpAudio (use-ai-audio-output.ts:179-213), 전체 정리 cleanupAllSessions (use-guest-page-session.ts:1309), 언마운트 (use-ai-session.ts:2769) | 새 활동 전환 (use-step-transition.ts:115), restartCurrentSession (use-guest-page-session.ts:809), 활성 세션 위 재시작 (use-ai-session.ts:1611), 시작 실패 정리 (:2317, :2580) |
guest-layout.tsx:425-426 → :130-134, hasWarmedUpRef)이나 "시작" 버튼(guest-layout-content.tsx:182-187)에서 warmUpAudio가 제스처 안에서 new Audio() + 빈 play()를 한다. 이번 로그의 warm-up 3회(21:00:10, 21:07:08, 21:08:35)가 이것이다.track.detach(el)는 기록만 지우지 않는다. 스트림에서 트랙을 빼고 남은 트랙이 없으면 srcObject = null로 만든다(detachTrack). 이어서 앱도 srcObject = null을 한 번 더 한다(중복). 새 세션의 attach는 새 MediaStream을 만들어 넣는다(attachToElement). 즉 재사용 세션에서도 요소의 스트림은 매번 비워졌다가 새로 들어간다.use-ai-audio-output.ts:177-178 주석). 같은 활동 안의 비AI 스텝은 세션을 끊지 않고 응답 차단만 한다(use-step-transition.ts:236-258).| 가설 | 판정 | 근거 |
|---|---|---|
| SFU·agent가 AI 음성을 안 보냄 | 기각 | 아동 packetsReceived 증가, 서버 트랙 게시 정상 |
| 단말 출력 장치 (사건 ①) | 기각 | 같은 기기·같은 페이지 앞뒤 세션 정상, 출력 probe 정상, 볼륨과 무관한 중계 송출도 0 |
| 단말 출력 장치 (사건 ②) | 유력(로그 밖) | 수신·재생 샘플·중계 정상인데 아동만 못 들음 |
| pause 직후 약 1ms 만에 attach·play 경합 | 기각 (분석 중 정정) | 1ms는 "종료 → 시작 함수 호출" 간격. 실제 attach는 방 접속·구독 뒤라 950~1187ms. 정상 세션(968ms)과 차이 없음 |
| 오디오 요소 재사용 | 미확정 (상관 있음) | 무음 4건 모두 재사용 세션, 새 요소 3건 모두 정상. 단 새 요소 = 페이지 재로드라 다른 상태와 분리 안 됨. 재사용 2회째(21:04:39)는 정상 |
| 빠른 AI→AI 전환 + Room AudioContext 정지 | 수신 쪽은 약화 | 현 설정에서 AI 트랙은 Room AudioContext를 거치지 않는다(2-2절). "빠른 AI→AI 전환에서만 발생"이라는 조건 자체는 이번에도 맞는다(5건 중 4건) |
load() 누락 — 재사용 시 요소 초기화 부족 | 약화 (분석 중 정정) | srcObject 대입 자체가 로드 절차를 다시 실행하고(video-audio-loopback.ts:411 주석), srcObject = null은 Chrome 내부 플레이어를 파괴한다(webrtcHacks). 재사용 세션도 이미 매번 비우고 새로 넣으므로 load() 추가 효과는 작을 수 있다 |
| "JS로는 C++ 오디오 파이프라인을 리셋할 수 없고, 네이티브 바인딩이 끊겼다" | 근거 없음 | JS의 srcObject 조작이 내부 플레이어 생성·파괴를 직접 일으킨다. new Audio()도 JS 조작인데 새 요소 세션은 정상이었다. 출력 장치 쪽은 공유 자원이라 먹통이었다면 다음 재사용 세션(21:04:39 정상)도 영향을 받았어야 한다 |
| 짧은 지연(50~100ms) 후 attach하면 풀린다 | 근거 없음 | 세션 시작 때 이미 약 1초(950~1187ms) 간격으로 비움→attach가 일어났는데도 무음 |
재현 환경을 만들 수 없으므로 원인을 먼저 확정하지 않는다. 정확히 감지했을 때만 가벼운 복구부터 시도하고, 단계별 결과를 로그로 남겨 고객 로그로 효과와 원인을 함께 검증한다.
1초 간격 2회 연속: packetsReceived 증가 > 0 && totalSamplesReceived 증가 = 0 && outputBlocked = false && AI 스텝 && visible // 절대값(≤960) 판정은 세션 중간에 멈추는 경우를 놓친다 → 증가량으로 판정 // 비AI 스텝·출력 차단 중에는 앱이 일부러 play()를 하지 않아 샘플 0이 정상 → 반드시 제외 // 1초 폴링은 playout drift 모니터가 이미 한다: isIdleInterval(livekit-playout-drift-monitor.ts:195, 238-250) + packetsReceived(PPI-1374)
| 단계 | 동작 | 안전성 · 기대 효과 |
|---|---|---|
| ① | 같은 요소 재부착: track.detach(el) → track.attach(el) → 재생 게이트 applyLiveKitRemoteAudioPlaybackGate | 요소·제스처 권한·sinkId 그대로라 회귀 위험 낮음. 효과는 불확실 — 세션 시작 때 이미 한 번 실패한 동작의 반복이다 |
| ② | 요소 교체: 새 Audio()에 attach → play() 성공 그리고 샘플 증가일 때만 교체 확정, 이전 요소 해제 | Chrome Android는 재생 권한이 페이지 단위라 허용될 가능성 높음. iOS·인앱 WebView는 거부될 수 있으나 검증 후 교체라 손해 없음. 효과는 엇갈림 — 새 요소 세션 3/3 정상이었지만 모두 페이지 재로드와 함께였고, 방 전환 문서의 느린 전환 재사용 3/3도 정상이었다 |
| ③ | 아동 화면 "소리 켜기" 버튼 → 탭(제스처) 안에서 요소 생성·attach·play() | 무음이 풀린 유일한 확인 조건(재로드 + 제스처)을 재로드 없이 재현. LiveKit 공식 패턴(AudioPlaybackStatusChanged + startAudio())과 같다 |
| ④ | 진행자 화면 "AI 음성 재연결 필요(재입장 권장)" 알림 | 지금은 사람이 30~60초 뒤에 알아챈다 |
play()를 부르면 비AI 스텝에서 꺼 둔 AI 소리가 영상 위로 흐른다(PPI-907 유형, use-step-transition.ts:236-258).sessionAttempt·연결 유지·트랙 구독 유지를 확인한 뒤에만 attach. 끝난 세션 트랙을 다시 붙이지 않는다.play() 약속이 남은 채 pause()되면 AbortError → catch 후 로그만.reset(livekit-client-session.ts:3350-3354, 현재 mode:'off')과 코드를 공유해 이중 동작을 막는다.ai-audio 중계 송출 바이트가 다시 흐르는지 함께 기록한다.| 환경 | 제스처 밖 new Audio() | 이유 |
|---|---|---|
| Android·데스크톱 Chrome | 대체로 허용 | 재생 권한이 페이지 단위(이미 터치함) |
| Android 인앱 WebView | 거부 가능 | mediaPlaybackRequiresUserGesture 기본값이 요소마다 제스처 요구 |
| iOS·iPadOS | 불확실 | 제스처로 시작한 요소만 허용이 원칙. 단 캡처 중인 페이지의 MediaStream 재생은 자동재생 허용. setSinkId는 제스처 밖에서 거부될 수 있음(PPI-1192) |
단계마다 stage, 시도 전후 packetsReceived·totalSamplesReceived 증가량, 재생 게이트 결과, 중계 bytes, UA를 남긴다. "①에서 복구" 비율이 높으면 요소 상태 문제, "②에서 복구"면 요소 객체 문제, "③까지 감"이면 페이지 상태·권한 문제로 원인이 좁혀진다. 권장 순서는 감지·로그 + ① + ③ 먼저, 플랫폼별 play() 거부율을 본 뒤 ② 추가.
ppi-issue-classifier 정답지에 두 사례를 등록하고 판정 가드를 추가했다.
| 사례 | 창 | 정답 | 판정 |
|---|---|---|---|
1013-ai-silent-both | 21:05:20~21:07:20 | server.audio | 적중 (가드 ai_relay_stall_persisted ≥ 1) |
1013-ai-silent-child | 21:06:30~21:08:30 | child.volume | 놓침 — 출력측을 보여 주는 로그가 없음 |
server.audio로 등록했다. 이번 분석으로 SFU는 패킷을 보냈고 아동 브라우저가 재생하지 않은 것이 확인됐으므로, 위치는 child.audio가 더 맞다. 재등록 여부를 결정해야 한다.