fix(haptics): analyze document audio across skips and AutoMix

This commit is contained in:
zarzet committed 2026-10-18 03:11:20 +07:00
1 parent f426b2d1f0
commit 2455cabced
4 files changed
+301 -8

No files matched your search

+37 -6
View File
@@ -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);
}
+5 -2
View File
@@ -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);
+146
View File
@@ -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]);
},
);
});
}
+113
View File
@@ -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);