File size: 6,943 Bytes
de0fe6c
4fa21e4
de0fe6c
 
 
 
4fa21e4
 
de0fe6c
 
 
4fa21e4
 
de0fe6c
4fa21e4
 
 
de0fe6c
 
 
 
 
4fa21e4
de0fe6c
 
 
 
4fa21e4
de0fe6c
 
 
 
4fa21e4
de0fe6c
4fa21e4
de0fe6c
 
 
 
 
 
 
 
4fa21e4
de0fe6c
 
 
 
 
 
 
4fa21e4
de0fe6c
 
 
 
 
 
 
4fa21e4
de0fe6c
4fa21e4
 
de0fe6c
 
 
4fa21e4
 
de0fe6c
 
 
 
4fa21e4
 
de0fe6c
 
 
 
4fa21e4
 
de0fe6c
 
 
 
 
4fa21e4
 
de0fe6c
 
 
4fa21e4
 
de0fe6c
 
 
4fa21e4
 
de0fe6c
02fcb7b
4fa21e4
02fcb7b
4fa21e4
de0fe6c
4fa21e4
de0fe6c
 
4fa21e4
de0fe6c
 
 
 
 
 
 
 
02fcb7b
 
 
de0fe6c
 
02fcb7b
 
 
de0fe6c
4fa21e4
de0fe6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""YuE2-3B inference engine for ZeroGPU.

