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(); + }); +}