녹음 파이프라인 (mediasoup→ffmpeg→OGG→S3) — 코드레벨 동작 흐름 P0코드레벨

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

작성일: 2026-06-14 대상: 개발자 — 수업 녹음 생성·전환·업로드 흐름 파악 핵심 파일: apps/socket/src/recording/

개요 · 범위

한 수업(room)의 아동 마이크 + 핑퐁이(AI) 음성을 mediasoup에서 RTP로 빼내 ffmpeg로 받아 OGG로 녹음하고, 종료 시 mp3로 트랜스코딩 + 자막(caption.json)과 함께 S3에 올린다. 활동(activity) 전환 때 마지막 발화를 잃지 않도록 dual-spawn 전환을 쓰는 것이 이 모듈의 핵심 난점이다.

실행 위치: SFU 서버(apps/socket) 내부 싱글톤 recordingManager. SFU 서버 문서(#2)의 produce 핸들러가 트리거를 건다. roomId당 세션 1개(sessions: Map).

전체 그림

[mediasoup Producer] 아동 mic-audio / AI ai-audio └─ PlainTransport(127.0.0.1, comedia:false) ──RTP/UDP──▶ ffmpeg(SDP 입력) └─ (둘 다면) child-priority ducking + amix ─▶ libopus 128k ─▶ OGG segment{n}.ogg └─ -progress pipe:1 (out_time_us) ─▶ caption 좌표(currentEncodedMs) [종료] stopFfmpeg(q\n drain) → collectSegment(ffprobe) → (다중이면)concat → transcodeToMp3 └─ S3 업로드(.mp3) + buildAndUploadCaptions(.captions.json) + saveRecordingLog(DB)

① mediasoup → ffmpeg RTP 인출 createPlainTransportConsumer, L846 / sdpGenerator.ts / udpPort.ts

  1. 1UDP 포트 예약. reserveLocalUdpPort()로 127.0.0.1 임시 포트를 잡는다. (PlainTransport.tuple.localPort는 mediasoup가 이미 listen 중이라 SDP에 쓰면 안 됨 → 별도 ffmpeg 수신 포트 필요)
  2. 2PlainTransport 생성·연결. router.createPlainTransport({ip:127.0.0.1, rtcpMux:true, comedia:false})transport.connect({ip:127.0.0.1, port: ffmpegListenPort}) (mediasoup가 이 포트로 RTP 송신).
  3. 3Consumer 생성(paused). producer를 consume하되 paused:true로 시작 → ffmpeg가 listener 바인딩 전 RTP 유실 방지.
  4. 4SDP 파일 작성. generateSdpContent가 consumer 코덱(opus)으로 RTP SDP를 만들어 파일로 저장 → ffmpeg -i {sdp} 입력.

결과 단위는 PlainTransportConsumerPair { transport, consumer, ffmpegListenPort }. child/ai 각각 한 쌍.

② ffmpeg 인코딩 startFfmpeg, L876–965

# child/ai 입력 각각
-protocol_whitelist file,rtp,udp  -i {roomId}_child.sdp
-protocol_whitelist file,rtp,udp  -i {roomId}_ai.sdp
# 둘 다 있으면 child-priority ducking 믹스 (input0=아동 sidechain → input1=AI 자동 감쇠)
-filter_complex [0:a]asplit=2[csc][cmix];[1:a][csc]sidechaincompress=threshold=0.03:ratio=10:attack=20:release=250[aiduck];[cmix][aiduck]amix=inputs=2:duration=longest:dropout_transition=2
-c:a libopus -b:a 128k
-f ogg -flush_packets 1 -y {roomId}_{ts}_seg{n}.ogg
-progress pipe:1 -stats_period 0.2   # caption timestamp용 진행 ms

③ 녹음 시작 트리거 media-handlers PRODUCE → tryStartPendingRecording, L221

사전에 registerPendingRecording(metadata)로 메타데이터(userId/lessonIndex/title)를 등록(Redis EX 3600 + 로컬 Map). 이후 produce 시점에 시작된다.

④ dual-spawn 전환 transitionFfmpeg, L1082 / replaceChild·replaceAiConsumer

활동 전환 등으로 producer가 바뀌면 ffmpeg 입력도 바꿔야 한다. 그냥 죽였다 켜면 전환 직전 마지막 발화가 잘린다. 그래서:

  1. 바뀐 쪽의 새 PlainTransport/Consumer 쌍을 paused로 생성 + 새 OGG segment 파일로 새 ffmpeg를 먼저 spawn.
  2. 새 consumer를 resume(200ms 후) → 새 ffmpeg가 RTP를 받기 시작.
  3. 그 다음에야 구 ffmpeg에 stdin "q\n" drain 신호 → 1.5s 내 미종료면 SIGTERM → 3s면 SIGKILL. 구 세그먼트 수집은 백그라운드(pendingTransitions)로.
핵심 불변식: 새 입력이 살아난 뒤 구 입력을 닫는다(겹침 구간 허용) → 발화 손실 0. 각 ffmpeg는 자기 OGG 경로를 closure로 캡처(ownedRecordingPath)해 동시 생존 윈도우에서 서로의 segment를 덮어쓰지 않는다.

replaceConsumer(단순 교체, ffmpeg 없을 때) vs transitionFfmpeg(ffmpeg 살아있을 때 dual-spawn) vs restartFfmpeg(SIGKILL 후 재시작) 세 경로가 상황별로 갈린다.

⑤ 종료 · 업로드 stopRecording, L356

  1. status="stopping" + 즉시 sessions에서 제거(재진입 차단) → operationChain + pendingTransitions 완료 대기.
  2. stopFfmpeg: q\n drain → SIGTERM → SIGKILL(최대 30s).
  3. collectSegment: 마지막 segment를 ffprobe로 duration 측정해 segmentPaths에 추가. 세그먼트 0개면 에러.
  4. 세그먼트 1개면 그대로, 여러 개면 concatSegments(ffmpeg -f concat -c copy)로 합침.
  5. transcodeToMp3(-af aresample=async=1:first_pts=0로 RTP 무신호 갭을 무음 복원 → mp3 길이를 캡션 좌표에 정합). 동시 트랜스코딩은 TranscodeSemaphore(2)로 제한.
  6. S3 업로드: {stage}/recordings/{userId}/{lessonIndex}/{날짜_시간_제목_idx}.mp3 (재시도 3회) → buildAndUploadCaptions(.captions.json) → saveRecordingLog(DB).
  7. finally cleanup: pair/transport close, SIGKILL 잔여 ffmpeg, sdp/segment/concat/tmp 파일 삭제. done일 때만 mp3 삭제 + pendingMetadata 제거(내 metadata일 때만).

⑥ 자막(caption) 좌표 appendCaptionTurn / markSpeakingStart·Stop / buildAndUploadCaptions

자막을 mp3 재생 위치에 정확히 매핑하기 위해 wall-clock이 아니라 audio-frame-count를 쓴다.

견고성 · 자가복구

메커니즘동작
degradedffmpeg error/비정상 종료 시 degraded=true. status는 "recording" 유지해 stop이 앞 세그먼트를 살려 업로드. 추가 transition은 차단(동일 실패 반복 방지).
intentionalKillrestartFfmpeg가 활성 ffmpeg를 의도적 SIGKILL하는 구간 표시 → 이 SIGKILL을 외부 kill(OOM/컨테이너)로 오인해 degraded 처리하는 오탐 방지.
좀비 세션30분마다 스캔. startedAt이 2h 초과한 "recording" 세션 강제 stop.
고아 파일tmp 디렉터리에서 mtime 2h 초과 파일 unlink.
producer close 추적trackProducerCleanup으로 producer가 닫히면 캐시(childProducer/aiProducer) 즉시 null → 닫힌 producer에 consume() 시도해 throw하는 stale window 차단.
재접속 보존status≠done이면 pendingMetadata 보존 → 재접속 후 같은 roomId 새 세션 재시작에 재사용.

함정 · 주의

파일 · 라인 레퍼런스

파일역할
recording/recordingManager.ts세션 상태머신·ffmpeg 생명주기·dual-spawn·stop·caption (1,653L)
recording/sdpGenerator.tsffmpeg용 RTP SDP 생성
recording/udpPort.ts127.0.0.1 임시 UDP 포트 예약
recording/ffprobe.tsOGG 세그먼트 duration 측정(safe)
recording/s3Uploader.tsmp3/captions S3 업로드(재시도 3회)
recording/types.tsRecordingSession/OggSegment/CaptionTurn 타입
sfu-socket/handlers/media-handlers.tsproduce 시 녹음 트리거 진입점

관련 문서