@flighthq/audio 0.3.0-next.1032.e2b99fc → 0.3.0-next.1220.6982c21

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 +1 @@
1
- {"version":3,"file":"audioResourceFrom.d.ts","sourceRoot":"","sources":["../src/audioResourceFrom.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAShF,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
+ {"version":3,"file":"audioResourceFrom.d.ts","sourceRoot":"","sources":["../src/audioResourceFrom.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAShF,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,CAWxB;AAED,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAgBxB;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"}
@@ -36,10 +36,24 @@ export async function loadAudioResourceFromBytes(context, bytes, mimeType, signa
36
36
  signal?.throwIfAborted();
37
37
  const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
38
38
  const audioBuffer = await context.decodeAudioData(buffer);
39
+ // decodeAudioData cannot be cancelled, so an abort landing mid-decode cannot stop the work — but it
40
+ // must still stop the result, or an aborted load resolves with a resource the caller no longer wants
41
+ // and is indistinguishable from one it asked for. This is the whole family's abort barrier: every
42
+ // loader here funnels through this function, so checking after the await is what makes "an aborted
43
+ // load never resolves" true for all of them rather than only for the pre-abort fast path above.
44
+ signal?.throwIfAborted();
39
45
  return createAudioResource(audioBuffer);
40
46
  }
41
47
  export async function loadAudioResourceFromUrl(context, url, signal) {
42
48
  const response = await fetch(url, { signal });
49
+ // fetch only rejects on a network-level failure, so a 404/500 arrives here as a perfectly good
50
+ // response whose body is an error page. Decoding that reports "unable to decode audio data" — the
51
+ // wrong failure, blaming the codec for a transport problem, after paying for a decode that never had
52
+ // a chance. Reporting the status keeps this function's existing reject-on-failure contract; whether
53
+ // this family should reject at all or return the empty-resource sentinel is the separate parked
54
+ // question in the assessment Backlog.
55
+ if (!response.ok)
56
+ throw new Error(`Failed to load audio: ${url} (${response.status} ${response.statusText})`);
43
57
  const arrayBuffer = await response.arrayBuffer();
44
58
  return loadAudioResourceFromBytes(context, new Uint8Array(arrayBuffer), response.headers.get('content-type') ?? undefined, signal);
45
59
  }
@@ -1 +1 @@
1
- {"version":3,"file":"audioResourceFrom.js","sourceRoot":"","sources":["../src/audioResourceFrom.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"audioResourceFrom.js","sourceRoot":"","sources":["../src/audioResourceFrom.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flighthq/audio",
3
- "version": "0.3.0-next.1032.e2b99fc",
3
+ "version": "0.3.0-next.1220.6982c21",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/flighthq/flight.git",
@@ -36,7 +36,7 @@
36
36
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
37
37
  },
38
38
  "dependencies": {
39
- "@flighthq/types": "0.3.0-next.1032.e2b99fc"
39
+ "@flighthq/types": "0.3.0-next.1220.6982c21"
40
40
  },
41
41
  "devDependencies": {
42
42
  "typescript": "^5.3.0"
@@ -14,6 +14,17 @@ const mockContext = {
14
14
  decodeAudioData: vi.fn().mockResolvedValue(decodedBuffer),
15
15
  } as unknown as AudioContext;
16
16
 
17
+ // A context whose decode is held open, so a test can land an abort while the decode is still in
18
+ // flight — the window the pre-abort fast path cannot see. `finishDecode` then completes it, modelling
19
+ // the real decodeAudioData, which has no cancellation and so always runs to completion.
20
+ function createPendingDecodeContext(): { context: AudioContext; finishDecode: () => void } {
21
+ let release: (buffer: AudioBuffer) => void = () => {};
22
+ const context = {
23
+ decodeAudioData: vi.fn(() => new Promise<AudioBuffer>((resolve) => (release = resolve))),
24
+ } as unknown as AudioContext;
25
+ return { context, finishDecode: () => release(decodedBuffer) };
26
+ }
27
+
17
28
  // jsdom lacks the AudioBuffer constructor; this minimal stand-in honours the { length,
18
29
  // numberOfChannels, sampleRate } constructor plus copyToChannel/getChannelData used by
19
30
  // createAudioResourceFromSamples.
@@ -78,6 +89,15 @@ describe('loadAudioResourceFromBase64', () => {
78
89
  expect(resource.buffer).toBe(decodedBuffer);
79
90
  expect(mockContext.decodeAudioData).toHaveBeenCalledOnce();
80
91
  });
92
+
93
+ it('rejects when the signal aborts while the decode is in flight', async () => {
94
+ const { context, finishDecode } = createPendingDecodeContext();
95
+ const controller = new AbortController();
96
+ const promise = loadAudioResourceFromBase64(context, btoa('abc'), 'audio/mpeg', controller.signal);
97
+ controller.abort(new Error('cancelled'));
98
+ finishDecode();
99
+ await expect(promise).rejects.toThrow('cancelled');
100
+ });
81
101
  });
82
102
 
83
103
  describe('loadAudioResourceFromBlob', () => {
@@ -91,6 +111,20 @@ describe('loadAudioResourceFromBlob', () => {
91
111
  expect(resource.buffer).toBe(decodedBuffer);
92
112
  expect(mockContext.decodeAudioData).toHaveBeenCalledOnce();
93
113
  });
114
+
115
+ it('rejects when the signal aborts while the decode is in flight', async () => {
116
+ const { context, finishDecode } = createPendingDecodeContext();
117
+ const blob = {
118
+ arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3, 4]).buffer),
119
+ type: 'audio/wav',
120
+ } as unknown as Blob;
121
+ const controller = new AbortController();
122
+ const promise = loadAudioResourceFromBlob(context, blob, controller.signal);
123
+ await Promise.resolve();
124
+ controller.abort(new Error('cancelled'));
125
+ finishDecode();
126
+ await expect(promise).rejects.toThrow('cancelled');
127
+ });
94
128
  });
