게스트(아동) 수업 중 USB 헤드셋을 재연결(뽑았다 꽂기)하거나
블루투스 헤드셋이 자동 재연결되면, 이미 setSinkId()로 바인딩된
오디오 엘리먼트가 이전 deviceId를 그대로 가리키게 됩니다.
OS가 재연결 시 동일한 기기에 새로운 deviceId를 부여할 수 있기 때문에
핑퐁이 AI 음성과 활동 영상의 오디오가 무음으로 전환됩니다.
use-ai-session.ts의 audioElementRefmeet-video.tsx의 videoRef
navigator.mediaDevices가 존재하지 않거나 addEventListener를 지원하지 않는
환경(일부 모바일 브라우저, 보안 컨텍스트 외 iframe 등)에서
navigator.mediaDevices.addEventListener("devicechange", …) 호출 시 TypeError가 발생했음.
setSinkId(deviceId)는 호출 시점의 deviceId를 오디오 엘리먼트에 고정합니다.
USB/블루투스 헤드셋이 재연결되면 OS가 새 deviceId를 생성하거나 기존 ID를 무효화할 수 있습니다.
브라우저는 바인딩된 ID가 사라져도 자동으로 setSinkId("")(기본 출력)로 fallback하지 않습니다.
Web Audio Output Devices API의 navigator.mediaDevices는 오디오/비디오 기기의
추가·제거·변경 시 devicechange 이벤트를 발화합니다.
이 이벤트를 구독하면 재연결을 감지하고 setSinkId()를 재호출해 복구할 수 있습니다.
| 시나리오 | enumerateDevices 결과 | 처리 |
|---|---|---|
| 헤드셋 재연결 (동일 deviceId 복구) | 원래 deviceId 목록에 존재 | setSinkId(deviceId) 재호출 |
| 헤드셋 재연결 (새 deviceId 부여) | 원래 deviceId 목록에 없음 | setSinkId("") — 기본 출력으로 fallback |
| 헤드셋 분리 | 원래 deviceId 목록에 없음 | setSinkId("") — 기본 출력으로 fallback |
""(빈 문자열)로 설정하는 이유: "default"는 Firefox에서
NotFoundError를 유발함. 빈 문자열은 브라우저 기본 출력 기기를 의미하며 모든 주요 브라우저에서 안전함.
두 가지 변경이 이루어졌습니다.
① startSession 시 사전 생성 오디오 엘리먼트에도 setSinkId 적용
기존에는 new Audio()로 새 엘리먼트를 생성할 때만 setSinkId를 호출했음.
이미 audioElementRef.current에 엘리먼트가 존재하는 경우(세션 재시작)에는
setSinkId가 호출되지 않았음.
// 변경 전: new Audio() 생성 블록 안에서만 setSinkId 호출 if (!audioEl) { audioEl = new Audio(); audioEl.autoplay = true; if (audioOutputDeviceId && typeof audioEl.setSinkId === "function") { await audioEl.setSinkId(audioOutputDeviceId); } audioElementRef.current = audioEl; } // 변경 후: 사전 생성 여부와 무관하게 최신 출력 기기 적용 if (!audioEl) { audioEl = new Audio(); audioEl.autoplay = true; audioElementRef.current = audioEl; } if (audioOutputDeviceId && typeof audioEl.setSinkId === "function") { await audioEl.setSinkId(audioOutputDeviceId); }
② devicechange 리스너 추가
useEffect(() => { if (!audioOutputDeviceId) return; if (!navigator.mediaDevices?.addEventListener) return; // 미지원 환경 가드 const handleDeviceChange = async () => { const audioEl = audioElementRef.current; if (!audioEl || typeof audioEl.setSinkId !== "function") return; const devices = await navigator.mediaDevices.enumerateDevices().catch(() => []); const isAvailable = devices.some( (d) => d.kind === "audiooutput" && d.deviceId === audioOutputDeviceId, ); audioEl.setSinkId(isAvailable ? audioOutputDeviceId : "").catch(() => {}); }; navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); return () => navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); }, [audioOutputDeviceId]);
audioOutputDeviceId prop을 새로 추가하고, 두 가지 effect를 연결했습니다.
① videoUrl 변경 시 setSinkId 적용
활동 스텝이 바뀌어 영상 URL이 교체되면 새 video 엘리먼트에 즉시 출력 기기를 바인딩합니다.
useEffect(() => { const video = videoRef.current; if (!video || !props.audioOutputDeviceId) return; if (typeof video.setSinkId !== "function") return; video.setSinkId(props.audioOutputDeviceId).catch((e) => { logger.warn("video setSinkId failed", { error: e, deviceId: props.audioOutputDeviceId }); }); }, [props.audioOutputDeviceId, videoUrl]);
② devicechange 리스너 추가
use-ai-session.ts와 동일한 패턴으로 헤드셋 재연결 시 video 엘리먼트에도 재라우팅합니다.
useEffect(() => { const deviceId = props.audioOutputDeviceId; if (!deviceId) return; if (!navigator.mediaDevices?.addEventListener) return; const handleDeviceChange = async () => { const video = videoRef.current; if (!video || typeof video.setSinkId !== "function") return; const devices = await navigator.mediaDevices.enumerateDevices().catch(() => []); const isAvailable = devices.some( (d) => d.kind === "audiooutput" && d.deviceId === deviceId, ); video.setSinkId(isAvailable ? deviceId : "").catch(() => {}); }; navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); return () => navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); }, [props.audioOutputDeviceId]);
audioOutputDeviceId를 useGuestPageSession → GuestLayoutContent → MeetVideo
prop chain으로 전달. 영상 URL 변경 시 setSinkId() 호출.
setSinkId()가 시스템 볼륨 슬라이더를 우회하는 문제로 앱 내 볼륨 슬라이더 도입.
use-guest-page-session에 audioVolume 상태 추가, guest-layout.tsx에 슬라이더 UI 구현.
볼륨 슬라이더 구현 전체 롤백. 대신 devicechange 리스너를 직접 추가해
헤드셋 재연결 시 setSinkId()를 재호출하여 무음 문제 해결.
setSinkId fallback을 "default" → ""로 변경(Firefox 대응).
navigator.mediaDevices?.addEventListener optional chaining 가드 추가.
일부 모바일 브라우저·비보안 컨텍스트에서 발생하던 TypeError 방지.
setSinkId-시스템볼륨-우회-분석.html 문서 참조.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 헤드셋 재연결 후 AI 음성 | 무음 (setSinkId 재호출 없음) | devicechange 감지 후 자동 복구 |
| 헤드셋 재연결 후 활동 영상 오디오 | 무음 | devicechange 감지 후 자동 복구 |
| 헤드셋 분리 후 오디오 | 무음 또는 오류 | 기본 출력으로 자동 fallback |
| mediaDevices 미지원 환경 | TypeError 발생 | optional chaining 가드로 무시 |
| setSinkId fallback 값 | "default" (Firefox NotFoundError) |
"" (모든 브라우저 안전) |
| 세션 재시작 시 사전 생성 오디오 엘리먼트 | setSinkId 미적용 | 조건 외부로 이동하여 항상 적용 |
setSinkId-시스템볼륨-우회-분석.html 참조.
| 파일 | 변경 내용 |
|---|---|
entities/guest-session/model/use-ai-session.ts |
startSession 내 setSinkId 위치 조정 + devicechange 리스너 useEffect 추가 |
shared/ui/meet-video.tsx |
audioOutputDeviceId prop 추가 + videoUrl 변경 시 setSinkId effect + devicechange 리스너 추가 |
widgets/guest/guest-layout/ui/guest-layout.tsx |
볼륨 슬라이더 UI 추가 후 롤백 (순 변경 없음) |
shared/ui/guest-layout-content.tsx |
audioOutputDeviceId prop 전달 추가 후 볼륨 관련 롤백 (순 변경: prop 전달만 유지) |
features/guest-page-session/model/use-guest-page-session.ts |
볼륨 슬라이더용 audioVolume 상태 추가 후 롤백 (순 변경 없음) |