Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ if(ESP_IDF_BUILD)
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_PLAYER)
endif()

if(CONFIG_SENDSPIN_ENABLE_ANNOUNCEMENT)
# Kconfig enforces the player dependency (ring buffer + decoder sources)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_ANNOUNCEMENT_SOURCES})
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_ANNOUNCEMENT)
endif()

if(CONFIG_SENDSPIN_ENABLE_CONTROLLER)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_CONTROLLER_SOURCES})
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_CONTROLLER)
Expand Down Expand Up @@ -109,6 +115,12 @@ else()
option(SENDSPIN_ENABLE_COLOR "Enable color role" ON)
option(SENDSPIN_ENABLE_ARTWORK "Enable artwork role" ON)
option(SENDSPIN_ENABLE_VISUALIZER "Enable visualizer role" ON)
option(SENDSPIN_ENABLE_ANNOUNCEMENT "Enable announcement role (requires player)" ON)

if(SENDSPIN_ENABLE_ANNOUNCEMENT AND NOT SENDSPIN_ENABLE_PLAYER)
# The announcement role reuses the player's ring buffer and decoder sources
message(FATAL_ERROR "SENDSPIN_ENABLE_ANNOUNCEMENT requires SENDSPIN_ENABLE_PLAYER")
endif()

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

Expand Down Expand Up @@ -136,6 +148,9 @@ else()
if(SENDSPIN_ENABLE_VISUALIZER)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_VISUALIZER_SOURCES})
endif()
if(SENDSPIN_ENABLE_ANNOUNCEMENT)
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_ANNOUNCEMENT_SOURCES})
endif()

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

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

config SENDSPIN_ENABLE_ANNOUNCEMENT
bool "Enable announcement role (draft)"
# Reuses the player role's ring buffer and decoder sources
depends on SENDSPIN_ENABLE_PLAYER
# Draft role (announcement@v1 spec proposal); opt-in until the spec lands
default n

endmenu
8 changes: 8 additions & 0 deletions cmake/sources.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ function(sendspin_get_sources BASE_DIR)
PARENT_SCOPE
)

# Requires SENDSPIN_PLAYER_SOURCES: reuses the player's ring buffer and decoder
set(SENDSPIN_ANNOUNCEMENT_SOURCES
${BASE_DIR}/src/announcement_role.cpp
${BASE_DIR}/src/announcement_task.cpp

PARENT_SCOPE
)

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

Expand Down
164 changes: 164 additions & 0 deletions include/sendspin/announcement_role.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// 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 announcement_role.h
/// @brief Announcement role that decodes short per-client audio clips (TTS, chimes, alerts)
/// concurrently with the media stream, with ducking hints for the embedder

#pragma once

#include "sendspin/config.h"
#include "sendspin/player_role.h" // ServerPlayerStreamObject (shared codec-parameter shape)
#include "sendspin/types.h"

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

