初始提交: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:
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark — wall time + peak RAM for SA3 across model sizes and clip lengths.
|
||||
|
||||
Runs each (DiT, decoder, seconds) cell as a fresh ./sa3 subprocess so peak
|
||||
RAM is isolated per run. Weights are pre-warmed via ensure_local() before
|
||||
timing starts, so HF download time is NOT charged against the benchmark.
|
||||
|
||||
Usage:
|
||||
uv run --no-project python scripts/benchmark.py
|
||||
# or
|
||||
./sa3 --help # see the regular CLI; this file is separate
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_DIR = SCRIPT_DIR.parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
from weights import ensure_local # noqa: E402
|
||||
|
||||
# ── what to benchmark ──────────────────────────────────────────────────────
|
||||
CONFIGS = [
|
||||
("sm-music", "same-s"),
|
||||
("medium", "same-l"),
|
||||
]
|
||||
SECONDS = [5, 30, 120, 380]
|
||||
PROMPT = "Impending tribal, epic orchestral buildup"
|
||||
SEED = 42
|
||||
|
||||
# Weights we need on disk before benchmarks start. T5Gemma is shared.
|
||||
PREFLIGHT_WEIGHTS = [
|
||||
"models/mlx/t5gemma_f16.npz",
|
||||
"models/mlx/dit_sm-music_f16.npz",
|
||||
"models/mlx/dit_medium_f16.npz",
|
||||
"models/mlx/same_s_decoder_f32.npz",
|
||||
"models/mlx/same_l_decoder_f32.npz",
|
||||
]
|
||||
|
||||
|
||||
# ── hardware info ──────────────────────────────────────────────────────────
|
||||
def hwinfo() -> str:
|
||||
try:
|
||||
chip = subprocess.check_output(
|
||||
["sysctl", "-n", "machdep.cpu.brand_string"], text=True
|
||||
).strip()
|
||||
except Exception:
|
||||
chip = platform.processor() or platform.machine() or "unknown"
|
||||
try:
|
||||
mem_bytes = int(subprocess.check_output(
|
||||
["sysctl", "-n", "hw.memsize"], text=True).strip())
|
||||
mem_gb = f"{mem_bytes / (1024**3):.0f} GB"
|
||||
except Exception:
|
||||
mem_gb = "?"
|
||||
return f"{chip} · {mem_gb} RAM"
|
||||
|
||||
|
||||
# ── output parsing ─────────────────────────────────────────────────────────
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
_DONE = re.compile(
|
||||
r"done\s+([\d.]+)s\s+wall\s+→\s+([\d.]+)s\s+audio\s+→\s+"
|
||||
r"([\d.]+)×\s+realtime\s+peak\s+RAM\s+([\d.]+)\s+(MB|GB)"
|
||||
)
|
||||
|
||||
def parse_done(stdout: str) -> dict | None:
|
||||
plain = _ANSI.sub("", stdout)
|
||||
m = _DONE.search(plain)
|
||||
if not m:
|
||||
return None
|
||||
return {
|
||||
"wall_s": float(m.group(1)),
|
||||
"audio_s": float(m.group(2)),
|
||||
"realtime": float(m.group(3)),
|
||||
"peak_ram": f"{m.group(4)} {m.group(5)}",
|
||||
}
|
||||
|
||||
|
||||
# ── single benchmark cell ──────────────────────────────────────────────────
|
||||
def run_one(dit: str, decoder: str, seconds: int) -> dict | None:
|
||||
out = Path(tempfile.gettempdir()) / f"bench_{dit}_{decoder}_{seconds}s.wav"
|
||||
cmd = [
|
||||
str(PROJECT_DIR / "sa3"),
|
||||
"--prompt", PROMPT,
|
||||
"--dit", dit,
|
||||
"--decoder", decoder,
|
||||
"--seconds", str(seconds),
|
||||
"--seed", str(SEED),
|
||||
"--out", str(out),
|
||||
]
|
||||
label = f" {dit:9s} {decoder:7s} {seconds:>4}s"
|
||||
print(f"{label} → running …", flush=True)
|
||||
t0 = time.time()
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, cwd=str(PROJECT_DIR),
|
||||
timeout=20 * 60, # 20-minute hard cap per cell
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"{label} → TIMEOUT after {time.time()-t0:.0f}s")
|
||||
return None
|
||||
elapsed = time.time() - t0
|
||||
out.unlink(missing_ok=True)
|
||||
|
||||
if proc.returncode != 0:
|
||||
last_err = (proc.stderr.strip().split("\n") or ["(no stderr)"])[-1][:120]
|
||||
print(f"{label} → FAILED exit={proc.returncode} {last_err}")
|
||||
return None
|
||||
parsed = parse_done(proc.stdout)
|
||||
if not parsed:
|
||||
print(f"{label} → FAILED (could not parse 'done' line after {elapsed:.0f}s)")
|
||||
return None
|
||||
print(
|
||||
f"{label} → {parsed['wall_s']:7.2f}s wall "
|
||||
f"{parsed['realtime']:5.2f}× RT peak {parsed['peak_ram']}"
|
||||
)
|
||||
return {"dit": dit, "decoder": decoder, "seconds": seconds, **parsed}
|
||||
|
||||
|
||||
# ── ASCII table ────────────────────────────────────────────────────────────
|
||||
def render_table(rows: list[dict]) -> str:
|
||||
headers = ["model", "decoder", "seconds", "wall (s)", "×realtime", "peak RAM"]
|
||||
widths = [9, 7, 7, 9, 9, 10]
|
||||
|
||||
def hline(l, m, r):
|
||||
return l + m.join("─" * (w + 2) for w in widths) + r
|
||||
def row(cells):
|
||||
parts = []
|
||||
for i, c in enumerate(cells):
|
||||
# right-align numeric columns (idx 2-5)
|
||||
parts.append(c.rjust(widths[i]) if i >= 2 else c.ljust(widths[i]))
|
||||
return "│ " + " │ ".join(parts) + " │"
|
||||
|
||||
lines = [hline("┌", "┬", "┐"), row(headers), hline("├", "┼", "┤")]
|
||||
prev_dit = None
|
||||
for r in rows:
|
||||
# Visual separator when the model changes
|
||||
if prev_dit is not None and r["dit"] != prev_dit:
|
||||
lines.append(hline("├", "┼", "┤"))
|
||||
prev_dit = r["dit"]
|
||||
lines.append(row([
|
||||
r["dit"], r["decoder"], str(r["seconds"]),
|
||||
f"{r['wall_s']:.2f}",
|
||||
f"{r['realtime']:.2f}×",
|
||||
r["peak_ram"],
|
||||
]))
|
||||
lines.append(hline("└", "┴", "┘"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── main ───────────────────────────────────────────────────────────────────
|
||||
def main() -> int:
|
||||
print()
|
||||
print("━" * 72)
|
||||
print(" Benchmark — Stable Audio 3 (MLX) on Apple Silicon")
|
||||
print("━" * 72)
|
||||
print(f" Hardware: {hwinfo()}")
|
||||
print(f" Prompt: {PROMPT!r}")
|
||||
print(f" Seed: {SEED}")
|
||||
print(f" Cells: {len(CONFIGS)} configs × {len(SECONDS)} durations = "
|
||||
f"{len(CONFIGS) * len(SECONDS)} runs")
|
||||
print()
|
||||
|
||||
print("→ Preflight: ensuring all weights are local (downloads NOT timed)")
|
||||
for rel in PREFLIGHT_WEIGHTS:
|
||||
ensure_local(rel)
|
||||
print(" done.\n")
|
||||
|
||||
print("→ Running benchmarks (each subprocess is isolated for clean peak-RAM)")
|
||||
rows: list[dict] = []
|
||||
for dit, decoder in CONFIGS:
|
||||
for s in SECONDS:
|
||||
r = run_one(dit, decoder, s)
|
||||
if r:
|
||||
rows.append(r)
|
||||
print()
|
||||
|
||||
if not rows:
|
||||
print("No successful runs — nothing to tabulate.")
|
||||
return 1
|
||||
|
||||
print("━" * 72)
|
||||
print(f" Results ({len(rows)}/{len(CONFIGS)*len(SECONDS)} cells succeeded)")
|
||||
print(f" Hardware: {hwinfo()}")
|
||||
print("━" * 72)
|
||||
print()
|
||||
print(render_table(rows))
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Shared, colored "Try these commands" block.
|
||||
|
||||
Used by:
|
||||
- scripts/install.py — printed at the end of install.sh
|
||||
- scripts/sa3_mlx.py — appended to `./sa3 --help`
|
||||
|
||||
Examples render with the user's `./sa3` wrapper when present (falling back to
|
||||
`uv run python` or bare `python`), and only show entries for DiT bundles that
|
||||
the user actually has installed. Bundles not yet on disk get a "re-run
|
||||
install.sh, or just use them — weights auto-download" note at the bottom.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# This file lives in <project>/scripts/. SCRIPT_DIR points at the project
|
||||
# root (where ./sa3, ./install.sh, models/, .venv/ live).
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# ── ANSI colours (safe no-ops when stdout isn't a TTY) ──────────────────────
|
||||
def _c(code: str) -> str:
|
||||
return code if sys.stdout.isatty() else ""
|
||||
|
||||
BOLD = _c("\033[1m")
|
||||
CYAN = _c("\033[1;36m")
|
||||
GREEN = _c("\033[1;32m")
|
||||
YELLOW = _c("\033[1;33m")
|
||||
DIM = _c("\033[2m")
|
||||
RESET = _c("\033[0m")
|
||||
|
||||
|
||||
def _have(binary: str) -> bool:
|
||||
from shutil import which
|
||||
return which(binary) is not None
|
||||
|
||||
|
||||
def _py_invocation() -> tuple[str, str, bool]:
|
||||
"""Return (command-to-print, tip-or-empty, is_wrapper).
|
||||
|
||||
Prefer the ./sa3 wrapper if present, else `uv run python scripts/sa3_mlx.py`,
|
||||
else fall back to a bare `python scripts/sa3_mlx.py`.
|
||||
"""
|
||||
wrapper = SCRIPT_DIR / "sa3"
|
||||
if wrapper.exists() and os.access(wrapper, os.X_OK):
|
||||
return "./sa3", "the ./sa3 wrapper handles .venv + uv automatically", True
|
||||
if _have("uv"):
|
||||
return "uv run python", "uv run finds .venv/ automatically — no activation needed", False
|
||||
venv_dir = SCRIPT_DIR / ".venv"
|
||||
if Path(sys.prefix).resolve() == venv_dir.resolve():
|
||||
return ".venv/bin/python", "source .venv/bin/activate # to run `python` directly", False
|
||||
return "python", "", False
|
||||
|
||||
|
||||
def print_example_commands(header: str | None = None) -> None:
|
||||
"""Print the categorized example-commands block.
|
||||
|
||||
`header` is the title line inside the rule block. Defaults to a neutral
|
||||
"Examples:" for --help; install.sh passes "✓ Install complete. …".
|
||||
"""
|
||||
from weights import bundle_status
|
||||
|
||||
py, tip, is_wrapper = _py_invocation()
|
||||
prefix = py if is_wrapper else f"{py} scripts/sa3_mlx.py"
|
||||
|
||||
def hdr(text: str) -> None:
|
||||
print(f"\n {CYAN}{text}{RESET}")
|
||||
def cmd(args: str, comment: str = "") -> None:
|
||||
line = f"{prefix} {args}"
|
||||
if comment:
|
||||
print(f" {GREEN}$ {line}{RESET} {DIM}# {comment}{RESET}")
|
||||
else:
|
||||
print(f" {GREEN}$ {line}{RESET}")
|
||||
|
||||
if header is None:
|
||||
header = f"{BOLD}Examples:{RESET}"
|
||||
|
||||
print(f"\n{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
|
||||
print(f" {header}")
|
||||
print(f"{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
|
||||
|
||||
if tip:
|
||||
print(f"\n {DIM}tip: {tip}{RESET}")
|
||||
|
||||
have = {name: bundle_status(name) == (4, 4) for name in ("medium", "sm-music", "sm-sfx")}
|
||||
|
||||
# ── Basic generation ─────────────────────────────────────────────
|
||||
hdr("🎵 Generate audio from a prompt")
|
||||
if have["medium"]:
|
||||
cmd('--prompt "A beautiful piano arpeggio grows into a cinematic climax" \\\n'
|
||||
f' --dit medium --decoder same-l --seconds 30 --out piano.wav',
|
||||
"highest quality (~5s for 10s clip on M1)")
|
||||
if have["sm-music"]:
|
||||
cmd('--prompt "lofi house loop, 120 BPM" \\\n'
|
||||
f' --dit sm-music --decoder same-s --seconds 15 --out lofi.wav',
|
||||
"fast music generation (~1s wall for 10s clip)")
|
||||
if have["sm-sfx"]:
|
||||
cmd('--prompt "footsteps on gravel, then a door slamming" \\\n'
|
||||
f' --dit sm-sfx --decoder same-s --seconds 8 --out sfx.wav',
|
||||
"sound-effect generation")
|
||||
|
||||
# ── Playback ─────────────────────────────────────────────────────
|
||||
hdr("▶ Play immediately after generation")
|
||||
one_dit = "medium" if have["medium"] else ("sm-music" if have["sm-music"] else "sm-sfx")
|
||||
one_dec = "same-l" if one_dit == "medium" else "same-s"
|
||||
cmd(f'--prompt "ambient drone" --dit {one_dit} --decoder {one_dec} \\\n'
|
||||
f' --seconds 10 --out drone.wav --play',
|
||||
"writes WAV + plays via afplay (Ctrl-C stops both)")
|
||||
|
||||
# ── Audio-to-audio + inpaint ─────────────────────────────────────
|
||||
hdr("🎚️ Audio-to-audio & inpainting (requires an input WAV)")
|
||||
cmd(f'--prompt "jazz fusion with electric piano" --dit {one_dit} --decoder {one_dec} \\\n'
|
||||
f' --init-audio funk.wav --init-noise-level 0.7 --out funk_jazz.wav',
|
||||
"variation: 0.4-0.8 typical, higher = more change")
|
||||
cmd(f'--prompt "explosive drum break" --dit {one_dit} --decoder {one_dec} \\\n'
|
||||
f' --init-audio funk.wav --inpaint-range "4,7" --out funk_drums.wav',
|
||||
"regenerate seconds 4-7, keep rest")
|
||||
|
||||
# ── CFG & negative prompts ───────────────────────────────────────
|
||||
hdr("🎯 Steer with CFG + negative prompts")
|
||||
cmd(f'--prompt "ambient drone" --cfg 3.0 \\\n'
|
||||
f' --negative-prompt "drums, vocals, distortion" \\\n'
|
||||
f' --dit {one_dit} --decoder {one_dec} --out clean_drone.wav',
|
||||
"cfg > 1.0 toward prompt, neg pushes away")
|
||||
|
||||
# Bundles not installed (offer the on-demand path)
|
||||
missing = [name for name, ok in have.items() if not ok]
|
||||
if missing:
|
||||
print(f"\n {YELLOW}note:{RESET} bundles not installed: {', '.join(missing)}.")
|
||||
print(f" Re-run {BOLD}./install.sh{RESET} to pick them up, or just use them in"
|
||||
f" {BOLD}./sa3{RESET} —")
|
||||
print(f" weights auto-download from HuggingFace on first use.")
|
||||
|
||||
print(f"\n{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}\n")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Convert a Stable Audio 3 *-base torch checkpoint → the MLX training npz.
|
||||
|
||||
Torch-free (safetensors.numpy). Produces `dit_<model>-base_f16.npz` — the DiT
|
||||
weights + baked conditioner that `lora_train_mlx.py --dit-weights` expects.
|
||||
LoRA training uses the BASE checkpoint (not the shipped ARC weights inference
|
||||
uses); these npz are auto-downloaded by weights.py, or reproduced here from the
|
||||
HF `stabilityai/stable-audio-3-*-base` repos.
|
||||
|
||||
python scripts/export_base_npz.py \
|
||||
--base-dir <sa3-*-base> # dir with model.safetensors + model_config.json
|
||||
--output models/mlx/dit_<model>-base_f16.npz
|
||||
[--validate-against <existing npz>] # assert bit-parity with a known-good npz
|
||||
|
||||
Conversion (identical to the shipped inference-npz layout): strip the DiT
|
||||
wrapper prefix `model.model.`, transpose the two Conv1d weights
|
||||
[out,in,k]→[out,k,in], rename RMSNorm `.gamma`→`.weight`, remap
|
||||
`.to_local_embed.{0,2}`→`.seq.{0,2}`, and fold the 3 conditioner tensors under
|
||||
the `cond.` prefix. Same for small (sm-music/sm-sfx) and medium.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
import mlx.core as mx
|
||||
import numpy as np
|
||||
from safetensors.numpy import load_file
|
||||
|
||||
_DIT_PREFIX = "model.model."
|
||||
_CONV_WEIGHTS = ("preprocess_conv.weight", "postprocess_conv.weight")
|
||||
_COND_MAP = {
|
||||
"conditioner.conditioners.prompt.padding_embedding": "cond.padding_embedding",
|
||||
"conditioner.conditioners.seconds_total.embedder.embedding.1.weight": "cond.seconds_total_weight",
|
||||
"conditioner.conditioners.seconds_total.embedder.embedding.1.bias": "cond.seconds_total_bias",
|
||||
}
|
||||
|
||||
|
||||
def convert(base_dir):
|
||||
sd = load_file(f"{base_dir}/model.safetensors")
|
||||
out = {}
|
||||
for k, arr in sd.items():
|
||||
if k in _COND_MAP:
|
||||
out[_COND_MAP[k]] = mx.array(arr.astype(np.float32))
|
||||
continue
|
||||
if not k.startswith(_DIT_PREFIX):
|
||||
continue # pretransform / T5 backbone — not part of the DiT npz
|
||||
sk = k[len(_DIT_PREFIX):]
|
||||
a = arr.astype(np.float32)
|
||||
if sk in _CONV_WEIGHTS: # Conv1d [out,in,k] -> MLX [out,k,in]
|
||||
out[sk] = mx.array(a.transpose(0, 2, 1))
|
||||
elif sk.endswith(".gamma"): # RMSNorm .gamma -> .weight
|
||||
out[sk[: -len(".gamma")] + ".weight"] = mx.array(a)
|
||||
else:
|
||||
sk = sk.replace(".to_local_embed.0.", ".to_local_embed.seq.0.")
|
||||
sk = sk.replace(".to_local_embed.2.", ".to_local_embed.seq.2.")
|
||||
out[sk] = mx.array(a)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--base-dir", required=True)
|
||||
ap.add_argument("--output", required=True)
|
||||
ap.add_argument("--validate-against", default=None,
|
||||
help="Assert key-set + value parity with a known-good npz.")
|
||||
args = ap.parse_args()
|
||||
|
||||
wd = convert(args.base_dir)
|
||||
print(f"converted {len(wd)} keys ({sum(1 for k in wd if k.startswith('cond'))} conditioner)")
|
||||
|
||||
if args.validate_against:
|
||||
ref = dict(mx.load(args.validate_against))
|
||||
extra, missing = set(wd) - set(ref), set(ref) - set(wd)
|
||||
assert not extra and not missing, f"key mismatch: extra={extra}, missing={missing}"
|
||||
worst = max(
|
||||
float(mx.max(mx.abs(wd[k].astype(mx.float32) - ref[k].astype(mx.float32))))
|
||||
for k in ref
|
||||
)
|
||||
print(f"validate: {len(ref)} keys match, max|Δ| = {worst:.2e}")
|
||||
# Tolerance is fp16-scale: the source ckpt is fp32 and both npz are fp16,
|
||||
# so a handful of the specially-transformed keys (conv transpose, the
|
||||
# to_local_embed rename) can differ by ~1 fp16 ULP (~4e-3) depending on
|
||||
# the rounding path. A real structural bug (wrong transpose/rename) gives
|
||||
# an O(0.1-1) diff or a shape/key mismatch — both caught well below this.
|
||||
assert worst < 1e-2, "values diverge from the reference npz (structural bug)"
|
||||
|
||||
mx.savez(args.output, **{k: v.astype(mx.float16) for k, v in wd.items()})
|
||||
print(f"wrote {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Internal install plumbing — do NOT invoke directly. Use ../install.sh from the project root.
|
||||
|
||||
Called by install.sh after it has set up uv + .venv. Responsibilities:
|
||||
1. (Optionally) pip-install requirements.txt — skipped when INSTALL_SKIP_PIP=1
|
||||
is set (install.sh sets this since uv already handled deps).
|
||||
2. Ask which DiT bundles to download, then fetch them from HuggingFace.
|
||||
3. Print example commands the user can copy-paste.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# This file lives in <project>/scripts/. SCRIPT_DIR points at the project
|
||||
# root (where models/, requirements.txt, .venv/, sa3 wrapper live).
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # `from weights import …`
|
||||
|
||||
MIN_PY = (3, 10)
|
||||
SKIP_PIP = os.environ.get("INSTALL_SKIP_PIP") == "1"
|
||||
|
||||
|
||||
def step(msg: str) -> None:
|
||||
print(f"\n\033[1;36m→ {msg}\033[0m")
|
||||
|
||||
|
||||
def check_environment() -> None:
|
||||
"""Bail out early with a clear message if the interpreter is wrong."""
|
||||
py = sys.version_info
|
||||
if (py.major, py.minor) < MIN_PY:
|
||||
print(
|
||||
f"\n\033[1;31merror\033[0m: this script is running under Python "
|
||||
f"{py.major}.{py.minor}, but MLX requires Python "
|
||||
f"{MIN_PY[0]}.{MIN_PY[1]}+.\n\n"
|
||||
f"Re-run install.py with a Python {MIN_PY[0]}.{MIN_PY[1]}+ interpreter, e.g.:\n"
|
||||
f" /path/to/python3.11 install.py\n\n"
|
||||
f"On macOS you can get one via:\n"
|
||||
f" brew install python@3.11\n"
|
||||
f" /opt/homebrew/bin/python3.11 install.py\n"
|
||||
f"or with pyenv:\n"
|
||||
f" pyenv install 3.11.10 && pyenv local 3.11.10 && python install.py\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
||||
print(
|
||||
f"\n\033[1;33mwarning\033[0m: this stack is Apple-Silicon-only "
|
||||
f"(MLX is Metal-backed). Detected {platform.system()}/{platform.machine()} "
|
||||
f"— pip install will probably fail when it tries to fetch MLX.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def pip_install_requirements() -> None:
|
||||
if SKIP_PIP:
|
||||
step("Dependencies already installed by install.sh (uv) — skipping pip step")
|
||||
return
|
||||
step("Installing Python dependencies")
|
||||
req = SCRIPT_DIR / "requirements.txt"
|
||||
|
||||
# Prefer pip; fall back to uv if pip isn't available in this interpreter
|
||||
# (e.g. user is running install.py directly inside a uv-created venv
|
||||
# that wasn't seeded with pip).
|
||||
pip_available = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "--version"],
|
||||
capture_output=True,
|
||||
).returncode == 0
|
||||
|
||||
if pip_available:
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-r", str(req)]
|
||||
elif _have("uv"):
|
||||
cmd = ["uv", "pip", "install", "--python", sys.executable, "-r", str(req)]
|
||||
else:
|
||||
print(
|
||||
f"\n\033[1;31merror\033[0m: neither pip nor uv is available in this "
|
||||
f"interpreter ({sys.executable}).\n"
|
||||
f"Install pip with `python -m ensurepip --upgrade`, or use ./install.sh "
|
||||
f"which sets everything up via uv.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
print(f" $ {' '.join(cmd)}")
|
||||
subprocess.check_call(cmd)
|
||||
|
||||
|
||||
def _have(binary: str) -> bool:
|
||||
"""True if the given binary is on PATH."""
|
||||
from shutil import which
|
||||
return which(binary) is not None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(
|
||||
description="SA3 MLX install — non-interactive bundle downloader + post-install help.",
|
||||
epilog="Normally invoked via ../install.sh, not directly.",
|
||||
)
|
||||
ap.add_argument("--download", default="",
|
||||
metavar="BUNDLES",
|
||||
help="Comma-separated list of bundles to pre-download "
|
||||
"(medium, sm-music, sm-sfx). Without this flag, "
|
||||
"nothing is downloaded — sa3_mlx.py will fetch any "
|
||||
"missing weights from HuggingFace on first use.")
|
||||
cli = ap.parse_args()
|
||||
|
||||
check_environment()
|
||||
pip_install_requirements()
|
||||
|
||||
# Import after pip install so a fresh checkout works.
|
||||
from weights import DIT_BUNDLES, SHARED, BUNDLE_SIZES, bundle_status, ensure_local
|
||||
|
||||
step("Current weights status")
|
||||
for name in DIT_BUNDLES:
|
||||
present, total = bundle_status(name)
|
||||
mark = "✓" if present == total else " "
|
||||
print(f" [{mark}] {name:9s} {present}/{total} files present ({BUNDLE_SIZES[name]})")
|
||||
|
||||
chosen: list[str] = []
|
||||
if cli.download.strip():
|
||||
chosen = [b.strip() for b in cli.download.split(",") if b.strip()]
|
||||
unknown = [b for b in chosen if b not in DIT_BUNDLES]
|
||||
if unknown:
|
||||
print(f"\n\033[1;31merror\033[0m: unknown bundle(s): {', '.join(unknown)}. "
|
||||
f"Choices: {', '.join(DIT_BUNDLES)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if chosen:
|
||||
step(f"Downloading {len(chosen)} bundle(s): {', '.join(chosen)}")
|
||||
seen: set[str] = set()
|
||||
for name in chosen:
|
||||
print(f"\n[{name}]")
|
||||
items = DIT_BUNDLES[name] + SHARED
|
||||
for rel, _hf in items:
|
||||
if rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
ensure_local(rel)
|
||||
else:
|
||||
step("No --download set — weights will auto-download on first ./sa3 use")
|
||||
print(f" To pre-download instead, pass: {sys.executable.split('/')[-1]} install.py --download medium")
|
||||
print(f" or: ./install.sh --download medium,sm-music")
|
||||
|
||||
from examples import print_example_commands, BOLD, GREEN, RESET
|
||||
print_example_commands(header=f"{BOLD}{GREEN}✓ Install complete.{RESET} Try these commands:")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborted.")
|
||||
sys.exit(130)
|
||||
@@ -0,0 +1,884 @@
|
||||
"""SA3 LoRA training on Apple Silicon — pure MLX, underfit conventions.
|
||||
|
||||
The MLX counterpart of underfit's raw-PyTorch training loop
|
||||
(github.com/dada-bots/underfit, underfit/training/loop.py), built on the
|
||||
trainable adapters in models/defs/lora.py. Every training convention and
|
||||
default mirrors underfit (see ../TRAINING_CONVENTIONS.md):
|
||||
|
||||
- rectified-flow velocity target (noise − x), signal-only masked MSE,
|
||||
per-sample-then-mean reduction
|
||||
- uniform timestep sampler + the SA3 models' "full" distribution shift
|
||||
(min_length 256, max_length 4096)
|
||||
- CFG dropout 0.1: per-sample, the whole cross-attention conditioning
|
||||
(prompt + seconds token) is zeroed; global_cond is kept
|
||||
- AdamW, single param group, torch defaults (betas 0.9/0.999, eps 1e-8,
|
||||
weight decay 0.0), no schedule, no clipping; LR has NO default and must
|
||||
be supplied (--lr)
|
||||
- adapters train in fp32 over a frozen fp16 base
|
||||
- step-driven loop; checkpoints every --checkpoint-every (1000) steps and
|
||||
at the end, saved BEFORE anything else can fail, named
|
||||
{run_label}-step={step}-epoch={epoch}.safetensors with lora_config
|
||||
metadata {rank, alpha, adapter_type, include, exclude, step, epoch,
|
||||
base_model}
|
||||
- resume offsets: explicit flags > checkpoint metadata > filename tokens
|
||||
- per-step loss_by_timestep.bin telemetry (struct "Iff": step, t_mean,
|
||||
loss_mean), flushed every 10 steps
|
||||
|
||||
Dataset: pre-encoded latents from scripts/pre_encode_mlx.py (npy + json
|
||||
pairs), cropped to --latent-crop-length with random crop, tiny datasets
|
||||
oversampled with replacement (batch_size*100 draws/epoch) — the underfit
|
||||
overfitting workflow. Prompts are built per-sample with underfit's tag
|
||||
augmentation. seconds_total stays the FULL source duration after cropping
|
||||
(deliberate underfit convention).
|
||||
|
||||
Performance:
|
||||
- the train step (loss + grad + optimizer update) is mx.compile'd by
|
||||
default (MLX docs "Compile > Training graphs" pattern; --no-compile for
|
||||
the eager path). Batch shapes are constant, so it compiles once.
|
||||
- T5Gemma prompt conditioning is cached per exact prompt string (per-run,
|
||||
in-memory; --no-t5-cache to disable) — the tiny-dataset workflow repeats
|
||||
a handful of prompts, so the hit rate is ≈100% after the first epoch.
|
||||
- --grad-checkpoint recomputes each transformer block's activations in the
|
||||
backward pass (mlx-lm style mx.checkpoint) — memory for big crops on
|
||||
small Macs, at the cost of roughly one extra forward.
|
||||
- wired memory limit is raised to the device's recommended working-set
|
||||
size at startup (mx.set_wired_limit).
|
||||
|
||||
Usage:
|
||||
python scripts/lora_train_mlx.py --dit sm-music \
|
||||
--latents-dir output/latents/my-set --lr 1e-4 \
|
||||
--name my-lora --save-dir output/runs --max-steps 2000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
REPO = SCRIPTS_DIR.parent
|
||||
sys.path.insert(0, str(REPO))
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import mlx.core as mx # noqa: E402
|
||||
import mlx.nn as nn # noqa: E402
|
||||
import mlx.optimizers as optim # noqa: E402
|
||||
from mlx.utils import tree_flatten # noqa: E402
|
||||
|
||||
from sa3_mlx import DIT_CHOICES, load_dit # noqa: E402
|
||||
from weights import ensure_local # noqa: E402
|
||||
from sa3_mlx import T5GEMMA_NPZ_REL # noqa: E402
|
||||
from models.defs.sa3_pipeline import ( # noqa: E402
|
||||
apply_prompt_padding, load_conditioner_from_npz,
|
||||
)
|
||||
from models.defs.t5gemma_mlx import T5Gemma # noqa: E402
|
||||
from models.defs.training import ( # noqa: E402
|
||||
rectified_flow_loss, sample_training_timesteps, shift_training_timesteps,
|
||||
)
|
||||
from models.defs.lora import ( # noqa: E402
|
||||
TrainableSecondsEmbedder, inject_from_lora_config, iter_trainable_lora_layers,
|
||||
load_lora_checkpoint, load_trainable_lora_state, save_lora_checkpoint,
|
||||
underfit_lora_config,
|
||||
)
|
||||
from models.defs import demo_mlx as demo # noqa: E402
|
||||
from models.defs.latent_dataset import ( # noqa: E402
|
||||
PreEncodedLatentDataset, iterate_batches,
|
||||
)
|
||||
|
||||
# underfit registry conventions per model
|
||||
BASE_MODEL_NAMES = {"sm-music": "sa3-sm-music", "sm-sfx": "sa3-sm-sfx",
|
||||
"medium": "sa3-medium"}
|
||||
LATENT_CROP_DEFAULTS = {"sm-music": 1300, "sm-sfx": 1300, "medium": 4096}
|
||||
DIST_SHIFT_DEFAULT = {"shift_type": "full",
|
||||
"options": {"min_length": 256, "max_length": 4096}}
|
||||
T5_CACHE_CAP = 512 # prompt-conditioning cache entries (oldest evicted first)
|
||||
|
||||
|
||||
def _rm(path):
|
||||
"""Remove a file, ignoring a missing one (temp-ckpt cleanup)."""
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def grad_checkpoint(layer):
|
||||
"""
|
||||
Update all instances of type(layer) to use gradient checkpointing.
|
||||
|
||||
Verbatim port of mlx-lm's mlx_lm/tuner/trainer.py:grad_checkpoint —
|
||||
activations of every instance of the layer's class are recomputed during
|
||||
the backward pass instead of being kept alive, trading ~one extra forward
|
||||
for a much smaller peak working set.
|
||||
"""
|
||||
fn = type(layer).__call__
|
||||
|
||||
def checkpointed_fn(model, *args, **kwargs):
|
||||
def inner_fn(params, *args, **kwargs):
|
||||
model.update(params)
|
||||
return fn(model, *args, **kwargs)
|
||||
|
||||
return mx.checkpoint(inner_fn)(model.trainable_parameters(), *args, **kwargs)
|
||||
|
||||
type(layer).__call__ = checkpointed_fn
|
||||
|
||||
|
||||
class TrainBundle(nn.Module):
|
||||
"""DiT + (optional) trainable seconds conditioner under one module root so
|
||||
a single value_and_grad covers every adapter parameter. The bundle's
|
||||
trainable_parameters() are the LoRA params only (bases frozen at inject)."""
|
||||
|
||||
def __init__(self, dit, secs):
|
||||
super().__init__()
|
||||
self.dit = dit
|
||||
self.secs = secs # TrainableSecondsEmbedder or None
|
||||
|
||||
|
||||
def parse_args():
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--dit", required=True, choices=list(DIT_CHOICES.keys()))
|
||||
ap.add_argument("--dit-weights", default=None,
|
||||
help="Override the DiT weight file — REQUIRED in practice for "
|
||||
"training parity with underfit: train on the BASE "
|
||||
"(rectified-flow) checkpoint, not the shipped ARC "
|
||||
"(rf_denoiser) weights that inference uses. Convert the "
|
||||
"HF *-base model.safetensors to npz first (441-key "
|
||||
"layout; see TRAINING_CONVENTIONS.md).")
|
||||
ap.add_argument("--latents-dir", required=True,
|
||||
help="Root of pre-encoded npy+json pairs (scripts/pre_encode_mlx.py)")
|
||||
ap.add_argument("--lr", type=float, required=True,
|
||||
help="Learning rate (underfit has NO default — must be supplied)")
|
||||
ap.add_argument("--name", default="underfit-run", help="Run name (defaults.ini)")
|
||||
ap.add_argument("--save-dir", default=str(REPO / "output" / "runs"))
|
||||
ap.add_argument("--batch-size", type=int, default=1)
|
||||
ap.add_argument("--seed", type=int, default=42)
|
||||
ap.add_argument("--max-steps", type=int, default=10_000_000_000,
|
||||
help="Absolute global-step target (resume-aware)")
|
||||
ap.add_argument("--checkpoint-every", type=int, default=1000)
|
||||
# adapter config — underfit dashboard defaults
|
||||
ap.add_argument("--adapter-type", default="dora-rows")
|
||||
ap.add_argument("--rank", type=int, default=16)
|
||||
ap.add_argument("--alpha", type=float, default=None, help="Default: = rank")
|
||||
ap.add_argument("--include", default=None,
|
||||
help="Comma-separated substring filters (underfit convention)")
|
||||
ap.add_argument("--exclude", default=None,
|
||||
help="Comma-separated; default = underfit's dashboard exclude list")
|
||||
ap.add_argument("--no-conditioner-lora", action="store_true",
|
||||
help="Skip the seconds-conditioner adapter (underfit adapts it "
|
||||
"by default — e.g. the plini checkpoint carries its delta)")
|
||||
ap.add_argument("--bora-mode", default="speed", choices=["speed", "memory"],
|
||||
help="bora/bora-xs forward: 'speed' (default) caches W0² per "
|
||||
"adapted layer (+1 weight copy) for the reformulated "
|
||||
"no-materialize forward; 'memory' keeps the original "
|
||||
"full-weight forward with no cache. Ignored for other "
|
||||
"adapter types.")
|
||||
# data
|
||||
ap.add_argument("--latent-crop-length", type=int, default=None,
|
||||
help="Default per model: 1300 (sm-*) / 4096 (medium)")
|
||||
ap.add_argument("--random-crop", action=argparse.BooleanOptionalAction, default=True)
|
||||
ap.add_argument("--prompt-config", default=None,
|
||||
help="JSON file with underfit prompt_config (trigger, tag options…)")
|
||||
# schedule / conditioning
|
||||
ap.add_argument("--timestep-sampler", default="uniform",
|
||||
choices=["uniform", "logit_normal", "trunc_logit_normal",
|
||||
"log_snr", "log_snr_uniform"])
|
||||
ap.add_argument("--dist-shift", default="full",
|
||||
choices=["none", "full", "flux", "logsnr"])
|
||||
ap.add_argument("--cfg-dropout-prob", type=float, default=0.1)
|
||||
ap.add_argument("--use-effective-length", action=argparse.BooleanOptionalAction,
|
||||
default=False,
|
||||
help="Shift timesteps by the EFFECTIVE latent length "
|
||||
"ceil(int(seconds_total*44100)/4096) per sample instead "
|
||||
"of the crop length (underfit model flag "
|
||||
"use_effective_length_for_schedule; True in the SA3 "
|
||||
"templates). Only affects the 'full'/'flux'/'logsnr' shift.")
|
||||
# optimizer (underfit reads these from optimizer_configs; torch AdamW defaults)
|
||||
ap.add_argument("--beta1", type=float, default=0.9)
|
||||
ap.add_argument("--beta2", type=float, default=0.999,
|
||||
help="AdamW β2 (torch default 0.999; SA3 templates use 0.95)")
|
||||
ap.add_argument("--eps", type=float, default=1e-8)
|
||||
ap.add_argument("--weight-decay", type=float, default=0.0,
|
||||
help="AdamW decoupled weight decay (torch default 0.0; SA3 "
|
||||
"templates use 0.01)")
|
||||
ap.add_argument("--gradient-clip-val", type=float, default=0.0,
|
||||
help="Global grad-norm clip over the adapter params before "
|
||||
"the optimizer step (0 = off). underfit's dashboard "
|
||||
"passes 1.0; the grad_norm metric is then post-clip, "
|
||||
"matching underfit's torch loop.")
|
||||
# LR schedule (underfit's optimizer_configs.diffusion.scheduler; InverseLR
|
||||
# with warmup — at step 0 the SA3 template LR is ~5e-7, not the nominal lr)
|
||||
ap.add_argument("--lr-scheduler", default="none", choices=["none", "inverse"],
|
||||
help="'inverse' = SAT-dev InverseLR: "
|
||||
"lr(step) = (1-warmup^(step+1)) * "
|
||||
"max(final, lr*(1+step/inv_gamma)^-power)")
|
||||
ap.add_argument("--inv-gamma", type=float, default=1e6)
|
||||
ap.add_argument("--lr-power", type=float, default=0.5)
|
||||
ap.add_argument("--lr-warmup", type=float, default=0.0,
|
||||
help="InverseLR exponential-warmup base in [0,1) (template 0.995)")
|
||||
ap.add_argument("--lr-final", type=float, default=0.0)
|
||||
# performance
|
||||
ap.add_argument("--compile", action=argparse.BooleanOptionalAction, default=True,
|
||||
help="mx.compile the train step (loss+grad+optimizer update; "
|
||||
"MLX 'Compile > Training graphs' pattern). "
|
||||
"--no-compile restores the eager path.")
|
||||
ap.add_argument("--grad-checkpoint", action="store_true",
|
||||
help="Gradient-checkpoint every transformer block (mlx-lm "
|
||||
"style mx.checkpoint): activations are recomputed in "
|
||||
"the backward pass — much lower peak memory for big "
|
||||
"crops, ~one extra forward of compute")
|
||||
ap.add_argument("--t5-cache", action=argparse.BooleanOptionalAction, default=True,
|
||||
help="Cache the padded T5Gemma prompt conditioning per exact "
|
||||
f"prompt string (per-run, in-memory, max {T5_CACHE_CAP} "
|
||||
"entries)")
|
||||
# resume
|
||||
ap.add_argument("--lora-ckpt-path", default=None)
|
||||
ap.add_argument("--step-offset", type=int, default=None)
|
||||
ap.add_argument("--epoch-offset", type=int, default=None)
|
||||
# demos (underfit convention: RF Euler inference on the base model + trained
|
||||
# LoRA, decode → mp3, baseline at step 0 then every --demo-every steps)
|
||||
ap.add_argument("--demo-every", type=int, default=0,
|
||||
help="Generate demos every N steps (0 = off). A baseline "
|
||||
"set is also rendered at step 0.")
|
||||
ap.add_argument("--demo-config", default=None,
|
||||
help="JSON list of demo entries. Each: {prompt, cfg=7, "
|
||||
"seed=0, steps=50, lora_strength?, lora_interval_max?}.")
|
||||
ap.add_argument("--demo-decoder", default=None,
|
||||
choices=["same-s", "same-l"],
|
||||
help="Decoder for demos (default: the DiT's default_decoder).")
|
||||
ap.add_argument("--demo-dir", default=None,
|
||||
help="Where to write demo_<i>_<step>.mp3 (default: cwd, "
|
||||
"which the dashboard sets to <run>/demos/).")
|
||||
ap.add_argument("--arc-weights", default=None,
|
||||
help="DiT weights for ARC demos (entries with arc=true): the "
|
||||
"SHIPPED rf_denoiser npz, NOT the training base. Default "
|
||||
"auto-downloads models/mlx/dit_<dit>_f16.npz. The trained "
|
||||
"LoRA is merged into a fresh copy of these weights and "
|
||||
"sampled with the pingpong integrator.")
|
||||
return ap.parse_args()
|
||||
|
||||
|
||||
def resolve_offsets(args, ckpt_config) -> tuple[int, int]:
|
||||
"""Underfit's resume ladder: explicit flags > checkpoint metadata >
|
||||
filename step=/epoch= tokens > zero."""
|
||||
if args.step_offset is not None or args.epoch_offset is not None:
|
||||
return int(args.step_offset or 0), int(args.epoch_offset or 0)
|
||||
if ckpt_config:
|
||||
step = ckpt_config.get("step")
|
||||
epoch = ckpt_config.get("epoch")
|
||||
if step is not None:
|
||||
return int(step), int(epoch or 0)
|
||||
if args.lora_ckpt_path:
|
||||
name = Path(args.lora_ckpt_path).name
|
||||
ms = re.search(r"step=(\d+)", name)
|
||||
me = re.search(r"epoch=(\d+)", name)
|
||||
if ms:
|
||||
return int(ms.group(1)), int(me.group(1)) if me else 0
|
||||
return 0, 0
|
||||
|
||||
|
||||
def build_conditioning(t5, padding_emb, prompts, max_len=256,
|
||||
cache=None, cache_stats=None):
|
||||
"""Frozen prompt-side conditioning: T5Gemma embeddings with the learned
|
||||
padding embedding applied. Returns [B, 256, 768] fp32 (the trainable
|
||||
seconds token is concatenated later, inside the grad scope).
|
||||
|
||||
When ``cache`` (a dict) is given, the padded embedding is cached per
|
||||
EXACT prompt string. The cache is per-run only (in-memory, never
|
||||
persisted) and stores evaluated [1, max_len, 768] fp32 mx arrays; it is
|
||||
capped at T5_CACHE_CAP entries, evicting oldest-inserted first. The
|
||||
tiny-dataset underfit workflow repeats a handful of prompts, so after
|
||||
the first epoch the T5Gemma forward is skipped entirely (~100% hits)."""
|
||||
prompts = list(prompts)
|
||||
if cache is None:
|
||||
embeds, mask = t5.encode(prompts, max_len=max_len)
|
||||
mx.eval(embeds, mask)
|
||||
padded = apply_prompt_padding(embeds.astype(mx.float32), mask,
|
||||
padding_emb.astype(mx.float32))
|
||||
return mx.stop_gradient(padded)
|
||||
|
||||
missing = [p for p in dict.fromkeys(prompts) if p not in cache]
|
||||
if cache_stats is not None:
|
||||
n_miss = sum(1 for p in prompts if p in set(missing))
|
||||
cache_stats["misses"] += n_miss
|
||||
cache_stats["hits"] += len(prompts) - n_miss
|
||||
if missing:
|
||||
embeds, mask = t5.encode(missing, max_len=max_len)
|
||||
padded = apply_prompt_padding(embeds.astype(mx.float32), mask,
|
||||
padding_emb.astype(mx.float32))
|
||||
mx.eval(padded)
|
||||
for i, p in enumerate(missing):
|
||||
while len(cache) >= T5_CACHE_CAP:
|
||||
cache.pop(next(iter(cache))) # evict oldest
|
||||
row = padded[i:i + 1]
|
||||
mx.eval(row)
|
||||
cache[p] = row
|
||||
print(f" t5-cache: encoded {len(missing)} new prompt(s) "
|
||||
f"({len(cache)} cached)")
|
||||
return mx.stop_gradient(mx.concatenate([cache[p] for p in prompts],
|
||||
axis=0))
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
np_rng = np.random.default_rng(args.seed)
|
||||
mx.random.seed(args.seed)
|
||||
|
||||
# Let Metal wire (pin) up to the recommended working set — avoids paging
|
||||
# stalls when the training working set approaches physical memory.
|
||||
if hasattr(mx, "set_wired_limit") and hasattr(mx, "device_info"):
|
||||
wired = mx.device_info().get("max_recommended_working_set_size")
|
||||
if wired:
|
||||
mx.set_wired_limit(int(wired))
|
||||
|
||||
alpha = float(args.alpha) if args.alpha is not None else float(args.rank)
|
||||
lora_config = underfit_lora_config()
|
||||
lora_config.update({"adapter_type": args.adapter_type, "rank": args.rank,
|
||||
"alpha": alpha})
|
||||
if args.include is not None:
|
||||
lora_config["include"] = [s.strip() for s in args.include.split(",") if s.strip()]
|
||||
if args.exclude is not None:
|
||||
lora_config["exclude"] = [s.strip() for s in args.exclude.split(",") if s.strip()]
|
||||
|
||||
crop_len = args.latent_crop_length or LATENT_CROP_DEFAULTS[args.dit]
|
||||
base_model = BASE_MODEL_NAMES[args.dit]
|
||||
|
||||
# ── run dirs (underfit layout: <save>/<name>/<uuid8>/checkpoints) ────────
|
||||
run_label = re.sub(r"-\d{14}$", "", args.name) if args.name else None
|
||||
session = uuid.uuid4().hex[:8]
|
||||
ckpt_dir = Path(args.save_dir) / args.name / session / "checkpoints"
|
||||
ckpt_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Engine: MLX · device: {mx.default_device()}")
|
||||
print(f"run: {args.name} session {session}")
|
||||
print(f"checkpoints → {ckpt_dir}")
|
||||
|
||||
# ── dataset ──────────────────────────────────────────────────────────────
|
||||
prompt_config = None
|
||||
if args.prompt_config:
|
||||
prompt_config = json.loads(Path(args.prompt_config).read_text())
|
||||
dataset = PreEncodedLatentDataset(
|
||||
args.latents_dir, crop_len, random_crop=args.random_crop,
|
||||
prompt_config=prompt_config, seed=args.seed)
|
||||
print(f"dataset: {len(dataset)} latent file(s), crop {crop_len} latents"
|
||||
+ (" (oversampling with replacement — tiny dataset)"
|
||||
if len(dataset) < args.batch_size else ""))
|
||||
# Batches per epoch — mirrors iterate_batches: tiny sets (< batch) oversample
|
||||
# to batch_size*100 draws (→ 100 batches); else ceil(n / batch). Used as the
|
||||
# tqdm total so the progress bar (and the dashboard's collapse signature)
|
||||
# matches underfit's torch dataloader.
|
||||
_n_idx = (args.batch_size * 100 if len(dataset) < args.batch_size
|
||||
else len(dataset))
|
||||
steps_per_epoch = max(1, (_n_idx + args.batch_size - 1) // args.batch_size)
|
||||
|
||||
# ── models ───────────────────────────────────────────────────────────────
|
||||
# LoRA training uses the BASE checkpoint (rectified_flow), NOT the shipped
|
||||
# ARC weights inference uses. Default to the base-model npz (auto-downloaded
|
||||
# from HF via weights.TRAINING_BASE); --dit-weights overrides with a path.
|
||||
t0 = time.time()
|
||||
import importlib
|
||||
if args.dit_weights:
|
||||
base_path = args.dit_weights
|
||||
else:
|
||||
base_path = str(ensure_local(f"models/mlx/dit_{args.dit}-base_f16.npz"))
|
||||
mod = importlib.import_module(DIT_CHOICES[args.dit]["loader"])
|
||||
dit_model = mod.load_dit(base_path, T_lat=crop_len, dtype=mx.float16,
|
||||
compile_=False)
|
||||
cond_src = base_path
|
||||
print(f"DiT loaded ({time.time()-t0:.1f}s, fp16 base, T_lat={crop_len})")
|
||||
t5 = T5Gemma.from_npz(str(ensure_local(T5GEMMA_NPZ_REL))) # frozen
|
||||
padding_emb, secs_embedder = load_conditioner_from_npz(cond_src, prefix="cond.")
|
||||
|
||||
# ── inject adapters ─────────────────────────────────────────────────────
|
||||
report, saved_config = inject_from_lora_config(
|
||||
dit_model, lora_config, checkpoint_prefix="model.",
|
||||
bora_mode=args.bora_mode)
|
||||
print(f"lora: {report.layer_count} DiT layer(s), {report.adapter_type}, "
|
||||
f"rank {args.rank}, alpha {alpha:g} "
|
||||
f"({report.trainable_parameters/1e6:.2f}M trainable)")
|
||||
|
||||
if args.grad_checkpoint:
|
||||
# Patch the TransformerBlock class (both DiT defs expose the blocks at
|
||||
# dit.transformer.layers) so every block recomputes in backward.
|
||||
grad_checkpoint(dit_model.transformer.layers[0])
|
||||
print(f"grad-checkpoint: on "
|
||||
f"({len(dit_model.transformer.layers)} transformer blocks)")
|
||||
|
||||
secs_module = None
|
||||
if not args.no_conditioner_lora:
|
||||
secs_module = TrainableSecondsEmbedder(secs_embedder.W, secs_embedder.b)
|
||||
try:
|
||||
cond_report, _ = inject_from_lora_config(
|
||||
secs_module, lora_config,
|
||||
checkpoint_prefix="conditioners.seconds_total.",
|
||||
bora_mode=args.bora_mode)
|
||||
print(f"lora: conditioner seconds embedder adapted "
|
||||
f"({cond_report.layer_count} layer)")
|
||||
except ValueError:
|
||||
secs_module = None # filtered out by include/exclude
|
||||
bundle = TrainBundle(dit_model, secs_module)
|
||||
|
||||
# ── resume ───────────────────────────────────────────────────────────────
|
||||
step_offset = epoch_offset = 0
|
||||
if args.lora_ckpt_path:
|
||||
sd, ckpt_cfg = load_lora_checkpoint(args.lora_ckpt_path)
|
||||
restored = load_trainable_lora_state(bundle, sd)
|
||||
step_offset, epoch_offset = resolve_offsets(args, ckpt_cfg)
|
||||
print(f"resumed {restored} adapter layer(s) from "
|
||||
f"{Path(args.lora_ckpt_path).name} (step {step_offset}, "
|
||||
f"epoch {epoch_offset})")
|
||||
else:
|
||||
step_offset, epoch_offset = resolve_offsets(args, None)
|
||||
|
||||
max_new_steps = max(0, args.max_steps - step_offset)
|
||||
|
||||
# ── optimizer: AdamW, single group; betas/eps/wd from config (torch
|
||||
# AdamW's decoupled weight decay — matches MLX's AdamW update exactly) ─────
|
||||
def scheduled_lr(step):
|
||||
"""SAT-dev InverseLR at 0-based step (== torch last_epoch on a fresh run,
|
||||
whose scheduler restarts even on resume). --lr-scheduler none → flat."""
|
||||
if args.lr_scheduler != "inverse":
|
||||
return args.lr
|
||||
warmup_factor = 1.0 - args.lr_warmup ** (step + 1)
|
||||
lr_mult = (1.0 + step / args.inv_gamma) ** (-args.lr_power)
|
||||
return warmup_factor * max(args.lr_final, args.lr * lr_mult)
|
||||
|
||||
optimizer = optim.AdamW(learning_rate=scheduled_lr(0),
|
||||
betas=[args.beta1, args.beta2],
|
||||
eps=args.eps, weight_decay=args.weight_decay)
|
||||
|
||||
# ── training-time local conditioning (underfit/upstream convention) ──────
|
||||
# The SA3 models are the diffusion_cond_inpaint variant: the trainer feeds
|
||||
# pure generation as an all-ONES inpaint mask + zero context (verified at
|
||||
# 80.7 dB forward parity vs the torch loop; the inference path's
|
||||
# local_add_cond=None ≡ zeros is an inference-only convention and trains
|
||||
# against the wrong conditioning regime — 4x loss difference).
|
||||
lac_const = mx.array(
|
||||
np.concatenate([np.ones((1, 1, crop_len), np.float32),
|
||||
np.zeros((1, 256, crop_len), np.float32)],
|
||||
axis=1).transpose(0, 2, 1)).astype(mx.float16)
|
||||
|
||||
# ── loss (adapters in the bundle are the only trainable params) ──────────
|
||||
# `seconds_in` is the raw seconds batch [B] fp32 when the trainable
|
||||
# seconds conditioner is active; with --no-conditioner-lora it is the
|
||||
# PRE-computed frozen seconds token [B, 1, 768] (built outside the step so
|
||||
# the compiled step stays pure mx — no numpy inside the traced graph).
|
||||
def loss_fn(bundle, latents, timesteps, loss_mask, prompt_cond,
|
||||
seconds_in, drop_mask):
|
||||
if bundle.secs is not None:
|
||||
sec_tok = bundle.secs(seconds_in) # [B, 1, 768] fp32
|
||||
else:
|
||||
sec_tok = seconds_in # frozen, precomputed
|
||||
cross = mx.concatenate([prompt_cond, sec_tok.astype(mx.float32)], axis=1)
|
||||
# CFG dropout (dit.py:441): whole cross_attn → zeros per sample;
|
||||
# global_cond (the seconds token) is kept.
|
||||
cross = mx.where(drop_mask[:, None, None], mx.zeros_like(cross), cross)
|
||||
global_cond = sec_tok[:, 0, :]
|
||||
cross16 = cross.astype(mx.float16)
|
||||
global16 = global_cond.astype(mx.float16)
|
||||
|
||||
def model_fn(noised, t):
|
||||
return bundle.dit(noised, t.astype(noised.dtype), cross16, global16,
|
||||
local_add_cond=lac_const)
|
||||
|
||||
return rectified_flow_loss(model_fn, latents, timesteps,
|
||||
loss_mask=loss_mask)
|
||||
|
||||
value_and_grad = nn.value_and_grad(bundle, loss_fn)
|
||||
|
||||
# ── train step (compiled by default — MLX "Compile > Training graphs") ──
|
||||
# Captured state: model params (the LoRA adapters the optimizer mutates),
|
||||
# optimizer moments, and the global PRNG key — rectified_flow_loss draws
|
||||
# its noise INSIDE the step. Everything np/python (timestep sampling, CFG
|
||||
# dropout draw, T5 encode, telemetry) stays OUTSIDE the step; batch
|
||||
# shapes/dtypes are constant (fixed crop + batch), so this traces once.
|
||||
# optimizer.init() materializes the moment arrays up front so the captured
|
||||
# state structure never changes (no recompile on step 2).
|
||||
optimizer.init(bundle.trainable_parameters())
|
||||
state = [bundle.state, optimizer.state, mx.random.state]
|
||||
clip_val = float(args.gradient_clip_val or 0.0)
|
||||
|
||||
def _global_l2(tree):
|
||||
"""sqrt(Σ ‖leaf‖²) over a param/grad tree in fp32 — the single global L2
|
||||
across all adapter leaves that underfit's _compute_grad_and_lora_norms
|
||||
reports (loop.py:243)."""
|
||||
total = mx.zeros((), dtype=mx.float32)
|
||||
for _, v in tree_flatten(tree):
|
||||
total = total + (v.astype(mx.float32) ** 2).sum()
|
||||
return mx.sqrt(total)
|
||||
|
||||
def train_step(latents, timesteps, loss_mask, prompt_cond, seconds_in,
|
||||
drop):
|
||||
loss, grads = value_and_grad(bundle, latents, timesteps, loss_mask,
|
||||
prompt_cond, seconds_in, drop)
|
||||
if clip_val > 0.0:
|
||||
grads, _ = optim.clip_grad_norm(grads, clip_val)
|
||||
# grad_norm on the POST-clip grads, lora_magnitude on the PRE-update
|
||||
# params — the order + definition underfit's torch loop uses (clip →
|
||||
# compute norms → optimizer.step, loop.py:628-637).
|
||||
grad_norm = _global_l2(grads)
|
||||
lora_mag = _global_l2(bundle.trainable_parameters())
|
||||
optimizer.update(bundle, grads)
|
||||
return loss, grad_norm, lora_mag
|
||||
|
||||
if args.compile:
|
||||
train_step = partial(mx.compile, inputs=state, outputs=state)(train_step)
|
||||
|
||||
# ── telemetry (underfit's loss_by_timestep.bin, struct "Iff") ────────────
|
||||
tele = open("loss_by_timestep.bin", "ab")
|
||||
|
||||
def checkpoint(step, epoch):
|
||||
fname = (f"{run_label}-step={step}-epoch={epoch}.safetensors"
|
||||
if run_label else f"step={step}-epoch={epoch}.safetensors")
|
||||
path = ckpt_dir / fname
|
||||
save_lora_checkpoint(
|
||||
bundle, path,
|
||||
include=lora_config.get("include"),
|
||||
exclude=lora_config.get("exclude"),
|
||||
extra_config={"step": int(step), "epoch": int(epoch),
|
||||
"base_model": base_model})
|
||||
print(f"\n ✓ checkpoint {path.name}")
|
||||
return path
|
||||
|
||||
# ── train loop (step-driven; epoch = one dataloader pass) ────────────────
|
||||
raw_step = 0
|
||||
epoch = epoch_offset
|
||||
last_saved_step = None
|
||||
t5_cache = {} if args.t5_cache else None
|
||||
t5_stats = {"hits": 0, "misses": 0}
|
||||
|
||||
# ── demos (underfit RF + ARC paths) ──────────────────────────────────────
|
||||
# RF/base entries: Euler inference on the BASE model + trained LoRA. ARC
|
||||
# entries (arc=true): the trained LoRA merged into the SHIPPED rf_denoiser
|
||||
# weights, sampled with the pingpong integrator — underfit's "train on base,
|
||||
# demo on ARC" story (its torch loop swaps in the full ARC model; here we
|
||||
# load the ARC npz with the LoRA merged at load). Both decode to mp3.
|
||||
# Baseline at the start, then every --demo-every steps + a final render.
|
||||
# Idempotent (a resume never re-renders an existing demo).
|
||||
demo_entries = None
|
||||
if args.demo_every > 0:
|
||||
if args.demo_config:
|
||||
demo_entries = json.loads(Path(args.demo_config).read_text())
|
||||
n_arc = sum(1 for e in demo_entries if e.get("arc"))
|
||||
print(f"demos: {len(demo_entries)} prompt(s) "
|
||||
f"({n_arc} ARC) every {args.demo_every} "
|
||||
f"step(s) → {args.demo_dir or '.'}")
|
||||
else:
|
||||
print("WARNING: --demo-every set but no --demo-config — demos disabled")
|
||||
demo_dir = args.demo_dir or "."
|
||||
demo_decoder_name = args.demo_decoder or DIT_CHOICES[args.dit]["default_decoder"]
|
||||
_model_default_decoder = DIT_CHOICES[args.dit]["default_decoder"]
|
||||
if demo_entries and demo_decoder_name != _model_default_decoder:
|
||||
print(f"demos: decoding with {demo_decoder_name.upper()} instead of the "
|
||||
f"model's {_model_default_decoder.upper()} (faster demos)")
|
||||
_demo_dec = {} # lazy: (decoder, chunk_fn, chunk_cfg)
|
||||
|
||||
def _demo_T_lat(entry):
|
||||
"""Per-demo latent length. The dashboard sets `duration` (seconds) on a
|
||||
demo entry only when it isn't at the crop length (see index.html
|
||||
demo_cond serialization) — full-length demos carry it, crop-length ones
|
||||
omit it. The DiT is length-agnostic (dit_mlx*), so one loaded model
|
||||
serves both without reloading."""
|
||||
dur = entry.get("duration")
|
||||
if dur:
|
||||
return max(2, int(round(float(dur) * demo.SAMPLE_RATE
|
||||
/ demo.SAMPLES_PER_LATENT)))
|
||||
return crop_len
|
||||
|
||||
def _demo_conditioning(prompt, seconds_val):
|
||||
"""cross_attn [1,257,768] fp16 + global_cond [1,768] fp16 for one demo
|
||||
prompt. Model-independent (T5Gemma + the trained seconds conditioner),
|
||||
so RF (base) and ARC demos share it."""
|
||||
prompt_cond = build_conditioning(t5, padding_emb, [prompt],
|
||||
cache=t5_cache, cache_stats=t5_stats)
|
||||
if bundle.secs is not None:
|
||||
sec_tok = bundle.secs(mx.array([seconds_val], dtype=mx.float32))
|
||||
else:
|
||||
sec_tok = secs_embedder([seconds_val])
|
||||
sec_tok = mx.stop_gradient(sec_tok.astype(mx.float32))
|
||||
cross = mx.concatenate([prompt_cond, sec_tok], axis=1).astype(mx.float16)
|
||||
gcond = sec_tok[:, 0, :].astype(mx.float16)
|
||||
return cross, gcond
|
||||
|
||||
def _run_arc_demos(step, arc_pending, decoder, chunk_fn, chunk_cfg):
|
||||
"""Merge the trained LoRA into a fresh copy of the SHIPPED rf_denoiser
|
||||
weights and sample with the pingpong integrator. Loaded once for the
|
||||
whole ARC set (length-agnostic), freed after (peak = base + ARC DiT)."""
|
||||
arc_path = args.arc_weights or str(
|
||||
ensure_local(f"models/mlx/dit_{args.dit}_f16.npz"))
|
||||
# Scratch LoRA for merging into the ARC DiT — write it to the SESSION
|
||||
# dir (ckpt_dir.parent), NOT the scanned checkpoints/ dir, so it never
|
||||
# shows up as a checkpoint (dotfile too, belt + suspenders).
|
||||
tmp_ckpt = ckpt_dir.parent / f".arc_demo_tmp_{step:08d}.safetensors"
|
||||
save_lora_checkpoint(
|
||||
bundle, tmp_ckpt, include=lora_config.get("include"),
|
||||
exclude=lora_config.get("exclude"),
|
||||
extra_config={"step": int(step), "base_model": base_model})
|
||||
t_a = time.time()
|
||||
try:
|
||||
arc_dit = mod.load_dit(arc_path, T_lat=crop_len, dtype=mx.float16,
|
||||
compile_=False, lora_paths=[str(tmp_ckpt)],
|
||||
lora_strength=1.0, lora_log=lambda *a, **k: None)
|
||||
print(f" ARC model ({args.dit}) + LoRA merged "
|
||||
f"({time.time()-t_a:.1f}s)")
|
||||
except Exception as e:
|
||||
print(f" ARC demos skipped: could not load "
|
||||
f"{Path(arc_path).name} with LoRA merged: {e}", flush=True)
|
||||
_rm(tmp_ckpt)
|
||||
return
|
||||
try:
|
||||
for i, entry in arc_pending:
|
||||
prompt = entry.get("prompt", "")
|
||||
cfg = float(entry.get("cfg", 1))
|
||||
seed = int(entry.get("seed", 0))
|
||||
steps = int(entry.get("steps", 8))
|
||||
T_lat = _demo_T_lat(entry)
|
||||
seconds_val = T_lat * demo.SAMPLES_PER_LATENT / demo.SAMPLE_RATE
|
||||
cross, gcond = _demo_conditioning(prompt, seconds_val)
|
||||
null = mx.zeros_like(cross) if cfg != 1.0 else None
|
||||
model_fn = demo.make_rf_model_fn(arc_dit, cross, gcond, cfg=cfg,
|
||||
null_cross_attn=null)
|
||||
sigmas = demo.build_sigmas(steps)
|
||||
noise = mx.random.normal((1, 256, T_lat), dtype=mx.float16,
|
||||
key=mx.random.key(seed))
|
||||
print(f" ▸ ARC demo {i}: {T_lat} latents (~{seconds_val:.0f}s audio), "
|
||||
f"{steps} steps", flush=True)
|
||||
try:
|
||||
t_gen = time.time()
|
||||
latent = demo.pingpong_sample(model_fn, noise, sigmas, seed=seed,
|
||||
desc=f"ARC demo {i}")
|
||||
audio = demo.decode_latents(decoder, chunk_fn, chunk_cfg,
|
||||
latent, T_lat)
|
||||
meta = {"prompt": prompt, "cfg": cfg, "seed": seed,
|
||||
"steps": steps, "step": int(step), "arc": True}
|
||||
demo.save_demo_mp3(audio, i, step, demo.SAMPLE_RATE,
|
||||
demo_dir, meta)
|
||||
print(f" ♪ ARC demo {i} @ step {step}: {prompt[:44]!r} "
|
||||
f"→ demo_{i}_{step:08d}.mp3 ({time.time()-t_gen:.0f}s)", flush=True)
|
||||
except Exception as e:
|
||||
print(f" ARC demo {i} failed: {e}", flush=True)
|
||||
finally:
|
||||
del arc_dit
|
||||
if hasattr(mx, "clear_cache"):
|
||||
mx.clear_cache()
|
||||
_rm(tmp_ckpt)
|
||||
|
||||
def run_demos(step):
|
||||
if not demo_entries:
|
||||
return
|
||||
os.makedirs(demo_dir, exist_ok=True)
|
||||
pending = [(i, e) for i, e in enumerate(demo_entries)
|
||||
if not os.path.exists(
|
||||
os.path.join(demo_dir, f"demo_{i}_{step:08d}.mp3"))]
|
||||
if not pending:
|
||||
return
|
||||
if "dec" not in _demo_dec:
|
||||
from sa3_mlx import load_decoder
|
||||
t_d = time.time()
|
||||
_demo_dec["dec"] = load_decoder(demo_decoder_name, dtype=mx.float32)
|
||||
print(f" demo decoder {demo_decoder_name} loaded ({time.time()-t_d:.1f}s)")
|
||||
decoder, chunk_fn, chunk_cfg = _demo_dec["dec"]
|
||||
adapters = list(iter_trainable_lora_layers(bundle))
|
||||
rf_pending = [(i, e) for i, e in pending if not e.get("arc")]
|
||||
arc_pending = [(i, e) for i, e in pending if e.get("arc")]
|
||||
|
||||
# ── RF/base demos (Euler on the base model + trained LoRA) ──
|
||||
for i, entry in rf_pending:
|
||||
prompt = entry.get("prompt", "")
|
||||
cfg = float(entry.get("cfg", 7))
|
||||
seed = int(entry.get("seed", 0))
|
||||
steps = int(entry.get("steps", 50))
|
||||
strength = entry.get("lora_strength")
|
||||
interval = entry.get("lora_interval_max")
|
||||
T_lat = _demo_T_lat(entry)
|
||||
seconds_val = T_lat * demo.SAMPLES_PER_LATENT / demo.SAMPLE_RATE
|
||||
cross, gcond = _demo_conditioning(prompt, seconds_val)
|
||||
null = mx.zeros_like(cross) if cfg != 1.0 else None
|
||||
model_fn = demo.make_rf_model_fn(bundle.dit, cross, gcond, cfg=cfg,
|
||||
null_cross_attn=null)
|
||||
sigmas = demo.build_sigmas(steps)
|
||||
noise = mx.random.normal((1, 256, T_lat), dtype=mx.float16,
|
||||
key=mx.random.key(seed))
|
||||
snap = before = None
|
||||
if interval is not None:
|
||||
snap = demo.snapshot_adapters(adapters)
|
||||
s_on = float(strength) if strength is not None else 1.0
|
||||
|
||||
def before(_i, sigma, s_on=s_on, interval=interval, snap=snap):
|
||||
demo.scale_adapters(snap, s_on if sigma <= interval else 0.0)
|
||||
elif strength is not None and float(strength) != 1.0:
|
||||
snap = demo.snapshot_adapters(adapters)
|
||||
demo.scale_adapters(snap, float(strength))
|
||||
print(f" ▸ demo {i}: {T_lat} latents (~{seconds_val:.0f}s audio), "
|
||||
f"{steps} steps", flush=True)
|
||||
try:
|
||||
t_gen = time.time()
|
||||
latent = demo.rf_euler_sample(model_fn, noise, sigmas,
|
||||
before_step=before, desc=f"demo {i}")
|
||||
audio = demo.decode_latents(decoder, chunk_fn, chunk_cfg, latent, T_lat)
|
||||
meta = {"prompt": prompt, "cfg": cfg, "seed": seed,
|
||||
"steps": steps, "step": int(step)}
|
||||
if strength is not None:
|
||||
meta["lora_strength"] = strength
|
||||
if interval is not None:
|
||||
meta["lora_interval_max"] = interval
|
||||
demo.save_demo_mp3(audio, i, step, demo.SAMPLE_RATE, demo_dir, meta)
|
||||
print(f" ♪ demo {i} @ step {step}: {prompt[:48]!r} "
|
||||
f"→ demo_{i}_{step:08d}.mp3 ({time.time()-t_gen:.0f}s)", flush=True)
|
||||
except Exception as e:
|
||||
print(f" demo {i} failed: {e}", flush=True)
|
||||
finally:
|
||||
if snap is not None:
|
||||
demo.scale_adapters(snap, 1.0) # restore trained factors
|
||||
|
||||
# ── ARC demos (pingpong on the shipped ARC weights + trained LoRA) ──
|
||||
if arc_pending:
|
||||
_run_arc_demos(step, arc_pending, decoder, chunk_fn, chunk_cfg)
|
||||
|
||||
if hasattr(mx, "clear_cache"):
|
||||
mx.clear_cache()
|
||||
|
||||
if hasattr(mx, "reset_peak_memory"):
|
||||
mx.reset_peak_memory() # measure the training loop, not weight loading
|
||||
t_start = time.time()
|
||||
print(f"training: max {max_new_steps} new step(s) "
|
||||
f"(global target {args.max_steps}), lr {args.lr:g}, "
|
||||
f"batch {args.batch_size}, cfg-dropout {args.cfg_dropout_prob}, "
|
||||
f"compile {'on' if args.compile else 'off'}")
|
||||
run_demos(step_offset) # baseline (untrained / resumed state)
|
||||
try:
|
||||
while raw_step < max_new_steps:
|
||||
# tqdm per epoch — byte-identical to underfit's torch loop
|
||||
# (loop.py:516) so the dashboard's progress collapse, history parser,
|
||||
# and grad-norm / lora-magnitude charts work unchanged. desc
|
||||
# "Step N, Epoch E"; postfix train/loss,lr,grad_norm,lora_magnitude.
|
||||
pbar = tqdm(
|
||||
iterate_batches(dataset, args.batch_size, shuffle=True,
|
||||
seed=args.seed + epoch),
|
||||
total=steps_per_epoch,
|
||||
desc=f"Step {raw_step + step_offset}, Epoch {epoch}",
|
||||
mininterval=0, miniters=1, file=sys.stdout,
|
||||
)
|
||||
for batch in pbar:
|
||||
if raw_step >= max_new_steps:
|
||||
break
|
||||
global_step = raw_step + step_offset + 1
|
||||
|
||||
latents = mx.array(batch["latents"]).astype(mx.float16)
|
||||
loss_mask = mx.array(batch["padding_mask"])
|
||||
seconds = mx.array(np.asarray(batch["seconds_total"],
|
||||
dtype=np.float32))
|
||||
|
||||
# timesteps: sampler + model-config distribution shift. The shift
|
||||
# sequence length is the EFFECTIVE latent length per sample when
|
||||
# --use-effective-length (underfit use_effective_length_for_schedule,
|
||||
# True in the SA3 templates), else the crop length.
|
||||
t_np = sample_training_timesteps(
|
||||
args.timestep_sampler, latents.shape[0], rng=np_rng)
|
||||
if args.dist_shift != "none":
|
||||
if args.use_effective_length:
|
||||
seq_len = np.asarray(
|
||||
[int(math.ceil(int(float(s) * 44100) / 4096))
|
||||
for s in batch["seconds_total"]])
|
||||
else:
|
||||
seq_len = latents.shape[-1]
|
||||
t_np = shift_training_timesteps(
|
||||
t_np, seq_len, shift_type=args.dist_shift,
|
||||
options=DIST_SHIFT_DEFAULT["options"]
|
||||
if args.dist_shift == "full" else None)
|
||||
timesteps = mx.array(t_np)
|
||||
|
||||
prompt_cond = build_conditioning(t5, padding_emb,
|
||||
batch["prompt"],
|
||||
cache=t5_cache,
|
||||
cache_stats=t5_stats)
|
||||
drop = mx.array((np_rng.random(latents.shape[0])
|
||||
< args.cfg_dropout_prob))
|
||||
if bundle.secs is not None:
|
||||
seconds_in = seconds
|
||||
else:
|
||||
# frozen conditioner: build the seconds token OUTSIDE the
|
||||
# (possibly compiled) step — see loss_fn
|
||||
seconds_in = mx.stop_gradient(secs_embedder(
|
||||
[float(s) for s in batch["seconds_total"]]))
|
||||
|
||||
# InverseLR (or flat): the scheduler steps per optimizer step,
|
||||
# last_epoch = raw_step (0-based new-step index), matching torch's
|
||||
# fresh-on-resume scheduler. Setting learning_rate updates the leaf
|
||||
# in optimizer.state, which the compiled step reads via inputs=state.
|
||||
lr_now = scheduled_lr(raw_step)
|
||||
if args.lr_scheduler != "none":
|
||||
optimizer.learning_rate = lr_now
|
||||
|
||||
loss, grad_norm, lora_mag = train_step(
|
||||
latents, timesteps, loss_mask, prompt_cond, seconds_in, drop)
|
||||
mx.eval(state, loss, grad_norm, lora_mag)
|
||||
|
||||
raw_step += 1
|
||||
loss_v = float(loss)
|
||||
tele.write(struct.pack("Iff", global_step,
|
||||
float(t_np.mean()), loss_v))
|
||||
if raw_step % 10 == 0:
|
||||
tele.flush()
|
||||
|
||||
# Metrics on the tqdm postfix — the dashboard parses
|
||||
# train/loss=, train/lr=, train/grad_norm=, train/lora_magnitude=
|
||||
# per step (server.py _HISTORY_RE/_LR_RE/_GN_RE/_LM_RE), and the
|
||||
# "Step N," prefix gives it the authoritative global step.
|
||||
pbar.set_postfix({
|
||||
"train/loss": f"{loss_v:.6f}",
|
||||
"train/lr": f"{lr_now:.3e}",
|
||||
"train/grad_norm": f"{float(grad_norm):.6f}",
|
||||
"train/lora_magnitude": f"{float(lora_mag):.6f}",
|
||||
})
|
||||
pbar.set_description(f"Step {global_step}, Epoch {epoch}")
|
||||
|
||||
save_now = (args.checkpoint_every > 0
|
||||
and global_step % args.checkpoint_every == 0)
|
||||
demo_now = (args.demo_every > 0
|
||||
and global_step % args.demo_every == 0)
|
||||
if save_now or demo_now:
|
||||
# Clear + disable the bar so checkpoint/demo prints land on
|
||||
# their own lines (matches the torch loop, loop.py:680).
|
||||
pbar.clear()
|
||||
pbar.disable = True
|
||||
try:
|
||||
if save_now:
|
||||
checkpoint(global_step, epoch) # save BEFORE demos
|
||||
last_saved_step = global_step
|
||||
if demo_now:
|
||||
run_demos(global_step)
|
||||
finally:
|
||||
pbar.disable = False
|
||||
pbar.refresh()
|
||||
pbar.close()
|
||||
epoch += 1
|
||||
except KeyboardInterrupt:
|
||||
print("\ninterrupted — saving final checkpoint")
|
||||
|
||||
global_step = raw_step + step_offset
|
||||
if raw_step > 0 and global_step != last_saved_step:
|
||||
checkpoint(global_step, epoch)
|
||||
if raw_step > 0:
|
||||
run_demos(global_step) # final render
|
||||
tele.close()
|
||||
if t5_cache is not None:
|
||||
print(f"t5 cache: {t5_stats['hits']} hit(s) / "
|
||||
f"{t5_stats['misses']} miss(es), {len(t5_cache)} cached")
|
||||
print(f"peak memory: {mx.get_peak_memory()/2**30:.2f} GB"
|
||||
+ (" (grad-checkpoint on)" if args.grad_checkpoint else ""))
|
||||
print(f"done: {raw_step} step(s) in {time.time()-t_start:.0f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""§9 forward-parity harness — MLX side + diff.
|
||||
|
||||
Reads shared_inputs.npz + torch_out.npz (from parity_forward_torch.py) and runs
|
||||
the SAME controlled forward through the MLX base DiT in fp32, then reports three
|
||||
PSNRs that LOCALIZE any mismatch:
|
||||
|
||||
(1) DiT-only — feed torch's cross/global + the shared noised into the MLX DiT.
|
||||
Isolates the DiT forward convention (weights are the same fp16 source→fp32,
|
||||
so ~79 dB is the cross-framework fp32 floor; a convention bug reads ≪40 dB).
|
||||
(2) conditioning — MLX-native cross/global vs torch's (T5 + seconds; fp16-T5 bound).
|
||||
(3) end-to-end — full MLX-native prediction + loss vs torch.
|
||||
|
||||
Also asserts the torch model's pretransform.scale == 1.0 (the softnorm no-scale
|
||||
convention the MLX trainer relies on). Run in the MLX venv:
|
||||
|
||||
python parity_forward_mlx.py --base-npz <dit_sm-music-base_f16.npz> --workdir <dir>
|
||||
"""
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def psnr(a, b):
|
||||
a = np.asarray(a, np.float64); b = np.asarray(b, np.float64)
|
||||
mse = np.mean((a - b) ** 2)
|
||||
if mse == 0:
|
||||
return float("inf")
|
||||
return 20 * np.log10(max(np.abs(a).max(), np.abs(b).max()) / np.sqrt(mse))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base-npz", required=True, help="MLX base DiT weights (dit_<model>-base_f16.npz)")
|
||||
ap.add_argument("--workdir", required=True)
|
||||
ap.add_argument("--dit-module", default="models.defs.dit_mlx")
|
||||
ap.add_argument("--prompt", default="techno")
|
||||
ap.add_argument("--seconds", type=float, default=30.0)
|
||||
args = ap.parse_args()
|
||||
|
||||
import mlx.core as mx
|
||||
from sa3_mlx import T5GEMMA_NPZ_REL
|
||||
from weights import ensure_local
|
||||
from models.defs.sa3_pipeline import apply_prompt_padding, load_conditioner_from_npz
|
||||
from models.defs.t5gemma_mlx import T5Gemma
|
||||
|
||||
tt = np.load(f"{args.workdir}/torch_out.npz")
|
||||
inp = np.load(f"{args.workdir}/shared_inputs.npz", allow_pickle=True)
|
||||
assert abs(float(tt["scale"]) - 1.0) < 1e-9, \
|
||||
f"pretransform.scale={float(tt['scale'])} != 1.0 — the MLX trainer's no-scale " \
|
||||
"softnorm convention is WRONG for this model (real bug)."
|
||||
|
||||
latents = mx.array(inp["latents"]).astype(mx.float32)
|
||||
noise = mx.array(inp["noise"]).astype(mx.float32)
|
||||
t = mx.array(inp["t"]).astype(mx.float32)
|
||||
B, C, T = latents.shape
|
||||
dit = importlib.import_module(args.dit_module).load_dit(
|
||||
args.base_npz, T_lat=T, dtype=mx.float32, compile_=False)
|
||||
|
||||
noised = latents * (1.0 - t)[:, None, None] + noise * t[:, None, None]
|
||||
target = noise - latents
|
||||
lac = mx.concatenate([mx.ones((B, 1, T)), mx.zeros((B, C, T))], axis=1).transpose(0, 2, 1).astype(mx.float32)
|
||||
|
||||
# (1) DiT-only: inject torch's cross/global
|
||||
pred1 = dit(noised, t, mx.array(tt["cross"]).astype(mx.float32),
|
||||
mx.array(tt["gcond"]).astype(mx.float32), local_add_cond=lac)
|
||||
mx.eval(pred1)
|
||||
|
||||
# (2) MLX-native conditioning
|
||||
t5 = T5Gemma.from_npz(str(ensure_local(T5GEMMA_NPZ_REL)))
|
||||
padding_emb, secs = load_conditioner_from_npz(args.base_npz, prefix="cond.")
|
||||
embeds, mask = t5.encode([args.prompt] * B, max_len=256)
|
||||
prompt_cond = apply_prompt_padding(embeds.astype(mx.float32), mask, padding_emb.astype(mx.float32))
|
||||
sec_tok = mx.stop_gradient(secs([args.seconds] * B).astype(mx.float32))
|
||||
cross_m = mx.concatenate([prompt_cond, sec_tok], axis=1)
|
||||
gcond_m = sec_tok[:, 0, :]
|
||||
mx.eval(cross_m, gcond_m)
|
||||
|
||||
# (3) end-to-end
|
||||
pred3 = dit(noised, t, cross_m, gcond_m, local_add_cond=lac)
|
||||
mx.eval(pred3)
|
||||
loss_m = float(mx.mean((pred3 - target) ** 2))
|
||||
|
||||
# (4) BACKWARD: ∂loss/∂noised through the DiT (with torch's cross/global so
|
||||
# the backward is compared on identical inputs — isolates the DiT backward).
|
||||
cross_t = mx.array(tt["cross"]).astype(mx.float32)
|
||||
gcond_t = mx.array(tt["gcond"]).astype(mx.float32)
|
||||
|
||||
def loss_of_noised(nz):
|
||||
pred = dit(nz, t, cross_t, gcond_t, local_add_cond=lac)
|
||||
return mx.mean((pred - target) ** 2)
|
||||
|
||||
grad_m = mx.grad(loss_of_noised)(noised)
|
||||
mx.eval(grad_m)
|
||||
p_grad = psnr(np.array(grad_m), tt["grad_noised"]) if "grad_noised" in tt else float("nan")
|
||||
|
||||
print(f"noised sanity PSNR = {psnr(np.array(noised), tt['noised']):.1f} dB (inf = bit-identical)")
|
||||
print(f"(1) DiT-only forward = {psnr(np.array(pred1), tt['prediction']):6.2f} dB [conv bug ≪40; fp32 floor ~79]")
|
||||
print(f"(2) conditioning = cross {psnr(np.array(cross_m), tt['cross']):.2f} dB / global {psnr(np.array(gcond_m), tt['gcond']):.2f} dB")
|
||||
print(f"(3) end-to-end pred = {psnr(np.array(pred3), tt['prediction']):6.2f} dB")
|
||||
print(f"(4) BACKWARD d loss/d noised PSNR = {p_grad:6.2f} dB [full DiT backward; conv bug ≪40]")
|
||||
print(f"loss: torch {float(tt['loss']):.6f} MLX {loss_m:.6f} |Δ| {abs(float(tt['loss'])-loss_m):.2e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,105 @@
|
||||
"""§9 forward-parity harness — torch (MPS) side.
|
||||
|
||||
Controlled single forward of the SA3 base model, replicating underfit's
|
||||
training-step forward (underfit/training/loop.py) on INJECTED inputs so the
|
||||
result is deterministic and diffable against the MLX trainer's forward
|
||||
(parity_forward_mlx.py). fp32, cfg_dropout=0.
|
||||
|
||||
Run in an env with `stable_audio_3` importable (the sa3 repo venv):
|
||||
|
||||
python parity_forward_torch.py \
|
||||
--base-dir <sa3-sm-music-base> # dir with model_config.json + model.safetensors
|
||||
--latents <clip.npy> # a [256,T] pre-encoded latent
|
||||
--workdir <dir> # writes shared_inputs.npz + torch_out.npz
|
||||
|
||||
Then run parity_forward_mlx.py (MLX venv) with the same --workdir.
|
||||
|
||||
The harness localizes any mismatch: it saves cross/global/noised/prediction/
|
||||
loss + the model's pretransform.scale (the MLX side asserts scale==1.0 for the
|
||||
softnorm no-scale convention; a non-1.0 value would be a real bug). Weights on
|
||||
both sides come from the same fp16 source upcast to fp32, so agreement is the
|
||||
cross-framework fp32 floor (~79 dB); a convention bug reads far lower.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from safetensors.torch import load_file
|
||||
from stable_audio_3.factory import create_diffusion_cond_from_config
|
||||
from stable_audio_3.loading_utils import remap_state_dict_keys
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base-dir", required=True,
|
||||
help="Base checkpoint dir (model_config.json + model.safetensors)")
|
||||
ap.add_argument("--latents", required=True, help="A [256,T] pre-encoded latent .npy")
|
||||
ap.add_argument("--workdir", required=True)
|
||||
ap.add_argument("--crop", type=int, default=256)
|
||||
ap.add_argument("--prompt", default="techno")
|
||||
ap.add_argument("--seconds", type=float, default=30.0)
|
||||
ap.add_argument("--noise-seed", type=int, default=12345)
|
||||
ap.add_argument("--device", default=None)
|
||||
args = ap.parse_args()
|
||||
os.makedirs(args.workdir, exist_ok=True)
|
||||
device = args.device or ("mps" if torch.backends.mps.is_available() else "cpu")
|
||||
|
||||
# Shared injected inputs (numpy — both engines consume these verbatim).
|
||||
clip = np.load(args.latents)[:, : args.crop]
|
||||
lat = np.stack([clip, clip, clip], 0).astype(np.float32) # B=3
|
||||
noise = np.random.default_rng(args.noise_seed).standard_normal(lat.shape).astype(np.float32)
|
||||
t = np.array([0.25, 0.5, 0.75], dtype=np.float32)
|
||||
np.savez(f"{args.workdir}/shared_inputs.npz", latents=lat, noise=noise, t=t,
|
||||
prompt=args.prompt, seconds_total=args.seconds)
|
||||
|
||||
cfg = json.load(open(f"{args.base_dir}/model_config.json"))
|
||||
print("building base model (fp32)...", flush=True)
|
||||
model = create_diffusion_cond_from_config(cfg)
|
||||
sd = load_file(f"{args.base_dir}/model.safetensors")
|
||||
sd = remap_state_dict_keys(sd, model.state_dict())
|
||||
missing, unexpected = model.load_state_dict(sd, strict=False)
|
||||
print(f"loaded: {len(missing)} missing / {len(unexpected)} unexpected", flush=True)
|
||||
model = model.to(device).eval().requires_grad_(False)
|
||||
|
||||
scale = float(getattr(model.pretransform, "scale", 1.0)) if model.pretransform is not None else 1.0
|
||||
print(f"pretransform_scale = {scale} diffusion_objective = {model.diffusion_objective}", flush=True)
|
||||
|
||||
latents = torch.tensor(lat).to(device)
|
||||
noise_t = torch.tensor(noise).to(device)
|
||||
tt = torch.tensor(t).to(device)
|
||||
B, C, T = latents.shape
|
||||
diffusion_input = latents / scale if scale != 1.0 else latents
|
||||
noised = diffusion_input * (1 - tt)[:, None, None] + noise_t * tt[:, None, None]
|
||||
target = noise_t - diffusion_input
|
||||
|
||||
metadata = [{"prompt": args.prompt, "seconds_total": args.seconds, "seconds_start": 0.0}
|
||||
for _ in range(B)]
|
||||
conditioning = model.conditioner(metadata, device)
|
||||
conditioning["inpaint_mask"] = [torch.ones(B, 1, T, device=device)]
|
||||
conditioning["inpaint_masked_input"] = [torch.zeros(B, C, T, device=device)]
|
||||
with torch.no_grad():
|
||||
ci = model.get_conditioning_inputs(conditioning)
|
||||
|
||||
# BACKWARD: gradient of the loss w.r.t. the noised input — the full DiT
|
||||
# backward sweep (every interior gradient the adapter grads are built from).
|
||||
noised.requires_grad_(True)
|
||||
output = model(noised, tt, cond=conditioning, cfg_dropout_prob=0.0,
|
||||
padding_mask=torch.ones(B, T, dtype=torch.bool, device=device))
|
||||
loss = ((output.float() - target.float()) ** 2).mean() # full mask → signal-only == mean
|
||||
loss.backward()
|
||||
grad_noised = noised.grad
|
||||
print(f"loss = {float(loss):.8f} |grad_noised| = {float(grad_noised.abs().mean()):.6e}", flush=True)
|
||||
np.savez(f"{args.workdir}/torch_out.npz",
|
||||
prediction=output.detach().float().cpu().numpy(), loss=float(loss),
|
||||
cross=ci["cross_attn_cond"].float().cpu().numpy(),
|
||||
gcond=ci["global_cond"].float().cpu().numpy(),
|
||||
noised=noised.detach().float().cpu().numpy(),
|
||||
target=target.float().cpu().numpy(),
|
||||
grad_noised=grad_noised.float().cpu().numpy(), scale=scale)
|
||||
print(f"wrote {args.workdir}/torch_out.npz", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Pre-encode audio files into SAME latents for LoRA finetuning (MLX, torch-free).
|
||||
|
||||
Offline audio → latent CLI mirroring underfit's ``dataset_processing/pre_encode.py``
|
||||
conventions, ported to the standalone MLX runtime: single-process, soundfile-based
|
||||
loading (no torch / torchaudio), and ``models.defs.audio_encoding.encode_audio``
|
||||
doing the padding / chunking / masking.
|
||||
|
||||
Every audio file under ``--audio-dir`` (recursive) becomes a ``.npy`` + ``.json``
|
||||
pair at the same relative path under ``--output-dir``:
|
||||
|
||||
<output-dir>/<relpath-with-npy-suffix> latents [D, T] float32
|
||||
<output-dir>/<relpath-with-json-suffix> metadata (underfit field parity)
|
||||
|
||||
Conventions matched to underfit:
|
||||
* Whole files, no clip splitting; cap at ``--max-duration`` seconds (default
|
||||
600) with the sample cap aligned DOWN to a multiple of 4096 (ds_ratio).
|
||||
* 44.1 kHz stereo — mono is duplicated to stereo, >2 channels keep the first
|
||||
two, other sample rates are linearly resampled. NO loudness/peak norm.
|
||||
* Encoder runs fp32; ``chunked=True`` for clips longer than 30 s.
|
||||
* Metadata: ``path, relpath, src_relpath, seconds_total`` (rounded 3dp),
|
||||
``seconds_start: 0, audio_samples, latent_shape``, latent-resolution 0/1
|
||||
``padding_mask``, merged with tags extracted from sidecars.
|
||||
* Skip files whose outputs already exist unless ``--overwrite``.
|
||||
* ``<output-dir>/details.json`` records the global encode params.
|
||||
|
||||
Latent scaling note: the MLX SAME encoders output DiT-space (softnorm) latents
|
||||
directly — the equivalent of torch's unscaled-latents-then-divide-by-scale
|
||||
convention with scale 1.0 — so latents are saved as-is and the trainer uses
|
||||
them directly (no pretransform-scale division at train time).
|
||||
|
||||
Tag extraction (ported from underfit's ``extract_tags``): JSON sidecar
|
||||
(``<stem>.json`` next to the audio, or ``<parent>/json/<stem>.json``) keeps all
|
||||
non-empty string/number values; else a ``.txt`` sidecar (same dir or
|
||||
``<parent>/txt/``) whose whole stripped content becomes the ``prompt`` tag.
|
||||
Embedded ID3/Vorbis tags are a DELIBERATE OMISSION — they need an extra
|
||||
dependency (``audio_metadata``), and sidecars are the underfit-recommended
|
||||
path anyway.
|
||||
|
||||
Usage:
|
||||
python pre_encode_mlx.py --audio-dir ~/clips --output-dir ~/latents \
|
||||
--codec same-s [--max-duration 600] [--overwrite]
|
||||
|
||||
``--codec same-s`` pairs with the sm-music / sm-sfx DiTs, ``same-l`` with medium.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent # optimized/mlx (scripts/ is one level down)
|
||||
if str(REPO) not in sys.path:
|
||||
sys.path.insert(0, str(REPO))
|
||||
if str(REPO / "scripts") not in sys.path:
|
||||
sys.path.insert(0, str(REPO / "scripts"))
|
||||
|
||||
import mlx.core as mx # noqa: E402
|
||||
|
||||
from models.defs.audio_encoding import ( # noqa: E402
|
||||
SAME_ENCODER_PAD_MODULO,
|
||||
SAMPLES_PER_LATENT,
|
||||
encode_audio,
|
||||
)
|
||||
|
||||
SAMPLE_RATE = 44100
|
||||
DS_RATIO = SAMPLES_PER_LATENT # 4096 samples per latent
|
||||
CHUNKED_THRESHOLD_SECONDS = 30.0
|
||||
|
||||
AUDIO_EXTS = {".wav", ".mp3", ".flac", ".ogg", ".aif", ".aiff", ".m4a"}
|
||||
_MIN_AUDIO_SIZE = 4096 # skip files smaller than this (macOS resource forks, corrupt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audio discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_audio_files(root) -> list[Path]:
|
||||
"""Recursively find audio files under ``root``, sorted by path.
|
||||
|
||||
Skips macOS resource forks (``._*``) and files too small to be real audio.
|
||||
"""
|
||||
files = []
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
for fn in filenames:
|
||||
if fn.startswith("._"):
|
||||
continue
|
||||
fp = Path(dirpath) / fn
|
||||
if fp.suffix.lower() in AUDIO_EXTS:
|
||||
try:
|
||||
if fp.stat().st_size < _MIN_AUDIO_SIZE:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
files.append(fp)
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audio loading (torch-free)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_audio(path) -> np.ndarray:
|
||||
"""Load an audio file → ``(2, T)`` float32 at 44.1 kHz.
|
||||
|
||||
soundfile decodes; mono is duplicated to stereo, >2 channels keep the
|
||||
first two; non-44.1 kHz input is linearly resampled at
|
||||
align_corners=False positions (same pattern as sa3_gradio.read_audio_any).
|
||||
No loudness/peak normalization.
|
||||
"""
|
||||
import soundfile as sf
|
||||
|
||||
data, sr = sf.read(str(path), dtype="float32", always_2d=True) # (T, ch)
|
||||
y = data.T
|
||||
y = np.stack([y[0], y[0]]) if y.shape[0] == 1 else y[:2]
|
||||
if sr != SAMPLE_RATE:
|
||||
in_len = y.shape[-1]
|
||||
new_len = int(round(in_len * SAMPLE_RATE / sr))
|
||||
scale = in_len / new_len
|
||||
pos = np.clip((np.arange(new_len) + 0.5) * scale - 0.5, 0, in_len - 1)
|
||||
y = np.stack([np.interp(pos, np.arange(in_len), ch) for ch in y])
|
||||
return np.ascontiguousarray(y, dtype=np.float32)
|
||||
|
||||
|
||||
def max_samples_for_duration(max_duration: float) -> int:
|
||||
"""Sample cap for ``max_duration`` seconds, aligned DOWN to ds_ratio (4096)."""
|
||||
return (int(max_duration * SAMPLE_RATE) // DS_RATIO) * DS_RATIO
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sidecar tag extraction (ported from underfit pre_encode.extract_tags;
|
||||
# embedded ID3/Vorbis tags deliberately omitted — see module docstring)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_tags(filepath) -> dict:
|
||||
"""Extract tag fields for an audio file from sidecar files.
|
||||
|
||||
Priority:
|
||||
1. JSON sidecar (``<stem>.json`` same dir, or ``<parent>/json/<stem>.json``)
|
||||
— all non-empty string/number values kept (custom keys preserved).
|
||||
2. Plain ``.txt`` sidecar (same dir or ``<parent>/txt/``) — whole stripped
|
||||
content becomes the ``prompt`` tag.
|
||||
Returns ``{}`` when neither sidecar exists (no embedded-tag fallback).
|
||||
"""
|
||||
fp = Path(filepath)
|
||||
stem = fp.stem
|
||||
|
||||
# 1. JSON sidecar — same dir, then sibling json/ dir
|
||||
sidecar_candidates = [
|
||||
fp.with_suffix(".json"),
|
||||
fp.parent.parent / "json" / (stem + ".json"),
|
||||
]
|
||||
for sidecar in sidecar_candidates:
|
||||
if sidecar.exists():
|
||||
try:
|
||||
with open(sidecar) as f:
|
||||
sc = json.load(f)
|
||||
tags_out = {
|
||||
k: str(v)
|
||||
for k, v in sc.items()
|
||||
if v and isinstance(v, (str, int, float))
|
||||
}
|
||||
if tags_out:
|
||||
return tags_out
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Plain .txt sidecar (SA3 convention) — same dir, then sibling txt/ dir.
|
||||
txt_candidates = [
|
||||
fp.with_suffix(".txt"),
|
||||
fp.parent.parent / "txt" / (stem + ".txt"),
|
||||
]
|
||||
for txt in txt_candidates:
|
||||
if txt.exists():
|
||||
try:
|
||||
content = txt.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if content:
|
||||
return {"prompt": content}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core encode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def encode_file(
|
||||
encoder,
|
||||
audio_np: np.ndarray,
|
||||
*,
|
||||
pad_modulo: int,
|
||||
actual_samples: int | None = None,
|
||||
):
|
||||
"""Encode one ``(2, T)`` float32 waveform → (latents, padding_mask, seconds).
|
||||
|
||||
Returns:
|
||||
latents_np: ``[D, T_latent]`` float32 (softnorm/DiT space, saved as-is)
|
||||
padding_mask: latent-resolution 0/1 int list, length ``T_latent`` —
|
||||
zeros over the alignment-padding tail
|
||||
seconds_total: ``round(actual_samples / 44100, 3)``
|
||||
|
||||
``chunked=True`` is used automatically for clips longer than 30 s.
|
||||
"""
|
||||
audio_np = np.asarray(audio_np)
|
||||
if audio_np.ndim != 2 or audio_np.shape[0] != 2:
|
||||
raise ValueError(f"Expected audio shaped (2, T), got {audio_np.shape}.")
|
||||
if actual_samples is None:
|
||||
actual_samples = int(audio_np.shape[-1])
|
||||
duration = actual_samples / SAMPLE_RATE
|
||||
encoded = encode_audio(
|
||||
encoder,
|
||||
audio_np[None, ...],
|
||||
valid_sample_lengths=[actual_samples],
|
||||
pad_modulo=pad_modulo,
|
||||
chunked=duration > CHUNKED_THRESHOLD_SECONDS,
|
||||
)
|
||||
latents_np = np.asarray(encoded.latents.astype(mx.float32))[0] # [D, T]
|
||||
padding_mask = [int(v) for v in np.asarray(encoded.padding_mask)[0]]
|
||||
seconds_total = round(actual_samples / SAMPLE_RATE, 3)
|
||||
return latents_np, padding_mask, seconds_total
|
||||
|
||||
|
||||
def build_metadata(
|
||||
fpath,
|
||||
rel,
|
||||
out_rel,
|
||||
*,
|
||||
actual_samples: int,
|
||||
latent_shape,
|
||||
padding_mask,
|
||||
seconds_total,
|
||||
) -> dict:
|
||||
"""Underfit-parity metadata dict for one encoded file (tags merged last)."""
|
||||
meta = {
|
||||
"path": str(fpath),
|
||||
"relpath": str(out_rel),
|
||||
"src_relpath": str(rel),
|
||||
"seconds_total": seconds_total,
|
||||
"seconds_start": 0,
|
||||
"audio_samples": int(actual_samples),
|
||||
"latent_shape": [int(s) for s in latent_shape],
|
||||
"padding_mask": padding_mask,
|
||||
}
|
||||
meta.update(extract_tags(fpath))
|
||||
return meta
|
||||
|
||||
|
||||
def run(
|
||||
audio_dir,
|
||||
output_dir,
|
||||
codec: str,
|
||||
encoder,
|
||||
*,
|
||||
max_duration: float = 600.0,
|
||||
overwrite: bool = False,
|
||||
exclude: set | None = None,
|
||||
) -> dict:
|
||||
"""Encode every audio file under ``audio_dir`` into ``output_dir``.
|
||||
|
||||
Returns stats: ``{"encoded", "skipped", "errors", "count"}``.
|
||||
"""
|
||||
if codec not in SAME_ENCODER_PAD_MODULO:
|
||||
raise ValueError(f"Unknown codec {codec!r}; expected same-s or same-l.")
|
||||
pad_modulo = SAME_ENCODER_PAD_MODULO[codec]
|
||||
|
||||
audio_dir = Path(audio_dir).expanduser().resolve()
|
||||
output_dir = Path(output_dir).expanduser().resolve()
|
||||
files = find_audio_files(audio_dir)
|
||||
if exclude:
|
||||
files = [f for f in files if str(f.relative_to(audio_dir)) not in exclude]
|
||||
max_samples = max_samples_for_duration(max_duration)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
details = {
|
||||
"codec": codec,
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"max_duration": max_duration,
|
||||
"max_samples": max_samples,
|
||||
"pad_modulo": pad_modulo,
|
||||
"audio_dir": str(audio_dir),
|
||||
"count": len(files),
|
||||
}
|
||||
with open(output_dir / "details.json", "w") as f:
|
||||
json.dump(details, f, indent=2)
|
||||
|
||||
total = len(files)
|
||||
encoded = skipped = errors = 0
|
||||
t0 = time.time()
|
||||
for index, fpath in enumerate(files, 1):
|
||||
rel = fpath.relative_to(audio_dir)
|
||||
out_rel = rel.with_suffix(".npy")
|
||||
npy_path = output_dir / out_rel
|
||||
json_path = npy_path.with_suffix(".json")
|
||||
tag = f"[{index}/{total}]"
|
||||
if npy_path.exists() and json_path.exists() and not overwrite:
|
||||
skipped += 1
|
||||
print(f" {tag} SKIP {out_rel} (exists)")
|
||||
continue
|
||||
try:
|
||||
audio = load_audio(fpath)
|
||||
actual_samples = int(audio.shape[-1])
|
||||
if actual_samples > max_samples:
|
||||
audio = audio[:, :max_samples]
|
||||
actual_samples = max_samples
|
||||
|
||||
latents_np, padding_mask, seconds_total = encode_file(
|
||||
encoder, audio, pad_modulo=pad_modulo, actual_samples=actual_samples
|
||||
)
|
||||
|
||||
npy_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.save(str(npy_path), latents_np)
|
||||
meta = build_metadata(
|
||||
fpath,
|
||||
rel,
|
||||
out_rel,
|
||||
actual_samples=actual_samples,
|
||||
latent_shape=latents_np.shape,
|
||||
padding_mask=padding_mask,
|
||||
seconds_total=seconds_total,
|
||||
)
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(meta, f)
|
||||
|
||||
encoded += 1
|
||||
shape_str = "x".join(str(s) for s in latents_np.shape)
|
||||
print(f" {tag} {out_rel} {seconds_total:.1f}s -> [{shape_str}]")
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
print(f" {tag} ERROR {rel}: {e}")
|
||||
|
||||
elapsed = time.time() - t0
|
||||
mins, secs = divmod(int(elapsed), 60)
|
||||
print(
|
||||
f"\nDone in {mins}m{secs:02d}s: {encoded} encoded, {skipped} skipped, "
|
||||
f"{errors} errors (of {total})"
|
||||
)
|
||||
print(f"Output: {output_dir}")
|
||||
return {"encoded": encoded, "skipped": skipped, "errors": errors, "count": total}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_codec_encoder(codec: str):
|
||||
"""Load the real SAME encoder for ``codec`` in fp32. Returns (encoder, pad_modulo)."""
|
||||
from sa3_mlx import load_encoder # lazy: pulls weight-download machinery
|
||||
|
||||
return load_encoder(codec, mx.float32)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pre-encode audio into SAME latents for MLX LoRA finetuning",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio-dir", "-i", required=True,
|
||||
help="Directory of audio files (searched recursively)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir", "-o", required=True,
|
||||
help="Output root; each audio file becomes <output-dir>/<relpath>.npy + .json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--codec", required=True, choices=sorted(SAME_ENCODER_PAD_MODULO),
|
||||
help="Autoencoder: same-s pairs with sm-music/sm-sfx, same-l with medium",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-duration", type=float, default=600.0,
|
||||
help="Crop files longer than N seconds (default: 600; aligned down to 4096 samples)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite", action="store_true",
|
||||
help="Re-encode files whose outputs already exist",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-file", type=str, default=None,
|
||||
help="Text file of relpaths (one per line, relative to --audio-dir) to skip",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
audio_dir = Path(args.audio_dir).expanduser().resolve()
|
||||
if not audio_dir.is_dir():
|
||||
print(f"Error: not a directory: {audio_dir}")
|
||||
sys.exit(1)
|
||||
files = find_audio_files(audio_dir)
|
||||
if not files:
|
||||
print(f"No audio files found in {audio_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
exclude = set()
|
||||
if args.exclude_file and Path(args.exclude_file).is_file():
|
||||
exclude = {ln.strip() for ln in Path(args.exclude_file).read_text().splitlines()
|
||||
if ln.strip()}
|
||||
n_before = len(files)
|
||||
if exclude:
|
||||
files = [f for f in files if str(f.relative_to(audio_dir)) not in exclude]
|
||||
if not files:
|
||||
print(f"All {n_before} file(s) excluded by {args.exclude_file}")
|
||||
sys.exit(1)
|
||||
|
||||
max_samples = max_samples_for_duration(args.max_duration)
|
||||
import mlx.core as mx
|
||||
print(f"Engine: MLX · device: {mx.default_device()}")
|
||||
print(f"Codec: {args.codec} (pad_modulo={SAME_ENCODER_PAD_MODULO[args.codec]}, fp32)")
|
||||
print(f"Input: {audio_dir} ({len(files)} audio files"
|
||||
+ (f", {n_before - len(files)} excluded)" if exclude else ")"))
|
||||
print(f"Output: {Path(args.output_dir).expanduser().resolve()}")
|
||||
print(f"Max: {max_samples / SAMPLE_RATE:.1f}s ({max_samples:,} samples)\n")
|
||||
|
||||
print("Loading encoder...")
|
||||
encoder, pad_modulo = load_codec_encoder(args.codec)
|
||||
assert pad_modulo == SAME_ENCODER_PAD_MODULO[args.codec]
|
||||
|
||||
run(
|
||||
audio_dir,
|
||||
args.output_dir,
|
||||
args.codec,
|
||||
encoder,
|
||||
max_duration=args.max_duration,
|
||||
overwrite=args.overwrite,
|
||||
exclude=exclude,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,891 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Mel-spectrogram renderer — numpy-only port of the TensorRT release's spec.py.
|
||||
|
||||
Same algorithm and constants as optimized/tensorRT/scripts/spec.py (the 3-band
|
||||
tinted stereo mel spectrogram from our run dashboards), with the torch pieces
|
||||
(STFT, resample, filterbank matmul) rewritten in numpy so the MLX release stays
|
||||
torch-free. Numerics match: periodic Hann window, center=True reflect padding,
|
||||
Slaney mel scale, librosa-compatible filterbank, linear resample with
|
||||
align_corners=False sampling positions.
|
||||
|
||||
Exposes render_spectrogram_png(pcm_int16, sample_rate, width, height).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import math
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# 3-band tint
|
||||
SPEC_BANDS = [
|
||||
(0, 200, (1.0, 0.0, 0.0)), # Bass -> Red
|
||||
(200, 1500, (0.0, 1.0, 0.0)), # Mid -> Green
|
||||
(1500, 16000, (0.0, 0.0, 1.0)), # High -> Blue
|
||||
]
|
||||
_BAND_COLORS = np.array([c for _, _, c in SPEC_BANDS], dtype=np.float32)
|
||||
|
||||
# Slaney mel-scale constants (linear below 1 kHz, log above), matches librosa default.
|
||||
_F_SP = 200.0 / 3
|
||||
_MIN_LOG_HZ = 1000.0
|
||||
_MIN_LOG_MEL = _MIN_LOG_HZ / _F_SP
|
||||
_LOGSTEP = math.log(6.4) / 27.0
|
||||
|
||||
|
||||
def _hz_to_mel(hz):
|
||||
hz = np.asarray(hz, dtype=np.float64)
|
||||
log_term = np.log(np.maximum(hz, _MIN_LOG_HZ) / _MIN_LOG_HZ) / _LOGSTEP
|
||||
return np.where(hz >= _MIN_LOG_HZ, _MIN_LOG_MEL + log_term, hz / _F_SP)
|
||||
|
||||
|
||||
def _mel_to_hz(mels):
|
||||
mels = np.asarray(mels, dtype=np.float64)
|
||||
return np.where(mels >= _MIN_LOG_MEL,
|
||||
_MIN_LOG_HZ * np.exp(_LOGSTEP * (mels - _MIN_LOG_MEL)),
|
||||
_F_SP * mels)
|
||||
|
||||
|
||||
def _mel_frequencies(n_mels, fmax, fmin=0.0):
|
||||
return _mel_to_hz(np.linspace(_hz_to_mel(fmin), _hz_to_mel(fmax), n_mels))
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _mel_filterbank(n_mels, n_fft, sr, fmax):
|
||||
pts = _mel_to_hz(np.linspace(_hz_to_mel(0.0), _hz_to_mel(fmax), n_mels + 2))
|
||||
fft_f = np.linspace(0, sr / 2, n_fft // 2 + 1)
|
||||
filt = np.zeros((n_mels, n_fft // 2 + 1), dtype=np.float32)
|
||||
for i in range(n_mels):
|
||||
lo, ce, hi = pts[i], pts[i + 1], pts[i + 2]
|
||||
left = (fft_f - lo) / max(ce - lo, 1e-10)
|
||||
right = (hi - fft_f) / max(hi - ce, 1e-10)
|
||||
filt[i] = np.maximum(0, np.minimum(left, right))
|
||||
enorm = (2.0 / (pts[2:n_mels + 2] - pts[0:n_mels])).astype(np.float32)
|
||||
filt *= enorm[:, None]
|
||||
return filt
|
||||
|
||||
|
||||
def _stft_power(y, n_fft, hop):
|
||||
"""|STFT|^2 with periodic Hann + center=True reflect pad (torch.stft-compatible).
|
||||
Returns (n_fft//2+1, n_frames) float32."""
|
||||
y = np.asarray(y, dtype=np.float32)
|
||||
pad = n_fft // 2
|
||||
y = np.pad(y, pad, mode="reflect")
|
||||
n_frames = 1 + (len(y) - n_fft) // hop
|
||||
idx = np.arange(n_fft)[None, :] + hop * np.arange(n_frames)[:, None]
|
||||
# periodic Hann (torch.hann_window default), not numpy's symmetric hanning
|
||||
win = (0.5 - 0.5 * np.cos(2.0 * np.pi * np.arange(n_fft) / n_fft)).astype(np.float32)
|
||||
frames = y[idx] * win[None, :]
|
||||
spec = np.fft.rfft(frames, n=n_fft, axis=1)
|
||||
return (spec.real ** 2 + spec.imag ** 2).T.astype(np.float32)
|
||||
|
||||
|
||||
def _melspectrogram(y_ch, sr, n_mels=30, fmax=16000, hop_length=2048, n_fft=2048):
|
||||
power = _stft_power(y_ch, n_fft, hop_length)
|
||||
return _mel_filterbank(n_mels, n_fft, sr, fmax) @ power
|
||||
|
||||
|
||||
def _power_to_db(S, top_db=80.0):
|
||||
log_spec = 10.0 * np.log10(np.maximum(S, 1e-10))
|
||||
return np.maximum(log_spec - log_spec.max(), -top_db)
|
||||
|
||||
|
||||
def _mel_channel(y_ch, sr, n_mels=30):
|
||||
"""dB-scaled mel + band-tinted RGB for one mono channel."""
|
||||
S = _melspectrogram(y_ch, sr, n_mels=n_mels, fmax=16000, hop_length=2048)
|
||||
|
||||
S_db = _power_to_db(S)
|
||||
np.clip(S_db, -60, 0, out=S_db)
|
||||
S_db += 60.0
|
||||
S_db /= 60.0
|
||||
np.power(S_db, 0.6, out=S_db)
|
||||
|
||||
mel_f = _mel_frequencies(n_mels, fmax=16000)
|
||||
n_frames = S.shape[1]
|
||||
band_norms = np.empty((3, n_frames), dtype=np.float32)
|
||||
for i, (flo, fhi, _) in enumerate(SPEC_BANDS):
|
||||
mask = (mel_f >= flo) & (mel_f < fhi)
|
||||
if mask.any():
|
||||
power = np.sum(S[mask], axis=0)
|
||||
db = 10.0 * np.log10(power + 1e-10)
|
||||
np.clip(db, -20, None, out=db)
|
||||
db -= -20
|
||||
mx = db.max()
|
||||
if mx > 0:
|
||||
db /= mx
|
||||
band_norms[i] = db
|
||||
else:
|
||||
band_norms[i] = 0.0
|
||||
|
||||
rgb = band_norms.T @ _BAND_COLORS
|
||||
for c in range(3):
|
||||
mx = rgb[:, c].max()
|
||||
if mx > 0:
|
||||
rgb[:, c] /= mx
|
||||
|
||||
return S_db, rgb
|
||||
|
||||
|
||||
def _resample_to_32k(pcm_int16, sr_in):
|
||||
"""Resample (T, 2) int16 PCM to (2, T') float32 at 32 kHz.
|
||||
Linear interpolation at align_corners=False sample positions (matches the
|
||||
torch.nn.functional.interpolate call in the TRT original)."""
|
||||
target_sr = 32000
|
||||
y = pcm_int16.astype(np.float32).T / 32768.0 # (2, T)
|
||||
if sr_in == target_sr:
|
||||
return y, target_sr
|
||||
in_len = y.shape[-1]
|
||||
new_len = int(round(in_len * target_sr / sr_in))
|
||||
scale = in_len / new_len
|
||||
pos = np.clip((np.arange(new_len, dtype=np.float64) + 0.5) * scale - 0.5, 0, in_len - 1)
|
||||
out = np.stack([np.interp(pos, np.arange(in_len), ch) for ch in y]).astype(np.float32)
|
||||
return out, target_sr
|
||||
|
||||
|
||||
def render_spectrogram_png(pcm_int16, sample_rate=44100, width=1200, height=240,
|
||||
n_mels=30) -> bytes:
|
||||
"""Render the 3-band tinted stereo mel spectrogram to PNG bytes.
|
||||
|
||||
Args:
|
||||
pcm_int16: (T, 2) int16 numpy array — stereo PCM.
|
||||
sample_rate: input PCM sample rate (default 44100).
|
||||
width, height: final image size.
|
||||
n_mels: mel filterbank size (30 matches the dashboards).
|
||||
|
||||
Returns: PNG bytes ready to base64 / save / etc.
|
||||
"""
|
||||
if pcm_int16.ndim != 2 or pcm_int16.shape[1] != 2:
|
||||
raise ValueError(f"expected (T, 2) PCM, got {pcm_int16.shape}")
|
||||
y, sr = _resample_to_32k(pcm_int16, sample_rate)
|
||||
S_L, rgb_L = _mel_channel(y[0], sr, n_mels=n_mels)
|
||||
S_R, rgb_R = _mel_channel(y[1], sr, n_mels=n_mels)
|
||||
|
||||
nf = min(S_L.shape[1], S_R.shape[1])
|
||||
S_L, S_R = S_L[:, :nf], S_R[:, :nf]
|
||||
rgb_L, rgb_R = rgb_L[:nf], rgb_R[:nf]
|
||||
nm = S_L.shape[0]
|
||||
|
||||
# L channel: flip vertically so bass-band is at bottom.
|
||||
S_L = S_L[::-1]
|
||||
|
||||
img = np.empty((nm * 2, nf, 3), dtype=np.float32)
|
||||
img[:nm] = S_L[:, :, np.newaxis] * rgb_L[np.newaxis, :, :]
|
||||
img[nm:] = S_R[:, :, np.newaxis] * rgb_R[np.newaxis, :, :]
|
||||
|
||||
np.clip(img, 0, 1, out=img)
|
||||
img *= 255
|
||||
pil = Image.fromarray(img.astype(np.uint8)).resize((width, height), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
pil.save(buf, format="PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Sanity-check every shipped npz and every CLI config of sa3_mlx.py.
|
||||
|
||||
Run from inside the sa3_mlx/ folder:
|
||||
python test_all_configs.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import os, subprocess, sys, time, wave, tempfile
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent # project root (scripts/ is one level down)
|
||||
PY = sys.executable
|
||||
|
||||
|
||||
# ─── expected weight files ────────────────────────────────────────────────
|
||||
|
||||
EXPECTED_NPZ = {
|
||||
"models/mlx/dit_sm-music_f16.npz": {"min_keys": 400, "must_have_prefix": "cond."},
|
||||
"models/mlx/dit_sm-sfx_f16.npz": {"min_keys": 400, "must_have_prefix": "cond."},
|
||||
"models/mlx/dit_medium_f16.npz": {"min_keys": 500, "must_have_prefix": "cond."},
|
||||
"models/mlx/same_s_encoder_f32.npz": {"min_keys": 100},
|
||||
"models/mlx/same_s_decoder_f32.npz": {"min_keys": 100},
|
||||
"models/mlx/same_l_encoder_f32.npz": {"min_keys": 200},
|
||||
"models/mlx/same_l_decoder_f32.npz": {"min_keys": 200},
|
||||
"models/mlx/t5gemma_f16.npz": {"min_keys": 130, "must_have_key": "TOKENIZER_MODEL"},
|
||||
}
|
||||
|
||||
|
||||
# ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def check_npz(path: Path, spec: dict) -> tuple[bool, str]:
|
||||
"""Open the npz and verify it has the expected shape/keys."""
|
||||
try:
|
||||
z = np.load(path, allow_pickle=True)
|
||||
except Exception as e:
|
||||
return False, f"load failed: {e}"
|
||||
keys = list(z.files)
|
||||
if len(keys) < spec["min_keys"]:
|
||||
return False, f"only {len(keys)} keys (expected ≥ {spec['min_keys']})"
|
||||
if "must_have_prefix" in spec:
|
||||
prefix = spec["must_have_prefix"]
|
||||
if not any(k.startswith(prefix) for k in keys):
|
||||
return False, f"no keys with prefix {prefix!r}"
|
||||
if "must_have_key" in spec:
|
||||
if spec["must_have_key"] not in keys:
|
||||
return False, f"missing key {spec['must_have_key']!r}"
|
||||
# Sanity: actually read one tensor without error
|
||||
try:
|
||||
for k in keys[:3]:
|
||||
_ = np.asarray(z[k])
|
||||
except Exception as e:
|
||||
return False, f"tensor read failed: {e}"
|
||||
return True, f"{len(keys)} keys"
|
||||
|
||||
|
||||
def read_wav(path: str) -> tuple[np.ndarray, int]:
|
||||
with wave.open(path, "rb") as w:
|
||||
nch = w.getnchannels()
|
||||
sr = w.getframerate()
|
||||
n = w.getnframes()
|
||||
pcm = np.frombuffer(w.readframes(n), dtype=np.int16).reshape(-1, nch).T.astype(np.float32) / 32767.0
|
||||
return pcm, sr
|
||||
|
||||
|
||||
def run_cli(name: str, extra_args: list[str], expected_seconds: float) -> tuple[bool, str]:
|
||||
"""Run sa3_mlx.py with the given args; sanity-check the output WAV."""
|
||||
out = Path(tempfile.gettempdir()) / f"sa3_test_{name}.wav"
|
||||
cmd = [PY, str(REPO / "scripts" / "sa3_mlx.py"),
|
||||
"--seed", "42",
|
||||
"--out", str(out)] + extra_args
|
||||
if "--steps" not in extra_args: # default to 4 steps for speed
|
||||
cmd += ["--steps", "4"]
|
||||
t0 = time.time()
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180,
|
||||
cwd=str(REPO))
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"TIMEOUT after 180s"
|
||||
dt = time.time() - t0
|
||||
if proc.returncode != 0:
|
||||
last_err = proc.stderr.strip().split("\n")[-1] if proc.stderr.strip() else "(no stderr)"
|
||||
return False, f"exit {proc.returncode}: {last_err[:120]}"
|
||||
if not out.exists():
|
||||
return False, "no output WAV produced"
|
||||
|
||||
pcm, sr = read_wav(str(out))
|
||||
# Quality gates: correct duration, non-NaN, not all-silent.
|
||||
actual_secs = pcm.shape[-1] / sr
|
||||
if abs(actual_secs - expected_seconds) > 0.1:
|
||||
return False, f"duration {actual_secs:.2f}s ≠ requested {expected_seconds:.2f}s"
|
||||
if not np.isfinite(pcm).all():
|
||||
return False, "audio contains NaN/Inf"
|
||||
peak = float(np.abs(pcm).max())
|
||||
rms = float(np.sqrt((pcm ** 2).mean()))
|
||||
if peak < 0.005 or rms < 0.0005:
|
||||
return False, f"effectively silent (peak={peak:.4f} rms={rms:.4f})"
|
||||
|
||||
out.unlink(missing_ok=True)
|
||||
return True, f"{dt:.1f}s peak={peak:.3f} rms={rms:.3f}"
|
||||
|
||||
|
||||
# ─── tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_weights():
|
||||
print("\n[ phase 1 ] verify every shipped npz loads cleanly\n")
|
||||
fails = []
|
||||
for relpath, spec in EXPECTED_NPZ.items():
|
||||
full = REPO / relpath
|
||||
if not full.exists():
|
||||
print(f" ✗ {relpath:42s} FILE MISSING")
|
||||
fails.append(relpath)
|
||||
continue
|
||||
ok, info = check_npz(full, spec)
|
||||
mark = "✓" if ok else "✗"
|
||||
print(f" {mark} {relpath:42s} {info}")
|
||||
if not ok:
|
||||
fails.append(relpath)
|
||||
return fails
|
||||
|
||||
|
||||
def test_configs():
|
||||
print("\n[ phase 2 ] end-to-end CLI configurations (steps=4, 3s clips)\n")
|
||||
SECONDS = "3"
|
||||
EXPECTED_SEC = 3.0
|
||||
|
||||
# Will use this as init-audio for audio-to-audio + inpaint tests.
|
||||
# Generate one short clip with sm-music first.
|
||||
init_wav = Path(tempfile.gettempdir()) / "sa3_test_init.wav"
|
||||
print(" [setup] generating a small init audio for a2a/inpaint tests …")
|
||||
subprocess.run([PY, str(REPO / "scripts" / "sa3_mlx.py"),
|
||||
"--prompt", "drums",
|
||||
"--dit", "sm-music", "--decoder", "same-s",
|
||||
"--seconds", SECONDS, "--seed", "1", "--steps", "4",
|
||||
"--out", str(init_wav)],
|
||||
capture_output=True, check=True, cwd=str(REPO))
|
||||
print(f" [setup] init audio at {init_wav}\n")
|
||||
|
||||
matrix = [
|
||||
# text-to-audio: each DiT × natural decoder
|
||||
("t2a-music", ["--prompt", "lofi house", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS]),
|
||||
("t2a-sfx", ["--prompt", "rain on roof", "--dit", "sm-sfx", "--decoder", "same-s", "--seconds", SECONDS]),
|
||||
("t2a-medium", ["--prompt", "piano solo", "--dit", "medium", "--decoder", "same-l", "--seconds", SECONDS]),
|
||||
|
||||
# cross-pairings (DiT/decoder mismatch — should still produce audio, may sound odd)
|
||||
("cross-music-L", ["--prompt", "lofi", "--dit", "sm-music", "--decoder", "same-l", "--seconds", SECONDS]),
|
||||
("cross-medium-S", ["--prompt", "piano", "--dit", "medium", "--decoder", "same-s", "--seconds", SECONDS]),
|
||||
|
||||
# mode: audio-to-audio
|
||||
("a2a-music", ["--prompt", "jazz fusion", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--init-audio", str(init_wav), "--init-noise-level", "0.6"]),
|
||||
("a2a-medium", ["--prompt", "ambient", "--dit", "medium", "--decoder", "same-l", "--seconds", SECONDS,
|
||||
"--init-audio", str(init_wav), "--init-noise-level", "0.6"]),
|
||||
|
||||
# mode: inpainting
|
||||
("inpaint-music", ["--prompt", "guitar solo", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--init-audio", str(init_wav), "--inpaint-range", "1,2"]),
|
||||
|
||||
# CFG variants
|
||||
("cfg3-music", ["--prompt", "techno beat", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--cfg", "3.0"]),
|
||||
("cfg3-neg", ["--prompt", "techno beat", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--cfg", "3.0", "--negative-prompt", "vocals, drums"]),
|
||||
("cfg0.5", ["--prompt", "techno", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--cfg", "0.5"]),
|
||||
("cfg-apg0-vanilla",["--prompt", "techno", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--cfg", "3.0", "--apg", "0.0"]),
|
||||
|
||||
# dtype: fp32 DiT
|
||||
("fp32-music", ["--prompt", "lofi", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--dit-dtype", "fp32"]),
|
||||
|
||||
# different step counts
|
||||
("steps1", ["--prompt", "lofi", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--steps", "1"]),
|
||||
("steps16", ["--prompt", "lofi", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--steps", "16"]),
|
||||
|
||||
# --no-free-models
|
||||
("no-free", ["--prompt", "lofi", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS,
|
||||
"--no-free-models"]),
|
||||
|
||||
# empty prompt (unconditional)
|
||||
("empty-prompt", ["--prompt", "", "--dit", "sm-music", "--decoder", "same-s", "--seconds", SECONDS]),
|
||||
]
|
||||
|
||||
fails = []
|
||||
for name, args in matrix:
|
||||
ok, info = run_cli(name, args, EXPECTED_SEC)
|
||||
mark = "✓" if ok else "✗"
|
||||
print(f" {mark} {name:24s} {info}")
|
||||
if not ok:
|
||||
fails.append((name, info))
|
||||
|
||||
init_wav.unlink(missing_ok=True)
|
||||
return fails
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 68)
|
||||
print("sa3_mlx full-stack self-test")
|
||||
print("=" * 68)
|
||||
npz_fails = test_weights()
|
||||
config_fails = test_configs()
|
||||
|
||||
print("\n" + "=" * 68)
|
||||
print("SUMMARY")
|
||||
print("=" * 68)
|
||||
if not npz_fails and not config_fails:
|
||||
print("✓ ALL PASS")
|
||||
return 0
|
||||
print(f"npz failures : {len(npz_fails)}")
|
||||
for f in npz_fails: print(f" - {f}")
|
||||
print(f"config failures: {len(config_fails)}")
|
||||
for name, info in config_fails: print(f" - {name}: {info}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Unit tests for models/defs/lora_merge.py — the LoRA merge-at-load math.
|
||||
|
||||
Pure-numpy/MLX, no model weights or torch needed. Run either way:
|
||||
|
||||
python scripts/test_lora_merge.py # standalone (no pytest needed)
|
||||
pytest scripts/test_lora_merge.py
|
||||
|
||||
Correctness is established by:
|
||||
* the zero-init -> identity invariant for every adapter type (a strong guard on
|
||||
axis / reshape / transpose bugs: with B/M_xs zeroed and magnitudes at their
|
||||
init values, every forward must return W0);
|
||||
* an exact independent reconstruction for standard LoRA and PEFT;
|
||||
* the Conv1d (out,k,in) <-> PyTorch (out,in,k) layout round-trip;
|
||||
* the to_local_embed name remap;
|
||||
* --lora-strength scaling and the bit-exact strength-0 bypass;
|
||||
* the trust boundary (pickle refusal) and base-mismatch / 0-merge handling.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import mlx.core as mx
|
||||
|
||||
# Import lora_merge the same way the CLI does (REPO = optimized/mlx on sys.path).
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, REPO)
|
||||
from models.defs import lora_merge as lm # noqa: E402
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
|
||||
|
||||
def _save_native(path, layer, params, cfg):
|
||||
d = {f"{layer}.parametrizations.weight.0.{k}": mx.array(v.astype(np.float32))
|
||||
for k, v in params.items()}
|
||||
mx.save_safetensors(path, d, metadata={"lora_config": json.dumps(cfg)})
|
||||
|
||||
|
||||
def _init_params(adapter_type, W0, rank, nonzero):
|
||||
"""Init so a zeroed core -> identity; if nonzero, give a real core."""
|
||||
fo, fi = W0.shape
|
||||
p = {}
|
||||
if not adapter_type.endswith("-xs"):
|
||||
p["lora_A"] = rng.standard_normal((rank, fi)).astype(np.float32)
|
||||
p["lora_B"] = (rng.standard_normal((fo, rank)) if nonzero
|
||||
else np.zeros((fo, rank))).astype(np.float32)
|
||||
else:
|
||||
p["M_xs"] = (rng.standard_normal((rank, rank)) if nonzero
|
||||
else np.zeros((rank, rank))).astype(np.float32)
|
||||
if "magnitude" in lm._PARAMS_FOR[adapter_type]:
|
||||
nd = 1 if "rows" in adapter_type else 0
|
||||
p["magnitude"] = np.linalg.norm(W0, axis=nd).astype(np.float32)
|
||||
if "magnitude_r" in lm._PARAMS_FOR[adapter_type]:
|
||||
p["magnitude_r"] = np.linalg.norm(W0, axis=1).astype(np.float32)
|
||||
p["magnitude_c"] = np.linalg.norm(W0, axis=0).astype(np.float32)
|
||||
return p
|
||||
|
||||
|
||||
def _np(arr):
|
||||
return np.array(arr.astype(mx.float32))
|
||||
|
||||
|
||||
def test_trust_boundary_refuses_pickle():
|
||||
for bad in ("evil.ckpt", "evil.pt", "evil.bin", "weights.npz"):
|
||||
try:
|
||||
lm._load_safetensors(bad)
|
||||
assert False, f"should have refused {bad}"
|
||||
except lm.LoraError:
|
||||
pass
|
||||
|
||||
|
||||
def test_all_types_zero_init_identity_and_nonzero_delta():
|
||||
W0 = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for atype in lm._PARAMS_FOR:
|
||||
path = os.path.join(tmp, f"{atype}.safetensors")
|
||||
# zero-init -> identity
|
||||
_save_native(path, "foo", _init_params(atype, W0, 4, nonzero=False),
|
||||
{"adapter_type": atype, "rank": 4, "alpha": 4})
|
||||
w = {"foo.weight": mx.array(W0)}
|
||||
lm.merge_loras_into_weights(w, [path])
|
||||
assert np.allclose(_np(w["foo.weight"]), W0, atol=1e-4), f"{atype} identity"
|
||||
# nonzero core -> finite, changed
|
||||
_save_native(path, "foo", _init_params(atype, W0, 4, nonzero=True),
|
||||
{"adapter_type": atype, "rank": 4, "alpha": 4})
|
||||
w = {"foo.weight": mx.array(W0)}
|
||||
lm.merge_loras_into_weights(w, [path])
|
||||
got = _np(w["foo.weight"])
|
||||
assert np.isfinite(got).all() and not np.allclose(got, W0), f"{atype} delta"
|
||||
|
||||
|
||||
def test_standard_lora_exact_and_strength():
|
||||
W0 = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
A = rng.standard_normal((4, 8)).astype(np.float32)
|
||||
B = rng.standard_normal((6, 4)).astype(np.float32)
|
||||
expect = W0 + (8.0 / 4.0) * (B @ A)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "std.safetensors")
|
||||
_save_native(path, "foo", {"lora_A": A, "lora_B": B},
|
||||
{"adapter_type": "lora", "rank": 4, "alpha": 8})
|
||||
w = {"foo.weight": mx.array(W0)}
|
||||
lm.merge_loras_into_weights(w, [path])
|
||||
assert np.allclose(_np(w["foo.weight"]), expect, atol=1e-4)
|
||||
# strength 0.5 halves the delta; strength 0 is the original
|
||||
w = {"foo.weight": mx.array(W0)}
|
||||
lm.merge_loras_into_weights(w, [path], strength=0.5)
|
||||
assert np.allclose(_np(w["foo.weight"]), W0 + 0.5 * (8.0 / 4.0) * (B @ A), atol=1e-4)
|
||||
w = {"foo.weight": mx.array(W0)}
|
||||
lm.merge_loras_into_weights(w, [path], strength=0.0)
|
||||
assert np.allclose(_np(w["foo.weight"]), W0, atol=1e-6)
|
||||
|
||||
|
||||
def test_conv1d_layout_round_trip():
|
||||
out_c, k_c, in_c = 4, 1, 3
|
||||
Wc = rng.standard_normal((out_c, k_c, in_c)).astype(np.float32) # MLX layout
|
||||
Ac = rng.standard_normal((2, in_c * k_c)).astype(np.float32)
|
||||
Bc = rng.standard_normal((out_c, 2)).astype(np.float32)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "conv.safetensors")
|
||||
_save_native(path, "conv", {"lora_A": Ac, "lora_B": Bc},
|
||||
{"adapter_type": "lora", "rank": 2, "alpha": 2})
|
||||
w = {"conv.weight": mx.array(Wc)}
|
||||
lm.merge_loras_into_weights(w, [path])
|
||||
got = _np(w["conv.weight"])
|
||||
expect = Wc + (Bc @ Ac).reshape(out_c, in_c, k_c).transpose(0, 2, 1)
|
||||
assert got.shape == Wc.shape and np.allclose(got, expect, atol=1e-4)
|
||||
|
||||
|
||||
def test_to_local_embed_remap():
|
||||
Wt = rng.standard_normal((5, 5)).astype(np.float32)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "tle.safetensors")
|
||||
_save_native(path, "transformer.layers.0.to_local_embed.0",
|
||||
{"lora_A": rng.standard_normal((2, 5)).astype(np.float32),
|
||||
"lora_B": rng.standard_normal((5, 2)).astype(np.float32)},
|
||||
{"adapter_type": "lora", "rank": 2, "alpha": 2})
|
||||
w = {"transformer.layers.0.to_local_embed.seq.0.weight": mx.array(Wt)}
|
||||
stats = lm.merge_loras_into_weights(w, [path])
|
||||
assert stats["merged"] == 1 and not stats["skipped"]
|
||||
|
||||
|
||||
def test_peft_format_exact():
|
||||
W0 = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
A = rng.standard_normal((4, 8)).astype(np.float32)
|
||||
B = rng.standard_normal((6, 4)).astype(np.float32)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with open(os.path.join(tmp, "adapter_config.json"), "w") as fh:
|
||||
json.dump({"peft_type": "LORA", "r": 4, "lora_alpha": 8, "use_dora": False}, fh)
|
||||
mx.save_safetensors(
|
||||
os.path.join(tmp, "adapter_model.safetensors"),
|
||||
{"base_model.model.foo.lora_A.weight": mx.array(A),
|
||||
"base_model.model.foo.lora_B.weight": mx.array(B)},
|
||||
metadata={"format": "pt"})
|
||||
w = {"foo.weight": mx.array(W0)}
|
||||
stats = lm.merge_loras_into_weights(w, [tmp]) # directory input
|
||||
assert stats["merged"] == 1
|
||||
assert np.allclose(_np(w["foo.weight"]), W0 + (8.0 / 4.0) * (B @ A), atol=1e-4)
|
||||
|
||||
|
||||
def test_unknown_layers_skipped_base_untouched():
|
||||
W0 = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "std.safetensors")
|
||||
_save_native(path, "foo",
|
||||
{"lora_A": rng.standard_normal((4, 8)).astype(np.float32),
|
||||
"lora_B": rng.standard_normal((6, 4)).astype(np.float32)},
|
||||
{"adapter_type": "lora", "rank": 4, "alpha": 4})
|
||||
w = {"other.weight": mx.array(W0)}
|
||||
msgs = []
|
||||
stats = lm.merge_loras_into_weights(w, [path], log=msgs.append)
|
||||
assert stats["merged"] == 0 and stats["skipped"] == ["foo"]
|
||||
assert np.allclose(_np(w["other.weight"]), W0)
|
||||
# a 0-merge run must warn, not look like a successful no-op
|
||||
assert any("WARNING" in m and "0 layers" in m for m in msgs)
|
||||
|
||||
|
||||
def test_base_mismatch_raises_clear_error():
|
||||
# base is (6, 8) but the adapter was trained for an (10, 8) layer
|
||||
W0 = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "mismatch.safetensors")
|
||||
_save_native(path, "foo",
|
||||
{"lora_A": rng.standard_normal((4, 8)).astype(np.float32),
|
||||
"lora_B": rng.standard_normal((10, 4)).astype(np.float32)},
|
||||
{"adapter_type": "lora", "rank": 4, "alpha": 4})
|
||||
w = {"foo.weight": mx.array(W0)}
|
||||
try:
|
||||
lm.merge_loras_into_weights(w, [path])
|
||||
assert False, "should have raised on base mismatch"
|
||||
except lm.LoraError as e:
|
||||
assert "foo" in str(e) and "base" in str(e).lower()
|
||||
|
||||
|
||||
def test_svd_bases_orthonormal():
|
||||
Wsvd = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
U, V = lm._svd_bases(Wsvd, rank=6)
|
||||
assert np.allclose(U.T @ U, np.eye(6), atol=1e-4)
|
||||
assert np.allclose(V.T @ V, np.eye(6), atol=1e-4)
|
||||
|
||||
|
||||
def test_underfit_wrapper_prefixes():
|
||||
"""Underfit (github.com/dada-bots/underfit) saves full-wrapper layer names:
|
||||
DiT layers as ``model.transformer...`` and the seconds conditioner as
|
||||
``conditioners.seconds_total.embedder.embedding.1``. Both must land on the
|
||||
bare npz keys; bare names keep working."""
|
||||
W_dit = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
W_cond = rng.standard_normal((5, 7)).astype(np.float32)
|
||||
A_d = rng.standard_normal((4, 8)).astype(np.float32)
|
||||
B_d = rng.standard_normal((6, 4)).astype(np.float32)
|
||||
A_c = rng.standard_normal((4, 7)).astype(np.float32)
|
||||
B_c = rng.standard_normal((5, 4)).astype(np.float32)
|
||||
cfg = {"adapter_type": "lora", "rank": 4, "alpha": 4}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "underfit.safetensors")
|
||||
d = {}
|
||||
for layer, (A, B) in {
|
||||
"model.transformer.layers.0.self_attn.to_qkv": (A_d, B_d),
|
||||
"conditioners.seconds_total.embedder.embedding.1": (A_c, B_c),
|
||||
}.items():
|
||||
d[f"{layer}.parametrizations.weight.0.lora_A"] = mx.array(A)
|
||||
d[f"{layer}.parametrizations.weight.0.lora_B"] = mx.array(B)
|
||||
mx.save_safetensors(path, d, metadata={"lora_config": json.dumps(cfg)})
|
||||
w = {"transformer.layers.0.self_attn.to_qkv.weight": mx.array(W_dit),
|
||||
"cond.seconds_total_weight": mx.array(W_cond)}
|
||||
stats = lm.merge_loras_into_weights(w, [path])
|
||||
assert stats["merged"] == 2 and not stats["skipped"], stats
|
||||
assert np.allclose(_np(w["transformer.layers.0.self_attn.to_qkv.weight"]),
|
||||
W_dit + B_d @ A_d, atol=1e-4)
|
||||
assert np.allclose(_np(w["cond.seconds_total_weight"]),
|
||||
W_cond + B_c @ A_c, atol=1e-4)
|
||||
# bare names still pass through, and model.-prefixed to_local_embed remaps
|
||||
assert lm._layer_to_npz_key("transformer.layers.3.ff.ff.2") == \
|
||||
"transformer.layers.3.ff.ff.2.weight"
|
||||
assert lm._layer_to_npz_key("model.transformer.layers.3.to_local_embed.0") == \
|
||||
"transformer.layers.3.to_local_embed.seq.0.weight"
|
||||
|
||||
|
||||
def test_lora_spec_parser():
|
||||
s = lm.parse_lora_spec(["a.safetensors"], default_strength=0.5)
|
||||
assert s == {"path": "a.safetensors", "strength": 0.5, "steps": None}
|
||||
s = lm.parse_lora_spec(["a.safetensors", "strength=0.8", "steps=2-8"])
|
||||
assert s["strength"] == 0.8 and s["steps"] == (2, 8)
|
||||
assert lm.parse_lora_spec(["a", "steps=2-"])["steps"] == (2, None)
|
||||
assert lm.parse_lora_spec(["a", "steps=-4"])["steps"] == (None, 4)
|
||||
assert lm.parse_lora_spec(["a", "steps=3"])["steps"] == (3, 3)
|
||||
for bad in (["a.st", "b.st"], # two paths in one flag
|
||||
["a", "steps=8-2"], # min > max
|
||||
["a", "steps=0-3"], # 1-based
|
||||
["a", "steps=-"], # empty range
|
||||
["a", "strength=loud"], # not a float
|
||||
[]):
|
||||
try:
|
||||
lm.parse_lora_spec(bad)
|
||||
assert False, f"should have rejected {bad}"
|
||||
except lm.LoraError:
|
||||
pass
|
||||
# resolve against an 8-step schedule (0-based inclusive out)
|
||||
assert lm.resolve_steps(None, 8) == (0, 7)
|
||||
assert lm.resolve_steps((2, 8), 8) == (1, 7)
|
||||
assert lm.resolve_steps((2, None), 8) == (1, 7)
|
||||
assert lm.resolve_steps((None, 4), 8) == (0, 3)
|
||||
assert lm.resolve_steps((2, 99), 8) == (1, 7) # clamp
|
||||
assert lm.resolve_steps((9, None), 8) is None # misses the schedule
|
||||
|
||||
|
||||
class _StubModule:
|
||||
"""Bare object with .weight — stands in for nn.Linear in plan tests."""
|
||||
def __init__(self, w):
|
||||
self.weight = w
|
||||
|
||||
|
||||
def _stub_tree(weights):
|
||||
"""Build a walkable module tree for keys like 'foo.weight' and
|
||||
'layers.0.lin.weight' (digits = list containers, as in the DiT)."""
|
||||
class _NS: # namespace node
|
||||
pass
|
||||
root = _NS()
|
||||
for key, w in weights.items():
|
||||
parts = key.split(".")[:-1] # drop trailing 'weight'
|
||||
obj = root
|
||||
for j, p in enumerate(parts[:-1]):
|
||||
nxt = parts[j + 1]
|
||||
if p.isdigit():
|
||||
obj = obj[int(p)]
|
||||
else:
|
||||
if not hasattr(obj, p):
|
||||
setattr(obj, p, [] if nxt.isdigit() else _NS())
|
||||
obj = getattr(obj, p)
|
||||
if isinstance(obj, list) and nxt.isdigit():
|
||||
while len(obj) <= int(nxt):
|
||||
obj.append(_NS())
|
||||
last = parts[-1]
|
||||
mod = _StubModule(w)
|
||||
if last.isdigit():
|
||||
obj[int(last)] = mod
|
||||
else:
|
||||
setattr(obj, last, mod)
|
||||
return root
|
||||
|
||||
|
||||
def test_step_plan_matches_reference():
|
||||
"""For every adapter type: gate two overlapping adapters across an 8-step
|
||||
schedule and check the in-place weights at EVERY step against the from-
|
||||
scratch reference W0 + Σ_active strength·(merged − W0)."""
|
||||
num_steps = 8
|
||||
K1, K2 = "foo.weight", "layers.0.lin.weight"
|
||||
W = {K1: rng.standard_normal((6, 8)).astype(np.float32),
|
||||
K2: rng.standard_normal((5, 7)).astype(np.float32)}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for atype in lm._PARAMS_FOR:
|
||||
p1 = os.path.join(tmp, f"{atype}-1.safetensors")
|
||||
p2 = os.path.join(tmp, f"{atype}-2.safetensors")
|
||||
prm = {k: _init_params(atype, w, 3, nonzero=True) for k, w in W.items()}
|
||||
d = {}
|
||||
for k, w in W.items():
|
||||
layer = k[: -len(".weight")]
|
||||
for pk, pv in prm[k].items():
|
||||
d[f"{layer}.parametrizations.weight.0.{pk}"] = mx.array(pv)
|
||||
mx.save_safetensors(p1, d, metadata={"lora_config": json.dumps(
|
||||
{"adapter_type": atype, "rank": 3, "alpha": 3})})
|
||||
prm2 = {K1: _init_params("lora", W[K1], 3, nonzero=True)}
|
||||
d2 = {f"foo.parametrizations.weight.0.{pk}": mx.array(pv)
|
||||
for pk, pv in prm2[K1].items()}
|
||||
mx.save_safetensors(p2, d2, metadata={"lora_config": json.dumps(
|
||||
{"adapter_type": "lora", "rank": 3, "alpha": 3})})
|
||||
|
||||
specs = [{"path": p1, "strength": 0.7, "steps": (2, 5)},
|
||||
{"path": p2, "strength": 1.3, "steps": (4, None)}]
|
||||
weights = {k: mx.array(w) for k, w in W.items()}
|
||||
plan = lm.prepare_loras(weights, specs, num_steps=num_steps)
|
||||
assert plan is not None and len(plan.layers) == 2
|
||||
|
||||
model = _stub_tree(weights)
|
||||
plan.attach(model)
|
||||
mods = {K1: model.foo, K2: model.layers[0].lin}
|
||||
|
||||
def ref(i):
|
||||
out = {k: W[k].copy() for k in W}
|
||||
if 1 <= i <= 4: # adapter 1 (steps 2-5, 0-based 1..4)
|
||||
for k in W:
|
||||
m = lm._merged_weight(W[k], prm[k], atype, 1.0)
|
||||
out[k] += 0.7 * (m - W[k])
|
||||
if i >= 3: # adapter 2 (steps 4-, 0-based 3..7)
|
||||
m = lm._merged_weight(W[K1], prm2[K1], "lora", 1.0)
|
||||
out[K1] += 1.3 * (m - W[K1])
|
||||
return out
|
||||
|
||||
for i in range(num_steps):
|
||||
plan.sync(i)
|
||||
expect = ref(i)
|
||||
for k in W:
|
||||
got = _np(mods[k].weight)
|
||||
err = np.abs(got - expect[k]).max()
|
||||
assert err < 1e-3, f"{atype} step {i} {k}: max|d|={err:.2e}"
|
||||
|
||||
|
||||
def test_step_plan_round_trip_drift():
|
||||
"""50 on/off gating cycles on fp16 weights must not accumulate drift
|
||||
(exact-inverse pairing — measured frozen-after-cycle-1 in the design
|
||||
benchmarks; this guards the implementation)."""
|
||||
K = "foo.weight"
|
||||
W0 = rng.standard_normal((32, 48)).astype(np.float16)
|
||||
prm = _init_params("dora-rows", W0.astype(np.float32), 4, nonzero=True)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "d.safetensors")
|
||||
_save_native(path, "foo", prm, {"adapter_type": "dora-rows", "rank": 4, "alpha": 4})
|
||||
weights = {K: mx.array(W0)}
|
||||
plan = lm.prepare_loras(weights, [{"path": path, "strength": 1.0,
|
||||
"steps": (2, None)}], num_steps=2)
|
||||
model = _stub_tree(weights)
|
||||
plan.attach(model)
|
||||
drift1 = None
|
||||
for cyc in range(50):
|
||||
plan.sync(1) # on
|
||||
plan.sync(0) # off
|
||||
d = np.abs(_np(model.foo.weight) - W0.astype(np.float32)).max()
|
||||
if cyc == 0:
|
||||
drift1 = d
|
||||
assert d <= drift1 + 1e-6, f"drift grew: cycle1 {drift1:.2e} → cycle50 {d:.2e}"
|
||||
rel = (np.linalg.norm(_np(model.foo.weight) - W0.astype(np.float32))
|
||||
/ np.linalg.norm(W0.astype(np.float32)))
|
||||
assert rel < 5e-3, f"round-trip rel drift {rel:.2e}"
|
||||
|
||||
|
||||
def test_full_interval_uses_merge_path():
|
||||
"""Specs without steps= (or covering every step) → no plan; weights match
|
||||
the plain merge_loras_into_weights result exactly."""
|
||||
W0 = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
prm = _init_params("lora", W0, 4, nonzero=True)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "l.safetensors")
|
||||
_save_native(path, "foo", prm, {"adapter_type": "lora", "rank": 4, "alpha": 4})
|
||||
wa = {"foo.weight": mx.array(W0)}
|
||||
wb = {"foo.weight": mx.array(W0)}
|
||||
plan = lm.prepare_loras(wa, [{"path": path, "strength": 0.6,
|
||||
"steps": (1, 8)}], num_steps=8)
|
||||
assert plan is None
|
||||
lm.merge_loras_into_weights(wb, [path], strength=0.6)
|
||||
assert np.array_equal(_np(wa["foo.weight"]), _np(wb["foo.weight"]))
|
||||
|
||||
|
||||
def test_gate_all_and_clear_restore_base():
|
||||
"""The gradio path: gate_all=True routes even full-range adapters through
|
||||
the plan so clear() can restore a cached model to base in place; an adapter
|
||||
matching zero layers raises a clear wrong-base error."""
|
||||
W0 = rng.standard_normal((6, 8)).astype(np.float32)
|
||||
prm = _init_params("dora-rows", W0, 4, nonzero=True)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "g.safetensors")
|
||||
_save_native(path, "foo", prm, {"adapter_type": "dora-rows", "rank": 4, "alpha": 4})
|
||||
w = {"foo.weight": mx.array(W0)}
|
||||
plan = lm.prepare_loras(w, [{"path": path, "strength": 1.0, "steps": None}],
|
||||
num_steps=8, gate_all=True)
|
||||
assert plan is not None, "gate_all must plan-manage full-range adapters"
|
||||
model = _stub_tree(w)
|
||||
plan.attach(model)
|
||||
assert not np.allclose(_np(model.foo.weight), W0), "step-1 state not applied"
|
||||
plan.clear()
|
||||
assert np.abs(_np(model.foo.weight) - W0).max() < 1e-4, "clear() must restore base"
|
||||
# an adapter whose layers don't exist in the target → clear error
|
||||
w2 = {"bar.weight": mx.array(W0)}
|
||||
try:
|
||||
lm.prepare_loras(w2, [{"path": path, "strength": 1.0, "steps": (2, None)}],
|
||||
num_steps=8)
|
||||
assert False, "zero-match adapter should raise"
|
||||
except lm.LoraError as e:
|
||||
assert "different base model" in str(e)
|
||||
|
||||
|
||||
def _main():
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
failed = []
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
print(f" ok {t.__name__}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" FAIL {t.__name__}: {e}")
|
||||
failed.append(t.__name__)
|
||||
print("\n" + ("ALL PASS" if not failed else f"FAILURES: {failed}"))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Shared weights manifest + downloader.
|
||||
|
||||
Maps every weight file the runtime needs to its position in the
|
||||
`stabilityai/stable-audio-3-optimized` HuggingFace repo.
|
||||
|
||||
`install.py` calls `ensure_local` upfront for the bundles the user picks.
|
||||
`sa3_mlx.py` calls `ensure_local` lazily, just before each model loads — so
|
||||
a fresh checkout with no weights still works if the user is willing to
|
||||
wait for the first run to download them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ID = "stabilityai/stable-audio-3-optimized"
|
||||
# weights.py lives in <project>/scripts/; SCRIPT_DIR points at the project
|
||||
# root so the local rel paths in the manifest ("models/mlx/foo.npz") resolve
|
||||
# against the actual project layout.
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Bundles the install script offers to the user. Each maps to a list of
|
||||
# weight files (local relative path on the left, HF repo path on the right).
|
||||
# T5Gemma is in SHARED because every bundle needs it.
|
||||
|
||||
DIT_BUNDLES: dict[str, list[tuple[str, str]]] = {
|
||||
"medium": [
|
||||
("models/mlx/dit_medium_f16.npz", "MLX/dit_medium_f16.npz"),
|
||||
("models/mlx/same_l_decoder_f32.npz", "MLX/same_l_decoder_f32.npz"),
|
||||
("models/mlx/same_l_encoder_f32.npz", "MLX/same_l_encoder_f32.npz"),
|
||||
],
|
||||
"sm-music": [
|
||||
("models/mlx/dit_sm-music_f16.npz", "MLX/dit_sm-music_f16.npz"),
|
||||
("models/mlx/same_s_decoder_f32.npz", "MLX/same_s_decoder_f32.npz"),
|
||||
("models/mlx/same_s_encoder_f32.npz", "MLX/same_s_encoder_f32.npz"),
|
||||
],
|
||||
"sm-sfx": [
|
||||
("models/mlx/dit_sm-sfx_f16.npz", "MLX/dit_sm-sfx_f16.npz"),
|
||||
("models/mlx/same_s_decoder_f32.npz", "MLX/same_s_decoder_f32.npz"),
|
||||
("models/mlx/same_s_encoder_f32.npz", "MLX/same_s_encoder_f32.npz"),
|
||||
],
|
||||
}
|
||||
|
||||
SHARED: list[tuple[str, str]] = [
|
||||
("models/mlx/t5gemma_f16.npz", "MLX/t5gemma_f16.npz"),
|
||||
]
|
||||
|
||||
# Base-model DiT weights — needed only for LoRA *training* (lora_train_mlx.py
|
||||
# trains on the BASE checkpoint, not the ARC weights inference uses). Not part
|
||||
# of any install bundle; auto-downloaded lazily by the trainer via ensure_local.
|
||||
TRAINING_BASE: list[tuple[str, str]] = [
|
||||
("models/mlx/dit_sm-music-base_f16.npz", "MLX/dit_sm-music-base_f16.npz"),
|
||||
("models/mlx/dit_sm-sfx-base_f16.npz", "MLX/dit_sm-sfx-base_f16.npz"),
|
||||
("models/mlx/dit_medium-base_f16.npz", "MLX/dit_medium-base_f16.npz"),
|
||||
]
|
||||
|
||||
# Human-friendly bundle sizes (for the install prompt).
|
||||
BUNDLE_SIZES = {
|
||||
"medium": "5.9 GB (medium DiT + SAME-L codec)",
|
||||
"sm-music": "1.3 GB (small music DiT + SAME-S codec)",
|
||||
"sm-sfx": "1.3 GB (small sfx DiT + SAME-S codec)",
|
||||
}
|
||||
|
||||
# Flat (local_rel_path → hf_path) lookup — used by sa3_mlx.py for lazy
|
||||
# auto-download at load time.
|
||||
FLAT_MANIFEST: dict[str, str] = {}
|
||||
for _items in DIT_BUNDLES.values():
|
||||
for _rel, _hf in _items:
|
||||
FLAT_MANIFEST[_rel] = _hf
|
||||
for _rel, _hf in SHARED:
|
||||
FLAT_MANIFEST[_rel] = _hf
|
||||
for _rel, _hf in TRAINING_BASE:
|
||||
FLAT_MANIFEST[_rel] = _hf
|
||||
|
||||
|
||||
def _hf_token_configured() -> bool:
|
||||
"""True if any HF token is set — env var or cached login on disk."""
|
||||
import os
|
||||
if os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"):
|
||||
return True
|
||||
try:
|
||||
from huggingface_hub import get_token # huggingface_hub ≥ 0.19
|
||||
return bool(get_token())
|
||||
except ImportError:
|
||||
try:
|
||||
from huggingface_hub import HfFolder
|
||||
return bool(HfFolder.get_token())
|
||||
except Exception:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_LOGIN_TIP_SHOWN = False
|
||||
|
||||
def _show_hf_login_tip_once() -> None:
|
||||
"""Print a one-time login suggestion if no HF token is configured.
|
||||
|
||||
Anonymous downloads work but have a ~50 GB/day soft cap on HF's LFS CDN
|
||||
and lower aggregate bandwidth — a free token effectively removes both.
|
||||
Stays silent if a token is already in place.
|
||||
"""
|
||||
global _LOGIN_TIP_SHOWN
|
||||
if _LOGIN_TIP_SHOWN:
|
||||
return
|
||||
_LOGIN_TIP_SHOWN = True
|
||||
if _hf_token_configured():
|
||||
return
|
||||
import sys
|
||||
YEL = "\033[1;33m" if sys.stdout.isatty() else ""
|
||||
BOLD = "\033[1m" if sys.stdout.isatty() else ""
|
||||
DIM = "\033[2m" if sys.stdout.isatty() else ""
|
||||
RST = "\033[0m" if sys.stdout.isatty() else ""
|
||||
print()
|
||||
print(f" {YEL}⚠ not logged in to HuggingFace{RST} — anonymous downloads work but are")
|
||||
print(f" rate-limited (~50 GB/day cap on the LFS CDN). For faster, higher-limit")
|
||||
print(f" downloads, log in once with a free read-only token:")
|
||||
print()
|
||||
print(f" 1. create an account at {BOLD}https://huggingface.co/join{RST}")
|
||||
print(f" 2. generate a token at {BOLD}https://huggingface.co/settings/tokens{RST}")
|
||||
print(f" {DIM}('Read' scope is enough){RST}")
|
||||
print(f" 3. save it on this machine — pick one:")
|
||||
print(f" {BOLD}hf auth login{RST} {DIM}# modern (huggingface_hub ≥ 1.0){RST}")
|
||||
print(f" {BOLD}huggingface-cli login{RST} {DIM}# classic; still works{RST}")
|
||||
print(f" {BOLD}export HF_TOKEN=hf_xxx{RST} {DIM}# one-off / scripts{RST}")
|
||||
print()
|
||||
|
||||
|
||||
def ensure_local(local_rel_path: str, verbose: bool = True) -> Path:
|
||||
"""Resolve a weight file to an absolute local path, downloading if missing.
|
||||
|
||||
Files are streamed into the HuggingFace cache (~/.cache/huggingface/hub/)
|
||||
and symlinked into the project at `local_rel_path` so the on-disk layout
|
||||
looks the same whether the file was bundled or downloaded.
|
||||
"""
|
||||
target = SCRIPT_DIR / local_rel_path
|
||||
if target.exists() or target.is_symlink():
|
||||
return target
|
||||
|
||||
if local_rel_path not in FLAT_MANIFEST:
|
||||
raise FileNotFoundError(
|
||||
f"{local_rel_path} is not in the weights manifest — can't auto-download."
|
||||
)
|
||||
|
||||
# First-download tip: nudge users toward logging in to HF for better limits.
|
||||
# No-op if a token is already configured.
|
||||
_show_hf_login_tip_once()
|
||||
|
||||
hf_filename = FLAT_MANIFEST[local_rel_path]
|
||||
if verbose:
|
||||
print(f" ↓ downloading {hf_filename} (from {REPO_ID})")
|
||||
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"huggingface_hub is required to auto-download weights.\n"
|
||||
"Run: pip install huggingface_hub\n"
|
||||
"Or run the install.py script in this directory."
|
||||
) from e
|
||||
|
||||
cached = hf_hub_download(repo_id=REPO_ID, filename=hf_filename)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Symlink keeps the HF cache canonical (one copy on disk) while exposing
|
||||
# the file at the project-relative path the runtime expects.
|
||||
if target.is_symlink():
|
||||
target.unlink()
|
||||
target.symlink_to(cached)
|
||||
return target
|
||||
|
||||
|
||||
def is_present(local_rel_path: str) -> bool:
|
||||
"""True if the file exists locally (does not trigger a download)."""
|
||||
p = SCRIPT_DIR / local_rel_path
|
||||
return p.exists() or p.is_symlink()
|
||||
|
||||
|
||||
def bundle_status(bundle: str) -> tuple[int, int]:
|
||||
"""Returns (present_count, total_count) for the bundle (including SHARED)."""
|
||||
items = DIT_BUNDLES[bundle] + SHARED
|
||||
present = sum(1 for rel, _ in items if is_present(rel))
|
||||
return present, len(items)
|
||||
Reference in New Issue
Block a user