Skip to content
Draft
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
21 changes: 21 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ if(ESP_IDF_BUILD)
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_VISUALIZER)
endif()

if(CONFIG_SENDSPIN_ENABLE_SOURCE)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_SOURCE_SOURCES})
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_SOURCE)
endif()

# Shared audio infrastructure for the roles that stream audio through the SPSC ring
if(CONFIG_SENDSPIN_ENABLE_PLAYER OR CONFIG_SENDSPIN_ENABLE_SOURCE)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_AUDIO_SOURCES})
endif()

# Register the component — uses assembled source list
idf_component_register(
SRCS ${SENDSPIN_ALL_SOURCES}
Expand Down Expand Up @@ -109,6 +119,7 @@ else()
option(SENDSPIN_ENABLE_COLOR "Enable color role" ON)
option(SENDSPIN_ENABLE_ARTWORK "Enable artwork role" ON)
option(SENDSPIN_ENABLE_VISUALIZER "Enable visualizer role" ON)
option(SENDSPIN_ENABLE_SOURCE "Enable source role (audio capture)" ON)

include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/host.cmake)

Expand Down Expand Up @@ -136,6 +147,13 @@ else()
if(SENDSPIN_ENABLE_VISUALIZER)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_VISUALIZER_SOURCES})
endif()
if(SENDSPIN_ENABLE_SOURCE)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_SOURCE_SOURCES})
endif()
# Shared audio infrastructure for the roles that stream audio through the SPSC ring
if(SENDSPIN_ENABLE_PLAYER OR SENDSPIN_ENABLE_SOURCE)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_AUDIO_SOURCES})
endif()

# Create the library — core sources + enabled role sources + host networking (added via host.cmake)
add_library(sendspin STATIC ${SENDSPIN_ALL_SOURCES})
Expand All @@ -159,6 +177,9 @@ else()
if(SENDSPIN_ENABLE_VISUALIZER)
target_compile_definitions(sendspin PUBLIC SENDSPIN_ENABLE_VISUALIZER)
endif()
if(SENDSPIN_ENABLE_SOURCE)
target_compile_definitions(sendspin PUBLIC SENDSPIN_ENABLE_SOURCE)
endif()

# Host build options
option(ENABLE_WERROR "Treat warnings as errors" OFF)
Expand Down
4 changes: 4 additions & 0 deletions Kconfig
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ menu "sendspin-cpp"
bool "Enable visualizer role"
default y

config SENDSPIN_ENABLE_SOURCE
bool "Enable source role (audio capture)"
default y

endmenu
16 changes: 15 additions & 1 deletion cmake/sources.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,30 @@ function(sendspin_get_sources BASE_DIR)
PARENT_SCOPE
)

# Shared audio sources — appended when any role that moves audio through the SPSC ring
# buffer is enabled (player or source), so the filename lives in exactly one list
set(SENDSPIN_AUDIO_SOURCES
${BASE_DIR}/src/audio_ring_buffer.cpp

PARENT_SCOPE
)

# Per-role source sets — conditionally compiled based on SENDSPIN_ENABLE_* options
set(SENDSPIN_PLAYER_SOURCES
${BASE_DIR}/src/player_role.cpp
${BASE_DIR}/src/audio_ring_buffer.cpp
${BASE_DIR}/src/decoder.cpp
${BASE_DIR}/src/sync_task.cpp

PARENT_SCOPE
)

set(SENDSPIN_SOURCE_SOURCES
${BASE_DIR}/src/source_role.cpp
${BASE_DIR}/src/source_task.cpp

PARENT_SCOPE
)

