diff --git a/docs/integration-guide.md b/docs/integration-guide.md index b25367a6..8fa384a7 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -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 diff --git a/examples/source_client/README.md b/examples/source_client/README.md index 937620cc..9b83b23f 100644 --- a/examples/source_client/README.md +++ b/examples/source_client/README.md @@ -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://:/`. +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://:/`. ## Build @@ -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://:8928/sendspin`, replacing `8928` if you passed `-p`. @@ -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. diff --git a/examples/source_client/main.cpp b/examples/source_client/main.cpp index 7f91143e..852bb9f3 100644 --- a/examples/source_client/main.cpp +++ b/examples/source_client/main.cpp @@ -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 /// -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) @@ -61,6 +62,7 @@ #include #include #include +#include #include #include #include @@ -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 { @@ -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() { @@ -171,7 +177,10 @@ class PortAudioCapture { return false; } source_ = &source; + channels_ = channels; bytes_per_frame_ = static_cast(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(¶ms, 0, sizeof(params)); @@ -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); @@ -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(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. @@ -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(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(sample_count) >= threshold_mean_square_; + + 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 dropped_writes_{0}; + std::atomic streaming_{false}; + std::atomic 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}; }; @@ -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"); 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"); @@ -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); @@ -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 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; @@ -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); @@ -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. @@ -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(); + } } }; @@ -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; @@ -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 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(DROP_REPORT_INTERVAL.count())); }