baseUrl = config('services.gemini.base_url'); $this->apiKey = config('services.gemini.api_key'); } /** * 나레이션 목록 */ public function index(Request $request): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.cm-song.index')); } $songs = CmSong::with('user') ->orderByDesc('created_at') ->paginate(20); return view('rd.cm-song.index', compact('songs')); } /** * 나레이션 제작 페이지 */ public function create(Request $request): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.cm-song.create')); } return view('rd.cm-song.create'); } /** * 나레이션 상세 */ public function show(Request $request, int $id): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.cm-song.show', $id)); } $song = CmSong::with('user')->findOrFail($id); return view('rd.cm-song.show', compact('song')); } /** * 나레이션 가사 생성 (Gemini API) */ public function generateLyrics(Request $request): JsonResponse { $request->validate([ 'company_name' => 'required|string|max:100', 'industry' => 'required|string|max:200', 'mood' => 'required|string|max:50', 'duration' => 'required|integer|min:10|max:60', ]); $duration = $request->duration; $lines = match (true) { $duration <= 15 => '3~4줄', $duration <= 30 => '6~8줄', $duration <= 45 => '10~12줄', default => '14~16줄', }; $prompt = "당신은 전문 나레이션 작사가입니다. 다음 정보를 바탕으로 기억에 남는 {$duration}초 분량의 라디오 나레이션 가사를 작성해주세요. 회사명: {$request->company_name} 업종/제품: {$request->industry} 분위기: {$request->mood} 조건: - {$lines}로 작성 ({$duration}초 분량) - 운율을 살려서 작성 - 지시문(예: (음악 소리), (밝은 목소리로)) 없이 오직 읽을 수 있는 가사 텍스트만 출력할 것."; try { $response = Http::timeout(30)->post( "{$this->baseUrl}/models/gemini-2.5-flash:generateContent?key={$this->apiKey}", [ 'contents' => [ ['parts' => [['text' => $prompt]]], ], ] ); if (! $response->successful()) { return response()->json([ 'success' => false, 'error' => '가사 생성에 실패했습니다: '.$response->status(), ], 500); } $data = $response->json(); $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; return response()->json([ 'success' => true, 'lyrics' => trim($text), ]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'error' => '가사 생성 중 오류: '.$e->getMessage(), ], 500); } } /** * TTS 음성 생성 (Gemini TTS API) */ public function generateAudio(Request $request): JsonResponse { $request->validate([ 'lyrics' => 'required|string|max:2000', ]); try { $response = Http::timeout(60)->post( "{$this->baseUrl}/models/gemini-2.5-flash-preview-tts:generateContent?key={$this->apiKey}", [ 'contents' => [ ['parts' => [['text' => $request->lyrics]]], ], 'generationConfig' => [ 'responseModalities' => ['AUDIO'], 'speechConfig' => [ 'voiceConfig' => [ 'prebuiltVoiceConfig' => [ 'voiceName' => 'Kore', ], ], ], ], ] ); if (! $response->successful()) { return response()->json([ 'success' => false, 'error' => '음성 생성에 실패했습니다: '.$response->status(), ], 500); } $data = $response->json(); $inlineData = $data['candidates'][0]['content']['parts'][0]['inlineData'] ?? null; if (! $inlineData || empty($inlineData['data'])) { return response()->json([ 'success' => false, 'error' => '음성 데이터를 받지 못했습니다.', ], 500); } return response()->json([ 'success' => true, 'audio_data' => $inlineData['data'], 'mime_type' => $inlineData['mimeType'] ?? 'audio/L16;rate=24000', ]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'error' => '음성 생성 중 오류: '.$e->getMessage(), ], 500); } } /** * 나레이션 저장 */ public function store(Request $request): JsonResponse { $request->validate([ 'company_name' => 'required|string|max:100', 'industry' => 'required|string|max:200', 'mood' => 'required|string|max:50', 'duration' => 'required|integer|min:10|max:60', 'lyrics' => 'required|string|max:2000', 'audio_data' => 'nullable|string', 'audio_mime_type' => 'nullable|string', ]); $tenantId = session('selected_tenant_id', 1); $userId = Auth::id(); $audioPath = null; // 오디오 데이터가 있으면 WAV 파일로 저장 if ($request->audio_data) { $mimeType = $request->audio_mime_type ?? 'audio/L16;rate=24000'; $audioBytes = base64_decode($request->audio_data); if (str_contains($mimeType, 'L16') || str_contains($mimeType, 'pcm')) { $sampleRate = 24000; if (preg_match('/rate=(\d+)/', $mimeType, $m)) { $sampleRate = (int) $m[1]; } $audioBytes = $this->pcmToWav($audioBytes, $sampleRate); } $filename = 'cm-song-'.date('Ymd-His').'-'.uniqid().'.wav'; $dir = "cm-songs/{$tenantId}"; Storage::disk('tenant')->makeDirectory($dir); Storage::disk('tenant')->put("{$dir}/{$filename}", $audioBytes); $audioPath = "{$dir}/{$filename}"; } $song = CmSong::create([ 'tenant_id' => $tenantId, 'user_id' => $userId, 'company_name' => $request->company_name, 'industry' => $request->industry, 'lyrics' => $request->lyrics, 'audio_path' => $audioPath, 'options' => [ 'mood' => $request->mood, 'duration' => $request->duration, ], ]); return response()->json([ 'success' => true, 'id' => $song->id, 'message' => '나레이션이 저장되었습니다.', ]); } /** * 음성 파일 다운로드 */ public function download(int $id) { $song = CmSong::findOrFail($id); if (! $song->audio_path || ! Storage::disk('tenant')->exists($song->audio_path)) { abort(404, '음성 파일이 없습니다.'); } $filename = "나레이션_{$song->company_name}_".date('Ymd', strtotime($song->created_at)).'.wav'; return Storage::disk('tenant')->download($song->audio_path, $filename); } /** * 나레이션 삭제 */ public function destroy(int $id): JsonResponse { $song = CmSong::findOrFail($id); if ($song->audio_path && Storage::disk('tenant')->exists($song->audio_path)) { Storage::disk('tenant')->delete($song->audio_path); } $song->delete(); return response()->json([ 'success' => true, 'message' => '나레이션이 삭제되었습니다.', ]); } /** * PCM → WAV 변환 (서버사이드) */ private function pcmToWav(string $pcmData, int $sampleRate): string { $numChannels = 1; $bitsPerSample = 16; $byteRate = $sampleRate * $numChannels * $bitsPerSample / 8; $blockAlign = $numChannels * $bitsPerSample / 8; $dataSize = strlen($pcmData); $header = pack('A4VVA4', 'RIFF', 36 + $dataSize, 0x45564157, 'WAVEfmt '); // 'WAVE' as little-endian is 0x45564157... actually let me write it properly $header = 'RIFF'; $header .= pack('V', 36 + $dataSize); $header .= 'WAVE'; $header .= 'fmt '; $header .= pack('V', 16); // SubChunk1Size $header .= pack('v', 1); // AudioFormat (PCM) $header .= pack('v', $numChannels); $header .= pack('V', $sampleRate); $header .= pack('V', $byteRate); $header .= pack('v', $blockAlign); $header .= pack('v', $bitsPerSample); $header .= 'data'; $header .= pack('V', $dataSize); return $header.$pcmData; } }