softglow's notebook

Dispatches from the Depths of a Super Nintendo

On QAudioOutput

Most QT resources seem to want me to use higher-level classes like QMediaPlayer or QSoundEffect to point at extant files or possibly a QAudioBuffer which is already filled with a QByteArray. There’s really not much out there about synthesizing audio and playing it straight through a QAudioDevice.

The latter may operate in push mode or pull mode; the docs will tell you as much. What may be less obvious is how those modes are chosen. It’s simply your choice of overloaded method:

QIODevice QAudioOutput::start(); // push mode
void QAudioOutput::start(QIODevice *dev); // pull mode

Looking over the pre-made classes that implement QIODevice, I notice that they love to tell you when data is ready to read but not when data may be written. And in both modes, the QAudioOutput is doing the reading and your application needs to know, somehow, when it should write.

So, I implemented my own QIODevice around my own buffer type. The class actually holds a pair of buffers, one being written by the generator thread and one being read by QAudioOutput. When both are finished, the buffers are swapped. Whenever the output calls read, I format data from the app’s buffer into bytes, directly into the output pointer.

And actually, nobody calls write. The generator can be connected directly to the app buffer, which provides its own append() method that consumes (potentially many) snd_sample_ts.

There are a pair of signals and slots each side uses to communicate about their shared buffer:

class SndIO : public QIODevice {
    Q_OBJECT
    ...
public slots:
    void writeComplete(SndBuf *buf);
signals:
    void readyWrite(SndBuf *buf);
    void emptied();
    void underrun();
}
class SndThread : public QObject {
    Q_OBJECT
    ...
public slots:
    void start(SndBuf *buf);
signals:
    void finished(SndBuf *buf);
}

The main thread then connects SndIO::readyWrite to SndThread::start and SndThread::finished to SndIO::writeComplete. The SndBuf itself isn’t used simultaneously by multiple threads, because the IO won’t touch it until the Thread has signaled that it has finished filling it.

This produces a four-state system, starting with “both buffers empty” before the main thread wires the objects and initiates the first fill on the IO.

1. empty/empty
2. empty/filling
3. draining/filling
4. draining/full

When state 2 emits finished, state 3 begins; emission of emptied there returns to state 2, otherwise, finished happens again and state 4 is entered. From state 4, the only move is back to state 3 when the emptied signal is emitted. After the initialization period (once state 3 has been entered for the first time), it’s possible for state 2 to receive another read from the QAudioOutput; this emits the underrun signal without changing state.

This is a actually a slightly-more-complex version of the producer/consumer problem. Instead of a single producer and consumer directly communicating, there is an intervening QIODevice carrying data across threads, consuming the generator’s output, then marshaling and producing it for the output to consume.

I’m also effectively using signals and slots to let the event loop block the producer when it gets too far ahead. When that happens, no “produce more” signal is delivered until the output can catch up.

All that said, this setup compiles but I haven’t finished wrapping a test program around it yet.