mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
220 lines
6.6 KiB
Python
220 lines
6.6 KiB
Python
"""Shared utilities for downloading, converting, and caching audio files.
|
|
|
|
Provides helpers used by both the recording audio cache and the ambient
|
|
noise cache to avoid duplicating download / ffmpeg / disk-cache logic.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from typing import Literal, Optional
|
|
|
|
from loguru import logger
|
|
|
|
from api.constants import APP_ROOT_DIR
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filesystem cache directory (shared by all audio caches)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CACHE_DIR = os.path.join(os.path.dirname(APP_ROOT_DIR), "dograh_pcm_cache")
|
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Download helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def download_storage_file(
|
|
storage_key: str,
|
|
storage_backend: str,
|
|
get_storage_fn,
|
|
) -> Optional[str]:
|
|
"""Download a file from object storage to a local temp file.
|
|
|
|
Returns the temp file path on success, or None on failure.
|
|
The caller is responsible for cleaning up the temp file.
|
|
"""
|
|
ext = ext_from_key(storage_key)
|
|
fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="dograh_dl_")
|
|
os.close(fd)
|
|
|
|
try:
|
|
storage = get_storage_fn(storage_backend)
|
|
success = await storage.adownload_file(storage_key, tmp_path)
|
|
if not success:
|
|
logger.error(f"Failed to download {storage_key}")
|
|
_safe_unlink(tmp_path)
|
|
return None
|
|
return tmp_path
|
|
except Exception:
|
|
logger.exception(f"Error downloading {storage_key}")
|
|
_safe_unlink(tmp_path)
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Audio conversion via ffmpeg
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def convert_audio_file(
|
|
file_path: str,
|
|
target_sample_rate: int,
|
|
output_format: Literal["pcm", "wav"] = "pcm",
|
|
) -> Optional[bytes]:
|
|
"""Convert an audio file via ffmpeg.
|
|
|
|
Args:
|
|
file_path: Path to the source audio file.
|
|
target_sample_rate: Desired output sample rate.
|
|
output_format: ``"pcm"`` for raw s16le bytes, ``"wav"`` for a
|
|
complete WAV file (16-bit mono).
|
|
|
|
Returns:
|
|
Converted audio bytes, or None on failure.
|
|
"""
|
|
ffmpeg = shutil.which("ffmpeg")
|
|
if not ffmpeg:
|
|
logger.error("ffmpeg not found on PATH - cannot convert audio")
|
|
return None
|
|
|
|
if output_format == "pcm":
|
|
fmt_args = ["-f", "s16le", "-acodec", "pcm_s16le"]
|
|
else:
|
|
fmt_args = ["-f", "wav", "-acodec", "pcm_s16le"]
|
|
|
|
cmd = [
|
|
ffmpeg,
|
|
"-i",
|
|
file_path,
|
|
*fmt_args,
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
str(target_sample_rate),
|
|
"-loglevel",
|
|
"error",
|
|
"pipe:1",
|
|
]
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await proc.communicate()
|
|
|
|
if proc.returncode != 0:
|
|
logger.error(f"ffmpeg failed (rc={proc.returncode}): {stderr.decode()}")
|
|
return None
|
|
if not stdout:
|
|
logger.error("ffmpeg produced no output")
|
|
return None
|
|
|
|
return stdout
|
|
except Exception:
|
|
logger.exception("ffmpeg subprocess error")
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File I/O helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def read_cached_file(path: str) -> bytes:
|
|
with open(path, "rb") as f:
|
|
return f.read()
|
|
|
|
|
|
def write_cache_file(path: str, data: bytes) -> None:
|
|
"""Atomically write *data* to *path* (write-to-tmp then rename)."""
|
|
fd, tmp = tempfile.mkstemp(dir=CACHE_DIR, suffix=".tmp")
|
|
os.close(fd)
|
|
with open(tmp, "wb") as f:
|
|
f.write(data)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def ext_from_key(storage_key: str) -> str:
|
|
"""Extract file extension from a storage key, defaulting to .wav."""
|
|
_, ext = os.path.splitext(storage_key)
|
|
return ext if ext else ".wav"
|
|
|
|
|
|
def _safe_unlink(path: str) -> None:
|
|
try:
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ambient noise file cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ambient_noise_cache_path(storage_key: str, sample_rate: int) -> str:
|
|
"""Return the on-disk path for a cached ambient noise WAV file."""
|
|
# Use a stable hash of the storage key so different uploads get different cache entries
|
|
import hashlib
|
|
|
|
key_hash = hashlib.sha256(storage_key.encode()).hexdigest()[:16]
|
|
return os.path.join(CACHE_DIR, f"ambient_{key_hash}_{sample_rate}.wav")
|
|
|
|
|
|
async def get_cached_ambient_noise_path(
|
|
storage_key: str,
|
|
storage_backend: str,
|
|
target_sample_rate: int,
|
|
) -> Optional[str]:
|
|
"""Return a local WAV file path for a custom ambient noise file.
|
|
|
|
Downloads from object storage and converts to mono WAV at
|
|
*target_sample_rate* on the first call; subsequent calls return the
|
|
cached path immediately.
|
|
|
|
Args:
|
|
storage_key: Object storage key for the uploaded audio file.
|
|
storage_backend: Storage backend identifier (e.g. ``"minio"``, ``"s3"``).
|
|
target_sample_rate: Target sample rate for the output WAV.
|
|
|
|
Returns:
|
|
Absolute path to the cached WAV file, or None on failure.
|
|
"""
|
|
from api.services.storage import get_storage_for_backend
|
|
|
|
cached = _ambient_noise_cache_path(storage_key, target_sample_rate)
|
|
if os.path.exists(cached):
|
|
logger.debug(f"Ambient noise served from cache: {cached}")
|
|
return cached
|
|
|
|
logger.info(f"Downloading custom ambient noise: {storage_key}")
|
|
|
|
def _get_storage(backend: str):
|
|
return get_storage_for_backend(backend)
|
|
|
|
tmp_path = await download_storage_file(storage_key, storage_backend, _get_storage)
|
|
if not tmp_path:
|
|
return None
|
|
|
|
try:
|
|
wav_data = await convert_audio_file(
|
|
tmp_path, target_sample_rate, output_format="wav"
|
|
)
|
|
if wav_data is None:
|
|
return None
|
|
|
|
write_cache_file(cached, wav_data)
|
|
logger.info(f"Cached custom ambient noise: {cached} ({len(wav_data)} bytes)")
|
|
return cached
|
|
except Exception:
|
|
logger.exception("Error caching ambient noise file")
|
|
return None
|
|
finally:
|
|
_safe_unlink(tmp_path)
|