YuE2-Jam / composer.py
youngsemicolon
Claude Opus 5.5
YuE2-Jam: run m-a-p/YuE2-3B instead of MiniMax Music 3, rebrand for YuE2
de0fe6c
Raw History Blame Contribute Delete
10.5 kB
"""LLM composer: plain-English song description -> YuE2 inputs.
Produces a title, short display tags, a YuE2 style prompt (comma-separated descriptors: language,
genre, singer, instruments, mood, tempo, as in the official examples) and section-tagged lyrics.
Runs on the HF Inference Router (no GPU).
"""
import json
import os
import re
import time
from openai import OpenAI
ROUTER_URL = "https://router.huggingface.co/v1"
# (model, timeout_s, extra_body). Measured per compose (~600 output tokens): gpt-oss-120b on Groq
# with low reasoning ~2s; DeepSeek-V4-Flash with thinking disabled ~7s via :fastest (Fireworks),
# 14-35s on DeepInfra. Thinking/reasoning must be off: reasoning tokens otherwise dominate latency
# (6-27s). Under load any provider can 429 transiently, so the chain is walked twice.
_NO_THINK = {"thinking": {"type": "disabled"}}
COMPOSER_MODELS = (
("openai/gpt-oss-120b:groq", 30, {"reasoning_effort": "low"}),
("deepseek-ai/DeepSeek-V4-Flash-0731:fastest", 45, _NO_THINK),
("deepseek-ai/DeepSeek-V4-Flash-0731:deepinfra", 75, _NO_THINK),
("deepseek-ai/DeepSeek-V4-Flash-0731:novita", 100, _NO_THINK),
)
VISUAL_MODELS = (
("openai/gpt-oss-120b:groq", 30, {"reasoning_effort": "low"}),
("deepseek-ai/DeepSeek-V4-Flash-0731:fastest", 45, _NO_THINK),
)
# YuE2's native section labels (the ABC planner marks the same sections in its score).
SECTIONS = ("Intro", "Verse", "Pre-Chorus", "Chorus", "Bridge", "Interlude", "Instrumental", "Outro")
_SECTION_ALIASES = {
"pre chorus": "Pre-Chorus", "prechorus": "Pre-Chorus", "post-chorus": "Chorus", "post chorus": "Chorus",
"hook": "Chorus", "refrain": "Chorus", "break": "Interlude", "solo": "Interlude", "inst": "Instrumental",
}
_SECTION_NAMES = {s.lower(): s for s in SECTIONS} | _SECTION_ALIASES
NO_VOCALS = "no vocals, no singing, no choir, no spoken words"
_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}"."""
_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."""
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.
Given a song description and a target duration, produce:
1. title: a short, catchy song title (2-5 words, no quotes).
2. tags: 3-5 short comma-separated style tags for a music feed card, e.g. "synth-pop, female vocals, 120 bpm, euphoric".
3. {_STYLE_RULES}
4. {_LYRICS_RULES}
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.
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."""
VISUAL_SYSTEM = (
"Reply with exactly ONE concrete visual noun (a physical object, animal, or natural element) "
"that captures the essence of this song. No explanation, no punctuation, just the single word."
)
_TAG_LINE_RE = re.compile(r"^\s*\[([^\]]*)\]\s*(.*)$")
def _client():
key = os.environ.get("HF_TOKEN", "")
if not key:
raise RuntimeError("HF_TOKEN is not configured on this Space (needed for the composer LLM).")
return OpenAI(base_url=ROUTER_URL, api_key=key, max_retries=0)
def _chat(system, user, *, temperature=0.8, max_tokens=6000, models=COMPOSER_MODELS, passes=2):
client = _client()
last_error = None
for attempt in range(passes):
for model, timeout, extra in models:
try:
completion = client.with_options(timeout=timeout).chat.completions.create(
model=model,
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
temperature=temperature,
max_tokens=max_tokens,
extra_body=extra,
)
text = completion.choices[0].message.content or ""
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
if not text:
raise ValueError(f"empty reply (finish_reason={completion.choices[0].finish_reason})")
return text
except Exception as e: # noqa: BLE001
print(f"[composer] {model} failed (pass {attempt + 1}): {type(e).__name__}: {e}", flush=True)
last_error = e
if attempt < passes - 1:
time.sleep(3)
raise RuntimeError("The composer model is overloaded right now; please try again in a moment.") from last_error
def _parse_json(text, required):
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
raise ValueError("no JSON object in composer reply")
data = json.loads(text[start : end + 1], strict=False) # tolerate raw newlines inside strings
missing = [k for k in required if not str(data.get(k, "")).strip()]
if missing:
raise ValueError(f"composer reply missing keys: {missing}")
return data
def _section_tag(label: str) -> str:
"""Canonical YuE2 tag: "verse 2" -> [Verse], "pre chorus" -> [Pre-Chorus], "hook" -> [Chorus]."""
key = re.sub(r"\s+", " ", re.sub(r"[\s_:-]*\d+$", "", label.strip().lower()))
return f"[{_SECTION_NAMES.get(key, label.strip().title())}]"
def normalize_lyrics(lyrics: str) -> str:
"""Canonical tags, each alone on its own line, with a blank line between sections."""
out = []
text = (lyrics or "").replace("\r", "").replace("\\n", "\n")
for line in text.split("\n"):
m = _TAG_LINE_RE.match(line)
if m and m.group(1).strip():
out += ["", _section_tag(m.group(1))]
if m.group(2).strip():
out.append(m.group(2).strip())
else:
out.append(line.rstrip())
text = "\n".join(out)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def has_words(lyrics: str) -> bool:
return any(line.strip() and not _TAG_LINE_RE.match(line) for line in (lyrics or "").split("\n"))
def compose(description: str, duration: float, instrumental: bool = False) -> dict:
"""Return {title, tags, style, lyrics}."""
user = f"Song description: {description.strip()}\nTarget duration: {int(duration)} seconds."
if instrumental:
user += "\nThis song is INSTRUMENTAL: no vocals at all. The lyrics are section tags only, with no words."
else:
user += "\nThe song has sung vocals (pick a fitting singer if the description does not specify one)."
required = ("title", "style", "lyrics")
last_error = None
for _ in range(2):
try:
data = _parse_json(_chat(COMPOSER_SYSTEM, user), required)
lyrics = normalize_lyrics(str(data["lyrics"]))
if not instrumental and not has_words(lyrics):
raise ValueError("no sung words in the lyrics of a vocal song")
break
except (ValueError, json.JSONDecodeError) as e:
print(f"[composer] bad reply: {e}", flush=True)
last_error = e
else:
raise RuntimeError("The composer returned an unusable reply; please try again.") from last_error
style = " ".join(str(data["style"]).split()).strip().strip('"\'')
if instrumental:
# Section tags only: the engine renders tag-only lyrics without a vocal line.
tags_only = [l for l in lyrics.split("\n") if _TAG_LINE_RE.match(l)]
lyrics = "\n\n".join(tags_only) or "[Instrumental]"
if not style.lower().startswith("instrumental"):
style = f"Instrumental, {style}"
if "no vocals" not in style.lower():
style = f"{style.rstrip(' ,.')}, {NO_VOCALS}"
title = str(data["title"]).strip().strip('"\'')[:80] or "Untitled"
tags = str(data.get("tags", "")).strip().strip(".")[:120]
return {"title": title, "tags": tags, "style": style, "lyrics": lyrics}
def visual_word(title: str, tags: str, lyrics: str, description: str) -> str:
"""One evocative noun for the cover-art prompt; falls back to the description's first words."""
fallback = " ".join((description or title or "music").split()[:2])
try:
text = _chat(
VISUAL_SYSTEM,
f"Title: {title}\nTags: {tags}\nLyrics: {lyrics[:300]}",
temperature=0.7,
max_tokens=2000,
models=VISUAL_MODELS,
passes=1,
)
word = text.split()[0].strip('."\'!,') if text.split() else ""
return word or fallback
except Exception as e: # noqa: BLE001
print(f"[composer] visual word failed: {e}", flush=True)
return fallback