From 83f318dee1a1b9275ec7f8ddc402edd68c2bb4a3 Mon Sep 17 00:00:00 2001 From: Paul Schunck Date: Fri, 29 May 2026 13:40:13 +0200 Subject: [PATCH 1/3] Compute Android waveform window offsets with 64-bit arithmetic On long files the 32-bit product `i * totalSamples` overflowed to a negative index, crashing getWaveform/getWaveformBytes with an IndexOutOfBoundsException. Extract the RMS reduction into a testable computeWaveform helper and widen the offset math to Long. --- .../audio_decoder/AudioDecoderPlugin.kt | 30 ++++++++++--- .../audio_decoder/AudioDecoderPluginTest.kt | 43 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/android/src/main/kotlin/nl/silversoft/audio_decoder/AudioDecoderPlugin.kt b/android/src/main/kotlin/nl/silversoft/audio_decoder/AudioDecoderPlugin.kt index b1296b5..76503a1 100644 --- a/android/src/main/kotlin/nl/silversoft/audio_decoder/AudioDecoderPlugin.kt +++ b/android/src/main/kotlin/nl/silversoft/audio_decoder/AudioDecoderPlugin.kt @@ -905,22 +905,42 @@ class AudioDecoderPlugin : FlutterPlugin, MethodCallHandler { codec.release() extractor.release() - if (allSamples.isEmpty()) { + return computeWaveform(allSamples.toShortArray(), numberOfSamples, normalization) + } + + /** + * Reduces decoded 16-bit PCM [samples] to a normalized RMS waveform of + * [numberOfSamples] points. + * + * The window bounds are computed with 64-bit arithmetic on purpose: for + * longer files the product `i * totalSamples` easily exceeds Int.MAX_VALUE, + * which would silently overflow to a negative offset and crash with an + * IndexOutOfBoundsException. The caller is expected to validate + * [normalization] beforehand. + */ + internal fun computeWaveform( + samples: ShortArray, + numberOfSamples: Int, + normalization: String = "perFile", + ): List { + if (samples.isEmpty()) { return List(numberOfSamples) { 0.0 } } // Compute RMS per window + val totalSamples = samples.size + val windowSize = max(1, totalSamples / numberOfSamples) val waveform = mutableListOf() var maxRms = 0.0 for (i in 0 until numberOfSamples) { - val start = i * allSamples.size / numberOfSamples - val end = min(start + max(1, allSamples.size / numberOfSamples), allSamples.size) - if (start >= allSamples.size) break + val start = (i.toLong() * totalSamples / numberOfSamples).toInt() + if (start >= totalSamples) break + val end = min(start + windowSize, totalSamples) var sumSquares = 0.0 for (j in start until end) { - val sample = allSamples[j].toDouble() + val sample = samples[j].toDouble() sumSquares += sample * sample } val rms = sqrt(sumSquares / (end - start)) diff --git a/android/src/test/kotlin/nl/silversoft/audio_decoder/AudioDecoderPluginTest.kt b/android/src/test/kotlin/nl/silversoft/audio_decoder/AudioDecoderPluginTest.kt index 1d8f627..09f43b2 100644 --- a/android/src/test/kotlin/nl/silversoft/audio_decoder/AudioDecoderPluginTest.kt +++ b/android/src/test/kotlin/nl/silversoft/audio_decoder/AudioDecoderPluginTest.kt @@ -4,6 +4,8 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import org.mockito.Mockito import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue /* * Once you have built the plugin's example app, you can run these tests from the command @@ -172,4 +174,45 @@ internal class AudioDecoderPluginTest { Mockito.isNull() ) } + + @Test + fun computeWaveform_largeSampleCount_doesNotOverflow() { + val plugin = AudioDecoderPlugin() + + // Regression for #45: a multi-minute file decodes to millions of PCM + // samples. With 32-bit window arithmetic `i * totalSamples` exceeds + // Int.MAX_VALUE and wraps to a negative offset, crashing with an + // IndexOutOfBoundsException. This sample count matches the length from + // the original bug report. + val numberOfSamples = 1000 + val samples = ShortArray(15_567_358) { 1000 } + + val waveform = plugin.computeWaveform(samples, numberOfSamples, "perFile") + + assertEquals(numberOfSamples, waveform.size) + assertTrue(waveform.all { it in 0.0..1.0 }) + } + + @Test + fun computeWaveform_emptySamples_returnsZeroFilledWaveform() { + val plugin = AudioDecoderPlugin() + + val waveform = plugin.computeWaveform(ShortArray(0), 256, "perFile") + + assertEquals(256, waveform.size) + assertTrue(waveform.all { it == 0.0 }) + } + + @Test + fun computeWaveform_absoluteNormalization_scalesByFullScale() { + val plugin = AudioDecoderPlugin() + + // A constant full-scale signal must normalize to 1.0 in absolute mode. + val samples = ShortArray(2048) { Short.MAX_VALUE } + + val waveform = plugin.computeWaveform(samples, 128, "absolute") + + assertEquals(128, waveform.size) + assertTrue(waveform.all { it > 0.99 && it <= 1.0 }) + } } From 657b22de1c8ea5d12084e07d680245f6aa89ec84 Mon Sep 17 00:00:00 2001 From: Paul Schunck Date: Fri, 29 May 2026 13:40:18 +0200 Subject: [PATCH 2/3] Release 0.8.1 with the Android waveform overflow fix --- CHANGELOG.md | 4 ++++ example/pubspec.lock | 2 +- pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 758147e..dd510f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.1 + +* **Fix `IndexOutOfBoundsException` on Android for long files** (#45) — `getWaveform` / `getWaveformBytes` crashed on medium-to-large audio (e.g. a 5-minute MP3) because the per-window offset `i * totalSamples` overflowed a 32-bit `Int` and wrapped to a negative index. The window bounds are now computed with 64-bit arithmetic. + ## 0.8.0 * **`WaveformNormalization` option** — opt into absolute amplitude scaling on `getWaveform` / `getWaveformBytes` to preserve loudness differences between tracks (useful for music apps that show several songs side by side). diff --git a/example/pubspec.lock b/example/pubspec.lock index b6f0361..5131294 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -15,7 +15,7 @@ packages: path: ".." relative: true source: path - version: "0.7.4" + version: "0.8.1" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 872840f..b38f67d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: audio_decoder description: "Decode MP3, M4A, AAC, FLAC, OGG & more to WAV/PCM using native platform APIs. Convert, trim, and analyze audio — no FFmpeg required." -version: 0.8.0 +version: 0.8.1 homepage: https://www.silversoft.nl repository: https://github.com/sjoenk/audio_decoder issue_tracker: https://github.com/sjoenk/audio_decoder/issues From 94777130f449ba5e308c131fdc28accc4bfe1456 Mon Sep 17 00:00:00 2001 From: Paul Schunck Date: Fri, 29 May 2026 14:44:07 +0200 Subject: [PATCH 3/3] Cover #45 with an on-device long-file waveform test Synthesize a multi-million-frame WAV at runtime and run getWaveform over it, so the 32-bit window-offset overflow is caught end-to-end on Android without bundling a large audio asset. --- .../plugin_integration_test.dart | 24 ++++++++++++++ example/integration_test/test_helpers.dart | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart index 525f2c0..28893bd 100644 --- a/example/integration_test/plugin_integration_test.dart +++ b/example/integration_test/plugin_integration_test.dart @@ -357,6 +357,30 @@ void main() { ); }, ); + + testWidgets('long file does not overflow window offsets (regression #45)', ( + WidgetTester tester, + ) async { + // A multi-minute file decodes to millions of PCM samples. With 32-bit + // window arithmetic the offset `i * totalSamples` exceeds Int.MAX_VALUE + // and wraps to a negative index, crashing on Android with an + // IndexOutOfBoundsException. 3M mono frames is enough to push the last + // windows past the 32-bit limit (999 * 3_000_000 ≈ 3.0e9 > 2.15e9). + final inputPath = '${tempDir.path}/long_tone.wav'; + await File(inputPath).writeAsBytes(buildPcmWav(frameCount: 3000000)); + + const sampleCount = 1000; + final waveform = await AudioDecoder.getWaveform( + inputPath, + numberOfSamples: sampleCount, + ); + + expect(waveform.length, sampleCount); + for (final sample in waveform) { + expect(sample, greaterThanOrEqualTo(0.0)); + expect(sample, lessThanOrEqualTo(1.0)); + } + }); }); // ── 7. Bytes API ──────────────────────────────────────────────────── diff --git a/example/integration_test/test_helpers.dart b/example/integration_test/test_helpers.dart index 33d8fbe..a28b274 100644 --- a/example/integration_test/test_helpers.dart +++ b/example/integration_test/test_helpers.dart @@ -9,6 +9,39 @@ int readUint16LE(Uint8List bytes, int offset) => bytes[offset] | (bytes[offset + int readUint32LE(Uint8List bytes, int offset) => bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24); +/// Build a synthetic mono 16-bit PCM WAV with [frameCount] frames and a +/// non-silent sawtooth waveform. +/// +/// Used to exercise long-file code paths on device without bundling a large +/// asset in the repository. +Uint8List buildPcmWav({required int frameCount, int sampleRate = 44100}) { + const channels = 1; + const bytesPerSample = 2; // 16-bit + final dataSize = frameCount * channels * bytesPerSample; + final bytes = Uint8List(44 + dataSize); + final view = ByteData.view(bytes.buffer); + + bytes.setRange(0, 4, 'RIFF'.codeUnits); + view.setUint32(4, 36 + dataSize, Endian.little); + bytes.setRange(8, 12, 'WAVE'.codeUnits); + bytes.setRange(12, 16, 'fmt '.codeUnits); + view.setUint32(16, 16, Endian.little); + view.setUint16(20, 1, Endian.little); // PCM + view.setUint16(22, channels, Endian.little); + view.setUint32(24, sampleRate, Endian.little); + view.setUint32(28, sampleRate * channels * bytesPerSample, Endian.little); + view.setUint16(32, channels * bytesPerSample, Endian.little); + view.setUint16(34, 16, Endian.little); + bytes.setRange(36, 40, 'data'.codeUnits); + view.setUint32(40, dataSize, Endian.little); + + // Sawtooth payload so every window has a non-zero RMS. + for (var i = 0; i < frameCount; i++) { + view.setInt16(44 + i * bytesPerSample, ((i * 137) % 65536) - 32768, Endian.little); + } + return bytes; +} + /// Validate the WAV header structure of [bytes] and return a map with /// the parsed header fields. Map validateWavHeader(Uint8List bytes) {