feat: [rd] AI 배경음악 생성 기능 추가 (Google Lyria RealTime)

- Lyria RealTime WebSocket 연동으로 다중 악기 배경음악 실시간 생성
- BPM, 밀도, 밝기, 스케일 컨트롤 지원
- 시퀀서 + 음성 + 배경음악 3중 합성 (playAll, exportWav)
- 서버 API 키 보호 엔드포인트 (lyria-config)
- 빠른 프롬프트 10종 제공
This commit is contained in:
김보곤
2026-03-08 13:37:25 +09:00
parent 7ef8971b93
commit 37b40c8513
3 changed files with 435 additions and 2 deletions

View File

@@ -341,6 +341,25 @@ public function soundLogo(Request $request): View|\Illuminate\Http\Response
return view('rd.sound-logo.index');
}
/**
* Lyria RealTime 접속용 API 설정 반환
*/
public function soundLogoLyriaConfig(): JsonResponse
{
$apiKey = config('services.gemini.api_key');
if (! $apiKey) {
return response()->json(['success' => false, 'error' => 'API 키가 설정되지 않았습니다.'], 500);
}
return response()->json([
'success' => true,
'api_key' => $apiKey,
'ws_url' => 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateMusic',
'model' => 'models/lyria-realtime-exp',
]);
}
/**
* 사운드 로고 AI 생성 (Gemini API)
*/

View File

