feat: [sound-logo] Phase 2 AI 어시스트 모드 추가
- Gemini API 연동: 프롬프트 → 음표 시퀀스 JSON 자동 생성 - AI 탭 UI: 프롬프트 입력, 카테고리/길이 선택, 빠른 프롬프트 10종 - AI 결과 미리보기: 음표 시각화, 미리듣기, 시퀀서 로드 - POST /rd/sound-logo/generate 엔드포인트 추가
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
use App\Services\Rd\AiQuotationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RdController extends Controller
|
||||
@@ -337,4 +339,117 @@ public function soundLogo(Request $request): View|\Illuminate\Http\Response
|
||||
|
||||
return view('rd.sound-logo.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사운드 로고 AI 생성 (Gemini API)
|
||||
*/
|
||||
public function soundLogoGenerate(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'prompt' => 'required|string|max:500',
|
||||
'category' => 'nullable|string',
|
||||
'duration' => 'nullable|numeric|min:0.3|max:5',
|
||||
]);
|
||||
|
||||
$apiKey = config('services.gemini.api_key');
|
||||
$baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta');
|
||||
$model = config('services.gemini.model', 'gemini-2.5-flash');
|
||||
|
||||
if (! $apiKey) {
|
||||
return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500);
|
||||
}
|
||||
|
||||
$category = $request->category ?? '기업 시그널';
|
||||
$duration = $request->duration ?? 1.5;
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
당신은 사운드 디자인 전문가입니다. 사용자의 요청에 맞는 사운드 로고(짧은 시그니처 사운드)를 Web Audio API 음표 시퀀스로 설계해주세요.
|
||||
|
||||
## 사용자 요청
|
||||
- 설명: {$request->prompt}
|
||||
- 카테고리: {$category}
|
||||
- 목표 길이: {$duration}초
|
||||
|
||||
## 사용 가능한 음표
|
||||
C3, C#3, D3, D#3, E3, F3, F#3, G3, G#3, A3, A#3, B3,
|
||||
C4, C#4, D4, D#4, E4, F4, F#4, G4, G#4, A4, A#4, B4,
|
||||
C5, C#5, D5, D#5, E5, F5, F#5, G5, G#5, A5, A#5, B5, C6
|
||||
|
||||
## 음표 타입
|
||||
- note: 단일 음 (note 필드 필수)
|
||||
- chord: 화음 (chord 배열 필수, 2~4개 음)
|
||||
- rest: 쉼표 (duration만 필요)
|
||||
|
||||
## 신스 타입
|
||||
- sine: 부드러움 (기업 로고, 알림에 적합)
|
||||
- triangle: 따뜻함 (성공, 게임에 적합)
|
||||
- square: 8bit/디지털 (게임, UI에 적합)
|
||||
- sawtooth: 날카로움 (록, 긴급 알림에 적합)
|
||||
|
||||
## 반드시 아래 JSON 형식으로만 응답하세요
|
||||
{
|
||||
"name": "사운드 이름",
|
||||
"desc": "사운드 설명 (한줄)",
|
||||
"synth": "sine",
|
||||
"adsr": { "attack": 10, "decay": 80, "sustain": 0.6, "release": 400 },
|
||||
"volume": 0.8,
|
||||
"reverb": 0.3,
|
||||
"notes": [
|
||||
{ "type": "note", "note": "C5", "duration": 0.20, "velocity": 0.8 },
|
||||
{ "type": "rest", "duration": 0.10 },
|
||||
{ "type": "chord", "chord": ["C4", "E4", "G4"], "duration": 0.50, "velocity": 1.0 }
|
||||
]
|
||||
}
|
||||
|
||||
## 설계 원칙
|
||||
- 음표의 duration 합계가 목표 길이({$duration}초)에 근접하도록 설계
|
||||
- velocity: 0.3~1.0 (음의 강약으로 표현력 추가)
|
||||
- ADSR: attack(1~500ms), decay(10~1000ms), sustain(0~1.0), release(10~3000ms)
|
||||
- 카테고리 특성에 맞는 synth와 ADSR 선택
|
||||
- 음악적으로 조화롭고 기억에 남는 멜로디 설계
|
||||
- 최소 2개, 최대 12개 음표 사용
|
||||
PROMPT;
|
||||
|
||||
try {
|
||||
$response = Http::timeout(30)->post(
|
||||
"{$baseUrl}/models/{$model}:generateContent?key={$apiKey}",
|
||||
[
|
||||
'contents' => [
|
||||
['parts' => [['text' => $prompt]]],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.9,
|
||||
'maxOutputTokens' => 2048,
|
||||
'responseMimeType' => 'application/json',
|
||||
],
|
||||
]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SoundLogo AI 생성 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'AI 서버 연결 실패'], 500);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('SoundLogo AI API 오류', ['status' => $response->status(), 'body' => $response->body()]);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'AI 생성 실패: '.$response->status()], 500);
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
|
||||
// JSON 파싱 (코드블록 제거)
|
||||
$text = preg_replace('/^```(?:json)?\s*/m', '', $text);
|
||||
$text = preg_replace('/```\s*$/m', '', $text);
|
||||
$result = json_decode(trim($text), true);
|
||||
|
||||
if (! $result || ! isset($result['notes'])) {
|
||||
Log::warning('SoundLogo AI 응답 파싱 실패', ['text' => substr($text, 0, 500)]);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'AI 응답을 파싱할 수 없습니다.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'data' => $result]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,8 @@
|
||||
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
|
||||
background: var(--sl-card); border-top: 1px solid var(--sl-border); flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<div x-data="soundLogo()" x-init="init()" class="sl-wrap">
|
||||
@@ -215,6 +217,9 @@
|
||||
<button class="sl-tab" :class="mode === 'preset' && 'active'" @click="mode = 'preset'">
|
||||
<i class="ri-magic-line"></i> 프리셋
|
||||
</button>
|
||||
<button class="sl-tab" :class="mode === 'ai' && 'active'" @click="mode = 'ai'" style="position: relative;">
|
||||
<i class="ri-sparkling-line"></i> AI 생성
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;"></div>
|
||||
@@ -325,6 +330,119 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- AI Mode -->
|
||||
<template x-if="mode === 'ai'">
|
||||
<div>
|
||||
<div style="max-width: 640px; margin: 0 auto;">
|
||||
<!-- AI Header -->
|
||||
<div style="text-align: center; margin-bottom: 24px;">
|
||||
<div style="font-size: 36px; margin-bottom: 8px;">✨</div>
|
||||
<h2 style="font-size: 18px; font-weight: 700; margin: 0 0 6px;">AI 사운드 로고 생성</h2>
|
||||
<p style="font-size: 12px; color: var(--sl-text2); margin: 0;">원하는 사운드를 설명하면 AI가 음표 시퀀스를 설계합니다</p>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Input -->
|
||||
<div class="sl-section">
|
||||
<div class="sl-section-title">프롬프트</div>
|
||||
<textarea class="sl-input" x-model="aiPrompt" rows="3"
|
||||
placeholder="예: 밝고 미래적인 IT 기업 시그널, 5음으로 상승하는 느낌"
|
||||
style="resize: vertical; min-height: 72px;"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px;">
|
||||
<div style="flex: 1;">
|
||||
<div class="sl-section-title">카테고리</div>
|
||||
<select class="sl-select" x-model="aiCategory">
|
||||
<option value="기업 시그널">기업 시그널</option>
|
||||
<option value="알림/메시지">알림/메시지</option>
|
||||
<option value="상태/피드백">상태/피드백</option>
|
||||
<option value="전환 효과">전환 효과</option>
|
||||
<option value="게임 효과">게임 효과</option>
|
||||
<option value="UI 인터랙션">UI 인터랙션</option>
|
||||
<option value="브랜드 징글">브랜드 징글</option>
|
||||
<option value="방송/미디어">방송/미디어</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<div class="sl-section-title">목표 길이</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="range" class="sl-slider" min="3" max="50" x-model.number="aiDurationRaw" style="flex:1;">
|
||||
<span style="font-size: 12px; font-weight: 600; min-width: 36px;" x-text="(aiDurationRaw / 10).toFixed(1) + '초'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generate Button -->
|
||||
<button class="sl-btn primary lg" style="width: 100%; justify-content: center; margin-bottom: 20px;"
|
||||
@click="generateWithAi()" :disabled="aiLoading || !aiPrompt.trim()">
|
||||
<template x-if="!aiLoading">
|
||||
<span><i class="ri-sparkling-line"></i> AI로 생성하기</span>
|
||||
</template>
|
||||
<template x-if="aiLoading">
|
||||
<span><i class="ri-loader-4-line" style="animation: spin 1s linear infinite;"></i> 생성 중...</span>
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<!-- AI Result Preview -->
|
||||
<template x-if="aiResult">
|
||||
<div style="background: var(--sl-card); border: 1px solid var(--sl-green); border-radius: 10px; padding: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
|
||||
<span style="font-size: 20px;">🎵</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 14px; font-weight: 600;" x-text="aiResult.name"></div>
|
||||
<div style="font-size: 11px; color: var(--sl-text2);" x-text="aiResult.desc"></div>
|
||||
</div>
|
||||
<span style="font-size: 11px; color: var(--sl-green); font-weight: 600;"
|
||||
x-text="aiResult.notes.length + '개 음표 · ' + aiResult.synth"></span>
|
||||
</div>
|
||||
|
||||
<!-- Mini note preview -->
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 12px; padding: 10px; background: var(--sl-bg); border-radius: 8px;">
|
||||
<template x-for="(n, i) in aiResult.notes" :key="i">
|
||||
<div style="padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600;"
|
||||
:style="n.type === 'rest' ? 'background: var(--sl-card2); color: var(--sl-text2);' : n.type === 'chord' ? 'background: rgba(139,92,246,.2); color: #a78bfa;' : 'background: rgba(99,102,241,.2); color: #818cf8;'"
|
||||
x-text="n.type === 'rest' ? '쉼' : n.type === 'chord' ? (n.chord||[]).join('+') : n.note">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="sl-btn success" style="flex: 1; justify-content: center;" @click="loadAiResult()">
|
||||
<i class="ri-check-line"></i> 시퀀서에 로드
|
||||
</button>
|
||||
<button class="sl-btn play" @click="previewAiResult()">
|
||||
<i class="ri-play-fill"></i> 미리듣기
|
||||
</button>
|
||||
<button class="sl-btn" @click="generateWithAi()">
|
||||
<i class="ri-refresh-line"></i> 다시 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- AI Error -->
|
||||
<template x-if="aiError">
|
||||
<div style="background: rgba(239,68,68,.1); border: 1px solid var(--sl-red); border-radius: 10px; padding: 12px; margin-top: 12px;">
|
||||
<div style="font-size: 12px; color: var(--sl-red);">
|
||||
<i class="ri-error-warning-line"></i> <span x-text="aiError"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Quick Prompts -->
|
||||
<div class="sl-section" style="margin-top: 20px;">
|
||||
<div class="sl-section-title">빠른 프롬프트 예시</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
<template x-for="qp in aiQuickPrompts" :key="qp">
|
||||
<button class="sl-btn sm" @click="aiPrompt = qp" x-text="qp"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Manual Mode -->
|
||||
<template x-if="mode === 'manual'">
|
||||
<div>
|
||||
@@ -436,6 +554,26 @@ function soundLogo() {
|
||||
presetCategory: 'all',
|
||||
audioCtx: null,
|
||||
|
||||
// AI 어시스트
|
||||
aiPrompt: '',
|
||||
aiCategory: '기업 시그널',
|
||||
aiDurationRaw: 15,
|
||||
aiLoading: false,
|
||||
aiResult: null,
|
||||
aiError: '',
|
||||
aiQuickPrompts: [
|
||||
'밝고 미래적인 IT 기업 로고',
|
||||
'따뜻하고 친근한 카페 알림음',
|
||||
'긴박한 뉴스 속보 시그널',
|
||||
'귀여운 모바일 앱 알림',
|
||||
'웅장한 영화 오프닝 사운드',
|
||||
'8bit 레트로 게임 코인 효과',
|
||||
'세련된 재즈 브랜드 징글',
|
||||
'차분한 명상 앱 시작음',
|
||||
'에너지 넘치는 유튜브 인트로',
|
||||
'고급스러운 호텔 안내 벨',
|
||||
],
|
||||
|
||||
synthTypes: [
|
||||
{ code: 'sine', label: 'Sine (부드러움)', icon: '🔵' },
|
||||
{ code: 'square', label: 'Square (8bit)', icon: '🟪' },
|
||||
@@ -922,6 +1060,61 @@ function soundLogo() {
|
||||
return buf;
|
||||
},
|
||||
|
||||
// ===== AI 어시스트 =====
|
||||
async generateWithAi() {
|
||||
if (this.aiLoading || !this.aiPrompt.trim()) return;
|
||||
this.aiLoading = true;
|
||||
this.aiResult = null;
|
||||
this.aiError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('{{ route("rd.sound-logo.generate") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: this.aiPrompt,
|
||||
category: this.aiCategory,
|
||||
duration: this.aiDurationRaw / 10,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
this.aiResult = data.data;
|
||||
} else {
|
||||
this.aiError = data.error || 'AI 생성에 실패했습니다.';
|
||||
}
|
||||
} catch (e) {
|
||||
this.aiError = '네트워크 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.aiLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
loadAiResult() {
|
||||
if (!this.aiResult) return;
|
||||
const r = this.aiResult;
|
||||
if (r.synth) this.synth = r.synth;
|
||||
if (r.adsr) this.adsr = { ...r.adsr };
|
||||
if (r.volume != null) this.volume = r.volume;
|
||||
if (r.reverb != null) this.reverb = r.reverb;
|
||||
if (r.notes) this.notes = JSON.parse(JSON.stringify(r.notes));
|
||||
this.mode = 'manual';
|
||||
this.toast((r.name || 'AI 사운드') + ' 로드됨');
|
||||
},
|
||||
|
||||
async previewAiResult() {
|
||||
if (!this.aiResult || this.isPlaying) return;
|
||||
// 임시로 AI 결과를 현재 설정에 적용 후 재생
|
||||
const backup = { synth: this.synth, adsr: { ...this.adsr }, volume: this.volume, reverb: this.reverb, notes: JSON.parse(JSON.stringify(this.notes)) };
|
||||
this.loadAiResult();
|
||||
await this.playAll();
|
||||
// 재생 완료 후 복원하지 않음 (로드된 상태 유지)
|
||||
},
|
||||
|
||||
// ===== Presets =====
|
||||
loadPreset(i) {
|
||||
const p = this.presets[i];
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
|
||||
// 사운드 로고 생성기
|
||||
Route::get('/sound-logo', [RdController::class, 'soundLogo'])->name('sound-logo');
|
||||
Route::post('/sound-logo/generate', [RdController::class, 'soundLogoGenerate'])->name('sound-logo.generate');
|
||||
});
|
||||
|
||||
// 일일 스크럼 (Blade 화면만)
|
||||
|
||||
Reference in New Issue
Block a user