Spaces:
Running on Zero
Running on Zero
| #!/usr/bin/env python3 | |
| """Run ACE-Step for one prepared pack item — the proven reference implementation. | |
| This file is the ONLY place GenerationParams is constructed. That is not a style | |
| preference, it is the lesson from 2026-07-16/17: apps/stack/infer_worker.py was a | |
| second, hand-typed copy of this call, it drifted in six places (global_caption='', | |
| audio_cover_strength=0.0, hardcoded bpm, wrong captions, timesignature='', steps=16), | |
| and it cost Francesco a night of "still crappy" output. Two hand-typed copies of the | |
| same call drift forever and you can never prove you have found the last divergence. | |
| One implementation is falsifiable; two are not. | |
| Three entry points, one code path: | |
| init_ace() load the model ONCE. Expensive (~20-40s: 4B decoder). | |
| build_params() metadata.json -> GenerationParams. THE single construction. | |
| run_take() one generation against an already-loaded session. ~7s warm. | |
| main() the CLI, unchanged. scripts/stemgen_webapp.py and apps/stems shell | |
| out to it and must keep working — it is the reference for | |
| docs/INFERENCE_RECIPE.md. | |
| The CLI does init_ace() + run_take() and exits, so it pays the load every time. A | |
| resident caller (apps/stack/infer_worker.py) does init_ace() once and then run_take() | |
| per request — the same functions, so it cannot drift from the CLI by construction. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import shutil | |
| import sys | |
| import time | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| # ========================================================================= | |
| # THE MAY 7 RECIPE — the defaults, living in the ONE implementation. | |
| # | |
| # Provenance: artifacts/generated_batches/batch_10_actual_lego_20260507/*/ | |
| # ace_lego_corrected/result.json — the last batch Francesco confirmed sounds good. | |
| # Copied, not invented. docs/INFERENCE_RECIPE.md is the writeup. | |
| # | |
| # WHY THESE LIVE HERE AND NOT IN A CALLER (found 2026-07-17, live): | |
| # apps/stack/infer_worker.py held these as its own constants. When serving switched | |
| # to shelling out to this script, the worker stopped being the path — and the recipe | |
| # went with it, because this script reads caption/global_caption from metadata.json | |
| # and the server's metadata.json has no `global_caption` key. So serving silently | |
| # reverted to global_caption="" — the single biggest prompt-side defect — with the | |
| # recipe still sitting "restored" in a file nothing called. | |
| # | |
| # A default in a caller is a default that gets lost. These are the defaults now, so | |
| # every caller (CLI, studio, resident worker) gets the proven recipe unless its | |
| # metadata deliberately overrides it. | |
| # ========================================================================= | |
| # The instruction that makes the model emit a STEM rather than a mix. | |
| RECIPE_GLOBAL_CAPTION = ( | |
| "Generate only the requested missing isolated stem so that it fits the provided " | |
| "audio context. Preserve timing, style, tempo, harmony, and arrangement. Do not " | |
| "generate a full mix." | |
| ) | |
| # Captions in ACE-Step's own register: short, natural descriptions of the target | |
| # stem — the way ACE was trained, not verbose instructions with negation lists. The | |
| # "isolated stem, no full mix" intent is carried by RECIPE_GLOBAL_CAPTION, so the | |
| # per-role caption just names the instrument and how it sits. (Was: long prose with | |
| # "Do not generate bass/guitars/vocals…", out of ACE's training distribution — | |
| # Francesco 2026-07-17. The proven long form is preserved in git if we A/B back.) | |
| RECIPE_CAPTIONS = { | |
| "drums": "a tight drum kit locked to the groove and tempo", | |
| "bass": "a groovy bass line locked to the drums and harmony", | |
| "melody": "a melodic lead line locked to the harmony", | |
| "vocals": "a lead vocal locked to the melody and phrasing", | |
| } | |
| RECIPE_TIMESIGNATURE = "4" | |
| def _meta_float(meta: dict, key: str): | |
| value = meta.get(key) | |
| if value in (None, "", "N/A"): | |
| return None | |
| try: | |
| return float(value) | |
| except Exception: | |
| return None | |
| def _meta_int(meta: dict, key: str): | |
| value = _meta_float(meta, key) | |
| return int(round(value)) if value else None | |
| class AceSession: | |
| """A loaded ACE model. Hold one of these and run_take() is ~7s instead of ~45s.""" | |
| dit_handler: Any | |
| llm_handler: Any | |
| device: str | |
| lora_path: str | None = None | |
| lora_scale: float = 1.0 | |
| full_ft_checkpoint: str | None = None | |
| load_seconds: float = 0.0 | |
| def init_ace( | |
| *, | |
| ace_root: str = "/home/fcolo/ace-step-1.5-xl", | |
| checkpoints: str = "/home/fcolo/ace-step/checkpoints", | |
| model: str = "acestep-v15-xl-base", | |
| device: str = "cuda", | |
| lm_model: str = "acestep-5Hz-lm-1.7B", | |
| lm_backend: str = "pt", | |
| no_thinking: bool = True, | |
| use_lm: bool = False, | |
| use_cot: bool = False, | |
| full_ft_checkpoint: str | None = None, | |
| lora_path: str | None = None, | |
| adapter_name: str = "stemgen", | |
| lora_scale: float = 1.0, | |
| log=print, | |
| ) -> AceSession: | |
| """Everything expensive, once. Safe to call from a long-lived process.""" | |
| if full_ft_checkpoint and lora_path: | |
| raise ValueError("--full-ft-checkpoint and --lora-path are mutually exclusive") | |
| t0 = time.time() | |
| # Hard CPU mode: ACE/PEFT sometimes tries to stage LoRA weights on CUDA even when | |
| # the requested generation device is CPU. Hide CUDA before importing ACE/torch so | |
| # CPU jobs don't fight long-running GPU work. | |
| if str(device).lower().split(":", 1)[0] == "cpu": | |
| os.environ["CUDA_VISIBLE_DEVICES"] = "" | |
| os.environ.setdefault("ACESTEP_VAE_ON_CPU", "1") | |
| os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") | |
| log("[INFO] CPU mode: CUDA_VISIBLE_DEVICES cleared for this process") | |
| ace_root_p = Path(ace_root).resolve() | |
| if str(ace_root_p) not in sys.path: | |
| sys.path.insert(0, str(ace_root_p)) | |
| os.environ["ACESTEP_CHECKPOINTS_DIR"] = checkpoints | |
| from acestep.handler import AceStepHandler | |
| from acestep.llm_inference import LLMHandler | |
| dit_handler = AceStepHandler() | |
| status, success = dit_handler.initialize_service( | |
| project_root=str(ace_root_p), | |
| config_path=model, | |
| device=device, | |
| prefer_source="huggingface", | |
| ) | |
| if not success: | |
| raise RuntimeError(f"ACE init failed: {status}") | |
| log(status) | |
| if full_ft_checkpoint: | |
| # Full fine-tune: swap the whole DiT decoder. Traced 2026-07-17 -- | |
| # initialize_service() -> init_service_loader.py:175 sets | |
| # self.model = AutoModel.from_pretrained(...), the SAME construction as | |
| # training_v2/model_loader.py:load_decoder_for_training(); and | |
| # AceStepConditionGenerationModel.__init__ (modeling_acestep_v15_base.py:1609) | |
| # sets self.decoder = AceStepDiTModel(config). So handler.model.decoder is | |
| # exactly the submodule train_full.py checkpoints. | |
| from safetensors.torch import load_file | |
| ckpt = Path(full_ft_checkpoint) / "decoder_model.safetensors" | |
| if not ckpt.is_file(): | |
| raise RuntimeError(f"no decoder_model.safetensors under {full_ft_checkpoint}") | |
| target = getattr(getattr(dit_handler, "model", None), "decoder", None) | |
| if target is None: | |
| raise RuntimeError("handler.model.decoder not found after initialize_service — " | |
| "ACE internals changed; re-trace before trusting this path.") | |
| # Fingerprint before/after: a load that silently no-ops (wrong keys, empty | |
| # dict) is otherwise indistinguishable from success. | |
| probe = next(k for k, _ in target.named_parameters()) | |
| before = float(dict(target.named_parameters())[probe].detach().float().sum().item()) | |
| sd = load_file(str(ckpt)) | |
| target.load_state_dict(sd, strict=True) # strict: any mismatch raises | |
| after = float(dict(target.named_parameters())[probe].detach().float().sum().item()) | |
| if before == after: | |
| raise RuntimeError(f"decoder weights UNCHANGED after load_state_dict (probe {probe} " | |
| f"sum {before}); refusing to run a checkpoint that did not apply.") | |
| target.eval() | |
| log(json.dumps({"full_ft_loaded": str(ckpt), "probe": probe, | |
| "sum_before": before, "sum_after": after, "tensors": len(sd)})) | |
| if lora_path: | |
| lora_status = dit_handler.add_lora(lora_path, adapter_name=adapter_name) | |
| log(lora_status) | |
| if not str(lora_status).startswith("✅"): | |
| raise RuntimeError(f"ACE LoRA load failed: {lora_status}") | |
| log(dit_handler.set_lora_scale(adapter_name, lora_scale)) | |
| log(dit_handler.set_use_lora(True)) | |
| llm_handler = None | |
| if use_lm or use_cot or not no_thinking: | |
| llm_handler = LLMHandler() | |
| lm_status, lm_success = llm_handler.initialize( | |
| checkpoint_dir=checkpoints, | |
| lm_model_path=lm_model, | |
| backend=lm_backend, | |
| device=device, | |
| offload_to_cpu=False, | |
| ) | |
| if not lm_success: | |
| raise RuntimeError(f"ACE LM init failed: {lm_status}") | |
| log(lm_status) | |
| return AceSession( | |
| dit_handler=dit_handler, | |
| llm_handler=llm_handler, | |
| device=device, | |
| lora_path=lora_path, | |
| lora_scale=lora_scale, | |
| full_ft_checkpoint=full_ft_checkpoint, | |
| load_seconds=round(time.time() - t0, 2), | |
| ) | |
| def build_params( | |
| meta: dict, | |
| item_dir: Path, | |
| *, | |
| task: str = "lego", | |
| steps: int = 64, | |
| seed: int = 1234, | |
| guidance_scale: float = 7.0, | |
| cover_strength: float = 0.45, | |
| no_thinking: bool = True, | |
| use_cot: bool = False, | |
| retake_seed: "int | None" = None, | |
| retake_variance: float = 0.0, | |
| ): | |
| """metadata.json -> (GenerationParams, GenerationConfig). | |
| ⚠️ THE SINGLE CONSTRUCTION. Every caller — CLI, resident worker, studio — comes | |
| through here. Do not copy this into another file; import it. See the module | |
| docstring for what a second copy cost. | |
| Values default to docs/INFERENCE_RECIPE.md (the May 7 recipe); metadata.json | |
| overrides where it carries a value. Note what the meta deliberately may omit: | |
| bpm absent means lego locks tempo from src_audio itself, which is correct — a | |
| GUESSED bpm is worse than none (the app sent a hardcoded 98 against audio at | |
| 119/170 and the model dutifully played out of tempo). | |
| """ | |
| from acestep.inference import GenerationParams, GenerationConfig | |
| role = meta.get("role", "") | |
| ace_role = "guitar" if role == "melody" else role | |
| # Recipe defaults, overridable by metadata. `or` not `.get(k, default)`: an empty | |
| # string in the metadata means "absent", not "deliberately empty" — and empty is | |
| # exactly the failure this guards. | |
| caption = (meta.get("ace_caption") or meta.get("prompt") or RECIPE_CAPTIONS.get(role) | |
| or f"Add {role} for this song.") | |
| global_caption = meta.get("global_caption") or RECIPE_GLOBAL_CAPTION | |
| source = item_dir / meta.get("source_audio", "context_mix_minus_target.wav") | |
| if task == "lego": | |
| instruction = f"Generate the {ace_role.upper()} track based on the audio context:" | |
| elif task == "complete": | |
| classes = meta.get("complete_track_classes") or [ace_role] | |
| instruction = "Complete the input track with " + " | ".join(str(c).upper() for c in classes) + ":" | |
| else: | |
| instruction = "Generate audio semantic tokens based on the given conditions:" | |
| params = GenerationParams( | |
| task_type=task, | |
| src_audio=str(source), | |
| instruction=instruction, | |
| caption=caption, | |
| global_caption=global_caption, | |
| lyrics=meta.get("lyrics", "[Instrumental]"), | |
| instrumental=bool(meta.get("instrumental", True)), | |
| vocal_language=meta.get("vocal_language", "unknown"), | |
| bpm=_meta_int(meta, "bpm"), | |
| keyscale=meta.get("keyscale") or meta.get("key") or "", | |
| timesignature=str(meta.get("timesignature") or meta.get("time_signature") or RECIPE_TIMESIGNATURE), | |
| duration=_meta_float(meta, "duration_seconds") or -1.0, | |
| repainting_start=0.0, | |
| repainting_end=-1, | |
| # Retake: variance-preserving variation. With a FIXED base `seed` per part and a | |
| # small `retake_variance`, each new sample is the SAME part subtly evolved — not | |
| # an unrelated diffusion draw. This is how a part 'continues from what it was'. | |
| retake_seed=retake_seed, | |
| retake_variance=float(retake_variance or 0.0), | |
| inference_steps=steps, | |
| seed=seed, | |
| thinking=not no_thinking, | |
| guidance_scale=guidance_scale, | |
| audio_cover_strength=cover_strength, | |
| use_cot_metas=use_cot, | |
| use_cot_caption=False, # production engine.py uses metas + language, NOT caption | |
| use_cot_language=use_cot, | |
| shift=3.0, # match production engine.py (was unset -> default) | |
| dcw_enabled=False, # #1255 NOISE FIX (production default) — the un-denoised culprit | |
| cover_noise_strength=0.0, | |
| use_adg=False, | |
| use_constrained_decoding=True, | |
| enable_normalization=True, | |
| normalization_db=-1.0, | |
| cfg_interval_start=0.0, | |
| cfg_interval_end=1.0, | |
| ) | |
| config = GenerationConfig(batch_size=1, use_random_seed=False, seeds=[seed], audio_format="wav") | |
| return params, config | |
| def run_take( | |
| session: AceSession, | |
| item_dir: Path, | |
| *, | |
| task: str = "lego", | |
| steps: int = 64, | |
| seed: int = 1234, | |
| guidance_scale: float = 7.0, | |
| cover_strength: float = 0.45, | |
| no_thinking: bool = True, | |
| use_cot: bool = False, | |
| out_dir: Path | None = None, | |
| generated_name: str | None = None, | |
| retake_seed: "int | None" = None, | |
| retake_variance: float = 0.0, | |
| log=print, | |
| ) -> dict: | |
| """One generation against an already-loaded session. Warm: ~7s. | |
| Identical to what the CLI does per item — because the CLI calls this. | |
| """ | |
| from acestep.inference import generate_music | |
| item_dir = Path(item_dir).resolve() | |
| meta = json.loads((item_dir / "metadata.json").read_text()) | |
| out_dir = Path(out_dir).resolve() if out_dir else item_dir / f"ace_{task}_corrected" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| params, config = build_params( | |
| meta, item_dir, task=task, steps=steps, seed=seed, | |
| guidance_scale=guidance_scale, cover_strength=cover_strength, | |
| no_thinking=no_thinking, use_cot=use_cot, | |
| retake_seed=retake_seed, retake_variance=retake_variance, | |
| ) | |
| t0 = time.time() | |
| result = generate_music(session.dit_handler, session.llm_handler, params, config, save_dir=str(out_dir)) | |
| (out_dir / "result.json").write_text( | |
| json.dumps(result.to_dict() if hasattr(result, "to_dict") else result.__dict__, indent=2, default=str) + "\n" | |
| ) | |
| if not result.success: | |
| raise RuntimeError(result.error or result.status_message) | |
| generated = result.audios[0]["path"] | |
| target_name = generated_name or ("generated_full_ace.wav" if task == "cover" else "generated_stem_ace.wav") | |
| copied_to = item_dir / target_name | |
| shutil.copy2(generated, copied_to) | |
| return { | |
| "generated": generated, | |
| "copied_to": str(copied_to), | |
| "task": task, | |
| "lora_path": session.lora_path, | |
| "lora_scale": session.lora_scale, | |
| "seed": seed, | |
| "steps": steps, | |
| "guidance_scale": guidance_scale, | |
| "cover_strength": cover_strength, | |
| "generate_seconds": round(time.time() - t0, 2), | |
| } | |
| def main() -> None: | |
| p = argparse.ArgumentParser(description="Run ACE-Step corrected baseline task for one prepared pack item.") | |
| p.add_argument("item_dir", type=Path) | |
| p.add_argument("--task", choices=["lego", "complete", "cover"], default="lego") | |
| p.add_argument("--ace-root", default="/home/fcolo/ace-step-1.5-xl") | |
| p.add_argument("--checkpoints", default="/home/fcolo/ace-step/checkpoints") | |
| p.add_argument("--model", default="acestep-v15-xl-base") | |
| p.add_argument("--lm-model", default="acestep-5Hz-lm-1.7B") | |
| p.add_argument("--lm-backend", default="pt", choices=["pt", "vllm"]) | |
| p.add_argument("--device", default="cuda") | |
| p.add_argument("--steps", type=int, default=64) | |
| p.add_argument("--seed", type=int, default=1234) | |
| p.add_argument("--guidance-scale", type=float, default=7.0) | |
| p.add_argument("--cover-strength", type=float, default=0.45) | |
| p.add_argument("--no-thinking", action="store_true") | |
| 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.") | |
| 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.") | |
| p.add_argument("--out-dir", type=Path, default=None, help="ACE raw output directory. Defaults to item_dir/ace_<task>_corrected.") | |
| 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.") | |
| p.add_argument("--full-ft-checkpoint", default=None, | |
| help="Directory holding decoder_model.safetensors from a FULL fine-tune " | |
| "(e.g. artifacts/train/full_finetune_v4_.../best). Replaces the whole DiT " | |
| "decoder. Mutually exclusive with --lora-path: an adapter trained against " | |
| "the ORIGINAL decoder stacked on replaced weights is silently wrong, so " | |
| "passing both is refused rather than combined.") | |
| p.add_argument("--lora-path", default=None, help="Optional PEFT LoRA adapter directory to load after base ACE init.") | |
| p.add_argument("--adapter-name", default="stemgen", help="Adapter name used when loading --lora-path.") | |
| p.add_argument("--lora-scale", type=float, default=1.0) | |
| args = p.parse_args() | |
| if args.full_ft_checkpoint and args.lora_path: | |
| raise SystemExit("--full-ft-checkpoint and --lora-path are mutually exclusive") | |
| session = init_ace( | |
| ace_root=args.ace_root, checkpoints=args.checkpoints, model=args.model, | |
| device=args.device, lm_model=args.lm_model, lm_backend=args.lm_backend, | |
| no_thinking=args.no_thinking, use_lm=args.use_lm, use_cot=args.use_cot, | |
| full_ft_checkpoint=args.full_ft_checkpoint, lora_path=args.lora_path, | |
| adapter_name=args.adapter_name, lora_scale=args.lora_scale, | |
| ) | |
| out = run_take( | |
| session, args.item_dir, task=args.task, steps=args.steps, seed=args.seed, | |
| guidance_scale=args.guidance_scale, cover_strength=args.cover_strength, | |
| no_thinking=args.no_thinking, use_cot=args.use_cot, | |
| out_dir=args.out_dir, generated_name=args.generated_name, | |
| ) | |
| # Same shape the CLI has always printed. Callers parse this. | |
| print(json.dumps({k: out[k] for k in ("generated", "copied_to", "task", "lora_path", "lora_scale")}, indent=2)) | |
| if __name__ == "__main__": | |
| main() | |