feat(lyrics): support multiple vocalists and aligned lyrics

This commit is contained in:
zarzet committed 2026-09-26 14:50:05 +07:00
1 parent fc41572417
commit 95e0e3197d
13 files changed
+642 -66

No files matched your search

@@ -44,6 +44,38 @@ LyricPronunciationLayout? _layout(
);
void main() {
testWidgets('right-aligned voices move original and pronunciation together', (
tester,
) async {
Future<void> pump(TextAlign alignment) => tester.pumpWidget(
MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: AlignedLyricPronunciation(
layout: _layout(_line(), width: 400)!,
visibility: 1,
textAlign: alignment,
primaryStyle: _primaryStyle,
pronunciationStyle: _pronunciationStyle,
textBuilder: (text, words, style) => Text(text, style: style),
),
),
),
),
);
await pump(TextAlign.left);
final original = tester.getTopLeft(find.text('今日'));
final reading = tester.getTopLeft(find.text('kyou'));
await pump(TextAlign.right);
final shift = tester.getTopLeft(find.text('今日')) - original;
expect(shift.dx, greaterThan(0));
expect(shift.dy, 0);
expect(tester.getTopLeft(find.text('kyou')) - reading, shift);
expect(tester.takeException(), isNull);
});
test('does not guess alignment without matching word timing', () {
expect(_layout(_line(pronunciationOffset: 100)), isNull);
expect(
+3 -1
View File
@@ -45,11 +45,13 @@ void main() {
[00:01.25]Lead line
[bg:Background line]
[00:02:500]<00:02.500>v2: Harmony line
[00:03.00]v3:Third voice
[00:04.00]V12:Twelfth voice
''';
expect(
cleanLyricsForDisplay(raw),
'Lead line\nBackground line\nHarmony line',
'Lead line\nBackground line\nHarmony line\nThird voice\nTwelfth voice',
);
expect(hasUsableLyricsContent(raw), isTrue);
});
+98
View File
@@ -7,6 +7,104 @@ String _tag(String kind, int time, String text) =>
'[x-$kind:$time:${base64.encode(utf8.encode(text))}]';
void main() {
test('all eLRC voices retain timing and supplements without visible IDs', () {
final lyrics = LyricsParser.parse('''
[offset:100]
${_tag('romaji', 2000, 'Second reading')}
${_tag('translation', 2000, 'Second translation')}
[00:01.00]v1:<00:01.00>First<00:03.00>
[00:02.00]<00:02.00> V2: Second<00:04.00>
[00:04.00]v3:Third
[00:06.00]v12:Twelfth
[00:08.00]The v2: label stays inside a sentence
''');
expect(lyrics.lines.map((line) => line.voice?.id), [
'v1',
'v2',
'v3',
'v12',
null,
]);
expect(lyrics.lines.map((line) => line.voice?.index), [0, 1, 2, 11, null]);
final second = lyrics.lines[1];
expect(second.text, 'Second');
expect(second.words.single.text, 'Second');
expect(second.words.single.time.inMilliseconds, 1900);
expect(second.end?.inMilliseconds, 3900);
expect(second.romanization, 'Second reading');
expect(second.translation, 'Second translation');
expect(lyrics.lines.last.text, 'The v2: label stays inside a sentence');
});
test('background eLRC retains the parent voice and its own word times', () {
final lyrics = LyricsParser.parse('''
[00:01.00]v2:<00:01.00>Main<00:05.00>
[bg:<00:02.00>Backing<00:04.00>]
''');
expect(lyrics.lines.map((line) => line.text), ['Main', 'Backing']);
final backing = lyrics.lines.last;
expect(backing.voice?.id, 'v2');
expect(backing.isBackground, isTrue);
expect(backing.time.inMilliseconds, 2000);
expect(backing.end?.inMilliseconds, 4000);
});
test('TTML resolves inherited voices and explicit groups by namespace', () {
final lyrics = LyricsParser.parse('''
<t:tt xmlns:t="http://www.w3.org/ns/ttml" xmlns:m="http://www.w3.org/ns/ttml#metadata">
<t:head><t:metadata>
<m:agent xml:id="lead" type="person"/>
<m:agent xml:id="guest" type="person"/>
<m:agent xml:id="v3" type="group"/>
<m:agent xml:id="third" type="person"/>
</t:metadata></t:head>
<t:body><t:div m:agent="lead">
<t:p begin="1s" end="3s">Lead</t:p>
<t:p begin="3s" end="5s" m:agent="guest">Guest</t:p>
<t:p begin="5s" end="7s" m:agent="v3">Together</t:p>
<t:p begin="7s" end="9s" m:agent="third">Third</t:p>
</t:div></t:body></t:tt>
''');
expect(lyrics.lines.map((line) => line.voice?.id), [
'lead',
'guest',
'v3',
'third',
]);
expect(lyrics.lines.map((line) => line.voice?.index), [0, 1, 2, 2]);
expect(lyrics.lines.map((line) => line.voice?.isGroup), [
false,
false,
true,
false,
]);
});
test(
'TTML splits span voices and backing parts without adding syllable gaps',
() {
final lyrics = LyricsParser.parse('''
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:m="http://www.w3.org/ns/ttml#metadata">
<head><metadata><m:agent xml:id="v1" type="person"/><m:agent xml:id="v2" type="person"/></metadata></head>
<body><div><p begin="1s" end="5s"><span m:agent="v1"><span begin="1s" end="2s">日本</span><span begin="2s" end="3s">語</span></span><span m:agent="v2" begin="2s" end="4s">Reply</span><span m:agent="v1" m:role="x-bg" begin="2s" end="3s">Echo</span></p></div></body></tt>
''');
expect(lyrics.lines.map((line) => line.text), ['日本語', 'Reply', 'Echo']);
expect(lyrics.lines.first.words.map((word) => word.text), ['日本', '語']);
expect(lyrics.lines.map((line) => line.time.inSeconds), [1, 2, 2]);
expect(lyrics.lines.map((line) => line.end?.inSeconds), [3, 4, 3]);
expect(lyrics.lines.last.isBackground, isTrue);
expect(lyrics.lines.last.voice?.id, 'v1');
},
);
test('TTML partial word timing never loses untimed text', () {
final line = LyricsParser.parse('''
<tt><body><p begin="1s" end="5s">Untimed <span begin="2s" end="3s">timed</span> ending</p></body></tt>
''').lines.single;
expect(line.text, 'Untimed timed ending');
expect(line.words, isEmpty);
});
test(
'writer tags and stored provider attribution remain separate from lyric rows',
() {
+40
View File
@@ -3,6 +3,46 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/lyrics_timeline.dart';
void main() {
test(
'backing vocals do not dim an unlabelled lead that is still singing',
() {
final lines = LyricsParser.parse('''
[00:01.00]<00:01.00>Main<00:05.00>
[bg:<00:02.00>Echo<00:03.00>]
''').lines;
expect(activeLyricIndices(lines, const Duration(seconds: 2), 1), {0, 1});
expect(activeLyricIndices(lines, const Duration(seconds: 4), 1), {0});
},
);
test(
'overlapping singers stay active until their ends, including seek back',
() {
final lines = LyricsParser.parse('''
[00:01.00]v1:<00:01.00>Lead<00:05.00>
[00:02.00]v2:<00:02.00>Guest<00:04.00>
[00:06.00]v3:Third
''').lines;
Set<int> active(int seconds) {
final position = Duration(seconds: seconds);
return activeLyricIndices(
lines,
position,
LyricsParser.activeIndex(lines, position),
);
}
expect(active(0), isEmpty);
expect(active(1), {0});
expect(active(3), {0, 1});
expect(active(4), {0});
expect(active(5), isEmpty);
expect(active(6), {2});
expect(active(3), {0, 1});
expect(active(1), {0});
},
);
List<(int, int)> gaps(String text) =>
lyricsTimelineWithGaps(LyricsParser.parse(text).lines)
.where((line) => line.text.isEmpty)
@@ -2491,6 +2491,124 @@ void main() {
}
for (final mornye in [false, true]) {
testWidgets(
'singers keep their side before and during overlapping vocals ($mornye)',
(tester) async {
metadataOverrides['lyrics'] =
'[x-romaji:2000:${base64.encode(utf8.encode('Guest reading'))}]\n'
'[x-translation:2000:${base64.encode(utf8.encode('Guest translation'))}]\n'
'[00:01.00]v1:<00:01.00>Lead<00:08.00>\n'
'[00:02.00]v2:<00:02.00>Guest<00:06.00>';
final playback = StreamController<PlaybackState>.broadcast();
addTearDown(playback.close);
await pumpNowPlaying(
tester,
theme: mornye ? MornyeTheme.build(Brightness.dark) : null,
size: const Size(390, 1100),
playbackEvents: playback.stream,
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
if (mornye) {
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
} else {
await tester.drag(find.byType(PageView), const Offset(-350, 0));
}
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.text('Lead')).textAlign,
TextAlign.left,
);
for (final text in ['Guest', 'Guest reading', 'Guest translation']) {
expect(
tester.widget<Text>(find.text(text)).textAlign,
TextAlign.right,
);
}
expect(find.textContaining('v1:'), findsNothing);
expect(find.textContaining('v2:'), findsNothing);
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
playing: false,
updatePosition: const Duration(seconds: 3),
),
);
await tester.pumpAndSettle();
for (final text in ['Lead', 'Guest']) {
final paint = find.descendant(
of: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == text,
),
matching: find.byType(CustomPaint),
);
expect(
paint,
findsOneWidget,
reason: 'Both overlapping parts stay highlighted',
);
final painter = tester.widget<CustomPaint>(paint).painter!;
final size = tester.getSize(paint);
final bounds = await tester.runAsync(() async {
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), size);
final picture = recorder.endRecording();
final image = await picture.toImage(
size.width.ceil(),
size.height.ceil(),
);
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
var left = image.width;
var right = 0;
for (var y = 0; y < image.height; y++) {
for (var x = 0; x < image.width; x++) {
if (bytes.getUint8((y * image.width + x) * 4 + 3) > 10) {
if (x < left) left = x;
if (x > right) right = x;
}
}
}
image.dispose();
picture.dispose();
return (left, right);
});
if (text == 'Lead') {
expect(bounds!.$1, lessThan(5));
} else {
expect(bounds!.$2, greaterThan(size.width - 5));
}
}
expect(
tester.widget<Text>(find.text('Guest translation')).textAlign,
TextAlign.right,
);
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
playing: false,
updatePosition: const Duration(seconds: 7),
),
);
await tester.pumpAndSettle();
expect(find.text('Guest'), findsOneWidget);
expect(find.text('Lead'), findsNothing);
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
playing: false,
updatePosition: const Duration(seconds: 9),
),
);
await tester.pumpAndSettle();
expect(find.text('Lead'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets('player shows original, romanization and English ($mornye)', (
tester,
) async {