From 65dbce27f53da0bc3a8953445ca076b6993911bb Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:11:27 +0700 Subject: [PATCH] fix(automix): wait for advancing audio before starting fades --- integration_test/automix_analysis_test.dart | 104 ++++++++++++++++++++ lib/services/automix_analysis.dart | 10 +- lib/services/music_player_automix.dart | 87 +++++++++++++--- test/music_player_automix_test.dart | 80 ++++++++++++++- 4 files changed, 261 insertions(+), 20 deletions(-) diff --git a/integration_test/automix_analysis_test.dart b/integration_test/automix_analysis_test.dart index 86c4b3e7..61b4943d 100644 --- a/integration_test/automix_analysis_test.dart +++ b/integration_test/automix_analysis_test.dart @@ -2,10 +2,15 @@ import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:spotiflac_android/models/automix_options.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/services/automix_analysis.dart'; import 'package:spotiflac_android/services/automix_analyzer.dart'; +import 'package:spotiflac_android/services/music_playback_deck.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; Uint8List _drumWav(double bpm, {bool silent = false}) { const sampleRate = 44100; @@ -93,4 +98,103 @@ void main() { await temporary.delete(recursive: true); } }); + + testWidgets('native Auto and crossfade keep incoming playback moving', ( + tester, + ) async { + final temporary = await Directory.systemTemp.createTemp('native-handoff-'); + try { + final files = [ + File('${temporary.path}/a.wav'), + File('${temporary.path}/b.wav'), + ]; + await files[0].writeAsBytes(_drumWav(120)); + await files[1].writeAsBytes(_drumWav(124)); + for (final effect in [AutoMixEffect.auto, AutoMixEffect.crossfade]) { + final decks = {}; + final runtime = MusicPlayerRuntime( + settings: AppSettings( + autoMix: true, + autoMixEffect: effect, + pauseOnMute: false, + ), + dependencies: PlaybackDependencies( + createDeck: (id) { + final deck = MusicPlaybackDeck(playerId: id); + decks[id] = deck; + return deck; + }, + ), + ); + final handler = MusicPlayerHandler(runtime: runtime); + try { + await handler.restoreSession( + items: [ + for (var i = 0; i < files.length; i++) + PlayableMedia( + id: '$i', + source: files[i].path, + title: 'Test $i', + artist: 'Test', + duration: const Duration(seconds: 30), + ), + ], + index: 0, + position: const Duration(seconds: 22), + shuffle: false, + ); + await handler.play(); + final deadline = Stopwatch()..start(); + while (handler.mediaItem.value?.id != '1' && + deadline.elapsed < const Duration(seconds: 10)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(handler.mediaItem.value?.id, '1', reason: effect.name); + expect( + handler.playbackState.value.speed, + effect == AutoMixEffect.auto ? closeTo(120 / 124, 0.01) : 1, + ); + final incoming = decks.entries + .singleWhere((e) => e.key.startsWith('music-mix-')) + .value; + var lastPosition = (await incoming.getCurrentPosition())!; + final stalled = Stopwatch()..start(); + final sampleWindow = Stopwatch()..start(); + var maximumStall = Duration.zero; + // Include both the overlap and the return to normal speed. + while (sampleWindow.elapsed < const Duration(seconds: 13)) { + await Future.delayed(const Duration(milliseconds: 50)); + final position = (await incoming.getCurrentPosition())!; + expect( + position, + greaterThanOrEqualTo(lastPosition), + reason: effect.name, + ); + if (position > lastPosition) { + if (stalled.elapsed > maximumStall) { + maximumStall = stalled.elapsed; + } + stalled.reset(); + lastPosition = position; + } + expect( + stalled.elapsed, + lessThan(const Duration(milliseconds: 600)), + reason: effect.name, + ); + } + expect(handler.playbackState.value.speed, 1); + // Position continuity is a transport check, not an audio-output capture. + debugPrint( + '${effect.name}: largest native position stall ${maximumStall.inMilliseconds} ms', + ); + } finally { + await handler.dispose(); + await runtime.dispose(); + } + } + } finally { + await temporary.delete(recursive: true); + } + }); } diff --git a/lib/services/automix_analysis.dart b/lib/services/automix_analysis.dart index 2f26be43..ccb3d326 100644 --- a/lib/services/automix_analysis.dart +++ b/lib/services/automix_analysis.dart @@ -268,12 +268,10 @@ class AutoMixPlan { /// Equal-power gain ramps; tempo returns gradually after the outgoing song ends. ({double outgoing, double incoming, double rate, bool complete}) -autoMixEnvelope(Duration elapsed, AutoMixPlan plan) { - final t = (elapsed.inMicroseconds / plan.duration.inMicroseconds).clamp( - 0.0, - 1.0, - ); - final recovery = ((elapsed - plan.duration).inMicroseconds / 8000000).clamp( +autoMixEnvelope(Duration elapsed, AutoMixPlan plan, {Duration? fadeDuration}) { + final duration = fadeDuration ?? plan.duration; + final t = (elapsed.inMicroseconds / duration.inMicroseconds).clamp(0.0, 1.0); + final recovery = ((elapsed - duration).inMicroseconds / 8000000).clamp( 0.0, 1.0, ); diff --git a/lib/services/music_player_automix.dart b/lib/services/music_player_automix.dart index 36e415cb..301f884e 100644 --- a/lib/services/music_player_automix.dart +++ b/lib/services/music_player_automix.dart @@ -77,7 +77,6 @@ class _MusicAutoMix { } final delay = plan.start - position; _startTimer?.cancel(); - if (delay < const Duration(milliseconds: -350)) return; final generation = _generation; _startTimer = Timer(delay.isNegative ? Duration.zero : delay, () { if (_current(generation)) unawaited(_start(generation)); @@ -243,16 +242,38 @@ class _MusicAutoMix { onPosition(position); return; } - if (delta > const Duration(milliseconds: 350)) return; - await incoming.resume(); + final outgoingEnd = + handler.mediaItem.value?.duration ?? plan.start + plan.duration; + if (outgoingEnd - position <= const Duration(milliseconds: 500)) { + await _abandonStart(generation); + return; + } + final incomingPosition = await incoming.getCurrentPosition(); if (!_current(generation)) return; + await incoming.resume(); + final audiblePosition = await _waitForProgress( + incoming, + incomingPosition ?? plan.incomingStart, + generation, + ); + if (!_current(generation)) return; + if (audiblePosition == null) { + _log.w('AutoMix standby did not advance; keeping the current song'); + await _abandonStart(generation); + return; + } final original = handler._player; final outgoingVolume = handler._normalizationVolume; var outgoing = original; final effectDeck = _effectPrepared; if (effectDeck != null) { try { - await effectDeck.seek(delta.isNegative ? Duration.zero : delta); + final livePosition = await original.getCurrentPosition() ?? position; + if (!_current(generation)) return; + final effectOffset = livePosition - plan.start; + await effectDeck.seek( + effectOffset.isNegative ? Duration.zero : effectOffset, + ); if (!_current(generation)) return; await effectDeck.resume(); if (!_current(generation)) return; @@ -267,6 +288,17 @@ class _MusicAutoMix { } _effectPrepared = null; } + final outgoingPosition = await original.getCurrentPosition() ?? position; + if (!_current(generation)) return; + final remaining = outgoingEnd - outgoingPosition; + // Native resume acknowledges a command before audio necessarily starts. + // Keep the old song at full gain until the new decoder advances, then + // fit the overlap into the remaining outro instead of fading into silence. + final fadeDuration = remaining <= Duration.zero + ? const Duration(milliseconds: 40) + : remaining < plan.duration + ? remaining + : plan.duration; _outgoing = outgoing; _prepared = null; _plan = null; @@ -290,16 +322,20 @@ class _MusicAutoMix { ) .copyWith(duration: _nextDuration), ); - handler._broadcastPosition(plan.incomingStart, force: true); + handler._broadcastPosition(audiblePosition, force: true); handler._broadcastState(playerState: PlayerState.playing); - unawaited(handler._persistSession(position: plan.incomingStart)); + unawaited(handler._persistSession(position: audiblePosition)); final clock = Stopwatch()..start(); _fadeTimer = Timer.periodic(const Duration(milliseconds: 40), (_) { if (_writing || generation != _generation) return; _writing = true; _fadeWrite = () async { try { - final levels = autoMixEnvelope(clock.elapsed, plan); + final levels = autoMixEnvelope( + clock.elapsed, + plan, + fadeDuration: fadeDuration, + ); final dry = _effectDry; final progress = (clock.elapsedMicroseconds / 240000).clamp( 0.0, @@ -318,11 +354,6 @@ class _MusicAutoMix { ); } if (generation != _generation) return; - if (dry != null && progress >= 1) { - _effectDry = null; - await handler._disposeDeck(dry); - if (generation != _generation) return; - } await incoming.setVolume(_incomingVolume * levels.incoming); if (generation != _generation) return; if ((levels.rate - rate).abs() > 0.001 || levels.complete) { @@ -330,7 +361,12 @@ class _MusicAutoMix { rate = levels.rate; } if (generation != _generation) return; - if (clock.elapsed >= plan.duration && _outgoing != null) { + if (dry != null && progress >= 1) { + _effectDry = null; + await handler._disposeDeck(dry); + if (generation != _generation) return; + } + if (clock.elapsed >= fadeDuration && _outgoing != null) { _outgoing = null; await handler._disposeDeck(outgoing); final tail = _effectTail; @@ -357,11 +393,36 @@ class _MusicAutoMix { }); } catch (error) { _log.w('AutoMix start skipped: $error'); + if (generation == _generation) await _abandonStart(generation); } finally { if (generation == _generation) _starting = false; } } + Future _abandonStart(int generation) async { + final attempt = _attempt; + await cancel(); + // Do not repeatedly prepare the same stalled transition on every tick. + if (_generation == generation + 1) _attempt = attempt; + } + + Future _waitForProgress( + MusicPlaybackDeck deck, + Duration initialPosition, + int generation, + ) async { + final clock = Stopwatch()..start(); + while (_current(generation) && clock.elapsed < const Duration(seconds: 2)) { + final position = await deck.getCurrentPosition(); + if (position != null && + position - initialPosition >= const Duration(milliseconds: 20)) { + return position; + } + await Future.delayed(const Duration(milliseconds: 40)); + } + return null; + } + Future cancel() async { _generation++; _cancelling++; diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index be7a134a..f2930c48 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -72,6 +72,8 @@ class _AudioNative { final resumedSources = []; Completer? resumeGate; final resumeGates = >{}; + final stalled = {}; + final clocks = {}; bool emitsDuration = true; void install() { @@ -110,14 +112,19 @@ class _AudioNative { if (emitsDuration) unawaited(event(id, 'audio.onDuration', 60000)); case 'seek': positions[id] = args['position']! as int; + clocks[id]?.reset(); unawaited(event(id, 'audio.onSeekComplete')); case 'resume': await resumeGate?.future; await resumeGates[id]?.future; playing.add(id); resumedSources.add(sources[id]!); + if (id.startsWith('music-mix-') || id.startsWith('music-effect-')) { + clocks[id] = Stopwatch()..start(); + } case 'pause' || 'stop': playing.remove(id); + clocks[id]?.stop(); case 'release': playing.remove(id); sources.remove(id); @@ -128,7 +135,10 @@ class _AudioNative { case 'getDuration': return 60000; case 'getCurrentPosition': - return positions[id]; + return (positions[id] ?? 0) + + (stalled.contains(id) + ? 0 + : clocks[id]?.elapsedMilliseconds ?? 0); } return null; }, @@ -1525,6 +1535,74 @@ void main() { }, ); + test( + 'Auto waits for advancing audio before fading the current song', + () async { + analyzer.matchBeats = true; + await prepare(); + final incoming = native.prepared; + native.stalled.add(incoming); + native.positions['music-player'] = 55670; + await _until(() => native.playing.contains(incoming)); + await Future.delayed(const Duration(milliseconds: 240)); + expect(native.lastVolume('music-player'), 1); + expect(native.lastVolume(incoming), 0); + expect(handler.mediaItem.value?.id, 'one'); + + native.positions['music-player'] = 58500; + native.stalled.remove(incoming); + native.clocks[incoming]!.reset(); + await _until(() => handler.mediaItem.value?.id == 'two'); + // Startup consumed most of the outro. Finish the fade within the remaining + // 1.5 seconds instead of fading against a song that has already ended. + await _until(() => !native.live.contains('music-player')); + expect(native.lastVolume(incoming), closeTo(1, 0.0001)); + }, + ); + + test('a stalled Auto standby leaves the current song playing', () async { + analyzer.matchBeats = true; + await prepare(); + final incoming = native.prepared; + native.stalled.add(incoming); + native.positions['music-player'] = 55670; + await _until(() => native.playing.contains(incoming)); + await Future.delayed(const Duration(milliseconds: 2300)); + expect(native.live, {'music-player'}); + expect(native.playing, {'music-player'}); + expect(native.lastVolume('music-player'), 1); + expect(handler.mediaItem.value?.id, 'one'); + expect(analyzer.calls, hasLength(2)); + }); + + test('a late Auto timer still overlaps within the remaining outro', () async { + analyzer.matchBeats = true; + final incoming = await startMix(startPosition: 58500); + await _until(() => !native.live.contains('music-player')); + expect(native.lastVolume(incoming), closeTo(1, 0.0001)); + expect(native.playing, {incoming}); + }); + + test( + 'pausing while Auto waits for audio cancels the pending handoff', + () async { + analyzer.matchBeats = true; + await prepare(); + final incoming = native.prepared; + native.stalled.add(incoming); + native.positions['music-player'] = 55670; + await _until(() => native.playing.contains(incoming)); + await handler.pause(); + native.stalled.remove(incoming); + await Future.delayed(const Duration(milliseconds: 100)); + expect(handler.mediaItem.value?.id, 'one'); + expect(handler.playbackState.value.playing, isFalse); + expect(native.live, {'music-player'}); + expect(native.playing, isEmpty); + expect(native.lastVolume('music-player'), 1); + }, + ); + test( 'seek during overlap cancels fade and seeks only the active deck', () async {