← All writing

haptify: turning audio into haptics, in pure Dart

Why I built a CLI that converts sound files into iOS and Android haptic patterns — and how it does the same conversion on-device, at runtime, with no native code.

Good haptics make an app feel alive. A crisp tap on success, a low rumble on an explosion, a texture under a drag — done well, you feel the interface as much as you see it. And yet most apps ship with a couple of generic buzzes.

The reason isn’t that developers don’t care. It’s that making haptics is genuinely painful. I got tired of it, so I built haptify: a pure-Dart CLI and library that generates haptic feedback files from audio. This post is about why it exists and how it works.

The tooling gap

When I went looking for a way to design haptics that fit a normal Flutter workflow, here’s what I found:

  • Lofelt Studio, the best mobile-focused haptic design tool, was acquired by Meta and sunset in July 2022.
  • Meta Haptics Studio is alive and genuinely good — it imports audio and runs audio-to-haptic analysis. But it’s a desktop GUI built around Meta’s own Haptics SDK and Quest-headset auditioning. It’s a design app, not something you drop into a build.
  • AHAPpy and similar scripts convert audio to haptics, but they’re iOS-only — they emit Apple’s .ahap and nothing for Android.
  • On pub.dev, the haptic packages (gaimon, advanced_haptics, pulsar_haptics, vibration…) are playback-only. They play patterns; none of them create patterns from audio.

None of these fit the thing I actually wanted: drop a .wav in, get haptics out, commit the result — reproducible, diffable, working on both platforms, part of the build like any other generated asset.

One command in, three files out

