- Web Audio API 기반 사운드 합성 엔진 - 4종 신스(sine/square/triangle/sawtooth) + ADSR 엔벨로프 - 노트 시퀀서 UI (비주얼 바 + 드롭다운 편집) - 10종 프리셋 (알림, 로고, 시작음, 성공 등) - WAV 내보내기, JSON import/export, localStorage 저장
939 lines
41 KiB
PHP
939 lines
41 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;
|
|
}
|
|
</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>
|
|
</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>초
|
|
</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>
|
|
|
|
<!-- 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)">×</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 class="sl-section-title" style="margin-bottom: 12px;">프리셋 템플릿 (10종) — 클릭하면 즉시 로드</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 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>
|
|
|
|
<!-- 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)">×</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,
|
|
audioCtx: null,
|
|
|
|
synthTypes: [
|
|
{ code: 'sine', label: 'Sine (부드러움)', icon: '🔵' },
|
|
{ code: 'square', label: 'Square (8bit)', icon: '🟪' },
|
|
{ code: 'triangle', label: 'Triangle (따뜻함)', icon: '🔺' },
|
|
{ code: 'sawtooth', label: 'Sawtooth (날카로움)', icon: '🟧' },
|
|
],
|
|
|
|
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: [
|
|
{
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
name: '기업 시그널 (무게감)', icon: '🎬', style: 'Netflix 스타일', duration: '2.5', desc: '깊은 울림의 2음 시그널',
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
name: '팡파레', icon: '🎉', style: '축하', duration: '2.0', desc: '화려한 6음 축하 팡파레',
|
|
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 },
|
|
]
|
|
},
|
|
{
|
|
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 },
|
|
]
|
|
},
|
|
],
|
|
|
|
// ===== 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;
|
|
});
|
|
|
|
// Draw waveform
|
|
this.drawWaveform(ctx);
|
|
|
|
const totalMs = (t - startTime) * 1000 + (this.adsr.release || 500);
|
|
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 totalDur = this.getTotalDuration() + this.adsr.release / 1000 + 0.5;
|
|
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;
|
|
});
|
|
|
|
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;
|
|
},
|
|
|
|
// ===== 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
|