feat: [rd] AI 배경음악 생성 기능 추가 (Google Lyria RealTime)
- Lyria RealTime WebSocket 연동으로 다중 악기 배경음악 실시간 생성 - BPM, 밀도, 밝기, 스케일 컨트롤 지원 - 시퀀서 + 음성 + 배경음악 3중 합성 (playAll, exportWav) - 서버 API 키 보호 엔드포인트 (lyria-config) - 빠른 프롬프트 10종 제공
This commit is contained in:
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 화면만)
|
||||
|
||||
Reference in New Issue
Block a user