원준호 45·46·47회기 “지지직”
— 지터·오디오 클럭 언더런 계측 판별 직접 원인 1건 확정 · 근본 원인 미확정 · 수정 미적용
마지막 업데이트 2026-09-12
결론
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.5ms | 3.0ms | 10.5ms |
| 지터 최대 | 13ms | 11ms | 35ms |
| 지터 추세 (회귀) | +0.09ms/분 (r=+0.21) | −0.04ms/분 (r=−0.07) | +1.12ms/분 (r=+0.67) |
| 수신 패킷 최소 | 49/초 | 50/초 | 34/초 |
| 오디오 클럭 부족 >200ms | 1회 (재입장 suspend) | 0회 | 6회 연속 · 30초 |
| 메인스레드 콜백 지연 >200ms | 1회 | 0회 | 1회 차이 없음 |
| LiveKit room 재연결 | 0.32회/분 | 0.45회/분 | 0.31회/분 차이 없음 |
| mediasoup transport 실패 | 0 | 0 | 0 정상 |
| 소켓 끊김 | 6회 | 0회 | 0회 47이 더 깨끗 |
핵심 판별법 — 메인스레드 정지와 오디오 렌더 언더런 가르기
LIVEKIT_AUDIO_CHAIN | Audio chain health 는 setInterval(5000) 로 5초마다 AudioContext.currentTime 을 남긴다(lib/voice-agent/livekit-audio-chain-processor.ts:283). 로그의 ts 는 Date.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
- 체인 재생성 경계를 넘어 계산 —
Audio chain graph built마다currentTime이 0부터 다시 시작한다. 기준을 리셋하지 않으면 12초짜리 가짜 지연이 잡힌다. - 탭 백그라운드 전환 — 20:48:56
isVisible:false직후 25초간 타이머가 스로틀돼 로그가 비고, 그 구간을 정상 틱으로 세면 3,114ms 가짜 정지가 잡힌다.
이 판별법이 왜 중요한가: 두 원인은 대응이 완전히 다르다. 메인스레드 정지는 JS 부하(렌더·타이머·동기 작업)를 줄여야 하고, 오디오 렌더 언더런은 오디오 그래프 자체의 부담(노드 수·워클릿·동시 컨텍스트)이나 기기 자원을 봐야 한다.
지터 — 열화는 맞지만 표본이 성기다
MONITOR_CONSUMER | Consumer inbound stats 의 jitter(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 ┃█████████████████
hooks/mediasoup/use-mediasoup-consumer.ts:12-14). 즉 활동 전환 직후 — 클라이언트가 가장 바쁜 순간 — 만 표본에 들어온다. 20분 세션에 9점뿐이고, 점 사이 구간은 관측 공백이다.12.6분(20:45:40) 35ms 는 특히 조심해서 읽어야 한다. 1초 전 진행자 쪽에서 Resetting video element → Unexpected pause → Monitor video seeking → SEEK_REBUFFER 가 연달아 일어났고, 아동 쪽은 재입장 6초 뒤였다. 양쪽 초기화 폭주가 측정 창에 겹친 값이라, 회선 열화의 증거로 쓰면 과대 해석이다. 같은 아이폰의 45회기도 20:45:19에 동일한 재입장을 했지만 11초 뒤 표본은 4ms/51패킷으로 깨끗했다 — 재입장 자체가 원인은 아니다.
packet loss는 “배제”가 아니라 “미측정”이다
초기 판정에서 “손실 0이므로 네트워크 배제”라고 적었으나 틀렸다. use-mediasoup-consumer.ts:101 은 packetsLost 를 델타가 아니라 샘플 시점의 누적값 그대로 기록하는데, 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초 전 진행자측 영상 시크·리버퍼. 수신단 경합으로도 동일하게 설명된다. |
frameWidth 하락 · framesPerSecond 하락 · totalEncodeTime 증가가 전부 미수집이다. 기각 근거는 “정황상 아니다”이지 “아님이 확정됐다”가 아니다.AI 오디오 엘리먼트 클럭은 이 판별에 못 쓴다
AI_SESSION | AI audio element health(entities/guest-session/model/use-ai-session.ts:146)의 currentTime 은 HTMLAudioElement 의 재생 위치다. 렌더가 밀려도 브라우저는 무음을 재생할 뿐 시계는 그대로 흐르므로 언더런이 원리적으로 관측되지 않는다.
AudioContext.currentTime | audio.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종
- consumer/producer 통계를 주기 샘플링으로 — 현재 생성 직후 1회뿐이라 “가장 바쁜 순간” 편향이 있다. 30초 주기로 바꾸고
packetsLost를 델타로 기록하면 손실·지터가 처음으로 판정 가능해진다. - 비디오 outbound 통계 추가 —
frameWidth/framesPerSecond/totalEncodeTime. 발열·스로틀을 가르는 가장 빠른 길이다. - 오디오 클럭 부족을 임계 초과 시 warn 으로 — 지금은
info라 사후 분석에서만 보인다. 200ms 초과가 연속 3회면 warn 을 찍으면 야간 트리아지에서 바로 걸린다.
1·3번은 계측만 늘리는 변경이라 런타임 위험이 낮다. 2번은 getStats 호출이 늘어나므로 저사양 단말 영향 확인이 필요하다.
시각화
세 회기의 지터 · 오디오 클럭 부족 · AI 오디오 엘리먼트 클럭을 같은 시간축(0~29.5분)에 겹친 인터랙티브 차트: 45·46·47회기 오디오 계측 (Claude Artifact · 각 점 호버 시 원본 값)
관련 문서
- 송아인 24회기 AI발화·영상오디오 “지지직” — 아동 단말 출력 경로 오염 원인 분석 — 같은 “지지직” 증상의 다른 메커니즘(브라우저 공용 출력 스트림 오염). 본 문서는 입력 체인 언더런이라 경로가 다르다. 두 문서의 판별 갈림길은 “AI 출력만인가, 마이크까지인가”.
- 오디오 처리 체인 (gate·EQ·comp·limiter) — 코드레벨 동작 흐름 — 본 문서에서 언더런이 난
ppi-livekit-input-audio-chain그래프의 구조. - WebRTC 모니터링/통계 (getStats) — 코드레벨 동작 흐름 — 지터·패킷 통계가 어디서 어떻게 수집되는지.
- 아동측 영상·오디오 끊김 — 동시 인코딩 부하 분석 및 simulcast 계획 — 단말 자원 부족이 미디어 품질로 나타나는 인접 사례.
조사 방법
세 회기의 클라이언트 세션 로그(각 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 successfully 의 deviceId 와 AUDIO_PROBE 의 outputDeviceLabels 로 확정했다(45·47 동일 아이폰, 46 안드로이드).
이 분석에서 결론이 두 번 바뀌었다. ① 초판은 “메인스레드 정지 9회”였으나, Δ벽시계가 정시임을 보고 오디오 렌더 언더런으로 정정했다(9회 중 2회는 체인 경계·탭 백그라운드 오집계였다). ② 초판은 “packet loss 0이므로 네트워크 배제”였으나, packetsLost 가 누적값임을 코드에서 확인하고 미측정으로 정정했다. 계측치를 읽기 전에 그 값이 어떤 창에서 어떻게 수집되는지 코드로 먼저 확인할 것.
수정 미적용: 이 문서는 원인 분석과 로깅 제안까지다. 코드 변경은 없다.