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/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
+ }