Improve a2 enhanced lrc support

This commit is contained in:
twodoorcoupe
2024-03-20 15:42:51 +01:00
parent 3ef7e993fa
commit 2f3ffee03c
4 changed files with 79 additions and 49 deletions

View File

@@ -64,7 +64,7 @@ from picard.util import (
) )
from picard.util.tags import ( from picard.util.tags import (
parse_comment_tag, parse_comment_tag,
parse_lyrics_tag, parse_subtag,
) )
@@ -245,8 +245,10 @@ class ID3File(File):
'MVIN': re.compile(r'^(?P<movementnumber>\d+)(?:/(?P<movementtotal>\d+))?$') 'MVIN': re.compile(r'^(?P<movementnumber>\d+)(?:/(?P<movementtotal>\d+))?$')
} }
__lrc_line_re_parse = re.compile(r'(\[\d\d:\d\d\.\d\d\])') __lrc_line_re_parse = re.compile(r'(\[\d\d:\d\d\.\d\d\d\])')
__lrc_syllable_re_parse = re.compile(r'(\[\d\d:\d\d\.\d\d\]|<\d\d:\d\d\.\d\d>)') __lrc_syllable_re_parse = re.compile(r'(<\d\d:\d\d\.\d\d\d>)')
__lrc_syllable_at_the_end_re_parse = re.compile(r'(<\d\d:\d\d\.\d\d\d>$)')
__lrc_both_re_parse = re.compile(r'(\[\d\d:\d\d\.\d\d\d\]|<\d\d:\d\d\.\d\d\d>)')
def __init__(self, filename): def __init__(self, filename):
super().__init__(filename) super().__init__(filename)
@@ -491,7 +493,7 @@ class ID3File(File):
for value in values: for value in values:
tags.add(id3.USLT(encoding=encoding, desc=desc, text=value)) tags.add(id3.USLT(encoding=encoding, desc=desc, text=value))
elif name == 'syncedlyrics' or name.startswith('syncedlyrics:'): elif name == 'syncedlyrics' or name.startswith('syncedlyrics:'):
(lang, desc) = parse_lyrics_tag(name) (lang, desc) = parse_subtag(name)
for value in values: for value in values:
sylt_lyrics = self._parse_lrc_text(value) sylt_lyrics = self._parse_lrc_text(value)
# If the text does not contain any timestamps, the tag is not added # If the text does not contain any timestamps, the tag is not added
@@ -619,7 +621,7 @@ class ID3File(File):
if frame.FrameID == 'USLT' and frame.desc == desc: if frame.FrameID == 'USLT' and frame.desc == desc:
del tags[key] del tags[key]
elif name == 'syncedlyrics' or name.startswith('syncedlyrics:'): elif name == 'syncedlyrics' or name.startswith('syncedlyrics:'):
(lang, desc) = parse_lyrics_tag(name) (lang, desc) = parse_subtag(name)
for key, frame in list(tags.items()): for key, frame in list(tags.items()):
if frame.FrameID == 'SYLT' and frame.desc == desc and frame.lang == lang \ if frame.FrameID == 'SYLT' and frame.desc == desc and frame.lang == lang \
and frame.type == 1 and frame.format == 2: and frame.type == 1 and frame.format == 2:
@@ -729,27 +731,50 @@ class ID3File(File):
return values return values
def _parse_sylt_text(self, text): def _parse_sylt_text(self, text):
lrc_lyrics = []
previous_line_ended = True def milliseconds_to_timestamp(ms):
for lyrics, milliseconds in text: minutes = ms // (60 * 1000)
minutes = milliseconds // (60 * 1000) seconds = (ms % (60 * 1000)) // 1000
seconds = (milliseconds % (60 * 1000)) // 1000 remaining_ms = ms % 1000
hundredths = (milliseconds % 1000) // 10 return f"{minutes:02d}:{seconds:02d}.{remaining_ms:03d}"
if previous_line_ended:
lrc_lyrics.append(f"[{minutes:02d}:{seconds:02d}.{hundredths:02d}]{lyrics}") lyrics, milliseconds = zip(*text)
previous_line_ended = False first_timestamp = milliseconds_to_timestamp(milliseconds[0])
else: lrc_lyrics = [f"[{first_timestamp}]"]
lrc_lyrics.append(f"<{minutes:02d}:{seconds:02d}.{hundredths:02d}>{lyrics}") for i, lyrics in enumerate(lyrics):
timestamp = milliseconds_to_timestamp(milliseconds[i])
if '\n' in lyrics: if '\n' in lyrics:
previous_line_ended = True split = lyrics.split('\n')
lrc_lyrics.append(f"<{timestamp}>{split[0]}")
for line in split[1:]:
if i + 1 < len(milliseconds):
estimation = (milliseconds[i] + milliseconds[i + 1]) // 2
else:
estimation = milliseconds[i]
timestamp = milliseconds_to_timestamp(estimation)
lrc_lyrics.append(f"\n[{timestamp}]{line}")
else:
lrc_lyrics.append(f"<{timestamp}>{lyrics}")
return "".join(lrc_lyrics) return "".join(lrc_lyrics)
def _parse_lrc_text(self, text): def _parse_lrc_text(self, text):
sylt_lyrics = [] sylt_lyrics = []
timestamp_and_lyrics = batched(self.__lrc_syllable_re_parse.split(text)[1:], 2)
# If the text is in a2 enhanced lrc
if self.__lrc_syllable_re_parse.search(text):
lines = []
split = text.split("\n")
for line in split:
if self.__lrc_line_re_parse.match(line):
line = self.__lrc_line_re_parse.sub("", line)
line = self.__lrc_syllable_at_the_end_re_parse.sub("", line.rstrip())
lines.append(line)
text = "\n".join(lines)
timestamp_and_lyrics = batched(self.__lrc_both_re_parse.split(text)[1:], 2)
for timestamp, lyrics in timestamp_and_lyrics: for timestamp, lyrics in timestamp_and_lyrics:
minutes, seconds, hundredths = timestamp[1:-1].replace(".", ":").split(':') minutes, seconds, ms = timestamp[1:-1].replace(".", ":").split(':')
milliseconds = int(minutes) * 60 * 1000 + int(seconds) * 1000 + int(hundredths) * 10 milliseconds = int(minutes) * 60 * 1000 + int(float('%s.%s' % (seconds, ms)) * 1000)
sylt_lyrics.append((lyrics, milliseconds)) sylt_lyrics.append((lyrics, milliseconds))
return sylt_lyrics return sylt_lyrics

