fix(automix): wait for advancing audio before starting fades

This commit is contained in:
zarzet committed 2026-10-12 12:11:27 +07:00
1 parent ac8523d789
commit 65dbce27f5
4 files changed
+261 -20

No files matched your search

+104
View File
@@ -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 = <String, MusicPlaybackDeck>{};
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<void>.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<void>.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);
}
});
}
+4 -6
View File
@@ -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,
);
+74 -13
View File
@@ -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<void> _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<Duration?> _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<void>.delayed(const Duration(milliseconds: 40));
}
return null;
}
Future<void> cancel() async {
_generation++;
_cancelling++;
+79 -1
View File
@@ -72,6 +72,8 @@ class _AudioNative {
final resumedSources = <String>[];
Completer<void>? resumeGate;
final resumeGates = <String, Completer<void>>{};
final stalled = <String>{};
final clocks = <String, Stopwatch>{};
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<void>.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<void>.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<void>.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 {