feat: [rd] CM송 길이 슬라이더, 다운로드, 저장/목록 기능 추가
- 10~60초 5초 간격 길이 선택 슬라이더 - 음성 파일 WAV 다운로드 - 생성 결과 DB 저장 + 목록/상세/삭제 관리 - CmSong 모델 + tenant 스토리지 연동
This commit is contained in:
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers\Rd;
|
||||
|
||||
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
|
||||
@@ -21,7 +24,7 @@ public function __construct()
|
||||
}
|
||||
|
||||
/**
|
||||
* CM송 제작 페이지
|
||||
* CM송 목록
|
||||
*/
|
||||
public function index(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
@@ -29,7 +32,37 @@ public function index(Request $request): View|\Illuminate\Http\Response
|
||||
return response('', 200)->header('HX-Redirect', route('rd.cm-song.index'));
|
||||
}
|
||||
|
||||
return view('rd.cm-song.index');
|
||||
$songs = CmSong::with('user')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('rd.cm-song.index', compact('songs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* CM송 제작 페이지
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* CM송 상세
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,16 +74,25 @@ public function generateLyrics(Request $request): JsonResponse
|
||||
'company_name' => 'required|string|max:100',
|
||||
'industry' => 'required|string|max:200',
|
||||
'mood' => 'required|string|max:50',
|
||||
'duration' => 'required|integer|min:10|max:60',
|
||||
]);
|
||||
|
||||
$prompt = "당신은 전문 CM송 작사가입니다. 다음 정보를 바탕으로 짧고 기억에 남는 15초 분량의 라디오 CM송 가사를 작성해주세요.
|
||||
$duration = $request->duration;
|
||||
$lines = match (true) {
|
||||
$duration <= 15 => '3~4줄',
|
||||
$duration <= 30 => '6~8줄',
|
||||
$duration <= 45 => '10~12줄',
|
||||
default => '14~16줄',
|
||||
};
|
||||
|
||||
$prompt = "당신은 전문 CM송 작사가입니다. 다음 정보를 바탕으로 기억에 남는 {$duration}초 분량의 라디오 CM송 가사를 작성해주세요.
|
||||
|
||||
회사명: {$request->company_name}
|
||||
업종/제품: {$request->industry}
|
||||
분위기: {$request->mood}
|
||||
|
||||
조건:
|
||||
- 3~4줄로 짧게 작성
|
||||
- {$lines}로 작성 ({$duration}초 분량)
|
||||
- 운율을 살려서 작성
|
||||
- 지시문(예: (음악 소리), (밝은 목소리로)) 없이 오직 읽을 수 있는 가사 텍스트만 출력할 것.";
|
||||
|
||||
@@ -144,4 +186,127 @@ public function generateAudio(Request $request): JsonResponse
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CM송 저장
|
||||
*/
|
||||
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',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$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/{$user->tenant_id}";
|
||||
Storage::disk('tenant')->makeDirectory($dir);
|
||||
Storage::disk('tenant')->put("{$dir}/{$filename}", $audioBytes);
|
||||
$audioPath = "{$dir}/{$filename}";
|
||||
}
|
||||
|
||||
$song = CmSong::create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'user_id' => $user->id,
|
||||
'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' => 'CM송이 저장되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성 파일 다운로드
|
||||
*/
|
||||
public function download(int $id)
|
||||
{
|
||||
$song = CmSong::findOrFail($id);
|
||||
|
||||
if (! $song->audio_path || ! Storage::disk('tenant')->exists($song->audio_path)) {
|
||||
abort(404, '음성 파일이 없습니다.');
|
||||
}
|
||||
|
||||
$filename = "CM송_{$song->company_name}_".date('Ymd', strtotime($song->created_at)).'.wav';
|
||||
|
||||
return Storage::disk('tenant')->download($song->audio_path, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* CM송 삭제
|
||||
*/
|
||||
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' => 'CM송이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
57
app/Models/Rd/CmSong.php
Normal file
57
app/Models/Rd/CmSong.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Rd;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CmSong extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'cm_songs';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'company_name',
|
||||
'industry',
|
||||
'lyrics',
|
||||
'audio_path',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, mixed $value): void
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
public function getMood(): string
|
||||
{
|
||||
return $this->getOption('mood', '-');
|
||||
}
|
||||
|
||||
public function getDuration(): int
|
||||
{
|
||||
return $this->getOption('duration', 15);
|
||||
}
|
||||
}
|
||||
467
resources/views/rd/cm-song/create.blade.php
Normal file
467
resources/views/rd/cm-song/create.blade.php
Normal file
@@ -0,0 +1,467 @@
|
||||
@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.cm-song.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> CM송 목록
|
||||
</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>
|
||||
|
||||
<!-- CM송 길이 슬라이더 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5 flex items-center gap-1.5">
|
||||
<i class="ri-time-line text-gray-400"></i>
|
||||
CM송 길이
|
||||
</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
id="durationSlider"
|
||||
min="10"
|
||||
max="60"
|
||||
step="5"
|
||||
value="15"
|
||||
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
||||
/>
|
||||
<span id="durationDisplay" class="px-3 py-1 bg-indigo-50 text-indigo-700 text-sm font-semibold rounded-lg whitespace-nowrap" style="min-width: 52px; text-align: center;">15초</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-400 mt-1 px-0.5">
|
||||
<span>10초</span>
|
||||
<span>30초</span>
|
||||
<span>60초</span>
|
||||
</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>
|
||||
|
||||
<!-- 다운로드 버튼 -->
|
||||
<button
|
||||
type="button"
|
||||
id="downloadBtn"
|
||||
class="flex items-center gap-2 px-5 py-2.5 border-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 rounded-xl text-sm font-medium transition"
|
||||
>
|
||||
<i class="ri-download-line"></i>
|
||||
음성 파일 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 저장 / 다시 만들기 -->
|
||||
<div class="flex items-center justify-center gap-4 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
id="saveBtn"
|
||||
class="flex items-center gap-1.5 px-5 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
<i class="ri-save-line"></i> 저장하기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="retryBtn"
|
||||
class="flex items-center gap-1.5 px-5 py-2 bg-white hover:bg-gray-50 text-gray-600 border rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
<i class="ri-refresh-line"></i> 다시 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-gray-400 mt-6">Powered by Google Gemini AI</p>
|
||||
@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 durationSlider = document.getElementById('durationSlider');
|
||||
const durationDisplay = document.getElementById('durationDisplay');
|
||||
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 downloadBtn = document.getElementById('downloadBtn');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
const retryBtn = document.getElementById('retryBtn');
|
||||
|
||||
let selectedMood = '신나는';
|
||||
let isPlaying = false;
|
||||
let currentAudioData = null;
|
||||
let currentAudioMimeType = null;
|
||||
let currentLyrics = null;
|
||||
let currentAudioBlob = null;
|
||||
|
||||
// 슬라이더 업데이트
|
||||
durationSlider.addEventListener('input', function () {
|
||||
durationDisplay.textContent = this.value + '초';
|
||||
});
|
||||
|
||||
// 분위기 선택
|
||||
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();
|
||||
const duration = parseInt(durationSlider.value);
|
||||
|
||||
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');
|
||||
currentAudioData = null;
|
||||
currentAudioMimeType = null;
|
||||
currentAudioBlob = null;
|
||||
|
||||
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,
|
||||
duration: duration,
|
||||
}),
|
||||
});
|
||||
|
||||
const lyricsData = await lyricsRes.json();
|
||||
if (!lyricsData.success) {
|
||||
throw new Error(lyricsData.error || '가사 생성 실패');
|
||||
}
|
||||
|
||||
currentLyrics = lyricsData.lyrics;
|
||||
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) {
|
||||
currentAudioData = audioData.audio_data;
|
||||
currentAudioMimeType = audioData.mime_type || 'audio/L16;rate=24000';
|
||||
|
||||
const mimeType = currentAudioMimeType;
|
||||
let audioUrl;
|
||||
|
||||
if (mimeType.includes('L16') || mimeType.includes('pcm')) {
|
||||
const rateMatch = mimeType.match(/rate=(\d+)/);
|
||||
const rate = rateMatch ? parseInt(rateMatch[1]) : 24000;
|
||||
const pcmBytes = base64ToUint8Array(audioData.audio_data);
|
||||
currentAudioBlob = pcmToWav(pcmBytes, rate);
|
||||
audioUrl = URL.createObjectURL(currentAudioBlob);
|
||||
} else {
|
||||
const rawBytes = base64ToUint8Array(audioData.audio_data);
|
||||
currentAudioBlob = new Blob([rawBytes], { type: mimeType });
|
||||
audioUrl = URL.createObjectURL(currentAudioBlob);
|
||||
}
|
||||
|
||||
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송 만들기';
|
||||
}
|
||||
}
|
||||
|
||||
// 다운로드
|
||||
downloadBtn.addEventListener('click', function () {
|
||||
if (!currentAudioBlob) return;
|
||||
const companyName = companyInput.value.trim() || 'CM송';
|
||||
const url = URL.createObjectURL(currentAudioBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'CM송_' + companyName + '_' + new Date().toISOString().slice(0,10) + '.wav';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// 저장
|
||||
saveBtn.addEventListener('click', async function () {
|
||||
if (!currentLyrics) return;
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> 저장 중...';
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route("rd.cm-song.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
company_name: companyInput.value.trim(),
|
||||
industry: industryInput.value.trim(),
|
||||
mood: selectedMood,
|
||||
duration: parseInt(durationSlider.value),
|
||||
lyrics: currentLyrics,
|
||||
audio_data: currentAudioData,
|
||||
audio_mime_type: currentAudioMimeType,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert('CM송이 저장되었습니다.');
|
||||
window.location.href = '{{ route("rd.cm-song.index") }}';
|
||||
} else {
|
||||
throw new Error(data.error || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('저장 중 오류: ' + error.message);
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="ri-save-line"></i> 저장하기';
|
||||
}
|
||||
});
|
||||
|
||||
// 재생/정지
|
||||
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
|
||||
@@ -1,348 +1,125 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'AI CM송 제작')
|
||||
@section('title', '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송 제작
|
||||
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 class="flex gap-2">
|
||||
<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>
|
||||
<a href="{{ route('rd.cm-song.create') }}" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition">
|
||||
<i class="ri-add-line mr-1"></i> CM송 제작
|
||||
</a>
|
||||
</div>
|
||||
</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 class="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
@if($songs->count() > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 bg-gray-50">
|
||||
<th class="text-left px-5 py-3 font-medium text-gray-600">No.</th>
|
||||
<th class="text-left px-5 py-3 font-medium text-gray-600">회사명</th>
|
||||
<th class="text-left px-5 py-3 font-medium text-gray-600">업종/제품</th>
|
||||
<th class="text-left px-5 py-3 font-medium text-gray-600">분위기</th>
|
||||
<th class="text-center px-5 py-3 font-medium text-gray-600">길이</th>
|
||||
<th class="text-center px-5 py-3 font-medium text-gray-600">음성</th>
|
||||
<th class="text-left px-5 py-3 font-medium text-gray-600">생성자</th>
|
||||
<th class="text-left px-5 py-3 font-medium text-gray-600">생성일</th>
|
||||
<th class="text-center px-5 py-3 font-medium text-gray-600">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@foreach($songs as $song)
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-5 py-3.5 text-gray-500">{{ $songs->total() - ($songs->perPage() * ($songs->currentPage() - 1)) - $loop->index }}</td>
|
||||
<td class="px-5 py-3.5">
|
||||
<a href="{{ route('rd.cm-song.show', $song->id) }}" class="font-medium text-gray-800 hover:text-indigo-600">
|
||||
{{ $song->company_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-5 py-3.5 text-gray-600">{{ Str::limit($song->industry, 30) }}</td>
|
||||
<td class="px-5 py-3.5">
|
||||
<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-xs rounded-full">{{ $song->getMood() }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3.5 text-center text-gray-600">{{ $song->getDuration() }}초</td>
|
||||
<td class="px-5 py-3.5 text-center">
|
||||
@if($song->audio_path)
|
||||
<span class="text-green-500"><i class="ri-volume-up-line"></i></span>
|
||||
@else
|
||||
<span class="text-gray-300">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-5 py-3.5 text-gray-600">{{ $song->user?->name ?? '-' }}</td>
|
||||
<td class="px-5 py-3.5 text-gray-500">{{ $song->created_at->format('Y-m-d H:i') }}</td>
|
||||
<td class="px-5 py-3.5 text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
@if($song->audio_path)
|
||||
<a href="{{ route('rd.cm-song.download', $song->id) }}" class="p-1.5 text-gray-400 hover:text-blue-600 transition" title="다운로드">
|
||||
<i class="ri-download-line"></i>
|
||||
</a>
|
||||
@endif
|
||||
<button
|
||||
type="button"
|
||||
onclick="deleteSong({{ $song->id }})"
|
||||
class="p-1.5 text-gray-400 hover:text-red-600 transition"
|
||||
title="삭제"
|
||||
>
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
@if($songs->hasPages())
|
||||
<div class="px-5 py-3 border-t border-gray-100">
|
||||
{{ $songs->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="py-16 text-center text-gray-400">
|
||||
<i class="ri-music-2-line text-5xl mb-3 block opacity-20"></i>
|
||||
<p>아직 생성된 CM송이 없습니다.</p>
|
||||
<a href="{{ route('rd.cm-song.create') }}" class="text-indigo-600 hover:text-indigo-800 text-sm mt-2 inline-block">
|
||||
첫 번째 CM송을 만들어보세요 →
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</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();
|
||||
function deleteSong(id) {
|
||||
if (!confirm('이 CM송을 삭제하시겠습니까?')) return;
|
||||
fetch(`/rd/cm-song/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
audioPlayer.play();
|
||||
alert(data.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
})
|
||||
.catch(() => alert('삭제 중 오류가 발생했습니다.'));
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
142
resources/views/rd/cm-song/show.blade.php
Normal file
142
resources/views/rd/cm-song/show.blade.php
Normal file
@@ -0,0 +1,142 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'CM송 상세 - ' . $song->company_name)
|
||||
|
||||
@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>
|
||||
CM송 상세
|
||||
</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('rd.cm-song.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> 목록
|
||||
</a>
|
||||
<a href="{{ route('rd.cm-song.create') }}" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition">
|
||||
<i class="ri-add-line mr-1"></i> 새로 만들기
|
||||
</a>
|
||||
</div>
|
||||
</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-information-line text-indigo-500"></i>
|
||||
CM송 정보
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500 shrink-0" style="width: 90px;">회사명</span>
|
||||
<span class="font-medium text-gray-800">{{ $song->company_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500 shrink-0" style="width: 90px;">업종/제품</span>
|
||||
<span class="text-gray-800">{{ $song->industry }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500 shrink-0" style="width: 90px;">분위기</span>
|
||||
<span class="px-2.5 py-0.5 bg-indigo-50 text-indigo-600 text-sm rounded-full">{{ $song->getMood() }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500 shrink-0" style="width: 90px;">길이</span>
|
||||
<span class="text-gray-800">{{ $song->getDuration() }}초</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500 shrink-0" style="width: 90px;">생성자</span>
|
||||
<span class="text-gray-800">{{ $song->user?->name ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500 shrink-0" style="width: 90px;">생성일</span>
|
||||
<span class="text-gray-800">{{ $song->created_at->format('Y-m-d H:i:s') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($song->audio_path)
|
||||
<div class="mt-6 pt-5 border-t border-gray-100">
|
||||
<a
|
||||
href="{{ route('rd.cm-song.download', $song->id) }}"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 border-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 rounded-xl text-sm font-medium transition"
|
||||
>
|
||||
<i class="ri-download-line"></i>
|
||||
음성 파일 다운로드
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 가사 + 플레이어 -->
|
||||
<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-file-music-line text-indigo-500"></i>
|
||||
가사
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 p-6 rounded-xl border border-gray-100 mb-6">
|
||||
<p class="text-lg leading-relaxed text-gray-800 whitespace-pre-line font-medium text-center">{{ $song->lyrics }}</p>
|
||||
</div>
|
||||
|
||||
@if($song->audio_path)
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<audio id="audioPlayer" src="{{ route('rd.cm-song.download', $song->id) }}" 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>
|
||||
@else
|
||||
<div class="text-center text-gray-400 py-4">
|
||||
<p class="text-sm">음성 파일이 없습니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const audioPlayer = document.getElementById('audioPlayer');
|
||||
const playBtn = document.getElementById('playBtn');
|
||||
const playIcon = document.getElementById('playIcon');
|
||||
const playLabel = document.getElementById('playLabel');
|
||||
|
||||
if (!audioPlayer || !playBtn) return;
|
||||
|
||||
let isPlaying = false;
|
||||
|
||||
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 = '들어보기';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -384,6 +384,11 @@
|
||||
// CM송 제작
|
||||
Route::prefix('cm-song')->name('cm-song.')->group(function () {
|
||||
Route::get('/', [CmSongController::class, 'index'])->name('index');
|
||||
Route::get('/create', [CmSongController::class, 'create'])->name('create');
|
||||
Route::post('/', [CmSongController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [CmSongController::class, 'show'])->name('show');
|
||||
Route::delete('/{id}', [CmSongController::class, 'destroy'])->name('destroy');
|
||||
Route::get('/{id}/download', [CmSongController::class, 'download'])->name('download');
|
||||
Route::post('/generate-lyrics', [CmSongController::class, 'generateLyrics'])->name('generate-lyrics');
|
||||
Route::post('/generate-audio', [CmSongController::class, 'generateAudio'])->name('generate-audio');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user