mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
feat(lyrics): support multiple vocalists and aligned lyrics
This commit is contained in:
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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user