mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 06:46:24 +00:00
- 음성인식(STT) 기술 개요 및 한국어 처리 특징 정리 - OpenAI Whisper API와 AWS Transcribe 비교 분석 - 실시간/배치 처리 방식별 아키텍처 설계 - WebSocket 기반 실시간 STT 처리 플로우 정의 - 성능 최적화 및 정확도 개선 방안 제시 - 비용 분석 및 모니터링 전략 수립 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1081 lines
35 KiB
Markdown
1081 lines
35 KiB
Markdown
# 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 단일 전략으로 전면 변경**<br>- STT 엔진: Whisper → Azure Speech Services<br>- 실시간 스트리밍 방식 적용 (지연 시간 < 1초)<br>- Speaker Diarization 기본 지원<br>- 폴백 전략 제거 (Azure 단일 사용)<br>- 구현 코드 전면 수정 (Frontend/Backend)<br>- 구현 일정 조정 (4주 → 5주) |
|
|
|
|
---
|
|
|
|
**문서 승인:**
|
|
- AI Specialist: 박서연
|
|
- Backend Developer: 이준호, 이동욱
|
|
- Frontend Developer: 최유진
|
|
- Architect: 홍길동
|
|
- QA Engineer: 정도현
|