From 11c6cf991533bfb5731dc7ae66bcc56119218c2d Mon Sep 17 00:00:00 2001
From: zarzet <42882290+zarzet@users.noreply.github.com>
Date: Sat, 17 Oct 2026 16:18:37 +0700
Subject: [PATCH] feat(player): synchronize music haptics with local playback
---
android/app/src/main/AndroidManifest.xml | 1 +
.../kotlin/com/zarz/spotiflac/MainActivity.kt | 3 +
.../com/zarz/spotiflac/MusicHapticsPlugin.kt | 92 +++++
ios/Runner.xcodeproj/project.pbxproj | 4 +
ios/Runner/AppDelegate.swift | 2 +
ios/Runner/MusicHaptics.swift | 70 ++++
lib/models/settings.dart | 4 +
lib/models/settings.g.dart | 2 +
lib/providers/settings_provider.dart | 5 +
.../settings/playback_settings_page.dart | 10 +
.../settings/settings_search_catalog.dart | 6 +
lib/services/music_haptics.dart | 387 ++++++++++++++++++
lib/services/music_player_service.dart | 31 ++
test/music_haptics_test.dart | 101 +++++
14 files changed, 718 insertions(+)
create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/MusicHapticsPlugin.kt
create mode 100644 ios/Runner/MusicHaptics.swift
create mode 100644 lib/services/music_haptics.dart
create mode 100644 test/music_haptics_test.dart
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5afcd9a7..56c2507b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
+
diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
index 520b5e1c..dba2e458 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
@@ -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") {
diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MusicHapticsPlugin.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MusicHapticsPlugin.kt
new file mode 100644
index 00000000..72f41cb7
--- /dev/null
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MusicHapticsPlugin.kt
@@ -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("intensity") ?: 0.0).coerceIn(0.0, 1.0),
+ (call.argument("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()
+ }
+}
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index cd8987c8..e5353e5e 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -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 = ""; };
A0D100000000000000000002 /* AudioOutputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputView.swift; sourceTree = ""; };
+ A0D100000000000000000004 /* MusicHaptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicHaptics.swift; sourceTree = ""; };
F59500000000000000000009 /* PlayerWidget.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PlayerWidget.xcconfig; sourceTree = ""; };
F59500000000000000000001 /* PlayerWidgetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetState.swift; sourceTree = ""; };
F59500000000000000000002 /* PlayerControlIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlIntent.swift; sourceTree = ""; };
@@ -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 */,
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 88c2c818..0f3369ae 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -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"
diff --git a/ios/Runner/MusicHaptics.swift b/ios/Runner/MusicHaptics.swift
new file mode 100644
index 00000000..515b03e3
--- /dev/null
+++ b/ios/Runner/MusicHaptics.swift
@@ -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
+ }
+}
diff --git a/lib/models/settings.dart b/lib/models/settings.dart
index 1ce31299..a9ea9557 100644
--- a/lib/models/settings.dart
+++ b/lib/models/settings.dart
@@ -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,
diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart
index 28489597..9a4dfbe6 100644
--- a/lib/models/settings.g.dart
+++ b/lib/models/settings.g.dart
@@ -60,6 +60,7 @@ AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson(
'dapExclusive': instance.dapExclusive,
'autoplay': instance.autoplay,
'autoplayPersonalization': instance.autoplayPersonalization,
+ 'musicHaptics': instance.musicHaptics,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart
index e307ac8d..c3c29548 100644
--- a/lib/providers/settings_provider.dart
+++ b/lib/providers/settings_provider.dart
@@ -650,6 +650,11 @@ class SettingsNotifier extends Notifier {
_saveSettings();
}
+ void setMusicHaptics(bool enabled) {
+ state = state.copyWith(musicHaptics: enabled);
+ _saveSettings();
+ }
+
void setUsbBitPerfect(bool enabled) {
state = state.copyWith(usbBitPerfect: enabled);
_saveSettings();
diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart
index ed791ded..40b932ed 100644
--- a/lib/screens/settings/playback_settings_page.dart
+++ b/lib/screens/settings/playback_settings_page.dart
@@ -141,6 +141,16 @@ class _PlaybackSettingsPageState extends ConsumerState {
.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',
diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart
index ff21f6e2..a5f7525f 100644
--- a/lib/screens/settings/settings_search_catalog.dart
+++ b/lib/screens/settings/settings_search_catalog.dart
@@ -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,
diff --git a/lib/services/music_haptics.dart b/lib/services/music_haptics.dart
new file mode 100644
index 00000000..12d6eadc
--- /dev/null
+++ b/lib/services/music_haptics.dart
@@ -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 = [];
+ final excess = [];
+ 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.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? jobs,
+ Future Function()? temporaryDirectory,
+ }) : _jobs =
+ jobs ??
+ AudioAnalysisJobs(start: _start, cancel: FFmpegKit.cancel),
+ _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
+
+ final _log = AppLogger('MusicHaptics');
+ final AudioAnalysisJobs _jobs;
+ final Future Function() _temporaryDirectory;
+ final _cache = {};
+ int _generation = 0;
+
+ static Future<({int id, Future completed})> _start(
+ List arguments,
+ ) async {
+ final done = Completer();
+ 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 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 Function(double intensity, double sharpness);
+
+const _channel = MethodChannel('com.zarz.spotiflac/haptics');
+
+Future nativeHapticPulse(double intensity, double sharpness) =>
+ _channel.invokeMethod('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 _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();
+ }
+}
diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart
index cc41f39d..18d3f060 100644
--- a/lib/services/music_player_service.dart
+++ b/lib/services/music_player_service.dart
@@ -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();
}
diff --git a/test/music_haptics_test.dart b/test/music_haptics_test.dart
new file mode 100644
index 00000000..fb990b56
--- /dev/null
+++ b/test/music_haptics_test.dart
@@ -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 = [];
+
+ @override
+ Future 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.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.delayed(const Duration(milliseconds: 600));
+ expect(pulses, hasLength(2));
+
+ haptics.update(null);
+ expect(analyzer.paths, ['/music/song.flac']);
+ haptics.dispose();
+ });
+}