원준호 45·46·47회기 “지지직”
— 지터·오디오 클럭 언더런 계측 판별 직접 원인 1건 확정 · 근본 원인 미확정 · 수정 미적용

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

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

작성일: 2026-08-31사건: 2026-08-27 20:29–20:49 KST (47회기)대조군: 45회기 08-18, 46회기 08-20room: c14d884f…_45/_46/_47대상: 클라이언트 세션로그 12,543 / 11,601 / 7,032건런타임: LiveKit

결론

네트워크가 아니라 아동 단말의 오디오 렌더 언더런이다. 47회기 20:47:58–20:48:30 30초 동안 연속 6회, 아동 기기의 AudioContext 클럭이 실시간의 90~93%로 계속 뒤처졌다(부족 288~506ms/5초). 같은 아이폰으로 9분 더 긴 45회기는 이 현상이 0회다. 이 체인은 ppi-livekit-input-audio-chain아동 마이크 입력 처리 그래프이고, 여기서 나온 트랙이 LiveKit 에이전트와 진행자 릴레이 양쪽으로 나가므로 언더런 하나가 “아동 마이크 지지직”을 직접 설명한다. 네트워크를 타기 전에 이미 단말 안에서 깨졌다.

근본 원인은 미확정이다. 언더런을 일으킨 것이 CPU 경합인지 발열 스로틀인지 오디오 세션 간섭인지 가릴 수단이 로그에 없다. 발열 가설은 반대 증거가 있다(아래 “발열 가설 기각 근거”).

시간순 인과 다이어그램

[47회기 아동 iPhone]
20:32:48  입장 · mediasoup send transport connected · LiveKit room connected
   ↓
20:38:11  네트워크 경보 rttHigh (rtt 503ms) — 1초 만에 해제, 손실 0
   ↓
20:40:59~20:43:34  진행자 수신 지터 완만한 상승 13 → 16 → 15ms
   ↓
20:44:49  아동 탭 visibility false → guest-disconnected · peer-left
20:45:29  아동 재입장 (페이지 재진입, 리소스 캐싱·미디어 재획득)
20:45:39  [진행자측] video element reset → unexpected pause → seek → SEEK_REBUFFER
20:45:40  지터 35ms · 수신 34패킷/초 (정상 50)    ← 측정 창이 양쪽 초기화 폭주와 겹침
   ↓
20:47:32  LiveKit room 재연결 · 오디오 체인 재생성
   ↓
20:47:58 ┐
20:48:05 │
20:48:10 │  AudioContext 클럭 부족 288 / 380 / 344 / 376 / 394 / 352 / 486ms
20:48:15 │  ← 벽시계 Δ는 5002~5043ms 로 정시. 느려진 쪽은 오디오 클럭.
20:48:20 │     = 오디오 렌더 언더런 30초 연속
20:48:25 │
20:48:30 ┘
   ↓
   ├→ 마이크 입력 체인이 깨짐 → LiveKit AI가 듣는 소리 + 진행자 릴레이 동시 오염
   └→ 같은 기기 오디오 렌더 자원 부족 → AI 출력 재생도 함께 깨짐 (경로는 다름)
   ↓
20:48:56  탭 visibility false (타이머 스로틀 — 25초 로그 공백)
20:49:01  세션 종료

20:45:40 지터 급등과 20:47:58 언더런은 2분 떨어진 별개 사건이다. 하나로 묶어 읽지 말 것. 신고 시각이 어느 쪽이었는지 확인되면 원인이 갈린다.

계측 3종 비교 — 45(iPhone) / 46(Android) / 47(iPhone)

45와 47은 같은 아이폰(deviceId 동일, outputDeviceLabels [기본-스피커/스피커/수신기]), 46은 안드로이드다. 동일 기기 비교는 45 ↔ 47.

