feat: [rd] CM송 길이 슬라이더, 다운로드, 저장/목록 기능 추가

- 10~60초 5초 간격 길이 선택 슬라이더
- 음성 파일 WAV 다운로드
- 생성 결과 DB 저장 + 목록/상세/삭제 관리
- CmSong 모델 + tenant 스토리지 연동
This commit is contained in:
김보곤
2026-03-05 14:37:00 +09:00
parent 0e9f1297b8
commit 272df31501
6 changed files with 942 additions and 329 deletions

View File

@@ -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
View 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);
}
}