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