Source code

Revision control

Copy as Markdown

// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
#include "tst_qmediaframeinputsbackend.h"
#include <QtMultimediaTestLib/private/audiogenerationutils_p.h>
#include <QtMultimediaTestLib/private/capturesessionfixture_p.h>
#include <QtMultimediaTestLib/private/mediabackendutils_p.h>
#include <QtMultimediaTestLib/private/mediainfo_p.h>
#include <QtMultimediaTestLib/private/qcolorutil_p.h>
#include <QtMultimediaTestLib/private/testvideosink_p.h>
#include <QtTest/qsignalspy.h>
#include <QtTest/qtest.h>
#include <QtMultimedia/qaudiobufferinput.h>
#include <QtMultimedia/qmediaplayer.h>
#include <QtMultimedia/qmediarecorder.h>
#include <QtMultimedia/qvideoframeinput.h>
#include <QtMultimedia/private/qplatformaudioresampler_p.h>
#include <QtMultimedia/private/qplatformmediaintegration_p.h>
#include <thread>
QT_BEGIN_NAMESPACE
namespace {
struct AudioComparisonResult
{
struct ChannelInfo
{
qreal normalizedCrossCorrelation = 0;
qreal maxDeviation = 0;
qreal avgDeviation = 0;
};
size_t actualSampleCount = 0;
size_t expectedSampleCount = 0;
size_t actualSamplesOffset = 0;
std::vector<ChannelInfo> channelsInfo;
bool check() const
{
return std::all_of(channelsInfo.begin(), channelsInfo.end(), [](const ChannelInfo &info) {
return info.normalizedCrossCorrelation > 0.96;
});
}
QString toString() const
{
QString result;
QTextStream stream(&result);
stream << "AudioComparisonResult:";
stream << "\n\tactualSampleCount: " << actualSampleCount;
stream << "\n\texpectedSampleCount: " << expectedSampleCount;
stream << "\n\tactualSamplesOffset: " << actualSamplesOffset;
int channel = 0;
for (auto &chInfo : channelsInfo) {
stream << "\n\tchannel: " << channel++;
stream << "\n\t\tnormalizedCrossCorrelation: " << chInfo.normalizedCrossCorrelation;
stream << "\n\t\tmaxDeviation: " << chInfo.maxDeviation;
stream << "\n\t\tavgDeviation: " << chInfo.avgDeviation;
}
stream.flush();
return result;
}
};
AudioComparisonResult::ChannelInfo compareChannelAudioData(const float *lhs, const float *rhs,
quint32 samplesCount, int channel,
int channelsCount)
{
AudioComparisonResult::ChannelInfo result;
qreal crossCorrelation = 0.;
qreal lhsStandardDeviation = 0.;
qreal rhsStandardDeviation = 0.;
qreal deviationsSum = 0.;
size_t i = channel;
for (quint32 sample = 0; sample < samplesCount; ++sample, i += channelsCount) {
crossCorrelation += lhs[i] * rhs[i];
lhsStandardDeviation += lhs[i] * lhs[i];
rhsStandardDeviation += rhs[i] * rhs[i];
const qreal deviation = qAbs(lhs[i] - rhs[i]);
deviationsSum += deviation;
result.maxDeviation = qMax(result.maxDeviation, deviation);
}
lhsStandardDeviation = sqrt(lhsStandardDeviation);
rhsStandardDeviation = sqrt(rhsStandardDeviation);
result.normalizedCrossCorrelation =
crossCorrelation / (lhsStandardDeviation * rhsStandardDeviation);
result.avgDeviation = deviationsSum / samplesCount;
return result;
}
AudioComparisonResult compareAudioData(QSpan<const float> actual, QSpan<const float> expected,
int channelsCount)
{
AudioComparisonResult result;
result.actualSampleCount = actual.size() / channelsCount;
result.expectedSampleCount = expected.size() / channelsCount;
// can be calculated
result.actualSamplesOffset = 0;
const quint32 samplesCount = static_cast<quint32>(qMin(
result.actualSampleCount - result.actualSamplesOffset, result.expectedSampleCount));
for (int channel = 0; channel < channelsCount; ++channel)
result.channelsInfo.push_back(
compareChannelAudioData(actual.data() + result.actualSamplesOffset * channelsCount,
expected.data(), samplesCount, channel, channelsCount));
return result;
}
QAudioBuffer convertAudioBuffer(QAudioBuffer buffer, const QAudioFormat &format)
{
if (format == buffer.format())
return buffer;
auto resampler =
QPlatformMediaIntegration::instance()->createAudioResampler(buffer.format(), format);
if (!resampler)
return {};
return resampler.value()->resample(buffer.constData<char>(), buffer.byteCount());
}
QSpan<const float> toFloatSpan(const QAudioBuffer &buffer)
{
return QSpan(buffer.constData<const float>(), buffer.byteCount() / sizeof(float));
}
AudioComparisonResult compareAudioData(QAudioBuffer actual, QAudioBuffer expected)
{
QAudioFormat format = actual.format();
format.setSampleFormat(QAudioFormat::Float);
actual = convertAudioBuffer(std::move(actual), format);
expected = convertAudioBuffer(std::move(expected), format);
return compareAudioData(toFloatSpan(actual), toFloatSpan(expected), format.channelCount());
}
} // namespace
void tst_QMediaFrameInputsBackend::initTestCase()
{
if (!initIntegrationTestCase())
return;
QSKIP_WMF("QVideoFrameInput/QAudioBufferInput not supported");
QSKIP_GSTREAMER("Not implemented in the gstreamer backend");
QSKIP_OHOS("OHOS backend does not yet wire QPlatformAudioBufferInput/QPlatformVideoFrameInput into a muxer-based recorder");
}
void tst_QMediaFrameInputsBackend::mediaRecorderWritesAudio_whenAudioFramesInputSends_data()
{
QTest::addColumn<int>("bufferCount");
QTest::addColumn<QAudioFormat::SampleFormat>("sampleFormat");
QTest::addColumn<QAudioFormat::ChannelConfig>("channelConfig");
QTest::addColumn<int>("sampleRate");
QTest::addColumn<milliseconds>("duration");
#ifndef Q_OS_WINDOWS // sample rate 8000 is not supported. TODO: investigate.
QTest::addRow("bufferCount: 20; sampleFormat: Int16; channelConfig: Mono; sampleRate: 8000; "
"duration: 1000")
<< 20 << QAudioFormat::Int16 << QAudioFormat::ChannelConfigMono << 8000 << 1000ms;
#endif
QTest::addRow("bufferCount: 30; sampleFormat: Int32; channelConfig: Stereo; sampleRate: "
"16000; duration: 2000")
<< 30 << QAudioFormat::Int32 << QAudioFormat::ChannelConfigStereo << 16000 << 2000ms;
QTest::addRow("bufferCount: 30; sampleFormat: Int16; channelConfig: Mono; sampleRate: "
"44100; duration: 2000")
<< 30 << QAudioFormat::Int16 << QAudioFormat::ChannelConfigMono << 44100 << 2000ms;
// TODO: investigate fails of channels configuration
// QTest::addRow("bufferCount: 10; sampleFormat: UInt8; channelConfig: 2Dot1; sampleRate:
// 40000; duration: 1500")
// << 10 << QAudioFormat::UInt8 << QAudioFormat::ChannelConfig2Dot1 << 40000 << 1500;
// QTest::addRow("bufferCount: 10; sampleFormat: Float; channelConfig: 3Dot0; sampleRate:
// 50000; duration: 2500")
// << 40 << QAudioFormat::Float << QAudioFormat::ChannelConfig3Dot0 << 50000 << 2500;
}
void tst_QMediaFrameInputsBackend::mediaRecorderWritesAudio_whenAudioFramesInputSends()
{
QFETCH(const int, bufferCount);
QFETCH(const QAudioFormat::SampleFormat, sampleFormat);
QFETCH(const QAudioFormat::ChannelConfig, channelConfig);
QFETCH(const int, sampleRate);
QFETCH(const milliseconds, duration);
CaptureSessionFixture f{ StreamType::Audio };
QAudioFormat format;
format.setSampleFormat(sampleFormat);
format.setSampleRate(sampleRate);
format.setChannelConfig(channelConfig);
f.m_audioGenerator.setFormat(format);
f.m_audioGenerator.setBufferCount(bufferCount);
f.m_audioGenerator.setDuration(duration);
f.start(RunMode::Pull, AutoStop::EmitEmpty);
QVERIFY(f.waitForRecorderStopped(60s));
QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
f.m_recorder.errorString().toLatin1().constData());
auto info = MediaInfo::create(f.m_recorder.actualLocation());
QVERIFY(info->m_hasAudio);
QCOMPARE_GE(info->m_duration, duration - 50ms);
QCOMPARE_LE(info->m_duration, duration + 50ms);
QVERIFY(info->m_audioBuffer.isValid());
microseconds audioDataDuration(
info->m_audioBuffer.format().durationForBytes(info->m_audioBuffer.byteCount()));
// TODO: investigate inaccuracies
QCOMPARE_GT(audioDataDuration, duration - 50ms);
QCOMPARE_LT(audioDataDuration, duration + 150ms);
QByteArray sentAudioData = createSineWaveData(format, duration);
const AudioComparisonResult comparisonResult =
compareAudioData(info->m_audioBuffer, QAudioBuffer(sentAudioData, format));
if (format.channelCount() != 1)
QSKIP("Temporary skip checking audio comparison for channels count > 1");
QVERIFY2(comparisonResult.check(), comparisonResult.toString().toLatin1().constData());
}
void tst_QMediaFrameInputsBackend::mediaRecorderWritesVideo_whenVideoFramesInputSendsFrames_data()
{
QTest::addColumn<int>("framesNumber");
QTest::addColumn<milliseconds>("frameDuration");
QTest::addColumn<QSize>("resolution");
QTest::addColumn<bool>("setTimeStamp");
QTest::addRow("framesNumber: 5; frameRate: 2; resolution: 50x80; with time stamps")
<< 5 << 500ms << QSize(50, 80) << true;
QTest::addRow("framesNumber: 20; frameRate: 1; resolution: 200x100; with time stamps")
<< 20 << 1000ms << QSize(200, 100) << true;
QTest::addRow("framesNumber: 20; frameRate: 30; resolution: 200x100; with frame rate")
<< 20 << 250ms << QSize(200, 100) << false;
QTest::addRow("framesNumber: 60; frameRate: 4; resolution: 200x100; with frame rate")
<< 60 << 24ms << QSize(200, 100) << false;
}
void tst_QMediaFrameInputsBackend::mediaRecorderWritesVideo_whenVideoFramesInputSendsFrames()
{
QFETCH(const int, framesNumber);
QFETCH(const milliseconds, frameDuration);
QFETCH(const QSize, resolution);
QFETCH(const bool, setTimeStamp);
CaptureSessionFixture f{ StreamType::Video };
f.m_videoGenerator.setFrameCount(framesNumber);
f.m_videoGenerator.setSize(resolution);
const qreal frameRate = 1e6 / duration_cast<microseconds>(frameDuration).count();
if (setTimeStamp)
f.m_videoGenerator.setPeriod(frameDuration);
else
f.m_videoGenerator.setFrameRate(frameRate);
f.start(RunMode::Pull, AutoStop::EmitEmpty);
QVERIFY(f.waitForRecorderStopped(60s));
QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError, f.m_recorder.errorString().toLatin1().constData());
auto info = MediaInfo::create(f.m_recorder.actualLocation());
QCOMPARE_LT(info->m_frameRate, frameRate * 1.001);
QCOMPARE_GT(info->m_frameRate, frameRate * 0.999);
QCOMPARE_LT(info->m_duration, frameDuration * framesNumber * 1.001);
QCOMPARE_GE(info->m_duration, frameDuration * framesNumber * 0.999);
QCOMPARE(info->m_size, resolution);
QCOMPARE_EQ(info->m_frameCount, framesNumber);
}
void tst_QMediaFrameInputsBackend::
sinkReceivesFrameWithTransformParams_whenPresentationTransformPresent_data()
{
QTest::addColumn<QtVideo::Rotation>("presentationRotation");
QTest::addColumn<bool>("presentationMirrored");
QTest::addRow("No rotation, not mirrored") << QtVideo::Rotation::None << false;
QTest::addRow("90 degrees, not mirrored") << QtVideo::Rotation::Clockwise90 << false;
QTest::addRow("180 degrees, not mirrored") << QtVideo::Rotation::Clockwise180 << false;
QTest::addRow("270 degrees, not mirrored") << QtVideo::Rotation::Clockwise270 << false;
QTest::addRow("No rotation, mirrored") << QtVideo::Rotation::None << true;
QTest::addRow("90 degrees, mirrored") << QtVideo::Rotation::Clockwise90 << true;
QTest::addRow("180 degrees, mirrored") << QtVideo::Rotation::Clockwise180 << true;
QTest::addRow("270 degrees, mirrored") << QtVideo::Rotation::Clockwise270 << true;
}
void tst_QMediaFrameInputsBackend::
sinkReceivesFrameWithTransformParams_whenPresentationTransformPresent()
{
QFETCH(const QtVideo::Rotation, presentationRotation);
QFETCH(const bool, presentationMirrored);
// Arrange
CaptureSessionFixture f{ StreamType::Video };
f.m_videoGenerator.setPattern(ImagePattern::ColoredSquares);
f.m_videoGenerator.setFrameCount(2);
f.m_videoGenerator.setPresentationRotation(presentationRotation);
f.m_videoGenerator.setPresentationMirrored(presentationMirrored);
TestVideoSink videoSink{ true /*store frames*/};
f.setVideoSink(&videoSink);
f.start(RunMode::Push, AutoStop::No);
// Act - push two frames
f.m_videoGenerator.nextFrame();
f.m_videoGenerator.nextFrame();
QCOMPARE_EQ(videoSink.m_frameList.size(), 2);
// Assert
const QVideoFrame frame = videoSink.m_frameList.back();
QCOMPARE_EQ(frame.mirrored(), presentationMirrored);
QCOMPARE_EQ(frame.rotation(), presentationRotation);
// Note: Frame data is not transformed and QVideoFrame::toImage does not apply
// transformations. Transformation parameters should be forwarded to rendering
const std::array<QColor, 4> colors = MediaInfo::sampleQuadrants(frame.toImage());
QVERIFY(fuzzyCompare(colors[0], Qt::red));
QVERIFY(fuzzyCompare(colors[1], Qt::green));
QVERIFY(fuzzyCompare(colors[2], Qt::blue));
QVERIFY(fuzzyCompare(colors[3], Qt::yellow));
}
void tst_QMediaFrameInputsBackend::mediaRecorderWritesVideo_withSingleFrame()
{
CaptureSessionFixture f{ StreamType::Video };
f.m_videoGenerator.setFrameCount(1);
f.m_videoGenerator.setSize({ 640, 480 });
f.m_videoGenerator.setPeriod(1s);
f.m_videoGenerator.setFrameRate(1.f);
f.start(RunMode::Pull, AutoStop::EmitEmpty);
QVERIFY(f.waitForRecorderStopped(60s));
QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError, f.m_recorder.errorString().toLatin1().constData());
auto info = MediaInfo::create(f.m_recorder.actualLocation());
QCOMPARE_EQ(info->m_frameCount, 1);
QCOMPARE_EQ(info->m_duration, 1s);
}
void tst_QMediaFrameInputsBackend::
mediaRecorderStops_whenEndOfStreamIsSentFromOtherThreadWhileStopping_data()
{
QTest::addColumn<int>("stopDelayMs");
// The race window is while the recorder tears down its encoders after stopping,
// and its timing depends on the machine. So we try a range of delays.
for (int delayMs : { 0, 1, 2, 4, 8, 16 })
QTest::addRow("%dms", delayMs) << delayMs;
}
// With autoStop enabled, an end-of-stream frame sent from a thread other than the
// recorder's own reaches the recorder through a queued connection. This checks that
// stopping the recorder before that queued notification is handled is safe.
void tst_QMediaFrameInputsBackend::
mediaRecorderStops_whenEndOfStreamIsSentFromOtherThreadWhileStopping()
{
QFETCH(const int, stopDelayMs);
CaptureSessionFixture f{ StreamType::Video };
f.m_videoGenerator.setFrameCount(1000);
f.m_recorder.setAutoStop(true);
f.start(RunMode::Push, AutoStop::No);
// Send frames until the recorder is encoding.
QTRY_VERIFY_WITH_TIMEOUT(
[&] {
if (!f.readyToSendVideoFrame.empty())
f.m_videoGenerator.nextFrame();
return f.m_recorder.duration() > 0;
}(),
60s);
// Wait until the frame input accepts another frame.
f.readyToSendVideoFrame.clear();
QTRY_VERIFY(!f.readyToSendVideoFrame.empty());
// Send the end-of-stream frame from another thread. We block until it is sent,
// so that the frame input is never accessed from two threads at the same time.
bool endOfStreamSent = false;
std::thread sender([&] {
endOfStreamSent = f.m_videoInput.sendVideoFrame(QVideoFrame{});
});
sender.join();
QVERIFY(endOfStreamSent);
// Stop before the queued end-of-stream notification is handled. We then block
// the event loop for a while, so the notification is handled while the recorder
// is tearing down its encoders.
f.m_recorder.stop();
std::this_thread::sleep_for(std::chrono::milliseconds{ stopDelayMs });
QVERIFY(f.waitForRecorderStopped(60s));
QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
f.m_recorder.errorString().toLatin1().constData());
}
void tst_QMediaFrameInputsBackend::
mediaRecorderStops_whenFramesSentFromOtherThread_andEndOfStreamSentFromMainThread_data()
{
QTest::addColumn<int>("senderStartDelayMs");
// Vary the delay before sending EOS to hit different points in the background sender's loop.
for (int delayMs : { 0, 1, 2, 4, 8, 16 })
QTest::addRow("%dms", delayMs) << delayMs;
}
// Checks that sending EOS from the main thread is safe while a background thread
// is actively sending frames — the mirror case of
// mediaRecorderStops_whenEndOfStreamIsSentFromOtherThreadWhileStopping.
void tst_QMediaFrameInputsBackend::
mediaRecorderStops_whenFramesSentFromOtherThread_andEndOfStreamSentFromMainThread()
{
QFETCH(const int, senderStartDelayMs);
CaptureSessionFixture f{ StreamType::Video };
f.m_recorder.setAutoStop(true);
f.start(RunMode::Push, AutoStop::No);
// Send frames from the main thread until the recorder is encoding.
QTRY_VERIFY_WITH_TIMEOUT(
[&] {
if (!f.readyToSendVideoFrame.empty())
f.m_videoGenerator.nextFrame();
return f.m_recorder.duration() > 0;
}(),
60s);
// Wait until the frame input accepts another frame.
f.readyToSendVideoFrame.clear();
QTRY_VERIFY(!f.readyToSendVideoFrame.empty());
// Start a background thread that continuously sends frames.
std::atomic<bool> keepSending{ true };
std::thread sender([&] {
while (keepSending.load(std::memory_order_relaxed))
f.m_videoInput.sendVideoFrame(f.m_videoGenerator.createFrame());
});
std::this_thread::sleep_for(std::chrono::milliseconds{ senderStartDelayMs });
// Send the end-of-stream from the main thread while the background thread
// is still sending frames.
f.m_videoInput.sendVideoFrame(QVideoFrame{});
keepSending.store(false, std::memory_order_relaxed);
sender.join();
QVERIFY(f.waitForRecorderStopped(60s));
QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError,
f.m_recorder.errorString().toLatin1().constData());
}
void tst_QMediaFrameInputsBackend::readyToSend_isEmitted_whenRecordingStarts_data()
{
QTest::addColumn<StreamType>("streamType");
QTest::addRow("audio") << StreamType::Audio;
QTest::addRow("video") << StreamType::Video;
QTest::addRow("audioAndVideo") << StreamType::AudioAndVideo;
}
void tst_QMediaFrameInputsBackend::readyToSend_isEmitted_whenRecordingStarts()
{
QFETCH(StreamType, streamType);
CaptureSessionFixture f{ streamType };
f.start(RunMode::Push, AutoStop::No);
if (f.hasAudio())
QTRY_COMPARE_EQ(f.readyToSendAudioBuffer.size(), 1);
if (f.hasVideo())
QTRY_COMPARE_EQ(f.readyToSendVideoFrame.size(), 1);
}
void tst_QMediaFrameInputsBackend::readyToSendVideoFrame_isEmitted_whenSendVideoFrameIsCalled()
{
CaptureSessionFixture f{ StreamType::Video };
f.start(RunMode::Push, AutoStop::No);
QVERIFY(f.readyToSendVideoFrame.wait());
f.m_videoInput.sendVideoFrame(f.m_videoGenerator.createFrame());
QVERIFY(f.readyToSendVideoFrame.wait());
f.m_videoInput.sendVideoFrame(f.m_videoGenerator.createFrame());
QVERIFY(f.readyToSendVideoFrame.wait());
}
void tst_QMediaFrameInputsBackend::readyToSendAudioBuffer_isEmitted_whenSendAudioBufferIsCalled()
{
CaptureSessionFixture f{ StreamType::Audio };
f.start(RunMode::Push, AutoStop::No);
QVERIFY(f.readyToSendAudioBuffer.wait());
f.m_audioInput.sendAudioBuffer(f.m_audioGenerator.createAudioBuffer());
QVERIFY(f.readyToSendAudioBuffer.wait());
f.m_audioInput.sendAudioBuffer(f.m_audioGenerator.createAudioBuffer());
QVERIFY(f.readyToSendAudioBuffer.wait());
}
void tst_QMediaFrameInputsBackend::readyToSendVideoFrame_isEmittedRepeatedly_whenPullModeIsEnabled()
{
CaptureSessionFixture f{ StreamType::Video };
constexpr int expectedSignalCount = 4;
f.m_videoGenerator.setFrameCount(expectedSignalCount - 1);
f.start(RunMode::Pull, AutoStop::EmitEmpty);
QVERIFY(f.waitForRecorderStopped(60s));
QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError, f.m_recorder.errorString().toLatin1().constData());
QCOMPARE_EQ(f.readyToSendVideoFrame.size(), expectedSignalCount);
}
void tst_QMediaFrameInputsBackend::
readyToSendAudioBuffer_isEmittedRepeatedly_whenPullModeIsEnabled()
{
CaptureSessionFixture f{ StreamType::Audio };
constexpr int expectedSignalCount = 4;
f.m_audioGenerator.setBufferCount(expectedSignalCount - 1);
f.start(RunMode::Pull, AutoStop::EmitEmpty);
QVERIFY(f.waitForRecorderStopped(60s));
QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError, f.m_recorder.errorString().toLatin1().constData());
QCOMPARE_EQ(f.readyToSendAudioBuffer.size(), expectedSignalCount);
}
void tst_QMediaFrameInputsBackend::
readyToSendAudioBufferAndVideoFrame_isEmittedRepeatedly_whenPullModeIsEnabled()
{
CaptureSessionFixture f{ StreamType::AudioAndVideo };
constexpr int expectedSignalCount = 4;
f.m_audioGenerator.setBufferCount(expectedSignalCount - 1);
f.m_videoGenerator.setFrameCount(expectedSignalCount - 1);
f.start(RunMode::Pull, AutoStop::EmitEmpty);
QVERIFY(f.waitForRecorderStopped(60s));
QVERIFY2(f.m_recorder.error() == QMediaRecorder::NoError, f.m_recorder.errorString().toLatin1().constData());
QCOMPARE_EQ(f.readyToSendAudioBuffer.size(), expectedSignalCount);
QCOMPARE_EQ(f.readyToSendVideoFrame.size(), expectedSignalCount);
}
QT_END_NAMESPACE
QTEST_MAIN(tst_QMediaFrameInputsBackend)
#include "moc_tst_qmediaframeinputsbackend.cpp"