0 votes

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://godotengine.org/qa/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.

asked Apr 11 in Engine by Zylann (26,157 points)
edited Apr 11 by Zylann

1 Answer

+1 vote

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.

answered Apr 11 by Zylann (26,157 points)
edited Apr 11 by Zylann
Welcome to Godot Engine Q&A, where you can ask questions and receive answers from other members of the community.

Please make sure to read How to use this Q&A? before posting your first questions.