File size: 10,502 Bytes
de0fe6c 4fa21e4 de0fe6c 4fa21e4 1226c3a 4fa21e4 1226c3a 4fa21e4 1226c3a 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 1226c3a 4fa21e4 1226c3a 4fa21e4 1226c3a 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 de0fe6c 4fa21e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | """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
|