Skip to content

Commit

Permalink
Merge pull request #202 from Borewit/support-DSD-format
Browse files Browse the repository at this point in the history
Support DSD format
  • Loading branch information
Borewit committed Apr 22, 2019
2 parents c27c029 + 81fc764 commit ed9d0f2
Show file tree
Hide file tree
Showing 28 changed files with 825 additions and 297 deletions.
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;
}
}

0 comments on commit ed9d0f2

Please sign in to comment.