初始提交: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 模板
This commit is contained in:
SoundGame Dev
2026-08-29 13:00:34 +08:00
commit f0f0a9b50e
446 changed files with 2627836 additions and 0 deletions
+391
View File
@@ -0,0 +1,391 @@
"""
Simple LoRA fine-tuning for Stable Audio 3.
Two dataset modes (exactly one required):
--data_dir Raw audio + caption pairs. Each clip needs a matching .txt file:
data_dir/
clip1.wav (or .flac, .mp3, .ogg)
clip1.txt ← text prompt for clip1
clip2.wav
clip2.txt
--encoded_dir Pre-encoded latents from pre_encode_dataset.py. Captions are
already embedded in the .json metadata — no .txt files needed:
encoded_dir/
000000000000.npy
000000000000.json
000000000001.npy
000000000001.json
Saves .safetensors LoRA checkpoints compatible with the inference model and run_gradio.py.
Usage:
uv run python scripts/train_lora.py --model medium-base --data_dir ./my_data --save_dir ./lora_out
uv run python scripts/train_lora.py --model medium-base --encoded_dir ./latents_out --save_dir ./lora_out
uv run python scripts/train_lora.py --model medium-base --data_dir ./my_data --steps 500 --rank 8
"""
# Disable HuggingFace progress bars BEFORE any imports
# This must be at the very top to take effect
import os
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
import argparse
import itertools
import json
from pathlib import Path
import torch
import pytorch_lightning as pl
from stable_audio_3.data.dataset import (
LatentDatasetConfig,
LocalDatasetConfig,
PreEncodedDataset,
SampleDataset,
collation_fn,
)
from safetensors.torch import load_file
from stable_audio_3.loading_utils import copy_state_dict
from stable_audio_3.model_configs import base_models
from stable_audio_3.factory import create_diffusion_cond_from_config
from stable_audio_3.models.lora.utils import load_lora_checkpoint
from stable_audio_3.training.diffusion import (
DiffusionCondTrainingWrapper,
DiffusionCondInpaintDemoCallback,
)
def load_model(model_name: str, device: torch.device):
if model_name not in base_models:
raise ValueError(
f"LoRA training requires a base model. Got '{model_name}', valid: {list(base_models)}"
)
model_cfg = base_models[model_name]
local_config, local_ckpt = model_cfg.resolve()
with open(local_config) as f:
model_config = json.load(f)
model = create_diffusion_cond_from_config(model_config)
copy_state_dict(model, load_file(local_ckpt))
model.to(device=device, dtype=torch.bfloat16).eval().requires_grad_(False)
if model.pretransform is not None:
model.pretransform.enable_grad = False
return model, model_config
def caption_metadata_fn(info, audio):
txt = Path(info["path"]).with_suffix(".txt")
if not txt.exists():
return {"__reject__": True}
return {"prompt": txt.read_text().strip()}
class ExceptionCallback(pl.Callback):
def on_exception(self, trainer, module, err):
print(f"{type(err).__name__}: {err}")
def train(args):
torch._dynamo.config.capture_scalar_outputs = True
torch.set_float32_matmul_precision("high")
seed = args.seed
pl.seed_everything(seed, workers=True)
model, model_config = load_model(
args.model, torch.device("cuda" if torch.cuda.is_available() else "cpu")
)
sample_rate = model.sample_rate
ds_ratio = model.pretransform.downsampling_ratio
# Align to downsampling ratio
sample_size = (int(args.duration * sample_rate) // ds_ratio) * ds_ratio
# Extract tokenizers from conditioners for pre-tokenization in DataLoader workers
tokenizers = {}
if hasattr(model, "conditioner"):
for key, cond in model.conditioner.conditioners.items():
if hasattr(cond, "tokenizer") and hasattr(cond, "max_length"):
tokenizers[key] = (cond.tokenizer, cond.max_length)
if args.encoded_dir:
dataset = PreEncodedDataset(
[LatentDatasetConfig(id="train", path=args.encoded_dir)],
latent_crop_length=sample_size // ds_ratio,
random_crop=True,
)
else:
dataset = SampleDataset(
[
LocalDatasetConfig(
id="train",
path=args.data_dir,
custom_metadata_fn=caption_metadata_fn,
)
],
sample_size=sample_size,
sample_rate=sample_rate,
force_channels="stereo",
)
dataloader = torch.utils.data.DataLoader(
dataset,
batch_size=args.batch_size,
shuffle=True,
num_workers=args.num_workers,
drop_last=True,
collate_fn=collation_fn,
worker_init_fn=lambda worker_id: torch.manual_seed(seed + worker_id),
)
lora_state_dict = None
if args.lora_checkpoint:
lora_state_dict, _ = load_lora_checkpoint(args.lora_checkpoint)
lora_config = {
"rank": args.rank,
"alpha": args.lora_alpha if args.lora_alpha is not None else args.rank,
"adapter_type": args.adapter_type,
"dropout": args.dropout,
"include": args.include,
"exclude": args.exclude,
}
optimizer_config = {
"diffusion": {
"optimizer": {
"type": "AdamW",
"config": {
"lr": args.lr,
"weight_decay": 0.01,
"betas": [0.9, 0.95],
},
}
}
}
training_wrapper = DiffusionCondTrainingWrapper(
model,
mask_loss_weight=1.0,
mask_padding_attention=True,
silence_extension_scale_seconds=4.0,
use_ema=False,
log_loss_info=False,
optimizer_configs=optimizer_config,
pre_encoded=bool(args.encoded_dir),
timestep_sampler="trunc_logit_normal",
timestep_sampler_options={},
inpainting_config={"mask_kwargs": {"mask_type_probabilities": [0.1, 0.8, 0.1]}},
use_effective_length_for_schedule=True,
sample_rate=model_config.get("sample_rate", 44100),
sample_size=model_config.get("sample_size"),
lora_config=lora_config,
lora_state_dict=lora_state_dict,
svd_bases_path=args.svd_bases_path,
log_every_n_steps=args.log_every,
ot_coupling=True,
base_precision=args.base_precision,
)
exc_callback = ExceptionCallback()
if args.logger == "wandb":
logger = pl.loggers.WandbLogger(project=args.name)
logger.watch(training_wrapper)
if args.save_dir and isinstance(logger.experiment.id, str):
checkpoint_dir = os.path.join(
args.save_dir,
logger.experiment.project,
logger.experiment.id,
"checkpoints",
)
else:
checkpoint_dir = None
elif args.logger == "comet":
logger = pl.loggers.CometLogger(project=args.name)
if args.save_dir and isinstance(logger.version, str):
checkpoint_dir = os.path.join(
args.save_dir, logger.name, logger.version, "checkpoints"
)
else:
print(
f"No save_dir specified, using {args.save_dir if args.save_dir else None}."
)
checkpoint_dir = args.save_dir if args.save_dir else None
elif args.logger == "csv":
logger = pl.loggers.CSVLogger(args.save_dir)
checkpoint_dir = args.save_dir if args.save_dir else None
else:
logger = None
checkpoint_dir = args.save_dir if args.save_dir else None
ckpt_callback = pl.callbacks.ModelCheckpoint(
every_n_train_steps=args.checkpoint_every, dirpath=checkpoint_dir, save_top_k=-1
)
demo_dl = torch.utils.data.DataLoader(
dataset,
batch_size=4,
shuffle=False,
num_workers=0,
drop_last=True,
collate_fn=collation_fn,
)
# Pre-fetch the first batch and cycle it so demos always use the same samples
demo_batch = next(iter(demo_dl))
_, metadata = demo_batch
for j in range(min(4, len(metadata))):
md = metadata[j]
print(
f"Demo sample {j}: prompt={md.get('prompt', '')} seconds_total={md.get('seconds_total', '')}"
)
demo_dl = itertools.cycle([demo_batch])
demo_callback = DiffusionCondInpaintDemoCallback(
demo_every=args.demo_every,
sample_size=model_config.get("sample_size"),
sample_rate=model_config.get("sample_rate"),
demo_steps=50,
num_demos=4,
demo_cfg_scales=[2, 4, 7],
demo_dl=demo_dl,
)
callbacks = [ckpt_callback, exc_callback, demo_callback]
# Combine args and config dicts
args_dict = vars(args)
args_dict.update({"model_config": model_config})
if args.logger == "comet":
logger.log_hyperparams(args_dict)
if not hasattr(args, "gradient_clip_val") or args.gradient_clip_val == 0:
args.gradient_clip_val = None
summary = pl.callbacks.ModelSummary(max_depth=2)
callbacks.append(summary)
trainer = pl.Trainer(
devices="auto",
accelerator="auto",
strategy="auto",
precision="bf16-mixed",
accumulate_grad_batches=1,
callbacks=callbacks,
logger=logger,
log_every_n_steps=1,
max_steps=args.steps,
default_root_dir=args.save_dir,
gradient_clip_val=args.gradient_clip_val,
reload_dataloaders_every_n_epochs=0,
num_sanity_val_steps=0, # If you need to debug validation, change this line
)
trainer.fit(training_wrapper, dataloader)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="Simple LoRA fine-tuning for Stable Audio 3"
)
p.add_argument("--model", choices=list(base_models), default="medium-base")
p.add_argument(
"--data_dir",
default=None,
help="Folder with audio files and matching .txt captions",
)
p.add_argument(
"--encoded_dir",
default=None,
help="Pre-encoded latent directory from pre_encode_dataset.py (.npy/.json pairs; captions embedded in .json, no .txt needed)",
)
p.add_argument("--rank", type=int, default=16)
p.add_argument(
"--lora_alpha",
type=float,
default=None,
help="LoRA alpha scaling factor (default: same as rank)",
)
p.add_argument(
"--adapter_type",
choices=[
"lora",
"dora",
"dora-rows",
"dora-cols",
"bora",
"lora-xs",
"dora-rows-xs",
"dora-cols-xs",
"bora-xs",
],
default="dora-rows",
)
p.add_argument(
"--dropout",
type=float,
default=0.0,
help="Dropout probability applied to LoRA inputs",
)
p.add_argument(
"--include",
nargs="*",
default=None,
help="Only apply LoRA to modules whose name contains one of these substrings",
)
p.add_argument(
"--exclude",
nargs="*",
default=None,
help="Skip modules whose name contains one of these substrings",
)
p.add_argument(
"--svd_bases_path",
default=None,
help="Path to pre-computed SVD bases (.pt) for -XS adapter types",
)
p.add_argument(
"--base_precision",
choices=["bf16", "bfloat16", "fp16", "float16"],
default="bf16",
help="Cast frozen base weights to lower precision (LoRA params stay fp32)",
)
p.add_argument(
"--lora_checkpoint",
default=None,
help="Path to an existing LoRA .safetensors checkpoint to resume from",
)
p.add_argument("--lr", type=float, default=1e-4)
p.add_argument("--steps", type=int, default=10_000)
p.add_argument("--batch_size", type=int, default=1)
p.add_argument(
"--duration",
type=float,
default=380.0,
help="Maximum clip duration in seconds (default 380)",
)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--logger", choices=["wandb", "comet", "csv", "none"], default="csv")
p.add_argument("--name", type=str, default="lora-finetune")
p.add_argument("--save_dir", type=str, default="./lora_checkpoints")
p.add_argument("--checkpoint_every", type=int, default=500)
p.add_argument("--log_every", type=int, default=100)
p.add_argument("--demo_every", type=int, default=500)
p.add_argument("--num_workers", type=int, default=8)
args = p.parse_args()
if not args.encoded_dir and not args.data_dir:
p.error("one of --data_dir or --encoded_dir is required")
train(args)
if __name__ == "__main__":
main()