pts 0.12.8 → 1.0.0
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 +92 -80
- package/dist/index.d.mts +6208 -1254
- package/dist/index.d.mts.map +1 -0
- package/dist/index.d.ts +6208 -1254
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9314 -10680
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +9266 -10611
- package/dist/index.mjs.map +1 -0
- package/dist/pts.js +9446 -10777
- package/dist/pts.js.map +1 -0
- package/dist/pts.min.js +3 -5
- package/dist/pts.min.js.map +1 -0
- package/package.json +91 -31
- package/src/Canvas.ts +1642 -0
- package/src/Color.ts +1109 -0
- package/src/Create.ts +1547 -0
- package/src/Dom.ts +940 -0
- package/src/Form.ts +312 -0
- package/src/Image.ts +722 -0
- package/src/LinearAlgebra.ts +530 -0
- package/src/Num.ts +1091 -0
- package/src/Op.ts +2127 -0
- package/src/Physics.ts +1233 -0
- package/src/Play.ts +861 -0
- package/src/Pt.ts +1303 -0
- package/src/Space.ts +898 -0
- package/src/Svg.ts +1573 -0
- package/src/Types.ts +301 -0
- package/src/Typography.ts +228 -0
- package/src/UI.ts +757 -0
- package/src/Util.ts +454 -0
- package/src/_module.ts +18 -0
- package/src/_script.ts +94 -0
- package/src/_triangulate.ts +884 -0
- package/src/uheprng.ts +153 -0
package/src/Play.ts
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import { Pt, Group, type Bound } from "./Pt";
|
|
2
|
+
import { Num } from "./Num";
|
|
3
|
+
import {
|
|
4
|
+
type ITempoListener,
|
|
5
|
+
type ITempoStartFn,
|
|
6
|
+
type ITempoProgressFn,
|
|
7
|
+
type ITempoResponses,
|
|
8
|
+
} from "./Types";
|
|
9
|
+
import {
|
|
10
|
+
type ISoundAnalyzer,
|
|
11
|
+
type SoundType,
|
|
12
|
+
type PtLike,
|
|
13
|
+
type IPlayer,
|
|
14
|
+
} from "./Types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tempo helps you create synchronized and rhythmic animations.
|
|
18
|
+
*/
|
|
19
|
+
export class Tempo implements IPlayer {
|
|
20
|
+
protected _bpm!: number; // beat per minute
|
|
21
|
+
protected _ms!: number; // millis per beat
|
|
22
|
+
|
|
23
|
+
protected _listeners: { [key: string]: ITempoListener } = {};
|
|
24
|
+
protected _listenerInc: number = 0;
|
|
25
|
+
public animateID!: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Construct a new Tempo instance by beats-per-minute. Alternatively, you can use [`Tempo.fromBeat`](#link) to create from milliseconds.
|
|
29
|
+
* @param bpm beats per minute. Must be greater than 0.
|
|
30
|
+
*/
|
|
31
|
+
constructor(bpm: number) {
|
|
32
|
+
this.bpm = bpm;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a new Tempo instance by specifying milliseconds-per-beat.
|
|
37
|
+
* @param ms milliseconds per beat. Must be greater than 0.
|
|
38
|
+
*/
|
|
39
|
+
static fromBeat(ms: number): Tempo {
|
|
40
|
+
return new Tempo(60000 / ms);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Beats-per-minute value. Must be greater than 0.
|
|
45
|
+
*/
|
|
46
|
+
get bpm(): number {
|
|
47
|
+
return this._bpm;
|
|
48
|
+
}
|
|
49
|
+
set bpm(n: number) {
|
|
50
|
+
this._bpm = n;
|
|
51
|
+
this._ms = 60000 / this._bpm;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Milliseconds per beat (Note that this is derived from the bpm value).
|
|
56
|
+
*/
|
|
57
|
+
get ms(): number {
|
|
58
|
+
return this._ms;
|
|
59
|
+
}
|
|
60
|
+
set ms(n: number) {
|
|
61
|
+
this._bpm = 60000 / n;
|
|
62
|
+
this._ms = n;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Get a listener unique id
|
|
66
|
+
protected _createID(): string {
|
|
67
|
+
return "_b" + this._listenerInc++;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* This is a core function that let you specify a rhythm and then define responses by calling the `start` and `progress` functions from the returned object. See [Animation guide](../guide/Animation-0700.html) for more details.
|
|
72
|
+
* The `start` function lets you set a callback on every start. It takes a function ([`ITempoStartFn`](#link)).
|
|
73
|
+
* The `progress` function lets you set a callback during progress. It takes a function ([`ITempoProgressFn`](#link)). Both functions let you optionally specify a time offset and a custom name.
|
|
74
|
+
* A positive offset shifts the beat earlier (fires sooner), a negative offset shifts it later.
|
|
75
|
+
* @param beats a rhythm in beats as a number or an array of numbers
|
|
76
|
+
* @example `tempo.every(2).start( (count) => ... )`, `tempo.every([2,4,6]).progress( (count, t) => ... )`
|
|
77
|
+
* @returns an object with chainable functions
|
|
78
|
+
*/
|
|
79
|
+
every(beats: number | number[]): ITempoResponses {
|
|
80
|
+
const self = this;
|
|
81
|
+
const p = Array.isArray(beats) ? beats[0] : beats;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
start: function (
|
|
85
|
+
fn: ITempoStartFn,
|
|
86
|
+
offset: number = 0,
|
|
87
|
+
name?: string,
|
|
88
|
+
): ITempoResponses {
|
|
89
|
+
const id = name || self._createID();
|
|
90
|
+
self._listeners[id] = {
|
|
91
|
+
name: id,
|
|
92
|
+
beats: beats,
|
|
93
|
+
period: p,
|
|
94
|
+
index: 0,
|
|
95
|
+
offset: offset,
|
|
96
|
+
duration: -1,
|
|
97
|
+
count: 0,
|
|
98
|
+
continuous: false,
|
|
99
|
+
fn: fn,
|
|
100
|
+
};
|
|
101
|
+
return this;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
progress: function (
|
|
105
|
+
fn: ITempoProgressFn,
|
|
106
|
+
offset: number = 0,
|
|
107
|
+
name?: string,
|
|
108
|
+
): ITempoResponses {
|
|
109
|
+
const id = name || self._createID();
|
|
110
|
+
self._listeners[id] = {
|
|
111
|
+
name: id,
|
|
112
|
+
beats: beats,
|
|
113
|
+
period: p,
|
|
114
|
+
index: 0,
|
|
115
|
+
offset: offset,
|
|
116
|
+
duration: -1,
|
|
117
|
+
count: 0,
|
|
118
|
+
continuous: true,
|
|
119
|
+
fn: fn,
|
|
120
|
+
};
|
|
121
|
+
return this;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Usually you can add a tempo instance to a space via [`Space.add`](#link) and it will track time automatically.
|
|
128
|
+
* But if necessary, you can track time manually via this function.
|
|
129
|
+
* @param time current time in milliseconds
|
|
130
|
+
*/
|
|
131
|
+
track(time: number) {
|
|
132
|
+
for (const k in this._listeners) {
|
|
133
|
+
if (this._listeners.hasOwnProperty(k)) {
|
|
134
|
+
const li = this._listeners[k];
|
|
135
|
+
const _t = li.offset ? time + li.offset : time;
|
|
136
|
+
const ms = li.period! * this._ms; // time per period
|
|
137
|
+
let isStart = false;
|
|
138
|
+
|
|
139
|
+
if (li.duration! < 0) {
|
|
140
|
+
// first tick is the start of the first period
|
|
141
|
+
li.duration = _t - (_t % this._ms);
|
|
142
|
+
li.count = li.count || 0; // a hand-built listener may omit count
|
|
143
|
+
isStart = true;
|
|
144
|
+
} else if (_t > li.duration! + ms) {
|
|
145
|
+
li.duration = _t - (_t % this._ms); // update
|
|
146
|
+
if (Array.isArray(li.beats)) {
|
|
147
|
+
// find next period from array
|
|
148
|
+
li.index = (li.index! + 1) % li.beats.length;
|
|
149
|
+
li.period = li.beats[li.index];
|
|
150
|
+
}
|
|
151
|
+
li.count = (li.count || 0) + 1;
|
|
152
|
+
isStart = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let done: void | boolean | undefined;
|
|
156
|
+
if (li.continuous) {
|
|
157
|
+
const t = Num.clamp((_t - li.duration!) / ms, 0, 1);
|
|
158
|
+
done = (li.fn as ITempoProgressFn).call(
|
|
159
|
+
li,
|
|
160
|
+
li.count!,
|
|
161
|
+
t,
|
|
162
|
+
_t,
|
|
163
|
+
isStart,
|
|
164
|
+
);
|
|
165
|
+
} else if (isStart) {
|
|
166
|
+
done = (li.fn as ITempoStartFn).call(li, li.count!);
|
|
167
|
+
}
|
|
168
|
+
if (done) delete this._listeners[li.name!];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Remove a `start` or `progress` callback function from the list of callbacks. See [`Tempo.every`](#link) for details
|
|
175
|
+
* @param name a name string specified when creating the callback function.
|
|
176
|
+
*/
|
|
177
|
+
stop(name: string): void {
|
|
178
|
+
if (this._listeners[name]) delete this._listeners[name];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* IPlayer interface. Internal implementation that calls `track( time )`.
|
|
183
|
+
*/
|
|
184
|
+
animate(time: number, ftime: number) {
|
|
185
|
+
this.track(time);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* IPlayer interface. Not implemented.
|
|
190
|
+
*/
|
|
191
|
+
resize(bound: Bound, evt?: Event) {
|
|
192
|
+
return; // not implemented in IPlayer
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* IPlayer interface. Not implemented.
|
|
197
|
+
*/
|
|
198
|
+
action(type: string, px: number, py: number, evt: Event) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Sound class simplifies common tasks like audio inputs and visualizations using a subset of Web Audio API. It can be used with other audio libraries like tone.js, and extended to support additional web audio functions. See [the guide](../guide/Sound-0800.html) to get started.
|
|
205
|
+
*/
|
|
206
|
+
export class Sound {
|
|
207
|
+
private _type: SoundType;
|
|
208
|
+
|
|
209
|
+
/** The audio context */
|
|
210
|
+
_ctx: AudioContext;
|
|
211
|
+
|
|
212
|
+
/** The audio node, which is usually a subclass liked OscillatorNode */
|
|
213
|
+
_node!: AudioNode;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The audio node to be connected to AudioContext when playing, if different than _node
|
|
217
|
+
* This is useful when using the connect() function to filter, as typically the output would
|
|
218
|
+
* come from the filtering nodes
|
|
219
|
+
*/
|
|
220
|
+
_outputNode!: AudioNode;
|
|
221
|
+
|
|
222
|
+
/** The audio stream when streaming from input device */
|
|
223
|
+
_stream!: MediaStream;
|
|
224
|
+
|
|
225
|
+
/** Audio src when loading from file */
|
|
226
|
+
_source!: HTMLMediaElement;
|
|
227
|
+
|
|
228
|
+
/* Audio buffer when using AudioBufferSourceNode */
|
|
229
|
+
_buffer!: AudioBuffer;
|
|
230
|
+
|
|
231
|
+
/** Analyzer if any */
|
|
232
|
+
analyzer!: ISoundAnalyzer;
|
|
233
|
+
|
|
234
|
+
protected _playing: boolean = false;
|
|
235
|
+
|
|
236
|
+
protected _timestamp!: number; // Tracking play time against ctx.currentTime
|
|
237
|
+
|
|
238
|
+
protected _wave!: PeriodicWave; // Wave when generating a "custom" oscillator
|
|
239
|
+
|
|
240
|
+
protected _gain!: GainNode; // Gain node for volume control, created on first start
|
|
241
|
+
|
|
242
|
+
protected _volume: number = 1;
|
|
243
|
+
|
|
244
|
+
protected _connected: AudioNode[] = []; // Nodes added via connect(), re-applied on gen restart
|
|
245
|
+
|
|
246
|
+
protected _bufferPlayed: boolean = false; // An AudioBufferSourceNode can only start once
|
|
247
|
+
|
|
248
|
+
protected _generated: boolean = false; // Whether _node is an oscillator created by _gen
|
|
249
|
+
|
|
250
|
+
// A single AudioContext shared by all Sound instances that don't provide their own
|
|
251
|
+
protected static _sharedContext: AudioContext;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Construct a `Sound` instance. Usually, it's more convenient to use one of the static methods like [`Sound.load`](#function_load) or [`Sound.from`](#function_from).
|
|
255
|
+
* By default, all instances share a single `AudioContext` (browsers limit how many can be live at once).
|
|
256
|
+
* @param type a `SoundType` string: "file", "input", or "gen"
|
|
257
|
+
* @param ctx Optionally provide your own AudioContext instead of the shared one
|
|
258
|
+
*/
|
|
259
|
+
constructor(type: SoundType, ctx?: AudioContext) {
|
|
260
|
+
this._type = type;
|
|
261
|
+
this._ctx = ctx || Sound._getContext();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get the shared AudioContext instance, creating it on first use. This is called internally only.
|
|
266
|
+
*/
|
|
267
|
+
protected static _getContext(): AudioContext {
|
|
268
|
+
if (!Sound._sharedContext) {
|
|
269
|
+
const _ctx =
|
|
270
|
+
typeof window !== "undefined" ? window.AudioContext : undefined;
|
|
271
|
+
if (!_ctx)
|
|
272
|
+
throw new Error(
|
|
273
|
+
"Your browser doesn't support Web Audio. (No AudioContext)",
|
|
274
|
+
);
|
|
275
|
+
Sound._sharedContext = new _ctx();
|
|
276
|
+
}
|
|
277
|
+
return Sound._sharedContext;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Create a `Sound` given an [AudioNode](https://developer.mozilla.org/en-US/docs/Web/API/AudioNode) and an [AudioContext](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext) from Web Audio API. See also [this example](../guide/js/examples/tone.html) using tone.js in the [guide](../guide/Sound-0800.html).
|
|
282
|
+
* @param node an AudioNode instance
|
|
283
|
+
* @param ctx an AudioContext instance
|
|
284
|
+
* @param type a string representing a type of input source: either "file", "input", or "gen".
|
|
285
|
+
* @param stream Optionally include a MediaStream, if the type is "input"
|
|
286
|
+
* @returns a `Sound` instance
|
|
287
|
+
*/
|
|
288
|
+
static from(
|
|
289
|
+
node: AudioNode,
|
|
290
|
+
ctx: AudioContext,
|
|
291
|
+
type: SoundType = "gen",
|
|
292
|
+
stream?: MediaStream,
|
|
293
|
+
) {
|
|
294
|
+
const s = new Sound(type, ctx);
|
|
295
|
+
s._node = node;
|
|
296
|
+
if (stream) s._stream = stream;
|
|
297
|
+
return s;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a `Sound` by loading from a sound file or an audio element.
|
|
302
|
+
* @param source either an url string to load a sound file, or an audio element.
|
|
303
|
+
* @param crossOrigin whether to support loading cross-origin. Default is "anonymous". When passing an audio element, set the attribute in markup before the element loads for it to take effect.
|
|
304
|
+
* @returns a `Sound` instance
|
|
305
|
+
* @example `Sound.load( '/path/to/file.mp3' )`
|
|
306
|
+
*/
|
|
307
|
+
static load(
|
|
308
|
+
source: HTMLMediaElement | string,
|
|
309
|
+
crossOrigin: string = "anonymous",
|
|
310
|
+
): Promise<Sound> {
|
|
311
|
+
return new Promise((resolve, reject) => {
|
|
312
|
+
const s = new Sound("file");
|
|
313
|
+
if (typeof source === "string") {
|
|
314
|
+
// crossOrigin must be set before src, or the initial request is made without CORS
|
|
315
|
+
s._source = new Audio();
|
|
316
|
+
s._source.crossOrigin = crossOrigin;
|
|
317
|
+
s._source.src = source;
|
|
318
|
+
} else {
|
|
319
|
+
s._source = source;
|
|
320
|
+
s._source.crossOrigin = crossOrigin;
|
|
321
|
+
}
|
|
322
|
+
s._source.autoplay = false;
|
|
323
|
+
|
|
324
|
+
const onError = () => {
|
|
325
|
+
s._source.removeEventListener("canplaythrough", ready);
|
|
326
|
+
reject(
|
|
327
|
+
new Error(`Error loading sound: ${s._source.src || "media element"}`),
|
|
328
|
+
);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const ready = () => {
|
|
332
|
+
// runs once: either immediately below, or via the once-only listener
|
|
333
|
+
s._source.removeEventListener("error", onError);
|
|
334
|
+
s._source.addEventListener("ended", function () {
|
|
335
|
+
s._playing = false;
|
|
336
|
+
});
|
|
337
|
+
s._node = s._ctx.createMediaElementSource(s._source);
|
|
338
|
+
resolve(s);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
if (s._source.readyState >= 4) {
|
|
342
|
+
// already buffered enough (eg, a previously loaded element)
|
|
343
|
+
ready();
|
|
344
|
+
} else {
|
|
345
|
+
s._source.addEventListener("error", onError, { once: true });
|
|
346
|
+
s._source.addEventListener("canplaythrough", ready, { once: true });
|
|
347
|
+
if (s._source.readyState === 0) s._source.load(); // eg, preload="none"
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Create a `Sound` by loading and decoding a sound file URL as an `AudioBufferSourceNode`.
|
|
354
|
+
* Unlike [`Sound.load`](#link), this loads the complete file instead of streaming it, which can provide more consistent analysis and replay behavior across browsers.
|
|
355
|
+
* @param url an url to the sound file
|
|
356
|
+
*/
|
|
357
|
+
static async loadAsBuffer(url: string): Promise<Sound> {
|
|
358
|
+
const s = new Sound("file");
|
|
359
|
+
const res = await fetch(url);
|
|
360
|
+
if (!res.ok)
|
|
361
|
+
throw new Error(`Error loading sound: ${url} (status ${res.status})`);
|
|
362
|
+
try {
|
|
363
|
+
s.createBuffer(await s._ctx.decodeAudioData(await res.arrayBuffer()));
|
|
364
|
+
} catch (err) {
|
|
365
|
+
// ErrorOptions isn't in this project's TS lib target; attach cause manually
|
|
366
|
+
throw Object.assign(new Error("Error decoding audio"), { cause: err });
|
|
367
|
+
}
|
|
368
|
+
return s;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Create or re-use an AudioBuffer. Only needed if you are using `Sound.loadAsBuffer` and want to prepare a replay manually — [`start`](#link) re-creates a used buffer automatically.
|
|
373
|
+
* @param buf an AudioBuffer. Optionally, you can call this without parameters to re-use existing buffer.
|
|
374
|
+
*/
|
|
375
|
+
createBuffer(buf?: AudioBuffer): this {
|
|
376
|
+
if (this._node) {
|
|
377
|
+
// the replaced node's late "ended" event must not clobber the new playback state
|
|
378
|
+
(this._node as AudioBufferSourceNode).onended = null;
|
|
379
|
+
this._node.disconnect();
|
|
380
|
+
}
|
|
381
|
+
this._node = this._ctx.createBufferSource();
|
|
382
|
+
if (buf !== undefined) this._buffer = buf;
|
|
383
|
+
|
|
384
|
+
(this._node as AudioBufferSourceNode).buffer = this._buffer; // apply or re-use buffer
|
|
385
|
+
(this._node as AudioBufferSourceNode).onended = () => {
|
|
386
|
+
this._playing = false;
|
|
387
|
+
};
|
|
388
|
+
this._bufferPlayed = false;
|
|
389
|
+
if (this.analyzer) this._node.connect(this.analyzer.node);
|
|
390
|
+
for (const n of this._connected) this._node.connect(n);
|
|
391
|
+
return this;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Create a `Sound` by generating a waveform using [OscillatorNode](https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode).
|
|
396
|
+
* @param type a string representing the waveform type: "sine", "square", "sawtooth", "triangle", "custom"
|
|
397
|
+
* @param val the frequency value in Hz to play, or a PeriodicWave instance if type is "custom".
|
|
398
|
+
* @returns a `Sound` instance
|
|
399
|
+
* @example `Sound.generate( 'sine', 120 )`
|
|
400
|
+
*/
|
|
401
|
+
static generate(type: OscillatorType, val: number | PeriodicWave): Sound {
|
|
402
|
+
const s = new Sound("gen");
|
|
403
|
+
return s._gen(type, val);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Create the oscillator
|
|
407
|
+
protected _gen(type: OscillatorType, val: number | PeriodicWave): Sound {
|
|
408
|
+
if (this._node) this._node.disconnect(); // tidy the replaced node's edges
|
|
409
|
+
this._node = this._ctx.createOscillator();
|
|
410
|
+
this._generated = true;
|
|
411
|
+
const osc = this._node as OscillatorNode;
|
|
412
|
+
if (type === "custom") {
|
|
413
|
+
this._wave = val as PeriodicWave;
|
|
414
|
+
osc.setPeriodicWave(this._wave);
|
|
415
|
+
} else {
|
|
416
|
+
osc.type = type;
|
|
417
|
+
osc.frequency.value = val as number;
|
|
418
|
+
}
|
|
419
|
+
return this;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Create a `Sound` by streaming from an input device like microphone. Note that this function returns a Promise which resolves to a Sound instance, and rejects if the input device is unavailable or permission is denied.
|
|
424
|
+
* @param constraint Optional constraints which can be used to select a specific input device. For example, you may use [`enumerateDevices`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices) to find a specific deviceId;
|
|
425
|
+
* @returns a `Promise` which resolves to `Sound` instance
|
|
426
|
+
* @example `Sound.input().then( s => sound = s ).catch( err => ... );`
|
|
427
|
+
*/
|
|
428
|
+
static async input(constraint?: MediaStreamConstraints): Promise<Sound> {
|
|
429
|
+
const s = new Sound("input");
|
|
430
|
+
const c = constraint ? constraint : { audio: true, video: false };
|
|
431
|
+
s._stream = await navigator.mediaDevices.getUserMedia(c);
|
|
432
|
+
s._node = s._ctx.createMediaStreamSource(s._stream);
|
|
433
|
+
return s;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Get this Sound's AudioContext instance for advanced use-cases.
|
|
438
|
+
*/
|
|
439
|
+
get ctx(): AudioContext {
|
|
440
|
+
return this._ctx;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Get this Sound's AudioNode subclass instance for advanced use-cases.
|
|
445
|
+
*/
|
|
446
|
+
get node(): AudioNode {
|
|
447
|
+
return this._node;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get this Sound's Output node AudioNode instance for advanced use-cases.
|
|
452
|
+
*/
|
|
453
|
+
get outputNode(): AudioNode {
|
|
454
|
+
return this._outputNode;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get this Sound's MediaStream (eg, from microphone, if in use) instance for advanced use-cases. See [`Sound.input`](#link)
|
|
459
|
+
*/
|
|
460
|
+
get stream(): MediaStream {
|
|
461
|
+
return this._stream;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Get this Sound's Audio element (if used) instance for advanced use-cases. See [`Sound.load`](#link).
|
|
466
|
+
*/
|
|
467
|
+
get source(): HTMLMediaElement {
|
|
468
|
+
return this._source;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Get this Sound's AudioBuffer (if any) instance for advanced use-cases. See [`Sound.loadAsBuffer`](#link).
|
|
473
|
+
*/
|
|
474
|
+
get buffer(): AudioBuffer {
|
|
475
|
+
return this._buffer;
|
|
476
|
+
}
|
|
477
|
+
set buffer(b: AudioBuffer) {
|
|
478
|
+
this._buffer = b;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Get the type of input for this Sound instance. Either "file", "input", or "gen"
|
|
483
|
+
*/
|
|
484
|
+
get type(): SoundType {
|
|
485
|
+
return this._type;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Indicate whether the sound is currently playing.
|
|
490
|
+
*/
|
|
491
|
+
get playing(): boolean {
|
|
492
|
+
return this._playing;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A value between 0 to 1 to indicate playback progress. Returns 0 if the sound has no duration (eg, generated or input sounds).
|
|
497
|
+
*/
|
|
498
|
+
get progress(): number {
|
|
499
|
+
let dur = 0;
|
|
500
|
+
let curr = 0;
|
|
501
|
+
if (this._buffer) {
|
|
502
|
+
dur = this._buffer.duration;
|
|
503
|
+
// a timestamp of exactly 0 is valid (started when currentTime === timeAt)
|
|
504
|
+
curr =
|
|
505
|
+
this._timestamp !== undefined
|
|
506
|
+
? this._ctx.currentTime - this._timestamp
|
|
507
|
+
: 0;
|
|
508
|
+
} else if (this._source) {
|
|
509
|
+
dur = this._source.duration;
|
|
510
|
+
curr = this._source.currentTime;
|
|
511
|
+
}
|
|
512
|
+
return dur > 0 ? Num.clamp(curr / dur, 0, 1) : 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Indicate whether the sound is ready to play. When loading from a file, this corresponds to a ["canplaythrough"](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState) event.
|
|
517
|
+
* You can also use `this.source.addEventListener( 'canplaythrough', ...)` if needed. See also [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplaythrough_event).
|
|
518
|
+
*/
|
|
519
|
+
get playable(): boolean {
|
|
520
|
+
if (this._type === "input" || this._type === "gen")
|
|
521
|
+
return this._node !== undefined;
|
|
522
|
+
return (
|
|
523
|
+
!!this._buffer ||
|
|
524
|
+
(this._source !== undefined && this._source.readyState === 4)
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* If an analyzer is added (see [`analyze`](#function_analyze) function), get the number of frequency bins in the analyzer. Returns 0 if no analyzer is added.
|
|
530
|
+
*/
|
|
531
|
+
get binSize(): number {
|
|
532
|
+
return this.analyzer ? this.analyzer.size : 0;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Get the sample rate of the audio, for example, at 44100 hz.
|
|
537
|
+
*/
|
|
538
|
+
get sampleRate(): number {
|
|
539
|
+
return this._ctx.sampleRate;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* If the sound is generated, this sets and gets the frequency of the tone.
|
|
544
|
+
*/
|
|
545
|
+
get frequency(): number {
|
|
546
|
+
const osc = this._node as OscillatorNode;
|
|
547
|
+
return this._type === "gen" && osc && osc.frequency
|
|
548
|
+
? osc.frequency.value
|
|
549
|
+
: 0;
|
|
550
|
+
}
|
|
551
|
+
set frequency(f: number) {
|
|
552
|
+
const osc = this._node as OscillatorNode;
|
|
553
|
+
if (this._type === "gen" && osc && osc.frequency) osc.frequency.value = f;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Get and set the volume of this sound. Default is 1. Values above 1 amplify the sound. Can be set before or during playback.
|
|
558
|
+
*/
|
|
559
|
+
get volume(): number {
|
|
560
|
+
return this._volume;
|
|
561
|
+
}
|
|
562
|
+
set volume(v: number) {
|
|
563
|
+
this._volume = Math.max(0, v || 0); // `|| 0` also converts NaN
|
|
564
|
+
if (this._gain) this._gain.gain.value = this._volume;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Connect another AudioNode to this `Sound` instance's AudioNode. Using this function, you can extend the capabilities of this `Sound` instance for advanced use cases such as filtering. The connection is restored if a generated sound is restarted.
|
|
569
|
+
* @param node another AudioNode
|
|
570
|
+
*/
|
|
571
|
+
connect(node: AudioNode): this {
|
|
572
|
+
this._connected.push(node);
|
|
573
|
+
this._node.connect(node);
|
|
574
|
+
return this;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Sets the 'output' node for this Sound
|
|
579
|
+
* This would typically be used after Sound.connect, if you are adding nodes
|
|
580
|
+
* in your chain for filtering purposes.
|
|
581
|
+
* @param outputNode The AudioNode that should connect to the AudioContext
|
|
582
|
+
*/
|
|
583
|
+
setOutputNode(outputNode: AudioNode): this {
|
|
584
|
+
this._outputNode = outputNode;
|
|
585
|
+
return this;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Removes the 'output' node added from setOutputNode
|
|
590
|
+
* Note: if you start the Sound after calling this, it will play via the default node
|
|
591
|
+
*/
|
|
592
|
+
removeOutputNode(): this {
|
|
593
|
+
this._outputNode = null!;
|
|
594
|
+
return this;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Add an analyzer to this `Sound`. Calling it again replaces the existing analyzer.
|
|
599
|
+
* @param size the number of frequency bins. Should be a power of 2.
|
|
600
|
+
* @param minDb Optional minimum decibels (corresponds to `AnalyserNode.minDecibels`)
|
|
601
|
+
* @param maxDb Optional maximum decibels (corresponds to `AnalyserNode.maxDecibels`)
|
|
602
|
+
* @param smooth Optional smoothing value (corresponds to `AnalyserNode.smoothingTimeConstant`)
|
|
603
|
+
*/
|
|
604
|
+
analyze(
|
|
605
|
+
size: number = 256,
|
|
606
|
+
minDb: number = -100,
|
|
607
|
+
maxDb: number = -30,
|
|
608
|
+
smooth: number = 0.8,
|
|
609
|
+
): this {
|
|
610
|
+
if (this.analyzer && this._node) {
|
|
611
|
+
try {
|
|
612
|
+
this._node.disconnect(this.analyzer.node);
|
|
613
|
+
} catch {
|
|
614
|
+
// the previous analyzer node was not connected
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const a = this._ctx.createAnalyser();
|
|
618
|
+
a.fftSize = size * 2;
|
|
619
|
+
a.minDecibels = minDb;
|
|
620
|
+
a.maxDecibels = maxDb;
|
|
621
|
+
a.smoothingTimeConstant = smooth;
|
|
622
|
+
this.analyzer = {
|
|
623
|
+
node: a,
|
|
624
|
+
size: a.frequencyBinCount,
|
|
625
|
+
data: new Uint8Array(a.frequencyBinCount),
|
|
626
|
+
};
|
|
627
|
+
this._node.connect(this.analyzer.node);
|
|
628
|
+
return this;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Get either time-domain or frequency domain
|
|
632
|
+
protected _domain(time: boolean): Uint8Array {
|
|
633
|
+
if (this.analyzer) {
|
|
634
|
+
if (time) {
|
|
635
|
+
this.analyzer.node.getByteTimeDomainData(
|
|
636
|
+
this.analyzer.data as Parameters<
|
|
637
|
+
AnalyserNode["getByteTimeDomainData"]
|
|
638
|
+
>[0],
|
|
639
|
+
);
|
|
640
|
+
} else {
|
|
641
|
+
this.analyzer.node.getByteFrequencyData(
|
|
642
|
+
this.analyzer.data as Parameters<
|
|
643
|
+
AnalyserNode["getByteFrequencyData"]
|
|
644
|
+
>[0],
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
return this.analyzer.data;
|
|
648
|
+
}
|
|
649
|
+
return new Uint8Array(0);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Map domain data to another range, reusing the Pts in `out` when provided
|
|
653
|
+
protected _domainTo(
|
|
654
|
+
time: boolean,
|
|
655
|
+
size: PtLike,
|
|
656
|
+
position: PtLike = [0, 0],
|
|
657
|
+
trim = [0, 0],
|
|
658
|
+
out?: Group,
|
|
659
|
+
): Group {
|
|
660
|
+
const data = time ? this.timeDomain() : this.freqDomain();
|
|
661
|
+
const g = out || new Group();
|
|
662
|
+
const len = data.length - trim[1];
|
|
663
|
+
const count = Math.max(0, len - trim[0]);
|
|
664
|
+
if (g.length > count) g.length = count;
|
|
665
|
+
for (let i = trim[0], j = 0; i < len; i++, j++) {
|
|
666
|
+
const x = position[0] + (size[0] * i) / len;
|
|
667
|
+
const y = position[1] + (size[1] * data[i]) / 255;
|
|
668
|
+
const p = g[j];
|
|
669
|
+
if (p && p.length >= 2) {
|
|
670
|
+
p[0] = x;
|
|
671
|
+
p[1] = y;
|
|
672
|
+
} else {
|
|
673
|
+
g[j] = new Pt(x, y);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return g;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Get the raw time-domain data from analyzer as unsigned 8-bit integers. An analyzer must be added before calling this function (See [analyze](#function_analyze) function).
|
|
681
|
+
*/
|
|
682
|
+
timeDomain(): Uint8Array {
|
|
683
|
+
return this._domain(true);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Map the time-domain data from analyzer to a range. An analyzer must be added before calling this function (See [analyze](#function_analyze) function).
|
|
688
|
+
* @param size map each data point `[index, value]` to `[width, height]`
|
|
689
|
+
* @param position Optionally, set a starting `[x, y]` position. Default is `[0, 0]`
|
|
690
|
+
* @param trim Optionally, trim the start and end values by `[startTrim, data.length-endTrim]`
|
|
691
|
+
* @param out Optionally, provide a `Group` (usually one returned by a previous call) whose Pts will be reused instead of allocating new ones — recommended when calling once per frame
|
|
692
|
+
* @returns a Group containing the mapped values
|
|
693
|
+
* @example form.point( s.timeDomainTo( space.size ) )
|
|
694
|
+
*/
|
|
695
|
+
timeDomainTo(
|
|
696
|
+
size: PtLike,
|
|
697
|
+
position: PtLike = [0, 0],
|
|
698
|
+
trim = [0, 0],
|
|
699
|
+
out?: Group,
|
|
700
|
+
): Group {
|
|
701
|
+
return this._domainTo(true, size, position, trim, out);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Get the raw frequency-domain data from analyzer as unsigned 8-bit integers. An analyzer must be added before calling this function (See [analyze](#function_analyze) function).
|
|
706
|
+
*/
|
|
707
|
+
freqDomain(): Uint8Array {
|
|
708
|
+
return this._domain(false);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Map the frequency-domain data from analyzer to a range. An analyzer must be added before calling this function (See [analyze](#function_analyze) function).
|
|
713
|
+
* @param size map each data point `[index, value]` to `[width, height]`
|
|
714
|
+
* @param position Optionally, set a starting `[x, y]` position. Default is `[0, 0]`
|
|
715
|
+
* @param trim Optionally, trim the start and end values by `[startTrim, data.length-endTrim]`
|
|
716
|
+
* @param out Optionally, provide a `Group` (usually one returned by a previous call) whose Pts will be reused instead of allocating new ones — recommended when calling once per frame
|
|
717
|
+
* @returns a Group containing the mapped values
|
|
718
|
+
* @example `form.point( s.freqDomainTo( space.size ) )`
|
|
719
|
+
*/
|
|
720
|
+
freqDomainTo(
|
|
721
|
+
size: PtLike,
|
|
722
|
+
position: PtLike = [0, 0],
|
|
723
|
+
trim = [0, 0],
|
|
724
|
+
out?: Group,
|
|
725
|
+
): Group {
|
|
726
|
+
return this._domainTo(false, size, position, trim, out);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Stop playing and disconnect the AudioNode.
|
|
731
|
+
*/
|
|
732
|
+
reset(): this {
|
|
733
|
+
this.stop();
|
|
734
|
+
if (this._node) this._node.disconnect();
|
|
735
|
+
if (this._outputNode) this._outputNode.disconnect();
|
|
736
|
+
return this;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Get the gain node for volume control, creating and connecting it on first use
|
|
740
|
+
protected _getGain(): GainNode {
|
|
741
|
+
if (!this._gain) {
|
|
742
|
+
this._gain = this._ctx.createGain();
|
|
743
|
+
this._gain.gain.value = this._volume;
|
|
744
|
+
this._gain.connect(this._ctx.destination);
|
|
745
|
+
}
|
|
746
|
+
return this._gain;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Start playing. Internally this connects the `AudioNode` to `AudioContext`'s destination.
|
|
751
|
+
* Calling `start( timeAt )` while a file or buffer sound is playing seeks to that time; a generated sound that is already playing is unaffected.
|
|
752
|
+
* @param timeAt optional parameter to play from a specific time, in seconds
|
|
753
|
+
*/
|
|
754
|
+
start(timeAt: number = 0): this {
|
|
755
|
+
if (this._ctx.state === "suspended") this._ctx.resume();
|
|
756
|
+
|
|
757
|
+
if (this._type === "file") {
|
|
758
|
+
if (this._buffer) {
|
|
759
|
+
// An AudioBufferSourceNode can only start once; re-create it for
|
|
760
|
+
// replay, seek-while-playing, or when only the buffer was assigned
|
|
761
|
+
if (this._playing || this._bufferPlayed || !this._node) {
|
|
762
|
+
if (this._playing && this.progress < 1) {
|
|
763
|
+
(this._node as AudioBufferSourceNode).stop();
|
|
764
|
+
}
|
|
765
|
+
this.createBuffer();
|
|
766
|
+
}
|
|
767
|
+
(this._node as AudioBufferSourceNode).start(0, timeAt);
|
|
768
|
+
this._bufferPlayed = true;
|
|
769
|
+
this._timestamp = this._ctx.currentTime - timeAt;
|
|
770
|
+
} else {
|
|
771
|
+
if (timeAt > 0) this._source.currentTime = timeAt;
|
|
772
|
+
const played = this._source.play();
|
|
773
|
+
if (played && played.catch) {
|
|
774
|
+
played.catch(() => {
|
|
775
|
+
// eg, autoplay was blocked — but only if a later start hasn't succeeded
|
|
776
|
+
if (this._source.paused) this._playing = false;
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
} else if (this._type === "gen" && this._generated) {
|
|
781
|
+
// restarting while playing would orphan the old oscillator, which keeps sounding
|
|
782
|
+
if (this._playing) return this;
|
|
783
|
+
const osc = this._node as OscillatorNode;
|
|
784
|
+
this._gen(
|
|
785
|
+
osc.type,
|
|
786
|
+
osc.type === "custom" ? this._wave : osc.frequency.value,
|
|
787
|
+
);
|
|
788
|
+
(this._node as OscillatorNode).start();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// restore analysis and filter chains; duplicate connects are no-ops
|
|
792
|
+
if (this.analyzer) this._node.connect(this.analyzer.node);
|
|
793
|
+
for (const n of this._connected) this._node.connect(n);
|
|
794
|
+
|
|
795
|
+
(this._outputNode || this._node).connect(this._getGain());
|
|
796
|
+
this._playing = true;
|
|
797
|
+
return this;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Stop playing. Internally this also disconnects the `AudioNode` from `AudioContext`'s destination. Calling `stop` when the sound is not playing has no effect.
|
|
802
|
+
*/
|
|
803
|
+
stop(): this {
|
|
804
|
+
if (!this._playing) return this;
|
|
805
|
+
(this._outputNode || this._node).disconnect(this._gain);
|
|
806
|
+
|
|
807
|
+
if (this._type === "file") {
|
|
808
|
+
if (this._buffer) {
|
|
809
|
+
// Safari throws InvalidState error if stop() is called after finished playing
|
|
810
|
+
if (this.progress < 1) (this._node as AudioBufferSourceNode).stop();
|
|
811
|
+
} else {
|
|
812
|
+
this._source.pause();
|
|
813
|
+
}
|
|
814
|
+
} else if (this._type === "gen") {
|
|
815
|
+
if (this._generated) (this._node as OscillatorNode).stop();
|
|
816
|
+
} else if (this._type === "input" && this._stream) {
|
|
817
|
+
// an input created from a node without its MediaStream has no tracks to stop
|
|
818
|
+
this._stream.getAudioTracks().forEach((track) => track.stop());
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
this._playing = false;
|
|
822
|
+
return this;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Toggle between `start` and `stop`.
|
|
827
|
+
*/
|
|
828
|
+
toggle(): this {
|
|
829
|
+
if (this._playing) {
|
|
830
|
+
this.stop();
|
|
831
|
+
} else {
|
|
832
|
+
this.start();
|
|
833
|
+
}
|
|
834
|
+
return this;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Stop playing and disconnect all nodes (including analyzer and volume), and release stream, source, and buffer references.
|
|
839
|
+
* The instance should not be used after calling this. Note that this never closes an `AudioContext`: the shared context lives for the page, and a context you provided is yours to close.
|
|
840
|
+
*/
|
|
841
|
+
dispose(): this {
|
|
842
|
+
// Input tracks are live as soon as they are acquired, even without start().
|
|
843
|
+
if (!this._playing && this._stream) {
|
|
844
|
+
this._stream.getAudioTracks().forEach((track) => track.stop());
|
|
845
|
+
}
|
|
846
|
+
this.reset();
|
|
847
|
+
if (this.analyzer) {
|
|
848
|
+
this.analyzer.node.disconnect();
|
|
849
|
+
this.analyzer = undefined!;
|
|
850
|
+
}
|
|
851
|
+
if (this._gain) {
|
|
852
|
+
this._gain.disconnect();
|
|
853
|
+
this._gain = undefined!;
|
|
854
|
+
}
|
|
855
|
+
this._connected = [];
|
|
856
|
+
this._stream = undefined!;
|
|
857
|
+
this._source = undefined!;
|
|
858
|
+
this._buffer = undefined!;
|
|
859
|
+
return this;
|
|
860
|
+
}
|
|
861
|
+
}
|