@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
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { ClipNode, getProcessorBlobUrl } from "@jadujoel/web-audio-clip-node";
|
|
2
|
+
import { workerCode } from "./generated/worker-code";
|
|
3
|
+
|
|
4
|
+
function getWorkerBlobUrl(): string {
|
|
5
|
+
const blob = new Blob([workerCode], { type: "application/javascript" });
|
|
6
|
+
return URL.createObjectURL(blob);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// ── DOM references ───────────────────────────────────────────────────
|
|
10
|
+
const streamBtn = document.getElementById("stream") as HTMLButtonElement;
|
|
11
|
+
const pauseBtn = document.getElementById("pause") as HTMLButtonElement;
|
|
12
|
+
const stopBtn = document.getElementById("stop") as HTMLButtonElement;
|
|
13
|
+
const urlInput = document.getElementById("url") as HTMLInputElement;
|
|
14
|
+
const throttleSelect = document.getElementById("throttle-select") as HTMLSelectElement;
|
|
15
|
+
const progressBar = document.getElementById("progress") as HTMLDivElement;
|
|
16
|
+
const statusText = document.getElementById("status") as HTMLParagraphElement;
|
|
17
|
+
const controlsPanel = document.getElementById("controls") as HTMLDivElement;
|
|
18
|
+
|
|
19
|
+
// Control sliders
|
|
20
|
+
const gainSlider = document.getElementById("ctrl-gain") as HTMLInputElement;
|
|
21
|
+
const panSlider = document.getElementById("ctrl-pan") as HTMLInputElement;
|
|
22
|
+
const rateSlider = document.getElementById("ctrl-rate") as HTMLInputElement;
|
|
23
|
+
const detuneSlider = document.getElementById("ctrl-detune") as HTMLInputElement;
|
|
24
|
+
const lowpassSlider = document.getElementById("ctrl-lowpass") as HTMLInputElement;
|
|
25
|
+
const highpassSlider = document.getElementById(
|
|
26
|
+
"ctrl-highpass",
|
|
27
|
+
) as HTMLInputElement;
|
|
28
|
+
const fadeInSlider = document.getElementById("ctrl-fadein") as HTMLInputElement;
|
|
29
|
+
const fadeOutSlider = document.getElementById(
|
|
30
|
+
"ctrl-fadeout",
|
|
31
|
+
) as HTMLInputElement;
|
|
32
|
+
const loopCheckbox = document.getElementById("ctrl-loop") as HTMLInputElement;
|
|
33
|
+
const loopStartSlider = document.getElementById(
|
|
34
|
+
"ctrl-loopstart",
|
|
35
|
+
) as HTMLInputElement;
|
|
36
|
+
const loopEndSlider = document.getElementById(
|
|
37
|
+
"ctrl-loopend",
|
|
38
|
+
) as HTMLInputElement;
|
|
39
|
+
const crossfadeSlider = document.getElementById(
|
|
40
|
+
"ctrl-crossfade",
|
|
41
|
+
) as HTMLInputElement;
|
|
42
|
+
|
|
43
|
+
// Value displays
|
|
44
|
+
const valGain = document.getElementById("val-gain") as HTMLSpanElement;
|
|
45
|
+
const valPan = document.getElementById("val-pan") as HTMLSpanElement;
|
|
46
|
+
const valRate = document.getElementById("val-rate") as HTMLSpanElement;
|
|
47
|
+
const valDetune = document.getElementById("val-detune") as HTMLSpanElement;
|
|
48
|
+
const valLowpass = document.getElementById("val-lowpass") as HTMLSpanElement;
|
|
49
|
+
const valHighpass = document.getElementById("val-highpass") as HTMLSpanElement;
|
|
50
|
+
const valFadeIn = document.getElementById("val-fadein") as HTMLSpanElement;
|
|
51
|
+
const valFadeOut = document.getElementById("val-fadeout") as HTMLSpanElement;
|
|
52
|
+
const valLoopStart = document.getElementById(
|
|
53
|
+
"val-loopstart",
|
|
54
|
+
) as HTMLSpanElement;
|
|
55
|
+
const valLoopEnd = document.getElementById("val-loopend") as HTMLSpanElement;
|
|
56
|
+
const valCrossfade = document.getElementById(
|
|
57
|
+
"val-crossfade",
|
|
58
|
+
) as HTMLSpanElement;
|
|
59
|
+
|
|
60
|
+
// ── State ────────────────────────────────────────────────────────────
|
|
61
|
+
let ctx: AudioContext | null = null;
|
|
62
|
+
let clip: ClipNode | null = null;
|
|
63
|
+
let worker: Worker | null = null;
|
|
64
|
+
|
|
65
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
66
|
+
function setStatus(msg: string) {
|
|
67
|
+
statusText.textContent = msg;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setProgress(ratio: number) {
|
|
71
|
+
progressBar.style.width = `${Math.min(100, ratio * 100).toFixed(1)}%`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Stream & Play ────────────────────────────────────────────────────
|
|
75
|
+
streamBtn.addEventListener("click", async () => {
|
|
76
|
+
const url = urlInput.value.trim();
|
|
77
|
+
if (!url) {
|
|
78
|
+
setStatus("Enter a URL first.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Tear down previous run
|
|
83
|
+
if (worker) {
|
|
84
|
+
worker.postMessage({ type: "abort" });
|
|
85
|
+
worker.terminate();
|
|
86
|
+
worker = null;
|
|
87
|
+
}
|
|
88
|
+
if (clip) {
|
|
89
|
+
clip.stop();
|
|
90
|
+
clip.disconnect();
|
|
91
|
+
clip = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create AudioContext
|
|
95
|
+
if (!ctx) {
|
|
96
|
+
ctx = new AudioContext();
|
|
97
|
+
await ctx.audioWorklet.addModule(getProcessorBlobUrl());
|
|
98
|
+
} else {
|
|
99
|
+
await ctx.resume();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Create ClipNode
|
|
103
|
+
clip = new ClipNode(ctx);
|
|
104
|
+
clip.loop = true;
|
|
105
|
+
clip.connect(ctx.destination);
|
|
106
|
+
|
|
107
|
+
// Apply current slider values to the new clip
|
|
108
|
+
applyControls();
|
|
109
|
+
|
|
110
|
+
// Show controls panel
|
|
111
|
+
controlsPanel.style.display = "";
|
|
112
|
+
pauseBtn.disabled = false;
|
|
113
|
+
stopBtn.disabled = false;
|
|
114
|
+
|
|
115
|
+
// Create MessageChannel: port1 → Worker, port2 → Processor
|
|
116
|
+
const channel = new MessageChannel();
|
|
117
|
+
|
|
118
|
+
// Transfer port2 to the processor via the ClipNode API (zero main-thread allocation)
|
|
119
|
+
clip.transferPort(channel.port2);
|
|
120
|
+
|
|
121
|
+
// Create and start decode worker from inline Blob URL
|
|
122
|
+
worker = new Worker(getWorkerBlobUrl());
|
|
123
|
+
|
|
124
|
+
worker.onmessage = (ev: MessageEvent) => {
|
|
125
|
+
const { type } = ev.data;
|
|
126
|
+
switch (type) {
|
|
127
|
+
case "progress": {
|
|
128
|
+
const { bytesReceived, totalBytes } = ev.data;
|
|
129
|
+
if (totalBytes) {
|
|
130
|
+
setProgress(bytesReceived / totalBytes);
|
|
131
|
+
setStatus(
|
|
132
|
+
`Downloading… ${((bytesReceived / 1024) | 0)} / ${((totalBytes / 1024) | 0)} KB`,
|
|
133
|
+
);
|
|
134
|
+
} else {
|
|
135
|
+
setStatus(`Downloading… ${((bytesReceived / 1024) | 0)} KB`);
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "decoded": {
|
|
140
|
+
const { samplesDecoded } = ev.data;
|
|
141
|
+
if (samplesDecoded > 0 && clip && clip.state === "initial") {
|
|
142
|
+
clip.start();
|
|
143
|
+
setStatus("Streaming & playing…");
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "info": {
|
|
148
|
+
setStatus(`Decoding: ${ev.data.sampleRate} Hz, ${ev.data.channels} ch`);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "done": {
|
|
152
|
+
setStatus(`Done — ${ev.data.samplesDecoded} samples decoded.`);
|
|
153
|
+
setProgress(1);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "error": {
|
|
157
|
+
setStatus(`Error: ${ev.data.message}`);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "aborted": {
|
|
161
|
+
setStatus("Aborted.");
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Init the worker with the port and absolute URL
|
|
168
|
+
const absoluteUrl = new URL(url, location.href).href;
|
|
169
|
+
const throttle = Number(throttleSelect.value);
|
|
170
|
+
worker.postMessage(
|
|
171
|
+
{ type: "init", port: channel.port1, url: absoluteUrl, throttle },
|
|
172
|
+
[channel.port1],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
setStatus(throttle > 0 ? `Starting stream… (${(throttle / 1024).toFixed(0)} KB/s)` : "Starting stream…");
|
|
176
|
+
setProgress(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── Transport controls ───────────────────────────────────────────────
|
|
180
|
+
pauseBtn.addEventListener("click", () => {
|
|
181
|
+
if (!clip || !ctx) return;
|
|
182
|
+
if (clip.state === "playing" || clip.state === "started") {
|
|
183
|
+
clip.pause();
|
|
184
|
+
setStatus("Paused.");
|
|
185
|
+
} else if (clip.state === "paused") {
|
|
186
|
+
clip.start();
|
|
187
|
+
setStatus("Resumed.");
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
stopBtn.addEventListener("click", () => {
|
|
192
|
+
if (!clip) return;
|
|
193
|
+
clip.stop();
|
|
194
|
+
if (worker) {
|
|
195
|
+
worker.postMessage({ type: "abort" });
|
|
196
|
+
worker.terminate();
|
|
197
|
+
worker = null;
|
|
198
|
+
}
|
|
199
|
+
setProgress(0);
|
|
200
|
+
setStatus("Stopped.");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── Control wiring ───────────────────────────────────────────────────
|
|
204
|
+
function formatPan(v: number): string {
|
|
205
|
+
if (Math.abs(v) < 0.005) return "C";
|
|
206
|
+
return v < 0 ? `L ${(-v * 100).toFixed(0)}` : `R ${(v * 100).toFixed(0)}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function formatHz(v: number): string {
|
|
210
|
+
return v >= 1000 ? `${(v / 1000).toFixed(1)} kHz` : `${v} Hz`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyControls() {
|
|
214
|
+
if (!clip) return;
|
|
215
|
+
clip.gain.value = Number(gainSlider.value);
|
|
216
|
+
clip.pan.value = Number(panSlider.value);
|
|
217
|
+
clip.playbackRate.value = Number(rateSlider.value);
|
|
218
|
+
clip.detune.value = Number(detuneSlider.value);
|
|
219
|
+
clip.lowpass.value = Number(lowpassSlider.value);
|
|
220
|
+
clip.highpass.value = Number(highpassSlider.value);
|
|
221
|
+
clip.fadeIn = Number(fadeInSlider.value);
|
|
222
|
+
clip.fadeOut = Number(fadeOutSlider.value);
|
|
223
|
+
clip.loop = loopCheckbox.checked;
|
|
224
|
+
clip.loopStart = Number(loopStartSlider.value);
|
|
225
|
+
clip.loopEnd = Number(loopEndSlider.value);
|
|
226
|
+
clip.loopCrossfade = Number(crossfadeSlider.value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Wire each slider to update the value display and the clip
|
|
230
|
+
gainSlider.addEventListener("input", () => {
|
|
231
|
+
valGain.textContent = Number(gainSlider.value).toFixed(2);
|
|
232
|
+
if (clip) clip.gain.value = Number(gainSlider.value);
|
|
233
|
+
});
|
|
234
|
+
panSlider.addEventListener("input", () => {
|
|
235
|
+
valPan.textContent = formatPan(Number(panSlider.value));
|
|
236
|
+
if (clip) clip.pan.value = Number(panSlider.value);
|
|
237
|
+
});
|
|
238
|
+
rateSlider.addEventListener("input", () => {
|
|
239
|
+
valRate.textContent = `${Number(rateSlider.value).toFixed(2)}×`;
|
|
240
|
+
if (clip) clip.playbackRate.value = Number(rateSlider.value);
|
|
241
|
+
});
|
|
242
|
+
detuneSlider.addEventListener("input", () => {
|
|
243
|
+
valDetune.textContent = `${detuneSlider.value} ct`;
|
|
244
|
+
if (clip) clip.detune.value = Number(detuneSlider.value);
|
|
245
|
+
});
|
|
246
|
+
lowpassSlider.addEventListener("input", () => {
|
|
247
|
+
valLowpass.textContent = formatHz(Number(lowpassSlider.value));
|
|
248
|
+
if (clip) clip.lowpass.value = Number(lowpassSlider.value);
|
|
249
|
+
});
|
|
250
|
+
highpassSlider.addEventListener("input", () => {
|
|
251
|
+
valHighpass.textContent = formatHz(Number(highpassSlider.value));
|
|
252
|
+
if (clip) clip.highpass.value = Number(highpassSlider.value);
|
|
253
|
+
});
|
|
254
|
+
fadeInSlider.addEventListener("input", () => {
|
|
255
|
+
valFadeIn.textContent = `${Number(fadeInSlider.value).toFixed(2)} s`;
|
|
256
|
+
if (clip) clip.fadeIn = Number(fadeInSlider.value);
|
|
257
|
+
});
|
|
258
|
+
fadeOutSlider.addEventListener("input", () => {
|
|
259
|
+
valFadeOut.textContent = `${Number(fadeOutSlider.value).toFixed(2)} s`;
|
|
260
|
+
if (clip) clip.fadeOut = Number(fadeOutSlider.value);
|
|
261
|
+
});
|
|
262
|
+
loopCheckbox.addEventListener("change", () => {
|
|
263
|
+
if (clip) clip.loop = loopCheckbox.checked;
|
|
264
|
+
});
|
|
265
|
+
loopStartSlider.addEventListener("input", () => {
|
|
266
|
+
valLoopStart.textContent = `${Number(loopStartSlider.value).toFixed(2)} s`;
|
|
267
|
+
if (clip) clip.loopStart = Number(loopStartSlider.value);
|
|
268
|
+
});
|
|
269
|
+
loopEndSlider.addEventListener("input", () => {
|
|
270
|
+
valLoopEnd.textContent = `${Number(loopEndSlider.value).toFixed(2)} s`;
|
|
271
|
+
if (clip) clip.loopEnd = Number(loopEndSlider.value);
|
|
272
|
+
});
|
|
273
|
+
crossfadeSlider.addEventListener("input", () => {
|
|
274
|
+
valCrossfade.textContent = `${Number(crossfadeSlider.value).toFixed(2)} s`;
|
|
275
|
+
if (clip) clip.loopCrossfade = Number(crossfadeSlider.value);
|
|
276
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "streaming-example",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build:worker": "bun run build-worker.ts",
|
|
7
|
+
"dev": "bun run build:worker && bun index.html"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@jadujoel/web-audio-clip-node": "file:../.."
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jadujoel/web-audio-clip-node",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Full-featured AudioWorklet clip player with playback rate, detune, gain, pan, filters, looping, fades, crossfade, and streaming buffer support. React components included.",
|
|
6
6
|
"keywords": [
|