From 559f12230d718f73596d40d44f93d815fb282ad3 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 21 Oct 2026 08:34:13 -0500 Subject: [PATCH] Pin sendspin-cpp to 90cbd808 and stamp audio on the client clock sendspin-cpp #208 moves the library's clock to CLOCK_MONOTONIC_RAW on Linux, so an NTP slew no longer drags the measured server offset. Every timestamp we hand it -- notify_audio_played() from the sinks, write_audio() from the sources -- must be on that clock too, so each backend now reads SendspinClient::now_us() instead of its own steady_clock helper. The PipeWire source translated pw_time::now as if it were ours; it now carries that reading's age on CLOCK_MONOTONIC across instead. Pinned to the commit until v0.10.0 is tagged. --- CMakeLists.txt | 2 +- docs/ROADMAP.md | 2 +- src/alsa_sink.cpp | 10 +++------- src/alsa_source.cpp | 12 ++++-------- src/coreaudio_sink.cpp | 11 ++++------- src/coreaudio_source.cpp | 11 ++++------- src/null_sink.cpp | 10 ++-------- src/null_source.cpp | 19 +++++++++---------- src/pipewire_sink.cpp | 11 ++++------- src/pipewire_source.cpp | 17 +++++++++++++++-- src/portaudio_sink.cpp | 11 ++++------- src/portaudio_source.cpp | 15 ++++++--------- src/pulse_sink.cpp | 11 ++++------- src/pulse_source.cpp | 12 ++++-------- 14 files changed, 65 insertions(+), 89 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 250ad26..2bc5393 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,7 +31,7 @@ endif() # sendspin-cpp -set(SENDSPIN_GIT_TAG "v0.9.5" CACHE STRING +set(SENDSPIN_GIT_TAG "90cbd80864bf763fb6a4100f23b1ad2e128c8a75" CACHE STRING "sendspin-cpp git tag or commit to build against") # player, metadata, controller (group transport verbs) and source; no FORCE, so -D overrides still win. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 29f06a5..74d4bc5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,7 +34,7 @@ passing such a URL today. ### 3. Upstream gaps in sendspin-cpp Each needs a library change; nothing this repo can call fixes them. Re-check on every -`SENDSPIN_GIT_TAG` bump (currently v0.9.5). +`SENDSPIN_GIT_TAG` bump (currently `90cbd808`, past v0.9.5). - **No log sink hook.** The host `SS_LOG*` macros are bare `fprintf(stderr, ...)` gated on one global level. So library lines carry no timestamp under `-f`, cannot be filtered per tag diff --git a/src/alsa_sink.cpp b/src/alsa_sink.cpp index 61f9a82..afd34d5 100644 --- a/src/alsa_sink.cpp +++ b/src/alsa_sink.cpp @@ -36,6 +36,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -79,12 +80,6 @@ void alsa_error_handler(const char* file, int line, const char* function, int er (err != 0) ? snd_strerror(err) : ""); } -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// Maps the stream's bit depth onto the interleaved little-endian PCM format ALSA wants. bool alsa_format_for(uint8_t bits_per_sample, snd_pcm_format_t& format) { for (size_t i = 0; i < PROBE_BIT_DEPTHS.size(); ++i) { @@ -632,7 +627,8 @@ size_t AlsaAudioSink::write(const uint8_t* data, size_t length, uint32_t timeout if (frames_done > 0 && this->pcm_ != nullptr && this->rate_ > 0) { snd_pcm_sframes_t delay = 0; if (snd_pcm_delay(this->pcm_, &delay) == 0 && delay >= 0) { - finish_us = now_us() + ((static_cast(delay) * 1000000) / this->rate_); + finish_us = SendspinClient::now_us() + + ((static_cast(delay) * 1000000) / this->rate_); have_timing = true; // Taken with the timestamp under the same lock, so nothing reports between them. gap_frames = this->recovery_.take_discarded_frames(); diff --git a/src/alsa_source.cpp b/src/alsa_source.cpp index b3289a1..3466931 100644 --- a/src/alsa_source.cpp +++ b/src/alsa_source.cpp @@ -17,13 +17,15 @@ #include "alsa_sink.h" #include "log.h" -#include +#include + #include #include namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -35,12 +37,6 @@ constexpr unsigned int PERIOD_TIME_US = 20000; constexpr int64_t US_PER_S = 1000000; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// ALSA's packed little-endian format for a capture bit depth. snd_pcm_format_t alsa_format_for(uint8_t bit_depth) { switch (bit_depth) { @@ -247,7 +243,7 @@ int AlsaAudioSource::read(uint8_t* data, size_t length, uint32_t timeout_ms, // Frames queued ahead of this read: the oldest is the first sample returned. snd_pcm_sframes_t delay = 0; - const int64_t now = now_us(); + const int64_t now = SendspinClient::now_us(); const bool have_delay = snd_pcm_delay(this->pcm_, &delay) == 0 && delay >= 0; const snd_pcm_sframes_t frames = diff --git a/src/coreaudio_sink.cpp b/src/coreaudio_sink.cpp index 2ddbaa8..1cb4a5a 100644 --- a/src/coreaudio_sink.cpp +++ b/src/coreaudio_sink.cpp @@ -18,6 +18,8 @@ #include "log.h" #include "pcm_volume.h" +#include + #include #include @@ -32,6 +34,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -47,12 +50,6 @@ constexpr size_t MIN_RING_FRAMES = 1024; constexpr std::array PROBE_FORMAT_NAMES{"SInt8", "SInt16", "SInt24", "SInt32"}; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// Asks the unit whether it will take this exact format on its input scope; the AU converts on /// top of what the device itself does, so this is what a stream would really get. bool unit_accepts(AudioUnit unit, uint32_t sample_rate, uint8_t channels, uint8_t bits) { @@ -863,7 +860,7 @@ OSStatus CoreAudioSink::property_listener(AudioObjectID /*object*/, UInt32 count OSStatus CoreAudioSink::render_callback(void* user_data, AudioUnitRenderActionFlags* /*flags*/, const AudioTimeStamp* timestamp, UInt32 /*bus*/, UInt32 frames, AudioBufferList* data) { - const int64_t entered_us = now_us(); + const int64_t entered_us = SendspinClient::now_us(); const uint64_t entered_host = mach_absolute_time(); auto* self = static_cast(user_data); diff --git a/src/coreaudio_source.cpp b/src/coreaudio_source.cpp index 9a90e55..0347afc 100644 --- a/src/coreaudio_source.cpp +++ b/src/coreaudio_source.cpp @@ -18,6 +18,8 @@ #include "coreaudio_permission.h" #include "log.h" +#include + #include #include @@ -28,6 +30,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -48,12 +51,6 @@ constexpr uint64_t TAP_SILENCE_WARN_S = 10; constexpr UInt32 INPUT_BUS = 1; constexpr UInt32 OUTPUT_BUS = 0; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// The device's own rate as a whole number of Hz; 0 when it cannot be read. uint32_t device_rate(AudioDeviceID device) { return static_cast(std::llround(ca_nominal_sample_rate(device))); @@ -539,7 +536,7 @@ OSStatus CoreAudioSource::property_listener(AudioObjectID /*object*/, UInt32 cou OSStatus CoreAudioSource::input_callback(void* user_data, AudioUnitRenderActionFlags* flags, const AudioTimeStamp* timestamp, UInt32 bus, UInt32 frames, AudioBufferList* /*data*/) { - const int64_t entered_us = now_us(); + const int64_t entered_us = SendspinClient::now_us(); const uint64_t entered_host = mach_absolute_time(); auto* self = static_cast(user_data); diff --git a/src/null_sink.cpp b/src/null_sink.cpp index 89bc9ac..bba158e 100644 --- a/src/null_sink.cpp +++ b/src/null_sink.cpp @@ -20,12 +20,12 @@ #include #include -#include #include namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -37,12 +37,6 @@ constexpr std::array SILENCE{}; constexpr int64_t US_PER_S = 1000000; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// Rounds `bytes` down to whole frames; a frame size of 0 means no stream, so no alignment. size_t align_to_frame(size_t bytes, size_t bytes_per_frame) { if (bytes_per_frame == 0) { @@ -157,7 +151,7 @@ void NullAudioSink::report_played(size_t bytes) { return; } // Never behind real time, so a stream that paused resumes from now rather than the past. - this->playhead_us_ = std::max(this->playhead_us_, now_us()) + + this->playhead_us_ = std::max(this->playhead_us_, SendspinClient::now_us()) + (static_cast(frames) * US_PER_S) / static_cast(rate); this->on_frames_played(frames, this->playhead_us_); } diff --git a/src/null_source.cpp b/src/null_source.cpp index d796aae..c1f3b1a 100644 --- a/src/null_source.cpp +++ b/src/null_source.cpp @@ -14,6 +14,8 @@ #include "null_source.h" +#include + #include #include #include @@ -22,6 +24,8 @@ namespace sendspin_cli { +using sendspin::SendspinClient; + namespace { constexpr double TONE_HZ = 440.0; @@ -29,12 +33,6 @@ constexpr double TONE_AMPLITUDE = 0.25; constexpr double TWO_PI = 6.283185307179586; constexpr int64_t US_PER_S = 1000000; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - } // namespace NullAudioSource::NullAudioSource(NullSourceSignal signal) : signal_(signal) {} @@ -60,7 +58,7 @@ bool NullAudioSource::open(const StreamFormat& format) { this->format_ = format; this->bytes_per_frame_ = static_cast(format.channels) * (static_cast(format.bit_depth) / 8U); - this->start_us_ = now_us(); + this->start_us_ = SendspinClient::now_us(); this->frames_ = 0; return this->bytes_per_frame_ != 0 && format.sample_rate != 0; } @@ -77,12 +75,13 @@ int NullAudioSource::read(uint8_t* data, size_t length, uint32_t timeout_ms, }; // Wait for the whole read to have "happened", as a device would. - const int64_t wait_us = std::min(time_of(this->frames_ + wanted) - now_us(), - static_cast(timeout_ms) * 1000); + const int64_t wait_us = + std::min(time_of(this->frames_ + wanted) - SendspinClient::now_us(), + static_cast(timeout_ms) * 1000); if (wait_us > 0) { std::this_thread::sleep_for(std::chrono::microseconds(wait_us)); } - const int64_t elapsed_frames = (now_us() - this->start_us_) * rate / US_PER_S; + const int64_t elapsed_frames = (SendspinClient::now_us() - this->start_us_) * rate / US_PER_S; const int64_t due = elapsed_frames - static_cast(this->frames_); if (due <= 0) { return 0; diff --git a/src/pipewire_sink.cpp b/src/pipewire_sink.cpp index 8f34324..74fd2eb 100644 --- a/src/pipewire_sink.cpp +++ b/src/pipewire_sink.cpp @@ -17,6 +17,8 @@ #include "log.h" #include "pcm_volume.h" +#include + #include #include #include @@ -34,6 +36,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -42,12 +45,6 @@ namespace { /// The name this stream carries in the graph. constexpr const char* PIPEWIRE_APP_NAME = "sendspin-cli"; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// SPA format per PROBE_BIT_DEPTHS entry, exactly as the player emits; PipeWire converts. constexpr std::array PROBE_FORMATS{ SPA_AUDIO_FORMAT_S8, SPA_AUDIO_FORMAT_S16_LE, SPA_AUDIO_FORMAT_S24_LE, SPA_AUDIO_FORMAT_S32_LE}; @@ -588,7 +585,7 @@ void PipeWireSink::stream_state_cb(void* userdata, enum pw_stream_state /*old*/, void PipeWireSink::stream_process_cb(void* userdata) { auto* self = static_cast(userdata); - const int64_t entered_us = now_us(); + const int64_t entered_us = SendspinClient::now_us(); // Safe unlocked: pw_stream_disconnect() waits for this callback to return. pw_buffer* buffer = pw_stream_dequeue_buffer(self->stream_); diff --git a/src/pipewire_source.cpp b/src/pipewire_source.cpp index a311515..0fcff74 100644 --- a/src/pipewire_source.cpp +++ b/src/pipewire_source.cpp @@ -16,6 +16,8 @@ #include "log.h" +#include + #include #include #include @@ -23,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +34,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -49,6 +53,13 @@ constexpr uint32_t RING_MS = 1000; constexpr int64_t US_PER_S = 1000000; +/// CLOCK_MONOTONIC, the clock pw_time::now is on, in microseconds. +int64_t monotonic_us() { + timespec ts{}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (static_cast(ts.tv_sec) * US_PER_S) + (ts.tv_nsec / 1000); +} + /// SPA's packed little-endian format for a capture bit depth. spa_audio_format spa_format_for(uint8_t bit_depth) { switch (bit_depth) { @@ -340,8 +351,10 @@ void PipeWireAudioSource::stream_process_cb(void* userdata) { static_cast(time.rate.denom)) + (static_cast(time.buffered + frames) / static_cast(self->rate_)); - // `now` is CLOCK_MONOTONIC, which is what steady_clock reads here. - const int64_t first_us = (time.now / 1000) - static_cast(age_s * 1e6); + // `now` is on CLOCK_MONOTONIC; its age there carries onto the client clock. + const int64_t graph_age_us = monotonic_us() - (time.now / 1000); + const int64_t first_us = + SendspinClient::now_us() - graph_age_us - static_cast(age_s * 1e6); self->origin_us_.store(first_us - static_cast(self->frames_written_) * US_PER_S / self->rate_); } diff --git a/src/portaudio_sink.cpp b/src/portaudio_sink.cpp index 1e74021..80acd5d 100644 --- a/src/portaudio_sink.cpp +++ b/src/portaudio_sink.cpp @@ -17,6 +17,8 @@ #include "log.h" #include "pcm_volume.h" +#include + #include #include #include @@ -28,6 +30,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -39,12 +42,6 @@ constexpr double RING_LATENCY_MULTIPLE = 3.0; /// Absolute floor on the ring, for a device that reports no latency at all. constexpr size_t MIN_RING_FRAMES = 1024; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// How -l names each PA_FORMATS entry. constexpr std::array PROBE_FORMAT_NAMES{"paInt8", "paInt16", "paInt24", "paInt32"}; @@ -705,7 +702,7 @@ void PortAudioSink::update_target_multiplier_() { int PortAudioSink::pa_callback(const void* /*input*/, void* output, unsigned long frame_count, const PaStreamCallbackTimeInfo* time_info, PaStreamCallbackFlags /*status_flags*/, void* user_data) { - const int64_t entered_us = now_us(); + const int64_t entered_us = SendspinClient::now_us(); auto* self = static_cast(user_data); auto* out = static_cast(output); diff --git a/src/portaudio_source.cpp b/src/portaudio_source.cpp index 85094ac..7059a30 100644 --- a/src/portaudio_source.cpp +++ b/src/portaudio_source.cpp @@ -20,6 +20,8 @@ #include "coreaudio_permission.h" #endif +#include + #include #include #include @@ -30,6 +32,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -46,12 +49,6 @@ constexpr int64_t STALL_US = 2000000; constexpr int64_t US_PER_S = 1000000; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - PaStreamParameters input_params(PaDeviceIndex device, const PaDeviceInfo* info, uint8_t channels, PaSampleFormat format) { PaStreamParameters params = {}; @@ -251,7 +248,7 @@ bool PortAudioSource::open(const StreamFormat& format) { // The spare byte the ring keeps to tell full from empty. this->ring_.reset((ring_frames * this->bytes_per_frame_) + 1); - this->opened_us_ = now_us(); + this->opened_us_ = SendspinClient::now_us(); err = Pa_StartStream(this->stream_); pa_lock.unlock(); if (err != paNoError) { @@ -286,7 +283,7 @@ int PortAudioSource::read(uint8_t* data, size_t length, uint32_t timeout_ms, } // Some host APIs keep a stream on a vanished device active but never call back again. const int64_t last_us = this->last_callback_us_.load(); - if (now_us() - (last_us != 0 ? last_us : this->opened_us_) > STALL_US) { + if (SendspinClient::now_us() - (last_us != 0 ? last_us : this->opened_us_) > STALL_US) { cli_log(LogLevel::ERROR, "portaudio: input '%s' stopped delivering audio", this->name().c_str()); return -1; @@ -331,7 +328,7 @@ void PortAudioSource::close() { int PortAudioSource::pa_callback(const void* input, void* /*output*/, unsigned long frame_count, const PaStreamCallbackTimeInfo* time_info, PaStreamCallbackFlags /*status_flags*/, void* user_data) { - const int64_t entered_us = now_us(); + const int64_t entered_us = SendspinClient::now_us(); auto* self = static_cast(user_data); self->last_callback_us_.store(entered_us); diff --git a/src/pulse_sink.cpp b/src/pulse_sink.cpp index 26e1c5e..44ddc85 100644 --- a/src/pulse_sink.cpp +++ b/src/pulse_sink.cpp @@ -17,6 +17,8 @@ #include "log.h" #include "pcm_volume.h" +#include + #include #include #include @@ -28,6 +30,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -39,12 +42,6 @@ constexpr int WRITE_SLICE_MS = 10; /// The application name the server shows in its mixer. constexpr const char* PULSE_APP_NAME = "sendspin-cli"; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// PulseAudio format per PROBE_BIT_DEPTHS entry; signed 8-bit goes out as sign-flipped U8. constexpr std::array PROBE_FORMATS{ PA_SAMPLE_U8, PA_SAMPLE_S16LE, PA_SAMPLE_S24LE, PA_SAMPLE_S32LE}; @@ -468,7 +465,7 @@ size_t PulseAudioSink::write(const uint8_t* data, size_t length, uint32_t timeou pa_usec_t latency_us = 0; int negative = 0; if (pa_stream_get_latency(this->stream_, &latency_us, &negative) == 0 && negative == 0) { - finish_us = now_us() + static_cast(latency_us); + finish_us = SendspinClient::now_us() + static_cast(latency_us); have_timing = true; // Taken with the timestamp, under the same lock, so no report can land between the // outage being retired and the first real playback being reported. diff --git a/src/pulse_source.cpp b/src/pulse_source.cpp index 6a36d27..5903c7e 100644 --- a/src/pulse_source.cpp +++ b/src/pulse_source.cpp @@ -16,8 +16,9 @@ #include "log.h" +#include + #include -#include #include #include #include @@ -25,6 +26,7 @@ namespace sendspin_cli { using sendspin::LogLevel; +using sendspin::SendspinClient; static constexpr const char* LOG_TAG = LOG_TAG_AUDIO; @@ -38,12 +40,6 @@ constexpr pa_usec_t FRAGMENT_US = 20000; constexpr int64_t US_PER_S = 1000000; -int64_t now_us() { - return std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - /// PulseAudio's packed little-endian format for a capture bit depth. pa_sample_format_t pulse_format_for(uint8_t bit_depth) { switch (bit_depth) { @@ -397,7 +393,7 @@ int PulseAudioSource::drain_(uint8_t* data, size_t length, int64_t& capture_time const bool timed = pa_stream_get_latency(this->stream_, &latency_us, &negative) == 0 && negative == 0; // The read index only moves with a whole fragment, so the part already returned is added back. - const int64_t first_us = now_us() - static_cast(latency_us) + + const int64_t first_us = SendspinClient::now_us() - static_cast(latency_us) + static_cast(this->fragment_offset_ / this->bytes_per_frame_) * US_PER_S / this->rate_;