namespace sendspin {

class SendspinClient;

// ============================================================================
// Announcement types
// ============================================================================

/// @brief Announcement stream parameters sent by the server in stream/start messages
///
/// The codec parameters share the player stream-object shape; the additional fields carry the
/// per-announcement ducking policy and optional output level.
struct ServerAnnouncementStreamObject {
/// @brief Default ducking ramp duration in milliseconds, used when the server omits the field
static constexpr uint16_t DEFAULT_DUCK_RAMP_MS = 100U;

ServerPlayerStreamObject format{};

/// Reduction in decibel (0-50) to apply to this client's own media output while the
/// announcement stream is active. 0 means no ducking.
uint8_t media_duck_db{0};

/// Ramp duration in milliseconds (0-2000) for both applying and releasing the ducking.
uint16_t duck_ramp_ms{DEFAULT_DUCK_RAMP_MS};

/// When set, the announcement should be rendered at the loudness that master volume
/// `volume` (0-100) would produce, regardless of the current master volume. When absent,
/// the announcement follows the current master volume.
std::optional<uint8_t> volume{};

bool is_complete() const {
return this->format.is_complete();
}
};

/// @brief Listener for announcement role events
///
/// THREAD SAFETY: on_announcement_write() fires on the announcement task's background thread.
/// Implementations must be thread-safe for this method. on_announcement_start(),
/// on_announcement_end(), and on_announcement_clear() fire on the main loop thread via
/// drain_events(). The listener must outlive the role.
class AnnouncementRoleListener {
public:
virtual ~AnnouncementRoleListener() = default;

/// @brief Writes decoded announcement PCM audio to the platform's announcement output
///
/// Fires on the announcement task's background thread. May block up to timeout_ms; the
/// blocking write is what paces the decode loop against the sink.
/// @param data Pointer to the decoded PCM audio data
/// @param length Number of bytes to write; always a whole number of PCM frames
/// @param timeout_ms Maximum time to wait for the write to complete
/// @return Number of bytes actually written; partial writes must be whole PCM frames
virtual size_t on_announcement_write(uint8_t* data, size_t length, uint32_t timeout_ms) = 0;

/// @brief Called when an announcement stream starts. Fires on the main loop thread
///
/// The embedder applies the ducking policy here (e.g. duck the media pipeline by
/// `params.media_duck_db` over `params.duck_ramp_ms`).
virtual void on_announcement_start(const ServerAnnouncementStreamObject& /*params*/) {}

/// @brief Called when the announcement stream ends (normally, aborted, or on transport
/// loss). Fires on the main loop thread. The embedder releases the ducking here
virtual void on_announcement_end() {}

/// @brief Called when the server clears the announcement stream (replace). Fires on the
/// main loop thread. The embedder drops any announcement audio it has buffered downstream;
/// the stream and the ducking state stay active
virtual void on_announcement_clear() {}
};

/**
* @brief Announcement role that decodes short per-client audio clips next to the media stream
*
* Owns an AnnouncementTask that runs on a background thread. Encoded announcement chunks
* arrive from the WebSocket network thread, are written into a dedicated ring buffer, decoded,
* and delivered to the platform through AnnouncementRoleListener::on_announcement_write().
* Unlike the player role there is no sample-accurate sync machinery: announcements are
* per-client, start at (or as soon as possible after) their first chunk's timestamp - which is
* also what aligns a coordinated multi-speaker announcement scheduled by the server - and are
* paced by the sink.
*
* Usage:
* 1. Implement AnnouncementRoleListener with at minimum on_announcement_write()
* 2. Add the role to the client via SendspinClient::add_announcement()
* 3. Call set_listener() with your listener implementation
*
* @code
* struct MyAnnouncementListener : AnnouncementRoleListener {
* size_t on_announcement_write(uint8_t* data, size_t len, uint32_t timeout_ms) override {
* return announcement_output.write(data, len, timeout_ms);
* }
* void on_announcement_start(const ServerAnnouncementStreamObject& params) override {
* media_mixer.apply_ducking(params.media_duck_db, params.duck_ramp_ms);
* }
* void on_announcement_end() override { media_mixer.apply_ducking(0, ramp_ms); }
* };
*
* MyAnnouncementListener listener;
* AnnouncementRoleConfig config;
* config.audio_formats = {{SendspinCodecFormat::PCM, 1, 48000, 16}};
* auto& announcement = client.add_announcement(config);
* announcement.set_listener(&listener);
* @endcode
*/
class AnnouncementRole {
friend class SendspinClient;

public:
struct Impl;

AnnouncementRole(AnnouncementRoleConfig config, SendspinClient* client);
~AnnouncementRole();

/// @brief Sets the listener for announcement events
/// @param listener Pointer to the listener implementation; must outlive this role
void set_listener(AnnouncementRoleListener* listener);

// ========================================
// Queries
// ========================================

/// @brief Returns a reference to the current announcement stream parameters
/// @return Const reference to the active announcement stream parameters.
const ServerAnnouncementStreamObject& get_current_stream_params() const;

/// @brief Returns true if announcement audio is currently being output
/// @return true while the announcement pipeline is playing, false otherwise.
bool is_playing() const;

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

} // namespace sendspin
23 changes: 23 additions & 0 deletions include/sendspin/client.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
namespace sendspin {

// Forward declarations for enabled roles
#ifdef SENDSPIN_ENABLE_ANNOUNCEMENT
class AnnouncementRole;
#endif
#ifdef SENDSPIN_ENABLE_ARTWORK
class ArtworkRole;
#endif
Expand Down Expand Up @@ -258,10 +261,27 @@ class SendspinClient {
VisualizerRole& add_visualizer(VisualizerRoleConfig config);
#endif

#ifdef SENDSPIN_ENABLE_ANNOUNCEMENT
/// @brief Adds the announcement role. Returns a reference for setting callbacks
AnnouncementRole& add_announcement(AnnouncementRoleConfig config);
#endif

// ========================================
// Role access (nullptr if not added)
// ========================================

#ifdef SENDSPIN_ENABLE_ANNOUNCEMENT
/// @brief Returns the announcement role, or nullptr if not added
/// @return Pointer to the announcement role, or nullptr
AnnouncementRole* announcement() {
return this->announcement_.get();
}
/// @brief Returns the announcement role (const), or nullptr if not added
/// @return Const pointer to the announcement role, or nullptr
const AnnouncementRole* announcement() const {
return this->announcement_.get();
}
#endif
#ifdef SENDSPIN_ENABLE_ARTWORK
/// @brief Returns the artwork role, or nullptr if not added
/// @return Pointer to the artwork role, or nullptr
Expand Down Expand Up @@ -465,6 +485,9 @@ class SendspinClient {
GroupUpdateObject group_state_{};

// Pointer fields
#ifdef SENDSPIN_ENABLE_ANNOUNCEMENT
std::unique_ptr<AnnouncementRole> announcement_;
#endif
#ifdef SENDSPIN_ENABLE_ARTWORK
std::unique_ptr<ArtworkRole> artwork_;
#endif
Expand Down
30 changes: 30 additions & 0 deletions include/sendspin/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,36 @@ struct PlayerRoleConfig {
MemoryLocation decode_buffer_location{MemoryLocation::PREFER_EXTERNAL};
};

// ============================================================================
// Announcement config types
// ============================================================================

/// @brief Configuration for the announcement role
struct AnnouncementRoleConfig {
/// @brief Default encoded announcement buffer size (~128 KB; announcements are short clips)
static constexpr size_t DEFAULT_AUDIO_BUFFER_CAPACITY = 131072U;

/// Announcement formats advertised to the server, in priority order. These are decoded on a
/// second concurrent pipeline next to the media stream, so prefer inexpensive formats
/// (mono, 16-bit, modest sample rates).
std::vector<AudioSupportedFormatObject> audio_formats{};
size_t audio_buffer_capacity{DEFAULT_AUDIO_BUFFER_CAPACITY};

bool psram_stack{false}; ///< Allocate announcement task stack in PSRAM (ESP-IDF only)

/// @brief Default FreeRTOS priority for the announcement decode task (ESP-IDF only).
/// Below the httpd server and media sync tasks so a playing announcement can never starve
/// the synchronized media pipeline.
static constexpr unsigned DEFAULT_ANNOUNCEMENT_TASK_PRIORITY = 4U;

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

/// @brief Memory placement for the decode buffer (ESP-IDF only; ignored on host).
/// Defaults to PREFER_EXTERNAL (SPIRAM).
MemoryLocation decode_buffer_location{MemoryLocation::PREFER_EXTERNAL};
};

// ============================================================================
// Artwork config types
// ============================================================================
Expand Down
Loading
Loading