mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 14:37:33 +02:00
fix(automix): wait for advancing audio before starting fades
This commit is contained in:
1 parent
ac8523d789
commit
65dbce27f5
4 files changed
+261
-20
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user