Files
sam-kd/voice_ai/index.php
hskwon aca1767eb9 초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
2025-12-10 20:14:31 +09:00

1436 lines
46 KiB
PHP

<?php
// 출력 버퍼링 시작 (경고 메시지 방지)
ob_start();
@error_reporting(0);
@ini_set('display_errors', 0);
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
@require_once($_SERVER['DOCUMENT_ROOT'] . "/load_header.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 권한 체크
if ($level > 5) {
echo "<script>alert('접근 권한이 없습니다.'); history.back();</script>";
exit;
}
// 회의록 리스트 조회 (최근 10개)
// MVP 단계: 모든 사용자가 모든 데이터를 볼 수 있도록 tenant_id 조건 제거
$meeting_list = [];
try {
$pdo = db_connect();
$sql = "SELECT id, title, created_at, summary_text, audio_file_path
FROM meeting_logs
ORDER BY created_at DESC
LIMIT 10";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$meeting_list = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
// DB 조회 실패 시 빈 배열
$meeting_list = [];
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 스마트 회의록 (SAM Project)</title>
<style>
.voice-container {
max-width: 900px;
margin: 40px auto;
padding: 30px;
}
.header-section {
text-align: center;
margin-bottom: 40px;
}
.header-section h3 {
margin-bottom: 10px;
color: #333;
}
.header-section p {
color: #6c757d;
font-size: 14px;
}
.recording-section {
background: #fff;
border-radius: 12px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.record-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.record-button {
width: 120px;
height: 120px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 24px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
position: relative;
overflow: hidden;
}
.record-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.record-button.recording {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4);
}
50% {
box-shadow: 0 4px 30px rgba(245, 87, 108, 0.8);
}
}
.timer {
font-size: 32px;
font-weight: bold;
color: #333;
font-family: 'Courier New', monospace;
min-height: 40px;
}
.timer.active {
color: #f5576c;
}
.status-indicator {
display: inline-block;
padding: 8px 20px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
.status-waiting {
background: #e9ecef;
color: #495057;
}
.status-recording {
background: #f8d7da;
color: #842029;
}
.status-processing {
background: #cfe2ff;
color: #084298;
}
.status-completed {
background: #d1e7dd;
color: #0f5132;
}
.status-error {
background: #f8d7da;
color: #842029;
}
.waveform-container {
width: 100%;
height: 100px;
background: #f8f9fa;
border-radius: 8px;
margin: 20px 0;
position: relative;
overflow: hidden;
}
.waveform-canvas {
width: 100%;
height: 100%;
}
.transcript-section {
background: #fff;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
min-height: 200px;
}
.transcript-section h5 {
margin-bottom: 15px;
color: #333;
}
.transcript-text {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
min-height: 150px;
font-size: 16px;
line-height: 1.8;
color: #333;
white-space: pre-wrap;
word-wrap: break-word;
}
.transcript-text.empty {
color: #999;
font-style: italic;
}
.action-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.btn-primary {
background: #0d6efd;
color: white;
}
.btn-primary:hover {
background: #0b5ed7;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5c636a;
}
.btn-success {
background: #198754;
color: white;
}
.btn-success:hover {
background: #157347;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #0d6efd;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.info-box p {
margin: 5px 0;
color: #084298;
font-size: 13px;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.processing-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
color: white;
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1000;
}
.spinner-border {
width: 3rem;
height: 3rem;
border: 0.25em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
/* 회의록 테이블 스타일 */
.meeting-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.meeting-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
border-bottom: 2px solid #dee2e6;
font-weight: bold;
color: #495057;
}
.meeting-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.meeting-table tr:hover {
background: #f8f9fa;
}
.summary-preview {
max-width: 300px;
color: #6c757d;
font-size: 13px;
}
.btn-small {
padding: 8px;
font-size: 16px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-right: 5px;
background: #6c757d;
color: white;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
position: relative;
}
.btn-small:hover {
background: #5c636a;
}
.btn-small i {
margin: 0;
}
/* 툴팁 스타일 */
.btn-small[title] {
position: relative;
}
.btn-small[title]:hover::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
padding: 6px 10px;
background: #333;
color: white;
font-size: 12px;
white-space: nowrap;
border-radius: 4px;
z-index: 1000;
pointer-events: none;
opacity: 0;
animation: tooltipFadeIn 0.2s ease-in-out forwards;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.btn-small[title]:hover::before {
content: '';
position: absolute;
bottom: calc(100% + 3px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #333;
z-index: 1001;
pointer-events: none;
opacity: 0;
animation: tooltipFadeIn 0.2s ease-in-out forwards;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-5px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* 모달 스타일 */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 30px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.close-btn {
position: absolute;
top: 15px;
right: 20px;
font-size: 28px;
font-weight: bold;
color: #999;
cursor: pointer;
}
.close-btn:hover {
color: #333;
}
.modal-body {
margin-top: 20px;
}
.detail-section {
margin-bottom: 25px;
}
.detail-section h6 {
margin-bottom: 10px;
color: #495057;
font-size: 14px;
}
.detail-text {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
<script>
// 즉시 실행: 전자결재 다이얼로그 강제 닫기
(function() {
function closeEworksDialogs() {
// 모든 전자결재 관련 모달 닫기
const modals = ['eworks_viewmodal', 'eworks_form'];
modals.forEach(function(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'none';
modal.classList.remove('show');
modal.setAttribute('aria-hidden', 'true');
}
});
// 모든 모달 배경 제거
const backdrops = document.querySelectorAll('.modal-backdrop');
backdrops.forEach(function(backdrop) {
backdrop.remove();
});
// body 클래스 정리
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';
}
// 즉시 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', closeEworksDialogs);
} else {
closeEworksDialogs();
}
// 지속적으로 확인 (다이얼로그가 나중에 열리는 경우 대비)
setInterval(closeEworksDialogs, 500);
})();
</script>
</head>
<body>
<?php include $_SERVER['DOCUMENT_ROOT'] . "/myheader.php"; ?>
<div class="processing-overlay" id="voiceloadingOverlay" style="display: none;">
<div class="spinner-border text-light" role="status"></div>
<h4 style="margin-top: 20px;">분석 후 요약작업 수행중... 잠시만 기다려 주세요</h4>
<p>오디오 업로드 → 텍스트 변환 → AI 요약 진행 중입니다.</p>
</div>
<div class="voice-container">
<div class="header-section">
<h3><i class="bi bi-mic-fill"></i> AI 스마트 회의록 작성</h3>
<p>음성을 녹음하고 AI를 통해 자동으로 회의록을 생성합니다</p>
</div>
<div class="info-box">
<p><i class="bi bi-info-circle"></i> Chrome 브라우저에서 마이크 권한을 허용해주세요</p>
<p><i class="bi bi-clock"></i> 실시간 음성 인식 - 말하는 즉시 텍스트로 변환됩니다</p>
<p><i class="bi bi-stars"></i> 녹음이 끝나면 AI가 자동으로 회의록을 작성합니다</p>
</div>
<div class="recording-section">
<div class="record-controls">
<button id="record-button" class="record-button">
<i class="bi bi-mic-fill"></i>
</button>
<div id="status" class="status-indicator status-waiting">대기중</div>
<div id="timer" class="timer">00:00</div>
<div class="waveform-container">
<canvas id="waveform" class="waveform-canvas"></canvas>
</div>
</div>
</div>
<div class="transcript-section">
<h5><i class="bi bi-eye"></i> 실시간 인식 미리보기</h5>
<div id="preview-text" class="transcript-text empty">
녹음 버튼을 클릭하여 음성을 녹음하세요
</div>
<div class="action-buttons">
<button id="save-analyze-btn" class="btn btn-primary" disabled>
<i class="bi bi-cloud-upload"></i> 회의 종료 및 AI 분석 시작
</button>
<button id="test-sample-btn" class="btn btn-secondary" style="margin-left: 10px;" title="테스트용 샘플 오디오 생성 (개발/테스트용)">
<i class="bi bi-file-earmark-music"></i> 샘플 테스트
</button>
</div>
</div>
<!-- 회의록 리스트 섹션 -->
<div class="transcript-section">
<h5><i class="bi bi-list-ul"></i> 최근 회의록 (최근 10개)</h5>
<div id="meeting-list">
<?php if (empty($meeting_list)): ?>
<p style="text-align: center; color: #999; padding: 30px;">
<i class="bi bi-inbox"></i><br>
저장된 회의록이 없습니다
</p>
<?php else: ?>
<table class="meeting-table">
<thead>
<tr>
<th>번호</th>
<th>제목</th>
<th>작성일</th>
<th>요약 미리보기</th>
<th>동작</th>
</tr>
</thead>
<tbody>
<?php foreach ($meeting_list as $index => $meeting): ?>
<tr>
<td><?= $index + 1 ?></td>
<td><?= htmlspecialchars($meeting['title']) ?></td>
<td><?= date('m-d H:i', strtotime($meeting['created_at'])) ?></td>
<td class="summary-preview">
<?= htmlspecialchars(mb_substr($meeting['summary_text'], 0, 50)) ?>...
</td>
<td>
<button class="btn-small" onclick="viewDetail(<?= $meeting['id'] ?>)" title="회의록 상세보기">
<i class="bi bi-eye"></i>
</button>
<?php if (!empty($meeting['audio_file_path'])): ?>
<a href="download_audio.php?id=<?= $meeting['id'] ?>"
class="btn-small"
download="meeting_<?= $meeting['id'] ?>.webm"
title="오디오 파일 다운로드">
<i class="bi bi-download"></i>
</a>
<?php else: ?>
<span class="btn-small" style="opacity: 0.5; cursor: not-allowed;" title="오디오 파일이 없습니다">
<i class="bi bi-download"></i>
</span>
<?php endif; ?>
<button class="btn-small" onclick="confirmDelete(<?= $meeting['id'] ?>, '<?= htmlspecialchars(addslashes($meeting['title'] ?: '회의녹음')) ?>')"
style="background-color: #dc3545; color: white;"
title="회의록 삭제">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
<!-- 상세보기 모달 -->
<div id="detail-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close-btn" onclick="closeModal()">&times;</span>
<h4 id="modal-title">회의록 상세</h4>
<div class="modal-body">
<div class="detail-section">
<h6><i class="bi bi-file-text"></i> 전체 텍스트</h6>
<div id="modal-transcript" class="detail-text"></div>
</div>
<div class="detail-section">
<h6><i class="bi bi-stars"></i> AI 요약</h6>
<div id="modal-summary" class="detail-text"></div>
</div>
</div>
</div>
</div>
<!-- 삭제 확인 모달 -->
<div id="delete-modal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 400px;">
<span class="close-btn" onclick="closeDeleteModal()">&times;</span>
<h4>회의록 삭제 확인</h4>
<div class="modal-body">
<p>정말로 이 회의록을 삭제하시겠습니까?</p>
<p style="font-weight: bold; color: #dc3545;" id="delete-meeting-title"></p>
<p style="font-size: 12px; color: #6c757d;">서버 파일과 Google Cloud Storage의 파일도 함께 삭제됩니다.</p>
</div>
<div style="text-align: right; margin-top: 20px;">
<button class="btn-small" onclick="closeDeleteModal()" style="margin-right: 10px; width: auto; padding: 8px 20px;">취소</button>
<button class="btn-small" onclick="deleteMeeting()" style="background-color: #dc3545; color: white; width: auto; padding: 8px 20px;">삭제</button>
</div>
</div>
</div>
<script>
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
let startTime;
let timerInterval;
// Web Speech API (미리보기용)
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition = null;
let isRecognitionActive = false;
// 음성 인식 텍스트 변수 (중복 방지)
let finalTranscript = '';
let interimTranscript = '';
// 시각화 변수
let audioContext = null;
let analyser = null;
let dataArray = null;
let animationId = null;
let mediaStream = null;
let gainNode = null; // 오디오 증폭용
let audioSource = null; // 오디오 소스
// DOM 요소
const recordBtn = document.getElementById('record-button');
const saveBtn = document.getElementById('save-analyze-btn');
const previewText = document.getElementById('preview-text');
const statusEl = document.getElementById('status');
const timerEl = document.getElementById('timer');
const waveformCanvas = document.getElementById('waveform');
const canvasCtx = waveformCanvas.getContext('2d');
// Canvas 크기 초기화
function resizeCanvas() {
waveformCanvas.width = waveformCanvas.offsetWidth;
waveformCanvas.height = waveformCanvas.offsetHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Web Speech API 초기화
function initSpeechRecognition() {
if (!SpeechRecognition) {
alert('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome 브라우저를 사용해주세요.');
return false;
}
recognition = new SpeechRecognition();
recognition.lang = 'ko-KR';
recognition.continuous = true;
recognition.interimResults = true;
// 민감도 향상을 위한 추가 설정
recognition.maxAlternatives = 3; // 더 많은 대안 결과 허용
return true;
}
// Waveform 시각화
function drawWaveform() {
if (!analyser || !dataArray) return;
animationId = requestAnimationFrame(drawWaveform);
analyser.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = '#f8f9fa';
canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = '#667eea';
canvasCtx.beginPath();
const sliceWidth = waveformCanvas.width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0;
const y = v * waveformCanvas.height / 2;
if (i === 0) {
canvasCtx.moveTo(x, y);
} else {
canvasCtx.lineTo(x, y);
}
x += sliceWidth;
}
canvasCtx.lineTo(waveformCanvas.width, waveformCanvas.height / 2);
canvasCtx.stroke();
}
function stopWaveform() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
// Clear canvas
canvasCtx.fillStyle = '#f8f9fa';
canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
}
// 오디오 스트림 시작 (시각화용 및 증폭)
async function startAudioStream() {
try {
// 마이크 제약 조건 최적화 (민감도 향상)
const audioConstraints = {
audio: {
echoCancellation: false, // 에코 캔슬 비활성화 (작은 소리 감지 향상)
noiseSuppression: false, // 노이즈 억제 비활성화 (작은 소리 감지 향상)
autoGainControl: true, // 자동 게인 제어 활성화
sampleRate: 48000, // 높은 샘플레이트
channelCount: 1 // 모노 채널
}
};
mediaStream = await navigator.mediaDevices.getUserMedia(audioConstraints);
// Setup Audio Context for Visualization and Amplification
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// 오디오 소스 생성
audioSource = audioContext.createMediaStreamSource(mediaStream);
// GainNode 생성 및 증폭 설정 (3배 증폭 - 작은 소리도 잘 들리도록)
gainNode = audioContext.createGain();
gainNode.gain.value = 3.0; // 3배 증폭 (최대 10까지 가능하지만 3이 적절)
// 오디오 체인: Source -> Gain -> Analyser
audioSource.connect(gainNode);
analyser = audioContext.createAnalyser();
gainNode.connect(analyser);
analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
// Start Waveform Visualization
drawWaveform();
return mediaStream;
} catch (error) {
console.error('마이크 접근 오류:', error);
alert('마이크 권한을 허용해주세요');
return null;
}
}
// 오디오 스트림 중지
function stopAudioStream() {
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
// 오디오 노드 연결 해제
if (audioSource) {
try {
audioSource.disconnect();
} catch (e) {
console.log('Audio source disconnect:', e);
}
audioSource = null;
}
if (gainNode) {
try {
gainNode.disconnect();
} catch (e) {
console.log('Gain node disconnect:', e);
}
gainNode = null;
}
if (analyser) {
try {
analyser.disconnect();
} catch (e) {
console.log('Analyser disconnect:', e);
}
analyser = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
}
// 녹음 시작/종료 처리
recordBtn.addEventListener('click', async () => {
if (!isRecording) {
// Speech Recognition 초기화
if (!recognition) {
if (!initSpeechRecognition()) {
return;
}
}
// 녹음 시작
const stream = await startAudioStream();
if (!stream) return;
// 변수 초기화
audioChunks = [];
finalTranscript = '';
interimTranscript = '';
// 1. MediaRecorder 시작 (파일 저장용)
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.start();
// 2. Web Speech API 시작 (화면 표시용)
if (!isRecognitionActive) {
try {
recognition.start();
isRecognitionActive = true;
} catch (e) {
console.error('음성 인식 시작 실패:', e);
}
}
isRecording = true;
recordBtn.classList.add('recording');
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
updateStatus('녹음 중...', 'recording');
startTimer();
saveBtn.disabled = true;
previewText.classList.remove('empty');
} else {
// 녹음 종료
mediaRecorder.stop();
if (isRecognitionActive) {
try {
recognition.stop();
isRecognitionActive = false;
} catch (e) {
console.error('음성 인식 종료 실패:', e);
}
}
isRecording = false;
recordBtn.classList.remove('recording');
recordBtn.innerHTML = '<i class="bi bi-mic-fill"></i>';
updateStatus('녹음 완료', 'completed');
stopTimer();
stopWaveform();
stopAudioStream();
// 최종 텍스트 정리 (임시 텍스트 제거)
if (finalTranscript.trim()) {
previewText.textContent = finalTranscript.trim();
saveBtn.disabled = false;
} else {
previewText.innerHTML = '<span style="color:#999;font-style:italic;">인식된 텍스트가 없습니다</span>';
saveBtn.disabled = true;
}
}
});
// Web Speech API 이벤트 핸들러 설정
function setupRecognitionHandlers() {
if (!recognition) return;
// 음성 인식 결과 처리
recognition.onresult = (event) => {
// 임시 텍스트 초기화 (중복 방지를 위해 매번 초기화)
interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
// 확정된 텍스트는 finalTranscript에 추가
finalTranscript += transcript + ' ';
} else {
// 임시 텍스트는 interimTranscript에 저장 (누적하지 않음)
interimTranscript += transcript;
}
}
// 텍스트 업데이트 (확정된 텍스트 + 현재 임시 텍스트만 표시)
const displayText = finalTranscript + (interimTranscript ? '<span style="color:#999">' + interimTranscript + '</span>' : '');
previewText.innerHTML = displayText || '음성을 인식하고 있습니다...';
previewText.classList.remove('empty');
previewText.scrollTop = previewText.scrollHeight;
// 버튼 활성화 (확정된 텍스트가 있을 때만)
if (finalTranscript.trim()) {
saveBtn.disabled = false;
}
};
// 음성 인식 시작
recognition.onstart = () => {
isRecognitionActive = true;
updateStatus('음성 인식 중', 'recording');
};
// 음성 인식 종료
recognition.onend = () => {
isRecognitionActive = false;
if (isRecording) {
// 자동 재시작 (연속 녹음 모드)
try {
recognition.start();
} catch (e) {
console.log('Recognition restart failed:', e);
}
} else {
updateStatus('변환 완료', 'completed');
}
};
// 에러 처리
recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
if (event.error === 'no-speech') {
// 음성이 감지되지 않음 - 자동 재시작 (작은 소리도 감지하도록 지속 시도)
if (isRecording) {
setTimeout(() => {
try {
if (isRecording && !isRecognitionActive) {
recognition.start();
}
} catch (e) {
console.log('Recognition restart after no-speech:', e);
}
}, 500);
}
return;
}
if (event.error === 'aborted') {
// 사용자가 중단함
return;
}
// 'network' 오류는 일시적이므로 재시도
if (event.error === 'network') {
if (isRecording) {
setTimeout(() => {
try {
if (isRecording && !isRecognitionActive) {
recognition.start();
}
} catch (e) {
console.log('Recognition restart after network error:', e);
}
}, 1000);
}
return;
}
updateStatus('오류 발생: ' + event.error, 'error');
if (event.error === 'not-allowed') {
alert('마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크 권한을 허용해주세요.');
}
};
}
// Update Status
function updateStatus(message, statusClass) {
statusEl.textContent = message;
statusEl.className = 'status-indicator status-' + statusClass;
}
// Timer Functions
function startTimer() {
startTime = Date.now();
timerEl.classList.add('active');
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
timerEl.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}, 1000);
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
timerEl.classList.remove('active');
}
// 테스트용 샘플 오디오 생성 (개발/테스트용)
const testSampleBtn = document.getElementById('test-sample-btn');
if (testSampleBtn) {
testSampleBtn.addEventListener('click', async () => {
if (!confirm('테스트용 샘플 오디오를 생성하시겠습니까?\n(실제 녹음 없이 테스트용 더미 데이터를 생성합니다)')) {
return;
}
try {
// Web Audio API를 사용하여 짧은 오디오 생성
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const sampleRate = audioContext.sampleRate;
const duration = 2; // 2초
const numSamples = sampleRate * duration;
const buffer = audioContext.createBuffer(1, numSamples, sampleRate);
const data = buffer.getChannelData(0);
// 간단한 사인파 생성 (테스트용)
for (let i = 0; i < numSamples; i++) {
data[i] = Math.sin(2 * Math.PI * 440 * i / sampleRate) * 0.1; // 440Hz, 작은 볼륨
}
// WAV로 변환
const wav = audioBufferToWav(buffer);
const blob = new Blob([wav], { type: 'audio/wav' });
// audioChunks에 추가
audioChunks = [blob];
finalTranscript = '안녕하세요. 미래기업입니다. 회의를 시작합니다.';
previewText.textContent = finalTranscript;
saveBtn.disabled = false;
updateStatus('샘플 데이터 준비 완료', 'completed');
alert('샘플 오디오가 생성되었습니다. "회의 종료 및 AI 분석 시작" 버튼을 클릭하세요.');
} catch (e) {
console.error('샘플 오디오 생성 실패:', e);
alert('샘플 오디오 생성에 실패했습니다: ' + e.message);
}
});
}
// AudioBuffer를 WAV로 변환하는 함수
function audioBufferToWav(buffer) {
const length = buffer.length;
const arrayBuffer = new ArrayBuffer(44 + length * 2);
const view = new DataView(arrayBuffer);
const channels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
// WAV 헤더 작성
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + length * 2, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, length * 2, true);
// 데이터 작성
const channelData = buffer.getChannelData(0);
let offset = 44;
for (let i = 0; i < length; i++) {
const sample = Math.max(-1, Math.min(1, channelData[i]));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
return arrayBuffer;
}
// 저장 및 분석 버튼 클릭
saveBtn.addEventListener('click', async () => {
// 버튼 비활성화 및 로딩 표시
saveBtn.disabled = true;
const originalBtnText = saveBtn.innerHTML;
saveBtn.innerHTML = '<span class="loading-spinner"></span> 처리 중...';
document.getElementById('voiceloadingOverlay').style.display = 'flex';
updateStatus('처리 중...', 'processing');
// Blob 생성
if (!audioChunks || audioChunks.length === 0) {
alert('녹음된 오디오 데이터가 없습니다. 녹음을 시작하고 일정 시간 이상 녹음해주세요.');
document.getElementById('voiceloadingOverlay').style.display = 'none';
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
updateStatus('오류', 'error');
return;
}
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
// 오디오 파일 크기 확인
if (audioBlob.size < 1000) { // 1KB 미만이면 너무 작음
alert('녹음된 오디오가 너무 작습니다. (' + audioBlob.size + ' bytes) 녹음을 다시 시도해주세요.');
document.getElementById('voiceloadingOverlay').style.display = 'none';
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
updateStatus('오류', 'error');
return;
}
const formData = new FormData();
formData.append('audio_file', audioBlob, 'meeting_record.webm');
// 확정된 텍스트 전송 (참고용)
if (finalTranscript && finalTranscript.trim()) {
formData.append('preview_text', finalTranscript.trim());
}
try {
const response = await fetch('process_meeting.php', {
method: 'POST',
body: formData
});
const text = await response.text();
// JSON 파싱 전에 텍스트 확인
let result;
try {
result = JSON.parse(text);
} catch (parseError) {
console.error('JSON 파싱 오류:', parseError);
console.error('응답 텍스트:', text);
alert('서버 응답 오류가 발생했습니다: ' + text.substring(0, 100));
throw parseError;
}
if (result.ok) {
// 성공 시 콘솔에 상세 정보 출력
console.log('=== 회의록 저장 성공 ===');
console.log('저장 ID:', result.id);
console.log('제목:', result.title);
console.log('파일 경로:', result.db_info?.file_path || 'N/A');
console.log('만료일:', result.db_info?.expiry_date || 'N/A');
console.log('전문 길이:', result.transcript?.length || 0, '자');
console.log('요약 길이:', result.summary?.length || 0, '자');
console.log('전문:', result.transcript);
console.log('요약:', result.summary);
console.log('========================');
alert('회의록이 성공적으로 생성되었습니다.\n저장 ID: ' + result.id + '\n제목: ' + result.title);
updateStatus('완료', 'completed');
// 페이지 리로드하여 리스트 갱신
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
let errorMsg = '오류 발생: ' + result.error;
if (result.details) {
errorMsg += '\n상세: ' + result.details;
}
if (result.curl_error) {
errorMsg += '\nCURL 오류: ' + result.curl_error;
}
console.error('서버 오류 응답:', result);
alert(errorMsg);
updateStatus('오류', 'error');
}
} catch (e) {
console.error('서버 통신 오류:', e);
// JSON 파싱 오류인 경우 더 자세한 정보 표시
if (e.message && e.message.includes('JSON')) {
console.error('JSON 파싱 실패 - 서버 응답에 PHP 경고가 포함되어 있을 수 있습니다.');
alert('서버 응답 오류가 발생했습니다. 페이지를 새로고침해주세요.');
} else {
alert('서버 통신 오류: ' + (e.message || '알 수 없는 오류'));
}
updateStatus('오류', 'error');
} finally {
document.getElementById('voiceloadingOverlay').style.display = 'none';
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
}
});
// 초기화: Recognition 핸들러 설정
if (SpeechRecognition) {
initSpeechRecognition();
setupRecognitionHandlers();
}
// 페이지 로드 시 로딩 오버레이 숨기기 및 전자결재 다이얼로그 닫기
document.addEventListener('DOMContentLoaded', function() {
const voiceloadingOverlay = document.getElementById('voiceloadingOverlay');
if (voiceloadingOverlay) {
voiceloadingOverlay.style.display = 'none';
}
// 전자결재 다이얼로그 자동 닫기
const eworksModal = document.getElementById('eworks_viewmodal');
if (eworksModal) {
eworksModal.style.display = 'none';
eworksModal.classList.remove('show');
}
const eworksDialog = document.querySelector('#eworks_viewmodal .modal-dialog');
if (eworksDialog) {
eworksDialog.style.display = 'none';
}
// Bootstrap 모달 닫기
if (typeof jQuery !== 'undefined' && jQuery('#eworks_viewmodal').length) {
jQuery('#eworks_viewmodal').modal('hide');
}
});
// 즉시 숨기기 (백업)
setTimeout(function() {
const voiceloadingOverlay = document.getElementById('voiceloadingOverlay');
if (voiceloadingOverlay) {
voiceloadingOverlay.style.display = 'none';
}
// 전자결재 다이얼로그 강제 닫기
const eworksModal = document.getElementById('eworks_viewmodal');
if (eworksModal) {
eworksModal.style.display = 'none';
eworksModal.classList.remove('show');
eworksModal.setAttribute('aria-hidden', 'true');
}
// 모든 모달 배경 제거
const modalBackdrops = document.querySelectorAll('.modal-backdrop');
modalBackdrops.forEach(function(backdrop) {
backdrop.remove();
});
// body 클래스 정리
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';
}, 100);
// 회의록 상세보기
async function viewDetail(meetingId) {
try {
const response = await fetch('get_meeting.php?id=' + meetingId);
const text = await response.text();
// JSON 파싱 전에 텍스트 확인
let result;
try {
result = JSON.parse(text);
} catch (parseError) {
console.error('JSON 파싱 오류:', parseError);
console.error('응답 텍스트:', text);
alert('서버 응답 오류가 발생했습니다. 콘솔을 확인해주세요.');
return;
}
if (result.ok) {
document.getElementById('modal-title').textContent = result.data.title;
document.getElementById('modal-transcript').textContent = result.data.transcript_text || '텍스트 없음';
document.getElementById('modal-summary').textContent = result.data.summary_text || '요약 없음';
document.getElementById('detail-modal').style.display = 'flex';
} else {
alert('데이터를 불러올 수 없습니다: ' + result.error);
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다');
}
}
// 모달 닫기
function closeModal() {
document.getElementById('detail-modal').style.display = 'none';
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
}
});
// 모달 배경 클릭 시 닫기
document.getElementById('detail-modal')?.addEventListener('click', function(event) {
if (event.target === this) {
closeModal();
}
});
// 삭제 확인 모달 관련 변수
let deleteMeetingId = null;
// 삭제 확인 모달 열기
function confirmDelete(meetingId, meetingTitle) {
deleteMeetingId = meetingId;
document.getElementById('delete-meeting-title').textContent = meetingTitle;
document.getElementById('delete-modal').style.display = 'flex';
}
// 삭제 확인 모달 닫기
function closeDeleteModal() {
document.getElementById('delete-modal').style.display = 'none';
deleteMeetingId = null;
}
// 회의록 삭제 실행
async function deleteMeeting() {
if (!deleteMeetingId) {
alert('삭제할 회의록이 선택되지 않았습니다.');
return;
}
try {
const response = await fetch('delete_meeting.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'id=' + deleteMeetingId
});
const text = await response.text();
let result;
try {
result = JSON.parse(text);
} catch (parseError) {
console.error('JSON 파싱 오류:', parseError);
console.error('응답 텍스트:', text);
alert('서버 응답 오류가 발생했습니다.');
return;
}
if (result.ok) {
alert('회의록이 삭제되었습니다.');
closeDeleteModal();
// 페이지 새로고침하여 목록 업데이트
location.reload();
} else {
alert('삭제 실패: ' + result.error);
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다');
}
}
// ESC 키로 삭제 모달도 닫기
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
closeDeleteModal();
}
});
// 삭제 모달 배경 클릭 시 닫기
document.getElementById('delete-modal')?.addEventListener('click', function(event) {
if (event.target === this) {
closeDeleteModal();
}
});
</script>
<?php
// 출력 버퍼 flush
ob_end_flush();
?>
</body>
</html>