마지막 업데이트 2026-09-12
오디오 분석 에이전트 (오인식 감지·자동 정정) — 코드레벨 동작 흐름 & 이슈 분석 가이드의 핵심을 설명식으로 먼저 안내합니다. 기술적 결론과 원문 근거는 아래 본문에 보존되어 있습니다.
핑퐁이(OpenAI Realtime)가 아동 발화를 잘못 알아듣는 경우(오인식)를 잡아내는 시스템이다.
진행자(모니터) 브라우저가 SFU로 수신 중인 아동 마이크·핑퐁이 음성을 발화 구간 단위로 녹음해 두었다가,
대화 턴이 끝나면 최근 n턴의 실제 오디오를 Gemini / GPT-4o-audio / Qwen omni에게 직접 들려주고
"전사와 실제 발화가 다른가?"를 JSON으로 판정받는다. 결과는 ① 모니터 알림, ② (설정 시) 핑퐁이 세션에 정정 문구 자동 주입, ③ S3+세션 로그 영속화의 3갈래로 소비된다.
핵심 파일은 use-turn-analysis · TurnAnalysisCore · use-guest-audio-capture · /api/audio-analysis/analyze(프록시) · apps/audio-analysis(분석 서비스).
guestStream.audioStream(아동 mic)·aiAudioStream(핑퐁이)을 녹음 소스로 쓴다. 따라서 모니터를 안 보고 있으면(카드 언마운트) 그 구간은 녹음도 분석도 없다.이슈 수정 시 여기서 시작. 모든 경로는 apps/web/ 기준.
| 증상 | 1차 의심 지점 | 판별 근거 |
|---|---|---|
| 분석이 아예 실행 안 됨 (알림 0건) |
① 활성 에이전트 미설정 — hooks/use-turn-analysis.ts:64-65 (agent 못 찾으면 조용히 return) ② blob 매칭 부족 스킵 — hooks/use-guest-audio-capture.ts:241-249 ③ 2자 미만 비용 가드 — hooks/turn-analysis-core.ts:104-107 ④ isSessionActive false로 ingest 중단 — hooks/use-turn-analysis.ts:138
|
②는 콘솔 [AudioCapture] nTurns 오디오 blob 매칭 부족으로 분석을 건너뜁니다 경고가 남는 유일한 스킵. ①③④는 로그 없이 침묵 — 재현 시 우선 breakpoint 지점 |
| 같은 턴 중복 알림 | dedup reset 조건 — hooks/use-turn-analysis.ts:134-137 (chatLogs가 0으로 비워졌다 다시 차는 경우 core.reset() 후 재ingest) |
transcripts 배열이 일시적으로 빈 배열이 되는 상위 스토어 이벤트(게스트 재접속·모니터 재구독)를 찾을 것. targeted 가드 자체는 hooks/turn-analysis-core.ts:103 |
| 분석 결과가 엉뚱한 턴 오디오 기준 | 근접도 매칭 — hooks/turn-audio-selection.ts:63-85 FIFO eviction — hooks/use-guest-audio-capture.ts:109-112 |
매칭은 role+시각 근접뿐, 내용 검증 없음. transcript timestamp(게스트 기록)와 blob startedAt(모니터 Date.now)의 기기 간 시계·전파 지연 skew가 크면 오매칭. S3 저장 오디오를 들어보면 확정 |
| 발화 꼬리 잘림 / 두 발화가 한 blob | STOP_TAIL_MARGIN_MS=500 — hooks/use-guest-audio-capture.ts:39,146-153 |
마진이 짧으면 꼬리 잘림, 발화 간격이 500ms 미만이면 한 턴으로 병합됨(의도된 동작). scheduleStop/cancelScheduledStop 타이밍 로그로 판별 |
| 400 UNSUPPORTED_AUDIO_FORMAT | WAV 변환 실패 → webm 원본 폴백 — hooks/use-turn-analysis.ts:74-78 서버 거부 — route.ts:80-89(Gemini) :192-201(OpenAI) |
폴백 webm은 서버에서 반드시 400. audioBlobToWav(lib/wav.ts)의 디코드 실패 원인(손상 blob·빈 blob)을 먼저 볼 것 |
| no-response 오발 (응답 있었는데 "(무응답)" 분석) | 8초 타이머 — hooks/turn-analysis-core.ts:26,66-78 assistant 도착 시 전체 취소 — hooks/turn-analysis-core.ts:81-83 |
핑퐁이 응답 지연(TTS 지연 등)이 8초를 넘으면 폴백이 먼저 발화. 이후 도착한 assistant는 이미 targeted라 재분석 없음 — pingpongSpeech가 "(무응답)"으로 남는 게 시그니처 |
| 자동 정정이 안 들어감 | 3중 조건 — session-card.tsx:682-688 / monitor-dashboard page.tsx:1041-1046 | enabled && probability==="high" && !error. LLM이 "high"를 문자열 그대로 안 주면(대소문자·한글) 불발 — 결과 JSON 원문은 audio_analysis 로그 message.result에 있음 |
| 자동 정정이 이상한 문구로 들어감 | 템플릿 치환 — types/db/audio-analysis-agent.types.ts:72-82 | 결과 JSON에 없는 {키}는 빈 문자열로 치환됨(에러 아님). 에이전트 프롬프트의 JSON 스키마와 정정 템플릿 키 불일치를 의심 |
| 로그 페이지에서 분석 로그가 안 보임 | 병합 필터 — lib/session-log-audio-analysis.ts:10-12 | target이 role==="assistant" && type==="audio"일 때만 병합. no-response 케이스는 relatedToLogId가 user 턴이라 병합에서 빠짐(알려진 공백, 아래 §7-③) |
| 알림은 오는데 S3 오디오 없음 | 업로드 실패 경로 — hooks/use-audio-analysis-logger.ts:37-69 | 실패해도 세션 로그는 저장됨(audioAnalysisS3Key만 누락). 브라우저 콘솔 오디오 분석 로그 영속화 일부 실패 경고 + logError 문자열로 단계 판별 |
| probability가 항상 medium + error | 클라이언트 오류 변환 — hooks/use-turn-analysis.ts:105-112 | fetch/서버 오류가 이 모양으로 변환됨. 서버 로그 채널 AUDIO_ANALYSIS_ANALYZE에서 502 원인(외부 API 오류·JSON 파싱 실패) 확인 |
| 파일 | 역할 |
|---|---|
| types/db/audio-analysis-agent.types.ts | 에이전트·결과·자동정정 타입과 상수. buildCorrectionText :72, extractJsonKeysFromPrompt :56, 기본 정정 템플릿 :43 |
| entities/audio-analysis-agent/model/use-audio-analysis-agents.ts | 에이전트 목록·활성 토글·정정 설정 CRUD 훅. 낙관적 업데이트+실패 롤백 (setActiveAgentId :75, setCorrectionInjection :53) |
| app/api/audio-analysis-agents/* | CRUD·/active(활성 1개, PUT은 admin 전용)·/correction — DynamoDB (키: AUDIO_ANALYSIS_AGENT[_ACTIVE|_CORRECTION]) |
| hooks/use-guest-audio-capture.ts (261L) | role별 발화 구간 MediaRecorder 녹음 + blob FIFO + getRecentBlobs :199 |
| hooks/turn-audio-selection.ts (111L) | chatLog 오디오 턴 ↔ blob 매칭 순수 함수 (selectRecentTurnAudioBlobs :45) |
| hooks/turn-analysis-core.ts (125L) | 턴 완료 감지 상태 머신. React 무의존 — 단위 테스트: turn-analysis-core.test.ts |
| hooks/use-turn-analysis.ts (147L) | core↔React 연결 + 분석 요청 조립 (analyze :63) |
| app/api/audio-analysis/analyze/route.ts | 인증 전용 프록시. body를 apps/audio-analysis로 스트림 전달만 PPI-1224 |
| apps/audio-analysis/src/analyze.ts | 실제 분석. Gemini :117 / OpenAI·Qwen :183 분기 |
| hooks/use-audio-analysis-logger.ts (111L) | 입력 오디오 S3 업로드 + audio_analysis 세션 로그 (logAnalysis :27) |
| lib/session-log-audio-analysis.ts | 로그 페이지 병합 (buildAudioAnalysisMap :3, 사용처 session-log.tsx:160) |
| components/sections/audio-analysis-agents-section.tsx | 관리 UI. 프리셋 관리 페이지에 마운트 (chat-preset.tsx:181) |
| 사용처 | 배선 지점 | 비고 |
|---|---|---|
| V2 카드뷰 세션 카드 | features/session/ui/session-card.tsx:620-690 (capture :645, logger :653, useTurnAnalysis :659, onResult :666, 정정 주입 :682) | 운영 실사용 경로 |
| V2 포커스뷰 | app/monitor-dashboard/[group]/[roomId]/page.tsx:1004-1046 | 카드뷰와 동일 배선 — 수정 시 두 곳 모두 반영 필요 |
| V1 host 페이지 | components/pages/host.tsx:575,593 | 레거시 — V1은 운영 미사용 |
에이전트는 "어느 LLM에게(provider/model), 어떤 지시로(systemPrompt), 최근 몇 턴을(nTurns) 들려줄지"의 프리셋이다. 여러 개를 만들어 두고 전역으로 1개만 활성화한다.
| AudioAnalysisAgent 필드 types:13-23 | 의미 |
|---|---|
provider / model | "gemini"(generateContent) · "openai"(gpt-4o-audio chat/completions) · "qwen"(DashScope omni, SSE 스트리밍). 타입은 AUDIO_ANALYSIS_PROVIDERS 상수 배열에서 파생되고 isAudioAnalysisProvider()로 런타임 검증한다 audio-analysis-agent.types.ts:11-19 |
systemPrompt | 분석 지시. {핑퐁이발화} 플레이스홀더가 서버에서 치환됨 route.ts:19-21. 출력 JSON 스키마도 이 프롬프트에 선언 |
nTurns | LLM에게 들려줄 최근 오디오 턴 수 (문맥 제공용) |
TurnAnalysisResult로 파싱할 뿐이고(route.ts:165-174, 283-292), 정정 템플릿의 {키} 치환도 결과 JSON의 동명 필드를 동적으로 읽는다. 에이전트 프롬프트의 JSON 스키마를 바꾸면 정정 템플릿 변수도 자동으로 따라온다(extractJsonKeysFromPrompt가 관리 UI에 키 목록 제공). 단 probability("high"/"medium"/"low")는 트리거 판정에 쓰이는 계약 필드다 — 프롬프트가 이 필드를 안 내놓으면 자동 정정이 영구 불발된다.hooks/use-guest-audio-capture.ts. 아동(user)과 핑퐁이(assistant)를 독립된 MediaRecorder 2대로, 발화 신호가 켜진 구간만 녹음한다. 발화 신호의 출처는 배선부 — 카드뷰 기준 monitorSession.isSpeaking/isAiSpeaking session-card.tsx:648-649.
chunks/startedAt은 recorder 인스턴스 전용 클로저 변수다. 발화 도중 스트림이 교체돼 새 recorder가 시작돼도, 이전 recorder의 비동기 dataavailable/onstop이 새 녹음을 오염시키지 않는다.console.warn("[AudioCapture] MediaRecorder 시작 실패")(:121)만 남기고 그 턴 녹음이 통째로 없다. ③ 녹음 소스가 SFU 수신 스트림이므로 iOS clone-track 무음류 이슈(수신 오디오 자체가 무음)면 분석 입력도 무음이 된다 — LLM이 "안 들림"으로 판정하는 케이스는 파이프라인 버그가 아닐 수 있음.hooks/turn-analysis-core.ts. React 무의존 순수 클래스(단위 테스트 있음 — 로직 수정 시 turn-analysis-core.test.ts 먼저). 전사 로그 스트림에서 "분석할 아동 턴"을 뽑는다.
hooks/turn-analysis-core.ts:80-99 — assistant 트리거의 두 가지 함정private handleAssistant(assistantLog: CoreLog): void { // ① assistant 도착 → 대기 중인 "모든" user 폴백 취소 — 연속 user 발화 후 응답이 오면 // 마지막 user 턴만 분석되고 앞의 user 턴들은 폴백까지 취소돼 영영 분석 안 됨 for (const [, p] of this.pending) this.deps.clearTimer(p.handle); this.pending.clear(); // ② 역방향 첫 user 턴 1개만 emit — pingpongSpeech는 "이 assistant 발화"로 고정 const assistantIdx = this.allLogs.indexOf(assistantLog); for (let i = assistantIdx - 1; i >= 0; i--) { if (this.allLogs[i].role === "user") { this.emit({ ... }); return; } } }
chatLogs.length === 0(게스트 접속 종료 등으로 전사가 비워질 때)에만 core.reset(). 스텝 전환으로 isSessionActive가 잠시 false가 돼도 ingest만 멈출 뿐 seenIds/targeted는 남아, 과거 턴 재분석(중복 알림)을 막는다. 반대로 말하면 — transcripts가 0으로 튀는 순간이 있으면 그 뒤 전체 재분석 폭탄이 가능하다.pingpongSpeech) 없이 판정된다. ③ isSessionActive=false 구간에 도착한 턴은 그 구간이 끝나도 ingest 시점의 chatLogs에 이미 있으면 seenIds에 들어가 분석 기회를 잃는다 — "스텝 전환 직후 턴이 분석 안 됐다"류 신고의 유력 지점.getRecentBlobs(nTurns, chatLogs, targetLogId) use-guest-audio-capture.ts:199-253 → 매칭 로직은 turn-audio-selection.ts.
type === "audio" 또는 "stt_verified"인 user/assistant 턴만. stt_verified가 raw audio 로그를 가리키면(relatedToLogId) 중복 제거, raw가 없으면 대체 기준.minStartedAt 단조 증가로 시간 역행 매칭 차단(:83).audio_analysis 오디오(분석에 실제 쓰인 입력)를 재생 → ② 로그 턴 timestamp와 blob startedAt/endedAt 차이 계산 → ③ skew가 크면 전사 timestamp의 출처(게스트 기록 시각 vs 모니터 수신 시각)를 추적하는 순서로. 근접도 정렬식은 :73-77 한 곳이다.analyze() use-turn-analysis.ts:63-116 → POST /api/audio-analysis/analyze route.ts:42-296.
hooks/use-turn-analysis.ts:71-78 — 형식 결정 지점 (400 이슈의 진원지)// OpenAI(wav/mp3)·Gemini(wav/mp3/ogg/flac/aac/aiff) 모두 webm 미지원 → 전송 전 턴별 WAV 변환. // "전 턴 성공 시에만" wav — 하나라도 실패하면 형식 혼용을 피해 원본(webm) 그대로 폴백 → 서버 400 const wavBlobs = await Promise.all(blobs.map((b) => audioBlobToWav(b))); const allWav = wavBlobs.every((b): b is Blob => b !== null); const sendBlobs = allWav ? wavBlobs : blobs; const mimeType = allWav ? "audio/wav" : blobs[0]?.type || "audio/webm";
| Gemini analyze.ts:117 | OpenAI analyze.ts:183 | Qwen analyze.ts:183 | |
|---|---|---|---|
| 엔드포인트 | models/{model}:generateContent | /v1/chat/completions (gpt-4o-audio 계열) | DASHSCOPE_BASE_URL/chat/completions |
| 오디오 전달 | 턴별 inline_data parts | 턴별 input_audio content | 같음 + data:;base64, 접두사 |
| 지원 포맷 가드 | GEMINI_SUPPORTED_AUDIO 화이트리스트 | wav/mp3만 toOpenAIAudioFormat :46 | |
| 프롬프트 | system_instruction + 마지막 text part | system 메시지 + user content 끝 text | |
| JSON 강제 | responseMimeType: application/json | response_format: json_object | 없음 — 프롬프트 유도 + SSE 파싱 |
| 턴 수 | n턴 배열 그대로 | 합본 1개 강제, 아니면 400 | |
| API 키 | GEMINI_API_KEY | OPENAI_API_KEY | DASHSCOPE_API_KEY |
{핑퐁이발화} 치환: analyze.ts:42 — assistant 트리거면 핑퐁이 응답 발화, no-response면 "(무응답)".ppi_audio_analysis_provider_duration_seconds·ppi_audio_analysis_requests_total·ppi_audio_analysis_payload_bytes·ppi_audio_analysis_in_flight. provider별 지연 분리는 여기서 본다. OOM 추적용으로 provider_request_started/completed 로그에 rss·heapUsed가 동봉되고, 30초 주기 memory_heartbeat가 따로 찍힌다.AUDIO_ANALYSIS_ANALYZE. 클라이언트는 이를 probability:"medium" + error 결과로 변환(use-turn-analysis.ts:105-112) — 알림에는 뜨되 자동 정정은 차단(!result.error 가드).nTurns는 서버에서 _nTurns로 받고 실사용 안 함(route.ts:57) — 실제 턴 수는 audioBase64.length가 결정. 턴 수 관련 이슈는 클라이언트 선별(§5)만 보면 된다.onResult → 결과 JSON의 non-empty 필드를 줄 단위로 펼쳐 AlertType.AUDIO_MISRECOGNITION("오디오 오인식 감지", shared/types/index.ts:58) 알림 추가. priority: NONE이라 조용한 정보성 알림이다.
기본 템플릿(types:43-47)은 "인식된 발화(잘못 들음) vs 실제 발화"를 명시하고 '소리 내어 말하지 말고 다음 발화에 반영만 하라'고 지시한다. 설정은 전역 단일 레코드라 모든 수업에 일괄 적용 (기본 off, types:49-52).
message.result에서 probability 원문 확인("high" 정확 일치인지) → ② /api/audio-analysis-agents/correction GET으로 enabled 확인 → ③ 게스트 LogRocket에서 sendMessage 텍스트 수신 여부 → ④ 주입은 됐는데 핑퐁이가 소리 내어 읽었다면 템플릿 지시문 문제(프롬프트 튜닝 영역).onLogData → logAnalysis
① n턴 blob → combineAudioBlobsToWav 단일 WAV remux :40 (타임라인 정상화)
└ 실패 시 원본 WebM 접합본 폴백 :41-48 (+ logError "타임라인 깨질 수 있음")
② /api/audio-analysis-logs/upload-url → presigned PUT → S3 업로드 :49-64
③ /api/session-logs POST :83-97 — role: "system", type: "audio_analysis"
message = {agentName, provider, model, nTurns, systemPrompt(치환본), textPrompt, blobCount, result}
relatedToLogId = assistant 턴 우선, 무응답이면 user 턴 (use-turn-analysis.ts:103)
audioAnalysisS3Key = 업로드 성공 시만세션 로그 페이지(components/pages/session-log.tsx:160)는 buildAudioAnalysisMap으로 분석 로그를 관련 턴 아래 병합 표시하고, /api/audio-analysis-logs/download-url로 분석에 실제 쓰인 오디오를 재생할 수 있다 — "LLM이 뭘 듣고 이렇게 판정했나" 사후 검증용.
role==="assistant" && type==="audio"일 때만 병합한다. 따라서 no-response 케이스(relatedToLogId=user 턴)는 DB에는 있어도 로그 페이지 병합 표시에서 빠진다. "무응답 분석 로그가 안 보인다"는 신고는 데이터 유실이 아니라 이 필터가 원인일 가능성부터.| 결정 | 이유 | 코드 지점 |
|---|---|---|
| 분석을 모니터(진행자) 측에서 실행 | 아동 기기(iPad) 부하 회피. 모니터는 이미 SFU로 아동/AI 오디오 수신 중이라 추가 전송 비용 없음 | session-card.tsx:645-651 |
| webm → WAV 클라이언트 변환 | OpenAI(wav/mp3)·Gemini 모두 webm 미지원. 일부만 성공하면 형식 혼용 대신 전체 원본 폴백 | use-turn-analysis.ts:71-78 |
| 2자 미만 발화 스킵 | 비용 가드 — 짧은 맞장구는 분석 가치 낮음 | turn-analysis-core.ts:104 |
| 턴당 분석 1회 (targeted dedup) | assistant 트리거·폴백 중복 방지. 스킵된 턴도 targeted 기록 | turn-analysis-core.ts:103-108 |
| blob 부족 시 분석 포기 | 일부 턴 오디오만으로 판정하면 오판 위험 — 불완전 입력이면 아예 안 함 | use-guest-audio-capture.ts:241-249 |
| 자동 정정은 high + 무오류 + 전역 opt-in | LLM 판정을 세션에 주입하는 위험 부담 → 3중 게이트 | session-card.tsx:682-685 |
| API 오류도 결과로 변환 (medium + error) | "분석 실패" 사실을 진행자에게 노출하되 자동 정정은 차단 | use-turn-analysis.ts:105-112 |
| 로그에 프롬프트 치환본까지 저장 | 프리셋이 바뀌어도 "그때 정확히 뭘 물었는지" 재현 가능 (프롬프트 튜닝 루프) | use-audio-analysis-logger.ts:71-80 |
hooks/turn-analysis-core.test.ts가 트리거·dedup·폴백 시나리오를 커버한다."high"/"medium"/"low" 문자열에 의존.