Skip to content

Commit dbbd93e

Browse files
committed
Add announcement role (announcement@v1)
1 parent 9331ace commit dbbd93e

17 files changed

Lines changed: 2200 additions & 24 deletions

CMakeLists.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ if(ESP_IDF_BUILD)
5353
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_PLAYER)
5454
endif()
5555

56+
if(CONFIG_SENDSPIN_ENABLE_ANNOUNCEMENT)
57+
# Kconfig enforces the player dependency (ring buffer + decoder sources)
58+
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_ANNOUNCEMENT_SOURCES})
59+
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_ANNOUNCEMENT)
60+
endif()
61+
5662
if(CONFIG_SENDSPIN_ENABLE_CONTROLLER)
5763
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_CONTROLLER_SOURCES})
5864
list(APPEND SENDSPIN_COMPILE_DEFS SENDSPIN_ENABLE_CONTROLLER)
@@ -109,6 +115,12 @@ else()
109115
option(SENDSPIN_ENABLE_COLOR "Enable color role" ON)
110116
option(SENDSPIN_ENABLE_ARTWORK "Enable artwork role" ON)
111117
option(SENDSPIN_ENABLE_VISUALIZER "Enable visualizer role" ON)
118+
option(SENDSPIN_ENABLE_ANNOUNCEMENT "Enable announcement role (requires player)" ON)
119+
120+
if(SENDSPIN_ENABLE_ANNOUNCEMENT AND NOT SENDSPIN_ENABLE_PLAYER)
121+
# The announcement role reuses the player's ring buffer and decoder sources
122+
message(FATAL_ERROR "SENDSPIN_ENABLE_ANNOUNCEMENT requires SENDSPIN_ENABLE_PLAYER")
123+
endif()
112124

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

@@ -136,6 +148,9 @@ else()
136148
if(SENDSPIN_ENABLE_VISUALIZER)
137149
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_VISUALIZER_SOURCES})
138150
endif()
151+
if(SENDSPIN_ENABLE_ANNOUNCEMENT)
152+
list(APPEND SENDSPIN_ALL_SOURCES ${SENDSPIN_ANNOUNCEMENT_SOURCES})
153+
endif()
139154

140155
# Create the library — core sources + enabled role sources + host networking (added via host.cmake)
141156
add_library(sendspin STATIC ${SENDSPIN_ALL_SOURCES})
@@ -159,6 +174,9 @@ else()
159174
if(SENDSPIN_ENABLE_VISUALIZER)
160175
target_compile_definitions(sendspin PUBLIC SENDSPIN_ENABLE_VISUALIZER)
161176
endif()
177+
if(SENDSPIN_ENABLE_ANNOUNCEMENT)
178+
target_compile_definitions(sendspin PUBLIC SENDSPIN_ENABLE_ANNOUNCEMENT)
179+
endif()
162180

163181
# Host build options
164182
option(ENABLE_WERROR "Treat warnings as errors" OFF)

Kconfig

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,11 @@ menu "sendspin-cpp"
3535
bool "Enable visualizer role"
3636
default y
3737

38+
config SENDSPIN_ENABLE_ANNOUNCEMENT
39+
bool "Enable announcement role (draft)"
40+
# Reuses the player role's ring buffer and decoder sources
41+
depends on SENDSPIN_ENABLE_PLAYER
42+
# Draft role (announcement@v1 spec proposal); opt-in until the spec lands
43+
default n
44+
3845
endmenu

cmake/sources.cmake

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ function(sendspin_get_sources BASE_DIR)
3636
PARENT_SCOPE
3737
)
3838

39+
# Requires SENDSPIN_PLAYER_SOURCES: reuses the player's ring buffer and decoder
40+
set(SENDSPIN_ANNOUNCEMENT_SOURCES
41+
${BASE_DIR}/src/announcement_role.cpp
42+
${BASE_DIR}/src/announcement_task.cpp
43+
44+
PARENT_SCOPE
45+
)
46+
3947
set(SENDSPIN_CONTROLLER_SOURCES
4048
${BASE_DIR}/src/controller_role.cpp
4149

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright 2026 Sendspin Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/// @file announcement_role.h
16+
/// @brief Announcement role that decodes short per-client audio clips (TTS, chimes, alerts)
17+
/// concurrently with the media stream, with ducking hints for the embedder
18+
19+
#pragma once
20+
21+
#include "sendspin/config.h"
22+
#include "sendspin/player_role.h" // ServerPlayerStreamObject (shared codec-parameter shape)
23+
#include "sendspin/types.h"
24+
25+
#include <cstddef>
26+
#include <cstdint>
27+
#include <memory>
28+
#include <optional>
29+
30+
namespace sendspin {
31+
32+
class SendspinClient;
33+
34+
// ============================================================================
35+
// Announcement types
36+
// ============================================================================
37+
38+
/// @brief Announcement stream parameters sent by the server in stream/start messages
39+
///
40+
/// The codec parameters share the player stream-object shape; the additional fields carry the
41+
/// per-announcement ducking policy and optional output level.
42+
struct ServerAnnouncementStreamObject {
43+
/// @brief Default ducking ramp duration in milliseconds, used when the server omits the field
44+
static constexpr uint16_t DEFAULT_DUCK_RAMP_MS = 100U;
45+
46+
ServerPlayerStreamObject format{};
47+
48+
/// Server clock time in microseconds when announcement output should begin. The client
49+
/// translates it via clock synchronization and starts at that time, or as soon as possible
50+
/// if it has already passed. The same value on several clients gives a coordinated
51+
/// multi-speaker start. Required.
52+
int64_t start_timestamp{0};
53+
54+
/// Reduction in decibel (0-50) to apply to this client's own media output while the
55+
/// announcement stream is active. 0 means no ducking.
56+
uint8_t media_duck_db{0};
57+
58+
/// Ramp duration in milliseconds (0-2000) for both applying and releasing the ducking.
59+
uint16_t duck_ramp_ms{DEFAULT_DUCK_RAMP_MS};
60+
61+
/// When set, the announcement should be rendered at the loudness that master volume
62+
/// `volume` (0-100) would produce, regardless of the current master volume. When absent,
63+
/// the announcement follows the current master volume.
64+
std::optional<uint8_t> volume{};
65+
66+
/// When true, a muted client still renders the announcement while its media stays muted.
67+
bool override_mute{false};
68+
69+
bool is_complete() const {
70+
return this->format.is_complete();
71+
}
72+
};
73+
74+
/// @brief Listener for announcement role events
75+
///
76+
/// THREAD SAFETY: on_announcement_write() fires on the announcement task's background thread.
77+
/// Implementations must be thread-safe for this method. on_announcement_start() and
78+
/// on_announcement_end() fire on the main loop thread via drain_events(). The listener must
79+
/// outlive the role.
80+
class AnnouncementRoleListener {
81+
public:
82+
virtual ~AnnouncementRoleListener() = default;
83+
84+
/// @brief Writes decoded announcement PCM audio to the platform's announcement output
85+
///
86+
/// Fires on the announcement task's background thread. May block up to timeout_ms; the
87+
/// blocking write is what paces the decode loop against the sink.
88+
/// @param data Pointer to the decoded PCM audio data
89+
/// @param length Number of bytes to write; always a whole number of PCM frames
90+
/// @param timeout_ms Maximum time to wait for the write to complete
91+
/// @return Number of bytes actually written; partial writes must be whole PCM frames
92+
virtual size_t on_announcement_write(uint8_t* data, size_t length, uint32_t timeout_ms) = 0;
93+
94+
/// @brief Called when an announcement stream starts. Fires on the main loop thread
95+
///
96+
/// The embedder applies the ducking policy here (e.g. duck the media pipeline by
97+
/// `params.media_duck_db` over `params.duck_ramp_ms`) and honors `params.override_mute` and
98+
/// `params.volume`. Also fires when the server re-sends `stream/start` to update the active
99+
/// announcement's config (duck level, volume, or override_mute): the embedder re-applies the
100+
/// updated policy, ramping a new `media_duck_db` from the current gain, without any
101+
/// intervening on_announcement_end().
102+
virtual void on_announcement_start(const ServerAnnouncementStreamObject& /*params*/) {}
103+
104+
/// @brief Called when the announcement stream ends (normally, aborted, or on transport
105+
/// loss). Fires on the main loop thread. The embedder releases the ducking here
106+
virtual void on_announcement_end() {}
107+
};
108+
109+
/**
110+
* @brief Announcement role that decodes short per-client audio clips next to the media stream
111+
*
112+
* Owns an AnnouncementTask that runs on a background thread. Encoded announcement chunks
113+
* arrive from the WebSocket network thread, are written into a dedicated ring buffer, decoded,
114+
* and delivered to the platform through AnnouncementRoleListener::on_announcement_write().
115+
* Unlike the player role there is no sample-accurate sync machinery: announcements are
116+
* per-client, start at (or as soon as possible after) the stream's single `start_timestamp` -
117+
* which is also what aligns a coordinated multi-speaker announcement scheduled by the server -
118+
* and are then paced by the sink.
119+
*
120+
* Usage:
121+
* 1. Implement AnnouncementRoleListener with at minimum on_announcement_write()
122+
* 2. Add the role to the client via SendspinClient::add_announcement()
123+
* 3. Call set_listener() with your listener implementation
124+
*
125+
* @code
126+
* struct MyAnnouncementListener : AnnouncementRoleListener {
127+
* size_t on_announcement_write(uint8_t* data, size_t len, uint32_t timeout_ms) override {
128+
* return announcement_output.write(data, len, timeout_ms);
129+
* }
130+
* void on_announcement_start(const ServerAnnouncementStreamObject& params) override {
131+
* media_mixer.apply_ducking(params.media_duck_db, params.duck_ramp_ms);
132+
* }
133+
* void on_announcement_end() override { media_mixer.apply_ducking(0, ramp_ms); }
134+
* };
135+
*
136+
* MyAnnouncementListener listener;
137+
* AnnouncementRoleConfig config;
138+
* config.audio_formats = {{SendspinCodecFormat::PCM, 1, 48000, 16}};
139+
* auto& announcement = client.add_announcement(config);
140+
* announcement.set_listener(&listener);
141+
* @endcode
142+
*/
143+
class AnnouncementRole {
144+
friend class SendspinClient;
145+
146+
public:
147+
struct Impl;
148+
149+
AnnouncementRole(AnnouncementRoleConfig config, SendspinClient* client);
150+
~AnnouncementRole();
151+
152+
/// @brief Sets the listener for announcement events
153+
/// @param listener Pointer to the listener implementation; must outlive this role
154+
void set_listener(AnnouncementRoleListener* listener);
155+
156+
// ========================================
157+
// Queries
158+
// ========================================
159+
160+
/// @brief Returns a reference to the current announcement stream parameters
161+
/// @return Const reference to the active announcement stream parameters.
162+
const ServerAnnouncementStreamObject& get_current_stream_params() const;
163+
164+
/// @brief Returns true if announcement audio is currently being output
165+
/// @return true while the announcement pipeline is playing, false otherwise.
166+
bool is_playing() const;
167+
168+
private:
169+
std::unique_ptr<Impl> impl_;
170+
};
171+
172+
} // namespace sendspin

include/sendspin/client.h

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
namespace sendspin {
3333

3434
// Forward declarations for enabled roles
35+
#ifdef SENDSPIN_ENABLE_ANNOUNCEMENT
36+
class AnnouncementRole;
37+
#endif
3538
#ifdef SENDSPIN_ENABLE_ARTWORK
3639
class ArtworkRole;
3740
#endif
@@ -312,10 +315,27 @@ class SendspinClient {
312315
VisualizerRole& add_visualizer(VisualizerRoleConfig config);
313316
#endif
314317

318+
#ifdef SENDSPIN_ENABLE_ANNOUNCEMENT
319+
/// @brief Adds the announcement role. Returns a reference for setting callbacks
320+
AnnouncementRole& add_announcement(AnnouncementRoleConfig config);
321+
#endif
322+
315323
// ========================================
316324
// Role access (nullptr if not added)
317325
// ========================================
318326

327+
#ifdef SENDSPIN_ENABLE_ANNOUNCEMENT
328+
/// @brief Returns the announcement role, or nullptr if not added
329+
/// @return Pointer to the announcement role, or nullptr
330+
AnnouncementRole* announcement() {
331+
return this->announcement_.get();
332+
}
333+
/// @brief Returns the announcement role (const), or nullptr if not added
334+
/// @return Const pointer to the announcement role, or nullptr
335+
const AnnouncementRole* announcement() const {
336+
return this->announcement_.get();
337+
}
338+
#endif
319339
#ifdef SENDSPIN_ENABLE_ARTWORK
320340
/// @brief Returns the artwork role, or nullptr if not added
321341
/// @return Pointer to the artwork role, or nullptr
@@ -538,6 +558,9 @@ class SendspinClient {
538558
GroupUpdateObject group_state_{};
539559

540560
// Pointer fields
561+
#ifdef SENDSPIN_ENABLE_ANNOUNCEMENT
562+
std::unique_ptr<AnnouncementRole> announcement_;
563+
#endif
541564
#ifdef SENDSPIN_ENABLE_ARTWORK
542565
std::unique_ptr<ArtworkRole> artwork_;
543566
#endif

include/sendspin/config.h

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,36 @@ struct PlayerRoleConfig {
162162
MemoryLocation decode_buffer_location{MemoryLocation::PREFER_EXTERNAL};
163163
};
164164

165+
// ============================================================================
166+
// Announcement config types
167+
// ============================================================================
168+
169+
/// @brief Configuration for the announcement role
170+
struct AnnouncementRoleConfig {
171+
/// @brief Default encoded announcement buffer size (~128 KB; announcements are short clips)
172+
static constexpr size_t DEFAULT_AUDIO_BUFFER_CAPACITY = 131072U;
173+
174+
/// Announcement formats advertised to the server, in priority order. These are decoded on a
175+
/// second concurrent pipeline next to the media stream, so prefer inexpensive formats
176+
/// (mono, 16-bit, modest sample rates).
177+
std::vector<AudioSupportedFormatObject> audio_formats{};
178+
size_t audio_buffer_capacity{DEFAULT_AUDIO_BUFFER_CAPACITY};
179+
180+
bool psram_stack{false}; ///< Allocate announcement task stack in PSRAM (ESP-IDF only)
181+
182+
/// @brief Default FreeRTOS priority for the announcement decode task (ESP-IDF only).
183+
/// Below the httpd server and media sync tasks so a playing announcement can never starve
184+
/// the synchronized media pipeline.
185+
static constexpr unsigned DEFAULT_ANNOUNCEMENT_TASK_PRIORITY = 4U;
186+
187+
unsigned priority{DEFAULT_ANNOUNCEMENT_TASK_PRIORITY}; ///< FreeRTOS priority for the
188+
///< announcement task (ESP-IDF only)
189+
190+
/// @brief Memory placement for the decode buffer (ESP-IDF only; ignored on host).
191+
/// Defaults to PREFER_EXTERNAL (SPIRAM).
192+
MemoryLocation decode_buffer_location{MemoryLocation::PREFER_EXTERNAL};
193+
};
194+
165195
// ============================================================================
166196
// Artwork config types
167197
// ============================================================================

0 commit comments

Comments
 (0)