마지막 업데이트 2026-07-22
MediaStreamTrack.clone()으로 만든 mic 트랙이 확률적으로
live 상태인데 무음(zero-audio)이 되어, AI 대화·전사는 정상인데 진행자(모니터)에게만
아동 목소리가 전달되지 않는 문제의 종합 문서. ① 현재 상황 ② 무음 발생 원인(2026-07-09 박서준 로그 대조
확진 + WebKit 공개 이슈 대조) ③ 근본 해결방안(적용된 자가회복 가드 + clone 축소 실험 + clone 제거)을 정리한다.
| 증상 | 아동 mic-audio가 진행자 측에 전달 안 됨. AI(핑퐁이) 대화·전사는 정상 — 이 비대칭이 시그니처 |
| 발생 조건 | iOS/iPadOS WebKit 게스트에서 mic producer용 clone 트랙 생성 시(수업 입장·AI 세션 시작·활동 전환마다) 확률적으로 발생. 관측 사례는 전부 iPad — 7/6 홍서진(CriOS, iOS는 모든 브라우저가 WebKit 엔진 강제), 7/9 박서준(iPadOS 18.7.9) |
| 현장 영향 | 진행자가 모니터 새로고침으로는 복구 불가 → 강제 kick(아동 재입장)만 회복. 박서준 세션에서 새로고침 2회 + kick 3회로 수업 흐름 반복 중단 |
| 진단 수단 | 게스트 송신측 [MEDIASOUP_PRODUCER] Audio producer outbound stats + 진행자 수신측 [MONITOR_CONSUMER] energy 측정 (7/8 로깅 범위 확장으로 확보) |
| 대응 상태 | 자가회복 가드 구현 완료 — PPI-1121 브랜치 커밋 60a8195c, 판정 로직 테스트 14/14 통과, dev 배포 후 iPad 실기기 검증 대기. 근본 대응(clone 축소/제거)은 후속 단계 |
getUserMedia (iPad 마이크)
→ AudioContext #1 (입력 게인)
→ AudioContext #2 (Gate→EQ→Compressor→Limiter) MediaStreamDestination = processedStream
├─ 원본 트랙 → OpenAI pc.addTrack (track.enabled로 mute 제어) ← 정상이었던 쪽
└─ clone #1 (mic-relay-stream.ts, 녹음·SFU 공용 — OpenAI mute 격리 목적)
└─ clone #2 (use-mediasoup-producer.ts:628 — producer 수명 독립 목적)
→ SFU mic-audio producer ← 무음이 난 쪽
같은 원본에서 갈라진 두 갈래 중 원본(OpenAI 입력)은 정상 동작하고 clone 갈래만 무음이었다.
즉 마이크·AudioContext·처리체인은 무죄이고, clone 지점에서 갈라진 트랙이 무음으로 태어난다.
아무 이벤트·예외 없이(readyState: live, muted: false) 확률적으로 발생하고,
같은 세션·같은 코드에서 어떤 clone은 정상, 어떤 clone은 무음이므로 애플리케이션 로직이 아니라
브라우저 엔진 레이어의 문제로 판단한다.
07:30:12 [아동] 대기용 mic producer #1 생성 (clone, 정상 — outbound energy 3e-06) 07:30:24 [아동] AI 세션 시작 → mic 스트림 교체(오디오 처리체인 적용) 07:30:26 [아동] mic producer #2 (e42afe5c) 재발행 — 이 clone이 무음으로 태어남 ★근본 원인 07:30:30~45 [아동] 실제 발화 "안녕"/"응" → AI 전사 정상 (원본 트랙은 살아있음) 07:30:34 [아동] 송신측 outbound stats: 발화 중인데 audioEnergyDelta=0 ★송신측 무음 확진 07:30:30 [진행자] consumer 무음 감지 WARN #1 (energy 3e-08) 07:31:28 [진행자] 안 들려서 모니터 새로고침 → 같은 죽은 producer를 재소비 07:31:35 [진행자] 무음 WARN (신고에 인용된 로그) — 새로고침 무효 확인 07:32:09 [진행자] kick #1 → 아동 재입장, 새 producer (b2b9caff) 07:32:49 [아동] 새 producer outbound energy 0.121 = 소리 회복 ★kick으로 회복 07:34~40 활동 전환마다 producer 재-clone (4회) — 무음 WARN 반복되나 전부 무발화 구간이라 판정 불가 07:43:30 [진행자] kick #2 → 07:44:06 진행자 수신 energy 0.047 = 정상 수신 확인 07:47:08 [진행자] 새로고침, 07:51:47 kick #3 → 간헐 재발 의심 (단발 체크라 미확정)
| 시각(UTC) | 측정 지점 | energy delta | 판정 |
|---|---|---|---|
| 07:30:34 | 아동 송신 e42afe5c (AI 세션 clone) — 전사로 발화 증명된 창 | 0 | 무음 확진 |
| 07:31:35 | 진행자 수신 c6b0c5c1 (새로고침 후 재소비) | 0 | 무음 (신고 로그) |
| 07:32:49 | 아동 송신 b2b9caff (kick #1 후 새 clone) | 0.121 | 정상 |
| 07:44:06 | 진행자 수신 b307c329 (kick #2 후) | 0.047 | 정상 |
핵심 증거: 아동 발화가 OpenAI VAD(Child speech started)와 전사("안녕.", "응.")로
증명되는 창에서 송신측 energy가 0 — 패킷은 나가는데(49pkt/1.7KB ≈ Opus DTX 무음 프레임)
내용물이 디지털 무음이다. 네트워크·SFU·진행자 수신 문제가 아니라 producer에 물린 트랙 자체가 무음이며,
같은 기기·같은 네트워크에서 kick 후 새 clone만 정상인 대조군까지 확보됐다.
가까운 공개 이슈는 존재하지만, 우리 증상과 1:1로 같은 확정 버그로 못 박기는 어렵다.
NotReadableError: The I/O read operation failed /
Failed to create MediaStream audio source 오류 발생.
→ iOS WebKit에서 audio MediaStreamTrack clone의 lifecycle/소스 처리 flaky가 공개적으로 확인된 사례.trackReadyState: live, trackMuted: false,
bytesReceivedDelta > 0, audioEnergyDelta: 0 —
clone 생성이 실패하거나 에러를 내는 게 아니라 live clone track이 zero-audio를 송신/중계하는 상태라
Bug 228255의 lifecycle 실패와 동일 건은 아니다.현재 판단:
outbound stats와 아동 발화 시각
(Child speech started / User STT)을 겹쳐서 — 발화 중인데 energy=0이면 clone 무음 확진.MONITOR_CONSUMER suspected silent는 consumer 생성 시
1회만 5초 샘플링. iPad 노이즈 억제로 무발화 구간은 정상이어도 energy≈0이라 이 WARN 단독으론 확정 불가.Audio producer outbound stalled(bytesSent=0)은 ai-audio의 RTP 송신 자체 정지로
Windows Edge에서도 발생하는 별개 이슈. 이번 건은 패킷은 나가고 내용물만 무음.발생 지점(clone)을 기준으로 ① 발생해도 자동 회복(적용 완료) → ② clone 단계 축소 실험 → ③ clone 제거(근본)의 3단계로 접근한다. ①로 현장 영향을 즉시 제거하고 발생률 데이터를 확보한 뒤 ②·③으로 발생 자체를 없애는 순서.
확진 판별식을 런타임 가드로 옮겼다: 발화(OpenAI VAD)가 확인된 측정 창에서 송신 energy 0 → 죽은 clone 확정 → producer 재발행(재-clone = 새 주사위). kick의 회복 효과를 앱 안에서 자동화한 것. 무발화 구간은 판정 보류라 오탐이 없다.
hooks/mediasoup/mic-silent-guard.ts: 순수 판정 로직 (silent-during-speech / audio-flowing / indeterminate 3분류, 테스트 14/14)use-mediasoup-producer.ts: 생성 직후 5초 주기 가드, 무음 확정 시 재발행 최대 2회, 초과 시 모니터에 마이크 오류 알림, 회복 시 "마이크 정상 활성화" 알림use-guest-page-session.ts: VAD speech_started/stopped로 발화 시각 추적 → 가드에 전달micGuard=off(킬스위치), micGuardIntervalMs, micGuardEpsilon, micGuardOverlapMs, micGuardMaxRecreates (URL 파라미터/localStorage, 적용값 로그 표시)Mic silent guard started ← 세션 시작 마커 (튜너블 적용값 포함) Silent mic clone detected during child speech, recreating producer ← 무음 감지·재발행 Mic producer audio confirmed after recreate ← 회복 성공 시그니처 Silent mic clone persists after max recreates ← 2회 실패 → 모니터 오류 알림
한계: 발생 자체를 막지는 못하고, 첫 발화 후 최대 ~10초의 무음 구간이 남는다(측정 2틱 필요).
현재 clone이 두 번이다: processedAudioStream track → relay clone(#1) → producer clone(#2).
iOS WebKit 회피 관점에서 두 번째 producer clone을 제거하고 relay 트랙을 producer에 직접 물리는 것
(produce({ track, stopTracks: false }) + cleanup 수정으로 producer close가 relay 트랙을 stop하지 않게)은
합리적인 실험이다. 특히 무음 로그가 iOS에만 집중된다면 우선순위가 높다.
소스가 이미 WebAudio 그래프이므로, destination 트랙을 clone하는 대신
처리체인 outputGain 뒤에 소비자별 MediaStreamDestination 3개(OpenAI / SFU / 녹음)를 두면
각 destination이 그래프에서 독립 네이티브 트랙을 직접 생성해 clone() 연산이 아예 사라진다 — 문제 발생 지점 제거.
outputGain.connect(dest2) 추가 수준이나, use-audio-processing-chain이
단일 스트림 반환 API라 분기 노출 구조 변경 필요