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 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.5",
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": "echo \"Error: no test specified\" && exit 1"
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(): Playback[];
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.source!.start());
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.source!.loop = true);
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
+ }