mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
fix(haptics): analyze document audio across skips and AutoMix
This commit is contained in:
1 parent
f426b2d1f0
commit
2455cabced
4 files changed
+301
-8
No files matched your search
@@ -4,11 +4,13 @@ import 'dart:math';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart' show AppLogger;
|
||||
|
||||
const musicHapticsSampleRate = 4000;
|
||||
@@ -174,6 +176,18 @@ class MusicHapticsAnalyzer {
|
||||
static Future<({int id, Future<bool> completed})> _start(
|
||||
List<String> arguments,
|
||||
) async {
|
||||
final inputIndex = arguments.indexOf('-i') + 1;
|
||||
if (inputIndex > 0 && arguments[inputIndex].startsWith('content://')) {
|
||||
// Resolve only when this owned job starts. FFmpegKit opens and closes
|
||||
// its own SAF descriptor, independently of the player's short lease.
|
||||
final input = await FFmpegKitConfig.getSafParameterForRead(
|
||||
arguments[inputIndex],
|
||||
);
|
||||
if (input == null || input.isEmpty) {
|
||||
throw StateError('Unable to open haptics audio source');
|
||||
}
|
||||
arguments = List.of(arguments)..[inputIndex] = input;
|
||||
}
|
||||
final done = Completer<bool>();
|
||||
final session = await FFmpegKit.executeWithArgumentsAsync(arguments, (
|
||||
session,
|
||||
@@ -206,11 +220,28 @@ class MusicHapticsAnalyzer {
|
||||
final generation = _generation;
|
||||
Directory? work;
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type != FileSystemEntityType.file) return null;
|
||||
final key = '$path:${stat.size}:${stat.modified.microsecondsSinceEpoch}';
|
||||
final cached = _cache.remove(key);
|
||||
if (cached != null) return _cache[key] = cached;
|
||||
String? key;
|
||||
if (path.startsWith('content://')) {
|
||||
// A document URI is not a filesystem path. Unknown modification times
|
||||
// still allow analysis, but cannot safely identify a cached revision.
|
||||
try {
|
||||
final modified = (await PlatformBridge.getSafFileModTimes([
|
||||
path,
|
||||
]))[path];
|
||||
if (modified != null && modified > 0) key = '$path:$modified';
|
||||
} catch (_) {
|
||||
// Some document providers cannot report a modification timestamp.
|
||||
}
|
||||
} else {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type != FileSystemEntityType.file) return null;
|
||||
key = '$path:${stat.size}:${stat.modified.microsecondsSinceEpoch}';
|
||||
}
|
||||
if (generation != _generation) return null;
|
||||
if (key != null) {
|
||||
final cached = _cache.remove(key);
|
||||
if (cached != null) return _cache[key] = cached;
|
||||
}
|
||||
work = await (await _temporaryDirectory()).createTemp('haptics-');
|
||||
if (generation != _generation) return null;
|
||||
final output = File('${work.path}/song.pcm');
|
||||
@@ -248,7 +279,7 @@ class MusicHapticsAnalyzer {
|
||||
}
|
||||
final track = await compute(analyzeHapticPcm, await output.readAsBytes());
|
||||
if (generation != _generation) return null;
|
||||
_cache[key] = track;
|
||||
if (key != null) _cache[key] = track;
|
||||
while (_cache.length > 8) {
|
||||
_cache.remove(_cache.keys.first);
|
||||
}
|
||||
|
||||
@@ -576,10 +576,13 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
final extras = mediaItem.value?.extras;
|
||||
final source = extras?['source']?.toString() ?? '';
|
||||
final resolved = extras?['resolvedSource']?.toString().trim() ?? '';
|
||||
final path = resolved.isNotEmpty ? resolved : source;
|
||||
final document = source.startsWith('content://');
|
||||
// Use a stable document identity for both direct playback and AutoMix.
|
||||
// Haptics opens its own SAF input; the player's descriptor is short-lived.
|
||||
final path = document ? source : (resolved.isNotEmpty ? resolved : source);
|
||||
final local =
|
||||
path.isNotEmpty &&
|
||||
!path.contains('://') &&
|
||||
(document || !path.contains('://')) &&
|
||||
_settings.musicHaptics &&
|
||||
_settings.playerMode == 'internal';
|
||||
_haptics.update(local ? path : null);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
|
||||
import 'package:spotiflac_android/services/music_haptics.dart';
|
||||
|
||||
Uint8List _pcm(double seconds, double Function(double time) sample) {
|
||||
@@ -35,7 +40,16 @@ class _Analyzer extends MusicHapticsAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingAnalyzer extends MusicHapticsAnalyzer {
|
||||
final pending = <String, Completer<HapticTrack?>>{};
|
||||
|
||||
@override
|
||||
Future<HapticTrack?> analyze(String path) =>
|
||||
(pending[path] = Completer<HapticTrack?>()).future;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
test('a kick on every beat becomes one deep pulse per beat', () {
|
||||
final track = analyzeHapticPcm(_pcm(6, _hits(60, 0.5, 0.25)));
|
||||
expect(track.length, inInclusiveRange(11, 12));
|
||||
@@ -98,4 +112,136 @@ void main() {
|
||||
expect(analyzer.paths, ['/music/song.flac']);
|
||||
haptics.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'analysis finished during loading starts on the first ready state',
|
||||
() async {
|
||||
var state = PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.loading,
|
||||
);
|
||||
var pulses = 0;
|
||||
final analyzer = _Analyzer(
|
||||
HapticTrack(
|
||||
Int32List.fromList([80]),
|
||||
Uint8List.fromList([255]),
|
||||
Uint8List.fromList([0]),
|
||||
),
|
||||
);
|
||||
final haptics = MusicHaptics(
|
||||
state: () => state,
|
||||
analyzer: analyzer,
|
||||
pulse: (_, _) async => pulses++,
|
||||
);
|
||||
addTearDown(haptics.dispose);
|
||||
haptics.update('/music/first.flac');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(haptics.track, isNotNull);
|
||||
expect(pulses, 0);
|
||||
state = PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
);
|
||||
haptics.update('/music/first.flac');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
expect(pulses, 1);
|
||||
expect(analyzer.paths, ['/music/first.flac']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'late analysis from a skipped song cannot replace the active pulses',
|
||||
() async {
|
||||
final state = PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
);
|
||||
final analyzer = _PendingAnalyzer();
|
||||
final haptics = MusicHaptics(
|
||||
state: () => state,
|
||||
analyzer: analyzer,
|
||||
pulse: (_, _) async {},
|
||||
);
|
||||
addTearDown(haptics.dispose);
|
||||
haptics.update('content://music/one');
|
||||
haptics.update('content://music/two');
|
||||
final latest = HapticTrack(Int32List(0), Uint8List(0), Uint8List(0));
|
||||
analyzer.pending['content://music/two']!.complete(latest);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
analyzer.pending['content://music/one']!.complete(
|
||||
HapticTrack(
|
||||
Int32List.fromList([80]),
|
||||
Uint8List.fromList([255]),
|
||||
Uint8List(1),
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(haptics.track, same(latest));
|
||||
},
|
||||
);
|
||||
|
||||
group('document analysis', () {
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
late Directory temporary;
|
||||
late MusicHapticsAnalyzer analyzer;
|
||||
late List<String> inputs;
|
||||
var modified = 1;
|
||||
|
||||
setUp(() async {
|
||||
temporary = await Directory.systemTemp.createTemp('haptics-test-');
|
||||
inputs = [];
|
||||
modified = 1;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'getSafFileModTimes');
|
||||
final source =
|
||||
(jsonDecode((call.arguments as Map)['uris'] as String) as List)
|
||||
.single
|
||||
as String;
|
||||
return {source: modified};
|
||||
});
|
||||
analyzer = MusicHapticsAnalyzer(
|
||||
temporaryDirectory: () async => temporary,
|
||||
jobs: AudioAnalysisJobs<bool>(
|
||||
start: (arguments) async {
|
||||
inputs.add(arguments[arguments.indexOf('-i') + 1]);
|
||||
await File(
|
||||
arguments.last,
|
||||
).writeAsBytes(_pcm(2, _hits(60, 0.5, 0.25)));
|
||||
return (id: inputs.length, completed: Future.value(true));
|
||||
},
|
||||
cancel: (_) async {},
|
||||
),
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
analyzer.dispose();
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
await temporary.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('document URI is decoded without a playback cache copy', () async {
|
||||
const source = 'content://music/document/first.flac';
|
||||
final first = await analyzer.analyze(source);
|
||||
expect(first?.length, greaterThan(0));
|
||||
expect(await analyzer.analyze(source), same(first));
|
||||
expect(inputs, [source]);
|
||||
modified++;
|
||||
expect(await analyzer.analyze(source), isNot(same(first)));
|
||||
expect(inputs, [source, source]);
|
||||
expect(await temporary.list().toList(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'unknown modification time permits analysis without caching stale pulses',
|
||||
() async {
|
||||
modified = 0;
|
||||
const source = 'content://music/document/unknown.flac';
|
||||
expect((await analyzer.analyze(source))?.length, greaterThan(0));
|
||||
expect((await analyzer.analyze(source))?.length, greaterThan(0));
|
||||
expect(inputs, [source, source]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:spotiflac_android/services/automix_analysis.dart';
|
||||
import 'package:spotiflac_android/services/automix_analyzer.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/services/music_haptics.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/playback_notification.dart';
|
||||
import 'package:spotiflac_android/models/automix_options.dart';
|
||||
@@ -56,6 +58,20 @@ class _Analyzer extends AutoMixAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
class _HapticsAnalyzer extends MusicHapticsAnalyzer {
|
||||
final paths = <String>[];
|
||||
|
||||
@override
|
||||
Future<HapticTrack?> analyze(String path) async {
|
||||
paths.add(path);
|
||||
return HapticTrack(
|
||||
Int32List.fromList(List.generate(20, (index) => (index + 1) * 150)),
|
||||
Uint8List.fromList(List.filled(20, 255)),
|
||||
Uint8List(20),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercises the real AudioPlayer transport/streams against a native-channel
|
||||
/// fake, including preparation, seek completion and outgoing completion events.
|
||||
class _AudioNative {
|
||||
@@ -219,8 +235,13 @@ void main() {
|
||||
late _EffectRenderer effectRenderer;
|
||||
late MusicPlayerHandler handler;
|
||||
late AutoplayLibraryLoader autoplayLoader;
|
||||
late _HapticsAnalyzer hapticsAnalyzer;
|
||||
late List<String?> hapticSongs;
|
||||
|
||||
setUp(() {
|
||||
musicPlayerRuntime.configure(
|
||||
musicPlayerRuntime.settings.copyWith(musicHaptics: false),
|
||||
);
|
||||
setPlaybackAutomationOptions(
|
||||
enabled: true,
|
||||
pauseOnMute: false,
|
||||
@@ -234,10 +255,14 @@ void main() {
|
||||
analyzer = _Analyzer();
|
||||
effectRenderer = _EffectRenderer();
|
||||
autoplayLoader = (_) async => _tracks;
|
||||
hapticsAnalyzer = _HapticsAnalyzer();
|
||||
hapticSongs = [];
|
||||
handler = MusicPlayerHandler(
|
||||
autoMixAnalyzer: analyzer,
|
||||
autoMixEffectRenderer: effectRenderer,
|
||||
autoplayLibraryLoader: (seed) => autoplayLoader(seed),
|
||||
hapticsAnalyzer: hapticsAnalyzer,
|
||||
hapticPulse: (_, _) async => hapticSongs.add(handler.mediaItem.value?.id),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -311,6 +336,77 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
group('music haptics transport', () {
|
||||
void enableHaptics() => musicPlayerRuntime.configure(
|
||||
musicPlayerRuntime.settings.copyWith(musicHaptics: true),
|
||||
);
|
||||
|
||||
test('direct documents work without AutoMix resolved metadata', () async {
|
||||
enableHaptics();
|
||||
const source = 'content://music/document/first.flac';
|
||||
handler.mediaItem.add(
|
||||
const MediaItem(
|
||||
id: 'document',
|
||||
title: 'Document',
|
||||
extras: {'source': source},
|
||||
),
|
||||
);
|
||||
handler.playbackState.add(
|
||||
PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
),
|
||||
);
|
||||
await _until(() => hapticSongs.isNotEmpty);
|
||||
expect(hapticSongs.first, 'document');
|
||||
expect(hapticsAnalyzer.paths, [source]);
|
||||
// A subsequent AutoMix-style metadata update keeps the same analysis.
|
||||
handler.mediaItem.add(
|
||||
handler.mediaItem.value!.copyWith(
|
||||
extras: {'source': source, 'resolvedSource': '/cache/first.flac'},
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(hapticsAnalyzer.paths, [source]);
|
||||
});
|
||||
|
||||
for (final autoMix in [false, true]) {
|
||||
test(
|
||||
'first song and manual skips retain haptics (AutoMix: $autoMix)',
|
||||
() async {
|
||||
enableHaptics();
|
||||
setAutoMixEnabled(autoMix);
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
await _until(() => hapticSongs.contains('one'));
|
||||
await handler.skipToNext();
|
||||
await _until(() => hapticSongs.contains('two'));
|
||||
final beforePrevious = hapticSongs.length;
|
||||
await handler.skipToPrevious();
|
||||
await _until(() => hapticSongs.skip(beforePrevious).contains('one'));
|
||||
expect(hapticSongs.last, 'one');
|
||||
expect(hapticsAnalyzer.paths, [
|
||||
'/one.flac',
|
||||
'/two.flac',
|
||||
'/one.flac',
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'ordinary completion and a subsequent skip both keep haptics',
|
||||
() async {
|
||||
enableHaptics();
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
await _until(() => hapticSongs.contains('one'));
|
||||
await native.event('music-player', 'audio.onComplete');
|
||||
await _until(() => hapticSongs.contains('two'));
|
||||
await handler.skipToNext();
|
||||
await _until(() => hapticSongs.contains('three'));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('playback failures', () {
|
||||
test('runtime error stays on the song and Play retries it', () async {
|
||||
await handler.setQueueAndPlay(_tracks);
|
||||
@@ -1627,6 +1723,23 @@ void main() {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
test(
|
||||
'haptics survives an AutoMix handoff followed by a manual skip',
|
||||
() async {
|
||||
musicPlayerRuntime.configure(
|
||||
musicPlayerRuntime.settings.copyWith(musicHaptics: true),
|
||||
);
|
||||
await startMix();
|
||||
await _until(() => hapticSongs.contains('two'));
|
||||
await handler.skipToNext();
|
||||
await _until(() => hapticSongs.contains('three'));
|
||||
expect(
|
||||
hapticsAnalyzer.paths,
|
||||
containsAllInOrder(['/one.flac', '/two.flac', '/three.flac']),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('Previous after AutoMix handoff reloads the preceding audio', () async {
|
||||
final active = await startMix();
|
||||
await handler.seek(Duration.zero);
|
||||
|
||||
Reference in new issue
Block a user