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