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

Support DSD format #202

Merged
merged 26 commits into from
Apr 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f4c92de
#195 Add DSF audio format parser
Borewit Apr 6, 2019
2feb815
#195 Add DSF to README
Borewit Apr 6, 2019
0014186
#195 Add keywords 'dsd' & 'dsf' to NPM
Borewit Apr 6, 2019
f81df4a
Code style: align table fence
Borewit Apr 6, 2019
20ec202
#195 Implement format.bitrate & format.lossless
Borewit Apr 9, 2019
4263624
#195 Fix minor errors in DSF chunk parsing
Borewit Apr 9, 2019
0bdf406
#196 Move common IFF (EA-IFF 85) to definition to shared 'iff' library.
Borewit Apr 7, 2019
0350c5a
#196 Implement Philips DSDIFF audio format parser
Borewit Apr 7, 2019
7e54761
#196 Add DSDIFF to documentation
Borewit Apr 7, 2019
1eae4a2
#196 Calculate bitrate for DSD encoding
Borewit Apr 9, 2019
039d025
#196 Add support for unofficial ID3v2 tag
Borewit Apr 11, 2019
eabdd27
Merge branch 'master' into 195-add-support-for-DSF-format
Borewit Apr 13, 2019
709f422
#201 Split WavPack token & parser code.
Borewit Apr 13, 2019
210bbac
#201 Add WavPack/DSD unit test.
Borewit Apr 13, 2019
9b70d7d
#201 Prevent EOF exception trying to read APE header.
Borewit Apr 13, 2019
962d7fc
Merge branch '196-add-support-for-DSDIFF-format' into support-DSD-format
Borewit Apr 13, 2019
3e3ac83
Merge branch '201-improve-WavPack-DSD-format' into support-DSD-format
Borewit Apr 13, 2019
72a952f
3.6.0-beta.4
Borewit Apr 13, 2019
83229fc
Update format sequence
Borewit Apr 13, 2019
3cad18b
#201, #dbry/WavPack#71: Fixed WavPack DSD sample-rate & bit-rate deco…
Borewit Apr 15, 2019
004c66d
Add debug statement
Borewit Apr 16, 2019
edde0a8
#201, #dbry/WavPack#71: Fixed WavPack DSD sample-rate & bit-rate deco…
Borewit Apr 22, 2019
3918f35
#201, #dbry/WavPack#71: Add unit test sample file
Borewit Apr 22, 2019
ad914c9
#201, #dbry/WavPack#71: Add compressed (encoded stream) bit-rate.
Borewit Apr 22, 2019
39bc428
Merge branch '201-improve-WavPack-DSD-format' into support-DSD-format
Borewit Apr 22, 2019
81fc764
Merge branch 'master' into support-DSD-format
Borewit Apr 22, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Stream and file based music metadata parser for node.
| AIFF / AIFF-C | Audio Interchange File Format | [:link:](https://wikipedia.org/wiki/Audio_Interchange_File_Format) | <img src="https://upload.wikimedia.org/wikipedia/commons/8/84/Apple_Computer_Logo_rainbow.svg" width="40" alt="Apple rainbow logo"> |
| APE | Monkey's Audio | [:link:](https://wikipedia.org/wiki/Monkey's_Audio) | <img src="https://foreverhits.files.wordpress.com/2015/05/ape_audio.jpg" width="40" alt="Monkey's Audio logo"> |
| ASF | Advanced Systems Format | [:link:](https://wikipedia.org/wiki/Advanced_Systems_Format) | |
| DSDIFF | Philips DSDIFF | [:link:](https://en.wikipedia.org/wiki/Direct_Stream_Digital) | <img src="https://upload.wikimedia.org/wikipedia/commons/b/bc/DSDlogo.svg" width="80" alt="DSD logo"> |
| DSF | Sony's DSD Stream File | [:link:](https://en.wikipedia.org/wiki/Direct_Stream_Digital) | <img src="https://upload.wikimedia.org/wikipedia/commons/b/bc/DSDlogo.svg" width="80" alt="DSD logo"> |
| FLAC | Free Lossless Audio Codec | [:link:](https://wikipedia.org/wiki/FLAC) | <img src="https://upload.wikimedia.org/wikipedia/commons/e/e0/Flac_logo_vector.svg" width="80" alt="FLAC logo"> |
| MP2 | MPEG-1 Audio Layer II | [:link:](https://wikipedia.org/wiki/MPEG-1_Audio_Layer_II) | |
| MP3 | MPEG-1 / MPEG-2 Audio Layer III | [:link:](https://wikipedia.org/wiki/MP3) | <img src="https://upload.wikimedia.org/wikipedia/commons/e/ea/Mp3.svg" width="80" alt="MP3 logo"> |
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "music-metadata",
"description": "Streaming music metadata parser for node and the browser.",
"version": "3.5.4",
"version": "3.6.0-beta.4",
"author": {
"name": "Borewit",
"url": "https://github.com/Borewit"
Expand Down Expand Up @@ -36,7 +36,12 @@
"Opus",
"speex",
"musepack",
"mpc"
"mpc",
"dsd",
"dsf",
"mpc",
"dff",
"dsdiff"
],
"main": "lib",
"typings": "lib/index",
Expand Down
11 changes: 11 additions & 0 deletions src/ParserFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import MusepackParser from './musepack';
import { OggParser } from './ogg/OggParser';
import { WaveParser } from './riff/WaveParser';
import { WavPackParser } from './wavpack/WavPackParser';
import {DsfParser} from "./dsf/DsfParser";
import {DsdiffParser} from "./dsdiff/DsdiffParser";

const debug = _debug("music-metadata:parser:factory");

Expand Down Expand Up @@ -59,6 +61,7 @@ export class ParserFactory {
const guessedType = fileType(buf);
if (!guessedType)
throw new Error("Failed to guess MIME-type");
debug(`Guessed file type is mime=${guessedType.mime}, extension=${guessedType.ext}`);
parserId = ParserFactory.getParserIdForMimeType(guessedType.mime);
if (!parserId)
throw new Error("Guessed MIME-type not supported: " + guessedType.mime);
Expand Down Expand Up @@ -131,6 +134,12 @@ export class ParserFactory {

case ".mpc":
return 'musepack';

case '.dsf':
return 'dsf';

case '.dff':
return 'dsdiff';
}
}

Expand All @@ -139,6 +148,8 @@ export class ParserFactory {
case 'aiff': return new AIFFParser();
case 'apev2': return new APEv2Parser();
case 'asf': return new AsfParser();
case 'dsf': return new DsfParser();
case 'dsdiff': return new DsdiffParser();
case 'flac': return new FlacParser();
case 'mp4': return new MP4Parser();
case 'mpeg': return new MpegParser();
Expand Down
40 changes: 15 additions & 25 deletions src/aiff/AiffParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { ID3v2Parser } from '../id3v2/ID3v2Parser';
import { FourCcToken } from '../common/FourCC';
import { BasicParser } from '../common/BasicParser';

import * as Chunk from './Chunk';
import * as AiffToken from './AiffToken';
import * as iff from '../iff';
import { ID3Stream } from "../id3v2/ID3Stream";

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

Expand All @@ -26,7 +28,7 @@ export class AIFFParser extends BasicParser {

public async parse(): Promise<void> {

const header = await this.tokenizer.readToken<Chunk.IChunkHeader>(Chunk.Header);
const header = await this.tokenizer.readToken<iff.IChunkHeader>(iff.Header);
if (header.chunkID !== 'FORM')
throw new Error('Invalid Chunk-ID, expected \'FORM\''); // Not AIFF format

Expand All @@ -50,12 +52,12 @@ export class AIFFParser extends BasicParser {

try {
do {
const chunkHeader = await this.tokenizer.readToken<Chunk.IChunkHeader>(Chunk.Header);
const chunkHeader = await this.tokenizer.readToken<iff.IChunkHeader>(iff.Header);

debug(`Chunk id=${chunkHeader.chunkID}`);
const nextChunk = 2 * Math.round(chunkHeader.size / 2);
const bytesread = await this.readData(chunkHeader);
await this.tokenizer.ignore(nextChunk - bytesread);
const nextChunk = 2 * Math.round(chunkHeader.chunkSize / 2);
const bytesRead = await this.readData(chunkHeader);
await this.tokenizer.ignore(nextChunk - bytesRead);
} while (true);
} catch (err) {
if (err.message !== endOfFile) {
Expand All @@ -64,29 +66,29 @@ export class AIFFParser extends BasicParser {
}
}

public async readData(header: Chunk.IChunkHeader): Promise<number> {
public async readData(header: iff.IChunkHeader): Promise<number> {
switch (header.chunkID) {

case 'COMM': // The Common Chunk
const common = await this.tokenizer.readToken<Chunk.ICommon>(new Chunk.Common(header, this.isCompressed));
const common = await this.tokenizer.readToken<AiffToken.ICommon>(new AiffToken.Common(header, this.isCompressed));
this.metadata.setFormat('bitsPerSample', common.sampleSize);
this.metadata.setFormat('sampleRate', common.sampleRate);
this.metadata.setFormat('numberOfChannels', common.numChannels);
this.metadata.setFormat('numberOfSamples', common.numSampleFrames);
this.metadata.setFormat('duration', common.numSampleFrames / common.sampleRate);
this.metadata.setFormat('encoder', common.compressionName);
return header.size;
return header.chunkSize;

case 'ID3 ': // ID3-meta-data
const id3_data = await this.tokenizer.readToken<Buffer>(new Token.BufferType(header.size));
const id3_data = await this.tokenizer.readToken<Buffer>(new Token.BufferType(header.chunkSize));
const id3stream = new ID3Stream(id3_data);
const rst = strtok3.fromStream(id3stream);
await ID3v2Parser.getInstance().parse(this.metadata, rst, this.options);
return header.size;
await new ID3v2Parser().parse(this.metadata, rst, this.options);
return header.chunkSize;

case 'SSND': // Sound Data Chunk
if (this.metadata.format.duration) {
this.metadata.setFormat('bitrate', 8 * header.size / this.metadata.format.duration);
this.metadata.setFormat('bitrate', 8 * header.chunkSize / this.metadata.format.duration);
}
return 0;

Expand All @@ -96,15 +98,3 @@ export class AIFFParser extends BasicParser {
}

}

class ID3Stream extends Readable {

constructor(private buf: Buffer) {
super();
}

public _read() {
this.push(this.buf);
this.push(null); // push the EOF-signaling `null` chunk
}
}
35 changes: 4 additions & 31 deletions src/aiff/Chunk.ts → src/aiff/AiffToken.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,7 @@
import * as Token from "token-types";
import * as assert from "assert";
import {FourCcToken} from "../common/FourCC";

export interface IChunkHeader {

/**
* A chunk ID (ie, 4 ASCII bytes)
*/
chunkID: string,
/**
* Number of data bytes following this data header
*/
size: number
}

/**
* Common AIFF chunk header
*/
export const Header: Token.IGetToken<IChunkHeader> = {
len: 8,

get: (buf, off): IChunkHeader => {
return {
// Group-ID
chunkID: FourCcToken.get(buf, off),
// Size
size: buf.readUInt32BE(off + 4)
};
}
};
import * as iff from '../iff';

/**
* The Common Chunk.
Expand All @@ -48,10 +21,10 @@ export class Common implements Token.IGetToken<ICommon> {

public len: number;

public constructor(header: IChunkHeader, private isAifc: boolean) {
public constructor(header: iff.IChunkHeader, private isAifc: boolean) {
const minimumChunkSize = isAifc ? 22 : 18;
assert.ok(header.size >= minimumChunkSize, `COMMON CHUNK size should always be at least ${minimumChunkSize}`);
this.len = header.size;
assert.ok(header.chunkSize >= minimumChunkSize, `COMMON CHUNK size should always be at least ${minimumChunkSize}`);
this.len = header.chunkSize;
}

public get(buf: Buffer, off: number): ICommon {
Expand Down
6 changes: 6 additions & 0 deletions src/apev2/APEv2Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export class APEv2Parser extends BasicParser {
* @returns {Promise<boolean>} True if tags have been found
*/
public static async parseTagHeader(metadata: INativeMetadataCollector, tokenizer: ITokenizer, options: IOptions): Promise<void> {

if (tokenizer.fileSize && tokenizer.fileSize - tokenizer.position < TagFooter.len) {
debug(`No APEv2 header found, end-of-file reached`);
return;
}

const footer = await tokenizer.peekToken<IFooter>(TagFooter);
if (footer.ID === preamble) {
await tokenizer.ignore(TagFooter.len);
Expand Down
166 changes: 166 additions & 0 deletions src/dsdiff/DsdiffParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as assert from 'assert';
import * as Token from 'token-types';
import * as initDebug from 'debug';
import { FourCcToken } from '../common/FourCC';
import { BasicParser } from '../common/BasicParser';
import { ID3Stream } from '../id3v2/ID3Stream';

import {ChunkHeader, IChunkHeader} from "./DsdiffToken";
import * as strtok3 from "strtok3/lib/core";
import { ID3v2Parser } from "../id3v2/ID3v2Parser";

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

/**
* DSDIFF - Direct Stream Digital Interchange File Format (Phillips)
*
* Ref:
* http://www.sonicstudio.com/pdf/dsd/DSDIFF_1.5_Spec.pdf
*/
export class DsdiffParser extends BasicParser {

public async parse(): Promise<void> {

const header = await this.tokenizer.readToken<IChunkHeader>(ChunkHeader);
assert.strictEqual(header.chunkID, 'FRM8');

const type = (await this.tokenizer.readToken<string>(FourCcToken)).trim();
switch (type) {

case 'DSD':
this.metadata.setFormat('dataformat', `DSDIFF/${type}`);
this.metadata.setFormat('lossless', true);
return this.readFmt8Chunks(header.chunkSize - FourCcToken.len);

default:
throw Error(`Unsupported DSDIFF type: ${type}`);
}
}

private async readFmt8Chunks(remainingSize: number): Promise<void> {

while (remainingSize >= ChunkHeader.len) {
const chunkHeader = await this.tokenizer.readToken<IChunkHeader>(ChunkHeader);

// If the data is an odd number of bytes in length, a pad byte must be added at the end
debug(`Chunk id=${chunkHeader.chunkID}`);
await this.readData(chunkHeader);
remainingSize -= (ChunkHeader.len + chunkHeader.chunkSize);
}
}

private async readData(header: IChunkHeader): Promise<void> {
debug(`Reading data of chunk[ID=${header.chunkID}, size=${header.chunkSize}]`);
const p0 = this.tokenizer.position;
switch (header.chunkID.trim()) {

case 'FVER': // 3.1 FORMAT VERSION CHUNK
const version = await this.tokenizer.readToken<number>(Token.UINT32_LE);
debug(`DSDIFF version=${version}`);
break;

case 'PROP': // 3.2 PROPERTY CHUNK
const propType = await this.tokenizer.readToken(FourCcToken);
assert.strictEqual(propType, 'SND ');
await this.handleSoundPropertyChunks(header.chunkSize - FourCcToken.len);
break;

case 'ID3': // Unofficial ID3 tag support
const id3_data = await this.tokenizer.readToken<Buffer>(new Token.BufferType(header.chunkSize));
const id3stream = new ID3Stream(id3_data);
const rst = strtok3.fromStream(id3stream);
await new ID3v2Parser().parse(this.metadata, rst, this.options);
break;

default:
debug(`Ignore chunk[ID=${header.chunkID}, size=${header.chunkSize}]`);
break;

case 'DSD':
const duration = header.chunkSize * 8 / (this.metadata.format.numberOfChannels * this.metadata.format.sampleRate); // ToDO: not sure if this is correct
this.metadata.setFormat('duration', duration);
break;

}
const remaining = header.chunkSize - (this.tokenizer.position - p0);
if (remaining > 0) {
debug(`After Parsing chunk, remaining ${remaining} bytes`);
await this.tokenizer.ignore(remaining);
}
}

private async handleSoundPropertyChunks(remainingSize: number): Promise<void> {
debug(`Parsing sound-property-chunks, remainingSize=${remainingSize}`);
while (remainingSize > 0) {
const sndPropHeader = await this.tokenizer.readToken<IChunkHeader>(ChunkHeader);
debug(`Sound-property-chunk[ID=${sndPropHeader.chunkID}, size=${sndPropHeader.chunkSize}]`);
const p0 = this.tokenizer.position;
switch (sndPropHeader.chunkID.trim()) {

case 'FS': // 3.2.1 Sample Rate Chunk
const sampleRate = await this.tokenizer.readToken<number>(Token.UINT32_BE);
this.metadata.setFormat('sampleRate', sampleRate);
break;

case 'CHNL': // 3.2.2 Channels Chunk
const numChannels = await this.tokenizer.readToken<number>(Token.UINT16_BE);
this.metadata.setFormat('numberOfChannels', numChannels);
await this.handleChannelChunks(sndPropHeader.chunkSize - Token.UINT16_BE.len);
break;

case 'CMPR': // 3.2.3 Compression Type Chunk
const compressionIdCode = (await this.tokenizer.readToken<string>(FourCcToken)).trim();
const count = await this.tokenizer.readToken<number>(Token.UINT8);
const compressionName = await this.tokenizer.readToken<string>(new Token.StringType(count, 'ascii'));
if (compressionIdCode === 'DSD') {
this.metadata.setFormat('lossless', true);
this.metadata.setFormat('bitsPerSample', 1);
}
this.metadata.setFormat('encoder', `${compressionIdCode} (${compressionName})`);
break;

case 'ABSS': // 3.2.4 Absolute Start Time Chunk
const hours = await this.tokenizer.readToken<number>(Token.UINT16_BE);
const minutes = await this.tokenizer.readToken<number>(Token.UINT8);
const seconds = await this.tokenizer.readToken<number>(Token.UINT8);
const samples = await this.tokenizer.readToken<number>(Token.UINT32_BE);
debug(`ABSS ${hours}:${minutes}:${seconds}.${samples}`);
break;

case 'LSCO': // 3.2.5 Loudspeaker Configuration Chunk
const lsConfig = await this.tokenizer.readToken<number>(Token.UINT16_BE);
debug(`LSCO lsConfig=${lsConfig}`);
break;

case 'COMT':
default:
debug(`Unknown sound-property-chunk[ID=${sndPropHeader.chunkID}, size=${sndPropHeader.chunkSize}]`);
await this.tokenizer.ignore(sndPropHeader.chunkSize);
}
const remaining = sndPropHeader.chunkSize - (this.tokenizer.position - p0);
if (remaining > 0) {
debug(`After Parsing sound-property-chunk ${sndPropHeader.chunkSize}, remaining ${remaining} bytes`);
await this.tokenizer.ignore(remaining);
}
remainingSize -= ChunkHeader.len + sndPropHeader.chunkSize;
debug(`Parsing sound-property-chunks, remainingSize=${remainingSize}`);
}
if (this.metadata.format.lossless && this.metadata.format.sampleRate && this.metadata.format.numberOfChannels && this.metadata.format.bitsPerSample) {
const bitrate = this.metadata.format.sampleRate * this.metadata.format.numberOfChannels * this.metadata.format.bitsPerSample;
this.metadata.setFormat('bitrate', bitrate);
}
}

private async handleChannelChunks(remainingSize: number): Promise<string[]> {
debug(`Parsing channel-chunks, remainingSize=${remainingSize}`);
const channels: string[] = [];
while (remainingSize >= FourCcToken.len) {
const channelId = await this.tokenizer.readToken<string>(FourCcToken);
debug(`Channel[ID=${channelId}]`);
channels.push(channelId);
remainingSize -= FourCcToken.len;
}
debug(`Channels: ${channels.join(', ')}`);
return channels;
}
}