오디오 분석 nTurns 재생 — 턴 끝부분 잘림 원인 분석 + 꼬리 마진 수정

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

핑퐁이 세션 로그 오디오 분석 컬럼의 nTurns 합본 음성을 재생하면 각 턴 끝부분이 잘려서 들리는 VOC. 합본/인코딩 경로는 무결함을 코드로 확인하고, 진짜 원인은 캡처 종료 타이밍(발화-종료 신호가 원격 미디어보다 빨라 in-flight 꼬리 오디오가 녹음되기 전 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 (커밋 없이 로컬 적용)

1. 동작 흐름 (캡처 → 합본 → 재생)

단계위치역할
① 캡처use-guest-audio-capture.ts발화 플래그(isUserSpeaking/isAiSpeaking)에 따라 role별 MediaRecorder 켜고 끔 (syncCapturestartCapture/stopCapture)
② 턴 매칭turn-audio-selection.tschatLog 타임스탬프와 녹음 blob을 role·시각으로 매칭해 nTurns개 선별
③ 합본lib/wav.ts · use-audio-analysis-logger.tscombineAudioBlobsToWav로 턴 사이 0.3초 무음을 넣어 단일 WAV로 묶어 S3 업로드
④ 재생세션 로그 오디오 분석 컬럼S3 합본 WAV를 floating player로 재생

2. 합본 / 인코딩 경로는 정상 — 범인 배제

lib/wav.tscombineAudioBlobsToWav(80–112행)를 코드 레벨로 검증한 결과 샘플 손실이 없다:

// 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 미추가
});
→ 합본 단계에서 잘리는 것이 아니다. 합본의 입력 blob 자체가 이미 끝부분이 잘린 상태로 들어온다. 분석 전송용 audioBlobToWav도 같은 decodeAudioData 경로라, 동일하게 잘린 입력을 받는다(LLM도 아동 발화 뒷부분을 못 듣게 됨).

3. 진짜 원인 — 캡처 종료 타이밍 (꼬리 마진 부재 × 원격 스트림 지연)

3-1. 종료 신호 즉시 stop, 꼬리 여유 없음

syncCapture는 발화 플래그가 false로 떨어지는 즉시 stopCapturerecorder.stop()를 동기 호출한다. 트레일링 마진(hangover)이 전혀 없다.

// (수정 전) use-guest-audio-capture.ts
} else if (state.isCapturing) {
  stopCapture(role);   // 종료 신호 즉시 stop — 꼬리 여유 0
}

3-2. 녹음 대상은 지연되는 원격 WebRTC 스트림

host/monitor에서 녹음하는 트랙은 릴레이된 원격 스트림이다:

반면 발화-종료 트리거는 소켓 컨트롤 채널 이벤트다 — use-monitor-session.ts:781(guest-speaking-stop) · :804(ai-speaking-stop).

경로 desync: 컨트롤 신호(소켓)가 미디어 경로(WebRTC jitter buffer + 네트워크 지연)보다 먼저 도착한다. 그래서 recorder.stop() 호출 순간, 해당 턴의 꼬리 오디오는 아직 jitter buffer에 남아 녹음 트랙에 렌더되기 전이고 → 그 부분은 영영 녹음되지 않는다. 미디어 경로가 컨트롤 경로를 항상 일정하게 후행하므로 모든 턴에서 일관되게 끝부분이 잘린다.

3-3. 오해 방지 — 타임슬라이스는 원인이 아님

recorder.start(500)의 500ms 타임슬라이스는 원인이 아니다. spec상 stop()이 마지막 미완 chunk를 flush한 뒤 onstop에서 blob을 만들기 때문(use-guest-audio-capture.ts:98-107). 즉 stop() 호출 시점까지의 데이터는 보존된다 — 문제는 그 호출 시점이 너무 이르다는 것.

4. 수정 — 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;

② 즉시 정지(flushStop) / 지연 정지(scheduleStop) 분리

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);
};

③ syncCapture 분기 교체

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에서도 stopTimerclearTimeout 후 recorder를 정지하도록 보강했다.

마진값 500ms 선정: jitter buffer 지연을 대부분 흡수하면서 턴 사이 gap(0.3초)보다 약간 큰 보수적 균형값. 빠른 turn-taking 시 다음 발화 도입부와 겹칠 위험은 cancelScheduledStop(재개 시 예약 취소)으로 흡수된다.

5. 주의 · 후속

관련 코드

생성: 2026-06-29 · 브랜치 PPI-1082 · 로컬 적용(커밋 전)