import subprocess from typing import Optional import os import boto3 import re def parse_ffmpeg_duration(stderr_text): """ Parse duration from ffmpeg stderr output. Args: stderr_text (str): The stderr output from ffmpeg containing duration information. Returns: float: Duration in seconds. Raises: ValueError: If the duration cannot be parsed from the ffmpeg output. Notes: - Expects duration in format "HH:MM:SS.mm" where mm is centiseconds - Converts to total seconds as a float - Used for audio/video duration extraction from ffmpeg output """ duration_match = re.search(r"Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})", stderr_text) if duration_match: hours = int(duration_match.group(1)) minutes = int(duration_match.group(2)) seconds = int(duration_match.group(3)) milliseconds = int(duration_match.group(4)) * 10 # .xx format is centiseconds return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000.0 else: raise ValueError("Could not parse duration from ffmpeg output.") def download_and_trim_audio( s3_bucket_name: str, s3_key: str, start_time: Optional[float], end_time: Optional[float], output_dir: str, ) -> dict: """Download and trim audio, getting duration from ffmpeg stderr.""" # Create necessary directories and define output paths os.makedirs(output_dir, exist_ok=True) original_audio_path = os.path.join(output_dir, "original.mp3") trimmed_audio_path = os.path.join(output_dir, "trimmed.mp3") # Download the MP3 file directly from S3 s3_client = boto3.client("s3") if not os.path.exists(original_audio_path): s3_client.download_file(s3_bucket_name, s3_key, original_audio_path) original_duration = None command = ["ffmpeg", "-y", "-i", original_audio_path] duration_str = None if start_time is not None and end_time is not None: duration_str = str(end_time - start_time) # First output: PCM data for spectrogram processing command.extend(["-map", "0:a"]) if start_time is not None: command.extend(["-ss", str(start_time)]) if duration_str is not None: command.extend(["-t", duration_str]) elif end_time is not None and start_time is None: command.extend(["-to", str(end_time)]) command.extend( [ "-vn", # Disable video stream processing "-sn", # Disable subtitle stream processing "-dn", # Disable data stream processing "-nostdin", # Disable reading from stdin "-ac", # Set number of audio channels "1", # Mono audio "-ar", # Set audio sample rate "48000", # 48kHz sample rate "-acodec", # Set audio codec "pcm_f32le", # 32-bit float PCM "-f", # Force output format "f32le", # Raw 32-bit float little-endian "pipe:1", # Output PCM to stdout ] ) # Second output: Trimmed audio file with original codecs command.extend(["-map", "0:a"]) if start_time is not None: # IMPORTANT: Apply -ss *again* for the second output if you want accurate trimming # without full decode. Alternatively, place -ss *before* -i for faster but less # precise seeking (might be acceptable depending on needs). command.extend(["-ss", str(start_time)]) if duration_str is not None: command.extend(["-t", duration_str]) elif end_time is not None and start_time is None: command.extend(["-to", str(end_time)]) command.extend(["-vn", "-sn", "-dn", "-acodec", "copy", trimmed_audio_path]) # Execute FFmpeg command process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout_data, stderr_data = process.communicate() if process.returncode != 0: raise subprocess.CalledProcessError( process.returncode, command, output=stdout_data, stderr=stderr_data ) original_duration = parse_ffmpeg_duration(stderr_data.decode("utf-8", errors="ignore")) if os.path.exists(original_audio_path): os.remove(original_audio_path) return { "trimmed_audio_path": trimmed_audio_path, "original_duration": original_duration, }