MogensR commited on
Commit
cfb2174
·
1 Parent(s): fa29cf2

Update processing/audio/audio_processor.py

Browse files
Files changed (1) hide show
  1. processing/audio/audio_processor.py +534 -524
processing/audio/audio_processor.py CHANGED
@@ -1,585 +1,595 @@
 
1
  """
2
  Audio Processing Module
3
- Handles audio extraction, processing, and integration with FFmpeg operations
 
 
 
 
 
 
 
 
4
  """
5
 
 
 
6
  import os
7
- import subprocess
8
- import tempfile
9
- import logging
10
  import time
 
 
 
 
 
11
  from pathlib import Path
12
- from typing import Optional, Dict, Any, List, Tuple
13
 
14
- # Import from core
15
  from core.exceptions import AudioProcessingError
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
19
  class AudioProcessor:
20
  """
21
- Comprehensive audio processing for video background replacement
22
  """
23
-
24
  def __init__(self, temp_dir: Optional[str] = None):
25
  self.temp_dir = temp_dir or tempfile.gettempdir()
26
- self.ffmpeg_available = self._check_ffmpeg_availability()
27
- self.ffprobe_available = self._check_ffprobe_availability()
28
-
29
- # Audio processing statistics
 
30
  self.stats = {
31
- 'audio_extractions': 0,
32
- 'audio_merges': 0,
33
- 'total_processing_time': 0.0,
34
- 'failed_operations': 0
35
  }
36
-
37
  if not self.ffmpeg_available:
38
  logger.warning("FFmpeg not available - audio processing will be limited")
39
-
40
- logger.info(f"AudioProcessor initialized (FFmpeg: {self.ffmpeg_available}, FFprobe: {self.ffprobe_available})")
41
-
42
- def _check_ffmpeg_availability(self) -> bool:
43
- """Check if FFmpeg is available on the system"""
44
- try:
45
- result = subprocess.run(
46
- ['ffmpeg', '-version'],
47
- capture_output=True,
48
- text=True,
49
- timeout=10
50
- )
51
- return result.returncode == 0
52
- except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
53
- return False
54
-
55
- def _check_ffprobe_availability(self) -> bool:
56
- """Check if FFprobe is available on the system"""
57
- try:
58
- result = subprocess.run(
59
- ['ffprobe', '-version'],
60
- capture_output=True,
61
- text=True,
62
- timeout=10
63
- )
64
- return result.returncode == 0
65
- except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
66
  return False
67
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def get_audio_info(self, video_path: str) -> Dict[str, Any]:
69
  """
70
- Get comprehensive audio information from video file
71
-
72
- Args:
73
- video_path: Path to the video file
74
-
75
- Returns:
76
- Dictionary containing audio information
77
  """
78
  if not self.ffprobe_available:
79
- return {'has_audio': False, 'error': 'FFprobe not available'}
80
-
81
  try:
82
- # Get audio stream information
83
- result = subprocess.run([
84
- 'ffprobe', '-v', 'quiet', '-select_streams', 'a:0',
85
- '-show_entries', 'stream=codec_name,sample_rate,channels,duration,bit_rate',
86
- '-of', 'csv=p=0', video_path
87
- ], capture_output=True, text=True, timeout=30)
88
-
89
- if result.returncode != 0:
90
- return {
91
- 'has_audio': False,
92
- 'error': 'No audio stream found',
93
- 'ffprobe_error': result.stderr
94
- }
95
-
96
- # Parse audio information
97
- audio_data = result.stdout.strip().split(',')
98
-
99
- if len(audio_data) >= 1 and audio_data[0]:
100
- info = {
101
- 'has_audio': True,
102
- 'codec': audio_data[0] if len(audio_data) > 0 else 'unknown',
103
- 'sample_rate': audio_data[1] if len(audio_data) > 1 else 'unknown',
104
- 'channels': audio_data[2] if len(audio_data) > 2 else 'unknown',
105
- 'duration': audio_data[3] if len(audio_data) > 3 else 'unknown',
106
- 'bit_rate': audio_data[4] if len(audio_data) > 4 else 'unknown'
107
- }
108
-
109
- # Convert string values to appropriate types
110
- try:
111
- if info['sample_rate'] != 'unknown':
112
- info['sample_rate'] = int(info['sample_rate'])
113
- if info['channels'] != 'unknown':
114
- info['channels'] = int(info['channels'])
115
- if info['duration'] != 'unknown':
116
- info['duration'] = float(info['duration'])
117
- if info['bit_rate'] != 'unknown':
118
- info['bit_rate'] = int(info['bit_rate'])
119
- except ValueError:
120
- pass # Keep as string if conversion fails
121
-
122
- return info
123
- else:
124
- return {'has_audio': False, 'error': 'Audio stream data empty'}
125
-
126
- except subprocess.TimeoutExpired:
127
- return {'has_audio': False, 'error': 'FFprobe timeout'}
128
  except Exception as e:
129
- logger.error(f"Error getting audio info: {e}")
130
- return {'has_audio': False, 'error': str(e)}
131
-
132
- def extract_audio(self, video_path: str, output_path: Optional[str] = None,
133
- audio_format: str = 'aac', quality: str = 'high') -> Optional[str]:
 
 
 
 
 
 
 
 
 
134
  """
135
- Extract audio from video file
136
-
137
- Args:
138
- video_path: Path to input video
139
- output_path: Output path for audio (auto-generated if None)
140
- audio_format: Output audio format (aac, mp3, wav)
141
- quality: Audio quality (low, medium, high)
142
-
143
- Returns:
144
- Path to extracted audio file or None if failed
145
  """
146
  if not self.ffmpeg_available:
147
  raise AudioProcessingError("extract", "FFmpeg not available", video_path)
