iPad AI 음성 밀림 1차 대응
영어 가드 LiveKit OFF · 저전력 프로브 · 재생 지연 감시 1차 커밋2차 로컬 · 실기기 미검증
마지막 업데이트 2026-09-14
저전력 × WebAudio 렌더 부하 재현 실험 뒤 코드에 반영한 첫 대응이다. 운영 동작이 바뀐 곳은 영어 가드를 LiveKit 런타임에서 끈 것 하나다. 나머지 둘 — 저전력 모드 관측 프로브와 AI 원격 오디오 재생 지연 감시 — 는 기본값이 관측 전용이라 세션 로그에 판별 재료만 추가한다. 재생 지연이 임계를 넘으면 오디오 엘리먼트를 재attach해 누적 버퍼를 버리는 리셋은 ?livekitPlayoutReset=on을 붙인 기기에서만 동작한다.
2026-09-13 · 커밋 기준 업데이트
커밋 c7ad8cb42db27429057e6fff0c2185b8dcc68db3에는 아래 영어 가드·저전력 프로브·재생 지연 감시와 함께, OFF인 마이크 입력 처리 노드의 실제 우회 및 테스트용 Context·렌더 부하 도구가 포함됐다. 자동 재생 리셋은 기본 OFF이며 실기기 개선 효과는 이번 문서 등록에서 검증하지 않았다.
아래 기존 본문은 2026-09-11의 단계별 분석·검증 기록이다. ‘2차 로컬·미커밋’ 및 ‘운영 동작 변경은 영어 가드 하나’라는 표기는 당시 범위이며, 최종 커밋 범위는 위 쉬운 설명을 기준으로 확인한다.
한 줄 결론
주장 등급: A 코드·테스트로 확정 / B 코드 구조 근거 추론 / C 실기기로 확인해야 할 가설.
1. 적용 범위
| # | 변경 | 운영 동작 변화 | 상태 | 근거 등급 |
|---|---|---|---|---|
| 1 | 영어 가드를 Realtime 런타임에서만 활성 | 있음 — LiveKit/VP 게스트에서 영어 가드용 AudioContext·ScriptProcessor·MFCC 연산이 더 이상 생성되지 않음 | 커밋 4ca9c122 | A 게이트 조건 / B LiveKit에서 무의미하다는 판단 |
| 2 | 저전력 모드 관측 프로브 LOW_POWER_PROBE | 없음 — 30초마다 로그 1건 | 커밋 4ca9c122 | A 판별 함수 / C 30fps 캡 = 저전력이라는 대응 |
| 3 | AI 원격 오디오 재생 지연 감시 + 재attach 리셋 LIVEKIT_PLAYOUT | 기본(off) 없음 — 5초마다 로그 1건. on일 때만 리셋 | 로컬 워킹트리 | A 판정·정책 / C 재attach가 밀림을 실제로 푸는지 |
보류한 것. ScriptProcessor→AudioWorklet 전환은 연산이 렌더 스레드로 옮겨가 실험이 지목한 병목을 더 바쁘게 만들 수 있어 제외했다(B). 저전력 감지 시 아바타 RMS·입력 체인 analyser를 줄이는 완화층(B층)은 프로브 로그로 상관이 확인된 뒤로 미뤘다. 마이크 입력 체인의 OFF 노드 우회는 최소 변경 완화 설계가 별도로 다룬다.
2. 왜 이 순서인가 — 실험에서 코드까지
3. [1] 영어 가드 LiveKit OFF
| 파일 | 변경 |
|---|---|
| apps/web/entities/guest-page-session/model/use-guest-page-session.ts:1523 | effectiveEnglishGuardEnabled = !isLowPerfDevice && isRealtimeMode && (override ?? englishGuardEnabled). isRealtimeMode는 노이즈 가드가 이미 쓰던 aiSession.resolvedVoiceRuntime === "realtime" 조건이다. |
- 왜 죽은 부하인가 B — 템플릿(apps/web/lib/english-guard-templates.ts)은 OpenAI Realtime 영어 환각 문구(“I just got promoted / You never believe”) 레퍼런스 MP3의 MFCC다. LiveKit/VP는 LLM 텍스트→TTS 경로라 같은 음성이 나올 수 없고, 영어 발화는 전사 텍스트로 보인다.
- 제거되는 것 A — use-ai-english-guard.ts:600~625의
new AudioContext()+createMediaStreamSource(aiAudioStream)+createScriptProcessor(메인스레드onaudioprocess에서 MFCC/FFT). 게스트는 guest-layout.tsx:106에서englishGuardEnabled: true를 하드코딩하고 있어 LiveKit에서도 항상 살아 있었다. - 사이드 이펙트 — Realtime 런타임 동작 무변경.
mode=test디버그 패널의 영어 가드 토글은 LiveKit에서 켜지지 않는다(P3, 의도된 결과). - 밀림 개선 효과 C — ScriptProcessor의 렌더 스레드 비용은 버퍼 복사와 메인스레드 왕복 대기 정도라 크지 않다. 저전력 임계에 걸친 기기에서만 체감될 수 있다.
4. [2] 저전력 모드 관측 프로브
| 파일 | 역할 |
|---|---|
| apps/web/hooks/use-low-power-mode-probe.ts | summarizeFrameIntervals(:24) 순수 판별 + useLowPowerModeProbe(:63) 훅 |
| apps/web/hooks/use-avatar-rms.ts:48 | peekSharedAudioContext — 생성·resume 부작용 없이 기존 공유 ctx만 조회. 프로브가 ctx를 새로 만들면 그 자체가 렌더 부하가 되기 때문 |
| use-guest-page-session.ts:1530 | 게스트 페이지에서 enabled: true로 상시 연결 |
판별
- 30초마다 rAF 60프레임 간격을 수집(
document.hidden이면 건너뜀). p10·median·p90·fps를 계산한다. - 저전력 의심 = p10 ≥ 28ms 이고 median ≤ 40ms. iOS 저전력 모드는 rAF를 30fps로 고정하므로 가장 빠른 프레임까지 33ms 근처에 머문다. 메인스레드 부하는 느린 프레임만 늘리고 빠른 프레임은 남기므로 p10이 둘을 가른다(테스트: 16.7ms×45 + 120ms×15 혼합은 의심 아님).
- 함께 기록: 공유 ctx의
outputLatencyMs·baseLatencyMs·ctxState. 실험 문서가 미기록으로 남긴 “저전력 시 CoreAudio I/O 버퍼 확대” 여부를 이 값으로 본다.
로그
| ctx | level | msg | 시점 |
|---|---|---|---|
LOW_POWER_PROBE | warn | Low power mode suspected (rAF capped near 30fps) | 의심 진입 시 1회 |
| info | Low power mode suspicion cleared | 해제 시 1회 | |
| info | Low power probe sample | 그 외 30초마다 |
S3 세션 로그 포함은 코드 추가 없이 성립한다 A. createLogger 출력은 logger.js의 싱크 → lib/lesson-log-sink.ts:96 registerLoggerSink → lessonLogBuffer로 컨텍스트 필터 없이 들어가고(minLevel 기본 debug), 그 버퍼가 lesson-logs .gz로 업로드된다. use-low-power-mode-probe.lesson-log.test.ts가 실제 logger→싱크→링버퍼 경로로 drain 결과에 ctx: "LOW_POWER_PROBE" 라인이 남는 것을 확인한다.
5. [3] AI 원격 오디오 재생 지연 감시 + 재attach 리셋
| 파일 | 역할 |
|---|---|
| apps/web/lib/voice-agent/livekit-playout-drift-monitor.ts | resolvePlayoutResetConfig(:23) 튜너블 · readInboundAudioReceiverStats(:77) · createPlayoutDriftTracker(:138) 판정 · createPlayoutResetPolicy(:289) 쿨다운/상한 · startPlayoutDriftMonitor(:361) 런타임 |
| apps/web/lib/voice-agent/livekit-client-session.ts | :2228 세션 수명 리셋 정책 생성, :2229 정지 함수 · :3138~ agent 참가자 오디오 attach 직후 시작, :3153 재attach 리셋 콜백 · :2842 teardown, :3063 Disconnected, :3213 TrackUnsubscribed에서 정지 |
| apps/web/package.json | test:livekit-vp 체인에 테스트 14건 등록 |
측정 (1초 주기)
| 지표 | 계산 | 어느 층인가 |
|---|---|---|
renderDeficitMs | 기준점 이후 (단조 시계 경과 − audioElement.currentTime 경과), 0 미만은 0 | 출력 싱크 층 C. 한계: MediaStream을 재생하는 엘리먼트의 currentTime은 WebKit 구현에서 스피커 출력이 아닌 재생 클럭으로 진행할 수 있어 출력단만 밀리는 경우를 못 볼 수 있다(외부 리뷰 P2). 그래서 샘플마다 coverage{elementClock, jitterBuffer, speakerOutput:false}로 관측 범위를 명시하고, inputContextOutputLatencyMs(마이크 입력 처리용 공유 ctx의 outputLatency, AI 출력 경로 자체는 아님)를 동봉해 실기기에서 가청 지연과 대조한 뒤 판정 지표를 확정한다 |
jitterBufferAvgMs | RemoteTrack.receiver.getStats() inbound-rtp의 ΔjitterBufferDelay / ΔjitterBufferEmittedCount × 1000 | NetEQ(수신 지터 버퍼) 층. livekit-client의 getReceiverStats()는 emittedCount를 노출하지 않아 raw 리포트를 직접 읽는다 |
concealedRatio | ΔconcealedSamples / ΔtotalSamplesReceived | 참고 지표. 패킷 손실·PLC 비율 |
driftMs | renderDeficitMs + (jitterBufferAvgMs ?? 0) | 판정 대상 |
판정과 조치
- 의심 =
driftMs ≥ 400ms가 3샘플 연속. 에피소드당 warn 1회, 임계 아래로 내려오면 cleared info. livekitPlayoutReset=off(기본) — 여기서 끝. 로그만.livekitPlayoutReset=on— 정책 통과 시 ①audioTrack.detach(el)②audioTrack.attach(el)(새 MediaStream → 누적 버퍼 폐기) ③autoplay·volume복원 ④ 기존applyLiveKitRemoteAudioPlaybackGate재적용(비AI 스텝이면 muted 유지) ⑤ 기준점 재설정. livekit-client의attach는 오디오 트랙이 있으면muted=false로 바꾸므로 ④가 뒤에 와야 한다 A.- 정책 — 직전 리셋 후 10초 이내
reset_skipped: cooldown, 세션당 5회 초과max_reached. 리셋 실패는 warn만 남기고 세션 유지. 정책 객체는connectLiveKitBrowserSession수명에서 한 번 만들어 감시기에 주입하므로 트랙 재구독으로 감시기가 다시 생겨도 쿨다운·상한이 이어진다(외부 리뷰 P2 반영). - 시계 — 경과 시간은
performance.now()(단조 시계) 기준이다.Date.now()는 시스템 시각 변경에 흔들려 정상 재생을 지연으로 오탐할 수 있어 기본값에서 제외했다(외부 리뷰 P2 반영, 시각 점프 테스트 포함).
오탐 가드 3종 A
| 상황 | 처리 | 이유 |
|---|---|---|
엘리먼트 paused | 측정 제외 + 재기준화 | 비AI 스텝 게이트로 멈춘 구간은 currentTime이 서는 게 정상 |
currentTime 역행 | 재기준화 | 재attach로 srcObject가 바뀌면 0으로 돌아간다 |
ΔtotalSamplesReceived = 0 | 유휴로 보고 제외 + 재기준화 | AI 침묵·패킷 중단 구간에서 currentTime이 멈추는 것을 결손으로 세면 on 모드에서 불필요한 리셋이 나고 쿨다운·상한을 소진한다(리뷰에서 P1로 잡아 추가) |
로그
| ctx | level | msg | 시점 |
|---|---|---|---|
LIVEKIT_PLAYOUT | info | Playout drift monitor started (speakerOutputObservable:false 고정 명시) / stopped | attach·정지 |
| info | Playout drift sample (+ coverage·inputContextOutputLatencyMs·inputContextBaseLatencyMs·inputContextState) | 5샘플(≈5초)마다 | |
| warn | Playout drift suspected | 의심 진입 1회 | |
| info | Playout drift cleared | 해제 1회 | |
| warn | Playout reset applied (audio element reattach) | on 리셋 실행 | |
| warn | Playout reset failed | 리셋 예외 |
audioDebug(개발 환경 콘솔)에는 audio_element_reattach_requested / audio_element_reattached가 엘리먼트 스냅샷과 함께 남는다.
6. 실기기 튜너블과 판별법
사용자 확인 체크리스트 (미수행)
- 정상 전원 iPad:
LOW_POWER_PROBEwarn이 안 뜨는지. 뜨면 판별 임계(28/40ms) 재조정. - 케이블 분리 + 저전력 ON: warn이 뜨고
fps≈30,outputLatencyMs가 정상 대비 커지는지. - 같은 조건에서 AI 스텝 진행:
LIVEKIT_PLAYOUTsample의renderDeficitMs·jitterBufferAvgMs중 어느 것이 쌓이는지. 밀림 청감과 suspected warn 시각이 맞는지. - 3에서 renderDeficit 우세면
?livekitPlayoutReset=on으로 재진입: reset applied 뒤 밀림이 풀리는지, 끊김이 100~200ms 수준인지, AI 침묵 구간에 불필요 리셋이 없는지. - LiveKit 영어 가드 OFF로 인한 변화 없음 확인: 활동 전환·듣기 토글·에코 등 기존 동작.
7. 검증 결과와 남은 위험
| 항목 | 결과 |
|---|---|
| 단위 테스트 | 프로브 9건 + 세션 로그 포함 2건(vitest) · 재생 지연 감시 18건(node:test, test:livekit-vp) 통과 |
| tsc / prettier | 통과. 사전 존재 에러 1건(host-disconnected.png 모듈 선언, develop 동일) 제외 |
| 기존 LiveKit 테스트 | livekit-client-session / livekit-audio-chain-processor / shared-input-audio-context 3파일 exit 0. pnpm test:livekit-vp 전체는 이 브랜치가 건드리지 않은 lesson-session.service.disconnect-race.test.ts가 사전 실패라 체인이 끊긴다(develop 동일) |
| 실기기 | 미수행 |
남은 위험. ① 리셋 순간 100~200ms 끊김(C). ② 밀림이 NetEQ 상류면 재attach로 안 풀릴 수 있음(C) — dry-run 로그로 먼저 층을 가른다. ②-1 renderDeficitMs가 출력단 정지를 못 볼 수 있어(WebKit MediaStream currentTime = 재생 클럭 가능성, C) 실기기에서 가청 밀림 시각과 renderDeficitMs·jitterBufferAvgMs·inputContextOutputLatencyMs 셋을 대조해 어느 지표가 반응하는지 확정해야 한다. 셋 다 무반응이면 판정 지표 자체를 바꿔야 한다. ③ 네이티브 30Hz 디스플레이는 저전력 프로브 오탐 라벨 가능(로그만). ④ 게스트당 로그량 증가: LOW_POWER_PROBE 약 120건/시간, LIVEKIT_PLAYOUT AI 스텝 중 약 720건/시간(LIVEKIT_AUDIO_CHAIN health와 같은 5초 주기). ⑤ 프로브가 아바타 RMS 훅의 모듈 상태에 의존(P2, 최소 변경 우선으로 수용).
관련 문서 · 코드
- PPI-1290 · AI 음성 밀림 복구 절차 설계 — 이 문서의 재attach 리셋을 서버 발화 중단·재구독·정책 재적용까지 확장한 5단계 복구 절차와, 자동 트리거 판단 기준을 dry-run 로그로 확정하는 절차(후속 설계 제안).
- iPad AI 음성 밀림 재현 — 저전력 모드 × WebAudio 렌더 부하 — 이 대응의 출발점. 그 문서의 “다음 단계” 1(상관 확정)과 2(저전력 감지)의 계측 부분을 구현했다.
- PPI-1290 · 최소 변경 완화 설계 — 마이크 입력 체인 OFF 노드 우회·RMS 간소화 제안. 이 문서의 B층(WebAudio 축소)과 짝이 된다.
- 게스트 AI 출력·마이크 입력 파이프라인 — 어느 그래프가 어느 스텝에 살아 있는지의 지도.
- AudioContext·graph 재활용 설계 — 재활용은 총 렌더 부하 축소 수단으로 재해석된 문서.
- apps/web/hooks/use-low-power-mode-probe.ts · apps/web/lib/voice-agent/livekit-playout-drift-monitor.ts · apps/web/lib/voice-agent/livekit-client-session.ts · apps/web/entities/guest-page-session/model/use-guest-page-session.ts