YuE 2: Inference & LoRA Training Guide

What is YuE 2?

YuE 2 is an open-weight, full-song generation model. You give it lyrics and a style, and it writes and sings a complete song with vocals and backing. It's effectively a local Suno killer. See inference results. It has two halves:

  • Score (music half): plans the song: melody, structure, phrasing.
  • Sound (NAR half): turns that plan into audio.

LoRAs train both halves, and you can set each half's strength separately at inference.


Inference

The most stable way to run YuE 2 is Ruach Studio. Install it by following the repo instructions. The defaults are good unless noted below.

Settings

Generation

Setting Default Range Notes
Guidance 1.6 1.0–4.0 The style's pull. 1.6 works for most LoRAs; 3.0 also gives good results
Lyrics guidance 2.2 1.0–2.6 The lyrics' pull. Never set it equal to Guidance (see below)
Sampling (score & sound) Leave – The dev's tuned stable set
Planning mode Full plan – See the planning modes table

Planning modes

Mode What it does Use for
Full plan Writes melody + chords (editable score), then the song Best overall, with or without LoRAs
Melody only Writes the melody, free accompaniment Covers, or on its own
Direct No score; straight from lyrics + style Creative results with LoRAs (Score at 0)

My experience:

  • The trainer's default makes Direct-trained LoRAs. These work best in Full plan.
  • Direct with Score at 0 is worth a try for creative results. Vocals and style can be more accurate, but the composition is slightly more hectic and sometimes less faithful to the data.
  • Melody only works well on its own, not just for covers. Still, Full plan is the best overall if you're not doing a cover.

Settings page

Setting Default Set to Why
Engine → Creator Heresy → vocal octave An octave down As the model wrote it The default turns planned female vocals male (see below)

"Set the vocal octave before anything else"

By default, Ruach Studio moves the vocal line of any score the model plans down an octave, which turns planned female vocals male.
Engine → Creator Heresy → vocal octave → "As the model wrote it".
This only affects Full plan when the model writes the score itself. An output info card that says "Vocal line: An octave down" means it was still active.

LoRAs

Setting Full plan / Melody only Direct Notes
Score strength 0.4–0.6 0 In Direct, Score above 0 can make output sped up or bad
Sound strength 0.7–1.0 0.7–1.0

"Check Free Tempo"

The red Free Tempo setting above the caption is easy to press by accident, even when attempting to click on the caption box and well outside its bounds. When it's on, score timing is off on songs with LoRAs (untested without), creativity is massively reduced.
It's silent: the only sign is at the bottom of the output info card. Make sure it's off.

Details

Guidance

Ruach Studio uses two guidance scales, one for the style and one for the lyrics. It added a third guidance branch (the prompt without its lyrics) to yue2.cpp for this.

  • The defaults (1.6 / 2.2) apply to every new song, in every mode.
  • Setting the two equal is a problem: about half the lyrics drift into hallucination, and the vowels lose their stress.
  • The brightest result the dev heard was guidance 4.0 with lyrics at 2.2. I've had good results with 3.0 / 2.3.
  • Nothing is clamped. Each field is coloured like a road:
    • Red: under 1.
    • Green: inside the dev's range.
    • Amber: past the range.
    • Red: well past the range.

Vocal octave

By default, Ruach Studio moves the vocal line of any score the model plans down an octave, which turns planned female vocals male.

  • This only affects Full plan when the model writes the score itself. Direct, Melody only and supplied scores are never moved.
  • A take card that says "Vocal line: An octave down" means the setting was still active.

Loading LoRAs

  • Place the LoRA in Ruach_Studio/loras/NAME/. It appears in the Creator right away.
  • When comparing LoRAs or epochs:
    • Use new lyrics, not training lyrics.
    • Fix the music seed and sound seed.
    • Change only the epoch or LoRA.
    • Keep the mode the same.

Other UIs (less stable)

There may be settings on these that match Ruach Studio for both training and inference. I just haven't found them.

HOT-Step.cpp

  • Load LoRAs via YuE2 Adapters → Import adapter…. It splits the file into its two halves.
  • Don't copy the file directly into adapters/. Files without the half field are skipped.

ComfyUI

  • Use CUDA 13+ and the latest PyTorch. Older setups have a huge slowdown.

