99//!
1010//! On platforms without audio support (e.g., musl builds), falls back to
1111//! terminal bell notifications.
12+ //!
13+ //! On Linux, ALSA error messages (e.g., "cannot find card 0") are suppressed
14+ //! during audio initialization to avoid noisy output on headless systems.
1215
1316#[ cfg( feature = "audio" ) ]
1417use std:: io:: Cursor ;
@@ -44,6 +47,125 @@ const COMPLETE_WAV: &[u8] = include_bytes!("sounds/complete.wav");
4447#[ cfg( feature = "audio" ) ]
4548const APPROVAL_WAV : & [ u8 ] = include_bytes ! ( "sounds/approval.wav" ) ;
4649
50+ /// Try to create audio output stream, suppressing ALSA errors on Linux.
51+ ///
52+ /// On Linux, ALSA prints error messages directly to stderr when no audio
53+ /// hardware is available (e.g., "ALSA lib confmisc.c: cannot find card 0").
54+ /// This function suppresses those messages by temporarily redirecting stderr
55+ /// to /dev/null during initialization.
56+ ///
57+ /// # Thread Safety Note
58+ ///
59+ /// This function temporarily redirects the process-wide stderr file descriptor (fd 2)
60+ /// to /dev/null. This is a global operation that affects all threads. Any concurrent
61+ /// stderr writes from other threads or libraries will be silently dropped during the
62+ /// brief window when ALSA initialization occurs.
63+ ///
64+ /// This trade-off is acceptable because:
65+ /// - The redirection window is very short (only during `OutputStream::try_default()`)
66+ /// - This function is called once at startup from a dedicated audio thread
67+ /// - ALSA error messages are noisy and unhelpful on headless systems
68+ /// - The alternative (letting ALSA spam stderr) degrades user experience significantly
69+ ///
70+ /// If stricter isolation is needed, consider calling `init()` before spawning other
71+ /// threads that may write to stderr.
72+ #[ cfg( all( feature = "audio" , target_os = "linux" ) ) ]
73+ fn try_create_output_stream ( ) -> Option < ( rodio:: OutputStream , rodio:: OutputStreamHandle ) > {
74+ use std:: os:: unix:: io:: AsRawFd ;
75+
76+ // Open /dev/null for redirecting stderr
77+ let dev_null = match std:: fs:: File :: open ( "/dev/null" ) {
78+ Ok ( f) => f,
79+ Err ( _) => {
80+ // Can't open /dev/null, try without suppression
81+ return match rodio:: OutputStream :: try_default ( ) {
82+ Ok ( ( stream, handle) ) => Some ( ( stream, handle) ) ,
83+ Err ( e) => {
84+ tracing:: debug!( "Failed to initialize audio output: {}" , e) ;
85+ None
86+ }
87+ } ;
88+ }
89+ } ;
90+
91+ // Save the original stderr file descriptor
92+ // SAFETY: dup is safe to call with a valid file descriptor (2 = stderr)
93+ let original_stderr = unsafe { libc:: dup ( 2 ) } ;
94+ if original_stderr == -1 {
95+ // dup failed, try without suppression
96+ return match rodio:: OutputStream :: try_default ( ) {
97+ Ok ( ( stream, handle) ) => Some ( ( stream, handle) ) ,
98+ Err ( e) => {
99+ tracing:: debug!( "Failed to initialize audio output: {}" , e) ;
100+ None
101+ }
102+ } ;
103+ }
104+
105+ // Redirect stderr to /dev/null
106+ // SAFETY: dup2 is safe with valid file descriptors
107+ let redirect_result = unsafe { libc:: dup2 ( dev_null. as_raw_fd ( ) , 2 ) } ;
108+ drop ( dev_null) ; // Close our handle to /dev/null
109+
110+ if redirect_result == -1 {
111+ // dup2 failed, restore and try without suppression
112+ // SAFETY: close is safe with a valid file descriptor
113+ unsafe { libc:: close ( original_stderr) } ;
114+ return match rodio:: OutputStream :: try_default ( ) {
115+ Ok ( ( stream, handle) ) => Some ( ( stream, handle) ) ,
116+ Err ( e) => {
117+ tracing:: debug!( "Failed to initialize audio output: {}" , e) ;
118+ None
119+ }
120+ } ;
121+ }
122+
123+ // Try to create the audio output stream (ALSA errors will go to /dev/null)
124+ let result = rodio:: OutputStream :: try_default ( ) ;
125+
126+ // Restore the original stderr
127+ // SAFETY: dup2 and close are safe with valid file descriptors
128+ unsafe {
129+ let restore_result = libc:: dup2 ( original_stderr, 2 ) ;
130+ if restore_result == -1 {
131+ // dup2 failed to restore stderr - this is a critical issue as stderr
132+ // will remain redirected to /dev/null for the rest of the process.
133+ // Log the error (which ironically may not be visible if stderr is broken).
134+ // Keep original_stderr open in case we can retry later or for debugging.
135+ tracing:: error!(
136+ "Failed to restore stderr after ALSA initialization (dup2 returned -1). \
137+ Stderr may remain redirected to /dev/null."
138+ ) ;
139+ } else {
140+ // Only close original_stderr if dup2 succeeded, as we may still need
141+ // it for retry attempts if restoration failed.
142+ libc:: close ( original_stderr) ;
143+ }
144+ }
145+
146+ match result {
147+ Ok ( ( stream, handle) ) => Some ( ( stream, handle) ) ,
148+ Err ( e) => {
149+ tracing:: debug!( "Failed to initialize audio output: {}" , e) ;
150+ None
151+ }
152+ }
153+ }
154+
155+ /// Try to create audio output stream (non-Linux platforms).
156+ ///
157+ /// On non-Linux platforms, ALSA is not used, so no stderr suppression is needed.
158+ #[ cfg( all( feature = "audio" , not( target_os = "linux" ) ) ) ]
159+ fn try_create_output_stream ( ) -> Option < ( rodio:: OutputStream , rodio:: OutputStreamHandle ) > {
160+ match rodio:: OutputStream :: try_default ( ) {
161+ Ok ( ( stream, handle) ) => Some ( ( stream, handle) ) ,
162+ Err ( e) => {
163+ tracing:: debug!( "Failed to initialize audio output: {}" , e) ;
164+ None
165+ }
166+ }
167+ }
168+
47169/// Initialize the global sound system.
48170/// Spawns a dedicated audio thread that owns the OutputStream.
49171/// Should be called once at application startup.
@@ -67,14 +189,8 @@ pub fn init() {
67189 thread:: Builder :: new ( )
68190 . name ( "cortex-audio" . to_string ( ) )
69191 . spawn ( move || {
70- // Try to create audio output
71- let output = match rodio:: OutputStream :: try_default ( ) {
72- Ok ( ( stream, handle) ) => Some ( ( stream, handle) ) ,
73- Err ( e) => {
74- tracing:: debug!( "Failed to initialize audio output: {}" , e) ;
75- None
76- }
77- } ;
192+ // Try to create audio output (with ALSA error suppression on Linux)
193+ let output = try_create_output_stream ( ) ;
78194
79195 // Process sound requests
80196 while let Ok ( sound_type) = rx. recv ( ) {
0 commit comments