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