Skip to content

fix(android): stop WaveformExtractor from killing the app, and decode PCM correctly - #2

Closed
victorrss wants to merge 4 commits into
mainfrom
fix/android-waveform-extractor-crash
Closed

fix(android): stop WaveformExtractor from killing the app, and decode PCM correctly#2
victorrss wants to merge 4 commits into
mainfrom
fix/android-waveform-extractor-crash

Conversation

@victorrss

@victorrss victorrss commented Aug 20, 2026

Copy link
Copy Markdown
Member

Description

Playing back a short clip crashes the app process on Android with an IllegalStateException in MediaCodec.native_stop, and the waveform it draws does not match the audio.

WaveformExtractor sizes its buckets from the container's declared duration, but a decoder emits more frames than that implies — an AAC encoder delay alone is 2048 frames, plus up to one 1024-frame block of padding. Once those extra frames fill one more bucket, the extractor calls stop() from inside its own onOutputBufferAvailable callback, releasing the codec mid-buffer; the end-of-stream callback queued behind it then stops the released codec:

FATAL EXCEPTION: main
java.lang.IllegalStateException
    at android.media.MediaCodec.native_stop
    at com.simform.audio_waveforms.WaveformExtractor.stop
    at com.simform.audio_waveforms.WaveformExtractor$startDecode$1$1.onOutputBufferAvailable

A 2.3s clip at 16 kHz declares 36,864 frames but decodes 38,912, so at ~55 bars its ~670-frame buckets overflow on every playback. The example app never trips this: its 9.6s–105s assets at 44.1 kHz with the default 100 points leave 4,000+ frames per bucket. Recording a clip shorter than ~4.6s there reproduces it.

What this changes

  • The decoder's trailing padding frames are dropped and the stream drains to EOF, instead of the codec being torn down from inside its own callback. stop() is idempotent for the races that remain — it is also reachable from stopExtraction on the platform thread.
  • Completion is signalled on end-of-stream or on cancellation, instead of comparing progress to exactly 1.0F. A clip that ends before it produced the requested number of points never reached that value, leaving extractWaveformData pending forever. ExtractorCallBack existed only to carry that signal and is removed. Replies are posted to the main looper, where a MethodChannel.Result has to be answered.
  • PCM decoding is corrected. handle16bit widened the low byte of each little-endian sample without masking, so every sample over 0x7F had its high bits swallowed by the sign — roughly half of them were noise. handle8bit read unsigned PCM as signed, and the 32-bit path reinterpreted ENCODING_PCM_FLOAT data as integers.
  • perSamplePoints has a floor of 1, so asking for more points than the clip has samples no longer divides by zero.
  • The trailing bucket takes its RMS over the samples it actually received rather than over a full bucket, which previously understated the last bar.

iOS is unaffected: WaveformExtractor.swift iterates exactly samplesPerPixel buckets and calls its completion unconditionally.

Checklist

  • The title of my PR starts with a Conventional Commit prefix (fix:, feat:, docs: etc).
  • I have followed the Contributor Guide when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality. There is no test source set for the Android plugin, so this was verified by hand: the crash reproduces reliably on a clip under ~4.6s and no longer occurs, and the corrected samples were checked against the audio.
  • I have updated/added relevant documentation in docs and added dartdoc comments with ///. The behaviour is internal to the Android extractor and the reasoning is documented in the source comments; no public API is described differently.
  • I have updated/added relevant examples in examples or docs. No example change: this is a bug fix behind the existing API.

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

The Dart API is untouched. The removed ExtractorCallBack interface and the changed WaveformExtractor constructor are Android-internal to the plugin and are not reachable from Dart.

… PCM correctly

Playing back a short clip crashed the process. The extractor sizes its
buckets from the container's declared duration, but a decoder emits more
frames than that implies — an AAC encoder delay alone is 2048 frames — so
the buckets ran out before the stream did. On that overflow it called
stop() from inside its own onOutputBufferAvailable callback, releasing the
MediaCodec mid-buffer; the end-of-stream callback queued behind it then
stopped the released codec and the app died on an IllegalStateException in
MediaCodec.native_stop. Those trailing frames are padding, so drop them and
let the stream drain to EOF instead, and make stop() idempotent for the
races that remain (it is also reachable from stopExtraction on the platform
thread).

