Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,14 @@ if(ESP_IDF_BUILD)

if(CONFIG_SENDSPIN_ENABLE_SOURCE)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_SOURCE_SOURCES})
# The source role's Opus encoder comes from micro-opus, which the player block above
# may already require; the dedup below keeps the REQUIRES list clean either way.
list(APPEND SENDSPIN_REQUIRES esphome__micro-opus)
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_SOURCE)
endif()

list(REMOVE_DUPLICATES SENDSPIN_REQUIRES)

# 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})
Expand Down
7 changes: 5 additions & 2 deletions cmake/host.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ function(sendspin_configure_host TARGET_LIB SOURCE_DIR)
ARDUINOJSON_USE_LONG_LONG=1
)

# micro-flac and micro-opus (audio codec libraries, required by player/decoder)
# Only fetched and linked when the player role is enabled.
# Audio codec libraries: micro-flac decodes FLAC for the player only; micro-opus carries
# both the Opus decoder (player) and encoder (source), so it is fetched and linked when
# either of those roles is enabled.
if(SENDSPIN_ENABLE_PLAYER)
FetchContent_Declare(
micro_flac
Expand All @@ -69,7 +70,9 @@ function(sendspin_configure_host TARGET_LIB SOURCE_DIR)
)
FetchContent_MakeAvailable(micro_flac)
target_link_libraries(${TARGET_LIB} PUBLIC micro_flac)
endif()

