Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,21 @@ class PresetHandle internal constructor(

private var composer: PatternComposer? = null

private fun ensureParsed() {
if (composer == null) {
val c = haptics.getPatternComposer()
if (sound != null) c.parsePatternWithSound(pattern, sound) else c.parsePattern(pattern)
composer = c
}
private var parsedFromMs: Long? = null

private fun ensureParsed(fromMs: Long) {
val alreadyParsedHere = composer != null && parsedFromMs == fromMs
if (alreadyParsedHere) return
val c = composer ?: haptics.getPatternComposer()
if (sound != null) c.parsePatternWithSound(pattern, sound, fromMs) else c.parsePattern(pattern, fromMs)
composer = c
parsedFromMs = fromMs
}

fun play() {
ensureParsed()
/** Plays the preset from [fromMs] into its timeline, audio and haptics together. */
@JvmOverloads
fun play(fromMs: Long = 0L) {
ensureParsed(maxOf(0L, fromMs))
composer?.play()
}

Expand All @@ -55,6 +60,7 @@ class PresetHandle internal constructor(
internal fun dispose() {
composer?.release()
composer = null
parsedFromMs = null
}
}

Expand All @@ -67,9 +73,10 @@ class LoadedBundle internal constructor(
) {
fun handle(id: String): PresetHandle? = handles[id]
val presetIds: List<String> 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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -41,4 +42,5 @@ pulsarBundles {
val loaded = pulsar.loadBundle(bytes) // or loadBundle(path) / loadBundleFromAsset("pulsar/acme-pack.pulsar")
loaded.presetIds // -> List<String>
loaded.play("heartbeatV2") // -> Boolean
loaded.play("heartbeatV2", fromMs = 2500) // seeks audio + haptics
```
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -28,34 +29,44 @@ class PatternComposer(
private var soundPlayer: AudioHapticPlayer? = null
private var useCoupledHaptics = false

fun parsePattern(hapticsData: PatternData) {
/** [fromMs] starts the pattern that far into its own timeline. */
@JvmOverloads
fun parsePattern(hapticsData: PatternData, fromMs: Long = 0L) {
val seekedPattern = PatternSeek.patternFrom(hapticsData, fromMs)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrationEffect = try {
engine.getHapticBuilder().createVibrationEffect(hapticsData)
engine.getHapticBuilder().createVibrationEffect(seekedPattern)
} catch (_: IllegalArgumentException) {
val message = "Skipping invalid haptic pattern after Android validation failure: ${summarizePattern(hapticsData)}"
val message = "Skipping invalid haptic pattern after Android validation failure: ${summarizePattern(seekedPattern)}"
Log.w(TAG, message)
null
}
if (vibrationEffect == null) {
val message = "Skipping invalid haptic pattern because it produced no playable vibration effect: ${summarizePattern(hapticsData)}"
val message = "Skipping invalid haptic pattern because it produced no playable vibration effect: ${summarizePattern(seekedPattern)}"
Log.w(TAG, message)
}
}

audioBuffer = audioSimulator.parsePattern(hapticsData)
audioBuffer = audioSimulator.parsePattern(seekedPattern)
}

fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData) {
parsePattern(hapticsData)
/**
* The sound's own `startMs`/`durationMs` are the authored trim window in the file; [fromMs]
* seeks the whole preset, moving audio and haptics together.
*/
@JvmOverloads
fun parsePatternWithSound(hapticsData: PatternData, sound: SoundData, fromMs: Long = 0L) {
parsePattern(hapticsData, fromMs)

val seekedSound = PatternSeek.soundFrom(sound, fromMs)
soundPlayer?.release()

useCoupledHaptics = sound.hapticChannels && isOggUri(sound.uri) && engine.supportsAudioCoupledHaptics()
useCoupledHaptics =
seekedSound.hapticChannels && isOggUri(seekedSound.uri) && engine.supportsAudioCoupledHaptics()

soundPlayer = AudioHapticPlayer(
context = engine.getContext(),
sound = sound,
sound = seekedSound,
hapticChannelsMuted = !useCoupledHaptics,
).also { it.load() }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.swmansion.pulsar.types

/**
* Re-anchors an authored pattern so playing it from zero feels like playing the original from
* `fromMs`. The composer only ever starts at zero.
*/
internal object PatternSeek {
fun interpolatedValueAt(points: List<ValuePoint>, atMs: Long): Float {
val first = points.firstOrNull() ?: return 0f
val last = points.last()
if (atMs <= first.time) return first.value
if (atMs >= last.time) return last.value
val nextIndex = points.indexOfFirst { it.time > atMs }
if (nextIndex <= 0) return last.value
val before = points[nextIndex - 1]
val after = points[nextIndex]
val span = after.time - before.time
if (span <= 0L) return after.value
return before.value + (after.value - before.value) * (atMs - before.time).toFloat() / span
}

fun envelopeFrom(points: List<ValuePoint>, fromMs: Long, remainingMs: Long): List<ValuePoint> {
if (points.isEmpty()) return emptyList()
val valueAtSeek = ValuePoint(time = 0L, value = interpolatedValueAt(points, fromMs))
val pointsAfterSeek = points
.filter { it.time > fromMs }
.map { ValuePoint(it.time - fromMs, it.value) }
if (pointsAfterSeek.isNotEmpty()) return listOf(valueAtSeek) + pointsAfterSeek
return holdingLastValue(valueAtSeek, remainingMs)
}

/**
* Emptying an envelope would silence BOTH continuous channels — the composer builds that
* line only when the amplitude and frequency curves are each non-empty.
*/
private fun holdingLastValue(point: ValuePoint, remainingMs: Long): List<ValuePoint> =
if (remainingMs > 0L) listOf(point, ValuePoint(remainingMs, point.value)) else listOf(point)

fun lastTimestampOf(pattern: PatternData): Long = maxOf(
pattern.discretePattern.maxOfOrNull { it.time } ?: 0L,
pattern.continuousPattern.amplitude.maxOfOrNull { it.time } ?: 0L,
pattern.continuousPattern.frequency.maxOfOrNull { it.time } ?: 0L,
)

fun patternFrom(pattern: PatternData, fromMs: Long): PatternData {
if (fromMs <= 0L) return pattern
val remainingMs = lastTimestampOf(pattern) - fromMs
return PatternData(
continuousPattern = ContinuousPattern(
amplitude = envelopeFrom(pattern.continuousPattern.amplitude, fromMs, remainingMs),
frequency = envelopeFrom(pattern.continuousPattern.frequency, fromMs, remainingMs),
),
discretePattern = pattern.discretePattern
.filter { it.time >= fromMs }
.map { it.copy(time = it.time - fromMs) },
)
}

/**
* Audio offset by [SoundData.offset] sits at file position `t - offset` when the haptics are
* at `t`, so a seek is spent on the lead-in first and only then on the file.
*/
fun soundFrom(sound: SoundData, fromMs: Long): SoundData {
val leadIn = maxOf(0L, sound.offset)
val seekIntoFile = maxOf(0L, fromMs - leadIn)
val playsToEndOfFile = sound.durationMs <= 0L
return sound.copy(
offset = maxOf(0L, leadIn - fromMs),
startMs = sound.startMs + seekIntoFile,
durationMs = if (playsToEndOfFile) 0L else maxOf(0L, sound.durationMs - seekIntoFile),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.swmansion.pulsar.types

import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test

class PatternSeekTest {

private val ramp = PatternData(
continuousPattern = ContinuousPattern(
amplitude = listOf(ValuePoint(0L, 0f), ValuePoint(1000L, 1f)),
frequency = listOf(ValuePoint(0L, 0.2f), ValuePoint(500L, 0.8f)),
),
discretePattern = listOf(
ConfigPoint(0L, 1f, 0.5f),
ConfigPoint(400L, 0.8f, 0.4f),
ConfigPoint(1000L, 0.6f, 0.3f),
),
)

@Test
fun `duration is the last timestamp across both lines`() {
assertEquals(1000L, PatternSeek.lastTimestampOf(ramp))
val empty = PatternData(ContinuousPattern(emptyList(), emptyList()), emptyList())
assertEquals(0L, PatternSeek.lastTimestampOf(empty))
}

@Test
fun `seeking to zero returns the same pattern`() {
assertSame(ramp, PatternSeek.patternFrom(ramp, 0L))
assertSame(ramp, PatternSeek.patternFrom(ramp, -100L))
}

@Test
fun `discrete events before the seek are dropped and the rest rebased`() {
val seeked = PatternSeek.patternFrom(ramp, 400L)
assertEquals(listOf(0L, 600L), seeked.discretePattern.map { it.time })
assertEquals(listOf(0.8f, 0.6f), seeked.discretePattern.map { it.amplitude })
}

@Test
fun `envelope is re-anchored on its interpolated value`() {
val seeked = PatternSeek.patternFrom(ramp, 250L)
assertEquals(listOf(0L, 750L), seeked.continuousPattern.amplitude.map { it.time })
assertEquals(listOf(0.25f, 1f), seeked.continuousPattern.amplitude.map { it.value })
}

@Test
fun `an envelope entirely before the seek holds its last value`() {
val seeked = PatternSeek.patternFrom(ramp, 800L)
assertEquals(listOf(0L, 200L), seeked.continuousPattern.frequency.map { it.time })
assertEquals(listOf(0.8f, 0.8f), seeked.continuousPattern.frequency.map { it.value })
assertTrue(seeked.continuousPattern.amplitude.isNotEmpty())
}

@Test
fun `a held envelope collapses to one point once nothing remains`() {
val seeked = PatternSeek.patternFrom(ramp, 1000L)
assertEquals(listOf(ValuePoint(0L, 0.8f)), seeked.continuousPattern.frequency)
}

@Test
fun `an empty envelope stays empty`() {
val noFrequency = PatternData(
ContinuousPattern(ramp.continuousPattern.amplitude, emptyList()),
emptyList(),
)
assertTrue(PatternSeek.patternFrom(noFrequency, 250L).continuousPattern.frequency.isEmpty())
}

@Test
fun `sound seeks into the file by the same amount`() {
val seeked = PatternSeek.soundFrom(SoundData(uri = "clip.wav"), 300L)
assertEquals(0L, seeked.offset)
assertEquals(300L, seeked.startMs)
assertEquals(0L, seeked.durationMs)
}

@Test
fun `sound eats into the lead-in before it touches the file`() {
val early = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 200L)
assertEquals(300L, early.offset)
assertEquals(0L, early.startMs)

val late = PatternSeek.soundFrom(SoundData(uri = "clip.wav", offset = 500L), 800L)
assertEquals(0L, late.offset)
assertEquals(300L, late.startMs)
}

@Test
fun `an authored trim window shrinks and its start advances`() {
val sound = SoundData(uri = "clip.wav", startMs = 1000L, durationMs = 900L)
val seeked = PatternSeek.soundFrom(sound, 400L)
assertEquals(1400L, seeked.startMs)
assertEquals(500L, seeked.durationMs)
}
}
18 changes: 15 additions & 3 deletions docs/src/content/docs/sdk/android.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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()`
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docs/src/content/docs/sdk/flutter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading