hgzero/test-audio/stt-test-wav.html
Minseo-Jo 9c2d8dc9b2 AI 제안사항 추출 기능 개선 및 API 경로 수정
- ClaudeService에 analyze_suggestions 메서드 추가
- 개선된 제안사항 추출 프롬프트 생성 (구체적이고 실행 가능한 제안사항)
- API 경로 수정: /api/v1/ai/suggestions → /api/ai/suggestions
- 프론트엔드 HTML API 경로 업데이트 (v1 제거)
- RealtimeSuggestionsResponse 모델 export 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 16:14:32 +09:00

561 lines
19 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 STT 실시간 테스트 (WAV)</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;
}
#transcript {
background: #f7fafc;
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
margin-top: 20px;
font-family: 'Courier New', monospace;
}
.transcript-item {
padding: 10px;
margin: 5px 0;
background: white;
border-radius: 5px;
border-left: 3px solid #667eea;
}
#suggestions {
background: #fffaf0;
border: 2px solid #fbbf24;
border-radius: 8px;
padding: 20px;
min-height: 150px;
max-height: 300px;
overflow-y: auto;
margin-top: 20px;
}
.suggestion-item {
padding: 10px;
margin: 5px 0;
background: white;
border-radius: 5px;
border-left: 3px solid #f59e0b;
}
.suggestion-title {
font-weight: bold;
color: #d97706;
margin-bottom: 5px;
}
.timestamp {
color: #718096;
font-size: 0.85em;
margin-bottom: 5px;
}
.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;
}
</style>
</head>
<body>
<div class="container">
<h1>🎤 HGZero 실시간 STT 테스트 (WAV)</h1>
<p class="subtitle">WebSocket 기반 실시간 음성-텍스트 변환 (PCM WAV 16kHz)</p>
<div class="info-box">
<h3>📋 테스트 정보</h3>
<p><strong>WebSocket URL:</strong> <code>ws://localhost:8084/ws/audio</code></p>
<p><strong>Meeting ID:</strong> <code>test-meeting-001</code></p>
<p><strong>Audio Format:</strong> PCM WAV, 16kHz, Mono, 16-bit</p>
</div>
<div id="status" class="status disconnected">
🔴 연결 끊김
</div>
<div class="controls">
<button id="startBtn" onclick="startRecording()">
🎤 녹음 시작
</button>
<button id="stopBtn" onclick="stopRecording()" disabled>
⏹️ 녹음 중지
</button>
</div>
<div id="transcript">
<p style="color: #a0aec0; text-align: center;">여기에 실시간 STT 결과가 5초마다 표시됩니다...</p>
</div>
<h3 style="margin-top: 30px; color: #667eea;">💡 실시간 AI 제안사항</h3>
<div id="suggestions">
<p style="color: #a0aec0; text-align: center;">AI 제안사항이 여기에 표시됩니다...</p>
</div>
<div class="log" id="log">
<div class="log-item">시스템 로그...</div>
</div>
</div>
<script>
let ws = null;
let audioContext = null;
let processor = null;
let input = null;
let chunkIndex = 0;
let eventSource = null;
const meetingId = 'test-meeting-001';
const sampleRate = 16000;
const aiServiceUrl = 'http://localhost:8086';
// WebSocket 연결
function connectWebSocket() {
const wsUrl = 'ws://localhost:8084/ws/audio';
addLog('WebSocket 연결 시도: ' + wsUrl, 'info');
ws = new WebSocket(wsUrl);
ws.onopen = () => {
addLog('✅ WebSocket 연결 성공', 'info');
updateStatus('connected', '🟢 연결됨');
document.getElementById('startBtn').disabled = false;
};
ws.onmessage = (event) => {
addLog('📩 서버 응답: ' + event.data, 'info');
try {
const data = JSON.parse(event.data);
if (data.status === 'started') {
updateStatus('recording', '🔴 녹음 중... (5초마다 STT 결과 표시)');
} else if (data.status === 'stopped') {
updateStatus('connected', '🟢 연결됨 (녹음 종료)');
} else if (data.transcript) {
displayTranscript(data);
}
} catch (e) {
addLog('서버 응답 파싱 실패: ' + e.message, 'error');
}
};
ws.onerror = (error) => {
addLog('❌ WebSocket 오류', 'error');
};
ws.onclose = () => {
addLog('🔴 WebSocket 연결 종료', 'error');
updateStatus('disconnected', '🔴 연결 끊김');
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = true;
};
}
// WAV 헤더 생성
function createWavHeader(dataLength, sampleRate, numChannels, bitsPerSample) {
const buffer = new ArrayBuffer(44);
const view = new DataView(buffer);
// RIFF identifier
writeString(view, 0, 'RIFF');
// file length
view.setUint32(4, 36 + dataLength, true);
// RIFF type
writeString(view, 8, 'WAVE');
// format chunk identifier
writeString(view, 12, 'fmt ');
// format chunk length
view.setUint32(16, 16, true);
// sample format (PCM)
view.setUint16(20, 1, true);
// channel count
view.setUint16(22, numChannels, true);
// sample rate
view.setUint32(24, sampleRate, true);
// byte rate
view.setUint32(28, sampleRate * numChannels * bitsPerSample / 8, true);
// block align
view.setUint16(32, numChannels * bitsPerSample / 8, true);
// bits per sample
view.setUint16(34, bitsPerSample, true);
// data chunk identifier
writeString(view, 36, 'data');
// data chunk length
view.setUint32(40, dataLength, true);
return buffer;
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
// Float32 to Int16 변환
function floatTo16BitPCM(float32Array) {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]));
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16Array;
}
// 녹음 시작
async function startRecording() {
try {
addLog('🎤 마이크 접근 요청...', 'info');
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: sampleRate,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
addLog('✅ 마이크 접근 허용', 'info');
// Audio Context 생성
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: sampleRate
});
input = audioContext.createMediaStreamSource(stream);
processor = audioContext.createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
// Float32 → Int16 PCM 변환
const pcmData = floatTo16BitPCM(inputData);
// WAV 헤더 + PCM 데이터
const wavHeader = createWavHeader(pcmData.length * 2, sampleRate, 1, 16);
const wavData = new Uint8Array(wavHeader.byteLength + pcmData.length * 2);
wavData.set(new Uint8Array(wavHeader), 0);
wavData.set(new Uint8Array(pcmData.buffer), wavHeader.byteLength);
// Base64로 인코딩하여 전송
const base64Audio = btoa(String.fromCharCode.apply(null, wavData));
const message = JSON.stringify({
type: 'chunk',
meetingId: meetingId,
audioData: base64Audio,
timestamp: Date.now(),
chunkIndex: chunkIndex++,
format: 'audio/wav',
sampleRate: sampleRate
});
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message);
if (chunkIndex % 10 === 0) { // 10초마다 로그
addLog(`📤 청크 전송 중... #${chunkIndex} (${wavData.length} bytes)`, 'info');
}
}
};
input.connect(processor);
processor.connect(audioContext.destination);
// 녹음 시작 메시지 전송
ws.send(JSON.stringify({
type: 'start',
meetingId: meetingId
}));
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
addLog('✅ 녹음 시작 (WAV PCM 16kHz)', 'info');
} catch (error) {
addLog('❌ 마이크 접근 실패: ' + error.message, 'error');
alert('마이크 접근이 거부되었습니다. 브라우저 설정을 확인해주세요.');
}
}
// 녹음 중지
function stopRecording() {
if (processor) {
processor.disconnect();
processor = null;
}
if (input) {
input.disconnect();
input = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
// 녹음 종료 메시지 전송
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'stop',
meetingId: meetingId
}));
}
document.getElementById('startBtn').disabled = false;
document.getElementById('stopBtn').disabled = true;
addLog('✅ 녹음 종료 명령 전송', 'info');
addLog('🛑 녹음 중지', 'info');
}
// STT 결과 표시
function displayTranscript(data) {
const transcriptDiv = document.getElementById('transcript');
// 초기 메시지 제거
if (transcriptDiv.querySelector('p')) {
transcriptDiv.innerHTML = '';
}
const item = document.createElement('div');
item.className = 'transcript-item';
const timestamp = new Date(data.timestamp || Date.now()).toLocaleTimeString('ko-KR');
item.innerHTML = `
<div class="timestamp">${timestamp} - 화자: ${data.speaker || '알 수 없음'}</div>
<div>${data.transcript || data.text || '(텍스트 없음)'}</div>
`;
transcriptDiv.appendChild(item);
transcriptDiv.scrollTop = transcriptDiv.scrollHeight;
addLog('📝 STT 결과 수신', '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;
// 로그 개수 제한 (최대 50개)
while (logDiv.children.length > 50) {
logDiv.removeChild(logDiv.firstChild);
}
}
// AI 제안사항 SSE 연결
function connectAISuggestions() {
const sseUrl = `${aiServiceUrl}/api/ai/suggestions/meetings/${meetingId}/stream`;
addLog('AI 제안사항 SSE 연결 시도: ' + sseUrl, 'info');
eventSource = new EventSource(sseUrl);
eventSource.addEventListener('ai-suggestion', (event) => {
try {
const data = JSON.parse(event.data);
displaySuggestions(data);
addLog('✅ AI 제안사항 수신', 'info');
} catch (e) {
addLog('AI 제안 파싱 실패: ' + e.message, 'error');
}
});
eventSource.onopen = () => {
addLog('✅ AI 제안사항 SSE 연결 성공', 'info');
};
eventSource.onerror = (error) => {
const state = eventSource.readyState;
let stateText = '';
switch(state) {
case EventSource.CONNECTING: stateText = 'CONNECTING'; break;
case EventSource.OPEN: stateText = 'OPEN'; break;
case EventSource.CLOSED: stateText = 'CLOSED'; break;
default: stateText = 'UNKNOWN';
}
addLog(`❌ AI 제안사항 SSE 오류 (State: ${stateText})`, 'error');
// 연결이 닫혔을 때만 재연결 시도
if (state === EventSource.CLOSED) {
eventSource.close();
setTimeout(() => {
addLog('AI SSE 재연결 시도...', 'info');
connectAISuggestions();
}, 5000);
}
};
}
// AI 제안사항 표시
function displaySuggestions(data) {
const suggestionsDiv = document.getElementById('suggestions');
// 초기 메시지 제거
if (suggestionsDiv.querySelector('p')) {
suggestionsDiv.innerHTML = '';
}
// 제안사항 표시
if (data.suggestions && data.suggestions.length > 0) {
data.suggestions.forEach(suggestion => {
const item = document.createElement('div');
item.className = 'suggestion-item';
const timestamp = new Date().toLocaleTimeString('ko-KR');
item.innerHTML = `
<div class="timestamp">${timestamp}</div>
<div class="suggestion-title">💡 ${suggestion.title || '제안사항'}</div>
<div>${suggestion.content || suggestion.description || suggestion}</div>
`;
suggestionsDiv.appendChild(item);
});
suggestionsDiv.scrollTop = suggestionsDiv.scrollHeight;
}
}
// 페이지 로드 시 WebSocket 및 SSE 연결
window.onload = () => {
addLog('🚀 HGZero STT 테스트 페이지 로드 (WAV 버전)', 'info');
connectWebSocket();
connectAISuggestions();
};
// 페이지 종료 시 정리
window.onbeforeunload = () => {
stopRecording();
if (ws) {
ws.close();
}
if (eventSource) {
eventSource.close();
}
};
</script>
</body>
</html>