AI-Toolkit (training)

  • Ruach Studio's trainer is built on a custom version of AI-Toolkit, so the two should be comparable.
  • I've moved to Ruach Studio since it gives the best results.

Inference Results

These are all from LoRAs, using roughly the settings above.

Yousei Teikoku - Rank 64, 100 Epochs, 15 songs
https://vocaroo.com/1imfzsfC46fy
https://vocaroo.com/12UhIcUpc5Zx
https://vocaroo.com/16Rb6EvPNW0e

Japanese Folk Metal - Rank 32, 50 epochs, 10 songs
https://files.catbox.moe/cvu2tn.flac
https://files.catbox.moe/o9n5lg.flac
https://files.catbox.moe/yel0jt.flac

Fate Gear - Rank 64, 50 epochs, 15 songs
https://vocaroo.com/1gWtTrdtr7S7
https://vocaroo.com/165I77c6pfwF


Training

Hardware: see Requirements. No suitable GPU? See Training on Modal.

1. Sourcing Audio

For the best results, use high-quality audio files.

Quality Guidelines:

  • Aim for FLAC or 320 kbps MP3.
  • If the song is unavailable elsewhere, 192 kbps may be okay.

2. Upscaling & Enhancing (Optional)

If you are forced to use lower-quality files (such as those from YouTube), you can use the following tool to enhance musicality:

  • Matchering 2: An experimental tool that yields amazing results. It requires a "target" mix (a high-quality recording/FLAC from any artist in the same genre) to reference.

3. Lyrics

  • Get lyrics from Genius or similar sites.
  • Add section tags: [Verse], [Chorus], [Bridge], etc.

4. Dataset Layout

Put everything in Ruach_Studio/datasets/raw/<set>/:

⎗
✓
1
2
3
4
5
6
datasets/raw/my-set/
├── 01. First Song.flac
├── 01. First Song.txt
├── 02. Second Song.flac
├── 02. Second Song.txt
└── styles.json

Lyrics file (01. First Song.txt), lyrics only:

⎗
✓
1
2
3
4
5
6
7
[Verse]
Hundred guns and thousand shots
Our ship is on fire

[Chorus]
Rising flame, your desire
Watch out now

Styles file (styles.json) is not read by Ruach Studio. The caption script in step 6 uses it. Write one style line per song, keyed by exact filename:

⎗
✓
1
2
3
4
5
6
7
{
  "_trigger": "myset_tr1gg3r",
  "styles": {
    "01. First Song.flac": "power metal, fast, 172 bpm, ...",
    "02. Second Song.flac": "power metal, 140 bpm, ..."
  }
}

Style line conventions:

  • Comma-separated lowercase tags, roughly in this order: language + genre, tempo word, bpm, vocal + delivery, instruments, mood, production, stereo field.
  • No performer names, no musical keys.
  • Tag different singers distinctly (e.g. raspy female lead vocal).
  • Flag mixed-language songs (japanese lyrics with english phrases).
  • BPM detectors often return half or double tempo. Check against the song: "blistering double-kick" is likely 160–200, not 80–100. If unsure, tap along to the chorus on a tap-tempo site.

5. Make the Set

  1. Trainer → Material → Reload → pick your set.
  2. Enter the trigger and set name (same as the folder name) → Make the set.

This writes datasets/prepared/<set>/ as 001.wav + 001.txt etc. Each caption looks like:

⎗
✓
1
2
3
4
myset_tr1gg3r, <style line>
[Lyrics]
[Verse]
...

The problem: Ruach Studio writes the same style line (the "shared style line") into every caption. Your per-song styles are lost.

6. Replace the Captions with Per-Song Styles

Save as ~/apply_styles.py:

⎗
✓
import json, shutil, sys
from pathlib import Path

name, styles_file = sys.argv[1], Path(sys.argv[2])
prep = Path.home() / "Ruach_Studio/datasets/prepared" / name
spec = json.loads(styles_file.read_text(encoding="utf-8"))
trigger, styles = spec["_trigger"], spec["styles"]
meta = json.loads((prep / "dataset.json").read_text("utf-8"))
tracks = meta["tracks"]
backup = Path.home() / f"{name}_captions_backup"
backup.mkdir(exist_ok=True)

