diff --git a/CMakeLists.txt b/CMakeLists.txt index 89b5fbaf84f..b93b20da007 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1149,6 +1149,7 @@ add_library( src/engine/sidechain/enginesidechain.cpp src/engine/sidechain/networkinputstreamworker.cpp src/engine/sidechain/networkoutputstreamworker.cpp + src/engine/sync/abletonlink.cpp src/engine/sync/enginesync.cpp src/engine/sync/internalclock.cpp src/engine/sync/synccontrol.cpp @@ -1679,6 +1680,7 @@ set( src/util/font.h src/util/fpclassify.h src/util/gitinfostore.h + src/util/hosttimefilter.h src/util/imagefiledata.h src/util/imageutils.h src/util/indexrange.h @@ -1915,6 +1917,7 @@ if(MSVC) target_compile_definitions( mixxx-lib PUBLIC + WIN32_LEAN_AND_MEAN _SILENCE_CXX17_ITERATOR_BASE_CLASS_DEPRECATION_WARNING _CRT_SECURE_NO_WARNINGS ) @@ -2008,6 +2011,9 @@ if(WIN32) # _WIN32_WINNT_WIN7 = 0x0601 target_compile_definitions(mixxx-lib PUBLIC WINVER=0x0601) target_compile_definitions(mixxx-lib PUBLIC _WIN32_WINNT=0x0601) + # Exclude Win32 APIs such as Cryptography, DDE, RPC, Shell, and Windows Sockets where including Windows.h. + # This prevents "Winsock.h already included", when compiling the ASIO dependency of Ableton Link. + target_compile_definitions(mixxx-lib PUBLIC WIN32_LEAN_AND_MEAN) if(MSVC) target_compile_definitions(mixxx-lib PUBLIC _USE_MATH_DEFINES) endif() @@ -2437,6 +2443,7 @@ add_executable( src/test/fileinfo_test.cpp src/test/frametest.cpp src/test/globaltrackcache_test.cpp + src/test/hosttimefilter_test.cpp src/test/hotcuecontrol_test.cpp src/test/imageutils_test.cpp src/test/indexrange_test.cpp @@ -2649,6 +2656,38 @@ if(WIN32) target_include_directories(mixxx PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") endif() +# Ableton Link +find_package( + AbletonLink + NAMES AbletonLink ableton ableton-link ableton-link-dev +) +# Note Debian Bug#993999 - Wrong CMake include path: https://salsa.debian.org/multimedia-team/ableton-link/-/commit/047f75abeeb6494256cb8d498995c17afd2a17e8 + +if(AbletonLink_FOUND) + message(STATUS "Using existing system installation of Ableton Link") + target_link_libraries(mixxx-lib PUBLIC Ableton::Link) +else() + message(STATUS "Fetch Ableton Link from GitHub") + include(FetchContent) + + set(FETCHCONTENT_QUIET FALSE) + + FetchContent_Declare( + AbletonLink + GIT_REPOSITORY https://github.com/Ableton/link.git + GIT_TAG Link-3.0.6 + ) + FetchContent_MakeAvailable(AbletonLink) + + include(${abletonlink_SOURCE_DIR}/AbletonLinkConfig.cmake) + target_link_libraries(mixxx-lib PUBLIC Ableton::Link) + target_include_directories( + mixxx-lib + SYSTEM + PUBLIC ${abletonlink_SOURCE_DIR}/include/ + ) +endif() + # Chromaprint find_package(Chromaprint REQUIRED) target_link_libraries(mixxx-lib PRIVATE Chromaprint::Chromaprint) diff --git a/res/skins/LateNight/style_palemoon.qss b/res/skins/LateNight/style_palemoon.qss index aa9e5bcd129..04b35592224 100644 --- a/res/skins/LateNight/style_palemoon.qss +++ b/res/skins/LateNight/style_palemoon.qss @@ -411,6 +411,9 @@ WTrackProperty[selected="true"], padding: 0px 2px 0px 2px; } #BatteryBox, + #AbletonLinkPeers{ + color: #d2d2d2; + } #ClockWidget { margin-bottom: 1px; } diff --git a/res/skins/LateNight/toolbar.xml b/res/skins/LateNight/toolbar.xml index 65436230dbd..518d416716b 100644 --- a/res/skins/LateNight/toolbar.xml +++ b/res/skins/LateNight/toolbar.xml @@ -137,6 +137,34 @@ + + AbletonLinkWidget + horizontal + min,min + + + + num_peers + AbletonLinkPeers + 25f,0min + 0 + center + + [AbletonLink],num_peers + + + + + + 100f,1min + ClockWidget horizontal diff --git a/src/controllers/controlpickermenu.cpp b/src/controllers/controlpickermenu.cpp index 746d7f1ed23..1c70e5ee34f 100644 --- a/src/controllers/controlpickermenu.cpp +++ b/src/controllers/controlpickermenu.cpp @@ -384,6 +384,18 @@ ControlPickerMenu::ControlPickerMenu(QWidget* pParent) tr("Decrease internal Leader BPM by 0.1"), pSyncMenu); pSyncMenu->addSeparator(); + addControl("[AbletonLink]", + "sync_enabled", + tr("Link button"), + tr("Joins or disconnect from Ableton Link session"), + pSyncMenu); + addControl("[AbletonLink]", + "num_peers", + tr("Ableton Link number of peers"), + tr("Number of connected Ableton Link peers"), + pSyncMenu); + + pSyncMenu->addSeparator(); addDeckAndSamplerControl("sync_leader", tr("Sync Leader"), tr("Sync mode 3-state toggle / indicator (Off, Soft Leader, " diff --git a/src/engine/enginebuffer.cpp b/src/engine/enginebuffer.cpp index a01979966be..9bdb27317c4 100644 --- a/src/engine/enginebuffer.cpp +++ b/src/engine/enginebuffer.cpp @@ -1606,6 +1606,10 @@ mixxx::audio::FramePos EngineBuffer::getTrackEndPosition() const { m_pTrackSamples->get()); } +double EngineBuffer::getTrackSampleRate() const { + return m_pTrackSampleRate->get(); +} + void EngineBuffer::setTrackEndPosition(mixxx::audio::FramePos position) { m_pTrackSamples->set(position.toEngineSamplePosMaybeInvalid()); } diff --git a/src/engine/enginebuffer.h b/src/engine/enginebuffer.h index f844235565c..306459a6d51 100644 --- a/src/engine/enginebuffer.h +++ b/src/engine/enginebuffer.h @@ -152,6 +152,7 @@ class EngineBuffer : public EngineObject { mixxx::audio::FramePos getExactPlayPos() const; double getVisualPlayPos() const; mixxx::audio::FramePos getTrackEndPosition() const; + double getTrackSampleRate() const; void setTrackEndPosition(mixxx::audio::FramePos position); double getUserOffset() const; diff --git a/src/engine/enginemixer.cpp b/src/engine/enginemixer.cpp index 737ed068b4d..bc545704958 100644 --- a/src/engine/enginemixer.cpp +++ b/src/engine/enginemixer.cpp @@ -229,9 +229,10 @@ std::span EngineMixer::getSidechainBuffer() const { return m_sidechainMix.span(); } -void EngineMixer::processChannels(std::size_t bufferSize) { +void EngineMixer::processChannels(std::size_t bufferSize, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachsDac) { // Update internal sync lock rate. - m_pEngineSync->onCallbackStart(m_sampleRate, bufferSize); + m_pEngineSync->onCallbackStart(m_sampleRate, bufferSize, absTimeWhenPrevOutputBufferReachsDac); m_activeBusChannels[EngineChannel::LEFT].clear(); m_activeBusChannels[EngineChannel::CENTER].clear(); @@ -348,7 +349,7 @@ void EngineMixer::processChannels(std::size_t bufferSize) { // After local bpms are updated, trigger the rest of the post-processing // which ensures that all channels are updating certain values at the - // same point in time. This prevents sync from failing depending on + // same point in time. This prevents sync from failing depending on // if the sync target was processed before or after the sync origin. std::for_each(m_activeChannels.cbegin() + activeChannelsStartIndex, m_activeChannels.cend(), @@ -357,9 +358,9 @@ void EngineMixer::processChannels(std::size_t bufferSize) { }); } -void EngineMixer::process(const std::size_t bufferSize) { +void EngineMixer::process(const std::size_t bufferSize, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachsDac) { DEBUG_ASSERT(bufferSize <= static_cast(kMaxEngineSamples)); - static bool haveSetName = false; if (!haveSetName) { QThread::currentThread()->setObjectName("Engine"); @@ -381,7 +382,7 @@ void EngineMixer::process(const std::size_t bufferSize) { } // Prepare all channels for output - processChannels(bufferSize); + processChannels(bufferSize, absTimeWhenPrevOutputBufferReachsDac); // Compute headphone mix // Head phone left/right mix diff --git a/src/engine/enginemixer.h b/src/engine/enginemixer.h index fc81ec58ddb..ea8261954c1 100644 --- a/src/engine/enginemixer.h +++ b/src/engine/enginemixer.h @@ -68,7 +68,8 @@ class EngineMixer : public QObject, public AudioSource { void onInputConnected(const AudioInput& input); void onInputDisconnected(const AudioInput& input); - void process(const std::size_t bufferSize); + void process(const std::size_t bufferSize, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachesDac); // Add an EngineChannel to the mixing engine. This is not thread safe -- // only call it before the engine has started mixing. @@ -257,7 +258,8 @@ class EngineMixer : public QObject, public AudioSource { // m_activeBusChannels, m_activeHeadphoneChannels, and // m_activeTalkoverChannels with each channel that is active for the // respective output. - void processChannels(std::size_t bufferSize); + void processChannels(std::size_t bufferSize, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachesDac); ChannelHandleFactoryPointer m_pChannelHandleFactory; void applyMainEffects(std::size_t bufferSize); diff --git a/src/engine/sync/abletonlink.cpp b/src/engine/sync/abletonlink.cpp new file mode 100644 index 00000000000..ba92a9bc282 --- /dev/null +++ b/src/engine/sync/abletonlink.cpp @@ -0,0 +1,215 @@ +#include "engine/sync/abletonlink.h" + +#include +#include + +#include "control/controlobject.h" +#include "engine/sync/enginesync.h" +#include "moc_abletonlink.cpp" +#include "preferences/usersettings.h" +#include "util/logger.h" + +namespace { +const mixxx::Logger kLogger("AbletonLink"); +constexpr mixxx::Bpm kDefaultBpm(9999.9); +} // namespace + +AbletonLink::AbletonLink(const QString& group, EngineSync* pEngineSync) + : m_group(group), + m_pEngineSync(pEngineSync), + m_syncMode(SyncMode::None), + m_oldTempo(kDefaultBpm), + m_audioBufferTimeMicros(0), + m_absTimeWhenPrevOutputBufferReachesDac(0), + m_sampleTimeAtStartCallback(0), + m_pLink(std::make_unique>(120.0)), + m_pLinkButton(std::make_unique(ConfigKey(group, "sync_enabled"))), + m_pNumLinkPeers(std::make_unique(ConfigKey(group, "num_peers"))) { + m_timeAtStartCallback = m_pLink->clock().micros(); + + m_pLinkButton->setButtonMode(mixxx::control::ButtonMode::Toggle); + m_pLinkButton->setStates(2); + + connect(m_pLinkButton.get(), + &ControlObject::valueChanged, + this, + &AbletonLink::slotControlSyncEnabled, + Qt::DirectConnection); + + m_pNumLinkPeers->setReadOnly(); + m_pNumLinkPeers->forceSet(0); + + // The callback is invoked on a Link - managed thread. + // The callback is the only entity, which access m_pNumLinkPeers + m_pLink->setNumPeersCallback([this](std::size_t numPeers) { + m_pNumLinkPeers->forceSet(numPeers); + }); + + m_pLink->enable(false); + + // Start/Stop sync makes not much sense for a DJ set + m_pLink->enableStartStopSync(false); + + audioThreadDebugOutput(); +} + +void AbletonLink::slotControlSyncEnabled(double controButtonlValue) { + m_pLink->enable(controButtonlValue > 0); +} + +void AbletonLink::setSyncMode(SyncMode syncMode) { + m_syncMode = syncMode; +} + +void AbletonLink::notifyUniquePlaying() { +} + +void AbletonLink::requestSync() { + qDebug() << "AbletonLink::requestSync()"; +} + +SyncMode AbletonLink::getSyncMode() const { + return m_syncMode; +} + +bool AbletonLink::isPlaying() const { + if (!m_pLink->isEnabled()) { + return false; + } + if (m_pLink->numPeers() < 1) { + return false; + } + + // Note, that ableton::Link::SessionState.isPlaying() is an optional Ableton + // Link feature (Start/Stop sync) and shouldn't be taken into account here. + return true; +} +bool AbletonLink::isAudible() const { + return false; +} +bool AbletonLink::isQuantized() const { + return true; +} + +mixxx::Bpm AbletonLink::getBpm() const { + return getBaseBpm(); +} + +double AbletonLink::getBeatDistance() const { + const auto sessionState = m_pLink->captureAudioSessionState(); + const auto beats = sessionState.beatAtTime( + m_absTimeWhenPrevOutputBufferReachesDac, getQuantum()); + return std::fmod(beats, 1.0); +} + +mixxx::Bpm AbletonLink::getBaseBpm() const { + const auto sessionState = m_pLink->captureAudioSessionState(); + return mixxx::Bpm(sessionState.tempo()); +} + +void AbletonLink::updateLeaderBeatDistance(double beatDistance) { + auto sessionState = m_pLink->captureAudioSessionState(); + const auto currentBeat = sessionState.beatAtTime( + m_absTimeWhenPrevOutputBufferReachesDac, getQuantum()); + const auto newBeat = currentBeat - std::fmod(currentBeat, 1.0) + beatDistance; + + sessionState.requestBeatAtTime(newBeat, m_absTimeWhenPrevOutputBufferReachesDac, getQuantum()); + m_pLink->commitAudioSessionState(sessionState); +} + +void AbletonLink::forceUpdateLeaderBeatDistance(double beatDistance) { + auto sessionState = m_pLink->captureAudioSessionState(); + const auto currentBeat = sessionState.beatAtTime( + m_absTimeWhenPrevOutputBufferReachesDac, getQuantum()); + const auto newBeat = currentBeat - std::fmod(currentBeat, 1.0) + beatDistance; + + sessionState.forceBeatAtTime(newBeat, m_absTimeWhenPrevOutputBufferReachesDac, getQuantum()); + m_pLink->commitAudioSessionState(sessionState); +} + +void AbletonLink::updateLeaderBpm(mixxx::Bpm bpm) { + auto sessionState = m_pLink->captureAudioSessionState(); + sessionState.setTempo(bpm.value(), m_absTimeWhenPrevOutputBufferReachesDac); + m_pLink->commitAudioSessionState(sessionState); +} + +void AbletonLink::notifyLeaderParamSource() { + // In Ableton Link all pears are equal. Therefore nothing differs, + // if AbletonLink becomes SyncLeader. + // TODO: Check the special case of half/double BPM sync. +} + +void AbletonLink::reinitLeaderParams(double beatDistance, mixxx::Bpm, mixxx::Bpm bpm) { + updateLeaderBeatDistance(beatDistance); + updateLeaderBpm(bpm); +} +void AbletonLink::updateInstantaneousBpm(mixxx::Bpm) { +} + +/// This method is called at the start of the audio callback. +/// It captures the current time and updates the audio buffer time. +/// If Ableton Link is enabled, it captures the session state and notifies +/// the engine sync about any changes in tempo and beat distance. +void AbletonLink::onCallbackStart(int sampleRate, + size_t bufferSize, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachesDac) { + m_timeAtStartCallback = m_pLink->clock().micros(); + + // auto latency = absTimeWhenPrevOutputBufferReachesDac - m_timeAtStartCallback; + /* qDebug() << "#####################:" << absTimeWhenPrevOutputBufferReachesDac.count() + << " ##################AbletonLatency " << latency.count() + << " Delta : " + << m_absTimeWhenPrevOutputBufferReachesDac.count() - + absTimeWhenPrevOutputBufferReachesDac.count(); + */ + m_audioBufferTimeMicros = std::chrono::microseconds( + bufferSize / mixxx::audio::ChannelCount::stereo() / + sampleRate * 1000000); + + m_absTimeWhenPrevOutputBufferReachesDac = absTimeWhenPrevOutputBufferReachesDac; + + if (!m_pLink->isEnabled()) { + return; + } + + const auto sessionState = m_pLink->captureAudioSessionState(); + const mixxx::Bpm tempo(sessionState.tempo()); + if (m_oldTempo != tempo) { + m_oldTempo = tempo; + m_pEngineSync->notifyRateChanged(this, tempo); + } + + const auto beats = sessionState.beatAtTime(absTimeWhenPrevOutputBufferReachesDac, getQuantum()); + const auto beatDistance = std::fmod(beats, 1.0); + m_pEngineSync->notifyBeatDistanceChanged(this, beatDistance); +} + +void AbletonLink::onCallbackEnd(int sampleRate, size_t bufferSize) { + Q_UNUSED(sampleRate) + Q_UNUSED(bufferSize) +} + +// Debug output function, to be called in audio thread +void AbletonLink::audioThreadDebugOutput() { + kLogger.debug() << "isEnabled()" << m_pLink->isEnabled(); + kLogger.debug() << "numPeers()" << m_pLink->numPeers(); + + const auto sessionState = m_pLink->captureAudioSessionState(); + + kLogger.debug() << "sessionState.tempo()" << sessionState.tempo(); + kLogger.debug() << "sessionState.beatAtTime()" + << sessionState.beatAtTime(m_pLink->clock().micros(), getQuantum()); + kLogger.debug() << "sessionState.phaseAtTime()" + << sessionState.phaseAtTime(m_pLink->clock().micros(), getQuantum()); + kLogger.debug() << "sessionState.timeAtBeat(0)" + << sessionState.timeAtBeat(0.0, getQuantum()).count(); + kLogger.debug() << "sessionState.isPlaying()" << sessionState.isPlaying(); + kLogger.debug() << "sessionState.timeForIsPlaying()" << sessionState.timeForIsPlaying().count(); + + // Est. Delay (micro-seconds) between onCallbackStart() and buffer's first + // audio sample reaching speakers + kLogger.debug() << "Est. Delay (us)" + << (m_absTimeWhenPrevOutputBufferReachesDac - + m_pLink->clock().micros()) + .count(); +} diff --git a/src/engine/sync/abletonlink.h b/src/engine/sync/abletonlink.h new file mode 100644 index 00000000000..5df94fd9e11 --- /dev/null +++ b/src/engine/sync/abletonlink.h @@ -0,0 +1,137 @@ +#pragma once + +#include +#include +#include + +#include "control/controlpushbutton.h" +#include "engine/channels/enginechannel.h" +#include "engine/enginebuffer.h" +#include "engine/sync/syncable.h" +#include "engine/sync/synccontrol.h" + +/// This class manages a link session. +/// Read & update (get & set) this session for Mixxx to be a synced Link +/// participant (bpm & phase) +/// +/// Ableton Link Readme (lib/ableton-link/README.md) +/// Documentation in the header (lib/ableton-link/include/ableton/Link.hpp) +/// Ableton provides a command line tool (LinkHut) for debugging Link programs +/// (instructions in the Readme) +/// +/// Ableton recommends getting/setting the link session from the audio thread +/// for maximum timing accuracy. Call the appropriate, realtime-safe functions +/// from the audio callback to do this. + +// std::chrono::steady_clock +// -> selected by keyword 'stl' in ableton-link +// Note that the resolution of std::chrono::steady_clock is not guaranteed +// to be high resolution, but it is guaranteed to be monotonic. +// However, on all major platforms, it is high resolution enough. +using MixxxClockRef = ableton::platforms::stl::Clock; + +class AbletonLink : public QObject, public Syncable { + Q_OBJECT + public: + AbletonLink(const QString& group, EngineSync* pEngineSync); + ~AbletonLink() override = default; + + const QString& getGroup() const override { + return m_group; + } + EngineChannel* getChannel() const override { + return nullptr; + } + + /// Notify a Syncable that their mode has changed. The Syncable must record + /// this mode and return the latest mode in response to getMode(). + void setSyncMode(SyncMode mode) override; + + /// Notify a Syncable that it is now the only currently-playing syncable. + void notifyUniquePlaying() override; + + /// Notify a Syncable that they should sync phase. + void requestSync() override; + + /// Must NEVER return a mode that was not set directly via + /// notifySyncModeChanged. + SyncMode getSyncMode() const override; + + /// Only relevant for player Syncables. + bool isPlaying() const override; + bool isAudible() const override; + bool isQuantized() const override; + + /// Gets the current speed of the syncable in bpm (bpm * rate slider), doesn't + /// include scratch or FF/REW values. + mixxx::Bpm getBpm() const override; + + /// Gets the beat distance as a fraction from 0 to 1 + double getBeatDistance() const override; + + /// Gets the speed of the syncable if it was playing at 1.0 rate. + mixxx::Bpm getBaseBpm() const override; + + /// The following functions are used to tell syncables about the state of the + /// current Sync Master. + /// Must never result in a call to + /// SyncableListener::notifyBeatDistanceChanged or signal loops could occur. + void updateLeaderBeatDistance(double beatDistance) override; + + /// Enforces the immediate change of the beat distance of all Link peers + void forceUpdateLeaderBeatDistance(double beatDistance); + + /// Must never result in a call to SyncableListener::notifyBpmChanged or + /// signal loops could occur. + void updateLeaderBpm(mixxx::Bpm bpm) override; + + void notifyLeaderParamSource() override; + + /// Combines the above three calls into one, since they are often set + /// simultaneously. Avoids redundant recalculation that would occur by + /// using the three calls separately. + void reinitLeaderParams(double beatDistance, mixxx::Bpm baseBpm, mixxx::Bpm bpm) override; + + /// Must never result in a call to + /// SyncableListener::notifyInstantaneousBpmChanged or signal loops could + /// occur. + void updateInstantaneousBpm(mixxx::Bpm bpm) override; + + void onCallbackStart(int sampleRate, + size_t bufferSize, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachesDac); + void onCallbackEnd(int sampleRate, size_t bufferSize); + + private: + ableton::link::HostTimeFilter m_hostTimeFilter; + QString m_group; + EngineSync* m_pEngineSync; // unowned, must outlive this. + SyncMode m_syncMode; + + mixxx::Bpm m_oldTempo; + + std::chrono::microseconds m_audioBufferTimeMicros; + std::chrono::microseconds m_absTimeWhenPrevOutputBufferReachesDac; + std::chrono::microseconds m_sampleTimeAtStartCallback; + std::chrono::microseconds m_timeAtStartCallback; + + std::unique_ptr> m_pLink; + std::unique_ptr m_pLinkButton; + std::unique_ptr m_pNumLinkPeers; + + void slotControlSyncEnabled(double value); + + std::chrono::microseconds getHostTime() const; + std::chrono::microseconds getHostTimeAtSpeaker(std::chrono::microseconds hostTime) const; + + double getQuantum() const { + // Mixxx doesn't know about bars/time-signatures yet - phase + // synchronisation can't be implemented therefore yet + return 1.0; + } + + // Test/Debug code + + /// Link getters to call from audio thread. + void audioThreadDebugOutput(); +}; diff --git a/src/engine/sync/enginesync.cpp b/src/engine/sync/enginesync.cpp index 1ff70961f44..d1c67fee109 100644 --- a/src/engine/sync/enginesync.cpp +++ b/src/engine/sync/enginesync.cpp @@ -4,6 +4,7 @@ #include "engine/channels/enginechannel.h" #include "engine/enginebuffer.h" +#include "engine/sync/abletonlink.h" #include "engine/sync/internalclock.h" #include "util/assert.h" #include "util/logger.h" @@ -11,12 +12,14 @@ namespace { const mixxx::Logger kLogger("EngineSync"); const QString kInternalClockGroup = QStringLiteral("[InternalClock]"); +const QString kAbletonLinkGroup = QStringLiteral("[AbletonLink]"); constexpr mixxx::Bpm kDefaultBpm = mixxx::Bpm(124.0); } // anonymous namespace EngineSync::EngineSync(UserSettingsPointer pConfig) : m_pConfig(pConfig), m_pInternalClock(new InternalClock(kInternalClockGroup, this)), + m_pAbletonLink(new AbletonLink(kAbletonLinkGroup, this)), m_pLeaderSyncable(nullptr) { qRegisterMetaType("SyncMode"); m_pInternalClock->updateLeaderBpm(kDefaultBpm); @@ -27,6 +30,7 @@ EngineSync::~EngineSync() { const mixxx::Bpm bpm = m_pInternalClock->getBpm(); m_pConfig->setValue(ConfigKey(kInternalClockGroup, "bpm"), bpm.isValid() ? bpm.value() : mixxx::Bpm::kValueUndefined); + delete m_pAbletonLink; delete m_pInternalClock; } @@ -366,6 +370,10 @@ Syncable* EngineSync::findBpmMatchTarget(Syncable* requester) { } } + if (m_pAbletonLink->isPlaying()) { + return m_pAbletonLink; + } + if (pStoppedSyncTarget) { return pStoppedSyncTarget; } @@ -395,11 +403,18 @@ void EngineSync::notifyPlayingAudible(Syncable* pSyncable, bool playingAudible) reinitLeaderParams(newLeader); } else { Syncable* pOnlyPlayer = getUniquePlayingSyncedDeck(); - if (pOnlyPlayer) { + if (pOnlyPlayer && !m_pAbletonLink->isPlaying()) { // Even if we didn't change leader, if there is only one player, then we should // reinit leader params. pOnlyPlayer->notifyUniquePlaying(); reinitLeaderParams(pOnlyPlayer); + } else if (pOnlyPlayer) { + // If the Leader is the only player, but Ableton Link peers are + // playing, then it will need to initialize parameters from Ableton + // Link. + + pOnlyPlayer->notifyUniquePlaying(); + reinitLeaderParams(m_pAbletonLink); } } } @@ -418,11 +433,41 @@ void EngineSync::notifyScratching(Syncable* pSyncable, bool scratching) { } if (isLeader(pSyncable->getSyncMode())) { Syncable* pOnlyPlayer = getUniquePlayingSyncedDeck(); - if (pOnlyPlayer) { + if (pOnlyPlayer && !m_pAbletonLink->isPlaying()) { // Even if we didn't change leader, if there is only one player (us), then we should // reinit the beat distance. pOnlyPlayer->notifyUniquePlaying(); - updateLeaderBeatDistance(pOnlyPlayer, pOnlyPlayer->getBeatDistance()); + double beatDistance = pOnlyPlayer->getBeatDistance(); + updateLeaderBeatDistance(pOnlyPlayer, beatDistance); + + // No other Ableton Link peers are playing -> Enforce immediate beat + // position shift This ensures that a peer that later joins/starts + // playing, starts in sync to the Mixxx sync leader + m_pAbletonLink->forceUpdateLeaderBeatDistance(beatDistance); + } else if (pOnlyPlayer) { + // If the Leader is the only player, but Ableton Link peers are + // playing, then it will need to sync phase to the beat distance + // from Ableton Link. + + auto* engineBuffer = pSyncable->getChannel()->getEngineBuffer(); + DEBUG_ASSERT(engineBuffer); + + const auto playPos = engineBuffer->getVisualPlayPos(); + const auto trackEnd = engineBuffer->getTrackEndPosition().value(); + const auto trackSampleRate = engineBuffer->getTrackSampleRate(); + const auto bpmValue = pSyncable->getBpm().value(); + const auto abletonBpmValue = m_pAbletonLink->getBpm().value(); + + const auto chBeatDistance = + (pSyncable->getBeatDistance() * 60 * trackSampleRate) / + (bpmValue * trackEnd); + const auto abletonBeatDistance = + (m_pAbletonLink->getBeatDistance() * 60 * trackSampleRate) / + (abletonBpmValue * trackEnd); + + const auto seekPos = playPos - chBeatDistance + abletonBeatDistance; + engineBuffer->slotControlSeek(seekPos); + engineBuffer->requestSyncPhase(); } else { // If the Leader isn't the only player, then it will need to sync // phase like followers do. @@ -437,7 +482,9 @@ void EngineSync::notifySeek(Syncable* pSyncable, mixxx::audio::FramePos position // This relies on the bpmcontrol being notified about the seek before // the sync control, but that's ok because that's intrinsic to how the // controls are constructed (see the constructor of enginebuffer). - updateLeaderBeatDistance(pSyncable, pSyncable->getBeatDistance()); + double beatDistance = pSyncable->getBeatDistance(); + updateLeaderBeatDistance(pSyncable, beatDistance); + m_pAbletonLink->updateLeaderBeatDistance(beatDistance); } } @@ -446,7 +493,9 @@ void EngineSync::notifyBaseBpmChanged(Syncable* pSyncable, mixxx::Bpm bpm) { kLogger.trace() << "EngineSync::notifyBaseBpmChanged" << pSyncable->getGroup() << bpm; } - if (isSyncLeader(pSyncable)) { + // In case of playing Ableton Link peers, don't overwrite BPM with base BPM + // (happens at track load) + if (isSyncLeader(pSyncable) && !m_pAbletonLink->isPlaying()) { updateLeaderBpm(pSyncable, bpm); } } @@ -503,7 +552,8 @@ void EngineSync::notifyBeatDistanceChanged(Syncable* pSyncable, double beatDista kLogger.trace() << "EngineSync::notifyBeatDistanceChanged" << pSyncable->getGroup() << beatDistance; } - if (pSyncable != m_pInternalClock) { + + if (pSyncable != m_pInternalClock && pSyncable != m_pAbletonLink) { if (getUniquePlayingSyncedDeck() == pSyncable) { updateLeaderBeatDistance(pSyncable, beatDistance); } @@ -585,12 +635,16 @@ void EngineSync::addSyncableDeck(Syncable* pSyncable) { m_syncables.append(pSyncable); } -void EngineSync::onCallbackStart(mixxx::audio::SampleRate sampleRate, std::size_t bufferSize) { +void EngineSync::onCallbackStart(mixxx::audio::SampleRate sampleRate, + std::size_t bufferSize, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachesDac) { m_pInternalClock->onCallbackStart(sampleRate, bufferSize); + m_pAbletonLink->onCallbackStart(sampleRate, bufferSize, absTimeWhenPrevOutputBufferReachesDac); } void EngineSync::onCallbackEnd(mixxx::audio::SampleRate sampleRate, std::size_t bufferSize) { m_pInternalClock->onCallbackEnd(sampleRate, bufferSize); + m_pAbletonLink->onCallbackEnd(sampleRate, bufferSize); } EngineChannel* EngineSync::getLeaderChannel() const { @@ -619,6 +673,9 @@ mixxx::Bpm EngineSync::leaderBpm() const { if (m_pLeaderSyncable) { return m_pLeaderSyncable->getBpm(); } + if (m_pAbletonLink->isPlaying()) { + return m_pAbletonLink->getBpm(); + } return m_pInternalClock->getBpm(); } @@ -626,6 +683,9 @@ double EngineSync::leaderBeatDistance() const { if (m_pLeaderSyncable) { return m_pLeaderSyncable->getBeatDistance(); } + if (m_pAbletonLink->isPlaying()) { + return m_pAbletonLink->getBeatDistance(); + } return m_pInternalClock->getBeatDistance(); } @@ -633,6 +693,9 @@ mixxx::Bpm EngineSync::leaderBaseBpm() const { if (m_pLeaderSyncable) { return m_pLeaderSyncable->getBaseBpm(); } + if (m_pAbletonLink->isPlaying()) { + return m_pAbletonLink->getBaseBpm(); + } return m_pInternalClock->getBaseBpm(); } @@ -640,6 +703,9 @@ void EngineSync::updateLeaderBpm(Syncable* pSource, mixxx::Bpm bpm) { if (pSource != m_pInternalClock) { m_pInternalClock->updateLeaderBpm(bpm); } + if (pSource != m_pAbletonLink) { + m_pAbletonLink->updateLeaderBpm(bpm); + } foreach (Syncable* pSyncable, m_syncables) { if (pSyncable == pSource || !pSyncable->isSynchronized()) { @@ -707,6 +773,8 @@ void EngineSync::reinitLeaderParams(Syncable* pSource) { } if (playingSyncables) { beatDistance = m_pInternalClock->getBeatDistance(); + } else if (m_pAbletonLink->isPlaying()) { + beatDistance = m_pAbletonLink->getBeatDistance(); } } const mixxx::Bpm baseBpm = pSource->getBaseBpm(); @@ -726,6 +794,9 @@ void EngineSync::reinitLeaderParams(Syncable* pSource) { if (pSource != m_pInternalClock) { m_pInternalClock->reinitLeaderParams(beatDistance, baseBpm, bpm); } + if (pSource != m_pAbletonLink) { + m_pAbletonLink->reinitLeaderParams(beatDistance, baseBpm, bpm); + } foreach (Syncable* pSyncable, m_syncables) { if (!pSyncable->isSynchronized()) { continue; diff --git a/src/engine/sync/enginesync.h b/src/engine/sync/enginesync.h index cf8bcaf913b..12d82c760d8 100644 --- a/src/engine/sync/enginesync.h +++ b/src/engine/sync/enginesync.h @@ -6,6 +6,7 @@ #include "preferences/usersettings.h" class InternalClock; +class AbletonLink; class EngineChannel; const QString kBpmConfigGroup = QStringLiteral("[BPM]"); @@ -71,7 +72,9 @@ class EngineSync : public SyncableListener { void addSyncableDeck(Syncable* pSyncable); EngineChannel* getLeaderChannel() const; - void onCallbackStart(mixxx::audio::SampleRate sampleRate, std::size_t bufferSize); + void onCallbackStart(mixxx::audio::SampleRate sampleRate, + std::size_t bufferSize, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachesDac); void onCallbackEnd(mixxx::audio::SampleRate sampleRate, std::size_t bufferSize); private: @@ -163,6 +166,8 @@ class EngineSync : public SyncableListener { UserSettingsPointer m_pConfig; /// The InternalClock syncable. InternalClock* m_pInternalClock; + /// The Ableton Link syncable. + AbletonLink* m_pAbletonLink; /// The current Syncable that is the leader. Syncable* m_pLeaderSyncable; /// The list of all Syncables registered via addSyncableDeck. diff --git a/src/soundio/sounddevice.h b/src/soundio/sounddevice.h index 64235778b12..adef142df5c 100644 --- a/src/soundio/sounddevice.h +++ b/src/soundio/sounddevice.h @@ -2,6 +2,7 @@ #include #include +#include #include "audio/types.h" #include "preferences/usersettings.h" @@ -54,6 +55,8 @@ class SoundDevice { bool operator==(const SoundDevice &other) const; bool operator==(const QString &other) const; + std::chrono::microseconds m_absTimeWhenPrevOutputBufferReachesDac; + protected: void composeOutputBuffer(CSAMPLE* outputBuffer, const SINT iFramesPerBuffer, @@ -80,6 +83,8 @@ class SoundDevice { mixxx::audio::ChannelCount m_numInputChannels; // The current samplerate for the sound device. mixxx::audio::SampleRate m_sampleRate; + // The output latency reported by portaudio streaminfo + double m_outputLatencyMillis; // The name of the audio API used by this device. QString m_hostAPI; // The **configured** number of frames per buffer. We'll tell PortAudio we diff --git a/src/soundio/sounddevicenetwork.cpp b/src/soundio/sounddevicenetwork.cpp index cef9669e20d..349539db1c4 100644 --- a/src/soundio/sounddevicenetwork.cpp +++ b/src/soundio/sounddevicenetwork.cpp @@ -16,6 +16,12 @@ #include "util/trace.h" #include "waveform/visualplayposition.h" +// HostTime clock reference type +// note that the resolution of std::chrono::steady_clock is not guaranteed +// to be high resolution, but it is guaranteed to be monotonic. +// However, on all major platforms, it is high resolution enough. +using ClockT = std::chrono::steady_clock; + namespace { constexpr int kNetworkLatencyFrames = 8192; // 185 ms @ 44100 Hz // Related chunk sizes: @@ -41,7 +47,8 @@ SoundDeviceNetwork::SoundDeviceNetwork( m_audioLatencyUsage(kAppGroup, QStringLiteral("audio_latency_usage")), m_framesSinceAudioLatencyUsageUpdate(0), m_denormals(false), - m_targetTime(0) { + m_targetTime(0), + m_hostTimeFilter(512) { // Setting parent class members: m_hostAPI = "Network stream"; m_sampleRate = SoundManagerConfig::kMixxxDefaultSampleRate; @@ -104,6 +111,8 @@ SoundDeviceStatus SoundDeviceNetwork::open(bool isClkRefDevice, int syncBuffers) << m_sampleRate << "Hz =" << requestedBufferTime.formatMillisWithUnit(); } + m_hostTimeFilter.clear(); + return SoundDeviceStatus::Ok; } @@ -499,7 +508,8 @@ void SoundDeviceNetwork::callbackProcessClkRef() { { ScopedTimer t(QStringLiteral("SoundDevicePortAudio::callbackProcess prepare %1"), m_deviceId.name); - m_pSoundManager->onDeviceOutputCallback(framesPerBuffer); + m_pSoundManager->onDeviceOutputCallback( + framesPerBuffer, m_absTimeWhenPrevOutputBufferReachesDac); } m_pSoundManager->writeProcess(framesPerBuffer); @@ -516,6 +526,24 @@ void SoundDeviceNetwork::updateCallbackEntryToDacTime(SINT framesPerBuffer) { m_targetTime += static_cast(framesPerBuffer / m_sampleRate.toDouble() * 1000000); double callbackEntrytoDacSecs = (m_targetTime - currentTime) / 1000000.0; callbackEntrytoDacSecs = math_max(callbackEntrytoDacSecs, 0.0001); + + // Use HostTimeFilter class to create a smooth linear regression + // between absolute network time and absolute host time + auto hostTime = std::chrono::duration_cast( + ClockT::now().time_since_epoch()); + + m_hostTimeFilter.insertTimePoint(static_cast(currentTime), hostTime); + + auto filteredHostTime = m_hostTimeFilter.calcHostTime(static_cast(currentTime)); + auto outputLatency = std::chrono::microseconds( + static_cast(callbackEntrytoDacSecs * 1000000)); + + if (filteredHostTime != HostTimeFilter::kInvalidHostTime) { + m_absTimeWhenPrevOutputBufferReachesDac = filteredHostTime + outputLatency; + } else { + m_absTimeWhenPrevOutputBufferReachesDac = hostTime + outputLatency; + } + VisualPlayPosition::setCallbackEntryToDacSecs(callbackEntrytoDacSecs, m_clkRefTimer); //qDebug() << callbackEntrytoDacSecs << timeSinceLastCbSecs; } diff --git a/src/soundio/sounddevicenetwork.h b/src/soundio/sounddevicenetwork.h index 4ae0fab9af8..9b5fac8fe01 100644 --- a/src/soundio/sounddevicenetwork.h +++ b/src/soundio/sounddevicenetwork.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #ifdef __LINUX__ @@ -14,6 +14,7 @@ #include "engine/sidechain/networkoutputstreamworker.h" #include "soundio/sounddevice.h" #include "util/fifo.h" +#include "util/hosttimefilter.h" #include "util/performancetimer.h" #define CPU_USAGE_UPDATE_RATE 30 // in 1/s, fits to display frame rate @@ -68,6 +69,8 @@ class SoundDeviceNetwork : public SoundDevice { /// The deadline for the next buffer, in microseconds since the Unix epoch. qint64 m_targetTime; PerformanceTimer m_clkRefTimer; + + HostTimeFilter m_hostTimeFilter; }; class SoundDeviceNetworkThread : public QThread { diff --git a/src/soundio/sounddeviceportaudio.cpp b/src/soundio/sounddeviceportaudio.cpp index 9af0d4e4070..44c53d196e2 100644 --- a/src/soundio/sounddeviceportaudio.cpp +++ b/src/soundio/sounddeviceportaudio.cpp @@ -20,6 +20,12 @@ #include "util/trace.h" #include "waveform/visualplayposition.h" +// HostTime clock reference type +// note that the resolution of std::chrono::steady_clock is not guaranteed +// to be high resolution, but it is guaranteed to be monotonic. +// However, on all major platforms, it is high resolution enough. +using ClockT = std::chrono::steady_clock; + #ifdef PA_USE_ALSA // for PaAlsa_EnableRealtimeScheduling #include @@ -41,6 +47,9 @@ constexpr int kCpuUsageUpdateRate = 30; // in 1/s, fits to display frame rate // callbacks can be always wrong due to a setup/open jitter constexpr int m_invalidTimeInfoWarningCount = 3; +// Some PortAudio drivers return zero output latency, this is the detection threshold +constexpr double kMinReasonableAudioLatencySecs = 0.001; + int paV19Callback(const void *inputBuffer, void *outputBuffer, unsigned long framesPerBuffer, const PaStreamCallbackTimeInfo *timeInfo, @@ -95,7 +104,10 @@ SoundDevicePortAudio::SoundDevicePortAudio(UserSettingsPointer config, m_syncBuffers(2), m_invalidTimeInfoCount(0), m_lastCallbackEntrytoDacSecs(0), - m_callbackResult(paAbort) { + m_callbackResult(paAbort), + m_hostTimeFilter(512), + m_cummulatedBufferTime(0), + m_meanOutputLatency(MovingInterquartileMean(128)) { // Setting parent class members: m_hostAPI = Pa_GetHostApiInfo(deviceInfo->hostApi)->name; m_sampleRate = mixxx::audio::SampleRate::fromDouble(deviceInfo->defaultSampleRate); @@ -227,9 +239,9 @@ SoundDeviceStatus SoundDevicePortAudio::open(bool isClkRefDevice, int syncBuffer } else { qDebug() << "framesPerBuffer:" << framesPerBuffer; } - double bufferMSec = framesPerBuffer / m_sampleRate.toDouble() * 1000; + double bufferSizeMillis = framesPerBuffer / m_sampleRate.toDouble() * 1000; qDebug() << "Requested sample rate: " << m_sampleRate << "Hz and buffer size:" - << bufferMSec << "ms"; + << bufferSizeMillis << "ms"; qDebug() << "Output channels:" << m_outputParams.channelCount << "| Input channels:" @@ -238,17 +250,17 @@ SoundDeviceStatus SoundDevicePortAudio::open(bool isClkRefDevice, int syncBuffer // Fill out the rest of the info. m_outputParams.device = m_deviceId.portAudioIndex; m_outputParams.sampleFormat = paFloat32; - m_outputParams.suggestedLatency = bufferMSec / 1000.0; + m_outputParams.suggestedLatency = bufferSizeMillis / 1000.0; m_outputParams.hostApiSpecificStreamInfo = nullptr; m_inputParams.device = m_deviceId.portAudioIndex; m_inputParams.sampleFormat = paFloat32; - m_inputParams.suggestedLatency = bufferMSec / 1000.0; + m_inputParams.suggestedLatency = bufferSizeMillis / 1000.0; m_inputParams.hostApiSpecificStreamInfo = nullptr; qDebug() << "Opening stream with id" << m_deviceId.portAudioIndex; - m_lastCallbackEntrytoDacSecs = bufferMSec / 1000.0; + m_lastCallbackEntrytoDacSecs = bufferSizeMillis / 1000.0; m_syncBuffers = syncBuffers; @@ -384,19 +396,22 @@ SoundDeviceStatus SoundDevicePortAudio::open(bool isClkRefDevice, int syncBuffer // Get the actual details of the stream & update Mixxx's data const PaStreamInfo* streamDetails = Pa_GetStreamInfo(pStream); m_sampleRate = mixxx::audio::SampleRate::fromDouble(streamDetails->sampleRate); - double currentLatencyMSec = streamDetails->outputLatency * 1000; + m_outputLatencyMillis = streamDetails->outputLatency * 1000; qDebug() << " Actual sample rate: " << m_sampleRate << "Hz, latency:" - << currentLatencyMSec << "ms"; + << m_outputLatencyMillis << "ms"; if (isClkRefDevice) { // Update the samplerate and latency ControlObjects, which allow the // waveform view to properly correct for the latency. ControlObject::set( ConfigKey(kAppGroup, QStringLiteral("output_latency_ms")), - currentLatencyMSec); + m_outputLatencyMillis); ControlObject::set(ConfigKey(kAppGroup, QStringLiteral("samplerate")), m_sampleRate); m_invalidTimeInfoCount = 0; m_clkRefTimer.start(); + + m_hostTimeFilter.clear(); + m_meanOutputLatency.clear(); } m_pStream.store(pStream, std::memory_order_release); @@ -446,7 +461,7 @@ SoundDeviceStatus SoundDevicePortAudio::close() { // Trying Pa_AbortStream instead, because StopStream seems to wait // until all the buffers have been flushed, which can take a // few (annoying) seconds when you're doing soundcard input. - // (it flushes the input buffer, and then some, or something) + //(it flushes the input buffer, and then some, or something) // BIG FAT WARNING: Pa_AbortStream() will kill threads while they're // waiting on a mutex, which will leave the mutex in an screwy // state. Don't use it! @@ -1023,7 +1038,8 @@ int SoundDevicePortAudio::callbackProcessClkRef( { ScopedTimer t(QStringLiteral("SoundDevicePortAudio::callbackProcess prepare %1"), m_deviceId.debugName()); - m_pSoundManager->onDeviceOutputCallback(framesPerBuffer); + m_pSoundManager->onDeviceOutputCallback( + framesPerBuffer, m_absTimeWhenPrevOutputBufferReachesDac); } if (out) { @@ -1081,6 +1097,49 @@ void SoundDevicePortAudio::updateCallbackEntryToDacTime( - timeInfo->currentTime; double bufferSizeSec = framesPerBuffer / m_sampleRate.toDouble(); + // Use HostTimeFilter class to create a smooth linear regression + // between absolute sound card time and absolute host time + PaTime soundCardTimeNow = Pa_GetStreamTime( + m_pStream); // There is a delay & jitter to timeInfo->currentTime + + m_cummulatedBufferTime += bufferSizeSec; + auto hostTime = std::chrono::duration_cast( + ClockT::now().time_since_epoch()); + + m_hostTimeFilter.insertTimePoint(m_cummulatedBufferTime, hostTime); + + auto filteredHostTimeNow = m_hostTimeFilter.calcHostTime(m_cummulatedBufferTime); + if (filteredHostTimeNow == HostTimeFilter::kInvalidHostTime) { + filteredHostTimeNow = hostTime; + } + + if (CmdlineArgs::Instance().getDeveloper()) { + qWarning() << "Pa_GetStreamTime: " + << static_cast(soundCardTimeNow * 1000000) + << "timeInfo->currentTime: " + << static_cast(timeInfo->currentTime * 1000000) + << "timeInfo->outputBufferDacTime: " + << static_cast( + timeInfo->outputBufferDacTime * 1000000) + << "m_absTimeWhenPrevOutputBufferReachesDac: " + << m_absTimeWhenPrevOutputBufferReachesDac.count(); + } + + // Only use latency from PortAudios timeInfo, if it's in reasonable range, + // otherwise use latency value from PortAudios streamInfo + if (callbackEntrytoDacSecs > kMinReasonableAudioLatencySecs && + timeSinceLastCbSecs < bufferSizeSec * 2) { + m_meanOutputLatency.insert(timeInfo->outputBufferDacTime - soundCardTimeNow); + + m_absTimeWhenPrevOutputBufferReachesDac = filteredHostTimeNow + + std::chrono::microseconds(static_cast( + m_meanOutputLatency.mean() * 1000000)); + } else { + m_absTimeWhenPrevOutputBufferReachesDac = filteredHostTimeNow + + std::chrono::microseconds( + static_cast(m_outputLatencyMillis * 1000)); + } + double diff = (timeSinceLastCbSecs + callbackEntrytoDacSecs) - (m_lastCallbackEntrytoDacSecs + bufferSizeSec); diff --git a/src/soundio/sounddeviceportaudio.h b/src/soundio/sounddeviceportaudio.h index bff6d90ece5..909d1c673f1 100644 --- a/src/soundio/sounddeviceportaudio.h +++ b/src/soundio/sounddeviceportaudio.h @@ -10,6 +10,8 @@ #include "soundio/soundmanagerconfig.h" #include "util/duration.h" #include "util/fifo.h" +#include "util/hosttimefilter.h" +#include "util/movinginterquartilemean.h" #include "util/performancetimer.h" class SoundManager; @@ -85,4 +87,8 @@ class SoundDevicePortAudio : public SoundDevice { PerformanceTimer m_clkRefTimer; PaTime m_lastCallbackEntrytoDacSecs; std::atomic m_callbackResult; + + HostTimeFilter m_hostTimeFilter; + double m_cummulatedBufferTime; + MovingInterquartileMean m_meanOutputLatency; }; diff --git a/src/soundio/soundmanager.cpp b/src/soundio/soundmanager.cpp index 273211831ca..cabd8aa26c0 100644 --- a/src/soundio/soundmanager.cpp +++ b/src/soundio/soundmanager.cpp @@ -591,10 +591,11 @@ void SoundManager::checkConfig() { // latency checks itself for validity on SMConfig::setLatency() } -void SoundManager::onDeviceOutputCallback(const SINT iFramesPerBuffer) { +void SoundManager::onDeviceOutputCallback(const SINT iFramesPerBuffer, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachsDac) { // Produce a block of samples for output. EngineMixer expects stereo // samples so multiply iFramesPerBuffer by 2. - m_pEngineMixer->process(iFramesPerBuffer * 2); + m_pEngineMixer->process(iFramesPerBuffer * 2, absTimeWhenPrevOutputBufferReachsDac); } void SoundManager::pushInputBuffers(const QList& inputs, diff --git a/src/soundio/soundmanager.h b/src/soundio/soundmanager.h index 205909535f4..3dd63dadb3e 100644 --- a/src/soundio/soundmanager.h +++ b/src/soundio/soundmanager.h @@ -77,7 +77,8 @@ class SoundManager : public QObject { void closeActiveConfig(); void checkConfig(); - void onDeviceOutputCallback(const SINT iFramesPerBuffer); + void onDeviceOutputCallback(const SINT iFramesPerBuffer, + std::chrono::microseconds absTimeWhenPrevOutputBufferReachesDac); // Used by SoundDevices to "push" any audio from their inputs that they have // into the mixing engine. diff --git a/src/test/enginemixertest.cpp b/src/test/enginemixertest.cpp index 513cc300c00..1e816b15820 100644 --- a/src/test/enginemixertest.cpp +++ b/src/test/enginemixertest.cpp @@ -150,7 +150,8 @@ TEST_P(EngineMixerTest, OutputWorks) { .WillOnce(Return()); } - m_pEngineMixer->process(static_cast(channels.at(0).second.size())); + m_pEngineMixer->process(static_cast(channels.at(0).second.size()), + std::chrono::microseconds(0)); assertBuffers(); } diff --git a/src/test/hosttimefilter_test.cpp b/src/test/hosttimefilter_test.cpp new file mode 100644 index 00000000000..5f563153d6b --- /dev/null +++ b/src/test/hosttimefilter_test.cpp @@ -0,0 +1,112 @@ +#include "util/hosttimefilter.h" + +#include + +#include + +using namespace std::chrono_literals; + +class HostTimeFilterTest : public ::testing::Test { + protected: + HostTimeFilterTest() + : m_filter(5) { // Initialize with 5 points for testing + } + + HostTimeFilter m_filter; +}; + +TEST_F(HostTimeFilterTest, InitialState) { + EXPECT_EQ(m_filter.calcHostTime(0.0), HostTimeFilter::kInvalidHostTime); +} + +TEST_F(HostTimeFilterTest, AddSinglePoint) { + m_filter.insertTimePoint(1.0, 1050us); + EXPECT_EQ(m_filter.calcHostTime(1.0), HostTimeFilter::kInvalidHostTime); +} + +TEST_F(HostTimeFilterTest, EqualFreqNoJitter) { + // Perfectly synced clocks, the filter should return hosttime == auxiliary time + m_filter.insertTimePoint(1000.0, 1000us); + EXPECT_EQ(m_filter.calcHostTime(1000.0), HostTimeFilter::kInvalidHostTime); + m_filter.insertTimePoint(2000.0, 2000us); + EXPECT_EQ(m_filter.calcHostTime(2000.0), 2000us); + m_filter.insertTimePoint(3000.0, 3000us); + EXPECT_EQ(m_filter.calcHostTime(3000.0), 3000us); + m_filter.insertTimePoint(4000.0, 4000us); + EXPECT_EQ(m_filter.calcHostTime(4000.0), 4000us); + m_filter.insertTimePoint(5000.0, 5000us); + EXPECT_EQ(m_filter.calcHostTime(5000.0), 5000us); + m_filter.insertTimePoint(6000.0, 6000us); + EXPECT_EQ(m_filter.calcHostTime(6000.0), 6000us); + m_filter.insertTimePoint(7000.0, 7000us); + EXPECT_EQ(m_filter.calcHostTime(7000.0), 7000us); +} + +TEST_F(HostTimeFilterTest, FasterFreqNoJitter) { + // Use 1024 sample buffer interval, instead of auxiliarry clock in time units + m_filter.insertTimePoint(1024.0, 1000us); + EXPECT_EQ(m_filter.calcHostTime(1024.0), HostTimeFilter::kInvalidHostTime); + m_filter.insertTimePoint(2048.0, 2000us); + EXPECT_EQ(m_filter.calcHostTime(2048.0), 2000us); + m_filter.insertTimePoint(3072.0, 3000us); + EXPECT_EQ(m_filter.calcHostTime(3072.0), 3000us); + m_filter.insertTimePoint(4096.0, 4000us); + EXPECT_EQ(m_filter.calcHostTime(4096.0), 4000us); + m_filter.insertTimePoint(5120.0, 5000us); + EXPECT_EQ(m_filter.calcHostTime(5120.0), 5000us); + m_filter.insertTimePoint(6144.0, 6000us); + EXPECT_EQ(m_filter.calcHostTime(6144.0), 6000us); + m_filter.insertTimePoint(7168.0, 7000us); + EXPECT_EQ(m_filter.calcHostTime(7168.0), 7000us); +} + +TEST_F(HostTimeFilterTest, FasterFreqWithJitter) { + // Use 1024 sample buffer interval, with 100us host time jitter + m_filter.insertTimePoint(1024.0, 1000us); + EXPECT_EQ(m_filter.calcHostTime(1024.0), HostTimeFilter::kInvalidHostTime); + m_filter.insertTimePoint(2048.0, 2100us); + EXPECT_EQ(m_filter.calcHostTime(2048.0), 2100us); + m_filter.insertTimePoint(3072.0, 3000us); + EXPECT_EQ(m_filter.calcHostTime(3072.0), 3033us); + m_filter.insertTimePoint(4096.0, 3900us); + EXPECT_EQ(m_filter.calcHostTime(4096.0), 3940us); + m_filter.insertTimePoint(5120.0, 5000us); + EXPECT_EQ(m_filter.calcHostTime(5120.0), 4960us); + m_filter.insertTimePoint(6144.0, 6000us); + EXPECT_EQ(m_filter.calcHostTime(6144.0), 5960us); + m_filter.insertTimePoint(7168.0, 7000us); + EXPECT_EQ(m_filter.calcHostTime(7168.0), 7000us); +} + +TEST_F(HostTimeFilterTest, FasterFreqSkippedPoints) { + // Use 1024 sample buffer interval, instead of auxiliarry clock in time units + m_filter.insertTimePoint(1024.0, 1000us); + EXPECT_EQ(m_filter.calcHostTime(1024.0), HostTimeFilter::kInvalidHostTime); + m_filter.insertTimePoint(2048.0, 2000us); + EXPECT_EQ(m_filter.calcHostTime(2048.0), 2000us); + m_filter.insertTimePoint(4096.0, 4000us); + EXPECT_EQ(m_filter.calcHostTime(4096.0), 4000us); + m_filter.insertTimePoint(5120.0, 5000us); + EXPECT_EQ(m_filter.calcHostTime(5120.0), 5000us); + m_filter.insertTimePoint(8192.0, 8000us); + EXPECT_EQ(m_filter.calcHostTime(8192.0), 8000us); + m_filter.insertTimePoint(9216.0, 9000us); + EXPECT_EQ(m_filter.calcHostTime(9216.0), 9000us); + m_filter.insertTimePoint(11264.0, 11000us); + EXPECT_EQ(m_filter.calcHostTime(11264.0), 11000us); +} + +TEST_F(HostTimeFilterTest, Reset) { + m_filter.insertTimePoint(1.0, 1050us); + m_filter.insertTimePoint(2.0, 1950us); + m_filter.clear(); + EXPECT_EQ(m_filter.calcHostTime(4.0), HostTimeFilter::kInvalidHostTime); +} + +TEST_F(HostTimeFilterTest, DenominatorZero) { + // Add two identical points to ensure the denominator becomes zero + m_filter.insertTimePoint(1.0, 1000us); + EXPECT_EQ(m_filter.calcHostTime(1.0), HostTimeFilter::kInvalidHostTime); + m_filter.insertTimePoint(1.0, 1000us); + EXPECT_EQ(m_filter.calcHostTime(1.0), HostTimeFilter::kInvalidHostTime); +} diff --git a/src/test/playermanagertest.cpp b/src/test/playermanagertest.cpp index d1642b5a4f4..6f6f2f31780 100644 --- a/src/test/playermanagertest.cpp +++ b/src/test/playermanagertest.cpp @@ -156,7 +156,7 @@ TEST_F(PlayerManagerTest, UnEjectTest) { false); ASSERT_NE(nullptr, deck1->getLoadedTrack()); - m_pEngine->process(1024); + m_pEngine->process(1024, std::chrono::microseconds(0)); waitForTrackToBeLoaded(deck1); // make sure eject does not trigger 'unreplace': // sleep for longer than 500 ms 'unreplace' period so this is not registered as double-click @@ -198,7 +198,7 @@ TEST_F(PlayerManagerTest, UnEjectReplaceTrackTest) { false); ASSERT_NE(nullptr, deck1->getLoadedTrack()); - m_pEngine->process(1024); + m_pEngine->process(1024, std::chrono::microseconds(0)); waitForTrackToBeLoaded(deck1); // Load another track, replacing the first, causing it to be unloaded. @@ -209,7 +209,7 @@ TEST_F(PlayerManagerTest, UnEjectReplaceTrackTest) { mixxx::StemChannelSelection(), #endif false); - m_pEngine->process(1024); + m_pEngine->process(1024, std::chrono::microseconds(0)); waitForTrackToBeLoaded(deck1); // Ejecting in an empty deck loads the last-ejected track. @@ -249,7 +249,7 @@ TEST_F(PlayerManagerTest, UnReplaceTest) { mixxx::StemChannelSelection(), #endif false); - m_pEngine->process(1024); + m_pEngine->process(1024, std::chrono::microseconds(0)); waitForTrackToBeLoaded(deck1); ASSERT_NE(nullptr, deck1->getLoadedTrack()); @@ -261,7 +261,7 @@ TEST_F(PlayerManagerTest, UnReplaceTest) { mixxx::StemChannelSelection(), #endif false); - m_pEngine->process(1024); + m_pEngine->process(1024, std::chrono::microseconds(0)); waitForTrackToBeLoaded(deck1); ASSERT_NE(nullptr, deck1->getLoadedTrack()); diff --git a/src/test/signalpathtest.h b/src/test/signalpathtest.h index 619e80e21f8..3d4575e3f95 100644 --- a/src/test/signalpathtest.h +++ b/src/test/signalpathtest.h @@ -256,7 +256,7 @@ class BaseSignalPathTest : public MixxxTest, SoundSourceProviderRegistration { void ProcessBuffer() { qDebug() << "------- Process Buffer -------"; - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); } ChannelHandleFactoryPointer m_pChannelHandleFactory; diff --git a/src/test/stemcontrolobjecttest.cpp b/src/test/stemcontrolobjecttest.cpp index d76687326c8..2342b12a399 100644 --- a/src/test/stemcontrolobjecttest.cpp +++ b/src/test/stemcontrolobjecttest.cpp @@ -195,8 +195,8 @@ TEST_F(StemControlTest, Volume) { m_pStem4Volume->set(0.0); // Proceed the buffer a first time to proceed the ramping gain - m_pEngineMixer->process(kProcessBufferSize); - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); assertBufferMatchesReference(m_pEngineMixer->getMainBuffer(), QStringLiteral("StemVolumeControlSilence")); @@ -205,8 +205,8 @@ TEST_F(StemControlTest, Volume) { m_pStem1Volume->set(1.0); // Proceed the buffer a first time to proceed the ramping gain - m_pEngineMixer->process(kProcessBufferSize); - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); assertBufferMatchesReference(m_pEngineMixer->getMainBuffer(), QStringLiteral("StemVolumeControlDrumOnly")); @@ -215,8 +215,8 @@ TEST_F(StemControlTest, Volume) { m_pStem2Volume->set(0.8); // Proceed the buffer a first time to proceed the ramping gain - m_pEngineMixer->process(kProcessBufferSize); - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); assertBufferMatchesReference(m_pEngineMixer->getMainBuffer(), QStringLiteral("StemVolumeControlDrumAndBass")); @@ -227,8 +227,8 @@ TEST_F(StemControlTest, Volume) { m_pStem4Volume->set(0.4); // Proceed the buffer a first time to proceed the ramping gain - m_pEngineMixer->process(kProcessBufferSize); - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); assertBufferMatchesReference(m_pEngineMixer->getMainBuffer(), QStringLiteral("StemVolumeControlFull")); } @@ -282,8 +282,8 @@ TEST_F(StemControlTest, Mute) { m_pStem4Mute->set(1.0); // Proceed the buffer a first time to proceed the ramping gain - m_pEngineMixer->process(kProcessBufferSize); - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); assertBufferMatchesReference(m_pEngineMixer->getMainBuffer(), QStringLiteral("StemVolumeControlSilence")); // Same than volume test @@ -292,8 +292,8 @@ TEST_F(StemControlTest, Mute) { m_pStem1Mute->set(0.0); // Proceed the buffer a first time to proceed the ramping gain - m_pEngineMixer->process(kProcessBufferSize); - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); assertBufferMatchesReference(m_pEngineMixer->getMainBuffer(), QStringLiteral("StemVolumeControlDrumOnly")); // Same than volume test @@ -302,8 +302,8 @@ TEST_F(StemControlTest, Mute) { m_pStem2Mute->set(0.0); // Proceed the buffer a first time to proceed the ramping gain - m_pEngineMixer->process(kProcessBufferSize); - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); assertBufferMatchesReference(m_pEngineMixer->getMainBuffer(), QStringLiteral("StemMuteControlDrumAndBass")); @@ -313,8 +313,8 @@ TEST_F(StemControlTest, Mute) { m_pStem4Mute->set(0.0); // Proceed the buffer a first time to proceed the ramping gain - m_pEngineMixer->process(kProcessBufferSize); - m_pEngineMixer->process(kProcessBufferSize); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); + m_pEngineMixer->process(kProcessBufferSize, std::chrono::microseconds(0)); assertBufferMatchesReference(m_pEngineMixer->getMainBuffer(), QStringLiteral("StemMuteControlFull")); } diff --git a/src/util/hosttimefilter.h b/src/util/hosttimefilter.h new file mode 100644 index 00000000000..757f9254e87 --- /dev/null +++ b/src/util/hosttimefilter.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include + +// HostTimeFilter is a class that provides a robust, jitter-free host time +// for time points of an auxiliary clock using linear regression. +class HostTimeFilter { + public: + static constexpr std::chrono::microseconds kInvalidHostTime = std::chrono::microseconds::min(); + + explicit HostTimeFilter(const std::size_t numPoints) + : m_numPoints(numPoints), + m_index(0), + m_sumAux(0.0), + m_sumHst(0.0), + m_sumAuxByHst(0.0), + m_sumAuxSquared(0.0) { + m_points.reserve(m_numPoints); + } + + void clear() { + m_index = 0; + m_points.clear(); + m_sumAux = 0.0; + m_sumHst = 0.0; + m_sumAuxByHst = 0.0; + m_sumAuxSquared = 0.0; + } + + // Inserts a new time point consisting of an auxiliary time and a host time. + // These points are used later to calculate the filtered host time using linear regression. + void insertTimePoint( + double auxiliaryTime, std::chrono::microseconds hostTime) { + const auto micros = hostTime.count(); + const auto timePoint = std::make_pair(auxiliaryTime, static_cast(micros)); + + if (m_points.size() < m_numPoints) { + m_points.push_back(timePoint); + m_sumAux += timePoint.first; + m_sumHst += timePoint.second; + m_sumAuxByHst += timePoint.first * timePoint.second; + m_sumAuxSquared += timePoint.first * timePoint.first; + } else { + const auto& prevPoint = m_points[m_index]; + m_sumAux += timePoint.first - prevPoint.first; + m_sumHst += timePoint.second - prevPoint.second; + m_sumAuxByHst += timePoint.first * timePoint.second - + prevPoint.first * prevPoint.second; + m_sumAuxSquared += timePoint.first * timePoint.first - + prevPoint.first * prevPoint.first; + m_points[m_index] = timePoint; + } + m_index = (m_index + 1) % m_numPoints; + } + + // Calculates the host time based on the auxiliary time using linear regression. + // Returns kInvalidHostTime if there are not enough points or if the calculation isn't possible. + std::chrono::microseconds calcHostTime(double auxiliaryTime) const { + if (m_points.size() < 2) { + return kInvalidHostTime; + } + + const double n = static_cast(m_points.size()); + const double denominator = (n * m_sumAuxSquared - m_sumAux * m_sumAux); + if (denominator == 0.0) { + return kInvalidHostTime; + } + + const double slope = (n * m_sumAuxByHst - m_sumAux * m_sumHst) / denominator; + const double intercept = (m_sumHst - slope * m_sumAux) / n; + + return std::chrono::microseconds( + static_cast(slope * auxiliaryTime + intercept)); + } + + private: + const std::size_t m_numPoints; + std::size_t m_index; + std::vector> m_points; + double m_sumAux; + double m_sumHst; + double m_sumAuxByHst; + double m_sumAuxSquared; +}; diff --git a/tools/debian_buildenv.sh b/tools/debian_buildenv.sh index 95ef67967ec..0ed9ba34eec 100755 --- a/tools/debian_buildenv.sh +++ b/tools/debian_buildenv.sh @@ -46,6 +46,7 @@ case "$1" in fi sudo apt-get install -y --no-install-recommends -- \ + ableton-link-dev \ ccache \ cmake \ clazy \