@flighthq/audio 0.1.0 → 0.2.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.
@@ -1,2 +1,4 @@
1
- export declare function inferAudioType(url: string): string | null;
1
+ export declare function canPlayAudioType(mimeType: string): boolean;
2
+ export declare function detectAudioMimeType(data: ArrayBuffer | Uint8Array): string | null;
3
+ export declare function inferAudioMimeType(url: string): string | null;
2
4
  //# sourceMappingURL=audioFormat.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"audioFormat.d.ts","sourceRoot":"","sources":["../src/audioFormat.ts"],"names":[],"mappings":"AAAA,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAoBzD"}
1
+ {"version":3,"file":"audioFormat.d.ts","sourceRoot":"","sources":["../src/audioFormat.ts"],"names":[],"mappings":"AAEA,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAG1D;AAKD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,GAAG,MAAM,GAAG,IAAI,CAmCjF;AAID,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAoB7D"}
@@ -1,4 +1,50 @@
1
- export function inferAudioType(url) {
1
+ // Reports whether the current environment can play a given MIME type, by probing an <audio> element's
2
+ // canPlayType. Returns false for the empty string and for any type the browser reports it cannot play.
3
+ export function canPlayAudioType(mimeType) {
4
+ if (mimeType === '')
5
+ return false;
6
+ return new Audio().canPlayType(mimeType) !== '';
7
+ }
8
+ // Sniffs the container MIME type from a buffer's leading magic bytes, or null when unrecognized.
9
+ // Mirrors detectImageMimeType. Covers wav, flac, ogg, mp3 (ID3 or MPEG frame sync), m4a/aac
10
+ // (ISO-BMFF ftyp), and webm (EBML).
11
+ export function detectAudioMimeType(data) {
12
+ const b = data instanceof Uint8Array ? data : new Uint8Array(data);
13
+ if (b.byteLength < 4)
14
+ return null;
15
+ // WAV: 'RIFF' (0-3) .... 'WAVE' (8-11)
16
+ if (b.byteLength >= 12 &&
17
+ b[0] === 0x52 &&
18
+ b[1] === 0x49 &&
19
+ b[2] === 0x46 &&
20
+ b[3] === 0x46 &&
21
+ b[8] === 0x57 &&
22
+ b[9] === 0x41 &&
23
+ b[10] === 0x56 &&
24
+ b[11] === 0x45)
25
+ return 'audio/wav';
26
+ // FLAC: 'fLaC'
27
+ if (b[0] === 0x66 && b[1] === 0x4c && b[2] === 0x61 && b[3] === 0x43)
28
+ return 'audio/flac';
29
+ // Ogg: 'OggS'
30
+ if (b[0] === 0x4f && b[1] === 0x67 && b[2] === 0x67 && b[3] === 0x53)
31
+ return 'audio/ogg';
32
+ // MP3: 'ID3' tag prefix, or an MPEG audio frame sync (11 set bits: 0xFF followed by 0xE0-mask).
33
+ if (b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33)
34
+ return 'audio/mpeg';
35
+ if (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)
36
+ return 'audio/mpeg';
37
+ // M4A / AAC (ISO base media): a box-size prefix (0-3) followed by 'ftyp' (4-7).
38
+ if (b.byteLength >= 8 && b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70)
39
+ return 'audio/mp4';
40
+ // WebM / Matroska (EBML): 1A 45 DF A3
41
+ if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3)
42
+ return 'audio/webm';
43
+ return null;
44
+ }
45
+ // Infers the MIME type from a URL's file extension, or null when unrecognized. Extension-based; use
46
+ // detectAudioMimeType when the bytes are in hand.
47
+ export function inferAudioMimeType(url) {
2
48
  const ext = url.split('?')[0].split('.').pop()?.toLowerCase();
3
49
  switch (ext) {
4
50
  case 'mp3':
@@ -1 +1 @@
1
- {"version":3,"file":"audioFormat.js","sourceRoot":"","sources":["../src/audioFormat.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,MAAM,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,WAAW,EAAE,CAAC;IAC9D,QAAQ,GAAG,EAAE,CAAC;QACZ,KAAK,KAAK;YACR,OAAO,YAAY,CAAC;QACtB,KAAK,KAAK;YACR,OAAO,WAAW,CAAC;QACrB,KAAK,KAAK;YACR,OAAO,WAAW,CAAC;QACrB,KAAK,KAAK;YACR,OAAO,WAAW,CAAC;QACrB,KAAK,MAAM;YACT,OAAO,YAAY,CAAC;QACtB,KAAK,MAAM;YACT,OAAO,YAAY,CAAC;QACtB,KAAK,KAAK;YACR,OAAO,WAAW,CAAC;QACrB;YACE,OAAO,IAAI,CAAC;IAChB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"audioFormat.js","sourceRoot":"","sources":["../src/audioFormat.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,12 @@
1
1
  import type { AudioResource } from '@flighthq/types';
2
+ export declare function cloneAudioResource(resource: Readonly<AudioResource>): AudioResource;
2
3
  export declare function createAudioResource(buffer?: AudioBuffer): AudioResource;
4
+ export declare function disposeAudioResource(resource: AudioResource): void;
5
+ export declare function getAudioResourceByteSize(resource: Readonly<AudioResource>): number;
6
+ export declare function getAudioResourceChannelCount(resource: Readonly<AudioResource>): number;
7
+ export declare function getAudioResourceChannelData(resource: Readonly<AudioResource>, channel: number): Float32Array | null;
8
+ export declare function getAudioResourceDuration(resource: Readonly<AudioResource>): number;
9
+ export declare function getAudioResourceSampleRate(resource: Readonly<AudioResource>): number;
10
+ export declare function hasAudioResourceBuffer(resource: Readonly<AudioResource>): boolean;
11
+ export declare function isAudioResourceEmpty(resource: Readonly<AudioResource>): boolean;
3
12
  //# sourceMappingURL=audioResource.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"audioResource.d.ts","sourceRoot":"","sources":["../src/audioResource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD,wBAAgB,mBAAmB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,aAAa,CAEvE"}
1
+ {"version":3,"file":"audioResource.d.ts","sourceRoot":"","sources":["../src/audioResource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAMrD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,aAAa,CAEnF;AAED,wBAAgB,mBAAmB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,aAAa,CAEvE;AAID,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,aAAa,GAAG,IAAI,CAElE;AAID,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAGlF;AAED,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAEtF;AAKD,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAInH;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAElF;AAED,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,MAAM,CAEpF;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,OAAO,CAEjF;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,GAAG,OAAO,CAE/E"}
@@ -1,4 +1,46 @@
1
+ // Allocates a new resource identity over the SAME underlying AudioBuffer. The buffer is shared by
2
+ // reference, not duplicated — clone gives an independent resource object over the same decoded PCM,
3
+ // e.g. to hand one decoded sound to two playback subsystems. Use createAudioResourceFromSamples with
4
+ // copied channel data when you need the samples themselves duplicated.
5
+ export function cloneAudioResource(resource) {
6
+ return { buffer: resource.buffer };
7
+ }
1
8
  export function createAudioResource(buffer) {
2
9
  return { buffer: buffer ?? null };
3
10
  }
11
+ // Releases the decoded AudioBuffer reference so it becomes eligible for GC. The AudioBuffer is plain
12
+ // GC-managed memory with no GPU/native handle to free, so this is dispose*, not destroy*.
13
+ export function disposeAudioResource(resource) {
14
+ resource.buffer = null;
15
+ }
16
+ // Returns the byte footprint of the decoded PCM: channels × length × 4 (Float32 samples). Returns 0
17
+ // when there is no buffer. Mirrors getImageResourceByteSize (width × height × 4).
18
+ export function getAudioResourceByteSize(resource) {
19
+ const buffer = resource.buffer;
20
+ return buffer !== null ? buffer.numberOfChannels * buffer.length * 4 : 0;
21
+ }
22
+ export function getAudioResourceChannelCount(resource) {
23
+ return resource.buffer !== null ? resource.buffer.numberOfChannels : 0;
24
+ }
25
+ // Returns the Float32 samples for one channel by reference (Web Audio's getChannelData contract), or
26
+ // null when there is no buffer or the channel index is out of range. Mutating the returned array
27
+ // mutates the decoded buffer in place.
28
+ export function getAudioResourceChannelData(resource, channel) {
29
+ const buffer = resource.buffer;
30
+ if (buffer === null || channel < 0 || channel >= buffer.numberOfChannels)
31
+ return null;
32
+ return buffer.getChannelData(channel);
33
+ }
34
+ export function getAudioResourceDuration(resource) {
35
+ return resource.buffer !== null ? resource.buffer.duration : 0;
36
+ }
37
+ export function getAudioResourceSampleRate(resource) {
38
+ return resource.buffer !== null ? resource.buffer.sampleRate : 0;
39
+ }
40
+ export function hasAudioResourceBuffer(resource) {
41
+ return resource.buffer !== null;
42
+ }
43
+ export function isAudioResourceEmpty(resource) {
44
+ return resource.buffer === null || resource.buffer.length === 0;
45
+ }
4
46
  //# sourceMappingURL=audioResource.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"audioResource.js","sourceRoot":"","sources":["../src/audioResource.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,mBAAmB,CAAC,MAAoB;IACtD,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,IAAI,EAAE,CAAC;AACpC,CAAC"}
1
+ {"version":3,"file":"audioResource.js","sourceRoot":"","sources":["../src/audioResource.ts"],"names":[],"mappings":"AAEA,kGAAkG;AAClG,oGAAoG;AACpG,qGAAqG;AACrG,uEAAuE;AACvE,MAAM,UAAU,kBAAkB,CAAC,QAAiC;IAClE,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;AACrC,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,MAAoB;IACtD,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,IAAI,EAAE,CAAC;AACpC,CAAC;AAED,qGAAqG;AACrG,0FAA0F;AAC1F,MAAM,UAAU,oBAAoB,CAAC,QAAuB;IAC1D,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC;AACzB,CAAC;AAED,oGAAoG;AACpG,kFAAkF;AAClF,MAAM,UAAU,wBAAwB,CAAC,QAAiC;IACxE,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;IAC/B,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,gBAAgB,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,4BAA4B,CAAC,QAAiC;IAC5E,OAAO,QAAQ,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC;AACzE,CAAC;AAED,qGAAqG;AACrG,iGAAiG;AACjG,uCAAuC;AACvC,MAAM,UAAU,2BAA2B,CAAC,QAAiC,EAAE,OAAe;IAC5F,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;IAC/B,IAAI,MAAM,KAAK,IAAI,IAAI,OAAO,GAAG,CAAC,IAAI,OAAO,IAAI,MAAM,CAAC,gBAAgB;QAAE,OAAO,IAAI,CAAC;IACtF,OAAO,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;AACxC,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,QAAiC;IACxE,OAAO,QAAQ,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AACjE,CAAC;AAED,MAAM,UAAU,0BAA0B,CAAC,QAAiC;IAC1E,OAAO,QAAQ,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,QAAiC;IACtE,OAAO,QAAQ,CAAC,MAAM,KAAK,IAAI,CAAC;AAClC,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,QAAiC;IACpE,OAAO,QAAQ,CAAC,MAAM,KAAK,IAAI,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC;AAClE,CAAC"}
@@ -1,4 +1,9 @@
1
1
  import type { AudioResource, AudioResourceUrl } from '@flighthq/types';
2
+ export declare function createAudioResourceFromSamples(channels: readonly Float32Array[], sampleRate: number): AudioResource;
3
+ export declare function loadAudioResourceFromBase64(context: AudioContext, base64: string, mimeType: string, signal?: AbortSignal): Promise<AudioResource>;
4
+ export declare function loadAudioResourceFromBlob(context: AudioContext, blob: Blob, signal?: AbortSignal): Promise<AudioResource>;
5
+ export declare function loadAudioResourceFromBytes(context: AudioContext, bytes: Uint8Array, mimeType?: string, signal?: AbortSignal): Promise<AudioResource>;
2
6
  export declare function loadAudioResourceFromUrl(context: AudioContext, url: string, signal?: AbortSignal): Promise<AudioResource>;
3
- export declare function loadAudioResourceFromUrls(context: AudioContext, sources: AudioResourceUrl[], signal?: AbortSignal): Promise<AudioResource>;
7
+ export declare function loadAudioResourceFromUrls(context: AudioContext, sources: readonly AudioResourceUrl[], signal?: AbortSignal): Promise<AudioResource>;
8
+ export declare function selectAudioResourceUrl(sources: readonly AudioResourceUrl[]): string | null;
4
9
  //# sourceMappingURL=audioResourceFrom.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"audioResourceFrom.d.ts","sourceRoot":"","sources":["../src/audioResourceFrom.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAKvE,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAKxB;AAED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,YAAY,EACrB,OAAO,EAAE,gBAAgB,EAAE,EAC3B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAKxB"}
1
+ {"version":3,"file":"audioResourceFrom.d.ts","sourceRoot":"","sources":["../src/audioResourceFrom.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AASvE,wBAAgB,8BAA8B,CAAC,QAAQ,EAAE,SAAS,YAAY,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,aAAa,CAUnH;AAID,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAKxB;AAED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,YAAY,EACrB,IAAI,EAAE,IAAI,EACV,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAGxB;AAKD,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,YAAY,EACrB,KAAK,EAAE,UAAU,EACjB,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAKxB;AAED,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CASxB;AAED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,YAAY,EACrB,OAAO,EAAE,SAAS,gBAAgB,EAAE,EACpC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAIxB;AAID,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,GAAG,MAAM,GAAG,IAAI,CAM1F"}
@@ -1,16 +1,62 @@
1
- import { inferAudioType } from './audioFormat';
1
+ import { canPlayAudioType, inferAudioMimeType } from './audioFormat';
2
2
  import { createAudioResource } from './audioResource';
3
+ // Builds a resource from raw PCM channel data without needing an AudioContext. Each entry in
4
+ // `channels` holds one channel's Float32 samples; all are expected to share the first channel's
5
+ // length. Uses the AudioBuffer constructor (not context.createBuffer) so it works off the audio
6
+ // thread. Returns a null-buffer resource for empty or zero-length input.
7
+ export function createAudioResourceFromSamples(channels, sampleRate) {
8
+ const numberOfChannels = channels.length;
9
+ const length = numberOfChannels > 0 ? channels[0].length : 0;
10
+ if (numberOfChannels === 0 || length === 0)
11
+ return createAudioResource();
12
+ const buffer = new AudioBuffer({ length, numberOfChannels, sampleRate });
13
+ for (let channel = 0; channel < numberOfChannels; channel++) {
14
+ // Narrow the widened ArrayBufferLike backing to the ArrayBuffer that copyToChannel requires.
15
+ buffer.copyToChannel(channels[channel], channel);
16
+ }
17
+ return createAudioResource(buffer);
18
+ }
19
+ // Web Audio's decodeAudioData content-sniffs the container, so `mimeType` is advisory here (threaded
20
+ // for loader-family symmetry and non-web backends); the decoder ignores it.
21
+ export async function loadAudioResourceFromBase64(context, base64, mimeType, signal) {
22
+ const binary = atob(base64);
23
+ const bytes = new Uint8Array(binary.length);
24
+ for (let i = 0; i < binary.length; i++)
25
+ bytes[i] = binary.charCodeAt(i);
26
+ return loadAudioResourceFromBytes(context, bytes, mimeType, signal);
27
+ }
28
+ export async function loadAudioResourceFromBlob(context, blob, signal) {
29
+ const arrayBuffer = await blob.arrayBuffer();
30
+ return loadAudioResourceFromBytes(context, new Uint8Array(arrayBuffer), blob.type || undefined, signal);
31
+ }
32
+ // Decodes encoded audio bytes into a resource. Copies the viewed region into a fresh ArrayBuffer
33
+ // before decoding so the caller's Uint8Array is not detached by decodeAudioData. `mimeType` is
34
+ // advisory (see loadAudioResourceFromBase64): decodeAudioData sniffs the container itself.
35
+ export async function loadAudioResourceFromBytes(context, bytes, mimeType, signal) {
36
+ signal?.throwIfAborted();
37
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
38
+ const audioBuffer = await context.decodeAudioData(buffer);
39
+ return createAudioResource(audioBuffer);
40
+ }
3
41
  export async function loadAudioResourceFromUrl(context, url, signal) {
4
42
  const response = await fetch(url, { signal });
5
43
  const arrayBuffer = await response.arrayBuffer();
6
- const audioBuffer = await context.decodeAudioData(arrayBuffer);
7
- return createAudioResource(audioBuffer);
44
+ return loadAudioResourceFromBytes(context, new Uint8Array(arrayBuffer), response.headers.get('content-type') ?? undefined, signal);
8
45
  }
9
46
  export async function loadAudioResourceFromUrls(context, sources, signal) {
10
- const probe = new Audio();
11
- const selected = sources.find(({ url, type }) => probe.canPlayType(type ?? inferAudioType(url) ?? '') !== '');
12
- if (selected === undefined)
47
+ const selected = selectAudioResourceUrl(sources);
48
+ if (selected === null)
13
49
  return createAudioResource();
14
- return loadAudioResourceFromUrl(context, selected.url, signal);
50
+ return loadAudioResourceFromUrl(context, selected, signal);
51
+ }
52
+ // Picks the first source URL the environment can play, or null when none is playable. A source's
53
+ // explicit `type` wins; otherwise the MIME type is inferred from the URL extension.
54
+ export function selectAudioResourceUrl(sources) {
55
+ for (const source of sources) {
56
+ const type = source.type ?? inferAudioMimeType(source.url) ?? '';
57
+ if (canPlayAudioType(type))
58
+ return source.url;
59
+ }
60
+ return null;
15
61
  }
16
62
  //# sourceMappingURL=audioResourceFrom.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"audioResourceFrom.js","sourceRoot":"","sources":["../src/audioResourceFrom.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAEtD,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC5C,OAAqB,EACrB,GAAW,EACX,MAAoB;IAEpB,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;IAC9C,MAAM,WAAW,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC;IACjD,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,eAAe,CAAC,WAAW,CAAC,CAAC;IAC/D,OAAO,mBAAmB,CAAC,WAAW,CAAC,CAAC;AAC1C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,OAAqB,EACrB,OAA2B,EAC3B,MAAoB;IAEpB,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC;IAC1B,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,IAAI,cAAc,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC;IAC9G,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,mBAAmB,EAAE,CAAC;IACzD,OAAO,wBAAwB,CAAC,OAAO,EAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;AACjE,CAAC"}
1
+ {"version":3,"file":"audioResourceFrom.js","sourceRoot":"","sources":["../src/audioResourceFrom.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@flighthq/audio",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/flighthq/flight.git",
7
+ "directory": "packages/audio"
8
+ },
4
9
  "type": "module",
5
10
  "main": "dist/index.js",
6
11
  "types": "dist/index.d.ts",
@@ -27,11 +32,11 @@
27
32
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
33
  },
29
34
  "dependencies": {
30
- "@flighthq/types": "0.1.0"
35
+ "@flighthq/types": "0.2.0"
31
36
  },
32
37
  "devDependencies": {
33
38
  "typescript": "^5.3.0"
34
39
  },
35
- "description": "Audio resources, URL constructors, and the shared audio context",
40
+ "description": "AudioResource lifecycle — create/clone/dispose, sample-tier and decoded-bytes constructors, URL/Blob/Base64 loaders, codec negotiation, and container MIME detection",
36
41
  "sideEffects": false
37
42
  }
@@ -1,43 +1,115 @@
1
- import { inferAudioType } from './audioFormat';
1
+ import { canPlayAudioType, detectAudioMimeType, inferAudioMimeType } from './audioFormat';
2
2
 
3
- describe('inferAudioType', () => {
3
+ describe('canPlayAudioType', () => {
4
+ beforeEach(() => {
5
+ vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockImplementation((type: string) =>
6
+ type === 'audio/mpeg' ? 'probably' : '',
7
+ );
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ it('returns false for the empty string without probing', () => {
15
+ expect(canPlayAudioType('')).toBe(false);
16
+ });
17
+
18
+ it('returns true for a type the element reports it can play', () => {
19
+ expect(canPlayAudioType('audio/mpeg')).toBe(true);
20
+ });
21
+
22
+ it('returns false for a type the element cannot play', () => {
23
+ expect(canPlayAudioType('audio/x-unknown')).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe('detectAudioMimeType', () => {
28
+ it('returns null for a buffer that is too small', () => {
29
+ expect(detectAudioMimeType(new ArrayBuffer(3))).toBeNull();
30
+ });
31
+
32
+ it('returns null for an unrecognised header', () => {
33
+ expect(detectAudioMimeType(new Uint8Array([0x00, 0x01, 0x02, 0x03]))).toBeNull();
34
+ });
35
+
36
+ it('detects WAV (RIFF....WAVE)', () => {
37
+ const b = new Uint8Array(12);
38
+ b.set([0x52, 0x49, 0x46, 0x46], 0);
39
+ b.set([0x57, 0x41, 0x56, 0x45], 8);
40
+ expect(detectAudioMimeType(b)).toBe('audio/wav');
41
+ });
42
+
43
+ it('detects FLAC (fLaC)', () => {
44
+ expect(detectAudioMimeType(new Uint8Array([0x66, 0x4c, 0x61, 0x43]))).toBe('audio/flac');
45
+ });
46
+
47
+ it('detects Ogg (OggS)', () => {
48
+ expect(detectAudioMimeType(new Uint8Array([0x4f, 0x67, 0x67, 0x53]))).toBe('audio/ogg');
49
+ });
50
+
51
+ it('detects MP3 via an ID3 tag', () => {
52
+ expect(detectAudioMimeType(new Uint8Array([0x49, 0x44, 0x33, 0x04]))).toBe('audio/mpeg');
53
+ });
54
+
55
+ it('detects MP3 via an MPEG frame sync', () => {
56
+ expect(detectAudioMimeType(new Uint8Array([0xff, 0xfb, 0x90, 0x00]))).toBe('audio/mpeg');
57
+ });
58
+
59
+ it('detects M4A / AAC (ftyp box)', () => {
60
+ const b = new Uint8Array([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]);
61
+ expect(detectAudioMimeType(b)).toBe('audio/mp4');
62
+ });
63
+
64
+ it('detects WebM (EBML)', () => {
65
+ expect(detectAudioMimeType(new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]))).toBe('audio/webm');
66
+ });
67
+
68
+ it('accepts an ArrayBuffer as well as a Uint8Array', () => {
69
+ const buf = new ArrayBuffer(4);
70
+ new Uint8Array(buf).set([0x66, 0x4c, 0x61, 0x43]);
71
+ expect(detectAudioMimeType(buf)).toBe('audio/flac');
72
+ });
73
+ });
74
+
75
+ describe('inferAudioMimeType', () => {
4
76
  it('returns "audio/mpeg" for .mp3 files', () => {
5
- expect(inferAudioType('track.mp3')).toBe('audio/mpeg');
77
+ expect(inferAudioMimeType('track.mp3')).toBe('audio/mpeg');
6
78
  });
7
79
 
8
80
  it('returns "audio/ogg" for .ogg files', () => {
9
- expect(inferAudioType('track.ogg')).toBe('audio/ogg');
81
+ expect(inferAudioMimeType('track.ogg')).toBe('audio/ogg');
10
82
  });
11
83
 
12
84
  it('returns "audio/wav" for .wav files', () => {
13
- expect(inferAudioType('track.wav')).toBe('audio/wav');
85
+ expect(inferAudioMimeType('track.wav')).toBe('audio/wav');
14
86
  });
15
87
 
16
88
  it('returns "audio/aac" for .aac files', () => {
17
- expect(inferAudioType('track.aac')).toBe('audio/aac');
89
+ expect(inferAudioMimeType('track.aac')).toBe('audio/aac');
18
90
  });
19
91
 
20
92
  it('returns "audio/flac" for .flac files', () => {
21
- expect(inferAudioType('track.flac')).toBe('audio/flac');
93
+ expect(inferAudioMimeType('track.flac')).toBe('audio/flac');
22
94
  });
23
95
 
24
96
  it('returns "audio/webm" for .webm files', () => {
25
- expect(inferAudioType('track.webm')).toBe('audio/webm');
97
+ expect(inferAudioMimeType('track.webm')).toBe('audio/webm');
26
98
  });
27
99
 
28
100
  it('returns "audio/mp4" for .m4a files', () => {
29
- expect(inferAudioType('track.m4a')).toBe('audio/mp4');
101
+ expect(inferAudioMimeType('track.m4a')).toBe('audio/mp4');
30
102
  });
31
103
 
32
104
  it('returns null for unrecognized extensions', () => {
33
- expect(inferAudioType('track.mid')).toBeNull();
105
+ expect(inferAudioMimeType('track.mid')).toBeNull();
34
106
  });
35
107
 
36
108
  it('strips query parameters before matching', () => {
37
- expect(inferAudioType('track.mp3?cb=1')).toBe('audio/mpeg');
109
+ expect(inferAudioMimeType('track.mp3?cb=1')).toBe('audio/mpeg');
38
110
  });
39
111
 
40
112
  it('returns null for a URL with no extension', () => {
41
- expect(inferAudioType('audio')).toBeNull();
113
+ expect(inferAudioMimeType('audio')).toBeNull();
42
114
  });
43
115
  });
@@ -1,8 +1,130 @@
1
- import { createAudioResource } from './audioResource';
1
+ import {
2
+ cloneAudioResource,
3
+ createAudioResource,
4
+ disposeAudioResource,
5
+ getAudioResourceByteSize,
6
+ getAudioResourceChannelCount,
7
+ getAudioResourceChannelData,
8
+ getAudioResourceDuration,
9
+ getAudioResourceSampleRate,
10
+ hasAudioResourceBuffer,
11
+ isAudioResourceEmpty,
12
+ } from './audioResource';
13
+
14
+ // jsdom has no AudioBuffer; the media package models it the same way — a plain cast carrying only the
15
+ // fields under test (mirrors its createMockAudioBuffer helper).
16
+ function createMockAudioBuffer(overrides: Partial<AudioBuffer> = {}): AudioBuffer {
17
+ const length = 100;
18
+ const numberOfChannels = 2;
19
+ const sampleRate = 44100;
20
+ const channels = [new Float32Array(length), new Float32Array(length)];
21
+ return {
22
+ duration: length / sampleRate,
23
+ getChannelData: (channel: number) => channels[channel],
24
+ length,
25
+ numberOfChannels,
26
+ sampleRate,
27
+ ...overrides,
28
+ } as unknown as AudioBuffer;
29
+ }
30
+
31
+ describe('cloneAudioResource', () => {
32
+ it('shares the buffer by reference and gives an independent resource identity', () => {
33
+ const buffer = createMockAudioBuffer();
34
+ const resource = createAudioResource(buffer);
35
+
36
+ const copy = cloneAudioResource(resource);
37
+
38
+ expect(copy).not.toBe(resource);
39
+ expect(copy.buffer).toBe(buffer);
40
+ });
41
+
42
+ it('copies a null buffer', () => {
43
+ expect(cloneAudioResource(createAudioResource()).buffer).toBeNull();
44
+ });
45
+ });
2
46
 
3
47
  describe('createAudioResource', () => {
4
48
  it('returns a resource with null buffer when called with no arguments', () => {
5
- const resource = createAudioResource();
49
+ expect(createAudioResource().buffer).toBeNull();
50
+ });
51
+
52
+ it('wraps a provided buffer', () => {
53
+ const buffer = createMockAudioBuffer();
54
+ expect(createAudioResource(buffer).buffer).toBe(buffer);
55
+ });
56
+ });
57
+
58
+ describe('disposeAudioResource', () => {
59
+ it('releases the buffer reference', () => {
60
+ const resource = createAudioResource(createMockAudioBuffer());
61
+ disposeAudioResource(resource);
6
62
  expect(resource.buffer).toBeNull();
7
63
  });
8
64
  });
65
+
66
+ describe('getAudioResourceByteSize', () => {
67
+ it('returns 0 when there is no buffer', () => {
68
+ expect(getAudioResourceByteSize(createAudioResource())).toBe(0);
69
+ });
70
+
71
+ it('returns channels × length × 4 for Float32 PCM', () => {
72
+ const resource = createAudioResource(createMockAudioBuffer());
73
+ expect(getAudioResourceByteSize(resource)).toBe(2 * 100 * 4);
74
+ });
75
+ });
76
+
77
+ describe('getAudioResourceChannelCount', () => {
78
+ it('returns 0 without a buffer and the channel count with one', () => {
79
+ expect(getAudioResourceChannelCount(createAudioResource())).toBe(0);
80
+ expect(getAudioResourceChannelCount(createAudioResource(createMockAudioBuffer()))).toBe(2);
81
+ });
82
+ });
83
+
84
+ describe('getAudioResourceChannelData', () => {
85
+ it('returns the Float32Array for an in-range channel', () => {
86
+ const resource = createAudioResource(createMockAudioBuffer());
87
+ const data = getAudioResourceChannelData(resource, 1);
88
+ expect(data).toBeInstanceOf(Float32Array);
89
+ expect(data?.length).toBe(100);
90
+ });
91
+
92
+ it('returns null without a buffer or for an out-of-range channel', () => {
93
+ expect(getAudioResourceChannelData(createAudioResource(), 0)).toBeNull();
94
+ const resource = createAudioResource(createMockAudioBuffer());
95
+ expect(getAudioResourceChannelData(resource, -1)).toBeNull();
96
+ expect(getAudioResourceChannelData(resource, 2)).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe('getAudioResourceDuration', () => {
101
+ it('returns 0 without a buffer and the buffer duration with one', () => {
102
+ expect(getAudioResourceDuration(createAudioResource())).toBe(0);
103
+ expect(getAudioResourceDuration(createAudioResource(createMockAudioBuffer({ duration: 2.5 })))).toBe(2.5);
104
+ });
105
+ });
106
+
107
+ describe('getAudioResourceSampleRate', () => {
108
+ it('returns 0 without a buffer and the sample rate with one', () => {
109
+ expect(getAudioResourceSampleRate(createAudioResource())).toBe(0);
110
+ expect(getAudioResourceSampleRate(createAudioResource(createMockAudioBuffer()))).toBe(44100);
111
+ });
112
+ });
113
+
114
+ describe('hasAudioResourceBuffer', () => {
115
+ it('is false without a buffer and true with one', () => {
116
+ expect(hasAudioResourceBuffer(createAudioResource())).toBe(false);
117
+ expect(hasAudioResourceBuffer(createAudioResource(createMockAudioBuffer()))).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe('isAudioResourceEmpty', () => {
122
+ it('is true without a buffer', () => {
123
+ expect(isAudioResourceEmpty(createAudioResource())).toBe(true);
124
+ });
125
+
126
+ it('is true for a zero-length buffer and false once it has samples', () => {
127
+ expect(isAudioResourceEmpty(createAudioResource(createMockAudioBuffer({ length: 0 })))).toBe(true);
128
+ expect(isAudioResourceEmpty(createAudioResource(createMockAudioBuffer()))).toBe(false);
129
+ });
130
+ });
@@ -1,20 +1,172 @@
1
- import { loadAudioResourceFromUrl, loadAudioResourceFromUrls } from './audioResourceFrom';
1
+ import {
2
+ createAudioResourceFromSamples,
3
+ loadAudioResourceFromBase64,
4
+ loadAudioResourceFromBlob,
5
+ loadAudioResourceFromBytes,
6
+ loadAudioResourceFromUrl,
7
+ loadAudioResourceFromUrls,
8
+ selectAudioResourceUrl,
9
+ } from './audioResourceFrom';
10
+
11
+ const decodedBuffer = { duration: 1 } as AudioBuffer;
2
12
 
3
13
  const mockContext = {
4
- decodeAudioData: vi.fn().mockResolvedValue({} as AudioBuffer),
14
+ decodeAudioData: vi.fn().mockResolvedValue(decodedBuffer),
5
15
  } as unknown as AudioContext;
6
16
 
17
+ // jsdom lacks the AudioBuffer constructor; this minimal stand-in honours the { length,
18
+ // numberOfChannels, sampleRate } constructor plus copyToChannel/getChannelData used by
19
+ // createAudioResourceFromSamples.
20
+ class MockAudioBuffer {
21
+ length: number;
22
+ numberOfChannels: number;
23
+ sampleRate: number;
24
+ private channels: Float32Array[];
25
+
26
+ constructor(options: { length: number; numberOfChannels: number; sampleRate: number }) {
27
+ this.length = options.length;
28
+ this.numberOfChannels = options.numberOfChannels;
29
+ this.sampleRate = options.sampleRate;
30
+ this.channels = Array.from({ length: options.numberOfChannels }, () => new Float32Array(options.length));
31
+ }
32
+
33
+ copyToChannel(source: Float32Array, channel: number): void {
34
+ this.channels[channel].set(source.subarray(0, this.length));
35
+ }
36
+
37
+ getChannelData(channel: number): Float32Array {
38
+ return this.channels[channel];
39
+ }
40
+ }
41
+
42
+ beforeEach(() => {
43
+ vi.stubGlobal('AudioBuffer', MockAudioBuffer);
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.unstubAllGlobals();
48
+ vi.restoreAllMocks();
49
+ (mockContext.decodeAudioData as ReturnType<typeof vi.fn>).mockClear();
50
+ });
51
+
52
+ describe('createAudioResourceFromSamples', () => {
53
+ it('builds a buffer from channel data and copies the samples', () => {
54
+ // Values exactly representable in Float32 so copyToChannel round-trips without precision drift.
55
+ const left = new Float32Array([0, 0.5, 0.25]);
56
+ const right = new Float32Array([0.75, -0.5, 0.25]);
57
+ const resource = createAudioResourceFromSamples([left, right], 48000);
58
+
59
+ expect(resource.buffer).not.toBeNull();
60
+ expect(resource.buffer?.numberOfChannels).toBe(2);
61
+ expect(resource.buffer?.length).toBe(3);
62
+ expect(resource.buffer?.sampleRate).toBe(48000);
63
+ expect(Array.from(resource.buffer!.getChannelData(1))).toEqual([0.75, -0.5, 0.25]);
64
+ });
65
+
66
+ it('returns a null-buffer resource for empty input', () => {
67
+ expect(createAudioResourceFromSamples([], 48000).buffer).toBeNull();
68
+ });
69
+
70
+ it('returns a null-buffer resource for zero-length channels', () => {
71
+ expect(createAudioResourceFromSamples([new Float32Array(0)], 48000).buffer).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe('loadAudioResourceFromBase64', () => {
76
+ it('decodes base64-encoded bytes into a resource', async () => {
77
+ const resource = await loadAudioResourceFromBase64(mockContext, btoa('abc'), 'audio/mpeg');
78
+ expect(resource.buffer).toBe(decodedBuffer);
79
+ expect(mockContext.decodeAudioData).toHaveBeenCalledOnce();
80
+ });
81
+ });
82
+
83
+ describe('loadAudioResourceFromBlob', () => {
84
+ it('decodes a blob into a resource', async () => {
85
+ // jsdom's Blob does not implement arrayBuffer(); a minimal double supplies what the loader reads.
86
+ const blob = {
87
+ arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3, 4]).buffer),
88
+ type: 'audio/wav',
89
+ } as unknown as Blob;
90
+ const resource = await loadAudioResourceFromBlob(mockContext, blob);
91
+ expect(resource.buffer).toBe(decodedBuffer);
92
+ expect(mockContext.decodeAudioData).toHaveBeenCalledOnce();
93
+ });
94
+ });
95
+
96
+ describe('loadAudioResourceFromBytes', () => {
97
+ it('decodes bytes into a resource', async () => {
98
+ const resource = await loadAudioResourceFromBytes(mockContext, new Uint8Array([1, 2, 3, 4]));
99
+ expect(resource.buffer).toBe(decodedBuffer);
100
+ });
101
+
102
+ it('does not detach the caller’s Uint8Array', async () => {
103
+ const bytes = new Uint8Array([1, 2, 3, 4]);
104
+ await loadAudioResourceFromBytes(mockContext, bytes);
105
+ expect(bytes.byteLength).toBe(4);
106
+ });
107
+
108
+ it('rejects when the signal is already aborted', async () => {
109
+ const controller = new AbortController();
110
+ controller.abort(new Error('cancelled'));
111
+ await expect(
112
+ loadAudioResourceFromBytes(mockContext, new Uint8Array([1, 2, 3, 4]), undefined, controller.signal),
113
+ ).rejects.toThrow('cancelled');
114
+ });
115
+ });
116
+
7
117
  describe('loadAudioResourceFromUrl', () => {
8
- it('returns a Promise', () => {
9
- const result = loadAudioResourceFromUrl(mockContext, 'test.mp3');
10
- result.catch(() => {});
11
- expect(result).toBeInstanceOf(Promise);
118
+ it('fetches, decodes, and returns a resource', async () => {
119
+ vi.stubGlobal(
120
+ 'fetch',
121
+ vi.fn().mockResolvedValue({
122
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
123
+ headers: new Headers({ 'content-type': 'audio/mpeg' }),
124
+ }),
125
+ );
126
+ const resource = await loadAudioResourceFromUrl(mockContext, 'sound.mp3');
127
+ expect(resource.buffer).toBe(decodedBuffer);
12
128
  });
13
129
  });
14
130
 
15
131
  describe('loadAudioResourceFromUrls', () => {
16
- it('resolves immediately with a null-buffer resource when sources is empty', async () => {
132
+ it('resolves with a null-buffer resource when sources is empty', async () => {
17
133
  const resource = await loadAudioResourceFromUrls(mockContext, []);
18
134
  expect(resource.buffer).toBeNull();
19
135
  });
136
+
137
+ it('loads the first playable source', async () => {
138
+ vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockImplementation((type: string) =>
139
+ type === 'audio/ogg' ? 'probably' : '',
140
+ );
141
+ const fetchMock = vi.fn().mockResolvedValue({
142
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
143
+ headers: new Headers(),
144
+ });
145
+ vi.stubGlobal('fetch', fetchMock);
146
+
147
+ const resource = await loadAudioResourceFromUrls(mockContext, [{ url: 'sound.mp3' }, { url: 'sound.ogg' }]);
148
+
149
+ expect(resource.buffer).toBe(decodedBuffer);
150
+ expect(fetchMock).toHaveBeenCalledWith('sound.ogg', expect.anything());
151
+ });
152
+ });
153
+
154
+ describe('selectAudioResourceUrl', () => {
155
+ beforeEach(() => {
156
+ vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockImplementation((type: string) =>
157
+ type === 'audio/ogg' ? 'maybe' : '',
158
+ );
159
+ });
160
+
161
+ it('returns the first source whose inferred type is playable', () => {
162
+ expect(selectAudioResourceUrl([{ url: 'a.mp3' }, { url: 'b.ogg' }])).toBe('b.ogg');
163
+ });
164
+
165
+ it('honours an explicit type over the URL extension', () => {
166
+ expect(selectAudioResourceUrl([{ type: 'audio/ogg', url: 'stream' }])).toBe('stream');
167
+ });
168
+
169
+ it('returns null when no source is playable', () => {
170
+ expect(selectAudioResourceUrl([{ url: 'a.mp3' }, { url: 'b.wav' }])).toBeNull();
171
+ });
20
172
  });