마지막 업데이트 2026-07-22
핵심 인과는 단순하다. 핑퐁이가 길게 말하는 동안 아동은 통째로 침묵한다. 이때 아동 RTP 입력의 무신호 구간이 ffmpeg 입력 타임라인에서 실제 시간만큼 남지 않고 짧아지면, 이후 아동 발화가 앞으로 당겨진다. 그 결과 긴 AI 발화의 꼬리와 후속 아동 발화가 녹음 합본에서 겹친다.
Realtime 모드가 상대적으로 덜 두드러진 이유는 OpenAI Realtime 스트림이 더 잘게 오고 턴 양보가 발생해, "한쪽만 길게 신호이고 다른 한쪽은 긴 무음"인 구간이 TTS보다 짧기 때문이다. 같은 구조적 위험은 있지만 TTS가 더 강하게 트리거했다.
| 방식 | 결과 |
|---|---|
-use_wallclock_as_timestamps 사용 |
발화 중인 입력으로 디먹스가 쏠려 조용한 입력의 재개 패킷을 늦게 읽음(read-starvation) → 그 늦은 read 시각으로 스탬핑 → 아동 턴이 뒤로 밀려 자기 슬롯은 무음, 상대 발화에 겹침. (-thread_queue_size로도 해소 안 됨) |
| wallclock 없이 native | DTX 무음 갭이 붕괴 → 핑퐁이 연속 발화가 하나로 병합 (예: AI#2+AI#3) |
-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)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.| 관찰 | 내용 | 해석 |
|---|---|---|
| TTS 합본 후반 겹침 | 260630_074100_29_29.mp3에서 약 33초 이후 아동/핑퐁이 소리가 겹침 | 세션 후반으로 갈수록 타임라인 오차가 누적되는 패턴 |
| Realtime 합본은 정상 | 동일 흐름에서 Realtime 모드는 겹침이 관찰되지 않음 | 녹음 합본 구조 자체의 문제지만 TTS 발화 패턴에서만 크게 노출 |
| TTS 단일 렌더 수정 후에도 후반 겹침 재발 | 로컬 출력 경로를 정리한 뒤에도 후반부 겹침 확인 | 브라우저 이중 재생만으로 설명 불가, 서버 합본 PTS 정렬 문제로 원인 범위 축소 |
녹음 서버는 mediasoup PlainTransport로 두 입력을 받아 ffmpeg에 SDP/RTP 입력으로 넣는다.
MediaStreamDestination으로 렌더한 스트림두 producer는 각각 produce({ track, appData })로 올라오며, producer 단계에서 공유 wall-clock time anchor를 갖지 않는다.
-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 보정으로 샘플을 버려, 무음 뒤 발화가 빨리 재생되는 부작용이 있었기 때문이다.
masterGain은 ctx.destination에 직접 연결하지 않고 MediaStreamDestination에만 연결한다. 로컬 재생도 object URL을 따로 만들지 않고 audioEl.srcObject = destination.stream으로 같은 스트림을 재생한다.
이 수정은 로컬 재생, SFU 송출, 녹음 입력이 서로 다른 재생 타임라인을 타는 위험을 줄인다. 다만 아동 무음 갭 붕괴를 직접 막는 핵심 수정은 서버의 wallclock PTS 적용이다.
apps/web/hooks/use-guest-audio-capture.ts에는 500ms stop tail margin을 추가했다. 종료 신호가 먼저 오고 실제 WebRTC 미디어 꼬리가 늦게 도착하는 경우, 발화 끝부분이 잘리는 위험을 줄이기 위한 보조 수정이다.
같은 작업 범위에서 세션 로그의 "모카봇 오디오 분석" 표시 조건도 정리했다. 요구사항은 assistant audio인 경우에만 분석 컬럼을 표시하는 것이다.
assistantLogId를 명시하고, 핑퐁이 발화 텍스트를 해당 assistant 응답으로 연결relatedToLogId를 저장relatedToLogId가 assistant audio 로그를 가리킬 때만 분석 결과 매핑이 수정은 녹음 합본 겹침의 직접 원인은 아니지만, 같은 회귀 확인 과정에서 드러난 로그 표시 오류를 함께 정리한 것이다.
| 항목 | 판정 | 설명 |
|---|---|---|
| 녹음 합본에서 아동 무음이 압축되어 후속 발화가 앞으로 밀리는 문제 | 해결 대상 | wallclock PTS로 무신호 시간을 실제 PTS 갭으로 보존 |
| TTS 로컬 이중 재생/별도 object URL 타임라인 위험 | 정리됨 | 단일 WebAudio 렌더 스트림을 SFU/녹음/로컬 출력에 사용 |
| producer 간 완전한 공유 clock 생성 | 아님 | producer 구조를 바꾼 것이 아니라 서버 합본 단계에서 입력 PTS를 wallclock 기준으로 보정 |
| 네트워크 지터/패킷 손실 자체 제거 | 아님 | 미디어 네트워크 품질 문제는 별도 관측/완화 대상 |
-use_wallclock_as_timestamps가 들어가는지 ffmpeg args 로그로 확인한다.first_pts=0, 아동 볼륨 저하 이력은 이 문서의 폐기된 1차 수정 기록과 PPI-1098 최종 해결로 흡수했다.