Voice AI 모바일 중복 현상 수정

This commit is contained in:
2025-12-16 13:36:34 +09:00
parent fb5a954691
commit 16a54cef2d
40 changed files with 8986 additions and 4231 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
586EADF5015F45A49E3546B8271FE76B.37BE05FC9B5422D984E1830C210E1976
comodoca.com

View File

@@ -0,0 +1,22 @@
<?php
/**
* Google Cloud Speech-to-Text API 설정 파일
*
* 사용 방법:
* 1. 이 파일을 google_speech_api.php로 복사
* 2. YOUR_API_KEY_HERE를 실제 Google Cloud API 키로 변경
*
* Google Cloud API 키 생성 방법:
* 1. Google Cloud Console (https://console.cloud.google.com/) 접속
* 2. 프로젝트 생성 또는 선택
* 3. "API 및 서비스" > "라이브러리"에서 "Cloud Speech-to-Text API" 검색 및 활성화
* 4. "API 및 서비스" > "사용자 인증 정보"에서 API 키 생성
* 5. 생성된 API 키를 아래에 입력
*/
// Google Cloud Speech-to-Text API 키
$GOOGLE_SPEECH_API_KEY = 'YOUR_API_KEY_HERE';
// 또는 서비스 계정 키 파일 경로 사용 (더 안전)
// $GOOGLE_SPEECH_SERVICE_ACCOUNT_KEY = '/path/to/service-account-key.json';

View File

