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

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

작성일: 2026-07-08 카테고리: Bugs 관련: PPI-1101, PPI-1097 대상: iPad/iPhone 내장 스피커+내장 마이크 환경

1. 질문 / 증상

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

2. TL;DR

두 모드는 핑퐁이 음성을 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로 바꾼다.

3. 두 모드의 재생 경로 (코드 근거)

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();

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 계열

4. 메커니즘 — voice-processing 유닛의 오디오 분류

아동 마이크는 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 모드가 맞는 시스템 덕킹과는 규모가 다른 수준이다.

5. 방증

6. 대응 이력 (PPI-1101)

접근내용결과
입력측 ① 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의 미디어 요소 재생 경로뿐이므로 그 경로만 우회한다.

7. 현재 상태 (2026-07-08)

8. 관련 문서