mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
Make MP3 next frame location more strict for better recovery (fixes #382), fix Date timezone for ID3 tags
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny';
|
import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny';
|
||||||
import { FRAME_HEADER_SIZE, readMp3FrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc';
|
import { MP3_FRAME_HEADER_SIZE, readMp3FrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc';
|
||||||
import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
|
import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
|
||||||
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
|
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
|
||||||
import createWorker from './encode.worker';
|
import createWorker from './encode.worker';
|
||||||
@@ -160,7 +160,7 @@ class Mp3Encoder extends CustomAudioEncoder {
|
|||||||
this.currentBufferOffset = requiredBufferSize;
|
this.currentBufferOffset = requiredBufferSize;
|
||||||
|
|
||||||
let pos = 0;
|
let pos = 0;
|
||||||
while (pos <= this.currentBufferOffset - FRAME_HEADER_SIZE) {
|
while (pos <= this.currentBufferOffset - MP3_FRAME_HEADER_SIZE) {
|
||||||
const word = new DataView(this.buffer.buffer).getUint32(pos, false);
|
const word = new DataView(this.buffer.buffer).getUint32(pos, false);
|
||||||
const header = readMp3FrameHeader(word, null).header;
|
const header = readMp3FrameHeader(word, null).header;
|
||||||
if (!header) {
|
if (!header) {
|
||||||
|
|||||||
+7
-2
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const FRAME_HEADER_SIZE = 4;
|
export const MP3_FRAME_HEADER_SIZE = 4;
|
||||||
export const SAMPLING_RATES = [44100, 48000, 32000];
|
export const SAMPLING_RATES = [44100, 48000, 32000];
|
||||||
export const KILOBIT_RATES = [
|
export const KILOBIT_RATES = [
|
||||||
// lowSamplingFrequency === 0
|
// lowSamplingFrequency === 0
|
||||||
@@ -122,7 +122,8 @@ export const readMp3FrameHeader = (word: number, remainingBytes: number | null):
|
|||||||
const layer = (secondByte >> 1) & 0x3;
|
const layer = (secondByte >> 1) & 0x3;
|
||||||
|
|
||||||
const bitrateIndex = (thirdByte >> 4) & 0xf;
|
const bitrateIndex = (thirdByte >> 4) & 0xf;
|
||||||
const frequencyIndex = ((thirdByte >> 2) & 0x3) % 3;
|
const frequencyIndex = ((thirdByte >> 2) & 0x3) % 3; // FFmpeg effectively does % 3 (but in a roundabout way)
|
||||||
|
|
||||||
const padding = (thirdByte >> 1) & 0x1;
|
const padding = (thirdByte >> 1) & 0x1;
|
||||||
|
|
||||||
const channel = (fourthByte >> 6) & 0x3;
|
const channel = (fourthByte >> 6) & 0x3;
|
||||||
@@ -212,3 +213,7 @@ export enum XingFlags {
|
|||||||
FileSize = 1 << 1,
|
FileSize = 1 << 1,
|
||||||
Toc = 1 << 2,
|
Toc = 1 << 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getMp3ChannelCount = (channel: number) => {
|
||||||
|
return channel === 3 ? 1 : 2;
|
||||||
|
};
|
||||||
|
|||||||
+2
-2
@@ -93,7 +93,7 @@ export const parseId3V1Tag = (slice: FileSlice, tags: MetadataTags) => {
|
|||||||
const yearText = readId3V1String(slice, 4);
|
const yearText = readId3V1String(slice, 4);
|
||||||
const year = Number.parseInt(yearText, 10);
|
const year = Number.parseInt(yearText, 10);
|
||||||
if (Number.isInteger(year) && year > 0) {
|
if (Number.isInteger(year) && year > 0) {
|
||||||
tags.date ??= new Date(year, 0, 1);
|
tags.date ??= new Date(String(year)); // String so that it parses as UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentBytes = readBytes(slice, 30);
|
const commentBytes = readBytes(slice, 30);
|
||||||
@@ -339,7 +339,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad
|
|||||||
const year = Number.parseInt(yearText, 10);
|
const year = Number.parseInt(yearText, 10);
|
||||||
|
|
||||||
if (Number.isInteger(year)) {
|
if (Number.isInteger(year)) {
|
||||||
tags.date ??= new Date(year, 0, 1);
|
tags.date ??= new Date(String(year)); // String so that it parses as UTC
|
||||||
}
|
}
|
||||||
}; break;
|
}; break;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ import {
|
|||||||
} from './matroska/ebml';
|
} from './matroska/ebml';
|
||||||
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
|
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
|
||||||
import { Mp3Demuxer } from './mp3/mp3-demuxer';
|
import { Mp3Demuxer } from './mp3/mp3-demuxer';
|
||||||
import { FRAME_HEADER_SIZE, getXingOffset, INFO, XING } from '../shared/mp3-misc';
|
import { MP3_FRAME_HEADER_SIZE, getXingOffset, INFO, XING } from '../shared/mp3-misc';
|
||||||
import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3';
|
import { ID3_V2_HEADER_SIZE, readId3V2Header } from './id3';
|
||||||
import { readNextMp3FrameHeader } from './mp3/mp3-reader';
|
import { readNextMp3FrameHeader } from './mp3/mp3-reader';
|
||||||
import { OggDemuxer } from './ogg/ogg-demuxer';
|
import { OggDemuxer } from './ogg/ogg-demuxer';
|
||||||
@@ -330,7 +330,7 @@ export class Mp3InputFormat extends InputFormat {
|
|||||||
|
|
||||||
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
|
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
|
||||||
// another header right after it:
|
// another header right after it:
|
||||||
const secondResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + FRAME_HEADER_SIZE);
|
const secondResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + MP3_FRAME_HEADER_SIZE);
|
||||||
if (!secondResult) {
|
if (!secondResult) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-10
@@ -28,6 +28,7 @@ import {
|
|||||||
XING,
|
XING,
|
||||||
XingFlags,
|
XingFlags,
|
||||||
computeAverageMp3FrameSize,
|
computeAverageMp3FrameSize,
|
||||||
|
getMp3ChannelCount,
|
||||||
} from '../../shared/mp3-misc';
|
} from '../../shared/mp3-misc';
|
||||||
import {
|
import {
|
||||||
ID3_V1_TAG_SIZE,
|
ID3_V1_TAG_SIZE,
|
||||||
@@ -108,7 +109,12 @@ export class Mp3Demuxer extends Demuxer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await readNextMp3FrameHeader(this.reader, this.lastLoadedPos, this.reader.fileSize);
|
const result = await readNextMp3FrameHeader(
|
||||||
|
this.reader,
|
||||||
|
this.lastLoadedPos,
|
||||||
|
this.reader.fileSize,
|
||||||
|
this.firstFrameHeader,
|
||||||
|
);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.lastSampleLoaded = true;
|
this.lastSampleLoaded = true;
|
||||||
return;
|
return;
|
||||||
@@ -157,13 +163,6 @@ export class Mp3Demuxer extends Demuxer {
|
|||||||
this.firstFrameHeaderPos = result.startPos;
|
this.firstFrameHeaderPos = result.startPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (header.sampleRate !== this.firstFrameHeader.sampleRate) {
|
|
||||||
console.warn(
|
|
||||||
`MP3 changed sample rate mid-file: ${this.firstFrameHeader.sampleRate} Hz to ${header.sampleRate} Hz.`
|
|
||||||
+ ` Might be a bug, so please report this file.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleDuration = header.audioSamplesInFrame / this.firstFrameHeader.sampleRate;
|
const sampleDuration = header.audioSamplesInFrame / this.firstFrameHeader.sampleRate;
|
||||||
const sample: Sample = {
|
const sample: Sample = {
|
||||||
timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate,
|
timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate,
|
||||||
@@ -332,7 +331,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
|||||||
|
|
||||||
getNumberOfChannels() {
|
getNumberOfChannels() {
|
||||||
assert(this.demuxer.firstFrameHeader);
|
assert(this.demuxer.firstFrameHeader);
|
||||||
return this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2;
|
return getMp3ChannelCount(this.demuxer.firstFrameHeader.channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSampleRate() {
|
getSampleRate() {
|
||||||
@@ -351,7 +350,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
codec: 'mp3',
|
codec: 'mp3',
|
||||||
numberOfChannels: this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2,
|
numberOfChannels: getMp3ChannelCount(this.demuxer.firstFrameHeader.channel),
|
||||||
sampleRate: this.demuxer.firstFrameHeader.sampleRate,
|
sampleRate: this.demuxer.firstFrameHeader.sampleRate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-6
@@ -6,10 +6,15 @@
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FRAME_HEADER_SIZE, Mp3FrameHeader, readMp3FrameHeader } from '../../shared/mp3-misc';
|
import { MP3_FRAME_HEADER_SIZE, getMp3ChannelCount, Mp3FrameHeader, readMp3FrameHeader } from '../../shared/mp3-misc';
|
||||||
import { Reader, readU32Be } from '../reader';
|
import { Reader, readU32Be } from '../reader';
|
||||||
|
|
||||||
export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, until: number | null): Promise<{
|
export const readNextMp3FrameHeader = async (
|
||||||
|
reader: Reader,
|
||||||
|
startPos: number,
|
||||||
|
until: number | null,
|
||||||
|
ref: Mp3FrameHeader | null = null,
|
||||||
|
): Promise<{
|
||||||
header: Mp3FrameHeader;
|
header: Mp3FrameHeader;
|
||||||
startPos: number;
|
startPos: number;
|
||||||
} | null> => {
|
} | null> => {
|
||||||
@@ -21,11 +26,11 @@ export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, u
|
|||||||
? Math.min(CHUNK_SIZE, until - currentPos)
|
? Math.min(CHUNK_SIZE, until - currentPos)
|
||||||
: CHUNK_SIZE;
|
: CHUNK_SIZE;
|
||||||
|
|
||||||
let slice = reader.requestSliceRange(currentPos, FRAME_HEADER_SIZE, maxLength);
|
let slice = reader.requestSliceRange(currentPos, MP3_FRAME_HEADER_SIZE, maxLength);
|
||||||
if (slice instanceof Promise) slice = await slice;
|
if (slice instanceof Promise) slice = await slice;
|
||||||
if (!slice || slice.length < FRAME_HEADER_SIZE) break;
|
if (!slice || slice.length < MP3_FRAME_HEADER_SIZE) break;
|
||||||
|
|
||||||
while (slice.remainingLength >= FRAME_HEADER_SIZE) {
|
while (slice.remainingLength >= MP3_FRAME_HEADER_SIZE) {
|
||||||
const posBeforeRead = slice.filePos;
|
const posBeforeRead = slice.filePos;
|
||||||
const word = readU32Be(slice);
|
const word = readU32Be(slice);
|
||||||
const remainingBytes = reader.fileSize !== null
|
const remainingBytes = reader.fileSize !== null
|
||||||
@@ -33,7 +38,17 @@ export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, u
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const result = readMp3FrameHeader(word, remainingBytes);
|
const result = readMp3FrameHeader(word, remainingBytes);
|
||||||
if (result.header) {
|
if (
|
||||||
|
result.header
|
||||||
|
&& (!ref || (
|
||||||
|
// This condition helps us recover malformed streams
|
||||||
|
// https://stackoverflow.com/a/20884944
|
||||||
|
result.header.sampleRate === ref.sampleRate
|
||||||
|
&& result.header.mpegVersionId === ref.mpegVersionId
|
||||||
|
&& result.header.layer === ref.layer
|
||||||
|
&& getMp3ChannelCount(result.header.channel) === getMp3ChannelCount(ref.channel)
|
||||||
|
))
|
||||||
|
) {
|
||||||
return { header: result.header, startPos: currentPos };
|
return { header: result.header, startPos: currentPos };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ import {
|
|||||||
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
||||||
UNDETERMINED_LANGUAGE,
|
UNDETERMINED_LANGUAGE,
|
||||||
} from '../misc';
|
} from '../misc';
|
||||||
import { FRAME_HEADER_SIZE as MP3_FRAME_HEADER_SIZE, readMp3FrameHeader } from '../../shared/mp3-misc';
|
import {
|
||||||
|
MP3_FRAME_HEADER_SIZE,
|
||||||
|
getMp3ChannelCount,
|
||||||
|
readMp3FrameHeader,
|
||||||
|
} from '../../shared/mp3-misc';
|
||||||
import { EncodedPacket, PacketType, PLACEHOLDER_DATA } from '../packet';
|
import { EncodedPacket, PacketType, PLACEHOLDER_DATA } from '../packet';
|
||||||
import { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../reader';
|
import { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../reader';
|
||||||
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
|
import { buildMpegTsMimeType, MpegTsStreamType, TIMESCALE, TS_PACKET_SIZE } from './mpeg-ts-misc';
|
||||||
@@ -629,7 +633,7 @@ export class MpegTsDemuxer extends Demuxer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
elementaryStream.info.numberOfChannels = result.header.channel === 3 ? 1 : 2;
|
elementaryStream.info.numberOfChannels = getMp3ChannelCount(result.header.channel);
|
||||||
elementaryStream.info.sampleRate = result.header.sampleRate;
|
elementaryStream.info.sampleRate = result.header.sampleRate;
|
||||||
} else if (elementaryStream.info.codec === 'ac3') {
|
} else if (elementaryStream.info.codec === 'ac3') {
|
||||||
const frameInfo = parseAc3SyncFrame(context.suppliedPacket.data);
|
const frameInfo = parseAc3SyncFrame(context.suppliedPacket.data);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { test } from 'vitest';
|
||||||
|
import { Input } from '../../src/input.js';
|
||||||
|
import { UrlSource } from '../../src/source.js';
|
||||||
|
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||||
|
import { assert } from '../../src/misc.js';
|
||||||
|
import { AudioSampleSink } from '../../src/media-sink.js';
|
||||||
|
|
||||||
|
// "joined" in the sense that it was two separate MP3s that were spliced together (I think)
|
||||||
|
test('Can decode malformed joined MP3', async () => {
|
||||||
|
using input = new Input({
|
||||||
|
source: new UrlSource('/malformed-join.mp3'),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioTrack = await input.getPrimaryAudioTrack();
|
||||||
|
assert(audioTrack);
|
||||||
|
|
||||||
|
const sink = new AudioSampleSink(audioTrack);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
for await (using sample of sink.samples());
|
||||||
|
});
|
||||||
Binary file not shown.
Reference in New Issue
Block a user