두 번 잡은 범인 — TTS 녹음 합본 겹침 사건 원인 확정 최종 해결(PPI-1098)

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

두 번 잡은 범인 — TTS 녹음 합본 겹침 사건 원인 확정 최종 해결(P…: 증상: 결론 — 범인부터 공개한다, 원인: 수사 일지 ③ — 재수사: 잡았더니 다른 범행이 시작됐다 (7/3), 수정·검증: 검증 포인트 흐름
동작 흐름 요약
  1. 증상: 결론 — 범인부터 공개한다
  2. 원인: 수사 일지 ③ — 재수사: 잡았더니 다른 범행이 시작됐다 (7/3)
  3. 수정·검증: 검증 포인트
작성일: 2026-07-29 (원본: 2026-06-30 / 최종 해결 2026-07-03) 관련 PR: #822 · 커밋 37d164d4, 7a135092 영역: recordingManager (서버측)
🧪 포맷 파일럿 문서 (채택 완료) — 수사반장 단독 모드 (웹툰·비유 요약 없이 서사 재구성만 적용한 최저 비용 기본형. 이 모드가 bugs 문서 표준으로 채택돼 2026-07-29 전체 반영됨). 내용은 원본 분석 문서와 동일하다. 원본은 "최종 해결이 위, 폐기된 1차 수정이 아래" 역순 구성인데, 이 파일럿은 수사 시간순으로 재배열해 1차 수정이 왜 나왔고 왜 부족했는지가 서사로 이어지는지 검증한다.

결론 — 범인부터 공개한다

진범은 ffmpeg 옵션이 아니라 구조였다 — 한 ffmpeg 프로세스가 갭 있는(턴제 무음) 두 라이브 RTP 입력을 동시에 읽으며 정렬하려는 것 자체. wallclock 옵션을 켜면 무음 갭은 보존되지만 read-starvation으로 아동 턴이 뒤로 밀리고, 끄면 밀림은 없지만 무음 갭이 붕괴해 핑퐁이 연속 발화가 병합된다. 옵션으로는 시소의 어느 쪽도 내려놓을 수 없었고, 최종 해결은 입력별 독립 ffmpeg 기록 + 후처리 정렬 믹스(PPI-1098)다.

TTS 모드 자체가 새 버그를 만든 것이 아니다. TTS의 긴 연속 발화 패턴이 서버 녹음 합본의 구조적 결함을 가장 크게 드러냈을 뿐이다. Realtime 모드는 스트림이 잘게 오고 턴 양보가 있어 같은 결함이 덜 노출됐다.

수사 일지 ① — 사건 발생과 초동 수사

6/30 — 신고 접수

녹음 합본 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 정렬로 원인 범위 축소

수사 일지 ② — 1차 검거: 무음 갭 붕괴 (6/30)

범행 수법의 복원

서버는 mediasoup PlainTransport로 아동 마이크와 AI 오디오를 독립된 두 RTP 스트림으로 받아 한 ffmpeg에 넣는다. 두 producer는 공유 wall-clock anchor가 없다. 여기서 핵심 인과가 드러났다:

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

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 보정으로 샘플을 버려, 무음 뒤 발화가 빨리 재생되는 부작용이 있었다.

수사 일지 ③ — 재수사: 잡았더니 다른 범행이 시작됐다 (7/3)

시소의 발견

wallclock을 채우자 다른 증상이 나타났다. 발화 중인 입력으로 디먹스가 쏠려 조용한 입력의 재개 패킷을 늦게 읽고(read-starvation), 그 늦은 read 시각으로 스탬핑해 아동 턴이 뒤로 밀렸다 — 자기 슬롯은 무음이 되고 상대 발화에 겹쳤다. -thread_queue_size로도 해소되지 않았다. 그렇다고 wallclock을 빼면 원래의 무음 갭 붕괴가 돌아와 핑퐁이 연속 발화가 하나로 병합됐다(AI#2+AI#3).

방식결과
-use_wallclock_as_timestamps 사용read-starvation → 아동 턴이 뒤로 밀려 자기 슬롯은 무음, 상대 발화에 겹침
wallclock 없이 nativeDTX 무음 갭 붕괴 → 핑퐁이 연속 발화가 하나로 병합
진범의 정체

1차 검거는 오인이 아니라 부분 검거였다 — 무음 갭 붕괴 메커니즘 자체는 맞았다. 하지만 두 증상은 같은 뿌리에서 나온 시소였다: 한 ffmpeg가 갭 있는 두 라이브 RTP를 동시에 읽으며 정렬하려는 구조 자체. 옵션은 시소의 어느 쪽을 밟을지 고를 뿐, 시소를 없애지 못한다. 입력별 독립 리더(별도 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 유지).

사건이 남긴 것

옵션 튜닝으로 시소가 멈추지 않으면 구조를 의심하라. 한 옵션을 켜면 증상 A가, 끄면 증상 B가 나타나는 트레이드오프는 두 증상이 같은 구조적 뿌리를 공유한다는 신호다. 이 사건에서 그 뿌리는 "한 리더가 갭 있는 두 라이브 스트림을 동시에 정렬"하는 설계였고, 해답은 옵션이 아니라 리더 분리였다.

검증 포인트

  • 긴 TTS 발화 뒤 아동이 말하는 시나리오를 녹음하고, 후반부에서 아동 발화가 AI 꼬리로 당겨지지 않는지 ear-check한다.
  • 입력별 stem OGG가 각각 별도 ffmpeg 프로세스로 기록되는지, stem에 절대 start_time이 보존되는지 확인한다.
  • producer 교체 시 새 stem 세그먼트가 생기고 경계에서 증폭·에코가 없는지 확인한다.
  • 세션 로그 오디오 분석 컬럼은 assistant audio 로그에만 표시되고 user audio에는 붙지 않는지 확인한다(같은 작업 범위에서 정리된 relatedToLogId 매핑).

관련 문서