Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/added-issue-4232.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Music Studio can install and generate full songs with ACE-Step 1.5 while keeping existing ACE-Step renders on v1.
135 changes: 135 additions & 0 deletions scripts/generate_acestep15.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/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 tempfile
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 <checkpoints>/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")
# 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({
"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())
41 changes: 39 additions & 2 deletions scripts/setup-image-video.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,15 +75,15 @@ 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")
# on Homebrew Python and aborts the whole script before the requested runtime
# 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
Expand Down Expand Up @@ -716,6 +717,39 @@ 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"
mkdir -p "${HOME}/.portos"

if ! venv_exists "$ACESTEP15_VENV"; then
echo "📦 Creating ACE-Step 1.5 venv at ${ACESTEP15_VENV}..."
"$PYTHON_BIN" -m venv "$ACESTEP15_VENV"
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
# 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_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; 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
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"
Expand Down Expand Up @@ -900,6 +934,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: ${ACESTEP15_PY} (separate venv, Transformers — full song + vocals)"
fi
if [[ "$INSTALL_MUSCRIPTOR" == "1" ]]; then
echo " MuScriptor: ${MUSCRIPTOR_PY} (separate venv, muscriptor — audio → MIDI)"
fi
Expand Down
30 changes: 30 additions & 0 deletions server/lib/pythonSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
12 changes: 12 additions & 0 deletions server/routes/music.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 37 additions & 1 deletion server/services/pipeline/musicGen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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)' },
]);
Expand Down Expand Up @@ -189,6 +198,32 @@ 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.
// 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,
},
'minimax-music3': {
id: 'minimax-music3',
name: 'MiniMax Music 3 (CUDA only)',
Expand Down Expand Up @@ -377,7 +412,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
Expand Down
Loading