마지막 업데이트 2026-07-22
한 수업(room)의 아동 마이크 + 핑퐁이(AI) 음성을 mediasoup에서 RTP로 빼내 ffmpeg로 받아 OGG로 녹음하고, 종료 시 mp3로 트랜스코딩 + 자막(caption.json)과 함께 S3에 올린다. 활동(activity) 전환 때 마지막 발화를 잃지 않도록 dual-spawn 전환을 쓰는 것이 이 모듈의 핵심 난점이다.
apps/socket) 내부 싱글톤 recordingManager. SFU 서버 문서(#2)의 produce 핸들러가 트리거를 건다. roomId당 세션 1개(sessions: Map).reserveLocalUdpPort()로 127.0.0.1 임시 포트를 잡는다. (PlainTransport.tuple.localPort는 mediasoup가 이미 listen 중이라 SDP에 쓰면 안 됨 → 별도 ffmpeg 수신 포트 필요)router.createPlainTransport({ip:127.0.0.1, rtcpMux:true, comedia:false}) → transport.connect({ip:127.0.0.1, port: ffmpegListenPort}) (mediasoup가 이 포트로 RTP 송신).paused:true로 시작 → ffmpeg가 listener 바인딩 전 RTP 유실 방지.generateSdpContent가 consumer 코덱(opus)으로 RTP SDP를 만들어 파일로 저장 → ffmpeg -i {sdp} 입력.결과 단위는 PlainTransportConsumerPair { transport, consumer, ffmpegListenPort }. child/ai 각각 한 쌍.
# 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
out_time_us를 파싱해 현재 OGG segment의 currentEncodedMs 갱신. 새 ffmpeg마다 0부터 카운트되므로 곧 해당 segment의 인코딩 진행 ms → caption 좌표의 기준(wall-clock 비의존).ffmpegStderrTail) — 실패 진단용.startRecording, L467): pair를 paused로 만들고 → startFfmpeg → 200ms(FFMPEG_LISTENER_READY_MS) 대기 → consumer resume → pushOggSegment(이 시점이 OGG t=0).사전에 registerPendingRecording(metadata)로 메타데이터(userId/lessonIndex/title)를 등록(Redis EX 3600 + 로컬 Map). 이후 produce 시점에 시작된다.
mic-audio produce + guestState.isAiSessionActive → tryStartPendingRecording(room, router, micProducer, 최신 ai-audio producer). 시작되면 RECORDING_STARTED 브로드캐스트.ai-audio produce → replaceAiConsumer(첫 부착이면 pair 생성 후 ffmpeg 재시작, 이미 있으면 transition).enqueueSessionOp(operationChain)으로 직렬화 — 동시 transition race 방지.활동 전환 등으로 producer가 바뀌면 ffmpeg 입력도 바꿔야 한다. 그냥 죽였다 켜면 전환 직전 마지막 발화가 잘린다. 그래서:
stdin "q\n" drain 신호 → 1.5s 내 미종료면 SIGTERM → 3s면 SIGKILL. 구 세그먼트 수집은 백그라운드(pendingTransitions)로.ownedRecordingPath)해 동시 생존 윈도우에서 서로의 segment를 덮어쓰지 않는다.replaceConsumer(단순 교체, ffmpeg 없을 때) vs transitionFfmpeg(ffmpeg 살아있을 때 dual-spawn) vs restartFfmpeg(SIGKILL 후 재시작) 세 경로가 상황별로 갈린다.
status="stopping" + 즉시 sessions에서 제거(재진입 차단) → operationChain + pendingTransitions 완료 대기.stopFfmpeg: q\n drain → SIGTERM → SIGKILL(최대 30s).collectSegment: 마지막 segment를 ffprobe로 duration 측정해 segmentPaths에 추가. 세그먼트 0개면 에러.concatSegments(ffmpeg -f concat -c copy)로 합침.transcodeToMp3(-af aresample=async=1:first_pts=0로 RTP 무신호 갭을 무음 복원 → mp3 길이를 캡션 좌표에 정합). 동시 트랜스코딩은 TranscodeSemaphore(2)로 제한.{stage}/recordings/{userId}/{lessonIndex}/{날짜_시간_제목_idx}.mp3 (재시도 3회) → buildAndUploadCaptions(.captions.json) → saveRecordingLog(DB).finally cleanup: pair/transport close, SIGKILL 잔여 ffmpeg, sdp/segment/concat/tmp 파일 삭제. done일 때만 mp3 삭제 + pendingMetadata 제거(내 metadata일 때만).자막을 mp3 재생 위치에 정확히 매핑하기 위해 wall-clock이 아니라 audio-frame-count를 쓴다.
SPEAKING_START 도착 → 현재 활성 segment의 (segIdx, currentEncodedMs) 스냅샷 보관.TRANSCRIPT_UPDATE(전사) 도착 → 보관된 스냅샷과 짝지어 CaptionTurn(logId/role/content + startSeg/endSeg 좌표) append.buildAndUploadCaptions가 cumulativePriorMs[segIdx] + segMs로 각 턴의 mp3 ms를 계산. logId는 SessionLog.id와 동일 → UI에서 전사-오디오 매칭.| 메커니즘 | 동작 |
|---|---|
| degraded | ffmpeg error/비정상 종료 시 degraded=true. status는 "recording" 유지해 stop이 앞 세그먼트를 살려 업로드. 추가 transition은 차단(동일 실패 반복 방지). |
| intentionalKill | restartFfmpeg가 활성 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.ts | ffmpeg용 RTP SDP 생성 |
| recording/udpPort.ts | 127.0.0.1 임시 UDP 포트 예약 |
| recording/ffprobe.ts | OGG 세그먼트 duration 측정(safe) |
| recording/s3Uploader.ts | mp3/captions S3 업로드(재시도 3회) |
| recording/types.ts | RecordingSession/OggSegment/CaptionTurn 타입 |
| sfu-socket/handlers/media-handlers.ts | produce 시 녹음 트리거 진입점 |