From 2d2e1ea50dc20e098757a89ed97fa227f92ad4e6 Mon Sep 17 00:00:00 2001 From: andy840119 Date: Mon, 10 Jun 2024 11:36:16 +0800 Subject: [PATCH] Copy the kar file format from the lrc folder. Because we need to adjust the lrc file format see: https://github.com/karaoke-dev/LrcParser/issues/40 --- LrcParser.Tests/Parser/Kar/KarParserTest.cs | 775 ++++++++++++++++++ .../Parser/Kar/Lines/KarLyricParserTest.cs | 60 ++ .../Parser/Kar/Lines/KarRubyParserTest.cs | 68 ++ .../Parser/Kar/Utils/KarTimedTextUtilsTest.cs | 43 + .../Parser/Kar/Utils/TimeTagUtilsTest.cs | 26 + LrcParser/Parser/Kar/KarParser.cs | 217 +++++ LrcParser/Parser/Kar/Lines/KarLyricParser.cs | 29 + LrcParser/Parser/Kar/Lines/KarRubyParser.cs | 51 ++ LrcParser/Parser/Kar/Metadata/KarLyric.cs | 19 + LrcParser/Parser/Kar/Metadata/KarRuby.cs | 42 + .../Parser/Kar/Utils/KarTimedTextUtils.cs | 75 ++ LrcParser/Parser/Kar/Utils/TimeTagUtils.cs | 46 ++ 12 files changed, 1451 insertions(+) create mode 100644 LrcParser.Tests/Parser/Kar/KarParserTest.cs create mode 100644 LrcParser.Tests/Parser/Kar/Lines/KarLyricParserTest.cs create mode 100644 LrcParser.Tests/Parser/Kar/Lines/KarRubyParserTest.cs create mode 100644 LrcParser.Tests/Parser/Kar/Utils/KarTimedTextUtilsTest.cs create mode 100644 LrcParser.Tests/Parser/Kar/Utils/TimeTagUtilsTest.cs create mode 100644 LrcParser/Parser/Kar/KarParser.cs create mode 100644 LrcParser/Parser/Kar/Lines/KarLyricParser.cs create mode 100644 LrcParser/Parser/Kar/Lines/KarRubyParser.cs create mode 100644 LrcParser/Parser/Kar/Metadata/KarLyric.cs create mode 100644 LrcParser/Parser/Kar/Metadata/KarRuby.cs create mode 100644 LrcParser/Parser/Kar/Utils/KarTimedTextUtils.cs create mode 100644 LrcParser/Parser/Kar/Utils/TimeTagUtils.cs diff --git a/LrcParser.Tests/Parser/Kar/KarParserTest.cs b/LrcParser.Tests/Parser/Kar/KarParserTest.cs new file mode 100644 index 0000000..44ef746 --- /dev/null +++ b/LrcParser.Tests/Parser/Kar/KarParserTest.cs @@ -0,0 +1,775 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using LrcParser.Model; +using LrcParser.Parser.Kar; +using NUnit.Framework; + +namespace LrcParser.Tests.Parser.Kar; + +public class KarParserTest : BaseLyricParserTest +{ + [Test] + public void TestDecode() + { + var lrcText = new[] + { + "[00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22]", + }; + + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "帰り道は", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 17970 }, + { new TextIndex(1), 18370 }, + { new TextIndex(2), 18550 }, + { new TextIndex(3), 18940 }, + { new TextIndex(3, IndexState.End), 19220 }, + }, + }, + ], + }; + + checkDecode(lrcText, song); + } + + [Test] + public void TestDecodeWithRuby() + { + var lrcText = new[] + { + "[00:01:00]島[00:02:00]島[00:03:00]島[00:04:00]", + "@Ruby1=島,しま,,[00:02:00]", + "@Ruby2=島,じま,[00:02:00],[00:03:00]", + "@Ruby3=島,とう,[00:03:00]", + }; + + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島島島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(1), 2000 }, + { new TextIndex(2), 3000 }, + { new TextIndex(2, IndexState.End), 4000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "しま", + StartCharIndex = 0, + EndCharIndex = 0, + }, + new RubyTag + { + Text = "じま", + StartCharIndex = 1, + EndCharIndex = 1, + }, + new RubyTag + { + Text = "とう", + StartCharIndex = 2, + EndCharIndex = 2, + }, + ], + }, + ], + }; + + checkDecode(lrcText, song); + } + + [Test] + public void TestDecodeWithRubyAndRubyTimeTag() + { + var lrcText = new[] + { + "[00:01:00]島[00:02:00]島[00:03:00]島[00:04:00]", + "@Ruby1=島,し[00:00:50]ま,,[00:02:00]", + "@Ruby2=島,じ[00:00:50]ま,[00:02:00],[00:03:00]", + "@Ruby3=島,と[00:00:50]う,[00:03:00]", + }; + + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島島島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(1), 2000 }, + { new TextIndex(2), 3000 }, + { new TextIndex(2, IndexState.End), 4000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 1500 }, + }, + StartCharIndex = 0, + EndCharIndex = 0, + }, + + new RubyTag + { + Text = "じま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 2500 }, + }, + StartCharIndex = 1, + EndCharIndex = 1, + }, + + new RubyTag + { + Text = "とう", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 3500 }, + }, + StartCharIndex = 2, + EndCharIndex = 2, + }, + ], + }, + ], + }; + + checkDecode(lrcText, song); + } + + [Test] + public void TestDecodeWithSameRubyWithDifferentRubyTimeTag() + { + var lrcText = new[] + { + "[00:01:00]島[00:02:00]島[00:03:00]島[00:04:00]", + "@Ruby1=島,し[00:00:40]ま,,[00:02:00]", + "@Ruby2=島,し[00:00:50]ま,[00:02:00],[00:03:00]", + "@Ruby3=島,し[00:00:60]ま,[00:03:00]", + }; + + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島島島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(1), 2000 }, + { new TextIndex(2), 3000 }, + { new TextIndex(2, IndexState.End), 4000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 1400 }, + }, + StartCharIndex = 0, + EndCharIndex = 0, + }, + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 2500 }, + }, + StartCharIndex = 1, + EndCharIndex = 1, + }, + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 3600 }, + }, + StartCharIndex = 2, + EndCharIndex = 2, + }, + ], + }, + ], + }; + + checkDecode(lrcText, song); + } + + [Test] + public void TestDecodeWithNoTimeRangeRuby() + { + var lrcText = new[] + { + "カラオケ", + "@Ruby1=カ,か", + "@Ruby2=ラ,ら", + "@Ruby3=オ,お", + "@Ruby4=ケ,け", + }; + + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "カラオケ", + RubyTags = + [ + new RubyTag + { + Text = "か", + StartCharIndex = 0, + EndCharIndex = 0, + }, + new RubyTag + { + Text = "ら", + StartCharIndex = 1, + EndCharIndex = 1, + }, + new RubyTag + { + Text = "お", + StartCharIndex = 2, + EndCharIndex = 2, + }, + new RubyTag + { + Text = "け", + StartCharIndex = 3, + EndCharIndex = 3, + }, + ], + }, + ], + }; + + checkDecode(lrcText, song); + } + + [Test] + public void TestDecodeWithRubyInDifferentLine() + { + var lrcText = new[] + { + "[00:01:00]島[00:02:00]", + "[00:03:00]島[00:04:00]", + "[00:05:00]島[00:06:00]", + "@Ruby1=島,しま,,[00:02:00]", + "@Ruby2=島,じま,[00:03:00],[00:04:00]", + "@Ruby3=島,とう,[00:05:00]", + }; + + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(0, IndexState.End), 2000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "しま", + StartCharIndex = 0, + EndCharIndex = 0, + }, + ], + }, + + new Lyric + { + Text = "島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 3000 }, + { new TextIndex(0, IndexState.End), 4000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "じま", + StartCharIndex = 0, + EndCharIndex = 0, + }, + ], + }, + + new Lyric + { + Text = "島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 5000 }, + { new TextIndex(0, IndexState.End), 6000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "とう", + StartCharIndex = 0, + EndCharIndex = 0, + }, + ], + }, + ], + }; + + checkDecode(lrcText, song); + } + + [Test] + public void TestDecodeWithInvalid() + { + // should not generate the ruby if ruby text is same as parent text. + var lrcText = new[] + { + "[00:01:00]島[00:02:00]", + "@Ruby1=島,島", + }; + + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(0, IndexState.End), 2000 }, + }, + }, + ], + }; + + checkDecode(lrcText, song); + } + + [Test] + public void TestEncode() + { + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "帰り道は", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 17970 }, + { new TextIndex(1), 18370 }, + { new TextIndex(2), 18550 }, + { new TextIndex(3), 18940 }, + { new TextIndex(3, IndexState.End), 19220 }, + }, + }, + ], + }; + + var lrcText = new[] + { + "[00:17.97]帰[00:18.37]り[00:18.55]道[00:18.94]は[00:19.22]", + }; + + checkEncode(song, lrcText); + } + + [Test] + public void TestEncodeWithRuby() + { + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島島島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(1), 2000 }, + { new TextIndex(2), 3000 }, + { new TextIndex(2, IndexState.End), 4000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "しま", + StartCharIndex = 0, + EndCharIndex = 0, + }, + new RubyTag + { + Text = "じま", + StartCharIndex = 1, + EndCharIndex = 1, + }, + new RubyTag + { + Text = "とう", + StartCharIndex = 2, + EndCharIndex = 2, + }, + ], + }, + ], + }; + + var lrcText = new[] + { + "[00:01.00]島[00:02.00]島[00:03.00]島[00:04.00]", + "", + "@Ruby1=島,しま,,[00:02.00]", + "@Ruby2=島,じま,[00:02.00],[00:03.00]", + "@Ruby3=島,とう,[00:03.00]", + }; + + checkEncode(song, lrcText); + } + + [Test] + public void TestEncodeWithRubyAndRubyTimeTag() + { + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島島島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(1), 2000 }, + { new TextIndex(2), 3000 }, + { new TextIndex(2, IndexState.End), 4000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 1500 }, + }, + StartCharIndex = 0, + EndCharIndex = 0, + }, + new RubyTag + { + Text = "じま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 2500 }, + }, + StartCharIndex = 1, + EndCharIndex = 1, + }, + new RubyTag + { + Text = "とう", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 3500 }, + }, + StartCharIndex = 2, + EndCharIndex = 2, + }, + ], + }, + ], + }; + + var lrcText = new[] + { + "[00:01.00]島[00:02.00]島[00:03.00]島[00:04.00]", + "", + "@Ruby1=島,し[00:00.50]ま,,[00:02.00]", + "@Ruby2=島,じ[00:00.50]ま,[00:02.00],[00:03.00]", + "@Ruby3=島,と[00:00.50]う,[00:03.00]", + }; + + checkEncode(song, lrcText); + } + + [Test] + public void TestEncodeWithSameRubyWithDifferentRubyTimeTag() + { + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島島島島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(1), 2000 }, + { new TextIndex(2), 3000 }, + { new TextIndex(3), 4000 }, + { new TextIndex(3, IndexState.End), 5000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 1400 }, + }, + StartCharIndex = 0, + EndCharIndex = 0, + }, + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + { new TextIndex(1), 2500 }, + }, + StartCharIndex = 1, + EndCharIndex = 1, + }, + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + // will merge with second time-tag + { new TextIndex(1), 3500 }, + }, + StartCharIndex = 2, + EndCharIndex = 2, + }, + new RubyTag + { + Text = "しま", + TimeTags = new SortedDictionary + { + // although the relative time is same as the first time-tag, but might not be able to merge. + { new TextIndex(1), 4400 }, + }, + StartCharIndex = 3, + EndCharIndex = 3, + }, + ], + }, + ], + }; + + var lrcText = new[] + { + "[00:01.00]島[00:02.00]島[00:03.00]島[00:04.00]島[00:05.00]", + "", + "@Ruby1=島,し[00:00.40]ま,,[00:02.00]", + "@Ruby2=島,し[00:00.50]ま,[00:02.00],[00:04.00]", + "@Ruby3=島,し[00:00.40]ま,[00:04.00]", + }; + + checkEncode(song, lrcText); + } + + [Test] + public void TestEncodeWithNoTimeRangeRuby() + { + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "カラオケ", + RubyTags = + [ + new RubyTag + { + Text = "か", + StartCharIndex = 0, + EndCharIndex = 0, + }, + new RubyTag + { + Text = "ら", + StartCharIndex = 1, + EndCharIndex = 1, + }, + new RubyTag + { + Text = "お", + StartCharIndex = 2, + EndCharIndex = 2, + }, + new RubyTag + { + Text = "け", + StartCharIndex = 3, + EndCharIndex = 3, + }, + ], + }, + ], + }; + + var lrcText = new[] + { + "カラオケ", + "", + "@Ruby1=カ,か", + "@Ruby2=ラ,ら", + "@Ruby3=オ,お", + "@Ruby4=ケ,け", + }; + + checkEncode(song, lrcText); + } + + [Test] + public void TestEncodeWithRubyInDifferentLine() + { + var song = new Song + { + Lyrics = + [ + new Lyric + { + Text = "島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 1000 }, + { new TextIndex(0, IndexState.End), 2000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "しま", + StartCharIndex = 0, + EndCharIndex = 0, + }, + ], + }, + + new Lyric + { + Text = "島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 3000 }, + { new TextIndex(0, IndexState.End), 4000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "じま", + StartCharIndex = 0, + EndCharIndex = 0, + }, + ], + }, + + new Lyric + { + Text = "島", + TimeTags = new SortedDictionary + { + { new TextIndex(0), 5000 }, + { new TextIndex(0, IndexState.End), 6000 }, + }, + RubyTags = + [ + new RubyTag + { + Text = "とう", + StartCharIndex = 0, + EndCharIndex = 0, + }, + ], + }, + ], + }; + + var lrcText = new[] + { + "[00:01.00]島[00:02.00]\n[00:03.00]島[00:04.00]\n[00:05.00]島[00:06.00]", + "", + "@Ruby1=島,しま,,[00:02.00]", + "@Ruby2=島,じま,[00:03.00],[00:04.00]", + "@Ruby3=島,とう,[00:05.00]", + }; + + checkEncode(song, lrcText); + } + + [Test] + public void TestEncodeWithEmptyFile() + { + var song = new Song(); + + var lrcText = new[] + { + "", + }; + + checkEncode(song, lrcText); + } + + private void checkDecode(string[] lrcTexts, Song song) + { + var actual = Decode(string.Join('\n', lrcTexts)); + AreEqual(song, actual); + } + + private void checkEncode(Song song, string[] lrcTexts) + { + var expected = string.Join('\n', lrcTexts); + var actual = Encode(song); + Assert.That(actual, Is.EqualTo(expected)); + } +} diff --git a/LrcParser.Tests/Parser/Kar/Lines/KarLyricParserTest.cs b/LrcParser.Tests/Parser/Kar/Lines/KarLyricParserTest.cs new file mode 100644 index 0000000..f5916bb --- /dev/null +++ b/LrcParser.Tests/Parser/Kar/Lines/KarLyricParserTest.cs @@ -0,0 +1,60 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using LrcParser.Parser.Kar.Lines; +using LrcParser.Parser.Kar.Metadata; +using LrcParser.Tests.Helper; +using LrcParser.Tests.Parser.Lines; +using NUnit.Framework; + +namespace LrcParser.Tests.Parser.Kar.Lines; + +public class KarLyricParserTest : BaseSingleLineParserTest +{ + [TestCase("[00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22]", true)] + [TestCase("karaoke", true)] + [TestCase("", false)] + [TestCase(null, false)] + [TestCase("@Ruby1=帰,かえ", true)] // will take off this if no other parser to process this line. + public void TestCanDecode(string text, bool expected) + { + var actual = CanDecode(text); + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase("[00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22]", "帰り道は", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" })] + [TestCase("帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22]", "帰り道は", new[] { "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" })] + [TestCase("[00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は", "帰り道は", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940" })] + [TestCase("帰り道は", "帰り道は", new string[] { })] + [TestCase("", "", new string[] { })] + [TestCase(null, "", new string[] { })] + public void TestDecode(string lyric, string text, string[] timeTags) + { + var expected = new KarLyric + { + Text = text, + TimeTags = TestCaseTagHelper.ParseTimeTags(timeTags), + }; + var actual = Decode(lyric); + + Assert.That(actual.Text, Is.EqualTo(expected.Text)); + Assert.That(actual.TimeTags, Is.EqualTo(expected.TimeTags)); + } + + [TestCase("帰り道は", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" }, "[00:17.97]帰[00:18.37]り[00:18.55]道[00:18.94]は[00:19.22]")] + [TestCase("帰り道は", new[] { "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" }, "帰[00:18.37]り[00:18.55]道[00:18.94]は[00:19.22]")] + [TestCase("帰り道は", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940" }, "[00:17.97]帰[00:18.37]り[00:18.55]道[00:18.94]は")] + [TestCase("帰り道は", new string[] { }, "帰り道は")] + [TestCase("", new string[] { }, "")] + public void TestEncode(string text, string[] timeTags, string expected) + { + var lyric = new KarLyric + { + Text = text, + TimeTags = TestCaseTagHelper.ParseTimeTags(timeTags), + }; + var actual = Encode(lyric); + + Assert.That(actual, Is.EqualTo(expected)); + } +} diff --git a/LrcParser.Tests/Parser/Kar/Lines/KarRubyParserTest.cs b/LrcParser.Tests/Parser/Kar/Lines/KarRubyParserTest.cs new file mode 100644 index 0000000..bdab6d9 --- /dev/null +++ b/LrcParser.Tests/Parser/Kar/Lines/KarRubyParserTest.cs @@ -0,0 +1,68 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using LrcParser.Parser.Kar.Lines; +using LrcParser.Parser.Kar.Metadata; +using LrcParser.Tests.Helper; +using LrcParser.Tests.Parser.Lines; +using NUnit.Framework; + +namespace LrcParser.Tests.Parser.Kar.Lines; + +public class KarRubyParserTest : BaseSingleLineParserTest +{ + [TestCase("@Ruby1=帰,かえ", true)] + [TestCase("", false)] + [TestCase(null, false)] + [TestCase("[00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22]", false)] + [TestCase("karaoke", false)] + public void TestCanDecode(string text, bool expected) + { + var actual = CanDecode(text); + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase("@Ruby1=帰,かえ,[00:53:19],[01:24:77]", "帰", "かえ", new string[] { }, 53190, 84770)] + [TestCase("@Ruby1=帰,かえ,[01:24:77]", "帰", "かえ", new string[] { }, 84770, null)] + [TestCase("@Ruby1=帰,かえ,,[01:24:77]", "帰", "かえ", new string[] { }, null, 84770)] + [TestCase("@Ruby1=帰,かえ", "帰", "かえ", new string[] { }, null, null)] + [TestCase("@Ruby1=帰,か[00:00:50]え", "帰", "かえ", new[] { "[1,start]:500" }, null, null)] + public void TestDecode(string rubyTag, string parent, string ruby, string[] timeTags, int? startTime, int? endTime) + { + var expected = new KarRuby + { + Parent = parent, + Ruby = ruby, + TimeTags = TestCaseTagHelper.ParseTimeTags(timeTags), + StartTime = startTime, + EndTime = endTime, + }; + var actual = Decode(rubyTag); + + Assert.That(actual.Ruby, Is.EqualTo(expected.Ruby)); + Assert.That(actual.Parent, Is.EqualTo(expected.Parent)); + Assert.That(actual.TimeTags, Is.EqualTo(expected.TimeTags)); + Assert.That(actual.StartTime, Is.EqualTo(expected.StartTime)); + Assert.That(actual.EndTime, Is.EqualTo(expected.EndTime)); + } + + [TestCase("帰", "かえ", new string[] { }, 53190, 84770, "@Ruby1=帰,かえ,[00:53.19],[01:24.77]")] + [TestCase("帰", "かえ", new string[] { }, 84770, null, "@Ruby1=帰,かえ,[01:24.77]")] + [TestCase("帰", "かえ", new string[] { }, null, 84770, "@Ruby1=帰,かえ,,[01:24.77]")] + [TestCase("帰", "かえ", new string[] { }, null, null, "@Ruby1=帰,かえ")] + [TestCase("帰", "かえ", new[] { "[1,start]:500" }, null, null, "@Ruby1=帰,か[00:00.50]え")] + public void TestEncode(string parent, string ruby, string[] timeTags, int? startTime, int? endTime, string expected) + { + var rubyTag = new KarRuby + { + Parent = parent, + Ruby = ruby, + TimeTags = TestCaseTagHelper.ParseTimeTags(timeTags), + StartTime = startTime, + EndTime = endTime, + }; + var actual = Encode(rubyTag); + + Assert.That(actual, Is.EqualTo(expected)); + } +} diff --git a/LrcParser.Tests/Parser/Kar/Utils/KarTimedTextUtilsTest.cs b/LrcParser.Tests/Parser/Kar/Utils/KarTimedTextUtilsTest.cs new file mode 100644 index 0000000..4333005 --- /dev/null +++ b/LrcParser.Tests/Parser/Kar/Utils/KarTimedTextUtilsTest.cs @@ -0,0 +1,43 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using LrcParser.Parser.Kar.Utils; +using LrcParser.Tests.Helper; +using NUnit.Framework; + +namespace LrcParser.Tests.Parser.Kar.Utils; + +public class KarTimedTextUtilsTest +{ + [TestCase("[00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22]", "帰り道は", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" })] + [TestCase(" [00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22]", " 帰り道は", new[] { "[1,start]:17970", "[2,start]:18370", "[3,start]:18550", "[4,start]:18940", "[4,end]:19220" })] + [TestCase("[00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22] ", "帰り道は ", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" })] + [TestCase("帰[00:18:37]り[00:18:55]道[00:18:94]は[00:19:22]", "帰り道は", new[] { "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" })] + [TestCase("[00:17:97]帰[00:18:37]り[00:18:55]道[00:18:94]は", "帰り道は", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940" })] + [TestCase("[00:51.00][01:29.99][01:48.29][02:31.00][02:41.99]You gotta fight !", "You gotta fight !", new[] { "[0,start]:51000" })] // decode with invalid format. + [TestCase("帰り道は", "帰り道は", new string[] { })] + [TestCase("", "", new string[] { })] + [TestCase(null, "", new string[] { })] + public void TestDecode(string text, string expectedText, string[] expectedTimeTags) + { + var (actualText, actualTimeTags) = KarTimedTextUtils.TimedTextToObject(text); + + Assert.That(actualText, Is.EqualTo(expectedText)); + Assert.That(actualTimeTags, Is.EqualTo(TestCaseTagHelper.ParseTimeTags(expectedTimeTags))); + } + + [TestCase("帰り道は", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" }, "[00:17.97]帰[00:18.37]り[00:18.55]道[00:18.94]は[00:19.22]")] + [TestCase(" 帰り道は", new[] { "[1,start]:17970", "[2,start]:18370", "[3,start]:18550", "[4,start]:18940", "[4,end]:19220" }, " [00:17.97]帰[00:18.37]り[00:18.55]道[00:18.94]は[00:19.22]")] + [TestCase("帰り道は ", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" }, "[00:17.97]帰[00:18.37]り[00:18.55]道[00:18.94]は[00:19.22] ")] + [TestCase("帰り道は", new[] { "[1,start]:18370", "[2,start]:18550", "[3,start]:18940", "[3,end]:19220" }, "帰[00:18.37]り[00:18.55]道[00:18.94]は[00:19.22]")] + [TestCase("帰り道は", new[] { "[0,start]:17970", "[1,start]:18370", "[2,start]:18550", "[3,start]:18940" }, "[00:17.97]帰[00:18.37]り[00:18.55]道[00:18.94]は")] + [TestCase("帰り道は", new string[] { }, "帰り道は")] + [TestCase("", new string[] { }, "")] + public void TestEncode(string text, string[] timeTags, string expected) + { + var actual = KarTimedTextUtils.ToTimedText(text, TestCaseTagHelper.ParseTimeTags(timeTags)); + + Assert.That(actual, Is.EqualTo(expected)); + } +} diff --git a/LrcParser.Tests/Parser/Kar/Utils/TimeTagUtilsTest.cs b/LrcParser.Tests/Parser/Kar/Utils/TimeTagUtilsTest.cs new file mode 100644 index 0000000..b26d7aa --- /dev/null +++ b/LrcParser.Tests/Parser/Kar/Utils/TimeTagUtilsTest.cs @@ -0,0 +1,26 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using LrcParser.Parser.Kar.Utils; +using NUnit.Framework; + +namespace LrcParser.Tests.Parser.Kar.Utils; + +public class TimeTagUtilsTest +{ + [TestCase("[00:01:00]", 1000)] + public void TestTimeTagToMillionSecond(string timeTag, int millionSecond) + { + var actual = TimeTagUtils.TimeTagToMillionSecond(timeTag); + + Assert.That(actual, Is.EqualTo(millionSecond)); + } + + [TestCase(1000, "[00:01.00]")] + public void TestTimeTagToMillionSecond(int millionSecond, string timeTag) + { + var actual = TimeTagUtils.MillionSecondToTimeTag(millionSecond); + + Assert.That(actual, Is.EqualTo(timeTag)); + } +} diff --git a/LrcParser/Parser/Kar/KarParser.cs b/LrcParser/Parser/Kar/KarParser.cs new file mode 100644 index 0000000..5002f10 --- /dev/null +++ b/LrcParser/Parser/Kar/KarParser.cs @@ -0,0 +1,217 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Text.RegularExpressions; +using LrcParser.Extension; +using LrcParser.Model; +using LrcParser.Parser.Kar.Lines; +using LrcParser.Parser.Kar.Metadata; + +namespace LrcParser.Parser.Kar; + +/// +/// Parser for encode and decode .kar lyric format +/// +public class KarParser : LyricParser +{ + public KarParser() + { + Register(); + Register(); + } + + protected override Song PostProcess(List values) + { + var lyrics = values.OfType(); + var rubies = values.OfType(); + + return new Song + { + Lyrics = lyrics.Select(l => new Lyric + { + Text = l.Text, + TimeTags = getTimeTags(l.TimeTags), + RubyTags = getRubyTags(rubies, l).ToList(), + }).ToList(), + }; + + static IEnumerable getRubyTags(IEnumerable rubyTags, KarLyric lyric) + { + var text = lyric.Text; + var timeTags = lyric.TimeTags; + + foreach (var rubyTag in rubyTags) + { + if (string.IsNullOrEmpty(rubyTag.Ruby) || string.IsNullOrEmpty(rubyTag.Parent)) + continue; + + if (rubyTag.Ruby == rubyTag.Parent) + continue; + + var hasStartTime = rubyTag.StartTime.HasValue; + var hasEndTime = rubyTag.EndTime.HasValue; + + var matches = new Regex(rubyTag.Parent).Matches(text); + + foreach (var match in matches.ToArray()) + { + var startLyricCharIndex = match.Index; + var endLyricCharIndex = startLyricCharIndex + match.Length - 1; + + var startTimeTag = timeTags.Reverse().LastOrDefault(x => x.Key >= new TextIndex(startLyricCharIndex)); + var endTimeTag = timeTags.FirstOrDefault(x => x.Key >= new TextIndex(endLyricCharIndex, IndexState.End)); + + if (!hasStartTime && !hasEndTime) + { + yield return new RubyTag + { + Text = rubyTag.Ruby, + TimeTags = getTimeTags(rubyTag.TimeTags, startTimeTag.Value), + StartCharIndex = startLyricCharIndex, + EndCharIndex = endLyricCharIndex, + }; + } + else + { + // should not add the ruby if is not in the time-range. + if (hasStartTime && rubyTag.StartTime > startTimeTag.Value) + continue; + + if (hasEndTime && rubyTag.EndTime < endTimeTag.Value) + continue; + + yield return new RubyTag + { + Text = rubyTag.Ruby, + TimeTags = getTimeTags(rubyTag.TimeTags, startTimeTag.Value), + StartCharIndex = convertStartTextIndexToCharIndex(startTimeTag.Key), + EndCharIndex = convertEndTextIndexToCharIndex(endTimeTag.Key), + }; + } + } + } + } + + static SortedDictionary getTimeTags(SortedDictionary timeTags, int offsetTime = 0) + => new(timeTags.ToDictionary(k => k.Key, v => v.Value + offsetTime as int?)); + + static int convertStartTextIndexToCharIndex(TextIndex textIndex) => textIndex.Index; + + static int convertEndTextIndexToCharIndex(TextIndex textIndex) => + textIndex.State switch + { + IndexState.Start => textIndex.Index - 1, + IndexState.End => textIndex.Index, + _ => throw new ArgumentOutOfRangeException(), + }; + } + + protected override IEnumerable PreProcess(Song song) + { + var lyrics = song.Lyrics; + + // first, should return the time-tag first. + foreach (var lyric in lyrics) + { + yield return new KarLyric + { + Text = lyric.Text, + TimeTags = getTimeTags(lyric.TimeTags), + }; + } + + // give it a line if contains ruby. + if (lyrics.Any(l => l.RubyTags.Any())) + yield return new object(); + + // then, export the ruby. + // should group by parent first because merge the ruby should not be affect by those rubies with different ruby. + var rubiesWithSameParent = lyrics.Select(getRubyTags).SelectMany(x => x).GroupBy(x => x.Parent); + + foreach (var groupWithSameParent in rubiesWithSameParent) + { + // should group with continuous ruby. + var rubiesWithSameRuby = groupWithSameParent + .OrderBy(x => x.StartTime).ThenBy(x => x.EndTime) + .GroupByContinuous(x => new RubyGroup + { + Ruby = x.Ruby, + TimeTags = x.TimeTags, + }).ToList(); + + // then, combine those continuous ruby. + foreach (var groupWithSameRuby in rubiesWithSameRuby) + { + var ruby = groupWithSameRuby.Key.Ruby; + var parent = groupWithSameParent.Key; + var timeTags = groupWithSameRuby.Key.TimeTags; + + // should process the value with same parent text and ruby text. + var isFirst = rubiesWithSameRuby.IndexOf(groupWithSameRuby) == 0; + var isLast = rubiesWithSameRuby.IndexOf(groupWithSameRuby) == rubiesWithSameRuby.Count - 1; + + var minStartTime = isFirst ? null : groupWithSameRuby.Min(x => x.StartTime); + var maxEndTime = isLast ? null : groupWithSameRuby.Max(x => x.EndTime); + + yield return new KarRuby + { + Ruby = ruby, + Parent = parent, + TimeTags = timeTags, + StartTime = minStartTime, + EndTime = maxEndTime, + }; + } + } + + yield break; + + static IEnumerable getRubyTags(Lyric lyric) + { + var timeTags = lyric.TimeTags; + + foreach (var rubyTag in lyric.RubyTags) + { + var startLyricCharIndex = rubyTag.StartCharIndex; + var endLyricCharIndex = rubyTag.EndCharIndex; + + var startTimeTag = timeTags.Reverse().LastOrDefault(x => x.Key >= new TextIndex(startLyricCharIndex) && x.Value.HasValue); + var endTimeTag = timeTags.FirstOrDefault(x => x.Key >= new TextIndex(endLyricCharIndex, IndexState.End) && x.Value.HasValue); + + yield return new KarRuby + { + Ruby = rubyTag.Text, + Parent = lyric.Text[startLyricCharIndex..(endLyricCharIndex + 1)], + TimeTags = getTimeTags(rubyTag.TimeTags, -startTimeTag.Value ?? 0), + StartTime = startTimeTag.Value, + EndTime = endTimeTag.Value, + }; + } + } + + static SortedDictionary getTimeTags(SortedDictionary timeTags, int offsetTime = 0) + => new(timeTags.Where(x => x.Value.HasValue).ToDictionary(k => k.Key, v => v.Value!.Value + offsetTime)); + } + + private struct RubyGroup : IEquatable + { + public string Ruby { get; set; } + + public SortedDictionary TimeTags { get; set; } + + public bool Equals(RubyGroup other) + { + return Ruby == other.Ruby && TimeTags.SequenceEqual(other.TimeTags); + } + + public override bool Equals(object? obj) + { + return obj is RubyGroup other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Ruby); + } + } +} diff --git a/LrcParser/Parser/Kar/Lines/KarLyricParser.cs b/LrcParser/Parser/Kar/Lines/KarLyricParser.cs new file mode 100644 index 0000000..4512939 --- /dev/null +++ b/LrcParser/Parser/Kar/Lines/KarLyricParser.cs @@ -0,0 +1,29 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using LrcParser.Parser.Lines; +using LrcParser.Parser.Kar.Metadata; +using LrcParser.Parser.Kar.Utils; + +namespace LrcParser.Parser.Kar.Lines; + +public class KarLyricParser : SingleLineParser +{ + public override bool CanDecode(string text) + => !string.IsNullOrWhiteSpace(text); + + public override KarLyric Decode(string text) + { + var (lyric, timeTags) = KarTimedTextUtils.TimedTextToObject(text); + return new KarLyric + { + Text = lyric, + TimeTags = timeTags, + }; + } + + public override string Encode(KarLyric component, int index) + { + return KarTimedTextUtils.ToTimedText(component.Text, component.TimeTags); + } +} diff --git a/LrcParser/Parser/Kar/Lines/KarRubyParser.cs b/LrcParser/Parser/Kar/Lines/KarRubyParser.cs new file mode 100644 index 0000000..d658be8 --- /dev/null +++ b/LrcParser/Parser/Kar/Lines/KarRubyParser.cs @@ -0,0 +1,51 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Text.RegularExpressions; +using LrcParser.Extension; +using LrcParser.Parser.Lines; +using LrcParser.Parser.Kar.Metadata; +using LrcParser.Parser.Kar.Utils; + +namespace LrcParser.Parser.Kar.Lines; + +public class KarRubyParser : SingleLineParser +{ + public override bool CanDecode(string text) + => !string.IsNullOrEmpty(text) && text.ToLower().StartsWith("@ruby", StringComparison.Ordinal); + + public override KarRuby Decode(string text) + { + var rubyTextRegex = new Regex("@(Ruby|ruby)(?[0-9]+)=(?.*$)"); + + var rubyTagResult = text.Split(','); + var rubyTextResult = rubyTextRegex.Match(rubyTagResult[0]); + + var parent = rubyTextResult.GetGroupValue("text")!; + var (ruby, timeTags) = KarTimedTextUtils.TimedTextToObject(rubyTagResult[1]); + var startTime = string.IsNullOrEmpty(rubyTagResult.ElementAtOrDefault(2)) ? default(int?) : TimeTagUtils.TimeTagToMillionSecond(rubyTagResult[2]); + var endTime = string.IsNullOrEmpty(rubyTagResult.ElementAtOrDefault(3)) ? default(int?) : TimeTagUtils.TimeTagToMillionSecond(rubyTagResult[3]); + + return new KarRuby + { + Parent = parent, + Ruby = ruby, + TimeTags = timeTags, + StartTime = startTime, + EndTime = endTime, + }; + } + + public override string Encode(KarRuby component, int index) + { + var parent = component.Parent; + var ruby = KarTimedTextUtils.ToTimedText(component.Ruby, component.TimeTags); + var startTime = component.StartTime == null ? "" : TimeTagUtils.MillionSecondToTimeTag(component.StartTime.Value); + var endTime = component.EndTime == null ? "" : TimeTagUtils.MillionSecondToTimeTag(component.EndTime.Value); + + var input = $"@Ruby{index + 1}={parent},{ruby},{startTime},{endTime}"; + + const string remove_last_comma_pattern = "([,]*)$"; + return Regex.Replace(input, remove_last_comma_pattern, ""); + } +} diff --git a/LrcParser/Parser/Kar/Metadata/KarLyric.cs b/LrcParser/Parser/Kar/Metadata/KarLyric.cs new file mode 100644 index 0000000..997b4f7 --- /dev/null +++ b/LrcParser/Parser/Kar/Metadata/KarLyric.cs @@ -0,0 +1,19 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using LrcParser.Model; + +namespace LrcParser.Parser.Kar.Metadata; + +public class KarLyric +{ + /// + /// Text + /// + public string Text { get; set; } = ""; + + /// + /// Time tags + /// + public SortedDictionary TimeTags { get; set; } = new(); +} diff --git a/LrcParser/Parser/Kar/Metadata/KarRuby.cs b/LrcParser/Parser/Kar/Metadata/KarRuby.cs new file mode 100644 index 0000000..fcc4470 --- /dev/null +++ b/LrcParser/Parser/Kar/Metadata/KarRuby.cs @@ -0,0 +1,42 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using LrcParser.Model; + +namespace LrcParser.Parser.Kar.Metadata; + +/// +/// Ruby tag +/// +/// +/// @Ruby1=帰,かえ +/// @Ruby25=時,じか,,[00:38:45] +/// @Ruby49=時,とき,[00:38:45],[01:04:49] +/// +public class KarRuby +{ + /// + /// Parent kanji + /// + public string Parent { get; set; } = ""; + + /// + /// Ruby + /// + public string Ruby { get; set; } = ""; + + /// + /// Time tags + /// + public SortedDictionary TimeTags { get; set; } = new(); + + /// + /// Start position + /// + public int? StartTime { get; set; } + + /// + /// End position + /// + public int? EndTime { get; set; } +} diff --git a/LrcParser/Parser/Kar/Utils/KarTimedTextUtils.cs b/LrcParser/Parser/Kar/Utils/KarTimedTextUtils.cs new file mode 100644 index 0000000..78cd695 --- /dev/null +++ b/LrcParser/Parser/Kar/Utils/KarTimedTextUtils.cs @@ -0,0 +1,75 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Text.RegularExpressions; +using LrcParser.Model; +using LrcParser.Utils; + +namespace LrcParser.Parser.Kar.Utils; + +internal static class KarTimedTextUtils +{ + internal static Tuple> TimedTextToObject(string timedText) + { + if (string.IsNullOrEmpty(timedText)) + return new Tuple>("", new SortedDictionary()); + + var timeTagRegex = new Regex(@"\[\d\d:\d\d[:.]\d\d\]"); + var matchTimeTags = timeTagRegex.Matches(timedText); + + var endTextIndex = timedText.Length; + + var startIndex = 0; + + var text = ""; + var timeTags = new SortedDictionary(); + + foreach (var match in matchTimeTags.ToArray()) + { + var endIndex = match.Index; + + if (startIndex < endIndex) + { + // add the text. + text += timedText[startIndex..endIndex]; + } + + // update the new start for next time-tag calculation. + startIndex = endIndex + match.Length; + + // add the time-tag. + var hasText = startIndex < endTextIndex; + var isEmptyStringNext = hasText && timedText[startIndex] == ' '; + + var state = hasText && !isEmptyStringNext ? IndexState.Start : IndexState.End; + var textIndex = text.Length - (state == IndexState.Start ? 0 : 1); + var time = TimeTagUtils.TimeTagToMillionSecond(match.Value); + + // using try add because it might be possible with duplicated time-tag position in the lyric. + timeTags.TryAdd(new TextIndex(textIndex, state), time); + } + + // should add remaining text at the right of the end time-tag. + text += timedText[startIndex..endTextIndex]; + + return new Tuple>(text, timeTags); + } + + internal static string ToTimedText(string text, SortedDictionary timeTags) + { + var insertIndex = 0; + + var timedText = text; + + foreach (var (textIndex, time) in timeTags) + { + var timeTagString = TimeTagUtils.MillionSecondToTimeTag(time); + var stringIndex = TextIndexUtils.ToGapIndex(textIndex); + timedText = timedText.Insert(insertIndex + stringIndex, timeTagString); + + insertIndex += timeTagString.Length; + } + + return timedText; + } +} diff --git a/LrcParser/Parser/Kar/Utils/TimeTagUtils.cs b/LrcParser/Parser/Kar/Utils/TimeTagUtils.cs new file mode 100644 index 0000000..49dbd29 --- /dev/null +++ b/LrcParser/Parser/Kar/Utils/TimeTagUtils.cs @@ -0,0 +1,46 @@ +// Copyright (c) karaoke.dev . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace LrcParser.Parser.Kar.Utils; + +internal static class TimeTagUtils +{ + private const char decimal_point = '.'; + + /// + /// Convert milliseconds to format [mm:ss.ss]. + /// + /// + /// Input : 17970 + /// Output : [00:17:97] + /// + /// + /// + internal static string MillionSecondToTimeTag(int millionSecond) + { + return millionSecond < 0 + ? "" + : string.Format("[{0:D2}:{1:D2}" + decimal_point + "{2:D2}]", millionSecond / 1000 / 60, millionSecond / 1000 % 60, millionSecond / 10 % 100); + } + + /// + /// Convert format [mm:ss.ss] to milliseconds. + /// + /// + /// Input : [00:17:97] + /// Output : 17970 + /// + /// + /// + internal static int TimeTagToMillionSecond(string timeTag) + { + if (timeTag.Length < 10 || timeTag[0] != '[' || !char.IsDigit(timeTag[1])) + return -1; + + int minute = int.Parse(timeTag.Substring(1, 2)); + int second = int.Parse(timeTag.Substring(4, 2)); + int millionSecond = int.Parse(timeTag.Substring(7, 2)); + + return (minute * 60 + second) * 1000 + millionSecond * 10; + } +}