아동070 1회기 — 아동 소리 안 들림 원인 분석 원인 규명 전달 무결 확정 · 무음 계측 부재

마지막 업데이트 2026-07-22

📅 발생 2026-06-18 ~19:45 KST 🏷️ roomId id-036…596d2_1 (V2 SFU · oneOnOne · 텍스팅 수업) 👤 아동070 1회기

증상 (인테이크)

아동 세션 접속 후 진행자 측에 아동 목소리가 전달되지 않음. 진행자 화면에 아동 웹캠 영상은 정상 표시되고, 아동 비디오 섹션에 말풍선(speaking) 애니메이션도 뜨는데 소리만 안 들림.

자료: 아동/진행자 LogRocket 세션 export(ppi-prod) + 진행자 녹화(.mov) + Grafana Loki(ppi-socket) 서버 로그 조회 완료 + 코드 추적.

결론 (요약)

전달 경로는 전부 무결하다 — 서버 Loki로 producer/consumer/transport 모두 정상 확인. 그리고 코드상 게스트 mic-audio relay는 텍스팅·STT OFF·듣기 OFF·끼어들기 OFF 와 무관하게 무조건 출력되도록 설계돼 있다. 따라서 남은 원인은 둘 중 하나: (A) 아동 단말 마이크 입력이 사실상 무음(낮은 입력이 노이즈 게이트 -55dBFS 아래로 떨어져 게이트가 닫힘 — 유력) 또는 (B) 진행자 포커스뷰가 도달한 오디오를 재생하지 않음(isLessonStarted 게이트).
핵심 한계: 실제 음성 유무(무음 여부)를 사후 확정할 audioLevel 계측이 게스트·서버·호스트 어디에도 없다 → 이 세션에 한해 (A)/(B) 최종 판별 불가. 정황상 (A) 소스 무음에 무게.

전제 정리 — 텍스팅 수업

이 수업은 텍스팅(비음성) 수업이며 로그상 isVoiceUser=false로 정합한다 (게스트 External STT updated {enabled:false}, isMicrophoneEnabled:false 내내, 서버의 입장 시 host-toggle-microphone ON 푸시 없음 — apps/socket/.../room-handlers.ts:229의 voice-lesson 자동 enable 미발동).

따라서 아동 마이크→핑퐁이(OpenAI) 경로가 차단된 것은 정상이다(텍스팅 수업이므로). 그러나 진행자 모니터링용 mic-audio relay는 별개 경로이므로, 아동의 실제 음성은 진행자에게 전달되어야 한다. 본 분석의 초점은 이 모니터링 relay 경로다.

① 모니터링 relay 전송 경로 — 전부 정상

단계근거상태
아동 마이크 캡처getUserMedia OK · audio track 1 · 처리체인 적용 · 오디오 에러 0건 · ERROR 로그 0건정상
relay 트랙 생성clonedTrack.enabled = true 강제 — 마이크 토글과 독립 use-ai-session.ts:1798-1804정상
mic-audio producerrelay 트랙을 또 clone해 produce, isMicrophoneEnabled 기반 pause/음소거 로직 없음 use-mediasoup-producer.ts:468-541정상
진행자 consume34/34 producer 매칭 · Consume 응답 37건 전부 success:true · 37건 resumed · 실패/에러/orphan 0정상
서버(Loki) 교차확인ppi-socket 로그: Producer created [kind:audio, peer:guest…] + 호스트 Consumer created 전부 매칭 · pause/error/fail 0건 · 게스트 transport connected, RTT 15~25ms(스파이크는 재접속 순간만)정상

Producer ↔ Consumer 관계 점검 (클라이언트 + 서버 Loki 교차, 재입장 10회 누적):

  • 게스트 생성 producer 34개 = Mic audio 15 · Camera video 11 · AI audio 8 (사이클마다 재생성)
  • 진행자 consume distinct producerId 34개 — orphan(미consume) 0개, Consume 37건 전부 success + resumed
  • 서버 Loki도 동일 확인: 모든 audio producer 생성·호스트 consume·transport connected, pause/error/fail 0건. (단 서버는 audioLevel/RTP byte를 로그로 안 남김 → 음성 "내용"은 서버 로그로도 확인 불가)