지표45회기46회기47회기
지속28.5분26.7분19.3분
지터 중앙값 (mic-audio)4.5ms3.0ms10.5ms
지터 최대13ms11ms35ms
지터 추세 (회귀)+0.09ms/분 (r=+0.21)−0.04ms/분 (r=−0.07)+1.12ms/분 (r=+0.67)
수신 패킷 최소49/초50/초34/초
오디오 클럭 부족 >200ms1회 (재입장 suspend)0회6회 연속 · 30초
메인스레드 콜백 지연 >200ms1회0회1회 차이 없음
LiveKit room 재연결0.32회/분0.45회/분0.31회/분 차이 없음
mediasoup transport 실패000 정상
소켓 끊김6회0회0회 47이 더 깨끗
큰 줄기 판정: packet loss ✗ 미측정(판정 불가) · LiveKit 연결 ✗ 배제 · mediasoup 연결 ✗ 배제 · 전송 타이밍(지터) ⚠ 열화 확인 · 단말 오디오 렌더 언더런 ✅ 확진

핵심 판별법 — 메인스레드 정지와 오디오 렌더 언더런 가르기

LIVEKIT_AUDIO_CHAIN | Audio chain healthsetInterval(5000) 로 5초마다 AudioContext.currentTime 을 남긴다(lib/voice-agent/livekit-audio-chain-processor.ts:283). 로그의 tsDate.now() 를 호출 시점에 동기 기록한다(logger.js:87). 두 시계가 독립적이라 어긋남의 방향으로 원인이 갈린다.

drift = Δts − Δ(currentTime × 1000)

Δts ≈ 5000  이고 Δcurrentime 이 작다   → 오디오 클럭이 느림  = 오디오 렌더 언더런
Δts ≫ 5000  이고 둘이 같이 늘어남      → 콜백이 늦게 불림    = 메인스레드 정지 / 타이머 스로틀

47회기 실측 — 벽시계 Δ가 전부 5002~5043ms 로 정시다. 메인스레드는 멎지 않았다.

20:47:58  Δ벽시계 5002ms   Δ오디오클럭 4712ms   부족 288ms
20:48:10  Δ벽시계 5012ms   Δ오디오클럭 4656ms   부족 344ms
20:48:15  Δ벽시계 5035ms   Δ오디오클럭 4624ms   부족 376ms
20:48:20  Δ벽시계 5023ms   Δ오디오클럭 4606ms   부족 394ms
20:48:25  Δ벽시계 5043ms   Δ오디오클럭 4648ms   부족 352ms
20:48:30  Δ벽시계 5020ms   Δ오디오클럭 4514ms   부족 486ms
집계 함정 2종 — 이걸 안 걸면 오탐이 난다.
  • 체인 재생성 경계를 넘어 계산Audio chain graph built 마다 currentTime 이 0부터 다시 시작한다. 기준을 리셋하지 않으면 12초짜리 가짜 지연이 잡힌다.
  • 탭 백그라운드 전환 — 20:48:56 isVisible:false 직후 25초간 타이머가 스로틀돼 로그가 비고, 그 구간을 정상 틱으로 세면 3,114ms 가짜 정지가 잡힌다.
정상 틱(Δts 4000~7000ms)만 남기고, 체인 재생성마다 기준을 끊어야 한다.

이 판별법이 왜 중요한가: 두 원인은 대응이 완전히 다르다. 메인스레드 정지는 JS 부하(렌더·타이머·동기 작업)를 줄여야 하고, 오디오 렌더 언더런은 오디오 그래프 자체의 부담(노드 수·워클릿·동시 컨텍스트)이나 기기 자원을 봐야 한다.

지터 — 열화는 맞지만 표본이 성기다

MONITOR_CONSUMER | Consumer inbound statsjitter(RFC 3550 interarrival jitter, 단위 ). 20ms Opus 패킷이 얼마나 들쭉날쭉 도착하는가이며, mediasoup은 RTP 타임스탬프를 그대로 포워딩하므로 아동 업링크의 흔들림도 이 값에 실려 온다. 다만 어느 홉에서 생겼는지는 이 값만으로 못 가른다.

