feat(lyrics): group smaller backing vocals beneath their lead

This commit is contained in:
zarzet committed 2026-09-27 04:01:43 +07:00
1 parent 443a9a2bdf
commit fedf5f137a
6 files changed
+346 -39

No files matched your search

+30
View File
@@ -47,8 +47,36 @@ ${_tag('translation', 2000, 'Second translation')}
expect(backing.isBackground, isTrue);
expect(backing.time.inMilliseconds, 2000);
expect(backing.end?.inMilliseconds, 4000);
expect(backing.vocalGroup, lyrics.lines.first.vocalGroup);
});
test(
'backing groups survive sorting, offsets and repeated backing parts',
() {
final lyrics = LyricsParser.parse('''
[offset:100]
[00:01.00]v2:<00:01.00>Lead<00:06.00>
[bg:<00:04.00>Echo<00:05.00>]
[bg:[00:05.00]<00:05.00>Again<00:06.00>]
[00:03.00]v1:<00:03.00>Other lead<00:06.00>
''');
expect(lyrics.lines.map((line) => line.text), [
'Lead',
'Other lead',
'Echo',
'Again',
]);
final lead = lyrics.lines.first;
expect(lyrics.lines[1].vocalGroup, isNot(lead.vocalGroup));
for (final backing in lyrics.lines.skip(2)) {
expect(backing.vocalGroup, lead.vocalGroup);
expect(backing.voice?.id, 'v2');
expect(backing.isBackground, isTrue);
}
expect(lyrics.lines[2].time.inMilliseconds, 3900);
},
);
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">
@@ -94,6 +122,8 @@ ${_tag('translation', 2000, 'Second translation')}
expect(lyrics.lines.map((line) => line.end?.inSeconds), [3, 4, 3]);
expect(lyrics.lines.last.isBackground, isTrue);
expect(lyrics.lines.last.voice?.id, 'v1');
expect(lyrics.lines.last.vocalGroup, lyrics.lines.first.vocalGroup);
expect(lyrics.lines[1].vocalGroup, isNot(lyrics.lines.first.vocalGroup));
},
);
+55
View File
@@ -3,6 +3,61 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/lyrics_timeline.dart';
void main() {
test('backing stays under its lead without changing the timing order', () {
final lines = LyricsParser.parse('''
[00:01.00]v1:<00:01.00>Lead<00:06.00>
[bg:<00:04.00>Echo<00:06.00>]
[bg:<00:05.00>Again<00:06.00>]
[00:03.00]v2:<00:03.00>Guest<00:06.00>
''').lines;
final layout = LyricDisplayLayout(lines);
expect(lines.map((line) => line.text), ['Lead', 'Guest', 'Echo', 'Again']);
expect(layout.lineOrder.map((i) => lines[i].text), [
'Lead',
'Echo',
'Again',
'Guest',
]);
expect(layout.rowForLine, [0, 3, 1, 2]);
expect(layout.leadForLine, [0, 1, 0, 0]);
expect(layout.focusForLine, [0, 1, 1, 1]);
expect(activeLyricIndices(lines, const Duration(seconds: 5), 3), {
0,
1,
2,
3,
});
});
test('background onset keeps focus on the lead and gaps remain separate', () {
final lines = lyricsTimelineWithGaps(
LyricsParser.parse('''
[00:05.00]v2:<00:05.00>Lead<00:10.00>
[bg:<00:06.00>Echo<00:11.00>]
[00:15.00]Next
''').lines,
);
final layout = LyricDisplayLayout(lines);
expect(lines.map((line) => line.text), ['', 'Lead', 'Echo', '', 'Next']);
expect(layout.lineOrder, [0, 1, 2, 3, 4]);
expect(layout.focusForLine, [0, 1, 1, 3, 4]);
expect(lines[3].time, const Duration(seconds: 11));
});
test(
'a backing pickup can start before the lead without losing its parent',
() {
final lines = LyricsParser.parse('''
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:m="http://www.w3.org/ns/ttml#metadata">
<body><p begin="1s" end="6s" m:agent="v2"><span m:role="x-bg" begin="1s" end="5s">Pickup</span><span begin="2s" end="6s">Lead</span></p></body></tt>
''').lines;
final layout = LyricDisplayLayout(lines);
expect(lines.map((line) => line.text), ['Pickup', 'Lead']);
expect(layout.lineOrder, [1, 0]);
expect(layout.focusForLine, [1, 1]);
},
);
test(
'backing vocals do not dim an unlabelled lead that is still singing',
() {
@@ -2593,6 +2593,99 @@ void main() {
}
for (final mornye in [false, true]) {
for (final ttml in [false, true]) {
testWidgets(
'backing vocals stay smaller under the lead with independent timing (Mornye: $mornye, TTML: $ttml)',
(tester) async {
metadataOverrides['lyrics'] = ttml
? '''
<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><p begin="1s" end="7s" m:agent="v2"><span begin="1s" end="6s">Lead</span><span m:role="x-bg" m:agent="v1" begin="3s" end="7s">Echo</span></p><p begin="8s" end="10s">Next</p></body></tt>
'''
: '''
[00:01.00]v2:<00:01.00>Lead<00:06.00>
[bg:v1:<00:03.00>Echo<00:07.00>]
[00:08.00]<00:08.00>Next<00:10.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();
final lead = tester.widget<Text>(find.text('Lead'));
final backing = tester.widget<Text>(find.text('Echo'));
expect(
backing.style!.fontSize,
lessThan(lead.style!.fontSize! * 0.85),
);
expect(backing.textAlign, TextAlign.right);
if (mornye) {
expect(
tester.getTopLeft(find.text('Echo')).dy -
tester.getBottomLeft(find.text('Lead')).dy,
closeTo(6, 0.1),
);
}
expect(find.textContaining('[bg:'), findsNothing);
expect(find.textContaining('v1:'), findsNothing);
Finder timed(String text) => find.descendant(
of: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == text,
),
matching: find.byType(CustomPaint),
);
Future<void> seek(int milliseconds) async {
playback.add(
PlaybackState(
playing: false,
processingState: AudioProcessingState.ready,
updatePosition: Duration(milliseconds: milliseconds),
),
);
await tester.pumpAndSettle();
}
double offset() =>
tester.widget<ListView>(find.byType(ListView)).controller!.offset;
await seek(2000);
expect(timed('Lead'), findsOneWidget);
expect(timed('Echo'), findsNothing);
final leadOffset = offset();
await seek(4000);
expect(timed('Lead'), findsOneWidget);
expect(timed('Echo'), findsOneWidget);
expect(offset(), closeTo(leadOffset, 0.1));
expect(
tester.getSize(timed('Echo')).height,
lessThan(tester.getSize(timed('Lead')).height * 0.8),
);
await seek(6500);
expect(timed('Lead'), findsNothing);
expect(timed('Echo'), findsOneWidget);
expect(offset(), closeTo(leadOffset, 0.1));
await seek(2000);
expect(timed('Lead'), findsOneWidget);
expect(timed('Echo'), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
testWidgets(
'singers keep their side before and during overlapping vocals ($mornye)',
(tester) async {