Add a source capture host example and sync the documentation - #117
Add a source capture host example and sync the documentation#117chrisuthe wants to merge 12 commits into
Conversation
8f828e6 to
09cc38b
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical authorization and lifetime defects, plus callback-safety and failure-handling issues, must be resolved before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a host source-capture example using PortAudio, with PCM/Opus streaming, optional mDNS, and synchronized documentation.
Changes:
- Introduces the
source_clientexample and build configuration. - Documents source-role APIs, internals, usage, and build gating.
- Updates project architecture and feature summaries.
File summaries
| File | Review |
|---|---|
README.md |
Lists source support and the new example. No findings. |
examples/source_client/README.md |
Documents building and running the example. No findings. |
examples/source_client/main.cpp |
Critical (2 votes), L433: Enforce source authorization before exposing privacy-sensitive input. Critical (1 vote), L423: Construct provider/listener before the client to satisfy lifetime contracts. Moderate (1 vote), L255: Avoid mutexes and logging in the real-time audio callback. Moderate (1 vote), L406: Exit the main loop when Pa_StartStream() fails.Moderate (1 vote), L462: Use a steady-clock deadline and one interval constant. Nit (1 vote), L257: Correct the inaccurate no-logging claim. Nit (1 vote), L76: Extract the duplicated mDNS advertiser into shared example code. |
examples/source_client/CMakeLists.txt |
Configures PortAudio and mDNS dependencies. No findings. |
docs/internals.md |
Documents source pipeline internals. No findings. |
docs/integration-guide.md |
Nit (1 vote), L189: Document pairing/trust requirements and consumer-facing pairing setup. |
docs/conventions.md |
Clarifies build gating for single-role examples. No findings. |
CMakeLists.txt |
Conditionally includes the source example. No findings. |
CLAUDE.md |
Updates architecture documentation. No findings. |
Review details
Suppressed comments (3)
docs/integration-guide.md:189
- This presents the server start command as the only streaming gate, but the linked source@v1 requirements also require a paired (
usertrust) connection and mandate refusal at trustnone. Add that requirement and the consumer-facing pairing setup here; the current implementation has no trust check, so this documentation currently describes a non-compliant and unsafe contract.
Streaming is gated by the server: the client never streams unsolicited, the default after connect is stopped, and permission does not survive reconnection. When the server commands start, the role opens the outbound stream and fires `on_streaming_started()`; from that point on, feed captured audio to `write_audio()`:
examples/source_client/main.cpp:259
- The “no logging on the audio callback” claim is false: this callback calls
SourceRole::write_audio(), whose ring-full path logs atsrc/source_task.cpp:207-218. Update the comment so users are not told this path is log-free.
// Counted here and reported from the main loop: no logging on the audio
// callback. A rejected write means the capture ring is full, or the stream
// closed while this callback was in flight.
examples/source_client/main.cpp:77
- This adds a third copy of the existing
MdnsAdvertiserimplementation (examples/basic_client/main.cpp:67-118andexamples/tui_client/main.cpp:79-110). That conflicts with the reuse-before-invention rule indocs/conventions.md:106-109and lets registration/error handling drift independently. Extract the advertiser intoexamples/commonand reuse it from all host examples.
// Manages mDNS service advertisement via dns_sd.h
class MdnsAdvertiser {
- Files reviewed: 9/9 changed files
- Comments generated: 5
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The example declares its listener and network provider before the client (their raw pointers must outlive it), exits the main loop when capture fails to start so shutdown closes the open stream, reports capture drops on a steady-clock interval instead of counted loop ticks, warns at startup that any handshaken server can start capture on this protocol revision, and corrects the audio-callback logging comment. The integration guide gains the matching authorization note and the 20 ms default with 5 ms Opus support; internals documents the binary send worker, its slot, and its reclamation path.
There was a problem hiding this comment.
🟡 Changes recommended
Several new documentation claims contradict the implementation, and the example duplicates existing shared functionality.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
docs/internals.md:417
- This is another reference to the nonexistent
accepting_audio_member. The gate opens by atomically addingSTREAM_ACCEPTINGtostream_state_throughtry_open_audio_gate()(src/source_task.h:202-209), which is important because that compare-exchange also verifies that streaming is still desired.
3. **Flush the ring and open the gate**: the flush makes the first chunk live audio by construction, and only then is `accepting_audio_` set. A `SOURCE_STREAM` STREAMING_STARTED event is pushed onto the inbox ring.
docs/internals.md:419
- The close path likewise clears the
STREAM_ACCEPTINGbit instream_state_; there is noaccepting_audio_field. Using the actual state representation keeps this lifecycle description consistent withsrc/source_task.cpp:402-404.
5. **Close**: clear `accepting_audio_` first (so the tail is finite), send a final short chunk only if the encoder can take it (allowed at stream end by the spec but not required; an Opus remainder that is not a legal frame duration is dropped rather than padded), send `client-stream/end` from this same thread — ordered after the last chunk by construction — and push STREAMING_STOPPED.
examples/source_client/main.cpp:302
- This is also a verbatim copy of
examples/basic_client/main.cpp:149-157. Centralize port parsing in the same shared example CLI helper required bydocs/conventions.md:106-109; otherwise validation changes must be kept synchronized manually.
static bool parse_port(const char* str, uint16_t& port) {
- Files reviewed: 9/9 changed files
- Comments generated: 6
- Review effort level: Balanced
Refresh the internals and integration-guide prose that the one-atomic lifecycle rework left stale: the write gate is the STREAM_ACCEPTING bit of stream_state_ (not accepting_audio_), desired state is STREAM_DESIRED (not stream_requested_), the host ring takes a short mutex so the guide no longer calls the path universally callback-safe, and the ESP worker allocation paragraph distinguishes the per-message text contexts from the make_shared BinarySendLookup and its owner-scoped reclamation. Extract the duplicated MdnsAdvertiser and log-level parser into examples/common and share them across all three host examples.
There was a problem hiding this comment.
🟡 Changes recommended
The helper extraction breaks supported no-PortAudio builds, and source internals documentation misstates send-completion behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 4
- Review effort level: Balanced
| #ifdef SENDSPIN_HAS_PORTAUDIO | ||
| #include "cli_util.h" | ||
| #include "mdns_advertiser.h" | ||
| #include "portaudio_sink.h" |
| #ifdef SENDSPIN_HAS_PORTAUDIO | ||
| #include "cli_util.h" | ||
| #include "mdns_advertiser.h" | ||
| #include "portaudio_sink.h" |
|
|
||
| ### Outbound send path | ||
|
|
||
| `SendspinConnection::send_binary_message()` is the transport contract the source task depends on (`src/connection.h`): callable from role task threads, and the completion callback fires **exactly once for every call** — inline before an error return, later from the transport, or from connection teardown for work that can never run. The task waits out every send's completion (`SOURCE_SEND_COMPLETE`) before reusing the staging buffer, which is also what makes destroying the task safe; the exactly-once contract is what keeps that unbounded wait from wedging. |
| static bool parse_port(const char* str, uint16_t& port) { | ||
| char* end = nullptr; | ||
| unsigned long value = strtoul(str, &end, 10); | ||
| if (*str == '\0' || *end != '\0' || value == 0 || value > 65535UL) { | ||
| return false; |
Part 6/6 of the source@v1 stack (tracker: #95).
What it adds:
examples/source_client(captures the default PortAudio input and streams it, with an Opus switch) and the documentation sync — integration guide, internals, CLAUDE.md, README.How it's used: build with examples on and run it against a Sendspin server; the README in the example folder has the details.
Note for maintainers: this also adds one clarifying sentence to
docs/conventions.md— an example that exists solely to demonstrate one role may gate its whole CMake target on that role's option (an#ifdefleaving a do-nothing binary is dead code, not a guard). Flagged here for explicit sign-off; drop it if you prefer the rule implicit.