1- use rodio:: { Decoder , OutputStream , Sink } ;
1+ use rodio:: source:: { Source , Zero } ;
2+ use rodio:: { Decoder , OutputStream , OutputStreamHandle , Sink } ;
23use std:: io:: Cursor ;
3- use std:: sync:: mpsc:: { self , Sender } ;
4+ use std:: sync:: mpsc:: { self , RecvTimeoutError , Sender } ;
45use std:: sync:: OnceLock ;
56use std:: thread;
7+ use std:: time:: Duration ;
8+
9+ /// How long the output stream stays open after the last chime. An open stream
10+ /// keeps CoreAudio running (and Bluetooth headphones awake) even while silent,
11+ /// so on macOS it is released when idle. 30s covers most dictations, so the
12+ /// stop chime usually reuses the start chime's stream. Other platforms keep the
13+ /// stream open for the life of the app; releasing it is untested there.
14+ #[ cfg( target_os = "macos" ) ]
15+ const OUTPUT_IDLE_TIMEOUT : Option < Duration > = Some ( Duration :: from_secs ( 30 ) ) ;
16+ #[ cfg( not( target_os = "macos" ) ) ]
17+ const OUTPUT_IDLE_TIMEOUT : Option < Duration > = None ;
18+
19+ /// Silence played ahead of a chime on a freshly opened stream. Bluetooth
20+ /// headphones drop the first moments of audio while waking, which would
21+ /// otherwise swallow the short chimes entirely.
22+ const WAKE_PREROLL : Duration = Duration :: from_millis ( 250 ) ;
623
724static START_RECORDING_CLIP : & [ u8 ] = include_bytes ! ( concat!(
825 env!( "CARGO_MANIFEST_DIR" ) ,
@@ -34,61 +51,87 @@ static ALERT_WINDOWS_11_CLIP: &[u8] = include_bytes!(concat!(
3451 "/assets/audio/alert-windows-11.wav"
3552) ) ;
3653
37- /// Channel sender for the warm audio thread.
54+ /// Channel sender for the audio thread.
3855static AUDIO_SENDER : OnceLock < Sender < AudioRequest > > = OnceLock :: new ( ) ;
3956
4057enum AudioRequest {
4158 Play ( & ' static [ u8 ] ) ,
4259}
4360
44- /// Initialize a dedicated audio thread at app startup for instant chime playback.
45- /// The thread keeps an OutputStream alive so we don't recreate it for each chime .
46- pub fn warm_audio_output ( ) {
61+ /// Start the dedicated chime playback thread. See `OUTPUT_IDLE_TIMEOUT` for
62+ /// how long its output stream stays open .
63+ pub fn start_audio_thread ( ) {
4764 let ( tx, rx) = mpsc:: channel :: < AudioRequest > ( ) ;
4865
49- // Store the sender for later use
5066 if AUDIO_SENDER . set ( tx) . is_err ( ) {
5167 log:: warn!( "Audio sender already initialized" ) ;
5268 return ;
5369 }
5470
55- // Spawn the dedicated audio thread
5671 thread:: spawn ( move || {
57- // Create the output stream once and keep it alive
58- let ( _stream, handle) = match OutputStream :: try_default ( ) {
59- Ok ( result) => {
60- log:: info!( "Pre-warmed audio output stream" ) ;
61- result
62- }
63- Err ( err) => {
64- log:: error!( "Failed to create audio output: {err}" ) ;
65- // Still process requests, but they'll fail gracefully
66- for request in rx {
67- let AudioRequest :: Play ( bytes) = request;
68- play_clip_fallback ( bytes) ;
69- }
70- return ;
71- }
72+ let mut output = match OUTPUT_IDLE_TIMEOUT {
73+ Some ( _) => None ,
74+ None => open_output ( ) ,
7275 } ;
7376
74- // Process play requests on this thread
75- for request in rx {
77+ loop {
78+ let request = match OUTPUT_IDLE_TIMEOUT {
79+ Some ( timeout) => rx. recv_timeout ( timeout) ,
80+ None => rx. recv ( ) . map_err ( RecvTimeoutError :: from) ,
81+ } ;
7682 match request {
77- AudioRequest :: Play ( bytes) => {
78- if let Ok ( sink) = Sink :: try_new ( & handle) {
79- if let Ok ( source) = Decoder :: new ( Cursor :: new ( bytes) ) {
80- sink. append ( source) ;
81- sink. sleep_until_end ( ) ;
82- }
83+ Ok ( AudioRequest :: Play ( bytes) ) => {
84+ let fresh = output. is_none ( ) ;
85+ if fresh {
86+ output = open_output ( ) ;
87+ }
88+ match & output {
89+ Some ( ( _, handle) ) => play_on ( handle, bytes, fresh) ,
90+ None => play_clip_fallback ( bytes) ,
8391 }
8492 }
93+ Err ( RecvTimeoutError :: Timeout ) => {
94+ if output. take ( ) . is_some ( ) {
95+ log:: debug!( "Released idle audio output stream" ) ;
96+ }
97+ }
98+ Err ( RecvTimeoutError :: Disconnected ) => break ,
8599 }
86100 }
87101 } ) ;
88102}
89103
90- /// Try to send a play request to the warm audio thread.
91- fn try_warm_play ( bytes : & ' static [ u8 ] ) -> bool {
104+ fn open_output ( ) -> Option < ( OutputStream , OutputStreamHandle ) > {
105+ OutputStream :: try_default ( )
106+ . inspect_err ( |err| log:: error!( "Failed to open default audio output stream: {err}" ) )
107+ . ok ( )
108+ }
109+
110+ /// `fresh` means the stream was just opened, so the device may still be waking.
111+ fn play_on ( handle : & OutputStreamHandle , bytes : & ' static [ u8 ] , fresh : bool ) {
112+ let sink = match Sink :: try_new ( handle) {
113+ Ok ( sink) => sink,
114+ Err ( err) => {
115+ log:: error!( "Failed to create audio sink: {err}" ) ;
116+ return ;
117+ }
118+ } ;
119+ let source = match Decoder :: new ( Cursor :: new ( bytes) ) {
120+ Ok ( source) => source,
121+ Err ( err) => {
122+ log:: error!( "Failed to decode audio clip: {err}" ) ;
123+ return ;
124+ }
125+ } ;
126+ if fresh {
127+ let silence = Zero :: < f32 > :: new ( source. channels ( ) , source. sample_rate ( ) ) ;
128+ sink. append ( silence. take_duration ( WAKE_PREROLL ) ) ;
129+ }
130+ sink. append ( source) ;
131+ sink. sleep_until_end ( ) ;
132+ }
133+
134+ fn try_send_to_audio_thread ( bytes : & ' static [ u8 ] ) -> bool {
92135 if let Some ( sender) = AUDIO_SENDER . get ( ) {
93136 sender. send ( AudioRequest :: Play ( bytes) ) . is_ok ( )
94137 } else {
@@ -121,8 +164,7 @@ pub fn play_alert_windows_11_clip() {
121164}
122165
123166fn play_clip ( bytes : & ' static [ u8 ] ) {
124- // Try the warm audio thread first (instant)
125- if try_warm_play ( bytes) {
167+ if try_send_to_audio_thread ( bytes) {
126168 return ;
127169 }
128170
@@ -132,25 +174,8 @@ fn play_clip(bytes: &'static [u8]) {
132174
133175fn play_clip_fallback ( bytes : & ' static [ u8 ] ) {
134176 thread:: spawn ( move || {
135- if let Ok ( ( stream, handle) ) = OutputStream :: try_default ( ) {
136- match Sink :: try_new ( & handle) {
137- Ok ( sink) => match Decoder :: new ( Cursor :: new ( bytes) ) {
138- Ok ( source) => {
139- sink. append ( source) ;
140- sink. sleep_until_end ( ) ;
141- }
142- Err ( err) => {
143- log:: error!( "Failed to decode audio clip: {err}" ) ;
144- }
145- } ,
146- Err ( err) => {
147- log:: error!( "Failed to create audio sink: {err}" ) ;
148- }
149- }
150-
151- drop ( stream) ;
152- } else {
153- log:: error!( "Failed to open default audio output stream" ) ;
177+ if let Some ( ( _stream, handle) ) = open_output ( ) {
178+ play_on ( & handle, bytes, true ) ;
154179 }
155180 } ) ;
156181}
0 commit comments