- 共享引擎(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 模板
892 lines
45 KiB
Python
892 lines
45 KiB
Python
"""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()
|