마지막 업데이트 2026-07-22
iOS(iPad) 내장 스피커+내장 마이크 환경에서 핑퐁이 발화 중 볼륨 감쇠(덕킹)가 발생하는데(PPI-1101), Realtime 모드에서는 감쇠 폭이 크지 않았던 반면 TTS 모드에서는 감쇠 폭이 훨씬 크다. 같은 기기·같은 마이크 조건인데 왜 모드에 따라 감쇠 폭이 다른가?
두 모드는 핑퐁이 음성을 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로 바꾼다.
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();
적용안은 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으로 캡처되므로(apps/web/hooks/local-media-constraints.ts), iOS는 Apple voice-processing I/O 유닛(playAndRecord 통화 세션)을 활성화한다. 이 상태에서:
fix/PPI-1101 브랜치 f0fbd455 커밋에서 검증). 입력측 접근이 폐기된 직접 사유.
| 접근 | 내용 | 결과 |
|---|---|---|
| 입력측 ① 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는 적용하지 않았다.