@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.
@@ -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.6",
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": [