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
.ahapand 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
dart pub global activate haptifyhaptify convert assets/audio/*.wavFor every input, haptify writes the files each platform actually needs:
| File | What it is |
|---|---|
explosion.ahap | Apple Core Haptics JSON, for iOS |
explosion.haptic.json | {timings, amplitudes, repeat} for Android’s VibrationEffect.createWaveform |
explosion_haptic.dart | dependency-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 → writeDecode. 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 necessity —
createWaveformis 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.
- 📦 pub.dev/packages/haptify
- 💻 github.com/imedboumalek/haptify (demo app included)
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.