Skip to content

Commit c00553e

Browse files
committed
Add an Opus encode path to the source role
1 parent d9abea6 commit c00553e

12 files changed

Lines changed: 886 additions & 59 deletions

CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,14 @@ if(ESP_IDF_BUILD)
8080

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

89+
list(REMOVE_DUPLICATES SENDSPIN_REQUIRES)
90+
8691
# Shared audio infrastructure for the roles that stream audio through the SPSC ring
8792
if(CONFIG_SENDSPIN_ENABLE_PLAYER OR CONFIG_SENDSPIN_ENABLE_SOURCE)
8893
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_AUDIO_SOURCES})

cmake/host.cmake

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ function(sendspin_configure_host TARGET_LIB SOURCE_DIR)
5858
ARDUINOJSON_USE_LONG_LONG=1
5959
)
6060

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

75+
if(SENDSPIN_ENABLE_PLAYER OR SENDSPIN_ENABLE_SOURCE)
7376
FetchContent_Declare(
7477
micro_opus
7578
GIT_REPOSITORY https://github.com/esphome-libs/micro-opus.git

cmake/sources.cmake

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function(sendspin_get_sources BASE_DIR)
4646
set(SENDSPIN_SOURCE_SOURCES
4747
${BASE_DIR}/src/source_role.cpp
4848
${BASE_DIR}/src/source_task.cpp
49+
${BASE_DIR}/src/source_encoder_opus.cpp
4950

5051
PARENT_SCOPE
5152
)

idf_component.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ dependencies:
1111
esphome/micro-opus:
1212
version: ">=0.3.5"
1313
rules:
14-
- if: "$CONFIG{SENDSPIN_ENABLE_PLAYER} == True"
14+
# Opus decode for the player role, Opus encode for the source role.
15+
- if: "$CONFIG{SENDSPIN_ENABLE_PLAYER} == True || $CONFIG{SENDSPIN_ENABLE_SOURCE} == True"
1516
description: "Sendspin synchronized audio streaming client for ESP32"
1617
license: "Apache-2.0"
1718
maintainers:

include/sendspin/config.h

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,10 @@ struct VisualizerRoleConfig {
273273
/// @brief Configuration for the source role (audio capture streamed to the server)
274274
///
275275
/// The configured format is the contract for every stream the role opens: there is no
276-
/// negotiation, and write_audio() bytes are forwarded untouched. An invalid config leaves the
277-
/// role added but inert (logged at ERROR; the role is not advertised and never streams) --
278-
/// spec-invalid values are rejected, never clamped or repaired.
276+
/// negotiation, and write_audio() consumes PCM in exactly that format (sent untouched for the
277+
/// PCM codec, encoded chunk-by-chunk for OPUS). An invalid config leaves the role added but
278+
/// inert (logged at ERROR; the role is not advertised and never streams) -- spec-invalid values
279+
/// are rejected, never clamped or repaired.
279280
struct SourceRoleConfig {
280281
/// @brief Chunk duration bounds from the Sendspin spec (Source messages): chunks MUST be
281282
/// at most 150 ms and SHOULD be at least 5 ms
@@ -299,11 +300,29 @@ struct SourceRoleConfig {
299300
/// drain threads (2)
300301
static constexpr unsigned DEFAULT_SOURCE_TASK_PRIORITY = 3U;
301302

303+
/// @brief Opus bitrate bounds in bit/s: the range libopus's OPUS_SET_BITRATE accepts
304+
static constexpr uint32_t OPUS_BITRATE_MIN = 500U;
305+
static constexpr uint32_t OPUS_BITRATE_MAX = 512000U;
306+
307+
/// @brief Default Opus bitrate (bit/s): transparent-leaning for 48 kHz stereo music per
308+
/// Opus encoding guidance. Mono/voice configs typically run 24000-64000
309+
static constexpr uint32_t DEFAULT_OPUS_BITRATE = 128000U;
310+
311+
/// @brief Maximum value libopus's OPUS_SET_COMPLEXITY accepts
312+
static constexpr uint8_t OPUS_COMPLEXITY_MAX = 10U;
313+
314+
/// @brief Default Opus encoder complexity: low, to fit an ESP32-class real-time encode
315+
/// budget. Hosts may raise it toward OPUS_COMPLEXITY_MAX for quality per CPU
316+
static constexpr uint8_t DEFAULT_OPUS_COMPLEXITY = 2U;
317+
302318
// 32-bit fields
303-
uint32_t sample_rate{48000}; ///< Capture sample rate in Hz; must be > 0
319+
/// @brief Capture sample rate in Hz; must be > 0. OPUS accepts only libopus's rates:
320+
/// 8000, 12000, 16000, 24000, or 48000 (a 44100 line-in must use PCM or resample upstream)
321+
uint32_t sample_rate{48000};
304322

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

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

335+
/// @brief Opus bitrate in bit/s, validated against [OPUS_BITRATE_MIN,
336+
/// OPUS_BITRATE_MAX]. Ignored (and unvalidated) when codec is PCM
337+
uint32_t opus_bitrate{DEFAULT_OPUS_BITRATE};
338+
316339
unsigned priority{DEFAULT_SOURCE_TASK_PRIORITY}; ///< FreeRTOS priority for the source
317340
///< task (ESP-IDF only)
318341

319-
/// @brief Memory placement for the capture ring and chunk staging buffer (ESP-IDF only;
320-
/// ignored on host). Bulk audio with sequential access, so PREFER_EXTERNAL (SPIRAM) --
321-
/// mirrors the player decode buffer's choice
342+
/// @brief Memory placement for the capture ring, chunk staging buffer, and the Opus
343+
/// encoder's scratch buffers (ESP-IDF only; ignored on host). Bulk audio with sequential
344+
/// access, so PREFER_EXTERNAL (SPIRAM) -- mirrors the player decode buffer's choice
322345
MemoryLocation buffer_location{MemoryLocation::PREFER_EXTERNAL};
323346

324347
// 8-bit fields
325-
/// @brief Outbound codec. Only PCM is accepted; an OPUS config is rejected as inert until
326-
/// Opus encoding lands
348+
/// @brief Outbound codec: PCM (chunks are the capture bytes, untouched) or OPUS (each
349+
/// chunk is encoded into one RFC 6716 packet). OPUS narrows the accepted format -- see the
350+
/// per-field validation notes on the fields above. OPUS also costs
351+
/// the encoder state plus micro-opus's per-thread scratch arena (~120 KB,
352+
/// SPIRAM-preferred, allocated lazily on the source task's first encode) -- the same
353+
/// per-thread arena the player's Opus decode allocates on its own task, so a device doing
354+
/// both holds two such arenas
327355
SendspinCodecFormat codec{SendspinCodecFormat::PCM};
328-
uint8_t channels{2}; ///< Capture channel count; must be > 0
329-
uint8_t bit_depth{16}; ///< Bits per sample; 16, 24 (3 packed bytes), or 32
356+
357+
/// @brief Opus encoder complexity, validated to at most OPUS_COMPLEXITY_MAX.
358+
/// Ignored (and unvalidated) when codec is PCM
359+
uint8_t opus_complexity{DEFAULT_OPUS_COMPLEXITY};
360+
uint8_t channels{2}; ///< Capture channel count; must be > 0 (1 or 2 for OPUS)
361+
uint8_t bit_depth{16}; ///< Bits per sample; 16, 24 (3 packed bytes), or 32 (16 for OPUS)
330362
bool line_sense{false}; ///< Advertise line-input signal sensing (see SourceRole::set_signal)
331363
bool psram_stack{false}; ///< Allocate source task stack in PSRAM (ESP-IDF only)
332364
};

src/decoder.cpp

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
#include "decoder.h"
1616

17+
#include "opus_state_location.h"
1718
#include "platform/logging.h"
1819

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

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

26-
// The OpusDecoder state is what micro-opus's CONFIG_OPUS_STATE_MEMORY_PREFERENCE Kconfig governs
27-
// when opus_decoder_create() does the allocation. We use opus_decoder_init() and own the backing
28-
// buffer ourselves, so we mirror the same Kconfig here so consumers get one consistent placement
29-
// rule for the OpusDecoder state regardless of who allocated it. The strict *_ONLY modes are
30-
// honored as a soft preference (falling back to the other region if the preferred is exhausted);
31-
// the buffer is only ~30-50KB so a fallback rarely matters in practice.
32-
#if defined(CONFIG_OPUS_STATE_PREFER_INTERNAL) || defined(CONFIG_OPUS_STATE_INTERNAL_ONLY)
33-
constexpr MemoryLocation OPUS_STATE_LOCATION = MemoryLocation::PREFER_INTERNAL;
34-
#else
35-
constexpr MemoryLocation OPUS_STATE_LOCATION = MemoryLocation::PREFER_EXTERNAL;
36-
#endif
37-
3827
void SendspinDecoder::reset_decoders() {
3928
if (this->flac_decoder_ != nullptr) {
4029
this->flac_decoder_->reset();

src/opus_state_location.h

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2026 Sendspin Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/// @file opus_state_location.h
16+
/// @brief Shared memory-placement rule for the libopus codec state this library allocates
17+
18+
#pragma once
19+
20+
#include "sendspin/types.h"
21+
22+
namespace sendspin {
23+
24+
// Mirrors micro-opus's CONFIG_OPUS_STATE_MEMORY_PREFERENCE for the codec-state buffers this
25+
// library allocates itself via the *_init() variants (decoder.cpp, source_encoder_opus.cpp), so
26+
// one placement rule governs Opus state regardless of who allocated it. The strict *_ONLY modes
27+
// are honored as a soft preference.
28+
#if defined(CONFIG_OPUS_STATE_PREFER_INTERNAL) || defined(CONFIG_OPUS_STATE_INTERNAL_ONLY)
29+
constexpr MemoryLocation OPUS_STATE_LOCATION = MemoryLocation::PREFER_INTERNAL;
30+
#else
31+
constexpr MemoryLocation OPUS_STATE_LOCATION = MemoryLocation::PREFER_EXTERNAL;
32+
#endif
33+
34+
} // namespace sendspin

src/source_encoder_opus.cpp

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright 2026 Sendspin Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#include "source_encoder_opus.h"
16+
17+
#include "opus_state_location.h"
18+
#include "platform/logging.h"
19+
#include "source_task.h"
20+
#include <opus.h>
21+
22+
#include <algorithm>
23+
#include <cstring>
24+
#include <iterator>
25+
26+
namespace sendspin {
27+
28+
static const char* const TAG = "sendspin.source_encoder";
29+
30+
bool OpusSourceEncoder::init(const SourceRoleConfig& config) {
31+
this->sample_rate_ = config.sample_rate;
32+
this->bytes_per_frame_ = source_bytes_per_frame(config.channels, config.bit_depth);
33+
34+
const int state_size = opus_encoder_get_size(config.channels);
35+
if (state_size <= 0 ||
36+
!this->encoder_state_.allocate(static_cast<size_t>(state_size), OPUS_STATE_LOCATION)) {
37+
SS_LOGE(TAG, "Couldn't allocate %d bytes for the Opus encoder state", state_size);
38+
return false;
39+
}
40+
41+
// AUDIO fixed for line-in/music capture; a tuning knob waits for a demonstrated need
42+
int err = opus_encoder_init(this->encoder_state_.as<OpusEncoder>(),
43+
static_cast<opus_int32>(config.sample_rate), config.channels,
44+
OPUS_APPLICATION_AUDIO);
45+
if (err == OPUS_OK) {
46+
err = opus_encoder_ctl(this->encoder_state_.as<OpusEncoder>(),
47+
OPUS_SET_BITRATE(static_cast<opus_int32>(config.opus_bitrate)));
48+
}
49+
if (err == OPUS_OK) {
50+
err =
51+
opus_encoder_ctl(this->encoder_state_.as<OpusEncoder>(),
52+
OPUS_SET_COMPLEXITY(static_cast<opus_int32>(config.opus_complexity)));
53+
}
54+
// OPUS_GET_LOOKAHEAD returns SAMPLES at the encoder's rate, not ms; stable for fixed
55+
// settings so queried once
56+
opus_int32 lookahead_samples = 0;
57+
if (err == OPUS_OK) {
58+
err = opus_encoder_ctl(this->encoder_state_.as<OpusEncoder>(),
59+
OPUS_GET_LOOKAHEAD(&lookahead_samples));
60+
}
61+
if (err != OPUS_OK) {
62+
SS_LOGE(TAG, "Couldn't initialize the Opus encoder, error %d", err);
63+
this->encoder_state_.reset();
64+
return false;
65+
}
66+
this->lookahead_us_ =
67+
source_frames_to_us(static_cast<uint64_t>(lookahead_samples), config.sample_rate);
68+
69+
// Both scratches follow the audio buffers' placement choice (same bytes, same access)
70+
const uint64_t chunk_bytes =
71+
source_ms_to_frames(config.chunk_duration_ms, config.sample_rate) * this->bytes_per_frame_;
72+
if (!this->pcm_scratch_.allocate(static_cast<size_t>(chunk_bytes), config.buffer_location) ||
73+
!this->packet_scratch_.allocate(MAX_PACKET_BYTES, config.buffer_location)) {
74+
SS_LOGE(TAG, "Couldn't allocate the Opus chunk scratch buffers");
75+
this->encoder_state_.reset();
76+
return false;
77+
}
78+
return true;
79+
}
80+
81+
bool OpusSourceEncoder::can_encode(size_t in_len) const {
82+
if (in_len == 0 || (in_len % this->bytes_per_frame_) != 0U) {
83+
return false;
84+
}
85+
// One opus_encode() call takes exactly one legal frame (RFC 6716 durations), tabled in
86+
// tenth-ms so 2.5 stays integral; counts are exact for every accepted rate
87+
static constexpr uint32_t OPUS_FRAME_TENTH_MS[] = {25, 50, 100, 200, 400, 600};
88+
static constexpr uint32_t TENTH_MS_PER_SECOND = 10000U;
89+
const size_t frames = in_len / this->bytes_per_frame_;
90+
return std::any_of(
91+
std::begin(OPUS_FRAME_TENTH_MS), std::end(OPUS_FRAME_TENTH_MS), [&](uint32_t tenth_ms) {
92+
return frames ==
93+
static_cast<size_t>(this->sample_rate_) * tenth_ms / TENTH_MS_PER_SECOND;
94+
});
95+
}
96+
97+
size_t OpusSourceEncoder::encode(const uint8_t* in, size_t in_len, uint8_t* out,
98+
size_t out_capacity) {
99+
if (!this->can_encode(in_len) || in_len > this->pcm_scratch_.size()) {
100+
// Defensive: the task consults can_encode() before handing over a remainder
101+
SS_LOGD(TAG, "Opus cannot encode a %u-byte chunk; dropping it",
102+
static_cast<unsigned>(in_len));
103+
return 0;
104+
}
105+
106+
// `in` sits behind the 9-byte wire header and is not int16-aligned, so copy to the aligned
107+
// scratch; encoding into the packet scratch (never `out`) is what honors in == out
108+
memcpy(this->pcm_scratch_.data(), in, in_len);
109+
const opus_int32 written =
110+
opus_encode(this->encoder_state_.as<OpusEncoder>(), this->pcm_scratch_.as<opus_int16>(),
111+
static_cast<int>(in_len / this->bytes_per_frame_), this->packet_scratch_.data(),
112+
static_cast<opus_int32>(MAX_PACKET_BYTES));
113+
if (written <= 0) {
114+
SS_LOGE(TAG, "Opus encode failed, error %d", static_cast<int>(written));
115+
return 0;
116+
}
117+
if (static_cast<size_t>(written) > out_capacity) {
118+
SS_LOGE(TAG, "Opus packet of %d bytes exceeds the %u-byte payload capacity; dropping chunk",
119+
static_cast<int>(written), static_cast<unsigned>(out_capacity));
120+
return 0;
121+
}
122+
memcpy(out, this->packet_scratch_.data(), static_cast<size_t>(written));
123+
return static_cast<size_t>(written);
124+
}
125+
126+
void OpusSourceEncoder::reset() {
127+
// Keeps the allocations (unlike the decode side): this encoder's format is the role's
128+
// contract for every stream it opens, so the cached lookahead stays valid too
129+
opus_encoder_ctl(this->encoder_state_.as<OpusEncoder>(), OPUS_RESET_STATE);
130+
}
131+
132+
} // namespace sendspin

0 commit comments

Comments
 (0)