PPI-1192 — iPad LiveKit AI 발화 무음과 setSinkId 사용자 제스처 수정
마지막 업데이트 2026-09-12
2026-08-12 · 브랜치 PPI-1192 · iPad 게스트 AI 세션
결론
세션은 AI 오디오 트랙을 정상 구독했고 재생 엘리먼트의 시간도 전진했지만, 실제 스피커 출력만 들리지 않았다. 코드에서 발견된 결함은 LiveKit
TrackSubscribed 비동기 콜백이 선택 sink를 다시 적용한다는 점이다. iOS/WebKit에서 setSinkId()는 사용자 제스처 제약을 받을 수 있으므로, 재구독 때의 재적용은 출력 경로를 불안정하게 만든다. 수정은 sink 선택을 준비/탭 제스처의 동기 호출 스택으로 제한하고, 구독 콜백은 준비된 엘리먼트에 트랙을 붙여 재생만 하도록 역할을 분리했다.
확정 범위: 코드 결함과 회귀 방지 테스트는 확인했다. 원 세션과 동일한 iPad 실기기에서 발화가 실제로 들리는지는 배포 후 검증이 남아 있으므로, 실기기 확인 전에는 사건의 물리 출력 복구를 확정으로 표현하지 않는다.
사용자 증상과 범위
| 관측 | 판정 |
|---|---|
| 인트로 영상 오디오는 들림 | 기기 스피커 전체, 시스템 볼륨, 모든 미디어 재생이 막힌 상태는 아님 |
| AI 활동 전환 뒤 AI 발화만 무음, 재접속 후에도 동일 | 활동 전환 이후 LiveKit 원격 오디오 출력 경로를 우선 조사 |
| 아동 마이크 입력은 진행자에게 정상 전달 | 게스트 마이크 입력과 mediasoup relay는 정상이며 이번 수정 범위에서 제외 |
Agent 오디오 트랙 구독 성공, 엘리먼트 currentTime 전진, muted:false, volume:1 | 수신·attach·decode·재생 시계보다 마지막 sink/render 단계가 유력 |
수정 전 실행 경로
준비 버튼 제스처→warmUpAudio→audio.setSinkId(selected)
LiveKit TrackSubscribed→audio.setSinkId(selected) 재호출→audio.play()
첫 호출이 사용자 제스처에서 시작됐더라도, 실제 트랙 구독은 네트워크 이벤트 뒤에 발생한다. 따라서 두 번째 setSinkId()는 제스처 호출 스택 밖이다. 동일한 "default" 값이라도 native API를 다시 호출한다는 사실이 문제이며, 선택값 비교 없이 재적용하던 구조였다.
구현한 설계
- 제스처 전용 sink helper —
apps/web/entities/guest-session/lib/ai-audio-output-sink.ts가 적용 중·적용 완료 sink와 요청 버전을 관리한다. - 동기 native 호출 —
warmUpAudio()와pointerdown/touchend핸들러가 비동기 경계 전에setSinkId()를 시작한다. - 구독 책임 축소 —
apps/web/lib/voice-agent/livekit-client-session.ts의TrackSubscribed는 트랙 attach, 재생 플래그,play(), 진단만 수행한다. - 장치 변경 처리 —
devicechange에서는 적용 상태만 무효화하고 native sink를 호출하지 않는다. 다음 사용자 제스처가 현재 선택 sink를 재적용한다. - 비동기 경합 방어 — 오래된 요청 완료가 최신 선택을 덮을 가능성이 생기면 적용 상태를 미확정으로 표시해 다음 제스처에서 재시도한다. 실패도 다음 제스처에서 재시도 가능하다.
- 공유 AudioContext 보호 — 최신 장치 ID는 ref로 읽고 제스처 리스너 effect는 마운트 동안 고정한다. 장치 선택 변경만으로 TTS/영상 loopback 공유 context가 닫히지 않는다.
수정 후 동작
| 상황 | 수정 후 |
|---|---|
| AI 오디오 준비 제스처 | 선택 sink의 native 호출을 즉시 시작하고 동일 요청은 중복 생략 |
Agent audio TrackSubscribed | sink를 변경하지 않고 준비된 Audio 엘리먼트에 attach 후 재생 |
| sink 적용 실패 | 브라우저 기본 출력 동작을 유지하고 다음 제스처에서 재시도 |
| 헤드셋 연결·해제 | 상태만 무효화하며 제스처 밖 setSinkId() 호출 없음 |
| A 요청 중 B 선택 또는 오래된 완료 역전 | 버전 검증으로 stale 상태 확정을 막고 필요 시 다음 제스처에서 재적용 |
검증 근거
pnpm --dir apps/web exec vitest run entities/guest-session/lib/ai-audio-output-sink.test.ts lib/voice-agent/livekit-client-session-diagnostics.test.ts— 3 files, 29 tests 통과pnpm --dir apps/web exec tsc --noEmit --pretty false— 통과../../node_modules/.bin/tsx lib/voice-agent/livekit-client-session.test.ts— 기존 LiveKit 세션 테스트 통과- 수정 파일 Prettier 검사와
git diff --check— 통과 - 독립 코드 재리뷰 — Critical/Important 잔여 이슈 없음
회귀 테스트는 sink native 호출이 제스처 함수 호출 중 동기 시작되는지, 동일 pending/applied 요청 중복 방지, 실패 재시도, Audio 엘리먼트 reset, 장치 선택 경합, stale 완료, 상태 무효화 후 재적용, LiveKit 구독 시 setSinkId() 미호출을 검증한다.
남은 검증과 운영 체크
- 원 증상과 같은 iPad에서 인트로 영상 → AI 활동 전환 → 첫 발화 청취를 확인한다.
- AI 활동 재진입과 재접속 뒤에도 첫 발화가 들리는지 확인한다.
- 헤드셋 연결/해제 뒤 화면 탭 한 번으로 선택 출력이 복구되는지 확인한다.
- 영상 loopback 오디오와 아동 마이크 → 진행자 relay가 기존과 동일한지 확인한다.
AI audio sink selection failed가 남으면errorName과 장치 ID를 기준으로 브라우저 정책/장치 소실을 분리한다.
관련 문서
애플리케이션 변경: apps/web/entities/guest-session/model/use-ai-session.ts · apps/web/entities/guest-session/lib/ai-audio-output-sink.ts · apps/web/lib/voice-agent/livekit-client-session.ts