홍윤우 1회기 녹음 “지지직”
— 단말 오디오 렌더 결손 원인 분석 (Codex 합의 루프 84%) 직접 원인 확정 · 1차 원인 미확정 · 수정 미적용

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

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

작성일: 2026-09-03사건: 2026-08-31 20:33–20:51 KST (1회기, 두부핑퐁 시즌2)room: f6fbb622…_1단말: iPhone · iOS 26.6.0 · Chrome 152(WKWebView)대상: 녹음 mp3 18:22 · 세션로그 8,717건 · ppi-socket 서버로그 11,230건 · 대조 세션 4건런타임: LiveKit

결론

mp3 4:30부터 들리는 지지직은 아동 iPhone 안에서 오디오 렌더·재생이 실시간을 못 따라가며 생긴 2~40 ms 결손이다. 게스트가 SFU로 올리는 두 스템(마이크 relay 트랙, AI 원격 트랙 clone)이 단말 안에서 이미 구멍 난 상태로 인코딩되어 서버 녹음·진행자 모니터·아동 로컬 재생에 같은 지지직이 남았다. 결손은 20:37:53(mp3 4:29)에 시작해 5분 동안 계단식으로 악화되고 20:42:50부터 실시간의 19%를 렌더하지 못하는 상태로 고정됐다. 활동 전환으로 AudioContext를 새로 만들어도 첫 5초부터 같은 결손이라 그래프가 아니라 단말 상태의 문제다.

네트워크 패킷 손실, 서버 ffmpeg 믹싱·스템 경계, 입력 클리핑, 메인스레드 정지, AudioContext suspend는 기각했다. 왜 결손이 생겼는가(열/CPU 스로틀링 vs 오디오 세션 재구성)는 이 로그로 확정할 수 없다.

주장 등급: A로그·서버로그에서 직접 확인된 사실 / B근거가 강한 추론(위 결론) / C가설. 이 문서는 Codex(gpt-5.6) 리뷰어와 4라운드 합의 루프를 거쳤고 최종 판정은 PLAUSIBILITY 84% · APPROVE다(아래 “리뷰 루프” 절).

시간순 인과 다이어그램

20:30:34  mic 캡처(iPhone 마이크, EC on) + 카메라 H.264 송출 시작
20:33:22  활동1 AI 세션: LiveKit 룸 + 새 AudioContext(48 kHz) + 입력 체인 그래프(ppi-livekit-input-audio-chain)
20:33:23.794  서버 녹음 시작 = mp3 0:00  (ppi-socket "Stems post-mixed" mp3T0Ms)
   │  활동1 내내 AudioContext.currentTime / 벽시계 = 1.000 · 파형 갭 0.1/발화초 (기저)
   ▼
20:37:32  활동2 전환: 룸 disconnect → 새 룸·새 AudioContext(currentTime 0.379부터) → AI 첫 발화 20:37:40
   ▼
20:37:53 (mp3 4:29)  첫 결손 창: 벽시계 5.001 s 동안 currentTime 4.936 s (−64 ms)   ◄── 사용자가 지지직을 인지한 4:30
   │  20:37:58 일시 회복(1.000) → 20:38:29부터 매 창 결손, 0.99 → 0.94 → 0.88 계단식 악화
   │  같은 분부터 게스트 mediasoup 전송 RTT 중앙값 11 → 13~39 ms, 스파이크 최대 533 ms(20:39:12)
   ▼
20:42:49 (mp3 9:25)  ratio 0.82 도달 → 이후 0.80~0.84 고정 (5초마다 약 0.95초 미렌더)
   │  파형: 발화 사이 근무음 갭 7.5~11/발화초 (mp3 9:00~10:30, 세션 최악 구간)
20:43:45 / 20:46:50  활동3·4: 또 새 AudioContext — 첫 5초 창부터 0.84 / 0.82, 회복 없음
   │
   ├─► 마이크: mic → [WebAudio 체인 HP/EQ/LP 13k/comp/limiter → MediaStreamDestination] → mediasoup mic-audio → SFU → 녹음 child stem
   └─► AI: LiveKit 원격 트랙(단말에서 Opus 디코드·재생) → track.clone() → mediasoup ai-audio → SFU → 녹음 ai stem   ※ WebAudio 미경유
   ▼
20:49:33  아동 이슈 리포트 "이상한 소리가 들려요"
20:51:45  종료 kick → 20:52:14 서버 9 스템 post-mix → S3 업로드 (파일명 시각 11:52:14 UTC = 업로드 시각)

