Sample value conversion from float to 24bit

Hey guys :slight_smile:

I’ve been reading a few places that I will not get an AudioBuffer as samples anywhere in JUCE (if I’m wrong please tell me :smiley: ).

I can handle floats, but I’d like to know precisely how these floats (on [-1,1]) are converted into 24bit or 16bit values which are sent to the audio driver. Ideally see the code that does it.

For 24bit, I’ll assume something like this:

int writeSampleTo24BitDriver( float sample )
{
  sample *= ((float)0x7FFFFF); // change range from [-1;1] to 24bit range
  return ((int)sample); // simply convert float into int, losing the decimals
}

I tried to look that up in the JUCE code but it’s a lot of code to go through and I could not find it.

Thank you!

So I could not find the code but I did some tests and it seems the actual code is similar to what I wrote above, except it’s multiplied by 0x800000 and not 0x7FFFFF to handle the max and min values (which are 0x800000 = -8388608 in decimal, and 0x7FFFFF = +8388607 in decimal).

int writeSampleTo24BitDriver( float sample )
{
  sample *= ((float)0x800000); // change range from [-1;1] to 24bit range
  return ((int)sample); // simply convert float into int, losing the decimals
}

Just FYI if someone has the same question I had :slight_smile:

EDIT: I just realized a sample value of 1.0 would create 0x800000 which is +8388608 on 32bits, but actually -8388608 on 24 bits. So this edge case would be handled and +1.0 should be converted to +0x7FFFFF=8388607 instead. Hope that makes sense :smiley:

Many current audio APIs will take float data, and the actual conversion to ints will happen somewhere in the system or the device driver itself, or even on the device. There are layers in between the hardware I/O and the client code, for things like mixing output from multiple applications, combining and syncing multiple audio devices and so on.

So when you reverse engineer the conversion method like you did, it might still depend on the operating system, audio API or the device driver. But I guess multiplying by 0x800000 and clipping to 0x7FFFFF is probably the best bet. But that means that an exact +1.0 does not exist.

2 Likes

Thanks hugo for the reply. I see, I did not think of that…

The device is fixed and configured to accept only 24bit data so the conversion has to happen on the computer (or USB host) side before sending the data over USB…

What about if the drivers are standard for MacOS (Core-audio) and for Windows (ASIO for example… though I guess the Windows case is a bit more complicated)?
I tried to have a look at the Core-audio documentation and it seems they handle 32-bit floats so that would confirm what you said.

juce has methods to convert audio sample types, see the AudioData class.

But I bet that juce will try to send float and leave it up to the driver to convert. They might have a fallback though.

The class to look into is juce::AudioIODevice. Inside that it wraps the platform specific implementations.

1 Like

Awesome that helps a lot :slight_smile:

I had a look at the code, more precisely juce_CoreAudio_mac.cpp and juce_ASIO_windows.cpp.

CoreAudio seems to handle everything in floats, for example temporary buffers are floats and most functions which use input or output channels are an array of floats. So the conversion must be done by CoreAudio.

ASIO seems to deal with integers directly, so the conversion happens in JUCE:

static void convertInt24ToFloat (const char* src, float* dest, int srcStrideBytes,
                                     int numSamples, bool littleEndian) noexcept
    {
        const double g = 1.0 / 0x7fffff;

        if (littleEndian)
        {
            while (--numSamples >= 0)
            {
                *dest++ = (float) (g * ByteOrder::littleEndian24Bit (src));
                src += srcStrideBytes;
            }
        }
        else
        {
            while (--numSamples >= 0)
            {
                *dest++ = (float) (g * ByteOrder::bigEndian24Bit (src));
                src += srcStrideBytes;
            }
        }
    }

    static void convertFloatToInt24 (const float* src, char* dest, int dstStrideBytes,
                                     int numSamples, bool littleEndian) noexcept
    {
        const double maxVal = (double) 0x7fffff;

        if (littleEndian)
        {
            while (--numSamples >= 0)
            {
                ByteOrder::littleEndian24BitToChars ((uint32) roundToInt (jlimit (-maxVal, maxVal, maxVal * *src++)), dest);
                dest += dstStrideBytes;
            }
        }
        else
        {
            while (--numSamples >= 0)
            {
                ByteOrder::bigEndian24BitToChars ((uint32) roundToInt (jlimit (-maxVal, maxVal, maxVal * *src++)), dest);
                dest += dstStrideBytes;
            }
        }
    }

So funny enough they multiply by 0x7FFFFF and not 0x800000, so the conversion code for ASIO means the output will never reach 0x800000. Well that’s nice to know, and conversion on other audio drivers might have the same issue.

It’s not supposed to reach 0x800000, because in signed 24bit that’s not +1.0, but -1.0. That’s the whole dilemma of fixed point conversion: there is no +1.0, only “almost 1.0”.

So (assuming float samples range strictly from -1.0 to 1.0) you can either scale by 0x7FFFFF and end up with a range of almost -1.0 to almost +1.0 with the whole signal very slightly reduced, or scale by 0x800000 (which is just a bitshift, no rounding error) and clip at 0x7FFFFF to get a range of exactly -1.0 to almost +1.0.

1 Like

Yep that makes sense!