@@ -5,10 +5,26 @@ ini_set('display_errors', 1);
echo "Starting Geo Attendance DB Fix...\n";
try {
// Connect directly
// Define DOCUMENT_ROOT if not set (for CLI execution)
if (!isset($_SERVER['DOCUMENT_ROOT']) || empty($_SERVER['DOCUMENT_ROOT'])) {
$_SERVER['DOCUMENT_ROOT'] = dirname(__DIR__); // Assumes script is in /geoattendance/
}
// Include mydb.php
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
try {
// Try connecting using the standard application logic (loads .env)
$pdo = db_connect();
echo "Connected via db_connect() (standard).\n";
} catch (Exception $e) {
echo "Standard connection failed. Trying local fallback...\n";
// Fallback for Local Development (CLI) where .env might have internal Docker hostnames
$dsn = "mysql:host=127.0.0.1;dbname=chandj;charset=utf8";
$pdo = new PDO($dsn, 'root', 'root');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "Connected via Local Fallback (127.0.0.1).\n";
}
// 1. Check for ID 0 and move it if exists
// We use a subquery to find a safe new ID

View File

@@ -158,15 +158,19 @@
const [office, setOffice] = useState(DEFAULT_OFFICE);
const [isLoading, setIsLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState(null);
const [noticeMsg, setNoticeMsg] = useState(null);
const [distance, setDistance] = useState(null);
const [activeTab, setActiveTab] = useState('home');
const [permissionStatus, setPermissionStatus] = useState('unknown');
const [isSecure, setIsSecure] = useState(window.isSecureContext);
const [geoMode, setGeoMode] = useState('알 수 없음');
const [geoRetryCount, setGeoRetryCount] = useState(0);
const [lastLocationAt, setLastLocationAt] = useState(null);
useEffect(() => {
lucide.createIcons();
}, [activeTab, records]);
}, [activeTab, records, errorMsg, noticeMsg]);
// Load records on mount
useEffect(() => {
@@ -191,59 +195,130 @@
// Start watching location with fallback logic
useEffect(() => {
let watchId = null;
const watchIdRef = { current: null };
const retryTimerRef = { current: null };
const profileIdxRef = { current: 0 };
const retryCountRef = { current: 0 };
const startWatch = (highAccuracy = true) => {
if (watchId) navigator.geolocation.clearWatch(watchId);
const profiles = [
{
label: '고정밀(High Accuracy)',
options: { enableHighAccuracy: true, maximumAge: 0, timeout: 20000 }
},
{
label: '저전력(Low Accuracy)',
// Cached/network location can be very helpful on desktop/indoors
options: { enableHighAccuracy: false, maximumAge: 600000, timeout: 30000 }
}
];
if ('geolocation' in navigator) {
watchId = navigator.geolocation.watchPosition(
const clearWatch = () => {
if (watchIdRef.current) {
try { navigator.geolocation.clearWatch(watchIdRef.current); } catch (e) {}
watchIdRef.current = null;
}
};
const clearRetryTimer = () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
const scheduleRetry = (baseDelayMs) => {
clearRetryTimer();
const attempt = retryCountRef.current;
const delay = Math.min(baseDelayMs * Math.max(1, attempt), 30000);
retryTimerRef.current = setTimeout(() => {
startWatch(0);
}, delay);
};
const startWatch = (profileIdx = 0) => {
clearRetryTimer();
profileIdxRef.current = profileIdx;
setGeoMode(profiles[profileIdx]?.label || '알 수 없음');
if (!('geolocation' in navigator)) {
setErrorMsg("Geolocation is not supported by your browser.");
return;
}
clearWatch();
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
retryCountRef.current = 0;
setGeoRetryCount(0);
const newLoc = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
};
setCurrentLocation(newLoc);
setLastLocationAt(Date.now());
setErrorMsg(null);
setNoticeMsg(null);
},
(err) => {
console.error(err);
// On Timeout (code 3) and currently using High Accuracy, try fallback
if (err.code === 3 && highAccuracy) {
console.warn("High accuracy timed out. Falling back to low accuracy.");
startWatch(false);
// Permission denied -> hard error (no auto retry)
if (err.code === 1) {
setNoticeMsg(null);
setErrorMsg("위치 정보 권한이 거부되었습니다. 브라우저 설정에서 권한을 허용해주세요.");
return;
}
let msg = "위치를 가져올 수 없습니다.";
switch(err.code) {
case 1: msg = "위치 정보 권한이 거부되었습니다. 브라우저 설정에서 권한을 허용해주세요."; break;
case 2: msg = "위치 정보를 사용할 수 없습니다. GPS 신호를 확인해주세요."; break;
case 3: msg = "위치 정보 요청 시간이 초과되었습니다. (Low Accuracy 시도 실패)"; break;
default: msg = "알 수 없는 오류가 발생했습니다. (" + err.message + ")"; break;
// Timeout -> try fallback profile first, then soft retry
if (err.code === 3) {
if (profileIdxRef.current < profiles.length - 1) {
setErrorMsg(null);
setNoticeMsg("정확한 위치가 지연되어 저전력(Low Accuracy) 모드로 전환합니다...");
startWatch(profileIdxRef.current + 1);
return;
}
retryCountRef.current += 1;
setGeoRetryCount(retryCountRef.current);
setErrorMsg(null);
setNoticeMsg(`위치 응답이 지연되고 있습니다. 자동 재시도 중... (${retryCountRef.current}회)`);
scheduleRetry(5000);
return;
}
// Position unavailable -> soft retry (indoors/desktop common)
if (err.code === 2) {
retryCountRef.current += 1;
setGeoRetryCount(retryCountRef.current);
setErrorMsg(null);
setNoticeMsg(`위치 정보를 사용할 수 없습니다. GPS/네트워크 상태를 확인 중... (${retryCountRef.current}회 재시도)`);
scheduleRetry(8000);
return;
}
// Unknown error -> show message, but still retry once in a while
retryCountRef.current += 1;
setGeoRetryCount(retryCountRef.current);
let msg = "알 수 없는 오류가 발생했습니다. (" + (err.message || "unknown") + ")";
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
msg += " (주의: 보안 연결(HTTPS)이 아니면 위치 정보가 차단될 수 있습니다)";
}
setErrorMsg(msg);
scheduleRetry(10000);
},
{
enableHighAccuracy: highAccuracy,
maximumAge: 10000,
timeout: 15000 // 15 seconds timeout
}
profiles[profileIdx]?.options || { enableHighAccuracy: true, maximumAge: 0, timeout: 20000 }
);
} else {
setErrorMsg("Geolocation is not supported by your browser.");
}
};
startWatch(true); // Start with High Accuracy
startWatch(0); // Start with High Accuracy profile
return () => {
if (watchId) navigator.geolocation.clearWatch(watchId);
clearRetryTimer();
clearWatch();
};
}, []);
@@ -362,6 +437,14 @@
{/* Main Content Area */}
<main className="p-4 space-y-6">
{/* Notice Banner (soft errors / retry status) */}
{noticeMsg && (
<div className="bg-amber-50 border border-amber-200 text-amber-800 p-4 rounded-xl flex items-start gap-3 text-sm">
<i data-lucide="info" className="shrink-0 mt-0.5 w-4 h-4"></i>
<p>{noticeMsg}</p>
</div>
)}
{/* Error Banner */}
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-xl flex items-start gap-3 text-sm">
@@ -507,6 +590,9 @@
주의: HTTPS가 아니면 최신 브라우저에서 GPS가 차단됩니다.
</p>
)}
<p>위치 모드: {geoMode}</p>
<p>재시도 횟수: {geoRetryCount}</p>
<p>마지막 수신: {lastLocationAt ? new Date(lastLocationAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'}) : '없음'}</p>
<p>거리: {distance ? distance.toFixed(1) : '알 수 없음'} 미터</p>
<p>반경 제한: {office.allowedRadiusMeters} 미터</p>
<p>GPS 정확도: {currentLocation?.accuracy ? currentLocation.accuracy.toFixed(1) + 'm' : '알 수 없음'}</p>

View File

@@ -187,7 +187,7 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
<div class="nav-item dropdown flex-fill me-3">
<!-- 드롭다운 메뉴-->
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">
LOT&수입검사
LOT/수입
</a>
<div class="dropdown-menu">
<a class="dropdown-item" href="<?=$root_dir?>/prodcode/list.php?header=header">
@@ -215,7 +215,7 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
<div class="nav-item dropdown flex-fill me-3">
<!-- 드롭다운 메뉴-->
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">
품질관리
품질
</a>
<div class="dropdown-menu">
<a class="dropdown-item" href="<?=$root_dir?>/output/list_document.php">
@@ -262,7 +262,7 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
<div class="nav-item dropdown flex-fill me-3">
<!-- 드롭다운 메뉴-->
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">
절곡
절곡
</a>
<div class="dropdown-menu">
<a class="dropdown-item" href="<?=$root_dir?>/bending/list.php?header=header">
@@ -297,7 +297,7 @@ require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
<div class="nav-item dropdown flex-fill me-3">
<!-- 드롭다운 메뉴-->
<a class="nav-link dropdown-toggle" href="#" data-toggle="dropdown">
차량/지게차
차량
</a>
<div class="dropdown-menu">
<a class="dropdown-item" href="<?=$root_dir?>/car/list.php">

View File

@@ -1,19 +0,0 @@
<?php
function db_connect(){ //DB연결을 함수로 정의
$db_user ="juil"; //추가한 계정이름(사용자명)
$db_pass ="123456"; //비밀번호
$db_host ="localhost";
$db_name ="juildb";
$db_type ="mysql";
$dsn ="$db_type:host=$db_host;db_name=$db_name;charset=utf8";
try{
$pdo=new PDO($dsn,$db_user,$db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES,FALSE);
} catch (PDOException $Exception) {
die('오류:'.$Exception->getMessage());
}
return $pdo;
?>

View File

@@ -0,0 +1,88 @@
# Google Cloud Speech-to-Text API 전환 계획
## 목표
Web Speech API의 모바일 제한을 해결하고, Google Cloud Speech-to-Text API로 전환하여 모바일과 웹에서 모두 안정적으로 작동하도록 개선
## 현재 문제점
1. 모바일 Chrome에서 Web Speech API의 `onresult` 이벤트가 발생하지 않음
2. 웹 버전에서 텍스트 중복 문제 발생 (수정 완료)
## 해결 방안
### 1. Google Cloud Speech-to-Text API 사용
- **장점:**
- 모바일과 웹에서 모두 안정적으로 작동
- 더 높은 정확도
- 실시간 스트리밍 지원
- 한국어 지원 우수
- **단점:**
- 서버 측 구현 필요
- Google Cloud API 키 필요
- API 사용량에 따른 비용 발생 (무료 할당량 있음)
### 2. 구현 계획
#### 2.1 서버 측 (PHP)
- `api/speech_to_text.php` 생성
- Google Cloud Speech-to-Text API 연동
- 오디오 파일 수신 및 변환
- JSON 응답 반환
#### 2.2 클라이언트 측 (JavaScript)
- MediaRecorder API로 오디오 녹음
- 실시간 스트리밍 또는 청크 단위 전송
- 서버 응답 받아서 텍스트 표시
- 기존 UI/UX 유지
### 3. 구현 단계
#### Phase 1: 서버 측 API 구현
1. Google Cloud Speech-to-Text API 설정
2. PHP API 엔드포인트 생성
3. 오디오 파일 수신 및 변환 로직
#### Phase 2: 클라이언트 측 구현
1. MediaRecorder API로 오디오 녹음
2. 실시간 또는 청크 단위 전송
3. 서버 응답 처리 및 텍스트 표시
#### Phase 3: 통합 및 테스트
1. 모바일/웹 호환성 테스트
2. 에러 처리 강화
3. 사용자 경험 개선
### 4. 필요한 설정
#### Google Cloud 설정
1. Google Cloud 프로젝트 생성
2. Speech-to-Text API 활성화
3. 서비스 계정 키 생성
4. PHP에서 사용할 수 있도록 설정
#### 서버 설정
1. PHP cURL 확장 확인
2. Google Cloud PHP 클라이언트 라이브러리 설치 (선택사항)
3. API 키 또는 서비스 계정 키 파일 위치 설정
### 5. API 사용 방법
#### 실시간 스트리밍 (권장)
- WebSocket 또는 Server-Sent Events 사용
- 실시간으로 오디오 전송 및 텍스트 수신
#### 청크 단위 전송
- 일정 간격으로 오디오 청크 전송
- 각 청크에 대한 텍스트 수신 및 누적
### 6. 비용 고려사항
- Google Cloud Speech-to-Text 무료 할당량: 월 60분
- 이후 사용량에 따른 과금
- 대안: 무료 할당량 내에서 사용하거나 유료 플랜 고려
## 다음 단계
1. Google Cloud 프로젝트 설정 확인
2. 서버 측 API 엔드포인트 구현
3. 클라이언트 측 오디오 녹음 및 전송 로직 구현
4. 통합 테스트

104
voice/README_GOOGLE_API.md Normal file
View File

@@ -0,0 +1,104 @@
# Google Cloud Speech-to-Text API 실행 가이드
## 빠른 시작
### 1. Google Cloud API 키 설정
1. **설정 파일 생성**
```bash
# config/google_speech_api.php.example을 복사
cp config/google_speech_api.php.example config/google_speech_api.php
```
2. **Google Cloud Console에서 API 키 생성**
- https://console.cloud.google.com/ 접속
- 프로젝트 생성 또는 선택
- "API 및 서비스" > "라이브러리"
- "Cloud Speech-to-Text API" 검색 및 활성화
- "API 및 서비스" > "사용자 인증 정보" > "사용자 인증 정보 만들기" > "API 키"
- 생성된 API 키 복사
3. **설정 파일에 API 키 입력**
```php
<?php
$GOOGLE_SPEECH_API_KEY = '여기에_생성한_API_키_입력';
```
### 2. 실행 방법
1. **웹 서버 실행** (이미 실행 중이면 생략)
- Apache/Nginx 등 웹 서버가 실행 중이어야 합니다.
2. **브라우저에서 접속**
- `http://localhost/5130/voice/index.php` 또는 실제 서버 주소
3. **테스트**
- **웹**: Web Speech API 사용 (기존 방식)
- **모바일**: 자동으로 Google Cloud Speech-to-Text API 사용
### 3. 동작 방식
#### 웹 (데스크탑)
- Web Speech API 사용 (기존 방식 유지)
- 실시간 음성 인식
- 중복 텍스트 문제 해결됨
#### 모바일
- Google Cloud Speech-to-Text API 자동 사용
- MediaRecorder로 오디오 녹음
- 3초마다 서버로 전송하여 텍스트 변환
- 실시간 텍스트 업데이트
### 4. 문제 해결
#### API 키 오류
```
에러: Google Cloud Speech-to-Text API 키가 설정되지 않았습니다.
```
- `config/google_speech_api.php` 파일 확인
- API 키가 올바르게 설정되었는지 확인
- Google Cloud Console에서 API가 활성화되었는지 확인
#### 오디오 전송 실패
- 네트워크 연결 확인
- 서버 로그 확인 (`api/speech_to_text.php`의 에러 로그)
- 브라우저 콘솔 확인 (디버그 패널 사용)
#### 텍스트 변환 실패
- 마이크 권한 확인
- 오디오 형식 지원 확인 (webm, wav, ogg 등)
- Google Cloud API 할당량 확인 (무료: 월 60분)
### 5. 비용 정보
- **무료 할당량**: 월 60분
- **초과 시**: 사용량에 따른 과금
- **비용 절감**: 짧은 오디오만 전송하도록 최적화됨 (3초 청크)
### 6. 디버깅
디버그 패널 사용:
1. 우측 하단 디버그 버튼 클릭
2. 모든 로그 확인
3. 복사 버튼으로 로그 복사하여 공유
### 7. 파일 구조
```
5130/voice/
├── index.php (메인 페이지)
├── api/
│ └── speech_to_text.php (Google API 엔드포인트)
├── config/
│ └── google_speech_api.php (API 키 설정 - 생성 필요)
└── GOOGLE_SPEECH_API_PLAN.md (상세 계획)
```
## 다음 단계
1. ✅ API 키 설정
2. ✅ 웹에서 테스트
3. ✅ 모바일에서 테스트
4. ✅ 정확도 확인
5. ✅ 필요시 추가 최적화

View File

@@ -0,0 +1,511 @@
<?php
/**
* Google Cloud Speech-to-Text API 엔드포인트
* 오디오 파일을 받아서 텍스트로 변환
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST');
header('Access-Control-Allow-Headers: Content-Type');
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
// 권한 체크
if ($level > 5) {
echo json_encode([
'success' => false,
'error' => '접근 권한이 없습니다.'
]);
exit;
}
// POST 요청만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode([
'success' => false,
'error' => 'POST 요청만 허용됩니다.'
]);
exit;
}
// 오디오 파일 확인
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
echo json_encode([
'success' => false,
'error' => '오디오 파일이 전송되지 않았습니다.'
]);
exit;
}
$audioFile = $_FILES['audio'];
$audioPath = $audioFile['tmp_name'];
$audioType = $audioFile['type'];
// 오디오 파일 유효성 검사
$allowedTypes = ['audio/webm', 'audio/wav', 'audio/ogg', 'audio/mp3', 'audio/mpeg', 'audio/x-flac'];
if (!in_array($audioType, $allowedTypes)) {
echo json_encode([
'success' => false,
'error' => '지원하지 않는 오디오 형식입니다. (webm, wav, ogg, mp3, flac 지원)'
]);
exit;
}
// Google Cloud Speech-to-Text API 설정
$googleApiKey = '';
$accessToken = null; // 서비스 계정용 OAuth 토큰
$useServiceAccount = false;
$googleApiUrl = 'https://speech.googleapis.com/v1/speech:recognize';
// API 키 파일 경로 (우선순위 순)
// 상대 경로와 절대 경로 모두 확인
$docRoot = $_SERVER['DOCUMENT_ROOT'];
$apiKeyPaths = [
$docRoot . '/5130/apikey/google_api.txt',
$docRoot . '/apikey/google_api.txt',
dirname($docRoot) . '/5130/apikey/google_api.txt', // 상위 디렉토리에서 확인
dirname($docRoot) . '/apikey/google_api.txt',
__DIR__ . '/../../apikey/google_api.txt', // 현재 파일 기준 상대 경로
__DIR__ . '/../../../apikey/google_api.txt',
$docRoot . '/5130/config/google_speech_api.php',
$docRoot . '/config/google_speech_api.php',
];
// 서비스 계정 파일 경로
$serviceAccountPaths = [
$docRoot . '/5130/apikey/google_service_account.json',
$docRoot . '/apikey/google_service_account.json',
dirname($docRoot) . '/5130/apikey/google_service_account.json',
dirname($docRoot) . '/apikey/google_service_account.json',
__DIR__ . '/../../apikey/google_service_account.json',
__DIR__ . '/../../../apikey/google_service_account.json',
];
// API 키 파일에서 읽기
$foundKeyPath = null;
$checkedPaths = [];
foreach ($apiKeyPaths as $keyPath) {
$checkedPaths[] = $keyPath . ' (exists: ' . (file_exists($keyPath) ? 'yes' : 'no') . ')';
if (file_exists($keyPath)) {
$foundKeyPath = $keyPath;
if (pathinfo($keyPath, PATHINFO_EXTENSION) === 'php') {
// PHP 설정 파일인 경우
require_once($keyPath);
if (isset($GOOGLE_SPEECH_API_KEY)) {
$googleApiKey = trim($GOOGLE_SPEECH_API_KEY);
break;
}
} else {
// 텍스트 파일인 경우 (google_api.txt)
$keyContent = file_get_contents($keyPath);
$googleApiKey = trim($keyContent);
// 빈 줄이나 주석 제거
$lines = explode("\n", $googleApiKey);
$googleApiKey = '';
foreach ($lines as $line) {
$line = trim($line);
if (!empty($line) && !preg_match('/^#/', $line)) {
$googleApiKey = $line;
break;
}
}
if (!empty($googleApiKey)) {
break;
}
}
}
}
// 서비스 계정 파일 찾기
$serviceAccountPath = null;
foreach ($serviceAccountPaths as $saPath) {
if (file_exists($saPath)) {
$serviceAccountPath = $saPath;
break;
}
}
// OAuth 2.0 토큰 생성 함수 (서비스 계정용)
function getServiceAccountToken($serviceAccountPath) {
if (!file_exists($serviceAccountPath)) {
return null;
}
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
if (!$serviceAccount || !isset($serviceAccount['private_key']) || !isset($serviceAccount['client_email'])) {
return null;
}
// Base64 URL 인코딩 함수
$base64UrlEncode = function($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
};
// JWT 생성
$now = time();
// JWT 헤더
$jwtHeader = [
'alg' => 'RS256',
'typ' => 'JWT'
];
// JWT 클레임
$jwtClaim = [
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
];
$encodedHeader = $base64UrlEncode(json_encode($jwtHeader));
$encodedClaim = $base64UrlEncode(json_encode($jwtClaim));
// 서명 생성
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if (!$privateKey) {
return null;
}
$signature = '';
$signData = $encodedHeader . '.' . $encodedClaim;
if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
openssl_free_key($privateKey);
return null;
}
openssl_free_key($privateKey);
$encodedSignature = $base64UrlEncode($signature);
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
// OAuth 2.0 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$tokenResponse = curl_exec($tokenCh);
$tokenHttpCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenHttpCode === 200) {
$tokenData = json_decode($tokenResponse, true);
if (isset($tokenData['access_token'])) {
return $tokenData['access_token'];
}
}
return null;
}
// API 키가 없거나 형식이 잘못된 경우 서비스 계정 시도
if (empty($googleApiKey) || strlen($googleApiKey) < 20) {
if ($serviceAccountPath) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
}
}
if (!$useServiceAccount) {
echo json_encode([
'success' => false,
'error' => 'Google Cloud Speech-to-Text API 인증 정보가 없습니다.',
'hint' => 'API 키 파일 또는 서비스 계정 파일을 찾을 수 없습니다.',
'checked_paths' => $checkedPaths,
'service_account_path' => $serviceAccountPath,
'document_root' => $docRoot,
'current_dir' => __DIR__
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
}
// API 키 형식 확인 (Google API 키는 보통 AIza로 시작)
$isValidFormat = (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35);
if (!$isValidFormat && !$useServiceAccount) {
// API 키 형식이 잘못된 경우 서비스 계정 시도
if ($serviceAccountPath) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
$googleApiKey = ''; // API 키 사용 안 함
}
}
}
// 서비스 계정이 있으면 우선 사용 (API 키보다 안정적)
// API 키는 간헐적으로 오류가 발생할 수 있으므로 서비스 계정을 기본으로 사용
if (!$useServiceAccount && $serviceAccountPath && file_exists($serviceAccountPath)) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
// API 키는 백업으로 유지하되 서비스 계정 우선 사용
error_log('Using service account for authentication (more reliable than API key)');
}
}
// API 키 디버깅 정보
$debugInfo = [
'api_key_path' => $foundKeyPath,
'api_key_length' => strlen($googleApiKey),
'api_key_prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'api_key_suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'use_service_account' => $useServiceAccount,
'service_account_path' => $serviceAccountPath,
'document_root' => $docRoot,
'checked_paths' => $checkedPaths
];
try {
// 오디오 파일을 base64로 인코딩
$audioContent = file_get_contents($audioPath);
$audioBase64 = base64_encode($audioContent);
// 오디오 형식에 따른 encoding 설정
$encoding = 'WEBM_OPUS';
$sampleRate = 16000; // 기본값
if (strpos($audioType, 'wav') !== false) {
$encoding = 'LINEAR16';
$sampleRate = 16000;
} elseif (strpos($audioType, 'ogg') !== false) {
$encoding = 'OGG_OPUS';
$sampleRate = 48000; // Opus는 보통 48kHz
} elseif (strpos($audioType, 'flac') !== false) {
$encoding = 'FLAC';
$sampleRate = 16000;
} elseif (strpos($audioType, 'webm') !== false) {
$encoding = 'WEBM_OPUS';
$sampleRate = 48000; // WebM Opus는 보통 48kHz
}
// Google Cloud Speech-to-Text API 요청 데이터
$requestData = [
'config' => [
'encoding' => $encoding,
'sampleRateHertz' => $sampleRate,
'languageCode' => 'ko-KR',
'enableAutomaticPunctuation' => true,
'enableWordTimeOffsets' => false,
'model' => 'latest_long', // 긴 오디오에 적합
'alternativeLanguageCodes' => ['ko'], // 추가 언어 코드
],
'audio' => [
'content' => $audioBase64
]
];
// 디버깅 정보 (개발 환경에서만)
if (isset($_GET['debug'])) {
error_log('Google Speech API Request: ' . json_encode([
'encoding' => $encoding,
'sampleRate' => $sampleRate,
'audioSize' => strlen($audioBase64),
'audioType' => $audioType
]));
}
// cURL로 API 호출
$headers = ['Content-Type: application/json'];
$apiUrl = $googleApiUrl;
if ($useServiceAccount && $accessToken) {
// 서비스 계정 사용: Bearer 토큰 사용
$headers[] = 'Authorization: Bearer ' . $accessToken;
} else if (!empty($googleApiKey)) {
// API 키 사용: 쿼리 파라미터로 전달
$apiUrl = $googleApiUrl . '?key=' . urlencode($googleApiKey);
} else {
throw new Exception('인증 정보가 없습니다. API 키 또는 서비스 계정이 필요합니다.');
}
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Exception('cURL 오류: ' . $curlError);
}
if ($httpCode !== 200) {
$errorData = json_decode($response, true);
$errorMessage = 'API 오류 (HTTP ' . $httpCode . ')';
$debugInfo = [];
if (isset($errorData['error'])) {
$errorMessage .= ': ' . ($errorData['error']['message'] ?? '알 수 없는 오류');
$debugInfo['error_details'] = $errorData['error'];
// API 키 오류인 경우 서비스 계정으로 재시도
if (isset($errorData['error']['message']) &&
(stripos($errorData['error']['message'], 'API key') !== false ||
stripos($errorData['error']['message'], 'not valid') !== false ||
stripos($errorData['error']['message'], 'invalid') !== false ||
stripos($errorData['error']['message'], 'unauthorized') !== false)) {
// 서비스 계정으로 재시도
if ($serviceAccountPath && file_exists($serviceAccountPath)) {
// 서비스 계정을 사용 중이었는데도 오류가 발생하면 토큰 재생성
if ($useServiceAccount) {
error_log('Service account token may have expired, regenerating...');
}
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
// 서비스 계정으로 재시도
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken
];
$ch = curl_init($googleApiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
// 서비스 계정으로 성공
$result = json_decode($response, true);
if (isset($result['results']) && count($result['results']) > 0) {
$transcript = '';
foreach ($result['results'] as $resultItem) {
if (isset($resultItem['alternatives'][0]['transcript'])) {
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
echo json_encode([
'success' => true,
'transcript' => $transcript,
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
? $result['results'][0]['alternatives'][0]['confidence']
: null,
'auth_method' => 'service_account',
'retry_success' => true
]);
exit;
}
}
}
}
// 재시도 실패 시 상세 안내
$errorMessage .= "\n\n⚠️ API 키 오류 해결 방법:\n";
$errorMessage .= "1. Google Cloud Console (https://console.cloud.google.com/) 접속\n";
$errorMessage .= "2. 프로젝트 선택: codebridge-chatbot\n";
$errorMessage .= "3. 'API 및 서비스' > '라이브러리'에서 'Cloud Speech-to-Text API' 검색 및 활성화\n";
$errorMessage .= "4. 'API 및 서비스' > '사용자 인증 정보'에서 API 키 확인\n";
$errorMessage .= "5. API 키 제한 설정에서 'Cloud Speech-to-Text API' 허용 확인\n";
$errorMessage .= "\n현재 API 키 파일: " . ($foundKeyPath ?? 'N/A');
$errorMessage .= "\nAPI 키 길이: " . strlen($googleApiKey) . " 문자";
if ($serviceAccountPath) {
$errorMessage .= "\n서비스 계정 파일: " . $serviceAccountPath;
}
}
} else {
$errorMessage .= ': ' . substr($response, 0, 200);
}
// 디버그 정보에 API 키 정보 추가
$debugInfo['api_key_info'] = [
'path' => $foundKeyPath,
'length' => strlen($googleApiKey),
'prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'format_valid' => !empty($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35) : false,
'use_service_account' => $useServiceAccount,
'service_account_path' => $serviceAccountPath
];
if (isset($_GET['debug'])) {
$debugInfo['full_response'] = $response;
$debugInfo['request_url'] = $useServiceAccount ? $googleApiUrl : ($googleApiUrl . '?key=' . substr($googleApiKey, 0, 10) . '...');
}
throw new Exception($errorMessage);
}
$result = json_decode($response, true);
// 결과 처리
if (isset($result['results']) && count($result['results']) > 0) {
$transcript = '';
foreach ($result['results'] as $resultItem) {
if (isset($resultItem['alternatives'][0]['transcript'])) {
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
echo json_encode([
'success' => true,
'transcript' => $transcript,
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
? $result['results'][0]['alternatives'][0]['confidence']
: null,
'auth_method' => $useServiceAccount ? 'service_account' : 'api_key'
]);
} else {
echo json_encode([
'success' => false,
'error' => '음성을 인식할 수 없었습니다.',
'debug' => $result
]);
}
} catch (Exception $e) {
$errorResponse = [
'success' => false,
'error' => $e->getMessage()
];
// 항상 API 키 정보 포함 (보안을 위해 일부만 표시)
$errorResponse['debug'] = [
'api_key_path' => $foundKeyPath ?? null,
'api_key_length' => isset($googleApiKey) ? strlen($googleApiKey) : 0,
'api_key_prefix' => isset($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'api_key_suffix' => isset($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'api_key_format' => isset($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 ? 'Google API Key (AIza...)' : 'Other format') : 'N/A',
'use_service_account' => isset($useServiceAccount) ? $useServiceAccount : false,
'service_account_path' => isset($serviceAccountPath) ? $serviceAccountPath : null,
'document_root' => $docRoot ?? null,
'checked_paths_count' => count($checkedPaths ?? [])
];
// 상세 디버그 모드일 때 추가 정보
if (isset($_GET['debug'])) {
$errorResponse['debug']['exception_class'] = get_class($e);
$errorResponse['debug']['file'] = $e->getFile();
$errorResponse['debug']['line'] = $e->getLine();
$errorResponse['debug']['checked_paths'] = $checkedPaths ?? [];
}
echo json_encode($errorResponse, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}

View File

@@ -638,6 +638,15 @@ function cleanup() {
## 버전 히스토리
### 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 기반 실시간 음성 인식
- ✅ 오디오 파형 시각화
@@ -679,6 +688,534 @@ function cleanup() {
---
**문서 버전**: 1.0
**최종 업데이트**: 2025-11-05
---
## 모바일 환경 지원 (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
<!-- 모바일 햄버거 메뉴 버튼 -->
<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">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="mobile-menu-content">
<nav class="navbar-nav">
<!-- 원본 nav 내용이 여기에 복사됨 -->
</nav>
</div>
</div>
```
**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 = '<i class="bi bi-x-lg"></i>'; // X 아이콘으로 변경
}
function closeMenu() {
menuSidebar.classList.remove('active');
menuOverlay.style.display = 'none';
document.body.style.overflow = ''; // 스크롤 복원
menuToggle.innerHTML = '<i class="bi bi-list"></i>'; // 햄버거 아이콘으로 변경
}
menuToggle.addEventListener('click', toggleMenu);
menuOverlay.addEventListener('click', closeMenu);
}
```
#### 4. 모바일 디버그 패널
**목적**: 모바일 환경에서 브라우저 콘솔을 볼 수 없어 디버깅이 어려운 문제를 해결하기 위해 온스크린 디버그 패널을 구현했습니다.
**HTML 구조:**
```html
<!-- 디버그 패널 토글 버튼 -->
<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>
</div>
```
**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 = `<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;
}
// 로그 복사
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 = '<i class="bi bi-check"></i>';
debugCopyBtn.style.background = 'rgba(76, 175, 80, 0.3)';
setTimeout(() => {
debugCopyBtn.innerHTML = '<i class="bi bi-clipboard"></i>';
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
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
```
**반응형 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,527 @@
<?php
/**
* Google Cloud Speech-to-Text API 엔드포인트
* 오디오 파일을 받아서 텍스트로 변환
*/
// 타임아웃 설정 (504 오류 방지)
ini_set('max_execution_time', 120); // 120초
set_time_limit(120);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST');
header('Access-Control-Allow-Headers: Content-Type');
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
// 권한 체크
if ($level > 5) {
echo json_encode([
'success' => false,
'error' => '접근 권한이 없습니다.'
]);
exit;
}
// 세션 쓰기 닫기 (이후 긴 작업 시 세션 잠금 방지)
session_write_close();
// POST 요청만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode([
'success' => false,
'error' => 'POST 요청만 허용됩니다.'
]);
exit;
}
// 오디오 파일 확인
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
echo json_encode([
'success' => false,
'error' => '오디오 파일이 전송되지 않았습니다.'
]);
exit;
}
$audioFile = $_FILES['audio'];
$audioPath = $audioFile['tmp_name'];
$audioType = $audioFile['type'];
// 오디오 파일 유효성 검사
$allowedTypes = ['audio/webm', 'audio/wav', 'audio/ogg', 'audio/mp3', 'audio/mpeg', 'audio/x-flac'];
if (!in_array($audioType, $allowedTypes)) {
echo json_encode([
'success' => false,
'error' => '지원하지 않는 오디오 형식입니다. (webm, wav, ogg, mp3, flac 지원)'
]);
exit;
}
// Google Cloud Speech-to-Text API 설정
$googleApiKey = '';
$accessToken = null; // 서비스 계정용 OAuth 토큰
$useServiceAccount = false;
$googleApiUrl = 'https://speech.googleapis.com/v1/speech:recognize';
// API 키 파일 경로 (우선순위 순)
// 상대 경로와 절대 경로 모두 확인
$docRoot = $_SERVER['DOCUMENT_ROOT'];
$apiKeyPaths = [
$docRoot . '/5130/apikey/google_api.txt',
$docRoot . '/apikey/google_api.txt',
dirname($docRoot) . '/5130/apikey/google_api.txt', // 상위 디렉토리에서 확인
dirname($docRoot) . '/apikey/google_api.txt',
__DIR__ . '/../../apikey/google_api.txt', // 현재 파일 기준 상대 경로
__DIR__ . '/../../../apikey/google_api.txt',
$docRoot . '/5130/config/google_speech_api.php',
$docRoot . '/config/google_speech_api.php',
];
// 서비스 계정 파일 경로
$serviceAccountPaths = [
$docRoot . '/5130/apikey/google_service_account.json',
$docRoot . '/apikey/google_service_account.json',
dirname($docRoot) . '/5130/apikey/google_service_account.json',
dirname($docRoot) . '/apikey/google_service_account.json',
__DIR__ . '/../../apikey/google_service_account.json',
__DIR__ . '/../../../apikey/google_service_account.json',
];
// API 키 파일에서 읽기
$foundKeyPath = null;
$checkedPaths = [];
foreach ($apiKeyPaths as $keyPath) {
$checkedPaths[] = $keyPath . ' (exists: ' . (file_exists($keyPath) ? 'yes' : 'no') . ')';
if (file_exists($keyPath)) {
$foundKeyPath = $keyPath;
if (pathinfo($keyPath, PATHINFO_EXTENSION) === 'php') {
// PHP 설정 파일인 경우
require_once($keyPath);
if (isset($GOOGLE_SPEECH_API_KEY)) {
$googleApiKey = trim($GOOGLE_SPEECH_API_KEY);
break;
}
} else {
// 텍스트 파일인 경우 (google_api.txt)
$keyContent = file_get_contents($keyPath);
$googleApiKey = trim($keyContent);
// 빈 줄이나 주석 제거
$lines = explode("\n", $googleApiKey);
$googleApiKey = '';
foreach ($lines as $line) {
$line = trim($line);
if (!empty($line) && !preg_match('/^#/', $line)) {
$googleApiKey = $line;
break;
}
}
if (!empty($googleApiKey)) {
break;
}
}
}
}
// 서비스 계정 파일 찾기
$serviceAccountPath = null;
foreach ($serviceAccountPaths as $saPath) {
if (file_exists($saPath)) {
$serviceAccountPath = $saPath;
break;
}
}
// OAuth 2.0 토큰 생성 함수 (서비스 계정용)
function getServiceAccountToken($serviceAccountPath) {
if (!file_exists($serviceAccountPath)) {
return null;
}
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
if (!$serviceAccount || !isset($serviceAccount['private_key']) || !isset($serviceAccount['client_email'])) {
return null;
}
// Base64 URL 인코딩 함수
$base64UrlEncode = function($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
};
// JWT 생성
$now = time();
// JWT 헤더
$jwtHeader = [
'alg' => 'RS256',
'typ' => 'JWT'
];
// JWT 클레임
$jwtClaim = [
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
];
$encodedHeader = $base64UrlEncode(json_encode($jwtHeader));
$encodedClaim = $base64UrlEncode(json_encode($jwtClaim));
// 서명 생성
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if (!$privateKey) {
return null;
}
$signature = '';
$signData = $encodedHeader . '.' . $encodedClaim;
if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
openssl_free_key($privateKey);
return null;
}
openssl_free_key($privateKey);
$encodedSignature = $base64UrlEncode($signature);
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
// OAuth 2.0 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
// 타임아웃 설정
curl_setopt($tokenCh, CURLOPT_TIMEOUT, 30); // 30초
curl_setopt($tokenCh, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
$tokenResponse = curl_exec($tokenCh);
$tokenHttpCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenHttpCode === 200) {
$tokenData = json_decode($tokenResponse, true);
if (isset($tokenData['access_token'])) {
return $tokenData['access_token'];
}
}
return null;
}
// API 키가 없거나 형식이 잘못된 경우 서비스 계정 시도
if (empty($googleApiKey) || strlen($googleApiKey) < 20) {
if ($serviceAccountPath) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
}
}
if (!$useServiceAccount) {
echo json_encode([
'success' => false,
'error' => 'Google Cloud Speech-to-Text API 인증 정보가 없습니다.',
'hint' => 'API 키 파일 또는 서비스 계정 파일을 찾을 수 없습니다.',
'checked_paths' => $checkedPaths,
'service_account_path' => $serviceAccountPath,
'document_root' => $docRoot,
'current_dir' => __DIR__
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
}
// API 키 형식 확인 (Google API 키는 보통 AIza로 시작)
$isValidFormat = (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35);
if (!$isValidFormat && !$useServiceAccount) {
// API 키 형식이 잘못된 경우 서비스 계정 시도
if ($serviceAccountPath) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
$googleApiKey = ''; // API 키 사용 안 함
}
}
}
// 서비스 계정이 있으면 우선 사용 (API 키보다 안정적)
// API 키는 간헐적으로 오류가 발생할 수 있으므로 서비스 계정을 기본으로 사용
if (!$useServiceAccount && $serviceAccountPath && file_exists($serviceAccountPath)) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
// API 키는 백업으로 유지하되 서비스 계정 우선 사용
error_log('Using service account for authentication (more reliable than API key)');
}
}
// API 키 디버깅 정보
$debugInfo = [
'api_key_path' => $foundKeyPath,
'api_key_length' => strlen($googleApiKey),
'api_key_prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'api_key_suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'use_service_account' => $useServiceAccount,
'service_account_path' => $serviceAccountPath,
'document_root' => $docRoot,
'checked_paths' => $checkedPaths
];
try {
// 오디오 파일을 base64로 인코딩
$audioContent = file_get_contents($audioPath);
$audioBase64 = base64_encode($audioContent);
// 오디오 형식에 따른 encoding 설정
$encoding = 'WEBM_OPUS';
$sampleRate = 16000; // 기본값
if (strpos($audioType, 'wav') !== false) {
$encoding = 'LINEAR16';
$sampleRate = 16000;
} elseif (strpos($audioType, 'ogg') !== false) {
$encoding = 'OGG_OPUS';
$sampleRate = 48000; // Opus는 보통 48kHz
} elseif (strpos($audioType, 'flac') !== false) {
$encoding = 'FLAC';
$sampleRate = 16000;
} elseif (strpos($audioType, 'webm') !== false) {
$encoding = 'WEBM_OPUS';
$sampleRate = 48000; // WebM Opus는 보통 48kHz
}
// Google Cloud Speech-to-Text API 요청 데이터
$requestData = [
'config' => [
'encoding' => $encoding,
'sampleRateHertz' => $sampleRate,
'languageCode' => 'ko-KR',
'enableAutomaticPunctuation' => true,
'enableWordTimeOffsets' => false,
'model' => 'latest_long', // 긴 오디오에 적합
'alternativeLanguageCodes' => ['ko'], // 추가 언어 코드
],
'audio' => [
'content' => $audioBase64
]
];
// 디버깅 정보 (개발 환경에서만)
if (isset($_GET['debug'])) {
error_log('Google Speech API Request: ' . json_encode([
'encoding' => $encoding,
'sampleRate' => $sampleRate,
'audioSize' => strlen($audioBase64),
'audioType' => $audioType
]));
}
// cURL로 API 호출
$headers = ['Content-Type: application/json'];
$apiUrl = $googleApiUrl;
if ($useServiceAccount && $accessToken) {
// 서비스 계정 사용: Bearer 토큰 사용
$headers[] = 'Authorization: Bearer ' . $accessToken;
} else if (!empty($googleApiKey)) {
// API 키 사용: 쿼리 파라미터로 전달
$apiUrl = $googleApiUrl . '?key=' . urlencode($googleApiKey);
} else {
throw new Exception('인증 정보가 없습니다. API 키 또는 서비스 계정이 필요합니다.');
}
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// 타임아웃 설정 (504 오류 방지)
curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 60초
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Exception('cURL 오류: ' . $curlError);
}
if ($httpCode !== 200) {
$errorData = json_decode($response, true);
$errorMessage = 'API 오류 (HTTP ' . $httpCode . ')';
$debugInfo = [];
if (isset($errorData['error'])) {
$errorMessage .= ': ' . ($errorData['error']['message'] ?? '알 수 없는 오류');
$debugInfo['error_details'] = $errorData['error'];
// API 키 오류인 경우 서비스 계정으로 재시도
if (isset($errorData['error']['message']) &&
(stripos($errorData['error']['message'], 'API key') !== false ||
stripos($errorData['error']['message'], 'not valid') !== false ||
stripos($errorData['error']['message'], 'invalid') !== false ||
stripos($errorData['error']['message'], 'unauthorized') !== false)) {
// 서비스 계정으로 재시도
if ($serviceAccountPath && file_exists($serviceAccountPath)) {
// 서비스 계정을 사용 중이었는데도 오류가 발생하면 토큰 재생성
if ($useServiceAccount) {
error_log('Service account token may have expired, regenerating...');
}
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
// 서비스 계정으로 재시도
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken
];
$ch = curl_init($googleApiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// 타임아웃 설정 (504 오류 방지)
curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 60초
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
// 서비스 계정으로 성공
$result = json_decode($response, true);
if (isset($result['results']) && count($result['results']) > 0) {
$transcript = '';
foreach ($result['results'] as $resultItem) {
if (isset($resultItem['alternatives'][0]['transcript'])) {
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
echo json_encode([
'success' => true,
'transcript' => $transcript,
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
? $result['results'][0]['alternatives'][0]['confidence']
: null,
'auth_method' => 'service_account',
'retry_success' => true
]);
exit;
}
}
}
}
// 재시도 실패 시 상세 안내
$errorMessage .= "\n\n⚠️ API 키 오류 해결 방법:\n";
$errorMessage .= "1. Google Cloud Console (https://console.cloud.google.com/) 접속\n";
$errorMessage .= "2. 프로젝트 선택: codebridge-chatbot\n";
$errorMessage .= "3. 'API 및 서비스' > '라이브러리'에서 'Cloud Speech-to-Text API' 검색 및 활성화\n";
$errorMessage .= "4. 'API 및 서비스' > '사용자 인증 정보'에서 API 키 확인\n";
$errorMessage .= "5. API 키 제한 설정에서 'Cloud Speech-to-Text API' 허용 확인\n";
$errorMessage .= "\n현재 API 키 파일: " . ($foundKeyPath ?? 'N/A');
$errorMessage .= "\nAPI 키 길이: " . strlen($googleApiKey) . " 문자";
if ($serviceAccountPath) {
$errorMessage .= "\n서비스 계정 파일: " . $serviceAccountPath;
}
}
} else {
$errorMessage .= ': ' . substr($response, 0, 200);
}
// 디버그 정보에 API 키 정보 추가
$debugInfo['api_key_info'] = [
'path' => $foundKeyPath,
'length' => strlen($googleApiKey),
'prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'format_valid' => !empty($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35) : false,
'use_service_account' => $useServiceAccount,
'service_account_path' => $serviceAccountPath
];
if (isset($_GET['debug'])) {
$debugInfo['full_response'] = $response;
$debugInfo['request_url'] = $useServiceAccount ? $googleApiUrl : ($googleApiUrl . '?key=' . substr($googleApiKey, 0, 10) . '...');
}
throw new Exception($errorMessage);
}
$result = json_decode($response, true);
// 결과 처리
if (isset($result['results']) && count($result['results']) > 0) {
$transcript = '';
foreach ($result['results'] as $resultItem) {
if (isset($resultItem['alternatives'][0]['transcript'])) {
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
echo json_encode([
'success' => true,
'transcript' => $transcript,
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
? $result['results'][0]['alternatives'][0]['confidence']
: null,
'auth_method' => $useServiceAccount ? 'service_account' : 'api_key'
]);
} else {
echo json_encode([
'success' => false,
'error' => '음성을 인식할 수 없었습니다.',
'debug' => $result
]);
}
} catch (Exception $e) {
$errorResponse = [
'success' => false,
'error' => $e->getMessage()
];
// 항상 API 키 정보 포함 (보안을 위해 일부만 표시)
$errorResponse['debug'] = [
'api_key_path' => $foundKeyPath ?? null,
'api_key_length' => isset($googleApiKey) ? strlen($googleApiKey) : 0,
'api_key_prefix' => isset($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'api_key_suffix' => isset($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'api_key_format' => isset($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 ? 'Google API Key (AIza...)' : 'Other format') : 'N/A',
'use_service_account' => isset($useServiceAccount) ? $useServiceAccount : false,
'service_account_path' => isset($serviceAccountPath) ? $serviceAccountPath : null,
'document_root' => $docRoot ?? null,
'checked_paths_count' => count($checkedPaths ?? [])
];
// 상세 디버그 모드일 때 추가 정보
if (isset($_GET['debug'])) {
$errorResponse['debug']['exception_class'] = get_class($e);
$errorResponse['debug']['file'] = $e->getFile();
$errorResponse['debug']['line'] = $e->getLine();
$errorResponse['debug']['checked_paths'] = $checkedPaths ?? [];
}
echo json_encode($errorResponse, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}

View File

@@ -0,0 +1,370 @@
<?php
// 출력 버퍼링 시작
while (ob_get_level()) {
ob_end_clean();
}
ob_start();
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 에러 응답 함수
function sendErrorResponse($message, $details = null) {
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
$response = ['ok' => false, 'error' => $message];
if ($details !== null) {
$response['details'] = $details;
}
echo json_encode($response, JSON_UNESCAPED_UNICODE);
exit;
}
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
// 1. 권한 체크
if (!isset($user_id) || $level > 5) {
sendErrorResponse('접근 권한이 없습니다.');
}
// 2. 파라미터 확인
$meeting_id = isset($_GET['meeting_id']) ? (int)$_GET['meeting_id'] : 0;
$operation_name = isset($_GET['operation_name']) ? trim($_GET['operation_name']) : '';
if (!$meeting_id || !$operation_name) {
sendErrorResponse('필수 파라미터가 없습니다.', 'meeting_id와 operation_name이 필요합니다.');
}
// 3. Google API 인증 정보 가져오기
$googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
$googleApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_api.txt';
$accessToken = null;
$googleApiKey = null;
// 서비스 계정 우선 사용
if (file_exists($googleServiceAccountFile)) {
$serviceAccount = json_decode(file_get_contents($googleServiceAccountFile), true);
if ($serviceAccount) {
// OAuth 2.0 토큰 생성
$now = time();
$jwtHeader = ['alg' => 'RS256', 'typ' => 'JWT'];
$jwtClaim = [
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
];
$base64UrlEncode = function($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
};
$encodedHeader = $base64UrlEncode(json_encode($jwtHeader));
$encodedClaim = $base64UrlEncode(json_encode($jwtClaim));
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if ($privateKey) {
$signature = '';
$signData = $encodedHeader . '.' . $encodedClaim;
if (openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
openssl_free_key($privateKey);
$encodedSignature = $base64UrlEncode($signature);
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
// OAuth 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$tokenResponse = curl_exec($tokenCh);
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenCode === 200) {
$tokenData = json_decode($tokenResponse, true);
if (isset($tokenData['access_token'])) {
$accessToken = $tokenData['access_token'];
}
}
}
}
}
}
// API 키 사용
if (!$accessToken && file_exists($googleApiKeyFile)) {
$googleApiKey = trim(file_get_contents($googleApiKeyFile));
}
if (!$accessToken && !$googleApiKey) {
sendErrorResponse('Google API 인증 정보가 없습니다.');
}
// 4. Google API에서 작업 상태 확인
if ($accessToken) {
$poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name);
$poll_headers = ['Authorization: Bearer ' . $accessToken];
} else {
$poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name) . '?key=' . urlencode($googleApiKey);
$poll_headers = [];
}
$poll_ch = curl_init($poll_url);
curl_setopt($poll_ch, CURLOPT_RETURNTRANSFER, true);
if (!empty($poll_headers)) {
curl_setopt($poll_ch, CURLOPT_HTTPHEADER, $poll_headers);
}
curl_setopt($poll_ch, CURLOPT_TIMEOUT, 30);
$poll_response = curl_exec($poll_ch);
$poll_code = curl_getinfo($poll_ch, CURLINFO_HTTP_CODE);
$poll_error = curl_error($poll_ch);
curl_close($poll_ch);
if ($poll_code !== 200) {
sendErrorResponse('작업 상태 확인 실패 (HTTP ' . $poll_code . ')', $poll_error ?: substr($poll_response, 0, 500));
}
$poll_data = json_decode($poll_response, true);
if (!$poll_data) {
sendErrorResponse('작업 상태 응답 파싱 실패', substr($poll_response, 0, 500));
}
// 5. 작업 완료 여부 확인
if (!isset($poll_data['done']) || $poll_data['done'] !== true) {
// 아직 처리 중
echo json_encode([
'ok' => true,
'processing' => true,
'done' => false,
'message' => '음성 인식 처리 중입니다. 잠시 후 다시 확인해주세요.'
], JSON_UNESCAPED_UNICODE);
exit;
}
// 6. 작업 완료 - 결과 처리
if (isset($poll_data['error'])) {
// 오류 발생
$pdo = db_connect();
$errorMsg = isset($poll_data['error']['message']) ? $poll_data['error']['message'] : '알 수 없는 오류';
$updateSql = "UPDATE meeting_logs SET
title = ?,
summary_text = ?
WHERE id = ?";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute(['오류 발생', 'Google STT 변환 실패: ' . $errorMsg, $meeting_id]);
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패',
'details' => $errorMsg
], JSON_UNESCAPED_UNICODE);
exit;
}
// 응답 구조 확인 및 로깅
error_log('Google STT 응답 구조: ' . json_encode([
'has_response' => isset($poll_data['response']),
'has_results' => isset($poll_data['response']['results']),
'results_count' => isset($poll_data['response']['results']) ? count($poll_data['response']['results']) : 0,
'response_keys' => isset($poll_data['response']) ? array_keys($poll_data['response']) : [],
'poll_data_keys' => array_keys($poll_data)
], JSON_UNESCAPED_UNICODE));
// response 필드가 없는 경우
if (!isset($poll_data['response'])) {
error_log('Google STT 응답에 response 필드가 없습니다. 전체 응답: ' . json_encode($poll_data, JSON_UNESCAPED_UNICODE));
sendErrorResponse('Google STT 응답 구조 오류', '응답에 response 필드가 없습니다. 작업이 완료되었지만 결과를 가져올 수 없습니다.');
}
// results가 없는 경우
if (!isset($poll_data['response']['results'])) {
error_log('Google STT 응답에 results 필드가 없습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE));
sendErrorResponse('Google STT 응답에 결과가 없습니다.', '응답에 results 필드가 없습니다. 음성이 인식되지 않았을 수 있습니다.');
}
// results가 비어있는 경우
if (empty($poll_data['response']['results'])) {
error_log('Google STT 응답에 results가 비어있습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE));
// 빈 결과를 DB에 저장하고 사용자에게 알림
$pdo = db_connect();
$updateSql = "UPDATE meeting_logs SET
title = ?,
transcript_text = ?,
summary_text = ?
WHERE id = ?";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute(['음성 인식 실패', '음성이 인식되지 않았습니다.', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.', $meeting_id]);
sendErrorResponse('음성 인식 실패', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.');
}
// 7. 텍스트 변환
$stt_data = ['results' => $poll_data['response']['results']];
$transcript = '';
foreach ($stt_data['results'] as $result) {
if (isset($result['alternatives'][0]['transcript'])) {
$transcript .= $result['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
if (empty($transcript)) {
sendErrorResponse('인식된 텍스트가 없습니다.');
}
// 8. Claude API로 요약 생성
$claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
$claudeKey = '';
if (file_exists($claudeKeyFile)) {
$claudeKey = trim(file_get_contents($claudeKeyFile));
}
$title = '무제 회의록';
$summary = '';
if (!empty($claudeKey)) {
$prompt = "다음 회의 녹취록을 분석하여 JSON 형식으로 응답해주세요.
요구사항:
1. \"title\": 회의 내용을 요약한 제목 (최대 20자, 한글 기준)
2. \"summary\": [회의 개요 / 주요 안건 / 결정 사항 / 향후 계획] 형식의 상세 요약
반드시 다음 JSON 형식으로만 응답해주세요:
{
\"title\": \"제목 (최대 20자)\",
\"summary\": \"상세 요약 내용\"
}
회의 녹취록:
" . $transcript;
$ch2 = curl_init('https://api.anthropic.com/v1/messages');
$requestBody = [
'model' => 'claude-3-5-haiku-20241022',
'max_tokens' => 2048,
'messages' => [['role' => 'user', 'content' => $prompt]]
];
curl_setopt($ch2, CURLOPT_POST, true);
curl_setopt($ch2, CURLOPT_HTTPHEADER, [
'x-api-key: ' . $claudeKey,
'anthropic-version: 2023-06-01',
'Content-Type: application/json'
]);
curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($requestBody));
curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
$ai_response = curl_exec($ch2);
$ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
if ($ai_code === 200) {
$ai_data = json_decode($ai_response, true);
if (isset($ai_data['content'][0]['text'])) {
$ai_text = trim($ai_data['content'][0]['text']);
$ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text);
$ai_text = preg_replace('/\s*```$/m', '', $ai_text);
$ai_text = trim($ai_text);
$parsed_data = json_decode($ai_text, true);
if (is_array($parsed_data)) {
if (isset($parsed_data['title']) && !empty($parsed_data['title'])) {
$title = mb_substr(trim($parsed_data['title']), 0, 20, 'UTF-8');
}
if (isset($parsed_data['summary']) && !empty($parsed_data['summary'])) {
$summary = $parsed_data['summary'];
} else {
$summary = $ai_text;
}
} else {
$summary = $ai_text;
if (preg_match('/["\']?title["\']?\s*[:=]\s*["\']([^"\']{1,20})["\']/', $ai_text, $matches)) {
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
} elseif (preg_match('/제목[:\s]+([^\n]{1,20})/', $ai_text, $matches)) {
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
} else {
$lines = explode("\n", $ai_text);
$first_line = trim($lines[0]);
if (!empty($first_line) && mb_strlen($first_line, 'UTF-8') <= 20) {
$title = $first_line;
} elseif (!empty($first_line)) {
$title = mb_substr($first_line, 0, 20, 'UTF-8');
}
}
}
}
}
}
if (empty($title) || $title === '무제 회의록') {
if (!empty($summary)) {
$summary_lines = explode("\n", $summary);
$first_summary_line = trim($summary_lines[0]);
if (!empty($first_summary_line)) {
$first_summary_line = preg_replace('/[\[\(].*?[\]\)]/', '', $first_summary_line);
$first_summary_line = trim($first_summary_line);
if (mb_strlen($first_summary_line, 'UTF-8') <= 20) {
$title = $first_summary_line;
} else {
$title = mb_substr($first_summary_line, 0, 20, 'UTF-8');
}
}
}
}
$title = mb_substr(trim($title), 0, 20, 'UTF-8');
if (empty($title)) {
$title = '무제 회의록';
}
if (empty($summary)) {
$summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)";
}
// 9. DB 업데이트
$pdo = db_connect();
$updateSql = "UPDATE meeting_logs SET
title = ?,
transcript_text = ?,
summary_text = ?
WHERE id = ?";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute([$title, $transcript, $summary, $meeting_id]);
// 10. 완료 응답
@ob_clean();
@ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'processing' => false,
'done' => true,
'meeting_id' => $meeting_id,
'title' => $title,
'transcript' => $transcript,
'summary' => $summary,
'message' => '음성 인식 및 요약이 완료되었습니다.'
], JSON_UNESCAPED_UNICODE);
?>

114
voice_ai/fix_meeting_db.php Normal file
View File

@@ -0,0 +1,114 @@
<?php
// fix_meeting_db.php
// meeting_logs 테이블의 id 컬럼에 AUTO_INCREMENT 속성을 추가하고
// 중복된 id=0 레코드를 수정하는 스크립트
error_reporting(E_ALL);
ini_set('display_errors', 1);
// DB 연결 설정
$host = '127.0.0.1';
$dbname = 'checker'; // .env를 로드하지 못할 경우를 대비한 기본값
$username = 'root';
$password = 'root';
echo "Database Fix Script for meeting_logs\n";
echo "=====================================\n";
// 1. .env 파일 로드 시도
$envPath = __DIR__ . '/../.env';
if (file_exists($envPath)) {
echo "Loading .env file...\n";
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0) continue;
list($name, $value) = explode('=', $line, 2);
$_ENV[trim($name)] = trim($value);
}
if (isset($_ENV['DB_HOST'])) $host = $_ENV['DB_HOST'];
if (isset($_ENV['DB_NAME'])) $dbname = $_ENV['DB_NAME'];
if (isset($_ENV['DB_USER'])) $username = $_ENV['DB_USER'];
if (isset($_ENV['DB_PASSWORD'])) $password = $_ENV['DB_PASSWORD'];
} else {
echo "Warning: .env file not found. Using default credentials.\n";
}
// 2. DB 연결
try {
echo "Connecting to database ($host, $dbname)...\n";
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8", $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "Connected successfully.\n";
// 3. 중복된 ID 확인 (id=0)
echo "Checking for duplicate id=0 in meeting_logs...\n";
$stmt = $pdo->query("SELECT COUNT(*) FROM meeting_logs WHERE id = 0");
$count = $stmt->fetchColumn();
echo "Found $count records with id=0.\n";
if ($count > 0) {
// 4. MAX(id) 확인
$stmt = $pdo->query("SELECT MAX(id) FROM meeting_logs");
$maxId = $stmt->fetchColumn();
if (!$maxId) $maxId = 0;
echo "Current MAX(id) is $maxId.\n";
// 5. id=0인 레코드들 업데이트
$stmt = $pdo->query("SELECT * FROM meeting_logs WHERE id = 0");
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$newId = $maxId + 1;
foreach ($rows as $row) {
// 중복된 id=0 레코드를 삭제하고 새로운 ID로 다시 삽입하는 것이 안전함
// (PRIMARY KEY 제약 조건 때문에 UPDATE가 실패할 수 있음)
// 데이터 복사
$insertSql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, created_at, file_expiry_date)
VALUES (:tenant_id, :user_id, :title, :audio_file_path, :transcript_text, :summary_text, :created_at, :file_expiry_date)";
$insertStmt = $pdo->prepare($insertSql);
$insertStmt->execute([
':tenant_id' => $row['tenant_id'],
':user_id' => $row['user_id'],
':title' => $row['title'],
':audio_file_path' => $row['audio_file_path'],
':transcript_text' => $row['transcript_text'],
':summary_text' => $row['summary_text'],
':created_at' => $row['created_at'],
':file_expiry_date' => $row['file_expiry_date']
]);
echo "Moved record (old id=0) to new id=" . $pdo->lastInsertId() . "\n";
}
// 원래 id=0 레코드 삭제
$pdo->exec("DELETE FROM meeting_logs WHERE id = 0");
echo "Deleted old records with id=0.\n";
}
// 6. AUTO_INCREMENT 속성 추가
echo "Applying AUTO_INCREMENT to meeting_logs.id...\n";
try {
$pdo->exec("ALTER TABLE meeting_logs MODIFY id INT AUTO_INCREMENT PRIMARY KEY");
echo "Success: AUTO_INCREMENT applied.\n";
} catch (PDOException $e) {
// 이미 설정되어 있거나 다른 문제가 있는 경우
echo "Note: " . $e->getMessage() . "\n";
// PRIMARY KEY가 이미 있다면 MODIFY만 시도
try {
$pdo->exec("ALTER TABLE meeting_logs MODIFY id INT AUTO_INCREMENT");
echo "Success: Mofified column to AUTO_INCREMENT.\n";
} catch (PDOException $e2) {
echo "Error applying AUTO_INCREMENT: " . $e2->getMessage() . "\n";
}
}
echo "Done.\n";
} catch (PDOException $e) {
echo "Database Error: " . $e->getMessage() . "\n";
exit(1);
}
?>

