오디오 분석 에이전트 — 통합 코드 레벨 문서
마지막 업데이트 2026-07-22
TL;DR
오디오 분석 에이전트는 수업 중 핑퐁이/아동의 실제 발화 오디오 Blob을 턴 단위로 녹음하고, 분석 대상 아동 발화 기준 최근 nTurns개 오디오 턴을 LLM(gpt-4o-audio / Gemini)에 보내 STT 오인식 가능성을 판정한다. 예를 들어 nTurns=2이고 분석 대상이 아동 답변이면 합본 범위는 핑퐁 → 아동이다. 운영자가 만든 에이전트(프롬프트·모델·턴 수)를 진행자가 자기 브라우저에서 활성화해 두면, 별도 조작 없이 턴마다 자동으로 돈다.
코드는 5단계 파이프라인이다 — ① 오디오 캡처(링버퍼) → ② 턴 감지·중복 제거(TurnAnalysisCore) → ③ 분석 요청(/api/audio-analysis/analyze) → ④ 결과 표시(모달) → ⑤ 영속화(S3 + audio_analysis 세션 로그). 이 문서는 각 단계를 파일:라인 칩으로 짚어가며 코드에서 직접 따라갈 수 있게 한다.
한눈에 보는 파이프라인
오디오 캡처 — 핑퐁/아동 발화를 role 포함 Blob 링버퍼로
hooks/use-guest-audio-capture.ts · isUserSpeaking/isAiSpeaking에 반응하는 MediaRecorder
아동과 핑퐁이 발화를 각각 role(user/assistant)·시각 메타와 함께 녹음한다. getRecentBlobs(n, chatLogs, targetLogId)가 분석 대상 턴 기준으로 실제 Blob이 모두 있는 경우에만 반환한다.
턴 감지 & 중복 제거 — 언제 분석을 쏠지 결정
hooks/turn-analysis-core.ts + hooks/use-turn-analysis.ts
채팅 로그를 ingest()하며 assistant 도착(직전 user 분석) 또는 8초 무응답을 트리거로 잡고, seenIds/targeted로 같은 발화를 두 번 분석하지 않게 막음.
분석 요청 — 오디오 + 전사를 LLM에 비교 분석
app/api/audio-analysis/analyze/route.ts · provider 분기(Gemini / OpenAI)
최근 n턴 Blob을 base64로 변환해 전송. 시스템 프롬프트의 {핑퐁이발화}를 실제 AI 발화로 치환. JSON(probability/reason/…)로 응답.
결과 표시 — 진행자에게 모달 경고 (V1 호스트)
components/pages/host.tsx:578 · onResult 콜백
오인식 가능성(🔴/🟡/🟢)·사유·추정 발화를 모달로 표시. (V2 세션카드는 onResult 미연결 — 로그만 남김)
영속화 — 오디오는 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이 매칭된 턴만 넣는 것이다.
syncCapture("user", isUserSpeaking, userStream)— 아동 발화 구간을 녹음한다.syncCapture("assistant", isAiSpeaking, aiStream)— 핑퐁이 발화 구간을 녹음한다. Realtime은 OpenAI WebRTContrackstream, TTS/Typecast는TtsPlayer.stream이 같은aiAudioStream으로 들어온다.MediaRecorder종료 시{ role, blob, startedAt, endedAt }를 FIFO로 보관한다.getRecentBlobs(n, chatLogs, targetLogId)는 로그 턴과 녹음 Blob을 role/time 기준으로 매칭한다.
// 한 턴 녹음이 끝날 때 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가 chatLogs를 core.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()가 실행된다.
- :62
getActiveAgentId()로 활성 에이전트 선택 — 없으면 즉시 return. - :64
getRecentBlobs(agent.nTurns, chatLogs, target.userLogId)로 분석 대상 user 기준 최근 n턴 오디오를 요청 — 기대 턴 수만큼 실제 Blob이 없으면 return. - :69 Blob들을
blobToBase64로 변환해 POST /api/audio-analysis/analyze 호출. - :87~89 응답 result를
analysisResultsMap에 저장 →onResult(단계 4)와onLogData(단계 5) 콜백 호출. - :90 실패 시
probability:"medium"에러 result를 만들어 그래도 결과를 흘린다.
서버 라우트의 provider 분기
app/api/audio-analysis/analyze/route.ts (withAuthMember 보호). 공통으로 :19 substitutePrompt()가 시스템 프롬프트의 {핑퐁이발화}를 실제 발화로 치환한다.
| Gemini :46 | OpenAI :106 | |
|---|---|---|
| 엔드포인트 | generativelanguage…/{model}:generateContent | api.openai.com/v1/chat/completions |
| 오디오 전달 | parts[].inline_data (mime+base64) | content[].input_audio (data+format) |
| 포맷 변환 | mimeType 그대로 | :24 toOpenAIAudioFormat (webm/ogg→opus) |
| 시스템 프롬프트 | system_instruction | role:"system" 메시지 |
| JSON 강제 | responseMimeType: application/json | response_format: json_object |
응답 text를 JSON.parse → TurnAnalysisResult로 만들어 { result, resolvedSystemPrompt, textPrompt }를 돌려준다(프롬프트도 같이 반환 → 로그에 남기기 위함).
API 키는 클라이언트가 아니라 서버 환경변수(GEMINI_API_KEY / OPENAI_API_KEY)에서만 읽는다. 관리 UI에는 키 입력란이 없다(…section.tsx:307).
6. 단계 ④ 결과 표시 (V1 호스트)
host.tsx:578 onResult가 probability를 높음 🔴 / 중간 🟡 / 낮음 🟢 라벨로 바꾸고, 사유·전사·추정 발화를 줄글로 모아 setAnalysisAlert(...) 모달을 띄운다. 결과 Map(analysisResults)은 자식 컴포넌트로도 내려간다(host.tsx:1521).
onResult를 넘기지 않는다(session-card.tsx:611~616). 즉 V2에서는 실시간 모달 경고 없이 세션 로그에만 남는다.
7. 단계 ⑤ 영속화 — S3 + audio_analysis 세션 로그
onLogData = hooks/use-audio-analysis-logger.ts logAnalysis(). 두 군데에 나눠 저장한다.
- 오디오 → S3: :38 n턴 Blob을
combineAudioBlobsToWav()로 단일 WAV로 remux해, POST /api/audio-analysis-logs/upload-url로 presigned URL 발급(:7 최대 20MB) → 그 URL에PUT. 키 구조는 lib/s3.ts{stage}/audio-analysis/{userId}/{logId}.{ext}. - 메타+결과 → 세션 로그: :73 POST /api/session-logs에
role:"system",type:"audio_analysis",message(agentName·provider·model·nTurns·프롬프트·result JSON), 그리고audioAnalysisS3Key를 저장.
// :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.ts | AudioAnalysisAgent, TurnAnalysisResult |
| 관리 UI | components/sections/audio-analysis-agents-section.tsx | CRUD 폼 · TurnPreview · localStorage 활성화 |
| 관리 훅 | entities/audio-analysis-agent/model/use-audio-analysis-agents.ts | 목록/생성/수정/삭제 |
| 캡처 | hooks/use-guest-audio-capture.ts | MediaRecorder 턴 단위 링버퍼, getRecentBlobs |
| 트리거 로직 | hooks/turn-analysis-core.ts | ingest/handleAssistant/scheduleFallback/emit/reset |
| 오케스트레이션 | hooks/use-turn-analysis.ts | 코어 + analyze() fetch + 결과 Map |
| 로거 | hooks/use-audio-analysis-logger.ts | S3 업로드 + 세션 로그 저장 |
| 분석 API | app/api/audio-analysis/analyze/route.ts | Gemini/OpenAI 분기, {핑퐁이발화} 치환 |
| 에이전트 API | app/api/audio-analysis-agents/route.ts + [id] | CRUD 엔드포인트 |
| 로그 API | app/api/audio-analysis-logs/upload-url · download-url | presigned URL 발급(20MB 제한) |
| S3 | lib/s3.ts:248 | {stage}/audio-analysis/{userId}/{logId}.{ext} |
| DB | lib/db-queries.ts:657~751 | chatPreset 테이블, AUDIO_ANALYSIS_AGENT 파티션 |
| 뷰어 | components/pages/session-log.tsx:546 | audio_analysis 컬럼 · 재생/다운로드 |
| 진입점 | components/pages/host.tsx · features/session/ui/session-card.tsx | V1/V2 배선 |
읽을 때 주의할 함정 5가지
- 활성 에이전트는 localStorage — 서버 상태가 아니라 진행자 브라우저 1칸. 다른 PC/탭과 공유되지 않는다.
- 버퍼는 실제 Blob 기준 —
getRecentBlobs(n, chatLogs, targetLogId)은 transcript만 보고 저장하지 않는다. role/time 기준으로 실제 녹음 Blob이 n개 모두 매칭되어야 한다. - 리셋 조건 — 스텝 전환(
isSessionActive=false)에는 리셋하지 않는다.chatLogs가 0일 때만. (중복 알림 버그의 원인이자 수정점) - 너무 짧은 발화 스킵 — 공백 제거 후 2자 미만이면 분석을 건너뛰되
targeted에 기록해 다시 시도하지 않는다. - V2엔 모달이 없다 — 세션카드는
onResult미연결. 결과는 세션 로그에서만 확인. (2026-06-30 bb04ee93 갱신: 모달 표시용 연결은 여전히 없지만, 오인식 정정 자동 주입 목적으로는 session-card·monitor-dashboard의onResult가 처음 연결됨 → PR 리뷰 문서)
통합된 이전 문서
아래 두 문서는 이 문서로 통합되었다. 기존 URL 호환을 위해 얇은 안내 페이지로 남긴다.
오디오-분석-에이전트-구현-기존구조-영향-분석.html— 기존 구조 변경/사이드 이펙트 내용은 이 문서의 데이터 모델, 런타임 진입점, 2026-06-23 수정 기록에 흡수.오디오-분석-에이전트-및-아동-알림.html— 초기 기능 설명과 아동 알림 개요는 관련 도메인 문서(아동-알림-프리셋-생성관리-및-개별-토글.html) 및 이 문서에 분리 반영.