마지막 업데이트 2026-07-29
두 모드는 핑퐁이 음성을 iOS 오디오 시스템의 서로 다른 경로로 재생하고, 마이크 EC(voice-processing)가 활성일 때 iOS가 두 경로를 다르게 취급한다.
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)을 겨냥했다 — 그러나 뒤에서 보듯 세 번의 입력측 접근이 모두 실패하고, 수사망은 출력측(재생 경로)으로 넘어간다.
fix/PPI-1101 브랜치 f0fbd455 커밋에서 검증). 입력측 접근이 폐기된 직접 사유.
EC OFF는 감쇠를 없애지만 AGC까지 죽여 아동 목소리가 VAD 문턱에 못 미치는 새 피해를 만든다 — 입력측을 건드리는 한 이 트레이드오프에서 벗어날 수 없다. 방아쇠(voice-processing)는 그대로 두고, 덕킹을 맞는 쪽(재생 경로)이 왜 모드마다 다른가를 파야 했다.
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(() => {});
};
기존 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 원격 트랙처럼 보이게 만들면 된다.
적용안은 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 계열 |
경로 차이가 감쇠 차이로 이어지는 메커니즘을 확인해야 수법이 완성된다. 열쇠는 아동 마이크가 EC ON으로 캡처된다는 사실 — 그 순간 iOS는 통화 세션 체제로 전환한다.
아동 마이크는 EC ON으로 캡처되므로(apps/web/hooks/local-media-constraints.ts), iOS는 Apple voice-processing I/O 유닛(playAndRecord 통화 세션)을 활성화한다. 이 상태에서:
입력측 접근 세 건은 모두 실패로 기록됐다 — 동적 전환은 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 MediaStream →
audio.srcObject 경로로 변경. Blob URL은 loopback 실패 시 fallback. |
로컬 반영 fix/tts-loopback-remote-audio 브랜치.
tts-player.test.ts / tsc --noEmit 통과. develop 미머지. |
출력측 우회가 Realtime 모드를 건드리지 않는 이유: Realtime은 애초에 far-end 경로라 문제가 없다. 덕킹당하는 것은 TTS의 미디어 요소 재생 경로뿐이므로 그 경로만 우회한다.
ai-output-mic-processing.ts,
user-talking-state.ts 삭제, use-ai-session.ts 복원.full:video/full:image 스텝에서
EC/NS/AGC OFF, AI 스텝 복귀 시 ON
(use-guest-page-session.ts:317-320 → applyMicProcessingConstraints).fix/tts-loopback-remote-audio 브랜치에 출력 경로 변경으로 로컬 반영됨.
use-ai-session.ts의 TTS player 생성 시 localPlaybackMode: "webrtc-loopback"을 지정하고,
tts-player.ts가 loopback remote stream을 audio.srcObject로 재생한다.
EC OFF / 마이크 OFF는 적용하지 않았다.