hgzero/test-audio/stt-test-ai.html
Minseo-Jo 2c3bc432b3 STT 서비스 음성 인식 및 AI 제안사항 표시 기능 구현
- PCM 16kHz 포맷 지원으로 Azure Speech 인식 성공
- WebSocket 실시간 전송 기능 추가
- DB 저장 로직 제거 (AI 서비스에서 제안사항 저장)
- AI SSE 기반 제안사항 표시 테스트 페이지 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 10:12:55 +09:00

472 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HGZero AI 제안사항 실시간 테스트</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 900px;
margin: 50px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
}
.container {
background: white;
border-radius: 15px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h1 {
color: #667eea;
text-align: center;
margin-bottom: 10px;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
}
.controls {
display: flex;
gap: 15px;
justify-content: center;
margin: 30px 0;
}
button {
padding: 15px 30px;
font-size: 16px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
font-weight: bold;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#startBtn {
background: #48bb78;
color: white;
}
#startBtn:hover:not(:disabled) {
background: #38a169;
transform: translateY(-2px);
}
#stopBtn {
background: #f56565;
color: white;
}
#stopBtn:hover:not(:disabled) {
background: #e53e3e;
transform: translateY(-2px);
}
.status {
text-align: center;
padding: 15px;
margin: 20px 0;
border-radius: 8px;
font-weight: bold;
}
.status.disconnected {
background: #fed7d7;
color: #c53030;
}
.status.connected {
background: #c6f6d5;
color: #276749;
}
.status.recording {
background: #feebc8;
color: #c05621;
}
.info-box {
background: #ebf8ff;
border-left: 4px solid #4299e1;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.info-box h3 {
margin-top: 0;
color: #2c5282;
}
#suggestions {
background: #f7fafc;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
margin-top: 20px;
}
.suggestion-item {
padding: 15px;
margin: 10px 0;
background: white;
border-radius: 8px;
border-left: 4px solid #48bb78;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.suggestion-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.suggestion-time {
color: #718096;
font-size: 0.85em;
}
.suggestion-confidence {
background: #48bb78;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8em;
}
.suggestion-content {
color: #2d3748;
line-height: 1.6;
}
.log {
background: #1a202c;
color: #48bb78;
padding: 15px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
max-height: 150px;
overflow-y: auto;
margin-top: 20px;
}
.log-item {
margin: 3px 0;
}
.log-error {
color: #fc8181;
}
.log-info {
color: #63b3ed;
}
.empty-state {
text-align: center;
color: #a0aec0;
padding: 40px 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>💡 HGZero AI 제안사항 실시간 테스트</h1>
<p class="subtitle">STT + Claude AI 기반 실시간 회의 제안사항</p>
<div class="info-box">
<h3>📋 테스트 정보</h3>
<p><strong>STT Service:</strong> <code>ws://localhost:8084/ws/audio</code></p>
<p><strong>AI Service:</strong> <code>http://localhost:8086/api/v1/ai/suggestions</code></p>
<p><strong>Meeting ID:</strong> <code id="meetingId">test-meeting-001</code></p>
</div>
<div id="status" class="status disconnected">
🔴 준비 중
</div>
<div class="controls">
<button id="startBtn" onclick="startSession()">
🎤 회의 시작
</button>
<button id="stopBtn" onclick="stopSession()" disabled>
⏹️ 회의 종료
</button>
</div>
<div id="suggestions">
<div class="empty-state">
<p>🎙️ 회의를 시작하면 AI가 분석한 제안사항이 여기에 표시됩니다.</p>
<p style="font-size: 0.9em; margin-top: 10px;">명확하게 회의 내용을 말씀해주세요.</p>
</div>
</div>
<div class="log" id="log">
<div class="log-item">시스템 로그...</div>
</div>
</div>
<script>
let sttWebSocket = null;
let aiEventSource = null;
let audioContext = null;
let micStream = null;
let chunkIndex = 0;
let isRecording = false;
const meetingId = 'test-meeting-001';
// PCM 데이터를 16bit로 변환
function floatTo16BitPCM(float32Array) {
const buffer = new ArrayBuffer(float32Array.length * 2);
const view = new DataView(buffer);
let offset = 0;
for (let i = 0; i < float32Array.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, float32Array[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return buffer;
}
// 회의 시작
async function startSession() {
try {
// 1. STT WebSocket 연결
await connectSTTWebSocket();
// 2. 마이크 시작
await startMicrophone();
// 3. AI SSE 연결
connectAIEventSource();
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
updateStatus('recording', '🔴 회의 진행 중...');
} catch (error) {
addLog('❌ 회의 시작 실패: ' + error.message, 'error');
alert('회의 시작에 실패했습니다: ' + error.message);
}
}
// STT WebSocket 연결
function connectSTTWebSocket() {
return new Promise((resolve, reject) => {
const wsUrl = 'ws://localhost:8084/ws/audio';
addLog('STT WebSocket 연결 시도...', 'info');
sttWebSocket = new WebSocket(wsUrl);
sttWebSocket.onopen = () => {
addLog('✅ STT WebSocket 연결 성공', 'info');
// 녹음 시작 메시지 전송
sttWebSocket.send(JSON.stringify({
type: 'start',
meetingId: meetingId
}));
resolve();
};
sttWebSocket.onerror = (error) => {
addLog('❌ STT WebSocket 오류', 'error');
reject(error);
};
sttWebSocket.onclose = () => {
addLog('🔴 STT WebSocket 연결 종료', 'error');
};
});
}
// 마이크 시작
async function startMicrophone() {
addLog('🎤 마이크 접근 요청...', 'info');
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
addLog('✅ 마이크 접근 허용', 'info');
// AudioContext 생성 (16kHz)
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000
});
const source = audioContext.createMediaStreamSource(micStream);
const scriptNode = audioContext.createScriptProcessor(2048, 1, 1);
scriptNode.onaudioprocess = (audioProcessingEvent) => {
if (!isRecording) return;
const inputBuffer = audioProcessingEvent.inputBuffer;
const inputData = inputBuffer.getChannelData(0);
// Float32 -> Int16 PCM 변환
const pcmData = floatTo16BitPCM(inputData);
// Base64 인코딩
const base64Audio = btoa(
new Uint8Array(pcmData).reduce((data, byte) => data + String.fromCharCode(byte), '')
);
// WebSocket으로 전송
if (sttWebSocket && sttWebSocket.readyState === WebSocket.OPEN) {
sttWebSocket.send(JSON.stringify({
type: 'chunk',
meetingId: meetingId,
audioData: base64Audio,
timestamp: Date.now(),
chunkIndex: chunkIndex++,
format: 'audio/pcm',
sampleRate: 16000
}));
}
};
source.connect(scriptNode);
scriptNode.connect(audioContext.destination);
chunkIndex = 0;
isRecording = true;
addLog('✅ 녹음 시작 (PCM 16kHz)', 'info');
}
// AI SSE 연결
function connectAIEventSource() {
const sseUrl = `http://localhost:8086/api/v1/ai/suggestions/meetings/${meetingId}/stream`;
addLog('AI SSE 연결 시도...', 'info');
aiEventSource = new EventSource(sseUrl);
aiEventSource.addEventListener('ai-suggestion', (event) => {
try {
const data = JSON.parse(event.data);
displaySuggestions(data.suggestions);
addLog(`💡 AI 제안사항 수신: ${data.suggestions.length}`, 'info');
} catch (error) {
addLog('❌ SSE 데이터 파싱 실패: ' + error.message, 'error');
}
});
aiEventSource.onopen = () => {
addLog('✅ AI SSE 연결 성공', 'info');
};
aiEventSource.onerror = (error) => {
addLog('❌ AI SSE 연결 오류', 'error');
};
}
// 제안사항 화면 표시
function displaySuggestions(suggestions) {
const suggestionsDiv = document.getElementById('suggestions');
// 첫 제안사항이면 empty state 제거
const emptyState = suggestionsDiv.querySelector('.empty-state');
if (emptyState) {
emptyState.remove();
}
suggestions.forEach(suggestion => {
const item = document.createElement('div');
item.className = 'suggestion-item';
const confidence = Math.round(suggestion.confidence * 100);
item.innerHTML = `
<div class="suggestion-header">
<span class="suggestion-time">${suggestion.timestamp}</span>
<span class="suggestion-confidence">${confidence}%</span>
</div>
<div class="suggestion-content">${suggestion.content}</div>
`;
suggestionsDiv.appendChild(item);
suggestionsDiv.scrollTop = suggestionsDiv.scrollHeight;
});
}
// 회의 종료
function stopSession() {
isRecording = false;
// 마이크 종료
if (audioContext) {
audioContext.close();
audioContext = null;
}
if (micStream) {
micStream.getTracks().forEach(track => track.stop());
micStream = null;
}
// STT WebSocket 종료
if (sttWebSocket) {
sttWebSocket.send(JSON.stringify({
type: 'stop',
meetingId: meetingId
}));
sttWebSocket.close();
sttWebSocket = null;
}
// AI SSE 종료
if (aiEventSource) {
aiEventSource.close();
aiEventSource = null;
}
document.getElementById('startBtn').disabled = false;
document.getElementById('stopBtn').disabled = true;
updateStatus('disconnected', '🔴 회의 종료');
addLog('✅ 회의 종료', 'info');
}
// 상태 업데이트
function updateStatus(statusClass, text) {
const statusDiv = document.getElementById('status');
statusDiv.className = 'status ' + statusClass;
statusDiv.textContent = text;
}
// 로그 추가
function addLog(message, type = 'info') {
const logDiv = document.getElementById('log');
const logItem = document.createElement('div');
logItem.className = 'log-item log-' + type;
const timestamp = new Date().toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
logItem.textContent = `[${timestamp}] ${message}`;
logDiv.appendChild(logItem);
logDiv.scrollTop = logDiv.scrollHeight;
}
// 페이지 종료 시 정리
window.onbeforeunload = () => {
if (isRecording) {
stopSession();
}
};
</script>
</body>
</html>