마지막 업데이트 2026-07-22
핑퐁이(OpenAI Realtime)가 아동 발화를 잘못 알아듣는 경우(오인식)를 잡아내는 시스템이다.
진행자(모니터) 브라우저가 SFU로 수신 중인 아동 마이크·핑퐁이 음성을 발화 구간 단위로 녹음해 두었다가,
대화 턴이 끝나면 최근 n턴의 실제 오디오를 Gemini / GPT-4o-audio에게 직접 들려주고
"전사와 실제 발화가 다른가?"를 JSON으로 판정받는다. 결과는 ① 모니터 알림, ② (설정 시) 핑퐁이 세션에 정정 문구 자동 주입, ③ S3+세션 로그 영속화의 3갈래로 소비된다.
핵심 파일은 use-turn-analysis · TurnAnalysisCore · use-guest-audio-capture · /api/audio-analysis/analyze.
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 (299L) | 서버 분석 프록시. Gemini :70-176 / OpenAI :177-295 분기 |
| 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) |
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 route.ts:70-176 | OpenAI route.ts:177-295 | |
|---|---|---|
| 엔드포인트 | models/{model}:generateContent :115-122 | /v1/chat/completions (gpt-4o-audio 계열) :230-240 |
| 오디오 전달 | 턴별 inline_data parts :95-97 | 턴별 input_audio content :202-208 |
| 지원 포맷 가드 | wav/mp3/mpeg/aiff/aac/ogg/flac :32-40,80-89 | wav/mp3만 :25-29,192-201 |
| 프롬프트 | system_instruction + 마지막 text part :98-104 | system 메시지 + user content 끝 text :209-221 |
| JSON 강제 | responseMimeType: application/json :105 | response_format: json_object :216 |
| API 키 | GEMINI_API_KEY :71 | OPENAI_API_KEY :179 |
{핑퐁이발화} 치환: route.ts:19-21 — assistant 트리거면 핑퐁이 응답 발화, no-response면 "(무응답)".observeExternalOperationDuration("audio_analysis_request", provider, status) :124-142, 242-260 → Grafana에서 provider별 지연 분리 가능.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" 문자열에 의존.