mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-13 03:39:10 +00:00
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>
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,560 @@
|
||||
<!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/v1/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>
|
||||
@@ -0,0 +1,405 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HGZero STT 실시간 테스트</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;
|
||||
}
|
||||
.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 테스트</h1>
|
||||
<p class="subtitle">WebSocket 기반 실시간 음성-텍스트 변환</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>📋 테스트 정보</h3>
|
||||
<p><strong>WebSocket URL:</strong> <code id="wsUrl">ws://localhost:8084/ws/audio</code></p>
|
||||
<p><strong>Meeting ID:</strong> <code id="meetingId">test-meeting-001</code></p>
|
||||
<p><strong>Sample Rate:</strong> 16000 Hz</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 결과가 표시됩니다...</p>
|
||||
</div>
|
||||
|
||||
<div class="log" id="log">
|
||||
<div class="log-item">시스템 로그...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
let audioContext = null;
|
||||
let audioWorkletNode = null;
|
||||
let micStream = null;
|
||||
let chunkIndex = 0;
|
||||
let isRecording = false;
|
||||
const meetingId = 'test-meeting-001';
|
||||
|
||||
// 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');
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'started') {
|
||||
updateStatus('recording', '🔴 녹음 중...');
|
||||
} else if (data.status === 'stopped') {
|
||||
updateStatus('connected', '🟢 연결됨 (녹음 종료)');
|
||||
} else if (data.transcript) {
|
||||
displayTranscript(data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
addLog('❌ WebSocket 오류: ' + error, 'error');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
addLog('🔴 WebSocket 연결 종료', 'error');
|
||||
updateStatus('disconnected', '🔴 연결 끊김');
|
||||
document.getElementById('startBtn').disabled = true;
|
||||
document.getElementById('stopBtn').disabled = true;
|
||||
};
|
||||
}
|
||||
|
||||
// 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 startRecording() {
|
||||
try {
|
||||
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);
|
||||
|
||||
// ScriptProcessorNode로 실시간 PCM 추출 (2048 샘플 = 약 128ms)
|
||||
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 (ws.readyState === WebSocket.OPEN) {
|
||||
const message = JSON.stringify({
|
||||
type: 'chunk',
|
||||
meetingId: meetingId,
|
||||
audioData: base64Audio,
|
||||
timestamp: Date.now(),
|
||||
chunkIndex: chunkIndex++,
|
||||
format: 'audio/pcm',
|
||||
sampleRate: 16000
|
||||
});
|
||||
|
||||
ws.send(message);
|
||||
|
||||
if (chunkIndex % 10 === 0) {
|
||||
addLog(`📤 청크 전송 #${chunkIndex} (${pcmData.byteLength} bytes)`, 'info');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(scriptNode);
|
||||
scriptNode.connect(audioContext.destination);
|
||||
|
||||
chunkIndex = 0;
|
||||
isRecording = true;
|
||||
|
||||
// 녹음 시작 메시지 전송
|
||||
ws.send(JSON.stringify({
|
||||
type: 'start',
|
||||
meetingId: meetingId
|
||||
}));
|
||||
|
||||
document.getElementById('startBtn').disabled = true;
|
||||
document.getElementById('stopBtn').disabled = false;
|
||||
|
||||
addLog('✅ 녹음 시작 (PCM 16kHz, 16bit, Mono)', 'info');
|
||||
|
||||
} catch (error) {
|
||||
addLog('❌ 마이크 접근 실패: ' + error.message, 'error');
|
||||
alert('마이크 접근이 거부되었습니다. 브라우저 설정을 확인해주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// 녹음 중지
|
||||
function stopRecording() {
|
||||
isRecording = false;
|
||||
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
|
||||
if (micStream) {
|
||||
micStream.getTracks().forEach(track => track.stop());
|
||||
micStream = 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');
|
||||
}
|
||||
|
||||
// STT 결과 표시
|
||||
function displayTranscript(data) {
|
||||
const transcriptDiv = document.getElementById('transcript');
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'transcript-item';
|
||||
|
||||
const timestamp = new Date(data.timestamp).toLocaleTimeString('ko-KR');
|
||||
item.innerHTML = `
|
||||
<div class="timestamp">${timestamp} - 화자: ${data.speaker || '알 수 없음'}</div>
|
||||
<div>${data.transcript}</div>
|
||||
`;
|
||||
|
||||
transcriptDiv.appendChild(item);
|
||||
transcriptDiv.scrollTop = transcriptDiv.scrollHeight;
|
||||
}
|
||||
|
||||
// 상태 업데이트
|
||||
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;
|
||||
}
|
||||
|
||||
// 페이지 로드 시 WebSocket 연결
|
||||
window.onload = () => {
|
||||
addLog('🚀 HGZero STT 테스트 페이지 로드', 'info');
|
||||
connectWebSocket();
|
||||
};
|
||||
|
||||
// 페이지 종료 시 정리
|
||||
window.onbeforeunload = () => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
stopRecording();
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user