148
-
149
- start_time = time.time()
150
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  try:
152
- # Check if input has audio
153
- audio_info = self.get_audio_info(video_path)
154
- if not audio_info.get('has_audio', False):
155
- logger.info(f"No audio found in {video_path}")
156
- return None
157
-
158
- # Generate output path if not provided
159
- if output_path is None:
160
- timestamp = int(time.time())
161
- output_path = os.path.join(
162
- self.temp_dir,
163
- f"extracted_audio_{timestamp}.{audio_format}"
164
- )
165
-
166
- # Quality settings
167
- quality_settings = {
168
- 'low': {'aac': ['-b:a', '96k'], 'mp3': ['-b:a', '128k'], 'wav': []},
169
- 'medium': {'aac': ['-b:a', '192k'], 'mp3': ['-b:a', '192k'], 'wav': []},
170
- 'high': {'aac': ['-b:a', '320k'], 'mp3': ['-b:a', '320k'], 'wav': []}
171
- }
172
-
173
- codec_settings = {
174
- 'aac': ['-c:a', 'aac'],
175
- 'mp3': ['-c:a', 'libmp3lame'],
176
- 'wav': ['-c:a', 'pcm_s16le']
177
  }
178
-
179
- # Build FFmpeg command
180
- cmd = ['ffmpeg', '-y', '-i', video_path]
181
- cmd.extend(codec_settings.get(audio_format, ['-c:a', 'aac']))
182
- cmd.extend(quality_settings.get(quality, {}).get(audio_format, []))
183
- cmd.extend(['-vn', output_path]) # -vn excludes video
184
-
185
- # Execute command
186
- result = subprocess.run(
187
- cmd,
188
- capture_output=True,
189
- text=True,
190
- timeout=300 # 5 minute timeout
191
- )
192
-
193
- if result.returncode != 0:
194
- raise AudioProcessingError(
195
- "extract",
196
- f"FFmpeg failed: {result.stderr}",
197
- video_path,
198
- output_path
199
- )
200
-
201
- if not os.path.exists(output_path):
202
- raise AudioProcessingError(
203
- "extract",
204
- "Output audio file was not created",
205
- video_path,
206
- output_path
207
- )
208
-
209
- # Update statistics
210
- processing_time = time.time() - start_time
211
- self.stats['audio_extractions'] += 1
212
- self.stats['total_processing_time'] += processing_time
213
-
214
- logger.info(f"Audio extracted successfully in {processing_time:.1f}s: {output_path}")
215
- return output_path
216
-
217
- except subprocess.TimeoutExpired:
218
- self.stats['failed_operations'] += 1
219
- raise AudioProcessingError("extract", "FFmpeg timeout during extraction", video_path)
220
  except Exception as e:
