fcolooo commited on
Commit
2f9dfba
·
verified ·
1 Parent(s): 2aeef6e

dev space: r2c 65k via FULLFT_SUBDIR

Browse files
Files changed (5) hide show
  1. README.md +16 -6
  2. app.py +743 -0
  3. packages.txt +1 -0
  4. requirements.txt +20 -0
  5. run_ace_task_baseline.py +420 -0
README.md CHANGED
@@ -1,13 +1,23 @@
1
  ---
2
- title: Stem 0 Web Dev
3
- emoji: 🚀
4
  colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.25.0
8
- python_version: '3.13'
9
  app_file: app.py
 
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Stem-0 DEV (r2c checkpoints)
3
+ emoji: 🎚️
4
  colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
+ python_version: "3.12"
8
+ sdk_version: 6.2.0
9
  app_file: app.py
10
+ startup_duration_timeout: 1h
11
  pinned: false
12
+ short_description: Generate the missing stem that fits your audio
13
  ---
14
 
15
+ # Stem-0 · missing-stem generation
16
+
17
+ Give it an audio **context** (e.g. drums+bass, or a full instrumental) and it generates the
18
+ **missing stem** so that it fits — tempo, key, arrangement. For vocals you supply the lyrics.
19
+
20
+ Base model: `ACE-Step/acestep-v15-xl-base` · LoRA: `fcolooo/stem-0-r128` (r128, context-contrastive recipe).
21
+
22
+ **ZeroGPU note:** the first generation loads ~20 GB of weights onto the GPU and is slow; later ones are fast.
23
+ Keep `steps` low while testing — GPU quota is per-day.
app.py ADDED
@@ -0,0 +1,743 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stem-0 · missing-stem generation on ZeroGPU.
2
+
3
+ Design notes (ZeroGPU + credit awareness):
4
+ * Weights are downloaded at STARTUP (CPU only) — downloads cost no GPU quota.
5
+ * Per ZeroGPU docs the model is placed on cuda at MODULE level — a PyTorch CUDA emulation mode is
6
+ active outside @spaces.GPU, and startup placement is what their transfer path is optimised for.
7
+ (Lazy-loading inside the decorated function is explicitly discouraged as much slower.)
8
+ * Duration is DYNAMIC: quota is billed per GPU-second, so we request only what the step count needs
9
+ instead of a flat 120s.
10
+ * Generation parameters come from OUR validated `run_ace_task_baseline.build_params` (imported,
11
+ not reimplemented — a second hand-typed copy of that call already drifted in six places once).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import shutil
18
+ import tempfile
19
+ import time
20
+ from pathlib import Path
21
+
22
+ import gradio as gr
23
+ import numpy as np
24
+ import soundfile as sf
25
+ import spaces
26
+ from huggingface_hub import snapshot_download
27
+
28
+ # ---- vendor the acestep SOURCE (not the pip package: its gradio/torch pins conflict with ZeroGPU)
29
+ ACE_SRC = Path(os.environ.get("STEM0_ACE_SRC", "/tmp/ace_src"))
30
+
31
+
32
+ def _fetch_ace_source() -> str:
33
+ import io, tarfile, urllib.request
34
+ if ACE_SRC.exists() and (ACE_SRC / "acestep").is_dir():
35
+ return str(ACE_SRC)
36
+ ACE_SRC.parent.mkdir(parents=True, exist_ok=True)
37
+ url = "https://github.com/ace-step/ACE-Step-1.5/archive/refs/heads/main.tar.gz"
38
+ raw = urllib.request.urlopen(url, timeout=180).read()
39
+ tmp = ACE_SRC.with_suffix(".x")
40
+ with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tf:
41
+ tf.extractall(tmp)
42
+ inner = next(p for p in tmp.iterdir() if p.is_dir())
43
+ if ACE_SRC.exists():
44
+ shutil.rmtree(ACE_SRC, ignore_errors=True)
45
+ inner.rename(ACE_SRC)
46
+ shutil.rmtree(tmp, ignore_errors=True)
47
+ return str(ACE_SRC)
48
+
49
+
50
+ CKPT = Path(os.environ.get("STEM0_CKPT", "/tmp/checkpoints"))
51
+ LORA_REPO = os.environ.get("STEM0_LORA_REPO", "fcolooo/stem-0-r128")
52
+ LORA_REV = os.environ.get("STEM0_LORA_REV", "main") # step_040000 — content-verified sha256 e0fe86e7…
53
+ # FULL-FINETUNE MODE. Set STEM0_FULLFT_REPO and this Space serves a fully fine-tuned decoder instead
54
+ # of base+adapter; leave it empty and nothing below changes. The two are mutually exclusive.
55
+ #
56
+ # The weights are overlaid onto the LIVE model rather than swapped in as files, because the base
57
+ # checkpoint is the whole 4.987B model in 4 shards while a full-finetune checkpoint is the DECODER
58
+ # ONLY (4.169B, 2 shards, decoder-relative keys) -- a file swap would load without error and silently
59
+ # discard the 818M non-decoder parameters. COVER_STRENGTH below is unaffected and still applies.
60
+ FULLFT_REPO = os.environ.get("STEM0_FULLFT_REPO", "")
61
+ FULLFT_REV = os.environ.get("STEM0_FULLFT_REV", "main")
62
+ # Optional subfolder inside FULLFT_REPO holding the decoder (e.g. "step_065000/decoder" in a
63
+ # checkpoint-mirror repo). Empty = decoder files at the repo root, the rc.0 layout.
64
+ FULLFT_SUBDIR = os.environ.get("STEM0_FULLFT_SUBDIR", "").strip("/")
65
+ # How tightly the surrounding context binds the generation. run_take defaults this to 0.45, which was
66
+ # never a decision for this task -- it is the default of whichever copy of run_ace_task_baseline gets
67
+ # imported (another copy in the same codebase defaults to 1.0). Measured on 2026-08-09 at matched
68
+ # n=10 against the same weights at 0.45: 5 wins, 0 losses, six metrics at p=0.002 with unanimous
69
+ # 10/0 track splits. melody onset correlation 0.166 -> 0.375, bass onset 0.108 -> 0.206. The sweep is
70
+ # monotone across 0.30/0.45/0.65/0.80/1.00, and 1.00 is the top of the tested range -- the curve had
71
+ # not turned over, so the true optimum may be higher and is untested. Degeneracy was ruled out
72
+ # independently: CLAP up on all four roles, centroid error down on three of four, RMS normal.
73
+ COVER_STRENGTH = float(os.environ.get("STEM0_COVER_STRENGTH", "1.0"))
74
+ HF_TOK = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
75
+ MODEL = "acestep-v15-xl-base"
76
+ ROLES = {"drums": "drums", "bass": "bass", "guitar (melody)": "melody", "vocals": "vocals"}
77
+ # These four are EXACT full captions from the training corpus (captions.jsonl), verified
78
+ # present, not phrases composed to sound plausible. The previous defaults were v0.1 recipe
79
+ # prose: 0/1142 exact match per role, and most content words ("locked", "groove", "kit",
80
+ # "harmony", "phrasing") appear in ZERO captions of their role. "guitar" in the melody
81
+ # default was the melody/guitar mismatch again, arriving through the default path.
82
+ # They are deliberately genre-less (3.7% of the corpus, in distribution): a default fires
83
+ # when the user named no genre, so it must not assert one. "balanced" is the most neutral
84
+ # attested descriptor for each role.
85
+ DEFAULT_CAPTIONS = {
86
+ "drums": "balanced drums.",
87
+ "bass": "balanced bass.",
88
+ "melody": "balanced lead melody.",
89
+ "vocals": "balanced vocals.",
90
+ }
91
+ # The EXACT 24 keyscale strings the LoRA was conditioned on (from the training meta cache:
92
+ # sharps only, lowercase mode). Offering anything else would be a format the model never saw.
93
+ KEYSCALES = [f"{p} {m}" for m in ("major", "minor")
94
+ for p in ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")]
95
+ BPM_MIN, BPM_MAX = 40, 240 # outside this, treat the value as noise and fall back to N/A
96
+
97
+ _SESSION = None
98
+ _BOOT: dict[str, str] = {}
99
+ # measured locally on a 3090: ~0.22 s/step at 30s. Blackwell is faster; pad for VAE decode + IO.
100
+ STEP_SECONDS = 0.35
101
+ BASE_SECONDS = 25.0
102
+
103
+
104
+ # Output length. seconds=0 means "match the uploaded context", which is the default: the natural
105
+ # request is a stem for the track you handed over, not a stem for the first N seconds of it.
106
+ #
107
+ # MIN/MAX are not taste. 10 s is the decoder's floor. 120 s is the longest length ever measured end to
108
+ # end here (2026-08-11: 30 s -> 12.33 s GPU-held, 120 s -> 27.82 s, correct duration returned both
109
+ # times), and it is also the point where get_duration below still fits inside ZeroGPU's 300 s ceiling
110
+ # at the maximum 64 steps: 120 + 25 + 0.35*64*(120/20) = 279. Raising MAX_SECONDS without re-deriving
111
+ # that arithmetic would let the Space under-request GPU time and get reclaimed mid-generation.
112
+ #
113
+ # Quality is a separate matter from mechanics and does NOT hold flat across the range: the model is
114
+ # trained on 30 s, and measured over a 120 s vocal the voiced fraction fell 52.2% (first 30 s) to
115
+ # 30.4% after, with the spectral centroid drifting up. Long output works; it does not sound as good.
116
+ AUTO_SECONDS = 0.0
117
+ MIN_SECONDS = 10.0
118
+ MAX_SECONDS = 120.0
119
+
120
+
121
+ def _audio_seconds(path: str) -> float:
122
+ """Duration of an upload, from the header. No decode, no GPU, no quota."""
123
+ info = sf.info(path)
124
+ return float(info.frames) / float(info.samplerate)
125
+
126
+
127
+ def resolve_seconds(context_path, requested) -> tuple[float, str]:
128
+ """Turn a requested length into the one actually rendered. Returns (seconds, note).
129
+
130
+ `requested` of 0 (AUTO_SECONDS) means match the upload. Anything else is an explicit override and
131
+ is honoured, still clamped -- the research scripts pass a fixed 30 s on purpose so probe items
132
+ stay comparable, and that must keep working.
133
+ """
134
+ try:
135
+ source = _audio_seconds(context_path) if context_path else 0.0
136
+ except Exception as exc: # noqa: BLE001
137
+ # A header we cannot read is not a reason to fail the request: fall back to the old default.
138
+ return 20.0, f"length 20.00s (could not read upload duration: {type(exc).__name__})"
139
+
140
+ if float(requested or 0) <= 0:
141
+ seconds = min(max(source, MIN_SECONDS), MAX_SECONDS)
142
+ note = f"length {seconds:.2f}s (matched to upload {source:.2f}s)"
143
+ if source > MAX_SECONDS:
144
+ note = (f"length {seconds:.2f}s — upload is {source:.2f}s, capped at {MAX_SECONDS:.0f}s "
145
+ f"(the longest length measured end to end)")
146
+ elif source < MIN_SECONDS:
147
+ note = (f"length {seconds:.2f}s — upload is only {source:.2f}s, raised to the "
148
+ f"{MIN_SECONDS:.0f}s decoder floor; the tail will be silence")
149
+ return seconds, note
150
+
151
+ seconds = min(max(float(requested), MIN_SECONDS), MAX_SECONDS)
152
+ return seconds, f"length {seconds:.2f}s (explicit; upload is {source:.2f}s)"
153
+
154
+
155
+ def get_duration(item_str=None, steps=24, seed=0, role_label=None, lyrics=None, caption=None,
156
+ seconds=20, *a, **k):
157
+ """Dynamic @spaces.GPU duration: ask for what we need, so quota isn't over-reserved.
158
+
159
+ `seconds` here is ALREADY resolved -- generate() calls resolve_seconds before _gpu_generate, and
160
+ ZeroGPU passes that same argument list to this function. If it ever received the raw slider value
161
+ again, a 0 would fall back to 20 via `or 20` and under-request the GPU for a long render.
162
+ """
163
+ # A full finetune reads ~8.3 GiB of shards into the live decoder; an adapter attach is 1.2 GiB.
164
+ extra = 0 if _LORA_READY else (120 if FULLFT_REPO else 45)
165
+ return int(min(300, extra + BASE_SECONDS + STEP_SECONDS * float(steps or 24) * max(1.0, float(seconds or 20) / 20.0)))
166
+
167
+
168
+ # ----------------------------------------------------------------------------- startup (no GPU)
169
+ def _prepare_source() -> str:
170
+ import sys
171
+ root = _fetch_ace_source()
172
+ if root not in sys.path:
173
+ sys.path.insert(0, root)
174
+ return root
175
+
176
+
177
+ def _download() -> str:
178
+ """Pull the base model, VAE + text encoder, and the LoRA. ~23 GB, one time, no GPU quota."""
179
+ CKPT.mkdir(parents=True, exist_ok=True)
180
+ t0 = time.time()
181
+ # base decoder -> checkpoints/acestep-v15-xl-base
182
+ snapshot_download("ACE-Step/acestep-v15-xl-base", local_dir=str(CKPT / MODEL),
183
+ max_workers=8, tqdm_class=None)
184
+ # vae/ + Qwen3-Embedding-0.6B/ + top-level config.json live in the umbrella repo
185
+ snapshot_download("ACE-Step/Ace-Step1.5", local_dir=str(CKPT), max_workers=8, tqdm_class=None,
186
+ allow_patterns=["config.json", "vae/*", "Qwen3-Embedding-0.6B/*"])
187
+ if FULLFT_REPO:
188
+ patterns = ["*.safetensors", "model.safetensors.index.json"]
189
+ if FULLFT_SUBDIR: # scope the download: a mirror repo holds several 8.3 GiB checkpoints
190
+ patterns = [f"{FULLFT_SUBDIR}/{p}" for p in patterns]
191
+ full = snapshot_download(FULLFT_REPO, revision=FULLFT_REV, local_dir="/tmp/fullft",
192
+ max_workers=4, tqdm_class=None, token=HF_TOK,
193
+ allow_patterns=patterns)
194
+ _BOOT["fullft_dir"] = str(Path(full) / FULLFT_SUBDIR) if FULLFT_SUBDIR else full
195
+ return f"downloaded in {time.time() - t0:.0f}s (full finetune {FULLFT_REPO}@{FULLFT_REV})"
196
+ # our adapter repo is PRIVATE -> needs the token from the Space secret
197
+ lora = snapshot_download(LORA_REPO, revision=LORA_REV, local_dir="/tmp/lora",
198
+ max_workers=4, tqdm_class=None, token=HF_TOK,
199
+ allow_patterns=["adapter_config.json", "adapter_model.safetensors"])
200
+ _BOOT["lora_dir"] = lora
201
+ return f"downloaded in {time.time() - t0:.0f}s"
202
+
203
+
204
+ try:
205
+ _BOOT["ace_src"] = _prepare_source()
206
+ _BOOT["status"] = _download()
207
+ except Exception as exc: # surface in the UI instead of a blank page
208
+ _BOOT["status"] = f"DOWNLOAD FAILED: {type(exc).__name__}: {exc}"
209
+ _BOOT.setdefault("lora_dir", "/tmp/lora")
210
+
211
+
212
+ def _warm_analysis() -> None:
213
+ """Run the bpm/key estimator once on synthetic audio at boot.
214
+
215
+ Two reasons: librosa/numba JIT makes a cold call ~20s of (unbilled but user-visible) prep, and
216
+ a version mismatch in the estimator should surface in the boot log rather than silently
217
+ degrading the first real render to N/A. Off-GPU, so it costs no quota."""
218
+ try:
219
+ t0 = time.time()
220
+ p = Path(tempfile.mkdtemp(prefix="warm_")) / "w.wav"
221
+ n = _ANALYSIS_SR * 4
222
+ t = np.arange(n) / _ANALYSIS_SR
223
+ click = (np.sin(2 * np.pi * 220 * t) * (np.sin(2 * np.pi * 2 * t) > 0.9)).astype("float32")
224
+ sf.write(p, click, _ANALYSIS_SR)
225
+ bpm, key = _detect_bpm_key(str(p))
226
+ _BOOT["analysis"] = (f"estimator ready in {time.time()-t0:.0f}s (warmup -> bpm={bpm} key={key or 'N/A'})"
227
+ if bpm or key else "ESTIMATOR BROKEN — bpm/key will fall back to N/A")
228
+ except Exception as exc:
229
+ _BOOT["analysis"] = f"ESTIMATOR BROKEN: {type(exc).__name__}: {exc}"
230
+ print(f"[analysis] {_BOOT['analysis']}")
231
+
232
+
233
+
234
+ # --------------------------------------------- model on cuda at module level (ZeroGPU requirement)
235
+ def _get_session():
236
+ global _SESSION
237
+ if _SESSION is None:
238
+ from run_ace_task_baseline import init_ace
239
+ # NOTE: lora_path deliberately omitted here — PEFT injection requires a real GPU, which
240
+ # only exists inside @spaces.GPU. The adapter is attached by _ensure_lora() on first call.
241
+ _SESSION = init_ace(
242
+ ace_root=_BOOT.get("ace_src", str(ACE_SRC)), checkpoints=str(CKPT), model=MODEL, device="cuda",
243
+ use_lm=False, no_thinking=True, # LM not needed: we pass explicit prompts
244
+ )
245
+ return _SESSION
246
+
247
+
248
+ _LORA_READY = False
249
+ _AOTI = {"state": "not compiled"}
250
+
251
+
252
+ def _demo_ctx(seconds: float = 20.0, sr: int = 48000) -> str:
253
+ """Cheap stand-in context used only to capture real decoder inputs for torch.export."""
254
+ import tempfile as _tf
255
+ rng = np.random.default_rng(0)
256
+ y = (rng.standard_normal((int(seconds * sr), 2)) * 0.05).astype("float32")
257
+ p = Path(_tf.mkdtemp()) / "ctx.wav"
258
+ sf.write(p, y, sr)
259
+ return str(p)
260
+
261
+
262
+ def _attach_fullft(session):
263
+ """Overlay a fully fine-tuned decoder onto the live model. Inside @spaces.GPU, like the adapter.
264
+
265
+ strict=True is the safety argument: a partial load would leave most of the decoder at base weights
266
+ and still generate plausible audio. The handler wraps the model in torch.compile when compiling is
267
+ enabled, so unwrap _orig_mod first -- otherwise the overlay targets the wrapper and every request
268
+ fails, which is exactly what happened on the dev Space.
269
+ """
270
+ from safetensors.torch import load_file
271
+
272
+ shards = sorted(Path(_BOOT["fullft_dir"]).glob("*.safetensors"))
273
+ if not shards:
274
+ raise RuntimeError(f"no .safetensors in {_BOOT.get('fullft_dir')}")
275
+ state = {}
276
+ for shard in shards:
277
+ state.update(load_file(str(shard)))
278
+ model = getattr(session.dit_handler, "model", None)
279
+ model = getattr(model, "_orig_mod", model)
280
+ decoder = getattr(model, "decoder", None)
281
+ _BOOT["overlay_target"] = (f"decoder={type(decoder).__name__} "
282
+ f"decoder_tensors={len(decoder.state_dict()) if decoder else 0} "
283
+ f"file_tensors={len(state)}")
284
+ print(f"[fullft] {_BOOT['overlay_target']}", flush=True)
285
+ if decoder is None:
286
+ raise RuntimeError("dit_handler.model.decoder not reachable; cannot overlay a full finetune")
287
+ decoder.load_state_dict(state, strict=True)
288
+ print(f"[fullft] overlaid {len(state)} tensors ({FULLFT_REPO}@{FULLFT_REV})", flush=True)
289
+
290
+
291
+ def _ensure_lora(session):
292
+ """Attach the trained weights — must run inside @spaces.GPU (needs a real GPU)."""
293
+ global _LORA_READY
294
+ if _LORA_READY:
295
+ return
296
+ if FULLFT_REPO:
297
+ try:
298
+ _attach_fullft(session)
299
+ except Exception as exc:
300
+ import traceback
301
+ _BOOT["weights_error"] = traceback.format_exc()[-4000:]
302
+ raise
303
+ _BOOT["weights_error"] = "none — full finetune overlaid"
304
+ _LORA_READY = True
305
+ return
306
+ h = session.dit_handler
307
+ st = h.add_lora(_BOOT["lora_dir"], adapter_name="stem0")
308
+ print("[lora]", st, flush=True)
309
+ if not str(st).startswith("✅"):
310
+ raise RuntimeError(f"LoRA load failed: {st}")
311
+ print("[lora]", h.set_lora_scale("stem0", 1.0), flush=True)
312
+ print("[lora]", h.set_use_lora(True), flush=True)
313
+ session.lora_path = _BOOT["lora_dir"]
314
+ _LORA_READY = True
315
+
316
+
317
+ def _to_flac(wav_path: Path) -> str:
318
+ """Return FLAC: lossless, ~2x smaller than WAV. Runs off-GPU, so it costs latency only — never quota.
319
+
320
+ Was MP3 (-q:a 2) for an 8x download saving. Replaced 2026-08-09: the generations are the product, and
321
+ re-encoding them lossily to save 2 MB is the wrong trade when FLAC is free of artifacts and still halves
322
+ the transfer. The measured alternative -- moving the VAE client-side and shipping 94 KiB of latents, 61x
323
+ smaller than WAV -- was prototyped and shelved: VAE encode+decode costs 61 s on a CPU client, which is
324
+ slower than the 19 s generation it was meant to accelerate. That design is worth revisiting only for
325
+ clients with a GPU.
326
+ """
327
+ out = wav_path.with_suffix(".flac")
328
+ try:
329
+ import subprocess
330
+ subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav_path),
331
+ "-codec:a", "flac", "-compression_level", "5", str(out)], check=True)
332
+ return str(out)
333
+ except Exception as exc: # never fail a good generation over encoding
334
+ print("[flac] falling back to wav:", exc, flush=True)
335
+ return str(wav_path)
336
+
337
+
338
+ _CTX_CACHE: dict[tuple, str] = {}
339
+
340
+
341
+ def _prepared_context(context_path: str, seconds: float) -> str:
342
+ """Decode/resample/trim once per (file, length). Pure CPU — must stay OUT of @spaces.GPU,
343
+ because ZeroGPU bills wall-clock while the GPU is held."""
344
+ import hashlib
345
+ h = hashlib.md5()
346
+ with open(context_path, "rb") as f:
347
+ for b in iter(lambda: f.read(1 << 20), b""):
348
+ h.update(b)
349
+ key = (h.hexdigest(), round(float(seconds), 2))
350
+ hit = _CTX_CACHE.get(key)
351
+ if hit and os.path.exists(hit):
352
+ return hit
353
+ y, sr = sf.read(context_path, always_2d=True)
354
+ if sr != 48000:
355
+ import librosa
356
+ y = librosa.resample(y.T.astype("float32"), orig_sr=sr, target_sr=48000).T
357
+ sr = 48000
358
+ if y.shape[1] == 1:
359
+ y = np.repeat(y, 2, axis=1)
360
+ n = int(seconds * sr)
361
+ y = y[:n] if len(y) > n else np.pad(y, ((0, n - len(y)), (0, 0)))
362
+ out = Path(tempfile.mkdtemp(prefix="ctx_")) / "context_mix_minus_target.wav"
363
+ sf.write(out, y.astype("float32"), sr)
364
+ _CTX_CACHE[key] = str(out)
365
+ return str(out)
366
+
367
+
368
+ # Krumhansl key-profile weights + analysis SR, COPIED VERBATIM from the script that produced our
369
+ # training labels (scripts/data_recipe/build_track_metas.py). Do not "improve" these: the point is
370
+ # to reproduce the exact label-generating process the LoRA was conditioned on, biases included.
371
+ _ANALYSIS_SR = 22050
372
+ _KMAJ = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
373
+ _KMIN = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
374
+ _NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
375
+ _ANALYSIS_CACHE: dict = {}
376
+
377
+
378
+ def _detect_bpm_key(wav_path: str) -> tuple[int | None, str]:
379
+ """Estimate (bpm, keyscale) with the SAME librosa procedure that labelled the training set.
380
+
381
+ Our training bpm/keyscale were never ground truth — build_track_metas.py estimated them from
382
+ audio. So the closest thing to the conditioning the model actually learned is to re-run that
383
+ identical estimator here, rather than to send N/A (a branch only ~0.7% of training saw).
384
+ CPU only, cached per file — must stay outside @spaces.GPU so it is never billed.
385
+ """
386
+ hit = _ANALYSIS_CACHE.get(wav_path)
387
+ if hit is not None:
388
+ return hit
389
+ def _scalar(x) -> float:
390
+ """librosa's tempo return shape moves between versions (scalar / (1,) / (n,)) and numpy 2
391
+ refuses float() on ndim>0. Take the first element whatever the shape."""
392
+ a = np.asarray(x, dtype="float64").ravel()
393
+ return float(a[0]) if a.size else 0.0
394
+
395
+ try:
396
+ import librosa
397
+ y, _ = librosa.load(wav_path, sr=_ANALYSIS_SR, mono=True)
398
+ try:
399
+ tempo = _scalar(librosa.feature.rhythm.tempo(y=y, sr=_ANALYSIS_SR))
400
+ except Exception:
401
+ tempo = _scalar(librosa.beat.beat_track(y=y, sr=_ANALYSIS_SR)[0])
402
+ chroma = librosa.feature.chroma_cqt(y=y, sr=_ANALYSIS_SR).mean(axis=1)
403
+ mode, root, _ = max(
404
+ ((m, i, np.corrcoef(np.roll(k, i), chroma)[0, 1])
405
+ for m, k in (("maj", _KMAJ), ("min", _KMIN)) for i in range(12)),
406
+ key=lambda x: (x[2] if np.isfinite(x[2]) else -9))
407
+ out = (int(round(tempo)), f"{_NOTES[root]} {'major' if mode == 'maj' else 'minor'}")
408
+ except Exception as e: # never fail a render over metadata
409
+ print(f"[analysis] bpm/key detection failed: {e}")
410
+ out = (None, "")
411
+ _ANALYSIS_CACHE[wav_path] = out
412
+ return out
413
+
414
+
415
+ def _prep_item(context_path: str, role: str, caption: str, lyrics: str, seconds: float,
416
+ bpm: float = 0, keyscale: str = "") -> Path:
417
+ item = Path(tempfile.mkdtemp(prefix="stem0_"))
418
+ prepared = _prepared_context(context_path, seconds)
419
+ shutil.copy2(prepared, item / "context_mix_minus_target.wav")
420
+ y, sr = sf.read(item / "context_mix_minus_target.wav", always_2d=True)
421
+ has_lyrics = role == "vocals" and bool(lyrics.strip())
422
+ meta = {
423
+ "role": role,
424
+ "prompt": caption.strip() or DEFAULT_CAPTIONS[role],
425
+ "ace_task_type": "lego",
426
+ "source_audio": "context_mix_minus_target.wav",
427
+ # NOTE: this drives the actual generation LENGTH (run_ace_task_baseline: duration=
428
+ # _meta_float(meta,"duration_seconds")), not just the metas line — never round it to 30.
429
+ "duration_seconds": float(len(y) / sr),
430
+ "timesignature": "4",
431
+ "lyrics": lyrics.strip() if has_lyrics else "[Instrumental]",
432
+ "instrumental": not has_lyrics,
433
+ "vocal_language": "en" if has_lyrics else "unknown",
434
+ }
435
+ # bpm / keyscale: pass through ONLY when the caller actually knows them.
436
+ #
437
+ # These are not cosmetic. Both metas builders emit the field unconditionally — training
438
+ # (preprocess_utils.build_metas_str) and inference (metadata_utils._dict_to_meta_string) —
439
+ # so leaving them out does not remove the line, it ships "- bpm: N/A". 1164/1172 (99%) of
440
+ # our training tracks carried a REAL bpm and keyscale, so N/A is the ~0.7% branch of the
441
+ # conditioning distribution, not a neutral default. A caller with ground truth (the live
442
+ # app knows its own click tempo) should send it. A caller that would be GUESSING must not:
443
+ # a wrong tempo makes the model play out of time, which is worse than the rare-token hit.
444
+ # A caller-supplied value is honoured as-is. Absent/garbage/"auto" -> field stays out unless
445
+ # "auto" opts into estimation below. float() is guarded because bpm may arrive as "auto".
446
+ try:
447
+ _b = float(bpm)
448
+ except (TypeError, ValueError):
449
+ _b = 0.0
450
+ if _b and BPM_MIN <= _b <= BPM_MAX:
451
+ meta["bpm"] = int(round(_b))
452
+ if keyscale and str(keyscale).strip() in KEYSCALES:
453
+ meta["keyscale"] = str(keyscale).strip()
454
+ #
455
+ # Measured on 40 tracks, estimator-on-partial-context vs the full-mix training label:
456
+ # key exact bpm exact bpm within 5%
457
+ # drums in context 88% 82% 85%
458
+ # drums absent 82% 52% 65%
459
+ #
460
+ # Key holds up either way -> always estimate it. Tempo collapses without drums to lock onto,
461
+ # and a wrong tempo makes the model play out of time (worse than N/A), so we only estimate bpm
462
+ # when the context plausibly contains drums — i.e. every role EXCEPT drums itself.
463
+ # ESTIMATION IS OPT-IN ("auto"), NOT the default. Measured on a labelled track, 64 steps,
464
+ # fixed seed, generated melody's chroma fit to the context's true key (D# major, ctx = +0.398):
465
+ #
466
+ # keyscale sent generated fit to the true key
467
+ # --------------- -----------------------------
468
+ # N/A (absent) +0.406 <- best; matches the context almost exactly
469
+ # "D# major" (right) +0.192 <- WORSE than sending nothing
470
+ # "A major" (wrong) -0.347 <- actively clashing
471
+ #
472
+ # And supplying a correct bpm did not improve tempo at all (117.5 generated either way against
473
+ # a 117 context). So the model already reads tempo AND harmony out of the context audio, and an
474
+ # explicit label competes with that evidence — a coarse 30s key summary is strictly less
475
+ # informative than the audio itself. The original "omit these" recipe was right; estimating
476
+ # them by default made output worse. Kept available for callers who genuinely want to force a
477
+ # key, plus "auto" for experiments.
478
+ # bpm rides a gr.Number, which rejects the string "auto" — so -1 is the numeric opt-in sentinel.
479
+ want_bpm_auto = str(bpm).strip().lower() == "auto" or _b == -1
480
+ want_key_auto = str(keyscale).strip().lower() == "auto"
481
+ if want_bpm_auto or want_key_auto:
482
+ det_bpm, det_key = _detect_bpm_key(str(item / "context_mix_minus_target.wav"))
483
+ est = []
484
+ if want_bpm_auto and role != "drums" and det_bpm and BPM_MIN <= det_bpm <= BPM_MAX:
485
+ meta["bpm"] = det_bpm
486
+ est.append("bpm")
487
+ if want_key_auto and det_key in KEYSCALES:
488
+ meta["keyscale"] = det_key
489
+ est.append("keyscale")
490
+ if est:
491
+ meta["_estimated"] = est
492
+ (item / "metadata.json").write_text(json.dumps(meta, indent=2))
493
+ return item
494
+
495
+
496
+ @spaces.GPU(duration=get_duration)
497
+ def _gpu_generate(item_str, steps, seed, *_):
498
+ """ONLY the model call. Everything CPU-bound is kept outside so it isn't billed as GPU time."""
499
+ from run_ace_task_baseline import run_take
500
+ session = _get_session()
501
+ _ensure_lora(session) # first call attaches the adapter on the real GPU
502
+ item = Path(item_str)
503
+ _t = time.time()
504
+ run_take(session, item, task="lego", steps=int(steps), seed=int(seed),
505
+ cover_strength=COVER_STRENGTH,
506
+ out_dir=item / "out", generated_name="generated_stem.wav", log=print)
507
+ held = time.time() - _t # actual GPU-held time == what ZeroGPU bills
508
+ return f"{item / 'generated_stem.wav'}|{held:.3f}"
509
+
510
+
511
+ def generate(context_audio, role_label, lyrics, caption, steps, seconds, seed, bpm=0, keyscale=""):
512
+ """bpm/keyscale are TRAILING and OPTIONAL: existing 7-arg callers keep working unchanged
513
+ (Gradio fills missing trailing inputs from component defaults)."""
514
+ if context_audio is None:
515
+ raise gr.Error("Upload a context audio first (e.g. drums+bass, or a full instrumental).")
516
+ if _BOOT.get("status", "").startswith("DOWNLOAD FAILED"):
517
+ raise gr.Error(_BOOT["status"])
518
+ role = ROLES[role_label]
519
+ if role == "vocals" and not lyrics.strip():
520
+ raise gr.Error("Vocals need lyrics — type some, or pick a different stem.")
521
+
522
+ # Resolve the output length BEFORE anything else touches `seconds`: _prep_item pads or trims the
523
+ # context to exactly this value, and get_duration sizes the ZeroGPU request from it.
524
+ seconds, length_note = resolve_seconds(context_audio, seconds)
525
+
526
+ tp = time.time()
527
+ item = _prep_item(context_audio, role, caption, lyrics, float(seconds), bpm, keyscale or "")
528
+ _m = json.loads((item / "metadata.json").read_text())
529
+ _est = _m.get("_estimated", []) # '~' in the note marks an estimate, not ground truth
530
+ prep_s = time.time() - tp
531
+ t0 = time.time()
532
+ _ret = _gpu_generate(str(item), int(steps), int(seed), role_label, lyrics, caption, seconds)
533
+ _path, _held = _ret.rsplit("|", 1)
534
+ gen = Path(_path)
535
+ held_s = float(_held) # GPU actually held (billed)
536
+ outer_s = time.time() - t0 # held + ZeroGPU allocation/queue wait
537
+ alloc_s = max(0.0, outer_s - held_s)
538
+ tm = time.time()
539
+
540
+ ctx, sr = sf.read(item / "context_mix_minus_target.wav", always_2d=True)
541
+ g, _ = sf.read(gen, always_2d=True)
542
+ n = min(len(ctx), len(g))
543
+ mix = ctx[:n] + g[:n]
544
+ pk = float(np.abs(mix).max()) or 1.0
545
+ mix = (mix * (0.97 / pk)).astype("float32")
546
+ mix_path = item / "mix.wav"
547
+ sf.write(mix_path, mix, sr)
548
+ t_enc = time.time()
549
+ gen_out, mix_out = _to_flac(gen), _to_flac(mix_path)
550
+ enc_s = time.time() - t_enc
551
+ mix_s = time.time() - tm
552
+ note = (f"role={role} · steps={int(steps)} · {length_note} · "
553
+ f"bpm={_m.get('bpm', 'N/A')}{'~' if 'bpm' in _est else ''} · "
554
+ f"key={_m.get('keyscale', 'N/A')}{'~' if 'keyscale' in _est else ''} · "
555
+ f"GPU-held {held_s:.2f}s ({held_s/max(int(steps),1):.3f}s/step) · "
556
+ f"alloc/queue {alloc_s:.2f}s · prep {prep_s:.2f}s · mix {mix_s:.2f}s · "
557
+ f"flac {enc_s:.2f}s (all off-GPU)")
558
+ return gen_out, mix_out, note
559
+
560
+
561
+ # Place the model on cuda NOW (startup), per ZeroGPU guidance. Safe outside @spaces.GPU thanks to
562
+ # their CUDA emulation mode; makes the first real request fast instead of paying a 20GB load then.
563
+ if not _BOOT.get("status", "").startswith("DOWNLOAD FAILED"):
564
+ try:
565
+ _t = time.time()
566
+ _get_session()
567
+ _BOOT["load"] = f"model on cuda in {time.time()-_t:.0f}s"
568
+ except Exception as exc:
569
+ _BOOT["load"] = f"MODEL LOAD FAILED: {type(exc).__name__}: {exc}"
570
+ print("[boot]", _BOOT.get("load"), flush=True)
571
+
572
+
573
+ def boot_status() -> str:
574
+ """CPU-only: verify download + cuda placement without spending any GPU quota."""
575
+ info = {k: str(v)[:300] for k, v in _BOOT.items()} | {"aoti": _AOTI["state"]}
576
+ # WHICH weights are actually loaded, answered by CONTENT not by branch name. A branch name has
577
+ # silently pointed at the wrong checkpoint twice (a fresh branch inherits main's commit, so a
578
+ # failed upload leaves a branch that looks right and isn't). Compare this to the local
579
+ # checkpoint's sha256 before trusting any claim about which step is being served.
580
+ info["lora_rev_requested"] = LORA_REV
581
+ if "lora_sha256" not in _BOOT:
582
+ try:
583
+ import hashlib
584
+ p = Path(_BOOT.get("lora_dir", "/tmp/lora")) / "adapter_model.safetensors"
585
+ h = hashlib.sha256()
586
+ with open(p, "rb") as f:
587
+ for b in iter(lambda: f.read(1 << 22), b""):
588
+ h.update(b)
589
+ _BOOT["lora_sha256"] = f"{h.hexdigest()} ({p.stat().st_size/1e6:.0f} MB)"
590
+ except Exception as e:
591
+ _BOOT["lora_sha256"] = f"UNAVAILABLE: {type(e).__name__}: {e}"
592
+ info["lora_sha256"] = _BOOT["lora_sha256"]
593
+ if FULLFT_REPO and "fullft_id" not in _BOOT:
594
+ try:
595
+ import hashlib
596
+ from huggingface_hub import HfApi
597
+ sha = HfApi(token=HF_TOK).model_info(FULLFT_REPO, revision=FULLFT_REV).sha
598
+ shards = sorted(Path(_BOOT.get("fullft_dir", "/tmp/fullft")).glob("*.safetensors"))
599
+ h2, total = hashlib.sha256(), 0
600
+ for shard in shards:
601
+ size = shard.stat().st_size
602
+ total += size
603
+ h2.update(f"{shard.name}:{size}".encode())
604
+ with open(shard, "rb") as f:
605
+ h2.update(f.read(4 << 20))
606
+ if size > (8 << 20):
607
+ f.seek(-(4 << 20), 2)
608
+ h2.update(f.read(4 << 20))
609
+ _BOOT["fullft_id"] = (f"commit {sha[:12]} · partial-digest {h2.hexdigest()[:16]} · "
610
+ f"{len(shards)} shards · {total/1e9:.2f} GB")
611
+ except Exception as e:
612
+ _BOOT["fullft_id"] = f"UNAVAILABLE: {type(e).__name__}: {e}"
613
+ if FULLFT_REPO:
614
+ info["fullft_id"] = _BOOT["fullft_id"]
615
+ info["fullft_rev_requested"] = FULLFT_REV
616
+ # REPORT THE REPO, not just the commit. deploy.py verifies the served commit against
617
+ # `status["fullft_repo"] or "fcolooo/stem-0-fullft"` -- and this dict never carried
618
+ # fullft_repo, so every non-prod Space silently fell back to prod's repo and FAILED
619
+ # verification while serving exactly what it was asked to. A deploy gate that cannot pass
620
+ # on a dev Space is a gate people learn to ignore, which is worse than no gate.
621
+ info["fullft_repo"] = FULLFT_REPO
622
+ try:
623
+ import torch
624
+ info["torch"] = torch.__version__
625
+ try:
626
+ import torchaudio; info["torchaudio"] = torchaudio.__version__
627
+ except Exception as e: info["torchaudio"] = f"MISSING: {e}"
628
+ import gradio; info["gradio"] = gradio.__version__
629
+ try:
630
+ import librosa, numpy
631
+ info["librosa"] = f"{librosa.__version__} (numpy {numpy.__version__})"
632
+ except Exception as e: info["librosa"] = f"MISSING: {e}"
633
+ except Exception as e:
634
+ info["torch"] = f"ERR {e}"
635
+ return json.dumps(info, indent=2)
636
+
637
+
638
+ @spaces.GPU(duration=1500)
639
+ def optimize_aoti(seconds: float = 20.0):
640
+ """Ahead-of-time compile the DiT decoder (torch.compile is unsupported on ZeroGPU).
641
+ Opt-in: compilation itself costs GPU quota, and the graph is specialised to the clip length
642
+ it was captured at, so recompile if you change `seconds`."""
643
+ import torch
644
+ from run_ace_task_baseline import run_take
645
+ if _AOTI["state"].startswith("applied"):
646
+ return _AOTI["state"]
647
+ session = _get_session()
648
+ _ensure_lora(session)
649
+ dec = session.dit_handler.model.decoder
650
+ t0 = time.time()
651
+ try:
652
+ # capture the real args the decoder is called with, by running a tiny generation
653
+ # aoti_capture aborts the call with a sentinel exception once it has the args, but ACE's
654
+ # generate_music catches everything and re-raises it as "Generation failed" — so swallow it
655
+ # here and check whether the args were recorded anyway.
656
+ with spaces.aoti_capture(dec) as call:
657
+ try:
658
+ item = _prep_item(_demo_ctx(seconds), "drums", "", "", float(seconds))
659
+ run_take(session, item, task="lego", steps=4, seed=1,
660
+ cover_strength=COVER_STRENGTH,
661
+ out_dir=item / "o", generated_name="g.wav", log=print)
662
+ except Exception as cap_exc:
663
+ print("[aoti] capture aborted as expected:", type(cap_exc).__name__, flush=True)
664
+ if not getattr(call, "args", None) and not getattr(call, "kwargs", None):
665
+ raise RuntimeError("aoti_capture recorded no decoder call")
666
+ print(f"[aoti] captured {len(call.args)} args / {len(call.kwargs)} kwargs", flush=True)
667
+ exported = torch.export.export(dec, args=call.args, kwargs=call.kwargs)
668
+ compiled = spaces.aoti_compile(exported)
669
+ spaces.aoti_apply(compiled, session.dit_handler.model.decoder)
670
+ _AOTI["state"] = f"applied in {time.time() - t0:.0f}s (captured at {seconds:.0f}s clips)"
671
+ except Exception as exc:
672
+ # eager still works — never let a failed optimisation break generation
673
+ _AOTI["state"] = f"AOTI failed, staying on eager: {type(exc).__name__}: {str(exc)[:300]}"
674
+ print("[aoti]", _AOTI["state"], flush=True)
675
+ return _AOTI["state"]
676
+
677
+
678
+ _warm_analysis() # JIT + version check up front, so no user's first render pays for it
679
+
680
+ with gr.Blocks(title="Stem-0 · missing-stem generation") as demo:
681
+ gr.Markdown(
682
+ "## Stem-0 — generate the missing stem\n"
683
+ "Upload an audio **context** (drums+bass, an instrumental, even a solo stem) and it writes the "
684
+ "missing part to fit. For **vocals**, type your own lyrics.\n\n"
685
+ f"*Boot: {_BOOT.get('status','?')} · {_BOOT.get('load','')}* · base `ACE-Step/acestep-v15-xl-base` + LoRA "
686
+ f"`{LORA_REPO}@{LORA_REV}`"
687
+ )
688
+ gr.Markdown(
689
+ "⚠️ **ZeroGPU quota is per-day.** The first run also loads ~20 GB onto the GPU and is slow; "
690
+ "later runs are quick. Keep **steps** low (24–32) while experimenting — cost scales with steps × seconds."
691
+ )
692
+ with gr.Row():
693
+ with gr.Column():
694
+ ctx_in = gr.Audio(label="Context audio (the stems you already have)", type="filepath")
695
+ role_in = gr.Dropdown(list(ROLES), value="vocals", label="Stem to generate")
696
+ cap_in = gr.Textbox(label="Caption (optional — describes the part you want)",
697
+ placeholder="a raw, intimate indie lead vocal, breathy and close")
698
+ lyr_in = gr.Textbox(label="Lyrics (vocals only)", lines=8,
699
+ placeholder="[Verse]\nyou're a cigarette in my head\nstill burning when i'm in bed")
700
+ with gr.Row():
701
+ steps_in = gr.Slider(16, 64, value=24, step=4, label="Steps (↑quality, ↑GPU cost)")
702
+ # 0 is the default and means "as long as the file you uploaded". The slider stays in
703
+ # the same position in the API's positional `data` array, so existing callers that
704
+ # send an explicit length keep working unchanged.
705
+ secs_in = gr.Slider(0, MAX_SECONDS, value=AUTO_SECONDS, step=5,
706
+ label="Seconds (0 = match the uploaded audio)")
707
+ seed_in = gr.Number(value=901, precision=0, label="Seed")
708
+ with gr.Row():
709
+ # Only fill these if you KNOW them (e.g. you played to a click). 99% of training
710
+ # carried a real bpm/key, so a correct value matches the conditioning the LoRA
711
+ # learned — but a guess is worse than leaving it blank.
712
+ bpm_in = gr.Number(value=0, precision=0,
713
+ label=f"BPM — 0 = omit, -1 = estimate ({BPM_MIN}-{BPM_MAX})")
714
+ # allow_custom_value: without it Gradio hard-errors an API caller that sends a key
715
+ # outside the list. We'd rather accept the string and let _prep_item fall back to
716
+ # N/A, so an unrecognised key degrades instead of failing the whole render.
717
+ key_in = gr.Dropdown([""] + KEYSCALES, value="", allow_custom_value=True,
718
+ label="Key — blank = omit, \"auto\" = estimate")
719
+ go = gr.Button("Generate", variant="primary")
720
+ with gr.Column():
721
+ stem_out = gr.Audio(label="Generated stem")
722
+ mix_out = gr.Audio(label="Context + generated (mix)")
723
+ info_out = gr.Markdown()
724
+ with gr.Accordion("Speed: ahead-of-time compile (AOTI)", open=False):
725
+ gr.Markdown(
726
+ "One-off AOTI compile of the DiT decoder — HF report **1.3–1.8x** on ZeroGPU. "
727
+ "Compilation itself consumes GPU quota and is specialised to the clip length used here, "
728
+ "so run it once for the `seconds` you actually generate at. Falls back to eager on failure."
729
+ )
730
+ with gr.Row():
731
+ aoti_secs = gr.Slider(10, 30, value=20, step=5, label="Compile for this clip length (s)")
732
+ aoti_btn = gr.Button("Compile now")
733
+ aoti_out = gr.Textbox(label="AOTI state", lines=2)
734
+ aoti_btn.click(optimize_aoti, aoti_secs, aoti_out, api_name="optimize")
735
+ with gr.Accordion("Boot status (free, no GPU)", open=False):
736
+ st_btn = gr.Button("Check")
737
+ st_out = gr.Textbox(label="status", lines=6)
738
+ st_btn.click(boot_status, None, st_out, api_name="status")
739
+ go.click(generate, [ctx_in, role_in, lyr_in, cap_in, steps_in, secs_in, seed_in, bpm_in, key_in],
740
+ [stem_out, mix_out, info_out], api_name="generate")
741
+
742
+ if __name__ == "__main__":
743
+ demo.queue(max_size=8).launch()
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NOTE: deliberately NOT installing ace-step as a package, and NOT installing torch/gradio.
2
+ # ace-step pins gradio==6.2.0 and exact torch==2.10.0+cu128 wheels; reinstalling torch inside a
3
+ # ZeroGPU Space fights the torch build that ZeroGPU patches. We vendor the acestep SOURCE at
4
+ # startup instead (app.py) and rely on the Space's own torch + gradio.
5
+ torchaudio
6
+ transformers>=4.51.0,<4.58.0
7
+ diffusers>=0.37.0
8
+ accelerate>=1.0.0
9
+ peft>=0.13
10
+ safetensors
11
+ soundfile
12
+ librosa
13
+ scipy
14
+ loguru
15
+ einops
16
+ diskcache
17
+ vector-quantize-pytorch
18
+ numba
19
+ huggingface_hub>=0.25
20
+ spaces
run_ace_task_baseline.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run ACE-Step for one prepared pack item — the proven reference implementation.
3
+
4
+ This file is the ONLY place GenerationParams is constructed. That is not a style
5
+ preference, it is the lesson from 2026-07-16/17: apps/stack/infer_worker.py was a
6
+ second, hand-typed copy of this call, it drifted in six places (global_caption='',
7
+ audio_cover_strength=0.0, hardcoded bpm, wrong captions, timesignature='', steps=16),
8
+ and it cost Francesco a night of "still crappy" output. Two hand-typed copies of the
9
+ same call drift forever and you can never prove you have found the last divergence.
10
+ One implementation is falsifiable; two are not.
11
+
12
+ Three entry points, one code path:
13
+
14
+ init_ace() load the model ONCE. Expensive (~20-40s: 4B decoder).
15
+ build_params() metadata.json -> GenerationParams. THE single construction.
16
+ run_take() one generation against an already-loaded session. ~7s warm.
17
+ main() the CLI, unchanged. scripts/stemgen_webapp.py and apps/stems shell
18
+ out to it and must keep working — it is the reference for
19
+ docs/INFERENCE_RECIPE.md.
20
+
21
+ The CLI does init_ace() + run_take() and exits, so it pays the load every time. A
22
+ resident caller (apps/stack/infer_worker.py) does init_ace() once and then run_take()
23
+ per request — the same functions, so it cannot drift from the CLI by construction.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import shutil
31
+ import sys
32
+ import time
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+
38
+ # =========================================================================
39
+ # THE MAY 7 RECIPE — the defaults, living in the ONE implementation.
40
+ #
41
+ # Provenance: artifacts/generated_batches/batch_10_actual_lego_20260507/*/
42
+ # ace_lego_corrected/result.json — the last batch Francesco confirmed sounds good.
43
+ # Copied, not invented. docs/INFERENCE_RECIPE.md is the writeup.
44
+ #
45
+ # WHY THESE LIVE HERE AND NOT IN A CALLER (found 2026-07-17, live):
46
+ # apps/stack/infer_worker.py held these as its own constants. When serving switched
47
+ # to shelling out to this script, the worker stopped being the path — and the recipe
48
+ # went with it, because this script reads caption/global_caption from metadata.json
49
+ # and the server's metadata.json has no `global_caption` key. So serving silently
50
+ # reverted to global_caption="" — the single biggest prompt-side defect — with the
51
+ # recipe still sitting "restored" in a file nothing called.
52
+ #
53
+ # A default in a caller is a default that gets lost. These are the defaults now, so
54
+ # every caller (CLI, studio, resident worker) gets the proven recipe unless its
55
+ # metadata deliberately overrides it.
56
+ # =========================================================================
57
+
58
+ # The instruction that makes the model emit a STEM rather than a mix.
59
+ RECIPE_GLOBAL_CAPTION = (
60
+ "Generate only the requested missing isolated stem so that it fits the provided "
61
+ "audio context. Preserve timing, style, tempo, harmony, and arrangement. Do not "
62
+ "generate a full mix."
63
+ )
64
+
65
+ # Captions in ACE-Step's own register: short, natural descriptions of the target
66
+ # stem — the way ACE was trained, not verbose instructions with negation lists. The
67
+ # "isolated stem, no full mix" intent is carried by RECIPE_GLOBAL_CAPTION, so the
68
+ # per-role caption just names the instrument and how it sits. (Was: long prose with
69
+ # "Do not generate bass/guitars/vocals…", out of ACE's training distribution —
70
+ # Francesco 2026-07-17. The proven long form is preserved in git if we A/B back.)
71
+ RECIPE_CAPTIONS = {
72
+ "drums": "a tight drum kit locked to the groove and tempo",
73
+ "bass": "a groovy bass line locked to the drums and harmony",
74
+ "melody": "a melodic lead line locked to the harmony",
75
+ "vocals": "a lead vocal locked to the melody and phrasing",
76
+ }
77
+ RECIPE_TIMESIGNATURE = "4"
78
+
79
+
80
+ def _meta_float(meta: dict, key: str):
81
+ value = meta.get(key)
82
+ if value in (None, "", "N/A"):
83
+ return None
84
+ try:
85
+ return float(value)
86
+ except Exception:
87
+ return None
88
+
89
+
90
+ def _meta_int(meta: dict, key: str):
91
+ value = _meta_float(meta, key)
92
+ return int(round(value)) if value else None
93
+
94
+
95
+ @dataclass
96
+ class AceSession:
97
+ """A loaded ACE model. Hold one of these and run_take() is ~7s instead of ~45s."""
98
+ dit_handler: Any
99
+ llm_handler: Any
100
+ device: str
101
+ lora_path: str | None = None
102
+ lora_scale: float = 1.0
103
+ full_ft_checkpoint: str | None = None
104
+ load_seconds: float = 0.0
105
+
106
+
107
+ def init_ace(
108
+ *,
109
+ ace_root: str = "/home/fcolo/ace-step-1.5-xl",
110
+ checkpoints: str = "/home/fcolo/ace-step/checkpoints",
111
+ model: str = "acestep-v15-xl-base",
112
+ device: str = "cuda",
113
+ lm_model: str = "acestep-5Hz-lm-1.7B",
114
+ lm_backend: str = "pt",
115
+ no_thinking: bool = True,
116
+ use_lm: bool = False,
117
+ use_cot: bool = False,
118
+ full_ft_checkpoint: str | None = None,
119
+ lora_path: str | None = None,
120
+ adapter_name: str = "stemgen",
121
+ lora_scale: float = 1.0,
122
+ log=print,
123
+ ) -> AceSession:
124
+ """Everything expensive, once. Safe to call from a long-lived process."""
125
+ if full_ft_checkpoint and lora_path:
126
+ raise ValueError("--full-ft-checkpoint and --lora-path are mutually exclusive")
127
+
128
+ t0 = time.time()
129
+ # Hard CPU mode: ACE/PEFT sometimes tries to stage LoRA weights on CUDA even when
130
+ # the requested generation device is CPU. Hide CUDA before importing ACE/torch so
131
+ # CPU jobs don't fight long-running GPU work.
132
+ if str(device).lower().split(":", 1)[0] == "cpu":
133
+ os.environ["CUDA_VISIBLE_DEVICES"] = ""
134
+ os.environ.setdefault("ACESTEP_VAE_ON_CPU", "1")
135
+ os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
136
+ log("[INFO] CPU mode: CUDA_VISIBLE_DEVICES cleared for this process")
137
+
138
+ ace_root_p = Path(ace_root).resolve()
139
+ if str(ace_root_p) not in sys.path:
140
+ sys.path.insert(0, str(ace_root_p))
141
+ os.environ["ACESTEP_CHECKPOINTS_DIR"] = checkpoints
142
+
143
+ from acestep.handler import AceStepHandler
144
+ from acestep.llm_inference import LLMHandler
145
+
146
+ dit_handler = AceStepHandler()
147
+ status, success = dit_handler.initialize_service(
148
+ project_root=str(ace_root_p),
149
+ config_path=model,
150
+ device=device,
151
+ prefer_source="huggingface",
152
+ )
153
+ if not success:
154
+ raise RuntimeError(f"ACE init failed: {status}")
155
+ log(status)
156
+
157
+ if full_ft_checkpoint:
158
+ # Full fine-tune: swap the whole DiT decoder. Traced 2026-07-17 --
159
+ # initialize_service() -> init_service_loader.py:175 sets
160
+ # self.model = AutoModel.from_pretrained(...), the SAME construction as
161
+ # training_v2/model_loader.py:load_decoder_for_training(); and
162
+ # AceStepConditionGenerationModel.__init__ (modeling_acestep_v15_base.py:1609)
163
+ # sets self.decoder = AceStepDiTModel(config). So handler.model.decoder is
164
+ # exactly the submodule train_full.py checkpoints.
165
+ from safetensors.torch import load_file
166
+
167
+ ckpt = Path(full_ft_checkpoint) / "decoder_model.safetensors"
168
+ if not ckpt.is_file():
169
+ raise RuntimeError(f"no decoder_model.safetensors under {full_ft_checkpoint}")
170
+ target = getattr(getattr(dit_handler, "model", None), "decoder", None)
171
+ if target is None:
172
+ raise RuntimeError("handler.model.decoder not found after initialize_service — "
173
+ "ACE internals changed; re-trace before trusting this path.")
174
+ # Fingerprint before/after: a load that silently no-ops (wrong keys, empty
175
+ # dict) is otherwise indistinguishable from success.
176
+ probe = next(k for k, _ in target.named_parameters())
177
+ before = float(dict(target.named_parameters())[probe].detach().float().sum().item())
178
+ sd = load_file(str(ckpt))
179
+ target.load_state_dict(sd, strict=True) # strict: any mismatch raises
180
+ after = float(dict(target.named_parameters())[probe].detach().float().sum().item())
181
+ if before == after:
182
+ raise RuntimeError(f"decoder weights UNCHANGED after load_state_dict (probe {probe} "
183
+ f"sum {before}); refusing to run a checkpoint that did not apply.")
184
+ target.eval()
185
+ log(json.dumps({"full_ft_loaded": str(ckpt), "probe": probe,
186
+ "sum_before": before, "sum_after": after, "tensors": len(sd)}))
187
+
188
+ if lora_path:
189
+ lora_status = dit_handler.add_lora(lora_path, adapter_name=adapter_name)
190
+ log(lora_status)
191
+ if not str(lora_status).startswith("✅"):
192
+ raise RuntimeError(f"ACE LoRA load failed: {lora_status}")
193
+ log(dit_handler.set_lora_scale(adapter_name, lora_scale))
194
+ log(dit_handler.set_use_lora(True))
195
+
196
+ llm_handler = None
197
+ if use_lm or use_cot or not no_thinking:
198
+ llm_handler = LLMHandler()
199
+ lm_status, lm_success = llm_handler.initialize(
200
+ checkpoint_dir=checkpoints,
201
+ lm_model_path=lm_model,
202
+ backend=lm_backend,
203
+ device=device,
204
+ offload_to_cpu=False,
205
+ )
206
+ if not lm_success:
207
+ raise RuntimeError(f"ACE LM init failed: {lm_status}")
208
+ log(lm_status)
209
+
210
+ return AceSession(
211
+ dit_handler=dit_handler,
212
+ llm_handler=llm_handler,
213
+ device=device,
214
+ lora_path=lora_path,
215
+ lora_scale=lora_scale,
216
+ full_ft_checkpoint=full_ft_checkpoint,
217
+ load_seconds=round(time.time() - t0, 2),
218
+ )
219
+
220
+
221
+ def build_params(
222
+ meta: dict,
223
+ item_dir: Path,
224
+ *,
225
+ task: str = "lego",
226
+ steps: int = 64,
227
+ seed: int = 1234,
228
+ guidance_scale: float = 7.0,
229
+ cover_strength: float = 0.45,
230
+ no_thinking: bool = True,
231
+ use_cot: bool = False,
232
+ retake_seed: "int | None" = None,
233
+ retake_variance: float = 0.0,
234
+ ):
235
+ """metadata.json -> (GenerationParams, GenerationConfig).
236
+
237
+ ⚠️ THE SINGLE CONSTRUCTION. Every caller — CLI, resident worker, studio — comes
238
+ through here. Do not copy this into another file; import it. See the module
239
+ docstring for what a second copy cost.
240
+
241
+ Values default to docs/INFERENCE_RECIPE.md (the May 7 recipe); metadata.json
242
+ overrides where it carries a value. Note what the meta deliberately may omit:
243
+ bpm absent means lego locks tempo from src_audio itself, which is correct — a
244
+ GUESSED bpm is worse than none (the app sent a hardcoded 98 against audio at
245
+ 119/170 and the model dutifully played out of tempo).
246
+ """
247
+ from acestep.inference import GenerationParams, GenerationConfig
248
+
249
+ role = meta.get("role", "")
250
+ ace_role = "guitar" if role == "melody" else role
251
+ # Recipe defaults, overridable by metadata. `or` not `.get(k, default)`: an empty
252
+ # string in the metadata means "absent", not "deliberately empty" — and empty is
253
+ # exactly the failure this guards.
254
+ caption = (meta.get("ace_caption") or meta.get("prompt") or RECIPE_CAPTIONS.get(role)
255
+ or f"Add {role} for this song.")
256
+ global_caption = meta.get("global_caption") or RECIPE_GLOBAL_CAPTION
257
+ source = item_dir / meta.get("source_audio", "context_mix_minus_target.wav")
258
+
259
+ if task == "lego":
260
+ instruction = f"Generate the {ace_role.upper()} track based on the audio context:"
261
+ elif task == "complete":
262
+ classes = meta.get("complete_track_classes") or [ace_role]
263
+ instruction = "Complete the input track with " + " | ".join(str(c).upper() for c in classes) + ":"
264
+ else:
265
+ instruction = "Generate audio semantic tokens based on the given conditions:"
266
+
267
+ params = GenerationParams(
268
+ task_type=task,
269
+ src_audio=str(source),
270
+ instruction=instruction,
271
+ caption=caption,
272
+ global_caption=global_caption,
273
+ lyrics=meta.get("lyrics", "[Instrumental]"),
274
+ instrumental=bool(meta.get("instrumental", True)),
275
+ vocal_language=meta.get("vocal_language", "unknown"),
276
+ bpm=_meta_int(meta, "bpm"),
277
+ keyscale=meta.get("keyscale") or meta.get("key") or "",
278
+ timesignature=str(meta.get("timesignature") or meta.get("time_signature") or RECIPE_TIMESIGNATURE),
279
+ duration=_meta_float(meta, "duration_seconds") or -1.0,
280
+ repainting_start=0.0,
281
+ repainting_end=-1,
282
+ # Retake: variance-preserving variation. With a FIXED base `seed` per part and a
283
+ # small `retake_variance`, each new sample is the SAME part subtly evolved — not
284
+ # an unrelated diffusion draw. This is how a part 'continues from what it was'.
285
+ retake_seed=retake_seed,
286
+ retake_variance=float(retake_variance or 0.0),
287
+ inference_steps=steps,
288
+ seed=seed,
289
+ thinking=not no_thinking,
290
+ guidance_scale=guidance_scale,
291
+ audio_cover_strength=cover_strength,
292
+ use_cot_metas=use_cot,
293
+ use_cot_caption=False, # production engine.py uses metas + language, NOT caption
294
+ use_cot_language=use_cot,
295
+ shift=3.0, # match production engine.py (was unset -> default)
296
+ dcw_enabled=False, # #1255 NOISE FIX (production default) — the un-denoised culprit
297
+ cover_noise_strength=0.0,
298
+ use_adg=False,
299
+ use_constrained_decoding=True,
300
+ enable_normalization=True,
301
+ normalization_db=-1.0,
302
+ cfg_interval_start=0.0,
303
+ cfg_interval_end=1.0,
304
+ )
305
+ config = GenerationConfig(batch_size=1, use_random_seed=False, seeds=[seed], audio_format="wav")
306
+ return params, config
307
+
308
+
309
+ def run_take(
310
+ session: AceSession,
311
+ item_dir: Path,
312
+ *,
313
+ task: str = "lego",
314
+ steps: int = 64,
315
+ seed: int = 1234,
316
+ guidance_scale: float = 7.0,
317
+ cover_strength: float = 0.45,
318
+ no_thinking: bool = True,
319
+ use_cot: bool = False,
320
+ out_dir: Path | None = None,
321
+ generated_name: str | None = None,
322
+ retake_seed: "int | None" = None,
323
+ retake_variance: float = 0.0,
324
+ log=print,
325
+ ) -> dict:
326
+ """One generation against an already-loaded session. Warm: ~7s.
327
+
328
+ Identical to what the CLI does per item — because the CLI calls this.
329
+ """
330
+ from acestep.inference import generate_music
331
+
332
+ item_dir = Path(item_dir).resolve()
333
+ meta = json.loads((item_dir / "metadata.json").read_text())
334
+ out_dir = Path(out_dir).resolve() if out_dir else item_dir / f"ace_{task}_corrected"
335
+ out_dir.mkdir(parents=True, exist_ok=True)
336
+
337
+ params, config = build_params(
338
+ meta, item_dir, task=task, steps=steps, seed=seed,
339
+ guidance_scale=guidance_scale, cover_strength=cover_strength,
340
+ no_thinking=no_thinking, use_cot=use_cot,
341
+ retake_seed=retake_seed, retake_variance=retake_variance,
342
+ )
343
+ t0 = time.time()
344
+ result = generate_music(session.dit_handler, session.llm_handler, params, config, save_dir=str(out_dir))
345
+ (out_dir / "result.json").write_text(
346
+ json.dumps(result.to_dict() if hasattr(result, "to_dict") else result.__dict__, indent=2, default=str) + "\n"
347
+ )
348
+ if not result.success:
349
+ raise RuntimeError(result.error or result.status_message)
350
+
351
+ generated = result.audios[0]["path"]
352
+ target_name = generated_name or ("generated_full_ace.wav" if task == "cover" else "generated_stem_ace.wav")
353
+ copied_to = item_dir / target_name
354
+ shutil.copy2(generated, copied_to)
355
+ return {
356
+ "generated": generated,
357
+ "copied_to": str(copied_to),
358
+ "task": task,
359
+ "lora_path": session.lora_path,
360
+ "lora_scale": session.lora_scale,
361
+ "seed": seed,
362
+ "steps": steps,
363
+ "guidance_scale": guidance_scale,
364
+ "cover_strength": cover_strength,
365
+ "generate_seconds": round(time.time() - t0, 2),
366
+ }
367
+
368
+
369
+ def main() -> None:
370
+ p = argparse.ArgumentParser(description="Run ACE-Step corrected baseline task for one prepared pack item.")
371
+ p.add_argument("item_dir", type=Path)
372
+ p.add_argument("--task", choices=["lego", "complete", "cover"], default="lego")
373
+ p.add_argument("--ace-root", default="/home/fcolo/ace-step-1.5-xl")
374
+ p.add_argument("--checkpoints", default="/home/fcolo/ace-step/checkpoints")
375
+ p.add_argument("--model", default="acestep-v15-xl-base")
376
+ p.add_argument("--lm-model", default="acestep-5Hz-lm-1.7B")
377
+ p.add_argument("--lm-backend", default="pt", choices=["pt", "vllm"])
378
+ p.add_argument("--device", default="cuda")
379
+ p.add_argument("--steps", type=int, default=64)
380
+ p.add_argument("--seed", type=int, default=1234)
381
+ p.add_argument("--guidance-scale", type=float, default=7.0)
382
+ p.add_argument("--cover-strength", type=float, default=0.45)
383
+ p.add_argument("--no-thinking", action="store_true")
384
+ p.add_argument("--use-lm", action="store_true", help="Initialize/use ACE 5Hz LM. Off by default for source-conditioned lego because CoT can rewrite the stem prompt.")
385
+ p.add_argument("--use-cot", action="store_true", help="Allow LM CoT to rewrite/fill caption/language/metas. Off by default to preserve explicit stem prompts.")
386
+ p.add_argument("--out-dir", type=Path, default=None, help="ACE raw output directory. Defaults to item_dir/ace_<task>_corrected.")
387
+ p.add_argument("--generated-name", default=None, help="Name to copy the generated wav to in item_dir. Defaults to generated_stem_ace.wav or generated_full_ace.wav.")
388
+ p.add_argument("--full-ft-checkpoint", default=None,
389
+ help="Directory holding decoder_model.safetensors from a FULL fine-tune "
390
+ "(e.g. artifacts/train/full_finetune_v4_.../best). Replaces the whole DiT "
391
+ "decoder. Mutually exclusive with --lora-path: an adapter trained against "
392
+ "the ORIGINAL decoder stacked on replaced weights is silently wrong, so "
393
+ "passing both is refused rather than combined.")
394
+ p.add_argument("--lora-path", default=None, help="Optional PEFT LoRA adapter directory to load after base ACE init.")
395
+ p.add_argument("--adapter-name", default="stemgen", help="Adapter name used when loading --lora-path.")
396
+ p.add_argument("--lora-scale", type=float, default=1.0)
397
+ args = p.parse_args()
398
+
399
+ if args.full_ft_checkpoint and args.lora_path:
400
+ raise SystemExit("--full-ft-checkpoint and --lora-path are mutually exclusive")
401
+
402
+ session = init_ace(
403
+ ace_root=args.ace_root, checkpoints=args.checkpoints, model=args.model,
404
+ device=args.device, lm_model=args.lm_model, lm_backend=args.lm_backend,
405
+ no_thinking=args.no_thinking, use_lm=args.use_lm, use_cot=args.use_cot,
406
+ full_ft_checkpoint=args.full_ft_checkpoint, lora_path=args.lora_path,
407
+ adapter_name=args.adapter_name, lora_scale=args.lora_scale,
408
+ )
409
+ out = run_take(
410
+ session, args.item_dir, task=args.task, steps=args.steps, seed=args.seed,
411
+ guidance_scale=args.guidance_scale, cover_strength=args.cover_strength,
412
+ no_thinking=args.no_thinking, use_cot=args.use_cot,
413
+ out_dir=args.out_dir, generated_name=args.generated_name,
414
+ )
415
+ # Same shape the CLI has always printed. Callers parse this.
416
+ print(json.dumps({k: out[k] for k in ("generated", "copied_to", "task", "lora_path", "lora_scale")}, indent=2))
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()