File diff suppressed because it is too large Load Diff

View File

@@ -1,137 +1,142 @@
<?php
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
error_reporting(0);
// 출력 버퍼링 시작 - 모든 출력을 캡처
while (ob_get_level()) {
ob_end_clean();
}
ob_start();
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 에러 리포팅 설정 (개발 중에는 활성화, 프로덕션에서는 비활성화)
error_reporting(E_ALL);
ini_set('display_errors', 0); // 출력은 버퍼로만
ini_set('log_errors', 1);
// GCS 업로드 함수
function uploadToGCS($file_path, $bucket_name, $object_name, $service_account_path) {
if (!file_exists($service_account_path)) {
error_log('GCS 업로드 실패: 서비스 계정 파일 없음');
return false;
// 에러 핸들러 설정
function handleError($errno, $errstr, $errfile, $errline) {
error_log("PHP Error [$errno]: $errstr in $errfile on line $errline");
return false; // 기본 에러 핸들러도 실행
}
set_error_handler('handleError');
// 치명적 에러 핸들러
function handleFatalError() {
$error = error_get_last();
if ($error !== NULL && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
ob_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => '서버 오류가 발생했습니다.',
'details' => $error['message'],
'file' => basename($error['file']),
'line' => $error['line']
], JSON_UNESCAPED_UNICODE);
exit;
}
}
register_shutdown_function('handleFatalError');
$serviceAccount = json_decode(file_get_contents($service_account_path), true);
if (!$serviceAccount) {
error_log('GCS 업로드 실패: 서비스 계정 JSON 파싱 오류');
return false;
// 에러 응답 함수 (require 전에 정의)
function sendErrorResponse($message, $details = null) {
while (ob_get_level()) {
ob_end_clean();
}
// OAuth 2.0 토큰 생성
$now = time();
$jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = base64_encode(json_encode([
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
]));
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if (!$privateKey) {
error_log('GCS 업로드 실패: 개인 키 읽기 오류');
return false;
}
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature);
// OAuth 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$tokenResponse = curl_exec($tokenCh);
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenCode !== 200) {
error_log('GCS 업로드 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')');
return false;
}
$tokenData = json_decode($tokenResponse, true);
if (!isset($tokenData['access_token'])) {
error_log('GCS 업로드 실패: OAuth 토큰 없음');
return false;
}
$accessToken = $tokenData['access_token'];
// GCS에 파일 업로드
$file_content = file_get_contents($file_path);
$mime_type = mime_content_type($file_path) ?: 'audio/webm';
$upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' .
urlencode($bucket_name) . '/o?uploadType=media&name=' .
urlencode($object_name);
$ch = curl_init($upload_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: ' . $mime_type,
'Content-Length: ' . strlen($file_content)
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
return 'gs://' . $bucket_name . '/' . $object_name;
} else {
error_log('GCS 업로드 실패 (HTTP ' . $code . '): ' . $response);
return false;
header('Content-Type: application/json; charset=utf-8');
$response = ['ok' => false, 'error' => $message];
if ($details !== null) {
$response['details'] = $details;
}
echo json_encode($response, JSON_UNESCAPED_UNICODE);
exit;
}
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
// 1. 권한 체크
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 1. 권한 및 세션 체크
if (!isset($user_id) || $level > 5) {
echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']);
exit;
sendErrorResponse('접근 권한이 없습니다.');
}
$tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용
$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/meetings/" . $tenant_id . "/";
// 2. POST 요청 확인
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendErrorResponse('POST 요청만 허용됩니다.');
}
// 2. 파일 업로드 처리
// 3. 파일 업로드 확인
if (!isset($_FILES['audio_file']) || $_FILES['audio_file']['error'] !== UPLOAD_ERR_OK) {
$errorMsg = '오디오 파일 업로드 실패';
if (isset($_FILES['audio_file']['error'])) {
switch ($_FILES['audio_file']['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$errorMsg = '파일 크기가 너무 큽니다.';
break;
case UPLOAD_ERR_PARTIAL:
$errorMsg = '파일이 부분적으로만 업로드되었습니다.';
break;
case UPLOAD_ERR_NO_FILE:
$errorMsg = '파일이 업로드되지 않았습니다.';
break;
case UPLOAD_ERR_NO_TMP_DIR:
$errorMsg = '임시 폴더가 없습니다.';
break;
case UPLOAD_ERR_CANT_WRITE:
$errorMsg = '파일 쓰기 실패.';
break;
case UPLOAD_ERR_EXTENSION:
$errorMsg = '파일 업로드가 확장에 의해 중지되었습니다.';
break;
}
}
sendErrorResponse($errorMsg);
}
// tenant_id 확인 및 변환
$tenant_id = isset($tenant_id) ? $tenant_id : (isset($user_id) ? $user_id : null);
// tenant_id가 null이거나 정수가 아닌 경우 처리
if (empty($tenant_id)) {
sendErrorResponse('유효하지 않은 사용자 ID입니다.', 'tenant_id와 user_id가 모두 없습니다.');
}
// tenant_id가 정수가 아닌 경우 (예: "pro") crc32 해시로 변환
if (!filter_var($tenant_id, FILTER_VALIDATE_INT)) {
$original_tenant_id = $tenant_id;
// null이 아닌 문자열로 변환
$tenant_id_str = (string)$tenant_id;
$tenant_id = crc32($tenant_id_str);
// 최소 1000 이상으로 보장 (낮은 숫자와의 충돌 방지)
if ($tenant_id < 1000) {
$tenant_id = abs($tenant_id) + 1000;
}
error_log("tenant_id 변환: '$original_tenant_id' -> $tenant_id");
}
if (empty($tenant_id) || $tenant_id <= 0) {
sendErrorResponse('유효하지 않은 사용자 ID입니다.', 'tenant_id: ' . var_export($tenant_id, true));
}
// 정수로 변환
$tenant_id = (int)$tenant_id;
// 업로드 디렉토리 생성
$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/meetings/" . $tenant_id . "/";
if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true);
if (!isset($_FILES['audio_file'])) {
echo json_encode(['ok' => false, 'error' => '오디오 파일이 없습니다.']);
exit;
}
// 파일 크기 확인
if ($_FILES['audio_file']['size'] == 0) {
echo json_encode(['ok' => false, 'error' => '오디오 파일이 비어있습니다. 녹음이 제대로 되지 않았을 수 있습니다.']);
exit;
sendErrorResponse('오디오 파일이 비어있습니다. 녹음이 제대로 되지 않았을 수 있습니다.');
}
$file_name = date('Ymd_His') . "_" . uniqid() . ".webm";
$file_path = $upload_dir . $file_name;
if (!move_uploaded_file($_FILES['audio_file']['tmp_name'], $file_path)) {
echo json_encode(['ok' => false, 'error' => '파일 저장 실패']);
exit;
sendErrorResponse('파일 저장 실패', '업로드 디렉토리 권한을 확인해주세요.');
}
// 3. STT 변환 (Google Cloud Speech-to-Text API)
@@ -223,101 +228,90 @@ if (file_exists($googleServiceAccountFile)) {
error_log('OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . '): ' . $tokenResponse);
$errorDetails = json_decode($tokenResponse, true);
$errorMessage = isset($errorDetails['error_description']) ? $errorDetails['error_description'] : (isset($errorDetails['error']) ? $errorDetails['error'] : substr($tokenResponse, 0, 500));
echo json_encode(['ok' => false, 'error' => 'OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')', 'details' => $errorMessage]);
echo json_encode(['ok' => false, 'error' => 'OAuth 토큰 요청 실패', 'details' => $errorMessage]);
exit;
}
}
// OAuth 토큰이 없고 API 키 파일이 있으면 API 키 사용
// API 키 사용 (서비스 계정이 없는 경우)
$googleApiKey = null;
if (!$accessToken && file_exists($googleApiKeyFile)) {
$googleApiKey = trim(file_get_contents($googleApiKeyFile));
if (!empty($googleApiKey)) {
// API 키 방식 사용 (기존 코드)
} else {
echo json_encode(['ok' => false, 'error' => 'Google API 키가 비어있습니다.']);
if (strlen($googleApiKey) < 20) {
echo json_encode(['ok' => false, 'error' => 'Google API 키 형식이 올바르지 않습니다.']);
exit;
}
} elseif (!$accessToken) {
echo json_encode(['ok' => false, 'error' => 'Google 서비스 계정 JSON 파일 또는 API 키 파일이 필요합니다.']);
}
if (!$accessToken && !$googleApiKey) {
echo json_encode(['ok' => false, 'error' => 'Google API 인증 정보가 없습니다. 서비스 계정 JSON 파일 또는 API 키가 필요합니다.']);
exit;
}
// 오디오 파일 크기 확인
// 오디오 파일 읽기
$file_size = filesize($file_path);
$max_inline_size = 10 * 1024 * 1024; // 10MB (인라인 오디오 제한)
$audio_content = file_get_contents($file_path);
// 파일 확장자 확인
$file_extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
// 인코딩 자동 감지 (파일 확장자 기반)
$encoding = null;
$sample_rate = null;
switch ($file_extension) {
case 'webm':
$encoding = 'WEBM_OPUS';
$sample_rate = 48000;
break;
case 'wav':
$encoding = 'LINEAR16'; // WAV 파일은 LINEAR16
$sample_rate = 16000; // WAV는 보통 16kHz
break;
case 'mp3':
$encoding = 'MP3';
$sample_rate = null; // MP3는 자동 감지
break;
case 'ogg':
$encoding = 'OGG_OPUS';
$sample_rate = 48000;
break;
case 'm4a':
$encoding = 'MP4';
$sample_rate = null;
break;
default:
// 기본값: webm으로 가정
$encoding = 'WEBM_OPUS';
$sample_rate = 48000;
// 파일 크기 확인 (60MB 제한)
if ($file_size > 60 * 1024 * 1024) {
echo json_encode([
'ok' => false,
'error' => '오디오 파일이 너무 큽니다. (최대 60MB)',
'file_size_mb' => round($file_size / 1024 / 1024, 2)
]);
exit;
}
// API 요청 본문 구성
$config = [
'languageCode' => 'ko-KR', // 한국어
// 오디오 인코딩 및 샘플레이트 자동 감지
$audio_encoding = 'WEBM_OPUS';
$sample_rate = 48000;
// 파일 확장자나 MIME 타입으로 인코딩 추정
$file_ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if ($file_ext === 'wav') {
$audio_encoding = 'LINEAR16';
$sample_rate = 16000;
} elseif ($file_ext === 'flac') {
$audio_encoding = 'FLAC';
$sample_rate = 48000;
} elseif ($file_ext === 'mp3' || $file_ext === 'm4a') {
$audio_encoding = 'MP3';
$sample_rate = 44100;
}
// 짧은 오디오(약 1MB 이하, 약 1분 이하)는 recognize API 사용 (즉시 결과)
// 긴 오디오는 longrunningrecognize API 사용 (비동기 처리)
$useRecognizeAPI = $file_size <= 1024 * 1024; // 1MB 이하
if ($useRecognizeAPI) {
// 짧은 오디오: recognize API 사용 (동기, 즉시 결과)
$apiUrl = 'https://speech.googleapis.com/v1/speech:recognize';
error_log('짧은 오디오 감지: recognize API 사용 (파일 크기: ' . round($file_size / 1024, 2) . ' KB)');
} else {
// 긴 오디오: longrunningrecognize API 사용 (비동기)
$apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize';
error_log('긴 오디오 감지: longrunningrecognize API 사용 (파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB)');
}
$requestBody = [
'config' => [
'encoding' => $audio_encoding,
'sampleRateHertz' => $sample_rate,
'languageCode' => 'ko-KR',
'enableAutomaticPunctuation' => true,
'model' => 'latest_long', // 긴 오디오용 모델
'audioChannelCount' => 1, // 모노 채널
'enableWordTimeOffsets' => false // 단어별 타임스탬프는 필요시에만
'enableWordTimeOffsets' => false,
'model' => $useRecognizeAPI ? 'latest_short' : 'latest_long' // 짧은 오디오는 latest_short 모델 사용
],
'audio' => [
'content' => base64_encode($audio_content)
]
];
// 인코딩 설정 (일부 형식은 생략 가능)
if ($encoding) {
$config['encoding'] = $encoding;
}
// 샘플레이트 설정 (필요한 경우만)
if ($sample_rate) {
$config['sampleRateHertz'] = $sample_rate;
}
$requestBody = ['config' => $config];
// 오디오 소스 설정: 작은 파일은 인라인, 큰 파일은 GCS URI 사용
// 먼저 인라인으로 시도하고, 오류 발생 시 재시도 로직으로 처리
$audioContent = base64_encode(file_get_contents($file_path));
$requestBody['audio'] = ['content' => $audioContent];
// 모든 오디오 길이를 지원하기 위해 longrunningrecognize API 사용
// (짧은 오디오도 처리 가능하며, 1분 제한이 없음)
// longrunningrecognize는 비동기 작업이므로 작업을 시작하고 폴링해야 함
$headers = ['Content-Type: application/json'];
if ($accessToken) {
$apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize';
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken
];
$headers[] = 'Authorization: Bearer ' . $accessToken;
} else {
$apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize?key=' . urlencode($googleApiKey);
$headers = ['Content-Type: application/json'];
$apiUrl .= '?key=' . urlencode($googleApiKey);
}
$ch = curl_init($apiUrl);
@@ -325,94 +319,71 @@ curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// 타임아웃 설정 (짧은 오디오는 더 짧게, 긴 오디오는 길게)
curl_setopt($ch, CURLOPT_TIMEOUT, $useRecognizeAPI ? 30 : 50); // recognize는 30초, longrunning은 50초
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
$operation_response = curl_exec($ch);
$operation_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$operation_error = curl_error($ch);
curl_close($ch);
// 오류 처리
if ($operation_code !== 200) {
error_log('Google STT longrunningrecognize 시작 실패: ' . $operation_response);
// 오류 응답 파싱
$error_data = json_decode($operation_response, true);
$error_message = '';
$needs_gcs = false;
$needs_encoding_fix = false;
if (isset($error_data['error']['message'])) {
$error_message = $error_data['error']['message'];
// 인코딩 오류 감지
if (strpos($error_message, 'Encoding') !== false ||
strpos($error_message, 'LINEAR16') !== false ||
strpos($error_message, 'must either be omitted') !== false) {
$needs_encoding_fix = true;
}
// GCS URI 필요 오류인 경우 감지
if (strpos($error_message, 'GCS URI') !== false ||
strpos($error_message, 'duration limit') !== false ||
strpos($error_message, 'exceeds duration limit') !== false ||
strpos($error_message, 'Inline audio exceeds') !== false) {
$needs_gcs = true;
}
}
// 인코딩 오류인 경우, 인코딩을 생략하고 재시도
if ($needs_encoding_fix) {
// 인코딩과 샘플레이트를 제거하고 자동 감지하도록 설정
unset($requestBody['config']['encoding']);
unset($requestBody['config']['sampleRateHertz']);
// 재시도
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$operation_response = curl_exec($ch);
$operation_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$operation_error = curl_error($ch);
curl_close($ch);
// 인코딩 수정 후 성공한 경우, 이후 로직으로 진행
if ($operation_code === 200) {
// 성공했으므로 이후 폴링 로직으로 진행
$needs_gcs = false;
} else {
error_log('Google STT 재시도 실패 (인코딩 수정): ' . $operation_response);
// 인코딩 수정 후에도 실패한 경우, 오류 데이터 다시 파싱
$error_data = json_decode($operation_response, true);
if (isset($error_data['error']['message'])) {
$error_message = $error_data['error']['message'];
// GCS URI 필요 오류인지 다시 확인
if (strpos($error_message, 'GCS URI') !== false ||
strpos($error_message, 'duration limit') !== false ||
strpos($error_message, 'exceeds duration limit') !== false ||
strpos($error_message, 'Inline audio exceeds') !== false) {
$needs_gcs = true;
}
}
}
}
// GCS URI가 필요한 경우, GCS에 업로드 후 URI 사용
if ($needs_gcs && $operation_code !== 200) {
// GCS 설정 확인
// 파일이 너무 큰 경우 GCS 사용 시도
if ($operation_code === 400 && (
strpos($error_message, 'too large') !== false ||
strpos($error_message, 'exceeds') !== false ||
$file_size > 10 * 1024 * 1024 // 10MB 이상
)) {
// Google Cloud Storage 업로드 시도
$gcs_config_file = $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt';
$bucket_name = null;
if (file_exists($gcs_config_file)) {
$gcs_config = parse_ini_file($gcs_config_file);
$bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : null;
}
$gcs_config = json_decode(file_get_contents($gcs_config_file), true);
$bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : '';
if ($bucket_name) {
// GCS에 파일 업로드
$gcs_object_name = 'meetings/' . $tenant_id . '/' . basename($file_path);
$gcs_uri = uploadToGCS($file_path, $bucket_name, $gcs_object_name, $googleServiceAccountFile);
if (!empty($bucket_name)) {
// GCS에 업로드
$gcs_object_name = 'meetings/' . $tenant_id . '/' . $file_name;
$gcs_uri = 'gs://' . $bucket_name . '/' . $gcs_object_name;
// 간단한 GCS 업로드 (서비스 계정 사용)
if ($accessToken) {
$gcs_upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' . $bucket_name . '/o?uploadType=media&name=' . urlencode($gcs_object_name);
$gcs_headers = [
'Authorization: Bearer ' . $accessToken,
'Content-Type: audio/webm'
];
$gcs_ch = curl_init($gcs_upload_url);
curl_setopt($gcs_ch, CURLOPT_POST, true);
curl_setopt($gcs_ch, CURLOPT_HTTPHEADER, $gcs_headers);
curl_setopt($gcs_ch, CURLOPT_POSTFIELDS, $audio_content);
curl_setopt($gcs_ch, CURLOPT_RETURNTRANSFER, true);
$gcs_response = curl_exec($gcs_ch);
$gcs_code = curl_getinfo($gcs_ch, CURLINFO_HTTP_CODE);
curl_close($gcs_ch);
if ($gcs_code === 200) {
// GCS 업로드 성공
error_log('GCS 업로드 성공: ' . $gcs_uri);
} else {
error_log('GCS 업로드 실패 (HTTP ' . $gcs_code . '): ' . $gcs_response);
$gcs_uri = null;
}
} else {
$gcs_uri = null;
}
} else {
$gcs_uri = null;
}
if ($gcs_uri) {
// GCS URI 사용
@@ -432,7 +403,7 @@ if ($operation_code !== 200) {
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패',
'details' => '오디오 파일이 너무 니다. Google Cloud Storage 설정이 필요합니다. /apikey/gcs_config.txt 파일을 생성하고 bucket_name을 설정해주세요.',
'details' => '오디오 파일이 너무 니다. Google Cloud Storage 설정이 필요합니다. /apikey/gcs_config.txt 파일을 생성하고 bucket_name을 설정해주세요.',
'file_size_mb' => round($file_size / 1024 / 1024, 2),
'help' => '자세한 설정 방법은 voice_ai/구글클라우드스토리지설정방법.md 파일을 참고하세요.'
]);
@@ -457,7 +428,7 @@ if ($operation_code !== 200) {
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패 (HTTP ' . $operation_code . ')',
'details' => '오디오 파일이 너무 니다. Google Cloud Storage 설정이 필요할 수 있습니다.',
'details' => '오디오 파일이 너무 니다. Google Cloud Storage 설정이 필요할 수 있습니다.',
'curl_error' => $operation_error,
'file_size_mb' => round($file_size / 1024 / 1024, 2)
]);
@@ -477,146 +448,52 @@ if ($operation_code !== 200) {
}
$operation_data = json_decode($operation_response, true);
if (!isset($operation_data['name'])) {
error_log('Google STT longrunningrecognize 작업 ID 없음: ' . $operation_response);
// $web_path 정의
$web_path = "/uploads/meetings/" . $tenant_id . "/" . $file_name;
if ($useRecognizeAPI) {
// recognize API: 즉시 결과 반환
if (!isset($operation_data['results']) || empty($operation_data['results'])) {
error_log('Google STT recognize API 결과 없음: ' . $operation_response);
echo json_encode([
'ok' => false,
'error' => 'Google STT 작업 시작 실패',
'details' => substr($operation_response, 0, 500)
]);
exit;
}
$operation_name = $operation_data['name'];
// 작업 완료까지 폴링 (오디오 길이에 따라 동적으로 타임아웃 설정)
// 4시간 오디오는 처리에 약 1~2시간 소요될 수 있음
// 최대 4시간 대기 (2880회 * 5초 = 4시간)
// PHP 실행 시간 제한도 충분히 설정 필요 (ini_set('max_execution_time', 14400))
ini_set('max_execution_time', 14400); // 4시간
set_time_limit(14400); // 4시간
$max_polls = 2880; // 2880회 * 5초 = 4시간
$poll_count = 0;
$operation_done = false;
while ($poll_count < $max_polls && !$operation_done) {
sleep(5); // 5초 대기
$poll_count++;
// 작업 상태 확인
if ($accessToken) {
$poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name);
$poll_headers = ['Authorization: Bearer ' . $accessToken];
} else {
$poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name) . '?key=' . urlencode($googleApiKey);
$poll_headers = [];
}
$poll_ch = curl_init($poll_url);
curl_setopt($poll_ch, CURLOPT_RETURNTRANSFER, true);
if (!empty($poll_headers)) {
curl_setopt($poll_ch, CURLOPT_HTTPHEADER, $poll_headers);
}
$poll_response = curl_exec($poll_ch);
$poll_code = curl_getinfo($poll_ch, CURLINFO_HTTP_CODE);
curl_close($poll_ch);
if ($poll_code === 200) {
$poll_data = json_decode($poll_response, true);
if (isset($poll_data['done']) && $poll_data['done'] === true) {
if (isset($poll_data['error'])) {
error_log('Google STT longrunningrecognize 오류: ' . json_encode($poll_data['error']));
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패',
'details' => isset($poll_data['error']['message']) ? $poll_data['error']['message'] : '알 수 없는 오류'
'error' => '음성 인식 결과가 없습니다.',
'details' => '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.'
]);
exit;
}
if (isset($poll_data['response']['results'])) {
$stt_data = ['results' => $poll_data['response']['results']];
$operation_done = true;
} else {
error_log('Google STT longrunningrecognize 결과 없음: ' . $poll_response);
echo json_encode([
'ok' => false,
'error' => 'Google STT 응답에 결과가 없습니다.',
'details' => substr($poll_response, 0, 500)
]);
exit;
}
}
} else {
error_log('Google STT longrunningrecognize 폴링 실패 (HTTP ' . $poll_code . '): ' . $poll_response);
}
}
if (!$operation_done) {
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환이 시간 초과되었습니다. (4시간 이상 소요)',
'details' => '작업이 아직 완료되지 않았습니다. 매우 긴 오디오(4시간 이상)는 처리 시간이 오래 걸릴 수 있습니다. 작업 ID: ' . $operation_name,
'operation_name' => $operation_name,
'poll_count' => $poll_count,
'elapsed_time_minutes' => round($poll_count * 5 / 60, 1)
]);
exit;
}
// Google Speech-to-Text 응답 형식 처리
if (!isset($stt_data['results']) || empty($stt_data['results'])) {
// 오류 응답인 경우 처리
if (isset($stt_data['error'])) {
$errorMsg = isset($stt_data['error']['message']) ? $stt_data['error']['message'] : '알 수 없는 오류';
echo json_encode([
'ok' => false,
'error' => 'Google STT API 오류: ' . $errorMsg,
'response' => substr($stt_response, 0, 500)
]);
} else {
echo json_encode([
'ok' => false,
'error' => 'Google STT 응답에 결과가 없습니다. (오디오가 너무 짧거나 인식할 수 없는 형식일 수 있습니다)',
'response' => substr($stt_response, 0, 500)
]);
}
exit;
}
// 모든 결과를 합쳐서 텍스트 생성
$transcript = '';
foreach ($stt_data['results'] as $result) {
// 텍스트 변환
$transcript = '';
foreach ($operation_data['results'] as $result) {
if (isset($result['alternatives'][0]['transcript'])) {
$transcript .= $result['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
}
$transcript = trim($transcript);
if (empty($transcript)) {
if (empty($transcript)) {
echo json_encode([
'ok' => false,
'error' => '인식된 텍스트가 없습니다. (오디오에 음성이 없거나 너무 작을 수 있습니다)',
'response' => substr($stt_response, 0, 500)
'error' => '인식된 텍스트가 없습니다.',
'details' => '녹음된 오디오에서 텍스트를 추출할 수 없었습니다.'
]);
exit;
}
}
// 4. 회의록 요약 및 제목 생성 (Claude API)
$claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
if (!file_exists($claudeKeyFile)) {
// Claude API 키가 없어도 요약은 실패하지만 계속 진행
// Claude API로 요약 생성
$claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
$claudeKey = '';
} else {
if (file_exists($claudeKeyFile)) {
$claudeKey = trim(file_get_contents($claudeKeyFile));
}
}
// Claude API에 JSON 형식으로 응답 요청
$prompt = "다음 회의 녹취록을 분석하여 JSON 형식으로 응답해주세요.
$title = '무제 회의록';
$summary = '';
if (!empty($claudeKey)) {
$prompt = "다음 회의 녹취록을 분석하여 JSON 형식으로 응답해주세요.
요구사항:
1. \"title\": 회의 내용을 요약한 제목 (최대 20자, 한글 기준)
@@ -631,72 +508,53 @@ $prompt = "다음 회의 녹취록을 분석하여 JSON 형식으로 응답해
회의 녹취록:
" . $transcript;
$ch2 = curl_init('https://api.anthropic.com/v1/messages');
$requestBody = [
$ch2 = curl_init('https://api.anthropic.com/v1/messages');
$requestBody = [
'model' => 'claude-3-5-haiku-20241022',
'max_tokens' => 2048,
'messages' => [['role' => 'user', 'content' => $prompt]]
];
];
curl_setopt($ch2, CURLOPT_POST, true);
curl_setopt($ch2, CURLOPT_HTTPHEADER, [
curl_setopt($ch2, CURLOPT_POST, true);
curl_setopt($ch2, CURLOPT_HTTPHEADER, [
'x-api-key: ' . $claudeKey,
'anthropic-version: 2023-06-01',
'Content-Type: application/json'
]);
curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($requestBody));
curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
]);
curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($requestBody));
curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch2, CURLOPT_TIMEOUT, 30);
$ai_response = curl_exec($ch2);
$ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
$ai_response = curl_exec($ch2);
$ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
// 기본값 설정
$title = '무제 회의록';
$summary = '';
if ($ai_code !== 200 || empty($claudeKey)) {
if (empty($claudeKey)) {
$summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)";
} else {
$summary = "요약 실패 (원문 저장됨)";
}
} else {
if ($ai_code === 200) {
$ai_data = json_decode($ai_response, true);
if (isset($ai_data['content'][0]['text'])) {
$ai_text = $ai_data['content'][0]['text'];
// JSON 추출 시도 (코드 블록이나 마크다운 제거)
$ai_text = trim($ai_text);
// ```json 또는 ``` 제거
$ai_text = trim($ai_data['content'][0]['text']);
$ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text);
$ai_text = preg_replace('/\s*```$/m', '', $ai_text);
$ai_text = trim($ai_text);
// JSON 파싱 시도
$parsed_data = json_decode($ai_text, true);
if (is_array($parsed_data)) {
// JSON 파싱 성공
if (isset($parsed_data['title']) && !empty($parsed_data['title'])) {
$title = mb_substr(trim($parsed_data['title']), 0, 20, 'UTF-8');
}
if (isset($parsed_data['summary']) && !empty($parsed_data['summary'])) {
$summary = $parsed_data['summary'];
} else {
$summary = $ai_text; // JSON 형식이 아니면 전체 텍스트 사용
$summary = $ai_text;
}
} else {
// JSON 파싱 실패 - 텍스트에서 제목 추출 시도
$summary = $ai_text;
// 텍스트에서 "title" 또는 "제목" 키워드로 제목 추출 시도
if (preg_match('/["\']?title["\']?\s*[:=]\s*["\']([^"\']{1,20})["\']/', $ai_text, $matches)) {
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
} elseif (preg_match('/제목[:\s]+([^\n]{1,20})/', $ai_text, $matches)) {
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
} else {
// 요약 텍스트의 첫 줄이나 첫 문장을 제목으로 사용
$lines = explode("\n", $ai_text);
$first_line = trim($lines[0]);
if (!empty($first_line) && mb_strlen($first_line, 'UTF-8') <= 20) {
@@ -706,16 +564,15 @@ if ($ai_code !== 200 || empty($claudeKey)) {
}
}
}
}
}
}
// 제목이 비어있으면 기본값 사용
if (empty($title) || $title === '무제 회의록') {
// 요약에서 의미있는 키워드 추출 시도
if (!empty($summary)) {
// 요약의 첫 문장이나 주요 키워드를 제목으로 사용
$summary_lines = explode("\n", $summary);
$first_summary_line = trim($summary_lines[0]);
if (!empty($first_summary_line)) {
// 괄호나 특수문자 제거
$first_summary_line = preg_replace('/[\[\(].*?[\]\)]/', '', $first_summary_line);
$first_summary_line = trim($first_summary_line);
if (mb_strlen($first_summary_line, 'UTF-8') <= 20) {
@@ -726,34 +583,88 @@ if ($ai_code !== 200 || empty($claudeKey)) {
}
}
}
} else {
$summary = "요약 응답 형식 오류 (원문 저장됨)";
}
}
// 제목 최종 검증 (20자 제한, 빈 값 방지)
$title = mb_substr(trim($title), 0, 20, 'UTF-8');
if (empty($title)) {
$title = mb_substr(trim($title), 0, 20, 'UTF-8');
if (empty($title)) {
$title = '무제 회의록';
}
}
if (empty($summary)) {
$summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)";
}
// 5. DB 저장 및 1주일 보관 설정
$pdo = db_connect();
$expiry_date = date('Y-m-d H:i:s', strtotime('+7 days'));
$web_path = "/uploads/meetings/" . $tenant_id . "/" . $file_name;
// SQL 인젝션 방지는 PDO Prepared Statement 사용 권장
$sql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date)
// DB 저장
$pdo = db_connect();
$insertSql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute([$tenant_id, $tenant_id, $title, $web_path, $transcript, $summary, $expiry_date]);
$insertStmt = $pdo->prepare($insertSql);
$expiry = date('Y-m-d H:i:s', strtotime('+7 days'));
$insertStmt->execute([$tenant_id, $tenant_id, $title, $web_path, $transcript, $summary, $expiry]);
$meeting_id = $pdo->lastInsertId();
echo json_encode([
// 즉시 완료 응답
@ob_clean();
@ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'id' => $pdo->lastInsertId(),
'processing' => false,
'done' => true,
'meeting_id' => $meeting_id,
'title' => $title,
'transcript' => $transcript,
'summary' => $summary
]);
'summary' => $summary,
'message' => '음성 인식 및 요약이 완료되었습니다.'
], JSON_UNESCAPED_UNICODE);
exit;
} else {
// longrunningrecognize API: 비동기 처리
if (!isset($operation_data['name'])) {
error_log('Google STT longrunningrecognize 작업 ID 없음: ' . $operation_response);
echo json_encode([
'ok' => false,
'error' => 'Google STT 작업 시작 실패',
'details' => substr($operation_response, 0, 500)
]);
exit;
}
$operation_name = $operation_data['name'];
// 504 Gateway Timeout 방지를 위해 비동기 처리로 변경
// 작업을 시작하고 즉시 응답, 클라이언트에서 폴링하도록 변경
// 작업 정보를 임시로 DB에 저장
$pdo_temp = db_connect();
$temp_sql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$temp_stmt = $pdo_temp->prepare($temp_sql);
$temp_title = '처리 중...';
$temp_summary = 'Google Speech-to-Text API 처리 중입니다. 잠시 후 다시 시도해주세요.';
$temp_expiry = date('Y-m-d H:i:s', strtotime('+7 days'));
// transcript_text에 operation_name 저장 (나중에 업데이트)
$temp_stmt->execute([$tenant_id, $tenant_id, $temp_title, $web_path, 'OPERATION_NAME:' . $operation_name, $temp_summary, $temp_expiry]);
$temp_meeting_id = $pdo_temp->lastInsertId();
// 즉시 응답 (클라이언트에서 폴링하도록)
@ob_clean();
@ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'processing' => true,
'meeting_id' => $temp_meeting_id,
'operation_name' => $operation_name,
'message' => '음성 인식 작업이 시작되었습니다. 처리 완료까지 시간이 걸릴 수 있습니다.',
'poll_url' => 'check_meeting_status.php?meeting_id=' . $temp_meeting_id . '&operation_name=' . urlencode($operation_name),
'access_token_available' => !empty($accessToken)
], JSON_UNESCAPED_UNICODE);
exit;
}
// 비동기 처리: 작업이 시작되었으므로 즉시 응답하고 종료
// 나머지 처리는 check_meeting_status.php에서 클라이언트 폴링으로 처리됨
// 이전 동기 처리 코드는 check_meeting_status.php로 이동됨
?>

