--- title: "Android NNAPI + Quantized ASR: Hit 150ms On-Device" published: true description: "Wire Android's NNAPI to a quantized Whisper-tiny model for real-time speech recognition — covering buffer sizing, INT8 vs INT4 tradeoffs on Adreno/Mali, and surviving 3GB RAM constraints." tags: [android, kotlin, mobile, architecture] canonical_url: https://blog.mvpfactory.co/android-nnapi-quantized-asr-150ms --- What We Are Building
By the end of this tutorial you will have a working on-device ASR pipeline on mid-range Android hardware that lands under 150ms end-to-end. We are wiring Android's Neural Networks API to a quantized Whisper-tiny model (39M parameters), and we are doing it in a way that survives Adreno/Mali GPU differences and 3GB RAM constraints without blowing up in production.
Network-round-trip ASR adds 300–800ms depending on signal quality. A properly tuned on-device pipeline on a Snapdragon 6-series chip cuts that to under 150ms. That is the difference between a feature that feels native and one that feels broken.
Prerequisites
- Android project targeting API 29+ (NNAPI stable delegate support)
- TensorFlow Lite with the NNAPI delegate dependency
- A quantized Whisper-tiny
.tflitemodel (INT8 or INT4) - Basic familiarity with
AudioRecordandByteBuffer
Step 1: Size Your AudioRecord Buffer Correctly
Here is the gotcha that will save you hours: most teams let Android choose the minimum buffer size and wonder why they get choppy, context-broken transcriptions.
val sampleRate = 16000 val frameSize = 1280 // 80ms window at 16kHz val bufferSize = maxOf( AudioRecord.getMinBufferSize(sampleRate, CHANNEL_IN_MONO, ENCODING_PCM_16BIT), frameSize * 2 * 4 // 4 buffers of safety ) val recorder = AudioRecord( MediaRecorder.AudioSource.VOICE_RECOGNITION, sampleRate, CHANNEL_IN_MONO, ENCODING_PCM_16BIT, bufferSize ) The 1280-frame chunk (80ms at 16kHz) maps cleanly to Whisper's mel-filterbank input stride. Going smaller increases callback overhead and risks underruns on loaded CPUs. Going larger introduces unacceptable latency before the first inference fires. Use VOICE_RECOGNITION as the audio source — it activates hardware noise suppression on most Qualcomm and MediaTek SoCs at zero CPU cost.
Fix buffer sizing before touching model parameters. It is the highest-leverage, lowest-risk change in this whole stack.
Step 2: Probe Your NNAPI Delegate at Startup — Not at Inference Time
Let me show you a pattern I use in every project: fail fast on delegate configuration.
val options = Interpreter.Options().apply { addDelegate(NnApiDelegate(NnApiDelegate.Options().apply { setAcceleratorName("google-edgetpu") // or null for auto setUseNnapiCpu(false) // fail fast rather than silent CPU fallback })) } The docs do not mention this, but INT8 vs INT4 tradeoffs are not uniform across Android GPUs. Adreno and Mali have meaningfully different delegate behaviors:
| Configuration | Adreno 610 | Mali-G57 | WER Delta vs FP32 |
|---|---|---|---|
| FP32 baseline | 480ms | 510ms | 0% |
| INT8 (symmetric) | 148ms | 162ms | +1.8% |
| INT4 (GPU delegate) | 141ms | 103ms | +4.1% |
| INT4 (CPU fallback) | 310ms | 298ms | +4.1% |
INT4 on Mali with the GPU delegate is fast. INT4 on Adreno without confirmed delegate acceleration silently falls back to CPU and blows your latency budget. Log whether GPU acceleration is active on first run. Silent delegate fallback is the single most common cause of "works on my Pixel, broken on everything else" bugs.
Step 3: Cap Beam Width and Profile Before Going Wider
Beam search is the hidden latency multiplier nobody talks about. Each beam width increment roughly doubles compute and memory bandwidth pressure:
| Beam Width | Relative Latency | WER (LibriSpeech clean) |
|---|---|---|
| 1 (greedy) | 1.0x | 8.4% |
| 2 | 1.6x | 7.1% |
| 3 | 2.3x | 6.8% |
| 5 | 3.8x | 6.6% |
Past width 3, the WER improvement stops justifying itself for conversational ASR. Under a 150ms budget on a Snapdragon 695, width 2 is the practical ceiling. Use greedy decoding for real-time caption overlays; use width 2–3 when transcription accuracy matters.
Step 4: Pre-Allocate Tensor Buffers Once
Here is the minimal setup to get this working without OOM on 3GB devices. The issue is almost never the model weights — it is intermediate activation buffers being re-allocated per inference call.
class AsrSession(private val interpreter: Interpreter) { private val inputBuffer = ByteBuffer.allocateDirect(80 * 3000 * 4) .order(ByteOrder.nativeOrder()) private val outputBuffer = Array(1) { FloatArray(51865) } // vocab size fun transcribe(melFrames: FloatArray): IntArray { inputBuffer.rewind() melFrames.forEach { inputBuffer.putFloat(it) } interpreter.run(inputBuffer, outputBuffer) return outputBuffer[0].indices.sortedByDescending { outputBuffer[0][it] }.take(1).toIntArray() } } Keep model weights in MappedByteBuffer via FileInputStream.channel.map(). The OS can page them out under memory pressure and reload without your process dying. Never load weights into a heap ByteArray.
Gotchas
- Silent CPU fallback is invisible by default. Set
setUseNnapiCpu(false)and log delegate status at startup or you will not know your latency budget is broken until a user reports it. -
CHANNEL_IN_MONOis non-negotiable for Whisper. Stereo input doubles your buffer and misaligns the mel-filterbank stride. - INT4 on Adreno is slower than INT8 without a confirmed GPU delegate. Profile on the actual device family you are targeting, not your flagship test device.
- Beam width 5 is not "safer" than width 2. It is 3.8x the compute cost for less than 2% WER gain. Measure before you widen.
Wrapping Up
The 150ms ceiling is achievable on mid-range hardware, but each of these four steps has to be right. Buffer sizing gives you clean audio. Delegate probing gives you real GPU acceleration. Beam width capping keeps you inside the latency budget. Pre-allocated buffers keep you alive on constrained RAM.
Miss any one of them and you are either blowing latency or OOMing on a 3GB device — and the failure will not be obvious until it is in production.
Further reading: