iOS 덕킹 — TTS 모드 감쇠 원인 분석 및 WebRTC loopback 우회

마지막 업데이트 2026-07-29

iOS 덕킹 — TTS 모드 감쇠 원인 분석 및 WebRTC loopbac…: 증상: 결론 — 범인부터 공개한다, 원인: 수사 일지 ③ — 범행 수법: 두 모드의 재생 경로 (코드 근거), 수정·검증: 현재 상태 (2026-07-08) 흐름
동작 흐름 요약
  1. 증상: 결론 — 범인부터 공개한다
  2. 원인: 수사 일지 ③ — 범행 수법: 두 모드의 재생 경로 (코드 근거)
  3. 수정·검증: 현재 상태 (2026-07-08)
작성일: 2026-07-08 카테고리: Bugs 관련: PPI-1101, PPI-1097 대상: iPad/iPhone 내장 스피커+내장 마이크 환경

결론 — 범인부터 공개한다

두 모드는 핑퐁이 음성을 iOS 오디오 시스템의 서로 다른 경로로 재생하고, 마이크 EC(voice-processing)가 활성일 때 iOS가 두 경로를 다르게 취급한다.

  • Realtime 모드: 핑퐁이 음성이 WebRTC 원격 트랙 → WebKit이 voice-processing 유닛의 far-end(통화 상대방 음성)로 렌더 → 덕킹 면제. 통화 모드 볼륨 프로파일 수준(수 dB)의 감쇠만 발생.
  • TTS 모드: 핑퐁이 음성이 Blob URL을 audio.src에 물린 미디어 요소 파일 재생이면 통화 세션 밖 "기타 오디오"로 분류 → voice-processing 활성 중 시스템 덕킹을 정면으로 맞음(체감 10~20dB급).

즉 Realtime은 "통화 안의 목소리", 기존 TTS는 "통화 중에 틀어놓은 미디어"로 분류되는 차이가 감쇠 폭 차이의 원인이다.

2026-07-08 적용안은 TTS 로컬 출력도 WebRTC remote stream처럼 보이게 만드는 local WebRTC loopback이다. EC OFF나 마이크 캡처 OFF 없이, 출력 경로만 RTCPeerConnection 수신 트랙 → audio.srcObject로 바꾼다.

수사 일지 ① — 사건 발생: 모드에 따라 다른 감쇠 폭

iOS(iPad) 내장 스피커+내장 마이크 환경에서 핑퐁이 발화 중 볼륨 감쇠(덕킹)가 발생하는데(PPI-1101), Realtime 모드에서는 감쇠 폭이 크지 않았던 반면 TTS 모드에서는 감쇠 폭이 훨씬 크다. 같은 기기·같은 마이크 조건인데 왜 모드에 따라 감쇠 폭이 다른가?

첫 단서

같은 기기, 같은 마이크, 같은 EC ON 조건에서 모드만 바꾸면 감쇠 폭이 달라진다 — 이 비대칭이 수사의 출발점이다. 마이크측 원인만으로는 모드 간 차이를 설명할 수 없으므로, 두 모드에서 다른 무언가 — 재생 경로 — 가 유력 용의 선상에 올랐다.

수사 일지 ② — 용의자 선상: 입력측 수사와 방증

입력측부터 조사

덕킹의 방아쇠는 마이크 캡처측 voice-processing이라는 방증이 먼저 잡혔다. 그래서 초기 수사는 입력측(EC/NC/AGC)을 겨냥했다 — 그러나 뒤에서 보듯 세 번의 입력측 접근이 모두 실패하고, 수사망은 출력측(재생 경로)으로 넘어간다.

수사망 전환

EC OFF는 감쇠를 없애지만 AGC까지 죽여 아동 목소리가 VAD 문턱에 못 미치는 새 피해를 만든다 — 입력측을 건드리는 한 이 트레이드오프에서 벗어날 수 없다. 방아쇠(voice-processing)는 그대로 두고, 덕킹을 맞는 쪽(재생 경로)이 왜 모드마다 다른가를 파야 했다.

수사 일지 ③ — 범행 수법: 두 모드의 재생 경로 (코드 근거)

3-1. Realtime 모드 — WebRTC 원격 트랙 재생

OpenAI Realtime API와 RTCPeerConnection으로 직접 연결되고, 핑퐁이 음성은 원격 MediaStreamTrack으로 도착해 srcObject로 재생된다.

apps/web/entities/guest-session/model/use-ai-session.ts:1960-1982

pc.ontrack = async (e) => {
  ...
  const originalStream = e.streams[0];
  setAiAudioStream(originalStream);          // SFU relay용
  audioEl.srcObject = originalStream;        // 로컬 재생 — WebRTC 원격 트랙
  audioEl.muted = false;
  audioEl.volume = 1.0;
  await audioEl.play().catch(() => {});
};

3-2. TTS 모드 — Blob URL 미디어 요소 재생

기존 TTS 경로는 TTS 응답(mp3)을 fetch한 뒤 Blob URL을 audio.src에 물려 브라우저 네이티브 미디어 디코더로 재생한다. WebAudio 디코딩 결과(item.buffer)는 녹음/SFU/아바타용 단일 스트림에만 사용되고 로컬 스피커 출력에는 쓰이지 않는다. (WebAudio MediaStreamDestination 파생 스트림을 srcObject로 로컬 재생하면 iOS Safari 피치 왜곡이 발생해 Blob URL 방식으로 정착 — PPI-1081 계열 이력.)

apps/web/entities/guest-session/lib/tts-player.ts:477-494 (playLocalAudioElement)

audio.srcObject = null;
audio.src = objectUrl;      // fetch한 mp3 Blob URL — 네이티브 미디어 파이프라인
audio.muted = false;
audio.volume = 1.0;
void audio.play();
범행 수법의 정체

여기서 두 모드의 운명이 갈린다. Realtime의 음성은 WebRTC 원격 트랙 — iOS가 "통화 상대방 목소리"로 대접한다. TTS의 음성은 Blob URL 파일 재생 — 통화 중 틀어놓은 배경음악과 같은 등급으로 강등돼 시스템 덕킹을 정면으로 맞는다. 그렇다면 답은 하나: TTS 출력도 WebRTC 원격 트랙처럼 보이게 만들면 된다.

3-3. 적용 후 TTS 모드 — local WebRTC loopback remote stream 재생

적용안은 TTS 합성/디코딩 구조와 녹음/SFU relay용 MediaStreamDestination은 유지하고, 로컬 스피커 출력만 local RTCPeerConnection 송수신 쌍으로 되받는다. 송신쪽은 TtsPlayer.stream의 audio track을 addTrack()하고, 수신쪽 ontrack에서 받은 stream을 audio.srcObject에 연결한다.

apps/web/entities/guest-session/lib/tts-player.ts:537-680 (playLoopbackAudioElement / createLocalAudioLoopback)

const sender = new RTCPeerConnection();
const receiver = new RTCPeerConnection();
sender.addTrack(ttsStream.getAudioTracks()[0], ttsStream);
receiver.ontrack = (event) => {
  audio.removeAttribute("src");
  audio.srcObject = event.streams[0];  // WebRTC remote stream처럼 재생
  audio.muted = false;
  audio.volume = 1.0;
  void audio.play();
};

이 경로가 실패하면 기존 Blob URL 재생으로 폴백한다. 따라서 WebRTC loopback 지원 여부와 무관하게 TTS 출력 자체는 유지된다. 성공 시 Blob URL은 fallback 용도로만 생성됐다가 즉시 revoke된다.

Realtime 모드TTS 모드(기존 Blob 경로)TTS 모드(적용 후 loopback)
음원 OpenAI WebRTC 원격 트랙 TTS API 응답 mp3 (fetch) TTS WebAudio 렌더 stream을 local WebRTC로 되받은 원격 트랙
로컬 재생 방식 audioEl.srcObject = 원격 MediaStream audio.src = Blob URL (파일 재생) audio.srcObject = loopback remote MediaStream
iOS 오디오 분류 통화(voice chat) far-end 신호 일반 미디어 재생 WebRTC remote track에 가까운 경로로 유도
voice-processing 활성 시 덕킹 면제 (AEC 에코 레퍼런스로 등록) "기타 오디오" 덕킹 대상 far-end 유사 경로로 덕킹 회피 기대
체감 감쇠 수 dB (통화 모드 볼륨 프로파일) 10~20dB급 (시스템 덕킹) 실기기 확인 필요. 단 코드 경로는 Realtime과 같은 srcObject remote stream 계열

