2222
2323static 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 ;
2725static 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.
3028static 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-
4130namespace 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-
247240void 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() {
373377void 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)],
0 commit comments