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: #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