native_haptics_and_audio 2.0.0
native_haptics_and_audio: ^2.0.0 copied to clipboard
Ultra-low latency native haptic and audio feedback designed specifically for high-speed performance on iOS and Android.
native_haptics_and_audio #
A high-performance Flutter plugin delivering ultra-low latency audio and haptic feedback.
Sounds are decoded once into native RAM and played through raw hardware APIs — Android's SoundPool and iOS's AVAudioEngine — so playback is instant, polyphonic, and free of bridge overhead. Ships a catalog of 13 UI sounds and plays your app's own audio through the same path.
Why this package? #
Standard Flutter audio plugins decode on every play and route through heavy bridge serialization, which introduces a small but perceptible lag. If a cashier is scanning 50 items a minute, even 100 ms of audio delay feels sluggish.
This plugin solves that by:
- Decoding once, playing many times. Audio is held in native RAM as raw PCM. Playback is a buffer dispatch, not a decode.
- Bounded memory. Nothing loads until you ask for it, and the on-demand cache has a hard ceiling — see Memory.
- Native hardware haptics. Raw
VibrationEffectpatterns on Android, cachedUIFeedbackGenerators on iOS. - Type safety.
pigeongenerates the bridge, so there are no stringly-typed method channels.
Features #
- Zero-latency audio — 13 bundled UI sounds, plus any asset your app declares.
- Per-play control — volume and rate on every call.
- Two-tier memory model — pin the sounds you play constantly, let the rest load on demand into a bounded LRU cache.
- Silent-switch control — decide whether the iOS ringer switch silences your sounds.
- Platform safe — silently no-ops on desktop and web instead of crashing.
Installation #
dependencies:
native_haptics_and_audio: ^2.0.0
Quick Start #
Everything runs through the singleton NativeHapticsAndAudioRepository.
1. Initialize #
Call this once before playing anything. It builds the audio engine but loads no audio.
import 'package:native_haptics_and_audio/native_haptics_and_audio.dart';
final repo = NativeHapticsAndAudioRepository.instance;
@override
void initState() {
super.initState();
_boot();
}
Future<void> _boot() async {
await repo.initialize();
// Pin the sounds on your hot path — they are never evicted.
await repo.preload(NativeSound.scannerBeep);
}
Concurrent
initialize()calls are deduplicated behind a single native call.
2. Play #
// Bundled sounds.
await repo.play(NativeSound.scannerBeep);
await repo.play(NativeSound.transactionSuccess, volume: 0.6);
// Your own assets — same method, same code path.
await repo.play(const CustomSound('assets/sfx/boom.m4a'));
// Haptics.
await repo.playHaptic(HapticPattern.success);
A sound that is not yet resident is loaded automatically on first play, so preload is an optimization, not a requirement.
3. Custom sounds #
Declare the asset in your own pubspec.yaml:
flutter:
assets:
- assets/sfx/boom.m4a
Then reference it:
const boom = CustomSound('assets/sfx/boom.m4a');
await repo.play(boom);
If the asset belongs to a package rather than your app, include the package prefix:
const chime = CustomSound('packages/my_package/assets/chime.m4a');
Requirements for custom audio:
| Property | Recommendation |
|---|---|
| Format | .m4a (preferred), .wav, .mp3. Not .ogg — see below. |
| Channels / rate | Mono, 44.1 kHz. Anything else is converted at load time; stereo is downmixed on iOS. |
| Duration | Under ~5 seconds. This is a hard limit on Android — see below. |
| Trimming | Remove leading silence — it is perceived as input lag. |
Prefer
.m4a: it decodes to PCM once at load, so it costs nothing at playback but is roughly five times smaller than.wav.
Avoid .ogg. Android's SoundPool decodes Vorbis, but iOS Core Audio has no Vorbis decoder. An .ogg asset will pass all your Android testing and then fail silently on iOS.
Respect the duration limit. Android's SoundPool enforces an internal sample-size ceiling. Exceed it and the load may still report success while playback is truncated, with no error reported on any channel — the one failure mode this plugin cannot surface to you.
Verifying your assets #
play() is fire-and-forget and never reports failure, so a broken asset would otherwise ship unnoticed. preload() returns whether the sound actually loaded:
if (!await repo.preloadAll(mySounds)) {
// Report to crash reporting — an asset is missing, malformed, or unsupported.
}
Doing this at startup catches missing and malformed assets before a user hits them. It returns false on load failure, before initialize(), and on unsupported platforms.
4. Cleanup #
@override
void dispose() {
repo.release();
super.dispose();
}
5. Unit & widget testing #
Use resetForTesting() for isolation between tests:
tearDown(() {
NativeHapticsAndAudioRepository.resetForTesting();
});
Memory #
Audio lives in native RAM as decoded PCM: roughly 88 KB per second on Android (16-bit) and 176 KB per second on iOS (float32). The full 13-sound catalog is about 1.1 MB on Android and 2.2 MB on iOS. The plugin bounds this with two tiers:
| Tier | Populated by | Evicted by |
|---|---|---|
| Pinned | preload() |
Only unload() / unloadAll() |
| Cached | play() on a cache miss |
Least-recently-used, once maxCachedSounds is reached |
The split exists because a plain LRU would defeat the purpose: preload your scanner beep, play ten other sounds, and the beep — the one sound you cared about — gets evicted. Pinning makes that impossible.
await repo.initialize(
maxCachedSounds: 10, // ceiling for on-demand sounds; pinned are exempt
maxStreams: 8, // simultaneous playback voices
respectSilentSwitch: true, // iOS: obey the hardware ringer switch
loadTimeout: Duration(seconds: 10), // abandon a decode that never completes
);
Ten typical notification clips occupy roughly 1 MB on Android and 2 MB on iOS, so the default is comfortable on any device. Inspect what is resident at runtime via repo.pinnedAssets, repo.cachedAssets, and repo.isLoaded(sound).
The silent switch #
respectSilentSwitch is iOS-only and has no Android equivalent.
true(default) — session categoryambient. The hardware ringer switch silences your sounds, which is the polite default for notification-style audio.false— session categoryplayback. Audio plays regardless of the switch. Use this only when audio is essential to your app's function, such as a POS confirmation tone.
Both settings mix with other apps' audio rather than interrupting it.
Platform Support #
| Platform | Status | Notes |
|---|---|---|
| Android | ✅ Supported | API 24+ (SoundPool + Vibrator). AGP 8 and AGP 9; Built-in Kotlin, so the plugin declares no Kotlin Gradle Plugin of its own |
| iOS | ✅ Supported | iOS 13.0+ (AVAudioEngine + UIFeedbackGenerator, SwiftPM & CocoaPods) |
| Web | ⚪ No-op | Calls return silently |
| macOS | ⚪ No-op | Calls return silently |
| Windows | ⚪ No-op | Calls return silently |
| Linux | ⚪ No-op | Calls return silently |
Permissions #
Android — the plugin declares VIBRATE in its own AndroidManifest.xml; it merges into your build automatically.
iOS — none required.
Available Sounds #
All clips are mono 44.1 kHz AAC, trimmed.
| Enum Value | Description |
|---|---|
NativeSound.scannerBeep |
Checkout scanner confirmation beep |
NativeSound.warningBeep |
Single warning tone for attention-needed events |
NativeSound.doubleWarningBeep |
Urgent double-beep for errors or duplicate scans |
NativeSound.kaching |
Cash register "ka-ching" for completed transactions |
NativeSound.appError |
Generic application error tone |
NativeSound.paymentSuccess |
Contactless payment approval chime |
NativeSound.deviceConnected |
Bluetooth peripheral connection established |
NativeSound.buzzerError |
Harsh buzzer for rejected or invalid input |
NativeSound.errorChime |
Soft descending tone for recoverable errors |
NativeSound.walletConfirm |
Digital wallet confirmation tone |
NativeSound.nfcSuccess |
NFC tag read successfully |
NativeSound.softSuccess |
Gentle, unobtrusive success tone |
NativeSound.transactionSuccess |
Full transaction completion fanfare |
Available Haptics #
| Enum Value | Description |
|---|---|
HapticPattern.success |
Crisp, short mechanical tick — confirms a successful action |
HapticPattern.warning |
Medium-intensity pulse — signals a non-critical warning |
HapticPattern.error |
Heavy double-buzz — signals an error or rejected action |
Migrating from 1.x #
The Pos prefix was specific to this plugin's original point-of-sale use case and has been dropped. The old names still work as deprecated aliases and will be removed in 3.0.0.
| 1.x | 2.0 |
|---|---|
PosSound |
NativeSound |
PosHaptic |
HapticPattern |
repo.playSound(sound) |
repo.play(sound) |
repo.playHaptic(haptic) |
repo.playHaptic(pattern) — unchanged |
The one behavior change to plan for: 1.x used iOS AudioServices, which is always silenced by the hardware ringer switch. 2.0 makes that a choice. The default (respectSilentSwitch: true) preserves 1.x behavior; pass false if your app needs audio regardless of the switch.
Sounds also no longer load automatically at initialize(). If you relied on every sound being ready up front:
await repo.initialize(maxCachedSounds: NativeSound.values.length);
await repo.preloadAll(NativeSound.values);
Most apps should instead pin only their hot sounds and let the rest load on demand.
Contributing #
Contributions are welcome! Please open an issue first to discuss what you would like to change.
- Fork the repo
- Create your feature branch (
git checkout -b feature/my-feature) - Run
flutter analyzeandflutter testbefore committing - If you change
pigeons/messages.dart, regenerate withdart run pigeon --input pigeons/messages.dart - Open a Pull Request
License #
This project is licensed under the MIT License — see the LICENSE file for details.