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>

Files changed (8) hide show
  1. README.md +28 -19
  2. app.py +58 -42
  3. composer.py +52 -46
  4. engine.py +101 -251
  5. index.html +30 -26
  6. logo.svg +5 -0
  7. logo_dark.png +0 -0
  8. requirements.txt +6 -1
README.md CHANGED
@@ -1,51 +1,60 @@
1
  ---
2
- title: MiniMax-Music3-Jam
3
  emoji: 🎤
4
- colorFrom: pink
5
  colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.24.0
8
  app_file: app.py
9
  pinned: false
10
- license: mit
11
- short_description: Describe any song, MiniMax Music 3 writes & sings it
12
  startup_duration_timeout: 1h
13
  suggested_hardware: zero-a10g
14
  models:
15
- - MiniMaxAI/MiniMax-Music3
 
16
  - Tongyi-MAI/Z-Image-Turbo
17
  tags:
18
  - mcp-server
19
  ---
20
 
21
- # MiniMax-Music3-Jam
22
 
23
- Describe any song in plain English. An LLM writes the title, the tagged lyrics and the three-part
24
- structured caption (Global Metadata / Vocal Details / Arrangement) from the official prompting guide,
25
- then [MiniMax Music 3](https://huggingface.co/MiniMaxAI/MiniMax-Music3) generates the full song with
26
- vocals. Songs can be shared to a community feed (stored in the `victor/minimax-music3-community` bucket,
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, lyrics, global_metadata, vocal_details, arrangement}`.
33
- - **Sing** (ZeroGPU): `engine.py` is the diffusers `ModularPipeline` port of MiniMax Music 3 with the
34
- AoTI kernels from `diffusers-internal-dev/MiniMax-Music3-aoti` (same path as the official Space).
 
 
 
 
 
35
  Cover art comes from Z-Image-Turbo in the same GPU allocation.
36
- - **Share**: WAV + cover + `meta.json` are written to the bucket mounted at `/data`; the feed is served
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, caption, seed, duration, created_at, thumbnail?, community_url?}`.
43
- - `POST /generate(caption, lyrics, audio_duration, seed, title, tags, community)` for explicit inputs.
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://victor-minimax-music3-jam.hf.space/gradio_api/mcp`
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/victor/minimax-music3-community:/data`.
 
 
 
 
 
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
- """MiniMax-Music3-Jam: describe a song in plain English, an LLM writes the lyrics and the
2
- structured caption, MiniMax Music 3 sings it, and the song can be shared to a community feed
3
- backed by an HF bucket mounted at /data."""
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 MiniMax Music 3 + AoTI kernels onto the GPU)
34
 
35
  _HERE = os.path.dirname(os.path.abspath(__file__))
36
- BUCKET_ID = os.environ.get("COMMUNITY_BUCKET", "victor/minimax-music3-community")
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(caption, lyrics, duration, seed, cover_word, *args, **kwargs):
85
- return engine.estimate_gpu_seconds(duration, STEPS) + (12 if cover_word else 0)
86
 
87
 
88
- @spaces.GPU(duration=_gpu_seconds, size="xlarge")
89
- def _gpu_job(caption, lyrics, duration, seed, cover_word):
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(caption, lyrics, duration, seed, STEPS, GUIDANCE)
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 _wav_bytes(wav: np.ndarray, sr: int) -> bytes:
 
 
 
 
 
 
 
 
 
 
 
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", "caption", "duration", "audio_url", "thumb_url", "created_at", "seed")
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}.wav"
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(wav_bytes: bytes, cover: bytes | None, meta: dict) -> dict:
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
- with open(os.path.join(song_dir, f"{song_id}.wav"), "wb") as f:
169
- f.write(wav_bytes)
 
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
- "audio_url": f"{BUCKET_URL}/songs/{song_id}/{song_id}.wav",
 
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="MiniMax-Music3-Jam")
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, caption, lyrics, duration, seed, community, cover_word):
211
  """Shared tail of /create and /generate: GPU job, encode, optional share. Returns result dict."""
212
- engine.validate(caption, lyrics)
213
- out = _gpu_job(caption, lyrics, duration, seed, cover_word)
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
- wav_bytes = _wav_bytes(wav, sr)
221
  result = {
222
  "title": title,
223
  "tags": tags,
224
  "lyrics": lyrics,
225
- "caption": caption,
226
  "seed": seed,
227
  "duration": round(audio_seconds, 1),
228
  "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
229
- "audio": "data:audio/wav;base64," + base64.b64encode(wav_bytes).decode(),
230
  }
231
  if cover:
232
  result["thumbnail"] = "data:image/png;base64," + base64.b64encode(cover).decode()
233
  if community:
234
  try:
235
- shared = _share(wav_bytes, cover, {
236
  "title": title, "tags": tags, "description": description, "lyrics": lyrics,
237
- "caption": caption, "duration": round(audio_seconds, 1), "seed": seed,
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/lyrics/structured caption -> MiniMax Music 3 generates it.
257
  Streams JSON status messages ({"status": ...}) and finally the result
258
- ({audio, title, tags, lyrics, caption, seed, duration, created_at, thumbnail?, community_url?})."""
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 & structured caption…"})
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"MiniMax Music 3 is recording “{song['title']}”…",
275
  "title": song["title"], "tags": song["tags"], "lyrics": song["lyrics"],
276
- "eta": engine.estimate_gpu_seconds(duration, STEPS),
277
  })
278
  result = _run_song(
279
- description=description, title=song["title"], tags=song["tags"], caption=song["caption"],
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
- caption: str,
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 structured caption + tagged lyrics (no LLM).
300
- cover_word: optional visual noun for the Z-Image cover art. Returns the same JSON result shape as /create."""
 
 
 
 
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, caption=(caption or "").strip(), lyrics=lyrics,
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.png", include_in_schema=False)
331
  async def logo():
332
- return FileResponse(os.path.join(_HERE, "logo_dark.png"), media_type="image/png")
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 -> MiniMax Music 3 inputs.
2
 
3
- Produces a title, short display tags, tagged lyrics and the three-part Structured Caption
4
- (Global Metadata / Vocal Details / Arrangement) the model was trained on, following the
5
- official prompting guide. Runs on the HF Inference Router (no GPU).
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
- _CAPTION_CONTRACT = """The three caption fields follow the exact labeled style the model was trained on. Be concrete and musical; describe an energy arc and instrument lifecycles, never a static equipment list or decorative adjectives. Never contradict an explicit user constraint: instrumental stays instrumental; never reverse a required vocal gender, tempo limit, required instrument, or exclusion. Do not quote or paraphrase lyric lines inside the caption. Total caption length roughly 250-400 words.
 
 
 
 
 
 
 
34
 
35
- global_metadata: one paragraph, in order: "Basic Attributes: bpm is <number>. key is <letter>, and scale is <major|minor>. <Genre / Subgenre>." then "Global Emotional Progression: <how the emotion evolves from the opening through the final section>." then "Application Scenarios & Imagery: <two or three vivid listening scenarios>." then "Sonics & Production Profile: <soundstage, frequency balance, dynamics, production character>."
36
 
37
- vocal_details: one paragraph: "Vocal Gender & Timbre: Singer A (<Male|Female>), <timbre and register>." then "Vocal Style: <delivery, and how it shifts per section>." then "Harmony/Backing Vocals: <where harmonies or doubles appear and their character>." then "Vocal FX: <restrained treatment: reverb, delay, light compression>." For instrumental pieces write "Instrumental, no vocals." and name the instrument or texture carrying the lead melodic role.
38
 
39
- arrangement: one paragraph: "Instrument Lifecycle Description (Primary/Secondary Layering): Primary: <core instruments present start to finish and their role>. Secondary: <instruments that enter, exit or intensify, and in which sections>." then "Groove & Foundation Progression: <how drums, bass and groove develop across sections>." then "Embellishments, Textures & Spatial FX: <fills, textures, transitional gestures, stereo and space treatment where relevant>." State what enters, exits, changes or intensifies for every section of the song, aligned with the lyric section tags."""
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. {_LYRICS_RULES}
48
- 4-6. global_metadata, vocal_details, arrangement: a structured caption. {_CAPTION_CONTRACT}
49
- Unless the user explicitly asks for an instrumental, the song HAS a singer: vocal_details must describe that singer (gender, timbre, style) and must never say "Instrumental".
50
- Answer with ONLY a JSON object with keys: title, tags, lyrics, global_metadata, vocal_details, arrangement. Inside the lyrics string, separate lines with JSON newline escapes, never with the two literal characters backslash and n."""
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
- SECTION_TAG_RE = re.compile(r"^\s*\[(intro|verse|pre-chorus|chorus|post-chorus|bridge|instrumental|solo|outro)\]\s*$", re.I)
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
- """Put every section tag alone on its own line (the model drops words that share a tag's line)."""
106
  out = []
107
  text = (lyrics or "").replace("\r", "").replace("\\n", "\n")
108
  for line in text.split("\n"):
109
- m = re.match(r"^\s*(\[[^\]]+\])\s*(.*)$", line)
110
- if m and m.group(2).strip():
111
- out.append(m.group(1).lower())
112
- out.append(m.group(2).strip())
113
- elif m:
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, lyrics, caption, global_metadata, vocal_details, arrangement}."""
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. Use [instrumental] sections only, with no words."
126
  else:
127
  user += "\nThe song has sung vocals (pick a fitting singer if the description does not specify one)."
128
- required = ("title", "lyrics", "global_metadata", "vocal_details", "arrangement")
129
  last_error = None
130
  for _ in range(2):
131
  try:
132
  data = _parse_json(_chat(COMPOSER_SYSTEM, user), required)
133
- vd = str(data["vocal_details"]).lower()
134
- if not instrumental and vd.startswith("instrumental"):
135
- raise ValueError("vocal_details says instrumental for a vocal song")
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
- lyrics = normalize_lyrics(str(data["lyrics"]))
144
- if instrumental and not any(SECTION_TAG_RE.match(l) for l in lyrics.split("\n")):
145
- lyrics = "[instrumental]\n\n" + lyrics
 
 
 
 
 
 
146
  title = str(data["title"]).strip().strip('"\'')[:80] or "Untitled"
147
  tags = str(data.get("tags", "")).strip().strip(".")[:120]
148
- global_metadata = str(data["global_metadata"]).strip()
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
- """MiniMax Music 3 inference engine for ZeroGPU.
2
 
3
- Ported from the official MiniMaxAI/MiniMax-Music3 Space (diffusers ModularPipeline +
4
- AoTI kernels compiled for the RTX Pro 6000 ZeroGPU hardware). Everything here runs at
5
- import time (model load + AoTI binding) except `generate_wav`, which must be called from
6
- inside a `@spaces.GPU` function.
7
  """
8
 
9
- import copy as _copy
 
 
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
- from diffusers import ModularPipeline
19
- from diffusers.models.modeling_outputs import Transformer2DModelOutput
20
- from transformers import StaticCache
21
- from transformers.integrations.executorch import TorchExportableModuleForDecoderOnlyLM
 
22
 
23
- MODEL_ID = "MiniMaxAI/MiniMax-Music3"
24
- AOTI_ID = "diffusers-internal-dev/MiniMax-Music3-aoti"
 
 
25
 
26
- PIPE = ModularPipeline.from_pretrained(MODEL_ID)
27
- PIPE.load_components(dtype=torch.bfloat16)
28
- PIPE.to("cuda")
 
29
 
30
- SAMPLE_RATE = int(PIPE.sampling_rate)
31
- FRAME_RATE = float(PIPE.frame_rate)
32
- MAX_FRAMES = 9000
33
 
 
 
 
 
 
 
 
 
34
 
35
- def _encode_prompt(caption, lyrics, device):
36
- # Mirrors the modular TextEncoderStep: the AR stage is driven manually below.
37
- import diffusers.modular_pipelines.minimax_music3.encoders as P
 
 
 
 
38
 
39
- text = (
40
- f"{P._IM_START}{P._CAPTION_START}{P._clean_caption(caption)}{P._CAPTION_END}"
41
- f"{P._LYRICS_START}{P._normalize_lyrics(lyrics)}{P._LYRICS_END}{P._IM_END}{P._AUDIO_START}"
42
- )
43
- input_ids = PIPE.tokenizer(text, return_tensors="pt")["input_ids"]
44
- if input_ids.shape[1] > P._MAX_PROMPT_TOKENS:
45
- raise ValueError(
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
- prompt_len = text_ids.shape[1]
125
- bucket = _BUCKETS[0]
126
- while bucket < prompt_len + 16:
127
- bucket *= 2
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
- @torch.inference_mode()
192
- def _decode_window(hidden_window, previous, generator, steps, guidance):
193
- previous_latent, previous_condition = previous
194
- condition = PIPE.condition_encoder(hidden_window)
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
- @torch.inference_mode()
226
- def _stream_windows(text_ids, max_frames, ar_generator, dit_generator, steps, guidance):
227
- frames = []
228
- windows_done = 0
229
- carry = (None, None)
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 _to_int16(waveform):
253
- return (waveform.cpu().numpy().T * 32767.0).astype(np.int16)
 
254
 
255
 
256
- def estimate_gpu_seconds(duration, steps=30):
257
- """Fitted on-Space (xlarge): wall = 0.75*dur + 0.20*dur*(steps/30) + ~15s cold-worker margin."""
258
- return min(int(float(duration) * (0.75 + 0.20 * float(steps) / 30.0) + 15), 600)
259
 
260
 
261
- def validate(caption: str, lyrics: str) -> None:
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
- caption = (caption or "").strip()
267
  lyrics = (lyrics or "").strip()
268
- if not caption:
269
- raise ValueError("A music description (structured caption) is required.")
270
  if not lyrics:
271
- raise ValueError("Lyrics are required (use [instrumental] for songs without vocals).")
272
- _encode_prompt(caption, lyrics, "cpu") # raises ValueError past the token limit
 
 
 
 
 
 
273
 
274
 
275
  @torch.inference_mode()
276
- def generate_wav(caption: str, lyrics: str, duration: float, seed: int, steps: int = 30, guidance: float = 1.7):
277
- """Run the full AR -> windowed DiT -> vocoder pipeline. Must run inside @spaces.GPU.
278
 
279
  Returns (int16 stereo ndarray [samples, 2], sample_rate, audio_seconds, wall_seconds).
280
  """
281
- caption = (caption or "").strip()
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
- chunks = list(_stream_windows(text_ids, max_frames, ar_generator, dit_generator, steps, guidance))
291
- full = _to_int16(torch.cat(chunks, dim=-1))
292
- audio_seconds = full.shape[0] / SAMPLE_RATE
293
- return full, SAMPLE_RATE, audio_seconds, time.time() - start
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>MiniMax-Music3-Jam</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=Outfit:wght@500&display=swap" rel="stylesheet" />
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
- /* Wordmark geometry is derived from the logo PNG (1200x224: text rows 65-152, text runs to the
49
- right edge) and the reference lockup: divider 1/4 mark-height from the "3" and from "jam",
50
- divider = 0.56 mark-height tall, "jam" x-height = 0.75 x "Music 3" cap height, same baseline. */
51
- .wordmark { --h: 26px; display: inline-block; line-height: 0; white-space: nowrap; margin-bottom: 6px; }
52
- .wordmark-logo {
53
- height: var(--h); width: auto; display: inline-block;
54
- vertical-align: calc(var(--h) * -0.321); /* put the PNG's text baseline on the line baseline */
 
 
 
55
  }
56
  .wordmark-divider {
57
- display: inline-block; width: 1px; height: calc(var(--h) * 0.56);
58
- background: rgb(148, 148, 149); vertical-align: calc(var(--h) * -0.125);
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: 'Outfit', 'Hanken Grotesk', system-ui, sans-serif;
64
  font-size: calc(var(--h) * 0.62); font-weight: 500; letter-spacing: 0.11em; line-height: 1;
65
- vertical-align: baseline; text-decoration: none;
66
- background: linear-gradient(90deg, #d42a68 0%, #dd4b5a 50%, #ef7640 100%);
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-logo" src="logo.png" alt="MiniMax Music 3" /><span class="wordmark-divider" aria-hidden="true"></span><a href="https://huggingface.co/MiniMaxAI/MiniMax-Music3" target="_blank" class="wordmark-jam">jam</a>
348
  </div>
349
- <p>Describe any song. An LLM writes it, MiniMax Music 3 sings it.</p>
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/MiniMaxAI/MiniMax-Music3" 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>
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/MiniMaxAI/MiniMax-Music3" target="_blank">MiniMax Music 3</a> ·
409
- <a href="https://huggingface.co/spaces/multimodalart/minimax-music3-prompting-guide" target="_blank">Prompting guide</a> ·
410
- <a href="https://huggingface.co/spaces/MiniMaxAI/MiniMax-Music3" target="_blank">Official Studio</a> ·
411
- <a href="https://huggingface.co/buckets/victor/minimax-music3-community" target="_blank">Community bucket</a>
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/victor/MiniMax-Music3-Jam", W / 2, 930);
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 &amp; structured caption…</div>
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 || "MiniMax Music 3 is recording…");
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 &amp; 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
- git+https://github.com/huggingface/diffusers@82319140e0456fd58beff0a251c38825bfc310de
 
 
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