Python's SoundFile: soundfile.write and clipping

May 2024 · 4 minute read

Suppose I read a WAV file using Python's soundfile,

import soundfile x, fs = soundfile.read("test.wav") 

The array x is in float32 and max(x) = 1, min(x) = -1. That is: each sample in x is a float32 number between -1 and 1. I do some manipulation on it and get y. Now I want to save y to a WAV file. Suppose that y has now values greater than 1 (and/or smaller than -1), and I use

soundfile.write("processed.wav", y, fs) 

How SoundFile treat the exceeded values? Does it do clipping (if y[t] > 1 is takes y[t] = 1) or normalization (divide the entire signal by max(abs(y))) or something else?

I didn't find the answer in the documentation: https://pysoundfile.readthedocs.io/en/latest/#soundfile.write

import numpy as np import soundfile as sf x = np.array([0,0.5,0.75, 1, 2]) sf.write("x.wav", x, 1) y, fs = sf.read("x.wav") print(y) 

The output is:

[0. 0.5 0.75 0.99996948 0.99996948] 

So it seems that it does do clipping, but I want to be sure. Can I control how soundfile.write deals with exceeding values?

2

1 Answer

The important question to answer here is not just what soundfile does, but how to confirm the behaviour.

Let's keep this neat little sample program, which with some extra annotation is:

import numpy as np import soundfile as sf x = np.array([0,0.5,0.75, 1, 2]) # x.dtype is 'float64' sf.write("x.wav", x, 1) # a wav at sampling rate 1 Hz y, fs = sf.read("x.wav") print(y) 

WAVs can come in a few flavours varying both in sampling rate and in data format (or bit-depth). One potential for odd behaviour is the 1 Hz sampling rate. Thankfully in this case it has no affect, but it would be a good idea in general to avoid potential problems stemming from weird values. Stick to a standard sampling rate until you can define the behaviour.

The documentation for Soundfile isn't opaque per se, but you do need to do a little chasing for information. For the write() method we see

subtype (str, optional) – See default_subtype() for the default value and available_subtypes() for all possible values.

However, another important bit of information is actually under the data field

The data type of data does not select the data type of the written file. Audio data will be converted to the given subtype. Writing int values to a float file will not scale the values to [-1.0, 1.0). If you write the value np.array([42], dtype='int32'), to a subtype='FLOAT' file, the file will then contain np.array([42.], dtype='float32').

Basically, the data type is not inferred by the sample data and will be scaled to the subtype.

When we look at default_subtype we find that the default for WAV is 16-bit PCM.

The tricky bit is, what does soundfile do when reading information with read?

Good practice would be to use something else to confirm the behaviour. If the second means of reading the data report the same information, then bingo, we've cracked it. If not, then that suggests at least one method is changing the data, so you have to try a third (and so on and so forth).

A nice way to read the data and be sure it hasn't been altered is by just reading with a hex editor.

Lets at this point remind ourselves that our output from soundfile.read() is:

[0. 0.5 0.75 0.99996948 0.99996948] 

The example above in hex this creates a file:

52494646 2E000000 57415645 666D7420 10000000 01000100 01000000 02000000 02001000 64617461 0A000000 00000040 0060FF7F FF7F 

We know it is 16-bit samples, so the last 10 bytes is what we're interested in (2-bytes per sample, 5 samples in total)

16-bits is signed, so we have a swing of ±2^{15}, i.e. 32768 (don't worry pedants, I'll get to it in a minute)

0000 0040 0060 FF7F FF7F 

Ah, but that is in little endian format. So, lets flip it around to make it a little easier to see

0000 4000 6000 7FFF 7FFF 

each one in turn

Since amplitudes are scaled to 32767, this is the reason for the slight error when reading the data back in: 32767 / 32768 equals 0.99996948 (with a little rounding error)

Lets confirm that behaviour by flipping the last two samples negative.

import numpy as np import soundfile as sf x = np.array([0,0.5,0.75, -1, -2]) # x.dtype is 'float64' sf.write("x.wav", x, 1) # a wav at sampling rate 1 Hz y, fs = sf.read("x.wav") print(y) 

In big endian format, our hex data is now

0000 4000 6000 8000 8000 

8000 is -32768 as a 16-bit signed integer.

From this we can confirm our data is being clipped (not normalised or wrapped)

ncG1vNJzZmirpJawrLvVnqmfpJ%2Bse6S7zGiorp2jqbawutJobXJraG2CdH2OqbCtoJ%2BjwG6%2Fzq6lnZ6ZobJuv86upZ2emaGybsPRoqueZZGjsW6vy6KnqaGenA%3D%3D