마지막 업데이트 2026-07-22
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 (커밋 없이 로컬 적용) |
| 단계 | 위치 | 역할 |
|---|---|---|
| ① 캡처 | 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로 재생 |
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도 아동 발화 뒷부분을 못 듣게 됨).
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에 남아 녹음 트랙에 렌더되기 전이고 → 그 부분은 영영 녹음되지 않는다.
미디어 경로가 컨트롤 경로를 항상 일정하게 후행하므로 모든 턴에서 일관되게 끝부분이 잘린다.
recorder.start(500)의 500ms 타임슬라이스는 원인이 아니다. spec상 stop()이 마지막 미완 chunk를 flush한 뒤 onstop에서 blob을 만들기 때문(use-guest-audio-capture.ts:98-107). 즉 stop() 호출 시점까지의 데이터는 보존된다 — 문제는 그 호출 시점이 너무 이르다는 것.
recorder.stop()에 500ms 꼬리 마진 적용종료 신호 수신 후 곧바로 멈추지 않고 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(재개 시 예약 취소)으로 흡수된다.
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 소켓 이벤트