diff --git a/app/Http/Controllers/RdController.php b/app/Http/Controllers/RdController.php
index 65a542da..18439262 100644
--- a/app/Http/Controllers/RdController.php
+++ b/app/Http/Controllers/RdController.php
@@ -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)
*/
diff --git a/resources/views/rd/sound-logo/index.blade.php b/resources/views/rd/sound-logo/index.blade.php
index 7fc5a5ce..5970168b 100644
--- a/resources/views/rd/sound-logo/index.blade.php
+++ b/resources/views/rd/sound-logo/index.blade.php
@@ -220,6 +220,10 @@
AI 생성
+
+ AI 배경음악
+ Lyria
+
@@ -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;
diff --git a/routes/web.php b/routes/web.php
index abb8c0e6..8751f03f 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -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 화면만)