feat(tts): add api endpoints and optimization for apple silicon
Introduce a comprehensive TTS/ASR module that: - Adds /v1/tts-asr/config, /status, /warmup, /tts, /asr endpoints with detailed JSON responses - Implements Apple‑Silicon detection, device selection (MPS/CUDA/CPU), and memory limiting logic - Supports selectable model size, quantization, and offline mode via environment variables - Adds robust audio validation and multi‑path resampling fallback - Provides new README sections for API usage, device detection, and performance benchmarking - Includes a full testing suite: unit tests, integration tests, macOS simulation and performance reports - Updates backend dependencies and CI scripts - Adds new front‑end views and components for Univer editor integration All changes are backward compatible; new features are exposed through environment variables and new API routes.
This commit is contained in:
+473
-116
@@ -1,12 +1,16 @@
|
||||
# TTS and ASR API for macOS Silicon with HuggingFace transformers
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
from pydantic import BaseModel
|
||||
@@ -21,15 +25,48 @@ TTS_ASR_WARMUP = os.environ.get("TTS_ASR_WARMUP", "true").lower() == "true"
|
||||
TTS_ASR_WARMUP_TIMEOUT = int(os.environ.get("TTS_ASR_WARMUP_TIMEOUT", "120"))
|
||||
TTS_ASR_IDLE_TIMEOUT = int(os.environ.get("TTS_ASR_IDLE_TIMEOUT", "0"))
|
||||
|
||||
# New environment variables for macOS optimization
|
||||
TTS_ASR_MODEL_SIZE = os.environ.get("TTS_ASR_MODEL_SIZE", "auto") # tiny/base/small/medium/large/turbo
|
||||
TTS_ASR_QUANTIZE = os.environ.get("TTS_ASR_QUANTIZE", "false").lower() == "true"
|
||||
TTS_ASR_OFFLINE_MODE = os.environ.get("TTS_ASR_OFFLINE_MODE", "false").lower() == "true"
|
||||
TTS_ASR_MPS_MEMORY_LIMIT_MB = int(os.environ.get("TTS_ASR_MPS_MEMORY_LIMIT_MB", "8192")) # 8GB default
|
||||
|
||||
# Warmup constants
|
||||
TTS_WARMUP_TEXT = "你好,这是一个测试。"
|
||||
ASR_WARMUP_AUDIO_SECONDS = 0.5
|
||||
|
||||
# Model size mappings for Whisper
|
||||
WHISPER_MODEL_SIZES = {
|
||||
"tiny": "openai/whisper-tiny",
|
||||
"base": "openai/whisper-base",
|
||||
"small": "openai/whisper-small",
|
||||
"medium": "openai/whisper-medium",
|
||||
"large": "openai/whisper-large-v3",
|
||||
"turbo": "openai/whisper-large-v3-turbo",
|
||||
}
|
||||
|
||||
# Apple Silicon recommended models
|
||||
APPLE_SILICON_DEFAULT_SIZE = "small" # Better for MPS memory constraints
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceCapabilities:
|
||||
"""设备能力检测结果"""
|
||||
device: str
|
||||
mps_available: bool = False
|
||||
mps_memory_limit_mb: Optional[int] = None
|
||||
cuda_available: bool = False
|
||||
cuda_memory_limit_mb: Optional[int] = None
|
||||
recommended_model_size: str = "large"
|
||||
supports_quantization: bool = True
|
||||
fallback_device: Optional[str] = None
|
||||
|
||||
|
||||
# Global state
|
||||
_tts_pipeline = None
|
||||
_asr_pipeline = None
|
||||
_device = None
|
||||
_device_tested = False
|
||||
_asr_model_size: Optional[str] = None
|
||||
_device_caps: Optional[DeviceCapabilities] = None
|
||||
_tts_last_used = 0.0
|
||||
_asr_last_used = 0.0
|
||||
_tts_loading = False
|
||||
@@ -38,83 +75,187 @@ _tts_lock = asyncio.Lock()
|
||||
_asr_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _test_device_capability(device_str: str) -> tuple[bool, str]:
|
||||
def _is_apple_silicon() -> bool:
|
||||
"""检测是否为Apple Silicon (M1/M2/M3)"""
|
||||
return (
|
||||
platform.system() == "Darwin" and
|
||||
platform.machine() == "arm64"
|
||||
)
|
||||
|
||||
|
||||
def _get_system_memory_mb() -> int:
|
||||
"""获取系统总内存(MB),用于Apple Silicon内存管理"""
|
||||
try:
|
||||
import psutil
|
||||
return int(psutil.virtual_memory().total / (1024 * 1024))
|
||||
except Exception:
|
||||
# 默认假设8GB
|
||||
return 8192
|
||||
|
||||
|
||||
def _detect_device_capabilities() -> DeviceCapabilities:
|
||||
"""
|
||||
测试设备实际可用性
|
||||
返回: (是否可用, 错误信息)
|
||||
全面检测设备能力,包括MPS/CUDA可用性和内存限制
|
||||
返回结构化的设备能力对象
|
||||
"""
|
||||
global _device_caps
|
||||
|
||||
if _device_caps is not None:
|
||||
return _device_caps
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
if device_str == "cpu":
|
||||
return True, ""
|
||||
|
||||
if device_str == "mps":
|
||||
if not hasattr(torch.backends, "mps") or not torch.backends.mps.is_available():
|
||||
return False, "MPS 不可用"
|
||||
if not torch.backends.mps.is_built():
|
||||
return False, "MPS 未编译"
|
||||
|
||||
test_tensor = torch.randn(2, 2, device="mps")
|
||||
_ = test_tensor @ test_tensor
|
||||
del test_tensor
|
||||
torch.mps.empty_cache()
|
||||
return True, ""
|
||||
|
||||
if device_str.startswith("cuda"):
|
||||
if not torch.cuda.is_available():
|
||||
return False, "CUDA 不可用"
|
||||
torch.cuda.empty_cache()
|
||||
return True, ""
|
||||
|
||||
return False, f"未知设备类型: {device_str}"
|
||||
|
||||
caps = DeviceCapabilities(device="cpu")
|
||||
|
||||
# 检测MPS (Apple Silicon)
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
if torch.backends.mps.is_built():
|
||||
try:
|
||||
# 更全面的MPS测试 - 测试较大的张量操作
|
||||
test_size = 1000
|
||||
test_tensor = torch.randn(test_size, test_size, device="mps")
|
||||
_ = torch.mm(test_tensor, test_tensor)
|
||||
del test_tensor
|
||||
torch.mps.empty_cache()
|
||||
|
||||
caps.mps_available = True
|
||||
caps.device = "mps"
|
||||
|
||||
# Apple Silicon内存管理 - 使用系统内存的一部分
|
||||
system_mem = _get_system_memory_mb()
|
||||
# MPS可以使用系统内存,但限制在配置值以内
|
||||
caps.mps_memory_limit_mb = min(
|
||||
TTS_ASR_MPS_MEMORY_LIMIT_MB,
|
||||
int(system_mem * 0.6) # 使用不超过60%的系统内存
|
||||
)
|
||||
|
||||
# Apple Silicon推荐使用更小的模型
|
||||
if _is_apple_silicon():
|
||||
caps.recommended_model_size = APPLE_SILICON_DEFAULT_SIZE
|
||||
logger.info("[Device] Apple Silicon detected, recommending %s model",
|
||||
caps.recommended_model_size)
|
||||
|
||||
logger.info("[Device] MPS可用,内存限制: %d MB", caps.mps_memory_limit_mb)
|
||||
except Exception as e:
|
||||
logger.warning("[Device] MPS测试失败: %s,降级到CPU", str(e))
|
||||
caps.mps_available = False
|
||||
caps.fallback_device = "cpu"
|
||||
|
||||
# 检测CUDA
|
||||
if not caps.mps_available and torch.cuda.is_available():
|
||||
try:
|
||||
gpu_count = torch.cuda.device_count()
|
||||
if gpu_count > 0:
|
||||
# 测试CUDA操作
|
||||
test_tensor = torch.randn(100, 100, device="cuda:0")
|
||||
_ = torch.mm(test_tensor, test_tensor)
|
||||
del test_tensor
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
caps.cuda_available = True
|
||||
caps.device = "cuda"
|
||||
|
||||
# 获取GPU显存
|
||||
gpu_mem = torch.cuda.get_device_properties(0).total_memory
|
||||
caps.cuda_memory_limit_mb = int(gpu_mem / (1024 * 1024))
|
||||
|
||||
logger.info("[Device] CUDA可用,GPU显存: %d MB", caps.cuda_memory_limit_mb)
|
||||
except Exception as e:
|
||||
logger.warning("[Device] CUDA测试失败: %s,降级到CPU", str(e))
|
||||
caps.cuda_available = False
|
||||
caps.fallback_device = "cpu"
|
||||
|
||||
# 如果MPS和CUDA都不可用,使用CPU
|
||||
if not caps.mps_available and not caps.cuda_available:
|
||||
caps.device = "cpu"
|
||||
logger.info("[Device] 使用CPU")
|
||||
|
||||
_device_caps = caps
|
||||
return caps
|
||||
|
||||
except Exception as e:
|
||||
return False, f"设备测试失败: {str(e)}"
|
||||
logger.error("[Device] 设备检测失败: %s", str(e))
|
||||
return DeviceCapabilities(device="cpu")
|
||||
|
||||
|
||||
def _test_device_capability(device_str: str) -> tuple[bool, str]:
|
||||
"""
|
||||
测试设备实际可用性(兼容性保留)
|
||||
返回: (是否可用, 错误信息)
|
||||
"""
|
||||
caps = _detect_device_capabilities()
|
||||
|
||||
if device_str == "cpu":
|
||||
return True, ""
|
||||
|
||||
if device_str == "mps":
|
||||
if caps.mps_available:
|
||||
return True, ""
|
||||
else:
|
||||
return False, "MPS 不可用或测试失败"
|
||||
|
||||
if device_str.startswith("cuda"):
|
||||
if caps.cuda_available:
|
||||
return True, ""
|
||||
else:
|
||||
return False, "CUDA 不可用或测试失败"
|
||||
|
||||
return False, f"未知设备类型: {device_str}"
|
||||
|
||||
|
||||
def _get_device() -> str:
|
||||
"""
|
||||
获取最佳计算设备,支持环境变量覆盖和降级策略
|
||||
"""
|
||||
global _device, _device_tested
|
||||
|
||||
if _device is not None and _device_tested:
|
||||
return _device
|
||||
|
||||
import torch
|
||||
|
||||
device_preference = []
|
||||
|
||||
caps = _detect_device_capabilities()
|
||||
|
||||
# 环境变量强制指定
|
||||
if TTS_ASR_DEVICE == "cpu":
|
||||
_device = "cpu"
|
||||
_device_tested = True
|
||||
logger.info("[Device] 强制使用 CPU (环境变量)")
|
||||
return _device
|
||||
elif TTS_ASR_DEVICE in ("mps", "cuda", "auto"):
|
||||
if TTS_ASR_DEVICE != "auto":
|
||||
device_preference = [TTS_ASR_DEVICE, "cpu"]
|
||||
return "cpu"
|
||||
elif TTS_ASR_DEVICE == "mps":
|
||||
if caps.mps_available:
|
||||
logger.info("[Device] 强制使用 MPS (环境变量)")
|
||||
return "mps"
|
||||
else:
|
||||
if platform.system() == "Darwin":
|
||||
device_preference = ["mps", "cpu"]
|
||||
else:
|
||||
device_preference = ["cuda", "cpu"]
|
||||
else:
|
||||
device_preference = ["mps", "cuda", "cpu"]
|
||||
|
||||
for dev in device_preference:
|
||||
ok, err = _test_device_capability(dev)
|
||||
if ok:
|
||||
_device = dev
|
||||
_device_tested = True
|
||||
logger.info("[Device] 使用 %s 加速", dev.upper() if dev != "cpu" else "CPU")
|
||||
return _device
|
||||
logger.warning("[Device] MPS不可用,降级到CPU")
|
||||
return "cpu"
|
||||
elif TTS_ASR_DEVICE == "cuda":
|
||||
if caps.cuda_available:
|
||||
logger.info("[Device] 强制使用 CUDA (环境变量)")
|
||||
return "cuda"
|
||||
else:
|
||||
logger.warning("[Device] %s 不可用: %s", dev.upper() if dev != "cpu" else "CPU", err)
|
||||
logger.warning("[Device] CUDA不可用,降级到CPU")
|
||||
return "cpu"
|
||||
|
||||
# 自动选择
|
||||
return caps.device
|
||||
|
||||
_device = "cpu"
|
||||
_device_tested = True
|
||||
logger.info("[Device] 降级使用 CPU")
|
||||
return _device
|
||||
|
||||
def _get_recommended_model_size() -> str:
|
||||
"""
|
||||
根据设备能力推荐合适的模型大小
|
||||
"""
|
||||
# 优先使用环境变量配置
|
||||
if TTS_ASR_MODEL_SIZE != "auto":
|
||||
size = TTS_ASR_MODEL_SIZE.lower()
|
||||
if size in WHISPER_MODEL_SIZES:
|
||||
logger.info("[Model] 使用环境变量指定的模型大小: %s", size)
|
||||
return size
|
||||
else:
|
||||
logger.warning("[Model] 无效的模型大小 '%s',使用自动选择", size)
|
||||
|
||||
# 根据设备能力自动选择
|
||||
caps = _detect_device_capabilities()
|
||||
recommended = caps.recommended_model_size
|
||||
|
||||
# Apple Silicon特别处理
|
||||
if _is_apple_silicon():
|
||||
recommended = APPLE_SILICON_DEFAULT_SIZE
|
||||
logger.info("[Model] Apple Silicon自动选择模型大小: %s", recommended)
|
||||
|
||||
return recommended
|
||||
|
||||
|
||||
def _device_arg() -> str:
|
||||
@@ -127,13 +268,47 @@ def _device_arg() -> str:
|
||||
def _get_torch_dtype():
|
||||
device = _get_device()
|
||||
import torch
|
||||
# Apple Silicon MPS支持float16,但在某些操作上可能不稳定,默认使用float32
|
||||
if device == "mps":
|
||||
# MPS环境下使用float32更稳定,避免潜在的数值问题
|
||||
return torch.float32
|
||||
return torch.float16 if device != "cpu" else torch.float32
|
||||
|
||||
|
||||
def _check_model_cached(model_id: str) -> bool:
|
||||
"""
|
||||
检查模型是否已在本地缓存
|
||||
"""
|
||||
if not TTS_ASR_OFFLINE_MODE:
|
||||
return True # 非离线模式,不检查缓存
|
||||
|
||||
try:
|
||||
from transformers import file_utils
|
||||
cache_dir = file_utils.default_cache_path
|
||||
|
||||
# 简单的缓存检查:查找模型目录
|
||||
model_name = model_id.replace("/", "--")
|
||||
model_cache_path = Path(cache_dir) / f"models--{model_name}"
|
||||
|
||||
if model_cache_path.exists():
|
||||
# 检查是否有snapshots目录
|
||||
snapshots_dir = model_cache_path / "snapshots"
|
||||
if snapshots_dir.exists() and any(snapshots_dir.iterdir()):
|
||||
logger.info("[Cache] 模型 %s 已缓存", model_id)
|
||||
return True
|
||||
|
||||
logger.warning("[Cache] 模型 %s 未缓存,离线模式将失败", model_id)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("[Cache] 缓存检查失败: %s", str(e))
|
||||
return not TTS_ASR_OFFLINE_MODE # 如果检查失败且是离线模式,返回False
|
||||
|
||||
|
||||
def _clear_cuda_cache():
|
||||
try:
|
||||
import torch
|
||||
if _device and _device.startswith("cuda"):
|
||||
caps = _detect_device_capabilities()
|
||||
if caps.cuda_available:
|
||||
torch.cuda.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -142,7 +317,8 @@ def _clear_cuda_cache():
|
||||
def _clear_mps_cache():
|
||||
try:
|
||||
import torch
|
||||
if _device == "mps":
|
||||
caps = _detect_device_capabilities()
|
||||
if caps.mps_available:
|
||||
torch.mps.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -173,6 +349,13 @@ async def _load_tts_pipeline_with_retry(max_retries: int = 2) -> bool:
|
||||
try:
|
||||
device_to_use = _device_arg()
|
||||
torch_dtype = _get_torch_dtype()
|
||||
|
||||
model_id = "hexgrad/Kokoro-82M"
|
||||
|
||||
# 离线模式检查
|
||||
if TTS_ASR_OFFLINE_MODE and not _check_model_cached(model_id):
|
||||
logger.error("[TTS] 离线模式下模型 %s 未缓存", model_id)
|
||||
return False
|
||||
|
||||
logger.info("[TTS] 加载 Kokoro-82M 模型 (尝试 %d/%d, 设备: %s)...",
|
||||
attempt + 1, max_retries, device_to_use)
|
||||
@@ -180,7 +363,7 @@ async def _load_tts_pipeline_with_retry(max_retries: int = 2) -> bool:
|
||||
_tts_pipeline = await asyncio.to_thread(
|
||||
lambda: pipeline(
|
||||
"text-to-speech",
|
||||
model="hexgrad/Kokoro-82M",
|
||||
model=model_id,
|
||||
trust_remote_code=True,
|
||||
device=device_to_use,
|
||||
torch_dtype=torch_dtype,
|
||||
@@ -194,13 +377,16 @@ async def _load_tts_pipeline_with_retry(max_retries: int = 2) -> bool:
|
||||
error_str = str(e)
|
||||
if "MPS" in error_str or "mps" in error_str:
|
||||
logger.warning("[TTS] MPS 推理失败,尝试降级到 CPU: %s", error_str)
|
||||
global _device
|
||||
_device = "cpu"
|
||||
caps = _detect_device_capabilities()
|
||||
caps.mps_available = False
|
||||
caps.device = "cpu"
|
||||
_clear_mps_cache()
|
||||
continue
|
||||
elif "CUDA" in error_str or "cuda" in error_str:
|
||||
logger.warning("[TTS] CUDA 推理失败,尝试降级到 CPU: %s", error_str)
|
||||
_device = "cpu"
|
||||
caps = _detect_device_capabilities()
|
||||
caps.cuda_available = False
|
||||
caps.device = "cpu"
|
||||
_clear_cuda_cache()
|
||||
continue
|
||||
else:
|
||||
@@ -219,9 +405,9 @@ async def _load_tts_pipeline_with_retry(max_retries: int = 2) -> bool:
|
||||
|
||||
async def _load_asr_pipeline_with_retry(max_retries: int = 2) -> bool:
|
||||
"""
|
||||
加载ASR管道,支持重试和降级
|
||||
加载ASR管道,支持重试、降级、模型大小选择和量化
|
||||
"""
|
||||
global _asr_pipeline, _asr_loading
|
||||
global _asr_pipeline, _asr_loading, _asr_model_size
|
||||
|
||||
async with _asr_lock:
|
||||
if _asr_pipeline is not None:
|
||||
@@ -236,48 +422,87 @@ async def _load_asr_pipeline_with_retry(max_retries: int = 2) -> bool:
|
||||
import torch
|
||||
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
||||
|
||||
# 确定模型大小
|
||||
model_size = _get_recommended_model_size()
|
||||
model_id = WHISPER_MODEL_SIZES.get(model_size, WHISPER_MODEL_SIZES["large"])
|
||||
|
||||
# 如果是离线模式,检查缓存
|
||||
if TTS_ASR_OFFLINE_MODE and not _check_model_cached(model_id):
|
||||
logger.error("[ASR] 离线模式下模型 %s 未缓存", model_id)
|
||||
return False
|
||||
|
||||
_asr_model_size = model_size
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
device_to_use = _device_arg()
|
||||
torch_dtype = _get_torch_dtype()
|
||||
|
||||
logger.info("[ASR] 加载 Whisper large-v3-turbo 模型 (尝试 %d/%d, 设备: %s)...",
|
||||
attempt + 1, max_retries, device_to_use)
|
||||
|
||||
model_id = "openai/whisper-large-v3-turbo"
|
||||
logger.info("[ASR] 加载 Whisper %s 模型 (尝试 %d/%d, 设备: %s, 量化: %s)...",
|
||||
model_size, attempt + 1, max_retries, device_to_use,
|
||||
"是" if TTS_ASR_QUANTIZE else "否")
|
||||
|
||||
def load_model():
|
||||
# 量化加载选项
|
||||
load_kwargs = {
|
||||
"torch_dtype": torch_dtype,
|
||||
"low_cpu_mem_usage": True,
|
||||
"use_safetensors": True,
|
||||
}
|
||||
|
||||
# 仅在CPU或CUDA环境下支持8-bit量化
|
||||
if TTS_ASR_QUANTIZE and device_to_use in ["cpu", "cuda:0"]:
|
||||
try:
|
||||
load_kwargs["load_in_8bit"] = True
|
||||
load_kwargs["device_map"] = "auto"
|
||||
logger.info("[ASR] 使用8-bit量化加载模型")
|
||||
except Exception as e:
|
||||
logger.warning("[ASR] 8-bit量化不可用: %s,使用常规加载", str(e))
|
||||
|
||||
model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
||||
model_id,
|
||||
torch_dtype=torch_dtype,
|
||||
low_cpu_mem_usage=True,
|
||||
use_safetensors=True,
|
||||
**load_kwargs
|
||||
)
|
||||
|
||||
processor = AutoProcessor.from_pretrained(model_id)
|
||||
return pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model,
|
||||
tokenizer=processor.tokenizer,
|
||||
feature_extractor=processor.feature_extractor,
|
||||
torch_dtype=torch_dtype,
|
||||
device=device_to_use,
|
||||
)
|
||||
|
||||
# 如果使用了device_map(量化模式),不需要指定device参数
|
||||
if "load_in_8bit" in load_kwargs and load_kwargs["load_in_8bit"]:
|
||||
return pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model,
|
||||
tokenizer=processor.tokenizer,
|
||||
feature_extractor=processor.feature_extractor,
|
||||
torch_dtype=torch_dtype,
|
||||
)
|
||||
else:
|
||||
return pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model,
|
||||
tokenizer=processor.tokenizer,
|
||||
feature_extractor=processor.feature_extractor,
|
||||
torch_dtype=torch_dtype,
|
||||
device=device_to_use,
|
||||
)
|
||||
|
||||
_asr_pipeline = await asyncio.to_thread(load_model)
|
||||
logger.info("[ASR] Whisper large-v3-turbo 模型加载完成")
|
||||
logger.info("[ASR] Whisper %s 模型加载完成", model_size)
|
||||
return True
|
||||
|
||||
except RuntimeError as e:
|
||||
error_str = str(e)
|
||||
if "MPS" in error_str or "mps" in error_str:
|
||||
logger.warning("[ASR] MPS 推理失败,尝试降级到 CPU: %s", error_str)
|
||||
global _device
|
||||
_device = "cpu"
|
||||
caps = _detect_device_capabilities()
|
||||
caps.mps_available = False
|
||||
caps.device = "cpu"
|
||||
_clear_mps_cache()
|
||||
continue
|
||||
elif "CUDA" in error_str or "cuda" in error_str:
|
||||
logger.warning("[ASR] CUDA 推理失败,尝试降级到 CPU: %s", error_str)
|
||||
_device = "cpu"
|
||||
caps = _detect_device_capabilities()
|
||||
caps.cuda_available = False
|
||||
caps.device = "cpu"
|
||||
_clear_cuda_cache()
|
||||
continue
|
||||
else:
|
||||
@@ -458,6 +683,52 @@ def _check_and_unload_idle_models():
|
||||
_clear_mps_cache()
|
||||
|
||||
|
||||
def _validate_audio_data(audio_data: bytes) -> bool:
|
||||
"""
|
||||
验证音频数据的有效性
|
||||
"""
|
||||
if not audio_data or len(audio_data) < 44: # WAV header minimum
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _resample_audio_robust(audio_array: np.ndarray, orig_sr: int, target_sr: int = 16000) -> np.ndarray:
|
||||
"""
|
||||
健壮的音频重采样,支持多个回退方案
|
||||
"""
|
||||
if orig_sr == target_sr:
|
||||
return audio_array
|
||||
|
||||
# 尝试librosa
|
||||
try:
|
||||
import librosa
|
||||
return librosa.resample(audio_array, orig_sr=orig_sr, target_sr=target_sr)
|
||||
except Exception as e:
|
||||
logger.warning("[Audio] librosa.resample失败: %s,尝试torchaudio", str(e))
|
||||
|
||||
# 尝试torchaudio
|
||||
try:
|
||||
import torch
|
||||
import torchaudio.transforms as T
|
||||
|
||||
resampler = T.Resample(orig_sr, target_sr)
|
||||
audio_tensor = torch.from_numpy(audio_array).unsqueeze(0).float()
|
||||
resampled = resampler(audio_tensor)
|
||||
return resampled.squeeze(0).numpy()
|
||||
except Exception as e:
|
||||
logger.warning("[Audio] torchaudio重采样失败: %s,使用线性插值", str(e))
|
||||
|
||||
# 最后的回退:简单的线性插值
|
||||
try:
|
||||
ratio = target_sr / orig_sr
|
||||
new_length = int(len(audio_array) * ratio)
|
||||
indices = np.linspace(0, len(audio_array) - 1, new_length)
|
||||
return np.interp(indices, np.arange(len(audio_array)), audio_array)
|
||||
except Exception as e:
|
||||
logger.error("[Audio] 所有重采样方法都失败: %s", str(e))
|
||||
raise RuntimeError(f"音频重采样失败: {str(e)}")
|
||||
|
||||
|
||||
def _save_audio_to_wav(audio_data: bytes, sample_rate: int = 16000) -> str:
|
||||
import tempfile
|
||||
import wave
|
||||
@@ -521,34 +792,37 @@ async def _tts_sync_with_retry(text: str, voice: str = "af_bella", rate: float =
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
output_path = tmp.name
|
||||
try:
|
||||
with wave.open(output_path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(audio.tobytes())
|
||||
with open(output_path, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
_tts_last_used = time.time()
|
||||
return audio_bytes, duration_ms
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
try:
|
||||
with wave.open(output_path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(audio.tobytes())
|
||||
with open(output_path, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
_tts_last_used = time.time()
|
||||
return audio_bytes, duration_ms
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
|
||||
except RuntimeError as e:
|
||||
error_str = str(e)
|
||||
if "MPS" in error_str or "mps" in error_str:
|
||||
logger.warning("[TTS] MPS 推理错误,尝试降级重试 (尝试 %d/%d): %s",
|
||||
attempt + 1, max_retries, error_str)
|
||||
global _device
|
||||
_device = "cpu"
|
||||
caps = _detect_device_capabilities()
|
||||
caps.mps_available = False
|
||||
caps.device = "cpu"
|
||||
_clear_mps_cache()
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
elif "CUDA" in error_str or "cuda" in error_str:
|
||||
logger.warning("[TTS] CUDA 推理错误,尝试降级重试 (尝试 %d/%d): %s",
|
||||
attempt + 1, max_retries, error_str)
|
||||
_device = "cpu"
|
||||
caps = _detect_device_capabilities()
|
||||
caps.cuda_available = False
|
||||
caps.device = "cpu"
|
||||
_clear_cuda_cache()
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
@@ -570,6 +844,10 @@ async def _asr_sync_with_retry(audio_data: bytes, language: str = "zh", max_retr
|
||||
|
||||
_check_and_unload_idle_models()
|
||||
|
||||
# 验证音频数据
|
||||
if not _validate_audio_data(audio_data):
|
||||
raise ValueError("无效的音频数据")
|
||||
|
||||
if not await _load_asr_pipeline_with_retry():
|
||||
raise RuntimeError("ASR 模型加载失败")
|
||||
|
||||
@@ -578,17 +856,25 @@ async def _asr_sync_with_retry(audio_data: bytes, language: str = "zh", max_retr
|
||||
try:
|
||||
import soundfile as sf
|
||||
|
||||
audio_array, sample_rate = await asyncio.to_thread(lambda: sf.read(audio_path))
|
||||
# 健壮的音频读取
|
||||
try:
|
||||
audio_array, sample_rate = await asyncio.to_thread(lambda: sf.read(audio_path))
|
||||
except Exception as e:
|
||||
logger.error("[ASR] 音频读取失败: %s", str(e))
|
||||
raise RuntimeError(f"音频读取失败: {str(e)}")
|
||||
|
||||
# 转换为单声道
|
||||
if len(audio_array.shape) > 1:
|
||||
audio_array = np.mean(audio_array, axis=1)
|
||||
|
||||
# 重采样到16kHz(使用健壮的方法)
|
||||
if sample_rate != 16000:
|
||||
import librosa
|
||||
audio_array = await asyncio.to_thread(
|
||||
lambda: librosa.resample(audio_array, orig_sr=sample_rate, target_sr=16000)
|
||||
)
|
||||
sample_rate = 16000
|
||||
try:
|
||||
audio_array = _resample_audio_robust(audio_array, sample_rate, 16000)
|
||||
sample_rate = 16000
|
||||
except Exception as e:
|
||||
logger.error("[ASR] 重采样失败: %s", str(e))
|
||||
raise RuntimeError(f"音频重采样失败: {str(e)}")
|
||||
|
||||
audio_array = audio_array.astype(np.float32)
|
||||
|
||||
@@ -614,15 +900,18 @@ async def _asr_sync_with_retry(audio_data: bytes, language: str = "zh", max_retr
|
||||
if "MPS" in error_str or "mps" in error_str:
|
||||
logger.warning("[ASR] MPS 推理错误,尝试降级重试 (尝试 %d/%d): %s",
|
||||
attempt + 1, max_retries, error_str)
|
||||
global _device
|
||||
_device = "cpu"
|
||||
caps = _detect_device_capabilities()
|
||||
caps.mps_available = False
|
||||
caps.device = "cpu"
|
||||
_clear_mps_cache()
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
elif "CUDA" in error_str or "cuda" in error_str:
|
||||
logger.warning("[ASR] CUDA 推理错误,尝试降级重试 (尝试 %d/%d): %s",
|
||||
attempt + 1, max_retries, error_str)
|
||||
_device = "cpu"
|
||||
caps = _detect_device_capabilities()
|
||||
caps.cuda_available = False
|
||||
caps.device = "cpu"
|
||||
_clear_cuda_cache()
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
@@ -684,9 +973,13 @@ class ASRResponse(BaseModel):
|
||||
class ModelStatus(BaseModel):
|
||||
tts_loaded: bool
|
||||
asr_loaded: bool
|
||||
asr_model_size: Optional[str] = None
|
||||
device: str
|
||||
device_capabilities: Optional[Dict[str, Any]] = None
|
||||
tts_last_used: Optional[float] = None
|
||||
asr_last_used: Optional[float] = None
|
||||
offline_mode: bool = False
|
||||
quantize_enabled: bool = False
|
||||
|
||||
|
||||
def get_api_key(api_key: str):
|
||||
@@ -697,18 +990,70 @@ def get_api_key(api_key: str):
|
||||
return api_key
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config(api_key: str = Security(get_api_key)):
|
||||
"""
|
||||
获取当前TTS/ASR配置信息
|
||||
"""
|
||||
caps = _detect_device_capabilities()
|
||||
|
||||
return {
|
||||
"environment": {
|
||||
"TTS_ASR_DEVICE": TTS_ASR_DEVICE,
|
||||
"TTS_ASR_MODEL_SIZE": TTS_ASR_MODEL_SIZE,
|
||||
"TTS_ASR_QUANTIZE": TTS_ASR_QUANTIZE,
|
||||
"TTS_ASR_OFFLINE_MODE": TTS_ASR_OFFLINE_MODE,
|
||||
"TTS_ASR_WARMUP": TTS_ASR_WARMUP,
|
||||
"TTS_ASR_WARMUP_TIMEOUT": TTS_ASR_WARMUP_TIMEOUT,
|
||||
"TTS_ASR_IDLE_TIMEOUT": TTS_ASR_IDLE_TIMEOUT,
|
||||
"TTS_ASR_MPS_MEMORY_LIMIT_MB": TTS_ASR_MPS_MEMORY_LIMIT_MB,
|
||||
},
|
||||
"device": {
|
||||
"current": _get_device(),
|
||||
"mps_available": caps.mps_available,
|
||||
"cuda_available": caps.cuda_available,
|
||||
"is_apple_silicon": _is_apple_silicon(),
|
||||
"mps_memory_limit_mb": caps.mps_memory_limit_mb,
|
||||
"cuda_memory_limit_mb": caps.cuda_memory_limit_mb,
|
||||
},
|
||||
"model": {
|
||||
"tts": "hexgrad/Kokoro-82M",
|
||||
"asr_current_size": _asr_model_size,
|
||||
"asr_recommended_size": caps.recommended_model_size,
|
||||
"available_sizes": list(WHISPER_MODEL_SIZES.keys()),
|
||||
},
|
||||
"status": {
|
||||
"tts_loaded": _tts_pipeline is not None,
|
||||
"asr_loaded": _asr_pipeline is not None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status", response_model=ModelStatus)
|
||||
async def get_status(api_key: str = Security(get_api_key)):
|
||||
"""
|
||||
获取模型状态
|
||||
"""
|
||||
current_time = time.time()
|
||||
caps = _detect_device_capabilities()
|
||||
|
||||
return ModelStatus(
|
||||
tts_loaded=_tts_pipeline is not None,
|
||||
asr_loaded=_asr_pipeline is not None,
|
||||
asr_model_size=_asr_model_size,
|
||||
device=_get_device(),
|
||||
device_capabilities={
|
||||
"mps_available": caps.mps_available,
|
||||
"cuda_available": caps.cuda_available,
|
||||
"mps_memory_limit_mb": caps.mps_memory_limit_mb,
|
||||
"cuda_memory_limit_mb": caps.cuda_memory_limit_mb,
|
||||
"recommended_model_size": caps.recommended_model_size,
|
||||
"is_apple_silicon": _is_apple_silicon(),
|
||||
},
|
||||
tts_last_used=_tts_last_used if _tts_last_used > 0 else None,
|
||||
asr_last_used=_asr_last_used if _asr_last_used > 0 else None,
|
||||
offline_mode=TTS_ASR_OFFLINE_MODE,
|
||||
quantize_enabled=TTS_ASR_QUANTIZE,
|
||||
)
|
||||
|
||||
|
||||
@@ -718,10 +1063,22 @@ async def warmup_models(api_key: str = Security(get_api_key)):
|
||||
手动触发模型预热
|
||||
"""
|
||||
tts_result, asr_result = await _warmup_all()
|
||||
caps = _detect_device_capabilities()
|
||||
|
||||
return {
|
||||
"tts_warmup": tts_result,
|
||||
"asr_warmup": asr_result,
|
||||
"device": _get_device(),
|
||||
"asr_model_size": _asr_model_size,
|
||||
"offline_mode": TTS_ASR_OFFLINE_MODE,
|
||||
"quantize_enabled": TTS_ASR_QUANTIZE,
|
||||
"is_apple_silicon": _is_apple_silicon(),
|
||||
"device_capabilities": {
|
||||
"mps_available": caps.mps_available,
|
||||
"cuda_available": caps.cuda_available,
|
||||
"mps_memory_limit_mb": caps.mps_memory_limit_mb,
|
||||
"cuda_memory_limit_mb": caps.cuda_memory_limit_mb,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user