Files
llm-in-text/backend/tests/test_tts_asr_extended.py
T

264 lines
8.5 KiB
Python

import os
import sys
import base64
import io
import types
import wave
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
def _make_mlx_stub():
"""Create minimal MLX stub for testing without Apple Silicon"""
mlx = types.SimpleNamespace()
mlx.core = types.SimpleNamespace()
mx_array = type('mx.array', (), {'item': lambda self: 1})
mlx.core.array = mx_array
def mock_load(path):
return MagicMock()
mlx.core.load = mock_load # type: ignore
mlx.nn = types.SimpleNamespace()
return mlx
def _make_mlx_audio_stub():
"""Create minimal mlx-audio stub"""
stt = types.SimpleNamespace()
stt.utils = types.SimpleNamespace()
def mock_load(path): # type: ignore
model = MagicMock()
output = types.SimpleNamespace()
output.text = "识别结果"
output.language = "zh-CN"
model.generate = MagicMock(return_value=output)
return model
stt.utils.load = mock_load # type: ignore
qwen3_asr_mod = types.SimpleNamespace()
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
stt.models = types.SimpleNamespace() # type: ignore
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
audio = types.SimpleNamespace()
audio.stt = stt # type: ignore
return audio
def _reload_tts_asr_with_mocks():
"""Reload tts_asr with mocked MLX dependencies"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
mlx_stub = _make_mlx_stub()
sys.modules['mlx'] = mlx_stub # type: ignore
sys.modules['mlx.core'] = mlx_stub.core # type: ignore
sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
audio_stub = _make_mlx_audio_stub()
sys.modules['mlx-audio'] = audio_stub # type: ignore
sys.modules['mlx_audio'] = audio_stub # type: ignore
sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
import tts_asr
return tts_asr, audio_stub
@pytest.fixture(autouse=True)
def _clean_env():
"""Clean ASR-related env vars before/after each test"""
saved = {}
for k in ['HF_ENDPOINT']:
saved[k] = os.environ.get(k)
if k in os.environ:
del os.environ[k]
yield
for k, v in saved.items():
if v is not None:
os.environ[k] = v # type: ignore
def _make_wav_bytes(sr=16000, duration_sec=1.0, channels=1):
"""Helper: generate WAV bytes as base64"""
samples = int(sr * duration_sec)
audio = np.random.randint(-32768, 32767, size=samples * channels, dtype=np.int16)
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(channels)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio.tobytes())
return base64.b64encode(buf.getvalue()).decode()
class TestASRLazyLoading:
"""测试 ASR 模型懒加载"""
def test_ensure_asr_loads_on_call(self):
tts, audio_stub = _reload_tts_asr_with_mocks()
assert tts._asr_model is None
model = tts._ensure_asr_model()
assert model is not None
def test_ensure_align_loads_on_call(self):
tts, audio_stub = _reload_tts_asr_with_mocks()
assert tts._align_model is None
model = tts._ensure_align_model()
assert model is not None
class TestASREndpoint:
"""测试 ASR 端点逻辑"""
def test_asr_basic_recognition(self, fastapi_testclient=None):
"""ASR 端点应正确返回识别结果"""
tts, _ = _reload_tts_asr_with_mocks()
# Mock the model to return known values
tts._asr_model = MagicMock()
output = types.SimpleNamespace()
output.text = "你好世界"
output.language = "zh-CN"
tts._asr_model.generate.return_value = output
wav_b64 = _make_wav_bytes()
req = tts.ASRRequest(audio_base64=wav_b64)
# Call generate directly (simulating endpoint logic)
audio_bytes = base64.b64decode(req.audio_base64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
raw = wf.readframes(wf.getnframes())
arr = np.frombuffer(raw, dtype=np.int16)
arr = arr.astype(np.float32) / 32768.0
result = tts._asr_model.generate(arr, language=req.language)
assert result.text == "你好世界"
def test_asr_stereo_to_mono(self):
"""立体声音频应被正确转换为单声道"""
wav_b64 = _make_wav_bytes(channels=2)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
assert wf.getnchannels() == 2
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16)
# Convert to mono
audio_array = np.mean(audio_array.reshape(-1, 2), axis=1)
assert audio_array.ndim == 1
def test_asr_resample_to_16k(self):
"""非 16kHz 音频应被重采样"""
wav_b64 = _make_wav_bytes(sr=48000, duration_sec=0.5)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
assert wf.getframerate() == 48000
def test_asr_44100_resample(self):
"""44.1kHz 常见采样率应被重采样到 16k"""
wav_b64 = _make_wav_bytes(sr=44100, duration_sec=1.0)
audio_bytes = base64.b64decode(wav_b64)
wav_buffer = io.BytesIO(audio_bytes)
with wave.open(wav_buffer, 'rb') as wf:
framerate = wf.getframerate()
n_frames = wf.getnframes()
raw_data = wf.readframes(n_frames)
audio_array = np.frombuffer(raw_data, dtype=np.int16)
# Simulate resample calculation
if framerate != 16000:
n_samples = int(len(audio_array) * 16000 / framerate)
else:
n_samples = len(audio_array)
expected_16k_samples = int(1.0 * 16000)
assert abs(n_samples - expected_16k_samples) < 2
class TestASRModelDownload:
"""测试 ASR 模型下载路径"""
def test_load_asr_from_path_success(self):
tts, _ = _reload_tts_asr_with_mocks()
with patch('backend.tts_asr.snapshot_download', return_value='/fake/asr'): # type: ignore
tts._load_asr_models()
assert tts._asr_model is not None
def test_load_asr_skips_without_mlx(self):
"""不注入 MLX stub 时应跳过 ASR"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
import tts_asr # noqa: F811
assert tts_asr.Qwen3ASRModel is None
def test_load_align_from_path(self):
tts, _ = _reload_tts_asr_with_mocks()
with patch('backend.tts_asr.snapshot_download', return_value='/fake/align'): # type: ignore
tts._load_asr_models()
assert tts._align_model is not None
class TestModelConstants:
"""测试模型 ID 常量"""
def test_asr_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "aufklarer" in tts.ASR_MODEL_ID_MS
def test_align_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "ForcedAligner" in tts.ALIGN_MODEL_ID_MS
def test_tts_model_id(self):
tts, _ = _reload_tts_asr_with_mocks()
assert "Qwen3-TTS" in tts.MODEL_ID_MS
class TestHFEndpointMirror:
"""测试镜像站配置"""
def test_hf_endpoint_set(self):
tts, _ = _reload_tts_asr_with_mocks()
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
def test_hf_endpoint_default(self):
"""即使环境变量未设置,模块也应默认设置镜像"""
for mod_name in list(sys.modules.keys()):
if 'tts_asr' in mod_name or 'mlx' in mod_name:
del sys.modules[mod_name]
if "HF_ENDPOINT" in os.environ:
del os.environ["HF_ENDPOINT"]
import tts_asr # noqa: F811
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"