iPad AI 음성 “밀림” 재현
— 저전력 모드 × WebAudio 렌더 부하 (디버그 하네스 실험) 재현 조건 확정 · 현장 부하 원천 미확정 · 수정 미적용

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

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

작성일: 2026-09-10실험일: 2026-09-09 ~ 09-10브랜치: PPI-1290-TEST (dev 배포)단말: iPad (실기기 1대, 게스트 ?mode=test)런타임: LiveKit (AI 음성 = 원격 트랙 → 미디어 엘리먼트)
2026-09-10 후속 설계. 공유 ctx 개선안은 덕킹 문제로 철회했다. 현재 권장 후보는 PPI-1290 · iPad AI 음성 밀림 — 최소 변경 완화 설계의 OFF 처리 노드 우회와 RMS 간소화다. 아래 현장 부하 후보의 실제 실행 조건은 후속 문서에서 재검토했다. health ratio·outputLatency만으로 AI 재생 지연이나 CoreAudio 버퍼 확대를 확정할 수 없으며, 개선안은 코드 미반영·효과 미검증이다.

결론

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

현장과의 대응 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.tsAI 스텝 마이크 ON 동안 상시
1아바타 RMS · 이퀄라이저 · 노이즈 가드 analyserapps/web/hooks/use-avatar-rms.ts, apps/web/shared/ui/ai-audio-equalizer.tsx, apps/web/hooks/use-ai-audio-noise-guard.tsAI 발화 중 중첩
2ScriptProcessorNode 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순위. 저전력에서 왕복이 늦으면 렌더 스레드가 버퍼를 기다리며 시간을 잃을 수 있어 배제는 아님
미확정 판별. 저전력 ON + 하네스 OFF로 실제 수업 흐름(영상 스텝 loopback ↔ AI 스텝 마이크·외부 STT ON)을 오가며 밀림이 나오는지, 그때 LIVEKIT_AUDIO_CHAIN health ratio가 0.98 아래로 떨어지는지 확인해야 앱 그래프가 현장 부하라는 것이 확정된다. 저전력 상태의 out: 값이 정상 대비 커지면 CoreAudio I/O 버퍼 확대까지 함께 확정된다.

재현 하네스 — 코드 경로와 사용법

모두 mode=test 게스트 세션(/client-guest/session)에서만 렌더되는 디버그 패널(우상단 Show Debug) 안에 있다. 운영 경로에는 노출되지 않는다.

진입과 배선

use-audio-context-leak-harness.ts — 개수 누적 + 렌더 부하

함수동작
addContexts(count, mode)213클릭마다 new AudioContext()resume(). "render"attachSilentRenderGraph(:183)로 gain 0 oscillator를 destination에 연결, "empty"면 노드 없음. 생성 객체를 배열로 붙잡아 GC를 막는다. 생성 예외는 멈추고 lastError로 노출
closeAll()258oscillator stop + 모든 ctx close
RENDER_LOAD_PROCESSOR_SOURCE70AudioWorkletProcessor 소스 문자열(Blob URL로 addModule). 생성자에서 20ms 동안 반복 횟수를 보정(calibrate, :86)한 뒤 process()마다 targetMs × itersPerMsMath.sqrt 루프. 1초마다 실제 콜백 수를 port.postMessage
startRenderLoad(percent)332전용 ctx 1개 + 워크렛 노드를 destination에 연결. targetMs = 128/sampleRate × 1000 × percent/100
setRenderLoadPercent / stopRenderLoad315 / 308실행 중 점유율 변경 / 노드 disconnect·ctx close·Blob URL revoke

AudioWorkletGlobalScope에는 performance.now()가 없어 Date.now()(1ms 해상도)로 보정한다. 패널 actualexpect(48kHz 기준 375/s)보다 5% 이상 낮으면 빨간색이며, 이것이 렌더 스레드가 데드라인을 놓치고 있다는 직접 증거다.

use-main-thread-stall-harness.ts — 메인스레드 정지

함수동작
blockMainThread(ms)50performance.now() 기준 동기 busy loop, 실측 정지 시간 반환
startPeriodic(stallMs, intervalSec)10350~3000ms 정지를 1~10s 주기로 setInterval. 실행 중 파라미터 변경 시 재시작
stallOnce(ms)155단발 3s/12s. 클릭 50ms 뒤 정지에 들어가 “정지 중” 표시가 먼저 그려진다. iPad 12초 정지 사례 재현용

로그 태그

커밋 (PPI-1290-TEST)

sha내용
409b40e3AudioContext 누적 하네스 + 패널 섹션 + vitest 6건 + Storybook
9793a199AudioWorklet 렌더 부하 모드(슬라이더·실측 콜백 표시)
6959e736패널 액션 영역 스크롤, English Guard·Noise Tests 섹션 제거
6fe2a317메인스레드 정지 하네스, Transcripts 높이 50vh 제한

배제한 가설과 근거

가설판정근거
AudioContext 개수 누적이 출력 지연을 만든다기각 A255개 running 상태에서 음성 정상. 이전 8/31·9/1 세션 로그 분석에서도 재생성 누적 가설은 반증됨(홍윤우 문서)
렌더 스레드 기아 단독기각 A정상 전원에서 콜백 40% 누락에도 음성 정상. 엘리먼트 재생 경로가 WebAudio와 독립(B)
메인스레드 정지기각 A3초 정지에 영상만 멈추고 음성 연속. 저전력에서도 워크렛 없이는 밀림 없음
저전력 모드 단독기각 A저전력 ON + 하네스 OFF에서 정상
저전력 × 렌더 부하확정 A두 번 재현. 메인스레드 정지 유무와 무관

주의사항

다음 단계 (제안 · 코드 미반영)

2026-09-11 반영. 1(상관 확정)과 2(저전력 감지)의 계측 부분은 LOW_POWER_PROBE 프로브로, 영어 가드는 LiveKit 런타임 OFF로 코드에 들어갔고, 원인층과 무관하게 지연 상한을 두는 AI 재생 지연 감시(dry-run)가 추가됐다. PPI-1290 · iPad AI 음성 밀림 1차 대응 참고.

  1. 저전력 ON + 하네스 OFF 실제 수업 흐름에서 가청 밀림과 적용 설정을 기록하고, 입력 health는 보조 지표로 비교한다.
  2. 최소 변경 완화 설계에 따라 OFF인 EQ·컴프레서·리미터의 실제 우회를 첫 후보로 검증한다.
  3. 효과가 부족하면 RMS 립싱크 간소화를 독립적으로 비교한다. 공유 ctx 개선안은 덕킹 문제로 철회했으며, 외부 STT·AudioWorklet 전환은 별도 후속 검토 대상이다.

관련 문서 · 코드