마지막 업데이트 2026-07-22
activity-1779843076660-0,1,2(체크인 ~ 빙글빙글 지우)까지만 녹음에 포함되고, activity-1779843076660-3(빙글빙글 주원) 이후 모든 활동(이서, 지우, 체크아웃)이 mp3에서 누락됨.
| 활동 | 제목 | 녹음 포함 |
|---|---|---|
activity-…-0 | 공통_공통_체크인_2_0_지우 | ✅ 포함 |
activity-…-1 | 공통_공통_이거알아_1_0_지우 | ✅ 포함 |
activity-…-2 | 공통_공통_빙글빙글2분토크_2_1_지우 | ✅ 포함 |
activity-…-3 | 공통_공통_빙글빙글2분토크_2_2_주원 | ❌ 누락 |
activity-…-4 | 공통_공통_빙글빙글2분토크_2_3_이서 | ❌ 누락 |
activity-…-5 | 공통_공통_빙글빙글2분토크_2_4_지우 | ❌ 누락 |
activity-…-9 | 공통_공통_체크아웃_2_0_지우 | ❌ 누락 |
녹음이 끊긴 경계는 09:38:24 ~ 09:38:45 구간(약 21초). 다른 활동 전환들이 0.8초 만에 끝난 것과 대조됨.
id-030 클라이언트가 close() 호출
09:38:25.073 [WARN] AI_SESSION DataChannel closed
09:38:25.205 mic producer 4002b864 생성 → replaceChildConsumer → transitionFfmpeg THROW (stale aiProducer)
09:38:25.~ ~ 09:38:44 activity-3 인트로 영상 재생 (주원 등장영상, 19초)
09:38:44.561 [WARN] MEET_VIDEO Video duration timeout fallback (ended event 누락)
09:38:44.564 STEP_TRANSITION Starting session for activity-3
09:38:44.921 mic producer 9fc85101 → transitionFfmpeg THROW (stale aiProducer)
09:38:45.337 AI producer id-013 생성 → replaceAiConsumer 진입
09:38:45.~ success path 진입했으나 consumer.resume 침묵 실패 추정 → 빈 OGG segment 시작
| 전환 | AI 세션 정지 → 새 AI producer 생성 | 결과 |
|---|---|---|
| 0 → 1 | 0.8초 | 정상 회복 (activity-1 녹음됨) |
| 1 → 2 | 0.8초 | 정상 회복 (activity-2 녹음됨) |
| 2 → 3 | 20.9초 | 회복 실패 (이후 전체 누락) |
| 3 → 4 | 12초 | 이미 끊긴 상태 유지 |
video_room_jiwoo1_intro_title_juwon_biggle_bridge_0)이라 AI 세션이 영상 종료 후에야 시작됨. 영상의 ended 이벤트가 발화되지 않아 fallback 타이머(19초)까지 대기 → stale window가 비정상적으로 길어짐.
타임아웃이나 폴링 로직이 아니라 AI 세션 상태가 바뀔 때 React state guestMicStream이 null ↔ MediaStream으로 토글되는 것이 트리거입니다.
use-guest-page-session.ts:956 의 nullish coalescing:
const mediasoupProducer = useMediasoupProducer({ ... micStream: aiSession.guestMicStream ?? localMedia.audioStream, ... });
| AI 세션 상태 | guestMicStream | ?? 평가 결과 |
|---|---|---|
| Active | relayStream (처리체인 적용된 MediaStream) | relayStream |
| Inactive | null | localMedia.audioStream (raw mic) |
use-ai-session.ts 에서 토글이 일어나는 두 지점:
// line 1387 — AI 세션 stop 시 (cleanup 함수 내부) setGuestMicStream(null); // line 1655 — AI 세션 start 시 (relay stream 생성 직후) setGuestMicStream(relayStream);
use-mediasoup-producer.ts:536 의 useEffect는 [isReady, micStream, roomId, peerId] 에 의존합니다. 참조 동등성(reference equality)으로 변경을 감지하므로 null → MediaStream 또는 MediaStream A → MediaStream B 변경 모두 cleanup(옛 producer close) + 본문(새 producer 생성)이 순서대로 실행됩니다.
transitionFfmpeg가 곧 회복함. activity-3은 stop → 19초 인트로 영상 → start 사이에 시간이 벌어지면서 두 번의 mic 재생성이 19초 간격으로 분리되고, 그 사이 session.aiProducer가 stale로 머무는 시간이 길어진 것.
recordingManager.ts의 transitionFfmpeg에 두 개의 침묵 실패 지점이 있고, 활동 전환의 AI 세션 재시작 지연이 길어질 때 이 둘이 누적되어 녹음이 영구히 비게 됨.
if (session.childProducer) { newChildPair = await this.createPlainTransportConsumer(router, session.childProducer, true); } if (session.aiProducer) { newAiPair = await this.createPlainTransportConsumer(router, session.aiProducer, true); // ← 닫힌 producer면 throw }
session.aiProducer가 클라이언트가 이미 닫은 producer를 가리키는 상태로 남아 있어 transport.consume({ producerId: closedProducer.id })가 throw → enqueueSessionOp의 .catch(line 533)가 로그만 찍고 무시.
try { await newChildPair.consumer.resume(); } catch {} // ← 에러 무음 삼킴 try { await newAiPair.consumer.resume(); } catch {} // ← 에러 무음 삼킴
resume이 실패하면 consumer가 paused 상태로 남아 새 ffmpeg는 spawn되었지만 RTP가 흐르지 않아 빈 OGG 파일(헤더만)이 생성됨.
if (!valid) { const idx = session.segmentPaths.indexOf(oldRecordingPath); if (idx >= 0) session.segmentPaths.splice(idx, 1); // ← 빈 segment 제거 }
빈 파일이 자동 제거되어 최종 concat 대상에서 빠지므로, mp3에는 activity-0/1/2 분량만 남음.
MEET_VIDEO Video duration timeout fallback으로 AI 세션 재시작이 20.9초 지연 → stale window 누적 → 회복 불능.seg1 (activity-0, 09:30:22 → 09:32:33) ≈ 2:11
seg2 (activity-1, 09:32:33 → 09:36:56) ≈ 4:23
seg3 (activity-2, 09:36:56 → 09:38:25, tail silence 포함) ≈ 1:21
합계 ≈ 7:55 (사용자 보고와 일치)
stale 참조 자체를 원천 차단. 이 한 가지만으로 이번 케이스의 직접 원인은 막힘.
producer.observer.once("close", () => { for (const session of recordingManager.getAllSessions()) { if (session.aiProducer === producer) session.aiProducer = null; if (session.childProducer === producer) session.childProducer = null; } });
transitionFfmpeg에 producer.closed 가드 필수if (session.childProducer && !session.childProducer.closed) { newChildPair = await this.createPlainTransportConsumer(router, session.childProducer, true); } if (session.aiProducer && !session.aiProducer.closed) { // ← closed 가드 newAiPair = await this.createPlainTransportConsumer(router, session.aiProducer, true); }
consumer.resume() 침묵 실패 가시화 필수try { await newChildPair.consumer.resume(); } catch (err) { logger.error("Failed to resume new child consumer", { roomId: session.roomId, err }); session.status = "error"; // 또는 restartFfmpeg 트리거 }
startFfmpeg의 ffmpeg.on("exit", ...)에서 비정상 코드/시그널이면 ERROR 로그 + 자동 restartFfmpeg 트리거. 현재는 lastFfmpegExit만 기록하고 침묵.
recording_transition_failed_total Prometheus 카운터 추가.[CAPTION-DIAG] OGG segment registered와 짝을 이루는 audioDurationMs=0 경고 로그 추가.MEET_VIDEO Video duration timeout 사전 방지 권장인트로 영상의 ended 이벤트 누락 fallback이 19초까지 늦어지지 않도록, currentTime >= duration * 0.99일 때 즉시 onEnd 호출하도록 임계값을 낮춤. stale window 자체를 줄여 동일 부류 사고 재발 확률을 낮춤.
roomId 09:36 ~ 09:50 구간의 다음 신호 카운트로 가설 1차 확인:
Recording session op failedffmpeg transitioned for roomlastFfmpegExit code/signal[CAPTION-DIAG] OGG segment registered 개수와 audioDurationMsrecordingManager가 닫힌 producer를 참조하면서 발생한 침묵 실패가 누적되어 이후 모든 녹음 segment가 빈 파일이 되었고, 빈 segment 자동 폐기 로직과 결합되어 최종 mp3에 activity-0/1/2 분량(7:55)만 남았음.