Files
sam-manage/resources/views/rd/sound-logo/index.blade.php
김보곤 ff2296d4d8 feat: [sound-logo] TTS 음성 오버레이 기능 추가
- Gemini TTS API 연동 (한국어 Kore 음성)
- 사이드바에 음성 오버레이 컨트롤: 텍스트 입력, 시작 시점, 볼륨
- 재생/WAV 내보내기 시 신스 + 음성 자동 합성
- POST /rd/sound-logo/tts 엔드포인트 추가
- L16 PCM → AudioBuffer 디코더 구현
2026-03-08 12:44:22 +09:00

1371 lines
79 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 - 56px);
background: var(--sl-bg); overflow: hidden;
font-family: 'Pretendard', -apple-system, sans-serif; color: var(--sl-text);
}
/* 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;
}
.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; bottom: 20px; left: 50%; transform: translateX(-50%);
background: var(--sl-indigo); color: #fff; padding: 10px 20px;
border-radius: 8px; font-size: 13px; z-index: 9999;
box-shadow: 0 4px 12px rgba(0,0,0,.3);
}
/* 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); } }
</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>
<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>
</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>
</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>
<!-- 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>
<!-- Toast -->
<div class="sl-toast" x-show="toastMsg" x-text="toastMsg"
x-transition.opacity.duration.300ms x-cloak></div>
</div>
@endsection
@push('scripts')
<script>
function soundLogo() {
return {
mode: 'manual',
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: '',
savedSounds: [],
currentSoundId: null,
presetIdx: -1,
presetCategory: 'all',
audioCtx: null,
// AI 어시스트
aiPrompt: '',
aiCategory: '기업 시그널',
aiDurationRaw: 15,
aiLoading: false,
aiResult: null,
aiError: '',
// 음성 오버레이
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 || this.notes.length === 0) return;
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);
}
// 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);
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) return this.toast('음표를 추가해 주세요');
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 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);
}
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 || !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;
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;
} else {
this.aiError = data.error || 'AI 생성에 실패했습니다.';
}
} catch (e) {
this.aiError = '네트워크 오류가 발생했습니다.';
} finally {
this.aiLoading = false;
}
},
loadAiResult() {
if (!this.aiResult) return;
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.aiResult || this.isPlaying) return;
// 임시로 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) {
this.toastMsg = msg;
setTimeout(() => { this.toastMsg = ''; }, 2500);
},
};
}
</script>
@endpush