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

Add support for LRC Lyrics #2285

Merged
merged 1 commit into from
Nov 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,13 @@ Following tag header formats are supported:
- [RIFF](https://wikipedia.org/wiki/Resource_Interchange_File_Format)/INFO
- [Vorbis comment](https://wikipedia.org/wiki/Vorbis_comment)
- [AIFF](https://wikipedia.org/wiki/Audio_Interchange_File_Format)

It allows many tags to be accessed in audio format, and tag format independent way.

Following lyric formats are supported:
- [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
- Synchronized lyrics (SYLT)
- Unsynchronized lyrics (USULT)
[
It allows many tags to be]() accessed in audio format, and tag format independent way.

Support for [MusicBrainz](https://musicbrainz.org/) tags as written by [Picard](https://picard.musicbrainz.org/).
[ReplayGain](https://wiki.hydrogenaud.io/index.php?title=ReplayGain) tags are supported.
Expand Down
7 changes: 7 additions & 0 deletions lib/common/MetadataCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CombinedTagMapper } from './CombinedTagMapper.js';
import { CommonTagMapper } from './GenericTagMapper.js';
import { toRatio } from './Util.js';
import { fileTypeFromBuffer } from 'file-type';
import { parseLrc } from '../lrc/LyricsParser.js';

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

Expand Down Expand Up @@ -265,6 +266,12 @@ export class MetadataCollector implements INativeMetadataCollector {
}
break;

case 'lyrics':
if (typeof tag.value === 'string') {
tag.value = parseLrc(tag.value);
}
break;

default:
// nothing to do
}
Expand Down
39 changes: 39 additions & 0 deletions lib/lrc/LyricsParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type ILyricsText, type ILyricsTag, LyricsContentType, TimestampFormat } from '../type.js';

/**
* Parse LRC (Lyrics) formatted text
* Ref: https://en.wikipedia.org/wiki/LRC_(file_format)
* @param lrcString
*/
export function parseLrc(lrcString: string): ILyricsTag {
const lines = lrcString.split('\n');
const syncText: ILyricsText[] = [];

// Regular expression to match LRC timestamps (e.g., [00:45.52])
const timestampRegex = /\[(\d{2}):(\d{2})\.(\d{2})\]/;

for (const line of lines) {
const match = line.match(timestampRegex);

if (match) {
const minutes = Number.parseInt(match[1], 10);
const seconds = Number.parseInt(match[2], 10);
const hundredths = Number.parseInt(match[3], 10);

// Convert the timestamp to milliseconds, as per TimestampFormat.milliseconds
const timestamp = (minutes * 60 + seconds) * 1000 + hundredths * 10;

// Get the text portion of the line (e.g., "あの蝶は自由になれたかな")
const text = line.replace(timestampRegex, '').trim();

syncText.push({ timestamp, text });
}
}

// Creating the ILyricsTag object
return {
contentType: LyricsContentType.lyrics,
timeStampFormat: TimestampFormat.milliseconds,
syncText,
};
}
2 changes: 1 addition & 1 deletion lib/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,7 @@ export interface IRandomReader {
randomRead(buffer: Uint8Array, offset: number, length: number, position: number): Promise<number>;
}

interface ILyricsText {
export interface ILyricsText {
text: string;
timestamp?: number;
}
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@
"info",
"parse",
"parser",
"bwf"
"bwf",
"slt",
"lyrics"
],
"scripts": {
"clean": "del-cli 'lib/**/*.js' 'lib/**/*.js.map' 'lib/**/*.d.ts' 'src/**/*.d.ts' 'test/**/*.js' 'test/**/*.js.map' 'test/**/*.js' 'test/**/*.js.map' 'doc-gen/**/*.js' 'doc-gen/**/*.js.map'",
Expand Down
Binary file not shown.
23 changes: 23 additions & 0 deletions test/test-file-flac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'node:fs';
import * as path from 'node:path';

import * as mm from '../lib/index.js';
import { LyricsContentType, TimestampFormat } from '../lib/index.js';
import { Parsers } from './metadata-parsers.js';
import { samplePath } from './util.js';

Expand Down Expand Up @@ -173,5 +174,27 @@ describe('Parse FLAC Vorbis comment', () => {
assert.equal(mm.ratingToStars(common.rating[0].rating), 4, 'Vorbis tag rating conversion');
});

it('Should decode LRC lyrics', async () => {

const filePath = path.join(flacFilePath, 'Dance In The Game - ZAQ - LRC.flac');
const {common} = await mm.parseFile(filePath);

assert.isArray(common.lyrics, 'common.lyrics');
assert.strictEqual(common.lyrics.length, 1, 'common.lyrics.length');
const lrcLyrics = common.lyrics[0];
assert.strictEqual(lrcLyrics.contentType, LyricsContentType.lyrics, 'lrcLyrics.contentType');
assert.strictEqual(lrcLyrics.timeStampFormat, TimestampFormat.milliseconds, 'lrcLyrics.timeStampFormat');
assert.isArray(lrcLyrics.syncText, 'lrcLyrics.syncText');
assert.strictEqual(lrcLyrics.syncText.length, 39, 'lrcLyrics.syncText.length');
assert.strictEqual(lrcLyrics.syncText[0].timestamp, 0, 'syncText[0].timestamp');
assert.strictEqual(lrcLyrics.syncText[0].text, '作词 : ZAQ', 'lrcLyrics.syncText[0].text');
assert.strictEqual(lrcLyrics.syncText[1].timestamp, 300, 'syncText[1].timestamp');
assert.strictEqual(lrcLyrics.syncText[1].text, '作曲 : ZAQ', 'lrcLyrics.syncText[1].text');

const syncText = lrcLyrics.syncText
assert.isArray(common.lyrics, 'common.lyrics');

});

});

Loading