Revision control

Copy as Markdown

// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
#include <QtMultimedia/qaudiodevice.h>
#include <QtMultimedia/qaudiosink.h>
#include <QtMultimedia/qaudiosource.h>
#include <QtMultimedia/qmediadevices.h>
#include <QtMultimedia/private/qaudioringbuffer_p.h>
#include <QtMultimedia/private/qaudiosystem_p.h>
#include <QtMultimedia/private/qautoresetevent_p.h>
#include <QtCore/qcommandlineoption.h>
#include <QtCore/qcommandlineparser.h>
#include <QtCore/qcoreapplication.h>
#include <QtCore/qdebug.h>
#include <QtCore/qdir.h>
#include <QtCore/quuid.h>
#include <QtCore/private/qexpected_p.h>
#include <ranges>
#include <variant>
#define DR_WAV_IMPLEMENTATION // header-only use
#define DRWAV_API static // emit symbols with hidden visibility
#define DRWAV_PRIVATE static
#define DR_WAV_NO_WCHAR
#include "dr_wav.h"
using namespace Qt::Literals;
QTextStream &operator<<(QTextStream &os, const QAudioFormat &fmt)
{
QString str;
QDebug dbg(&str);
dbg << fmt;
os << str;
return os;
}
namespace {
namespace CLI {
struct IOConfiguration
{
int deviceIndex;
std::optional<int> nativePeriodSize;
std::optional<int> numberOfChannels;
std::optional<int> sampleRate;
};
struct Output
{
IOConfiguration config;
};
struct Input
{
IOConfiguration config;
};
struct ListDevices
{
};
using Arguments = std::variant<ListDevices, Input, Output>;
Arguments parseArguments(const QCoreApplication &app)
{
QCommandLineParser parser;
parser.setApplicationDescription("Minimal test for audio callback io.");
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption periodSizeOption{
{ u"b"_s, u"period-size"_s },
"Set the hardware buffer size in frames (integer). Used by 'input' and 'output' commands.",
"size",
};
QCommandLineOption deviceIndexOption{
{ u"d"_s, u"device"_s },
"Specify the device index (string). Used by 'input' and 'output' commands.",
"index",
"0",
};
QCommandLineOption samplerate{
{ u"s"_s, u"samplerate"_s },
"Specify the sample rate. Used by 'input' and 'output' commands.",
"samplerate",
"-1",
};
QCommandLineOption channels{
{ u"c"_s, u"channels"_s },
"Specify the number of channels rate. Used by 'input' and 'output' commands.",
"channels",
"-1",
};
parser.addOption(periodSizeOption);
parser.addOption(deviceIndexOption);
parser.addOption(samplerate);
parser.addOption(channels);
parser.addPositionalArgument("command", "The command to execute: list-devices, input, output.");
parser.process(app);
const QStringList positionalArguments = parser.positionalArguments();
if (positionalArguments.isEmpty()) {
qCritical() << "Error: No command specified.";
parser.showHelp(1); // Show help and exit with error code 1
}
const QString command =
positionalArguments.first().toLower(); // Get the first positional arg as the command
if (command == u"list-devices"_s)
return ListDevices{};
auto parseOptionalString = [&](const QCommandLineOption &option)
-> q23::expected<std::optional<int>, std::error_code> {
if (!parser.isSet(option))
return std::nullopt;
QString str = parser.value(option);
bool ok = false;
int value = str.toInt(&ok);
if (!ok)
return q23::unexpected(std::make_error_code(std::errc::invalid_argument));
if (value == -1)
return std::nullopt;
return value;
};
auto bufferSize = parseOptionalString(periodSizeOption);
auto deviceIndex = parseOptionalString(deviceIndexOption);
auto sampleRate = parseOptionalString(samplerate);
auto channelCount = parseOptionalString(channels);
if (!bufferSize || !deviceIndex || !sampleRate || !channelCount) {
qCritical() << "Error: Invalid command line arguments.";
parser.showHelp(1);
}
if (*bufferSize && *bufferSize <= 0) {
qCritical() << "Error: Invalid buffer size '" << **bufferSize
<< "'. Must be a positive integer.";
parser.showHelp(1);
}
if (*deviceIndex && *deviceIndex < 0) {
qCritical() << "Error: Invalid device'" << **deviceIndex
<< "'. Must be a non-negative integer.";
parser.showHelp(1);
}
if (*sampleRate && *sampleRate <= 0) {
qCritical() << "Error: Invalid sample rate '" << **sampleRate
<< "'. Must be a positive integer.";
parser.showHelp(1);
}
if (*channelCount && *channelCount <= 0) {
qCritical() << "Error: Invalid channel count '" << **channelCount
<< "'. Must be a positive integer.";
parser.showHelp(1);
}
IOConfiguration conf{
.deviceIndex = *deviceIndex.value_or(0),
.nativePeriodSize = (*bufferSize).value_or(1024),
.numberOfChannels = *channelCount,
.sampleRate = *sampleRate,
};
if (command == u"input"_s)
return Input{ conf };
if (command == u"output"_s)
return Output{ conf };
qCritical() << "Error: Unknown command '" << command << "'.";
parser.showHelp(1);
Q_UNREACHABLE_RETURN(ListDevices{});
}
} // namespace CLI
void printAudioDeviceList(const QList<QAudioDevice> &devices, const QString &title = QString())
{
QTextStream out(stdout);
out << title << "\n";
if (devices.isEmpty()) {
out << "No audio devices found.\n";
out.flush(); // Ensure output is immediate
return;
}
int index = 0;
for (const QAudioDevice &device : devices) {
QString description = device.description(); // User-friendly name
out << "\t" << index++ << ": " << description << "\n";
}
out.flush(); // Ensure output is displayed immediately
}
int runCommand(CLI::ListDevices)
{
QList<QAudioDevice> inputs = QMediaDevices::audioInputs();
QList<QAudioDevice> outputs = QMediaDevices::audioOutputs();
printAudioDeviceList(inputs, u"Audio Inputs"_s);
printAudioDeviceList(outputs, u"Audio Outputs"_s);
return 0;
}
QAudioFormat makeAudioFormat(const QAudioDevice &device, const CLI::IOConfiguration &cfg)
{
QAudioFormat format = device.preferredFormat();
format.setSampleFormat(QAudioFormat::Float);
if (cfg.sampleRate)
format.setSampleRate(*cfg.sampleRate);
if (cfg.numberOfChannels) {
format.setChannelCount(*cfg.numberOfChannels);
format.setChannelConfig(
QAudioFormat::defaultChannelConfigForChannelCount(*cfg.numberOfChannels));
}
return format;
}
int runCommand(const CLI::Input &input)
{
QTextStream out(stdout);
QAudioDevice device =
QMediaDevices::audioInputs().value(input.config.deviceIndex, QAudioDevice{});
if (device.isNull()) {
qCritical() << "Error: Invalid input device index" << input.config.deviceIndex;
return 1;
};
QAudioFormat format = makeAudioFormat(device, input.config);
out << "Opening " << device.description() << " with " << format << "\n";
QAudioSource source(device, format);
source.setNativePeriodFrameCount(input.config.nativePeriodSize.value_or(-1));
using namespace QtPrivate;
drwav_data_format wavFormat{
.container = drwav_container_w64,
.format = DR_WAVE_FORMAT_IEEE_FLOAT,
.channels = static_cast<drwav_uint32>(format.channelCount()),
.sampleRate = static_cast<drwav_uint32>(format.sampleRate()),
.bitsPerSample = 32,
};
drwav wav;
QString fileName = QDir::tempPath() + u"/"_s
+ QUuid::createUuid().toString(QUuid::WithoutBraces) + u".wav"_s;
drwav_bool32 fileOpen =
drwav_init_file_write(&wav, fileName.toUtf8().constData(), &wavFormat, nullptr);
if (!fileOpen) {
qCritical() << "Error opening WAV file for writing:" << fileName;
return 1;
}
auto closeFile = qScopeGuard([&] {
if (!drwav_uninit(&wav))
qCritical() << "Failed to close WAV file:" << fileName;
});
out << "Writing audio data to file: " << fileName << "\n";
out.flush();
QAudioRingBuffer<float> ringBuffer{ 96000 * 10 }; // 10s
QAutoResetEvent bufferReadyEvent;
bufferReadyEvent.callOnActivated([&] {
ringBuffer.consumeSome([&](QSpan<float> data) {
auto frames = data.size() / format.channelCount();
drwav_write_pcm_frames(&wav, frames, data.data());
QSpan<float> take = std::views::take(data, frames);
return take;
});
});
source.start([&](QSpan<const float> output) mutable {
ringBuffer.write(output);
bufferReadyEvent.set();
});
if (source.error() != QAudio::NoError) {
qCritical() << "Error starting audio sink:" << source.error();
return 1;
}
return qApp->exec();
}
int runCommand(const CLI::Output &output)
{
QAudioDevice device =
QMediaDevices::audioOutputs().value(output.config.deviceIndex, QAudioDevice{});
if (device.isNull()) {
qCritical() << "Error: Invalid output device index" << output.config.deviceIndex;
return 1;
};
QAudioFormat format = makeAudioFormat(device, output.config);
QAudioSink sink(device, format);
sink.setNativePeriodFrameCount(output.config.nativePeriodSize.value_or(-1));
float phaseIncrement = 2 * M_PI * 220.f / format.sampleRate(); // 220 Hz tone
sink.start([&, phase = 0.f](QSpan<float> output) mutable {
int channels = format.channelCount();
while (!output.isEmpty()) {
std::ranges::fill(std::views::take(output, channels), std::sin(phase));
output = std::views::drop(output, channels);
phase += phaseIncrement;
if (phase >= 2 * float(M_PI))
phase -= 2 * float(M_PI); // Wrap phase
}
});
if (sink.error() != QAudio::NoError) {
qCritical() << "Error starting audio sink:" << sink.error();
return 1;
}
return qApp->exec();
}
} // namespace
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QCoreApplication::setApplicationName("Minimal test for audio callback io");
QCoreApplication::setApplicationVersion("1.0");
auto command = CLI::parseArguments(app);
return std::visit([](auto &cmd) {
return runCommand(cmd);
}, command);
}