@jadujoel/web-audio-clip-node 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -32
- package/dist/audio/ClipNode.d.ts +2 -0
- package/dist/audio/ClipNode.js +6 -1
- package/dist/audio/processor-code.d.ts +1 -1
- package/dist/audio/processor-code.js +1 -1
- package/dist/audio/processor-kernel.js +3 -0
- package/dist/audio/processor.js +9 -0
- package/dist/audio/version.d.ts +1 -1
- package/dist/audio/version.js +1 -1
- package/dist/lib.bundle.js +3 -3
- package/dist/lib.bundle.js.map +3 -3
- package/dist/processor.js +2 -2
- package/dist/processor.js.map +4 -4
- package/examples/README.md +12 -4
- package/examples/cdn-vanilla/README.md +10 -6
- package/examples/cdn-vanilla/index.html +1065 -33
- package/examples/index.html +1 -0
- package/examples/self-hosted/public/processor.js +2 -2
- package/examples/streaming/README.md +25 -0
- package/examples/streaming/build-worker.ts +21 -0
- package/examples/streaming/decode-worker.ts +308 -0
- package/examples/streaming/index.html +211 -0
- package/examples/streaming/main.ts +276 -0
- package/examples/streaming/package.json +12 -0
- package/package.json +1 -1
|
@@ -5,53 +5,1085 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>ClipNode – CDN Vanilla Example</title>
|
|
7
7
|
<style>
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
--bg: #07111f;
|
|
11
|
+
--bg-elevated: rgba(8, 22, 39, 0.78);
|
|
12
|
+
--surface: rgba(19, 38, 63, 0.92);
|
|
13
|
+
--surface-strong: rgba(29, 55, 88, 0.98);
|
|
14
|
+
--border: rgba(144, 190, 255, 0.16);
|
|
15
|
+
--text: #eff6ff;
|
|
16
|
+
--muted: #a9bdd6;
|
|
17
|
+
--accent: #7dd3fc;
|
|
18
|
+
--accent-strong: #38bdf8;
|
|
19
|
+
--accent-warm: #f59e0b;
|
|
20
|
+
--danger: #fb7185;
|
|
21
|
+
--shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
|
|
22
|
+
--radius-xl: 28px;
|
|
23
|
+
--radius-lg: 18px;
|
|
24
|
+
--radius-md: 12px;
|
|
25
|
+
--mono: "SFMono-Regular", "Consolas", monospace;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
|
30
|
+
color: var(--text);
|
|
31
|
+
background:
|
|
32
|
+
radial-gradient(circle at top left, rgba(56, 189, 248, 0.2), transparent 38%),
|
|
33
|
+
radial-gradient(circle at bottom right, rgba(245, 158, 11, 0.18), transparent 34%),
|
|
34
|
+
linear-gradient(180deg, #06101d 0%, #081625 46%, #030712 100%);
|
|
35
|
+
margin: 0;
|
|
36
|
+
min-height: 100vh;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
body::before {
|
|
40
|
+
content: "";
|
|
41
|
+
position: fixed;
|
|
42
|
+
inset: 0;
|
|
43
|
+
background-image:
|
|
44
|
+
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
|
45
|
+
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
|
46
|
+
background-size: 26px 26px;
|
|
47
|
+
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
|
48
|
+
pointer-events: none;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.shell {
|
|
52
|
+
width: min(1080px, calc(100vw - 32px));
|
|
53
|
+
margin: 32px auto;
|
|
54
|
+
padding: 28px;
|
|
55
|
+
border: 1px solid var(--border);
|
|
56
|
+
border-radius: var(--radius-xl);
|
|
57
|
+
background: var(--bg-elevated);
|
|
58
|
+
backdrop-filter: blur(18px);
|
|
59
|
+
box-shadow: var(--shadow);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.hero {
|
|
63
|
+
display: grid;
|
|
64
|
+
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.85fr);
|
|
65
|
+
gap: 20px;
|
|
66
|
+
align-items: stretch;
|
|
67
|
+
margin-bottom: 20px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.hero-copy,
|
|
71
|
+
.hero-card,
|
|
72
|
+
.panel {
|
|
73
|
+
border: 1px solid var(--border);
|
|
74
|
+
border-radius: var(--radius-lg);
|
|
75
|
+
background: linear-gradient(180deg, rgba(21, 42, 68, 0.95), rgba(9, 22, 39, 0.9));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.hero-copy {
|
|
79
|
+
padding: 24px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.eyebrow {
|
|
83
|
+
display: inline-flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: 8px;
|
|
86
|
+
padding: 6px 10px;
|
|
87
|
+
border-radius: 999px;
|
|
88
|
+
background: rgba(125, 211, 252, 0.12);
|
|
89
|
+
color: var(--accent);
|
|
90
|
+
font-size: 0.78rem;
|
|
91
|
+
letter-spacing: 0.08em;
|
|
92
|
+
text-transform: uppercase;
|
|
93
|
+
font-weight: 700;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
h1 {
|
|
97
|
+
margin: 16px 0 10px;
|
|
98
|
+
font-size: clamp(2.2rem, 4vw, 3.8rem);
|
|
99
|
+
line-height: 0.95;
|
|
100
|
+
letter-spacing: -0.04em;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.hero-copy p {
|
|
104
|
+
margin: 0;
|
|
105
|
+
max-width: 60ch;
|
|
106
|
+
color: var(--muted);
|
|
107
|
+
font-size: 1rem;
|
|
108
|
+
line-height: 1.65;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.hero-notes {
|
|
112
|
+
margin-top: 18px;
|
|
113
|
+
display: flex;
|
|
114
|
+
flex-wrap: wrap;
|
|
115
|
+
gap: 10px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.hero-note {
|
|
119
|
+
padding: 10px 12px;
|
|
120
|
+
border-radius: 999px;
|
|
121
|
+
background: rgba(255, 255, 255, 0.04);
|
|
122
|
+
color: var(--text);
|
|
123
|
+
font-size: 0.88rem;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.hero-card {
|
|
127
|
+
padding: 24px;
|
|
128
|
+
position: relative;
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.hero-card::before,
|
|
133
|
+
.hero-card::after {
|
|
134
|
+
content: "";
|
|
135
|
+
position: absolute;
|
|
136
|
+
inset: auto auto 18px 18px;
|
|
137
|
+
width: 180px;
|
|
138
|
+
height: 180px;
|
|
139
|
+
border-radius: 50%;
|
|
140
|
+
background: radial-gradient(circle, rgba(125, 211, 252, 0.28), transparent 70%);
|
|
141
|
+
filter: blur(10px);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.hero-card::after {
|
|
145
|
+
inset: 22px 14px auto auto;
|
|
146
|
+
width: 110px;
|
|
147
|
+
height: 110px;
|
|
148
|
+
background: radial-gradient(circle, rgba(245, 158, 11, 0.26), transparent 70%);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.wave {
|
|
152
|
+
position: relative;
|
|
153
|
+
height: 120px;
|
|
154
|
+
margin-bottom: 18px;
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 6px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.wave span {
|
|
161
|
+
flex: 1;
|
|
162
|
+
height: var(--h);
|
|
163
|
+
border-radius: 999px;
|
|
164
|
+
background: linear-gradient(180deg, rgba(125, 211, 252, 0.95), rgba(56, 189, 248, 0.16));
|
|
165
|
+
box-shadow: 0 0 18px rgba(56, 189, 248, 0.18);
|
|
166
|
+
animation: pulse 1.8s ease-in-out infinite;
|
|
167
|
+
animation-delay: var(--delay);
|
|
168
|
+
transform-origin: center;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@keyframes pulse {
|
|
172
|
+
0%,
|
|
173
|
+
100% { transform: scaleY(0.82); }
|
|
174
|
+
50% { transform: scaleY(1.08); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.hero-card h2 {
|
|
178
|
+
margin: 0 0 8px;
|
|
179
|
+
font-size: 1rem;
|
|
180
|
+
letter-spacing: 0.08em;
|
|
181
|
+
text-transform: uppercase;
|
|
182
|
+
color: var(--accent);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.hero-card p {
|
|
186
|
+
margin: 0;
|
|
187
|
+
color: var(--muted);
|
|
188
|
+
line-height: 1.6;
|
|
189
|
+
max-width: 28ch;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.layout {
|
|
193
|
+
display: grid;
|
|
194
|
+
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
|
195
|
+
gap: 20px;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.panel {
|
|
199
|
+
padding: 20px;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.panel h2 {
|
|
203
|
+
margin: 0 0 14px;
|
|
204
|
+
font-size: 0.96rem;
|
|
205
|
+
letter-spacing: 0.08em;
|
|
206
|
+
text-transform: uppercase;
|
|
207
|
+
color: var(--muted);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.transport-row {
|
|
211
|
+
display: flex;
|
|
212
|
+
flex-wrap: wrap;
|
|
213
|
+
gap: 10px;
|
|
214
|
+
align-items: center;
|
|
215
|
+
margin-bottom: 14px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
button {
|
|
219
|
+
appearance: none;
|
|
220
|
+
border: 1px solid transparent;
|
|
221
|
+
border-radius: 999px;
|
|
222
|
+
padding: 12px 18px;
|
|
223
|
+
font: inherit;
|
|
224
|
+
font-weight: 700;
|
|
225
|
+
color: var(--text);
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
transition:
|
|
228
|
+
transform 0.16s ease,
|
|
229
|
+
border-color 0.16s ease,
|
|
230
|
+
background 0.16s ease,
|
|
231
|
+
opacity 0.16s ease;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
button:hover {
|
|
235
|
+
transform: translateY(-1px);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
button:disabled {
|
|
239
|
+
opacity: 0.45;
|
|
240
|
+
cursor: not-allowed;
|
|
241
|
+
transform: none;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.btn-primary {
|
|
245
|
+
background: linear-gradient(135deg, var(--accent-strong), #0ea5e9);
|
|
246
|
+
box-shadow: 0 10px 24px rgba(14, 165, 233, 0.28);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.btn-secondary {
|
|
250
|
+
background: rgba(255, 255, 255, 0.05);
|
|
251
|
+
border-color: rgba(255, 255, 255, 0.08);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.btn-danger {
|
|
255
|
+
background: rgba(251, 113, 133, 0.12);
|
|
256
|
+
border-color: rgba(251, 113, 133, 0.24);
|
|
257
|
+
color: #fecdd3;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.toggle {
|
|
261
|
+
margin-left: auto;
|
|
262
|
+
display: inline-flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
gap: 10px;
|
|
265
|
+
font-size: 0.95rem;
|
|
266
|
+
color: var(--muted);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.toggle input {
|
|
270
|
+
width: 18px;
|
|
271
|
+
height: 18px;
|
|
272
|
+
accent-color: var(--accent-strong);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.status {
|
|
276
|
+
margin: 0;
|
|
277
|
+
padding: 12px 14px;
|
|
278
|
+
border-radius: var(--radius-md);
|
|
279
|
+
background: rgba(255, 255, 255, 0.045);
|
|
280
|
+
color: var(--text);
|
|
281
|
+
line-height: 1.6;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.stats {
|
|
285
|
+
display: grid;
|
|
286
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
287
|
+
gap: 10px;
|
|
288
|
+
margin-top: 14px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.stat {
|
|
292
|
+
padding: 14px;
|
|
293
|
+
border-radius: var(--radius-md);
|
|
294
|
+
background: rgba(255, 255, 255, 0.045);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.stat-label {
|
|
298
|
+
display: block;
|
|
299
|
+
margin-bottom: 6px;
|
|
300
|
+
color: var(--muted);
|
|
301
|
+
font-size: 0.78rem;
|
|
302
|
+
letter-spacing: 0.06em;
|
|
303
|
+
text-transform: uppercase;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.stat-value {
|
|
307
|
+
font-family: var(--mono);
|
|
308
|
+
font-size: 1.02rem;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.controls {
|
|
312
|
+
display: grid;
|
|
313
|
+
gap: 14px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.control {
|
|
317
|
+
padding: 14px;
|
|
318
|
+
border-radius: var(--radius-md);
|
|
319
|
+
background: rgba(255, 255, 255, 0.045);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.control header {
|
|
323
|
+
display: flex;
|
|
324
|
+
justify-content: space-between;
|
|
325
|
+
gap: 12px;
|
|
326
|
+
align-items: baseline;
|
|
327
|
+
margin-bottom: 10px;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.control strong {
|
|
331
|
+
font-size: 0.95rem;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.control output {
|
|
335
|
+
font-family: var(--mono);
|
|
336
|
+
color: var(--accent);
|
|
337
|
+
font-size: 0.92rem;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
input[type="range"] {
|
|
341
|
+
width: 100%;
|
|
342
|
+
accent-color: var(--accent-warm);
|
|
343
|
+
cursor: pointer;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.code-note {
|
|
347
|
+
margin-top: 18px;
|
|
348
|
+
padding: 14px;
|
|
349
|
+
border-radius: var(--radius-md);
|
|
350
|
+
background: rgba(3, 7, 18, 0.56);
|
|
351
|
+
color: var(--muted);
|
|
352
|
+
font-size: 0.92rem;
|
|
353
|
+
line-height: 1.6;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.code-note code {
|
|
357
|
+
font-family: var(--mono);
|
|
358
|
+
color: var(--text);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
@media (max-width: 860px) {
|
|
362
|
+
.hero,
|
|
363
|
+
.layout {
|
|
364
|
+
grid-template-columns: 1fr;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.toggle {
|
|
368
|
+
margin-left: 0;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
@media (max-width: 640px) {
|
|
373
|
+
.shell {
|
|
374
|
+
width: min(100vw - 20px, 100%);
|
|
375
|
+
margin: 10px auto;
|
|
376
|
+
padding: 16px;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
h1 {
|
|
380
|
+
font-size: 2.2rem;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.hero-copy,
|
|
384
|
+
.hero-card,
|
|
385
|
+
.panel {
|
|
386
|
+
padding: 16px;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.stats {
|
|
390
|
+
grid-template-columns: 1fr;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
10
393
|
</style>
|
|
11
394
|
</head>
|
|
12
395
|
<body>
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
396
|
+
<div class="shell">
|
|
397
|
+
<section class="hero">
|
|
398
|
+
<div class="hero-copy">
|
|
399
|
+
<div class="eyebrow">No bundler • AudioWorklet • CDN</div>
|
|
400
|
+
<h1>ClipNode in one HTML file.</h1>
|
|
401
|
+
<p>
|
|
402
|
+
This demo loads the package from jsDelivr, boots the worklet from the CDN,
|
|
403
|
+
synthesizes its own stereo clip, and foregrounds the things
|
|
404
|
+
<code>AudioBufferSourceNode</code> does not give you: pause/resume, reusable start,
|
|
405
|
+
buffer hot-swap, loop callbacks, loop crossfade,
|
|
406
|
+
real-time playhead control, and sample-accurate fades.
|
|
407
|
+
</p>
|
|
408
|
+
<div class="hero-notes">
|
|
409
|
+
<span class="hero-note">Pause / Resume</span>
|
|
410
|
+
<span class="hero-note">Reusable start()</span>
|
|
411
|
+
<span class="hero-note">Buffer hot-swap</span>
|
|
412
|
+
<span class="hero-note">Loop callback counter</span>
|
|
413
|
+
<span class="hero-note">Crossfaded loop points</span>
|
|
414
|
+
<span class="hero-note">Live sample playhead seek</span>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
<aside class="hero-card" aria-hidden="true">
|
|
418
|
+
<div class="wave">
|
|
419
|
+
<span style="--h: 24%; --delay: 0.05s"></span>
|
|
420
|
+
<span style="--h: 62%; --delay: 0.18s"></span>
|
|
421
|
+
<span style="--h: 38%; --delay: 0.1s"></span>
|
|
422
|
+
<span style="--h: 78%; --delay: 0.24s"></span>
|
|
423
|
+
<span style="--h: 52%; --delay: 0.08s"></span>
|
|
424
|
+
<span style="--h: 88%; --delay: 0.14s"></span>
|
|
425
|
+
<span style="--h: 40%; --delay: 0.22s"></span>
|
|
426
|
+
<span style="--h: 70%; --delay: 0.16s"></span>
|
|
427
|
+
<span style="--h: 34%; --delay: 0.02s"></span>
|
|
428
|
+
<span style="--h: 58%; --delay: 0.12s"></span>
|
|
429
|
+
<span style="--h: 81%; --delay: 0.2s"></span>
|
|
430
|
+
<span style="--h: 30%; --delay: 0.07s"></span>
|
|
431
|
+
</div>
|
|
432
|
+
<h2>What you are hearing</h2>
|
|
433
|
+
<p>
|
|
434
|
+
A short synthetic groove with bass, hats, and melodic stabs rendered into an
|
|
435
|
+
<code>AudioBuffer</code>, then played through <code>ClipNode</code> with loop telemetry and direct
|
|
436
|
+
playhead control.
|
|
437
|
+
</p>
|
|
438
|
+
</aside>
|
|
439
|
+
</section>
|
|
440
|
+
|
|
441
|
+
<section class="layout">
|
|
442
|
+
<div class="panel">
|
|
443
|
+
<h2>Transport</h2>
|
|
444
|
+
<div class="transport-row">
|
|
445
|
+
<button id="play" class="btn-primary" type="button">Start</button>
|
|
446
|
+
<button id="pause" class="btn-secondary" type="button" disabled>Pause</button>
|
|
447
|
+
<button id="stop" class="btn-danger" type="button" disabled>Stop</button>
|
|
448
|
+
<button id="swapBuffer" class="btn-secondary" type="button" disabled>Swap Buffer</button>
|
|
449
|
+
<label class="toggle" for="loopEnabled">
|
|
450
|
+
<input id="loopEnabled" type="checkbox" checked />
|
|
451
|
+
Loop section
|
|
452
|
+
</label>
|
|
453
|
+
</div>
|
|
454
|
+
<p class="status" id="status">
|
|
455
|
+
Press Start to create the demo clip, load the worklet from jsDelivr, and begin playback.
|
|
456
|
+
</p>
|
|
457
|
+
<div class="stats">
|
|
458
|
+
<div class="stat">
|
|
459
|
+
<span class="stat-label">State</span>
|
|
460
|
+
<span class="stat-value" id="stateValue">initial</span>
|
|
461
|
+
</div>
|
|
462
|
+
<div class="stat">
|
|
463
|
+
<span class="stat-label">Playhead Seconds</span>
|
|
464
|
+
<span class="stat-value" id="playheadValue">0.00s</span>
|
|
465
|
+
</div>
|
|
466
|
+
<div class="stat">
|
|
467
|
+
<span class="stat-label">Playhead Sample</span>
|
|
468
|
+
<span class="stat-value" id="sampleValue">0</span>
|
|
469
|
+
</div>
|
|
470
|
+
<div class="stat">
|
|
471
|
+
<span class="stat-label">Buffer Duration</span>
|
|
472
|
+
<span class="stat-value" id="durationValue">6.00s</span>
|
|
473
|
+
</div>
|
|
474
|
+
<div class="stat">
|
|
475
|
+
<span class="stat-label">Loops Triggered</span>
|
|
476
|
+
<span class="stat-value" id="loopCountValue">0</span>
|
|
477
|
+
</div>
|
|
478
|
+
<div class="stat">
|
|
479
|
+
<span class="stat-label">Starts (reused node)</span>
|
|
480
|
+
<span class="stat-value" id="startCountValue">0</span>
|
|
481
|
+
</div>
|
|
482
|
+
<div class="stat">
|
|
483
|
+
<span class="stat-label">Buffer Swaps</span>
|
|
484
|
+
<span class="stat-value" id="swapCountValue">0</span>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
<div class="code-note">
|
|
488
|
+
Worklet module loaded via <code>getProcessorCdnUrl("0.1.6")</code>.
|
|
489
|
+
The same <code>ClipNode</code> handles pause/resume, repeated <code>start()</code> calls
|
|
490
|
+
without recreating the node, and live buffer replacement. Controls below update the
|
|
491
|
+
live instance directly, including <code>clip.playhead</code>, <code>clip.loopCrossfade</code>,
|
|
492
|
+
and <code>clip.onlooped</code>.
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<div class="panel">
|
|
497
|
+
<h2>Controls</h2>
|
|
498
|
+
<div class="controls">
|
|
499
|
+
<label class="control" for="rate">
|
|
500
|
+
<header>
|
|
501
|
+
<strong>Playback Rate</strong>
|
|
502
|
+
<output id="rateValue" for="rate">1.00x</output>
|
|
503
|
+
</header>
|
|
504
|
+
<input id="rate" type="range" min="0.25" max="2" step="0.01" value="1" />
|
|
505
|
+
</label>
|
|
506
|
+
|
|
507
|
+
<label class="control" for="detune">
|
|
508
|
+
<header>
|
|
509
|
+
<strong>Detune</strong>
|
|
510
|
+
<output id="detuneValue" for="detune">0 cents</output>
|
|
511
|
+
</header>
|
|
512
|
+
<input id="detune" type="range" min="-1200" max="1200" step="1" value="0" />
|
|
513
|
+
</label>
|
|
514
|
+
|
|
515
|
+
<label class="control" for="gain">
|
|
516
|
+
<header>
|
|
517
|
+
<strong>Gain</strong>
|
|
518
|
+
<output id="gainValue" for="gain">0.95</output>
|
|
519
|
+
</header>
|
|
520
|
+
<input id="gain" type="range" min="0" max="1.5" step="0.01" value="0.95" />
|
|
521
|
+
</label>
|
|
522
|
+
|
|
523
|
+
<label class="control" for="pan">
|
|
524
|
+
<header>
|
|
525
|
+
<strong>Pan</strong>
|
|
526
|
+
<output id="panValue" for="pan">0.00</output>
|
|
527
|
+
</header>
|
|
528
|
+
<input id="pan" type="range" min="-1" max="1" step="0.01" value="0" />
|
|
529
|
+
</label>
|
|
530
|
+
|
|
531
|
+
<label class="control" for="loopStart">
|
|
532
|
+
<header>
|
|
533
|
+
<strong>Loop Start</strong>
|
|
534
|
+
<output id="loopStartValue" for="loopStart">0.80s</output>
|
|
535
|
+
</header>
|
|
536
|
+
<input id="loopStart" type="range" min="0" max="5.8" step="0.01" value="0.8" />
|
|
537
|
+
</label>
|
|
538
|
+
|
|
539
|
+
<label class="control" for="loopEnd">
|
|
540
|
+
<header>
|
|
541
|
+
<strong>Loop End</strong>
|
|
542
|
+
<output id="loopEndValue" for="loopEnd">5.40s</output>
|
|
543
|
+
</header>
|
|
544
|
+
<input id="loopEnd" type="range" min="0.2" max="6" step="0.01" value="5.4" />
|
|
545
|
+
</label>
|
|
546
|
+
|
|
547
|
+
<label class="control" for="loopCrossfade">
|
|
548
|
+
<header>
|
|
549
|
+
<strong>Loop Crossfade</strong>
|
|
550
|
+
<output id="loopCrossfadeValue" for="loopCrossfade">0.04s</output>
|
|
551
|
+
</header>
|
|
552
|
+
<input id="loopCrossfade" type="range" min="0" max="0.2" step="0.002" value="0.04" />
|
|
553
|
+
</label>
|
|
554
|
+
|
|
555
|
+
<label class="control" for="fadeIn">
|
|
556
|
+
<header>
|
|
557
|
+
<strong>Fade In</strong>
|
|
558
|
+
<output id="fadeInValue" for="fadeIn">0.00s</output>
|
|
559
|
+
</header>
|
|
560
|
+
<input id="fadeIn" type="range" min="0" max="0.4" step="0.005" value="0" />
|
|
561
|
+
</label>
|
|
562
|
+
|
|
563
|
+
<label class="control" for="fadeOut">
|
|
564
|
+
<header>
|
|
565
|
+
<strong>Fade Out</strong>
|
|
566
|
+
<output id="fadeOutValue" for="fadeOut">0.08s</output>
|
|
567
|
+
</header>
|
|
568
|
+
<input id="fadeOut" type="range" min="0" max="0.4" step="0.005" value="0.08" />
|
|
569
|
+
</label>
|
|
570
|
+
|
|
571
|
+
<label class="control" for="playhead">
|
|
572
|
+
<header>
|
|
573
|
+
<strong>Playhead Seek</strong>
|
|
574
|
+
<output id="playheadControlValue" for="playhead">sample 0</output>
|
|
575
|
+
</header>
|
|
576
|
+
<input id="playhead" type="range" min="0" max="288000" step="1" value="0" />
|
|
577
|
+
</label>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
</section>
|
|
581
|
+
</div>
|
|
18
582
|
<script type="module">
|
|
19
|
-
|
|
20
|
-
|
|
583
|
+
import {
|
|
584
|
+
ClipNode,
|
|
585
|
+
getProcessorCdnUrl,
|
|
586
|
+
} from "https://cdn.jsdelivr.net/npm/@jadujoel/web-audio-clip-node@0.1.6/dist/lib.bundle.js";
|
|
587
|
+
|
|
588
|
+
const version = "0.1.6";
|
|
589
|
+
const ui = {
|
|
590
|
+
play: document.getElementById("play"),
|
|
591
|
+
pause: document.getElementById("pause"),
|
|
592
|
+
stop: document.getElementById("stop"),
|
|
593
|
+
swapBuffer: document.getElementById("swapBuffer"),
|
|
594
|
+
loopEnabled: document.getElementById("loopEnabled"),
|
|
595
|
+
rate: document.getElementById("rate"),
|
|
596
|
+
detune: document.getElementById("detune"),
|
|
597
|
+
gain: document.getElementById("gain"),
|
|
598
|
+
pan: document.getElementById("pan"),
|
|
599
|
+
loopStart: document.getElementById("loopStart"),
|
|
600
|
+
loopEnd: document.getElementById("loopEnd"),
|
|
601
|
+
loopCrossfade: document.getElementById("loopCrossfade"),
|
|
602
|
+
fadeIn: document.getElementById("fadeIn"),
|
|
603
|
+
fadeOut: document.getElementById("fadeOut"),
|
|
604
|
+
playhead: document.getElementById("playhead"),
|
|
605
|
+
rateValue: document.getElementById("rateValue"),
|
|
606
|
+
detuneValue: document.getElementById("detuneValue"),
|
|
607
|
+
gainValue: document.getElementById("gainValue"),
|
|
608
|
+
panValue: document.getElementById("panValue"),
|
|
609
|
+
loopStartValue: document.getElementById("loopStartValue"),
|
|
610
|
+
loopEndValue: document.getElementById("loopEndValue"),
|
|
611
|
+
loopCrossfadeValue: document.getElementById("loopCrossfadeValue"),
|
|
612
|
+
fadeInValue: document.getElementById("fadeInValue"),
|
|
613
|
+
fadeOutValue: document.getElementById("fadeOutValue"),
|
|
614
|
+
playheadControlValue: document.getElementById("playheadControlValue"),
|
|
615
|
+
status: document.getElementById("status"),
|
|
616
|
+
stateValue: document.getElementById("stateValue"),
|
|
617
|
+
playheadValue: document.getElementById("playheadValue"),
|
|
618
|
+
sampleValue: document.getElementById("sampleValue"),
|
|
619
|
+
durationValue: document.getElementById("durationValue"),
|
|
620
|
+
loopCountValue: document.getElementById("loopCountValue"),
|
|
621
|
+
startCountValue: document.getElementById("startCountValue"),
|
|
622
|
+
swapCountValue: document.getElementById("swapCountValue"),
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const state = {
|
|
626
|
+
ctx: null,
|
|
627
|
+
clip: null,
|
|
628
|
+
duration: 6,
|
|
629
|
+
loopCount: 0,
|
|
630
|
+
startCount: 0,
|
|
631
|
+
swapCount: 0,
|
|
632
|
+
nodeState: "initial",
|
|
633
|
+
isSeeking: false,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
function clamp(value, min, max) {
|
|
637
|
+
return Math.min(max, Math.max(min, value));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function formatSeconds(value) {
|
|
641
|
+
return `${Number(value).toFixed(2)}s`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function setStatus(message) {
|
|
645
|
+
ui.status.textContent = message;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function setNodeState(nextState) {
|
|
649
|
+
state.nodeState = nextState;
|
|
650
|
+
ui.stateValue.textContent = nextState;
|
|
651
|
+
|
|
652
|
+
const active = nextState === "started" || nextState === "scheduled" || nextState === "resumed";
|
|
653
|
+
const paused = nextState === "paused";
|
|
654
|
+
|
|
655
|
+
ui.pause.disabled = !state.clip || (!active && !paused);
|
|
656
|
+
ui.stop.disabled = !state.clip || (!active && !paused);
|
|
657
|
+
ui.swapBuffer.disabled = !state.clip;
|
|
658
|
+
ui.pause.textContent = paused ? "Resume" : "Pause";
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function syncOutputs() {
|
|
662
|
+
ui.rateValue.textContent = `${Number(ui.rate.value).toFixed(2)}x`;
|
|
663
|
+
ui.detuneValue.textContent = `${Math.round(Number(ui.detune.value))} cents`;
|
|
664
|
+
ui.gainValue.textContent = Number(ui.gain.value).toFixed(2);
|
|
665
|
+
ui.panValue.textContent = Number(ui.pan.value).toFixed(2);
|
|
666
|
+
ui.loopStartValue.textContent = formatSeconds(ui.loopStart.value);
|
|
667
|
+
ui.loopEndValue.textContent = formatSeconds(ui.loopEnd.value);
|
|
668
|
+
ui.loopCrossfadeValue.textContent = formatSeconds(ui.loopCrossfade.value);
|
|
669
|
+
ui.fadeInValue.textContent = formatSeconds(ui.fadeIn.value);
|
|
670
|
+
ui.fadeOutValue.textContent = formatSeconds(ui.fadeOut.value);
|
|
671
|
+
ui.playheadControlValue.textContent = `sample ${Math.round(Number(ui.playhead.value))}`;
|
|
672
|
+
ui.durationValue.textContent = formatSeconds(state.duration);
|
|
673
|
+
ui.loopCountValue.textContent = String(state.loopCount);
|
|
674
|
+
ui.startCountValue.textContent = String(state.startCount);
|
|
675
|
+
ui.swapCountValue.textContent = String(state.swapCount);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function normalizeLoopRange(changedKey) {
|
|
679
|
+
const minGap = 0.08;
|
|
680
|
+
let start = Number(ui.loopStart.value);
|
|
681
|
+
let end = Number(ui.loopEnd.value);
|
|
682
|
+
|
|
683
|
+
if (end - start < minGap) {
|
|
684
|
+
if (changedKey === "loopStart") {
|
|
685
|
+
end = clamp(start + minGap, minGap, state.duration);
|
|
686
|
+
ui.loopEnd.value = String(end);
|
|
687
|
+
} else {
|
|
688
|
+
start = clamp(end - minGap, 0, state.duration - minGap);
|
|
689
|
+
ui.loopStart.value = String(start);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function applyControls() {
|
|
695
|
+
if (!state.clip) return;
|
|
696
|
+
|
|
697
|
+
normalizeLoopRange();
|
|
698
|
+
|
|
699
|
+
state.clip.playbackRate.value = Number(ui.rate.value);
|
|
700
|
+
state.clip.detune.value = Number(ui.detune.value);
|
|
701
|
+
state.clip.gain.value = Number(ui.gain.value);
|
|
702
|
+
state.clip.pan.value = Number(ui.pan.value);
|
|
703
|
+
state.clip.loop = ui.loopEnabled.checked;
|
|
704
|
+
state.clip.loopStart = Number(ui.loopStart.value);
|
|
705
|
+
state.clip.loopEnd = Number(ui.loopEnd.value);
|
|
706
|
+
state.clip.loopCrossfade = Number(ui.loopCrossfade.value);
|
|
707
|
+
state.clip.fadeIn = Number(ui.fadeIn.value);
|
|
708
|
+
state.clip.fadeOut = Number(ui.fadeOut.value);
|
|
709
|
+
state.clip.toggleFadeIn(Number(ui.fadeIn.value) > 0);
|
|
710
|
+
state.clip.toggleFadeOut(Number(ui.fadeOut.value) > 0);
|
|
711
|
+
state.clip.toggleLoopStart(true);
|
|
712
|
+
state.clip.toggleLoopEnd(true);
|
|
713
|
+
state.clip.toggleLoopCrossfade(Number(ui.loopCrossfade.value) > 0);
|
|
714
|
+
|
|
715
|
+
if (state.isSeeking) {
|
|
716
|
+
state.clip.playhead = Number(ui.playhead.value);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function pseudoNoise(index, seed) {
|
|
721
|
+
const raw = Math.sin((index + 1) * 12.9898 + seed * 78.233) * 43758.5453123;
|
|
722
|
+
return (raw - Math.floor(raw)) * 2 - 1;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function writeTone(left, right, sampleRate, startTime, duration, options) {
|
|
726
|
+
const {
|
|
727
|
+
freq,
|
|
728
|
+
amp = 0.25,
|
|
729
|
+
pan = 0,
|
|
730
|
+
harmonics = [1, 0.45, 0.18],
|
|
731
|
+
attack = 0.004,
|
|
732
|
+
release = 0.12,
|
|
733
|
+
drift = 0,
|
|
734
|
+
} = options;
|
|
735
|
+
|
|
736
|
+
const startSample = Math.floor(startTime * sampleRate);
|
|
737
|
+
const sampleCount = Math.floor(duration * sampleRate);
|
|
738
|
+
const leftGain = Math.cos((pan + 1) * Math.PI * 0.25);
|
|
739
|
+
const rightGain = Math.sin((pan + 1) * Math.PI * 0.25);
|
|
740
|
+
|
|
741
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
742
|
+
const index = startSample + i;
|
|
743
|
+
if (index >= left.length) break;
|
|
744
|
+
|
|
745
|
+
const t = i / sampleRate;
|
|
746
|
+
const attackGain = attack > 0 ? clamp(t / attack, 0, 1) : 1;
|
|
747
|
+
const releaseGain = release > 0 ? clamp((duration - t) / release, 0, 1) : 1;
|
|
748
|
+
const envelope = Math.min(attackGain, releaseGain) * Math.exp(-t * 1.6);
|
|
749
|
+
|
|
750
|
+
let sample = 0;
|
|
751
|
+
for (let harmonic = 0; harmonic < harmonics.length; harmonic += 1) {
|
|
752
|
+
const harmonicFreq = freq * (harmonic + 1 + drift * t * 0.35);
|
|
753
|
+
sample += harmonics[harmonic] * Math.sin(2 * Math.PI * harmonicFreq * t);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
sample *= amp * envelope;
|
|
757
|
+
left[index] += sample * leftGain;
|
|
758
|
+
right[index] += sample * rightGain;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function writeNoise(left, right, sampleRate, startTime, duration, options) {
|
|
763
|
+
const { amp = 0.14, pan = 0, seed = 0, color = 0.45 } = options;
|
|
764
|
+
const startSample = Math.floor(startTime * sampleRate);
|
|
765
|
+
const sampleCount = Math.floor(duration * sampleRate);
|
|
766
|
+
const leftGain = Math.cos((pan + 1) * Math.PI * 0.25);
|
|
767
|
+
const rightGain = Math.sin((pan + 1) * Math.PI * 0.25);
|
|
768
|
+
let previous = 0;
|
|
769
|
+
|
|
770
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
771
|
+
const index = startSample + i;
|
|
772
|
+
if (index >= left.length) break;
|
|
773
|
+
|
|
774
|
+
const t = i / sampleRate;
|
|
775
|
+
const envelope = Math.exp(-t * 18) * clamp((duration - t) / duration, 0, 1);
|
|
776
|
+
const white = pseudoNoise(index, seed);
|
|
777
|
+
previous = previous * color + white * (1 - color);
|
|
778
|
+
const sample = previous * amp * envelope;
|
|
779
|
+
left[index] += sample * leftGain;
|
|
780
|
+
right[index] += sample * rightGain;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function createDemoBuffer(audioCtx) {
|
|
785
|
+
const duration = 6;
|
|
786
|
+
const sampleRate = audioCtx.sampleRate;
|
|
787
|
+
const frameCount = Math.floor(duration * sampleRate);
|
|
788
|
+
const buffer = audioCtx.createBuffer(2, frameCount, sampleRate);
|
|
789
|
+
const left = buffer.getChannelData(0);
|
|
790
|
+
const right = buffer.getChannelData(1);
|
|
791
|
+
|
|
792
|
+
const kickTimes = [0, 1, 2, 3, 4, 5];
|
|
793
|
+
const snareTimes = [1, 3, 5];
|
|
794
|
+
const hatTimes = Array.from({ length: 24 }, (_, step) => step * 0.25);
|
|
795
|
+
|
|
796
|
+
for (const time of kickTimes) {
|
|
797
|
+
writeTone(left, right, sampleRate, time, 0.38, {
|
|
798
|
+
freq: 50,
|
|
799
|
+
amp: 0.38,
|
|
800
|
+
pan: 0,
|
|
801
|
+
harmonics: [1, 0.25, 0.08],
|
|
802
|
+
release: 0.24,
|
|
803
|
+
drift: -0.3,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
for (const time of snareTimes) {
|
|
808
|
+
writeNoise(left, right, sampleRate, time, 0.2, {
|
|
809
|
+
amp: 0.2,
|
|
810
|
+
pan: 0.05,
|
|
811
|
+
seed: time * 31,
|
|
812
|
+
color: 0.18,
|
|
813
|
+
});
|
|
814
|
+
writeTone(left, right, sampleRate, time, 0.12, {
|
|
815
|
+
freq: 190,
|
|
816
|
+
amp: 0.11,
|
|
817
|
+
pan: -0.03,
|
|
818
|
+
harmonics: [1, 0.1],
|
|
819
|
+
release: 0.08,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
for (const time of hatTimes) {
|
|
824
|
+
writeNoise(left, right, sampleRate, time, 0.08, {
|
|
825
|
+
amp: time % 0.5 === 0 ? 0.11 : 0.075,
|
|
826
|
+
pan: time % 0.5 === 0 ? 0.28 : -0.22,
|
|
827
|
+
seed: 100 + time * 13,
|
|
828
|
+
color: 0.08,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const chordRoots = [261.63, 293.66, 329.63];
|
|
833
|
+
chordRoots.forEach((root, bar) => {
|
|
834
|
+
const start = bar * 2;
|
|
835
|
+
const notes = [root, root * 1.25, root * 1.5];
|
|
836
|
+
notes.forEach((freq, index) => {
|
|
837
|
+
writeTone(left, right, sampleRate, start + index * 0.04, 1.25, {
|
|
838
|
+
freq,
|
|
839
|
+
amp: 0.11,
|
|
840
|
+
pan: -0.3 + index * 0.28,
|
|
841
|
+
harmonics: [1, 0.32, 0.12, 0.05],
|
|
842
|
+
attack: 0.01,
|
|
843
|
+
release: 0.24,
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
});
|
|
21
847
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
848
|
+
const melody = [523.25, 587.33, 659.25, 783.99, 698.46, 659.25, 587.33, 523.25];
|
|
849
|
+
melody.forEach((freq, index) => {
|
|
850
|
+
writeTone(left, right, sampleRate, 0.5 + index * 0.5, 0.28, {
|
|
851
|
+
freq,
|
|
852
|
+
amp: 0.09,
|
|
853
|
+
pan: index % 2 === 0 ? -0.35 : 0.35,
|
|
854
|
+
harmonics: [1, 0.24, 0.06],
|
|
855
|
+
attack: 0.005,
|
|
856
|
+
release: 0.12,
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
return buffer;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function createAltBuffer(audioCtx) {
|
|
864
|
+
const duration = 6;
|
|
865
|
+
const sampleRate = audioCtx.sampleRate;
|
|
866
|
+
const frameCount = Math.floor(duration * sampleRate);
|
|
867
|
+
const buffer = audioCtx.createBuffer(2, frameCount, sampleRate);
|
|
868
|
+
const left = buffer.getChannelData(0);
|
|
869
|
+
const right = buffer.getChannelData(1);
|
|
870
|
+
|
|
871
|
+
const kickTimes = [0, 0.75, 1.5, 2.25, 3, 3.75, 4.5, 5.25];
|
|
872
|
+
for (const time of kickTimes) {
|
|
873
|
+
writeTone(left, right, sampleRate, time, 0.22, {
|
|
874
|
+
freq: 72,
|
|
875
|
+
amp: 0.32,
|
|
876
|
+
pan: 0,
|
|
877
|
+
harmonics: [1, 0.4, 0.15],
|
|
878
|
+
release: 0.16,
|
|
879
|
+
drift: -0.15,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
25
882
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
883
|
+
const hatTimes = Array.from({ length: 48 }, (_, step) => step * 0.125);
|
|
884
|
+
for (const time of hatTimes) {
|
|
885
|
+
writeNoise(left, right, sampleRate, time, 0.04, {
|
|
886
|
+
amp: 0.07,
|
|
887
|
+
pan: time % 0.25 === 0 ? -0.4 : 0.4,
|
|
888
|
+
seed: 200 + time * 17,
|
|
889
|
+
color: 0.05,
|
|
890
|
+
});
|
|
33
891
|
}
|
|
34
|
-
|
|
892
|
+
|
|
893
|
+
const arpNotes = [392, 523.25, 659.25, 783.99, 659.25, 523.25];
|
|
894
|
+
arpNotes.forEach((freq, index) => {
|
|
895
|
+
for (let rep = 0; rep < 4; rep += 1) {
|
|
896
|
+
writeTone(left, right, sampleRate, rep * 1.5 + index * 0.18, 0.16, {
|
|
897
|
+
freq,
|
|
898
|
+
amp: 0.1,
|
|
899
|
+
pan: -0.5 + index * 0.2,
|
|
900
|
+
harmonics: [1, 0.3, 0.08],
|
|
901
|
+
attack: 0.003,
|
|
902
|
+
release: 0.08,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
return buffer;
|
|
35
908
|
}
|
|
36
909
|
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
910
|
+
async function ensureClip() {
|
|
911
|
+
if (state.clip && state.ctx) return state.clip;
|
|
912
|
+
|
|
913
|
+
const ctx = new AudioContext();
|
|
914
|
+
await ctx.audioWorklet.addModule(getProcessorCdnUrl(version));
|
|
915
|
+
|
|
916
|
+
const clip = new ClipNode(ctx);
|
|
917
|
+
clip.connect(ctx.destination);
|
|
918
|
+
clip.buffer = createDemoBuffer(ctx);
|
|
919
|
+
|
|
920
|
+
state.ctx = ctx;
|
|
921
|
+
state.clip = clip;
|
|
922
|
+
state.duration = clip.buffer.duration;
|
|
923
|
+
state.loopCount = 0;
|
|
924
|
+
|
|
925
|
+
ui.loopStart.max = String(Math.max(state.duration - 0.08, 0.08));
|
|
926
|
+
ui.loopEnd.max = String(state.duration);
|
|
927
|
+
ui.loopEnd.value = String(Math.min(Number(ui.loopEnd.value), state.duration));
|
|
928
|
+
ui.playhead.max = String(Math.max(Math.floor(state.duration * ctx.sampleRate), 1));
|
|
929
|
+
|
|
930
|
+
clip.onframe = ([, , playhead]) => {
|
|
931
|
+
ui.playheadValue.textContent = formatSeconds(playhead / ctx.sampleRate);
|
|
932
|
+
ui.sampleValue.textContent = String(playhead);
|
|
933
|
+
if (!state.isSeeking) {
|
|
934
|
+
ui.playhead.value = String(playhead);
|
|
935
|
+
ui.playheadControlValue.textContent = `sample ${playhead}`;
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
clip.onlooped = () => {
|
|
940
|
+
state.loopCount += 1;
|
|
941
|
+
ui.loopCountValue.textContent = String(state.loopCount);
|
|
942
|
+
setStatus(`Loop callback fired ${state.loopCount} time${state.loopCount === 1 ? "" : "s"}. Crossfade is ${Number(ui.loopCrossfade.value).toFixed(3)}s.`);
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
clip.onstatechange = (nextState) => {
|
|
946
|
+
setNodeState(nextState);
|
|
947
|
+
|
|
948
|
+
if (nextState === "paused") {
|
|
949
|
+
setStatus("Paused. Resume to continue from the current playhead.");
|
|
950
|
+
} else if (nextState === "started" || nextState === "resumed") {
|
|
951
|
+
setStatus("Playing through ClipNode. Try dragging the playhead while audio is running or increasing loop crossfade.");
|
|
952
|
+
} else if (nextState === "stopped" || nextState === "ended") {
|
|
953
|
+
setStatus("Stopped. Press Start to reuse the same node \u2014 no recreation needed.");
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
applyControls();
|
|
958
|
+
syncOutputs();
|
|
959
|
+
setNodeState("initial");
|
|
960
|
+
return clip;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function startPlayback() {
|
|
964
|
+
const clip = await ensureClip();
|
|
965
|
+
await state.ctx.resume();
|
|
966
|
+
|
|
967
|
+
if (state.nodeState === "paused") {
|
|
968
|
+
clip.resume();
|
|
969
|
+
return;
|
|
44
970
|
}
|
|
971
|
+
|
|
972
|
+
if (state.nodeState === "started" || state.nodeState === "scheduled" || state.nodeState === "resumed") {
|
|
973
|
+
setStatus("Already playing. Adjust the controls live or stop first.");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
state.loopCount = 0;
|
|
978
|
+
ui.loopCountValue.textContent = "0";
|
|
979
|
+
state.startCount += 1;
|
|
980
|
+
ui.startCountValue.textContent = String(state.startCount);
|
|
45
981
|
clip.start();
|
|
46
|
-
|
|
47
|
-
});
|
|
982
|
+
}
|
|
48
983
|
|
|
49
|
-
|
|
50
|
-
if (clip)
|
|
51
|
-
|
|
52
|
-
|
|
984
|
+
function stopPlayback() {
|
|
985
|
+
if (!state.clip) return;
|
|
986
|
+
state.clip.stop();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function togglePause() {
|
|
990
|
+
if (!state.clip) return;
|
|
991
|
+
|
|
992
|
+
if (state.nodeState === "paused") {
|
|
993
|
+
await state.ctx.resume();
|
|
994
|
+
state.clip.resume();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (state.nodeState === "started" || state.nodeState === "scheduled" || state.nodeState === "resumed") {
|
|
999
|
+
state.clip.pause();
|
|
53
1000
|
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
ui.play.addEventListener("click", () => {
|
|
1004
|
+
startPlayback().catch((error) => {
|
|
1005
|
+
console.error(error);
|
|
1006
|
+
setStatus(`Could not start playback: ${error instanceof Error ? error.message : String(error)}`);
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
ui.pause.addEventListener("click", () => {
|
|
1011
|
+
togglePause().catch((error) => {
|
|
1012
|
+
console.error(error);
|
|
1013
|
+
setStatus(`Pause or resume failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
ui.stop.addEventListener("click", stopPlayback);
|
|
1018
|
+
|
|
1019
|
+
ui.swapBuffer.addEventListener("click", () => {
|
|
1020
|
+
if (!state.clip || !state.ctx) return;
|
|
1021
|
+
const newBuffer = createAltBuffer(state.ctx);
|
|
1022
|
+
state.clip.buffer = newBuffer;
|
|
1023
|
+
state.duration = newBuffer.duration;
|
|
1024
|
+
state.swapCount += 1;
|
|
1025
|
+
ui.swapCountValue.textContent = String(state.swapCount);
|
|
1026
|
+
ui.playhead.max = String(Math.max(Math.floor(state.duration * state.ctx.sampleRate), 1));
|
|
1027
|
+
ui.loopStart.max = String(Math.max(state.duration - 0.08, 0.08));
|
|
1028
|
+
ui.loopEnd.max = String(state.duration);
|
|
1029
|
+
syncOutputs();
|
|
1030
|
+
setStatus(`Buffer swapped (${state.swapCount}×). The node keeps playing with the new audio — no stop/start required.`);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
[
|
|
1034
|
+
ui.rate,
|
|
1035
|
+
ui.detune,
|
|
1036
|
+
ui.gain,
|
|
1037
|
+
ui.pan,
|
|
1038
|
+
ui.loopCrossfade,
|
|
1039
|
+
ui.fadeIn,
|
|
1040
|
+
ui.fadeOut,
|
|
1041
|
+
ui.loopEnabled,
|
|
1042
|
+
].forEach((input) => {
|
|
1043
|
+
input.addEventListener("input", () => {
|
|
1044
|
+
syncOutputs();
|
|
1045
|
+
applyControls();
|
|
1046
|
+
});
|
|
1047
|
+
input.addEventListener("change", () => {
|
|
1048
|
+
syncOutputs();
|
|
1049
|
+
applyControls();
|
|
1050
|
+
});
|
|
54
1051
|
});
|
|
1052
|
+
|
|
1053
|
+
ui.loopStart.addEventListener("input", () => {
|
|
1054
|
+
normalizeLoopRange("loopStart");
|
|
1055
|
+
syncOutputs();
|
|
1056
|
+
applyControls();
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
ui.loopEnd.addEventListener("input", () => {
|
|
1060
|
+
normalizeLoopRange("loopEnd");
|
|
1061
|
+
syncOutputs();
|
|
1062
|
+
applyControls();
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
ui.playhead.addEventListener("pointerdown", () => {
|
|
1066
|
+
state.isSeeking = true;
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
ui.playhead.addEventListener("input", () => {
|
|
1070
|
+
state.isSeeking = true;
|
|
1071
|
+
syncOutputs();
|
|
1072
|
+
applyControls();
|
|
1073
|
+
ui.playheadValue.textContent = formatSeconds(Number(ui.playhead.value) / (state.ctx?.sampleRate ?? 48000));
|
|
1074
|
+
ui.sampleValue.textContent = String(Math.round(Number(ui.playhead.value)));
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
ui.playhead.addEventListener("change", () => {
|
|
1078
|
+
state.isSeeking = false;
|
|
1079
|
+
syncOutputs();
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
ui.playhead.addEventListener("pointerup", () => {
|
|
1083
|
+
state.isSeeking = false;
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
syncOutputs();
|
|
55
1087
|
</script>
|
|
56
1088
|
</body>
|
|
57
1089
|
</html>
|