done = 0
for t in tracks:
    cap, style = prep / f"{t['stem']}.txt", styles.get(t["file"])
    if not cap.is_file() or not style:
        print("skipped:", t["stem"], t["file"])
        continue
    if not (backup / cap.name).exists():
        shutil.copy2(cap, backup / cap.name)
    lines = cap.read_text(encoding="utf-8").split("\n")
    lines[0] = f"{trigger}, {style}"
    cap.write_text("\n".join(lines), encoding="utf-8")
    done += 1
print(f"{done} of {len(tracks)} replaced (originals: {backup})")

Run it after Make the set, then check one caption:

⎗
✓
1
2
3
cd ~/Ruach_Studio
.venv/bin/python ~/apply_styles.py my-set  datasets/raw/my-set/styles.json
head -n 3 datasets/prepared/my-set/001.txt
  • Pressing Make the set again overwrites the captions. Rerun the script afterwards.
  • To change a BPM or tag later, edit styles.json and rerun the script.

7. Train

The trainer's defaults produce Direct-trained LoRAs, which work best in Full plan at inference (see Planning modes).

Rank by dataset size (the dev's recommendation):

Dataset Rank
One voice, one instrument or one sound (20–50 songs) 16, at most 32
A style or an artist (varied catalogue) 32
Many voices (hours of material, each voice tagged) 64; 128 only if 64 measurably falls short

The trainer refuses anything above 128.

My results so far:

Dataset Rank Epochs
10–15 songs 32 (default) 50
Many background vocals 64 100

Timing: on an RTX 3090, 50 epochs on 10–20 songs takes about 1 hour, using 21–24 GB VRAM. Modal's L40S is faster (below).

Saves land every 5 epochs. In Telemetry, click epochs to add them to the Creator's LoRA list, then compare them with fixed seeds.


Training on Modal

For those without a beefy local GPU, or who want a speed-up over a 3090.

  • Modal gives $30 of free GPU credits every month.
  • Use the L40S (48 GB):
    • 100 epochs on 15 songs at rank 64 takes ~40 min, about $1.30.
    • It beat H100 and H200 on this trainer, possibly thanks to its Ada Lovelace architecture (same as the 4090).

Benchmarks (1 epoch, 15 tracks, rank 64):

GPU s/it 100 epochs
RTX 3090 (local) ~4.4 ~110 min
H100 3.86 ~97 min
H200 4.17 ~104 min
L40S 1.58 ~40 min

How the work is split:

  • The latent cache is built locally. The VAE and tokenizers stay home.
  • Modal only receives the trainer code, the base checkpoint and the prepared set.
  • The output is the same LoRA format Ruach Studio saves.

Setup (once)

⎗
✓
1
2
3
mkdir ~/modal-ruach && cd ~/modal-ruach
python3 -m venv .venv && source .venv/bin/activate
pip install modal && modal setup

Upload the base checkpoint:

⎗
✓
modal volume create ruach-data
modal volume put ruach-data  ~/Ruach_Studio/checkpoints/comfy/checkpoints/yue2_3b_bf16.safetensors  /models/checkpoints/yue2_3b_bf16.safetensors

Save as ~/modal-ruach/modal_ruach.py:

⎗
✓
"""Ruach Studio's YuE2 LoRA trainer on Modal.
Latent cache built at home; Modal gets the trainer code,
the bf16 checkpoint and the prepared set.
RUACH_GPU=L40S (default) | H100 | H200 | B200 | RTX-PRO-6000
RUACH_NGPU=2 for two cards."""
import glob, json, os, shutil, subprocess, threading, time
import modal

app = modal.App("ruach-lora")
vol = modal.Volume.from_name("ruach-data", create_if_missing=True)

LOCAL_TRAINER = os.path.expanduser(
    os.environ.get("RUACH_TRAINER", "~/Ruach_Studio/lab/trainer"))
KIT = "/workspace/ruach"
TRAINER = f"{KIT}/lab/trainer"
WORK = "/tmp/ruach"
CHECKPOINT = "/data/models/checkpoints/yue2_3b_bf16.safetensors"
CU128 = "https://download.pytorch.org/whl/cu128"

GPU = os.environ.get("RUACH_GPU", "L40S")
NGPU = int(os.environ.get("RUACH_NGPU", "1"))
GPU_SPEC = GPU if NGPU == 1 else f"{GPU}:{NGPU}"

image = (
    modal.Image.debian_slim(python_version="3.12")
    .apt_install("ffmpeg", "libsndfile1", "rsync")
    .pip_install("torch==2.11.0", "torchaudio==2.11.0", index_url=CU128)
    .pip_install("safetensors==0.8.0", "transformers==4.57.6",
                 "einops==0.8.2", "rotary-embedding-torch==0.6.5",
                 "numpy", "soundfile", "librosa", "sentencepiece",
                 "pyarrow")
    .env({"PYTHONUNBUFFERED": "1",
          "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"})
    .add_local_dir(LOCAL_TRAINER, TRAINER, copy=True,
                   ignore=["__pycache__", "*.pyc"])
    .run_commands(f"cd {TRAINER} && python -c \"import sys; "
                  "sys.path.insert(0, '.'); import train; "
                  "import pyarrow.parquet; print('trainer imports ok')\"")
)

# every key spelled out so a change in train.py's defaults can't move it
RECIPE = {
    "epochs": 100, "save_epochs": 10,
    "rank": 64, "rank_ar": 0, "rank_nar": 0,
    "lr": 1e-4, "lr_scale": "sqrt",
    "lr_tail_epochs": 0.0, "lr_tail_floor": 0.0,
    "ar_loss_weight": 0.5, "ar_kl_weight": 0.2, "ar_lr_multiplier": 0.5,
    "train_window_frames": 1500, "chunk": 512,
    "ar_max_tokens": 0, "ar_cap_tokens": 0,
    "cot": "off", "abc_dropout": 0.5, "do_separation": False,
    "seed": 42, "max_grad_norm": 1.0, "accum": 1,
    "checkpointing": "auto",  # off while memory allows, on at an OOM
    "ema": False,
    "val_fraction": 0.0, "patience": 0, "nar_phase_epochs": 0,
    "resume_from": 0, "lazy_cache": "auto",
    "vram_meter": False, "long_off_card0": False,
}


def _sync(src, dst):
    if os.path.isdir(src):
        os.makedirs(dst, exist_ok=True)
        subprocess.run(["rsync", "-a", src + "/", dst + "/"], check=False)
    try:
        vol.commit()
        print(f"[volume] synced {time.strftime('%H:%M:%S')}", flush=True)
    except Exception as e:  # noqa: BLE001
        print(f"[volume] commit failed: {e}", flush=True)


def _captions(ds):
    caps = sorted(glob.glob(f"{ds}/*.txt"))
    def cached(c):
        stem = os.path.splitext(os.path.basename(c))[0]
        return os.path.isfile(f"{ds}/_ruach_cache/{stem}_ruach.safetensors")
    return caps, [c for c in caps if not cached(c)]


def _trainer_lack(ds):
    import sys
    if TRAINER not in sys.path:
        sys.path.insert(0, TRAINER)
    import cache as latent_cache
    return latent_cache.missing(ds)


@app.function(image=image, gpu=GPU_SPEC, cpu=8.0, memory=65536,
              volumes={"/data": vol}, timeout=86400, max_containers=1)
def train(name: str, dataset: str, checkpoint: str = CHECKPOINT,
          overrides: str = "{}"):
    import torch
    cards = torch.cuda.device_count()
    print(f"cards: {cards} x {torch.cuda.get_device_name(0)}", flush=True)

    vol.reload()
    ds = f"/data/datasets/{dataset}"
    caps, lack = _captions(ds)
    if not caps:
        raise FileNotFoundError(f"no captions in {ds}")
    if lack or _trainer_lack(ds):
        raise RuntimeError("tracks without a latent cache: "
                           "build it at home and upload the set again")
    if not os.path.isfile(checkpoint):
        raise FileNotFoundError(checkpoint)

    r = dict(RECIPE, **json.loads(overrides))
    tracks = len(caps)
    cfg = {k: v for k, v in r.items() if k not in ("epochs", "save_epochs")}
    cfg.setdefault("steps", int(r["epochs"] * tracks))
    cfg.setdefault("save_every", int(r["save_epochs"] * tracks))
    cfg.update(name=name, out=WORK, dataset=ds, checkpoint=checkpoint)

    local_out, vol_out = f"{WORK}/output/{name}", f"/data/runs/{name}"
    if os.path.isdir(vol_out):  # same --name resumes from the last save
        shutil.copytree(vol_out, local_out, dirs_exist_ok=True)
        print(f"resuming from {vol_out}", flush=True)
    os.makedirs(local_out, exist_ok=True)
    cfg_path = f"{local_out}/config.json"
    with open(cfg_path, "w", encoding="utf-8") as f:
        json.dump(cfg, f, indent=1)
    print(json.dumps(cfg, indent=1), flush=True)
    print(f"{tracks} tracks: {cfg['steps']} steps, "
          f"a save every {cfg['save_every']}", flush=True)

    script = f"{TRAINER}/train.py"
    cmd = (["python", "-m", "torch.distributed.run", "--standalone",
            f"--nproc_per_node={cards}", script, cfg_path]
           if cards > 1 else ["python", script, cfg_path])
    stop = threading.Event()

    def loop():
        while not stop.wait(600):
            _sync(local_out, vol_out)
    threading.Thread(target=loop, daemon=True).start()
    try:
        code = subprocess.run(cmd, cwd=KIT).returncode
    finally:
        stop.set()
        _sync(local_out, vol_out)
    if code != 0:
        raise RuntimeError(f"train.py exited {code}: see the log above")


@app.function(image=image, volumes={"/data": vol}, timeout=1800)
def check(dataset: str = "", checkpoint: str = CHECKPOINT):
    vol.reload()
    print(checkpoint, "found" if os.path.isfile(checkpoint) else "MISSING")
    if dataset:
        ds = f"/data/datasets/{dataset}"
        caps, lack = _captions(ds)
        print(f"{len(caps)} captions, {len(caps) - len(lack)} cached"
              + (" (ready)" if not lack else ""))
        print(f"trainer's cache check: {len(_trainer_lack(ds))} "
              "tracks to cache")

Per Set

1. Build the cache locally. Make the set and replace the captions (steps 5–6), press Unload in the bar, then run:

⎗
✓
cd ~/Ruach_Studio && .venv/bin/python - <<'PY'
import os, sys
sys.path.insert(0, "lab/trainer")
import cache
ds = "datasets/prepared/NAME"
ck = "checkpoints/comfy/checkpoints/yue2_3b_bf16.safetensors"
lack = cache.missing(ds)
print(len(lack), "tracks to cache")
if lack:
    cache.build(ds, ck, os.path.abspath("."), "cuda", lack)
PY

2. Upload and check. Run these from ~/modal-ruach with the venv active:

⎗
✓
modal volume put ruach-data  ~/Ruach_Studio/datasets/prepared/NAME /datasets/NAME
modal run modal_ruach.py::check --dataset NAME

You want both lines to read: N captions, N cached (ready) and 0 tracks to cache.

3. Train.

⎗
✓
modal run --detach modal_ruach.py::train  --name RUN --dataset NAME

The default in the custom Modal script is rank 64, 100 epochs, a save every 10. For the RS defaults:

⎗
✓
modal run --detach modal_ruach.py::train  --name RUN --dataset NAME  --overrides '{"rank": 32, "epochs": 50, "save_epochs": 5}'
  • The log should show cards: 1 x NVIDIA L40S. A run that never prints cards: is waiting for a GPU.
  • Saves reach the volume every 10 minutes and at the end.
  • To stop a run: modal app list, then modal app stop <id>.
  • Rerunning the same --name resumes from the last save.

4. Download. Saves are named by step, where step = epoch × tracks. Set T to your track count:

⎗
✓
1
2
3
4
5
6
7
8
modal volume ls ruach-data /runs/RUN
mkdir -p ~/Ruach_Studio/loras/RUN
T=15
for e in 010 020 030 040 050 060 070 080 090 100; do
  f=~/Ruach_Studio/loras/RUN/RUN-e${e}.safetensors
  s=$(printf "%09d" $((10#$e * T)))
  [ -f "$f" ] || modal volume get ruach-data    /runs/RUN/RUN_${s}.safetensors "$f"
done

Tips:

  • Paste commands one at a time. A pasted block runs to the end, including a detached run.
  • Modal's dashboard keeps old logs. Check the timestamp before reading one.
Edit

Pub: 06 Nov 2026 01:37 UTC

Edit: 06 Nov 2026 06:28 UTC

Views: 94