221
- self.stats['failed_operations'] += 1
222
- if isinstance(e, AudioProcessingError):
223
- raise
224
- else:
225
- raise AudioProcessingError("extract", f"Unexpected error: {str(e)}", video_path)
226
-
227
- def add_audio_to_video(self, original_video: str, processed_video: str,
228
- output_path: Optional[str] = None,
229
- audio_quality: str = 'high') -> str:
230
  """
231
- Add audio from original video to processed video
232
-
233
- Args:
234
- original_video: Path to original video with audio
235
- processed_video: Path to processed video without audio
236
- output_path: Output path (auto-generated if None)
237
- audio_quality: Audio quality setting
238
-
239
- Returns:
240
- Path to final video with audio
241
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  if not self.ffmpeg_available:
243
- logger.warning("FFmpeg not available - returning processed video without audio")
244
- return processed_video
245
-
246
- start_time = time.time()
247
-
248
- try:
249
- # Check if original video has audio
250
- audio_info = self.get_audio_info(original_video)
251
- if not audio_info.get('has_audio', False):
252
- logger.info("Original video has no audio - returning processed video")
253
- return processed_video
254
-
255
- # Generate output path if not provided
256
- if output_path is None:
257
- timestamp = int(time.time())
258
- output_path = os.path.join(
259
- self.temp_dir,
260
- f"final_with_audio_{timestamp}.mp4"
261
- )
262
-
263
- # Quality settings for audio encoding
264
- quality_settings = {
265
- 'low': ['-b:a', '96k'],
266
- 'medium': ['-b:a', '192k'],
267
- 'high': ['-b:a', '320k']
268
- }
269
-
270
- # Build FFmpeg command to combine video and audio
 
 
271
  cmd = [
272
- 'ffmpeg', '-y',
273
- '-i', processed_video, # Video input
274
- '-i', original_video, # Audio source
275
- '-c:v', 'copy', # Copy video stream as-is
276
- '-c:a', 'aac', # Encode audio as AAC
 
 
 
 
277
  ]
278
-
279
- # Add quality settings
280
- cmd.extend(quality_settings.get(audio_quality, quality_settings['high']))
281
-
282
- # Map streams and set duration
283
- cmd.extend([
284
- '-map', '0:v:0', # Video from first input
285
- '-map', '1:a:0', # Audio from second input
286
- '-shortest', # Match shortest stream duration
287
- output_path
288
- ])
289
-
290
- # Execute command
291
- result = subprocess.run(
292
- cmd,
293
- capture_output=True,
294
- text=True,
295
- timeout=600 # 10 minute timeout
296
- )
297
-
298
- if result.returncode != 0:
299
- logger.warning(f"Audio merge failed: {result.stderr}")
300
- logger.warning("Returning processed video without audio")
301
- return processed_video
302
-
303
- if not os.path.exists(output_path):
304
- logger.warning("Output video with audio was not created")
305
- return processed_video
306
-
307
- # Verify the output file
308
- if os.path.getsize(output_path) == 0:
309
- logger.warning("Output video file is empty")
310
- try:
311
- os.remove(output_path)
312
- except:
313
- pass
314
- return processed_video
315
-
316
- # Clean up original processed video if successful
317
- try:
318
- if output_path != processed_video:
319
- os.remove(processed_video)
320
- logger.debug("Cleaned up intermediate processed video")
321
- except Exception as e:
322
- logger.warning(f"Could not clean up intermediate file: {e}")
323
-
324
- # Update statistics
325
- processing_time = time.time() - start_time
326
- self.stats['audio_merges'] += 1
327
- self.stats['total_processing_time'] += processing_time
328
-
329
- logger.info(f"Audio merged successfully in {processing_time:.1f}s: {output_path}")
 
 
 
 
 
 
 
 
 
 
 
 
330
  return output_path
331
-
332
- except subprocess.TimeoutExpired:
333
- self.stats['failed_operations'] += 1
334
- logger.warning("Audio merge timeout - returning processed video without audio")
335
- return processed_video
 
 
 
 
 
 
 
336
  except Exception as e:
337
- self.stats['failed_operations'] += 1
338
- logger.warning(f"Audio merge error: {e} - returning processed video without audio")
339
- return processed_video
340
-
341
- def sync_audio_video(self, video_path: str, audio_path: str,
342
- output_path: str, offset_ms: float = 0.0) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  """
344
- Synchronize separate audio and video files
345
-
346
- Args:
347
- video_path: Path to video file
348
- audio_path: Path to audio file
349
- output_path: Output path for synchronized file
350
- offset_ms: Audio offset in milliseconds (positive = delay audio)
351
-
352
- Returns:
353
- True if successful, False otherwise
354
  """
355
  if not self.ffmpeg_available:
356
  raise AudioProcessingError("sync", "FFmpeg not available")
357
-
358
- try:
359
- cmd = ['ffmpeg', '-y', '-i', video_path, '-i', audio_path]
360
-
361
- # Add audio offset if specified
362
- if offset_ms != 0.0:
363
- offset_seconds = offset_ms / 1000.0
364
- cmd.extend(['-itsoffset', str(offset_seconds)])
365
-
366
- cmd.extend([
367
- '-c:v', 'copy', # Copy video as-is
368
- '-c:a', 'aac', # Encode audio as AAC
369
- '-b:a', '192k', # Audio bitrate
370
- '-shortest', # Match shortest stream
371
- output_path
372
- ])
373
-
374
- result = subprocess.run(
375
- cmd,
376
- capture_output=True,
377
- text=True,
378
- timeout=600
379
- )
380
-
381
- if result.returncode != 0:
382
- raise AudioProcessingError(
383
- "sync",
384
- f"Synchronization failed: {result.stderr}",
385
- video_path
386
- )
387
-
388
- return os.path.exists(output_path) and os.path.getsize(output_path) > 0
389
-
390
- except subprocess.TimeoutExpired:
391
- raise AudioProcessingError("sync", "Synchronization timeout", video_path)
392
- except Exception as e:
393
- if isinstance(e, AudioProcessingError):
394
- raise
395
  else:
396
- raise AudioProcessingError("sync", f"Unexpected error: {str(e)}", video_path)
397
-
398
- def adjust_audio_levels(self, input_path: str, output_path: str,
399
- volume_factor: float = 1.0, normalize: bool = False) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  """
401
- Adjust audio levels in a video file
402
-
403
- Args:
404
- input_path: Input video path
405
- output_path: Output video path
406
- volume_factor: Volume multiplication factor (1.0 = no change)
407
- normalize: Whether to normalize audio levels
408
-
409
- Returns:
410
- True if successful, False otherwise
411
  """
412
  if not self.ffmpeg_available:
413
  raise AudioProcessingError("adjust_levels", "FFmpeg not available")
414
-
415
- try:
416
- cmd = ['ffmpeg', '-y', '-i', input_path, '-c:v', 'copy']
417
-
418
- # Build audio filter
419
- audio_filters = []
420
-
421
- if volume_factor != 1.0:
422
- audio_filters.append(f"volume={volume_factor}")
423
-
424
- if normalize:
425
- audio_filters.append("loudnorm")
426
-
427
- if audio_filters:
428
- cmd.extend(['-af', ','.join(audio_filters)])
429
-
430
- cmd.extend(['-c:a', 'aac', '-b:a', '192k', output_path])
431
-
432
- result = subprocess.run(
433
- cmd,
434
- capture_output=True,
435
- text=True,
436
- timeout=600
437
- )
438
-
439
- if result.returncode != 0:
440
- raise AudioProcessingError(
441
- "adjust_levels",
442
- f"Level adjustment failed: {result.stderr}",
443
- input_path
444
- )
445
-
446
- return os.path.exists(output_path) and os.path.getsize(output_path) > 0
447
-
448
- except Exception as e:
449
- if isinstance(e, AudioProcessingError):
450
- raise
451
  else:
452
- raise AudioProcessingError("adjust_levels", f"Unexpected error: {str(e)}", input_path)
453
-
454
- def get_supported_formats(self) -> Dict[str, List[str]]:
455
- """Get supported audio and video formats"""
456
- if not self.ffmpeg_available:
457
- return {'audio': [], 'video': []}
458
-
459
- try:
460
- # Get supported formats from FFmpeg
461
- result = subprocess.run(
462
- ['ffmpeg', '-formats'],
463
- capture_output=True,
464
- text=True,
465
- timeout=30
466
- )
467
-
468
- if result.returncode != 0:
469
- return {'audio': ['aac', 'mp3', 'wav'], 'video': ['mp4', 'avi', 'mov']}
470
-
471
- # Parse output (simplified - could be more comprehensive)
472
- lines = result.stdout.split('\n')
473
- audio_formats = []
474
- video_formats = []
475
-
476
- for line in lines:
477
- if 'aac' in line.lower():
478
- audio_formats.append('aac')
479
- elif 'mp3' in line.lower():
480
- audio_formats.append('mp3')
481
- elif 'wav' in line.lower():
482
- audio_formats.append('wav')
483
- elif 'mp4' in line.lower():
484
- video_formats.append('mp4')
485
- elif 'avi' in line.lower():
486
- video_formats.append('avi')
487
- elif 'mov' in line.lower():
488
- video_formats.append('mov')
489
-
490
- return {
491
- 'audio': list(set(audio_formats)) or ['aac', 'mp3', 'wav'],
492
- 'video': list(set(video_formats)) or ['mp4', 'avi', 'mov']
493
- }
494
-
495
- except Exception as e:
496
- logger.warning(f"Could not get supported formats: {e}")
497
- return {'audio': ['aac', 'mp3', 'wav'], 'video': ['mp4', 'avi', 'mov']}
498
-
499
- def validate_audio_video_compatibility(self, video_path: str, audio_path: str) -> Dict[str, Any]:
500
- """
501
- Validate compatibility between video and audio files
502
-
503
- Returns:
504
- Dictionary with compatibility information
505
- """
506
- if not self.ffprobe_available:
507
- return {'compatible': False, 'error': 'FFprobe not available'}
508
-
509
- try:
510
- # Get video info
511
- video_result = subprocess.run([
512
- 'ffprobe', '-v', 'quiet', '-select_streams', 'v:0',
513
- '-show_entries', 'stream=duration', '-of', 'csv=p=0', video_path
514
- ], capture_output=True, text=True, timeout=30)
515
-
516
- # Get audio info
517
- audio_result = subprocess.run([
518
- 'ffprobe', '-v', 'quiet', '-select_streams', 'a:0',
519
- '-show_entries', 'stream=duration', '-of', 'csv=p=0', audio_path
520
- ], capture_output=True, text=True, timeout=30)
521
-
522
- if video_result.returncode != 0 or audio_result.returncode != 0:
523
- return {'compatible': False, 'error': 'Could not read file information'}
524
-
525
- try:
526
- video_duration = float(video_result.stdout.strip())
527
- audio_duration = float(audio_result.stdout.strip())
528
-
529
- duration_diff = abs(video_duration - audio_duration)
530
- duration_diff_percent = (duration_diff / max(video_duration, audio_duration)) * 100
531
-
532
- return {
533
- 'compatible': duration_diff_percent < 5.0, # 5% tolerance
534
- 'video_duration': video_duration,
535
- 'audio_duration': audio_duration,
536
- 'duration_difference': duration_diff,
537
- 'duration_difference_percent': duration_diff_percent,
538
- 'recommendation': (
539
- 'Compatible' if duration_diff_percent < 5.0
540
- else 'Duration mismatch - consider trimming/extending'
541
- )
542
- }
543
-
544
- except ValueError:
545
- return {'compatible': False, 'error': 'Invalid duration values'}
546
-
547
- except Exception as e:
548
- return {'compatible': False, 'error': str(e)}
549
-
550
  def get_stats(self) -> Dict[str, Any]:
551
- """Get audio processing statistics"""
 
 
552
  return {
553
- 'ffmpeg_available': self.ffmpeg_available,
554
- 'ffprobe_available': self.ffprobe_available,
555
- 'audio_extractions': self.stats['audio_extractions'],
556
- 'audio_merges': self.stats['audio_merges'],
557
- 'total_processing_time': self.stats['total_processing_time'],
558
- 'failed_operations': self.stats['failed_operations'],
559
- 'success_rate': (
560
- (self.stats['audio_extractions'] + self.stats['audio_merges']) /
561
- max(1, self.stats['audio_extractions'] + self.stats['audio_merges'] + self.stats['failed_operations'])
562
- ) * 100
563
  }
564
-
565
  def cleanup_temp_files(self, max_age_hours: int = 24):
566
- """Clean up temporary audio files older than specified age"""
 
 
567
  try:
568
  temp_path = Path(self.temp_dir)
569
- current_time = time.time()
570
- cutoff_time = current_time - (max_age_hours * 3600)
571
-
572
- cleaned_files = 0
573
- for file_path in temp_path.glob("*audio*.{aac,mp3,wav,mp4}"):
574
- if file_path.stat().st_mtime < cutoff_time:
575
  try:
576
- file_path.unlink()
577
- cleaned_files += 1
 
578
  except Exception as e:
579
- logger.warning(f"Could not delete temp file {file_path}: {e}")
580
-
581
- if cleaned_files > 0:
582
- logger.info(f"Cleaned up {cleaned_files} temporary audio files")
583
-
584
  except Exception as e:
585
- logger.warning(f"Error during temp file cleanup: {e}")
 
1
+ #!/usr/bin/env python3
2
  """
