From 95a1b7a8f1cd5e6e65303fb4b2309c46ace61807 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 9 Sep 2026 18:23:23 +0200 Subject: [PATCH 1/5] feat(bundles): let a preset play from a position in its timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PresetHandle.play()` could only start at the top, so a host app driving a `.pulsar` preset had no way to seek — the capability existed one layer down (the composers already take a sound `start`) but was never exposed. Adds `play(fromMs = 0)` across every SDK, defaulting to today's behaviour. A non-zero seek moves audio and haptics together: - the pattern is re-anchored — discrete events before the seek dropped and the rest rebased, both continuous envelopes re-anchored on their interpolated value at that instant; - an envelope whose points all sit before the seek HOLDS its last value rather than emptying, because the engines build the continuous channel only when amplitude and frequency are both non-empty — emptying either one silences both; - the audio seeks to the matching position in the file, keeping an authored `offset` as lead-in until the seek passes it. Each non-zero seek re-parses (the engines can only start a parsed pattern from zero); `fromMs = 0` still reuses the cached parse, so ordinary playback costs exactly what it did before. Because re-parsing is now something an app does repeatedly while scrubbing, iOS also releases the CoreHaptics audio resource it replaces — `registerAudioResource` had no counterpart, so every re-parse leaked one. Android already released its previous sound player. Covers the re-anchoring with unit tests on all four surfaces (swift-testing, JUnit, kotlin.test, jest). --- .../swmansion/pulsar/bundle/PulsarBundle.kt | 41 ++++-- .../com/swmansion/pulsar/types/PatternSeek.kt | 75 +++++++++++ .../swmansion/pulsar/types/PatternSeekTest.kt | 105 ++++++++++++++++ .../test/fake_pulsar_platform.dart | 6 +- .../swmansion/pulsar/flutter/PulsarPlugin.kt | 3 +- flutter/pulsar/ios/Classes/PulsarPlugin.swift | 3 +- flutter/pulsar/lib/pulsar_method_channel.dart | 14 ++- .../pulsar/lib/pulsar_platform_interface.dart | 7 +- flutter/pulsar/lib/src/pulsar_bundle.dart | 8 +- flutter/pulsar/test/pulsar_test.dart | 6 +- .../Sources/Pulsar/Bundle/PulsarBundle.swift | 41 ++++-- .../Pulsar/Composers/PatternComposer.swift | 14 ++- .../Sources/Pulsar/HapticEngineWrapper.swift | 10 ++ .../Sources/Pulsar/Types/PatternSeek.swift | 72 +++++++++++ .../Tests/PulsarTests/PatternSeekTests.swift | 92 ++++++++++++++ .../com/swmansion/pulsar/PatternSeek.kt | 58 +++++++++ .../swmansion/pulsar/bundle/PulsarBundle.kt | 33 +++-- .../com/swmansion/pulsar/PatternSeekTest.kt | 67 ++++++++++ react-native/react-native-pulsar/BUNDLES.md | 22 +++- .../pulsar/reactnative/PulsarModule.kt | 4 +- .../react-native-pulsar/ios/Haptics.mm | 6 +- .../react-native-pulsar/src/NativeRNPulsar.ts | 6 +- .../src/__tests__/createBundle.test.tsx | 48 ++++++- .../src/__tests__/patternSeek.test.ts | 119 ++++++++++++++++++ .../react-native-pulsar/src/createBundle.ts | 34 +++-- .../react-native-pulsar/src/patternSeek.ts | 105 ++++++++++++++++ 26 files changed, 944 insertions(+), 55 deletions(-) create mode 100644 Android/Pulsar/src/main/java/com/swmansion/pulsar/types/PatternSeek.kt create mode 100644 Android/Pulsar/src/test/java/com/swmansion/pulsar/types/PatternSeekTest.kt create mode 100644 iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift create mode 100644 iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift create mode 100644 kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt create mode 100644 kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt create mode 100644 react-native/react-native-pulsar/src/__tests__/patternSeek.test.ts create mode 100644 react-native/react-native-pulsar/src/patternSeek.ts 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..fb27ad53 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 @@ -3,6 +3,7 @@ package com.swmansion.pulsar.bundle import com.swmansion.pulsar.Pulsar import com.swmansion.pulsar.composers.PatternComposer import com.swmansion.pulsar.types.PatternData +import com.swmansion.pulsar.types.PatternSeek import com.swmansion.pulsar.types.SoundData /** Lottie bytes + timing for a preset's animation; the host app's own Lottie view renders it. */ @@ -35,16 +36,36 @@ 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 + /** The seek position [composer] is currently parsed at, or null while unparsed. */ + private var parsedFromMs: Long? = null + + /** + * Parses at [fromMs], reusing the cached parse when the position has not moved. A preset + * played only from the start therefore still parses exactly once, as it always has. + */ + private fun ensureParsed(fromMs: Long) { + if (composer != null && parsedFromMs == fromMs) return + val c = composer ?: haptics.getPatternComposer() + val seeked = PatternSeek.patternFrom(pattern, fromMs) + if (sound != null) { + c.parsePatternWithSound(seeked, PatternSeek.soundFrom(sound, fromMs)) + } else { + c.parsePattern(seeked) } + composer = c + parsedFromMs = fromMs } - fun play() { - ensureParsed() + /** + * Plays the preset from [fromMs] into its timeline, audio and haptics together. Defaults to + * the start of the preset. + * + * The pattern is re-anchored and re-parsed on every non-zero seek; `play()` keeps the parse + * cached, so repeat plays from the start cost nothing extra. + */ + @JvmOverloads + fun play(fromMs: Long = 0L) { + ensureParsed(maxOf(0L, fromMs)) composer?.play() } @@ -55,6 +76,7 @@ class PresetHandle internal constructor( internal fun dispose() { composer?.release() composer = null + parsedFromMs = null } } @@ -67,9 +89,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/types/PatternSeek.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/PatternSeek.kt new file mode 100644 index 00000000..99b785c4 --- /dev/null +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/PatternSeek.kt @@ -0,0 +1,75 @@ +package com.swmansion.pulsar.types + +/** + * Re-anchors an authored pattern so that playing it from zero feels like playing the original + * from `fromMs`. The composer only ever starts at zero, so seeking replays a shifted copy. + */ +internal object PatternSeek { + /** The value the envelope holds at [atMs], interpolating between the surrounding points. */ + fun valueAt(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 + } + + /** + * An envelope whose points all sit before the seek HOLDS its last value for the rest of the + * pattern rather than emptying. Emptying it would silence the whole continuous channel: the + * composer builds that channel only when the amplitude AND frequency curves are both + * non-empty, so seeking past the end of either one kills both. + */ + fun envelopeFrom(points: List, fromMs: Long, remainingMs: Long): List { + if (points.isEmpty()) return emptyList() + val held = ValuePoint(time = 0L, value = valueAt(points, fromMs)) + val rest = points.filter { it.time > fromMs }.map { ValuePoint(it.time - fromMs, it.value) } + if (rest.isNotEmpty()) return listOf(held) + rest + return if (remainingMs > 0L) listOf(held, ValuePoint(remainingMs, held.value)) else listOf(held) + } + + /** The last authored timestamp in the pattern, across both lines. */ + fun durationOf(pattern: PatternData): Long = maxOf( + pattern.discretePattern.maxOfOrNull { it.time } ?: 0L, + pattern.continuousPattern.amplitude.maxOfOrNull { it.time } ?: 0L, + pattern.continuousPattern.frequency.maxOfOrNull { it.time } ?: 0L, + ) + + /** Drops the discrete events before [fromMs], rebases the rest, re-anchors both envelopes. */ + fun patternFrom(pattern: PatternData, fromMs: Long): PatternData { + if (fromMs <= 0L) return pattern + val remainingMs = durationOf(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) }, + ) + } + + /** + * Where the audio file and the haptics line up after a seek. + * + * A sound offset by `offset` ms is at file position `t - offset` when the haptics are at `t`, + * so seeking to [fromMs] either advances into the file or eats into the lead-in. + */ + fun soundFrom(sound: SoundData, fromMs: Long): SoundData { + val lead = maxOf(0L, sound.offset) + val intoFile = maxOf(0L, fromMs - lead) + return sound.copy( + offset = maxOf(0L, lead - fromMs), + startMs = sound.startMs + intoFile, + // A zero duration means "to the end of the file", so only an authored window shrinks. + durationMs = if (sound.durationMs > 0L) maxOf(0L, sound.durationMs - intoFile) else 0L, + ) + } +} 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..461578eb --- /dev/null +++ b/Android/Pulsar/src/test/java/com/swmansion/pulsar/types/PatternSeekTest.kt @@ -0,0 +1,105 @@ +package com.swmansion.pulsar.types + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Re-anchoring an authored pattern so it can be replayed from a seek position. */ +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.durationOf(ramp)) + val empty = PatternData(ContinuousPattern(emptyList(), emptyList()), emptyList()) + assertEquals(0L, PatternSeek.durationOf(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 }) + } + + /** + * Emptying it instead would silence BOTH channels, since the composer only builds the + * continuous line when amplitude and frequency are each non-empty. + */ + @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`() { + // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. + val early = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 200L) + assertEquals(300L, early.offset) + assertEquals(0L, early.startMs) + + // Past the lead-in, the remainder is a seek into the file. + 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/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/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..331be069 100644 --- a/flutter/pulsar/lib/src/pulsar_bundle.dart +++ b/flutter/pulsar/lib/src/pulsar_bundle.dart @@ -93,7 +93,13 @@ class PresetHandle { 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)); + /// + /// Pass [fromMs] to start that far into the preset's timeline: the pattern is + /// re-anchored and the audio seeks to match. Defaults to the start. Every + /// non-zero seek re-parses; playing from the start reuses the cached parse. + 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..4a753a9a 100644 --- a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift @@ -72,6 +72,8 @@ struct ResolvedSound { private weak var pulsar: Pulsar? private let sound: ResolvedSound? private var composer: PatternComposer? + /// The seek position the cached ``composer`` is currently parsed at, or nil while unparsed. + private var parsedFromMs: Double? init(id: String, name: String, duration: Double, pulsar: Pulsar, pattern: PatternData, sound: ResolvedSound?, animation: BundleAnimation?) { self.id = id @@ -83,19 +85,40 @@ struct ResolvedSound { self.animation = animation } - private func ensureParsed() { - guard composer == nil, let pulsar = pulsar else { return } - let c = pulsar.getPatternComposer() + /// Parses at `fromMs`, reusing the cached parse when the position has not moved. A preset + /// played only from the start therefore still parses exactly once, as it always has. + private func ensureParsed(fromMs: Double) { + guard composer == nil || parsedFromMs != fromMs, let pulsar = pulsar else { return } + let c = composer ?? pulsar.getPatternComposer() + let seeked = PatternSeek.pattern(pattern, from: fromMs) if let s = sound { - c.parsePatternWithSound(hapticsData: pattern, uri: s.uri, volume: s.volume, offset: s.offset) + let window = PatternSeek.soundWindow(offset: s.offset, from: fromMs) + c.parsePatternWithSound( + hapticsData: seeked, + uri: s.uri, + volume: s.volume, + offset: window.offset, + start: window.start, + duration: 0 + ) } else { - c.parsePattern(hapticsData: pattern) + c.parsePattern(hapticsData: seeked) } composer = c + parsedFromMs = fromMs } + /// Plays the preset from its start — haptics plus its synced audio, if it has one. @objc public func play() { - ensureParsed() + play(fromMs: 0) + } + + /// Plays the preset from `fromMs` into its timeline, audio and haptics together. + /// + /// The pattern is re-anchored and re-parsed on every non-zero seek; `fromMs: 0` keeps the + /// parse cached, so repeat plays from the start cost nothing extra. + @objc public func play(fromMs: Double) { + ensureParsed(fromMs: max(0, fromMs)) composer?.play() } @@ -106,6 +129,7 @@ struct ResolvedSound { func dispose() { composer?.dispose() composer = nil + parsedFromMs = nil } } @@ -126,8 +150,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/Composers/PatternComposer.swift b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift index 67ca0e42..c119cd50 100644 --- a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift +++ b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift @@ -18,6 +18,8 @@ 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? + // The engine-side resource the current audio event plays, released alongside that temp file. + private var audioResourceID: CHHapticAudioResourceID? public convenience init(engine: HapticEngineWrapper, audioSimulator: AudioSimulator) { self.init() @@ -30,11 +32,12 @@ public class PatternComposer: NSObject { } @objc public func parsePattern(hapticsData: PatternData) { + releaseAudio() parse(hapticsData: hapticsData, audioEvent: nil) } @objc public func parsePatternWithSound(hapticsData: PatternData, uri: String, volume: Float = 1, offset: Double = 0, start: Double = 0, duration: Double = 0) { - removeTempAudio() + releaseAudio() let audioEvent = makeAudioEvent(uri: uri, volume: volume, offset: offset, start: start, duration: duration) parse(hapticsData: hapticsData, audioEvent: audioEvent) } @@ -115,6 +118,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 +153,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 +201,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..dc38ca1c 100644 --- a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift +++ b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift @@ -79,6 +79,16 @@ public extension HapticEngineWrapper { } } + /// Releases a resource obtained from ``registerAudioResource(url:)``. Re-parsing a sounded + /// pattern registers a fresh one, so the replaced resource has to be handed back. + 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..ce906ccd --- /dev/null +++ b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Re-anchors an authored pattern so that playing it from zero sounds like playing the +/// original from `fromMs`. The composer only ever starts at zero, so seeking replays a +/// shifted copy. +enum PatternSeek { + /// The value the envelope holds at `atMs`, interpolating between the surrounding points. + static func value(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) + } + + /// An envelope whose points all sit before the seek HOLDS its last value for the rest of + /// the pattern rather than emptying. Emptying it would silence the whole continuous + /// channel: the composer builds that channel only when the amplitude AND frequency curves + /// are both non-empty, so seeking past the end of either one kills both. + static func envelope(_ points: [ValuePoint], from fromMs: Double, remaining remainingMs: Double) -> [ValuePoint] { + if points.isEmpty { return [] } + let held = ValuePoint(time: 0, value: value(of: points, at: fromMs)) + let rest = points + .filter { $0.time > fromMs } + .map { ValuePoint(time: $0.time - fromMs, value: $0.value) } + if !rest.isEmpty { return [held] + rest } + return remainingMs > 0 ? [held, ValuePoint(time: remainingMs, value: held.value)] : [held] + } + + /// The last authored timestamp in the pattern, across both lines. + static func duration(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 }) + ) + ) + } + + /// Drops the discrete events before `fromMs`, rebases the rest, and re-anchors both envelopes. + static func pattern(_ pattern: PatternData, from fromMs: Double) -> PatternData { + if fromMs <= 0 { return pattern } + let remainingMs = duration(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) } + ) + } + + /// Where the audio file and the haptics line up after a seek. + /// + /// A sound offset by `offset` ms is at file position `t - offset` when the haptics are at + /// `t`, so seeking to `fromMs` either advances into the file or eats into the lead-in. + static func soundWindow(offset: Double, from fromMs: Double) -> (start: Double, offset: Double) { + let lead = max(0, offset) + if fromMs <= lead { return (start: 0, offset: lead - fromMs) } + return (start: fromMs - lead, offset: 0) + } +} diff --git a/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift b/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift new file mode 100644 index 00000000..d0f1b0c1 --- /dev/null +++ b/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift @@ -0,0 +1,92 @@ +import Testing +import Foundation +@testable import Pulsar + +/// Re-anchoring an authored pattern so it can be replayed from a seek position. +@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.duration(of: ramp) == 1000) + let empty = PatternData( + continuousPattern: ContinuousPattern(amplitude: [], frequency: []), + discretePattern: [] + ) + #expect(PatternSeek.duration(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]) + } + + /// Emptying it instead would silence BOTH channels, since the composer only builds the + /// continuous line when amplitude and frequency are each non-empty. + @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, from: 300) + #expect(window.start == 300) + #expect(window.offset == 0) + } + + @Test func soundEatsIntoTheLeadInBeforeItTouchesTheFile() { + // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. + let early = PatternSeek.soundWindow(offset: 500, from: 200) + #expect(early.start == 0) + #expect(early.offset == 300) + + // Past the lead-in, the remainder is a seek into the file. + let late = PatternSeek.soundWindow(offset: 500, from: 800) + #expect(late.start == 300) + #expect(late.offset == 0) + } +} 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..34c82982 --- /dev/null +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt @@ -0,0 +1,58 @@ +package com.swmansion.pulsar.kmp + +/** + * Re-anchors an authored pattern so that playing it from zero feels like playing the original + * from `fromMs`. The composer only ever starts at zero, so seeking replays a shifted copy. + */ +internal object PatternSeek { + /** The value the envelope holds at [atMs], interpolating between the surrounding points. */ + fun valueAt(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 + } + + /** + * An envelope whose points all sit before the seek HOLDS its last value for the rest of the + * pattern rather than emptying. Emptying it would silence the whole continuous channel: the + * composer builds that channel only when the amplitude AND frequency curves are both + * non-empty, so seeking past the end of either one kills both. + */ + fun envelopeFrom(points: List, fromMs: Long, remainingMs: Long): List { + if (points.isEmpty()) return emptyList() + val held = ValuePoint(time = 0L, value = valueAt(points, fromMs)) + val rest = points.filter { it.time > fromMs }.map { ValuePoint(it.time - fromMs, it.value) } + if (rest.isNotEmpty()) return listOf(held) + rest + return if (remainingMs > 0L) listOf(held, ValuePoint(remainingMs, held.value)) else listOf(held) + } + + /** The last authored timestamp in the pattern, across both lines. */ + fun durationOf(pattern: PatternData): Long = maxOf( + pattern.discretePattern.maxOfOrNull { it.time } ?: 0L, + pattern.continuousPattern.amplitude.maxOfOrNull { it.time } ?: 0L, + pattern.continuousPattern.frequency.maxOfOrNull { it.time } ?: 0L, + ) + + /** Drops the discrete events before [fromMs], rebases the rest, re-anchors both envelopes. */ + fun patternFrom(pattern: PatternData, fromMs: Long): PatternData { + if (fromMs <= 0L) return pattern + val remainingMs = durationOf(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) }, + ) + } +} 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..265d4843 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 @@ -2,6 +2,7 @@ package com.swmansion.pulsar.kmp.bundle import com.swmansion.pulsar.kmp.PatternComposer import com.swmansion.pulsar.kmp.PatternData +import com.swmansion.pulsar.kmp.PatternSeek import com.swmansion.pulsar.kmp.Pulsar import kotlinx.serialization.json.Json @@ -39,14 +40,29 @@ class PresetHandle internal constructor( private var composer: PatternComposer? = null - private fun ensureParsed() { - if (composer == null) { - composer = haptics.getPatternComposer().also { it.parsePattern(pattern) } - } + /** The seek position [composer] is currently parsed at, or null while unparsed. */ + private var parsedFromMs: Long? = null + + /** + * Parses at [fromMs], reusing the cached parse when the position has not moved. A preset + * played only from the start therefore still parses exactly once, as it always has. + */ + private fun ensureParsed(fromMs: Long) { + if (composer != null && parsedFromMs == fromMs) return + val c = composer ?: haptics.getPatternComposer() + c.parsePattern(PatternSeek.patternFrom(pattern, fromMs)) + composer = c + parsedFromMs = fromMs } - fun play() { - ensureParsed() + /** + * Plays the preset from [fromMs] into its timeline. Defaults to the start of the preset. + * + * The pattern is re-anchored and re-parsed on every non-zero seek; `play()` keeps the parse + * cached, so repeat plays from the start cost nothing extra. + */ + fun play(fromMs: Long = 0L) { + ensureParsed(maxOf(0L, fromMs)) composer?.play() } @@ -57,6 +73,7 @@ class PresetHandle internal constructor( internal fun dispose() { composer?.dispose() composer = null + parsedFromMs = null } } @@ -69,9 +86,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() } 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..4ee9bf87 --- /dev/null +++ b/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt @@ -0,0 +1,67 @@ +package com.swmansion.pulsar.kmp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Re-anchoring an authored pattern so it can be replayed from a seek position. */ +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.durationOf(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 }) + } + + /** + * Emptying it instead would silence BOTH channels, since the composer only builds the + * continuous line when amplitude and frequency are each non-empty. + */ + @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 anEmptyEnvelopeStaysEmpty() { + val noFrequency = PatternData( + ContinuousPattern(ramp.continuousPattern.amplitude, emptyList()), + emptyList(), + ) + assertTrue(PatternSeek.patternFrom(noFrequency, 250L).continuousPattern.frequency.isEmpty()) + } +} diff --git a/react-native/react-native-pulsar/BUNDLES.md b/react-native/react-native-pulsar/BUNDLES.md index 0d33b60c..d2f6dac1 100644 --- a/react-native/react-native-pulsar/BUNDLES.md +++ b/react-native/react-native-pulsar/BUNDLES.md @@ -70,10 +70,28 @@ 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 +``` + +Audio and haptics move together: the pattern is re-anchored — discrete events before the seek +are dropped, the rest rebased, and the continuous envelopes re-anchored on their value at that +instant — and the audio seeks to the matching position in the file. A preset authored with an +audio `offset` keeps its lead-in until the seek passes it. + +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..8ce02870 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 @@ -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..0b8e145b 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]; }); } diff --git a/react-native/react-native-pulsar/src/NativeRNPulsar.ts b/react-native/react-native-pulsar/src/NativeRNPulsar.ts index 5f11c875..99601c04 100644 --- a/react-native/react-native-pulsar/src/NativeRNPulsar.ts +++ b/react-native/react-native-pulsar/src/NativeRNPulsar.ts @@ -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..4b6d9221 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,30 @@ describe('loadBundleSync', () => { expect(native.PatternComposer_play).toHaveBeenLastCalledWith(100); }); + it('re-parses an inline pattern at each new seek and releases the last one', () => { + const bundle = defineBundle(definition).loadBundleSync(false); + + bundle.heartbeatV2.play(); + bundle.heartbeatV2.play(600); + bundle.heartbeatV2.play(600); + + expect(native.PatternComposer_parsePattern).toHaveBeenCalledTimes(2); + // The parse anchored at zero is freed once the seek replaces it. + expect(native.PatternComposer_release).toHaveBeenCalledWith(100); + expect(native.PatternComposer_play).toHaveBeenLastCalledWith(101); + + // Seeking past the last authored point leaves the envelope holding its final value, + // which is what keeps the continuous channel alive. + const seeked = native.PatternComposer_parsePattern.mock.calls[1]![0]!; + expect(seeked.discretePattern).toEqual([]); + expect(seeked.continuousPattern.amplitude).toEqual([ + { time: 0, value: 0.9 }, + ]); + expect(seeked.continuousPattern.frequency).toEqual([ + { time: 0, value: 0.5 }, + ]); + }); + it('exposes preset metadata and dynamic lookup', () => { const bundle = defineBundle(definition).loadBundleSync(); @@ -129,11 +153,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 +201,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/__tests__/patternSeek.test.ts b/react-native/react-native-pulsar/src/__tests__/patternSeek.test.ts new file mode 100644 index 00000000..4ee7a8cd --- /dev/null +++ b/react-native/react-native-pulsar/src/__tests__/patternSeek.test.ts @@ -0,0 +1,119 @@ +import { patternDurationMs, patternFrom } from '../patternSeek'; +import type { Pattern } from '../types'; + +const ramp: Pattern = { + discretePattern: [ + { time: 0, amplitude: 1, frequency: 0.5 }, + { time: 400, amplitude: 0.8, frequency: 0.4 }, + { time: 1000, amplitude: 0.6, frequency: 0.3 }, + ], + continuousPattern: { + amplitude: [ + { time: 0, value: 0 }, + { time: 1000, value: 1 }, + ], + frequency: [ + { time: 0, value: 0.2 }, + { time: 500, value: 0.8 }, + ], + }, +}; + +describe('patternDurationMs', () => { + it('takes the last authored timestamp across both lines', () => { + expect(patternDurationMs(ramp)).toBe(1000); + }); + + it('is zero for an empty pattern', () => { + expect( + patternDurationMs({ + discretePattern: [], + continuousPattern: { amplitude: [], frequency: [] }, + }) + ).toBe(0); + }); +}); + +describe('patternFrom', () => { + it('returns the pattern untouched at or before zero', () => { + expect(patternFrom(ramp, 0)).toBe(ramp); + expect(patternFrom(ramp, -100)).toBe(ramp); + }); + + it('drops the discrete events before the seek and rebases the rest', () => { + expect(patternFrom(ramp, 400).discretePattern).toEqual([ + { time: 0, amplitude: 0.8, frequency: 0.4 }, + { time: 600, amplitude: 0.6, frequency: 0.3 }, + ]); + }); + + it('re-anchors an envelope onto its interpolated value at the seek', () => { + expect(patternFrom(ramp, 250).continuousPattern.amplitude).toEqual([ + { time: 0, value: 0.25 }, + { time: 750, value: 1 }, + ]); + }); + + it('holds the last value when every point sits before the seek', () => { + // Emptying it instead would silence BOTH channels, since the composer only builds + // the continuous line when amplitude and frequency are each non-empty. + expect(patternFrom(ramp, 800).continuousPattern.frequency).toEqual([ + { time: 0, value: 0.8 }, + { time: 200, value: 0.8 }, + ]); + expect(patternFrom(ramp, 800).continuousPattern.amplitude).not.toEqual([]); + }); + + it('collapses a held envelope to a single point once nothing remains', () => { + expect(patternFrom(ramp, 1000).continuousPattern.frequency).toEqual([ + { time: 0, value: 0.8 }, + ]); + }); + + it('leaves an empty envelope empty', () => { + const noFrequency: Pattern = { + ...ramp, + continuousPattern: { ...ramp.continuousPattern, frequency: [] }, + }; + expect(patternFrom(noFrequency, 250).continuousPattern.frequency).toEqual( + [] + ); + }); + + describe('sound', () => { + const withSound = (sound: NonNullable): Pattern => ({ + ...ramp, + sound, + }); + + it('seeks into the file by the same amount', () => { + expect(patternFrom(withSound({ uri: 'clip.wav' }), 300).sound).toEqual({ + uri: 'clip.wav', + offset: 0, + start: 300, + duration: 0, + }); + }); + + it('eats into the lead-in before it touches the file', () => { + // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. + expect( + patternFrom(withSound({ uri: 'clip.wav', offset: 500 }), 200).sound + ).toEqual({ uri: 'clip.wav', offset: 300, start: 0, duration: 0 }); + + // Past the lead-in, the remainder is a seek into the file. + expect( + patternFrom(withSound({ uri: 'clip.wav', offset: 500 }), 800).sound + ).toEqual({ uri: 'clip.wav', offset: 0, start: 300, duration: 0 }); + }); + + it('shrinks an authored trim window and adds to its start', () => { + expect( + patternFrom( + withSound({ uri: 'clip.wav', start: 1000, duration: 900 }), + 400 + ).sound + ).toEqual({ uri: 'clip.wav', offset: 0, start: 1400, duration: 500 }); + }); + }); +}); diff --git a/react-native/react-native-pulsar/src/createBundle.ts b/react-native/react-native-pulsar/src/createBundle.ts index f4c9de33..748112b7 100644 --- a/react-native/react-native-pulsar/src/createBundle.ts +++ b/react-native/react-native-pulsar/src/createBundle.ts @@ -1,5 +1,6 @@ import { Image } from 'react-native'; import Pulsar from './NativeRNPulsar'; +import { patternFrom } from './patternSeek'; import type { Pattern } from './types'; // workaround for RN prototype caching issue @@ -21,7 +22,14 @@ export type PresetHandle = { readonly animation?: PresetAnimation; readonly hasAudio: boolean; readonly hasAnimation: boolean; - play: () => void; + /** + * Plays the preset — haptics plus its synced audio, if it has one. Pass `fromMs` to start + * that far into the preset's timeline: the pattern is re-anchored and the audio seeks to + * match. Defaults to the start. + * + * Every non-zero seek re-parses; playing from the start reuses the cached parse. + */ + play: (fromMs?: number) => void; stop: () => void; }; @@ -132,6 +140,8 @@ function createLoadedBundle( bundleToken?: string ): Bundle> { const parsedIds = new Map(); + // What each cached parse is anchored at, so a repeat play from the same position reuses it. + const parsedFrom = new Map(); const presets: Record = {}; let disposed = false; @@ -144,12 +154,20 @@ function createLoadedBundle( }; for (const [id, preset] of Object.entries(definition.presets)) { - const parseOnce = () => { + const parseAt = (fromMs: number) => { const alreadyParsed = parsedIds.get(id); - if (alreadyParsed !== undefined) return alreadyParsed; + if (alreadyParsed !== undefined && parsedFrom.get(id) === fromMs) { + return alreadyParsed; + } + if (alreadyParsed !== undefined) { + Pulsar.PatternComposer_release(alreadyParsed); + } - const parsedId = Pulsar.PatternComposer_parsePattern(preset.pattern); + const parsedId = Pulsar.PatternComposer_parsePattern( + patternFrom(preset.pattern, fromMs) + ); parsedIds.set(id, parsedId); + parsedFrom.set(id, fromMs); return parsedId; }; @@ -161,13 +179,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 +212,7 @@ function createLoadedBundle( Pulsar.PatternComposer_release(parsedId); } parsedIds.clear(); + parsedFrom.clear(); }, }); } diff --git a/react-native/react-native-pulsar/src/patternSeek.ts b/react-native/react-native-pulsar/src/patternSeek.ts new file mode 100644 index 00000000..bd675a9f --- /dev/null +++ b/react-native/react-native-pulsar/src/patternSeek.ts @@ -0,0 +1,105 @@ +import type { Pattern } from './types'; + +type EnvelopePoint = Pattern['continuousPattern']['amplitude'][number]; + +/** The value the envelope holds at `atMs`, interpolating between the surrounding points. */ +function valueAt(points: EnvelopePoint[], atMs: number): number { + if (points.length === 0) return 0; + const first = points[0]!; + const last = points[points.length - 1]!; + if (atMs <= first.time) return first.value; + if (atMs >= last.time) return last.value; + const nextIndex = points.findIndex((point) => point.time > atMs); + if (nextIndex <= 0) return last.value; + const before = points[nextIndex - 1]!; + const after = points[nextIndex]!; + const span = after.time - before.time; + if (span <= 0) return after.value; + return ( + before.value + ((after.value - before.value) * (atMs - before.time)) / span + ); +} + +/** + * An envelope whose points all sit before the seek HOLDS its last value for the rest of the + * pattern rather than emptying. Emptying it would silence the whole continuous channel: the + * composer builds that channel only when the amplitude AND frequency curves are both + * non-empty, so seeking past the end of either one kills both. + */ +function envelopeFrom( + points: EnvelopePoint[], + fromMs: number, + remainingMs: number +): EnvelopePoint[] { + if (points.length === 0) return []; + const held = { time: 0, value: valueAt(points, fromMs) }; + const rest = points + .filter((point) => point.time > fromMs) + .map((point) => ({ time: point.time - fromMs, value: point.value })); + if (rest.length > 0) return [held, ...rest]; + return remainingMs > 0 + ? [held, { time: remainingMs, value: held.value }] + : [held]; +} + +/** The last authored timestamp in the pattern, across both lines. */ +export function patternDurationMs(pattern: Pattern): number { + const latest = (points: { time: number }[]) => + points.reduce((so_far, point) => Math.max(so_far, point.time), 0); + return Math.max( + latest(pattern.discretePattern), + latest(pattern.continuousPattern.amplitude), + latest(pattern.continuousPattern.frequency) + ); +} + +/** + * Re-anchors a pattern so that playing it from zero feels like playing the original from + * `fromMs`: discrete events before the seek are dropped and the rest rebased, both envelopes + * re-anchored. The composer only ever starts at zero, so seeking replays a shifted copy. + */ +export function patternFrom(pattern: Pattern, fromMs: number): Pattern { + if (fromMs <= 0) return pattern; + const remainingMs = patternDurationMs(pattern) - fromMs; + const { sound } = pattern; + return { + ...pattern, + discretePattern: pattern.discretePattern + .filter((point) => point.time >= fromMs) + .map((point) => ({ ...point, time: point.time - fromMs })), + continuousPattern: { + amplitude: envelopeFrom( + pattern.continuousPattern.amplitude, + fromMs, + remainingMs + ), + frequency: envelopeFrom( + pattern.continuousPattern.frequency, + fromMs, + remainingMs + ), + }, + ...(sound ? { sound: soundFrom(sound, fromMs) } : {}), + }; +} + +/** + * Where the audio file and the haptics line up after a seek. + * + * A sound offset by `offset` ms is at file position `t - offset` when the haptics are at `t`, + * so seeking to `fromMs` either advances into the file or eats into the lead-in. A zero + * duration means "to the end of the file", so only an authored window shrinks. + */ +function soundFrom( + sound: NonNullable, + fromMs: number +): NonNullable { + const lead = Math.max(0, sound.offset ?? 0); + const intoFile = Math.max(0, fromMs - lead); + return { + ...sound, + offset: Math.max(0, lead - fromMs), + start: (sound.start ?? 0) + intoFile, + duration: sound.duration ? Math.max(0, sound.duration - intoFile) : 0, + }; +} From 74795dbd00cc1237536b9b162be8407535c55301 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 9 Sep 2026 18:39:18 +0200 Subject: [PATCH 2/5] feat(kmp): carry bundle audio, so a KMP preset plays and seeks like the others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KMP's `PresetHandle` hard-coded `hasAudio = false` and never looked at `preset.audio`, on the grounds that synced audio "needs platform temp-file extraction". Everything else was already in place: `AudioRef` is modelled in the manifest DTO, `PatternComposerHandle.parsePatternWithSound` exists, and both platform composers implement it — the iOS one already slices its audio window exactly like the Swift SDK, and the Android one delegates to a composer that seeks its MediaPlayer. So the only missing piece was the extraction itself: a `writeBundleMedia` expect/actual putting the archive's audio in a platform cache directory (Android `cacheDir`, iOS caches), which is what the Swift and Kotlin bundle loaders already do. With it, a KMP preset plays its audio and honours `play(fromMs)` on both platforms. Also: - `SoundData` gains `hapticChannels`, mirroring the Android SDK, so bundle audio can be marked plain music — otherwise an `.ogg` bundle track would take the coupled path and mute Pulsar's own haptics. - The iOS composer releases the CoreHaptics audio resource it replaces, the same leak just fixed in the Swift SDK. --- .../swmansion/pulsar/AndroidPulsarFactory.kt | 9 ++++- .../pulsar/bundle/BundleAssets.android.kt | 9 +++++ .../com/swmansion/pulsar/PatternSeek.kt | 17 ++++++++++ .../kotlin/com/swmansion/pulsar/Pulsar.kt | 4 +-- .../kotlin/com/swmansion/pulsar/Types.kt | 5 +++ .../swmansion/pulsar/bundle/BundleAssets.kt | 6 ++++ .../swmansion/pulsar/bundle/PulsarBundle.kt | 33 ++++++++++++++----- .../com/swmansion/pulsar/bundle/README.md | 11 ++++--- .../com/swmansion/pulsar/PatternSeekTest.kt | 21 ++++++++++++ .../pulsar/bundle/BundleAssets.ios.kt | 21 ++++++++++++ .../iosimpl/composers/PatternComposer.kt | 13 ++++++-- .../iosimpl/haptics/HapticEngineWrapper.kt | 9 +++++ .../com/swmansion/pulsar/kmp/app/App.kt | 2 -- 13 files changed, 139 insertions(+), 21 deletions(-) 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..63a5a157 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 @@ -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 index 34c82982..b8a19dc9 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt @@ -55,4 +55,21 @@ internal object PatternSeek { .map { it.copy(time = it.time - fromMs) }, ) } + + /** + * Where the audio file and the haptics line up after a seek. + * + * A sound offset by `offset` ms is at file position `t - offset` when the haptics are at `t`, + * so seeking to [fromMs] either advances into the file or eats into the lead-in. + */ + fun soundFrom(sound: SoundData, fromMs: Long): SoundData { + val lead = maxOf(0L, sound.offset) + val intoFile = maxOf(0L, fromMs - lead) + return sound.copy( + offset = maxOf(0L, lead - fromMs), + startMs = sound.startMs + intoFile, + // A zero duration means "to the end of the file", so only an authored window shrinks. + durationMs = if (sound.durationMs > 0L) maxOf(0L, sound.durationMs - intoFile) else 0L, + ) + } } 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/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 265d4843..1d3e5381 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 @@ -4,6 +4,7 @@ import com.swmansion.pulsar.kmp.PatternComposer import com.swmansion.pulsar.kmp.PatternData import com.swmansion.pulsar.kmp.PatternSeek 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. */ @@ -13,12 +14,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. */ @@ -31,9 +27,10 @@ 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 @@ -50,7 +47,12 @@ class PresetHandle internal constructor( private fun ensureParsed(fromMs: Long) { if (composer != null && parsedFromMs == fromMs) return val c = composer ?: haptics.getPatternComposer() - c.parsePattern(PatternSeek.patternFrom(pattern, fromMs)) + val seeked = PatternSeek.patternFrom(pattern, fromMs) + if (sound != null) { + c.parsePatternWithSound(seeked, PatternSeek.soundFrom(sound, fromMs)) + } else { + c.parsePattern(seeked) + } composer = c parsedFromMs = fromMs } @@ -144,6 +146,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, @@ -151,6 +165,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..109b6ac0 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 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 index 4ee9bf87..6f9c3395 100644 --- a/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt +++ b/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt @@ -56,6 +56,27 @@ class PatternSeekTest { 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() { + // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. + val early = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 200L) + assertEquals(300L, early.offset) + assertEquals(0L, early.startMs) + + // Past the lead-in, the remainder is a seek into the file. + 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( 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..bb51e4dd 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 @@ -25,6 +25,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,13 +49,16 @@ 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 + // The engine-side resource the current audio event plays, released alongside that temp file. + private var audioResourceId: CHHapticAudioResourceID? = null override fun parsePattern(pattern: PatternData) { + releaseAudio() parse(pattern, audioEvent = null) } override fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { - removeTempAudio() + releaseAudio() parse(pattern, audioEvent = makeAudioEvent(sound)) } @@ -133,6 +137,7 @@ internal class IOSPatternComposerHandle( sourceUrl } val resourceId = engine.registerAudioResource(url) ?: return null + audioResourceId = resourceId return CHHapticEvent( audioResourceID = resourceId, parameters = listOf( @@ -174,7 +179,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 +228,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..f7295296 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,15 @@ internal class IOSHapticEngineWrapper { return if (id == null || id.toLong() == 0L) null else id } + /** + * Releases a resource obtained from [registerAudioResource]. Re-parsing a sounded pattern + * registers a fresh one, so the replaced resource has to be handed back. + */ + 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) { From d2af82ab876dd82580186ca11f7b5b1af943a8f7 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 9 Sep 2026 19:03:57 +0200 Subject: [PATCH 3/5] refactor(bundles): let the composers own the seek, so RN stops duplicating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: React Native had its own copy of the re-anchoring logic in `src/patternSeek.ts`. It was only used on one path — `loadBundleSync()` with no binary, where the pattern lives in JS and goes over the generic `PatternComposer_parsePattern(data)` call, which had no offset to pass. The binary-backed path already delegated to native `LoadedBundle.play(id, fromMs)`. A second implementation of the same rule is a liability, so the offset moves down to where it belongs: `parsePattern(hapticsData, fromMs)` and `parsePatternWithSound(..., fromMs)` on the composers themselves. The composer is what can only start at zero, so it is what should compensate. Consequences: - `src/patternSeek.ts` and its test are gone; `createBundle.ts` sends the authored pattern plus `fromMs` and lets native re-anchor it, on both paths. - `PresetHandle` no longer does seek arithmetic on any platform — it forwards `fromMs` — so `PatternSeek` now has exactly one caller per SDK. - `PatternSeek.soundWindow` takes the authored `start`/`duration` too, since the composer applies it to sounds that already carry a trim window. `fromMs` defaults to 0 everywhere, so every existing caller is untouched; the one exception is the RN ObjC bridge, because a Swift default argument still changes the generated selector. --- .../swmansion/pulsar/bundle/PulsarBundle.kt | 8 +- .../pulsar/composers/PatternComposer.kt | 19 ++- .../Sources/Pulsar/Bundle/PulsarBundle.swift | 13 +- .../Pulsar/Composers/PatternComposer.swift | 21 +++- .../Sources/Pulsar/Types/PatternSeek.swift | 18 ++- .../Tests/PulsarTests/PatternSeekTests.swift | 13 +- .../swmansion/pulsar/AndroidPulsarFactory.kt | 8 +- .../com/swmansion/pulsar/PulsarControllers.kt | 20 ++- .../com/swmansion/pulsar/PulsarRuntime.kt | 4 +- .../swmansion/pulsar/bundle/PulsarBundle.kt | 8 +- .../com/swmansion/pulsar/PulsarFacadeTest.kt | 7 +- .../iosimpl/composers/PatternComposer.kt | 12 +- .../pulsar/reactnative/PulsarModule.kt | 6 +- .../react-native-pulsar/ios/Haptics.mm | 8 +- .../react-native-pulsar/src/NativeRNPulsar.ts | 2 +- .../src/__tests__/createBundle.test.tsx | 24 ++-- .../src/__tests__/patternSeek.test.ts | 119 ------------------ .../react-native-pulsar/src/createBundle.ts | 5 +- .../react-native-pulsar/src/patternSeek.ts | 105 ---------------- .../src/usePatternComposer.ts | 2 +- 20 files changed, 122 insertions(+), 300 deletions(-) delete mode 100644 react-native/react-native-pulsar/src/__tests__/patternSeek.test.ts delete mode 100644 react-native/react-native-pulsar/src/patternSeek.ts 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 fb27ad53..2625a486 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 @@ -3,7 +3,6 @@ package com.swmansion.pulsar.bundle import com.swmansion.pulsar.Pulsar import com.swmansion.pulsar.composers.PatternComposer import com.swmansion.pulsar.types.PatternData -import com.swmansion.pulsar.types.PatternSeek import com.swmansion.pulsar.types.SoundData /** Lottie bytes + timing for a preset's animation; the host app's own Lottie view renders it. */ @@ -46,12 +45,7 @@ class PresetHandle internal constructor( private fun ensureParsed(fromMs: Long) { if (composer != null && parsedFromMs == fromMs) return val c = composer ?: haptics.getPatternComposer() - val seeked = PatternSeek.patternFrom(pattern, fromMs) - if (sound != null) { - c.parsePatternWithSound(seeked, PatternSeek.soundFrom(sound, fromMs)) - } else { - c.parsePattern(seeked) - } + if (sound != null) c.parsePatternWithSound(pattern, sound, fromMs) else c.parsePattern(pattern, fromMs) composer = c parsedFromMs = fromMs } 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..505918ca 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,7 +29,13 @@ class PatternComposer( private var soundPlayer: AudioHapticPlayer? = null private var useCoupledHaptics = false - fun parsePattern(hapticsData: PatternData) { + /** + * Parses a pattern for playback. [fromMs] starts it that far into its own timeline: the + * engine can only play a parsed pattern from zero, so the pattern is re-anchored instead. + */ + @JvmOverloads + fun parsePattern(hapticsData0: PatternData, fromMs: Long = 0L) { + val hapticsData = PatternSeek.patternFrom(hapticsData0, fromMs) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrationEffect = try { engine.getHapticBuilder().createVibrationEffect(hapticsData) @@ -46,9 +53,15 @@ class PatternComposer( audioBuffer = audioSimulator.parsePattern(hapticsData) } - fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData) { - parsePattern(hapticsData) + /** + * As [parsePattern], with a synced audio track. The sound's own `startMs`/`durationMs` are + * the authored trim window in the file; [fromMs] seeks the whole preset, moving both together. + */ + @JvmOverloads + fun parsePatternWithSound(hapticsData: PatternData, sound0: SoundData, fromMs: Long = 0L) { + parsePattern(hapticsData, fromMs) + val sound = PatternSeek.soundFrom(sound0, fromMs) soundPlayer?.release() useCoupledHaptics = sound.hapticChannels && isOggUri(sound.uri) && engine.supportsAudioCoupledHaptics() diff --git a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift index 4a753a9a..6001e88f 100644 --- a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift @@ -90,19 +90,18 @@ struct ResolvedSound { private func ensureParsed(fromMs: Double) { guard composer == nil || parsedFromMs != fromMs, let pulsar = pulsar else { return } let c = composer ?? pulsar.getPatternComposer() - let seeked = PatternSeek.pattern(pattern, from: fromMs) if let s = sound { - let window = PatternSeek.soundWindow(offset: s.offset, from: fromMs) c.parsePatternWithSound( - hapticsData: seeked, + hapticsData: pattern, uri: s.uri, volume: s.volume, - offset: window.offset, - start: window.start, - duration: 0 + offset: s.offset, + start: 0, + duration: 0, + fromMs: fromMs ) } else { - c.parsePattern(hapticsData: seeked) + c.parsePattern(hapticsData: pattern, fromMs: fromMs) } composer = c parsedFromMs = fromMs diff --git a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift index c119cd50..8b4b007c 100644 --- a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift +++ b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift @@ -31,15 +31,26 @@ public class PatternComposer: NSObject { dispose() } - @objc public func parsePattern(hapticsData: PatternData) { + /// Parses a pattern for playback. `fromMs` starts it that far into its own timeline: the + /// engine can only play a parsed pattern from zero, so the pattern is re-anchored instead. + @objc public func parsePattern(hapticsData: PatternData, fromMs: Double = 0) { releaseAudio() - parse(hapticsData: hapticsData, audioEvent: nil) + 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) { + /// As ``parsePattern(hapticsData:fromMs:)``, with a synced audio track. `start`/`duration` are + /// the authored trim window in the file; `fromMs` seeks the whole preset, moving both 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 audioEvent = makeAudioEvent(uri: uri, volume: volume, offset: offset, start: start, duration: duration) - parse(hapticsData: hapticsData, audioEvent: audioEvent) + 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?) { diff --git a/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift index ce906ccd..ae136e04 100644 --- a/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift +++ b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift @@ -63,10 +63,20 @@ enum PatternSeek { /// Where the audio file and the haptics line up after a seek. /// /// A sound offset by `offset` ms is at file position `t - offset` when the haptics are at - /// `t`, so seeking to `fromMs` either advances into the file or eats into the lead-in. - static func soundWindow(offset: Double, from fromMs: Double) -> (start: Double, offset: Double) { + /// `t`, so seeking to `fromMs` either advances into the file or eats into the lead-in. A zero + /// `duration` means "to the end of the file", so only an authored trim window shrinks. + static func soundWindow( + offset: Double, + start: Double, + duration: Double, + from fromMs: Double + ) -> (start: Double, duration: Double, offset: Double) { let lead = max(0, offset) - if fromMs <= lead { return (start: 0, offset: lead - fromMs) } - return (start: fromMs - lead, offset: 0) + let intoFile = max(0, fromMs - lead) + return ( + start: start + intoFile, + duration: duration > 0 ? max(0, duration - intoFile) : 0, + offset: max(0, lead - fromMs) + ) } } diff --git a/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift b/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift index d0f1b0c1..14277eef 100644 --- a/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift +++ b/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift @@ -73,20 +73,27 @@ import Foundation } @Test func soundSeeksIntoTheFileByTheSameAmount() { - let window = PatternSeek.soundWindow(offset: 0, from: 300) + 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() { // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. - let early = PatternSeek.soundWindow(offset: 500, from: 200) + let early = PatternSeek.soundWindow(offset: 500, start: 0, duration: 0, from: 200) #expect(early.start == 0) #expect(early.offset == 300) // Past the lead-in, the remainder is a seek into the file. - let late = PatternSeek.soundWindow(offset: 500, from: 800) + 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 63a5a157..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) { 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..4bd7600b 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,20 @@ class PulsarPreset internal constructor( class PatternComposer internal constructor( private val handle: PatternComposerHandle, ) { - fun parsePattern(pattern: PatternData) { - handle.parsePattern(pattern) - } - - fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { - handle.parsePatternWithSound(pattern, sound) + /** + * Parses a pattern for playback. [fromMs] starts it that far into its own timeline: the + * engine can only play a parsed pattern from zero, so the pattern is re-anchored instead. + */ + fun parsePattern(pattern: PatternData, fromMs: Long = 0L) { + handle.parsePattern(pattern, fromMs) + } + + /** + * As [parsePattern], with a synced audio track. The sound's own `startMs`/`durationMs` are + * the authored trim window in the file; [fromMs] seeks the whole preset, moving both 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/bundle/PulsarBundle.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/bundle/PulsarBundle.kt index 1d3e5381..0767213f 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 @@ -2,7 +2,6 @@ package com.swmansion.pulsar.kmp.bundle import com.swmansion.pulsar.kmp.PatternComposer import com.swmansion.pulsar.kmp.PatternData -import com.swmansion.pulsar.kmp.PatternSeek import com.swmansion.pulsar.kmp.Pulsar import com.swmansion.pulsar.kmp.SoundData import kotlinx.serialization.json.Json @@ -47,12 +46,7 @@ class PresetHandle internal constructor( private fun ensureParsed(fromMs: Long) { if (composer != null && parsedFromMs == fromMs) return val c = composer ?: haptics.getPatternComposer() - val seeked = PatternSeek.patternFrom(pattern, fromMs) - if (sound != null) { - c.parsePatternWithSound(seeked, PatternSeek.soundFrom(sound, fromMs)) - } else { - c.parsePattern(seeked) - } + if (sound != null) c.parsePatternWithSound(pattern, sound, fromMs) else c.parsePattern(pattern, fromMs) composer = c parsedFromMs = fromMs } 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/iosimpl/composers/PatternComposer.kt b/kmp/Pulsar/library/src/iosMain/kotlin/com/swmansion/pulsar/iosimpl/composers/PatternComposer.kt index bb51e4dd..803d6c47 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 @@ -52,14 +53,17 @@ internal class IOSPatternComposerHandle( // The engine-side resource the current audio event plays, released alongside that temp file. private var audioResourceId: CHHapticAudioResourceID? = null - override fun parsePattern(pattern: PatternData) { + override fun parsePattern(pattern: PatternData, fromMs: Long) { releaseAudio() - parse(pattern, audioEvent = null) + parse(PatternSeek.patternFrom(pattern, fromMs), audioEvent = null) } - override fun parsePatternWithSound(pattern: PatternData, sound: SoundData) { + override fun parsePatternWithSound(pattern: PatternData, sound: SoundData, fromMs: Long) { releaseAudio() - parse(pattern, audioEvent = makeAudioEvent(sound)) + parse( + PatternSeek.patternFrom(pattern, fromMs), + audioEvent = makeAudioEvent(PatternSeek.soundFrom(sound, fromMs)), + ) } private fun parse(pattern: PatternData, audioEvent: CHHapticEvent?) { 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 8ce02870..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 diff --git a/react-native/react-native-pulsar/ios/Haptics.mm b/react-native/react-native-pulsar/ios/Haptics.mm index 0b8e145b..caa8bcd6 100644 --- a/react-native/react-native-pulsar/ios/Haptics.mm +++ b/react-native/react-native-pulsar/ios/Haptics.mm @@ -302,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++; @@ -328,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 99601c04..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, 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 4b6d9221..3f958690 100644 --- a/react-native/react-native-pulsar/src/__tests__/createBundle.test.tsx +++ b/react-native/react-native-pulsar/src/__tests__/createBundle.test.tsx @@ -107,7 +107,7 @@ describe('loadBundleSync', () => { expect(native.PatternComposer_play).toHaveBeenLastCalledWith(100); }); - it('re-parses an inline pattern at each new seek and releases the last one', () => { + it('hands the seek to the native composer and re-parses only when it moves', () => { const bundle = defineBundle(definition).loadBundleSync(false); bundle.heartbeatV2.play(); @@ -115,20 +115,20 @@ describe('loadBundleSync', () => { bundle.heartbeatV2.play(600); expect(native.PatternComposer_parsePattern).toHaveBeenCalledTimes(2); + // The authored pattern goes over untouched — the native side re-anchors it. + expect(native.PatternComposer_parsePattern).toHaveBeenNthCalledWith( + 1, + definition.presets.heartbeatV2.pattern, + 0 + ); + expect(native.PatternComposer_parsePattern).toHaveBeenNthCalledWith( + 2, + definition.presets.heartbeatV2.pattern, + 600 + ); // The parse anchored at zero is freed once the seek replaces it. expect(native.PatternComposer_release).toHaveBeenCalledWith(100); expect(native.PatternComposer_play).toHaveBeenLastCalledWith(101); - - // Seeking past the last authored point leaves the envelope holding its final value, - // which is what keeps the continuous channel alive. - const seeked = native.PatternComposer_parsePattern.mock.calls[1]![0]!; - expect(seeked.discretePattern).toEqual([]); - expect(seeked.continuousPattern.amplitude).toEqual([ - { time: 0, value: 0.9 }, - ]); - expect(seeked.continuousPattern.frequency).toEqual([ - { time: 0, value: 0.5 }, - ]); }); it('exposes preset metadata and dynamic lookup', () => { diff --git a/react-native/react-native-pulsar/src/__tests__/patternSeek.test.ts b/react-native/react-native-pulsar/src/__tests__/patternSeek.test.ts deleted file mode 100644 index 4ee7a8cd..00000000 --- a/react-native/react-native-pulsar/src/__tests__/patternSeek.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { patternDurationMs, patternFrom } from '../patternSeek'; -import type { Pattern } from '../types'; - -const ramp: Pattern = { - discretePattern: [ - { time: 0, amplitude: 1, frequency: 0.5 }, - { time: 400, amplitude: 0.8, frequency: 0.4 }, - { time: 1000, amplitude: 0.6, frequency: 0.3 }, - ], - continuousPattern: { - amplitude: [ - { time: 0, value: 0 }, - { time: 1000, value: 1 }, - ], - frequency: [ - { time: 0, value: 0.2 }, - { time: 500, value: 0.8 }, - ], - }, -}; - -describe('patternDurationMs', () => { - it('takes the last authored timestamp across both lines', () => { - expect(patternDurationMs(ramp)).toBe(1000); - }); - - it('is zero for an empty pattern', () => { - expect( - patternDurationMs({ - discretePattern: [], - continuousPattern: { amplitude: [], frequency: [] }, - }) - ).toBe(0); - }); -}); - -describe('patternFrom', () => { - it('returns the pattern untouched at or before zero', () => { - expect(patternFrom(ramp, 0)).toBe(ramp); - expect(patternFrom(ramp, -100)).toBe(ramp); - }); - - it('drops the discrete events before the seek and rebases the rest', () => { - expect(patternFrom(ramp, 400).discretePattern).toEqual([ - { time: 0, amplitude: 0.8, frequency: 0.4 }, - { time: 600, amplitude: 0.6, frequency: 0.3 }, - ]); - }); - - it('re-anchors an envelope onto its interpolated value at the seek', () => { - expect(patternFrom(ramp, 250).continuousPattern.amplitude).toEqual([ - { time: 0, value: 0.25 }, - { time: 750, value: 1 }, - ]); - }); - - it('holds the last value when every point sits before the seek', () => { - // Emptying it instead would silence BOTH channels, since the composer only builds - // the continuous line when amplitude and frequency are each non-empty. - expect(patternFrom(ramp, 800).continuousPattern.frequency).toEqual([ - { time: 0, value: 0.8 }, - { time: 200, value: 0.8 }, - ]); - expect(patternFrom(ramp, 800).continuousPattern.amplitude).not.toEqual([]); - }); - - it('collapses a held envelope to a single point once nothing remains', () => { - expect(patternFrom(ramp, 1000).continuousPattern.frequency).toEqual([ - { time: 0, value: 0.8 }, - ]); - }); - - it('leaves an empty envelope empty', () => { - const noFrequency: Pattern = { - ...ramp, - continuousPattern: { ...ramp.continuousPattern, frequency: [] }, - }; - expect(patternFrom(noFrequency, 250).continuousPattern.frequency).toEqual( - [] - ); - }); - - describe('sound', () => { - const withSound = (sound: NonNullable): Pattern => ({ - ...ramp, - sound, - }); - - it('seeks into the file by the same amount', () => { - expect(patternFrom(withSound({ uri: 'clip.wav' }), 300).sound).toEqual({ - uri: 'clip.wav', - offset: 0, - start: 300, - duration: 0, - }); - }); - - it('eats into the lead-in before it touches the file', () => { - // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. - expect( - patternFrom(withSound({ uri: 'clip.wav', offset: 500 }), 200).sound - ).toEqual({ uri: 'clip.wav', offset: 300, start: 0, duration: 0 }); - - // Past the lead-in, the remainder is a seek into the file. - expect( - patternFrom(withSound({ uri: 'clip.wav', offset: 500 }), 800).sound - ).toEqual({ uri: 'clip.wav', offset: 0, start: 300, duration: 0 }); - }); - - it('shrinks an authored trim window and adds to its start', () => { - expect( - patternFrom( - withSound({ uri: 'clip.wav', start: 1000, duration: 900 }), - 400 - ).sound - ).toEqual({ uri: 'clip.wav', offset: 0, start: 1400, duration: 500 }); - }); - }); -}); diff --git a/react-native/react-native-pulsar/src/createBundle.ts b/react-native/react-native-pulsar/src/createBundle.ts index 748112b7..103975e3 100644 --- a/react-native/react-native-pulsar/src/createBundle.ts +++ b/react-native/react-native-pulsar/src/createBundle.ts @@ -1,6 +1,5 @@ import { Image } from 'react-native'; import Pulsar from './NativeRNPulsar'; -import { patternFrom } from './patternSeek'; import type { Pattern } from './types'; // workaround for RN prototype caching issue @@ -163,8 +162,10 @@ function createLoadedBundle( Pulsar.PatternComposer_release(alreadyParsed); } + // The native composer re-anchors the pattern, the same way the bundle path does. const parsedId = Pulsar.PatternComposer_parsePattern( - patternFrom(preset.pattern, fromMs) + preset.pattern, + fromMs ); parsedIds.set(id, parsedId); parsedFrom.set(id, fromMs); diff --git a/react-native/react-native-pulsar/src/patternSeek.ts b/react-native/react-native-pulsar/src/patternSeek.ts deleted file mode 100644 index bd675a9f..00000000 --- a/react-native/react-native-pulsar/src/patternSeek.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Pattern } from './types'; - -type EnvelopePoint = Pattern['continuousPattern']['amplitude'][number]; - -/** The value the envelope holds at `atMs`, interpolating between the surrounding points. */ -function valueAt(points: EnvelopePoint[], atMs: number): number { - if (points.length === 0) return 0; - const first = points[0]!; - const last = points[points.length - 1]!; - if (atMs <= first.time) return first.value; - if (atMs >= last.time) return last.value; - const nextIndex = points.findIndex((point) => point.time > atMs); - if (nextIndex <= 0) return last.value; - const before = points[nextIndex - 1]!; - const after = points[nextIndex]!; - const span = after.time - before.time; - if (span <= 0) return after.value; - return ( - before.value + ((after.value - before.value) * (atMs - before.time)) / span - ); -} - -/** - * An envelope whose points all sit before the seek HOLDS its last value for the rest of the - * pattern rather than emptying. Emptying it would silence the whole continuous channel: the - * composer builds that channel only when the amplitude AND frequency curves are both - * non-empty, so seeking past the end of either one kills both. - */ -function envelopeFrom( - points: EnvelopePoint[], - fromMs: number, - remainingMs: number -): EnvelopePoint[] { - if (points.length === 0) return []; - const held = { time: 0, value: valueAt(points, fromMs) }; - const rest = points - .filter((point) => point.time > fromMs) - .map((point) => ({ time: point.time - fromMs, value: point.value })); - if (rest.length > 0) return [held, ...rest]; - return remainingMs > 0 - ? [held, { time: remainingMs, value: held.value }] - : [held]; -} - -/** The last authored timestamp in the pattern, across both lines. */ -export function patternDurationMs(pattern: Pattern): number { - const latest = (points: { time: number }[]) => - points.reduce((so_far, point) => Math.max(so_far, point.time), 0); - return Math.max( - latest(pattern.discretePattern), - latest(pattern.continuousPattern.amplitude), - latest(pattern.continuousPattern.frequency) - ); -} - -/** - * Re-anchors a pattern so that playing it from zero feels like playing the original from - * `fromMs`: discrete events before the seek are dropped and the rest rebased, both envelopes - * re-anchored. The composer only ever starts at zero, so seeking replays a shifted copy. - */ -export function patternFrom(pattern: Pattern, fromMs: number): Pattern { - if (fromMs <= 0) return pattern; - const remainingMs = patternDurationMs(pattern) - fromMs; - const { sound } = pattern; - return { - ...pattern, - discretePattern: pattern.discretePattern - .filter((point) => point.time >= fromMs) - .map((point) => ({ ...point, time: point.time - fromMs })), - continuousPattern: { - amplitude: envelopeFrom( - pattern.continuousPattern.amplitude, - fromMs, - remainingMs - ), - frequency: envelopeFrom( - pattern.continuousPattern.frequency, - fromMs, - remainingMs - ), - }, - ...(sound ? { sound: soundFrom(sound, fromMs) } : {}), - }; -} - -/** - * Where the audio file and the haptics line up after a seek. - * - * A sound offset by `offset` ms is at file position `t - offset` when the haptics are at `t`, - * so seeking to `fromMs` either advances into the file or eats into the lead-in. A zero - * duration means "to the end of the file", so only an authored window shrinks. - */ -function soundFrom( - sound: NonNullable, - fromMs: number -): NonNullable { - const lead = Math.max(0, sound.offset ?? 0); - const intoFile = Math.max(0, fromMs - lead); - return { - ...sound, - offset: Math.max(0, lead - fromMs), - start: (sound.start ?? 0) + intoFile, - duration: sound.duration ? Math.max(0, sound.duration - intoFile) : 0, - }; -} 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]); From f6721e57440d9672a04732c5310283be45a0ef8a Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 9 Sep 2026 19:27:53 +0200 Subject: [PATCH 4/5] docs(bundles): document play(fromMs) across the SDK docs, and let names carry the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of review feedback. Comments: the seek work leaned on narrative block comments where naming would do. Extracting `holdingLastValue` carries the envelope rule in its name, and `seekIntoFile` / `leadIn` / `playsToEndOfFile` / `alreadyParsedHere` / `seekedPattern` retire the rest. `duration(of:)` became `lastTimestamp(of:)`, which is what it actually returns, and `value`/`valueAt` became `interpolatedValue`/`interpolatedValueAt`. What survives is the one fact a reader cannot derive locally: emptying an envelope silences both continuous channels, because the composer builds that line only when the amplitude and frequency curves are each non-empty. Docs: `play(fromMs)` was only in BUNDLES.md. It now appears wherever the bundle API is described — the five SDK pages on the docs site, and the iOS, Android, KMP and Flutter bundle READMEs — alongside the updated `parsePattern` / `parsePatternWithSound` signatures. The KMP page's caution that synced bundle audio "is not wired yet" is gone, since it now is. --- .../swmansion/pulsar/bundle/PulsarBundle.kt | 16 ++---- .../com/swmansion/pulsar/bundle/README.md | 2 + .../pulsar/composers/PatternComposer.kt | 30 +++++------ .../com/swmansion/pulsar/types/PatternSeek.kt | 54 +++++++++---------- .../swmansion/pulsar/types/PatternSeekTest.kt | 11 +--- docs/src/content/docs/sdk/android.mdx | 18 +++++-- docs/src/content/docs/sdk/flutter.mdx | 11 +++- docs/src/content/docs/sdk/ios.mdx | 19 +++++-- docs/src/content/docs/sdk/kmp.mdx | 24 +++++---- docs/src/content/docs/sdk/react-native.mdx | 13 +++++ flutter/pulsar/BUNDLES.md | 1 + flutter/pulsar/lib/src/pulsar_bundle.dart | 7 +-- .../Sources/Pulsar/Bundle/PulsarBundle.swift | 10 +--- iOS/Pulsar/Sources/Pulsar/Bundle/README.md | 2 + .../Pulsar/Composers/PatternComposer.swift | 8 ++- .../Sources/Pulsar/HapticEngineWrapper.swift | 2 - .../Sources/Pulsar/Types/PatternSeek.swift | 50 ++++++++--------- .../Tests/PulsarTests/PatternSeekTests.swift | 9 +--- .../com/swmansion/pulsar/PatternSeek.kt | 54 +++++++++---------- .../com/swmansion/pulsar/PulsarControllers.kt | 9 ++-- .../swmansion/pulsar/bundle/PulsarBundle.kt | 15 ++---- .../com/swmansion/pulsar/bundle/README.md | 1 + .../com/swmansion/pulsar/PatternSeekTest.kt | 9 +--- .../iosimpl/composers/PatternComposer.kt | 1 - .../iosimpl/haptics/HapticEngineWrapper.kt | 4 -- react-native/react-native-pulsar/BUNDLES.md | 9 ++-- .../src/__tests__/createBundle.test.tsx | 2 - .../react-native-pulsar/src/createBundle.ts | 22 +++----- 28 files changed, 197 insertions(+), 216 deletions(-) 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 2625a486..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,28 +35,18 @@ class PresetHandle internal constructor( private var composer: PatternComposer? = null - /** The seek position [composer] is currently parsed at, or null while unparsed. */ private var parsedFromMs: Long? = null - /** - * Parses at [fromMs], reusing the cached parse when the position has not moved. A preset - * played only from the start therefore still parses exactly once, as it always has. - */ private fun ensureParsed(fromMs: Long) { - if (composer != null && parsedFromMs == fromMs) return + 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 } - /** - * Plays the preset from [fromMs] into its timeline, audio and haptics together. Defaults to - * the start of the preset. - * - * The pattern is re-anchored and re-parsed on every non-zero seek; `play()` keeps the parse - * cached, so repeat plays from the start cost nothing extra. - */ + /** Plays the preset from [fromMs] into its timeline, audio and haptics together. */ @JvmOverloads fun play(fromMs: Long = 0L) { ensureParsed(maxOf(0L, fromMs)) 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 505918ca..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 @@ -29,46 +29,44 @@ class PatternComposer( private var soundPlayer: AudioHapticPlayer? = null private var useCoupledHaptics = false - /** - * Parses a pattern for playback. [fromMs] starts it that far into its own timeline: the - * engine can only play a parsed pattern from zero, so the pattern is re-anchored instead. - */ + /** [fromMs] starts the pattern that far into its own timeline. */ @JvmOverloads - fun parsePattern(hapticsData0: PatternData, fromMs: Long = 0L) { - val hapticsData = PatternSeek.patternFrom(hapticsData0, fromMs) + 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) } /** - * As [parsePattern], with a synced audio track. The sound's own `startMs`/`durationMs` are - * the authored trim window in the file; [fromMs] seeks the whole preset, moving both together. + * 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, sound0: SoundData, fromMs: Long = 0L) { + fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData, fromMs: Long = 0L) { parsePattern(hapticsData, fromMs) - val sound = PatternSeek.soundFrom(sound0, 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 index 99b785c4..e8b5fdc5 100644 --- a/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/PatternSeek.kt +++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/types/PatternSeek.kt @@ -1,12 +1,11 @@ package com.swmansion.pulsar.types /** - * Re-anchors an authored pattern so that playing it from zero feels like playing the original - * from `fromMs`. The composer only ever starts at zero, so seeking replays a shifted copy. + * 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 { - /** The value the envelope holds at [atMs], interpolating between the surrounding points. */ - fun valueAt(points: List, atMs: Long): Float { + fun interpolatedValueAt(points: List, atMs: Long): Float { val first = points.firstOrNull() ?: return 0f val last = points.last() if (atMs <= first.time) return first.value @@ -20,31 +19,32 @@ internal object PatternSeek { return before.value + (after.value - before.value) * (atMs - before.time).toFloat() / span } - /** - * An envelope whose points all sit before the seek HOLDS its last value for the rest of the - * pattern rather than emptying. Emptying it would silence the whole continuous channel: the - * composer builds that channel only when the amplitude AND frequency curves are both - * non-empty, so seeking past the end of either one kills both. - */ fun envelopeFrom(points: List, fromMs: Long, remainingMs: Long): List { if (points.isEmpty()) return emptyList() - val held = ValuePoint(time = 0L, value = valueAt(points, fromMs)) - val rest = points.filter { it.time > fromMs }.map { ValuePoint(it.time - fromMs, it.value) } - if (rest.isNotEmpty()) return listOf(held) + rest - return if (remainingMs > 0L) listOf(held, ValuePoint(remainingMs, held.value)) else listOf(held) + 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) } - /** The last authored timestamp in the pattern, across both lines. */ - fun durationOf(pattern: PatternData): Long = maxOf( + /** + * 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, ) - /** Drops the discrete events before [fromMs], rebases the rest, re-anchors both envelopes. */ fun patternFrom(pattern: PatternData, fromMs: Long): PatternData { if (fromMs <= 0L) return pattern - val remainingMs = durationOf(pattern) - fromMs + val remainingMs = lastTimestampOf(pattern) - fromMs return PatternData( continuousPattern = ContinuousPattern( amplitude = envelopeFrom(pattern.continuousPattern.amplitude, fromMs, remainingMs), @@ -57,19 +57,17 @@ internal object PatternSeek { } /** - * Where the audio file and the haptics line up after a seek. - * - * A sound offset by `offset` ms is at file position `t - offset` when the haptics are at `t`, - * so seeking to [fromMs] either advances into the file or eats into the lead-in. + * 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 lead = maxOf(0L, sound.offset) - val intoFile = maxOf(0L, fromMs - lead) + val leadIn = maxOf(0L, sound.offset) + val seekIntoFile = maxOf(0L, fromMs - leadIn) + val playsToEndOfFile = sound.durationMs <= 0L return sound.copy( - offset = maxOf(0L, lead - fromMs), - startMs = sound.startMs + intoFile, - // A zero duration means "to the end of the file", so only an authored window shrinks. - durationMs = if (sound.durationMs > 0L) maxOf(0L, sound.durationMs - intoFile) else 0L, + 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 index 461578eb..0d4f43ab 100644 --- a/Android/Pulsar/src/test/java/com/swmansion/pulsar/types/PatternSeekTest.kt +++ b/Android/Pulsar/src/test/java/com/swmansion/pulsar/types/PatternSeekTest.kt @@ -5,7 +5,6 @@ import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test -/** Re-anchoring an authored pattern so it can be replayed from a seek position. */ class PatternSeekTest { private val ramp = PatternData( @@ -22,9 +21,9 @@ class PatternSeekTest { @Test fun `duration is the last timestamp across both lines`() { - assertEquals(1000L, PatternSeek.durationOf(ramp)) + assertEquals(1000L, PatternSeek.lastTimestampOf(ramp)) val empty = PatternData(ContinuousPattern(emptyList(), emptyList()), emptyList()) - assertEquals(0L, PatternSeek.durationOf(empty)) + assertEquals(0L, PatternSeek.lastTimestampOf(empty)) } @Test @@ -47,10 +46,6 @@ class PatternSeekTest { assertEquals(listOf(0.25f, 1f), seeked.continuousPattern.amplitude.map { it.value }) } - /** - * Emptying it instead would silence BOTH channels, since the composer only builds the - * continuous line when amplitude and frequency are each non-empty. - */ @Test fun `an envelope entirely before the seek holds its last value`() { val seeked = PatternSeek.patternFrom(ramp, 800L) @@ -84,12 +79,10 @@ class PatternSeekTest { @Test fun `sound eats into the lead-in before it touches the file`() { - // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. val early = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 200L) assertEquals(300L, early.offset) assertEquals(0L, early.startMs) - // Past the lead-in, the remainder is a seek into the file. val late = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 800L) assertEquals(0L, late.offset) assertEquals(300L, late.startMs) 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/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/lib/src/pulsar_bundle.dart b/flutter/pulsar/lib/src/pulsar_bundle.dart index 331be069..2e049d50 100644 --- a/flutter/pulsar/lib/src/pulsar_bundle.dart +++ b/flutter/pulsar/lib/src/pulsar_bundle.dart @@ -92,11 +92,8 @@ 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. - /// - /// Pass [fromMs] to start that far into the preset's timeline: the pattern is - /// re-anchored and the audio seeks to match. Defaults to the start. Every - /// non-zero seek re-parses; playing from the start reuses the cached parse. + /// 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), ); diff --git a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift index 6001e88f..59800413 100644 --- a/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift +++ b/iOS/Pulsar/Sources/Pulsar/Bundle/PulsarBundle.swift @@ -72,7 +72,6 @@ struct ResolvedSound { private weak var pulsar: Pulsar? private let sound: ResolvedSound? private var composer: PatternComposer? - /// The seek position the cached ``composer`` is currently parsed at, or nil while unparsed. private var parsedFromMs: Double? init(id: String, name: String, duration: Double, pulsar: Pulsar, pattern: PatternData, sound: ResolvedSound?, animation: BundleAnimation?) { @@ -85,10 +84,9 @@ struct ResolvedSound { self.animation = animation } - /// Parses at `fromMs`, reusing the cached parse when the position has not moved. A preset - /// played only from the start therefore still parses exactly once, as it always has. private func ensureParsed(fromMs: Double) { - guard composer == nil || parsedFromMs != fromMs, let pulsar = pulsar else { return } + let alreadyParsedHere = composer != nil && parsedFromMs == fromMs + guard !alreadyParsedHere, let pulsar = pulsar else { return } let c = composer ?? pulsar.getPatternComposer() if let s = sound { c.parsePatternWithSound( @@ -107,15 +105,11 @@ struct ResolvedSound { parsedFromMs = fromMs } - /// Plays the preset from its start — haptics plus its synced audio, if it has one. @objc public func play() { play(fromMs: 0) } /// Plays the preset from `fromMs` into its timeline, audio and haptics together. - /// - /// The pattern is re-anchored and re-parsed on every non-zero seek; `fromMs: 0` keeps the - /// parse cached, so repeat plays from the start cost nothing extra. @objc public func play(fromMs: Double) { ensureParsed(fromMs: max(0, fromMs)) composer?.play() 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 8b4b007c..825f2804 100644 --- a/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift +++ b/iOS/Pulsar/Sources/Pulsar/Composers/PatternComposer.swift @@ -18,7 +18,6 @@ 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? - // The engine-side resource the current audio event plays, released alongside that temp file. private var audioResourceID: CHHapticAudioResourceID? public convenience init(engine: HapticEngineWrapper, audioSimulator: AudioSimulator) { @@ -31,15 +30,14 @@ public class PatternComposer: NSObject { dispose() } - /// Parses a pattern for playback. `fromMs` starts it that far into its own timeline: the - /// engine can only play a parsed pattern from zero, so the pattern is re-anchored instead. + /// `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) } - /// As ``parsePattern(hapticsData:fromMs:)``, with a synced audio track. `start`/`duration` are - /// the authored trim window in the file; `fromMs` seeks the whole preset, moving both together. + /// `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) diff --git a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift index dc38ca1c..5e4bbbe0 100644 --- a/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift +++ b/iOS/Pulsar/Sources/Pulsar/HapticEngineWrapper.swift @@ -79,8 +79,6 @@ public extension HapticEngineWrapper { } } - /// Releases a resource obtained from ``registerAudioResource(url:)``. Re-parsing a sounded - /// pattern registers a fresh one, so the replaced resource has to be handed back. func unregisterAudioResource(_ id: CHHapticAudioResourceID) { do { try engine?.unregisterAudioResource(id) diff --git a/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift index ae136e04..3d28c65d 100644 --- a/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift +++ b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift @@ -1,11 +1,9 @@ import Foundation -/// Re-anchors an authored pattern so that playing it from zero sounds like playing the -/// original from `fromMs`. The composer only ever starts at zero, so seeking replays a -/// shifted copy. +/// Re-anchors an authored pattern so playing it from zero feels like playing the original +/// from `fromMs`. The composer only ever starts at zero. enum PatternSeek { - /// The value the envelope holds at `atMs`, interpolating between the surrounding points. - static func value(of points: [ValuePoint], at atMs: Double) -> Float { + 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 } @@ -19,22 +17,23 @@ enum PatternSeek { return before.value + (after.value - before.value) * Float((atMs - before.time) / span) } - /// An envelope whose points all sit before the seek HOLDS its last value for the rest of - /// the pattern rather than emptying. Emptying it would silence the whole continuous - /// channel: the composer builds that channel only when the amplitude AND frequency curves - /// are both non-empty, so seeking past the end of either one kills both. static func envelope(_ points: [ValuePoint], from fromMs: Double, remaining remainingMs: Double) -> [ValuePoint] { if points.isEmpty { return [] } - let held = ValuePoint(time: 0, value: value(of: points, at: fromMs)) - let rest = points + 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 !rest.isEmpty { return [held] + rest } - return remainingMs > 0 ? [held, ValuePoint(time: remainingMs, value: held.value)] : [held] + if !pointsAfterSeek.isEmpty { return [valueAtSeek] + pointsAfterSeek } + return holdingLastValue(valueAtSeek, for: remainingMs) } - /// The last authored timestamp in the pattern, across both lines. - static func duration(of pattern: PatternData) -> Double { + /// 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 }), @@ -45,10 +44,9 @@ enum PatternSeek { ) } - /// Drops the discrete events before `fromMs`, rebases the rest, and re-anchors both envelopes. static func pattern(_ pattern: PatternData, from fromMs: Double) -> PatternData { if fromMs <= 0 { return pattern } - let remainingMs = duration(of: pattern) - fromMs + let remainingMs = lastTimestamp(of: pattern) - fromMs return PatternData( continuousPattern: ContinuousPattern( amplitude: envelope(pattern.continuousPattern.amplitude, from: fromMs, remaining: remainingMs), @@ -60,23 +58,21 @@ enum PatternSeek { ) } - /// Where the audio file and the haptics line up after a seek. - /// - /// A sound offset by `offset` ms is at file position `t - offset` when the haptics are at - /// `t`, so seeking to `fromMs` either advances into the file or eats into the lead-in. A zero - /// `duration` means "to the end of the file", so only an authored trim window shrinks. + /// 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 lead = max(0, offset) - let intoFile = max(0, fromMs - lead) + let leadIn = max(0, offset) + let seekIntoFile = max(0, fromMs - leadIn) + let playsToEndOfFile = duration <= 0 return ( - start: start + intoFile, - duration: duration > 0 ? max(0, duration - intoFile) : 0, - offset: max(0, lead - fromMs) + 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 index 14277eef..5f31e5ef 100644 --- a/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift +++ b/iOS/Pulsar/Tests/PulsarTests/PatternSeekTests.swift @@ -2,7 +2,6 @@ import Testing import Foundation @testable import Pulsar -/// Re-anchoring an authored pattern so it can be replayed from a seek position. @Suite struct PatternSeekTests { private let ramp = PatternData( @@ -18,12 +17,12 @@ import Foundation ) @Test func durationIsTheLastTimestampAcrossBothLines() { - #expect(PatternSeek.duration(of: ramp) == 1000) + #expect(PatternSeek.lastTimestamp(of: ramp) == 1000) let empty = PatternData( continuousPattern: ContinuousPattern(amplitude: [], frequency: []), discretePattern: [] ) - #expect(PatternSeek.duration(of: empty) == 0) + #expect(PatternSeek.lastTimestamp(of: empty) == 0) } @Test func seekingToZeroReturnsTheSamePattern() { @@ -46,8 +45,6 @@ import Foundation #expect(seeked.continuousPattern.amplitude.map { $0.value } == [0.25, 1]) } - /// Emptying it instead would silence BOTH channels, since the composer only builds the - /// continuous line when amplitude and frequency are each non-empty. @Test func anEnvelopeEntirelyBeforeTheSeekHoldsItsLastValue() { let seeked = PatternSeek.pattern(ramp, from: 800) #expect(seeked.continuousPattern.frequency.map { $0.time } == [0, 200]) @@ -80,12 +77,10 @@ import Foundation } @Test func soundEatsIntoTheLeadInBeforeItTouchesTheFile() { - // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. let early = PatternSeek.soundWindow(offset: 500, start: 0, duration: 0, from: 200) #expect(early.start == 0) #expect(early.offset == 300) - // Past the lead-in, the remainder is a seek into the file. let late = PatternSeek.soundWindow(offset: 500, start: 0, duration: 0, from: 800) #expect(late.start == 300) #expect(late.offset == 0) 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 index b8a19dc9..7e1d7b87 100644 --- a/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt +++ b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PatternSeek.kt @@ -1,12 +1,11 @@ package com.swmansion.pulsar.kmp /** - * Re-anchors an authored pattern so that playing it from zero feels like playing the original - * from `fromMs`. The composer only ever starts at zero, so seeking replays a shifted copy. + * 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 { - /** The value the envelope holds at [atMs], interpolating between the surrounding points. */ - fun valueAt(points: List, atMs: Long): Float { + fun interpolatedValueAt(points: List, atMs: Long): Float { val first = points.firstOrNull() ?: return 0f val last = points.last() if (atMs <= first.time) return first.value @@ -20,31 +19,32 @@ internal object PatternSeek { return before.value + (after.value - before.value) * (atMs - before.time).toFloat() / span } - /** - * An envelope whose points all sit before the seek HOLDS its last value for the rest of the - * pattern rather than emptying. Emptying it would silence the whole continuous channel: the - * composer builds that channel only when the amplitude AND frequency curves are both - * non-empty, so seeking past the end of either one kills both. - */ fun envelopeFrom(points: List, fromMs: Long, remainingMs: Long): List { if (points.isEmpty()) return emptyList() - val held = ValuePoint(time = 0L, value = valueAt(points, fromMs)) - val rest = points.filter { it.time > fromMs }.map { ValuePoint(it.time - fromMs, it.value) } - if (rest.isNotEmpty()) return listOf(held) + rest - return if (remainingMs > 0L) listOf(held, ValuePoint(remainingMs, held.value)) else listOf(held) + 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) } - /** The last authored timestamp in the pattern, across both lines. */ - fun durationOf(pattern: PatternData): Long = maxOf( + /** + * 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, ) - /** Drops the discrete events before [fromMs], rebases the rest, re-anchors both envelopes. */ fun patternFrom(pattern: PatternData, fromMs: Long): PatternData { if (fromMs <= 0L) return pattern - val remainingMs = durationOf(pattern) - fromMs + val remainingMs = lastTimestampOf(pattern) - fromMs return PatternData( continuousPattern = ContinuousPattern( amplitude = envelopeFrom(pattern.continuousPattern.amplitude, fromMs, remainingMs), @@ -57,19 +57,17 @@ internal object PatternSeek { } /** - * Where the audio file and the haptics line up after a seek. - * - * A sound offset by `offset` ms is at file position `t - offset` when the haptics are at `t`, - * so seeking to [fromMs] either advances into the file or eats into the lead-in. + * 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 lead = maxOf(0L, sound.offset) - val intoFile = maxOf(0L, fromMs - lead) + val leadIn = maxOf(0L, sound.offset) + val seekIntoFile = maxOf(0L, fromMs - leadIn) + val playsToEndOfFile = sound.durationMs <= 0L return sound.copy( - offset = maxOf(0L, lead - fromMs), - startMs = sound.startMs + intoFile, - // A zero duration means "to the end of the file", so only an authored window shrinks. - durationMs = if (sound.durationMs > 0L) maxOf(0L, sound.durationMs - intoFile) else 0L, + 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/PulsarControllers.kt b/kmp/Pulsar/library/src/commonMain/kotlin/com/swmansion/pulsar/PulsarControllers.kt index 4bd7600b..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,17 +825,14 @@ class PulsarPreset internal constructor( class PatternComposer internal constructor( private val handle: PatternComposerHandle, ) { - /** - * Parses a pattern for playback. [fromMs] starts it that far into its own timeline: the - * engine can only play a parsed pattern from zero, so the pattern is re-anchored instead. - */ + /** [fromMs] starts the pattern that far into its own timeline. */ fun parsePattern(pattern: PatternData, fromMs: Long = 0L) { handle.parsePattern(pattern, fromMs) } /** - * As [parsePattern], with a synced audio track. The sound's own `startMs`/`durationMs` are - * the authored trim window in the file; [fromMs] seeks the whole preset, moving both together. + * 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) 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 0767213f..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 @@ -36,27 +36,18 @@ class PresetHandle internal constructor( private var composer: PatternComposer? = null - /** The seek position [composer] is currently parsed at, or null while unparsed. */ private var parsedFromMs: Long? = null - /** - * Parses at [fromMs], reusing the cached parse when the position has not moved. A preset - * played only from the start therefore still parses exactly once, as it always has. - */ private fun ensureParsed(fromMs: Long) { - if (composer != null && parsedFromMs == fromMs) return + 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 } - /** - * Plays the preset from [fromMs] into its timeline. Defaults to the start of the preset. - * - * The pattern is re-anchored and re-parsed on every non-zero seek; `play()` keeps the parse - * cached, so repeat plays from the start cost nothing extra. - */ + /** Plays the preset from [fromMs] into its timeline, audio and haptics together. */ fun play(fromMs: Long = 0L) { ensureParsed(maxOf(0L, fromMs)) composer?.play() 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 109b6ac0..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 @@ -60,4 +60,5 @@ host app's own Lottie view. 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 index 6f9c3395..09c1aff3 100644 --- a/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt +++ b/kmp/Pulsar/library/src/commonTest/kotlin/com/swmansion/pulsar/PatternSeekTest.kt @@ -5,7 +5,6 @@ import kotlin.test.assertEquals import kotlin.test.assertSame import kotlin.test.assertTrue -/** Re-anchoring an authored pattern so it can be replayed from a seek position. */ class PatternSeekTest { private val ramp = PatternData( @@ -22,7 +21,7 @@ class PatternSeekTest { @Test fun durationIsTheLastTimestampAcrossBothLines() { - assertEquals(1000L, PatternSeek.durationOf(ramp)) + assertEquals(1000L, PatternSeek.lastTimestampOf(ramp)) } @Test @@ -45,10 +44,6 @@ class PatternSeekTest { assertEquals(listOf(0.25f, 1f), seeked.continuousPattern.amplitude.map { it.value }) } - /** - * Emptying it instead would silence BOTH channels, since the composer only builds the - * continuous line when amplitude and frequency are each non-empty. - */ @Test fun anEnvelopeEntirelyBeforeTheSeekHoldsItsLastValue() { val seeked = PatternSeek.patternFrom(ramp, 800L) @@ -66,12 +61,10 @@ class PatternSeekTest { @Test fun soundEatsIntoTheLeadInBeforeItTouchesTheFile() { - // 200ms into a 500ms lead-in: the audio has not begun, so only the wait shortens. val early = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 200L) assertEquals(300L, early.offset) assertEquals(0L, early.startMs) - // Past the lead-in, the remainder is a seek into the file. val late = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 800L) assertEquals(0L, late.offset) assertEquals(300L, late.startMs) 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 803d6c47..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 @@ -50,7 +50,6 @@ 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 - // The engine-side resource the current audio event plays, released alongside that temp file. private var audioResourceId: CHHapticAudioResourceID? = null override fun parsePattern(pattern: PatternData, fromMs: Long) { 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 f7295296..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,10 +89,6 @@ internal class IOSHapticEngineWrapper { return if (id == null || id.toLong() == 0L) null else id } - /** - * Releases a resource obtained from [registerAudioResource]. Re-parsing a sounded pattern - * registers a fresh one, so the replaced resource has to be handed back. - */ fun unregisterAudioResource(id: CHHapticAudioResourceID) { runCatching { engine?.unregisterAudioResource(id, null) } .onFailure { log("Error unregistering audio resource: ${it.message}") } diff --git a/react-native/react-native-pulsar/BUNDLES.md b/react-native/react-native-pulsar/BUNDLES.md index d2f6dac1..63ee9190 100644 --- a/react-native/react-native-pulsar/BUNDLES.md +++ b/react-native/react-native-pulsar/BUNDLES.md @@ -80,11 +80,14 @@ Haptics.fanfare.play(2500); // start 2.5s in Haptics.fanfare.play(); // ...and from the top, as before ``` -Audio and haptics move together: the pattern is re-anchored — discrete events before the seek -are dropped, the rest rebased, and the continuous envelopes re-anchored on their value at that -instant — and the audio seeks to the matching position in the file. A preset authored with an +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. 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 3f958690..65c6b0ce 100644 --- a/react-native/react-native-pulsar/src/__tests__/createBundle.test.tsx +++ b/react-native/react-native-pulsar/src/__tests__/createBundle.test.tsx @@ -115,7 +115,6 @@ describe('loadBundleSync', () => { bundle.heartbeatV2.play(600); expect(native.PatternComposer_parsePattern).toHaveBeenCalledTimes(2); - // The authored pattern goes over untouched — the native side re-anchors it. expect(native.PatternComposer_parsePattern).toHaveBeenNthCalledWith( 1, definition.presets.heartbeatV2.pattern, @@ -126,7 +125,6 @@ describe('loadBundleSync', () => { definition.presets.heartbeatV2.pattern, 600 ); - // The parse anchored at zero is freed once the seek replaces it. expect(native.PatternComposer_release).toHaveBeenCalledWith(100); expect(native.PatternComposer_play).toHaveBeenLastCalledWith(101); }); diff --git a/react-native/react-native-pulsar/src/createBundle.ts b/react-native/react-native-pulsar/src/createBundle.ts index 103975e3..008ab912 100644 --- a/react-native/react-native-pulsar/src/createBundle.ts +++ b/react-native/react-native-pulsar/src/createBundle.ts @@ -21,13 +21,7 @@ export type PresetHandle = { readonly animation?: PresetAnimation; readonly hasAudio: boolean; readonly hasAnimation: boolean; - /** - * Plays the preset — haptics plus its synced audio, if it has one. Pass `fromMs` to start - * that far into the preset's timeline: the pattern is re-anchored and the audio seeks to - * match. Defaults to the start. - * - * Every non-zero seek re-parses; playing from the start reuses the cached parse. - */ + /** Plays the preset from `fromMs` into its timeline — haptics plus its synced audio. */ play: (fromMs?: number) => void; stop: () => void; }; @@ -139,7 +133,6 @@ function createLoadedBundle( bundleToken?: string ): Bundle> { const parsedIds = new Map(); - // What each cached parse is anchored at, so a repeat play from the same position reuses it. const parsedFrom = new Map(); const presets: Record = {}; let disposed = false; @@ -154,15 +147,12 @@ function createLoadedBundle( for (const [id, preset] of Object.entries(definition.presets)) { const parseAt = (fromMs: number) => { - const alreadyParsed = parsedIds.get(id); - if (alreadyParsed !== undefined && parsedFrom.get(id) === fromMs) { - return alreadyParsed; - } - if (alreadyParsed !== undefined) { - Pulsar.PatternComposer_release(alreadyParsed); - } + const previousId = parsedIds.get(id); + const alreadyParsedHere = + previousId !== undefined && parsedFrom.get(id) === fromMs; + if (alreadyParsedHere) return previousId; + if (previousId !== undefined) Pulsar.PatternComposer_release(previousId); - // The native composer re-anchors the pattern, the same way the bundle path does. const parsedId = Pulsar.PatternComposer_parsePattern( preset.pattern, fromMs From f088b8aaaaa0dfb356a5c4d037a31a34706eff76 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Wed, 9 Sep 2026 19:41:26 +0200 Subject: [PATCH 5/5] docs(ios): record why seek(toOffset:) cannot replace the pattern re-anchoring --- iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift index 3d28c65d..993309f3 100644 --- a/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift +++ b/iOS/Pulsar/Sources/Pulsar/Types/PatternSeek.swift @@ -1,7 +1,9 @@ import Foundation /// Re-anchors an authored pattern so playing it from zero feels like playing the original -/// from `fromMs`. The composer only ever starts at zero. +/// 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 }