→ 마이크 토글(isMicrophoneEnabled="듣기")은 OpenAI PC 트랙만 게이팅하며(use-ai-session.ts:1813-1817), 모니터링 relay producer와는 무관. 전달 인프라(시그널링·중계·transport)는 양쪽 + 서버 모두 무결.

① -보강: mic-audio relay는 "무조건 출력" 설계

텍스팅·STT OFF·듣기 OFF·끼어들기 OFF 어떤 조합에서도 게스트 mic-audio producer는 출력되도록 코드상 보장된다.

// use-guest-page-session.ts:1171 — producer 입력은 무조건 폴백 (조건 없음)
useMediasoupProducer({ ..., micStream: aiSession.guestMicStream ?? localMedia.audioStream });

// use-mediasoup-producer.ts:468 — 게이트는 isReady·transport·micStream 뿐 (STT/voice/토글 게이트 없음)
if (!isReady || !sendTransportRef.current || !micStream) return;

// use-ai-session.ts:1798-1807 — relay 클론은 enabled=true 강제, toggleMicrophone이 절대 안 건드림
relayTracks = processed.getAudioTracks().map(t => { const c = t.clone(); c.enabled = true; return c; });
즉 "텍스팅이라 마이크가 라우팅 안 됐다"는 설계상 갭이 아니다. 출력은 설계상 보장 → 문제는 "출력" 설계가 아니라 소스 내용(A) 또는 호스트 재생(B).

① -보강2: 노이즈 게이트 — 낮은 마이크 입력은 무음으로 차단 ⚠️

마이크 처리체인은 Gate → EQ → Compressor → Limiter 순이고, 호스트 relay는 이 게이트 통과 후 스트림에서 clone된다(AI 대화 구간). 게이트 설정(use-audio-processing-chain.ts:19-24, 114-133):

GATE_OPEN_THRESHOLD_DB  = -40   // 입력 RMS > -40dBFS → 게이트 열림
GATE_CLOSE_THRESHOLD_DB = -55   // 입력 RMS < -55dBFS → 게이트 닫힘
GATE_NOISE_FLOOR_GAIN   = 0.001 // 닫히면 출력 게인 0.001 ≈ 무음(-60dB)

→ 아동 마이크 입력이 -55dBFS 미만이면(볼륨 낮춤 / 마이크가 멀어 RMS 낮음 / 온보드 default 마이크) 게이트가 닫혀 relay가 무음이 된다. 게인 노드도 1.0이라 게이트 전에 끌어올려 보정하지 않음.

※ 인트로 영상 구간은 raw localMedia.audioStream(게이트 없음) 사용 → 그땐 작게라도 들림. AI 대화 구간만 게이트 통과 relay.

이것이 후보 (A) 소스 무음의 구체 메커니즘. "볼륨만 줄여도 작게는 들린다"는 일반론과 달리, PPI는 게이트가 있어 충분히 낮으면 완전 무음이 된다.

② 후보 (B) — 호스트 재생 게이트 isLessonStarted

포커스뷰(oneOnOne, 진행자가 있던 화면)에서 consume한 오디오를 실제 <audio>로 재생하는 건 isLessonStarted("수업 시작" 클릭)에 게이팅된다. 영상은 이 게이트가 없어 무조건 표시된다 — "영상은 나오는데 소리만 안 남"의 한 가지 가능 구조.

// monitor-dashboard/[group]/[roomId]/page.tsx:220, 723-731
const [isLessonStarted, setIsLessonStarted] = useState(false);  // 기본 false
useAudioStreams({
  micAudioRef, aiAudioRef,
  audioStream: monitorSession.guestStream?.audioStream,  // 아동 마이크
  enabled: isLessonStarted,  // ← 핵심 게이트
});

// use-audio-streams.ts:72 — enabled=false면 srcObject 연결조차 안 함
if (!enabled || !audioStream || !micAudioRef.current) return;

// 반면 영상(page.tsx:742-744)은 isLessonStarted 무관 — 무조건 표시
guestVideoRef.current.srcObject = monitorSession.guestStream.videoStream;
로그 정황: 호스트 AUDIO_STREAMS 로거에 "Guest disconnected, cleaning up" 11건만 찍히고 재생 시도 로그(Autoplay blocked/Failed to play)는 0건 → isLessonStarted=false로 effect 조기 return과 정합(단정 아님, 로컬 state라 로그에 직접 안 남음).

진행자 .mov 확인 결과 (프레임 판독):

  • "듣기" 토글 OFF 내내 — 단 이건 아동→AI 입력 게이트라 호스트 청취와 무관(텍스팅이라 정상). isMicrophoneEnabled host readings 8건 전부 false와 일치.
  • "끼어들기" OFF 내내 — AI 입력 바지인 방지용, relay 무관.
  • "음소거"(isMonitorMuted) OFF — 음소거 아님.
  • 진행자 기기 정상 — .mov 녹화 오디오에 연속 출력(평균 -30dB, 피크 0dB) → 스피커/출력 정상. 기기 고장 아님.
  • 아동 비디오·말풍선은 정상 표시(말풍선=진행자 타이핑 유래).

→ 음소거·기기·듣기 토글 전부 배제. 후보 (B)가 성립하려면 isLessonStarted 미충족이어야 하나, .mov로 "수업 시작" 클릭 여부 최종 확인 필요. (A) 소스 무음 가능성이 더 높음(노이즈 게이트 + default 온보드 마이크 + 게인 1.0).

오해를 부른 두 가지 (인과 반전)

1. 진행자가 본 "9~10회 끊김"은 진행자가 직접 강제퇴장한 것

통념: 아동 연결이 불안정해 끊겼다 → 틀림. 진행자 로그에 [CLIENT_HOST_SOCKET] Force kicking guest {reason:"kick"}9회 찍힘. reason="kick"은 코드상 진행자가 "강제퇴장→확인" 다이얼로그를 누를 때만 발생(use-host-socket.ts:280 기본값 + page.tsx:1929-1931). 즉 증상의 원인이 아니라, 소리가 안 들리자 진행자가 취한 오진 대응(재접속 유도). {{재접속안내}} 프리셋 4회 + 마지막 classstop 알림톡도 같은 맥락.

2. 아동 비디오 섹션 "말풍선"은 아동 음성이 아니라 진행자 타이핑으로 뜬 것

말풍선(GUEST_SPEAKING_START)은 isUserTalking으로 구동되는데, 소스가 둘이다 (use-guest-page-session.ts:1220-1233): ① 진행자가 채팅 textarea 타이핑 → 게스트가 notifySpeakingStart emit, ② 아동 실제 VAD. 텍스팅 수업이라 ②(OpenAI VAD)는 못 뜬다(mic→AI 차단). 실측: 모든 GUEST_SPEAKING_START/STOP"Host textarea typing"과 같은 밀리초에 1:1로 발생, 아동 speech_started 이벤트 0건. → 말풍선은 진행자 타이핑 표시였고, 아동이 실제로 말한 기록은 로그에 없다.

아동 단말 디바이스 (로그 기록)

구분로그 값
마이크(입력)deviceId:"default" / "기본값 - Microphone(Conexant HD Audio)" — 노트북 온보드 마이크. getUserMedia도 매번 audioDeviceId:"default"
카메라(영상)5bdce057… / "LG Camera (0bda:562e)" (Realtek 칩) — 내장/외장 불명
디바이스 개수audioInput 3 · audioOutput 3 · videoInput 1
권한audioGranted:true, videoGranted:true — 권한 정상

영상=LG Camera, 음성=default(Conexant)로 서로 다른 디바이스 엔트리인 건 사실이나, 카메라가 내장인지 외장인지는 판별 불가(VID:PID는 내장도 동일 형식, groupId·나머지 마이크 라벨 미로깅, 브라우저 API에 internal/external 플래그 없음). 따라서 "외장이라 마이크가 멀어서"는 외장일 때만 유효한 추정.

권한·캡처는 정상(audioGranted, audioTracks:1). AI가 아동 음성을 못 받은 건 권한이 아니라 텍스팅 수업의 의도적 mic→AI 차단(initialMicEnabled=false) 때문 — 권한/소스 결함의 근거가 아님.

