Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix MP3 Xing/LAME header handling #683

Merged
merged 2 commits into from
Dec 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions lib/mpeg/MpegParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as initDebug from 'debug';

import Common from '../common/Util';
import { AbstractID3Parser } from '../id3v2/AbstractID3Parser';
import { InfoTagHeaderTag, IXingInfoTag, LameEncoderVersion, XingInfoTag } from './XingTag';
import { InfoTagHeaderTag, IXingInfoTag, LameEncoderVersion, readXingHeader } from './XingTag';

const debug = initDebug('music-metadata:parser:mpeg');

Expand Down Expand Up @@ -595,12 +595,15 @@ export class MpegParser extends AbstractID3Parser {
*/
private async readXingInfoHeader(): Promise<IXingInfoTag> {

const infoTag = await this.tokenizer.readToken<IXingInfoTag>(XingInfoTag);
this.offset += XingInfoTag.len; // 12
const _offset = this.tokenizer.position;
const infoTag = await readXingHeader(this.tokenizer);
this.offset += this.tokenizer.position - _offset;

this.metadata.setFormat('tool', Common.stripNulls(infoTag.codec));
if (infoTag.lame) {
this.metadata.setFormat('tool', Common.stripNulls(infoTag.lame.version));
}

if ((infoTag.headerFlags[3] & 0x01) === 1) {
if (infoTag.streamSize) {
const duration = this.audioFrameHeader.calcDuration(infoTag.numFrames);
this.metadata.setFormat('duration', duration);
debug('Get duration from Xing header: %s', this.metadata.format.duration);
Expand Down
103 changes: 52 additions & 51 deletions lib/mpeg/XingTag.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import * as Token from "token-types";
import { IGetToken } from "strtok3/lib/core";
import { IGetToken, ITokenizer } from 'strtok3/lib/core';
import Common from '../common/Util';

export interface IXingHeaderFlags {
frames: boolean;
bytes: boolean;
toc: boolean;
vbrScale: boolean;
}

/**
* Info Tag: Xing, LAME
Expand All @@ -15,77 +23,70 @@ export const LameEncoderVersion = new Token.StringType(6, 'ascii');

export interface IXingInfoTag {

headerFlags: Buffer,

/**
* total bit stream frames from Vbr header data
*/
numFrames: number,
numFrames?: number,

/**
* Actual stream size = file size - header(s) size [bytes]
*/
streamSize: number,
streamSize?: number,

/**
* the number of header data bytes (from original file)
*/
vbrScale: number,
toc?: Buffer;

/**
* LAME Tag, extends the Xing header format
* First added in LAME 3.12 for VBR
* The modified header is also included in CBR files (effective LAME 3.94), with "Info" instead of "XING" near the beginning.
* the number of header data bytes (from original file)
*/
vbrScale?: number;

// Initial LAME info, e.g.: LAME3.99r
codec: string,
/**
* Info tag revision
*/
infoTagRevision: number,
/**
* VBR method
*/
vbrMethod: number;
lame?: {
version: string;
}
}

/**
* Info Tag
* Ref: http://gabriel.mp3-tech.org/mp3infotag.html
*/
export const XingInfoTag: IGetToken<IXingInfoTag> = {
len: 136, // 140 bytes - 4 bytes TAG = 136 + 7 byte extension
export const XingHeaderFlags: IGetToken<IXingHeaderFlags> = {
len: 4,

get: (buf, off) => {
return {

// === ZONE A - Traditional Xing VBR Tag data ===

// 4 bytes for HeaderFlags
headerFlags: new Token.BufferType(4).get(buf, off),

numFrames: Token.UINT32_BE.get(buf, off + 4),

streamSize: Token.UINT32_BE.get(buf, off + 8),

// the number of header data bytes (from original file)
vbrScale: Token.UINT32_BE.get(buf, off + 112),

/**
* LAME Tag, extends the Xing header format
* First added in LAME 3.12 for VBR
* The modified header is also included in CBR files (effective LAME 3.94), with "Info" instead of "XING" near the beginning.
*/

// === ZONE B - Initial LAME info ===

// Initial LAME info, e.g.: LAME3.99r
codec: new Token.StringType(9, 'ascii').get(buf, off + 116), // bytes $9A-$A => 154-164 (offset doc - 38)
// Info tag revision
infoTagRevision: Token.UINT8.get(buf, off + 125) >> 4,
// VBR method
vbrMethod: Token.UINT8.get(buf, off + 125) & 0xf // $A5
frames: Common.isBitSet(buf, off, 31),
bytes: Common.isBitSet(buf, off, 30),
toc: Common.isBitSet(buf, off, 29),
vbrScale: Common.isBitSet(buf, off, 28)
};
}
};

// /**
// * XING Header Tag
// * Ref: http://gabriel.mp3-tech.org/mp3infotag.html
// */
export async function readXingHeader(tokenizer: ITokenizer): Promise<IXingInfoTag> {
const flags = await tokenizer.readToken(XingHeaderFlags);
const xingInfoTag: IXingInfoTag = {};
if (flags.frames) {
xingInfoTag.numFrames = await tokenizer.readToken(Token.UINT32_BE);
}
if (flags.bytes) {
xingInfoTag.streamSize = await tokenizer.readToken(Token.UINT32_BE);
}
if (flags.toc) {
xingInfoTag.toc = Buffer.alloc(100);
await tokenizer.readBuffer(xingInfoTag.toc);
}
if (flags.vbrScale) {
xingInfoTag.vbrScale = await tokenizer.readToken(Token.UINT32_BE);
}
const lameTag = await tokenizer.peekToken(new Token.StringType(4, 'ascii'));
if (lameTag === 'LAME') {
xingInfoTag. lame = {
version: await tokenizer.readToken(new Token.StringType(9, 'ascii'))
};
}
return xingInfoTag;
}
Binary file added test/samples/mp3/Solace.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer1/fl1.mp1
Binary file not shown.
Binary file added test/samples/mp3/layer1/fl2.mp1
Binary file not shown.
Binary file added test/samples/mp3/layer1/fl3.mp1
Binary file not shown.
Binary file added test/samples/mp3/layer1/fl4.mp1
Binary file not shown.
Binary file added test/samples/mp3/layer1/fl5.mp1
Binary file not shown.
Binary file added test/samples/mp3/layer1/fl6.mp1
Binary file not shown.
Binary file added test/samples/mp3/layer1/fl7.mp1
Binary file not shown.
Binary file added test/samples/mp3/layer1/fl8.mp1
Binary file not shown.
Binary file added test/samples/mp3/layer2/fl10.mp2
Binary file not shown.
Binary file added test/samples/mp3/layer2/fl11.mp2
Binary file not shown.
Binary file added test/samples/mp3/layer2/fl12.mp2
Binary file not shown.
Binary file added test/samples/mp3/layer2/fl13.mp2
Binary file not shown.
Binary file added test/samples/mp3/layer2/fl14.mp2
Binary file not shown.
Binary file added test/samples/mp3/layer2/fl15.mp2
Binary file not shown.
Binary file added test/samples/mp3/layer2/fl16.mp2
Binary file not shown.
Binary file added test/samples/mp3/layer3/compl.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/he_32khz.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/he_44khz.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/he_48khz.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/he_mode.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/hecommon.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/si.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/si_block.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/si_huff.mp3
Binary file not shown.
Binary file added test/samples/mp3/layer3/sin1k0db.mp3
Binary file not shown.
106 changes: 101 additions & 5 deletions test/test-file-mp3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,89 @@ import { Parsers } from './metadata-parsers';
describe('Parse MP3 files', () => {

const samplePath = path.join(__dirname, 'samples');
const mp3SamplePath = path.join(samplePath, 'mp3');

describe('Test patterns for ISO/MPEG ', () => {

it('ISO/MPEG 1 Layer 1', async () => {

// http://mpgedit.org/mpgedit/mpgedit/testdata/mpegdata.html#ISO_m1l1
const samples = [
{filename: 'fl1.mp1', bitRate: 384, sampleRate: 32000, channels: 2},
{filename: 'fl2.mp1', bitRate: 384, sampleRate: 44100, channels: 2},
{filename: 'fl3.mp1', bitRate: 384, sampleRate: 48000, channels: 2},
{filename: 'fl4.mp1', bitRate: 32, sampleRate: 32000, channels: 1},
{filename: 'fl5.mp1', bitRate: 448, sampleRate: 48000, channels: 2},
{filename: 'fl6.mp1', bitRate: 384, sampleRate: 44100, channels: 2},
{filename: 'fl7.mp1', bitRate: 384, sampleRate: 44100, channels: 2},
{filename: 'fl8.mp1', bitRate: 384, sampleRate: 44100, channels: 2}
];

for (const sample of samples) {
const {format} = await mm.parseFile(path.join(mp3SamplePath, 'layer1', sample.filename), {duration: true});
assert.strictEqual(format.container, 'MPEG', 'format.container');
assert.strictEqual(format.codec, 'MPEG 1 Layer 1', `'${sample.filename}' format.codec`);
assert.strictEqual(format.bitrate, sample.bitRate * 1000, `'${sample.filename}' format.bitrate`);
assert.strictEqual(format.sampleRate, sample.sampleRate, `'${sample.filename}' format.sampleRate`);
assert.strictEqual(format.numberOfChannels, sample.channels, `'${sample.filename}' format.channels`);
}

});

it('ISO/MPEG 1 Layer 2', async () => {

// http://mpgedit.org/mpgedit/mpgedit/testdata/mpegdata.html#ISO_m1l2
const samples = [
{filename: 'fl10.mp2', bitRate: 192, sampleRate: 32000, channels: 2},
{filename: 'fl11.mp2', bitRate: 192, sampleRate: 44100, channels: 2},
{filename: 'fl12.mp2', bitRate: 192, sampleRate: 48000, channels: 2},
{filename: 'fl13.mp2', bitRate: 32, sampleRate: 32000, channels: 1},
{filename: 'fl14.mp2', bitRate: 384, sampleRate: 48000, channels: 2},
{filename: 'fl15.mp2', bitRate: 384, sampleRate: 48000, channels: 2},
{filename: 'fl16.mp2', bitRate: 256, sampleRate: 48000, channels: 2}
];

for (const sample of samples) {
const {format} = await mm.parseFile(path.join(mp3SamplePath, 'layer2', sample.filename), {duration: true});
assert.strictEqual(format.container, 'MPEG', 'format.container');
assert.strictEqual(format.codec, 'MPEG 1 Layer 2', `'${sample.filename}' format.codec`);
assert.strictEqual(format.bitrate, sample.bitRate * 1000, `'${sample.filename}' format.bitrate`);
assert.strictEqual(format.sampleRate, sample.sampleRate, `'${sample.filename}' format.sampleRate`);
assert.strictEqual(format.numberOfChannels, sample.channels, `'${sample.filename}' format.channels`);
}

});

// http://mpgedit.org/mpgedit/mpgedit/testdata/mpegdata.html#ISO_m1l2
it('ISO/MPEG 1 Layer 3', async () => {

const samples = [
{filename: 'compl.mp3', bitRate: 64, sampleRate: 48000, channels: 1},
{filename: 'he_32khz.mp3', sampleRate: 32000, channels: 1},
{filename: 'he_44khz.mp3', sampleRate: 44100, channels: 1},
{filename: 'he_48khz.mp3', sampleRate: 48000, channels: 1},
{filename: 'he_mode.mp3', sampleRate: 44100, channels: 1},
{filename: 'hecommon.mp3', bitRate: 128, sampleRate: 44100, channels: 2},
{filename: 'si.mp3', bitRate: 64, sampleRate: 44100, channels: 1},
{filename: 'si.mp3', bitRate: 64, sampleRate: 44100, channels: 1},
{filename: 'si_huff.mp3', bitRate: 64, sampleRate: 44100, channels: 1},
{filename: 'sin1k0db.mp3', bitRate: 128, sampleRate: 44100, channels: 2}
];

for (const sample of samples) {
const {format} = await mm.parseFile(path.join(mp3SamplePath, 'layer3', sample.filename), {duration: true});
assert.strictEqual(format.container, 'MPEG', 'format.container');
assert.strictEqual(format.codec, 'MPEG 1 Layer 3', `'${sample.filename}' format.codec`);
if (sample.bitRate) {
assert.strictEqual(format.bitrate, sample.bitRate * 1000, `'${sample.filename}' format.bitrate`);
}
assert.strictEqual(format.sampleRate, sample.sampleRate, `'${sample.filename}' format.sampleRate`);
assert.strictEqual(format.numberOfChannels, sample.channels, `'${sample.filename}' format.channels`);
}

});

});

it('should handle audio-frame-header-bug', function() {

Expand All @@ -28,7 +111,7 @@ describe('Parse MP3 files', () => {

this.timeout(15000); // Parsing this file can take a bit longer

const filePath = path.join(samplePath, 'mp3', 'Sleep Away.mp3');
const filePath = path.join(mp3SamplePath, 'Sleep Away.mp3');

return mm.parseFile(filePath, {duration: true}).then(metadata => {
const {format, common} = metadata;
Expand All @@ -54,12 +137,12 @@ describe('Parse MP3 files', () => {
// https://github.com/Borewit/music-metadata/issues/381
it('should be able to handle empty ID3v2 tag', async () => {

const filePath = path.join(samplePath, 'mp3', 'issue-381.mp3');
const filePath = path.join(mp3SamplePath, 'issue-381.mp3');

const {format} = await mm.parseFile(filePath);

assert.deepEqual(format.container, 'MPEG', 'format.container');
assert.deepEqual(format.tagTypes, [ 'ID3v2.3', 'ID3v1' ], 'format.tagTypes');
assert.deepEqual(format.tagTypes, ['ID3v2.3', 'ID3v1'], 'format.tagTypes');
});

// https://github.com/Borewit/music-metadata/issues/398
Expand Down Expand Up @@ -96,10 +179,10 @@ describe('Parse MP3 files', () => {

assert.strictEqual(common.title, 'Jan Pillemann Otze', 'common.title');
assert.strictEqual(common.artist, 'Mickie Krause', 'common.artist');
assert.approximately(format.duration, 217.86, 0.005, 'format.duration');
assert.approximately(format.duration, 217.86, 0.005, 'format.duration');
});

it('Able to handle corrupt LAME header', async() => {
it('Able to handle corrupt LAME header', async () => {

const filePath = path.join(samplePath, 'mp3', 'issue-554.mp3');

Expand Down Expand Up @@ -216,4 +299,17 @@ describe('Parse MP3 files', () => {

});

describe('Handle Xing header', () => {

it('Handle Xing header, without LAME extension', async () => {

const filePath = path.join(mp3SamplePath, 'Solace.mp3');
const {format, common} = await mm.parseFile(filePath, {duration: true});
assert.strictEqual(format.container, 'MPEG', 'format.container');
assert.strictEqual(format.codec, 'MPEG 1 Layer 3', 'format.codec');
assert.deepEqual(format.tagTypes, ['ID3v2.3', 'ID3v1'], 'format.tagTypes');
});

});

});