f8ecc6c047
The pre-fix wrapper at stacks/ace-step/infer-api.py returned a JSON
{output_path: "..."} reference to a file written inside the
container at /app/outputs/. That path was unreachable from outside
the container — every consumer got 134 bytes of JSON-pretending-to-
be-WAV instead of audio. Surfaced by the asset_engine consumer's
end-to-end smoke (althing thread 01KRCJF7NGMXYE9F62Q1A6KFD4 msg 5);
my own earlier smoke missed it because I checked HTTP=200 and stopped
reading instead of inspecting the response body.
Wrapper now reads back the file the pipeline writes and streams the
bytes via fastapi.responses.Response with media_type set from the
audio_format request field (audio/wav | audio/mpeg | audio/flac).
The in-container path is exposed via X-Output-Path header for log
correlation but is no longer load-bearing.
Verified end-to-end against live ace-step on irv-ml1:
POST /generate -> HTTP 200 in 80s
content-type: audio/wav
content-length: 945226
x-output-path: /app/outputs/output_cfe87d1d....wav
$ file response.wav
RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit,
stereo 48000 Hz
Catalog: ace-step bumped version 3 -> 4. Dropped
response.output_field (no longer applicable). reproducibility.notes
expanded to record both the v2 18-arg-tuple fix and this v4
inline-streaming change so the history is auditable from the
catalog itself.
Stale ACEStepOutput Pydantic model left in infer-api.py for now —
unused but small; future cleanup.
171 lines
5.6 KiB
Python
171 lines
5.6 KiB
Python
# Patched ACE-Step REST API.
|
|
#
|
|
# Upstream's infer-api.py builds an 18-element positional `params`
|
|
# tuple and splats it into the pipeline. The pipeline's __call__
|
|
# actually expects 24 positional args — upstream's tuple is missing
|
|
# `format` at the head and the audio2audio/lora block at the tail.
|
|
# The result of the missing `format` head: every subsequent arg shifts
|
|
# left, the int `audio_duration` lands in `format`'s slot, and the
|
|
# pipeline blows up with "object of type 'int' has no len()" when it
|
|
# tries to validate the format string.
|
|
#
|
|
# This file copies upstream's behavior verbatim except for the params
|
|
# tuple, which now matches the canonical 24-arg pipeline signature
|
|
# inferred from the Gradio handler in acestep/ui/components.py.
|
|
# (Defaults for the new fields mirror Gradio's defaults: format=wav,
|
|
# audio2audio_enable=False, ref_audio_strength=0.5, no ref audio, no
|
|
# LoRA.)
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel
|
|
from typing import List, Optional
|
|
import os
|
|
from acestep.pipeline_ace_step import ACEStepPipeline
|
|
import uuid
|
|
|
|
app = FastAPI(title="ACEStep Pipeline API (patched)")
|
|
|
|
# audio_format -> Content-Type. The wrapper writes the file in the
|
|
# format the upstream pipeline supports for the requested extension;
|
|
# we just need to label the bytes correctly on the wire.
|
|
_AUDIO_MIME = {
|
|
"wav": "audio/wav",
|
|
"mp3": "audio/mpeg",
|
|
"flac": "audio/flac",
|
|
}
|
|
|
|
|
|
class ACEStepInput(BaseModel):
|
|
checkpoint_path: str
|
|
bf16: bool = True
|
|
torch_compile: bool = False
|
|
device_id: int = 0
|
|
output_path: Optional[str] = None
|
|
audio_duration: float
|
|
prompt: str
|
|
lyrics: str
|
|
infer_step: int
|
|
guidance_scale: float
|
|
scheduler_type: str
|
|
cfg_type: str
|
|
omega_scale: float
|
|
actual_seeds: List[int]
|
|
guidance_interval: float
|
|
guidance_interval_decay: float
|
|
min_guidance_scale: float
|
|
use_erg_tag: bool
|
|
use_erg_lyric: bool
|
|
use_erg_diffusion: bool
|
|
oss_steps: List[int]
|
|
guidance_scale_text: float = 0.0
|
|
guidance_scale_lyric: float = 0.0
|
|
# Optional new fields exposed for completeness — Gradio defaults.
|
|
# lora_name_or_path: empty string trips HF Hub's repo-id validator
|
|
# ("Repo id ... cannot ... be empty"), so default to None and only
|
|
# forward it as a string when actually set.
|
|
audio_format: str = "wav"
|
|
audio2audio_enable: bool = False
|
|
ref_audio_strength: float = 0.5
|
|
ref_audio_input: Optional[str] = None
|
|
lora_name_or_path: Optional[str] = None
|
|
lora_weight: float = 1.0
|
|
|
|
|
|
class ACEStepOutput(BaseModel):
|
|
status: str
|
|
output_path: Optional[str]
|
|
message: str
|
|
|
|
|
|
def initialize_pipeline(
|
|
checkpoint_path: str, bf16: bool, torch_compile: bool, device_id: int
|
|
) -> ACEStepPipeline:
|
|
os.environ["CUDA_VISIBLE_DEVICES"] = str(device_id)
|
|
return ACEStepPipeline(
|
|
checkpoint_dir=checkpoint_path,
|
|
dtype="bfloat16" if bf16 else "float32",
|
|
torch_compile=torch_compile,
|
|
)
|
|
|
|
|
|
@app.post("/generate")
|
|
async def generate_audio(input_data: ACEStepInput):
|
|
"""Generate music; respond with the audio bytes inline.
|
|
|
|
Pre-2026-05-11 this returned a JSON {output_path} reference to a
|
|
file inside the container — useless to any external consumer
|
|
since the path wasn't reachable from outside. Now streams the
|
|
bytes back directly with the right Content-Type, and the file in
|
|
/app/outputs/ is incidental bookkeeping (the pipeline writes
|
|
there as part of its normal flow; we read it back for the wire).
|
|
"""
|
|
try:
|
|
model_demo = initialize_pipeline(
|
|
input_data.checkpoint_path,
|
|
input_data.bf16,
|
|
input_data.torch_compile,
|
|
input_data.device_id,
|
|
)
|
|
|
|
params = (
|
|
input_data.audio_format,
|
|
input_data.audio_duration,
|
|
input_data.prompt,
|
|
input_data.lyrics,
|
|
input_data.infer_step,
|
|
input_data.guidance_scale,
|
|
input_data.scheduler_type,
|
|
input_data.cfg_type,
|
|
input_data.omega_scale,
|
|
", ".join(map(str, input_data.actual_seeds)),
|
|
input_data.guidance_interval,
|
|
input_data.guidance_interval_decay,
|
|
input_data.min_guidance_scale,
|
|
input_data.use_erg_tag,
|
|
input_data.use_erg_lyric,
|
|
input_data.use_erg_diffusion,
|
|
", ".join(map(str, input_data.oss_steps)),
|
|
input_data.guidance_scale_text,
|
|
input_data.guidance_scale_lyric,
|
|
input_data.audio2audio_enable,
|
|
input_data.ref_audio_strength,
|
|
input_data.ref_audio_input,
|
|
input_data.lora_name_or_path or "none",
|
|
input_data.lora_weight,
|
|
)
|
|
|
|
ext = input_data.audio_format.lower()
|
|
output_path = (
|
|
input_data.output_path
|
|
or f"/app/outputs/output_{uuid.uuid4().hex}.{ext}"
|
|
)
|
|
|
|
model_demo(*params, save_path=output_path)
|
|
|
|
with open(output_path, "rb") as f:
|
|
audio_bytes = f.read()
|
|
|
|
return Response(
|
|
content=audio_bytes,
|
|
media_type=_AUDIO_MIME.get(ext, "application/octet-stream"),
|
|
headers={
|
|
# Surfaces the in-container path for debugging/log correlation
|
|
# without putting it on the response body.
|
|
"X-Output-Path": output_path,
|
|
},
|
|
)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Error generating audio: {str(e)}")
|
|
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
return {"status": "healthy"}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|