Skip to content

Commit 585114c

Browse files
committed
Adopt single announcement start timestamp; drop stream/clear; add override_mute
1 parent 9ff2822 commit 585114c

8 files changed

Lines changed: 213 additions & 125 deletions

File tree

include/sendspin/announcement_role.h

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ struct ServerAnnouncementStreamObject {
4545

4646
ServerPlayerStreamObject format{};
4747

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+
4854
/// Reduction in decibel (0-50) to apply to this client's own media output while the
4955
/// announcement stream is active. 0 means no ducking.
5056
uint8_t media_duck_db{0};
@@ -57,6 +63,9 @@ struct ServerAnnouncementStreamObject {
5763
/// the announcement follows the current master volume.
5864
std::optional<uint8_t> volume{};
5965

66+
/// When true, a muted client still renders the announcement while its media stays muted.
67+
bool override_mute{false};
68+
6069
bool is_complete() const {
6170
return this->format.is_complete();
6271
}
@@ -65,9 +74,9 @@ struct ServerAnnouncementStreamObject {
6574
/// @brief Listener for announcement role events
6675
///
6776
/// THREAD SAFETY: on_announcement_write() fires on the announcement task's background thread.
68-
/// Implementations must be thread-safe for this method. on_announcement_start(),
69-
/// on_announcement_end(), and on_announcement_clear() fire on the main loop thread via
70-
/// drain_events(). The listener must outlive the role.
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.
7180
class AnnouncementRoleListener {
7281
public:
7382
virtual ~AnnouncementRoleListener() = default;
@@ -85,17 +94,16 @@ class AnnouncementRoleListener {
8594
/// @brief Called when an announcement stream starts. Fires on the main loop thread
8695
///
8796
/// The embedder applies the ducking policy here (e.g. duck the media pipeline by
88-
/// `params.media_duck_db` over `params.duck_ramp_ms`).
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().
89102
virtual void on_announcement_start(const ServerAnnouncementStreamObject& /*params*/) {}
90103

91104
/// @brief Called when the announcement stream ends (normally, aborted, or on transport
92105
/// loss). Fires on the main loop thread. The embedder releases the ducking here
93106
virtual void on_announcement_end() {}
94-
95-
/// @brief Called when the server clears the announcement stream (replace). Fires on the
96-
/// main loop thread. The embedder drops any announcement audio it has buffered downstream;
97-
/// the stream and the ducking state stay active
98-
virtual void on_announcement_clear() {}
99107
};
100108

101109
/**
@@ -105,9 +113,9 @@ class AnnouncementRoleListener {
105113
* arrive from the WebSocket network thread, are written into a dedicated ring buffer, decoded,
106114
* and delivered to the platform through AnnouncementRoleListener::on_announcement_write().
107115
* Unlike the player role there is no sample-accurate sync machinery: announcements are
108-
* per-client, start at (or as soon as possible after) their first chunk's timestamp - which is
109-
* also what aligns a coordinated multi-speaker announcement scheduled by the server - and are
110-
* paced by the sink.
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.
111119
*
112120
* Usage:
113121
* 1. Implement AnnouncementRoleListener with at minimum on_announcement_write()

src/announcement_role.cpp

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,11 @@
2222

2323
static const char* const TAG = "sendspin.announcement";
2424

25-
/// @brief Size of the big-endian 64-bit timestamp at the start of announcement binary messages.
26-
static constexpr size_t BINARY_TIMESTAMP_SIZE = 8;
2725
static constexpr uint32_t HEADER_SEND_TIMEOUT_MS = 100U;
2826
// Denominator for the advertised buffer capacity fraction: advertises (N-1)/N of capacity,
2927
// matching the player role's ring-buffer metadata headroom.
3028
static constexpr size_t ANNOUNCEMENT_BUFFER_ADVERTISE_DENOMINATOR = 5;
3129

32-
/// @brief Swaps bytes of a big-endian 64-bit value to host byte order.
33-
static int64_t be64_to_host(const uint8_t* bytes) {
34-
uint64_t val = 0;
35-
for (int i = 0; i < 8; ++i) {
36-
val = (val << 8) | bytes[i];
37-
}
38-
return static_cast<int64_t>(val);
39-
}
40-
4130
namespace sendspin {
4231

4332
// ============================================================================
@@ -154,15 +143,11 @@ SS_HOT void AnnouncementRole::Impl::handle_binary(const uint8_t* data, size_t le
154143
if (this->config.audio_formats.empty()) {
155144
return;
156145
}
157-
if (len < BINARY_TIMESTAMP_SIZE) {
158-
SS_LOGW(TAG, "Binary message too short for timestamp");
159-
return;
160-
}
161-
int64_t timestamp = be64_to_host(data);
162-
// Announcement chunks are not sync-critical and must not be dropped for being late, so a
163-
// full ring buffer is the only failure here (bounded by the advertised buffer_capacity).
164-
if (!this->task->write_audio_chunk(data + BINARY_TIMESTAMP_SIZE, len - BINARY_TIMESTAMP_SIZE,
165-
timestamp, CHUNK_TYPE_ENCODED_AUDIO, 0)) {
146+
// Announcement chunks are untimed: the whole payload (after the stripped type byte) is one
147+
// encoded audio frame. The stream's single start time travels with the codec header, not
148+
// the chunks. Chunks are not sync-critical and must not be dropped for being late, so a full
149+
// ring buffer is the only failure here (bounded by the advertised buffer_capacity).
150+
if (!this->task->write_audio_chunk(data, len, 0, CHUNK_TYPE_ENCODED_AUDIO, 0)) {
166151
SS_LOGW(TAG, "Failed to buffer announcement chunk");
167152
}
168153
}
@@ -175,9 +160,24 @@ void AnnouncementRole::Impl::handle_stream_start(
175160
return;
176161
}
177162

163+
// A stream/start arriving while the announcement is already playing is a configuration update
164+
// (duck level, volume, or override_mute), not a new clip: update the params in place and let
165+
// the main thread re-apply the policy via on_announcement_start, without a codec header, a
166+
// buffer clear, or a task restart. Replacing a clip is done by the server with stream/end
167+
// then a fresh stream/start.
168+
if (this->task->is_running()) {
169+
this->event_state->stream_params_slot.write(announcement_obj);
170+
this->enqueue_stream_event(AnnouncementStreamCallbackType::CONFIG_UPDATE);
171+
return;
172+
}
173+
178174
bool header_sent = false;
179175
const ServerPlayerStreamObject& format = announcement_obj.format;
180176

177+
// The codec header chunk carries the stream's single start_timestamp: it is the first chunk
178+
// the task consumes, so the task reads the scheduled start time from it before any audio.
179+
const int64_t start_timestamp = announcement_obj.start_timestamp;
180+
181181
if (!format.bit_depth.has_value() || !format.channels.has_value() ||
182182
!format.sample_rate.has_value() || !format.codec.has_value()) {
183183
SS_LOGE(TAG, "Announcement stream start missing required audio parameters");
@@ -195,8 +195,8 @@ void AnnouncementRole::Impl::handle_stream_start(
195195
: CHUNK_TYPE_OPUS_DUMMY_HEADER;
196196

197197
header_sent = this->task->write_audio_chunk(reinterpret_cast<const uint8_t*>(&header),
198-
sizeof(DummyHeader), 0, chunk_type,
199-
HEADER_SEND_TIMEOUT_MS);
198+
sizeof(DummyHeader), start_timestamp,
199+
chunk_type, HEADER_SEND_TIMEOUT_MS);
200200
if (!header_sent) {
201201
SS_LOGE(TAG, "Failed to send announcement codec header");
202202
}
@@ -205,9 +205,9 @@ void AnnouncementRole::Impl::handle_stream_start(
205205
SS_LOGE(TAG, "FLAC codec header missing");
206206
} else {
207207
std::vector<uint8_t> flac_header = base64_decode(format.codec_header.value());
208-
header_sent =
209-
this->task->write_audio_chunk(flac_header.data(), flac_header.size(), 0,
210-
CHUNK_TYPE_FLAC_HEADER, HEADER_SEND_TIMEOUT_MS);
208+
header_sent = this->task->write_audio_chunk(flac_header.data(), flac_header.size(),
209+
start_timestamp, CHUNK_TYPE_FLAC_HEADER,
210+
HEADER_SEND_TIMEOUT_MS);
211211
if (!header_sent) {
212212
SS_LOGE(TAG, "Failed to send announcement codec header");
213213
}
@@ -237,13 +237,6 @@ void AnnouncementRole::Impl::handle_stream_end() const {
237237
this->enqueue_stream_event(AnnouncementStreamCallbackType::STREAM_END);
238238
}
239239

240-
void AnnouncementRole::Impl::handle_stream_clear() const {
241-
// The server replaces the announcement audio: discard what is buffered, keep the stream
242-
// and the ducking state active. The chunks that follow are the replacement clip.
243-
this->task->signal_stream_clear();
244-
this->enqueue_stream_event(AnnouncementStreamCallbackType::STREAM_CLEARED);
245-
}
246-
247240
void AnnouncementRole::Impl::on_stream_ring_event(AnnouncementStreamCallbackType event) {
248241
this->pending_events.push_back(event);
249242
}
@@ -295,9 +288,20 @@ void AnnouncementRole::Impl::drain_events() {
295288
}
296289
break;
297290
}
298-
case AnnouncementStreamCallbackType::STREAM_CLEARED: {
291+
case AnnouncementStreamCallbackType::CONFIG_UPDATE: {
292+
// Re-sent stream/start on an active stream: adopt the new duck/volume/override_mute
293+
// params and re-apply the policy through on_announcement_start. The task keeps
294+
// playing; no state change and no task signal.
295+
ServerAnnouncementStreamObject stream_params;
296+
if (this->event_state->stream_params_slot.take(stream_params)) {
297+
this->current_stream_params = std::move(stream_params);
298+
}
299299
if (this->listener && this->stream_active) {
300-
this->listener->on_announcement_clear();
300+
const uint32_t generation = this->cleanup_generation;
301+
this->listener->on_announcement_start(this->current_stream_params);
302+
if (this->cleanup_generation != generation) {
303+
teardown_reentered = true;
304+
}
301305
}
302306
break;
303307
}
@@ -373,7 +377,7 @@ void AnnouncementRole::Impl::cleanup() {
373377
void AnnouncementRole::Impl::enqueue_stream_event(AnnouncementStreamCallbackType event) const {
374378
// A dropped STREAM_START would leave the task waiting for its start signal; a dropped
375379
// STREAM_END would leave media ducked. Both wedge the announcement, so log drops at ERROR.
376-
static const char* const EVENT_NAMES[] = {"STREAM_START", "STREAM_END", "STREAM_CLEARED",
380+
static const char* const EVENT_NAMES[] = {"STREAM_START", "STREAM_END", "CONFIG_UPDATE",
377381
"OUTPUT_STARTED", "OUTPUT_FINISHED"};
378382
push_event_or_log(this->inbox, InboxEventType::ANNOUNCEMENT_STREAM, static_cast<uint8_t>(event),
379383
TAG, EVENT_NAMES[static_cast<uint8_t>(event)],

src/announcement_role_impl.h

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ struct ClientStateMessage;
3434
enum class AnnouncementStreamCallbackType : uint8_t {
3535
STREAM_START, // New announcement stream is starting (from the network thread)
3636
STREAM_END, // Announcement stream ended (from the network thread or cleanup)
37-
STREAM_CLEARED, // Server cleared buffered announcement audio (from the network thread)
37+
CONFIG_UPDATE, // Re-sent stream/start updates the active stream's config (network thread)
3838
OUTPUT_STARTED, // Task began writing announcement audio to the sink (from the task thread)
3939
OUTPUT_FINISHED, // Task finished draining the announcement (from the task thread)
4040
};
@@ -63,7 +63,6 @@ struct AnnouncementRole::Impl {
6363
void handle_binary(const uint8_t* data, size_t len) const;
6464
void handle_stream_start(const ServerAnnouncementStreamObject& announcement_obj) const;
6565
void handle_stream_end() const;
66-
void handle_stream_clear() const;
6766
void on_stream_ring_event(AnnouncementStreamCallbackType event);
6867
// True if this tick has drainable announcement work. All announcement events (lifecycle from
6968
// the network thread, output transitions from the announcement task) travel over the shared

src/announcement_task.cpp

Lines changed: 13 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ bool AnnouncementTask::start(bool task_stack_in_psram, unsigned priority) {
9595
AnnouncementTaskBits::ANNOUNCEMENT_TASK_IDLE |
9696
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STOP |
9797
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END |
98-
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_CLEAR |
9998
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_START);
10099

101100
platform_configure_thread("SendspinAnnc", ANNOUNCEMENT_TASK_STACK_SIZE,
@@ -125,13 +124,6 @@ void AnnouncementTask::signal_stream_end() {
125124
this->event_flags_.set(AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END);
126125
}
127126

128-
void AnnouncementTask::signal_stream_clear() {
129-
if (!this->is_initialized()) {
130-
return;
131-
}
132-
this->event_flags_.set(AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_CLEAR);
133-
}
134-
135127
void AnnouncementTask::signal_stream_start() {
136128
if (!this->is_initialized()) {
137129
return;
@@ -217,11 +209,9 @@ bool AnnouncementTask::wait_for_codec_header(AudioStreamInfo* stream_info) {
217209
if ((flags & AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STOP) != 0U) {
218210
return false;
219211
}
220-
if ((flags & (AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END |
221-
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_CLEAR)) != 0U) {
222-
// Stale commands from a stream that ended while idle
223-
this->event_flags_.clear(AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END |
224-
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_CLEAR);
212+
if ((flags & AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END) != 0U) {
213+
// Stale command from a stream that ended while idle
214+
this->event_flags_.clear(AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END);
225215
}
226216

227217
AudioRingBufferEntry* entry =
@@ -231,17 +221,19 @@ bool AnnouncementTask::wait_for_codec_header(AudioStreamInfo* stream_info) {
231221
}
232222

233223
const ChunkType chunk_type = entry->chunk_type;
234-
if (chunk_type == CHUNK_TYPE_ENCODED_AUDIO ||
235-
chunk_type == CHUNK_TYPE_STREAM_CLEAR_MARKER) {
224+
if (chunk_type == CHUNK_TYPE_ENCODED_AUDIO) {
236225
// Stale audio from a previous announcement; discard until a codec header arrives
237226
this->encoded_ring_buffer_->return_chunk(entry);
238227
continue;
239228
}
240229

230+
// The header chunk carries the stream's single scheduled start time.
231+
const int64_t start_timestamp = entry->timestamp;
241232
const bool header_ok = this->decoder_->process_header(entry->data(), entry->data_size,
242233
chunk_type, stream_info);
243234
this->encoded_ring_buffer_->return_chunk(entry);
244235
if (header_ok) {
236+
this->start_timestamp_ = start_timestamp;
245237
this->decode_buffer_.resize(this->decoder_->get_decode_buffer_size());
246238
return true;
247239
}
@@ -264,14 +256,6 @@ void AnnouncementTask::play_stream() {
264256
this->drain_ring_buffer();
265257
break;
266258
}
267-
if ((flags & AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_CLEAR) != 0U) {
268-
// Replace: drop what is buffered, keep playing what follows. The clear boundary is
269-
// approximate (chunks racing the drain may be dropped with the old clip), which the
270-
// silence-padded replacement stream tolerates.
271-
this->event_flags_.clear(AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_CLEAR);
272-
this->drain_ring_buffer();
273-
continue;
274-
}
275259

276260
AudioRingBufferEntry* entry =
277261
this->encoded_ring_buffer_->receive_chunk(ANNOUNCEMENT_CHUNK_RECEIVE_TIMEOUT_MS);
@@ -285,10 +269,6 @@ void AnnouncementTask::play_stream() {
285269
last_data_us = platform_time_us();
286270

287271
if (entry->chunk_type != CHUNK_TYPE_ENCODED_AUDIO) {
288-
if (entry->chunk_type == CHUNK_TYPE_STREAM_CLEAR_MARKER) {
289-
this->encoded_ring_buffer_->return_chunk(entry);
290-
continue;
291-
}
292272
// Mid-stream codec header (configuration update): re-initialize the decoder
293273
AudioStreamInfo new_info{};
294274
if (this->decoder_->process_header(entry->data(), entry->data_size, entry->chunk_type,
@@ -312,7 +292,6 @@ void AnnouncementTask::play_stream() {
312292
entry->data(), entry->data_size, this->decode_buffer_.data(),
313293
this->decode_buffer_.size(), &decoded_size);
314294
}
315-
const int64_t chunk_timestamp = entry->timestamp;
316295
this->encoded_ring_buffer_->return_chunk(entry);
317296

318297
if (!decode_ok) {
@@ -324,14 +303,13 @@ void AnnouncementTask::play_stream() {
324303
}
325304

326305
if (!output_started) {
327-
// Loose start scheduling: begin at the first chunk's timestamp when time sync is
328-
// available, or as soon as possible otherwise. Announcement chunks are never dropped
329-
// for lateness.
330-
this->wait_for_start_time(chunk_timestamp);
306+
// Loose start scheduling: begin at the stream's single start_timestamp when time sync
307+
// is available, or as soon as possible otherwise. Announcement chunks are never
308+
// dropped for lateness.
309+
this->wait_for_start_time(this->start_timestamp_);
331310
if ((this->event_flags_.get() &
332311
(AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STOP |
333-
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END |
334-
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_CLEAR)) != 0U) {
312+
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END)) != 0U) {
335313
continue; // Re-enter the loop head to handle the command
336314
}
337315
output_started = true;
@@ -345,8 +323,7 @@ void AnnouncementTask::play_stream() {
345323
while (offset < decoded_size && listener != nullptr) {
346324
if ((this->event_flags_.get() &
347325
(AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STOP |
348-
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END |
349-
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_CLEAR)) != 0U) {
326+
AnnouncementTaskBits::ANNOUNCEMENT_COMMAND_STREAM_END)) != 0U) {
350327
break;
351328
}
352329
const size_t written = listener->on_announcement_write(

0 commit comments

Comments
 (0)