Files
sam-manage/app/Http/Controllers/Rd/CmSongController.php
김보곤 d02c142f65 feat: [rd] 사운드로고/나레이션 AI 토큰 사용량 기록 추가
- RdController: 사운드로고-AI생성, 사운드로고-TTS 토큰 기록
- CmSongController: 나레이션-가사생성, 나레이션-TTS 토큰 기록
- AI 토큰 사용량 UI에 사운드로고/나레이션 카테고리 분류 추가
2026-03-08 12:57:29 +09:00

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;
}
}