2022-09-30 05:39:11 +00:00

481 lines
13 KiB
JavaScript

const fs = require('fs');
const imageSize = require('probe-image-size');
const formatVorbisComment = require('./lib/formatVorbisComment');
const BLOCK_TYPE = {
0: 'STREAMINFO',
1: 'PADDING',
2: 'APPLICATION',
3: 'SEEKTABLE',
4: 'VORBIS_COMMENT', // There may be only one VORBIS_COMMENT block in a stream.
5: 'CUESHEET',
6: 'PICTURE',
};
const STREAMINFO = 0;
const PADDING = 1;
const APPLICATION = 2;
const SEEKTABLE = 3;
const VORBIS_COMMENT = 4;
const CUESHEET = 5;
const PICTURE = 6;
class Metaflac {
constructor(flac) {
if (typeof flac !== 'string' && !Buffer.isBuffer(flac)) {
throw new Error('Metaflac(flac) flac must be string or buffer.');
}
this.flac = flac;
this.buffer = null;
this.marker = '';
this.streamInfo = null;
this.blocks = [];
this.padding = null;
this.vorbisComment = null;
this.vendorString = '';
this.tags = [];
this.pictures = [];
this.picturesSpecs = [];
this.picturesDatas = [];
this.framesOffset = 0;
this.init();
}
init() {
typeof this.flac === 'string' ? (this.buffer = fs.readFileSync(this.flac)) : (this.buffer = this.flac);
let offset = 0;
const marker = this.buffer.slice(0, (offset += 4)).toString('ascii');
if (marker !== 'fLaC') {
throw new Error('The file does not appear to be a FLAC file.');
}
let blockType = 0;
let isLastBlock = false;
while (!isLastBlock) {
blockType = this.buffer.readUInt8(offset++);
isLastBlock = blockType > 128;
blockType = blockType % 128;
// console.log('Block Type: %d %s', blockType, BLOCK_TYPE[blockType]);
const blockLength = this.buffer.readUIntBE(offset, 3);
offset += 3;
// console.log('Offset: %d', offset);
if (blockType === STREAMINFO) {
this.streamInfo = this.buffer.slice(offset, offset + blockLength);
}
/*
if (blockType === PADDING) {
this.padding = this.buffer.slice(offset, offset + blockLength);
}
*/
if (blockType === VORBIS_COMMENT) {
this.vorbisComment = this.buffer.slice(offset, offset + blockLength);
this.parseVorbisComment();
}
/*
if (blockType === PICTURE) {
this.pictures.push(this.buffer.slice(offset, offset + blockLength));
this.parsePictureBlock();
}
*/
if ([APPLICATION, SEEKTABLE, CUESHEET].includes(blockType)) {
this.blocks.push([blockType, this.buffer.slice(offset, offset + blockLength)]);
}
// console.log('Block Length: %d', blockLength);
offset += blockLength;
}
this.framesOffset = offset;
// console.log('Total Length: %d', offset);
}
parseVorbisComment() {
const vendorLength = this.vorbisComment.readUInt32LE(0);
// console.log('Vendor length: %d', vendorLength);
this.vendorString = this.vorbisComment.slice(4, vendorLength + 4).toString('utf8');
// console.log('Vendor string: %s', this.vendorString);
/*
const userCommentListLength = this.vorbisComment.readUInt32LE(4 + vendorLength);
// console.log('user_comment_list_length: %d', userCommentListLength);
const userCommentListBuffer = this.vorbisComment.slice(4 + vendorLength + 4);
for (let offset = 0; offset < userCommentListBuffer.length; ) {
const length = userCommentListBuffer.readUInt32LE(offset);
offset += 4;
const comment = userCommentListBuffer.slice(offset, offset += length).toString('utf8');
// console.log('Comment length: %d, this.buffer: %s', length, comment);
this.tags.push(comment);
}
*/
}
parsePictureBlock() {
this.pictures.forEach((picture) => {
let offset = 0;
const type = picture.readUInt32BE(offset);
offset += 4;
const mimeTypeLength = picture.readUInt32BE(offset);
offset += 4;
const mime = picture.slice(offset, offset + mimeTypeLength).toString('ascii');
offset += mimeTypeLength;
const descriptionLength = picture.readUInt32BE(offset);
offset += 4;
const description = picture.slice(offset, (offset += descriptionLength)).toString('utf8');
const width = picture.readUInt32BE(offset);
offset += 4;
const height = picture.readUInt32BE(offset);
offset += 4;
const depth = picture.readUInt32BE(offset);
offset += 4;
const colors = picture.readUInt32BE(offset);
offset += 4;
const pictureDataLength = picture.readUInt32BE(offset);
offset += 4;
this.picturesDatas.push(picture.slice(offset, offset + pictureDataLength));
this.picturesSpecs.push(
this.buildSpecification({
type,
mime,
description,
width,
height,
depth,
colors,
})
);
});
}
getPicturesSpecs() {
return this.picturesSpecs;
}
/**
* Get the MD5 signature from the STREAMINFO block.
*/
getMd5sum() {
return this.streamInfo.slice(18, 34).toString('hex');
}
/**
* Get the minimum block size from the STREAMINFO block.
*/
getMinBlocksize() {
return this.streamInfo.readUInt16BE(0);
}
/**
* Get the maximum block size from the STREAMINFO block.
*/
getMaxBlocksize() {
return this.streamInfo.readUInt16BE(2);
}
/**
* Get the minimum frame size from the STREAMINFO block.
*/
getMinFramesize() {
return this.streamInfo.readUIntBE(4, 3);
}
/**
* Get the maximum frame size from the STREAMINFO block.
*/
getMaxFramesize() {
return this.streamInfo.readUIntBE(7, 3);
}
/**
* Get the sample rate from the STREAMINFO block.
*/
getSampleRate() {
// 20 bits number
return this.streamInfo.readUIntBE(10, 3) >> 4;
}
/**
* Get the number of channels from the STREAMINFO block.
*/
getChannels() {
// 3 bits
return this.streamInfo.readUIntBE(10, 3) & (0x00000f >> 1);
}
/**
* Get the # of bits per sample from the STREAMINFO block.
*/
getBps() {
return this.streamInfo.readUIntBE(12, 2) & (0x01f0 >> 4);
}
/**
* Get the total # of samples from the STREAMINFO block.
*/
getTotalSamples() {
return this.streamInfo.readUIntBE(13, 5) & 0x0fffffffff;
}
/**
* Show the vendor string from the VORBIS_COMMENT block.
*/
getVendorTag() {
return this.vendorString;
}
/**
* Get all tags where the the field name matches NAME.
*
* @param {string} name
*/
getTag(name) {
return this.tags
.filter((item) => {
const itemName = item.split('=')[0];
return itemName === name;
})
.join('\n');
}
/**
* Remove all tags whose field name is NAME.
*
* @param {string} name
*/
removeTag(name) {
this.tags = this.tags.filter((item) => {
const itemName = item.split('=')[0];
return itemName !== name;
});
}
/**
* Remove first tag whose field name is NAME.
*
* @param {string} name
*/
removeFirstTag(name) {
const found = this.tags.findIndex((item) => {
return item.split('=')[0] === name;
});
if (found !== -1) {
this.tags.splice(found, 1);
}
}
/**
* Remove all tags, leaving only the vendor string.
*/
removeAllTags() {
this.tags = [];
}
/**
* Add a tag.
* The FIELD must comply with the Vorbis comment spec, of the form NAME=VALUE. If there is currently no tag block, one will be created.
*
* @param {string} field
*/
setTag(field) {
if (field.indexOf('=') === -1) {
throw new Error(`malformed vorbis comment field "${field}", field contains no '=' character`);
}
this.tags.push(field);
}
/**
* Like setTag, except the VALUE is a filename whose contents will be read verbatim to set the tag value.
*
* @param {string} field
*/
setTagFromFile(field) {
const position = field.indexOf('=');
if (position === -1) {
throw new Error(`malformed vorbis comment field "${field}", field contains no '=' character`);
}
const name = field.substring(0, position);
const filename = field.substr(position + 1);
let value;
try {
value = fs.readFileSync(filename, 'utf8');
} catch (e) {
throw new Error(`can't open file '${filename}' for '${name}' tag value`);
}
this.tags.push(`${name}=${value}`);
}
/**
* Import tags from a file.
* Each line should be of the form NAME=VALUE.
*
* @param {string} filename
*/
importTagsFrom(filename) {
const tags = fs.readFileSync(filename, 'utf8').split('\n');
tags.forEach((line) => {
if (line.indexOf('=') === -1) {
throw new Error(`malformed vorbis comment "${line}", contains no '=' character`);
}
});
this.tags = this.tags.concat(tags);
}
/**
* Export tags to a file.
* Each line will be of the form NAME=VALUE.
*
* @param {string} filename
*/
exportTagsTo(filename) {
fs.writeFileSync(filename, this.tags.join('\n'), 'utf8');
}
/**
* Import a picture and store it in a PICTURE metadata block.
*
* @param {string} filename
*/
importPicture(picture) {
if (!Buffer.isBuffer(picture)) {
picture = fs.readFileSync(picture);
}
const dimensions = imageSize.sync(picture);
if (!dimensions) {
throw new Error('Unable to determine image dimensions');
}
if (dimensions.mime !== 'image/jpeg' && dimensions.mime !== 'image/png') {
throw new Error(`only support image/jpeg and image/png picture temporarily, current import ${mime}`);
}
const spec = this.buildSpecification({
mime: dimensions.mime,
width: dimensions.width,
height: dimensions.height,
});
this.pictures.push(this.buildPictureBlock(picture, spec));
this.picturesSpecs.push(spec);
}
/**
* Export PICTURE block to a file.
*
* @param {string} filename
*/
exportPictureTo(filename) {
if (this.picturesDatas.length > 0) {
fs.writeFileSync(filename, this.picturesDatas[0]);
}
}
/**
* Return all tags.
*/
getAllTags() {
return this.tags;
}
buildSpecification(spec = {}) {
const defaults = {
type: 3,
mime: 'image/jpeg',
description: '',
width: 0,
height: 0,
depth: 24,
colors: 0,
};
return Object.assign(defaults, spec);
}
/**
* Build a picture block.
*
* @param {Buffer} picture
* @param {Object} specification
* @returns {Buffer}
*/
buildPictureBlock(picture, specification = {}) {
const pictureType = Buffer.alloc(4);
const mimeLength = Buffer.alloc(4);
const mime = Buffer.from(specification.mime, 'ascii');
const descriptionLength = Buffer.alloc(4);
const description = Buffer.from(specification.description, 'utf8');
const width = Buffer.alloc(4);
const height = Buffer.alloc(4);
const depth = Buffer.alloc(4);
const colors = Buffer.alloc(4);
const pictureLength = Buffer.alloc(4);
pictureType.writeUInt32BE(specification.type);
mimeLength.writeUInt32BE(specification.mime.length);
descriptionLength.writeUInt32BE(specification.description.length);
width.writeUInt32BE(specification.width);
height.writeUInt32BE(specification.height);
depth.writeUInt32BE(specification.depth);
colors.writeUInt32BE(specification.colors);
pictureLength.writeUInt32BE(picture.length);
return Buffer.concat([
pictureType,
mimeLength,
mime,
descriptionLength,
description,
width,
height,
depth,
colors,
pictureLength,
picture,
]);
}
buildMetadataBlock(type, block, isLast = false) {
const header = Buffer.alloc(4);
if (isLast) {
type += 128;
}
header.writeUIntBE(type, 0, 1);
header.writeUIntBE(block.length, 1, 3);
return Buffer.concat([header, block]);
}
buildMetadata() {
const bufferArray = [];
bufferArray.push(this.buildMetadataBlock(STREAMINFO, this.streamInfo));
this.blocks.forEach((block) => {
bufferArray.push(this.buildMetadataBlock(...block));
});
bufferArray.push(this.buildMetadataBlock(VORBIS_COMMENT, formatVorbisComment(this.vendorString, this.tags)));
this.pictures.forEach((block) => {
bufferArray.push(this.buildMetadataBlock(PICTURE, block));
});
if (this.padding == null) this.padding = Buffer.alloc(16384);
bufferArray.push(this.buildMetadataBlock(PADDING, this.padding, true));
return bufferArray;
}
buildStream() {
const metadata = this.buildMetadata();
return [this.buffer.slice(0, 4), ...metadata, this.buffer.slice(this.framesOffset)];
}
/**
* Save change to file or return changed buffer.
*/
save() {
if (typeof this.flac === 'string') {
fs.writeFileSync(this.flac, Buffer.concat(this.buildStream()));
} else {
return Buffer.concat(this.buildStream());
}
}
}
module.exports = Metaflac;