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"