# 음성 녹음 및 AI 요약 시스템 개발 문서 ## 프로젝트 개요 Chrome 브라우저의 Web Speech API를 활용한 실시간 음성 인식 및 Claude AI를 이용한 회사 기록용 요약 생성 시스템 **주요 기능:** - 실시간 음성 → 텍스트 변환 (무료, API 키 불필요) - 오디오 파형 실시간 시각화 - AI 기반 회사 기록용 요약 생성 - 텍스트 복사/다운로드 기능 --- ## 시스템 아키텍처 ### 디렉토리 구조 ``` voice/ ├── index.php # 메인 UI (음성 녹음 인터페이스) ├── summary_api.php # Claude API 요약 백엔드 └── claude_api.php # (사용안함 - OpenAI Whisper용) ``` ### 기술 스택 - **Frontend**: HTML5, CSS3, Vanilla JavaScript - **Backend**: PHP 7+ - **APIs**: - Web Speech API (Google) - 음성 인식 - Claude 3.5 Haiku API - 텍스트 요약 - **Browser APIs**: - MediaRecorder API - 오디오 스트림 - Web Audio API - 파형 시각화 - Clipboard API - 복사 기능 --- ## 주요 기능 상세 ### 1. 실시간 음성 인식 (Web Speech API) **특징:** - ✅ 완전 무료 (API 키 불필요) - ✅ 실시간 변환 (말하는 즉시 텍스트 표시) - ✅ 한국어 지원 (`ko-KR`) - ✅ 임시 결과(회색) + 최종 결과(검정색) 동시 표시 **주요 코드:** ```javascript // Web Speech API 초기화 const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; recognition = new SpeechRecognition(); recognition.lang = 'ko-KR'; recognition.continuous = true; // 연속 인식 recognition.interimResults = true; // 중간 결과 표시 recognition.maxAlternatives = 1; // 음성 인식 결과 처리 recognition.onresult = (event) => { interimTranscript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { finalTranscript += transcript + ' '; } else { interimTranscript += transcript; } } // 화면 업데이트 const displayText = finalTranscript + (interimTranscript ? '' + interimTranscript + '' : ''); transcriptEl.innerHTML = displayText; }; ``` **에러 처리:** - `no-speech`: 음성 미감지 → 자동 재시작 - `aborted`: 사용자 중단 → 무시 - `not-allowed`: 마이크 권한 거부 → 알림 표시 --- ### 2. 오디오 파형 시각화 (Web Audio API) **구현 방식:** - Canvas 2D API를 사용한 실시간 파형 그리기 - AudioContext + AnalyserNode를 통한 주파수 분석 **주요 코드:** ```javascript // 오디오 스트림 시작 mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); 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); // 파형 그리기 (애니메이션 프레임) function drawWaveform() { 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(); } ``` --- ### 3. AI 요약 기능 (Claude 3.5 Haiku) **요약 지침:** 1. 회사 업무 기록 형식 2. 중요 내용, 결정사항, 액션 아이템 명확 정리 3. 간결하고 핵심적인 요약 4. 날짜, 시간, 담당자, 금액 등 중요 정보 보존 5. 불필요한 대화 및 중복 제거 **Backend API (`summary_api.php`):** ```php 5) { echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']); exit; } // POST 데이터 받기 $input = file_get_contents('php://input'); $data = json_decode($input, true); if (!isset($data['text']) || empty(trim($data['text']))) { echo json_encode(['ok' => false, 'error' => '요약할 텍스트가 없습니다.']); exit; } $text = $data['text']; // Claude API 키 읽기 $apiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt'; if (!file_exists($apiKeyFile)) { echo json_encode(['ok' => false, 'error' => 'Claude API 키 파일이 존재하지 않습니다.']); exit; } $apiKey = trim(file_get_contents($apiKeyFile)); // Claude API 요청 프롬프트 $promptText = << 'claude-3-5-haiku-20241022', // Haiku 모델 사용 'max_tokens' => 2048, 'messages' => [ [ 'role' => 'user', 'content' => $promptText ] ] ]; $ch = curl_init($apiUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'x-api-key: ' . $apiKey, 'anthropic-version: 2023-06-01' ]); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody)); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { $errorJson = json_decode($response, true); $errorDetails = $errorJson['error']['message'] ?? 'API 호출 실패'; echo json_encode([ 'ok' => false, 'error' => "Claude API 호출 실패 (HTTP {$httpCode})", 'details' => $errorDetails ]); exit; } $apiResponse = json_decode($response, true); $summary = trim($apiResponse['content'][0]['text']); echo json_encode([ 'ok' => true, 'summary' => $summary ]); ``` **Frontend 호출:** ```javascript aiSummaryBtn.addEventListener('click', async () => { const text = transcriptEl.textContent; aiSummaryBtn.disabled = true; aiSummaryBtn.innerHTML = ' 요약 중...'; try { 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 { throw new Error(result.error); } } catch (error) { alert('AI 요약에 실패했습니다: ' + error.message); } finally { aiSummaryBtn.disabled = false; aiSummaryBtn.innerHTML = originalBtnText; } }); ``` --- ### 4. UI/UX 기능 #### 타이머 - MM:SS 형식 - 녹음 중 빨간색 표시 - 1초 간격 업데이트 ```javascript 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); } ``` #### 상태 표시 - 대기중 (회색) - 음성 인식 중 (빨강) - 처리중 (파랑) - 변환 완료 (녹색) - 오류 (빨강) ```javascript function updateStatus(message, statusClass) { statusEl.textContent = message; statusEl.className = 'status-indicator status-' + statusClass; } ``` #### 녹음 버튼 애니메이션 ```css .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); } } ``` --- ## 설치 및 설정 ### 1. 필수 요구사항 - PHP 7.0 이상 - Chrome 브라우저 (Web Speech API 지원) - HTTPS 환경 (마이크 접근 권한) - Claude API 키 ### 2. 파일 배치 ```bash # voice 디렉토리 생성 및 파일 복사 mkdir -p /your-project/voice cp index.php /your-project/voice/ cp summary_api.php /your-project/voice/ ``` ### 3. API 키 설정 ```bash # Claude API 키 파일 생성 mkdir -p /your-project/apikey echo "your-claude-api-key" > /your-project/apikey/claude_api.txt chmod 600 /your-project/apikey/claude_api.txt ``` ### 4. 세션 설정 (`session.php`) ```php 5) { echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']); exit; } ``` ### 3. API 키 보호 - 파일 권한: `chmod 600 apikey/claude_api.txt` - .gitignore에 추가: `apikey/` - 환경 변수 사용 권장 ### 4. CORS 설정 ```php header('Access-Control-Allow-Origin: https://your-domain.com'); header('Access-Control-Allow-Methods: POST'); header('Access-Control-Allow-Headers: Content-Type'); ``` --- ## 트러블슈팅 ### 1. 마이크 권한 거부 **증상**: "마이크 권한을 허용해주세요" 알림 **해결**: - Chrome 설정 → 개인정보 및 보안 → 사이트 설정 → 마이크 - 해당 사이트 권한 허용 ### 2. Web Speech API 미작동 **증상**: "이 브라우저는 음성 인식을 지원하지 않습니다" **해결**: - Chrome 브라우저 사용 - HTTPS 환경 확인 - 브라우저 업데이트 ### 3. Claude API 404 에러 **증상**: "Claude API 호출 실패 (HTTP 404)" **원인**: 모델명 불일치 (Sonnet vs Haiku) **해결**: ```php // summary_api.php에서 모델 확인 'model' => 'claude-3-5-haiku-20241022' // Haiku 사용 ``` ### 4. 요약이 너무 길거나 짧음 **해결**: `max_tokens` 조정 ```php $requestBody = [ 'model' => 'claude-3-5-haiku-20241022', 'max_tokens' => 2048, // 조정 (1024 ~ 4096) // ... ]; ``` --- ## 확장 아이디어 ### 1. 다국어 지원 ```javascript // 언어 선택 UI 추가 recognition.lang = selectedLanguage; // 'en-US', 'ja-JP', 'zh-CN' 등 ``` ### 2. 요약 스타일 선택 ```php // 프롬프트에 스타일 옵션 추가 $styles = [ 'formal' => '공식적인 회사 문서 스타일', 'casual' => '일반 업무 메모 스타일', 'bullet' => '핵심만 요약한 bullet point 스타일' ]; ``` ### 3. 음성 파일 저장 ```javascript // MediaRecorder로 녹음 파일 저장 const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); // 서버로 업로드 또는 로컬 다운로드 ``` ### 4. 회의록 템플릿 ```php // 회의록 형식 프롬프트 **응답 형식:** - 회의 일시: - 참석자: - 주요 안건: - 결정 사항: - 액션 아이템: ``` ### 5. 실시간 자막 표시 ```javascript // 음성 인식 중 자막 오버레이 const subtitleOverlay = document.createElement('div'); subtitleOverlay.className = 'subtitle-overlay'; subtitleOverlay.textContent = interimTranscript; ``` --- ## 성능 최적화 ### 1. Canvas 렌더링 최적화 ```javascript // requestAnimationFrame 사용으로 부드러운 애니메이션 function drawWaveform() { animationId = requestAnimationFrame(drawWaveform); // ... 파형 그리기 } ``` ### 2. API 요청 디바운싱 ```javascript // 연속 요청 방지 let summaryTimeout; aiSummaryBtn.addEventListener('click', () => { clearTimeout(summaryTimeout); summaryTimeout = setTimeout(callSummaryAPI, 300); }); ``` ### 3. 메모리 관리 ```javascript // 오디오 스트림 정리 function cleanup() { if (mediaStream) { mediaStream.getTracks().forEach(track => track.stop()); mediaStream = null; } if (audioContext) { audioContext.close(); audioContext = null; } } ``` --- ## 라이선스 및 의존성 ### 사용된 라이브러리 - **Bootstrap 5**: MIT License - **Bootstrap Icons**: MIT License ### API 서비스 - **Web Speech API**: Google (무료) - **Claude API**: Anthropic (유료) ### 브라우저 API - MediaRecorder API - Web Audio API - Clipboard API - Fetch API --- ## 버전 히스토리 ### v1.1.0 (2025-12-14) - ✅ 모바일 환경 지원: Google Cloud Speech-to-Text API 통합 - ✅ 모바일 햄버거 메뉴 구현 (사이드바 네비게이션) - ✅ 모바일 디버그 패널 (온스크린 콘솔 로그) - ✅ 서비스 계정 인증 지원 (OAuth 2.0) - ✅ API 키 오류 시 자동 서비스 계정 전환 - ✅ 모바일 화면 최적화 (뷰포트 설정, 확대 방지) - ✅ 실시간 오디오 청크 전송 (3초 간격) ### v1.0.0 (2025-11-05) - ✅ Web Speech API 기반 실시간 음성 인식 - ✅ 오디오 파형 시각화 - ✅ Claude 3.5 Haiku API 요약 기능 - ✅ 복사/다운로드 기능 - ✅ 반응형 UI 디자인 --- ## 개발자 정보 **개발 환경:** - PHP 7+ - Chrome 120+ - Bootstrap 5.x **테스트 환경:** - Windows 11 + Chrome - macOS + Chrome/Safari - Linux + Chrome **문의:** - 프로젝트: 5130 ERP System - 모듈: Voice Recognition & AI Summary --- ## 참고 자료 ### 공식 문서 - [Web Speech API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API) - [Claude API - Anthropic](https://docs.anthropic.com/claude/reference/messages) - [Web Audio API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API) ### 관련 기술 - [MediaRecorder API](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder) - [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) - [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) --- --- ## 모바일 환경 지원 (v1.1.0) ### 개요 모바일 브라우저에서 Web Speech API가 불안정하거나 작동하지 않는 문제를 해결하기 위해 Google Cloud Speech-to-Text API를 통합했습니다. 또한 모바일 환경에서 개발 및 디버깅을 위한 온스크린 디버그 패널과 햄버거 메뉴를 구현했습니다. ### 주요 변경 사항 #### 1. 모바일 환경 감지 및 API 자동 전환 ```javascript // 모바일 감지 함수 function isMobileDevice() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); } // 모바일에서는 자동으로 Google API 사용 if (isMobile) { useGoogleAPI = true; console.log('모바일 감지: Google Cloud Speech-to-Text API 사용'); } ``` #### 2. Google Cloud Speech-to-Text API 통합 **클라이언트 사이드 (index.php):** ```javascript // Google API로 오디오 녹음 시작 async function startGoogleRecognition() { // 1. 마이크 권한 요청 const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } }); // 2. MediaRecorder 설정 const options = { mimeType: 'audio/webm;codecs=opus', audioBitsPerSecond: 16000 }; mediaRecorder = new MediaRecorder(stream, options); audioChunks = []; // 3. 오디오 데이터 수집 mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunks.push(event.data); } }; // 4. 주기적으로 서버로 전송 (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); mediaRecorder.start(3000); // 3초마다 데이터 수집 } ``` **서버 사이드 (api/speech_to_text.php):** ```php // 서비스 계정 인증 (우선 사용) if ($serviceAccountPath && file_exists($serviceAccountPath)) { $accessToken = getServiceAccountToken($serviceAccountPath); if ($accessToken) { $useServiceAccount = true; } } // OAuth 2.0 토큰 생성 function getServiceAccountToken($serviceAccountPath) { $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); // JWT 생성 $jwtHeader = base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $jwtClaim = base64UrlEncode(json_encode([ 'iss' => $serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/cloud-platform', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => time() + 3600, 'iat' => time() ])); // 서명 생성 $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); $jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64UrlEncode($signature); // OAuth 토큰 요청 $ch = curl_init('https://oauth2.googleapis.com/token'); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt ])); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); $tokenData = json_decode($response, true); return $tokenData['access_token'] ?? null; } // API 호출 (서비스 계정 또는 API 키) if ($useServiceAccount && $accessToken) { $headers[] = 'Authorization: Bearer ' . $accessToken; } else { $apiUrl = $googleApiUrl . '?key=' . urlencode($googleApiKey); } ``` #### 3. 모바일 햄버거 메뉴 **HTML 구조:** ```html

