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로 전환됨

수정 전 경로와 문제

videoAudioContextWebRTC loopback내부 Audio.srcObject스피커
video.setSinkId(selectedDevice)실제 출력자는 이미 내부 Audio이며, effect 호출은 사용자 제스처 권한도 잃을 수 있음

loopback이 성공하면 에코 방지를 위해 AudioContext.destination 직접 출력은 끊긴다. 따라서 원본 video의 sink 설정은 실제 소리의 목적지를 바꾸지 못한다.

수정 사항

  1. 실제 출력자에 sink 적용VideoAudioLoopback이 소유한 내부 HTMLAudioElement에 선택된 audioOutputDeviceId를 적용한다.
  2. 사용자 제스처 동기 호출 — 세션 시작 클릭의 동기 실행 구간에서, 유휴 상태라면 native setSinkId()를 즉시 호출한다. 이미 요청이 진행 중이면 권한을 가장하지 않고 직렬 큐에 넣는다.
  3. 기본 출력 폴백 — 선택 sink가 거절되면 setSinkId("")로 기본 출력으로 되돌리고 재생은 계속한다.
  4. 장치 변경 반영 — controller를 폐기하지 않고 내부 Audio의 현재 sink만 갱신한다. source video가 없거나 setSinkId를 지원하지 않아도 loopback 업데이트는 독립적으로 수행한다.
  5. 경쟁 조건 차단 — sink 요청을 직렬화·coalesce하여 오래된 요청/실패 폴백이 최신 장치를 덮어쓰지 못하게 한다. 비동기 sink 대기 중 dispose·연결 손실이면 stale 재생을 막고, 필요한 경우 협상을 재시도한다.

수정 후 보장

상황동작
시작 버튼 클릭, sink 요청 유휴내부 Audio의 native setSinkId를 같은 사용자 제스처 호출 스택에서 실행
선택 장치 거절기본 출력("")으로 폴백하고 loopback 재생 유지
A 요청 대기 중 B 장치 선택native 호출을 직렬화해 B 완료 뒤 A가 물리적으로 되돌리는 것을 방지
sink 대기 중 loopback 연결 손실/정리stale continuation의 재생을 차단하고, dispose가 아니면 협상 종료 뒤 재시도

회귀 방지 검증

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