feat(player): synchronize music haptics with local playback

This commit is contained in:
zarzet committed 2026-10-17 16:18:37 +07:00
1 parent 78a5e078cd
commit 11c6cf9915
14 files changed
+718

No files matched your search

+1
View File
@@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.BLUETOOTH" tools:node="remove" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" tools:node="remove" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
@@ -1005,6 +1005,9 @@ class MainActivity: FlutterFragmentActivity() {
if (!flutterEngine.plugins.has(UsbBitPerfectPlugin::class.java)) {
flutterEngine.plugins.add(UsbBitPerfectPlugin())
}
if (!flutterEngine.plugins.has(MusicHapticsPlugin::class.java)) {
flutterEngine.plugins.add(MusicHapticsPlugin())
}
concertCalendarChannel = MethodChannel(messenger, "com.zarz.spotiflac/concert_calendar").also { channel ->
channel.setMethodCallHandler { call, result ->
if (call.method != "add") {
@@ -0,0 +1,92 @@
package com.zarz.spotiflac
import android.content.Context
import android.media.AudioAttributes
import android.os.Build
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
/** Renders one music pulse at a time; Dart owns timing against playback. */
class MusicHapticsPlugin : FlutterPlugin {
private lateinit var channel: MethodChannel
private var vibrator: Vibrator? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
val context = binding.applicationContext
vibrator = if (Build.VERSION.SDK_INT >= 31) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
channel = MethodChannel(binding.binaryMessenger, "com.zarz.spotiflac/haptics")
channel.setMethodCallHandler { call, result ->
if (call.method != "pulse") return@setMethodCallHandler result.notImplemented()
runCatching {
pulse(
(call.argument<Double>("intensity") ?: 0.0).coerceIn(0.0, 1.0),
(call.argument<Double>("sharpness") ?: 0.0).coerceIn(0.0, 1.0),
)
}
result.success(null)
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
vibrator?.cancel()
vibrator = null
}
private fun pulse(intensity: Double, sharpness: Double) {
val vibrator = vibrator ?: return
if (Build.VERSION.SDK_INT < 26 || !vibrator.hasVibrator() || intensity <= 0) return
val effect = composed(vibrator, intensity, sharpness)
?: if (vibrator.hasAmplitudeControl()) {
// Bass hits are longer and softer; bright hits short and crisp.
VibrationEffect.createOneShot(
(14 + 16 * (1 - sharpness)).toLong(),
(intensity * 255).toInt().coerceIn(1, 255),
)
} else if (intensity >= 0.6) {
// On/off motors cannot express accents; render strong hits only.
VibrationEffect.createOneShot(12, VibrationEffect.DEFAULT_AMPLITUDE)
} else {
return
}
// Media usage honours the system's separate media vibration switch.
if (Build.VERSION.SDK_INT >= 33) {
vibrator.vibrate(
effect,
VibrationAttributes.createForUsage(VibrationAttributes.USAGE_MEDIA),
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(
effect,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
}
}
/** Device-tuned primitives feel cleaner than raw motor amplitudes. */
private fun composed(vibrator: Vibrator, intensity: Double, sharpness: Double): VibrationEffect? {
if (Build.VERSION.SDK_INT < 31) return null
val primitive = if (sharpness < 0.5) {
VibrationEffect.Composition.PRIMITIVE_THUD
} else {
VibrationEffect.Composition.PRIMITIVE_CLICK
}
if (!vibrator.areAllPrimitivesSupported(primitive)) return null
return VibrationEffect.startComposition()
.addPrimitive(primitive, intensity.toFloat())
.compose()
}
}
+4
View File
@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
C0CE00000000000000000001 /* ConcertCalendarBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CE00000000000000000002 /* ConcertCalendarBridge.swift */; };
A0D100000000000000000001 /* AudioOutputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D100000000000000000002 /* AudioOutputView.swift */; };
A0D100000000000000000003 /* MusicHaptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D100000000000000000004 /* MusicHaptics.swift */; };
F59500000000000000000011 /* PlayerWidgetState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000001 /* PlayerWidgetState.swift */; };
F59500000000000000000012 /* PlayerControlIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000002 /* PlayerControlIntent.swift */; };
F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000004 /* PlayerWidgetBridge.swift */; };
@@ -73,6 +74,7 @@
/* Begin PBXFileReference section */
C0CE00000000000000000002 /* ConcertCalendarBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcertCalendarBridge.swift; sourceTree = "<group>"; };
A0D100000000000000000002 /* AudioOutputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputView.swift; sourceTree = "<group>"; };
A0D100000000000000000004 /* MusicHaptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicHaptics.swift; sourceTree = "<group>"; };
F59500000000000000000009 /* PlayerWidget.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PlayerWidget.xcconfig; sourceTree = "<group>"; };
F59500000000000000000001 /* PlayerWidgetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetState.swift; sourceTree = "<group>"; };
F59500000000000000000002 /* PlayerControlIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlIntent.swift; sourceTree = "<group>"; };
@@ -215,6 +217,7 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
F59500000000000000000004 /* PlayerWidgetBridge.swift */,
A0D100000000000000000002 /* AudioOutputView.swift */,
A0D100000000000000000004 /* MusicHaptics.swift */,
C0CE00000000000000000002 /* ConcertCalendarBridge.swift */,
F59500000000000000000007 /* Runner.entitlements */,
C03400000000000000000002 /* CoreBackend.swift */,
@@ -478,6 +481,7 @@
F59500000000000000000012 /* PlayerControlIntent.swift in Sources */,
F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */,
A0D100000000000000000001 /* AudioOutputView.swift in Sources */,
A0D100000000000000000003 /* MusicHaptics.swift in Sources */,
C0CE00000000000000000001 /* ConcertCalendarBridge.swift in Sources */,
C03400000000000000000001 /* CoreBackend.swift in Sources */,
D08A594730CF000100000001 /* DownloadProgressSubscription.swift in Sources */,
+2
View File
@@ -26,6 +26,7 @@ import UniformTypeIdentifiers
private var concertCalendar: ConcertCalendarBridge?
private var playbackNotification: PlaybackNotificationBridge?
private var audioRouteInfo: AudioRouteInfoBridge?
private var musicHaptics: MusicHapticsBridge?
private let coreBackend: CoreBackend = createCoreBackend()
private var pendingSessionGrantEvents: [[String: Any]] = []
@@ -117,6 +118,7 @@ import UniformTypeIdentifiers
GeneratedPluginRegistrant.register(with: registry)
playbackNotification = PlaybackNotificationBridge(messenger: messenger)
audioRouteInfo = AudioRouteInfoBridge(messenger: messenger)
musicHaptics = MusicHapticsBridge(messenger: messenger)
registry.registrar(forPlugin: "AudioOutputView")?.register(
AudioOutputViewFactory(messenger: messenger),
withId: "com.zarz.spotiflac/audio_output"
+70
View File
@@ -0,0 +1,70 @@
import CoreHaptics
import Flutter
/// Renders one music pulse at a time; Dart owns timing against playback.
final class MusicHapticsBridge {
private let channel: FlutterMethodChannel
private var engine: CHHapticEngine?
private let supported = CHHapticEngine.capabilitiesForHardware().supportsHaptics
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "com.zarz.spotiflac/haptics", binaryMessenger: messenger)
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "pulse" else {
result(FlutterMethodNotImplemented)
return
}
let arguments = call.arguments as? [String: Any]
let intensity = (arguments?["intensity"] as? Double) ?? 0
let sharpness = (arguments?["sharpness"] as? Double) ?? 0
self?.pulse(intensity: Float(min(max(intensity, 0), 1)), sharpness: Float(min(max(sharpness, 0), 1)))
result(nil)
}
}
private func pulse(intensity: Float, sharpness: Float) {
guard supported, intensity > 0 else { return }
do {
let engine = try self.engine ?? start()
// Auto-shutdown leaves the engine reusable but stopped. Restart
// before the first beat, rather than losing that beat to an error.
try engine.start()
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
],
relativeTime: 0
)
let player = try engine.makePlayer(with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: CHHapticTimeImmediate)
} catch {
// A stopped or interrupted engine is recreated on the next beat.
engine = nil
}
}
private func start() throws -> CHHapticEngine {
let engine = try CHHapticEngine()
// Music already owns the audio session; never route audio here.
engine.playsHapticsOnly = true
engine.isAutoShutdownEnabled = true
engine.stoppedHandler = { [weak self, weak engine] reason in
guard reason != .idleTimeout else { return }
DispatchQueue.main.async {
guard let self, self.engine === engine else { return }
self.engine = nil
}
}
engine.resetHandler = { [weak self, weak engine] in
DispatchQueue.main.async {
guard let self, self.engine === engine else { return }
self.engine = nil
}
}
try engine.start()
self.engine = engine
return engine
}
}
+4
View File
@@ -65,6 +65,7 @@ class AppSettings {
final bool dapExclusive;
final bool autoplay;
final bool autoplayPersonalization;
final bool musicHaptics;
final bool isFirstLaunch;
final bool checkForUpdates;
final String updateChannel;
@@ -199,6 +200,7 @@ class AppSettings {
this.dapExclusive = false,
this.autoplay = false,
this.autoplayPersonalization = true,
this.musicHaptics = false,
this.isFirstLaunch = true,
this.checkForUpdates = true,
this.updateChannel = 'stable',
@@ -308,6 +310,7 @@ class AppSettings {
bool? dapExclusive,
bool? autoplay,
bool? autoplayPersonalization,
bool? musicHaptics,
bool? isFirstLaunch,
bool? checkForUpdates,
String? updateChannel,
@@ -417,6 +420,7 @@ class AppSettings {
autoplay: autoplay ?? this.autoplay,
autoplayPersonalization:
autoplayPersonalization ?? this.autoplayPersonalization,
musicHaptics: musicHaptics ?? this.musicHaptics,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
+2
View File
@@ -60,6 +60,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
dapExclusive: json['dapExclusive'] as bool? ?? false,
autoplay: json['autoplay'] as bool? ?? false,
autoplayPersonalization: json['autoplayPersonalization'] as bool? ?? true,
musicHaptics: json['musicHaptics'] as bool? ?? false,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
@@ -182,6 +183,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'dapExclusive': instance.dapExclusive,
'autoplay': instance.autoplay,
'autoplayPersonalization': instance.autoplayPersonalization,
'musicHaptics': instance.musicHaptics,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
+5
View File
@@ -650,6 +650,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setMusicHaptics(bool enabled) {
state = state.copyWith(musicHaptics: enabled);
_saveSettings();
}
void setUsbBitPerfect(bool enabled) {
state = state.copyWith(usbBitPerfect: enabled);
_saveSettings();
@@ -141,6 +141,16 @@ class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
.read(settingsProvider.notifier)
.setPlaybackNormalization(value),
),
SettingsSwitchItem(
icon: Icons.vibration,
title: context.l10n.musicHaptics,
subtitle: context.l10n.musicHapticsDescription,
value: settings.musicHaptics,
enabled: settings.playerMode == 'internal',
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setMusicHaptics(value),
),
SettingsSwitchItem(
icon: Icons.compare_arrows,
title: 'AutoMix',
@@ -294,6 +294,12 @@ class SettingsSearchCatalog {
subtitle: l10n.libraryPlaybackNormalizationSubtitle,
keywords: const ['replaygain', 'volume'],
),
SettingsSearchEntry(
icon: Icons.vibration,
title: l10n.musicHaptics,
subtitle: l10n.musicHapticsDescription,
keywords: const ['haptic', 'vibrate', 'vibration', 'beat', 'getar'],
),
SettingsSearchEntry(
icon: Icons.volume_off_outlined,
title: l10n.playbackPauseOnMute,
+387
View File
@@ -0,0 +1,387 @@
import 'dart:async';
import 'dart:io';
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/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/utils/logger.dart' show AppLogger;
const musicHapticsSampleRate = 4000;
const _hop = 40; // 10 ms frames.
const _maxSeconds = 1200;
/// One pulse per detected hit, stored compactly for a whole song.
class HapticTrack {
HapticTrack(this.times, this.intensity, this.sharpness);
/// Pulse starts in milliseconds, ascending.
final Int32List times;
final Uint8List intensity;
final Uint8List sharpness;
int get length => times.length;
/// The first pulse at or after [milliseconds].
int indexAt(int milliseconds) {
var low = 0;
var high = times.length;
while (low < high) {
final middle = (low + high) >> 1;
if (times[middle] < milliseconds) {
low = middle + 1;
} else {
high = middle;
}
}
return low;
}
}
/// Finds hits in mono s16le PCM at [musicHapticsSampleRate].
///
/// Energy is split into bass (below about 150 Hz) and treble (above about
/// 400 Hz). Rises in either band are onsets; kick and bass hits weigh more,
/// as they are what a phone's motor renders well. An adaptive threshold
/// follows the song's own density, so busy and sparse passages both produce
/// a readable pattern instead of a constant buzz.
HapticTrack analyzeHapticPcm(Uint8List pcm) {
final samples = ByteData.sublistView(pcm);
final frames = pcm.length ~/ (2 * _hop);
final empty = HapticTrack(Int32List(0), Uint8List(0), Uint8List(0));
if (frames < 8) return empty;
final low = Float64List(frames);
final high = Float64List(frames);
final lowAlpha = exp(-2 * pi * 150 / musicHapticsSampleRate);
final highAlpha = exp(-2 * pi * 400 / musicHapticsSampleRate);
var low1 = 0.0, low2 = 0.0, smooth1 = 0.0, smooth2 = 0.0;
for (var frame = 0; frame < frames; frame++) {
var lowSum = 0.0;
var highSum = 0.0;
for (var i = 0; i < _hop; i++) {
final x = samples.getInt16((frame * _hop + i) * 2, Endian.little) / 32768;
low1 = (1 - lowAlpha) * x + lowAlpha * low1;
low2 = (1 - lowAlpha) * low1 + lowAlpha * low2;
// Two one-pole high-passes keep a kick's fundamental out of treble.
smooth1 = (1 - highAlpha) * x + highAlpha * smooth1;
final high1 = x - smooth1;
smooth2 = (1 - highAlpha) * high1 + highAlpha * smooth2;
final high2 = high1 - smooth2;
lowSum += low2 * low2;
highSum += high2 * high2;
}
low[frame] = sqrt(lowSum / _hop);
high[frame] = sqrt(highSum / _hop);
}
final loudness = Float64List(frames);
for (var i = 0; i < frames; i++) {
loudness[i] = low[i] + high[i];
}
final sorted = Float64List.fromList(loudness)..sort();
final gate = max(0.002, sorted[(frames * 0.9).floor()] * 0.1);
double loud(Float64List band) {
final ranked = Float64List.fromList(band)..sort();
return max(1e-4, ranked[(frames * 0.9).floor()]);
}
// Rises are measured against each band's typical loud level, not in log
// terms: every quiet sound starting is a large log rise, but only loud hits
// such as kicks and snares should become pulses.
final lowReference = loud(low);
final highReference = loud(high);
final onset = Float64List(frames);
for (var i = 2; i < frames; i++) {
onset[i] =
max(0.0, low[i] - low[i - 2]) / lowReference +
max(0.0, high[i] - high[i - 2]) / highReference * 0.3;
}
final prefix = Float64List(frames + 1);
for (var i = 0; i < frames; i++) {
prefix[i + 1] = prefix[i] + onset[i];
}
const radius = 40; // ±0.4 s of context for the threshold.
const peak = 3; // A hit must be the largest rise within ±30 ms.
const minimumGap = 15; // At most about six pulses per second.
final found = <int>[];
final excess = <double>[];
var last = -minimumGap;
for (var i = 2; i < frames; i++) {
if (loudness[i] < gate || i - last < minimumGap) continue;
final value = onset[i];
final from = max(0, i - radius);
final to = min(frames, i + radius + 1);
final threshold = max(
0.15,
(prefix[to] - prefix[from]) / (to - from) * 2.4,
);
if (value <= threshold) continue;
var largest = true;
for (var j = max(0, i - peak); j <= min(frames - 1, i + peak); j++) {
if (onset[j] > value) {
largest = false;
break;
}
}
if (!largest) continue;
found.add(i);
excess.add(value - threshold);
last = i;
}
if (found.isEmpty) return empty;
final ranked = List<double>.of(excess)..sort();
final reference = max(1e-6, ranked[(ranked.length * 0.95).floor()]);
final times = Int32List(found.length);
final intensity = Uint8List(found.length);
final sharpness = Uint8List(found.length);
for (var n = 0; n < found.length; n++) {
final i = found[n];
// Log rises ignore level, so compare the bands' linear rises instead.
final lowRise = max(0.0, low[i] - low[i - 2]);
final highRise = max(0.0, high[i] - high[i - 2]);
final bass = lowRise / (lowRise + highRise + 1e-9);
final strength = sqrt((excess[n] / reference).clamp(0.0, 1.0));
times[n] = i * _hop * 1000 ~/ musicHapticsSampleRate;
intensity[n] = (255 * (0.3 + 0.7 * strength) * (0.65 + 0.35 * bass))
.round()
.clamp(1, 255);
sharpness[n] = (255 * (1 - bass)).round().clamp(0, 255);
}
return HapticTrack(times, intensity, sharpness);
}
/// Decodes a low-rate mono copy of a local song once and caches its pulses.
class MusicHapticsAnalyzer {
MusicHapticsAnalyzer({
AudioAnalysisJobs<bool>? jobs,
Future<Directory> Function()? temporaryDirectory,
}) : _jobs =
jobs ??
AudioAnalysisJobs<bool>(start: _start, cancel: FFmpegKit.cancel),
_temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
final _log = AppLogger('MusicHaptics');
final AudioAnalysisJobs<bool> _jobs;
final Future<Directory> Function() _temporaryDirectory;
final _cache = <String, HapticTrack>{};
int _generation = 0;
static Future<({int id, Future<bool> completed})> _start(
List<String> arguments,
) async {
final done = Completer<bool>();
final session = await FFmpegKit.executeWithArgumentsAsync(arguments, (
session,
) async {
if (!done.isCompleted) {
done.complete(ReturnCode.isSuccess(await session.getReturnCode()));
}
});
final id = session.getSessionId();
if (id == null) throw StateError('Haptics session has no ID');
final timeout = Timer(
const Duration(seconds: 60),
() => unawaited(FFmpegKit.cancel(id)),
);
return (id: id, completed: done.future.whenComplete(timeout.cancel));
}
void cancel() {
_generation++;
_jobs.invalidate();
}
void dispose() {
cancel();
_jobs.dispose();
_cache.clear();
}
Future<HapticTrack?> analyze(String path) async {
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;
work = await (await _temporaryDirectory()).createTemp('haptics-');
if (generation != _generation) return null;
final output = File('${work.path}/song.pcm');
final success = await _jobs.run([
'-hide_banner',
'-loglevel',
'error',
'-nostdin',
'-y',
'-threads',
'1',
'-i',
path,
'-t',
'$_maxSeconds',
'-vn',
'-sn',
'-dn',
'-ac',
'1',
'-ar',
'$musicHapticsSampleRate',
'-c:a',
'pcm_s16le',
'-f',
's16le',
output.path,
]);
if (!success || generation != _generation || !await output.exists()) {
return null;
}
if (await output.length() >
musicHapticsSampleRate * 2 * (_maxSeconds + 1)) {
return null;
}
final track = await compute(analyzeHapticPcm, await output.readAsBytes());
if (generation != _generation) return null;
_cache[key] = track;
while (_cache.length > 8) {
_cache.remove(_cache.keys.first);
}
return track;
} on AudioAnalysisCancelled {
return null;
} on Object catch (error) {
if (generation == _generation) {
_log.w('Music haptics unavailable (${error.runtimeType})');
}
return null;
} finally {
if (work != null && await work.exists()) {
await work.delete(recursive: true);
}
}
}
}
typedef HapticPulse = Future<void> Function(double intensity, double sharpness);
const _channel = MethodChannel('com.zarz.spotiflac/haptics');
Future<void> nativeHapticPulse(double intensity, double sharpness) =>
_channel.invokeMethod<void>('pulse', {
'intensity': intensity,
'sharpness': sharpness,
});
/// Plays a song's pulses against the live playback position.
///
/// Only the next pulse is scheduled. Each one re-reads the extrapolated
/// position, so seeks, pauses, AutoMix rate changes and position updates
/// correct the timing within a beat.
class MusicHaptics {
MusicHaptics({
required this.state,
MusicHapticsAnalyzer? analyzer,
HapticPulse? pulse,
this.lead = const Duration(milliseconds: 12),
}) : _analyzer = analyzer ?? MusicHapticsAnalyzer(),
_pulse = pulse ?? nativeHapticPulse;
final PlaybackState Function() state;
final MusicHapticsAnalyzer _analyzer;
final HapticPulse _pulse;
/// Motors need a moment to spin up; fire slightly early.
final Duration lead;
String? _path;
HapticTrack? _track;
Timer? _timer;
int _generation = 0;
bool _unsupported = false;
@visibleForTesting
HapticTrack? get track => _track;
/// [path] is null when haptics are off or the song is not a local file.
void update(String? path) {
if (path != _path) {
_path = path;
_track = null;
_timer?.cancel();
_generation++;
_analyzer.cancel();
if (path != null && !_unsupported) unawaited(_load(path, _generation));
return;
}
_schedule();
}
Future<void> _load(String path, int generation) async {
final track = await _analyzer.analyze(path);
if (generation != _generation) return;
_track = track;
_schedule();
}
int? _position() {
final current = state();
if (!current.playing ||
current.processingState != AudioProcessingState.ready) {
return null;
}
return current.position.inMilliseconds;
}
void _schedule([int from = 0]) {
_timer?.cancel();
final track = _track;
final position = _position();
if (track == null || position == null || _unsupported) return;
final index = max(from, track.indexAt(position + lead.inMilliseconds));
if (index >= track.length) return;
final speed = state().speed > 0 ? state().speed : 1.0;
final wait = ((track.times[index] - position) / speed).round();
final generation = _generation;
_timer = Timer(
Duration(milliseconds: max(0, wait - lead.inMilliseconds)),
() => _fire(index, generation),
);
}
void _fire(int index, int generation) {
if (generation != _generation) return;
final track = _track;
final position = _position();
if (track != null && position != null) {
// A late timer or a position correction makes the pulse stale.
final offset = track.times[index] - position - lead.inMilliseconds;
if (offset > 40) return _schedule();
if (offset >= -40) {
unawaited(
_pulse(
track.intensity[index] / 255,
track.sharpness[index] / 255,
).catchError((Object error) {
if (error is MissingPluginException) {
_unsupported = true;
_timer?.cancel();
}
}),
);
}
}
_schedule(index + 1);
}
void dispose() {
_generation++;
_timer?.cancel();
_analyzer.dispose();
}
}
+31
View File
@@ -35,6 +35,7 @@ import 'package:spotiflac_android/models/automix_options.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/automix_effect_renderer.dart';
import 'package:spotiflac_android/services/automix_status.dart';
import 'package:spotiflac_android/services/music_haptics.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/ios_container_paths.dart';
import 'package:spotiflac_android/utils/playback_artwork.dart';
@@ -323,6 +324,7 @@ class MusicPlayerHandler extends BaseAudioHandler
late MusicPlaybackDeck _player;
late final _MusicAutoMix _autoMix;
late final _MusicAutoplay _autoplay;
late final MusicHaptics _haptics;
bool _completing = false;
double _normalizationVolume = 1;
final _playerSubscriptions =
@@ -402,6 +404,10 @@ class MusicPlayerHandler extends BaseAudioHandler
previous.usbBitPerfect != settings.usbBitPerfect) {
_refreshPlaybackAutomation();
}
if (previous.musicHaptics != settings.musicHaptics ||
previous.playerMode != settings.playerMode) {
_updateHaptics();
}
if (previous.autoplay != settings.autoplay ||
previous.autoplayPersonalization != settings.autoplayPersonalization ||
previous.localLibraryEnabled != settings.localLibraryEnabled) {
@@ -414,7 +420,14 @@ class MusicPlayerHandler extends BaseAudioHandler
AutoMixAnalyzer? autoMixAnalyzer,
AutoMixEffectRenderer? autoMixEffectRenderer,
AutoplayLibraryLoader? autoplayLibraryLoader,
MusicHapticsAnalyzer? hapticsAnalyzer,
HapticPulse? hapticPulse,
}) : _runtime = runtime ?? musicPlayerRuntime {
_haptics = MusicHaptics(
state: () => playbackState.value,
analyzer: hapticsAnalyzer,
pulse: hapticPulse,
);
_sourceCache = ContentUriPlaybackCache(
copy: PlatformBridge.copyContentUriToTemp,
isDisposed: () => _disposed,
@@ -530,6 +543,8 @@ class MusicPlayerHandler extends BaseAudioHandler
if (playing) unawaited(_playbackAutomation.playbackStarted());
}),
);
_subscriptions.add(mediaItem.listen((_) => _updateHaptics()));
_subscriptions.add(playbackState.listen((_) => _updateHaptics()));
_listeningTimer = Timer.periodic(const Duration(seconds: 10), (_) {
unawaited(_dependencies.recorder.flush());
});
@@ -555,6 +570,21 @@ class MusicPlayerHandler extends BaseAudioHandler
}
}
/// Haptics follow only decodable local files; streams have no full copy.
void _updateHaptics() {
if (_disposed) return;
final extras = mediaItem.value?.extras;
final source = extras?['source']?.toString() ?? '';
final resolved = extras?['resolvedSource']?.toString().trim() ?? '';
final path = resolved.isNotEmpty ? resolved : source;
final local =
path.isNotEmpty &&
!path.contains('://') &&
_settings.musicHaptics &&
_settings.playerMode == 'internal';
_haptics.update(local ? path : null);
}
void _configurePersonalization() {
final enabled =
_settings.autoplayPersonalization && _settings.playerMode == 'internal';
@@ -2188,6 +2218,7 @@ class MusicPlayerHandler extends BaseAudioHandler
await _playbackAutomation.dispose();
await _sourceChangeTail;
await _autoMix.dispose();
_haptics.dispose();
for (final sub in _subscriptions) {
await sub.cancel();
}
+101
View File
@@ -0,0 +1,101 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/music_haptics.dart';
Uint8List _pcm(double seconds, double Function(double time) sample) {
final count = (seconds * musicHapticsSampleRate).round();
final data = ByteData(count * 2);
for (var i = 0; i < count; i++) {
final value = sample(i / musicHapticsSampleRate).clamp(-1.0, 1.0);
data.setInt16(i * 2, (value * 32767).round(), Endian.little);
}
return data.buffer.asUint8List();
}
/// Decaying bursts every [period] seconds, starting at [offset].
double Function(double) _hits(double frequency, double period, double offset) =>
(time) {
if (time < offset) return 0;
final local = (time - offset) % period;
return 0.8 * exp(-local * 30) * sin(2 * pi * frequency * local);
};
class _Analyzer extends MusicHapticsAnalyzer {
_Analyzer(this.result);
final HapticTrack result;
final paths = <String>[];
@override
Future<HapticTrack?> analyze(String path) async {
paths.add(path);
return result;
}
}
void main() {
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));
for (var n = 0; n < track.length; n++) {
final beat = ((track.times[n] - 250) / 500).round();
expect(
(track.times[n] - (250 + beat * 500)).abs(),
lessThanOrEqualTo(30),
);
expect(track.sharpness[n], lessThan(128));
}
});
test('bright hits are sharper than bass hits', () {
final track = analyzeHapticPcm(_pcm(4, _hits(1200, 0.4, 0.2)));
expect(track.length, greaterThan(6));
expect(track.sharpness.reduce(min), greaterThan(128));
});
test('silence and steady tones produce no pulses', () {
expect(analyzeHapticPcm(_pcm(3, (_) => 0)).length, 0);
final tone = analyzeHapticPcm(
_pcm(3, (time) => 0.5 * sin(2 * pi * 220 * time)),
);
expect(tone.length, lessThanOrEqualTo(1));
});
test('pulses follow the playing position and stop on pause', () async {
var state = PlaybackState(
playing: true,
processingState: AudioProcessingState.ready,
updatePosition: Duration.zero,
);
final pulses = <(double, double)>[];
final analyzer = _Analyzer(
HapticTrack(
Int32List.fromList([80, 280, 900]),
Uint8List.fromList([255, 51, 255]),
Uint8List.fromList([0, 255, 0]),
),
);
final haptics = MusicHaptics(
state: () => state,
analyzer: analyzer,
pulse: (intensity, sharpness) async => pulses.add((intensity, sharpness)),
);
haptics.update('/music/song.flac');
await Future<void>.delayed(const Duration(milliseconds: 400));
expect(pulses, [(1.0, 0.0), (0.2, 1.0)]);
state = state.copyWith(
playing: false,
updatePosition: const Duration(milliseconds: 400),
);
haptics.update('/music/song.flac');
await Future<void>.delayed(const Duration(milliseconds: 600));
expect(pulses, hasLength(2));
haptics.update(null);
expect(analyzer.paths, ['/music/song.flac']);
haptics.dispose();
});
}