Skip to content

Commit a825121

Browse files
fix: resolve MP4 playback failures and decoder WASM memory leaks
Enable keepMdatData on MP4Box to support non-faststart MP4 containers where mdat precedes moov metadata. Add eager WASM cleanup and backpressure draining across audio decoders to prevent memory accumulation on stream teardown. This should fix the mass accumulation of WASM/libsamplerate allocations per-stream and JSArrayBufferData.
1 parent 2b58752 commit a825121

5 files changed

Lines changed: 249 additions & 143 deletions

File tree

dist/src/playback/player.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,7 @@ export class Player {
21302130
if (this.connection?.audioStream) {
21312131
this._snapshotPosition();
21322132
this.connection.audioStream.setFilters(this.filters);
2133+
this._fading('trackEndSchedule', { startPosition: this._realPosition() });
21332134
}
21342135
if (!this.nextResourceIsCrossfade) {
21352136
this.nextResource?.setFilters(this.filters);
@@ -2691,7 +2692,10 @@ export class Player {
26912692
if (!Number.isFinite(total) || total <= 0)
26922693
return false;
26932694
const startPosition = payload.startPosition || 0;
2694-
const remaining = Math.max(0, total - startPosition);
2695+
const playbackSpeed = this._getAudioStream()?.getEffectiveRate?.() ??
2696+
this._getTimescaleSpeed();
2697+
const remaining = Math.max(0, total - startPosition) /
2698+
(playbackSpeed > 0 ? playbackSpeed : 1);
26952699
const teSection = this.fading?.trackEnd;
26962700
const hasFade = teSection &&
26972701
Number.isFinite(teSection.duration) &&

dist/src/playback/processing/streamProcessor.js

Lines changed: 92 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -319,63 +319,71 @@ const _seekOffset = (res) => {
319319
};
320320
async function _buildMp4SeekOptions(url, seekTimeMs, proxy) {
321321
const mp4Box = await getMP4Box();
322-
const mp4 = mp4Box.createFile();
322+
const mp4 = mp4Box.createFile(false);
323323
const prefetch = [];
324324
let readyInfo = null;
325325
let nextStart = 0;
326-
await new Promise(async (resolve, reject) => {
327-
mp4.onError = (e) => reject(new Error(`MP4Box init error: ${e}`));
328-
mp4.onReady = (info) => {
329-
readyInfo = info;
330-
resolve();
331-
};
332-
const CHUNK = 512 * 1024;
333-
const MAX_FETCHES = 40;
334-
try {
335-
for (let i = 0; i < MAX_FETCHES && !readyInfo; i++) {
336-
const buf = await _fetchRange(url, nextStart, nextStart + CHUNK - 1, proxy);
337-
const ab = _toArrayBufferWithFileStart(buf, nextStart);
338-
prefetch.push({ fileStart: nextStart, data: ab });
339-
const appended = mp4.appendBuffer(ab);
340-
if (typeof appended === 'number') {
341-
nextStart = appended;
326+
try {
327+
await new Promise(async (resolve, reject) => {
328+
mp4.onError = (e) => reject(new Error(`MP4Box init error: ${e}`));
329+
mp4.onReady = (info) => {
330+
readyInfo = info;
331+
resolve();
332+
};
333+
const CHUNK = 512 * 1024;
334+
const MAX_FETCHES = 40;
335+
try {
336+
for (let i = 0; i < MAX_FETCHES && !readyInfo; i++) {
337+
const buf = await _fetchRange(url, nextStart, nextStart + CHUNK - 1, proxy);
338+
const ab = _toArrayBufferWithFileStart(buf, nextStart);
339+
prefetch.push({ fileStart: nextStart, data: ab });
340+
const appended = mp4.appendBuffer(ab);
341+
if (typeof appended === 'number') {
342+
nextStart = appended;
343+
}
344+
else {
345+
nextStart += ab.byteLength;
346+
}
347+
if (!Number.isFinite(nextStart) || nextStart < 0)
348+
break;
342349
}
343-
else {
344-
nextStart += ab.byteLength;
350+
if (!readyInfo) {
351+
reject(new Error('Could not parse MP4 metadata (moov not found quickly).'));
345352
}
346-
if (!Number.isFinite(nextStart) || nextStart < 0)
347-
break;
348353
}
349-
if (!readyInfo) {
350-
reject(new Error('Could not parse MP4 metadata (moov not found quickly).'));
354+
catch (e) {
355+
reject(e);
351356
}
357+
});
358+
const info = readyInfo;
359+
const audioTrack = info?.tracks.find((t) => t.codec?.startsWith('mp4a'));
360+
if (!audioTrack) {
361+
throw new Error('No AAC track found in MP4/M4A');
362+
}
363+
mp4.setExtractionOptions(audioTrack.id, null, { nbSamples: 1 });
364+
const seekTimeSec = seekTimeMs / 1000;
365+
const mp4boxFile = mp4;
366+
const seekRes = mp4boxFile.seek(seekTimeSec, true);
367+
const startOffset = _seekOffset(seekRes);
368+
if (!Number.isFinite(startOffset) || startOffset < 0) {
369+
throw new Error(`MP4Box seek returned invalid offset: ${JSON.stringify(seekRes)}`);
352370
}
353-
catch (e) {
354-
reject(e);
355-
}
356-
});
357-
const info = readyInfo;
358-
const audioTrack = info?.tracks.find((t) => t.codec?.startsWith('mp4a'));
359-
if (!audioTrack) {
360-
throw new Error('No AAC track found in MP4/M4A');
361-
}
362-
mp4.setExtractionOptions(audioTrack.id, null, { nbSamples: 1 });
363-
const seekTimeSec = seekTimeMs / 1000;
364-
const mp4boxFile = mp4;
365-
const seekRes = mp4boxFile.seek(seekTimeSec, true);
366-
const startOffset = _seekOffset(seekRes);
367-
try {
368-
mp4.stop();
371+
return {
372+
prefetch,
373+
baseFileStart: startOffset,
374+
seekTimeSec
375+
};
369376
}
370-
catch { }
371-
if (!Number.isFinite(startOffset) || startOffset < 0) {
372-
throw new Error(`MP4Box seek returned invalid offset: ${JSON.stringify(seekRes)}`);
377+
finally {
378+
try {
379+
mp4.stop();
380+
mp4.flush();
381+
}
382+
catch { }
383+
mp4.onReady = null;
384+
mp4.onSamples = null;
385+
mp4.onError = null;
373386
}
374-
return {
375-
prefetch,
376-
baseFileStart: startOffset,
377-
seekTimeSec
378-
};
379387
}
380388
const _createSeekableProxyRequest = (proxy) => {
381389
if (!proxy)
@@ -502,8 +510,10 @@ class BaseAudioResource {
502510
}
503511
for (let i = this.pipes.length - 1; i >= 0; i--) {
504512
const pipe = this.pipes[i];
513+
pipe.resume?.();
505514
pipe.abort?.();
506515
pipe.unpipe?.();
516+
pipe.cleanup?.();
507517
pipe.destroy?.();
508518
}
509519
this.pipes.length = 0;
@@ -694,6 +704,9 @@ class SymphoniaDecoderStream extends Transform {
694704
this._aborted = true;
695705
this._cancelTimers();
696706
}
707+
cleanup() {
708+
this._cleanup();
709+
}
697710
_cancelTimers() {
698711
if (this._timeoutId) {
699712
clearTimeout(this._timeoutId);
@@ -1044,15 +1057,28 @@ class AACDecoderStream extends Transform {
10441057
})
10451058
.catch((err) => this.emit('error', err));
10461059
}
1047-
_destroy(err, cb) {
1060+
cleanup() {
10481061
this.ringBuffer.dispose();
10491062
this.pendingChunks.length = 0;
1050-
if (this.decoder)
1051-
this.decoder.free?.();
1063+
if (this.decoder) {
1064+
try {
1065+
this.decoder.free?.();
1066+
this.decoder.destroy?.();
1067+
}
1068+
catch { }
1069+
this.decoder = null;
1070+
}
10521071
if (this.resampler) {
1053-
this.resampler.destroy?.();
1072+
try {
1073+
this.resampler.destroy?.();
1074+
}
1075+
catch { }
10541076
this.resampler = null;
10551077
}
1078+
this.resamplerCreationPromise = null;
1079+
}
1080+
_destroy(err, cb) {
1081+
this.cleanup();
10561082
super._destroy(err, cb);
10571083
}
10581084
_downmixToStereo(interleavedPCM, channels, samplesPerChannel) {
@@ -1213,12 +1239,25 @@ class AACDecoderStream extends Transform {
12131239
converterType: _getResamplerConverterType(this.resamplingQuality, libSampleRate)
12141240
}))
12151241
.then((resampler) => {
1242+
if (this.destroyed || this.closed) {
1243+
try {
1244+
resampler.destroy?.();
1245+
}
1246+
catch { }
1247+
return null;
1248+
}
12161249
this.resampler = resampler;
12171250
this.resamplerCreationPromise = null;
12181251
return resampler;
1252+
})
1253+
.catch((err) => {
1254+
this.resamplerCreationPromise = null;
1255+
throw err;
12191256
});
12201257
}
12211258
const resampler = await this.resamplerCreationPromise;
1259+
if (!resampler || this.destroyed || this.closed)
1260+
return;
12221261
const resampled = resampler.full(pcm);
12231262
const pcmInt16 = new Int16Array(resampled.length);
12241263
for (let i = 0; i < resampled.length; i++) {
@@ -1268,12 +1307,7 @@ class AACDecoderStream extends Transform {
12681307
}
12691308
catch (_err) { }
12701309
}
1271-
if (this.resampler) {
1272-
this.resampler.destroy?.();
1273-
this.resampler = null;
1274-
}
1275-
if (this.decoder)
1276-
this.decoder.destroy?.();
1310+
this.cleanup();
12771311
callback();
12781312
}
12791313
}
@@ -1375,7 +1409,7 @@ class MP4ToAACStream extends Transform {
13751409
}
13761410
this._initPromise = (async () => {
13771411
const mp4Box = await getMP4Box();
1378-
this.mp4boxFile = mp4Box.createFile(false);
1412+
this.mp4boxFile = mp4Box.createFile(true);
13791413
this._setupMP4BoxHandlers();
13801414
})();
13811415
await this._initPromise;

dist/src/sources/eternalbox.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,7 @@ export default class EternalboxSource {
683683
* @internal
684684
*/
685685
_parseMp4ToAdtsFrames(buffer) {
686-
const mp4boxFile = MP4Box.createFile();
686+
const mp4boxFile = MP4Box.createFile(false);
687687
const frames = [];
688688
const frameStarts = [];
689689
const frameEnds = [];
@@ -696,11 +696,11 @@ export default class EternalboxSource {
696696
return;
697697
timescale = audioTrack.timescale;
698698
audioConfig = this._getAudioConfig(audioTrack);
699-
mp4boxFile.setExtractionOptions(audioTrack.id, null, { nbSamples: 1 });
699+
mp4boxFile.setExtractionOptions(audioTrack.id, null, { nbSamples: 50 });
700700
mp4boxFile.start();
701701
};
702-
mp4boxFile.onSamples = (_id, _user, samples) => {
703-
if (!audioConfig || !timescale)
702+
mp4boxFile.onSamples = (id, _user, samples) => {
703+
if (!audioConfig || !timescale || !samples?.length)
704704
return;
705705
for (const sample of samples) {
706706
if (!sample?.data)
@@ -713,11 +713,27 @@ export default class EternalboxSource {
713713
frameStarts.push(sample.dts / timescale);
714714
frameEnds.push((sample.dts + sample.duration) / timescale);
715715
}
716+
const lastSample = samples[samples.length - 1];
717+
const lastNumber = lastSample?.number;
718+
if (typeof lastNumber === 'number') {
719+
const file = mp4boxFile;
720+
file.releaseUsedSamples(id, lastNumber + 1);
721+
}
716722
};
717723
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
718724
arrayBuffer.fileStart = 0;
719725
mp4boxFile.appendBuffer(arrayBuffer);
720-
mp4boxFile.flush();
726+
try {
727+
mp4boxFile.flush();
728+
}
729+
catch { }
730+
try {
731+
mp4boxFile.stop();
732+
}
733+
catch { }
734+
mp4boxFile.onReady = undefined;
735+
mp4boxFile.onSamples = undefined;
736+
mp4boxFile.onError = undefined;
721737
return { frames, frameStarts, frameEnds, totalBytes };
722738
}
723739
/**

0 commit comments

Comments
 (0)