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

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

핑퐁이/아동 대화턴 녹음 흐름 (시각화) 아키텍처 PR #786 반영: 입력: 이 문서는 이렇게 읽으면 됩니다, 주요 처리 단계: ① 전체 흐름 (큰 그림), 결과: 한 장 요약 흐름
동작 흐름 요약
  1. 입력: 이 문서는 이렇게 읽으면 됩니다
  2. 주요 처리 단계: ① 전체 흐름 (큰 그림)
  3. 결과: 한 장 요약
문서 읽는 법 · 설명식

이 문서는 이렇게 읽으면 됩니다

핑퐁이/아동 대화턴 녹음 흐름 (시각화)의 핵심을 설명식으로 먼저 안내합니다. 기술적 결론과 원문 근거는 아래 본문에 보존되어 있습니다.

핵심 흐름 펼쳐 보기
  1. 비유와 핵심 질문으로 먼저 전체 구조를 잡습니다.
  2. 실제 컴포넌트·파일·데이터 흐름을 따라 내려갑니다.
  3. 코드 라인과 주의사항에서 구현 근거를 확인합니다.
  • 한 장 요약
  • ① 전체 흐름 (큰 그림)
  • ② 두 오디오 소스 — 무엇이 녹음되나
작성일: 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 전환부, 서버로그 조사 필요

관련 문서