- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频 (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine 状态机接口、MainActivity 全屏封面 + TalkBack - 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局 - 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速 - 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链 - 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/ asset-pipeline(资产流水线)/ games/*(三款游戏设计文档) - 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账, 全部 ogg 资产入库可复现 - env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
148 lines
5.1 KiB
Python
148 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
try: # torch is absent in the torch-free MLX venv; MLX-only tests don't need it
|
|
import torch
|
|
import torchaudio
|
|
|
|
from stable_audio_3 import AutoencoderModel, StableAudioModel
|
|
from stable_audio_3.model_configs import ae_models, base_models
|
|
except ModuleNotFoundError:
|
|
torch = None
|
|
torchaudio = None
|
|
AutoencoderModel = StableAudioModel = None
|
|
ae_models = ()
|
|
base_models = ()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hardware detection — used by fixtures and tests to gate GPU-only paths
|
|
# ---------------------------------------------------------------------------
|
|
HAS_CUDA = torch is not None and torch.cuda.is_available()
|
|
HAS_MPS = torch is not None and torch.backends.mps.is_available()
|
|
HAS_ACCEL = HAS_CUDA or HAS_MPS
|
|
ACCEL_DEVICE = "cuda" if HAS_CUDA else ("mps" if HAS_MPS else "cpu")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI options
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def pytest_addoption(parser):
|
|
parser.addoption(
|
|
"--save-audio",
|
|
action="store_true",
|
|
default=False,
|
|
help="Save generated audio to disk. Files are written to test_audio_outputs/.",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def device():
|
|
"""Best available compute device for this session."""
|
|
return ACCEL_DEVICE
|
|
|
|
|
|
@pytest.fixture(scope="session", params=["small-music", "small-sfx", "medium"])
|
|
def sa3_model(request):
|
|
"""Session-scoped model fixture parametrized over model sizes.
|
|
|
|
small-music — loads via from_pretrained("small-music"); runs on CPU or accelerator.
|
|
small-sfx — loads via from_pretrained("small-sfx"); runs on CPU or accelerator.
|
|
medium — requires a CUDA GPU; skipped otherwise.
|
|
"""
|
|
name = request.param
|
|
|
|
if name in ("small-music", "small-sfx"):
|
|
return StableAudioModel.from_pretrained(name, device=ACCEL_DEVICE)
|
|
|
|
if name == "medium":
|
|
if not HAS_CUDA:
|
|
pytest.skip("Medium model requires a CUDA GPU — none detected")
|
|
return StableAudioModel.from_pretrained("medium", device=ACCEL_DEVICE)
|
|
|
|
|
|
@pytest.fixture(scope="session", params=list(base_models))
|
|
def sa3_base_model(request):
|
|
"""Session-scoped fixture for base (un-fine-tuned) models.
|
|
|
|
small-*-base — runs on CUDA if available, otherwise CPU (MPS is skipped because
|
|
APG projection requires float64 which MPS does not support).
|
|
medium-base — requires a CUDA GPU; skipped otherwise.
|
|
"""
|
|
name = request.param
|
|
if name == "medium-base" and not HAS_CUDA:
|
|
pytest.skip("medium-base requires a CUDA GPU — none detected")
|
|
device = "cuda" if HAS_CUDA else "cpu"
|
|
return StableAudioModel.from_pretrained(name, device=device)
|
|
|
|
|
|
@pytest.fixture(scope="session", params=list(ae_models))
|
|
def autoencoder(request):
|
|
"""Session-scoped autoencoder model fixture parametrized over AE model sizes.
|
|
|
|
same-l requires a CUDA GPU; skipped otherwise.
|
|
"""
|
|
name = request.param
|
|
if name == "same-l" and not HAS_CUDA:
|
|
pytest.skip(f"{name} requires a CUDA GPU — none detected")
|
|
|
|
return AutoencoderModel.from_pretrained(name, device=ACCEL_DEVICE)
|
|
|
|
|
|
@pytest.fixture
|
|
def maybe_save_audio(request):
|
|
"""Return a callable that saves audio to disk when --save-audio is passed.
|
|
|
|
Usage in tests:
|
|
def test_foo(sa3_model, maybe_save_audio):
|
|
audio = sa3_model.generate(prompt="drums", ...)
|
|
maybe_save_audio(audio, sr, "drums")
|
|
|
|
Files are written to test_audio_outputs/{test_name[param]}_{prompt_slug}.wav.
|
|
Does nothing when --save-audio is not set.
|
|
"""
|
|
enabled = request.config.getoption("--save-audio")
|
|
|
|
def _save(audio: torch.Tensor, sample_rate: int, prompt: str) -> None:
|
|
if not enabled:
|
|
return
|
|
out_dir = Path("test_audio_outputs")
|
|
out_dir.mkdir(exist_ok=True)
|
|
slug = re.sub(r"[^\w]+", "_", prompt).strip("_")[:40] or "audio"
|
|
test_name = request.node.name # e.g. test_text_to_audio[small]
|
|
filename = out_dir / f"{test_name}_{slug}.wav"
|
|
torchaudio.save(str(filename), audio.squeeze(0).cpu(), sample_rate)
|
|
|
|
return _save
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Environment checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not HAS_ACCEL, reason="Flash attention check requires a GPU/accelerator"
|
|
)
|
|
def test_flash_attention_available(sa3_model, request):
|
|
"""Verify flash_attn is importable on GPU environments (medium model only)."""
|
|
if request.node.callspec.params.get("sa3_model") != "medium":
|
|
pytest.skip("Flash attention check is medium-model only")
|
|
|
|
try:
|
|
import flash_attn # noqa: F401
|
|
except ImportError:
|
|
pytest.fail(
|
|
"flash_attn is not installed. Install via: uv sync --extra flash-attn"
|
|
)
|