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

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

녹음 파이프라인 (mediasoup→ffmpeg→OGG→S3) — 코드레벨…: 입력: 개요 · 범위, 주요 처리 단계: ④ dual-spawn 전환 transitionFfmpeg, L1082 /…, 결과: 파일 · 라인 레퍼런스 흐름
동작 흐름 요약
  1. 입력: 개요 · 범위
  2. 주요 처리 단계: ④ dual-spawn 전환 transitionFfmpeg, L1082 /…
  3. 결과: 파일 · 라인 레퍼런스
작성일: 2026-06-14 대상: 개발자 — 수업 녹음 생성·전환·업로드 흐름 파악 핵심 파일: apps/socket/src/recording/
💬 대화로 먼저 이해하기 — "녹음 스튜디오 기사 비유" (비개발자·처음 읽는 사람용)
Q수업 녹음 파일은 어떻게 만들어지나요?
A스튜디오 녹음 기사를 떠올리면 돼요. 아동 마이크와 핑퐁이(AI) 음성 두 채널을 방송 회선(mediasoup)에서 살짝 분기해(PlainTransport/RTP) 녹음기(ffmpeg)로 보내고, 테이프(OGG 세그먼트)에 담아요. 수업이 끝나면 테이프들을 이어붙여 mp3로 만들어 자막과 함께 창고(S3)에 보관합니다.
Q아동과 핑퐁이가 동시에 말하면 소리가 겹치지 않나요?
A라디오 DJ가 멘트를 시작하면 배경음악이 자동으로 작아지죠? 똑같이 아동 목소리가 들어오는 순간 AI 음성을 자동 감쇠(child-priority ducking)시킨 뒤 한 트랙으로 섞어요(amix). 아동 발화가 항상 또렷하게 남습니다.
Q활동이 바뀔 때 녹음이 끊길 것 같은데요?
A그게 이 모듈의 핵심 난점이에요. 테이프를 갈 때 새 녹음기를 먼저 켜서 돌아가는 걸 확인한 뒤에야 구 녹음기를 끕니다(dual-spawn). 잠깐 두 대가 같이 돌더라도 전환 직전 마지막 발화를 절대 잃지 않는 "새것 먼저, 구것 나중"이 불변식이죠.
Q코드에서는 어디를 보면 되나요?
A대부분 recording/recordingManager.ts(1,653줄) 한 파일의 상태머신이에요. 전환 로직은 본문 "④ dual-spawn 전환", 실수하기 쉬운 순서 규칙은 "함정 · 주의" 섹션을 보세요.

개요 · 범위

한 수업(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 시 녹음 트리거 진입점

관련 문서