Falln87's picture
Add MusePlayer backend server with ACE-Step 1.5 integration
b5c5905 verified
Raw
History Blame Contribute Delete
23.1 kB
"""
MusePlayer Backend Server
=========================
Self-hosted ACE-Step 1.5 music generation API on Hugging Face Spaces.
Deploy this as a Gradio Space. It serves both a Gradio UI and a REST API.
Requirements (requirements.txt):
------------------------------
torch>=2.5.0
diffusers>=0.38.0
transformers>=4.45.0
accelerate>=0.34.0
soundfile>=0.12.1
numpy>=1.26.0
gradio>=5.0.0
fastapi>=0.115.0
uvicorn>=0.30.0
pydantic>=2.9.0
requests>=2.32.0
pillow>=10.0.0
"""
import os
import io
import uuid
import json
import base64
import time
import threading
from datetime import datetime
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field, asdict
from queue import Queue
import torch
import soundfile as sf
import numpy as np
from pydantic import BaseModel, Field
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
from fastapi.responses import StreamingResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import gradio as gr
# ------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------
MODEL_ID = os.getenv("ACESTEP_MODEL", "ACE-Step/Ace-Step1.5")
DEVICE = os.getenv("ACESTEP_DEVICE", "cuda" if torch.cuda.is_available() else "cpu")
TORCH_DTYPE = torch.bfloat16 if DEVICE == "cuda" else torch.float32
MAX_QUEUE = int(os.getenv("MAX_QUEUE", 5))
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/tmp/museplayer_outputs")
os.makedirs(OUTPUT_DIR, exist_ok=True)
# ------------------------------------------------------------------
# Global state
# ------------------------------------------------------------------
pipe = None
generation_queue = Queue(maxsize=MAX_QUEUE)
results_store: Dict[str, Dict[str, Any]] = {}
user_prefs: Dict[str, Dict[str, Any]] = {}
# ------------------------------------------------------------------
# Pydantic models
# ------------------------------------------------------------------
class GenerateRequest(BaseModel):
prompt: str = Field(..., description="Music style description")
lyrics: str = Field("", description="Lyrics with [verse]/[chorus] tags")
duration: float = Field(30.0, ge=5.0, le=600.0, description="Duration in seconds")
bpm: Optional[int] = Field(None, ge=40, le=300, description="Beats per minute")
keyscale: Optional[str] = Field(None, description="e.g. C major, A minor")
timesignature: Optional[str] = Field(None, description="e.g. 4 for 4/4")
vocal_language: str = Field("en", description="Lyrics language code")
num_inference_steps: int = Field(8, ge=1, le=100)
guidance_scale: float = Field(7.0, ge=1.0, le=20.0)
seed: Optional[int] = Field(None, description="Random seed for reproducibility")
user_id: str = Field("anonymous", description="User identifier for personalization")
station_type: str = Field("custom", description="Station genre/mood tag")
class FeedbackRequest(BaseModel):
track_id: str
user_id: str
action: str = Field(..., pattern="^(like|dislike|skip|complete|favorite)$")
station_type: Optional[str] = None
class UserProfile(BaseModel):
user_id: str
liked_prompts: List[str] = []
disliked_prompts: List[str] = []
favorite_genres: List[str] = []
preferred_duration: float = 60.0
preferred_bpm_range: List[int] = [80, 140]
preferred_keys: List[str] = []
# ------------------------------------------------------------------
# Model loading
# ------------------------------------------------------------------
def load_model():
"""Lazy-load the ACE-Step 1.5 pipeline."""
global pipe
if pipe is not None:
return pipe
from diffusers import AceStepPipeline
print(f"[MusePlayer] Loading ACE-Step model: {MODEL_ID} on {DEVICE} ...")
pipe = AceStepPipeline.from_pretrained(
MODEL_ID,
torch_dtype=TORCH_DTYPE,
)
pipe = pipe.to(DEVICE)
if hasattr(pipe, "vae") and hasattr(pipe.vae, "enable_slicing"):
pipe.vae.enable_slicing()
if hasattr(pipe, "vae") and hasattr(pipe.vae, "enable_tiling"):
pipe.vae.enable_tiling()
print("[MusePlayer] Model loaded successfully.")
return pipe
# ------------------------------------------------------------------
# Music generation
# ------------------------------------------------------------------
def generate_music(
prompt: str,
lyrics: str = "",
duration: float = 30.0,
bpm: Optional[int] = None,
keyscale: Optional[str] = None,
timesignature: Optional[str] = None,
vocal_language: str = "en",
num_inference_steps: int = 8,
guidance_scale: float = 7.0,
seed: Optional[int] = None,
track_id: Optional[str] = None,
) -> str:
"""Generate music and return the path to the output file."""
model = load_model()
generator = None
if seed is not None:
generator = torch.Generator(device=DEVICE).manual_seed(seed)
kwargs = dict(
prompt=prompt,
lyrics=lyrics,
audio_duration=duration,
vocal_language=vocal_language,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
shift=3.0,
generator=generator,
)
if bpm is not None:
kwargs["bpm"] = bpm
if keyscale is not None:
kwargs["keyscale"] = keyscale
if timesignature is not None:
kwargs["timesignature"] = timesignature
audio = model(**kwargs).audios
# audio shape: [batch, channels, samples] or [batch, samples]
# Save to file
tid = track_id or str(uuid.uuid4())[:8]
out_path = os.path.join(OUTPUT_DIR, f"{tid}.wav")
audio_np = audio[0]
if hasattr(audio_np, "cpu"):
audio_np = audio_np.cpu()
if hasattr(audio_np, "numpy"):
audio_np = audio_np.numpy()
if audio_np.ndim == 1:
audio_np = audio_np[np.newaxis, :]
sf.write(out_path, audio_np.T, model.sample_rate)
return out_path
def worker_loop():
"""Background thread processing generation queue."""
while True:
try:
job = generation_queue.get(timeout=1)
except Exception:
continue
track_id = job["track_id"]
try:
start = time.time()
path = generate_music(track_id=track_id, **job["params"])
elapsed = time.time() - start
results_store[track_id] = {
"status": "completed",
"track_id": track_id,
"file_path": path,
"created_at": datetime.utcnow().isoformat(),
"generation_time_sec": round(elapsed, 2),
"params": job["params"],
"station_type": job.get("station_type", "custom"),
"user_id": job.get("user_id", "anonymous"),
}
except Exception as e:
results_store[track_id] = {
"status": "failed",
"track_id": track_id,
"error": str(e),
"params": job["params"],
}
finally:
generation_queue.task_done()
# Start background worker
threading.Thread(target=worker_loop, daemon=True).start()
# ------------------------------------------------------------------
# Preference / Personalization engine
# ------------------------------------------------------------------
PROMPT_TEMPLATES = {
"lofi": "lo-fi hip hop, warm vinyl crackle, soft piano chords, dusty drums, relaxed, nostalgic, bedroom studio",
"energetic": "upbeat electronic dance, driving four-on-the-floor kick, bright synth stabs, energetic, festival anthem",
"chill": "ambient downtempo, airy pads, gentle acoustic guitar, warm bass, relaxed evening vibes, soft rain",
"focus": "minimal instrumental, steady soft beat, warm synth pads, no vocals, productive focus, clean mix",
"sleep": "slow ambient drone, deep soft pads, no percussion, gentle piano, sleep meditation, 432 Hz",
"workout": "high-energy trap, aggressive 808s, fast hi-hats, powerful build-ups, gym motivation, intense",
"acoustic": "intimate acoustic folk, fingerstyle guitar, warm vocals, gentle harmonica, campfire storytelling",
"jazz": "smooth jazz, brushed drums, walking bass, muted trumpet, late night lounge, sophisticated",
"classical": "cinematic orchestral, soaring strings, grand piano, emotional film score, epic crescendo",
"synthwave": "retro synthwave, analog synth arpeggios, driving electronic beat, neon lights, 80s nostalgia",
}
BPM_RANGES = {
"lofi": (60, 90),
"energetic": (120, 140),
"chill": (70, 100),
"focus": (60, 90),
"sleep": (40, 70),
"workout": (130, 160),
"acoustic": (80, 110),
"jazz": (80, 120),
"classical": (60, 120),
"synthwave": (100, 130),
}
def get_user_profile(user_id: str) -> Dict[str, Any]:
"""Retrieve or initialize user preference profile."""
if user_id not in user_prefs:
user_prefs[user_id] = {
"user_id": user_id,
"liked_prompts": [],
"disliked_prompts": [],
"favorite_genres": [],
"preferred_duration": 60.0,
"preferred_bpm_range": [80, 140],
"preferred_keys": [],
"liked_tracks": [],
"listening_history": [],
"created_at": datetime.utcnow().isoformat(),
}
return user_prefs[user_id]
def update_preferences_from_feedback(user_id: str, action: str, track_meta: Dict[str, Any]):
"""Update user profile based on feedback."""
profile = get_user_profile(user_id)
prompt = track_meta.get("params", {}).get("prompt", "")
station = track_meta.get("station_type", "custom")
if action in ("like", "favorite"):
if prompt and prompt not in profile["liked_prompts"]:
profile["liked_prompts"].append(prompt)
if station not in profile["favorite_genres"]:
profile["favorite_genres"].append(station)
profile["liked_tracks"].append(track_meta["track_id"])
elif action == "dislike":
if prompt and prompt not in profile["disliked_prompts"]:
profile["disliked_prompts"].append(prompt)
elif action == "complete":
# Implicit positive signal
pass
# Keep history bounded
profile["listening_history"].append({
"track_id": track_meta.get("track_id"),
"action": action,
"station": station,
"prompt": prompt,
"timestamp": datetime.utcnow().isoformat(),
})
if len(profile["listening_history"]) > 200:
profile["listening_history"] = profile["listening_history"][-200:]
def build_personalized_prompt(user_id: str, station_type: str = "custom") -> Dict[str, Any]:
"""Build a generation prompt tailored to the user's taste."""
profile = get_user_profile(user_id)
# Start from station template
if station_type in PROMPT_TEMPLATES:
base = PROMPT_TEMPLATES[station_type]
bpm_low, bpm_high = BPM_RANGES[station_type]
else:
# Custom / blend from liked prompts
if profile["liked_prompts"]:
base = " ".join(profile["liked_prompts"][-3:])
bpm_low, bpm_high = 80, 140
else:
base = "unique instrumental track, blended genre fusion, modern production"
bpm_low, bpm_high = 80, 140
# Inject positive style keywords from liked prompts
liked_keywords = []
for p in profile["liked_prompts"][-5:]:
liked_keywords.extend(p.split(", ")[:3])
if liked_keywords:
liked_str = ", ".join(set(liked_keywords[-6:]))
base = f"{base}. Also incorporate: {liked_str}"
# Avoid disliked styles
for dp in profile["disliked_prompts"][-3:]:
if dp:
base += f". Avoid: {dp.split(',')[0]} style"
# Pick BPM in preferred range
bpm = int((bpm_low + bpm_high) / 2)
if profile["preferred_bpm_range"]:
low, high = profile["preferred_bpm_range"]
bpm = int(max(low, min(high, bpm)))
duration = profile.get("preferred_duration", 60.0)
return {
"prompt": base,
"duration": duration,
"bpm": bpm,
"station_type": station_type,
}
# ------------------------------------------------------------------
# FastAPI app
# ------------------------------------------------------------------
app = FastAPI(title="MusePlayer API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
return {"status": "ok", "model_loaded": pipe is not None, "queue_size": generation_queue.qsize()}
@app.get("/stations")
async def list_stations():
"""Return available station types with descriptions."""
return {
"stations": [
{"id": k, "name": k.replace("_", " ").title(), "description": v[:80] + "..."}
for k, v in PROMPT_TEMPLATES.items()
]
}
@app.post("/generate")
async def api_generate(req: GenerateRequest, background_tasks: BackgroundTasks):
"""Queue a music generation job."""
if generation_queue.full():
raise HTTPException(status_code=503, detail="Generation queue is full. Try again shortly.")
track_id = str(uuid.uuid4())[:12]
params = {
"prompt": req.prompt,
"lyrics": req.lyrics,
"duration": req.duration,
"bpm": req.bpm,
"keyscale": req.keyscale,
"timesignature": req.timesignature,
"vocal_language": req.vocal_language,
"num_inference_steps": req.num_inference_steps,
"guidance_scale": req.guidance_scale,
"seed": req.seed,
}
job = {
"track_id": track_id,
"params": params,
"user_id": req.user_id,
"station_type": req.station_type,
}
# Pre-register result
results_store[track_id] = {
"status": "queued",
"track_id": track_id,
"queued_at": datetime.utcnow().isoformat(),
"params": params,
}
generation_queue.put(job)
return {"track_id": track_id, "status": "queued", "estimated_wait_sec": generation_queue.qsize() * 30}
@app.post("/generate-personalized")
async def api_generate_personalized(user_id: str = "anonymous", station_type: str = "lofi"):
"""Generate a track tailored to the user's taste profile."""
if generation_queue.full():
raise HTTPException(status_code=503, detail="Generation queue is full.")
profile = get_user_profile(user_id)
built = build_personalized_prompt(user_id, station_type)
track_id = str(uuid.uuid4())[:12]
params = {
"prompt": built["prompt"],
"lyrics": "",
"duration": built["duration"],
"bpm": built["bpm"],
"keyscale": None,
"timesignature": None,
"vocal_language": "en",
"num_inference_steps": 8,
"guidance_scale": 7.0,
"seed": None,
}
job = {
"track_id": track_id,
"params": params,
"user_id": user_id,
"station_type": station_type,
}
results_store[track_id] = {
"status": "queued",
"track_id": track_id,
"queued_at": datetime.utcnow().isoformat(),
"params": params,
"personalized": True,
}
generation_queue.put(job)
return {
"track_id": track_id,
"status": "queued",
"prompt_used": built["prompt"],
"estimated_wait_sec": generation_queue.qsize() * 30,
}
@app.get("/track/{track_id}")
async def get_track(track_id: str):
"""Get track status or stream audio if complete."""
if track_id not in results_store:
raise HTTPException(status_code=404, detail="Track not found")
meta = results_store[track_id]
if meta["status"] == "completed":
file_path = meta["file_path"]
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Audio file missing")
def iterfile():
with open(file_path, "rb") as f:
yield from f
return StreamingResponse(
iterfile(),
media_type="audio/wav",
headers={"Content-Disposition": f'attachment; filename="{track_id}.wav"'},
)
return JSONResponse(content={"status": meta["status"], "track_id": track_id})
@app.get("/track/{track_id}/status")
async def track_status(track_id: str):
if track_id not in results_store:
raise HTTPException(status_code=404, detail="Track not found")
meta = results_store[track_id]
return {
"status": meta["status"],
"track_id": track_id,
"params": meta.get("params"),
"generation_time_sec": meta.get("generation_time_sec"),
"error": meta.get("error"),
}
@app.get("/track/{track_id}/download")
async def track_download(track_id: str, format: str = "wav"):
"""Download track in requested format (wav or mp3)."""
if track_id not in results_store or results_store[track_id]["status"] != "completed":
raise HTTPException(status_code=404, detail="Track not available")
file_path = results_store[track_id]["file_path"]
if format == "mp3":
# Simple wav->mp3 conversion stub (requires ffmpeg in real deploy)
mp3_path = file_path.replace(".wav", ".mp3")
if not os.path.exists(mp3_path):
# In production, use ffmpeg: os.system(f"ffmpeg -i {file_path} -q:a 2 {mp3_path}")
# For HF Space, we'll return wav as fallback
format = "wav"
file_path = results_store[track_id]["file_path"]
else:
file_path = mp3_path
def iterfile():
with open(file_path, "rb") as f:
yield from f
return StreamingResponse(
iterfile(),
media_type="audio/wav" if format == "wav" else "audio/mpeg",
headers={"Content-Disposition": f'attachment; filename="{track_id}.{format}"'},
)
@app.post("/feedback")
async def api_feedback(req: FeedbackRequest):
"""Record user feedback for personalization."""
track_id = req.track_id
if track_id not in results_store:
raise HTTPException(status_code=404, detail="Track not found")
track_meta = results_store[track_id]
update_preferences_from_feedback(req.user_id, req.action, track_meta)
# Update station type if provided
if req.station_type:
track_meta["station_type"] = req.station_type
return {"status": "ok", "user_id": req.user_id, "action": req.action}
@app.get("/profile/{user_id}")
async def get_profile(user_id: str):
return get_user_profile(user_id)
@app.post("/profile/{user_id}")
async def update_profile(user_id: str, updates: Dict[str, Any]):
profile = get_user_profile(user_id)
allowed = {"preferred_duration", "preferred_bpm_range", "preferred_keys", "favorite_genres"}
for k, v in updates.items():
if k in allowed:
profile[k] = v
return profile
@app.get("/history/{user_id}")
async def get_history(user_id: str, limit: int = Query(20, ge=1, le=100)):
profile = get_user_profile(user_id)
history = profile.get("listening_history", [])
return {"history": history[-limit:]}
@app.get("/tracks")
async def list_tracks():
"""List all generated tracks (for admin/demo)."""
tracks = []
for tid, meta in results_store.items():
tracks.append({
"track_id": tid,
"status": meta["status"],
"station_type": meta.get("station_type", "custom"),
"user_id": meta.get("user_id", "anonymous"),
"created_at": meta.get("created_at", meta.get("queued_at")),
"prompt": meta.get("params", {}).get("prompt", "")[:60],
})
return {"tracks": sorted(tracks, key=lambda x: x.get("created_at", ""), reverse=True)}
# ------------------------------------------------------------------
# Gradio UI (for HF Space demo + manual testing)
# ------------------------------------------------------------------
def gradio_generate(prompt, lyrics, duration, bpm, steps, guidance, seed):
track_id = str(uuid.uuid4())[:8]
out_path = generate_music(
prompt=prompt,
lyrics=lyrics,
duration=duration,
bpm=int(bpm) if bpm else None,
num_inference_steps=int(steps),
guidance_scale=float(guidance),
seed=int(seed) if seed else None,
track_id=track_id,
)
return out_path, f"Track ID: {track_id}"
def create_gradio_ui():
with gr.Blocks(title="MusePlayer ACE-Step Server") as demo:
gr.Markdown("# 🎡 MusePlayer Backend β€” ACE-Step 1.5")
gr.Markdown("Self-hosted AI music generation. Use the REST API or test below.")
with gr.Row():
with gr.Column():
prompt = gr.Textbox(
label="Prompt",
value="A beautiful piano piece with soft melodies and gentle rhythm",
lines=2,
)
lyrics = gr.Textbox(
label="Lyrics (optional)",
placeholder="[verse]\nSoft notes in the morning light\n[chorus]\nMusic fills the air tonight",
lines=4,
)
duration = gr.Slider(5, 120, value=30, step=5, label="Duration (seconds)")
bpm = gr.Number(value=120, label="BPM (optional)")
steps = gr.Slider(1, 50, value=8, step=1, label="Inference Steps")
guidance = gr.Slider(1.0, 20.0, value=7.0, step=0.5, label="Guidance Scale")
seed = gr.Number(value=None, label="Seed (optional)")
btn = gr.Button("Generate Music", variant="primary")
with gr.Column():
audio_out = gr.Audio(label="Generated Music", type="filepath")
info_out = gr.Textbox(label="Info", interactive=False)
btn.click(
gradio_generate,
inputs=[prompt, lyrics, duration, bpm, steps, guidance, seed],
outputs=[audio_out, info_out],
)
gr.Markdown("""
### API Endpoints
- `POST /generate` β€” Queue a track
- `POST /generate-personalized` β€” Auto-tailored track
- `GET /track/{id}` β€” Stream audio
- `GET /track/{id}/status` β€” Check status
- `POST /feedback` β€” Like / dislike / skip
- `GET /profile/{user_id}` β€” Get user taste profile
- `GET /stations` β€” List stations
""")
return demo
# Mount Gradio into FastAPI so both run on the same port
demo = create_gradio_ui()
app = gr.mount_gradio_app(app, demo, path="/")
# ------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
# Preload model on startup if env says so
if os.getenv("PRELOAD_MODEL", "1") == "1":
threading.Thread(target=load_model, daemon=True).start()
port = int(os.getenv("PORT", 7860))
uvicorn.run(app, host="0.0.0.0", port=port)