2025-12-10 20:14:31 +09:00
|
|
|
|
<?php
|
|
|
|
|
|
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
|
|
|
|
|
require_once($_SERVER['DOCUMENT_ROOT'] . "/load_header.php");
|
|
|
|
|
|
|
|
|
|
|
|
// 권한 체크
|
|
|
|
|
|
if ($level > 5) {
|
|
|
|
|
|
echo "<script>alert('접근 권한이 없습니다.'); history.back();</script>";
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
?>
|
|
|
|
|
|
|
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="ko">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
2025-12-16 13:36:34 +09:00
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
2025-12-10 20:14:31 +09:00
|
|
|
|
<title>음성 녹음 및 텍스트 변환</title>
|
|
|
|
|
|
<style>
|
2025-12-16 13:36:34 +09:00
|
|
|
|
* {
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
|
-webkit-font-smoothing: antialiased;
|
|
|
|
|
|
-moz-osx-font-smoothing: grayscale;
|
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
.voice-container {
|
|
|
|
|
|
max-width: 900px;
|
|
|
|
|
|
margin: 40px auto;
|
|
|
|
|
|
padding: 30px;
|
2025-12-16 13:36:34 +09:00
|
|
|
|
width: 100%;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.audio-player {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin: 20px 0;
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
2025-12-16 13:36:34 +09:00
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 6px;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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); }
|
|
|
|
|
|
}
|
2025-12-16 13:36:34 +09:00
|
|
|
|
|
|
|
|
|
|
/* 모바일 햄버거 메뉴 스타일 */
|
|
|
|
|
|
.mobile-menu-toggle {
|
|
|
|
|
|
display: none; /* 기본적으로 숨김, 모바일에서만 표시 */
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
top: 15px;
|
|
|
|
|
|
right: 15px;
|
|
|
|
|
|
z-index: 1001;
|
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
width: 48px;
|
|
|
|
|
|
height: 48px;
|
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-toggle:hover {
|
|
|
|
|
|
transform: scale(1.05);
|
|
|
|
|
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-toggle:active {
|
|
|
|
|
|
transform: scale(0.95);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-toggle i {
|
|
|
|
|
|
transition: transform 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-overlay {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.5);
|
|
|
|
|
|
z-index: 999;
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transition: opacity 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-overlay.active {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-sidebar {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
right: -100%;
|
|
|
|
|
|
width: 280px;
|
|
|
|
|
|
max-width: 85%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background: white;
|
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
transition: right 0.3s ease;
|
|
|
|
|
|
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-sidebar.active {
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-header {
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-header h4 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-close {
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content {
|
|
|
|
|
|
padding: 20px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .navbar-nav {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .nav-item {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
border-bottom: 1px solid #e9ecef;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .nav-link {
|
|
|
|
|
|
padding: 15px 20px;
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .nav-link:hover {
|
|
|
|
|
|
background: #f8f9fa;
|
|
|
|
|
|
color: #667eea;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .dropdown-menu {
|
|
|
|
|
|
position: static;
|
|
|
|
|
|
float: none;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
background: #f8f9fa;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: none; /* 기본적으로 숨김 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .dropdown-menu.show {
|
|
|
|
|
|
display: block !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .dropdown-item {
|
|
|
|
|
|
padding: 12px 20px 12px 50px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
color: #555;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .dropdown-item:hover,
|
|
|
|
|
|
.mobile-menu-content .dropdown-item:active {
|
|
|
|
|
|
background: #e9ecef;
|
|
|
|
|
|
color: #667eea;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .dropdown-toggle {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .dropdown-toggle::after {
|
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
|
transition: transform 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content .dropdown-toggle[aria-expanded="true"]::after {
|
|
|
|
|
|
transform: rotate(180deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-content hr {
|
|
|
|
|
|
margin: 8px 0;
|
|
|
|
|
|
border-color: #dee2e6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 모바일 반응형 스타일 */
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
/* 모바일에서 기본 nav 숨기기 */
|
|
|
|
|
|
.navbar-custom {
|
|
|
|
|
|
display: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-toggle {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 컨테이너 상단 여백 조정 (햄버거 버튼 공간 확보) */
|
|
|
|
|
|
.voice-container {
|
|
|
|
|
|
margin-top: 70px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.voice-container {
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
margin: 20px auto;
|
|
|
|
|
|
padding: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-section {
|
|
|
|
|
|
margin-bottom: 25px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-section h3 {
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-section p {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.recording-section {
|
|
|
|
|
|
padding: 25px 20px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-button {
|
|
|
|
|
|
width: 100px;
|
|
|
|
|
|
height: 100px;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-button:active {
|
|
|
|
|
|
transform: scale(0.95);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.timer {
|
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
|
min-height: 35px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-indicator {
|
|
|
|
|
|
padding: 6px 16px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.waveform-container {
|
|
|
|
|
|
height: 80px;
|
|
|
|
|
|
margin: 15px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transcript-section {
|
|
|
|
|
|
padding: 20px 15px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
min-height: 150px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transcript-section h5 {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transcript-text {
|
|
|
|
|
|
padding: 15px;
|
|
|
|
|
|
min-height: 120px;
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.action-buttons {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
margin-top: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 14px 20px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
min-height: 48px; /* 터치 친화적 최소 높이 */
|
|
|
|
|
|
white-space: normal;
|
|
|
|
|
|
word-break: keep-all;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-box {
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
margin-bottom: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-box p {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
margin: 4px 0;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 작은 모바일 화면 (480px 이하) */
|
|
|
|
|
|
@media (max-width: 480px) {
|
|
|
|
|
|
.voice-container {
|
|
|
|
|
|
padding: 10px;
|
|
|
|
|
|
margin: 10px auto;
|
|
|
|
|
|
margin-top: 70px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-toggle {
|
|
|
|
|
|
top: 10px;
|
|
|
|
|
|
right: 10px;
|
|
|
|
|
|
width: 44px;
|
|
|
|
|
|
height: 44px;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mobile-menu-sidebar {
|
|
|
|
|
|
width: 260px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-section {
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-section h3 {
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-section p {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.recording-section {
|
|
|
|
|
|
padding: 20px 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-button {
|
|
|
|
|
|
width: 90px;
|
|
|
|
|
|
height: 90px;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.timer {
|
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.status-indicator {
|
|
|
|
|
|
padding: 5px 12px;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.waveform-container {
|
|
|
|
|
|
height: 70px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transcript-section {
|
|
|
|
|
|
padding: 15px 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transcript-text {
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
min-height: 100px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-box {
|
|
|
|
|
|
padding: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-box p {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 가로 모드 (landscape) 모바일 최적화 */
|
|
|
|
|
|
@media (max-width: 768px) and (orientation: landscape) {
|
|
|
|
|
|
.voice-container {
|
|
|
|
|
|
padding: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.recording-section {
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-button {
|
|
|
|
|
|
width: 80px;
|
|
|
|
|
|
height: 80px;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.timer {
|
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transcript-section {
|
|
|
|
|
|
padding: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transcript-text {
|
|
|
|
|
|
min-height: 100px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.action-buttons {
|
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
flex: 1 1 calc(50% - 4px);
|
|
|
|
|
|
min-width: calc(50% - 4px);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 매우 작은 화면 (360px 이하) */
|
|
|
|
|
|
@media (max-width: 360px) {
|
|
|
|
|
|
.voice-container {
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-section h3 {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-button {
|
|
|
|
|
|
width: 80px;
|
|
|
|
|
|
height: 80px;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.timer {
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.transcript-text {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
padding: 10px 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 터치 디바이스 최적화 */
|
|
|
|
|
|
@media (hover: none) and (pointer: coarse) {
|
|
|
|
|
|
.record-button:hover {
|
|
|
|
|
|
transform: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn:hover {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.record-button:active {
|
|
|
|
|
|
transform: scale(0.95);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn:active {
|
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 터치 영역 확대 */
|
|
|
|
|
|
.record-button {
|
|
|
|
|
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 스크롤 부드럽게 */
|
|
|
|
|
|
html {
|
|
|
|
|
|
scroll-behavior: smooth;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 모바일에서 텍스트 선택 개선 */
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.transcript-text {
|
|
|
|
|
|
-webkit-user-select: text;
|
|
|
|
|
|
-moz-user-select: text;
|
|
|
|
|
|
-ms-user-select: text;
|
|
|
|
|
|
user-select: text;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 디버그 패널 스타일 */
|
|
|
|
|
|
.debug-panel {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
bottom: 20px;
|
|
|
|
|
|
right: 20px;
|
|
|
|
|
|
width: 350px;
|
|
|
|
|
|
max-width: calc(100% - 40px);
|
|
|
|
|
|
max-height: 400px;
|
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
|
border: 2px solid #667eea;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
|
|
|
|
z-index: 10000;
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
font-family: 'Courier New', monospace;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-panel.active {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-panel-header {
|
|
|
|
|
|
background: #667eea;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
padding: 10px 15px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
border-radius: 6px 6px 0 0;
|
|
|
|
|
|
cursor: move;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-panel-title {
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-panel-controls {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-panel-btn {
|
|
|
|
|
|
background: rgba(255,255,255,0.2);
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
width: 24px;
|
|
|
|
|
|
height: 24px;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
transition: background 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-panel-btn:hover {
|
|
|
|
|
|
background: rgba(255,255,255,0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-panel-content {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: 10px;
|
|
|
|
|
|
color: #d4d4d4;
|
|
|
|
|
|
background: #1e1e1e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-log {
|
|
|
|
|
|
margin: 4px 0;
|
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
|
border-left: 3px solid #667eea;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-log.log {
|
|
|
|
|
|
border-left-color: #4ec9b0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-log.info {
|
|
|
|
|
|
border-left-color: #4fc3f7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-log.warn {
|
|
|
|
|
|
border-left-color: #ffa726;
|
|
|
|
|
|
color: #ffa726;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-log.error {
|
|
|
|
|
|
border-left-color: #f44336;
|
|
|
|
|
|
color: #ff6b6b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-toggle-btn {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
bottom: 20px;
|
|
|
|
|
|
right: 20px;
|
|
|
|
|
|
width: 50px;
|
|
|
|
|
|
height: 50px;
|
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
z-index: 9999;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-toggle-btn:hover {
|
|
|
|
|
|
transform: scale(1.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-toggle-btn.active {
|
|
|
|
|
|
background: #f44336;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.debug-panel {
|
|
|
|
|
|
width: calc(100% - 20px);
|
|
|
|
|
|
max-width: calc(100% - 20px);
|
|
|
|
|
|
bottom: 10px;
|
|
|
|
|
|
right: 10px;
|
|
|
|
|
|
max-height: 50vh;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.debug-toggle-btn {
|
|
|
|
|
|
bottom: 10px;
|
|
|
|
|
|
right: 10px;
|
|
|
|
|
|
width: 44px;
|
|
|
|
|
|
height: 44px;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
|
|
<?php include $_SERVER['DOCUMENT_ROOT'] . "/myheader.php"; ?>
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
<!-- 모바일 햄버거 메뉴 버튼 -->
|
|
|
|
|
|
<button class="mobile-menu-toggle" id="mobile-menu-toggle" aria-label="메뉴 열기">
|
|
|
|
|
|
<i class="bi bi-list"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 모바일 메뉴 오버레이 -->
|
|
|
|
|
|
<div class="mobile-menu-overlay" id="mobile-menu-overlay"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 모바일 사이드 메뉴 -->
|
|
|
|
|
|
<div class="mobile-menu-sidebar" id="mobile-menu-sidebar">
|
|
|
|
|
|
<div class="mobile-menu-header">
|
|
|
|
|
|
<h4>메뉴</h4>
|
|
|
|
|
|
<button class="mobile-menu-close" id="mobile-menu-close" aria-label="메뉴 닫기">
|
|
|
|
|
|
<i class="bi bi-x-lg"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mobile-menu-content">
|
|
|
|
|
|
<!-- myheader.php의 nav 내용을 복사하여 여기에 표시 -->
|
|
|
|
|
|
<nav class="navbar-nav">
|
|
|
|
|
|
<?php
|
|
|
|
|
|
// myheader.php의 nav 구조를 모바일용으로 복제
|
|
|
|
|
|
// 실제로는 JavaScript로 동적으로 복사하거나 PHP로 처리
|
|
|
|
|
|
?>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
<div class="voice-container">
|
|
|
|
|
|
<div class="header-section">
|
|
|
|
|
|
<h3><i class="bi bi-mic-fill"></i> 음성 녹음 및 텍스트 변환</h3>
|
|
|
|
|
|
<p>음성을 녹음하고 AI를 통해 텍스트로 변환합니다</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
<div class="info-box" id="info-box">
|
2025-12-10 20:14:31 +09:00
|
|
|
|
<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> Google Web Speech API를 사용한 무료 한국어 음성 인식</p>
|
2025-12-16 13:36:34 +09:00
|
|
|
|
<p id="mobile-info" style="display: none;"><i class="bi bi-phone"></i> <strong>모바일:</strong> Android Chrome 권장, iOS Safari는 제한적 지원</p>
|
|
|
|
|
|
<p id="mobile-warning" style="display: none; color: #842029; font-weight: bold;"><i class="bi bi-exclamation-triangle"></i> <strong>모바일 제한:</strong> 일부 모바일 Chrome에서는 Web Speech API가 제한될 수 있습니다. 데스크탑 Chrome 사용을 권장합니다.</p>
|
2025-12-10 20:14:31 +09:00
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<audio id="audio-player" class="audio-player" controls></audio>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="transcript-section">
|
|
|
|
|
|
<h5><i class="bi bi-file-text"></i> 변환된 텍스트</h5>
|
|
|
|
|
|
<div id="transcript" class="transcript-text empty">
|
|
|
|
|
|
녹음 버튼을 클릭하여 음성을 녹음하세요
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="action-buttons">
|
|
|
|
|
|
<button id="copy-btn" class="btn btn-primary" disabled>
|
|
|
|
|
|
<i class="bi bi-clipboard"></i> 복사
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button id="download-btn" class="btn btn-secondary" disabled>
|
|
|
|
|
|
<i class="bi bi-download"></i> 다운로드
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button id="ai-summary-btn" class="btn btn-success" disabled>
|
|
|
|
|
|
<i class="bi bi-stars"></i> AI 요약
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button id="reset-btn" class="btn btn-secondary">
|
|
|
|
|
|
<i class="bi bi-arrow-clockwise"></i> 초기화
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="transcript-section" id="summary-section" style="display: none;">
|
|
|
|
|
|
<h5><i class="bi bi-stars"></i> AI 요약 (회사 기록용)</h5>
|
|
|
|
|
|
<textarea id="summary-text" class="transcript-text" style="min-height: 150px; width: 100%; resize: vertical; box-sizing: border-box; padding: 20px; border: 1px solid #dee2e6; border-radius: 8px; font-size: 16px; line-height: 1.8;" placeholder="AI 요약 버튼을 클릭하면 여기에 요약이 표시됩니다"></textarea>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="action-buttons">
|
|
|
|
|
|
<button id="copy-summary-btn" class="btn btn-primary">
|
|
|
|
|
|
<i class="bi bi-clipboard"></i> 요약 복사
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button id="download-summary-btn" class="btn btn-secondary">
|
|
|
|
|
|
<i class="bi bi-download"></i> 요약 다운로드
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 모바일 감지
|
|
|
|
|
|
function isMobileDevice() {
|
|
|
|
|
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isIOS() {
|
|
|
|
|
|
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isAndroid() {
|
|
|
|
|
|
return /Android/i.test(navigator.userAgent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
// Web Speech API 변수
|
|
|
|
|
|
let recognition = null;
|
|
|
|
|
|
let isRecognizing = false;
|
|
|
|
|
|
let finalTranscript = '';
|
|
|
|
|
|
let interimTranscript = '';
|
2025-12-16 13:36:34 +09:00
|
|
|
|
let isMobile = isMobileDevice();
|
|
|
|
|
|
|
|
|
|
|
|
// Google Cloud Speech-to-Text API 변수
|
|
|
|
|
|
let mediaRecorder = null;
|
|
|
|
|
|
let audioChunks = [];
|
|
|
|
|
|
let useGoogleAPI = false; // 모바일에서는 자동으로 true로 설정
|
|
|
|
|
|
let recordingInterval = null;
|
|
|
|
|
|
let audioBlob = null;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 타이머 변수
|
|
|
|
|
|
let startTime = null;
|
|
|
|
|
|
let timerInterval = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 시각화 변수
|
|
|
|
|
|
let audioContext = null;
|
|
|
|
|
|
let analyser = null;
|
|
|
|
|
|
let dataArray = null;
|
|
|
|
|
|
let animationId = null;
|
|
|
|
|
|
let mediaStream = null;
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 모바일에서는 Google API 사용 (Web Speech API가 작동하지 않으므로)
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
useGoogleAPI = true;
|
|
|
|
|
|
console.log('모바일 감지: Google Cloud Speech-to-Text API 사용');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 모바일 정보 표시
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
const mobileInfo = document.getElementById('mobile-info');
|
|
|
|
|
|
if (mobileInfo) {
|
|
|
|
|
|
mobileInfo.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
const mobileWarning = document.getElementById('mobile-warning');
|
|
|
|
|
|
if (mobileWarning) {
|
|
|
|
|
|
mobileWarning.style.display = 'block';
|
|
|
|
|
|
mobileWarning.innerHTML = '<i class="bi bi-exclamation-triangle"></i> <strong>모바일:</strong> Google Cloud Speech-to-Text API를 사용합니다. (Web Speech API 대체)';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
// DOM Elements
|
|
|
|
|
|
const recordButton = document.getElementById('record-button');
|
|
|
|
|
|
const statusEl = document.getElementById('status');
|
|
|
|
|
|
const timerEl = document.getElementById('timer');
|
|
|
|
|
|
const waveformCanvas = document.getElementById('waveform');
|
|
|
|
|
|
const audioPlayer = document.getElementById('audio-player');
|
|
|
|
|
|
const transcriptEl = document.getElementById('transcript');
|
|
|
|
|
|
const copyBtn = document.getElementById('copy-btn');
|
|
|
|
|
|
const downloadBtn = document.getElementById('download-btn');
|
|
|
|
|
|
const aiSummaryBtn = document.getElementById('ai-summary-btn');
|
|
|
|
|
|
const resetBtn = document.getElementById('reset-btn');
|
|
|
|
|
|
const summarySection = document.getElementById('summary-section');
|
|
|
|
|
|
const summaryText = document.getElementById('summary-text');
|
|
|
|
|
|
const copySummaryBtn = document.getElementById('copy-summary-btn');
|
|
|
|
|
|
const downloadSummaryBtn = document.getElementById('download-summary-btn');
|
|
|
|
|
|
|
|
|
|
|
|
// Canvas Context
|
|
|
|
|
|
const canvasCtx = waveformCanvas.getContext('2d');
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize Canvas Size
|
|
|
|
|
|
function resizeCanvas() {
|
|
|
|
|
|
waveformCanvas.width = waveformCanvas.offsetWidth;
|
|
|
|
|
|
waveformCanvas.height = waveformCanvas.offsetHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
resizeCanvas();
|
|
|
|
|
|
window.addEventListener('resize', resizeCanvas);
|
|
|
|
|
|
|
|
|
|
|
|
// Web Speech API 초기화
|
|
|
|
|
|
function initSpeechRecognition() {
|
|
|
|
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
|
|
|
|
|
|
|
|
|
if (!SpeechRecognition) {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
let errorMsg = '이 브라우저는 음성 인식을 지원하지 않습니다.';
|
|
|
|
|
|
if (isIOS()) {
|
|
|
|
|
|
errorMsg += '\n\niOS Safari는 Web Speech API를 지원하지 않습니다. Android Chrome 또는 데스크탑 Chrome을 사용해주세요.';
|
|
|
|
|
|
} else if (isMobile) {
|
|
|
|
|
|
errorMsg += '\n\n모바일에서는 Android Chrome 브라우저를 권장합니다.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
errorMsg += '\n\nChrome 브라우저를 사용해주세요.';
|
|
|
|
|
|
}
|
|
|
|
|
|
alert(errorMsg);
|
2025-12-10 20:14:31 +09:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// HTTPS 체크 (모바일에서 더 엄격)
|
|
|
|
|
|
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
|
|
|
|
|
console.warn('HTTPS가 아닙니다. 모바일에서 마이크 접근이 제한될 수 있습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
recognition = new SpeechRecognition();
|
2025-12-16 13:36:34 +09:00
|
|
|
|
|
|
|
|
|
|
// 모바일에서 언어 설정 시도 (ko-KR이 작동하지 않을 수 있음)
|
|
|
|
|
|
// 여러 언어 코드를 시도해볼 수 있도록 설정
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
// 모바일에서는 'ko' 또는 'ko-KR' 시도
|
|
|
|
|
|
recognition.lang = 'ko-KR';
|
|
|
|
|
|
console.log('모바일: 언어 설정 - ko-KR');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
recognition.lang = 'ko-KR';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 모바일에서 continuous를 true로 변경 (false에서도 작동하지 않았으므로)
|
|
|
|
|
|
// 일부 모바일에서는 continuous=true가 필요할 수 있음
|
2025-12-10 20:14:31 +09:00
|
|
|
|
recognition.continuous = true;
|
|
|
|
|
|
recognition.interimResults = true;
|
|
|
|
|
|
recognition.maxAlternatives = 1;
|
2025-12-16 13:36:34 +09:00
|
|
|
|
|
|
|
|
|
|
// 모바일에서 디버깅 정보
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.log('모바일 모드: continuous=' + recognition.continuous + ', interimResults=true, lang=' + recognition.lang);
|
|
|
|
|
|
console.warn('⚠️ 모바일: continuous=true로 설정했습니다.');
|
|
|
|
|
|
console.warn('⚠️ 모바일: onresult가 발생하지 않으면 Web Speech API가 모바일에서 제한될 수 있습니다.');
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 음성 인식 결과 처리
|
|
|
|
|
|
recognition.onresult = (event) => {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('=== onresult 이벤트 발생 ===');
|
|
|
|
|
|
console.log('전체 이벤트 객체:', event);
|
|
|
|
|
|
console.log('resultIndex:', event.resultIndex);
|
|
|
|
|
|
console.log('results.length:', event.results ? event.results.length : 'undefined');
|
|
|
|
|
|
console.log('isMobile:', isMobile);
|
|
|
|
|
|
|
|
|
|
|
|
// 모바일에서 결과가 제대로 전달되는지 확인
|
|
|
|
|
|
if (!event.results || event.results.length === 0) {
|
|
|
|
|
|
console.error('❌ 결과가 없습니다! event.results:', event.results);
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.error('모바일에서 결과가 전달되지 않았습니다. Web Speech API가 제대로 작동하지 않을 수 있습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
// 임시 텍스트 초기화 (중복 방지를 위해 매번 초기화)
|
2025-12-16 13:36:34 +09:00
|
|
|
|
const prevFinal = finalTranscript;
|
|
|
|
|
|
const prevInterim = interimTranscript;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
interimTranscript = '';
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('이전 상태 - finalTranscript:', prevFinal, 'interimTranscript:', prevInterim);
|
|
|
|
|
|
console.log('resultIndex:', event.resultIndex, '새로 처리할 결과부터 시작');
|
|
|
|
|
|
|
|
|
|
|
|
// resultIndex부터 처리하여 중복 방지 (continuous 모드에서 중요)
|
|
|
|
|
|
let processedCount = 0;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
const result = event.results[i];
|
|
|
|
|
|
if (!result) {
|
|
|
|
|
|
console.warn(`결과 ${i}: null 또는 undefined`);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!result[0]) {
|
|
|
|
|
|
console.warn(`결과 ${i}: result[0]이 없습니다. result 구조:`, result);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
const transcript = result[0].transcript;
|
|
|
|
|
|
const isFinal = result.isFinal;
|
|
|
|
|
|
const confidence = result[0].confidence || 'N/A';
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`결과 ${i}: "${transcript}" (isFinal: ${isFinal}, confidence: ${confidence})`);
|
|
|
|
|
|
|
|
|
|
|
|
if (isFinal) {
|
2025-12-10 20:14:31 +09:00
|
|
|
|
// 확정된 텍스트는 finalTranscript에 추가
|
|
|
|
|
|
finalTranscript += transcript + ' ';
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('✅ 확정된 텍스트 추가:', transcript);
|
|
|
|
|
|
console.log('현재 finalTranscript:', finalTranscript);
|
|
|
|
|
|
processedCount++;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
} else {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 임시 텍스트는 interimTranscript에 저장 (모바일에서도 표시)
|
2025-12-10 20:14:31 +09:00
|
|
|
|
interimTranscript += transcript;
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('⏳ 임시 텍스트:', transcript);
|
|
|
|
|
|
console.log('현재 interimTranscript:', interimTranscript);
|
|
|
|
|
|
processedCount++;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-16 13:36:34 +09:00
|
|
|
|
|
|
|
|
|
|
console.log(`처리된 결과 개수: ${processedCount}/${event.results.length}`);
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 텍스트 업데이트 (확정된 텍스트 + 현재 임시 텍스트만 표시)
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 모바일에서는 모든 텍스트를 즉시 표시
|
|
|
|
|
|
console.log('=== 텍스트 업데이트 시작 ===');
|
|
|
|
|
|
console.log('finalTranscript 길이:', finalTranscript.length, '내용:', finalTranscript);
|
|
|
|
|
|
console.log('interimTranscript 길이:', interimTranscript.length, '내용:', interimTranscript);
|
|
|
|
|
|
|
|
|
|
|
|
let displayText = '';
|
|
|
|
|
|
|
|
|
|
|
|
if (finalTranscript.trim()) {
|
|
|
|
|
|
displayText = finalTranscript.trim();
|
|
|
|
|
|
console.log('✅ finalTranscript 추가됨:', displayText);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('⚠️ finalTranscript가 비어있음');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (interimTranscript.trim()) {
|
|
|
|
|
|
const interimHtml = '<span style="color: #999; font-style: italic;">' + interimTranscript.trim() + '</span>';
|
|
|
|
|
|
displayText += (displayText ? ' ' : '') + interimHtml;
|
|
|
|
|
|
console.log('⏳ interimTranscript 추가됨:', interimTranscript.trim());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('⚠️ interimTranscript가 비어있음');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('최종 displayText:', displayText);
|
|
|
|
|
|
console.log('transcriptEl 존재:', !!transcriptEl);
|
|
|
|
|
|
|
|
|
|
|
|
// 모바일에서는 텍스트가 조금이라도 있으면 표시
|
|
|
|
|
|
if (displayText.trim() || finalTranscript.trim() || interimTranscript.trim()) {
|
|
|
|
|
|
const textToShow = displayText || '음성을 인식하고 있습니다...';
|
|
|
|
|
|
console.log('📝 화면에 표시할 텍스트:', textToShow);
|
|
|
|
|
|
|
|
|
|
|
|
transcriptEl.innerHTML = textToShow;
|
|
|
|
|
|
transcriptEl.classList.remove('empty');
|
|
|
|
|
|
|
|
|
|
|
|
console.log('✅ transcriptEl.innerHTML 업데이트 완료');
|
|
|
|
|
|
console.log('transcriptEl.innerHTML 실제 값:', transcriptEl.innerHTML);
|
|
|
|
|
|
|
|
|
|
|
|
// 모바일에서 강제로 화면 업데이트
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.log('모바일: 강제 리플로우 실행');
|
|
|
|
|
|
transcriptEl.style.display = 'none';
|
|
|
|
|
|
transcriptEl.offsetHeight; // 리플로우 강제
|
|
|
|
|
|
transcriptEl.style.display = '';
|
|
|
|
|
|
console.log('모바일: 리플로우 완료');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('❌ 표시할 텍스트가 없습니다!');
|
|
|
|
|
|
console.error('finalTranscript:', finalTranscript);
|
|
|
|
|
|
console.error('interimTranscript:', interimTranscript);
|
|
|
|
|
|
console.error('displayText:', displayText);
|
|
|
|
|
|
|
|
|
|
|
|
// 모바일에서도 최소한의 메시지 표시
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.warn('모바일: 기본 메시지 표시');
|
|
|
|
|
|
transcriptEl.innerHTML = '음성을 인식하고 있습니다...';
|
|
|
|
|
|
transcriptEl.classList.remove('empty');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('=== 텍스트 업데이트 완료 ===');
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 버튼 활성화 (확정된 텍스트가 있을 때만)
|
|
|
|
|
|
if (finalTranscript.trim()) {
|
|
|
|
|
|
copyBtn.disabled = false;
|
|
|
|
|
|
downloadBtn.disabled = false;
|
|
|
|
|
|
aiSummaryBtn.disabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 음성 인식 시작
|
|
|
|
|
|
recognition.onstart = () => {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('onstart 이벤트 발생');
|
2025-12-10 20:14:31 +09:00
|
|
|
|
isRecognizing = true;
|
|
|
|
|
|
updateStatus('음성 인식 중', 'recording');
|
2025-12-16 13:36:34 +09:00
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.log('모바일: 음성 인식 시작됨');
|
|
|
|
|
|
console.warn('⚠️ 모바일: onresult 이벤트가 발생하는지 확인하세요.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 음성이 감지되기 시작할 때 (모바일에서 유용)
|
|
|
|
|
|
recognition.onspeechstart = () => {
|
|
|
|
|
|
console.log('🎤 onspeechstart: 음성이 감지되기 시작했습니다.');
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.log('모바일: 음성 감지 시작 - onresult가 곧 발생해야 합니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 음성 감지가 끝날 때
|
|
|
|
|
|
recognition.onspeechend = () => {
|
|
|
|
|
|
console.log('🔇 onspeechend: 음성 감지가 끝났습니다.');
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.log('모바일: 음성 감지 종료 - 결과를 기다립니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 음성 인식이 시작되기 전에 음성이 감지되었을 때
|
|
|
|
|
|
recognition.onaudiostart = () => {
|
|
|
|
|
|
console.log('🔊 onaudiostart: 오디오 입력이 시작되었습니다.');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 오디오 입력이 끝났을 때
|
|
|
|
|
|
recognition.onaudioend = () => {
|
|
|
|
|
|
console.log('🔇 onaudioend: 오디오 입력이 끝났습니다.');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 음성 인식 서비스가 연결되었을 때
|
|
|
|
|
|
recognition.onsoundstart = () => {
|
|
|
|
|
|
console.log('🔊 onsoundstart: 소리가 감지되기 시작했습니다.');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 소리 감지가 끝났을 때
|
|
|
|
|
|
recognition.onsoundend = () => {
|
|
|
|
|
|
console.log('🔇 onsoundend: 소리 감지가 끝났습니다.');
|
2025-12-10 20:14:31 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 음성 인식 종료
|
|
|
|
|
|
recognition.onend = () => {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('=== onend 이벤트 발생 ===');
|
|
|
|
|
|
console.log('isRecognizing:', isRecognizing);
|
|
|
|
|
|
console.log('finalTranscript 현재 상태:', finalTranscript);
|
|
|
|
|
|
console.log('interimTranscript 현재 상태:', interimTranscript);
|
|
|
|
|
|
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.warn('⚠️ 모바일: onend 발생 - onresult가 발생했는지 확인하세요.');
|
|
|
|
|
|
if (!finalTranscript && !interimTranscript) {
|
|
|
|
|
|
console.error('❌ 모바일: onresult가 발생하지 않았습니다!');
|
|
|
|
|
|
console.error('가능한 원인:');
|
|
|
|
|
|
console.error('1. 모바일 Chrome의 Web Speech API 제한 (가장 가능성 높음)');
|
|
|
|
|
|
console.error('2. 네트워크 연결 문제 (Web Speech API는 서버 통신 필요)');
|
|
|
|
|
|
console.error('3. 브라우저 호환성 문제');
|
|
|
|
|
|
console.error('4. 음성 인식 서비스 접근 불가');
|
|
|
|
|
|
console.error('');
|
|
|
|
|
|
console.error('💡 해결 방법:');
|
|
|
|
|
|
console.error('1. 데스크탑 Chrome 브라우저 사용 (가장 확실한 방법)');
|
|
|
|
|
|
console.error('2. 다른 모바일 브라우저 시도 (Samsung Internet 등)');
|
|
|
|
|
|
console.error('3. 모바일 Chrome 업데이트 확인');
|
|
|
|
|
|
console.error('4. 인터넷 연결 확인 (Web Speech API는 온라인 필요)');
|
|
|
|
|
|
|
|
|
|
|
|
// 사용자에게 경고 표시 (첫 번째 발생 시에만)
|
|
|
|
|
|
if (!window.mobileWarningShown) {
|
|
|
|
|
|
window.mobileWarningShown = true;
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const warningMsg = '⚠️ 모바일에서 음성 인식이 작동하지 않을 수 있습니다.\n\n' +
|
|
|
|
|
|
'모바일 Chrome의 Web Speech API는 제한적일 수 있습니다.\n\n' +
|
|
|
|
|
|
'권장 사항:\n' +
|
|
|
|
|
|
'• 데스크탑 Chrome 브라우저 사용\n' +
|
|
|
|
|
|
'• 다른 모바일 브라우저 시도\n' +
|
|
|
|
|
|
'• 인터넷 연결 확인';
|
|
|
|
|
|
console.warn(warningMsg);
|
|
|
|
|
|
// alert는 사용자 경험을 해칠 수 있으므로 console.warn만 사용
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
}
|
2025-12-16 13:36:34 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// continuous=false일 때는 onend가 자주 발생하므로 자동 재시작
|
|
|
|
|
|
// continuous=true일 때는 예상치 못한 종료일 때만 발생
|
|
|
|
|
|
if (recordButton.classList.contains('recording')) {
|
|
|
|
|
|
console.log('녹음 중이므로 자동 재시작 시도');
|
|
|
|
|
|
// 짧은 딜레이 후 재시작 (모바일에서 안정성 향상)
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (recordButton.classList.contains('recording')) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
isRecognizing = true;
|
|
|
|
|
|
recognition.start();
|
|
|
|
|
|
console.log('✅ 재시작 성공');
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.warn('⚠️ 모바일: 재시작 후 onresult가 발생하는지 확인하세요.');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('❌ Recognition restart failed:', e);
|
|
|
|
|
|
// 재시작 실패 시 상태 업데이트
|
|
|
|
|
|
if (e.name === 'InvalidStateError') {
|
|
|
|
|
|
// 이미 시작된 경우 무시
|
|
|
|
|
|
console.log('이미 시작됨, 무시');
|
|
|
|
|
|
isRecognizing = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
isRecognizing = false;
|
|
|
|
|
|
updateStatus('재시작 실패: ' + e.message, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, isMobile ? 200 : 100);
|
2025-12-10 20:14:31 +09:00
|
|
|
|
} else {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('녹음 중지됨, 정상 종료');
|
|
|
|
|
|
isRecognizing = false;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
updateStatus('변환 완료', 'completed');
|
|
|
|
|
|
stopTimer();
|
|
|
|
|
|
stopWaveform();
|
|
|
|
|
|
stopAudioStream();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 에러 처리
|
|
|
|
|
|
recognition.onerror = (event) => {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.error('❌ Speech recognition error 발생!');
|
|
|
|
|
|
console.error('에러 코드:', event.error);
|
|
|
|
|
|
console.error('에러 메시지:', event.message || 'N/A');
|
|
|
|
|
|
console.error('전체 이벤트:', event);
|
|
|
|
|
|
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.error('⚠️ 모바일에서 에러 발생 - Web Speech API가 제대로 작동하지 않을 수 있습니다.');
|
|
|
|
|
|
console.error('모바일 브라우저:', navigator.userAgent);
|
|
|
|
|
|
console.error('HTTPS 여부:', window.location.protocol);
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
if (event.error === 'no-speech') {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.warn('⚠️ no-speech: 음성이 감지되지 않았습니다.');
|
|
|
|
|
|
// 음성이 감지되지 않음 - 모바일에서는 자동 재시작하지 않음
|
|
|
|
|
|
if (!isMobile) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 모바일에서는 상태만 업데이트
|
|
|
|
|
|
updateStatus('음성 대기 중...', 'recording');
|
|
|
|
|
|
console.warn('모바일: 음성 대기 중... (onresult가 발생하지 않을 수 있음)');
|
2025-12-10 20:14:31 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (event.error === 'aborted') {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('ℹ️ aborted: 사용자가 중단했습니다.');
|
2025-12-10 20:14:31 +09:00
|
|
|
|
// 사용자가 중단함
|
2025-12-16 13:36:34 +09:00
|
|
|
|
isRecognizing = false;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 모바일에서 더 자세한 에러 메시지
|
|
|
|
|
|
let errorMsg = '오류 발생: ' + event.error;
|
|
|
|
|
|
if (isMobile && event.error === 'network') {
|
|
|
|
|
|
errorMsg += '\n\n네트워크 연결을 확인해주세요.';
|
|
|
|
|
|
console.error('❌ 모바일 네트워크 오류: Web Speech API는 인터넷 연결이 필요합니다.');
|
|
|
|
|
|
} else if (isMobile && event.error === 'service-not-allowed') {
|
|
|
|
|
|
errorMsg += '\n\n음성 인식 서비스를 사용할 수 없습니다.';
|
|
|
|
|
|
console.error('❌ 모바일 서비스 오류: 음성 인식 서비스가 허용되지 않았습니다.');
|
|
|
|
|
|
} else if (isMobile) {
|
|
|
|
|
|
console.error('❌ 모바일에서 알 수 없는 오류:', event.error);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateStatus(errorMsg, 'error');
|
|
|
|
|
|
isRecognizing = false;
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
if (event.error === 'not-allowed') {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
let alertMsg = '마이크 권한이 거부되었습니다.';
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
alertMsg += '\n\n모바일 브라우저 설정에서 마이크 권한을 허용해주세요.\n(설정 > 사이트 설정 > 마이크)';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alertMsg += '\n\n브라우저 설정에서 마이크 권한을 허용해주세요.';
|
|
|
|
|
|
}
|
|
|
|
|
|
alert(alertMsg);
|
2025-12-10 20:14:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetTimer() {
|
|
|
|
|
|
stopTimer();
|
|
|
|
|
|
timerEl.textContent = '00:00';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Waveform Visualization
|
|
|
|
|
|
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 {
|
|
|
|
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
|
|
|
|
|
|
|
// Setup Audio Context for Visualization
|
|
|
|
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
|
analyser = audioContext.createAnalyser();
|
|
|
|
|
|
const source = audioContext.createMediaStreamSource(mediaStream);
|
|
|
|
|
|
source.connect(analyser);
|
|
|
|
|
|
|
|
|
|
|
|
analyser.fftSize = 2048;
|
|
|
|
|
|
const bufferLength = analyser.frequencyBinCount;
|
|
|
|
|
|
dataArray = new Uint8Array(bufferLength);
|
|
|
|
|
|
|
|
|
|
|
|
// Start Waveform Visualization
|
|
|
|
|
|
drawWaveform();
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('마이크 접근 오류:', error);
|
|
|
|
|
|
alert('마이크 권한을 허용해주세요');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 오디오 스트림 중지
|
|
|
|
|
|
function stopAudioStream() {
|
|
|
|
|
|
if (mediaStream) {
|
|
|
|
|
|
mediaStream.getTracks().forEach(track => track.stop());
|
|
|
|
|
|
mediaStream = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (audioContext) {
|
|
|
|
|
|
audioContext.close();
|
|
|
|
|
|
audioContext = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 음성 인식 시작
|
|
|
|
|
|
async function startRecognition() {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 모바일에서는 매번 recognition 객체를 재생성 (일부 모바일에서 필요)
|
|
|
|
|
|
if (isMobile && recognition) {
|
|
|
|
|
|
console.log('모바일: 기존 recognition 객체 정리');
|
|
|
|
|
|
try {
|
|
|
|
|
|
recognition.stop();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 무시
|
|
|
|
|
|
}
|
|
|
|
|
|
recognition = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
// Speech Recognition 초기화
|
|
|
|
|
|
if (!recognition) {
|
|
|
|
|
|
if (!initSpeechRecognition()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 모바일에서는 마이크 권한을 먼저 요청
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 오디오 스트림 시작 (시각화용 및 권한 확인)
|
|
|
|
|
|
const streamStarted = await startAudioStream();
|
|
|
|
|
|
if (!streamStarted) {
|
|
|
|
|
|
updateStatus('마이크 권한 필요', 'error');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('마이크 접근 오류:', error);
|
|
|
|
|
|
updateStatus('마이크 접근 실패', 'error');
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
alert('마이크 권한을 허용해주세요.\n\n모바일 브라우저 설정에서 사이트의 마이크 권한을 확인해주세요.');
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 변수 초기화
|
|
|
|
|
|
finalTranscript = '';
|
|
|
|
|
|
interimTranscript = '';
|
2025-12-16 13:36:34 +09:00
|
|
|
|
transcriptEl.innerHTML = '음성을 인식하고 있습니다...';
|
|
|
|
|
|
transcriptEl.classList.remove('empty');
|
|
|
|
|
|
|
|
|
|
|
|
console.log('=== 음성 인식 시작 준비 ===');
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.log('📱 모바일 모드로 시작');
|
|
|
|
|
|
console.log('브라우저:', navigator.userAgent);
|
|
|
|
|
|
console.log('HTTPS:', window.location.protocol);
|
|
|
|
|
|
console.log('네트워크 상태:', navigator.onLine ? '온라인' : '오프라인');
|
|
|
|
|
|
|
|
|
|
|
|
// 네트워크 상태 확인
|
|
|
|
|
|
if (!navigator.onLine) {
|
|
|
|
|
|
console.error('❌ 모바일: 오프라인 상태입니다. Web Speech API는 인터넷 연결이 필요합니다.');
|
|
|
|
|
|
alert('인터넷 연결이 필요합니다.\n\nWeb Speech API는 서버와 통신해야 하므로 온라인 상태여야 합니다.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HTTPS 확인
|
|
|
|
|
|
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
|
|
|
|
|
console.warn('⚠️ 모바일: HTTPS가 아닙니다. Web Speech API가 제한될 수 있습니다.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.warn('⚠️ 모바일: onresult 이벤트가 발생하는지 주의 깊게 확인하세요.');
|
|
|
|
|
|
console.warn('⚠️ 모바일: onresult가 발생하지 않으면 Web Speech API가 작동하지 않는 것입니다.');
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
// UI 업데이트
|
|
|
|
|
|
recordButton.classList.add('recording');
|
|
|
|
|
|
recordButton.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
|
|
|
|
|
updateStatus('음성 인식 중', 'recording');
|
|
|
|
|
|
startTimer();
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 음성 인식 시작 (모바일에서는 약간의 딜레이 후 시작)
|
2025-12-10 20:14:31 +09:00
|
|
|
|
try {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
// 모바일에서 안정성을 위해 짧은 딜레이
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (recordButton.classList.contains('recording')) {
|
|
|
|
|
|
console.log('모바일: recognition.start() 호출');
|
|
|
|
|
|
recognition.start();
|
|
|
|
|
|
console.log('모바일: recognition.start() 완료');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('모바일: 녹음이 중지되어 start() 취소');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 200);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('데스크탑: recognition.start() 호출');
|
|
|
|
|
|
recognition.start();
|
|
|
|
|
|
console.log('데스크탑: recognition.start() 완료');
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('음성 인식 시작 오류:', error);
|
2025-12-16 13:36:34 +09:00
|
|
|
|
updateStatus('음성 인식 시작 실패: ' + error.message, 'error');
|
|
|
|
|
|
recordButton.classList.remove('recording');
|
|
|
|
|
|
recordButton.innerHTML = '<i class="bi bi-mic-fill"></i>';
|
|
|
|
|
|
stopTimer();
|
|
|
|
|
|
stopWaveform();
|
|
|
|
|
|
stopAudioStream();
|
2025-12-10 20:14:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 음성 인식 중지
|
|
|
|
|
|
function stopRecognition() {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('stopRecognition 호출됨');
|
|
|
|
|
|
console.log('현재 finalTranscript:', finalTranscript);
|
|
|
|
|
|
console.log('현재 interimTranscript:', interimTranscript);
|
|
|
|
|
|
|
|
|
|
|
|
// isRecognizing을 먼저 false로 설정하여 onend에서 재시작하지 않도록
|
|
|
|
|
|
isRecognizing = false;
|
|
|
|
|
|
|
|
|
|
|
|
if (recognition) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
recognition.stop();
|
|
|
|
|
|
console.log('recognition.stop() 완료');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Recognition stop error:', error);
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UI 업데이트
|
|
|
|
|
|
recordButton.classList.remove('recording');
|
|
|
|
|
|
recordButton.innerHTML = '<i class="bi bi-mic-fill"></i>';
|
|
|
|
|
|
|
|
|
|
|
|
// 최종 텍스트 정리 (임시 텍스트 제거, 확정된 텍스트만 표시)
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 모바일에서는 interimTranscript도 확인
|
|
|
|
|
|
const hasFinalText = finalTranscript.trim().length > 0;
|
|
|
|
|
|
const hasInterimText = interimTranscript.trim().length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
console.log('텍스트 상태 - final:', hasFinalText, 'interim:', hasInterimText);
|
|
|
|
|
|
|
|
|
|
|
|
if (hasFinalText) {
|
2025-12-10 20:14:31 +09:00
|
|
|
|
transcriptEl.textContent = finalTranscript.trim();
|
|
|
|
|
|
transcriptEl.classList.remove('empty');
|
|
|
|
|
|
updateStatus('변환 완료', 'completed');
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.log('최종 텍스트 표시:', finalTranscript.trim());
|
|
|
|
|
|
|
|
|
|
|
|
// 버튼 활성화
|
|
|
|
|
|
copyBtn.disabled = false;
|
|
|
|
|
|
downloadBtn.disabled = false;
|
|
|
|
|
|
aiSummaryBtn.disabled = false;
|
|
|
|
|
|
} else if (hasInterimText && isMobile) {
|
|
|
|
|
|
// 모바일에서는 interim 텍스트라도 있으면 표시
|
|
|
|
|
|
transcriptEl.textContent = interimTranscript.trim();
|
|
|
|
|
|
transcriptEl.classList.remove('empty');
|
|
|
|
|
|
updateStatus('변환 완료 (임시)', 'completed');
|
|
|
|
|
|
console.log('임시 텍스트 표시 (모바일):', interimTranscript.trim());
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 버튼 활성화
|
|
|
|
|
|
copyBtn.disabled = false;
|
|
|
|
|
|
downloadBtn.disabled = false;
|
|
|
|
|
|
aiSummaryBtn.disabled = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
transcriptEl.innerHTML = '<span style="color:#999;font-style:italic;">인식된 텍스트가 없습니다</span>';
|
|
|
|
|
|
transcriptEl.classList.add('empty');
|
|
|
|
|
|
updateStatus('대기중', 'waiting');
|
2025-12-16 13:36:34 +09:00
|
|
|
|
console.warn('인식된 텍스트가 없음');
|
2025-12-10 20:14:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 버튼 비활성화
|
|
|
|
|
|
copyBtn.disabled = true;
|
|
|
|
|
|
downloadBtn.disabled = true;
|
|
|
|
|
|
aiSummaryBtn.disabled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stopTimer();
|
|
|
|
|
|
stopWaveform();
|
|
|
|
|
|
stopAudioStream();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// ========== Google Cloud Speech-to-Text API 함수 ==========
|
|
|
|
|
|
|
|
|
|
|
|
// Google API로 오디오 녹음 시작
|
|
|
|
|
|
async function startGoogleRecognition() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('=== Google Cloud Speech-to-Text API 시작 ===');
|
|
|
|
|
|
|
|
|
|
|
|
// 마이크 권한 요청
|
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
|
audio: {
|
|
|
|
|
|
channelCount: 1,
|
|
|
|
|
|
sampleRate: 16000,
|
|
|
|
|
|
echoCancellation: true,
|
|
|
|
|
|
noiseSuppression: true
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
mediaStream = stream;
|
|
|
|
|
|
|
|
|
|
|
|
// 오디오 시각화를 위한 설정
|
|
|
|
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
|
analyser = audioContext.createAnalyser();
|
|
|
|
|
|
const source = audioContext.createMediaStreamSource(stream);
|
|
|
|
|
|
source.connect(analyser);
|
|
|
|
|
|
|
|
|
|
|
|
analyser.fftSize = 2048;
|
|
|
|
|
|
const bufferLength = analyser.frequencyBinCount;
|
|
|
|
|
|
dataArray = new Uint8Array(bufferLength);
|
|
|
|
|
|
|
|
|
|
|
|
// 파형 시각화 시작
|
|
|
|
|
|
drawWaveform();
|
|
|
|
|
|
|
|
|
|
|
|
// MediaRecorder 설정 (최적의 형식 선택)
|
|
|
|
|
|
const options = {
|
|
|
|
|
|
mimeType: 'audio/webm;codecs=opus',
|
|
|
|
|
|
audioBitsPerSecond: 16000
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 지원하는 형식 확인 (우선순위: webm opus > webm > ogg opus)
|
|
|
|
|
|
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
|
|
|
|
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
|
|
|
|
|
options.mimeType = 'audio/webm';
|
|
|
|
|
|
} else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
|
|
|
|
|
options.mimeType = 'audio/ogg;codecs=opus';
|
|
|
|
|
|
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
|
|
|
|
|
options.mimeType = 'audio/mp4';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 기본 형식 사용
|
|
|
|
|
|
options.mimeType = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('MediaRecorder 형식:', options.mimeType || '기본값');
|
|
|
|
|
|
|
|
|
|
|
|
mediaRecorder = new MediaRecorder(stream, options);
|
|
|
|
|
|
audioChunks = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 데이터 수집
|
|
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
|
|
|
|
if (event.data.size > 0) {
|
|
|
|
|
|
audioChunks.push(event.data);
|
|
|
|
|
|
console.log('오디오 청크 수집:', event.data.size, 'bytes');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 녹음 중지 시 처리
|
|
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
|
|
|
|
console.log('MediaRecorder 중지됨');
|
|
|
|
|
|
audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
|
|
|
|
|
console.log('오디오 Blob 생성:', audioBlob.size, 'bytes');
|
|
|
|
|
|
|
|
|
|
|
|
// 서버로 전송
|
|
|
|
|
|
await sendAudioToServer(audioBlob);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 주기적으로 오디오 전송 (실시간 느낌)
|
|
|
|
|
|
mediaRecorder.start(3000); // 3초마다 데이터 수집
|
|
|
|
|
|
|
|
|
|
|
|
// 주기적으로 서버로 전송
|
|
|
|
|
|
recordingInterval = setInterval(async () => {
|
|
|
|
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
|
|
|
|
mediaRecorder.stop();
|
|
|
|
|
|
mediaRecorder.start(3000);
|
|
|
|
|
|
|
|
|
|
|
|
if (audioChunks.length > 0) {
|
|
|
|
|
|
const chunkBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
|
|
|
|
|
audioChunks = []; // 청크 초기화
|
|
|
|
|
|
await sendAudioToServer(chunkBlob, true); // 실시간 전송
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
|
|
|
|
|
|
// UI 업데이트
|
|
|
|
|
|
recordButton.classList.add('recording');
|
|
|
|
|
|
recordButton.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
|
|
|
|
|
updateStatus('음성 인식 중 (Google API)', 'recording');
|
|
|
|
|
|
startTimer();
|
|
|
|
|
|
isRecognizing = true;
|
|
|
|
|
|
|
|
|
|
|
|
console.log('✅ Google API 녹음 시작 완료');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Google API 녹음 시작 오류:', error);
|
|
|
|
|
|
updateStatus('녹음 시작 실패: ' + error.message, 'error');
|
|
|
|
|
|
alert('마이크 권한을 허용해주세요.');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Google API로 오디오 녹음 중지
|
|
|
|
|
|
function stopGoogleRecognition() {
|
|
|
|
|
|
console.log('=== Google Cloud Speech-to-Text API 중지 ===');
|
|
|
|
|
|
|
|
|
|
|
|
isRecognizing = false;
|
|
|
|
|
|
|
|
|
|
|
|
// MediaRecorder 중지
|
|
|
|
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
|
|
|
|
mediaRecorder.stop();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 인터벌 정리
|
|
|
|
|
|
if (recordingInterval) {
|
|
|
|
|
|
clearInterval(recordingInterval);
|
|
|
|
|
|
recordingInterval = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 스트림 정리
|
|
|
|
|
|
if (mediaStream) {
|
|
|
|
|
|
mediaStream.getTracks().forEach(track => track.stop());
|
|
|
|
|
|
mediaStream = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 마지막 오디오 전송
|
|
|
|
|
|
if (audioChunks.length > 0 && mediaRecorder) {
|
|
|
|
|
|
const finalBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
|
|
|
|
|
sendAudioToServer(finalBlob);
|
|
|
|
|
|
audioChunks = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UI 업데이트
|
|
|
|
|
|
recordButton.classList.remove('recording');
|
|
|
|
|
|
recordButton.innerHTML = '<i class="bi bi-mic-fill"></i>';
|
|
|
|
|
|
|
|
|
|
|
|
stopTimer();
|
|
|
|
|
|
stopWaveform();
|
|
|
|
|
|
stopAudioStream();
|
|
|
|
|
|
|
|
|
|
|
|
console.log('✅ Google API 녹음 중지 완료');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 서버로 오디오 전송
|
|
|
|
|
|
async function sendAudioToServer(audioBlob, isChunk = false) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('서버로 오디오 전송:', audioBlob.size, 'bytes', isChunk ? '(청크)' : '(최종)');
|
|
|
|
|
|
|
|
|
|
|
|
// 빈 오디오 체크
|
|
|
|
|
|
if (audioBlob.size === 0) {
|
|
|
|
|
|
console.warn('⚠️ 빈 오디오 파일, 전송 건너뜀');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
// 파일명에 타임스탬프 추가하여 고유성 보장
|
|
|
|
|
|
const fileName = 'recording_' + Date.now() + '.webm';
|
|
|
|
|
|
formData.append('audio', audioBlob, fileName);
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch('api/speech_to_text.php', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: formData
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 응답 상태 확인
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.success && result.transcript) {
|
|
|
|
|
|
console.log('✅ 텍스트 변환 성공:', result.transcript);
|
|
|
|
|
|
console.log('신뢰도:', result.confidence || 'N/A');
|
|
|
|
|
|
|
|
|
|
|
|
if (isChunk) {
|
|
|
|
|
|
// 실시간 청크: 텍스트 추가 (중복 방지)
|
|
|
|
|
|
const newText = result.transcript.trim();
|
|
|
|
|
|
if (newText && !finalTranscript.includes(newText)) {
|
|
|
|
|
|
finalTranscript += newText + ' ';
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 최종: 텍스트 설정 또는 추가
|
|
|
|
|
|
if (finalTranscript.trim()) {
|
|
|
|
|
|
finalTranscript += ' ' + result.transcript.trim();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
finalTranscript = result.transcript.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 화면 업데이트
|
|
|
|
|
|
transcriptEl.textContent = finalTranscript.trim();
|
|
|
|
|
|
transcriptEl.classList.remove('empty');
|
|
|
|
|
|
|
|
|
|
|
|
// 버튼 활성화
|
|
|
|
|
|
if (finalTranscript.trim()) {
|
|
|
|
|
|
copyBtn.disabled = false;
|
|
|
|
|
|
downloadBtn.disabled = false;
|
|
|
|
|
|
aiSummaryBtn.disabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateStatus('음성 인식 중 (Google API)', 'recording');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('❌ 텍스트 변환 실패:', result.error);
|
|
|
|
|
|
console.error('디버그 정보:', result.debug || result.hint || '없음');
|
|
|
|
|
|
|
|
|
|
|
|
// API 키 오류인 경우 사용자에게 상세 안내
|
|
|
|
|
|
if (result.error && (
|
|
|
|
|
|
result.error.includes('API key') ||
|
|
|
|
|
|
result.error.includes('API 키') ||
|
|
|
|
|
|
result.error.includes('not valid') ||
|
|
|
|
|
|
result.error.includes('invalid')
|
|
|
|
|
|
)) {
|
|
|
|
|
|
updateStatus('API 키 오류', 'error');
|
|
|
|
|
|
if (!isChunk) {
|
|
|
|
|
|
let errorMsg = 'Google Cloud Speech-to-Text API 키 오류가 발생했습니다.\n\n';
|
|
|
|
|
|
errorMsg += '해결 방법:\n';
|
|
|
|
|
|
errorMsg += '1. Google Cloud Console (https://console.cloud.google.com/) 접속\n';
|
|
|
|
|
|
errorMsg += '2. 프로젝트: codebridge-chatbot 선택\n';
|
|
|
|
|
|
errorMsg += '3. "API 및 서비스" > "라이브러리"에서 "Cloud Speech-to-Text API" 활성화\n';
|
|
|
|
|
|
errorMsg += '4. "API 및 서비스" > "사용자 인증 정보"에서 API 키 확인\n';
|
|
|
|
|
|
errorMsg += '5. API 키 제한 설정에서 "Cloud Speech-to-Text API" 허용 확인\n\n';
|
|
|
|
|
|
errorMsg += '현재 API 키 파일: ' + (result.debug?.api_key_path || 'N/A');
|
|
|
|
|
|
|
|
|
|
|
|
alert(errorMsg);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (!isChunk) {
|
|
|
|
|
|
// 에러 메시지에서 줄바꿈 처리
|
|
|
|
|
|
const errorMsg = result.error ? result.error.replace(/\\n/g, '\n') : '알 수 없는 오류';
|
|
|
|
|
|
updateStatus('변환 실패: ' + errorMsg.split('\n')[0], 'error');
|
|
|
|
|
|
|
|
|
|
|
|
// 상세 에러가 있으면 콘솔에 출력
|
|
|
|
|
|
if (result.error && result.error.includes('\n')) {
|
|
|
|
|
|
console.error('상세 에러:', result.error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('서버 전송 오류:', error);
|
|
|
|
|
|
if (!isChunk) {
|
|
|
|
|
|
updateStatus('전송 실패: ' + error.message, 'error');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========== 통합 함수 ==========
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
// Record Button Click
|
|
|
|
|
|
recordButton.addEventListener('click', () => {
|
|
|
|
|
|
if (!isRecognizing) {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
if (useGoogleAPI) {
|
|
|
|
|
|
startGoogleRecognition();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startRecognition();
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
} else {
|
2025-12-16 13:36:34 +09:00
|
|
|
|
if (useGoogleAPI) {
|
|
|
|
|
|
stopGoogleRecognition();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stopRecognition();
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Copy Button
|
|
|
|
|
|
copyBtn.addEventListener('click', () => {
|
|
|
|
|
|
const text = transcriptEl.textContent;
|
|
|
|
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
|
|
|
|
const originalText = copyBtn.innerHTML;
|
|
|
|
|
|
copyBtn.innerHTML = '<i class="bi bi-check"></i> 복사됨';
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
copyBtn.innerHTML = originalText;
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
console.error('복사 실패:', err);
|
|
|
|
|
|
alert('복사에 실패했습니다');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Download Button
|
|
|
|
|
|
downloadBtn.addEventListener('click', () => {
|
|
|
|
|
|
const text = transcriptEl.textContent;
|
|
|
|
|
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = `transcript_${new Date().getTime()}.txt`;
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// AI Summary Button
|
|
|
|
|
|
aiSummaryBtn.addEventListener('click', async () => {
|
|
|
|
|
|
const text = transcriptEl.textContent;
|
|
|
|
|
|
|
|
|
|
|
|
if (!text || text.trim() === '') {
|
|
|
|
|
|
alert('요약할 텍스트가 없습니다.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 버튼 비활성화 및 로딩 표시
|
|
|
|
|
|
aiSummaryBtn.disabled = true;
|
|
|
|
|
|
const originalBtnText = aiSummaryBtn.innerHTML;
|
|
|
|
|
|
aiSummaryBtn.innerHTML = '<span class="loading-spinner"></span> 요약 중...';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Claude API 호출
|
|
|
|
|
|
const response = await fetch('summary_api.php', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
text: text
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
|
summaryText.value = result.summary;
|
|
|
|
|
|
summarySection.style.display = 'block';
|
|
|
|
|
|
|
|
|
|
|
|
// 요약 섹션으로 스크롤
|
|
|
|
|
|
summarySection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 에러 상세 정보 콘솔 출력
|
|
|
|
|
|
console.error('API 에러 응답:', result);
|
|
|
|
|
|
|
|
|
|
|
|
let errorMsg = result.error || 'API 호출 실패';
|
|
|
|
|
|
if (result.details) {
|
|
|
|
|
|
errorMsg += '\n상세: ' + result.details;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (result.curl_error) {
|
|
|
|
|
|
errorMsg += '\nCURL 오류: ' + result.curl_error;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
throw new Error(errorMsg);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('요약 오류:', error);
|
|
|
|
|
|
console.error('에러 상세:', error);
|
|
|
|
|
|
|
|
|
|
|
|
// 더 자세한 에러 메시지 표시
|
|
|
|
|
|
let errorMsg = 'AI 요약에 실패했습니다: ' + error.message;
|
|
|
|
|
|
alert(errorMsg);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
// 버튼 복원
|
|
|
|
|
|
aiSummaryBtn.disabled = false;
|
|
|
|
|
|
aiSummaryBtn.innerHTML = originalBtnText;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Copy Summary Button
|
|
|
|
|
|
copySummaryBtn.addEventListener('click', () => {
|
|
|
|
|
|
const text = summaryText.value;
|
|
|
|
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
|
|
|
|
const originalText = copySummaryBtn.innerHTML;
|
|
|
|
|
|
copySummaryBtn.innerHTML = '<i class="bi bi-check"></i> 복사됨';
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
copySummaryBtn.innerHTML = originalText;
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
console.error('복사 실패:', err);
|
|
|
|
|
|
alert('복사에 실패했습니다');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Download Summary Button
|
|
|
|
|
|
downloadSummaryBtn.addEventListener('click', () => {
|
|
|
|
|
|
const text = summaryText.value;
|
|
|
|
|
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = `summary_${new Date().getTime()}.txt`;
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Reset Button
|
|
|
|
|
|
resetBtn.addEventListener('click', () => {
|
|
|
|
|
|
if (confirm('모든 내용을 초기화하시겠습니까?')) {
|
|
|
|
|
|
// Stop recognition if active
|
|
|
|
|
|
if (isRecognizing) {
|
|
|
|
|
|
stopRecognition();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reset all states
|
|
|
|
|
|
finalTranscript = '';
|
|
|
|
|
|
interimTranscript = '';
|
|
|
|
|
|
transcriptEl.textContent = '녹음 버튼을 클릭하여 음성을 녹음하세요';
|
|
|
|
|
|
transcriptEl.classList.add('empty');
|
|
|
|
|
|
|
|
|
|
|
|
copyBtn.disabled = true;
|
|
|
|
|
|
downloadBtn.disabled = true;
|
|
|
|
|
|
aiSummaryBtn.disabled = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 요약 섹션 숨기기
|
|
|
|
|
|
summarySection.style.display = 'none';
|
|
|
|
|
|
summaryText.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
resetTimer();
|
|
|
|
|
|
stopWaveform();
|
|
|
|
|
|
stopAudioStream();
|
|
|
|
|
|
updateStatus('대기중', 'waiting');
|
|
|
|
|
|
|
|
|
|
|
|
recordButton.classList.remove('recording');
|
|
|
|
|
|
recordButton.innerHTML = '<i class="bi bi-mic-fill"></i>';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
// 모바일 햄버거 메뉴 기능
|
|
|
|
|
|
function initMobileMenu() {
|
|
|
|
|
|
const menuToggle = document.getElementById('mobile-menu-toggle');
|
|
|
|
|
|
const menuClose = document.getElementById('mobile-menu-close');
|
|
|
|
|
|
const menuSidebar = document.getElementById('mobile-menu-sidebar');
|
|
|
|
|
|
const menuOverlay = document.getElementById('mobile-menu-overlay');
|
|
|
|
|
|
const menuContent = document.querySelector('.mobile-menu-content .navbar-nav');
|
|
|
|
|
|
const originalNav = document.querySelector('.navbar-custom .navbar-nav');
|
|
|
|
|
|
|
|
|
|
|
|
// 원본 nav 내용을 모바일 메뉴로 복사
|
|
|
|
|
|
if (originalNav && menuContent) {
|
|
|
|
|
|
menuContent.innerHTML = originalNav.innerHTML;
|
|
|
|
|
|
|
|
|
|
|
|
// 드롭다운 메뉴 토글 기능 추가
|
|
|
|
|
|
const dropdownToggles = menuContent.querySelectorAll('.dropdown-toggle');
|
|
|
|
|
|
dropdownToggles.forEach(toggle => {
|
|
|
|
|
|
toggle.addEventListener('click', function(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
const dropdown = this.nextElementSibling;
|
|
|
|
|
|
if (dropdown && dropdown.classList.contains('dropdown-menu')) {
|
|
|
|
|
|
const isOpen = dropdown.classList.contains('show');
|
|
|
|
|
|
// 모든 드롭다운 닫기
|
|
|
|
|
|
menuContent.querySelectorAll('.dropdown-menu').forEach(menu => {
|
|
|
|
|
|
menu.classList.remove('show');
|
|
|
|
|
|
});
|
|
|
|
|
|
menuContent.querySelectorAll('.dropdown-toggle').forEach(tog => {
|
|
|
|
|
|
tog.setAttribute('aria-expanded', 'false');
|
|
|
|
|
|
});
|
|
|
|
|
|
// 클릭한 드롭다운 토글
|
|
|
|
|
|
if (!isOpen) {
|
|
|
|
|
|
dropdown.classList.add('show');
|
|
|
|
|
|
toggle.setAttribute('aria-expanded', 'true');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 메뉴 외부 클릭 시 드롭다운 닫기
|
|
|
|
|
|
document.addEventListener('click', function(e) {
|
|
|
|
|
|
if (!menuContent.contains(e.target)) {
|
|
|
|
|
|
menuContent.querySelectorAll('.dropdown-menu').forEach(menu => {
|
|
|
|
|
|
menu.classList.remove('show');
|
|
|
|
|
|
});
|
|
|
|
|
|
menuContent.querySelectorAll('.dropdown-toggle').forEach(tog => {
|
|
|
|
|
|
tog.setAttribute('aria-expanded', 'false');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 메뉴 열기
|
|
|
|
|
|
function openMenu() {
|
|
|
|
|
|
menuSidebar.classList.add('active');
|
|
|
|
|
|
menuOverlay.style.display = 'block';
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
menuOverlay.classList.add('active');
|
|
|
|
|
|
}, 10);
|
|
|
|
|
|
document.body.style.overflow = 'hidden'; // 스크롤 방지
|
|
|
|
|
|
// 햄버거 버튼 아이콘 변경
|
|
|
|
|
|
if (menuToggle) {
|
|
|
|
|
|
menuToggle.innerHTML = '<i class="bi bi-x-lg"></i>';
|
|
|
|
|
|
menuToggle.setAttribute('aria-label', '메뉴 닫기');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 메뉴 닫기
|
|
|
|
|
|
function closeMenu() {
|
|
|
|
|
|
menuSidebar.classList.remove('active');
|
|
|
|
|
|
menuOverlay.classList.remove('active');
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
menuOverlay.style.display = 'none';
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
document.body.style.overflow = ''; // 스크롤 복원
|
|
|
|
|
|
// 햄버거 버튼 아이콘 변경
|
|
|
|
|
|
if (menuToggle) {
|
|
|
|
|
|
menuToggle.innerHTML = '<i class="bi bi-list"></i>';
|
|
|
|
|
|
menuToggle.setAttribute('aria-label', '메뉴 열기');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 메뉴 토글 (열기/닫기)
|
|
|
|
|
|
function toggleMenu() {
|
|
|
|
|
|
const isOpen = menuSidebar.classList.contains('active');
|
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
|
closeMenu();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
openMenu();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 이벤트 리스너
|
|
|
|
|
|
if (menuToggle) {
|
|
|
|
|
|
menuToggle.addEventListener('click', toggleMenu);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (menuClose) {
|
|
|
|
|
|
menuClose.addEventListener('click', closeMenu);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (menuOverlay) {
|
|
|
|
|
|
menuOverlay.addEventListener('click', closeMenu);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ESC 키로 메뉴 닫기
|
|
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
|
|
|
|
if (e.key === 'Escape' && menuSidebar.classList.contains('active')) {
|
|
|
|
|
|
closeMenu();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
// Page Load Complete
|
|
|
|
|
|
window.addEventListener('load', function() {
|
|
|
|
|
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
|
|
|
|
|
if (loadingOverlay) {
|
|
|
|
|
|
loadingOverlay.style.display = 'none';
|
|
|
|
|
|
}
|
2025-12-16 13:36:34 +09:00
|
|
|
|
|
|
|
|
|
|
// 모바일 햄버거 메뉴 초기화
|
|
|
|
|
|
initMobileMenu();
|
|
|
|
|
|
|
|
|
|
|
|
// 모바일 환경 체크 및 안내
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
console.log('모바일 환경 감지됨');
|
|
|
|
|
|
|
|
|
|
|
|
// iOS 체크
|
|
|
|
|
|
if (isIOS()) {
|
|
|
|
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
|
|
|
if (!SpeechRecognition) {
|
|
|
|
|
|
const infoBox = document.getElementById('info-box');
|
|
|
|
|
|
if (infoBox) {
|
|
|
|
|
|
const warning = document.createElement('p');
|
|
|
|
|
|
warning.style.color = '#842029';
|
|
|
|
|
|
warning.style.fontWeight = 'bold';
|
|
|
|
|
|
warning.innerHTML = '<i class="bi bi-exclamation-triangle"></i> <strong>주의:</strong> iOS Safari는 Web Speech API를 지원하지 않습니다. Android Chrome 또는 데스크탑 브라우저를 사용해주세요.';
|
|
|
|
|
|
infoBox.appendChild(warning);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HTTPS 체크
|
|
|
|
|
|
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
|
|
|
|
|
|
const infoBox = document.getElementById('info-box');
|
|
|
|
|
|
if (infoBox) {
|
|
|
|
|
|
const warning = document.createElement('p');
|
|
|
|
|
|
warning.style.color = '#842029';
|
|
|
|
|
|
warning.style.fontWeight = 'bold';
|
|
|
|
|
|
warning.innerHTML = '<i class="bi bi-shield-exclamation"></i> <strong>주의:</strong> HTTPS 연결이 아닙니다. 모바일에서 마이크 접근이 제한될 수 있습니다.';
|
|
|
|
|
|
infoBox.appendChild(warning);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-10 20:14:31 +09:00
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
2025-12-16 13:36:34 +09:00
|
|
|
|
<!-- 디버그 패널 -->
|
|
|
|
|
|
<button class="debug-toggle-btn" id="debug-toggle-btn" title="디버그 패널 열기/닫기">
|
|
|
|
|
|
<i class="bi bi-bug"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="debug-panel" id="debug-panel">
|
|
|
|
|
|
<div class="debug-panel-header">
|
|
|
|
|
|
<span class="debug-panel-title">🔍 디버그 콘솔</span>
|
|
|
|
|
|
<div class="debug-panel-controls">
|
|
|
|
|
|
<button class="debug-panel-btn" id="debug-copy-btn" title="로그 복사">
|
|
|
|
|
|
<i class="bi bi-clipboard"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="debug-panel-btn" id="debug-clear-btn" title="로그 지우기">
|
|
|
|
|
|
<i class="bi bi-trash"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="debug-panel-btn" id="debug-close-btn" title="패널 닫기">
|
|
|
|
|
|
<i class="bi bi-x"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="debug-panel-content" id="debug-content">
|
|
|
|
|
|
<div class="debug-log info">디버그 패널이 준비되었습니다. 로그가 여기에 표시됩니다.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
// 디버그 패널 기능
|
|
|
|
|
|
(function() {
|
|
|
|
|
|
const debugPanel = document.getElementById('debug-panel');
|
|
|
|
|
|
const debugToggleBtn = document.getElementById('debug-toggle-btn');
|
|
|
|
|
|
const debugCloseBtn = document.getElementById('debug-close-btn');
|
|
|
|
|
|
const debugClearBtn = document.getElementById('debug-clear-btn');
|
|
|
|
|
|
const debugCopyBtn = document.getElementById('debug-copy-btn');
|
|
|
|
|
|
const debugContent = document.getElementById('debug-content');
|
|
|
|
|
|
const maxLogs = 100; // 최대 로그 개수
|
|
|
|
|
|
|
|
|
|
|
|
// 디버그 패널 토글
|
|
|
|
|
|
function toggleDebugPanel() {
|
|
|
|
|
|
debugPanel.classList.toggle('active');
|
|
|
|
|
|
debugToggleBtn.classList.toggle('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 로그 추가
|
|
|
|
|
|
function addLog(message, type = 'log') {
|
|
|
|
|
|
const logDiv = document.createElement('div');
|
|
|
|
|
|
logDiv.className = `debug-log ${type}`;
|
|
|
|
|
|
|
|
|
|
|
|
const timestamp = new Date().toLocaleTimeString('ko-KR');
|
|
|
|
|
|
const logMessage = typeof message === 'object' ? JSON.stringify(message, null, 2) : String(message);
|
|
|
|
|
|
|
|
|
|
|
|
logDiv.innerHTML = `<span style="color: #888; font-size: 10px;">[${timestamp}]</span> ${logMessage}`;
|
|
|
|
|
|
|
|
|
|
|
|
debugContent.appendChild(logDiv);
|
|
|
|
|
|
|
|
|
|
|
|
// 최대 로그 개수 제한
|
|
|
|
|
|
while (debugContent.children.length > maxLogs) {
|
|
|
|
|
|
debugContent.removeChild(debugContent.firstChild);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 자동 스크롤
|
|
|
|
|
|
debugContent.scrollTop = debugContent.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 로그 지우기
|
|
|
|
|
|
function clearLogs() {
|
|
|
|
|
|
debugContent.innerHTML = '<div class="debug-log info">로그가 지워졌습니다.</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 로그 복사
|
|
|
|
|
|
async function copyLogs() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 모든 로그 요소 가져오기
|
|
|
|
|
|
const logElements = debugContent.querySelectorAll('.debug-log');
|
|
|
|
|
|
|
|
|
|
|
|
if (logElements.length === 0) {
|
|
|
|
|
|
console.warn('복사할 로그가 없습니다.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 로그 내용을 텍스트로 변환
|
|
|
|
|
|
let logText = '=== 디버그 로그 ===\n';
|
|
|
|
|
|
logText += `생성 시간: ${new Date().toLocaleString('ko-KR')}\n`;
|
|
|
|
|
|
logText += `로그 개수: ${logElements.length}\n`;
|
|
|
|
|
|
logText += '==================\n\n';
|
|
|
|
|
|
|
|
|
|
|
|
logElements.forEach((logEl, index) => {
|
|
|
|
|
|
// 타임스탬프와 메시지 추출
|
|
|
|
|
|
const logContent = logEl.textContent || logEl.innerText;
|
|
|
|
|
|
const logType = logEl.className.includes('error') ? '[ERROR]' :
|
|
|
|
|
|
logEl.className.includes('warn') ? '[WARN]' :
|
|
|
|
|
|
logEl.className.includes('info') ? '[INFO]' :
|
|
|
|
|
|
'[LOG]';
|
|
|
|
|
|
|
|
|
|
|
|
logText += `${index + 1}. ${logType} ${logContent}\n`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 클립보드에 복사
|
|
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
|
|
|
|
await navigator.clipboard.writeText(logText);
|
|
|
|
|
|
|
|
|
|
|
|
// 복사 성공 피드백
|
|
|
|
|
|
const originalIcon = debugCopyBtn.innerHTML;
|
|
|
|
|
|
debugCopyBtn.innerHTML = '<i class="bi bi-check"></i>';
|
|
|
|
|
|
debugCopyBtn.style.background = 'rgba(76, 175, 80, 0.3)';
|
|
|
|
|
|
|
|
|
|
|
|
console.log('✅ 로그가 클립보드에 복사되었습니다.');
|
|
|
|
|
|
|
|
|
|
|
|
// 2초 후 원래 아이콘으로 복원
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
debugCopyBtn.innerHTML = originalIcon;
|
|
|
|
|
|
debugCopyBtn.style.background = '';
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 클립보드 API를 사용할 수 없는 경우 대체 방법
|
|
|
|
|
|
const textArea = document.createElement('textarea');
|
|
|
|
|
|
textArea.value = logText;
|
|
|
|
|
|
textArea.style.position = 'fixed';
|
|
|
|
|
|
textArea.style.left = '-999999px';
|
|
|
|
|
|
document.body.appendChild(textArea);
|
|
|
|
|
|
textArea.select();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
document.execCommand('copy');
|
|
|
|
|
|
console.log('✅ 로그가 클립보드에 복사되었습니다. (대체 방법)');
|
|
|
|
|
|
|
|
|
|
|
|
// 복사 성공 피드백
|
|
|
|
|
|
const originalIcon = debugCopyBtn.innerHTML;
|
|
|
|
|
|
debugCopyBtn.innerHTML = '<i class="bi bi-check"></i>';
|
|
|
|
|
|
debugCopyBtn.style.background = 'rgba(76, 175, 80, 0.3)';
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
debugCopyBtn.innerHTML = originalIcon;
|
|
|
|
|
|
debugCopyBtn.style.background = '';
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('복사 실패:', err);
|
|
|
|
|
|
alert('복사에 실패했습니다. 로그를 수동으로 선택하여 복사해주세요.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
document.body.removeChild(textArea);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('로그 복사 중 오류 발생:', error);
|
|
|
|
|
|
alert('로그 복사 중 오류가 발생했습니다: ' + error.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 이벤트 리스너
|
|
|
|
|
|
if (debugToggleBtn) {
|
|
|
|
|
|
debugToggleBtn.addEventListener('click', toggleDebugPanel);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (debugCloseBtn) {
|
|
|
|
|
|
debugCloseBtn.addEventListener('click', toggleDebugPanel);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (debugClearBtn) {
|
|
|
|
|
|
debugClearBtn.addEventListener('click', clearLogs);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (debugCopyBtn) {
|
|
|
|
|
|
debugCopyBtn.addEventListener('click', copyLogs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// console 오버라이드
|
|
|
|
|
|
const originalConsole = {
|
|
|
|
|
|
log: console.log.bind(console),
|
|
|
|
|
|
info: console.info.bind(console),
|
|
|
|
|
|
warn: console.warn.bind(console),
|
|
|
|
|
|
error: console.error.bind(console)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
console.log = function(...args) {
|
|
|
|
|
|
originalConsole.log(...args);
|
|
|
|
|
|
addLog(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '), 'log');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
console.info = function(...args) {
|
|
|
|
|
|
originalConsole.info(...args);
|
|
|
|
|
|
addLog(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '), 'info');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
console.warn = function(...args) {
|
|
|
|
|
|
originalConsole.warn(...args);
|
|
|
|
|
|
addLog(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '), 'warn');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
console.error = function(...args) {
|
|
|
|
|
|
originalConsole.error(...args);
|
|
|
|
|
|
addLog(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '), 'error');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 전역 함수로 export (다른 스크립트에서도 사용 가능)
|
|
|
|
|
|
window.debugLog = addLog;
|
|
|
|
|
|
window.clearDebugLogs = clearLogs;
|
|
|
|
|
|
window.copyDebugLogs = copyLogs;
|
|
|
|
|
|
window.toggleDebugPanel = toggleDebugPanel;
|
|
|
|
|
|
|
|
|
|
|
|
// 초기 메시지
|
|
|
|
|
|
console.log('디버그 패널이 활성화되었습니다.');
|
|
|
|
|
|
console.info('모바일에서도 콘솔 로그를 확인할 수 있습니다.');
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
2025-12-10 20:14:31 +09:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|