마지막 업데이트 2026-07-22
전체 녹음파일(mp3)에 핑퐁이/아동 발화가 섞여·중복 녹음되고, 녹음파일 캡션의 재생 위치가 맞지 않음.
특이점: 진행자측 "듣기 ON" 수업에선 정상 녹음되는 반면, "듣기 OFF + 진행자 타이핑" 수업에선 핑퐁/아동 중복 녹음 + 캡션 위치 어긋남.
자료: 두 회기의 녹음 caption.json 파일 직접 측정(고장 1건·정상 1건) + 코드 추적(web 게스트 + apps/socket recordingManager + STT 라우팅).
캡션 좌표는 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 적용.
오디오 직접 분석 결과, 이 섞임은 ⑤-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엔 이 메커니즘이 없다.
// 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↓로 튜닝.
| 파일 | 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초(예: "어? 저기 봐봐! 하준이가 분수대에…")로 늘어남.
녹음 캡션 수집기는 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개가 정상 기록된다.
인과 체인:
즉 "듣기 OFF + 타이핑"과 "TTS 모드"의 동시 등장은 운영 패턴 상관일 뿐 코드 인과가 아니다(진행자가 수동으로 응답을 밀어넣는 TTS 운영 방식에서 듣기를 끄는 경향). 인과 변수는 STT 모드(Realtime vs External)다.
고장 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번이 체감 악화 요인이다.
서버 녹음은 아동 마이크 트랙(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번)다.
드롭아웃(④)은 별건 — recordingManager 세그먼트 전환/무신호 처리 조사가 필요하며, 캡션 필터 픽스와 분리해서 다룬다.
관련 문서: bugs/ffprobe-WARN-빈OGG세그먼트-캡션probe-비대칭-원인-분석.html · bugs/mediasoup-녹음-activity-전환-끊김-원인-분석.html · bugs/아동070-1회기-아동소리-안들림-원인분석.html