오디오 분석 에이전트 — 통합 코드 레벨 문서

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

오디오 분석 에이전트 STT 오인식 검증 멀티턴 (nTurns) TurnAnalysisCore MediaRecorder 링버퍼 gpt-4o-audio / Gemini {핑퐁이발화} 치환 audio_analysis 세션로그 PPI-1006 2026-06-23 업데이트

TL;DR

오디오 분석 에이전트는 수업 중 핑퐁이/아동의 실제 발화 오디오 Blob을 턴 단위로 녹음하고, 분석 대상 아동 발화 기준 최근 nTurns개 오디오 턴을 LLM(gpt-4o-audio / Gemini)에 보내 STT 오인식 가능성을 판정한다. 예를 들어 nTurns=2이고 분석 대상이 아동 답변이면 합본 범위는 핑퐁 → 아동이다. 운영자가 만든 에이전트(프롬프트·모델·턴 수)를 진행자가 자기 브라우저에서 활성화해 두면, 별도 조작 없이 턴마다 자동으로 돈다.

코드는 5단계 파이프라인이다 — ① 오디오 캡처(링버퍼) → ② 턴 감지·중복 제거(TurnAnalysisCore) → ③ 분석 요청(/api/audio-analysis/analyze) → ④ 결과 표시(모달) → ⑤ 영속화(S3 + audio_analysis 세션 로그). 이 문서는 각 단계를 파일:라인 칩으로 짚어가며 코드에서 직접 따라갈 수 있게 한다.

한눈에 보는 파이프라인

1

오디오 캡처 — 핑퐁/아동 발화를 role 포함 Blob 링버퍼로

hooks/use-guest-audio-capture.ts · isUserSpeaking/isAiSpeaking에 반응하는 MediaRecorder

아동과 핑퐁이 발화를 각각 role(user/assistant)·시각 메타와 함께 녹음한다. getRecentBlobs(n, chatLogs, targetLogId)가 분석 대상 턴 기준으로 실제 Blob이 모두 있는 경우에만 반환한다.

2

턴 감지 & 중복 제거 — 언제 분석을 쏠지 결정

hooks/turn-analysis-core.ts + hooks/use-turn-analysis.ts

채팅 로그를 ingest()하며 assistant 도착(직전 user 분석) 또는 8초 무응답을 트리거로 잡고, seenIds/targeted로 같은 발화를 두 번 분석하지 않게 막음.

3

분석 요청 — 오디오 + 전사를 LLM에 비교 분석

app/api/audio-analysis/analyze/route.ts · provider 분기(Gemini / OpenAI)

최근 n턴 Blob을 base64로 변환해 전송. 시스템 프롬프트의 {핑퐁이발화}를 실제 AI 발화로 치환. JSON(probability/reason/…)로 응답.

4

결과 표시 — 진행자에게 모달 경고 (V1 호스트)

components/pages/host.tsx:578 · onResult 콜백

오인식 가능성(🔴/🟡/🟢)·사유·추정 발화를 모달로 표시. (V2 세션카드는 onResult 미연결 — 로그만 남김)

5

영속화 — 오디오는 S3, 메타+결과는 세션 로그

hooks/use-audio-analysis-logger.ts/api/audio-analysis-logs/upload-url + /api/session-logs

합친 오디오를 presigned PUT으로 S3 업로드, 결과 JSON을 type:"audio_analysis" 세션 로그로 저장(audioAnalysisS3Key 포함). 세션 로그 뷰어에서 재생·다운로드.

등장 인물 (모듈 맵)

관리 (운영자)

  • audio-analysis-agents-section.tsx — CRUD UI
  • use-audio-analysis-agents.ts — CRUD 훅
  • /api/audio-analysis-agents

런타임 (진행자)

  • use-guest-audio-capture.ts — 캡처
  • turn-analysis-core.ts — 트리거 로직
  • use-turn-analysis.ts — 오케스트레이션

분석 (서버)

  • /api/audio-analysis/analyze
  • Gemini generateContent
  • OpenAI chat/completions

