From fedf5f137a44e8093a499671f615bf573515c15d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:01:43 +0700 Subject: [PATCH] feat(lyrics): group smaller backing vocals beneath their lead --- lib/screens/now_playing_screen.dart | 129 +++++++++++++------ lib/utils/lyrics_parser.dart | 26 +++- lib/utils/lyrics_timeline.dart | 52 ++++++++ test/lyrics_parser_test.dart | 30 +++++ test/lyrics_timeline_test.dart | 55 ++++++++ test/now_playing_lyrics_transition_test.dart | 93 +++++++++++++ 6 files changed, 346 insertions(+), 39 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 036e4f43..7e10a39e 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -2903,6 +2903,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { Timer? _lineBoundaryTimer; Timer? _userScrollIdleTimer; late List _lines; + late LyricDisplayLayout _displayLayout; late List _lineKeys; int _active = -1; Set _activeLines = {}; @@ -2968,6 +2969,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _lineExtents = null; _lineLayoutKey = null; _lines = lyricsTimelineWithGaps(widget.lyrics.lines); + _displayLayout = LyricDisplayLayout(_lines); _lineKeys = List.generate( _lines.length, (index) => GlobalKey(debugLabel: 'lyric-line-$index'), @@ -3067,6 +3069,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { if (!mounted) return; final activeLines = activeLyricIndices(_lines, position, active); final indexChanged = active != _active; + final previousFocus = _active < 0 + ? -1 + : _displayLayout.focusForLine[_active]; if (!indexChanged && activeLines.length == _activeLines.length && activeLines.containsAll(_activeLines)) { @@ -3077,7 +3082,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _activeLines = activeLines; _activeTransitionPosition = position; }); - if (!indexChanged) return; + if (!indexChanged || + (active >= 0 && _displayLayout.focusForLine[active] == previousFocus)) { + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && active == _active) unawaited(_maybeAutoScroll(active)); }); @@ -3136,11 +3144,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } void _measureMornyeLines(double width) { - final style = Theme.of(context).textTheme.headlineSmall?.copyWith( - height: 1.3, - fontSize: _mornyeLyricFontSize, - fontWeight: FontWeight.bold, - ); + final style = _mornyeLyricStyle(context); final scaler = MediaQuery.textScalerOf(context); final direction = Directionality.of(context); final locale = Localizations.maybeLocaleOf(context); @@ -3153,16 +3157,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { locale: locale, ); final measurements = <(double, double, double)>[]; - final layouts = []; - for (final line in _lines) { + final layouts = List.filled(_lines.length, null); + for (final index in _displayLayout.lineOrder) { + final line = _lines[index]; if (line.text.isEmpty) { measurements.add((56, 0, 0)); - layouts.add(null); continue; } - painter.text = TextSpan(text: line.text, style: style); + painter.text = TextSpan( + text: line.text, + style: _mornyeLyricStyle(context, background: line.isBackground), + ); painter.layout(maxWidth: width); - var height = painter.height + 32; + final padding = _linePadding(index).vertical; + var height = painter.height + padding; var pronunciationHeight = 0.0; var translationHeight = 0.0; LyricPronunciationLayout? pronunciationLayout; @@ -3178,7 +3186,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { pronunciationHeight = 6 + painter.height; final aligned = LyricPronunciationLayout.measure( line: line, - primaryStyle: _mornyeLyricStyle(context), + primaryStyle: _mornyeLyricStyle( + context, + background: line.isBackground, + ), pronunciationStyle: style, maxWidth: width, textScaler: scaler, @@ -3187,13 +3198,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ); if (aligned != null) { pronunciationLayout = aligned; - height = aligned.primaryHeight + 32; + height = aligned.primaryHeight + padding; pronunciationHeight = aligned.pronunciationHeight; } } } measurements.add((height, pronunciationHeight, translationHeight)); - layouts.add(pronunciationLayout); + layouts[index] = pronunciationLayout; } _lineMeasurements = measurements; _pronunciationLayouts = layouts; @@ -3204,16 +3215,21 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { // A short intro may have no countdown row. Still return to the first // upcoming lyric when the user scrubs back before any vocals. if (index < 0 && widget.seekPreview.value != null) index = 0; - if (_userScrolling || index < 0 || !_scroll.hasClients) return; + if (_userScrolling || + index < 0 || + index >= _lines.length || + !_scroll.hasClients) { + return; + } + index = _displayLayout.focusForLine[index]; + final row = _displayLayout.rowForLine[index]; final duration = immediate || MediaQuery.disableAnimationsOf(context) ? Duration.zero : Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380); final extents = _lineExtents; - if (context.isMornye && extents != null && index < extents.length) { + if (context.isMornye && extents != null && row < extents.length) { final position = _scroll.position; - final target = extents - .take(index) - .fold(0.0, (sum, extent) => sum + extent); + final target = extents.take(row).fold(0.0, (sum, extent) => sum + extent); final offset = target.clamp( position.minScrollExtent, position.maxScrollExtent, @@ -3245,7 +3261,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { final position = _scroll.position; final target = syncedLyricsEstimatedOffset( - index: index, + index: row, estimatedLineExtent: _estimatedLyricExtent, ); final clamped = target.clamp( @@ -3263,7 +3279,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } if (!mounted || _userScrolling || - index != _active || + (_active >= 0 && index != _displayLayout.focusForLine[_active]) || index >= _lineKeys.length) { return; } @@ -3345,7 +3361,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _layoutVisibility != visibility && previousExtents.length == _lineExtents!.length && _scroll.hasClients) { - var anchor = _active.clamp(0, previousExtents.length); + var anchor = _active < 0 + ? 0 + : _displayLayout.rowForLine[_displayLayout + .focusForLine[_active]]; if (_userScrolling) { anchor = 0; var extent = 0.0; @@ -3398,10 +3417,12 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { : null, padding: EdgeInsets.fromLTRB(24, topPadding, 24, bottomPadding), itemCount: lines.length + (widget.credits == null ? 0 : 1), - itemBuilder: (context, index) { - if (index == lines.length) return widget.credits!; + itemBuilder: (context, row) { + if (row == lines.length) return widget.credits!; + final index = _displayLayout.lineOrder[row]; final line = lines[index]; - final textAlign = _lyricTextAlign(context, line); + final leadIndex = _displayLayout.leadForLine[index]; + final textAlign = _lyricTextAlign(context, lines[leadIndex]); final isActive = _activeLines.contains(index); final isPast = index < active; @@ -3473,7 +3494,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { : Theme.of(context).textTheme.titleLarge) ?.copyWith( height: context.tokens.lyricsLineHeight, - fontSize: mornye ? _mornyeLyricFontSize : null, + fontSize: line.isBackground + ? (mornye ? _mornyeLyricFontSize * 0.68 : 18) + : mornye + ? _mornyeLyricFontSize + : null, fontWeight: mornye || isActive ? FontWeight.bold : FontWeight.w500, @@ -3503,10 +3528,17 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ), ); if (mornye) { - final distance = (index - active).abs(); + final activeLead = active < 0 + ? -1 + : _displayLayout.focusForLine[active]; + final distance = activeLead < 0 + ? row + 1 + : (_displayLayout.rowForLine[leadIndex] - + _displayLayout.rowForLine[activeLead]) + .abs(); // Nearby lines need visible defocus at the larger lyric size; // progressively soften lines further from the current one. - final sigma = !blurLyrics || isActive + final sigma = !blurLyrics || isActive || leadIndex == activeLead ? 0.0 : active < 0 ? 4.8 @@ -3529,9 +3561,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { return Padding( key: _lineKeys[index], - padding: EdgeInsets.symmetric( - vertical: context.tokens.lyricsLinePaddingV, - ), + padding: _linePadding(index), child: GestureDetector( onTap: () => ref.read(musicPlayerControllerProvider).seek(line.time), @@ -3556,6 +3586,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ), ); } + + EdgeInsets _linePadding(int index) { + final row = _displayLayout.rowForLine[index]; + final order = _displayLayout.lineOrder; + final hasBackingBelow = + row + 1 < order.length && + _lines[order[row + 1]].isBackground && + _displayLayout.leadForLine[order[row + 1]] == + _displayLayout.leadForLine[index]; + return EdgeInsets.only( + top: _lines[index].isBackground ? 6 : context.tokens.lyricsLinePaddingV, + bottom: hasBackingBelow ? 0 : context.tokens.lyricsLinePaddingV, + ); + } } class _LyricsCredits extends StatelessWidget { @@ -3608,9 +3652,11 @@ Iterable<(String, TextStyle, List, bool)> _lyricSupplements( yield ( text, base.copyWith( - fontSize: context.isMornye - ? (translation ? 18 : 22) - : (translation ? 14 : 16), + fontSize: + (context.isMornye + ? (translation ? 18.0 : 22.0) + : (translation ? 14.0 : 16.0)) * + (line.isBackground ? 0.8 : 1), height: 1.35, fontWeight: context.isMornye ? (translation ? FontWeight.w600 : FontWeight.bold) @@ -3622,9 +3668,9 @@ Iterable<(String, TextStyle, List, bool)> _lyricSupplements( } } -TextStyle _mornyeLyricStyle(BuildContext context) => +TextStyle _mornyeLyricStyle(BuildContext context, {bool background = false}) => (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()).copyWith( - fontSize: _mornyeLyricFontSize, + fontSize: _mornyeLyricFontSize * (background ? 0.68 : 1), height: context.tokens.lyricsLineHeight, fontWeight: FontWeight.bold, ); @@ -3686,7 +3732,10 @@ Widget _withLyricSupplements( line.romanization?.trim().isNotEmpty != true) { return withSupplements(primary); } - final primaryStyle = _mornyeLyricStyle(context); + final primaryStyle = _mornyeLyricStyle( + context, + background: line.isBackground, + ); final pronunciationStyle = supplements .firstWhere((supplement) => !supplement.$4) .$2; @@ -3900,7 +3949,11 @@ class _WordHighlightedLyricLineState final style = (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) .copyWith( - fontSize: context.isMornye ? _mornyeLyricFontSize : null, + fontSize: widget.line.isBackground + ? (context.isMornye ? _mornyeLyricFontSize * 0.68 : 18) + : context.isMornye + ? _mornyeLyricFontSize + : null, height: context.tokens.lyricsLineHeight, fontWeight: FontWeight.bold, ); diff --git a/lib/utils/lyrics_parser.dart b/lib/utils/lyrics_parser.dart index f1f70c8f..84b2e6d3 100644 --- a/lib/utils/lyrics_parser.dart +++ b/lib/utils/lyrics_parser.dart @@ -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 = []; final plain = []; 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), ), ); } diff --git a/lib/utils/lyrics_timeline.dart b/lib/utils/lyrics_timeline.dart index 339ad913..b6ab7885 100644 --- a/lib/utils/lyrics_timeline.dart +++ b/lib/utils/lyrics_timeline.dart @@ -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 lineOrder; + final List rowForLine; + final List leadForLine; + final List focusForLine; + + LyricDisplayLayout(List 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? 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 activeLyricIndices( diff --git a/test/lyrics_parser_test.dart b/test/lyrics_parser_test.dart index 5ebbd0ad..17d73378 100644 --- a/test/lyrics_parser_test.dart +++ b/test/lyrics_parser_test.dart @@ -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(''' @@ -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)); }, ); diff --git a/test/lyrics_timeline_test.dart b/test/lyrics_timeline_test.dart index 47bf70d8..22fa8f58 100644 --- a/test/lyrics_timeline_test.dart +++ b/test/lyrics_timeline_test.dart @@ -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(''' + +

PickupLead

+''').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', () { diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 1687df91..b67fc291 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -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 + ? ''' + + +

LeadEcho

Next

+''' + : ''' +[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.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(find.text('Lead')); + final backing = tester.widget(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 seek(int milliseconds) async { + playback.add( + PlaybackState( + playing: false, + processingState: AudioProcessingState.ready, + updatePosition: Duration(milliseconds: milliseconds), + ), + ); + await tester.pumpAndSettle(); + } + + double offset() => + tester.widget(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 {