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

This commit is contained in:
zarzet
2026-09-27 04:01:43 +07:00
parent 443a9a2bdf
commit fedf5f137a
6 changed files with 346 additions and 39 deletions
+25 -1
View File
@@ -33,6 +33,9 @@ class LyricLine {
final LyricVoice? voice;
final bool isBackground;
/// Links backing vocals to their lead even when another singer overlaps.
final int? vocalGroup;
const LyricLine({
required this.time,
this.end,
@@ -43,6 +46,7 @@ class LyricLine {
this.translation,
this.voice,
this.isBackground = false,
this.vocalGroup,
});
bool get hasWordTiming => words.isNotEmpty;
@@ -286,6 +290,7 @@ class LyricsParser {
words: words,
voice: voice ?? previous.voice,
isBackground: true,
vocalGroup: previous.vocalGroup,
),
);
sawWordTiming |= words.isNotEmpty;
@@ -315,8 +320,12 @@ class LyricsParser {
end: words.lastOrNull?.end,
text: cleanContent,
words: words,
voice: voice,
voice:
voice ?? (background != null ? parsed.lastOrNull?.voice : null),
isBackground: background != null,
vocalGroup: background != null
? parsed.lastOrNull?.vocalGroup
: parsed.length,
),
);
}
@@ -362,6 +371,7 @@ class LyricsParser {
text: l.text,
voice: l.voice,
isBackground: l.isBackground,
vocalGroup: l.vocalGroup,
romanization: romanization,
romanizationWords: _shiftWords(validWords, offsetMs),
translation: alignedTranslation[l.time.inMilliseconds],
@@ -535,6 +545,7 @@ class LyricsParser {
final lines = <LyricLine>[];
final plain = <String>[];
var sawWords = false;
var nextVocalGroup = 0;
for (final p in paragraphs) {
final begin = _parseClock(p.getAttribute('begin'));
@@ -593,6 +604,10 @@ class LyricsParser {
}
visit(p, inheritedVoice, false, null, null);
final leadGroups = {
for (final run in runs)
if (!run.background && run.text.isNotEmpty) run: nextVocalGroup++,
};
for (final run in runs) {
final lineText = run.text;
if (lineText.isEmpty) continue;
@@ -603,6 +618,12 @@ class LyricsParser {
: words.firstOrNull?.time ?? begin;
if (lineBegin == null) continue;
sawWords |= words.isNotEmpty;
final lead = run.background
? leadGroups.keys
.where((lead) => lead.voice?.id == run.voice?.id)
.firstOrNull ??
leadGroups.keys.firstOrNull
: run;
lines.add(
LyricLine(
time: lineBegin,
@@ -611,6 +632,9 @@ class LyricsParser {
words: words,
voice: run.voice,
isBackground: run.background,
vocalGroup:
leadGroups[lead] ??
(run.background ? lines.lastOrNull?.vocalGroup : null),
),
);
}
+52
View File
@@ -1,5 +1,57 @@
import 'package:spotiflac_android/utils/lyrics_parser.dart';
/// Visual order is separate from the chronological timeline: a backing part
/// stays directly under its lead, including when another singer starts first.
class LyricDisplayLayout {
final List<int> lineOrder;
final List<int> rowForLine;
final List<int> leadForLine;
final List<int> focusForLine;
LyricDisplayLayout(List<LyricLine> lines)
: lineOrder = [],
rowForLine = List.filled(lines.length, 0),
leadForLine = List.generate(lines.length, (index) => index),
focusForLine = List.filled(lines.length, 0) {
final leads = {
for (var i = 0; i < lines.length; i++)
if (!lines[i].isBackground && lines[i].vocalGroup != null)
lines[i].vocalGroup!: i,
};
final backing = <int, List<int>>{};
int? previousLead;
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (!line.isBackground) {
if (line.text.isNotEmpty) previousLead = i;
continue;
}
final lead = line.vocalGroup == null
? previousLead
: leads[line.vocalGroup];
if (lead == null) continue;
leadForLine[i] = lead;
backing.putIfAbsent(lead, () => []).add(i);
}
for (var i = 0; i < lines.length; i++) {
if (leadForLine[i] != i) continue;
lineOrder.add(i);
lineOrder.addAll(backing[i] ?? const []);
}
for (var row = 0; row < lineOrder.length; row++) {
rowForLine[lineOrder[row]] = row;
}
var latestLead = 0;
for (var i = 0; i < lines.length; i++) {
if (!lines[i].isBackground) latestLead = i;
// A delayed echo must not scroll backwards after the next lead starts.
focusForLine[i] = leadForLine[i] > latestLead
? leadForLine[i]
: latestLead;
}
}
}
/// Keep overlapping vocal parts lit until their explicit end. Untimed lines
/// retain the normal single-line behavior instead of guessing a vocal length.
Set<int> activeLyricIndices(