File size: 13,426 Bytes
a771b29
 
3579235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f83304
3579235
 
 
57d5fba
3579235
57d5fba
e2c6c69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3579235
 
 
3f536a3
a771b29
3579235
 
 
 
 
 
 
 
 
 
 
aa51e5e
3579235
56cb069
 
3579235
 
 
 
3d6ff37
 
 
 
3579235
 
 
 
3d6ff37
 
3579235
 
 
 
 
 
 
39231d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a771b29
 
 
 
7c17e66
 
3579235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884eb42
 
 
 
3579235
884eb42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3579235
884eb42
 
 
 
 
3579235
884eb42
 
 
 
 
 
3579235
884eb42
 
 
 
 
 
 
 
 
3579235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884eb42
3579235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf4d1ea
 
 
 
2f83304
 
 
 
 
 
 
3f536a3
2f83304
 
3579235
 
a771b29
 
 
 
 
 
2f83304
 
 
 
3f536a3
 
 
 
 
2f83304
3579235
2f83304
3f536a3
 
7c17e66
3579235
3f536a3
 
 
3579235
 
 
 
 
8da4d5f
 
 
 
 
 
 
 
 
3f536a3
a3e6af0
3f536a3
 
 
8da4d5f
 
 
 
 
a571fd3
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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
import spaces  # imported first, as ZeroGPU requires

import os
import sys
import math
from pathlib import Path

ROOT = Path(__file__).resolve().parent
SONGFORMER = ROOT / "src" / "SongFormer"
os.chdir(SONGFORMER)
sys.path.insert(0, str(ROOT / "src" / "third_party"))
sys.path.insert(0, str(SONGFORMER))

import numpy as np

import gradio as gr
import torch
import librosa
import soundfile as sf
from omegaconf import OmegaConf
from ema_pytorch import EMA
from huggingface_hub import hf_hub_download
import easydict
from muq import MuQ

# MuQ hands its own config, wrapped in an EasyDict, to the models it builds.
# Transformers 5 reads config._attn_implementation while assembling attention,
# and an EasyDict raises AttributeError for a key it does not hold rather than
# returning None, so the load fails before any inference.
#
# This answers that one read without adding an attribute. Setting the value on
# the class does satisfy the read, but it also puts the name in dir(), and MuQ
# builds its submodules from the config's attribute names, so it comes back as
# an unexpected keyword argument. A __getattr__ hook runs only when a real
# lookup misses, so it stays out of dir(), vars() and **kwargs.
_easydict_getattr = getattr(easydict.EasyDict, "__getattr__", None)


def _attn_aware_getattr(self, name):
    if name == "_attn_implementation":
        return "eager"
    if _easydict_getattr is not None:
        return _easydict_getattr(self, name)
    raise AttributeError(name)


easydict.EasyDict.__getattr__ = _attn_aware_getattr
from musicfm.model.musicfm_25hz import MusicFM25Hz
from postprocessing.functional import postprocess_functional_structure
from dataset.label2id import DATASET_ID_ALLOWED_LABEL_IDS, DATASET_LABEL_TO_DATASET_ID
from pyharp import AudioLabel, LabelList, ModelCard, build_endpoint
from pyharp.tags import Channels, SampleRate, Subcategory

AFTER_DOWNSAMPLING_FRAME_RATES = 8.333
DATASET_LABEL = "SongForm-HX-8Class"
DATASET_IDS = [5]
TIME_DUR = 420
INPUT_SAMPLING_RATE = 24000

def initialize_models():
    from models.SongFormer import Model
    from safetensors.torch import load_file

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    hp = OmegaConf.load("configs/SongFormer.yaml")
    muq_model = MuQ.from_pretrained("OpenMuQ/MuQ-large-msd-iter",
                                    revision="0562a57814f6f8bbd9fdea0a25921a2fce1a841a")
    muq_model = muq_model.to(device).eval()

    musicfm_model = MusicFM25Hz(
        is_flash=False,
        stat_path=hf_hub_download("minzwon/MusicFM", "msd_stats.json",
                                  revision="4513b38bc25ad1d227b1980819b9691ba97f4d87"),
        model_path=hf_hub_download("minzwon/MusicFM", "pretrained_msd.pt",
                                   revision="4513b38bc25ad1d227b1980819b9691ba97f4d87"),
    )
    musicfm_model = musicfm_model.to(device).eval()

    msa_model = Model(hp)
    checkpoint = hf_hub_download("ASLP-lab/SongFormer", "SongFormer.safetensors",
                                 revision="a75880ed1b7375ac71860ec6c4fc9c899cf99515")
    model_ema = EMA(msa_model, include_online_model=False)
    model_ema.load_state_dict(load_file(checkpoint, device="cpu"))
    msa_model.load_state_dict(model_ema.ema_model.state_dict())
    msa_model.to(device).eval()
    return muq_model, musicfm_model, msa_model, hp, device


