Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Double> {
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<Double>()
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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 })
}
}
24 changes: 24 additions & 0 deletions example/integration_test/plugin_integration_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────
Expand Down
33 changes: 33 additions & 0 deletions example/integration_test/test_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, int> validateWavHeader(Uint8List bytes) {
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.7.4"
version: "0.8.1"
boolean_selector:
dependency: transitive
description:
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down