Source code

Revision control

Copy as Markdown

// Copyright (C) 2021 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
#include "audiosource.h"
#include <QAudioDevice>
#include <QAudioSource>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QLabel>
#include <QMessageBox>
#include <QPainter>
#include <QtEndian>
#if QT_CONFIG(permissions)
# include <QCoreApplication>
# include <QPermission>
#endif
namespace {
using namespace std::chrono_literals;
constexpr auto visualizerUpdateInterval = 16ms;
constexpr auto recordingUpdateInterval = 100ms;
constexpr int volumeSliderMaximum = 100;
constexpr int progressBarMaximum = 100;
float calculateLevel(const char *data, qint64 len, const QAudioFormat &format)
{
const int channelBytes = format.bytesPerSample();
const int sampleBytes = format.bytesPerFrame();
Q_ASSERT(format.bytesPerFrame() != 0); // divide by 0
const quint64 numSamples = len / sampleBytes;
float maxValue = 0;
const auto *ptr = reinterpret_cast<const unsigned char *>(data);
for (int i = 0; i < int(numSamples); ++i) {
for (int j = 0; j < format.channelCount(); ++j) {
float value = format.normalizedSampleValue(ptr);
maxValue = qMax(value, maxValue);
ptr += channelBytes;
}
}
return maxValue;
}
using namespace Qt::Literals::StringLiterals;
QString sampleFormatToString(QAudioFormat::SampleFormat f)
{
switch (f) {
case QAudioFormat::UInt8: return u"UInt8"_s;
case QAudioFormat::Int16: return u"Int16"_s;
case QAudioFormat::Int32: return u"Int32"_s;
case QAudioFormat::Float: return u"Float"_s;
default: return u"Unknown"_s;
}
}
QString audioModeToString(AudioTestMode mode)
{
switch (mode) {
case AudioTestMode::Pull: return u"Pull Mode"_s;
case AudioTestMode::Push: return u"Push Mode"_s;
case AudioTestMode::Callback: return u"Callback Mode"_s;
default: return u"Unknown"_s;
}
}
constexpr std::array allSupportedSampleRates{
8'000, 11'025, 12'000, 16'000, 22'050, 24'000, 32'000, 44'100,
48'000, 64'000, 88'200, 96'000, 128'000, 176'400, 192'000,
};
template <typename T>
void setCurrentValue(QComboBox *box, const T &value)
{
int idx = box->findData(QVariant::fromValue(value));
if (idx >= 0)
box->setCurrentIndex(idx);
}
void syncFormatGui(QComboBox *m_formatBox, QComboBox *m_channelsBox, QComboBox *m_rateBox,
const QAudioFormat &format)
{
setCurrentValue(m_formatBox, format.sampleFormat());
setCurrentValue(m_rateBox, format.sampleRate());
setCurrentValue(m_channelsBox, format.channelCount());
}
} // namespace
AudioInfo::AudioInfo(const QAudioFormat &format) : m_format(format) { }
void AudioInfo::start()
{
open(QIODevice::WriteOnly);
}
void AudioInfo::stop()
{
close();
}
qint64 AudioInfo::readData(char * /* data */, qint64 /* maxlen */)
{
return 0;
}
qint64 AudioInfo::writeData(const char *data, qint64 len)
{
m_level = calculateLevel(data, len, m_format);
emit levelChanged(m_level);
return len;
}
RenderArea::RenderArea(QWidget *parent) : QWidget(parent)
{
setBackgroundRole(QPalette::Base);
setAutoFillBackground(true);
setMinimumHeight(30);
setMinimumWidth(200);
}
void RenderArea::paintEvent(QPaintEvent * /* event */)
{
QPainter painter(this);
painter.setPen(Qt::black);
const QRect frame = painter.viewport() - QMargins(10, 10, 10, 10);
painter.drawRect(frame);
if (m_level == 0.0)
return;
float remappedLevel = QtAudio::convertVolume(m_level, QtAudio::LinearVolumeScale,
QtAudio::LogarithmicVolumeScale);
const int pos = qRound(qreal(frame.width() - 1) * remappedLevel);
painter.fillRect(frame.left() + 1, frame.top() + 1, pos, frame.height() - 1, Qt::red);
}
void RenderArea::setLevel(float value)
{
m_level = value;
update();
}
InputTest::InputTest()
: m_devices(new QMediaDevices(this))
{
init();
}
InputTest::~InputTest()
{
cleanupAudioSource();
}
void InputTest::initializeWindow()
{
m_layout = new QVBoxLayout(this);
m_canvas = new RenderArea(this);
m_layout->addWidget(m_canvas);
m_deviceBox = new QComboBox(this);
QAudioDevice defaultDevice = QMediaDevices::defaultAudioInput();
for (auto &deviceInfo : QMediaDevices::audioInputs())
m_deviceBox->addItem(deviceInfo.description(), QVariant::fromValue(deviceInfo));
auto defaultDeviceIndex = m_deviceBox->findData(QVariant::fromValue(defaultDevice));
m_deviceBox->setCurrentIndex(defaultDeviceIndex);
connect(m_deviceBox, &QComboBox::currentIndexChanged, this, &InputTest::deviceChanged);
connect(m_devices, &QMediaDevices::audioInputsChanged, this, &InputTest::updateAudioDevices);
m_layout->addWidget(m_deviceBox);
m_modeBox = new QComboBox(this);
m_modeBox->addItem(audioModeToString(AudioTestMode::Pull));
m_modeBox->addItem(audioModeToString(AudioTestMode::Push));
m_modeBox->addItem(audioModeToString(AudioTestMode::Callback));
m_modeBox->setCurrentIndex(qToUnderlying(m_mode));
connect(m_modeBox, &QComboBox::currentIndexChanged, this, [this](int index) {
m_mode = AudioTestMode(index);
restartAudioStream(false);
});
m_layout->addWidget(m_modeBox);
m_suspendResumeButton = new QPushButton(this);
connect(m_suspendResumeButton, &QPushButton::clicked, this, &InputTest::toggleSuspend);
m_layout->addWidget(m_suspendResumeButton);
m_volumeSlider = new QSlider(Qt::Horizontal, this);
m_volumeSlider->setRange(0, volumeSliderMaximum);
m_volumeSlider->setValue(volumeSliderMaximum);
connect(m_volumeSlider, &QSlider::valueChanged, this, &InputTest::sliderChanged);
m_layout->addWidget(m_volumeSlider);
auto *formatBox = new QHBoxLayout;
// Sample format selector
auto *formatLabel = new QLabel(tr("Sample Format:"));
m_formatBox = new QComboBox(this);
// Sample rate selector
auto *rateLabel = new QLabel(tr("Sample Rate:"));
m_rateBox = new QComboBox(this);
// Channel count selector
auto *chLabel = new QLabel(tr("Channels:"));
m_channelsBox = new QComboBox(this);
for (auto *box : { m_channelsBox, m_rateBox, m_formatBox })
connect(box, &QComboBox::activated, this, [this, box]() {
formatChanged(box);
});
// add all to the same row
const int horizontalSpacing = 12;
formatBox->addWidget(formatLabel);
formatBox->addWidget(m_formatBox);
formatBox->addSpacing(horizontalSpacing);
formatBox->addWidget(rateLabel);
formatBox->addWidget(m_rateBox);
formatBox->addSpacing(horizontalSpacing);
formatBox->addWidget(chLabel);
formatBox->addWidget(m_channelsBox);
m_layout->addLayout(formatBox);
// Native period frame count selector
QHBoxLayout *periodLayout = new QHBoxLayout;
auto *periodLabel = new QLabel(tr("Period Size:"));
m_periodBox = new QComboBox(this);
m_periodBox->addItem(tr("Default"), -1);
for (int v = 32; v <= 4096; v *= 2)
m_periodBox->addItem(QString::number(v), v);
connect(m_periodBox, &QComboBox::activated, this, [this]() {
restartAudioStream(false);
});
periodLayout->addWidget(periodLabel);
periodLayout->addWidget(m_periodBox);
m_layout->addLayout(periodLayout);
m_recordButton = new QPushButton(tr("Start 5s Recording"), this);
connect(m_recordButton, &QPushButton::clicked, this, [this]() {
if (m_audioSource && !m_currentDevice.isNull())
restartAudioStream(true);
});
m_layout->addWidget(m_recordButton);
m_recordProgressBar = new QProgressBar(this);
m_recordProgressBar->hide();
m_recordProgressBar->setRange(0, progressBarMaximum);
m_recordProgressBar->setFormat(tr("Recording... %p%"));
m_layout->addWidget(m_recordProgressBar);
}
void InputTest::startAudioSource(const QAudioDevice &device, const QAudioFormat &format)
{
if (m_audioSource)
cleanupAudioSource();
m_audioSource = std::make_unique<QAudioSource>(device, format);
m_audioSource->setNativePeriodFrameCount(m_periodBox->currentData().toInt());
m_currentDevice = device;
syncFormatGui(m_formatBox, m_channelsBox, m_rateBox, m_audioSource->format());
connect(m_audioSource.get(), &QAudioSource::stateChanged, this,
[this, device](QAudio::State state) {
switch (state) {
case QAudio::ActiveState:
m_suspendResumeButton->setText(tr("Suspend capture"));
return;
case QAudio::SuspendedState:
m_suspendResumeButton->setText(tr("Resume capture"));
return;
default:
break;
}
const auto err = m_audioSource->error();
// startup failure (format rejected or device unavailable)
if (err == QAudio::OpenError && state == QAudio::StoppedState) {
QMessageBox::warning(this, tr("Audio start failed"),
tr("Device rejected the format or is unavailable."));
return;
}
// runtime I/O or fatal device error (disconnects, etc.)
if (err == QAudio::IOError || err == QAudio::FatalError) {
if (m_currentDevice == device) {
m_currentDevice = {};
m_deviceBox->setCurrentIndex(-1);
}
QMessageBox::warning(this, tr("Audio error"), tr("Audio device error."));
return;
}
});
m_audioInfo = std::make_unique<AudioInfo>(format);
connect(m_audioInfo.get(), &AudioInfo::levelChanged, m_canvas, &RenderArea::setLevel);
float initialVolume =
QAudio::convertVolume(float(m_audioSource->volume()), QAudio::LinearVolumeScale,
QAudio::LogarithmicVolumeScale);
m_volumeSlider->setValue(qRound(initialVolume * volumeSliderMaximum));
m_audioInfo->start();
restartAudioStream(false);
}
void InputTest::cleanupAudioSource()
{
if (m_audioInfo)
m_audioInfo->stop();
if (m_audioSource) {
m_audioSource->stop();
m_audioSource->disconnect(this);
}
m_audioSource.reset();
m_currentDevice = {};
}
void InputTest::initializeErrorWindow()
{
auto *layout = new QVBoxLayout(this);
auto *errorLabel = new QLabel(tr("Microphone permission is not granted!"));
errorLabel->setWordWrap(true);
errorLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(errorLabel);
}
void InputTest::restartAudioStream(bool record)
{
m_audioSource->stop();
m_audioSource->setNativePeriodFrameCount(m_periodBox->currentData().toInt());
setRecorder(nullptr); // Stops possible recording
if (m_callbackVisualizerTimer.isActive())
m_callbackVisualizerTimer.stop();
switch (m_mode) {
case AudioTestMode::Pull: {
startPullMode(record);
break;
}
case AudioTestMode::Push: {
startPushMode(record);
break;
}
case AudioTestMode::Callback: {
startCallbackMode(record);
break;
}
default:
Q_UNREACHABLE();
}
if (m_audioSource->error() != QAudio::NoError) {
QMessageBox::warning(this, tr("Audio start failed"),
tr("Device rejected the format or is unavailable."));
}
}
void InputTest::timerEvent(QTimerEvent *event)
{
if (event->timerId() == m_callbackVisualizerTimer.timerId())
m_canvas->setLevel(m_level.exchange(0.f));
else if (event->timerId() == m_recordingProgressTimer.timerId()) {
Q_ASSERT(m_recorder);
if (m_recorder->isDone())
restartAudioStream(false);
else
updateRecordingProgress();
}
}
template <typename T>
void InputTest::processCallback(QSpan<const T> buffer, const QAudioFormat &format) noexcept
Q_DECL_NONBLOCKING_FUNCTION
{
auto *callbackRecorder = static_cast<CallbackRecorder *>(m_recorder.get());
if (callbackRecorder)
callbackRecorder->writeSpan(as_bytes(buffer));
float level = calculateLevel(reinterpret_cast<const char *>(buffer.data()), buffer.size_bytes(),
format);
float lastLevel = m_level.load(std::memory_order_relaxed);
while (!m_level.compare_exchange_weak(lastLevel, std::max(level, lastLevel)))
;
}
void InputTest::startPullMode(bool record)
{
// pull mode: QAudioSource provides a QIODevice to pull from
auto *readDevice = m_audioSource->start();
if (!readDevice)
return;
if (record)
setRecorder(std::make_unique<PullRecorder>(m_audioSource->format(),
getRecordingFileName()));
connect(readDevice, &QIODevice::readyRead, this, [this, readDevice]() {
const qint64 maxLength = m_audioSource->bytesAvailable();
QByteArray buffer(maxLength, 0);
const qint64 bytesRead = readDevice->read(buffer.data(), maxLength);
if (bytesRead <= 0)
return;
const float level = calculateLevel(buffer.constData(), bytesRead, m_audioSource->format());
m_canvas->setLevel(level);
auto *pullRecorder = static_cast<PullRecorder *>(m_recorder.get());
if (!pullRecorder)
return;
pullRecorder->writeSpan(
{ reinterpret_cast<const std::byte *>(buffer.constData()), (qsizetype)bytesRead });
});
}
void InputTest::startPushMode(bool record)
{
// push mode: QAudioSource pushes data into QIODevice
if (record) {
auto pushRecorder = std::make_unique<PushRecorder>(m_audioSource->format(), getRecordingFileName());
pushRecorder->open(QIODeviceBase::WriteOnly);
m_audioSource->start(pushRecorder.get());
setRecorder(std::move(pushRecorder));
} else {
m_audioSource->start(m_audioInfo.get());
}
}
void InputTest::startCallbackMode(bool record)
{
// callback mode: QAudioSource calls a callback function on audio thread with a buffer to read from
if (record)
setRecorder(std::make_unique<CallbackRecorder>(m_audioSource->format(),
getRecordingFileName()));
QAudioFormat format = m_audioSource->format();
switch (format.sampleFormat()) {
case QAudioFormat::UInt8:
m_audioSource->start([this, format](QSpan<const uint8_t> buffer) {
processCallback(buffer, format);
});
break;
case QAudioFormat::Int16:
m_audioSource->start([this, format](QSpan<const int16_t> buffer) {
processCallback(buffer, format);
});
break;
case QAudioFormat::Int32:
m_audioSource->start([this, format](QSpan<const int32_t> buffer) {
processCallback(buffer, format);
});
break;
case QAudioFormat::Float:
m_audioSource->start([this, format](QSpan<const float> buffer) {
processCallback(buffer, format);
});
break;
default:
Q_UNREACHABLE();
};
m_callbackVisualizerTimer.start(visualizerUpdateInterval, Qt::PreciseTimer, this);
}
QString InputTest::getRecordingFileName() const
{
QDir tempDir(QDir::tempPath());
return tempDir.filePath(
QStringLiteral("%1-%2-%3.wav")
.arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_hh-mm-ss"))
.arg(audioModeToString(m_mode))
.arg(m_deviceBox->currentText().split(u" "_s).at(0))
.replace(u" "_s, u"_"_s));
}
void InputTest::init()
{
#if QT_CONFIG(permissions)
QMicrophonePermission microphonePermission;
switch (qApp->checkPermission(microphonePermission)) {
case Qt::PermissionStatus::Undetermined:
qApp->requestPermission(microphonePermission, this, &InputTest::init);
return;
case Qt::PermissionStatus::Denied:
qWarning("Microphone permission is not granted!");
initializeErrorWindow();
return;
case Qt::PermissionStatus::Granted:
break;
}
#endif
initializeWindow();
deviceChanged(m_deviceBox->currentIndex());
}
void InputTest::toggleSuspend()
{
// toggle suspend/resume
switch (m_audioSource->state()) {
case QAudio::SuspendedState:
m_audioSource->resume();
break;
case QAudio::ActiveState:
m_audioSource->suspend();
break;
default:
// no-op
break;
}
}
void InputTest::deviceChanged(int index)
{
auto device = m_deviceBox->itemData(index).value<QAudioDevice>();
// clear format selectors
m_formatBox->clear();
m_channelsBox->clear();
m_rateBox->clear();
// Populate format selectors
if (!device.isNull()) {
// sample formats
const auto formats = device.supportedSampleFormats();
for (const QAudioFormat::SampleFormat sf : formats)
m_formatBox->addItem(sampleFormatToString(sf), QVariant::fromValue(sf));
// channels
for (int ch = device.minimumChannelCount(); ch <= device.maximumChannelCount(); ++ch)
m_channelsBox->addItem(QString::number(ch), ch);
// populate from the hardcoded list
for (int rate : allSupportedSampleRates) {
if (rate < device.minimumSampleRate() || rate > device.maximumSampleRate())
continue;
m_rateBox->addItem(QString::number(rate), rate);
}
}
if (device != m_currentDevice) {
cleanupAudioSource();
if (!device.isNull()) {
startAudioSource(m_deviceBox->itemData(index).value<QAudioDevice>(),
device.preferredFormat());
}
}
}
void InputTest::sliderChanged(int value)
{
float linearVolume =
QAudio::convertVolume(float(value) / float(volumeSliderMaximum),
QAudio::LogarithmicVolumeScale, QAudio::LinearVolumeScale);
m_audioSource->setVolume(linearVolume);
}
void InputTest::updateAudioDevices()
{
QSignalBlocker blockUpdates(m_deviceBox);
m_deviceBox->clear();
const QList<QAudioDevice> devices = QMediaDevices::audioInputs();
for (const QAudioDevice &deviceInfo : devices)
m_deviceBox->addItem(deviceInfo.description(), QVariant::fromValue(deviceInfo));
const int currentDeviceIndex = m_deviceBox->findData(QVariant::fromValue(m_currentDevice));
if (currentDeviceIndex != -1) {
// select previous device
m_deviceBox->setCurrentIndex(currentDeviceIndex);
} else {
blockUpdates.unblock();
// select default device
QAudioDevice defaultDevice = QMediaDevices::defaultAudioInput();
const int defaultDeviceIndex = m_deviceBox->findData(QVariant::fromValue(defaultDevice));
const int currentIndex = m_deviceBox->currentIndex();
m_deviceBox->setCurrentIndex(defaultDeviceIndex);
if (defaultDeviceIndex == currentIndex) {
// device changed, reinitialize audio
deviceChanged(defaultDeviceIndex);
}
}
}
void InputTest::formatChanged(QComboBox *box)
{
auto device = m_deviceBox->currentData().value<QAudioDevice>();
QAudioFormat newFormat = m_audioSource->format();
if (box == m_formatBox)
newFormat.setSampleFormat(QAudioFormat::SampleFormat(box->currentData().toInt()));
else if (box == m_rateBox)
newFormat.setSampleRate(box->currentData().toInt());
else if (box == m_channelsBox)
newFormat.setChannelCount(box->currentData().toInt());
startAudioSource(device, newFormat);
}
void InputTest::updateRecordingProgress()
{
if (!m_recorder) {
// Recording completed
m_recordingProgressTimer.stop();
} else {
m_recordProgressBar->setValue(m_recorder->progress(progressBarMaximum));
if (!m_recordingProgressTimer.isActive()) {
// Recording started
m_recordingProgressTimer.start(recordingUpdateInterval, Qt::CoarseTimer, this);
}
}
}
void InputTest::updateControlsForRecording()
{
bool isRecording = !!m_recorder;
if (isRecording) {
m_recordButton->hide();
m_recordProgressBar->show();
} else {
m_recordProgressBar->hide();
m_recordButton->show();
}
m_channelsBox->setDisabled(isRecording);
m_deviceBox->setDisabled(isRecording);
m_formatBox->setDisabled(isRecording);
m_modeBox->setDisabled(isRecording);
m_rateBox->setDisabled(isRecording);
}
void InputTest::setRecorder(std::unique_ptr<AudioRecorder> recorder)
{
m_recorder = std::move(recorder);
updateRecordingProgress();
updateControlsForRecording();
}
#include "moc_audiosource.cpp"