Feat: AI 서비스 및 STT 서비스 기능 개선

- AI 서비스: Redis 캐싱 및 EventHub 통합 개선
- STT 서비스: 오디오 버퍼링 및 변환 기능 추가
- 설정 파일 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo
2025-10-30 15:23:30 +09:00
parent ad287de176
commit 032842cf53
13 changed files with 1096 additions and 156 deletions
@@ -2,7 +2,7 @@ package com.unicorn.hgzero.stt.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.dto.AudioChunkDto;
import com.unicorn.hgzero.stt.service.AudioBufferService;
import com.unicorn.hgzero.stt.service.InMemoryAudioBufferService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -24,7 +24,7 @@ import java.util.concurrent.ConcurrentHashMap;
@RequiredArgsConstructor
public class AudioWebSocketHandler extends AbstractWebSocketHandler {
private final AudioBufferService audioBufferService;
private final InMemoryAudioBufferService audioBufferService;
private final ObjectMapper objectMapper;
// 세션별 회의 ID 매핑
@@ -1,74 +1,118 @@
package com.unicorn.hgzero.stt.event.publisher;
import com.azure.messaging.eventhubs.EventData;
import com.azure.messaging.eventhubs.EventDataBatch;
import com.azure.messaging.eventhubs.EventHubClientBuilder;
import com.azure.messaging.eventhubs.EventHubProducerClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.concurrent.CompletableFuture;
/**
* Azure Event Hub 이벤트 발행자 구현체
* Azure Event Hubs를 통한 이벤트 발행 기능
* Azure Event Hubs를 통한 실제 이벤트 발행 기능
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventHubPublisher implements EventPublisher {
private final ObjectMapper objectMapper;
@Value("${azure.eventhub.connection-string}")
private String connectionString;
@Value("${azure.eventhub.name}")
private String eventHubName;
private EventHubProducerClient producerClient;
/**
* Event Hub Producer Client 초기화
*/
@PostConstruct
public void initialize() {
if (connectionString == null || connectionString.isEmpty()) {
log.warn("Event Hub 연결 문자열이 설정되지 않음 - Event Hub 발행 비활성화");
return;
}
try {
producerClient = new EventHubClientBuilder()
.connectionString(connectionString, eventHubName)
.buildProducerClient();
log.info("Event Hub Producer Client 초기화 완료 - EventHub: {}", eventHubName);
} catch (Exception e) {
log.error("Event Hub Producer Client 초기화 실패", e);
throw new RuntimeException("Event Hub 연결에 실패했습니다", e);
}
}
/**
* 애플리케이션 종료 시 Producer Client 정리
*/
@PreDestroy
public void cleanup() {
if (producerClient != null) {
try {
producerClient.close();
log.info("Event Hub Producer Client 종료 완료");
} catch (Exception e) {
log.error("Event Hub Producer Client 종료 중 오류", e);
}
}
}
@Override
public void publish(String topic, Object event) {
if (producerClient == null) {
log.warn("Event Hub가 연결되지 않음 - 이벤트 발행 건너뜀");
return;
}
try {
String eventData = objectMapper.writeValueAsString(event);
// 실제로는 Azure Event Hubs SDK 사용
// EventHubProducerClient producer = createProducer(topic);
// EventDataBatch batch = producer.createBatch();
// batch.tryAdd(new EventData(eventData));
// producer.send(batch);
// 시뮬레이션
simulateEventHubPublish(topic, eventData);
log.info("이벤트 발행 완료 - topic: {}, event: {}", topic, event.getClass().getSimpleName());
// Event Data Batch 생성 및 전송
EventDataBatch batch = producerClient.createBatch();
// 이벤트 추가
boolean added = batch.tryAdd(new EventData(eventData));
if (!added) {
log.error("이벤트가 배치 크기를 초과하여 추가 실패 - 데이터 길이: {}", eventData.length());
throw new RuntimeException("이벤트 배치 추가 실패");
}
// Event Hub로 전송
producerClient.send(batch);
log.info("✅ 이벤트 발행 완료 - EventHub: {}, EventType: {}, 데이터 길이: {}자",
eventHubName, event.getClass().getSimpleName(), eventData.length());
log.debug("발행된 데이터: {}", eventData);
} catch (Exception e) {
log.error("이벤트 발행 실패 - topic: {}, event: {}", topic, event.getClass().getSimpleName(), e);
log.error("이벤트 발행 실패 - EventHub: {}, EventType: {}",
eventHubName, event.getClass().getSimpleName(), e);
throw new RuntimeException("이벤트 발행에 실패했습니다", e);
}
}
@Override
public void publishAsync(String topic, Object event) {
CompletableFuture.runAsync(() -> {
try {
publish(topic, event);
} catch (Exception e) {
log.error("비동기 이벤트 발행 실패 - topic: {}, event: {}", topic, event.getClass().getSimpleName(), e);
log.error("비동기 이벤트 발행 실패 - EventHub: {}, EventType: {}",
eventHubName, event.getClass().getSimpleName(), e);
}
});
}
/**
* Azure Event Hub 발행 시뮬레이션
*/
private void simulateEventHubPublish(String topic, String eventData) {
log.debug("Event Hub 발행 시뮬레이션:");
log.debug("Topic: {}", topic);
log.debug("Event Data: {}", eventData);
// 실제로는 다음과 같은 Azure Event Hubs 코드 사용:
/*
EventHubProducerClient producer = new EventHubClientBuilder()
.connectionString(connectionString, eventHubName)
.buildProducerClient();
EventDataBatch batch = producer.createBatch();
batch.tryAdd(new EventData(eventData));
producer.send(batch);
producer.close();
*/
}
}
}
@@ -15,30 +15,44 @@ import java.util.Set;
import java.util.UUID;
/**
* 오디오 배치 프로세서
* 15초마다 Redis에 축적된 오디오를 처리하여 텍스트로 변환
* 오디오 배치 프로세서 (2단계 처리)
*
* Note: STT 결과는 DB에 저장하지 않고, Event Hub와 WebSocket으로만 전송
* 최종 회의록은 AI 서비스에서 저장
* 1단계 (STT): 5초마다 짧은 세그먼트 변환
* - 빠른 음성 인식으로 실시간 피드백
* - WebSocket으로 클라이언트에 즉시 표시
* - Event Hub로 AI 서비스에 전송 (누적)
*
* 2단계 (AI): AI 서비스에서 1분치 세그먼트로 제안사항 생성
* - Redis 누적 텍스트 (4-5개 세그먼트) 분석
* - SSE로 실시간 제안사항 전송
*
* Note: STT 결과는 DB 저장 없음, Event Hub와 WebSocket으로만 전송
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AudioBatchProcessor {
private final AudioBufferService audioBufferService;
private final InMemoryAudioBufferService audioBufferService;
private final AzureSpeechService azureSpeechService;
private final EventPublisher eventPublisher;
private final AudioWebSocketHandler webSocketHandler;
/**
* 15초마다 오디오 배치 처리
* 7초마다 오디오 배치 처리 (실시간 STT)
* - Redis에서 오디오 청크 조회
* - Azure Speech로 텍스트 변환
* - Event Hub 이벤트 발행 (AI 서비스로 전송)
* - WebSocket 실시간 전송 (클라이언트 표시)
* - Azure Speech로 텍스트 변환 (적절한 길이의 세그먼트)
* - Event Hub 이벤트 발행 (AI 서비스로 전송, 누적됨)
* - WebSocket 실시간 전송 (클라이언트에 즉시 표시)
*
* 7초 선택 이유:
* - 문장 완성도: 대부분의 발화가 완료되는 시간
* - 실시간성: 사용자가 즉각 피드백 확인 가능
* - Azure Speech 호환: recognizeOnceAsync() 최대 15초 이내
*
* AI 제안사항은 AI 서비스에서 별도로 생성 (약 1분치 누적 분석)
*/
@Scheduled(fixedDelay = 15000, initialDelay = 15000) // 15초마다 실행, 최초 15초 후 시작
@Scheduled(fixedDelay = 7000, initialDelay = 7000) // 7초마다 실행
public void processAudioBatch() {
try {
// 활성 회의 목록 조회
@@ -68,7 +82,7 @@ public class AudioBatchProcessor {
*/
private void processOneMeeting(String meetingId) {
try {
// Redis에서 최근 15초 오디오 청크 조회
// Redis에서 최근 7초 오디오 청크 조회
List<AudioChunkDto> chunks = audioBufferService.getAudioChunks(meetingId);
if (chunks.isEmpty()) {
@@ -78,7 +92,7 @@ public class AudioBatchProcessor {
log.info("오디오 청크 조회 완료 - meetingId: {}, chunks: {}개", meetingId, chunks.size());
// 오디오 청크 병합 (15초 분량)
// 오디오 청크 병합 (7초 분량)
byte[] mergedAudio = audioBufferService.mergeAudioChunks(chunks);
if (mergedAudio.length == 0) {
@@ -102,7 +116,7 @@ public class AudioBatchProcessor {
// WebSocket으로 실시간 결과 전송 (클라이언트 표시)
sendTranscriptToClients(meetingId, result);
// Redis 정리
// 처리 완료된 청크 삭제 (중복 처리 방지)
audioBufferService.clearProcessedChunks(meetingId);
log.info("회의 처리 완료 - meetingId: {}, text: {}", meetingId, result.getText());
@@ -4,6 +4,7 @@ import com.microsoft.cognitiveservices.speech.*;
import com.microsoft.cognitiveservices.speech.audio.AudioConfig;
import com.microsoft.cognitiveservices.speech.audio.AudioInputStream;
import com.microsoft.cognitiveservices.speech.audio.PushAudioInputStream;
import com.unicorn.hgzero.stt.util.AudioConverter;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
@@ -43,9 +44,15 @@ public class AzureSpeechService {
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region);
speechConfig.setSpeechRecognitionLanguage(language);
// 연속 인식 설정 최적화
speechConfig.setProperty(PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, "3000");
speechConfig.setProperty(PropertyId.SpeechServiceConnection_InitialSilenceTimeoutMs, "10000");
// 연속 인식 설정 최적화 - 회의록에 적합하게 조정
speechConfig.setProperty(PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, "5000"); // 5초로 증가
speechConfig.setProperty(PropertyId.SpeechServiceConnection_InitialSilenceTimeoutMs, "15000"); // 15초로 증가
// 중간 결과 활성화 (연속 인식 시 유용)
speechConfig.setProperty(PropertyId.SpeechServiceResponse_RequestDetailedResultTrueFalse, "true");
// 음성 인식 품질 향상
speechConfig.setProperty(PropertyId.SpeechServiceConnection_RecoLanguage, language);
log.info("Azure Speech Service 초기화 완료 - Region: {}, Language: {}", region, language);
@@ -58,7 +65,7 @@ public class AzureSpeechService {
/**
* 오디오 데이터를 텍스트로 변환 (배치 처리용)
*
* @param audioData 병합된 오디오 데이터 (5초 분량)
* @param audioData 병합된 오디오 데이터 (15초 분량)
* @return 인식 결과
*/
public RecognitionResult recognizeAudio(byte[] audioData) {
@@ -67,10 +74,69 @@ public class AzureSpeechService {
return createSimulationResult();
}
// 오디오 데이터 품질 검증
if (!AudioConverter.isValidAudioData(audioData)) {
log.warn("유효하지 않은 오디오 데이터 - 인식 건너뜀");
return new RecognitionResult("", 0.0, false);
}
// 오디오 통계 로깅 (디버깅용)
AudioConverter.AudioStats stats = AudioConverter.calculateStats(audioData);
log.debug("오디오 통계: {}", stats);
// 재시도 로직 (최대 3회)
int maxRetries = 3;
int retryDelay = 1000; // 1초
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
RecognitionResult result = recognizeAudioInternal(audioData);
if (result.isSuccess()) {
log.info("음성 인식 성공 (시도 {}/{})", attempt, maxRetries);
return result;
}
// NoMatch 결과일 경우 재시도
if (attempt < maxRetries) {
log.warn("음성 인식 실패 (NoMatch) - 재시도 {}/{}", attempt, maxRetries);
Thread.sleep(retryDelay);
retryDelay *= 2; // 지수 백오프
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("재시도 중단됨", e);
break;
} catch (Exception e) {
log.error("음성 인식 실패 (시도 {}/{})", attempt, maxRetries, e);
if (attempt < maxRetries) {
try {
Thread.sleep(retryDelay);
retryDelay *= 2;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
log.warn("음성 인식 최종 실패 - 최대 재시도 횟수 초과");
return new RecognitionResult("", 0.0, false);
}
/**
* 실제 음성 인식 수행 (내부 메서드)
*/
private RecognitionResult recognizeAudioInternal(byte[] audioData) throws Exception {
PushAudioInputStream pushStream = null;
SpeechRecognizer recognizer = null;
try {
// WAV 형식으로 변환
byte[] wavData = AudioConverter.convertToWav(audioData);
// Push 오디오 스트림 생성
pushStream = AudioInputStream.createPushStream();
AudioConfig audioConfig = AudioConfig.fromStreamInput(pushStream);
@@ -79,7 +145,7 @@ public class AzureSpeechService {
recognizer = new SpeechRecognizer(speechConfig, audioConfig);
// 오디오 데이터 전송
pushStream.write(audioData);
pushStream.write(wavData);
pushStream.close();
// 인식 실행 (동기 방식)
@@ -88,10 +154,6 @@ public class AzureSpeechService {
// 결과 처리
return processRecognitionResult(result);
} catch (Exception e) {
log.error("음성 인식 실패", e);
return new RecognitionResult("", 0.0, false);
} finally {
// 리소스 정리
if (recognizer != null) {
@@ -106,19 +168,25 @@ public class AzureSpeechService {
private RecognitionResult processRecognitionResult(SpeechRecognitionResult result) {
if (result.getReason() == ResultReason.RecognizedSpeech) {
String text = result.getText();
double confidence = calculateConfidence(text);
log.info("음성 인식 성공: {}, 신뢰도: {:.2f}", text, confidence);
// Azure Speech SDK의 실제 신뢰도 사용 (있는 경우)
// Note: Java SDK는 confidence를 직접 제공하지 않으므로 추정치 사용
double confidence = calculateConfidence(text, result);
log.info("음성 인식 성공: {}, 신뢰도: {}", text, confidence);
return new RecognitionResult(text, confidence, true);
} else if (result.getReason() == ResultReason.NoMatch) {
log.debug("음성 인식 실패 - NoMatch (무음 또는 인식 불가)");
NoMatchDetails noMatch = NoMatchDetails.fromResult(result);
log.debug("음성 인식 실패 - NoMatch, Reason: {}", noMatch.getReason());
return new RecognitionResult("", 0.0, false);
} else if (result.getReason() == ResultReason.Canceled) {
CancellationDetails cancellation = CancellationDetails.fromResult(result);
log.error("음성 인식 취소 - Reason: {}, Details: {}",
cancellation.getReason(), cancellation.getErrorDetails());
log.error("음성 인식 취소 - Reason: {}, ErrorCode: {}, Details: {}",
cancellation.getReason(),
cancellation.getErrorCode(),
cancellation.getErrorDetails());
return new RecognitionResult("", 0.0, false);
}
@@ -126,20 +194,45 @@ public class AzureSpeechService {
}
/**
* 신뢰도 계산 (추정)
* Azure Speech는 confidence를 직접 제공하지 않으므로 텍스트 길이 기반 추정
* 신뢰도 계산 (개선된 추정 알고리즘)
* Azure Speech Java SDK는 confidence를 직접 제공하지 않으므로 여러 지표 기반 추정
*/
private double calculateConfidence(String text) {
private double calculateConfidence(String text, SpeechRecognitionResult result) {
if (text == null || text.trim().isEmpty()) {
return 0.0;
}
// 텍스트 길이 기반 휴리스틱
double confidence = 0.7; // 기본값
// 1. 텍스트 길이 기반 (더 긴 텍스트 = 높은 신뢰도)
int length = text.length();
if (length > 50) return 0.95;
if (length > 20) return 0.85;
if (length > 10) return 0.75;
return 0.65;
if (length > 50) {
confidence += 0.15;
} else if (length > 20) {
confidence += 0.10;
} else if (length > 10) {
confidence += 0.05;
}
// 2. 단어 수 기반 (더 많은 단어 = 높은 신뢰도)
String[] words = text.trim().split("\\s+");
if (words.length > 10) {
confidence += 0.10;
} else if (words.length > 5) {
confidence += 0.05;
}
// 3. 특수문자 비율 (낮을수록 높은 신뢰도)
long specialCharCount = text.chars()
.filter(c -> !Character.isLetterOrDigit(c) && !Character.isWhitespace(c))
.count();
double specialCharRatio = (double) specialCharCount / length;
if (specialCharRatio < 0.05) {
confidence += 0.05;
}
// 최대값 제한
return Math.min(confidence, 0.98);
}
/**
@@ -0,0 +1,165 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.stt.dto.AudioChunkDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 인메모리 오디오 버퍼 서비스 (Redis 대체)
*
* 7초 배치 처리를 위한 임시 저장소
* - 빠른 속도: 네트워크 없음, 메모리 직접 접근
* - 타임아웃 없음: Azure Redis 타임아웃 문제 해결
* - 간단함: Redis 설정 불필요
*
* Note: 서버 재시작 시 데이터 손실 가능 (7초 분량만 손실되므로 MVP에서는 허용)
*/
@Slf4j
@Service
public class InMemoryAudioBufferService {
// 회의 ID별 오디오 청크 저장소
private final Map<String, List<AudioChunkDto>> audioChunksMap = new ConcurrentHashMap<>();
// 활성 회의 목록
private final Set<String> activeMeetings = ConcurrentHashMap.newKeySet();
/**
* 오디오 청크 버퍼링 (인메모리 저장)
*/
public void bufferAudioChunk(AudioChunkDto chunk) {
try {
String meetingId = chunk.getMeetingId();
// 회의별 청크 리스트 가져오기 (없으면 생성)
List<AudioChunkDto> chunks = audioChunksMap.computeIfAbsent(
meetingId,
k -> Collections.synchronizedList(new ArrayList<>())
);
// 청크 추가
chunks.add(chunk);
// 활성 회의 목록에 추가
activeMeetings.add(meetingId);
log.debug("오디오 청크 버퍼링 완료 (인메모리) - meetingId: {}, chunkIndex: {}, 총: {}개",
meetingId, chunk.getChunkIndex(), chunks.size());
} catch (Exception e) {
log.error("오디오 청크 버퍼링 실패 - meetingId: {}", chunk.getMeetingId(), e);
}
}
/**
* 활성 회의 목록 조회
*/
public Set<String> getActiveMeetings() {
return new HashSet<>(activeMeetings);
}
/**
* 회의의 모든 오디오 청크 조회 (배치 처리용)
*/
public List<AudioChunkDto> getAudioChunks(String meetingId) {
List<AudioChunkDto> chunks = audioChunksMap.get(meetingId);
if (chunks == null || chunks.isEmpty()) {
return Collections.emptyList();
}
// 복사본 반환 (thread-safe)
synchronized (chunks) {
return new ArrayList<>(chunks);
}
}
/**
* 처리 완료된 청크 삭제
*/
public void clearProcessedChunks(String meetingId) {
try {
List<AudioChunkDto> chunks = audioChunksMap.get(meetingId);
if (chunks != null) {
synchronized (chunks) {
int removedCount = chunks.size();
chunks.clear();
log.debug("오디오 청크 삭제 완료 - meetingId: {}, 삭제된 청크: {}개",
meetingId, removedCount);
}
}
} catch (Exception e) {
log.error("오디오 청크 삭제 실패 - meetingId: {}", meetingId, e);
}
}
/**
* 오디오 청크 병합 (7초 분량)
*/
public byte[] mergeAudioChunks(List<AudioChunkDto> chunks) {
if (chunks == null || chunks.isEmpty()) {
return new byte[0];
}
try {
// 청크 인덱스 순서로 정렬
List<AudioChunkDto> sortedChunks = chunks.stream()
.sorted(Comparator.comparing(AudioChunkDto::getChunkIndex))
.collect(Collectors.toList());
// 전체 크기 계산
int totalSize = sortedChunks.stream()
.mapToInt(chunk -> chunk.getAudioData().length)
.sum();
// 병합
byte[] mergedAudio = new byte[totalSize];
int position = 0;
for (AudioChunkDto chunk : sortedChunks) {
byte[] chunkData = chunk.getAudioData();
System.arraycopy(chunkData, 0, mergedAudio, position, chunkData.length);
position += chunkData.length;
}
log.debug("오디오 청크 병합 완료 - 청크 수: {}, 총 크기: {} bytes",
sortedChunks.size(), totalSize);
return mergedAudio;
} catch (Exception e) {
log.error("오디오 청크 병합 실패", e);
return new byte[0];
}
}
/**
* 회의 종료 시 데이터 정리
*/
public void cleanupMeeting(String meetingId) {
audioChunksMap.remove(meetingId);
activeMeetings.remove(meetingId);
log.info("회의 데이터 정리 완료 (인메모리) - meetingId: {}", meetingId);
}
/**
* 전체 통계 조회 (모니터링용)
*/
public Map<String, Object> getStatistics() {
int totalChunks = audioChunksMap.values().stream()
.mapToInt(List::size)
.sum();
return Map.of(
"activeMeetings", activeMeetings.size(),
"totalChunks", totalChunks,
"meetingsWithData", audioChunksMap.size()
);
}
}
@@ -0,0 +1,204 @@
package com.unicorn.hgzero.stt.util;
import lombok.extern.slf4j.Slf4j;
import javax.sound.sampled.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
/**
* 오디오 형식 변환 유틸리티
* WebM → WAV(PCM 16bit 16kHz) 변환
*/
@Slf4j
public class AudioConverter {
private static final int TARGET_SAMPLE_RATE = 16000;
private static final int TARGET_SAMPLE_SIZE_IN_BITS = 16;
private static final int TARGET_CHANNELS = 1; // Mono
private static final boolean TARGET_SIGNED = true;
private static final boolean TARGET_BIG_ENDIAN = false;
/**
* WebM/기타 형식을 WAV(PCM 16bit 16kHz mono)로 변환
*
* @param audioData 원본 오디오 데이터
* @return WAV 형식 오디오 데이터
*/
public static byte[] convertToWav(byte[] audioData) {
if (audioData == null || audioData.length == 0) {
log.warn("변환할 오디오 데이터 없음");
return new byte[0];
}
try {
// WebM은 JavaSound API가 직접 지원하지 않으므로
// 이미 PCM 데이터라고 가정하고 WAV 헤더만 추가
// (실제 WebM 디코딩은 FFmpeg 필요)
// 최소 데이터 크기 검증 (1초 = 16000 samples * 2 bytes = 32KB)
if (audioData.length < 16000) {
log.warn("오디오 데이터가 너무 작음 ({}bytes) - 최소 16KB 필요", audioData.length);
}
// WAV 헤더 생성 및 데이터 결합
byte[] wavData = addWavHeader(audioData);
log.debug("오디오 형식 변환 완료 - 원본: {}bytes → WAV: {}bytes",
audioData.length, wavData.length);
return wavData;
} catch (Exception e) {
log.error("오디오 형식 변환 실패", e);
return audioData; // 실패 시 원본 반환
}
}
/**
* PCM 데이터에 WAV 헤더 추가
* Format: PCM 16bit 16kHz Mono
*/
private static byte[] addWavHeader(byte[] pcmData) throws IOException {
ByteArrayOutputStream wavStream = new ByteArrayOutputStream();
// RIFF 헤더
wavStream.write("RIFF".getBytes());
wavStream.write(intToByteArray(36 + pcmData.length), 0, 4); // ChunkSize
wavStream.write("WAVE".getBytes());
// fmt 청크
wavStream.write("fmt ".getBytes());
wavStream.write(intToByteArray(16), 0, 4); // Subchunk1Size (PCM = 16)
wavStream.write(shortToByteArray((short) 1), 0, 2); // AudioFormat (1 = PCM)
wavStream.write(shortToByteArray((short) TARGET_CHANNELS), 0, 2); // NumChannels
wavStream.write(intToByteArray(TARGET_SAMPLE_RATE), 0, 4); // SampleRate
int byteRate = TARGET_SAMPLE_RATE * TARGET_CHANNELS * TARGET_SAMPLE_SIZE_IN_BITS / 8;
wavStream.write(intToByteArray(byteRate), 0, 4); // ByteRate
int blockAlign = TARGET_CHANNELS * TARGET_SAMPLE_SIZE_IN_BITS / 8;
wavStream.write(shortToByteArray((short) blockAlign), 0, 2); // BlockAlign
wavStream.write(shortToByteArray((short) TARGET_SAMPLE_SIZE_IN_BITS), 0, 2); // BitsPerSample
// data 청크
wavStream.write("data".getBytes());
wavStream.write(intToByteArray(pcmData.length), 0, 4); // Subchunk2Size
wavStream.write(pcmData);
return wavStream.toByteArray();
}
/**
* int를 little-endian byte array로 변환
*/
private static byte[] intToByteArray(int value) {
return new byte[] {
(byte) (value & 0xff),
(byte) ((value >> 8) & 0xff),
(byte) ((value >> 16) & 0xff),
(byte) ((value >> 24) & 0xff)
};
}
/**
* short를 little-endian byte array로 변환
*/
private static byte[] shortToByteArray(short value) {
return new byte[] {
(byte) (value & 0xff),
(byte) ((value >> 8) & 0xff)
};
}
/**
* 오디오 품질 검증
*
* @param audioData 검증할 오디오 데이터
* @return 최소 품질 기준 충족 여부
*/
public static boolean isValidAudioData(byte[] audioData) {
if (audioData == null || audioData.length == 0) {
log.warn("오디오 데이터 없음");
return false;
}
// 최소 크기: 0.5초 = 16000 samples/sec * 0.5 sec * 2 bytes = 16KB
int minSize = TARGET_SAMPLE_RATE * TARGET_SAMPLE_SIZE_IN_BITS / 8 / 2;
if (audioData.length < minSize) {
log.warn("오디오 데이터가 너무 작음 - size: {}bytes, 최소: {}bytes",
audioData.length, minSize);
return false;
}
// 무음 여부 검증 (모든 샘플이 0이면 무음)
boolean allZero = true;
for (byte b : audioData) {
if (b != 0) {
allZero = false;
break;
}
}
if (allZero) {
log.warn("무음 데이터 감지");
return false;
}
return true;
}
/**
* 오디오 데이터 통계 계산 (디버깅용)
*/
public static AudioStats calculateStats(byte[] audioData) {
if (audioData == null || audioData.length == 0) {
return new AudioStats(0, 0.0, 0.0, 0.0);
}
double sum = 0;
double sumSquares = 0;
int max = 0;
for (int i = 0; i < audioData.length - 1; i += 2) {
// 16bit PCM 샘플 읽기 (little-endian)
short sample = (short) ((audioData[i + 1] << 8) | (audioData[i] & 0xff));
int absSample = Math.abs(sample);
sum += absSample;
sumSquares += absSample * absSample;
max = Math.max(max, absSample);
}
int numSamples = audioData.length / 2;
double avg = sum / numSamples;
double rms = Math.sqrt(sumSquares / numSamples);
return new AudioStats(numSamples, avg, rms, max);
}
/**
* 오디오 통계 정보
*/
public static class AudioStats {
public final int numSamples;
public final double avgAmplitude;
public final double rmsAmplitude;
public final double maxAmplitude;
public AudioStats(int numSamples, double avgAmplitude, double rmsAmplitude, double maxAmplitude) {
this.numSamples = numSamples;
this.avgAmplitude = avgAmplitude;
this.rmsAmplitude = rmsAmplitude;
this.maxAmplitude = maxAmplitude;
}
@Override
public String toString() {
return String.format("AudioStats{samples=%d, avg=%.1f, rms=%.1f, max=%.1f}",
numSamples, avgAmplitude, rmsAmplitude, maxAmplitude);
}
}
}
+7 -5
View File
@@ -37,13 +37,15 @@ spring:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
timeout: 30000ms # 30초로 증가 (Azure 원격 Redis 대응)
connect-timeout: 10000ms # 연결 타임아웃 명시
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
max-active: 20 # 연결 풀 크기 증가 (8 → 20)
max-idle: 10
min-idle: 2 # 최소 유지 연결 (0 → 2)
max-wait: 5000ms # 연결 대기 최대 시간 (-1ms → 5000ms)
shutdown-timeout: 2000ms
database: ${REDIS_DATABASE:3}
# Server Configuration