88import sounddevice as sd
99import soundfile as sf
1010from rich .console import Console
11+ from rich .progress import (
12+ BarColumn ,
13+ Progress ,
14+ TaskProgressColumn ,
15+ TextColumn ,
16+ )
1117from rich .table import Table
1218
1319from .exceptions import (
@@ -163,13 +169,70 @@ def _adjust_channels(data: np.ndarray, max_channels: int) -> np.ndarray:
163169
164170 return data
165171
166- def play_audio (self , audio_file : Path , * , blocking : bool = False , sound_volume : float = 1.0 ) -> bool :
172+ def _load_and_prepare_audio (
173+ self ,
174+ audio_file : Path ,
175+ sound_volume : float ,
176+ ) -> tuple [np .ndarray , int ] | None :
177+ """Load and prepare audio data for playback.
178+
179+ Args:
180+ audio_file: Path to the audio file
181+ sound_volume: Per-sound volume multiplier
182+
183+ Returns:
184+ Tuple of (data, samplerate) or None if loading failed
185+
186+ """
187+ try :
188+ data , samplerate = sf .read (str (audio_file )) # pyright: ignore[reportGeneralTypeIssues]
189+
190+ except sf .LibsndfileError as e :
191+ logger .exception ("Failed to read audio file" )
192+ error = AudioFileCorruptedError (
193+ f"Cannot read audio file: { audio_file .name } " ,
194+ details = {"path" : str (audio_file ), "error" : str (e )},
195+ )
196+ self .console .print (f"[red]✗[/red] { error .message } " )
197+ self .console .print (f"[dim]💡 { error .suggestion } [/dim]" )
198+ return None
199+
200+ # Ensure data is in the correct format
201+ if len (data .shape ) == 1 :
202+ data = data .reshape (- 1 , 1 )
203+
204+ # Get device info to match channels
205+ device_info = sd .query_devices (self .output_device_id )
206+ max_channels = device_info ["max_output_channels" ] # pyright: ignore[reportCallIssue, reportArgumentType]
207+
208+ logger .debug (
209+ f"Audio info: duration={ len (data ) / samplerate :.2f} s, rate={ samplerate } , channels={ data .shape [1 ]} " ,
210+ )
211+
212+ # Adjust channels if needed
213+ data = self ._adjust_channels (data , max_channels )
214+
215+ # Apply combined volume scaling: global x sound-specific
216+ final_volume = self .volume * sound_volume
217+ data *= final_volume
218+
219+ return data , samplerate
220+
221+ def play_audio (
222+ self ,
223+ audio_file : Path ,
224+ * ,
225+ blocking : bool = False ,
226+ sound_volume : float = 1.0 ,
227+ show_progress : bool = True ,
228+ ) -> bool :
167229 """Play an audio file through the selected output device.
168230
169231 Args:
170232 audio_file: Path to the audio file
171233 blocking: If True, wait for playback to finish
172234 sound_volume: Per-sound volume multiplier (0.0 to 2.0), combined with global volume
235+ show_progress: If True and blocking, show a progress bar
173236
174237 Returns:
175238 True if playback started successfully, False otherwise.
@@ -196,52 +259,32 @@ def play_audio(self, audio_file: Path, *, blocking: bool = False, sound_volume:
196259 self .console .print (f"[dim]💡 { e .suggestion } [/dim]" )
197260 return False
198261
199- try :
200- # Stop any currently playing audio
201- self .stop_audio ()
202-
203- logger .debug (f"Loading audio file: { audio_file } " )
204-
205- # Load and play the audio file
206- data , samplerate = sf .read (str (audio_file )) # pyright: ignore[reportGeneralTypeIssues]
207-
208- # Ensure data is in the correct format
209- if len (data .shape ) == 1 :
210- # Mono audio
211- data = data .reshape (- 1 , 1 )
212-
213- # Get device info to match channels
214- device_info = sd .query_devices (self .output_device_id )
215- max_channels = device_info ["max_output_channels" ] # pyright: ignore[reportCallIssue, reportArgumentType]
216-
217- logger .debug (
218- f"Audio info: duration={ len (data ) / samplerate :.2f} s, rate={ samplerate } , channels={ data .shape [1 ]} " ,
219- )
262+ # Stop any currently playing audio
263+ self .stop_audio ()
220264
221- # Adjust channels if needed
222- data = self ._adjust_channels (data , max_channels )
265+ logger .debug (f"Loading audio file: { audio_file } " )
223266
224- # Apply combined volume scaling: global x sound-specific
225- final_volume = self .volume * sound_volume
226- data *= final_volume
267+ # Load and prepare audio
268+ result = self ._load_and_prepare_audio (audio_file , sound_volume )
269+ if result is None :
270+ return False
271+ data , samplerate = result
227272
273+ try :
228274 logger .debug (f"Starting playback to device { self .output_device_id } " )
229275 sd .play (data , samplerate , device = self .output_device_id )
230276
231277 if blocking :
278+ # Calculate duration for progress bar
279+ duration_seconds = len (data ) / samplerate
280+
281+ if show_progress and self .console .is_terminal :
282+ return self ._show_progress (audio_file .name , duration_seconds )
283+
232284 # Use polling loop instead of sd.wait() to allow KeyboardInterrupt
233285 while sd .get_stream () and sd .get_stream ().active :
234286 time .sleep (0.1 )
235287
236- except sf .LibsndfileError as e :
237- logger .exception ("Failed to read audio file" )
238- error = AudioFileCorruptedError (
239- f"Cannot read audio file: { audio_file .name } " ,
240- details = {"path" : str (audio_file ), "error" : str (e )},
241- )
242- self .console .print (f"[red]✗[/red] { error .message } " )
243- self .console .print (f"[dim]💡 { error .suggestion } [/dim]" )
244- return False
245288 except sd .PortAudioError as e :
246289 # Device disconnection or error during playback
247290 if "device" in str (e ).lower () or "stream" in str (e ).lower ():
@@ -264,6 +307,78 @@ def play_audio(self, audio_file: Path, *, blocking: bool = False, sound_volume:
264307 )
265308 return True
266309
310+ def _format_time (self , seconds : float ) -> str :
311+ """Format seconds as M:SS.
312+
313+ Args:
314+ seconds: Number of seconds
315+
316+ Returns:
317+ Formatted time string
318+
319+ """
320+ mins = int (seconds // 60 )
321+ secs = int (seconds % 60 )
322+ return f"{ mins } :{ secs :02d} "
323+
324+ def _show_progress (self , filename : str , duration : float ) -> bool :
325+ """Display progress bar during playback.
326+
327+ Args:
328+ filename: Name of the audio file
329+ duration: Total duration in seconds
330+
331+ Returns:
332+ True if completed, False if interrupted
333+
334+ """
335+ with Progress (
336+ TextColumn ("[bold cyan]▶[/bold cyan]" ),
337+ TextColumn ("[white]{task.fields[filename]}[/white]" ),
338+ BarColumn (bar_width = 30 , style = "cyan" , complete_style = "green" ),
339+ TaskProgressColumn (),
340+ TextColumn ("•" ),
341+ TextColumn ("[cyan]{task.fields[elapsed]}[/cyan]" ),
342+ TextColumn ("/" ),
343+ TextColumn ("[dim]{task.fields[total_time]}[/dim]" ),
344+ TextColumn ("•" ),
345+ TextColumn ("[dim]Ctrl+C to stop[/dim]" ),
346+ console = self .console ,
347+ transient = True ,
348+ ) as progress :
349+ task = progress .add_task (
350+ "Playing" ,
351+ total = duration ,
352+ filename = filename ,
353+ elapsed = "0:00" ,
354+ total_time = self ._format_time (duration ),
355+ )
356+
357+ start_time = time .time ()
358+
359+ try :
360+ while sd .get_stream () and sd .get_stream ().active :
361+ elapsed = time .time () - start_time
362+ progress .update (
363+ task ,
364+ completed = min (elapsed , duration ),
365+ elapsed = self ._format_time (elapsed ),
366+ )
367+ time .sleep (0.1 ) # 10 FPS update rate
368+
369+ except KeyboardInterrupt :
370+ self .stop_audio ()
371+ self .console .print ("\n [yellow]⏹[/yellow] Playback stopped" )
372+ return False
373+ else :
374+ # Ensure 100% on completion
375+ progress .update (
376+ task ,
377+ completed = duration ,
378+ elapsed = self ._format_time (duration ),
379+ )
380+ return True
381+
267382 def stop_audio (self ) -> None :
268383 """Stop any currently playing audio."""
269384 try :
0 commit comments