googleCloud = $googleCloud; } /** * 텍스트 → MP3 음성 파일 생성 * * @return string|null 저장된 파일 경로 */ public function synthesize(string $text, string $savePath, array $options = []): ?string { $token = $this->googleCloud->getAccessToken(); if (! $token) { Log::error('TtsService: 액세스 토큰 획득 실패'); return null; } try { $languageCode = $options['language_code'] ?? 'ko-KR'; $voiceName = $options['voice_name'] ?? 'ko-KR-Neural2-A'; $speakingRate = $options['speaking_rate'] ?? 1.4; $pitch = $options['pitch'] ?? 0.0; // TTS 전송 전 이모지/특수문자 제거 (읽어버리는 문제 방지) $cleanText = $this->stripNonSpeechChars($text); $response = Http::withToken($token) ->timeout(30) ->post('https://texttospeech.googleapis.com/v1/text:synthesize', [ 'input' => [ 'text' => $cleanText, ], 'voice' => [ 'languageCode' => $languageCode, 'name' => $voiceName, ], 'audioConfig' => [ 'audioEncoding' => 'MP3', 'speakingRate' => $speakingRate, 'pitch' => $pitch, 'sampleRateHertz' => 24000, ], ]); if (! $response->successful()) { Log::error('TtsService: TTS API 실패', [ 'status' => $response->status(), 'body' => $response->body(), ]); return null; } $data = $response->json(); $audioContent = $data['audioContent'] ?? null; if (! $audioContent) { Log::error('TtsService: 오디오 데이터 없음'); return null; } $dir = dirname($savePath); if (! is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents($savePath, base64_decode($audioContent)); Log::info('TtsService: 음성 파일 생성 완료', [ 'path' => $savePath, 'text_length' => mb_strlen($text), ]); return $savePath; } catch (\Exception $e) { Log::error('TtsService: 예외 발생', ['error' => $e->getMessage()]); return null; } } /** * 장면별 일괄 나레이션 생성 * * @param array $scenes [{narration, scene_number}, ...] * @return array [scene_number => file_path, ...] */ public function synthesizeScenes(array $scenes, string $baseDir): array { $results = []; foreach ($scenes as $scene) { $sceneNum = $scene['scene_number'] ?? 0; $narration = $scene['narration'] ?? ''; if (empty($narration)) { continue; } $savePath = "{$baseDir}/narration_{$sceneNum}.mp3"; $result = $this->synthesize($narration, $savePath); if ($result) { $results[$sceneNum] = $result; } } return $results; } /** * TTS에 보내기 전 이모지, 특수문자, 동작 표현 제거 */ private function stripNonSpeechChars(string $text): string { // 이모지 유니코드 블록 제거 $text = preg_replace('/[\x{1F600}-\x{1F64F}]/u', '', $text); // 이모티콘 $text = preg_replace('/[\x{1F300}-\x{1F5FF}]/u', '', $text); // 기호/픽토그래프 $text = preg_replace('/[\x{1F680}-\x{1F6FF}]/u', '', $text); // 교통/지도 $text = preg_replace('/[\x{1F900}-\x{1F9FF}]/u', '', $text); // 보충 이모지 $text = preg_replace('/[\x{1FA00}-\x{1FA6F}]/u', '', $text); // 체스/확장 $text = preg_replace('/[\x{1FA70}-\x{1FAFF}]/u', '', $text); // 심볼 확장 $text = preg_replace('/[\x{2600}-\x{27BF}]/u', '', $text); // 기타 심볼 $text = preg_replace('/[\x{2700}-\x{27BF}]/u', '', $text); // Dingbats $text = preg_replace('/[\x{FE00}-\x{FE0F}]/u', '', $text); // 변형 선택자 $text = preg_replace('/[\x{200D}]/u', '', $text); // Zero Width Joiner $text = preg_replace('/[\x{20E3}]/u', '', $text); // 키캡 $text = preg_replace('/[\x{E0020}-\x{E007F}]/u', '', $text); // 태그 // *강조* 스타일 텍스트에서 별표만 제거 (내용은 유지) $text = str_replace('*', '', $text); // (효과음), [동작] 등 괄호 표현 제거 $text = preg_replace('/\([^)]*\)/', '', $text); $text = preg_replace('/\[[^\]]*\]/', '', $text); // ○, ●, ◎ 등 특수 기호 제거 $text = preg_replace('/[○●◎◇◆□■△▲▽▼★☆♡♥]/u', '', $text); // 연속 공백 정리 $text = preg_replace('/\s+/', ' ', $text); return trim($text); } }