TonieToolbox 0.6.0rc3__py3-none-any.whl → 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
TonieToolbox/player.py ADDED
@@ -0,0 +1,638 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TAF Player Module for TonieToolbox
4
+
5
+ This module provides a simple audio player for TAF (Tonie Audio Format) files.
6
+ It uses the existing TAF parsing functionality and FFmpeg for cross-platform audio playback.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import time
12
+ import tempfile
13
+ import threading
14
+ import subprocess
15
+ from typing import Optional, Dict, Any, List
16
+ from pathlib import Path
17
+
18
+ from .logger import get_logger
19
+ from .tonie_analysis import get_header_info_cli, get_audio_info
20
+ from .dependency_manager import ensure_dependency, get_ffplay_binary
21
+ from .constants import SAMPLE_RATE_KHZ
22
+
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ class TAFPlayerError(Exception):
28
+ """Custom exception for TAF player errors."""
29
+ pass
30
+
31
+
32
+ class TAFPlayer:
33
+ """
34
+ A simple TAF file player using FFmpeg for audio playback.
35
+
36
+ This player can load TAF files, extract audio data, and play it using FFmpeg.
37
+ It supports basic playback controls like play, pause, resume, stop, and seek.
38
+ """
39
+
40
+ def __init__(self):
41
+ """Initialize the TAF player."""
42
+ self.taf_file: Optional[Path] = None
43
+ self.taf_info: Optional[Dict[str, Any]] = None
44
+ self.temp_audio_file: Optional[Path] = None
45
+ self.ffmpeg_process: Optional[subprocess.Popen] = None
46
+ self.is_playing: bool = False
47
+ self.is_paused: bool = False
48
+ self.current_position: float = 0.0
49
+ self.total_duration: float = 0.0
50
+ self.playback_thread: Optional[threading.Thread] = None
51
+ self._stop_event = threading.Event()
52
+ self.header_size: int = 0 # Dynamic header size from TAF parsing
53
+
54
+ # Ensure FFmpeg is available
55
+ try:
56
+ ensure_dependency('ffmpeg')
57
+ except Exception as e:
58
+ raise TAFPlayerError(f"Failed to ensure FFmpeg availability: {e}")
59
+
60
+ def load(self, taf_file_path: str) -> None:
61
+ """
62
+ Load a TAF file for playback.
63
+
64
+ Args:
65
+ taf_file_path: Path to the TAF file to load
66
+
67
+ Raises:
68
+ TAFPlayerError: If the file cannot be loaded or parsed
69
+ """
70
+ taf_path = Path(taf_file_path)
71
+
72
+ if not taf_path.exists():
73
+ raise TAFPlayerError(f"TAF file not found: {taf_file_path}")
74
+
75
+ if not taf_path.suffix.lower() == '.taf':
76
+ raise TAFPlayerError(f"File is not a TAF file: {taf_file_path}")
77
+
78
+ logger.info(f"Loading TAF file: {taf_path}")
79
+
80
+ try:
81
+ # Parse TAF file header and get info
82
+ with open(taf_path, 'rb') as taf_file:
83
+ # Get header information using the CLI version for better error handling
84
+ header_size, tonie_header, file_size, audio_size, sha1sum, \
85
+ opus_head_found, opus_version, channel_count, sample_rate, \
86
+ bitstream_serial_no, opus_comments, valid = get_header_info_cli(taf_file)
87
+
88
+ if not valid:
89
+ raise TAFPlayerError("Invalid or corrupted TAF file")
90
+
91
+ # Get audio information including total duration
92
+ page_count, alignment_okay, page_size_okay, total_time, \
93
+ chapter_times = get_audio_info(taf_file, sample_rate, tonie_header, header_size)
94
+
95
+ # Store header size for audio extraction
96
+ self.header_size = 4 + header_size # 4 bytes for header size + actual header
97
+
98
+ # Build structured information dictionary
99
+ self.taf_info = {
100
+ 'file_size': file_size,
101
+ 'audio_size': audio_size,
102
+ 'sha1_hash': sha1sum.hexdigest() if sha1sum else None,
103
+ 'sample_rate': sample_rate,
104
+ 'channels': channel_count,
105
+ 'bitstream_serial': bitstream_serial_no,
106
+ 'opus_version': opus_version,
107
+ 'page_count': page_count,
108
+ 'total_time': total_time,
109
+ 'opus_comments': opus_comments,
110
+ 'chapters': []
111
+ }
112
+
113
+ # Process chapter information
114
+ if hasattr(tonie_header, 'chapterPages') and len(tonie_header.chapterPages) > 0:
115
+ chapter_granules = [0] # Start with position 0
116
+
117
+ # Find granule positions for each chapter page
118
+ for chapter_page in tonie_header.chapterPages:
119
+ # For now, we'll estimate chapter positions
120
+ # A more accurate implementation would need to parse OGG pages
121
+ pass # Create chapter list with times
122
+ chapter_start_time = 0.0
123
+ for i, chapter_time in enumerate(chapter_times):
124
+ # Parse the formatted time string back to seconds
125
+ duration_seconds = self._parse_time_string(chapter_time)
126
+
127
+ # Store the current chapter with correct start position
128
+ self.taf_info['chapters'].append({
129
+ 'index': i + 1,
130
+ 'title': f'Chapter {i + 1}',
131
+ 'duration': duration_seconds, # Store as float seconds
132
+ 'start': chapter_start_time # Position from start of the file
133
+ })
134
+
135
+ # Update start time for the next chapter
136
+ chapter_start_time += duration_seconds
137
+
138
+ # Extract bitrate from opus comments if available
139
+ if opus_comments and 'ENCODER_OPTIONS' in opus_comments:
140
+ import re
141
+ match = re.search(r'bitrate=(\d+)', opus_comments['ENCODER_OPTIONS'])
142
+ if match:
143
+ self.taf_info['bitrate'] = int(match.group(1))
144
+ self.taf_file = taf_path
145
+ self.total_duration = total_time
146
+
147
+ logger.info(f"Successfully loaded TAF file: {taf_path.name}")
148
+ self._print_file_info()
149
+
150
+ except Exception as e:
151
+ raise TAFPlayerError(f"Failed to parse TAF file: {e}")
152
+
153
+ def _print_file_info(self) -> None:
154
+ """Print information about the loaded TAF file."""
155
+ if not self.taf_info:
156
+ return
157
+
158
+ print("\n" + "="*50)
159
+ print("TAF FILE INFORMATION")
160
+ print("="*50)
161
+
162
+ # Basic file info
163
+ if 'audio_id' in self.taf_info:
164
+ print(f"Audio ID: {self.taf_info['audio_id']}")
165
+
166
+ if 'sha1_hash' in self.taf_info:
167
+ print(f"SHA1 Hash: {self.taf_info['sha1_hash']}")
168
+
169
+ # Audio properties
170
+ if 'sample_rate' in self.taf_info:
171
+ print(f"Sample Rate: {self.taf_info['sample_rate']} Hz")
172
+
173
+ if 'channels' in self.taf_info:
174
+ print(f"Channels: {self.taf_info['channels']}")
175
+
176
+ if 'bitrate' in self.taf_info:
177
+ print(f"Bitrate: {self.taf_info['bitrate']} kbps")
178
+
179
+ if self.total_duration > 0:
180
+ print(f"Duration: {self._format_time(self.total_duration)}")
181
+
182
+ # Chapter information
183
+ if 'chapters' in self.taf_info and self.taf_info['chapters']:
184
+ print(f"\nChapters: {len(self.taf_info['chapters'])}")
185
+ for i, chapter in enumerate(self.taf_info['chapters'][:5]): # Show first 5 chapters
186
+ start_time = self._format_time(chapter.get('start', 0))
187
+ title = chapter.get('title', f'Chapter {i+1}')
188
+ print(f" {i+1:2d}. {title} ({start_time})")
189
+
190
+ if len(self.taf_info['chapters']) > 5:
191
+ print(f" ... and {len(self.taf_info['chapters']) - 5} more chapters")
192
+
193
+ print("="*50 + "\n")
194
+
195
+ def _extract_audio_data(self) -> Path:
196
+ """
197
+ Extract audio data from TAF file to a temporary file for playback.
198
+
199
+ Returns:
200
+ Path to the temporary audio file
201
+
202
+ Raises:
203
+ TAFPlayerError: If audio extraction fails
204
+ """
205
+ if not self.taf_file:
206
+ raise TAFPlayerError("No TAF file loaded")
207
+
208
+ try:
209
+ # Create temporary file for extracted audio
210
+ temp_fd, temp_path = tempfile.mkstemp(suffix='.ogg', prefix='taf_player_')
211
+ os.close(temp_fd)
212
+ temp_file = Path(temp_path)
213
+ logger.debug(f"Extracting audio data to: {temp_file}")
214
+
215
+ # Read TAF file and extract audio data
216
+ with open(self.taf_file, 'rb') as taf:
217
+ # Skip TAF header (use dynamic header size from parsing)
218
+ taf.seek(self.header_size)
219
+
220
+ # Read remaining data (OGG audio)
221
+ audio_data = taf.read()
222
+
223
+ # Write to temporary file
224
+ with open(temp_file, 'wb') as temp:
225
+ temp.write(audio_data)
226
+
227
+ logger.debug("Audio data extraction completed")
228
+ return temp_file
229
+
230
+ except Exception as e:
231
+ if 'temp_file' in locals():
232
+ temp_file.unlink(missing_ok=True)
233
+ raise TAFPlayerError(f"Failed to extract audio data: {e}")
234
+
235
+ def play(self, start_time: float = 0.0) -> None:
236
+ """
237
+ Start playback of the loaded TAF file.
238
+
239
+ Args:
240
+ start_time: Time in seconds to start playback from
241
+
242
+ Raises:
243
+ TAFPlayerError: If playback cannot be started
244
+ """
245
+ if not self.taf_file:
246
+ raise TAFPlayerError("No TAF file loaded")
247
+
248
+ if self.is_playing:
249
+ logger.warning("Playback already in progress")
250
+ return
251
+
252
+ try:
253
+ # Extract audio data if not already done
254
+ if not self.temp_audio_file or not self.temp_audio_file.exists():
255
+ self.temp_audio_file = self._extract_audio_data()
256
+
257
+ self.current_position = start_time
258
+ self._stop_event.clear()
259
+
260
+ # Start playback in a separate thread
261
+ self.playback_thread = threading.Thread(
262
+ target=self._playback_worker,
263
+ args=(start_time,),
264
+ daemon=True
265
+ )
266
+ self.playback_thread.start()
267
+ self.is_playing = True
268
+ self.is_paused = False
269
+
270
+ logger.info(f"Started playback of: {self.taf_file.name}")
271
+ if start_time > 0:
272
+ logger.info(f"Starting from: {self._format_time(start_time)}")
273
+
274
+ except Exception as e:
275
+ raise TAFPlayerError(f"Failed to start playback: {e}")
276
+ def _playback_worker(self, start_time: float = 0.0) -> None:
277
+ """Worker thread for handling FFplay audio playback."""
278
+ try:
279
+ # Get FFplay binary path
280
+ ffplay_path = get_ffplay_binary(auto_download=True)
281
+ if not ffplay_path:
282
+ raise TAFPlayerError("FFplay not found and could not be downloaded")
283
+
284
+ logger.debug(f"Using FFplay at: {ffplay_path}")
285
+
286
+ # Verify temp audio file exists
287
+ if not self.temp_audio_file or not self.temp_audio_file.exists():
288
+ raise TAFPlayerError("No audio data available for playback")
289
+
290
+ # Build FFplay command
291
+ ffplay_cmd = [
292
+ ffplay_path,
293
+ str(self.temp_audio_file),
294
+ '-nodisp', # No video display window
295
+ '-autoexit', # Exit when playback finishes
296
+ '-loglevel', 'warning' # Reduce output noise
297
+ ]
298
+
299
+ # Add start time if specified
300
+ if start_time > 0:
301
+ ffplay_cmd.extend(['-ss', str(start_time)])
302
+
303
+ logger.debug(f"FFplay command: {' '.join(ffplay_cmd)}")
304
+
305
+ # Start FFplay process
306
+ self.ffmpeg_process = subprocess.Popen(
307
+ ffplay_cmd,
308
+ stdout=subprocess.DEVNULL,
309
+ stderr=subprocess.PIPE,
310
+ stdin=subprocess.PIPE
311
+ )
312
+
313
+ # Wait for process completion or stop signal
314
+ while self.ffmpeg_process.poll() is None:
315
+ if self._stop_event.wait(0.1):
316
+ break
317
+
318
+ # Update current position (rough estimate)
319
+ if not self.is_paused:
320
+ self.current_position += 0.1
321
+
322
+ # Clean up process
323
+ if self.ffmpeg_process and self.ffmpeg_process.poll() is None:
324
+ self.ffmpeg_process.terminate()
325
+ self.ffmpeg_process.wait(timeout=5)
326
+
327
+ except Exception as e:
328
+ logger.error(f"Playback error: {e}")
329
+ finally:
330
+ self.is_playing = False
331
+ self.is_paused = False
332
+ self.ffmpeg_process = None
333
+ def pause(self) -> None:
334
+ """Pause playback."""
335
+ if not self.is_playing:
336
+ logger.warning("No playback in progress")
337
+ return
338
+
339
+ if self.is_paused:
340
+ logger.warning("Playback already paused")
341
+ return
342
+
343
+ if self.ffmpeg_process:
344
+ try:
345
+ # Send SIGSTOP to pause (Unix) or terminate and remember position (Windows)
346
+ if hasattr(self.ffmpeg_process, 'suspend'):
347
+ self.ffmpeg_process.suspend()
348
+ self.is_paused = True
349
+ logger.info("Playback paused")
350
+ else:
351
+ # For Windows, we'll need to stop and restart
352
+ # Save current position before stopping
353
+ pause_position = self.current_position
354
+ self._stop_playback_process()
355
+ # Restore the position and set paused state
356
+ self.current_position = pause_position
357
+ self.is_playing = False
358
+ self.is_paused = True
359
+ logger.info("Playback paused")
360
+
361
+ except Exception as e:
362
+ logger.error(f"Failed to pause playback: {e}")
363
+ def resume(self) -> None:
364
+ """Resume paused playback."""
365
+ if not self.is_paused:
366
+ logger.warning("Playback is not paused")
367
+ return
368
+
369
+ try:
370
+ if self.ffmpeg_process and hasattr(self.ffmpeg_process, 'resume'):
371
+ # Unix-like systems with process suspension support
372
+ self.ffmpeg_process.resume()
373
+ self.is_paused = False
374
+ logger.info("Playback resumed")
375
+ else:
376
+ # Windows or systems without suspension - restart from saved position
377
+ resume_position = self.current_position
378
+ self.is_paused = False
379
+ self.play(resume_position)
380
+ return
381
+
382
+ except Exception as e:
383
+ logger.error(f"Failed to resume playback: {e}")
384
+
385
+ def _stop_playback_process(self) -> None:
386
+ """Stop the playback process without resetting position or state."""
387
+ # Signal stop to playback thread
388
+ self._stop_event.set()
389
+
390
+ # Terminate FFmpeg process
391
+ if self.ffmpeg_process:
392
+ try:
393
+ self.ffmpeg_process.terminate()
394
+ self.ffmpeg_process.wait(timeout=5)
395
+ except subprocess.TimeoutExpired:
396
+ self.ffmpeg_process.kill()
397
+ except Exception as e:
398
+ logger.error(f"Error stopping FFmpeg process: {e}")
399
+
400
+ # Wait for playback thread to finish
401
+ if self.playback_thread and self.playback_thread.is_alive():
402
+ self.playback_thread.join(timeout=5)
403
+
404
+ self.ffmpeg_process = None
405
+
406
+ def stop(self) -> None:
407
+ """Stop playback."""
408
+ if not self.is_playing:
409
+ return
410
+
411
+ logger.info("Stopping playback")
412
+
413
+ self._stop_playback_process()
414
+
415
+ self.is_playing = False
416
+ self.is_paused = False
417
+ self.current_position = 0.0
418
+
419
+ logger.info("Playback stopped")
420
+
421
+ def seek(self, position: float) -> None:
422
+ """
423
+ Seek to a specific position in the audio.
424
+
425
+ Args:
426
+ position: Time in seconds to seek to
427
+ """
428
+ if position < 0:
429
+ position = 0.0
430
+ elif self.total_duration > 0 and position > self.total_duration:
431
+ position = self.total_duration
432
+
433
+ was_playing = self.is_playing
434
+
435
+ if was_playing:
436
+ self.stop()
437
+
438
+ self.current_position = position
439
+
440
+ if was_playing:
441
+ self.play(position)
442
+
443
+ logger.info(f"Seeked to: {self._format_time(position)}")
444
+ def get_status(self) -> Dict[str, Any]:
445
+ """
446
+ Get current player status.
447
+
448
+ Returns:
449
+ Dictionary containing player status information
450
+ """
451
+ return {
452
+ 'file_loaded': self.taf_file is not None,
453
+ 'file_path': str(self.taf_file) if self.taf_file else None,
454
+ 'is_playing': self.is_playing,
455
+ 'is_paused': self.is_paused,
456
+ 'current_position': self.current_position,
457
+ 'total_duration': self.total_duration,
458
+ 'progress_percent': (self.current_position / self.total_duration * 100) if self.total_duration > 0 else 0.0
459
+ }
460
+
461
+ def cleanup(self) -> None:
462
+ """Clean up resources used by the player."""
463
+ self.stop()
464
+
465
+ # Remove temporary audio file
466
+ if self.temp_audio_file and self.temp_audio_file.exists():
467
+ try:
468
+ self.temp_audio_file.unlink()
469
+ logger.debug("Cleaned up temporary audio file")
470
+ except Exception as e:
471
+ logger.warning(f"Failed to clean up temporary file: {e}")
472
+
473
+ def _format_time(self, seconds: float) -> str:
474
+ """
475
+ Format time in seconds to MM:SS or HH:MM:SS format.
476
+
477
+ Args:
478
+ seconds: Time in seconds
479
+
480
+ Returns:
481
+ Formatted time string
482
+ """
483
+ hours = int(seconds // 3600)
484
+ minutes = int((seconds % 3600) // 60)
485
+ secs = int(seconds % 60)
486
+
487
+ if hours > 0:
488
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
489
+ else:
490
+ return f"{minutes:02d}:{secs:02d}"
491
+
492
+ def _parse_time_string(self, time_str: str) -> float:
493
+ """
494
+ Parse a time string (HH:MM:SS.FF or MM:SS) back to seconds.
495
+
496
+ Args:
497
+ time_str: Formatted time string
498
+
499
+ Returns:
500
+ Time in seconds as float
501
+ """
502
+ try:
503
+ parts = time_str.split(':')
504
+ if len(parts) == 3:
505
+ # HH:MM:SS or HH:MM:SS.FF format
506
+ hours = int(parts[0])
507
+ minutes = int(parts[1])
508
+ # Handle potential fractional seconds
509
+ seconds_part = parts[2]
510
+ if '.' in seconds_part:
511
+ seconds = float(seconds_part)
512
+ else:
513
+ seconds = int(seconds_part)
514
+ return hours * 3600 + minutes * 60 + seconds
515
+ elif len(parts) == 2:
516
+ # MM:SS format
517
+ minutes = int(parts[0])
518
+ seconds_part = parts[1]
519
+ if '.' in seconds_part:
520
+ seconds = float(seconds_part)
521
+ else:
522
+ seconds = int(seconds_part)
523
+ return minutes * 60 + seconds
524
+ else:
525
+ # Single number, assume seconds
526
+ return float(time_str)
527
+ except (ValueError, IndexError):
528
+ logger.warning(f"Could not parse time string: {time_str}")
529
+ return 0.0
530
+
531
+ def __enter__(self):
532
+ """Context manager entry."""
533
+ return self
534
+
535
+ def __exit__(self, exc_type, exc_val, exc_tb):
536
+ """Context manager exit - cleanup resources."""
537
+ self.cleanup()
538
+
539
+
540
+ def interactive_player(taf_file_path: str) -> None:
541
+ """
542
+ Start an interactive TAF player session.
543
+
544
+ Args:
545
+ taf_file_path: Path to the TAF file to play
546
+ """
547
+ print("TonieToolbox TAF Player")
548
+ print("======================")
549
+
550
+ try:
551
+ with TAFPlayer() as player:
552
+ player.load(taf_file_path)
553
+
554
+ print("\nControls:")
555
+ print(" [p]lay - Play/pause toggle")
556
+ print(" [s]top - Stop playback")
557
+ print(" [q]uit - Quit player")
558
+ print(" [i]nfo - Show file information")
559
+ print(" seek <MM:SS> - Seek to specific time")
560
+ print(" status - Show current status")
561
+ print("\nType a command and press Enter:")
562
+
563
+ while True:
564
+ try:
565
+ command = input("> ").strip().lower()
566
+
567
+ if command in ['q', 'quit', 'exit']:
568
+ break
569
+ elif command in ['p', 'play']:
570
+ if player.is_playing and not player.is_paused:
571
+ # Currently playing, so pause
572
+ player.pause()
573
+ elif player.is_paused:
574
+ # Currently paused, so resume
575
+ player.resume()
576
+ else:
577
+ # Not playing, so start
578
+ player.play()
579
+ elif command in ['s', 'stop']:
580
+ player.stop()
581
+ elif command in ['i', 'info']:
582
+ player._print_file_info()
583
+ elif command == 'status':
584
+ status = player.get_status()
585
+ print(f"Status: {'Playing' if status['is_playing'] else 'Stopped'}")
586
+ if status['is_paused']:
587
+ print(" (Paused)")
588
+ print(f"Position: {player._format_time(status['current_position'])}")
589
+ if status['total_duration'] > 0:
590
+ print(f"Duration: {player._format_time(status['total_duration'])}")
591
+ print(f"Progress: {status['progress_percent']:.1f}%")
592
+ elif command.startswith('seek '):
593
+ try:
594
+ time_str = command[5:].strip()
595
+ if ':' in time_str:
596
+ parts = time_str.split(':')
597
+ if len(parts) == 2:
598
+ minutes, seconds = map(int, parts)
599
+ seek_time = minutes * 60 + seconds
600
+ elif len(parts) == 3:
601
+ hours, minutes, seconds = map(int, parts)
602
+ seek_time = hours * 3600 + minutes * 60 + seconds
603
+ else:
604
+ raise ValueError("Invalid time format")
605
+ else:
606
+ seek_time = float(time_str)
607
+
608
+ player.seek(seek_time)
609
+ except ValueError:
610
+ print("Invalid time format. Use MM:SS or HH:MM:SS or seconds")
611
+ elif command == '':
612
+ continue
613
+ else:
614
+ print("Unknown command. Type 'q' to quit.")
615
+
616
+ except KeyboardInterrupt:
617
+ break
618
+ except EOFError:
619
+ break
620
+ except Exception as e:
621
+ logger.error(f"Command error: {e}")
622
+
623
+ print("\nGoodbye!")
624
+
625
+ except TAFPlayerError as e:
626
+ logger.error(f"Player error: {e}")
627
+ sys.exit(1)
628
+ except Exception as e:
629
+ logger.error(f"Unexpected error: {e}")
630
+ sys.exit(1)
631
+
632
+
633
+ if __name__ == "__main__":
634
+ if len(sys.argv) != 2:
635
+ print("Usage: python -m TonieToolbox.player <taf_file>")
636
+ sys.exit(1)
637
+
638
+ interactive_player(sys.argv[1])
@@ -338,4 +338,32 @@ def process_recursive_folders(root_path: str, use_media_tags: bool = False, name
338
338
  folder_path, output_name, len(audio_files))
339
339
 
340
340
  logger.info("Created %d processing tasks", len(results))
341
- return results
341
+ return results
342
+
343
+ def get_all_audio_files_recursive(root_path: str) -> list[str]:
344
+ """
345
+ Get all audio files recursively from a directory tree.
346
+
347
+ Args:
348
+ root_path (str): Root directory to search
349
+
350
+ Returns:
351
+ list[str]: List of all audio files found recursively
352
+ """
353
+ logger.debug("Getting all audio files recursively from: %s", root_path)
354
+
355
+ # Use the existing find_audio_folders function to get all folders with audio files
356
+ all_folders = find_audio_folders(root_path)
357
+
358
+ # Extract all audio files from all folders
359
+ all_audio_files = []
360
+ for folder_info in all_folders:
361
+ audio_files = folder_info['audio_files']
362
+ all_audio_files.extend(audio_files)
363
+ logger.debug("Found %d audio files in folder: %s", len(audio_files), folder_info['path'])
364
+
365
+ # Sort files for consistent processing order
366
+ all_audio_files.sort()
367
+
368
+ logger.info("Found %d total audio files recursively", len(all_audio_files))
369
+ return all_audio_files