본문으로 건너뛰기
Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
view/hinana/echo.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="color-scheme" content="light dark">
6 <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0">
7 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#0f141e' : '#f8f7f5' %>">
8 <meta property="og:image" content="/image/lounge1.png" />
9 <meta property="og:title" content="에코 스튜디오 — 비나래 라운지" />
10 <meta property="og:description" content="음악 파일에 에코 효과를 입혀보세요." />
11 <title>에코 스튜디오 — 비나래 라운지</title>
12 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
13 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
14 <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700&family=Montserrat:wght@300;400;700&display=swap" rel="stylesheet">
15 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
16 <script src="/js/popup.js"></script>
17 <script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script>
18 <style>
19 *, *::before, *::after { box-sizing: border-box; }
20 :root {
21 --bg-main: #f8f7f5; --bg-secondary: #ffffff; --bg-tertiary: #1a2238;
22 --bg-card: #ffffff; --bg-input: #f1f0ed;
23 --text-primary: #1a2238; --text-secondary: #5e6676;
24 --accent: #c5a059; --accent-dim: rgba(197,160,89,0.12);
25 --border: #e5e1da; --danger: #ef4444;
26 --font: 'Noto Sans KR', sans-serif;
27 --viz-bg: #1a2238;
28 }
29 body.dark-mode {
30 --bg-main: #0f141e; --bg-secondary: #1a2238; --bg-tertiary: #111827;
31 --bg-card: #1e2a42; --bg-input: #243050;
32 --text-primary: #e7e5e4; --text-secondary: #94a3b8;
33 --border: #2e3a59;
34 --viz-bg: #0a0f1a;
35 }
36 html, body { margin: 0; padding: 0; background: var(--bg-main); color: var(--text-primary); font-family: var(--font); min-height: 100vh; transition: background 0.2s, color 0.2s; }
37 a { text-decoration: none; color: inherit; }
38
39 /* 헤더 */
40 .site-header {
41 background: var(--bg-tertiary); height: 60px; padding: 0 24px;
42 display: flex; align-items: center; justify-content: space-between; gap: 14px;
43 border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100;
44 }
45 .site-header .brand { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; font-size: 0.9rem; font-weight: 700; }
46 .site-header .brand > span:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
47 .header-logo { height: 26px; max-width: 170px; object-fit: contain; }
48 .site-header nav a { font-size: 0.82rem; color: var(--text-secondary); margin-left: 20px; transition: color 0.2s; }
49 .site-header nav a:hover { color: var(--accent); }
50
51 /* 레이아웃 */
52 .page-body { display: flex; gap: 0; min-height: calc(100vh - 60px); }
53
54 /* 패널 */
55 .panel {
56 width: 300px; min-width: 300px; background: var(--bg-secondary);
57 border-right: 1px solid var(--border);
58 display: flex; flex-direction: column; overflow-y: auto;
59 }
60 .panel-section { padding: 18px; border-bottom: 1px solid var(--border); }
61 .panel-title {
62 font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
63 letter-spacing: 0.1em; color: var(--text-secondary); margin-bottom: 12px;
64 }
65
66 /* 폼 요소 */
67 .form-label-sm { font-size: 0.78rem; color: var(--text-secondary); margin-bottom: 5px; display: flex; justify-content: space-between; }
68 .form-label-sm span { color: var(--accent); font-weight: 600; }
69 input[type=range] {
70 width: 100%; accent-color: var(--accent); cursor: pointer;
71 background: transparent;
72 }
73 .form-select-sm, .form-control-sm {
74 font-size: 0.85rem; padding: 7px 10px; width: 100%;
75 border: 1px solid var(--border); border-radius: 6px;
76 background: var(--bg-input); color: var(--text-primary);
77 }
78 .form-select-sm:focus, .form-control-sm:focus { outline: none; border-color: var(--accent); }
79
80 /* 업로드 존 */
81 .upload-zone {
82 border: 2px dashed var(--border); border-radius: 8px;
83 padding: 22px; text-align: center; cursor: pointer;
84 color: var(--text-secondary); font-size: 0.82rem;
85 transition: border-color 0.2s, color 0.2s;
86 }
87 .upload-zone:hover, .upload-zone.dragover { border-color: var(--accent); color: var(--text-primary); }
88 .upload-zone i { display: block; font-size: 1.8rem; margin-bottom: 8px; opacity: 0.5; }
89 .file-name { font-size: 0.78rem; color: var(--accent); margin-top: 8px; word-break: break-all; }
90
91 /* 버튼 */
92 .btn-accent {
93 background: var(--accent); color: var(--ui-on-accent, #fff); border: none;
94 padding: 9px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 600;
95 cursor: pointer; width: 100%; transition: opacity 0.2s; font-family: var(--font);
96 }
97 .btn-accent:hover { opacity: 0.85; }
98 .btn-accent:disabled { opacity: 0.35; cursor: not-allowed; }
99 .btn-outline {
100 background: transparent; border: 1px solid var(--border);
101 color: var(--text-primary); padding: 8px 14px; border-radius: 6px;
102 font-size: 0.82rem; cursor: pointer; transition: border-color 0.15s; font-family: var(--font);
103 }
104 .btn-outline:hover { border-color: var(--accent); color: var(--accent); }
105
106 /* 슬라이더 레이아웃 */
107 .slider-row { margin-bottom: 14px; }
108
109 /* 프리셋 버튼들 */
110 .preset-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
111 .preset-btn {
112 padding: 8px 6px; border: 1px solid var(--border); border-radius: 6px;
113 background: var(--bg-input); color: var(--text-secondary);
114 font-size: 0.76rem; cursor: pointer; transition: all 0.15s; font-family: var(--font);
115 text-align: center;
116 }
117 .preset-btn:hover, .preset-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
118
119 /* 메인 영역 */
120 .main-area {
121 flex: 1; display: flex; flex-direction: column;
122 background: var(--bg-main); padding: 32px; gap: 24px;
123 }
124
125 /* 비주얼라이저 */
126 .viz-card {
127 background: var(--bg-card); border: 1px solid var(--border);
128 border-radius: 12px; padding: 20px; position: relative;
129 }
130 .viz-title {
131 font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
132 letter-spacing: 0.1em; color: var(--text-secondary); margin-bottom: 14px;
133 }
134 #vizCanvas { width: 100%; height: 120px; display: block; border-radius: 6px; background: var(--viz-bg); }
135
136 /* 플레이어 */
137 .player-card {
138 background: var(--bg-card); border: 1px solid var(--border);
139 border-radius: 12px; padding: 24px;
140 }
141 .player-meta { margin-bottom: 18px; }
142 .player-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
143 .player-sub { font-size: 0.78rem; color: var(--text-secondary); }
144
145 .progress-bar-wrap {
146 height: 4px; background: var(--bg-input); border-radius: 2px;
147 margin-bottom: 6px; cursor: pointer; position: relative;
148 }
149 .progress-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; width: 0%; transition: width 0.1s linear; pointer-events: none; }
150 .time-row { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 18px; }
151
152 .player-controls { display: flex; align-items: center; justify-content: center; gap: 16px; }
153 .ctrl-btn {
154 background: none; border: none; color: var(--text-secondary);
155 font-size: 1.4rem; cursor: pointer; transition: color 0.15s; padding: 4px;
156 }
157 .ctrl-btn:hover { color: var(--text-primary); }
158 .ctrl-btn.play-btn {
159 width: 52px; height: 52px; border-radius: 50%;
160 background: var(--accent); color: var(--ui-on-accent, #fff); font-size: 1.2rem;
161 display: flex; align-items: center; justify-content: center;
162 }
163 .ctrl-btn.play-btn:hover { opacity: 0.85; }
164 .ctrl-btn.active { color: var(--accent); }
165
166 /* 다운로드 */
167 .download-card {
168 background: var(--bg-card); border: 1px solid var(--border);
169 border-radius: 12px; padding: 20px;
170 }
171 .rec-indicator {
172 display: inline-flex; align-items: center; gap: 6px;
173 font-size: 0.76rem; color: var(--danger); font-weight: 600;
174 opacity: 0; transition: opacity 0.3s;
175 }
176 .rec-indicator.active { opacity: 1; }
177 .rec-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); animation: recPulse 1s ease-in-out infinite; }
178 @keyframes recPulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
179
180 /* 힌트 */
181 .hint { font-size: 0.73rem; color: var(--text-secondary); margin-top: 6px; line-height: 1.5; }
182
183 /* 상태 표시 */
184 .status-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; gap: 14px; color: var(--text-secondary); text-align: center; }
185 .status-empty i { font-size: 3rem; opacity: 0.2; }
186
187 /* 모바일 */
188 @media (max-width: 768px) {
189 .page-body { flex-direction: column; }
190 .panel { width: 100%; min-width: unset; border-right: none; border-bottom: 1px solid var(--border); }
191 .main-area { padding: 16px; }
192 .site-header { padding: 0 12px; gap: 8px; }
193 .site-header .brand { gap: 8px; font-size: 0.82rem; }
194 .header-logo { max-width: 132px; height: 24px; }
195 .site-header nav { display: flex !important; align-items: center; gap: 6px; flex: 0 0 auto; }
196 .site-header nav a { margin-left: 0; width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; font-size: 0; }
197 .site-header nav a i { font-size: 1rem; margin: 0 !important; }
198 }
199 </style>
200 <link rel="stylesheet" href="/css/lounge-modern.css">
201 <link rel="stylesheet" href="/css/site-ui.css?v=3">
202 <link rel="stylesheet" href="/css/site-app.css?v=1">
203 </head>
204 <body data-ui-page="echo" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
205
206 <%- include('partials/site-header', { uiPage: 'echo' }) %>
207 <div id="ui-content" tabindex="-1" class="ui-content-anchor"></div>
208
209 <div class="page-body">
210
211 <!-- 왼쪽 패널 -->
212 <div class="panel">
213
214 <!-- 파일 업로드 -->
215 <div class="panel-section">
216 <div class="panel-title"><i class="bi bi-music-note-beamed me-1"></i>오디오 파일</div>
217 <div class="upload-zone" id="uploadZone">
218 <i class="bi bi-upload"></i>
219 클릭하거나 파일을 드래그하세요
220 </div>
221 <input type="file" id="fileInput" accept="audio/*" style="display:none;">
222 <div class="file-name" id="fileName" style="display:none;"></div>
223 <p class="hint mt-2">MP3, WAV, OGG, AAC, FLAC 지원</p>
224 </div>
225
226 <!-- 프리셋 -->
227 <div class="panel-section">
228 <div class="panel-title"><i class="bi bi-stars me-1"></i>프리셋</div>
229 <div class="preset-grid">
230 <button class="preset-btn" data-preset="room">작은 방</button>
231 <button class="preset-btn" data-preset="hall">콘서트홀</button>
232 <button class="preset-btn" data-preset="cave">동굴</button>
233 <button class="preset-btn" data-preset="phone">전화기</button>
234 <button class="preset-btn" data-preset="stadium">스타디움</button>
235 <button class="preset-btn" data-preset="none">에코 없음</button>
236 </div>
237 </div>
238
239 <!-- 에코 설정 -->
240 <div class="panel-section">
241 <div class="panel-title"><i class="bi bi-sliders me-1"></i>에코 세부 설정</div>
242
243 <div class="slider-row">
244 <label class="form-label-sm">에코 강도 <span id="valWet">40%</span></label>
245 <input type="range" id="slWet" min="0" max="100" step="1" value="40">
246 <div class="hint" style="margin-top:2px;">에코가 얼마나 강하게 들리는지</div>
247 </div>
248 <div class="slider-row" style="margin-top:12px;">
249 <label class="form-label-sm">잔향 길이 <span id="valFeedback">35%</span></label>
250 <input type="range" id="slFeedback" min="0" max="85" step="1" value="35">
251 <div class="hint" style="margin-top:2px;">에코가 얼마나 오래 지속되는지</div>
252 </div>
253 <div class="slider-row" style="margin-top:12px;">
254 <label class="form-label-sm">딜레이 시간 <span id="valDelay">80ms</span></label>
255 <input type="range" id="slDelay" min="10" max="500" step="1" value="80">
256 <div class="hint" style="margin-top:2px;">노래방: 60~120ms / 홀: 200ms+</div>
257 </div>
258 <div class="slider-row" style="margin-top:12px;">
259 <label class="form-label-sm">원본 음량 <span id="valDry">100%</span></label>
260 <input type="range" id="slDry" min="0" max="100" step="1" value="100">
261 </div>
262 </div>
263
264 <!-- 저장 -->
265 <div class="panel-section">
266 <div class="panel-title"><i class="bi bi-download me-1"></i>저장</div>
267 <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
268 <span class="rec-indicator" id="recIndicator"><span class="rec-dot"></span> 녹음 중</span>
269 </div>
270 <div style="display:flex; gap:8px;">
271 <button class="btn-accent" id="btnSaveWav" disabled style="flex:1;">
272 <i class="bi bi-file-earmark-music me-1"></i>WAV
273 </button>
274 <button class="btn-accent" id="btnSaveMp3" disabled style="flex:1; background:var(--bg-card); border:1px solid var(--accent); color:var(--accent);">
275 <i class="bi bi-file-earmark-music me-1"></i>MP3
276 </button>
277 </div>
278 <p class="hint">재생 없이 즉시 렌더링하여 저장합니다. MP3는 변환에 수 초 걸릴 수 있어요.</p>
279 </div>
280
281 </div>
282
283 <!-- 메인 영역 -->
284 <div class="main-area" id="mainArea">
285
286 <!-- 빈 상태 -->
287 <div class="status-empty" id="statusEmpty">
288 <i class="bi bi-music-note-list"></i>
289 <div>
290 <div style="font-size:1rem; font-weight:700; margin-bottom:6px;">오디오 파일을 불러오세요</div>
291 <div style="font-size:0.82rem;">MP3, WAV 등 오디오 파일을 업로드하면<br>에코 효과를 실시간으로 적용할 수 있어요.</div>
292 </div>
293 </div>
294
295 <!-- 로드 후 표시 -->
296 <div id="playerArea" style="display:none; display:flex; flex-direction:column; gap:20px;">
297
298 <!-- 비주얼라이저 -->
299 <div class="viz-card">
300 <div class="viz-title"><i class="bi bi-soundwave me-1"></i>실시간 파형</div>
301 <canvas id="vizCanvas"></canvas>
302 </div>
303
304 <!-- 플레이어 -->
305 <div class="player-card">
306 <div class="player-meta">
307 <div class="player-title" id="playerTitle">—</div>
308 <div class="player-sub" id="playerSub">오디오 파일</div>
309 </div>
310 <div class="progress-bar-wrap" id="progressWrap">
311 <div class="progress-bar-fill" id="progressFill"></div>
312 </div>
313 <div class="time-row">
314 <span id="timeCurrent">0:00</span>
315 <span id="timeDuration">0:00</span>
316 </div>
317 <div class="player-controls">
318 <button class="ctrl-btn" id="btnRewind" title="처음으로"><i class="bi bi-skip-start-fill"></i></button>
319 <button class="ctrl-btn play-btn" id="btnPlay"><i class="bi bi-play-fill"></i></button>
320 <button class="ctrl-btn" id="btnLoop" title="반복"><i class="bi bi-arrow-repeat"></i></button>
321 </div>
322 </div>
323
324 <!-- 다운로드 안내 -->
325 <div class="download-card">
326 <div class="panel-title" style="margin-bottom:8px;"><i class="bi bi-info-circle me-1"></i>사용 방법</div>
327 <ol style="font-size:0.8rem; color:var(--text-secondary); line-height:2; padding-left:18px;">
328 <li>왼쪽 패널에서 에코 설정을 조정하세요</li>
329 <li>재생 버튼을 눌러 효과를 미리 들어보세요</li>
330 <li><strong style="color:var(--text-primary);">녹음 저장</strong> 버튼을 누르면 자동으로 재생 및 녹음이 시작됩니다</li>
331 <li>재생이 끝나면 자동으로 WAV 파일로 다운로드됩니다</li>
332 </ol>
333 </div>
334
335 </div>
336
337 </div>
338 </div>
339
340 <script>
341 // ── 상태 ──────────────────────────────────────────────────────
342 let audioCtx = null;
343 let audioBuffer = null;
344 let sourceNode = null;
345 let isPlaying = false;
346 let isLooping = false;
347 let startTime = 0;
348 let pauseOffset = 0;
349 let animFrameId = null;
350
351 // 노드들
352 let dryGain, wetGain, delayNode, feedbackGain, analyser, dest;
353
354 // 설정값 (딜레이는 초 단위)
355 let cfg = { delay: 0.08, feedback: 0.35, wet: 0.40, dry: 1.0 };
356
357 // 녹음
358 let mediaRecorder = null;
359 let recChunks = [];
360 let isRecording = false;
361
362 // ── DOM ───────────────────────────────────────────────────────
363 const uploadZone = document.getElementById('uploadZone');
364 const fileInput = document.getElementById('fileInput');
365 const fileName = document.getElementById('fileName');
366 const statusEmpty = document.getElementById('statusEmpty');
367 const playerArea = document.getElementById('playerArea');
368 const btnPlay = document.getElementById('btnPlay');
369 const btnRewind = document.getElementById('btnRewind');
370 const btnLoop = document.getElementById('btnLoop');
371 const btnSaveWav = document.getElementById('btnSaveWav');
372 const btnSaveMp3 = document.getElementById('btnSaveMp3');
373 const progressFill = document.getElementById('progressFill');
374 const progressWrap = document.getElementById('progressWrap');
375 const timeCurrent = document.getElementById('timeCurrent');
376 const timeDuration = document.getElementById('timeDuration');
377 const playerTitle = document.getElementById('playerTitle');
378 const recIndicator = document.getElementById('recIndicator');
379 const vizCanvas = document.getElementById('vizCanvas');
380 const vizCtx = vizCanvas.getContext('2d');
381
382 // ── 업로드 ───────────────────────────────────────────────────
383 uploadZone.addEventListener('click', () => fileInput.click());
384 uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
385 uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
386 uploadZone.addEventListener('drop', e => {
387 e.preventDefault(); uploadZone.classList.remove('dragover');
388 if (e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]);
389 });
390 fileInput.addEventListener('change', () => { if (fileInput.files[0]) loadFile(fileInput.files[0]); });
391
392 async function loadFile(file) {
393 stopPlayback();
394 if (audioCtx) { audioCtx.close(); audioCtx = null; }
395
396 playerTitle.textContent = file.name.replace(/\.[^/.]+$/, '');
397 fileName.textContent = file.name;
398 fileName.style.display = 'block';
399
400 const arrayBuffer = await file.arrayBuffer();
401 audioCtx = new (window.AudioContext || window.webkitAudioContext)();
402 audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
403
404 timeDuration.textContent = fmtTime(audioBuffer.duration);
405 statusEmpty.style.display = 'none';
406 playerArea.style.display = 'flex';
407 btnSaveWav.disabled = false;
408 btnSaveMp3.disabled = false;
409 pauseOffset = 0;
410 updateProgress(0);
411 buildGraph();
412 startViz();
413 }
414
415 // ── AudioContext 그래프 구성 ──────────────────────────────────
416 function buildGraph() {
417 if (!audioCtx) return;
418
419 // 분석기
420 analyser = audioCtx.createAnalyser();
421 analyser.fftSize = 1024;
422
423 // Dry 경로
424 dryGain = audioCtx.createGain();
425 dryGain.gain.value = cfg.dry;
426
427 // Wet/에코 경로 (다중 탭)
428 wetGain = audioCtx.createGain();
429 wetGain.gain.value = cfg.wet;
430
431 delayNode = audioCtx.createDelay(4.0);
432 delayNode.delayTime.value = cfg.delay;
433
434 feedbackGain = audioCtx.createGain();
435 feedbackGain.gain.value = cfg.feedback;
436
437 // 연결
438 // analyser → destination
439 analyser.connect(audioCtx.destination);
440 // dry: source → dryGain → analyser
441 dryGain.connect(analyser);
442 // wet: source → delay → wetGain → analyser
443 // ↑_feedbackGain_↓ (루프)
444 delayNode.connect(wetGain);
445 wetGain.connect(analyser);
446 delayNode.connect(feedbackGain);
447 feedbackGain.connect(delayNode);
448 }
449
450 function connectSource(src) {
451 src.connect(dryGain);
452 src.connect(delayNode);
453 }
454
455 // ── 재생 ─────────────────────────────────────────────────────
456 async function play(fromOffset) {
457 if (!audioBuffer || !audioCtx) return;
458 if (audioCtx.state === 'suspended') await audioCtx.resume();
459
460 stopSourceOnly();
461
462 sourceNode = audioCtx.createBufferSource();
463 sourceNode.buffer = audioBuffer;
464 sourceNode.loop = isLooping;
465 connectSource(sourceNode);
466
467 sourceNode.start(0, fromOffset);
468 startTime = audioCtx.currentTime - fromOffset;
469 isPlaying = true;
470 btnPlay.innerHTML = '<i class="bi bi-pause-fill"></i>';
471
472 sourceNode.onended = () => {
473 if (!isLooping) {
474 isPlaying = false;
475 pauseOffset = 0;
476 btnPlay.innerHTML = '<i class="bi bi-play-fill"></i>';
477 updateProgress(0);
478 timeCurrent.textContent = '0:00';
479 if (isRecording) stopRecording();
480 }
481 };
482 }
483
484 function pause() {
485 if (!isPlaying) return;
486 pauseOffset = audioCtx.currentTime - startTime;
487 stopSourceOnly();
488 isPlaying = false;
489 btnPlay.innerHTML = '<i class="bi bi-play-fill"></i>';
490 }
491
492 function stopSourceOnly() {
493 if (sourceNode) {
494 sourceNode.onended = null;
495 try { sourceNode.stop(); } catch(e) {}
496 sourceNode = null;
497 }
498 }
499
500 function stopPlayback() {
501 stopSourceOnly();
502 isPlaying = false;
503 pauseOffset = 0;
504 if (btnPlay) btnPlay.innerHTML = '<i class="bi bi-play-fill"></i>';
505 }
506
507 btnPlay.addEventListener('click', () => {
508 if (!audioBuffer) return;
509 isPlaying ? pause() : play(pauseOffset);
510 });
511
512 btnRewind.addEventListener('click', () => {
513 pauseOffset = 0;
514 if (isPlaying) play(0);
515 else updateProgress(0);
516 timeCurrent.textContent = '0:00';
517 });
518
519 btnLoop.addEventListener('click', () => {
520 isLooping = !isLooping;
521 btnLoop.classList.toggle('active', isLooping);
522 if (sourceNode) sourceNode.loop = isLooping;
523 });
524
525 // ── 진행바 ───────────────────────────────────────────────────
526 progressWrap.addEventListener('click', e => {
527 if (!audioBuffer) return;
528 const ratio = e.offsetX / progressWrap.offsetWidth;
529 pauseOffset = ratio * audioBuffer.duration;
530 if (isPlaying) play(pauseOffset);
531 else { updateProgress(ratio); timeCurrent.textContent = fmtTime(pauseOffset); }
532 });
533
534 function updateProgress(ratio) {
535 progressFill.style.width = (ratio * 100) + '%';
536 }
537
538 function fmtTime(s) {
539 const m = Math.floor(s / 60), sec = Math.floor(s % 60);
540 return m + ':' + String(sec).padStart(2, '0');
541 }
542
543 // ── 비주얼라이저 ─────────────────────────────────────────────
544 function startViz() {
545 if (animFrameId) cancelAnimationFrame(animFrameId);
546 drawViz();
547 }
548
549 function drawViz() {
550 animFrameId = requestAnimationFrame(drawViz);
551 if (!analyser) { clearCanvas(); return; }
552
553 const W = vizCanvas.width = vizCanvas.offsetWidth * window.devicePixelRatio;
554 const H = vizCanvas.height = vizCanvas.offsetHeight * window.devicePixelRatio;
555 vizCtx.clearRect(0, 0, W, H);
556
557 if (!isPlaying) { clearCanvas(); return; }
558
559 const data = new Uint8Array(analyser.frequencyBinCount);
560 analyser.getByteFrequencyData(data);
561
562 const barW = W / data.length * 2.5;
563 let x = 0;
564 for (let i = 0; i < data.length; i++) {
565 const v = data[i] / 255;
566 const h = v * H;
567 const r = Math.round(197 + (v * 40));
568 const g = Math.round(160 - (v * 60));
569 const b = Math.round(89 - (v * 40));
570 vizCtx.fillStyle = `rgba(${r},${g},${b},${0.7 + v * 0.3})`;
571 vizCtx.fillRect(x, H - h, barW - 1, h);
572 x += barW;
573 }
574
575 // 진행 시간 업데이트
576 if (isPlaying && audioCtx) {
577 const cur = audioCtx.currentTime - startTime;
578 const dur = audioBuffer ? audioBuffer.duration : 0;
579 if (cur <= dur) {
580 timeCurrent.textContent = fmtTime(cur);
581 updateProgress(cur / dur);
582 }
583 }
584 }
585
586 function clearCanvas() {
587 const W = vizCanvas.width = vizCanvas.offsetWidth * window.devicePixelRatio;
588 const H = vizCanvas.height = vizCanvas.offsetHeight * window.devicePixelRatio;
589 vizCtx.fillStyle = '#0a0f1a';
590 vizCtx.fillRect(0, 0, W, H);
591 // 빈 선 그리기
592 vizCtx.strokeStyle = 'rgba(197,160,89,0.15)';
593 vizCtx.lineWidth = 1;
594 vizCtx.beginPath();
595 vizCtx.moveTo(0, H / 2);
596 vizCtx.lineTo(W, H / 2);
597 vizCtx.stroke();
598 }
599
600 // ── 슬라이더 ─────────────────────────────────────────────────
601 // 딜레이: 슬라이더는 ms 단위, cfg는 초 단위
602 document.getElementById('slDelay').addEventListener('input', function() {
603 cfg.delay = parseInt(this.value) / 1000;
604 document.getElementById('valDelay').textContent = this.value + 'ms';
605 if (delayNode) delayNode.delayTime.setTargetAtTime(cfg.delay, audioCtx.currentTime, 0.01);
606 });
607 document.getElementById('slFeedback').addEventListener('input', function() {
608 cfg.feedback = parseInt(this.value) / 100;
609 document.getElementById('valFeedback').textContent = this.value + '%';
610 if (feedbackGain) feedbackGain.gain.setTargetAtTime(cfg.feedback, audioCtx.currentTime, 0.01);
611 });
612 document.getElementById('slWet').addEventListener('input', function() {
613 cfg.wet = parseInt(this.value) / 100;
614 document.getElementById('valWet').textContent = this.value + '%';
615 if (wetGain) wetGain.gain.setTargetAtTime(cfg.wet, audioCtx.currentTime, 0.01);
616 });
617 document.getElementById('slDry').addEventListener('input', function() {
618 cfg.dry = parseInt(this.value) / 100;
619 document.getElementById('valDry').textContent = this.value + '%';
620 if (dryGain) dryGain.gain.setTargetAtTime(cfg.dry, audioCtx.currentTime, 0.01);
621 });
622
623 // ── 프리셋 (딜레이는 ms 단위로 저장) ────────────────────────
624 const PRESETS = {
625 room: { delayMs: 80, feedback: 32, wet: 38, dry: 100 },
626 hall: { delayMs: 180, feedback: 50, wet: 55, dry: 90 },
627 cave: { delayMs: 260, feedback: 65, wet: 65, dry: 85 },
628 phone: { delayMs: 30, feedback: 12, wet: 25, dry: 100 },
629 stadium: { delayMs: 320, feedback: 70, wet: 60, dry: 80 },
630 none: { delayMs: 10, feedback: 0, wet: 0, dry: 100 },
631 };
632
633 document.querySelectorAll('.preset-btn').forEach(btn => {
634 btn.addEventListener('click', () => {
635 document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
636 btn.classList.add('active');
637 const p = PRESETS[btn.dataset.preset];
638 cfg.delay = p.delayMs / 1000;
639 cfg.feedback = p.feedback / 100;
640 cfg.wet = p.wet / 100;
641 cfg.dry = p.dry / 100;
642 applyPresetUI(p);
643 if (audioCtx) {
644 if (delayNode) delayNode.delayTime.setTargetAtTime(cfg.delay, audioCtx.currentTime, 0.02);
645 if (feedbackGain) feedbackGain.gain.setTargetAtTime(cfg.feedback, audioCtx.currentTime, 0.02);
646 if (wetGain) wetGain.gain.setTargetAtTime(cfg.wet, audioCtx.currentTime, 0.02);
647 if (dryGain) dryGain.gain.setTargetAtTime(cfg.dry, audioCtx.currentTime, 0.02);
648 }
649 });
650 });
651
652 function applyPresetUI(p) {
653 document.getElementById('slDelay').value = p.delayMs;
654 document.getElementById('slFeedback').value = p.feedback;
655 document.getElementById('slWet').value = p.wet;
656 document.getElementById('slDry').value = p.dry;
657 document.getElementById('valDelay').textContent = p.delayMs + 'ms';
658 document.getElementById('valFeedback').textContent = p.feedback + '%';
659 document.getElementById('valWet').textContent = p.wet + '%';
660 document.getElementById('valDry').textContent = p.dry + '%';
661 }
662
663 // ── 오프라인 렌더링 공통 ──────────────────────────────────────
664 async function renderOffline() {
665 const tailSec = Math.max(1.5, cfg.delay * cfg.feedback * 12);
666 const totalDuration = audioBuffer.duration + tailSec;
667
668 const offCtx = new OfflineAudioContext(
669 audioBuffer.numberOfChannels,
670 Math.ceil(totalDuration * audioBuffer.sampleRate),
671 audioBuffer.sampleRate
672 );
673
674 const offDry = offCtx.createGain(); offDry.gain.value = cfg.dry;
675 const offWet = offCtx.createGain(); offWet.gain.value = cfg.wet;
676 const offDelay = offCtx.createDelay(4.0); offDelay.delayTime.value = cfg.delay;
677 const offFeedback = offCtx.createGain(); offFeedback.gain.value = cfg.feedback;
678
679 offDry.connect(offCtx.destination);
680 offDelay.connect(offWet);
681 offWet.connect(offCtx.destination);
682 offDelay.connect(offFeedback);
683 offFeedback.connect(offDelay);
684
685 const src = offCtx.createBufferSource();
686 src.buffer = audioBuffer;
687 src.connect(offDry);
688 src.connect(offDelay);
689 src.start(0);
690
691 return await offCtx.startRendering();
692 }
693
694 function setSavingState(saving) {
695 isRecording = saving;
696 recIndicator.classList.toggle('active', saving);
697 btnSaveWav.disabled = saving;
698 btnSaveMp3.disabled = saving;
699 if (!saving) {
700 btnSaveWav.innerHTML = '<i class="bi bi-file-earmark-music me-1"></i>WAV';
701 btnSaveMp3.innerHTML = '<i class="bi bi-file-earmark-music me-1"></i>MP3';
702 }
703 }
704
705 // WAV 저장
706 btnSaveWav.addEventListener('click', async () => {
707 if (!audioBuffer || isRecording) return;
708 setSavingState(true);
709 btnSaveWav.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>렌더링 중...';
710
711 const rendered = await renderOffline();
712 const blob = audioBufferToWav(rendered);
713 downloadBlob(blob, (playerTitle.textContent || 'echo_output') + '_echo.wav');
714 setSavingState(false);
715 });
716
717 // MP3 저장
718 btnSaveMp3.addEventListener('click', async () => {
719 if (!audioBuffer || isRecording) return;
720 if (typeof lamejs === 'undefined') {
721 showAlert('MP3 인코더를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.'); return;
722 }
723 setSavingState(true);
724 btnSaveMp3.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>인코딩 중...';
725
726 const rendered = await renderOffline();
727 const blob = audioBufferToMp3(rendered);
728 downloadBlob(blob, (playerTitle.textContent || 'echo_output') + '_echo.mp3');
729 setSavingState(false);
730 });
731
732 function downloadBlob(blob, filename) {
733 const url = URL.createObjectURL(blob);
734 const a = document.createElement('a');
735 a.href = url; a.download = filename; a.click();
736 URL.revokeObjectURL(url);
737 }
738
739 // ── MP3 인코딩 (lamejs) ───────────────────────────────────────
740 function audioBufferToMp3(buffer) {
741 const numCh = Math.min(buffer.numberOfChannels, 2);
742 const sampleRate = buffer.sampleRate;
743 const kbps = 256;
744 const mp3enc = new lamejs.Mp3Encoder(numCh, sampleRate, kbps);
745 const blockSize = 1152;
746 const chunks = [];
747
748 const left = pcmFloat32ToInt16(buffer.getChannelData(0));
749 const right = numCh > 1 ? pcmFloat32ToInt16(buffer.getChannelData(1)) : left;
750
751 for (let i = 0; i < left.length; i += blockSize) {
752 const L = left.subarray(i, i + blockSize);
753 const R = right.subarray(i, i + blockSize);
754 const buf = numCh === 2 ? mp3enc.encodeBuffer(L, R) : mp3enc.encodeBuffer(L);
755 if (buf.length > 0) chunks.push(buf);
756 }
757 const end = mp3enc.flush();
758 if (end.length > 0) chunks.push(end);
759 return new Blob(chunks, { type: 'audio/mp3' });
760 }
761
762 function pcmFloat32ToInt16(float32) {
763 const int16 = new Int16Array(float32.length);
764 for (let i = 0; i < float32.length; i++) {
765 const s = Math.max(-1, Math.min(1, float32[i]));
766 int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
767 }
768 return int16;
769 }
770
771 // ── WAV 인코딩 ────────────────────────────────────────────────
772 function audioBufferToWav(buffer) {
773 const numCh = buffer.numberOfChannels;
774 const sampleRate = buffer.sampleRate;
775 const numFrames = buffer.length;
776 const bytesPerSample = 2;
777 const dataSize = numFrames * numCh * bytesPerSample;
778 const wavBuffer = new ArrayBuffer(44 + dataSize);
779 const view = new DataView(wavBuffer);
780
781 function writeStr(off, str) { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)); }
782 writeStr(0, 'RIFF');
783 view.setUint32(4, 36 + dataSize, true);
784 writeStr(8, 'WAVE');
785 writeStr(12, 'fmt ');
786 view.setUint32(16, 16, true);
787 view.setUint16(20, 1, true);
788 view.setUint16(22, numCh, true);
789 view.setUint32(24, sampleRate, true);
790 view.setUint32(28, sampleRate * numCh * bytesPerSample, true);
791 view.setUint16(32, numCh * bytesPerSample, true);
792 view.setUint16(34, 16, true);
793 writeStr(36, 'data');
794 view.setUint32(40, dataSize, true);
795
796 let offset = 44;
797 for (let i = 0; i < numFrames; i++) {
798 for (let ch = 0; ch < numCh; ch++) {
799 const s = Math.max(-1, Math.min(1, buffer.getChannelData(ch)[i]));
800 view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
801 offset += 2;
802 }
803 }
804 return new Blob([wavBuffer], { type: 'audio/wav' });
805 }
806 </script>
807
808 <footer class="site-footer">
809 <div class="footer-brand">
810 <img src="/image/sign.png" alt="비나래" class="footer-logo">
811 <div>
812 <div class="footer-title">비나래 라운지</div>
813 <div>Archive My Lounge</div>
814 </div>
815 </div>
816 <div>
817 <div class="footer-links">
818 <a href="/hinana/index">Archive</a>
819 <a href="/hinana/lounge">Lounge</a>
820 <a href="/hinana/gallery">Gallery</a>
821 <a href="/hinana/plaza">Plaza</a>
822 <a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a>
823 </div>
824 <div class="footer-copy"><span class="footer-version">Ver. 9.2.1.0 - Iochi Mari</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
825 </div>
826 </footer>
827 </body>
828 </html>
829