littlejsengine 1.18.29 → 1.20.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/FAQ.md +841 -815
- package/README.md +211 -199
- package/dist/littlejs.d.ts +4383 -812
- package/dist/littlejs.esm.js +31848 -16680
- package/dist/littlejs.esm.min.js +14 -1
- package/dist/littlejs.js +31230 -16166
- package/dist/littlejs.min.js +13 -1
- package/dist/littlejs.release.js +27419 -15457
- package/package.json +10 -5
- package/plugins/audioEffects.js +375 -0
- package/plugins/box2d.js +2516 -2073
- package/plugins/drawUtilities.js +206 -31
- package/plugins/gltf.js +740 -0
- package/plugins/jsconfig.json +19 -0
- package/plugins/lightSystem.js +719 -330
- package/plugins/math3d.js +911 -0
- package/plugins/medalSystem.js +118 -72
- package/plugins/newgrounds.js +400 -199
- package/plugins/pathFinder.js +208 -175
- package/plugins/pluginExport.js +196 -115
- package/plugins/postProcess.js +284 -166
- package/plugins/render3d.js +4072 -0
- package/plugins/render3dDebug.js +109 -0
- package/plugins/render3dExtras.js +1754 -0
- package/plugins/render3dVoxels.js +502 -0
- package/plugins/textureSheet.js +86 -48
- package/plugins/threejs.js +183 -158
- package/plugins/tweakables.js +411 -0
- package/plugins/tweenSystem.js +646 -526
- package/plugins/uiSystem.js +2024 -1687
- package/plugins/zzfxm.js +8 -15
- package/src/engine.js +345 -194
- package/src/engineAudio.js +1022 -777
- package/src/engineBuild.mjs +289 -253
- package/src/engineDebug.js +912 -735
- package/src/engineDraw.js +1774 -1510
- package/src/engineEditor.js +2431 -0
- package/src/engineExport.js +419 -396
- package/src/engineInput.js +1462 -1293
- package/src/engineLogo.js +4 -3
- package/src/engineMath.js +1274 -1183
- package/src/engineObject.js +674 -551
- package/src/engineParticles.js +583 -532
- package/src/engineRelease.js +67 -45
- package/src/engineSettings.js +808 -726
- package/src/engineTileLayer.js +995 -741
- package/src/engineUtilities.js +32 -13
- package/src/engineWebGL.js +1162 -941
- package/src/jsconfig.json +17 -13
- package/plugins/box2d.wasm.js +0 -630
- package/plugins/box2d.wasm.wasm +0 -0
package/src/engineAudio.js
CHANGED
|
@@ -1,778 +1,1023 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LittleJS Audio System
|
|
3
|
-
* - Play audio files (mp3, ogg, wave) and generate sounds with ZzFX
|
|
4
|
-
* - ZzFX sound generator integration: <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX</a>
|
|
5
|
-
* - Sound caching for fast playback and memory efficiency
|
|
6
|
-
* - Volume control with attenuation and stereo panning
|
|
7
|
-
* - 2D spatial audio based on camera position with distance-based falloff
|
|
8
|
-
* - Sound instance management (pause, resume, stop)
|
|
9
|
-
* - Speech synthesis for text-to-speech
|
|
10
|
-
* - Music playback with ZzFXM support
|
|
11
|
-
* - Web Audio API integration with master gain control
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* @
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* @
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
* -
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
*
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
{
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
{
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
{
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1
|
+
/**
|
|
2
|
+
* LittleJS Audio System
|
|
3
|
+
* - Play audio files (mp3, ogg, wave) and generate sounds with ZzFX
|
|
4
|
+
* - ZzFX sound generator integration: <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX</a>
|
|
5
|
+
* - Sound caching for fast playback and memory efficiency
|
|
6
|
+
* - Volume control with attenuation and stereo panning
|
|
7
|
+
* - 2D spatial audio based on camera position with distance-based falloff
|
|
8
|
+
* - Sound instance management (pause, resume, stop)
|
|
9
|
+
* - Speech synthesis for text-to-speech
|
|
10
|
+
* - Music playback with ZzFXM support
|
|
11
|
+
* - Web Audio API integration with master gain control
|
|
12
|
+
* - Sounds and the master bus can route through effects, see the audio effects plugin
|
|
13
|
+
* @namespace Audio
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
/** Audio context used by the engine, undefined outside a browser, where the engine runs headless
|
|
19
|
+
* @type {AudioContext}
|
|
20
|
+
* @memberof Audio */
|
|
21
|
+
let audioContext = typeof AudioContext == 'undefined' ? undefined : new AudioContext;
|
|
22
|
+
|
|
23
|
+
/** Master gain node for all audio to pass through, made at load so effects can connect to it any time
|
|
24
|
+
* @type {GainNode}
|
|
25
|
+
* @memberof Audio */
|
|
26
|
+
let audioMasterGain = audioContext?.createGain();
|
|
27
|
+
let audioMasterVolume; // the soundVolume the master gain was last set to
|
|
28
|
+
if (audioMasterGain)
|
|
29
|
+
{
|
|
30
|
+
audioMasterGain.connect(audioContext.destination);
|
|
31
|
+
audioMasterGain.gain.value = audioMasterVolume = soundVolume; // set starting value
|
|
32
|
+
audioContext.addEventListener?.('statechange', audioStateChange);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// soundVolume can be set directly, so the master gain follows it each frame
|
|
36
|
+
function audioUpdateVolume()
|
|
37
|
+
{
|
|
38
|
+
if (audioMasterGain && soundVolume !== audioMasterVolume)
|
|
39
|
+
audioMasterGain.gain.value = audioMasterVolume = soundVolume;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// the current master effect, kept so setAudioMasterEffect can undo the route it made,
|
|
43
|
+
// and whether its output came from an effect, which gets its default route back
|
|
44
|
+
let audioMasterEffectInput, audioMasterEffectOutput, audioMasterEffectOutputIsEffect;
|
|
45
|
+
|
|
46
|
+
/** Default sample rate used for sounds
|
|
47
|
+
* @default 44100
|
|
48
|
+
* @memberof Audio */
|
|
49
|
+
const audioDefaultSampleRate = 44100;
|
|
50
|
+
|
|
51
|
+
/** Check if the audio context is running and available for playback
|
|
52
|
+
* @return {boolean} - True if the audio context is running, false when there is none
|
|
53
|
+
* @memberof Audio */
|
|
54
|
+
function audioIsRunning()
|
|
55
|
+
{ return audioContext?.state === 'running'; }
|
|
56
|
+
|
|
57
|
+
function audioInit()
|
|
58
|
+
{
|
|
59
|
+
if (!soundEnable || headlessMode) return;
|
|
60
|
+
|
|
61
|
+
document.addEventListener('visibilitychange', audioVisibilityChange);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// a hidden page stops the game, so its sound stops too, and the audio clock with it so every sound picks up
|
|
65
|
+
// exactly where it was; only a suspend made here is undone, not one the browser holds until the first input
|
|
66
|
+
let audioSuspendedWhenHidden = false;
|
|
67
|
+
function audioVisibilityChange()
|
|
68
|
+
{
|
|
69
|
+
if (document.hidden)
|
|
70
|
+
{
|
|
71
|
+
if (!soundPauseWhenHidden || audioContext.state != 'running') return;
|
|
72
|
+
audioSuspendedWhenHidden = true;
|
|
73
|
+
audioContext.suspend();
|
|
74
|
+
}
|
|
75
|
+
else if (audioSuspendedWhenHidden)
|
|
76
|
+
{
|
|
77
|
+
audioSuspendedWhenHidden = false;
|
|
78
|
+
audioContext.resume();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// sound instances whose start failed only because the context was not running, like music started in gameInit
|
|
83
|
+
// before the first input, each with the time it tried; they start once the context runs unless paused or stopped
|
|
84
|
+
// first, and a one shot drops out once it would have ended anyway, so a backlog of sounds can't all play at once
|
|
85
|
+
const audioWaitingInstances = new Map;
|
|
86
|
+
function audioWaitingPrune(now=performance.now())
|
|
87
|
+
{
|
|
88
|
+
for (const [instance, startTime] of audioWaitingInstances)
|
|
89
|
+
{
|
|
90
|
+
const remaining = (instance.getDuration() - instance.pausedTime) / instance.rate;
|
|
91
|
+
if (!instance.loop && now - startTime > remaining * 1e3)
|
|
92
|
+
audioWaitingInstances.delete(instance);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function audioStateChange()
|
|
96
|
+
{
|
|
97
|
+
if (!audioIsRunning()) return;
|
|
98
|
+
audioWaitingPrune();
|
|
99
|
+
const instances = [...audioWaitingInstances.keys()];
|
|
100
|
+
audioWaitingInstances.clear();
|
|
101
|
+
for (const instance of instances)
|
|
102
|
+
instance.resume();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Anything with input and output audio nodes, like an effect from the audio effects plugin
|
|
106
|
+
* @typedef {{input: AudioNode, output: AudioNode}} AudioEffectNodes
|
|
107
|
+
* @memberof Audio */
|
|
108
|
+
|
|
109
|
+
/** Route all sound through an effect between the master gain and the speakers
|
|
110
|
+
* - Pass a node or an effect, or the first and last of a chain, each a node or an effect
|
|
111
|
+
* - With one argument a node is both ends, and an effect uses its own input and output
|
|
112
|
+
* - The output node is disconnected from everything else first, so it only feeds the speakers
|
|
113
|
+
* - The two ends of a chain must already be connected to each other, like effectA.connect(effectB)
|
|
114
|
+
* - Call with no arguments to remove the effect, an effect that was the master goes back to feeding the master gain
|
|
115
|
+
* - Debug video capture records the end of the master chain, but loses its tap if the effect changes mid-capture
|
|
116
|
+
* @param {AudioNode|AudioEffectNodes} [input] - Node or effect the master gain connects to
|
|
117
|
+
* @param {AudioNode|AudioEffectNodes} [output] - Node or effect that connects to the audio destination, defaults to the input's output
|
|
118
|
+
* @memberof Audio */
|
|
119
|
+
function setAudioMasterEffect(input, output)
|
|
120
|
+
{
|
|
121
|
+
// an effect stands in for its nodes, and a node is both ends when no output is passed
|
|
122
|
+
// (the output resolves first since its default comes from the input effect)
|
|
123
|
+
const outputArg = output || input;
|
|
124
|
+
const outputIsEffect = !!outputArg && 'input' in outputArg;
|
|
125
|
+
output = audioEffectNode(output, 'output') || audioEffectNode(input, 'output');
|
|
126
|
+
input = audioEffectNode(input, 'input');
|
|
127
|
+
ASSERT(!input || typeof input.connect === 'function', 'input must be an AudioNode or an effect with input and output nodes');
|
|
128
|
+
ASSERT(!output || typeof output.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes');
|
|
129
|
+
if (!audioMasterGain) return; // no audio outside a browser, where the engine runs headless
|
|
130
|
+
|
|
131
|
+
// undo the current route selectively so other taps survive; an effect's output that still feeds the speakers
|
|
132
|
+
// goes back to the master gain, its default, so it still works for sounds, but one that connect() already moved
|
|
133
|
+
// on, like the head of a longer chain being set now, stays where it was sent
|
|
134
|
+
audioMasterGain.disconnect(audioMasterEffectInput || audioContext.destination);
|
|
135
|
+
if (audioMasterEffectOutput)
|
|
136
|
+
{
|
|
137
|
+
let fedSpeakers = true;
|
|
138
|
+
try { audioMasterEffectOutput.disconnect(audioContext.destination); }
|
|
139
|
+
catch { fedSpeakers = false; }
|
|
140
|
+
if (audioMasterEffectOutputIsEffect && fedSpeakers)
|
|
141
|
+
audioMasterEffectOutput.connect(audioMasterGain);
|
|
142
|
+
}
|
|
143
|
+
audioMasterEffectInput = input;
|
|
144
|
+
audioMasterEffectOutput = output;
|
|
145
|
+
audioMasterEffectOutputIsEffect = outputIsEffect;
|
|
146
|
+
|
|
147
|
+
// connect the master gain to the speakers, through the effect if there is one
|
|
148
|
+
if (input)
|
|
149
|
+
{
|
|
150
|
+
audioMasterGain.connect(input);
|
|
151
|
+
output.disconnect();
|
|
152
|
+
output.connect(audioContext.destination);
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
audioMasterGain.connect(audioContext.destination);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// get one of an effect's nodes, or the thing itself when it is already a node
|
|
159
|
+
/** @param {AudioNode|AudioEffectNodes|undefined} effectOrNode
|
|
160
|
+
* @param {'input'|'output'} key
|
|
161
|
+
* @return {AudioNode}
|
|
162
|
+
* @ignore */
|
|
163
|
+
function audioEffectNode(effectOrNode, key)
|
|
164
|
+
{
|
|
165
|
+
if (effectOrNode && 'input' in effectOrNode)
|
|
166
|
+
return /** @type {AudioEffectNodes} */ (effectOrNode)[key];
|
|
167
|
+
return /** @type {AudioNode} */ (effectOrNode);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight;
|
|
171
|
+
// returns when the ramp ends
|
|
172
|
+
function audioParamRamp(param, value, fadeTime=0)
|
|
173
|
+
{
|
|
174
|
+
ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero');
|
|
175
|
+
const startTime = audioContext.currentTime;
|
|
176
|
+
param.cancelScheduledValues(startTime);
|
|
177
|
+
if (fadeTime)
|
|
178
|
+
{
|
|
179
|
+
param.setValueAtTime(param.value, startTime);
|
|
180
|
+
param.linearRampToValueAtTime(value, startTime + fadeTime);
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
param.value = value;
|
|
184
|
+
return startTime + fadeTime;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @callback SoundLoadCallback - Function called when sound is loaded
|
|
191
|
+
* @param {Sound} sound
|
|
192
|
+
* @memberof Audio
|
|
193
|
+
*/
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Sound Object - Stores a sound for later
|
|
197
|
+
* - this can be used to load and play wave, mp3, and ogg files
|
|
198
|
+
* - it can also create sounds using the ZzFX sound generator
|
|
199
|
+
* - can attenuate and apply stereo panning to sounds
|
|
200
|
+
* - sound instance control with pause/resume capability
|
|
201
|
+
*
|
|
202
|
+
* <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
|
|
203
|
+
* @memberof Audio
|
|
204
|
+
* @example
|
|
205
|
+
* // load an audio asset file
|
|
206
|
+
* const sound_example = new Sound('sound.mp3');
|
|
207
|
+
*
|
|
208
|
+
* // create a zzfx sound
|
|
209
|
+
* const sound_example = new Sound([.5,.5]);
|
|
210
|
+
*
|
|
211
|
+
* // play a sound
|
|
212
|
+
* sound_example.play();
|
|
213
|
+
*/
|
|
214
|
+
class Sound
|
|
215
|
+
{
|
|
216
|
+
/** Create a sound object and cache the audio for later use
|
|
217
|
+
* @param {string|URL|Array} [asset] - Filename or URL of an audio file, or a zzfx array
|
|
218
|
+
* @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds it overrides the array's own randomness, which is used if undefined
|
|
219
|
+
* @param {number} [range=soundDefaultRange] - World space max range of sound
|
|
220
|
+
* @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
|
|
221
|
+
* @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
|
|
222
|
+
*/
|
|
223
|
+
constructor(asset, randomness, range=soundDefaultRange, taper=soundDefaultTaper, onloadCallback)
|
|
224
|
+
{
|
|
225
|
+
if (!soundEnable || headlessMode) return;
|
|
226
|
+
|
|
227
|
+
ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
|
|
228
|
+
ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
|
|
229
|
+
ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
|
|
230
|
+
ASSERT(isNumber(range), 'range must be a number');
|
|
231
|
+
ASSERT(isNumber(taper), 'taper must be a number');
|
|
232
|
+
|
|
233
|
+
/** @property {number} - World space max range of sound */
|
|
234
|
+
this.range = range;
|
|
235
|
+
/** @property {number} - At what percentage of range should it start tapering */
|
|
236
|
+
this.taper = taper;
|
|
237
|
+
/** @property {number} - How much to randomize frequency each time sound plays
|
|
238
|
+
* @type {number} */
|
|
239
|
+
this.randomness = randomness ?? 0;
|
|
240
|
+
/** @property {number} - Sample rate for this sound */
|
|
241
|
+
this.sampleRate = audioDefaultSampleRate;
|
|
242
|
+
/** @property {number} - How many samples per channel this sound has */
|
|
243
|
+
this.sampleLength = 0;
|
|
244
|
+
/** @property {AudioBuffer|undefined} - Decoded audio shared by every play of this sound
|
|
245
|
+
* @type {AudioBuffer|undefined} */
|
|
246
|
+
this.sampleBuffer = undefined;
|
|
247
|
+
// internal, the 3D plugin reads it to know the sound has loaded
|
|
248
|
+
/** @ignore
|
|
249
|
+
* @type {Array<Array<number>|Float32Array>|undefined} */
|
|
250
|
+
this._sampleChannels = undefined;
|
|
251
|
+
/** @property {number} - Percentage of this sound currently loaded, sounds
|
|
252
|
+
* fetched from a url stay at 0 until decoding completes */
|
|
253
|
+
this.loadedPercent = 0;
|
|
254
|
+
/** @property {SoundLoadCallback|undefined} - function to call when sound is loaded
|
|
255
|
+
* @type {SoundLoadCallback|undefined} */
|
|
256
|
+
this.onloadCallback = onloadCallback;
|
|
257
|
+
/** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route every play of this sound through instead of the master gain
|
|
258
|
+
* - Where this sound's audio goes, unlike AudioEffect.output which is an effect's own node, effects chain with connect()
|
|
259
|
+
* @type {AudioNode|AudioEffectNodes|undefined} */
|
|
260
|
+
this.output = undefined;
|
|
261
|
+
|
|
262
|
+
if (isArray(asset))
|
|
263
|
+
{
|
|
264
|
+
// generate zzfx sound — copy so we don't mutate the caller's array
|
|
265
|
+
const zzfxSound = asset.slice();
|
|
266
|
+
|
|
267
|
+
// remove randomness so it can be applied on playback, a value passed in wins over the array's
|
|
268
|
+
const randomnessIndex = 1;
|
|
269
|
+
this.randomness = randomness ?? zzfxSound[randomnessIndex] ?? .05;
|
|
270
|
+
zzfxSound[randomnessIndex] = 0;
|
|
271
|
+
|
|
272
|
+
// generate the zzfx samples, then hand them to an audio buffer so
|
|
273
|
+
// the plain arrays can be released and every play shares the buffer
|
|
274
|
+
this.sampleChannels = [zzfxG(...zzfxSound)];
|
|
275
|
+
this.buildSampleBuffer();
|
|
276
|
+
this.loadedPercent = 1;
|
|
277
|
+
onloadCallback?.(this);
|
|
278
|
+
}
|
|
279
|
+
else if (asset)
|
|
280
|
+
{
|
|
281
|
+
// load the audio file, a URL object as bundlers give works like its string;
|
|
282
|
+
// report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
|
|
283
|
+
const filename = asset + '';
|
|
284
|
+
this.loadSound(filename).catch(e=>
|
|
285
|
+
LOG('Sound load failed for', filename, '-', e.message));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Sample data for each channel
|
|
290
|
+
* Sounds keep their samples in an audio buffer, so reading this rebuilds
|
|
291
|
+
* the arrays from it and caches them. The copies are safe to hold onto,
|
|
292
|
+
* playing a sound detaches the buffer's own channel arrays.
|
|
293
|
+
* @return {Array<Array<number>|Float32Array>|undefined} */
|
|
294
|
+
get sampleChannels()
|
|
295
|
+
{
|
|
296
|
+
const buffer = this.sampleBuffer;
|
|
297
|
+
if (!this._sampleChannels && buffer)
|
|
298
|
+
{
|
|
299
|
+
const channels = [];
|
|
300
|
+
for (let i = 0; i < buffer.numberOfChannels; i++)
|
|
301
|
+
channels.push(buffer.getChannelData(i).slice());
|
|
302
|
+
this._sampleChannels = channels;
|
|
303
|
+
}
|
|
304
|
+
return this._sampleChannels;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** @param {Array<Array<number>|Float32Array>} sampleChannels */
|
|
308
|
+
set sampleChannels(sampleChannels)
|
|
309
|
+
{
|
|
310
|
+
// new samples invalidate the buffer built from the old ones
|
|
311
|
+
this._sampleChannels = sampleChannels;
|
|
312
|
+
this.sampleBuffer = undefined;
|
|
313
|
+
this.sampleLength = sampleChannels?.[0]?.length || 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Move this sound's samples into an audio buffer that every play can share
|
|
317
|
+
* Does nothing if there is already a buffer or no samples to build one from */
|
|
318
|
+
buildSampleBuffer()
|
|
319
|
+
{
|
|
320
|
+
if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
|
|
321
|
+
|
|
322
|
+
this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
|
|
323
|
+
|
|
324
|
+
// the buffer owns the samples now, release the arrays we built it from
|
|
325
|
+
this._sampleChannels = undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Play the sound
|
|
329
|
+
* - Browsers hold audio until the first user input, a sound played before it returns a paused instance
|
|
330
|
+
* that starts on its own once audio runs, unless paused or stopped first; a one shot that would have
|
|
331
|
+
* ended by then is dropped, and only the newest play of each sound waits, so a sound played every frame
|
|
332
|
+
* starts once
|
|
333
|
+
* @param {Vector2} [pos] - World space position to play the sound if any
|
|
334
|
+
* @param {number} [volume] - How much to scale volume by
|
|
335
|
+
* @param {number} [pitch] - How much to scale pitch by
|
|
336
|
+
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
337
|
+
* @param {boolean} [loop] - Should the sound loop?
|
|
338
|
+
* @param {boolean} [paused] - Should the sound start paused
|
|
339
|
+
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
|
|
340
|
+
*/
|
|
341
|
+
play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
|
|
342
|
+
{
|
|
343
|
+
ASSERT(!pos || isVector2(pos), 'pos must be a vec2');
|
|
344
|
+
ASSERT(isNumber(volume), 'volume must be a number');
|
|
345
|
+
ASSERT(isNumber(pitch), 'pitch must be a number');
|
|
346
|
+
ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
|
|
347
|
+
|
|
348
|
+
if (!soundEnable || headlessMode) return;
|
|
349
|
+
if (!this.sampleBuffer && !this._sampleChannels) return;
|
|
350
|
+
|
|
351
|
+
let pan;
|
|
352
|
+
if (pos)
|
|
353
|
+
{
|
|
354
|
+
const range = this.range;
|
|
355
|
+
if (range)
|
|
356
|
+
{
|
|
357
|
+
// apply range based fade
|
|
358
|
+
const lengthSquared = cameraPos.distanceSquared(pos);
|
|
359
|
+
if (lengthSquared > range*range)
|
|
360
|
+
return; // out of range
|
|
361
|
+
|
|
362
|
+
// attenuate volume by distance, full volume out to the taper and a fade past it,
|
|
363
|
+
// so a taper of 1 plays at full volume right up to the range
|
|
364
|
+
const distance = lengthSquared**.5, taperRange = range*this.taper;
|
|
365
|
+
if (distance > taperRange)
|
|
366
|
+
volume *= percent(distance, range, taperRange);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// get pan from screen space coords
|
|
370
|
+
pan = worldToScreen(pos).x * 2/mainCanvasSize.x - 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Create sound instance
|
|
374
|
+
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
|
|
375
|
+
const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
|
|
376
|
+
|
|
377
|
+
if (debug && debugSound && pos)
|
|
378
|
+
{
|
|
379
|
+
// visualize where positioned sounds play and their falloff range
|
|
380
|
+
debugCircle(pos, .5, '#0ff', .5, true, false);
|
|
381
|
+
if (this.range)
|
|
382
|
+
{
|
|
383
|
+
debugCircle(pos, 2*this.range, '#0ff', .5, false, false); // silent radius
|
|
384
|
+
debugCircle(pos, 2*this.range*this.taper, '#0ff', .5, false, false); // full volume radius
|
|
385
|
+
}
|
|
386
|
+
debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5, 0, 'monospace', false);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return instance;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Play the sound on a loop, the same as play with loop on; stop or change it through the SoundInstance returned
|
|
393
|
+
* @param {Vector2} [pos] - World space position to play the sound if any
|
|
394
|
+
* @param {number} [volume] - How much to scale volume by
|
|
395
|
+
* @param {number} [pitch] - How much to scale pitch by
|
|
396
|
+
* @param {number} [randomnessScale] - How much to scale pitch randomness
|
|
397
|
+
* @param {boolean} [paused] - Should the sound start paused
|
|
398
|
+
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode */
|
|
399
|
+
playLoop(pos, volume=1, pitch=1, randomnessScale=1, paused=false)
|
|
400
|
+
{ return this.play(pos, volume, pitch, randomnessScale, true, paused); }
|
|
401
|
+
|
|
402
|
+
/** Play a music track that loops by default
|
|
403
|
+
* @param {number} [volume] - Volume to play the music at
|
|
404
|
+
* @param {boolean} [loop] - Should the music loop?
|
|
405
|
+
* @param {boolean} [paused] - Should the music start paused
|
|
406
|
+
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
|
|
407
|
+
*/
|
|
408
|
+
playMusic(volume=1, loop=true, paused=false)
|
|
409
|
+
{ return this.play(undefined, volume, 1, 0, loop, paused); }
|
|
410
|
+
|
|
411
|
+
/** Play the sound as a musical note with a semitone offset
|
|
412
|
+
* This can be used to play music with chromatic scales
|
|
413
|
+
* @param {number} [semitoneOffset] - How many semitones to offset pitch
|
|
414
|
+
* @param {Vector2} [pos] - World space position to play the sound if any
|
|
415
|
+
* @param {number} [volume=1] - How much to scale volume by
|
|
416
|
+
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
|
|
417
|
+
*/
|
|
418
|
+
playNote(semitoneOffset=0, pos, volume)
|
|
419
|
+
{
|
|
420
|
+
ASSERT(isNumber(semitoneOffset), 'semitoneOffset must be a number');
|
|
421
|
+
const pitch = getNoteFrequency(semitoneOffset, 1);
|
|
422
|
+
return this.play(pos, volume, pitch, 0);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Get how long this sound is in seconds
|
|
426
|
+
* @return {number} - How long the sound is in seconds (0 if loading)
|
|
427
|
+
*/
|
|
428
|
+
getDuration()
|
|
429
|
+
{ return this.sampleLength / this.sampleRate || 0; }
|
|
430
|
+
|
|
431
|
+
/** Check if sound is loaded, for sounds fetched from a url
|
|
432
|
+
* @return {boolean} - True if sound is loaded and ready to play
|
|
433
|
+
*/
|
|
434
|
+
isLoaded() { return this.loadedPercent === 1; }
|
|
435
|
+
|
|
436
|
+
/** Loads a sound from a URL and decodes it into sample data.
|
|
437
|
+
* @param {string} filename
|
|
438
|
+
* @return {Promise} */
|
|
439
|
+
async loadSound(filename)
|
|
440
|
+
{
|
|
441
|
+
const response = await fetch(filename);
|
|
442
|
+
if (!response.ok)
|
|
443
|
+
throw new Error(`Failed to load sound from ${filename}: ${response.status} ${response.statusText}`);
|
|
444
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
445
|
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
446
|
+
|
|
447
|
+
// keep the decoded buffer as is, it is exactly what playback needs and
|
|
448
|
+
// every play shares it, no channel data is read or copied
|
|
449
|
+
this.sampleRate = audioBuffer.sampleRate;
|
|
450
|
+
this.sampleLength = audioBuffer.length;
|
|
451
|
+
this.sampleBuffer = audioBuffer;
|
|
452
|
+
this._sampleChannels = undefined; // samples read from a sound loaded before are that sound's, not this one's
|
|
453
|
+
this.loadedPercent = 1;
|
|
454
|
+
this.onloadCallback?.(this);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Sound Instance - Wraps an AudioBufferSourceNode for individual sound control
|
|
462
|
+
* Represents a single playing instance of a sound with pause/resume capabilities
|
|
463
|
+
* @memberof Audio
|
|
464
|
+
* @example
|
|
465
|
+
* // Play a sound and get an instance for control
|
|
466
|
+
* const jumpSound = new Sound([.5,.5,220]);
|
|
467
|
+
* const instance = jumpSound.play();
|
|
468
|
+
*
|
|
469
|
+
* // Control the individual instance
|
|
470
|
+
* instance.setVolume(.5);
|
|
471
|
+
* instance.pause();
|
|
472
|
+
* instance.resume();
|
|
473
|
+
* instance.stop();
|
|
474
|
+
*/
|
|
475
|
+
class SoundInstance
|
|
476
|
+
{
|
|
477
|
+
/** Create a sound instance
|
|
478
|
+
* @param {Sound} sound - The sound object
|
|
479
|
+
* @param {number} [volume] - How much to scale volume by
|
|
480
|
+
* @param {number} [rate] - The playback rate to use
|
|
481
|
+
* @param {number} [pan] - How much to apply stereo panning
|
|
482
|
+
* @param {boolean} [loop] - Should the sound loop?
|
|
483
|
+
* @param {boolean} [paused] - Should the sound start paused? */
|
|
484
|
+
constructor(sound, volume=1, rate=1, pan=0, loop=false, paused=false)
|
|
485
|
+
{
|
|
486
|
+
ASSERT(sound instanceof Sound, 'SoundInstance requires a valid Sound object');
|
|
487
|
+
ASSERT(volume >= 0, 'Sound volume must be positive or zero');
|
|
488
|
+
ASSERT(rate >= 0, 'Sound rate must be positive or zero');
|
|
489
|
+
ASSERT(isNumber(pan), 'Sound pan must be a number');
|
|
490
|
+
|
|
491
|
+
/** @property {Sound} - The sound object */
|
|
492
|
+
this.sound = sound;
|
|
493
|
+
/** @property {number} - How much to scale volume by */
|
|
494
|
+
this.volume = volume;
|
|
495
|
+
/** @property {number} - The playback rate to use */
|
|
496
|
+
this.rate = rate;
|
|
497
|
+
/** @property {number} - How much to apply stereo panning */
|
|
498
|
+
this.pan = pan;
|
|
499
|
+
/** @property {boolean} - Should the sound loop */
|
|
500
|
+
this.loop = loop;
|
|
501
|
+
/** @property {number|undefined} - Where it is in the sound while not playing, in the sound's own seconds, undefined while playing
|
|
502
|
+
* @type {number|undefined} */
|
|
503
|
+
this.pausedTime = 0;
|
|
504
|
+
/** @property {number} - Audio context time its place was last taken at, while playing
|
|
505
|
+
* @type {number|undefined} */
|
|
506
|
+
this.startTime = undefined;
|
|
507
|
+
/** @property {number} - Where it was in the sound at startTime, in the sound's own seconds */
|
|
508
|
+
this.startOffset = 0;
|
|
509
|
+
/** @property {GainNode|undefined} - Gain node for the sound, undefined once it is stopped or paused
|
|
510
|
+
* @type {GainNode|undefined} */
|
|
511
|
+
this.gainNode = undefined;
|
|
512
|
+
/** @property {StereoPannerNode|undefined} - Stereo panner for the sound, undefined once it is stopped or paused
|
|
513
|
+
* @type {StereoPannerNode|undefined} */
|
|
514
|
+
this.pannerNode = undefined;
|
|
515
|
+
/** @property {AudioBufferSourceNode|undefined} - Source node of the audio, undefined while not playing
|
|
516
|
+
* @type {AudioBufferSourceNode|undefined} */
|
|
517
|
+
this.source = undefined;
|
|
518
|
+
/** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route this instance through, copied from the sound
|
|
519
|
+
* @type {AudioNode|AudioEffectNodes|undefined} */
|
|
520
|
+
this.output = sound.output;
|
|
521
|
+
/** @property {AudioEndedCallback|undefined} - Called when this instance plays to its end, not when it is stopped
|
|
522
|
+
* or paused; it is read when the sound ends, so it can be set at any time
|
|
523
|
+
* @type {AudioEndedCallback|undefined} */
|
|
524
|
+
this.onendedCallback = undefined;
|
|
525
|
+
/** A playback that ends on its own leaves the instance stopped, its time back at 0; the ended event of one
|
|
526
|
+
* stopped or replaced since is too late to change anything
|
|
527
|
+
* @private */
|
|
528
|
+
this.sourceEnded = (source)=>
|
|
529
|
+
{
|
|
530
|
+
if (source !== this.source) return;
|
|
531
|
+
this.source = this.gainNode = this.pannerNode = undefined;
|
|
532
|
+
this.startTime = undefined;
|
|
533
|
+
this.pausedTime = 0;
|
|
534
|
+
this.onendedCallback?.(source);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// start sound
|
|
538
|
+
if (!paused)
|
|
539
|
+
this.start();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Start playing the sound instance from a place in the sound
|
|
543
|
+
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
|
|
544
|
+
*/
|
|
545
|
+
start(offset=0)
|
|
546
|
+
{
|
|
547
|
+
ASSERT(offset >= 0, 'Sound start offset must be positive or zero');
|
|
548
|
+
if (this.isPlaying())
|
|
549
|
+
this.stop();
|
|
550
|
+
this.gainNode = audioContext.createGain();
|
|
551
|
+
this.pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(this.pan, -1, 1)});
|
|
552
|
+
|
|
553
|
+
// build the shared buffer if it was not made at load time, then play it
|
|
554
|
+
this.sound.buildSampleBuffer();
|
|
555
|
+
this.source = this.sound.sampleBuffer ?
|
|
556
|
+
playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode) :
|
|
557
|
+
playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode);
|
|
558
|
+
audioWaitingInstances.delete(this);
|
|
559
|
+
if (this.source)
|
|
560
|
+
{
|
|
561
|
+
this.startTime = audioContext.currentTime;
|
|
562
|
+
this.startOffset = offset;
|
|
563
|
+
this.pausedTime = undefined;
|
|
564
|
+
}
|
|
565
|
+
else
|
|
566
|
+
{
|
|
567
|
+
// the sound could not start, keep the place so a later resume picks it up,
|
|
568
|
+
// which happens on its own when it failed only because audio is not running yet
|
|
569
|
+
this.startTime = this.gainNode = this.pannerNode = undefined;
|
|
570
|
+
this.pausedTime = offset;
|
|
571
|
+
if (!audioIsRunning())
|
|
572
|
+
{
|
|
573
|
+
// only the newest of each sound waits, a loop a game plays again each frame is one loop
|
|
574
|
+
audioWaitingPrune();
|
|
575
|
+
for (const other of audioWaitingInstances.keys())
|
|
576
|
+
other.sound === this.sound && audioWaitingInstances.delete(other);
|
|
577
|
+
audioWaitingInstances.set(this, performance.now());
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Set the volume of this sound instance, with an optional fade to it
|
|
583
|
+
* - A fade ducks music under dialogue or cross fades two tracks without a click
|
|
584
|
+
* @param {number} volume
|
|
585
|
+
* @param {number} [fadeTime] - Seconds to fade to the new volume over */
|
|
586
|
+
setVolume(volume, fadeTime=0)
|
|
587
|
+
{
|
|
588
|
+
ASSERT(volume >= 0, 'Sound volume must be positive or zero');
|
|
589
|
+
ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
|
|
590
|
+
this.volume = volume;
|
|
591
|
+
this.gainNode && audioParamRamp(this.gainNode.gain, volume, fadeTime);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Set the stereo pan of this sound instance, while it plays too
|
|
595
|
+
* - A looping sound can follow its source across the screen this way
|
|
596
|
+
* @param {number} pan - -1 is left, 0 is center, 1 is right, clamped to that range */
|
|
597
|
+
setPan(pan)
|
|
598
|
+
{
|
|
599
|
+
ASSERT(isNumber(pan), 'Sound pan must be a number');
|
|
600
|
+
this.pan = pan;
|
|
601
|
+
if (this.pannerNode)
|
|
602
|
+
this.pannerNode.pan.value = clamp(pan, -1, 1);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Set the playback rate of this sound instance, its speed and pitch, while it plays
|
|
606
|
+
* - A looping sound can follow something smoothly this way, like an engine with the speed
|
|
607
|
+
* - A rate of 0 freezes the sound in place, and it carries on from there when the rate comes back
|
|
608
|
+
* @param {number} rate - 1 is normal, 2 is twice as fast and an octave up */
|
|
609
|
+
setRate(rate)
|
|
610
|
+
{
|
|
611
|
+
ASSERT(rate >= 0, 'Sound rate must be positive or zero');
|
|
612
|
+
// keep the place in the sound, only the speed changes from here
|
|
613
|
+
if (this.isPlaying())
|
|
614
|
+
{
|
|
615
|
+
this.startOffset = this.getCurrentTime();
|
|
616
|
+
this.startTime = audioContext.currentTime;
|
|
617
|
+
}
|
|
618
|
+
this.rate = rate;
|
|
619
|
+
if (this.source)
|
|
620
|
+
this.source.playbackRate.value = rate;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Stop this sound instance and reset position to the start
|
|
624
|
+
* @param {number} [fadeTime] - Seconds to fade out over before stopping */
|
|
625
|
+
stop(fadeTime=0)
|
|
626
|
+
{
|
|
627
|
+
ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
|
|
628
|
+
audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
|
|
629
|
+
if (this.isPlaying())
|
|
630
|
+
{
|
|
631
|
+
if (fadeTime)
|
|
632
|
+
{
|
|
633
|
+
// ramp off gain from where it is now (not 1, or low-volume
|
|
634
|
+
// instances would jump back up before fading, and a volume
|
|
635
|
+
// fade in flight carries on down from its current point);
|
|
636
|
+
// the ramp and the stop share one end time
|
|
637
|
+
this.source.stop(audioParamRamp(this.gainNode.gain, 0, fadeTime));
|
|
638
|
+
}
|
|
639
|
+
else
|
|
640
|
+
this.source.stop();
|
|
641
|
+
}
|
|
642
|
+
this.pausedTime = 0;
|
|
643
|
+
this.source = undefined;
|
|
644
|
+
this.startTime = undefined;
|
|
645
|
+
// let go of the gain node so a later setVolume can't cancel the fade out, the ended listener disconnects
|
|
646
|
+
// it, and start makes a new one
|
|
647
|
+
this.gainNode = undefined;
|
|
648
|
+
this.pannerNode = undefined;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Pause this sound instance */
|
|
652
|
+
pause()
|
|
653
|
+
{
|
|
654
|
+
audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
|
|
655
|
+
if (this.isPaused()) return;
|
|
656
|
+
|
|
657
|
+
// save current time and stop sound
|
|
658
|
+
this.pausedTime = this.getCurrentTime();
|
|
659
|
+
this.source.stop();
|
|
660
|
+
this.source = undefined;
|
|
661
|
+
this.startTime = undefined;
|
|
662
|
+
this.gainNode = undefined; // resume starts with a new one at the volume set meanwhile
|
|
663
|
+
this.pannerNode = undefined;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Resume this sound instance */
|
|
667
|
+
resume()
|
|
668
|
+
{
|
|
669
|
+
if (!this.isPaused()) return;
|
|
670
|
+
|
|
671
|
+
// restart sound from paused time
|
|
672
|
+
this.start(this.pausedTime);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Check if this instance is currently playing
|
|
676
|
+
* @return {boolean} - True if playing
|
|
677
|
+
*/
|
|
678
|
+
isPlaying() { return !!this.source; }
|
|
679
|
+
|
|
680
|
+
/** Check if this instance is paused or stopped (not currently playing)
|
|
681
|
+
* @return {boolean} - True if not playing
|
|
682
|
+
*/
|
|
683
|
+
isPaused() { return !this.isPlaying(); }
|
|
684
|
+
|
|
685
|
+
/** Get where it is in the sound, in the sound's own seconds: at a rate of 2 it moves two seconds for each one
|
|
686
|
+
* that passes, and at 0 it stays put
|
|
687
|
+
* @return {number} - Seconds into the sound
|
|
688
|
+
*/
|
|
689
|
+
getCurrentTime()
|
|
690
|
+
{
|
|
691
|
+
if (!this.isPlaying()) return this.pausedTime;
|
|
692
|
+
const duration = this.getDuration();
|
|
693
|
+
const place = this.startOffset + (audioContext.currentTime - this.startTime) * this.rate;
|
|
694
|
+
return duration ? mod(place, duration) : 0; // a sound still loading has no length yet
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Get the length of the sound in its own seconds, the same at any rate; divide by the rate for how long it takes to play
|
|
698
|
+
* @return {number} - Length in seconds (0 if loading)
|
|
699
|
+
*/
|
|
700
|
+
getDuration() { return this.sound.getDuration(); }
|
|
701
|
+
|
|
702
|
+
/** Get source of this sound instance
|
|
703
|
+
* @return {AudioBufferSourceNode|undefined} - The source, or undefined while not playing
|
|
704
|
+
*/
|
|
705
|
+
getSource() { return this.source; }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
709
|
+
|
|
710
|
+
/** Speak text with passed in settings
|
|
711
|
+
* @param {string} text - The text to speak
|
|
712
|
+
* @param {number} [volume] - How much to scale volume by
|
|
713
|
+
* @param {number} [rate] - How quickly to speak
|
|
714
|
+
* @param {number} [pitch] - How much to change the pitch by
|
|
715
|
+
* @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
|
|
716
|
+
* @return {SpeechSynthesisUtterance|undefined} - The utterance that was spoken, or undefined if speech is unavailable
|
|
717
|
+
* @memberof Audio */
|
|
718
|
+
function speak(text, volume=1, rate=1, pitch=1, language='')
|
|
719
|
+
{
|
|
720
|
+
ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
|
|
721
|
+
if (!soundEnable || headlessMode) return;
|
|
722
|
+
if (typeof speechSynthesis === 'undefined') return;
|
|
723
|
+
|
|
724
|
+
// common languages (not supported by all browsers)
|
|
725
|
+
// en - english, it - italian, fr - french, de - german, es - spanish
|
|
726
|
+
// ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean
|
|
727
|
+
|
|
728
|
+
// build utterance and speak
|
|
729
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
730
|
+
utterance.lang = language;
|
|
731
|
+
utterance.volume = clamp(volume*soundVolume);
|
|
732
|
+
utterance.rate = rate;
|
|
733
|
+
utterance.pitch = pitch;
|
|
734
|
+
speechSynthesis.speak(utterance);
|
|
735
|
+
return utterance;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Stop all queued speech
|
|
739
|
+
* @memberof Audio */
|
|
740
|
+
function speakStop()
|
|
741
|
+
{
|
|
742
|
+
if (typeof speechSynthesis !== 'undefined')
|
|
743
|
+
speechSynthesis.cancel();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Get frequency of a note on a musical scale
|
|
747
|
+
* @param {number} semitoneOffset - How many semitones away from the root note
|
|
748
|
+
* @param {number} [rootFrequency] - Frequency at semitone offset 0
|
|
749
|
+
* @return {number} - The frequency of the note
|
|
750
|
+
* @memberof Audio */
|
|
751
|
+
function getNoteFrequency(semitoneOffset, rootFrequency=220)
|
|
752
|
+
{ return rootFrequency * 2**(semitoneOffset/12); }
|
|
753
|
+
|
|
754
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* @callback AudioEndedCallback - Function called when a sound ends
|
|
758
|
+
* @param {AudioBufferSourceNode} source
|
|
759
|
+
* @memberof Audio
|
|
760
|
+
*/
|
|
761
|
+
|
|
762
|
+
/** Play cached audio samples with given settings
|
|
763
|
+
* @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback)
|
|
764
|
+
* @param {number} [volume] - How much to scale volume by
|
|
765
|
+
* @param {number} [rate] - The playback rate to use
|
|
766
|
+
* @param {number} [pan] - How much to apply stereo panning
|
|
767
|
+
* @param {boolean} [loop] - True if the sound should loop when it reaches the end
|
|
768
|
+
* @param {number} [sampleRate=44100] - Sample rate for the sound
|
|
769
|
+
* @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
|
|
770
|
+
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
|
|
771
|
+
* @param {AudioEndedCallback} [onended] - Callback for when the sound ends
|
|
772
|
+
* @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
|
|
773
|
+
* @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
|
|
774
|
+
* @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
|
|
775
|
+
* @memberof Audio */
|
|
776
|
+
function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended, output, pannerNode)
|
|
777
|
+
{
|
|
778
|
+
if (!soundEnable || headlessMode) return;
|
|
779
|
+
|
|
780
|
+
if (!audioIsRunning())
|
|
781
|
+
{
|
|
782
|
+
// fix stalled audio, don't build a buffer that can't be played;
|
|
783
|
+
// but a context suspended because the page is hidden stays suspended until it shows
|
|
784
|
+
if (!audioSuspendedWhenHidden)
|
|
785
|
+
audioContext.resume();
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const buffer = createAudioBuffer(sampleChannels, sampleRate);
|
|
790
|
+
return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended, output, pannerNode);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Copy arrays of samples into a new audio buffer
|
|
794
|
+
* @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
|
|
795
|
+
* @param {number} [sampleRate=44100] - Sample rate for the sound
|
|
796
|
+
* @return {AudioBuffer} - The audio buffer holding the samples
|
|
797
|
+
* @memberof Audio */
|
|
798
|
+
function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
|
|
799
|
+
{
|
|
800
|
+
const channelCount = sampleChannels.length;
|
|
801
|
+
const sampleLength = sampleChannels[0].length;
|
|
802
|
+
const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
|
|
803
|
+
sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
|
|
804
|
+
return buffer;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Play an audio buffer with given settings
|
|
808
|
+
* The buffer can be shared by any number of sounds playing at once
|
|
809
|
+
* @param {AudioBuffer} buffer - The audio buffer to play
|
|
810
|
+
* @param {number} [volume] - How much to scale volume by
|
|
811
|
+
* @param {number} [rate] - The playback rate to use
|
|
812
|
+
* @param {number} [pan] - How much to apply stereo panning
|
|
813
|
+
* @param {boolean} [loop] - True if the sound should loop when it reaches the end
|
|
814
|
+
* @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
|
|
815
|
+
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
|
|
816
|
+
* @param {AudioEndedCallback} [onended] - Callback for when the sound ends
|
|
817
|
+
* @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
|
|
818
|
+
* @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
|
|
819
|
+
* @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
|
|
820
|
+
* @memberof Audio */
|
|
821
|
+
function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended, output, pannerNode)
|
|
822
|
+
{
|
|
823
|
+
if (!soundEnable || headlessMode) return;
|
|
824
|
+
|
|
825
|
+
if (!audioIsRunning())
|
|
826
|
+
{
|
|
827
|
+
// fix stalled audio, this sound won't be able to play;
|
|
828
|
+
// but a context suspended because the page is hidden stays suspended until it shows
|
|
829
|
+
if (!audioSuspendedWhenHidden)
|
|
830
|
+
audioContext.resume();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// setup source, many sources can share one buffer
|
|
835
|
+
const source = audioContext.createBufferSource();
|
|
836
|
+
source.buffer = buffer;
|
|
837
|
+
source.playbackRate.value = rate;
|
|
838
|
+
source.loop = loop;
|
|
839
|
+
|
|
840
|
+
// create and connect gain node
|
|
841
|
+
gainNode = gainNode || audioContext.createGain();
|
|
842
|
+
gainNode.gain.value = volume;
|
|
843
|
+
const outputNode = audioEffectNode(output, 'input') || audioMasterGain;
|
|
844
|
+
ASSERT(typeof outputNode.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes');
|
|
845
|
+
gainNode.connect(outputNode);
|
|
846
|
+
|
|
847
|
+
// connect source to stereo panner and gain
|
|
848
|
+
const panner = pannerNode || new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
|
|
849
|
+
source.connect(panner).connect(gainNode);
|
|
850
|
+
|
|
851
|
+
// disconnect nodes when the sound ends so the audio graph doesn't grow
|
|
852
|
+
// unbounded across many play() calls (source.stop() also fires 'ended')
|
|
853
|
+
source.addEventListener('ended', ()=>
|
|
854
|
+
{
|
|
855
|
+
gainNode.disconnect();
|
|
856
|
+
panner.disconnect();
|
|
857
|
+
if (onended) onended(source);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// play and return sound, the offset is a place in the buffer whatever the rate
|
|
861
|
+
source.start(0, offset);
|
|
862
|
+
|
|
863
|
+
if (debug && debugSound)
|
|
864
|
+
LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
|
|
865
|
+
|
|
866
|
+
return source;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
870
|
+
// ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
|
|
871
|
+
|
|
872
|
+
/** Generate and play a ZzFX sound
|
|
873
|
+
*
|
|
874
|
+
* <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
|
|
875
|
+
* @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5]
|
|
876
|
+
* @return {AudioBufferSourceNode|undefined} - The audio node of the sound played, undefined if play fails
|
|
877
|
+
* @memberof Audio */
|
|
878
|
+
function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
|
|
879
|
+
|
|
880
|
+
/** Generate samples for a ZzFX sound
|
|
881
|
+
* @param {number} [volume] - Volume scale (percent)
|
|
882
|
+
* @param {number} [randomness] - How much to randomize frequency (percent Hz)
|
|
883
|
+
* @param {number} [frequency] - Frequency of sound (Hz)
|
|
884
|
+
* @param {number} [attack] - Attack time, how fast sound starts (seconds)
|
|
885
|
+
* @param {number} [sustain] - Sustain time, how long sound holds (seconds)
|
|
886
|
+
* @param {number} [release] - Release time, how fast sound fades out (seconds)
|
|
887
|
+
* @param {number} [shape] - Shape of the sound wave
|
|
888
|
+
* @param {number} [shapeCurve] - Squareness of wave (0=square, 1=normal, 2=pointy)
|
|
889
|
+
* @param {number} [slide] - How much to slide frequency (kHz/s)
|
|
890
|
+
* @param {number} [deltaSlide] - How much to change slide (kHz/s/s)
|
|
891
|
+
* @param {number} [pitchJump] - Frequency of pitch jump (Hz)
|
|
892
|
+
* @param {number} [pitchJumpTime] - Time of pitch jump (seconds)
|
|
893
|
+
* @param {number} [repeatTime] - Resets some parameters periodically (seconds)
|
|
894
|
+
* @param {number} [noise] - How much random noise to add (percent)
|
|
895
|
+
* @param {number} [modulation] - Frequency of modulation wave, negative flips phase (Hz)
|
|
896
|
+
* @param {number} [bitCrush] - Resamples at a lower frequency in (samples*100)
|
|
897
|
+
* @param {number} [delay] - Overlap sound with itself for reverb and flanger effects (seconds)
|
|
898
|
+
* @param {number} [sustainVolume] - Volume level for sustain (percent)
|
|
899
|
+
* @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
|
|
900
|
+
* @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
|
|
901
|
+
* @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
|
|
902
|
+
* @return {Array} - Array of audio samples
|
|
903
|
+
* @memberof Audio */
|
|
904
|
+
function zzfxG
|
|
905
|
+
(
|
|
906
|
+
volume = 1,
|
|
907
|
+
randomness = .05,
|
|
908
|
+
frequency = 220,
|
|
909
|
+
attack = 0,
|
|
910
|
+
sustain = 0,
|
|
911
|
+
release = .1,
|
|
912
|
+
shape = 0,
|
|
913
|
+
shapeCurve = 1,
|
|
914
|
+
slide = 0,
|
|
915
|
+
deltaSlide = 0,
|
|
916
|
+
pitchJump = 0,
|
|
917
|
+
pitchJumpTime = 0,
|
|
918
|
+
repeatTime = 0,
|
|
919
|
+
noise = 0,
|
|
920
|
+
modulation = 0,
|
|
921
|
+
bitCrush = 0,
|
|
922
|
+
delay = 0,
|
|
923
|
+
sustainVolume = 1,
|
|
924
|
+
decay = 0,
|
|
925
|
+
tremolo = 0,
|
|
926
|
+
filter = 0
|
|
927
|
+
)
|
|
928
|
+
{
|
|
929
|
+
// init parameters
|
|
930
|
+
let sampleRate = audioDefaultSampleRate,
|
|
931
|
+
PI2 = PI*2,
|
|
932
|
+
startSlide = slide *= 500 * PI2 / sampleRate / sampleRate,
|
|
933
|
+
startFrequency = frequency *=
|
|
934
|
+
(1 + rand(randomness,-randomness)) * PI2 / sampleRate,
|
|
935
|
+
modOffset = 0, // modulation offset
|
|
936
|
+
repeat = 0, // repeat offset
|
|
937
|
+
crush = 0, // bit crush offset
|
|
938
|
+
jump = 1, // pitch jump timer
|
|
939
|
+
length, // sample length
|
|
940
|
+
b = [], // sample buffer
|
|
941
|
+
t = 0, // sample time
|
|
942
|
+
i = 0, // sample index
|
|
943
|
+
s = 0, // sample value
|
|
944
|
+
f, // wave frequency
|
|
945
|
+
|
|
946
|
+
// biquad LP/HP filter
|
|
947
|
+
quality = 2, w = PI2 * min(abs(filter), sampleRate/4 - 1) * 2 / sampleRate, // stable below a quarter rate
|
|
948
|
+
cosw = cos(w), alpha = sin(w) / 2 / quality,
|
|
949
|
+
a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0,
|
|
950
|
+
b0 = (1 + sign(filter) * cosw) / 2 / a0,
|
|
951
|
+
b1 = -(sign(filter) + cosw) / a0, b2 = b0,
|
|
952
|
+
x2 = 0, x1 = 0, y2 = 0, y1 = 0;
|
|
953
|
+
|
|
954
|
+
// scale by sample rate
|
|
955
|
+
const minAttack = 9; // prevent pop if attack is 0
|
|
956
|
+
attack = attack * sampleRate || minAttack;
|
|
957
|
+
decay *= sampleRate;
|
|
958
|
+
sustain *= sampleRate;
|
|
959
|
+
release *= sampleRate;
|
|
960
|
+
delay *= sampleRate;
|
|
961
|
+
deltaSlide *= 500 * PI2 / sampleRate**3;
|
|
962
|
+
modulation *= PI2 / sampleRate;
|
|
963
|
+
pitchJump *= PI2 / sampleRate;
|
|
964
|
+
pitchJumpTime *= sampleRate;
|
|
965
|
+
repeatTime = repeatTime * sampleRate | 0;
|
|
966
|
+
|
|
967
|
+
// generate waveform
|
|
968
|
+
for (length = attack + decay + sustain + release + delay | 0;
|
|
969
|
+
i < length; b[i++] = s * volume) // sample
|
|
970
|
+
{
|
|
971
|
+
if (!(++crush%(bitCrush*100|0))) // bit crush
|
|
972
|
+
{
|
|
973
|
+
s = shape? shape>1? shape>2? shape>3? shape>4? // wave shape
|
|
974
|
+
(t/PI2%1 < shapeCurve/2? 1 : -1) : // 5 square duty
|
|
975
|
+
sin(t**3) : // 4 noise
|
|
976
|
+
max(min(tan(t),1),-1): // 3 tan
|
|
977
|
+
1-(2*t/PI2%2+2)%2: // 2 saw
|
|
978
|
+
1-4*abs(round(t/PI2)-t/PI2): // 1 triangle
|
|
979
|
+
sin(t); // 0 sin
|
|
980
|
+
|
|
981
|
+
s = (repeatTime ?
|
|
982
|
+
1 - tremolo + tremolo*sin(PI2*i/repeatTime) // tremolo
|
|
983
|
+
: 1) *
|
|
984
|
+
(shape>4?s:sign(s)*abs(s)**shapeCurve) * // shape curve
|
|
985
|
+
(i < attack ? i/attack : // attack
|
|
986
|
+
i < attack + decay ? // decay
|
|
987
|
+
1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff
|
|
988
|
+
i < attack + decay + sustain ? // sustain
|
|
989
|
+
sustainVolume : // sustain volume
|
|
990
|
+
i < length - delay ? // release
|
|
991
|
+
(length - i - delay)/release * // release falloff
|
|
992
|
+
sustainVolume : // release volume
|
|
993
|
+
0); // post release
|
|
994
|
+
|
|
995
|
+
s = delay ? s/2 + (delay > i ? 0 : // delay
|
|
996
|
+
(i<length-delay? 1 : (length-i)/delay) * // release delay
|
|
997
|
+
b[i-delay|0]/2/(volume||1)) : s; // sample delay, stored samples are 0 at volume 0
|
|
998
|
+
|
|
999
|
+
if (filter) // apply filter
|
|
1000
|
+
s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
f = (frequency += slide += deltaSlide) *// frequency
|
|
1004
|
+
cos(modulation*modOffset++); // modulation
|
|
1005
|
+
t += f + f*noise*sin(i**5); // noise
|
|
1006
|
+
|
|
1007
|
+
if (jump && ++jump > pitchJumpTime) // pitch jump
|
|
1008
|
+
{
|
|
1009
|
+
frequency += pitchJump; // apply pitch jump
|
|
1010
|
+
startFrequency += pitchJump; // also apply to start
|
|
1011
|
+
jump = 0; // stop pitch jump time
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (repeatTime && !(++repeat % repeatTime)) // repeat
|
|
1015
|
+
{
|
|
1016
|
+
frequency = startFrequency; // reset frequency
|
|
1017
|
+
slide = startSlide; // reset slide
|
|
1018
|
+
jump ||= 1; // reset pitch jump time
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return b; // return sample buffer
|
|
778
1023
|
}
|