3
  Audio Processing Module
4
+ Handles audio extraction, processing, and integration with FFmpeg operations.
5
+
6
+ Upgrades:
7
+ - Prefer lossless audio stream-copy for muxing (no generational loss).
8
+ - Safe fallback to AAC re-encode when needed.
9
+ - Optional EBU R128 loudness normalization (two-pass loudnorm).
10
+ - Optional audio/video offset with sample-accurate filters.
11
+ - Robust ffprobe-based audio detection and metadata.
12
+ - MoviePy fallback when ffmpeg is unavailable.
13
  """
14
 
15
+ from __future__ import annotations
16
+
17
  import os
18
+ import re
19
+ import json
 
20
  import time
21
+ import math
22
+ import shutil
23
+ import logging
24
+ import tempfile
25
+ import subprocess
26
  from pathlib import Path
27
+ from typing import Optional, Dict, Any, List
28
 
 
29
  from core.exceptions import AudioProcessingError
30
 
31
  logger = logging.getLogger(__name__)
32
 
33
+
34
  class AudioProcessor:
35
  """
36
+ Comprehensive audio processing for video background replacement.
37
  """
38
+
39
  def __init__(self, temp_dir: Optional[str] = None):
40
  self.temp_dir = temp_dir or tempfile.gettempdir()
41
+ self.ffmpeg_path = shutil.which("ffmpeg")
42
+ self.ffprobe_path = shutil.which("ffprobe")
43
+ self.ffmpeg_available = self.ffmpeg_path is not None
44
+ self.ffprobe_available = self.ffprobe_path is not None
45
+
46
  self.stats = {
47
+ "audio_extractions": 0,
48
+ "audio_merges": 0,
49
+ "total_processing_time": 0.0,
50
+ "failed_operations": 0,
51
  }
52
+
53
  if not self.ffmpeg_available:
54
  logger.warning("FFmpeg not available - audio processing will be limited")
55
+ logger.info(
56
+ "AudioProcessor initialized (FFmpeg: %s, FFprobe: %s)",
57
+ self.ffmpeg_available,
58
+ self.ffprobe_available,
59
+ )
60
+
61
+ # -------------------------------
62
+ # Utilities
63
+ # -------------------------------
64
+
65
+ def _run(self, cmd: List[str], tag: str = "") -> subprocess.CompletedProcess:
66
+ logger.info("ffmpeg%s: %s", f"[{tag}]" if tag else "", " ".join(cmd))
67
+ return subprocess.run(cmd, text=True, capture_output=True)
68
+
69
+ def _has_audio(self, path: str) -> bool:
70
+ if not os.path.isfile(path):
 
 
 
 
 
 
 
 
 
 
 
71
  return False
72
+ if self.ffprobe_available:
73
+ try:
74
+ proc = subprocess.run(
75
+ [
76
+ self.ffprobe_path, "-v", "error",
77
+ "-select_streams", "a:0",
78
+ "-show_entries", "stream=index",
79
+ "-of", "csv=p=0",
80
+ path,
81
+ ],
82
+ text=True, capture_output=True, check=False,
83
+ )
84
+ return bool(proc.stdout.strip())
85
+ except Exception:
86
+ pass
87
+ # fallback heuristic via ffmpeg demuxer messages
88
+ if self.ffmpeg_available:
89
+ try:
90
+ proc = subprocess.run(
91
+ [self.ffmpeg_path, "-hide_banner", "-loglevel", "error", "-i", path, "-f", "null", "-"],
92
+ text=True, capture_output=True,
93
+ )
94
+ return "Audio:" in (proc.stderr or "")
95
+ except Exception:
96
+ return False
97
+ return False
98
+
99
+ # -------------------------------
100
+ # Metadata
101
+ # -------------------------------
102
+
103
  def get_audio_info(self, video_path: str) -> Dict[str, Any]:
104
  """
105
+ Get comprehensive audio information from a media file.
 
 
 
 
 
 
106
  """
107
  if not self.ffprobe_available:
108
+ return {"has_audio": False, "error": "FFprobe not available"}
109
+
110
  try:
111
+ proc = subprocess.run(
112
+ [
113
+ self.ffprobe_path, "-v", "error",
114
+ "-select_streams", "a:0",
115
+ "-show_entries", "stream=codec_name,sample_rate,channels,bit_rate,duration",
116
+ "-of", "json",
117
+ video_path,
118
+ ],
119
+ text=True, capture_output=True, check=False,
120
+ )
121
+ if proc.returncode != 0:
122
+ return {"has_audio": False, "error": proc.stderr.strip()}
123
+
124
+ data = json.loads(proc.stdout or "{}")
125
+ streams = data.get("streams", [])
126
+ if not streams:
127
+ return {"has_audio": False, "error": "No audio stream found"}
128
+
129
+ s = streams[0]
130
+ info = {
131
+ "has_audio": True,
132
+ "codec": s.get("codec_name", "unknown"),
133
+ "sample_rate": int(s["sample_rate"]) if s.get("sample_rate") else "unknown",
134
+ "channels": int(s["channels"]) if s.get("channels") else "unknown",
135
+ "duration": float(s["duration"]) if s.get("duration") else "unknown",
136
+ "bit_rate": int(s["bit_rate"]) if s.get("bit_rate") else "unknown",
137
+ }
138
+ return info
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  except Exception as e:
140
+ logger.error("Error getting audio info: %s", e)
141
+ return {"has_audio": False, "error": str(e)}
142
+
143
+ # -------------------------------
144
+ # Extraction
145
+ # -------------------------------
146
+
147
+ def extract_audio(
148
+ self,
149
+ video_path: str,
150
+ output_path: Optional[str] = None,
151
+ audio_format: str = "aac",
152
+ quality: str = "high",
153
+ ) -> Optional[str]:
154
  """
155
+ Extract audio from a media file to a separate file.
 
 
 
 
 
 
 
 
 
156
  """
157
  if not self.ffmpeg_available:
158
  raise AudioProcessingError("extract", "FFmpeg not available", video_path)
159
+
160
+ start = time.time()
161
+ info = self.get_audio_info(video_path)
162
+ if not info.get("has_audio", False):
163
+ logger.info("No audio found in %s", video_path)
164
+ return None
165
+
166
+ if output_path is None:
167
+ output_path = os.path.join(self.temp_dir, f"extracted_audio_{int(time.time())}.{audio_format}")
168
+
169
+ quality_map = {
170
+ "low": {"aac": ["-b:a", "96k"], "mp3": ["-b:a", "128k"], "wav": []},
171
+ "medium": {"aac": ["-b:a", "192k"], "mp3": ["-b:a", "192k"], "wav": []},
172
+ "high": {"aac": ["-b:a", "320k"], "mp3": ["-b:a", "320k"], "wav": []},
173
+ }
174
+ codec_map = {"aac": ["-c:a", "aac"], "mp3": ["-c:a", "libmp3lame"], "wav": ["-c:a", "pcm_s16le"]}
175
+
176
+ cmd = [self.ffmpeg_path, "-y", "-i", video_path]
177
+ cmd += codec_map.get(audio_format, ["-c:a", "aac"])
178
+ cmd += quality_map.get(quality, {}).get(audio_format, [])
179
+ cmd += ["-vn", output_path]
180
+
181
+ proc = self._run(cmd, "extract")
182
+ if proc.returncode != 0:
183
+ self.stats["failed_operations"] += 1
184
+ raise AudioProcessingError("extract", f"FFmpeg failed: {proc.stderr}", video_path, output_path)
185
+
186
+ if not os.path.exists(output_path):
187
+ self.stats["failed_operations"] += 1
188
+ raise AudioProcessingError("extract", "Output audio file was not created", video_path, output_path)
189
+
190
+ self.stats["audio_extractions"] += 1
191
+ self.stats["total_processing_time"] += (time.time() - start)
192
+ logger.info("Audio extracted: %s", output_path)
193
+ return output_path
194
+
195
+ # -------------------------------
196
+ # Loudness normalization (EBU R128, two-pass)
197
+ # -------------------------------
198
+
199
+ def _measure_loudness(self, src_with_audio: str, stream_selector: str = "1:a:0") -> Optional[Dict[str, float]]:
200
+ """
201
+ First pass loudnorm to measure levels. Returns dict with input_i, input_tp, input_lra, input_thresh, target_offset.
202
+ We run ffmpeg with -filter_complex on the selected audio input and parse the printed JSON (stderr).
203
+ """
204
+ # Build a dummy graph that takes the audio stream and measures it
205
+ # We’ll map it but discard the output (null muxer)
206
+ cmd = [
207
+ self.ffmpeg_path, "-hide_banner", "-nostats", "-loglevel", "warning",
208
+ "-i", src_with_audio,
209
+ "-vn",
210
+ "-af", "loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json",
211
+ "-f", "null", "-"
212
+ ]
213
+ proc = self._run(cmd, "loudnorm-pass1")
214
+ txt = (proc.stderr or "") + (proc.stdout or "")
215
+ # Extract JSON block
216
+ m = re.search(r"\{\s*\"input_i\"[^\}]+\}", txt, re.MULTILINE | re.DOTALL)
217
+ if not m:
218
+ logger.warning("Could not parse loudnorm analysis output.")
219
+ return None
220
  try:
221
+ data = json.loads(m.group(0))
222
+ # Legacy ffmpeg uses keys like "input_i", "input_tp", "input_lra", "input_thresh", "target_offset"
223
+ return {
224
+ "input_i": float(data.get("input_i")),
225
+ "input_tp": float(data.get("input_tp")),
226
+ "input_lra": float(data.get("input_lra")),
227
+ "input_thresh": float(data.get("input_thresh")),
228
+ "target_offset": float(data.get("target_offset")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  except Exception as e:
231
+ logger.warning("Loudnorm analysis JSON parse error: %s", e)
232
+ return None
233
+
234
+ def _build_loudnorm_filter(self, measured: Dict[str, float], target_I=-16.0, target_TP=-1.5, target_LRA=11.0) -> str:
 
 
 
 
 
235
  """
236
+ Build the second-pass loudnorm filter string using measured values.
 
 
 
 
 
 
 
 
 
237
  """
238
+ # Some ffmpeg builds call these "measured_*" or "input_*"; we used "input_*" names above.
239
+ return (
240
+ "loudnorm="
241
+ f"I={target_I}:TP={target_TP}:LRA={target_LRA}:"
242
+ f"measured_I={measured['input_i']}:"
243
+ f"measured_TP={measured['input_tp']}:"
244
+ f"measured_LRA={measured['input_lra']}:"
245
+ f"measured_thresh={measured['input_thresh']}:"
246
+ f"offset={measured['target_offset']}:"
247
+ "linear=true:print_format=summary"
248
+ )
249
+
250
+ # -------------------------------
251
+ # Muxing (video + audio)
252
+ # -------------------------------
253
+
254
+ def add_audio_to_video(
255
+ self,
256
+ original_video: str,
257
+ processed_video: str,
258
+ output_path: Optional[str] = None,
259
+ audio_quality: str = "high",
260
+ normalize: bool = False,
261
+ normalize_I: float = -16.0,
262
+ normalize_TP: float = -1.5,
263
+ normalize_LRA: float = 11.0,
264
+ offset_ms: float = 0.0,
265
+ ) -> str:
266
+ """
267
+ Add/mux the audio from original_video into processed_video.
268
+
269
+ Strategy:
270
+ 1) If no audio in original → return processed (or copy to desired name).
271
+ 2) If ffmpeg present:
272
+ a) If normalize/offset requested → re-encode AAC with filters (two-pass loudnorm).
273
+ b) Else try stream-copy (lossless): -c:a copy. If that fails, AAC re-encode.
274
+ 3) If ffmpeg missing → fallback to MoviePy (re-encode).
275
+
276
+ Returns path to the muxed video (MP4).
277
+ """
278
+ if not os.path.isfile(processed_video):
279
+ raise FileNotFoundError(f"Processed video not found: {processed_video}")
280
+
281
+ if output_path is None:
282
+ base = os.path.splitext(os.path.basename(processed_video))[0]
283
+ output_path = os.path.join(os.path.dirname(processed_video), f"{base}_with_audio.mp4")
284
+
285
+ # If no audio available, just return the processed video (copied to expected name)
286
+ if not self._has_audio(original_video):
287
+ logger.info("Original has no audio; returning processed video unchanged.")
288
+ if processed_video != output_path:
289
+ shutil.copy2(processed_video, output_path)
290
+ return output_path
291
+
292
  if not self.ffmpeg_available:
293
+ logger.warning("FFmpeg not available using MoviePy fallback.")
294
+ return self._moviepy_mux(original_video, processed_video, output_path)
295
+
296
+ start = time.time()
297
+
298
+ # If normalization or offset requested → we must re-encode audio with filters.
299
+ if normalize or abs(offset_ms) > 1e-3:
300
+ # Two-pass loudnorm if normalize=True
301
+ filter_chain = []
302
+ if abs(offset_ms) > 1e-3:
303
+ if offset_ms > 0:
304
+ # Positive delay: adelay per channel. Use stereo-safe form.
305
+ ms = int(round(offset_ms))
306
+ filter_chain.append(f"adelay={ms}|{ms}")
307
+ else:
308
+ # Negative offset: trim audio start and reset PTS
309
+ secs = abs(offset_ms) / 1000.0
310
+ filter_chain.append(f"atrim=start={secs},asetpts=PTS-STARTPTS")
311
+
312
+ if normalize:
313
+ measured = self._measure_loudness(original_video)
314
+ if measured:
315
+ filter_chain.append(self._build_loudnorm_filter(measured, normalize_I, normalize_TP, normalize_LRA))
316
+ else:
317
+ # Fallback to single-pass loudnorm
318
+ filter_chain.append(f"loudnorm=I={normalize_I}:TP={normalize_TP}:LRA={normalize_LRA}")
319
+
320
+ afilter = ",".join(filter_chain) if filter_chain else None
321
+
322
+ # Build re-encode command: copy video, re-encode audio AAC (web-safe), filters applied
323
  cmd = [
324
+ self.ffmpeg_path, "-hide_banner", "-loglevel", "error",
325
+ "-i", processed_video, # 0 = video
326
+ "-i", original_video, # 1 = audio
327
+ "-map", "0:v:0", "-map", "1:a:0",
328
+ "-c:v", "copy",
329
+ "-c:a", "aac", "-b:a", "192k", "-ac", "2", "-ar", "48000",
330
+ "-shortest",
331
+ "-movflags", "+faststart",
332
+ "-y", output_path,
333
  ]
334
+ if afilter:
335
+ # Apply audio filter chain to the mapped audio input
336
+ cmd = [
337
+ self.ffmpeg_path, "-hide_banner", "-loglevel", "error",
338
+ "-i", processed_video,
339
+ "-i", original_video,
340
+ "-map", "0:v:0",
341
+ "-filter_complex", f"[1:a]{afilter}[aout]",
342
+ "-map", "[aout]",
343
+ "-c:v", "copy",
344
+ "-c:a", "aac", "-b:a", "192k", "-ac", "2", "-ar", "48000",
345
+ "-shortest",
346
+ "-movflags", "+faststart",
347
+ "-y", output_path,
348
+ ]
349
+
350
+ proc = self._run(cmd, "mux-reencode-filters")
351
+ if proc.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
352
+ self.stats["audio_merges"] += 1
353
+ self.stats["total_processing_time"] += (time.time() - start)
354
+ logger.info("Audio merged with filters (normalize=%s, offset_ms=%.2f): %s", normalize, offset_ms, output_path)
355
+ return output_path
356
+
357
+ logger.warning("Filtered mux failed; stderr: %s", proc.stderr)
358
+
359
+ # Else: try pure stream-copy (lossless)
360
+ cmd_copy = [
361
+ self.ffmpeg_path, "-hide_banner", "-loglevel", "error",
362
+ "-i", processed_video, # 0 = video
363
+ "-i", original_video, # 1 = audio
364
+ "-map", "0:v:0", "-map", "1:a:0",
365
+ "-c:v", "copy",
366
+ "-c:a", "copy",
367
+ "-shortest",
368
+ "-movflags", "+faststart",
369
+ "-y", output_path,
370
+ ]
371
+ proc = self._run(cmd_copy, "mux-copy")
372
+ if proc.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
373
+ self.stats["audio_merges"] += 1
374
+ self.stats["total_processing_time"] += (time.time() - start)
375
+ logger.info("Audio merged (stream-copy): %s", output_path)
376
+ return output_path
377
+
378
+ # Last resort: AAC re-encode without filters
379
+ quality_map = {"low": ["-b:a", "96k"], "medium": ["-b:a", "192k"], "high": ["-b:a", "320k"]}
380
+ cmd_aac = [
381
+ self.ffmpeg_path, "-hide_banner", "-loglevel", "error",
382
+ "-i", processed_video,
383
+ "-i", original_video,
384
+ "-map", "0:v:0", "-map", "1:a:0",
385
+ "-c:v", "copy",
386
+ "-c:a", "aac",
387
+ *quality_map.get(audio_quality, quality_map["high"]),
388
+ "-ac", "2", "-ar", "48000",
389
+ "-shortest",
390
+ "-movflags", "+faststart",
391
+ "-y", output_path,
392
+ ]
393
+ proc = self._run(cmd_aac, "mux-aac")
394
+ if proc.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
395
+ self.stats["audio_merges"] += 1
396
+ self.stats["total_processing_time"] += (time.time() - start)
397
+ logger.info("Audio merged (AAC re-encode): %s", output_path)
398
  return output_path
399
+
400
+ # Fallback: MoviePy (re-encodes)
401
+ logger.warning("FFmpeg mux failed; using MoviePy fallback.")
402
+ return self._moviepy_mux(original_video, processed_video, output_path)
403
+
404
+ # -------------------------------
405
+ # Fallback: MoviePy
406
+ # -------------------------------
407
+
408
+ def _moviepy_mux(self, original_video: str, processed_video: str, output_path: str) -> str:
409
+ try:
410
+ from moviepy.editor import VideoFileClip, AudioFileClip
411
  except Exception as e:
412
+ self.stats["failed_operations"] += 1
413
+ raise AudioProcessingError("mux", f"MoviePy unavailable and ffmpeg failed: {e}", processed_video)
414
+
415
+ with VideoFileClip(processed_video) as v_clip:
416
+ try:
417
+ a_clip = AudioFileClip(original_video)
418
+ except Exception as e:
419
+ logger.warning("MoviePy could not load audio from %s (%s). Returning processed video.", original_video, e)
420
+ if processed_video != output_path:
421
+ shutil.copy2(processed_video, output_path)
422
+ return output_path
423
+
424
+ v_clip = v_clip.set_audio(a_clip)
425
+ v_clip.write_videofile(
426
+ output_path,
427
+ codec="libx264",
428
+ audio_codec="aac",
429
+ audio_bitrate="192k",
430
+ temp_audiofile=os.path.join(self.temp_dir, "temp-audio.m4a"),
431
+ remove_temp=True,
432
+ threads=2,
433
+ preset="medium",
434
+ )
435
+ return output_path
436
+
437
+ # -------------------------------
438
+ # Sync helper (explicit)
439
+ # -------------------------------
440
+
441
+ def sync_audio_video(
442
+ self,
443
+ video_path: str,
444
+ audio_path: str,
445
+ output_path: str,
446
+ offset_ms: float = 0.0,
447
+ normalize: bool = False,
448
+ normalize_I: float = -16.0,
449
+ normalize_TP: float = -1.5,
450
+ normalize_LRA: float = 11.0,
451
+ ) -> bool:
452
  """
453
+ Synchronize a separate audio file with a video (copy video, re-encode audio AAC).
454
+ Positive offset_ms delays audio; negative trims audio start.
 
 
 
 
 
 
 
 
455
  """
456
  if not self.ffmpeg_available:
457
  raise AudioProcessingError("sync", "FFmpeg not available")
458
+
459
+ filter_chain = []
460
+ if abs(offset_ms) > 1e-3:
461
+ if offset_ms > 0:
462
+ ms = int(round(offset_ms))
463
+ filter_chain.append(f"adelay={ms}|{ms}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  else:
465
+ secs = abs(offset_ms) / 1000.0
466
+ filter_chain.append(f"atrim=start={secs},asetpts=PTS-STARTPTS")
467
+
468
+ if normalize:
469
+ measured = self._measure_loudness(audio_path)
470
+ if measured:
471
+ filter_chain.append(self._build_loudnorm_filter(measured, normalize_I, normalize_TP, normalize_LRA))
472
+ else:
473
+ filter_chain.append(f"loudnorm=I={normalize_I}:TP={normalize_TP}:LRA={normalize_LRA}")
474
+
475
+ afilter = ",".join(filter_chain) if filter_chain else None
476
+
477
+ if afilter:
478
+ cmd = [
479
+ self.ffmpeg_path, "-hide_banner", "-loglevel", "error",
480
+ "-i", video_path,
481
+ "-i", audio_path,
482
+ "-map", "0:v:0",
483
+ "-filter_complex", f"[1:a]{afilter}[aout]",
484
+ "-map", "[aout]",
485
+ "-c:v", "copy",
486
+ "-c:a", "aac", "-b:a", "192k", "-ac", "2", "-ar", "48000",
487
+ "-shortest",
488
+ "-movflags", "+faststart",
489
+ "-y", output_path,
490
+ ]
491
+ else:
492
+ cmd = [
493
+ self.ffmpeg_path, "-hide_banner", "-loglevel", "error",
494
+ "-i", video_path,
495
+ "-i", audio_path,
496
+ "-map", "0:v:0", "-map", "1:a:0",
497
+ "-c:v", "copy",
498
+ "-c:a", "aac", "-b:a", "192k", "-ac", "2", "-ar", "48000",
499
+ "-shortest",
500
+ "-movflags", "+faststart",
501
+ "-y", output_path,
502
+ ]
503
+
504
+ proc = self._run(cmd, "sync")
505
+ return proc.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0
506
+
507
+ # -------------------------------
508
+ # Levels (simple convenience)
509
+ # -------------------------------
510
+
511
+ def adjust_audio_levels(
512
+ self,
513
+ input_path: str,
514
+ output_path: str,
515
+ volume_factor: float = 1.0,
516
+ normalize: bool = False,
517
+ normalize_I: float = -16.0,
518
+ normalize_TP: float = -1.5,
519
+ normalize_LRA: float = 11.0,
520
+ ) -> bool:
521
  """
522
+ Adjust levels on a single-file video (copy video, re-encode audio AAC).
 
 
 
 
 
 
 
 
 
523
  """
524
  if not self.ffmpeg_available:
525
  raise AudioProcessingError("adjust_levels", "FFmpeg not available")
526
+
527
+ filters = []
528
+ if volume_factor != 1.0:
529
+ filters.append(f"volume={volume_factor}")
530
+ if normalize:
531
+ measured = self._measure_loudness(input_path)
532
+ if measured:
533
+ filters.append(self._build_loudnorm_filter(measured, normalize_I, normalize_TP, normalize_LRA))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  else:
535
+ filters.append(f"loudnorm=I={normalize_I}:TP={normalize_TP}:LRA={normalize_LRA}")
536
+
537
+ if filters:
538
+ cmd = [
539
+ self.ffmpeg_path, "-hide_banner", "-loglevel", "error",
540
+ "-i", input_path,
541
+ "-c:v", "copy",
542
+ "-af", ",".join(filters),
543
+ "-c:a", "aac", "-b:a", "192k", "-ac", "2", "-ar", "48000",
544
+ "-movflags", "+faststart",
545
+ "-y", output_path,
546
+ ]
547
+ else:
548
+ # nothing to do; copy
549
+ shutil.copy2(input_path, output_path)
550
+ return True
551
+
552
+ proc = self._run(cmd, "adjust-levels")
553
+ if proc.returncode != 0:
554
+ raise AudioProcessingError("adjust_levels", proc.stderr, input_path)
555
+ return os.path.exists(output_path) and os.path.getsize(output_path) > 0
556
+
557
+ # -------------------------------
558
+ # Housekeeping / stats
559
+ # -------------------------------
560
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  def get_stats(self) -> Dict[str, Any]:
562
+ tot_ops = self.stats["audio_extractions"] + self.stats["audio_merges"] + self.stats["failed_operations"]
563
+ successes = self.stats["audio_extractions"] + self.stats["audio_merges"]
564
+ success_rate = (successes / max(1, tot_ops)) * 100.0
565
  return {
566
+ "ffmpeg_available": self.ffmpeg_available,
567
+ "ffprobe_available": self.ffprobe_available,
568
+ "audio_extractions": self.stats["audio_extractions"],
569
+ "audio_merges": self.stats["audio_merges"],
570
+ "total_processing_time": self.stats["total_processing_time"],
571
+ "failed_operations": self.stats["failed_operations"],
572
+ "success_rate": success_rate,
 
 
 
573
  }
574
+
575
  def cleanup_temp_files(self, max_age_hours: int = 24):
576
+ """
577
+ Clean up temporary audio/video files older than specified age in temp_dir.
578
+ """
579
  try:
580
  temp_path = Path(self.temp_dir)
581
+ cutoff = time.time() - (max_age_hours * 3600)
582
+ cleaned = 0
583
+ # Pathlib doesn't support brace expansion; iterate explicitly
584
+ for ext in (".aac", ".mp3", ".wav", ".mp4", ".m4a"):
585
+ for p in temp_path.glob(f"*audio*{ext}"):
 
586
  try:
587
+ if p.stat().st_mtime < cutoff:
588
+ p.unlink()
589
+ cleaned += 1
590
  except Exception as e:
591
+ logger.warning("Could not delete temp file %s: %s", p, e)
592
+ if cleaned:
593
+ logger.info("Cleaned up %d temporary audio files", cleaned)
 
 
594
  except Exception as e:
595
+ logger.warning("Temp file cleanup error: %s", e)