메뉴

``` **CSS 스타일:** ```css /* 햄버거 버튼 (모바일에서만 표시) */ .mobile-menu-toggle { display: none; position: fixed; top: 15px; right: 15px; width: 48px; height: 48px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 8px; z-index: 1000; cursor: pointer; } @media (max-width: 768px) { .mobile-menu-toggle { display: flex; } .navbar-custom { display: none; /* 데스크탑 메뉴 숨김 */ } } /* 사이드 메뉴 */ .mobile-menu-sidebar { position: fixed; top: 0; right: -100%; width: 280px; max-width: 85%; height: 100%; background: white; z-index: 1000; transition: right 0.3s ease; box-shadow: -2px 0 10px rgba(0,0,0,0.1); } .mobile-menu-sidebar.active { right: 0; } /* 오버레이 */ .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; } ``` **JavaScript 기능:** ```javascript function initMobileMenu() { const menuToggle = document.getElementById('mobile-menu-toggle'); const menuSidebar = document.getElementById('mobile-menu-sidebar'); const menuOverlay = document.getElementById('mobile-menu-overlay'); // 원본 nav 내용을 모바일 메뉴로 복사 const originalNav = document.querySelector('.navbar-custom .navbar-nav'); const menuContent = document.querySelector('.mobile-menu-content .navbar-nav'); if (originalNav && menuContent) { menuContent.innerHTML = originalNav.innerHTML; } // 메뉴 토글 function toggleMenu() { const isOpen = menuSidebar.classList.contains('active'); if (isOpen) { closeMenu(); } else { openMenu(); } } function openMenu() { menuSidebar.classList.add('active'); menuOverlay.style.display = 'block'; document.body.style.overflow = 'hidden'; // 스크롤 방지 menuToggle.innerHTML = ''; // X 아이콘으로 변경 } function closeMenu() { menuSidebar.classList.remove('active'); menuOverlay.style.display = 'none'; document.body.style.overflow = ''; // 스크롤 복원 menuToggle.innerHTML = ''; // 햄버거 아이콘으로 변경 } menuToggle.addEventListener('click', toggleMenu); menuOverlay.addEventListener('click', closeMenu); } ``` #### 4. 모바일 디버그 패널 **목적**: 모바일 환경에서 브라우저 콘솔을 볼 수 없어 디버깅이 어려운 문제를 해결하기 위해 온스크린 디버그 패널을 구현했습니다. **HTML 구조:** ```html
🔍 디버그 콘솔
``` **JavaScript 구현:** ```javascript // 디버그 패널 기능 (function() { const debugPanel = document.getElementById('debug-panel'); const debugContent = document.getElementById('debug-content'); const maxLogs = 100; // 로그 추가 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 = `[${timestamp}] ${logMessage}`; debugContent.appendChild(logDiv); // 최대 로그 개수 제한 while (debugContent.children.length > maxLogs) { debugContent.removeChild(debugContent.firstChild); } // 자동 스크롤 debugContent.scrollTop = debugContent.scrollHeight; } // 로그 복사 async function copyLogs() { const logs = Array.from(debugContent.children).map(log => { const type = log.className.replace('debug-log ', ''); const text = log.textContent; return `[${type.toUpperCase()}] ${text}`; }); const logText = `=== 디버그 로그 ===\n생성 시간: ${new Date().toLocaleString('ko-KR')}\n로그 개수: ${logs.length}\n==================\n\n${logs.map((log, i) => `${i + 1}. ${log}`).join('\n')}`; if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(logText); // 복사 성공 피드백 debugCopyBtn.innerHTML = ''; debugCopyBtn.style.background = 'rgba(76, 175, 80, 0.3)'; setTimeout(() => { debugCopyBtn.innerHTML = ''; debugCopyBtn.style.background = ''; }, 2000); } } // 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.copyDebugLogs = copyLogs; })(); ``` #### 5. 모바일 화면 최적화 **뷰포트 설정 (확대 방지):** ```html ``` **반응형 CSS:** ```css @media (max-width: 768px) { .voice-container { padding: 15px; margin: 20px auto; } .record-button { width: 100px; height: 100px; font-size: 20px; } .action-buttons { flex-direction: column; } .action-buttons button { width: 100%; min-height: 48px; /* 터치 영역 최소 크기 */ } } @media (max-width: 480px) { .voice-container { padding: 10px; } .record-button { width: 80px; height: 80px; font-size: 18px; } } ``` ### 서비스 계정 설정 #### 1. Google Cloud Console에서 서비스 계정 생성 1. [Google Cloud Console](https://console.cloud.google.com/) 접속 2. 프로젝트 선택: `codebridge-chatbot` 3. "API 및 서비스" > "사용자 인증 정보" 4. "사용자 인증 정보 만들기" > "서비스 계정" 5. 서비스 계정 이름 입력 후 생성 6. 역할: "Cloud Speech-to-Text API 사용자" 또는 "Cloud Platform" 권한 부여 7. "키" 탭에서 "키 추가" > "JSON" 선택하여 다운로드 #### 2. 서비스 계정 파일 배치 ```bash # 서비스 계정 JSON 파일을 apikey 디렉토리에 저장 cp ~/Downloads/your-service-account.json /path/to/5130/apikey/google_service_account.json chmod 600 /path/to/5130/apikey/google_service_account.json ``` #### 3. API 활성화 - Google Cloud Console > "API 및 서비스" > "라이브러리" - "Cloud Speech-to-Text API" 검색 및 활성화 ### 인증 우선순위 1. **서비스 계정** (우선 사용) - `google_service_account.json` 파일이 있으면 자동으로 사용 - OAuth 2.0 토큰 생성 (1시간 유효) - API 키보다 안정적이고 보안이 강함 2. **API 키** (백업) - 서비스 계정이 없을 때만 사용 - `google_api.txt` 파일에서 읽기 - 형식: `AIzaSy...` (39자 이상) 3. **자동 재시도** - API 키 오류 발생 시 서비스 계정으로 자동 전환 - 서비스 계정 토큰 만료 시 자동 재생성 ### 파일 구조 ``` voice/ ├── index.php # 메인 UI (모바일 지원 포함) ├── api/ │ └── speech_to_text.php # Google Cloud Speech-to-Text API 엔드포인트 ├── dev.md # 개발 문서 (이 파일) └── ... apikey/ ├── google_service_account.json # 서비스 계정 키 (우선 사용) └── google_api.txt # API 키 (백업) ``` ### 모바일 테스트 방법 1. **디버그 패널 사용** - 모바일 화면 우측 하단의 버그 아이콘 클릭 - 디버그 패널에서 모든 로그 확인 - "복사" 버튼으로 로그 전체 복사 가능 2. **네트워크 확인** - 모바일에서 HTTPS 연결 확인 - 마이크 권한 허용 확인 - Google API 응답 확인 (디버그 패널에서) 3. **인증 방법 확인** - 디버그 로그에서 `auth_method: 'service_account'` 확인 - API 키 오류 발생 시 자동으로 서비스 계정으로 전환되는지 확인 ### 주의사항 1. **서비스 계정 키 보안** - 서비스 계정 JSON 파일은 절대 공개 저장소에 커밋하지 않기 - 파일 권한: `chmod 600` - `.gitignore`에 추가 2. **API 할당량** - Google Cloud Speech-to-Text API는 유료 서비스 - 무료 할당량: 월 60분 (첫 12개월) - 이후: $0.006 per 15초 3. **모바일 브라우저 호환성** - Android Chrome: 완전 지원 - iOS Safari: 제한적 지원 (Web Speech API 미지원) - 권장: Android Chrome 사용 ### 트러블슈팅 #### 1. "API key not valid" 오류 **원인**: API 키가 유효하지 않거나 형식이 잘못됨 **해결**: - 서비스 계정 파일 확인 (`google_service_account.json`) - 서비스 계정이 있으면 자동으로 사용됨 - API 키 형식 확인: `AIzaSy...`로 시작해야 함 #### 2. 모바일에서 텍스트 변환 안 됨 **원인**: Web Speech API가 모바일에서 작동하지 않음 **해결**: - 모바일에서는 자동으로 Google API 사용 - 디버그 패널에서 "Google Cloud Speech-to-Text API 시작" 로그 확인 - 서비스 계정 파일 경로 확인 #### 3. 디버그 패널이 보이지 않음 **원인**: CSS 또는 JavaScript 오류 **해결**: - 브라우저 콘솔에서 오류 확인 - `debug-toggle-btn` 요소가 존재하는지 확인 - 모바일에서만 표시되는지 확인 (데스크탑에서는 숨김) --- **문서 버전**: 1.1 **최종 업데이트**: 2025-12-14 **작성자**: Claude Code Development Team