Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ if(SENDSPIN_CLI_BUILD_TESTS)
tests/device_spec_test.cpp
tests/discovery_test.cpp
tests/hooks_test.cpp
tests/null_sink_test.cpp
tests/pcm_ring_test.cpp
tests/pcm_volume_test.cpp
tests/pipewire_sink_test.cpp
Expand Down
3 changes: 2 additions & 1 deletion src/audio_sink.h
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ class AudioSink {
return SinkCapabilities::permissive();
}

/// Reports frames that reached the DAC, for sync feedback; optional for instant sinks.
/// Reports frames that reached the DAC, for sync feedback; a sink that never reports stalls
/// the player before its first chunk, so an instant sink reports against a virtual clock.
/// Assign before start() and never after: backends read it from their audio thread.
std::function<void(uint32_t frames, int64_t timestamp)> on_frames_played;

Expand Down
28 changes: 28 additions & 0 deletions src/null_sink.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

#include <algorithm>
#include <array>
#include <chrono>
#include <cstdio>

namespace sendspin_cli {
Expand All @@ -34,6 +35,14 @@ namespace {
constexpr size_t SILENCE_CHUNK = 4096;
constexpr std::array<uint8_t, SILENCE_CHUNK> SILENCE{};

constexpr int64_t US_PER_S = 1000000;

int64_t now_us() {
return std::chrono::duration_cast<std::chrono::microseconds>(
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) {
Expand All @@ -54,6 +63,7 @@ bool NullAudioSink::configure(uint32_t sample_rate, uint8_t channels, uint8_t bi
const size_t bytes_per_frame =
static_cast<size_t>(channels) * (static_cast<size_t>(bits_per_sample) / 8U);
this->bytes_per_frame_.store(bytes_per_frame);
this->sample_rate_.store(sample_rate);

cli_log(LogLevel::INFO, "%s: stream %u Hz, %u ch, %u-bit (%zu bytes/frame)",
this->name().c_str(), sample_rate, channels, bits_per_sample, bytes_per_frame);
Expand All @@ -70,6 +80,7 @@ size_t NullAudioSink::write(const uint8_t* data, size_t length, uint32_t /*timeo
const bool to_stdout = this->output_ == NullSinkOutput::Stdout && !this->stdout_failed_.load();
if (!to_stdout) {
this->total_bytes_.fetch_add(length);
this->report_played(length);
return length;
}

Expand Down Expand Up @@ -100,6 +111,7 @@ size_t NullAudioSink::write(const uint8_t* data, size_t length, uint32_t /*timeo
}

this->total_bytes_.fetch_add(consumed);
this->report_played(consumed);
return consumed;
}

Expand Down Expand Up @@ -134,4 +146,20 @@ size_t NullAudioSink::total_bytes() const {
return this->total_bytes_.load();
}

void NullAudioSink::report_played(size_t bytes) {
const size_t bytes_per_frame = this->bytes_per_frame_.load();
const uint32_t rate = this->sample_rate_.load();
if (bytes_per_frame == 0 || rate == 0 || !this->on_frames_played) {
return;
}
const auto frames = static_cast<uint32_t>(bytes / bytes_per_frame);
if (frames == 0) {
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()) +
(static_cast<int64_t>(frames) * US_PER_S) / static_cast<int64_t>(rate);
this->on_frames_played(frames, this->playhead_us_);
}

} // namespace sendspin_cli
7 changes: 7 additions & 0 deletions src/null_sink.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum class NullSinkOutput {
};

/// An AudioSink that needs no device; consumes instantly, and honours mute but not volume.
/// Reports playback against a virtual clock that runs ahead of real time by the server's lead.
class NullAudioSink final : public AudioSink {
public:
explicit NullAudioSink(NullSinkOutput output);
Expand All @@ -48,10 +49,16 @@ class NullAudioSink final : public AudioSink {
size_t total_bytes() const;

private:
/// Reports `bytes` as played, finishing on the virtual clock. write()'s thread only.
void report_played(size_t bytes);

NullSinkOutput output_;
std::atomic<size_t> total_bytes_{0};
/// Frame size, so a short stdout write rounds down to a frame boundary.
std::atomic<size_t> bytes_per_frame_{0};
std::atomic<uint32_t> sample_rate_{0};
/// When the last reported frame finishes, in steady-clock microseconds.
int64_t playhead_us_{0};
/// Latches once stdout goes bad; the sink then discards.
std::atomic<bool> stdout_failed_{false};
std::atomic<uint8_t> volume_{DEFAULT_SINK_VOLUME};
Expand Down
75 changes: 75 additions & 0 deletions tests/null_sink_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright 2026 sendspin-cpp-cli Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/// NullAudioSink's playback reports, which the player releases each chunk against.

#include "null_sink.h"

#include <gtest/gtest.h>

#include <cstddef>
#include <cstdint>
#include <vector>

namespace sendspin_cli {
namespace {

struct Report {
uint32_t frames;
int64_t finish_us;
};

/// A discarding sink with every report recorded.
struct Harness {
Harness() {
this->sink.on_frames_played = [this](uint32_t frames, int64_t finish_us) {
this->reports.push_back({frames, finish_us});
};
}

NullAudioSink sink{NullSinkOutput::Discard};
std::vector<Report> reports;
};

/// 480 frames of 16-bit stereo: 10 ms at 48 kHz.
const std::vector<uint8_t> TEN_MS(480 * 4, 0);

TEST(NullSink, EveryWriteIsReportedPlayedOnAClockThatKeepsAdvancing) {
Harness harness;
ASSERT_TRUE(harness.sink.configure(48000, 2, 16));

EXPECT_EQ(harness.sink.write(TEN_MS.data(), TEN_MS.size(), 20), TEN_MS.size());
EXPECT_EQ(harness.sink.write(TEN_MS.data(), TEN_MS.size(), 20), TEN_MS.size());

ASSERT_EQ(harness.reports.size(), 2U);
EXPECT_EQ(harness.reports[0].frames, 480U);
EXPECT_EQ(harness.reports[1].frames, 480U);
// The second write finishes a full write after the first, however fast the two ran.
EXPECT_GE(harness.reports[1].finish_us - harness.reports[0].finish_us, 10000);
}

TEST(NullSink, NothingIsReportedWithoutAStreamOrFrames) {
Harness harness;

EXPECT_EQ(harness.sink.write(TEN_MS.data(), TEN_MS.size(), 20), TEN_MS.size());
ASSERT_TRUE(harness.sink.configure(48000, 2, 16));
EXPECT_EQ(harness.sink.write(TEN_MS.data(), 0, 20), 0U);
harness.sink.stop();
EXPECT_EQ(harness.sink.write(TEN_MS.data(), TEN_MS.size(), 20), TEN_MS.size());

EXPECT_TRUE(harness.reports.empty());
}

} // namespace
} // namespace sendspin_cli
Loading