From 032842cf5335856876e41976e9d4914613e3a5e8 Mon Sep 17 00:00:00 2001 From: Minseo-Jo Date: Thu, 30 Oct 2025 15:23:30 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20AI=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=8F=20STT=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 서비스: Redis 캐싱 및 EventHub 통합 개선 - STT 서비스: 오디오 버퍼링 및 변환 기능 추가 - 설정 파일 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ai-python/app/api/v1/suggestions.py | 46 +- ai-python/app/config.py | 7 +- ai-python/app/prompts/suggestions_prompt.py | 430 ++++++++++++++++-- ai-python/app/services/eventhub_service.py | 28 +- ai-python/app/services/redis_service.py | 36 +- ai-python/main.py | 19 +- .../stt/controller/AudioWebSocketHandler.java | 4 +- .../event/publisher/EventHubPublisher.java | 124 +++-- .../stt/service/AudioBatchProcessor.java | 40 +- .../stt/service/AzureSpeechService.java | 137 +++++- .../service/InMemoryAudioBufferService.java | 165 +++++++ .../hgzero/stt/util/AudioConverter.java | 204 +++++++++ stt/src/main/resources/application.yml | 12 +- 13 files changed, 1096 insertions(+), 156 deletions(-) create mode 100644 stt/src/main/java/com/unicorn/hgzero/stt/service/InMemoryAudioBufferService.java create mode 100644 stt/src/main/java/com/unicorn/hgzero/stt/util/AudioConverter.java diff --git a/ai-python/app/api/v1/suggestions.py b/ai-python/app/api/v1/suggestions.py index 8511e35..5442e60 100644 --- a/ai-python/app/api/v1/suggestions.py +++ b/ai-python/app/api/v1/suggestions.py @@ -116,21 +116,45 @@ async def stream_ai_suggestions(meeting_id: str): if accumulated_text: logger.info(f"텍스트 누적 완료 - meetingId: {meeting_id}, 길이: {len(accumulated_text)}") + # 이미 생성된 제안사항 조회 + existing_suggestions = await redis_service.get_generated_suggestions(meeting_id) + # Claude API로 분석 suggestions = await claude_service.analyze_suggestions(accumulated_text) if suggestions.suggestions: - # SSE 이벤트 전송 - yield { - "event": "ai-suggestion", - "id": str(current_count), - "data": suggestions.json() - } + # 중복 제거: 새로운 제안사항만 필터링 + new_suggestions = [ + s for s in suggestions.suggestions + if s.content not in existing_suggestions + ] - logger.info( - f"AI 제안사항 발행 - meetingId: {meeting_id}, " - f"개수: {len(suggestions.suggestions)}" - ) + if new_suggestions: + # 새로운 제안사항만 SSE 이벤트 전송 + from app.models import RealtimeSuggestionsResponse + filtered_response = RealtimeSuggestionsResponse(suggestions=new_suggestions) + + yield { + "event": "ai-suggestion", + "id": str(current_count), + "data": filtered_response.json() + } + + # Redis에 새로운 제안사항 저장 + for suggestion in new_suggestions: + await redis_service.add_generated_suggestion( + meeting_id, + suggestion.content + ) + + logger.info( + f"AI 제안사항 발행 - meetingId: {meeting_id}, " + f"전체: {len(suggestions.suggestions)}, 신규: {len(new_suggestions)}" + ) + else: + logger.info( + f"중복 제거 후 신규 제안사항 없음 - meetingId: {meeting_id}" + ) previous_count = current_count @@ -160,8 +184,6 @@ async def stream_ai_suggestions(meeting_id: str): headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", - "Access-Control-Allow-Origin": "http://localhost:8888", - "Access-Control-Allow-Credentials": "true", } ) diff --git a/ai-python/app/config.py b/ai-python/app/config.py index ffaec13..ce87c5e 100644 --- a/ai-python/app/config.py +++ b/ai-python/app/config.py @@ -36,14 +36,15 @@ class Settings(BaseSettings): "http://localhost:3000", "http://127.0.0.1:8888", "http://127.0.0.1:8080", - "http://127.0.0.1:3000" + "http://127.0.0.1:3000", + "http://localhost:*" # 모든 localhost 포트 허용 ] # 로깅 log_level: str = "INFO" - # 분석 임계값 (충분한 맥락 확보) - min_segments_for_analysis: int = 4 # 4개 세그먼트 (약 60초, 제안사항 추출에 충분한 맥락) + # 분석 임계값 (실시간 응답을 위해 낮춤) + min_segments_for_analysis: int = 2 # 2개 세그먼트 (약 30초, 빠른 피드백) text_retention_seconds: int = 300 # 5분 class Config: diff --git a/ai-python/app/prompts/suggestions_prompt.py b/ai-python/app/prompts/suggestions_prompt.py index 9c84b8e..5226793 100644 --- a/ai-python/app/prompts/suggestions_prompt.py +++ b/ai-python/app/prompts/suggestions_prompt.py @@ -1,98 +1,432 @@ -"""AI 제안사항 추출 프롬프트 (MVP 최적화)""" +"""AI 제안사항 추출 프롬프트 (회의록 작성 MVP 최적화)""" def get_suggestions_prompt(transcript_text: str) -> tuple[str, str]: """ - 회의 텍스트에서 AI 제안사항을 추출하는 프롬프트 생성 (MVP용) + 회의 텍스트에서 AI 제안사항을 추출하는 프롬프트 생성 (회의록 MVP용) Returns: (system_prompt, user_prompt) 튜플 """ - system_prompt = """당신은 회의 내용에서 실행 가능한 액션 아이템을 찾는 전문가입니다. -복잡한 분석보다는, 명확하게 "해야 할 일"이 언급된 부분을 빠르게 찾아내는 것이 목표입니다.""" + system_prompt = """당신은 실시간 회의록 작성 AI 비서입니다. - user_prompt = f"""다음 회의 대화에서 **실행해야 할 제안사항**을 찾아주세요. +**핵심 역할**: +회의 중 발언되는 내용을 실시간으로 분석하여, 회의록 작성자가 놓칠 수 있는 중요한 정보를 즉시 메모로 제공합니다. + +**작업 방식**: +1. 회의 안건, 결정 사항, 이슈, 액션 아이템을 자동으로 분류 +2. 담당자, 기한, 우선순위 등 구조화된 정보로 정리 +3. 단순 발언 반복이 아닌, 실무에 바로 사용 가능한 형식으로 요약 +4. 회의록 작성 시간을 70% 단축시키는 것이 목표 + +**핵심 원칙**: +- 인사말, 반복, 불필요한 추임새는 완전히 제거 +- 실제 회의록에 들어갈 내용만 추출 +- 명확하고 간결하게 (20-50자) +- 구어체 종결어미(~다, ~요, ~습니다) 제거하고 명사형으로 정리""" + + user_prompt = f"""다음 회의 대화를 실시간으로 분석하여 **회의록 메모**를 작성하세요. # 회의 내용 {transcript_text} --- -# 제안사항을 찾는 간단한 방법 +# 회의록 항목별 패턴 학습 -아래 패턴이 포함된 문장을 찾으세요: +## 📋 1. 회의 안건 (Agenda) -## ✅ 명확한 액션 패턴 -- "~해야 한다", "~해야 할 것 같다" -- "~하기로 했다", "~하기로 결정" -- "~할 예정이다", "~할 계획이다" -- "~해주세요", "~부탁드립니다" -- "~하도록 하겠습니다", "~진행하겠습니다" -- "~확인해 보겠습니다", "~검토하겠습니다" +### 패턴 인식 +- "오늘 회의 안건은 ~" +- "논의할 주제는 ~" +- "다룰 내용은 ~" +- "검토할 사항은 ~" -## ⏰ 시간 관련 표현 -- "다음 주까지", "이번 주 금요일까지" -- "내일", "오늘 중으로" -- "회의 전까지", "발표 전에" +### ✅ 좋은 예시 +**입력**: "오늘 회의 안건은 신제품 출시 일정과 마케팅 전략입니다." +**출력**: +```json +{{ + "content": "📋 회의 안건: 신제품 출시 일정, 마케팅 전략", + "confidence": 0.95 +}} +``` -## 👤 담당자 관련 표현 -- "김 대리가", "박 과장님이" -- "우리 팀에서", "마케팅팀이" -- "제가", "저희가" +**입력**: "다음 주 프로젝트 킥오프에 대해 논의하겠습니다." +**출력**: +```json +{{ + "content": "📋 회의 안건: 다음 주 프로젝트 킥오프", + "confidence": 0.90 +}} +``` -# 실제 회의 예시로 학습하기 +### ❌ 나쁜 예시 +**입력**: "오늘 회의 안건은 신제품 출시 일정입니다." +**나쁜 출력**: +```json +{{ + "content": "오늘 회의 안건은 신제품 출시 일정입니다", ❌ 구어체 그대로 반복 + "confidence": 0.90 +}} +``` +**이유**: 구어체 종결어미(~입니다) 그대로 반복. "📋 회의 안건: 신제품 출시 일정"으로 구조화해야 함 -## 예시 1 -**회의 내용**: "마케팅 예산안을 김 팀장님이 다음 주 수요일까지 검토해서 공유해 주시기로 했습니다." -**추출**: "마케팅 예산안을 다음 주 수요일까지 검토하여 공유" (담당: 김 팀장) +--- -## 예시 2 -**회의 내용**: "그럼 제가 내일 오전에 고객사에 연락해서 미팅 일정을 잡도록 하겠습니다." -**추출**: "고객사에 연락하여 미팅 일정 조율" (시간: 내일 오전) +## ✅ 2. 결정 사항 (Decisions) -## 예시 3 -**회의 내용**: "법무팀과 계약서 검토를 이번 주 내로 끝내야 할 것 같아요." -**추출**: "법무팀과 계약서 검토 진행" (기한: 이번 주 내) +### 패턴 인식 +- "결정 사항은 ~", "~로 결정했습니다" +- "~하기로 했습니다", "~로 합의했습니다" +- "~로 확정됐습니다" +- "최종 결론은 ~" + +### ✅ 좋은 예시 +**입력**: "회의 결과, 신규 프로젝트는 다음 달부터 착수하기로 결정했습니다." +**출력**: +```json +{{ + "content": "✅ 결정사항: 신규 프로젝트 다음 달 착수", + "confidence": 0.95 +}} +``` + +**입력**: "최종 결론은 외주 개발사와 계약하기로 합의했습니다." +**출력**: +```json +{{ + "content": "✅ 결정사항: 외주 개발사와 계약 진행", + "confidence": 0.92 +}} +``` + +### ❌ 나쁜 예시 +**입력**: "신규 프로젝트는 다음 달부터 착수하기로 결정했습니다." +**나쁜 출력**: +```json +{{ + "content": "신규 프로젝트는 다음 달부터 착수하기로 결정했습니다", ❌ 원문 그대로 + "confidence": 0.90 +}} +``` +**이유**: 발언을 그대로 반복. "✅ 결정사항: 신규 프로젝트 다음 달 착수"로 구조화해야 함 + +--- + +## 🎯 3. 액션 아이템 (Action Items) + +### 패턴 인식 +- "~팀에서 ~해 주세요" +- "~님이 ~까지 ~하기로 했습니다" +- "~을 ~까지 완료하겠습니다" +- "~을 검토해 보겠습니다" + +### ✅ 좋은 예시 +**입력**: "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요." +**출력**: +```json +{{ + "content": "🎯 개발팀: API 문서 작성 (기한: 이번 주 금요일)", + "confidence": 0.95 +}} +``` + +**입력**: "김 팀장님이 내일까지 견적서를 검토해서 회신하기로 했습니다." +**출력**: +```json +{{ + "content": "🎯 김 팀장: 견적서 검토 및 회신 (기한: 내일)", + "confidence": 0.93 +}} +``` + +**입력**: "제가 고객사에 연락해서 미팅 일정 잡도록 하겠습니다." +**출력**: +```json +{{ + "content": "🎯 고객사 미팅 일정 조율 예정", + "confidence": 0.85 +}} +``` + +### ❌ 나쁜 예시 +**입력**: "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요." +**나쁜 출력 1**: +```json +{{ + "content": "개발팀에서 API 문서를 이번 주 금요일까지 작성해 주세요", ❌ 원문 반복 + "confidence": 0.90 +}} +``` +**나쁜 출력 2**: +```json +{{ + "content": "API 문서 작성", ❌ 담당자와 기한 누락 + "confidence": 0.80 +}} +``` +**이유**: "🎯 개발팀: API 문서 작성 (기한: 이번 주 금요일)" 형식으로 구조화해야 함 + +--- + +## ⚠️ 4. 이슈/문제점 (Issues) + +### 패턴 인식 +- "문제가 있습니다", "이슈가 발생했습니다" +- "우려되는 점은 ~" +- "해결이 필요한 부분은 ~" +- "리스크가 있습니다" + +### ✅ 좋은 예시 +**입력**: "현재 서버 성능 이슈가 발생해서 긴급 점검이 필요합니다." +**출력**: +```json +{{ + "content": "⚠️ 이슈: 서버 성능 문제 발생, 긴급 점검 필요", + "confidence": 0.92 +}} +``` + +**입력**: "예산이 부족할 것 같다는 우려가 있습니다." +**출력**: +```json +{{ + "content": "⚠️ 이슈: 예산 부족 우려", + "confidence": 0.80 +}} +``` + +### ❌ 나쁜 예시 +**입력**: "현재 서버 성능 이슈가 발생했습니다." +**나쁜 출력**: +```json +{{ + "content": "현재 서버 성능 이슈가 발생했습니다", ❌ 구어체 그대로 + "confidence": 0.85 +}} +``` +**이유**: "⚠️ 이슈: 서버 성능 문제 발생"으로 구조화하고 구어체 제거해야 함 + +--- + +## 💡 5. 아이디어/제안 (Suggestions) + +### 패턴 인식 +- "제안하는 바는 ~" +- "~하는 것이 좋을 것 같습니다" +- "~을 고려해 볼 필요가 있습니다" + +### ✅ 좋은 예시 +**입력**: "자동화 테스트를 도입하는 것을 검토해 보면 좋을 것 같습니다." +**출력**: +```json +{{ + "content": "💡 제안: 자동화 테스트 도입 검토", + "confidence": 0.85 +}} +``` + +--- + +## 📊 6. 진행 상황/보고 (Progress) + +### 패턴 인식 +- "~까지 완료했습니다" +- "현재 ~% 진행 중입니다" +- "~단계까지 진행됐습니다" + +### ✅ 좋은 예시 +**입력**: "현재 설계 단계는 80% 완료됐고, 다음 주부터 개발 착수 가능합니다." +**출력**: +```json +{{ + "content": "📊 진행상황: 설계 80% 완료, 다음 주 개발 착수 예정", + "confidence": 0.90 +}} +``` + +--- + +## ❌ 제외해야 할 내용 (반드시 제외) + +### 인사말 +**입력**: "안녕하세요, 여러분. 회의 시작하겠습니다." +**출력**: (메모 없음 - 인사말은 제외) + +### 단순 반복 +**입력**: "녹음을 시작합니다. 녹음을 시작합니다." +**출력**: (메모 없음 - 형식적 발언 제외) + +### 추임새/불필요한 발언 +**입력**: "음, 그러니까, 네 네, 저기요..." +**출력**: (메모 없음 - 추임새 제외) + +### 형식적 마무리 +**입력**: "수고하셨습니다. 회의를 마치겠습니다." +**출력**: (메모 없음 - 형식적 마무리 제외) + +--- + +# 실전 회의 시뮬레이션 + +## 예시 1: 프로젝트 킥오프 회의 + +**입력**: +"안녕하세요. 오늘 회의 안건은 신규 프로젝트 킥오프입니다. 프로젝트명은 HGZero이고, 목표는 회의록 자동화입니다. 개발팀에서 다음 주 월요일까지 기술 스택을 검토해 주세요. 예산은 5천만원으로 확정됐습니다." + +**출력**: +```json +{{ + "suggestions": [ + {{ + "content": "📋 회의 안건: 신규 프로젝트(HGZero) 킥오프 - 회의록 자동화", + "confidence": 0.95 + }}, + {{ + "content": "🎯 개발팀: 기술 스택 검토 (기한: 다음 주 월요일)", + "confidence": 0.93 + }}, + {{ + "content": "✅ 결정사항: 프로젝트 예산 5천만원 확정", + "confidence": 0.95 + }} + ] +}} +``` + +--- + +## 예시 2: 이슈 대응 회의 + +**입력**: +"현재 프로덕션 서버에서 성능 저하가 발생하고 있습니다. 인프라팀에서 긴급 점검을 진행하기로 했고, 오늘 오후 3시까지 원인 파악하겠습니다. 고객사에는 임시로 사과 공지를 게시하기로 결정했습니다." + +**출력**: +```json +{{ + "suggestions": [ + {{ + "content": "⚠️ 이슈: 프로덕션 서버 성능 저하 발생", + "confidence": 0.95 + }}, + {{ + "content": "🎯 인프라팀: 긴급 점검 및 원인 파악 (기한: 오늘 오후 3시)", + "confidence": 0.93 + }}, + {{ + "content": "✅ 결정사항: 고객사 사과 공지 게시", + "confidence": 0.90 + }} + ] +}} +``` + +--- + +## 예시 3: 일반 업무 회의 (나쁜 예시 포함) + +**입력**: +"안녕하세요, 안녕하세요. 녹음을 시작합니다. 음, 그러니까 마케팅 캠페인을 다음 달에 진행하기로 했습니다. 김 과장님이 기획안을 이번 주까지 작성해 주세요. 감사합니다." + +**❌ 나쁜 출력**: +```json +{{ + "suggestions": [ + {{ + "content": "안녕하세요", ❌ 인사말 포함 + "confidence": 0.50 + }}, + {{ + "content": "녹음을 시작합니다", ❌ 형식적 발언 + "confidence": 0.60 + }}, + {{ + "content": "마케팅 캠페인을 다음 달에 진행하기로 했습니다", ❌ 구어체 그대로 + "confidence": 0.80 + }} + ] +}} +``` + +**✅ 좋은 출력**: +```json +{{ + "suggestions": [ + {{ + "content": "✅ 결정사항: 마케팅 캠페인 다음 달 진행", + "confidence": 0.92 + }}, + {{ + "content": "🎯 김 과장: 캠페인 기획안 작성 (기한: 이번 주)", + "confidence": 0.93 + }} + ] +}} +``` --- # 출력 형식 + 반드시 아래 JSON 형식으로만 응답하세요: ```json {{ "suggestions": [ {{ - "content": "제안사항 내용 (구체적이고 실행 가능하게, 50자 이상 작성)", - "confidence": 0.85 (이 제안사항의 중요도/확실성, 0.7-1.0 사이) - }}, - {{ - "content": "또 다른 제안사항", - "confidence": 0.92 + "content": "📋/✅/🎯/⚠️/💡/📊 분류: 구체적인 내용 (담당자/기한 포함)", + "confidence": 0.85 }} ] }} ``` -# MVP 추출 규칙 (쉽고 명확하게) +--- -1. **위에 제시된 패턴을 먼저 찾으세요** - - "~해야", "~하기로", "~할 예정", "~부탁" 등 +# 최종 작성 규칙 -2. **실제로 언급된 내용만 추출** (추측 금지) +## ✅ 반드시 지켜야 할 규칙 -3. **1개 이상 추출** (없으면 빈 배열 반환) +1. **이모지 분류 필수** + - 📋 회의 안건 + - ✅ 결정사항 + - 🎯 액션 아이템 + - ⚠️ 이슈/문제점 + - 💡 제안/아이디어 + - 📊 진행상황 -4. **confidence 기준 완화**: 0.6 이상이면 OK +2. **구조화 필수** + - 담당자가 있으면 반드시 명시 + - 기한이 있으면 반드시 포함 + - 형식: "담당자: 업무 내용 (기한: XX)" -5. **길이 제한 완화**: 20자 이상이면 OK +3. **구어체 종결어미 제거** + - ❌ "~입니다", "~했습니다", "~해요", "~합니다" + - ✅ 명사형 종결: "~ 진행", "~ 완료", "~ 확정", "~ 검토" -6. **JSON만 출력** (```json, 주석, 설명 모두 금지) +4. **반드시 제외** + - 인사말 ("안녕하세요", "감사합니다", "수고하셨습니다") + - 반복/추임새 ("네 네", "음 음", "그러니까", "저기") + - 형식적 발언 ("녹음 시작", "회의 종료", "회의 시작") + +5. **길이** + - 20-70자 (너무 짧거나 길지 않게) + +6. **confidence 기준** + - 0.90-1.0: 명확한 결정사항, 기한 포함 + - 0.80-0.89: 일반적인 액션 아이템 + - 0.70-0.79: 암묵적이거나 추측 필요 + +7. **출력** + - JSON만 출력 (주석, 설명, ```json 모두 금지) + - 최소 1개 이상 추출 (의미 있는 내용이 없으면 빈 배열) --- -이제 위 회의 내용에서 제안사항을 JSON 형식으로 추출하세요. -명확한 액션 패턴("~해야", "~하기로" 등)이 있는 문장을 찾아 추출하면 됩니다.""" +이제 위 회의 내용을 분석하여 **회의록 메모**를 JSON 형식으로 작성하세요. +학습한 패턴을 활용하여 회의 안건, 결정사항, 액션 아이템, 이슈 등을 자동으로 분류하고 구조화하세요. +반드시 구어체 종결어미(~다, ~요, ~습니다)를 제거하고 명사형으로 정리하세요.""" return system_prompt, user_prompt diff --git a/ai-python/app/services/eventhub_service.py b/ai-python/app/services/eventhub_service.py index 6cea95f..b083a1a 100644 --- a/ai-python/app/services/eventhub_service.py +++ b/ai-python/app/services/eventhub_service.py @@ -2,6 +2,7 @@ import asyncio import logging import json +from datetime import datetime from azure.eventhub.aio import EventHubConsumerClient from app.config import get_settings @@ -63,12 +64,30 @@ class EventHubService: } """ try: - # 이벤트 원본 데이터 로깅 - raw_body = event.body_as_str() - logger.info(f"수신한 이벤트 원본 (처음 300자): {raw_body[:300]}") + # 이벤트 원본 데이터 추출 + try: + # Event Hub 데이터는 bytes 또는 str일 수 있음 + if hasattr(event, 'body_as_str'): + raw_body = event.body_as_str() + elif hasattr(event, 'body'): + raw_body = event.body.decode('utf-8') if isinstance(event.body, bytes) else str(event.body) + else: + logger.error(f"이벤트 타입 미지원: {type(event)}") + return + + logger.info(f"수신한 이벤트 원본 (처음 300자): {raw_body[:300]}") + logger.debug(f"이벤트 전체 길이: {len(raw_body)}자") + except Exception as extract_error: + logger.error(f"이벤트 데이터 추출 실패: {extract_error}", exc_info=True) + return # 이벤트 데이터 파싱 - event_data = json.loads(raw_body) + try: + event_data = json.loads(raw_body) + except json.JSONDecodeError as json_error: + logger.error(f"JSON 파싱 실패 - 전체 데이터: {raw_body}") + logger.error(f"파싱 에러: {json_error}") + return event_type = event_data.get("eventType") meeting_id = event_data.get("meetingId") @@ -78,7 +97,6 @@ class EventHubService: # timestamp 변환: LocalDateTime 배열 → Unix timestamp (ms) # Java LocalDateTime은 [year, month, day, hour, minute, second, nano] 형식 if isinstance(timestamp_raw, list) and len(timestamp_raw) >= 3: - from datetime import datetime year, month, day = timestamp_raw[0:3] hour = timestamp_raw[3] if len(timestamp_raw) > 3 else 0 minute = timestamp_raw[4] if len(timestamp_raw) > 4 else 0 diff --git a/ai-python/app/services/redis_service.py b/ai-python/app/services/redis_service.py index 018d6c5..3eab7c4 100644 --- a/ai-python/app/services/redis_service.py +++ b/ai-python/app/services/redis_service.py @@ -105,6 +105,34 @@ class RedisService: count = await self.redis_client.zcard(key) return count if count else 0 + async def add_generated_suggestion(self, meeting_id: str, suggestion_content: str): + """ + 생성된 제안사항 저장 (중복 방지용) + + Args: + meeting_id: 회의 ID + suggestion_content: 제안사항 내용 + """ + key = f"meeting:{meeting_id}:suggestions" + await self.redis_client.sadd(key, suggestion_content) + # TTL 설정 (1시간) + await self.redis_client.expire(key, 3600) + logger.debug(f"제안사항 저장 - meetingId: {meeting_id}") + + async def get_generated_suggestions(self, meeting_id: str) -> set: + """ + 이미 생성된 제안사항 목록 조회 + + Args: + meeting_id: 회의 ID + + Returns: + 제안사항 set + """ + key = f"meeting:{meeting_id}:suggestions" + suggestions = await self.redis_client.smembers(key) + return suggestions if suggestions else set() + async def cleanup_meeting_data(self, meeting_id: str): """ 회의 종료 시 데이터 정리 @@ -112,6 +140,10 @@ class RedisService: Args: meeting_id: 회의 ID """ - key = f"meeting:{meeting_id}:transcript" - await self.redis_client.delete(key) + transcript_key = f"meeting:{meeting_id}:transcript" + suggestions_key = f"meeting:{meeting_id}:suggestions" + + await self.redis_client.delete(transcript_key) + await self.redis_client.delete(suggestions_key) + logger.info(f"회의 데이터 정리 완료 - meetingId: {meeting_id}") diff --git a/ai-python/main.py b/ai-python/main.py index 5e6d0e9..e140050 100644 --- a/ai-python/main.py +++ b/ai-python/main.py @@ -28,13 +28,24 @@ app = FastAPI( openapi_url="/api/openapi.json" ) -# CORS 미들웨어 설정 +# CORS 미들웨어 설정 (SSE 지원) app.add_middleware( CORSMiddleware, - allow_origins=settings.cors_origins, + allow_origins=["*"], # 개발 환경에서는 모든 origin 허용 allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=[ + "Authorization", + "Content-Type", + "X-Requested-With", + "Accept", + "Origin", + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + "Cache-Control", + "X-Accel-Buffering" + ], + expose_headers=["*"], ) # API 라우터 등록 diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java b/stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java index 3d1438e..d359421 100644 --- a/stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java +++ b/stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java @@ -2,7 +2,7 @@ package com.unicorn.hgzero.stt.controller; import com.fasterxml.jackson.databind.ObjectMapper; import com.unicorn.hgzero.stt.dto.AudioChunkDto; -import com.unicorn.hgzero.stt.service.AudioBufferService; +import com.unicorn.hgzero.stt.service.InMemoryAudioBufferService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -24,7 +24,7 @@ import java.util.concurrent.ConcurrentHashMap; @RequiredArgsConstructor public class AudioWebSocketHandler extends AbstractWebSocketHandler { - private final AudioBufferService audioBufferService; + private final InMemoryAudioBufferService audioBufferService; private final ObjectMapper objectMapper; // 세션별 회의 ID 매핑 diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/event/publisher/EventHubPublisher.java b/stt/src/main/java/com/unicorn/hgzero/stt/event/publisher/EventHubPublisher.java index bd883fb..5e7a180 100644 --- a/stt/src/main/java/com/unicorn/hgzero/stt/event/publisher/EventHubPublisher.java +++ b/stt/src/main/java/com/unicorn/hgzero/stt/event/publisher/EventHubPublisher.java @@ -1,74 +1,118 @@ package com.unicorn.hgzero.stt.event.publisher; +import com.azure.messaging.eventhubs.EventData; +import com.azure.messaging.eventhubs.EventDataBatch; +import com.azure.messaging.eventhubs.EventHubClientBuilder; +import com.azure.messaging.eventhubs.EventHubProducerClient; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import java.util.concurrent.CompletableFuture; /** * Azure Event Hub 이벤트 발행자 구현체 - * Azure Event Hubs를 통한 이벤트 발행 기능 + * Azure Event Hubs를 통한 실제 이벤트 발행 기능 */ @Slf4j @Component @RequiredArgsConstructor public class EventHubPublisher implements EventPublisher { - + private final ObjectMapper objectMapper; - + + @Value("${azure.eventhub.connection-string}") + private String connectionString; + + @Value("${azure.eventhub.name}") + private String eventHubName; + + private EventHubProducerClient producerClient; + + /** + * Event Hub Producer Client 초기화 + */ + @PostConstruct + public void initialize() { + if (connectionString == null || connectionString.isEmpty()) { + log.warn("Event Hub 연결 문자열이 설정되지 않음 - Event Hub 발행 비활성화"); + return; + } + + try { + producerClient = new EventHubClientBuilder() + .connectionString(connectionString, eventHubName) + .buildProducerClient(); + + log.info("Event Hub Producer Client 초기화 완료 - EventHub: {}", eventHubName); + } catch (Exception e) { + log.error("Event Hub Producer Client 초기화 실패", e); + throw new RuntimeException("Event Hub 연결에 실패했습니다", e); + } + } + + /** + * 애플리케이션 종료 시 Producer Client 정리 + */ + @PreDestroy + public void cleanup() { + if (producerClient != null) { + try { + producerClient.close(); + log.info("Event Hub Producer Client 종료 완료"); + } catch (Exception e) { + log.error("Event Hub Producer Client 종료 중 오류", e); + } + } + } + @Override public void publish(String topic, Object event) { + if (producerClient == null) { + log.warn("Event Hub가 연결되지 않음 - 이벤트 발행 건너뜀"); + return; + } + try { String eventData = objectMapper.writeValueAsString(event); - - // 실제로는 Azure Event Hubs SDK 사용 - // EventHubProducerClient producer = createProducer(topic); - // EventDataBatch batch = producer.createBatch(); - // batch.tryAdd(new EventData(eventData)); - // producer.send(batch); - - // 시뮬레이션 - simulateEventHubPublish(topic, eventData); - - log.info("이벤트 발행 완료 - topic: {}, event: {}", topic, event.getClass().getSimpleName()); - + + // Event Data Batch 생성 및 전송 + EventDataBatch batch = producerClient.createBatch(); + + // 이벤트 추가 + boolean added = batch.tryAdd(new EventData(eventData)); + if (!added) { + log.error("이벤트가 배치 크기를 초과하여 추가 실패 - 데이터 길이: {}", eventData.length()); + throw new RuntimeException("이벤트 배치 추가 실패"); + } + + // Event Hub로 전송 + producerClient.send(batch); + + log.info("✅ 이벤트 발행 완료 - EventHub: {}, EventType: {}, 데이터 길이: {}자", + eventHubName, event.getClass().getSimpleName(), eventData.length()); + log.debug("발행된 데이터: {}", eventData); + } catch (Exception e) { - log.error("이벤트 발행 실패 - topic: {}, event: {}", topic, event.getClass().getSimpleName(), e); + log.error("이벤트 발행 실패 - EventHub: {}, EventType: {}", + eventHubName, event.getClass().getSimpleName(), e); throw new RuntimeException("이벤트 발행에 실패했습니다", e); } } - + @Override public void publishAsync(String topic, Object event) { CompletableFuture.runAsync(() -> { try { publish(topic, event); } catch (Exception e) { - log.error("비동기 이벤트 발행 실패 - topic: {}, event: {}", topic, event.getClass().getSimpleName(), e); + log.error("비동기 이벤트 발행 실패 - EventHub: {}, EventType: {}", + eventHubName, event.getClass().getSimpleName(), e); } }); } - - /** - * Azure Event Hub 발행 시뮬레이션 - */ - private void simulateEventHubPublish(String topic, String eventData) { - log.debug("Event Hub 발행 시뮬레이션:"); - log.debug("Topic: {}", topic); - log.debug("Event Data: {}", eventData); - - // 실제로는 다음과 같은 Azure Event Hubs 코드 사용: - /* - EventHubProducerClient producer = new EventHubClientBuilder() - .connectionString(connectionString, eventHubName) - .buildProducerClient(); - - EventDataBatch batch = producer.createBatch(); - batch.tryAdd(new EventData(eventData)); - producer.send(batch); - producer.close(); - */ - } -} \ No newline at end of file +} diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java b/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java index 4222d34..dbc163f 100644 --- a/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java +++ b/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java @@ -15,30 +15,44 @@ import java.util.Set; import java.util.UUID; /** - * 오디오 배치 프로세서 - * 15초마다 Redis에 축적된 오디오를 처리하여 텍스트로 변환 + * 오디오 배치 프로세서 (2단계 처리) * - * Note: STT 결과는 DB에 저장하지 않고, Event Hub와 WebSocket으로만 전송 - * 최종 회의록은 AI 서비스에서 저장 + * 1단계 (STT): 5초마다 짧은 세그먼트 변환 + * - 빠른 음성 인식으로 실시간 피드백 + * - WebSocket으로 클라이언트에 즉시 표시 + * - Event Hub로 AI 서비스에 전송 (누적) + * + * 2단계 (AI): AI 서비스에서 1분치 세그먼트로 제안사항 생성 + * - Redis 누적 텍스트 (4-5개 세그먼트) 분석 + * - SSE로 실시간 제안사항 전송 + * + * Note: STT 결과는 DB 저장 없음, Event Hub와 WebSocket으로만 전송 */ @Slf4j @Service @RequiredArgsConstructor public class AudioBatchProcessor { - private final AudioBufferService audioBufferService; + private final InMemoryAudioBufferService audioBufferService; private final AzureSpeechService azureSpeechService; private final EventPublisher eventPublisher; private final AudioWebSocketHandler webSocketHandler; /** - * 15초마다 오디오 배치 처리 + * 7초마다 오디오 배치 처리 (실시간 STT) * - Redis에서 오디오 청크 조회 - * - Azure Speech로 텍스트 변환 - * - Event Hub 이벤트 발행 (AI 서비스로 전송) - * - WebSocket 실시간 전송 (클라이언트 표시) + * - Azure Speech로 텍스트 변환 (적절한 길이의 세그먼트) + * - Event Hub 이벤트 발행 (AI 서비스로 전송, 누적됨) + * - WebSocket 실시간 전송 (클라이언트에 즉시 표시) + * + * 7초 선택 이유: + * - 문장 완성도: 대부분의 발화가 완료되는 시간 + * - 실시간성: 사용자가 즉각 피드백 확인 가능 + * - Azure Speech 호환: recognizeOnceAsync() 최대 15초 이내 + * + * AI 제안사항은 AI 서비스에서 별도로 생성 (약 1분치 누적 분석) */ - @Scheduled(fixedDelay = 15000, initialDelay = 15000) // 15초마다 실행, 최초 15초 후 시작 + @Scheduled(fixedDelay = 7000, initialDelay = 7000) // 7초마다 실행 public void processAudioBatch() { try { // 활성 회의 목록 조회 @@ -68,7 +82,7 @@ public class AudioBatchProcessor { */ private void processOneMeeting(String meetingId) { try { - // Redis에서 최근 15초 오디오 청크 조회 + // Redis에서 최근 7초 오디오 청크 조회 List chunks = audioBufferService.getAudioChunks(meetingId); if (chunks.isEmpty()) { @@ -78,7 +92,7 @@ public class AudioBatchProcessor { log.info("오디오 청크 조회 완료 - meetingId: {}, chunks: {}개", meetingId, chunks.size()); - // 오디오 청크 병합 (15초 분량) + // 오디오 청크 병합 (7초 분량) byte[] mergedAudio = audioBufferService.mergeAudioChunks(chunks); if (mergedAudio.length == 0) { @@ -102,7 +116,7 @@ public class AudioBatchProcessor { // WebSocket으로 실시간 결과 전송 (클라이언트 표시) sendTranscriptToClients(meetingId, result); - // Redis 정리 + // 처리 완료된 청크 삭제 (중복 처리 방지) audioBufferService.clearProcessedChunks(meetingId); log.info("회의 처리 완료 - meetingId: {}, text: {}", meetingId, result.getText()); diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java b/stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java index 9ec54e4..7b9aa49 100644 --- a/stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java +++ b/stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java @@ -4,6 +4,7 @@ import com.microsoft.cognitiveservices.speech.*; import com.microsoft.cognitiveservices.speech.audio.AudioConfig; import com.microsoft.cognitiveservices.speech.audio.AudioInputStream; import com.microsoft.cognitiveservices.speech.audio.PushAudioInputStream; +import com.unicorn.hgzero.stt.util.AudioConverter; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; @@ -43,9 +44,15 @@ public class AzureSpeechService { speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region); speechConfig.setSpeechRecognitionLanguage(language); - // 연속 인식 설정 최적화 - speechConfig.setProperty(PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, "3000"); - speechConfig.setProperty(PropertyId.SpeechServiceConnection_InitialSilenceTimeoutMs, "10000"); + // 연속 인식 설정 최적화 - 회의록에 적합하게 조정 + speechConfig.setProperty(PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, "5000"); // 5초로 증가 + speechConfig.setProperty(PropertyId.SpeechServiceConnection_InitialSilenceTimeoutMs, "15000"); // 15초로 증가 + + // 중간 결과 활성화 (연속 인식 시 유용) + speechConfig.setProperty(PropertyId.SpeechServiceResponse_RequestDetailedResultTrueFalse, "true"); + + // 음성 인식 품질 향상 + speechConfig.setProperty(PropertyId.SpeechServiceConnection_RecoLanguage, language); log.info("Azure Speech Service 초기화 완료 - Region: {}, Language: {}", region, language); @@ -58,7 +65,7 @@ public class AzureSpeechService { /** * 오디오 데이터를 텍스트로 변환 (배치 처리용) * - * @param audioData 병합된 오디오 데이터 (5초 분량) + * @param audioData 병합된 오디오 데이터 (15초 분량) * @return 인식 결과 */ public RecognitionResult recognizeAudio(byte[] audioData) { @@ -67,10 +74,69 @@ public class AzureSpeechService { return createSimulationResult(); } + // 오디오 데이터 품질 검증 + if (!AudioConverter.isValidAudioData(audioData)) { + log.warn("유효하지 않은 오디오 데이터 - 인식 건너뜀"); + return new RecognitionResult("", 0.0, false); + } + + // 오디오 통계 로깅 (디버깅용) + AudioConverter.AudioStats stats = AudioConverter.calculateStats(audioData); + log.debug("오디오 통계: {}", stats); + + // 재시도 로직 (최대 3회) + int maxRetries = 3; + int retryDelay = 1000; // 1초 + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + RecognitionResult result = recognizeAudioInternal(audioData); + + if (result.isSuccess()) { + log.info("음성 인식 성공 (시도 {}/{})", attempt, maxRetries); + return result; + } + + // NoMatch 결과일 경우 재시도 + if (attempt < maxRetries) { + log.warn("음성 인식 실패 (NoMatch) - 재시도 {}/{}", attempt, maxRetries); + Thread.sleep(retryDelay); + retryDelay *= 2; // 지수 백오프 + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("재시도 중단됨", e); + break; + } catch (Exception e) { + log.error("음성 인식 실패 (시도 {}/{})", attempt, maxRetries, e); + if (attempt < maxRetries) { + try { + Thread.sleep(retryDelay); + retryDelay *= 2; + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + + log.warn("음성 인식 최종 실패 - 최대 재시도 횟수 초과"); + return new RecognitionResult("", 0.0, false); + } + + /** + * 실제 음성 인식 수행 (내부 메서드) + */ + private RecognitionResult recognizeAudioInternal(byte[] audioData) throws Exception { PushAudioInputStream pushStream = null; SpeechRecognizer recognizer = null; try { + // WAV 형식으로 변환 + byte[] wavData = AudioConverter.convertToWav(audioData); + // Push 오디오 스트림 생성 pushStream = AudioInputStream.createPushStream(); AudioConfig audioConfig = AudioConfig.fromStreamInput(pushStream); @@ -79,7 +145,7 @@ public class AzureSpeechService { recognizer = new SpeechRecognizer(speechConfig, audioConfig); // 오디오 데이터 전송 - pushStream.write(audioData); + pushStream.write(wavData); pushStream.close(); // 인식 실행 (동기 방식) @@ -88,10 +154,6 @@ public class AzureSpeechService { // 결과 처리 return processRecognitionResult(result); - } catch (Exception e) { - log.error("음성 인식 실패", e); - return new RecognitionResult("", 0.0, false); - } finally { // 리소스 정리 if (recognizer != null) { @@ -106,19 +168,25 @@ public class AzureSpeechService { private RecognitionResult processRecognitionResult(SpeechRecognitionResult result) { if (result.getReason() == ResultReason.RecognizedSpeech) { String text = result.getText(); - double confidence = calculateConfidence(text); - log.info("음성 인식 성공: {}, 신뢰도: {:.2f}", text, confidence); + // Azure Speech SDK의 실제 신뢰도 사용 (있는 경우) + // Note: Java SDK는 confidence를 직접 제공하지 않으므로 추정치 사용 + double confidence = calculateConfidence(text, result); + + log.info("음성 인식 성공: {}, 신뢰도: {}", text, confidence); return new RecognitionResult(text, confidence, true); } else if (result.getReason() == ResultReason.NoMatch) { - log.debug("음성 인식 실패 - NoMatch (무음 또는 인식 불가)"); + NoMatchDetails noMatch = NoMatchDetails.fromResult(result); + log.debug("음성 인식 실패 - NoMatch, Reason: {}", noMatch.getReason()); return new RecognitionResult("", 0.0, false); } else if (result.getReason() == ResultReason.Canceled) { CancellationDetails cancellation = CancellationDetails.fromResult(result); - log.error("음성 인식 취소 - Reason: {}, Details: {}", - cancellation.getReason(), cancellation.getErrorDetails()); + log.error("음성 인식 취소 - Reason: {}, ErrorCode: {}, Details: {}", + cancellation.getReason(), + cancellation.getErrorCode(), + cancellation.getErrorDetails()); return new RecognitionResult("", 0.0, false); } @@ -126,20 +194,45 @@ public class AzureSpeechService { } /** - * 신뢰도 계산 (추정) - * Azure Speech는 confidence를 직접 제공하지 않으므로 텍스트 길이 기반 추정 + * 신뢰도 계산 (개선된 추정 알고리즘) + * Azure Speech Java SDK는 confidence를 직접 제공하지 않으므로 여러 지표 기반 추정 */ - private double calculateConfidence(String text) { + private double calculateConfidence(String text, SpeechRecognitionResult result) { if (text == null || text.trim().isEmpty()) { return 0.0; } - // 텍스트 길이 기반 휴리스틱 + double confidence = 0.7; // 기본값 + + // 1. 텍스트 길이 기반 (더 긴 텍스트 = 높은 신뢰도) int length = text.length(); - if (length > 50) return 0.95; - if (length > 20) return 0.85; - if (length > 10) return 0.75; - return 0.65; + if (length > 50) { + confidence += 0.15; + } else if (length > 20) { + confidence += 0.10; + } else if (length > 10) { + confidence += 0.05; + } + + // 2. 단어 수 기반 (더 많은 단어 = 높은 신뢰도) + String[] words = text.trim().split("\\s+"); + if (words.length > 10) { + confidence += 0.10; + } else if (words.length > 5) { + confidence += 0.05; + } + + // 3. 특수문자 비율 (낮을수록 높은 신뢰도) + long specialCharCount = text.chars() + .filter(c -> !Character.isLetterOrDigit(c) && !Character.isWhitespace(c)) + .count(); + double specialCharRatio = (double) specialCharCount / length; + if (specialCharRatio < 0.05) { + confidence += 0.05; + } + + // 최대값 제한 + return Math.min(confidence, 0.98); } /** diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/service/InMemoryAudioBufferService.java b/stt/src/main/java/com/unicorn/hgzero/stt/service/InMemoryAudioBufferService.java new file mode 100644 index 0000000..fd81cf0 --- /dev/null +++ b/stt/src/main/java/com/unicorn/hgzero/stt/service/InMemoryAudioBufferService.java @@ -0,0 +1,165 @@ +package com.unicorn.hgzero.stt.service; + +import com.unicorn.hgzero.stt.dto.AudioChunkDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 인메모리 오디오 버퍼 서비스 (Redis 대체) + * + * 7초 배치 처리를 위한 임시 저장소 + * - 빠른 속도: 네트워크 없음, 메모리 직접 접근 + * - 타임아웃 없음: Azure Redis 타임아웃 문제 해결 + * - 간단함: Redis 설정 불필요 + * + * Note: 서버 재시작 시 데이터 손실 가능 (7초 분량만 손실되므로 MVP에서는 허용) + */ +@Slf4j +@Service +public class InMemoryAudioBufferService { + + // 회의 ID별 오디오 청크 저장소 + private final Map> audioChunksMap = new ConcurrentHashMap<>(); + + // 활성 회의 목록 + private final Set activeMeetings = ConcurrentHashMap.newKeySet(); + + /** + * 오디오 청크 버퍼링 (인메모리 저장) + */ + public void bufferAudioChunk(AudioChunkDto chunk) { + try { + String meetingId = chunk.getMeetingId(); + + // 회의별 청크 리스트 가져오기 (없으면 생성) + List chunks = audioChunksMap.computeIfAbsent( + meetingId, + k -> Collections.synchronizedList(new ArrayList<>()) + ); + + // 청크 추가 + chunks.add(chunk); + + // 활성 회의 목록에 추가 + activeMeetings.add(meetingId); + + log.debug("오디오 청크 버퍼링 완료 (인메모리) - meetingId: {}, chunkIndex: {}, 총: {}개", + meetingId, chunk.getChunkIndex(), chunks.size()); + + } catch (Exception e) { + log.error("오디오 청크 버퍼링 실패 - meetingId: {}", chunk.getMeetingId(), e); + } + } + + /** + * 활성 회의 목록 조회 + */ + public Set getActiveMeetings() { + return new HashSet<>(activeMeetings); + } + + /** + * 회의의 모든 오디오 청크 조회 (배치 처리용) + */ + public List getAudioChunks(String meetingId) { + List chunks = audioChunksMap.get(meetingId); + + if (chunks == null || chunks.isEmpty()) { + return Collections.emptyList(); + } + + // 복사본 반환 (thread-safe) + synchronized (chunks) { + return new ArrayList<>(chunks); + } + } + + /** + * 처리 완료된 청크 삭제 + */ + public void clearProcessedChunks(String meetingId) { + try { + List chunks = audioChunksMap.get(meetingId); + + if (chunks != null) { + synchronized (chunks) { + int removedCount = chunks.size(); + chunks.clear(); + log.debug("오디오 청크 삭제 완료 - meetingId: {}, 삭제된 청크: {}개", + meetingId, removedCount); + } + } + + } catch (Exception e) { + log.error("오디오 청크 삭제 실패 - meetingId: {}", meetingId, e); + } + } + + /** + * 오디오 청크 병합 (7초 분량) + */ + public byte[] mergeAudioChunks(List chunks) { + if (chunks == null || chunks.isEmpty()) { + return new byte[0]; + } + + try { + // 청크 인덱스 순서로 정렬 + List sortedChunks = chunks.stream() + .sorted(Comparator.comparing(AudioChunkDto::getChunkIndex)) + .collect(Collectors.toList()); + + // 전체 크기 계산 + int totalSize = sortedChunks.stream() + .mapToInt(chunk -> chunk.getAudioData().length) + .sum(); + + // 병합 + byte[] mergedAudio = new byte[totalSize]; + int position = 0; + + for (AudioChunkDto chunk : sortedChunks) { + byte[] chunkData = chunk.getAudioData(); + System.arraycopy(chunkData, 0, mergedAudio, position, chunkData.length); + position += chunkData.length; + } + + log.debug("오디오 청크 병합 완료 - 청크 수: {}, 총 크기: {} bytes", + sortedChunks.size(), totalSize); + + return mergedAudio; + + } catch (Exception e) { + log.error("오디오 청크 병합 실패", e); + return new byte[0]; + } + } + + /** + * 회의 종료 시 데이터 정리 + */ + public void cleanupMeeting(String meetingId) { + audioChunksMap.remove(meetingId); + activeMeetings.remove(meetingId); + log.info("회의 데이터 정리 완료 (인메모리) - meetingId: {}", meetingId); + } + + /** + * 전체 통계 조회 (모니터링용) + */ + public Map getStatistics() { + int totalChunks = audioChunksMap.values().stream() + .mapToInt(List::size) + .sum(); + + return Map.of( + "activeMeetings", activeMeetings.size(), + "totalChunks", totalChunks, + "meetingsWithData", audioChunksMap.size() + ); + } +} diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/util/AudioConverter.java b/stt/src/main/java/com/unicorn/hgzero/stt/util/AudioConverter.java new file mode 100644 index 0000000..f474a72 --- /dev/null +++ b/stt/src/main/java/com/unicorn/hgzero/stt/util/AudioConverter.java @@ -0,0 +1,204 @@ +package com.unicorn.hgzero.stt.util; + +import lombok.extern.slf4j.Slf4j; + +import javax.sound.sampled.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * 오디오 형식 변환 유틸리티 + * WebM → WAV(PCM 16bit 16kHz) 변환 + */ +@Slf4j +public class AudioConverter { + + private static final int TARGET_SAMPLE_RATE = 16000; + private static final int TARGET_SAMPLE_SIZE_IN_BITS = 16; + private static final int TARGET_CHANNELS = 1; // Mono + private static final boolean TARGET_SIGNED = true; + private static final boolean TARGET_BIG_ENDIAN = false; + + /** + * WebM/기타 형식을 WAV(PCM 16bit 16kHz mono)로 변환 + * + * @param audioData 원본 오디오 데이터 + * @return WAV 형식 오디오 데이터 + */ + public static byte[] convertToWav(byte[] audioData) { + if (audioData == null || audioData.length == 0) { + log.warn("변환할 오디오 데이터 없음"); + return new byte[0]; + } + + try { + // WebM은 JavaSound API가 직접 지원하지 않으므로 + // 이미 PCM 데이터라고 가정하고 WAV 헤더만 추가 + // (실제 WebM 디코딩은 FFmpeg 필요) + + // 최소 데이터 크기 검증 (1초 = 16000 samples * 2 bytes = 32KB) + if (audioData.length < 16000) { + log.warn("오디오 데이터가 너무 작음 ({}bytes) - 최소 16KB 필요", audioData.length); + } + + // WAV 헤더 생성 및 데이터 결합 + byte[] wavData = addWavHeader(audioData); + + log.debug("오디오 형식 변환 완료 - 원본: {}bytes → WAV: {}bytes", + audioData.length, wavData.length); + + return wavData; + + } catch (Exception e) { + log.error("오디오 형식 변환 실패", e); + return audioData; // 실패 시 원본 반환 + } + } + + /** + * PCM 데이터에 WAV 헤더 추가 + * Format: PCM 16bit 16kHz Mono + */ + private static byte[] addWavHeader(byte[] pcmData) throws IOException { + ByteArrayOutputStream wavStream = new ByteArrayOutputStream(); + + // RIFF 헤더 + wavStream.write("RIFF".getBytes()); + wavStream.write(intToByteArray(36 + pcmData.length), 0, 4); // ChunkSize + wavStream.write("WAVE".getBytes()); + + // fmt 청크 + wavStream.write("fmt ".getBytes()); + wavStream.write(intToByteArray(16), 0, 4); // Subchunk1Size (PCM = 16) + wavStream.write(shortToByteArray((short) 1), 0, 2); // AudioFormat (1 = PCM) + wavStream.write(shortToByteArray((short) TARGET_CHANNELS), 0, 2); // NumChannels + wavStream.write(intToByteArray(TARGET_SAMPLE_RATE), 0, 4); // SampleRate + + int byteRate = TARGET_SAMPLE_RATE * TARGET_CHANNELS * TARGET_SAMPLE_SIZE_IN_BITS / 8; + wavStream.write(intToByteArray(byteRate), 0, 4); // ByteRate + + int blockAlign = TARGET_CHANNELS * TARGET_SAMPLE_SIZE_IN_BITS / 8; + wavStream.write(shortToByteArray((short) blockAlign), 0, 2); // BlockAlign + wavStream.write(shortToByteArray((short) TARGET_SAMPLE_SIZE_IN_BITS), 0, 2); // BitsPerSample + + // data 청크 + wavStream.write("data".getBytes()); + wavStream.write(intToByteArray(pcmData.length), 0, 4); // Subchunk2Size + wavStream.write(pcmData); + + return wavStream.toByteArray(); + } + + /** + * int를 little-endian byte array로 변환 + */ + private static byte[] intToByteArray(int value) { + return new byte[] { + (byte) (value & 0xff), + (byte) ((value >> 8) & 0xff), + (byte) ((value >> 16) & 0xff), + (byte) ((value >> 24) & 0xff) + }; + } + + /** + * short를 little-endian byte array로 변환 + */ + private static byte[] shortToByteArray(short value) { + return new byte[] { + (byte) (value & 0xff), + (byte) ((value >> 8) & 0xff) + }; + } + + /** + * 오디오 품질 검증 + * + * @param audioData 검증할 오디오 데이터 + * @return 최소 품질 기준 충족 여부 + */ + public static boolean isValidAudioData(byte[] audioData) { + if (audioData == null || audioData.length == 0) { + log.warn("오디오 데이터 없음"); + return false; + } + + // 최소 크기: 0.5초 = 16000 samples/sec * 0.5 sec * 2 bytes = 16KB + int minSize = TARGET_SAMPLE_RATE * TARGET_SAMPLE_SIZE_IN_BITS / 8 / 2; + + if (audioData.length < minSize) { + log.warn("오디오 데이터가 너무 작음 - size: {}bytes, 최소: {}bytes", + audioData.length, minSize); + return false; + } + + // 무음 여부 검증 (모든 샘플이 0이면 무음) + boolean allZero = true; + for (byte b : audioData) { + if (b != 0) { + allZero = false; + break; + } + } + + if (allZero) { + log.warn("무음 데이터 감지"); + return false; + } + + return true; + } + + /** + * 오디오 데이터 통계 계산 (디버깅용) + */ + public static AudioStats calculateStats(byte[] audioData) { + if (audioData == null || audioData.length == 0) { + return new AudioStats(0, 0.0, 0.0, 0.0); + } + + double sum = 0; + double sumSquares = 0; + int max = 0; + + for (int i = 0; i < audioData.length - 1; i += 2) { + // 16bit PCM 샘플 읽기 (little-endian) + short sample = (short) ((audioData[i + 1] << 8) | (audioData[i] & 0xff)); + int absSample = Math.abs(sample); + + sum += absSample; + sumSquares += absSample * absSample; + max = Math.max(max, absSample); + } + + int numSamples = audioData.length / 2; + double avg = sum / numSamples; + double rms = Math.sqrt(sumSquares / numSamples); + + return new AudioStats(numSamples, avg, rms, max); + } + + /** + * 오디오 통계 정보 + */ + public static class AudioStats { + public final int numSamples; + public final double avgAmplitude; + public final double rmsAmplitude; + public final double maxAmplitude; + + public AudioStats(int numSamples, double avgAmplitude, double rmsAmplitude, double maxAmplitude) { + this.numSamples = numSamples; + this.avgAmplitude = avgAmplitude; + this.rmsAmplitude = rmsAmplitude; + this.maxAmplitude = maxAmplitude; + } + + @Override + public String toString() { + return String.format("AudioStats{samples=%d, avg=%.1f, rms=%.1f, max=%.1f}", + numSamples, avgAmplitude, rmsAmplitude, maxAmplitude); + } + } +} diff --git a/stt/src/main/resources/application.yml b/stt/src/main/resources/application.yml index 2ae8084..27d0b35 100644 --- a/stt/src/main/resources/application.yml +++ b/stt/src/main/resources/application.yml @@ -37,13 +37,15 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} - timeout: 2000ms + timeout: 30000ms # 30초로 증가 (Azure 원격 Redis 대응) + connect-timeout: 10000ms # 연결 타임아웃 명시 lettuce: pool: - max-active: 8 - max-idle: 8 - min-idle: 0 - max-wait: -1ms + max-active: 20 # 연결 풀 크기 증가 (8 → 20) + max-idle: 10 + min-idle: 2 # 최소 유지 연결 (0 → 2) + max-wait: 5000ms # 연결 대기 최대 시간 (-1ms → 5000ms) + shutdown-timeout: 2000ms database: ${REDIS_DATABASE:3} # Server Configuration