핑퐁이/아동 대화턴 녹음 흐름 (시각화) 아키텍처PR #786 반영

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

작성일: 2026-06-25 대상: 한 대화 턴이 mp3 + caption.json 이 되기까지 전 과정 핵심: recordingManager · child-priority ducking · 턴 양보

한 장 요약

수업의 두 오디오 — 아동 마이크핑퐁이(AI) 음성 — 을 SFU에서 RTP로 빼내 ffmpeg가 한 트랙으로 합쳐(amix) OGG로 녹음하고, 종료 시 mp3 + 자막(caption.json)으로 S3에 올린다. 두 화자가 동시에 말하면 한 트랙에 겹쳐 녹음되는데, 아동이 말하는 동안 핑퐁이를 자동 감쇠(child-priority ducking) 해 아동을 우세하게 만든다.

핵심 한 줄: 녹음은 "두 소스를 합치는" 구조라 동시 발화 시 겹친다. Realtime은 OpenAI가 턴을 양보(AI 중단)해 안 겹치고, TTS는 그 양보가 없어 겹쳤다 → 녹음 합성 단계에서 아동 우선 ducking으로 해결(PR #786).

① 전체 흐름 (큰 그림)

[게스트 브라우저] 아동 마이크 ───────────────▶ childProducer ┐ 핑퐁이 AI 음성 │ ( Realtime: OpenAI 원격트랙 ) = Realtime 원격트랙 또는 ───▶ aiProducer │ ( TTS: Typecast player.stream ) TTS Typecast 합성스트림 │ ▼ [SFU 서버 · recordingManager] │ PlainTransport(127.0.0.1)로 consume childPair ─RTP/UDP─┐ │ aiPair ─RTP/UDP─┤ │ ▼ ▼ ffmpeg ─ filter_complex (child-priority ducking) ─ libopus ─▶ OGG segment{n}.ogg [0:a]asplit→[1:a][child]sidechaincompress→amix │ │ │ -progress out_time_us (활동 전환 시) dual-spawn: 새 ffmpeg 먼저 spawn → 구 ffmpeg drain ▼ caption 좌표(segIdx,segMs) [종료 stopRecording] concat(OGG들) ─▶ transcodeToMp3 (-af aresample=async, 무신호 갭 복원) └─▶ S3 .mp3 + buildAndUploadCaptions .captions.json + DB 로그
아동 마이크와 핑퐁이는 서로 다른 producer/consumer 쌍으로 들어와 ffmpeg 한 프로세스에서 합쳐진다.

② 두 오디오 소스 — 무엇이 녹음되나

소스경로특징
아동 (childPair)게스트 마이크 → childProducer → SFU consume항상 켜져 있음. 아동이 내는 모든 소리를 연속 캡처(상시 ON).
핑퐁이 (aiPair)Realtime: OpenAI Realtime 원격트랙
TTS: Typecast 합성 → player.stream
모드에 따라 출처가 다름. "멈추는 방식"이 모드별로 갈린다(③).

두 트랙은 ffmpeg가 amix로 한 mp3에 합친다. 두 화자가 같은 시각에 소리내면 물리적으로 겹쳐 녹음되고, 한 번 섞이면 분리 불가 — 그래서 "합칠 때 누구를 우선할지"가 관건이 된다.

②-B TTS 모드(Typecast) 녹음 흐름 현재

TTS 모드에서 핑퐁이 음성은 OpenAI가 아니라 Typecast로 클라이언트가 합성한다. 그 합성 오디오가 어떻게 녹음 aiPair가 되는지:

[OpenAI Realtime] output_modalities:["text"] # TTS면 오디오 안 만듦(텍스트만) └─ assistant 텍스트 응답(델타) ▼ [TtsPlayer] appendDelta → 문장 청크 → Typecast 합성(REST) → AudioBuffer ├─▶ (a) Web Audio → MediaStreamDestination → player.stream │ └─▶ aiAudioStream → aiProducer(SFU) → aiPair ──▶ ffmpeg 녹음 ✅ └─▶ (b) localAudioElement(blob URL) ──▶ 게스트 스피커 재생(로컬 청취) + onSpeechStart/End ─▶ (합성) output_audio_buffer.started/stopped ─▶ AI_SPEAKING_START/STOP ─▶ caption "assistant" 마커
요약: TTS 녹음 aiPair = TtsPlayer.stream(Typecast 합성) → aiProducer. Realtime은 OpenAI 원격트랙 → aiProducer. 이후 ffmpeg 합성·ducking·caption 좌표는 두 모드 공통이다.

③ 대화 턴 타임라인 — Realtime vs TTS 핵심

왜 Realtime은 안 겹치고 TTS는 겹쳤나? 핑퐁이 오디오가 아동에게 "양보(중단)"하느냐의 차이다.

Realtime — OpenAI가 턴을 자동 양보 (interrupt ON)

핑퐁이 ███████✂ ██████ 아동 ▒▒▒▒▒▒▒ ▒▒▒▒ ▲ 아동 발화 → OpenAI server VAD 가 AI 응답 즉시 취소 → 핑퐁이 트랙 멈춤 ⇒ 두 소리가 시간상 거의 안 겹침 ⇒ 녹음 깨끗 ✓

TTS — 서버측 양보 없음 → 겹침

핑퐁이 ██████████████ (Typecast 합성음을 끝까지 재생, 안 멈춤) 아동 ▒▒▒▒▒▒▒ └──────┘ ← 이 구간 두 소리 동시 = 녹음 중첩! [PR #786 해결] 겹침 구간에서 핑퐁이만 자동 감쇠(child-priority ducking): 핑퐁이 ██████░░░░░░░ (아동 말할 때 ↓ 줄임) 아동 ▒▒▒▒▒▒▒ ⇒ 아동 우세, 무중첩 ✓
파랑 █ 핑퐁이 발화 · 주황 ▒ 아동 발화 · ✂/✗ 중단/겹침 · 감쇠(ducking)
왜 TTS는 양보가 없나: Realtime 핑퐁이는 OpenAI가 생성하므로 server VAD가 응답을 취소하면 트랙이 멈춘다. TTS 핑퐁이는 Typecast로 클라이언트가 합성·재생하므로 OpenAI 취소 메커니즘이 닿지 않는다. 또한 TTS는 진행자 수동/타이핑 운영이라 끼어들기 OFF(핑퐁이 끝까지 발화)로 쓰는 경우가 많다.

④ 해결 — child-priority ducking (sidechaincompress) 현재 적용

대화 동작은 그대로 두고(핑퐁이는 끝까지 발화), 녹음 합성(amix) 단계에서만 아동 발화 중 핑퐁이를 자동으로 줄인다. recordingManager.tsstartFfmpeg에서 child+ai 입력이 모두 있을 때 적용.

// input 0 = 아동(child), input 1 = 핑퐁이(ai)
[0:a]asplit=2[csc][cmix];                       // 아동을 2갈래로 복제(트리거용/믹스용)
[1:a][csc]sidechaincompress=threshold=0.03:ratio=10:attack=20:release=250[aiduck];
                                              // 아동(csc)이 크면 핑퐁이(1:a)를 자동 감쇠
[cmix][aiduck]amix=inputs=2:duration=longest:dropout_transition=2
아동 음량 ▁▁▁▆▆▆▆▁▁▁ │ (sidechain 트리거) ▼ 핑퐁이 볼륨 ███→░░░→███ (아동 말할 때만 자동으로 낮아짐)
이 방식의 장점: 라이브 amix에 ~1줄 추가. 한 ffmpeg에서 동기 캡처라 "두 트랙 정렬(순서 안 맞음)" 문제가 원천적으로 없고, 캡션 좌표·대화 동작에 영향 0. realtime/tts 양쪽 무중첩 확인 완료.

대안 비교 (왜 이걸 골랐나):

방식판정
barge-in (아동 발화 시 TTS 중단)끼어들기 OFF와 모순 + 불안정 → 폐기
stem 분리 후 합성 + window ducking가능하나 대규모 리팩토링 + 타임라인 정렬 위험 → 보류
sidechaincompress 라이브 ducking~1줄, 정렬·캡션 영향 없음 → 채택
튜닝/한계: ducking은 아동 음량 기반이라 VAD 임계 아래 조용한 소리엔 안 걸린다(대신 그 소리도 조용함). 더 세게 원하면 ratio↑ / threshold↓. child 마이크에 섞인 핑퐁이 echo는 ducking이 아니라 echoCancellation(마이크 캡처 제약)이 담당.

⑤ 자막(caption) 좌표 흐름 — mp3 어디에 매핑되나

자막을 mp3 재생 위치에 정확히 붙이기 위해 wall-clock이 아닌 오디오 인코딩 진행(segIdx, segMs)을 쓴다. ducking은 좌표에 영향 없음(같은 타임라인).

아동 발화 ─▶ TRANSCRIPT_UPDATE ─▶ buildRecordingCaptionTurn (External STT: stt_verified/text 포함) 핑퐁이 발화 ─▶ AI_SPEAKING_START/STOP ─▶ markSpeakingStart/Stop("assistant") │ ▼ (도착 시점의 활성 segment 위치 스냅샷) CaptionTurn { logId, role, startSegIdx/segMs, endSegIdx/segMs } │ [종료] buildAndUploadCaptions: cumulativePriorMs[segIdx] + segMs ─▶ caption.startMs / endMs (mp3 ms)
주의(과거 버그, 해결됨): RTP 무신호 구간이 OGG granule(좌표)엔 남지만 transcode 시 갭이 붕괴돼 mp3가 짧아져 캡션이 어긋났다 → transcodeToMp3aresample=async=1:first_pts=0로 갭을 무음 복원해 정합(PR #786). External STT 아동 캡션 누락도 buildRecordingCaptionTurn으로 함께 수정.

⑥ 현재 상태 · 이력 (PR #786)

항목상태
캡션 위치 어긋남 (granule vs mp3)✅ 해결 — aresample=async
External STT 아동 캡션 누락✅ 해결 — buildRecordingCaptionTurn
마이크 음향 에코✅ 완화 — echoCancellation 명시
핑퐁이/아동 음성 중첩✅ 해결 — child-priority sidechain ducking
활동 전환 시 녹음 잘림/드롭아웃⚠ 별건 · 미확정 — dual-spawn 전환부, 서버로그 조사 필요

관련 문서