MeetVideo loopback 오디오 출력 — sink 라우팅 원인 분석 및 수정
마지막 업데이트 2026-07-22
2026-07-21 · PPI-1147 · iOS 게스트 세션
결론
영상 파일·디코드·재생은 정상이었다. 무음의 원인은 loopback 성립 뒤 실제 출력자가 원본 video가 아닌 내부 Audio 엘리먼트로 바뀌는데도, 출력 장치 설정은 원본 video에만 적용하던 구조였다. 또한
setSinkId()는 사용자 제스처가 필요한 API인데 React effect에서 호출되어 거절될 수 있었다.
관측 근거
| 로그/상태 | 판정 |
|---|---|
muted:false, volume:1, audioTrackCount:1, readyState:4, 재생 시간 전진 | 영상·오디오 트랙의 로드와 디코드는 정상 |
video setSinkId failed: DOMException: A user gesture is required | 선택 출력 장치 지정이 React effect 시점에서 거절됨 |
Video audio loopback established | 실제 출력 경로가 loopback-owned HTMLAudioElement로 전환됨 |
수정 전 경로와 문제
video→AudioContext→WebRTC loopback→내부 Audio.srcObject→스피커
video.setSinkId(selectedDevice)✕실제 출력자는 이미 내부 Audio이며, effect 호출은 사용자 제스처 권한도 잃을 수 있음
loopback이 성공하면 에코 방지를 위해 AudioContext.destination 직접 출력은 끊긴다. 따라서 원본 video의 sink 설정은 실제 소리의 목적지를 바꾸지 못한다.
수정 사항
- 실제 출력자에 sink 적용 —
VideoAudioLoopback이 소유한 내부HTMLAudioElement에 선택된audioOutputDeviceId를 적용한다. - 사용자 제스처 동기 호출 — 세션 시작 클릭의 동기 실행 구간에서, 유휴 상태라면 native
setSinkId()를 즉시 호출한다. 이미 요청이 진행 중이면 권한을 가장하지 않고 직렬 큐에 넣는다. - 기본 출력 폴백 — 선택 sink가 거절되면
setSinkId("")로 기본 출력으로 되돌리고 재생은 계속한다. - 장치 변경 반영 — controller를 폐기하지 않고 내부 Audio의 현재 sink만 갱신한다. source video가 없거나
setSinkId를 지원하지 않아도 loopback 업데이트는 독립적으로 수행한다. - 경쟁 조건 차단 — sink 요청을 직렬화·coalesce하여 오래된 요청/실패 폴백이 최신 장치를 덮어쓰지 못하게 한다. 비동기 sink 대기 중 dispose·연결 손실이면 stale 재생을 막고, 필요한 경우 협상을 재시도한다.
수정 후 보장
| 상황 | 동작 |
|---|---|
| 시작 버튼 클릭, sink 요청 유휴 | 내부 Audio의 native setSinkId를 같은 사용자 제스처 호출 스택에서 실행 |
| 선택 장치 거절 | 기본 출력("")으로 폴백하고 loopback 재생 유지 |
| A 요청 대기 중 B 장치 선택 | native 호출을 직렬화해 B 완료 뒤 A가 물리적으로 되돌리는 것을 방지 |
| sink 대기 중 loopback 연결 손실/정리 | stale continuation의 재생을 차단하고, dispose가 아니면 협상 종료 뒤 재시도 |
회귀 방지 검증
- 사용자 제스처 경로에서 native sink 호출이 await 이전에 관측되는지
- loopback 준비 시 선택 sink 재적용 및 거절 시 기본 출력 폴백
- 활성 재생 중 장치 변경·재연결이 내부 Audio에 반영되는지
- 오래된 성공/실패 요청이 최신 sink를 덮지 않는지
- deferred sink 중 dispose·연결 손실에서 stale 재생 또는 재시도 누수가 없는지
pnpm --dir apps/web exec tsx shared/lib/video-audio-loopback.test.ts로 위 경로를 포함한 loopback 회귀 테스트를 검증했다.
별도 확인 사항
웹 전체 TypeScript 검사는 이번 수정과 무관하게 생성된 .next/types/validator.ts가 존재하지 않는 app/api/resources/[id]/duplicate/route.js를 참조해 실패한다. 이는 본 변경의 타입 오류가 아니라 기존 생성 산출물 정합성 문제로 분리해 처리한다.
관련 코드: apps/web/shared/lib/video-audio-loopback.ts · apps/web/shared/ui/meet-video.tsx