Files
SoundGame/tools/stable-audio-3/optimized/mlx/scripts/sa3_mlx.py
T
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

892 lines
45 KiB
Python
Raw 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 text-to-audio inference in pure MLX (no stable-audio-tools needed).
Pipeline:
prompt → T5Gemma → conditioning (cross_attn + global) → DiT pingpong (8 steps)
→ SAME-S / SAME-L decoder → patched-pretransform unpatch → WAV
Usage:
python sa3_mlx.py --prompt "lofi house loop" --dit small --decoder same-s --seconds 30 --out out.wav
If --dit or --decoder is omitted, the script prompts the user interactively.
"""
from __future__ import annotations
import argparse, math, os, random, subprocess, sys, termios, time, tty, wave
from pathlib import Path
import numpy as np
import mlx.core as mx
REPO = Path(__file__).resolve().parent.parent # project root (scripts/ is one level down)
sys.path.insert(0, str(REPO)) # so `from models.defs.*` resolves
sys.path.insert(0, str(REPO / "scripts")) # so `from weights import *` resolves
from models.defs.sa3_pipeline import (
SecondsTotalEmbedder,
apply_prompt_padding,
build_pingpong_schedule,
sample_flow_pingpong,
patched_decode,
load_conditioner_from_npz,
)
from models.defs.t5gemma_mlx import T5Gemma
from weights import ensure_local, is_present
SAMPLE_RATE = 44100
SAMPLES_PER_LATENT = 4096 # PatchedPretransform downsample × SAME 16× expansion
# ─── Display helpers (ANSI color when stdout is a TTY) ───────────────────
_USE_COLOR = sys.stdout.isatty()
_RULE_W = 64
def _c(code: str, s: str) -> str:
return f"\x1b[{code}m{s}\x1b[0m" if _USE_COLOR else s
def bold(s): return _c("1", s)
def dim(s): return _c("2", s)
def cyan(s): return _c("36", s)
def yellow(s): return _c("33", s)
def green(s): return _c("32", s)
def magenta(s):return _c("35", s)
def rule(char="━", color=cyan):
print(color(char * _RULE_W))
def banner(title: str):
rule()
print(f" {bold(title)}")
rule()
def _fmt_mem(b: int) -> str:
if b >= 1024**3: return f"{b/1024**3:5.2f} GB"
return f"{b/1024**2:5.0f} MB"
def stage(idx_total: str, label: str, ms: float | None = None, peak_b: int | None = None):
"""Right-aligned timing column with dotted fill. `peak_b` is accepted for
backward-compat / tracking-via-caller but is NOT rendered inline — peak RAM
is shown only in the 'Peak RAM by stage' summary at the end."""
head = f" {cyan(idx_total)} {bold(label)}"
if ms is None:
print(head)
return
visible = len(f" {idx_total} {label}")
fill = max(2, _RULE_W - visible - 9)
dots = dim("·" * fill)
print(f"{head} {dots} {yellow(f'{ms:>5.0f} ms')}")
def sub(text: str):
print(f" {dim(text)}")
# Each DiT .npz file has the conditioner weights baked in under a "cond." key prefix.
# The codec architecture (same-s vs same-l) is fixed per DiT family.
DIT_CHOICES = {
"sm-music": {"loader": "models.defs.dit_mlx",
"ckpt": "models/mlx/dit_sm-music_f16.npz",
"default_decoder": "same-s"},
"sm-sfx": {"loader": "models.defs.dit_mlx",
"ckpt": "models/mlx/dit_sm-sfx_f16.npz",
"default_decoder": "same-s"},
"medium": {"loader": "models.defs.dit_mlx_medium",
"ckpt": "models/mlx/dit_medium_f16.npz",
"default_decoder": "same-l"},
}
DECODER_CHOICES = {
"same-s": ("models.defs.same_s_decoder", "decode_chunked", (8, 2),
"models/mlx/same_s_decoder_f32.npz"),
"same-l": ("models.defs.same_l_decoder", "decode_chunked", (128, 8),
"models/mlx/same_l_decoder_f32.npz"),
}
# Encoder paired with each decoder (used for --init-audio).
ENCODER_CHOICES = {
"same-s": ("models.defs.same_s_encoder", 32, "models/mlx/same_s_encoder_f32.npz"),
"same-l": ("models.defs.same_l_encoder", 16, "models/mlx/same_l_encoder_f32.npz"),
}
T5GEMMA_NPZ_REL = "models/mlx/t5gemma_f16.npz"
def _arrow_pick(prompt: str, options: list[str], default: str | None = None) -> str:
"""Tiny arrow-key picker — no external deps, posix termios only.
Up/Down to move, Enter to select, Ctrl-C to abort. Falls back to a
numeric prompt when stdin isn't a TTY (piped input, CI, etc.).
"""
if not sys.stdin.isatty():
print(prompt)
for i, o in enumerate(options):
mark = "*" if o == default else " "
print(f" {mark} [{i}] {o}")
s = input(f"Choose [0-{len(options)-1}] (Enter for default): ").strip()
if s == "":
return default or options[0]
if s.isdigit() and 0 <= int(s) < len(options):
return options[int(s)]
return s if s in options else (default or options[0])
idx = options.index(default) if default in options else 0
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
print(prompt)
# leave room for the options
for _ in options:
print()
try:
tty.setcbreak(fd)
while True:
# move up `len(options)` lines and redraw
sys.stdout.write(f"\x1b[{len(options)}A")
for i, o in enumerate(options):
if i == idx:
sys.stdout.write(f"\x1b[2K\x1b[36m▶ {o}\x1b[0m\n")
else:
sys.stdout.write(f"\x1b[2K {o}\n")
sys.stdout.flush()
ch = sys.stdin.read(1)
if ch == "\x1b":
seq = sys.stdin.read(2)
if seq == "[A":
idx = (idx - 1) % len(options)
elif seq == "[B":
idx = (idx + 1) % len(options)
elif ch in ("\n", "\r"):
return options[idx]
elif ch == "\x03":
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def prompt_user_if_missing(args):
"""Interactive arrow-key selection when --dit / --decoder aren't supplied."""
if args.dit is None:
args.dit = _arrow_pick("Choose DiT model:", list(DIT_CHOICES.keys()), default="medium")
print(f" → {args.dit}")
if args.decoder is None:
suggested = DIT_CHOICES[args.dit]["default_decoder"]
args.decoder = _arrow_pick("Choose audio decoder:", list(DECODER_CHOICES.keys()), default=suggested)
print(f" → {args.decoder}")
if args.seed is None:
args.seed = random.randint(0, 2**31 - 1)
return args
def load_dit(dit_name: str, T_lat: int, dtype, lora_specs=None, num_steps=None):
cfg = DIT_CHOICES[dit_name]
ckpt = ensure_local(cfg["ckpt"])
import importlib, io, contextlib
mod = importlib.import_module(cfg["loader"])
# The loader's own chatter is swallowed, but the LoRA merge summary is routed
# to stderr (not redirected) so it stays visible to callers that capture it.
lora_log = lambda m: print(m, file=sys.stderr)
with contextlib.redirect_stdout(io.StringIO()):
model = mod.load_dit(str(ckpt), T_lat=T_lat, dtype=dtype, compile_=False,
lora_specs=lora_specs, num_steps=num_steps,
lora_log=lora_log)
return model, str(ckpt)
def load_decoder(decoder_name: str, dtype):
spec = DECODER_CHOICES[decoder_name]
weights_path = ensure_local(spec[3])
import importlib, io, contextlib
mod = importlib.import_module(spec[0])
with contextlib.redirect_stdout(io.StringIO()):
model = mod.load_model(weights_path=str(weights_path), dtype=dtype, compile_=False)
chunk_fn = getattr(mod, spec[1])
chunk_cfg = spec[2]
return model, chunk_fn, chunk_cfg
# Peak unified-memory tracker (works as both pre-0.34 mx.metal.* and new mx.* API)
def _reset_peak_mem():
fn = getattr(mx, "reset_peak_memory", None) or getattr(mx.metal, "reset_peak_memory", None)
if fn: fn()
def _get_peak_mem_bytes() -> int:
fn = getattr(mx, "get_peak_memory", None) or getattr(mx.metal, "get_peak_memory", None)
return int(fn()) if fn else 0
def _free_to_pool():
"""gc.collect() + flush MLX's memory cache so freed weights actually return to the OS."""
import gc
gc.collect()
fn = getattr(mx, "clear_cache", None) or getattr(getattr(mx, "metal", None), "clear_cache", None)
if fn: fn()
_CUMULATIVE_PEAK_B = 0
_STAGE_PEAKS: list[tuple[str, int]] = [] # [(stage_label, peak_bytes)]
def _stage_peak_b(label: str | None = None) -> int:
"""Read current peak, track cumulative max + per-stage log, reset for next stage."""
global _CUMULATIVE_PEAK_B
b = _get_peak_mem_bytes()
_CUMULATIVE_PEAK_B = max(_CUMULATIVE_PEAK_B, b)
if label is not None:
_STAGE_PEAKS.append((label, b))
_reset_peak_mem()
return b
def save_wav(path: str, audio: np.ndarray, sample_rate: int = SAMPLE_RATE):
"""audio: (channels, T) float32 in [-1, 1]. Writes 16-bit PCM stereo WAV."""
if not np.isfinite(audio).all():
n_bad = int((~np.isfinite(audio)).sum())
raise RuntimeError(f"refusing to write WAV — audio contains {n_bad} non-finite samples (NaN/Inf)")
audio = np.clip(audio, -1.0, 1.0)
pcm = (audio * 32767.0).astype(np.int16).T # (T, channels) interleaved
with wave.open(path, "wb") as w:
w.setnchannels(audio.shape[0])
w.setsampwidth(2)
w.setframerate(sample_rate)
w.writeframes(pcm.tobytes())
def read_wav(path: str) -> np.ndarray:
"""Read a WAV file. Returns (2, T) float32 in [-1, 1].
Handles 16-bit PCM at 44.1 kHz natively. Falls back to ffmpeg for any
other format (24-bit, 32-bit float, 48 kHz, etc.)
Mono input is duplicated to stereo.
"""
try:
with wave.open(path, "rb") as w:
nch, sw, sr, nframes = w.getnchannels(), w.getsampwidth(), w.getframerate(), w.getnframes()
if sr == SAMPLE_RATE and sw == 2:
raw = np.frombuffer(w.readframes(nframes), dtype=np.int16).astype(np.float32) / 32767.0
if nch == 1:
return np.stack([raw, raw], axis=0) # (2, T)
return raw.reshape(-1, nch).T[:2] # (2, T)
except wave.Error:
pass # unsupported format (32-bit float, 24-bit PCM, etc.)
# Fallback: decode via ffmpeg — handles any sample rate, bit depth, or format
try:
result = subprocess.run(
["ffmpeg", "-v", "error", "-i", path,
"-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", "2", "-"],
capture_output=True, check=True,
)
except FileNotFoundError:
raise RuntimeError(
f"{path}: unsupported WAV format. Install ffmpeg to handle 24-bit/32-bit/48kHz audio:\n"
f" brew install ffmpeg\n"
f"Or convert manually: ffmpeg -i \"{path}\" -ar {SAMPLE_RATE} -ac 2 -sample_fmt s16 resampled.wav"
)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"{path}: ffmpeg failed — {e.stderr.decode().strip()}")
raw = np.frombuffer(result.stdout, dtype=np.int16).astype(np.float32) / 32767.0
return raw.reshape(-1, 2).T
def load_encoder(decoder_name: str, dtype):
"""Mirror load_decoder but for the matching encoder."""
spec = ENCODER_CHOICES[decoder_name]
weights_path = ensure_local(spec[2])
import importlib, io, contextlib
mod = importlib.import_module(spec[0])
with contextlib.redirect_stdout(io.StringIO()):
model = mod.load_model(weights_path=str(weights_path), dtype=dtype, compile_=False)
pad_modulo = spec[1]
return model, pad_modulo
def _preflight_download(args) -> None:
"""Resolve every weight file this run needs and download any that are
missing — BEFORE the banner prints and BEFORE the wall-clock starts.
Network time then isn't charged against "×realtime" and the user sees
download progress as a clearly separate setup step."""
needed = [
T5GEMMA_NPZ_REL,
DIT_CHOICES[args.dit]["ckpt"],
DECODER_CHOICES[args.decoder][3],
]
if args.init_audio:
needed.append(ENCODER_CHOICES[args.decoder][2])
missing = [p for p in needed if not is_present(p)]
if not missing:
return
print(f" Fetching {len(missing)} missing weight file(s) before starting:")
for rel in missing:
ensure_local(rel)
print()
def patch_audio(audio: np.ndarray, patch_size: int = 256) -> np.ndarray:
"""Patched-pretransform encode: (B, 2, T_audio) → (B, 512, T_audio/256).
Mirrors rearrange("b c (l h) -> b (c h) l", h=patch_size)."""
B, C, T = audio.shape
assert T % patch_size == 0, f"audio length {T} not multiple of patch_size {patch_size}"
L = T // patch_size
x = audio.reshape(B, C, L, patch_size).transpose(0, 1, 3, 2) # (B, C, patch, L)
return x.reshape(B, C * patch_size, L) # (B, 512, L)
class _HelpfulParser(argparse.ArgumentParser):
"""argparse that prints full help (not just usage) when a flag is unknown / invalid,
and tacks the shared example-commands block onto the end of -h / --help."""
def error(self, message):
sys.stderr.write(f"\nerror: {message}\n\n")
self.print_help(sys.stderr)
sys.exit(2)
def print_help(self, file=None):
super().print_help(file)
# Append the colored example-commands block. Same content as install.sh's
# final summary; rendered to stdout regardless of `file` so the colors and
# emojis don't get split between two streams when help goes to stderr.
try:
from examples import print_example_commands
print_example_commands()
except Exception:
pass # never let an examples-block failure mask the actual --help
def main():
ap = _HelpfulParser(
description="SA3 text-to-audio (+ audio-to-audio + inpainting) in pure MLX",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"modes\n"
" text-to-audio --prompt P\n"
" audio-to-audio --prompt P --init-audio IN.wav [--init-noise-level σ]\n"
" inpainting --prompt P --init-audio IN.wav --inpaint-range START,END\n"
" negative CFG --prompt P --cfg N --negative-prompt P_NEG\n"
"\nrun `sa3_mlx.py --help` for per-flag details."
),
)
# ── Inputs ────────────────────────────────────────────────────────────────
ap.add_argument("--prompt", default=None,
help="Text prompt describing the audio to generate. "
"Empty string is valid (unconditional generation). "
"If omitted, the script asks interactively via stdin.")
ap.add_argument("--negative-prompt", default=None,
help="Negative prompt for CFG's unconditional branch. "
"When --cfg=1.0 this flag has no effect (no uncond pass is run). "
"When unset and --cfg ≠ 1.0, the uncond branch uses zero embeddings.")
ap.add_argument("--init-audio", default=None,
help="Path to a WAV file (44.1 kHz, 16-bit PCM, stereo or mono) to use as the "
"starting point. Enables audio-to-audio mode (with --init-noise-level) or "
"inpainting mode (with --inpaint-range). Encoder is loaded automatically. "
"Audio is trimmed or zero-padded to match --seconds.")
ap.add_argument("--inpaint-range", default=None,
help="Inpainting time range as 'START,END' in seconds (e.g. '5,10'). "
"Requires --init-audio. The model regenerates the masked range while "
"preserving the rest of the input exactly (via paste-back).")
# ── Models ────────────────────────────────────────────────────────────────
ap.add_argument("--dit", choices=list(DIT_CHOICES.keys()), default=None,
help="DiT model. 'small' = sa3-sm-music (faster, smaller). "
"'medium' = sa3-medium-ARC (larger, higher quality). "
"If omitted, prompts interactively with arrow-key picker.")
ap.add_argument("--decoder", choices=list(DECODER_CHOICES.keys()), default=None,
help="Audio decoder. 'same-s' pairs with sa3-small (50 M params). "
"'same-l' pairs with sa3-medium (426 M params). "
"If omitted, prompts interactively with arrow-key picker.")
ap.add_argument("--dit-dtype", default="fp16", choices=["fp32", "fp16"],
help="DiT compute dtype. Default fp16 — validated transparent at ~50-57 dB PSNR "
"vs FP32. Halves DiT memory and speeds sampling ~25%%. The decoder always "
"runs FP32 (SAME-S catastrophically cancels at fp16 due to differential "
"attention) and T5Gemma is always fp16. Set to fp32 only if you need "
"bit-exact reproducibility against the PyTorch reference.")
ap.add_argument("--t5gemma-npz", default=None,
help="Path to the bundled T5Gemma FP16 .npz (weights + tokenizer). "
"Default points at models/mlx/t5gemma_f16.npz next to this script; "
"auto-downloaded from HuggingFace if not present.")
ap.add_argument("--lora", action="append", nargs="+", default=None,
metavar=("ADAPTER", "KEY=VAL"),
help="A LoRA adapter to apply to the DiT — repeat the flag for several. "
"Each --lora takes the adapter path followed by optional key=value "
"options: strength=S (default --lora-strength) and steps=RANGE, a "
"1-based inclusive sampling-step range: 2-8, 2- (2..last), -4 (1..4) "
"or 3 (just step 3; default: all steps). Example: "
"--lora plini.safetensors strength=0.8 steps=2- . Adapters covering "
"every step are merged at load; step-gated ones are re-merged in "
"place at the (few) step boundaries — ~80 ms each on medium, no "
"extra memory, every step runs at full speed. The adapter is a "
".safetensors file (SA3-native train_lora.py / underfit output) or "
"a PEFT adapter directory (with its adapter_config.json). ONLY "
".safetensors is accepted — a pickle .ckpt/.pt is refused (it would "
"execute code on load). The adapter's base must match --dit.")
ap.add_argument("--lora-strength", type=float, default=1.0,
help="Default strength for --lora adapters without their own strength= "
"(default 1.0). 0 disables (bit-identical to no LoRA); >1 amplifies.")
# ── Sampling ──────────────────────────────────────────────────────────────
ap.add_argument("--seconds", type=float, default=30.0,
help="Output audio length in seconds. T_lat (latent positions) is derived as "
"ceil(seconds * 44100 / 4096) — a natural ceil that depends ONLY on "
"--seconds (and --seed via the DiT), never on --decoder. This keeps the "
"sampled latent (and hence the music) identical across decoders for a "
"given prompt/seed. Final WAV is trimmed to exactly --seconds.")
ap.add_argument("--steps", type=int, default=8,
help="Number of pingpong sampling steps. Minimum 1 (single forward pass — fastest, "
"lowest quality). rf_denoiser is distilled for 8 (default — sweet spot). "
">8 gives diminishing returns and may overshoot. The LogSNR schedule "
"auto-computes for any N: steps=N produces N+1 sigma values from σmax→0. "
"Sample wall time scales ~linearly with steps; quality/coherence improves "
"noticeably from 1→4 and 4→8, less from 8→16.")
ap.add_argument("--seed", type=int, default=None,
help="Random seed (any int). Set this for reproducible outputs. "
"If omitted, a random seed is chosen and printed in the final 'done' line.")
ap.add_argument("--init-noise-level", type=float, default=1.0,
help="σmax — the schedule's starting noise level (always honored regardless of mode). "
"Valid range: [0.01, ∞). Below 0.01 the script errors because the model is "
"undefined at t≈0 and produces NaN. With --init-audio: 0.4-0.8 is typical for "
"variation; 1.0 = full regeneration (init ignored). Without --init-audio: 1.0 "
"is standard text-to-audio; <1.0 is a creative effect (model sees pure noise but "
"with t=σmax timestep — mildly OOD); >1.0 is 'guidance overshoot' (more diverse, "
"increasingly weird).")
ap.add_argument("--cfg", type=float, default=1.0,
help="Classifier-Free Guidance scale. 1.0 = off (single forward pass, the rf_denoiser "
"default). >1.0 pushes toward the prompt (classic CFG). [0, 1) pulls toward the "
"unconditional / negative branch (less prompt-aligned, more diverse). <0 actively "
"pushes AWAY from the prompt. Any value ≠ 1.0 costs ~2× per step (batched cond + "
"uncond forward).")
ap.add_argument("--apg", type=float, default=1.0,
help="Adaptive Projected Guidance scale [0..1], only matters when --cfg ≠ 1.0. "
"1.0 = full APG (project the cond−uncond difference orthogonal to cond_denoised; "
"prevents over-saturation at high CFG). 0.0 = vanilla CFG (use the full "
"difference). Intermediate values blend the two. rf_denoiser default is 1.0.")
# ── Memory / runtime ──────────────────────────────────────────────────────
ap.add_argument("--free-models", action=argparse.BooleanOptionalAction, default=True,
help="Free each model after its last use (T5Gemma after step 2, encoder after "
"step 3a, DiT after step 3b) to minimize peak RAM. Lowers decode-stage peak "
"by 3-5 GB on sa3-medium since the DiT (2.7 GB FP16) doesn't sit idle while "
"the decoder runs. Use --no-free-models to keep everything resident — only "
"useful if you plan to call models multiple times in one run (currently the "
"script doesn't). Default: on.")
# ── Output ────────────────────────────────────────────────────────────────
ap.add_argument("--out", default=None,
help="Output WAV path. Relative paths land in the project's output/ "
"directory (auto-created); absolute paths are used as-is. "
"Always written as 16-bit PCM stereo at 44.1 kHz, trimmed to "
"exactly --seconds. If omitted, auto-named from the prompt and seed.")
ap.add_argument("--play", action="store_true",
help="After writing the WAV, play it through the default output device "
"via the macOS `afplay` binary. Blocking — the script exits when "
"playback finishes. Ctrl-C stops playback and exits the script "
"(SIGINT is delivered to both processes).")
args = ap.parse_args()
if args.steps < 1:
ap.error(f"--steps must be ≥ 1 (got {args.steps})")
# Parse --lora groups into specs (fail fast, before any model loads).
args.lora_specs = None
if args.lora:
from models.defs.lora_merge import parse_lora_spec, LoraError
try:
args.lora_specs = [parse_lora_spec(toks, args.lora_strength)
for toks in args.lora]
except LoraError as e:
ap.error(str(e))
args = prompt_user_if_missing(args)
if args.prompt is None:
args.prompt = input("Prompt: ").strip()
# Resolve output path — auto-name from prompt+seed when --out is not given.
if args.out is None:
import re as _re
slug = _re.sub(r'[^a-z0-9]+', '_', args.prompt.lower()).strip('_')[:48]
args.out = f"{slug}_{args.seed}.wav" if slug else f"out_{args.seed}.wav"
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = REPO / "output" / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
args.out = str(out_path)
# Empty prompt is allowed — T5Gemma will produce padding-only embeddings,
# which (with the learned padding_embedding) is the unconditional case.
dtype = mx.float32 if args.dit_dtype == "fp32" else mx.float16
# T_lat is a DECODER-INDEPENDENT function of --seconds only (natural ceil, no
# even-bump). This matches the TensorRT pipeline (sa3_trt.py::resolve_T_lat) so
# the same prompt/seed/seconds yields the SAME latent — and hence the same music —
# regardless of --decoder. The old code bumped T_lat to even for same-s, which made
# make_noise(T_lat, seed) draw a different noise tensor and thus sample a completely
# different latent just by switching decoder. SAME-S's internal-length constraint
# (T_lat*17 must align to 34, i.e. even) is satisfied by the decode dispatch below:
# odd T_lat is routed through decode_chunked, whose every window is an even kernel.
T_lat = max(1, math.ceil(args.seconds * SAMPLE_RATE / SAMPLES_PER_LATENT))
target_dur = T_lat * SAMPLES_PER_LATENT / SAMPLE_RATE
# Inpaint validation + parameter mapping
inpaint_range = None
if args.inpaint_range is not None:
if args.init_audio is None:
sys.exit("error: --inpaint-range requires --init-audio (the audio to inpaint into)")
try:
s_str, e_str = args.inpaint_range.split(",")
inp_start_sec = float(s_str.strip()); inp_end_sec = float(e_str.strip())
except ValueError:
sys.exit(f"error: --inpaint-range must be 'START,END' in seconds; got {args.inpaint_range!r}")
if not (0 <= inp_start_sec < inp_end_sec <= args.seconds):
sys.exit(f"error: invalid inpaint range {inp_start_sec}-{inp_end_sec}s "
f"(must satisfy 0 <= start < end <= {args.seconds}s)")
inp_start_lat = max(0, int(round(inp_start_sec * SAMPLE_RATE / SAMPLES_PER_LATENT)))
inp_end_lat = min(T_lat, int(round(inp_end_sec * SAMPLE_RATE / SAMPLES_PER_LATENT)))
inpaint_range = (inp_start_lat, inp_end_lat)
# σmax is always taken from --init-noise-level, regardless of mode. The mode
# only changes how σmax is *used* (init mix vs schedule-only vs inpaint init).
sigma_max = float(args.init_noise_level)
mode = ("inpaint" if inpaint_range else
"audio-to-audio" if args.init_audio else "text-to-audio")
# Reject very low σmax in any mode — the diffusion model was never trained
# at t≈0 and produces NaN/garbage there.
MIN_SIGMA = 0.01
if sigma_max < MIN_SIGMA:
sys.exit(
f"error: --init-noise-level={sigma_max} is too low (σmax min is {MIN_SIGMA}). "
f"The rf_denoiser model is undefined at t≈0; below {MIN_SIGMA} the schedule "
f"collapses and the model emits NaN. Pass --init-noise-level=1.0 for normal "
f"text-to-audio, or use the input WAV directly if you want it unchanged."
)
# ── Preflight: ensure every weight file this run needs is on disk BEFORE
# we print the banner or start the wall-time clock. ensure_local() prints
# a "↓ downloading …" line per missing file and symlinks from the HF cache.
_preflight_download(args)
global _CUMULATIVE_PEAK_B
_CUMULATIVE_PEAK_B = 0
_STAGE_PEAKS.clear()
_reset_peak_mem()
t_wall_start = time.time()
print()
banner(f"SA3 → MLX {mode}")
k = lambda s: dim(f"{s:>10}")
v = lambda s, w=10: f"{s:<{w}}"
print(f" {k('prompt')} {bold(repr(args.prompt))}")
if args.negative_prompt:
suffix = "" if args.cfg != 1.0 else dim(" (ignored: --cfg=1.0, no uncond branch)")
print(f" {k('neg prompt')} {bold(repr(args.negative_prompt))}{suffix}")
dit_decoder_line = f" {k('dit')} {magenta(v(args.dit))} {k('decoder')} {magenta(v(args.decoder))}"
if args.init_audio:
dit_decoder_line += f" {k('encoder')} {magenta(v(args.decoder))}"
print(dit_decoder_line)
if args.init_audio:
print(f" {k('init audio')} {bold(args.init_audio)}")
if inpaint_range:
s0, s1 = inpaint_range
print(f" {k('inpaint')} {bold(f'{inp_start_sec:.2f}s..{inp_end_sec:.2f}s')} "
f"{dim(f'(latent {s0}..{s1} of {T_lat})')}")
print(f" {k('σmax')} {bold(f'{sigma_max:.2f}')}")
print(f" {k('seconds')} {v(f'{args.seconds}s')} {k('steps')} {v(args.steps)} {k('seed')} {args.seed}")
cfg_label = f"{args.cfg}"
if args.cfg != 1.0:
cfg_label += f" (apg={args.apg})"
print(f" {k('DiT dtype')} {v(args.dit_dtype)} {k('cfg')} {cfg_label}")
print(f" {k('T_lat')} {T_lat} {dim(f'({target_dur:.2f}s → trimmed to {args.seconds}s)')}")
print()
# ── 1. Text encoder ──
t0 = time.time()
t5_path = args.t5gemma_npz if args.t5gemma_npz else str(ensure_local(T5GEMMA_NPZ_REL))
enc = T5Gemma.from_npz(t5_path)
embeds, mask = enc.encode([args.prompt], max_len=256)
mx.eval(embeds, mask)
stage("[1/5]", "T5Gemma encode", (time.time()-t0)*1000, peak_b=_stage_peak_b("T5Gemma encode"))
sub(f"embeds {embeds.shape} {embeds.dtype}")
# ── 2. Conditioning ──
# Conditioner weights are baked into the DiT .npz under "cond." prefix.
t0 = time.time()
padding_emb, secs_embedder = load_conditioner_from_npz(
str(ensure_local(DIT_CHOICES[args.dit]["ckpt"])), prefix="cond.")
if args.lora_specs:
# The conditioner is loaded straight from the npz, so the DiT-side merge
# never reaches it — apply any seconds-conditioner deltas (underfit
# adapters carry one) to the live embedder here. Whole-generation:
# conditioning runs once, before sampling, so steps= cannot gate it.
from models.defs.lora_merge import apply_conditioner_lora
secs_embedder.W, _n_cond = apply_conditioner_lora(secs_embedder.W,
args.lora_specs)
if _n_cond:
sub(f"lora conditioner delta applied ({_n_cond} adapter(s), whole generation)")
embeds = embeds.astype(dtype)
embeds_padded = apply_prompt_padding(embeds, mask, padding_emb.astype(dtype))
seconds_embed = secs_embedder(args.seconds).astype(dtype) # (1, 1, 768)
cross_attn = mx.concatenate([embeds_padded, seconds_embed], axis=1) # (1, 257, 768)
global_cond = seconds_embed[:, 0, :] # (1, 768)
# Negative / null cross_attn for CFG (used whenever cfg != 1.0)
null_cross_attn = None
if args.cfg != 1.0:
if args.negative_prompt:
neg_embeds, neg_mask = enc.encode([args.negative_prompt], max_len=256)
mx.eval(neg_embeds, neg_mask)
neg_embeds = neg_embeds.astype(dtype)
neg_padded = apply_prompt_padding(neg_embeds, neg_mask, padding_emb.astype(dtype))
null_cross_attn = mx.concatenate([neg_padded, seconds_embed], axis=1)
else:
# Match upstream: zeros_like(cross_attn) for the uncond branch.
null_cross_attn = mx.zeros_like(cross_attn)
mx.eval(null_cross_attn)
mx.eval(cross_attn, global_cond)
stage("[2/5]", "Conditioning", (time.time()-t0)*1000, peak_b=_stage_peak_b("Conditioning"))
sub(f"cross_attn {cross_attn.shape} global {global_cond.shape}"
+ (f" neg_cross_attn ready ({'prompt' if args.negative_prompt else 'zeros'})" if null_cross_attn is not None else ""))
# T5Gemma weights are no longer needed (we have the embeddings). Free 537 MB.
if args.free_models:
del enc
_free_to_pool()
# ── 3a. (audio-to-audio only) Encode init_audio → init_latents ──
init_latents = None
if args.init_audio:
stage("[3a]", f"Encoding init audio → latents")
t0 = time.time()
enc_model, pad_mod = load_encoder(args.decoder, mx.float32)
sub(f"encoder load {(time.time()-t0)*1000:.0f} ms")
t0 = time.time()
# The ENCODER (SAME-S) needs T_audio_patches divisible by `pad_mod` (32) → an
# even latent length. T_lat itself stays the natural-ceil (decoder-independent)
# value; we only round the encode grid UP to the modulo, then trim the latent
# back to T_lat. This keeps the DiT/noise path identical across decoders.
enc_T_lat = T_lat
if (T_lat * 16) % pad_mod != 0:
enc_T_lat = math.ceil((T_lat * 16) / pad_mod) * pad_mod // 16
target_samples = enc_T_lat * SAMPLES_PER_LATENT
audio_np = read_wav(args.init_audio) # (2, T_audio_in)
if audio_np.shape[-1] >= target_samples:
audio_np = audio_np[:, :target_samples]
init_action = f"trimmed to {target_samples} samples"
else:
pad = target_samples - audio_np.shape[-1]
audio_np = np.pad(audio_np, ((0, 0), (0, pad)), mode="constant")
init_action = f"zero-padded by {pad} samples"
audio_np = audio_np[None, ...] # (1, 2, T)
sub(f"read+prep ({init_action}) {(time.time()-t0)*1000:.0f} ms")
# Patch + encode (encoder always runs FP32 — softnorm-bottleneck-sensitive)
t0 = time.time()
patches_np = patch_audio(audio_np, patch_size=256) # (1, 512, enc_T_lat*16)
# Sanity: T_audio_patches must be divisible by encoder's required modulo
assert patches_np.shape[-1] % pad_mod == 0, (
f"T_audio_patches={patches_np.shape[-1]} not divisible by {pad_mod} "
f"(decoder={args.decoder})"
)
init_latents = enc_model(mx.array(patches_np))[..., :T_lat] # trim to natural T_lat
mx.eval(init_latents)
_stage_peak_b('Init audio encode')
sub(f"encode {(time.time()-t0)*1000:.0f} ms latents {init_latents.shape}")
init_latents = init_latents.astype(dtype)
del enc_model
# ── 3b. DiT pingpong sampling ──
stage("[3/5]", f"DiT — load + sample ({args.steps} steps, σmax={sigma_max:.2f})")
if args.lora_specs:
for s in args.lora_specs:
rng = s["steps"]
rng_str = ("all steps" if rng is None else
f"steps {rng[0] or 1}-{rng[1] if rng[1] is not None else args.steps}")
sub(f"lora {os.path.basename(s['path'].rstrip('/'))} "
f"(strength {s['strength']:g}, {rng_str})")
t0 = time.time(); dit_model, _ = load_dit(args.dit, T_lat=T_lat, dtype=dtype,
lora_specs=args.lora_specs, num_steps=args.steps)
_stage_peak_b('DiT load')
sub(f"load {time.time()-t0:.1f}s")
sigmas = build_pingpong_schedule(args.steps, sigma_max=sigma_max, use_logsnr_shift=True)
sched_str = " · ".join(f"{float(x):.3f}" for x in sigmas)
sub(f"schedule {sched_str}")
key = mx.random.key(args.seed)
pure_noise = mx.random.normal((1, 256, T_lat), dtype=dtype, key=key)
if init_latents is not None and inpaint_range is None:
# rf_denoiser init_data mix (linear interpolation, matches upstream):
# noise = init_data * (1 - σmax) + pure_noise * σmax
noise = init_latents * (1.0 - sigma_max) + pure_noise * sigma_max
sub(f"init: latent * {1-sigma_max:.2f} + noise * {sigma_max:.2f}")
else:
noise = pure_noise
mx.eval(noise)
# Build local_add_cond for inpainting: [B, T_lat, 257] = (mask, masked_input)
local_add_cond = None
paste_back = None
if inpaint_range is not None:
s0, s1 = inpaint_range
# mask: 1 = keep (context), 0 = inpaint (regenerate); shape (1, 1, T_lat)
mask_np = np.ones((1, 1, T_lat), dtype=np.float32)
mask_np[:, :, s0:s1] = 0.0
mask = mx.array(mask_np)
# masked_input: init_latents with the inpaint region zeroed
masked_input = init_latents.astype(mx.float32) * mask # (1, 256, T_lat)
# concat over channel dim → (1, 257, T_lat); DiT expects batch-last-channel → transpose
lac = mx.concatenate([mask, masked_input], axis=1).transpose(0, 2, 1).astype(dtype)
local_add_cond = lac
# Paste-back keeps unchanged regions bit-exact (model preserves them well but
# paste-back makes it guaranteed).
paste_back = (init_latents, mask)
sub(f"local_add_cond {tuple(lac.shape)} inpaint mask: {s0}..{s1} of {T_lat} latents "
f"({(s1-s0)/T_lat*100:.0f}% regenerated)")
def model_fn(x, t):
if args.cfg == 1.0:
return dit_model(x, t, cross_attn, global_cond, local_add_cond=local_add_cond)
# Batched CFG: one forward over cat([x, x]) on the batch dim.
x2 = mx.concatenate([x, x], axis=0)
t2 = mx.concatenate([t, t], axis=0)
cross2 = mx.concatenate([cross_attn, null_cross_attn], axis=0)
global2 = mx.concatenate([global_cond, global_cond], axis=0)
lac2 = None if local_add_cond is None else mx.concatenate([local_add_cond, local_add_cond], axis=0)
v_batched = dit_model(x2, t2, cross2, global2, local_add_cond=lac2)
cond_v, uncond_v = mx.split(v_batched, 2, axis=0)
# Convert to denoised space (RF: denoised = x - σ * v, with σ = t)
sigma = t.reshape(-1, 1, 1).astype(mx.float32)
cond_d = x.astype(mx.float32) - cond_v.astype(mx.float32) * sigma
uncond_d = x.astype(mx.float32) - uncond_v.astype(mx.float32) * sigma
diff = cond_d - uncond_d
if args.apg <= 0.0:
cfg_diff = diff
else:
# Project diff onto direction orthogonal to cond_d (per-sample, over (C, T)).
# fp32 throughout — fp16 has catastrophic-cancellation risk in the projection.
norm = mx.sqrt((cond_d * cond_d).sum(axis=(-2, -1), keepdims=True))
unit = cond_d / mx.maximum(norm, 1e-8)
parallel = (diff * unit).sum(axis=(-2, -1), keepdims=True) * unit
diff_orth = diff - parallel
cfg_diff = diff_orth if args.apg >= 1.0 else (args.apg * diff_orth + (1.0 - args.apg) * diff)
cfg_d = cond_d + (args.cfg - 1.0) * cfg_diff
cfg_v = (x.astype(mx.float32) - cfg_d) / sigma
return cfg_v.astype(x.dtype)
# Live in-place progress while sampling (TTY only — quiet log files).
t_step_prev = [time.time()]
def _on_step(i: int, total: int):
if not _USE_COLOR: # _USE_COLOR ≡ stdout.isatty() set at top of this file
return
now = time.time()
elapsed = (now - t_step_prev[0]) * 1000
t_step_prev[0] = now
bar_w = 20
filled = int(round(bar_w * i / total))
bar = cyan("█" * filled) + dim("·" * (bar_w - filled))
sys.stdout.write(f"\r\x1b[K {dim('sampling')} {bar} "
f"{bold(f'step {i}/{total}')} {yellow(f'{elapsed:.0f} ms')}")
sys.stdout.flush()
t0 = time.time()
_lora_plan = getattr(dit_model, "_lora_plan", None)
latents = sample_flow_pingpong(model_fn, noise, sigmas, seed=args.seed + 1,
paste_back=paste_back, on_step=_on_step,
before_step=_lora_plan.sync if _lora_plan else None)
mx.eval(latents)
sample_ms = (time.time()-t0)*1000
# Clear the progress line, then print the final summary in its place.
if _USE_COLOR:
sys.stdout.write("\r\x1b[K")
_stage_peak_b('DiT sample')
sub(f"sample {sample_ms:.0f} ms ({sample_ms/max(args.steps,1):.0f} ms/step)")
# DiT weights are no longer needed (latents are computed). Free 2.7 GB for sa3-medium.
if args.free_models:
del dit_model, model_fn
_free_to_pool()
# ── 4. Decode latents → audio patches ──
# Decoder always fp32 — SAME-S needs it (FP16 catastrophically cancels at differential attn);
# SAME-L works at fp16 but we use fp32 for max quality.
stage("[4/5]", f"Decoder ({args.decoder}, FP32)")
t0 = time.time()
dec_dtype = mx.float32
decoder, chunk_fn, (chunk, ovl) = load_decoder(args.decoder, dec_dtype)
_stage_peak_b('Decoder load')
sub(f"load {(time.time()-t0)*1000:.0f} ms")
latents_fp32 = latents.astype(dec_dtype)
t0 = time.time()
kernel = chunk + 2 * ovl
if T_lat > kernel:
# Natural-ceil T_lat may be odd; decode_chunked windows are all even kernels,
# so odd T_lat decodes correctly here (this is the common path, incl. 30s→323).
patches = chunk_fn(decoder, latents_fp32, chunk, ovl)
decode_mode = f"chunked (chunk={chunk}, ovl={ovl})"
elif T_lat % 2 == 0:
patches = decoder(latents_fp32)
decode_mode = "un-chunked"
elif T_lat > 6:
# Odd T_lat in (6, kernel]: a chunk=2,ovl=2 kernel (=6 < T_lat) keeps every
# window even for SAME-S while never zero-padding real latents.
patches = chunk_fn(decoder, latents_fp32, 2, 2)
decode_mode = "chunked (chunk=2, ovl=2)"
else:
# Tiny odd T_lat ≤ 6 (sub-0.5s): no even chunk kernel fits. Reflect-pad one
# latent to make T even, un-chunked decode, then trim the extra 16 patches.
# SAME-L has no even constraint and takes this path only for symmetry.
latents_even = mx.concatenate([latents_fp32, latents_fp32[..., -1:]], axis=-1)
patches = decoder(latents_even)[..., : T_lat * 16]
decode_mode = "un-chunked (odd-pad→trim)"
mx.eval(patches)
_stage_peak_b('Decode')
sub(f"decode {decode_mode} → {(time.time()-t0)*1000:.0f} ms patches {patches.shape}")
# ── 5. Unpatch → audio + save WAV ──
t0 = time.time()
audio = patched_decode(patches, patch_size=256, channels=2) # (1, 2, T_lat*4096)
mx.eval(audio)
audio_np = np.array(audio.astype(mx.float32))[0] # (2, T_lat*4096)
requested_samples = int(round(args.seconds * SAMPLE_RATE))
if audio_np.shape[-1] > requested_samples:
audio_np = audio_np[..., :requested_samples]
save_wav(args.out, audio_np)
stage("[5/5]", "Unpatch + write WAV", (time.time()-t0)*1000, peak_b=_stage_peak_b("Unpatch + WAV"))
peak = float(np.abs(audio_np).max()); rms = float(np.sqrt((audio_np**2).mean()))
sub(f"audio {audio_np.shape} peak {peak:.3f} rms {rms:.3f}")
# ── Totals ──
total = time.time() - t_wall_start
audio_dur = audio_np.shape[-1] / SAMPLE_RATE
peak_str = _fmt_mem(_CUMULATIVE_PEAK_B)
# Per-stage peak RAM table
print()
print(f" {bold('Peak RAM by stage')}")
name_w = max(len(n) for n, _ in _STAGE_PEAKS) if _STAGE_PEAKS else 0
sorted_for_emphasis = max((b for _, b in _STAGE_PEAKS), default=0)
for name, b in _STAGE_PEAKS:
bar_units = int(round(b / max(sorted_for_emphasis, 1) * 24))
bar = cyan("█" * bar_units) + dim("·" * (24 - bar_units))
mark = bold(" ← max") if b == sorted_for_emphasis else ""
print(f" {dim(name.ljust(name_w))} {bar} {_fmt_mem(b)}{mark}")
print()
rule()
print(f" {bold(green('done'))} "
f"{bold(f'{total:.2f}s')} wall → "
f"{audio_dur:.1f}s audio → "
f"{bold(yellow(f'{audio_dur/total:.2f}× realtime'))} "
f"{dim(f'peak RAM {peak_str}')} "
f"{dim(f'seed {args.seed}')}")
# Prominent path block: show relative-to-cwd if shorter (more readable when
# the user is already in the project), absolute otherwise (more copy-pasteable).
abs_out = os.path.abspath(args.out)
try:
rel_out = os.path.relpath(abs_out)
except ValueError:
rel_out = abs_out
shown = rel_out if len(rel_out) <= len(abs_out) and not rel_out.startswith("..") else abs_out
print(f" {bold(green('▸ saved'))} {bold(shown)} {dim(f'({abs_out})' if shown != abs_out else '')}".rstrip())
rule()
if args.play:
try:
print(f" {bold('▶ playing')} {args.out} {dim('(Ctrl-C to stop)')}")
subprocess.run(["afplay", args.out], check=False)
except KeyboardInterrupt:
print() # newline after the ^C echo
if __name__ == "__main__":
main()