95
129
 
96
130
  describe('loadAudioResourceFromBytes', () => {
@@ -112,6 +146,31 @@ describe('loadAudioResourceFromBytes', () => {
112
146
  loadAudioResourceFromBytes(mockContext, new Uint8Array([1, 2, 3, 4]), undefined, controller.signal),
113
147
  ).rejects.toThrow('cancelled');
114
148
  });
149
+
150
+ // Every loader in this family funnels through here, and each is covered separately, because the
151
+ // guarantee is per entry point: a barrier that only holds for direct callers still lets the wrappers
152
+ // resolve past an abort.
153
+ it('rejects when the signal aborts while the decode is in flight', async () => {
154
+ const { context, finishDecode } = createPendingDecodeContext();
155
+ const controller = new AbortController();
156
+ const promise = loadAudioResourceFromBytes(context, new Uint8Array([1, 2, 3, 4]), undefined, controller.signal);
157
+ controller.abort(new Error('cancelled'));
158
+ finishDecode();
159
+ await expect(promise).rejects.toThrow('cancelled');
160
+ });
161
+
162
+ it('does not resolve with a decoded buffer after an abort', async () => {
163
+ const { context, finishDecode } = createPendingDecodeContext();
164
+ const controller = new AbortController();
165
+ const promise = loadAudioResourceFromBytes(context, new Uint8Array([1, 2, 3, 4]), undefined, controller.signal);
166
+ controller.abort(new Error('cancelled'));
167
+ finishDecode();
168
+ const settled = await promise.then(
169
+ (resource) => `resolved:${resource.buffer !== null}`,
170
+ () => 'rejected',
171
+ );
172
+ expect(settled).toBe('rejected');
173
+ });
115
174
  });
116
175
 
117
176
  describe('loadAudioResourceFromUrl', () => {
@@ -119,6 +178,7 @@ describe('loadAudioResourceFromUrl', () => {
119
178
  vi.stubGlobal(
120
179
  'fetch',
121
180
  vi.fn().mockResolvedValue({
181
+ ok: true,
122
182
  arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
123
183
  headers: new Headers({ 'content-type': 'audio/mpeg' }),
124
184
  }),
@@ -126,6 +186,44 @@ describe('loadAudioResourceFromUrl', () => {
126
186
  const resource = await loadAudioResourceFromUrl(mockContext, 'sound.mp3');
127
187
  expect(resource.buffer).toBe(decodedBuffer);
128
188
  });
189
+
190
+ // fetch resolves for 404/500, so without a status check the error page reaches the decoder and the
191
+ // caller is told the codec failed. The assertion pins the status, not merely that it rejected.
192
+ it('rejects with the HTTP status rather than decoding an error response', async () => {
193
+ const decodeAudioData = vi.fn().mockResolvedValue(decodedBuffer);
194
+ vi.stubGlobal(
195
+ 'fetch',
196
+ vi.fn().mockResolvedValue({
197
+ ok: false,
198
+ status: 404,
199
+ statusText: 'Not Found',
200
+ arrayBuffer: () => Promise.resolve(new TextEncoder().encode('<html>404</html>').buffer),
201
+ headers: new Headers({ 'content-type': 'text/html' }),
202
+ }),
203
+ );
204
+ await expect(
205
+ loadAudioResourceFromUrl({ decodeAudioData } as unknown as AudioContext, 'missing.mp3'),
206
+ ).rejects.toThrow('Failed to load audio: missing.mp3 (404 Not Found)');
207
+ expect(decodeAudioData).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it('rejects when the signal aborts while the decode is in flight', async () => {
211
+ const { context, finishDecode } = createPendingDecodeContext();
212
+ vi.stubGlobal(
213
+ 'fetch',
214
+ vi.fn().mockResolvedValue({
215
+ ok: true,
216
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
217
+ headers: new Headers({ 'content-type': 'audio/mpeg' }),
218
+ }),
219
+ );
220
+ const controller = new AbortController();
221
+ const promise = loadAudioResourceFromUrl(context, 'sound.mp3', controller.signal);
222
+ await Promise.resolve();
223
+ controller.abort(new Error('cancelled'));
224
+ finishDecode();
225
+ await expect(promise).rejects.toThrow('cancelled');
226
+ });
129
227
  });
130
228
 
131
229
  describe('loadAudioResourceFromUrls', () => {
@@ -139,6 +237,7 @@ describe('loadAudioResourceFromUrls', () => {
139
237
  type === 'audio/ogg' ? 'probably' : '',
140
238
  );
141
239
  const fetchMock = vi.fn().mockResolvedValue({
240
+ ok: true,
142
241
  arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
143
242
  headers: new Headers(),
144
243
  });