From bb276118ec86478bea85467ffecaac912cdd1bd0 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Fri, 7 Aug 2026 20:32:07 +0200 Subject: [PATCH 1/6] platform implementation --- .../pulsar/audio/AudioHapticPlayer.kt | 111 ++++++++++++++++++ .../pulsar/composers/PatternComposer.kt | 42 ++++++- .../pulsar/haptics/HapticEngineWrapper.kt | 6 + .../java/com/swmansion/pulsar/types/Points.kt | 15 +++ .../Pulsar/Composers/PatternComposer.swift | 43 ++++++- .../Sources/Pulsar/HapticEngineWrapper.swift | 13 ++ 6 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 Android/Pulsar/src/main/java/com/swmansion/pulsar/audio/AudioHapticPlayer.kt 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..34b934b6 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, @@ -18,6 +20,9 @@ class PatternComposer( 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 +41,51 @@ class PatternComposer( audioBuffer = audioSimulator.parsePattern(hapticsData) } + fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData) { + parsePattern(hapticsData) + + soundPlayer?.release() + useCoupledHaptics = sound.hapticChannels && 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..2faf7e2b 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,21 @@ data class ContinuousPattern( val frequency: List, ) +/** + * @param uri absolute file path, `file://` uri, or the name of a resource in `res/raw`. + * @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; when false the file is + * treated as plain audio and Pulsar's own haptics are always played alongside it. + */ +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/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift index 4b686034..4392fb40 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) } } + private 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 ? "caf" : 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 } From 98a20656ae1d9a1db0854dd8393c964ff9a22f7d Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Fri, 7 Aug 2026 21:12:36 +0200 Subject: [PATCH 2/6] more extensions support --- .../pulsar/composers/PatternComposer.kt | 10 +++++- .../java/com/swmansion/pulsar/types/Points.kt | 8 +++-- .../pulsar/reactnative/PulsarModule.kt | 33 ++++++++++++++++++- 3 files changed, 46 insertions(+), 5 deletions(-) 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 34b934b6..4e157548 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 @@ -45,7 +45,10 @@ class PatternComposer( parsePattern(hapticsData) soundPlayer?.release() - useCoupledHaptics = sound.hapticChannels && engine.supportsAudioCoupledHaptics() + + val isNonOggFile = isKnownNonOggUri(sound.uri) + useCoupledHaptics = sound.hapticChannels && !isNonOggFile && engine.supportsAudioCoupledHaptics() + soundPlayer = AudioHapticPlayer( context = engine.getContext(), sound = sound, @@ -53,6 +56,11 @@ class PatternComposer( ).also { it.load() } } + private fun isKnownNonOggUri(uri: String): Boolean { + val extension = uri.substringAfterLast('.', "").lowercase() + return extension.isNotEmpty() && extension != "ogg" + } + fun play() { val player = soundPlayer if (player != null) { 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 2faf7e2b..da881e90 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 @@ -41,9 +41,11 @@ data class ContinuousPattern( * @param uri absolute file path, `file://` uri, or the name of a resource in `res/raw`. * @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; when false the file is - * treated as plain audio and Pulsar's own haptics are always played alongside it. + * @param hapticChannels whether the file carries baked haptic channels (only `.ogg` + * can). When true (default) the coupled path is used on supported devices; when + * false the file is treated as plain audio and Pulsar's own haptics are always + * played alongside it. A uri with a known non-`.ogg` extension (e.g. `.wav`/`.mp3`) + * is auto-detected and forced onto the fallback regardless of this flag. */ data class SoundData( val uri: String, diff --git a/react-native/react-native-pulsar/android/src/main/java/com/swmansion/pulsar/reactnative/PulsarModule.kt b/react-native/react-native-pulsar/android/src/main/java/com/swmansion/pulsar/reactnative/PulsarModule.kt index 39e75634..4c7be601 100644 --- a/react-native/react-native-pulsar/android/src/main/java/com/swmansion/pulsar/reactnative/PulsarModule.kt +++ b/react-native/react-native-pulsar/android/src/main/java/com/swmansion/pulsar/reactnative/PulsarModule.kt @@ -11,6 +11,7 @@ import com.swmansion.pulsar.types.RealtimeComposerStrategy 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 @ReactModule(name = PulsarModule.NAME) @@ -180,6 +181,36 @@ class PulsarModule(reactContext: ReactApplicationContext) : return currentId.toDouble() } + override fun PatternComposer_parsePatternWithSound( + data: ReadableMap?, + uri: String?, + volume: Double, + offset: Double + ): Double { + val patternComposer = pulsar.getPatternComposer() + + data?.let { + val patternData = patternDataFromJSPattern(it) + if (uri != null) { + patternComposer.parsePatternWithSound( + patternData, + SoundData( + uri = uri, + volume = volume.toFloat(), + offset = offset.toLong() + ) + ) + } else { + patternComposer.parsePattern(patternData) + } + } + + val currentId = nextId + nextId++ + patternComposersRegistry[currentId] = patternComposer + return currentId.toDouble() + } + override fun PatternComposer_play(patternId: Double) { patternComposersRegistry[patternId.toInt()]?.play() } @@ -189,7 +220,7 @@ class PulsarModule(reactContext: ReactApplicationContext) : } override fun PatternComposer_release(patternId: Double) { - patternComposersRegistry.remove(patternId.toInt()) + patternComposersRegistry.remove(patternId.toInt())?.release() } // RealtimeComposer ----------------------------------------------------------------- From 6a2fd565413e1c6c2d8fab31cc6959afe3d26dc0 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Fri, 7 Aug 2026 21:12:50 +0200 Subject: [PATCH 3/6] React Native support --- .../react-native-pulsar/ios/Haptics.mm | 18 ++++++++++ .../react-native-pulsar/src/NativeRNPulsar.ts | 1 + react-native/react-native-pulsar/src/types.ts | 34 ++++++++++++++++++- .../src/usePatternComposer.ts | 21 ++++++++++-- 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/react-native/react-native-pulsar/ios/Haptics.mm b/react-native/react-native-pulsar/ios/Haptics.mm index 9f5025b5..14c5e7c4 100644 --- a/react-native/react-native-pulsar/ios/Haptics.mm +++ b/react-native/react-native-pulsar/ios/Haptics.mm @@ -165,6 +165,24 @@ - (nonnull NSNumber *)PatternComposer_parsePattern:(JS::NativeRNPulsar::Pattern return @(currentId); } +- (nonnull NSNumber *)PatternComposer_parsePatternWithSound:(JS::NativeRNPulsar::Pattern &)data + uri:(nonnull NSString *)uri + volume:(double)volume + offset:(double)offset { + auto patternComposer = [pulsar_ getPatternComposer]; + + PatternData *patternData = PatternDataFromJSPattern(data); + [patternComposer parsePatternWithSoundWithHapticsData:patternData + uri:uri + volume:(float)volume + offset:offset]; + + int currentId = nextId; + nextId++; + patternComposersRegistry_[@(currentId)] = patternComposer; + return @(currentId); +} + - (void)PatternComposer_play:(double)patternId { if (!RNPulsarIsAppActive()) { return; diff --git a/react-native/react-native-pulsar/src/NativeRNPulsar.ts b/react-native/react-native-pulsar/src/NativeRNPulsar.ts index 255fc459..741ff599 100644 --- a/react-native/react-native-pulsar/src/NativeRNPulsar.ts +++ b/react-native/react-native-pulsar/src/NativeRNPulsar.ts @@ -42,6 +42,7 @@ export interface Spec extends TurboModule { RealtimeComposer_playDiscrete(amplitude: number, frequency: number): void; PatternComposer_parsePattern(data: Pattern): number; + PatternComposer_parsePatternWithSound(data: Pattern, uri: string, volume: number, offset: number): number; PatternComposer_play(patternId: number): void; PatternComposer_stop(patternId: number): void; PatternComposer_release(patternId: number): void; diff --git a/react-native/react-native-pulsar/src/types.ts b/react-native/react-native-pulsar/src/types.ts index 0dcfb1c2..02bab0f4 100644 --- a/react-native/react-native-pulsar/src/types.ts +++ b/react-native/react-native-pulsar/src/types.ts @@ -1,9 +1,41 @@ +/** + * A short authored sound played in sync with a haptic pattern. + * + * Platform notes: + * - iOS: `uri` must resolve to a **local** file (absolute path, `file://`, or a + * resource bundled in the app). It is registered as a Core Haptics audio event, + * so audio and haptics share the engine clock (sample-accurate). Remote URLs + * are not supported. + * - Android: `uri` is a path, `file://` uri, or the name of a resource in + * `res/raw`. Provide an `.ogg` with baked haptic channels for perfect, + * audio-coupled sync on supported devices. Any other format (`.wav`/`.mp3`), + * or a device without coupled-haptics support, automatically falls back to + * playing the audio while the pattern's own haptics fire in parallel. + */ +export type Sound = { + /** + * The audio source: either a Metro asset from `require('./boom.wav')`, or a uri + * string (a bundled resource name, an absolute path, or a `file://` uri). + * + * A `require()` is resolved automatically via `Image.resolveAssetSource`. Note + * it resolves to a **remote** Metro URL in dev but a local file in release — + * and synced playback needs a local file, so test `require()` sounds on a + * release build / device. + */ + uri: number | string, + /** Playback volume, 0.0–1.0. Defaults to 1.0. */ + volume?: number, + /** Shift of the audio relative to the haptics, in ms. Defaults to 0. */ + offset?: number, +} + export type Pattern = { discretePattern: { time: number, amplitude: number, frequency: number }[], continuousPattern: { amplitude: { time: number, value: number }[], frequency: { time: number, value: number }[], - } + }, + sound?: Sound, } export type PatternComposer = { diff --git a/react-native/react-native-pulsar/src/usePatternComposer.ts b/react-native/react-native-pulsar/src/usePatternComposer.ts index 13437b9c..76381a5c 100644 --- a/react-native/react-native-pulsar/src/usePatternComposer.ts +++ b/react-native/react-native-pulsar/src/usePatternComposer.ts @@ -1,11 +1,19 @@ import { useCallback, useEffect } from 'react'; +import { Image } from 'react-native'; import Pulsar from './NativeRNPulsar'; -import type { Pattern, PatternComposer } from './types'; +import type { Pattern, PatternComposer, Sound } from './types'; import { useSharableState } from './useSharableState'; -// workaround for RN prototype caching issue +// workaround for RN prototype caching issue Pulsar.PatternComposer_play; +function resolveSoundUri(uri: Sound['uri']): string | undefined { + if (typeof uri === 'number') { + return Image.resolveAssetSource(uri)?.uri; + } + return uri; +} + export default function usePatternComposer(pattern?: Pattern): PatternComposer { const patternId = useSharableState(-1); @@ -26,7 +34,14 @@ export default function usePatternComposer(pattern?: Pattern): PatternComposer { }, []); const parse = useCallback((pattern: Pattern) => { - 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]); From d4d9ba972dfe935807d0cef6837affec7058e53e Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Sat, 8 Aug 2026 09:36:50 +0200 Subject: [PATCH 4/6] flutter and kmp --- .../com/swmansion/pulsar/PulsarPlugin.kt | 26 ++++++++++ flutter/pulsar/ios/Classes/PulsarPlugin.swift | 23 +++++++++ flutter/pulsar/lib/pulsar_method_channel.dart | 17 +++++++ .../pulsar/lib/pulsar_platform_interface.dart | 8 ++++ flutter/pulsar/lib/pulsar_types.dart | 21 +++++++++ .../lib/src/pulsar_pattern_composer.dart | 10 ++++ .../swmansion/pulsar/AndroidPulsarFactory.kt | 9 ++++ .../com/swmansion/pulsar/PulsarControllers.kt | 4 ++ .../com/swmansion/pulsar/PulsarRuntime.kt | 1 + .../kotlin/com/swmansion/pulsar/Types.kt | 6 +++ .../iosimpl/composers/PatternComposer.kt | 47 +++++++++++++++++-- .../iosimpl/haptics/HapticEngineWrapper.kt | 14 ++++++ 12 files changed, 183 insertions(+), 3 deletions(-) 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 3b904c46..f60099d0 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..31f29837 100644 --- a/flutter/pulsar/lib/pulsar_types.dart +++ b/flutter/pulsar/lib/pulsar_types.dart @@ -181,3 +181,24 @@ 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. + 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/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/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..9be0a3bb 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 "caf" + 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() From 2c068f186de8574bdfe28fc20a143cfa46ce8ef7 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Mon, 10 Aug 2026 09:55:06 +0200 Subject: [PATCH 5/6] API update --- .../swmansion/pulsar/composers/PatternComposer.kt | 13 ++++++------- .../main/java/com/swmansion/pulsar/types/Points.kt | 12 +++++++----- .../Sources/Pulsar/Composers/PatternComposer.swift | 4 ++-- .../pulsar/iosimpl/composers/PatternComposer.kt | 2 +- react-native/react-native-pulsar/src/types.ts | 8 ++++---- 5 files changed, 20 insertions(+), 19 deletions(-) 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 4e157548..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 @@ -15,6 +15,11 @@ 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 @@ -46,8 +51,7 @@ class PatternComposer( soundPlayer?.release() - val isNonOggFile = isKnownNonOggUri(sound.uri) - useCoupledHaptics = sound.hapticChannels && !isNonOggFile && engine.supportsAudioCoupledHaptics() + useCoupledHaptics = sound.hapticChannels && isOggUri(sound.uri) && engine.supportsAudioCoupledHaptics() soundPlayer = AudioHapticPlayer( context = engine.getContext(), @@ -56,11 +60,6 @@ class PatternComposer( ).also { it.load() } } - private fun isKnownNonOggUri(uri: String): Boolean { - val extension = uri.substringAfterLast('.', "").lowercase() - return extension.isNotEmpty() && extension != "ogg" - } - fun play() { val player = soundPlayer if (player != null) { 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 da881e90..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 @@ -39,13 +39,15 @@ data class ContinuousPattern( /** * @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 (only `.ogg` - * can). When true (default) the coupled path is used on supported devices; when - * false the file is treated as plain audio and Pulsar's own haptics are always - * played alongside it. A uri with a known non-`.ogg` extension (e.g. `.wav`/`.mp3`) - * is auto-detected and forced onto the fallback regardless of this flag. + * @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, diff --git a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift index 4392fb40..31f4b4ee 100644 --- a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift +++ b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift @@ -128,12 +128,12 @@ public class PatternComposer: NSObject { if let id = discretePlayerId { engine.stopPlayer(id: id) } } - private static func resolveSoundURL(_ uri: String) -> URL? { + 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 ? "caf" : ns.pathExtension + let ext = ns.pathExtension.isEmpty ? "wav" : ns.pathExtension return Bundle.main.url(forResource: name, withExtension: ext) } 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 9be0a3bb..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 @@ -125,7 +125,7 @@ internal class IOSPatternComposerHandle( 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 "caf" + val ext = if (dotIndex > 0) uri.substring(dotIndex + 1) else "wav" return NSBundle.mainBundle.URLForResource(name, withExtension = ext) } diff --git a/react-native/react-native-pulsar/src/types.ts b/react-native/react-native-pulsar/src/types.ts index 02bab0f4..3fe0aa41 100644 --- a/react-native/react-native-pulsar/src/types.ts +++ b/react-native/react-native-pulsar/src/types.ts @@ -7,10 +7,10 @@ * so audio and haptics share the engine clock (sample-accurate). Remote URLs * are not supported. * - Android: `uri` is a path, `file://` uri, or the name of a resource in - * `res/raw`. Provide an `.ogg` with baked haptic channels for perfect, - * audio-coupled sync on supported devices. Any other format (`.wav`/`.mp3`), - * or a device without coupled-haptics support, automatically falls back to - * playing the audio while the pattern's own haptics fire in parallel. + * `res/raw`. An **explicit** `.ogg` with baked haptic channels gives perfect, + * audio-coupled sync on supported devices. Anything else — `.wav`/`.mp3`, a + * bare name (defaults to `.wav`), or a device without coupled-haptics support — + * plays the audio while the pattern's own haptics fire in parallel. */ export type Sound = { /** From 434abdd57d24d76da1302d7eb831bb5170a2389d Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Mon, 10 Aug 2026 09:55:19 +0200 Subject: [PATCH 6/6] Example app + tests --- .../pulsar/composers/AudioSyncUnitTest.kt | 67 +++++++++++++++++++ .../RobolectricAudioCoupledHapticsTest.kt | 52 ++++++++++++++ .../pulsarapp/screens/PublicAPIsScreen.kt | 26 +++++++ flutter/PulsarApp/lib/main.dart | 37 ++++++++++ flutter/pulsar/lib/pulsar_types.dart | 3 +- .../test/pulsar_method_channel_test.dart | 23 +++++++ .../PulsarTests/ComposerAndAudioTests.swift | 58 ++++++++++++++++ iOS/PulsarApp/PulsarApp/APITestingView.swift | 32 ++++++++- .../com/swmansion/pulsar/PulsarFacadeTest.kt | 12 ++++ .../com/swmansion/pulsar/kmp/app/App.kt | 18 +++++ .../src/screens/PublicApisScreen.tsx | 30 +++++++++ 11 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 Android/Pulsar/src/test/java/com/swmansion/pulsar/composers/AudioSyncUnitTest.kt create mode 100644 Android/Pulsar/src/test/java/com/swmansion/pulsar/haptics/RobolectricAudioCoupledHapticsTest.kt 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/lib/pulsar_types.dart b/flutter/pulsar/lib/pulsar_types.dart index 31f29837..182137c2 100644 --- a/flutter/pulsar/lib/pulsar_types.dart +++ b/flutter/pulsar/lib/pulsar_types.dart @@ -187,7 +187,8 @@ 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. + /// Local file path, `file://` uri, or bundled resource name (no extension needed + /// — defaults to `.wav`). final String uri; /// Playback volume, 0.0–1.0. 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/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/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/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`} /> + +