수사 일지 ④ — 범행 동기: voice-processing 유닛의 오디오 분류

왜 iOS는 두 경로를 다르게 대하는가

경로 차이가 감쇠 차이로 이어지는 메커니즘을 확인해야 수법이 완성된다. 열쇠는 아동 마이크가 EC ON으로 캡처된다는 사실 — 그 순간 iOS는 통화 세션 체제로 전환한다.

아동 마이크는 EC ON으로 캡처되므로(apps/web/hooks/local-media-constraints.ts), iOS는 Apple voice-processing I/O 유닛(playAndRecord 통화 세션)을 활성화한다. 이 상태에서:

  1. WebRTC 원격 트랙은 "통화 상대방 음성"으로 렌더링된다. WebKit이 원격 트랙을 voice-processing 유닛의 far-end 신호로 직접 넣어준다. 이 신호는 AEC(에코 제거)의 레퍼런스이기도 하므로 시스템이 이를 깎을 이유가 없다 — 오히려 통화 명료도를 위해 보전한다. 부수 효과로 에코 제거도 정확하다 (출력 신호를 정확히 알고 마이크 입력에서 상쇄).
  2. 미디어 요소 파일 재생(TTS Blob)은 "통화 외 기타 오디오"로 취급된다. voice-processing이 활성인 동안 iOS는 통화 명료도를 우선해 통화 세션 밖의 미디어 재생을 강하게 덕킹한다. 핑퐁이 음성이 통화 오디오가 아니라 배경음악과 같은 등급으로 분류되는 셈이다. AEC 입장에서도 이 소리는 레퍼런스가 없는 미지의 신호라 억제 대상이 된다.
Realtime 모드에서도 감쇠가 아예 0은 아니다. 마이크 캡처 시작으로 통화 세션(playAndRecord)에 진입하면 스피커 출력 프로파일 자체가 미디어 재생 모드보다 약간 낮아진다. 다만 이는 TTS 모드가 맞는 시스템 덕킹과는 규모가 다른 수준이다.

수사 일지 ⑤ — 검거 이력: 폐기된 세 접근과 최종 우회 (PPI-1101)

세 번의 오인 체포

입력측 접근 세 건은 모두 실패로 기록됐다 — 동적 전환은 revert(#844), audioSession 힌트는 실기기 검증 실패로 재시도 금지, EC 상시 OFF는 AGC 번들 이탈로 폐기. 네 번째, 출력측 loopback 우회만이 살아남았다.

접근내용결과
입력측 ① EC/NC/AGC 동적 전환 AI 발화 중 마이크 EC/NC/AGC를 임시 OFF, 발화 종료 후 복구 (0b4a4e43, PR #828) revert 3b5405e0(PR #844, 2026-07-08)으로 전체 되돌림
입력측 ② audioSession 힌트 WebKit navigator.audioSession = "playback" 힌트 (fix2 브랜치) 실패 iPad 실기기 검증 실패, 재시도 금지
입력측 ③ EC 상시 OFF echoCancellation constraint 제거 폐기 AGC 번들 이탈로 마이크 레벨 VAD 문턱 미달
출력측 — WebRTC loopback 우회 TTS 로컬 출력만 MediaStreamAudioDestinationNode.stream → local RTCPeerConnection sender/receiver → receiver ontrack의 remote MediaStreamaudio.srcObject 경로로 변경. Blob URL은 loopback 실패 시 fallback. 로컬 반영 fix/tts-loopback-remote-audio 브랜치. tts-player.test.ts / tsc --noEmit 통과. develop 미머지.

출력측 우회가 Realtime 모드를 건드리지 않는 이유: Realtime은 애초에 far-end 경로라 문제가 없다. 덕킹당하는 것은 TTS의 미디어 요소 재생 경로뿐이므로 그 경로만 우회한다.

현재 상태 (2026-07-08)

관련 문서