DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

Why audio_decode divides by 32768, and never by 32767

toFloat32() divides every sample by 32768, not by 32767, the actual maximum value a signed 16-bit sample can hold. That looks like an off-by-one. It isn't. Write this conversion yourself the obvious way and it breaks a promise your downstream code is almost certainly relying on.

The range is not symmetric

A signed 16-bit integer holds 65536 distinct values, and two's complement splits them unevenly: one more negative value than positive. The range runs from -32768 to 32767, not to -32767. That single missing value on the positive side is where the whole question comes from.

final extremes = Int16List.fromList([-32768, 32767, 0, -1, 1]);
final pcm = PcmAudio(sampleRate: 44100, channels: 1, samples: extremes);
final f = pcm.toFloat32();
for (var i = 0; i < extremes.length; i++) {
  print('${extremes[i]} / 32768 = ${f[i]}');
}
Enter fullscreen mode Exit fullscreen mode

Running that against the real package:

-32768 / 32768 = -1.0
32767 / 32768 = 0.999969482421875
0 / 32768 = 0.0
-1 / 32768 = -0.000030517578125
1 / 32768 = 0.000030517578125
Enter fullscreen mode Exit fullscreen mode

The most negative sample lands on exactly -1.0. The most positive sample lands a hair under 1.0, short by about three thousandths of a percent. That gap is the cost of the fix. Dividing by 32768 gives up a little headroom on the positive side so no sample can land outside [-1.0, 1.0].

Dividing by 32768 keeps every sample inside [-1, 1]. Dividing by 32767 does not: both endpoints map cleanly under 32768, but under 32767 the negative extreme lands outside the box.

What the naive version actually does

Divide by 32767 instead, the number that looks correct because it is the true maximum, and the positive extreme becomes exact: 32767 / 32767 = 1.0. That's the appeal. But run the same division on the negative extreme:

final wrongMin = -32768 / 32767;
print(wrongMin); // -1.000030518509476
Enter fullscreen mode Exit fullscreen mode

-1.00003 is outside [-1.0, 1.0]. It is a real value your decoder would hand to whatever comes next, and plenty of things downstream in a pipeline assume the bound holds without checking: a WAV writer that clamps to 16 bits on the way back out, an FFT window function, a shader that maps [-1,1] to a texture coordinate. Depending on the implementation, that either clips the full-scale sample, silently miscomputes on it, or throws on an assumption nobody wrote down. It only shows up on the loudest sample in the file, the one most likely to matter.

The two roundings are not equally fine. One always stays in bounds. The other overflows on exactly the input most likely to occur in real audio, a full-scale sample.

What it looks like on real audio

toFloat32() on an actual decoded file stays inside that box for the same reason, at any amplitude:

A real decoded sine wave, from audio_decode's own test fixture, plotted from toFloat32 and shown to scale inside the [-1, 1] box the conversion guarantees.

final bytes = File('clip.mp3').readAsBytesSync();
final pcm = decodeMp3(bytes);
final samples = pcm.toFloat32(); // always in [-1.0, 1.0]
Enter fullscreen mode Exit fullscreen mode

This particular clip is a quiet test tone, nowhere near full scale, so the real proof is the number line above, not this waveform. The extremes are what expose the asymmetry. The guarantee toFloat32() makes doesn't depend on that: it holds just as well on a file that hits -32768 on every other sample.

The rule

When you normalize a signed integer range to a float range, check which end is one value wider before picking a divisor. Dividing by the true maximum looks right, but on an asymmetric range it leaves the extreme case out of bounds with no warning. Dividing by the next power of two gives up a little precision on the narrow side instead. In audio, the sample most likely to hit the edge of the range is also the one downstream code is least ready to see out of bounds. Use the power of two.


audio_decode decodes Ogg Vorbis and MP3 to PCM in pure Dart over FFI, with toFloat32(), channel(), and toMono() for waveform, DSP, and ML preprocessing.

Top comments (0)