How to read audio samples as [-1..1] floats?

:information_source: Attention Topic was automatically imported from the old Question2Answer platform.
:bust_in_silhouette: Asked By Zylann

I’m trying to read and write samples from an AudioStreamSample as -1…1 float values. But I’m hitting a number of walls while trying to do this.

The doc only says data expects signed PCM8 data. But what about 16 bits? Some other properties of AudioStreamSample are documented as being in bytes, which doesnt seem to make any sense in 16-bits. And yet I even have issues with 8-bits.

So I rolled my own test project to figure this out:
I made a simple sine wave sound with Audacity, with an amplitude not exceeding 0.1 (in the -1…1 linear range).
I imported that sound both as 8bit and 16bits, and tried to read samples using these functions (not returning anything, just checking if I read correctly):

func load_8bits():
	var stream = load("res://sample_8bits.wav")
	assert(stream.format == AudioStreamSample.FORMAT_8_BITS)
	var bytes = stream.data
	var i = 0
	while i < len(bytes):
		var b = bytes[i]
		# Despite what the doc says, PoolByteArray contains uint8_t values,
		# which are unsigned bytes. So in GDScript we get unsigned integers.
		# Subtracting 128 to get back to signed PCM8, I guess
		var s = float(b - 128) / 128.0
		# Quick amplitude test to verify we got the sample properly
		assert(s < 0.2)
		i += 1


func load_16bits():
	var stream = load("res://sample_16bits.wav")
	assert(stream.format == AudioStreamSample.FORMAT_16_BITS)
	var bytes = stream.data
	var i = 0
	# Read by packs of 2 bytes
	while i < len(bytes):
		var b0 = bytes[i]
		var b1 = bytes[i + 1]
		# Combine low bits and high bits to obtain 16-bit value (unsigned? signed?)
		var u = b0 | (b1 << 8)
		# Same here, convert from unsigned to signed
		var s = float(u - 32768) / 32768.0
		# Quick test to verify we got the sample properly
		assert(s < 0.2)
		i += 2

But that doesn’t work. The assert fails all the time, which means the obtained samples are invalid (they should not exceed 0.1).

In particular, I checked the 8bit one, it failed because it read a byte which value was 254. If we subtract 128, that’s a value of 126 in PCM8. Which is far too high Oo

Anyone ever managed to read such data properly? I’m pretty sure I did things correctly.

Note: I read https://forum.godotengine.org/28685/is-it-possible-to-make-the-audio-data-in-gdscript-only , but it relates more to writing audio and I’m looking for an approach using bit shifting rather than creating millions of PoolByteArrays.

:bust_in_silhouette: Reply From: Zylann

After more fiddling, I realized that converting between unsigned and signed is not straightforward. It requires to emulate 8-bit and 16-bit wrapping.

I updated the functions to be more usable, for future reference:

static func read_8bit_samples(stream: AudioStreamSample) -> Array:
	assert(stream.format == AudioStreamSample.FORMAT_8_BITS)
	var bytes = stream.data
	var samples = []
	for i in len(bytes):
		var b = bytes[i]
		# Despite what the doc says, PoolByteArray contains uint8_t values,
		# which are unsigned bytes representing signed numbers.
		# In GDScript, we still get positive integers, i.e -2 => 253.
		# So we bring back their representation as unsigned,
		# emulating the 8-bit wrapping behavior.
		var u = (b + 128) & 0xff
		# Then bring back to signed -1..1 range
		var s = float(u - 128) / 128.0
		samples.append(s)
	return samples


static func read_16bit_samples(stream: AudioStreamSample) -> Array:
	assert(stream.format == AudioStreamSample.FORMAT_16_BITS)
	var bytes = stream.data
	var samples = []
	var i = 0
	# Read by packs of 2 bytes
	while i < len(bytes):
		var b0 = bytes[i]
		var b1 = bytes[i + 1]
		# Combine low bits and high bits to obtain 16-bit value
		var u = b0 | (b1 << 8)
		# Emulate signed to unsigned 16-bit conversion
		u = (u + 32768) & 0xffff
		# Convert to -1..1 range
		var s = float(u - 32768) / 32768.0
		samples.append(s)
		i += 2
	return samples

		
static func write_8bit_samples(samples: Array) -> AudioStreamSample:
	var bytes = PoolByteArray()
	bytes.resize(len(samples))
	for i in len(samples):
		var u = int(samples[i] * 128.0) + 128
		# Godot will internally cast to byte so we don't need to emulate it.
		# This is the only part the doc explains accurately.
		bytes[i] = u - 128
	var stream = AudioStreamSample.new()
	stream.stereo = false
	stream.format = AudioStreamSample.FORMAT_8_BITS
	stream.data = bytes
	return stream


static func write_16bit_samples(samples: Array) -> AudioStreamSample:
	var bytes = PoolByteArray()
	bytes.resize(len(samples) * 2)
	for i in len(samples):
		var j = i * 2
		var u = int(samples[i] * 32768.0) + 32768
		# Emulate cast from unsigned to signed
		u = (u - 32768) & 0xffff
		# Assign low and high byte
		bytes[j] = u & 0xff
		bytes[j + 1] = u >> 8
	var stream = AudioStreamSample.new()
	stream.stereo = false
	stream.format = AudioStreamSample.FORMAT_16_BITS
	stream.data = bytes
	return stream

Note: there is something strange remaining, still.
When I save a 16-bit sound back to disk as wav, the file plays correctly, but is slightly pitched down (about 5%). It may be due to the fact the original sample I used was 48000 Hz, while the default is 44100.