From 0209652a900406decda71c46bf09b69f41a22eb8 Mon Sep 17 00:00:00 2001 From: Minseo-Jo Date: Mon, 27 Oct 2025 13:39:22 +0900 Subject: [PATCH] =?UTF-8?q?STT=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EB=B0=A9=EC=8B=9D=20=EA=B5=AC=ED=98=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 구현사항: - 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 --- develop/dev/dev-stt-batch-implementation.md | 466 ++++++++++++++++++ .../unicorn/hgzero/stt/SttApplication.java | 2 + .../hgzero/stt/config/RedisStreamConfig.java | 45 ++ .../hgzero/stt/config/WebSocketConfig.java | 50 +- .../stt/controller/AudioWebSocketHandler.java | 161 ++++++ .../unicorn/hgzero/stt/dto/AudioChunkDto.java | 47 ++ .../stt/service/AudioBatchProcessor.java | 170 +++++++ .../stt/service/AudioBufferService.java | 203 ++++++++ .../stt/service/AzureSpeechService.java | 205 ++++++++ 9 files changed, 1307 insertions(+), 42 deletions(-) create mode 100644 develop/dev/dev-stt-batch-implementation.md create mode 100644 stt/src/main/java/com/unicorn/hgzero/stt/config/RedisStreamConfig.java create mode 100644 stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java create mode 100644 stt/src/main/java/com/unicorn/hgzero/stt/dto/AudioChunkDto.java create mode 100644 stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java create mode 100644 stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBufferService.java create mode 100644 stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java diff --git a/develop/dev/dev-stt-batch-implementation.md b/develop/dev/dev-stt-batch-implementation.md new file mode 100644 index 0000000..642fe4a --- /dev/null +++ b/develop/dev/dev-stt-batch-implementation.md @@ -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 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 meetings = audioBufferService.getActiveMeetings(); + + for (String meetingId : meetings) { + // 2. 오디오 청크 조회 (최근 5초) + List 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 + +``` + +### 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 diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/SttApplication.java b/stt/src/main/java/com/unicorn/hgzero/stt/SttApplication.java index 51d4896..e8b8783 100644 --- a/stt/src/main/java/com/unicorn/hgzero/stt/SttApplication.java +++ b/stt/src/main/java/com/unicorn/hgzero/stt/SttApplication.java @@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; /** * STT Service Application @@ -21,6 +22,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "com.unicorn.hgzero.stt.repository.jpa", "com.unicorn.hgzero.common.repository" }) +@EnableScheduling // 배치 작업 스케줄링 활성화 public class SttApplication { public static void main(String[] args) { diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/config/RedisStreamConfig.java b/stt/src/main/java/com/unicorn/hgzero/stt/config/RedisStreamConfig.java new file mode 100644 index 0000000..fa0ba51 --- /dev/null +++ b/stt/src/main/java/com/unicorn/hgzero/stt/config/RedisStreamConfig.java @@ -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 audioRedisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate 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); + } +} diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/config/WebSocketConfig.java b/stt/src/main/java/com/unicorn/hgzero/stt/config/WebSocketConfig.java index 9d084a0..20b3df0 100644 --- a/stt/src/main/java/com/unicorn/hgzero/stt/config/WebSocketConfig.java +++ b/stt/src/main/java/com/unicorn/hgzero/stt/config/WebSocketConfig.java @@ -1,5 +1,7 @@ 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.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; @@ -11,51 +13,15 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry */ @Configuration @EnableWebSocket +@RequiredArgsConstructor public class WebSocketConfig implements WebSocketConfigurer { - + + private final AudioWebSocketHandler audioWebSocketHandler; + @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - // 실시간 STT WebSocket 엔드포인트 등록 - registry.addHandler(new SttWebSocketHandler(), "/ws/stt/{sessionId}") + // 오디오 스트리밍 WebSocket 엔드포인트 + registry.addHandler(audioWebSocketHandler, "/ws/audio") .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; - } - } } \ No newline at end of file diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java b/stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java new file mode 100644 index 0000000..ac3d580 --- /dev/null +++ b/stt/src/main/java/com/unicorn/hgzero/stt/controller/AudioWebSocketHandler.java @@ -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 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 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 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); + } +} diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/dto/AudioChunkDto.java b/stt/src/main/java/com/unicorn/hgzero/stt/dto/AudioChunkDto.java new file mode 100644 index 0000000..72f97ae --- /dev/null +++ b/stt/src/main/java/com/unicorn/hgzero/stt/dto/AudioChunkDto.java @@ -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; +} diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java b/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java new file mode 100644 index 0000000..ae0d9d3 --- /dev/null +++ b/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBatchProcessor.java @@ -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 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 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); + } + } +} diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBufferService.java b/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBufferService.java new file mode 100644 index 0000000..8d9955f --- /dev/null +++ b/stt/src/main/java/com/unicorn/hgzero/stt/service/AudioBufferService.java @@ -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 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 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 getAudioChunks(String meetingId) { + try { + String streamKey = getStreamKey(meetingId); + List chunks = new ArrayList<>(); + + // Redis Stream에서 모든 데이터 조회 (XRANGE) + List> records = redisTemplate.opsForStream() + .range(streamKey, org.springframework.data.domain.Range.unbounded()); + + if (records == null || records.isEmpty()) { + return chunks; + } + + // MapRecord를 AudioChunkDto로 변환 + for (MapRecord record : records) { + Map 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 getActiveMeetings() { + try { + Set 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 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; + } +} diff --git a/stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java b/stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java new file mode 100644 index 0000000..ece6842 --- /dev/null +++ b/stt/src/main/java/com/unicorn/hgzero/stt/service/AzureSpeechService.java @@ -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; + } + } +}