diff --git a/design/userstory.md b/design/userstory.md index 621af4a..0ca44ad 100644 --- a/design/userstory.md +++ b/design/userstory.md @@ -33,7 +33,6 @@ 5. **RAG** - 맥락 기반 용어 설명, 관련 문서 검색 및 연결, 업무 이력 통합 6. **Collaboration** - 실시간 동기화, 버전 관리, 충돌 해결 7. **Todo** - Todo 할당 및 관리, 진행 상황 추적, 회의록 실시간 연동 -8. **Notification** - 알림 발송 및 리마인더 관리 --- @@ -277,10 +276,10 @@ UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용 [음성 녹음 처리] - 오디오 스트림 실시간 캡처 - 회의 ID와 연결 - - 음성 데이터 저장 (Azure 스토리지) + - 음성 데이터 저장 [발언 인식 처리] - - AI 음성인식 엔진 연동 (Azure Speech 등) + - AI 음성인식 엔진 연동 - 화자 자동 식별 - 참석자 목록 매칭 - 음성 특징 분석 diff --git a/design/구현방안-STT.md b/design/구현방안-STT.md new file mode 100644 index 0000000..329d55b --- /dev/null +++ b/design/구현방안-STT.md @@ -0,0 +1,1080 @@ +# STT (Speech-to-Text) 구현방안 + +## 📋 문서 정보 +- **작성일**: 2025-10-21 +- **최종 수정일**: 2025-10-21 +- **작성자**: 회의록 서비스 개발팀 +- **버전**: 2.0 +- **검토자**: 박서연(AI), 이준호(Backend), 이동욱(Backend), 최유진(Frontend), 홍길동(Architect), 정도현(QA) +- **STT 엔진**: Azure Speech Services (실시간 스트리밍 + 화자 식별) + +--- + +## 1. 개요 + +### 1.1 목적 +회의 참석자의 발언을 실시간으로 음성 인식하여 텍스트로 변환하고, AI 기반 회의록 자동 작성의 기반 데이터를 제공합니다. + +### 1.2 핵심 요구사항 +- **실시간성**: 발언 후 1초 이내 화면 표시 (Azure 실시간 스트리밍) +- **정확도**: STT confidence score 90% 이상 +- **화자 식별**: 참석자별 발언 자동 구분 (Azure Speaker Diarization) +- **안정성**: 네트워크 장애 시에도 녹음 데이터 보존 + +### 1.3 Azure Speech Services 선정 이유 +- ✅ **실시간 스트리밍**: 1초 이내 지연 시간으로 요구사항 충족 +- ✅ **화자 식별 기본 제공**: Speaker Diarization 내장 (별도 구현 불필요) +- ✅ **한국어 최적화**: Microsoft의 한국어 특화 모델로 높은 정확도 +- ✅ **엔터프라이즈 안정성**: 99.9% SLA 보장 +- ✅ **Azure 생태계 통합**: 향후 Azure 기반 인프라 확장 용이 + +### 1.4 차별화 전략 +STT 자체는 기본 기능(Hygiene Factor)이나, 다음 차별화 요소와 연계됩니다: +- 맥락 기반 용어 설명 (RAG) +- AI 회의록 자동 작성 +- Todo 자동 추출 + +--- + +## 2. 아키텍처 설계 + +### 2.1 전체 구조 + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Client │─────▶│ STT Gateway │─────▶│ Azure Speech │ +│ (Browser) │ │ Service │ │ Services │ +│ │ │ │ │ (실시간 스트리밍)│ +└─────────────┘ └──────────────┘ └─────────────────┘ + │ │ │ + │ │ │ + │ │ ┌───────▼───────┐ + │ │ │ Speaker │ + │ │ │ Diarization │ + │ │ │ (화자 식별) │ + │ │ └───────────────┘ + │ │ │ + ▼ │ │ +┌─────────────┐ ┌──────▼──────┐ ┌───────▼─────┐ +│ WebSocket │◀─────│ RabbitMQ │◀─────│ Claude API │ +│ Server │ │ Queue │ │ (후처리) │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ + │ ▼ + │ ┌─────────────┐ + └─────────────▶│ Redis │ + │ Cache │ + └─────────────┘ +``` + +### 2.2 계층별 역할 + +#### **Client Layer (Frontend)** +- **MediaRecorder API**: 브라우저에서 실시간 음성 캡처 +- **WebSocket Client**: 실시간 텍스트 수신 및 화면 동기화 +- **로컬 저장**: 네트워크 장애 시 음성 데이터 임시 저장 + +#### **STT Gateway Service** +- **오디오 스트림 수신**: 클라이언트로부터 실시간 음성 스트림 수신 +- **Azure Speech 연동**: Azure Speech Services 실시간 스트리밍 API 호출 +- **화자 식별 처리**: Azure Speaker Diarization 결과 수신 및 참석자 매칭 +- **이벤트 발행**: RabbitMQ에 `TextTranscribed` 이벤트 발행 + +#### **Azure Speech Services** +- **실시간 스트리밍 STT**: 음성을 실시간으로 텍스트 변환 (< 1초 지연) +- **Speaker Diarization**: 화자별 발언 자동 구분 +- **언어 모델**: 한국어 특화 최적화 모델 +- **신뢰도 점수**: 각 발언에 대한 confidence score 제공 + +#### **Message Queue (RabbitMQ)** +- **비동기 처리**: STT 결과를 비동기로 후속 서비스에 전달 +- **이벤트 라우팅**: `TextTranscribed` → AI Service, Meeting Service +- **재시도 로직**: 실패 시 자동 재처리 (최대 3회) + +#### **AI Service (Claude API)** +- **텍스트 후처리**: 구어체 → 문어체 변환, 문법 교정 +- **회의록 구조화**: 템플릿에 맞춰 내용 정리 +- **Todo 추출**: 액션 아이템 자동 식별 + +#### **Cache Layer (Redis)** +- **실시간 발언 캐싱**: `meeting:{meeting_id}:live_text` +- **섹션별 내용 캐싱**: `meeting:{meeting_id}:sections:{section_id}` +- **화자 정보 캐싱**: `meeting:{meeting_id}:speakers` + +#### **WebSocket Server** +- **실시간 동기화**: 모든 참석자에게 텍스트 변환 결과 즉시 전송 +- **Delta 전송**: 변경된 부분만 전송하여 대역폭 최적화 + +--- + +## 3. 데이터 구조 설계 + +### 3.1 Azure Speech 스트리밍 연결 설정 + +```json +{ + "session_id": "SESSION_001", + "meeting_id": "MTG_001", + "config": { + "language": "ko-KR", + "sample_rate": 16000, + "format": "audio/wav", + "enable_diarization": true, + "max_speakers": 10, + "profanity_filter": "masked", + "enable_dictation": true + }, + "participants": [ + { + "user_id": "USR_001", + "name": "김철수", + "voice_signature": null + }, + { + "user_id": "USR_002", + "name": "이영희", + "voice_signature": null + } + ] +} +``` + +### 3.2 실시간 오디오 스트림 전송 (WebSocket) + +```json +{ + "type": "audio_chunk", + "session_id": "SESSION_001", + "audio_data": "base64_encoded_audio", + "timestamp": "2025-10-21T14:30:15.000Z", + "sequence": 42 +} +``` + +### 3.3 Azure Speech 실시간 응답 (WebSocket) + +```json +{ + "type": "recognition_result", + "session_id": "SESSION_001", + "result_id": "RESULT_001", + "recognition_status": "Success", + "duration": 4500000000, + "offset": 0, + "text": "회의를 시작하겠습니다. 오늘은 프로젝트 킥오프 회의입니다.", + "confidence": 0.95, + "speaker_id": "Speaker_1", + "lexical": "회의를 시작하겠습니다 오늘은 프로젝트 킥오프 회의입니다", + "itn": "회의를 시작하겠습니다. 오늘은 프로젝트 킥오프 회의입니다.", + "display": "회의를 시작하겠습니다. 오늘은 프로젝트 킥오프 회의입니다.", + "words": [ + { + "word": "회의를", + "offset": 0, + "duration": 400000000, + "confidence": 0.96 + }, + { + "word": "시작하겠습니다", + "offset": 400000000, + "duration": 1100000000, + "confidence": 0.94 + } + ], + "is_final": true, + "timestamp": "2025-10-21T14:30:16.000Z" +} +``` + +### 3.4 화자 매칭 결과 (STT Gateway 내부 처리) + +```json +{ + "result_id": "RESULT_001", + "azure_speaker_id": "Speaker_1", + "matched_user": { + "user_id": "USR_001", + "name": "김철수", + "confidence": 0.88 + }, + "matching_method": "voice_pattern", + "timestamp": "2025-10-21T14:30:16.000Z" +} +``` + +### 3.5 Claude API 호출 구조 + +#### **요청 (STT Gateway → Claude API)** + +```json +{ + "model": "claude-3-5-sonnet-20241022", + "max_tokens": 2048, + "messages": [ + { + "role": "user", + "content": "다음은 회의 발언 내용입니다. 회의록 형식에 맞춰 정리해주세요.\n\n발언: \"회의를 시작하겠습니다. 오늘은 프로젝트 킥오프 회의입니다.\"\n화자: 김철수\n시간: 2025-10-21 14:30:15\n\n템플릿 섹션: 안건, 논의 내용, 결정 사항, Todo" + } + ], + "temperature": 0.3, + "system": "당신은 회의록 작성 전문가입니다. 발언 내용을 구조화하여 명확하고 간결하게 정리합니다." +} +``` + +#### **응답 (Claude API → AI Service)** + +```json +{ + "id": "msg_01XYZ...", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "## 안건\n- 프로젝트 킥오프 회의 진행\n\n## 논의 내용\n- (발언 내용을 기반으로 자동 작성됩니다)\n\n## 결정 사항\n- (아직 결정된 사항 없음)\n\n## Todo\n- (아직 할당된 작업 없음)" + } + ], + "model": "claude-3-5-sonnet-20241022", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 245, + "output_tokens": 128 + } +} +``` + +### 3.4 RabbitMQ 이벤트 구조 + +```json +{ + "event_type": "TextTranscribed", + "event_id": "EVT_001", + "timestamp": "2025-10-21T14:30:18.000Z", + "correlation_id": "CORR_001", + "payload": { + "meeting_id": "MTG_001", + "speaker": { + "id": "USR_001", + "name": "김철수" + }, + "transcription": { + "text": "회의를 시작하겠습니다. 오늘은 프로젝트 킥오프 회의입니다.", + "confidence": 0.95, + "segments": [...] + }, + "timestamp": "2025-10-21T14:30:15.000Z" + }, + "metadata": { + "source": "stt-gateway-service", + "version": "1.0" + } +} +``` + +### 3.6 Redis 캐시 구조 + +```javascript +// 1. 실시간 발언 (TTL: 10분) +Key: "meeting:MTG_001:live_text" +Value: { + "speaker": "김철수", + "text": "회의를 시작하겠습니다...", + "timestamp": "2025-10-21T14:30:15.000Z", + "is_final": true +} + +// 2. 섹션별 내용 (TTL: 회의 종료 후 1시간) +Key: "meeting:MTG_001:sections:agenda" +Value: { + "section_id": "agenda", + "section_name": "안건", + "content": "프로젝트 킥오프 회의 진행\n- 프로젝트 목표 및 범위 확정\n- 역할 분담 및 일정 계획", + "verified": false, + "last_updated": "2025-10-21T14:32:00.000Z" +} + +// 3. 화자 정보 (TTL: 회의 종료 후 1시간) +Key: "meeting:MTG_001:speakers" +Value: [ + { + "id": "USR_001", + "name": "김철수", + "role": "주관자", + "speech_count": 15, + "speech_duration_ms": 180000 + }, + { + "id": "USR_002", + "name": "이영희", + "role": "참석자", + "speech_count": 12, + "speech_duration_ms": 150000 + } +] + +// 4. 회의 메타데이터 (TTL: 회의 종료 후 24시간) +Key: "meeting:MTG_001:metadata" +Value: { + "meeting_id": "MTG_001", + "title": "프로젝트 킥오프 회의", + "status": "in_progress", + "start_time": "2025-10-21T14:00:00.000Z", + "participants": ["USR_001", "USR_002", "USR_003"], + "total_speech_count": 42, + "last_activity": "2025-10-21T14:32:00.000Z" +} +``` + +### 3.7 WebSocket 실시간 동기화 메시지 + +```json +{ + "type": "transcription_update", + "message_id": "WS_MSG_001", + "timestamp": "2025-10-21T14:30:18.000Z", + "data": { + "meeting_id": "MTG_001", + "speaker": { + "id": "USR_001", + "name": "김철수" + }, + "transcription": { + "text": "회의를 시작하겠습니다.", + "is_final": true, + "confidence": 0.95 + }, + "target_section": "agenda", + "action": "append" + } +} +``` + +--- + +## 4. 처리 흐름 (Sequence) + +### 4.1 실시간 스트리밍 흐름 + +``` +Client STT Gateway Azure Speech RabbitMQ AI Service WebSocket Server + │ │ │ │ │ │ + │─1.WebSocket 연결─▶│ │ │ │ │ + │ │─2.Speech 세션─▶│ │ │ │ + │ │ 시작 │ │ │ │ + │ │◀─3.세션 준비───│ │ │ │ + │ │ │ │ │ │ + │─4.실시간 음성──▶│ │ │ │ │ + │ 스트림 전송 │─5.오디오 전송─▶│ │ │ │ + │ │ │ │ │ │ + │ │◀─6.실시간 텍스트│ │ │ │ + │ │ (화자 식별) │ │ │ │ + │ │ │ │ │ │ + │ │──────7.이벤트 발행─────────▶│ │ │ + │ │ │ │──8.구독──▶│ │ + │ │ │ │ │──9.Claude──▶│ + │ │ │ │ │ 후처리 │ + │◀────────────────────────────────────────────────────────10.실시간 동기화────│ +``` + +**단계별 설명:** +1. **Client**: WebSocket으로 STT Gateway 연결 +2. **STT Gateway**: Azure Speech Services 스트리밍 세션 시작 +3. **Azure Speech**: 세션 준비 완료 응답 +4. **Client**: MediaRecorder로 실시간 음성 스트림 전송 +5. **STT Gateway**: Azure Speech로 오디오 스트림 전달 +6. **Azure Speech**: 실시간 텍스트 변환 + 화자 식별 (< 1초 지연) +7. **STT Gateway**: RabbitMQ에 `TextTranscribed` 이벤트 발행 +8. **AI Service**: RabbitMQ 구독하여 이벤트 수신 +9. **AI Service**: Claude API로 텍스트 후처리 (구조화, 요약) +10. **WebSocket Server**: 모든 참석자에게 실시간 동기화 + +### 4.2 화자 식별 흐름 + +``` +Azure Speech STT Gateway Redis Cache Participants DB + │ │ │ │ + │─1.Speaker_1───▶│ │ │ + │ 인식 결과 │ │ │ + │ │─2.Speaker_1──▶│ │ + │ │ 매핑 조회 │ │ + │ │◀─3.매핑 없음──│ │ + │ │ │ │ + │ │────────4.참석자 목록 조회────────▶│ + │ │◀───────5.참석자 목록──────────────│ + │ │ │ │ + │ │─6.음성 패턴───│ │ + │ │ 기반 매칭 │ │ + │ │ │ │ + │ │─7.Speaker_1 =─▶│ │ + │ │ USR_001 저장 │ │ +``` + +**화자 매칭 전략:** +1. **첫 발언**: Azure가 제공한 Speaker_1, Speaker_2 등을 참석자 목록과 매칭 +2. **음성 패턴 분석**: 발언 순서, 발언 빈도, 음성 특징 기반 추정 +3. **Redis 캐싱**: 매칭 결과를 캐싱하여 이후 발언에 재사용 +4. **수동 보정**: 사용자가 화자를 수동으로 지정 가능 + +--- + +## 5. 구현 상세 + +### 5.1 Frontend (React) + +#### **음성 캡처 및 WebSocket 스트리밍** + +```javascript +// Azure Speech 실시간 스트리밍 +class AzureSpeechRecorder { + constructor(meetingId, speakerId) { + this.meetingId = meetingId; + this.speakerId = speakerId; + this.ws = null; + this.mediaRecorder = null; + this.audioContext = null; + } + + async start() { + // WebSocket 연결 + this.ws = new WebSocket(`ws://localhost:3001/api/stt/stream`); + + this.ws.onopen = () => { + // 세션 시작 요청 + this.ws.send(JSON.stringify({ + type: 'session_start', + session_id: `SESSION_${Date.now()}`, + meeting_id: this.meetingId, + config: { + language: 'ko-KR', + sample_rate: 16000, + format: 'audio/wav', + enable_diarization: true, + max_speakers: 10 + } + })); + }; + + this.ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.type === 'session_ready') { + this.startRecording(); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + } + + async startRecording() { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: 16000, // Azure 권장 + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + // AudioContext로 PCM 변환 + this.audioContext = new AudioContext({ sampleRate: 16000 }); + const source = this.audioContext.createMediaStreamSource(stream); + const processor = this.audioContext.createScriptProcessor(4096, 1, 1); + + processor.onaudioprocess = (e) => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const audioData = e.inputBuffer.getChannelData(0); + + // Float32 PCM to Int16 PCM 변환 + const int16Array = new Int16Array(audioData.length); + for (let i = 0; i < audioData.length; i++) { + int16Array[i] = Math.max(-32768, Math.min(32767, audioData[i] * 32768)); + } + + // Base64 인코딩하여 전송 + const base64Audio = this.arrayBufferToBase64(int16Array.buffer); + + this.ws.send(JSON.stringify({ + type: 'audio_chunk', + session_id: this.sessionId, + audio_data: base64Audio, + timestamp: new Date().toISOString() + })); + } + }; + + source.connect(processor); + processor.connect(this.audioContext.destination); + } + + arrayBufferToBase64(buffer) { + let binary = ''; + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + stop() { + if (this.ws) { + this.ws.send(JSON.stringify({ + type: 'session_end', + session_id: this.sessionId + })); + this.ws.close(); + } + + if (this.audioContext) { + this.audioContext.close(); + } + } +} +``` + +#### **WebSocket 실시간 수신** + +```javascript +class TranscriptionWebSocket { + constructor(meetingId, onTranscription) { + this.meetingId = meetingId; + this.onTranscription = onTranscription; + this.ws = null; + } + + connect() { + this.ws = new WebSocket(`ws://localhost:8080/ws/meetings/${this.meetingId}`); + + this.ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.type === 'transcription_update') { + this.onTranscription(message.data); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + // 재연결 로직 + setTimeout(() => this.connect(), 3000); + }; + } + + disconnect() { + if (this.ws) { + this.ws.close(); + } + } +} +``` + +### 5.2 Backend (Node.js + Azure Speech SDK) + +#### **STT Gateway Service (WebSocket Server)** + +```javascript +const WebSocket = require('ws'); +const sdk = require('microsoft-cognitiveservices-speech-sdk'); +const amqp = require('amqplib'); +const redis = require('redis'); + +const wss = new WebSocket.Server({ port: 3001, path: '/api/stt/stream' }); +const redisClient = redis.createClient({ url: process.env.REDIS_URL }); + +// Azure Speech 설정 +const AZURE_SPEECH_KEY = process.env.AZURE_SPEECH_KEY; +const AZURE_SPEECH_REGION = process.env.AZURE_SPEECH_REGION; // e.g., 'koreacentral' + +// 세션 저장소 +const sessions = new Map(); + +wss.on('connection', (ws) => { + console.log('Client connected'); + let recognizer = null; + let sessionId = null; + + ws.on('message', async (data) => { + const message = JSON.parse(data); + + try { + switch (message.type) { + case 'session_start': + sessionId = message.session_id; + await startAzureSpeechSession(ws, sessionId, message.meeting_id, message.config); + break; + + case 'audio_chunk': + // 오디오 청크는 Azure Speech SDK가 자동 처리 + break; + + case 'session_end': + if (recognizer) { + recognizer.stopContinuousRecognitionAsync(); + } + break; + } + } catch (error) { + console.error('WebSocket message error:', error); + ws.send(JSON.stringify({ + type: 'error', + error: error.message + })); + } + }); + + ws.on('close', () => { + if (recognizer) { + recognizer.stopContinuousRecognitionAsync(); + } + sessions.delete(sessionId); + console.log('Client disconnected'); + }); +}); + +// Azure Speech 세션 시작 +async function startAzureSpeechSession(ws, sessionId, meetingId, config) { + // Azure Speech SDK 설정 + const speechConfig = sdk.SpeechConfig.fromSubscription( + AZURE_SPEECH_KEY, + AZURE_SPEECH_REGION + ); + + speechConfig.speechRecognitionLanguage = config.language || 'ko-KR'; + speechConfig.enableDictation(); + speechConfig.setProfanity(sdk.ProfanityOption.Masked); + + // 오디오 스트림 설정 (Push Stream) + const pushStream = sdk.AudioInputStream.createPushStream(); + const audioConfig = sdk.AudioConfig.fromStreamInput(pushStream); + + // Conversation Transcriber (화자 식별 포함) + const transcriber = new sdk.ConversationTranscriber(speechConfig, audioConfig); + + // 실시간 인식 이벤트 핸들러 + transcriber.transcribed = async (s, e) => { + if (e.result.reason === sdk.ResultReason.RecognizedSpeech) { + const result = { + text: e.result.text, + speaker_id: e.result.speakerId, + confidence: e.result.properties.getProperty('Confidence'), + offset: e.result.offset, + duration: e.result.duration + }; + + console.log(`[${result.speaker_id}]: ${result.text}`); + + // 화자 매칭 + const matchedUser = await matchSpeaker(meetingId, result.speaker_id); + + // RabbitMQ 이벤트 발행 + const event = { + event_type: 'TextTranscribed', + event_id: `EVT_${Date.now()}`, + timestamp: new Date().toISOString(), + payload: { + meeting_id: meetingId, + speaker: { + id: matchedUser?.user_id || 'Unknown', + name: matchedUser?.name || result.speaker_id, + azure_speaker_id: result.speaker_id + }, + transcription: { + text: result.text, + confidence: parseFloat(result.confidence) || 0.9 + }, + timestamp: new Date().toISOString() + }, + metadata: { + source: 'azure-speech-service', + version: '2.0' + } + }; + + await publishToQueue('text-transcribed', event); + + // WebSocket으로 클라이언트에 실시간 전송 + ws.send(JSON.stringify({ + type: 'recognition_result', + session_id: sessionId, + result_id: `RESULT_${Date.now()}`, + recognition_status: 'Success', + text: result.text, + confidence: result.confidence, + speaker_id: result.speaker_id, + matched_user: matchedUser, + is_final: true, + timestamp: new Date().toISOString() + })); + } + }; + + // 에러 핸들러 + transcriber.canceled = (s, e) => { + console.error(`Recognition canceled: ${e.errorDetails}`); + ws.send(JSON.stringify({ + type: 'error', + error: e.errorDetails + })); + }; + + // 인식 시작 + transcriber.startTranscribingAsync(() => { + console.log('Azure Speech recognition started'); + ws.send(JSON.stringify({ + type: 'session_ready', + session_id: sessionId + })); + + // 세션 저장 + sessions.set(sessionId, { + transcriber, + pushStream, + meetingId + }); + }); + + // WebSocket에서 받은 오디오 데이터를 Push Stream에 전달 + ws.on('message', (data) => { + const message = JSON.parse(data); + if (message.type === 'audio_chunk' && message.session_id === sessionId) { + const audioBuffer = Buffer.from(message.audio_data, 'base64'); + pushStream.write(audioBuffer); + } + }); +} + +// 화자 매칭 로직 +async function matchSpeaker(meetingId, azureSpeakerId) { + // Redis에서 기존 매칭 조회 + const cacheKey = `meeting:${meetingId}:speaker_mapping:${azureSpeakerId}`; + const cached = await redisClient.get(cacheKey); + + if (cached) { + return JSON.parse(cached); + } + + // 신규 화자인 경우 참석자 목록에서 추정 + // TODO: 실제로는 발언 패턴, 순서 등을 분석하여 매칭 + const participants = await getParticipants(meetingId); + + if (participants && participants.length > 0) { + // 간단한 매칭 전략: 순서대로 할당 + const speakerIndex = parseInt(azureSpeakerId.replace('Speaker_', '')) - 1; + const matchedUser = participants[speakerIndex % participants.length]; + + // Redis에 캐싱 + await redisClient.setEx(cacheKey, 3600, JSON.stringify(matchedUser)); + + return matchedUser; + } + + return null; +} + +// 참석자 목록 조회 +async function getParticipants(meetingId) { + // TODO: 실제 DB에서 조회 + // 임시로 Redis에서 조회 + const key = `meeting:${meetingId}:participants`; + const data = await redisClient.get(key); + return data ? JSON.parse(data) : []; +} + +// RabbitMQ 발행 +async function publishToQueue(queueName, message) { + const connection = await amqp.connect(process.env.RABBITMQ_URL); + const channel = await connection.createChannel(); + await channel.assertQueue(queueName, { durable: true }); + channel.sendToQueue(queueName, Buffer.from(JSON.stringify(message)), { + persistent: true + }); + await channel.close(); + await connection.close(); +} + +console.log('Azure Speech STT Gateway running on port 3001'); +``` + +#### **AI Service (Claude 후처리)** + +```javascript +const Anthropic = require('@anthropic-ai/sdk'); +const amqp = require('amqplib'); +const redis = require('redis'); + +const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY +}); + +const redisClient = redis.createClient({ + url: process.env.REDIS_URL +}); + +// RabbitMQ 구독 +async function consumeQueue() { + const connection = await amqp.connect(process.env.RABBITMQ_URL); + const channel = await connection.createChannel(); + await channel.assertQueue('text-transcribed', { durable: true }); + + channel.consume('text-transcribed', async (msg) => { + const event = JSON.parse(msg.content.toString()); + await processTranscription(event); + channel.ack(msg); + }); +} + +// Claude로 텍스트 후처리 +async function processTranscription(event) { + const { meeting_id, speaker, transcription } = event.payload; + + // Redis에서 기존 회의록 내용 조회 + const sectionsKey = `meeting:${meeting_id}:sections:*`; + const sections = await redisClient.keys(sectionsKey); + + const context = sections.length > 0 + ? await redisClient.get(sections[0]) + : '(새로운 회의)'; + + // Claude API 호출 + const message = await anthropic.messages.create({ + model: 'claude-3-5-sonnet-20241022', + max_tokens: 2048, + temperature: 0.3, + system: '당신은 회의록 작성 전문가입니다. 발언 내용을 구조화하여 명확하고 간결하게 정리합니다.', + messages: [ + { + role: 'user', + content: `다음은 회의 발언 내용입니다. 회의록 형식에 맞춰 정리해주세요. + +발언: "${transcription.text}" +화자: ${speaker.name} +시간: ${event.payload.timestamp} + +기존 회의록 내용: +${context} + +템플릿 섹션: 안건, 논의 내용, 결정 사항, Todo` + } + ] + }); + + const structuredContent = message.content[0].text; + + // Redis에 업데이트된 내용 저장 + await redisClient.setEx( + `meeting:${meeting_id}:sections:discussion`, + 3600, + structuredContent + ); + + // WebSocket으로 실시간 동기화 + await broadcastToWebSocket(meeting_id, { + type: 'transcription_update', + data: { + meeting_id, + speaker, + transcription: { + text: structuredContent, + is_final: true, + confidence: transcription.confidence + }, + target_section: 'discussion', + action: 'append' + } + }); +} + +// WebSocket 브로드캐스트 +async function broadcastToWebSocket(meetingId, message) { + // WebSocket 서버로 메시지 전송 (구현 필요) + // 실제로는 Redis Pub/Sub 또는 별도 WebSocket 서버 연동 +} + +// 서비스 시작 +(async () => { + await redisClient.connect(); + await consumeQueue(); + console.log('AI Service started'); +})(); +``` + +--- + +## 6. 오류 처리 및 복구 전략 + +### 6.1 오류 시나리오 + +| 시나리오 | 감지 방법 | 대응 전략 | +|----------|-----------|-----------| +| Azure Speech 장애 | SDK error callback | 자동 재연결 (exponential backoff), 로컬 녹음 저장 | +| 네트워크 단절 | WebSocket 연결 끊김 | 자동 재연결 (최대 5회), 클라이언트 로컬 저장 | +| 낮은 confidence | score < 0.7 | 사용자에게 경고 표시, 수동 수정 권장 | +| 화자 식별 실패 | Speaker_Unknown | "미지정 화자"로 표시, 수동 지정 인터페이스 제공 | +| RabbitMQ 장애 | 메시지 발행 실패 | 재시도 3회 후 Redis 임시 저장, 수동 복구 | +| Azure API 할당량 초과 | 429 Too Many Requests | 경고 알림, 회의 일시 중지 권장 | + +### 6.2 재시도 로직 + +```javascript +async function retryWithExponentialBackoff(fn, maxRetries = 3) { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + if (attempt === maxRetries - 1) throw error; + const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s + await new Promise(resolve => setTimeout(resolve, delay)); + } + } +} +``` + +--- + +## 7. 성능 및 확장성 + +### 7.1 성능 목표 + +| 지표 | 목표값 | 측정 방법 | +|------|--------|-----------| +| **STT 지연 시간** | **< 1초** | 발언 시작 → 화면 표시 (Azure 실시간 스트리밍) | +| WebSocket 지연 | < 100ms | 메시지 발행 → 클라이언트 수신 | +| Claude API 응답 | < 2초 | API 호출 → 응답 수신 | +| 동시 회의 처리 | 100개 | Azure Speech 동시 세션 부하 테스트 | +| 화자 식별 정확도 | > 85% | Speaker Diarization 정확도 | + +### 7.2 확장성 전략 + +- **수평 확장**: STT Gateway WebSocket 서버를 여러 인스턴스로 분산 (Load Balancer) +- **캐싱**: Redis에 화자 매칭 정보 캐싱하여 중복 처리 방지 +- **Queue 파티셔닝**: 회의 ID 기반 RabbitMQ 파티셔닝 +- **Azure 리소스 관리**: Azure Speech Services 할당량 모니터링 및 자동 스케일링 +- **CDN**: 음성 파일 저장 시 S3 + CloudFront 활용 + +--- + +## 8. 보안 및 개인정보 보호 + +### 8.1 보안 요구사항 + +- **전송 암호화**: HTTPS/WSS 사용 +- **인증/인가**: JWT 토큰 기반 회의 접근 제어 +- **음성 데이터 보호**: 녹음 파일 암호화 저장 (AES-256) +- **개인정보 처리**: GDPR 준수, 음성 데이터 보관 기간 제한 (30일) + +### 8.2 데이터 생명주기 + +``` +녹음 시작 → 실시간 처리 → Redis 캐싱 (10분) + ↓ + PostgreSQL 저장 (30일) + ↓ + 자동 삭제 (회의 종료 후 30일) +``` + +--- + +## 9. 모니터링 및 로깅 + +### 9.1 모니터링 지표 + +- **STT 성공률**: Whisper 성공률, Google 폴백 비율 +- **평균 confidence score**: 텍스트 변환 품질 추적 +- **처리 지연 시간**: 각 단계별 소요 시간 +- **오류율**: API 오류, 네트워크 오류 비율 + +### 9.2 로깅 전략 + +```javascript +// 구조화된 로그 +{ + "timestamp": "2025-10-21T14:30:18.000Z", + "level": "INFO", + "service": "stt-gateway", + "event": "transcription_success", + "request_id": "REQ_001", + "meeting_id": "MTG_001", + "provider": "whisper", + "confidence": 0.95, + "processing_time_ms": 850 +} +``` + +--- + +## 10. 테스트 전략 + +### 10.1 단위 테스트 +- Azure Speech SDK 연동 모킹 +- Claude API 응답 파싱 +- Redis 캐싱 로직 +- 화자 매칭 알고리즘 + +### 10.2 통합 테스트 +- STT Gateway (Azure Speech) → RabbitMQ → AI Service 전체 플로우 +- WebSocket 양방향 실시간 동기화 +- 화자 식별 및 매칭 정확도 검증 + +### 10.3 성능 테스트 +- 동시 100개 회의 시뮬레이션 (Azure Speech 동시 세션) +- 실시간 스트리밍 지연 시간 측정 (목표: < 1초) +- Azure API 할당량 및 처리량 테스트 + +### 10.4 품질 테스트 +- 다양한 음질 환경에서 STT 정확도 측정 +- 화자 식별 정확도 검증 (목표: > 85%) +- 한국어 방언 및 억양 대응 테스트 + +--- + +## 11. 구현 일정 + +| 단계 | 작업 | 담당자 | 예상 기간 | +|------|------|--------|-----------| +| 1 | Frontend 음성 캡처 (WebSocket) 구현 | 최유진 | 4일 | +| 2 | Azure Speech SDK 연동 및 STT Gateway 개발 | 이준호 | 6일 | +| 3 | 화자 식별 및 매칭 로직 구현 | 박서연 | 3일 | +| 4 | RabbitMQ 설정 및 이벤트 처리 | 이동욱 | 3일 | +| 5 | AI Service (Claude 연동) | 박서연 | 4일 | +| 6 | Redis 캐싱 구현 | 이준호 | 2일 | +| 7 | WebSocket 양방향 실시간 동기화 | 최유진 | 4일 | +| 8 | 통합 테스트 및 화자 식별 검증 | 정도현 | 6일 | +| 9 | 성능 최적화 및 Azure 리소스 튜닝 | 전체 | 3일 | + +**총 예상 기간**: 35일 (약 5주) + +--- + +## 12. 참고 자료 + +### Azure Speech Services +- [Azure Speech SDK Documentation](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/) +- [Conversation Transcription (화자 식별)](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/conversation-transcription) +- [Real-time Speech-to-Text](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/get-started-speech-to-text) +- [Azure Speech Pricing](https://azure.microsoft.com/en-us/pricing/details/cognitive-services/speech-services/) + +### 기타 참고 자료 +- [Anthropic Claude API](https://docs.anthropic.com/claude/reference/messages_post) +- [RabbitMQ 공식 문서](https://www.rabbitmq.com/documentation.html) +- [Redis Caching Best Practices](https://redis.io/docs/manual/patterns/) +- [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) +- [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API) + +--- + +## 13. 변경 이력 + +| 버전 | 날짜 | 작성자 | 변경 내용 | +|------|------|--------|-----------| +| 1.0 | 2025-10-21 | 개발팀 전체 | 최초 작성 (Whisper + Google 하이브리드 전략) | +| 2.0 | 2025-10-21 | 개발팀 전체 | **Azure Speech Services 단일 전략으로 전면 변경**
- STT 엔진: Whisper → Azure Speech Services
- 실시간 스트리밍 방식 적용 (지연 시간 < 1초)
- Speaker Diarization 기본 지원
- 폴백 전략 제거 (Azure 단일 사용)
- 구현 코드 전면 수정 (Frontend/Backend)
- 구현 일정 조정 (4주 → 5주) | + +--- + +**문서 승인:** +- AI Specialist: 박서연 +- Backend Developer: 이준호, 이동욱 +- Frontend Developer: 최유진 +- Architect: 홍길동 +- QA Engineer: 정도현