Skip to content

Add support for QOA sound format - #3753

Open
Ryu204 wants to merge 3 commits into
SFML:masterfrom
Ryu204:feat/qoa-format
Open

Ryu204 wants to merge 3 commits into
SFML:masterfrom
Ryu204:feat/qoa-format

Conversation

@Ryu204

@Ryu204 Ryu204 commented Sep 18, 2026 •

Copy link
Copy Markdown

This PR adds support for QOA sound format. It was requested in #2969.

Since there is no dependency, some files containing common utilities for reader and writer were added (SoundFileQoa.hpp and SoundFileQoa.cpp).

Known limitations/remarks:

  • QOA only supports 32 bit sample count, while SFML interface support 64 bit.
  • QOA supports varying sample rate and a streaming mode which does not require sample count to be known. Both were not implemented.
  • Seeking relies on all frames having same sample rate and channel count.

Tasks

  • Tested on Linux
  • Tested on Windows

How to test this PR?

#include <SFML/Audio.hpp>
#include <chrono>
#include <filesystem>
#include <iostream>
#include <thread>

int main() {
  sf::SoundBuffer soundBuffer;
  if (!soundBuffer.loadFromFile(std::filesystem::path{"./coin.qoa"})) {
    std::cerr << "Failed to open sound file\n";
    return -1;
  }
  sf::Sound sound{soundBuffer};
  sound.play();
  std::this_thread::sleep_for(std::chrono::seconds{1});
  if (!soundBuffer.saveToFile(std::filesystem::path{"./coin2.qoa"})) {
    std::cerr << "Failed to saved new sound file\n";
    return -2;
  }
  return 0;
}

@Ryu204 Ryu204 changed the title Feat/qoa format Add support for QOA sound format Sep 18, 2026
@Ryu204
Ryu204 force-pushed the feat/qoa-format branch 2 times, most recently from 1ab7cdc to 6f00ef3 Compare September 22, 2026 15:15
};

template <typename Out, typename Num>
constexpr Out clampInt(Num value)

@Bambo-Borris Bambo-Borris Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to use std::clamp? Quick search indicates std::clamp is used elsewhere in SFML.

Ignore me, I just re-read what this is doing

@eXpl0it3r eXpl0it3r left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this! 🙂

I went through the whole diff and I think, I found a few correctness issues in the writer, mostly in the encoding itself.

Instead of fixing those one by one, I believe it would make sense to vendor qoa.h similar to how we do it with QOI or STB in extlibs/headers. The reader and writer then only need to deal with the streams, the framing and the channel mapping.

Additionally, the lists of supported formats in the sf::SoundBuffer, sf::Music and sf::InputSoundFile documentation need to be updated.

Comment on lines +194 to +199
std::vector<std::uint8_t> buffer;
if (const auto error = seekAndWriteHeader(static_cast<std::uint32_t>(samplesPerChannel), buffer))
{
err() << "Failed to write QOA file header: " << *error << std::endl;
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

write() can be called multiple times, sf::OutputSoundFile::write forwards every call as is. Here every call seeks back to 0, writes the header with only the sample count of that call, creates a fresh LMS state and writes the frames right after the header. If someone writes in chunks, only the last chunk survives.

The header should be written once in open() with a placeholder, the total sample count and the LMS state need to be members, incomplete frames need to be buffered, and the sample count gets patched when closing the file, similar to what the WAV writer does.

Comment on lines +157 to +160
for (std::size_t i = 0; i < channelCount; ++i)
m_remapTable[i] = static_cast<std::uint8_t>(
std::find(targetChannelMap.begin(), targetChannelMap.end(), channelMap[i]) - targetChannelMap.begin());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remap table is the wrong way around. This stores the QOA position for input channel i, but in writeFrame it's used as "input index for output position i". Have a look at SoundFileWriterWav.cpp, there it's std::find(channelMap.begin(), channelMap.end(), targetChannelMap[i]).

Additionally, the slices of output channel k are encoded with lms.channels[m_remapTable[k]], while writeLmsState writes the states in index order. With any channel map that isn't the identity, the decoder ends up with the wrong predictor state from the second frame on.

Comment on lines +255 to +256
const auto frameBodySizeByte = qoaFile::getFrameSizeByte(m_frameSharedData->numChannels) -
qoaFile::frameHeaderSizeByte::value;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This always uses the size of a full frame with 256 slices, while the frame header declares the actual size. For the last frame, which is usually shorter, the whole buffer is written anyways, so the file ends with a bunch of zero bytes past the declared frame size. getFrameSizeByte(numChannels, slicesPerChannel) should be used here as well.


bool isChannelCountValid(std::uint8_t channelCount)
{
return channelCount <= maxChannels;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts 0 channels, which should fail like for other formats. In the writer count % numChannels then divides by zero, and on the reader side a crafted file gets through check() and open(), and ends up at sampleOffset / m_channelCount in seek().

}

const auto sampleOffset = static_cast<std::uint32_t>(rawSampleOffset);
const auto isPastEnd = sampleOffset / m_channelCount >= m_samplesPerChannel;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the plan for streaming files, i.e. samplesPerChannel == 0? The PR description says they're not supported, but open() succeeds with a sample count of 0 and read() does decode them. Here however every offset is considered past the end, so seek(0) jumps to EOF.

I'd either reject them in open() with a clear error message or support them properly. Have you checked other how other formats do it?

#include <SFML/Audio/SoundFileReaderQoa.hpp>

#include <SFML/System/Err.hpp>
#include <SFML/System/FileInputStream.hpp>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think FileInputStream.hpp isn't used, while <algorithm> is missing for std::min and std::max.

m_channelCount = firstFrameHeader->numChannels;
m_sampleRate = firstFrameHeader->sampleRate;
m_samplesPerChannel = headerContent->samplesPerChannel;
m_currentFrameSamples.clear();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m_decodedSamplesPerChannel isn't reset here, unlike the other members.

if (remainingSamplesInFrame <= 0)
return 0;
const auto readSamplesCount = static_cast<std::uint16_t>(std::min<std::uint32_t>(remainingSamplesInFrame, maxCount));
std::memcpy(samples, m_currentFrameSamples.data() + m_currentFrameNextSampleIndex, readSamplesCount * 2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sizeof(std::int16_t) instead of the magic 2, or just std::copy_n.

std::vector<std::int16_t> m_currentFrameSamples; //!< Decoded samples of last processed frame
std::uint16_t m_currentFrameNextSampleIndex{}; //!< Index of next unread sample in the last processed frame
InputStream* m_inputStream{}; //!< The input stream received in the open method
std::size_t m_streamFirstFramePosition{};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documentation comment for m_streamFirstFramePosition.

Comment on lines +148 to +152
if (!std::is_permutation(channelMap.begin(), channelMap.end(), targetChannelMap.begin()))
{
err() << "Unsupported channel when writing QOA file" << std::endl;
return false;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the three iterator overload this reads out of bounds, if channelMap.size() doesn't match channelCount. Use the overload that takes both end iterators.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants