loadServiceAccount(); } /** * 서비스 계정 로드 */ private function loadServiceAccount(): void { $path = config('services.google.credentials_path'); if ($path && file_exists($path)) { $this->serviceAccount = json_decode(file_get_contents($path), true); } } /** * OAuth 토큰 발급 */ private function getAccessToken(): ?string { // 캐시된 토큰이 유효하면 재사용 if ($this->accessToken && $this->tokenExpiry && time() < $this->tokenExpiry - 60) { return $this->accessToken; } if (! $this->serviceAccount) { Log::error('Google Cloud: 서비스 계정 파일이 없습니다.'); return null; } try { $now = time(); $jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $jwtClaim = base64_encode(json_encode([ 'iss' => $this->serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/cloud-platform', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now, ])); $privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']); if (! $privateKey) { Log::error('Google Cloud: 개인 키 읽기 실패'); return null; } openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); $jwt = $jwtHeader.'.'.$jwtClaim.'.'.base64_encode($signature); $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]); if ($response->successful()) { $data = $response->json(); $this->accessToken = $data['access_token']; $this->tokenExpiry = $now + ($data['expires_in'] ?? 3600); return $this->accessToken; } Log::error('Google Cloud: OAuth 토큰 발급 실패', ['response' => $response->body()]); return null; } catch (\Exception $e) { Log::error('Google Cloud: OAuth 토큰 발급 예외', ['error' => $e->getMessage()]); return null; } } /** * GCS에 파일 업로드 * @return array|null ['uri' => 'gs://...', 'size' => bytes] or null */ public function uploadToStorage(string $localPath, string $objectName): ?array { $token = $this->getAccessToken(); if (! $token) { return null; } $bucket = config('services.google.storage_bucket'); if (! $bucket) { Log::error('Google Cloud: Storage 버킷 설정 없음'); return null; } try { $fileContent = file_get_contents($localPath); $fileSize = strlen($fileContent); $mimeType = mime_content_type($localPath) ?: 'audio/webm'; $uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/'. urlencode($bucket).'/o?uploadType=media&name='. urlencode($objectName); $response = Http::withToken($token) ->withHeaders(['Content-Type' => $mimeType]) ->withBody($fileContent, $mimeType) ->post($uploadUrl); if ($response->successful()) { $result = $response->json(); Log::info('Google Cloud: Storage 업로드 성공', [ 'object' => $objectName, 'size' => $result['size'] ?? $fileSize, 'bucket' => $bucket, ]); return [ 'uri' => 'gs://'.$bucket.'/'.$objectName, 'size' => (int) ($result['size'] ?? $fileSize), ]; } Log::error('Google Cloud: Storage 업로드 실패', ['response' => $response->body()]); return null; } catch (\Exception $e) { Log::error('Google Cloud: Storage 업로드 예외', ['error' => $e->getMessage()]); return null; } } /** * Base64 오디오를 GCS에 업로드 * @return array|null ['uri' => 'gs://...', 'size' => bytes] or null */ public function uploadBase64Audio(string $base64Audio, string $objectName): ?array { // Base64 데이터 파싱 $audioData = $base64Audio; if (preg_match('/^data:audio\/\w+;base64,(.+)$/', $base64Audio, $matches)) { $audioData = $matches[1]; } // 임시 파일 생성 $tempPath = storage_path('app/temp/'.uniqid('audio_').'.webm'); $tempDir = dirname($tempPath); if (! is_dir($tempDir)) { mkdir($tempDir, 0755, true); } file_put_contents($tempPath, base64_decode($audioData)); // GCS 업로드 $result = $this->uploadToStorage($tempPath, $objectName); // 임시 파일 삭제 @unlink($tempPath); return $result; } /** * Speech-to-Text API 호출 */ public function speechToText(string $gcsUri, string $languageCode = 'ko-KR'): ?string { $token = $this->getAccessToken(); if (! $token) { return null; } try { // 긴 오디오는 비동기 처리 (LongRunningRecognize) $response = Http::withToken($token) ->post('https://speech.googleapis.com/v1/speech:longrunningrecognize', [ 'config' => [ 'encoding' => 'WEBM_OPUS', 'sampleRateHertz' => 48000, 'languageCode' => $languageCode, 'enableAutomaticPunctuation' => true, 'model' => 'latest_long', ], 'audio' => [ 'uri' => $gcsUri, ], ]); if (! $response->successful()) { Log::error('Google Cloud: STT 요청 실패', ['response' => $response->body()]); return null; } $operation = $response->json(); $operationName = $operation['name'] ?? null; Log::info('Google Cloud: STT 요청 응답', ['operation' => $operation]); if (! $operationName) { Log::error('Google Cloud: STT 작업 이름 없음', ['response_body' => $response->body()]); return null; } // 작업 완료 대기 (폴링) $result = $this->waitForSttOperation($operationName); Log::info('Google Cloud: STT 완료', ['operationName' => $operationName, 'result_length' => strlen($result ?? '')]); return $result; } catch (\Exception $e) { Log::error('Google Cloud: STT 예외', ['error' => $e->getMessage()]); return null; } } /** * STT 작업 완료 대기 */ private function waitForSttOperation(string $operationName, int $maxAttempts = 60): ?string { $token = $this->getAccessToken(); if (! $token) { Log::error('Google Cloud: STT 폴링 토큰 획득 실패'); return null; } for ($i = 0; $i < $maxAttempts; $i++) { sleep(5); // 5초 대기 $response = Http::withToken($token) ->get("https://speech.googleapis.com/v1/operations/{$operationName}"); if (! $response->successful()) { continue; } $result = $response->json(); if (isset($result['done']) && $result['done']) { if (isset($result['error'])) { Log::error('Google Cloud: STT 작업 실패', ['error' => $result['error']]); return null; } // 결과 텍스트 추출 $transcript = ''; $results = $result['response']['results'] ?? []; foreach ($results as $res) { $alternatives = $res['alternatives'] ?? []; if (! empty($alternatives)) { $transcript .= $alternatives[0]['transcript'] ?? ''; } } return $transcript; } } Log::error('Google Cloud: STT 작업 타임아웃'); return null; } /** * Speaker Diarization을 포함한 Speech-to-Text API 호출 * * @return array|null ['segments' => [...], 'full_transcript' => '...'] */ public function speechToTextWithDiarization( string $gcsUri, string $languageCode = 'ko-KR', int $minSpeakers = 2, int $maxSpeakers = 6 ): ?array { $token = $this->getAccessToken(); if (! $token) { return null; } try { $response = Http::withToken($token) ->post('https://speech.googleapis.com/v1/speech:longrunningrecognize', [ 'config' => [ 'encoding' => 'WEBM_OPUS', 'sampleRateHertz' => 48000, 'languageCode' => $languageCode, 'enableAutomaticPunctuation' => true, 'model' => 'latest_long', 'enableWordTimeOffsets' => true, 'diarizationConfig' => [ 'enableSpeakerDiarization' => true, 'minSpeakerCount' => $minSpeakers, 'maxSpeakerCount' => $maxSpeakers, ], ], 'audio' => [ 'uri' => $gcsUri, ], ]); if (! $response->successful()) { Log::error('Google Cloud: STT Diarization 요청 실패', ['response' => $response->body()]); return null; } $operation = $response->json(); $operationName = $operation['name'] ?? null; if (! $operationName) { Log::error('Google Cloud: STT Diarization 작업 이름 없음'); return null; } Log::info('Google Cloud: STT Diarization 요청 시작', ['operationName' => $operationName]); $rawResult = $this->waitForSttDiarizationOperation($operationName); if (! $rawResult) { return null; } return $this->parseDiarizationResult($rawResult); } catch (\Exception $e) { Log::error('Google Cloud: STT Diarization 예외', ['error' => $e->getMessage()]); return null; } } /** * STT Diarization 작업 완료 대기 (raw 결과 반환) */ private function waitForSttDiarizationOperation(string $operationName, int $maxAttempts = 60): ?array { $token = $this->getAccessToken(); if (! $token) { return null; } for ($i = 0; $i < $maxAttempts; $i++) { sleep(5); $response = Http::withToken($token) ->get("https://speech.googleapis.com/v1/operations/{$operationName}"); if (! $response->successful()) { continue; } $result = $response->json(); if (isset($result['done']) && $result['done']) { if (isset($result['error'])) { Log::error('Google Cloud: STT Diarization 작업 실패', ['error' => $result['error']]); return null; } return $result; } } Log::error('Google Cloud: STT Diarization 작업 타임아웃'); return null; } /** * Diarization 결과를 화자별 세그먼트로 파싱 */ private function parseDiarizationResult(array $operationResult): ?array { $results = $operationResult['response']['results'] ?? []; if (empty($results)) { return null; } // Diarization 결과는 마지막 result의 alternatives[0].words에 전체 word-level 정보가 있음 $lastResult = end($results); $words = $lastResult['alternatives'][0]['words'] ?? []; if (empty($words)) { // word-level 결과 없으면 일반 transcript로 폴백 $transcript = ''; foreach ($results as $res) { $transcript .= ($res['alternatives'][0]['transcript'] ?? '') . ' '; } return [ 'segments' => [[ 'speaker_name' => '화자 1', 'speaker_label' => '1', 'text' => trim($transcript), 'start_time_ms' => 0, 'end_time_ms' => null, 'is_manual_speaker' => false, ]], 'full_transcript' => '[화자 1] ' . trim($transcript), 'speaker_count' => 1, ]; } // word-level 화자 정보를 세그먼트로 그룹핑 $segments = []; $currentSpeaker = null; $currentWords = []; $segmentStartMs = 0; foreach ($words as $word) { $speakerTag = $word['speakerTag'] ?? 0; $wordText = $word['word'] ?? ''; $startMs = $this->parseGoogleTimeToMs($word['startTime'] ?? '0s'); $endMs = $this->parseGoogleTimeToMs($word['endTime'] ?? '0s'); if ($speakerTag !== $currentSpeaker && $currentSpeaker !== null && ! empty($currentWords)) { $segments[] = [ 'speaker_name' => '화자 ' . $currentSpeaker, 'speaker_label' => (string) $currentSpeaker, 'text' => trim(implode(' ', $currentWords)), 'start_time_ms' => $segmentStartMs, 'end_time_ms' => $startMs, 'is_manual_speaker' => false, ]; $currentWords = []; $segmentStartMs = $startMs; } $currentSpeaker = $speakerTag; $currentWords[] = $wordText; } // 마지막 세그먼트 if (! empty($currentWords)) { $lastWord = end($words); $segments[] = [ 'speaker_name' => '화자 ' . $currentSpeaker, 'speaker_label' => (string) $currentSpeaker, 'text' => trim(implode(' ', $currentWords)), 'start_time_ms' => $segmentStartMs, 'end_time_ms' => $this->parseGoogleTimeToMs($lastWord['endTime'] ?? '0s'), 'is_manual_speaker' => false, ]; } // full_transcript 생성 $fullTranscript = ''; foreach ($segments as $seg) { $fullTranscript .= "[{$seg['speaker_name']}] {$seg['text']}\n"; } // 고유 화자 수 $speakerCount = count(array_unique(array_column($segments, 'speaker_label'))); return [ 'segments' => $segments, 'full_transcript' => trim($fullTranscript), 'speaker_count' => $speakerCount, ]; } /** * Google STT 시간 형식("1.500s")을 밀리초로 변환 */ private function parseGoogleTimeToMs(string $timeStr): int { if (preg_match('/^([\d.]+)s$/', $timeStr, $matches)) { return (int) round((float) $matches[1] * 1000); } return 0; } /** * GCS 파일 삭제 */ public function deleteFromStorage(string $objectName): bool { $token = $this->getAccessToken(); if (! $token) { return false; } $bucket = config('services.google.storage_bucket'); if (! $bucket) { return false; } try { $deleteUrl = 'https://storage.googleapis.com/storage/v1/b/'. urlencode($bucket).'/o/'.urlencode($objectName); $response = Http::withToken($token)->delete($deleteUrl); return $response->successful(); } catch (\Exception $e) { Log::error('Google Cloud: Storage 삭제 예외', ['error' => $e->getMessage()]); return false; } } /** * GCS에서 파일 다운로드 (스트림) */ public function downloadFromStorage(string $objectName): ?string { $token = $this->getAccessToken(); if (! $token) { Log::error('Google Cloud: 다운로드 토큰 획득 실패'); return null; } $bucket = config('services.google.storage_bucket'); if (! $bucket) { Log::error('Google Cloud: Storage 버킷 설정 없음'); return null; } try { $url = 'https://storage.googleapis.com/storage/v1/b/'. urlencode($bucket).'/o/'.urlencode($objectName).'?alt=media'; $response = Http::withToken($token)->get($url); if ($response->successful()) { Log::info('Google Cloud: Storage 다운로드 성공', [ 'object' => $objectName, 'size' => strlen($response->body()), ]); return $response->body(); } Log::error('Google Cloud: Storage 다운로드 실패', [ 'status' => $response->status(), 'response' => $response->body(), ]); return null; } catch (\Exception $e) { Log::error('Google Cloud: Storage 다운로드 예외', ['error' => $e->getMessage()]); return null; } } /** * 서비스 사용 가능 여부 */ public function isAvailable(): bool { return $this->serviceAccount !== null && $this->getAccessToken() !== null; } }