TTS/Realtime 녹음 합본 오디오 겹침·밀림 — 원인 분석 및 최종 해결 원인 확정 최종 해결(PPI-1098)

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

작성일: 2026-06-30 최종 업데이트: 2026-07-03 (PPI-1098) 관련 PR: #822 · 커밋 37d164d4, 7a135092 영역: recordingManager (서버측)

결론

TTS 모드 자체가 새로운 버그를 만든 것이 아니다. TTS 모드의 긴 연속 발화 패턴이 서버 녹음 합본 단계의 아동 입력 무음 갭 붕괴를 가장 크게 드러냈다. 붕괴되는 쪽은 핑퐁이 출력이 아니라 아동 마이크 입력의 긴 무음 구간이다.

핵심 인과는 단순하다. 핑퐁이가 길게 말하는 동안 아동은 통째로 침묵한다. 이때 아동 RTP 입력의 무신호 구간이 ffmpeg 입력 타임라인에서 실제 시간만큼 남지 않고 짧아지면, 이후 아동 발화가 앞으로 당겨진다. 그 결과 긴 AI 발화의 꼬리와 후속 아동 발화가 녹음 합본에서 겹친다.

Realtime 모드가 상대적으로 덜 두드러진 이유는 OpenAI Realtime 스트림이 더 잘게 오고 턴 양보가 발생해, "한쪽만 길게 신호이고 다른 한쪽은 긴 무음"인 구간이 TTS보다 짧기 때문이다. 같은 구조적 위험은 있지만 TTS가 더 강하게 트리거했다.

최종 해결 (PPI-1098, 2026-07-03) 현재 적용

아래 "수정 내용"(2026-06-30, 단일 ffmpeg + wallclock)은 부족한 것으로 판명돼 폐기됐다. 실제 문제는 두 겹이었고, 한 ffmpeg가 두 RTP 입력을 함께 읽는 구조로는 트레이드오프를 벗어날 수 없었다. 최종 해결은 입력별 독립 ffmpeg + 후처리 정렬 믹스다.

근본 원인 — 단일 ffmpeg가 두 입력을 읽는 구조 (2겹)

방식결과
-use_wallclock_as_timestamps 사용 발화 중인 입력으로 디먹스가 쏠려 조용한 입력의 재개 패킷을 늦게 읽음(read-starvation) → 그 늦은 read 시각으로 스탬핑 → 아동 턴이 뒤로 밀려 자기 슬롯은 무음, 상대 발화에 겹침. (-thread_queue_size로도 해소 안 됨)
wallclock 없이 native DTX 무음 갭이 붕괴 → 핑퐁이 연속 발화가 하나로 병합 (예: AI#2+AI#3)
둘은 같은 뿌리다. 한 ffmpeg가 두 개의 갭 있는(턴제 무음) 라이브 RTP를 동시에 읽으며 정렬하려는 것이 문제. wallclock ON이면 ②(병합)는 낫고 ①(밀림)이 깨지고, OFF면 반대. 입력별 독립 리더(=별도 ffmpeg 프로세스)만이 근본 해결이다.

해결 구조 — 입력별 독립 ffmpeg + 후처리 정렬 믹스

child mic / ai-audio 각 입력을 별도 ffmpeg가 단일 입력으로 stem OGG에 기록한다. 입력이 하나뿐이라 항상 즉시 read 되어 read-starvation·갭 붕괴가 원천 제거된다. 각 stem은 -use_wallclock_as_timestamps -copyts로 기록해 절대 wallclock start_time을 파일에 보존한다. 정지 시 각 stem의 절대 start_time을 읽어 최소값 기준 adelay로 정렬한 뒤 amix로 합쳐 mp3로 인코딩한다(후처리 믹스).
// 1) 입력별 기록 (single-input → read-starvation 없음)
ffmpeg -thread_queue_size 1024 -use_wallclock_as_timestamps 1 \
  -protocol_whitelist file,rtp,udp -copyts -i child.sdp \
  -c:a libopus -b:a 128k -f ogg -flush_packets 1 child_seg0.ogg
// (ai.sdp → ai_seg0.ogg 도 동일하게 별도 프로세스)

// 2) 정지 시 후처리 믹스 — 각 stem 절대 start_time 을 min 기준 adelay 정렬
ffprobe -show_entries stream=start_time ...   // stem 별 절대 시각(초)
delayMs = round((start_time - min) * 1000)
ffmpeg -i child_seg0.ogg -i ai_seg0.ogg -filter_complex \
  "[0:a]aresample=async=1,adelay=0:all=1[a_0];[1:a]aresample=async=1,adelay=2140:all=1[a_1];\
   [a_0][a_1]amix=inputs=2:duration=longest:normalize=0,alimiter=limit=0.98,asetpts=PTS-STARTPTS" \
  -ac 1 -ar 48000 -c:a libmp3lame -b:a 128k out.mp3

producer 교체 시 해당 입력 ffmpeg만 재시작(새 stem 세그먼트)하고, 모든 세그먼트를 각자의 절대 start_time으로 adelay해 amix에 넣는다. 세그먼트가 몇 개든 절대 시각에 놓이므로 concat(순차 이어붙이기)이 불필요하고 전환 경계도 어긋나지 않는다.

캡션은 단일 ffmpeg frame-count 의존을 버리고 wallclock 기반(mp3 t0 = 최소 stem start_time)으로 좌표를 산출한다.

종료 flush: stop 경로 drain 은 긴 타임아웃(15s/30s)으로 수행해 마지막 발화(종료멘트) 잘림을 방지한다.

추가 보강 (커밋 7a135092)

  • stem 무결성 필터 (collectValidStems): 크기뿐 아니라 probeAudioDurationMsSafe로 디코드 길이를 검증해 잘린(EOF)·손상 stem을 드롭. 재시작 폭주 중 finalize 안 된 세그먼트가 섞여 post-mix가 code=1로 실패하던 프로덕션 에러(... .ogg: End of file) 방지 — 손상 stem만 제외하고 나머지로 녹음을 살린다.
  • 교체 경계 중복 방지 (restartInputFfmpeg): new consumer resume 직후 old consumer를 pause() 해, 교체 순간 old/new 두 stem에 같은 오디오가 동시 기록돼 절대시각 amix에서 증폭·에코로 들리던 회귀 위험 제거. 버퍼된 데이터는 이어지는 drain이 flush.
검증: 후처리 정렬 로직을 합성 stem으로 오프라인 선검증한 뒤, TTS·Realtime 두 모드 모두 실제 재현으로 합본 정상 확인. 수정은 전부 서버측(apps/socket/src/recording/recordingManager.ts)이며, 클라이언트 ai-audio 로컬 재생은 Blob URL(audio.src) 방식 그대로 유지(WebAudio 파생 MediaStream을 로컬 재생에 쓰면 iOS Safari 피치 왜곡 → Blob URL 유지).
이하 내용은 2026-06-30 시점의 초기 분석·수정 기록이다. 무음 갭 붕괴 메커니즘(②) 설명은 여전히 유효하지만, 수정(단일 ffmpeg + wallclock)은 위 PPI-1098로 대체됐다. 특히 "TTS 로컬 출력 단일 렌더 스트림" 항목은 iOS 피치 왜곡 때문에 되돌려져 Blob URL 방식이 최종이다.

증상과 관찰

관찰내용해석
TTS 합본 후반 겹침 260630_074100_29_29.mp3에서 약 33초 이후 아동/핑퐁이 소리가 겹침 세션 후반으로 갈수록 타임라인 오차가 누적되는 패턴
Realtime 합본은 정상 동일 흐름에서 Realtime 모드는 겹침이 관찰되지 않음 녹음 합본 구조 자체의 문제지만 TTS 발화 패턴에서만 크게 노출
TTS 단일 렌더 수정 후에도 후반 겹침 재발 로컬 출력 경로를 정리한 뒤에도 후반부 겹침 확인 브라우저 이중 재생만으로 설명 불가, 서버 합본 PTS 정렬 문제로 원인 범위 축소

원인 메커니즘

1. 두 입력은 독립 RTP 스트림이다

녹음 서버는 mediasoup PlainTransport로 두 입력을 받아 ffmpeg에 SDP/RTP 입력으로 넣는다.

  • AI 오디오: 게스트 로컬에서 TTS를 WebAudio MediaStreamDestination으로 렌더한 스트림
  • 아동 오디오: WebRTC 마이크 캡처 스트림

두 producer는 각각 produce({ track, appData })로 올라오며, producer 단계에서 공유 wall-clock time anchor를 갖지 않는다.

2. wallclock 없이 ffmpeg가 읽으면 무신호 시간이 샘플로 남지 않을 수 있다

ffmpeg가 두 SDP/RTP 입력을 각 입력의 RTP 타임스탬프 중심으로 해석하면, DTX/무신호 구간처럼 실제 패킷이 거의 없는 구간은 "실제 시간만큼의 빈 구간"으로 남지 못할 수 있다. 특히 아동이 긴 시간 침묵하는 동안 아동 입력 타임라인이 AI 입력보다 짧아지는 문제가 생긴다.

3. 후속 아동 발화가 앞으로 슬라이드한다

  1. TTS가 긴 연속 발화를 출력한다.
  2. 그동안 아동 마이크는 긴 침묵 상태다.
  3. 아동 입력의 침묵 구간이 PTS 갭으로 보존되지 않고 짧아진다.
  4. 침묵 뒤에 들어온 아동 발화가 실제 시각보다 앞쪽에 배치된다.
  5. 긴 TTS 발화 꼬리와 아동 발화가 합본에서 겹친다.

수정 내용

1. 서버 합본 입력을 wallclock PTS로 정렬

핵심 수정은 apps/socket/src/recording/recordingManager.ts다. child/AI 두 입력이 모두 있을 때 각 입력 앞에 -use_wallclock_as_timestamps 1을 적용해, 패킷 도착 벽시계 기준으로 입력 PTS를 만들도록 했다. 이로써 무신호 구간이 실제 길이의 PTS 갭으로 남고, amix가 그 위치를 보존한다.
// child + ai 두 입력이 모두 있을 때만 적용
ffmpeg \
  -use_wallclock_as_timestamps 1 -protocol_whitelist file,rtp,udp -i child.sdp \
  -use_wallclock_as_timestamps 1 -protocol_whitelist file,rtp,udp -i ai.sdp \
  -filter_complex "[0:a]asplit=2[csc][cmix];[1:a][csc]sidechaincompress=...;[cmix][aiduck]amix=inputs=2:duration=longest:dropout_transition=2,asetpts=PTS-STARTPTS"

asetpts=PTS-STARTPTS는 합본 결과의 시작점을 0으로 리베이스하기 위한 처리다. gap 보존 자체는 입력의 wallclock PTS와 amix가 담당한다.

입력별 aresample=async는 사용하지 않았다. burst 도착 시 hard 보정으로 샘플을 버려, 무음 뒤 발화가 빨리 재생되는 부작용이 있었기 때문이다.

2. TTS 로컬 출력은 단일 렌더 스트림만 사용

apps/web/entities/guest-session/lib/tts-player.ts는 TTS를 WebAudio에서 한 번만 렌더한다. masterGainctx.destination에 직접 연결하지 않고 MediaStreamDestination에만 연결한다. 로컬 재생도 object URL을 따로 만들지 않고 audioEl.srcObject = destination.stream으로 같은 스트림을 재생한다.

이 수정은 로컬 재생, SFU 송출, 녹음 입력이 서로 다른 재생 타임라인을 타는 위험을 줄인다. 다만 아동 무음 갭 붕괴를 직접 막는 핵심 수정은 서버의 wallclock PTS 적용이다.

3. 발화 종료 꼬리 마진 보존

apps/web/hooks/use-guest-audio-capture.ts에는 500ms stop tail margin을 추가했다. 종료 신호가 먼저 오고 실제 WebRTC 미디어 꼬리가 늦게 도착하는 경우, 발화 끝부분이 잘리는 위험을 줄이기 위한 보조 수정이다.

같이 정리된 로그 분석 이슈

같은 작업 범위에서 세션 로그의 "모카봇 오디오 분석" 표시 조건도 정리했다. 요구사항은 assistant audio인 경우에만 분석 컬럼을 표시하는 것이다.

  • turn-analysis-core.ts: 분석 대상에 assistantLogId를 명시하고, 핑퐁이 발화 텍스트를 해당 assistant 응답으로 연결
  • use-turn-analysis.ts, use-audio-analysis-logger.ts: relatedToLogId를 저장
  • session-log-audio-analysis.ts: relatedToLogId가 assistant audio 로그를 가리킬 때만 분석 결과 매핑

이 수정은 녹음 합본 겹침의 직접 원인은 아니지만, 같은 회귀 확인 과정에서 드러난 로그 표시 오류를 함께 정리한 것이다.

수정 범위와 한계

항목판정설명
녹음 합본에서 아동 무음이 압축되어 후속 발화가 앞으로 밀리는 문제 해결 대상 wallclock PTS로 무신호 시간을 실제 PTS 갭으로 보존
TTS 로컬 이중 재생/별도 object URL 타임라인 위험 정리됨 단일 WebAudio 렌더 스트림을 SFU/녹음/로컬 출력에 사용
producer 간 완전한 공유 clock 생성 아님 producer 구조를 바꾼 것이 아니라 서버 합본 단계에서 입력 PTS를 wallclock 기준으로 보정
네트워크 지터/패킷 손실 자체 제거 아님 미디어 네트워크 품질 문제는 별도 관측/완화 대상

검증 포인트

  • 긴 TTS 발화 뒤 아동이 말하는 시나리오를 녹음하고, 후반부에서 아동 발화가 AI 꼬리로 당겨지지 않는지 ear-check한다.
  • 두 입력이 모두 있는 합본에서만 -use_wallclock_as_timestamps가 들어가는지 ffmpeg args 로그로 확인한다.
  • 단일 입력 녹음은 기존 출력 PTS 타임라인 보호를 위해 wallclock 옵션이 적용되지 않는지 확인한다.
  • 세션 로그 오디오 분석 컬럼은 assistant audio 로그에만 표시되고 user audio에는 붙지 않는지 확인한다.

관련 문서