Skip to content

Commit

Permalink
Add support for ID3v2 SYLT frame
Browse files Browse the repository at this point in the history
Improve and extend generic comment mapping
  • Loading branch information
Borewit committed Jul 20, 2024
1 parent ee84b3d commit c7fd893
Show file tree
Hide file tree
Showing 26 changed files with 653 additions and 185 deletions.
2 changes: 1 addition & 1 deletion lib/common/GenericTagMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down
9 changes: 9 additions & 0 deletions lib/common/MetadataCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions lib/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<G> = NodeReadableStream<G> | ReadableStream<G>;

/**
Expand Down
72 changes: 51 additions & 21 deletions lib/id3v2/FrameParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -195,38 +195,58 @@ 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':
case 'USLT':
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':
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 0 additions & 6 deletions lib/id3v2/ID3v22TagMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 0 additions & 8 deletions lib/id3v2/ID3v24TagMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 0 additions & 4 deletions lib/id3v2/ID3v2Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
66 changes: 66 additions & 0 deletions lib/id3v2/ID3v2Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -152,3 +171,50 @@ export const TextEncodingToken: IGetToken<ITextEncoding> = {
}
}
};

/**
* `USLT` frame fields
*/
export interface ITextHeader {
encoding: ITextEncoding;
language: string;
}

/**
* Used to read first portion of `SYLT` frame
*/
export const TextHeader: IGetToken<ITextHeader> = {
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<ISyncTextHeader> = {
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)
};
}
};
2 changes: 1 addition & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
33 changes: 29 additions & 4 deletions lib/type.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -90,7 +92,7 @@ export interface ICommonTagsResult {
/**
* List of comments
*/
comment?: string[];
comment?: IComment[];
/**
* Genre
*/
Expand All @@ -104,9 +106,9 @@ export interface ICommonTagsResult {
*/
composer?: string[];
/**
* Lyrics
* Synchronized lyrics
*/
lyrics?: string[];
lyrics?: ILyricsTag[];
/**
* Album title, formatted for alphabetic ordering
*/
Expand Down Expand Up @@ -564,7 +566,6 @@ export interface IAudioMetadata extends INativeAudioMetadata {
* Metadata, form independent interface
*/
common: ICommonTagsResult;

}

/**
Expand Down Expand Up @@ -689,3 +690,27 @@ export interface IRandomReader {
*/
randomRead(buffer: Uint8Array, offset: number, length: number, position: number): Promise<number>;
}

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[];
}
Binary file added test/samples/mp3/menu-sash.mp3
Binary file not shown.
11 changes: 3 additions & 8 deletions test/test-async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
})
});
Expand Down Expand Up @@ -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',
Expand Down
Loading

0 comments on commit c7fd893

Please sign in to comment.