cacophony 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +14 -2
- package/src/cacophony.test.ts +23 -0
- package/src/cacophony.ts +247 -11
package/README.md
CHANGED
|
@@ -86,9 +86,10 @@ Resumes audio by restoring the global volume to its preceding value.
|
|
|
86
86
|
|
|
87
87
|
### Shared Methods Across Sound Sources
|
|
88
88
|
|
|
89
|
-
All classes representing sound sources (`Sound`, `Playback`, `Group`) offer the following methods for a consistent interface and user-friendly experience:
|
|
89
|
+
All classes representing sound sources (`Sound`, `Playback`, `Group`) and the `BaseSound` interface offer the following methods for a consistent interface and user-friendly experience:
|
|
90
90
|
|
|
91
91
|
- `play()`
|
|
92
|
+
- `seek(time: number)`: Seeks the current playback to the specified time in seconds.
|
|
92
93
|
- `stop()`
|
|
93
94
|
- `pause()`
|
|
94
95
|
- `resume()`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cacophony",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Typescript audio library with caching",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"build": "npm run clean && tsc --build",
|
|
9
9
|
"clean": "tsc --build --clean",
|
|
10
10
|
"prepublishOnly": "npm run build",
|
|
11
|
-
"test": "
|
|
11
|
+
"test": "jest",
|
|
12
|
+
"ci:test": "jest --no-color --ci"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
14
15
|
"audio",
|
|
@@ -16,10 +17,21 @@
|
|
|
16
17
|
],
|
|
17
18
|
"author": "Christopher Toth",
|
|
18
19
|
"license": "ISC",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/ctoth/cacophony.git"
|
|
23
|
+
},
|
|
19
24
|
"devDependencies": {
|
|
25
|
+
"@types/jest": "^29.5.8",
|
|
26
|
+
"jest": "^29.7.0",
|
|
20
27
|
"standardized-audio-context-mock": "^9.6.29",
|
|
28
|
+
"ts-jest": "^29.1.1",
|
|
21
29
|
"typescript": "^5.1.6"
|
|
22
30
|
},
|
|
31
|
+
"jest": {
|
|
32
|
+
"preset": "ts-jest",
|
|
33
|
+
"testEnvironment": "node"
|
|
34
|
+
},
|
|
23
35
|
"dependencies": {
|
|
24
36
|
"standardized-audio-context": "^25.3.55"
|
|
25
37
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Cacophony } from './cacophony';
|
|
2
|
+
import { AudioContext } from 'standardized-audio-context-mock';
|
|
3
|
+
|
|
4
|
+
let cacophony: Cacophony;
|
|
5
|
+
let audioContextMock: AudioContext;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
audioContextMock = new AudioContext();
|
|
9
|
+
cacophony = new Cacophony(audioContextMock);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
audioContextMock.close();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('Cacophony is created with the correct context', () => {
|
|
17
|
+
expect(cacophony.context).toBe(audioContextMock);
|
|
18
|
+
});
|
|
19
|
+
test('createSound creates a sound with the correct buffer', async () => {
|
|
20
|
+
const buffer = new AudioBuffer({ length: 100, sampleRate: 44100 });
|
|
21
|
+
const sound = await cacophony.createSound(buffer);
|
|
22
|
+
expect(sound.buffer).toBe(buffer);
|
|
23
|
+
});
|
package/src/cacophony.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AudioContext, IAudioBuffer, IAudioBufferSourceNode, IAudioListener, IBiquadFilterNode, IGainNode, IPannerNode } from 'standardized-audio-context';
|
|
1
|
+
import { AudioContext, IAudioBuffer, IAudioBufferSourceNode, IAudioListener, IBiquadFilterNode, IGainNode, IMediaStreamAudioSourceNode, IPannerNode } from 'standardized-audio-context';
|
|
2
2
|
import { CacheManager } from './cache';
|
|
3
3
|
|
|
4
4
|
|
|
@@ -7,6 +7,7 @@ type BiquadFilterNode = IBiquadFilterNode<AudioContext>;
|
|
|
7
7
|
|
|
8
8
|
type AudioBufferSourceNode = IAudioBufferSourceNode<AudioContext>;
|
|
9
9
|
type PannerNode = IPannerNode<AudioContext>;
|
|
10
|
+
type MediaStreamAudioSourceNode = IMediaStreamAudioSourceNode<AudioContext>;
|
|
10
11
|
|
|
11
12
|
export type Position = [number, number, number];
|
|
12
13
|
|
|
@@ -16,7 +17,8 @@ export type FadeType = 'linear' | 'exponential'
|
|
|
16
17
|
|
|
17
18
|
export interface BaseSound {
|
|
18
19
|
// the stuff you should be able to do with anything that makes sound including groups, sounds, and playbacks.
|
|
19
|
-
play():
|
|
20
|
+
play(): BaseSound[];
|
|
21
|
+
seek?(time: number): void;
|
|
20
22
|
stop(): void;
|
|
21
23
|
pause(): void;
|
|
22
24
|
resume(): void;
|
|
@@ -24,7 +26,7 @@ export interface BaseSound {
|
|
|
24
26
|
removeFilter(filter: BiquadFilterNode): void;
|
|
25
27
|
volume: number;
|
|
26
28
|
position: Position;
|
|
27
|
-
loop(loopCount?: LoopCount): LoopCount;
|
|
29
|
+
loop?(loopCount?: LoopCount): LoopCount;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export class Cacophony {
|
|
@@ -110,6 +112,20 @@ export class Cacophony {
|
|
|
110
112
|
this.setGlobalVolume(this.prevVolume);
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
getMicrophoneStream(): Promise<MicrophoneStream> {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
navigator.mediaDevices.getUserMedia({ audio: true })
|
|
118
|
+
.then(stream => {
|
|
119
|
+
const microphoneStream = new MicrophoneStream(this.context);
|
|
120
|
+
microphoneStream.play();
|
|
121
|
+
resolve(microphoneStream);
|
|
122
|
+
})
|
|
123
|
+
.catch(err => {
|
|
124
|
+
reject(err);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
|
|
@@ -138,10 +154,14 @@ export class Sound extends FilterManager implements BaseSound {
|
|
|
138
154
|
buffer: IAudioBuffer;
|
|
139
155
|
context: AudioContext;
|
|
140
156
|
playbacks: Playback[] = [];
|
|
141
|
-
globalGainNode: GainNode;
|
|
157
|
+
private globalGainNode: GainNode;
|
|
142
158
|
private _position: Position = [0, 0, 0];
|
|
143
159
|
loopCount: LoopCount = 0;
|
|
144
160
|
|
|
161
|
+
seek(time: number): void {
|
|
162
|
+
this.playbacks.forEach(playback => playback.seek(time));
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
constructor(buffer: AudioBuffer, context: AudioContext, globalGainNode: IGainNode<AudioContext>) {
|
|
146
166
|
super();
|
|
147
167
|
this.buffer = buffer;
|
|
@@ -165,7 +185,7 @@ export class Sound extends FilterManager implements BaseSound {
|
|
|
165
185
|
|
|
166
186
|
play(): Playback[] {
|
|
167
187
|
const playback = this.preplay();
|
|
168
|
-
playback.forEach(p => p.
|
|
188
|
+
playback.forEach(p => p.play());
|
|
169
189
|
return playback;
|
|
170
190
|
}
|
|
171
191
|
|
|
@@ -199,7 +219,7 @@ export class Sound extends FilterManager implements BaseSound {
|
|
|
199
219
|
return this.loopCount;
|
|
200
220
|
}
|
|
201
221
|
this.loopCount = loopCount;
|
|
202
|
-
this.playbacks.forEach(p => p.
|
|
222
|
+
this.playbacks.forEach(p => p.sourceLoop = true);
|
|
203
223
|
return this.loopCount;
|
|
204
224
|
}
|
|
205
225
|
|
|
@@ -224,13 +244,27 @@ export class Sound extends FilterManager implements BaseSound {
|
|
|
224
244
|
}
|
|
225
245
|
|
|
226
246
|
class Playback extends FilterManager implements BaseSound {
|
|
227
|
-
context: AudioContext;
|
|
228
|
-
source?: AudioBufferSourceNode;
|
|
229
|
-
gainNode?: GainNode;
|
|
230
|
-
panner?: PannerNode;
|
|
247
|
+
private context: AudioContext;
|
|
248
|
+
private source?: AudioBufferSourceNode;
|
|
249
|
+
private gainNode?: GainNode;
|
|
250
|
+
private panner?: PannerNode;
|
|
231
251
|
loopCount: LoopCount = 0;
|
|
232
252
|
currentLoop: number = 0;
|
|
233
|
-
buffer: IAudioBuffer | null = null;
|
|
253
|
+
private buffer: IAudioBuffer | null = null;
|
|
254
|
+
|
|
255
|
+
seek(time: number): void {
|
|
256
|
+
if (!this.source || !this.buffer || !this.gainNode || !this.panner) {
|
|
257
|
+
throw new Error('Cannot seek a sound that has been cleaned up');
|
|
258
|
+
}
|
|
259
|
+
// Stop the current playback
|
|
260
|
+
this.source.stop();
|
|
261
|
+
// Create a new source to start from the desired time
|
|
262
|
+
this.source = this.context.createBufferSource();
|
|
263
|
+
this.source.buffer = this.buffer;
|
|
264
|
+
this.refreshFilters();
|
|
265
|
+
this.source.connect(this.panner).connect(this.gainNode);
|
|
266
|
+
this.source.start(0, time);
|
|
267
|
+
}
|
|
234
268
|
|
|
235
269
|
constructor(source: AudioBufferSourceNode, gainNode: GainNode, context: AudioContext, loopCount: LoopCount = 0) {
|
|
236
270
|
super();
|
|
@@ -277,6 +311,13 @@ class Playback extends FilterManager implements BaseSound {
|
|
|
277
311
|
this.gainNode.gain.value = v;
|
|
278
312
|
}
|
|
279
313
|
|
|
314
|
+
set sourceLoop(loop: boolean) {
|
|
315
|
+
if (!this.source) {
|
|
316
|
+
throw new Error('Cannot set loop on a sound that has been cleaned up');
|
|
317
|
+
}
|
|
318
|
+
this.source.loop = loop;
|
|
319
|
+
}
|
|
320
|
+
|
|
280
321
|
fadeIn(time: number, fadeType: FadeType = 'linear'): Promise<void> {
|
|
281
322
|
return new Promise(resolve => {
|
|
282
323
|
if (!this.gainNode) {
|
|
@@ -429,6 +470,10 @@ export class Group implements BaseSound {
|
|
|
429
470
|
private _position: Position = [0, 0, 0];
|
|
430
471
|
loopCount: LoopCount = 0;
|
|
431
472
|
|
|
473
|
+
seek(time: number): void {
|
|
474
|
+
this.sounds.forEach(sound => sound.seek(time));
|
|
475
|
+
}
|
|
476
|
+
|
|
432
477
|
addSound(sound: Sound): void {
|
|
433
478
|
this.sounds.push(sound);
|
|
434
479
|
}
|
|
@@ -498,3 +543,194 @@ export class Group implements BaseSound {
|
|
|
498
543
|
}
|
|
499
544
|
}
|
|
500
545
|
|
|
546
|
+
export class StreamPlayback extends FilterManager implements BaseSound {
|
|
547
|
+
private context: AudioContext;
|
|
548
|
+
private source?: MediaStreamAudioSourceNode;
|
|
549
|
+
private gainNode?: GainNode;
|
|
550
|
+
private panner?: PannerNode;
|
|
551
|
+
loopCount: LoopCount = 0;
|
|
552
|
+
currentLoop: number = 0;
|
|
553
|
+
|
|
554
|
+
constructor(source: MediaStreamAudioSourceNode, gainNode: GainNode, context: AudioContext, loopCount: LoopCount = 0) {
|
|
555
|
+
super();
|
|
556
|
+
this.loopCount = loopCount;
|
|
557
|
+
this.source = source;
|
|
558
|
+
this.gainNode = gainNode;
|
|
559
|
+
this.context = context;
|
|
560
|
+
this.panner = context.createPanner();
|
|
561
|
+
source.connect(this.panner).connect(this.gainNode);
|
|
562
|
+
this.refreshFilters();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
play() {
|
|
566
|
+
if (!this.source) {
|
|
567
|
+
throw new Error('Cannot play a sound that has been cleaned up');
|
|
568
|
+
}
|
|
569
|
+
return [this];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
get volume(): number {
|
|
573
|
+
if (!this.gainNode) {
|
|
574
|
+
throw new Error('Cannot get volume of a sound that has been cleaned up');
|
|
575
|
+
}
|
|
576
|
+
return this.gainNode.gain.value;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
set volume(v: number) {
|
|
580
|
+
if (!this.gainNode) {
|
|
581
|
+
throw new Error('Cannot set volume of a sound that has been cleaned up');
|
|
582
|
+
}
|
|
583
|
+
this.gainNode.gain.value = v;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
stop(): void {
|
|
587
|
+
if (!this.source) {
|
|
588
|
+
throw new Error('Cannot stop a sound that has been cleaned up');
|
|
589
|
+
}
|
|
590
|
+
this.source.mediaStream.getTracks().forEach(track => track.stop());
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
pause(): void {
|
|
594
|
+
if (!this.source) {
|
|
595
|
+
throw new Error('Cannot pause a sound that has been cleaned up');
|
|
596
|
+
}
|
|
597
|
+
this.source.mediaStream.getTracks().forEach(track => track.enabled = false);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
resume(): void {
|
|
601
|
+
if (!this.source) {
|
|
602
|
+
throw new Error('Cannot resume a sound that has been cleaned up');
|
|
603
|
+
}
|
|
604
|
+
this.source.mediaStream.getTracks().forEach(track => track.enabled = true);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
addFilter(filter: BiquadFilterNode): void {
|
|
608
|
+
super.addFilter(filter);
|
|
609
|
+
this.refreshFilters();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
removeFilter(filter: BiquadFilterNode): void {
|
|
613
|
+
super.removeFilter(filter);
|
|
614
|
+
this.refreshFilters();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
set position(position: Position) {
|
|
618
|
+
if (!this.panner) {
|
|
619
|
+
throw new Error('Cannot move a sound that has been cleaned up');
|
|
620
|
+
}
|
|
621
|
+
const [x, y, z] = position;
|
|
622
|
+
this.panner.positionX.value = x;
|
|
623
|
+
this.panner.positionY.value = y;
|
|
624
|
+
this.panner.positionZ.value = z;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
get position(): Position {
|
|
628
|
+
if (!this.panner) {
|
|
629
|
+
throw new Error('Cannot get position of a sound that has been cleaned up');
|
|
630
|
+
}
|
|
631
|
+
return [this.panner.positionX.value, this.panner.positionY.value, this.panner.positionZ.value];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private refreshFilters(): void {
|
|
635
|
+
if (!this.source || !this.gainNode) {
|
|
636
|
+
throw new Error('Cannot update filters on a sound that has been cleaned up');
|
|
637
|
+
}
|
|
638
|
+
let connection = this.source;
|
|
639
|
+
this.source.disconnect();
|
|
640
|
+
connection = this.applyFilters(connection);
|
|
641
|
+
connection.connect(this.gainNode);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export class MicrophoneStream extends FilterManager implements BaseSound {
|
|
646
|
+
context: AudioContext;
|
|
647
|
+
private _position: Position = [0, 0, 0];
|
|
648
|
+
loopCount: LoopCount = 0;
|
|
649
|
+
private prevVolume: number = 1;
|
|
650
|
+
private microphoneGainNode: GainNode;
|
|
651
|
+
private streamPlayback?: StreamPlayback;
|
|
652
|
+
private stream: MediaStream | undefined;
|
|
653
|
+
private streamSource?: MediaStreamAudioSourceNode;
|
|
654
|
+
|
|
655
|
+
constructor(context: AudioContext) {
|
|
656
|
+
super();
|
|
657
|
+
this.context = context;
|
|
658
|
+
this.microphoneGainNode = this.context.createGain();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
play(): StreamPlayback[] {
|
|
663
|
+
if (!this.stream) {
|
|
664
|
+
navigator.mediaDevices.getUserMedia({ audio: true })
|
|
665
|
+
.then(stream => {
|
|
666
|
+
this.stream = stream;
|
|
667
|
+
this.streamSource = this.context.createMediaStreamSource(this.stream);
|
|
668
|
+
this.streamPlayback = new StreamPlayback(this.streamSource, this.microphoneGainNode, this.context);
|
|
669
|
+
this.streamPlayback.play();
|
|
670
|
+
})
|
|
671
|
+
.catch(err => {
|
|
672
|
+
console.error('Error initializing microphone stream:', err);
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return this.streamPlayback ? [this.streamPlayback] : [];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
seek(time: number): void {
|
|
679
|
+
// Seeking is not applicable for live microphone stream
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
stop(): void {
|
|
683
|
+
if (this.streamPlayback) {
|
|
684
|
+
this.streamPlayback.stop();
|
|
685
|
+
this.streamPlayback = undefined;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
pause(): void {
|
|
690
|
+
if (this.streamPlayback) {
|
|
691
|
+
this.streamPlayback.pause();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
resume(): void {
|
|
696
|
+
if (this.streamPlayback) {
|
|
697
|
+
this.streamPlayback.resume();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
addFilter(filter: BiquadFilterNode): void {
|
|
702
|
+
if (this.streamPlayback) {
|
|
703
|
+
this.streamPlayback.addFilter(filter);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
removeFilter(filter: BiquadFilterNode): void {
|
|
708
|
+
if (this.streamPlayback) {
|
|
709
|
+
this.streamPlayback.removeFilter(filter);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
get volume(): number {
|
|
714
|
+
return this.streamPlayback ? this.streamPlayback.volume : 0;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
set volume(volume: number) {
|
|
718
|
+
if (this.streamPlayback) {
|
|
719
|
+
this.streamPlayback.volume = volume;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
get position(): Position {
|
|
724
|
+
// Position is not applicable for live microphone stream
|
|
725
|
+
return [0, 0, 0];
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
set position(position: Position) {
|
|
729
|
+
// Position is not applicable for live microphone stream
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
loop(loopCount?: LoopCount): LoopCount {
|
|
733
|
+
// Looping is not applicable for live microphone stream
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
}
|