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
4 changes: 3 additions & 1 deletion docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ A role configured with `line_sense` reports the capture input's signal state fro
source.set_signal(SourceSignal::PRESENT); // or ABSENT
```

`examples/source_client/` is a working reference: it captures the default PortAudio input device and feeds `write_audio()` from the capture callback with each buffer's capture time on the client's clock.
Detecting the signal is up to the application (a jack-detect pin, a link state, or a level meter on the input); call `set_signal()` on each change. The server decides what to do with it (roles/source/v1.md "Default streaming behavior").

`examples/source_client/` is a working reference: it captures the default PortAudio input device and feeds `write_audio()` from the capture callback with each buffer's capture time on the client's clock. With `-s` it also reports line sense from the input's level.

## Step 3: Implement Listener Interfaces

Expand Down
7 changes: 6 additions & 1 deletion examples/source_client/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Source Client Example

Runs the sendspin-cpp client with the source role on a host computer (macOS/Linux), capturing the default PortAudio input device and streaming it to a Sendspin server. The server gates streaming: capture starts on its start command and stops on its stop. When built with mDNS support, it advertises via mDNS so Sendspin servers discover and connect automatically; otherwise connect to a server manually with `-u ws://<server-host>:<port>/<path>`.
Runs the sendspin-cpp client with the source role on a host computer (macOS/Linux), capturing the default PortAudio input device and streaming it to a Sendspin server. The server gates streaming: audio flows from its start command until its stop. When built with mDNS support, it advertises via mDNS so Sendspin servers discover and connect automatically; otherwise connect to a server manually with `-u ws://<server-host>:<port>/<path>`.

## Build

