마지막 업데이트 2026-07-29
recorder.stop() 호출)임을 규명. recorder.stop()에
500ms 꼬리 마진을 도입해 수정. 로컬 적용
| 증상 | 오디오 분석 에이전트 nTurns 합본 재생 시 각 턴 끝부분 음성이 잘림 (user/assistant 양쪽, 매 턴 일관) |
| 대상 경로 | 세션 로그(/app/main/log) → 오디오 분석 컬럼 → S3 합본 WAV 재생 |
| 원인 | 발화-종료 신호(소켓 컨트롤 채널) 수신 즉시 recorder.stop() 호출 → 녹음 대상인 원격 WebRTC 스트림의 jitter buffer 꼬리가 트랙에 도달하기 전 정지 = 매 턴 꼬리 절단 |
| 범인 아님 | combineAudioBlobsToWav 합본·encodeWavMono 인코딩 (샘플 손실 없음, 코드로 검증) |
| 수정 파일 | apps/web/hooks/use-guest-audio-capture.ts |
| 수정 내용 | 종료 신호 후 STOP_TAIL_MARGIN_MS = 500 지연 stop, 마진 중 발화 재개 시 예약 취소(한 턴 유지), 타이머 정리 |
| 브랜치 | PPI-1082 (커밋 없이 로컬 적용) |
세션 로그의 오디오 분석 컬럼에서 nTurns 합본을 재생하면 각 턴의 끝부분이 잘려서 들린다는 VOC. user/assistant 양쪽에서, 그것도 매 턴 일관되게 잘렸다. 산발적 노이즈가 아니라 규칙적인 패턴 — 어딘가 구조적인 지점에서 매번 같은 방식으로 오디오가 사라지고 있다는 뜻이다. 수사를 위해 먼저 이 소리가 지나가는 경로 전체를 그렸다.
| 단계 | 위치 | 역할 |
|---|---|---|
| ① 캡처 | use-guest-audio-capture.ts | 발화 플래그(isUserSpeaking/isAiSpeaking)에 따라 role별 MediaRecorder 켜고 끔 (syncCapture→startCapture/stopCapture) |
| ② 턴 매칭 | turn-audio-selection.ts | chatLog 타임스탬프와 녹음 blob을 role·시각으로 매칭해 nTurns개 선별 |
| ③ 합본 | lib/wav.ts · use-audio-analysis-logger.ts | combineAudioBlobsToWav로 턴 사이 0.3초 무음을 넣어 단일 WAV로 묶어 S3 업로드 |
| ④ 재생 | 세션 로그 오디오 분석 컬럼 | S3 합본 WAV를 floating player로 재생 |
"여러 blob을 하나로 이어붙이는" 합본 단계가 가장 먼저 의심됐다. 이어붙이다 경계에서 샘플을 흘리는 고전적 수법이 있기 때문이다. 그래서 combineAudioBlobsToWav를 코드 레벨로 심문했다.
lib/wav.ts의 combineAudioBlobsToWav(80–112행)를 코드 레벨로 검증한 결과 샘플 손실이 없다:
decodeAudioData는 컨텍스트 길이(new OAC(1, 1, 16000)의 1 프레임)와 무관하게 디코드된 전체 길이의 AudioBuffer를 반환한다. 컨텍스트 길이는 startRendering()에만 영향.downmixToMono(52–60행)는 buf.length 전체를 복사.merged.set(m, off) 한 뒤 gap만 더한다.// combineAudioBlobsToWav — 각 턴 PCM 전체 + 0.3s gap. 누락 구간 없음 const gap = Math.round(rate * gapSec); monos.forEach((m, i) => { merged.set(m, off); // 턴 PCM 통째로 복사 off += m.length + (i < monos.length - 1 ? gap : 0); // 마지막 턴 뒤엔 gap 미추가 });
audioBlobToWav도 같은 decodeAudioData 경로라, 동일하게 잘린 입력을 받는다(LLM도 아동 발화 뒷부분을 못 듣게 됨).
합본은 받은 것을 통째로 이어붙일 뿐, 받은 blob 자체가 이미 잘려 있었다. 범행 현장은 합본보다 상류 — blob이 만들어지는 캡처 단계다. 수사망을 use-guest-audio-capture.ts로 옮겼다.
syncCapture는 발화 플래그가 false로 떨어지는 즉시 stopCapture→recorder.stop()를 동기 호출한다. 트레일링 마진(hangover)이 전혀 없다.
// (수정 전) use-guest-audio-capture.ts } else if (state.isCapturing) { stopCapture(role); // 종료 신호 즉시 stop — 꼬리 여유 0 }
host/monitor에서 녹음하는 트랙은 릴레이된 원격 스트림이다:
guestStream?.audioStream / aiAudioStream (monitor-dashboard/[group]/[roomId]/page.tsx:908-913)sessionRelay.sessionAudioStream / remoteVideoRef (host.tsx:559-565)반면 발화-종료 트리거는 소켓 컨트롤 채널 이벤트다 — use-monitor-session.ts:781(guest-speaking-stop) · :804(ai-speaking-stop).
recorder.stop() 호출 순간, 해당 턴의 꼬리 오디오는 아직 jitter buffer에 남아 녹음 트랙에 렌더되기 전이고 → 그 부분은 영영 녹음되지 않는다.
미디어 경로가 컨트롤 경로를 항상 일정하게 후행하므로 모든 턴에서 일관되게 끝부분이 잘린다.
"매 턴 일관되게 잘린다"는 초동 관찰이 여기서 완전히 설명된다. 컨트롤 신호는 항상 미디어보다 빠르므로, 잘림은 우연이 아니라 구조가 보장하는 결과였다. 다만 검거 전에 다른 용의자 하나를 마저 배제해야 했다 — 500ms 타임슬라이스.
recorder.start(500)의 500ms 타임슬라이스는 원인이 아니다. spec상 stop()이 마지막 미완 chunk를 flush한 뒤 onstop에서 blob을 만들기 때문(use-guest-audio-capture.ts:98-107). 즉 stop() 호출 시점까지의 데이터는 보존된다 — 문제는 그 호출 시점이 너무 이르다는 것.
recorder.stop()에 500ms 꼬리 마진 적용원인이 "stop 호출이 너무 이르다"이므로, 해법은 stop을 미디어 꼬리가 도착할 때까지 미루는 것이다. 단, 무작정 미루면 다음 발화 도입부를 침범할 수 있어 — 마진 중 발화가 재개되면 예약을 취소해 한 턴으로 잇는 안전장치를 함께 설계했다.
종료 신호 수신 후 곧바로 멈추지 않고 500ms 대기 후 stop한다. 마진 동안 같은 role 발화가 재개되면 예약을 취소해 끊김 없이 한 턴으로 잇는다.
interface RoleCaptureState {
recorder: MediaRecorder | null;
isCapturing: boolean;
stream: MediaStream | null;
stopTimer: ReturnType<typeof setTimeout> | null; // 꼬리 마진 stop 예약
}
// 발화-종료 신호는 소켓 컨트롤 채널로 오지만, 녹음 대상은 jitter buffer/네트워크로
// 지연되는 원격 WebRTC 스트림이다. 신호 즉시 stop하면 트랙에 도달 못한 꼬리가 잘리므로
// stop을 이만큼 지연시켜 in-flight 미디어 꼬리까지 녹음한다.
const STOP_TAIL_MARGIN_MS = 500;
const cancelScheduledStop = (role) => { const state = stateRefs.current[role]; if (state.stopTimer !== null) { clearTimeout(state.stopTimer); state.stopTimer = null; } }; // 예약 없이 즉시 끊는다(stream 교체·언마운트 등) const flushStop = (role) => { cancelScheduledStop(role); const state = stateRefs.current[role]; if (state.recorder && state.recorder.state !== "inactive") { state.recorder.stop(); state.recorder = null; } state.stream = null; state.isCapturing = false; }; // 종료 신호 후 꼬리 마진만큼 대기 후 정지. 마진 중 발화 재개 시 예약 취소되어 한 턴 유지 const scheduleStop = (role) => { const state = stateRefs.current[role]; if (state.stopTimer !== null) return; state.stopTimer = setTimeout(() => { stateRefs.current[role].stopTimer = null; flushStop(role); }, STOP_TAIL_MARGIN_MS); };
const syncCapture = (role, talking, stream) => {
const state = stateRefs.current[role];
if (talking) {
cancelScheduledStop(role); // 마진 중 재개 → 예약 취소, 한 턴으로 잇기
if (!state.isCapturing) {
startCapture(role, stream);
} else if (state.stream !== stream) {
stopCapture(role);
flushStop(role); // stream 교체는 즉시 끊고 재시작
startCapture(role, stream);
}
} else if (state.isCapturing) {
stopCapture(role); // 즉시 stop (꼬리 잘림)
scheduleStop(role); // 500ms 뒤 stop (꼬리 보존)
}
};
언마운트 cleanup에서도 stopTimer를 clearTimeout 후 recorder를 정지하도록 보강했다.
cancelScheduledStop(재개 시 예약 취소)으로 흡수된다.
stop을 500ms 미루는 수정은 턴 경계를 다루는 주변 로직에도 파장을 남긴다. 검거로 끝내지 않고, 지연 stop이 건드릴 수 있는 지점들을 마지막으로 점검했다.
endedAt(onstop 시점)이 ~500ms 늦춰진다. turn-audio-selection.ts는 startedAt~endedAt 중점으로 매칭하므로 영향은 경미하나, 매우 짧은 턴이 연속될 때 매칭 안정성 QA 권장.lib/wav.ts)은 무변경 — 입력 blob이 온전해지면 재생/분석 양쪽 모두 자동 개선.apps/web/hooks/use-guest-audio-capture.ts — 캡처/꼬리 마진 (수정)apps/web/hooks/turn-audio-selection.ts — 턴↔blob 매칭apps/web/lib/wav.ts — combineAudioBlobsToWav / audioBlobToWav (무결, 무변경)apps/web/hooks/use-audio-analysis-logger.ts — 합본 WAV S3 업로드apps/web/hooks/use-turn-analysis.ts — 분석 전송 파이프라인apps/web/entities/monitor-session/model/use-monitor-session.ts:781,804 — speaking-stop 소켓 이벤트