mp3 t0는 게스트 로그가 아니라 서버 로그로 확정했다. 게스트 producer 생성 시각(20:33:22~24)과 1~2초 차이가 있으므로 4:30 = 20:37:53.79 KST.

근거 1 — AudioContext 클럭 결손 A

LIVEKIT_AUDIO_CHAIN | Audio chain health는 5초마다 audioContext.currentTime을 남긴다(lib/voice-agent/livekit-audio-chain-processor.ts HEALTH_LOG_INTERVAL_MS=5000). 연속 두 건의 currentTime 차 ÷ 벽시계 차 = ratio. “Audio chain graph built”마다 currentTime이 0.3~0.5부터 다시 시작하므로(활동마다 새 LiveKit 룸 = 새 AudioContext) 세그먼트 안에서만 차분한다.

세그먼트(그래프 생성)표본ratio 평균min첫 <0.99
seg1 20:33:22 (활동1)481.00000.997없음
seg2 20:37:33 (활동2)720.91300.81120:37:53 (64/72 창이 <0.99)
seg3 20:43:45 (활동3)340.81600.802첫 창부터
seg4 20:46:50 (활동4)360.81290.805첫 창부터

비교에 쓴 두 시계 — 같은 로그 줄의 두 필드

외부 시계나 추정치는 쓰지 않았다. 한 줄에 함께 기록된 두 필드만 차분했다.

2964줄: {"ts":1788176273934, "ctx":"LIVEKIT_AUDIO_CHAIN", "msg":"Audio chain health",
         "data":"{\"state\":\"running\",\"currentTime\":20.315,\"currentTimeAdvanced\":true,
                  \"lastLevelDb\":-52.2,\"processedTrackState\":\"live\",\"relayTrackState\":\"live\"}", "role":"guest", ...}
필드무엇의 시계인가어디서 기록되나단위·정밀도
ts시스템 시계(벽시계)apps/web/logger.js의 logSink가 로그를 남기는 순간 Date.now()epoch ms
data.currentTime오디오 클럭 — 렌더한 샘플 수 ÷ sampleRate(48000). 렌더 스레드가 128샘플 블록을 처리할 때만 2.667 ms씩 증가livekit-audio-chain-processor.ts startHealthLogging의 5초 setInterval 콜백에서 audioContext.currentTime초, 소수 3자리(±0.5 ms)
data.state컨텍스트 상태같은 콜백에서 audioContext.staterunning / suspended / interrupted / closed
data.currentTimeAdvanced직전 창 대비 전진 여부(멈춤 감지용)같은 콜백boolean — false면 Audio chain not rendering 경고로 바뀜

측정 로그 위치 — 파일 줄 번호와 ts 원본값 (검색용)

