마지막 업데이트 2026-07-29
MediaStreamTrack.clone()으로 만든 mic 트랙이 확률적으로
live 상태인데 무음(zero-audio)이 되어, AI 대화·전사는 정상인데 진행자(모니터)에게만
아동 목소리가 전달되지 않는 문제의 종합 문서. ① 현재 상황 ② 무음 발생 원인(2026-07-09 박서준 로그 대조
확진 + WebKit 공개 이슈 대조) ③ 근본 해결방안(적용된 자가회복 가드 + clone 축소 실험 + clone 제거)을 정리한다.
MediaStreamTrack.clone()으로 갈라진 mic 트랙이 확률적으로
readyState: live·muted: false인데 무음(zero-audio)으로 태어난다(표준 위반 동작).
확진 근거는 아동 발화가 VAD·전사로 증명된 창에서 송신측 audioEnergyDelta=0(2026-07-09 박서준 로그 대조).
대응은 3단계 — ① 발화 중 무음 감지 시 producer 재발행하는 자가회복 가드(적용 완료, PPI-1121 60a8195c)
② producer clone 축소 실험 ③ 소비자별 MediaStreamDestination 분기로 clone 자체 제거(근본).
아동 목소리가 진행자(모니터)에게 들리지 않는다는 신고. 그런데 AI(핑퐁이) 대화와 전사는 정상이었다 — 이 비대칭이 사건의 시그니처다. 관측 사례는 전부 iPad: 7/6 홍서진(CriOS — iOS는 모든 브라우저가 WebKit 엔진 강제), 7/9 박서준(iPadOS 18.7.9). 진행자가 모니터를 새로고침해도 복구되지 않았고, 강제 kick(아동 재입장)만이 소리를 되살렸다. 박서준 세션에서는 새로고침 2회 + kick 3회로 수업 흐름이 반복 중단됐다.
| 증상 | 아동 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 축소/제거)은 후속 단계 |
7/6 홍서진 건 초동 수사 때는 소리가 "어디서" 사라지는지 잴 수단이 없었다. 7/8 로깅 범위 확장으로 게스트 송신측 outbound stats와 진행자 수신측 energy 측정이 확보됐고, 바로 다음 날 재발한 박서준 건에서 이 도구가 결정타가 된다.
마이크에서 SFU까지의 오디오 경로를 복원했다. SFU로 가는 트랙은 clone의 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은 무음이므로 애플리케이션 로직이 아니라
브라우저 엔진 레이어의 문제로 판단한다.
원본 갈래(OpenAI 입력)가 정상이라는 사실이 마이크·AudioContext·처리체인의 알리바이를 세워줬다. 남은 용의자는 clone 분기점뿐. 이제 필요한 것은 "clone 트랙이 정말 무음으로 태어났다"는 물증이다.
7/8에 심어둔 energy 로깅이 처음으로 현행범을 포착했다. 아동측과 진행자측 로그를 같은 타임라인에 겹치자 사건 전체가 재구성됐다.
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만 정상인 대조군까지 확보됐다.
엔진 레이어로 좁혀졌으니 다음 질문은 "이미 알려진 버그인가"다. WebKit 공개 이슈 트래커와 표준 문서를 뒤졌다.
가까운 공개 이슈는 존재하지만, 우리 증상과 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 연산 자체를 없앤다.
발생 지점(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라 분기 노출 구조 변경 필요