From 7ba7d6066f11b8a01cfdffec7c4bd2c285b7ae58 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 08:45:48 +0000 Subject: [PATCH 1/5] feat: add ACE-Step 1.5 music engine (#4232) --- .changelog/next/added-issue-4232.md | 1 + scripts/generate_acestep15.py | 129 ++++++++++++++++++++++ scripts/setup-image-video.sh | 43 +++++++- server/lib/pythonSetup.js | 30 +++++ server/routes/music.test.js | 12 ++ server/services/pipeline/musicGen.js | 34 +++++- server/services/pipeline/musicGen.test.js | 23 +++- 7 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 .changelog/next/added-issue-4232.md create mode 100644 scripts/generate_acestep15.py diff --git a/.changelog/next/added-issue-4232.md b/.changelog/next/added-issue-4232.md new file mode 100644 index 0000000000..6a8a26a048 --- /dev/null +++ b/.changelog/next/added-issue-4232.md @@ -0,0 +1 @@ +- Music Studio can install and generate full songs with ACE-Step 1.5 while keeping existing ACE-Step renders on v1. diff --git a/scripts/generate_acestep15.py b/scripts/generate_acestep15.py new file mode 100644 index 0000000000..3e17ddef1a --- /dev/null +++ b/scripts/generate_acestep15.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""ACE-Step 1.5 music sidecar using PortOS' STAGE/RESULT protocol. + +ACE-Step 1.5 is not compatible with the v1 ``ACEStepPipeline`` package. Its +fixed Hugging Face snapshot contains a DiT with custom Transformers code plus +the VAE, text encoder, and 5 Hz language model. The existing Music install flow +downloads that whole snapshot to the HF cache; this runner opens the cached +snapshot only, so generation never triggers an unannounced model download. +""" + +import argparse +import json +import os +import shutil +import sys +import wave + + +MODEL_ID = "ACE-Step/Ace-Step1.5" +MODEL_VARIANT = "acestep-v15-turbo" + + +def stage(name, detail=""): + print(f"STAGE:{name}" + (f":{detail}" if detail else ""), file=sys.stderr, flush=True) + + +def wav_duration_seconds(path): + with wave.open(path, "rb") as audio: + return audio.getnframes() / float(audio.getframerate() or 1) + + +def cached_checkpoint_dir(repo): + from huggingface_hub import snapshot_download + + # Model installation is explicit in the Music UI. local_files_only keeps a + # direct Generate click from silently downloading this multi-GB snapshot. + return snapshot_download(repo_id=repo, local_files_only=True) + + +def main(): + parser = argparse.ArgumentParser(description="PortOS ACE-Step 1.5 runner") + parser.add_argument("--model", default=MODEL_ID) + parser.add_argument("--text", required=True) + parser.add_argument("--lyrics", default="") + parser.add_argument("--output", required=True) + parser.add_argument("--duration", type=float, default=60.0) + parser.add_argument("--runtime-dir", default="") + args = parser.parse_args() + + text = args.text.strip() + if not text: + print("ERROR: --text is required", file=sys.stderr, flush=True) + return 2 + if args.model != MODEL_ID: + print(f"ERROR: ACE-Step 1.5 uses the fixed model {MODEL_ID}", file=sys.stderr, flush=True) + return 2 + + stage("resolve-model", args.model) + try: + checkpoint_dir = cached_checkpoint_dir(args.model) + except Exception as exc: + print(f"ERROR: ACE-Step 1.5 model weights are not installed: {exc}", file=sys.stderr, flush=True) + return 1 + + # AceStepHandler performs AutoModel.from_pretrained(..., + # trust_remote_code=True) against /acestep-v15-turbo. Point + # its checkpoint resolver at the installed HF snapshot rather than copying + # its multi-component tree into the virtualenv or a temporary directory. + os.environ["ACESTEP_CHECKPOINTS_DIR"] = checkpoint_dir + from acestep.handler import AceStepHandler + from acestep.inference import GenerationConfig, GenerationParams, generate_music + + duration = max(1.0, min(float(args.duration or 60.0), 240.0)) + stage("load-model", MODEL_VARIANT) + dit_handler = AceStepHandler() + status, initialized = dit_handler.initialize_service( + project_root="", + config_path=MODEL_VARIANT, + device="auto", + offload_to_cpu=False, + ) + if not initialized: + print(f"ERROR: ACE-Step 1.5 could not initialize: {status}", file=sys.stderr, flush=True) + return 1 + + output_dir = os.path.dirname(os.path.abspath(args.output)) + os.makedirs(output_dir, exist_ok=True) + lyrics = args.lyrics.strip() or "[Instrumental]" + params = GenerationParams( + task_type="text2music", + caption=text, + lyrics=lyrics, + duration=duration, + inference_steps=8, + guidance_scale=1.0, + thinking=False, + seed=-1, + ) + stage("generate", f"{duration:.1f}s") + result = generate_music( + dit_handler=dit_handler, + llm_handler=None, + params=params, + config=GenerationConfig(batch_size=1, audio_format="wav"), + save_dir=output_dir, + ) + if not result.success or not result.audios: + reason = getattr(result, "error", None) or getattr(result, "status_message", "unknown error") + print(f"ERROR: ACE-Step 1.5 generation failed: {reason}", file=sys.stderr, flush=True) + return 1 + + produced = result.audios[0].get("path") + if not produced or not os.path.isfile(produced): + print("ERROR: ACE-Step 1.5 returned no audio file", file=sys.stderr, flush=True) + return 1 + if os.path.abspath(produced) != os.path.abspath(args.output): + shutil.move(produced, args.output) + stage("encode-wav") + print("RESULT:" + json.dumps({ + "output": args.output, + "model": args.model, + "durationSec": round(wav_duration_seconds(args.output), 3), + }), flush=True) + stage("done") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/setup-image-video.sh b/scripts/setup-image-video.sh index f5976e7ec0..f902228b0e 100755 --- a/scripts/setup-image-video.sh +++ b/scripts/setup-image-video.sh @@ -18,6 +18,7 @@ # MLX_EXAMPLES_PIN commit SHA of ml-explore/mlx-examples to check out for MusicGen (default: main). # INSTALL_AUDIOLDM2 '1' to bootstrap a venv at ~/.portos/venv-audioldm2 (torch + diffusers) for local AudioLDM2 long-form background-music generation (pipeline audio stage, second backend alongside MusicGen). Default: 0; opt in with INSTALL_AUDIOLDM2=1 (runs on MPS / CUDA / CPU). # INSTALL_ACESTEP '1' to bootstrap a venv at ~/.portos/venv-acestep (torch + the acestep package) for local ACE-Step full-song generation with vocals (Music studio, third backend). Default: 0; opt in with INSTALL_ACESTEP=1 (runs on MPS / CUDA / CPU; checkpoints auto-download to ~/.cache/ace-step on first run). +# INSTALL_ACESTEP15 '1' to bootstrap a venv at ~/.portos/venv-acestep15 (the ACE-Step 1.5 package + torch) for local ACE-Step 1.5 full-song generation (Music studio). Default: 0; opt in with INSTALL_ACESTEP15=1 (runs on MPS / CUDA / CPU; model weights are installed separately from Music). # INSTALL_MUSCRIPTOR '1' to bootstrap a venv at ~/.portos/venv-muscriptor (the muscriptor pip package + its torch stack) for local audio → MIDI transcription (Rounds reference audio + Music Video parsing). Default: 0; opt in with INSTALL_MUSCRIPTOR=1 (runs on MPS / CUDA / CPU; model weights auto-download from HuggingFace on first transcription). set -euo pipefail @@ -74,7 +75,7 @@ mkdir -p "${PORTOS_DATA}/video-thumbnails" # When the user only wants a specific BYOV runtime (set via INSTALL_LTX2 / # INSTALL_WAN22 / INSTALL_HUNYUAN / INSTALL_MINIMAX_H3 / INSTALL_MINIMAX_H3_CUDA — or one of the self-contained MUSIC venvs -# INSTALL_MUSICGEN / INSTALL_AUDIOLDM2 / INSTALL_ACESTEP — typically from the +# INSTALL_MUSICGEN / INSTALL_AUDIOLDM2 / INSTALL_ACESTEP / INSTALL_ACESTEP15 — typically from the # in-app installer), skip the mflux + legacy mlx_video preamble. Those # bring-your-own-venv runtimes are self-contained and don't depend on mflux; # running the preamble unprompted hits PEP 668 ("externally-managed-environment") @@ -82,7 +83,7 @@ mkdir -p "${PORTOS_DATA}/video-thumbnails" # install ever starts — which on Linux/CPU/CUDA blocks the advertised # `INSTALL_ACESTEP=1 bash …` path. A bare `bash setup-image-video.sh` still # installs mflux as before. -ANY_BYOV="${INSTALL_LTX2:-0}${INSTALL_LTX25:-0}${INSTALL_WAN22:-0}${INSTALL_HUNYUAN:-0}${INSTALL_MINIMAX_H3:-0}${INSTALL_MINIMAX_H3_CUDA:-0}${INSTALL_MUSICGEN:-0}${INSTALL_AUDIOLDM2:-0}${INSTALL_ACESTEP:-0}${INSTALL_MINIMAX_MUSIC3:-0}${INSTALL_MUSCRIPTOR:-0}" +ANY_BYOV="${INSTALL_LTX2:-0}${INSTALL_LTX25:-0}${INSTALL_WAN22:-0}${INSTALL_HUNYUAN:-0}${INSTALL_MINIMAX_H3:-0}${INSTALL_MINIMAX_H3_CUDA:-0}${INSTALL_MUSICGEN:-0}${INSTALL_AUDIOLDM2:-0}${INSTALL_ACESTEP:-0}${INSTALL_ACESTEP15:-0}${INSTALL_MINIMAX_MUSIC3:-0}${INSTALL_MUSCRIPTOR:-0}" # "no BYOV runtime was requested" = the concatenation contains no non-zero # character. Matching a literal string of zeros instead made this a counting # exercise that the string and the variable list had to agree on — and they had @@ -716,6 +717,41 @@ if [[ "$INSTALL_ACESTEP" == "1" ]]; then echo "✅ ACE-Step venv ready: $ACESTEP_PY" fi +INSTALL_ACESTEP15="${INSTALL_ACESTEP15:-0}" +if [[ "$INSTALL_ACESTEP15" == "1" ]]; then + # ACE-Step 1.5 is a separate architecture from v1. The package supplies the + # multi-component pipeline whose DiT loads custom Transformers code with + # trust_remote_code, so it must never share v1's `acestep` venv. + ACESTEP15_VENV="${HOME}/.portos/venv-acestep15" + ACESTEP15_PY="$ACESTEP15_VENV/bin/python3" + mkdir -p "${HOME}/.portos" + + if [[ ! -x "$ACESTEP15_PY" && ! -x "$ACESTEP15_VENV/Scripts/python.exe" ]]; then + echo "📦 Creating ACE-Step 1.5 venv at ${ACESTEP15_VENV}..." + "$PYTHON_BIN" -m venv "$ACESTEP15_VENV" + fi + if [[ ! -x "$ACESTEP15_PY" ]]; then + ACESTEP15_PY="$ACESTEP15_VENV/Scripts/python.exe" + fi + echo "📦 Installing ACE-Step 1.5 into ${ACESTEP15_VENV}..." + "$ACESTEP15_PY" -m pip install --upgrade pip wheel setuptools >/dev/null + # ACE-Step 1.5's Linux/Windows torch pins are published on PyTorch's CUDA + # index. Passing it as an extra index remains harmless on macOS, where the + # package selects its native MPS-compatible torch dependency. + # Pin the runtime to a vendor release. The model snapshot is installed + # separately through Music, but its custom-code loader depends on this + # package's handler contract. + "$ACESTEP15_PY" -m pip install --upgrade --extra-index-url https://download.pytorch.org/whl/cu128 \ + "git+https://github.com/ace-step/ACE-Step-1.5.git@v0.1.8" \ + "huggingface_hub[hf_xet]" + if ! "$ACESTEP15_PY" -c "import torch; from transformers import AutoModel; from acestep.handler import AceStepHandler" 2>/dev/null; then + echo "❌ ACE-Step 1.5 venv built but its Transformers runtime failed to import." >&2 + echo " Check that torch, transformers, and ACE-Step 1.5 installed cleanly in ${ACESTEP15_VENV}." >&2 + exit 1 + fi + echo "✅ ACE-Step 1.5 venv ready: $ACESTEP15_PY" +fi + INSTALL_MINIMAX_MUSIC3="${INSTALL_MINIMAX_MUSIC3:-0}" if [[ "$INSTALL_MINIMAX_MUSIC3" == "1" ]]; then MINIMAX_MUSIC3_VENV="${HOME}/.portos/venv-minimax-music3" @@ -900,6 +936,9 @@ fi if [[ "$INSTALL_ACESTEP" == "1" ]]; then echo " ACE-Step: ${ACESTEP_PY} (separate venv, acestep — full song + vocals)" fi +if [[ "$INSTALL_ACESTEP15" == "1" ]]; then + echo " ACE-Step 1.5: ${HOME}/.portos/venv-acestep15/bin/python3 (separate venv, Transformers — full song + vocals)" +fi if [[ "$INSTALL_MUSCRIPTOR" == "1" ]]; then echo " MuScriptor: ${MUSCRIPTOR_PY} (separate venv, muscriptor — audio → MIDI)" fi diff --git a/server/lib/pythonSetup.js b/server/lib/pythonSetup.js index afef58e035..28156da3c7 100644 --- a/server/lib/pythonSetup.js +++ b/server/lib/pythonSetup.js @@ -427,6 +427,36 @@ export function invalidateAcestepPython() { cachedAcestepPython = null; } +// ACE-Step 1.5 has a different runtime from v1: its installed package supplies +// the multi-component Transformers pipeline that loads the fixed HF snapshot +// with trust_remote_code. Keep it in a sibling venv so v1 renders remain +// reproducible even when the two packages need different torch stacks. +const ACESTEP15_VENV_CANDIDATES = IS_WIN + ? [ + join(HOME, '.portos', 'venv-acestep15', 'Scripts', 'python.exe'), + join(PATHS.data, 'python', 'venv-acestep15', 'Scripts', 'python.exe'), + ] + : [ + join(HOME, '.portos', 'venv-acestep15', 'bin', 'python3'), + join(PATHS.data, 'python', 'venv-acestep15', 'bin', 'python3'), + ]; + +export const ACESTEP15_VENV_DEFAULT = ACESTEP15_VENV_CANDIDATES[0]; +export const ACESTEP15_RUNTIME_DIR = ''; + +let cachedAcestep15Python = null; +export function resolveAcestep15Python() { + if (cachedAcestep15Python && existsSync(cachedAcestep15Python)) return cachedAcestep15Python; + for (const p of ACESTEP15_VENV_CANDIDATES) { + if (existsSync(p)) { cachedAcestep15Python = p; return p; } + } + return null; +} + +export function invalidateAcestep15Python() { + cachedAcestep15Python = null; +} + const MINIMAX_MUSIC3_VENV_CANDIDATES = IS_WIN ? [ join(HOME, '.portos', 'venv-minimax-music3', 'Scripts', 'python.exe'), diff --git a/server/routes/music.test.js b/server/routes/music.test.js index c3d2781050..f22a060d13 100644 --- a/server/routes/music.test.js +++ b/server/routes/music.test.js @@ -22,6 +22,7 @@ vi.mock('../services/pipeline/musicGen.js', () => { const ENGINES = { musicgen: { id: 'musicgen', name: 'MusicGen', models: [{ id: 'm', name: 'M' }], defaultModelId: 'm', minDurationSec: 1, maxDurationSec: 30, defaultDurationSec: 12, installEnv: 'INSTALL_MUSICGEN', venvDefault: '/v/mg', resolvePython: () => (gen.ready ? '/v/mg/bin/python3' : null), customModels: true }, acestep: { id: 'acestep', name: 'ACE-Step', models: [{ id: 'a', name: 'A' }], defaultModelId: 'a', minDurationSec: 1, maxDurationSec: 240, defaultDurationSec: 60, installEnv: 'INSTALL_ACESTEP', venvDefault: '/v/ace', resolvePython: () => (gen.ready ? '/v/ace/bin/python3' : null), lyrics: true, customModels: false }, + acestep15: { id: 'acestep15', name: 'ACE-Step 1.5', models: [{ id: 'ace-step-v1.5', repo: 'ACE-Step/Ace-Step1.5', name: 'ACE-Step 1.5' }], defaultModelId: 'ace-step-v1.5', minDurationSec: 1, maxDurationSec: 240, defaultDurationSec: 60, installEnv: 'INSTALL_ACESTEP15', venvDefault: '/v/ace15', resolvePython: () => (gen.ready ? '/v/ace15/bin/python3' : null), lyrics: true, customModels: false, fixedModelInstall: true }, 'minimax-music3': { id: 'minimax-music3', name: 'MiniMax Music 3', models: [{ id: 'minimax-music3', repo: 'MiniMaxAI/MiniMax-Music3', name: 'MiniMax Music 3' }], defaultModelId: 'minimax-music3', minDurationSec: 1, maxDurationSec: 300, defaultDurationSec: 60, installEnv: 'INSTALL_MINIMAX_MUSIC3', venvDefault: '/v/minimax', resolvePython: () => (gen.ready ? '/v/minimax/bin/python3' : null), lyrics: true, customModels: false, fixedModelInstall: true, cudaRequired: true }, }; return { @@ -189,6 +190,17 @@ describe('music routes', () => { }); }); + it('GET /engines exposes ACE-Step 1.5 as a fixed model install distinct from v1', async () => { + cache.cached = false; + const r = await request(app).get('/api/music/engines'); + expect(r.status).toBe(200); + expect(r.body.engines.find((e) => e.id === 'acestep15')).toMatchObject({ + lyrics: true, customModels: false, fixedModelInstall: true, + modelReady: false, runtimeReady: true, ready: false, + installEnv: 'INSTALL_ACESTEP15', + }); + }); + it('POST /models rejects an engine that does not support custom models (acestep)', async () => { const r = await request(app).post('/api/music/models').send({ engine: 'acestep', repo: 'someorg/ace-variant' }); expect(r.status).toBe(400); diff --git a/server/services/pipeline/musicGen.js b/server/services/pipeline/musicGen.js index f32a948b04..1b785db920 100644 --- a/server/services/pipeline/musicGen.js +++ b/server/services/pipeline/musicGen.js @@ -42,6 +42,7 @@ import { resolveMusicgenPython, MUSICGEN_RUNTIME_DIR, MUSICGEN_VENV_DEFAULT, resolveAudioldm2Python, AUDIOLDM2_RUNTIME_DIR, AUDIOLDM2_VENV_DEFAULT, resolveAcestepPython, ACESTEP_RUNTIME_DIR, ACESTEP_VENV_DEFAULT, + resolveAcestep15Python, ACESTEP15_RUNTIME_DIR, ACESTEP15_VENV_DEFAULT, resolveMinimaxMusic3Python, MINIMAX_MUSIC3_RUNTIME_DIR, MINIMAX_MUSIC3_VENV_DEFAULT, } from '../../lib/pythonSetup.js'; import { getCudaCapability } from '../../lib/cudaCapability.js'; @@ -57,6 +58,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const MUSICGEN_SCRIPT = join(__dirname, '../../../scripts/generate_musicgen.py'); const AUDIOLDM2_SCRIPT = join(__dirname, '../../../scripts/generate_audioldm2.py'); const ACESTEP_SCRIPT = join(__dirname, '../../../scripts/generate_acestep.py'); +const ACESTEP15_SCRIPT = join(__dirname, '../../../scripts/generate_acestep15.py'); const MINIMAX_MUSIC3_SCRIPT = join(__dirname, '../../../scripts/generate_minimax_music3.py'); // Back-compat alias for the pre-multi-engine `buildMusicGenArgs` default. const SIDECAR_SCRIPT = MUSICGEN_SCRIPT; @@ -97,6 +99,13 @@ export const ACESTEP_MODELS = Object.freeze([ { id: 'ace-step-v1-3.5b', repo: 'ACE-Step/ACE-Step-v1-3.5B', name: 'ACE-Step v1 3.5B (full song + vocals)' }, ]); export const DEFAULT_ACESTEP_MODEL_ID = 'ace-step-v1-3.5b'; +// ACE-Step 1.5 stores its DiT, VAE, text encoder, and LM in one HF repository. +// It is deliberately a separate engine: persisted v1 render metadata must +// continue to select v1's pip-based runtime instead of silently changing. +export const ACESTEP15_MODELS = Object.freeze([ + { id: 'ace-step-v1.5', repo: 'ACE-Step/Ace-Step1.5', name: 'ACE-Step 1.5 (full song + vocals)' }, +]); +export const DEFAULT_ACESTEP15_MODEL_ID = 'ace-step-v1.5'; export const MINIMAX_MUSIC3_MODELS = Object.freeze([ { id: 'minimax-music3', repo: 'MiniMaxAI/MiniMax-Music3', name: 'MiniMax Music 3 (CUDA, up to 5 minutes)' }, ]); @@ -189,6 +198,28 @@ export const ENGINES = Object.freeze({ // --model by design. customModels: false, }, + acestep15: { + id: 'acestep15', + name: 'ACE-Step 1.5 (full song + vocals)', + models: ACESTEP15_MODELS, + defaultModelId: DEFAULT_ACESTEP15_MODEL_ID, + minDurationSec: 1, + // The vendor supports much longer compositions, but retain the established + // studio window until a user-facing duration expansion is separately tested. + maxDurationSec: 240, + defaultDurationSec: 60, + scriptPath: ACESTEP15_SCRIPT, + runtimeDir: ACESTEP15_RUNTIME_DIR, + resolvePython: resolveAcestep15Python, + venvDefault: ACESTEP15_VENV_DEFAULT, + installEnv: 'INSTALL_ACESTEP15', + // ACE-Step 1.5's handler imports the fixed snapshot's custom + // modeling_acestep_v15_turbo.py via transformers AutoModel trust_remote_code. + healthProbe: 'import torch; from transformers import AutoModel; from acestep.handler import AceStepHandler', + lyrics: true, + customModels: false, + fixedModelInstall: true, + }, 'minimax-music3': { id: 'minimax-music3', name: 'MiniMax Music 3 (CUDA only)', @@ -377,7 +408,8 @@ export function buildMusicGenArgs({ pythonPath, scriptPath = SIDECAR_SCRIPT, run * ServerError (503) when the selected backend's venv isn't provisioned, or * (500) when the sidecar exits non-zero / produces no result. * - * `engine` selects the backend (`musicgen` | `audioldm2` | `acestep`); unknown + * `engine` selects the backend (`musicgen` | `audioldm2` | `acestep` | + * `acestep15`); unknown * ids fall back to the default. `modelId` is resolved within that engine's * registry. `lyrics` is forwarded only to lyric-aware engines (ACE-Step); other * engines ignore it. `signal` (optional AbortSignal) SIGTERMs the child — wired diff --git a/server/services/pipeline/musicGen.test.js b/server/services/pipeline/musicGen.test.js index 4a716acc50..3f158c2e28 100644 --- a/server/services/pipeline/musicGen.test.js +++ b/server/services/pipeline/musicGen.test.js @@ -63,7 +63,7 @@ describe('AUDIOLDM2_MODELS registry', () => { describe('ENGINES backend registry', () => { it('exposes all backends with the fields the route + UI consume', () => { - expect(Object.keys(ENGINES).sort()).toEqual(['acestep', 'audioldm2', 'minimax-music3', 'musicgen']); + expect(Object.keys(ENGINES).sort()).toEqual(['acestep', 'acestep15', 'audioldm2', 'minimax-music3', 'musicgen']); for (const engine of Object.values(ENGINES)) { expect(typeof engine.id).toBe('string'); expect(typeof engine.name).toBe('string'); @@ -89,6 +89,16 @@ describe('ENGINES backend registry', () => { expect(ENGINES.audioldm2.maxDurationSec).toBeGreaterThan(ENGINES.musicgen.maxDurationSec); }); + it('keeps ACE-Step 1.5 separate from v1 and requires its fixed installed snapshot', () => { + expect(ENGINES.acestep15).toMatchObject({ + id: 'acestep15', installEnv: 'INSTALL_ACESTEP15', lyrics: true, + customModels: false, fixedModelInstall: true, + }); + expect(ENGINES.acestep15.models).toEqual([expect.objectContaining({ repo: 'ACE-Step/Ace-Step1.5' })]); + expect(ENGINES.acestep15.scriptPath).toMatch(/generate_acestep15\.py$/); + expect(ENGINES.acestep.models[0].repo).toBe('ACE-Step/ACE-Step-v1-3.5B'); + }); + it('musicgen window mirrors the legacy module-level constants', () => { expect(ENGINES.musicgen.minDurationSec).toBe(MIN_DURATION_SEC); expect(ENGINES.musicgen.maxDurationSec).toBe(MAX_DURATION_SEC); @@ -136,6 +146,17 @@ describe('clampDuration', () => { }); describe('buildSidecarArgs', () => { + it('routes ACE-Step 1.5 to its own sidecar with the fixed model repo and lyrics', () => { + const { args } = buildSidecarArgs({ + engineId: 'acestep15', pythonPath: '/venv/python', repo: 'ACE-Step/Ace-Step1.5', + prompt: 'bright pop', lyrics: '[Verse] Example', durationSec: 999, outputPath: '/tmp/out.wav', + }); + expect(args[0]).toMatch(/generate_acestep15\.py$/); + expect(args.slice(args.indexOf('--model'), args.indexOf('--model') + 2)).toEqual(['--model', 'ACE-Step/Ace-Step1.5']); + expect(args.slice(args.indexOf('--duration'), args.indexOf('--duration') + 2)).toEqual(['--duration', '240']); + expect(args.slice(args.indexOf('--lyrics'), args.indexOf('--lyrics') + 2)).toEqual(['--lyrics', '[Verse] Example']); + }); + it('builds MiniMax Music 3 args with lyrics and clamps to five minutes', () => { const { args } = buildSidecarArgs({ engineId: 'minimax-music3', pythonPath: '/venv/python', repo: 'MiniMaxAI/MiniMax-Music3', From 9a8794560f52f4c31aa38a939c025ee493a29d83 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 07:32:55 -0700 Subject: [PATCH 2/5] fix: honor PORTOS_TORCH_CUDA_INDEX override in ACE-Step 1.5 install Every other CUDA-index-using venv block in this script (MiniMax H3, MiniMax Music3) respects PORTOS_TORCH_CUDA_INDEX so a user with a different CUDA toolkit can override the pinned wheel index. The new ACE-Step 1.5 block hardcoded cu128 instead. --- scripts/setup-image-video.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/setup-image-video.sh b/scripts/setup-image-video.sh index f902228b0e..ffb1b7b9df 100755 --- a/scripts/setup-image-video.sh +++ b/scripts/setup-image-video.sh @@ -741,7 +741,8 @@ if [[ "$INSTALL_ACESTEP15" == "1" ]]; then # Pin the runtime to a vendor release. The model snapshot is installed # separately through Music, but its custom-code loader depends on this # package's handler contract. - "$ACESTEP15_PY" -m pip install --upgrade --extra-index-url https://download.pytorch.org/whl/cu128 \ + ACESTEP15_TORCH_INDEX="${PORTOS_TORCH_CUDA_INDEX:-https://download.pytorch.org/whl/cu128}" + "$ACESTEP15_PY" -m pip install --upgrade --extra-index-url "$ACESTEP15_TORCH_INDEX" \ "git+https://github.com/ace-step/ACE-Step-1.5.git@v0.1.8" \ "huggingface_hub[hf_xet]" if ! "$ACESTEP15_PY" -c "import torch; from transformers import AutoModel; from acestep.handler import AceStepHandler" 2>/dev/null; then From 7c48efbe52c1df8e1491296c311bc1267dba7d29 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 07:38:26 -0700 Subject: [PATCH 3/5] address review (claude): render ACE-Step 1.5 output in a temp dir, widen healthProbe - generate_acestep15.py now renders into a per-invocation tempfile.TemporaryDirectory (mirroring generate_acestep.py's v1 pattern) instead of writing directly into the shared PortOS music library. A stray or partial file from a failed/partial generation no longer lingers as a phantom track. - ENGINES.acestep15.healthProbe now also imports acestep.inference (the actual generation path), not just acestep.handler, so a venv missing that submodule reports unhealthy up front instead of failing generation with a bare ImportError. --- scripts/generate_acestep15.py | 40 ++++++++++++++++------------ server/services/pipeline/musicGen.js | 6 ++++- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/scripts/generate_acestep15.py b/scripts/generate_acestep15.py index 3e17ddef1a..eb792a1705 100644 --- a/scripts/generate_acestep15.py +++ b/scripts/generate_acestep15.py @@ -13,6 +13,7 @@ import os import shutil import sys +import tempfile import wave @@ -97,23 +98,28 @@ def main(): seed=-1, ) stage("generate", f"{duration:.1f}s") - result = generate_music( - dit_handler=dit_handler, - llm_handler=None, - params=params, - config=GenerationConfig(batch_size=1, audio_format="wav"), - save_dir=output_dir, - ) - if not result.success or not result.audios: - reason = getattr(result, "error", None) or getattr(result, "status_message", "unknown error") - print(f"ERROR: ACE-Step 1.5 generation failed: {reason}", file=sys.stderr, flush=True) - return 1 - - produced = result.audios[0].get("path") - if not produced or not os.path.isfile(produced): - print("ERROR: ACE-Step 1.5 returned no audio file", file=sys.stderr, flush=True) - return 1 - if os.path.abspath(produced) != os.path.abspath(args.output): + # Render into a per-invocation temp dir, not directly into the shared + # PortOS music library (output_dir): any extra or partial file the vendor + # call writes to save_dir beyond the one we move out — including on a + # failed/partial generation — is cleaned up automatically when the `with` + # block exits, instead of lingering as a phantom track in the library. + with tempfile.TemporaryDirectory(prefix="acestep15-") as tmp: + result = generate_music( + dit_handler=dit_handler, + llm_handler=None, + params=params, + config=GenerationConfig(batch_size=1, audio_format="wav"), + save_dir=tmp, + ) + if not result.success or not result.audios: + reason = getattr(result, "error", None) or getattr(result, "status_message", "unknown error") + print(f"ERROR: ACE-Step 1.5 generation failed: {reason}", file=sys.stderr, flush=True) + return 1 + + produced = result.audios[0].get("path") + if not produced or not os.path.isfile(produced): + print("ERROR: ACE-Step 1.5 returned no audio file", file=sys.stderr, flush=True) + return 1 shutil.move(produced, args.output) stage("encode-wav") print("RESULT:" + json.dumps({ diff --git a/server/services/pipeline/musicGen.js b/server/services/pipeline/musicGen.js index 1b785db920..d724640d7b 100644 --- a/server/services/pipeline/musicGen.js +++ b/server/services/pipeline/musicGen.js @@ -215,7 +215,11 @@ export const ENGINES = Object.freeze({ installEnv: 'INSTALL_ACESTEP15', // ACE-Step 1.5's handler imports the fixed snapshot's custom // modeling_acestep_v15_turbo.py via transformers AutoModel trust_remote_code. - healthProbe: 'import torch; from transformers import AutoModel; from acestep.handler import AceStepHandler', + // Probe the generation import path too (acestep.inference), not just the + // handler — a venv missing that submodule would otherwise report healthy + // and fail generation with a bare ImportError instead of the actionable + // "runtime not found" 503. + healthProbe: 'import torch; from transformers import AutoModel; from acestep.handler import AceStepHandler; from acestep.inference import GenerationConfig, GenerationParams, generate_music', lyrics: true, customModels: false, fixedModelInstall: true, From 14e3a47b618a4d8ee0665bda38c8ed79aa70233f Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 07:40:33 -0700 Subject: [PATCH 4/5] address review (claude): widen ACE-Step 1.5 install-time sanity probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install script's post-install import check only verified acestep.handler, not acestep.inference — the module the generation sidecar actually imports. A venv with a broken inference submodule would print venv-ready at install time and only fail later at generation. Mirrors the same fix already applied to the JS-side healthProbe for this engine. --- scripts/setup-image-video.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/setup-image-video.sh b/scripts/setup-image-video.sh index ffb1b7b9df..0bed5d3508 100755 --- a/scripts/setup-image-video.sh +++ b/scripts/setup-image-video.sh @@ -745,7 +745,7 @@ if [[ "$INSTALL_ACESTEP15" == "1" ]]; then "$ACESTEP15_PY" -m pip install --upgrade --extra-index-url "$ACESTEP15_TORCH_INDEX" \ "git+https://github.com/ace-step/ACE-Step-1.5.git@v0.1.8" \ "huggingface_hub[hf_xet]" - if ! "$ACESTEP15_PY" -c "import torch; from transformers import AutoModel; from acestep.handler import AceStepHandler" 2>/dev/null; then + if ! "$ACESTEP15_PY" -c "import torch; from transformers import AutoModel; from acestep.handler import AceStepHandler; from acestep.inference import GenerationConfig, GenerationParams, generate_music" 2>/dev/null; then echo "❌ ACE-Step 1.5 venv built but its Transformers runtime failed to import." >&2 echo " Check that torch, transformers, and ACE-Step 1.5 installed cleanly in ${ACESTEP15_VENV}." >&2 exit 1 From e10d15d15d9c518c0e248e16e10129ddbb7cfc8a Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 07:48:04 -0700 Subject: [PATCH 5/5] fix: adopt venv_exists/venv_python shared helpers for ACE-Step 1.5 venv Rebasing onto main picked up issue #4200's shared-helper convention (venv_exists/venv_python) and its guard test forbidding hardcoded "$X_VENV/bin/python3" call sites outside those helpers. The ACE-Step 1.5 block predated that convention; convert it to match the sibling engines (MiniMax Music 3, AudioLDM2, ACE-Step v1), including the summary line which now references the resolved ${ACESTEP15_PY} instead of hardcoding the POSIX path. --- scripts/setup-image-video.sh | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/scripts/setup-image-video.sh b/scripts/setup-image-video.sh index 0bed5d3508..d9702a9422 100755 --- a/scripts/setup-image-video.sh +++ b/scripts/setup-image-video.sh @@ -723,16 +723,13 @@ if [[ "$INSTALL_ACESTEP15" == "1" ]]; then # multi-component pipeline whose DiT loads custom Transformers code with # trust_remote_code, so it must never share v1's `acestep` venv. ACESTEP15_VENV="${HOME}/.portos/venv-acestep15" - ACESTEP15_PY="$ACESTEP15_VENV/bin/python3" mkdir -p "${HOME}/.portos" - if [[ ! -x "$ACESTEP15_PY" && ! -x "$ACESTEP15_VENV/Scripts/python.exe" ]]; then + if ! venv_exists "$ACESTEP15_VENV"; then echo "📦 Creating ACE-Step 1.5 venv at ${ACESTEP15_VENV}..." "$PYTHON_BIN" -m venv "$ACESTEP15_VENV" fi - if [[ ! -x "$ACESTEP15_PY" ]]; then - ACESTEP15_PY="$ACESTEP15_VENV/Scripts/python.exe" - fi + ACESTEP15_PY="$(venv_python "$ACESTEP15_VENV")" echo "📦 Installing ACE-Step 1.5 into ${ACESTEP15_VENV}..." "$ACESTEP15_PY" -m pip install --upgrade pip wheel setuptools >/dev/null # ACE-Step 1.5's Linux/Windows torch pins are published on PyTorch's CUDA @@ -938,7 +935,7 @@ if [[ "$INSTALL_ACESTEP" == "1" ]]; then echo " ACE-Step: ${ACESTEP_PY} (separate venv, acestep — full song + vocals)" fi if [[ "$INSTALL_ACESTEP15" == "1" ]]; then - echo " ACE-Step 1.5: ${HOME}/.portos/venv-acestep15/bin/python3 (separate venv, Transformers — full song + vocals)" + echo " ACE-Step 1.5: ${ACESTEP15_PY} (separate venv, Transformers — full song + vocals)" fi if [[ "$INSTALL_MUSCRIPTOR" == "1" ]]; then echo " MuScriptor: ${MUSCRIPTOR_PY} (separate venv, muscriptor — audio → MIDI)"