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
+
+
+ sync_enabled
+ GuiToggleButton
+ 35f,20f
+ Link
+ Link
+ [AbletonLink],sync_enabled
+
+
+ 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 \