한 장 요약
수업의 두 오디오 — 아동 마이크 와 핑퐁이(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" 마커
- 녹음에 들어가는 건 (a)
player.stream — Web Audio MediaStreamDestination 릴레이. Realtime의 OpenAI 원격트랙 자리를 이 스트림이 대체한다(setAiAudioStream(ttsStream)).
- OpenAI는 text-only(
output_modalities:["text"], openai-create-call.ts) — 진짜 오디오가 없어 output_audio_buffer 이벤트는 전부 TtsPlayer가 합성해 주입. pc.ontrack도 TTS면 무시(무음 원격트랙).
- 스피커(b)와 녹음(a)은 별개 출력 — 녹음
aiPair엔 (a)만 들어간다. (b)는 게스트 로컬 청취용.
- 핑퐁이는 아동 발화에도 멈추지 않고 합성 큐를 끝까지 재생 → ③의 중첩 원인.
요약: 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.ts의 startFfmpeg에서 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가 짧아져 캡션이 어긋났다 → transcodeToMp3에 aresample=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 전환부, 서버로그 조사 필요 |