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