@@ -220,6 +220,10 @@
<button class="sl-tab" :class="mode === 'ai' && 'active'" @click="mode = 'ai'" style="position: relative;">
<i class="ri-sparkling-line"></i> AI 생성
</button>
<button class="sl-tab" :class="mode === 'bgm' && 'active'" @click="mode = 'bgm'" style="position: relative;">
<i class="ri-music-ai-line"></i> AI 배경음악
<span style="position: absolute; top: -2px; right: -2px; font-size: 8px; background: var(--sl-green); color: #fff; padding: 1px 4px; border-radius: 4px; font-weight: 700;">Lyria</span>
</button>
</div>
<div style="flex:1;"></div>
@@ -229,6 +233,9 @@
<template x-if="voiceBuffer">
<span style="color: var(--sl-green);"> | <i class="ri-mic-fill"></i> 음성</span>
</template>
<template x-if="bgmBuffer">
<span style="color: var(--sl-amber);"> | <i class="ri-music-ai-line"></i> 배경음악</span>
</template>
</span>
</div>
@@ -484,6 +491,161 @@
</div>
</template>
<!-- BGM Mode (Lyria RealTime) -->
<template x-if="mode === 'bgm'">
<div>
<div style="padding: 20px;">
<!-- 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;">Google Lyria RealTime 텍스트 프롬프트로 다중 악기 배경음악을 실시간 생성합니다</p>
</div>
<!-- Prompt Input -->
<div class="sl-section">
<div class="sl-section-title">음악 프롬프트</div>
<textarea class="sl-input" x-model="bgmPrompt" rows="3"
placeholder="예: 밝고 희망적인 어쿠스틱 기타와 피아노, 부드러운 드럼"
style="resize: vertical; min-height: 72px;"></textarea>
</div>
<!-- Options Row -->
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;">
<div>
<div class="sl-section-title">BPM</div>
<div style="display: flex; align-items: center; gap: 6px;">
<input type="range" class="sl-slider" min="60" max="200" x-model.number="bgmBpm" style="flex:1;">
<span style="font-size: 12px; font-weight: 600; min-width: 32px;" x-text="bgmBpm"></span>
</div>
</div>
<div>
<div class="sl-section-title">밀도 (Density)</div>
<div style="display: flex; align-items: center; gap: 6px;">
<input type="range" class="sl-slider" min="0" max="100" x-model.number="bgmDensity" style="flex:1;">
<span style="font-size: 12px; font-weight: 600; min-width: 32px;" x-text="(bgmDensity / 100).toFixed(1)"></span>
</div>
</div>
<div>
<div class="sl-section-title">밝기 (Brightness)</div>
<div style="display: flex; align-items: center; gap: 6px;">
<input type="range" class="sl-slider" min="0" max="100" x-model.number="bgmBrightness" style="flex:1;">
<span style="font-size: 12px; font-weight: 600; min-width: 32px;" x-text="(bgmBrightness / 100).toFixed(1)"></span>
</div>
</div>
<div>
<div class="sl-section-title">스케일</div>
<select class="sl-select" x-model="bgmScale">
<option value="C_MAJOR_A_MINOR">C Major / A Minor</option>
<option value="D_FLAT_MAJOR_B_FLAT_MINOR">D♭ Major / B♭ Minor</option>
<option value="D_MAJOR_B_MINOR">D Major / B Minor</option>
<option value="E_FLAT_MAJOR_C_MINOR">E♭ Major / C Minor</option>
<option value="E_MAJOR_C_SHARP_MINOR">E Major / C# Minor</option>
<option value="F_MAJOR_D_MINOR">F Major / D Minor</option>
<option value="G_FLAT_MAJOR_E_FLAT_MINOR">G♭ Major / E♭ Minor</option>
<option value="G_MAJOR_E_MINOR">G Major / E Minor</option>
<option value="A_FLAT_MAJOR_F_MINOR">A♭ Major / F Minor</option>
<option value="A_MAJOR_F_SHARP_MINOR">A Major / F# Minor</option>
<option value="B_FLAT_MAJOR_G_MINOR">B♭ Major / G Minor</option>
<option value="B_MAJOR_G_SHARP_MINOR">B Major / G# Minor</option>
</select>
</div>
</div>
<!-- Duration & Generation Controls -->
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: flex-end;">
<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="30" x-model.number="bgmDuration" style="flex:1;">
<span style="font-size: 12px; font-weight: 600; min-width: 36px;" x-text="bgmDuration + '초'"></span>
</div>
</div>
<div>
<button class="sl-btn primary lg" style="justify-content: center; min-width: 180px;"
@click="generateBgm()" :disabled="bgmLoading || !bgmPrompt.trim()">
<template x-if="!bgmLoading">
<span><i class="ri-music-ai-line"></i> 배경음악 생성</span>
</template>
<template x-if="bgmLoading">
<span><i class="ri-loader-4-line" style="animation: spin 1s linear infinite;"></i>
<span x-text="bgmProgress"></span>
</span>
</template>
</button>
</div>
</div>
<!-- BGM Result -->
<template x-if="bgmBuffer">
<div style="background: var(--sl-card); border: 1px solid var(--sl-green); border-radius: 10px; padding: 16px; margin-bottom: 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;">AI 배경음악 생성 완료</div>
<div style="font-size: 11px; color: var(--sl-text2);">
<span x-text="bgmBuffer.duration.toFixed(1) + '초'"></span> ·
<span x-text="bgmBuffer.numberOfChannels + 'ch'"></span> ·
<span x-text="bgmBuffer.sampleRate + 'Hz'"></span>
</div>
</div>
</div>
<!-- BGM Volume -->
<div class="sl-param" style="margin-bottom: 12px;">
<div class="sl-param-label"><span>배경음악 볼륨</span><span x-text="(bgmVolume * 100).toFixed(0) + '%'"></span></div>
<input type="range" class="sl-slider" min="0" max="100" x-model.number="bgmVolume"
:style="'background: linear-gradient(to right, var(--sl-green) ' + (bgmVolume * 100) + '%, var(--sl-card2) ' + (bgmVolume * 100) + '%);'"
x-on:input="bgmVolume = $event.target.value / 100">
</div>
<div style="display: flex; gap: 8px;">
<button class="sl-btn play" style="flex: 1; justify-content: center;" @click="playBgmOnly()">
<i class="ri-play-fill"></i> 배경음악만 재생
</button>
<button class="sl-btn success" style="flex: 1; justify-content: center;" @click="exportBgmWav()">
<i class="ri-download-line"></i> WAV 다운로드
</button>
<button class="sl-btn danger sm" @click="clearBgm()">
<i class="ri-delete-bin-line"></i>
</button>
</div>
</div>
</template>
<!-- BGM Error -->
<template x-if="bgmError">
<div style="background: rgba(239,68,68,.1); border: 1px solid var(--sl-red); border-radius: 10px; padding: 12px; margin-bottom: 16px;">
<div style="font-size: 12px; color: var(--sl-red);">
<i class="ri-error-warning-line"></i> <span x-text="bgmError"></span>
</div>
</div>
</template>
<!-- Quick Prompts for BGM -->
<div class="sl-section">
<div class="sl-section-title">빠른 프롬프트 예시</div>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
<template x-for="qp in bgmQuickPrompts" :key="qp">
<button class="sl-btn sm" @click="bgmPrompt = qp" x-text="qp"></button>
</template>
</div>
</div>
<!-- Info box -->
<div style="background: rgba(99,102,241,.08); border: 1px solid rgba(99,102,241,.3); border-radius: 10px; padding: 12px; margin-top: 16px;">
<div style="font-size: 12px; color: var(--sl-text2); line-height: 1.6;">
<strong style="color: var(--sl-text);">💡 합성 가이드</strong><br>
시퀀서(수동/프리셋/AI생성) + 음성(TTS) + 배경음악(Lyria) 3 합성 가능<br>
배경음악 생성 <strong> 전체 재생</strong> 버튼으로 합성 결과를 확인하세요<br>
WAV 내보내기 모든 레이어가 하나의 파일로 합쳐집니다<br>
Google Lyria RealTime (실험적 모델) 다중 악기 실시간 생성
</div>
</div>
</div>
</div>
</template>
<!-- Manual Mode -->
<template x-if="mode === 'manual'">
<div>
@@ -602,6 +764,31 @@ function soundLogo() {
aiLoading: false,
aiResult: null,
aiError: '',
// 배경음악 (Lyria RealTime)
bgmPrompt: '',
bgmBpm: 120,
bgmDensity: 50,
bgmBrightness: 50,
bgmScale: 'C_MAJOR_A_MINOR',
bgmDuration: 10,
bgmLoading: false,
bgmProgress: '연결 중...',
bgmBuffer: null,
bgmVolume: 0.5,
bgmError: '',
bgmWs: null,
bgmQuickPrompts: [
'밝고 희망적인 어쿠스틱 기타와 피아노',
'세련된 재즈 피아노 트리오',
'에너지 넘치는 일렉트로닉 팝',
'차분한 로파이 힙합 비트',
'웅장한 오케스트라 팡파르',
'부드러운 보사노바 기타',
'미니멀 테크노, 깊은 베이스',
'몽환적인 앰비언트 신스패드',
'펑키한 슬랩 베이스와 브라스',
'클래식 록 기타 리프와 드럼',
],
// 음성 오버레이
voiceText: '',
voiceAudioData: null,
@@ -965,12 +1152,23 @@ function soundLogo() {
voiceSrc.start(startTime + this.voiceDelay);
}
// BGM overlay
if (this.bgmBuffer) {
const bgmSrc = ctx.createBufferSource();
bgmSrc.buffer = this.bgmBuffer;
const bgmGain = ctx.createGain();
bgmGain.gain.value = this.bgmVolume;
bgmSrc.connect(bgmGain).connect(ctx.destination);
bgmSrc.start(startTime);
}
// Draw waveform
this.drawWaveform(ctx);
const synthMs = (t - startTime) * 1000 + (this.adsr.release || 500);
const voiceMs = this.voiceBuffer ? (this.voiceDelay + this.voiceBuffer.duration) * 1000 + 200 : 0;
const totalMs = Math.max(synthMs, voiceMs);
const bgmMs = this.bgmBuffer ? this.bgmBuffer.duration * 1000 + 200 : 0;
const totalMs = Math.max(synthMs, voiceMs, bgmMs);
setTimeout(() => {
this.isPlaying = false;
this.playingIdx = -1;
@@ -1038,7 +1236,8 @@ function soundLogo() {
const sampleRate = 44100;
const synthDur = this.getTotalDuration() + this.adsr.release / 1000 + 0.5;
const voiceDur = this.voiceBuffer ? this.voiceDelay + this.voiceBuffer.duration + 0.5 : 0;
const totalDur = Math.max(synthDur, voiceDur);
const bgmDur = this.bgmBuffer ? this.bgmBuffer.duration + 0.5 : 0;
const totalDur = Math.max(synthDur, voiceDur, bgmDur);
const offline = new OfflineAudioContext(2, sampleRate * totalDur, sampleRate);
let t = 0.05;
@@ -1083,6 +1282,16 @@ function soundLogo() {
voiceSrc.start(0.05 + this.voiceDelay);
}
// BGM overlay in offline context
if (this.bgmBuffer) {
const bgmSrc = offline.createBufferSource();
bgmSrc.buffer = this.bgmBuffer;
const bgmGain = offline.createGain();
bgmGain.gain.value = this.bgmVolume;
bgmSrc.connect(bgmGain).connect(offline.destination);
bgmSrc.start(0.05);
}
const buffer = await offline.startRendering();
const wav = this.bufferToWav(buffer);
const blob = new Blob([wav], { type: 'audio/wav' });
@@ -1205,6 +1414,210 @@ function soundLogo() {
this.toast('음성 삭제됨');
},
// ===== 배경음악 (Lyria RealTime) =====
async generateBgm() {
if (this.bgmLoading || !this.bgmPrompt.trim()) return;
this.bgmLoading = true;
this.bgmError = '';
this.bgmProgress = '연결 중...';
try {
// 1) 서버에서 API 설정 가져오기
const cfgRes = await fetch('{{ route("rd.sound-logo.lyria-config") }}');
const cfg = await cfgRes.json();
if (!cfg.success) throw new Error(cfg.error || 'API 설정 로드 실패');
// 2) WebSocket 연결
const wsUrl = cfg.ws_url + '?key=' + cfg.api_key;
const ws = new WebSocket(wsUrl);
this.bgmWs = ws;
const audioChunks = [];
const duration = this.bgmDuration;
let setupDone = false;
let playStartTime = null;
ws.onopen = () => {
this.bgmProgress = '초기화 중...';
// Setup 메시지 전송
ws.send(JSON.stringify({
setup: { model: cfg.model }
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// Setup 완료
if (msg.setupComplete) {
setupDone = true;
this.bgmProgress = '프롬프트 설정 중...';
// 프롬프트 설정
ws.send(JSON.stringify({
client_content: {
weightedPrompts: [{ text: this.bgmPrompt, weight: 1.0 }]
}
}));
// 생성 설정
ws.send(JSON.stringify({
music_generation_config: {
musicGenerationConfig: {
bpm: this.bgmBpm,
density: this.bgmDensity / 100,
brightness: this.bgmBrightness / 100,
scale: this.bgmScale,
temperature: 1.0,
}
}
}));
// 재생 시작
ws.send(JSON.stringify({
playback_control: { playbackControl: 'PLAY' }
}));
playStartTime = Date.now();
this.bgmProgress = '음악 생성 중... 0/' + duration + '초';
}
// 오디오 청크 수신
if (msg.serverContent?.audioChunks) {
for (const chunk of msg.serverContent.audioChunks) {
if (chunk.data) {
audioChunks.push(chunk.data);
}
}
if (playStartTime) {
const elapsed = ((Date.now() - playStartTime) / 1000).toFixed(0);
this.bgmProgress = '음악 생성 중... ' + elapsed + '/' + duration + '초';
}
}
// 프롬프트 필터링 경고
if (msg.filteredPrompt) {
this.bgmError = '프롬프트가 안전 필터에 의해 거부되었습니다.';
this.bgmLoading = false;
ws.close();
}
};
ws.onerror = () => {
this.bgmError = 'WebSocket 연결 오류가 발생했습니다.';
this.bgmLoading = false;
};
ws.onclose = () => {
this.bgmWs = null;
if (audioChunks.length > 0 && !this.bgmError) {
this.bgmProgress = '오디오 처리 중...';
this.decodeBgmChunks(audioChunks);
} else if (!this.bgmError) {
this.bgmError = '오디오 데이터를 받지 못했습니다.';
this.bgmLoading = false;
}
};
// 지정 시간 후 정지
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
playback_control: { playbackControl: 'STOP' }
}));
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) ws.close();
}, 500);
}
}, duration * 1000 + 2000); // 약간의 여유 시간
} catch (e) {
this.bgmError = e.message || '배경음악 생성 중 오류 발생';
this.bgmLoading = false;
}
},
async decodeBgmChunks(chunks) {
try {
// base64 청크들을 합쳐서 하나의 PCM 데이터로 변환
const allBytes = [];
for (const chunk of chunks) {
const binaryStr = atob(chunk);
for (let i = 0; i < binaryStr.length; i++) {
allBytes.push(binaryStr.charCodeAt(i));
}
}
const pcmData = new Uint8Array(allBytes);
// Lyria RealTime: 16-bit PCM, stereo, 48kHz (little-endian)
const sampleRate = 48000;
const numChannels = 2;
const bytesPerSample = 2;
const numSamples = Math.floor(pcmData.length / (numChannels * bytesPerSample));
if (numSamples === 0) {
this.bgmError = '유효한 오디오 데이터가 없습니다.';
this.bgmLoading = false;
return;
}
const ctx = this.getAudioCtx();
const buffer = ctx.createBuffer(numChannels, numSamples, sampleRate);
const view = new DataView(pcmData.buffer);
for (let ch = 0; ch < numChannels; ch++) {
const channelData = buffer.getChannelData(ch);
for (let i = 0; i < numSamples; i++) {
const byteOffset = (i * numChannels + ch) * bytesPerSample;
if (byteOffset + 1 < pcmData.length) {
channelData[i] = view.getInt16(byteOffset, true) / 32768; // little-endian
}
}
}
this.bgmBuffer = buffer;
this.toast('배경음악 생성 완료 (' + buffer.duration.toFixed(1) + '초)');
} catch (e) {
this.bgmError = '오디오 디코딩 실패: ' + e.message;
} finally {
this.bgmLoading = false;
}
},
async playBgmOnly() {
if (!this.bgmBuffer) return;
const ctx = this.getAudioCtx();
if (ctx.state === 'suspended') await ctx.resume();
const src = ctx.createBufferSource();
src.buffer = this.bgmBuffer;
const gain = ctx.createGain();
gain.gain.value = this.bgmVolume;
src.connect(gain).connect(ctx.destination);
src.start();
},
async exportBgmWav() {
if (!this.bgmBuffer) return;
const wav = this.bufferToWav(this.bgmBuffer);
const blob = new Blob([wav], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bgm-lyria-' + new Date().toISOString().slice(0,10) + '.wav';
a.click();
URL.revokeObjectURL(url);
this.toast('배경음악 WAV 다운로드');
},
clearBgm() {
if (this.bgmWs && this.bgmWs.readyState === WebSocket.OPEN) {
this.bgmWs.close();
}
this.bgmBuffer = null;
this.bgmLoading = false;
this.bgmError = '';
this.toast('배경음악 삭제됨');
},
// ===== AI 어시스트 =====
async generateWithAi() {
if (this.aiLoading || !this.aiPrompt.trim()) return;

View File

@@ -425,6 +425,7 @@
Route::get('/sound-logo', [RdController::class, 'soundLogo'])->name('sound-logo');
Route::post('/sound-logo/generate', [RdController::class, 'soundLogoGenerate'])->name('sound-logo.generate');
Route::post('/sound-logo/tts', [RdController::class, 'soundLogoTts'])->name('sound-logo.tts');
Route::get('/sound-logo/lyria-config', [RdController::class, 'soundLogoLyriaConfig'])->name('sound-logo.lyria-config');
});
// 일일 스크럼 (Blade 화면만)