iPad AI 음성 “밀림” 재현
— 저전력 모드 × WebAudio 렌더 부하 (디버그 하네스 실험) 재현 조건 확정 · 현장 부하 원천 미확정 · 수정 미적용
마지막 업데이트 2026-09-12
결론
AI 음성이 뒤로 밀리는 현상은 “저전력 모드 + WebAudio 렌더 스레드 부하”가 함께 있을 때만 재현됐다. 정상 전원에서는 AudioContext를 255개 쌓아도, 렌더 콜백을 40% 누락시켜도, 메인스레드를 3초씩 정지시켜도 AI 음성은 멀쩡했고 영상만 끊겼다. 충전 케이블을 빼고 저전력 모드를 켠 뒤 같은 렌더 부하(150%)를 걸자 밀림이 나왔고, 저전력만 켜고 부하를 끄면 다시 정상이었다.
따라서 저전력 모드는 단독 원인이 아니라 증폭기다. 정상 전원에서 서로 독립이던 WebAudio 렌더 경로와 LiveKit 미디어 재생 경로가, 줄어든 실시간 CPU 안에서 경쟁하게 된다(추정). 현장에서 그 부하를 만드는 앱 그래프가 무엇인지는 아직 확정하지 않았다.
주장 등급: A실기기에서 직접 관찰 / B코드·구조에 근거한 추론 / C가설. 실험은 iPad 1대, 사용자 청감 기준이며 로그 정량화는 하지 않았다.
시간순 인과 다이어그램
[가설] 페이지 수명 동안 AudioContext 누적 → iOS WebKit 출력 지연
↓ 하네스로 ctx 88개 → 255개 누적 (무음 oscillator 렌더 / 빈 ctx) → 음성 정상 … 개수 가설 기각
↓ AudioWorklet busy loop 150% (콜백당 4ms, 데드라인 2.67ms) → 실측 콜백 227/s (40% 누락) → 음성 정상 … 렌더 기아 단독 기각
↓ 메인스레드 동기 정지 500ms·3000ms (요청=실측) → 영상 프레임 정지, 음성 정상 … 메인스레드 기각
↓ 충전 케이블 분리 + 저전력 모드 ON, 하네스 OFF → 음성 정상 … 저전력 단독 기각
↓ 저전력 ON + AudioWorklet 150% → 밀림 재현
↓ 저전력 ON + AudioWorklet 150% + 메인스레드 정지 → 밀림 재현 (메인스레드 정지는 불필요)
[결론] 저전력(실시간 CPU 축소) × 렌더 스레드 부하 → 미디어 재생 스레드 경쟁 → 플레이아웃 지연 누적 = 밀림
실험 매트릭스 A
| 전원 상태 | 하네스 부하 | 관측 지표 | AI 음성 | 기각/확정 |
|---|---|---|---|---|
| 정상(충전) | AudioContext 누적 88 → 255개 (무음 렌더·빈 ctx 혼합) | total 255, 생성 에러 없음 | 정상 | 개수 누적 가설 기각 |
| 정상 | AudioWorklet 렌더 부하 150% (4ms/콜백) | 기대 375/s → 실측 227/s | 정상 | 렌더 기아 단독 기각 |
| 정상 | 메인스레드 정지 3000ms / 10s 주기, 500ms 주기, 단발 3s | 요청 = 실측 (3000ms) | 정상, 영상만 끊김 | 메인스레드 정지 기각 |
| 정상 | 워크렛 + 메인스레드 동시 | 실측 308/s + 500ms | 정상, 영상만 끊김 | — |
| 저전력(케이블 분리) | 없음 | — | 정상 | 저전력 단독 기각 |
| 저전력 | AudioWorklet 150% | — | 밀림 | 재현 조건 확정 |
| 저전력 | 워크렛 150% + 메인스레드 정지 | — | 밀림 | 워크렛만으로 충분 |
“밀림”은 사용자 청감 기준. 저전력 상태의 out:(outputLatency)·actual 수치는 기록하지 않아 버퍼 확대 여부는 미확정이다.
왜 정상 전원에서는 아무것도 안 통했나 B
- AI 음성은 WebAudio를 타지 않는다. LiveKit 원격 오디오 트랙은 apps/web/lib/voice-agent/livekit-client-session.ts:3103에서
audioTrack.attach(audioElement)로 미디어 엘리먼트에 붙어 재생된다. 수신·디코드·출력이 WebRTC/CoreAudio 스레드에 있어 메인스레드 정지나 WebAudio 렌더 기아가 신호 경로에 끼어들 자리가 없다. - 빈 ctx·무음 oscillator는 렌더 비용이 거의 0이다. 렌더 콜백은 128프레임마다 destination에서 그래프를 당기는데, 노드가 없거나 gain 0 사인 1개면 버퍼를 채우고 끝난다. 255개여도 2.67ms 데드라인에 근접하지 못한다. WebKit은 Chrome(6개 상한)과 달리 하드웨어 컨텍스트 개수 제한이 없어 생성 에러도 나지 않았다.
- 정상 전원에서는 실시간 스레드에 여유가 있다. 렌더 콜백 40%를 잃어도 미디어 재생 스레드가 자기 데드라인을 지켰다. 저전력 모드가 이 여유를 없앤 것이 두 경로를 경쟁 관계로 바꾼 조건이다.
현장과의 대응 C
아동 iPad는 케이블 없이 쓰다가 배터리 20%에서 저전력 모드가 자동으로 켜진다. “특정 회기 후반부에만 밀림” 신고가 이 경로일 수 있다. 하네스의 150%는 인위적이지만 앱도 게스트에서 WebAudio 그래프를 상시 돌리므로, 저전력에서 임계를 넘길 현장 부하 후보는 다음이다.
| 우선 | 그래프 | 위치 | 근거 |
|---|---|---|---|
| 1 | 영상 오디오 loopback 믹싱 | apps/web/shared/lib/video-audio-loopback.ts | 렌더 스레드 직접 연산, 영상 스텝마다 활성 |
| 1 | 입력 체인 프로세서(Gate→EQ→Comp→Limiter) | apps/web/lib/voice-agent/livekit-audio-chain-processor.ts | AI 스텝 마이크 ON 동안 상시 |
| 1 | 아바타 RMS · 이퀄라이저 · 노이즈 가드 analyser | apps/web/hooks/use-avatar-rms.ts, apps/web/shared/ui/ai-audio-equalizer.tsx, apps/web/hooks/use-ai-audio-noise-guard.ts | AI 발화 중 중첩 |
| 2 | ScriptProcessorNode 3곳 | apps/web/entities/guest-session/lib/external-stt-observer.ts:332, apps/web/entities/guest-page-session/model/use-guest-page-session.ts:1811, apps/web/hooks/use-ai-english-guard.ts:620 | 메인스레드 왕복이 본질이라 2순위. 저전력에서 왕복이 늦으면 렌더 스레드가 버퍼를 기다리며 시간을 잃을 수 있어 배제는 아님 |
LIVEKIT_AUDIO_CHAIN health ratio가 0.98 아래로 떨어지는지 확인해야 앱 그래프가 현장 부하라는 것이 확정된다. 저전력 상태의 out: 값이 정상 대비 커지면 CoreAudio I/O 버퍼 확대까지 함께 확정된다.재현 하네스 — 코드 경로와 사용법
모두 mode=test 게스트 세션(/client-guest/session)에서만 렌더되는 디버그 패널(우상단 Show Debug) 안에 있다. 운영 경로에는 노출되지 않는다.
진입과 배선
- apps/web/widgets/guest/guest-layout/ui/guest-layout.tsx:558-559 —
isTestMode일 때 두 하네스를GuestDebugPanel에 전달 - apps/web/entities/guest-page-session/model/use-guest-page-session.ts:1465, 1470 —
useAudioContextLeakHarness·useMainThreadStallHarness를enabled: !!isTestMode로 생성해 세션 객체로 반환 - apps/web/widgets/guest/guest-layout/ui/guest-debug-panel.tsx — 섹션 3개: AudioContext Leak Test(:203), Render Load(:292), Main Thread Stall(:397). Transcripts는
max-h-[50vh](:116), 액션 영역은max-h-[55vh] overflow-y-auto(:135)로 제한해 전사가 쌓여도 버튼이 눌린다
use-audio-context-leak-harness.ts — 개수 누적 + 렌더 부하
| 함수 | 줄 | 동작 |
|---|---|---|
addContexts(count, mode) | 213 | 클릭마다 new AudioContext() 후 resume(). "render"면 attachSilentRenderGraph(:183)로 gain 0 oscillator를 destination에 연결, "empty"면 노드 없음. 생성 객체를 배열로 붙잡아 GC를 막는다. 생성 예외는 멈추고 lastError로 노출 |
closeAll() | 258 | oscillator stop + 모든 ctx close |
RENDER_LOAD_PROCESSOR_SOURCE | 70 | AudioWorkletProcessor 소스 문자열(Blob URL로 addModule). 생성자에서 20ms 동안 반복 횟수를 보정(calibrate, :86)한 뒤 process()마다 targetMs × itersPerMs회 Math.sqrt 루프. 1초마다 실제 콜백 수를 port.postMessage |
startRenderLoad(percent) | 332 | 전용 ctx 1개 + 워크렛 노드를 destination에 연결. targetMs = 128/sampleRate × 1000 × percent/100 |
setRenderLoadPercent / stopRenderLoad | 315 / 308 | 실행 중 점유율 변경 / 노드 disconnect·ctx close·Blob URL revoke |
AudioWorkletGlobalScope에는 performance.now()가 없어 Date.now()(1ms 해상도)로 보정한다. 패널 actual이 expect(48kHz 기준 375/s)보다 5% 이상 낮으면 빨간색이며, 이것이 렌더 스레드가 데드라인을 놓치고 있다는 직접 증거다.
use-main-thread-stall-harness.ts — 메인스레드 정지
| 함수 | 줄 | 동작 |
|---|---|---|
blockMainThread(ms) | 50 | performance.now() 기준 동기 busy loop, 실측 정지 시간 반환 |
startPeriodic(stallMs, intervalSec) | 103 | 50~3000ms 정지를 1~10s 주기로 setInterval. 실행 중 파라미터 변경 시 재시작 |
stallOnce(ms) | 155 | 단발 3s/12s. 클릭 50ms 뒤 정지에 들어가 “정지 중” 표시가 먼저 그려진다. iPad 12초 정지 사례 재현용 |
로그 태그
AUDIO_CONTEXT_LEAK_TEST— 배치 생성 스냅샷(total·running·suspended·interrupted·latency), 렌더 부하 시작/변경/종료MAIN_THREAD_STALL_TEST— 정지마다 requestedMs·actualMs·stallCount- 대조용 기존 로그:
LIVEKIT_AUDIO_CHAIN(health ratio), audioDebugaudio_play_requested(재생 요청 시각)
커밋 (PPI-1290-TEST)
| sha | 내용 |
|---|---|
409b40e3 | AudioContext 누적 하네스 + 패널 섹션 + vitest 6건 + Storybook |
9793a199 | AudioWorklet 렌더 부하 모드(슬라이더·실측 콜백 표시) |
6959e736 | 패널 액션 영역 스크롤, English Guard·Noise Tests 섹션 제거 |
6fe2a317 | 메인스레드 정지 하네스, Transcripts 높이 50vh 제한 |
배제한 가설과 근거
| 가설 | 판정 | 근거 |
|---|---|---|
| AudioContext 개수 누적이 출력 지연을 만든다 | 기각 A | 255개 running 상태에서 음성 정상. 이전 8/31·9/1 세션 로그 분석에서도 재생성 누적 가설은 반증됨(홍윤우 문서) |
| 렌더 스레드 기아 단독 | 기각 A | 정상 전원에서 콜백 40% 누락에도 음성 정상. 엘리먼트 재생 경로가 WebAudio와 독립(B) |
| 메인스레드 정지 | 기각 A | 3초 정지에 영상만 멈추고 음성 연속. 저전력에서도 워크렛 없이는 밀림 없음 |
| 저전력 모드 단독 | 기각 A | 저전력 ON + 하네스 OFF에서 정상 |
| 저전력 × 렌더 부하 | 확정 A | 두 번 재현. 메인스레드 정지 유무와 무관 |
주의사항
- 저전력 모드는 충전 중이면 켜지지 않거나 80%에서 자동 해제된다. 재현 시 케이블을 먼저 뺀다.
- “엘리먼트 재생 경로는 WebAudio·메인스레드와 독립”은 iPad 1대 관찰이다. TTS 런타임(문장별 Blob 파일 재생)에서는 재생 중 문장은 안 끊기지만 다음 문장 fetch·시작이 메인스레드에 묶여 문장 사이 공백이 늘어나는 형태의 밀림이 가능하다(C, 미실험).
- 워크렛 부하는 보정 시점 클럭 기준이다. 저전력으로 클럭이 더 떨어지면 실제 점유는 설정값보다 커질 수 있어, 저전력 상태에서는
actual수치를 함께 기록해야 비교가 된다. - 하네스는
mode=test전용이며 운영 경로 동작을 바꾸지 않는다. 다만useNoiseTestHarness·englishGuard값은 UI만 제거됐고 세션 훅에는 남아 있다. - 디버그 패널은 이전에 Debug Actions 영역에 overflow가 없어 섹션이 늘면 화면 밖으로 밀렸다. 새 섹션을 추가할 때는 액션 영역 스크롤 안에 넣는다.
다음 단계 (제안 · 코드 미반영)
2026-09-11 반영. 1(상관 확정)과 2(저전력 감지)의 계측 부분은 LOW_POWER_PROBE 프로브로, 영어 가드는 LiveKit 런타임 OFF로 코드에 들어갔고, 원인층과 무관하게 지연 상한을 두는 AI 재생 지연 감시(dry-run)가 추가됐다. PPI-1290 · iPad AI 음성 밀림 1차 대응 참고.
- 저전력 ON + 하네스 OFF 실제 수업 흐름에서 가청 밀림과 적용 설정을 기록하고, 입력 health는 보조 지표로 비교한다.
- 최소 변경 완화 설계에 따라 OFF인 EQ·컴프레서·리미터의 실제 우회를 첫 후보로 검증한다.
- 효과가 부족하면 RMS 립싱크 간소화를 독립적으로 비교한다. 공유 ctx 개선안은 덕킹 문제로 철회했으며, 외부 STT·AudioWorklet 전환은 별도 후속 검토 대상이다.
관련 문서 · 코드
- 홍윤우 1회기 녹음 지지직 — 단말 오디오 렌더 결손 원인 분석 — 그 문서가 미확정으로 남긴 “열/CPU 스로틀링 vs 오디오 세션 재구성” 중, 이 실험은 CPU 축소(저전력) 쪽이 렌더 결손을 재생 지연으로 바꾸는 조건임을 실기기로 보였다.
- AudioContext·오디오 graph 재활용 — iOS 렌더 밀림 완화 설계 — 공유 ctx 개선안은 덕킹 문제로 철회. 이전 검토 기록으로 보존한다.
- 게스트 AI 출력·마이크 입력 파이프라인 현재 구조 — 어떤 그래프가 어느 스텝에 살아 있는지의 지도.
- apps/web/entities/guest-page-session/model/use-audio-context-leak-harness.ts, use-main-thread-stall-harness.ts, apps/web/widgets/guest/guest-layout/ui/guest-debug-panel.tsx