파일은 .gz 확장자지만 평문 JSONL(첫 바이트 {"ts)이다. 총 8,717줄 중 "ctx":"LIVEKIT_AUDIO_CHAIN" 198줄(274~8518줄). grep -n '"ts":1788176273934' lesson-log-20260831.jsonl.gz 처럼 ts 원본값으로 바로 찾는다. Δts는 인접 두 줄의 ts 차(ms), Δctx는 data.currentTime 차(s); graph built 줄에서 currentTime이 리셋되므로 그 경계는 건너 비교하지 않는다.

ts(원본)시각(KST)msgcurrentTimeΔts(ms)Δctx(s)ratio
274178817600263120:33:22.631graph built (활동1)0.477
2774178817625393220:37:33.932graph built (활동2)0.379
2890178817626893320:37:48.933health (정상)15.37950005.0001.000
2964178817627393420:37:53.934health — 첫 결손 (mp3 4:29)20.31550014.9360.987
3053178817627893520:37:58.935health (일시 회복)25.31550015.0001.000
3250178817630911520:38:29.115health — 지속 악화 시작55.36051675.1330.993
5285178817656945220:42:49.452health — 0.82 도달293.80850044.1090.821
5286178817657445720:42:54.457health — 이후 고정297.88550054.0770.815
5398178817662563720:43:45.637graph built (활동3, 새 AudioContext)1.773
5549178817663567420:43:55.674health — 새 컨텍스트 첫 창부터 결손10.16850114.1650.831
7030178817681079420:46:50.794graph built (활동4)4.560
8518178817699659820:49:56.598health — 마지막155.60850264.1070.817

원문 로그 발췌 — lesson-log-20260831.jsonl, ctx=LIVEKIT_AUDIO_CHAIN (값은 로그 그대로, Δ·ratio만 계산)

20:37:33.932  Audio chain graph built   state=running currentTime=0.379
20:37:48.933  Audio chain health        state=running currentTime=15.379   Δwall=5.000s Δctx=5.000s ratio=1.000   (ts=1788176268933)
20:37:53.934  Audio chain health        state=running currentTime=20.315   Δwall=5.001s Δctx=4.936s ratio=0.987   (ts=1788176273934) ← 첫 결손 = mp3 4:29
20:37:58.935  Audio chain health        state=running currentTime=25.315   Δwall=5.001s Δctx=5.000s ratio=1.000   (ts=1788176278935)
   …
20:42:49.452  Audio chain health        state=running currentTime=293.808  Δwall=5.004s Δctx=4.109s ratio=0.821   (ts=1788176569452)
20:42:54.457  Audio chain health        state=running currentTime=297.885  Δwall=5.005s Δctx=4.077s ratio=0.815   (ts=1788176574457)
20:43:45.637  Audio chain graph built   state=running currentTime=1.773    ← 활동3, 새 AudioContext
20:43:55.674  Audio chain health        state=running currentTime=10.168   Δwall=5.011s Δctx=4.165s ratio=0.831   (ts=1788176635674) ← 새 컨텍스트도 첫 창부터 결손

로그가 직접 말하는 것: 벽시계(ts)는 5.00 s씩 정확히 흐르는데 currentTime은 4.1 s만 전진한다. state=running이 유지되므로 컨텍스트가 멈춘 것이 아니다(멈췻다면 전진 0 + “Audio chain not rendering” 경고·statechange 로그가 남는데 둘 다 0건). 로그에 없는 것: “그 사이 소리가 실제로 끊겼다”는 기록 — 그것은 아래 근거 2의 파형에서 따로 확인했다.

유효 창 190개 중 127개가 ratio < 0.98(0.99/0.95/0.90 기준 각 134/119/100개). 해석의 근거와 배제:

한계: 이 빌드에는 render-underrun 카운터가 없어 “currentTime 결손 = 하드웨어 언더런”은 스펙·구현 일반론에 의존한다. 그래서 아래 근거 2(파형)로 결손이 실제 소리로 나타났음을 독립 확인했다.

근거 2 — 파형 갭과 결손률의 용량-반응 A

mp3를 48 kHz PCM으로 디코드 → 1 ms 에너지 → 발화(>−32 dBFS) 사이에 끼인 2~40 ms 근무음(<−55 dBFS) 갭을 셈. 전체 640개: 4:25 이전 14개, 이후 626개.

mp3 구간ratio 평균갭/발화초갭 지속 비율
0:00~4:30 (활동1)1.0000.00~0.29≤1.4%
4:30~7:000.99~0.940.00~0.731~4%
7:00~9:000.92~0.860.25~1.111~6%
9:00~10:300.85~0.817.5~11.116~27%
10:30~16:300.81~0.830.7~4.03~8%

원본 수치 발췌 — gaps.csv(mp3 초, 갭 길이 ms) · 1 ms 에너지

4:25 이전 전체 14개
00:13.467 2   00:34.049 4   01:00.920 2   01:01.055 3   01:08.975 3   01:27.482 2   02:10.395 2
02:22.167 2   02:23.802 2   02:23.919 2   02:39.428 2   03:20.204 2   03:34.705 8   03:58.178 10

4:25 이후 626개 중 최악 구간 9:30~9:34 연속 20개
09:30.870 2   09:31.127 2   09:31.135 3   09:31.139 17  09:31.190 3   09:32.337 2   09:32.363 30
09:32.830 23  09:32.870 6   09:32.919 7   09:33.222 15  09:33.266 31  09:33.308 2   09:33.312 4
09:33.318 3   09:33.334 2   09:33.397 2   09:33.400 3   09:33.425 22  09:33.448 9

갭 1개 확대 — 120 ms 창을 1 ms 단위로 (#=발화 >−32 dB, -=중간, .=근무음 <−55 dB, 갭 시작이 40 ms 지점)
09:32.363 갭 30ms   ....-----.--.-..---#########------------..............................-....................--------###--------
09:33.266 갭 31ms   ....................----------###-------...............................--...-###--..--....--...--##--------
06:03.758 갭 16ms   #######-----#---------.------.---.-....-................--.-------##################-#--##---##-----------
09:32.363 구간 dB(2 ms 간격): −39 −48 −49 −47 −52 −58 −65 −67 −72 −67 −73 −75 −69 −73 −77 −78 −87 −85 −73 −79 −53 −60 …

발화(−30 dB대) 사이에 −70~−87 dB의 구멍이 30 ms 들어가 있다. 자연 휴지는 이렇게 짧고 급하게 떨어지지 않는다. 파형이 말하는 것은 “결손이 있었다”까지이고, 원인 연결은 위 로그 ratio와의 동시성·용량-반응에서 온다.

갭 지표의 절대값은 세션·콘텐츠에 따라 0.1~0.6/초로 달라진다(대조 녹음 3건). 그래서 증거는 같은 세션·같은 단말·같은 런타임 안의 대비로만 썼다. 갭 지속 비율이 결손 19%에 못 미치는 구간이 많은 것은 이 지표가 하한이기 때문(2 ms 미만·다른 스템이 채운 갭·자연 휴지와 합쳐진 갭 제외)이며, “미렌더 19%가 전부 무음으로 들린다”고 주장하지 않는다.

근거 3 — 대조군: 결손 시그니처는 이 세션에만 있다 A

세션단말health 창세그먼트 ratio<0.98 창
홍윤우 8/31 (본 건)iPhone, iOS 26.61901.000 / 0.913 / 0.816 / 0.813127
장호준 9/1 (지지직 신고)iPhone641.000 / 0.9999 / 0.9989 / 0.99552 (min 0.979)
김리호 9/1PC32615 세그먼트 0.998~1.0001 (단발 0.976)
안유찬 9/2기본값12811 세그먼트 0.998~1.0010
최이안 9/1PC2307 세그먼트 1.0000

정상 세션 3건과 다른 iPhone 세션 1건 어디에도 수십 창 연속 0.81~0.94는 없다. 같은 iPhone/iOS 26 계열에서도 없으므로 결손은 iOS·빌드 고유 현상이 아니라 그 세션의 단말 상태다.

반례의 정직한 보고 — 장호준 9/1 “지지직”. 폴더명상 지지직 신고가 있는 장호준 세션은 결손 시그니처가 없고, 녹음 mp3 2개의 갭률도 0.38/발화초(30초 버킷 최대 0.84)로 낮다. 즉 장호준의 지지직은 홍윤우와 다른 기전이며(로그가 20:41에 끝나 후반부 미확인) 별건 조사가 필요하다. 이 문서의 주장 범위는 홍윤우 세션이다. 송아인 24회기(출력 경로 오염, 비-iOS)도 또 다른 기전이다 — “지지직”은 최소 세 가지 다른 원인으로 나뉜다.

배제한 대안 가설

가설기각 근거남는 구멍
게스트 업링크 패킷 손실 / PLC게스트 20:39:14 getStats packetsLostRate 0·모니터 consumer 13건 packetsLost 0 / 갭 66%가 10 ms 미만 / 활동1(RTT 정상)에서 갭·결손 모두 0 / 손실은 업링크에만 나므로 단말 내부 지표(currentTime)와 같은 곡선으로 움직일 이유가 없음손실 지표가 consumer 생성 직후 스냅샷뿐(원준호 문서의 “누적값·미측정” 지적 동일). 게스트 RTT 지터 상승(20:37~)의 방향(원인/결과)은 미확정
서버 ffmpeg amix / 스템 경계 / aresample7개 스템 경계 ±2초 안 갭 0~1개, 갭 최다 구간(9:00~10:30)은 경계와 무관 / ffmpeg 실패·degraded 없음 / 156 B 세그먼트 4건은 전환 순간 1~2초 살다 교체된 raw-clone producer(헤더만, 정상 drop)원본 OGG 스템은 서버 TTL 2h로 삭제되어 직접 대조 불가
입력 클리핑100 ms 창 0 dBFS 초과 3개, 나머지 ≤−0.4 dB. 14분 지속 증상과 양립 안 함mp3 디코드 후 값
메인스레드 long task벽시계 Δ 정시(1,620건 전사 전송·2,218건 데이터 수신 중에도) / 활성 ScriptProcessor 없음(external STT off)
같은 AudioContext의 그래프 누수활동마다 새 컨텍스트인데 첫 창부터 결손이전 컨텍스트 미해제 누적은 코드상 약화(livekit-client 2.19.2가 disconnect 시 audioContext.close(), 앱은 webAudioMix 미지정) — 증거 없음
서버·SFU 전역 장애같은 룸 호스트(모니터) 전송 RTT는 20:31~20:51 내내 235~267 ms로 불변호스트는 다른 경로라 “게스트 Wi-Fi vs 단말” 분리는 못 함

1차 원인 후보 C — 미확정

  1. 단말 CPU/열·전력 스로틀링 — 카메라 인코딩+영상 디코드+WebRTC 2세트 장시간, 5분 램프 후 고원 패턴, 같은 시각 게스트 RTT 지터 상승과 부합. 단 원준호 47회기에서는 같은 아이폰의 더 긴 세션이 멀쩡해 발열 가설에 반대 증거가 있었다. 홍윤우 건은 그 반대 증거가 적용되지 않지만(단일 세션) 확인 수단도 없다.
  2. iOS 오디오 세션/route 재구성(voice-processing IO 유닛, 샘플레이트) — audioSessionState:null은 미계측일 뿐 정상의 증거가 아니다.
  3. 활동 전환마다 누적되는 오디오 노드·PC — 코드상 약화(위 표).

iOS는 웹에 온도·스로틀 상태를 노출하지 않고, 이 프로젝트는 비디오 producer 통계를 로깅하지 않는다. 1차 원인을 가르는 유일한 길은 재현 실기기에서 ratio·게스트 RTT·기기 온도를 동시에 보는 것이다.

2026-09-10 후속 실험. iPad 실기기 디버그 하네스로 후보 1을 부분 검증했다. 정상 전원에서는 렌더 콜백 40% 누락에도 AI 음성 재생이 정상이었고, 저전력 모드(CPU 축소) + 같은 렌더 부하에서만 재생 밀림이 재현됐다. 후보 3(노드 누적)은 ctx 255개에서도 정상이라 기각. 후보 2(오디오 세션 재구성)는 미실험. 상세: iPad AI 음성 밀림 재현 — 저전력 모드 × WebAudio 렌더 부하.

게스트 오디오 파이프라인 점검 — 앱 비효율이 결손을 만들었나 B

결론: WebAudio 파이프라인 자체는 렌더 결손을 만들 만큼 무겁지 않다. 렌더 스레드에 앱이 얹는 작업은 네이티브 노드 약 20개와 20 ms 주기 파라미터 자동화가 전부이고, 같은 코드가 활동1(4분)과 대조 세션 4건에서 ratio 1.000이었다. 다만 단말 전체 부하(인코더·디코더·60 fps 캔버스)와 활동 전환마다의 재생성은 무거워, 1차 원인 후보인 열/CPU 스로틀링에 간접 기여할 여지는 남는다.

이 세션에서 실제로 돌던 것 (iPhone · LiveKit 런타임, 로그로 활성 여부 확인)

구성부하 판정
WebAudio 렌더 스레드동시 AudioContext 3개 — ① LiveKit 입력 체인 11노드(source→gate→HP→peaking→LP 13k→comp→limiter→gain×2→dest×2 + analyser, livekit-audio-chain-processor.ts) ② 아바타 RMS 공유 ctx(source+analyser, use-avatar-rms.ts, 세션 내내 유지) ③ 영상 loopback 공유 ctx(MediaElementSource+analyser+destination, video-audio-loopback.ts). 출력 probe ctx는 13회 생성 후 즉시 close. AudioWorklet·ScriptProcessor 없음(external STT off, english guard 미사용).가벼움
렌더 스레드에 넣는 지속 작업체인 모니터 MONITOR_INTERVAL_MS=20 — 매 틱 outputGain.setTargetAtTime(값이 같아도 무조건) + gate 전환 시 1회 → 초당 50~100 자동화 이벤트가벼움 (개선 여지)
메인 스레드 / GPU아바타 립싱크 use-avatar-rms.ts: requestAnimationFrame 60 fps로 analyser 읽기 + canvas에 idle/talking 비디오 2장 drawImage + 그라디언트 합성. 그 외 체인 20 ms 폴링, loopback 침묵 감시 400 ms, LiveKit 레벨 1 s, 미디어 health 2 s, mediasoup 상태 1 s, 전사 1,620건·데이터채널 2,218건 처리무거움 (iPhone에서 가장 비싼 주기 작업)
인코더 / 디코더송신 Opus 3개(LiveKit 마이크, mediasoup mic relay, mediasoup AI clone) + 영상 loopback Opus 256 kbps CBR(webrtc-audio-loopback.ts:29) + 카메라 H.264 540p 300 kbps 단일 레이어(use-mediasoup-producer.ts:699). 수신 디코더 2개(LiveKit AI, loopback). 비디오 디코드 3~4 스트림 동시(스텝 영상, 아바타 idle 상시 loop, talking, 카메라 미리보기)상당함
활동 전환마다LiveKit 룸·PC·AudioContext 재생성(설계)에 더해 mediasoup 송신 전송(ICE/DTLS)과 카메라·마이크·AI producer 전부 재생성. 로그 “Initializing mediasoup producer” 5회(20:30:11, 20:33:22, 20:37:33, 20:43:42, 20:46:45) — 각각 LiveKit connecting 100 ms 뒤, JOIN 재발생 없음 → React 의존성 identity 변화로 effect 재실행 추정(use-mediasoup-producer.ts transport effect; 트리거 미확정). 서버에서는 활동마다 child stem ffmpeg 3회 재시작·156 B 빈 세그먼트. 마이크 raw clone producer가 relay 트랙으로 교체되기 전 1초 살다 죽음비효율 확정 · 직접 원인 아님

활동 전환이 직접 원인이 아닌 이유: 결손 시작(20:37:53)은 전환(20:37:33) 20초 뒤이고, 활동1 전환(20:33:22) 뒤에는 4분간 정상이었다.

판정

줄일 수 있는 것 (제안만 · 코드 미수정)

  1. 아바타 RMS 캔버스 합성을 iOS/저사양에서 30 fps로 낮추거나 비디오 opacity 교차로 대체.
  2. 활동 전환 시 mediasoup 전송 재생성 제거 — 먼저 effect 재실행 트리거(의존성 identity) 확인.
  3. 체인 outputGain.setTargetAtTime을 값 변화 시에만 호출.
  4. 영상 loopback Opus 256 k CBR → 96~128 k.
  5. AI clone 재인코딩은 서버 녹음·모니터용으로 구조상 필요해 단기 제거 어려움.

검증은 실기기에서 항목을 하나씩 끄며 ratio를 보는 것이 유일한 방법이다. health 로그에 outputLatency·활성 AudioContext 수·캔버스 fps를 추가하면 다음 사례부터는 로그로 가른다.

재사용 판별법

  1. ratio 스캔 — 게스트 세션로그의 Audio chain health currentTime을 “graph built” 경계마다 끊어 차분. <0.98 창이 연속이면 단말 렌더 결손, 없으면 네트워크·서버 경로로 넘어간다. 정상 세션은 0.998~1.000, 단발 0.976이 최대. 2026-09-18 이후 배포 세션은 사후 차분이 필요 없다 — health 로그에 renderRatio가 직접 남고, <0.98 2회 연속이면 Audio chain render deficit WARN이 찍힌다(PR #1117 리뷰).
  2. mp3 t0 — ppi-socket 서버 로그 Stems post-mixed {"mp3T0Ms"}가 정답. 게스트 producer 시각으로 추정하면 1~2초 오차.
  3. 파형 갭 — 1 ms 에너지에서 발화 사이 2~40 ms 근무음 갭. 절대값 대신 세션 내 대비(결손 전/후, ratio 구간별)로만 판정.
  4. 패킷 손실 배제는 신중히packetsLost는 consumer 생성 직후 스냅샷. 갭 길이 분포(<10 ms 다수)가 손실 단독을 기각하는 보조 축.
  5. AI audio element health의 currentTime은 쓰지 말 것 — HTMLAudioElement 재생 위치라 렌더가 밀려도 시계가 그대로 흐른다(원준호 문서 동일).

재현 스크립트(ppi 레포 로컬, 커밋 대상 아님): .omc/trace/crackle-0831/analyze_gaps.py(갭·페어링·상관·부트스트랩·RTT 일괄), scan_sessions.py(다른 세션 ratio 스캔 / mp3 갭률), dose_response.py(임계값 없는 용량-반응). 서버 로그 회수는 loki-logs.py --service ppi-socket --env prod --start "8/31 20:28" --end "8/31 20:55" --search f6fbb622.

리뷰 루프 — Codex 4라운드

Herdr 분할 pane에서 Codex(gpt-5.6)를 리뷰어로 띄워 분석 문서 ↔ 리뷰를 반복했다(consensus-loop의 분석 모드). 목표 타당성은 처음 90%에서 사용자 지시로 80%로 조정.

라운드판정리뷰어의 핵심 지적대응
R168% REVISEcurrentTime 결손을 렌더 언더런으로 과잉 인과화 / E1·E4 수치 오류(12건→8건) / 로컬 청취·AI 공통 경로 입증 부족 / mp3 t0 미확정서버 로그 확보(t0 확정), 파형 갭 지표 신설, AI 구간 분석, 수치 정정, 주장 A/B/C 등급 분리
R276% REVISE갭 지표 재현 불가(스크립트 없음) / 상관은 공통 추세일 수 있음 / AI 페어링 규칙 미명세(START 179·STOP 199) / 서버 corrupt 세그먼트 4건 설명 부족analyze_gaps.py 공개, 세그먼트 내부 용량-반응·램프 상관·부트스트랩, 페어링 상태기계 명세, 세그먼트↔producer 표
R379% REVISE임계값 사후 선택 / 호스트 RTT는 동일 경로 대조가 아님(F-14) / 독립 대조군 없음임계값 없는 사분위·순위상관, 대조 세션 4건 스캔, 장호준 반례 보고, F-14 수용
R484% APPROVE산출물 파일 2개가 잘못된 경로 실행 결과(F-15)재생성. resolved 7 / partially 8 / open P0·P1 0

partially로 남은 8건은 모두 “이 데이터셋에서 얻을 수 없는 직접 증거”(렌더 underrun 카운터, 게스트 업링크 RTP 연속 통계, AI 독립 스템, 원본 OGG) 부재 때문이다. 리뷰어가 지목한 ‘80% 이상 확정으로 가는 결정적 1가지’는 문제 시각의 단말 render-underrun 카운터와 독립 child/AI 원본 스템을 함께 확보하는 것. 리뷰어가 직접 재계산해 일치 확인한 수치: 190/127창, 갭 640/14/626, 길이 분포, 페어링 179/199/20, seg2 용량-반응 0.14/0.44/4.88, 부트스트랩 CI, RTT 시계열, mp3T0Ms, 대조군 4건.

제안 (코드 미반영)

  1. 계측 — health 로그에 outputLatency·활성 AudioContext 수·결손률(직접 계산) 추가, 결손 <0.98 창 3회 연속이면 warn. 야간 트리아지 룰로 등록하면 재발을 다음 날 잡는다. → 결손률 부분은 PR #1117(2026-09-18)로 반영: renderRatio 필드 + 2회 연속 WARN. outputLatency·AudioContext 수는 미반영.
  2. 실기기 재현 — 같은 iOS 26.6 iPhone으로 카메라+영상+AI 15분, ratio·게스트 RTT·기기 온도 동시 기록. 카메라 송출 OFF 대조군으로 열 가설 판별.
  3. 스템 보존 — 문제 룸에 RECORDING_DUMP_STEMS를 켜 child/ai 스템을 독립 대조.
  4. 장호준 9/1 별건 조사 — 이 문서의 시그니처가 없으므로 다른 축(네트워크·출력 경로)으로.

관련 문서

조사 방법

mp3(ffmpeg/numpy 파형 분석), 게스트·모니터 세션로그(8,717건), ppi-socket prod Loki 로그(11,230건), livekit-client 2.19.2·앱 코드(use-mediasoup-producer.ts, livekit-audio-chain-processor.ts, recordingManager.ts), LogRocket 세션(UA), 다른 세션 로그 4건·녹음 3건을 대조군으로 썼다. 코드는 수정하지 않았다.

이 분석에서 바뀐 것. ① R1은 “두 스템이 같은 단말 오디오 IO에서 만들어진다”를 코드 근거 없이 썼다 — AI 스템은 WebAudio를 거치지 않는다는 리뷰 지적을 받아 파형에서 AI 구간 갭을 따로 세어 보강했다. ② “packetsLost 0 = 네트워크 배제”를 “스냅샷이라 미측정, 갭 길이 분포가 보조 축”으로 완화했다. ③ 호스트 RTT 불변을 “SFU 전역 장애 없음”까지로 축소했다. ④ 열 스로틀링을 ‘유력’으로 두되 원준호 문서의 반대 증거를 병기했다.