diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/audio/AudioHapticPlayer.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/audio/AudioHapticPlayer.kt new file mode 100644 index 00000000..657a36e7 --- /dev/null +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/audio/AudioHapticPlayer.kt @@ -0,0 +1,111 @@ +package com.swmansion.pulsar.audio + +import android.content.Context +import android.media.AudioAttributes +import android.media.SoundPool +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.swmansion.pulsar.types.SoundData + +class AudioHapticPlayer( + private val context: Context, + private val sound: SoundData, + private val hapticChannelsMuted: Boolean, +) { + companion object { + private const val TAG = "Pulsar" + private const val LOAD_SUCCESS = 0 + } + + private var soundPool: SoundPool? = null + private var soundId: Int = 0 + private var streamId: Int = 0 + private var isLoaded = false + private var playPending = false + private val mainHandler = Handler(Looper.getMainLooper()) + + fun load() { + val attributesBuilder = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + attributesBuilder.setHapticChannelsMuted(hapticChannelsMuted) + } + + val pool = SoundPool.Builder() + .setMaxStreams(4) + .setAudioAttributes(attributesBuilder.build()) + .build() + + pool.setOnLoadCompleteListener { _, sampleId, status -> + if (status == LOAD_SUCCESS && sampleId == soundId) { + isLoaded = true + if (playPending) { + playPending = false + scheduleStream() + } + } else { + Log.w(TAG, "Failed to load sound (status=$status): ${sound.uri}") + } + } + + soundId = loadInto(pool) + soundPool = pool + } + + fun play() { + if (isLoaded) { + scheduleStream() + } else { + playPending = true + } + } + + fun stop() { + playPending = false + mainHandler.removeCallbacksAndMessages(null) + if (streamId != 0) { + soundPool?.stop(streamId) + streamId = 0 + } + } + + fun release() { + stop() + soundPool?.release() + soundPool = null + isLoaded = false + } + + private fun scheduleStream() { + if (sound.offset > 0) { + mainHandler.postDelayed({ startStream() }, sound.offset) + } else { + startStream() + } + } + + private fun startStream() { + val pool = soundPool ?: return + streamId = pool.play(soundId, sound.volume, sound.volume, 1, 0, 1f) + } + + private fun loadInto(pool: SoundPool): Int { + val uri = sound.uri + val isPath = uri.startsWith("/") || uri.startsWith("file://") + + if (!isPath) { + val name = uri.substringAfterLast('/').substringBeforeLast('.') + val resId = context.resources.getIdentifier(name, "raw", context.packageName) + if (resId != 0) { + return pool.load(context, resId, 1) + } + } + + val path = if (uri.startsWith("file://")) uri.removePrefix("file://") else uri + return pool.load(path, 1) + } +} diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/composers/PatternComposer.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/composers/PatternComposer.kt index a55d506a..49748456 100644 --- a/Android/Pulsar/src/main/java/com/swmansion/pulsar/composers/PatternComposer.kt +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/composers/PatternComposer.kt @@ -3,9 +3,11 @@ package com.swmansion.pulsar.composers import android.os.Build import android.os.VibrationEffect import android.util.Log +import com.swmansion.pulsar.audio.AudioHapticPlayer import com.swmansion.pulsar.audio.AudioSimulator import com.swmansion.pulsar.haptics.HapticEngineWrapper import com.swmansion.pulsar.types.PatternData +import com.swmansion.pulsar.types.SoundData class PatternComposer( private val engine: HapticEngineWrapper, @@ -13,11 +15,19 @@ class PatternComposer( ) { companion object { private const val TAG = "Pulsar" + + internal fun isOggUri(uri: String): Boolean { + val extension = uri.substringAfterLast('/').substringAfterLast('.', "").lowercase() + return extension == "ogg" + } } private var vibrationEffect: VibrationEffect? = null private var audioBuffer: ByteArray? = null + private var soundPlayer: AudioHapticPlayer? = null + private var useCoupledHaptics = false + fun parsePattern(hapticsData: PatternData) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrationEffect = try { @@ -36,20 +46,53 @@ class PatternComposer( audioBuffer = audioSimulator.parsePattern(hapticsData) } + fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData) { + parsePattern(hapticsData) + + soundPlayer?.release() + + useCoupledHaptics = sound.hapticChannels && isOggUri(sound.uri) && engine.supportsAudioCoupledHaptics() + + soundPlayer = AudioHapticPlayer( + context = engine.getContext(), + sound = sound, + hapticChannelsMuted = !useCoupledHaptics, + ).also { it.load() } + } + fun play() { - audioSimulator.play(audioBuffer) - vibrationEffect?.let { engine.vibrate(it) } + val player = soundPlayer + if (player != null) { + player.play() + if (!useCoupledHaptics) { + vibrationEffect?.let { engine.vibrate(it) } + } + } else { + audioSimulator.play(audioBuffer) + vibrationEffect?.let { engine.vibrate(it) } + } } fun playAudioOnly() { - audioSimulator.play(audioBuffer) + val player = soundPlayer + if (player != null) { + player.play() + } else { + audioSimulator.play(audioBuffer) + } } fun stop() { + soundPlayer?.stop() audioSimulator.stop() engine.stop() } + fun release() { + soundPlayer?.release() + soundPlayer = null + } + private fun summarizePattern(hapticsData: PatternData): String { val discreteCount = hapticsData.discretePattern.size val amplitudeCount = hapticsData.continuousPattern.amplitude.size diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/haptics/HapticEngineWrapper.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/haptics/HapticEngineWrapper.kt index 49674031..cf8bf287 100644 --- a/Android/Pulsar/src/main/java/com/swmansion/pulsar/haptics/HapticEngineWrapper.kt +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/haptics/HapticEngineWrapper.kt @@ -79,6 +79,12 @@ class HapticEngineWrapper(private val context: Context) { return hapticBuilder } + fun getContext(): Context = context + + fun supportsAudioCoupledHaptics(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && (vibrator?.hasVibrator() == true) + } + fun isAmplitudeSupported(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && vibrationService.hasAmplitudeControl() } diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/Points.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/Points.kt index fbd51ea0..0f480e03 100644 --- a/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/Points.kt +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/Points.kt @@ -37,6 +37,25 @@ data class ContinuousPattern( val frequency: List, ) +/** + * @param uri absolute file path, `file://` uri, or the name of a resource in `res/raw`. + * The format comes from the extension; when none is given the default is `.wav`, + * so a bare name like `"beep"` loads `res/raw/beep.wav`. + * @param volume playback volume from [0-1]. + * @param offset delay applied to audio start relative to haptics, in ms (fallback path only). + * @param hapticChannels whether the file carries baked haptic channels. When true + * (default) the coupled path is used on supported devices, but only for an + * **explicit** `.ogg` uri; when false the file is treated as plain audio and + * Pulsar's own haptics are always played alongside it. Any non-`.ogg` uri — + * including a bare name (which defaults to `.wav`) — uses the fallback regardless. + */ +data class SoundData( + val uri: String, + val volume: Float = 1f, + val offset: Long = 0L, + val hapticChannels: Boolean = true, +) + data class PatternData( val continuousPattern: ContinuousPattern, val discretePattern: List, diff --git a/Android/Pulsar/src/test/java/com/swmansion/pulsar/composers/AudioSyncUnitTest.kt b/Android/Pulsar/src/test/java/com/swmansion/pulsar/composers/AudioSyncUnitTest.kt new file mode 100644 index 00000000..691603fb --- /dev/null +++ b/Android/Pulsar/src/test/java/com/swmansion/pulsar/composers/AudioSyncUnitTest.kt @@ -0,0 +1,67 @@ +package com.swmansion.pulsar.composers + +import com.swmansion.pulsar.types.SoundData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-JVM coverage of the synchronized-audio decision logic that does not need + * the Android framework: the `.ogg` vs. non-`.ogg` detection that drives whether + * the coupled (audio-coupled haptics) path or the audio + generated-haptics + * fallback is used, plus the [SoundData] defaults the RN/Flutter/KMP wrappers rely on. + */ +class AudioSyncUnitTest { + + @Test + fun explicitOggUrisAreCoupledEligible() { + // .ogg is the only format that can carry baked haptic channels. + assertTrue(PatternComposer.isOggUri("boom.ogg")) + assertTrue(PatternComposer.isOggUri("sounds/boom.ogg")) + assertTrue(PatternComposer.isOggUri("/data/local/boom.ogg")) + assertTrue(PatternComposer.isOggUri("file:///assets/boom.ogg")) + } + + @Test + fun oggDetectionIsCaseInsensitive() { + assertTrue(PatternComposer.isOggUri("boom.OGG")) + assertTrue(PatternComposer.isOggUri("boom.Ogg")) + } + + @Test + fun nonOggFilesAreNotCoupledEligible() { + assertFalse(PatternComposer.isOggUri("boom.wav")) + assertFalse(PatternComposer.isOggUri("boom.mp3")) + assertFalse(PatternComposer.isOggUri("boom.WAV")) + assertFalse(PatternComposer.isOggUri("boom.m4a")) + assertFalse(PatternComposer.isOggUri("/sdcard/boom.mp3")) + assertFalse(PatternComposer.isOggUri("file:///assets/boom.wav")) + } + + @Test + fun bareResourceNameDefaultsToWavAndIsNotCoupled() { + // No extension => default .wav => plain audio + fallback haptics, so a + // bare name like "beep" can't silently take the coupled path. + assertFalse(PatternComposer.isOggUri("beep")) + assertFalse(PatternComposer.isOggUri("sounds/beep")) + assertFalse(PatternComposer.isOggUri("")) + } + + @Test + fun soundDataAppliesTheDocumentedDefaults() { + val sound = SoundData(uri = "boom.ogg") + assertEquals(1f, sound.volume) + assertEquals(0L, sound.offset) + assertTrue(sound.hapticChannels) + } + + @Test + fun soundDataKeepsExplicitValues() { + val sound = SoundData(uri = "boom.wav", volume = 0.5f, offset = 30L, hapticChannels = false) + assertEquals("boom.wav", sound.uri) + assertEquals(0.5f, sound.volume) + assertEquals(30L, sound.offset) + assertFalse(sound.hapticChannels) + } +} diff --git a/Android/Pulsar/src/test/java/com/swmansion/pulsar/haptics/RobolectricAudioCoupledHapticsTest.kt b/Android/Pulsar/src/test/java/com/swmansion/pulsar/haptics/RobolectricAudioCoupledHapticsTest.kt new file mode 100644 index 00000000..4609b586 --- /dev/null +++ b/Android/Pulsar/src/test/java/com/swmansion/pulsar/haptics/RobolectricAudioCoupledHapticsTest.kt @@ -0,0 +1,52 @@ +package com.swmansion.pulsar.haptics + +import android.os.Vibrator +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Robolectric coverage of `HapticEngineWrapper.supportsAudioCoupledHaptics()` — the + * capability gate that decides whether an `.ogg` sound file's baked haptic channels + * can drive the vibrator (perfect sync) or the audio + generated-haptics fallback is + * used. It requires API 29+ (where `setHapticChannelsMuted` exists) and a vibrator. + */ +@RunWith(RobolectricTestRunner::class) +class RobolectricAudioCoupledHapticsTest { + + private fun engineWithVibrator(hasVibrator: Boolean): HapticEngineWrapper { + val app = RuntimeEnvironment.getApplication() + val vibrator = app.getSystemService(Vibrator::class.java) + shadowOf(vibrator).setHasVibrator(hasVibrator) + return HapticEngineWrapper(app) + } + + @Test + @Config(sdk = [34]) + fun supportedWhenApi29PlusAndVibratorPresent() { + assertTrue(engineWithVibrator(hasVibrator = true).supportsAudioCoupledHaptics()) + } + + @Test + @Config(sdk = [34]) + fun unsupportedWithoutAVibrator() { + assertFalse(engineWithVibrator(hasVibrator = false).supportsAudioCoupledHaptics()) + } + + @Test + @Config(sdk = [29]) + fun supportedExactlyAtApi29() { + assertTrue(engineWithVibrator(hasVibrator = true).supportsAudioCoupledHaptics()) + } + + @Test + @Config(sdk = [28]) + fun unsupportedBelowApi29EvenWithAVibrator() { + assertFalse(engineWithVibrator(hasVibrator = true).supportsAudioCoupledHaptics()) + } +} diff --git a/Android/PulsarApp/app/src/main/java/com/swmansion/pulsarapp/screens/PublicAPIsScreen.kt b/Android/PulsarApp/app/src/main/java/com/swmansion/pulsarapp/screens/PublicAPIsScreen.kt index 7735654a..3244cdd4 100644 --- a/Android/PulsarApp/app/src/main/java/com/swmansion/pulsarapp/screens/PublicAPIsScreen.kt +++ b/Android/PulsarApp/app/src/main/java/com/swmansion/pulsarapp/screens/PublicAPIsScreen.kt @@ -27,6 +27,7 @@ import com.swmansion.pulsar.types.CompatibilityMode import com.swmansion.pulsar.types.ConfigPoint import com.swmansion.pulsar.types.ContinuousPattern import com.swmansion.pulsar.types.PatternData +import com.swmansion.pulsar.types.SoundData import com.swmansion.pulsar.types.ValuePoint @RequiresApi(Build.VERSION_CODES.R) @@ -201,6 +202,31 @@ fun PublicAPIsScreen(pulsar: Pulsar?) { ) } + item { + APITestRow( + label = "PatternComposer.parsePatternWithSound()", + state = "Ready", + onButtonClick = { + if (composer == null) { + composer = pulsar?.getPatternComposer() + } + val pattern = PatternData( + continuousPattern = ContinuousPattern(amplitude = emptyList(), frequency = emptyList()), + discretePattern = listOf( + ConfigPoint(time = 0L, amplitude = 1f, frequency = 0.8f), + ConfigPoint(time = 200L, amplitude = 0.7f, frequency = 0.5f), + ), + ) + // Play a short sound in sync with the haptics. A bare name + // defaults to `.wav`, so add `beep.wav` to `app/src/main/res/raw/` + // (use an explicit `beep.ogg` with baked haptic channels for + // coupled sync). Missing files degrade to haptics-only. Call + // play() afterwards. + composer?.parsePatternWithSound(pattern, SoundData(uri = "beep")) + } + ) + } + item { APITestRow( label = "PatternComposer.play()", diff --git a/flutter/PulsarApp/lib/main.dart b/flutter/PulsarApp/lib/main.dart index 2086db53..6ac29267 100644 --- a/flutter/PulsarApp/lib/main.dart +++ b/flutter/PulsarApp/lib/main.dart @@ -114,6 +114,34 @@ class _PulsarDemoScreenState extends State { } } + Future _playPatternWithSound() async { + _setStatus('Playing pattern + sound…'); + try { + final pattern = PatternData( + continuousPattern: const ContinuousPattern(amplitude: [], frequency: []), + discretePattern: const [ + DiscretePoint(time: 0, amplitude: 1.0, frequency: 0.8), + DiscretePoint(time: 200, amplitude: 0.7, frequency: 0.5), + ], + ); + + // A short sound played in sync with the haptics. A bare name defaults to + // `.wav`, so bundle `beep.wav` natively — iOS: in the app bundle; Android: + // in `res/raw` (use an explicit `beep.ogg` with baked haptic channels for + // coupled sync). To ship it as a Flutter asset instead, copy it from + // `rootBundle` to a temp file first and pass that absolute path. Missing + // files degrade gracefully to haptics-only. + await _patternComposer.parsePatternWithSound( + pattern, + const Sound(uri: 'beep', volume: 1.0, offset: 0), + ); + await _patternComposer.play(); + _setStatus('Pattern + sound: playing'); + } catch (e) { + _setStatus('Pattern + sound error: $e'); + } + } + // ── Build ────────────────────────────────────────────────────────────────── @override @@ -213,6 +241,15 @@ class _PulsarDemoScreenState extends State { ), ], ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _playPatternWithSound, + icon: const Icon(Icons.music_note), + label: const Text('Play Pattern + Sound'), + ), + ), const SizedBox(height: 12), // Realtime composer diff --git a/flutter/pulsar/android/src/main/kotlin/com/swmansion/pulsar/PulsarPlugin.kt b/flutter/pulsar/android/src/main/kotlin/com/swmansion/pulsar/PulsarPlugin.kt index e79deaea..02e0271e 100644 --- a/flutter/pulsar/android/src/main/kotlin/com/swmansion/pulsar/PulsarPlugin.kt +++ b/flutter/pulsar/android/src/main/kotlin/com/swmansion/pulsar/PulsarPlugin.kt @@ -14,6 +14,7 @@ import com.swmansion.pulsar.types.ConfigPoint import com.swmansion.pulsar.types.ContinuousPattern import com.swmansion.pulsar.types.PatternData import com.swmansion.pulsar.types.RealtimeComposerStrategy +import com.swmansion.pulsar.types.SoundData import com.swmansion.pulsar.types.ValuePoint /** PulsarPlugin */ @@ -237,6 +238,24 @@ class PulsarPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success(resolvedComposerId) } + "PatternComposer_parsePatternWithSound" -> { + val data = call.argument>("data") + ?: return result.error("INVALID_ARGS", "data required", null) + val patternData = parsePatternData(data) + ?: return result.error("INVALID_ARGS", "invalid pattern data", null) + val soundMap = call.argument>("sound") + ?: return result.error("INVALID_ARGS", "sound required", null) + val sound = parseSoundData(soundMap) + ?: return result.error("INVALID_ARGS", "invalid sound", null) + val composerId = call.argument("composerId") + val resolvedComposerId = composerId ?: nextPatternComposerId++ + val composer = patternComposers[resolvedComposerId] ?: p.getPatternComposer().also { + patternComposers[resolvedComposerId] = it + } + composer.parsePatternWithSound(patternData, sound) + result.success(resolvedComposerId) + } + "PatternComposer_playPattern" -> { val data = call.argument>("data") ?: return result.error("INVALID_ARGS", "data required", null) @@ -313,4 +332,11 @@ class PulsarPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { discretePattern = discrete ) } + + private fun parseSoundData(data: Map): SoundData? { + val uri = data["uri"] as? String ?: return null + val volume = (data["volume"] as? Number)?.toFloat() ?: 1f + val offset = (data["offset"] as? Number)?.toLong() ?: 0L + return SoundData(uri = uri, volume = volume, offset = offset) + } } diff --git a/flutter/pulsar/ios/Classes/PulsarPlugin.swift b/flutter/pulsar/ios/Classes/PulsarPlugin.swift index 91d0f087..e73a4a76 100644 --- a/flutter/pulsar/ios/Classes/PulsarPlugin.swift +++ b/flutter/pulsar/ios/Classes/PulsarPlugin.swift @@ -178,6 +178,29 @@ public class PulsarPlugin: NSObject, FlutterPlugin { patternComposer.parsePattern(hapticsData: patternData) result(resolvedComposerId) + case "PatternComposer_parsePatternWithSound": + guard let data = args?["data"] as? [String: Any], + let patternData = parsePatternData(data), + let sound = args?["sound"] as? [String: Any], + let uri = sound["uri"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "valid pattern data and sound required", details: nil)) + return + } + let volume = (sound["volume"] as? NSNumber)?.floatValue ?? 1.0 + let offset = (sound["offset"] as? NSNumber)?.doubleValue ?? 0.0 + let composerId = args?["composerId"] as? Int + let resolvedComposerId = composerId ?? nextPatternComposerId + if composerId == nil { + nextPatternComposerId += 1 + } + let patternComposer = patternComposers[resolvedComposerId] ?? { + let composer = pulsar.getPatternComposer() + patternComposers[resolvedComposerId] = composer + return composer + }() + patternComposer.parsePatternWithSound(hapticsData: patternData, uri: uri, volume: volume, offset: offset) + result(resolvedComposerId) + case "PatternComposer_playPattern": guard let data = args?["data"] as? [String: Any], let patternData = parsePatternData(data) else { diff --git a/flutter/pulsar/lib/pulsar_method_channel.dart b/flutter/pulsar/lib/pulsar_method_channel.dart index b3af7759..222e68d4 100644 --- a/flutter/pulsar/lib/pulsar_method_channel.dart +++ b/flutter/pulsar/lib/pulsar_method_channel.dart @@ -161,6 +161,23 @@ class MethodChannelPulsar extends PulsarPlatform { return result ?? composerId ?? 0; } + @override + Future patternParsePatternWithSound( + PatternData data, + Sound sound, { + int? composerId, + }) async { + final result = await methodChannel.invokeMethod( + 'PatternComposer_parsePatternWithSound', + { + 'data': data.toMap(), + 'sound': sound.toMap(), + if (composerId != null) 'composerId': composerId, + }, + ); + return result ?? composerId ?? 0; + } + @override Future patternPlayPattern(PatternData data, {int? composerId}) async { final result = await methodChannel.invokeMethod( diff --git a/flutter/pulsar/lib/pulsar_platform_interface.dart b/flutter/pulsar/lib/pulsar_platform_interface.dart index 972f1d1f..297a2980 100644 --- a/flutter/pulsar/lib/pulsar_platform_interface.dart +++ b/flutter/pulsar/lib/pulsar_platform_interface.dart @@ -102,6 +102,14 @@ abstract class PulsarPlatform extends PlatformInterface { Future patternParsePattern(PatternData data, {int? composerId}) => throw UnimplementedError('patternParsePattern() not implemented'); + Future patternParsePatternWithSound( + PatternData data, + Sound sound, { + int? composerId, + }) => throw UnimplementedError( + 'patternParsePatternWithSound() not implemented', + ); + Future patternPlayPattern(PatternData data, {int? composerId}) => throw UnimplementedError('patternPlayPattern() not implemented'); diff --git a/flutter/pulsar/lib/pulsar_types.dart b/flutter/pulsar/lib/pulsar_types.dart index 878caa36..182137c2 100644 --- a/flutter/pulsar/lib/pulsar_types.dart +++ b/flutter/pulsar/lib/pulsar_types.dart @@ -181,3 +181,25 @@ class PatternData { 'discretePattern': discretePattern.map((p) => p.toMap()).toList(), }; } + +/// A short authored sound to play in sync with a haptic pattern. +class Sound { + /// Creates a [Sound] source. + const Sound({required this.uri, this.volume = 1.0, this.offset = 0}); + + /// Local file path, `file://` uri, or bundled resource name (no extension needed + /// — defaults to `.wav`). + final String uri; + + /// Playback volume, 0.0–1.0. + final double volume; + + /// Shift of the audio relative to the haptics, in milliseconds. + final int offset; + + Map toMap() => { + 'uri': uri, + 'volume': volume, + 'offset': offset, + }; +} diff --git a/flutter/pulsar/lib/src/pulsar_pattern_composer.dart b/flutter/pulsar/lib/src/pulsar_pattern_composer.dart index 0be296eb..0480ed80 100644 --- a/flutter/pulsar/lib/src/pulsar_pattern_composer.dart +++ b/flutter/pulsar/lib/src/pulsar_pattern_composer.dart @@ -37,6 +37,16 @@ class PulsarPatternComposer { ); } + /// Parse and store a pattern together with a synchronized [sound]. Call [play] + /// afterwards. See [Sound] for how to provide the audio file per platform. + Future parsePatternWithSound(PatternData data, Sound sound) async { + _composerId = await PulsarPlatform.instance.patternParsePatternWithSound( + data, + sound, + composerId: _composerId, + ); + } + /// Parse and play a pattern in one step. Future playPattern(PatternData data) async { _composerId = await PulsarPlatform.instance.patternPlayPattern( diff --git a/flutter/pulsar/test/pulsar_method_channel_test.dart b/flutter/pulsar/test/pulsar_method_channel_test.dart index cdad43ca..a6a281ff 100644 --- a/flutter/pulsar/test/pulsar_method_channel_test.dart +++ b/flutter/pulsar/test/pulsar_method_channel_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pulsar_haptics/pulsar_method_channel.dart'; +import 'package:pulsar_haptics/pulsar_types.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -34,6 +35,28 @@ void main() { expect(loggedCalls.single.arguments, {'name': 'BalloonPop'}); }); + test('parsePatternWithSound sends the pattern and sound over the channel', () async { + final pattern = PatternData( + continuousPattern: const ContinuousPattern(amplitude: [], frequency: []), + discretePattern: const [ + DiscretePoint(time: 0, amplitude: 1.0, frequency: 0.8), + ], + ); + + await platform.patternParsePatternWithSound( + pattern, + const Sound(uri: 'beep', volume: 0.5, offset: 20), + ); + + expect(loggedCalls, hasLength(1)); + final call = loggedCalls.single; + expect(call.method, 'PatternComposer_parsePatternWithSound'); + final args = call.arguments as Map; + expect(args['sound'], {'uri': 'beep', 'volume': 0.5, 'offset': 20}); + expect(args['data'], isNotNull); + expect(args.containsKey('composerId'), isFalse); + }); + test('preloadPresets normalizes each preset name', () async { await platform.preloadPresets([ 'balloonPop', diff --git a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift index 4b686034..31f4b4ee 100644 --- a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift +++ b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift @@ -13,6 +13,7 @@ public class PatternComposer: NSObject { private var discretePattern: CHHapticPattern? private var audioBuffer: AVAudioPCMBuffer? private var audioSimulator: AudioSimulator! + private var hasSound = false public convenience init(engine: HapticEngineWrapper, audioSimulator: AudioSimulator) { self.init() @@ -25,8 +26,18 @@ public class PatternComposer: NSObject { } @objc public func parsePattern(hapticsData: PatternData) { + parse(hapticsData: hapticsData, audioEvent: nil) + } + + @objc public func parsePatternWithSound(hapticsData: PatternData, uri: String, volume: Float = 1, offset: Double = 0) { + let audioEvent = makeAudioEvent(uri: uri, volume: volume, offset: offset) + parse(hapticsData: hapticsData, audioEvent: audioEvent) + } + + private func parse(hapticsData: PatternData, audioEvent: CHHapticEvent?) { discreteLine.reset() continuousLine.reset() + hasSound = audioEvent != nil let intensityCurveLine = continuousLine.intensityCurveLine let sharpnessCurveLine = continuousLine.sharpnessCurveLine @@ -65,9 +76,10 @@ public class PatternComposer: NSObject { continuousPlayerId = engine.createPlayer(pattern: pattern) } - if (!discreteLine.getEvents.isEmpty) { + let discreteEvents = discreteLine.getEvents + (audioEvent.map { [$0] } ?? []) + if (!discreteEvents.isEmpty) { let pattern = try CHHapticPattern( - events: discreteLine.getEvents, + events: discreteEvents, parameters: [] ) discretePattern = pattern @@ -85,8 +97,23 @@ public class PatternComposer: NSObject { self.play() } + private func makeAudioEvent(uri: String, volume: Float, offset: Double) -> CHHapticEvent? { + guard let url = PatternComposer.resolveSoundURL(uri) else { + print("Pulsar: could not resolve sound uri: \(uri)") + return nil + } + guard let resourceID = engine.registerAudioResource(url: url) else { return nil } + return CHHapticEvent( + audioResourceID: resourceID, + parameters: [CHHapticEventParameter(parameterID: .audioVolume, value: volume)], + relativeTime: max(0, offset) / 1000.0 + ) + } + @objc public func play() { - audioSimulator.play(buffer: audioBuffer) + if !hasSound { + audioSimulator.play(buffer: audioBuffer) + } if let id = continuousPlayerId { engine.playPlayer(id: id, pattern: continuousPattern) } if let id = discretePlayerId { engine.playPlayer(id: id, pattern: discretePattern) } } @@ -101,6 +128,15 @@ public class PatternComposer: NSObject { if let id = discretePlayerId { engine.stopPlayer(id: id) } } + static func resolveSoundURL(_ uri: String) -> URL? { + if uri.hasPrefix("file://") { return URL(string: uri) } + if FileManager.default.fileExists(atPath: uri) { return URL(fileURLWithPath: uri) } + let ns = uri as NSString + let name = ns.deletingPathExtension + let ext = ns.pathExtension.isEmpty ? "wav" : ns.pathExtension + return Bundle.main.url(forResource: name, withExtension: ext) + } + @objc public func dispose() { stop() if let id = continuousPlayerId { engine.removePlayer(id: id) } @@ -110,5 +146,6 @@ public class PatternComposer: NSObject { continuousPattern = nil discretePattern = nil audioBuffer = nil + hasSound = false } } diff --git a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift index db92678a..f10543a2 100644 --- a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift +++ b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift @@ -66,6 +66,19 @@ public extension HapticEngineWrapper { return registerPlayer(player) } + func registerAudioResource(url: URL) -> CHHapticAudioResourceID? { + bootstrapAppLifecycleTrackingIfNeeded() + guard canPlayHaptics() else { return nil } + + startEngine() + do { + return try engine?.registerAudioResource(url, options: [:]) + } catch { + print("Error registering audio resource: \(error.localizedDescription)") + return nil + } + } + func getRealtimePlayer() -> CHHapticAdvancedPatternPlayer? { bootstrapAppLifecycleTrackingIfNeeded() guard canPlayHaptics() else { return nil } diff --git a/iOS/Pulsar/Tests/PulsarTests/ComposerAndAudioTests.swift b/iOS/Pulsar/Tests/PulsarTests/ComposerAndAudioTests.swift index 9428e7cd..222e1767 100644 --- a/iOS/Pulsar/Tests/PulsarTests/ComposerAndAudioTests.swift +++ b/iOS/Pulsar/Tests/PulsarTests/ComposerAndAudioTests.swift @@ -71,3 +71,61 @@ import Foundation #expect((buffer?.frameLength ?? 0) > 0) } } + +/// `PatternComposer.resolveSoundURL` maps a JS/native-supplied sound uri to a local +/// file URL for Core Haptics audio-resource registration. Pure string/file logic — +/// no engine or hardware needed. +@Suite struct SoundURLResolutionTests { + + @Test func passesThroughFileScheme() { + let url = PatternComposer.resolveSoundURL("file:///tmp/pulsar/boom.wav") + #expect(url?.absoluteString == "file:///tmp/pulsar/boom.wav") + } + + @Test func resolvesAnExistingAbsolutePath() { + let path = NSTemporaryDirectory() + "pulsar-\(UUID().uuidString).caf" + FileManager.default.createFile(atPath: path, contents: Data([0x00, 0x01])) + defer { try? FileManager.default.removeItem(atPath: path) } + + let url = PatternComposer.resolveSoundURL(path) + #expect(url?.isFileURL == true) + #expect(url?.path == path) + } + + @Test func returnsNilForAnUnresolvableName() { + // Not a file path and not a bundled resource -> nil (caller falls back to haptics-only). + #expect(PatternComposer.resolveSoundURL("pulsar-missing-\(UUID().uuidString)") == nil) + } +} + +/// `PatternComposer.parsePatternWithSound` must degrade gracefully: on the simulator +/// there is no Core Haptics, and a missing/unregisterable sound file must leave a +/// plain haptics parse behind without crashing. +@MainActor +@Suite struct PatternComposerSoundTests { + + private let pattern = PatternData( + line: [ + [[0, 1.0], [100, 0.0]], + [[0, 0.5]], + ], + bar: [[0, 1.0, 0.3]] + ) + + @Test func parsePatternWithSoundIsSafeWhenFileMissing() { + let composer = Pulsar().getPatternComposer() + composer.parsePatternWithSound(hapticsData: pattern, uri: "pulsar-missing-sound.caf", volume: 0.8, offset: 20) + composer.play() + composer.playAudioOnly() + composer.stop() + composer.dispose() + } + + @Test func parsePatternWithSoundAppliesDefaultVolumeAndOffset() { + // Swift default params (volume = 1, offset = 0) must be callable with just a uri. + let composer = Pulsar().getPatternComposer() + composer.parsePatternWithSound(hapticsData: pattern, uri: "pulsar-missing-sound.caf") + composer.play() + composer.dispose() + } +} diff --git a/iOS/PulsarApp/PulsarApp/APITestingView.swift b/iOS/PulsarApp/PulsarApp/APITestingView.swift index cef93ed0..3cfc83a9 100644 --- a/iOS/PulsarApp/PulsarApp/APITestingView.swift +++ b/iOS/PulsarApp/PulsarApp/APITestingView.swift @@ -17,6 +17,7 @@ struct APITestingView: View { @State private var stopHapticsStatus = "" @State private var shutdownStatus = "" @State private var patternParsedStatus = "" + @State private var patternSoundStatus = "" @State private var patternPlayingStatus = "" var body: some View { @@ -159,7 +160,36 @@ struct APITestingView: View { } } ) - + + APIButtonRow( + title: "Parse Pattern + Sound", + statusMessage: patternSoundStatus, + action: { + let discretePattern: [DiscretePoint] = [ + DiscretePoint(time: 0.0, amplitude: 1.0, frequency: 0.8), + DiscretePoint(time: 0.2, amplitude: 0.7, frequency: 0.5), + ] + let data = PatternData( + continuousPattern: ContinuousPattern(amplitude: [], frequency: []), + discretePattern: discretePattern + ) + + if patternComposer == nil { + patternComposer = pulsar.getPatternComposer() + } + // Play a short sound in sync with the haptics. A bare name + // defaults to `.wav`, so add `beep.wav` to the app target's + // Copy Bundle Resources; a missing file degrades to + // haptics-only. Requires a real device (no Core Haptics + // on the simulator). Call play() afterwards. + patternComposer?.parsePatternWithSound(hapticsData: data, uri: "beep") + patternSoundStatus = "Pattern + sound parsed" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + patternSoundStatus = "" + } + } + ) + APIButtonRow( title: "Play Pattern", statusMessage: patternPlayingStatus, diff --git a/kmp/Pulsar/library/src/androidMain/kotlin/com/swmansion/pulsar/AndroidPulsarFactory.kt b/kmp/Pulsar/library/src/androidMain/kotlin/com/swmansion/pulsar/AndroidPulsarFactory.kt index cf2c8c9f..a05a90b7 100644 --- a/kmp/Pulsar/library/src/androidMain/kotlin/com/swmansion/pulsar/AndroidPulsarFactory.kt +++ b/kmp/Pulsar/library/src/androidMain/kotlin/com/swmansion/pulsar/AndroidPulsarFactory.kt @@ -8,6 +8,7 @@ import com.swmansion.pulsar.types.PatternData as AndroidPatternData import com.swmansion.pulsar.types.ContinuousPattern as AndroidContinuousPattern import com.swmansion.pulsar.types.ConfigPoint as AndroidConfigPoint import com.swmansion.pulsar.types.ValuePoint as AndroidValuePoint +import com.swmansion.pulsar.types.SoundData as AndroidSoundData import com.swmansion.pulsar.types.RealtimeComposable as AndroidRealtimeComposer import com.swmansion.pulsar.types.RealtimeComposerStrategy as AndroidRealtimeComposerStrategy import com.swmansion.pulsar.types.CompatibilityMode as AndroidCompatibilityMode @@ -336,6 +337,10 @@ private class AndroidPatternComposerHandle( composer.parsePattern(pattern.toAndroidPatternData()) } + override fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { + composer.parsePatternWithSound(pattern.toAndroidPatternData(), sound.toAndroidSoundData()) + } + override fun playPattern(pattern: PatternData) { composer.parsePattern(pattern.toAndroidPatternData()) composer.play() @@ -366,6 +371,10 @@ private class AndroidRealtimeComposerHandle( override fun isActive(): Boolean = composer.isActive() } +private fun SoundData.toAndroidSoundData(): AndroidSoundData { + return AndroidSoundData(uri = uri, volume = volume, offset = offset) +} + private fun PatternData.toAndroidPatternData(): AndroidPatternData { return AndroidPatternData( continuousPattern = AndroidContinuousPattern( diff --git a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarControllers.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarControllers.kt index 1aed75fb..5f13583a 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarControllers.kt +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarControllers.kt @@ -829,6 +829,10 @@ class PatternComposer internal constructor( handle.parsePattern(pattern) } + fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { + handle.parsePatternWithSound(pattern, sound) + } + fun playPattern(pattern: PatternData) { handle.playPattern(pattern) } diff --git a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarRuntime.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarRuntime.kt index 79bac94a..62923e81 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarRuntime.kt +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarRuntime.kt @@ -261,6 +261,7 @@ interface PulsarPresetsHandle { interface PatternComposerHandle { fun parsePattern(pattern: PatternData) + fun parsePatternWithSound(pattern: PatternData, sound: SoundData) fun playPattern(pattern: PatternData) fun play() fun playAudioOnly() diff --git a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/Types.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/Types.kt index 76de6d8c..8136abe4 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/Types.kt +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/Types.kt @@ -36,6 +36,12 @@ data class ContinuousPattern( val frequency: List, ) +data class SoundData( + val uri: String, + val volume: Float = 1f, + val offset: Long = 0L, +) + data class PatternData( val continuousPattern: ContinuousPattern, val discretePattern: List, diff --git a/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PulsarFacadeTest.kt b/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PulsarFacadeTest.kt index a8e414db..5b8f3e00 100644 --- a/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PulsarFacadeTest.kt +++ b/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PulsarFacadeTest.kt @@ -43,6 +43,10 @@ class PulsarFacadeTest { discretePattern = listOf(ConfigPoint(0, 1f, 0.4f)), ), ) + composer.parsePatternWithSound( + PatternData(), + SoundData(uri = "beep.ogg", volume = 0.5f, offset = 20L), + ) composer.play() composer.playPattern(PatternData()) composer.playAudioOnly() @@ -62,6 +66,7 @@ class PulsarFacadeTest { assertTrue(factory.handle.cacheCleared) assertTrue(factory.handle.hapticsStopped) assertTrue(factory.handle.patternParsed) + assertEquals(SoundData(uri = "beep.ogg", volume = 0.5f, offset = 20L), factory.handle.patternSound) assertTrue(factory.handle.patternPlayed) assertTrue(factory.handle.audioPlayed) assertTrue(factory.handle.patternStopped) @@ -171,6 +176,7 @@ private class FakeHandle : PulsarPlatformHandle { } val patternParsed get() = patternHandle.parsed + val patternSound get() = patternHandle.soundParsed val patternPlayed get() = patternHandle.played val audioPlayed get() = patternHandle.audioOnlyPlayed val patternStopped get() = patternHandle.stopped @@ -404,11 +410,17 @@ private class FakePatternHandle : PatternComposerHandle { var played = false var audioOnlyPlayed = false var stopped = false + var soundParsed: SoundData? = null override fun parsePattern(pattern: PatternData) { parsed = true } + override fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { + parsed = true + soundParsed = sound + } + override fun playPattern(pattern: PatternData) { parsed = true played = true diff --git a/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/composers/PatternComposer.kt b/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/composers/PatternComposer.kt index 6d2a669f..1d9f94be 100644 --- a/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/composers/PatternComposer.kt +++ b/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/composers/PatternComposer.kt @@ -2,6 +2,7 @@ package com.swmansion.pulsar.kmp.iosimpl.composers import com.swmansion.pulsar.kmp.PatternComposerHandle import com.swmansion.pulsar.kmp.PatternData +import com.swmansion.pulsar.kmp.SoundData import com.swmansion.pulsar.kmp.iosimpl.audio.IOSAudioBuffer import com.swmansion.pulsar.kmp.iosimpl.audio.IOSAudioSimulator import com.swmansion.pulsar.kmp.iosimpl.haptics.IOSContinuousLine @@ -11,10 +12,14 @@ import com.swmansion.pulsar.kmp.iosimpl.haptics.log import kotlinx.cinterop.ExperimentalForeignApi import platform.CoreHaptics.CHHapticEvent import platform.CoreHaptics.CHHapticEventParameter +import platform.CoreHaptics.CHHapticEventParameterIDAudioVolume import platform.CoreHaptics.CHHapticEventParameterIDHapticIntensity import platform.CoreHaptics.CHHapticEventParameterIDHapticSharpness import platform.CoreHaptics.CHHapticEventTypeHapticContinuous import platform.CoreHaptics.CHHapticPattern +import platform.Foundation.NSBundle +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL @OptIn(ExperimentalForeignApi::class) internal class IOSPatternComposerHandle( @@ -28,10 +33,20 @@ internal class IOSPatternComposerHandle( private var continuousPattern: CHHapticPattern? = null private var discretePattern: CHHapticPattern? = null private var audioBuffer: IOSAudioBuffer? = null + private var hasSound = false override fun parsePattern(pattern: PatternData) { + parse(pattern, audioEvent = null) + } + + override fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { + parse(pattern, audioEvent = makeAudioEvent(sound)) + } + + private fun parse(pattern: PatternData, audioEvent: CHHapticEvent?) { discreteLine.reset() continuousLine.reset() + hasSound = audioEvent != null val intensityCurveLine = continuousLine.intensityCurveLine val sharpnessCurveLine = continuousLine.sharpnessCurveLine @@ -70,9 +85,10 @@ internal class IOSPatternComposerHandle( continuousPlayerId = null } - if (discreteLine.getEvents.isNotEmpty()) { + val discreteEvents = discreteLine.getEvents + listOfNotNull(audioEvent) + if (discreteEvents.isNotEmpty()) { val patternToPlay = CHHapticPattern( - events = discreteLine.getEvents, + events = discreteEvents, parameters = emptyList(), error = null, ) @@ -89,13 +105,37 @@ internal class IOSPatternComposerHandle( audioBuffer = audioSimulator.parsePattern(pattern) } + private fun makeAudioEvent(sound: SoundData): CHHapticEvent? { + val url = resolveSoundURL(sound.uri) ?: run { + log("could not resolve sound uri: ${sound.uri}") + return null + } + val resourceId = engine.registerAudioResource(url) ?: return null + return CHHapticEvent( + audioResourceID = resourceId, + parameters = listOf( + CHHapticEventParameter(CHHapticEventParameterIDAudioVolume, sound.volume), + ), + relativeTime = maxOf(0L, sound.offset).toDouble() / 1000.0, + ) + } + + private fun resolveSoundURL(uri: String): NSURL? { + if (uri.startsWith("file://")) return NSURL(string = uri) + if (NSFileManager.defaultManager.fileExistsAtPath(uri)) return NSURL.fileURLWithPath(uri) + val dotIndex = uri.lastIndexOf('.') + val name = if (dotIndex > 0) uri.substring(0, dotIndex) else uri + val ext = if (dotIndex > 0) uri.substring(dotIndex + 1) else "wav" + return NSBundle.mainBundle.URLForResource(name, withExtension = ext) + } + override fun playPattern(pattern: PatternData) { parsePattern(pattern) play() } override fun play() { - audioSimulator.play(audioBuffer) + if (!hasSound) audioSimulator.play(audioBuffer) continuousPlayerId?.let { engine.playPlayer(it, continuousPattern) } discretePlayerId?.let { engine.playPlayer(it, discretePattern) } } @@ -119,5 +159,6 @@ internal class IOSPatternComposerHandle( continuousPattern = null discretePattern = null audioBuffer = null + hasSound = false } } diff --git a/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/haptics/HapticEngineWrapper.kt b/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/haptics/HapticEngineWrapper.kt index 9b712837..856c368c 100644 --- a/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/haptics/HapticEngineWrapper.kt +++ b/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/haptics/HapticEngineWrapper.kt @@ -4,6 +4,7 @@ import com.swmansion.pulsar.kmp.Pulsar import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ObjCAction import platform.CoreHaptics.CHHapticAdvancedPatternPlayerProtocol +import platform.CoreHaptics.CHHapticAudioResourceID import platform.CoreHaptics.CHHapticEngine import platform.CoreHaptics.CHHapticEvent import platform.CoreHaptics.CHHapticEventParameter @@ -13,6 +14,7 @@ import platform.CoreHaptics.CHHapticEventTypeHapticContinuous import platform.CoreHaptics.CHHapticPattern import platform.CoreHaptics.CHHapticPatternPlayerProtocol import platform.Foundation.NSLog +import platform.Foundation.NSURL import platform.Foundation.NSNotification import platform.Foundation.NSNotificationCenter import platform.UIKit.UIApplication @@ -75,6 +77,18 @@ internal class IOSHapticEngineWrapper { return registerPlayer(player) } + fun registerAudioResource(url: NSURL): CHHapticAudioResourceID? { + if (!canPlayHaptics()) return null + startEngine() + // CoreHaptics binds this NSURL parameter as the forward-declared + // `objcnames.classes.NSURL`; both are the same ObjC class at runtime. + @Suppress("UNCHECKED_CAST", "CAST_NEVER_SUCCEEDS") + val id = runCatching { + engine?.registerAudioResource(url as objcnames.classes.NSURL, mapOf(), null) + }.onFailure { log("Error registering audio resource: ${it.message}") }.getOrNull() + return if (id == null || id.toLong() == 0L) null else id + } + fun getRealtimePlayer(): CHHapticAdvancedPatternPlayerProtocol? { if (!canPlayHaptics()) return null startEngine() diff --git a/kmp/PulsarApp/composeApp/src/commonMain/kotlin/com/swmansion/pulsar/kmp/app/App.kt b/kmp/PulsarApp/composeApp/src/commonMain/kotlin/com/swmansion/pulsar/kmp/app/App.kt index 80bd5780..4271c21f 100644 --- a/kmp/PulsarApp/composeApp/src/commonMain/kotlin/com/swmansion/pulsar/kmp/app/App.kt +++ b/kmp/PulsarApp/composeApp/src/commonMain/kotlin/com/swmansion/pulsar/kmp/app/App.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.swmansion.pulsar.kmp.ConfigPoint import com.swmansion.pulsar.kmp.ContinuousPattern import com.swmansion.pulsar.kmp.PatternData +import com.swmansion.pulsar.kmp.SoundData import com.swmansion.pulsar.kmp.Pulsar import com.swmansion.pulsar.kmp.ValuePoint @@ -105,6 +106,23 @@ fun App() { ) { Text("Play custom pattern") } + Button( + onClick = { + // A short sound synchronized with the haptics. A bare name + // defaults to `.wav`, so bundle `beep.wav` natively — iOS: + // in the app bundle; Android: in `res/raw` (use an explicit + // `beep.ogg` with baked haptic channels for coupled sync). + // Missing files degrade gracefully to haptics-only. + pulsar?.getPatternComposer()?.apply { + parsePatternWithSound(demoPattern(), SoundData(uri = "beep")) + play() + } + status = "Played a haptic pattern with a synchronized sound." + }, + enabled = pulsar != null, + ) { + Text("Play pattern + sound") + } } } Card(modifier = Modifier.fillMaxWidth()) { diff --git a/react-native/PulsarApp/src/screens/PublicApisScreen.tsx b/react-native/PulsarApp/src/screens/PublicApisScreen.tsx index 44e51ad3..d41d48ef 100644 --- a/react-native/PulsarApp/src/screens/PublicApisScreen.tsx +++ b/react-native/PulsarApp/src/screens/PublicApisScreen.tsx @@ -127,6 +127,30 @@ export default function PublicApisScreen() { setLastAction(`Pattern parsed with ID: ${patternId}`); }; + const handleParsePatternWithSound = () => { + const pattern: Pattern = { + discretePattern: [ + { time: 0, amplitude: 1.0, frequency: 0.5 }, + { time: 200, amplitude: 0.8, frequency: 0.7 }, + ], + continuousPattern: { amplitude: [], frequency: [] }, + }; + + // A short sound synchronized with the haptics. A bare name defaults to `.wav`, + // so bundle `beep.wav` natively — iOS: in the app target; Android: in `res/raw`. + // (Use an explicit `beep.ogg` with baked haptic channels for coupled sync on + // Android.) In a real app you can also pass `require('./beep.wav')` through the + // `usePatternComposer` hook. Missing files degrade gracefully to haptics-only. + const patternId = PulsarNative.PatternComposer_parsePatternWithSound( + pattern, + 'beep', + 1.0, // volume + 0, // offset (ms) + ); + setPatternIds([...patternIds, patternId]); + setLastAction(`Pattern + sound parsed with ID: ${patternId}`); + }; + const handlePlayPattern = () => { if (patternIds.length === 0) { setLastAction('No patterns available. Parse a pattern first.'); @@ -264,6 +288,12 @@ export default function PublicApisScreen() { status={`${patternIds.length} pattern(s) parsed`} /> + + { - const newPatternId = Pulsar.PatternComposer_parsePattern(pattern); + const resolvedUri = pattern.sound ? resolveSoundUri(pattern.sound.uri) : undefined; + let newPatternId: number; + if (pattern.sound && resolvedUri) { + const { volume = 1, offset = 0 } = pattern.sound; + newPatternId = Pulsar.PatternComposer_parsePatternWithSound(pattern, resolvedUri, volume, offset); + } else { + newPatternId = Pulsar.PatternComposer_parsePattern(pattern); + } patternId.set(newPatternId); }, [pattern]);