diff --git a/CMakeLists.txt b/CMakeLists.txt index 323fd4fefa18..b4ad8d593d49 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1680,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 @@ -2442,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 diff --git a/src/soundio/sounddevicenetwork.cpp b/src/soundio/sounddevicenetwork.cpp index 09e426ec176b..73477d1de071 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; @@ -520,10 +527,14 @@ void SoundDeviceNetwork::updateCallbackEntryToDacTime(SINT framesPerBuffer) { double callbackEntrytoDacSecs = (m_targetTime - currentTime) / 1000000.0; callbackEntrytoDacSecs = math_max(callbackEntrytoDacSecs, 0.0001); - // Use Ableton's HostTimeFilter class to create a smooth linear regression + // Use HostTimeFilter class to create a smooth linear regression // between absolute network time and absolute host time - m_absTimeWhenPrevOutputBufferReachesDac = m_hostTimeFilter.sampleTimeToHostTime( - static_cast(currentTime)) + + + auto hostTime = std::chrono::duration_cast( + ClockT::now().time_since_epoch()); + + m_absTimeWhenPrevOutputBufferReachesDac = m_hostTimeFilter.calcFilteredHostTime( + static_cast(currentTime), hostTime) + std::chrono::microseconds(static_cast(callbackEntrytoDacSecs * 1000000)); VisualPlayPosition::setCallbackEntryToDacSecs(callbackEntrytoDacSecs, m_clkRefTimer); diff --git a/src/soundio/sounddevicenetwork.h b/src/soundio/sounddevicenetwork.h index caf83eebc888..61f9b7635fc1 100644 --- a/src/soundio/sounddevicenetwork.h +++ b/src/soundio/sounddevicenetwork.h @@ -3,8 +3,6 @@ #include #include #include -#include -#include #ifdef __LINUX__ #include @@ -16,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 @@ -25,13 +24,6 @@ class SoundManager; class EngineNetworkStream; class SoundDeviceNetworkThread; -// 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 SoundDeviceNetwork : public SoundDevice { public: SoundDeviceNetwork(UserSettingsPointer config, @@ -78,7 +70,7 @@ class SoundDeviceNetwork : public SoundDevice { qint64 m_targetTime; PerformanceTimer m_clkRefTimer; - ableton::link::HostTimeFilter m_hostTimeFilter; + mixxx::HostTimeFilter m_hostTimeFilter; }; class SoundDeviceNetworkThread : public QThread { diff --git a/src/soundio/sounddeviceportaudio.cpp b/src/soundio/sounddeviceportaudio.cpp index 5d738f2c8c27..47b5b66d9d85 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 @@ -99,8 +105,9 @@ SoundDevicePortAudio::SoundDevicePortAudio(UserSettingsPointer config, m_invalidTimeInfoCount(0), m_lastCallbackEntrytoDacSecs(0), m_callbackResult(paAbort), + m_hostTimeFilter(512), m_cummulatedBufferTime(0), - m_meanOutputLatency(MovingInterquartileMean(501)) { + m_meanOutputLatency(MovingInterquartileMean(512)) { // Setting parent class members: m_hostAPI = Pa_GetHostApiInfo(deviceInfo->hostApi)->name; m_sampleRate = mixxx::audio::SampleRate::fromDouble(deviceInfo->defaultSampleRate); @@ -1090,14 +1097,16 @@ void SoundDevicePortAudio::updateCallbackEntryToDacTime( - timeInfo->currentTime; double bufferSizeSec = framesPerBuffer / m_sampleRate.toDouble(); - // Use Ableton's HostTimeFilter class to create a smooth linear regression + // 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 filteredHostTimeNow = - m_hostTimeFilter.sampleTimeToHostTime(m_cummulatedBufferTime); + auto hostTime = std::chrono::duration_cast( + ClockT::now().time_since_epoch()); + auto filteredHostTimeNow = m_hostTimeFilter.calcFilteredHostTime( + m_cummulatedBufferTime, hostTime); qWarning() << "Pa_GetStreamTime: " << static_cast(soundCardTimeNow * 1000000) diff --git a/src/soundio/sounddeviceportaudio.h b/src/soundio/sounddeviceportaudio.h index 029b8abef046..405e671e9246 100644 --- a/src/soundio/sounddeviceportaudio.h +++ b/src/soundio/sounddeviceportaudio.h @@ -3,8 +3,6 @@ #include #include -#include -#include #include #include "control/pollingcontrolproxy.h" @@ -12,18 +10,12 @@ #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; -// 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 SoundDevicePortAudio : public SoundDevice { public: SoundDevicePortAudio(UserSettingsPointer config, @@ -96,7 +88,7 @@ class SoundDevicePortAudio : public SoundDevice { PaTime m_lastCallbackEntrytoDacSecs; std::atomic m_callbackResult; - ableton::link::HostTimeFilter m_hostTimeFilter; + mixxx::HostTimeFilter m_hostTimeFilter; double m_cummulatedBufferTime; MovingInterquartileMean m_meanOutputLatency; }; diff --git a/src/test/hosttimefilter_test.cpp b/src/test/hosttimefilter_test.cpp new file mode 100644 index 000000000000..dee226b404ef --- /dev/null +++ b/src/test/hosttimefilter_test.cpp @@ -0,0 +1,85 @@ +#include "util/hosttimefilter.h" + +#include + +#include + +using namespace std::chrono_literals; + +namespace mixxx { + +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.calcFilteredHostTime(0.0, 0us), 0us); +} + +TEST_F(HostTimeFilterTest, AddSinglePoint) { + EXPECT_NEAR(m_filter.calcFilteredHostTime(1.0, 1050us).count(), 1050, 1); +} + +TEST_F(HostTimeFilterTest, EqualFreqNoJitter) { + // Wo perfectly synced clocks, the filter should return the same host time as the auxiliary time + EXPECT_NEAR(m_filter.calcFilteredHostTime(1000.0, 1000us).count(), 1000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(2000.0, 2000us).count(), 2000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(3000.0, 3000us).count(), 3000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(4000.0, 4000us).count(), 4000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(5000.0, 5000us).count(), 5000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(6000.0, 6000us).count(), 6000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(7000.0, 7000us).count(), 7000, 1); +} + +TEST_F(HostTimeFilterTest, FasterFreqNoJitter) { + // Use 1024 sample buffer interval, instead of auxiliarry clock in time units + EXPECT_NEAR(m_filter.calcFilteredHostTime(1024.0, 1000us).count(), 1000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(2048.0, 2000us).count(), 2000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(3072.0, 3000us).count(), 3000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(4096.0, 4000us).count(), 4000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(5120.0, 5000us).count(), 5000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(6144.0, 6000us).count(), 6000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(7168.0, 7000us).count(), 7000, 1); +} + +TEST_F(HostTimeFilterTest, FasterFreqWithJitter) { + // Use 1024 sample buffer interval, with 100us host time jitter + EXPECT_NEAR(m_filter.calcFilteredHostTime(1024.0, 1000us).count(), 1000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(2048.0, 2100us).count(), 2100, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(3072.0, 3000us).count(), 3033, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(4096.0, 3900us).count(), 3940, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(5120.0, 5000us).count(), 4960, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(6144.0, 6000us).count(), 5960, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(7168.0, 7000us).count(), 7000, 1); +} + +TEST_F(HostTimeFilterTest, FasterFreqSkippedPoints) { + // Use 1024 sample buffer interval, instead of auxiliarry clock in time units + EXPECT_NEAR(m_filter.calcFilteredHostTime(1024.0, 1000us).count(), 1000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(2048.0, 2000us).count(), 2000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(4096.0, 4000us).count(), 4000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(5120.0, 5000us).count(), 5000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(8192.0, 8000us).count(), 8000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(9216.0, 9000us).count(), 9000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(11264.0, 11000us).count(), 11000, 1); +} + +TEST_F(HostTimeFilterTest, Reset) { + m_filter.calcFilteredHostTime(1.0, 1050us); + m_filter.calcFilteredHostTime(2.0, 1950us); + m_filter.reset(); + EXPECT_NEAR(m_filter.calcFilteredHostTime(4.0, 7777us).count(), 7777, 1); +} + +TEST_F(HostTimeFilterTest, DenominatorZero) { + // Add two identical points to ensure the denominator becomes zero + EXPECT_NEAR(m_filter.calcFilteredHostTime(1.0, 1000us).count(), 1000, 1); + EXPECT_NEAR(m_filter.calcFilteredHostTime(1.0, 1000us).count(), 1000, 1); +} + +} // namespace mixxx diff --git a/src/util/hosttimefilter.h b/src/util/hosttimefilter.h new file mode 100644 index 000000000000..24844ab5570a --- /dev/null +++ b/src/util/hosttimefilter.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include + +namespace mixxx { + +class HostTimeFilter { + public: + 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 reset() { + m_index = 0; + m_points.clear(); + m_sumAux = 0.0; + m_sumHst = 0.0; + m_sumAuxByHst = 0.0; + m_sumAuxSquared = 0.0; + } + + std::chrono::microseconds calcFilteredHostTime( + 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; + + return linearRegression(timePoint); + } + + 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; + + std::chrono::microseconds linearRegression(const std::pair& timePoint) const { + if (m_points.size() < 2) { + return std::chrono::microseconds(static_cast(timePoint.second)); + } + + const double n = static_cast(m_points.size()); + const double denominator = (n * m_sumAuxSquared - m_sumAux * m_sumAux); + if (denominator == 0.0) { + return std::chrono::microseconds(static_cast(timePoint.second)); + } + + 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 * timePoint.first + intercept)); + } +}; + +} // namespace mixxx