DocDoeAI / app /services /audio_utils.py
asnannp's picture
Deploy backend cd4237ff: support routes + rate limit + exam_date nullable + upload 413 fix
7c6ffa6
Raw
History Blame Contribute Delete
7.59 kB
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