Spaces:
Running on Zero
Running on Zero
| """MiniMax-Music3-Jam: describe a song in plain English, an LLM writes the lyrics and the | |
| structured caption, MiniMax Music 3 sings it, and the song can be shared to a community feed | |
| backed by an HF bucket mounted at /data.""" | |
| import os | |
| # Cache dirs must be set before any HF/torch import (ZeroGPU: ~/.cache is not writable). | |
| os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules") | |
| os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib") | |
| os.environ["GRADIO_ANALYTICS_ENABLED"] = "False" | |
| for _v in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): | |
| os.environ.pop(_v, None) | |
| import spaces # noqa: E402 must precede torch | |
| import base64 # noqa: E402 | |
| import io # noqa: E402 | |
| import json # noqa: E402 | |
| import random # noqa: E402 | |
| import time # noqa: E402 | |
| import traceback # noqa: E402 | |
| import uuid # noqa: E402 | |
| from datetime import datetime, timezone # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import scipy.io.wavfile # noqa: E402 | |
| import torch # noqa: E402 | |
| from fastapi.responses import FileResponse, HTMLResponse # noqa: E402 | |
| import gradio as gr # noqa: E402 | |
| from gradio import Server # noqa: E402 | |
| import composer # noqa: E402 | |
| import engine # noqa: E402 (loads MiniMax Music 3 + AoTI kernels onto the GPU) | |
| _HERE = os.path.dirname(os.path.abspath(__file__)) | |
| BUCKET_ID = os.environ.get("COMMUNITY_BUCKET", "victor/minimax-music3-community") | |
| BUCKET_URL = f"https://huggingface.co/buckets/{BUCKET_ID}/resolve" | |
| DATA_DIR = os.environ.get("COMMUNITY_DIR", "/data") | |
| SONGS_DIR = os.path.join(DATA_DIR, "songs") | |
| MAX_SEED = int(np.iinfo(np.int32).max) | |
| MAX_DURATION = 180.0 | |
| STEPS, GUIDANCE = 30, 1.7 | |
| FEED_LIMIT = 60 | |
| print(f"[startup] model ready: sr={engine.SAMPLE_RATE} frame_rate={engine.FRAME_RATE}", flush=True) | |
| print(f"[startup] community bucket: {BUCKET_ID} mounted at {DATA_DIR} (exists={os.path.isdir(DATA_DIR)})", flush=True) | |
| # ── Cover art (Z-Image-Turbo), optional ────────────────────────────────────── | |
| try: | |
| from diffusers import FlowMatchEulerDiscreteScheduler, ZImagePipeline | |
| _zimage = ZImagePipeline.from_pretrained("Tongyi-MAI/Z-Image-Turbo", torch_dtype=torch.bfloat16) | |
| _zimage.to("cuda") | |
| print("[startup] Z-Image-Turbo loaded for cover art", flush=True) | |
| except Exception as e: # noqa: BLE001 | |
| _zimage = None | |
| print(f"[startup] Z-Image-Turbo unavailable, songs will ship without cover art: {e}", flush=True) | |
| def _render_cover(word: str) -> bytes | None: | |
| if _zimage is None or not word: | |
| return None | |
| try: | |
| t0 = time.time() | |
| _zimage.scheduler = FlowMatchEulerDiscreteScheduler(num_train_timesteps=1000, shift=3.0) | |
| image = _zimage( | |
| prompt=f"{word} studio photography close-up black background", | |
| height=768, width=768, | |
| guidance_scale=0.0, | |
| num_inference_steps=9, | |
| generator=torch.Generator("cuda").manual_seed(random.randint(1, 1_000_000)), | |
| max_sequence_length=512, | |
| ).images[0] | |
| buf = io.BytesIO() | |
| image.save(buf, format="PNG", optimize=True) | |
| print(f"[cover] '{word}' in {time.time() - t0:.1f}s ({len(buf.getvalue()) // 1024}KB)", flush=True) | |
| return buf.getvalue() | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[cover] failed: {e}", flush=True) | |
| return None | |
| # ── GPU job: song (+ optional cover) in a single ZeroGPU allocation ─────────── | |
| def _gpu_seconds(caption, lyrics, duration, seed, cover_word, *args, **kwargs): | |
| return engine.estimate_gpu_seconds(duration, STEPS) + (12 if cover_word else 0) | |
| def _gpu_job(caption, lyrics, duration, seed, cover_word): | |
| # Errors are returned as data: exceptions raised in the ZeroGPU worker reach the caller | |
| # stripped down to their class name. | |
| try: | |
| wav, sr, audio_seconds, wall = engine.generate_wav(caption, lyrics, duration, seed, STEPS, GUIDANCE) | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[gpu ERROR] {type(e).__name__}: {e}\n{traceback.format_exc()}", flush=True) | |
| return {"error": f"{type(e).__name__}: {e}"} | |
| print(f"[gpu] {audio_seconds:.1f}s of audio in {wall:.1f}s (seed={seed})", flush=True) | |
| cover = _render_cover(cover_word) if cover_word else None | |
| return {"wav": wav, "sr": sr, "seconds": audio_seconds, "cover": cover} | |
| class GenerationError(RuntimeError): | |
| pass | |
| def _wav_bytes(wav: np.ndarray, sr: int) -> bytes: | |
| buf = io.BytesIO() | |
| scipy.io.wavfile.write(buf, sr, wav) | |
| return buf.getvalue() | |
| def _friendly_error(err: Exception) -> str: | |
| msg = (str(err) or "").lower() | |
| if any(h in msg for h in ("gpu limit", "quota", "no gpu", "could not allocate", "gpu is busy", "too many", "concurrent")): | |
| return "The shared GPU is at capacity right now. Please wait a minute and retry." | |
| if "out of memory" in msg or "oom" in msg: | |
| return "Generation ran out of GPU memory. Try a shorter duration." | |
| if "zero audio frames" in msg: | |
| return "The model produced no audio for these inputs; try different lyrics or a longer duration." | |
| if isinstance(err, GenerationError): | |
| return "Generation failed on the GPU. Please try again (a shorter duration helps if it repeats)." | |
| if isinstance(err, (ValueError, RuntimeError)) and str(err): | |
| return str(err) | |
| return f"Generation failed ({type(err).__name__}). Please try again." | |
| # ── Community feed (bucket-backed, cached in memory) ───────────────────────── | |
| _feed: list[dict] = [] | |
| def _public_meta(meta: dict) -> dict: | |
| keys = ("id", "title", "tags", "description", "lyrics", "caption", "duration", "audio_url", "thumb_url", "created_at", "seed") | |
| return {k: meta.get(k) for k in keys} | |
| def _load_feed(): | |
| if not os.path.isdir(SONGS_DIR): | |
| print("[feed] no songs dir yet, starting empty", flush=True) | |
| return | |
| t0 = time.time() | |
| for song_id in os.listdir(SONGS_DIR): | |
| meta_path = os.path.join(SONGS_DIR, song_id, "meta.json") | |
| if not os.path.isfile(meta_path): | |
| continue | |
| try: | |
| with open(meta_path) as f: | |
| meta = json.load(f) | |
| meta["audio_url"] = f"{BUCKET_URL}/songs/{song_id}/{song_id}.wav" | |
| if os.path.isfile(os.path.join(SONGS_DIR, song_id, "thumb.png")): | |
| meta["thumb_url"] = f"{BUCKET_URL}/songs/{song_id}/thumb.png" | |
| else: | |
| meta["thumb_url"] = None | |
| _feed.append(_public_meta(meta)) | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[feed] skipping {song_id}: {e}", flush=True) | |
| _feed.sort(key=lambda s: s.get("created_at") or "", reverse=True) | |
| print(f"[feed] loaded {len(_feed)} songs in {time.time() - t0:.1f}s", flush=True) | |
| _load_feed() | |
| def _share(wav_bytes: bytes, cover: bytes | None, meta: dict) -> dict: | |
| """Persist a song to the bucket mount and prepend it to the in-memory feed.""" | |
| song_id = uuid.uuid4().hex[:12] | |
| song_dir = os.path.join(SONGS_DIR, song_id) | |
| os.makedirs(song_dir, exist_ok=True) | |
| with open(os.path.join(song_dir, f"{song_id}.wav"), "wb") as f: | |
| f.write(wav_bytes) | |
| thumb_url = None | |
| if cover: | |
| with open(os.path.join(song_dir, "thumb.png"), "wb") as f: | |
| f.write(cover) | |
| thumb_url = f"{BUCKET_URL}/songs/{song_id}/thumb.png" | |
| meta = { | |
| **meta, | |
| "id": song_id, | |
| "audio_url": f"{BUCKET_URL}/songs/{song_id}/{song_id}.wav", | |
| "thumb_url": thumb_url, | |
| "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| } | |
| with open(os.path.join(song_dir, "meta.json"), "w") as f: | |
| json.dump(meta, f, indent=2, ensure_ascii=False) | |
| public = _public_meta(meta) | |
| _feed.insert(0, public) | |
| print(f"[share] {song_id} '{meta.get('title')}' -> {meta['audio_url']}", flush=True) | |
| return public | |
| # ── gr.Server app ──────────────────────────────────────────────────────────── | |
| app = Server(title="MiniMax-Music3-Jam") | |
| def _clamp_duration(d) -> float: | |
| try: | |
| d = float(d) | |
| except (TypeError, ValueError): | |
| d = 60.0 | |
| return max(10.0, min(MAX_DURATION, d)) | |
| def _resolve_seed(seed) -> int: | |
| try: | |
| seed = int(seed) | |
| except (TypeError, ValueError): | |
| seed = -1 | |
| return random.randint(0, MAX_SEED) if seed < 0 else min(seed, MAX_SEED) | |
| def _run_song(*, description, title, tags, caption, lyrics, duration, seed, community, cover_word): | |
| """Shared tail of /create and /generate: GPU job, encode, optional share. Returns result dict.""" | |
| engine.validate(caption, lyrics) | |
| out = _gpu_job(caption, lyrics, duration, seed, cover_word) | |
| if out.get("error"): | |
| msg = out["error"] | |
| if "out of memory" in msg.lower(): | |
| raise GenerationError("out of memory") | |
| raise GenerationError(msg) | |
| wav, sr, audio_seconds, cover = out["wav"], out["sr"], out["seconds"], out["cover"] | |
| wav_bytes = _wav_bytes(wav, sr) | |
| result = { | |
| "title": title, | |
| "tags": tags, | |
| "lyrics": lyrics, | |
| "caption": caption, | |
| "seed": seed, | |
| "duration": round(audio_seconds, 1), | |
| "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| "audio": "data:audio/wav;base64," + base64.b64encode(wav_bytes).decode(), | |
| } | |
| if cover: | |
| result["thumbnail"] = "data:image/png;base64," + base64.b64encode(cover).decode() | |
| if community: | |
| try: | |
| shared = _share(wav_bytes, cover, { | |
| "title": title, "tags": tags, "description": description, "lyrics": lyrics, | |
| "caption": caption, "duration": round(audio_seconds, 1), "seed": seed, | |
| }) | |
| result["community_url"] = shared["audio_url"] | |
| result["id"] = shared["id"] | |
| result["created_at"] = shared["created_at"] | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[share] failed: {e}", flush=True) | |
| result["share_error"] = "Song generated, but sharing to the community feed failed." | |
| return result | |
| def create( | |
| description: str, | |
| audio_duration: float = 60.0, | |
| seed: int = -1, | |
| community: bool = False, | |
| instrumental: bool = False, | |
| ) -> str: | |
| """One-box: describe a song -> LLM writes title/lyrics/structured caption -> MiniMax Music 3 generates it. | |
| Streams JSON status messages ({"status": ...}) and finally the result | |
| ({audio, title, tags, lyrics, caption, seed, duration, created_at, thumbnail?, community_url?}).""" | |
| try: | |
| description = (description or "").strip() | |
| if not description: | |
| raise ValueError("Describe the song you want first.") | |
| duration = _clamp_duration(audio_duration) | |
| seed = _resolve_seed(seed) | |
| yield json.dumps({"status": "composing", "message": "Writing lyrics & structured caption…"}) | |
| t0 = time.time() | |
| song = composer.compose(description, duration, instrumental=bool(instrumental)) | |
| print(f"[create] composed '{song['title']}' in {time.time() - t0:.1f}s | tags={song['tags']}", flush=True) | |
| cover_word = composer.visual_word(song["title"], song["tags"], song["lyrics"], description) if _zimage else "" | |
| yield json.dumps({ | |
| "status": "generating", | |
| "message": f"MiniMax Music 3 is recording “{song['title']}”…", | |
| "title": song["title"], "tags": song["tags"], "lyrics": song["lyrics"], | |
| "eta": engine.estimate_gpu_seconds(duration, STEPS), | |
| }) | |
| result = _run_song( | |
| description=description, title=song["title"], tags=song["tags"], caption=song["caption"], | |
| lyrics=song["lyrics"], duration=duration, seed=seed, community=bool(community), cover_word=cover_word, | |
| ) | |
| yield json.dumps(result) | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[create ERROR] {type(e).__name__}: {e}\n{traceback.format_exc()}", flush=True) | |
| raise gr.Error(_friendly_error(e), print_exception=False) from e | |
| def generate( | |
| caption: str, | |
| lyrics: str, | |
| audio_duration: float = 60.0, | |
| seed: int = -1, | |
| title: str = "", | |
| tags: str = "", | |
| community: bool = False, | |
| cover_word: str = "", | |
| ) -> str: | |
| """Advanced: generate from an explicit structured caption + tagged lyrics (no LLM). | |
| cover_word: optional visual noun for the Z-Image cover art. Returns the same JSON result shape as /create.""" | |
| try: | |
| duration = _clamp_duration(audio_duration) | |
| seed = _resolve_seed(seed) | |
| lyrics = composer.normalize_lyrics(lyrics) | |
| title = (title or "").strip()[:80] or "Untitled" | |
| tags = (tags or "").strip()[:120] | |
| result = _run_song( | |
| description="", title=title, tags=tags, caption=(caption or "").strip(), lyrics=lyrics, | |
| duration=duration, seed=seed, community=bool(community), | |
| cover_word=(cover_word or "").strip()[:40] if _zimage else "", | |
| ) | |
| return json.dumps(result) | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[generate ERROR] {type(e).__name__}: {e}\n{traceback.format_exc()}", flush=True) | |
| raise gr.Error(_friendly_error(e), print_exception=False) from e | |
| def community() -> str: | |
| """Newest community songs (JSON list), served from memory. Each entry carries created_at (UTC ISO).""" | |
| return json.dumps(_feed[:FEED_LIMIT], ensure_ascii=False) | |
| async def homepage(): | |
| with open(os.path.join(_HERE, "index.html"), encoding="utf-8") as f: | |
| return f.read() | |
| async def logo(): | |
| return FileResponse(os.path.join(_HERE, "logo_dark.png"), media_type="image/png") | |
| demo = app | |
| if __name__ == "__main__": | |
| demo.launch(show_error=True) | |