Files
sam-kd/voice/index.php

2518 lines
83 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>음성 녹음 및 텍스트 변환</title>
<style>
* {
box-sizing: border-box;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
.voice-container {
max-width: 900px;
margin: 40px auto;
padding: 30px;
width: 100%;
}
.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;
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.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); }
}
/* 모바일 햄버거 메뉴 스타일 */
.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;
}
}
</style>
</head>
<body>
<?php include $_SERVER['DOCUMENT_ROOT'] . "/myheader.php"; ?>
<!-- 모바일 햄버거 메뉴 버튼 -->
<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>
<div class="voice-container">
<div class="header-section">
<h3><i class="bi bi-mic-fill"></i> 음성 녹음 및 텍스트 변환</h3>
<p>음성을 녹음하고 AI를 통해 텍스트로 변환합니다</p>
</div>
<div class="info-box" id="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> Google Web Speech API를 사용한 무료 한국어 음성 인식</p>
<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>
</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>
// 모바일 감지
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);
}
// Web Speech API 변수
let recognition = null;
let isRecognizing = false;
let finalTranscript = '';
let interimTranscript = '';
let isMobile = isMobileDevice();
// Google Cloud Speech-to-Text API 변수
let mediaRecorder = null;
let audioChunks = [];
let useGoogleAPI = false; // 모바일에서는 자동으로 true로 설정
let recordingInterval = null;
let audioBlob = null;
// 타이머 변수
let startTime = null;
let timerInterval = null;
// 시각화 변수
let audioContext = null;
let analyser = null;
let dataArray = null;
let animationId = null;
let mediaStream = null;
// 모바일에서는 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 대체)';
}
}
// 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) {
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);
return false;
}
// HTTPS 체크 (모바일에서 더 엄격)
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
console.warn('HTTPS가 아닙니다. 모바일에서 마이크 접근이 제한될 수 있습니다.');
}
recognition = new SpeechRecognition();
// 모바일에서 언어 설정 시도 (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가 필요할 수 있음
recognition.continuous = true;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
// 모바일에서 디버깅 정보
if (isMobile) {
console.log('모바일 모드: continuous=' + recognition.continuous + ', interimResults=true, lang=' + recognition.lang);
console.warn('⚠️ 모바일: continuous=true로 설정했습니다.');
console.warn('⚠️ 모바일: onresult가 발생하지 않으면 Web Speech API가 모바일에서 제한될 수 있습니다.');
}
// 음성 인식 결과 처리
recognition.onresult = (event) => {
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;
}
// 임시 텍스트 초기화 (중복 방지를 위해 매번 초기화)
const prevFinal = finalTranscript;
const prevInterim = interimTranscript;
interimTranscript = '';
console.log('이전 상태 - finalTranscript:', prevFinal, 'interimTranscript:', prevInterim);
console.log('resultIndex:', event.resultIndex, '새로 처리할 결과부터 시작');
// resultIndex부터 처리하여 중복 방지 (continuous 모드에서 중요)
let processedCount = 0;
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (!result) {
console.warn(`결과 ${i}: null 또는 undefined`);
continue;
}
if (!result[0]) {
console.warn(`결과 ${i}: result[0]이 없습니다. result 구조:`, result);
continue;
}
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) {
// 확정된 텍스트는 finalTranscript에 추가
finalTranscript += transcript + ' ';
console.log('✅ 확정된 텍스트 추가:', transcript);
console.log('현재 finalTranscript:', finalTranscript);
processedCount++;
} else {
// 임시 텍스트는 interimTranscript에 저장 (모바일에서도 표시)
interimTranscript += transcript;
console.log('⏳ 임시 텍스트:', transcript);
console.log('현재 interimTranscript:', interimTranscript);
processedCount++;
}
}
console.log(`처리된 결과 개수: ${processedCount}/${event.results.length}`);
// 텍스트 업데이트 (확정된 텍스트 + 현재 임시 텍스트만 표시)
// 모바일에서는 모든 텍스트를 즉시 표시
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('=== 텍스트 업데이트 완료 ===');
// 버튼 활성화 (확정된 텍스트가 있을 때만)
if (finalTranscript.trim()) {
copyBtn.disabled = false;
downloadBtn.disabled = false;
aiSummaryBtn.disabled = false;
}
};
// 음성 인식 시작
recognition.onstart = () => {
console.log('onstart 이벤트 발생');
isRecognizing = true;
updateStatus('음성 인식 중', 'recording');
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: 소리 감지가 끝났습니다.');
};
// 음성 인식 종료
recognition.onend = () => {
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);
}
}
}
// 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);
} else {
console.log('녹음 중지됨, 정상 종료');
isRecognizing = false;
updateStatus('변환 완료', 'completed');
stopTimer();
stopWaveform();
stopAudioStream();
}
};
// 에러 처리
recognition.onerror = (event) => {
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);
}
if (event.error === 'no-speech') {
console.warn('⚠️ no-speech: 음성이 감지되지 않았습니다.');
// 음성이 감지되지 않음 - 모바일에서는 자동 재시작하지 않음
if (!isMobile) {
return;
}
// 모바일에서는 상태만 업데이트
updateStatus('음성 대기 중...', 'recording');
console.warn('모바일: 음성 대기 중... (onresult가 발생하지 않을 수 있음)');
return;
}
if (event.error === 'aborted') {
console.log(' aborted: 사용자가 중단했습니다.');
// 사용자가 중단함
isRecognizing = false;
return;
}
// 모바일에서 더 자세한 에러 메시지
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;
if (event.error === 'not-allowed') {
let alertMsg = '마이크 권한이 거부되었습니다.';
if (isMobile) {
alertMsg += '\n\n모바일 브라우저 설정에서 마이크 권한을 허용해주세요.\n(설정 > 사이트 설정 > 마이크)';
} else {
alertMsg += '\n\n브라우저 설정에서 마이크 권한을 허용해주세요.';
}
alert(alertMsg);
}
};
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() {
// 모바일에서는 매번 recognition 객체를 재생성 (일부 모바일에서 필요)
if (isMobile && recognition) {
console.log('모바일: 기존 recognition 객체 정리');
try {
recognition.stop();
} catch (e) {
// 무시
}
recognition = null;
}
// Speech Recognition 초기화
if (!recognition) {
if (!initSpeechRecognition()) {
return;
}
}
// 모바일에서는 마이크 권한을 먼저 요청
try {
// 오디오 스트림 시작 (시각화용 및 권한 확인)
const streamStarted = await startAudioStream();
if (!streamStarted) {
updateStatus('마이크 권한 필요', 'error');
return;
}
} catch (error) {
console.error('마이크 접근 오류:', error);
updateStatus('마이크 접근 실패', 'error');
if (isMobile) {
alert('마이크 권한을 허용해주세요.\n\n모바일 브라우저 설정에서 사이트의 마이크 권한을 확인해주세요.');
}
return;
}
// 변수 초기화
finalTranscript = '';
interimTranscript = '';
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가 작동하지 않는 것입니다.');
}
// UI 업데이트
recordButton.classList.add('recording');
recordButton.innerHTML = '<i class="bi bi-stop-fill"></i>';
updateStatus('음성 인식 중', 'recording');
startTimer();
// 음성 인식 시작 (모바일에서는 약간의 딜레이 후 시작)
try {
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() 완료');
}
} catch (error) {
console.error('음성 인식 시작 오류:', error);
updateStatus('음성 인식 시작 실패: ' + error.message, 'error');
recordButton.classList.remove('recording');
recordButton.innerHTML = '<i class="bi bi-mic-fill"></i>';
stopTimer();
stopWaveform();
stopAudioStream();
}
}
// 음성 인식 중지
function stopRecognition() {
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);
}
}
// UI 업데이트
recordButton.classList.remove('recording');
recordButton.innerHTML = '<i class="bi bi-mic-fill"></i>';
// 최종 텍스트 정리 (임시 텍스트 제거, 확정된 텍스트만 표시)
// 모바일에서는 interimTranscript도 확인
const hasFinalText = finalTranscript.trim().length > 0;
const hasInterimText = interimTranscript.trim().length > 0;
console.log('텍스트 상태 - final:', hasFinalText, 'interim:', hasInterimText);
if (hasFinalText) {
transcriptEl.textContent = finalTranscript.trim();
transcriptEl.classList.remove('empty');
updateStatus('변환 완료', 'completed');
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());
// 버튼 활성화
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');
console.warn('인식된 텍스트가 없음');
// 버튼 비활성화
copyBtn.disabled = true;
downloadBtn.disabled = true;
aiSummaryBtn.disabled = true;
}
stopTimer();
stopWaveform();
stopAudioStream();
}
// ========== 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');
}
}
}
// ========== 통합 함수 ==========
// Record Button Click
recordButton.addEventListener('click', () => {
if (!isRecognizing) {
if (useGoogleAPI) {
startGoogleRecognition();
} else {
startRecognition();
}
} else {
if (useGoogleAPI) {
stopGoogleRecognition();
} else {
stopRecognition();
}
}
});
// 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>';
}
});
// 모바일 햄버거 메뉴 기능
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();
}
});
}
// Page Load Complete
window.addEventListener('load', function() {
const loadingOverlay = document.getElementById('loadingOverlay');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
// 모바일 햄버거 메뉴 초기화
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);
}
}
}
});
</script>
<!-- 디버그 패널 -->
<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>
</body>
</html>