[Model] Add SheetSage2 preprocessing and YuE2 score handoff (#8371)

Signed-off-by: princepride <wangzhipeng628@gmail.com>
This commit is contained in:
汪志鹏 authored and GitHub committed 2026-10-05 11:09:37 +08:00
1 parent bbd539d2b6
commit 5ca23106fd
7 files changed
+651 -1

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ th {
| `OmniGen2Pipeline` | OmniGen2 | `OmniGen2/OmniGen2` | ✅︎ | ✅︎ | | ✅︎ | — |
| `StableAudioPipeline` | Stable-Audio-Open | `stabilityai/stable-audio-open-1.0` | ✅︎ | ✅︎ | | ✅︎ | — |
| `MiniMaxMusic3ForConditionalGeneration` | MiniMax Music 3 (text-to-music) | `MiniMaxAI/MiniMax-Music3` | ✅︎ | | | | — |
| `Yue2ForCausalLM` | YuE2-3B (text-to-music with ABC plan) | `m-a-p/YuE2-3B` | ✅︎ | | | | — |
| `Yue2ForCausalLM` | YuE2-3B (text-to-music with ABC plan) | `m-a-p/YuE2-3B` | ✅︎ | | | | [Recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/m-a-p/YuE2-3B.md) / [Audio-to-score](https://github.com/vllm-project/vllm-omni/blob/main/recipes/m-a-p/SheetSage2-H200.md) |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | ✅︎ | | | ✅︎ | [Published](https://recipes.vllm.ai/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice) |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
+2
View File
@@ -66,6 +66,8 @@ recipes/
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB / 1x Intel Arc Pro B70 32GB |
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU / 1x Intel Arc Pro B70 32GB |
| [`LTX/LTX-2.5.md`](./LTX/LTX-2.5.md) | LTX-2.5-Diffusers: Full/SFT one-stage and distilled two-stage T2V/I2V with synchronized audio | NVIDIA B300; cuDNN-qualified / 1x Intel Arc Pro B70 32GB |
| [`m-a-p/SheetSage2-H200.md`](./m-a-p/SheetSage2-H200.md) | Official audio-to-score preprocessing and YuE2 request export | 1x H200 141GB |
| [`m-a-p/YuE2-3B.md`](./m-a-p/YuE2-3B.md) | Offline and online text-to-music with optional ABC score | 1x RTX 4090 24GB |
| [`MammothModa2/MammothModa2.md`](./MammothModa2/MammothModa2.md) | Preview and Dev text-to-image (AR → DiT); Dev text/image understanding | Preview: 1x L40S 48GB / 1x ≥40GB GPU; Dev: 1x NVIDIA GPU with sufficient cache headroom |
| [`NVIDIA/SANA-Video-2B.md`](./NVIDIA/SANA-Video-2B.md) | Native and Diffusers-adapter 480p/720p T2V+I2V | 1x RTX 5090 32GB |
| [`meituan-longcat/LongCat-Video-Avatar-1.5.md`](./meituan-longcat/LongCat-Video-Avatar-1.5.md) | Audio-driven avatar video generation (AT2V / AI2V, single- and multi-speaker, AVC continuation) | 1x H100 80GB |
+259
View File
@@ -0,0 +1,259 @@
# SheetSage2 audio-to-score preprocessing — H200
## Summary
- Vendor: m-a-p
- Model: `m-a-p/SheetSage2` with the `m-a-p/MERT-v2-FullSong` parent
- Task: recording → ABC score, MIDI and timed music annotations
- Mode: standalone official Transformers inference; optional YuE2 request export
- Hardware: one NVIDIA H200 (141 GB)
- Maintainer: [@princepride](https://github.com/princepride)
## When to use this recipe
Use a recording as the musical score for a YuE2 cover, or inspect its melodies,
chords and structure. The tool calls SheetSage2's official `transcribe()` API.
It does not register SheetSage2 as a native vLLM model or start an Omni server.
The transcription tool works independently of YuE2. For score-conditioned
generation, use an Omni checkout containing the merged
[PR #7886](https://github.com/vllm-project/vllm-omni/pull/7886)
(`423f34326ed420e5acf0b1fb862a1b5ffb7e0fa7` or later). See the
[YuE2 recipe](YuE2-3B.md) for the generation model's runtime contract.
## Supported model contract
| Item | Contract |
| --- | --- |
| Input | One local recording decodable by FFmpeg; upstream converts it to mono, 24 kHz |
| Duration | Entire recording by default; `--max-seconds` limits preprocessing to a prefix |
| Full score | Default: melody voices and chords; exported request uses `cot=full` |
| Melody score | `--melody-only`: vocal and instrumental melodies, without score/playback chords; request uses `cot=melody` |
| Outputs | `score.abc`, `transcription.mid`, upstream annotations and `manifest.json` |
| YuE2 handoff | `--lyrics-file` and `--style` together also write `yue2_request.json` |
| Output directory | Must be new or empty; failed exports produce no YuE2 request |
| Deployment profile | One process, one H200, FP32 weight loading and BF16 inference autocast |
`--melody-only` does not isolate or clone a singer. The upstream model still
decodes its task annotations and retains those raw annotation files. YuE2
uses the exported score to condition newly generated audio; a transcription
does not guarantee an exact reproduction of the recording.
## References
- [Official SheetSage2 model card](https://huggingface.co/m-a-p/SheetSage2)
- [Pinned SheetSage2 source](https://huggingface.co/m-a-p/SheetSage2/tree/cafc0df1021e14f49e928c4b345f5959d414ef64)
- [Official YuE2 model card and cover documentation](https://huggingface.co/m-a-p/YuE2-3B)
- Tool: [`tools/sheetsage2_transcribe.py`](../../tools/sheetsage2_transcribe.py)
- Offline generation: [`examples/offline_inference/yue2/end2end.py`](../../examples/offline_inference/yue2/end2end.py)
- [Supported generation models](../../docs/models/supported_models.md)
## Hardware
- Accelerator: one NVIDIA H200, 141 GB; no multi-device interconnect required.
- Qualification: local M4A transcription, melody-only prefix, full-score
whole-song export and one offline YuE2 cover. Run transcription and generation
sequentially in separate processes. Other devices, CPU inference and
performance scaling are not qualified by this recipe.
## Software environment
- Ubuntu 22.04, Python 3.10, NVIDIA driver 590.48.01.
- PyTorch/torchaudio 2.8.0 (CUDA 12.8 wheels), Transformers 4.45.2.
- FFmpeg 4.4.2 was used for the local M4A check; upstream recommends FFmpeg 6.1.
- vLLM: not required in the preprocessing environment.
- vLLM-Omni: tool developed against main `a038b38179e9c788d3af6a8e73f94652afb247e4`.
- YuE2 qualification: vLLM 0.30.0, PyTorch 2.13.0+cu130, Transformers 5.14.1;
#7886 at `5cc8acc942e76fa6f12f8b2f2558c2663abc2aa5` with the FP32 VAE-loading
and scalar `truncated` fixes subsequently included in the merged integration.
## Command
### Set up transcription
Run from this repository's root. Create a **separate environment**: the
official transcriber's Transformers version differs from Omni's serving
dependencies. FFmpeg must be on `PATH`.
```bash
uv venv --python 3.10 .venv-sheetsage2
uv pip install --python .venv-sheetsage2/bin/python \
-r tools/requirements/sheetsage2.txt
.venv-sheetsage2/bin/python tools/sheetsage2_transcribe.py reference.wav \
--trust-remote-code --device cuda:0 --melody-only \
--output-dir outputs/reference-score
```
The default Hub checkpoint and its custom Python code are pinned to
`cafc0df1021e14f49e928c4b345f5959d414ef64`. Its configuration pins the MERT-v2
parent to `d8ba1c745e733b3908ce6ad16ebeb17ac7600a42`. Review that code before
passing `--trust-remote-code`. `--revision` can override the default pin;
custom Hub IDs use their default revision unless one is supplied.
Local snapshots must preserve this pinned source layout: `modeling_sheetsage2.py`
and its transitive relative-import dependencies are sibling `.py` files in the
snapshot root. The local module-cache workaround targets that flat layout;
reorganized snapshots with nested Python packages are not supported by it.
For offline use, download **both** model snapshots, including their Python,
JSON and safetensors files, before disconnecting:
```bash
.venv-sheetsage2/bin/hf download m-a-p/SheetSage2 \
--revision cafc0df1021e14f49e928c4b345f5959d414ef64 \
--local-dir models/SheetSage2
.venv-sheetsage2/bin/hf download m-a-p/MERT-v2-FullSong \
--revision d8ba1c745e733b3908ce6ad16ebeb17ac7600a42 \
--local-dir models/MERT-v2-FullSong
```
### Transcribe a full recording for a cover
Supply target lyrics in `lyrics.txt`, including YuE2 section tags such as
`[Verse]` and `[Chorus]`. Match the lyric phrasing to the reference melody.
This example retains the melody and chords of the whole recording:
```bash
.venv-sheetsage2/bin/python tools/sheetsage2_transcribe.py reference.wav \
--model models/SheetSage2 --base-model-path models/MERT-v2-FullSong \
--local-files-only --trust-remote-code --device cuda:0 \
--lyrics-file lyrics.txt --style 'Chinese folk, guzheng, gentle vocals' \
--seed 831001 --output-dir outputs/cover-score
```
Add `--melody-only` for a new arrangement without the original chords;
this also changes the exported request from `cot=full` to `cot=melody`.
For a short transcription smoke test, add `--max-seconds 45` and use lyrics
appropriate to that excerpt. Use a fresh output directory for each run.
### Generate offline
Switch to your **Omni environment** for generation. Download `m-a-p/YuE2-3B`
and `m-a-p/YuE2-Vae` to `models/YuE2-3B` and `models/YuE2-Vae`, respectively.
The offline entrypoint needs a local YuE2 directory containing `qwen.tiktoken`.
From the repository root, pass the exported ABC to the existing entrypoint:
```bash
CUDA_VISIBLE_DEVICES=0 python examples/offline_inference/yue2/end2end.py \
--model models/YuE2-3B --vae models/YuE2-Vae \
--lyrics "$(cat lyrics.txt)" \
--style 'Chinese folk, guzheng, gentle vocals' \
--abc-file outputs/cover-score/score.abc --cot full \
--seed 831001 --max-frames 9000 --gpu-memory-utilization 0.25 \
--output cover.wav
```
Use `--cot melody` if the score was transcribed with `--melody-only`.
Changing `--cot` alone does not remove chords from an existing score.
The 9,000-frame budget permits up to 360 seconds at 25 frames/s; it does
not force that duration. The entrypoint's default 200-frame budget caps
audio at 8 seconds. Check that the final report says `truncated=False`.
The memory fraction above was used on one H200; it is not a measured
minimum memory requirement.
### Submit over HTTP (configuration-only)
The exported JSON can also be submitted to YuE2's speech endpoint. These
commands have not been qualified with an HTTP synthesis run in this recipe.
Start YuE2 in its **Omni environment**:
```bash
vllm serve m-a-p/YuE2-3B --omni --port 8091
```
Submit the exported file:
```bash
curl --fail-with-body http://localhost:8091/v1/audio/speech \
-H 'Content-Type: application/json' \
--data-binary @outputs/cover-score/yue2_request.json \
--output cover.wav
```
The JSON contains the score itself, not a server-local file path. It sets
`stream=false`, requests WAV, and preserves an optional generation seed.
Use `--yue2-model` to match a custom served model name. The tool does not
submit the request or set a generation token budget; the server's own
duration and context limits apply.
## Verification
After a successful transcription, set `output` below to its output directory
(`outputs/reference-score` for the transcription-only example). The YuE2
request is checked when exported with `--lyrics-file` and `--style`:
```bash
.venv-sheetsage2/bin/python - <<'PY'
import json
from pathlib import Path
import mido
output = Path("outputs/cover-score")
abc = (output / "score.abc").read_text(encoding="utf-8")
manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
assert abc.strip()
request_path = output / "yue2_request.json"
if request_path.exists():
request = json.loads(request_path.read_text(encoding="utf-8"))
assert request["extra_params"]["abc"] == abc
assert request["extra_params"]["cot"] == ("melody" if manifest["melody_only"] else "full")
print("YuE2 request verified")
midi = mido.MidiFile(output / "transcription.mid")
assert any(msg.type == "note_on" and msg.velocity for track in midi.tracks for msg in track)
print("ABC, MIDI and manifest verified")
PY
```
Run the CPU CLI contract tests in the repository's normal test
environment (with pytest and pytest-mock installed):
```bash
python -m pytest tests/tools/test_sheetsage2_transcribe.py -m 'core_model and cpu' -q
```
These tests cover request construction, loading options, validation and
failure handling with a mocked backend. They do not measure transcription
accuracy or generated cover similarity.
Local H200 checks with the pinned backend:
| Input / mode | Result |
| --- | --- |
| 45 s M4A prefix, melody-only | 579 ABC characters; parseable MIDI with 118 note-on events |
| 242.219 s M4A, full score | 3,033 ABC characters; parseable MIDI with 1,087 note-on events |
| Default Hub ID, 10 s prefix | Successful ABC and MIDI export |
| Local snapshots, fresh Transformers module cache | Successful offline full-score export |
| Both generated JSON requests, #7886 adapter at `5cc8acc942e76fa6f12f8b2f2558c2663abc2aa5` | Validation and prompt construction passed (919 / 2,821 prompt tokens) |
| Full score + new Mandarin lyrics, offline YuE2, `cot=full`, seed 831001, 9,000-frame budget | 241.319 s, 48 kHz stereo WAV; 6,034 generated tokens; `truncated=False` |
The offline cover used a 108 BPM Chinese traditional pop style and one H200
with `--gpu-memory-utilization 0.25`. The saved waveform contained finite
samples, peak amplitude 0.9501, RMS 0.1232 and no full-scale samples. This is
one functional run, not an accuracy or performance benchmark. It does not
establish perceptual equivalence to the recording. HTTP synthesis and
recordings longer than 300 seconds were not exercised. The local recording,
lyrics, generated scores and cover audio are not included in the repository.
## Notes
- Weights load in FP32 because upstream merges MERT adapters before inference
autocast. `--dtype fp32` disables BF16 autocast.
- For local snapshots the tool prepares transitive Python dependencies in
Transformers' module cache. This avoids the pinned Transformers version's
direct-import-only copy behavior without editing upstream files.
- SheetSage2 manages long recordings with its own overlapping windows. This
tool leaves windowing and notation generation to the official implementation.
- Failed runs may retain upstream diagnostic annotations. Choose a new output
directory when retrying; no incomplete request is published for synthesis.
- Model weights have upstream license terms (CC BY-NC 4.0); this repository
does not bundle weights or recordings.
## Supported features
| Feature | Status |
| --- | --- |
| Audio-to-score preprocessing | Official backend; local file input |
| YuE2 score-conditioned generation | Offline ABC handoff qualified on H200; HTTP request export validated, HTTP synthesis configuration-only |
| Native Omni inference, continuous batching, TP/PP/SP | Not implemented for SheetSage2 |
| Streaming transcription or cover audio | Not supported by this tool |
+5
View File
@@ -19,6 +19,11 @@ renders it with the lyrics. `cot=off` skips the score and generates music
directly. Weights are **CC BY-NC 4.0** (non-commercial); this recipe loads
user-downloaded weights and bundles none.
To start from a recording, use the [SheetSage2 H200 recipe](SheetSage2-H200.md)
to transcribe it into ABC in a separate environment, then pass the score to
YuE2 with `--abc-file`. It includes full-song frame budgets and offline cover
validation details.
## References
- Model card: [m-a-p/YuE2-3B](https://huggingface.co/m-a-p/YuE2-3B)
+193
View File
@@ -0,0 +1,193 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Contracts for the standalone audio-to-score/YuE2 handoff tool."""
import json
import sys
from pathlib import Path
import pytest
from tools import sheetsage2_transcribe as cli
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.fixture
def arguments(tmp_path):
audio = tmp_path / "song.wav"
audio.write_bytes(b"audio is decoded by the upstream transcriber")
return cli.create_parser().parse_args([str(audio), "--output-dir", str(tmp_path / "score"), "--trust-remote-code"])
@pytest.fixture
def backend(mocker):
model = mocker.Mock()
model.config = mocker.Mock(
spec_set=["model_type", "base_model_revision"], model_type="sheetsage2", base_model_revision="parent-sha"
)
model.eval.return_value = model
model.to.return_value = model
model.transcribe.return_value = {"abc": "X:1\nM:4/4\nK:C\nC4|\n", "midi": b"MThd\x00\x00\x00\x06"}
loader = mocker.Mock(return_value=model)
torch = mocker.Mock(
float32=object(),
__version__="test",
get_num_threads=lambda: 8,
set_num_threads=mocker.Mock(),
accelerator=mocker.Mock(is_available=lambda: False),
)
mocker.patch.dict(
sys.modules,
{"torch": torch, "transformers": mocker.Mock(AutoModel=mocker.Mock(from_pretrained=loader))},
)
return mocker.Mock(model=model, loader=loader, torch=torch)
@pytest.mark.parametrize("melody_only,expected_cot", [(True, "melody"), (False, "full")])
def test_score_handoff_preserves_unicode_and_selects_matching_cot(arguments, backend, melody_only, expected_cot):
arguments.lyrics_file = arguments.audio.with_suffix(".txt")
arguments.lyrics_file.write_text("[Verse]\n江边的清晨", encoding="utf-8")
arguments.style = " Chinese folk, bamboo flute "
arguments.yue2_model = "music-server"
arguments.melody_only = melody_only
arguments.seed = 123
output = cli.transcribe(arguments)
request = json.loads((output / "yue2_request.json").read_text())
assert request == {
"model": "music-server",
"input": "[Verse]\n江边的清晨",
"instructions": "Chinese folk, bamboo flute",
"response_format": "wav",
"stream": False,
"seed": 123,
"extra_params": {"cot": expected_cot, "abc": backend.model.transcribe.return_value["abc"]},
}
assert backend.model.transcribe.call_args.kwargs["melody_only"] == melody_only
assert "max_new_tokens" not in request # Leave natural song length to the server.
def test_default_model_pins_code_and_loads_adapters_in_fp32(arguments, backend):
output = cli.transcribe(arguments)
options = backend.loader.call_args.kwargs
assert options["revision"] == options["code_revision"] == cli.DEFAULT_REVISION
assert options["torch_dtype"] is backend.torch.float32
backend.model.to.assert_called_once_with("cpu")
assert not (output / "yue2_request.json").exists()
def test_local_offline_checkpoint_forwards_parent_without_default_hub_revision(arguments, backend, tmp_path, mocker):
arguments.model = str(tmp_path / "SheetSage2")
snapshot = Path(arguments.model)
snapshot.mkdir()
source = snapshot / "modeling_sheetsage2.py"
direct = snapshot / "exports_sheetsage2.py"
transitive = snapshot / "chord_spelling_sheetsage2.py"
source.write_text("from .exports_sheetsage2 import export\n")
direct.write_text("from .chord_spelling_sheetsage2 import chord\n")
transitive.write_text("chord = 'C'\n")
dynamic_modules = mocker.Mock()
dynamic_modules.get_relative_import_files.return_value = [str(direct), str(transitive)]
mocker.patch.dict(sys.modules, {"transformers.dynamic_module_utils": dynamic_modules})
calls = mocker.Mock()
calls.attach_mock(dynamic_modules.get_cached_module_file, "prime")
calls.attach_mock(backend.loader, "load")
arguments.base_model_path = tmp_path / "MERT"
arguments.base_model_path.mkdir()
arguments.local_files_only = True
cli.transcribe(arguments)
dynamic_modules.get_relative_import_files.assert_called_once_with(str(source))
dynamic_modules.get_cached_module_file.assert_has_calls(
[
mocker.call(arguments.model, direct.name, local_files_only=True),
mocker.call(arguments.model, transitive.name, local_files_only=True),
]
)
assert [call[0] for call in calls.mock_calls] == ["prime", "prime", "load"]
backend.model.transcribe.assert_called_once()
options = backend.loader.call_args.kwargs
assert options["revision"] is None
assert options["local_files_only"] is True
assert options["base_model_path"] == str(arguments.base_model_path)
@pytest.mark.parametrize("limit", [0, -1, float("inf"), float("nan")])
def test_invalid_duration_fails_before_model_loading(arguments, backend, limit):
arguments.max_seconds = limit
with pytest.raises(ValueError, match="finite and positive"):
cli.transcribe(arguments)
backend.loader.assert_not_called()
assert not arguments.output_dir.exists()
def test_nonempty_output_is_not_reused(arguments, backend):
arguments.output_dir.mkdir()
old = arguments.output_dir / "yue2_request.json"
old.write_text("old request")
with pytest.raises(ValueError, match="new or empty"):
cli.transcribe(arguments)
assert old.read_text() == "old request"
backend.loader.assert_not_called()
def test_explicit_code_trust_is_required(arguments, backend):
arguments.trust_remote_code = False
with pytest.raises(ValueError, match="--trust-remote-code"):
cli.transcribe(arguments)
backend.loader.assert_not_called()
@pytest.mark.parametrize(
"result,message",
[
({"abc": "", "midi": b"MThd"}, "ABC export failed"),
({"abc": "X:1", "abc_error": "invalid beat grid", "midi": b"MThd"}, "invalid beat grid"),
({"abc": "X:1", "midi": b"invalid"}, "MIDI export failed"),
],
)
def test_failed_export_never_writes_a_yue2_request(arguments, backend, result, message):
arguments.lyrics_file = arguments.audio.with_suffix(".txt")
arguments.lyrics_file.write_text("lyrics")
arguments.style = "folk"
backend.model.transcribe.return_value = result
with pytest.raises(RuntimeError, match=message):
cli.transcribe(arguments)
assert not (arguments.output_dir / "yue2_request.json").exists()
assert not (arguments.output_dir / "manifest.json").exists()
def test_style_requires_lyrics_before_loading(arguments, backend):
arguments.style = "folk"
with pytest.raises(ValueError, match="supplied together"):
cli.transcribe(arguments)
backend.loader.assert_not_called()
def test_seed_requires_request_context_before_loading(arguments, backend):
arguments.seed = 123
with pytest.raises(ValueError, match="supply --lyrics-file and --style"):
cli.transcribe(arguments)
backend.loader.assert_not_called()
assert not arguments.output_dir.exists()
@pytest.mark.parametrize("seed", [-1, 2**63])
def test_out_of_range_seed_fails_before_loading(arguments, backend, seed):
arguments.lyrics_file = arguments.audio.with_suffix(".txt")
arguments.lyrics_file.write_text("lyrics", encoding="utf-8")
arguments.style = "folk"
arguments.seed = seed
with pytest.raises(ValueError, match="between 0 and"):
cli.transcribe(arguments)
backend.loader.assert_not_called()
assert not arguments.output_dir.exists()
def test_cli_reports_export_failure_as_nonzero(arguments, backend, capsys):
backend.model.transcribe.return_value = {"abc": "", "midi": b"MThd"}
with pytest.raises(SystemExit) as exc:
cli.main([str(arguments.audio), "--output-dir", str(arguments.output_dir), "--trust-remote-code"])
assert exc.value.code == 1
assert "ABC export failed" in capsys.readouterr().err
+13
View File
@@ -0,0 +1,13 @@
# Isolated preprocessing environment; do not install into the Omni server venv.
# Matches m-a-p/SheetSage2 at cafc0df1021e14f49e928c4b345f5959d414ef64.
torch==2.8.0
torchaudio==2.8.0
transformers==4.45.2
huggingface-hub==0.36.0
safetensors==0.5.3
numpy==1.24.3
scipy==1.13.1
mir_eval==0.8.2
pretty_midi==0.2.10
mido==1.3.3
setuptools==78.1.1
+178
View File
@@ -0,0 +1,178 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Convert music to a score and optionally prepare a YuE2 speech request.
Run this preprocessing tool in the separate environment documented in
recipes/m-a-p/SheetSage2-H200.md. It uses the upstream transcriber, not an Omni
engine, and does not import vLLM or change the serving environment.
"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
DEFAULT_MODEL = "m-a-p/SheetSage2"
DEFAULT_REVISION = "cafc0df1021e14f49e928c4b345f5959d414ef64"
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("audio", type=Path, help="Local music recording (decoded by FFmpeg)")
parser.add_argument("--output-dir", required=True, type=Path, help="New or empty output directory")
parser.add_argument("--model", default=DEFAULT_MODEL, help="SheetSage2 Hub ID or local snapshot")
parser.add_argument("--revision", help="Model/code revision; the default Hub model uses a pinned commit")
parser.add_argument("--base-model-path", type=Path, help="Local MERT-v2 parent for offline loading")
parser.add_argument("--local-files-only", action="store_true")
parser.add_argument(
"--trust-remote-code", action="store_true", help="Allow the upstream model's custom Python code"
)
parser.add_argument("--device", default="auto", help="auto, cpu, cuda:0, etc.")
parser.add_argument(
"--dtype", choices=("bf16", "fp32"), default="bf16", help="Inference autocast; weights load in FP32"
)
parser.add_argument("--melody-only", action="store_true", help="Retain vocal/instrumental melodies and omit chords")
parser.add_argument(
"--max-seconds", type=float, help="Transcribe only this many seconds; otherwise process the song"
)
parser.add_argument("--lyrics-file", type=Path, help="With --style, write yue2_request.json for /v1/audio/speech")
parser.add_argument("--style", help="Target musical arrangement for the optional YuE2 request")
parser.add_argument("--yue2-model", default="m-a-p/YuE2-3B", help="Served model name in the optional request")
parser.add_argument("--seed", type=int, help="Optional YuE2 generation seed")
return parser
def validate_args(args: argparse.Namespace) -> str | None:
if not args.audio.is_file():
raise ValueError(f"Audio file does not exist: {args.audio}")
if not args.trust_remote_code:
raise ValueError(
"SheetSage2 uses custom model code; pass --trust-remote-code after reviewing the model repository"
)
if args.max_seconds is not None and (not math.isfinite(args.max_seconds) or args.max_seconds <= 0):
raise ValueError("--max-seconds must be finite and positive")
if args.output_dir.exists() and (not args.output_dir.is_dir() or any(args.output_dir.iterdir())):
raise ValueError("--output-dir must be a new or empty directory")
if args.base_model_path is not None and not args.base_model_path.is_dir():
raise ValueError(f"MERT-v2 directory does not exist: {args.base_model_path}")
if (args.lyrics_file is None) != (args.style is None):
raise ValueError("--lyrics-file and --style must be supplied together")
if args.lyrics_file is None:
if args.seed is not None:
raise ValueError("--seed applies to the YuE2 request; supply --lyrics-file and --style")
return None
lyrics = args.lyrics_file.read_text(encoding="utf-8").strip()
if not lyrics or not args.style.strip():
raise ValueError("Lyrics and --style must not be blank")
if not args.yue2_model.strip():
raise ValueError("--yue2-model must not be blank")
if args.seed is not None and not 0 <= args.seed < 2**63:
raise ValueError("--seed must be between 0 and 2**63 - 1")
return lyrics
def build_yue2_request(abc: str, lyrics: str, args: argparse.Namespace) -> dict[str, object]:
request: dict[str, object] = {
"model": args.yue2_model,
"input": lyrics,
"instructions": args.style.strip(),
"response_format": "wav",
"stream": False,
"extra_params": {"cot": "melody" if args.melody_only else "full", "abc": abc},
}
if args.seed is not None:
request["seed"] = args.seed
return request
def transcribe(args: argparse.Namespace) -> Path:
# Validate before loading dependencies/weights or touching an existing output.
lyrics = validate_args(args)
import torch
from transformers import AutoModel
if Path(args.model).is_dir():
# Transformers 4.45 only copies direct imports for local snapshots.
# Prime transitive dependencies too (e.g. chord_spelling_sheetsage2),
# using its cache API without modifying the upstream source files.
# The pinned snapshot keeps the entry point and all relative imports
# as sibling .py files; local snapshots must preserve that flat layout.
from transformers.dynamic_module_utils import get_cached_module_file, get_relative_import_files
source = Path(args.model) / "modeling_sheetsage2.py"
for dependency in get_relative_import_files(str(source)):
get_cached_module_file(args.model, Path(dependency).name, local_files_only=True)
torch.set_num_threads(min(4, torch.get_num_threads()))
device = args.device
if device == "auto":
device = torch.accelerator.current_accelerator().type if torch.accelerator.is_available() else "cpu"
revision = args.revision
if revision is None and args.model == DEFAULT_MODEL:
revision = DEFAULT_REVISION
load_options = {
"trust_remote_code": args.trust_remote_code,
"revision": revision,
"code_revision": revision,
"local_files_only": args.local_files_only,
# Upstream merges the MERT adapters before any reduced-precision cast.
"torch_dtype": torch.float32,
}
if args.base_model_path is not None:
load_options["base_model_path"] = str(args.base_model_path)
model = AutoModel.from_pretrained(args.model, **load_options).eval().to(device)
if model.config.model_type != "sheetsage2":
raise ValueError("--model must identify a SheetSage2 checkpoint")
args.output_dir.mkdir(parents=True, exist_ok=True)
result = model.transcribe(
str(args.audio),
output_dir=str(args.output_dir),
melody_only=args.melody_only,
dtype=args.dtype,
max_seconds=args.max_seconds,
)
abc = result.get("abc")
if result.get("abc_error") or not isinstance(abc, str) or not abc.strip():
raise RuntimeError(
f"ABC export failed: {result.get('abc_error') or 'empty score'}; no YuE2 request was written"
)
midi = result.get("midi")
if not isinstance(midi, bytes) or not midi.startswith(b"MThd"):
raise RuntimeError("MIDI export failed; no YuE2 request was written")
if lyrics is not None:
request = build_yue2_request(abc, lyrics, args)
(args.output_dir / "yue2_request.json").write_text(
json.dumps(request, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
manifest = {
"model": args.model,
"revision": revision,
"base_model_revision": getattr(model.config, "base_model_revision", None),
"audio": str(args.audio.resolve()),
"melody_only": args.melody_only,
"max_seconds": args.max_seconds,
"device": str(device),
"inference_dtype": args.dtype,
"torch_version": torch.__version__,
"backend": "upstream SheetSage2 transcribe",
}
(args.output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return args.output_dir
def main(argv: list[str] | None = None) -> None:
parser = create_parser()
args = parser.parse_args(argv)
try:
output_dir = transcribe(args)
except (ValueError, RuntimeError, OSError, ImportError) as exc:
parser.exit(1, f"SheetSage2: {exc}\nSee recipes/m-a-p/SheetSage2-H200.md for the isolated environment setup.\n")
print(f"Saved score.abc, transcription.mid and annotations to {output_dir.resolve()}")
if args.lyrics_file is not None:
print(f"YuE2 request: {output_dir.resolve() / 'yue2_request.json'}")
if __name__ == "__main__":
main()