Terminal window
dart pub global activate haptify
haptify convert assets/audio/*.wav

For every input, haptify writes the files each platform actually needs:

FileWhat it is
explosion.ahapApple Core Haptics JSON, for iOS
explosion.haptic.json{timings, amplitudes, repeat} for Android’s VibrationEffect.createWaveform
explosion_haptic.dartdependency-free Dart constants, compiled straight into the app

(There’s also an opt-in primitives format targeting Android’s VibrationEffect.Composition for API 30+ devices.)

Deliberate design choice: haptify has no platform channels. It authors patterns; the playback plugins you already use (gaimon, vibration, …) play them. It slots into the ecosystem instead of competing with it.

It’s a signal chain, not a volume knob

The naive conversion — map loudness to vibration strength — feels mushy. Real sounds have structure: attacks, decays, texture. The analyzer runs a small DSP pipeline over the decoded audio:

  • an RMS loudness envelope drives haptic intensity over time;
  • energy-flux onset detection finds transients — the percussive hits that should feel like distinct, sharp taps rather than being smeared into the rumble;
  • zero-crossing rate estimates sharpness (brightness), so a crisp click feels crisp and a dull thud feels dull;
  • silence gating and Ramer–Douglas–Peucker simplification keep the output compact — curves get a point budget that scales with duration, so a 60-second track keeps its envelope detail without bloating the file.

On iOS it goes further: haptify emits time-varying sharpness curves, so a cymbal that decays from bright to dull feels that way in your hand, instead of getting one averaged sharpness for the whole event.

Everything is tunable from the CLI (--onset-sensitivity, --curve-rate, --gamma, --no-sharpness-curves, …) and mirrored in the library’s AnalysisOptions.

A Core Haptics gotcha worth sharing

One bug taught me more about AHAP than the docs did. Parameter curves carry control points with timestamps — and those timestamps are relative to the curve’s own start time, not absolute within the pattern. My first encoder wrote them absolute. Short sounds played fine; long sounds drifted, because every curve after the first ran with doubled offsets. The fix was one subtraction — finding it meant golden-file tests and a very careful read of the spec. If you ever hand-author AHAP: check your curve timing.

Under the hood

This section is for the curious — skip straight to runtime conversion if you just want to use the tool.

How the pipeline works internally

The whole package is one straight pipeline:

decode → analyze → HapticPattern → encode → write

Decode. WAV goes through the wav package; MP3 through the vendored minimp3 port. The MP3 wrapper skips ID3v2 tags and the Xing/Info metadata frame, then trims LAME encoder delay and padding — without that trim, every haptic event would land tens of milliseconds late relative to the audio. Everything downmixes to mono Float64List samples; loudness and timing are what matter for haptics, stereo doesn’t.

Analyze. The signal is cut into 10 ms frames. Each frame gets an RMS energy and a zero-crossing count. Onsets are where the energy flux (the frame-to-frame rise) exceeds a moving local average by a tunable factor — that’s what separates a drum hit from a swelling pad. Frames below the silence threshold split the signal into segments, with sub-3-frame gaps bridged so a tremolo doesn’t shatter into confetti. Sharpness comes from the zero-crossing rate via sqrt(zcr / 0.5), which spreads typical audio across the usable 0–1 range instead of clustering at the bottom.

The model. Analysis produces a HapticPattern: an immutable tree of sealed event types (transient vs continuous), envelopes, and parameter curves. Constructors validate eagerly — an out-of-range intensity throws at construction, not at encode time three steps later. When you want leniency (say, parsing a hand-edited file), .clamped() factories clamp instead. The pattern model is the contract in the middle: the analyzer never knows about output formats, the encoders never know about audio.

Encode. The two main encoders are deliberately asymmetric:

  • The AHAP encoder is lossless — Core Haptics natively supports everything the model expresses, so events and curves pass through intact. The fiddly parts are spec compliance: control-point times relative to the curve’s start, and curves split at 16 points with a shared boundary point so the pieces join seamlessly.
  • The Android waveform encoder is lossy by necessitycreateWaveform is just on/off timings with amplitudes, no frequency control. It samples the pattern’s computed intensity at fixed steps (at step midpoints, which avoids a half-step lag that audibly dulled attack transients), quantizes to 0–255, and run-length-collapses equal neighbors. Anything that couldn’t survive the trip — a sharpness curve, say — becomes a warning carried on the result object, never thrown: a lossy conversion with notes is a result, not an error.

Curve budgets. Continuous segments get their envelope as a parameter curve, simplified with Ramer–Douglas–Peucker. The point budget scales with duration (16 points per second by default, floor of 32), and the encoder binary-searches RDP’s tolerance until the simplified curve actually uses that budget — a fixed tolerance either wastes points on simple sounds or crushes detail in complex ones.

None of this is exotic DSP — it’s textbook building blocks. The work is in the glue: the timing trims, the midpoint sampling, the relative-time encoding, the eager validation. That’s also why it can be pure Dart.

The part I’m most excited about: runtime conversion

Because the whole pipeline is pure Dart — including a vendored MP3 decoder, so there’s no ffmpeg and no native code — the same conversion runs on-device, at runtime:

final pattern = const AudioAnalyzer().analyzeBytes(uploadedBytes);

That means a shipping app can take a sound the user just uploaded or recorded and turn it into haptics live. As far as I can tell, no other Flutter package does audio→haptic conversion at all, let alone on-device. (Android 12+ has a platform-level HapticGenerator, but it’s Android-only, tied to live audio playback through the OS, and produces no portable pattern you can store or play on iOS.)

Getting there meant solving MP3 in pure Dart: I vendored a CC0 port of minimp3 and wrapped it with ID3 handling and LAME gapless trimming, so decoded timing matches the original audio — verified bit-exact against ffmpeg apart from a ~25 ms startup transient. Not glamorous work, but it’s the difference between “works in my CLI” and “works inside your app on a phone.”

Feel it yourself

Haptics are the one medium a blog post can’t demo. The repo ships a Flutter demo app that’s the honest test: bundled sounds with haptics pre-generated by the CLI, plus an upload flow that converts your own audio on the spot (the analyzer runs in a background isolate). Clone it, run it on a real phone — simulators can’t vibrate — and judge the feel on your own sound effects.

What’s next

haptify 0.4.0 is stable on pub.dev under a verified publisher, and there’s a prerelease channel tracking the dev branch if you want what’s next early. On the roadmap: a preset pattern library and easing/curve toolkit, so you can reach for a well-designed “success” or “impact” without starting from a sound file every time.

If you’ve been skipping haptics because the tooling hurts, try it on your own assets — and tell me where the feel breaks down. That feedback is the most valuable thing you can give a tool like this.


I’m Imed Boumalek — technical founder and senior mobile engineer. I build products end to end and automate everything in between. More at imed.dev.