imChuling commited on
Commit
ebd2308
·
1 Parent(s): 2a6e572

Deploy MuQ-MuLan music-text similarity endpoint

Browse files
Files changed (7) hide show
  1. .gitignore +6 -0
  2. README.md +42 -8
  3. SOURCES.md +30 -0
  4. app.py +164 -0
  5. model.json +41 -0
  6. muq_mulan_runtime.py +110 -0
  7. requirements.txt +9 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .venv/
2
+ .ruff_cache/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .DS_Store
6
+ outputs/
README.md CHANGED
@@ -1,15 +1,49 @@
1
  ---
2
- title: MuQ MuLan
3
- emoji: 👀
4
- colorFrom: red
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: cc-by-nc-4.0
12
- short_description: Music-text similarity for music clips
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MuQ-MuLan
3
+ emoji: 🎧
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: 5.28.0
8
+ python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: cc-by-nc-4.0
12
+ short_description: Rank music descriptions against an audio clip
13
  ---
14
 
15
+ # MuQ-MuLan Music-Text Similarity
16
+
17
+ A HARP-compatible deployment of MuQ-MuLan for comparing a music clip with
18
+ candidate text descriptions. It can support workflows such as tagging,
19
+ searching a sample library, or choosing the description that best matches a
20
+ piece of music.
21
+
22
+ The Space loads the official MuQ-MuLan-large checkpoint from Hugging Face.
23
+ Model files are downloaded at runtime and cached by the Space.
24
+
25
+ ## Inputs
26
+
27
+ - One music audio clip between 10 and 60 seconds
28
+ - One to eight candidate descriptions, one per line
29
+
30
+ Audio is converted to mono and resampled to 24 kHz. MuQ-MuLan processes
31
+ 10-second windows and averages their embeddings for longer clips. Text may be
32
+ written in English or Chinese.
33
+
34
+ ## Output
35
+
36
+ A JSON file containing the candidate descriptions in descending similarity
37
+ order. Scores are cosine similarities from -1 to 1, not calibrated
38
+ probabilities.
39
+
40
+ ## Sources
41
+
42
+ See [SOURCES.md](SOURCES.md) for the model, source revision, license, and
43
+ associated paper.
44
+
45
+ ## License
46
+
47
+ The upstream source code is MIT licensed. The MuQ-MuLan model weights are
48
+ released under CC BY-NC 4.0, so this deployment is intended for
49
+ non-commercial use.
SOURCES.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sources
2
+
3
+ ## MuQ and MuQ-MuLan
4
+
5
+ - Paper: https://arxiv.org/abs/2501.01108
6
+ - Source: https://github.com/tencent-ailab/MuQ
7
+ - Pinned source revision:
8
+ `28847ea50cd31ac4b8b6a7dacc051ad7d1c7606a`
9
+ - Source license: MIT
10
+ - Checkpoint: https://huggingface.co/OpenMuQ/MuQ-MuLan-large
11
+ - Pinned checkpoint revision:
12
+ `2e01c796b71dca71b45251384c04cd7b237c9020`
13
+ - Checkpoint license: CC BY-NC 4.0
14
+
15
+ MuQ-MuLan is a joint music-text embedding model trained through contrastive
16
+ learning. Its audio and text embeddings are L2-normalized, so their dot
17
+ product is cosine similarity. The official model supports English and Chinese
18
+ text. For audio longer than 10 seconds, the official inference code embeds
19
+ non-overlapping 10-second windows and averages their representations.
20
+
21
+ ## Encoders
22
+
23
+ - Audio encoder: https://huggingface.co/OpenMuQ/MuQ-large-msd-iter
24
+ - Pinned audio revision:
25
+ `0562a57814f6f8bbd9fdea0a25921a2fce1a841a`
26
+ - Audio model license: CC BY-NC 4.0
27
+ - Text encoder: https://huggingface.co/FacebookAI/xlm-roberta-base
28
+ - Pinned text revision:
29
+ `e73636d4f797dec63c3081bb6ed5c7b0bb3f2089`
30
+ - Text model license: MIT
app.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tempfile
5
+ import uuid
6
+ from pathlib import Path
7
+
8
+ import gradio as gr
9
+ import soundfile as sf
10
+
11
+ try:
12
+ import spaces
13
+ except ImportError:
14
+ class spaces:
15
+ class GPU:
16
+ def __init__(self, func=None, duration=60):
17
+ self.func = func
18
+
19
+ def __call__(self, *args, **kwargs):
20
+ if self.func is not None:
21
+ return self.func(*args, **kwargs)
22
+ return args[0]
23
+
24
+ from pyharp import ModelCard, build_endpoint
25
+
26
+ from muq_mulan_runtime import rank_descriptions
27
+
28
+ MIN_AUDIO_SECONDS = 10
29
+ MAX_AUDIO_SECONDS = 60
30
+ MAX_DESCRIPTIONS = 8
31
+ MAX_DESCRIPTION_LENGTH = 300
32
+ OUTPUT_ROOT = Path(tempfile.gettempdir()) / "muq_mulan_outputs"
33
+
34
+ model_card = ModelCard(
35
+ name="MuQ-MuLan",
36
+ description=(
37
+ "Rank English or Chinese music descriptions by their similarity "
38
+ "to an uploaded music clip."
39
+ ),
40
+ author="Tencent AI Lab",
41
+ tags=[
42
+ "music-information-retrieval",
43
+ "music-text-retrieval",
44
+ "music-tagging",
45
+ "audio-analysis",
46
+ ],
47
+ )
48
+
49
+
50
+ def _validate_audio(path: str | None) -> str:
51
+ if not path:
52
+ raise gr.Error("Please upload a music clip.")
53
+
54
+ try:
55
+ duration = sf.info(path).duration
56
+ except Exception as exc:
57
+ raise gr.Error(f"Could not read the audio file: {exc}") from exc
58
+
59
+ if duration < MIN_AUDIO_SECONDS:
60
+ raise gr.Error(
61
+ f"Audio must be at least {MIN_AUDIO_SECONDS} seconds long. "
62
+ f"Received {duration:.1f} seconds."
63
+ )
64
+ if duration > MAX_AUDIO_SECONDS:
65
+ raise gr.Error(
66
+ f"Audio must be no longer than {MAX_AUDIO_SECONDS} seconds. "
67
+ f"Received {duration:.1f} seconds."
68
+ )
69
+ return path
70
+
71
+
72
+ def _parse_descriptions(value: str | None) -> list[str]:
73
+ descriptions = [
74
+ line.strip()
75
+ for line in (value or "").splitlines()
76
+ if line.strip()
77
+ ]
78
+ if not descriptions:
79
+ raise gr.Error("Enter at least one music description.")
80
+ if len(descriptions) > MAX_DESCRIPTIONS:
81
+ raise gr.Error(
82
+ f"Enter no more than {MAX_DESCRIPTIONS} descriptions."
83
+ )
84
+ if any(len(description) > MAX_DESCRIPTION_LENGTH for description in descriptions):
85
+ raise gr.Error(
86
+ "Each description must be no more than "
87
+ f"{MAX_DESCRIPTION_LENGTH} characters."
88
+ )
89
+ return descriptions
90
+
91
+
92
+ @spaces.GPU(duration=240)
93
+ def process_fn(
94
+ input_audio: str | None,
95
+ candidate_descriptions: str | None,
96
+ ) -> str:
97
+ input_audio = _validate_audio(input_audio)
98
+ descriptions = _parse_descriptions(candidate_descriptions)
99
+
100
+ try:
101
+ results = rank_descriptions(input_audio, descriptions)
102
+ except Exception as exc:
103
+ raise gr.Error(f"MuQ-MuLan inference failed: {exc}") from exc
104
+
105
+ output_dir = OUTPUT_ROOT / uuid.uuid4().hex
106
+ output_dir.mkdir(parents=True, exist_ok=True)
107
+ output_path = output_dir / "muq_mulan_similarity.json"
108
+ output_path.write_text(
109
+ json.dumps(
110
+ {
111
+ "model": "OpenMuQ/MuQ-MuLan-large",
112
+ "score_type": "cosine_similarity",
113
+ "score_range": [-1.0, 1.0],
114
+ "results": results,
115
+ },
116
+ ensure_ascii=False,
117
+ indent=2,
118
+ )
119
+ + "\n",
120
+ encoding="utf-8",
121
+ )
122
+ return str(output_path)
123
+
124
+
125
+ with gr.Blocks(title="MuQ-MuLan Music-Text Similarity") as demo:
126
+ input_components = [
127
+ gr.Audio(
128
+ type="filepath",
129
+ label="Music Audio",
130
+ )
131
+ .harp_required(True)
132
+ .set_info("Music clip between 10 and 60 seconds long."),
133
+ gr.Textbox(
134
+ lines=5,
135
+ label="Candidate Descriptions",
136
+ placeholder=(
137
+ "upbeat electronic dance music\n"
138
+ "slow acoustic ballad\n"
139
+ "一首轻快的钢琴曲"
140
+ ),
141
+ )
142
+ .harp_required(True)
143
+ .set_info("Enter one English or Chinese description per line."),
144
+ ]
145
+ output_components = [
146
+ gr.File(
147
+ type="filepath",
148
+ file_types=[".json"],
149
+ label="Similarity Ranking",
150
+ ).set_info("Descriptions ranked by cosine similarity."),
151
+ ]
152
+ build_endpoint(
153
+ model_card=model_card,
154
+ input_components=input_components,
155
+ output_components=output_components,
156
+ process_fn=process_fn,
157
+ )
158
+
159
+
160
+ if __name__ == "__main__":
161
+ demo.queue(default_concurrency_limit=1).launch(
162
+ show_error=True,
163
+ pwa=True,
164
+ )
model.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "MuQ-MuLan",
3
+ "task": "Music-text similarity and description ranking",
4
+ "source_repository": "tencent-ailab/MuQ",
5
+ "source_revision": "28847ea50cd31ac4b8b6a7dacc051ad7d1c7606a",
6
+ "source_license": "MIT",
7
+ "checkpoint": {
8
+ "repo": "OpenMuQ/MuQ-MuLan-large",
9
+ "revision": "2e01c796b71dca71b45251384c04cd7b237c9020",
10
+ "license": "CC BY-NC 4.0"
11
+ },
12
+ "audio_encoder": {
13
+ "repo": "OpenMuQ/MuQ-large-msd-iter",
14
+ "revision": "0562a57814f6f8bbd9fdea0a25921a2fce1a841a",
15
+ "license": "CC BY-NC 4.0"
16
+ },
17
+ "text_encoder": {
18
+ "repo": "FacebookAI/xlm-roberta-base",
19
+ "revision": "e73636d4f797dec63c3081bb6ed5c7b0bb3f2089",
20
+ "license": "MIT"
21
+ },
22
+ "input": {
23
+ "sample_rate": 24000,
24
+ "channels": 1,
25
+ "minimum_duration_seconds": 10,
26
+ "maximum_duration_seconds": 60,
27
+ "window_seconds": 10,
28
+ "maximum_descriptions": 8,
29
+ "text_languages": [
30
+ "English",
31
+ "Chinese"
32
+ ]
33
+ },
34
+ "output": {
35
+ "type": "cosine_similarity_ranking",
36
+ "range": [
37
+ -1.0,
38
+ 1.0
39
+ ]
40
+ }
41
+ }
muq_mulan_runtime.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+
7
+ import soundfile as sf
8
+ import torch
9
+ import torchaudio.functional as AF
10
+ from huggingface_hub import hf_hub_download, snapshot_download
11
+ from muq import MuQMuLan
12
+
13
+ SAMPLE_RATE = 24_000
14
+ MODEL_REPO = "OpenMuQ/MuQ-MuLan-large"
15
+ MODEL_REVISION = "2e01c796b71dca71b45251384c04cd7b237c9020"
16
+ AUDIO_MODEL_REPO = "OpenMuQ/MuQ-large-msd-iter"
17
+ AUDIO_MODEL_REVISION = "0562a57814f6f8bbd9fdea0a25921a2fce1a841a"
18
+ TEXT_MODEL_REPO = "xlm-roberta-base"
19
+ TEXT_MODEL_REVISION = "e73636d4f797dec63c3081bb6ed5c7b0bb3f2089"
20
+
21
+
22
+ def _select_device() -> str:
23
+ if torch.cuda.is_available():
24
+ return "cuda"
25
+ if torch.backends.mps.is_available():
26
+ return "mps"
27
+ return "cpu"
28
+
29
+
30
+ @lru_cache(maxsize=1)
31
+ def _load_model() -> MuQMuLan:
32
+ config_path = hf_hub_download(
33
+ repo_id=MODEL_REPO,
34
+ filename="config.json",
35
+ revision=MODEL_REVISION,
36
+ )
37
+ config = json.loads(Path(config_path).read_text(encoding="utf-8"))
38
+
39
+ audio_model_path = snapshot_download(
40
+ repo_id=AUDIO_MODEL_REPO,
41
+ revision=AUDIO_MODEL_REVISION,
42
+ allow_patterns=[
43
+ "config.json",
44
+ "model.safetensors",
45
+ ],
46
+ )
47
+ text_model_path = snapshot_download(
48
+ repo_id=TEXT_MODEL_REPO,
49
+ revision=TEXT_MODEL_REVISION,
50
+ allow_patterns=[
51
+ "config.json",
52
+ "model.safetensors",
53
+ "sentencepiece.bpe.model",
54
+ "special_tokens_map.json",
55
+ "tokenizer.json",
56
+ "tokenizer_config.json",
57
+ ],
58
+ )
59
+ config["audio_model"]["name"] = audio_model_path
60
+ config["text_model"]["name"] = text_model_path
61
+
62
+ return MuQMuLan.from_pretrained(
63
+ MODEL_REPO,
64
+ revision=MODEL_REVISION,
65
+ config=config,
66
+ ).eval()
67
+
68
+
69
+ def _load_audio(path: str) -> torch.Tensor:
70
+ audio, sample_rate = sf.read(
71
+ Path(path),
72
+ dtype="float32",
73
+ always_2d=True,
74
+ )
75
+ waveform = torch.from_numpy(audio).mean(dim=1)
76
+ if sample_rate != SAMPLE_RATE:
77
+ waveform = AF.resample(waveform, sample_rate, SAMPLE_RATE)
78
+ return waveform.unsqueeze(0)
79
+
80
+
81
+ @torch.inference_mode()
82
+ def rank_descriptions(
83
+ audio_path: str,
84
+ descriptions: list[str],
85
+ ) -> list[dict[str, object]]:
86
+ device_name = _select_device()
87
+ device = torch.device(device_name)
88
+ model = _load_model().to(device)
89
+ waveform = _load_audio(audio_path).to(device)
90
+
91
+ audio_embedding = model(wavs=waveform)
92
+ text_embeddings = model(texts=descriptions)
93
+ scores = model.calc_similarity(
94
+ audio_embedding,
95
+ text_embeddings,
96
+ )[0].detach().cpu().tolist()
97
+
98
+ ranked = sorted(
99
+ zip(descriptions, scores),
100
+ key=lambda item: item[1],
101
+ reverse=True,
102
+ )
103
+ return [
104
+ {
105
+ "rank": index,
106
+ "description": description,
107
+ "similarity": round(float(score), 6),
108
+ }
109
+ for index, (description, score) in enumerate(ranked, start=1)
110
+ ]
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio==5.28.0
2
+ torch==2.8.0
3
+ torchaudio==2.8.0
4
+ transformers==4.53.3
5
+ huggingface-hub>=0.33,<1
6
+ numpy>=1.26,<3
7
+ soundfile>=0.12.1,<1
8
+ git+https://github.com/tencent-ailab/MuQ.git@28847ea50cd31ac4b8b6a7dacc051ad7d1c7606a
9
+ git+https://github.com/TEAMuP-dev/pyharp.git@d65c4f7d0264dcdb3024a6c5466cddd7b2defdca