Wraps the official `yue2_infer` package shipped in the m-a-p/YuE2-3B repo: one AR/NAR backbone
plans a chord-annotated ABC score, writes 25 Hz semantic tokens, turns them into acoustic latents
by flow matching, and YuE2-Vae decodes 48 kHz stereo. Everything here runs at import time (model
load) except `generate_wav`, which must be called from inside a `@spaces.GPU` function.
"""

import re
import subprocess
import sys
import time

import spaces  # noqa: F401  must precede torch (ZeroGPU patches CUDA init)
import numpy as np
import torch

MODEL_ID = "m-a-p/YuE2-3B"
VAE_ID = "m-a-p/YuE2-Vae"
# The inference package lives in the model repo. Its metadata hard-pins huggingface-hub 0.x, which
# Gradio 6 cannot run with, so it goes in without dependencies (the real ones are in requirements.txt).
WHEEL_URL = f"https://huggingface.co/{MODEL_ID}/resolve/main/yue2_infer-0.1.5-py3-none-any.whl"

try:
    import yue2  # noqa: F401
except ImportError:
    subprocess.run([sys.executable, "-m", "pip", "install", "--no-deps", "-q", WHEEL_URL], check=True)

from yue2 import YuE2Pipeline  # noqa: E402
from yue2.modeling_vae import YuE2VAE  # noqa: E402
from yue2.modeling_yue2 import YuE2ForCausalLM  # noqa: E402
from yue2.protocol import CONTEXT, SongRequest, token_prefixes  # noqa: E402

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"

# YuE2Pipeline.__init__ caps the process at a 24 GiB memory fraction, which forces a real CUDA init
# under ZeroGPU's startup emulation; skip only that cap.
_set_memory_fraction = torch.cuda.set_per_process_memory_fraction
torch.cuda.set_per_process_memory_fraction = lambda *args, **kwargs: None
try:
    PIPE = YuE2Pipeline.from_pretrained(MODEL_ID, vae=VAE_ID, device=DEVICE, progress=False)
finally:
    torch.cuda.set_per_process_memory_fraction = _set_memory_fraction

# Weights are loaded here rather than lazily by the pipeline: transformers 5 takes `dtype` (the
# package passes `torch_dtype`), and the VAE stays resident next to the backbone, whereas the
# package's decode() moves the 7 GB backbone to the CPU and back for every song.
PIPE._model = YuE2ForCausalLM.from_pretrained(
    PIPE.model_dir, local_files_only=True, dtype=torch.bfloat16
).eval().to(DEVICE)
VAE = YuE2VAE.from_pretrained(PIPE.vae_dir, decoder_only=True, device=DEVICE, local_files_only=True)

SAMPLE_RATE = int(VAE.config.sample_rate)
FRAME_RATE = SAMPLE_RATE / VAE.config.downsampling_ratio  # semantic tokens per second of audio
ABC_MAX_TOKENS = PIPE.generation_config.abc.max_tokens
ODE_STEPS = PIPE.generation_config.ode_steps
MAX_AUDIO_SECONDS = 240.0
MAX_STYLE_CHARS, MAX_LYRICS_CHARS = 1500, 10000
FADE_SECONDS = 3.0

_TAG_LINE = re.compile(r"^\s*\[[^\]]*\]\s*$")


def is_instrumental(lyrics: str) -> bool:
    """True when the lyrics hold only section tags, i.e. there are no words to sing."""
    return not any(line.strip() and not _TAG_LINE.match(line) for line in (lyrics or "").splitlines())


def _cot(lyrics: str) -> str:
    # Full planning writes a melody+chords score whose lead is a Vocal voice, which YuE2 then sings;
    # instrumentals skip the symbolic plan.
    return "off" if is_instrumental(lyrics) else "full"


def max_audio_seconds(duration: float) -> float:
    """Hard cap on the rendered length. YuE2 picks the song length from the lyrics (the composer
    sizes them to `duration`); the cap only bounds GPU time, with headroom for intros and outros."""
    return min(float(duration) * 1.3 + 15.0, MAX_AUDIO_SECONDS)


def _gpu_time(audio_seconds: float, abc_tokens: float) -> float:
    # Fitted on ZeroGPU with this same package (hugging-apps/yue2-instrumental-cot-lora): ~104 tok/s
    # while planning, ~0.265 s per audio-second for semantic decoding + VAE, ~0.0023 s per
    # audio-second per ODE step, ~11 s fixed (worker start + VAE).
    return 11.0 + abc_tokens / 104.0 + audio_seconds * (0.265 + 0.0023 * ODE_STEPS)


def estimate_gpu_seconds(duration: float) -> int:
    """ZeroGPU reservation: worst case (ABC plan at its token cap, song at its length cap) + 10%."""
    return int(min(_gpu_time(max_audio_seconds(duration), ABC_MAX_TOKENS) * 1.1, 300))


def eta_seconds(duration: float) -> int:
    """Typical wall time for a song of ~`duration` seconds (plans run 1.6k-2.7k ABC tokens)."""
    return int(_gpu_time(float(duration) * 1.1, 2200))


def validate(style: str, lyrics: str) -> None:
    """CPU-side input validation (raise ValueError with a user-facing message).

    Runs before the ZeroGPU call: exceptions raised inside the GPU worker lose their message.
    """
    style = (style or "").strip()
    lyrics = (lyrics or "").strip()
    if not style:
        raise ValueError("A style description is required.")
    if not lyrics:
        raise ValueError("Lyrics are required (section tags only, e.g. [Instrumental], make an instrumental).")
    if len(style) > MAX_STYLE_CHARS:
        raise ValueError(f"Keep the style under {MAX_STYLE_CHARS} characters.")
    if len(lyrics) > MAX_LYRICS_CHARS:
        raise ValueError(f"Keep the lyrics under {MAX_LYRICS_CHARS} characters.")
    prompt_tokens = len(token_prefixes(SongRequest(style=style, lyrics=lyrics, cot=_cot(lyrics)), PIPE.tokenizer))
    if prompt_tokens + ABC_MAX_TOKENS + 3 + int(MAX_AUDIO_SECONDS * FRAME_RATE) > CONTEXT:
        raise ValueError("The lyrics are too long for YuE2's context window; please shorten them.")


@torch.inference_mode()
def generate_wav(style: str, lyrics: str, duration: float, seed: int):
    """Run plan -> semantic tokens -> flow matching -> VAE. Must run inside @spaces.GPU.

    Returns (int16 stereo ndarray [samples, 2], sample_rate, audio_seconds, wall_seconds).
    """
    style, lyrics = (style or "").strip(), (lyrics or "").strip()
    start = time.time()
    plan = PIPE.plan(style=style, lyrics=lyrics, cot=_cot(lyrics), seed=int(seed))
    max_tokens = int(max_audio_seconds(duration) * FRAME_RATE)
    semantic = PIPE.generate_semantic(plan, sampling={"max_tokens": max_tokens})
    latents = PIPE.synthesize(semantic)
    audio = VAE.decode_tiled(
        torch.from_numpy(latents).T.contiguous().unsqueeze(0), core_frames=1024, halo_frames=16, output_device="cpu"
    )
    wav = audio[0].clamp(-1.0, 1.0).T.contiguous().numpy()
    if semantic.truncated:  # stopped at the length cap: fade out rather than cut mid-phrase
        n = min(len(wav), int(FADE_SECONDS * SAMPLE_RATE))
        wav[-n:] *= np.linspace(1.0, 0.0, n, dtype=np.float32)[:, None]
    print(
        f"[engine] cot={plan.request.cot} abc_tokens={len(plan.abc_ids)} (truncated={plan.truncated}) "
        f"semantic_tokens={len(semantic.tokens)}/{max_tokens} (truncated={semantic.truncated}) "
        f"abc={plan.timing.get('seconds', 0):.1f}s semantic={semantic.timing['seconds']:.1f}s",
        flush=True,
    )
    return (wav * 32767.0).astype(np.int16), SAMPLE_RATE, len(wav) / SAMPLE_RATE, time.time() - start