Revision control
++ // Copyright (C) 2025 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 "qsoundeffectwithplayer_p.h"
++
++ #include <QtCore/q20map.h>
++ #include <QtCore/qmutex.h>
++ #include <QtCore/private/qobject_p.h>
++
++ #include <utility>
++
++ QT_BEGIN_NAMESPACE
++
++ namespace QtMultimediaPrivate {
++
++ namespace {
++
++ QSpan<const float> toFloatSpan(QSpan<const char> byteArray)
++ {
++ return QSpan{
++ reinterpret_cast<const float *>(byteArray.data()),
++ qsizetype(byteArray.size_bytes() / sizeof(float)),
++ };
++ }
++
++ struct AudioDeviceFormatLess
++ {
++ bool operator()(const std::pair<QAudioDevice, QAudioFormat> &lhs,
++ const std::pair<QAudioDevice, QAudioFormat> &rhs) const
++ {
++ auto cmp = qCompareThreeWay(lhs.first.id(), rhs.first.id());
++ if (cmp == Qt::strong_ordering::less)
++ return true;
++ if (cmp == Qt::strong_ordering::greater)
++ return false;
++
++ return std::tuple(lhs.second.sampleRate(), lhs.second.sampleFormat(),
++ lhs.second.channelCount())
++ < std::tuple(rhs.second.sampleRate(), rhs.second.sampleFormat(),
++ rhs.second.channelCount());
++ }
++ };
++
++ } // namespace
++
++ // we keep a pool of engines with one engine per device/format
++ std::shared_ptr<QRtAudioEngine>
++ QSoundEffectPrivateWithPlayer::getEngineFor(const QAudioDevice &device, const QAudioFormat &format)
++ {
++ if (device.isNull()) {
++ qWarning() << "QRtAudioEngine needs to be called with a valid device";
++ return nullptr;
++ }
++
++ if (format.sampleFormat() != QAudioFormat::Float) {
++ qWarning() << "QRtAudioEngine requires floating point samples";
++ return nullptr;
++ }
++
++ if (!device.isFormatSupported(format)) {
++ qWarning() << "QRtAudioEngine needs to be called with a supported fromat";
++ return nullptr;
++ }
++
++ static QMutex s_playerRegistryMutex;
++ static std::map<std::pair<QAudioDevice, QAudioFormat>, std::weak_ptr<QRtAudioEngine>,
++ AudioDeviceFormatLess>
++ s_playerRegistry;
++
++ auto guard = std::lock_guard{ s_playerRegistryMutex };
++
++ auto key = std::pair{ device, format };
++ auto found = s_playerRegistry.find(key);
++ if (found != s_playerRegistry.end()) {
++ auto player = found->second.lock();
++ if (player)
++ return player;
++ }
++
++ // lazy clean up
++ q20::erase_if(s_playerRegistry, [](auto &&keyValuePair) {
++ return keyValuePair.second.expired();
++ });
++
++ // SoundEffect can prevent the stream from appear in an OS mixer
++ auto endpointRole = AudioEndpointRole::SoundEffect;
++
++ auto player = std::shared_ptr<QRtAudioEngine>(
++ new QRtAudioEngine{ QRtAudioEngine::MoveToApplicationThread::Yes, device, format,
++ endpointRole },
++ &QObjectPrivate::deleteInOwnThread);
++ s_playerRegistry.emplace(key, player);
++
++ return player;
++ }
++
++ ///////////////////////////////////////////////////////////////////////////////////////////////////
++
++ QSoundEffectVoice::QSoundEffectVoice(VoiceId voiceId, std::shared_ptr<const QSample> sample,
++ float volume, bool muted, int totalLoopCount,
++ QAudioFormat engineFormat)
++ : QRtAudioEngineVoice{ voiceId },
++ m_sample{ std::move(sample) },
++ m_engineFormat{ engineFormat },
++ m_volume{ volume },
++ m_muted{ muted },
++ m_loopsRemaining{ totalLoopCount }
++ {
++ }
++
++ QSoundEffectVoice::~QSoundEffectVoice() = default;
++
++ VoicePlayResult QSoundEffectVoice::play(QSpan<float> outputBuffer) noexcept Q_DECL_NONBLOCKING_FUNCTION
++ {
++ qsizetype playedFrames = playVoice(outputBuffer);
++ m_currentFrame += playedFrames;
++
++ if (m_currentFrame == m_totalFrames) {
++ const bool isInfiniteLoop = loopsRemaining() == QSoundEffect::Infinite;
++ bool continuePlaying = isInfiniteLoop;
++
++ if (!isInfiniteLoop)
++ continuePlaying = m_loopsRemaining.fetch_sub(1, std::memory_order_relaxed) > 1;
++
++ if (continuePlaying) {
++ if (!isInfiniteLoop)
++ m_currentLoopChanged.set();
++ m_currentFrame = 0;
++ QSpan remainingOutputBuffer =
++ drop(outputBuffer, playedFrames * m_engineFormat.channelCount());
++ return play(remainingOutputBuffer);
++ }
++ return VoicePlayResult::Finished;
++ }
++ return VoicePlayResult::Playing;
++ }
++
++ qsizetype QSoundEffectVoice::playVoice(QSpan<float> outputBuffer) noexcept Q_DECL_NONBLOCKING_FUNCTION
++ {
++ const QAudioFormat &format = m_sample->format();
++ const int totalSamples = m_totalFrames * format.channelCount();
++ const int currentSample = format.channelCount() * m_currentFrame;
++
++ const QSpan fullSample = toFloatSpan(m_sample->data());
++ const QSpan playbackRange = take(drop(fullSample, currentSample), totalSamples);
++
++ Q_ASSERT(!playbackRange.empty());
++
++ const int sampleCh = format.channelCount();
++ const int engineCh = m_engineFormat.channelCount();
++ const qsizetype sampleSamples = playbackRange.size();
++ const qsizetype outputSamples = outputBuffer.size();
++ const qsizetype maxFrames = std::min(sampleSamples / sampleCh, outputSamples / engineCh);
++ const qsizetype framesToPlay = maxFrames;
++
++ enum ConversionType : uint8_t { SameChannels, MonoToStereo, StereoToMono };
++ const ConversionType conversion = [&] {
++ if (sampleCh == engineCh)
++ return SameChannels;
++ if (sampleCh == 1 && engineCh == 2)
++ return MonoToStereo;
++ if (sampleCh == 2 && engineCh == 1)
++ return StereoToMono;
++ Q_UNREACHABLE_RETURN(SameChannels);
++ }();
++
++ if (m_muted || m_volume == 0.f)
++ return framesToPlay;
++
++ // later: (auto)vectorize?
++ switch (conversion) {
++ case SameChannels:
++ for (qsizetype frame = 0; frame < framesToPlay; ++frame) {
++ const qsizetype sampleBase = frame * sampleCh;
++ const qsizetype outputBase = frame * engineCh;
++ for (int ch = 0; ch < sampleCh; ++ch) {
++ outputBuffer[outputBase + ch] += playbackRange[sampleBase + ch] * m_volume;
++ }
++ }
++ break;
++ case MonoToStereo:
++ for (qsizetype frame = 0; frame < framesToPlay; ++frame) {
++ const qsizetype sampleBase = frame * sampleCh;
++ const qsizetype outputBase = frame * engineCh;
++ const float val = playbackRange[sampleBase] * m_volume;
++ outputBuffer[outputBase] += val;
++ outputBuffer[outputBase + 1] += val;
++ }
++ break;
++ case StereoToMono:
++ float scale = 0.5f * m_volume;
++ for (qsizetype frame = 0; frame < framesToPlay; ++frame) {
++ const qsizetype sampleBase = frame * sampleCh;
++ const qsizetype outputBase = frame * engineCh;
++ const float val = (playbackRange[sampleBase] + playbackRange[sampleBase + 1]) * scale;
++ outputBuffer[outputBase] += val;
++ }
++ break;
++ }
++
++ return framesToPlay;
++ }
++
++ bool QSoundEffectVoice::isActive() noexcept Q_DECL_NONBLOCKING_FUNCTION
++ {
++ if (m_currentFrame != m_totalFrames)
++ return true;
++
++ return loopsRemaining() != 0;
++ }
++
++ std::shared_ptr<QSoundEffectVoice>
++ QSoundEffectVoice::clone(std::optional<QAudioFormat> newEngineFormat) const
++ {
++ auto clone = std::make_shared<QSoundEffectVoice>(QRtAudioEngine::allocateVoiceId(), m_sample,
++ m_volume, m_muted, loopsRemaining(),
++ newEngineFormat.value_or(m_engineFormat));
++
++ // caveat: reading frame is not atomic, so we may have a race here ... is is rare, though,
++ // not sure if we really care
++ clone->m_currentFrame = m_currentFrame;
++ return clone;
++ }
++
++ ///////////////////////////////////////////////////////////////////////////////////////////////////
++
++ QSoundEffectPrivateWithPlayer::QSoundEffectPrivateWithPlayer(QSoundEffect *q,
++ QAudioDevice audioDevice)
++ : q_ptr{ q }, m_audioDevice{ std::move(audioDevice) }
++ {
++ resolveAudioDevice();
++
++ QObject::connect(&m_mediaDevices, &QMediaDevices::audioOutputsChanged, this, [this] {
++ const QAudioDevice defaultAudioDevice = QMediaDevices::defaultAudioOutput();
++ if (defaultAudioDevice == m_defaultAudioDevice)
++ return;
++
++ m_defaultAudioDevice = defaultAudioDevice;
++ if (m_audioDevice.isNull())
++ setResolvedAudioDevice(m_defaultAudioDevice);
++ });
++
++ m_playerReleaseTimer.setTimerType(Qt::VeryCoarseTimer);
++ m_playerReleaseTimer.setSingleShot(true);
++ }
++
++ QSoundEffectPrivateWithPlayer::~QSoundEffectPrivateWithPlayer()
++ {
++ stop();
++ if (m_sampleLoadFuture)
++ m_sampleLoadFuture->cancelChain();
++ }
++
++ bool QSoundEffectPrivateWithPlayer::setAudioDevice(QAudioDevice device)
++ {
++ if (device == m_audioDevice)
++ return false;
++
++ m_audioDevice = std::move(device);
++ resolveAudioDevice();
++ return true;
++ }
++
++ void QSoundEffectPrivateWithPlayer::setResolvedAudioDevice(QAudioDevice device)
++ {
++ if (m_resolvedAudioDevice == device)
++ return;
++
++ m_resolvedAudioDevice = std::move(device);
++
++ if (m_player)
++ for (const auto &voice : m_voices)
++ m_player->stop(voice);
++
++ std::vector<std::shared_ptr<QSoundEffectVoice>> voices{
++ std::make_move_iterator(m_voices.begin()), std::make_move_iterator(m_voices.end())
++ };
++ m_voices.clear();
++
++ if (m_sample) {
++ bool hasPlayer = updatePlayer(m_sample);
++ if (!hasPlayer) {
++ setStatus(QSoundEffect::Error);
++ return;
++ }
++
++ for (const auto &voice : voices)
++ // we re-allocate a new voice ID and play on the new player
++ play(voice->clone(m_player->audioSink().format()));
++
++ setStatus(QSoundEffect::Ready);
++ } else {
++ setStatus(m_sampleLoadFuture ? QSoundEffect::Loading : QSoundEffect::Null);
++ }
++ }
++
++ void QSoundEffectPrivateWithPlayer::resolveAudioDevice()
++ {
++ if (m_audioDevice.isNull())
++ m_defaultAudioDevice = QMediaDevices::defaultAudioOutput();
++ setResolvedAudioDevice(m_audioDevice.isNull() ? m_defaultAudioDevice : m_audioDevice);
++ }
++
++ QAudioDevice QSoundEffectPrivateWithPlayer::audioDevice() const
++ {
++ return m_audioDevice;
++ }
++
++ void QSoundEffectPrivateWithPlayer::setSource(QUrl url, QSampleCache &sampleCache)
++ {
++ if (m_sampleLoadFuture) {
++ m_sampleLoadFuture->cancelChain();
++ m_sampleLoadFuture = std::nullopt;
++ }
++
++ if (m_player) {
++ QObject::disconnect(m_voiceFinishedConnection);
++ m_playerReleaseTimer.callOnTimeout(this, [player = std::move(m_player)] {
++ // we keep the player referenced for a little longer, so that later calls to
++ // getEngineFor will be able to reuse the existing instance
++ }, Qt::SingleShotConnection);
++ m_playerReleaseTimer.start();
++ }
++
++ m_sample = {};
++
++ if (url.isEmpty()) {
++ setStatus(QSoundEffect::Null);
++ return;
++ }
++
++ if (!url.isValid()) {
++ setStatus(QSoundEffect::Error);
++ return;
++ }
++
++ setStatus(QSoundEffect::Loading);
++
++ m_sampleLoadFuture =
++ sampleCache.requestSampleFuture(url).then(this, [this, url](SharedSamplePtr result) {
++ if (result) {
++ if (!formatIsSupported(result->format())) {
++ qWarning("QSoundEffect: QSoundEffect only supports mono or stereo files");
++ setStatus(QSoundEffect::Error);
++ return;
++ }
++
++ bool hasPlayer = updatePlayer(result);
++ m_sample = std::move(result);
++ if (!hasPlayer) {
++ qWarning("QSoundEffect: playback of this format is not supported on the selected "
++ "audio device");
++ setStatus(QSoundEffect::Error);
++ return;
++ }
++
++ setStatus(QSoundEffect::Ready);
++ if (std::exchange(m_playPending, false)) {
++ play();
++ }
++ } else {
++ qWarning("QSoundEffect: Error decoding source %ls", qUtf16Printable(url.toString()));
++ setStatus(QSoundEffect::Error);
++ }
++ });
++ }
++
++ void QSoundEffectPrivateWithPlayer::setStatus(QSoundEffect::Status status)
++ {
++ if (status == m_status)
++ return;
++ m_status = status;
++ Q_EMIT q_ptr->statusChanged();
++ }
++
++ QSoundEffect::Status QSoundEffectPrivateWithPlayer::status() const
++ {
++ return m_status;
++ }
++
++ int QSoundEffectPrivateWithPlayer::loopCount() const
++ {
++ return m_loopCount;
++ }
++
++ bool QSoundEffectPrivateWithPlayer::setLoopCount(int loopCount)
++ {
++ if (loopCount == 0)
++ loopCount = 1;
++
++ if (loopCount == m_loopCount)
++ return false;
++
++ m_loopCount = loopCount;
++
++ if (m_voices.empty())
++ return true;
++
++ const std::shared_ptr<QSoundEffectVoice> &voice = *m_voices.rbegin();
++ voice->m_loopsRemaining.store(loopCount, std::memory_order_relaxed);
++
++ setLoopsRemaining(loopCount);
++
++ return true;
++ }
++
++ int QSoundEffectPrivateWithPlayer::loopsRemaining() const
++ {
++ if (m_voices.empty())
++ return 0;
++
++ return m_loopsRemaining;
++ }
++
++ float QSoundEffectPrivateWithPlayer::volume() const
++ {
++ return m_volume;
++ }
++
++ bool QSoundEffectPrivateWithPlayer::setVolume(float volume)
++ {
++ if (m_volume == volume)
++ return false;
++
++ m_volume = volume;
++ for (const auto &voice : m_voices) {
++ m_player->visitVoiceRt(voice, [volume](QSoundEffectVoice &voice) {
++ voice.m_volume = volume;
++ });
++ }
++ return true;
++ }
++
++ bool QSoundEffectPrivateWithPlayer::muted() const
++ {
++ return m_muted;
++ }
++
++ bool QSoundEffectPrivateWithPlayer::setMuted(bool muted)
++ {
++ if (m_muted == muted)
++ return false;
++
++ m_muted = muted;
++ for (const auto &voice : m_voices) {
++ m_player->visitVoiceRt(voice, [muted](QSoundEffectVoice &voice) {
++ voice.m_muted = muted;
++ });
++ }
++ return true;
++ }
++
++ void QSoundEffectPrivateWithPlayer::play()
++ {
++ if (!m_sample) {
++ m_playPending = true;
++ return;
++ }
++
++ if (status() != QSoundEffect::Ready)
++ return;
++
++ Q_ASSERT(m_player);
++
++ // each `play` will start a new voice
++ auto voice = std::make_shared<QSoundEffectVoice>(QRtAudioEngine::allocateVoiceId(), m_sample,
++ m_volume, m_muted, m_loopCount,
++ m_player->audioSink().format());
++
++ play(std::move(voice));
++ }
++
++ void QSoundEffectPrivateWithPlayer::stop()
++ {
++ size_t activeVoices = m_voices.size();
++ for (const auto &voice : m_voices)
++ m_player->stop(voice->voiceId());
++ setLoopsRemaining(0);
++
++ m_voices.clear();
++ m_playPending = false;
++ if (activeVoices)
++ Q_EMIT q_ptr->playingChanged();
++ }
++
++ bool QSoundEffectPrivateWithPlayer::playing() const
++ {
++ return !m_voices.empty();
++ }
++
++ void QSoundEffectPrivateWithPlayer::play(std::shared_ptr<QSoundEffectVoice> voice)
++ {
++ QObject::connect(&voice->m_currentLoopChanged, &QAutoResetEvent::activated, this,
++ [this, voiceId = voice->voiceId()] {
++ auto foundVoice = m_voices.find(voiceId);
++ if (foundVoice == m_voices.end())
++ return;
++
++ if (voiceId != activeVoice())
++ return;
++
++ setLoopsRemaining((*foundVoice)->loopsRemaining());
++ });
++
++ m_player->play(voice);
++ m_voices.insert(std::move(voice));
++ setLoopsRemaining(m_loopCount);
++ if (m_voices.size() == 1)
++ Q_EMIT q_ptr->playingChanged();
++ }
++
++ bool QSoundEffectPrivateWithPlayer::updatePlayer(const SharedSamplePtr &sample)
++ {
++ Q_ASSERT(sample);
++ Q_ASSERT(m_voices.empty());
++ QObject::disconnect(m_voiceFinishedConnection);
++
++ m_player = {};
++ if (m_resolvedAudioDevice.isNull())
++ return false;
++
++ m_player = [&]() -> std::shared_ptr<QRtAudioEngine> {
++ auto player = getEngineFor(m_resolvedAudioDevice, sample->format());
++ if (player)
++ return player;
++
++ QAudioFormat alternativeFormat = sample->format();
++ switch (sample->format().channelCount()) {
++ case 1:
++ alternativeFormat.setChannelCount(2);
++ break;
++ case 2:
++ alternativeFormat.setChannelCount(1);
++ break;
++ default:
++ Q_UNREACHABLE_RETURN({});
++ }
++
++ return std::make_shared<QRtAudioEngine>(m_resolvedAudioDevice, alternativeFormat);
++ }();
++
++ if (!m_player)
++ return false;
++
++ m_voiceFinishedConnection = QObject::connect(m_player.get(), &QRtAudioEngine::voiceFinished,
++ this, [this](VoiceId voiceId) {
++ if (voiceId == activeVoice())
++ setLoopsRemaining(0);
++
++ auto found = m_voices.find(voiceId);
++ if (found != m_voices.end()) {
++ m_voices.erase(found);
++ if (m_voices.empty())
++ Q_EMIT q_ptr->playingChanged();
++ }
++ });
++ return true;
++ }
++
++ std::optional<VoiceId> QSoundEffectPrivateWithPlayer::activeVoice() const
++ {
++ if (m_voices.empty())
++ return std::nullopt;
++ return (*m_voices.rbegin())->voiceId();
++ }
++
++ bool QSoundEffectPrivateWithPlayer::formatIsSupported(const QAudioFormat &fmt)
++ {
++ switch (fmt.channelCount()) {
++ case 1:
++ case 2:
++ return true;
++ default:
++ return false;
++ }
++ }
++
++ void QSoundEffectPrivateWithPlayer::setLoopsRemaining(int loopsRemaining)
++ {
++ if (loopsRemaining == m_loopsRemaining)
++ return;
++ m_loopsRemaining = loopsRemaining;
++ Q_EMIT q_ptr->loopsRemainingChanged();
++ }
++
++ } // namespace QtMultimediaPrivate
++
++ QT_END_NAMESPACE