Expand Down Expand Up @@ -39,6 +39,7 @@ macOS has mDNS support built in; no extra dependencies needed.
./build/examples/source_client/source_client "Line In" # custom name
./build/examples/source_client/source_client -o # stream Opus instead of PCM
./build/examples/source_client/source_client -p 8930 # listen on a custom port
./build/examples/source_client/source_client -s -50 # report line sense at -50 dBFS
```

The client listens on port 8928 by default. When mDNS is enabled it advertises `_sendspin._tcp` with the configured port so Sendspin servers on the local network discover and connect automatically; otherwise tell the server to connect with `ws://<this-host>:8928/sendspin`, replacing `8928` if you passed `-p`.
Expand All @@ -47,6 +48,10 @@ Audio is captured at 48 kHz / 16-bit from the default input device (mono or ster

Press Ctrl+C to stop.

## Line sense

With `-s DBFS` the client reports line sense from the input's RMS level: present after 100 ms at or above the threshold, absent after 2 s below. The server may use it to decide when to start the stream. Pick a threshold a little above the input's noise floor. Capture then runs from startup, so a microphone's in-use indicator stays on.

## Pairing

Unpaired access stays off, so only a server this client has paired with can start the stream. On startup the example prints its pairing token; paste it into a server that asks for one, or start pairing from the server and enter the code the example prints. The keypair and pairing records persist in `~/.sendspin-source.json`, separate from the basic client's.
126 changes: 118 additions & 8 deletions examples/source_client/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
/// -u URL Connect to a WebSocket URL (e.g. ws://192.168.1.10:8928/sendspin)
/// -p PORT Listen on PORT (default: 8928)
/// -o Stream Opus-encoded audio instead of PCM (needs SENDSPIN_ENABLE_OPUS)
/// -s DBFS Report line sense: signal present while the input's level is at or above DBFS
Comment thread
kahrendt marked this conversation as resolved.
/// -l LEVEL Set log level: none, error, warn, info (default), debug, verbose
/// -v Verbose logging (same as -l verbose)
/// -q Quiet logging (same as -l error)
Expand Down Expand Up @@ -61,6 +62,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <optional>
#include <string>
#include <thread>
#include <utility>
Expand All @@ -77,6 +79,10 @@ static constexpr uint32_t CAPTURE_SAMPLE_RATE = 48000;
static constexpr uint8_t CAPTURE_BIT_DEPTH = 16;
static constexpr unsigned long CAPTURE_FRAMES_PER_BUFFER = CAPTURE_SAMPLE_RATE / 50;

// How long the input level must stay across the line sense threshold to flip the signal
static constexpr uint32_t SIGNAL_PRESENT_HOLD_MS = 100;
static constexpr uint32_t SIGNAL_ABSENT_HOLD_MS = 2000;

#ifdef SENDSPIN_HAS_MDNS
// Manages mDNS service advertisement via dns_sd.h
class MdnsAdvertiser {
Expand Down Expand Up @@ -121,9 +127,9 @@ class MdnsAdvertiser {
};
#endif // SENDSPIN_HAS_MDNS

/// @brief Captures the default PortAudio input device and feeds the source role: the input
/// callback forwards each buffer to write_audio(), which never blocks, with its capture time
/// mapped onto the client's clock; the listener callbacks start and stop the capture stream.
/// @brief Captures the default PortAudio input device and feeds the source role: while the
/// source role is streaming, the input callback forwards each buffer to write_audio(), which
/// never blocks, with its capture time mapped onto the client's clock.
class PortAudioCapture {
public:
PortAudioCapture() {
Expand Down Expand Up @@ -171,7 +177,10 @@ class PortAudioCapture {
return false;
}
source_ = &source;
channels_ = channels;
bytes_per_frame_ = static_cast<size_t>(channels) * (CAPTURE_BIT_DEPTH / 8U);
present_hold_frames_ = sample_rate / 1000U * SIGNAL_PRESENT_HOLD_MS;
absent_hold_frames_ = sample_rate / 1000U * SIGNAL_ABSENT_HOLD_MS;

PaStreamParameters params;
memset(&params, 0, sizeof(params));
Expand Down Expand Up @@ -217,6 +226,24 @@ class PortAudioCapture {
}
}

/// @brief Meters each buffer's RMS level against threshold_dbfs; call before start()
void enable_line_sense(double threshold_dbfs) {
// Compared as a mean square, so the callback needs no logarithm
constexpr double FULL_SCALE = 32768.0;
threshold_mean_square_ = FULL_SCALE * FULL_SCALE * std::pow(10.0, threshold_dbfs / 10.0);
line_sense_ = true;
}

/// @brief Whether the metered input currently counts as carrying a signal
bool signal_present() const {
return signal_present_.load(std::memory_order_relaxed);
}

/// @brief Gates forwarding to write_audio(); set from the source listener callbacks
void set_streaming(bool streaming) {
streaming_.store(streaming, std::memory_order_relaxed);
}

/// @brief The number of refused writes since the last call, resetting the count
uint32_t take_dropped_writes() {
return dropped_writes_.exchange(0, std::memory_order_relaxed);
Expand All @@ -230,6 +257,12 @@ class PortAudioCapture {
if (input == nullptr) {
return paContinue; // Input overflow gap; nothing to forward
}
if (self->line_sense_) {
self->meter(static_cast<const int16_t*>(input), frame_count);
}
if (!self->streaming_.load(std::memory_order_relaxed)) {
return paContinue;
}

// Only the buffer's age (currentTime - inputBufferAdcTime) is portable across PortAudio
// backends, so it is subtracted from the steady clock, the client's clock on host.
Expand Down Expand Up @@ -257,10 +290,43 @@ class PortAudioCapture {
return paContinue;
}

/// @brief Updates the signal state from one buffer's level, applying the hold times
void meter(const int16_t* samples, unsigned long frame_count) {
const size_t sample_count = static_cast<size_t>(frame_count) * channels_;
if (sample_count == 0) {
return;
}
double sum_squares = 0.0;
for (size_t i = 0; i < sample_count; ++i) {
const double sample = samples[i];
sum_squares += sample * sample;
}
const bool loud = sum_squares / static_cast<double>(sample_count) >= threshold_mean_square_;
Comment thread
kahrendt marked this conversation as resolved.

const bool present = signal_present_.load(std::memory_order_relaxed);
if (loud == present) {
contrary_frames_ = 0;
return;
}
contrary_frames_ += frame_count;
if (contrary_frames_ >= (present ? absent_hold_frames_ : present_hold_frames_)) {
signal_present_.store(loud, std::memory_order_relaxed);
contrary_frames_ = 0;
}
}

SourceRole* source_{nullptr};
PaStream* stream_{nullptr};
size_t bytes_per_frame_{0};
std::atomic<uint32_t> dropped_writes_{0};
std::atomic<bool> streaming_{false};
std::atomic<bool> signal_present_{false};
double threshold_mean_square_{0.0};
uint64_t contrary_frames_{0}; // Frames the level has disagreed with the reported signal
uint32_t present_hold_frames_{0};
uint32_t absent_hold_frames_{0};
uint8_t channels_{0};
bool line_sense_{false};
bool initialized_{false};
};

Expand All @@ -279,6 +345,7 @@ static void print_usage(const char* prog) {
#ifdef SENDSPIN_ENABLE_OPUS
fprintf(stderr, " -o Stream Opus-encoded audio instead of PCM\n");
#endif
fprintf(stderr, " -s DBFS Report line sense: signal present at or above DBFS (e.g. -50)\n");
Comment thread
kahrendt marked this conversation as resolved.
fprintf(stderr, " -l LEVEL Log level: none, error, warn, info (default), debug, verbose\n");
fprintf(stderr, " -v Verbose logging (same as -l verbose)\n");
fprintf(stderr, " -q Quiet logging (same as -l error)\n");
Expand All @@ -295,6 +362,16 @@ static bool parse_log_level(const char* str, LogLevel& level) {
return false;
}

static bool parse_dbfs(const char* str, double& dbfs) {
char* end = nullptr;
double value = strtod(str, &end);
if (*str == '\0' || *end != '\0' || !std::isfinite(value) || value > 0.0) {
return false;
}
dbfs = value;
return true;
}

static bool parse_port(const char* str, uint16_t& port) {
char* end = nullptr;
unsigned long value = strtoul(str, &end, 10);
Expand All @@ -315,8 +392,9 @@ int main(int argc, char* argv[]) {
std::string connect_url;
uint16_t server_port = DEFAULT_SENDSPIN_PORT;
bool use_opus = false;
std::optional<double> line_sense_dbfs;
int opt;
while ((opt = getopt(argc, argv, "u:p:ol:vqh")) != -1) {
while ((opt = getopt(argc, argv, "u:p:os:l:vqh")) != -1) {
switch (opt) {
case 'u':
connect_url = optarg;
Expand All @@ -336,6 +414,17 @@ int main(int argc, char* argv[]) {
fprintf(stderr, "-o needs a library built with SENDSPIN_ENABLE_OPUS\n");
return 1;
#endif
case 's': {
double dbfs = 0.0;
if (!parse_dbfs(optarg, dbfs)) {
fprintf(stderr, "Invalid line sense threshold (dBFS, at most 0): %s\n",
optarg);
print_usage(argv[0]);
return 1;
}
line_sense_dbfs = dbfs;
break;
}
case 'l':
if (!parse_log_level(optarg, log_level)) {
fprintf(stderr, "Unknown log level: %s\n", optarg);
Expand Down Expand Up @@ -402,20 +491,29 @@ int main(int argc, char* argv[]) {
source_config.channels = channels;
source_config.bit_depth = CAPTURE_BIT_DEPTH;
source_config.codec = use_opus ? SendspinCodecFormat::OPUS : SendspinCodecFormat::PCM;
source_config.line_sense = line_sense_dbfs.has_value();
auto& source = client.add_source(std::move(source_config));

if (!capture.open(source, CAPTURE_SAMPLE_RATE, channels)) {
return 1;
}
if (line_sense_dbfs.has_value()) {
capture.enable_line_sense(*line_sense_dbfs);
if (!capture.start()) {
return 1;
}
}

// --- Listener implementations ---

struct CaptureSourceListener : SourceRoleListener {
PortAudioCapture& capture;
explicit CaptureSourceListener(PortAudioCapture& c) : capture(c) {}
bool line_sense;
CaptureSourceListener(PortAudioCapture& c, bool sense) : capture(c), line_sense(sense) {}

void on_streaming_started() override {
fprintf(stderr, ">>> Streaming started\n");
capture.set_streaming(true);
if (!capture.start()) {
// The stream is open on the wire but capture cannot run; leave the main loop so
// shutdown closes the stream rather than leave the server waiting on silence.
Expand All @@ -426,7 +524,10 @@ int main(int argc, char* argv[]) {

void on_streaming_stopped() override {
fprintf(stderr, ">>> Streaming stopped\n");
capture.stop();
capture.set_streaming(false);
if (!line_sense) {
capture.stop();
}
}
};

Expand Down Expand Up @@ -481,7 +582,7 @@ int main(int argc, char* argv[]) {
bool is_network_ready() override { return true; }
};

CaptureSourceListener source_listener(capture);
CaptureSourceListener source_listener(capture, line_sense_dbfs.has_value());
SourceClientListener client_listener;
HostNetworkProvider network_provider;

Expand Down Expand Up @@ -537,14 +638,23 @@ int main(int argc, char* argv[]) {
// Main loop
constexpr auto DROP_REPORT_INTERVAL = std::chrono::seconds(5);
auto next_drop_report = std::chrono::steady_clock::now() + DROP_REPORT_INTERVAL;
std::optional<bool> reported_signal;
while (running.load()) {
client.loop();
if (line_sense_dbfs.has_value()) {
bool present = capture.signal_present();
if (reported_signal != present) {
reported_signal = present;
source.set_signal(present ? SourceSignal::PRESENT : SourceSignal::ABSENT);
fprintf(stderr, ">>> Line sense: signal %s\n", present ? "present" : "absent");
}
}
// Surface capture drops off the audio callback, which only counts them
if (std::chrono::steady_clock::now() >= next_drop_report) {
next_drop_report += DROP_REPORT_INTERVAL;
uint32_t dropped = capture.take_dropped_writes();
if (dropped > 0) {
// A stop also refuses the few callbacks still in flight before capture stops
// A stop also refuses the callbacks until on_streaming_stopped() runs
fprintf(stderr, ">>> Refused %u capture writes in the last %lld s\n", dropped,
static_cast<long long>(DROP_REPORT_INTERVAL.count()));
}
Expand Down
Loading