마지막 업데이트 2026-07-29
videoLoopbackGain·videoLoopbackJbt)을 보세요.| 티켓 | PPI-1121 (계보: PPI-1097 영상 감쇠 → PPI-1101 TTS 덕킹 → PPI-1121 clone 무음/에코) |
| 커밋 | 1f3c3e67 loopback 라우팅 + EC 토글 제거 · 05fd985e 재생 속도 출렁임 억제 (브랜치 PPI-1121) |
| 적용 범위 | iOS 계열(iPhone/iPad, iPadOS 13+ 데스크톱 UA 위장 포함) 게스트 세션에서만. PC/Android 게스트·모니터 페이지는 기존 video 직접 재생 유지 |
| 검증 상태 | iPad 실기기 확인 덕킹 회피 + 출렁임 억제 작동 (2026-07-13) |
| 핵심 파일 | shared/lib/webrtc-audio-loopback.ts · shared/lib/video-audio-loopback.ts · shared/ui/meet-video.tsx · shared/ui/guest-layout-content.tsx |
덕킹의 원인 체인 (iOS 관찰 동작):
getUserMedia(EC on) → WebKit이 voice-processing I/O 유닛으로 캡처 → 오디오 세션이 통화 모드(playAndRecord/voiceChat 계열)로 전환 → OS가 통화 명료도를 위해 "미디어 카테고리" 출력을 자동 감쇠(덕킹) → HTMLMediaElement 재생(영상 src, TTS blob)이 감쇠됨 단, WebRTC remote 트랙(srcObject) 재생은 "통화 상대 음성"으로 분류 → 덕킹 면제 ←★ 회피 원리
기존 대응(PPI-1097 EC 스텝 토글)의 사이드 이펙트 체인:
비-AI 스텝 진입 시 applyConstraints로 EC/NS/AGC OFF → AI 스텝 복귀 시 ON 복원 → iOS에서 EC 토글 = voice-processing 유닛 전체 재구성 → ① 복원 실패 시 EC OFF "고착" → 스피커 TTS가 마이크로 재유입 → 에코 전사(확정 시그니처) → ② 재구성 중 producer clone이 결정론적 무음 (PPI-1121 clone 무음 계열) → ③ OpenAI에 에코+실음성 혼입 → VAD 혼란 → "아동 말이 전달 안 됨" 체감
결론: 입력(마이크 constraint)은 절대 건드리지 않고, 출력을 전부 덕킹 면제 분류(통화 채널)로 옮긴다. TTS는 PPI-1101에서 이미 loopback으로 옮겼고, 이번에 영상까지 옮기면서 EC 토글의 존재 이유가 사라져 제거했다. 이제 EC/NS/AGC는 세션 내내 ON 고정이다 (런타임에 끄는 코드가 코드베이스에 더 이상 없음).
<video src=blob> ──(MediaElementAudioSourceNode, element당 1회·WeakMap 캐시)──▶ hub(GainNode)
│
┌─────────────────────────────────────────────────────────┤
▼ ▼
localGain(0.1) → MediaStreamAudioDestination ctx.destination 직결
│ (loopback feed) (loopback 미성립 시에만 연결,
▼ 원음 유지 — 잔존 덕킹 감수)
RTCPeerConnection 쌍 (sender→receiver, 기기 내부 host candidate)
▼
remote 트랙 → 숨김 audio.srcObject 재생 ←★ 통화 분류 = 덕킹 면제
| 이전 (PPI-1097 + 1dc7317d iOS 게이트) | 이후 (이번 적용) | |
|---|---|---|
| 영상/이미지 스텝 | EC/NS/AGC OFF (덕킹 조건 이탈) | EC ON 유지, 영상 오디오는 loopback으로 덕킹 면제 |
| AI 스텝 복귀 | applyConstraints로 ON 복원 (실패 시 고착) | 복원 동작 자체가 없음 |
| voice-processing | 스텝마다 재구성 (clone 무음 유발 조건) | 세션 내내 안정 (재구성 0회) |
| 제거 코드 | use-guest-page-session.ts의 스텝 토글 useEffect, local-media-constraints.ts의 applyMicProcessingConstraints (마지막 런타임 EC 조작 코드) | |
증상: iPad 실기기에서 loopback 영상 오디오가 빨라졌다 느려졌다 함. 원인: WebRTC 수신측 적응형 지터 버퍼(NetEQ)가 버퍼 수위를 맞추려 오디오를 시간 압축/확장하는 표준 동작. 로컬 loopback이라 네트워크 지터는 없지만, iPad 부하(영상 디코드+SFU+Realtime 동시 구동)로 송신 패킷 생산이 불규칙하면 같은 증상이 난다. 말소리(TTS)에선 안 들리고 연속 오디오(영상)에서 드러난다.
| 대응 | 메커니즘 | 구현 |
|---|---|---|
| 수신 버퍼 고정 | receiver.jitterBufferTarget = 150ms — 적응 대신 고정 목표로 재생. 대가는 목표값만큼의 고정 오디오 지연(립싱크) | 미지원 시 playoutDelayHint 폴백, 적용 API를 established 로그의 jitterBufferApplied에 기록 (null=미지원) |
| Opus 균일화 | 패킷 크기/간격 일정화로 NetEQ 적응 유발 조건 축소 | answer SDP fmtp 패치: cbr=1;usedtx=0;maxaveragebitrate=256000;stereo=1 (stabilizeOpusSdp) |
| contentHint | 연속 신호용 인코딩 모드 | 송신 트랙 contentHint="music" |
세 옵션 모두 VideoAudioLoopback.ensureLoopback() 안에서만 전달되므로 loopback 적용 조건(iOS 게스트)을 그대로 상속한다. TTS loopback은 옵션 미전달로 기존 검증 동작 유지. 네트워크 속도와는 무관한 경로(기기 내부 host candidate + 사전 캐시 blob)지만, 극한 네트워크에서 다른 연결(SFU/Realtime)의 복구 처리가 CPU를 눌러 간접 재발할 이론적 여지는 있음 → 그 경우 videoLoopbackJbt 상향으로 대응.
TtsPlayer (entities/guest-session/lib/tts-player.ts) ──┐
├──▶ createWebRtcAudioLoopback() ← "어떻게 되받나" (공용 전송 계층)
VideoAudioLoopback (shared/lib/video-audio-loopback.ts)┘ shared/lib/webrtc-audio-loopback.ts
▲
MeetVideo (iOS 게스트 세션에서만 컨트롤러 생성) ← "무엇을, 실패하면 어떻게" (소비자별 정책 계층)
| 파일 | 역할 |
|---|---|
| webrtc-audio-loopback.ts | 무상태 공용 유틸. PC 쌍 협상, ICE candidate 지연 전달(조기 도착 유실 방지), remote track 3초 타임아웃(Promise.race), 연결 사망 1회 통지(onConnectionLost), Opus SDP 패치·jitterBufferTarget·contentHint 옵션 |
| video-audio-loopback.ts | 영상 전용 정책. WebAudio 그래프(hub/게인/직결), element 캐시, 폴백·재시도 상태기계, 런타임 튜너블, 숨김 audio element 관리 |
| tts-player.ts | TTS 전용 정책(기존): blob 폴백, chunk 진행, 로컬 감쇠 게인 0.1. 이번에 협상 로직만 공용 유틸로 위임 |
| 출력 | 경로 | 덕킹 | 감쇠 게인 |
|---|---|---|---|
| TTS (정상) | loopback → srcObject | 면제 | LOOPBACK_LOCAL_GAIN 0.1 (실기기 확정) |
| TTS (폴백) | blob URL 재생 | 맞음 (감수) | 없음 (원음으로 보상) |
| 영상 (정상) | loopback → srcObject | 면제 | VIDEO_LOOPBACK_LOCAL_GAIN 0.1 (TTS 기준 차용) |
| 영상 (폴백) | WebAudio ctx.destination 직결 | 잔존 (감수) | 없음 (원음) |
| 이미지 스텝 | 오디오 없음 | — | — |
| 키 | 기본값 | 용도 |
|---|---|---|
| videoLoopbackGain | 0.1 (허용 0~4) | loopback 로컬 출력 감쇠 — TTS 캘리브레이션 기준 차용, 실기기 미세조정용 |
| videoLoopbackJbt | 150ms (허용 0~1000, 0=미적용) | 수신 버퍼 고정 목표 — 출렁임 잔존 시 상향, 립싱크 이슈 시 하향 |
| videoLoopback=off | — | loopback 비활성(직결만) — 출렁임/음질 문제의 loopback 기인 여부 A/B 확진용 |
| 로그 | 의미 |
|---|---|
| Video audio routed into loopback graph | attach 성공 (localGain·loopbackState 포함) |
| Video audio loopback established | loopback 성립 + remote 재생 시작 (attempts·localGain·jitterBufferTargetMs·jitterBufferApplied) |
| ... setup failed / ... playback failed, using direct output | 협상 실패(재시도) / play 거부(직결 확정 폴백) |
| ... connection lost | 성립 후 연결 사망 → 직결 복귀 + 재협상 |
| ... disabled by override | videoLoopback=off A/B 모드 |