영속화 (서버)

  • use-audio-analysis-logger.ts
  • /api/audio-analysis-logs/*
  • session-log.tsx — 뷰어

0. 데이터 모델 — 무엇이 흐르는가

먼저 두 타입만 머리에 넣으면 전체가 읽힌다. apps/web/types/db/audio-analysis-agent.types.ts

// 운영자가 등록하는 에이전트 정의 (DynamoDB chatPreset 테이블에 저장)
interface AudioAnalysisAgent {
  userId: string;        // 파티션 키 = "AUDIO_ANALYSIS_AGENT" (AUDIO_ANALYSIS_AGENT_KEY)
  id: string;
  name: string;
  provider: "gemini" | "openai";
  model: string;         // 예: gpt-4o-audio-preview, gemini-2.5-flash
  systemPrompt: string;  // {핑퐁이발화} 변수 사용 가능
  nTurns: number;       // 분석에 포함할 최근 아동 발화 턴 수 (1~5)
  order?: number;
}

// LLM이 돌려주는 분석 결과 (JSON)
interface TurnAnalysisResult {
  probability: "high" | "medium" | "low";  // 오인식 가능성
  reason: string;            // 판단 사유 (한국어)
  transcript?: string;       // 오디오에서 실제로 들린 내용
  heardContent?: string;     // AI가 인식한 내용
  estimatedActual?: string;  // 실제 발화 추정
  error?: string;
}

저장 위치 트릭 — 에이전트는 별도 테이블이 아니라 채팅 프리셋 테이블에 userId = "AUDIO_ANALYSIS_AGENT"라는 고정 파티션 키로 묶여 저장된다. lib/db-queries.ts:657 getAudioAnalysisAgents() 가 이 키로 Query → order로 정렬한다.

1. 관리 UI — 에이전트 만들고 "내 브라우저에서" 켜기

운영자 화면은 components/sections/audio-analysis-agents-section.tsx:204. CRUD는 entities/audio-analysis-agent/model/use-audio-analysis-agents.ts 훅이 /api/audio-analysis-agents(목록·생성), /api/audio-analysis-agents/[id](수정·삭제)를 호출한다.

활성화는 DB가 아니라 localStorage

중요한 포인트 — 어느 에이전트를 쓸지는 서버에 저장되지 않는다. audio-analysis-agents-section.tsx:282 handleActivate()localStorage["ppi_active_analysis_agent_id"] 한 칸만 토글한다(토글식, 같은 걸 누르면 해제).

const LS_ACTIVE_AGENT = "ppi_active_analysis_agent_id";
const handleActivate = (id) => {
  const newId = activeAgentId === id ? "" : id;  // 다시 누르면 OFF
  newId ? localStorage.setItem(LS_ACTIVE_AGENT, newId)
        : localStorage.removeItem(LS_ACTIVE_AGENT);
};

그래서 활성 에이전트는 진행자 PC·브라우저 단위다. 같은 키를 런타임에서 use-turn-analysis.ts:10 getActiveAgentId()가 읽어 매 분석마다 에이전트를 고른다. 활성이 없으면 분석은 그냥 일어나지 않는다.

nTurns 시각화 — 분석 대상 아동 발화 기준 최근 N개 오디오 턴

audio-analysis-agents-section.tsx:80 TurnPreview분석 대상 아동 발화로 끝나는 최근 n개 오디오 턴을 보여준다. 새 핑퐁 응답은 분석을 트리거할 뿐 합본 범위에는 포함되지 않는다.

예) nTurns=2 · 분석 대상 = 아동 답변

핑퐁 질문 아동 답변 새 핑퐁 응답(트리거)

이때 실제 LLM과 audio_analysis 합본 WAV에 들어가는 오디오는 핑퐁 질문 Blob → 아동 답변 Blob 2개다. {핑퐁이발화} 변수도 새 응답이 아니라 아동 답변 직전의 핑퐁 질문으로 치환된다.

2. 런타임 진입점 — 어디서 켜지나

같은 3개 훅을 V1 호스트와 V2 세션카드가 동일하게 엮는다.

진입점배선특징
V1 호스트
components/pages/host.tsx:558~592
useGuestAudioCapture + useAudioAnalysisLogger + useTurnAnalysis onResult 연결 → 모달 표시, isPeerTalking = sessionRelay.isPeerTalking
V2 세션카드
features/session/ui/session-card.tsx:600~616
동일 3종 onResult 미연결(로그만), isPeerTalking = monitorSession.isSpeaking
// host.tsx:558 — 3개 훅이 데이터로 연결된다
const { getRecentBlobs } = useGuestAudioCapture({ remoteVideoRef, isPeerTalking: sessionRelay.isPeerTalking });

const { logAnalysis } = useAudioAnalysisLogger(  // userId/lessonIndex/activityId 컨텍스트
  selectedUserId && selectedLessonIndex ? { userId, lessonIndex, getActivityId } : null);

const { analysisResults } = useTurnAnalysis({
  agents: audioAnalysisAgents,        // 활성 후보 목록(localStorage로 1개 선택)
  getRecentBlobs,                     // ← 단계 1이 제공
  chatLogs,                           // ← 단계 2의 입력
  isSessionActive: sessionRelay.isActive,
  onResult: (_logId, result) => { setAnalysisAlert(...) },  // ← 단계 4 (V1만)
  onLogData: logAnalysis,             // ← 단계 5
});

3. 단계 ① 오디오 캡처 — role/time 기반 턴 Blob 링버퍼

hooks/use-guest-audio-capture.ts. 핵심은 핑퐁이와 아동 발화를 모두 TurnAudioBlob으로 기록하되, 최종 합본에는 실제 녹음 Blob이 매칭된 턴만 넣는 것이다.

// 한 턴 녹음이 끝날 때 role/time 메타와 함께 보관
turnAudioBlobsRef.current = [
  ...turnAudioBlobsRef.current,
  { role, blob, startedAt, endedAt: Date.now() },
].slice(-Math.max(maxTurns * 3, maxTurns));
부분 합본 방지. nTurns=3이면 기대 오디오 턴 3개 모두에 실제 Blob이 매칭되어야 한다. 하나라도 부족하면 getRecentBlobs()[]를 반환해 분석 API 호출과 audio_analysis WAV 저장을 건너뛴다. 1~2턴짜리 부분 WAV를 남기지 않는다.

4. 단계 ② 턴 감지 & 중복 제거 — TurnAnalysisCore

가장 머리를 써야 하는 부분. 순수 로직은 React와 분리되어 hooks/turn-analysis-core.ts에 클래스로 있고(테스트는 turn-analysis-core.test.ts), use-turn-analysis.ts가 타이머·fetch 같은 부수효과를 주입한다.

입력: 채팅 로그를 흘려넣는다

use-turn-analysis.ts:113 effect가 chatLogscore.ingest()로 전달한다. turn-analysis-core.ts:45 ingest()seenIds처음 보는 로그만 골라 누적하고, 역할별로 분기한다.

두 가지 트리거

트리거발생코드분석 대상
assistant 핑퐁이(assistant) 로그 도착 :77 handleAssistant() 대기 폴백 전부 취소 후, 직전 user 발화 1개를 분석 대상으로 삼는다. pingpongSpeech는 새 assistant 응답이 아니라 해당 user 직전의 assistant 메시지를 우선 사용한다.
no-response user 발화 후 8초 동안 응답 없음 :63 scheduleFallback() 그 user 발화 (pingpongSpeech = "(무응답)")
// :77 — assistant 도착 = 직전 아동 발화에 핑퐁이가 응답한 순간
private handleAssistant(assistantLog) {
  for (const [, p] of this.pending) this.deps.clearTimer(p.handle); // 무응답 폴백 취소
  this.pending.clear();
  // 내부 히스토리를 역방향으로 훑어 직전 user 1개를 찾음
  for (let i = assistantIdx - 1; i >= 0; i--) {
    if (this.allLogs[i].role === "user") {
      const previousPingpongSpeech = this.findPreviousAssistantMessage(i);
      this.emit({ userLogId, transcribedText, pingpongSpeech: previousPingpongSpeech ?? assistantLog.message, trigger: "assistant" });
      return;
    }
  }
}

중복 방지의 두 집합: seenIds vs targeted

seenIds

  • ingest된 모든 로그 id
  • 같은 로그를 두 번 누적하지 않게

targeted

  • 이미 분석 대상으로 확정된 user 로그 id (:97 emit())
  • assistant·no-response가 같은 발화를 가리켜도 한 번만
  • 너무 짧은 발화(normalizedLength < 2)는 분석 스킵하되 targeted에 넣어 재시도 차단

★ 스텝 전환 중복 알림 버그 수정 (핵심 함정)

use-turn-analysis.ts:113~125의 effect 분기가 의도적으로 미묘하다.

if (chatLogs.length === 0) { core.reset(); return; }  // 게스트 접속 종료 등에만 상태 초기화
if (!isSessionActive) return;                  // 스텝 전환으로 잠깐 inactive → 누적/dedup 상태 유지
core.ingest(chatLogs.map(...));

스텝(활동) 전환 시 isSessionActive가 잠깐 false가 된다. 이때 상태를 리셋하면 누적된 과거 대화의 targeted가 날아가, 전환 후 이미 분석했던 턴을 다시 분석(중복 알림)한다. 그래서 리셋은 대화 로그 자체가 빌 때(chatLogs.length === 0, = 게스트 접속 종료)만 한다. turn-analysis-core.ts:107 reset()이 네 집합(seen/targeted/pending/allLogs)을 모두 비운다.

5. 단계 ③ 분석 요청 — analyze() → 서버

코어가 타깃을 onTarget으로 올리면 use-turn-analysis.ts:61 analyze()가 실행된다.

  1. :62 getActiveAgentId()로 활성 에이전트 선택 — 없으면 즉시 return.
  2. :64 getRecentBlobs(agent.nTurns, chatLogs, target.userLogId)로 분석 대상 user 기준 최근 n턴 오디오를 요청 — 기대 턴 수만큼 실제 Blob이 없으면 return.
  3. :69 Blob들을 blobToBase64로 변환해 POST /api/audio-analysis/analyze 호출.
  4. :87~89 응답 result를 analysisResults Map에 저장 → onResult(단계 4)와 onLogData(단계 5) 콜백 호출.
  5. :90 실패 시 probability:"medium" 에러 result를 만들어 그래도 결과를 흘린다.

서버 라우트의 provider 분기

app/api/audio-analysis/analyze/route.ts (withAuthMember 보호). 공통으로 :19 substitutePrompt()가 시스템 프롬프트의 {핑퐁이발화}를 실제 발화로 치환한다.

Gemini :46OpenAI :106
엔드포인트generativelanguage…/{model}:generateContentapi.openai.com/v1/chat/completions
오디오 전달parts[].inline_data (mime+base64)content[].input_audio (data+format)
포맷 변환mimeType 그대로:24 toOpenAIAudioFormat (webm/ogg→opus)
시스템 프롬프트system_instructionrole:"system" 메시지
JSON 강제responseMimeType: application/jsonresponse_format: json_object

응답 text를 JSON.parseTurnAnalysisResult로 만들어 { result, resolvedSystemPrompt, textPrompt }를 돌려준다(프롬프트도 같이 반환 → 로그에 남기기 위함).

API 키는 클라이언트가 아니라 서버 환경변수(GEMINI_API_KEY / OPENAI_API_KEY)에서만 읽는다. 관리 UI에는 키 입력란이 없다(…section.tsx:307).

6. 단계 ④ 결과 표시 (V1 호스트)

host.tsx:578 onResultprobability높음 🔴 / 중간 🟡 / 낮음 🟢 라벨로 바꾸고, 사유·전사·추정 발화를 줄글로 모아 setAnalysisAlert(...) 모달을 띄운다. 결과 Map(analysisResults)은 자식 컴포넌트로도 내려간다(host.tsx:1521).

V1/V2 차이 — V2 세션카드는 onResult를 넘기지 않는다(session-card.tsx:611~616). 즉 V2에서는 실시간 모달 경고 없이 세션 로그에만 남는다.

7. 단계 ⑤ 영속화 — S3 + audio_analysis 세션 로그

onLogData = hooks/use-audio-analysis-logger.ts logAnalysis(). 두 군데에 나눠 저장한다.

// :61 — message에 분석 전모를 JSON으로 직렬화해 세션 로그 한 줄에 담는다
const message = JSON.stringify({
  agentName, provider, model, nTurns,
  systemPrompt: data.resolvedSystemPrompt,  textPrompt: data.textPrompt,
  blobCount: data.blobs.length,             result: data.result,
});

세션 로그 타입은 types/db/session-log.types.ts:26"audio_analysis"로, 오디오 키는 :82 audioAnalysisS3Key?로 추가돼 있다(최근 "세션 로그 전용 컬럼" 작업).

실패해도 죽지 않는다 — S3 업로드/로그 저장이 실패하면 logError에 모아 logger.warn만 남기고 진행한다(:91). 오디오가 없으면(blobs.length === 0) 업로드를 건너뛰고 메타만 저장.

세션 로그 뷰어에서 보기

components/pages/session-log.tsx:546 "오디오 분석" 컬럼이 type === "audio_analysis" 행을 렌더한다 — 에이전트 배지, result 필드 나열, 프롬프트 <details>, 그리고 audioAnalysisS3Key가 있으면 🎵 재생 / 다운로드 버튼. 재생·다운로드는 :347 / :362에서 POST /api/audio-analysis-logs/download-url로 presigned 조회 URL을 받아 쓴다.

8. 2026-06-23 nTurns 합본 WAV 수정 기록

문제 증상은 nTurns=2인데 세션 로그의 합본 WAV가 아동 턴만 2개 들어가거나, 반대로 핑퐁 1턴만 들어가는 것이었다. 근본 원인은 실제 오디오 Blob이 있는 턴과 transcript/STT 보정 턴을 구별하는 기준이 불완전했던 것이다.

문제원인수정
아동 턴만 2개 합본assistant transcript에 source:"audio"가 없어 핑퐁이 턴이 오디오 후보에서 제외됨. 아동 stt_verified 전사 턴은 후보에 섞여 아동 → 아동처럼 선택됨.use-ai-session.ts assistant transcript에 source:"audio" 추가. 모니터/세션카드 변환은 source === "audio" || type === "audio"를 오디오 턴으로 인정.
핑퐁 1턴만 합본아동 턴이 raw audio 로그가 아니라 stt_verified로 먼저/대신 들어오는 경우, 기존 선택 로직이 아동 턴을 기대 오디오 턴으로 세지 못함.stt_verified를 오디오 발화 턴의 대체 기준으로 인정하되, relatedToLogId가 raw audio를 가리키면 중복 제거하고 raw audio 턴으로 해석.
부분 WAV 저장기대 턴보다 적은 Blob이 매칭되어도 남은 Blob만 합본 저장 가능.selected.length < expectedCount이면 경고 후 [] 반환. 분석과 로그 저장을 모두 스킵.

현재 보장. nTurns=2는 분석 대상 아동 발화 기준으로 핑퐁 → 아동 2개 실제 Blob이 모두 있어야 합본 WAV가 저장된다. nTurns=3도 동일하게 3개 Blob이 모두 매칭되어야 한다. transcript나 stt_verified 자체가 오디오로 저장되는 것이 아니라, 실제 녹음 Blob을 찾기 위한 턴 기준으로만 쓰인다.

// turn-audio-selection.ts — stt_verified는 raw audio와 중복되지 않게 해석
const rawAudioIds = new Set(chatLogs
  .filter((log) => isAudioRole(log.role) && log.type === "audio")
  .map((log) => log.id));

const audioTurns = chatLogs.filter((log) => {
  if (!isAudioTurn(log)) return false;
  return !(log.type === "stt_verified" && log.relatedToLogId && rawAudioIds.has(log.relatedToLogId));
});

전체 시퀀스 (한 턴 기준)

아동이 말함 → 멈춤

isPeerTalking true→false

MediaRecorder.onstop → Blob 1개가 링버퍼에 적재

핑퐁이 응답 로그 도착

chatLogs 갱신 → core.ingest()

handleAssistant가 직전 user를 targeted에 넣고 emit (중복이면 무시)

analyze() 호출

활성 에이전트 + getRecentBlobs(nTurns)

base64 변환 → /api/audio-analysis/analyze → LLM → TurnAnalysisResult

④⑤

결과 분기

onResult(모달, V1) · onLogData(영속화)

진행자에게 경고 + S3 오디오/세션 로그 저장 → 뷰어에서 재생

코드 맵 — 파일별 역할 한눈에

레이어파일역할
타입types/db/audio-analysis-agent.types.tsAudioAnalysisAgent, TurnAnalysisResult
관리 UIcomponents/sections/audio-analysis-agents-section.tsxCRUD 폼 · TurnPreview · localStorage 활성화
관리 훅entities/audio-analysis-agent/model/use-audio-analysis-agents.ts목록/생성/수정/삭제
캡처hooks/use-guest-audio-capture.tsMediaRecorder 턴 단위 링버퍼, getRecentBlobs
트리거 로직hooks/turn-analysis-core.tsingest/handleAssistant/scheduleFallback/emit/reset
오케스트레이션hooks/use-turn-analysis.ts코어 + analyze() fetch + 결과 Map
로거hooks/use-audio-analysis-logger.tsS3 업로드 + 세션 로그 저장
분석 APIapp/api/audio-analysis/analyze/route.tsGemini/OpenAI 분기, {핑퐁이발화} 치환
에이전트 APIapp/api/audio-analysis-agents/route.ts + [id]CRUD 엔드포인트
로그 APIapp/api/audio-analysis-logs/upload-url · download-urlpresigned URL 발급(20MB 제한)
S3lib/s3.ts:248{stage}/audio-analysis/{userId}/{logId}.{ext}
DBlib/db-queries.ts:657~751chatPreset 테이블, AUDIO_ANALYSIS_AGENT 파티션
뷰어components/pages/session-log.tsx:546audio_analysis 컬럼 · 재생/다운로드
진입점components/pages/host.tsx · features/session/ui/session-card.tsxV1/V2 배선

읽을 때 주의할 함정 5가지

통합된 이전 문서

아래 두 문서는 이 문서로 통합되었다. 기존 URL 호환을 위해 얇은 안내 페이지로 남긴다.