diff --git a/OSBindings/Qt/ClockSignal.pro b/OSBindings/Qt/ClockSignal.pro index 6ba3b6561..0081ff534 100644 --- a/OSBindings/Qt/ClockSignal.pro +++ b/OSBindings/Qt/ClockSignal.pro @@ -248,6 +248,7 @@ HEADERS += \ ../../Storage/Tape/Formats/*.hpp \ ../../Storage/Tape/Parsers/*.hpp \ \ + audiobuffer.h \ mainwindow.h \ scantargetwidget.h \ timer.h diff --git a/OSBindings/Qt/audiobuffer.h b/OSBindings/Qt/audiobuffer.h new file mode 100644 index 000000000..0f9137a0f --- /dev/null +++ b/OSBindings/Qt/audiobuffer.h @@ -0,0 +1,103 @@ +#ifndef AUDIOSOURCE_H +#define AUDIOSOURCE_H + +#include +#include + +#include + +/*! + * \brief Provides an intermediate receipticle for audio data. + * + * Provides a QIODevice that will attempt to buffer the minimum amount + * of data before handing it off to a polling QAudioOutput. + * + * Adding an extra buffer increases worst-case latency but resolves a + * startup race condition in which it is difficult to tell how much data a + * QAudioOutput that is populated by pushing data currently has buffered; + * it also works around what empirically seemed to be a minimum 16384-byte + * latency on push audio generation. + */ +struct AudioBuffer: public QIODevice { + AudioBuffer() { + open(QIODevice::ReadOnly | QIODevice::Unbuffered); + } + + void setDepth(size_t depth) { + buffer.resize(depth); + } + + // AudioBuffer-specific behaviour: always provide the latest data, + // even if that means skipping some. + qint64 readData(char *data, const qint64 maxlen) override { + if(!maxlen) { + return 0; + } + + std::lock_guard lock(mutex); + if(readPointer == writePointer) return 0; + + const size_t dataAvailable = std::min(writePointer - readPointer, size_t(maxlen)); + size_t dataToCopy = dataAvailable; + + // Push the read pointer such that only the most recent chunk is returned; + // nevertheless don't allow it to be pushed to a point where less than half + // a buffer is left, if avoidable. QAudioOutput doesn't make any guarantees + // about how much data it will read at a time so there's some second guessing here. + // + // TODO: can I be smarter than this? +// const size_t newReadPointer = std::min(writePointer - dataToCopy, writePointer - (buffer.size() >> 1)); +// readPointer = std::max(readPointer, newReadPointer); + + while(dataToCopy) { + const size_t nextLength = std::min(buffer.size() - (readPointer % buffer.size()), dataToCopy); + memcpy(data, &buffer[readPointer % buffer.size()], nextLength); + + dataToCopy -= nextLength; + data += nextLength; + readPointer += nextLength; + } + + return dataAvailable; + } + + qint64 bytesAvailable() const override { + std::lock_guard lock(mutex); + return writePointer - readPointer; + } + + // Required to make QIODevice concrete; not used. + qint64 writeData(const char *, qint64) override { + return 0; + } + + // Posts a new set of source data. This buffer permits only the amount of data + // specified by @c setDepth to be enqueued into the future. Additional writes + // after the buffer is full will overwrite the newest data. + void write(const std::vector &source) { + std::lock_guard lock(mutex); + const size_t sourceSize = source.size() * sizeof(int16_t); + size_t endPoint = std::min(writePointer + sourceSize, readPointer + buffer.size()); + + writePointer = endPoint - sourceSize; + size_t bytesToCopy = sourceSize; + auto data = reinterpret_cast(source.data()); + + while(bytesToCopy) { + size_t nextLength = std::min(buffer.size() - (writePointer % buffer.size()), bytesToCopy); + memcpy(&buffer[writePointer % buffer.size()], data, nextLength); + + bytesToCopy -= nextLength; + data += nextLength; + writePointer += nextLength; + } + } + + private: + mutable std::mutex mutex; + std::vector buffer; + mutable size_t readPointer = 0; + size_t writePointer = 0; +}; + +#endif // AUDIOSOURCE_H diff --git a/OSBindings/Qt/mainwindow.cpp b/OSBindings/Qt/mainwindow.cpp index 5ea1c035f..ae3013608 100644 --- a/OSBindings/Qt/mainwindow.cpp +++ b/OSBindings/Qt/mainwindow.cpp @@ -12,7 +12,7 @@ * it seems like Qt doesn't offer a way to constrain the aspect ratio of a view by constraining the size of the window (i.e. you can use a custom layout to constrain a view, but that won't - affect the window, so isn't useful for this project). Therefore the emulation window + affect the window, so isn't useful for this project). Therefore the emulation window resizes freely. */ MainWindow::MainWindow(QWidget *parent) @@ -149,6 +149,7 @@ void MainWindow::launchMachine() { // Install audio output if required. const auto audio_producer = machine->audio_producer(); if(audio_producer) { + static constexpr size_t samplesPerBuffer = 256; const auto speaker = audio_producer->get_speaker(); if(speaker) { const QAudioDeviceInfo &defaultDeviceInfo = QAudioDeviceInfo::defaultOutputDevice(); @@ -158,7 +159,6 @@ void MainWindow::launchMachine() { // are available, and — at least for now — assume a good buffer size. audioIsStereo = (idealFormat.channelCount() > 1) && speaker->get_is_stereo(); audioIs8bit = idealFormat.sampleSize() < 16; - const int samplesPerBuffer = 256; speaker->set_output_rate(idealFormat.sampleRate(), samplesPerBuffer, audioIsStereo); // Adjust format appropriately, and create an audio output. @@ -166,17 +166,12 @@ void MainWindow::launchMachine() { idealFormat.setSampleSize(audioIs8bit ? 8 : 16); audioOutput = std::make_unique(idealFormat, this); - // Start the output. setBufferSize is supposed to set the audio buffer size, but - // appears not to work properly on macOS; experimenting with this on that platform - // revealed that setting the buffer size to 1024, then reading it back to find out - // what I actually got results in 2048. If I use a QIODevice-based audio output, - // i.e. one that pulls data as required, it then pulls in units of... 16384 bytes. - // That's almost 1/6th of a second — epic latency. - // - // So I have no idea. TODO: find an alternative audio library, I guess. + // Start the output. The additional `audioBuffer` is meant to minimise latency, + // believe it or not, given Qt's semantics. speaker->set_delegate(this); audioOutput->setBufferSize(samplesPerBuffer * sizeof(int16_t)); - audioIODevice = audioOutput->start(); + audioOutput->start(&audioBuffer); + audioBuffer.setDepth(audioOutput->bufferSize()); } } @@ -217,13 +212,14 @@ void MainWindow::launchMachine() { } void MainWindow::speaker_did_complete_samples(Outputs::Speaker::Speaker *, const std::vector &buffer) { - const char *data = reinterpret_cast(buffer.data()); - size_t sizeLeft = buffer.size() * sizeof(int16_t); - while(sizeLeft) { - const auto bytesWritten = audioIODevice->write(data, qint64(sizeLeft)); - sizeLeft -= bytesWritten; - data += bytesWritten; - } + audioBuffer.write(buffer); +// const char *data = reinterpret_cast(buffer.data()); +// size_t sizeLeft = buffer.size() * sizeof(int16_t); +// while(sizeLeft) { +// const auto bytesWritten = audioIODevice->write(data, qint64(sizeLeft)); +// sizeLeft -= bytesWritten; +// data += bytesWritten; +// } } void MainWindow::dragEnterEvent(QDragEnterEvent* event) { diff --git a/OSBindings/Qt/mainwindow.h b/OSBindings/Qt/mainwindow.h index 5c6c48f1b..4a1eda8ac 100644 --- a/OSBindings/Qt/mainwindow.h +++ b/OSBindings/Qt/mainwindow.h @@ -5,6 +5,7 @@ #include #include +#include "audiobuffer.h" #include "timer.h" #include "ui_mainwindow.h" @@ -50,9 +51,9 @@ class MainWindow : public QMainWindow, public Outputs::Speaker::Speaker::Delegat std::mutex machineMutex; std::unique_ptr audioOutput; - QIODevice *audioIODevice = nullptr; bool audioIs8bit = false, audioIsStereo = false; void speaker_did_complete_samples(Outputs::Speaker::Speaker *speaker, const std::vector &buffer) override; + AudioBuffer audioBuffer; bool processEvent(QKeyEvent *);