증상 (인테이크)
아동 세션 접속 후 진행자 측에 아동 목소리가 전달되지 않음. 진행자 화면에 아동 웹캠 영상은 정상 표시되고, 아동 비디오 섹션에 말풍선(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 producer | relay 트랙을 또 clone해 produce, isMicrophoneEnabled 기반 pause/음소거 로직 없음 use-mediasoup-producer.ts:468-541 | 정상 |
| 진행자 consume | 34/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; });
- "듣기"(toggleMicrophone) = 아동→AI(OpenAI) 입력 트랙만 on/off (use-ai-session.ts:969-970의
mediaStreamRef). 모니터링 relay와 무관.
- "끼어들기" OFF + AI 발화 중 → AI 입력 트랙만 추가 음소거(바지인 방지). relay 영향 없음.
- 입력 게인 노드 = 1.0(무보정).
즉 "텍스팅이라 마이크가 라우팅 안 됐다"는 설계상 갭이 아니다. 출력은 설계상 보장 → 문제는 "출력" 설계가 아니라 소스 내용(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) 소스 무음(노이즈 게이트 차단)에 무게.
재발 시 확정 / 예방
- 게스트 relay에 audioLevel(RMS·게이트
gateClosed/lastLevelDb) 주기 로깅 추가 → "소스 무음 / 게이트 닫힘"을 즉시 수치 판별 (use-audio-processing-chain.ts에 상태값 이미 존재, 로깅만 없음).
- 또는 ppi-socket에 mic-audio producer inbound-rtp audioLevel/bytesReceived 로깅.
- 입력 게인 1.0 고정 + 노이즈 게이트(-55dBFS) 조합이 저입력 마이크(온보드·원거리)에서 무음을 유발할 수 있음 → 게이트 임계/게인 자동조정 또는 입력레벨 경고 검토.
- 외장 카메라 사용 시 오디오도 동일/근접 마이크로 페어링 유도(default가 온보드로 잡힐 때 안내).
.mov 한 가지만 더 보면 (A)/(B) 확정
- 20:00:05 직후 "수업 시작" 모달 클릭 여부 → 안 눌렀으면 (B), 눌렀으면 (A)로 확정.
관련 코드 참조
- apps/web/entities/guest-page-session/model/use-guest-page-session.ts:1171 — micStream = guestMicStream ?? localMedia.audioStream (무조건 폴백, 게이트 없음)
- apps/web/hooks/mediasoup/use-mediasoup-producer.ts:468-541 — mic-audio producer (게이트: isReady·transport·micStream 뿐, STT/voice/토글 무관)
- apps/web/entities/guest-session/model/use-ai-session.ts:1788-1817 — Gate→EQ→Comp→Limiter 처리체인 / relay forced enabled=true clone / OpenAI PC 트랙만 mic토글 게이팅(1813-1817), toggleMicrophone 969-970
- apps/web/hooks/use-audio-processing-chain.ts:19-24, 114-133 — 노이즈 게이트 (open -40 / close -55dBFS / floor gain 0.001)
- apps/web/app/monitor-dashboard/[group]/[roomId]/page.tsx:220, 723-731, 742-744 — isLessonStarted 재생 게이트 / 영상 무조건 표시 · session-controls.tsx:121-139 "듣기"=toggleMicrophone(AI입력)
- apps/web/hooks/client-host/use-audio-streams.ts:72 — enabled(=isLessonStarted) 조기 return
- apps/web/entities/guest-page-session/model/use-guest-page-session.ts:1220-1233 — 진행자 타이핑 → GUEST_SPEAKING_START(말풍선)
- apps/web/entities/host-socket/model/use-host-socket.ts:280 / page.tsx:1929-1931 — forceKickGuest(reason="kick") 수동 다이얼로그
- apps/socket/src/sfu-socket/handlers/room-handlers.ts:229 — isVoiceUser일 때만 host-toggle-microphone ON 푸시 (텍스팅이라 미발동)
관련 문서: bugs/모니터-게스트-마이크-무음-원인-분석.html · architecture/모니터-대시보드-카드뷰-포커스뷰-코드레벨-동작흐름.html