Revision control

++ // Copyright (C) 2016 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
++
++ #include "qsamplecache_p.h"
++
++ #include <QtMultimedia/qaudiobuffer.h>
++ #include <QtMultimedia/qaudiodecoder.h>
++ #include <QtMultimedia/private/qaudiohelpers_p.h>
++ #include <QtMultimedia/private/qdrawavaudiodecoder_p.h>
++ #include <QtMultimedia/private/qmultimediautils_p.h>
++ #include <QtConcurrent/qtconcurrentrun.h>
++ #include <QtCore/qapplicationstatic.h>
++ #include <QtCore/qbuffer.h>
++ #include <QtCore/qcoreapplication.h>
++ #include <QtCore/qdebug.h>
++ #include <QtCore/qeventloop.h>
++ #include <QtCore/qfile.h>
++ #include <QtCore/qfuturewatcher.h>
++ #include <QtCore/qloggingcategory.h>
++ #include <QtCore/qmutex.h>
++
++ #if QT_CONFIG(network)
++ # include <QtNetwork/qnetworkaccessmanager.h>
++ # include <QtNetwork/qnetworkreply.h>
++ # include <QtNetwork/qnetworkrequest.h>
++ #endif
++
++ #include "dr_wav.h"
++
++ #include <utility>
++
++ Q_STATIC_LOGGING_CATEGORY(qLcSampleCache, "qt.multimedia.samplecache")
++
++ #if !QT_CONFIG(thread)
++ # define thread_local
++ #endif
++
++ QT_BEGIN_NAMESPACE
++
++ QSample::QSample(QUrl url, QSampleCache *parent) : m_parent(parent), m_url(std::move(url)) { }
++
++ QSample::QSample(QUrl url, QSampleCache *parent, std::optional<SampleRate> targetSampleRate)
++ : m_parent(parent), m_url(std::move(url)), m_targetSampleRate(targetSampleRate)
++ {
++ }
++
++ QSample::~QSample()
++ {
++ // Remove ourselves from our parent
++ if (m_parent)
++ m_parent->removeUnreferencedSample(m_url, m_targetSampleRate);
++
++ qCDebug(qLcSampleCache) << "~QSample" << this << ": deleted [" << m_url << "]" << QThread::currentThread();
++ }
++
++ void QSample::setError()
++ {
++ m_state = State::Error;
++ }
++
++ void QSample::setData(QByteArray data, QAudioFormat format)
++ {
++ m_state = State::Ready;
++ m_soundData = std::move(data);
++ m_audioFormat = format;
++ }
++
++ QSample::State QSample::state() const
++ {
++ return m_state;
++ }
++
++ void QSample::clearParent()
++ {
++ m_parent = nullptr;
++ }
++
++ ///////////////////////////////////////////////////////////////////////////////////////////////////
++
++ Q_APPLICATION_STATIC(QSampleCache, sampleCache)
++
++ QSampleCache *QSampleCache::instance()
++ {
++ return sampleCache();
++ }
++
++ #if QT_CONFIG(thread)
++ QThreadPool *QSampleCache::threadPool()
++ {
++ #ifdef Q_OS_WASM
++ return QThreadPool::globalInstance();
++ #else
++ return &m_threadPool;
++ #endif
++ }
++ #endif
++
++ QSampleCache::QSampleCache(QObject *parent) : QObject(parent)
++ {
++ #if QT_CONFIG(thread)
++ if (!thread()->isMainThread())
++ moveToThread(qApp->thread());
++
++ # if !defined(Q_OS_WASM)
++ // we limit the number of loader threads to avoid thread explosion
++ static constexpr int loaderThreadLimit = 8;
++ m_threadPool.setObjectName("QSampleCachePool");
++ m_threadPool.setMaxThreadCount(loaderThreadLimit);
++ m_threadPool.setExpiryTimeout(15);
++ m_threadPool.setThreadPriority(QThread::LowPriority);
++ m_threadPool.setServiceLevel(QThread::QualityOfService::Eco);
++
++ qAddPostRoutine([] {
++ // HACK: we need to stop the thread pool before qApp is nulled, otherwise some threads might still try construct
++ // some Q_APPLICATION_STATIC instances, causing assertion failures inside QNetworkAccessManager
++ Q_ASSERT(qApp && "QApplication is still valid");
++
++ QSampleCache *instance = sampleCache();
++
++ instance->m_threadPool.clear();
++ instance->m_threadPool.waitForDone();
++ });
++
++ # endif // Q_OS_WASM
++ #endif // QT_CONFIG(thread)
++ }
++
++ QSampleCache::~QSampleCache()
++ {
++ #if QT_CONFIG(thread) && !defined(Q_OS_WASM)
++ m_threadPool.clear();
++ m_threadPool.waitForDone();
++ #endif
++
++ for (auto &entry : m_loadedSamples) {
++ auto samplePtr = entry.second.lock();
++ if (samplePtr)
++ samplePtr->clearParent();
++ }
++
++ for (auto &entry : m_pendingSamples) {
++ auto samplePtr = entry.second.first;
++ if (samplePtr)
++ samplePtr->clearParent();
++ }
++ }
++
++ QSampleCache::SampleLoadResult QSampleCache::loadSample(QSpan<const char> data)
++ {
++ using namespace QtPrivate;
++
++ drwav wavParser;
++ bool success = drwav_init_memory(&wavParser, data.data(), data.size(), nullptr);
++ if (!success)
++ return q23::unexpected(QSampleLoadError::FormatError);
++
++ auto cleanup = qScopeGuard([&] {
++ drwav_uninit(&wavParser);
++ });
++
++ // using float as internal format. one could argue to use int16 and save half the ram at the
++ // cost of potential run-time conversions
++ QAudioFormat audioFormat;
++ audioFormat.setChannelCount(wavParser.channels);
++ audioFormat.setSampleFormat(QAudioFormat::Float);
++ audioFormat.setSampleRate(wavParser.sampleRate);
++ audioFormat.setChannelConfig(
++ QAudioFormat::defaultChannelConfigForChannelCount(wavParser.channels));
++
++ std::optional<QByteArray> sampleData = QtMultimediaPrivate::readFramesInChunks(
++ wavParser.totalPCMFrameCount, sizeof(float) * wavParser.channels,
++ [&](uint64_t frameCount, char *out) -> uint64_t {
++ return drwav_read_pcm_frames_f32(&wavParser, frameCount, reinterpret_cast<float *>(out));
++ });
++ if (!sampleData)
++ return q23::unexpected(QSampleLoadError::FormatError);
++
++ return std::pair{
++ std::move(*sampleData),
++ audioFormat,
++ };
++ }
++
++ namespace {
++
++ QByteArray convertToFloat32(const QByteArray &data, const QAudioFormat &fmt)
++ {
++ if (fmt.sampleFormat() == QAudioFormat::Float)
++ return data;
++
++ int totalSamples = fmt.framesForBytes(data.size()) * fmt.channelCount();
++
++ QByteArray result{
++ totalSamples * int(sizeof(float)),
++ Qt::Initialization::Uninitialized,
++ };
++
++ using namespace QAudioHelperInternal;
++ convertSampleFormat(as_bytes(QSpan{ data }), toNativeSampleFormat(fmt.sampleFormat()),
++ as_writable_bytes(QSpan{ result }), NativeSampleFormat::float32_t);
++
++ return result;
++ }
++
++ QSampleCache::SampleLoadResult runDecoderLoop(QAudioDecoder &decoder, QEventLoop &loop)
++ {
++ using SampleLoadResult = QSampleCache::SampleLoadResult;
++
++ QByteArray accumulated;
++ QAudioFormat fmt;
++ SampleLoadResult result = q23::unexpected(QSampleLoadError::DecoderError);
++
++ QObject::connect(&decoder, &QAudioDecoder::bufferReady, &loop, [&] {
++ QAudioBuffer buf = decoder.read();
++ if (!buf.isValid())
++ return;
++ if (!fmt.isValid())
++ fmt = buf.format();
++ accumulated.append(buf.constData<char>(), buf.byteCount());
++ });
++
++ QObject::connect(&decoder, &QAudioDecoder::finished, &loop, [&] {
++ QByteArray floatData = convertToFloat32(accumulated, fmt);
++ QAudioFormat floatFmt = fmt;
++ floatFmt.setSampleFormat(QAudioFormat::Float);
++ result = std::pair{ std::move(floatData), floatFmt };
++ loop.quit();
++ });
++
++ QObject::connect(&decoder, qOverload<QAudioDecoder::Error>(&QAudioDecoder::error),
++ &loop, [&](QAudioDecoder::Error) {
++ result = q23::unexpected(QSampleLoadError::DecoderError);
++ loop.quit();
++ });
++
++ decoder.start();
++ if (decoder.error() != QAudioDecoder::NoError)
++ return q23::unexpected(QSampleLoadError::DecoderError);
++
++ loop.exec(QEventLoop::ExcludeUserInputEvents);
++
++ return result;
++ }
++
++ } // unnamed namespace
++
++ QSampleCache::SampleLoadResult
++ QSampleCache::loadSampleViaDecoder(std::variant<QUrl, QByteArray> arg)
++ {
++ // caveat: we run our own event loop, so this function should ideally not be run from the main thread.
++ using namespace QtMultimediaPrivate;
++
++ QAudioDecoder decoder;
++ if (!decoder.isSupported())
++ return q23::unexpected(QSampleLoadError::NotSupported);
++
++ QEventLoop loop;
++ // clang-format off
++ return std::visit(qOverloadedVisitor([&](QUrl url) -> SampleLoadResult {
++ decoder.setSource(url);
++ return runDecoderLoop(decoder, loop);
++ }, [&](QByteArray data) -> SampleLoadResult {
++ QBuffer buffer;
++ buffer.setData(data);
++ if (!buffer.open(QIODevice::ReadOnly))
++ return q23::unexpected(QSampleLoadError::IoError);
++ decoder.setSourceDevice(&buffer);
++
++ return runDecoderLoop(decoder, loop);
++ }), std::move(arg));
++ // clang-format on
++ }
++
++ #if QT_CONFIG(network)
++
++ namespace {
++
++ Q_CONSTINIT thread_local std::optional<QNetworkAccessManager> g_networkAccessManager;
++ QNetworkAccessManager &threadLocalNetworkAccessManager()
++ {
++ if (!g_networkAccessManager.has_value()) {
++ g_networkAccessManager.emplace();
++
++ if (QThread::isMainThread()) {
++ // poor man's Q_APPLICATION_STATIC
++ qAddPostRoutine([] {
++ g_networkAccessManager.reset();
++ });
++ }
++ }
++
++ return *g_networkAccessManager;
++ }
++
++ } // namespace
++
++ #endif
++
++ #if QT_CONFIG(thread)
++
++ QSampleCache::SampleLoadResult
++ QSampleCache::loadSample(const QUrl &url, std::optional<SampleSourceType> forceSourceType)
++ {
++ using namespace Qt::Literals;
++
++ SampleSourceType realSourceType =
++ forceSourceType.value_or(url.scheme() == u"qrc"_s || url.scheme() == u"file"_s
++ ? SampleSourceType::File
++ : SampleSourceType::NetworkManager);
++
++ if (realSourceType == SampleSourceType::AudioDecoder)
++ return loadSampleViaDecoder(url);
++
++ if (url.scheme().isEmpty())
++ // exit early, to avoid QNetworkAccessManager trying to construct a default ssl
++ // configuration, which tends to cause timeouts on CI on macos.
++ // catch this case and exit early.
++ return q23::unexpected(QSampleLoadError::IoError);
++
++ bool errorOccurred = false;
++
++ std::unique_ptr<QIODevice> decoderInput;
++ if (realSourceType == SampleSourceType::File) {
++ QString locationString =
++ url.isLocalFile() ? url.toLocalFile() : u":" + url.toString(QUrl::RemoveScheme);
++
++ auto *file = new QFile(locationString);
++ bool opened = file->open(QFile::ReadOnly);
++ if (!opened)
++ errorOccurred = true;
++ decoderInput.reset(file);
++ } else {
++ #if QT_CONFIG(network)
++ QNetworkReply *reply = threadLocalNetworkAccessManager().get(QNetworkRequest(url));
++
++ if (reply->error() != QNetworkReply::NoError)
++ errorOccurred = true;
++
++ connect(reply, &QNetworkReply::errorOccurred, reply,
++ [&]([[maybe_unused]] QNetworkReply::NetworkError errorCode) {
++ errorOccurred = true;
++ });
++
++ decoderInput.reset(reply);
++ #else
++ return q23::unexpected(QSampleLoadError::IoError);
++ #endif
++ }
++
++ if (!decoderInput->isOpen())
++ return q23::unexpected(QSampleLoadError::IoError);
++
++ QByteArray data = decoderInput->readAll();
++ if (data.isEmpty() || errorOccurred)
++ return q23::unexpected(QSampleLoadError::IoError);
++
++ SampleLoadResult result = loadSample(data);
++ if (!result && result.error() == QSampleLoadError::FormatError && !forceSourceType) {
++ qCDebug(qLcSampleCache) << "drwav failed, retrying via QAudioDecoder";
++ result = loadSampleViaDecoder(data);
++ if (!result) {
++ qCDebug(qLcSampleCache) << "device-based decode failed, retrying via URL";
++ result = loadSampleViaDecoder(url);
++ }
++ }
++ return result;
++ }
++
++ #endif
++
++ QFuture<QSampleCache::SampleLoadResult>
++ QSampleCache::loadSampleAsync(const QUrl &url, std::optional<SampleSourceType> forceSourceType)
++ {
++ auto promise = std::make_shared<QPromise<QSampleCache::SampleLoadResult>>();
++ auto future = promise->future();
++
++ auto fulfilPromise = [&](auto &&result) mutable {
++ promise->start();
++ promise->addResult(result);
++ promise->finish();
++ };
++
++ using namespace Qt::Literals;
++
++ SampleSourceType realSourceType = (url.scheme() == u"qrc"_s || url.scheme() == u"file"_s)
++ ? SampleSourceType::File
++ : SampleSourceType::NetworkManager;
++ if (realSourceType == SampleSourceType::File) {
++ QString locationString = url.toString(QUrl::RemoveScheme);
++ if (url.scheme() == u"qrc"_s)
++ locationString = u":" + locationString;
++ QFile file{ locationString };
++ bool opened = file.open(QFile::ReadOnly);
++ if (!opened) {
++ fulfilPromise(q23::unexpected(QSampleLoadError::IoError));
++ return future;
++ }
++
++ QByteArray data = file.readAll();
++ if (data.isEmpty()) {
++ fulfilPromise(q23::unexpected(QSampleLoadError::IoError));
++ return future;
++ }
++
++ if (forceSourceType == SampleSourceType::AudioDecoder)
++ return loadSampleAsyncViaDecoder(data);
++
++ auto drwavResult = loadSample(data);
++ if (drwavResult || drwavResult.error() != QSampleLoadError::FormatError) {
++ fulfilPromise(drwavResult);
++ return future;
++ }
++ return loadSampleAsyncViaDecoder(data);
++ }
++
++ #if QT_CONFIG(network)
++
++ QNetworkReply *reply = threadLocalNetworkAccessManager().get(QNetworkRequest(url));
++
++ if (reply->error() != QNetworkReply::NoError) {
++ fulfilPromise(q23::unexpected(QSampleLoadError::IoError));
++ reply->deleteLater();
++ return future;
++ }
++
++ connect(reply, &QNetworkReply::errorOccurred, reply,
++ [reply, promise]([[maybe_unused]] QNetworkReply::NetworkError errorCode) {
++ promise->start();
++ promise->addResult(q23::unexpected(QSampleLoadError::IoError));
++ promise->finish();
++ reply->deleteLater(); // we cannot delete immediately
++ });
++
++ connect(reply, &QNetworkReply::finished, reply,
++ [promise, reply, fulfilPromise = std::move(fulfilPromise), forceSourceType]() mutable {
++ QByteArray data = reply->readAll();
++ if (data.isEmpty()) {
++ promise->start();
++ promise->addResult(q23::unexpected(QSampleLoadError::IoError));
++ promise->finish();
++ } else if (forceSourceType == SampleSourceType::AudioDecoder) {
++ auto decoderFuture = loadSampleAsyncViaDecoder(data);
++ decoderFuture.then([promise](SampleLoadResult result) {
++ promise->start();
++ promise->addResult(std::move(result));
++ promise->finish();
++ });
++ } else {
++ auto drwavResult = loadSample(data);
++ if (drwavResult || drwavResult.error() != QSampleLoadError::FormatError) {
++ fulfilPromise(drwavResult);
++ } else {
++ auto decoderFuture = loadSampleAsyncViaDecoder(data);
++ decoderFuture.then([promise](SampleLoadResult result) {
++ promise->start();
++ promise->addResult(std::move(result));
++ promise->finish();
++ });
++ }
++ }
++ reply->deleteLater(); // we cannot delete immediately
++ });
++ #else
++ fulfilPromise(q23::unexpected(QSampleLoadError::IoError));
++ #endif
++ return future;
++ }
++
++ namespace {
++
++ // keeps the only reference to the QAudioDecoder instances alive until they finish decoding.
++ // We cannot keep the QAudioDecoder instances in the the decoder connections, as they may not
++ // fire if the application is shutting down before the decoder terminates
++ struct QStaticDecoderSingleton
++ {
++ void addDecoder(std::shared_ptr<QAudioDecoder> decoder)
++ {
++ std::lock_guard guard(mutex);
++ decoders.insert(std::move(decoder));
++ }
++
++ void removeDecoder(std::shared_ptr<QAudioDecoder> decoder)
++ {
++ std::lock_guard guard(mutex);
++ decoders.erase(decoder);
++ }
++
++ std::set<std::shared_ptr<QAudioDecoder>> decoders;
++ QBasicMutex mutex;
++ };
++
++ Q_APPLICATION_STATIC(QStaticDecoderSingleton, decoders);
++
++ } // namespace
++
++ QFuture<QSampleCache::SampleLoadResult>
++ QSampleCache::loadSampleAsyncViaDecoder(QByteArray data)
++ {
++ // NB: we heap-allocate the QAudioDecoder and keep it alive until decoding finishes or an error
++ // occurs. however we cannot keep the QAudioDecoder alive in the lambda captures of the decoder
++ // signals, as they might not fire if the application is shutting down before the decoder
++ // terminates, causing a potential leak. Instead, we keep the decoders in a singleton set until
++ // they finish decoding, and capture weak pointers to them in the lambda captures of the
++ // finished/error signals.
++
++ auto promise = std::make_shared<QPromise<SampleLoadResult>>();
++ auto future = promise->future();
++
++ auto decoder = QtMultimediaPrivate::makeSharedDeleteLater<QAudioDecoder>();
++ if (!decoder->isSupported()) {
++ promise->start();
++ promise->addResult(q23::unexpected(QSampleLoadError::NotSupported));
++ promise->finish();
++ return future;
++ }
++
++ auto *buffer = new QBuffer(decoder.get());
++ buffer->setData(data);
++ buffer->open(QIODevice::ReadOnly);
++
++ auto accum = std::make_shared<QByteArray>();
++ auto fmt = std::make_shared<QAudioFormat>();
++
++ decoders->addDecoder(decoder);
++
++ QObject::connect(decoder.get(), &QAudioDecoder::bufferReady, decoder.get(),
++ [weakDecoder = std::weak_ptr<QAudioDecoder>(decoder), accum, fmt] {
++ auto decoder = weakDecoder.lock();
++ if (!decoder)
++ return;
++
++ QAudioBuffer buf = decoder->read();
++ if (!buf.isValid())
++ return;
++ if (!fmt->isValid())
++ *fmt = buf.format();
++ accum->append(buf.constData<char>(), buf.byteCount());
++ });
++
++ QObject::connect(decoder.get(), &QAudioDecoder::finished, decoder.get(),
++ [promise, accum, fmt, weakDecoder = std::weak_ptr<QAudioDecoder>(decoder)] {
++ auto decoder = weakDecoder.lock();
++ if (!decoder)
++ return;
++
++ QByteArray floatData = convertToFloat32(*accum, *fmt);
++ QAudioFormat floatFmt = *fmt;
++ floatFmt.setSampleFormat(QAudioFormat::Float);
++ promise->start();
++ promise->addResult(std::pair{ std::move(floatData), floatFmt });
++ promise->finish();
++
++ decoders->removeDecoder(decoder);
++ });
++
++ QObject::connect(
++ decoder.get(), qOverload<QAudioDecoder::Error>(&QAudioDecoder::error), decoder.get(),
++ [promise, weakDecoder = std::weak_ptr<QAudioDecoder>(decoder)](QAudioDecoder::Error) {
++ auto decoder = weakDecoder.lock();
++ if (!decoder)
++ return;
++
++ promise->start();
++ promise->addResult(q23::unexpected(QSampleLoadError::DecoderError));
++ promise->finish();
++ decoders->removeDecoder(decoder);
++ });
++
++ decoder->setSourceDevice(buffer);
++ decoder->start();
++ return future;
++ }
++
++ bool QSampleCache::isCached(const QUrl &url, std::optional<int> targetSampleRate) const
++ {
++ std::lock_guard guard(m_mutex);
++ using namespace QtMultimediaPrivate;
++
++ const SampleKey key{ url, asSampleRate(targetSampleRate) };
++ return m_loadedSamples.find(key) != m_loadedSamples.end()
++ || m_pendingSamples.find(key) != m_pendingSamples.end();
++ }
++
++ QFuture<SharedSamplePtr>
++ QSampleCache::requestSampleFuture(const QUrl &url, std::optional<int> targetSampleRate,
++ std::optional<SampleSourceType> forceSourceType)
++ {
++ std::lock_guard guard(m_mutex);
++ using namespace QtMultimediaPrivate;
++
++ auto targetRate = asSampleRate(targetSampleRate);
++ const SampleKey key{ url, targetRate };
++
++ auto promise = std::make_shared<QPromise<SharedSamplePtr>>();
++ auto future = promise->future();
++
++ // found and ready
++ auto found = m_loadedSamples.find(key);
++ if (found != m_loadedSamples.end()) {
++ SharedSamplePtr foundSample = found->second.lock();
++ Q_ASSERT(foundSample);
++ Q_ASSERT(foundSample->state() == QSample::Ready);
++ promise->start();
++ promise->addResult(std::move(foundSample));
++ promise->finish();
++ return future;
++ }
++
++ // already in the process of being loaded
++ auto pending = m_pendingSamples.find(key);
++ if (pending != m_pendingSamples.end()) {
++ pending->second.second.append(promise);
++ return future;
++ }
++
++ // we need to start a new load process
++ SharedSamplePtr sample = std::make_shared<QSample>(url, this, targetRate);
++ m_pendingSamples.emplace(key, std::pair{ sample, QList<SharedSamplePromise>{ promise } });
++
++ QFuture<SampleLoadResult> futureResult = [&] {
++ #if QT_CONFIG(thread)
++ if (threadPool()->maxThreadCount() > 0)
++ return QtConcurrent::run(threadPool(), [url, type = forceSourceType] {
++ return loadSample(url, type);
++ });
++ #endif
++ return loadSampleAsync(url, forceSourceType);
++ }();
++
++ futureResult.then(this,
++ [this, key, targetRate,
++ sample = std::move(sample)](SampleLoadResult loadResult) mutable {
++ if (loadResult) {
++ QByteArray sampleData = loadResult->first;
++ QAudioFormat sampleFormat = loadResult->second;
++
++ if (targetRate && sampleFormat.sampleRate() != qToUnderlying(*targetRate)) {
++ const int rate = qToUnderlying(*targetRate);
++ const qsizetype totalFloats = sampleData.size() / qsizetype(sizeof(float));
++ QSpan<const float> inputSpan{
++ reinterpret_cast<const float *>(sampleData.constData()),
++ totalFloats
++ };
++ sampleData = QAudioHelperInternal::resampleAudioCatmullRom(
++ inputSpan, sampleFormat.channelCount(),
++ sampleFormat.sampleRate(), rate);
++ sampleFormat.setSampleRate(rate);
++ }
++
++ sample->setData(std::move(sampleData), sampleFormat);
++ } else {
++ sample->setError();
++ }
++
++ std::lock_guard guard(m_mutex);
++
++ auto pending = m_pendingSamples.find(key);
++ if (pending != m_pendingSamples.end()) {
++ for (auto &promise : pending->second.second) {
++ promise->start();
++ promise->addResult(loadResult ? sample : nullptr);
++ promise->finish();
++ }
++ }
++
++ if (loadResult)
++ m_loadedSamples.emplace(key, sample);
++
++ if (pending != m_pendingSamples.end())
++ m_pendingSamples.erase(pending);
++ sample = {};
++ });
++
++ return future;
++ }
++
++ void QSampleCache::removeUnreferencedSample(const QUrl &url,
++ std::optional<SampleRate> targetSampleRate)
++ {
++ std::lock_guard guard(m_mutex);
++ m_loadedSamples.erase(SampleKey{ url, targetSampleRate });
++ }
++
++ QT_END_NAMESPACE
++
++ #if !QT_CONFIG(thread)
++ # undef thread_local
++ #endif