test/app/services/audio/audio_service.py

235 lines
7.8 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 -*-
"""
音频处理服务
封装音频处理逻辑,提供统一的音频下载、格式转换、归一化等服务。
API层应该通过此服务层处理音频,而不是直接调用 utils/audio.py 中的函数。
"""
import logging
import threading
from dataclasses import dataclass
from typing import Optional
from fastapi import Request
from ...core.config import settings
from ...core.exceptions import InvalidMessageException
from ...utils.audio import (
download_audio_from_url,
save_audio_to_temp_file,
normalize_audio_for_asr,
get_audio_duration,
cleanup_temp_file,
get_audio_file_suffix,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class AudioProcessingResult:
normalized_path: str
duration: float
original_path: str
timestamp_scale: float = 1.0
class AudioProcessingService:
"""音频处理服务
提供统一的音频处理接口,包括:
1. 从URL下载音频
2. 处理上传的音频文件
3. 音频格式转换和归一化
4. 临时文件管理
"""
async def process_from_request(
self,
request: Request,
audio_address: Optional[str] = None,
task_id: Optional[str] = None,
sample_rate: Optional[int] = None,
) -> AudioProcessingResult:
"""从请求中处理音频
支持两种方式:
1. 请求体上传:从请求体读取二进制音频/视频数据
2. URL下载:通过 audio_address 参数指定音频/视频 URL
当请求体和 audio_address 同时存在时,优先使用请求体,
并忽略 audio_address。
Args:
request: FastAPI请求对象
audio_address: 音频文件URL(可选)
task_id: 任务ID,用于日志记录(可选)
sample_rate: 目标采样率(可选,默认16000)
Returns:
Processed audio path, duration, original path, and timestamp metadata.
Raises:
InvalidMessageException: 音频数据为空或文件太大
InvalidParameterException: URL无效或下载失败
"""
task_id = task_id or "unknown"
target_sr = sample_rate or 16000
# 优先读取请求体;若请求体为空,再回退到 audio_address。
# 注意:对于 FastAPI 已经解析过 form/multipart 的请求,
# 再次读取 body 可能抛出 "Stream consumed"。
try:
uploaded_data = await request.body()
except RuntimeError as exc:
if "Stream consumed" in str(exc):
logger.info(f"[{task_id}] 请求体已被上游读取,跳过 request.body() 回退逻辑")
uploaded_data = b""
else:
raise
if uploaded_data:
if audio_address:
logger.info(f"[{task_id}] 检测到同时提供上传内容和 audio_address,已忽略 audio_address")
return self._process_audio_bytes(
audio_data=uploaded_data,
filename=None,
task_id=task_id,
target_sr=target_sr,
)
if audio_address:
logger.info(f"[{task_id}] 开始从URL下载音频: {audio_address}")
audio_data = download_audio_from_url(audio_address)
logger.info(
f"[{task_id}] 音频下载完成,大小: {len(audio_data) / 1024 / 1024:.2f}MB"
)
return self._process_audio_bytes(
audio_data=audio_data,
filename=audio_address,
task_id=task_id,
target_sr=target_sr,
)
raise InvalidMessageException("音频数据为空", task_id)
async def process_upload_file(
self,
audio_data: bytes,
filename: Optional[str] = None,
task_id: Optional[str] = None,
sample_rate: Optional[int] = None,
) -> AudioProcessingResult:
"""处理上传的音频文件
Args:
audio_data: 音频二进制数据
filename: 原始文件名(用于检测格式,可选)
task_id: 任务ID,用于日志记录(可选)
sample_rate: 目标采样率(可选,默认16000)
Returns:
Processed audio path, duration, original path, and timestamp metadata.
Raises:
InvalidMessageException: 音频数据为空或文件太大
"""
task_id = task_id or "unknown"
target_sr = sample_rate or 16000
return self._process_audio_bytes(
audio_data=audio_data,
filename=filename,
task_id=task_id,
target_sr=target_sr,
)
def _process_audio_bytes(
self,
*,
audio_data: bytes,
filename: Optional[str],
task_id: str,
target_sr: int,
) -> AudioProcessingResult:
"""Persist, normalize, and measure audio bytes."""
audio_path = None
normalized_audio_path = None
try:
if not audio_data:
raise InvalidMessageException("音频数据为空", task_id)
file_size = len(audio_data)
logger.info(f"[{task_id}] 音频文件大小: {file_size / 1024 / 1024:.2f}MB")
# 检查文件大小
if file_size > settings.MAX_AUDIO_SIZE:
max_mb = settings.MAX_AUDIO_SIZE // 1024 // 1024
raise InvalidMessageException(
f"音频文件太大,最大支持{max_mb}MB", task_id
)
file_suffix = get_audio_file_suffix(
audio_address=filename,
audio_data=audio_data,
)
logger.info(f"[{task_id}] 识别文件格式: {file_suffix}")
audio_path = save_audio_to_temp_file(audio_data, file_suffix)
logger.info(f"[{task_id}] 临时文件: {audio_path}")
logger.info(f"[{task_id}] 开始音频格式转换...")
normalized_audio = normalize_audio_for_asr(audio_path, target_sr)
normalized_audio_path = normalized_audio.path
logger.info(f"[{task_id}] 音频格式转换完成: {normalized_audio_path}")
decoded_duration = get_audio_duration(normalized_audio_path)
audio_duration = decoded_duration * normalized_audio.timestamp_scale
logger.info(f"[{task_id}] 音频时长: {audio_duration:.1f}s")
return AudioProcessingResult(
normalized_path=normalized_audio_path,
duration=audio_duration,
original_path=audio_path,
timestamp_scale=normalized_audio.timestamp_scale,
)
except Exception:
if audio_path:
cleanup_temp_file(audio_path)
if normalized_audio_path and normalized_audio_path != audio_path:
cleanup_temp_file(normalized_audio_path)
raise
def cleanup(
self, audio_path: Optional[str], normalized_path: Optional[str] = None
) -> None:
"""清理临时文件
Args:
audio_path: 原始音频文件路径
normalized_path: 归一化后的音频文件路径(可选)
"""
if audio_path:
cleanup_temp_file(audio_path)
if normalized_path and normalized_path != audio_path:
cleanup_temp_file(normalized_path)
# 全局服务实例(单例模式)
_audio_service: Optional[AudioProcessingService] = None
_audio_service_lock = threading.Lock()
def get_audio_service() -> AudioProcessingService:
"""获取音频处理服务实例(线程安全的单例)
Returns:
AudioProcessingService: 音频处理服务实例
"""
global _audio_service
if _audio_service is None:
with _audio_service_lock:
if _audio_service is None:
_audio_service = AudioProcessingService()
return _audio_service