diff --git a/app/Http/Controllers/RdController.php b/app/Http/Controllers/RdController.php
index f5e7180e..16d93c82 100644
--- a/app/Http/Controllers/RdController.php
+++ b/app/Http/Controllers/RdController.php
@@ -452,4 +452,63 @@ public function soundLogoGenerate(Request $request): JsonResponse
return response()->json(['success' => true, 'data' => $result]);
}
+
+ /**
+ * 사운드 로고 TTS 음성 생성 (Gemini TTS API)
+ */
+ public function soundLogoTts(Request $request): JsonResponse
+ {
+ $request->validate([
+ 'text' => 'required|string|max:200',
+ ]);
+
+ $apiKey = config('services.gemini.api_key');
+ $baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta');
+
+ if (! $apiKey) {
+ return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500);
+ }
+
+ try {
+ $response = Http::timeout(30)->post(
+ "{$baseUrl}/models/gemini-2.5-flash-preview-tts:generateContent?key={$apiKey}",
+ [
+ 'contents' => [
+ ['parts' => [['text' => $request->text]]],
+ ],
+ 'generationConfig' => [
+ 'responseModalities' => ['AUDIO'],
+ 'speechConfig' => [
+ 'voiceConfig' => [
+ 'prebuiltVoiceConfig' => [
+ 'voiceName' => 'Kore',
+ ],
+ ],
+ ],
+ ],
+ ]
+ );
+ } catch (\Exception $e) {
+ Log::error('SoundLogo TTS 생성 실패', ['error' => $e->getMessage()]);
+
+ return response()->json(['success' => false, 'error' => 'TTS 서버 연결 실패'], 500);
+ }
+
+ if (! $response->successful()) {
+ return response()->json(['success' => false, 'error' => 'TTS 생성 실패: '.$response->status()], 500);
+ }
+
+ $data = $response->json();
+ $inlineData = $data['candidates'][0]['content']['parts'][0]['inlineData'] ?? null;
+
+ if (! $inlineData || empty($inlineData['data'])) {
+ return response()->json(['success' => false, 'error' => '음성 데이터를 받지 못했습니다.'], 500);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'audio_data' => $inlineData['data'],
+ 'mime_type' => $inlineData['mimeType'] ?? 'audio/L16;rate=24000',
+ ]);
+ }
}
diff --git a/resources/views/rd/sound-logo/index.blade.php b/resources/views/rd/sound-logo/index.blade.php
index cd3c76e8..7fc5a5ce 100644
--- a/resources/views/rd/sound-logo/index.blade.php
+++ b/resources/views/rd/sound-logo/index.blade.php
@@ -226,6 +226,9 @@
개 음표 |
초
+
+ | 음성
+
@@ -278,6 +281,44 @@
+
+
+
음성 오버레이 (TTS)
+
+
+
+
+
+
+
+
+
내 사운드
@@ -561,6 +602,15 @@ function soundLogo() {
aiLoading: false,
aiResult: null,
aiError: '',
+ // 음성 오버레이
+ voiceText: '',
+ voiceAudioData: null,
+ voiceMimeType: '',
+ voiceLoading: false,
+ voiceDelay: 0.0,
+ voiceVolume: 0.8,
+ voiceBuffer: null,
+
aiQuickPrompts: [
'밝고 미래적인 IT 기업 로고',
'따뜻하고 친근한 카페 알림음',
@@ -905,10 +955,22 @@ function soundLogo() {
t += n.duration || 0.2;
});
+ // Voice overlay
+ if (this.voiceBuffer) {
+ const voiceSrc = ctx.createBufferSource();
+ voiceSrc.buffer = this.voiceBuffer;
+ const voiceGain = ctx.createGain();
+ voiceGain.gain.value = this.voiceVolume;
+ voiceSrc.connect(voiceGain).connect(ctx.destination);
+ voiceSrc.start(startTime + this.voiceDelay);
+ }
+
// Draw waveform
this.drawWaveform(ctx);
- const totalMs = (t - startTime) * 1000 + (this.adsr.release || 500);
+ 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);
setTimeout(() => {
this.isPlaying = false;
this.playingIdx = -1;
@@ -974,7 +1036,9 @@ function soundLogo() {
if (this.notes.length === 0) return this.toast('음표를 추가해 주세요');
const sampleRate = 44100;
- const totalDur = this.getTotalDuration() + this.adsr.release / 1000 + 0.5;
+ 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 offline = new OfflineAudioContext(2, sampleRate * totalDur, sampleRate);
let t = 0.05;
@@ -1009,6 +1073,16 @@ function soundLogo() {
t += n.duration || 0.2;
});
+ // Voice overlay in offline context
+ if (this.voiceBuffer) {
+ const voiceSrc = offline.createBufferSource();
+ voiceSrc.buffer = this.voiceBuffer;
+ const voiceGain = offline.createGain();
+ voiceGain.gain.value = this.voiceVolume;
+ voiceSrc.connect(voiceGain).connect(offline.destination);
+ voiceSrc.start(0.05 + this.voiceDelay);
+ }
+
const buffer = await offline.startRendering();
const wav = this.bufferToWav(buffer);
const blob = new Blob([wav], { type: 'audio/wav' });
@@ -1060,6 +1134,77 @@ function soundLogo() {
return buf;
},
+ // ===== 음성 오버레이 (TTS) =====
+ async generateVoice() {
+ if (this.voiceLoading || !this.voiceText.trim()) return;
+ this.voiceLoading = true;
+ this.voiceBuffer = null;
+
+ try {
+ const res = await fetch('{{ route("rd.sound-logo.tts") }}', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
+ },
+ body: JSON.stringify({ text: this.voiceText }),
+ });
+
+ const data = await res.json();
+ if (data.success && data.audio_data) {
+ this.voiceAudioData = data.audio_data;
+ this.voiceMimeType = data.mime_type || 'audio/L16;rate=24000';
+ // 샘플레이트 파싱
+ const rateMatch = this.voiceMimeType.match(/rate=(\d+)/);
+ const sampleRate = rateMatch ? parseInt(rateMatch[1]) : 24000;
+ this.voiceBuffer = this.decodeL16(data.audio_data, sampleRate);
+ this.toast('음성 생성 완료: "' + this.voiceText + '"');
+ } else {
+ this.toast(data.error || '음성 생성 실패');
+ }
+ } catch (e) {
+ this.toast('음성 생성 중 오류 발생');
+ } finally {
+ this.voiceLoading = false;
+ }
+ },
+
+ decodeL16(base64Data, sampleRate) {
+ const binaryStr = atob(base64Data);
+ const bytes = new Uint8Array(binaryStr.length);
+ for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
+
+ const view = new DataView(bytes.buffer);
+ const numSamples = Math.floor(bytes.length / 2);
+ const ctx = this.getAudioCtx();
+ const buffer = ctx.createBuffer(1, numSamples, sampleRate);
+ const ch = buffer.getChannelData(0);
+
+ for (let i = 0; i < numSamples; i++) {
+ ch[i] = view.getInt16(i * 2, false) / 32768; // L16 = big-endian
+ }
+ return buffer;
+ },
+
+ async playVoiceOnly() {
+ if (!this.voiceBuffer) return;
+ const ctx = this.getAudioCtx();
+ if (ctx.state === 'suspended') await ctx.resume();
+ const src = ctx.createBufferSource();
+ src.buffer = this.voiceBuffer;
+ const gain = ctx.createGain();
+ gain.gain.value = this.voiceVolume;
+ src.connect(gain).connect(ctx.destination);
+ src.start();
+ },
+
+ clearVoice() {
+ this.voiceBuffer = null;
+ this.voiceAudioData = null;
+ this.voiceMimeType = '';
+ this.toast('음성 삭제됨');
+ },
+
// ===== AI 어시스트 =====
async generateWithAi() {
if (this.aiLoading || !this.aiPrompt.trim()) return;
diff --git a/routes/web.php b/routes/web.php
index eec33127..abb8c0e6 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -424,6 +424,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');
});
// 일일 스크럼 (Blade 화면만)