| from __future__ import annotations |
|
|
| import contextlib |
| import math |
| import shutil |
| import struct |
| import subprocess |
| import tempfile |
| import wave |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| DEFAULT_SAMPLE_RATE = 24_000 |
| DEFAULT_SAMPLE_WIDTH = 2 |
| DEFAULT_CHANNELS = 1 |
|
|
|
|
| class AudioUtilityError(RuntimeError): |
| """Raised when a local audio utility cannot complete safely.""" |
|
|
|
|
| def get_audio_duration_seconds(file_path: str | Path) -> float: |
| path = Path(file_path) |
| if not path.exists(): |
| raise AudioUtilityError(f"Audio file does not exist: {path}") |
|
|
| ffprobe_duration = _ffprobe_duration_seconds(path) |
| if ffprobe_duration is not None: |
| return ffprobe_duration |
|
|
| if path.suffix.lower() == ".wav": |
| with contextlib.closing(wave.open(str(path), "rb")) as wav_file: |
| frame_count = wav_file.getnframes() |
| frame_rate = wav_file.getframerate() |
| if frame_rate <= 0: |
| raise AudioUtilityError(f"Invalid WAV frame rate for {path}") |
| return round(frame_count / float(frame_rate), 3) |
|
|
| try: |
| from mutagen import File as MutagenFile |
| except ImportError as exc: |
| raise AudioUtilityError("Install mutagen to detect non-WAV audio durations.") from exc |
|
|
| audio = MutagenFile(path) |
| if audio is None or audio.info is None: |
| raise AudioUtilityError(f"Could not read audio metadata for {path}") |
| length = getattr(audio.info, "length", None) |
| if length is None: |
| raise AudioUtilityError(f"Audio duration is missing for {path}") |
| return round(float(length), 3) |
|
|
|
|
| def media_has_audio_stream(file_path: str | Path) -> bool: |
| return _media_has_stream(file_path, "a:0", "audio") |
|
|
|
|
| def media_has_video_stream(file_path: str | Path) -> bool: |
| return _media_has_stream(file_path, "v:0", "video") |
|
|
|
|
| def _media_has_stream(file_path: str | Path, stream_selector: str, expected: str) -> bool: |
| path = Path(file_path) |
| if not path.exists() or not _ffprobe_available(): |
| return False |
| command = [ |
| "ffprobe", |
| "-v", |
| "error", |
| "-select_streams", |
| stream_selector, |
| "-show_entries", |
| "stream=codec_type", |
| "-of", |
| "csv=p=0", |
| str(path), |
| ] |
| result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False) |
| return result.returncode == 0 and expected in result.stdout.lower() |
|
|
|
|
| def normalize_audio_volume(file_path: str | Path, *, enabled: bool = True) -> Path: |
| path = Path(file_path) |
| if not enabled or not _ffmpeg_available() or not path.exists(): |
| return path |
|
|
| with tempfile.NamedTemporaryFile( |
| suffix=path.suffix, |
| delete=False, |
| dir=str(path.parent), |
| ) as temp_file: |
| temp_path = Path(temp_file.name) |
|
|
| command = [ |
| "ffmpeg", |
| "-y", |
| "-hide_banner", |
| "-loglevel", |
| "error", |
| "-i", |
| str(path), |
| "-af", |
| "loudnorm=I=-18:TP=-2:LRA=11", |
| str(temp_path), |
| ] |
| result = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False) |
| if result.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0: |
| temp_path.replace(path) |
| else: |
| temp_path.unlink(missing_ok=True) |
|
|
| return path |
|
|
|
|
| def trim_silence_and_pad_audio( |
| file_path: str | Path, |
| *, |
| padding_seconds: float = 0.4, |
| enabled: bool = True, |
| ) -> Path: |
| path = Path(file_path) |
| if not enabled or not _ffmpeg_available() or not path.exists(): |
| return path |
|
|
| with tempfile.NamedTemporaryFile( |
| suffix=path.suffix, |
| delete=False, |
| dir=str(path.parent), |
| ) as temp_file: |
| temp_path = Path(temp_file.name) |
|
|
| command = [ |
| "ffmpeg", |
| "-y", |
| "-hide_banner", |
| "-loglevel", |
| "error", |
| "-i", |
| str(path), |
| "-af", |
| ( |
| "silenceremove=start_periods=1:start_duration=0.15:start_threshold=-45dB:" |
| "stop_periods=1:stop_duration=0.3:stop_threshold=-45dB," |
| f"apad=pad_dur={max(padding_seconds, 0):.2f}" |
| ), |
| str(temp_path), |
| ] |
| result = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False) |
| if result.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0: |
| temp_path.replace(path) |
| else: |
| temp_path.unlink(missing_ok=True) |
|
|
| return path |
|
|
|
|
| def merge_wav_files(input_files: list[str | Path], output_file: str | Path) -> Path: |
| paths = [Path(file) for file in input_files] |
| if not paths: |
| raise AudioUtilityError("No WAV files were provided for merging.") |
|
|
| output_path = Path(output_file) |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| params: Any | None = None |
| expected_format: tuple[int, int, int, str, str] | None = None |
| frames: list[bytes] = [] |
| for path in paths: |
| with contextlib.closing(wave.open(str(path), "rb")) as wav_file: |
| current_params = wav_file.getparams() |
| comparable = ( |
| current_params.nchannels, |
| current_params.sampwidth, |
| current_params.framerate, |
| current_params.comptype, |
| current_params.compname, |
| ) |
| if params is None: |
| params = current_params |
| expected_format = comparable |
| elif comparable != expected_format: |
| raise AudioUtilityError("WAV files use different audio formats.") |
| frames.append(wav_file.readframes(current_params.nframes)) |
|
|
| if params is None: |
| raise AudioUtilityError("Could not read WAV parameters.") |
|
|
| with contextlib.closing(wave.open(str(output_path), "wb")) as output_wav: |
| output_wav.setparams(params) |
| for frame_bytes in frames: |
| output_wav.writeframes(frame_bytes) |
|
|
| return output_path |
|
|
|
|
| def _ffmpeg_available() -> bool: |
| return shutil.which("ffmpeg") is not None |
|
|
|
|
| def _ffprobe_available() -> bool: |
| return shutil.which("ffprobe") is not None |
|
|
|
|
| def _ffprobe_duration_seconds(path: Path) -> float | None: |
| if not _ffprobe_available(): |
| return None |
| command = [ |
| "ffprobe", |
| "-v", |
| "error", |
| "-show_entries", |
| "format=duration", |
| "-of", |
| "default=noprint_wrappers=1:nokey=1", |
| str(path), |
| ] |
| result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False) |
| if result.returncode != 0: |
| return None |
| try: |
| duration = float(result.stdout.strip()) |
| except ValueError: |
| return None |
| if duration <= 0: |
| return None |
| return round(duration, 3) |
|
|
|
|
| def create_mock_audio( |
| output_file: str | Path, |
| duration_seconds: float, |
| *, |
| tone: bool = False, |
| ) -> Path: |
| output_path = Path(output_file) |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| safe_duration = max(float(duration_seconds), 0.5) |
| frame_count = int(DEFAULT_SAMPLE_RATE * safe_duration) |
| amplitude = 420 if tone else 0 |
|
|
| with contextlib.closing(wave.open(str(output_path), "wb")) as wav_file: |
| wav_file.setnchannels(DEFAULT_CHANNELS) |
| wav_file.setsampwidth(DEFAULT_SAMPLE_WIDTH) |
| wav_file.setframerate(DEFAULT_SAMPLE_RATE) |
| for index in range(frame_count): |
| sample = 0 |
| if amplitude: |
| sample = int(amplitude * math.sin(2 * math.pi * 440 * index / DEFAULT_SAMPLE_RATE)) |
| wav_file.writeframes(struct.pack("<h", sample)) |
|
|
| return output_path |
|
|