diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt index ae65977a..a172d5f9 100644 --- a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt @@ -35,16 +35,21 @@ class PresetHandle internal constructor( private var composer: PatternComposer? = null - private fun ensureParsed() { - if (composer == null) { - val c = haptics.getPatternComposer() - if (sound != null) c.parsePatternWithSound(pattern, sound) else c.parsePattern(pattern) - composer = c - } + private var parsedFromMs: Long? = null + + private fun ensureParsed(fromMs: Long) { + val alreadyParsedHere = composer != null && parsedFromMs == fromMs + if (alreadyParsedHere) return + val c = composer ?: haptics.getPatternComposer() + if (sound != null) c.parsePatternWithSound(pattern, sound, fromMs) else c.parsePattern(pattern, fromMs) + composer = c + parsedFromMs = fromMs } - fun play() { - ensureParsed() + /** Plays the preset from [fromMs] into its timeline, audio and haptics together. */ + @JvmOverloads + fun play(fromMs: Long = 0L) { + ensureParsed(maxOf(0L, fromMs)) composer?.play() } @@ -55,6 +60,7 @@ class PresetHandle internal constructor( internal fun dispose() { composer?.release() composer = null + parsedFromMs = null } } @@ -67,9 +73,10 @@ class LoadedBundle internal constructor( ) { fun handle(id: String): PresetHandle? = handles[id] val presetIds: List get() = handles.keys.toList() - fun play(id: String): Boolean { + @JvmOverloads + fun play(id: String, fromMs: Long = 0L): Boolean { val h = handles[id] ?: return false - h.play() + h.play(fromMs) return true } fun dispose() = handles.values.forEach { it.dispose() } diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md index bcf5de5c..2edf668a 100644 --- a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md @@ -9,6 +9,7 @@ autocomplete. val pulsar = Pulsar(context) val bundle = pulsar.loadBundleSync(AcmePack.descriptor) // AcmePack is generated bundle.heartbeatV2.play() // ← autocompletes +bundle.heartbeatV2.play(fromMs = 2500) // starts 2.5s in bundle.explosion.stop() // Animation bytes for the app's own Lottie view (Pulsar times, the app renders): @@ -41,4 +42,5 @@ pulsarBundles { val loaded = pulsar.loadBundle(bytes) // or loadBundle(path) / loadBundleFromAsset("pulsar/acme-pack.pulsar") loaded.presetIds // -> List loaded.play("heartbeatV2") // -> Boolean +loaded.play("heartbeatV2", fromMs = 2500) // seeks audio + haptics ``` 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 49748456..c4d9be87 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 @@ -7,6 +7,7 @@ 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.PatternSeek import com.swmansion.pulsar.types.SoundData class PatternComposer( @@ -28,34 +29,44 @@ class PatternComposer( private var soundPlayer: AudioHapticPlayer? = null private var useCoupledHaptics = false - fun parsePattern(hapticsData: PatternData) { + /** [fromMs] starts the pattern that far into its own timeline. */ + @JvmOverloads + fun parsePattern(hapticsData: PatternData, fromMs: Long = 0L) { + val seekedPattern = PatternSeek.patternFrom(hapticsData, fromMs) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrationEffect = try { - engine.getHapticBuilder().createVibrationEffect(hapticsData) + engine.getHapticBuilder().createVibrationEffect(seekedPattern) } catch (_: IllegalArgumentException) { - val message = "Skipping invalid haptic pattern after Android validation failure: ${summarizePattern(hapticsData)}" + val message = "Skipping invalid haptic pattern after Android validation failure: ${summarizePattern(seekedPattern)}" Log.w(TAG, message) null } if (vibrationEffect == null) { - val message = "Skipping invalid haptic pattern because it produced no playable vibration effect: ${summarizePattern(hapticsData)}" + val message = "Skipping invalid haptic pattern because it produced no playable vibration effect: ${summarizePattern(seekedPattern)}" Log.w(TAG, message) } } - audioBuffer = audioSimulator.parsePattern(hapticsData) + audioBuffer = audioSimulator.parsePattern(seekedPattern) } - fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData) { - parsePattern(hapticsData) + /** + * The sound's own `startMs`/`durationMs` are the authored trim window in the file; [fromMs] + * seeks the whole preset, moving audio and haptics together. + */ + @JvmOverloads + fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData, fromMs: Long = 0L) { + parsePattern(hapticsData, fromMs) + val seekedSound = PatternSeek.soundFrom(sound, fromMs) soundPlayer?.release() - useCoupledHaptics = sound.hapticChannels && isOggUri(sound.uri) && engine.supportsAudioCoupledHaptics() + useCoupledHaptics = + seekedSound.hapticChannels && isOggUri(seekedSound.uri) && engine.supportsAudioCoupledHaptics() soundPlayer = AudioHapticPlayer( context = engine.getContext(), - sound = sound, + sound = seekedSound, hapticChannelsMuted = !useCoupledHaptics, ).also { it.load() } } diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/PatternSeek.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/PatternSeek.kt new file mode 100644 index 00000000..e8b5fdc5 --- /dev/null +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/PatternSeek.kt @@ -0,0 +1,73 @@ +package com.swmansion.pulsar.types + +/** + * Re-anchors an authored pattern so playing it from zero feels like playing the original from + * `fromMs`. The composer only ever starts at zero. + */ +internal object PatternSeek { + fun interpolatedValueAt(points: List, atMs: Long): Float { + val first = points.firstOrNull() ?: return 0f + val last = points.last() + if (atMs <= first.time) return first.value + if (atMs >= last.time) return last.value + val nextIndex = points.indexOfFirst { it.time > atMs } + if (nextIndex <= 0) return last.value + val before = points[nextIndex - 1] + val after = points[nextIndex] + val span = after.time - before.time + if (span <= 0L) return after.value + return before.value + (after.value - before.value) * (atMs - before.time).toFloat() / span + } + + fun envelopeFrom(points: List, fromMs: Long, remainingMs: Long): List { + if (points.isEmpty()) return emptyList() + val valueAtSeek = ValuePoint(time = 0L, value = interpolatedValueAt(points, fromMs)) + val pointsAfterSeek = points + .filter { it.time > fromMs } + .map { ValuePoint(it.time - fromMs, it.value) } + if (pointsAfterSeek.isNotEmpty()) return listOf(valueAtSeek) + pointsAfterSeek + return holdingLastValue(valueAtSeek, remainingMs) + } + + /** + * Emptying an envelope would silence BOTH continuous channels — the composer builds that + * line only when the amplitude and frequency curves are each non-empty. + */ + private fun holdingLastValue(point: ValuePoint, remainingMs: Long): List = + if (remainingMs > 0L) listOf(point, ValuePoint(remainingMs, point.value)) else listOf(point) + + fun lastTimestampOf(pattern: PatternData): Long = maxOf( + pattern.discretePattern.maxOfOrNull { it.time } ?: 0L, + pattern.continuousPattern.amplitude.maxOfOrNull { it.time } ?: 0L, + pattern.continuousPattern.frequency.maxOfOrNull { it.time } ?: 0L, + ) + + fun patternFrom(pattern: PatternData, fromMs: Long): PatternData { + if (fromMs <= 0L) return pattern + val remainingMs = lastTimestampOf(pattern) - fromMs + return PatternData( + continuousPattern = ContinuousPattern( + amplitude = envelopeFrom(pattern.continuousPattern.amplitude, fromMs, remainingMs), + frequency = envelopeFrom(pattern.continuousPattern.frequency, fromMs, remainingMs), + ), + discretePattern = pattern.discretePattern + .filter { it.time >= fromMs } + .map { it.copy(time = it.time - fromMs) }, + ) + } + + /** + * Audio offset by [SoundData.offset] sits at file position `t - offset` when the haptics are + * at `t`, so a seek is spent on the lead-in first and only then on the file. + */ + fun soundFrom(sound: SoundData, fromMs: Long): SoundData { + val leadIn = maxOf(0L, sound.offset) + val seekIntoFile = maxOf(0L, fromMs - leadIn) + val playsToEndOfFile = sound.durationMs <= 0L + return sound.copy( + offset = maxOf(0L, leadIn - fromMs), + startMs = sound.startMs + seekIntoFile, + durationMs = if (playsToEndOfFile) 0L else maxOf(0L, sound.durationMs - seekIntoFile), + ) + } +} diff --git a/Android/Pulsar/src/test/java/com/swmansion/pulsar/types/PatternSeekTest.kt b/Android/Pulsar/src/test/java/com/swmansion/pulsar/types/PatternSeekTest.kt new file mode 100644 index 00000000..0d4f43ab --- /dev/null +++ b/Android/Pulsar/src/test/java/com/swmansion/pulsar/types/PatternSeekTest.kt @@ -0,0 +1,98 @@ +package com.swmansion.pulsar.types + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class PatternSeekTest { + + private val ramp = PatternData( + continuousPattern = ContinuousPattern( + amplitude = listOf(ValuePoint(0L, 0f), ValuePoint(1000L, 1f)), + frequency = listOf(ValuePoint(0L, 0.2f), ValuePoint(500L, 0.8f)), + ), + discretePattern = listOf( + ConfigPoint(0L, 1f, 0.5f), + ConfigPoint(400L, 0.8f, 0.4f), + ConfigPoint(1000L, 0.6f, 0.3f), + ), + ) + + @Test + fun `duration is the last timestamp across both lines`() { + assertEquals(1000L, PatternSeek.lastTimestampOf(ramp)) + val empty = PatternData(ContinuousPattern(emptyList(), emptyList()), emptyList()) + assertEquals(0L, PatternSeek.lastTimestampOf(empty)) + } + + @Test + fun `seeking to zero returns the same pattern`() { + assertSame(ramp, PatternSeek.patternFrom(ramp, 0L)) + assertSame(ramp, PatternSeek.patternFrom(ramp, -100L)) + } + + @Test + fun `discrete events before the seek are dropped and the rest rebased`() { + val seeked = PatternSeek.patternFrom(ramp, 400L) + assertEquals(listOf(0L, 600L), seeked.discretePattern.map { it.time }) + assertEquals(listOf(0.8f, 0.6f), seeked.discretePattern.map { it.amplitude }) + } + + @Test + fun `envelope is re-anchored on its interpolated value`() { + val seeked = PatternSeek.patternFrom(ramp, 250L) + assertEquals(listOf(0L, 750L), seeked.continuousPattern.amplitude.map { it.time }) + assertEquals(listOf(0.25f, 1f), seeked.continuousPattern.amplitude.map { it.value }) + } + + @Test + fun `an envelope entirely before the seek holds its last value`() { + val seeked = PatternSeek.patternFrom(ramp, 800L) + assertEquals(listOf(0L, 200L), seeked.continuousPattern.frequency.map { it.time }) + assertEquals(listOf(0.8f, 0.8f), seeked.continuousPattern.frequency.map { it.value }) + assertTrue(seeked.continuousPattern.amplitude.isNotEmpty()) + } + + @Test + fun `a held envelope collapses to one point once nothing remains`() { + val seeked = PatternSeek.patternFrom(ramp, 1000L) + assertEquals(listOf(ValuePoint(0L, 0.8f)), seeked.continuousPattern.frequency) + } + + @Test + fun `an empty envelope stays empty`() { + val noFrequency = PatternData( + ContinuousPattern(ramp.continuousPattern.amplitude, emptyList()), + emptyList(), + ) + assertTrue(PatternSeek.patternFrom(noFrequency, 250L).continuousPattern.frequency.isEmpty()) + } + + @Test + fun `sound seeks into the file by the same amount`() { + val seeked = PatternSeek.soundFrom(SoundData(uri = "clip.wav"), 300L) + assertEquals(0L, seeked.offset) + assertEquals(300L, seeked.startMs) + assertEquals(0L, seeked.durationMs) + } + + @Test + fun `sound eats into the lead-in before it touches the file`() { + val early = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 200L) + assertEquals(300L, early.offset) + assertEquals(0L, early.startMs) + + val late = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 800L) + assertEquals(0L, late.offset) + assertEquals(300L, late.startMs) + } + + @Test + fun `an authored trim window shrinks and its start advances`() { + val sound = SoundData(uri = "clip.wav", startMs = 1000L, durationMs = 900L) + val seeked = PatternSeek.soundFrom(sound, 400L) + assertEquals(1400L, seeked.startMs) + assertEquals(500L, seeked.durationMs) + } +} diff --git a/docs/src/content/docs/sdk/android.mdx b/docs/src/content/docs/sdk/android.mdx index a697f2fd..b808d552 100644 --- a/docs/src/content/docs/sdk/android.mdx +++ b/docs/src/content/docs/sdk/android.mdx @@ -595,9 +595,11 @@ val composer = pulsar.getPatternComposer() Parses a `PatternData` object and prepares it for playback. ```kotlin -fun parsePattern(hapticsData: PatternData) +fun parsePattern(hapticsData: PatternData, fromMs: Long = 0) ``` +`fromMs` starts the pattern that far into its own timeline. The engine can only play a parsed pattern from zero, so Pulsar re-anchors it instead: discrete events before the seek are dropped and the rest rebased, and each continuous envelope is re-anchored on its value at that instant. + #### `parsePatternWithSound(hapticsData, sound)` :::caution[Unreleased API] @@ -607,9 +609,11 @@ Synchronized sound is not part of the latest release (`1.3.0`). It is available Parses a pattern together with a short sound played in sync with the haptics. ```kotlin -fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData) +fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData, fromMs: Long = 0) ``` +`fromMs` seeks the whole preset: the haptics are re-anchored and the sound advances into the file by the same amount, so both move together. It composes with the sound's own `startMs`/`durationMs` trim window rather than replacing it. + On devices that support audio-coupled haptics, provide an **`.ogg`** whose baked haptic channels drive the vibrator for perfect, single-stream sync. Any other file — `.wav`/`.mp3`, or a bare name (which defaults to `.wav`) — plays the audio while the pattern's own generated `VibrationEffect` fires in parallel. See [`SoundData`](#sounddata) for `uri`, `volume`, `offset`, `hapticChannels`, and the `startMs`/`durationMs` trim window. #### `play()` @@ -784,10 +788,18 @@ val bundle = pulsar.loadBundleAsync(AcmePack.descriptor) Both check the packaged bundle's content hash against the generated types, so a stale APK asset fails loudly instead of quietly playing the wrong pattern. Pass `strict = false` to skip it. -Each `PresetHandle` exposes `id`, `name`, `duration`, `pattern`, `hasAudio`, `hasAnimation`, `play()`, `stop()`, and `animation` — the Lottie bytes +Each `PresetHandle` exposes `id`, `name`, `duration`, `pattern`, `hasAudio`, `hasAnimation`, `play(fromMs = 0)`, `stop()`, and `animation` — the Lottie bytes and timing for your own animation view. Pulsar carries and time-aligns the animation; the app renders it: +Pass `fromMs` to start a preset that far into its own timeline — audio and haptics seek together, so a progress bar can scrub it: + +```kotlin +bundle.heartbeatV2.play(fromMs = 2500) +``` + +Every non-zero seek re-parses the preset; playing from the start reuses the cached parse. + The [Lottie SDK](/pulsar/lottie/overview/) takes a `PresetHandle` directly and reads all of that for you — pattern, animation and duration — so you rarely have to unpack it by hand. ```kotlin diff --git a/docs/src/content/docs/sdk/flutter.mdx b/docs/src/content/docs/sdk/flutter.mdx index 16029287..16666a10 100644 --- a/docs/src/content/docs/sdk/flutter.mdx +++ b/docs/src/content/docs/sdk/flutter.mdx @@ -728,11 +728,20 @@ Audio authored into a preset plays through the native iOS/Android path alongside animation bytes are carried for your own Lottie view. Each `PresetHandle` exposes `id`, `name`, `duration`, `pattern`, `hasAudio`, `hasAnimation`, -`play()`, `stop()`, and `animation` — the Lottie `data`, `frameRate` and `totalFrames`, ready for +`play({fromMs})`, `stop()`, and `animation` — the Lottie `data`, `frameRate` and `totalFrames`, ready for `Lottie.memory`. That metadata is read back from the native bundle when it loads; pass `includeAnimations: false` to `loadBundleAsync` to skip the animation bytes when nothing will render them. +Pass `fromMs` to start a preset that far into its own timeline — audio and haptics seek together, +so a progress bar can scrub it: + +```dart +bundle.heartbeatV2.play(fromMs: 2500); +``` + +Every non-zero seek re-parses the preset; playing from the start reuses the cached parse. + The [Lottie SDK](/pulsar/lottie/overview/) takes a `PresetHandle` directly and reads all of that for you — pattern, animation and duration — so you rarely have to unpack it by hand. diff --git a/docs/src/content/docs/sdk/ios.mdx b/docs/src/content/docs/sdk/ios.mdx index a84f71d7..34b829bc 100644 --- a/docs/src/content/docs/sdk/ios.mdx +++ b/docs/src/content/docs/sdk/ios.mdx @@ -493,9 +493,11 @@ let composer = pulsar.getPatternComposer() Parses a `PatternData` object and prepares it for playback. ```swift -func parsePattern(hapticsData: PatternData) +func parsePattern(hapticsData: PatternData, fromMs: Double = 0) ``` +`fromMs` starts the pattern that far into its own timeline. The engine can only play a parsed pattern from zero, so Pulsar re-anchors it instead: discrete events before the seek are dropped and the rest rebased, and each continuous envelope is re-anchored on its value at that instant. + #### `parsePatternWithSound(hapticsData:uri:volume:offset:start:duration:)` :::caution[Unreleased API] @@ -511,7 +513,8 @@ func parsePatternWithSound( volume: Float = 1, offset: Double = 0, start: Double = 0, - duration: Double = 0 + duration: Double = 0, + fromMs: Double = 0 ) ``` @@ -519,6 +522,8 @@ func parsePatternWithSound( `start` and `duration` (both in milliseconds) trim the clip: `start` is where playback begins in the source file, `duration` is how much of it to play (`0` plays to the end). Core Haptics registers an audio resource by URL only, so when either is set the file is sliced to a temporary `.caf` first — the temp file is cleaned up on the next parse and on dispose. +`fromMs` is a different thing: it seeks the whole preset, re-anchoring the haptics and advancing into the file so both move together. A preset authored with an audio `offset` keeps its lead-in until the seek passes it. + #### `playPattern(hapticsData:)` Parses and immediately plays a pattern. Equivalent to calling `parsePattern` followed by `play`. @@ -679,10 +684,18 @@ let bundle = try await pulsar.loadBundleAsync(AcmePack.descriptor) The descriptor finds the `.pulsar` in the app's main bundle, and its content hash is checked against the generated types so a stale copy fails loudly. Pass `strict: false` to skip that. -Each `PresetHandle` exposes `id`, `name`, `duration`, `pattern`, `hasAudio`, `hasAnimation`, `play()`, `stop()`, and `animation` — the Lottie bytes +Each `PresetHandle` exposes `id`, `name`, `duration`, `pattern`, `hasAudio`, `hasAnimation`, `play()` / `play(fromMs:)`, `stop()`, and `animation` — the Lottie bytes and timing for your own animation view. Pulsar carries and time-aligns the animation; the app renders it: +Pass `fromMs` to start a preset that far into its own timeline — audio and haptics seek together, so a progress bar can scrub it: + +```swift +bundle.heartbeatV2.play(fromMs: 2500) +``` + +Every non-zero seek re-parses the preset; playing from the start reuses the cached parse. + The [Lottie SDK](/pulsar/lottie/overview/) takes a `PresetHandle` directly and reads all of that for you — pattern, animation and duration — so you rarely have to unpack it by hand. ```swift diff --git a/docs/src/content/docs/sdk/kmp.mdx b/docs/src/content/docs/sdk/kmp.mdx index 9ec3f47b..2887b136 100644 --- a/docs/src/content/docs/sdk/kmp.mdx +++ b/docs/src/content/docs/sdk/kmp.mdx @@ -417,9 +417,11 @@ val composer = pulsar.getPatternComposer() Parses a `PatternData` object and prepares it for playback. ```kotlin -fun parsePattern(pattern: PatternData) +fun parsePattern(pattern: PatternData, fromMs: Long = 0) ``` +`fromMs` starts the pattern that far into its own timeline. The engine can only play a parsed pattern from zero, so Pulsar re-anchors it instead: discrete events before the seek are dropped and the rest rebased, and each continuous envelope is re-anchored on its value at that instant. + #### `parsePatternWithSound(pattern, sound)` :::caution[Unreleased API] @@ -429,9 +431,11 @@ Synchronized sound is not part of the latest release (`0.1.0`). It is available Parses a pattern together with a short sound played in sync with the haptics. ```kotlin -fun parsePatternWithSound(pattern: PatternData, sound: SoundData) +fun parsePatternWithSound(pattern: PatternData, sound: SoundData, fromMs: Long = 0) ``` +`fromMs` seeks the whole preset: the haptics are re-anchored and the sound advances into the file by the same amount, so both move together. It composes with the sound's own `startMs`/`durationMs` trim window rather than replacing it. + On Android, an explicit `.ogg` with baked haptic channels enables audio-coupled sync on supported devices; any other file — or a bare name, which defaults to `.wav` — plays the audio while the pattern's own haptics fire in parallel. On iOS the sound is registered as a Core Haptics audio event on the pattern's timeline. See [`SoundData`](#sounddata). #### `playPattern(pattern)` @@ -687,16 +691,18 @@ a bundle kept there uses the `bytes` overload above. The loaded bundle's content hash is checked against the generated types; pass `strict = false` to skip it. -Each `PresetHandle` exposes `id`, `name`, `duration`, `pattern`, `hasAudio`, `hasAnimation`, `play()`, `stop()`, and `animation` — the Lottie bytes +Each `PresetHandle` exposes `id`, `name`, `duration`, `pattern`, `hasAudio`, `hasAnimation`, `play(fromMs = 0)`, `stop()`, and `animation` — the Lottie bytes and timing for your own animation view. -The [Lottie SDK](/pulsar/lottie/overview/) takes a `PresetHandle` directly and reads all of that for you — pattern, animation and duration — so you rarely have to unpack it by hand. +Pass `fromMs` to start a preset that far into its own timeline — audio and haptics seek together, so a progress bar can scrub it: -:::caution -KMP v1 plays a preset's haptics and carries its animation bytes, but **synced bundle audio is not -wired yet** (it needs platform temp-file extraction). Use the native iOS or Android SDK for packs -whose presets carry sound. -::: +```kotlin +bundle.heartbeatV2.play(fromMs = 2500) +``` + +Every non-zero seek re-parses the preset; playing from the start reuses the cached parse. + +The [Lottie SDK](/pulsar/lottie/overview/) takes a `PresetHandle` directly and reads all of that for you — pattern, animation and duration — so you rarely have to unpack it by hand. See [`kmp/PulsarApp`](https://github.com/software-mansion/pulsar/tree/main/kmp/PulsarApp) for a working screen. diff --git a/docs/src/content/docs/sdk/react-native.mdx b/docs/src/content/docs/sdk/react-native.mdx index 76cc4498..b390485d 100644 --- a/docs/src/content/docs/sdk/react-native.mdx +++ b/docs/src/content/docs/sdk/react-native.mdx @@ -446,6 +446,19 @@ One generated module drives both paths, so the types are identical either way. L twice gives two independent bundles; `dispose()` on one leaves the other working, and a disposed bundle is inert rather than silently re-parsing. +### Playing from a position + +Pass `fromMs` to start a preset that far into its own timeline, so a progress bar can scrub it: + +```ts +bundle.celebration.play(2500); // start 2.5s in +bundle.celebration.play(); // ...and from the top +``` + +The native side re-anchors the pattern and seeks the audio to match, so both move together. On the +embedded-pattern path there is no audio to move, so a seek there scrubs the haptics alone. Every +non-zero seek re-parses; playing from the start reuses the cached parse. + --- ## usePatternComposer diff --git a/flutter/PulsarLottie/test/fake_pulsar_platform.dart b/flutter/PulsarLottie/test/fake_pulsar_platform.dart index 99ceeb63..2d978da2 100644 --- a/flutter/PulsarLottie/test/fake_pulsar_platform.dart +++ b/flutter/PulsarLottie/test/fake_pulsar_platform.dart @@ -59,7 +59,11 @@ class RecordingPulsarPlatform extends PulsarPlatform { calls.add('patternRelease'); @override - Future playBundlePreset(String token, String presetId) async { + Future playBundlePreset( + String token, + String presetId, { + double fromMs = 0, + }) async { calls.add('playBundlePreset'); playedPresets.add(presetId); } diff --git a/flutter/pulsar/BUNDLES.md b/flutter/pulsar/BUNDLES.md index 6a43e2a5..ff0264dd 100644 --- a/flutter/pulsar/BUNDLES.md +++ b/flutter/pulsar/BUNDLES.md @@ -31,6 +31,7 @@ import 'bundles/acme_pack.bundle.dart'; final pulsar = Pulsar(); final bundle = await pulsar.loadBundleAsync(acmePack); // acmePack is generated bundle.heartbeatV2.play(); // ← autocompletes +bundle.heartbeatV2.play(fromMs: 2500); // starts 2.5s in bundle.explosion.stop(); ``` diff --git a/flutter/pulsar/android/src/main/kotlin/com/swmansion/pulsar/flutter/PulsarPlugin.kt b/flutter/pulsar/android/src/main/kotlin/com/swmansion/pulsar/flutter/PulsarPlugin.kt index 8ced49e8..e754ac79 100644 --- a/flutter/pulsar/android/src/main/kotlin/com/swmansion/pulsar/flutter/PulsarPlugin.kt +++ b/flutter/pulsar/android/src/main/kotlin/com/swmansion/pulsar/flutter/PulsarPlugin.kt @@ -124,7 +124,8 @@ class PulsarPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { if (token == null || presetId == null) { return result.error("INVALID_ARGS", "token/presetId required", null) } - bundles[token]?.handle(presetId)?.play() + val fromMs = call.argument("fromMs") ?: 0.0 + bundles[token]?.handle(presetId)?.play(fromMs.toLong()) result.success(null) } diff --git a/flutter/pulsar/ios/Classes/PulsarPlugin.swift b/flutter/pulsar/ios/Classes/PulsarPlugin.swift index 80f016a0..ee94fdef 100644 --- a/flutter/pulsar/ios/Classes/PulsarPlugin.swift +++ b/flutter/pulsar/ios/Classes/PulsarPlugin.swift @@ -90,7 +90,8 @@ public class PulsarPlugin: NSObject, FlutterPlugin { result(FlutterError(code: "INVALID_ARGS", message: "token/presetId required", details: nil)) return } - bundles[token]?.handle(presetId)?.play() + let fromMs = (args?["fromMs"] as? NSNumber)?.doubleValue ?? 0 + bundles[token]?.handle(presetId)?.play(fromMs: fromMs) result(nil) case "Pulsar_stopBundlePreset": diff --git a/flutter/pulsar/lib/pulsar_method_channel.dart b/flutter/pulsar/lib/pulsar_method_channel.dart index 8a17f6b4..15d337a9 100644 --- a/flutter/pulsar/lib/pulsar_method_channel.dart +++ b/flutter/pulsar/lib/pulsar_method_channel.dart @@ -44,11 +44,15 @@ class MethodChannelPulsar extends PulsarPlatform { } @override - Future playBundlePreset(String token, String presetId) => - methodChannel.invokeMethod('Pulsar_playBundlePreset', { - 'token': token, - 'presetId': presetId, - }); + Future playBundlePreset( + String token, + String presetId, { + double fromMs = 0, + }) => methodChannel.invokeMethod('Pulsar_playBundlePreset', { + 'token': token, + 'presetId': presetId, + 'fromMs': fromMs, + }); @override Future stopBundlePreset(String token, String presetId) => diff --git a/flutter/pulsar/lib/pulsar_platform_interface.dart b/flutter/pulsar/lib/pulsar_platform_interface.dart index 27d867c1..2adc9649 100644 --- a/flutter/pulsar/lib/pulsar_platform_interface.dart +++ b/flutter/pulsar/lib/pulsar_platform_interface.dart @@ -42,8 +42,11 @@ abstract class PulsarPlatform extends PlatformInterface { bool includeAnimations = true, }) => throw UnimplementedError('bundlePresets() not implemented'); - Future playBundlePreset(String token, String presetId) => - throw UnimplementedError('playBundlePreset() not implemented'); + Future playBundlePreset( + String token, + String presetId, { + double fromMs = 0, + }) => throw UnimplementedError('playBundlePreset() not implemented'); Future stopBundlePreset(String token, String presetId) => throw UnimplementedError('stopBundlePreset() not implemented'); diff --git a/flutter/pulsar/lib/src/pulsar_bundle.dart b/flutter/pulsar/lib/src/pulsar_bundle.dart index e59b2b92..2e049d50 100644 --- a/flutter/pulsar/lib/src/pulsar_bundle.dart +++ b/flutter/pulsar/lib/src/pulsar_bundle.dart @@ -92,8 +92,11 @@ class PresetHandle { /// Whether the preset carries a Lottie animation, exposed as [animation]. bool get hasAnimation => animation != null; - /// Play the preset natively — haptics, plus its synced audio when it has any. - void play() => unawaited(PulsarPlatform.instance.playBundlePreset(_token, id)); + /// Play the preset from [fromMs] into its timeline — haptics, plus its synced + /// audio when it has any. + void play({double fromMs = 0}) => unawaited( + PulsarPlatform.instance.playBundlePreset(_token, id, fromMs: fromMs), + ); /// Stop a preset started with [play]. void stop() => unawaited(PulsarPlatform.instance.stopBundlePreset(_token, id)); diff --git a/flutter/pulsar/test/pulsar_test.dart b/flutter/pulsar/test/pulsar_test.dart index 1b0368b2..c1db89bb 100644 --- a/flutter/pulsar/test/pulsar_test.dart +++ b/flutter/pulsar/test/pulsar_test.dart @@ -124,7 +124,11 @@ class MockPulsarPlatform }) async => const []; @override - Future playBundlePreset(String token, String presetId) async {} + Future playBundlePreset( + String token, + String presetId, { + double fromMs = 0, + }) async {} @override Future stopBundlePreset(String token, String presetId) async {} diff --git a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift index 5626a899..59800413 100644 --- a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift @@ -72,6 +72,7 @@ struct ResolvedSound { private weak var pulsar: Pulsar? private let sound: ResolvedSound? private var composer: PatternComposer? + private var parsedFromMs: Double? init(id: String, name: String, duration: Double, pulsar: Pulsar, pattern: PatternData, sound: ResolvedSound?, animation: BundleAnimation?) { self.id = id @@ -83,19 +84,34 @@ struct ResolvedSound { self.animation = animation } - private func ensureParsed() { - guard composer == nil, let pulsar = pulsar else { return } - let c = pulsar.getPatternComposer() + private func ensureParsed(fromMs: Double) { + let alreadyParsedHere = composer != nil && parsedFromMs == fromMs + guard !alreadyParsedHere, let pulsar = pulsar else { return } + let c = composer ?? pulsar.getPatternComposer() if let s = sound { - c.parsePatternWithSound(hapticsData: pattern, uri: s.uri, volume: s.volume, offset: s.offset) + c.parsePatternWithSound( + hapticsData: pattern, + uri: s.uri, + volume: s.volume, + offset: s.offset, + start: 0, + duration: 0, + fromMs: fromMs + ) } else { - c.parsePattern(hapticsData: pattern) + c.parsePattern(hapticsData: pattern, fromMs: fromMs) } composer = c + parsedFromMs = fromMs } @objc public func play() { - ensureParsed() + play(fromMs: 0) + } + + /// Plays the preset from `fromMs` into its timeline, audio and haptics together. + @objc public func play(fromMs: Double) { + ensureParsed(fromMs: max(0, fromMs)) composer?.play() } @@ -106,6 +122,7 @@ struct ResolvedSound { func dispose() { composer?.dispose() composer = nil + parsedFromMs = nil } } @@ -126,8 +143,11 @@ struct ResolvedSound { @objc public func handle(_ id: String) -> PresetHandle? { handles[id] } @objc public var presetIds: [String] { Array(handles.keys) } @objc public func play(_ id: String) -> Bool { + play(id, fromMs: 0) + } + @objc public func play(_ id: String, fromMs: Double) -> Bool { guard let h = handles[id] else { return false } - h.play() + h.play(fromMs: fromMs) return true } @objc public func dispose() { handles.values.forEach { $0.dispose() } } diff --git a/iOS/Pulsar/Sources/Pulsar/Bundle/README.md b/iOS/Pulsar/Sources/Pulsar/Bundle/README.md index 04e5e047..e3f3cccf 100644 --- a/iOS/Pulsar/Sources/Pulsar/Bundle/README.md +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/README.md @@ -14,6 +14,7 @@ autocomplete. let pulsar = Pulsar() let bundle = try pulsar.loadBundleSync(AcmePack.descriptor) // AcmePack is generated bundle.heartbeatV2.play() // ← autocompletes +bundle.heartbeatV2.play(fromMs: 2500) // starts 2.5s in bundle.explosion.stop() // Animation bytes for the app's own Lottie view (Pulsar times, the app renders): @@ -49,4 +50,5 @@ The wrappers use the untyped, string-keyed surface: let loaded = try pulsar.loadBundle(path: bundlePath) // or loadBundle(data:) loaded.presetIds // -> [String] loaded.play("heartbeatV2") // -> Bool +loaded.play("heartbeatV2", fromMs: 2500) // seeks audio + haptics ``` diff --git a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift index 67ca0e42..825f2804 100644 --- a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift +++ b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift @@ -18,6 +18,7 @@ public class PatternComposer: NSObject { // (Core Haptics registers an audio resource by URL only, so a windowed clip has to be // sliced to a file first). Removed on the next parse and on dispose. private var tempAudioURL: URL? + private var audioResourceID: CHHapticAudioResourceID? public convenience init(engine: HapticEngineWrapper, audioSimulator: AudioSimulator) { self.init() @@ -29,14 +30,25 @@ public class PatternComposer: NSObject { dispose() } - @objc public func parsePattern(hapticsData: PatternData) { - parse(hapticsData: hapticsData, audioEvent: nil) + /// `fromMs` starts the pattern that far into its own timeline. + @objc public func parsePattern(hapticsData: PatternData, fromMs: Double = 0) { + releaseAudio() + parse(hapticsData: PatternSeek.pattern(hapticsData, from: fromMs), audioEvent: nil) } - @objc public func parsePatternWithSound(hapticsData: PatternData, uri: String, volume: Float = 1, offset: Double = 0, start: Double = 0, duration: Double = 0) { - removeTempAudio() - let audioEvent = makeAudioEvent(uri: uri, volume: volume, offset: offset, start: start, duration: duration) - parse(hapticsData: hapticsData, audioEvent: audioEvent) + /// `start`/`duration` are the authored trim window in the file; `fromMs` seeks the whole + /// preset, moving audio and haptics together. + @objc public func parsePatternWithSound(hapticsData: PatternData, uri: String, volume: Float = 1, offset: Double = 0, start: Double = 0, duration: Double = 0, fromMs: Double = 0) { + releaseAudio() + let window = PatternSeek.soundWindow(offset: offset, start: start, duration: duration, from: fromMs) + let audioEvent = makeAudioEvent( + uri: uri, + volume: volume, + offset: window.offset, + start: window.start, + duration: window.duration + ) + parse(hapticsData: PatternSeek.pattern(hapticsData, from: fromMs), audioEvent: audioEvent) } private func parse(hapticsData: PatternData, audioEvent: CHHapticEvent?) { @@ -115,6 +127,7 @@ public class PatternComposer: NSObject { } } guard let resourceID = engine.registerAudioResource(url: url) else { return nil } + audioResourceID = resourceID return CHHapticEvent( audioResourceID: resourceID, parameters: [CHHapticEventParameter(parameterID: .audioVolume, value: volume)], @@ -149,7 +162,11 @@ public class PatternComposer: NSObject { } } - private func removeTempAudio() { + private func releaseAudio() { + if let id = audioResourceID { + engine.unregisterAudioResource(id) + audioResourceID = nil + } if let url = tempAudioURL { try? FileManager.default.removeItem(at: url) tempAudioURL = nil @@ -193,6 +210,6 @@ public class PatternComposer: NSObject { discretePattern = nil audioBuffer = nil hasSound = false - removeTempAudio() + releaseAudio() } } diff --git a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift index f10543a2..5e4bbbe0 100644 --- a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift +++ b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift @@ -79,6 +79,14 @@ public extension HapticEngineWrapper { } } + func unregisterAudioResource(_ id: CHHapticAudioResourceID) { + do { + try engine?.unregisterAudioResource(id) + } catch { + print("Error unregistering audio resource: \(error.localizedDescription)") + } + } + func getRealtimePlayer() -> CHHapticAdvancedPatternPlayer? { bootstrapAppLifecycleTrackingIfNeeded() guard canPlayHaptics() else { return nil } diff --git a/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift new file mode 100644 index 00000000..993309f3 --- /dev/null +++ b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Re-anchors an authored pattern so playing it from zero feels like playing the original +/// from `fromMs`. `CHHapticAdvancedPatternPlayer.seek(toOffset:)` cannot stand in for this: +/// it emits nothing for a `hapticContinuous` or `audioCustom` event it lands inside (Apple +/// radar 29274583), and those are the two events every parsed pattern is built from. +enum PatternSeek { + static func interpolatedValue(of points: [ValuePoint], at atMs: Double) -> Float { + guard let first = points.first, let last = points.last else { return 0 } + if atMs <= first.time { return first.value } + if atMs >= last.time { return last.value } + guard let nextIndex = points.firstIndex(where: { $0.time > atMs }), nextIndex > 0 else { + return last.value + } + let before = points[nextIndex - 1] + let after = points[nextIndex] + let span = after.time - before.time + if span <= 0 { return after.value } + return before.value + (after.value - before.value) * Float((atMs - before.time) / span) + } + + static func envelope(_ points: [ValuePoint], from fromMs: Double, remaining remainingMs: Double) -> [ValuePoint] { + if points.isEmpty { return [] } + let valueAtSeek = ValuePoint(time: 0, value: interpolatedValue(of: points, at: fromMs)) + let pointsAfterSeek = points + .filter { $0.time > fromMs } + .map { ValuePoint(time: $0.time - fromMs, value: $0.value) } + if !pointsAfterSeek.isEmpty { return [valueAtSeek] + pointsAfterSeek } + return holdingLastValue(valueAtSeek, for: remainingMs) + } + + /// Emptying an envelope would silence BOTH continuous channels — the composer builds that + /// line only when the amplitude and frequency curves are each non-empty. + private static func holdingLastValue(_ point: ValuePoint, for remainingMs: Double) -> [ValuePoint] { + remainingMs > 0 ? [point, ValuePoint(time: remainingMs, value: point.value)] : [point] + } + + static func lastTimestamp(of pattern: PatternData) -> Double { + let latest = { (times: [Double]) in times.reduce(0) { max($0, $1) } } + return max( + latest(pattern.discretePattern.map { $0.time }), + max( + latest(pattern.continuousPattern.amplitude.map { $0.time }), + latest(pattern.continuousPattern.frequency.map { $0.time }) + ) + ) + } + + static func pattern(_ pattern: PatternData, from fromMs: Double) -> PatternData { + if fromMs <= 0 { return pattern } + let remainingMs = lastTimestamp(of: pattern) - fromMs + return PatternData( + continuousPattern: ContinuousPattern( + amplitude: envelope(pattern.continuousPattern.amplitude, from: fromMs, remaining: remainingMs), + frequency: envelope(pattern.continuousPattern.frequency, from: fromMs, remaining: remainingMs) + ), + discretePattern: pattern.discretePattern + .filter { $0.time >= fromMs } + .map { DiscretePoint(time: $0.time - fromMs, amplitude: $0.amplitude, frequency: $0.frequency) } + ) + } + + /// Audio offset by `offset` sits at file position `t - offset` when the haptics are at `t`, + /// so a seek is spent on the lead-in first and only then on the file. + static func soundWindow( + offset: Double, + start: Double, + duration: Double, + from fromMs: Double + ) -> (start: Double, duration: Double, offset: Double) { + let leadIn = max(0, offset) + let seekIntoFile = max(0, fromMs - leadIn) + let playsToEndOfFile = duration <= 0 + return ( + start: start + seekIntoFile, + duration: playsToEndOfFile ? 0 : max(0, duration - seekIntoFile), + offset: max(0, leadIn - fromMs) + ) + } +} diff --git a/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift b/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift new file mode 100644 index 00000000..5f31e5ef --- /dev/null +++ b/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift @@ -0,0 +1,94 @@ +import Testing +import Foundation +@testable import Pulsar + +@Suite struct PatternSeekTests { + + private let ramp = PatternData( + continuousPattern: ContinuousPattern( + amplitude: [ValuePoint(time: 0, value: 0), ValuePoint(time: 1000, value: 1)], + frequency: [ValuePoint(time: 0, value: 0.2), ValuePoint(time: 500, value: 0.8)] + ), + discretePattern: [ + DiscretePoint(time: 0, amplitude: 1, frequency: 0.5), + DiscretePoint(time: 400, amplitude: 0.8, frequency: 0.4), + DiscretePoint(time: 1000, amplitude: 0.6, frequency: 0.3), + ] + ) + + @Test func durationIsTheLastTimestampAcrossBothLines() { + #expect(PatternSeek.lastTimestamp(of: ramp) == 1000) + let empty = PatternData( + continuousPattern: ContinuousPattern(amplitude: [], frequency: []), + discretePattern: [] + ) + #expect(PatternSeek.lastTimestamp(of: empty) == 0) + } + + @Test func seekingToZeroReturnsTheSamePattern() { + let original = ObjectIdentifier(ramp) + let atZero = ObjectIdentifier(PatternSeek.pattern(ramp, from: 0)) + let beforeZero = ObjectIdentifier(PatternSeek.pattern(ramp, from: -100)) + #expect(atZero == original) + #expect(beforeZero == original) + } + + @Test func discreteEventsBeforeTheSeekAreDroppedAndTheRestRebased() { + let seeked = PatternSeek.pattern(ramp, from: 400) + #expect(seeked.discretePattern.map { $0.time } == [0, 600]) + #expect(seeked.discretePattern.map { $0.amplitude } == [0.8, 0.6]) + } + + @Test func envelopeIsReanchoredOnItsInterpolatedValue() { + let seeked = PatternSeek.pattern(ramp, from: 250) + #expect(seeked.continuousPattern.amplitude.map { $0.time } == [0, 750]) + #expect(seeked.continuousPattern.amplitude.map { $0.value } == [0.25, 1]) + } + + @Test func anEnvelopeEntirelyBeforeTheSeekHoldsItsLastValue() { + let seeked = PatternSeek.pattern(ramp, from: 800) + #expect(seeked.continuousPattern.frequency.map { $0.time } == [0, 200]) + #expect(seeked.continuousPattern.frequency.map { $0.value } == [0.8, 0.8]) + #expect(!seeked.continuousPattern.amplitude.isEmpty) + } + + @Test func aHeldEnvelopeCollapsesToOnePointOnceNothingRemains() { + let seeked = PatternSeek.pattern(ramp, from: 1000) + #expect(seeked.continuousPattern.frequency.map { $0.time } == [0]) + #expect(seeked.continuousPattern.frequency.map { $0.value } == [0.8]) + } + + @Test func anEmptyEnvelopeStaysEmpty() { + let noFrequency = PatternData( + continuousPattern: ContinuousPattern( + amplitude: ramp.continuousPattern.amplitude, + frequency: [] + ), + discretePattern: [] + ) + #expect(PatternSeek.pattern(noFrequency, from: 250).continuousPattern.frequency.isEmpty) + } + + @Test func soundSeeksIntoTheFileByTheSameAmount() { + let window = PatternSeek.soundWindow(offset: 0, start: 0, duration: 0, from: 300) + #expect(window.start == 300) + #expect(window.offset == 0) + #expect(window.duration == 0) + } + + @Test func soundEatsIntoTheLeadInBeforeItTouchesTheFile() { + let early = PatternSeek.soundWindow(offset: 500, start: 0, duration: 0, from: 200) + #expect(early.start == 0) + #expect(early.offset == 300) + + let late = PatternSeek.soundWindow(offset: 500, start: 0, duration: 0, from: 800) + #expect(late.start == 300) + #expect(late.offset == 0) + } + + @Test func anAuthoredTrimWindowShrinksAndItsStartAdvances() { + let window = PatternSeek.soundWindow(offset: 0, start: 1000, duration: 900, from: 400) + #expect(window.start == 1400) + #expect(window.duration == 500) + } +} 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 6feb4ec5..96b5ebb8 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 @@ -333,12 +333,12 @@ private class AndroidPresetsHandle( private class AndroidPatternComposerHandle( private val composer: AndroidPatternComposer, ) : PatternComposerHandle { - override fun parsePattern(pattern: PatternData) { - composer.parsePattern(pattern.toAndroidPatternData()) + override fun parsePattern(pattern: PatternData, fromMs: Long) { + composer.parsePattern(pattern.toAndroidPatternData(), fromMs) } - override fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { - composer.parsePatternWithSound(pattern.toAndroidPatternData(), sound.toAndroidSoundData()) + override fun parsePatternWithSound(pattern: PatternData, sound: SoundData, fromMs: Long) { + composer.parsePatternWithSound(pattern.toAndroidPatternData(), sound.toAndroidSoundData(), fromMs) } override fun playPattern(pattern: PatternData) { @@ -372,7 +372,14 @@ private class AndroidRealtimeComposerHandle( } private fun SoundData.toAndroidSoundData(): AndroidSoundData { - return AndroidSoundData(uri = uri, volume = volume, offset = offset, startMs = startMs, durationMs = durationMs) + return AndroidSoundData( + uri = uri, + volume = volume, + offset = offset, + startMs = startMs, + durationMs = durationMs, + hapticChannels = hapticChannels, + ) } private fun PatternData.toAndroidPatternData(): AndroidPatternData { diff --git a/kmp/Pulsar/library/src/androidMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.android.kt b/kmp/Pulsar/library/src/androidMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.android.kt index 52b64bd0..303d66b7 100644 --- a/kmp/Pulsar/library/src/androidMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.android.kt +++ b/kmp/Pulsar/library/src/androidMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.android.kt @@ -13,3 +13,12 @@ internal actual fun readBundleAsset(assetName: String): ByteArray { ) return context.assets.open(assetName).use { it.readBytes() } } + +internal actual fun writeBundleMedia(bundleId: String, name: String, bytes: ByteArray): String { + val context = PulsarInitializerState.applicationContext + ?: throw PulsarBundleException( + "Pulsar has no application context yet — load the bundle after app startup.", + ) + val dir = File(context.cacheDir, "PulsarBundles/$bundleId").apply { mkdirs() } + return File(dir, name).apply { writeBytes(bytes) }.absolutePath +} diff --git a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt new file mode 100644 index 00000000..7e1d7b87 --- /dev/null +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt @@ -0,0 +1,73 @@ +package com.swmansion.pulsar.kmp + +/** + * Re-anchors an authored pattern so playing it from zero feels like playing the original from + * `fromMs`. The composer only ever starts at zero. + */ +internal object PatternSeek { + fun interpolatedValueAt(points: List, atMs: Long): Float { + val first = points.firstOrNull() ?: return 0f + val last = points.last() + if (atMs <= first.time) return first.value + if (atMs >= last.time) return last.value + val nextIndex = points.indexOfFirst { it.time > atMs } + if (nextIndex <= 0) return last.value + val before = points[nextIndex - 1] + val after = points[nextIndex] + val span = after.time - before.time + if (span <= 0L) return after.value + return before.value + (after.value - before.value) * (atMs - before.time).toFloat() / span + } + + fun envelopeFrom(points: List, fromMs: Long, remainingMs: Long): List { + if (points.isEmpty()) return emptyList() + val valueAtSeek = ValuePoint(time = 0L, value = interpolatedValueAt(points, fromMs)) + val pointsAfterSeek = points + .filter { it.time > fromMs } + .map { ValuePoint(it.time - fromMs, it.value) } + if (pointsAfterSeek.isNotEmpty()) return listOf(valueAtSeek) + pointsAfterSeek + return holdingLastValue(valueAtSeek, remainingMs) + } + + /** + * Emptying an envelope would silence BOTH continuous channels — the composer builds that + * line only when the amplitude and frequency curves are each non-empty. + */ + private fun holdingLastValue(point: ValuePoint, remainingMs: Long): List = + if (remainingMs > 0L) listOf(point, ValuePoint(remainingMs, point.value)) else listOf(point) + + fun lastTimestampOf(pattern: PatternData): Long = maxOf( + pattern.discretePattern.maxOfOrNull { it.time } ?: 0L, + pattern.continuousPattern.amplitude.maxOfOrNull { it.time } ?: 0L, + pattern.continuousPattern.frequency.maxOfOrNull { it.time } ?: 0L, + ) + + fun patternFrom(pattern: PatternData, fromMs: Long): PatternData { + if (fromMs <= 0L) return pattern + val remainingMs = lastTimestampOf(pattern) - fromMs + return PatternData( + continuousPattern = ContinuousPattern( + amplitude = envelopeFrom(pattern.continuousPattern.amplitude, fromMs, remainingMs), + frequency = envelopeFrom(pattern.continuousPattern.frequency, fromMs, remainingMs), + ), + discretePattern = pattern.discretePattern + .filter { it.time >= fromMs } + .map { it.copy(time = it.time - fromMs) }, + ) + } + + /** + * Audio offset by [SoundData.offset] sits at file position `t - offset` when the haptics are + * at `t`, so a seek is spent on the lead-in first and only then on the file. + */ + fun soundFrom(sound: SoundData, fromMs: Long): SoundData { + val leadIn = maxOf(0L, sound.offset) + val seekIntoFile = maxOf(0L, fromMs - leadIn) + val playsToEndOfFile = sound.durationMs <= 0L + return sound.copy( + offset = maxOf(0L, leadIn - fromMs), + startMs = sound.startMs + seekIntoFile, + durationMs = if (playsToEndOfFile) 0L else maxOf(0L, sound.durationMs - seekIntoFile), + ) + } +} diff --git a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/Pulsar.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/Pulsar.kt index 320bfbc5..d67761e0 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/Pulsar.kt +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/Pulsar.kt @@ -78,8 +78,8 @@ class Pulsar private constructor( /** * Load a `.pulsar` bundle from raw bytes. The app supplies the bytes (e.g. from its own - * resource loader); KMP does not resolve platform assets. Plays haptics and exposes animation - * bytes; synced bundle audio is handled by the native iOS/Android SDKs. + * resource loader); KMP does not resolve platform assets. Plays a preset's haptics and its + * synced audio, and exposes animation bytes for the host app's own Lottie view. */ fun loadBundle(bytes: ByteArray): LoadedBundle = BundleLoaderImpl.load(this, bytes) 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 5f13583a..ff787d76 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 @@ -825,12 +825,17 @@ class PulsarPreset internal constructor( class PatternComposer internal constructor( private val handle: PatternComposerHandle, ) { - fun parsePattern(pattern: PatternData) { - handle.parsePattern(pattern) + /** [fromMs] starts the pattern that far into its own timeline. */ + fun parsePattern(pattern: PatternData, fromMs: Long = 0L) { + handle.parsePattern(pattern, fromMs) } - fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { - handle.parsePatternWithSound(pattern, sound) + /** + * The sound's own `startMs`/`durationMs` are the authored trim window in the file; [fromMs] + * seeks the whole preset, moving audio and haptics together. + */ + fun parsePatternWithSound(pattern: PatternData, sound: SoundData, fromMs: Long = 0L) { + handle.parsePatternWithSound(pattern, sound, fromMs) } fun playPattern(pattern: PatternData) { 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 62923e81..62e29ff7 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 @@ -260,8 +260,8 @@ interface PulsarPresetsHandle { } interface PatternComposerHandle { - fun parsePattern(pattern: PatternData) - fun parsePatternWithSound(pattern: PatternData, sound: SoundData) + fun parsePattern(pattern: PatternData, fromMs: Long = 0L) + fun parsePatternWithSound(pattern: PatternData, sound: SoundData, fromMs: Long = 0L) 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 26f013df..17ef8a66 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 @@ -42,6 +42,11 @@ data class SoundData( val offset: Long = 0L, val startMs: Long = 0L, val durationMs: Long = 0L, + /** + * Whether the file carries baked haptic channels. Android only, and only for an explicit + * `.ogg`: when false the file is plain audio and Pulsar's own haptics play alongside it. + */ + val hapticChannels: Boolean = true, ) data class PatternData( diff --git a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.kt index 2d11636a..39937694 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.kt +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.kt @@ -3,3 +3,9 @@ package com.swmansion.pulsar.kmp.bundle internal expect fun readBundleFile(path: String): ByteArray internal expect fun readBundleAsset(assetName: String): ByteArray + +/** + * Writes a bundle's media entry to a platform cache directory and returns its absolute path. + * Both composers take audio by uri, so the bytes inside the `.pulsar` have to reach disk first. + */ +internal expect fun writeBundleMedia(bundleId: String, name: String, bytes: ByteArray): String diff --git a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/PulsarBundle.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/PulsarBundle.kt index ac059a43..8599271e 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/PulsarBundle.kt +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/PulsarBundle.kt @@ -3,6 +3,7 @@ package com.swmansion.pulsar.kmp.bundle import com.swmansion.pulsar.kmp.PatternComposer import com.swmansion.pulsar.kmp.PatternData import com.swmansion.pulsar.kmp.Pulsar +import com.swmansion.pulsar.kmp.SoundData import kotlinx.serialization.json.Json /** Lottie bytes + timing for a preset's animation; the host app's own Lottie view renders it. */ @@ -12,12 +13,7 @@ class BundleAnimation internal constructor( val totalFrames: Int, ) -/** - * A single playable preset from a loaded bundle. Parses its pattern lazily on first play. - * - * NOTE: KMP v1 plays haptics and exposes animation bytes; synced bundle audio is not yet wired - * (it needs platform temp-file extraction) — use the native iOS/Android SDKs for audio-synced packs. - */ +/** A single playable preset from a loaded bundle. Parses its pattern lazily on first play. */ class PresetHandle internal constructor( val id: String, /** Human label the preset was authored under. */ @@ -30,23 +26,30 @@ class PresetHandle internal constructor( */ val pattern: PatternData, private val haptics: Pulsar, + private val sound: SoundData?, ) { - /** Always `false` on KMP: synced bundle audio is not wired yet (see the class note). */ - val hasAudio: Boolean get() = false + /** Whether the preset carries a synced audio track, which [play] plays alongside the haptics. */ + val hasAudio: Boolean get() = sound != null /** Whether the preset carries a Lottie animation, exposed as [animation]. */ val hasAnimation: Boolean get() = animation != null private var composer: PatternComposer? = null - private fun ensureParsed() { - if (composer == null) { - composer = haptics.getPatternComposer().also { it.parsePattern(pattern) } - } + private var parsedFromMs: Long? = null + + private fun ensureParsed(fromMs: Long) { + val alreadyParsedHere = composer != null && parsedFromMs == fromMs + if (alreadyParsedHere) return + val c = composer ?: haptics.getPatternComposer() + if (sound != null) c.parsePatternWithSound(pattern, sound, fromMs) else c.parsePattern(pattern, fromMs) + composer = c + parsedFromMs = fromMs } - fun play() { - ensureParsed() + /** Plays the preset from [fromMs] into its timeline, audio and haptics together. */ + fun play(fromMs: Long = 0L) { + ensureParsed(maxOf(0L, fromMs)) composer?.play() } @@ -57,6 +60,7 @@ class PresetHandle internal constructor( internal fun dispose() { composer?.dispose() composer = null + parsedFromMs = null } } @@ -69,9 +73,9 @@ class LoadedBundle internal constructor( ) { fun handle(id: String): PresetHandle? = handles[id] val presetIds: List get() = handles.keys.toList() - fun play(id: String): Boolean { + fun play(id: String, fromMs: Long = 0L): Boolean { val h = handles[id] ?: return false - h.play() + h.play(fromMs) return true } fun dispose() = handles.values.forEach { it.dispose() } @@ -127,6 +131,18 @@ internal object BundleLoaderImpl { val animation = preset.animation?.let { anim -> files[anim.src]?.let { BundleAnimation(it, anim.frameRate ?: 0.0, anim.totalFrames ?: 0) } } + val sound = preset.audio?.let { audio -> + files[audio.src]?.let { data -> + SoundData( + uri = writeBundleMedia(manifest.id, audio.src.substringAfterLast('/'), data), + volume = audio.volume ?: 1f, + offset = (audio.offset ?: 0.0).toLong(), + // Bundle audio is plain music: always play Pulsar's own haptics alongside it. + hapticChannels = false, + ) + } + } + handles[preset.id] = PresetHandle( id = preset.id, name = preset.name, @@ -134,6 +150,7 @@ internal object BundleLoaderImpl { animation = animation, pattern = pattern, haptics = haptics, + sound = sound, ) } return LoadedBundle(manifest.id, manifest.hash ?: "", manifest.revision ?: 0, handles) diff --git a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/README.md b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/README.md index 54172875..1b78e647 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/README.md +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/README.md @@ -45,11 +45,14 @@ a bundle stored there always takes the `bytes` overload — which is what the ex The loaded bundle's content hash is asserted against the generated types, failing loudly on a stale bundle/types mismatch. Pass `strict = false` to skip it. -## Limits +## Media -KMP v1 plays a preset's haptics and exposes its animation bytes (`preset.animation`) for the host -app's own Lottie view. **Synced bundle audio is not wired yet** — it needs platform temp-file -extraction — so use the native iOS/Android SDKs for audio-synced packs. +A preset plays its haptics and, when it carries one, its synced audio track — the loader extracts +the audio from the archive into a platform cache directory, because both composers take audio by +uri. `preset.hasAudio` reports whether there is one. + +Animation is carried, not rendered: `preset.animation` exposes the Lottie bytes and timing for the +host app's own Lottie view. ## Untyped surface @@ -57,4 +60,5 @@ extraction — so use the native iOS/Android SDKs for audio-synced packs. val loaded = pulsar.loadBundle(bytes) // no descriptor loaded.presetIds // -> List loaded.play("heartbeatV2") // -> Boolean +loaded.play("heartbeatV2", fromMs = 2500) // seeks audio + haptics ``` diff --git a/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt b/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt new file mode 100644 index 00000000..09c1aff3 --- /dev/null +++ b/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt @@ -0,0 +1,81 @@ +package com.swmansion.pulsar.kmp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PatternSeekTest { + + private val ramp = PatternData( + continuousPattern = ContinuousPattern( + amplitude = listOf(ValuePoint(0L, 0f), ValuePoint(1000L, 1f)), + frequency = listOf(ValuePoint(0L, 0.2f), ValuePoint(500L, 0.8f)), + ), + discretePattern = listOf( + ConfigPoint(0L, 1f, 0.5f), + ConfigPoint(400L, 0.8f, 0.4f), + ConfigPoint(1000L, 0.6f, 0.3f), + ), + ) + + @Test + fun durationIsTheLastTimestampAcrossBothLines() { + assertEquals(1000L, PatternSeek.lastTimestampOf(ramp)) + } + + @Test + fun seekingToZeroReturnsTheSamePattern() { + assertSame(ramp, PatternSeek.patternFrom(ramp, 0L)) + assertSame(ramp, PatternSeek.patternFrom(ramp, -100L)) + } + + @Test + fun discreteEventsBeforeTheSeekAreDroppedAndTheRestRebased() { + val seeked = PatternSeek.patternFrom(ramp, 400L) + assertEquals(listOf(0L, 600L), seeked.discretePattern.map { it.time }) + assertEquals(listOf(0.8f, 0.6f), seeked.discretePattern.map { it.amplitude }) + } + + @Test + fun envelopeIsReanchoredOnItsInterpolatedValue() { + val seeked = PatternSeek.patternFrom(ramp, 250L) + assertEquals(listOf(0L, 750L), seeked.continuousPattern.amplitude.map { it.time }) + assertEquals(listOf(0.25f, 1f), seeked.continuousPattern.amplitude.map { it.value }) + } + + @Test + fun anEnvelopeEntirelyBeforeTheSeekHoldsItsLastValue() { + val seeked = PatternSeek.patternFrom(ramp, 800L) + assertEquals(listOf(ValuePoint(0L, 0.8f), ValuePoint(200L, 0.8f)), seeked.continuousPattern.frequency) + assertTrue(seeked.continuousPattern.amplitude.isNotEmpty()) + } + + @Test + fun soundSeeksIntoTheFileByTheSameAmount() { + val seeked = PatternSeek.soundFrom(SoundData(uri = "clip.wav"), 300L) + assertEquals(0L, seeked.offset) + assertEquals(300L, seeked.startMs) + assertEquals(0L, seeked.durationMs) + } + + @Test + fun soundEatsIntoTheLeadInBeforeItTouchesTheFile() { + val early = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 200L) + assertEquals(300L, early.offset) + assertEquals(0L, early.startMs) + + val late = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 800L) + assertEquals(0L, late.offset) + assertEquals(300L, late.startMs) + } + + @Test + fun anEmptyEnvelopeStaysEmpty() { + val noFrequency = PatternData( + ContinuousPattern(ramp.continuousPattern.amplitude, emptyList()), + emptyList(), + ) + assertTrue(PatternSeek.patternFrom(noFrequency, 250L).continuousPattern.frequency.isEmpty()) + } +} 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 5b8f3e00..0ba13cda 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 @@ -411,14 +411,17 @@ private class FakePatternHandle : PatternComposerHandle { var audioOnlyPlayed = false var stopped = false var soundParsed: SoundData? = null + var parsedFromMs: Long? = null - override fun parsePattern(pattern: PatternData) { + override fun parsePattern(pattern: PatternData, fromMs: Long) { parsed = true + parsedFromMs = fromMs } - override fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { + override fun parsePatternWithSound(pattern: PatternData, sound: SoundData, fromMs: Long) { parsed = true soundParsed = sound + parsedFromMs = fromMs } override fun playPattern(pattern: PatternData) { diff --git a/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.ios.kt b/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.ios.kt index 31952166..0707795f 100644 --- a/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.ios.kt +++ b/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/bundle/BundleAssets.ios.kt @@ -4,8 +4,14 @@ import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.usePinned import platform.Foundation.NSBundle +import platform.Foundation.NSCachesDirectory import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask +import platform.Foundation.create import platform.Foundation.dataWithContentsOfFile +import platform.Foundation.writeToFile import platform.posix.memcpy internal actual fun readBundleFile(path: String): ByteArray { @@ -30,3 +36,18 @@ private fun NSData.toByteArray(): ByteArray { out.usePinned { memcpy(it.addressOf(0), bytes, length) } } } + +@OptIn(ExperimentalForeignApi::class) +internal actual fun writeBundleMedia(bundleId: String, name: String, bytes: ByteArray): String { + val caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, true) + .firstOrNull() as? String + ?: throw PulsarBundleException("Could not resolve the caches directory") + val dir = "$caches/PulsarBundles/$bundleId" + NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null) + val path = "$dir/$name" + val data = bytes.usePinned { NSData.create(bytes = it.addressOf(0), length = bytes.size.toULong()) } + if (!data.writeToFile(path, true)) { + throw PulsarBundleException("Could not write bundle media to \"$path\"") + } + return path +} 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 c4fc0d45..ff54f594 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.PatternSeek import com.swmansion.pulsar.kmp.SoundData import com.swmansion.pulsar.kmp.iosimpl.audio.IOSAudioBuffer import com.swmansion.pulsar.kmp.iosimpl.audio.IOSAudioSimulator @@ -25,6 +26,7 @@ import platform.CoreHaptics.CHHapticEventParameter import platform.CoreHaptics.CHHapticEventParameterIDAudioVolume import platform.CoreHaptics.CHHapticEventParameterIDHapticIntensity import platform.CoreHaptics.CHHapticEventParameterIDHapticSharpness +import platform.CoreHaptics.CHHapticAudioResourceID import platform.CoreHaptics.CHHapticEventTypeHapticContinuous import platform.CoreHaptics.CHHapticPattern import platform.Foundation.NSBundle @@ -48,14 +50,19 @@ internal class IOSPatternComposerHandle( // Haptics registers an audio resource by URL only, so a windowed clip is sliced to a // file first). Removed on the next parse and on dispose. private var tempAudioURL: NSURL? = null + private var audioResourceId: CHHapticAudioResourceID? = null - override fun parsePattern(pattern: PatternData) { - parse(pattern, audioEvent = null) + override fun parsePattern(pattern: PatternData, fromMs: Long) { + releaseAudio() + parse(PatternSeek.patternFrom(pattern, fromMs), audioEvent = null) } - override fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { - removeTempAudio() - parse(pattern, audioEvent = makeAudioEvent(sound)) + override fun parsePatternWithSound(pattern: PatternData, sound: SoundData, fromMs: Long) { + releaseAudio() + parse( + PatternSeek.patternFrom(pattern, fromMs), + audioEvent = makeAudioEvent(PatternSeek.soundFrom(sound, fromMs)), + ) } private fun parse(pattern: PatternData, audioEvent: CHHapticEvent?) { @@ -133,6 +140,7 @@ internal class IOSPatternComposerHandle( sourceUrl } val resourceId = engine.registerAudioResource(url) ?: return null + audioResourceId = resourceId return CHHapticEvent( audioResourceID = resourceId, parameters = listOf( @@ -174,7 +182,9 @@ internal class IOSPatternComposerHandle( }.onFailure { log("could not slice audio window: ${it.message}") }.getOrNull() } - private fun removeTempAudio() { + private fun releaseAudio() { + audioResourceId?.let { engine.unregisterAudioResource(it) } + audioResourceId = null tempAudioURL?.path?.let { path -> runCatching { NSFileManager.defaultManager.removeItemAtPath(path, null) } } @@ -221,6 +231,6 @@ internal class IOSPatternComposerHandle( discretePattern = null audioBuffer = null hasSound = false - removeTempAudio() + releaseAudio() } } 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 856c368c..87e2e771 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 @@ -89,6 +89,11 @@ internal class IOSHapticEngineWrapper { return if (id == null || id.toLong() == 0L) null else id } + fun unregisterAudioResource(id: CHHapticAudioResourceID) { + runCatching { engine?.unregisterAudioResource(id, null) } + .onFailure { log("Error unregistering audio resource: ${it.message}") } + } + 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 233f6290..15925a4f 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 @@ -258,8 +258,6 @@ fun App() { /** * Plays presets from a bundle shipped as a Compose resource. KMP has no shared asset API, so the * app supplies the bytes — hence the suspending read. - * - * KMP v1 has no bundle audio yet, so `arcadeBonusAlert` is felt but not heard. */ @Composable private fun BundleCard(pulsar: Pulsar?, onStatus: (String) -> Unit) { diff --git a/react-native/react-native-pulsar/BUNDLES.md b/react-native/react-native-pulsar/BUNDLES.md index 0d33b60c..63ee9190 100644 --- a/react-native/react-native-pulsar/BUNDLES.md +++ b/react-native/react-native-pulsar/BUNDLES.md @@ -70,10 +70,31 @@ through JavaScript as base64. After the load, `play()` is synchronous either way local file read; in development it is a **blocking HTTP round trip to Metro**, so prefer `loadBundleAsync()` unless you genuinely cannot await. +## Playing from a position + +`play(fromMs)` starts the preset that far into its own timeline instead of at the top, so a +progress bar can seek: + +```ts +Haptics.fanfare.play(2500); // start 2.5s in +Haptics.fanfare.play(); // ...and from the top, as before +``` + +The native side re-anchors the pattern — discrete events before the seek are dropped, the rest +rebased, and the continuous envelopes re-anchored on their value at that instant — and seeks the +audio to the matching position in the file, so both move together. A preset authored with an +audio `offset` keeps its lead-in until the seek passes it. + +`loadBundleSync()` without the binary carries no audio at all, so a seek there scrubs the +haptics alone. + +Each non-zero `fromMs` re-parses the preset; `play()` from the start reuses the cached parse, so +plain playback costs exactly what it always did. + ## Presets and animations -Each preset handle carries `id`, `name`, `duration`, `play()`, `stop()`, the raw `pattern`, -and media metadata: +Each preset handle carries `id`, `name`, `duration`, `play(fromMs?)`, `stop()`, the raw +`pattern`, and media metadata: ```ts Haptics.fanfare.hasAudio; 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 569549a7..9252c7cd 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 @@ -188,12 +188,12 @@ class PulsarModule(reactContext: ReactApplicationContext) : ) } - override fun PatternComposer_parsePattern(data: ReadableMap?): Double { + override fun PatternComposer_parsePattern(data: ReadableMap?, fromMs: Double): Double { val patternComposer = pulsar.getPatternComposer() - + data?.let { val patternData = patternDataFromJSPattern(it) - patternComposer.parsePattern(patternData) + patternComposer.parsePattern(patternData, fromMs.toLong()) } val currentId = nextId @@ -346,9 +346,9 @@ class PulsarModule(reactContext: ReactApplicationContext) : return reactApplicationContext.resources.openRawResource(resourceId).use { it.readBytes() } } - override fun Pulsar_playBundlePreset(token: String?, presetId: String?) { + override fun Pulsar_playBundlePreset(token: String?, presetId: String?, fromMs: Double) { if (token == null || presetId == null) return - bundlesRegistry[token]?.play(presetId) + bundlesRegistry[token]?.play(presetId, fromMs.toLong()) } override fun Pulsar_stopBundlePreset(token: String?, presetId: String?) { diff --git a/react-native/react-native-pulsar/ios/Haptics.mm b/react-native/react-native-pulsar/ios/Haptics.mm index 40da050e..caa8bcd6 100644 --- a/react-native/react-native-pulsar/ios/Haptics.mm +++ b/react-native/react-native-pulsar/ios/Haptics.mm @@ -195,13 +195,15 @@ - (void)Pulsar_loadBundleFromUri:(nonnull NSString *)uri [task resume]; } -- (void)Pulsar_playBundlePreset:(nonnull NSString *)token presetId:(nonnull NSString *)presetId { +- (void)Pulsar_playBundlePreset:(nonnull NSString *)token + presetId:(nonnull NSString *)presetId + fromMs:(double)fromMs { if (!RNPulsarIsAppActive()) { return; } LoadedBundle *bundle = [self bundleForToken:token]; RNPulsarPerformSafely(@"Pulsar_playBundlePreset", ^{ - [bundle play:presetId]; + [bundle play:presetId fromMs:fromMs]; }); } @@ -300,11 +302,12 @@ - (void)Pulsar_setRealtimeComposerStrategy:(double)strategy { return [[PatternData alloc] initWithContinuousPattern:continuousPattern discretePattern:discretePoints]; } -- (nonnull NSNumber *)PatternComposer_parsePattern:(JS::NativeRNPulsar::Pattern &)data { +- (nonnull NSNumber *)PatternComposer_parsePattern:(JS::NativeRNPulsar::Pattern &)data + fromMs:(double)fromMs { auto patternComposer = [pulsar_ getPatternComposer]; PatternData *patternData = PatternDataFromJSPattern(data); - [patternComposer parsePatternWithHapticsData:patternData]; + [patternComposer parsePatternWithHapticsData:patternData fromMs:fromMs]; int currentId = nextId; nextId++; @@ -326,7 +329,8 @@ - (nonnull NSNumber *)PatternComposer_parsePatternWithSound:(JS::NativeRNPulsar: volume:(float)volume offset:offset start:start - duration:duration]; + duration:duration + fromMs:0]; int currentId = nextId; nextId++; diff --git a/react-native/react-native-pulsar/src/NativeRNPulsar.ts b/react-native/react-native-pulsar/src/NativeRNPulsar.ts index 5f11c875..7c5e23b0 100644 --- a/react-native/react-native-pulsar/src/NativeRNPulsar.ts +++ b/react-native/react-native-pulsar/src/NativeRNPulsar.ts @@ -50,7 +50,7 @@ export interface Spec extends TurboModule { RealtimeComposer_isActive(): boolean; RealtimeComposer_playDiscrete(amplitude: number, frequency: number): void; - PatternComposer_parsePattern(data: Pattern): number; + PatternComposer_parsePattern(data: Pattern, fromMs: number): number; PatternComposer_parsePatternWithSound( data: Pattern, uri: string, @@ -66,7 +66,11 @@ export interface Spec extends TurboModule { // Both return an opaque token, or "" on failure. Sync blocks the JS thread on the URI read. Pulsar_loadBundleFromUriSync(uri: string): string; Pulsar_loadBundleFromUri(uri: string): Promise; - Pulsar_playBundlePreset(token: string, presetId: string): void; + Pulsar_playBundlePreset( + token: string, + presetId: string, + fromMs: number + ): void; Pulsar_stopBundlePreset(token: string, presetId: string): void; Pulsar_disposeBundle(token: string): void; } diff --git a/react-native/react-native-pulsar/src/__tests__/createBundle.test.tsx b/react-native/react-native-pulsar/src/__tests__/createBundle.test.tsx index 0de6194a..65c6b0ce 100644 --- a/react-native/react-native-pulsar/src/__tests__/createBundle.test.tsx +++ b/react-native/react-native-pulsar/src/__tests__/createBundle.test.tsx @@ -107,6 +107,28 @@ describe('loadBundleSync', () => { expect(native.PatternComposer_play).toHaveBeenLastCalledWith(100); }); + it('hands the seek to the native composer and re-parses only when it moves', () => { + const bundle = defineBundle(definition).loadBundleSync(false); + + bundle.heartbeatV2.play(); + bundle.heartbeatV2.play(600); + bundle.heartbeatV2.play(600); + + expect(native.PatternComposer_parsePattern).toHaveBeenCalledTimes(2); + expect(native.PatternComposer_parsePattern).toHaveBeenNthCalledWith( + 1, + definition.presets.heartbeatV2.pattern, + 0 + ); + expect(native.PatternComposer_parsePattern).toHaveBeenNthCalledWith( + 2, + definition.presets.heartbeatV2.pattern, + 600 + ); + expect(native.PatternComposer_release).toHaveBeenCalledWith(100); + expect(native.PatternComposer_play).toHaveBeenLastCalledWith(101); + }); + it('exposes preset metadata and dynamic lookup', () => { const bundle = defineBundle(definition).loadBundleSync(); @@ -129,11 +151,30 @@ describe('loadBundleSync', () => { bundle.explosion.play(); expect(native.Pulsar_playBundlePreset).toHaveBeenCalledWith( 'com.acme.haptics#1', - 'explosion' + 'explosion', + 0 ); expect(native.PatternComposer_parsePattern).not.toHaveBeenCalled(); }); + it('forwards a seek position to the native bundle', () => { + const bundle = defineBundle(definition).loadBundleSync(true); + + bundle.explosion.play(750); + expect(native.Pulsar_playBundlePreset).toHaveBeenLastCalledWith( + 'com.acme.haptics#1', + 'explosion', + 750 + ); + + bundle.explosion.play(-1); + expect(native.Pulsar_playBundlePreset).toHaveBeenLastCalledWith( + 'com.acme.haptics#1', + 'explosion', + 0 + ); + }); + it('throws when the native sync load fails', () => { native.Pulsar_loadBundleFromUriSync.mockReturnValue(''); @@ -158,7 +199,8 @@ describe('loadBundleAsync', () => { expect(result).toBeUndefined(); expect(native.Pulsar_playBundlePreset).toHaveBeenCalledWith( 'com.acme.haptics#1', - 'explosion' + 'explosion', + 0 ); }); diff --git a/react-native/react-native-pulsar/src/createBundle.ts b/react-native/react-native-pulsar/src/createBundle.ts index f4c9de33..008ab912 100644 --- a/react-native/react-native-pulsar/src/createBundle.ts +++ b/react-native/react-native-pulsar/src/createBundle.ts @@ -21,7 +21,8 @@ export type PresetHandle = { readonly animation?: PresetAnimation; readonly hasAudio: boolean; readonly hasAnimation: boolean; - play: () => void; + /** Plays the preset from `fromMs` into its timeline — haptics plus its synced audio. */ + play: (fromMs?: number) => void; stop: () => void; }; @@ -132,6 +133,7 @@ function createLoadedBundle( bundleToken?: string ): Bundle> { const parsedIds = new Map(); + const parsedFrom = new Map(); const presets: Record = {}; let disposed = false; @@ -144,12 +146,19 @@ function createLoadedBundle( }; for (const [id, preset] of Object.entries(definition.presets)) { - const parseOnce = () => { - const alreadyParsed = parsedIds.get(id); - if (alreadyParsed !== undefined) return alreadyParsed; - - const parsedId = Pulsar.PatternComposer_parsePattern(preset.pattern); + const parseAt = (fromMs: number) => { + const previousId = parsedIds.get(id); + const alreadyParsedHere = + previousId !== undefined && parsedFrom.get(id) === fromMs; + if (alreadyParsedHere) return previousId; + if (previousId !== undefined) Pulsar.PatternComposer_release(previousId); + + const parsedId = Pulsar.PatternComposer_parsePattern( + preset.pattern, + fromMs + ); parsedIds.set(id, parsedId); + parsedFrom.set(id, fromMs); return parsedId; }; @@ -161,13 +170,14 @@ function createLoadedBundle( animation: preset.lottie, hasAudio: preset.audio, hasAnimation: preset.animation, - play: () => { + play: (fromMs = 0) => { if (disposed) return warnDisposed('play', id); + const from = Math.max(0, fromMs); if (bundleToken) { - Pulsar.Pulsar_playBundlePreset(bundleToken, id); + Pulsar.Pulsar_playBundlePreset(bundleToken, id, from); return; } - Pulsar.PatternComposer_play(parseOnce()); + Pulsar.PatternComposer_play(parseAt(from)); }, stop: () => { if (disposed) return warnDisposed('stop', id); @@ -193,6 +203,7 @@ function createLoadedBundle( Pulsar.PatternComposer_release(parsedId); } parsedIds.clear(); + parsedFrom.clear(); }, }); } diff --git a/react-native/react-native-pulsar/src/usePatternComposer.ts b/react-native/react-native-pulsar/src/usePatternComposer.ts index 8bf2cd2d..15731a9f 100644 --- a/react-native/react-native-pulsar/src/usePatternComposer.ts +++ b/react-native/react-native-pulsar/src/usePatternComposer.ts @@ -40,7 +40,7 @@ export default function usePatternComposer(pattern?: Pattern): PatternComposer { const { volume = 1, offset = 0, start = 0, duration = 0 } = pattern.sound; newPatternId = Pulsar.PatternComposer_parsePatternWithSound(pattern, resolvedUri, volume, offset, start, duration); } else { - newPatternId = Pulsar.PatternComposer_parsePattern(pattern); + newPatternId = Pulsar.PatternComposer_parsePattern(pattern, 0); } patternId.set(newPatternId); }, [pattern]);