From c7fd893a22169679a2b622fd6cc7048d32907762 Mon Sep 17 00:00:00 2001 From: Borewit Date: Fri, 19 Jul 2024 20:27:26 +0200 Subject: [PATCH] Add support for ID3v2 SYLT frame Improve and extend generic comment mapping --- lib/common/GenericTagMapper.ts | 2 +- lib/common/MetadataCollector.ts | 9 + lib/core.ts | 2 + lib/id3v2/FrameParser.ts | 72 ++++-- lib/id3v2/ID3v22TagMapper.ts | 6 - lib/id3v2/ID3v24TagMapper.ts | 8 - lib/id3v2/ID3v2Parser.ts | 4 - lib/id3v2/ID3v2Token.ts | 66 +++++ lib/index.ts | 2 +- lib/type.ts | 33 ++- test/samples/mp3/menu-sash.mp3 | Bin 0 -> 19717 bytes test/test-async.ts | 11 +- test/test-comment-mapping.ts | 38 ++- test/test-file-aiff.ts | 8 +- test/test-file-ape.ts | 3 +- test/test-file-mp3.ts | 2 +- test/test-file-mp4.ts | 8 +- test/test-file-mpeg.ts | 12 +- test/test-file-wav.ts | 2 +- test/test-id3v1.1.ts | 2 +- test/test-id3v2-lyrics.ts | 323 ++++++++++++++++++++----- test/test-id3v2.2.ts | 63 +++-- test/test-id3v2.3.ts | 117 +++++++-- test/test-id3v2.4.ts | 31 ++- test/test-musicbrainz-picard-vorbis.ts | 4 +- test/test-pr-544.ts | 10 +- 26 files changed, 653 insertions(+), 185 deletions(-) create mode 100644 test/samples/mp3/menu-sash.mp3 diff --git a/lib/common/GenericTagMapper.ts b/lib/common/GenericTagMapper.ts index 883440ef9..15ca7b601 100644 --- a/lib/common/GenericTagMapper.ts +++ b/lib/common/GenericTagMapper.ts @@ -5,7 +5,7 @@ import { INativeMetadataCollector, IWarningCollector } from './MetadataCollector export interface IGenericTagMapper { /** - * Which tagType it able to map to the generic mapping format + * Which tagType is able to map to the generic mapping format */ tagTypes: generic.TagType[]; diff --git a/lib/common/MetadataCollector.ts b/lib/common/MetadataCollector.ts index c2bb11d12..94f27ce6a 100644 --- a/lib/common/MetadataCollector.ts +++ b/lib/common/MetadataCollector.ts @@ -251,6 +251,15 @@ export class MetadataCollector implements INativeMetadataCollector { return; break; + case 'comment': + if (typeof tag.value === 'string') { + tag.value = {text: tag.value}; + } + if (tag.value.descriptor === 'iTunPGAP') { + this.setGenericTag(tagType, {id: 'gapless', value: tag.value.text === '1'}); + } + break; + default: // nothing to do } diff --git a/lib/core.ts b/lib/core.ts index 0885835b8..4ae803d5c 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -15,6 +15,8 @@ import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; export { IFileInfo } from 'strtok3'; +export { IAudioMetadata, IOptions, ITag, INativeTagDict, ICommonTagsResult, IFormat, IPicture, IRatio, IChapter, ILyricsTag, LyricsContentType, TimestampFormat } from './type.js'; + export type AnyWebStream = NodeReadableStream | ReadableStream; /** diff --git a/lib/id3v2/FrameParser.ts b/lib/id3v2/FrameParser.ts index 1e22483e6..d2cca54ed 100644 --- a/lib/id3v2/FrameParser.ts +++ b/lib/id3v2/FrameParser.ts @@ -2,10 +2,11 @@ import initDebug from 'debug'; import * as Token from 'token-types'; import * as util from '../common/Util.js'; -import { AttachedPictureType, ID3v2MajorVersion, TextEncodingToken } from './ID3v2Token.js'; +import { AttachedPictureType, ID3v2MajorVersion, TextEncodingToken, SyncTextHeader, TextHeader, ITextEncoding } from './ID3v2Token.js'; import { Genres } from '../id3v1/ID3v1Parser.js'; import { IWarningCollector } from '../common/MetadataCollector.js'; +import { IComment, ILyricsTag } from '../type.js'; const debug = initDebug('music-metadata:id3v2:frame-parser'); @@ -91,7 +92,6 @@ export class FrameParser { let output: any = []; // ToDo const nullTerminatorLength = FrameParser.getNullTerminatorLength(encoding); let fzero: number; - const out: IOut = {}; debug(`Parsing tag type=${type}, encoding=${encoding}, bom=${bom}`); switch (type !== 'TXXX' && type[0] === 'T' ? 'T*' : type) { @@ -195,19 +195,36 @@ export class FrameParser { break; case 'SYLT': - // skip text encoding (1 byte), - // language (3 bytes), - // time stamp format (1 byte), - // content tagTypes (1 byte), - // content descriptor (1 byte) - offset += 7; - - output = []; + const syltHeader = SyncTextHeader.get(uint8Array, 0); + offset += SyncTextHeader.len; + + const result: ILyricsTag = { + descriptor: '', + language: syltHeader.language, + contentType: syltHeader.contentType, + timeStampFormat: syltHeader.timeStampFormat, + syncText: [] + }; + + let readSyllables = false; while (offset < length) { - const txt = uint8Array.slice(offset, offset = util.findZero(uint8Array, offset, length, encoding)); - offset += 5; // push offset forward one + 4 byte timestamp - output.push(util.decodeString(txt, encoding)); + + const nullStr = FrameParser.readNullTerminatedString(uint8Array.subarray(offset), syltHeader.encoding); + offset += nullStr.len; + + if (readSyllables) { + const timestamp = Token.UINT32_BE.get(uint8Array, offset); + offset += Token.UINT32_BE.len; + result.syncText.push({ + text: nullStr.text, + timestamp + }); + } else { + result.descriptor = nullStr.text; + readSyllables = true; + } } + output = result; break; case 'ULT': @@ -215,18 +232,21 @@ export class FrameParser { case 'COM': case 'COMM': - offset += 1; + const textHeader = TextHeader.get(uint8Array, offset); + offset += TextHeader.len; - out.language = util.decodeString(uint8Array.slice(offset, offset + 3), defaultEnc); - offset += 3; + const descriptorStr = FrameParser.readNullTerminatedString(uint8Array.subarray(offset), textHeader.encoding); + offset += descriptorStr.len; - fzero = util.findZero(uint8Array, offset, length, encoding); - out.description = util.decodeString(uint8Array.slice(offset, fzero), encoding); - offset = fzero + nullTerminatorLength; + const textStr = FrameParser.readNullTerminatedString(uint8Array.subarray(offset), textHeader.encoding); - out.text = util.decodeString(uint8Array.slice(offset, length), encoding).replace(/\x00+$/, ''); + const comment: IComment = { + language: textHeader.language, + descriptor: descriptorStr.text, + text: textStr.text + }; - output = [out]; + output = comment; break; case 'UFID': @@ -310,6 +330,16 @@ export class FrameParser { return output; } + protected static readNullTerminatedString(uint8Array: Uint8Array, encoding: ITextEncoding): {text: string, len: number} { + let offset = encoding.bom ? 2 : 0; + const txt = uint8Array.slice(offset, offset = util.findZero(uint8Array, offset, uint8Array.length, encoding.encoding)); + offset += encoding.encoding === 'utf-16le' ? 2 : 1; + return { + text: util.decodeString(txt, encoding.encoding), + len: offset + }; + } + protected static fixPictureMimeType(pictureType: string): string { pictureType = pictureType.toLocaleLowerCase(); switch (pictureType) { diff --git a/lib/id3v2/ID3v22TagMapper.ts b/lib/id3v2/ID3v22TagMapper.ts index 45dd3a65b..bc9573cfa 100644 --- a/lib/id3v2/ID3v22TagMapper.ts +++ b/lib/id3v2/ID3v22TagMapper.ts @@ -30,12 +30,6 @@ export const id3v22TagMap: INativeTagMap = { TEN: 'encodedby', TSS: 'encodersettings', WAR: 'website', - 'COM:iTunPGAP': 'gapless' - /* ToDo: iTunes tags: - 'COM:iTunNORM': , - 'COM:iTunSMPB': 'encoder delay', - 'COM:iTunes_CDDB_IDs' - */, PCS: 'podcast', TCP: "compilation", diff --git a/lib/id3v2/ID3v24TagMapper.ts b/lib/id3v2/ID3v24TagMapper.ts index 5d4e54417..631dd18f7 100644 --- a/lib/id3v2/ID3v24TagMapper.ts +++ b/lib/id3v2/ID3v24TagMapper.ts @@ -200,18 +200,10 @@ export class ID3v24TagMapper extends CaseInsensitiveTagMap { } break; - case 'COMM': - tag.value = tag.value?.text; - break; - case 'POPM': tag.value = ID3v24TagMapper.toRating(tag.value); break; - case 'USLT': - tag.value = tag.value?.text; - break; - default: break; } diff --git a/lib/id3v2/ID3v2Parser.ts b/lib/id3v2/ID3v2Parser.ts index 488b94aaa..d1fc1b658 100644 --- a/lib/id3v2/ID3v2Parser.ts +++ b/lib/id3v2/ID3v2Parser.ts @@ -154,10 +154,6 @@ export class ID3v2Parser { await this.handleTag(tag, tag.value.text, () => tag.value.description); } break; - case 'COM': - case 'COMM': - await this.handleTag(tag, tag.value, value => value.description, tag.id === 'COM' ? value => value.text : value => value); - break; default: await (Array.isArray(tag.value) ? Promise.all(tag.value.map(value => this.addTag(tag.id, value))) : this.addTag(tag.id, tag.value)); } diff --git a/lib/id3v2/ID3v2Token.ts b/lib/id3v2/ID3v2Token.ts index 36ca4d7d4..f3f407b0e 100644 --- a/lib/id3v2/ID3v2Token.ts +++ b/lib/id3v2/ID3v2Token.ts @@ -43,6 +43,25 @@ export interface IExtendedHeader { crcDataPresent: boolean; } +/** + * https://id3.org/id3v2.3.0#Synchronised_lyrics.2Ftext + */ +export enum LyricsContentType { + other = 0, + lyrics = 1, + text = 2, + movement_part = 3, + events = 4, + chord = 5, + trivia_pop = 6 +} + +export enum TimestampFormat { + notSynchronized0, + mpegFrameNumber = 1, + milliseconds = 2 +} + /** * 28 bits (representing up to 256MB) integer, the msb is 0 to avoid 'false syncsignals'. * 4 * %0xxxxxxx @@ -152,3 +171,50 @@ export const TextEncodingToken: IGetToken = { } } }; + +/** + * `USLT` frame fields + */ +export interface ITextHeader { + encoding: ITextEncoding; + language: string; +} + +/** + * Used to read first portion of `SYLT` frame + */ +export const TextHeader: IGetToken = { + len: 4, + + get: (uint8Array: Uint8Array, off: number): ITextHeader => { + return { + encoding: TextEncodingToken.get(uint8Array, off), + language: new Token.StringType(3, 'latin1').get(uint8Array, off + 1) + }; + } +}; + +/** + * SYLT` frame fields + */ +export interface ISyncTextHeader extends ITextHeader { + contentType: LyricsContentType; + timeStampFormat: TimestampFormat; +} + +/** + * Used to read first portion of `SYLT` frame + */ +export const SyncTextHeader: IGetToken = { + len: 6, + + get: (uint8Array: Uint8Array, off: number): ISyncTextHeader => { + const text = TextHeader.get(uint8Array, off); + return { + encoding: text.encoding, + language: text.language, + timeStampFormat: Token.UINT8.get(uint8Array, off + 4), + contentType: Token.UINT8.get(uint8Array, off + 5) + }; + } +}; diff --git a/lib/index.ts b/lib/index.ts index 300cf9113..49b56d43a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -11,7 +11,7 @@ import { ParserFactory } from './ParserFactory.js'; import { IAudioMetadata, IOptions } from './type.js'; import { RandomFileReader } from './common/RandomFileReader.js'; -export { IAudioMetadata, IOptions, ITag, INativeTagDict, ICommonTagsResult, IFormat, IPicture, IRatio, IChapter } from './type.js'; +export { IAudioMetadata, IOptions, ITag, INativeTagDict, ICommonTagsResult, IFormat, IPicture, IRatio, IChapter, ILyricsTag, LyricsContentType, TimestampFormat } from './type.js'; export { parseFromTokenizer, parseBuffer, parseBlob, parseWebStream, selectCover, orderTags, ratingToStars, IFileInfo } from './core.js'; const debug = initDebug('music-metadata:parser'); diff --git a/lib/type.ts b/lib/type.ts index b912f0521..3cfc217db 100644 --- a/lib/type.ts +++ b/lib/type.ts @@ -1,8 +1,10 @@ import { GenericTagId, TagType } from './common/GenericTagTypes.js'; import { IFooter } from './apev2/APEv2Token.js'; import { TrackType } from './matroska/types.js'; +import { LyricsContentType, TimestampFormat } from './id3v2/ID3v2Token.js'; export { TrackType } from './matroska/types.js'; +export { LyricsContentType, TimestampFormat } from './id3v2/ID3v2Token.js'; /** * Attached picture, typically used for cover art @@ -90,7 +92,7 @@ export interface ICommonTagsResult { /** * List of comments */ - comment?: string[]; + comment?: IComment[]; /** * Genre */ @@ -104,9 +106,9 @@ export interface ICommonTagsResult { */ composer?: string[]; /** - * Lyrics + * Synchronized lyrics */ - lyrics?: string[]; + lyrics?: ILyricsTag[]; /** * Album title, formatted for alphabetic ordering */ @@ -564,7 +566,6 @@ export interface IAudioMetadata extends INativeAudioMetadata { * Metadata, form independent interface */ common: ICommonTagsResult; - } /** @@ -689,3 +690,27 @@ export interface IRandomReader { */ randomRead(buffer: Uint8Array, offset: number, length: number, position: number): Promise; } + +interface ILyricsText { + text: string; + timestamp?: number; +} + +export interface IComment { + descriptor?: string; + language?: string; + text?: string; +} + +export interface ILyricsTag extends IComment { + contentType: LyricsContentType; + timeStampFormat: TimestampFormat; + /** + * Un-synchronized lyrics + */ + text?: string; + /** + * Synchronized lyrics + */ + syncText: ILyricsText[]; +} diff --git a/test/samples/mp3/menu-sash.mp3 b/test/samples/mp3/menu-sash.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..665677065b2c376e4957a405fd75d46f0d82051a GIT binary patch literal 19717 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 pzX|=QY>fZ_ literal 0 HcmV?d00001 diff --git a/test/test-async.ts b/test/test-async.ts index 52cba6eaf..97641fe97 100644 --- a/test/test-async.ts +++ b/test/test-async.ts @@ -15,13 +15,8 @@ describe('Asynchronous observer updates', () => { await mm.parseFile(flacFilePath, { observer: (event => { eventTags.push(event.tag); - switch (typeof event.tag.value) { - case 'number': - case 'string': - case 'boolean': - break; - default: - event.tag.value = null; + if (event.tag.id === 'picture') { + event.tag.value = null; } }) }); @@ -80,7 +75,7 @@ describe('Asynchronous observer updates', () => { { id: 'comment', type: 'common', - value: 'EAC-Secure Mode=should ignore equal sign' + value: {text: 'EAC-Secure Mode=should ignore equal sign'} }, { id: 'genre', diff --git a/test/test-comment-mapping.ts b/test/test-comment-mapping.ts index 5e794f85f..273d29486 100644 --- a/test/test-comment-mapping.ts +++ b/test/test-comment-mapping.ts @@ -20,7 +20,7 @@ describe('Mapping of common comment tag', () => { // Parse flac/Vorbis file const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [{text: 'Test 123'}]); }); it('should map ogg/Vorbis', async () => { @@ -29,7 +29,7 @@ describe('Mapping of common comment tag', () => { // Parse ogg/Vorbis file const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [{text: 'Test 123'}]); }); }); @@ -41,7 +41,7 @@ describe('Mapping of common comment tag', () => { // Run with default options const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [{text: 'Test 123'}]); }); it('WavPack / APEv2', async () => { @@ -50,7 +50,7 @@ describe('Mapping of common comment tag', () => { // Run with default options const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [{text: 'Test 123'}]); }); }); @@ -62,7 +62,11 @@ describe('Mapping of common comment tag', () => { // Run with default options const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [{ + descriptor: "", + language: "eng", + text: "Test 123" + }]); }); it('RIFF/WAVE/PCM / ID3v2.3', async () => { @@ -70,7 +74,11 @@ describe('Mapping of common comment tag', () => { const filePath = path.join(samplePath, 'MusicBrainz - Beth Hart - Sinner\'s Prayer [id3v2.3].wav'); const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [{ + descriptor: "", + language: "eng", + text: "Test 123" + }]); }); }); @@ -83,7 +91,11 @@ describe('Mapping of common comment tag', () => { // Run with default options const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [{ + descriptor: "", + language: "eng", + text: "Test 123" + }]); }); it('should parse AIFF/ID3v2.4 audio file', async () => { @@ -92,7 +104,13 @@ describe('Mapping of common comment tag', () => { // Run with default options const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [ + { + descriptor: "", + language: "eng", + text: "Test 123" + } + ]); }); }); @@ -104,7 +122,7 @@ describe('Mapping of common comment tag', () => { // Run with default options const metadata = await mm.parseFile(filePath); // Aggregation of '----:com.apple.iTunes:NOTES' & '©cmt' - t.deepEqual(metadata.common.comment, ['Medieval CUE Splitter (www.medieval.it)', 'Test 123']); + t.deepEqual(metadata.common.comment, [{text: 'Medieval CUE Splitter (www.medieval.it)'}, {text: 'Test 123'}]); }); it('should map WMA/ASF header', async () => { @@ -114,7 +132,7 @@ describe('Mapping of common comment tag', () => { // Parse wma/asf file const metadata = await mm.parseFile(filePath); - t.deepEqual(metadata.common.comment, ['Test 123']); + t.deepEqual(metadata.common.comment, [{text: 'Test 123'}]); }); }); diff --git a/test/test-file-aiff.ts b/test/test-file-aiff.ts index 64ab1eaa3..c9c203cf8 100644 --- a/test/test-file-aiff.ts +++ b/test/test-file-aiff.ts @@ -42,7 +42,7 @@ describe('Parse AIFF (Audio Interchange File Format)', () => { it(parser.description, async function(){ // AIFF-C file, stereo A-law data (compression type: alaw) // Source: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/AIFF/Samples.html - const metadata = await parser.initParser(this.skip(), path.join(aiffSamplePath, 'M1F1-AlawC-AFsp.aif'), 'audio/aiff'); + const metadata = await parser.initParser(() => this.skip(), path.join(aiffSamplePath, 'M1F1-AlawC-AFsp.aif'), 'audio/aiff'); checkFormat(metadata.format, 'Alaw 2:1', 8000, 2, 16, 23493); }); }); @@ -150,7 +150,11 @@ describe('Parse AIFF (Audio Interchange File Format)', () => { assert.strictEqual(format.container, 'AIFF-C', 'format.container'); assert.strictEqual(format.codec, 'Alaw 2:1', 'format.codec'); - assert.deepStrictEqual(common.comment, ['AFspdate: 2003-01-30 03:28:34 UTC', 'user: kabal@CAPELLA' ,'program: CopyAudio'], 'common.comment'); + assert.deepStrictEqual(common.comment, [ + {text: 'AFspdate: 2003-01-30 03:28:34 UTC'}, + {text: 'user: kabal@CAPELLA'}, + {text: 'program: CopyAudio'} + ], 'common.comment'); }); }); diff --git a/test/test-file-ape.ts b/test/test-file-ape.ts index d3502f97b..d767cffa6 100644 --- a/test/test-file-ape.ts +++ b/test/test-file-ape.ts @@ -40,14 +40,13 @@ describe('Parse APE (Monkey\'s Audio)', () => { Parsers.forEach(parser => { it(parser.description, async function(){ - const metadata = await parser.initParser(this.skip(), path.join(samplePath, 'monkeysaudio.ape'), 'audio/ape'); + const metadata = await parser.initParser(() => this.skip(), path.join(samplePath, 'monkeysaudio.ape'), 'audio/ape'); assert.isDefined(metadata, 'metadata should be defined'); checkFormat(metadata.format); checkCommon(metadata.common); assert.isDefined(metadata.native, 'metadata.native should be defined'); assert.isDefined(metadata.native.APEv2, 'metadata.native.APEv2 should be defined'); checkNative(mm.orderTags(metadata.native.APEv2)); - }); }); diff --git a/test/test-file-mp3.ts b/test/test-file-mp3.ts index 3fe7aa7fa..bd73d993a 100644 --- a/test/test-file-mp3.ts +++ b/test/test-file-mp3.ts @@ -296,7 +296,7 @@ describe('Parse MP3 files', () => { assert.deepEqual(common.artists, ['Band Aid'], 'common.artists'); assert.strictEqual(common.album, 'Now That\'s What I Call Xmas', 'common.album'); assert.strictEqual(common.year, 2006, 'common.year'); - assert.deepEqual(common.comment, ['TunNORM', ' 0000080E 00000AA9 00002328 000034F4 0002BF65 0002BF4E 000060AC 0000668F 0002BF4E 00033467'], 'common.comment'); + assert.deepEqual(common.comment, [{text: 'TunNORM'}, {text:' 0000080E 00000AA9 00002328 000034F4 0002BF65 0002BF4E 000060AC 0000668F 0002BF4E 00033467'}], 'common.comment'); assert.deepEqual(common.genre, ['General Holiday'], 'common.genre'); assert.deepEqual(common.track.no, 2, 'common.track.no'); }); diff --git a/test/test-file-mp4.ts b/test/test-file-mp4.ts index 6a867b360..d658376fc 100644 --- a/test/test-file-mp4.ts +++ b/test/test-file-mp4.ts @@ -107,7 +107,7 @@ describe('Parse MPEG-4 files with iTunes metadata', () => { assert.deepEqual(common.album, 'Live at Tom\'s Bullpen in Dover, DE (2016-04-30)'); assert.deepEqual(common.albumartist, 'They Say We\'re Sinking'); - assert.deepEqual(common.comment, ['youtube rip\r\nSource: https://www.youtube.com/playlist?list=PLZ4QPxwBgg9TfsFVAArOBfuve_0e7zQaV']); + assert.deepEqual(common.comment, [{text: 'youtube rip\r\nSource: https://www.youtube.com/playlist?list=PLZ4QPxwBgg9TfsFVAArOBfuve_0e7zQaV'}]); }); }); }); @@ -164,7 +164,7 @@ describe('Parse MPEG-4 files with iTunes metadata', () => { assert.strictEqual(common.encodedby, 'Chapter and Verse V 1.5'); assert.deepEqual(common.disk, {no: null, of: null}); assert.deepEqual(common.track, {no: 1, of: null}); - assert.deepEqual(common.comment, ['https://archive.org/details/glories_of_ireland_1801_librivox']); + assert.deepEqual(common.comment, [{text: 'https://archive.org/details/glories_of_ireland_1801_librivox'}]); const iTunes = mm.orderTags(metadata.native.iTunes); assert.deepEqual(iTunes.stik, [2], 'iTunes.stik = 2 = Audiobook'); // Ref: http://www.zoyinc.com/?p=1004 @@ -412,7 +412,7 @@ describe('Parse MPEG-4 files with iTunes metadata', () => { const filePath = path.join(mp4Samples, 'Apple voice memo.m4a'); - const {format, quality, common, native} = await mm.parseFile(filePath); + const {format, native} = await mm.parseFile(filePath); assert.strictEqual(format.container, 'M4A/isom/mp42', 'format.container'); assert.strictEqual(format.codec, 'MPEG-4/AAC', 'format.codec'); @@ -461,7 +461,7 @@ describe('Parse MPEG-4 files with iTunes metadata', () => { it('moov.udta.meta.ilst.rate mapping', async () => { const filePath = path.join(samplePath, 'rating', 'testcase.m4a'); - const {format, common, native} = await mm.parseFile(filePath); + const {common} = await mm.parseFile(filePath); assert.isDefined(common.rating, 'Expect rating property to be present'); assert.equal(common.rating[0].rating, 0.80, 'Vorbis tag rating score of 80%'); diff --git a/test/test-file-mpeg.ts b/test/test-file-mpeg.ts index 6bbf1f81a..b4f5a8537 100644 --- a/test/test-file-mpeg.ts +++ b/test/test-file-mpeg.ts @@ -111,7 +111,11 @@ describe('Parse MPEG', () => { t.strictEqual(common.disk.no, null, 'common.disk.no'); t.strictEqual(common.disk.of, null, 'common.disk.of'); t.deepEqual(common.genre, ['Ska-Punk'], 'common.genre'); - t.deepEqual(common.comment, ['Jive'], 'common.genre'); + t.deepEqual(common.comment, [{ + descriptor: "", + language: "eng", + text: "Jive" + }], 'common.genre'); } function checkID3v1(id3v1: mm.INativeTagDict) { @@ -134,7 +138,7 @@ describe('Parse MPEG', () => { t.deepEqual(id3v23.TYER, ['1998'], 'native: TYER'); t.deepEqual(id3v23.TCOM, ['CA'], 'native: TCOM'); // ToDo: common property? t.deepEqual(id3v23.TRCK, ['04'], 'native: TRCK'); - t.deepEqual(id3v23.COMM, [{description: '', language: 'eng', text: 'Jive'}], 'native: COMM'); + t.deepEqual(id3v23.COMM, [{descriptor: '', language: 'eng', text: 'Jive'}], 'native: COMM'); } const result = await mm.parseFile(filePath, {duration: true}); @@ -175,7 +179,7 @@ describe('Parse MPEG', () => { t.strictEqual(common.disk.no, null, 'common.disk.no'); t.strictEqual(common.disk.of, null, 'common.disk.of'); t.deepEqual(common.genre, ['Ska-Punk'], 'common.genre'); - t.deepEqual(common.comment, ['Jive'], 'common.genre'); + t.deepEqual(common.comment, [{descriptor: '', language: 'eng', text: 'Jive'}], 'common.comment'); } function checkID3v23(native: mm.INativeTagDict) { @@ -187,7 +191,7 @@ describe('Parse MPEG', () => { t.deepEqual(native.TYER, ['1998'], 'native: TYER'); t.deepEqual(native.TCOM, ['CA'], 'native: TCOM'); t.deepEqual(native.TRCK, ['07'], 'native: TRCK'); - t.deepEqual(native.COMM, [{description: '', language: 'eng', text: 'Jive'}], 'native: COMM'); + t.deepEqual(native.COMM, [{descriptor: '', language: 'eng', text: 'Jive'}], 'native: COMM'); } const result = await mm.parseFile(filePath, {duration: true}); diff --git a/test/test-file-wav.ts b/test/test-file-wav.ts index 02cc65139..4b9cc2721 100644 --- a/test/test-file-wav.ts +++ b/test/test-file-wav.ts @@ -101,7 +101,7 @@ describe('Parse RIFF/WAVE audio format', () => { assert.deepEqual(common.date, '2018-04-26T13:26:19-05:00'); assert.deepEqual(common.year, 2018); assert.deepEqual(common.encodedby, 'Adobe Audition CC 2018.1 (Macintosh)'); - assert.deepEqual(common.comment, ['Comments here!']); + assert.deepEqual(common.comment, [{text: 'Comments here!'}]); assert.deepEqual(common.genre, ['Blues']); assert.deepEqual(common.engineer, ['Engineer']); assert.deepEqual(common.technician, ['Technician']); diff --git a/test/test-id3v1.1.ts b/test/test-id3v1.1.ts index f58b5c875..84ae32a3f 100644 --- a/test/test-id3v1.1.ts +++ b/test/test-id3v1.1.ts @@ -32,7 +32,7 @@ describe('Parsing MPEG / ID3v1', () => { assert.strictEqual(common.track.no, 1, 'common.track.no = 1 (ID3v1 tag)'); assert.strictEqual(common.track.of, null, 'common.track.of = null'); assert.deepEqual(common.genre, ['Electronic'], 'common.genre'); - assert.deepEqual(common.comment, ['abcdefg'], 'common.comment'); + assert.deepEqual(common.comment, [{text: 'abcdefg'}], 'common.comment'); } /** diff --git a/test/test-id3v2-lyrics.ts b/test/test-id3v2-lyrics.ts index b3fb3756b..f6ce3766c 100644 --- a/test/test-id3v2-lyrics.ts +++ b/test/test-id3v2-lyrics.ts @@ -1,81 +1,272 @@ - -import {assert} from 'chai'; +import { assert } from 'chai'; import path from 'node:path'; import * as mm from '../lib/index.js'; import { samplePath } from './util.js'; - -const t = assert; +import { LyricsContentType } from '../lib/core.js'; it("should be able to read id3v2 files with lyrics", () => { const filename = 'id3v2-lyrics.mp3'; const filePath = path.join(samplePath, filename); + const expectedSyncText = [ + { + text: "The way we're living makes no sense", + timestamp: 10 + }, + { + text: "Take me back to the age of innocence", + timestamp: 3780 + }, + { + text: "I wanna go back then", + timestamp: 7540 + }, + { + text: "Take me back to the age of innocence", + timestamp: 11310 + }, + { + text: "Back to the age of innocence", + timestamp: 15070 + }, + { + text: "When clockwork fixed by lights and books", + timestamp: 30440 + }, + { + text: "When singers wrote songs instead of hooks", + timestamp: 34410 + }, + { + text: "When the value wasn't in the price", + timestamp: 37170 + }, + { + text: "When the fight for life was in the civil rights", + timestamp: 40540 + }, + { + text: "When we could live life through a screen", + timestamp: 43100 + }, + { + text: "When everything you knew was as good as it seems", + timestamp: 46070 + }, + { + text: "When the only worry was the concept of sin", + timestamp: 49440 + }, + { + text: "When did it begin?", + timestamp: 53000 + }, + { + text: "I wonder if I could,", + timestamp: 55770 + }, + { + text: "Go back to old Hollywood", + timestamp: 58530 + }, + { + text: "When presidents dropped blonde bombshells", + timestamp: 62700 + }, + { + text: "Instead of creating the perfect hell", + timestamp: 66470 + }, + { + text: "The way we're living makes no sense", + timestamp: 69030 + }, + { + text: "Take me back to the age of innocence", + timestamp: 72800 + }, + { + text: "I wanna go back then", + timestamp: 75560 + }, + { + text: "Take me back to the age of innocence", + timestamp: 78730 + }, + { + text: "Back to the age of innocence", + timestamp: 92700 + }, + { + text: "When pharmaceuticals were there to make life beautiful", + timestamp: 97260 + }, + { + text: "When the way that we were born was more than suitable", + timestamp: 100830 + }, + { + text: "When everybody thought they could make a difference", + timestamp: 104190 + }, + { + text: "And you couldn't get your pics within an instance", + timestamp: 106560 + }, + { + text: "I wonder if I could,", + timestamp: 110130 + }, + { + text: "Go back to old Hollywood", + timestamp: 113290 + }, + { + text: "When presidents dropped blonde bombshells", + timestamp: 117260 + }, + { + text: "Instead of creating the perfect hell", + timestamp: 120820 + }, + { + text: "The way we're living makes no sense", + timestamp: 123590 + }, + { + text: "Take me back to the age of innocence", + timestamp: 127360 + }, + { + text: "I wanna go back then", + timestamp: 130720 + }, + { + text: "Take me back to the age of innocence", + timestamp: 133090 + }, + { + text: "Back to the age of innocence", + timestamp: 136850 + }, + { + text: "I wanna get older, don't fight my age", + timestamp: 146420 + }, + { + text: "Take me back to those simpler days", + timestamp: 151790 + }, + { + text: "I wonder how it all happened", + timestamp: 158350 + }, + { + text: "I wanna get older, don't fight my age", + timestamp: 163520 + }, + { + text: "Don't wanna be the shade of a scalpel's blade", + timestamp: 167680 + }, + { + text: "So please tell me, please tell me", + timestamp: 170050 + }, + { + text: "What ever happened? Happened?", + timestamp: 178420 + }, + { + text: "I wonder if I could,", + timestamp: 180580 + }, + { + text: "Go back to old Hollywood", + timestamp: 182550 + }, + { + text: "When presidents dropped blonde bombshells", + timestamp: 185310 + }, + { + text: "Instead of creating their lives to hell", + timestamp: 188480 + }, + { + text: "The way we're living makes no sense", + timestamp: 191650 + }, + { + text: "Take me back, take me back", + timestamp: 194410 + }, + { + text: "To the age of innocence", + timestamp: 196180 + }, + { + text: "I wanna go back then", + timestamp: 198340 + }, + { + text: "Take me back, take me back", + timestamp: 200310 + }, + { + text: "To the age of innocence", + timestamp: 202080 + }, + { + text: "Back to the age of innocence", + timestamp: 204640 + }, + { + text: "I wanna get older, don't fight my age", + timestamp: 207010 + }, + { + text: "Take me back to those simpler days", + timestamp: 208970 + }, + { + text: "I wonder how it all happened", + timestamp: 210740 + }, + { + text: "I wanna get older, don't fight my age", + timestamp: 213910 + }, + { + text: "Don't wanna be the page on the scalpel's blade", + timestamp: 215870 + }, + { + text: "So please tell me, please tell me", + timestamp: 217640 + }, + { + text: "What ever happened?", + timestamp: 220600 + }, + { + text: "Happened?", + timestamp: 224370 + } + ]; + return mm.parseFile(filePath).then(metadata => { - t.deepEqual(metadata.common.lyrics, [ - 'The way we\'re living makes no sense', - 'Take me back to the age of innocence', - 'I wanna go back then', - 'Take me back to the age of innocence', - 'Back to the age of innocence', - 'When clockwork fixed by lights and books', - 'When singers wrote songs instead of hooks', - 'When the value wasn\'t in the price', - 'When the fight for life was in the civil rights', - 'When we could live life through a screen', - 'When everything you knew was as good as it seems', - 'When the only worry was the concept of sin', - 'When did it begin?', - 'I wonder if I could,', - 'Go back to old Hollywood', - 'When presidents dropped blonde bombshells', - 'Instead of creating the perfect hell', - 'The way we\'re living makes no sense', - 'Take me back to the age of innocence', - 'I wanna go back then', - 'Take me back to the age of innocence', - 'Back to the age of innocence', - 'When pharmaceuticals were there to make life beautiful', - 'When the way that we were born was more than suitable', - 'When everybody thought they could make a difference', - 'And you couldn\'t get your pics within an instance', - 'I wonder if I could,', - 'Go back to old Hollywood', - 'When presidents dropped blonde bombshells', - 'Instead of creating the perfect hell', - 'The way we\'re living makes no sense', - 'Take me back to the age of innocence', - 'I wanna go back then', - 'Take me back to the age of innocence', - 'Back to the age of innocence', - 'I wanna get older, don\'t fight my age', - 'Take me back to those simpler days', - 'I wonder how it all happened', - 'I wanna get older, don\'t fight my age', - 'Don\'t wanna be the shade of a scalpel\'s blade', - 'So please tell me, please tell me', - 'What ever happened? Happened?', - 'I wonder if I could,', - 'Go back to old Hollywood', - 'When presidents dropped blonde bombshells', - 'Instead of creating their lives to hell', - 'The way we\'re living makes no sense', - 'Take me back, take me back', - 'To the age of innocence', - 'I wanna go back then', - 'Take me back, take me back', - 'To the age of innocence', - 'Back to the age of innocence', - 'I wanna get older, don\'t fight my age', - 'Take me back to those simpler days', - 'I wonder how it all happened', - 'I wanna get older, don\'t fight my age', - 'Don\'t wanna be the page on the scalpel\'s blade', - 'So please tell me, please tell me', - 'What ever happened?', - 'Happened?'], 'Check lyrics'); + assert.isDefined(metadata.common.lyrics, 'metadata.common.lyrics'); + assert.strictEqual(metadata.common.lyrics.length, 1, 'metadata.common.lyrics.length'); + const lyrics = metadata.common.lyrics[0]; + + assert.strictEqual(lyrics.descriptor, '', 'metadata.common.lyrics[0].descriptor'); + assert.strictEqual(lyrics.contentType, LyricsContentType.lyrics, 'metadata.common.lyrics[0].contentType'); + assert.strictEqual(lyrics.language, 'eng', 'metadata.common.lyrics[0].language'); + assert.deepEqual(lyrics.syncText, expectedSyncText, 'metadata.common.lyrics[0].synchronized'); }); }); diff --git a/test/test-id3v2.2.ts b/test/test-id3v2.2.ts index 88365f4d8..e5db73565 100644 --- a/test/test-id3v2.2.ts +++ b/test/test-id3v2.2.ts @@ -28,19 +28,38 @@ describe('ID3v2Parser', () => { assert.deepEqual(id3v22.TP1, ['RushJet1'], '[\'ID3v2.2\'].TP1'); assert.deepEqual(id3v22.TRK, ['2/15'], '[\'ID3v2.2\'].TRK'); assert.deepEqual(id3v22.TYE, ['2011'], '[\'ID3v2.2\'].TYE'); - assert.deepEqual(id3v22['COM:iTunPGAP'], ['0'], '[\'ID3v2.2\'][\'COM:iTunPGAP\']'); assert.deepEqual(id3v22.TEN, ['iTunes 10.2.2.14'], '[\'ID3v2.2\'].TEN'); - assert.deepEqual(id3v22['COM:iTunNORM'], [' 00000308 00000000 00001627 00000000 00006FD6 00000000 00007F21 00000000 0000BE68 00000000'], 'COM:iTunNORM'); - assert.deepEqual(id3v22['COM:iTunSMPB'], [' 00000000 00000210 00000811 000000000043E1DF 00000000 001EBD63 00000000 00000000 00000000 00000000 00000000 00000000'], 'id3v22.TYE[\'COM:iTunSMPB\']'); assert.isDefined(id3v22.PIC, '[\'ID3v2.2\'].PIC'); assert.deepEqual(id3v22.TCO, ['Chiptune'], '[\'ID3v2.2\'].TCO'); assert.deepEqual(id3v22.TAL, ['Forgotten Music'], '[\'ID3v2.2\'].TAL'); assert.deepEqual(id3v22.TT2, ['Ancient Ruin Adventure'], '[\'ID3v2.2\'].TT2'); - assert.deepEqual(id3v22.COM, ['UBI025, 23.05.2011, http://ubiktune.org/releases/ubi025-rushjet1-forgotten-music'], '[\'ID3v2.2\'][\'COM\']'); - - assert.deepEqual(metadata.common.comment, ['UBI025, 23.05.2011, http://ubiktune.org/releases/ubi025-rushjet1-forgotten-music'], 'common.comment'); + const expectedComment = [ + { + descriptor: "", + language: "eng", + text: "UBI025, 23.05.2011, http://ubiktune.org/releases/ubi025-rushjet1-forgotten-music" + }, + { + descriptor: "iTunPGAP", + language: "eng", + text: "0" + }, + { + descriptor: "iTunNORM", + language: "eng", + text: " 00000308 00000000 00001627 00000000 00006FD6 00000000 00007F21 00000000 0000BE68 00000000" + }, + { + descriptor: "iTunSMPB", + language: "eng", + text: " 00000000 00000210 00000811 000000000043E1DF 00000000 001EBD63 00000000 00000000 00000000 00000000 00000000 00000000" + } + ]; + + assert.deepEqual(id3v22.COM, expectedComment, '[\'ID3v2.2\'][\'COM\']'); + assert.deepEqual(metadata.common.comment, expectedComment, 'common.comment'); }); it('should decode file \'id3v2.2.mp3\'', async () => { @@ -59,7 +78,28 @@ describe('ID3v2Parser', () => { assert.strictEqual(metadata.common.picture[0].format, 'image/jpeg', 'picture format'); assert.strictEqual(metadata.common.picture[0].data.length, 99738, 'picture length'); assert.strictEqual(metadata.common.gapless, false, 'common.gapless'); - assert.isUndefined(metadata.common.comment, 'common.comment'); + assert.deepEqual(metadata.common.comment, [ + { + descriptor: 'iTunPGAP', + language: 'eng', + text: '0' + }, + { + descriptor: 'iTunNORM', + language: 'eng', + text: ' 0000299C 0000291D 0000DBE0 0000D6BA 0003C378 0003C2C1 0000902A 00008F1B 00012FC6 00015FBC' + }, + { + descriptor: 'iTunSMPB', + language: 'eng', + text: ' 00000000 00000210 00000AD4 0000000000B6499C 00000000 006327AD 00000000 00000000 00000000 00000000 00000000 00000000' + }, + { + descriptor: 'iTunes_CDDB_IDs', + language: 'eng', + text: '11+3ABC77F16B8A2F0F1E1A1EBAB868A98F+8210091' + } + ], 'common.comment'); assert.isDefined(metadata.native['ID3v2.2'], 'Native id3v2.2 tags should be present'); const id3v22 = mm.orderTags(metadata.native['ID3v2.2']); @@ -67,13 +107,6 @@ describe('ID3v2Parser', () => { assert.deepEqual(id3v22.TP1, ['Shiny Toy Guns'], '[\'ID3v2.2\'].TP1'); assert.deepEqual(id3v22.TRK, ['1/11'], '[\'ID3v2.2\'].TRK'); assert.deepEqual(id3v22.TYE, ['2006'], '[\'ID3v2.2\'].TYE'); - assert.deepEqual(id3v22['COM:iTunPGAP'], ['0'], '[\'ID3v2.2\'][\'COM:iTunPGAP\']'); - assert.deepEqual(id3v22.TEN, ['iTunes v7.0.2.16'], '[\'ID3v2.2\'].TEN'); - assert.deepEqual(id3v22['COM:iTunNORM'], [' 0000299C 0000291D 0000DBE0 0000D6BA 0003C378 0003C2C1 0000902A 00008F1B 00012FC6 00015FBC'], 'COM:iTunNORM'); - - assert.deepEqual(id3v22['COM:iTunSMPB'], [' 00000000 00000210 00000AD4 0000000000B6499C 00000000 006327AD 00000000 00000000 00000000 00000000 00000000 00000000'], 'id3v22.TYE[\'COM:iTunSMPB\']'); - - assert.deepEqual(id3v22['COM:iTunes_CDDB_IDs'], ['11+3ABC77F16B8A2F0F1E1A1EBAB868A98F+8210091'], 'COM:iTunes_CDDB_IDs'); assert.isDefined(id3v22.PIC, '[\'ID3v2.2\'].PIC'); assert.deepEqual(id3v22.TCO, ['Alternative'], '[\'ID3v2.2\'].TCO'); @@ -81,7 +114,7 @@ describe('ID3v2Parser', () => { assert.deepEqual(id3v22.TT2, ['You Are The One'], '[\'ID3v2.2\'].TT2'); assert.deepEqual(id3v22.ULT, [{ - description: '', + descriptor: '', language: 'eng', /* eslint-disable max-len */ text: 'Black rose & a radio fire\nits so contagious\nsuch something changing my mind\nim gonna take whats evil\n\nYour cover melting inside\nwith wide eyes you tremble\nkissing over & over again\nyour god knows his faithful\n\nI try - to digest my pride\nbut passions grip i fear\nwhen i climb - into shallow vats of wine\ni think i almost hear - but its not clear\n\nYou are the one\nyou\'ll never be alone again\nyou\'re more then in my head - your more\n\nSpin faster shouting out loud\nyou cant steal whats paid for\nsuch something hurting again\nmurder son shes painful\n\nYou so believe your own lies\non my skin your fingers\nrunaway until the last time\nwere gonna lose forever\n\nwhen you try - don\'t try to say you wont\ntry to crawl into my head\nwhen you cry - cause it\'s all built up inside\nyour tears already said - already said\n\nYou\'ll never be alone again' diff --git a/test/test-id3v2.3.ts b/test/test-id3v2.3.ts index 64143cede..b6f2d4266 100644 --- a/test/test-id3v2.3.ts +++ b/test/test-id3v2.3.ts @@ -6,6 +6,7 @@ import { ID3v2Parser } from '../lib/id3v2/ID3v2Parser.js'; import { MetadataCollector } from '../lib/common/MetadataCollector.js'; import * as mm from '../lib/index.js'; import { samplePath } from './util.js'; +import { LyricsContentType, TimestampFormat, type ILyricsTag } from '../lib/core.js'; describe('Extract metadata from ID3v2.3 header', () => { @@ -178,7 +179,19 @@ describe('Extract metadata from ID3v2.3 header', () => { assert.deepEqual(id3v23.TPE1, ['2 Unlimited2', 'Ray', 'Anita'], 'null separated id3v23.TPE1'); assert.deepEqual(common.artists, ['2 Unlimited2', 'Ray', 'Anita'], 'common.artists'); - assert.deepEqual(common.comment, ['[DJSet]', '[All]'], 'common.comment'); + assert.isDefined(common.comment, 'common.comment'); + assert.deepEqual(common.comment, [ + { + descriptor: "", + language: "eng", + text: "[DJSet]" + }, + { + descriptor: "", + language: "eng", + text: "[All]" + } + ], 'common.comment'); assert.deepEqual(common.genre, ['Dance', 'Classics'], 'common.genre'); ['TPE1', 'TCOM', 'TCON'].forEach(tag => { @@ -245,28 +258,94 @@ describe('Extract metadata from ID3v2.3 header', () => { }); // https://id3.org/id3v2.3.0#Unsychronised_lyrics.2Ftext_transcription - it('4.9 Unsychronised lyrics/text transcription', async () => { + it('4.9 USLT: Unsychronised lyrics/text transcription', async () => { + + const expectedLyricsText = "Lord, have mercy, Lord, have mercy on me\nLord, have mercy, Lord, have mercy on me\n" + + "Well, if I've done somebody wrong\nLord, have mercy if you please\n\n" + + "I used to have plenty of money\nThe finest clothes in town\n" + + "Bad luck and trouble overtook me\nAnd God, look at me now\n\n" + + "Please have mercy, Lord, have mercy on me\nAnd if I've done somebody wrong\nLord, have mercy if you please\n\n" + + "Keep on working, my child\nOh, in the morning, oh\nLord, have mercy\n\nIf I've been a bad girl, baby\nYeah, I'll change my ways\n" + + "Don't want bad luck and trouble\nOn me all my days\n\n" + + "Please have mercy, Lord, have mercy on me\nAnd if I've done somebody wrong\nLord, have mercy if you please\n" + + "Have mercy on me"; + const {native, common} = await mm.parseFile(path.join(samplePath, 'MusicBrainz - Beth Hart - Sinner\'s Prayer [id3v2.3].V2.mp3')); - const lyrics = "Lord, have mercy, Lord, have mercy on me\nLord, have mercy, Lord, have mercy on me\n" + - "Well, if I've done somebody wrong\nLord, have mercy if you please\n\n" + - "I used to have plenty of money\nThe finest clothes in town\n" + - "Bad luck and trouble overtook me\nAnd God, look at me now\n\n" + - "Please have mercy, Lord, have mercy on me\nAnd if I've done somebody wrong\nLord, have mercy if you please\n\n" + - "Keep on working, my child\nOh, in the morning, oh\nLord, have mercy\n\nIf I've been a bad girl, baby\nYeah, I'll change my ways\n" + - "Don't want bad luck and trouble\nOn me all my days\n\n" + - "Please have mercy, Lord, have mercy on me\nAnd if I've done somebody wrong\nLord, have mercy if you please\n" + - "Have mercy on me"; + const id3v23 = mm.orderTags(native['ID3v2.3']); assert.isDefined(id3v23.USLT, 'Should contain ID3v2.3 USLT tag'); - assert.deepEqual(id3v23.USLT[0], { - description: "Sinner's Prayer", - language: "eng", - text: lyrics - }); + assert.strictEqual(id3v23.USLT.length,1, 'id3v23.USLT.length'); + const uslt = id3v23.USLT[0] as ILyricsTag; + assert.strictEqual(uslt.descriptor, "Sinner's Prayer", 'id3v23.USLT.description'); + assert.strictEqual(uslt.language, "eng", 'id3v23.USLT.language'); + assert.isDefined(uslt.text, 'id3v23.USLT.text'); + assert.strictEqual(uslt.text, expectedLyricsText, 'id3v23.USLT.text'); + // Check mapping to common IDv2.3 tag assert.isDefined(common.lyrics, 'Should map tag id3v23.USLT to common.lyrics'); - assert.strictEqual(common.lyrics[0], lyrics); + const lyrics = common.lyrics[0]; + assert.strictEqual(lyrics.descriptor, "Sinner's Prayer", 'common.lyrics.descriptor'); + assert.strictEqual(lyrics.language, "eng", 'common.lyrics.language'); + assert.isDefined(lyrics.text, 'common.lyrics.text'); + assert.strictEqual(lyrics.text, expectedLyricsText, 'common.lyrics.text'); + }); + + // Issue: https://github.com/Borewit/music-metadata/issues/2134 + it('4.10. Synchronised lyrics/text', async () => { + const filePath = path.join(samplePath, 'mp3', 'menu-sash.mp3'); + const {format, native} = await mm.parseFile(filePath); + + assert.strictEqual(format.container, 'MPEG'); + assert.strictEqual(format.codec, 'MPEG 1 Layer 3'); + assert.isDefined(native['ID3v2.3'], 'Presence of ID3v2.3 tag header'); + const id3v23 = mm.orderTags(native['ID3v2.3']); + const syltTags = id3v23.SYLT as ILyricsTag[]; + assert.strictEqual(syltTags.length, 3, 'Number of SYLT tags'); + + [syltTags[0], syltTags[1]].forEach(sylt => { + assert.strictEqual(sylt.descriptor, 'captions', 'id3v23.sylt.descriptor'); + assert.strictEqual(sylt.timeStampFormat, TimestampFormat.milliseconds, 'id3v23.sylt.timeStampFormat in milliseconds'); + assert.strictEqual(sylt.language, 'eng', 'id3v23.sylt.language'); + assert.strictEqual(sylt.contentType, LyricsContentType.text, 'id3v23.sylt.contentType'); + assert.deepEqual(sylt.syncText, [ + { + text: 'Check out your sash!', + timestamp: 9 + } + ], 'id3v23.sylt.syncText'); + }); + + assert.strictEqual(syltTags[2].descriptor, 'lipsync', 'id3v23.sylt.descriptor'); + assert.strictEqual(syltTags[2].timeStampFormat, TimestampFormat.milliseconds, 'id3v23.sylt.timeStampFormat in milliseconds'); + assert.strictEqual(syltTags[2].language, 'eng', 'id3v23.sylt.language'); + assert.strictEqual(syltTags[2].contentType, LyricsContentType.other, 'id3v23.sylt.contentType'); + assert.deepEqual(syltTags[2].syncText, [ + { + text: 'X', + timestamp: 0 + }, { + text: 'F', + timestamp: 110 + }, { + text: 'C', + timestamp: 280 + }, { + text: 'F', + timestamp: 350 + }, { + text: 'B', + timestamp: 560 + }, { + text: 'C', + timestamp: 630 + }, { + text: 'B', + timestamp: 980 + }, { + text: 'X', + timestamp: 1120 + }], 'id3v23.sylt.syncText'); }); // http://id3.org/id3v2.3.0#General_encapsulated_object @@ -281,9 +360,9 @@ describe('Extract metadata from ID3v2.3 header', () => { assert.strictEqual(format.container, 'MPEG', 'format.container'); assert.deepEqual(format.tagTypes, ['ID3v2.3'], 'format.tagTypes'); - assert.strictEqual(common.title, 'test', 'common.title'); + assert.isDefined(native['ID3v2.3'], 'Presence of ID3v2.3 tag header'); const id3v2 = mm.orderTags(native['ID3v2.3']); assert.deepEqual(id3v2.GEOB[0].type, 'application/octet-stream', 'ID3v2.GEOB[0].type'); assert.deepEqual(id3v2.GEOB[0].filename, '', 'ID3v2.GEOB[0].filename'); @@ -379,7 +458,7 @@ describe('Extract metadata from ID3v2.3 header', () => { const {native, common} = await mm.parseFile(filePath); assert.isDefined(native['ID3v2.4'], 'Expect ID3v2.4 tag header to be present'); - const grp1Tags = native['ID3v2.4'].filter(tag => tag.id==='GRP1'); + const grp1Tags = native['ID3v2.4'].filter(tag => tag.id === 'GRP1'); assert.strictEqual(grp1Tags.length, 1, 'Expect ID3v2.4 GRP1 tag be present'); assert.strictEqual(grp1Tags[0].value, 'GRP1-Test', 'Expect ID3v2.4 GRP1 value'); diff --git a/test/test-id3v2.4.ts b/test/test-id3v2.4.ts index 206c40e5d..630f2e461 100644 --- a/test/test-id3v2.4.ts +++ b/test/test-id3v2.4.ts @@ -44,7 +44,28 @@ describe("Decode MP3/ID3v2.4", () => { const filePath = path.join(samplePath, 'mp3', 'issue-502.mp3'); const {common} = await mm.parseFile(filePath); - assert.deepEqual(common.comment, ['CLEAN'], 'common.comment'); + assert.deepEqual(common.comment, [ + { + descriptor: "iTunPGAP", + language: "eng", + text: "0" + }, + { + descriptor: "iTunNORM", + language: "eng", + text: " 000003B5 000003DA 00002F21 00003563 000023B6 0000D74E 00007267 000077A0 0000D7B6 0000D7B6" + }, + { + descriptor: "iTunSMPB", + language: "eng", + text: " 00000000 00000210 0000084C 00000000006E6BA4 00000000 00641054 00000000 00000000 00000000 00000000 00000000 00000000" + }, + { + descriptor: "", + language: "eng", + text: "CLEAN" + } + ], 'common.comment'); }); it("should respect skipCovers-flag", () => { @@ -88,14 +109,16 @@ describe("Decode MP3/ID3v2.4", () => { const id3v23 = mm.orderTags(native['ID3v2.4']); assert.isDefined(id3v23.USLT, 'Should contain ID3v2.4 USLT tag'); assert.deepEqual(id3v23.USLT[0], { - description: "Sinner's Prayer", - language: "eng", + descriptor: "Sinner's Prayer", + language: 'eng', text: lyrics }); // Check mapping to common IDv2.3 tag assert.isDefined(common.lyrics, 'Should map tag id3v23.USLT to common.lyrics'); - assert.strictEqual(common.lyrics[0], lyrics); + assert.strictEqual(common.lyrics[0].descriptor, "Sinner's Prayer", 'common.lyrics[0].descriptor'); + assert.strictEqual(common.lyrics[0].language, 'eng', 'common.lyrics[0].language'); + assert.strictEqual(common.lyrics[0].text, lyrics); }); }); diff --git a/test/test-musicbrainz-picard-vorbis.ts b/test/test-musicbrainz-picard-vorbis.ts index dd8c54426..48650a127 100644 --- a/test/test-musicbrainz-picard-vorbis.ts +++ b/test/test-musicbrainz-picard-vorbis.ts @@ -37,7 +37,9 @@ it('MusicBrains/Picard tags in FLAC', async () => { assert.deepEqual(common.originaldate, '2011-09-11', 'common.originaldate'); assert.deepEqual(common.releasestatus, 'official', 'common.releasestatus'); assert.deepEqual(common.releasetype, ['album', 'compilation'], 'common.releasetype'); - assert.deepEqual(common.comment, ['EAC-Secure Mode'], 'common.comment'); + assert.deepEqual(common.comment, [{ + text: "EAC-Secure Mode" + }], 'common.comment'); assert.deepEqual(common.genre, ['Alt. Rock'], 'common.genre'); assert.deepEqual(common.musicbrainz_albumid, '6032dfc4-8880-4fea-b1c0-aaee52e1113c', 'common.musicbrainz_albumid'); assert.deepEqual(common.musicbrainz_recordingid, 'b0c1d984-ba93-4167-880a-ac02255bf9e7', 'common.musicbrainz_recordingid'); diff --git a/test/test-pr-544.ts b/test/test-pr-544.ts index 298cd1087..f3c716cf3 100644 --- a/test/test-pr-544.ts +++ b/test/test-pr-544.ts @@ -379,7 +379,13 @@ describe('Add, change and fix some mappings #pr-544', () => { const filePath = path.join(samplePath, filename); return mm.parseFile(filePath).then(metadata => { - t.deepEqual(metadata.common.comment, ['Tagged with Mp3tag v3.01'], 'metadata.common.comment'); + t.deepEqual(metadata.common.comment, [ + { + descriptor: "", + language: "eng", + text: "Tagged with Mp3tag v3.01" + } + ], 'metadata.common.comment'); }); }); @@ -388,7 +394,7 @@ describe('Add, change and fix some mappings #pr-544', () => { const filePath = path.join(samplePath, filename); return mm.parseFile(filePath).then(metadata => { - t.deepEqual(metadata.common.comment, ['Tagged with Mp3tag v3.01'], 'metadata.common.comment'); + t.deepEqual(metadata.common.comment, [{text: 'Tagged with Mp3tag v3.01'}], 'metadata.common.comment'); }); }); });