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

261 lines
12 KiB
Python

#!/usr/bin/env python3
"""Capture FP8 calibration data for the SA3 DiT (producer-side).
``build_dit_fp8.py`` needs a calibration ``.npz``: real DiT inputs sampled
across the pingpong sampling schedule. This script produces one from nothing
but the model checkpoint, by driving the model's own ``generate()`` and
recording the six DiT engine inputs at every sampling step.
FP8 is the only quantization path in this repo that needs calibration data
(``fp16`` / ``fp32`` need none, MLX is weight-only), so this also
establishes the convention: a ``<name>.calib.npz`` whose keys are the six ONNX
input names, each a leading-axis batch of samples. The npz is a reproducible
producer artifact: gitignored, never committed, regenerated on demand.
Native by construction:
* Loads with ``loading_utils.load_diffusion_cond`` (the repo's own loader:
``create_diffusion_cond_from_config`` + ``copy_state_dict``), wrapped in a
``StableAudioModel``, so it needs only the checkpoint dir
(``model_config.json`` + ``model.safetensors`` + the bundled
``t5gemma-b-b-ul2/`` encoder, loaded locally; falls back to the config's HF
model_name if absent). No ``StableAudioModel.from_pretrained`` (its
hard-coded checkpoint registry), no ``stable-audio-tools``.
* Prompts come from the repo's own ``interface/reprompt.py``: the Music
few-shot examples (``SYSTEM_PROMPTS["Music"]``), pulled at runtime via the
module's ``_extract_examples`` so there is one source of truth, not a
copy. These are the exact post-reprompt format the model is driven with at
deployment (genre + instruments + rhythm + mood + BPM + Length).
* Captures from the real ``StableAudioModel.generate()`` with
``sampler_type="pingpong"``, ``steps=8``, ``cfg_scale=1.0`` (the settings
the model is sampled with): the conditioner, the dist-shifted sigma
schedule, and the pingpong renoise are all the real inference path, not a
re-implementation.
* Hooks the ``DiTWrapper`` forward, where conditioning arrives as the
257-token ``cross_attn_cond`` (``[t5(256) | seconds(1)]``) and the
257-channel ``local_add_cond`` (``[inpaint_masked_input(256) |
inpaint_mask(1)]``, all zeros for plain text-to-music). The trailing
seconds token is dropped to ``t5_hidden`` (256); ``seconds_total`` is the
raw duration scalar (the engine recomputes the seconds embedding itself).
The six keys and the verified mapping:
x (N, 256, L) pre-conv latent at each step
t (N,) sigma at each step
t5_hidden (N, 256, 768) raw T5Gemma hidden states (cross-attn token 257 dropped)
t5_mask (N, 256) T5 attention mask
seconds_total (N,) raw duration scalar
local_add_cond (N, 257, L) inpaint local conditioning (zeros for t2m)
Why these prompts, 1 seed, one duration:
Calibration is amax (``calibration_method="max"``), so the question is "have
we seen the tail that sets each tensor's scale", not "have we covered the
distribution". The quantized prompt-driven GEMMs (``to_cond_embed`` and the
cross-attn projections; attention BMMs are excluded by ``disable_mha_qdq``)
have activation maxima dominated by T5Gemma's systematic outlier channels,
which fire for essentially every prompt, so amax saturates after a handful of
prompts. The reprompt example set (47 prompts, a few hundred samples
over the 8 sigmas) sits comfortably inside the standard PTQ calibration band
(128-512 samples), past the knee. Broader or out-of-distribution prompts can
make calibration *worse*: under max calibration a single OOD outlier inflates
the scale and coarsens fp8 resolution for the in-distribution inputs the
engine actually serves, so the deployment-matched in-repo set is the right
default. A second seed re-rolls noise the outlier channels already maxed, so
seeds are the least useful axis. Duration is irrelevant to fp8 scales (the
seconds-embedding front-end is inside the re-applied FP32 islands, never
quantized), and a single duration keeps the npz rectangular for the build
contract.
Usage:
python make_calib.py \
--model-config <ckpt>/model_config.json \
--checkpoint <ckpt>/model.safetensors \
--out sa3-m.calib.npz
[--duration 54.0] [--steps 8] [--seed 1528] [--device cuda]
Requires (producer): torch, numpy, and this repo's ``stable_audio_3`` package
with the model checkpoint.
"""
import argparse
import json
import sys
from pathlib import Path
import numpy as np
T5_TOKENS = 256
T5_HIDDEN_DIM = 768
IO_CHANNELS = 256
LOCAL_ADD_CHANNELS = 257 # [inpaint_masked_input(256) | inpaint_mask(1)]
# Default duration: with the default 6 s duration_padding_sec this adapts to a
# latent length L=646, a representative shape inside the published sa3-m profile's
# dynamic range (min 1, opt 1292, max 4096). Calibration scales are per-tensor,
# so the capture length need not equal the profile's opt point.
DEFAULT_DURATION_S = 54.0
DEFAULT_STEPS = 8
DEFAULT_SEED = 1528
def _load_prompts() -> list:
"""The Music few-shot examples from the repo's own ``reprompt.py`` (the
exact post-reprompt format the model is driven with at deployment), via the
module's own ``_extract_examples`` parser. Imported lazily so ``--help``
doesn't pull in transformers (which the model load needs anyway)."""
from stable_audio_3.interface.reprompt import (
SYSTEM_PROMPTS, _extract_examples)
prompts = _extract_examples(SYSTEM_PROMPTS["Music"])
if not prompts:
raise RuntimeError(
"no Music examples found in reprompt.SYSTEM_PROMPTS['Music']")
return prompts
# ---------------------------------------------------------------------------
# capture
# ---------------------------------------------------------------------------
def _load_model(model_config_path: Path, checkpoint_path: Path, device: str):
"""Load via the repo's own ``load_diffusion_cond`` (config dict + safetensors)
and wrap in ``StableAudioModel`` so ``generate()`` is the real entry point.
fp32 (model_half=False) for a clean, deterministic capture; amax from fp32 is
a safe upper bound for the fp16/fp8 engine."""
from stable_audio_3.loading_utils import load_diffusion_cond
from stable_audio_3.model import StableAudioModel
with open(model_config_path) as f:
model_config = json.load(f)
# Point the T5Gemma conditioner at the encoder bundled in the checkpoint dir
# (snapshot_download lays it out as <ckpt>/<subfolder>/), so capture needs
# only the checkpoint and runs offline. Drop repo_id so a stale network repo
# can never win. Falls back to the config's HF model_name if not bundled.
dest = model_config_path.parent
for c in model_config.get("model", {}).get("conditioning", {}).get("configs", []):
if c.get("type") == "t5gemma":
cc = c.setdefault("config", {})
subfolder = cc.get("subfolder", "t5gemma-b-b-ul2")
if (dest / subfolder).is_dir():
cc["model_path"] = str(dest)
cc.pop("repo_id", None)
print(f" loading model: {checkpoint_path}", flush=True)
model = load_diffusion_cond(
model_config, str(checkpoint_path), device=device, model_half=False)
return StableAudioModel(model, model_config, device, model_half=False)
def capture(sa3, prompts, duration, steps, base_seed):
"""Hook the DiTWrapper forward and run the pingpong generate() once per
prompt. Each generate() emits exactly ``steps`` DiT calls, one per sigma, so
sigma coverage is balanced without per-sigma capping."""
rows = {"x": [], "t": [], "t5_hidden": [], "t5_mask": [], "local_add_cond": []}
dit = sa3.dit # DiTWrapper
orig_forward = dit.forward
def hook(x, t, **kw):
ca = kw["cross_attn_cond"]
mask = kw["cross_attn_mask"]
if ca.shape[1] == T5_TOKENS + 1: # drop the trailing seconds token
ca = ca[:, :T5_TOKENS]
mask = mask[:, :T5_TOKENS]
lac = kw.get("local_add_cond", None)
for i in range(x.shape[0]): # batch is 1 at cfg_scale=1.0
rows["x"].append(x[i].float().cpu().numpy())
rows["t"].append(np.array([float(t[i])], dtype=np.float32))
rows["t5_hidden"].append(ca[i].float().cpu().numpy())
rows["t5_mask"].append(mask[i].float().cpu().numpy())
if lac is not None:
rows["local_add_cond"].append(lac[i].float().cpu().numpy())
else:
rows["local_add_cond"].append(
np.zeros((LOCAL_ADD_CHANNELS, x.shape[-1]), dtype=np.float32))
return orig_forward(x, t, **kw)
try:
dit.forward = hook
for p_idx, prompt in enumerate(prompts):
sa3.generate(
prompt=prompt, duration=duration, steps=steps,
cfg_scale=1.0, sampler_type="pingpong",
seed=base_seed + p_idx,
return_latents=True, # skip decode; we only need DiT inputs
)
print(f" [{p_idx + 1}/{len(prompts)}] {len(rows['x'])} samples",
flush=True)
finally:
dit.forward = orig_forward
return rows
def _save(rows, duration, out_path: Path):
n = len(rows["x"])
if n == 0:
raise RuntimeError("captured 0 samples")
lengths = {a.shape[-1] for a in rows["x"]}
if len(lengths) != 1:
raise RuntimeError(
f"captured ragged latent lengths {sorted(lengths)} - the npz must be "
f"rectangular; use a single --duration")
L = lengths.pop()
out = {
"x": np.stack(rows["x"]).astype(np.float32),
"t": np.concatenate(rows["t"]).astype(np.float32),
"t5_hidden": np.stack(rows["t5_hidden"]).astype(np.float32),
"t5_mask": np.stack(rows["t5_mask"]).astype(np.float32),
"seconds_total": np.full(n, float(duration), dtype=np.float32),
"local_add_cond": np.stack(rows["local_add_cond"]).astype(np.float32),
}
out_path.parent.mkdir(parents=True, exist_ok=True)
np.savez(out_path, **out)
sched = sorted({round(float(t), 6) for t in out["t"]}, reverse=True)
print(f"\n wrote {out_path}: {n} samples, L={L}")
print(f" sigma schedule ({len(sched)}): {[round(s, 4) for s in sched]}")
lo, hi = float(out['t5_hidden'].min()), float(out['t5_hidden'].max())
print(f" ranges: t5_hidden [{lo:.2f}, {hi:.2f}] "
f"x [{float(out['x'].min()):.2f}, {float(out['x'].max()):.2f}] "
f"local_add_cond absmax {float(np.abs(out['local_add_cond']).max()):.3g}")
return out_path
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__.split("\n\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--model-config", required=True,
help="Path to model_config.json")
ap.add_argument("--checkpoint", required=True,
help="Path to model.safetensors")
ap.add_argument("--out", default="sa3-m.calib.npz",
help="Output calibration npz")
ap.add_argument("--duration", type=float, default=DEFAULT_DURATION_S,
help="Generation duration in seconds (one value: keeps the "
"npz rectangular; default adapts to L=646)")
ap.add_argument("--steps", type=int, default=DEFAULT_STEPS,
help="Pingpong steps (= sigmas captured per generate)")
ap.add_argument("--seed", type=int, default=DEFAULT_SEED,
help="Base seed (a per-prompt offset is derived)")
ap.add_argument("--device", default="cuda")
args = ap.parse_args()
prompts = _load_prompts()
n_expected = len(prompts) * args.steps
print(f"capturing {len(prompts)} prompts x {args.steps} sigmas = "
f"{n_expected} samples @ {args.duration}s")
sa3 = _load_model(Path(args.model_config), Path(args.checkpoint), args.device)
rows = capture(sa3, prompts, args.duration, args.steps, args.seed)
_save(rows, args.duration, Path(args.out))
return 0
if __name__ == "__main__":
sys.exit(main())