Files
sam-manage/app/Services/GoogleCloudService.php
김보곤 b2fbd3d113 feat:회의록 자동 화자 분리(Phase 2) 구현 및 세그먼트 저장 에러 수정
- GoogleCloudService에 speechToTextWithDiarization 메서드 추가
- Google STT V1 diarizationConfig 활성화로 자동 화자 구분
- MeetingMinuteService에 processDiarization 메서드 추가
- POST /{id}/diarize 엔드포인트 및 라우트 추가
- 프론트엔드에 '화자 분리' 버튼 추가 (RecordingControlBar)
- saveSegments 컨트롤러에 try-catch 에러 핸들링 추가
- 빈 텍스트 세그먼트 필터링 로직 추가 (서버/클라이언트 양쪽)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:29:16 +09:00

587 lines
18 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Google Cloud 서비스 (Storage, Speech-to-Text)
*/
class GoogleCloudService
{
private ?array $serviceAccount = null;
private ?string $accessToken = null;
private ?int $tokenExpiry = null;
public function __construct()
{
$this->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;
}
}