184 lines
5.2 KiB
Python
184 lines
5.2 KiB
Python
import asyncio
|
|
import io
|
|
import math
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import wave
|
|
|
|
try:
|
|
import soundfile as sf
|
|
except Exception: # pragma: no cover - depends on runtime native libs
|
|
sf = None
|
|
|
|
from utils.audio_format import (
|
|
DEFAULT_CHANNELS,
|
|
DEFAULT_SAMPLE_RATE,
|
|
DEFAULT_SAMPLE_WIDTH,
|
|
detect_audio_format,
|
|
)
|
|
from utils.logger import session_logger
|
|
|
|
|
|
def _duration_from_wave(audio_data: bytes) -> int | None:
|
|
try:
|
|
with wave.open(io.BytesIO(audio_data), "rb") as wav_file:
|
|
frame_rate = wav_file.getframerate()
|
|
frame_count = wav_file.getnframes()
|
|
if frame_rate <= 0 or frame_count <= 0:
|
|
return None
|
|
return max(1, int(round(frame_count * 1000 / frame_rate)))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _duration_from_soundfile(audio_data: bytes) -> int | None:
|
|
if sf is None:
|
|
return None
|
|
try:
|
|
with sf.SoundFile(io.BytesIO(audio_data)) as audio_file:
|
|
sample_rate = int(audio_file.samplerate or 0)
|
|
frames = int(audio_file.frames or 0)
|
|
if sample_rate <= 0 or frames <= 0:
|
|
return None
|
|
return max(1, int(round(frames * 1000 / sample_rate)))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _duration_from_pcm_16k_mono(audio_data: bytes) -> int | None:
|
|
frame_size = DEFAULT_CHANNELS * DEFAULT_SAMPLE_WIDTH
|
|
if DEFAULT_SAMPLE_RATE <= 0 or frame_size <= 0 or not audio_data:
|
|
return None
|
|
frames = len(audio_data) / frame_size
|
|
if frames <= 0:
|
|
return None
|
|
return max(1, int(round(frames * 1000 / DEFAULT_SAMPLE_RATE)))
|
|
|
|
|
|
def _duration_from_ffprobe(audio_data: bytes, suffix: str) -> int | None:
|
|
ffprobe_path = shutil.which("ffprobe")
|
|
if not ffprobe_path:
|
|
return None
|
|
|
|
tmp_path = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
|
|
tmp_file.write(audio_data)
|
|
tmp_path = tmp_file.name
|
|
|
|
process = subprocess.run(
|
|
[
|
|
ffprobe_path,
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
"format=duration",
|
|
"-of",
|
|
"default=noprint_wrappers=1:nokey=1",
|
|
tmp_path,
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
if process.returncode != 0:
|
|
return None
|
|
duration_seconds = float((process.stdout or "").strip())
|
|
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
|
|
return None
|
|
return max(1, int(round(duration_seconds * 1000)))
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
if tmp_path and os.path.exists(tmp_path):
|
|
try:
|
|
os.remove(tmp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _suffix_for_audio(*, audio_format: str, mime_type: str | None) -> str:
|
|
normalized_mime = (mime_type or "").strip().lower()
|
|
if audio_format == "mp3" or normalized_mime in {"audio/mpeg", "audio/mp3"}:
|
|
return ".mp3"
|
|
if audio_format == "wav" or normalized_mime in {"audio/wav", "audio/x-wav"}:
|
|
return ".wav"
|
|
if normalized_mime in {"audio/aac", "audio/x-aac"}:
|
|
return ".aac"
|
|
if normalized_mime in {"audio/mp4", "audio/x-m4a", "audio/m4a"}:
|
|
return ".m4a"
|
|
if normalized_mime == "audio/webm":
|
|
return ".webm"
|
|
return ".audio"
|
|
|
|
|
|
def _can_treat_as_pcm(*, audio_format: str, mime_type: str | None) -> bool:
|
|
normalized_mime = (mime_type or "").strip().lower()
|
|
return audio_format == "pcm_s16le_16k_mono" and normalized_mime in {
|
|
"",
|
|
"application/octet-stream",
|
|
"audio/pcm",
|
|
"audio/raw",
|
|
"audio/s16le",
|
|
}
|
|
|
|
|
|
def parse_audio_duration_ms_sync(
|
|
audio_data: bytes,
|
|
*,
|
|
mime_type: str | None = None,
|
|
source: str = "audio",
|
|
) -> int | None:
|
|
if not audio_data:
|
|
return None
|
|
|
|
audio_format = detect_audio_format(audio_data)
|
|
|
|
duration_ms = _duration_from_wave(audio_data)
|
|
if duration_ms is not None:
|
|
return duration_ms
|
|
|
|
duration_ms = _duration_from_soundfile(audio_data)
|
|
if duration_ms is not None:
|
|
return duration_ms
|
|
|
|
duration_ms = _duration_from_ffprobe(
|
|
audio_data,
|
|
suffix=_suffix_for_audio(audio_format=audio_format, mime_type=mime_type),
|
|
)
|
|
if duration_ms is not None:
|
|
return duration_ms
|
|
|
|
if _can_treat_as_pcm(audio_format=audio_format, mime_type=mime_type):
|
|
duration_ms = _duration_from_pcm_16k_mono(audio_data)
|
|
if duration_ms is not None:
|
|
return duration_ms
|
|
|
|
session_logger.warning(
|
|
"system",
|
|
"audio_duration",
|
|
(
|
|
"failed to parse audio duration: "
|
|
f"source={source}, detected_format={audio_format}, "
|
|
f"mime_type={mime_type or 'unknown'}, size_bytes={len(audio_data)}"
|
|
),
|
|
)
|
|
return None
|
|
|
|
|
|
async def parse_audio_duration_ms(
|
|
audio_data: bytes,
|
|
*,
|
|
mime_type: str | None = None,
|
|
source: str = "audio",
|
|
) -> int | None:
|
|
return await asyncio.to_thread(
|
|
parse_audio_duration_ms_sync,
|
|
audio_data,
|
|
mime_type=mime_type,
|
|
source=source,
|
|
)
|