Compare commits

..
11 Commits
Author SHA1 Message Date
Vanilagy c67c5e4072 Bump patch 2026-09-04 22:56:36 +02:00
hi.jack2eeandGitHub 6c88763a5c Terminate sample iterators before throwing so late-decoded samples are closed (#484)
for await never calls return() on an iterator whose next() threw, so the
error branches in mediaSamplesInRange / mediaSamplesAtTimestamps left the
iteration un-terminated: the pump's decoder callback kept queueing decoded
samples that no consumer or cleanup path could ever close, orphaning them
until GC ("A VideoSample was garbage collected without first being closed").

Set terminated (and ended, where the pump gates on it) before throwing in
both the InputDisposedError and out-of-band error branches of both sample
generators - samples emitted after the throw are then closed on arrival by
the existing checks in the decoder callbacks.

Observed on iOS Safari when the hardware VideoDecoder dies mid-iteration
("Decoding task did not complete"): 3-14 samples orphaned per failing
iterator; with this change, at most the single sample already in flight
during the error microtask window.
2026-09-04 10:01:18 +02:00
Vanilagy 5954a472fa Remove console.log 2026-09-04 10:00:33 +02:00
Vanilagy 6319bf2eee Bump patch 2026-09-03 10:48:32 +02:00
Vanilagy 5ffb5a0979 Add sponsor 2026-09-03 10:48:03 +02:00
Vanilagy cf2edfada2 Relax audio track assert for esds box (fixes #481) 2026-09-02 09:32:06 +02:00
Vanilagy a97e9800d2 Bump patch 2026-08-31 14:11:25 +02:00
Vanilagy 23f5e96afd Rely not only on FinalizationRegistry 2026-08-31 14:05:33 +02:00
Vanilagy 9e203c0c8a Manually close file readers to avoid indefinite memory leak (fixes #144) 2026-08-31 13:44:58 +02:00
Vanilagy dec669163f Add reliable AudioSample.copyTo() fallback for unsupported destination formats (fixes #479) 2026-08-30 12:49:22 +02:00
Vanilagy e16b01a4c6 Fix Infinity not being allowed where it should be (fixes #478) 2026-08-30 12:19:25 +02:00
18 changed files with 301 additions and 100 deletions
+1
View File
@@ -127,6 +127,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/2565549', name: 'MatthewNorton', url: 'https://github.com/MatthewNorton' },
{ image: 'https://avatars.githubusercontent.com/u/272247752', name: 'Motionik', url: 'https://github.com/Motionik' },
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/41021374', name: 'arth', url: 'https://github.com/arthtyagi' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/536008', name: 'Satish Goda', url: 'https://github.com/satishgoda' },
+10 -10
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.55.4",
"version": "1.55.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.55.4",
"version": "1.55.7",
"license": "MPL-2.0",
"workspaces": [
".",
@@ -12958,7 +12958,7 @@
},
"packages/aac-encoder": {
"name": "@mediabunny/aac-encoder",
"version": "1.55.4",
"version": "1.55.7",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12973,7 +12973,7 @@
},
"packages/ac3": {
"name": "@mediabunny/ac3",
"version": "1.55.4",
"version": "1.55.7",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12988,7 +12988,7 @@
},
"packages/dts": {
"name": "@mediabunny/dts",
"version": "1.55.4",
"version": "1.55.7",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -13003,7 +13003,7 @@
},
"packages/flac-encoder": {
"name": "@mediabunny/flac-encoder",
"version": "1.55.4",
"version": "1.55.7",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -13018,7 +13018,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.55.4",
"version": "1.55.7",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -13033,7 +13033,7 @@
},
"packages/prores": {
"name": "@mediabunny/prores",
"version": "1.55.4",
"version": "1.55.7",
"license": "MPL-2.0",
"dependencies": {
"turbores": "^1.2.2"
@@ -13048,10 +13048,10 @@
},
"packages/server": {
"name": "@mediabunny/server",
"version": "1.55.4",
"version": "1.55.7",
"license": "MPL-2.0",
"dependencies": {
"@mediabunny/prores": "^1.55.4",
"@mediabunny/prores": "^1.55.7",
"node-av": "^6.0.0"
},
"funding": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.55.4",
"version": "1.55.7",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/aac-encoder",
"author": "Vanilagy",
"version": "1.55.4",
"version": "1.55.7",
"description": "AAC encoder extension for Mediabunny, based on libavcodec.",
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/ac3",
"author": "Vanilagy",
"version": "1.55.4",
"version": "1.55.7",
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on libavcodec.",
"main": "./dist/bundles/mediabunny-ac3.mjs",
"module": "./dist/bundles/mediabunny-ac3.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/dts",
"author": "Vanilagy",
"version": "1.55.4",
"version": "1.55.7",
"description": "DTS decoder and encoder extension for Mediabunny, based on libavcodec.",
"main": "./dist/bundles/mediabunny-dts.mjs",
"module": "./dist/bundles/mediabunny-dts.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/flac-encoder",
"author": "Vanilagy",
"version": "1.55.4",
"version": "1.55.7",
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.55.4",
"version": "1.55.7",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/prores",
"author": "Vanilagy",
"version": "1.55.4",
"version": "1.55.7",
"description": "Apple ProRes decoder extension for Mediabunny, based on TurboRes.",
"main": "./dist/bundles/mediabunny-prores.mjs",
"module": "./dist/bundles/mediabunny-prores.mjs",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/server",
"author": "Vanilagy",
"version": "1.55.4",
"version": "1.55.7",
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.",
"main": "./dist/bundles/mediabunny-server.cjs",
"module": "./dist/bundles/mediabunny-server.mjs",
@@ -35,7 +35,7 @@
},
"dependencies": {
"node-av": "^6.0.0",
"@mediabunny/prores": "^1.55.4"
"@mediabunny/prores": "^1.55.7"
},
"peerDependencies": {
"mediabunny": "^1.45.0"
+11 -2
View File
@@ -12,7 +12,16 @@ import { customAudioDecoders, customVideoDecoders } from './custom-coder';
import { Input } from './input';
import { Logging } from './logging';
import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink';
import { assert, isThenable, MaybePromise, Rational, Rotation, roundToDivisor, simplifyRational } from './misc';
import {
assert,
isNumber,
isThenable,
MaybePromise,
Rational,
Rotation,
roundToDivisor,
simplifyRational,
} from './misc';
import { TrackType } from './output';
import { EncodedPacket, PacketType } from './packet';
import { TrackDisposition } from './metadata';
@@ -869,7 +878,7 @@ export class InputVideoTrack extends InputTrack {
}
if (
options.targetPacketCount !== undefined
&& (!Number.isFinite(options.targetPacketCount) || options.targetPacketCount < 0)
&& (!isNumber(options.targetPacketCount) || options.targetPacketCount < 0)
) {
throw new TypeError('options.targetPacketCount must be a non-negative number.');
}
+1 -2
View File
@@ -1558,10 +1558,9 @@ export class IsobmffDemuxer extends Demuxer {
case 'esds': {
const track = this.currentTrack;
if (!track) {
if (!track || track.info?.type !== 'audio') {
break;
}
assert(track.info?.type === 'audio');
slice.skip(4); // Version + flags
+12
View File
@@ -601,11 +601,18 @@ export abstract class BaseMediaSampleSink<
async next() {
while (true) {
if (track.input._disposed) {
// Once next() throws, the consumer will never call return(), so terminate the
// iteration here - otherwise, the pump keeps queueing decoded samples that
// nothing can ever close.
terminated = true;
ended = true;
closeSamples();
throw new InputDisposedError();
} else if (terminated) {
return { value: undefined, done: true };
} else if (hasOutOfBandError) {
terminated = true;
ended = true;
closeSamples();
throw outOfBandError;
} else if (sampleQueue.length > 0) {
@@ -827,11 +834,16 @@ export abstract class BaseMediaSampleSink<
async next() {
while (true) {
if (track.input._disposed) {
// Once next() throws, the consumer will never call return(), so terminate the
// iteration here - otherwise, the pump keeps queueing decoded samples that
// nothing can ever close.
terminated = true;
closeSamples();
throw new InputDisposedError();
} else if (terminated) {
return { value: undefined, done: true };
} else if (hasOutOfBandError) {
terminated = true;
closeSamples();
throw outOfBandError;
} else if (sampleQueue.length > 0) {
+4 -4
View File
@@ -30,7 +30,7 @@ import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer';
import { WaveMuxer } from './wave/wave-muxer';
import { HlsMuxer } from './hls/hls-muxer';
import { HLS_MIME_TYPE } from './hls/hls-misc';
import { MaybePromise, FilePath, toArray } from './misc';
import { MaybePromise, FilePath, isNumber, toArray } from './misc';
import { Target } from './target';
/**
@@ -229,7 +229,7 @@ export abstract class IsobmffOutputFormat extends OutputFormat {
}
if (
options.minimumFragmentDuration !== undefined
&& (!Number.isFinite(options.minimumFragmentDuration) || options.minimumFragmentDuration < 0)
&& (!isNumber(options.minimumFragmentDuration) || options.minimumFragmentDuration < 0)
) {
throw new TypeError('options.minimumFragmentDuration, when provided, must be a non-negative number.');
}
@@ -514,7 +514,7 @@ export class MkvOutputFormat extends OutputFormat {
}
if (
options.minimumClusterDuration !== undefined
&& (!Number.isFinite(options.minimumClusterDuration) || options.minimumClusterDuration < 0)
&& (!isNumber(options.minimumClusterDuration) || options.minimumClusterDuration < 0)
) {
throw new TypeError('options.minimumClusterDuration, when provided, must be a non-negative number.');
}
@@ -861,7 +861,7 @@ export class OggOutputFormat extends OutputFormat {
}
if (
options.maximumPageDuration !== undefined
&& (!Number.isFinite(options.maximumPageDuration) || options.maximumPageDuration <= 0)
&& (!isNumber(options.maximumPageDuration) || options.maximumPageDuration <= 0)
) {
throw new TypeError('options.maximumPageDuration, when provided, must be a positive number.');
}
+112 -67
View File
@@ -2658,7 +2658,7 @@ export class AudioSample implements Disposable {
const { format, frameCount: optFrameCount, frameOffset: optFrameOffset } = options;
let { planeIndex } = options;
const srcFormat = this.format;
let srcFormat = this.format;
const destFormat = format ?? this.format;
if (!destFormat) throw new Error('Destination format not determined');
@@ -2705,88 +2705,133 @@ export class AudioSample implements Disposable {
frameOffset,
copyFrameCount,
);
return;
} else {
// Per spec, only f32-planar conversion must be supported, but in practice, all browsers support all
// destination formats, so let's just delegate here:
this._data.copyTo(destination, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: destFormat,
});
try {
// The spec only requires f32-planar to work here, but most of the time, all formats work. Sometimes
// they don't, and that's why we try-catch it.
this._data.copyTo(destination, {
planeIndex,
frameOffset,
frameCount: copyFrameCount,
format: destFormat,
});
return;
} catch (error) {
if (destFormat === 'f32-planar') {
throw error;
}
// Format conversion likely wasn't supported, so let's fall back to the manual conversion path
srcFormat = 'f32-planar';
}
}
} else {
const readFn = getReadFunction(srcFormat);
const srcBytesPerSample = getBytesPerSample(srcFormat);
const srcIsPlanar = formatIsPlanar(srcFormat);
}
let uint8Data: Uint8Array;
if (this._data instanceof AudioSampleResource) {
const getDataPlaneValidated = (index: number) => {
const result = (this._data as AudioSampleResource).getDataPlane(index);
if (!(result instanceof Uint8Array)) {
throw new TypeError('getDataPlane() must return a Uint8Array.');
}
const readFn = getReadFunction(srcFormat);
const srcBytesPerSample = getBytesPerSample(srcFormat);
const srcIsPlanar = formatIsPlanar(srcFormat);
const expectedSize = numFrames * srcBytesPerSample * (srcIsPlanar ? 1 : numChannels);
if (result.byteLength !== expectedSize) {
throw new TypeError(
`Data plane ${index} has invalid size. Expected exactly ${expectedSize} bytes, got`
+ ` ${result.byteLength} bytes.`,
);
}
let uint8Data: Uint8Array;
if (this._data instanceof AudioSampleResource) {
const getDataPlaneValidated = (index: number) => {
const result = (this._data as AudioSampleResource).getDataPlane(index);
if (!(result instanceof Uint8Array)) {
throw new TypeError('getDataPlane() must return a Uint8Array.');
}
return result;
};
const expectedSize = numFrames * srcBytesPerSample * (srcIsPlanar ? 1 : numChannels);
if (result.byteLength !== expectedSize) {
throw new TypeError(
`Data plane ${index} has invalid size. Expected exactly ${expectedSize} bytes, got`
+ ` ${result.byteLength} bytes.`,
);
}
if (srcIsPlanar) {
if (destIsPlanar) {
// Only one source plane will be extracted, so let's fetch only that one
uint8Data = getDataPlaneValidated(planeIndex);
planeIndex = 0; // To fix the subsequent access
} else {
// Pack all planes tightly together
uint8Data = new Uint8Array(numFrames * srcBytesPerSample * numChannels);
for (let ch = 0; ch < numChannels; ch++) {
const planeData = getDataPlaneValidated(ch);
uint8Data.set(planeData, ch * numFrames * srcBytesPerSample);
}
}
return result;
};
if (srcIsPlanar) {
if (destIsPlanar) {
// Only one source plane will be extracted, so let's fetch only that one
uint8Data = getDataPlaneValidated(planeIndex);
planeIndex = 0; // To fix the subsequent access
} else {
uint8Data = getDataPlaneValidated(0); // That's the only plane there is
// Pack all planes tightly together
uint8Data = new Uint8Array(numFrames * srcBytesPerSample * numChannels);
for (let ch = 0; ch < numChannels; ch++) {
const planeData = getDataPlaneValidated(ch);
uint8Data.set(planeData, ch * numFrames * srcBytesPerSample);
}
}
} else {
uint8Data = this._data;
uint8Data = getDataPlaneValidated(0); // That's the only plane there is
}
} else if (this._data instanceof Uint8Array) {
uint8Data = this._data;
} else {
assert(srcFormat === 'f32-planar');
const srcView = toDataView(uint8Data);
if (destIsPlanar) {
// Only one source plane will be read, so let's copy only that one
uint8Data = new Uint8Array(this._data.allocationSize({
format: 'f32-planar',
planeIndex,
}));
this._data.copyTo(uint8Data, {
format: 'f32-planar',
planeIndex,
});
planeIndex = 0; // To fix the subsequent access
} else {
// All planes will be read, so fetch 'em all
uint8Data = new Uint8Array(this._data.allocationSize({
format: 'f32-planar',
planeIndex: 0,
}) * numChannels);
for (let i = 0; i < copyFrameCount; i++) {
if (destIsPlanar) {
const destOffset = i * destBytesPerSample;
for (let ch = 0; ch < numChannels; ch++) {
this._data.copyTo(
uint8Data.subarray(
ch * numFrames * srcBytesPerSample,
(ch + 1) * numFrames * srcBytesPerSample,
),
{
format: 'f32-planar',
planeIndex: ch,
},
);
}
}
}
const srcView = toDataView(uint8Data);
for (let i = 0; i < copyFrameCount; i++) {
if (destIsPlanar) {
const destOffset = i * destBytesPerSample;
let srcOffset: number;
if (srcIsPlanar) {
srcOffset = (planeIndex * numFrames + (i + frameOffset)) * srcBytesPerSample;
} else {
srcOffset = (((i + frameOffset) * numChannels) + planeIndex) * srcBytesPerSample;
}
const normalized = readFn(srcView, srcOffset);
writeFn(destView, destOffset, normalized);
} else {
for (let ch = 0; ch < numChannels; ch++) {
const destIndex = i * numChannels + ch;
const destOffset = destIndex * destBytesPerSample;
let srcOffset: number;
if (srcIsPlanar) {
srcOffset = (planeIndex * numFrames + (i + frameOffset)) * srcBytesPerSample;
srcOffset = (ch * numFrames + (i + frameOffset)) * srcBytesPerSample;
} else {
srcOffset = (((i + frameOffset) * numChannels) + planeIndex) * srcBytesPerSample;
srcOffset = (((i + frameOffset) * numChannels) + ch) * srcBytesPerSample;
}
const normalized = readFn(srcView, srcOffset);
writeFn(destView, destOffset, normalized);
} else {
for (let ch = 0; ch < numChannels; ch++) {
const destIndex = i * numChannels + ch;
const destOffset = destIndex * destBytesPerSample;
let srcOffset: number;
if (srcIsPlanar) {
srcOffset = (ch * numFrames + (i + frameOffset)) * srcBytesPerSample;
} else {
srcOffset = (((i + frameOffset) * numChannels) + ch) * srcBytesPerSample;
}
const normalized = readFn(srcView, srcOffset);
writeFn(destView, destOffset, normalized);
}
}
}
}
@@ -3201,9 +3246,9 @@ export const toInterleavedAudioFormat = (format: AudioSampleFormat): 'u8' | 's16
};
/**
* WebKit has a bug where calling AudioData.copyTo with a format different from the source format
* crashes the tab when there are more than 2 channels. This function works around that by always
* copying with the source format and then manually converting to the destination format.
* WebKit has a bug where calling AudioData.copyTo with a format different from the source format crashes the tab when
* there are more than 2 channels. This function works around that by always copying with the source format and then
* manually converting to the destination format.
*
* See https://bugs.webkit.org/show_bug.cgi?id=302521.
*/
+36 -4
View File
@@ -496,6 +496,14 @@ export type BlobSourceOptions = {
useStreamReader?: boolean;
};
const blobReaderRegistry = typeof FinalizationRegistry !== 'undefined'
? new FinalizationRegistry<ReadableStreamDefaultReader<Uint8Array>>((reader) => {
// Browsers don't GC readers that aren't "done", which creates indefinite memory leaks,
// see https://github.com/Vanilagy/mediabunny/issues/144#issuecomment-5465467062. So, we need to do it instead.
void reader.cancel().catch(() => {});
})
: null;
/**
* A source backed by a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). Since a
* [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) is also a `Blob`, this is the source to use when
@@ -510,6 +518,8 @@ export class BlobSource extends Source {
_options: BlobSourceOptions;
/** @internal */
_orchestrator: ReadOrchestrator;
/** @internal */
_readers = new WeakMap<ReadWorker, ReadableStreamDefaultReader<Uint8Array> | null>();
/**
* Creates a new {@link BlobSource} backed by the specified
@@ -541,6 +551,17 @@ export class BlobSource extends Source {
maxCacheSize: options.maxCacheSize ?? (8 * 2 ** 20 /* 8 MiB */),
maxWorkerCount: 4,
runWorker: this._runWorker.bind(this),
onIdleWorkerRemoved: (worker) => {
const reader = this._readers.get(worker);
if (reader) {
this._readers.delete(worker);
blobReaderRegistry?.unregister(worker);
// If we don't do this, memory leaks indefinitely
void reader.cancel().catch(() => {});
}
},
prefetchProfile: PREFETCH_PROFILES.fileSystem,
});
@@ -562,9 +583,6 @@ export class BlobSource extends Source {
return this._orchestrator.read(start, end, minReadPosition, maxReadPosition);
}
/** @internal */
_readers = new WeakMap<ReadWorker, ReadableStreamDefaultReader<Uint8Array> | null>();
/** @internal */
private async _runWorker(worker: ReadWorker) {
assert(worker.strictTarget);
@@ -578,10 +596,15 @@ export class BlobSource extends Source {
// - ReadableStream stalls under backpressure (especially video)
// Affects Safari and all iOS browsers (Chrome, Firefox, etc.).
// Use arrayBuffer() fallback for WebKit browsers.
if ('stream' in this._blob && !isWebKit() && this._options.useStreamReader !== false) {
if (
'stream' in this._blob && !isWebKit()
&& this._options.useStreamReader !== false
) {
// Get a reader of the blob starting at the required offset, and then keep it around
const slice = this._blob.slice(worker.currentPos);
reader = slice.stream().getReader();
blobReaderRegistry?.register(worker, reader, worker);
} else {
// We'll need to use more primitive ways
reader = null;
@@ -1970,6 +1993,7 @@ class ReadOrchestrator {
runWorker: (worker: ReadWorker) => Promise<void>;
prefetchProfile: PrefetchProfile;
maxWorkerCount: number;
onIdleWorkerRemoved?: (worker: ReadWorker) => void;
}) {}
read(
@@ -2263,6 +2287,7 @@ class ReadOrchestrator {
assert(oldestIndex !== null);
assert(oldestWorker.pendingSlices.length === 0);
this.workers.splice(oldestIndex, 1);
this.options.onIdleWorkerRemoved?.(oldestWorker);
} else {
return null; // All workers are still running, we can't create a new one
}
@@ -2410,6 +2435,7 @@ class ReadOrchestrator {
otherWorker.currentPos, otherWorker.targetPos, // These should typically be equal when the worker's idle
)) {
this.workers.splice(i, 1);
this.options.onIdleWorkerRemoved?.(otherWorker);
i--;
}
}
@@ -2486,6 +2512,7 @@ class ReadOrchestrator {
worker.running = false;
this.workers.splice(index, 1);
this.options.onIdleWorkerRemoved?.(worker);
if (this.fileSize === null) {
// We can now deduce the file size!
@@ -2597,6 +2624,11 @@ class ReadOrchestrator {
worker.pendingSlices.length = 0;
worker.aborted = true;
if (!worker.running) {
// Running workers clean up after themselves when they notice the abort
this.options.onIdleWorkerRemoved?.(worker);
}
}
for (const queuedRead of this.queuedReads) {
+105
View File
@@ -137,3 +137,108 @@ test('trim, throws on a closed sample', () => {
sample.close();
expect(() => sample.trim(0, 5)).toThrow('AudioSample is closed.');
});
test('copyTo, AudioData-backed, planar destination falls back to manual conversion', () => {
withMockedNativeCopyTo((calls) => {
using sample = makeAudioDataBackedSample();
for (let c = 0; c < NUM_CHANNELS; c++) {
const out = new Int16Array(NUM_FRAMES);
sample.copyTo(out, { planeIndex: c, format: 's16-planar' });
for (let f = 0; f < NUM_FRAMES; f++) {
expect(out[f]).toBe(cellValue(f, c));
}
}
// With a frame offset
const out = new Int16Array(NUM_FRAMES - 3);
sample.copyTo(out, { planeIndex: 1, format: 's16-planar', frameOffset: 3 });
for (let f = 0; f < NUM_FRAMES - 3; f++) {
expect(out[f]).toBe(cellValue(f + 3, 1));
}
// The native conversion must have been attempted (and thrown)
expect(calls.some(options => options.format === 's16-planar')).toBe(true);
});
});
test('copyTo, AudioData-backed, interleaved destination falls back to manual conversion', () => {
withMockedNativeCopyTo((calls) => {
using sample = makeAudioDataBackedSample();
const out = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
sample.copyTo(out, { planeIndex: 0, format: 'f32' });
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
expect(out[f * NUM_CHANNELS + c]).toBe(Math.fround(cellValue(f, c) / 32767));
}
}
// The native conversion must have been attempted (and thrown)
expect(calls.some(options => options.format === 'f32')).toBe(true);
});
});
test('copyTo, AudioData-backed, control case with native format conversion', () => {
using sample = makeAudioDataBackedSample();
for (let c = 0; c < NUM_CHANNELS; c++) {
const out = new Int16Array(NUM_FRAMES);
sample.copyTo(out, { planeIndex: c, format: 's16-planar' });
for (let f = 0; f < NUM_FRAMES; f++) {
// Allow off-by-one
expect(Math.abs(out[f]! - cellValue(f, c))).toBeLessThanOrEqual(1);
}
}
const out = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
sample.copyTo(out, { planeIndex: 0, format: 'f32' });
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
expect(out[f * NUM_CHANNELS + c]).toBe(Math.fround(cellValue(f, c) / 32767));
}
}
});
const withMockedNativeCopyTo = (fn: (calls: AudioDataCopyToOptions[]) => void) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const original = AudioData.prototype.copyTo;
const calls: AudioDataCopyToOptions[] = [];
AudioData.prototype.copyTo = function (this: AudioData, destination, options) {
calls.push(options);
if (options.format !== undefined && options.format !== 'f32-planar') {
throw new DOMException('Format conversion not supported.', 'NotSupportedError');
}
return original.call(this, destination, options);
};
try {
fn(calls);
} finally {
AudioData.prototype.copyTo = original;
}
};
const makeAudioDataBackedSample = () => {
const data = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
data[f * NUM_CHANNELS + c] = cellValue(f, c) / 32767;
}
}
return new AudioSample(new AudioData({
format: 'f32',
sampleRate: SAMPLE_RATE,
numberOfFrames: NUM_FRAMES,
numberOfChannels: NUM_CHANNELS,
timestamp: TIMESTAMP * 1e6,
data,
}));
};
@@ -30,8 +30,6 @@ test('FLAC encoder, 24-bit', async () => {
expect(result.size).toBeGreaterThan(90_000);
console.log(result.size);
expect(sample.format).toBe('s32');
});