1818//! → cancel flag: a 10 ms barge-in monitor thread silences the player and
1919//! releases tts_active on the flag's rising edge (~15 ms flag-to-silence,
2020//! even mid-sentence while the worker is blocked in synth_chunk); the
21- //! worker then consumes the flag — drain queue + clear + play (un-pause)
21+ //! worker then consumes the flag — drain queue + clear + play (un-pause).
22+ //! Monitor clears and worker player mutations are serialized through the
23+ //! `player_ops` mutex, with the flag re-checked under the lock — see the
24+ //! monitor block in `tts_worker` for the race this closes.
2225//! ```
2326//!
2427//! Lookahead pipelining spans *items*, not just sentences within one item:
@@ -37,7 +40,7 @@ use std::{
3740 sync:: {
3841 atomic:: { AtomicBool , Ordering } ,
3942 mpsc:: { self , SyncSender } ,
40- Arc ,
43+ Arc , Mutex , MutexGuard , PoisonError ,
4144 } ,
4245 thread,
4346 time:: Duration ,
@@ -364,23 +367,42 @@ fn tts_worker(
364367 // monitor keeps re-clearing until the worker catches up, which also
365368 // covers a sentence appended in the race window after the worker's own
366369 // post-synthesis cancel check.
370+ //
371+ // `player_ops` closes the converse race (found in review): the monitor
372+ // loads `cancel == true`, is preempted, the worker consumes the cancel
373+ // and appends a fresh post-cancel utterance, then the monitor resumes
374+ // from its stale branch and deletes audio that was meant to play. All
375+ // worker player mutations (appends and cancel/shutdown clears) hold this
376+ // lock, and the monitor re-checks `cancel` *while holding it* — so its
377+ // clear either runs before fresh audio can be appended, or observes
378+ // `cancel == false` and no-ops. The lock is uncontended except during an
379+ // actual barge-in, so the hot path is unaffected.
380+ let player_ops = Arc :: new ( Mutex :: new ( ( ) ) ) ;
367381 let monitor_stop = Arc :: new ( AtomicBool :: new ( false ) ) ;
368382 let monitor = {
369383 let player = Arc :: clone ( & player) ;
370384 let cancel = Arc :: clone ( & cancel) ;
371385 let tts_active = Arc :: clone ( & tts_active) ;
372386 let stop = Arc :: clone ( & monitor_stop) ;
387+ let player_ops = Arc :: clone ( & player_ops) ;
373388 thread:: Builder :: new ( )
374389 . name ( "tts-barge-in-monitor" . into ( ) )
375390 . spawn ( move || {
376391 while !stop. load ( Ordering :: Acquire ) {
377392 if cancel. load ( Ordering :: Acquire ) {
378- // clear() pauses the persistent player; play() un-pauses
379- // (see handle_cancel_or_shutdown). Idempotent — safe to
380- // repeat every tick until the worker consumes the flag.
381- player. clear ( ) ;
382- player. play ( ) ;
383- tts_active. store ( false , Ordering :: Release ) ;
393+ let _ops = lock_player_ops ( & player_ops) ;
394+ // Re-check under the lock: the worker may have
395+ // consumed this cancel (and appended fresh audio)
396+ // between the load above and the lock acquisition.
397+ if cancel. load ( Ordering :: Acquire ) {
398+ // clear() pauses the persistent player; play()
399+ // un-pauses (see handle_cancel_or_shutdown).
400+ // Idempotent — safe to repeat every tick until
401+ // the worker consumes the flag.
402+ player. clear ( ) ;
403+ player. play ( ) ;
404+ tts_active. store ( false , Ordering :: Release ) ;
405+ }
384406 }
385407 thread:: sleep ( MONITOR_TICK ) ;
386408 }
@@ -408,7 +430,13 @@ fn tts_worker(
408430 let mut first_append = true ;
409431
410432 loop {
411- if handle_cancel_or_shutdown ( & cancel, & shutdown, & tts_active, & text_rx, Some ( & player) ) {
433+ if handle_cancel_or_shutdown (
434+ & cancel,
435+ & shutdown,
436+ & tts_active,
437+ & text_rx,
438+ Some ( ( & player, & player_ops) ) ,
439+ ) {
412440 if shutdown. load ( Ordering :: Acquire ) {
413441 break ;
414442 }
@@ -435,7 +463,13 @@ fn tts_worker(
435463
436464 // Check cancel again after unblocking — a cancel may have arrived
437465 // while we were waiting.
438- if handle_cancel_or_shutdown ( & cancel, & shutdown, & tts_active, & text_rx, Some ( & player) ) {
466+ if handle_cancel_or_shutdown (
467+ & cancel,
468+ & shutdown,
469+ & tts_active,
470+ & text_rx,
471+ Some ( ( & player, & player_ops) ) ,
472+ ) {
439473 if shutdown. load ( Ordering :: Acquire ) {
440474 break ;
441475 }
@@ -471,7 +505,13 @@ fn tts_worker(
471505 . collect ( ) ;
472506
473507 for sentence in & sentences {
474- if handle_cancel_or_shutdown ( & cancel, & shutdown, & tts_active, & text_rx, Some ( & player) ) {
508+ if handle_cancel_or_shutdown (
509+ & cancel,
510+ & shutdown,
511+ & tts_active,
512+ & text_rx,
513+ Some ( ( & player, & player_ops) ) ,
514+ ) {
475515 first_append = true ;
476516 break ;
477517 }
@@ -483,16 +523,6 @@ fn tts_worker(
483523
484524 match engine. synth_chunk ( text, "en" , & style, SYNTH_STEPS , SYNTH_SPEED ) {
485525 Ok ( samples) if !samples. is_empty ( ) => {
486- // A barge-in may have arrived during synthesis (the
487- // blocking window the monitor thread exists for). Don't
488- // append the now-stale sentence — the human interrupted;
489- // speaking it anyway would talk over them. The flag is
490- // deliberately NOT consumed here: the loop-top
491- // handle_cancel_or_shutdown does the full consume
492- // (drain queue, reset lead-in) on the next iteration.
493- if cancel. load ( Ordering :: Acquire ) {
494- break ;
495- }
496526 let mut boosted = apply_playback_gain ( samples) ;
497527 // Fade-out only — fading-in would attenuate the consonant
498528 // onset (see `apply_fade_out` docstring + the
@@ -506,6 +536,25 @@ fn tts_worker(
506536 // every chunk a quiet device warm-up window.
507537 let buf =
508538 build_sentence_append_buffer ( & mut first_append, boosted, silence_buf_len) ;
539+
540+ // Check-and-append under `player_ops`, serialized with
541+ // the monitor: a barge-in may have arrived during
542+ // synthesis (the blocking window the monitor thread
543+ // exists for). Don't append the now-stale sentence — the
544+ // human interrupted; speaking it anyway would talk over
545+ // them. Holding the lock for the check + append means the
546+ // monitor can never clear between our check passing and
547+ // the buffer landing. The flag is deliberately NOT
548+ // consumed here: the loop-top handle_cancel_or_shutdown
549+ // does the full consume (drain queue, reset lead-in) on
550+ // the next iteration.
551+ let _ops = lock_player_ops ( & player_ops) ;
552+ if cancel. load ( Ordering :: Acquire ) {
553+ // Nothing appended; the loop-top consume re-arms
554+ // `first_append` (the flag is still set — the worker
555+ // is its only consumer).
556+ break ;
557+ }
509558 player. append ( SamplesBuffer :: new ( channels, rate, buf) ) ;
510559 // NOTE: tts_active is set AFTER player.append(), not
511560 // before. Setting it before synthesis would cause STT to
@@ -540,38 +589,61 @@ fn tts_worker(
540589
541590/// Check for cancel or shutdown. Returns `true` if the caller should break/continue.
542591/// On cancel: drains the text queue and clears the cancel flag.
592+ ///
593+ /// `player` pairs the Player with the `player_ops` mutex shared with the
594+ /// barge-in monitor thread; the cancel/shutdown clear runs under that lock so
595+ /// it is serialized with the monitor's stale-branch re-check (see the monitor
596+ /// block in `tts_worker`).
543597fn handle_cancel_or_shutdown (
544598 cancel : & AtomicBool ,
545599 shutdown : & AtomicBool ,
546600 tts_active : & AtomicBool ,
547601 text_rx : & mpsc:: Receiver < String > ,
548- player : Option < & rodio:: Player > ,
602+ player : Option < ( & rodio:: Player , & Mutex < ( ) > ) > ,
549603) -> bool {
550604 if shutdown. load ( Ordering :: Acquire ) {
551- if let Some ( p) = player {
605+ if let Some ( ( p, ops) ) = player {
606+ let _ops = lock_player_ops ( ops) ;
552607 p. clear ( ) ;
553608 }
554609 tts_active. store ( false , Ordering :: Release ) ;
555610 return true ;
556611 }
557612 if cancel. load ( Ordering :: Acquire ) {
558- if let Some ( p) = player {
613+ if let Some ( ( p, ops) ) = player {
614+ let _ops = lock_player_ops ( ops) ;
559615 // `Player::clear()` removes queued sources AND pauses the player
560616 // (rodio 0.22 `clear()` ends with `self.pause()`). With one
561617 // persistent Player for the worker's lifetime, the un-pause is
562618 // mandatory: without `play()`, every append after a barge-in
563619 // would queue silently forever.
564620 p. clear ( ) ;
565621 p. play ( ) ;
622+ // Consume the flag under the lock: once released with
623+ // `cancel == false`, the monitor's stale branch no-ops instead
624+ // of clearing the fresh post-cancel utterance.
625+ while text_rx. try_recv ( ) . is_ok ( ) { }
626+ cancel. store ( false , Ordering :: Release ) ;
627+ } else {
628+ while text_rx. try_recv ( ) . is_ok ( ) { }
629+ cancel. store ( false , Ordering :: Release ) ;
566630 }
567- while text_rx. try_recv ( ) . is_ok ( ) { }
568- cancel. store ( false , Ordering :: Release ) ;
569631 tts_active. store ( false , Ordering :: Release ) ;
570632 return true ;
571633 }
572634 false
573635}
574636
637+ /// Acquire the `player_ops` lock, recovering from poison.
638+ ///
639+ /// The data under the mutex is `()` — it only serializes Player mutations —
640+ /// so a panicked holder leaves nothing inconsistent to observe and recovery
641+ /// is always safe. Without this, a worker panic would wedge the monitor (or
642+ /// vice versa) on `unwrap()`.
643+ fn lock_player_ops ( ops : & Mutex < ( ) > ) -> MutexGuard < ' _ , ( ) > {
644+ ops. lock ( ) . unwrap_or_else ( PoisonError :: into_inner)
645+ }
646+
575647/// Apply the fixed playback gain ([`PLAYBACK_GAIN`]), hard-clamped to ±1.0.
576648///
577649/// Replaces the earlier per-sentence peak normalization — see the
0 commit comments