마지막 업데이트 2026-07-29
공범도 있었다. 재수사(6/25)에서 Realtime 모드도 어긋나는 별개 원인(OGG granule vs mp3 길이 불일치)이 추가로 검거됐고, "핑퐁이↔아동 섞임"의 주범으로 지목했던 acoustic echo는 위증으로 판명 — 진범은 동시 발화 겹침(끼어들기 미차단)이었으며 sidechain ducking으로 해결됐다(PR #786). 아래는 그 수사 전 과정을 시간순으로 기록한 것이다.
녹음 mp3가 이상하다는 신고가 들어왔다. 두 화자가 섞여·중복으로 들리고 캡션을 클릭하면 엉뚱한 위치로 간다. 결정적 단서 하나 — "듣기 ON" 수업은 멀쩡한데 "듣기 OFF + 진행자 타이핑" 수업만 고장이라는 것. 자연히 첫 용의자로 "듣기 토글"이 지목됐다.
전체 녹음파일(mp3)에 핑퐁이/아동 발화가 섞여·중복 녹음되고, 녹음파일 캡션의 재생 위치가 맞지 않음.
특이점: 진행자측 "듣기 ON" 수업에선 정상 녹음되는 반면, "듣기 OFF + 진행자 타이핑" 수업에선 핑퐁/아동 중복 녹음 + 캡션 위치 어긋남.
자료: 두 회기의 녹음 caption.json 파일 직접 측정(고장 1건·정상 1건) + 코드 추적(web 게스트 + apps/socket recordingManager + STT 라우팅).
소문이 아니라 물증부터 확보했다. 고장 회기(아동064 2회기)와 정상 회기(아동251 11회기)의 caption.json을 직접 열어 role 분포를 세어 보니, 고장 케이스에서 있어야 할 것이 통째로 없었다 — 아동(user) 캡션 0개.
| 파일 | role 분포 | 모드 | 판정 |
|---|---|---|---|
| 아동064 2회기 (고장) 260624_095321_…_2회기_2 | assistant 54 · user 0 | TTS / External STT | 아동 캡션 0개 🔴 |
| 아동251 11회기 (정상) 260624_095353_…_A10_11 | assistant 71 · user 49 | Realtime | 양쪽 정상 ✅ |
고장 케이스는 19분 수업인데 아동(user) 캡션이 단 한 개도 없다. 아동이 말하지 않은 게 아니라(핑퐁이 대사가 "같이 찾아줄 수 있어?" 등 아동 응답을 전제), 아동 전사가 캡션으로 기록되지 않은 것이다.
그 결과 핑퐁이 캡션의 길이가 비정상적으로 부풀려진다 — 아동이 말한 (캡션 없는) 구간을 직전 핑퐁이 캡션의 endMs가 흡수하기 때문:
| 지표 | 아동064(고장) | 아동251(정상) |
|---|---|---|
| 평균 캡션 길이 | 14.4초 | 8.9초 |
| 15초 초과 캡션 | 19 / 54 (35%) | 10 / 120 (8%) |
| 최대 캡션 길이 | 74.0초 | 90.3초* |
* 정상도 일부 긴 캡션은 있으나 user 캡션이 사이사이 끼어 경계를 잘라주므로 클릭 위치가 맞는다. 고장 케이스는 경계가 사라져 한 핑퐁이 캡션이 짧은 한 문장인데도 58.8초(예: "어? 저기 봐봐! 하준이가 분수대에…")로 늘어남.
"user 캡션 0개"라는 물증은 캡션 수집기를 가리켰다. 코드를 따라가 보니 수집기의 입장 조건과 External STT의 발송 형식이 어긋나 있었다 — 문은 type==="audio"만 여는데, External STT는 그 문으로 들어오지 않는다.
녹음 캡션 수집기는 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개가 정상 기록된다.
인과 체인:
진범(STT 모드)이 잡혔으니 첫 용의자의 알리바이를 확인할 차례. 신고 때 지목된 "듣기 토글"은 코드 경로상 녹음에 손을 댈 수 없는 위치에 있었다.
즉 "듣기 OFF + 타이핑"과 "TTS 모드"의 동시 등장은 운영 패턴 상관일 뿐 코드 인과가 아니다(진행자가 수동으로 응답을 밀어넣는 TTS 운영 방식에서 듣기를 끄는 경향). 인과 변수는 STT 모드(Realtime vs External)다.
캡션 누락은 "위치 어긋남"을 설명하지만, 녹음이 뚝뚝 끊겨 들리는 것까지 설명하진 못했다. 세그먼트 시각을 wall-clock과 대조하자 별건의 여죄가 드러났다 — 오디오가 통째로 사라지고 있었다.
고장 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를 못 받아 세그먼트가 짧게 끝나는 것으로 추정 — 서버 로그로 확정 필요.
"섞여·중복 녹음"이라는 신고 내용도 그대로 둘 수 없었다. 하나의 증상처럼 보였지만 뜯어 보니 세 겹이었다 — 설계상 정상인 것, 진짜 중복을 만드는 것(당시엔 echo로 추정), 체감을 악화시키는 것. 이 중 2번(echo)은 뒤의 재수사(일지 ⑨)에서 정정된다.
"전체 녹음파일에 핑퐁/아동 발화가 섞여·중복 녹음됨"은 세 겹으로 나뉜다. 1번은 설계상 정상, 2번이 진짜 "중복" 원인, 3번이 체감 악화 요인이다.
서버 녹음은 아동 마이크 트랙(childPair) + 핑퐁이 AI 트랙(aiPair)을 ffmpeg amix(duration=longest, dropout_transition=2)로 한 mp3에 합성한다. 두 화자가 동시에 말하면 그대로 겹쳐 녹음된다. 정상 케이스(아동251)도 동일 구조이므로 "둘 다 들리는 것" 자체는 버그가 아니다.
TTS 모드에서 TtsPlayer는 핑퐁이 합성 오디오를 두 출력으로 동시 재생한다:
// 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된 핑퐁이). 둘 사이 미세한 시간차로 울림/중복처럼 들린다.
수사 일지 ⑤의 드롭아웃(전환당 40~83초, 총 3.5분 누락)으로 서로 다른 시점의 발화가 mp3에서 바로 인접해 붙는다. 맥락이 끊긴 채 인접 재생되어 더 뒤섞여 들린다.
※ 별개 참고 — 당초 의심한 "dual-spawn 세그먼트 중첩으로 같은 오디오가 두 세그먼트에 중복 인코딩"은 측정으로 반증됐다(중첩 0건, 오히려 거대 공백 — 수사 일지 ⑤). 즉 "중첩 녹음"의 원인은 세그먼트 중복이 아니라 acoustic echo(2번)다.
1차 원인(캡션 필터)이 확정된 시점에서 수정 방향을 세웠다. 이 계획은 다음 날 PR #786으로 실제 집행된다(수사 일지 ⑧).
드롭아웃(수사 일지 ⑤)은 별건 — recordingManager 세그먼트 전환/무신호 처리 조사가 필요하며, 캡션 필터 픽스와 분리해서 다룬다.
사건이 종결되기 전에 후속 케이스(아동333 24회기)가 들어왔다. 이 녹음은 Realtime 모드 — 즉 아동 캡션이 정상 존재하는데도 어긋났다. 1차 범인(캡션 누락)으로는 설명할 수 없는 새 범행. 독립적인 2차 원인을 추적해야 했다.
캡션 좌표는 OGG granule 길이(recordingManager.ts buildAndUploadCaptions의 probeAudioDurationMsSafe → durationMs/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 9 | assistant 6 · user 3 |
프론트(session-log-floating-player.tsx)는 audio.currentTime = Math.min(startMs/1000, audio.duration)로 seek → startMs가 119.8초를 넘는 후반 캡션은 전부 맨 끝으로 클램프된다.
아동333(Realtime) mp3를 echo 자기상관 probe한 결과 139개 발화 윈도우 중 secondary-corr>0.3은 2개뿐(중앙값 0.08)으로 일관된 echo 시그니처 없음. ⑤-2 acoustic echo는 TTS 모드 특유 증상이라는 가설과 일치(Realtime은 원격 트랙이 AEC reference에 잡힘).
// 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.ts에 buildRecordingCaptionTurn 추가 → 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 적용.
echoCancellation 픽스를 배포했는데도 새 녹음에서 섞임이 그대로 재발했다. 잡은 줄 알았던 echo는 범인이 아니었다는 뜻. 오디오를 직접 뜯어 위증을 가려내기로 했다.
오디오 직접 분석 결과, 이 섞임은 ⑤-2의 acoustic echo가 아니다:
| 분석 | 결과 | 의미 |
|---|---|---|
| 동시 이중피치 — 저음(110~200Hz)+고음(270~400Hz)이 같은 프레임에 동시 존재 | 23~31초 72프레임 중 39프레임 | 서로 다른 기본주파수가 동시 → 물리적으로 다른 두 화자가 진짜 겹쳐 말함 |
| echo 더블링 — 단lag 자기상관 2nd-peak | 0~22s 중앙 0.15 / 22~32s 중앙 0.10 (강한 echo는 0.5+) | 섞임 심한 구간이 오히려 더 낮음 → echo 아님 |
| mp3 vs caption durationMs | 32.5s ≈ 32.5s | 타임라인 정합(단일 세그먼트) — 어긋남/드롭아웃 무관 |
echo는 한 목소리의 지연 복제라 새 피치를 만들 수 없다(110Hz가 echo돼도 110Hz). 두 피치가 동시에 잡힌다 = 진짜 동시 발화. 24.4~32.5s(약 8초)는 두 음성이 겹친 채 캡션도 거의 없다(stt_verified 1개).
서버 녹음은 항상 켜진 아동 마이크(childPair) + 핑퐁이 출력(aiPair)을 연속 amix한다. TTS 모드는 아동이 말해도 핑퐁이 발화를 중단(cancel/duck)하지 않아, 응답 재생 중 아동이 끼어들면(또는 반대) 두 트랙이 실제로 겹쳐 둘 다 녹음된다 → 양방향 섞임.
Realtime이 멀쩡한 이유: OpenAI turn-detection이 아동 발화 시 assistant 오디오를 즉시 취소해 겹침 자체가 안 생긴다. TTS엔 이 메커니즘이 없다.
진범(동시 발화 겹침)의 수법을 알았으니 남은 건 수갑의 선택. barge-in은 운영 정책과 모순돼 폐기했고, stem 분리는 과했다. 답은 녹음 믹스 단계에서 아동을 우선하는 라이브 ducking이었다.
// 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↓로 튜닝.
여죄(드롭아웃)의 확정에는 서버 로그가 필요하다. 후임 수사관을 위한 단서 목록.
관련 문서: bugs/ffprobe-WARN-빈OGG세그먼트-캡션probe-비대칭-원인-분석.html · bugs/mediasoup-녹음-activity-전환-끊김-원인-분석.html · bugs/아동070-1회기-아동소리-안들림-원인분석.html