model_card = ModelCard(
    name="SongFormer",
    description=(
        "Analyze the section boundaries and labels of a music recording, which is mixed to "
        "mono and resampled to 24 kHz. The "
        "code is CC-BY-4.0, but it loads MuQ weights that are CC-BY-NC-4.0, so "
        "results cannot be used commercially. Repository: "
        "https://github.com/ASLP-lab/SongFormer Paper: "
        "https://arxiv.org/abs/2510.02797"
    ),
    author="Chunbo Hao, Ruibin Yuan, Jixun Yao, et al.",
    tags=[Subcategory.MUSIC_ANALYSIS, SampleRate(INPUT_SAMPLING_RATE), Channels(1)],
)


# pyharp runs process_fn in a worker, which imports this file as __mp_main__ and starts with the
# server, so the models are downloaded and placed there alone
if __name__ != "__main__":
    MODELS = initialize_models()


def process_audio(audio_path, muq_model, musicfm_model, msa_model, hp, device, win_size=420, hop_size=420, num_classes=128):
    wav, _ = librosa.load(audio_path, sr=INPUT_SAMPLING_RATE)
    audio = torch.tensor(wav).to(device)

    # Prepare output
    total_len = (
        (audio.shape[0] // INPUT_SAMPLING_RATE) // TIME_DUR * TIME_DUR
    ) + TIME_DUR
    total_frames = math.ceil(total_len * AFTER_DOWNSAMPLING_FRAME_RATES)

    logits = {
        "function_logits": np.zeros([total_frames, num_classes]),
        "boundary_logits": np.zeros([total_frames]),
    }
    logits_num = {
        "function_logits": np.zeros([total_frames, num_classes]),
        "boundary_logits": np.zeros([total_frames]),
    }

    # Prepare label masks
    dataset_id2label_mask = {}
    for key, allowed_ids in DATASET_ID_ALLOWED_LABEL_IDS.items():
        dataset_id2label_mask[key] = np.ones(num_classes, dtype=bool)
        dataset_id2label_mask[key][allowed_ids] = False

    lens = 0
    i = 0

    with torch.no_grad():
        while True:
            start_idx = i * INPUT_SAMPLING_RATE
            end_idx = min((i + win_size) * INPUT_SAMPLING_RATE, audio.shape[-1])
            if start_idx >= audio.shape[-1]:
                break
            if end_idx - start_idx <= 1024:
                break

            audio_seg = audio[start_idx:end_idx]

            # Get embeddings
            muq_output = muq_model(audio_seg.unsqueeze(0), output_hidden_states=True)
            muq_embd_420s = muq_output["hidden_states"][10]
            del muq_output
            torch.cuda.empty_cache()

            _, musicfm_hidden_states = musicfm_model.get_predictions(
                audio_seg.unsqueeze(0)
            )
            musicfm_embd_420s = musicfm_hidden_states[10]
            del musicfm_hidden_states
            torch.cuda.empty_cache()

            # Process 30-second segments
            wraped_muq_embd_30s = []
            wraped_musicfm_embd_30s = []

            for idx_30s in range(i, i + hop_size, 30):
                start_idx_30s = idx_30s * INPUT_SAMPLING_RATE
                end_idx_30s = min(
                    (idx_30s + 30) * INPUT_SAMPLING_RATE,
                    audio.shape[-1],
                    (i + hop_size) * INPUT_SAMPLING_RATE,
                )
                if start_idx_30s >= audio.shape[-1]:
                    break
                if end_idx_30s - start_idx_30s <= 1024:
                    continue

                wraped_muq_embd_30s.append(
                    muq_model(
                        audio[start_idx_30s:end_idx_30s].unsqueeze(0),
                        output_hidden_states=True,
                    )["hidden_states"][10]
                )
                torch.cuda.empty_cache()

                wraped_musicfm_embd_30s.append(
                    musicfm_model.get_predictions(
                        audio[start_idx_30s:end_idx_30s].unsqueeze(0)
                    )[1][10]
                )
                torch.cuda.empty_cache()

            wraped_muq_embd_30s = torch.concatenate(wraped_muq_embd_30s, dim=1)
            wraped_musicfm_embd_30s = torch.concatenate(
                wraped_musicfm_embd_30s, dim=1
            )

            all_embds = [
                wraped_musicfm_embd_30s,
                wraped_muq_embd_30s,
                musicfm_embd_420s,
                muq_embd_420s,
            ]

            # Align embedding lengths
            min_embd_len = min(x.shape[1] for x in all_embds)
            all_embds = [x[:, :min_embd_len, :] for x in all_embds]

            embd = torch.concatenate(all_embds, axis=-1)

            # Inference
            dataset_ids = torch.Tensor(DATASET_IDS).to(device, dtype=torch.long)
            msa_info, chunk_logits = msa_model.infer(
                input_embeddings=embd,
                dataset_ids=dataset_ids,
                label_id_masks=torch.Tensor(
                    dataset_id2label_mask[
                        DATASET_LABEL_TO_DATASET_ID[DATASET_LABEL]
                    ]
                )
                .to(device, dtype=bool)
                .unsqueeze(0)
                .unsqueeze(0),
                with_logits=True,
            )

            # Accumulate logits
            start_frame = int(i * AFTER_DOWNSAMPLING_FRAME_RATES)
            end_frame = start_frame + min(
                math.ceil(hop_size * AFTER_DOWNSAMPLING_FRAME_RATES),
                chunk_logits["boundary_logits"][0].shape[0],
            )

            logits["function_logits"][start_frame:end_frame, :] += (
                chunk_logits["function_logits"][0].detach().cpu().numpy()
            )
            logits["boundary_logits"][start_frame:end_frame] = (
                chunk_logits["boundary_logits"][0].detach().cpu().numpy()
            )
            logits_num["function_logits"][start_frame:end_frame, :] += 1
            logits_num["boundary_logits"][start_frame:end_frame] += 1
            lens += end_frame - start_frame

            i += hop_size

    # Average logits
    logits["function_logits"] /= np.maximum(logits_num["function_logits"], 1)
    logits["boundary_logits"] /= np.maximum(logits_num["boundary_logits"], 1)

    logits["function_logits"] = torch.from_numpy(
        logits["function_logits"][:lens]
    ).unsqueeze(0)
    logits["boundary_logits"] = torch.from_numpy(
        logits["boundary_logits"][:lens]
    ).unsqueeze(0)

    # Post-process
    msa_infer_output = postprocess_functional_structure(logits, hp)

    return logits, msa_infer_output


def rule_post_processing(msa_list):
    if len(msa_list) <= 2:
        return msa_list

    result = msa_list.copy()

    while len(result) > 2:
        first_duration = result[1][0] - result[0][0]
        if first_duration < 1.0:
            result[0] = (result[0][0], result[1][1])
            result = [result[0]] + result[2:]
        else:
            break

    while len(result) > 2:
        last_label_duration = result[-1][0] - result[-2][0]
        if last_label_duration < 1.0:
            result = result[:-2] + [result[-1]]
        else:
            break

    while len(result) > 2:
        if result[0][1] == result[1][1] and result[1][0] <= 10.0:
            result = [(result[0][0], result[0][1])] + result[2:]
        else:
            break

    while len(result) > 2:
        last_duration = result[-1][0] - result[-2][0]
        if result[-2][1] == result[-3][1] and last_duration <= 10.0:
            result = result[:-2] + [result[-1]]
        else:
            break

    return result


# PyHARP stops a job that runs past its timeout_s, 15 minutes by default, so a request whose
# reservation would exceed that is refused rather than started. A shorter one is left to
# ZeroGPU, which admits it when the caller's own allowance covers it.
MAX_GPU_SECONDS = 900


def gpu_seconds(audio_path: str) -> float:
    # Measured at about 6 s for 30 s of audio, padded
    return 15 + 0.3 * sf.info(audio_path).duration


def process_fn(audio_path: str | None) -> tuple[str, LabelList]:
    # Checked before the GPU is reserved, so a recording the model cannot take spends none of the
    # caller's ZeroGPU allowance
    if not audio_path:
        raise gr.Error("Upload a music recording.")
    try:
        duration = sf.info(audio_path).duration
    except Exception as exc:
        raise gr.Error(f"Could not read the audio file: {exc}") from exc
    if duration < 5:
        raise gr.Error("Audio must be at least 5 seconds long.")
    if gpu_seconds(audio_path) > MAX_GPU_SECONDS:
        limit = (MAX_GPU_SECONDS - 15) / 0.3 / 60
        raise gr.Error(f"Trim the recording to {limit:.0f} minutes or less.")

    labels = LabelList()
    for start, end, section in analyze(audio_path):
        labels.append(AudioLabel(t=start, duration=end - start, label=section))

    return audio_path, labels


@spaces.GPU(duration=gpu_seconds)
def analyze(audio_path: str) -> list[tuple[float, float, str]]:
    """The sections of the recording, each as its start and end in seconds and its label."""
    _, msa_output = process_audio(audio_path, *MODELS)
    msa_output = rule_post_processing(msa_output)
    return [
        (round(float(msa_output[index][0]), 2), round(float(msa_output[index + 1][0]), 2),
         msa_output[index][1])
        for index in range(len(msa_output) - 1)
    ]


if __name__ == "__main__":
    with gr.Blocks(title="SongFormer Music Structure Analysis") as demo:
        build_endpoint(
            model_card=model_card,
            input_components=[
                gr.Audio(type="filepath", label="Music Recording")
                .harp_required(True)
                .set_info("Upload a music recording for section analysis."),
            ],
            output_components=[
                gr.Audio(type="filepath", label="Audio with Sections")
                .set_info("The input, returned to carry the section labels."),
                gr.JSON(label="Sections")
                .set_info("Each section as a label over the audio, from its start to the next "
                          "boundary."),
            ],
            process_fn=process_fn,
        )


    demo.queue(default_concurrency_limit=1).launch(pwa=True, show_error=True)