From f12c588ad1c6023e0abd137049fca5f882dcb693 Mon Sep 17 00:00:00 2001 From: Borewit Date: Sun, 10 Nov 2024 16:44:56 +0100 Subject: [PATCH] Add support for LRC Lyrics --- README.md | 9 +++- lib/common/MetadataCollector.ts | 7 ++++ lib/lrc/LyricsParser.ts | 39 ++++++++++++++++++ lib/type.ts | 2 +- package.json | 4 +- .../flac/Dance In The Game - ZAQ - LRC.flac | Bin 0 -> 21780 bytes test/test-file-flac.ts | 23 +++++++++++ 7 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 lib/lrc/LyricsParser.ts create mode 100644 test/samples/flac/Dance In The Game - ZAQ - LRC.flac diff --git a/README.md b/README.md index 6c83212d2..36c862ec9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/common/MetadataCollector.ts b/lib/common/MetadataCollector.ts index 5ef513c37..ddeaa39e0 100644 --- a/lib/common/MetadataCollector.ts +++ b/lib/common/MetadataCollector.ts @@ -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'); @@ -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 } diff --git a/lib/lrc/LyricsParser.ts b/lib/lrc/LyricsParser.ts new file mode 100644 index 000000000..940956aa7 --- /dev/null +++ b/lib/lrc/LyricsParser.ts @@ -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, + }; +} diff --git a/lib/type.ts b/lib/type.ts index 58983c0f6..727a51b5e 100644 --- a/lib/type.ts +++ b/lib/type.ts @@ -716,7 +716,7 @@ export interface IRandomReader { randomRead(buffer: Uint8Array, offset: number, length: number, position: number): Promise; } -interface ILyricsText { +export interface ILyricsText { text: string; timestamp?: number; } diff --git a/package.json b/package.json index b3e8664b9..31b417b23 100644 --- a/package.json +++ b/package.json @@ -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'", diff --git a/test/samples/flac/Dance In The Game - ZAQ - LRC.flac b/test/samples/flac/Dance In The Game - ZAQ - LRC.flac new file mode 100644 index 0000000000000000000000000000000000000000..2915804c2a19fdbafb6b6c618cdb9c30825f091e GIT binary patch literal 21780 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�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 { 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'); + + }); + });