youngsemicolon Claude Opus 5.5 commited on
Commit ·
de0fe6c
1
Parent(s): 6f6cec1
YuE2-Jam: run m-a-p/YuE2-3B instead of MiniMax Music 3, rebrand for YuE2
Browse files- engine.py: official yue2_infer package (installed --no-deps from the model repo),
ABC score planning + semantic tokens + flow matching + resident YuE2-Vae decoder;
duration caps the render (fade-out at the cap), tag-only lyrics render instrumentals
with cot="off"
- composer.py: LLM writes a YuE2 style line + [Verse]/[Chorus]-tagged lyrics
- app.py: /generate takes style instead of caption, MP3 output (WAV fallback),
Mike0021/yue2-jam-community bucket, default ZeroGPU size
- index.html/README: YuE mark + YuE2 lockup, YuE2 links, 3 min option
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- README.md +28 -19
- app.py +58 -42
- composer.py +52 -46
- engine.py +101 -251
- index.html +30 -26
- logo.svg +5 -0
- logo_dark.png +0 -0
- requirements.txt +6 -1
README.md
CHANGED
|
@@ -1,51 +1,60 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🎤
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.24.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
license:
|
| 11 |
-
short_description: Describe any song,
|
| 12 |
startup_duration_timeout: 1h
|
| 13 |
suggested_hardware: zero-a10g
|
| 14 |
models:
|
| 15 |
-
-
|
|
|
|
| 16 |
- Tongyi-MAI/Z-Image-Turbo
|
| 17 |
tags:
|
| 18 |
- mcp-server
|
| 19 |
---
|
| 20 |
|
| 21 |
-
#
|
| 22 |
|
| 23 |
-
Describe any song in plain English. An LLM writes the title,
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
each with its creation date).
|
| 28 |
|
| 29 |
## How it works
|
| 30 |
|
| 31 |
- **Compose** (CPU): `composer.py` calls an LLM on the HF Inference Router and returns
|
| 32 |
-
`{title, tags,
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
Cover art comes from Z-Image-Turbo in the same GPU allocation.
|
| 36 |
-
- **Share**:
|
| 37 |
from memory.
|
| 38 |
|
| 39 |
## API
|
| 40 |
|
| 41 |
- `POST /create(description, audio_duration, seed, community, instrumental)` streams status JSON then the
|
| 42 |
-
final result `{audio, title, tags, lyrics,
|
| 43 |
-
- `POST /generate(
|
| 44 |
- `POST /community()` returns the newest shared songs.
|
| 45 |
|
| 46 |
The same three endpoints are also exposed as MCP tools (`create`, `generate`, `community`) via Gradio's
|
| 47 |
built-in `mcp_server=True` — schema and description come straight from each function's type hints and
|
| 48 |
-
docstring, no extra code. MCP endpoint (streamable HTTP): `https://
|
| 49 |
(legacy SSE transport at `/gradio_api/mcp/sse`; add `?tools=create,community` to either to expose only a subset).
|
| 50 |
|
| 51 |
-
Requires the `HF_TOKEN` secret (composer LLM) and the bucket volume `hf://buckets/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: YuE2-Jam
|
| 3 |
emoji: 🎤
|
| 4 |
+
colorFrom: yellow
|
| 5 |
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.24.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: cc-by-nc-4.0
|
| 11 |
+
short_description: Describe any song, YuE2 writes & sings it
|
| 12 |
startup_duration_timeout: 1h
|
| 13 |
suggested_hardware: zero-a10g
|
| 14 |
models:
|
| 15 |
+
- m-a-p/YuE2-3B
|
| 16 |
+
- m-a-p/YuE2-Vae
|
| 17 |
- Tongyi-MAI/Z-Image-Turbo
|
| 18 |
tags:
|
| 19 |
- mcp-server
|
| 20 |
---
|
| 21 |
|
| 22 |
+
# YuE2-Jam
|
| 23 |
|
| 24 |
+
Describe any song in plain English. An LLM writes the title, a YuE2 style prompt and section-tagged
|
| 25 |
+
lyrics, then [YuE2-3B](https://huggingface.co/m-a-p/YuE2-3B) plans a melody-and-chords score and
|
| 26 |
+
sings the full song (48 kHz stereo). Songs can be shared to a community feed (stored in the
|
| 27 |
+
`Mike0021/yue2-jam-community` bucket, each with its creation date).
|
|
|
|
| 28 |
|
| 29 |
## How it works
|
| 30 |
|
| 31 |
- **Compose** (CPU): `composer.py` calls an LLM on the HF Inference Router and returns
|
| 32 |
+
`{title, tags, style, lyrics}`: `style` is a line of comma-separated descriptors (language, genre,
|
| 33 |
+
singer, instruments, mood, BPM) as in the official examples; `lyrics` use YuE2's section tags
|
| 34 |
+
(`[Intro]`, `[Verse]`, `[Pre-Chorus]`, `[Chorus]`, `[Bridge]`, `[Interlude]`, `[Outro]`).
|
| 35 |
+
- **Sing** (ZeroGPU): `engine.py` runs the official `yue2_infer` package from the model repo
|
| 36 |
+
(installed with `--no-deps`; see `requirements.txt`): ABC score planning (`cot="full"`), 25 Hz
|
| 37 |
+
semantic tokens, flow matching, then the [YuE2-Vae](https://huggingface.co/m-a-p/YuE2-Vae) decoder.
|
| 38 |
+
YuE2 sets the song length from the lyrics; the requested duration caps it (fade-out if the cap is
|
| 39 |
+
hit). Lyrics made only of section tags render an instrumental without a score plan (`cot="off"`).
|
| 40 |
Cover art comes from Z-Image-Turbo in the same GPU allocation.
|
| 41 |
+
- **Share**: MP3 + cover + `meta.json` are written to the bucket mounted at `/data`; the feed is served
|
| 42 |
from memory.
|
| 43 |
|
| 44 |
## API
|
| 45 |
|
| 46 |
- `POST /create(description, audio_duration, seed, community, instrumental)` streams status JSON then the
|
| 47 |
+
final result `{audio, title, tags, lyrics, style, seed, duration, created_at, thumbnail?, community_url?}`.
|
| 48 |
+
- `POST /generate(style, lyrics, audio_duration, seed, title, tags, community, cover_word)` for explicit inputs.
|
| 49 |
- `POST /community()` returns the newest shared songs.
|
| 50 |
|
| 51 |
The same three endpoints are also exposed as MCP tools (`create`, `generate`, `community`) via Gradio's
|
| 52 |
built-in `mcp_server=True` — schema and description come straight from each function's type hints and
|
| 53 |
+
docstring, no extra code. MCP endpoint (streamable HTTP): `https://mike0021-yue2-jam.hf.space/gradio_api/mcp`
|
| 54 |
(legacy SSE transport at `/gradio_api/mcp/sse`; add `?tools=create,community` to either to expose only a subset).
|
| 55 |
|
| 56 |
+
Requires the `HF_TOKEN` secret (composer LLM) and the bucket volume `hf://buckets/Mike0021/yue2-jam-community:/data`.
|
| 57 |
+
|
| 58 |
+
YuE2 weights are released under [CC BY-NC 4.0](https://huggingface.co/m-a-p/YuE2-3B/blob/main/LICENSE):
|
| 59 |
+
non-commercial use only. The header mark is the YuE logo (Apache 2.0) from the
|
| 60 |
+
[YuE2 project page](https://map-yue2.github.io/).
|
app.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
|
| 5 |
import os
|
| 6 |
|
|
@@ -17,6 +17,7 @@ import base64 # noqa: E402
|
|
| 17 |
import io # noqa: E402
|
| 18 |
import json # noqa: E402
|
| 19 |
import random # noqa: E402
|
|
|
|
| 20 |
import time # noqa: E402
|
| 21 |
import traceback # noqa: E402
|
| 22 |
import uuid # noqa: E402
|
|
@@ -30,17 +31,17 @@ import gradio as gr # noqa: E402
|
|
| 30 |
from gradio import Server # noqa: E402
|
| 31 |
|
| 32 |
import composer # noqa: E402
|
| 33 |
-
import engine # noqa: E402 (loads
|
| 34 |
|
| 35 |
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 36 |
-
BUCKET_ID = os.environ.get("COMMUNITY_BUCKET", "
|
| 37 |
BUCKET_URL = f"https://huggingface.co/buckets/{BUCKET_ID}/resolve"
|
| 38 |
DATA_DIR = os.environ.get("COMMUNITY_DIR", "/data")
|
| 39 |
SONGS_DIR = os.path.join(DATA_DIR, "songs")
|
| 40 |
MAX_SEED = int(np.iinfo(np.int32).max)
|
| 41 |
MAX_DURATION = 180.0
|
| 42 |
-
STEPS, GUIDANCE = 30, 1.7
|
| 43 |
FEED_LIMIT = 60
|
|
|
|
| 44 |
|
| 45 |
print(f"[startup] model ready: sr={engine.SAMPLE_RATE} frame_rate={engine.FRAME_RATE}", flush=True)
|
| 46 |
print(f"[startup] community bucket: {BUCKET_ID} mounted at {DATA_DIR} (exists={os.path.isdir(DATA_DIR)})", flush=True)
|
|
@@ -81,16 +82,16 @@ def _render_cover(word: str) -> bytes | None:
|
|
| 81 |
|
| 82 |
|
| 83 |
# ── GPU job: song (+ optional cover) in a single ZeroGPU allocation ───────────
|
| 84 |
-
def _gpu_seconds(
|
| 85 |
-
return engine.estimate_gpu_seconds(duration
|
| 86 |
|
| 87 |
|
| 88 |
-
@spaces.GPU(duration=_gpu_seconds
|
| 89 |
-
def _gpu_job(
|
| 90 |
# Errors are returned as data: exceptions raised in the ZeroGPU worker reach the caller
|
| 91 |
# stripped down to their class name.
|
| 92 |
try:
|
| 93 |
-
wav, sr, audio_seconds, wall = engine.generate_wav(
|
| 94 |
except Exception as e: # noqa: BLE001
|
| 95 |
print(f"[gpu ERROR] {type(e).__name__}: {e}\n{traceback.format_exc()}", flush=True)
|
| 96 |
return {"error": f"{type(e).__name__}: {e}"}
|
|
@@ -103,10 +104,21 @@ class GenerationError(RuntimeError):
|
|
| 103 |
pass
|
| 104 |
|
| 105 |
|
| 106 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
buf = io.BytesIO()
|
| 108 |
scipy.io.wavfile.write(buf, sr, wav)
|
| 109 |
-
return buf.getvalue()
|
| 110 |
|
| 111 |
|
| 112 |
def _friendly_error(err: Exception) -> str:
|
|
@@ -115,8 +127,6 @@ def _friendly_error(err: Exception) -> str:
|
|
| 115 |
return "The shared GPU is at capacity right now. Please wait a minute and retry."
|
| 116 |
if "out of memory" in msg or "oom" in msg:
|
| 117 |
return "Generation ran out of GPU memory. Try a shorter duration."
|
| 118 |
-
if "zero audio frames" in msg:
|
| 119 |
-
return "The model produced no audio for these inputs; try different lyrics or a longer duration."
|
| 120 |
if isinstance(err, GenerationError):
|
| 121 |
return "Generation failed on the GPU. Please try again (a shorter duration helps if it repeats)."
|
| 122 |
if isinstance(err, (ValueError, RuntimeError)) and str(err):
|
|
@@ -129,7 +139,7 @@ _feed: list[dict] = []
|
|
| 129 |
|
| 130 |
|
| 131 |
def _public_meta(meta: dict) -> dict:
|
| 132 |
-
keys = ("id", "title", "tags", "description", "lyrics", "
|
| 133 |
return {k: meta.get(k) for k in keys}
|
| 134 |
|
| 135 |
|
|
@@ -145,7 +155,7 @@ def _load_feed():
|
|
| 145 |
try:
|
| 146 |
with open(meta_path) as f:
|
| 147 |
meta = json.load(f)
|
| 148 |
-
meta["audio_url"] = f"{BUCKET_URL}/songs/{song_id}/{song_id
|
| 149 |
if os.path.isfile(os.path.join(SONGS_DIR, song_id, "thumb.png")):
|
| 150 |
meta["thumb_url"] = f"{BUCKET_URL}/songs/{song_id}/thumb.png"
|
| 151 |
else:
|
|
@@ -160,13 +170,14 @@ def _load_feed():
|
|
| 160 |
_load_feed()
|
| 161 |
|
| 162 |
|
| 163 |
-
def _share(
|
| 164 |
"""Persist a song to the bucket mount and prepend it to the in-memory feed."""
|
| 165 |
song_id = uuid.uuid4().hex[:12]
|
| 166 |
song_dir = os.path.join(SONGS_DIR, song_id)
|
| 167 |
os.makedirs(song_dir, exist_ok=True)
|
| 168 |
-
|
| 169 |
-
|
|
|
|
| 170 |
thumb_url = None
|
| 171 |
if cover:
|
| 172 |
with open(os.path.join(song_dir, "thumb.png"), "wb") as f:
|
|
@@ -175,7 +186,8 @@ def _share(wav_bytes: bytes, cover: bytes | None, meta: dict) -> dict:
|
|
| 175 |
meta = {
|
| 176 |
**meta,
|
| 177 |
"id": song_id,
|
| 178 |
-
"
|
|
|
|
| 179 |
"thumb_url": thumb_url,
|
| 180 |
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 181 |
}
|
|
@@ -188,7 +200,7 @@ def _share(wav_bytes: bytes, cover: bytes | None, meta: dict) -> dict:
|
|
| 188 |
|
| 189 |
|
| 190 |
# ── gr.Server app ────────────────────────────────────────────────────────────
|
| 191 |
-
app = Server(title="
|
| 192 |
|
| 193 |
|
| 194 |
def _clamp_duration(d) -> float:
|
|
@@ -207,34 +219,34 @@ def _resolve_seed(seed) -> int:
|
|
| 207 |
return random.randint(0, MAX_SEED) if seed < 0 else min(seed, MAX_SEED)
|
| 208 |
|
| 209 |
|
| 210 |
-
def _run_song(*, description, title, tags,
|
| 211 |
"""Shared tail of /create and /generate: GPU job, encode, optional share. Returns result dict."""
|
| 212 |
-
engine.validate(
|
| 213 |
-
out = _gpu_job(
|
| 214 |
if out.get("error"):
|
| 215 |
msg = out["error"]
|
| 216 |
if "out of memory" in msg.lower():
|
| 217 |
raise GenerationError("out of memory")
|
| 218 |
raise GenerationError(msg)
|
| 219 |
wav, sr, audio_seconds, cover = out["wav"], out["sr"], out["seconds"], out["cover"]
|
| 220 |
-
|
| 221 |
result = {
|
| 222 |
"title": title,
|
| 223 |
"tags": tags,
|
| 224 |
"lyrics": lyrics,
|
| 225 |
-
"
|
| 226 |
"seed": seed,
|
| 227 |
"duration": round(audio_seconds, 1),
|
| 228 |
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 229 |
-
"audio": "data:
|
| 230 |
}
|
| 231 |
if cover:
|
| 232 |
result["thumbnail"] = "data:image/png;base64," + base64.b64encode(cover).decode()
|
| 233 |
if community:
|
| 234 |
try:
|
| 235 |
-
shared = _share(
|
| 236 |
"title": title, "tags": tags, "description": description, "lyrics": lyrics,
|
| 237 |
-
"
|
| 238 |
})
|
| 239 |
result["community_url"] = shared["audio_url"]
|
| 240 |
result["id"] = shared["id"]
|
|
@@ -253,9 +265,9 @@ def create(
|
|
| 253 |
community: bool = False,
|
| 254 |
instrumental: bool = False,
|
| 255 |
) -> str:
|
| 256 |
-
"""One-box: describe a song -> LLM writes title/
|
| 257 |
Streams JSON status messages ({"status": ...}) and finally the result
|
| 258 |
-
({audio, title, tags, lyrics,
|
| 259 |
try:
|
| 260 |
description = (description or "").strip()
|
| 261 |
if not description:
|
|
@@ -263,7 +275,7 @@ def create(
|
|
| 263 |
duration = _clamp_duration(audio_duration)
|
| 264 |
seed = _resolve_seed(seed)
|
| 265 |
|
| 266 |
-
yield json.dumps({"status": "composing", "message": "Writing lyrics &
|
| 267 |
t0 = time.time()
|
| 268 |
song = composer.compose(description, duration, instrumental=bool(instrumental))
|
| 269 |
print(f"[create] composed '{song['title']}' in {time.time() - t0:.1f}s | tags={song['tags']}", flush=True)
|
|
@@ -271,12 +283,12 @@ def create(
|
|
| 271 |
|
| 272 |
yield json.dumps({
|
| 273 |
"status": "generating",
|
| 274 |
-
"message": f"
|
| 275 |
"title": song["title"], "tags": song["tags"], "lyrics": song["lyrics"],
|
| 276 |
-
"eta": engine.
|
| 277 |
})
|
| 278 |
result = _run_song(
|
| 279 |
-
description=description, title=song["title"], tags=song["tags"],
|
| 280 |
lyrics=song["lyrics"], duration=duration, seed=seed, community=bool(community), cover_word=cover_word,
|
| 281 |
)
|
| 282 |
yield json.dumps(result)
|
|
@@ -287,7 +299,7 @@ def create(
|
|
| 287 |
|
| 288 |
@app.api(name="generate", concurrency_limit=None, time_limit=900)
|
| 289 |
def generate(
|
| 290 |
-
|
| 291 |
lyrics: str,
|
| 292 |
audio_duration: float = 60.0,
|
| 293 |
seed: int = -1,
|
|
@@ -296,8 +308,12 @@ def generate(
|
|
| 296 |
community: bool = False,
|
| 297 |
cover_word: str = "",
|
| 298 |
) -> str:
|
| 299 |
-
"""Advanced: generate from an explicit
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
try:
|
| 302 |
duration = _clamp_duration(audio_duration)
|
| 303 |
seed = _resolve_seed(seed)
|
|
@@ -305,7 +321,7 @@ def generate(
|
|
| 305 |
title = (title or "").strip()[:80] or "Untitled"
|
| 306 |
tags = (tags or "").strip()[:120]
|
| 307 |
result = _run_song(
|
| 308 |
-
description="", title=title, tags=tags,
|
| 309 |
duration=duration, seed=seed, community=bool(community),
|
| 310 |
cover_word=(cover_word or "").strip()[:40] if _zimage else "",
|
| 311 |
)
|
|
@@ -327,9 +343,9 @@ async def homepage():
|
|
| 327 |
return f.read()
|
| 328 |
|
| 329 |
|
| 330 |
-
@app.get("/logo.
|
| 331 |
async def logo():
|
| 332 |
-
return FileResponse(os.path.join(_HERE, "
|
| 333 |
|
| 334 |
|
| 335 |
demo = app
|
|
|
|
| 1 |
+
"""YuE2-Jam: describe a song in plain English, an LLM writes the lyrics and the style prompt,
|
| 2 |
+
YuE2 plans a score and sings it, and the song can be shared to a community feed backed by an HF
|
| 3 |
+
bucket mounted at /data."""
|
| 4 |
|
| 5 |
import os
|
| 6 |
|
|
|
|
| 17 |
import io # noqa: E402
|
| 18 |
import json # noqa: E402
|
| 19 |
import random # noqa: E402
|
| 20 |
+
import subprocess # noqa: E402
|
| 21 |
import time # noqa: E402
|
| 22 |
import traceback # noqa: E402
|
| 23 |
import uuid # noqa: E402
|
|
|
|
| 31 |
from gradio import Server # noqa: E402
|
| 32 |
|
| 33 |
import composer # noqa: E402
|
| 34 |
+
import engine # noqa: E402 (loads YuE2-3B + YuE2-Vae onto the GPU)
|
| 35 |
|
| 36 |
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 37 |
+
BUCKET_ID = os.environ.get("COMMUNITY_BUCKET", "Mike0021/yue2-jam-community")
|
| 38 |
BUCKET_URL = f"https://huggingface.co/buckets/{BUCKET_ID}/resolve"
|
| 39 |
DATA_DIR = os.environ.get("COMMUNITY_DIR", "/data")
|
| 40 |
SONGS_DIR = os.path.join(DATA_DIR, "songs")
|
| 41 |
MAX_SEED = int(np.iinfo(np.int32).max)
|
| 42 |
MAX_DURATION = 180.0
|
|
|
|
| 43 |
FEED_LIMIT = 60
|
| 44 |
+
AUDIO_MIME = {"mp3": "audio/mpeg", "wav": "audio/wav"}
|
| 45 |
|
| 46 |
print(f"[startup] model ready: sr={engine.SAMPLE_RATE} frame_rate={engine.FRAME_RATE}", flush=True)
|
| 47 |
print(f"[startup] community bucket: {BUCKET_ID} mounted at {DATA_DIR} (exists={os.path.isdir(DATA_DIR)})", flush=True)
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
# ── GPU job: song (+ optional cover) in a single ZeroGPU allocation ───────────
|
| 85 |
+
def _gpu_seconds(style, lyrics, duration, seed, cover_word, *args, **kwargs):
|
| 86 |
+
return engine.estimate_gpu_seconds(duration) + (12 if cover_word else 0)
|
| 87 |
|
| 88 |
|
| 89 |
+
@spaces.GPU(duration=_gpu_seconds)
|
| 90 |
+
def _gpu_job(style, lyrics, duration, seed, cover_word):
|
| 91 |
# Errors are returned as data: exceptions raised in the ZeroGPU worker reach the caller
|
| 92 |
# stripped down to their class name.
|
| 93 |
try:
|
| 94 |
+
wav, sr, audio_seconds, wall = engine.generate_wav(style, lyrics, duration, seed)
|
| 95 |
except Exception as e: # noqa: BLE001
|
| 96 |
print(f"[gpu ERROR] {type(e).__name__}: {e}\n{traceback.format_exc()}", flush=True)
|
| 97 |
return {"error": f"{type(e).__name__}: {e}"}
|
|
|
|
| 104 |
pass
|
| 105 |
|
| 106 |
|
| 107 |
+
def _encode_audio(wav: np.ndarray, sr: int) -> tuple[bytes, str]:
|
| 108 |
+
"""MP3 at 192 kbps (a 3-minute 48 kHz stereo WAV is ~35 MB), WAV if ffmpeg fails. Returns (bytes, ext)."""
|
| 109 |
+
try:
|
| 110 |
+
out = subprocess.run(
|
| 111 |
+
["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "s16le", "-ar", str(sr), "-ac", str(wav.shape[1]),
|
| 112 |
+
"-i", "pipe:0", "-codec:a", "libmp3lame", "-b:a", "192k", "-f", "mp3", "pipe:1"],
|
| 113 |
+
input=np.ascontiguousarray(wav).tobytes(), capture_output=True, check=True, timeout=120,
|
| 114 |
+
).stdout
|
| 115 |
+
if out:
|
| 116 |
+
return out, "mp3"
|
| 117 |
+
except (OSError, subprocess.SubprocessError) as e:
|
| 118 |
+
print(f"[encode] mp3 failed, falling back to WAV: {e}", flush=True)
|
| 119 |
buf = io.BytesIO()
|
| 120 |
scipy.io.wavfile.write(buf, sr, wav)
|
| 121 |
+
return buf.getvalue(), "wav"
|
| 122 |
|
| 123 |
|
| 124 |
def _friendly_error(err: Exception) -> str:
|
|
|
|
| 127 |
return "The shared GPU is at capacity right now. Please wait a minute and retry."
|
| 128 |
if "out of memory" in msg or "oom" in msg:
|
| 129 |
return "Generation ran out of GPU memory. Try a shorter duration."
|
|
|
|
|
|
|
| 130 |
if isinstance(err, GenerationError):
|
| 131 |
return "Generation failed on the GPU. Please try again (a shorter duration helps if it repeats)."
|
| 132 |
if isinstance(err, (ValueError, RuntimeError)) and str(err):
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
def _public_meta(meta: dict) -> dict:
|
| 142 |
+
keys = ("id", "title", "tags", "description", "lyrics", "style", "duration", "audio_url", "thumb_url", "created_at", "seed")
|
| 143 |
return {k: meta.get(k) for k in keys}
|
| 144 |
|
| 145 |
|
|
|
|
| 155 |
try:
|
| 156 |
with open(meta_path) as f:
|
| 157 |
meta = json.load(f)
|
| 158 |
+
meta["audio_url"] = f"{BUCKET_URL}/songs/{song_id}/{meta.get('audio_file') or song_id + '.wav'}"
|
| 159 |
if os.path.isfile(os.path.join(SONGS_DIR, song_id, "thumb.png")):
|
| 160 |
meta["thumb_url"] = f"{BUCKET_URL}/songs/{song_id}/thumb.png"
|
| 161 |
else:
|
|
|
|
| 170 |
_load_feed()
|
| 171 |
|
| 172 |
|
| 173 |
+
def _share(audio: bytes, ext: str, cover: bytes | None, meta: dict) -> dict:
|
| 174 |
"""Persist a song to the bucket mount and prepend it to the in-memory feed."""
|
| 175 |
song_id = uuid.uuid4().hex[:12]
|
| 176 |
song_dir = os.path.join(SONGS_DIR, song_id)
|
| 177 |
os.makedirs(song_dir, exist_ok=True)
|
| 178 |
+
audio_file = f"{song_id}.{ext}"
|
| 179 |
+
with open(os.path.join(song_dir, audio_file), "wb") as f:
|
| 180 |
+
f.write(audio)
|
| 181 |
thumb_url = None
|
| 182 |
if cover:
|
| 183 |
with open(os.path.join(song_dir, "thumb.png"), "wb") as f:
|
|
|
|
| 186 |
meta = {
|
| 187 |
**meta,
|
| 188 |
"id": song_id,
|
| 189 |
+
"audio_file": audio_file,
|
| 190 |
+
"audio_url": f"{BUCKET_URL}/songs/{song_id}/{audio_file}",
|
| 191 |
"thumb_url": thumb_url,
|
| 192 |
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 193 |
}
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
# ── gr.Server app ────────────────────────────────────────────────────────────
|
| 203 |
+
app = Server(title="YuE2-Jam")
|
| 204 |
|
| 205 |
|
| 206 |
def _clamp_duration(d) -> float:
|
|
|
|
| 219 |
return random.randint(0, MAX_SEED) if seed < 0 else min(seed, MAX_SEED)
|
| 220 |
|
| 221 |
|
| 222 |
+
def _run_song(*, description, title, tags, style, lyrics, duration, seed, community, cover_word):
|
| 223 |
"""Shared tail of /create and /generate: GPU job, encode, optional share. Returns result dict."""
|
| 224 |
+
engine.validate(style, lyrics)
|
| 225 |
+
out = _gpu_job(style, lyrics, duration, seed, cover_word)
|
| 226 |
if out.get("error"):
|
| 227 |
msg = out["error"]
|
| 228 |
if "out of memory" in msg.lower():
|
| 229 |
raise GenerationError("out of memory")
|
| 230 |
raise GenerationError(msg)
|
| 231 |
wav, sr, audio_seconds, cover = out["wav"], out["sr"], out["seconds"], out["cover"]
|
| 232 |
+
audio, ext = _encode_audio(wav, sr)
|
| 233 |
result = {
|
| 234 |
"title": title,
|
| 235 |
"tags": tags,
|
| 236 |
"lyrics": lyrics,
|
| 237 |
+
"style": style,
|
| 238 |
"seed": seed,
|
| 239 |
"duration": round(audio_seconds, 1),
|
| 240 |
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 241 |
+
"audio": f"data:{AUDIO_MIME[ext]};base64," + base64.b64encode(audio).decode(),
|
| 242 |
}
|
| 243 |
if cover:
|
| 244 |
result["thumbnail"] = "data:image/png;base64," + base64.b64encode(cover).decode()
|
| 245 |
if community:
|
| 246 |
try:
|
| 247 |
+
shared = _share(audio, ext, cover, {
|
| 248 |
"title": title, "tags": tags, "description": description, "lyrics": lyrics,
|
| 249 |
+
"style": style, "duration": round(audio_seconds, 1), "seed": seed,
|
| 250 |
})
|
| 251 |
result["community_url"] = shared["audio_url"]
|
| 252 |
result["id"] = shared["id"]
|
|
|
|
| 265 |
community: bool = False,
|
| 266 |
instrumental: bool = False,
|
| 267 |
) -> str:
|
| 268 |
+
"""One-box: describe a song -> LLM writes title/style/lyrics -> YuE2 plans a score and sings it.
|
| 269 |
Streams JSON status messages ({"status": ...}) and finally the result
|
| 270 |
+
({audio, title, tags, lyrics, style, seed, duration, created_at, thumbnail?, community_url?})."""
|
| 271 |
try:
|
| 272 |
description = (description or "").strip()
|
| 273 |
if not description:
|
|
|
|
| 275 |
duration = _clamp_duration(audio_duration)
|
| 276 |
seed = _resolve_seed(seed)
|
| 277 |
|
| 278 |
+
yield json.dumps({"status": "composing", "message": "Writing lyrics & style…"})
|
| 279 |
t0 = time.time()
|
| 280 |
song = composer.compose(description, duration, instrumental=bool(instrumental))
|
| 281 |
print(f"[create] composed '{song['title']}' in {time.time() - t0:.1f}s | tags={song['tags']}", flush=True)
|
|
|
|
| 283 |
|
| 284 |
yield json.dumps({
|
| 285 |
"status": "generating",
|
| 286 |
+
"message": f"YuE2 is scoring & recording “{song['title']}”…",
|
| 287 |
"title": song["title"], "tags": song["tags"], "lyrics": song["lyrics"],
|
| 288 |
+
"eta": engine.eta_seconds(duration),
|
| 289 |
})
|
| 290 |
result = _run_song(
|
| 291 |
+
description=description, title=song["title"], tags=song["tags"], style=song["style"],
|
| 292 |
lyrics=song["lyrics"], duration=duration, seed=seed, community=bool(community), cover_word=cover_word,
|
| 293 |
)
|
| 294 |
yield json.dumps(result)
|
|
|
|
| 299 |
|
| 300 |
@app.api(name="generate", concurrency_limit=None, time_limit=900)
|
| 301 |
def generate(
|
| 302 |
+
style: str,
|
| 303 |
lyrics: str,
|
| 304 |
audio_duration: float = 60.0,
|
| 305 |
seed: int = -1,
|
|
|
|
| 308 |
community: bool = False,
|
| 309 |
cover_word: str = "",
|
| 310 |
) -> str:
|
| 311 |
+
"""Advanced: generate from an explicit style prompt + section-tagged lyrics (no LLM).
|
| 312 |
+
style: comma-separated descriptors (language, genre, singer, instruments, mood, BPM), e.g. "English, warm
|
| 313 |
+
piano pop, expressive female voice, acoustic piano, light drums, 88 BPM". lyrics: [Verse]/[Chorus]/... tags
|
| 314 |
+
each on their own line; tags with no words render an instrumental. audio_duration: target length in seconds
|
| 315 |
+
(YuE2 sets the length from the lyrics; this caps it). cover_word: optional visual noun for the Z-Image
|
| 316 |
+
cover art. Returns the same JSON result shape as /create."""
|
| 317 |
try:
|
| 318 |
duration = _clamp_duration(audio_duration)
|
| 319 |
seed = _resolve_seed(seed)
|
|
|
|
| 321 |
title = (title or "").strip()[:80] or "Untitled"
|
| 322 |
tags = (tags or "").strip()[:120]
|
| 323 |
result = _run_song(
|
| 324 |
+
description="", title=title, tags=tags, style=" ".join((style or "").split()), lyrics=lyrics,
|
| 325 |
duration=duration, seed=seed, community=bool(community),
|
| 326 |
cover_word=(cover_word or "").strip()[:40] if _zimage else "",
|
| 327 |
)
|
|
|
|
| 343 |
return f.read()
|
| 344 |
|
| 345 |
|
| 346 |
+
@app.get("/logo.svg", include_in_schema=False)
|
| 347 |
async def logo():
|
| 348 |
+
return FileResponse(os.path.join(_HERE, "logo.svg"), media_type="image/svg+xml")
|
| 349 |
|
| 350 |
|
| 351 |
demo = app
|
composer.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
"""LLM composer: plain-English song description ->
|
| 2 |
|
| 3 |
-
Produces a title, short display tags,
|
| 4 |
-
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
|
@@ -30,31 +30,34 @@ VISUAL_MODELS = (
|
|
| 30 |
("deepseek-ai/DeepSeek-V4-Flash-0731:fastest", 45, _NO_THINK),
|
| 31 |
)
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
_LYRICS_RULES = """lyrics: singable lyrics using ONLY these section tags, each ALWAYS ALONE on its own line: [intro] [verse] [pre-chorus] [chorus] [post-chorus] [bridge] [instrumental] [solo] [outro]. Never put words on the same line as a tag. Size the structure to the duration: <=30s: one verse + one chorus; ~60s: verse/pre-chorus/chorus/verse/chorus; >=120s: full structure with bridge and outro. Roughly 12-16 sung words per 10 seconds. Musical instructions (tempo, instruments, dynamics) never belong in the lyrics. If the song is instrumental, use [instrumental] sections with no words. Write the lyrics in the language the user asks for (default: English)."""
|
| 42 |
-
|
| 43 |
-
COMPOSER_SYSTEM = f"""You write inputs for MiniMax Music 3, a lyrics+description music generation model.
|
| 44 |
Given a song description and a target duration, produce:
|
| 45 |
1. title: a short, catchy song title (2-5 words, no quotes).
|
| 46 |
2. tags: 3-5 short comma-separated style tags for a music feed card, e.g. "synth-pop, female vocals, 120 bpm, euphoric".
|
| 47 |
-
3. {
|
| 48 |
-
4
|
| 49 |
-
Unless the user explicitly asks for an instrumental, the song HAS a singer:
|
| 50 |
-
Answer with ONLY a JSON object with keys: title, tags,
|
| 51 |
|
| 52 |
VISUAL_SYSTEM = (
|
| 53 |
"Reply with exactly ONE concrete visual noun (a physical object, animal, or natural element) "
|
| 54 |
"that captures the essence of this song. No explanation, no punctuation, just the single word."
|
| 55 |
)
|
| 56 |
|
| 57 |
-
|
| 58 |
|
| 59 |
|
| 60 |
def _client():
|
|
@@ -101,38 +104,47 @@ def _parse_json(text, required):
|
|
| 101 |
return data
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def normalize_lyrics(lyrics: str) -> str:
|
| 105 |
-
"""
|
| 106 |
out = []
|
| 107 |
text = (lyrics or "").replace("\r", "").replace("\\n", "\n")
|
| 108 |
for line in text.split("\n"):
|
| 109 |
-
m =
|
| 110 |
-
if m and m.group(
|
| 111 |
-
out
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
out.append(m.group(1).lower())
|
| 115 |
else:
|
| 116 |
out.append(line.rstrip())
|
| 117 |
text = "\n".join(out)
|
| 118 |
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def compose(description: str, duration: float, instrumental: bool = False) -> dict:
|
| 122 |
-
"""Return {title, tags,
|
| 123 |
user = f"Song description: {description.strip()}\nTarget duration: {int(duration)} seconds."
|
| 124 |
if instrumental:
|
| 125 |
-
user += "\nThis song is INSTRUMENTAL: no vocals at all.
|
| 126 |
else:
|
| 127 |
user += "\nThe song has sung vocals (pick a fitting singer if the description does not specify one)."
|
| 128 |
-
required = ("title", "
|
| 129 |
last_error = None
|
| 130 |
for _ in range(2):
|
| 131 |
try:
|
| 132 |
data = _parse_json(_chat(COMPOSER_SYSTEM, user), required)
|
| 133 |
-
|
| 134 |
-
if not instrumental and
|
| 135 |
-
raise ValueError("
|
| 136 |
break
|
| 137 |
except (ValueError, json.JSONDecodeError) as e:
|
| 138 |
print(f"[composer] bad reply: {e}", flush=True)
|
|
@@ -140,24 +152,18 @@ def compose(description: str, duration: float, instrumental: bool = False) -> di
|
|
| 140 |
else:
|
| 141 |
raise RuntimeError("The composer returned an unusable reply; please try again.") from last_error
|
| 142 |
|
| 143 |
-
|
| 144 |
-
if instrumental
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
title = str(data["title"]).strip().strip('"\'')[:80] or "Untitled"
|
| 147 |
tags = str(data.get("tags", "")).strip().strip(".")[:120]
|
| 148 |
-
|
| 149 |
-
vocal_details = str(data["vocal_details"]).strip()
|
| 150 |
-
arrangement = str(data["arrangement"]).strip()
|
| 151 |
-
caption = "\n".join(s for s in (global_metadata, vocal_details, arrangement) if s)
|
| 152 |
-
return {
|
| 153 |
-
"title": title,
|
| 154 |
-
"tags": tags,
|
| 155 |
-
"lyrics": lyrics,
|
| 156 |
-
"caption": caption,
|
| 157 |
-
"global_metadata": global_metadata,
|
| 158 |
-
"vocal_details": vocal_details,
|
| 159 |
-
"arrangement": arrangement,
|
| 160 |
-
}
|
| 161 |
|
| 162 |
|
| 163 |
def visual_word(title: str, tags: str, lyrics: str, description: str) -> str:
|
|
|
|
| 1 |
+
"""LLM composer: plain-English song description -> YuE2 inputs.
|
| 2 |
|
| 3 |
+
Produces a title, short display tags, a YuE2 style prompt (comma-separated descriptors: language,
|
| 4 |
+
genre, singer, instruments, mood, tempo, as in the official examples) and section-tagged lyrics.
|
| 5 |
+
Runs on the HF Inference Router (no GPU).
|
| 6 |
"""
|
| 7 |
|
| 8 |
import json
|
|
|
|
| 30 |
("deepseek-ai/DeepSeek-V4-Flash-0731:fastest", 45, _NO_THINK),
|
| 31 |
)
|
| 32 |
|
| 33 |
+
# YuE2's native section labels (the ABC planner marks the same sections in its score).
|
| 34 |
+
SECTIONS = ("Intro", "Verse", "Pre-Chorus", "Chorus", "Bridge", "Interlude", "Instrumental", "Outro")
|
| 35 |
+
_SECTION_ALIASES = {
|
| 36 |
+
"pre chorus": "Pre-Chorus", "prechorus": "Pre-Chorus", "post-chorus": "Chorus", "post chorus": "Chorus",
|
| 37 |
+
"hook": "Chorus", "refrain": "Chorus", "break": "Interlude", "solo": "Interlude", "inst": "Instrumental",
|
| 38 |
+
}
|
| 39 |
+
_SECTION_NAMES = {s.lower(): s for s in SECTIONS} | _SECTION_ALIASES
|
| 40 |
+
NO_VOCALS = "no vocals, no singing, no choir, no spoken words"
|
| 41 |
|
| 42 |
+
_STYLE_RULES = f"""style: ONE line of 8-14 comma-separated musical descriptors, like the official YuE2 examples: start with the lyrics language ("English", "Mandarin Chinese", ...), then genre and subgenre, the singer (gender, timbre, delivery, e.g. "breathy female voice", "gritty male baritone"), 3-5 key instruments each with a short character ("rounded electric bass", "gated snare"), mood and energy arc, production character, and the tempo as "<number> BPM". Example: "English, warm piano pop, expressive female voice, acoustic piano, rounded bass and light drums, lyrical memorable melody, unhurried phrasing, 88 BPM". Plain musical words only: no sentences, quotes, title or lyric lines. Never contradict an explicit user constraint (vocal gender, tempo, required or excluded instruments). For an instrumental, start with "Instrumental" instead of a language, name the instrument carrying the melody, and end with "{NO_VOCALS}"."""
|
| 43 |
|
| 44 |
+
_LYRICS_RULES = """lyrics: singable lyrics using ONLY these section tags: [Intro] [Verse] [Pre-Chorus] [Chorus] [Bridge] [Interlude] [Outro]. Each tag ALONE on its own line with the section's lines below it, and one blank line between sections. [Intro] and [Interlude] are usually left empty (instrumental passages). Size the structure to the duration: <=30s: one [Verse] + one [Chorus]; ~60s: [Intro] [Verse] [Chorus] [Verse] [Chorus]; ~120s: [Intro] [Verse] [Pre-Chorus] [Chorus] [Verse] [Pre-Chorus] [Chorus] [Bridge] [Chorus] [Outro]; >=180s: that full structure plus an [Interlude] and a final repeated [Chorus]. 4 lines per sung section, short rhythmic lines of 5-9 words, roughly 12-16 sung words per 10 seconds; repeat the chorus words each time it returns. Musical instructions (tempo, instruments, dynamics) never belong in the lyrics. Write in the language the user asks for (default: English; YuE2 is strongest in English and Mandarin Chinese). If the song is instrumental, the lyrics are ONLY the section tags, with no words."""
|
| 45 |
|
| 46 |
+
COMPOSER_SYSTEM = f"""You write inputs for YuE2, an open music generation model that sings complete songs from a style prompt and section-tagged lyrics.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
Given a song description and a target duration, produce:
|
| 48 |
1. title: a short, catchy song title (2-5 words, no quotes).
|
| 49 |
2. tags: 3-5 short comma-separated style tags for a music feed card, e.g. "synth-pop, female vocals, 120 bpm, euphoric".
|
| 50 |
+
3. {_STYLE_RULES}
|
| 51 |
+
4. {_LYRICS_RULES}
|
| 52 |
+
Unless the user explicitly asks for an instrumental, the song HAS a singer: the style must describe that singer and the lyrics must have words to sing.
|
| 53 |
+
Answer with ONLY a JSON object with keys: title, tags, style, lyrics. Inside the lyrics string, separate lines with JSON newline escapes, never with the two literal characters backslash and n."""
|
| 54 |
|
| 55 |
VISUAL_SYSTEM = (
|
| 56 |
"Reply with exactly ONE concrete visual noun (a physical object, animal, or natural element) "
|
| 57 |
"that captures the essence of this song. No explanation, no punctuation, just the single word."
|
| 58 |
)
|
| 59 |
|
| 60 |
+
_TAG_LINE_RE = re.compile(r"^\s*\[([^\]]*)\]\s*(.*)$")
|
| 61 |
|
| 62 |
|
| 63 |
def _client():
|
|
|
|
| 104 |
return data
|
| 105 |
|
| 106 |
|
| 107 |
+
def _section_tag(label: str) -> str:
|
| 108 |
+
"""Canonical YuE2 tag: "verse 2" -> [Verse], "pre chorus" -> [Pre-Chorus], "hook" -> [Chorus]."""
|
| 109 |
+
key = re.sub(r"\s+", " ", re.sub(r"[\s_:-]*\d+$", "", label.strip().lower()))
|
| 110 |
+
return f"[{_SECTION_NAMES.get(key, label.strip().title())}]"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
def normalize_lyrics(lyrics: str) -> str:
|
| 114 |
+
"""Canonical tags, each alone on its own line, with a blank line between sections."""
|
| 115 |
out = []
|
| 116 |
text = (lyrics or "").replace("\r", "").replace("\\n", "\n")
|
| 117 |
for line in text.split("\n"):
|
| 118 |
+
m = _TAG_LINE_RE.match(line)
|
| 119 |
+
if m and m.group(1).strip():
|
| 120 |
+
out += ["", _section_tag(m.group(1))]
|
| 121 |
+
if m.group(2).strip():
|
| 122 |
+
out.append(m.group(2).strip())
|
|
|
|
| 123 |
else:
|
| 124 |
out.append(line.rstrip())
|
| 125 |
text = "\n".join(out)
|
| 126 |
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 127 |
|
| 128 |
|
| 129 |
+
def has_words(lyrics: str) -> bool:
|
| 130 |
+
return any(line.strip() and not _TAG_LINE_RE.match(line) for line in (lyrics or "").split("\n"))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
def compose(description: str, duration: float, instrumental: bool = False) -> dict:
|
| 134 |
+
"""Return {title, tags, style, lyrics}."""
|
| 135 |
user = f"Song description: {description.strip()}\nTarget duration: {int(duration)} seconds."
|
| 136 |
if instrumental:
|
| 137 |
+
user += "\nThis song is INSTRUMENTAL: no vocals at all. The lyrics are section tags only, with no words."
|
| 138 |
else:
|
| 139 |
user += "\nThe song has sung vocals (pick a fitting singer if the description does not specify one)."
|
| 140 |
+
required = ("title", "style", "lyrics")
|
| 141 |
last_error = None
|
| 142 |
for _ in range(2):
|
| 143 |
try:
|
| 144 |
data = _parse_json(_chat(COMPOSER_SYSTEM, user), required)
|
| 145 |
+
lyrics = normalize_lyrics(str(data["lyrics"]))
|
| 146 |
+
if not instrumental and not has_words(lyrics):
|
| 147 |
+
raise ValueError("no sung words in the lyrics of a vocal song")
|
| 148 |
break
|
| 149 |
except (ValueError, json.JSONDecodeError) as e:
|
| 150 |
print(f"[composer] bad reply: {e}", flush=True)
|
|
|
|
| 152 |
else:
|
| 153 |
raise RuntimeError("The composer returned an unusable reply; please try again.") from last_error
|
| 154 |
|
| 155 |
+
style = " ".join(str(data["style"]).split()).strip().strip('"\'')
|
| 156 |
+
if instrumental:
|
| 157 |
+
# Section tags only: the engine renders tag-only lyrics without a vocal line.
|
| 158 |
+
tags_only = [l for l in lyrics.split("\n") if _TAG_LINE_RE.match(l)]
|
| 159 |
+
lyrics = "\n\n".join(tags_only) or "[Instrumental]"
|
| 160 |
+
if not style.lower().startswith("instrumental"):
|
| 161 |
+
style = f"Instrumental, {style}"
|
| 162 |
+
if "no vocals" not in style.lower():
|
| 163 |
+
style = f"{style.rstrip(' ,.')}, {NO_VOCALS}"
|
| 164 |
title = str(data["title"]).strip().strip('"\'')[:80] or "Untitled"
|
| 165 |
tags = str(data.get("tags", "")).strip().strip(".")[:120]
|
| 166 |
+
return {"title": title, "tags": tags, "style": style, "lyrics": lyrics}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
def visual_word(title: str, tags: str, lyrics: str, description: str) -> str:
|
engine.py
CHANGED
|
@@ -1,293 +1,143 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
inside a `@spaces.GPU` function.
|
| 7 |
"""
|
| 8 |
|
| 9 |
-
import
|
|
|
|
|
|
|
| 10 |
import time
|
| 11 |
|
| 12 |
-
import spaces # must precede torch (ZeroGPU patches CUDA init)
|
| 13 |
import numpy as np
|
| 14 |
import torch
|
| 15 |
-
import torch.nn as _nn
|
| 16 |
-
from huggingface_hub import snapshot_download
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
FRAME_RATE = float(PIPE.frame_rate)
|
| 32 |
-
MAX_FRAMES = 9000
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
f"The assembled prompt has {input_ids.shape[1]} tokens; the maximum is {P._MAX_PROMPT_TOKENS}."
|
| 47 |
-
)
|
| 48 |
-
unconditional_ids = input_ids.clone()
|
| 49 |
-
unconditional_ids[:, 1:-2] = P._AUDIO_CFG_TOKEN_ID
|
| 50 |
-
return torch.cat((input_ids, unconditional_ids), dim=0).to(device)
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
# ── AoTI kernels ─────────────────────────────────────────────────────────────
|
| 54 |
-
# The transformer artifact is static over full 689-latent chunks; the once-per-song
|
| 55 |
-
# final short chunk falls back to eager.
|
| 56 |
-
_AOTI_DIR = snapshot_download(AOTI_ID)
|
| 57 |
-
_eager_transformer_forward = PIPE.transformer.forward
|
| 58 |
-
spaces.aoti_load_from_package_dir(PIPE.transformer, f"{_AOTI_DIR}/transformer")
|
| 59 |
-
_aoti_transformer_forward = PIPE.transformer.forward
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _guarded_transformer_forward(hidden_states, timestep, encoder_hidden_states, return_dict=True):
|
| 63 |
-
if hidden_states.shape[-1] == 689:
|
| 64 |
-
out = _aoti_transformer_forward(hidden_states, timestep, encoder_hidden_states)
|
| 65 |
-
if not isinstance(out, Transformer2DModelOutput):
|
| 66 |
-
out = Transformer2DModelOutput(sample=out[0] if isinstance(out, (tuple, list)) else out)
|
| 67 |
-
return out
|
| 68 |
-
return _eager_transformer_forward(hidden_states, timestep, encoder_hidden_states, return_dict=return_dict)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
PIPE.transformer.forward = _guarded_transformer_forward
|
| 72 |
-
spaces.aoti_load_from_package_dir(PIPE.vocoder, f"{_AOTI_DIR}/vocoder")
|
| 73 |
-
|
| 74 |
-
# AoTI LM decode step, one artifact per StaticCache bucket; eager per-frame glue. Eager
|
| 75 |
-
# full-sequence prefill writes directly into each artifact's cache buffers (aliased
|
| 76 |
-
# StaticCache), matching eager exactly.
|
| 77 |
-
_LM = PIPE.language_model
|
| 78 |
-
_BUCKETS = [1024, 2048, 4096, 8192]
|
| 79 |
-
_STOP_CHECK_INTERVAL = 25
|
| 80 |
-
|
| 81 |
-
_lm_headless = _copy.copy(_LM)
|
| 82 |
-
_lm_headless._modules = dict(_LM._modules) # nn.Module shallow copies share _modules
|
| 83 |
-
_lm_headless.lm_head = _nn.Identity()
|
| 84 |
-
_lm_headless.generation_config = _copy.deepcopy(_LM.generation_config)
|
| 85 |
-
_lm_headless.generation_config.cache_implementation = "static"
|
| 86 |
-
|
| 87 |
-
_LM_STEPS = {}
|
| 88 |
-
for _bucket in _BUCKETS:
|
| 89 |
-
_exportable = TorchExportableModuleForDecoderOnlyLM(
|
| 90 |
-
_lm_headless, batch_size=2, max_cache_len=_bucket, device="cuda"
|
| 91 |
-
)
|
| 92 |
-
for _m in _exportable.modules():
|
| 93 |
-
_m._non_persistent_buffers_set.clear()
|
| 94 |
-
spaces.aoti_load_from_package_dir(_exportable.model, f"{_AOTI_DIR}/lm_step_{_bucket}")
|
| 95 |
-
_LM_STEPS[_bucket] = _exportable.model
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def _aliased_cache(step_module, bucket):
|
| 99 |
-
cache = StaticCache(max_cache_len=bucket, config=_LM.config.get_text_config())
|
| 100 |
-
cache.early_initialization(
|
| 101 |
-
2, _LM.config.num_key_value_heads, _LM.config.head_dim, _LM.dtype, torch.device("cuda")
|
| 102 |
-
)
|
| 103 |
-
for i, layer in enumerate(cache.layers):
|
| 104 |
-
layer.keys = step_module.get_buffer(f"key_cache_{i}")
|
| 105 |
-
layer.values = step_module.get_buffer(f"value_cache_{i}")
|
| 106 |
-
layer.cumulative_length = step_module.get_buffer(f"cumulative_length_{i}")
|
| 107 |
-
layer.keys.zero_()
|
| 108 |
-
layer.values.zero_()
|
| 109 |
-
layer.cumulative_length.zero_()
|
| 110 |
-
return cache
|
| 111 |
|
|
|
|
| 112 |
|
| 113 |
-
def _hop_lm_cache(src_bucket, dst_bucket, used):
|
| 114 |
-
src, dst = _LM_STEPS[src_bucket], _LM_STEPS[dst_bucket]
|
| 115 |
-
for i in range(_LM.config.num_hidden_layers):
|
| 116 |
-
dst.get_buffer(f"key_cache_{i}")[:, :, :used] = src.get_buffer(f"key_cache_{i}")[:, :, :used]
|
| 117 |
-
dst.get_buffer(f"value_cache_{i}")[:, :, :used] = src.get_buffer(f"value_cache_{i}")[:, :, :used]
|
| 118 |
-
dst.get_buffer(f"cumulative_length_{i}").copy_(src.get_buffer(f"cumulative_length_{i}"))
|
| 119 |
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
-
def _iter_frames_aoti(text_ids, max_frames, generator=None):
|
| 122 |
-
import diffusers.modular_pipelines.minimax_music3.encoders as P
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
step = _LM_STEPS[bucket]
|
| 129 |
-
cache = _aliased_cache(step, bucket)
|
| 130 |
-
prompt_embeds = _LM.model.embed_tokens(text_ids)
|
| 131 |
-
output = _LM.model(
|
| 132 |
-
inputs_embeds=prompt_embeds,
|
| 133 |
-
past_key_values=cache,
|
| 134 |
-
cache_position=torch.arange(prompt_len, device="cuda"),
|
| 135 |
-
use_cache=True,
|
| 136 |
-
)
|
| 137 |
-
last_hidden = output.last_hidden_state[:, -1]
|
| 138 |
-
|
| 139 |
-
vocab_mask = torch.ones(_LM.config.vocab_size, dtype=torch.bool, device="cuda")
|
| 140 |
-
vocab_mask[P._AUDIO_CODE_OFFSET : P._AUDIO_CODE_OFFSET + P._SEMANTIC_VOCAB_SIZE] = False
|
| 141 |
-
vocab_mask[P._AUDIO_END_TOKEN_ID] = False
|
| 142 |
-
|
| 143 |
-
emitted = 0
|
| 144 |
-
position = prompt_len
|
| 145 |
-
pending = []
|
| 146 |
-
for frame_index in range(max_frames + 1):
|
| 147 |
-
if position + 2 >= bucket:
|
| 148 |
-
new_bucket = bucket * 2
|
| 149 |
-
_hop_lm_cache(bucket, new_bucket, position)
|
| 150 |
-
bucket = new_bucket
|
| 151 |
-
step = _LM_STEPS[bucket]
|
| 152 |
-
logits = _LM.lm_head(last_hidden).float()
|
| 153 |
-
logits = logits.masked_fill(vocab_mask, -float("inf"))
|
| 154 |
-
conditional, unconditional = logits[0:1], logits[1:2]
|
| 155 |
-
guided = unconditional + (conditional - unconditional) * P._AR_CFG_SCALE
|
| 156 |
-
threshold = torch.topk(conditional, P._AR_CFG_TOP_K, dim=-1).values[..., -1, None]
|
| 157 |
-
guided = guided.masked_fill(conditional < threshold, -float("inf"))
|
| 158 |
-
guided = guided.masked_fill(vocab_mask.unsqueeze(0), -float("inf"))
|
| 159 |
-
sampled = P._sample_top_k(guided, generator)
|
| 160 |
-
semantic_code = (sampled - P._AUDIO_CODE_OFFSET).clamp_min(0).repeat(2)
|
| 161 |
-
frame_codes, depth_hidden = P._generate_depth_codes(PIPE, last_hidden, semantic_code, generator)
|
| 162 |
-
frame_hidden = torch.cat((last_hidden[:1].clone(), depth_hidden), dim=-1) if frame_index > 0 else None
|
| 163 |
-
pending.append((sampled, frame_hidden))
|
| 164 |
-
if len(pending) >= _STOP_CHECK_INTERVAL or frame_index == max_frames:
|
| 165 |
-
stop_flags = torch.cat([s == P._AUDIO_END_TOKEN_ID for s, _ in pending]).tolist()
|
| 166 |
-
for flag, (_, fh) in zip(stop_flags, pending):
|
| 167 |
-
if flag:
|
| 168 |
-
return
|
| 169 |
-
if fh is not None:
|
| 170 |
-
emitted += 1
|
| 171 |
-
yield fh
|
| 172 |
-
if emitted >= max_frames:
|
| 173 |
-
return
|
| 174 |
-
pending = []
|
| 175 |
-
feedback = P._embed_audio_frame(PIPE, frame_codes)
|
| 176 |
-
last_hidden = step(inputs_embeds=feedback, cache_position=torch.tensor([position], device="cuda"))[:, -1]
|
| 177 |
-
position += 1
|
| 178 |
-
for _, fh in pending:
|
| 179 |
-
if fh is not None:
|
| 180 |
-
yield fh
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
PIPE._iter_frames = _iter_frames_aoti
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
# ── Windowed flow-matching decode + vocoder ──────────────────────────────────
|
| 187 |
-
_CHUNK, _HOP, _HOP_SAMPLES = 200, 100, 86 * 512
|
| 188 |
-
_CROP_RIGHT_SAMPLES = (344 - 86) * 512
|
| 189 |
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
condition = condition.to(PIPE.transformer.dtype)
|
| 196 |
-
latents = torch.randn(
|
| 197 |
-
(1, PIPE.transformer.config.in_channels, condition.shape[1]),
|
| 198 |
-
generator=generator, device="cuda", dtype=condition.dtype,
|
| 199 |
-
)
|
| 200 |
-
overlap, noise_prompt = 0, None
|
| 201 |
-
if previous_latent is not None:
|
| 202 |
-
overlap = min(previous_latent.shape[-1], latents.shape[-1])
|
| 203 |
-
noise_prompt = latents[..., :overlap].clone()
|
| 204 |
-
condition[:, :overlap] = previous_condition[:, :overlap]
|
| 205 |
-
condition_input = torch.cat((condition, torch.zeros_like(condition)), dim=0)
|
| 206 |
-
PIPE.scheduler.set_timesteps(sigmas=np.linspace(1.0, 1.0 / steps, steps), device="cuda")
|
| 207 |
-
for timestep in PIPE.scheduler.timesteps:
|
| 208 |
-
if overlap > 0:
|
| 209 |
-
t = timestep.to(latents.dtype)
|
| 210 |
-
latents[..., :overlap] = (1.0 - (1.0 - 1e-6) * t) * noise_prompt + t * previous_latent[..., :overlap]
|
| 211 |
-
velocity = PIPE.transformer(
|
| 212 |
-
latents.expand(2, -1, -1).contiguous(), timestep.expand(2).to(latents.dtype), condition_input
|
| 213 |
-
).sample
|
| 214 |
-
velocity = velocity[1:2] + guidance * (velocity[0:1] - velocity[1:2])
|
| 215 |
-
latents = PIPE.scheduler.step(velocity, timestep, latents).prev_sample
|
| 216 |
-
if overlap > 0:
|
| 217 |
-
latents[..., :overlap] = previous_latent[..., :overlap]
|
| 218 |
-
overlap_start = max(0, latents.shape[-1] - 2 * 172)
|
| 219 |
-
overlap_end = max(overlap_start, latents.shape[-1] - 172)
|
| 220 |
-
carry = (latents[..., overlap_start:overlap_end], condition[:, overlap_start:overlap_end])
|
| 221 |
-
waveform = PIPE.vocoder(latents.to(PIPE.vocoder.dtype)).float().clamp(-1.0, 1.0)[0]
|
| 222 |
-
return waveform, carry
|
| 223 |
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
for hidden in PIPE._iter_frames(text_ids, max_frames, ar_generator):
|
| 231 |
-
frames.append(hidden)
|
| 232 |
-
window_start = windows_done * _HOP
|
| 233 |
-
if len(frames) > window_start + _CHUNK:
|
| 234 |
-
window = torch.stack(frames[window_start : window_start + _CHUNK], dim=1)
|
| 235 |
-
waveform, carry = _decode_window(window, carry, dit_generator, steps, guidance)
|
| 236 |
-
left = 0 if windows_done == 0 else _HOP_SAMPLES
|
| 237 |
-
windows_done += 1
|
| 238 |
-
yield waveform[:, left : waveform.shape[-1] - _CROP_RIGHT_SAMPLES]
|
| 239 |
-
if not frames:
|
| 240 |
-
raise RuntimeError("The model generated zero audio frames; try different lyrics or a longer duration.")
|
| 241 |
-
total = len(frames)
|
| 242 |
-
window_starts = [0] if total <= _CHUNK else list(range(0, total - _HOP, _HOP))
|
| 243 |
-
for w in range(windows_done, len(window_starts)):
|
| 244 |
-
window_start = window_starts[w]
|
| 245 |
-
window = torch.stack(frames[window_start : min(window_start + _CHUNK, total)], dim=1)
|
| 246 |
-
waveform, carry = _decode_window(window, carry, dit_generator, steps, guidance)
|
| 247 |
-
left = 0 if w == 0 else _HOP_SAMPLES
|
| 248 |
-
right = _CROP_RIGHT_SAMPLES if w < len(window_starts) - 1 else 0
|
| 249 |
-
yield waveform[:, left : waveform.shape[-1] - right]
|
| 250 |
|
| 251 |
|
| 252 |
-
def
|
| 253 |
-
|
|
|
|
| 254 |
|
| 255 |
|
| 256 |
-
def
|
| 257 |
-
"""
|
| 258 |
-
return
|
| 259 |
|
| 260 |
|
| 261 |
-
def validate(
|
| 262 |
"""CPU-side input validation (raise ValueError with a user-facing message).
|
| 263 |
|
| 264 |
Runs before the ZeroGPU call: exceptions raised inside the GPU worker lose their message.
|
| 265 |
"""
|
| 266 |
-
|
| 267 |
lyrics = (lyrics or "").strip()
|
| 268 |
-
if not
|
| 269 |
-
raise ValueError("A
|
| 270 |
if not lyrics:
|
| 271 |
-
raise ValueError("Lyrics are required (
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
|
| 275 |
@torch.inference_mode()
|
| 276 |
-
def generate_wav(
|
| 277 |
-
"""Run
|
| 278 |
|
| 279 |
Returns (int16 stereo ndarray [samples, 2], sample_rate, audio_seconds, wall_seconds).
|
| 280 |
"""
|
| 281 |
-
|
| 282 |
-
lyrics = (lyrics or "").strip()
|
| 283 |
-
steps, guidance = int(steps), float(guidance)
|
| 284 |
-
text_ids = _encode_prompt(caption, lyrics, "cuda")
|
| 285 |
-
max_frames = min(int(float(duration) * FRAME_RATE), MAX_FRAMES)
|
| 286 |
-
ar_generator = torch.Generator("cuda").manual_seed(int(seed))
|
| 287 |
-
dit_generator = torch.Generator("cuda").manual_seed(int(seed) + 1)
|
| 288 |
-
|
| 289 |
start = time.time()
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""YuE2-3B inference engine for ZeroGPU.
|
| 2 |
|
| 3 |
+
Wraps the official `yue2_infer` package shipped in the m-a-p/YuE2-3B repo: one AR/NAR backbone
|
| 4 |
+
plans a chord-annotated ABC score, writes 25 Hz semantic tokens, turns them into acoustic latents
|
| 5 |
+
by flow matching, and YuE2-Vae decodes 48 kHz stereo. Everything here runs at import time (model
|
| 6 |
+
load) except `generate_wav`, which must be called from inside a `@spaces.GPU` function.
|
| 7 |
"""
|
| 8 |
|
| 9 |
+
import re
|
| 10 |
+
import subprocess
|
| 11 |
+
import sys
|
| 12 |
import time
|
| 13 |
|
| 14 |
+
import spaces # noqa: F401 must precede torch (ZeroGPU patches CUDA init)
|
| 15 |
import numpy as np
|
| 16 |
import torch
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
MODEL_ID = "m-a-p/YuE2-3B"
|
| 19 |
+
VAE_ID = "m-a-p/YuE2-Vae"
|
| 20 |
+
# The inference package lives in the model repo. Its metadata hard-pins huggingface-hub 0.x, which
|
| 21 |
+
# Gradio 6 cannot run with, so it goes in without dependencies (the real ones are in requirements.txt).
|
| 22 |
+
WHEEL_URL = f"https://huggingface.co/{MODEL_ID}/resolve/main/yue2_infer-0.1.5-py3-none-any.whl"
|
| 23 |
|
| 24 |
+
try:
|
| 25 |
+
import yue2 # noqa: F401
|
| 26 |
+
except ImportError:
|
| 27 |
+
subprocess.run([sys.executable, "-m", "pip", "install", "--no-deps", "-q", WHEEL_URL], check=True)
|
| 28 |
|
| 29 |
+
from yue2 import YuE2Pipeline # noqa: E402
|
| 30 |
+
from yue2.modeling_vae import YuE2VAE # noqa: E402
|
| 31 |
+
from yue2.modeling_yue2 import YuE2ForCausalLM # noqa: E402
|
| 32 |
+
from yue2.protocol import CONTEXT, SongRequest, token_prefixes # noqa: E402
|
| 33 |
|
| 34 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
|
|
|
|
|
|
| 35 |
|
| 36 |
+
# YuE2Pipeline.__init__ caps the process at a 24 GiB memory fraction, which forces a real CUDA init
|
| 37 |
+
# under ZeroGPU's startup emulation; skip only that cap.
|
| 38 |
+
_set_memory_fraction = torch.cuda.set_per_process_memory_fraction
|
| 39 |
+
torch.cuda.set_per_process_memory_fraction = lambda *args, **kwargs: None
|
| 40 |
+
try:
|
| 41 |
+
PIPE = YuE2Pipeline.from_pretrained(MODEL_ID, vae=VAE_ID, device=DEVICE, progress=False)
|
| 42 |
+
finally:
|
| 43 |
+
torch.cuda.set_per_process_memory_fraction = _set_memory_fraction
|
| 44 |
|
| 45 |
+
# Weights are loaded here rather than lazily by the pipeline: transformers 5 takes `dtype` (the
|
| 46 |
+
# package passes `torch_dtype`), and the VAE stays resident next to the backbone, whereas the
|
| 47 |
+
# package's decode() moves the 7 GB backbone to the CPU and back for every song.
|
| 48 |
+
PIPE._model = YuE2ForCausalLM.from_pretrained(
|
| 49 |
+
PIPE.model_dir, local_files_only=True, dtype=torch.bfloat16
|
| 50 |
+
).eval().to(DEVICE)
|
| 51 |
+
VAE = YuE2VAE.from_pretrained(PIPE.vae_dir, decoder_only=True, device=DEVICE, local_files_only=True)
|
| 52 |
|
| 53 |
+
SAMPLE_RATE = int(VAE.config.sample_rate)
|
| 54 |
+
FRAME_RATE = SAMPLE_RATE / VAE.config.downsampling_ratio # semantic tokens per second of audio
|
| 55 |
+
ABC_MAX_TOKENS = PIPE.generation_config.abc.max_tokens
|
| 56 |
+
ODE_STEPS = PIPE.generation_config.ode_steps
|
| 57 |
+
MAX_AUDIO_SECONDS = 240.0
|
| 58 |
+
MAX_STYLE_CHARS, MAX_LYRICS_CHARS = 1500, 10000
|
| 59 |
+
FADE_SECONDS = 3.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
_TAG_LINE = re.compile(r"^\s*\[[^\]]*\]\s*$")
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
+
def is_instrumental(lyrics: str) -> bool:
|
| 65 |
+
"""True when the lyrics hold only section tags, i.e. there are no words to sing."""
|
| 66 |
+
return not any(line.strip() and not _TAG_LINE.match(line) for line in (lyrics or "").splitlines())
|
| 67 |
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
def _cot(lyrics: str) -> str:
|
| 70 |
+
# Full planning writes a melody+chords score whose lead is a Vocal voice, which YuE2 then sings;
|
| 71 |
+
# instrumentals skip the symbolic plan.
|
| 72 |
+
return "off" if is_instrumental(lyrics) else "full"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
+
def max_audio_seconds(duration: float) -> float:
|
| 76 |
+
"""Hard cap on the rendered length. YuE2 picks the song length from the lyrics (the composer
|
| 77 |
+
sizes them to `duration`); the cap only bounds GPU time, with headroom for intros and outros."""
|
| 78 |
+
return min(float(duration) * 1.3 + 15.0, MAX_AUDIO_SECONDS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
+
def _gpu_time(audio_seconds: float, abc_tokens: float) -> float:
|
| 82 |
+
# Fitted on ZeroGPU with this same package (hugging-apps/yue2-instrumental-cot-lora): ~104 tok/s
|
| 83 |
+
# while planning, ~0.265 s per audio-second for semantic decoding + VAE, ~0.0023 s per
|
| 84 |
+
# audio-second per ODE step, ~11 s fixed (worker start + VAE).
|
| 85 |
+
return 11.0 + abc_tokens / 104.0 + audio_seconds * (0.265 + 0.0023 * ODE_STEPS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
+
def estimate_gpu_seconds(duration: float) -> int:
|
| 89 |
+
"""ZeroGPU reservation: worst case (ABC plan at its token cap, song at its length cap) + 10%."""
|
| 90 |
+
return int(min(_gpu_time(max_audio_seconds(duration), ABC_MAX_TOKENS) * 1.1, 300))
|
| 91 |
|
| 92 |
|
| 93 |
+
def eta_seconds(duration: float) -> int:
|
| 94 |
+
"""Typical wall time for a song of ~`duration` seconds (plans run 1.6k-2.7k ABC tokens)."""
|
| 95 |
+
return int(_gpu_time(float(duration) * 1.1, 2200))
|
| 96 |
|
| 97 |
|
| 98 |
+
def validate(style: str, lyrics: str) -> None:
|
| 99 |
"""CPU-side input validation (raise ValueError with a user-facing message).
|
| 100 |
|
| 101 |
Runs before the ZeroGPU call: exceptions raised inside the GPU worker lose their message.
|
| 102 |
"""
|
| 103 |
+
style = (style or "").strip()
|
| 104 |
lyrics = (lyrics or "").strip()
|
| 105 |
+
if not style:
|
| 106 |
+
raise ValueError("A style description is required.")
|
| 107 |
if not lyrics:
|
| 108 |
+
raise ValueError("Lyrics are required (section tags only, e.g. [Instrumental], make an instrumental).")
|
| 109 |
+
if len(style) > MAX_STYLE_CHARS:
|
| 110 |
+
raise ValueError(f"Keep the style under {MAX_STYLE_CHARS} characters.")
|
| 111 |
+
if len(lyrics) > MAX_LYRICS_CHARS:
|
| 112 |
+
raise ValueError(f"Keep the lyrics under {MAX_LYRICS_CHARS} characters.")
|
| 113 |
+
prompt_tokens = len(token_prefixes(SongRequest(style=style, lyrics=lyrics, cot=_cot(lyrics)), PIPE.tokenizer))
|
| 114 |
+
if prompt_tokens + ABC_MAX_TOKENS + 3 + int(MAX_AUDIO_SECONDS * FRAME_RATE) > CONTEXT:
|
| 115 |
+
raise ValueError("The lyrics are too long for YuE2's context window; please shorten them.")
|
| 116 |
|
| 117 |
|
| 118 |
@torch.inference_mode()
|
| 119 |
+
def generate_wav(style: str, lyrics: str, duration: float, seed: int):
|
| 120 |
+
"""Run plan -> semantic tokens -> flow matching -> VAE. Must run inside @spaces.GPU.
|
| 121 |
|
| 122 |
Returns (int16 stereo ndarray [samples, 2], sample_rate, audio_seconds, wall_seconds).
|
| 123 |
"""
|
| 124 |
+
style, lyrics = (style or "").strip(), (lyrics or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
start = time.time()
|
| 126 |
+
plan = PIPE.plan(style=style, lyrics=lyrics, cot=_cot(lyrics), seed=int(seed))
|
| 127 |
+
max_tokens = int(max_audio_seconds(duration) * FRAME_RATE)
|
| 128 |
+
semantic = PIPE.generate_semantic(plan, sampling={"max_tokens": max_tokens})
|
| 129 |
+
latents = PIPE.synthesize(semantic)
|
| 130 |
+
audio = VAE.decode_tiled(
|
| 131 |
+
torch.from_numpy(latents).T.contiguous().unsqueeze(0), core_frames=1024, halo_frames=16, output_device="cpu"
|
| 132 |
+
)
|
| 133 |
+
wav = audio[0].clamp(-1.0, 1.0).T.contiguous().numpy()
|
| 134 |
+
if semantic.truncated: # stopped at the length cap: fade out rather than cut mid-phrase
|
| 135 |
+
n = min(len(wav), int(FADE_SECONDS * SAMPLE_RATE))
|
| 136 |
+
wav[-n:] *= np.linspace(1.0, 0.0, n, dtype=np.float32)[:, None]
|
| 137 |
+
print(
|
| 138 |
+
f"[engine] cot={plan.request.cot} abc_tokens={len(plan.abc_ids)} (truncated={plan.truncated}) "
|
| 139 |
+
f"semantic_tokens={len(semantic.tokens)}/{max_tokens} (truncated={semantic.truncated}) "
|
| 140 |
+
f"abc={plan.timing.get('seconds', 0):.1f}s semantic={semantic.timing['seconds']:.1f}s",
|
| 141 |
+
flush=True,
|
| 142 |
+
)
|
| 143 |
+
return (wav * 32767.0).astype(np.int16), SAMPLE_RATE, len(wav) / SAMPLE_RATE, time.time() - start
|
index.html
CHANGED
|
@@ -3,9 +3,10 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>
|
|
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700&family=
|
| 9 |
<script src="https://cdn.jsdelivr.net/npm/mp4-muxer@5.1.3/build/mp4-muxer.min.js"></script>
|
| 10 |
<style>
|
| 11 |
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -20,6 +21,7 @@
|
|
| 20 |
--accent-dim: oklch(0.40 0.005 260);
|
| 21 |
--accent-glow: rgba(255, 255, 255, 0.08);
|
| 22 |
--progress: oklch(0.72 0.14 155);
|
|
|
|
| 23 |
--text: rgba(255, 255, 255, 0.87);
|
| 24 |
--text-muted: rgba(255, 255, 255, 0.40);
|
| 25 |
--text-faint: rgba(255, 255, 255, 0.25);
|
|
@@ -45,25 +47,26 @@
|
|
| 45 |
|
| 46 |
/* ── Header ───────────────────────────────────────────────────────────── */
|
| 47 |
header { padding: 0 0 20px; text-align: left; }
|
| 48 |
-
/*
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
.wordmark
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
.wordmark-divider {
|
| 57 |
-
display: inline-block; width: 1px; height: calc(var(--h) * 0.56);
|
| 58 |
-
background: rgb(148, 148, 149);
|
| 59 |
-
margin-left: calc(var(--h) * 0.25); /* the PNG has no right padding: the "3" ends at its edge */
|
| 60 |
-
margin-right: calc(var(--h) * 0.25);
|
| 61 |
}
|
| 62 |
.wordmark-jam {
|
| 63 |
-
display: inline-block; font-family: '
|
| 64 |
font-size: calc(var(--h) * 0.62); font-weight: 500; letter-spacing: 0.11em; line-height: 1;
|
| 65 |
-
|
| 66 |
-
background: linear-gradient(90deg, #
|
| 67 |
-webkit-background-clip: text; background-clip: text; color: transparent;
|
| 68 |
transition: opacity 0.15s;
|
| 69 |
}
|
|
@@ -344,9 +347,9 @@
|
|
| 344 |
|
| 345 |
<header>
|
| 346 |
<div class="wordmark">
|
| 347 |
-
<img class="wordmark-
|
| 348 |
</div>
|
| 349 |
-
<p>Describe any song. An LLM writes it,
|
| 350 |
</header>
|
| 351 |
|
| 352 |
<div class="compose-box">
|
|
@@ -359,6 +362,7 @@
|
|
| 359 |
<option value="30">30s</option>
|
| 360 |
<option value="60" selected>1 min</option>
|
| 361 |
<option value="120">2 min</option>
|
|
|
|
| 362 |
</select>
|
| 363 |
</label>
|
| 364 |
<label class="pill"><input type="checkbox" id="instrumental-check" /><span>Instrumental</span></label>
|
|
@@ -369,7 +373,7 @@
|
|
| 369 |
</div>
|
| 370 |
<p class="upsell" id="upsell-studio">
|
| 371 |
Don't forget to
|
| 372 |
-
<a href="https://huggingface.co/
|
| 373 |
</p>
|
| 374 |
<div class="status-bar" id="status-bar"></div>
|
| 375 |
|
|
@@ -405,10 +409,10 @@
|
|
| 405 |
</div>
|
| 406 |
|
| 407 |
<footer>
|
| 408 |
-
Powered by <a href="https://huggingface.co/
|
| 409 |
-
<a href="https://
|
| 410 |
-
<a href="https://
|
| 411 |
-
<a href="https://huggingface.co/buckets/
|
| 412 |
</footer>
|
| 413 |
|
| 414 |
<script type="module">
|
|
@@ -631,7 +635,7 @@
|
|
| 631 |
ctx.fillText("Create your own song:", W / 2, 880);
|
| 632 |
ctx.fillStyle = "rgba(255,255,255,0.55)";
|
| 633 |
ctx.font = "500 33px 'Hanken Grotesk', sans-serif";
|
| 634 |
-
ctx.fillText("hf.co/spaces/
|
| 635 |
ctx.textAlign = "left";
|
| 636 |
}
|
| 637 |
|
|
@@ -927,7 +931,7 @@
|
|
| 927 |
).join("");
|
| 928 |
placeholder.innerHTML = `
|
| 929 |
<div class="placeholder-title">${esc(desc.slice(0, 80))}</div>
|
| 930 |
-
<div class="placeholder-sub">Writing lyrics &
|
| 931 |
<div class="fake-waveform">${bars}</div>
|
| 932 |
`;
|
| 933 |
outputSection.parentNode.insertBefore(placeholder, outputSection);
|
|
@@ -970,7 +974,7 @@
|
|
| 970 |
// Intermediate progress message
|
| 971 |
if (result.title) phTitle.textContent = result.title;
|
| 972 |
if (result.status === "generating") {
|
| 973 |
-
startEta(Number(result.eta) || 60, result.message || "
|
| 974 |
} else {
|
| 975 |
phSub.textContent = result.message || "Working…";
|
| 976 |
}
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>YuE2-Jam</title>
|
| 7 |
+
<link rel="icon" href="logo.svg" type="image/svg+xml" />
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700&family=Space+Grotesk:wght@500;600&display=swap" rel="stylesheet" />
|
| 10 |
<script src="https://cdn.jsdelivr.net/npm/mp4-muxer@5.1.3/build/mp4-muxer.min.js"></script>
|
| 11 |
<style>
|
| 12 |
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
| 21 |
--accent-dim: oklch(0.40 0.005 260);
|
| 22 |
--accent-glow: rgba(255, 255, 255, 0.08);
|
| 23 |
--progress: oklch(0.72 0.14 155);
|
| 24 |
+
--brand: #e07a5a; /* YuE2 terracotta (#aa482f on the official light site), lifted for dark */
|
| 25 |
--text: rgba(255, 255, 255, 0.87);
|
| 26 |
--text-muted: rgba(255, 255, 255, 0.40);
|
| 27 |
--text-faint: rgba(255, 255, 255, 0.25);
|
|
|
|
| 47 |
|
| 48 |
/* ── Header ───────────────────────────────────────────────────────────── */
|
| 49 |
header { padding: 0 0 20px; text-align: left; }
|
| 50 |
+
/* Lockup after the official YuE2 site: YuE mark + "YuE" in Space Grotesk 600 with a raised accent "2". */
|
| 51 |
+
.wordmark { --h: 26px; display: inline-flex; align-items: baseline; white-space: nowrap; margin-bottom: 6px; line-height: 1; }
|
| 52 |
+
.wordmark-mark { width: var(--h); height: var(--h); align-self: center; margin-right: calc(var(--h) * 0.32); }
|
| 53 |
+
.wordmark-name {
|
| 54 |
+
font-family: 'Space Grotesk', 'Hanken Grotesk', system-ui, sans-serif; font-weight: 600;
|
| 55 |
+
font-size: calc(var(--h) * 0.86); letter-spacing: -0.08em; color: var(--text);
|
| 56 |
+
}
|
| 57 |
+
.wordmark-name span {
|
| 58 |
+
display: inline-block; color: var(--brand); font-size: 0.7em; letter-spacing: 0;
|
| 59 |
+
margin-left: 0.14em; transform: translateY(-0.26em);
|
| 60 |
}
|
| 61 |
.wordmark-divider {
|
| 62 |
+
display: inline-block; width: 1px; height: calc(var(--h) * 0.56); align-self: center;
|
| 63 |
+
background: rgb(148, 148, 149); margin: 0 calc(var(--h) * 0.3);
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
.wordmark-jam {
|
| 66 |
+
display: inline-block; font-family: 'Space Grotesk', 'Hanken Grotesk', system-ui, sans-serif;
|
| 67 |
font-size: calc(var(--h) * 0.62); font-weight: 500; letter-spacing: 0.11em; line-height: 1;
|
| 68 |
+
text-decoration: none;
|
| 69 |
+
background: linear-gradient(90deg, #c4573a 0%, #e07a5a 50%, #f2a07c 100%);
|
| 70 |
-webkit-background-clip: text; background-clip: text; color: transparent;
|
| 71 |
transition: opacity 0.15s;
|
| 72 |
}
|
|
|
|
| 347 |
|
| 348 |
<header>
|
| 349 |
<div class="wordmark">
|
| 350 |
+
<img class="wordmark-mark" src="logo.svg" alt="" /><span class="wordmark-name">YuE<span>2</span></span><span class="wordmark-divider" aria-hidden="true"></span><a href="https://huggingface.co/m-a-p/YuE2-3B" target="_blank" class="wordmark-jam">jam</a>
|
| 351 |
</div>
|
| 352 |
+
<p>Describe any song. An LLM writes it, YuE2 sings it.</p>
|
| 353 |
</header>
|
| 354 |
|
| 355 |
<div class="compose-box">
|
|
|
|
| 362 |
<option value="30">30s</option>
|
| 363 |
<option value="60" selected>1 min</option>
|
| 364 |
<option value="120">2 min</option>
|
| 365 |
+
<option value="180">3 min</option>
|
| 366 |
</select>
|
| 367 |
</label>
|
| 368 |
<label class="pill"><input type="checkbox" id="instrumental-check" /><span>Instrumental</span></label>
|
|
|
|
| 373 |
</div>
|
| 374 |
<p class="upsell" id="upsell-studio">
|
| 375 |
Don't forget to
|
| 376 |
+
<a href="https://huggingface.co/m-a-p/YuE2-3B" target="_blank" rel="noopener">like the model <svg class="heart-icon" viewBox="0 0 24 24" fill="#e53935" aria-hidden="true"><path d="M12 21s-7.09-4.35-9.64-8.36C.66 10.02 1.24 6.6 4.1 5.02c2.2-1.22 4.9-.63 6.4 1.18.6.72 1.02 1.55 1.5 1.55s.9-.83 1.5-1.55c1.5-1.81 4.2-2.4 6.4-1.18 2.86 1.58 3.44 5 1.74 7.62C19.09 16.65 12 21 12 21z"/></svg></a>
|
| 377 |
</p>
|
| 378 |
<div class="status-bar" id="status-bar"></div>
|
| 379 |
|
|
|
|
| 409 |
</div>
|
| 410 |
|
| 411 |
<footer>
|
| 412 |
+
Powered by <a href="https://huggingface.co/m-a-p/YuE2-3B" target="_blank">YuE2-3B</a> (CC BY-NC 4.0) ·
|
| 413 |
+
<a href="https://map-yue2.github.io/" target="_blank">Project page</a> ·
|
| 414 |
+
<a href="https://github.com/multimodal-art-projection/YuE" target="_blank">GitHub</a> ·
|
| 415 |
+
<a href="https://huggingface.co/buckets/Mike0021/yue2-jam-community" target="_blank">Community bucket</a>
|
| 416 |
</footer>
|
| 417 |
|
| 418 |
<script type="module">
|
|
|
|
| 635 |
ctx.fillText("Create your own song:", W / 2, 880);
|
| 636 |
ctx.fillStyle = "rgba(255,255,255,0.55)";
|
| 637 |
ctx.font = "500 33px 'Hanken Grotesk', sans-serif";
|
| 638 |
+
ctx.fillText("hf.co/spaces/Mike0021/YuE2-Jam", W / 2, 930);
|
| 639 |
ctx.textAlign = "left";
|
| 640 |
}
|
| 641 |
|
|
|
|
| 931 |
).join("");
|
| 932 |
placeholder.innerHTML = `
|
| 933 |
<div class="placeholder-title">${esc(desc.slice(0, 80))}</div>
|
| 934 |
+
<div class="placeholder-sub">Writing lyrics & style…</div>
|
| 935 |
<div class="fake-waveform">${bars}</div>
|
| 936 |
`;
|
| 937 |
outputSection.parentNode.insertBefore(placeholder, outputSection);
|
|
|
|
| 974 |
// Intermediate progress message
|
| 975 |
if (result.title) phTitle.textContent = result.title;
|
| 976 |
if (result.status === "generating") {
|
| 977 |
+
startEta(Number(result.eta) || 60, result.message || "YuE2 is recording…");
|
| 978 |
} else {
|
| 979 |
phSub.textContent = result.message || "Working…";
|
| 980 |
}
|
logo.svg
ADDED
|
|
logo_dark.png
DELETED
|
Binary file (57.3 kB)
|
|
|
requirements.txt
CHANGED
|
@@ -1,6 +1,11 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
| 2 |
transformers==5.15.0
|
|
|
|
| 3 |
accelerate
|
|
|
|
|
|
|
| 4 |
scipy
|
| 5 |
spaces
|
| 6 |
openai
|
|
|
|
| 1 |
+
# yue2_infer itself is installed by engine.py with --no-deps from the m-a-p/YuE2-3B repo: its metadata
|
| 2 |
+
# hard-pins huggingface-hub==0.36.2, which conflicts with Gradio 6. These are its real runtime deps.
|
| 3 |
+
torch==2.10.0
|
| 4 |
transformers==5.15.0
|
| 5 |
+
diffusers==0.41.0
|
| 6 |
accelerate
|
| 7 |
+
safetensors
|
| 8 |
+
tiktoken
|
| 9 |
scipy
|
| 10 |
spaces
|
| 11 |
openai
|