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
42 changes: 42 additions & 0 deletions Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ package com.swmansion.pulsar
import android.app.Activity
import android.content.Context
import com.swmansion.pulsar.audio.AudioSimulator
import com.swmansion.pulsar.bundle.BundleDescriptor
import com.swmansion.pulsar.bundle.BundleLoaderImpl
import com.swmansion.pulsar.bundle.BundleResolver
import com.swmansion.pulsar.bundle.LoadedBundle
import com.swmansion.pulsar.bundle.PulsarBundle
import com.swmansion.pulsar.bundle.PulsarBundleException
import com.swmansion.pulsar.composers.PatternComposer
import com.swmansion.pulsar.composers.RealtimeComposer
import com.swmansion.pulsar.haptics.HapticEngineWrapper
import com.swmansion.pulsar.presets.PresetsWrapper
import com.swmansion.pulsar.types.CompatibilityMode
import com.swmansion.pulsar.types.RealtimeComposerStrategy
import java.io.File

open class Pulsar(protected var context: Context) {
protected val engine = HapticEngineWrapper(context)
Expand Down Expand Up @@ -87,4 +94,39 @@ open class Pulsar(protected var context: Context) {
fun enableImpulseCompositionMode(state: Boolean) {
engine.enableImpulseCompositionMode(state)
}

// region: preset bundles

/** Load a `.pulsar` bundle from raw bytes (used by the React Native / Flutter bridges). */
fun loadBundle(bytes: ByteArray): LoadedBundle = BundleLoaderImpl.load(this, context, bytes)

/** Load a `.pulsar` bundle from a file path. */
fun loadBundle(path: String): LoadedBundle = loadBundle(File(path).readBytes())

/** Load a `.pulsar` bundle bundled under `src/main/assets/`. */
fun loadBundleFromAsset(assetName: String): LoadedBundle =
context.assets.open(assetName).use { loadBundle(it.readBytes()) }

/**
* Typed load for Kotlin consumers, using a `pulsar-gen`-generated descriptor.
*
* val bundle = pulsar.loadBundle(AcmePack.descriptor)
* bundle.presets.heartbeatV2.play()
*/
fun <P> loadBundle(descriptor: BundleDescriptor<P>, strict: Boolean = false): PulsarBundle<P> {
val loaded = loadBundleFromAsset(descriptor.assetName)
if (strict && descriptor.contentHash.isNotEmpty() && loaded.contentHash != descriptor.contentHash) {
throw PulsarBundleException(
"Bundle content hash mismatch: generated types expect ${descriptor.contentHash} " +
"but the loaded bundle is ${loaded.contentHash}. Re-export the bundle or regenerate the types.",
)
}
val missing = descriptor.presetIds.filter { loaded.handle(it) == null }
if (missing.isNotEmpty()) {
throw PulsarBundleException("Bundle is missing preset(s) $missing — regenerate types with pulsar-gen")
}
return PulsarBundle(loaded, descriptor.build(BundleResolver(loaded)))
}

// endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.swmansion.pulsar.bundle

import android.content.Context
import com.swmansion.pulsar.Pulsar
import com.swmansion.pulsar.types.SoundData
import kotlinx.serialization.json.Json
import java.io.File

/** Decodes a `.pulsar` archive into a [LoadedBundle]. Invoked by the `Pulsar.loadBundle*` members. */
internal object BundleLoaderImpl {
private val json = Json { ignoreUnknownKeys = true }
private const val SCHEMA = "pulsar.bundle/1"

fun load(haptics: Pulsar, context: Context, bytes: ByteArray): LoadedBundle {
val files = Unzip.read(bytes)
val manifestBytes = files["manifest.json"]
?: throw PulsarBundleException("Bundle is missing manifest.json")
val manifest = json.decodeFromString(BundleManifest.serializer(), manifestBytes.decodeToString())
if (manifest.schema != SCHEMA) {
throw PulsarBundleException("Unsupported bundle schema \"${manifest.schema}\" (expected $SCHEMA)")
}

val mediaDir = File(context.cacheDir, "PulsarBundles/${manifest.id}").apply { mkdirs() }
val handles = LinkedHashMap<String, PresetHandle>()

for (preset in manifest.presets) {
val hapticsBytes = files[preset.haptics]
?: throw PulsarBundleException("Bundle is missing referenced entry \"${preset.haptics}\"")
// Device wire shape decodes directly, then maps into the SDK's PatternData.
val pattern = json.decodeFromString(DevicePatternDto.serializer(), hapticsBytes.decodeToString())
.toPatternData()

val sound = preset.audio?.let { audio ->
files[audio.src]?.let { data ->
val dest = File(mediaDir, audio.src.substringAfterLast('/'))
dest.writeBytes(data)
SoundData(
uri = dest.absolutePath,
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,
)
}
}

val animation = preset.animation?.let { anim ->
files[anim.src]?.let { BundleAnimation(it, anim.frameRate ?: 0.0, anim.totalFrames ?: 0) }
}

handles[preset.id] = PresetHandle(
id = preset.id,
duration = (preset.duration ?: 0.0).toLong(),
animation = animation,
haptics = haptics,
pattern = pattern,
sound = sound,
)
}

return LoadedBundle(
id = manifest.id,
contentHash = manifest.hash ?: "",
revision = manifest.revision ?: 0,
handles = handles,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.swmansion.pulsar.bundle

import com.swmansion.pulsar.types.ConfigPoint
import com.swmansion.pulsar.types.ContinuousPattern
import com.swmansion.pulsar.types.PatternData
import com.swmansion.pulsar.types.ValuePoint
import kotlinx.serialization.Serializable

// Codable mirror of manifest.json — see docs/bundle-format.md.

@Serializable
internal data class BundleManifest(
val schema: String,
val generator: String? = null,
val id: String,
val name: String,
val revision: Int? = null,
val hash: String? = null,
val presets: List<PresetEntry>,
)

@Serializable
internal data class PresetEntry(
val id: String,
val name: String,
val duration: Double? = null,
val haptics: String,
val audio: AudioRef? = null,
val animation: AnimationRef? = null,
)

@Serializable
internal data class AudioRef(val src: String, val volume: Float? = null, val offset: Double? = null)

@Serializable
internal data class AnimationRef(val src: String, val frameRate: Double? = null, val totalFrames: Int? = null)

// Device wire shape of a haptics payload; decodes directly, then maps into the SDK's PatternData.

@Serializable
internal data class ValuePointDto(val time: Double, val value: Float)

@Serializable
internal data class ConfigPointDto(val time: Double, val amplitude: Float, val frequency: Float)

@Serializable
internal data class ContinuousDto(val amplitude: List<ValuePointDto>, val frequency: List<ValuePointDto>)

@Serializable
internal data class DevicePatternDto(
val continuousPattern: ContinuousDto,
val discretePattern: List<ConfigPointDto>,
) {
fun toPatternData(): PatternData = PatternData(
continuousPattern = ContinuousPattern(
amplitude = continuousPattern.amplitude.map { ValuePoint(it.time.toLong(), it.value) },
frequency = continuousPattern.frequency.map { ValuePoint(it.time.toLong(), it.value) },
),
discretePattern = discretePattern.map { ConfigPoint(it.time.toLong(), it.amplitude, it.frequency) },
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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.SoundData

/** Lottie bytes + timing for a preset's animation; the host app's own Lottie view renders it. */
class BundleAnimation internal constructor(
val data: ByteArray,
val frameRate: Double,
val totalFrames: Int,
)

/** A single playable preset from a loaded bundle. Parses its pattern lazily on first play. */
class PresetHandle internal constructor(
val id: String,
val duration: Long,
val animation: BundleAnimation?,
private val haptics: Pulsar,
private val pattern: PatternData,
private val sound: SoundData?,
) {
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
}
}

fun play() {
ensureParsed()
composer?.play()
}

fun stop() {
composer?.stop()
}

internal fun dispose() {
composer?.release()
composer = null
}
}

/** Untyped loaded bundle — the surface the React Native / Flutter bridges use (string ids). */
class LoadedBundle internal constructor(
val id: String,
val contentHash: String,
val revision: Int,
private val handles: Map<String, PresetHandle>,
) {
fun handle(id: String): PresetHandle? = handles[id]
val presetIds: List<String> get() = handles.keys.toList()
fun play(id: String): Boolean {
val h = handles[id] ?: return false
h.play()
return true
}
fun dispose() = handles.values.forEach { it.dispose() }
}

/**
* Looks up preset handles by id when a generated descriptor builds its typed presets view.
* `loadBundle` guarantees every id in the descriptor exists before this is used.
*/
class BundleResolver internal constructor(private val loaded: LoadedBundle) {
operator fun get(id: String): PresetHandle = loaded.handle(id)!!
}

/** Emitted by pulsar-gen: binds a bundle asset + hash to a typed presets builder. */
class BundleDescriptor<P>(
val assetName: String,
val bundleId: String,
val contentHash: String,
val presetIds: List<String>,
val build: (BundleResolver) -> P,
)

/** The typed bundle returned by `pulsar.loadBundle(SomeBundle.descriptor)`. */
class PulsarBundle<P> internal constructor(
private val loaded: LoadedBundle,
val presets: P,
) {
val id: String get() = loaded.id
val revision: Int get() = loaded.revision
val contentHash: String get() = loaded.contentHash
fun get(id: String): PresetHandle? = loaded.handle(id)
fun dispose() = loaded.dispose()
}

class PulsarBundleException(message: String) : Exception(message)
44 changes: 44 additions & 0 deletions Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Pulsar bundles (Android)

Load a `.pulsar` bundle authored in Pulsar Studio at runtime and play its presets with full
autocomplete. See [`docs/bundle-format.md`](../../../../../../../docs/bundle-format.md) for the format.

## Kotlin usage

```kotlin
val pulsar = Pulsar(context)
val bundle = pulsar.loadBundle(AcmePack.descriptor) // AcmePack is generated
bundle.presets.heartbeatV2.play() // ← autocompletes
bundle.presets.explosion.stop()

// Animation bytes for the app's own Lottie view (Pulsar times, the app renders):
bundle.presets.heartbeatV2.animation?.let { myLottieView.setAnimation(it.data.inputStream(), null) }
```

`loadBundle(descriptor, strict = true)` asserts the loaded bundle's content hash matches the
generated types, failing loudly on a stale bundle/types mismatch.

## Zero-manual codegen (Gradle plugin)

```kotlin
plugins { id("com.swmansion.pulsar.gen") }
```

Drop `.pulsar` files into `src/pulsarBundles/`. On every build the plugin generates the typed
`object` per bundle and packages the bundle into the APK assets (under `assets/pulsar/`) — the
FlutterGen / Compose-Resources model, no manual step. Configure via:

```kotlin
pulsarBundles {
// bundlesDir.set(layout.projectDirectory.dir("src/pulsarBundles")) // default
packageName.set("com.acme.haptics")
}
```

## Bridge surface (React Native / Flutter)

```kotlin
val loaded = pulsar.loadBundle(bytes) // or loadBundle(path) / loadBundleFromAsset("pulsar/acme-pack.pulsar")
loaded.presetIds // -> List<String>
loaded.play("heartbeatV2") // -> Boolean
```
22 changes: 22 additions & 0 deletions Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/Unzip.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.swmansion.pulsar.bundle

import java.io.ByteArrayInputStream
import java.util.zip.ZipInputStream

/** Reads a `.pulsar` (zip) into a map of entry path -> bytes using the JDK's zip support. */
internal object Unzip {
fun read(bytes: ByteArray): Map<String, ByteArray> {
val out = LinkedHashMap<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
out[entry.name] = zis.readBytes()
}
zis.closeEntry()
entry = zis.nextEntry
}
}
return out
}
}
Loading
Loading