Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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