마지막 업데이트 2026-07-29
TTS 모드 자체가 새 버그를 만든 것이 아니다. TTS의 긴 연속 발화 패턴이 서버 녹음 합본의 구조적 결함을 가장 크게 드러냈을 뿐이다. Realtime 모드는 스트림이 잘게 오고 턴 양보가 있어 같은 결함이 덜 노출됐다.
녹음 합본 260630_074100_29_29.mp3에서 약 33초 이후 아동과 핑퐁이의 소리가 겹쳐 들린다는 신고. 세션 후반으로 갈수록 오차가 누적되는 패턴이었다. 이상한 점이 하나 — 같은 흐름의 Realtime 모드 합본은 멀쩡했다. TTS 모드에서만 크게 들렸다.
① 브라우저의 TTS 이중 재생(로컬 출력이 별도 object URL 타임라인을 타는 위험) ② 서버 녹음 합본의 PTS 정렬. 첫 번째 용의자부터 조사했다 — TTS 로컬 출력을 단일 WebAudio 렌더 스트림으로 정리했다. 그런데도 후반 겹침이 재발했다. 용의자 ①은 알리바이가 성립했고, 수사망은 서버 합본으로 좁혀졌다.
| 관찰 | 내용 | 해석 |
|---|---|---|
| TTS 합본 후반 겹침 | 260630_074100_29_29.mp3에서 약 33초 이후 아동/핑퐁이 소리가 겹침 | 세션 후반으로 갈수록 타임라인 오차가 누적되는 패턴 |
| Realtime 합본은 정상 | 동일 흐름에서 Realtime 모드는 겹침이 관찰되지 않음 | 녹음 합본 구조 자체의 문제지만 TTS 발화 패턴에서만 크게 노출 |
| TTS 단일 렌더 수정 후에도 재발 | 로컬 출력 경로를 정리한 뒤에도 후반부 겹침 확인 | 브라우저 이중 재생만으로 설명 불가 → 서버 합본 PTS 정렬로 원인 범위 축소 |
서버는 mediasoup PlainTransport로 아동 마이크와 AI 오디오를 독립된 두 RTP 스트림으로 받아 한 ffmpeg에 넣는다. 두 producer는 공유 wall-clock anchor가 없다. 여기서 핵심 인과가 드러났다:
child/AI 두 입력이 모두 있을 때 각 입력에 -use_wallclock_as_timestamps 1을 적용해, 패킷 도착 벽시계 기준으로 PTS를 만들도록 했다. 무신호 구간이 실제 길이의 PTS 갭으로 남고 amix가 그 위치를 보존한다. 보조 수정으로 발화 종료 500ms tail margin(use-guest-audio-capture.ts)도 넣었다. 사건은 종결된 것처럼 보였다.
// 1차 수정 (6/30) — 이후 폐기됨 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 "...amix=inputs=2:duration=longest,asetpts=PTS-STARTPTS"
입력별 aresample=async는 쓰지 않았다 — burst 도착 시 hard 보정으로 샘플을 버려, 무음 뒤 발화가 빨리 재생되는 부작용이 있었다.
wallclock을 채우자 다른 증상이 나타났다. 발화 중인 입력으로 디먹스가 쏠려 조용한 입력의 재개 패킷을 늦게 읽고(read-starvation), 그 늦은 read 시각으로 스탬핑해 아동 턴이 뒤로 밀렸다 — 자기 슬롯은 무음이 되고 상대 발화에 겹쳤다. -thread_queue_size로도 해소되지 않았다. 그렇다고 wallclock을 빼면 원래의 무음 갭 붕괴가 돌아와 핑퐁이 연속 발화가 하나로 병합됐다(AI#2+AI#3).
| 방식 | 결과 |
|---|---|
-use_wallclock_as_timestamps 사용 | read-starvation → 아동 턴이 뒤로 밀려 자기 슬롯은 무음, 상대 발화에 겹침 |
| wallclock 없이 native | DTX 무음 갭 붕괴 → 핑퐁이 연속 발화가 하나로 병합 |
1차 검거는 오인이 아니라 부분 검거였다 — 무음 갭 붕괴 메커니즘 자체는 맞았다. 하지만 두 증상은 같은 뿌리에서 나온 시소였다: 한 ffmpeg가 갭 있는 두 라이브 RTP를 동시에 읽으며 정렬하려는 구조 자체. 옵션은 시소의 어느 쪽을 밟을지 고를 뿐, 시소를 없애지 못한다. 입력별 독립 리더(별도 ffmpeg 프로세스)만이 근본 해결이다.
-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.relatedToLogId 매핑).