View File

@@ -0,0 +1,527 @@
<?php
/**
* Google Cloud Speech-to-Text API 엔드포인트
* 오디오 파일을 받아서 텍스트로 변환
*/
// 타임아웃 설정 (504 오류 방지)
ini_set('max_execution_time', 120); // 120초
set_time_limit(60); // PHP 실행 시간 60초로 제한
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST');
header('Access-Control-Allow-Headers: Content-Type');
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
// 권한 체크
if ($level > 5) {
echo json_encode([
'success' => false,
'error' => '접근 권한이 없습니다.'
]);
exit;
}
// 세션 쓰기 닫기 (이후 긴 작업 시 세션 잠금 방지)
session_write_close();
// POST 요청만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode([
'success' => false,
'error' => 'POST 요청만 허용됩니다.'
]);
exit;
}
// 오디오 파일 확인
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
echo json_encode([
'success' => false,
'error' => '오디오 파일이 전송되지 않았습니다.'
]);
exit;
}
$audioFile = $_FILES['audio'];
$audioPath = $audioFile['tmp_name'];
$audioType = $audioFile['type'];
// 오디오 파일 유효성 검사
$allowedTypes = ['audio/webm', 'audio/wav', 'audio/ogg', 'audio/mp3', 'audio/mpeg', 'audio/x-flac'];
if (!in_array($audioType, $allowedTypes)) {
echo json_encode([
'success' => false,
'error' => '지원하지 않는 오디오 형식입니다. (webm, wav, ogg, mp3, flac 지원)'
]);
exit;
}
// Google Cloud Speech-to-Text API 설정
$googleApiKey = '';
$accessToken = null; // 서비스 계정용 OAuth 토큰
$useServiceAccount = false;
$googleApiUrl = 'https://speech.googleapis.com/v1/speech:recognize';
// API 키 파일 경로 (우선순위 순)
// 상대 경로와 절대 경로 모두 확인
$docRoot = $_SERVER['DOCUMENT_ROOT'];
$apiKeyPaths = [
$docRoot . '/5130/apikey/google_api.txt',
$docRoot . '/apikey/google_api.txt',
dirname($docRoot) . '/5130/apikey/google_api.txt', // 상위 디렉토리에서 확인
dirname($docRoot) . '/apikey/google_api.txt',
__DIR__ . '/../../apikey/google_api.txt', // 현재 파일 기준 상대 경로
__DIR__ . '/../../../apikey/google_api.txt',
$docRoot . '/5130/config/google_speech_api.php',
$docRoot . '/config/google_speech_api.php',
];
// 서비스 계정 파일 경로
$serviceAccountPaths = [
$docRoot . '/5130/apikey/google_service_account.json',
$docRoot . '/apikey/google_service_account.json',
dirname($docRoot) . '/5130/apikey/google_service_account.json',
dirname($docRoot) . '/apikey/google_service_account.json',
__DIR__ . '/../../apikey/google_service_account.json',
__DIR__ . '/../../../apikey/google_service_account.json',
];
// API 키 파일에서 읽기
$foundKeyPath = null;
$checkedPaths = [];
foreach ($apiKeyPaths as $keyPath) {
$checkedPaths[] = $keyPath . ' (exists: ' . (file_exists($keyPath) ? 'yes' : 'no') . ')';
if (file_exists($keyPath)) {
$foundKeyPath = $keyPath;
if (pathinfo($keyPath, PATHINFO_EXTENSION) === 'php') {
// PHP 설정 파일인 경우
require_once($keyPath);
if (isset($GOOGLE_SPEECH_API_KEY)) {
$googleApiKey = trim($GOOGLE_SPEECH_API_KEY);
break;
}
} else {
// 텍스트 파일인 경우 (google_api.txt)
$keyContent = file_get_contents($keyPath);
$googleApiKey = trim($keyContent);
// 빈 줄이나 주석 제거
$lines = explode("\n", $googleApiKey);
$googleApiKey = '';
foreach ($lines as $line) {
$line = trim($line);
if (!empty($line) && !preg_match('/^#/', $line)) {
$googleApiKey = $line;
break;
}
}
if (!empty($googleApiKey)) {
break;
}
}
}
}
// 서비스 계정 파일 찾기
$serviceAccountPath = null;
foreach ($serviceAccountPaths as $saPath) {
if (file_exists($saPath)) {
$serviceAccountPath = $saPath;
break;
}
}
// OAuth 2.0 토큰 생성 함수 (서비스 계정용)
function getServiceAccountToken($serviceAccountPath) {
if (!file_exists($serviceAccountPath)) {
return null;
}
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
if (!$serviceAccount || !isset($serviceAccount['private_key']) || !isset($serviceAccount['client_email'])) {
return null;
}
// Base64 URL 인코딩 함수
$base64UrlEncode = function($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
};
// JWT 생성
$now = time();
// JWT 헤더
$jwtHeader = [
'alg' => 'RS256',
'typ' => 'JWT'
];
// JWT 클레임
$jwtClaim = [
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
];
$encodedHeader = $base64UrlEncode(json_encode($jwtHeader));
$encodedClaim = $base64UrlEncode(json_encode($jwtClaim));
// 서명 생성
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if (!$privateKey) {
return null;
}
$signature = '';
$signData = $encodedHeader . '.' . $encodedClaim;
if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
openssl_free_key($privateKey);
return null;
}
openssl_free_key($privateKey);
$encodedSignature = $base64UrlEncode($signature);
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
// OAuth 2.0 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
// 타임아웃 설정
curl_setopt($tokenCh, CURLOPT_TIMEOUT, 30); // 30초
curl_setopt($tokenCh, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
$tokenResponse = curl_exec($tokenCh);
$tokenHttpCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenHttpCode === 200) {
$tokenData = json_decode($tokenResponse, true);
if (isset($tokenData['access_token'])) {
return $tokenData['access_token'];
}
}
return null;
}
// API 키가 없거나 형식이 잘못된 경우 서비스 계정 시도
if (empty($googleApiKey) || strlen($googleApiKey) < 20) {
if ($serviceAccountPath) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
}
}
if (!$useServiceAccount) {
echo json_encode([
'success' => false,
'error' => 'Google Cloud Speech-to-Text API 인증 정보가 없습니다.',
'hint' => 'API 키 파일 또는 서비스 계정 파일을 찾을 수 없습니다.',
'checked_paths' => $checkedPaths,
'service_account_path' => $serviceAccountPath,
'document_root' => $docRoot,
'current_dir' => __DIR__
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
}
// API 키 형식 확인 (Google API 키는 보통 AIza로 시작)
$isValidFormat = (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35);
if (!$isValidFormat && !$useServiceAccount) {
// API 키 형식이 잘못된 경우 서비스 계정 시도
if ($serviceAccountPath) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
$googleApiKey = ''; // API 키 사용 안 함
}
}
}
// 서비스 계정이 있으면 우선 사용 (API 키보다 안정적)
// API 키는 간헐적으로 오류가 발생할 수 있으므로 서비스 계정을 기본으로 사용
if (!$useServiceAccount && $serviceAccountPath && file_exists($serviceAccountPath)) {
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
$useServiceAccount = true;
// API 키는 백업으로 유지하되 서비스 계정 우선 사용
error_log('Using service account for authentication (more reliable than API key)');
}
}
// API 키 디버깅 정보
$debugInfo = [
'api_key_path' => $foundKeyPath,
'api_key_length' => strlen($googleApiKey),
'api_key_prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'api_key_suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'use_service_account' => $useServiceAccount,
'service_account_path' => $serviceAccountPath,
'document_root' => $docRoot,
'checked_paths' => $checkedPaths
];
try {
// 오디오 파일을 base64로 인코딩
$audioContent = file_get_contents($audioPath);
$audioBase64 = base64_encode($audioContent);
// 오디오 형식에 따른 encoding 설정
$encoding = 'WEBM_OPUS';
$sampleRate = 16000; // 기본값
if (strpos($audioType, 'wav') !== false) {
$encoding = 'LINEAR16';
$sampleRate = 16000;
} elseif (strpos($audioType, 'ogg') !== false) {
$encoding = 'OGG_OPUS';
$sampleRate = 48000; // Opus는 보통 48kHz
} elseif (strpos($audioType, 'flac') !== false) {
$encoding = 'FLAC';
$sampleRate = 16000;
} elseif (strpos($audioType, 'webm') !== false) {
$encoding = 'WEBM_OPUS';
$sampleRate = 48000; // WebM Opus는 보통 48kHz
}
// Google Cloud Speech-to-Text API 요청 데이터
$requestData = [
'config' => [
'encoding' => $encoding,
'sampleRateHertz' => $sampleRate,
'languageCode' => 'ko-KR',
'enableAutomaticPunctuation' => true,
'enableWordTimeOffsets' => false,
'model' => 'latest_long', // 긴 오디오에 적합
'alternativeLanguageCodes' => ['ko'], // 추가 언어 코드
],
'audio' => [
'content' => $audioBase64
]
];
// 디버깅 정보 (개발 환경에서만)
if (isset($_GET['debug'])) {
error_log('Google Speech API Request: ' . json_encode([
'encoding' => $encoding,
'sampleRate' => $sampleRate,
'audioSize' => strlen($audioBase64),
'audioType' => $audioType
]));
}
// cURL로 API 호출
$headers = ['Content-Type: application/json'];
$apiUrl = $googleApiUrl;
if ($useServiceAccount && $accessToken) {
// 서비스 계정 사용: Bearer 토큰 사용
$headers[] = 'Authorization: Bearer ' . $accessToken;
} else if (!empty($googleApiKey)) {
// API 키 사용: 쿼리 파라미터로 전달
$apiUrl = $googleApiUrl . '?key=' . urlencode($googleApiKey);
} else {
throw new Exception('인증 정보가 없습니다. API 키 또는 서비스 계정이 필요합니다.');
}
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// 타임아웃 설정 (504 오류 방지)
curl_setopt($ch, CURLOPT_TIMEOUT, 50); // 50초
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Exception('cURL 오류: ' . $curlError);
}
if ($httpCode !== 200) {
$errorData = json_decode($response, true);
$errorMessage = 'API 오류 (HTTP ' . $httpCode . ')';
$debugInfo = [];
if (isset($errorData['error'])) {
$errorMessage .= ': ' . ($errorData['error']['message'] ?? '알 수 없는 오류');
$debugInfo['error_details'] = $errorData['error'];
// API 키 오류인 경우 서비스 계정으로 재시도
if (isset($errorData['error']['message']) &&
(stripos($errorData['error']['message'], 'API key') !== false ||
stripos($errorData['error']['message'], 'not valid') !== false ||
stripos($errorData['error']['message'], 'invalid') !== false ||
stripos($errorData['error']['message'], 'unauthorized') !== false)) {
// 서비스 계정으로 재시도
if ($serviceAccountPath && file_exists($serviceAccountPath)) {
// 서비스 계정을 사용 중이었는데도 오류가 발생하면 토큰 재생성
if ($useServiceAccount) {
error_log('Service account token may have expired, regenerating...');
}
$accessToken = getServiceAccountToken($serviceAccountPath);
if ($accessToken) {
// 서비스 계정으로 재시도
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $accessToken
];
$ch = curl_init($googleApiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestData));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// 타임아웃 설정 (504 오류 방지)
curl_setopt($ch, CURLOPT_TIMEOUT, 50); // 50초
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
// 서비스 계정으로 성공
$result = json_decode($response, true);
if (isset($result['results']) && count($result['results']) > 0) {
$transcript = '';
foreach ($result['results'] as $resultItem) {
if (isset($resultItem['alternatives'][0]['transcript'])) {
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
echo json_encode([
'success' => true,
'transcript' => $transcript,
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
? $result['results'][0]['alternatives'][0]['confidence']
: null,
'auth_method' => 'service_account',
'retry_success' => true
]);
exit;
}
}
}
}
// 재시도 실패 시 상세 안내
$errorMessage .= "\n\n⚠️ API 키 오류 해결 방법:\n";
$errorMessage .= "1. Google Cloud Console (https://console.cloud.google.com/) 접속\n";
$errorMessage .= "2. 프로젝트 선택: codebridge-chatbot\n";
$errorMessage .= "3. 'API 및 서비스' > '라이브러리'에서 'Cloud Speech-to-Text API' 검색 및 활성화\n";
$errorMessage .= "4. 'API 및 서비스' > '사용자 인증 정보'에서 API 키 확인\n";
$errorMessage .= "5. API 키 제한 설정에서 'Cloud Speech-to-Text API' 허용 확인\n";
$errorMessage .= "\n현재 API 키 파일: " . ($foundKeyPath ?? 'N/A');
$errorMessage .= "\nAPI 키 길이: " . strlen($googleApiKey) . " 문자";
if ($serviceAccountPath) {
$errorMessage .= "\n서비스 계정 파일: " . $serviceAccountPath;
}
}
} else {
$errorMessage .= ': ' . substr($response, 0, 200);
}
// 디버그 정보에 API 키 정보 추가
$debugInfo['api_key_info'] = [
'path' => $foundKeyPath,
'length' => strlen($googleApiKey),
'prefix' => !empty($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'suffix' => !empty($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'format_valid' => !empty($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 || strlen($googleApiKey) >= 35) : false,
'use_service_account' => $useServiceAccount,
'service_account_path' => $serviceAccountPath
];
if (isset($_GET['debug'])) {
$debugInfo['full_response'] = $response;
$debugInfo['request_url'] = $useServiceAccount ? $googleApiUrl : ($googleApiUrl . '?key=' . substr($googleApiKey, 0, 10) . '...');
}
throw new Exception($errorMessage);
}
$result = json_decode($response, true);
// 결과 처리
if (isset($result['results']) && count($result['results']) > 0) {
$transcript = '';
foreach ($result['results'] as $resultItem) {
if (isset($resultItem['alternatives'][0]['transcript'])) {
$transcript .= $resultItem['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
echo json_encode([
'success' => true,
'transcript' => $transcript,
'confidence' => isset($result['results'][0]['alternatives'][0]['confidence'])
? $result['results'][0]['alternatives'][0]['confidence']
: null,
'auth_method' => $useServiceAccount ? 'service_account' : 'api_key'
]);
} else {
echo json_encode([
'success' => false,
'error' => '음성을 인식할 수 없었습니다.',
'debug' => $result
]);
}
} catch (Exception $e) {
$errorResponse = [
'success' => false,
'error' => $e->getMessage()
];
// 항상 API 키 정보 포함 (보안을 위해 일부만 표시)
$errorResponse['debug'] = [
'api_key_path' => $foundKeyPath ?? null,
'api_key_length' => isset($googleApiKey) ? strlen($googleApiKey) : 0,
'api_key_prefix' => isset($googleApiKey) ? substr($googleApiKey, 0, 10) . '...' : null,
'api_key_suffix' => isset($googleApiKey) ? '...' . substr($googleApiKey, -5) : null,
'api_key_format' => isset($googleApiKey) ? (strpos($googleApiKey, 'AIza') === 0 ? 'Google API Key (AIza...)' : 'Other format') : 'N/A',
'use_service_account' => isset($useServiceAccount) ? $useServiceAccount : false,
'service_account_path' => isset($serviceAccountPath) ? $serviceAccountPath : null,
'document_root' => $docRoot ?? null,
'checked_paths_count' => count($checkedPaths ?? [])
];
// 상세 디버그 모드일 때 추가 정보
if (isset($_GET['debug'])) {
$errorResponse['debug']['exception_class'] = get_class($e);
$errorResponse['debug']['file'] = $e->getFile();
$errorResponse['debug']['line'] = $e->getLine();
$errorResponse['debug']['checked_paths'] = $checkedPaths ?? [];
}
echo json_encode($errorResponse, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}

View File

@@ -0,0 +1,373 @@
<?php
// 출력 버퍼링 시작
while (ob_get_level()) {
ob_end_clean();
}
ob_start();
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
// 에러 응답 함수
function sendErrorResponse($message, $details = null) {
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
$response = ['ok' => false, 'error' => $message];
if ($details !== null) {
$response['details'] = $details;
}
echo json_encode($response, JSON_UNESCAPED_UNICODE);
exit;
}
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
// 1. 권한 체크
if (!isset($user_id) || $level > 5) {
sendErrorResponse('접근 권한이 없습니다.');
}
// 2. 파라미터 확인
$consult_id = isset($_GET['consult_id']) ? (int)$_GET['consult_id'] : 0;
$operation_name = isset($_GET['operation_name']) ? trim($_GET['operation_name']) : '';
if (!$consult_id || !$operation_name) {
sendErrorResponse('필수 파라미터가 없습니다.', 'consult_id와 operation_name이 필요합니다.');
}
// 3. Google API 인증 정보 가져오기
$googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json';
$googleApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_api.txt';
$accessToken = null;
$googleApiKey = null;
// 서비스 계정 우선 사용
if (file_exists($googleServiceAccountFile)) {
$serviceAccount = json_decode(file_get_contents($googleServiceAccountFile), true);
if ($serviceAccount) {
// OAuth 2.0 토큰 생성
$now = time();
$jwtHeader = ['alg' => 'RS256', 'typ' => 'JWT'];
$jwtClaim = [
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/cloud-platform',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
];
$base64UrlEncode = function($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
};
$encodedHeader = $base64UrlEncode(json_encode($jwtHeader));
$encodedClaim = $base64UrlEncode(json_encode($jwtClaim));
$privateKey = openssl_pkey_get_private($serviceAccount['private_key']);
if ($privateKey) {
$signature = '';
$signData = $encodedHeader . '.' . $encodedClaim;
if (openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
openssl_free_key($privateKey);
$encodedSignature = $base64UrlEncode($signature);
$jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature;
// OAuth 토큰 요청
$tokenCh = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($tokenCh, CURLOPT_POST, true);
curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true);
curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
// 타임아웃 설정
curl_setopt($tokenCh, CURLOPT_TIMEOUT, 30); // 30초
curl_setopt($tokenCh, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
$tokenResponse = curl_exec($tokenCh);
$tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE);
curl_close($tokenCh);
if ($tokenCode === 200) {
$tokenData = json_decode($tokenResponse, true);
if (isset($tokenData['access_token'])) {
$accessToken = $tokenData['access_token'];
}
}
}
}
}
}
// API 키 사용
if (!$accessToken && file_exists($googleApiKeyFile)) {
$googleApiKey = trim(file_get_contents($googleApiKeyFile));
}
if (!$accessToken && !$googleApiKey) {
sendErrorResponse('Google API 인증 정보가 없습니다.');
}
// 4. Google API에서 작업 상태 확인
if ($accessToken) {
$poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name);
$poll_headers = ['Authorization: Bearer ' . $accessToken];
} else {
$poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name) . '?key=' . urlencode($googleApiKey);
$poll_headers = [];
}
$poll_ch = curl_init($poll_url);
curl_setopt($poll_ch, CURLOPT_RETURNTRANSFER, true);
if (!empty($poll_headers)) {
curl_setopt($poll_ch, CURLOPT_HTTPHEADER, $poll_headers);
}
curl_setopt($poll_ch, CURLOPT_TIMEOUT, 30);
$poll_response = curl_exec($poll_ch);
$poll_code = curl_getinfo($poll_ch, CURLINFO_HTTP_CODE);
$poll_error = curl_error($poll_ch);
curl_close($poll_ch);
if ($poll_code !== 200) {
sendErrorResponse('작업 상태 확인 실패 (HTTP ' . $poll_code . ')', $poll_error ?: substr($poll_response, 0, 500));
}
$poll_data = json_decode($poll_response, true);
if (!$poll_data) {
sendErrorResponse('작업 상태 응답 파싱 실패', substr($poll_response, 0, 500));
}
// 5. 작업 완료 여부 확인
if (!isset($poll_data['done']) || $poll_data['done'] !== true) {
// 아직 처리 중
echo json_encode([
'ok' => true,
'processing' => true,
'done' => false,
'message' => '음성 인식 처리 중입니다. 잠시 후 다시 확인해주세요.'
], JSON_UNESCAPED_UNICODE);
exit;
}
// 6. 작업 완료 - 결과 처리
if (isset($poll_data['error'])) {
// 오류 발생
$pdo = db_connect();
$errorMsg = isset($poll_data['error']['message']) ? $poll_data['error']['message'] : '알 수 없는 오류';
$updateSql = "UPDATE consult_logs SET
title = ?,
summary_text = ?
WHERE id = ?";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute(['오류 발생', 'Google STT 변환 실패: ' . $errorMsg, $consult_id]);
echo json_encode([
'ok' => false,
'error' => 'Google STT 변환 실패',
'details' => $errorMsg
], JSON_UNESCAPED_UNICODE);
exit;
}
// 응답 구조 확인 및 로깅
error_log('Google STT 응답 구조: ' . json_encode([
'has_response' => isset($poll_data['response']),
'has_results' => isset($poll_data['response']['results']),
'results_count' => isset($poll_data['response']['results']) ? count($poll_data['response']['results']) : 0,
'response_keys' => isset($poll_data['response']) ? array_keys($poll_data['response']) : [],
'poll_data_keys' => array_keys($poll_data)
], JSON_UNESCAPED_UNICODE));
// response 필드가 없는 경우
if (!isset($poll_data['response'])) {
error_log('Google STT 응답에 response 필드가 없습니다. 전체 응답: ' . json_encode($poll_data, JSON_UNESCAPED_UNICODE));
sendErrorResponse('Google STT 응답 구조 오류', '응답에 response 필드가 없습니다. 작업이 완료되었지만 결과를 가져올 수 없습니다.');
}
// results가 없는 경우
if (!isset($poll_data['response']['results'])) {
error_log('Google STT 응답에 results 필드가 없습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE));
sendErrorResponse('Google STT 응답에 결과가 없습니다.', '응답에 results 필드가 없습니다. 음성이 인식되지 않았을 수 있습니다.');
}
// results가 비어있는 경우
if (empty($poll_data['response']['results'])) {
error_log('Google STT 응답에 results가 비어있습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE));
// 빈 결과를 DB에 저장하고 사용자에게 알림
$pdo = db_connect();
$updateSql = "UPDATE consult_logs SET
title = ?,
transcript_text = ?,
summary_text = ?
WHERE id = ?";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute(['음성 인식 실패', '음성이 인식되지 않았습니다.', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.', $consult_id]);
sendErrorResponse('음성 인식 실패', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.');
}
// 7. 텍스트 변환
$stt_data = ['results' => $poll_data['response']['results']];
$transcript = '';
foreach ($stt_data['results'] as $result) {
if (isset($result['alternatives'][0]['transcript'])) {
$transcript .= $result['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
if (empty($transcript)) {
sendErrorResponse('인식된 텍스트가 없습니다.');
}
// 8. Claude API로 요약 생성
$claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
$claudeKey = '';
if (file_exists($claudeKeyFile)) {
$claudeKey = trim(file_get_contents($claudeKeyFile));
}
$title = '무제 업무협의록';
$summary = '';
if (!empty($claudeKey)) {
$prompt = "다음 업무협의 녹취록을 분석하여 JSON 형식으로 응답해주세요.
요구사항:
1. \"title\": 업무협의 내용을 요약한 제목 (최대 20자, 한글 기준)
2. \"summary\": [업무협의 개요 / 주요 안건 / 결정 사항 / 향후 계획] 형식의 상세 요약
반드시 다음 JSON 형식으로만 응답해주세요:
{
\"title\": \"제목 (최대 20자)\",
\"summary\": \"상세 요약 내용\"
}
업무협의 녹취록:
" . $transcript;
$ch2 = curl_init('https://api.anthropic.com/v1/messages');
$requestBody = [
'model' => 'claude-3-5-haiku-20241022',
'max_tokens' => 2048,
'messages' => [['role' => 'user', 'content' => $prompt]]
];
curl_setopt($ch2, CURLOPT_POST, true);
curl_setopt($ch2, CURLOPT_HTTPHEADER, [
'x-api-key: ' . $claudeKey,
'anthropic-version: 2023-06-01',
'Content-Type: application/json'
]);
curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($requestBody));
curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true);
$ai_response = curl_exec($ch2);
$ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
if ($ai_code === 200) {
$ai_data = json_decode($ai_response, true);
if (isset($ai_data['content'][0]['text'])) {
$ai_text = trim($ai_data['content'][0]['text']);
$ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text);
$ai_text = preg_replace('/\s*```$/m', '', $ai_text);
$ai_text = trim($ai_text);
$parsed_data = json_decode($ai_text, true);
if (is_array($parsed_data)) {
if (isset($parsed_data['title']) && !empty($parsed_data['title'])) {
$title = mb_substr(trim($parsed_data['title']), 0, 20, 'UTF-8');
}
if (isset($parsed_data['summary']) && !empty($parsed_data['summary'])) {
$summary = $parsed_data['summary'];
} else {
$summary = $ai_text;
}
} else {
$summary = $ai_text;
if (preg_match('/["\']?title["\']?\s*[:=]\s*["\']([^"\']{1,20})["\']/', $ai_text, $matches)) {
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
} elseif (preg_match('/제목[:\s]+([^\n]{1,20})/', $ai_text, $matches)) {
$title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8');
} else {
$lines = explode("\n", $ai_text);
$first_line = trim($lines[0]);
if (!empty($first_line) && mb_strlen($first_line, 'UTF-8') <= 20) {
$title = $first_line;
} elseif (!empty($first_line)) {
$title = mb_substr($first_line, 0, 20, 'UTF-8');
}
}
}
}
}
}
if (empty($title) || $title === '무제 업무협의록') {
if (!empty($summary)) {
$summary_lines = explode("\n", $summary);
$first_summary_line = trim($summary_lines[0]);
if (!empty($first_summary_line)) {
$first_summary_line = preg_replace('/[\[\(].*?[\]\)]/', '', $first_summary_line);
$first_summary_line = trim($first_summary_line);
if (mb_strlen($first_summary_line, 'UTF-8') <= 20) {
$title = $first_summary_line;
} else {
$title = mb_substr($first_summary_line, 0, 20, 'UTF-8');
}
}
}
}
$title = mb_substr(trim($title), 0, 20, 'UTF-8');
if (empty($title)) {
$title = '무제 업무협의록';
}
if (empty($summary)) {
$summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)";
}
// 9. DB 업데이트
$pdo = db_connect();
$updateSql = "UPDATE consult_logs SET
title = ?,
transcript_text = ?,
summary_text = ?
WHERE id = ?";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute([$title, $transcript, $summary, $consult_id]);
// 10. 완료 응답
@ob_clean();
@ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'processing' => false,
'done' => true,
'consult_id' => $consult_id,
'title' => $title,
'transcript' => $transcript,
'summary' => $summary,
'message' => '음성 인식 및 요약이 완료되었습니다.'
], JSON_UNESCAPED_UNICODE);
?>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff