test/app/utils/audio.py

529 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -*- coding: utf-8 -*-
"""
统一音频处理工具
ASR音频处理功能
"""
import os
import tempfile
import requests
import librosa
import soundfile as sf
import numpy as np
import subprocess
import logging
from dataclasses import dataclass
from typing import Tuple, Optional
from io import BytesIO
from urllib.parse import unquote, urlparse
from ..core.config import settings
from ..core.exceptions import (
InvalidParameterException,
InvalidMessageException,
DefaultServerErrorException,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class NormalizedAudio:
path: str
timestamp_scale: float = 1.0
def download_audio_from_url(url: str, max_size: Optional[int] = None) -> bytes:
"""从 URL 或服务端本地路径读取音频文件
Args:
url: 音频文件 URL、本地路径或 file:// 路径
max_size: 最大文件大小限制
Returns:
音频文件的二进制数据
Raises:
InvalidParameterException: URL无效或下载失败
InvalidMessageException: 文件太大
"""
if not url:
raise InvalidParameterException("URL不能为空")
max_file_size = max_size or settings.MAX_AUDIO_SIZE
parsed = urlparse(url)
if parsed.scheme in {"", "file"}:
local_path = os.path.expanduser(unquote(parsed.path) if parsed.scheme == "file" else url)
if not os.path.isfile(local_path):
raise InvalidParameterException(f"本地音频文件不存在: {local_path}")
file_size = os.path.getsize(local_path)
if file_size > max_file_size:
max_size_mb = max_file_size // 1024 // 1024
raise InvalidMessageException(f"音频文件太大,最大支持{max_size_mb}MB")
with open(local_path, "rb") as file_obj:
return file_obj.read()
try:
response = requests.get(url, timeout=30, stream=True)
response.raise_for_status()
# 检查Content-Length头
content_length = response.headers.get("content-length")
if content_length and int(content_length) > max_file_size:
max_size_mb = max_file_size // 1024 // 1024
raise InvalidMessageException(f"音频文件太大,最大支持{max_size_mb}MB")
# 分块下载并检查大小
audio_data = BytesIO()
downloaded_size = 0
for chunk in response.iter_content(chunk_size=8192):
downloaded_size += len(chunk)
if downloaded_size > max_file_size:
max_size_mb = max_file_size // 1024 // 1024
raise InvalidMessageException(f"音频文件太大,最大支持{max_size_mb}MB")
audio_data.write(chunk)
return audio_data.getvalue()
except requests.RequestException as e:
raise InvalidParameterException(f"下载音频文件失败: {str(e)}")
def save_audio_to_temp_file(audio_data: bytes, suffix: str = ".wav") -> str:
"""保存音频数据到临时文件
Args:
audio_data: 音频二进制数据
suffix: 文件后缀
Returns:
临时文件路径
Raises:
AudioProcessingException: 保存失败
"""
try:
with tempfile.NamedTemporaryFile(
delete=False, suffix=suffix, dir=settings.TEMP_DIR
) as temp_file:
temp_file.write(audio_data)
return temp_file.name
except Exception as e:
raise DefaultServerErrorException(f"保存音频文件失败: {str(e)}")
def cleanup_temp_file(file_path: str) -> None:
"""清理临时文件
Args:
file_path: 文件路径
"""
try:
if file_path and os.path.exists(file_path):
os.remove(file_path)
except Exception:
# 静默忽略清理错误
pass
def load_audio_file(audio_path: str, target_sr: int = 16000) -> Tuple[np.ndarray, int]:
"""加载音频文件并转换为指定采样率
Args:
audio_path: 音频文件路径
target_sr: 目标采样率
Returns:
(audio_data, sample_rate): 音频数据和采样率
Raises:
AudioProcessingException: 加载失败
"""
try:
# 使用librosa加载音频
audio_data, sr = librosa.load(audio_path, sr=target_sr)
return audio_data, int(sr)
except Exception as e:
raise DefaultServerErrorException(f"加载音频文件失败: {str(e)}")
def get_audio_duration(audio_path: str) -> float:
"""获取音频文件时长
Args:
audio_path: 音频文件路径
Returns:
音频时长(秒)
Raises:
AudioProcessingException: 获取时长失败
"""
try:
# Load audio and get duration
y, sr = librosa.load(audio_path, sr=None)
duration = librosa.get_duration(y=y, sr=sr)
return duration
except Exception as e:
raise DefaultServerErrorException(f"获取音频时长失败: {str(e)}")
def get_container_duration(audio_path: str) -> Optional[float]:
"""通过 ffprobe 获取音频容器的 metadata 时长
对于 m4a/AAC 等压缩格式,容器记录的时长可能与实际解码样本数不一致
(常见于 m3u8/ts 分片合并的音频)。返回 None 表示获取失败。
"""
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", audio_path],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
return float(result.stdout.strip())
except Exception as e:
logger.debug(f"ffprobe 获取容器时长失败: {e}")
return None
def get_timestamp_scale(original_audio_path: str, decoded_duration: float) -> float:
"""计算时间戳缩放系数
对比容器 metadata 时长与解码后实际时长,返回缩放系数。
用于修正 m4a/AAC 等格式中容器时长与解码时长不一致的问题。
Args:
original_audio_path: 原始音频文件路径(转换前)
decoded_duration: 解码后的实际音频时长(秒)
Returns:
缩放系数(容器时长 / 解码时长),无差异时返回 1.0
"""
container_duration = get_container_duration(original_audio_path)
if container_duration is None or decoded_duration <= 0:
return 1.0
scale = container_duration / decoded_duration
if abs(scale - 1.0) < 0.001:
# 差异 < 0.1%,忽略
return 1.0
logger.info(
f"检测到容器/解码时长不一致: container={container_duration:.3f}s, "
f"decoded={decoded_duration:.3f}s, scale={scale:.6f}"
)
return scale
def resample_audio_array(
audio_array: np.ndarray,
original_sr: int,
target_sr: int,
) -> np.ndarray:
"""重采样音频数组
Args:
audio_array: 原始音频数据
original_sr: 原始采样率
target_sr: 目标采样率
Returns:
重采样后的音频数据
"""
if original_sr == target_sr:
return audio_array
try:
# 确保是1D数组用于librosa重采样
if audio_array.ndim > 1:
# 如果是多声道,取第一个声道
if audio_array.shape[0] > audio_array.shape[1]:
audio_1d = audio_array[0, :]
else:
audio_1d = (
audio_array[:, 0]
if audio_array.shape[1] > 1
else audio_array.flatten()
)
else:
audio_1d = audio_array
# 使用librosa进行重采样
resampled = librosa.resample(audio_1d, orig_sr=original_sr, target_sr=target_sr)
logger.info(f"音频重采样: {original_sr}Hz -> {target_sr}Hz")
return resampled
except Exception as e:
logger.warning(f"音频重采样失败: {str(e)},使用原始音频")
return audio_array
def adjust_audio_volume(audio_array: np.ndarray, volume: int) -> np.ndarray:
"""调节音频音量
Args:
audio_array: 音频数据数组
volume: 音量值,范围0~100,50为原始音量
Returns:
调节后的音频数据
"""
if int(volume) == 50:
return audio_array
if volume < 0 or volume > 100:
logger.warning(f"音量值{volume}超出范围[0,100],使用默认值50")
volume = 50
# 将音量值转换为倍数 (0-100 -> 0-2.0)
volume_factor = volume / 50.0
# 应用音量调节
adjusted_audio = audio_array * volume_factor
# 防止削波,如果音量过大导致超过范围,进行归一化
max_val = np.max(np.abs(adjusted_audio))
if max_val > 1.0:
adjusted_audio = adjusted_audio / max_val
logger.info(f"音量调节后进行归一化,最大值: {max_val:.3f}")
logger.info(f"音频音量已调节: {volume}/100 (倍数: {volume_factor:.2f})")
return adjusted_audio
def save_audio_array(
audio_array: np.ndarray,
output_path: str,
sample_rate: int = 22050,
format: str = "wav",
original_sr: Optional[int] = None,
volume: int = 50,
) -> str:
"""保存音频数组到文件
Args:
audio_array: 音频数据数组
output_path: 输出文件路径
sample_rate: 目标采样率
format: 音频格式
original_sr: 原始采样率(用于重采样)
volume: 音量值,范围0~100,默认50
Returns:
保存的文件路径
Raises:
AudioProcessingException: 保存失败
"""
try:
# 如果指定了原始采样率且与目标采样率不同,进行重采样
if original_sr and original_sr != sample_rate:
audio_array = resample_audio_array(audio_array, original_sr, sample_rate)
# 调节音频音量
audio_array = adjust_audio_volume(audio_array, volume)
# 确保音频数据是float32格式
if audio_array.dtype != np.float32:
audio_array = audio_array.astype(np.float32)
# 确保音频数据在正确的范围内
if np.max(np.abs(audio_array)) > 1.0:
audio_array = audio_array / np.max(np.abs(audio_array))
# 确保是2D张量 (channels, samples)
if audio_array.ndim == 1:
audio_array = audio_array[np.newaxis, :] # 添加通道维度
elif audio_array.ndim > 2:
audio_array = audio_array.squeeze()
if audio_array.ndim == 1:
audio_array = audio_array[np.newaxis, :]
# 根据格式选择保存方法
if format.lower() == "wav":
sf.write(output_path, audio_array.T, sample_rate, format="WAV")
else:
# 使用soundfile保存其他格式
# 确保音频数据是单声道
if audio_array.shape[0] > 1:
audio_array = np.mean(audio_array, axis=0)
sf.write(output_path, audio_array.T, sample_rate, format=format.upper())
return output_path
except Exception as e:
raise DefaultServerErrorException(f"保存音频文件失败: {str(e)}")
def convert_audio_to_wav(
input_path: str, output_path: Optional[str] = None, target_sr: int = 16000
) -> str:
"""转换音频文件为WAV格式
Args:
input_path: 输入文件路径
output_path: 输出文件路径(可选)
target_sr: 目标采样率,默认16000Hz
Returns:
转换后的文件路径
Raises:
AudioProcessingException: 转换失败
"""
if not output_path:
output_path = input_path.rsplit(".", 1)[0] + ".wav"
try:
# 使用librosa加载并重采样
audio_data, _ = librosa.load(input_path, sr=target_sr)
sf.write(output_path, audio_data, target_sr, format="WAV")
return output_path
except Exception as e:
# 尝试使用ffmpeg转换
try:
subprocess.run(
[
"ffmpeg",
"-f", "s16le",
"-ar", str(target_sr),
"-ac", "1",
"-i", input_path,
"-acodec", "pcm_s16le",
output_path,
"-y",
],
check=True,
capture_output=True,
)
return output_path
except (subprocess.CalledProcessError, FileNotFoundError):
raise DefaultServerErrorException(f"音频格式转换失败: {str(e)}")
def normalize_audio_for_asr(audio_path: str, target_sr: int = 16000) -> NormalizedAudio:
"""Normalize audio and return explicit timestamp metadata.
Args:
audio_path: 输入音频文件路径
target_sr: 目标采样率,默认16000Hz
Returns:
Normalized audio path and timestamp scale metadata.
"""
try:
# 检查文件扩展名
file_ext = os.path.splitext(audio_path)[1].lower()
# 如果已经是WAV格式且采样率正确,直接返回
if file_ext == ".wav":
# 检查采样率
_, sr = librosa.load(audio_path, sr=None)
if sr == target_sr:
return NormalizedAudio(path=audio_path)
# 转换为标准WAV格式
normalized_path = convert_audio_to_wav(audio_path, target_sr=target_sr)
logger.debug(f"音频文件已标准化: {audio_path} -> {normalized_path}")
timestamp_scale = 1.0
if normalized_path != audio_path:
decoded_duration = get_audio_duration(normalized_path)
timestamp_scale = get_timestamp_scale(audio_path, decoded_duration)
return NormalizedAudio(path=normalized_path, timestamp_scale=timestamp_scale)
except Exception as e:
raise DefaultServerErrorException(f"音频标准化失败: {str(e)}")
def generate_temp_audio_path(prefix: str = "audio", suffix: str = ".wav") -> str:
"""生成临时音频文件路径
Args:
prefix: 文件名前缀
suffix: 文件后缀
Returns:
临时文件路径
"""
import time
timestamp = int(time.time())
filename = f"{prefix}_{timestamp}_{os.getpid()}{suffix}"
return os.path.join(settings.TEMP_DIR, filename)
def detect_audio_format_from_bytes(data: bytes) -> str:
"""通过文件头(magic bytes)检测音频格式
Args:
data: 音频文件的前几个字节
Returns:
文件后缀(包含点号)
"""
if len(data) < 12:
return ".wav"
# 检查常见音频格式的文件头
if data[:4] == b"RIFF" and data[8:12] == b"WAVE":
return ".wav"
elif data[:3] == b"ID3" or (data[0:2] == b"\xff\xfb") or (data[0:2] == b"\xff\xfa"):
return ".mp3"
elif data[:4] == b"fLaC":
return ".flac"
elif data[:4] == b"OggS":
return ".ogg"
elif data[4:8] == b"ftyp":
# M4A/AAC/MP4/MOV 容器
return ".mp4"
elif data[:4] == b"\x1aE\xdf\xa3":
# WebM/MKV
return ".webm"
# 默认为 wav,librosa 会自动处理
return ".wav"
def get_audio_file_suffix(
audio_address: Optional[str] = None, audio_data: Optional[bytes] = None
) -> str:
"""自动识别音频文件后缀
Args:
audio_address: 音频文件URL(可选)
audio_data: 音频二进制数据(可选,用于检测文件头)
Returns:
文件后缀(包含点号)
"""
if audio_address:
# 从URL中提取扩展名
parsed = urlparse(audio_address)
path = unquote(parsed.path)
# 获取扩展名
ext = os.path.splitext(path)[1].lower()
if ext and ext in [
".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".pcm", ".webm",
".mp4", ".mpeg", ".mpga", ".mov", ".mkv", ".avi",
]:
return ext
# 无法识别扩展名,默认为 .wav
return ".wav"
elif audio_data:
# 通过文件头检测格式
return detect_audio_format_from_bytes(audio_data[:12])
else:
# 默认为 .wav
return ".wav"