From f47ad0058c90bc3c3e8a7dba04d0ecf801227938 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 16 Oct 2026 13:29:57 -0500 Subject: [PATCH] Read the host clock from CLOCK_MONOTONIC_RAW on Linux On Linux, `std::chrono::steady_clock` is `CLOCK_MONOTONIC`, which NTP slews. aiosendspin timestamps on `CLOCK_MONOTONIC_RAW`, so while the host's time daemon corrects the system clock, the offset the time filter measures moves at the slew rate. That rate changes faster than the filter's drift model follows, the sync error crosses the hard sync threshold, and playback hard-corrects. `platform_time_us()` now reads `CLOCK_MONOTONIC_RAW` on Linux and keeps `steady_clock` on other hosts. ESP-IDF is unchanged. `SendspinClient::now_us()` exposes the client clock, so consumers stamp `notify_audio_played()` and `write_audio()` times in the library's clock domain. The examples and the integration guide use it. --- docs/integration-guide.md | 4 ++-- examples/common/portaudio_sink.cpp | 8 ++++---- examples/source_client/main.cpp | 4 +--- examples/tui_client/main.cpp | 13 +++---------- include/sendspin/client.h | 3 +++ src/client.cpp | 5 +++++ src/platform/time.h | 11 ++++++++++- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/integration-guide.md b/docs/integration-guide.md index d352e8dc..e18059f7 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -193,7 +193,7 @@ auto& source = client.add_source(source_config); An invalid config (see [SourceRoleConfig](#sourceroleconfig)) leaves the role inert; a valid config whose buffers cannot be allocated fails `client.start()` instead. An `OPUS` role streams Opus only to a server whose `server/hello` lists it, and the same capture as PCM otherwise. -Feed the capture to `write_audio()` from one capture thread, with the capture time of each buffer's first sample on the client's clock (`std::chrono::steady_clock` on host, `esp_timer_get_time()` on ESP-IDF), or 0 to have the write stamped as ending now: +Feed the capture to `write_audio()` from one capture thread, with the capture time of each buffer's first sample on the client's clock (`SendspinClient::now_us()`), or 0 to have the write stamped as ending now: ```cpp source.write_audio(pcm_bytes, len, capture_time_us); @@ -276,7 +276,7 @@ player.notify_audio_played(frames_played, current_timestamp_us); ``` - `frames_played`: Number of audio frames (not bytes) just played -- `timestamp`: Client timestamp in microseconds when the audio will finish playing (e.g., from `std::chrono::steady_clock`) +- `timestamp`: Client timestamp in microseconds when the audio will finish playing (e.g., from `SendspinClient::now_us()`) This method is thread-safe and is expected to be called from an audio callback thread. diff --git a/examples/common/portaudio_sink.cpp b/examples/common/portaudio_sink.cpp index 10e73c50..cdc5f692 100644 --- a/examples/common/portaudio_sink.cpp +++ b/examples/common/portaudio_sink.cpp @@ -14,6 +14,8 @@ #include "portaudio_sink.h" +#include "sendspin/client.h" + #include #include #include @@ -314,9 +316,7 @@ void PortAudioSink::clear() { int PortAudioSink::pa_callback(const void* /*input*/, void* output, unsigned long frame_count, const PaStreamCallbackTimeInfo* time_info, PaStreamCallbackFlags /*status_flags*/, void* user_data) { - int64_t now_us = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); + int64_t now_us = SendspinClient::now_us(); auto* self = static_cast(user_data); size_t bytes_requested = frame_count * self->bytes_per_frame_; @@ -339,7 +339,7 @@ int PortAudioSink::pa_callback(const void* /*input*/, void* output, unsigned lon // outputBufferDacTime is when the first sample of this buffer will hit the DAC. // We compute the finish time (when the last frame exits the DAC) by adding // the frame duration. The offset from currentTime to DAC time is converted - // from PA's clock domain to esp_timer's clock domain. + // from PA's clock domain to the client clock. if (bytes_read > 0 && self->on_frames_played) { uint32_t frames_played = static_cast(bytes_read / self->bytes_per_frame_); diff --git a/examples/source_client/main.cpp b/examples/source_client/main.cpp index 852bb9f3..12a5d904 100644 --- a/examples/source_client/main.cpp +++ b/examples/source_client/main.cpp @@ -273,9 +273,7 @@ class PortAudioCapture { if (time_info != nullptr && time_info->currentTime > 0 && time_info->inputBufferAdcTime > 0 && time_info->inputBufferAdcTime <= time_info->currentTime) { - int64_t now_us = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); + int64_t now_us = SendspinClient::now_us(); double age_us = (time_info->currentTime - time_info->inputBufferAdcTime) * 1000000.0; capture_us = now_us - static_cast(std::round(age_us)); } diff --git a/examples/tui_client/main.cpp b/examples/tui_client/main.cpp index c28ffd63..44f9ed90 100644 --- a/examples/tui_client/main.cpp +++ b/examples/tui_client/main.cpp @@ -90,13 +90,6 @@ using namespace sendspin; static constexpr uint16_t DEFAULT_SENDSPIN_PORT = SendspinClientConfig::DEFAULT_SERVER_PORT; static const char* SENDSPIN_PATH = "/sendspin"; -// Big-endian helpers for binary visualizer data parsing -static int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - // Null audio write callback (used when PortAudio is not available) static size_t null_audio_write(uint8_t*, size_t length, uint32_t) { return length; @@ -929,14 +922,14 @@ int main(int argc, char* argv[]) { void on_beat(int64_t /*client_timestamp*/, bool /*downbeat*/) override { std::lock_guard lock(state.mutex); - int64_t current_us = now_us(); + int64_t current_us = SendspinClient::now_us(); state.vis_beat = true; state.vis_beat_expire_us = current_us + 100000; // 100ms flash } void on_peak(int64_t /*client_timestamp*/, uint8_t /*strength*/) override { std::lock_guard lock(state.mutex); - int64_t current_us = now_us(); + int64_t current_us = SendspinClient::now_us(); state.vis_peak = true; state.vis_peak_expire_us = current_us + 100000; // 100ms flash } @@ -1080,7 +1073,7 @@ int main(int argc, char* argv[]) { // Smooth visualizer display values every tick (10ms), only when visible if (vis_showing) { - int64_t current_us = now_us(); + int64_t current_us = SendspinClient::now_us(); std::lock_guard lock(state.mutex); // Smooth decay: display values chase target values each tick. diff --git a/include/sendspin/client.h b/include/sendspin/client.h index 06a6f9c6..665472d7 100644 --- a/include/sendspin/client.h +++ b/include/sendspin/client.h @@ -318,6 +318,9 @@ class SendspinClient { /// @brief Returns the current log level (host builds only, INFO on ESP-IDF) static LogLevel get_log_level(); + /// @brief Returns the current time on the client clock in microseconds. Any thread. + static int64_t now_us(); + // ======================================== // Lifecycle // ======================================== diff --git a/src/client.cpp b/src/client.cpp index 04c8545e..ae2ce5a9 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -28,6 +28,7 @@ #include "platform/logging.h" #include "platform/memory.h" #include "platform/network_info.h" +#include "platform/time.h" #include "record_store.h" #include "teardown_tracker.h" #ifdef SENDSPIN_ENABLE_ARTWORK @@ -321,6 +322,10 @@ LogLevel SendspinClient::get_log_level() { return static_cast(platform_get_log_level()); } +int64_t SendspinClient::now_us() { + return platform_time_us(); +} + // ============================================================================ // Lifecycle // ============================================================================ diff --git a/src/platform/time.h b/src/platform/time.h index df16896d..3d9ae353 100644 --- a/src/platform/time.h +++ b/src/platform/time.h @@ -50,14 +50,23 @@ inline TickType_t platform_ms_to_ticks(uint32_t timeout_ms) { #else // Host #include +#include namespace sendspin { /// @brief Returns monotonic time in microseconds -/// @return Microseconds elapsed since an arbitrary epoch (steady_clock). +/// @return Microseconds elapsed since an arbitrary epoch (CLOCK_MONOTONIC_RAW on Linux, +/// steady_clock elsewhere). inline int64_t platform_time_us() { +#if defined(__linux__) + timespec ts{}; + clock_gettime(CLOCK_MONOTONIC_RAW, &ts); + auto now = std::chrono::seconds(ts.tv_sec) + std::chrono::nanoseconds(ts.tv_nsec); + return std::chrono::duration_cast(now).count(); +#else auto now = std::chrono::steady_clock::now(); return std::chrono::duration_cast(now.time_since_epoch()).count(); +#endif } } // namespace sendspin