저사양 신호는 영상 쪽(DECODE_OVERLOAD 1·dropped 2·SEEK_REBUFFER 1, 가벼움)으로만 나타났고 오디오 캡처 에러 0건. 저사양은 영상 끊김의 별개 요인이며 무음과 직접 무관.

무음 여부 — 사후 확정 불가 (계측 부재)

"아동 마이크가 실제 음성을 실었는가"를 검증할 데이터가 세 곳 모두 없다:

위치audioLevel/내용 계측
아동(게스트)relay에 audioLevel/VAD 계측 없음. OpenAI VAD(speech_started)는 입력 트랙 disabled라 못 뜸(텍스팅)
서버(ppi-socket)producer audioLevel/score/byte 미로깅 (Loki 직접 조회로 확인)
진행자(host)consume 성공만 기록, 무음 감지 로직 없음 — WARN/ERROR 41건 전부 MEET_VIDEO(영상)·DIAG-HOST(강퇴) 뿐, mic 무음 관련 0건
서버 MP3 녹음텍스팅 수업이라 AI TTS만 녹음, 아동 마이크 미포함 → 청취 검증 불가

→ "진행자가 못 들음"은 사람의 관찰일 뿐 어떤 계측도 기록하지 않았다. 이 세션에 한해 (A)/(B) 최종 판별 불가.

근거 타임라인 (UTC / KST=+9)

10:48:25Z (19:48) host monitor-dashboard 마운트, consumer/receive transport 생성, peers:[] (게스트 미입장) 11:00:05Z (20:00) host guest 최초 입장 감지(peer-joined) — host 마운트 11.5분 뒤 11:00:08Z (20:00) guest join room, send transport 11:00:17Z (20:00) guest getUserMedia OK(audioGranted), far_field 헤드셋 모드, External STT=false 11:00:20Z (20:00) guest mic audio producer 957cca3b 생성 11:00:16Z (20:00) host mic-audio consume "resumed successfully", "Added mic audio track" 11:00:23Z (20:00) host Room state synced — isMicrophoneEnabled=false (이후 8 readings 전부 false / 텍스팅 수업이라 정상) 11:00:26Z (20:00) guest 진행자 승인, 인트로 영상 재생(0/0 인트로 → 0/1 고양이브릿지) 11:03:12Z (20:03) guest AI 대화1("하랑대화1") 시작, 핑퐁이 TTS 오프닝 발화 전 구간 아동 음성 VAD/STT 0건. 말풍선은 진행자 textarea 타이핑과 1:1로 발생 11:03:27Z (20:03) host→guest "{{재접속안내}}" 프리셋 발송 11:03:33Z (20:03) host "Force kicking guest {reason:kick}" → 11:03:36 guest force kicked 11:03:33~11:13:00 host 발신 강제퇴장 9~10회 반복, 매번 아동 자동 재입장 11:15:38Z (20:15) host classstop(수업중단) 알림톡 발송, 수업 종료

roomId 매칭 근거

  • roomId: uuid-0650 (suffix _1 = 1회기, lessonIndex=1, type=oneOnOne)
  • 아동 세션: LogRocket 6-019eda5b-… — URL client-guest/session?roomId=id-036…
  • 진행자 세션: LogRocket 6-019eda58-… — URL monitor-dashboard/…/id-036…_1?type=oneOnOne
  • 매칭: 동일 roomId + 시간대 겹침(진행자 19:48~20:16 ⊃ 아동 20:00~20:13 KST) + peer payload guestPeerId 일치

미해결 · 재발 방지

현 상태: 전달 무결(서버 확정) + mic-audio 무조건 출력 설계 확정 + 호스트 기기/음소거/듣기/끼어들기 배제(.mov) + 권한 정상. 남은 (A)소스무음 / (B)호스트미재생은 audioLevel 계측 부재로 이 세션 사후 확정 불가. 정황상 (A) 소스 무음(노이즈 게이트 차단)에 무게.

재발 시 확정 / 예방

.mov 한 가지만 더 보면 (A)/(B) 확정

관련 코드 참조

관련 문서: bugs/모니터-게스트-마이크-무음-원인-분석.html · architecture/모니터-대시보드-카드뷰-포커스뷰-코드레벨-동작흐름.html