Completion was signalled by comparing progress to exactly 1.0F, which a clip
ending early never reaches, leaving extractWaveformData pending forever.
The extractor now owns its reply and answers on end-of-stream or on
cancellation, so ExtractorCallBack has nothing left to do. Replies are
posted to the main looper, where a MethodChannel.Result has to be answered.

The samples themselves were wrong too. handle16bit widened the low byte of
each little-endian sample without masking, so every sample over 0x7F had its
high bits swallowed by the sign — roughly half of them were noise.
handle8bit read unsigned PCM as signed, and the 32-bit path reinterpreted
ENCODING_PCM_FLOAT data as integers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI lite review requested due to automatic review settings August 20, 2026 18:34
Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the Android waveform extraction pipeline by preventing MediaCodec teardown races that can crash the app, ensuring extraction always completes (including early-ending clips/cancellation), and fixing PCM decoding correctness for multiple sample formats.

Changes:

  • Makes WaveformExtractor.stop() idempotent and moves final reply ownership into the extractor (posted to the main looper) instead of using ExtractorCallBack.
  • Prevents waveform bucket overflow from killing the app by dropping trailing padding frames and handling partial final buckets at EOF.
  • Fixes PCM decoding for 8-bit unsigned, 16-bit little-endian sign/masking, and 32-bit float PCM.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt Fixes teardown race/crash, ensures completion reply, and corrects PCM decoding + bucket sizing/EOF handling.
android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt Removes ExtractorCallBack usage and relies on extractor-owned completion.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 146 to 149
} catch (e: Exception) {
inputEof = true
result.error(
Constants.LOG_TAG,
e.message,
"Invalid input buffer."
)
submitError(e.message, "Invalid input buffer.")
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 4e9e4b3. No end-of-stream buffer was queued on that path, so the codec would never have reached EOF and would have kept running with its caller already answered. It now calls stop() right after submitError(); stop() is idempotent and nulls the decoder, so any callback still in flight returns early.

Comment on lines +180 to +184
totalSamples = (sampleRate.toLong() * durationMillis) / 1000
perSamplePoints = totalSamples / expectedPoints
// A bucket of zero samples would divide by zero on
// every point when more points are asked for than the
// clip has samples.
perSamplePoints = max(1L, totalSamples / expectedPoints)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, this was a real crash path — integer division throws, and it would have thrown from the codec's callback thread. Fixed in 4e9e4b3 by flooring the count once into a pointCount field and using it everywhere a bucket is sized, including updateProgress(), which would otherwise have produced an infinite progress value.

Comment on lines 395 to 399
private fun sendProgress(rms: Float) {
sampleData.add(rms)
extractorCallBack.onProgress(progress)
sampleCount = 0
sampleSum = 0.0

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 4e9e4b3. Same defect class as the result reply this PR already moved to the main looper, so it should not have been left behind. It follows AudioRecorder.sendBytesToFlutter now, and posts a snapshot of the sample list rather than the live one, since the post outlives the callback and the list keeps growing until extraction ends.

victorrss and others added 2 commits August 20, 2026 15:41
… progress to the main looper

Addresses review feedback:

- A queueInputBuffer failure never queued an end-of-stream buffer, so the
  codec would keep running with its caller already answered. Tear down there.
- expectedPoints arrives from Flutter and nothing stops it from being zero,
  which threw an ArithmeticException from onOutputFormatChanged. Floor it once
  and use that everywhere a bucket is sized.
- invokeMethod ran on the codec's callback looper. Post it to the main looper,
  as AudioRecorder.sendBytesToFlutter already does, with a snapshot of the
  sample list since the post outlives the callback.

Co-authored-by: Cursor <cursoragent@cursor.com>
…input buffer

Stopping a recording could never finalize the file. End of stream is queued
from onInputBufferAvailable, but the codec does not call back for a buffer it
has already handed over, and by the time recording stops the input queue is
drained with one such buffer held. Nothing then marked the end of the stream:
the encoder never finished, stopEncoder never ran, the completion callback
never fired, and the caller waited forever for a file left without its moov
box. The leaked MediaMuxer only stopped later, from its finalizer.

signalToStop now queues the end-of-stream buffer itself when it is holding
one and the queue is drained. The end-of-stream timestamp calculation moves
into a helper, shared with the callback path.

Co-authored-by: Cursor <cursoragent@cursor.com>
@victorrss

Copy link
Copy Markdown
Member Author

Closing: we are not taking the fork down this path for now. The branch stays available if we pick the extractor work back up.

@victorrss victorrss closed this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants