Files
sam-manage/resources/views/rd/sound-logo/index.blade.php
김보곤 c993826fdc fix: [sound-logo] UX 개선 - 중앙 toast 안내 시스템 + transport bar 레이아웃 수정
- toast를 화면 중앙에 표시하고 info/warn/error 유형별 색상 분리
- 모든 기능에 조건 미충족 시 가이드 메시지 추가 (음표/음성/배경음악 미생성 안내)
- 에러 발생 시 console 대신 사용자 친화적 toast로 알림
- transport bar 하단 잘림 수정 (height 계산 + margin 보정)
2026-03-08 13:53:24 +09:00

2060 lines
119 KiB
PHP

@extends('layouts.app')
@section('title', '사운드 로고 생성기')
@section('content')
<style>
:root {
--sl-blue: #3b82f6;
--sl-indigo: #6366f1;
--sl-purple: #8b5cf6;
--sl-green: #10b981;
--sl-amber: #f59e0b;
--sl-red: #ef4444;
--sl-bg: #0f172a;
--sl-card: #1e293b;
--sl-card2: #334155;
--sl-border: #475569;
--sl-text: #f1f5f9;
--sl-text2: #94a3b8;
--sl-radius: 10px;
}
.sl-wrap {
display: flex; flex-direction: column; height: calc(100vh - 64px);
background: var(--sl-bg); overflow: hidden;
font-family: 'Pretendard', -apple-system, sans-serif; color: var(--sl-text);
margin: -24px;
}
/* Toolbar */
.sl-toolbar {
display: flex; align-items: center; height: 48px; padding: 0 16px;
background: var(--sl-card); border-bottom: 1px solid var(--sl-border);
gap: 12px; flex-shrink: 0;
}
.sl-toolbar h1 { font-size: 14px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 6px; }
.sl-toolbar h1 i { color: var(--sl-purple); }
/* Buttons */
.sl-btn {
display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px;
border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer;
border: 1px solid var(--sl-border); background: var(--sl-card2); color: var(--sl-text);
transition: all .15s;
}
.sl-btn:hover { background: #475569; }
.sl-btn.primary { background: var(--sl-indigo); border-color: var(--sl-indigo); color: #fff; }
.sl-btn.primary:hover { background: #4f46e5; }
.sl-btn.success { background: var(--sl-green); border-color: var(--sl-green); color: #fff; }
.sl-btn.danger { background: var(--sl-red); border-color: var(--sl-red); color: #fff; }
.sl-btn.sm { padding: 4px 8px; font-size: 11px; }
.sl-btn.lg { padding: 10px 20px; font-size: 14px; }
.sl-btn.play { background: var(--sl-green); border-color: var(--sl-green); color: #fff; min-width: 80px; justify-content: center; }
.sl-btn.play:hover { background: #059669; }
.sl-btn.stop { background: var(--sl-red); border-color: var(--sl-red); color: #fff; }
/* Tabs */
.sl-tabs { display: flex; gap: 2px; background: var(--sl-card); border-radius: 8px; padding: 3px; }
.sl-tab {
padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 500;
cursor: pointer; color: var(--sl-text2); transition: all .15s; border: none; background: none;
}
.sl-tab.active { background: var(--sl-indigo); color: #fff; }
.sl-tab:hover:not(.active) { color: var(--sl-text); background: var(--sl-card2); }
/* Main Layout */
.sl-main {
display: flex; flex: 1; overflow: hidden; min-height: 0;
}
.sl-sidebar {
width: 240px; flex-shrink: 0; background: var(--sl-card);
border-right: 1px solid var(--sl-border); overflow-y: auto; padding: 12px;
}
.sl-content {
flex: 1; overflow-y: auto; padding: 20px;
}
/* Section */
.sl-section { margin-bottom: 20px; }
.sl-section-title {
font-size: 11px; font-weight: 600; color: var(--sl-text2);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
}
/* Note Sequencer */
.sl-sequencer {
display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end;
background: var(--sl-card); border-radius: var(--sl-radius); padding: 16px;
border: 1px solid var(--sl-border); min-height: 120px;
}
.sl-note {
display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer;
padding: 8px 6px; border-radius: 8px; border: 2px solid transparent;
transition: all .15s; position: relative; min-width: 52px;
}
.sl-note:hover { border-color: var(--sl-indigo); }
.sl-note.active { border-color: var(--sl-green); background: rgba(16,185,129,.1); }
.sl-note.playing { border-color: var(--sl-amber); background: rgba(245,158,11,.15); box-shadow: 0 0 12px rgba(245,158,11,.3); }
.sl-note-bar {
width: 36px; border-radius: 4px 4px 0 0; background: var(--sl-indigo);
transition: height .2s;
}
.sl-note-label { font-size: 11px; font-weight: 600; color: var(--sl-text); }
.sl-note-dur { font-size: 9px; color: var(--sl-text2); }
.sl-note-del {
position: absolute; top: 2px; right: 2px; width: 16px; height: 16px;
border-radius: 50%; background: var(--sl-red); color: #fff; font-size: 10px;
display: none; align-items: center; justify-content: center; cursor: pointer; border: none;
}
.sl-note:hover .sl-note-del { display: flex; }
/* Rest */
.sl-rest .sl-note-bar { background: var(--sl-card2); border: 1px dashed var(--sl-border); }
.sl-rest .sl-note-label { color: var(--sl-text2); }
/* Chord */
.sl-chord .sl-note-bar { background: linear-gradient(135deg, var(--sl-purple), var(--sl-indigo)); }
/* Add Note Button */
.sl-add-note {
width: 44px; min-height: 80px; border: 2px dashed var(--sl-border); border-radius: 8px;
display: flex; align-items: center; justify-content: center; cursor: pointer;
color: var(--sl-text2); font-size: 18px; transition: all .15s;
}
.sl-add-note:hover { border-color: var(--sl-indigo); color: var(--sl-indigo); }
/* Param Sliders */
.sl-param { margin-bottom: 12px; }
.sl-param-label {
display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 4px;
}
.sl-param-label span:first-child { color: var(--sl-text2); }
.sl-param-label span:last-child { color: var(--sl-text); font-weight: 600; }
.sl-slider {
width: 100%; -webkit-appearance: none; height: 4px; border-radius: 2px;
background: var(--sl-card2); outline: none;
}
.sl-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%;
background: var(--sl-indigo); cursor: pointer; border: 2px solid var(--sl-bg);
}
/* Waveform Canvas */
.sl-waveform {
width: 100%; height: 80px; border-radius: var(--sl-radius);
background: var(--sl-card); border: 1px solid var(--sl-border);
}
/* Preset List */
.sl-preset {
padding: 8px 10px; border-radius: 6px; cursor: pointer; margin-bottom: 4px;
display: flex; align-items: center; gap: 8px; font-size: 12px;
color: var(--sl-text2); transition: all .15s;
}
.sl-preset:hover { background: var(--sl-card2); color: var(--sl-text); }
.sl-preset.active { background: var(--sl-indigo); color: #fff; }
.sl-preset i { font-size: 14px; }
/* Project List */
.sl-proj {
padding: 6px 8px; border-radius: 6px; cursor: pointer; margin-bottom: 3px;
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; color: var(--sl-text2); transition: all .15s;
}
.sl-proj:hover { background: var(--sl-card2); }
.sl-proj.active { background: rgba(99,102,241,.2); color: var(--sl-text); }
/* Select / Input */
.sl-select, .sl-input {
width: 100%; padding: 6px 10px; border-radius: 6px; font-size: 12px;
background: var(--sl-card2); border: 1px solid var(--sl-border); color: var(--sl-text);
outline: none;
}
.sl-select:focus, .sl-input:focus { border-color: var(--sl-indigo); }
/* Toast */
.sl-toast {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: #fff; padding: 14px 28px; max-width: 420px; text-align: center;
border-radius: 10px; font-size: 13px; font-weight: 500; z-index: 9999;
box-shadow: 0 8px 24px rgba(0,0,0,.5); line-height: 1.5;
}
.sl-toast.info { background: var(--sl-indigo); }
.sl-toast.warn { background: #d97706; }
.sl-toast.error { background: var(--sl-red); }
/* Note picker dropdown */
.sl-note-picker {
position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 8px;
padding: 8px; z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,.4);
display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; min-width: 220px;
}
.sl-note-picker button {
padding: 4px 2px; border-radius: 4px; border: none; font-size: 10px; font-weight: 600;
cursor: pointer; background: var(--sl-card2); color: var(--sl-text);
}
.sl-note-picker button:hover { background: var(--sl-indigo); }
.sl-note-picker button.black { background: #1a1a2e; color: #a5b4fc; }
.sl-note-picker button.black:hover { background: var(--sl-purple); color: #fff; }
/* Transport */
.sl-transport {
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); } }
/* Help Modal */
.sl-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 9000;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
.sl-modal {
background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 14px;
width: 90%; max-width: 760px; max-height: 85vh; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,.5);
}
.sl-modal-header {
display: flex; align-items: center; gap: 10px; padding: 16px 20px;
border-bottom: 1px solid var(--sl-border); flex-shrink: 0;
}
.sl-modal-header h2 { font-size: 16px; font-weight: 700; margin: 0; flex: 1; }
.sl-modal-body {
overflow-y: auto; padding: 20px; flex: 1;
font-size: 13px; line-height: 1.8; color: var(--sl-text2);
}
.sl-modal-body h3 {
font-size: 14px; font-weight: 700; color: var(--sl-text); margin: 20px 0 8px;
display: flex; align-items: center; gap: 6px;
}
.sl-modal-body h3:first-child { margin-top: 0; }
.sl-modal-body strong { color: var(--sl-text); }
.sl-modal-body .help-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;
}
.sl-modal-body .help-table {
width: 100%; border-collapse: collapse; margin: 8px 0 12px; font-size: 12px;
}
.sl-modal-body .help-table th,
.sl-modal-body .help-table td {
padding: 6px 10px; border: 1px solid var(--sl-border); text-align: left;
}
.sl-modal-body .help-table th {
background: var(--sl-bg); color: var(--sl-text); font-weight: 600; white-space: nowrap;
}
.sl-modal-body .help-step {
display: flex; gap: 10px; margin-bottom: 10px;
}
.sl-modal-body .help-step-num {
width: 24px; height: 24px; border-radius: 50%; background: var(--sl-indigo); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; flex-shrink: 0; margin-top: 1px;
}
.sl-modal-body .help-step-text { flex: 1; }
.sl-modal-body .help-divider {
border: none; border-top: 1px solid var(--sl-border); margin: 16px 0;
}
</style>
<div x-data="soundLogo()" x-init="init()" class="sl-wrap">
<!-- Toolbar -->
<div class="sl-toolbar">
<h1><i class="ri-music-2-line"></i> 사운드 로고 생성기</h1>
<button class="sl-btn sm" @click="showHelp = true" title="사용법 안내"
style="padding: 4px 8px; border-radius: 50%; width: 28px; height: 28px; justify-content: center;">
<i class="ri-question-line" style="font-size: 14px;"></i>
</button>
<div style="flex:1;"></div>
<div class="sl-tabs">
<button class="sl-tab" :class="mode === 'manual' && 'active'" @click="mode = 'manual'">
<i class="ri-piano-line"></i> 수동
</button>
<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>
<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>
<span style="font-size: 11px; color: var(--sl-text2);">
<i class="ri-music-line"></i> <span x-text="notes.length"></span> 음표 |
<span x-text="getTotalDuration().toFixed(2)"></span>
<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>
<!-- Main -->
<div class="sl-main">
<!-- Sidebar -->
<div class="sl-sidebar">
<!-- Synth Type -->
<div class="sl-section">
<div class="sl-section-title">음색 (Synthesizer)</div>
<template x-for="s in synthTypes" :key="s.code">
<div class="sl-preset" :class="synth === s.code && 'active'" @click="synth = s.code">
<span x-text="s.icon"></span>
<span x-text="s.label"></span>
</div>
</template>
</div>
<!-- ADSR -->
<div class="sl-section">
<div class="sl-section-title">엔벨로프 (ADSR)</div>
<div class="sl-param">
<div class="sl-param-label"><span>Attack</span><span x-text="adsr.attack + 'ms'"></span></div>
<input type="range" class="sl-slider" min="1" max="500" x-model.number="adsr.attack">
</div>
<div class="sl-param">
<div class="sl-param-label"><span>Decay</span><span x-text="adsr.decay + 'ms'"></span></div>
<input type="range" class="sl-slider" min="1" max="1000" x-model.number="adsr.decay">
</div>
<div class="sl-param">
<div class="sl-param-label"><span>Sustain</span><span x-text="(adsr.sustain * 100).toFixed(0) + '%'"></span></div>
<input type="range" class="sl-slider" min="0" max="100" :value="adsr.sustain * 100" @input="adsr.sustain = $event.target.value / 100">
</div>
<div class="sl-param">
<div class="sl-param-label"><span>Release</span><span x-text="adsr.release + 'ms'"></span></div>
<input type="range" class="sl-slider" min="10" max="3000" x-model.number="adsr.release">
</div>
</div>
<!-- Effects -->
<div class="sl-section">
<div class="sl-section-title">이펙트</div>
<div class="sl-param">
<div class="sl-param-label"><span>볼륨</span><span x-text="(volume * 100).toFixed(0) + '%'"></span></div>
<input type="range" class="sl-slider" min="0" max="100" :value="volume * 100" @input="volume = $event.target.value / 100">
</div>
<div class="sl-param">
<div class="sl-param-label"><span>리버브</span><span x-text="(reverb * 100).toFixed(0) + '%'"></span></div>
<input type="range" class="sl-slider" min="0" max="100" :value="reverb * 100" @input="reverb = $event.target.value / 100">
</div>
</div>
<!-- Voice Overlay -->
<div class="sl-section">
<div class="sl-section-title">음성 오버레이 (TTS)</div>
<div style="margin-bottom: 6px;">
<input class="sl-input" x-model="voiceText" placeholder='예: 쌤!, SAM' style="margin-bottom: 6px;">
<button class="sl-btn sm primary" style="width:100%;" @click="generateVoice()" :disabled="voiceLoading || !voiceText.trim()">
<template x-if="!voiceLoading">
<span><i class="ri-mic-line"></i> 음성 생성</span>
</template>
<template x-if="voiceLoading">
<span><i class="ri-loader-4-line" style="animation: spin 1s linear infinite;"></i> 생성 ...</span>
</template>
</button>
</div>
<template x-if="voiceBuffer">
<div>
<div style="padding: 6px 8px; border-radius: 6px; background: rgba(16,185,129,.1); border: 1px solid rgba(16,185,129,.3); margin-bottom: 8px;">
<div style="font-size: 11px; color: var(--sl-green); display: flex; align-items: center; gap: 4px;">
<i class="ri-checkbox-circle-fill"></i>
<span x-text="'\"' + voiceText + '\" · ' + voiceBuffer.duration.toFixed(1) + '초'"></span>
</div>
</div>
<div class="sl-param">
<div class="sl-param-label"><span>시작 시점</span><span x-text="voiceDelay.toFixed(1) + '초'"></span></div>
<input type="range" class="sl-slider" min="0" max="30" :value="voiceDelay * 10" @input="voiceDelay = $event.target.value / 10">
</div>
<div class="sl-param">
<div class="sl-param-label"><span>음성 볼륨</span><span x-text="(voiceVolume * 100).toFixed(0) + '%'"></span></div>
<input type="range" class="sl-slider" min="0" max="100" :value="voiceVolume * 100" @input="voiceVolume = $event.target.value / 100">
</div>
<div style="display: flex; gap: 4px;">
<button class="sl-btn sm" @click="playVoiceOnly()" style="flex:1;"><i class="ri-play-fill"></i> 음성만</button>
<button class="sl-btn sm danger" @click="clearVoice()" style="flex:1;"><i class="ri-delete-bin-line"></i> 삭제</button>
</div>
</div>
</template>
</div>
<!-- Saved Sounds -->
<div class="sl-section">
<div class="sl-section-title"> 사운드</div>
<template x-for="(snd, idx) in savedSounds" :key="snd.id">
<div class="sl-proj" :class="currentSoundId === snd.id && 'active'" @click="loadSound(snd)">
<span><i class="ri-music-line"></i> <span x-text="snd.name"></span></span>
<button class="sl-btn sm danger" style="padding:2px 5px;" @click.stop="deleteSound(idx)">&times;</button>
</div>
</template>
<button class="sl-btn sm" style="width:100%; margin-top: 6px;" @click="saveCurrentSound()">
<i class="ri-save-line"></i> 현재 사운드 저장
</button>
</div>
</div>
<!-- Content -->
<div class="sl-content">
<!-- Preset Mode -->
<template x-if="mode === 'preset'">
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap;">
<div class="sl-section-title" style="margin: 0;">프리셋 템플릿 (50)</div>
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
<template x-for="c in presetCategories" :key="c.code">
<button class="sl-btn sm"
:class="presetCategory === c.code && 'primary'"
@click="presetCategory = c.code"
x-text="c.label + (c.code === 'all' ? ' (' + presets.length + ')' : ' (' + presets.filter(p => p.cat === c.code).length + ')')">
</button>
</template>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px;">
<template x-for="(p, i) in presets" :key="i">
<div x-show="presetCategory === 'all' || p.cat === presetCategory"
style="background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 10px; padding: 14px; cursor: pointer; transition: all .15s;"
:style="presetIdx === i ? 'border-color: var(--sl-indigo); box-shadow: 0 0 12px rgba(99,102,241,.3);' : ''"
@click="loadPreset(i)">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 20px;" x-text="p.icon"></span>
<div>
<div style="font-size: 13px; font-weight: 600;" x-text="p.name"></div>
<div style="font-size: 10px; color: var(--sl-text2);" x-text="p.style + ' · ' + p.duration + '초'"></div>
</div>
</div>
<div style="font-size: 11px; color: var(--sl-text2);" x-text="p.desc"></div>
</div>
</template>
</div>
</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>
<!-- 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>
<!-- Note Sequencer -->
<div class="sl-section">
<div class="sl-section-title">음표 시퀀서 클릭하여 편집, 높이 = 높이</div>
<div class="sl-sequencer" @click.self="pickerIdx = -1">
<template x-for="(n, i) in notes" :key="i">
<div class="sl-note" :class="{
'sl-rest': n.type === 'rest',
'sl-chord': n.type === 'chord',
'playing': playingIdx === i,
'active': selectedIdx === i
}"
@click="selectedIdx = i; pickerIdx = (pickerIdx === i ? -1 : i);"
style="position: relative;">
<div class="sl-note-bar"
:style="'height:' + getNoteBarHeight(n) + 'px; width:' + Math.max(36, n.duration * 80) + 'px;'"></div>
<div class="sl-note-label" x-text="getNoteLabel(n)"></div>
<div class="sl-note-dur" x-text="n.duration.toFixed(2) + 's'"></div>
<button class="sl-note-del" @click.stop="removeNote(i)">&times;</button>
<!-- Note Picker -->
<div class="sl-note-picker" x-show="pickerIdx === i" @click.stop x-cloak>
<template x-for="nn in allNotes" :key="nn">
<button :class="nn.includes('#') ? 'black' : ''"
@click="updateNoteValue(i, nn); pickerIdx = -1;"
x-text="nn"></button>
</template>
<div style="grid-column: span 7; border-top: 1px solid var(--sl-border); padding-top: 6px; margin-top: 4px;">
<div style="display: flex; gap: 4px; align-items: center;">
<span style="font-size: 10px; color: var(--sl-text2); white-space: nowrap;">길이:</span>
<input type="range" class="sl-slider" min="5" max="200" :value="notes[i]?.duration * 100"
@input="notes[i].duration = $event.target.value / 100" style="flex:1;">
<span style="font-size: 10px; color: var(--sl-text); min-width: 32px;" x-text="notes[i]?.duration.toFixed(2) + 's'"></span>
</div>
</div>
</div>
</div>
</template>
<!-- Add buttons -->
<div style="display: flex; flex-direction: column; gap: 4px;">
<div class="sl-add-note" @click="addNote('note')" style="min-height: 50px;" title="음표 추가">
<i class="ri-add-line"></i>
</div>
<div class="sl-add-note" @click="addNote('rest')" style="min-height: 30px; font-size: 12px;" title="쉼표 추가">
<i class="ri-space"></i>
</div>
</div>
</div>
</div>
<!-- Waveform -->
<div class="sl-section">
<div class="sl-section-title">파형</div>
<canvas class="sl-waveform" x-ref="waveformCanvas"></canvas>
</div>
</div>
</template>
</div>
</div>
<!-- Transport Bar -->
<div class="sl-transport">
<button class="sl-btn play lg" @click="playAll()" :disabled="isPlaying">
<i class="ri-play-fill"></i> 재생
</button>
<button class="sl-btn stop" @click="stopAll()" :disabled="!isPlaying">
<i class="ri-stop-fill"></i>
</button>
<div style="flex: 1;"></div>
<button class="sl-btn" @click="exportWav()">
<i class="ri-download-line"></i> WAV 저장
</button>
<button class="sl-btn" @click="exportJson()">
<i class="ri-file-code-line"></i> JSON
</button>
<button class="sl-btn" @click="importJson()">
<i class="ri-upload-line"></i> 불러오기
</button>
</div>
<!-- Help Modal -->
<template x-if="showHelp">
<div class="sl-modal-overlay" @click.self="showHelp = false" @keydown.escape.window="showHelp = false">
<div class="sl-modal">
<div class="sl-modal-header">
<i class="ri-book-open-line" style="font-size: 20px; color: var(--sl-indigo);"></i>
<h2>사운드 로고 생성기 사용법</h2>
<button class="sl-btn sm" @click="showHelp = false" style="border-radius: 50%; width: 28px; height: 28px; padding: 0; justify-content: center;">
<i class="ri-close-line"></i>
</button>
</div>
<div class="sl-modal-body">
<h3><i class="ri-information-line" style="color: var(--sl-indigo);"></i> 개요</h3>
<p>사운드 로고 생성기는 <strong>브랜드 시그니처 사운드</strong> 만드는 도구입니다.<br>
3개의 레이어를 조합하여 하나의 완성된 사운드 로고를 제작할 있습니다.</p>
<table class="help-table">
<tr>
<th><i class="ri-piano-line"></i> 시퀀서</th>
<td>음표를 배치하여 멜로디/효과음 제작 (Web Audio API 신스사이저)</td>
</tr>
<tr>
<th><i class="ri-mic-line"></i> 음성 (TTS)</th>
<td>"쌤!", "SAM" 음성 오버레이 추가 (Google Gemini TTS)</td>
</tr>
<tr>
<th><i class="ri-music-ai-line"></i> 배경음악</th>
<td>다중 악기 배경음악 생성 (Google Lyria RealTime AI)</td>
</tr>
</table>
<hr class="help-divider">
<h3><i class="ri-piano-line" style="color: var(--sl-blue);"></i> 1 수동 모드</h3>
<p>음표를 하나씩 배치하여 직접 사운드를 디자인합니다.</p>
<div class="help-step">
<div class="help-step-num">1</div>
<div class="help-step-text"><strong>좌측 사이드바</strong>에서 음색(Sine/Square/Triangle/Sawtooth) 선택합니다.</div>
</div>
<div class="help-step">
<div class="help-step-num">2</div>
<div class="help-step-text">시퀀서 영역의 <strong>+ 버튼</strong> 클릭하여 음표/화음/쉼표를 추가합니다.</div>
</div>
<div class="help-step">
<div class="help-step-num">3</div>
<div class="help-step-text">음표를 클릭하면 <strong> 높이 선택 피커</strong> 열립니다. 음표 <strong>x 버튼</strong>으로 삭제합니다.</div>
</div>
<div class="help-step">
<div class="help-step-num">4</div>
<div class="help-step-text">하단 파라미터(ADSR, 볼륨, 리버브) 음색을 미세 조정합니다.</div>
</div>
<table class="help-table">
<tr><th>Attack</th><td>소리가 최대 볼륨에 도달하는 시간 (ms)</td></tr>
<tr><th>Decay</th><td>최대 볼륨에서 지속 레벨로 감쇄하는 시간 (ms)</td></tr>
<tr><th>Sustain</th><td>음이 유지되는 동안의 볼륨 비율 (0~1)</td></tr>
<tr><th>Release</th><td>건반에서 손을 소리가 사라지는 시간 (ms)</td></tr>
</table>
<hr class="help-divider">
<h3><i class="ri-magic-line" style="color: var(--sl-purple);"></i> 2 프리셋</h3>
<p><strong>50종의 프리셋 템플릿</strong> 8 카테고리에서 선택할 있습니다.</p>
<table class="help-table">
<tr><th>기업 시그널</th><td>스타트업 로고, 이메일 송신 </td></tr>
<tr><th>알림/메시지</th><td>카카오톡, 알람 알림음</td></tr>
<tr><th>상태/피드백</th><td>성공, 에러, 경고 효과음</td></tr>
<tr><th>전환 효과</th><td>화면 전환, 팝업 </td></tr>
<tr><th>게임 효과</th><td>코인, 레벨업, 게임오버</td></tr>
<tr><th>UI 인터랙션</th><td>클릭, 호버, 토글</td></tr>
<tr><th>브랜드 징글</th><td>광고, 라디오 CM 징글</td></tr>
<tr><th>방송/미디어</th><td>뉴스 속보, 시보 </td></tr>
</table>
<p>프리셋을 클릭하면 시퀀서에 자동 로드되며, <strong>수동 모드에서 추가 편집</strong> 가능합니다.</p>
<hr class="help-divider">
<h3><i class="ri-sparkling-line" style="color: var(--sl-amber);"></i> 3 AI 생성</h3>
<p>텍스트 프롬프트를 입력하면 <strong>Gemini AI가 음표 시퀀스를 자동 설계</strong>합니다.</p>
<div class="help-step">
<div class="help-step-num">1</div>
<div class="help-step-text">원하는 사운드를 자연어로 설명합니다. (: "밝고 미래적인 IT 기업 로고")</div>
</div>
<div class="help-step">
<div class="help-step-num">2</div>
<div class="help-step-text">카테고리와 목표 길이를 설정하고 <strong>"AI로 생성하기"</strong> 클릭합니다.</div>
</div>
<div class="help-step">
<div class="help-step-num">3</div>
<div class="help-step-text">결과 미리보기에서 <strong>미리듣기</strong> , 마음에 들면 <strong>"시퀀서에 로드"</strong>합니다.</div>
</div>
<p><strong>빠른 프롬프트</strong> 버튼을 클릭하면 예시 문장이 자동 입력됩니다.</p>
<hr class="help-divider">
<h3><i class="ri-music-ai-line" style="color: var(--sl-green);"></i> 4 AI 배경음악 (Lyria)</h3>
<p>Google Lyria RealTime AI로 <strong>다중 악기 배경음악</strong> 실시간 생성합니다.</p>
<div class="help-step">
<div class="help-step-num">1</div>
<div class="help-step-text">음악 프롬프트를 입력합니다. (: "밝고 희망적인 어쿠스틱 기타와 피아노")</div>
</div>
<div class="help-step">
<div class="help-step-num">2</div>
<div class="help-step-text">BPM, 밀도, 밝기, 스케일() 조정합니다.</div>
</div>
<div class="help-step">
<div class="help-step-num">3</div>
<div class="help-step-text">녹음 길이(3~30) 설정하고 <strong>"배경음악 생성"</strong> 클릭합니다.</div>
</div>
<div class="help-step">
<div class="help-step-num">4</div>
<div class="help-step-text">생성 완료 <strong>볼륨을 조절</strong>하고 시퀀서/음성과 합성합니다.</div>
</div>
<table class="help-table">
<tr><th>BPM</th><td>분당 비트 (60=느림, 120=보통, 200=빠름)</td></tr>
<tr><th>밀도</th><td>악기/음의 밀집도 (0=sparse, 1=dense)</td></tr>
<tr><th>밝기</th><td>음색의 밝기 (0=어두움, 1=밝음)</td></tr>
<tr><th>스케일</th><td>음악의 조성 (: C Major, A Minor)</td></tr>
</table>
<hr class="help-divider">
<h3><i class="ri-mic-line" style="color: var(--sl-green);"></i> 음성 오버레이 (TTS)</h3>
<p>좌측 사이드바 하단의 <strong>음성 오버레이</strong> 영역에서 텍스트를 입력하고 음성을 생성합니다.</p>
<div class="help-step">
<div class="help-step-num">1</div>
<div class="help-step-text">텍스트 입력 (: "쌤!", "SAM", "코드브릿지엑스").</div>
</div>
<div class="help-step">
<div class="help-step-num">2</div>
<div class="help-step-text"><strong>"음성 생성"</strong> 클릭 AI가 자연스러운 한국어 음성을 생성합니다.</div>
</div>
<div class="help-step">
<div class="help-step-num">3</div>
<div class="help-step-text"><strong>시작 시점</strong>(지연 시간) <strong>볼륨</strong> 조절합니다.</div>
</div>
<hr class="help-divider">
<h3><i class="ri-play-circle-line" style="color: var(--sl-green);"></i> 재생 내보내기</h3>
<table class="help-table">
<tr>
<th style="white-space: nowrap;"><span class="help-badge" style="background: var(--sl-green); color: #fff;"> 전체 재생</span></th>
<td>시퀀서 + 음성 + 배경음악을 <strong>동시에 합성 재생</strong>합니다.</td>
</tr>
<tr>
<th style="white-space: nowrap;"><span class="help-badge" style="background: var(--sl-red); color: #fff;"> 정지</span></th>
<td>재생을 즉시 멈춥니다.</td>
</tr>
<tr>
<th style="white-space: nowrap;"><span class="help-badge" style="background: var(--sl-card2); color: var(--sl-text);">WAV</span></th>
<td>3 레이어가 합쳐진 <strong>WAV 파일을 다운로드</strong>합니다.</td>
</tr>
<tr>
<th style="white-space: nowrap;"><span class="help-badge" style="background: var(--sl-card2); color: var(--sl-text);">JSON</span></th>
<td>시퀀서 설정을 JSON으로 <strong>내보내기/불러오기</strong> 합니다.</td>
</tr>
<tr>
<th style="white-space: nowrap;"><span class="help-badge" style="background: var(--sl-card2); color: var(--sl-text);">저장</span></th>
<td>현재 시퀀서 설정을 브라우저 로컬 저장소에 저장합니다.</td>
</tr>
</table>
<hr class="help-divider">
<h3><i class="ri-stack-line" style="color: var(--sl-indigo);"></i> 3 합성 구조</h3>
<p>최종 사운드 로고는 아래 3개의 레이어가 믹싱되어 출력됩니다.</p>
<div style="background: var(--sl-bg); border-radius: 8px; padding: 14px; font-family: monospace; font-size: 12px; line-height: 1.8; margin: 8px 0;">
<span style="color: var(--sl-blue);">Layer 1</span> 시퀀서 (수동/프리셋/AI) ──┐<br>
<span style="color: var(--sl-green);">Layer 2</span> 음성 TTS (Gemini) ────┼──→ <strong style="color: var(--sl-text);">합성 재생 / WAV 내보내기</strong><br>
<span style="color: var(--sl-amber);">Layer 3</span> 배경음악 (Lyria) ────┘
</div>
<p style="margin-top: 12px; font-size: 11px; color: var(--sl-text2);">
레이어는 독립적으로 사용 가능합니다. 시퀀서만, 음성만, 배경음악만 사용해도 됩니다.
</p>
</div>
</div>
</div>
</template>
<!-- Toast -->
<div class="sl-toast" :class="toastType" x-show="toastMsg" x-text="toastMsg"
x-transition.opacity.duration.300ms x-cloak></div>
</div>
@endsection
@push('scripts')
<script>
function soundLogo() {
return {
mode: 'manual',
showHelp: false,
synth: 'sine',
adsr: { attack: 10, decay: 100, sustain: 0.7, release: 500 },
volume: 0.8,
reverb: 0.2,
notes: [],
selectedIdx: -1,
pickerIdx: -1,
playingIdx: -1,
isPlaying: false,
toastMsg: '',
toastType: 'info',
savedSounds: [],
currentSoundId: null,
presetIdx: -1,
presetCategory: 'all',
audioCtx: null,
// AI 어시스트
aiPrompt: '',
aiCategory: '기업 시그널',
aiDurationRaw: 15,
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,
voiceMimeType: '',
voiceLoading: false,
voiceDelay: 0.0,
voiceVolume: 0.8,
voiceBuffer: null,
aiQuickPrompts: [
'밝고 미래적인 IT 기업 로고',
'따뜻하고 친근한 카페 알림음',
'긴박한 뉴스 속보 시그널',
'귀여운 모바일 앱 알림',
'웅장한 영화 오프닝 사운드',
'8bit 레트로 게임 코인 효과',
'세련된 재즈 브랜드 징글',
'차분한 명상 앱 시작음',
'에너지 넘치는 유튜브 인트로',
'고급스러운 호텔 안내 벨',
],
synthTypes: [
{ code: 'sine', label: 'Sine (부드러움)', icon: '🔵' },
{ code: 'square', label: 'Square (8bit)', icon: '🟪' },
{ code: 'triangle', label: 'Triangle (따뜻함)', icon: '🔺' },
{ code: 'sawtooth', label: 'Sawtooth (날카로움)', icon: '🟧' },
],
presetCategories: [
{ code: 'all', label: '전체' },
{ code: 'corporate', label: '기업 시그널' },
{ code: 'notification', label: '알림/메시지' },
{ code: 'status', label: '상태/피드백' },
{ code: 'transition', label: '전환 효과' },
{ code: 'game', label: '게임 효과' },
{ code: 'ui', label: 'UI 인터랙션' },
{ code: 'jingle', label: '브랜드 징글' },
{ code: 'broadcast', label: '방송/미디어' },
],
allNotes: [
'C3','D3','E3','F3','G3','A3','B3',
'C4','D4','E4','F4','G4','A4','B4',
'C5','D5','E5','F5','G5','A5','B5','C6',
],
noteFreq: {
'C3':130.81,'C#3':138.59,'D3':146.83,'D#3':155.56,'E3':164.81,'F3':174.61,
'F#3':185.00,'G3':196.00,'G#3':207.65,'A3':220.00,'A#3':233.08,'B3':246.94,
'C4':261.63,'C#4':277.18,'D4':293.66,'D#4':311.13,'E4':329.63,'F4':349.23,
'F#4':369.99,'G4':392.00,'G#4':415.30,'A4':440.00,'A#4':466.16,'B4':493.88,
'C5':523.25,'C#5':554.37,'D5':587.33,'D#5':622.25,'E5':659.25,'F5':698.46,
'F#5':739.99,'G5':783.99,'G#5':830.61,'A5':880.00,'A#5':932.33,'B5':987.77,
'C6':1046.50
},
presets: [
// ━━━ 기업 시그널 (Corporate) ━━━
{ cat:'corporate', name:'기업 시그널 (밝음)', icon:'🏢', style:'Intel 스타일', duration:'1.5', desc:'밝고 미래적인 5음 시그널',
synth:'sine', adsr:{attack:10,decay:80,sustain:0.6,release:400}, volume:0.8, reverb:0.3,
notes:[{type:'note',note:'G5',duration:0.18,velocity:0.8},{type:'note',note:'D5',duration:0.18,velocity:0.9},{type:'note',note:'G5',duration:0.18,velocity:0.7},{type:'note',note:'D5',duration:0.18,velocity:0.8},{type:'note',note:'G5',duration:0.50,velocity:1.0}] },
{ cat:'corporate', name:'기업 시그널 (무게감)', icon:'🎬', style:'Netflix 스타일', duration:'2.5', desc:'깊은 울림의 코드 시그널',
synth:'sine', adsr:{attack:30,decay:200,sustain:0.8,release:2000}, volume:0.9, reverb:0.6,
notes:[{type:'chord',chord:['D3','A3'],duration:0.6,velocity:0.9},{type:'rest',duration:0.15},{type:'chord',chord:['D3','A3','D4','F#4'],duration:1.2,velocity:1.0}] },
{ cat:'corporate', name:'기업 시그널 (미래)', icon:'🚀', style:'Microsoft 스타일', duration:'2.0', desc:'상승 4음 테크 시그널',
synth:'sine', adsr:{attack:20,decay:100,sustain:0.7,release:600}, volume:0.8, reverb:0.4,
notes:[{type:'note',note:'E4',duration:0.25,velocity:0.6},{type:'note',note:'B4',duration:0.25,velocity:0.7},{type:'note',note:'D#5',duration:0.25,velocity:0.8},{type:'note',note:'E5',duration:0.60,velocity:1.0}] },
{ cat:'corporate', name:'기업 시그널 (단순)', icon:'🍎', style:'Apple 스타일', duration:'1.0', desc:'미니멀 단음 + 여운',
synth:'sine', adsr:{attack:50,decay:150,sustain:0.8,release:1500}, volume:0.7, reverb:0.5,
notes:[{type:'note',note:'C5',duration:0.80,velocity:0.9}] },
{ cat:'corporate', name:'기업 시그널 (반복)', icon:'📱', style:'Samsung 스타일', duration:'1.0', desc:'경쾌한 3음 반복 패턴',
synth:'triangle', adsr:{attack:5,decay:50,sustain:0.5,release:200}, volume:0.7, reverb:0.2,
notes:[{type:'note',note:'E5',duration:0.12,velocity:0.7},{type:'note',note:'E5',duration:0.12,velocity:0.8},{type:'rest',duration:0.05},{type:'note',note:'G5',duration:0.12,velocity:0.9},{type:'note',note:'E5',duration:0.30,velocity:1.0}] },
{ cat:'corporate', name:'기업 시그널 (디지털)', icon:'💻', style:'스타트업', duration:'1.5', desc:'디지털 아르페지오 상승',
synth:'square', adsr:{attack:5,decay:40,sustain:0.4,release:300}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'C4',duration:0.10,velocity:0.5},{type:'note',note:'E4',duration:0.10,velocity:0.6},{type:'note',note:'G4',duration:0.10,velocity:0.7},{type:'note',note:'C5',duration:0.10,velocity:0.8},{type:'note',note:'E5',duration:0.40,velocity:1.0}] },
{ cat:'corporate', name:'기업 시그널 (프리미엄)', icon:'👑', style:'럭셔리', duration:'3.0', desc:'깊은 베이스 + 하이 코드',
synth:'sine', adsr:{attack:40,decay:200,sustain:0.8,release:2500}, volume:0.9, reverb:0.7,
notes:[{type:'note',note:'F3',duration:0.8,velocity:0.7},{type:'rest',duration:0.2},{type:'chord',chord:['C4','E4','G4','C5'],duration:1.5,velocity:1.0}] },
// ━━━ 알림/메시지 (Notification) ━━━
{ cat:'notification', name:'알림음 (경쾌)', icon:'💬', style:'카카오톡 스타일', duration:'0.5', desc:'짧고 귀여운 2음 알림',
synth:'triangle', adsr:{attack:5,decay:50,sustain:0.4,release:200}, volume:0.7, reverb:0.1,
notes:[{type:'note',note:'E5',duration:0.12,velocity:0.8},{type:'note',note:'A5',duration:0.20,velocity:0.9}] },
{ cat:'notification', name:'알림음 (정보)', icon:'🔔', style:'Slack 스타일', duration:'1.0', desc:'차분한 3음 정보 알림',
synth:'sine', adsr:{attack:10,decay:80,sustain:0.5,release:300}, volume:0.6, reverb:0.2,
notes:[{type:'note',note:'C5',duration:0.15,velocity:0.7},{type:'note',note:'E5',duration:0.15,velocity:0.8},{type:'note',note:'G5',duration:0.30,velocity:0.9}] },
{ cat:'notification', name:'알림음 (부드러움)', icon:'🕊️', style:'iMessage 스타일', duration:'0.8', desc:'부드러운 상승 3음',
synth:'sine', adsr:{attack:15,decay:60,sustain:0.6,release:400}, volume:0.6, reverb:0.3,
notes:[{type:'note',note:'F5',duration:0.12,velocity:0.6},{type:'note',note:'A5',duration:0.12,velocity:0.7},{type:'note',note:'C6',duration:0.30,velocity:0.8}] },
{ cat:'notification', name:'알림음 (채팅)', icon:'💭', style:'Discord 스타일', duration:'0.4', desc:'빠른 2음 팝 사운드',
synth:'triangle', adsr:{attack:3,decay:30,sustain:0.3,release:150}, volume:0.6, reverb:0.1,
notes:[{type:'note',note:'E4',duration:0.08,velocity:0.7},{type:'note',note:'G4',duration:0.15,velocity:0.8}] },
{ cat:'notification', name:'알림음 (이메일)', icon:'📧', style:'전통 메일', duration:'1.2', desc:'클래식 메일 도착음',
synth:'sine', adsr:{attack:10,decay:100,sustain:0.5,release:500}, volume:0.6, reverb:0.3,
notes:[{type:'note',note:'E5',duration:0.15,velocity:0.7},{type:'rest',duration:0.05},{type:'note',note:'E5',duration:0.12,velocity:0.8},{type:'note',note:'G5',duration:0.25,velocity:0.7},{type:'note',note:'E5',duration:0.35,velocity:0.6}] },
{ cat:'notification', name:'알림음 (긴급)', icon:'🚨', style:'긴급 알림', duration:'0.8', desc:'빠른 반복 긴급 알림',
synth:'square', adsr:{attack:3,decay:20,sustain:0.5,release:80}, volume:0.7, reverb:0.1,
notes:[{type:'note',note:'A5',duration:0.08,velocity:0.9},{type:'rest',duration:0.03},{type:'note',note:'A5',duration:0.08,velocity:1.0},{type:'rest',duration:0.03},{type:'note',note:'A5',duration:0.08,velocity:1.0},{type:'rest',duration:0.03},{type:'note',note:'A5',duration:0.15,velocity:1.0}] },
{ cat:'notification', name:'알림음 (리마인더)', icon:'⏰', style:'차임벨', duration:'1.5', desc:'은은한 차임 리마인더',
synth:'sine', adsr:{attack:20,decay:120,sustain:0.5,release:800}, volume:0.5, reverb:0.5,
notes:[{type:'note',note:'G5',duration:0.25,velocity:0.7},{type:'note',note:'E5',duration:0.25,velocity:0.6},{type:'note',note:'C5',duration:0.25,velocity:0.5},{type:'note',note:'G5',duration:0.50,velocity:0.8}] },
// ━━━ 상태/피드백 (Status) ━━━
{ cat:'status', name:'성공 사운드', icon:'✅', style:'레벨업', duration:'1.0', desc:'상승 진행 4음 성공 효과',
synth:'triangle', adsr:{attack:5,decay:60,sustain:0.5,release:400}, volume:0.7, reverb:0.3,
notes:[{type:'note',note:'C5',duration:0.12,velocity:0.7},{type:'note',note:'E5',duration:0.12,velocity:0.8},{type:'note',note:'G5',duration:0.12,velocity:0.9},{type:'note',note:'C6',duration:0.40,velocity:1.0}] },
{ cat:'status', name:'에러 사운드', icon:'❌', style:'경고음', duration:'0.5', desc:'낮은 2음 하강 경고',
synth:'square', adsr:{attack:5,decay:40,sustain:0.3,release:150}, volume:0.5, reverb:0.1,
notes:[{type:'note',note:'E4',duration:0.15,velocity:0.8},{type:'note',note:'C4',duration:0.25,velocity:0.9}] },
{ cat:'status', name:'경고음', icon:'⚠️', style:'주의', duration:'1.0', desc:'반복되는 주의 경고',
synth:'sawtooth', adsr:{attack:5,decay:30,sustain:0.4,release:100}, volume:0.4, reverb:0.1,
notes:[{type:'note',note:'A4',duration:0.12,velocity:0.8},{type:'rest',duration:0.08},{type:'note',note:'A4',duration:0.12,velocity:0.9},{type:'rest',duration:0.08},{type:'note',note:'A4',duration:0.30,velocity:1.0}] },
{ cat:'status', name:'완료 사운드', icon:'🎯', style:'태스크 완료', duration:'0.8', desc:'깔끔한 완료 확인음',
synth:'sine', adsr:{attack:5,decay:50,sustain:0.5,release:300}, volume:0.6, reverb:0.2,
notes:[{type:'note',note:'G4',duration:0.10,velocity:0.6},{type:'note',note:'D5',duration:0.10,velocity:0.7},{type:'note',note:'G5',duration:0.30,velocity:0.9}] },
{ cat:'status', name:'취소 사운드', icon:'↩️', style:'되돌리기', duration:'0.5', desc:'부드러운 하강 취소음',
synth:'sine', adsr:{attack:10,decay:60,sustain:0.4,release:200}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'B4',duration:0.12,velocity:0.6},{type:'note',note:'G4',duration:0.20,velocity:0.5}] },
{ cat:'status', name:'연결 성공', icon:'🔗', style:'커넥트', duration:'0.8', desc:'상승 2음 연결 확인',
synth:'sine', adsr:{attack:10,decay:80,sustain:0.6,release:350}, volume:0.6, reverb:0.3,
notes:[{type:'note',note:'D5',duration:0.15,velocity:0.7},{type:'note',note:'A5',duration:0.35,velocity:0.9}] },
{ cat:'status', name:'연결 해제', icon:'🔌', style:'디스커넥트', duration:'0.8', desc:'하강 2음 연결 해제',
synth:'sine', adsr:{attack:10,decay:80,sustain:0.5,release:350}, volume:0.5, reverb:0.3,
notes:[{type:'note',note:'A5',duration:0.15,velocity:0.7},{type:'note',note:'D5',duration:0.35,velocity:0.5}] },
// ━━━ 전환 효과 (Transition) ━━━
{ cat:'transition', name:'전환 효과 (업)', icon:'⬆️', style:'상승 스윕', duration:'0.5', desc:'빠른 상승 전환음',
synth:'sine', adsr:{attack:5,decay:30,sustain:0.3,release:100}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'C4',duration:0.06,velocity:0.5},{type:'note',note:'E4',duration:0.06,velocity:0.6},{type:'note',note:'G4',duration:0.06,velocity:0.7},{type:'note',note:'C5',duration:0.06,velocity:0.8},{type:'note',note:'E5',duration:0.06,velocity:0.9},{type:'note',note:'G5',duration:0.10,velocity:1.0}] },
{ cat:'transition', name:'전환 효과 (다운)', icon:'⬇️', style:'하강 스윕', duration:'0.5', desc:'빠른 하강 전환음',
synth:'sine', adsr:{attack:5,decay:30,sustain:0.3,release:100}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'G5',duration:0.06,velocity:1.0},{type:'note',note:'E5',duration:0.06,velocity:0.9},{type:'note',note:'C5',duration:0.06,velocity:0.8},{type:'note',note:'G4',duration:0.06,velocity:0.7},{type:'note',note:'E4',duration:0.06,velocity:0.6},{type:'note',note:'C4',duration:0.10,velocity:0.5}] },
{ cat:'transition', name:'전환 (스파크)', icon:'✨', style:'반짝임', duration:'0.6', desc:'높은 음역의 반짝이는 전환',
synth:'sine', adsr:{attack:3,decay:20,sustain:0.2,release:150}, volume:0.4, reverb:0.4,
notes:[{type:'note',note:'C6',duration:0.05,velocity:0.9},{type:'note',note:'G5',duration:0.05,velocity:0.7},{type:'note',note:'C6',duration:0.05,velocity:1.0},{type:'note',note:'E5',duration:0.05,velocity:0.6},{type:'note',note:'G5',duration:0.05,velocity:0.8},{type:'note',note:'C6',duration:0.15,velocity:1.0}] },
{ cat:'transition', name:'전환 (워프)', icon:'🌀', style:'워프/이동', duration:'0.8', desc:'공간 이동 느낌의 전환',
synth:'sawtooth', adsr:{attack:5,decay:50,sustain:0.3,release:200}, volume:0.3, reverb:0.5,
notes:[{type:'note',note:'C3',duration:0.08,velocity:0.4},{type:'note',note:'G3',duration:0.08,velocity:0.5},{type:'note',note:'E4',duration:0.08,velocity:0.6},{type:'note',note:'C5',duration:0.08,velocity:0.7},{type:'note',note:'G5',duration:0.08,velocity:0.8},{type:'note',note:'C6',duration:0.20,velocity:0.9}] },
{ cat:'transition', name:'전환 (클릭)', icon:'🔘', style:'기계적', duration:'0.2', desc:'짧은 기계적 클릭 전환',
synth:'square', adsr:{attack:1,decay:10,sustain:0.2,release:50}, volume:0.4, reverb:0.0,
notes:[{type:'note',note:'C5',duration:0.03,velocity:0.9},{type:'rest',duration:0.02},{type:'note',note:'G5',duration:0.05,velocity:0.7}] },
{ cat:'transition', name:'전환 (슬라이드)', icon:'➡️', style:'글라이드', duration:'0.6', desc:'부드러운 슬라이드 전환',
synth:'sine', adsr:{attack:20,decay:40,sustain:0.5,release:200}, volume:0.5, reverb:0.3,
notes:[{type:'note',note:'E4',duration:0.08,velocity:0.5},{type:'note',note:'F4',duration:0.08,velocity:0.5},{type:'note',note:'G4',duration:0.08,velocity:0.6},{type:'note',note:'A4',duration:0.08,velocity:0.6},{type:'note',note:'B4',duration:0.08,velocity:0.7},{type:'note',note:'C5',duration:0.15,velocity:0.8}] },
// ━━━ 게임 효과 (Game) ━━━
{ cat:'game', name:'코인 획득', icon:'🪙', style:'마리오 스타일', duration:'0.4', desc:'경쾌한 코인 수집 효과',
synth:'square', adsr:{attack:2,decay:20,sustain:0.3,release:100}, volume:0.5, reverb:0.1,
notes:[{type:'note',note:'E5',duration:0.08,velocity:0.8},{type:'note',note:'B5',duration:0.20,velocity:1.0}] },
{ cat:'game', name:'파워업', icon:'⚡', style:'버프', duration:'1.0', desc:'상승 아르페지오 파워업',
synth:'square', adsr:{attack:3,decay:30,sustain:0.4,release:150}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'C4',duration:0.06,velocity:0.5},{type:'note',note:'E4',duration:0.06,velocity:0.6},{type:'note',note:'G4',duration:0.06,velocity:0.7},{type:'note',note:'C5',duration:0.06,velocity:0.8},{type:'note',note:'E5',duration:0.06,velocity:0.9},{type:'note',note:'G5',duration:0.06,velocity:1.0},{type:'note',note:'C6',duration:0.30,velocity:1.0}] },
{ cat:'game', name:'게임 오버', icon:'💀', style:'패배', duration:'2.0', desc:'하강 반음계 게임 오버',
synth:'sawtooth', adsr:{attack:10,decay:100,sustain:0.5,release:800}, volume:0.5, reverb:0.4,
notes:[{type:'note',note:'E4',duration:0.20,velocity:0.8},{type:'note',note:'D#4',duration:0.20,velocity:0.7},{type:'note',note:'D4',duration:0.20,velocity:0.6},{type:'note',note:'C#4',duration:0.20,velocity:0.5},{type:'note',note:'C4',duration:0.60,velocity:0.4}] },
{ cat:'game', name:'보너스', icon:'🎁', style:'보상', duration:'0.8', desc:'빠른 팡파레 보너스',
synth:'triangle', adsr:{attack:3,decay:40,sustain:0.4,release:200}, volume:0.6, reverb:0.2,
notes:[{type:'note',note:'C5',duration:0.06,velocity:0.7},{type:'note',note:'E5',duration:0.06,velocity:0.8},{type:'note',note:'G5',duration:0.06,velocity:0.9},{type:'rest',duration:0.03},{type:'note',note:'C5',duration:0.06,velocity:0.7},{type:'note',note:'E5',duration:0.06,velocity:0.8},{type:'note',note:'G5',duration:0.06,velocity:0.9},{type:'note',note:'C6',duration:0.25,velocity:1.0}] },
{ cat:'game', name:'점프', icon:'🦘', style:'액션', duration:'0.3', desc:'짧은 상승 점프 효과',
synth:'square', adsr:{attack:2,decay:15,sustain:0.2,release:80}, volume:0.5, reverb:0.1,
notes:[{type:'note',note:'C4',duration:0.05,velocity:0.7},{type:'note',note:'G4',duration:0.10,velocity:0.9}] },
{ cat:'game', name:'아이템 드롭', icon:'📦', style:'드롭', duration:'0.5', desc:'하강 아이템 획득음',
synth:'triangle', adsr:{attack:3,decay:25,sustain:0.3,release:120}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'G5',duration:0.06,velocity:0.8},{type:'note',note:'E5',duration:0.06,velocity:0.7},{type:'note',note:'C5',duration:0.06,velocity:0.6},{type:'note',note:'G4',duration:0.15,velocity:0.9}] },
// ━━━ UI 인터랙션 (UI) ━━━
{ cat:'ui', name:'버튼 클릭', icon:'👆', style:'탭', duration:'0.1', desc:'미세한 버튼 터치 피드백',
synth:'sine', adsr:{attack:1,decay:10,sustain:0.1,release:50}, volume:0.3, reverb:0.0,
notes:[{type:'note',note:'C5',duration:0.05,velocity:0.6}] },
{ cat:'ui', name:'토글 On', icon:'🟢', style:'활성화', duration:'0.2', desc:'상승 2음 토글 켜기',
synth:'sine', adsr:{attack:3,decay:20,sustain:0.3,release:100}, volume:0.4, reverb:0.1,
notes:[{type:'note',note:'E5',duration:0.06,velocity:0.6},{type:'note',note:'A5',duration:0.10,velocity:0.8}] },
{ cat:'ui', name:'토글 Off', icon:'🔴', style:'비활성화', duration:'0.2', desc:'하강 2음 토글 끄기',
synth:'sine', adsr:{attack:3,decay:20,sustain:0.3,release:100}, volume:0.4, reverb:0.1,
notes:[{type:'note',note:'A5',duration:0.06,velocity:0.6},{type:'note',note:'E5',duration:0.10,velocity:0.5}] },
{ cat:'ui', name:'스와이프', icon:'👉', style:'제스처', duration:'0.3', desc:'부드러운 스와이프 피드백',
synth:'sine', adsr:{attack:5,decay:15,sustain:0.2,release:80}, volume:0.3, reverb:0.2,
notes:[{type:'note',note:'G4',duration:0.04,velocity:0.4},{type:'note',note:'B4',duration:0.04,velocity:0.5},{type:'note',note:'D5',duration:0.04,velocity:0.6},{type:'note',note:'G5',duration:0.08,velocity:0.7}] },
{ cat:'ui', name:'팝업 등장', icon:'💡', style:'어텐션', duration:'0.4', desc:'팝업 알림 등장 효과',
synth:'triangle', adsr:{attack:5,decay:30,sustain:0.4,release:150}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'G4',duration:0.08,velocity:0.6},{type:'note',note:'C5',duration:0.08,velocity:0.7},{type:'note',note:'E5',duration:0.15,velocity:0.9}] },
{ cat:'ui', name:'드래그 시작', icon:'✊', style:'그랩', duration:'0.15', desc:'드래그 시작 피드백',
synth:'sine', adsr:{attack:2,decay:10,sustain:0.2,release:40}, volume:0.3, reverb:0.0,
notes:[{type:'note',note:'D5',duration:0.04,velocity:0.5},{type:'note',note:'F5',duration:0.06,velocity:0.6}] },
// ━━━ 브랜드 징글 (Jingle) ━━━
{ cat:'jingle', name:'팡파레', icon:'🎉', style:'축하', duration:'2.0', desc:'화려한 축하 팡파레',
synth:'triangle', adsr:{attack:10,decay:100,sustain:0.6,release:600}, volume:0.8, reverb:0.4,
notes:[{type:'note',note:'G4',duration:0.12,velocity:0.7},{type:'note',note:'C5',duration:0.12,velocity:0.8},{type:'note',note:'E5',duration:0.12,velocity:0.9},{type:'rest',duration:0.08},{type:'note',note:'G5',duration:0.20,velocity:1.0},{type:'rest',duration:0.06},{type:'chord',chord:['C5','E5','G5','C6'],duration:0.80,velocity:1.0}] },
{ cat:'jingle', name:'엔딩 징글', icon:'🔚', style:'마무리', duration:'2.0', desc:'깔끔한 하강 마무리 멜로디',
synth:'sine', adsr:{attack:15,decay:100,sustain:0.6,release:800}, volume:0.7, reverb:0.4,
notes:[{type:'note',note:'G5',duration:0.20,velocity:0.8},{type:'note',note:'E5',duration:0.20,velocity:0.7},{type:'note',note:'C5',duration:0.20,velocity:0.8},{type:'rest',duration:0.10},{type:'chord',chord:['C4','E4','G4'],duration:0.80,velocity:0.9}] },
{ cat:'jingle', name:'오프닝 징글', icon:'🎪', style:'시작', duration:'2.0', desc:'밝은 상승 오프닝 멜로디',
synth:'triangle', adsr:{attack:10,decay:80,sustain:0.6,release:500}, volume:0.8, reverb:0.3,
notes:[{type:'note',note:'C4',duration:0.15,velocity:0.6},{type:'note',note:'E4',duration:0.15,velocity:0.7},{type:'note',note:'G4',duration:0.15,velocity:0.8},{type:'note',note:'C5',duration:0.15,velocity:0.9},{type:'rest',duration:0.05},{type:'chord',chord:['E5','G5','C6'],duration:0.80,velocity:1.0}] },
{ cat:'jingle', name:'멜로디 루프', icon:'🔁', style:'반복 멜로디', duration:'2.0', desc:'중독성 있는 루프 멜로디',
synth:'sine', adsr:{attack:10,decay:60,sustain:0.5,release:300}, volume:0.6, reverb:0.3,
notes:[{type:'note',note:'E5',duration:0.20,velocity:0.7},{type:'note',note:'G5',duration:0.15,velocity:0.8},{type:'note',note:'A5',duration:0.15,velocity:0.9},{type:'note',note:'G5',duration:0.20,velocity:0.7},{type:'note',note:'E5',duration:0.15,velocity:0.8},{type:'note',note:'D5',duration:0.15,velocity:0.7},{type:'note',note:'C5',duration:0.30,velocity:0.6}] },
{ cat:'jingle', name:'록 시그널', icon:'🎸', style:'파워 코드', duration:'1.5', desc:'강렬한 파워 코드 시그널',
synth:'sawtooth', adsr:{attack:5,decay:60,sustain:0.5,release:400}, volume:0.6, reverb:0.3,
notes:[{type:'chord',chord:['E3','B3','E4'],duration:0.30,velocity:1.0},{type:'rest',duration:0.05},{type:'chord',chord:['G3','D4','G4'],duration:0.30,velocity:0.9},{type:'rest',duration:0.05},{type:'chord',chord:['A3','E4','A4'],duration:0.50,velocity:1.0}] },
{ cat:'jingle', name:'재즈 시그널', icon:'🎷', style:'스윙', duration:'2.0', desc:'세련된 재즈 코드 진행',
synth:'sine', adsr:{attack:20,decay:120,sustain:0.7,release:600}, volume:0.7, reverb:0.5,
notes:[{type:'chord',chord:['C4','E4','G4','B4'],duration:0.40,velocity:0.7},{type:'rest',duration:0.10},{type:'chord',chord:['F4','A4','C5','E5'],duration:0.40,velocity:0.8},{type:'rest',duration:0.10},{type:'chord',chord:['G4','B4','D5','F5'],duration:0.40,velocity:0.9},{type:'chord',chord:['C4','E4','G4','C5'],duration:0.60,velocity:1.0}] },
// ━━━ 방송/미디어 (Broadcast) ━━━
{ cat:'broadcast', name:'로딩 루프', icon:'🔄', style:'반복 패턴', duration:'1.5', desc:'리듬감 있는 4음 대기 루프',
synth:'sine', adsr:{attack:10,decay:60,sustain:0.4,release:200}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'E4',duration:0.15,velocity:0.6},{type:'note',note:'G4',duration:0.15,velocity:0.7},{type:'note',note:'A4',duration:0.15,velocity:0.8},{type:'note',note:'G4',duration:0.15,velocity:0.6}] },
{ cat:'broadcast', name:'뉴스 인트로', icon:'📺', style:'뉴스 시그널', duration:'2.5', desc:'긴장감 있는 뉴스 오프닝',
synth:'sine', adsr:{attack:15,decay:100,sustain:0.7,release:800}, volume:0.8, reverb:0.4,
notes:[{type:'chord',chord:['C4','G4'],duration:0.25,velocity:0.7},{type:'chord',chord:['D4','A4'],duration:0.25,velocity:0.8},{type:'chord',chord:['E4','B4'],duration:0.25,velocity:0.9},{type:'rest',duration:0.10},{type:'chord',chord:['C4','E4','G4','C5'],duration:0.80,velocity:1.0}] },
{ cat:'broadcast', name:'카운트다운', icon:'⏱️', style:'카운트', duration:'3.0', desc:'3-2-1 카운트다운 비프',
synth:'square', adsr:{attack:3,decay:20,sustain:0.3,release:100}, volume:0.5, reverb:0.1,
notes:[{type:'note',note:'C5',duration:0.10,velocity:0.6},{type:'rest',duration:0.60},{type:'note',note:'C5',duration:0.10,velocity:0.7},{type:'rest',duration:0.60},{type:'note',note:'C5',duration:0.10,velocity:0.8},{type:'rest',duration:0.30},{type:'note',note:'C6',duration:0.40,velocity:1.0}] },
{ cat:'broadcast', name:'브레이킹 뉴스', icon:'🔴', style:'속보', duration:'1.5', desc:'긴급한 속보 알림음',
synth:'sawtooth', adsr:{attack:5,decay:50,sustain:0.5,release:300}, volume:0.7, reverb:0.2,
notes:[{type:'chord',chord:['E4','G#4','B4'],duration:0.15,velocity:1.0},{type:'rest',duration:0.05},{type:'chord',chord:['E4','G#4','B4'],duration:0.15,velocity:1.0},{type:'rest',duration:0.05},{type:'chord',chord:['E4','G#4','B4','E5'],duration:0.50,velocity:1.0}] },
{ cat:'broadcast', name:'팟캐스트 인트로', icon:'🎙️', style:'캐주얼', duration:'2.0', desc:'따뜻하고 캐주얼한 인트로',
synth:'triangle', adsr:{attack:20,decay:80,sustain:0.6,release:400}, volume:0.6, reverb:0.3,
notes:[{type:'note',note:'G4',duration:0.20,velocity:0.6},{type:'note',note:'B4',duration:0.15,velocity:0.7},{type:'note',note:'D5',duration:0.15,velocity:0.8},{type:'note',note:'G5',duration:0.30,velocity:0.7},{type:'rest',duration:0.10},{type:'chord',chord:['G4','B4','D5'],duration:0.50,velocity:0.8}] },
{ cat:'broadcast', name:'유튜브 인트로', icon:'▶️', style:'에너제틱', duration:'1.5', desc:'에너지 넘치는 인트로',
synth:'sawtooth', adsr:{attack:5,decay:40,sustain:0.4,release:200}, volume:0.5, reverb:0.2,
notes:[{type:'note',note:'E4',duration:0.08,velocity:0.7},{type:'note',note:'G4',duration:0.08,velocity:0.8},{type:'note',note:'B4',duration:0.08,velocity:0.9},{type:'rest',duration:0.03},{type:'chord',chord:['E4','G4','B4','E5'],duration:0.40,velocity:1.0}] },
{ cat:'broadcast', name:'라디오 징글', icon:'📻', style:'클래식 라디오', duration:'2.0', desc:'클래식 라디오 스테이션 징글',
synth:'sine', adsr:{attack:10,decay:80,sustain:0.6,release:500}, volume:0.7, reverb:0.4,
notes:[{type:'note',note:'C5',duration:0.15,velocity:0.7},{type:'note',note:'D5',duration:0.10,velocity:0.7},{type:'note',note:'E5',duration:0.15,velocity:0.8},{type:'note',note:'G5',duration:0.10,velocity:0.8},{type:'note',note:'A5',duration:0.15,velocity:0.9},{type:'rest',duration:0.05},{type:'chord',chord:['C5','E5','G5'],duration:0.60,velocity:1.0}] },
],
// ===== Init =====
init() {
this.loadSavedSounds();
// 기본 빈 시퀀스
if (this.notes.length === 0) {
this.notes = [
{ type:'note', note:'C5', duration: 0.20, velocity: 0.8 },
{ type:'note', note:'E5', duration: 0.20, velocity: 0.9 },
{ type:'note', note:'G5', duration: 0.15, velocity: 0.7 },
{ type:'rest', duration: 0.05 },
{ type:'chord', chord:['C5','E5','G5'], duration: 0.80, velocity: 1.0 },
];
}
},
getAudioCtx() {
if (!this.audioCtx) this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return this.audioCtx;
},
// ===== Note helpers =====
getNoteLabel(n) {
if (n.type === 'rest') return '쉼표';
if (n.type === 'chord') return (n.chord || []).join('+');
return n.note || 'C4';
},
getNoteBarHeight(n) {
if (n.type === 'rest') return 20;
const note = n.type === 'chord' ? (n.chord?.[0] || 'C4') : (n.note || 'C4');
const idx = this.allNotes.indexOf(note);
return Math.max(20, 15 + (idx >= 0 ? idx : 7) * 3.5);
},
getTotalDuration() {
return this.notes.reduce((sum, n) => sum + (n.duration || 0), 0);
},
addNote(type) {
if (type === 'rest') {
this.notes.push({ type: 'rest', duration: 0.10 });
} else {
this.notes.push({ type: 'note', note: 'C5', duration: 0.20, velocity: 0.8 });
}
this.pickerIdx = -1;
},
removeNote(i) {
this.notes.splice(i, 1);
this.pickerIdx = -1;
this.selectedIdx = -1;
},
updateNoteValue(i, nn) {
if (this.notes[i].type === 'rest') return;
if (this.notes[i].type === 'chord') {
if (!this.notes[i].chord) this.notes[i].chord = [];
this.notes[i].chord.push(nn);
} else {
this.notes[i].note = nn;
}
},
// ===== Play =====
async playNote(n, ctx, startTime) {
if (n.type === 'rest') return;
const freqs = n.type === 'chord'
? (n.chord || []).map(c => this.noteFreq[c]).filter(Boolean)
: [this.noteFreq[n.note]].filter(Boolean);
if (freqs.length === 0) return;
const vel = (n.velocity || 0.8) * this.volume;
const a = this.adsr.attack / 1000;
const d = this.adsr.decay / 1000;
const s = this.adsr.sustain;
const r = this.adsr.release / 1000;
const dur = n.duration || 0.2;
freqs.forEach(freq => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = this.synth;
osc.frequency.setValueAtTime(freq, startTime);
// ADSR
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(vel, startTime + a);
gain.gain.linearRampToValueAtTime(vel * s, startTime + a + d);
gain.gain.setValueAtTime(vel * s, startTime + dur);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + dur + r);
osc.connect(gain).connect(ctx.destination);
osc.start(startTime);
osc.stop(startTime + dur + r + 0.05);
});
},
async playAll() {
if (this.isPlaying) return;
if (this.notes.length === 0 && !this.voiceBuffer && !this.bgmBuffer) {
return this.toast('재생할 콘텐츠가 없습니다.\n음표를 추가하거나 음성/배경음악을 먼저 생성해 주세요.', 'warn');
}
if (this.notes.length === 0 && !this.voiceBuffer && this.bgmBuffer) {
// BGM만 있는 경우 — 허용하되 안내
}
this.isPlaying = true;
this.pickerIdx = -1;
const ctx = this.getAudioCtx();
if (ctx.state === 'suspended') await ctx.resume();
let t = ctx.currentTime + 0.05;
const startTime = t;
this.notes.forEach((n, i) => {
const noteStart = t;
this.playNote(n, ctx, noteStart);
// Highlight
const delayMs = (noteStart - startTime) * 1000;
setTimeout(() => { this.playingIdx = i; }, delayMs);
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);
}
// 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 bgmMs = this.bgmBuffer ? this.bgmBuffer.duration * 1000 + 200 : 0;
const totalMs = Math.max(synthMs, voiceMs, bgmMs);
setTimeout(() => {
this.isPlaying = false;
this.playingIdx = -1;
}, totalMs);
},
stopAll() {
if (this.audioCtx) {
this.audioCtx.close();
this.audioCtx = null;
}
this.isPlaying = false;
this.playingIdx = -1;
},
// ===== Waveform =====
drawWaveform(ctx) {
const canvas = this.$refs.waveformCanvas;
if (!canvas) return;
const c = canvas.getContext('2d');
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
// Connect to destination via analyser
const bufLen = analyser.frequencyBinCount;
const dataArr = new Uint8Array(bufLen);
const draw = () => {
if (!this.isPlaying) {
// Clear
c.fillStyle = '#1e293b';
c.fillRect(0, 0, canvas.width, canvas.height);
return;
}
requestAnimationFrame(draw);
analyser.getByteTimeDomainData(dataArr);
c.fillStyle = '#1e293b';
c.fillRect(0, 0, canvas.width, canvas.height);
c.lineWidth = 2;
c.strokeStyle = '#6366f1';
c.beginPath();
const sliceW = canvas.width / bufLen;
let x = 0;
for (let i = 0; i < bufLen; i++) {
const v = dataArr[i] / 128.0;
const y = v * canvas.height / 2;
i === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
x += sliceW;
}
c.lineTo(canvas.width, canvas.height / 2);
c.stroke();
};
draw();
},
// ===== Export WAV =====
async exportWav() {
if (this.notes.length === 0 && !this.voiceBuffer && !this.bgmBuffer) {
return this.toast('저장할 콘텐츠가 없습니다.\n음표, 음성, 배경음악 중 하나 이상을 먼저 만들어 주세요.', 'warn');
}
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 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;
this.notes.forEach(n => {
if (n.type !== 'rest') {
const freqs = n.type === 'chord'
? (n.chord || []).map(c => this.noteFreq[c]).filter(Boolean)
: [this.noteFreq[n.note]].filter(Boolean);
const vel = (n.velocity || 0.8) * this.volume;
const a = this.adsr.attack / 1000;
const d = this.adsr.decay / 1000;
const s = this.adsr.sustain;
const r = this.adsr.release / 1000;
const dur = n.duration || 0.2;
freqs.forEach(freq => {
const osc = offline.createOscillator();
const gain = offline.createGain();
osc.type = this.synth;
osc.frequency.setValueAtTime(freq, t);
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(vel, t + a);
gain.gain.linearRampToValueAtTime(vel * s, t + a + d);
gain.gain.setValueAtTime(vel * s, t + dur);
gain.gain.exponentialRampToValueAtTime(0.001, t + dur + r);
osc.connect(gain).connect(offline.destination);
osc.start(t);
osc.stop(t + dur + r + 0.05);
});
}
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);
}
// 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' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sound-logo-' + new Date().toISOString().slice(0,10) + '.wav';
a.click();
URL.revokeObjectURL(url);
this.toast('WAV 파일이 다운로드됩니다');
},
bufferToWav(buffer) {
const numCh = buffer.numberOfChannels;
const len = buffer.length;
const sampleRate = buffer.sampleRate;
const bitsPerSample = 16;
const byteRate = sampleRate * numCh * bitsPerSample / 8;
const blockAlign = numCh * bitsPerSample / 8;
const dataSize = len * blockAlign;
const buf = new ArrayBuffer(44 + dataSize);
const view = new DataView(buf);
const writeStr = (offset, str) => { for(let i=0;i<str.length;i++) view.setUint8(offset+i, str.charCodeAt(i)); };
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numCh, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
writeStr(36, 'data');
view.setUint32(40, dataSize, true);
let offset = 44;
for (let i = 0; i < len; i++) {
for (let ch = 0; ch < numCh; ch++) {
let sample = buffer.getChannelData(ch)[i];
sample = Math.max(-1, Math.min(1, sample));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
}
return buf;
},
// ===== 음성 오버레이 (TTS) =====
async generateVoice() {
if (this.voiceLoading) return;
if (!this.voiceText.trim()) return this.toast('음성으로 변환할 텍스트를 입력해 주세요.', 'warn');
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 || '음성 생성에 실패했습니다. 다시 시도해 주세요.', 'error');
}
} catch (e) {
this.toast('음성 생성 중 네트워크 오류가 발생했습니다.', 'error');
} 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 this.toast('음성을 먼저 생성해 주세요.\n음성 탭에서 텍스트를 입력하고 생성 버튼을 눌러주세요.', 'warn');
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('음성 삭제됨');
},
// ===== 배경음악 (Lyria RealTime) =====
async generateBgm() {
if (this.bgmLoading) return;
if (!this.bgmPrompt.trim()) return this.toast('배경음악 프롬프트를 입력해 주세요.\n원하는 분위기나 장르를 설명해 주세요.', 'warn');
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;
this.toast('입력한 프롬프트가 안전 필터에 거부되었습니다.\n다른 표현으로 변경해 주세요.', 'error');
ws.close();
}
};
ws.onerror = () => {
this.bgmError = 'WebSocket 연결 오류가 발생했습니다.';
this.bgmLoading = false;
this.toast('배경음악 서버 연결에 실패했습니다.\n잠시 후 다시 시도해 주세요.', 'error');
};
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;
this.toast('배경음악 생성에 실패했습니다.\n다른 프롬프트로 다시 시도해 주세요.', 'error');
}
};
// 지정 시간 후 정지
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;
this.toast(this.bgmError, 'error');
}
},
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;
this.toast('배경음악 오디오 처리에 실패했습니다.', 'error');
} finally {
this.bgmLoading = false;
}
},
async playBgmOnly() {
if (!this.bgmBuffer) return this.toast('배경음악을 먼저 생성해 주세요.\nAI 배경음악 탭에서 프롬프트를 입력하고 생성 버튼을 눌러주세요.', 'warn');
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 this.toast('배경음악을 먼저 생성해 주세요.', 'warn');
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) return;
if (!this.aiPrompt.trim()) return this.toast('AI 생성 프롬프트를 입력해 주세요.\n원하는 사운드 로고를 설명해 주세요.', 'warn');
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;
this.toast('AI 사운드 로고가 생성되었습니다.');
} else {
this.aiError = data.error || 'AI 생성에 실패했습니다.';
this.toast(this.aiError, 'error');
}
} catch (e) {
this.aiError = '네트워크 오류가 발생했습니다.';
this.toast(this.aiError, 'error');
} finally {
this.aiLoading = false;
}
},
loadAiResult() {
if (!this.aiResult) return this.toast('적용할 AI 결과가 없습니다.\n먼저 AI 생성을 실행해 주세요.', 'warn');
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.isPlaying) return;
if (!this.aiResult) return this.toast('AI 생성 결과가 없습니다.\nAI 생성 탭에서 먼저 사운드를 생성해 주세요.', 'warn');
// 임시로 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];
this.presetIdx = i;
this.synth = p.synth;
this.adsr = { ...p.adsr };
this.volume = p.volume;
this.reverb = p.reverb;
this.notes = JSON.parse(JSON.stringify(p.notes));
this.mode = 'manual';
this.toast(p.name + ' 로드됨');
},
// ===== Save/Load =====
loadSavedSounds() {
try {
const d = localStorage.getItem('sl_sounds');
this.savedSounds = d ? JSON.parse(d) : [];
} catch { this.savedSounds = []; }
},
persistSounds() {
localStorage.setItem('sl_sounds', JSON.stringify(this.savedSounds));
},
saveCurrentSound() {
const name = prompt('사운드 이름을 입력하세요:', '새 사운드');
if (!name) return;
const snd = {
id: 'snd_' + Date.now(),
name,
synth: this.synth,
adsr: { ...this.adsr },
volume: this.volume,
reverb: this.reverb,
notes: JSON.parse(JSON.stringify(this.notes)),
createdAt: new Date().toISOString(),
};
this.savedSounds.push(snd);
this.currentSoundId = snd.id;
this.persistSounds();
this.toast('저장됨: ' + name);
},
loadSound(snd) {
this.currentSoundId = snd.id;
this.synth = snd.synth;
this.adsr = { ...snd.adsr };
this.volume = snd.volume || 0.8;
this.reverb = snd.reverb || 0.2;
this.notes = JSON.parse(JSON.stringify(snd.notes));
this.mode = 'manual';
this.toast(snd.name + ' 로드됨');
},
deleteSound(idx) {
if (!confirm('삭제하시겠습니까?')) return;
this.savedSounds.splice(idx, 1);
this.persistSounds();
this.toast('삭제됨');
},
// ===== JSON Import/Export =====
exportJson() {
const data = { synth: this.synth, adsr: this.adsr, volume: this.volume, reverb: this.reverb, notes: this.notes };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sound-logo-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
URL.revokeObjectURL(url);
this.toast('JSON 내보내기 완료');
},
importJson() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (data.notes) this.notes = data.notes;
if (data.synth) this.synth = data.synth;
if (data.adsr) this.adsr = { ...data.adsr };
if (data.volume != null) this.volume = data.volume;
if (data.reverb != null) this.reverb = data.reverb;
this.mode = 'manual';
this.toast('불러오기 완료');
} catch { this.toast('JSON 파일 오류'); }
};
reader.readAsText(file);
};
input.click();
},
toast(msg, type = 'info') {
this.toastMsg = msg;
this.toastType = type;
const dur = type === 'error' ? 3500 : type === 'warn' ? 3000 : 2500;
setTimeout(() => { this.toastMsg = ''; }, dur);
},
};
}
</script>
@endpush