View File

@@ -199,7 +199,7 @@ def parse_comment_tag(name): # noqa: E302
return lang, desc return lang, desc
def parse_lyrics_tag(name): def parse_subtag(name):
""" """
Parses a tag name like "lyrics:XXX:desc", where XXX is the language. Parses a tag name like "lyrics:XXX:desc", where XXX is the language.
If language is not set, the colons are still mandatory, and "eng" is If language is not set, the colons are still mandatory, and "eng" is

View File

@@ -372,9 +372,10 @@ class CommonId3Tests:
@skipUnlessTestfile @skipUnlessTestfile
def test_syncedlyrics_preserve_language_and_description(self): def test_syncedlyrics_preserve_language_and_description(self):
metadata = Metadata({'syncedlyrics:ita': '[00:00.00]foo3'}) metadata = Metadata({'syncedlyrics': '[00:00.000]<00:00.000>foo1'})
metadata.add('syncedlyrics:deu:desc', '[00:00.00]foo1') metadata.add('syncedlyrics:deu:desc', '[00:00.000]<00:00.000>foo2')
metadata.add('syncedlyrics::desc', '[00:00.00]foo4') metadata.add('syncedlyrics:ita', '[00:00.000]<00:00.000>foo3')
metadata.add('syncedlyrics::desc', '[00:00.000]<00:00.000>foo4')
loaded_metadata = save_and_load_metadata(self.filename, metadata) loaded_metadata = save_and_load_metadata(self.filename, metadata)
self.assertEqual(metadata['syncedlyrics'], loaded_metadata['syncedlyrics:eng']) self.assertEqual(metadata['syncedlyrics'], loaded_metadata['syncedlyrics:eng'])
self.assertEqual(metadata['syncedlyrics:deu:desc'], loaded_metadata['syncedlyrics:deu:desc']) self.assertEqual(metadata['syncedlyrics:deu:desc'], loaded_metadata['syncedlyrics:deu:desc'])
@@ -383,7 +384,7 @@ class CommonId3Tests:
@skipUnlessTestfile @skipUnlessTestfile
def test_syncedlyrics_delete(self): def test_syncedlyrics_delete(self):
metadata = Metadata({'syncedlyrics': '[00:00.00]foo3'}) metadata = Metadata({'syncedlyrics': '[00:00.000]<00:00.000>foo1'})
metadata.delete('syncedlyrics:eng') metadata.delete('syncedlyrics:eng')
save_metadata(self.filename, metadata) save_metadata(self.filename, metadata)
raw_metadata = load_raw(self.filename) raw_metadata = load_raw(self.filename)
@@ -777,31 +778,35 @@ class ID3FileTest(PicardTestCase):
def test_syncedlyrics_converting_to_lrc(self): def test_syncedlyrics_converting_to_lrc(self):
sylt = ( sylt = (
[("first", 0), ("bar\n", 500), ("second", 1000), ("bar", 1500)], [("Test", 0), ("normal\n", 500), ("behaviour", 1000)],
[("test", 0), ("syl", 10), ("la", 20), ("bles", 30)], [("Test", 0), ("syl", 10), ("la", 20), ("bles", 30)],
[("test lyrics with\n", 0), ("only line time stamps", 5000)]) [("Test newline\nin the middle", 0), ("of the text", 1000)],
[("Test empty lyrics at the end\n", 0), ("", 1000)],
[("Test timestamp estimation", 0), ("in the\nlast phrase", 1000)])
correct_lrc = ( correct_lrc = (
"[00:00.00]first<00:00.50>bar\n[00:01.00]second<00:01.50>bar", "[00:00.000]<00:00.000>Test<00:00.500>normal\n[00:00.750]<00:01.000>behaviour",
"[00:00.00]test<00:00.01>syl<00:00.02>la<00:00.03>bles", "[00:00.000]<00:00.000>Test<00:00.010>syl<00:00.020>la<00:00.030>bles",
"[00:00.00]test lyrics with\n[00:05.00]only line time stamps") "[00:00.000]<00:00.000>Test newline\n[00:00.500]in the middle<00:01.000>of the text",
for sylt, correct_sylt in zip(sylt, correct_lrc): "[00:00.000]<00:00.000>Test empty lyrics at the end\n[00:00.500]<00:01.000>",
sylt = self.file._parse_sylt_text(sylt) "[00:00.000]<00:00.000>Test timestamp estimation<00:01.000>in the\n[00:01.000]last phrase")
self.assertEqual(sylt, correct_sylt) for sylt, correct_lrc in zip(sylt, correct_lrc):
lrc = self.file._parse_sylt_text(sylt)
self.assertEqual(lrc, correct_lrc)
def test_syncedlyrics_converting_to_sylt(self): def test_syncedlyrics_converting_to_sylt(self):
lrc = ( lrc = (
"[00:00.00]first<00:00.50>bar\n[00:01.00]second<00:01.50>bar", "[00:00.000]<00:00.000>Test<00:00.500>normal\n[00:00.750]<00:01.000>behaviour",
"[00:00.00]test lyrics with\n[01:00.00]only line time stamps", "[00:00.000]Test lyrics with\n[01:00.000]only line time stamps",
"[00:00.00]test lyrics with no[00:01.00]new lines", "<00:00.000>Test lyrics with<01:00.000>only syllable time stamps",
"first invalid[00:00.00]input\nsecond invalid[00:01.00]input", "[00:00.000]<00:00.000>Test extra<00:00.500>\n[00:00.750]<00:00.750>timestamp<00:01.500>",
"[00:02.00]test out of order[00:01.00]timestamps", "Test invalid[00:00.000]input\nTest invalid[00:01.000]input",
"Test lyrics with no timestamps") "Test lyrics with no timestamps")
correct_sylt = ( correct_sylt = (
[("first", 0), ("bar\n", 500), ("second", 1000), ("bar", 1500)], [("Test", 0), ("normal\n", 500), ("behaviour", 1000)],
[("test lyrics with\n", 0), ("only line time stamps", 60 * 1000)], [("Test lyrics with\n", 0), ("only line time stamps", 60 * 1000)],
[("test lyrics with no", 0), ("new lines", 1000)], [("Test lyrics with", 0), ("only syllable time stamps", 60 * 1000)],
[("input\nsecond invalid", 0), ("input", 1000)], [("Test extra\n", 0), ("timestamp", 750)],
[("test out of order", 2000), ("timestamps", 1000)], [("input\nTest invalid", 0), ("input", 1000)],
[]) [])
for lrc, correct_sylt in zip(lrc, correct_sylt): for lrc, correct_sylt in zip(lrc, correct_sylt):
sylt = self.file._parse_lrc_text(lrc) sylt = self.file._parse_lrc_text(lrc)

