Compare commits

...
Author SHA1 Message Date
Kevin Ahrendt af61645b84 [sendspin] Add the source role, streaming a microphone to the server
The server starts and stops the stream; the microphone runs only while it is
open. Each buffer is passed to sendspin-cpp with its capture time. Supports PCM
and Opus.
2026-10-26 08:30:50 -04:00
Kevin Ahrendt 099ffda05d [microphone] Pass a capture time with each audio buffer
Data callbacks can take the capture time of the buffer's first frame as a second
argument. Callbacks that only take the data keep working. Microphone platforms
publish through publish_audio_().
2026-10-26 08:30:42 -04:00
14 changed files with 688 additions and 15 deletions

No files matched your search

@@ -277,14 +277,14 @@ void I2SAudioMicrophone::mic_task(void *params) {
xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_RUNNING);
while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) {
if (this_microphone->data_callbacks_.size() > 0) {
if (this_microphone->has_data_callbacks_()) {
samples.resize(bytes_to_read);
size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS);
samples.resize(bytes_read);
if (this_microphone->correct_dc_offset_) {
this_microphone->fix_dc_offset_(samples);
}
this_microphone->data_callbacks_.call(samples);
this_microphone->publish_audio_(samples);
} else {
vTaskDelay(pdMS_TO_TICKS(READ_DURATION_MS));
}
+39 -4
View File
@@ -4,6 +4,8 @@
#include <cstddef>
#include <cstdint>
#include <type_traits>
#include <utility>
#include <vector>
#include "esphome/core/helpers.h"
@@ -16,16 +18,38 @@ enum State : uint8_t {
STATE_STOPPING,
};
/// @brief Adapts a data callback to the (data, capture_time_us) form.
///
/// A callback that already takes the capture time is returned as is; one that only takes the data is wrapped so the
/// capture time is dropped.
template<typename F> auto with_capture_time(F &&callback) {
using Fn = std::decay_t<F>;
if constexpr (std::is_invocable_v<const Fn &, const std::vector<uint8_t> &, int64_t>) {
return Fn(std::forward<F>(callback));
} else {
static_assert(std::is_invocable_v<const Fn &, const std::vector<uint8_t> &>,
"A data callback takes (const std::vector<uint8_t> &) or (const std::vector<uint8_t> &, int64_t)");
return [callback = Fn(std::forward<F>(callback))](const std::vector<uint8_t> &data, int64_t) { callback(data); };
}
}
class Microphone {
public:
virtual void start() = 0;
virtual void stop() = 0;
/// @brief Registers a callback for captured audio.
///
/// The callback takes either (const std::vector<uint8_t> &data) or (const std::vector<uint8_t> &data,
/// int64_t capture_time_us). capture_time_us is when the first frame in data was captured, in esp_timer_get_time()
/// microseconds, or 0 if unknown. Callbacks run on the microphone's own task.
template<typename F> void add_data_callback(F &&data_callback) {
this->data_callbacks_.add([this, data_callback](const std::vector<uint8_t> &data) {
this->data_callbacks_.add([this, callback = with_capture_time(std::forward<F>(data_callback))](
const std::vector<uint8_t> &data, int64_t capture_time_us) {
if (this->mute_state_) {
data_callback(std::vector<uint8_t>(data.size(), 0));
callback(std::vector<uint8_t>(data.size(), 0), capture_time_us);
} else {
data_callback(data);
callback(data, capture_time_us);
}
});
}
@@ -39,12 +63,23 @@ class Microphone {
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
protected:
/// @brief Passes captured audio to every registered callback.
/// @param data Captured audio in the microphone's format
/// @param capture_time_us When the first frame in data was captured, in esp_timer_get_time() microseconds, or 0 if
/// unknown
void publish_audio_(const std::vector<uint8_t> &data, int64_t capture_time_us = 0) {
this->data_callbacks_.call(data, capture_time_us);
}
bool has_data_callbacks_() const { return this->data_callbacks_.size() > 0; }
State state_{STATE_STOPPED};
bool mute_state_{false};
audio::AudioStreamInfo audio_stream_info_;
CallbackManager<void(const std::vector<uint8_t> &)> data_callbacks_{};
private:
CallbackManager<void(const std::vector<uint8_t> &, int64_t)> data_callbacks_{};
};
} // namespace esphome::microphone
@@ -45,8 +45,11 @@ class MicrophoneSource final {
/// @param channel 0-indexed channel number to enable
void add_channel(uint8_t channel) { this->channels_.set(channel); }
/// @brief Registers a callback for the processed audio. Takes the same callback forms as
/// Microphone::add_data_callback and passes the capture time through unchanged.
template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
this->mic_->add_data_callback([this, callback = with_capture_time(std::forward<F>(data_callback))](
const std::vector<uint8_t> &data, int64_t capture_time_us) {
if (this->enabled_ || this->passive_) {
if (this->processed_samples_ == nullptr) {
// Create vector if its unused
@@ -56,7 +59,7 @@ class MicrophoneSource final {
// Take temporary ownership of samples vector to avoid deallocation before the callback finishes
std::shared_ptr<std::vector<uint8_t>> output_samples = this->processed_samples_;
this->process_audio_(data, *output_samples);
data_callback(*output_samples);
callback(*output_samples, capture_time_us);
}
});
}
@@ -137,7 +137,7 @@ void ResamplerMicrophone::process_audio_(const std::vector<uint8_t> &data) {
}
if (this->resampler_ == nullptr) {
this->data_callbacks_.call(data);
this->publish_audio_(data);
return;
}
@@ -162,7 +162,7 @@ void ResamplerMicrophone::process_audio_(const std::vector<uint8_t> &data) {
if (results.frames_generated > 0) {
this->output_buffer_.resize(this->audio_stream_info_.frames_to_bytes(results.frames_generated));
this->data_callbacks_.call(this->output_buffer_);
this->publish_audio_(this->output_buffer_);
} else if (results.frames_used == 0) {
break; // No progress; drop the rest of the chunk instead of spinning
}
+230 -5
View File
@@ -4,16 +4,29 @@ from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32, mdns, network, psram, socket, wifi
from esphome.components import (
audio,
esp32,
mdns,
microphone,
network,
psram,
socket,
wifi,
)
from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER
from esphome.components.mdns import DOMAIN as MDNS_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_BUFFER_DURATION,
CONF_BUFFER_SIZE,
CONF_CHANNELS,
CONF_ESPHOME,
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_MICROPHONE,
CONF_MODEL,
CONF_NAME,
CONF_PROJECT,
@@ -25,6 +38,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +73,14 @@ CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code"
CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded"
CONF_ON_PAIRING_FAILED = "on_pairing_failed"
CONF_CODEC = "codec"
CONF_CHUNK_DURATION = "chunk_duration"
CONF_OPUS_BITRATE = "opus_bitrate"
CONF_OPUS_COMPLEXITY = "opus_complexity"
CONF_ON_STREAMING_STARTED = "on_streaming_started"
CONF_ON_STREAMING_STOPPED = "on_streaming_stopped"
CONF_PRESENT = "present"
# A static pairing code is exactly 8 decimal digits.
STATIC_PAIRING_CODE_DIGITS = 8
@@ -104,6 +126,15 @@ CODECS = {
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
OPUS_SAMPLE_RATE = 48000
SOURCE_CODECS = {
CODEC_OPUS: CODEC_FORMAT_OPUS,
CODEC_PCM: CODEC_FORMAT_PCM,
}
# The source role's limits, matching SourceRoleConfig in sendspin-cpp.
SOURCE_BITS_PER_SAMPLE = (16, 24, 32)
SOURCE_OPUS_SAMPLE_RATES = (8000, 12000, 16000, 24000, 48000)
SOURCE_OPUS_CHUNK_DURATIONS_MS = (5, 10, 20, 40, 60)
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
@@ -117,6 +148,7 @@ AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObj
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
SourceRoleConfig = sendspin_library_ns.struct("SourceRoleConfig")
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
# preference for the player role's transfer buffers.
@@ -176,6 +208,11 @@ class SendspinConfiguration:
visualizer_support: bool = False
pairing_code_display_support: bool = False
switch_types: set[str] = field(default_factory=set)
# Set by the sendspin.source actions and conditions, which need a source block.
source_referenced: bool = False
source_line_sense: bool = False
# The microphone's sample rate, read during final validation.
source_sample_rate: int | None = None
artwork_preferences: list[ConfigType] = field(default_factory=list)
player_config: ConfigType | None = None
@@ -270,6 +307,82 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
return config
_SOURCE_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STREAMING_STARTED, "add_on_source_streaming_started_callback"
),
automation.CallbackAutomation(
CONF_ON_STREAMING_STOPPED, "add_on_source_streaming_stopped_callback"
),
)
def _validate_source_format(config: ConfigType) -> ConfigType:
bits_per_sample = config[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]
if bits_per_sample not in SOURCE_BITS_PER_SAMPLE:
raise cv.Invalid(
f"{CONF_BITS_PER_SAMPLE} must be one of {SOURCE_BITS_PER_SAMPLE}",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
if config[CONF_CODEC] == CODEC_OPUS:
if bits_per_sample != 16:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires a {CONF_BITS_PER_SAMPLE} of 16",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
if (
config[CONF_CHUNK_DURATION].total_milliseconds
not in SOURCE_OPUS_CHUNK_DURATIONS_MS
):
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires {CONF_CHUNK_DURATION} to be one of "
f"{', '.join(f'{ms}ms' for ms in SOURCE_OPUS_CHUNK_DURATIONS_MS)}",
path=[CONF_CHUNK_DURATION],
)
return config
SOURCE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_MICROPHONE): microphone.microphone_source_schema(
min_bits_per_sample=16,
max_bits_per_sample=32,
min_channels=1,
max_channels=2,
),
cv.Optional(CONF_CODEC, default=CODEC_PCM): cv.one_of(
*SOURCE_CODECS, lower=True
),
cv.Optional(CONF_CHUNK_DURATION, default="20ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(
min=cv.TimePeriod(milliseconds=5),
max=cv.TimePeriod(milliseconds=150),
),
),
# sendspin-cpp refuses any single write longer than 5/8 of the buffer, so a short buffer
# would reject every microphone read.
cv.Optional(CONF_BUFFER_DURATION, default="500ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=100)),
),
cv.Optional(audio.CONF_BUFFER_MEMORY): cv.one_of(
*MEMORY_LOCATIONS, lower=True
),
cv.Optional(CONF_OPUS_BITRATE, default="64kbps"): cv.All(
cv.bps, cv.int_, cv.int_range(min=500, max=512000)
),
cv.Optional(CONF_OPUS_COMPLEXITY, default=2): cv.int_range(min=0, max=10),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_ON_STREAMING_STARTED): automation.validate_automation({}),
cv.Optional(CONF_ON_STREAMING_STOPPED): automation.validate_automation({}),
}
),
_validate_source_format,
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -295,6 +408,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}),
cv.Optional(CONF_SOURCE): SOURCE_SCHEMA,
}
),
cv.only_on_esp32,
@@ -335,9 +449,40 @@ def _final_validate(config: ConfigType) -> ConfigType:
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
_final_validate_source(config)
return config
def _final_validate_source(config: ConfigType) -> None:
data = _get_data()
if (source := config.get(CONF_SOURCE)) is None:
if data.source_referenced:
raise cv.Invalid(
f"The sendspin.source actions and conditions require a '{CONF_SOURCE}' "
"block on the sendspin component"
)
return
mic_source = source[CONF_MICROPHONE]
microphone.final_validate_microphone_source_schema(DOMAIN)(mic_source)
# MicrophoneSource cannot resample, so the stream takes the microphone's own rate.
mic_config = fv.get_declaring_config(mic_source[CONF_MICROPHONE])
sample_rate = mic_config.get(audio.CONF_MAX_SAMPLE_RATE)
if sample_rate is None or mic_config.get(audio.CONF_MIN_SAMPLE_RATE) != sample_rate:
raise cv.Invalid(
"The microphone used by the sendspin source must have a fixed sample rate",
path=[CONF_SOURCE, CONF_MICROPHONE],
)
if source[CONF_CODEC] == CODEC_OPUS and sample_rate not in SOURCE_OPUS_SAMPLE_RATES:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires the microphone {CONF_SAMPLE_RATE} to be one of "
f"{', '.join(str(rate) for rate in SOURCE_OPUS_SAMPLE_RATES)}",
path=[CONF_SOURCE, CONF_CODEC],
)
data.source_sample_rate = sample_rate
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -347,7 +492,8 @@ def _request_controller_role(config: ConfigType) -> ConfigType:
return config
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role.
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. The
# source action and condition also use it, and need a source block.
SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
@@ -381,6 +527,41 @@ automation.register_apply_action(
)
def _request_source(config: ConfigType) -> ConfigType:
"""Mark that a source action or condition is used, so a missing source block is reported."""
_get_data().source_referenced = True
return config
def _request_source_line_sense(config: ConfigType) -> ConfigType:
"""Advertise line sense for the sendspin.source.set_signal action."""
_get_data().source_line_sense = True
return _request_source(config)
automation.register_apply_condition(
"sendspin.source.is_streaming",
cv.All(SENDSPIN_HUB_ACTION_SCHEMA, _request_source),
"is_source_streaming()",
)
# Using the action advertises line sense, so the server knows to expect signal reports.
automation.register_apply_action(
"sendspin.source.set_signal",
cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinHub),
cv.Required(CONF_PRESENT): cv.templatable(cv.boolean),
}
),
_request_source_line_sense,
),
automation.ApplyField(CONF_PRESENT, "set_source_signal", cg.bool_),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -433,9 +614,8 @@ async def to_code(config: ConfigType) -> None:
data = _get_data()
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
# The color role is not yet wired up in ESPHome; disable it in the library for now.
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
@@ -525,7 +705,52 @@ async def to_code(config: ConfigType) -> None:
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]:
if (source := config.get(CONF_SOURCE)) is not None:
cg.add_define("USE_SENDSPIN_SOURCE", True)
mic_source = await microphone.microphone_source_to_code(source[CONF_MICROPHONE])
cg.add(var.set_microphone_source(mic_source))
source_psram_stack = source.get(CONF_TASK_STACK_IN_PSRAM, False)
if source_psram_stack:
psram.request_external_task_stack()
# Designated initializers, so the fields follow SourceRoleConfig's declaration order.
source_struct_fields = [
("sample_rate", data.source_sample_rate),
("chunk_duration_ms", source[CONF_CHUNK_DURATION]),
("capture_buffer_ms", source[CONF_BUFFER_DURATION]),
("opus_bitrate", source[CONF_OPUS_BITRATE]),
]
if (buffer_memory := source.get(audio.CONF_BUFFER_MEMORY)) is not None:
source_struct_fields.append(
("buffer_location", MEMORY_LOCATION_ENUM[buffer_memory])
)
source_struct_fields += [
("codec", SOURCE_CODECS[source[CONF_CODEC]]),
("opus_complexity", source[CONF_OPUS_COMPLEXITY]),
("channels", len(source[CONF_MICROPHONE][CONF_CHANNELS])),
("bit_depth", source[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]),
("line_sense", data.source_line_sense),
("psram_stack", source_psram_stack),
]
cg.add(
var.set_source_config(
cg.StructInitializer(SourceRoleConfig, *source_struct_fields)
)
)
await automation.build_callback_automations(
var, source, _SOURCE_CALLBACK_AUTOMATIONS
)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
player_uses_opus = (
data.player_support and CODEC_OPUS in data.player_config[CONF_CODECS]
)
source_uses_opus = source is not None and source[CONF_CODEC] == CODEC_OPUS
if not player_uses_opus and not source_uses_opus:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
if data.visualizer_support:
@@ -158,6 +158,16 @@ void SendspinHub::setup() {
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
#ifdef USE_SENDSPIN_SOURCE
this->source_role_ = &this->client_->add_source(this->source_config_);
this->source_role_->set_listener(this);
// THREAD CONTEXT: Microphone task, the role's single producer. A capture time of 0 (unknown) makes the library
// stamp the buffer as ending now.
this->microphone_source_->add_data_callback([this](const std::vector<uint8_t> &data, int64_t capture_time_us) {
this->source_role_->write_audio(data.data(), data.size(), capture_time_us);
});
#endif
// Set before setup() by codegen; an unpaired access switch sets it later, from its own setup().
if (this->unpaired_access_.has_value()) {
this->client_->set_unpaired_access_enabled(*this->unpaired_access_);
@@ -223,6 +233,23 @@ void SendspinHub::dump_config() {
preference.display_offset_ms);
}
#endif
#ifdef USE_SENDSPIN_SOURCE
ESP_LOGCONFIG(TAG,
" Source:\n"
" Codec: %s\n"
" Sample rate: %" PRIu32 " Hz\n"
" Channels: %u\n"
" Bits per sample: %u\n"
" Chunk duration: %" PRIu32 " ms\n"
" Buffer duration: %" PRIu32 " ms\n"
" Line sense: %s",
this->source_config_.codec == sendspin::SendspinCodecFormat::OPUS ? LOG_STR_LITERAL("Opus")
: LOG_STR_LITERAL("PCM"),
this->source_config_.sample_rate, this->source_config_.channels, this->source_config_.bit_depth,
this->source_config_.chunk_duration_ms, this->source_config_.capture_buffer_ms,
YESNO(this->source_config_.line_sense));
#endif
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
@@ -564,6 +591,27 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
}
#endif
#ifdef USE_SENDSPIN_SOURCE
// THREAD CONTEXT: Main loop (invoked from the sendspin.source.set_signal action)
void SendspinHub::set_source_signal(bool present) {
if (this->source_role_ != nullptr) {
this->source_role_->set_signal(present ? sendspin::SourceSignal::PRESENT : sendspin::SourceSignal::ABSENT);
}
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_started() {
this->microphone_source_->start();
this->source_streaming_started_callbacks_.call();
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_stopped() {
this->microphone_source_->stop();
this->source_streaming_stopped_callbacks_.call();
}
#endif
} // namespace esphome::sendspin_
#endif // USE_ESP_IDF
@@ -31,6 +31,11 @@
#ifdef USE_SENDSPIN_PLAYER
#include <sendspin/player_role.h>
#endif
#ifdef USE_SENDSPIN_SOURCE
#include "esphome/components/microphone/microphone_source.h"
#include <sendspin/source_role.h>
#endif
#include <array>
#include <cstddef>
@@ -75,6 +80,7 @@ enum SendspinFeature : uint32_t {
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
/// child thereafter, since no fan-out is needed.
/// - The source role streams a microphone. Its data callback runs on the microphone's task.
///
/// The sendspin-cpp library follows this design:
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
@@ -91,6 +97,9 @@ class SendspinHub final : public Component,
#endif
#ifdef USE_SENDSPIN_METADATA
public sendspin::MetadataRoleListener,
#endif
#ifdef USE_SENDSPIN_SOURCE
public sendspin::SourceRoleListener,
#endif
public sendspin::SendspinClientListener,
public sendspin::SendspinNetworkProvider,
@@ -285,6 +294,29 @@ class SendspinHub final : public Component,
sendspin::PlayerRole *get_player_role();
#endif
#ifdef USE_SENDSPIN_SOURCE
void set_source_config(const sendspin::SourceRoleConfig &config) { this->source_config_ = config; }
void set_microphone_source(microphone::MicrophoneSource *microphone_source) {
this->microphone_source_ = microphone_source;
}
/// @brief Reports whether the capture input has a signal; exposed as the sendspin.source.set_signal action.
///
/// Using the action enables line sense in the source config. Ignored before setup(). Must be called from the main
/// loop thread.
void set_source_signal(bool present);
/// @brief Returns whether the source is streaming microphone audio to the server.
bool is_source_streaming() const { return this->source_role_ != nullptr && this->source_role_->is_streaming(); }
template<typename F> void add_on_source_streaming_started_callback(F &&callback) {
this->source_streaming_started_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_source_streaming_stopped_callback(F &&callback) {
this->source_streaming_stopped_callbacks_.add(std::forward<F>(callback));
}
#endif
protected:
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
@@ -383,6 +415,20 @@ class SendspinHub final : public Component,
sendspin::PlayerRoleConfig player_config_{};
#endif
#ifdef USE_SENDSPIN_SOURCE
// The server opens and closes the stream; the microphone runs only while it is open.
void on_streaming_started() override;
void on_streaming_stopped() override;
sendspin::SourceRoleConfig source_config_{};
sendspin::SourceRole *source_role_{nullptr};
microphone::MicrophoneSource *microphone_source_{nullptr};
// Lazy: each is fed by an optional YAML automation.
LazyCallbackManager<void()> source_streaming_started_callbacks_{};
LazyCallbackManager<void()> source_streaming_stopped_callbacks_{};
#endif
// --- Core member variables ---
// Built once in setup(): make_preference() allocates a backend that is never freed.
+1
View File
@@ -518,6 +518,7 @@
#define USE_SENDSPIN_CONTROLLER
#define USE_SENDSPIN_METADATA
#define USE_SENDSPIN_PLAYER
#define USE_SENDSPIN_SOURCE
#define USE_SENDSPIN_SWITCH
#define USE_SENDSPIN_VISUALIZER
#define USE_SENDSPIN_PORT 8928 // NOLINT
@@ -0,0 +1,31 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO4
i2s_bclk_pin: GPIO5
microphone:
- platform: i2s_audio
id: mic
i2s_din_pin: GPIO39
adc_type: external
sample_rate: 48000
sendspin:
source:
microphone: mic
codec: opus
buffer_memory: internal
on_streaming_started:
- sendspin.source.set_signal:
present: true
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO4
i2s_bclk_pin: GPIO5
microphone:
- platform: i2s_audio
id: mic
i2s_din_pin: GPIO39
adc_type: external
sample_rate: 16000
sendspin:
source:
microphone: mic
@@ -0,0 +1,185 @@
"""Validation and code generation tests for the sendspin source role.
These cover the rejection branches of the source block and the generated role
config, which a compile test cannot check: a `test*.yaml` can only assert that a
configuration is accepted.
"""
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.audio import CONF_MAX_SAMPLE_RATE, CONF_MIN_SAMPLE_RATE
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
from esphome.components.sendspin import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
_get_data,
_request_source,
)
from esphome.config import Config
from esphome.const import CONF_ID, CONF_SOURCE, PlatformFramework
from esphome.core import CORE, ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "sendspin_hub"
MIC_ID = "mic"
def _full_config(min_sample_rate: int = 16000, max_sample_rate: int = 16000) -> Config:
"""A full config carrying one microphone, as the ID pass leaves it."""
mic: ConfigType = {
CONF_ID: ID(MIC_ID, is_declaration=True, type="microphone"),
CONF_MIN_SAMPLE_RATE: min_sample_rate,
CONF_MAX_SAMPLE_RATE: max_sample_rate,
"min_channels": 1,
"max_channels": 2,
"min_bits_per_sample": 16,
"max_bits_per_sample": 32,
}
full = Config()
full["microphone"] = [mic]
full.declare_ids.append((mic[CONF_ID], ["microphone", 0, CONF_ID]))
return full
def _hub_config(**source: Any) -> ConfigType:
"""Validate a hub config with a source block, allowing field overrides."""
source_config: ConfigType = {
"microphone": {"microphone": ID(MIC_ID, is_declaration=False)}
}
source_config.update(source)
return CONFIG_SCHEMA({CONF_ID: HUB_ID, CONF_SOURCE: source_config})
@pytest.fixture
def microphone(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config())
@pytest.mark.usefixtures("microphone")
def test_defaults() -> None:
config = _hub_config()
source = config[CONF_SOURCE]
assert source["codec"] == "pcm"
assert source["chunk_duration"].total_milliseconds == 20
assert source["buffer_duration"].total_milliseconds == 500
@pytest.mark.usefixtures("microphone")
def test_final_validate_records_sample_rate() -> None:
"""The stream takes the microphone's own rate, since nothing can resample it."""
FINAL_VALIDATE_SCHEMA(_hub_config())
assert _get_data().source_sample_rate == 16000
@pytest.mark.usefixtures("microphone")
def test_unsupported_bits_per_sample_rejected() -> None:
with pytest.raises(cv.Invalid, match="bits_per_sample must be one of"):
_hub_config(microphone={"microphone": MIC_ID, "bits_per_sample": 20})
@pytest.mark.usefixtures("microphone")
def test_opus_requires_16_bits() -> None:
with pytest.raises(cv.Invalid, match="requires a bits_per_sample of 16"):
_hub_config(
codec="opus", microphone={"microphone": MIC_ID, "bits_per_sample": 24}
)
@pytest.mark.usefixtures("microphone")
def test_opus_chunk_duration_rejected() -> None:
"""Opus encodes each chunk as one frame, so only its frame sizes work."""
with pytest.raises(cv.Invalid, match="requires chunk_duration to be one of"):
_hub_config(codec="opus", chunk_duration="15ms")
@pytest.mark.usefixtures("microphone")
def test_short_buffer_rejected() -> None:
"""A buffer too short for one microphone read would refuse every write."""
with pytest.raises(cv.Invalid, match="must be at least"):
_hub_config(buffer_duration="50ms")
def test_variable_sample_rate_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(min_sample_rate=16000, max_sample_rate=48000),
)
with pytest.raises(cv.Invalid, match="must have a fixed sample rate"):
FINAL_VALIDATE_SCHEMA(_hub_config())
def test_opus_sample_rate_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(min_sample_rate=44100, max_sample_rate=44100),
)
with pytest.raises(
cv.Invalid, match="requires the microphone sample_rate to be one of"
):
FINAL_VALIDATE_SCHEMA(_hub_config(codec="opus"))
def test_source_automation_without_source_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The source actions and condition have nothing to act on without a source block."""
set_core_config(PlatformFramework.ESP32_IDF)
_request_source({})
with pytest.raises(cv.Invalid, match="require a 'source' block"):
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA({CONF_ID: HUB_ID}))
def _sdkconfig_option(name: str) -> Any:
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
def test_opus_source_config_generated(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The set_signal action turns on line sense, and an Opus source keeps Opus in the library."""
main_cpp = generate_main(component_config_path("source_opus.yaml"))
assert "set_source_config(" in main_cpp
assert ".sample_rate = 48000" in main_cpp
assert ".capture_buffer_ms = 500" in main_cpp
assert ".buffer_location = sendspin::MemoryLocation::PREFER_INTERNAL" in main_cpp
assert ".codec = sendspin::SendspinCodecFormat::OPUS" in main_cpp
assert ".line_sense = true" in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is None
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is None
def test_pcm_source_config_generated(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without the set_signal action or buffer_memory, neither is set, and unused Opus is left out."""
main_cpp = generate_main(component_config_path("source_pcm.yaml"))
assert ".sample_rate = 16000" in main_cpp
assert ".codec = sendspin::SendspinCodecFormat::PCM" in main_cpp
assert ".line_sense = false" in main_cpp
assert ".buffer_location" not in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is False
def test_no_source_disables_the_role(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
assert "set_source_config(" not in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is False
@@ -0,0 +1,46 @@
packages:
sendspin: !include common.yaml
microphone:
- platform: i2s_audio
id: sendspin_mic_id
i2s_audio_id: i2s_audio_bus
i2s_din_pin: ${i2s_din_pin}
adc_type: external
channel: stereo
sample_rate: 48000
bits_per_sample: 16bit
sendspin:
source:
microphone:
microphone: sendspin_mic_id
channels: [0, 1]
bits_per_sample: 16
gain_factor: 2
codec: opus
chunk_duration: 10ms
buffer_duration: 200ms
buffer_memory: internal
opus_bitrate: 96kbps
opus_complexity: 3
task_stack_in_psram: true
on_streaming_started:
then:
- logger.log: "Source streaming started"
- sendspin.source.set_signal:
present: true
on_streaming_stopped:
then:
- logger.log: "Source streaming stopped"
- sendspin.source.set_signal:
present: !lambda return false;
interval:
- interval: 10s
then:
- if:
condition:
sendspin.source.is_streaming:
then:
- logger.log: "Source is streaming"
@@ -0,0 +1,6 @@
substitutions:
i2s_din_pin: GPIO39
packages:
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
sendspin: !include common-source.yaml
@@ -0,0 +1,21 @@
# PCM with the defaults, a mono microphone at a rate Opus does not accept
substitutions:
i2s_din_pin: GPIO39
packages:
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
sendspin: !include common.yaml
microphone:
- platform: i2s_audio
id: sendspin_mic_id
i2s_audio_id: i2s_audio_bus
i2s_din_pin: ${i2s_din_pin}
adc_type: external
sample_rate: 44100
sendspin:
source:
microphone:
microphone: sendspin_mic_id
bits_per_sample: 24