diff --git a/CMakeLists.txt b/CMakeLists.txt index c35af57..7d9a008 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/cmake/host.cmake b/cmake/host.cmake index f68ce9d..65960bd 100644 --- a/cmake/host.cmake +++ b/cmake/host.cmake @@ -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 @@ -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 diff --git a/cmake/sources.cmake b/cmake/sources.cmake index 5ef0b5e..bea3ea1 100644 --- a/cmake/sources.cmake +++ b/cmake/sources.cmake @@ -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 ) diff --git a/idf_component.yml b/idf_component.yml index 88e9d93..f5846c1 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -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: diff --git a/include/sendspin/config.h b/include/sendspin/config.h index 6903f9c..5591281 100644 --- a/include/sendspin/config.h +++ b/include/sendspin/config.h @@ -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 @@ -299,14 +300,32 @@ 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; + /// @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 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{DEFAULT_SOURCE_SAMPLE_RATE}; /// @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 @@ -316,20 +335,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) }; diff --git a/src/decoder.cpp b/src/decoder.cpp index f9c7801..eaf28ad 100644 --- a/src/decoder.cpp +++ b/src/decoder.cpp @@ -14,6 +14,7 @@ #include "decoder.h" +#include "opus_state_location.h" #include "platform/logging.h" #include @@ -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(); diff --git a/src/opus_state_location.h b/src/opus_state_location.h new file mode 100644 index 0000000..1890ff8 --- /dev/null +++ b/src/opus_state_location.h @@ -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 diff --git a/src/source_encoder_opus.cpp b/src/source_encoder_opus.cpp new file mode 100644 index 0000000..8347b2c --- /dev/null +++ b/src/source_encoder_opus.cpp @@ -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 + +#include +#include +#include + +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(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(), + static_cast(config.sample_rate), config.channels, + OPUS_APPLICATION_AUDIO); + if (err == OPUS_OK) { + err = opus_encoder_ctl(this->encoder_state_.as(), + OPUS_SET_BITRATE(static_cast(config.opus_bitrate))); + } + if (err == OPUS_OK) { + err = + opus_encoder_ctl(this->encoder_state_.as(), + OPUS_SET_COMPLEXITY(static_cast(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(), + 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(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(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(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(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(), this->pcm_scratch_.as(), + static_cast(in_len / this->bytes_per_frame_), this->packet_scratch_.data(), + static_cast(MAX_PACKET_BYTES)); + if (written <= 0) { + SS_LOGE(TAG, "Opus encode failed, error %d", static_cast(written)); + return 0; + } + if (static_cast(written) > out_capacity) { + SS_LOGE(TAG, "Opus packet of %d bytes exceeds the %u-byte payload capacity; dropping chunk", + static_cast(written), static_cast(out_capacity)); + return 0; + } + memcpy(out, this->packet_scratch_.data(), static_cast(written)); + return static_cast(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(), OPUS_RESET_STATE); +} + +} // namespace sendspin diff --git a/src/source_encoder_opus.h b/src/source_encoder_opus.h new file mode 100644 index 0000000..675f3fc --- /dev/null +++ b/src/source_encoder_opus.h @@ -0,0 +1,78 @@ +// 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_encoder_opus.h +/// @brief Opus implementation of the source encoder seam + +#pragma once + +#include "platform/memory.h" +#include "sendspin/config.h" +#include "source_encoder.h" + +namespace sendspin { + +/** + * @brief Encodes each assembled PCM chunk into exactly one bare RFC 6716 Opus packet, no + * container (Sendspin spec, Source messages); config validation guarantees a full chunk is one + * legal Opus frame, so encode() is a single opus_encode() call. All state is allocated once in + * init(); the encode path allocates nothing. + */ +class OpusSourceEncoder final : public SourceEncoder { +public: + /// @brief Upper bound on any packet encode() produces: libopus's recommended max_data_bytes + /// ("4000 bytes is recommended", opus.h), above any accepted config's worst case + /// (512 kbit/s x 60 ms = 3840). The task sizes its payload area to at least this. + static constexpr size_t MAX_PACKET_BYTES = 4000U; + + /// @brief Allocates and initializes the libopus encoder state and chunk scratch buffers + /// from an opus-validated source config + /// @param config The role config; must have passed validate_config() for OPUS. + /// @return true on success; false on allocation or libopus failure (logged; the caller + /// fails SourceTask::init() closed and the role never streams). + bool init(const SourceRoleConfig& config); + + bool can_encode(size_t in_len) const override; + + size_t encode(const uint8_t* in, size_t in_len, uint8_t* out, size_t out_capacity) override; + + int64_t lookahead_us() const override { + return this->lookahead_us_; + } + + void reset() override; + +private: + // Struct fields + /// libopus encoder state, sized by opus_encoder_get_size() and placed by the shared + /// OPUS_STATE_LOCATION rule (opus_state_location.h) + PlatformBuffer encoder_state_; + /// Aligned PCM copy of the chunk under encode; see encode() for why the input is copied + PlatformBuffer pcm_scratch_; + /// Encoded packet landing area, sized to libopus's recommended maximum; encode() copies + /// the packet out to satisfy the seam's in == out contract + PlatformBuffer packet_scratch_; + + // 64-bit fields + /// Encoder delay in µs, converted from OPUS_GET_LOOKAHEAD samples once at init + int64_t lookahead_us_{0}; + + // size_t fields + size_t bytes_per_frame_{0}; + + // 32-bit fields + uint32_t sample_rate_{0}; +}; + +} // namespace sendspin diff --git a/src/source_role.cpp b/src/source_role.cpp index f539db9..155825b 100644 --- a/src/source_role.cpp +++ b/src/source_role.cpp @@ -19,6 +19,9 @@ #include "sendspin/client.h" #include "source_role_impl.h" +#include +#include + static const char* const TAG = "sendspin.source"; namespace sendspin { @@ -32,34 +35,84 @@ namespace sendspin { /// advertised or streaming), like a player with no audio formats. static bool validate_config(const SourceRoleConfig& config) { bool valid = true; - if (config.codec != SendspinCodecFormat::PCM) { - // OPUS is a recognized config value but not implemented yet; anything else is invalid - // outright. Both reject the same way (fail closed). - SS_LOGE(TAG, "Rejecting source config: only the pcm codec is supported (got %d)", + if (config.codec != SendspinCodecFormat::PCM && config.codec != SendspinCodecFormat::OPUS) { + SS_LOGE(TAG, "Rejecting source config: codec must be pcm or opus (got %d)", static_cast(config.codec)); valid = false; } - if (config.sample_rate == 0) { - SS_LOGE(TAG, "Rejecting source config: sample_rate must be > 0"); - valid = false; - } - if (config.channels == 0) { - SS_LOGE(TAG, "Rejecting source config: channels must be > 0"); - valid = false; - } - if (config.bit_depth != 16 && config.bit_depth != 24 && config.bit_depth != 32) { - SS_LOGE(TAG, "Rejecting source config: bit_depth must be 16, 24, or 32 (got %u)", - config.bit_depth); - valid = false; - } - if (config.chunk_duration_ms < SourceRoleConfig::CHUNK_MIN_MS || - config.chunk_duration_ms > SourceRoleConfig::CHUNK_MAX_MS) { - // Sendspin spec, Source messages: MUST <= 150 ms, SHOULD >= 5 ms; the SHOULD is - // deliberately enforced as hard as the MUST - SS_LOGE(TAG, "Rejecting source config: chunk_duration_ms %u outside [%u, %u]", - config.chunk_duration_ms, SourceRoleConfig::CHUNK_MIN_MS, - SourceRoleConfig::CHUNK_MAX_MS); - valid = false; + if (config.codec == SendspinCodecFormat::OPUS) { + // libopus accepts exactly these rates; a 44100 line-in must use PCM or resample + static constexpr uint32_t OPUS_SAMPLE_RATES[] = {8000, 12000, 16000, 24000, 48000}; + // Single legal Opus frames within the spec's chunk bounds + static constexpr uint32_t OPUS_CHUNK_DURATIONS_MS[] = {10, 20, 40, 60}; + const auto contains = [](const auto& values, uint32_t value) { + return std::find(std::begin(values), std::end(values), value) != std::end(values); + }; + if (!contains(OPUS_SAMPLE_RATES, config.sample_rate)) { + SS_LOGE(TAG, + "Rejecting source config: opus sample_rate must be 8000, 12000, 16000, " + "24000, or 48000 (got %u)", + config.sample_rate); + valid = false; + } + if (config.channels != 1 && config.channels != 2) { + SS_LOGE(TAG, "Rejecting source config: opus channels must be 1 or 2 (got %u)", + config.channels); + valid = false; + } + if (config.bit_depth != 16) { + // The capture contract: the encoder consumes 16-bit PCM (the wire field is ignored + // by servers for opus per the Sendspin spec) + SS_LOGE(TAG, "Rejecting source config: opus bit_depth must be 16 (got %u)", + config.bit_depth); + valid = false; + } + if (!contains(OPUS_CHUNK_DURATIONS_MS, config.chunk_duration_ms)) { + // One chunk is one opus_encode() call, so it must be one legal frame; the PCM + // default of 25 is deliberately not remapped -- fail closed beats silent repair + SS_LOGE(TAG, + "Rejecting source config: opus chunk_duration_ms must be 10, 20, 40, or 60 " + "(got %u)", + config.chunk_duration_ms); + valid = false; + } + if (config.opus_bitrate < SourceRoleConfig::OPUS_BITRATE_MIN || + config.opus_bitrate > SourceRoleConfig::OPUS_BITRATE_MAX) { + // libopus's accepted OPUS_SET_BITRATE range + SS_LOGE(TAG, "Rejecting source config: opus_bitrate %u outside [%u, %u]", + config.opus_bitrate, SourceRoleConfig::OPUS_BITRATE_MIN, + SourceRoleConfig::OPUS_BITRATE_MAX); + valid = false; + } + if (config.opus_complexity > SourceRoleConfig::OPUS_COMPLEXITY_MAX) { + SS_LOGE(TAG, "Rejecting source config: opus_complexity %u exceeds %u", + config.opus_complexity, SourceRoleConfig::OPUS_COMPLEXITY_MAX); + valid = false; + } + } else { + // PCM format rules (also applied to a rejected codec value) + if (config.sample_rate == 0) { + SS_LOGE(TAG, "Rejecting source config: sample_rate must be > 0"); + valid = false; + } + if (config.channels == 0) { + SS_LOGE(TAG, "Rejecting source config: channels must be > 0"); + valid = false; + } + if (config.bit_depth != 16 && config.bit_depth != 24 && config.bit_depth != 32) { + SS_LOGE(TAG, "Rejecting source config: bit_depth must be 16, 24, or 32 (got %u)", + config.bit_depth); + valid = false; + } + if (config.chunk_duration_ms < SourceRoleConfig::CHUNK_MIN_MS || + config.chunk_duration_ms > SourceRoleConfig::CHUNK_MAX_MS) { + // Sendspin spec, Source messages: MUST <= 150 ms, SHOULD >= 5 ms; the SHOULD is + // deliberately enforced as hard as the MUST + SS_LOGE(TAG, "Rejecting source config: chunk_duration_ms %u outside [%u, %u]", + config.chunk_duration_ms, SourceRoleConfig::CHUNK_MIN_MS, + SourceRoleConfig::CHUNK_MAX_MS); + valid = false; + } } if (config.capture_buffer_ms == 0) { SS_LOGE(TAG, "Rejecting source config: capture_buffer_ms must be > 0"); diff --git a/src/source_task.cpp b/src/source_task.cpp index 6a67354..be9155e 100644 --- a/src/source_task.cpp +++ b/src/source_task.cpp @@ -21,6 +21,7 @@ #include "platform/thread.h" #include "platform/time.h" #include "protocol_messages.h" +#include "source_encoder_opus.h" #include "source_role_impl.h" #include "time_filter.h" @@ -33,7 +34,7 @@ namespace sendspin { static const char* const TAG = "sendspin.source_task"; /// @brief Same budget as the sync task: the deepest paths are the stream start/end JSON build -/// and the transport send +/// and the transport send; Opus working buffers live on micro-opus's pseudostack, not here static constexpr size_t SOURCE_TASK_STACK_SIZE = 6192; /// @brief Ring receive timeout (ms) bounding how long the task waits before re-checking the @@ -104,16 +105,26 @@ bool SourceTask::init(SourceRole::Impl* source_impl, SendspinClient* client, return false; } - if (!this->staging_.allocate(SOURCE_WIRE_HEADER_SIZE + this->chunk_bytes_, + size_t payload_capacity = this->chunk_bytes_; + if (config.codec == SendspinCodecFormat::OPUS) { + auto opus_encoder = std::make_unique(); + if (!opus_encoder->init(config)) { + return false; // Cause already logged; no streaming without a working encoder + } + this->encoder_ = std::move(opus_encoder); + // An opus packet can exceed the chunk's PCM size (small chunks vs a high bitrate), so + // the payload area covers the encoder's max packet: no accepted config drops on capacity + payload_capacity = std::max(payload_capacity, OpusSourceEncoder::MAX_PACKET_BYTES); + } else { + this->encoder_ = std::make_unique(); + } + + if (!this->staging_.allocate(SOURCE_WIRE_HEADER_SIZE + payload_capacity, config.buffer_location)) { SS_LOGE(TAG, "Couldn't allocate chunk staging buffer."); return false; } - // Config validation admits only PCM, so the passthrough is the only encoder to construct; - // the seam exists for the Opus encoder to slot in (see source_encoder.h) - this->encoder_ = std::make_unique(); - this->send_complete_cb_ = [this](bool ok) { this->last_send_ok_.store(ok, std::memory_order_release); this->event_flags_.set(SourceTaskBits::SOURCE_SEND_COMPLETE); diff --git a/tests/test_source_role.cpp b/tests/test_source_role.cpp index 732ae84..2dce65a 100644 --- a/tests/test_source_role.cpp +++ b/tests/test_source_role.cpp @@ -12,8 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Source role tests. Three layers, mirroring the acceptance criteria: +// Source role tests. Four layers, mirroring the acceptance criteria: // - Pure chunk/timestamp bookkeeping helpers from source_task.h, tested directly. +// - OpusSourceEncoder codec tests: encode->decode round-trip through the library's own +// decoder, lookahead conversion, the in == out aliasing contract, and packet bounds. // - SourceRole::Impl-level tests (make_impl pattern from test_artwork_role.cpp): config // validation, hello/state field building, the command latch's fail-closed path, and // lifecycle callback pairing. @@ -29,21 +31,30 @@ #include "sendspin/client.h" #include "sendspin/config.h" #include "source_encoder.h" +#include "source_encoder_opus.h" #include "source_role_impl.h" #include "source_task.h" #include #include #include +// The decode side of the round-trip tests is player-role code (decoder.cpp), absent from a +// source-only build; those tests are guarded the way any consumer guards role usage. +#ifdef SENDSPIN_ENABLE_PLAYER +#include "decoder.h" +#endif + #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -57,6 +68,8 @@ namespace { constexpr uint16_t WIRE_TEST_PORT = 19010; constexpr uint16_t TIME_GATE_TEST_PORT = 19011; constexpr uint16_t RECONNECT_TEST_PORT = 19012; +constexpr uint16_t OPUS_WIRE_TEST_PORT = 19013; +constexpr uint16_t OPUS_CAPACITY_WIRE_TEST_PORT = 19014; // Default-config wire framing, derived exactly as source_task.cpp derives it: 25 ms at // 48 kHz stereo 16-bit -> 1200 frames x 4 bytes, behind a 1-byte type + 8-byte timestamp header. @@ -95,6 +108,10 @@ TEST(SourceBookkeeping, FramesToUs) { EXPECT_EQ(source_frames_to_us(48000, 48000), 1000000); // Truncation, not rounding: 1 frame at 48 kHz is 20.83 µs. EXPECT_EQ(source_frames_to_us(1, 48000), 20); + // The opus lookahead conversion (OpusSourceEncoder::init) rides on this same helper: + // OPUS_GET_LOOKAHEAD samples at the encoder rate -> µs. + EXPECT_EQ(source_frames_to_us(312, 48000), 6500); + EXPECT_EQ(source_frames_to_us(104, 16000), 6500); } // Defends the chunk-anchor bookkeeping in SourceTask::stream(): the wire timestamp anchors on @@ -147,6 +164,263 @@ TEST(SourceBookkeeping, PcmPassthroughEncoderContract) { EXPECT_FALSE(encoder.can_encode(0)); } +// ============================================================================ +// Opus encoder (source_encoder_opus.h / .cpp) +// ============================================================================ + +/// Baseline opus source config: 48 kHz stereo 16-bit, 20 ms chunks (one legal Opus frame; the +/// PCM chunk default of 25 ms is invalid for opus). +SourceRoleConfig make_opus_config() { + SourceRoleConfig config; + config.codec = SendspinCodecFormat::OPUS; + config.chunk_duration_ms = 20; + return config; +} + +/// 440 Hz int16 sine at amplitude 8000, identical in every channel. +std::vector make_sine(size_t frames, uint8_t channels, uint32_t rate) { + std::vector pcm(frames * channels); + for (size_t i = 0; i < frames; ++i) { + const double phase = 2.0 * std::numbers::pi * 440.0 * static_cast(i) / rate; + const auto sample = static_cast(8000.0 * std::sin(phase)); + for (uint8_t ch = 0; ch < channels; ++ch) { + pcm[i * channels + ch] = sample; + } + } + return pcm; +} + +#ifdef SENDSPIN_ENABLE_PLAYER +/// Pearson correlation between the first channel of two interleaved int16 windows. Guarded +/// with the round-trip tests that use it, so a player-off build has no unused function. +double pearson_first_channel(const int16_t* a, const int16_t* b, size_t frames, + uint8_t channels) { + double sum_a = 0, sum_b = 0, sum_aa = 0, sum_bb = 0, sum_ab = 0; + for (size_t i = 0; i < frames; ++i) { + const double x = a[i * channels]; + const double y = b[i * channels]; + sum_a += x; + sum_b += y; + sum_aa += x * x; + sum_bb += y * y; + sum_ab += x * y; + } + const auto n = static_cast(frames); + const double covariance = sum_ab - sum_a * sum_b / n; + const double denom = + std::sqrt((sum_aa - sum_a * sum_a / n) * (sum_bb - sum_b * sum_b / n)); + return denom > 0 ? covariance / denom : 0.0; +} +#endif // SENDSPIN_ENABLE_PLAYER + +// Defends OpusSourceEncoder::can_encode(): only single legal Opus frame durations pass, which +// is what lets the task drop an unencodable stream-end remainder instead of padding it. +TEST(SourceOpusEncoder, CanEncodeOnlyLegalFrameDurations) { + OpusSourceEncoder encoder; + ASSERT_TRUE(encoder.init(make_opus_config())); // 48 kHz stereo: 4 bytes per frame + + // Every legal single-frame duration at 48 kHz: 2.5, 5, 10, 20, 40, 60 ms. + for (size_t frames : {120, 240, 480, 960, 1920, 2880}) { + EXPECT_TRUE(encoder.can_encode(frames * 4)) << frames; + } + // Control: anything else -- the PCM chunk default, off-by-one frame counts, a partial + // frame, and empty input -- is unencodable. + EXPECT_FALSE(encoder.can_encode(1200 * 4)); // 25 ms + EXPECT_FALSE(encoder.can_encode(479 * 4)); + EXPECT_FALSE(encoder.can_encode(961 * 4)); + EXPECT_FALSE(encoder.can_encode(6)); + EXPECT_FALSE(encoder.can_encode(0)); +} + +// Defends init()'s own fail-closed path: validate_config() rejects 44100 long before the task +// runs, but the encoder must refuse a rate libopus cannot take rather than trusting its caller. +TEST(SourceOpusEncoder, InitFailsClosedOnUnsupportedRate) { + SourceRoleConfig config = make_opus_config(); + config.sample_rate = 44100; + OpusSourceEncoder encoder; + EXPECT_FALSE(encoder.init(config)); +} + +// Defends the lookahead derivation. OPUS_GET_LOOKAHEAD returns SAMPLES at the encoder's rate; +// libopus's encoder delay is a fixed time span (~6.5 ms) at every rate, so converting against +// the wrong rate would show up here as a 3x disagreement between a 48 kHz and a 16 kHz encoder. +TEST(SourceOpusEncoder, LookaheadIsPositiveSubChunkAndRateConsistent) { + OpusSourceEncoder at48k; + ASSERT_TRUE(at48k.init(make_opus_config())); + EXPECT_GT(at48k.lookahead_us(), 0); + EXPECT_LT(at48k.lookahead_us(), 20000); // under one chunk duration + + SourceRoleConfig mono16k = make_opus_config(); + mono16k.sample_rate = 16000; + mono16k.channels = 1; + OpusSourceEncoder at16k; + ASSERT_TRUE(at16k.init(mono16k)); + EXPECT_GT(at16k.lookahead_us(), 0); + EXPECT_LT(std::llabs(at16k.lookahead_us() - at48k.lookahead_us()), 2000); +} + +// Defends the payload-capacity guarantee behind SourceTask::init()'s staging sizing: a small +// chunk (8 kHz mono: 320 PCM bytes per 20 ms) at the maximum bitrate legally produces packets +// LARGER than the chunk's PCM size, and every packet still fits MAX_PACKET_BYTES -- the +// capacity the task guarantees the payload area, so no accepted config drops on capacity. +TEST(SourceOpusEncoder, SmallChunkHighBitratePacketsFitGuaranteedCapacity) { + SourceRoleConfig config = make_opus_config(); + config.sample_rate = 8000; + config.channels = 1; + config.opus_bitrate = 512000; + OpusSourceEncoder encoder; + ASSERT_TRUE(encoder.init(config)); + + constexpr size_t CHUNK_BYTES = 160 * 2; // 20 ms of 8 kHz mono int16 + // White noise spends the most bits; a fixed LCG keeps the input reproducible. + std::vector noise(160); + uint32_t lcg = 1; + std::vector out(OpusSourceEncoder::MAX_PACKET_BYTES); + size_t max_written = 0; + for (int chunk = 0; chunk < 10; ++chunk) { + for (auto& sample : noise) { + lcg = lcg * 1664525U + 1013904223U; + sample = static_cast(lcg >> 16); + } + const size_t written = encoder.encode(reinterpret_cast(noise.data()), + CHUNK_BYTES, out.data(), out.size()); + ASSERT_GT(written, 0U); + ASSERT_LE(written, OpusSourceEncoder::MAX_PACKET_BYTES); + max_written = std::max(max_written, written); + } + // The case the guarantee exists for really occurs: noise at this bitrate produces + // packets far beyond the 320-byte PCM chunk. + EXPECT_GT(max_written, CHUNK_BYTES); + + // The capacity guard the guarantee rests on: the same encoder offered a payload area of + // only the chunk's PCM size must drop the oversized packet (return 0), never truncate it + // or write past the buffer. + for (auto& sample : noise) { + lcg = lcg * 1664525U + 1013904223U; + sample = static_cast(lcg >> 16); + } + EXPECT_EQ(0U, encoder.encode(reinterpret_cast(noise.data()), CHUNK_BYTES, + out.data(), CHUNK_BYTES)); +} + +// Defends the seam's in == out contract (the task assembles PCM into the send buffer's payload +// area and encodes in place) and reset(): aliased and separate-buffer encodes are +// byte-identical, from fresh state -- libopus output is deterministic only from identical +// encoder state, hence one fresh encoder per shape -- and reset() restores that initial state. +TEST(SourceOpusEncoder, AliasedEncodeMatchesSeparateBuffers) { + constexpr size_t CHUNK_BYTES = 960 * 4; + const auto input = make_sine(960, 2, 48000); + const auto* input_bytes = reinterpret_cast(input.data()); + + OpusSourceEncoder separate; + ASSERT_TRUE(separate.init(make_opus_config())); + std::vector out(CHUNK_BYTES); + const size_t separate_len = separate.encode(input_bytes, CHUNK_BYTES, out.data(), out.size()); + ASSERT_GT(separate_len, 0U); + + OpusSourceEncoder aliased; + ASSERT_TRUE(aliased.init(make_opus_config())); + std::vector in_place(CHUNK_BYTES); + memcpy(in_place.data(), input_bytes, CHUNK_BYTES); + const size_t aliased_len = + aliased.encode(in_place.data(), CHUNK_BYTES, in_place.data(), in_place.size()); + ASSERT_EQ(aliased_len, separate_len); + EXPECT_EQ(0, memcmp(out.data(), in_place.data(), separate_len)); + + // reset() keeps the allocation but restores the initial state: the same chunk encodes to + // the same packet again. + separate.reset(); + std::vector after_reset(CHUNK_BYTES); + const size_t reset_len = + separate.encode(input_bytes, CHUNK_BYTES, after_reset.data(), after_reset.size()); + ASSERT_EQ(reset_len, separate_len); + EXPECT_EQ(0, memcmp(out.data(), after_reset.data(), separate_len)); +} + +#ifdef SENDSPIN_ENABLE_PLAYER +// The codec acceptance proof: a deterministic signal encoded chunk by chunk produces exactly +// one nonempty, bounded RFC 6716 packet per chunk that the library's own opus decode path -- +// configured from rate/channels alone, as a Sendspin server is (dummy header, no container) -- +// accepts and reconstructs within lossy tolerance. +TEST(SourceOpusEncoder, RoundTripThroughLibraryDecoder) { + constexpr uint32_t RATE = 48000; + constexpr uint8_t CHANNELS = 2; + constexpr size_t CHUNK_FRAMES = 960; // 20 ms + constexpr size_t CHUNK_BYTES = CHUNK_FRAMES * CHANNELS * 2; + constexpr size_t NUM_CHUNKS = 25; // half a second + + OpusSourceEncoder encoder; + ASSERT_TRUE(encoder.init(make_opus_config())); + + SendspinDecoder decoder; + const DummyHeader header{RATE, 16, CHANNELS}; + uint8_t header_bytes[sizeof(DummyHeader)]; + memcpy(header_bytes, &header, sizeof(header)); + AudioStreamInfo stream_info; + ASSERT_TRUE(decoder.process_header(header_bytes, sizeof(header_bytes), + CHUNK_TYPE_OPUS_DUMMY_HEADER, &stream_info)); + + const auto input = make_sine(CHUNK_FRAMES * NUM_CHUNKS, CHANNELS, RATE); + std::vector decoded(input.size()); + std::vector packet(CHUNK_BYTES); + std::vector decode_buf(decoder.get_decode_buffer_size()); + + for (size_t chunk = 0; chunk < NUM_CHUNKS; ++chunk) { + const auto* in_bytes = + reinterpret_cast(input.data() + chunk * CHUNK_FRAMES * CHANNELS); + const size_t written = encoder.encode(in_bytes, CHUNK_BYTES, packet.data(), packet.size()); + // Packet bound: nonempty and inside the 4000-byte scratch (libopus's recommended + // maximum) at the default bitrate. + ASSERT_GT(written, 0U); + ASSERT_LE(written, 4000U); + + size_t decoded_size = 0; + ASSERT_TRUE(decoder.decode_audio_chunk(packet.data(), written, decode_buf.data(), + decode_buf.size(), &decoded_size)); + // One packet per chunk, decoding to exactly the configured chunk duration. + ASSERT_EQ(decoded_size, CHUNK_BYTES); + memcpy(decoded.data() + chunk * CHUNK_FRAMES * CHANNELS, decode_buf.data(), decoded_size); + } + + // The decoded stream lags the input by the encoder's pre-skip: decoded[i] reproduces + // input[i - preskip]. Align by the encoder's own lookahead value, as a server timestamping + // consumer effectively does. + const int64_t lookahead_us = encoder.lookahead_us(); + ASSERT_GT(lookahead_us, 0); + const auto preskip = static_cast( + (lookahead_us * RATE + (US_PER_SECOND / 2)) / US_PER_SECOND); + + // Shape over a mid-signal window (100 ms in, past codec warmup): loose lossy tolerance -- + // correlation and energy, never sample equality. + constexpr size_t WINDOW_START = 4800; + constexpr size_t WINDOW_FRAMES = 14400; + const double aligned = + pearson_first_channel(input.data() + WINDOW_START * CHANNELS, + decoded.data() + (WINDOW_START + preskip) * CHANNELS, + WINDOW_FRAMES, CHANNELS); + EXPECT_GT(aligned, 0.9); + + // Control: a quarter-period misalignment (27 frames of 440 Hz at 48 kHz) collapses the + // correlation, so the assertion above genuinely depends on the pre-skip alignment. + const double misaligned = + pearson_first_channel(input.data() + WINDOW_START * CHANNELS, + decoded.data() + (WINDOW_START + preskip + 27) * CHANNELS, + WINDOW_FRAMES, CHANNELS); + EXPECT_LT(misaligned, 0.5); + + // Energy within a factor of four (amplitude within a factor of two) over the same window. + double input_energy = 0, decoded_energy = 0; + for (size_t i = WINDOW_START; i < WINDOW_START + WINDOW_FRAMES; ++i) { + const double x = input[i * CHANNELS]; + const double y = decoded[(i + preskip) * CHANNELS]; + input_energy += x * x; + decoded_energy += y * y; + } + EXPECT_GT(decoded_energy, input_energy / 4); + EXPECT_LT(decoded_energy, input_energy * 4); +} +#endif // SENDSPIN_ENABLE_PLAYER + // ============================================================================ // Impl-level harness // ============================================================================ @@ -252,6 +526,82 @@ TEST(SourceConfigValidation, FormatFields) { } } +// Defends the opus branch of validate_config() (source_role.cpp): every codec-narrowed field +// rejects fail-closed with an accepting control, and pcm is untouched by the new rules. +TEST(SourceConfigValidation, OpusFormatRules) { + auto opus_with = [](const auto& mutate) { + SourceRoleConfig config = make_opus_config(); + mutate(config); + return make_impl(config); + }; + // Control: the baseline opus config (48 kHz stereo 16-bit 20 ms) is accepted. + EXPECT_TRUE(advertises_source(*opus_with([](auto&) {}))); + + // sample_rate: only the rates libopus accepts. + EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.sample_rate = 44100; }))); + for (uint32_t rate : {8000, 12000, 16000, 24000, 48000}) { + EXPECT_TRUE(advertises_source(*opus_with([&](auto& c) { c.sample_rate = rate; }))) + << rate; + } + + // chunk_duration_ms: one legal Opus frame; the PCM default (25) is rejected, not remapped. + EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.chunk_duration_ms = 25; }))); + EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.chunk_duration_ms = 5; }))); + for (uint32_t ms : {10, 20, 40, 60}) { + EXPECT_TRUE(advertises_source(*opus_with([&](auto& c) { c.chunk_duration_ms = ms; }))) + << ms; + } + + // channels: mono or stereo only (pcm accepts any nonzero count). + EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.channels = 3; }))); + EXPECT_TRUE(advertises_source(*opus_with([](auto& c) { c.channels = 1; }))); + + // bit_depth: the opus capture contract is 16-bit. + EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.bit_depth = 24; }))); + + // opus_bitrate: libopus's accepted range, boundaries included. + EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.opus_bitrate = 400; }))); + EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.opus_bitrate = 512001; }))); + EXPECT_TRUE(advertises_source(*opus_with([](auto& c) { c.opus_bitrate = 500; }))); + EXPECT_TRUE(advertises_source(*opus_with([](auto& c) { c.opus_bitrate = 512000; }))); + + // opus_complexity: at most 10. + EXPECT_FALSE(advertises_source(*opus_with([](auto& c) { c.opus_complexity = 11; }))); + EXPECT_TRUE(advertises_source(*opus_with([](auto& c) { c.opus_complexity = 10; }))); + + // Control: pcm is untouched by every rule above -- 44100/24-bit/25 ms stays accepted, and + // the opus fields are ignored however invalid. + SourceRoleConfig pcm; + pcm.sample_rate = 44100; + pcm.bit_depth = 24; + pcm.opus_bitrate = 0; + pcm.opus_complexity = 255; + EXPECT_TRUE(advertises_source(*make_impl(pcm))); +} + +// Defends the codec gate end to end: an invalid opus config is rejected as inert (no +// advertisement, no task) with the role still starting cleanly, while a valid one initializes +// the task -- constructing a real Opus encoder -- exactly like pcm. +TEST(SourceConfigValidation, OpusConfigGatesTaskInit) { + { + SourceRoleConfig config = make_opus_config(); + config.sample_rate = 44100; + auto impl = make_impl(config); + EXPECT_FALSE(advertises_source(*impl)); + EXPECT_TRUE(impl->start()); + EXPECT_FALSE(impl->task->is_initialized()); + // Writes on an inert role are rejected, not crashed on. + const uint8_t frame[4] = {0, 0, 0, 0}; + EXPECT_FALSE(impl->write_audio(frame, sizeof(frame), 0)); + } + { + auto impl = make_impl(make_opus_config()); + EXPECT_TRUE(advertises_source(*impl)); + EXPECT_TRUE(impl->start()); + EXPECT_TRUE(impl->task->is_initialized()); + } +} + // Defends build_hello_fields(): a valid role advertises source@v1 with the support object // carrying the configured line_sense flag. TEST(SourceHello, AdvertisesSupportObject) { @@ -753,4 +1103,142 @@ TEST(SourceWire, PermissionDoesNotSurviveReconnect) { 4000)); } +#ifdef SENDSPIN_ENABLE_PLAYER +// The opus acceptance shape end to end: a source configured {opus, 48 kHz, stereo, 16-bit, +// 20 ms} announces the opus codec and streams one RFC 6716 packet per chunk that the library's +// own decode path accepts, with wire timestamps compensated by the real encoder pre-skip. +// (Player-gated for the decode side, like the round-trip test.) +TEST(SourceWire, StreamsOpusPacketsEndToEnd) { + SourceRoleConfig role_config = make_opus_config(); + WireHarness harness(OPUS_WIRE_TEST_PORT, role_config); + ASSERT_TRUE(harness.start()); + FakeSourceServer fake(server_url(OPUS_WIRE_TEST_PORT), "source-server-o"); + ASSERT_TRUE(harness.establish(fake)); + + fake.send_source_command("start"); + ASSERT_TRUE(pump_until( + harness.client, + [&] { + return harness.listener.started == 1 && + fake.count_text_containing("client-stream/start") == 1; + }, + 4000)); + // The announced format carries the opus contract. + EXPECT_EQ(fake.count_text_containing(R"("codec":"opus")"), 1U); + + // 40 ms of sine in 10 ms writes with contiguous capture stamps: exactly two 20 ms chunks + // and no stream-end remainder. + const int64_t base = platform_time_us(); + const auto sine = make_sine(480 * 4, 2, 48000); + for (int i = 0; i < 4; ++i) { + const auto* bytes = reinterpret_cast(sine.data() + i * 480 * 2); + ASSERT_TRUE(harness.client.source()->write_audio(bytes, 480 * 4, base + i * 10000)); + } + ASSERT_TRUE(pump_until( + harness.client, [&] { return fake.binary_count() == 2; }, 4000)); + + fake.send_source_command("stop"); + ASSERT_TRUE(pump_until( + harness.client, + [&] { + return harness.listener.stopped == 1 && + fake.count_text_containing("client-stream/end") == 1; + }, + 4000)); + + const auto events = fake.snapshot(); + std::vector chunks; + for (const auto& event : events) { + if (event.binary) { + chunks.push_back(event.data); + } + } + ASSERT_EQ(chunks.size(), 2U); + + // Each chunk decodes -- from rate/channels alone, as a server would -- to exactly one + // 20 ms frame, proving one bare packet per chunk. + SendspinDecoder decoder; + const DummyHeader header{48000, 16, 2}; + uint8_t header_bytes[sizeof(DummyHeader)]; + memcpy(header_bytes, &header, sizeof(header)); + AudioStreamInfo stream_info; + ASSERT_TRUE(decoder.process_header(header_bytes, sizeof(header_bytes), + CHUNK_TYPE_OPUS_DUMMY_HEADER, &stream_info)); + std::vector decode_buf(decoder.get_decode_buffer_size()); + for (const auto& chunk : chunks) { + ASSERT_GT(chunk.size(), WIRE_HEADER); + EXPECT_EQ(static_cast(chunk[0]), SENDSPIN_BINARY_SOURCE_AUDIO); + size_t decoded_size = 0; + ASSERT_TRUE(decoder.decode_audio_chunk( + reinterpret_cast(chunk.data()) + WIRE_HEADER, + chunk.size() - WIRE_HEADER, decode_buf.data(), decode_buf.size(), &decoded_size)); + EXPECT_EQ(decoded_size, 960U * 4U); + } + + // Timestamps: the first chunk's anchor is `base` minus the encoder pre-skip (zero-offset + // time sync keeps the server-domain value near the supplied stamps; the window below is + // wide enough for filter jitter but excludes an uncompensated timestamp). A reference + // encoder at the same settings supplies the expected lookahead. + OpusSourceEncoder reference; + ASSERT_TRUE(reference.init(role_config)); + const int64_t lookahead_us = reference.lookahead_us(); + const int64_t ts1 = read_be64(reinterpret_cast(chunks[0].data()) + 1); + EXPECT_GT(base - ts1, lookahead_us - 4000); + EXPECT_LT(base - ts1, lookahead_us + 4000); + + // Chunk cadence: the second anchor is exactly one 20 ms chunk after the first, with slack + // for the filter refining its offset between the sends. + const int64_t ts2 = read_be64(reinterpret_cast(chunks[1].data()) + 1); + EXPECT_NEAR(static_cast(ts2 - ts1), 20000.0, 2000.0); +} +#endif // SENDSPIN_ENABLE_PLAYER + +// Defends SourceTask::init()'s payload-capacity guarantee at the task layer: with a small PCM +// chunk (8 kHz mono: 320 bytes per 20 ms) at the maximum bitrate, noise packets exceed the +// chunk's PCM size, so a staging payload sized to the chunk alone (dropping init()'s +// max(chunk, MAX_PACKET_BYTES)) would fail every send on the encoder's capacity guard and no +// binary chunk would ever reach the wire. +TEST(SourceWire, OversizedOpusPacketsStillFitStaging) { + SourceRoleConfig role_config; + role_config.codec = SendspinCodecFormat::OPUS; + role_config.sample_rate = 8000; + role_config.channels = 1; + role_config.bit_depth = 16; + role_config.chunk_duration_ms = 20; + role_config.opus_bitrate = 512000; + WireHarness harness(OPUS_CAPACITY_WIRE_TEST_PORT, role_config); + ASSERT_TRUE(harness.start()); + FakeSourceServer fake(server_url(OPUS_CAPACITY_WIRE_TEST_PORT), "source-server-c"); + ASSERT_TRUE(harness.establish(fake)); + + fake.send_source_command("start"); + ASSERT_TRUE(pump_until( + harness.client, [&] { return harness.listener.started == 1; }, 4000)); + + // 40 ms of white noise in 10 ms writes with contiguous capture stamps: exactly two 20 ms + // chunks, each spending enough bits at 512 kbit/s to outgrow its own PCM. + constexpr size_t WRITE_FRAMES = 80; // 10 ms of 8 kHz mono + const int64_t base = platform_time_us(); + std::vector noise(WRITE_FRAMES); + uint32_t lcg = 1; + for (int i = 0; i < 4; ++i) { + for (auto& sample : noise) { + lcg = lcg * 1664525U + 1013904223U; + sample = static_cast(lcg >> 16); + } + ASSERT_TRUE(harness.client.source()->write_audio( + reinterpret_cast(noise.data()), WRITE_FRAMES * 2, base + i * 10000)); + } + ASSERT_TRUE(pump_until( + harness.client, [&] { return fake.binary_count() == 2; }, 4000)); + + // The guarantee's premise really held on the wire: each payload outgrew the PCM chunk. + constexpr size_t CHUNK_BYTES = 160 * 2; + for (const auto& event : fake.snapshot()) { + if (event.binary) { + EXPECT_GT(event.data.size() - WIRE_HEADER, CHUNK_BYTES); + } + } +} + } // namespace