View File

@@ -25,7 +25,7 @@ from test.picardtestcase import PicardTestCase
from picard.util.tags import ( from picard.util.tags import (
display_tag_name, display_tag_name,
parse_comment_tag, parse_comment_tag,
parse_lyrics_tag, parse_subtag,
) )
@@ -42,7 +42,7 @@ class UtilTagsTest(PicardTestCase):
self.assertEqual(('eng', ''), parse_comment_tag('comment')) self.assertEqual(('eng', ''), parse_comment_tag('comment'))
def test_parse_lyrics_tag(self): def test_parse_lyrics_tag(self):
self.assertEqual(('eng', ''), parse_lyrics_tag('lyrics')) self.assertEqual(('eng', ''), parse_subtag('lyrics'))
self.assertEqual(('XXX', 'foo'), parse_lyrics_tag('lyrics:XXX:foo')) self.assertEqual(('XXX', 'foo'), parse_subtag('lyrics:XXX:foo'))
self.assertEqual(('XXX', ''), parse_lyrics_tag('lyrics:XXX')) self.assertEqual(('XXX', ''), parse_subtag('lyrics:XXX'))
self.assertEqual(('eng', 'foo'), parse_lyrics_tag('lyrics::foo')) self.assertEqual(('eng', 'foo'), parse_subtag('lyrics::foo'))