녹음 캡션 위치 어긋남 — TTS 모드 아동 캡션 누락 원인 분석 원인 규명 1차 원인 확정 ✅ 해결 (PR #786) ✅ 중첩 해결 (sidechain ducking) echo 가설 정정 드롭아웃 2차 원인 서버로그 대기

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

📅 분석 2026-06-24 · 업데이트 2026-06-25 🏷️ 녹음 caption.json · recordingManager · External STT · aresample 👤 아동064 2회기(고장) vs 아동251 11회기(정상) · 아동333 24회기(Realtime도 어긋남)

증상 (인테이크)

전체 녹음파일(mp3)에 핑퐁이/아동 발화가 섞여·중복 녹음되고, 녹음파일 캡션의 재생 위치가 맞지 않음.

특이점: 진행자측 "듣기 ON" 수업에선 정상 녹음되는 반면, "듣기 OFF + 진행자 타이핑" 수업에선 핑퐁/아동 중복 녹음 + 캡션 위치 어긋남.

자료: 두 회기의 녹음 caption.json 파일 직접 측정(고장 1건·정상 1건) + 코드 추적(web 게스트 + apps/socket recordingManager + STT 라우팅).

결론 (요약)

"듣기 토글"은 원인이 아니다 — 진짜 분기 변수는 STT 모드다. "듣기 OFF + 진행자 타이핑" 수업은 사실상 TTS/수동 + External STT 모드이고, 이 모드에서 아동(user) 발화 전사가 녹음 캡션에서 전부 누락된다. External STT는 아동 전사를 type:"text" / "stt_verified"로 보내는데, 서버 녹음 캡션 수집기는 type==="audio" 받기 때문이다. 누락된 아동 구간만큼 직전 핑퐁이 캡션의 endMs가 늘어나(최대 74초) 캡션 클릭/seek 위치가 실제 발화와 어긋난다.
2차 원인(서버 로그 필요): 고장 녹음은 wall-clock 19분 중 약 3.5분 분량 오디오가 전환 시 드롭아웃(중첩 아님 — 전환당 40~83초 누락)으로 빠져 더 끊겨/뒤섞여 들린다. 원인은 recordingManager 세그먼트 전환부로 추정, Loki 로그로 확정 필요.

🆕 2026-06-25 업데이트 — Realtime 모드도 어긋남 (별개 원인) · 수정 적용 PR #786

후속 케이스(아동333 24회기)에서 새 사실 확인: 이 녹음은 Realtime 모드라 아동(user) 캡션이 type:"audio"정상 존재(누락 아님)한다. 그런데도 캡션이 어긋났다. 즉 위 ②의 "아동 캡션 누락"과 독립적인 2차 원인이 있다 — 캡션 좌표 타임라인 vs 실제 mp3 길이 불일치.

캡션 좌표는 OGG granule 길이(recordingManager.ts buildAndUploadCaptionsprobeAudioDurationMsSafedurationMs/cumulativePriorMs) 기준인데, 이 값은 RTP DTX/무신호 구간을 포함한다. 하지만 배포 mp3는 transcode 시 그 갭이 붕괴(collapse)돼 실제 샘플만 남아 더 짧다. 결과적으로 mp3 재생 위치가 캡션 좌표보다 점점 앞서 어긋나고, 후반 캡션은 mp3 끝으로 클램프된다.

지표아동333 file1 (090719)아동333 file2 (090903)
caption.json durationMs (granule)195.0초86.1초
실제 mp3 길이 (ffprobe 전체 디코드)119.8초74.5초
차이 (붕괴된 무음)−75.1초−11.5초
mp3 내 0.8초↑ 무음0.0초 (음성으로 꽉 참)
role 분포 (모두 type:audio)assistant 15 · user 9assistant 6 · user 3

프론트(session-log-floating-player.tsx)는 audio.currentTime = Math.min(startMs/1000, audio.duration)로 seek → startMs가 119.8초를 넘는 후반 캡션은 전부 맨 끝으로 클램프된다.

방법론 교훈: 원래 분석(④)은 caption.json의 durationMs(Σdur)를 mp3 길이로 가정하고 실제 mp3를 ffprobe하지 않아 이 granule↔mp3 divergence가 가려졌다. mp3 길이는 항상 ffprobe/ffmpeg -f null로 직접 측정해 caption durationMs와 비교할 것.

echo 중첩(⑤-2) — Realtime에선 거의 없음

아동333(Realtime) mp3를 echo 자기상관 probe한 결과 139개 발화 윈도우 중 secondary-corr>0.3은 2개뿐(중앙값 0.08)으로 일관된 echo 시그니처 없음. ⑤-2 acoustic echo는 TTS 모드 특유 증상이라는 가설과 일치(Realtime은 원격 트랙이 AEC reference에 잡힘).

✅ 적용된 수정 (PR #786)

1. 캡션 타임라인 정합 (이 신규 원인 픽스). transcode에 aresample=async를 걸어 PTS 갭을 무음으로 채워 mp3 길이를 granule(=durationMs)에 맞춘다. 캡션 정합 + ⑤-3 "끊겨/뒤섞여 들림"도 동시 해소.
// apps/socket/src/recording/recordingManager.ts — transcodeToMp3
ffmpeg -i input.ogg \
  -af aresample=async=1:first_pts=0 \   // ← 추가: 갭을 무음으로 복원
  -ac 1 -ar 48000 -codec:a libmp3lame -b:a 128k out.mp3

합성 OGG 검증: 내부 3초 갭 단일 세그먼트 granule 10s → 필터 없으면 7s(붕괴)·필터 적용 10s(복원). 다중 세그먼트 concat granule 15s → 12s / 15s. 경계엔 무음 추가 안 됨(캡션 cumulativePriorMs가 transition 갭을 무시하는 것과 일치).

2. External STT 아동 캡션 누락 수정 (②의 핵심 픽스). session-handlers.tsbuildRecordingCaptionTurn 추가 → type:"audio" 외에 아동 stt_verified, text+recordingCaptionSource:"external_stt"도 캡션 적격. 순수 텍스트(진행자 개입)는 제외, 빈 content는 trim 가드. use-ai-session.ts는 시스템 지시문이 아닌 아동 실제 STT 텍스트를 recordingCaptionContent로 전달.

3. 마이크 음향 에코 방지 (⑤-2 픽스). local-media-constraints.ts(신규) 헬퍼로 마이크 캡처에 echoCancellation/noiseSuppression/autoGainControl: true 명시. use-local-media-stream.ts/use-ai-session.ts 적용.

머지 전 확인: ① 신규 캡션 정합은 dev에서 갭 있는 세션 1건 녹음해 durationMs와 mp3 ffprobe 길이 일치 확인. ③ echo(AEC) 실효는 TTS 모드 녹음 ear-check로 검증(브라우저/디바이스 의존). ④ 전환부 드롭아웃(40~83초)은 이 PR 범위 밖 — 별건 유지.

🔬 2026-06-25 (추가 검증) — "핑퐁이↔아동 섞임"은 echo 아님, 동시 발화 겹침 가설 정정

echoCancellation 픽스 배포 녹음(260625_022034_녹음_테스트_12_12)에서도 23초+부터 양방향 섞임(핑퐁이 턴에 아동 / 아동 턴에 핑퐁이)이 그대로 발생 → AEC로 안 잡힘. 이 사실이 echo 가설을 반증한다.

오디오 직접 분석 결과, 이 섞임은 ⑤-2의 acoustic echo가 아니다:

분석결과의미
동시 이중피치 — 저음(110~200Hz)+고음(270~400Hz)이 같은 프레임에 동시 존재23~31초 72프레임 중 39프레임서로 다른 기본주파수가 동시 → 물리적으로 다른 두 화자가 진짜 겹쳐 말함
echo 더블링 — 단lag 자기상관 2nd-peak0~22s 중앙 0.15 / 22~32s 중앙 0.10 (강한 echo는 0.5+)섞임 심한 구간이 오히려 더 낮음 → echo 아님
mp3 vs caption durationMs32.5s ≈ 32.5s타임라인 정합(단일 세그먼트) — 어긋남/드롭아웃 무관

echo는 한 목소리의 지연 복제라 새 피치를 만들 수 없다(110Hz가 echo돼도 110Hz). 두 피치가 동시에 잡힌다 = 진짜 동시 발화. 24.4~32.5s(약 8초)는 두 음성이 겹친 채 캡션도 거의 없다(stt_verified 1개).

⑤-2 정정: "TTS 이중출력 acoustic echo가 진짜 중복 원인"은 과대평가였다(당시 코드 경로만 확인된 "추정"). 적어도 이 케이스의 주원인은 echo가 아니라 동시 발화 겹침이며, echoCancellation 픽스가 배포 후에도 무효였던 것이 그 방증이다.

진짜 원인 — TTS 모드 끼어들기(barge-in) 미차단

서버 녹음은 항상 켜진 아동 마이크(childPair) + 핑퐁이 출력(aiPair)을 연속 amix한다. TTS 모드는 아동이 말해도 핑퐁이 발화를 중단(cancel/duck)하지 않아, 응답 재생 중 아동이 끼어들면(또는 반대) 두 트랙이 실제로 겹쳐 둘 다 녹음된다 → 양방향 섞임.

Realtime이 멀쩡한 이유: OpenAI turn-detection이 아동 발화 시 assistant 오디오를 즉시 취소해 겹침 자체가 안 생긴다. TTS엔 이 메커니즘이 없다.

당초 수정 방향(barge-in)은 폐기됨. "아동 발화 시 TTS 중단"으로 Realtime을 모사하려 했으나 — (1) 끼어들기 OFF 운영(핑퐁이가 끝까지 발화)과 모순이고, (2) dev 검증에서 안정적으로 동작하지 않았다. 최종 해결은 아래 "✅ 최종 해결" 참조.

✅ 최종 해결 — child-priority ducking (sidechaincompress) PR #786 적용

녹음 amix 단계에 아동 sidechain ducking을 적용해 해결. 아동(input0)을 sidechain으로 써서 핑퐁이 AI(input1)를 sidechaincompress로 자동 감쇠 → 아동이 말하는 동안 핑퐁이가 줄어 아동 우선 녹음이 된다. realtime/tts 양쪽 무중첩 확인 완료.
// recordingManager.ts startFfmpeg — child+ai 입력이 모두 있을 때의 filter_complex
[0:a]asplit=2[csc][cmix];
[1:a][csc]sidechaincompress=threshold=0.03:ratio=10:attack=20:release=250[aiduck];
[cmix][aiduck]amix=inputs=2:duration=longest:dropout_transition=2

왜 이 방식인가 (대안 비교):

방식결과판정
barge-in (TTS 중단)대화 변경·끼어들기 OFF와 모순·불안정폐기
stem 분리 + window ducking가능하나 recordingManager 대규모 리팩토링 + 스템 정렬 위험보류(과함)
sidechaincompress (라이브 ducking)filter_complex ~1줄, 정렬 문제 없음, 캡션 좌표 유지, 대화 안 바뀜채택 ✅

핵심 장점: 한 ffmpeg에서 동기 캡처되므로 "순서 안 맞음" 문제가 원천적으로 없고, 라이브 amix만 바꿔 캡션 좌표·대화 동작에 영향이 없다. echo가 약하다는 측정 덕에 sidechain의 오duck(echo로 인한) 위험도 미미.

합성 입력 검증: 아동 발화 구간에서 AI(800Hz) 성분 0.0312 → 0.0094 (약 −10dB ducking). 더 강하게 원하면 ratio↑/threshold↓로 튜닝.

남은 한계: ducking은 아동 에너지 기반이라 임계 아래 조용한 소리는 안 줄인다(대신 그 소리도 조용함). child 마이크에 섞인 핑퐁이 echo는 sidechain으로 못 지움(echoCancellation이 담당, 측정상 약함). ④ 전환부 드롭아웃은 별건.

① 결정적 증거 — caption.json role 분포 (직접 측정)

파일role 분포모드판정
아동064 2회기 (고장)
260624_095321_…_2회기_2
assistant 54 · user 0TTS / External STT아동 캡션 0개 🔴
아동251 11회기 (정상)
260624_095353_…_A10_11
assistant 71 · user 49Realtime양쪽 정상 ✅

고장 케이스는 19분 수업인데 아동(user) 캡션이 단 한 개도 없다. 아동이 말하지 않은 게 아니라(핑퐁이 대사가 "같이 찾아줄 수 있어?" 등 아동 응답을 전제), 아동 전사가 캡션으로 기록되지 않은 것이다.

그 결과 핑퐁이 캡션의 길이가 비정상적으로 부풀려진다 — 아동이 말한 (캡션 없는) 구간을 직전 핑퐁이 캡션의 endMs가 흡수하기 때문:

지표아동064(고장)아동251(정상)
평균 캡션 길이14.4초8.9초
15초 초과 캡션19 / 54 (35%)10 / 120 (8%)
최대 캡션 길이74.0초90.3초*

* 정상도 일부 긴 캡션은 있으나 user 캡션이 사이사이 끼어 경계를 잘라주므로 클릭 위치가 맞는다. 고장 케이스는 경계가 사라져 한 핑퐁이 캡션이 짧은 한 문장인데도 58.8초(예: "어? 저기 봐봐! 하준이가 분수대에…")로 늘어남.

② 코드 인과 체인 — 왜 아동 캡션이 누락되나

녹음 캡션 수집기는 type==="audio"인 TRANSCRIPT_UPDATE만 누적한다.

// apps/socket/src/sfu-socket/handlers/session-handlers.ts:47-73
// "text 타입은 실제 오디오가 없어 재생 좌표가 의미 없고 ▶ 버튼이 무음만 재생하게 되므로 제외."
const isCaptionEligible =
  !!transcript?.id &&
  typeof transcript.content === "string" &&
  transcript.type === "audio";   // ← user(text/stt_verified) 전부 탈락
if (isCaptionEligible) {
  recordingManager.appendCaptionTurn(roomId, { logId: transcript.id, role: transcript.role, ... });
}

그런데 External STT 모드는 아동 전사를 type:"audio"로 보내지 않는다:

// apps/web/entities/guest-session/model/use-ai-session.ts
// (A) waitForSttResponse=true (동기) — :611-624
const sttTranscript = { id: `user-stt-...`, role: "user", type: "text" as const, ... };
onTranscriptUpdateRef.current?.(sttTranscript);     // → 소켓 TRANSCRIPT_UPDATE, type=text

// (B) waitForSttResponse=false (비동기) — :687-696
saveSessionLog({ role: "user", type: "stt_verified", message: transcript, ... });

반면 Realtime 모드는 OpenAI Realtime의 input_audio_transcription.completed로 아동 전사를 type:"audio"로 emit → 캡션 적격 → 아동251 user 캡션 49개가 정상 기록된다.

인과 체인:

  1. 듣기 OFF + 진행자 타이핑 수업 = TTS/수동 → External STT 사용 (waitForSttResponse)
  2. External STT는 아동 전사를 type:"text" 또는 "stt_verified"로 emit
  3. 녹음 캡션 수집기가 type==="audio" 만 받음 → 아동 전사 전량 탈락
  4. caption.json에 핑퐁이(assistant)만 남음 (user 0개)
  5. 핑퐁이 캡션 endMs가 다음 핑퐁이 발화까지 확장 → 길이 부풀림
  6. → 캡션 클릭/seek 시 실제 발화 위치와 어긋남 = "캡션 위치 안 맞음"

③ "듣기 토글"은 녹음과 무관 — 인과 반전

통념: 진행자 "듣기 OFF"가 녹음을 망가뜨린다 → 틀림. 청취(듣기) 토글은 진행자 브라우저의 AudioContext suspend/resume일 뿐이고(session-audio-player.tsx), 녹음은 게스트→SFU의 childProducer/aiProducer를 PlainTransport로 직접 consume하므로 진행자 경로를 거치지 않는다. 모드 결정(use-ai-session.ts:1648 ttsMode = !forceRealtime && speechOutput.mode==="tts" && !!ttsVoice)에도 듣기/타이핑 입력은 전혀 없다.

즉 "듣기 OFF + 타이핑"과 "TTS 모드"의 동시 등장은 운영 패턴 상관일 뿐 코드 인과가 아니다(진행자가 수동으로 응답을 밀어넣는 TTS 운영 방식에서 듣기를 끄는 경향). 인과 변수는 STT 모드(Realtime vs External)다.

④ 2차 원인 — 대량 오디오 드롭아웃 (중첩 아님) ⚠️

고장 mp3는 세그먼트 단순 concatenation(durationMs == ΣaudioDurationMs)인데, wall-clock 대비 오디오가 통째로 빠진다:

지표아동064(고장)아동251(정상)
mp3 길이(Σdur)15.5분22.1분
wall-clock 경과약 21.2분22.6분
전환당 누락(gap−dur)40~83초약 1.7초
중첩(overlap) 세그먼트0건0건

정상 케이스는 전환마다 ~1.7초만 누락되지만, 고장 케이스는 seg3에서 83초·seg6 76초·seg15 60초 등 단일 전환에서 수십 초가 빠져 총 약 3.5분 분량이 녹음에서 사라졌다. 이 드롭아웃이 녹음을 뚝뚝 끊겨 들리게 하고 핑퐁이 캡션 endMs 부풀림에도 추가 기여한다.

※ 당초 의심한 "dual-spawn 중첩으로 길이 부풀림" 가설은 측정으로 반증됨(중첩 0건, 오히려 거대 공백). TTS 모드에서 ai-audio producer(TtsPlayer 스트림)가 발화 사이 장시간 무신호일 때 ffmpeg가 RTP를 못 받아 세그먼트가 짧게 끝나는 것으로 추정 — 서버 로그로 확정 필요.

⑤ 핑퐁이/아동 음성 중첩 녹음 — 원인 분석

"전체 녹음파일에 핑퐁/아동 발화가 섞여·중복 녹음됨"은 세 겹으로 나뉜다. 1번은 설계상 정상, 2번이 진짜 "중복" 원인, 3번이 체감 악화 요인이다.

1. 한 mp3에 둘 다 담기는 건 설계 (정상)

서버 녹음은 아동 마이크 트랙(childPair) + 핑퐁이 AI 트랙(aiPair)을 ffmpeg amix(duration=longest, dropout_transition=2)로 한 mp3에 합성한다. 두 화자가 동시에 말하면 그대로 겹쳐 녹음된다. 정상 케이스(아동251)도 동일 구조이므로 "둘 다 들리는 것" 자체는 버그가 아니다.

2. TTS 모드 acoustic echo → 핑퐁이가 두 번 인입 (진짜 "중복" 원인) ⚠️ 2026-06-25 정정

정정: 이 "acoustic echo가 중복의 진짜 원인"은 과대평가였다. 후속 검증(상단 "🔬 추가 검증" 섹션) 결과, 적어도 녹음 테스트 케이스의 주원인은 echo가 아니라 동시 발화 겹침(끼어들기 미차단)이었다.

TTS 모드에서 TtsPlayer는 핑퐁이 합성 오디오를 두 출력으로 동시 재생한다:

  • (a) MediaStreamDestination → ai-audio producer → 녹음 aiPair (tts-player.ts:437)
  • (b) localAudioElement(스피커) (tts-player.ts:471)
// tts-player.ts — 한 청크가 Web Audio(릴레이) + localAudioElement(스피커) 둘 다로 재생
this.currentLocalAudioEnded = !this.playObjectUrl(next.objectUrl, gen, metadata);  // (b) 스피커 :432
source.start();                                                       // (a) 릴레이 :437

// use-local-media-stream.ts:51-53 — V2 마이크 캡처에 echoCancellation 미명시
audio: audioDeviceId ? { deviceId: { exact: audioDeviceId } } : true   // AEC 브라우저 기본값 의존

V2 마이크 캡처가 echoCancellation을 명시하지 않아, 스피커(b)로 나간 핑퐁이가 아동 마이크로 다시 들어가 child 트랙에도 잡힌다.

→ 결과적으로 mp3에 핑퐁이가 두 경로로 인입된다: aiPair(깨끗한 핑퐁이) + childPair(echo된 핑퐁이). 둘 사이 미세한 시간차로 울림/중복처럼 들린다.

왜 Realtime(듣기 ON)에선 덜한가: Realtime은 핑퐁이 원격 트랙이 OpenAI PeerConnection을 거쳐 재생되어 브라우저 WebRTC AEC가 그걸 reference로 마이크에서 부분 상쇄하는 반면, TTS의 localAudioElement는 PC와 무관해 AEC reference에서 빠진다(추정). 두 모드 모두 스피커 재생은 하지만, AEC reference 여부가 갈린다.

3. 대량 드롭아웃으로 시간 압축 (체감 악화)

④의 드롭아웃(전환당 40~83초, 총 3.5분 누락)으로 서로 다른 시점의 발화가 mp3에서 바로 인접해 붙는다. 맥락이 끊긴 채 인접 재생되어 더 뒤섞여 들린다.

echo 실재 검증(2번): 코드상 경로는 확인됐으나, 실제 녹음에서 echo가 일어나는지는 디바이스(헤드셋 vs 스피커)·브라우저 AEC 동작에 좌우된다. 확정하려면 고장 mp3에서 핑퐁이 발화 중 아동이 말 안 하는 구간을 들어 child 트랙에 핑퐁이가 겹쳐 들리는지 확인하면 된다.

※ 별개 참고 — 당초 의심한 "dual-spawn 세그먼트 중첩으로 같은 오디오가 두 세그먼트에 중복 인코딩"은 측정으로 반증됐다(중첩 0건, 오히려 거대 공백 ④). 즉 "중첩 녹음"의 원인은 세그먼트 중복이 아니라 acoustic echo(2번)다.

수정 방향

✅ 적용됨 PR #786 — 상세·검증은 상단 "2026-06-25 업데이트" 참조.

핵심 픽스 — 녹음 캡션 수집 필터 확장. session-handlers.ts:50transcript.type === "audio" 조건이 External STT의 아동 전사("text"·"stt_verified")도 캡션 적격으로 받도록 해야 한다.
주의 — 원래 의도 보존: 주석("text는 오디오 없어 제외")은 실제 오디오가 없는 순수 텍스트(진행자 개입·시스템 메시지 등)를 캡션에서 빼려던 것이다. 따라서 단순히 type 검사를 없애지 말고, 실제 아동 음성이 존재하는 STT 전사(stt_verified/text)오디오 없는 순수 텍스트를 구분해 전자만 적격 처리해야 한다(예: role + source/relatedToLogId 기반 판별, 또는 STT 전사 전용 type 신설).

드롭아웃(④)은 별건 — recordingManager 세그먼트 전환/무신호 처리 조사가 필요하며, 캡션 필터 픽스와 분리해서 다룬다.

roomId / 추가 자료 (드롭아웃 확정용)

  • 고장 녹음 S3: prod/recordings/uuid-0652/2/260624_095321_두부핑퐁_시즌2_2회기_2.mp3 (userId id-045…, lessonIndex 2)
  • Loki: {service="ppi-socket"} |= "<roomId 또는 파일명>"transitionFfmpeg / replaceAiConsumer / ffmpeg 종료 / segment duration 이상을 보면 드롭아웃 원인 확정 가능.
  • LogRocket: [CAPTION-DIAG] TRANSCRIPT_UPDATE eligibility 로그에서 isCaptionEligible:false, role:user, type:text 확인 시 1차 원인이 로그로도 재확인됨.

관련 코드 참조

관련 문서: bugs/ffprobe-WARN-빈OGG세그먼트-캡션probe-비대칭-원인-분석.html · bugs/mediasoup-녹음-activity-전환-끊김-원인-분석.html · bugs/아동070-1회기-아동소리-안들림-원인분석.html