diff --git a/packages/mp3-encoder/src/index.ts b/packages/mp3-encoder/src/index.ts index 6f9abc3..fdea91a 100644 --- a/packages/mp3-encoder/src/index.ts +++ b/packages/mp3-encoder/src/index.ts @@ -7,7 +7,7 @@ */ 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'; // @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand import createWorker from './encode.worker'; @@ -160,7 +160,7 @@ class Mp3Encoder extends CustomAudioEncoder { this.currentBufferOffset = requiredBufferSize; 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 header = readMp3FrameHeader(word, null).header; if (!header) { diff --git a/shared/mp3-misc.ts b/shared/mp3-misc.ts index 1b53994..6ff3a8c 100644 --- a/shared/mp3-misc.ts +++ b/shared/mp3-misc.ts @@ -6,7 +6,7 @@ * 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 KILOBIT_RATES = [ // lowSamplingFrequency === 0 @@ -122,7 +122,8 @@ export const readMp3FrameHeader = (word: number, remainingBytes: number | null): const layer = (secondByte >> 1) & 0x3; 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 channel = (fourthByte >> 6) & 0x3; @@ -212,3 +213,7 @@ export enum XingFlags { FileSize = 1 << 1, Toc = 1 << 2, } + +export const getMp3ChannelCount = (channel: number) => { + return channel === 3 ? 1 : 2; +}; diff --git a/src/id3.ts b/src/id3.ts index e1cb6e4..39094db 100644 --- a/src/id3.ts +++ b/src/id3.ts @@ -93,7 +93,7 @@ export const parseId3V1Tag = (slice: FileSlice, tags: MetadataTags) => { const yearText = readId3V1String(slice, 4); const year = Number.parseInt(yearText, 10); 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); @@ -339,7 +339,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad const year = Number.parseInt(yearText, 10); 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; diff --git a/src/input-format.ts b/src/input-format.ts index e1866a1..b11a00e 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -22,7 +22,7 @@ import { } from './matroska/ebml'; import { MatroskaDemuxer } from './matroska/matroska-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 { readNextMp3FrameHeader } from './mp3/mp3-reader'; 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 // 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) { return false; } diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index e1b6c86..b32d76e 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -28,6 +28,7 @@ import { XING, XingFlags, computeAverageMp3FrameSize, + getMp3ChannelCount, } from '../../shared/mp3-misc'; import { 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) { this.lastSampleLoaded = true; return; @@ -157,13 +163,6 @@ export class Mp3Demuxer extends Demuxer { 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 sample: Sample = { timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate, @@ -332,7 +331,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { getNumberOfChannels() { assert(this.demuxer.firstFrameHeader); - return this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2; + return getMp3ChannelCount(this.demuxer.firstFrameHeader.channel); } getSampleRate() { @@ -351,7 +350,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return { codec: 'mp3', - numberOfChannels: this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2, + numberOfChannels: getMp3ChannelCount(this.demuxer.firstFrameHeader.channel), sampleRate: this.demuxer.firstFrameHeader.sampleRate, }; } diff --git a/src/mp3/mp3-reader.ts b/src/mp3/mp3-reader.ts index cd7dd96..4897dda 100644 --- a/src/mp3/mp3-reader.ts +++ b/src/mp3/mp3-reader.ts @@ -6,10 +6,15 @@ * 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'; -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; startPos: number; } | null> => { @@ -21,11 +26,11 @@ export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, u ? Math.min(CHUNK_SIZE, until - currentPos) : 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 || 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 word = readU32Be(slice); const remainingBytes = reader.fileSize !== null @@ -33,7 +38,17 @@ export const readNextMp3FrameHeader = async (reader: Reader, startPos: number, u : null; 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 }; } diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 80b09e5..41d4d79 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -63,7 +63,11 @@ import { TRANSFER_CHARACTERISTICS_MAP_INVERSE, UNDETERMINED_LANGUAGE, } 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 { FileSlice, readBytes, Reader, readU16Be, readU32Be, readU8 } from '../reader'; 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; } else if (elementaryStream.info.codec === 'ac3') { const frameInfo = parseAc3SyncFrame(context.suppliedPacket.data); diff --git a/test/browser/mp3.test.ts b/test/browser/mp3.test.ts new file mode 100644 index 0000000..2c3af54 --- /dev/null +++ b/test/browser/mp3.test.ts @@ -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()); +}); diff --git a/test/public/malformed-join.mp3 b/test/public/malformed-join.mp3 new file mode 100644 index 0000000..46d7c32 Binary files /dev/null and b/test/public/malformed-join.mp3 differ