47회기 mic-audio 지터 (활동 전환 시점마다 1점)
 0.0분   9ms   ┃█████████
 1.9분  10ms   ┃██████████
 3.3분   9ms   ┃█████████      ← 여기까지 8분간 평평
 7.2분   9ms   ┃█████████
 8.0분  13ms   ┃█████████████  ← 상승 시작
 9.1분  16ms   ┃████████████████
10.5분  15ms   ┃███████████████
12.6분  35ms   ┃███████████████████████████████████  수신 34/초
14.7분  17ms   ┃█████████████████
계측 편향에 주의. 이 값은 consumer 생성 5초 뒤 + 1초 델타 창에서만 찍힌다(hooks/mediasoup/use-mediasoup-consumer.ts:12-14). 즉 활동 전환 직후 — 클라이언트가 가장 바쁜 순간 — 만 표본에 들어온다. 20분 세션에 9점뿐이고, 점 사이 구간은 관측 공백이다.

12.6분(20:45:40) 35ms 는 특히 조심해서 읽어야 한다. 1초 전 진행자 쪽에서 Resetting video elementUnexpected pauseMonitor video seekingSEEK_REBUFFER 가 연달아 일어났고, 아동 쪽은 재입장 6초 뒤였다. 양쪽 초기화 폭주가 측정 창에 겹친 값이라, 회선 열화의 증거로 쓰면 과대 해석이다. 같은 아이폰의 45회기도 20:45:19에 동일한 재입장을 했지만 11초 뒤 표본은 4ms/51패킷으로 깨끗했다 — 재입장 자체가 원인은 아니다.

packet loss는 “배제”가 아니라 “미측정”이다

초기 판정에서 “손실 0이므로 네트워크 배제”라고 적었으나 틀렸다. use-mediasoup-consumer.ts:101packetsLost 를 델타가 아니라 샘플 시점의 누적값 그대로 기록하는데, consumer가 활동마다 새로 생성돼 매번 0에서 시작한다.

const data = {
  ...
  packetsLost: second.packetsLost,   // 누적값 · 델타 아님
  jitter: second.jitter,
};

따라서 로그의 “손실 0”이 덮는 구간은 각 consumer의 첫 6초뿐이다. 그 사이 수 분간의 손실은 측정되지 않았다. 손실을 배제하려면 별도 계측이 필요하다.

발열 가설 기각 근거

근거내용
더 긴 세션이 멀쩡같은 아이폰으로 28.5분 돌린 45회기의 언더런 0회. 누적 발열이면 9분 더 긴 쪽이 더 나빠야 한다.
시간 순서 어긋남지터 급등 12.6분 → 언더런 18.2분. 발열이 공통 원인이면 언더런이 선행하거나 동반해야 하는데 2분 늦다.
급등 시점에 다른 설명35ms 스파이크 1초 전 진행자측 영상 시크·리버퍼. 수신단 경합으로도 동일하게 설명된다.
다만 발열을 확인할 수단 자체가 없다. iOS는 웹에 온도·스로틀 상태를 노출하지 않고, 이 프로젝트는 비디오 producer 통계를 로깅하지 않는다(오디오 outbound만). 발열의 웹 관측 대리지표인 frameWidth 하락 · framesPerSecond 하락 · totalEncodeTime 증가가 전부 미수집이다. 기각 근거는 “정황상 아니다”이지 “아님이 확정됐다”가 아니다.

AI 오디오 엘리먼트 클럭은 이 판별에 못 쓴다

AI_SESSION | AI audio element health(entities/guest-session/model/use-ai-session.ts:146)의 currentTimeHTMLAudioElement재생 위치다. 렌더가 밀려도 브라우저는 무음을 재생할 뿐 시계는 그대로 흐르므로 언더런이 원리적으로 관측되지 않는다.

AudioContext.currentTimeaudio.currentTime (srcObject)
의미렌더링한 샘플 수 ÷ 샘플레이트재생 위치
렌더 부족 시시계가 느려짐 → 탐지 가능그대로 → 탐지 불가
주기5초 고정발화 시작 트리거 + 60초 최소 간격
리셋체인 재생성 시srcObject 재할당 시

