mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 10:16:24 +00:00
STT 서비스 배치 방식 구현 완료
주요 구현사항: - 5초마다 Redis 오디오 버퍼를 배치 처리하여 텍스트 변환 - WebSocket 실시간 오디오 수신 및 Redis Stream 저장 - Azure Speech Service 연동 (시뮬레이션 모드 포함) - Event Hub 이벤트 발행 (AI 서비스 연동) 아키텍처: Frontend (오디오 캡처) → WebSocket → STT Service → Redis Stream (버퍼) → @Scheduled(5초) 배치 처리 → Azure Speech API → DB 저장 + Event Hub 발행 → AI Service (텍스트 분석) 핵심 컴포넌트: 1. AudioWebSocketHandler - WebSocket 연결 관리 - JSON/Binary 메시지 처리 - Redis Stream에 오디오 저장 2. AudioBufferService - Redis Stream 오디오 버퍼링 - 청크 조회 및 병합 - 활성 회의 관리 3. AzureSpeechService - Azure Speech SDK 연동 - 배치 단위 음성 인식 - 시뮬레이션 모드 지원 4. AudioBatchProcessor - @Scheduled(5초) 배치 작업 - 오디오 → 텍스트 변환 - TranscriptSegment DB 저장 - Event Hub 이벤트 발행 배치 방식의 장점: ✅ 비용 최적화: Azure API 호출 1/5 감소 ✅ 문맥 이해: 5초 분량 한 번에 처리로 정확도 향상 ✅ AI 효율: 일정량 텍스트 주기적 생성 ✅ 안정성: 재시도 로직 구현 용이 설정: - Azure Speech: eastus, ko-KR - Redis: 포트 6379, DB 3 - WebSocket: /ws/audio - 배치 주기: 5초 (고정) 다음 단계: - 프론트엔드 WebSocket 클라이언트 구현 - 실제 Azure Speech API 키 설정 - E2E 통합 테스트 (STT → AI → Frontend) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d43c9f0130
commit
0209652a90
466
develop/dev/dev-stt-batch-implementation.md
Normal file
466
develop/dev/dev-stt-batch-implementation.md
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
# STT 서비스 배치 방식 구현 완료 보고서
|
||||||
|
|
||||||
|
**작성일**: 2025-10-27
|
||||||
|
**작성자**: 준호 (Backend Developer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
**STT 서비스를 배치 처리 방식으로 구현 완료**
|
||||||
|
|
||||||
|
- **핵심**: 5초마다 Redis에 축적된 오디오를 배치 처리하여 Azure Speech로 텍스트 변환
|
||||||
|
- **장점**: 비용 절감, 문맥 이해 향상, AI 분석 효율 증가
|
||||||
|
- **기술**: Java/Spring Boot, Azure Speech SDK, Redis Stream, WebSocket
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 최종 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (회의 화면) │
|
||||||
|
│ - 오디오 캡처 (매초) │
|
||||||
|
│ - WebSocket으로 실시간 전송 │
|
||||||
|
└────────────────────┬─────────────────────────────────────┘
|
||||||
|
│ WebSocket (ws://localhost:8084/ws/audio)
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ STT Service (Java/Spring Boot) │
|
||||||
|
│ 포트: 8084 │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ 1. AudioWebSocketHandler │
|
||||||
|
│ - WebSocket 메시지 수신 (JSON/Binary) │
|
||||||
|
│ - Base64 디코딩 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 2. AudioBufferService │
|
||||||
|
│ - Redis Stream에 오디오 청크 저장 │
|
||||||
|
│ - Key: audio:stream:{meetingId} │
|
||||||
|
│ - TTL: 1분 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 3. Redis Stream (버퍼) │
|
||||||
|
│ - 오디오 청크 임시 저장 │
|
||||||
|
│ - 5초 분량 축적 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 4. AudioBatchProcessor (@Scheduled) │
|
||||||
|
│ - 5초마다 실행 │
|
||||||
|
│ - Redis에서 청크 조회 → 병합 │
|
||||||
|
│ - Azure Speech API 호출 │
|
||||||
|
│ - TranscriptSegment DB 저장 │
|
||||||
|
│ - Event Hub 이벤트 발행 │
|
||||||
|
└────────────────────┬─────────────────────────────────────┘
|
||||||
|
│ Event Hub (transcription-events)
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ AI Service (Python/FastAPI) │
|
||||||
|
│ 포트: 8086 │
|
||||||
|
│ - Event Hub에서 텍스트 수신 │
|
||||||
|
│ - Redis에 텍스트 축적 (슬라이딩 윈도우 5분) │
|
||||||
|
│ - Claude API 분석 → SSE로 프론트엔드 전송 │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 구현 컴포넌트
|
||||||
|
|
||||||
|
### 1. Redis Stream 설정
|
||||||
|
|
||||||
|
**파일**: `stt/src/main/java/com/unicorn/hgzero/stt/config/RedisStreamConfig.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
public class RedisStreamConfig {
|
||||||
|
@Bean(name = "audioRedisTemplate")
|
||||||
|
public RedisTemplate<String, byte[]> audioRedisTemplate(...) {
|
||||||
|
// 오디오 데이터 저장용
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. AudioChunkDto
|
||||||
|
|
||||||
|
**파일**: `stt/src/main/java/com/unicorn/hgzero/stt/dto/AudioChunkDto.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class AudioChunkDto {
|
||||||
|
private String meetingId;
|
||||||
|
private byte[] audioData; // 오디오 데이터
|
||||||
|
private Long timestamp; // 타임스탬프 (밀리초)
|
||||||
|
private Integer chunkIndex; // 순서
|
||||||
|
private String format; // audio/webm
|
||||||
|
private Integer sampleRate; // 16000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. AudioBufferService
|
||||||
|
|
||||||
|
**파일**: `stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBufferService.java`
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- `bufferAudioChunk()`: 오디오 청크를 Redis Stream에 저장
|
||||||
|
- `getAudioChunks()`: 회의별 오디오 청크 조회
|
||||||
|
- `mergeAudioChunks()`: 청크 병합 (5초 분량)
|
||||||
|
- `clearProcessedChunks()`: 처리 완료 후 Redis 정리
|
||||||
|
|
||||||
|
### 4. AudioWebSocketHandler
|
||||||
|
|
||||||
|
**파일**: `stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java`
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- WebSocket 연결 관리
|
||||||
|
- 텍스트 메시지 처리 (JSON 형식)
|
||||||
|
- `type: "start"`: 녹음 시작
|
||||||
|
- `type: "chunk"`: 오디오 청크 (Base64)
|
||||||
|
- `type: "stop"`: 녹음 종료
|
||||||
|
- 바이너리 메시지 처리 (직접 오디오 데이터)
|
||||||
|
|
||||||
|
### 5. AzureSpeechService
|
||||||
|
|
||||||
|
**파일**: `stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java`
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- `recognizeAudio()`: 오디오 데이터를 텍스트로 변환
|
||||||
|
- Azure Speech SDK 사용
|
||||||
|
- 시뮬레이션 모드 지원 (API 키 없을 때)
|
||||||
|
|
||||||
|
**설정**:
|
||||||
|
```yaml
|
||||||
|
azure:
|
||||||
|
speech:
|
||||||
|
subscription-key: ${AZURE_SPEECH_SUBSCRIPTION_KEY:}
|
||||||
|
region: ${AZURE_SPEECH_REGION:eastus}
|
||||||
|
language: ko-KR
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. AudioBatchProcessor
|
||||||
|
|
||||||
|
**파일**: `stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java`
|
||||||
|
|
||||||
|
**핵심 기능**:
|
||||||
|
- `@Scheduled(fixedDelay = 5000)`: 5초마다 실행
|
||||||
|
- 활성 회의 목록 조회
|
||||||
|
- 각 회의별 오디오 처리:
|
||||||
|
1. Redis에서 오디오 청크 조회
|
||||||
|
2. 청크 병합 (5초 분량)
|
||||||
|
3. Azure Speech API 호출
|
||||||
|
4. TranscriptSegment DB 저장
|
||||||
|
5. Event Hub 이벤트 발행
|
||||||
|
6. Redis 정리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 데이터 흐름
|
||||||
|
|
||||||
|
### 1. 오디오 수신 (실시간)
|
||||||
|
|
||||||
|
**Frontend → STT Service:**
|
||||||
|
```javascript
|
||||||
|
// WebSocket 연결
|
||||||
|
const ws = new WebSocket('ws://localhost:8084/ws/audio');
|
||||||
|
|
||||||
|
// 녹음 시작
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'start',
|
||||||
|
meetingId: 'meeting-123'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 오디오 청크 전송 (매초)
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'chunk',
|
||||||
|
meetingId: 'meeting-123',
|
||||||
|
audioData: base64AudioData,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
chunkIndex: 0,
|
||||||
|
format: 'audio/webm',
|
||||||
|
sampleRate: 16000
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**STT Service:**
|
||||||
|
```java
|
||||||
|
// AudioWebSocketHandler
|
||||||
|
handleTextMessage() {
|
||||||
|
AudioChunkDto chunk = parseMessage(message);
|
||||||
|
audioBufferService.bufferAudioChunk(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioBufferService
|
||||||
|
bufferAudioChunk(chunk) {
|
||||||
|
redis.opsForStream().add("audio:stream:meeting-123", chunk);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 배치 처리 (5초마다)
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Scheduled(fixedDelay = 5000)
|
||||||
|
public void processAudioBatch() {
|
||||||
|
// 1. 활성 회의 조회
|
||||||
|
Set<String> meetings = audioBufferService.getActiveMeetings();
|
||||||
|
|
||||||
|
for (String meetingId : meetings) {
|
||||||
|
// 2. 오디오 청크 조회 (최근 5초)
|
||||||
|
List<AudioChunkDto> chunks = audioBufferService.getAudioChunks(meetingId);
|
||||||
|
|
||||||
|
// 3. 청크 병합
|
||||||
|
byte[] mergedAudio = audioBufferService.mergeAudioChunks(chunks);
|
||||||
|
|
||||||
|
// 4. Azure Speech 인식
|
||||||
|
RecognitionResult result = azureSpeechService.recognizeAudio(mergedAudio);
|
||||||
|
|
||||||
|
// 5. DB 저장
|
||||||
|
saveTranscriptSegment(meetingId, result);
|
||||||
|
|
||||||
|
// 6. Event Hub 발행
|
||||||
|
publishTranscriptionEvent(meetingId, result);
|
||||||
|
|
||||||
|
// 7. Redis 정리
|
||||||
|
audioBufferService.clearProcessedChunks(meetingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Event Hub 이벤트 발행
|
||||||
|
|
||||||
|
```java
|
||||||
|
TranscriptionEvent.SegmentCreated event = TranscriptionEvent.SegmentCreated.of(
|
||||||
|
segmentId,
|
||||||
|
meetingId,
|
||||||
|
result.getText(),
|
||||||
|
"참석자",
|
||||||
|
LocalDateTime.now(),
|
||||||
|
5.0, // duration
|
||||||
|
result.getConfidence(),
|
||||||
|
warningFlag
|
||||||
|
);
|
||||||
|
|
||||||
|
eventPublisher.publishAsync("transcription-events", event);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. AI 서비스 수신 (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# AI Service (Python)
|
||||||
|
async def on_event(partition_context, event):
|
||||||
|
event_data = json.loads(event.body_as_str())
|
||||||
|
|
||||||
|
if event_data["eventType"] == "TranscriptSegmentReady":
|
||||||
|
meetingId = event_data["meetingId"]
|
||||||
|
text = event_data["text"]
|
||||||
|
|
||||||
|
# Redis에 텍스트 축적
|
||||||
|
await redis_service.add_transcript_segment(meetingId, text, timestamp)
|
||||||
|
|
||||||
|
# Claude API 분석 트리거
|
||||||
|
await analyze_and_emit_suggestions(meetingId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 설정 및 실행
|
||||||
|
|
||||||
|
### 1. 환경 변수 설정
|
||||||
|
|
||||||
|
**IntelliJ 실행 프로파일** (`.run/STT.run.xml`):
|
||||||
|
```xml
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="AZURE_SPEECH_SUBSCRIPTION_KEY" value="your-key-here"/>
|
||||||
|
<entry key="AZURE_SPEECH_REGION" value="eastus"/>
|
||||||
|
<entry key="REDIS_HOST" value="20.249.177.114"/>
|
||||||
|
<entry key="REDIS_PORT" value="6379"/>
|
||||||
|
<entry key="REDIS_PASSWORD" value="Hi5Jessica!"/>
|
||||||
|
<entry key="EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://..."/>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 서비스 시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# IntelliJ에서 STT 실행 프로파일 실행
|
||||||
|
# 또는
|
||||||
|
|
||||||
|
# Gradle로 실행
|
||||||
|
cd stt
|
||||||
|
./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 로그 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f stt/logs/stt.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 로그**:
|
||||||
|
```
|
||||||
|
2025-10-27 12:00:00 - Azure Speech Service 초기화 완료 - Region: eastus, Language: ko-KR
|
||||||
|
2025-10-27 12:00:05 - WebSocket 연결 성공 - sessionId: abc123
|
||||||
|
2025-10-27 12:00:10 - 오디오 청크 버퍼링 완료 - meetingId: meeting-123
|
||||||
|
2025-10-27 12:00:15 - 배치 처리 시작 - 활성 회의: 1개
|
||||||
|
2025-10-27 12:00:15 - 음성 인식 성공: 신제품 개발 일정에 대해 논의하고 있습니다.
|
||||||
|
2025-10-27 12:00:15 - Event Hub 이벤트 발행 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 방법
|
||||||
|
|
||||||
|
### 1. WebSocket 테스트 (JavaScript)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ws = new WebSocket('ws://localhost:8084/ws/audio');
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket 연결 성공');
|
||||||
|
|
||||||
|
// 녹음 시작
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'start',
|
||||||
|
meetingId: 'test-meeting'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5초 동안 오디오 청크 전송 (시뮬레이션)
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'chunk',
|
||||||
|
meetingId: 'test-meeting',
|
||||||
|
audioData: 'dGVzdCBhdWRpbyBkYXRh', // Base64
|
||||||
|
timestamp: Date.now(),
|
||||||
|
chunkIndex: i
|
||||||
|
}));
|
||||||
|
}, i * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10초 후 녹음 종료
|
||||||
|
setTimeout(() => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'stop'
|
||||||
|
}));
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
console.log('응답:', JSON.parse(event.data));
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Redis 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli -h 20.249.177.114 -p 6379 -a Hi5Jessica!
|
||||||
|
|
||||||
|
# 활성 회의 목록
|
||||||
|
SMEMBERS active:meetings
|
||||||
|
|
||||||
|
# 오디오 스트림 확인
|
||||||
|
XRANGE audio:stream:test-meeting - +
|
||||||
|
|
||||||
|
# 스트림 길이
|
||||||
|
XLEN audio:stream:test-meeting
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 데이터베이스 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 텍스트 세그먼트 조회
|
||||||
|
SELECT * FROM transcript_segment
|
||||||
|
WHERE recording_id = 'test-meeting'
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 성능 특성
|
||||||
|
|
||||||
|
| 항목 | 값 | 비고 |
|
||||||
|
|------|-----|------|
|
||||||
|
| **배치 주기** | 5초 | @Scheduled(fixedDelay = 5000) |
|
||||||
|
| **지연 시간** | 최대 5초 | 사용자 경험에 영향 없음 |
|
||||||
|
| **Azure API 호출 빈도** | 1/5초 | 실시간 방식 대비 1/5 감소 |
|
||||||
|
| **Redis TTL** | 1분 | 처리 지연 대비 |
|
||||||
|
| **오디오 청크 크기** | 가변 | 프론트엔드 전송 주기에 따름 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 장점
|
||||||
|
|
||||||
|
1. **비용 최적화**
|
||||||
|
- Azure Speech API 호출 빈도 1/5 감소
|
||||||
|
- 비용 절감 효과
|
||||||
|
|
||||||
|
2. **문맥 이해 향상**
|
||||||
|
- 5초 분량을 한 번에 처리
|
||||||
|
- 문장 단위 인식으로 정확도 향상
|
||||||
|
|
||||||
|
3. **AI 분석 효율**
|
||||||
|
- 일정량의 텍스트가 주기적으로 생성
|
||||||
|
- AI가 분석하기 적합한 분량
|
||||||
|
|
||||||
|
4. **안정성**
|
||||||
|
- 재시도 로직 구현 용이
|
||||||
|
- 일시적 네트워크 오류 대응
|
||||||
|
|
||||||
|
5. **확장성**
|
||||||
|
- 여러 회의 동시 처리 가능
|
||||||
|
- Redis로 분산 처리 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
### 1. Azure Speech API 키 관리
|
||||||
|
|
||||||
|
- `.run/STT.run.xml`에 실제 API 키 설정 필요
|
||||||
|
- Git에 커밋하지 않도록 주의
|
||||||
|
|
||||||
|
### 2. Redis 연결
|
||||||
|
|
||||||
|
- Redis 서버가 실행 중이어야 함
|
||||||
|
- 연결 정보 확인 필요
|
||||||
|
|
||||||
|
### 3. Event Hub 설정
|
||||||
|
|
||||||
|
- Event Hub 연결 문자열 필요
|
||||||
|
- AI 서비스와 동일한 Event Hub 사용
|
||||||
|
|
||||||
|
### 4. 배치 주기 조정
|
||||||
|
|
||||||
|
- 5초 주기는 기본값
|
||||||
|
- 필요시 `application.yml`에서 조정 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 다음 단계
|
||||||
|
|
||||||
|
1. **프론트엔드 연동**
|
||||||
|
- WebSocket 클라이언트 구현
|
||||||
|
- 오디오 캡처 및 전송
|
||||||
|
|
||||||
|
2. **E2E 테스트**
|
||||||
|
- 실제 음성 데이터로 테스트
|
||||||
|
- Azure Speech API 연동 검증
|
||||||
|
|
||||||
|
3. **AI 서비스 통합 테스트**
|
||||||
|
- Event Hub 통신 확인
|
||||||
|
- SSE 스트리밍 검증
|
||||||
|
|
||||||
|
4. **성능 최적화**
|
||||||
|
- 배치 주기 조정
|
||||||
|
- Redis 메모리 사용량 모니터링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 문의
|
||||||
|
|
||||||
|
**STT 서비스**: 준호 (Backend Developer)
|
||||||
|
**AI 서비스**: 서연 (AI Specialist)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**최종 업데이트**: 2025-10-27
|
||||||
@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* STT Service Application
|
* STT Service Application
|
||||||
@ -21,6 +22,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
|||||||
"com.unicorn.hgzero.stt.repository.jpa",
|
"com.unicorn.hgzero.stt.repository.jpa",
|
||||||
"com.unicorn.hgzero.common.repository"
|
"com.unicorn.hgzero.common.repository"
|
||||||
})
|
})
|
||||||
|
@EnableScheduling // 배치 작업 스케줄링 활성화
|
||||||
public class SttApplication {
|
public class SttApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.unicorn.hgzero.stt.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||||
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis Stream 설정
|
||||||
|
* 오디오 데이터 버퍼링용
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RedisStreamConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 데이터 저장용 RedisTemplate
|
||||||
|
*/
|
||||||
|
@Bean(name = "audioRedisTemplate")
|
||||||
|
public RedisTemplate<String, byte[]> audioRedisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
|
RedisTemplate<String, byte[]> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(connectionFactory);
|
||||||
|
|
||||||
|
// Key Serializer
|
||||||
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
|
|
||||||
|
// Value Serializer (byte array는 기본 직렬화 사용)
|
||||||
|
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
|
||||||
|
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
|
||||||
|
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일반 데이터 저장용 StringRedisTemplate
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
|
return new StringRedisTemplate(connectionFactory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
package com.unicorn.hgzero.stt.config;
|
package com.unicorn.hgzero.stt.config;
|
||||||
|
|
||||||
|
import com.unicorn.hgzero.stt.controller.AudioWebSocketHandler;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
@ -11,51 +13,15 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
|||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class WebSocketConfig implements WebSocketConfigurer {
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
|
||||||
|
private final AudioWebSocketHandler audioWebSocketHandler;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
// 실시간 STT WebSocket 엔드포인트 등록
|
// 오디오 스트리밍 WebSocket 엔드포인트
|
||||||
registry.addHandler(new SttWebSocketHandler(), "/ws/stt/{sessionId}")
|
registry.addHandler(audioWebSocketHandler, "/ws/audio")
|
||||||
.setAllowedOrigins("*"); // 실제 운영 환경에서는 특정 도메인으로 제한
|
.setAllowedOrigins("*"); // 실제 운영 환경에서는 특정 도메인으로 제한
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* STT WebSocket 핸들러
|
|
||||||
* 실시간 음성 데이터 수신 및 처리
|
|
||||||
*/
|
|
||||||
private static class SttWebSocketHandler implements org.springframework.web.socket.WebSocketHandler {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterConnectionEstablished(org.springframework.web.socket.WebSocketSession session) throws Exception {
|
|
||||||
System.out.println("STT WebSocket 연결 설정: " + session.getId());
|
|
||||||
// 실제로는 Azure Speech Service 스트리밍 연결 설정
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleMessage(org.springframework.web.socket.WebSocketSession session,
|
|
||||||
org.springframework.web.socket.WebSocketMessage<?> message) throws Exception {
|
|
||||||
// 실시간 음성 데이터 처리
|
|
||||||
System.out.println("음성 데이터 수신: " + message.getPayload());
|
|
||||||
// 실제로는 TranscriptionService.processAudioStream() 호출
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleTransportError(org.springframework.web.socket.WebSocketSession session,
|
|
||||||
Throwable exception) throws Exception {
|
|
||||||
System.err.println("WebSocket 전송 오류: " + exception.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterConnectionClosed(org.springframework.web.socket.WebSocketSession session,
|
|
||||||
org.springframework.web.socket.CloseStatus closeStatus) throws Exception {
|
|
||||||
System.out.println("STT WebSocket 연결 종료: " + session.getId());
|
|
||||||
// 리소스 정리
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean supportsPartialMessages() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
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 lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.BinaryMessage;
|
||||||
|
import org.springframework.web.socket.CloseStatus;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
import org.springframework.web.socket.handler.AbstractWebSocketHandler;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 WebSocket 핸들러
|
||||||
|
* 프론트엔드에서 실시간 오디오 스트림을 수신
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AudioWebSocketHandler extends AbstractWebSocketHandler {
|
||||||
|
|
||||||
|
private final AudioBufferService audioBufferService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
// 세션별 회의 ID 매핑
|
||||||
|
private final Map<String, String> sessionMeetingMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||||
|
log.info("WebSocket 연결 성공 - sessionId: {}", session.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||||
|
try {
|
||||||
|
String payload = message.getPayload();
|
||||||
|
|
||||||
|
// JSON 파싱
|
||||||
|
Map<String, Object> data = objectMapper.readValue(payload, Map.class);
|
||||||
|
|
||||||
|
String type = (String) data.get("type");
|
||||||
|
|
||||||
|
if ("start".equals(type)) {
|
||||||
|
// 녹음 시작
|
||||||
|
String meetingId = (String) data.get("meetingId");
|
||||||
|
sessionMeetingMap.put(session.getId(), meetingId);
|
||||||
|
log.info("녹음 시작 - sessionId: {}, meetingId: {}", session.getId(), meetingId);
|
||||||
|
|
||||||
|
// 응답 전송
|
||||||
|
session.sendMessage(new TextMessage("{\"status\":\"started\",\"meetingId\":\"" + meetingId + "\"}"));
|
||||||
|
|
||||||
|
} else if ("chunk".equals(type)) {
|
||||||
|
// 오디오 청크 수신 (Base64 인코딩)
|
||||||
|
handleAudioChunk(session, data);
|
||||||
|
|
||||||
|
} else if ("stop".equals(type)) {
|
||||||
|
// 녹음 종료
|
||||||
|
String meetingId = sessionMeetingMap.get(session.getId());
|
||||||
|
log.info("녹음 종료 - sessionId: {}, meetingId: {}", session.getId(), meetingId);
|
||||||
|
|
||||||
|
// 응답 전송
|
||||||
|
session.sendMessage(new TextMessage("{\"status\":\"stopped\"}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("텍스트 메시지 처리 실패 - sessionId: {}", session.getId(), e);
|
||||||
|
session.sendMessage(new TextMessage("{\"error\":\"" + e.getMessage() + "\"}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception {
|
||||||
|
try {
|
||||||
|
String meetingId = sessionMeetingMap.get(session.getId());
|
||||||
|
|
||||||
|
if (meetingId == null) {
|
||||||
|
log.warn("회의 ID 없음 - sessionId: {}", session.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] audioData = message.getPayload().array();
|
||||||
|
|
||||||
|
// 오디오 청크 생성
|
||||||
|
AudioChunkDto chunk = AudioChunkDto.builder()
|
||||||
|
.meetingId(meetingId)
|
||||||
|
.audioData(audioData)
|
||||||
|
.timestamp(System.currentTimeMillis())
|
||||||
|
.chunkIndex(0) // 바이너리 메시지는 인덱스 없음
|
||||||
|
.format("audio/webm")
|
||||||
|
.sampleRate(16000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Redis에 버퍼링
|
||||||
|
audioBufferService.bufferAudioChunk(chunk);
|
||||||
|
|
||||||
|
log.debug("오디오 바이너리 수신 - sessionId: {}, size: {} bytes",
|
||||||
|
session.getId(), audioData.length);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("바이너리 메시지 처리 실패 - sessionId: {}", session.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON으로 전송된 오디오 청크 처리
|
||||||
|
*/
|
||||||
|
private void handleAudioChunk(WebSocketSession session, Map<String, Object> data) {
|
||||||
|
try {
|
||||||
|
String meetingId = (String) data.get("meetingId");
|
||||||
|
String audioBase64 = (String) data.get("audioData");
|
||||||
|
Long timestamp = data.get("timestamp") != null
|
||||||
|
? ((Number) data.get("timestamp")).longValue()
|
||||||
|
: System.currentTimeMillis();
|
||||||
|
Integer chunkIndex = data.get("chunkIndex") != null
|
||||||
|
? ((Number) data.get("chunkIndex")).intValue()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Base64 디코딩
|
||||||
|
byte[] audioData = Base64.getDecoder().decode(audioBase64);
|
||||||
|
|
||||||
|
// 오디오 청크 생성
|
||||||
|
AudioChunkDto chunk = AudioChunkDto.builder()
|
||||||
|
.meetingId(meetingId)
|
||||||
|
.audioData(audioData)
|
||||||
|
.timestamp(timestamp)
|
||||||
|
.chunkIndex(chunkIndex)
|
||||||
|
.format((String) data.getOrDefault("format", "audio/webm"))
|
||||||
|
.sampleRate(data.get("sampleRate") != null
|
||||||
|
? ((Number) data.get("sampleRate")).intValue()
|
||||||
|
: 16000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Redis에 버퍼링
|
||||||
|
audioBufferService.bufferAudioChunk(chunk);
|
||||||
|
|
||||||
|
log.debug("오디오 청크 수신 - meetingId: {}, chunkIndex: {}, size: {} bytes",
|
||||||
|
meetingId, chunkIndex, audioData.length);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("오디오 청크 처리 실패 - sessionId: {}", session.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||||
|
String meetingId = sessionMeetingMap.remove(session.getId());
|
||||||
|
log.info("WebSocket 연결 종료 - sessionId: {}, meetingId: {}, status: {}",
|
||||||
|
session.getId(), meetingId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||||
|
log.error("WebSocket 전송 오류 - sessionId: {}", session.getId(), exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package com.unicorn.hgzero.stt.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 청크 DTO
|
||||||
|
* 프론트엔드에서 전송되는 오디오 데이터
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AudioChunkDto {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의 ID
|
||||||
|
*/
|
||||||
|
private String meetingId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 데이터 (Base64 인코딩 또는 바이트 배열)
|
||||||
|
*/
|
||||||
|
private byte[] audioData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임스탬프 (밀리초)
|
||||||
|
*/
|
||||||
|
private Long timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 청크 인덱스 (순서 보장)
|
||||||
|
*/
|
||||||
|
private Integer chunkIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 포맷 (예: "audio/webm", "audio/wav")
|
||||||
|
*/
|
||||||
|
private String format;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 샘플링 레이트 (예: 16000, 44100)
|
||||||
|
*/
|
||||||
|
private Integer sampleRate;
|
||||||
|
}
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
package com.unicorn.hgzero.stt.service;
|
||||||
|
|
||||||
|
import com.unicorn.hgzero.stt.dto.AudioChunkDto;
|
||||||
|
import com.unicorn.hgzero.stt.event.TranscriptionEvent;
|
||||||
|
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
|
||||||
|
import com.unicorn.hgzero.stt.repository.entity.TranscriptSegmentEntity;
|
||||||
|
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 배치 프로세서
|
||||||
|
* 5초마다 Redis에 축적된 오디오를 처리하여 텍스트로 변환
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AudioBatchProcessor {
|
||||||
|
|
||||||
|
private final AudioBufferService audioBufferService;
|
||||||
|
private final AzureSpeechService azureSpeechService;
|
||||||
|
private final TranscriptSegmentRepository segmentRepository;
|
||||||
|
private final EventPublisher eventPublisher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5초마다 오디오 배치 처리
|
||||||
|
* - Redis에서 오디오 청크 조회
|
||||||
|
* - Azure Speech로 텍스트 변환
|
||||||
|
* - DB 저장
|
||||||
|
* - Event Hub 이벤트 발행
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 5000, initialDelay = 10000) // 5초마다 실행, 최초 10초 후 시작
|
||||||
|
@Transactional
|
||||||
|
public void processAudioBatch() {
|
||||||
|
try {
|
||||||
|
// 활성 회의 목록 조회
|
||||||
|
Set<String> activeMeetings = audioBufferService.getActiveMeetings();
|
||||||
|
|
||||||
|
if (activeMeetings.isEmpty()) {
|
||||||
|
log.debug("활성 회의 없음 - 배치 처리 스킵");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("배치 처리 시작 - 활성 회의: {}개", activeMeetings.size());
|
||||||
|
|
||||||
|
// 각 회의별로 처리
|
||||||
|
for (String meetingId : activeMeetings) {
|
||||||
|
processOneMeeting(meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("배치 처리 완료");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("배치 처리 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하나의 회의에 대한 오디오 처리
|
||||||
|
*/
|
||||||
|
private void processOneMeeting(String meetingId) {
|
||||||
|
try {
|
||||||
|
// Redis에서 최근 5초 오디오 청크 조회
|
||||||
|
List<AudioChunkDto> chunks = audioBufferService.getAudioChunks(meetingId);
|
||||||
|
|
||||||
|
if (chunks.isEmpty()) {
|
||||||
|
log.debug("오디오 청크 없음 - meetingId: {}", meetingId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("오디오 청크 조회 완료 - meetingId: {}, chunks: {}개", meetingId, chunks.size());
|
||||||
|
|
||||||
|
// 오디오 청크 병합 (5초 분량)
|
||||||
|
byte[] mergedAudio = audioBufferService.mergeAudioChunks(chunks);
|
||||||
|
|
||||||
|
if (mergedAudio.length == 0) {
|
||||||
|
log.warn("병합된 오디오 없음 - meetingId: {}", meetingId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Azure Speech API로 음성 인식
|
||||||
|
AzureSpeechService.RecognitionResult result = azureSpeechService.recognizeAudio(mergedAudio);
|
||||||
|
|
||||||
|
if (!result.isSuccess() || result.getText().trim().isEmpty()) {
|
||||||
|
log.debug("음성 인식 결과 없음 - meetingId: {}", meetingId);
|
||||||
|
// Redis 청크는 삭제 (무음 또는 인식 불가)
|
||||||
|
audioBufferService.clearProcessedChunks(meetingId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 세그먼트 DB 저장
|
||||||
|
saveTranscriptSegment(meetingId, result);
|
||||||
|
|
||||||
|
// Event Hub 이벤트 발행
|
||||||
|
publishTranscriptionEvent(meetingId, result);
|
||||||
|
|
||||||
|
// Redis 정리
|
||||||
|
audioBufferService.clearProcessedChunks(meetingId);
|
||||||
|
|
||||||
|
log.info("회의 처리 완료 - meetingId: {}, text: {}", meetingId, result.getText());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("회의 처리 실패 - meetingId: {}", meetingId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 세그먼트 DB 저장
|
||||||
|
*/
|
||||||
|
private void saveTranscriptSegment(String meetingId, AzureSpeechService.RecognitionResult result) {
|
||||||
|
String segmentId = UUID.randomUUID().toString();
|
||||||
|
long timestamp = System.currentTimeMillis();
|
||||||
|
boolean warningFlag = result.getConfidence() < 0.6;
|
||||||
|
|
||||||
|
TranscriptSegmentEntity segment = TranscriptSegmentEntity.builder()
|
||||||
|
.segmentId(segmentId)
|
||||||
|
.recordingId(meetingId) // 간소화: recordingId = meetingId
|
||||||
|
.text(result.getText())
|
||||||
|
.speakerId("UNKNOWN") // 화자 식별 제거
|
||||||
|
.speakerName("참석자")
|
||||||
|
.timestamp(timestamp)
|
||||||
|
.duration(5.0) // 5초 분량
|
||||||
|
.confidence(result.getConfidence())
|
||||||
|
.warningFlag(warningFlag)
|
||||||
|
.chunkIndex(0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
segmentRepository.save(segment);
|
||||||
|
|
||||||
|
log.debug("텍스트 세그먼트 저장 완료 - segmentId: {}, text: {}",
|
||||||
|
segmentId, result.getText());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event Hub 이벤트 발행 (AI 서비스로 전송)
|
||||||
|
*/
|
||||||
|
private void publishTranscriptionEvent(String meetingId, AzureSpeechService.RecognitionResult result) {
|
||||||
|
try {
|
||||||
|
// 간소화된 이벤트 (TranscriptSegmentReady)
|
||||||
|
TranscriptionEvent.SegmentCreated event = TranscriptionEvent.SegmentCreated.of(
|
||||||
|
UUID.randomUUID().toString(), // segmentId
|
||||||
|
meetingId, // recordingId
|
||||||
|
meetingId, // meetingId
|
||||||
|
result.getText(), // text
|
||||||
|
"UNKNOWN", // speakerId
|
||||||
|
"참석자", // speakerName
|
||||||
|
LocalDateTime.now(), // timestamp
|
||||||
|
5.0, // duration
|
||||||
|
result.getConfidence(), // confidence
|
||||||
|
result.getConfidence() < 0.6 // warningFlag
|
||||||
|
);
|
||||||
|
|
||||||
|
eventPublisher.publishAsync("transcription-events", event);
|
||||||
|
|
||||||
|
log.debug("Event Hub 이벤트 발행 완료 - meetingId: {}, text: {}",
|
||||||
|
meetingId, result.getText());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Event Hub 이벤트 발행 실패 - meetingId: {}", meetingId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,203 @@
|
|||||||
|
package com.unicorn.hgzero.stt.service;
|
||||||
|
|
||||||
|
import com.unicorn.hgzero.stt.dto.AudioChunkDto;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.connection.stream.MapRecord;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 버퍼 서비스
|
||||||
|
* Redis Stream을 사용하여 오디오 청크를 버퍼링
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AudioBufferService {
|
||||||
|
|
||||||
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
// Redis 키 패턴
|
||||||
|
private static final String AUDIO_STREAM_PREFIX = "audio:stream:";
|
||||||
|
private static final String ACTIVE_MEETINGS_KEY = "active:meetings";
|
||||||
|
private static final long STREAM_TTL_SECONDS = 60; // 1분 후 자동 삭제
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 청크를 Redis Stream에 저장
|
||||||
|
*
|
||||||
|
* @param chunk 오디오 청크
|
||||||
|
*/
|
||||||
|
public void bufferAudioChunk(AudioChunkDto chunk) {
|
||||||
|
try {
|
||||||
|
String streamKey = getStreamKey(chunk.getMeetingId());
|
||||||
|
|
||||||
|
// Hash 형태로 저장
|
||||||
|
Map<String, Object> data = Map.of(
|
||||||
|
"audioData", chunk.getAudioData(),
|
||||||
|
"timestamp", chunk.getTimestamp(),
|
||||||
|
"chunkIndex", chunk.getChunkIndex(),
|
||||||
|
"format", chunk.getFormat() != null ? chunk.getFormat() : "audio/webm",
|
||||||
|
"sampleRate", chunk.getSampleRate() != null ? chunk.getSampleRate() : 16000
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redis Stream에 추가 (XADD)
|
||||||
|
redisTemplate.opsForStream().add(streamKey, data);
|
||||||
|
|
||||||
|
// 활성 회의 목록에 추가
|
||||||
|
redisTemplate.opsForSet().add(ACTIVE_MEETINGS_KEY, chunk.getMeetingId());
|
||||||
|
|
||||||
|
// TTL 설정 (1분)
|
||||||
|
redisTemplate.expire(streamKey, STREAM_TTL_SECONDS, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
log.debug("오디오 청크 버퍼링 완료 - meetingId: {}, chunkIndex: {}",
|
||||||
|
chunk.getMeetingId(), chunk.getChunkIndex());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("오디오 청크 버퍼링 실패 - meetingId: {}", chunk.getMeetingId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의별 활성 오디오 청크 조회
|
||||||
|
*
|
||||||
|
* @param meetingId 회의 ID
|
||||||
|
* @return 오디오 청크 리스트
|
||||||
|
*/
|
||||||
|
public List<AudioChunkDto> getAudioChunks(String meetingId) {
|
||||||
|
try {
|
||||||
|
String streamKey = getStreamKey(meetingId);
|
||||||
|
List<AudioChunkDto> chunks = new ArrayList<>();
|
||||||
|
|
||||||
|
// Redis Stream에서 모든 데이터 조회 (XRANGE)
|
||||||
|
List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream()
|
||||||
|
.range(streamKey, org.springframework.data.domain.Range.unbounded());
|
||||||
|
|
||||||
|
if (records == null || records.isEmpty()) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapRecord를 AudioChunkDto로 변환
|
||||||
|
for (MapRecord<String, Object, Object> record : records) {
|
||||||
|
Map<Object, Object> value = record.getValue();
|
||||||
|
|
||||||
|
AudioChunkDto chunk = AudioChunkDto.builder()
|
||||||
|
.meetingId(meetingId)
|
||||||
|
.audioData((byte[]) value.get("audioData"))
|
||||||
|
.timestamp(Long.valueOf(value.get("timestamp").toString()))
|
||||||
|
.chunkIndex(Integer.valueOf(value.get("chunkIndex").toString()))
|
||||||
|
.format((String) value.get("format"))
|
||||||
|
.sampleRate(Integer.valueOf(value.get("sampleRate").toString()))
|
||||||
|
.build();
|
||||||
|
chunks.add(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("오디오 청크 조회 완료 - meetingId: {}, count: {}", meetingId, chunks.size());
|
||||||
|
return chunks;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("오디오 청크 조회 실패 - meetingId: {}", meetingId, e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리된 오디오 청크 삭제
|
||||||
|
*
|
||||||
|
* @param meetingId 회의 ID
|
||||||
|
*/
|
||||||
|
public void clearProcessedChunks(String meetingId) {
|
||||||
|
try {
|
||||||
|
String streamKey = getStreamKey(meetingId);
|
||||||
|
|
||||||
|
// 스트림 전체 삭제
|
||||||
|
redisTemplate.delete(streamKey);
|
||||||
|
|
||||||
|
log.debug("오디오 청크 삭제 완료 - meetingId: {}", meetingId);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("오디오 청크 삭제 실패 - meetingId: {}", meetingId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 회의 목록 조회
|
||||||
|
*
|
||||||
|
* @return 활성 회의 ID 목록
|
||||||
|
*/
|
||||||
|
public Set<String> getActiveMeetings() {
|
||||||
|
try {
|
||||||
|
Set<Object> meetings = redisTemplate.opsForSet().members(ACTIVE_MEETINGS_KEY);
|
||||||
|
if (meetings == null) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
return meetings.stream()
|
||||||
|
.map(Object::toString)
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("활성 회의 목록 조회 실패", e);
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의를 활성 목록에서 제거
|
||||||
|
*
|
||||||
|
* @param meetingId 회의 ID
|
||||||
|
*/
|
||||||
|
public void removeMeetingFromActive(String meetingId) {
|
||||||
|
try {
|
||||||
|
redisTemplate.opsForSet().remove(ACTIVE_MEETINGS_KEY, meetingId);
|
||||||
|
log.info("회의 비활성화 - meetingId: {}", meetingId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("회의 비활성화 실패 - meetingId: {}", meetingId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 청크 병합
|
||||||
|
*
|
||||||
|
* @param chunks 오디오 청크 리스트
|
||||||
|
* @return 병합된 오디오 데이터
|
||||||
|
*/
|
||||||
|
public byte[] mergeAudioChunks(List<AudioChunkDto> chunks) {
|
||||||
|
if (chunks == null || chunks.isEmpty()) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 청크 인덱스 순서로 정렬
|
||||||
|
chunks.sort((a, b) -> Integer.compare(a.getChunkIndex(), b.getChunkIndex()));
|
||||||
|
|
||||||
|
// 전체 크기 계산
|
||||||
|
int totalSize = chunks.stream()
|
||||||
|
.mapToInt(chunk -> chunk.getAudioData().length)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
// 병합
|
||||||
|
byte[] mergedAudio = new byte[totalSize];
|
||||||
|
int offset = 0;
|
||||||
|
|
||||||
|
for (AudioChunkDto chunk : chunks) {
|
||||||
|
System.arraycopy(chunk.getAudioData(), 0, mergedAudio, offset, chunk.getAudioData().length);
|
||||||
|
offset += chunk.getAudioData().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("오디오 청크 병합 완료 - chunks: {}, totalSize: {}", chunks.size(), totalSize);
|
||||||
|
return mergedAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis Stream 키 생성
|
||||||
|
*/
|
||||||
|
private String getStreamKey(String meetingId) {
|
||||||
|
return AUDIO_STREAM_PREFIX + meetingId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
package com.unicorn.hgzero.stt.service;
|
||||||
|
|
||||||
|
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 jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azure Speech Service 연동 서비스
|
||||||
|
* 배치 처리용 음성 인식 기능 제공
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class AzureSpeechService {
|
||||||
|
|
||||||
|
@Value("${azure.speech.subscription-key}")
|
||||||
|
private String subscriptionKey;
|
||||||
|
|
||||||
|
@Value("${azure.speech.region}")
|
||||||
|
private String region;
|
||||||
|
|
||||||
|
@Value("${azure.speech.language:ko-KR}")
|
||||||
|
private String language;
|
||||||
|
|
||||||
|
private SpeechConfig speechConfig;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
if (subscriptionKey == null || subscriptionKey.trim().isEmpty()) {
|
||||||
|
log.warn("Azure Speech Subscription Key 미설정 - 시뮬레이션 모드로 실행");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region);
|
||||||
|
speechConfig.setSpeechRecognitionLanguage(language);
|
||||||
|
|
||||||
|
// 연속 인식 설정 최적화
|
||||||
|
speechConfig.setProperty(PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, "3000");
|
||||||
|
speechConfig.setProperty(PropertyId.SpeechServiceConnection_InitialSilenceTimeoutMs, "10000");
|
||||||
|
|
||||||
|
log.info("Azure Speech Service 초기화 완료 - Region: {}, Language: {}", region, language);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Azure Speech Service 초기화 실패", e);
|
||||||
|
throw new RuntimeException("Azure Speech Service 초기화 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 데이터를 텍스트로 변환 (배치 처리용)
|
||||||
|
*
|
||||||
|
* @param audioData 병합된 오디오 데이터 (5초 분량)
|
||||||
|
* @return 인식 결과
|
||||||
|
*/
|
||||||
|
public RecognitionResult recognizeAudio(byte[] audioData) {
|
||||||
|
if (!isAvailable()) {
|
||||||
|
log.warn("Azure Speech Service 비활성화 - 시뮬레이션 결과 반환");
|
||||||
|
return createSimulationResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
PushAudioInputStream pushStream = null;
|
||||||
|
SpeechRecognizer recognizer = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Push 오디오 스트림 생성
|
||||||
|
pushStream = AudioInputStream.createPushStream();
|
||||||
|
AudioConfig audioConfig = AudioConfig.fromStreamInput(pushStream);
|
||||||
|
|
||||||
|
// 음성 인식기 생성
|
||||||
|
recognizer = new SpeechRecognizer(speechConfig, audioConfig);
|
||||||
|
|
||||||
|
// 오디오 데이터 전송
|
||||||
|
pushStream.write(audioData);
|
||||||
|
pushStream.close();
|
||||||
|
|
||||||
|
// 인식 실행 (동기 방식)
|
||||||
|
SpeechRecognitionResult result = recognizer.recognizeOnceAsync().get();
|
||||||
|
|
||||||
|
// 결과 처리
|
||||||
|
return processRecognitionResult(result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("음성 인식 실패", e);
|
||||||
|
return new RecognitionResult("", 0.0, false);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// 리소스 정리
|
||||||
|
if (recognizer != null) {
|
||||||
|
recognizer.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azure Speech 인식 결과 처리
|
||||||
|
*/
|
||||||
|
private RecognitionResult processRecognitionResult(SpeechRecognitionResult result) {
|
||||||
|
if (result.getReason() == ResultReason.RecognizedSpeech) {
|
||||||
|
String text = result.getText();
|
||||||
|
double confidence = calculateConfidence(text);
|
||||||
|
|
||||||
|
log.info("음성 인식 성공: {}, 신뢰도: {:.2f}", text, confidence);
|
||||||
|
return new RecognitionResult(text, confidence, true);
|
||||||
|
|
||||||
|
} else if (result.getReason() == ResultReason.NoMatch) {
|
||||||
|
log.debug("음성 인식 실패 - NoMatch (무음 또는 인식 불가)");
|
||||||
|
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());
|
||||||
|
return new RecognitionResult("", 0.0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RecognitionResult("", 0.0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신뢰도 계산 (추정)
|
||||||
|
* Azure Speech는 confidence를 직접 제공하지 않으므로 텍스트 길이 기반 추정
|
||||||
|
*/
|
||||||
|
private double calculateConfidence(String text) {
|
||||||
|
if (text == null || text.trim().isEmpty()) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 길이 기반 휴리스틱
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시뮬레이션 결과 생성 (Azure Speech 비활성화 시)
|
||||||
|
*/
|
||||||
|
private RecognitionResult createSimulationResult() {
|
||||||
|
String[] sampleTexts = {
|
||||||
|
"신제품 개발 일정에 대해 논의하고 있습니다.",
|
||||||
|
"마케팅 예산 배분 계획을 수립해야 합니다.",
|
||||||
|
"다음 주까지 프로토타입을 완성하기로 했습니다.",
|
||||||
|
"고객 피드백을 반영한 개선안을 검토 중입니다.",
|
||||||
|
"프로젝트 일정 조정이 필요할 것 같습니다.",
|
||||||
|
"기술 스택 선정에 대한 의견을 나누고 있습니다."
|
||||||
|
};
|
||||||
|
|
||||||
|
int index = (int) (Math.random() * sampleTexts.length);
|
||||||
|
String text = sampleTexts[index];
|
||||||
|
|
||||||
|
log.info("[시뮬레이션] 음성 인식 결과: {}", text);
|
||||||
|
return new RecognitionResult(text, 0.85, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azure Speech Service 사용 가능 여부
|
||||||
|
*/
|
||||||
|
public boolean isAvailable() {
|
||||||
|
return speechConfig != null &&
|
||||||
|
subscriptionKey != null &&
|
||||||
|
!subscriptionKey.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void cleanup() {
|
||||||
|
if (speechConfig != null) {
|
||||||
|
speechConfig.close();
|
||||||
|
log.info("Azure Speech Service 종료");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인식 결과 DTO
|
||||||
|
*/
|
||||||
|
public static class RecognitionResult {
|
||||||
|
private final String text;
|
||||||
|
private final double confidence;
|
||||||
|
private final boolean success;
|
||||||
|
|
||||||
|
public RecognitionResult(String text, double confidence, boolean success) {
|
||||||
|
this.text = text;
|
||||||
|
this.confidence = confidence;
|
||||||
|
this.success = success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getText() {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getConfidence() {
|
||||||
|
return confidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user