if(SENDSPIN_ENABLE_PLAYER OR SENDSPIN_ENABLE_SOURCE)
FetchContent_Declare(
micro_opus
GIT_REPOSITORY https://github.com/esphome-libs/micro-opus.git
Expand Down
1 change: 1 addition & 0 deletions cmake/sources.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function(sendspin_get_sources BASE_DIR)
set(SENDSPIN_SOURCE_SOURCES
${BASE_DIR}/src/source_role.cpp
${BASE_DIR}/src/source_task.cpp
${BASE_DIR}/src/source_encoder_opus.cpp

PARENT_SCOPE
)
Expand Down
3 changes: 2 additions & 1 deletion idf_component.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ dependencies:
esphome/micro-opus:
version: ">=0.3.5"
rules:
- if: "$CONFIG{SENDSPIN_ENABLE_PLAYER} == True"
# Opus decode for the player role, Opus encode for the source role.
- if: "$CONFIG{SENDSPIN_ENABLE_PLAYER} == True || $CONFIG{SENDSPIN_ENABLE_SOURCE} == True"
description: "Sendspin synchronized audio streaming client for ESP32"
license: "Apache-2.0"
maintainers:
Expand Down
56 changes: 44 additions & 12 deletions include/sendspin/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,10 @@ struct VisualizerRoleConfig {
/// @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.
/// negotiation, and write_audio() consumes PCM in exactly that format (sent untouched for the
/// PCM codec, encoded chunk-by-chunk for OPUS). 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
Expand All @@ -299,11 +300,29 @@ struct SourceRoleConfig {
/// drain threads (2)
static constexpr unsigned DEFAULT_SOURCE_TASK_PRIORITY = 3U;

/// @brief Opus bitrate bounds in bit/s: the range libopus's OPUS_SET_BITRATE accepts
static constexpr uint32_t OPUS_BITRATE_MIN = 500U;
static constexpr uint32_t OPUS_BITRATE_MAX = 512000U;

/// @brief Default Opus bitrate (bit/s): transparent-leaning for 48 kHz stereo music per
/// Opus encoding guidance. Mono/voice configs typically run 24000-64000
static constexpr uint32_t DEFAULT_OPUS_BITRATE = 128000U;

/// @brief Maximum value libopus's OPUS_SET_COMPLEXITY accepts
static constexpr uint8_t OPUS_COMPLEXITY_MAX = 10U;

/// @brief Default Opus encoder complexity: low, to fit an ESP32-class real-time encode
/// budget. Hosts may raise it toward OPUS_COMPLEXITY_MAX for quality per CPU
static constexpr uint8_t DEFAULT_OPUS_COMPLEXITY = 2U;

// 32-bit fields
uint32_t sample_rate{48000}; ///< Capture sample rate in Hz; must be > 0
/// @brief Capture sample rate in Hz; must be > 0. OPUS accepts only libopus's rates:
/// 8000, 12000, 16000, 24000, or 48000 (a 44100 line-in must use PCM or resample upstream)
uint32_t sample_rate{48000};

/// @brief Outbound chunk duration in milliseconds, validated against the spec bounds
/// [CHUNK_MIN_MS, CHUNK_MAX_MS]
/// [CHUNK_MIN_MS, CHUNK_MAX_MS]. OPUS accepts only 10, 20, 40, or 60 (one
/// chunk is exactly one legal Opus frame), so the PCM default of 25 is rejected for OPUS
uint32_t chunk_duration_ms{DEFAULT_CHUNK_MS};

/// @brief Capture ring capacity in milliseconds of audio in the configured format (the
Expand All @@ -313,20 +332,33 @@ struct SourceRoleConfig {
/// small write_audio() calls reduce the effective audio capacity below this figure
uint32_t capture_buffer_ms{DEFAULT_CAPTURE_BUFFER_MS};

/// @brief Opus bitrate in bit/s, validated against [OPUS_BITRATE_MIN,
/// OPUS_BITRATE_MAX]. Ignored (and unvalidated) when codec is PCM
uint32_t opus_bitrate{DEFAULT_OPUS_BITRATE};

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
/// @brief Memory placement for the capture ring, chunk staging buffer, and the Opus
/// encoder's scratch buffers (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
/// @brief Outbound codec: PCM (chunks are the capture bytes, untouched) or OPUS (each
/// chunk is encoded into one RFC 6716 packet). OPUS narrows the accepted format -- see the
/// per-field validation notes on the fields above. OPUS also costs
/// the encoder state plus micro-opus's per-thread scratch arena (~120 KB,
/// SPIRAM-preferred, allocated lazily on the source task's first encode) -- the same
/// per-thread arena the player's Opus decode allocates on its own task, so a device doing
/// both holds two such arenas
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

/// @brief Opus encoder complexity, validated to at most OPUS_COMPLEXITY_MAX.
/// Ignored (and unvalidated) when codec is PCM
uint8_t opus_complexity{DEFAULT_OPUS_COMPLEXITY};
uint8_t channels{2}; ///< Capture channel count; must be > 0 (1 or 2 for OPUS)
uint8_t bit_depth{16}; ///< Bits per sample; 16, 24 (3 packed bytes), or 32 (16 for OPUS)
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)
};
Expand Down
13 changes: 1 addition & 12 deletions src/decoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#include "decoder.h"

#include "opus_state_location.h"
#include "platform/logging.h"

#include <cstring>
Expand All @@ -23,18 +24,6 @@ namespace sendspin {

static const char* const TAG = "sendspin.decoder";

// The OpusDecoder state is what micro-opus's CONFIG_OPUS_STATE_MEMORY_PREFERENCE Kconfig governs
// when opus_decoder_create() does the allocation. We use opus_decoder_init() and own the backing
// buffer ourselves, so we mirror the same Kconfig here so consumers get one consistent placement
// rule for the OpusDecoder state regardless of who allocated it. The strict *_ONLY modes are
// honored as a soft preference (falling back to the other region if the preferred is exhausted);
// the buffer is only ~30-50KB so a fallback rarely matters in practice.
#if defined(CONFIG_OPUS_STATE_PREFER_INTERNAL) || defined(CONFIG_OPUS_STATE_INTERNAL_ONLY)
constexpr MemoryLocation OPUS_STATE_LOCATION = MemoryLocation::PREFER_INTERNAL;
#else
constexpr MemoryLocation OPUS_STATE_LOCATION = MemoryLocation::PREFER_EXTERNAL;
#endif

void SendspinDecoder::reset_decoders() {
if (this->flac_decoder_ != nullptr) {
this->flac_decoder_->reset();
Expand Down
34 changes: 34 additions & 0 deletions src/opus_state_location.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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 opus_state_location.h
/// @brief Shared memory-placement rule for the libopus codec state this library allocates

#pragma once

#include "sendspin/types.h"

namespace sendspin {

// Mirrors micro-opus's CONFIG_OPUS_STATE_MEMORY_PREFERENCE for the codec-state buffers this
// library allocates itself via the *_init() variants (decoder.cpp, source_encoder_opus.cpp), so
// one placement rule governs Opus state regardless of who allocated it. The strict *_ONLY modes
// are honored as a soft preference.
#if defined(CONFIG_OPUS_STATE_PREFER_INTERNAL) || defined(CONFIG_OPUS_STATE_INTERNAL_ONLY)
constexpr MemoryLocation OPUS_STATE_LOCATION = MemoryLocation::PREFER_INTERNAL;
#else
constexpr MemoryLocation OPUS_STATE_LOCATION = MemoryLocation::PREFER_EXTERNAL;
#endif

} // namespace sendspin
132 changes: 132 additions & 0 deletions src/source_encoder_opus.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// 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.

#include "source_encoder_opus.h"

#include "opus_state_location.h"
#include "platform/logging.h"
#include "source_task.h"
#include <opus.h>

#include <algorithm>
#include <cstring>
#include <iterator>

namespace sendspin {

static const char* const TAG = "sendspin.source_encoder";

bool OpusSourceEncoder::init(const SourceRoleConfig& config) {
this->sample_rate_ = config.sample_rate;
this->bytes_per_frame_ = source_bytes_per_frame(config.channels, config.bit_depth);

const int state_size = opus_encoder_get_size(config.channels);
if (state_size <= 0 ||
!this->encoder_state_.allocate(static_cast<size_t>(state_size), OPUS_STATE_LOCATION)) {
SS_LOGE(TAG, "Couldn't allocate %d bytes for the Opus encoder state", state_size);
return false;
}

// AUDIO fixed for line-in/music capture; a tuning knob waits for a demonstrated need
int err = opus_encoder_init(this->encoder_state_.as<OpusEncoder>(),
static_cast<opus_int32>(config.sample_rate), config.channels,
OPUS_APPLICATION_AUDIO);
if (err == OPUS_OK) {
err = opus_encoder_ctl(this->encoder_state_.as<OpusEncoder>(),
OPUS_SET_BITRATE(static_cast<opus_int32>(config.opus_bitrate)));
}
if (err == OPUS_OK) {
err =
opus_encoder_ctl(this->encoder_state_.as<OpusEncoder>(),
OPUS_SET_COMPLEXITY(static_cast<opus_int32>(config.opus_complexity)));
}
// OPUS_GET_LOOKAHEAD returns SAMPLES at the encoder's rate, not ms; stable for fixed
// settings so queried once
opus_int32 lookahead_samples = 0;
if (err == OPUS_OK) {
err = opus_encoder_ctl(this->encoder_state_.as<OpusEncoder>(),
OPUS_GET_LOOKAHEAD(&lookahead_samples));
}
if (err != OPUS_OK) {
SS_LOGE(TAG, "Couldn't initialize the Opus encoder, error %d", err);
this->encoder_state_.reset();
return false;
}
this->lookahead_us_ =
source_frames_to_us(static_cast<uint64_t>(lookahead_samples), config.sample_rate);

// Both scratches follow the audio buffers' placement choice (same bytes, same access)
const uint64_t chunk_bytes =
source_ms_to_frames(config.chunk_duration_ms, config.sample_rate) * this->bytes_per_frame_;
if (!this->pcm_scratch_.allocate(static_cast<size_t>(chunk_bytes), config.buffer_location) ||
!this->packet_scratch_.allocate(MAX_PACKET_BYTES, config.buffer_location)) {
SS_LOGE(TAG, "Couldn't allocate the Opus chunk scratch buffers");
this->encoder_state_.reset();
return false;
}
return true;
}

bool OpusSourceEncoder::can_encode(size_t in_len) const {
if (in_len == 0 || (in_len % this->bytes_per_frame_) != 0U) {
return false;
}
// One opus_encode() call takes exactly one legal frame (RFC 6716 durations), tabled in
// tenth-ms so 2.5 stays integral; counts are exact for every accepted rate
static constexpr uint32_t OPUS_FRAME_TENTH_MS[] = {25, 50, 100, 200, 400, 600};
static constexpr uint32_t TENTH_MS_PER_SECOND = 10000U;
const size_t frames = in_len / this->bytes_per_frame_;
return std::any_of(
std::begin(OPUS_FRAME_TENTH_MS), std::end(OPUS_FRAME_TENTH_MS), [&](uint32_t tenth_ms) {
return frames ==
static_cast<size_t>(this->sample_rate_) * tenth_ms / TENTH_MS_PER_SECOND;
});
}

size_t OpusSourceEncoder::encode(const uint8_t* in, size_t in_len, uint8_t* out,
size_t out_capacity) {
if (!this->can_encode(in_len) || in_len > this->pcm_scratch_.size()) {
// Defensive: the task consults can_encode() before handing over a remainder
SS_LOGD(TAG, "Opus cannot encode a %u-byte chunk; dropping it",
static_cast<unsigned>(in_len));
return 0;
}

// `in` sits behind the 9-byte wire header and is not int16-aligned, so copy to the aligned
// scratch; encoding into the packet scratch (never `out`) is what honors in == out
memcpy(this->pcm_scratch_.data(), in, in_len);
const opus_int32 written =
opus_encode(this->encoder_state_.as<OpusEncoder>(), this->pcm_scratch_.as<opus_int16>(),
static_cast<int>(in_len / this->bytes_per_frame_), this->packet_scratch_.data(),
static_cast<opus_int32>(MAX_PACKET_BYTES));
if (written <= 0) {
SS_LOGE(TAG, "Opus encode failed, error %d", static_cast<int>(written));
return 0;
}
if (static_cast<size_t>(written) > out_capacity) {
SS_LOGE(TAG, "Opus packet of %d bytes exceeds the %u-byte payload capacity; dropping chunk",
static_cast<int>(written), static_cast<unsigned>(out_capacity));
return 0;
}
memcpy(out, this->packet_scratch_.data(), static_cast<size_t>(written));
return static_cast<size_t>(written);
}

void OpusSourceEncoder::reset() {
// Keeps the allocations (unlike the decode side): this encoder's format is the role's
// contract for every stream it opens, so the cached lookahead stays valid too
opus_encoder_ctl(this->encoder_state_.as<OpusEncoder>(), OPUS_RESET_STATE);
}

} // namespace sendspin
Loading
Loading