set(SENDSPIN_CONTROLLER_SOURCES
${BASE_DIR}/src/controller_role.cpp

Expand Down
23 changes: 23 additions & 0 deletions include/sendspin/client.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ class MetadataRole;
#ifdef SENDSPIN_ENABLE_PLAYER
class PlayerRole;
#endif
#ifdef SENDSPIN_ENABLE_SOURCE
class SourceRole;
#endif
#ifdef SENDSPIN_ENABLE_VISUALIZER
class VisualizerRole;
#endif
Expand Down Expand Up @@ -258,6 +261,11 @@ class SendspinClient {
VisualizerRole& add_visualizer(VisualizerRoleConfig config);
#endif

#ifdef SENDSPIN_ENABLE_SOURCE
/// @brief Adds the source role. Returns a reference for setting callbacks
SourceRole& add_source(SourceRoleConfig config);
#endif

// ========================================
// Role access (nullptr if not added)
// ========================================
Expand Down Expand Up @@ -322,6 +330,18 @@ class SendspinClient {
return this->player_.get();
}
#endif
#ifdef SENDSPIN_ENABLE_SOURCE
/// @brief Returns the source role, or nullptr if not added
/// @return Pointer to the source role, or nullptr
SourceRole* source() {
return this->source_.get();
}
/// @brief Returns the source role (const), or nullptr if not added
/// @return Const pointer to the source role, or nullptr
const SourceRole* source() const {
return this->source_.get();
}
#endif
#ifdef SENDSPIN_ENABLE_VISUALIZER
/// @brief Returns the visualizer role, or nullptr if not added
/// @return Pointer to the visualizer role, or nullptr
Expand Down Expand Up @@ -490,6 +510,9 @@ class SendspinClient {
SendspinPersistenceProvider* persistence_provider_{nullptr};
#ifdef SENDSPIN_ENABLE_PLAYER
std::unique_ptr<PlayerRole> player_;
#endif
#ifdef SENDSPIN_ENABLE_SOURCE
std::unique_ptr<SourceRole> source_;
#endif
std::unique_ptr<SendspinTimeBurst> time_burst_;
#ifdef SENDSPIN_ENABLE_VISUALIZER
Expand Down
72 changes: 71 additions & 1 deletion include/sendspin/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ struct SendspinClientConfig {
// Player config types
// ============================================================================

/// @brief Audio codec format for a player stream
/// @brief Audio codec format for an audio stream (player playback or source capture)
enum class SendspinCodecFormat : uint8_t {
FLAC, // FLAC lossless audio
OPUS, // Opus compressed audio
Expand Down Expand Up @@ -266,4 +266,74 @@ struct VisualizerRoleConfig {
unsigned priority{2}; ///< FreeRTOS priority for the drain thread (ESP-IDF only)
};

// ============================================================================
// Source config types
// ============================================================================

/// @brief Configuration for the source role (audio capture streamed to the server)
///
/// The configured format is the contract for every stream the role opens: there is no
/// negotiation, and write_audio() bytes are forwarded untouched. An invalid config leaves the
/// role added but inert (logged at ERROR; the role is not advertised and never streams) --
/// spec-invalid values are rejected, never clamped or repaired.
struct SourceRoleConfig {
/// @brief Chunk duration bounds from the Sendspin spec (Source messages): chunks MUST be
/// at most 150 ms and SHOULD be at least 5 ms
static constexpr uint32_t CHUNK_MIN_MS = 5U;
static constexpr uint32_t CHUNK_MAX_MS = 150U;

/// @brief Default duration (ms) of one outbound audio chunk. Small enough to keep the
/// capture-to-server latency and per-chunk staging buffer modest, large enough that the
/// per-chunk framing/send overhead stays negligible; well inside the spec bounds above,
/// and a legal Opus frame duration so switching codec alone never invalidates a default
/// config
static constexpr uint32_t DEFAULT_CHUNK_MS = 20U;

/// @brief Default capture ring capacity in milliseconds. Derived from the spec's maximum
/// chunk duration: the ring IS the "small bound" of the spec's stall policy (Source
/// messages) -- backlog beyond it is dropped at write_audio() and streaming resumes from
/// live capture rather than bursting stale audio
static constexpr uint32_t DEFAULT_CAPTURE_BUFFER_MS = CHUNK_MAX_MS;

/// @brief Default FreeRTOS priority for the source task (ESP-IDF only). Below the HTTP
/// server task (SendspinClientConfig::DEFAULT_HTTPD_PRIORITY = 5) and the sync/decode task
/// (6) so outbound capture can never starve inbound playback, above the artwork/visualizer
/// drain threads (2)
static constexpr unsigned DEFAULT_SOURCE_TASK_PRIORITY = 3U;

/// @brief Default capture sample rate (Hz): the native rate of most capture hardware
static constexpr uint32_t DEFAULT_SOURCE_SAMPLE_RATE = 48000U;

// 32-bit fields
uint32_t sample_rate{DEFAULT_SOURCE_SAMPLE_RATE}; ///< Capture sample rate in Hz; must be > 0

/// @brief Outbound chunk duration in milliseconds, validated against the spec bounds
/// [CHUNK_MIN_MS, CHUNK_MAX_MS]
uint32_t chunk_duration_ms{DEFAULT_CHUNK_MS};

/// @brief Capture ring capacity in milliseconds of audio in the configured format (the
/// byte size is computed from sample_rate, channels, and bit_depth). See
/// DEFAULT_CAPTURE_BUFFER_MS for why this doubles as the stall-policy backlog bound.
/// Approximate: per-write ring metadata comes out of a fixed +25% margin, so many very
/// small write_audio() calls reduce the effective audio capacity below this figure
uint32_t capture_buffer_ms{DEFAULT_CAPTURE_BUFFER_MS};

unsigned priority{DEFAULT_SOURCE_TASK_PRIORITY}; ///< FreeRTOS priority for the source
///< task (ESP-IDF only)

/// @brief Memory placement for the capture ring and chunk staging buffer (ESP-IDF only;
/// ignored on host). Bulk audio with sequential access, so PREFER_EXTERNAL (SPIRAM) --
/// mirrors the player decode buffer's choice
MemoryLocation buffer_location{MemoryLocation::PREFER_EXTERNAL};

// 8-bit fields
/// @brief Outbound codec. Only PCM is accepted; an OPUS config is rejected as inert until
/// Opus encoding lands
SendspinCodecFormat codec{SendspinCodecFormat::PCM};
uint8_t channels{2}; ///< Capture channel count; must be > 0
uint8_t bit_depth{16}; ///< Bits per sample; 16, 24 (3 packed bytes), or 32
bool line_sense{false}; ///< Advertise line-input signal sensing (see SourceRole::set_signal)
bool psram_stack{false}; ///< Allocate source task stack in PSRAM (ESP-IDF only)
};

} // namespace sendspin
137 changes: 137 additions & 0 deletions include/sendspin/source_role.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright 2026 Sendspin 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.

/// @file source_role.h
/// @brief Audio capture role that streams audio from the client to the Sendspin server

#pragma once

#include "sendspin/config.h"

#include <cstddef>
#include <cstdint>
#include <memory>

namespace sendspin {

class SendspinClient;

// ============================================================================
// Source types
// ============================================================================

/// @brief Line-input signal state reported by the source role in client/state messages
enum class SourceSignal : uint8_t {
PRESENT, // Audio signal detected on the capture input
ABSENT, // No audio signal on the capture input
};

/// @brief Listener for source role events. All methods fire on the main loop thread.
class SourceRoleListener {
public:
virtual ~SourceRoleListener() = default;

/// @brief Called when the outbound stream to the server has opened (the server commanded
/// start and client-stream/start was sent); write_audio() accepts audio from this point on
virtual void on_streaming_started() {}

/// @brief Called when the outbound stream to the server has closed (server stop command,
/// connection loss, or disconnect); write_audio() rejects audio again
virtual void on_streaming_stopped() {}
};

/**
* @brief Audio capture role that streams audio to the server
*
* Streaming is gated by the server (Sendspin spec, Source messages): the client never streams
* unsolicited, the default after connect is stopped, and permission does not survive
* reconnection. When the server commands start, the role announces its capture format with
* client-stream/start and then forwards audio written via write_audio() as timestamped binary
* chunks until the server commands stop (or the connection is lost), closing with
* client-stream/end.
*
* The capture format is fixed at add_source() time: the SourceRoleConfig passed there is the
* format contract for every stream this role opens. Changing formats requires tearing down the
* client and re-adding the role with a new config.
*
* Usage:
* 1. Implement SourceRoleListener to learn when the server starts/stops the stream
* 2. Add the role to the client via SendspinClient::add_source() with the capture format
* 3. Call set_listener() with your listener implementation
* 4. Feed captured audio to write_audio() from your capture thread
*
* @code
* struct MySourceListener : SourceRoleListener {
* void on_streaming_started() override { capture.enable(); }
* void on_streaming_stopped() override { capture.disable(); }
* };
*
* MySourceListener listener;
* auto& source = client.add_source(SourceRoleConfig{});
* source.set_listener(&listener);
*
* // Capture thread:
* source.write_audio(pcm_frames, len, capture_time_us);
* @endcode
*/
class SourceRole {
friend class SendspinClient;

public:
struct Impl;

SourceRole(SourceRoleConfig config, SendspinClient* client);
~SourceRole();
Comment on lines +94 to +95

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation for the whole stack lands in part 6 (#117) per the cover's stack map — integration guide, internals (thread/Inbox/pipeline sections), CLAUDE.md, and README all cover the source role there; conventions' same-PR rule is satisfied at stack level.


/// @brief Sets the listener for source events
/// @note The listener must outlive this role
/// @param listener Pointer to the listener implementation
void set_listener(SourceRoleListener* listener);

/// @brief Writes captured audio into the outbound stream
///
/// Hot path: non-allocating and never waiting on the consumer (ring timeout 0). The ring is
/// lock-free on ESP; the host implementation takes a short mutex-guarded critical section,
/// so hard-real-time host callers should hand off through their own lock-free stage.
/// Exactly one producer thread may call this; the library does not serialize concurrent
/// writers.
///
/// @param data Interleaved little-endian signed PCM in the configured format (24-bit as 3
/// packed bytes per sample). Bytes are forwarded untouched; the config passed to
/// add_source() is the format contract.
/// @param len Length in bytes; must be a whole number of frames (one frame = one sample
/// across all channels). A non-whole-frame write is rejected as a whole.
/// @param capture_time_us Local-clock capture time (same domain as the client's time
/// functions) of the FIRST sample in data. Pass 0 to stamp with the current time,
/// a best-effort fallback for callers that cannot timestamp their ADC.
/// @return true if the audio was accepted; false when the stream is not open or the
/// capture buffer is full (the write is dropped and counted).
bool write_audio(const uint8_t* data, size_t len, int64_t capture_time_us);

/// @brief Reports the capture input's signal state, published to the server via client/state
///
/// Must be called from the main loop thread. Only meaningful when
/// SourceRoleConfig::line_sense is set; ignored (with a warning) otherwise.
/// @param signal The new signal state
void set_signal(SourceSignal signal);

/// @brief Returns true while the outbound stream is open. Main-loop thread only: reflects
/// the started/stopped listener callbacks, which fire there.
bool is_streaming() const;

private:
std::unique_ptr<Impl> impl_;
};

} // namespace sendspin
5 changes: 3 additions & 2 deletions src/audio_ring_buffer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ static const char* const TAG = "sendspin.ring_buffer";
// Constructor / Destructor
// ============================================================================

std::unique_ptr<SendspinAudioRingBuffer> SendspinAudioRingBuffer::create(size_t buffer_size) {
std::unique_ptr<SendspinAudioRingBuffer> SendspinAudioRingBuffer::create(size_t buffer_size,
MemoryLocation location) {
auto rb = std::unique_ptr<SendspinAudioRingBuffer>(new SendspinAudioRingBuffer());

if (!rb->storage_.allocate(buffer_size)) {
if (!rb->storage_.allocate(buffer_size, location)) {
SS_LOGE(TAG, "Failed to allocate %zu bytes for ring buffer", buffer_size);
return nullptr;
}
Expand Down
Loading
Loading