- RdController: 사운드로고-AI생성, 사운드로고-TTS 토큰 기록 - CmSongController: 나레이션-가사생성, 나레이션-TTS 토큰 기록 - AI 토큰 사용량 UI에 사운드로고/나레이션 카테고리 분류 추가
323 lines
10 KiB
PHP
323 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Rd;
|
|
|
|
use App\Helpers\AiTokenHelper;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Rd\CmSong;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\View\View;
|
|
|
|
class CmSongController extends Controller
|
|
{
|
|
private string $baseUrl;
|
|
|
|
private string $apiKey;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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();
|
|
|
|
// 토큰 사용량 기록
|
|
AiTokenHelper::saveGeminiUsage($data, 'gemini-2.5-flash', '나레이션-가사생성');
|
|
|
|
$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();
|
|
|
|
// 토큰 사용량 기록
|
|
AiTokenHelper::saveGeminiUsage($data, 'gemini-2.5-flash-preview-tts', '나레이션-TTS');
|
|
|
|
$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;
|
|
}
|
|
}
|