mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
feat(player): synchronize music haptics with local playback
This commit is contained in:
1 parent
78a5e078cd
commit
11c6cf9915
14 files changed
+718
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user