Files
SoundGame Dev f0f0a9b50e 初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线
- 共享引擎(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 模板
2026-08-29 13:00:34 +08:00

366 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# sa3_mlx — Stable Audio 3 in pure MLX
Apple-Silicon-native inference for **Stable Audio 3**, with no PyTorch,
transformers, or stable-audio-tools at runtime.
## Quick Install
One line on a fresh Apple Silicon Mac — installs everything and plays
back ~2 minutes of "Impending tribal, epic orchestral buildup":
```bash
curl -LsSf https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/mlx/bootstrap.sh | bash
```
Already cloned the repo? Run from inside `optimized/mlx/`:
```bash
./install.sh # one-time setup
./sa3 --prompt "Impending tribal, epic orchestral buildup" --play # generates + plays
```
## Three models, three modes
| `--dit` | model | best for |
|------------|--------------------|--------------------------------|
| `sm-music` | sa3-sm-music (50 M block) | fast music generation |
| `sm-sfx` | sa3-sm-sfx (50 M block) | sound effects |
| `medium` | sa3-medium-ARC (1.4 B) | higher-quality music, slower |
| mode | flags | example |
|------------------|-----------------------------------------------|----------------------------------|
| text-to-audio | `--prompt P` | new clip from a description |
| audio-to-audio | `--prompt P --init-audio IN.wav --init-noise-level σ` | variation of an existing clip |
| inpainting | `--prompt P --init-audio IN.wav --inpaint-range "S,E"` | regenerate one section, keep rest |
| CFG + negative | `--cfg 3.0 --negative-prompt P_NEG` | steer toward / away from prompts |
```
prompt ─▶ T5Gemma encoder ─▶ DiT pingpong sampler ─▶ SAME-S/L decoder ─▶ WAV
▲
optional: encoder + init audio (audio-to-audio / inpaint)
```
## Install
```bash
./install.sh
```
`install.sh` is uv-based. On a fresh machine it will:
1. Install [uv](https://github.com/astral-sh/uv) via the official curl
installer if it's missing (prompts y/N; `-y` skips the prompt).
2. Create a project-local `.venv/` with managed Python 3.11.
3. `uv pip install` the runtime deps into the venv (much faster than pip).
4. Ask which DiT bundles to download from HuggingFace
(`stabilityai/stable-audio-3-optimized`). Each pick pulls its matching
audio codec; T5Gemma (the shared text encoder) is downloaded once.
Already-present weights are skipped.
End-to-end on a fresh machine: **~10 seconds** + weight downloads.
> Don't want to pre-pick bundles? Skip install entirely and just run
> `./sa3 --prompt …` — any missing weight file is downloaded from HF on
> first use and symlinked into `models/mlx/` from the HuggingFace cache.
Apple Silicon only (MLX is Metal-backed). Python 3.10+. `./install.sh
--python 3.12` to pin a different Python.
## Run
`./sa3` is a thin shell wrapper around `uv run python scripts/sa3_mlx.py
"$@"` that prompts to run `./install.sh` if uv or `.venv/` isn't set up.
```bash
# Text-to-audio
./sa3 --prompt "lofi house loop" --dit sm-music --decoder same-s --out lofi.wav
# Sound effects
./sa3 --prompt "footsteps on gravel" --dit sm-sfx --decoder same-s --out steps.wav
# Higher-quality music (medium DiT)
./sa3 --prompt "A beautiful piano arpeggio grows into a cinematic climax" \
--dit medium --decoder same-l --seconds 30 --out piano.wav
# Audio-to-audio variation (σmax 0.4-0.8 typical)
./sa3 --prompt "jazz fusion with electric piano" --dit sm-music --decoder same-s \
--init-audio funk.wav --init-noise-level 0.7 --out funk_jazz.wav
# Inpaint seconds 4-7
./sa3 --prompt "explosive drum break" --dit sm-music --decoder same-s \
--init-audio funk.wav --inpaint-range "4,7" --out funk_drums.wav
# CFG + negative prompt
./sa3 --prompt "ambient drone" --cfg 3.0 --negative-prompt "drums, vocals" \
--dit sm-music --decoder same-s --out drone.wav
# Apply a LoRA finetune (merged into the DiT at load; base must match --dit)
./sa3 --prompt "arabic maqam oud taqsim" --dit medium --decoder same-l \
--lora ./my_lora.safetensors --lora-strength 1.0 --out maqam.wav
# Per-LoRA strength + sampling-step gating (skipping step 1 often helps).
# steps= is 1-based inclusive: 2-8, 2- (2..last), -4 (1..4), 3 (just step 3).
# Step-gated adapters are re-merged in place at the few step boundaries
# (~80 ms each on medium) — no extra memory, every step runs at full speed.
./sa3 --prompt "progressive metal instrumental" --dit medium --decoder same-l \
--lora plini.safetensors strength=0.8 steps=2- \
--lora rain_texture.safetensors steps=-4 --out prog.wav
# Generate + play immediately (afplay; Ctrl-C stops both)
./sa3 --prompt "rainforest" --dit sm-sfx --decoder same-s --play
# All options + categorised examples
./sa3 --help
```
Omit `--dit` / `--decoder` for an interactive arrow-key picker. Omit
`--prompt` for a stdin prompt. Relative `--out` paths land in `output/`
(auto-created); absolute paths are honoured as-is. The output path is
printed prominently as a `▸ saved` line at the end of each run.
### Without the wrapper
```bash
uv run python scripts/sa3_mlx.py --prompt "..." --dit medium --decoder same-l
# or, after `source .venv/bin/activate`:
python scripts/sa3_mlx.py --prompt "..." --dit medium --decoder same-l
```
## Web UI (gradio)
```bash
./sa3-gradio # public gradio.live share link by default
./sa3-gradio --no-share # local-only (http://127.0.0.1:7860)
./sa3-gradio --dit medium # initial model (switchable in the UI)
```
Every generation mode is wired: text-to-audio, CFG 0–10 (0 = the negative prompt
takes over, 0.5 = halfway between both prompts, >1 = extrapolate), audio-to-audio
(guide audio + σmax slider), and inpainting (separate reference audio + start/end
range sliders) — a2a and inpainting combine, so an inpainted span can regenerate
from the guide audio instead of noise. Each clip renders with a 3-band tinted stereo mel
spectrogram (bass=red / mid=green / high=blue). Models hot-swap from the
dropdowns and cache in unified memory, so switching back is instant; WAVs land
in `output/gradio/`. The UI needs two extra packages (`gradio`, `pillow`) —
`./sa3-gradio` offers to install them into `.venv` on first run.
## Speed & memory
Measured on **M1 8 GB** (the slowest M-chip). Newer chips are faster —
roughly **1.2–1.4× on M2**, **1.5–2× on M3**, **2–3× on M4**, especially
on the memory-bandwidth-bound seq=1 attention path.
| `--dit` | Wall (10 s clip) | × realtime | Peak RAM |
|-----------------------|------------------|------------|----------|
| `sm-music` / `sm-sfx` | ~1 s | ~10× | 1.6 GB |
| `medium` | ~5 s | ~2× | 3.8 GB |
Peak RAM assumes `--free-models` (default on) — T5Gemma is freed after
conditioning and the DiT after sampling, so the decoder never competes
with the upstream models for memory. 8 GB M1 is fine for everything;
disable with `--no-free-models` only if you have headroom and plan to
batch multiple runs in one process.
### Benchmark yourself
Reproduce the table above on your own machine — sweeps both models across
4 clip lengths (5 s / 30 s / 120 s / 380 s) and prints an ASCII summary.
Each cell runs in its own subprocess so peak RAM is measured cleanly per
run; weights are pre-warmed via `ensure_local()` so HF download time
doesn't pollute the timings. Takes ~3-10 min depending on your chip.
```bash
uv run --no-project python scripts/benchmark.py
```
Sample run on **M4 Pro / 48 GB**:
```
┌───────────┬─────────┬─────────┬───────────┬───────────┬────────────┐
│ model │ decoder │ seconds │ wall (s) │ ×realtime │ peak RAM │
├───────────┼─────────┼─────────┼───────────┼───────────┼────────────┤
│ sm-music │ same-s │ 5 │ 0.80 │ 6.27× │ 1.62 GB │
│ sm-music │ same-s │ 30 │ 1.53 │ 19.61× │ 1.94 GB │
│ sm-music │ same-s │ 120 │ 4.12 │ 29.13× │ 2.38 GB │
│ sm-music │ same-s │ 380 │ 13.46 │ 28.24× │ 2.58 GB │
├───────────┼─────────┼─────────┼───────────┼───────────┼────────────┤
│ medium │ same-l │ 5 │ 2.20 │ 2.27× │ 3.82 GB │
│ medium │ same-l │ 30 │ 5.06 │ 5.92× │ 3.89 GB │
│ medium │ same-l │ 120 │ 14.68 │ 8.17× │ 5.21 GB │
│ medium │ same-l │ 380 │ 47.78 │ 7.95× │ 5.05 GB │
└───────────┴─────────┴─────────┴───────────┴───────────┴────────────┘
```
## LoRA training
Finetune SA3 on your own audio, entirely in MLX (no PyTorch) — a full training
CLI that replicates [underfit](https://github.com/dada-bots/underfit)'s
conventions, defaults, and checkpoint format (so it can run as underfit's
Apple-Silicon backend). Three steps: **pre-encode → train → generate**. See
`TRAINING_CONVENTIONS.md` for the complete convention inventory and the
torch(MPS)-vs-MLX forward+backward parity results.
> **Want a UI instead of the CLI? Use [underfit](https://github.com/dada-bots/underfit).**
> It's a full LoRA-training dashboard that drives *this* MLX trainer as its
> Apple-Silicon backend — dataset scanning/encoding, live loss + loss-by-timestep
> curves, demo MP3s with spectrograms, per-checkpoint gradio launch, and
> one-click model downloads. On a Mac it's the recommended way to train; follow
> underfit's *Apple-Silicon quickstart*. The commands below are the lower-level
> path — good for scripting, CI, or headless runs.
**1. Pre-encode** your audio to SAME latents (once, offline — torch-free):
```bash
uv run python scripts/pre_encode_mlx.py \
--audio-dir ~/my-clips --output-dir ~/my-latents --codec same-s
```
Each file becomes `<stem>.npy` latents `[D, T]` + a `<stem>.json` sidecar
(duration, padding mask, tags) — the exact format underfit's dataset uses.
Use `same-s` for sm-music/sm-sfx, `same-l` for medium.
**2. Train.** Training uses the **BASE** checkpoint (`stabilityai/stable-audio-3-*-base`,
rectified_flow), *not* the shipped ARC weights inference uses. The base-model npz is
**auto-downloaded** from HuggingFace on first run — no flag needed (override with
`--dit-weights <path>`; regenerate one yourself from a `-base` repo with
`scripts/export_base_npz.py`):
```bash
uv run python scripts/lora_train_mlx.py \
--dit sm-music --latents-dir ~/my-latents --lr 1e-4 --name my-lora \
--adapter-type dora-rows --rank 16 --max-steps 2000
```
Defaults mirror underfit (dora-rows / rank 16, AdamW, uniform sampler + the
"full" distribution shift, CFG-dropout 0.1, signal-only masked rectified-flow
loss, checkpoint every 1000 steps). To match the full dashboard template config,
add `--timestep-sampler trunc_logit_normal --use-effective-length --beta2 0.95
--weight-decay 0.01 --lr-scheduler inverse --lr-warmup 0.995`. Checkpoints land at
`output/runs/<name>/<uuid>/checkpoints/<name>-step=S-epoch=E.safetensors` with
`lora_config` metadata; resume with `--lora-ckpt-path <ckpt>`.
Optional training-time demos (RF-Euler inference → mp3, underfit's on-disk
format): `--demo-every 1000 --demo-config demos.json`, where `demos.json` is a
list of `{"prompt", "cfg", "seed", "steps", "lora_strength"?, "lora_interval_max"?}`.
**3. Generate** with your adapter — the checkpoint applies to the shipped ARC
model at inference via `--lora` (see "Apply a LoRA finetune" above; also
`scripts/sa3_gradio.py --lora <ckpt>` to open the web UI with it preloaded):
```bash
./sa3 --dit sm-music --decoder same-s --prompt "..." --out finetuned.wav \
--lora "output/runs/my-lora/"*"/checkpoints/my-lora-step=2000-epoch="*.safetensors
```
### Building your own loop
The CLI is assembled from reusable pieces: `inject_trainable_lora` /
`save_lora_checkpoint` / `apply_lora_checkpoint` (`models/defs/lora.py` — all 9
LoRA/DoRA/BoRA/-XS types with the no-weight-materialization forward), the RF
loss + samplers + distribution shift (`models/defs/training.py`), the
pre-encoded dataset + prompt templates (`models/defs/latent_dataset.py`), and
`encode_audio` (`models/defs/audio_encoding.py`). Checkpoints use the same
safetensors keys + `lora_config` metadata as the PyTorch trainer, so adapters
are interchangeable in either direction.
## Flag reference
| Flag | Default | Notes |
|-----------------------|----------|-----------------------------------------------------------------------|
| `--prompt` | (asks) | Text prompt; empty string = unconditional |
| `--negative-prompt` | — | CFG uncond branch; only used when `--cfg ≠ 1.0` |
| `--dit` | (asks) | `sm-music`, `sm-sfx`, or `medium` |
| `--decoder` | (asks) | `same-s` (pairs with sm-*) or `same-l` (pairs with medium) |
| `--seconds` | 30 | Output length |
| `--steps` | 8 | Pingpong sampler steps; 1 = single forward (fastest), 8 = sweet spot |
| `--seed` | random | Set for reproducibility; the chosen seed is printed at the end |
| `--cfg` | 1.0 | Guidance scale; 1.0 = off, >1 toward prompt, <1 toward uncond |
| `--apg` | 1.0 | Adaptive Projected Guidance; only matters when `--cfg ≠ 1` |
| `--init-audio` | — | WAV (44.1 kHz, 16-bit PCM) input for audio-to-audio / inpaint |
| `--init-noise-level` | 1.0 | σmax; 0.4–0.8 typical for variation, 1.0 = full regen, >1 = overshoot |
| `--inpaint-range` | — | `START,END` seconds; regenerate that span, keep the rest |
| `--dit-dtype` | fp16 | DiT compute dtype (decoder always FP32; T5Gemma always fp16) |
| `--lora` | — | A `.safetensors` LoRA adapter (SA3-native/underfit or PEFT) with optional `strength=S` and `steps=MIN-MAX` tokens; repeat the flag to stack adapters. Full-range adapters merge at load; step-gated ones re-merge in place at step boundaries. Pickle `.ckpt/.pt` is refused. Base must match `--dit` |
| `--lora-strength` | 1.0 | Default strength for adapters without their own `strength=`; 0 = bit-exact bypass, >1 amplifies |
| `--free-models` | on | Progressive model freeing; `--no-free-models` keeps them resident |
| `--out` | out.wav | Relative → `output/<file>`; absolute → as-is. 16-bit PCM stereo @ 44.1 kHz, trimmed to exactly `--seconds` |
| `--play` | off | After writing, play via `afplay`; Ctrl-C stops both processes |
## Files
```
sa3_mlx/
├── sa3 ← shell wrapper (use this)
├── install.sh ← uv bootstrap (run once)
├── README.md
├── TRAINING_CONVENTIONS.md ← LoRA-training conventions + MPS-vs-MLX parity
├── requirements.txt
├── output/ ← default landing zone for generated WAVs
├── scripts/
│ ├── sa3_mlx.py ← orchestrator CLI (invoked by ./sa3)
│ ├── weights.py ← weights manifest + HF auto-download
│ ├── examples.py ← shared examples block (--help + post-install)
│ ├── install.py ← install.sh's Python half (bundle picker)
│ ├── test_all_configs.py ← npz + CLI config sanity tests
│ ├── benchmark.py ← wall-time + peak-RAM matrix across model × duration
│ ├── lora_train_mlx.py ← LoRA training CLI (underfit conventions)
│ ├── pre_encode_mlx.py ← audio → SAME-latent pre-encode (torch-free)
│ ├── sa3_gradio.py ← web UI (invoked by ./sa3-gradio; --lora preload)
│ ├── test_lora_merge.py ← adapter-math tests (weight-free; run in CI)
│ └── parity_forward_{torch,mlx}.py ← torch(MPS)-vs-MLX fwd+bwd parity harness
└── models/
├── defs/
│ ├── sa3_pipeline.py ← sampler + conditioner + unpatch
│ ├── t5gemma_mlx.py ← T5Gemma encoder + SentencePiece wrapper
│ ├── dit_mlx.py ← small DiT (sm-music + sm-sfx)
│ ├── dit_mlx_medium.py ← medium DiT (differential attention)
│ ├── same_s_{encoder,decoder}.py ← small codec
│ ├── same_l_{encoder,decoder}.py ← large codec
│ ├── lora.py ← trainable LoRA/DoRA/BoRA adapters (9 types)
│ ├── lora_merge.py ← inference-time LoRA merge + per-step gating
│ ├── training.py ← RF loss + timestep samplers + distribution shift
│ ├── latent_dataset.py ← pre-encoded dataset + underfit prompt templates
│ ├── audio_encoding.py ← waveform → SAME latents (pre-encode bridge)
│ └── demo_mlx.py ← training-time RF-Euler demos → mp3
└── mlx/ ← .npz weights (auto-downloaded; ~8.4 GB total)
├── t5gemma_f16.npz 541 MB text encoder + tokenizer
├── dit_sm-music_f16.npz 877 MB DiT + conditioner baked in
├── dit_sm-sfx_f16.npz 877 MB DiT + conditioner baked in
├── dit_medium_f16.npz 2.77 GB DiT + conditioner baked in
├── same_s_{encoder,decoder}_f32.npz ~210 MB each shared sm-* codec
└── same_l_{encoder,decoder}_f32.npz ~1.7 GB each medium codec
```
Each DiT `.npz` bundles its small conditioner under a `cond.*` key prefix
(learned `padding_embedding` + a tiny SecondsTotalEmbedder), so the
orchestrator only loads one file per model. The two small DiTs share the
same SAME-S codec weights — they're bit-exact between checkpoints, so
only one set of small-codec npz files is shipped.
## Notes on the design
- **Mixed precision**: DiT runs FP16 (validated transparent at ~50–57 dB
PSNR vs FP32), the audio decoder always runs FP32 (SAME-S's differential
attention catastrophically cancels in FP16), T5Gemma always runs FP16.
`--dit-dtype fp32` reverts to all-FP32 for bit-exact reproducibility.
- **Padding embedding**: empty / short prompts don't see raw zeros for
pad positions — they see a learned 768-dim `cond.padding_embedding`
baked into each DiT npz. This is what makes "" (empty prompt) produce
sensible unconditional audio instead of noise.
- **Auto-download**: weights aren't bundled — they're pulled from
`stabilityai/stable-audio-3-optimized` on HF on first use and symlinked
into `models/mlx/` from the HF cache. No duplication.
- **Chunked decoding**: SAME-L can't fit a long clip into one decode pass
on 8 GB; the decoder splits into overlap-2 (SAME-S) or overlap-8 (SAME-L)
chunks with a uniform-kernel edge treatment that stays bit-exact vs an
un-chunked reference at the natural sizes.
## License & attribution
Model weights derived from Stability AI's Stable Audio 3 checkpoints.
T5Gemma text encoder from Google.
Use of the Stable Audio 3 weights is governed by the **Stability AI
Community License**. Please refer to the full terms at
<https://stability.ai/license>.