feat: [rd] AI CM송 제작 기능 추가

- Gemini API 기반 CM송 가사 생성 + TTS 음성 생성
- 연구개발 대시보드에 CM송 제작 카드 추가
- 서버사이드 API 프록시로 API 키 보호
This commit is contained in:
김보곤
2026-03-05 14:13:41 +09:00
parent 9727a092e6
commit 0e9f1297b8
4 changed files with 520 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers\Rd;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
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');
}
/**
* CM송 제작 페이지
*/
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'));
}
return view('rd.cm-song.index');
}
/**
* CM송 가사 생성 (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',
]);
$prompt = "당신은 전문 CM송 작사가입니다. 다음 정보를 바탕으로 짧고 기억에 남는 15초 분량의 라디오 CM송 가사를 작성해주세요.
회사명: {$request->company_name}
업종/제품: {$request->industry}
분위기: {$request->mood}
조건:
- 3~4줄로 짧게 작성
- 운율을 살려서 작성
- 지시문(예: (음악 소리), (밝은 목소리로)) 없이 오직 읽을 수 있는 가사 텍스트만 출력할 것.";
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);
}
}
}

View File

@@ -0,0 +1,348 @@
@extends('layouts.app')
@section('title', 'AI CM송 제작')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
<i class="ri-music-2-line text-indigo-600"></i>
AI CM송 제작
</h1>
<a href="{{ route('rd.index') }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
<i class="ri-arrow-left-line mr-1"></i> R&D 대시보드
</a>
</div>
<!-- 메인 콘텐츠 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 입력 섹션 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-5 flex items-center gap-2">
<i class="ri-edit-line text-indigo-500"></i>
CM송 정보 입력
</h2>
<div class="space-y-5">
<!-- 회사명 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5 flex items-center gap-1.5">
<i class="ri-building-line text-gray-400"></i>
회사명 / 브랜드명
</label>
<input
type="text"
id="companyName"
placeholder="예: 코드브릿지엑스"
class="w-full px-4 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition outline-none"
/>
</div>
<!-- 업종/제품 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5 flex items-center gap-1.5">
<i class="ri-briefcase-line text-gray-400"></i>
업종 / 주요 제품
</label>
<input
type="text"
id="industry"
placeholder="예: ERP 솔루션, 스마트 공장 자동화"
class="w-full px-4 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition outline-none"
/>
</div>
<!-- 분위기 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5 flex items-center gap-1.5">
<i class="ri-emotion-happy-line text-gray-400"></i>
원하는 분위기
</label>
<div class="grid grid-cols-2 gap-3" id="moodSelector">
<button type="button" data-mood="신나는" class="mood-btn selected py-2.5 px-4 rounded-lg text-sm font-medium transition border-2 border-indigo-200 bg-indigo-50 text-indigo-700">
신나는
</button>
<button type="button" data-mood="신뢰감 있는" class="mood-btn py-2.5 px-4 rounded-lg text-sm font-medium transition border-2 border-gray-100 bg-white text-gray-600 hover:border-gray-200">
신뢰감 있는
</button>
<button type="button" data-mood="감성적인" class="mood-btn py-2.5 px-4 rounded-lg text-sm font-medium transition border-2 border-gray-100 bg-white text-gray-600 hover:border-gray-200">
감성적인
</button>
<button type="button" data-mood="유머러스한" class="mood-btn py-2.5 px-4 rounded-lg text-sm font-medium transition border-2 border-gray-100 bg-white text-gray-600 hover:border-gray-200">
유머러스한
</button>
</div>
</div>
<!-- 생성 버튼 -->
<button
type="button"
id="generateBtn"
class="w-full py-3.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg font-medium flex items-center justify-center gap-2 transition shadow-sm hover:shadow-md"
>
<i class="ri-sparkling-line"></i>
CM송 만들기
</button>
</div>
</div>
<!-- 결과 섹션 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex flex-col">
<h2 class="text-lg font-semibold text-gray-800 mb-5 flex items-center gap-2">
<i class="ri-music-2-line text-indigo-500"></i>
생성된 CM송
</h2>
<div class="flex-1 flex flex-col justify-center" id="resultArea">
<!-- 초기 상태 -->
<div id="emptyState" class="text-center text-gray-400 py-12">
<i class="ri-music-2-line text-5xl mb-3 block opacity-20"></i>
<p class="text-sm">정보를 입력하고 버튼을 누르면<br>이곳에 CM송이 나타납니다.</p>
</div>
<!-- 로딩 상태 -->
<div id="loadingState" class="text-center text-indigo-400 py-12 hidden">
<i class="ri-loader-4-line text-5xl mb-3 block animate-spin"></i>
<p class="text-sm" id="loadingText">AI가 멋진 가사를 쓰고 있어요...</p>
</div>
<!-- 결과 -->
<div id="resultState" class="space-y-6 hidden">
<!-- 가사 -->
<div class="bg-gray-50 p-6 rounded-xl border border-gray-100">
<p id="lyricsText" class="text-lg leading-relaxed text-gray-800 whitespace-pre-line font-medium text-center"></p>
</div>
<!-- 오디오 플레이어 -->
<div id="audioSection" class="hidden">
<div class="flex flex-col items-center gap-4">
<audio id="audioPlayer" class="hidden"></audio>
<button
type="button"
id="playBtn"
class="w-16 h-16 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full flex items-center justify-center shadow-lg hover:shadow-xl transition hover:scale-105"
>
<i id="playIcon" class="ri-play-fill text-3xl ml-0.5"></i>
</button>
<p id="playLabel" class="text-sm text-gray-500 font-medium">들어보기</p>
</div>
</div>
<!-- 다시 만들기 -->
<div class="text-center pt-2">
<button
type="button"
id="retryBtn"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
>
<i class="ri-refresh-line mr-1"></i> 다시 만들기
</button>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
const companyInput = document.getElementById('companyName');
const industryInput = document.getElementById('industry');
const generateBtn = document.getElementById('generateBtn');
const moodSelector = document.getElementById('moodSelector');
const emptyState = document.getElementById('emptyState');
const loadingState = document.getElementById('loadingState');
const loadingText = document.getElementById('loadingText');
const resultState = document.getElementById('resultState');
const lyricsText = document.getElementById('lyricsText');
const audioSection = document.getElementById('audioSection');
const audioPlayer = document.getElementById('audioPlayer');
const playBtn = document.getElementById('playBtn');
const playIcon = document.getElementById('playIcon');
const playLabel = document.getElementById('playLabel');
const retryBtn = document.getElementById('retryBtn');
let selectedMood = '신나는';
let isPlaying = false;
// 분위기 선택
moodSelector.addEventListener('click', function (e) {
const btn = e.target.closest('.mood-btn');
if (!btn) return;
selectedMood = btn.dataset.mood;
document.querySelectorAll('.mood-btn').forEach(b => {
b.classList.remove('selected', 'border-indigo-200', 'bg-indigo-50', 'text-indigo-700');
b.classList.add('border-gray-100', 'bg-white', 'text-gray-600');
});
btn.classList.add('selected', 'border-indigo-200', 'bg-indigo-50', 'text-indigo-700');
btn.classList.remove('border-gray-100', 'bg-white', 'text-gray-600');
});
// PCM → WAV 변환
function pcmToWav(pcmData, sampleRate) {
const numChannels = 1;
const bitsPerSample = 16;
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
const blockAlign = (numChannels * bitsPerSample) / 8;
const dataSize = pcmData.length;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
function writeString(offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
writeString(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
writeString(36, 'data');
view.setUint32(40, dataSize, true);
new Uint8Array(buffer, 44).set(pcmData);
return new Blob([buffer], { type: 'audio/wav' });
}
function base64ToUint8Array(base64) {
const binaryString = window.atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
function showState(state) {
emptyState.classList.add('hidden');
loadingState.classList.add('hidden');
resultState.classList.add('hidden');
if (state === 'empty') emptyState.classList.remove('hidden');
if (state === 'loading') loadingState.classList.remove('hidden');
if (state === 'result') resultState.classList.remove('hidden');
}
// CM송 생성
async function generate() {
const companyName = companyInput.value.trim();
const industry = industryInput.value.trim();
if (!companyName || !industry) {
alert('회사명과 업종을 입력해주세요.');
return;
}
generateBtn.disabled = true;
generateBtn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> CM송 제작 중...';
showState('loading');
loadingText.textContent = 'AI가 멋진 가사를 쓰고 있어요...';
audioSection.classList.add('hidden');
try {
// 1. 가사 생성
const lyricsRes = await fetch('{{ route("rd.cm-song.generate-lyrics") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({
company_name: companyName,
industry: industry,
mood: selectedMood,
}),
});
const lyricsData = await lyricsRes.json();
if (!lyricsData.success) {
throw new Error(lyricsData.error || '가사 생성 실패');
}
lyricsText.textContent = lyricsData.lyrics;
showState('result');
// 2. TTS 음성 생성
loadingText.textContent = '목소리를 녹음하고 있어요...';
// 가사는 보여주면서 오디오 로딩
const audioRes = await fetch('{{ route("rd.cm-song.generate-audio") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({ lyrics: lyricsData.lyrics }),
});
const audioData = await audioRes.json();
if (audioData.success && audioData.audio_data) {
const mimeType = audioData.mime_type || 'audio/L16;rate=24000';
let audioUrl;
if (mimeType.includes('L16') || mimeType.includes('pcm')) {
// PCM → WAV 변환
const sampleRate = 24000;
const rateMatch = mimeType.match(/rate=(\d+)/);
const rate = rateMatch ? parseInt(rateMatch[1]) : sampleRate;
const pcmBytes = base64ToUint8Array(audioData.audio_data);
const wavBlob = pcmToWav(pcmBytes, rate);
audioUrl = URL.createObjectURL(wavBlob);
} else {
audioUrl = 'data:' + mimeType + ';base64,' + audioData.audio_data;
}
audioPlayer.src = audioUrl;
audioSection.classList.remove('hidden');
}
} catch (error) {
console.error('CM송 생성 오류:', error);
alert('생성 중 오류가 발생했습니다: ' + error.message);
showState('empty');
} finally {
generateBtn.disabled = false;
generateBtn.innerHTML = '<i class="ri-sparkling-line"></i> CM송 만들기';
}
}
// 재생/정지
playBtn.addEventListener('click', function () {
if (isPlaying) {
audioPlayer.pause();
} else {
audioPlayer.play();
}
});
audioPlayer.addEventListener('play', function () {
isPlaying = true;
playIcon.className = 'ri-pause-fill text-3xl';
playIcon.classList.remove('ml-0.5');
playLabel.textContent = '재생 중...';
});
audioPlayer.addEventListener('pause', function () {
isPlaying = false;
playIcon.className = 'ri-play-fill text-3xl ml-0.5';
playLabel.textContent = '들어보기';
});
audioPlayer.addEventListener('ended', function () {
isPlaying = false;
playIcon.className = 'ri-play-fill text-3xl ml-0.5';
playLabel.textContent = '들어보기';
});
generateBtn.addEventListener('click', generate);
retryBtn.addEventListener('click', generate);
});
</script>
@endpush

View File

@@ -94,6 +94,23 @@
</div>
</a>
<!-- AI CM송 제작 -->
<a href="{{ route('rd.cm-song.index') }}" class="bg-white rounded-lg shadow-sm p-6 hover:shadow-md transition group">
<div class="flex items-start gap-4">
<div class="w-14 h-14 bg-gradient-to-br from-indigo-500 to-violet-600 rounded-xl flex items-center justify-center text-white shrink-0">
<i class="ri-music-2-line text-2xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800 group-hover:text-indigo-600 transition">AI CM송 제작</h3>
<p class="text-sm text-gray-500 mt-1">회사명과 업종을 입력하면 AI가 CM송 가사를 작성하고 목소리를 입혀줍니다.</p>
<div class="flex gap-2 mt-3">
<span class="px-2 py-0.5 bg-purple-50 text-purple-600 text-xs rounded-full">Gemini TTS</span>
<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-xs rounded-full">음성 생성</span>
</div>
</div>
</div>
</a>
<!-- 모듈 카탈로그 (Phase 2) -->
<div class="bg-white rounded-lg shadow-sm p-6 opacity-60">
<div class="flex items-start gap-4">

View File

@@ -50,6 +50,7 @@
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProjectManagementController;
use App\Http\Controllers\QuoteFormulaController;
use App\Http\Controllers\Rd\CmSongController;
use App\Http\Controllers\RdController;
use App\Http\Controllers\RoadmapController;
use App\Http\Controllers\RoleController;
@@ -379,6 +380,13 @@
Route::get('/ai-quotation/{id}/document', [RdController::class, 'documentQuotation'])->name('ai-quotation.document');
Route::get('/ai-quotation/{id}/edit', [RdController::class, 'editQuotation'])->name('ai-quotation.edit');
Route::get('/ai-quotation/{id}', [RdController::class, 'showQuotation'])->name('ai-quotation.show');
// CM송 제작
Route::prefix('cm-song')->name('cm-song.')->group(function () {
Route::get('/', [CmSongController::class, 'index'])->name('index');
Route::post('/generate-lyrics', [CmSongController::class, 'generateLyrics'])->name('generate-lyrics');
Route::post('/generate-audio', [CmSongController::class, 'generateAudio'])->name('generate-audio');
});
});
// 일일 스크럼 (Blade 화면만)