실측으로도 연속 측정 가능한 구간의 오차는 세 회기 모두 ±61ms 이내로 차이가 없다(45: −2/−364/−3/−3/−1/−3/−1/−5, 46: +24/+44/−39/+61/−41/−2, 47: −1/−1/−1). 이 로그는 “무음·일시정지” 판별용이지 글리치 판별용이 아니다. 47회기는 언더런 구간(20:47:58~20:48:30)에 이 로그가 아예 없다 — 60초 제한 때문에 다음 표본 전에 세션이 끝났다.

계측 공백 — 이 분석으로 말할 수 없는 것

항목상태
지지직이 실제로 들린 시각미확정 — 신고에 시각 없음. 20:45:40(지터)인지 20:47:58(언더런)인지 갈리지 않는다
packet loss미측정 — consumer 첫 6초만 관측
지터가 생긴 홉미확정 — 아동 업링크 / SFU / 진행자 다운링크 셋 다 가능
언더런의 근본 원인미확정 — CPU 경합 / 발열 / 오디오 세션 간섭 구분 불가
발열·스로틀 여부미수집 지표 — 비디오 producer 통계 미로깅
SFU 서버측 producer 수신 지터미조회 — Grafana ppi-socket 에 있는지 확인 필요
NetEQ 은닉량(concealedSamples)미수집 지표

제안 — 이 공백을 메우는 로깅 3종

  1. consumer/producer 통계를 주기 샘플링으로 — 현재 생성 직후 1회뿐이라 “가장 바쁜 순간” 편향이 있다. 30초 주기로 바꾸고 packetsLost 를 델타로 기록하면 손실·지터가 처음으로 판정 가능해진다.
  2. 비디오 outbound 통계 추가frameWidth / framesPerSecond / totalEncodeTime. 발열·스로틀을 가르는 가장 빠른 길이다.
  3. 오디오 클럭 부족을 임계 초과 시 warn 으로 — 지금은 info 라 사후 분석에서만 보인다. 200ms 초과가 연속 3회면 warn 을 찍으면 야간 트리아지에서 바로 걸린다.

1·3번은 계측만 늘리는 변경이라 런타임 위험이 낮다. 2번은 getStats 호출이 늘어나므로 저사양 단말 영향 확인이 필요하다.

시각화

세 회기의 지터 · 오디오 클럭 부족 · AI 오디오 엘리먼트 클럭을 같은 시간축(0~29.5분)에 겹친 인터랙티브 차트: 45·46·47회기 오디오 계측 (Claude Artifact · 각 점 호버 시 원본 값)

관련 문서

조사 방법

세 회기의 클라이언트 세션 로그(각 12,543 / 11,601 / 7,032건)를 ts 로 정렬해 복원하고, 회기 길이가 달라(28.5 / 26.7 / 19.3분) 모든 빈도 지표를 분당 정규화해 비교했다. 지터는 MONITOR_CONSUMER, 오디오 클럭은 LIVEKIT_AUDIO_CHAIN, 재생 위치는 AI_SESSION 에서 뽑았다. 단말 동일성은 LOCAL_MEDIA_STREAM | Local media started successfullydeviceIdAUDIO_PROBEoutputDeviceLabels 로 확정했다(45·47 동일 아이폰, 46 안드로이드).

이 분석에서 결론이 두 번 바뀌었다. ① 초판은 “메인스레드 정지 9회”였으나, Δ벽시계가 정시임을 보고 오디오 렌더 언더런으로 정정했다(9회 중 2회는 체인 경계·탭 백그라운드 오집계였다). ② 초판은 “packet loss 0이므로 네트워크 배제”였으나, packetsLost 가 누적값임을 코드에서 확인하고 미측정으로 정정했다. 계측치를 읽기 전에 그 값이 어떤 창에서 어떻게 수집되는지 코드로 먼저 확인할 것.

수정 미적용: 이 문서는 원인 분석과 로깅 제안까지다. 코드 변경은 없다.