File size: 8,429 Bytes
d8d28a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89eb823
d8d28a6
 
89eb823
 
c62ef95
97aa642
d8d28a6
 
 
 
 
 
89eb823
 
 
 
 
 
 
 
 
d8d28a6
89eb823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d8d28a6
 
7315c49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d8d28a6
 
 
 
 
 
 
 
 
 
 
c14d57a
 
 
 
d8d28a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c62ef95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97aa642
 
 
d8d28a6
 
 
 
 
 
c62ef95
 
 
 
 
 
 
 
d8d28a6
c62ef95
 
 
d8d28a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8672734
 
d8d28a6
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Custom Hugging Face Inference Endpoint handler.

Loads Google's Multi-Instrument Spectrogram Diffusion
(`google/music-spectrogram-diffusion`) from the endpoint's local snapshot
and turns a MIDI file into a 16 kHz WAV.

This file must live at the *root* of the Hub repo the endpoint points at,
alongside `requirements.txt`. `google/music-spectrogram-diffusion` has no
`handler.py` and no Transformers `config.json`, so a Custom task against
that repo falls back to `pipeline()` and crashes. Fork the model, add these
two files, then set the endpoint to that fork with task=custom.

Request body (Band's `/midi-to-audio` client):

    { "inputs": "<base64 .mid>", "parameters": { "prompt": "optional extra text" } }

Response:

    { "audio_base64": "<base64 wav>", "sample_rate": 16000, "format": "wav" }

The prompt is accepted so Band can send clip text; this pipeline does not
condition on it. Keep the field so a later handler can without a client change.
"""
from __future__ import annotations

import base64
import importlib
import io
import os
import subprocess
import sys
import tempfile
import traceback
import wave
from typing import Any

import numpy as np
import torch


def _ensure_pkg(module: str, *pip_names: str) -> None:
    try:
        importlib.import_module(module)
        return
    except ImportError:
        pass
    subprocess.check_call(
        [sys.executable, "-m", "pip", "install", "--no-cache-dir", *pip_names],
    )
    importlib.invalidate_caches()
    importlib.import_module(module)


# Diffusers exposes dummy SpectrogramDiffusionPipeline objects unless note-seq
# is present *before* that symbol is first resolved. Install extras first.
_ensure_pkg("note_seq", "note-seq", "pretty_midi")
_ensure_pkg("onnxruntime", "onnxruntime")


def _pipeline_classes():
    try:
        from diffusers import MidiProcessor, SpectrogramDiffusionPipeline
    except ImportError:  # moved under deprecated/ in newer Diffusers
        from diffusers.pipelines.deprecated.spectrogram_diffusion import (
            MidiProcessor,
            SpectrogramDiffusionPipeline,
        )
    dummy = "dummy" in getattr(SpectrogramDiffusionPipeline, "__module__", "")
    dummy = dummy or bool(getattr(SpectrogramDiffusionPipeline, "_backends", None))
    if not dummy:
        return MidiProcessor, SpectrogramDiffusionPipeline
    for key in list(sys.modules):
        if key == "diffusers" or key.startswith("diffusers."):
            del sys.modules[key]
    from diffusers import MidiProcessor, SpectrogramDiffusionPipeline
    return MidiProcessor, SpectrogramDiffusionPipeline


MidiProcessor, SpectrogramDiffusionPipeline = _pipeline_classes()


def _patch_t5_cache_position() -> None:
    """Newer Transformers T5Attention requires cache_position; this pipeline never passes it."""
    import inspect

    try:
        from transformers.models.t5.modeling_t5 import T5Attention
    except ImportError:
        return
    orig = T5Attention.forward
    if "cache_position" not in inspect.signature(orig).parameters:
        return

    def wrapped(self, *args, **kwargs):  # type: ignore[no-untyped-def]
        if kwargs.get("cache_position") is None:
            hidden = args[0] if args else kwargs.get("hidden_states")
            if hidden is not None:
                kwargs["cache_position"] = torch.arange(hidden.shape[1], device=hidden.device)
        return orig(self, *args, **kwargs)

    T5Attention.forward = wrapped  # type: ignore[method-assign]


_patch_t5_cache_position()


SAMPLE_RATE = 16000
FALLBACK_MODEL_ID = os.environ.get("HF_MSD_MODEL_ID", "google/music-spectrogram-diffusion")


class EndpointHandler:
    def __init__(self, path: str = "") -> None:
        # Inference Endpoints pass the local snapshot (`/repository`), which
        # has `model_index.json` + decoder/encoder/melgan weights. Load that,
        # not the Hub id — otherwise the replica re-downloads and ignores the
        # fork this handler was deployed from.
        model_path = path if _has_diffusers_index(path) else FALLBACK_MODEL_ID
        if _has_diffusers_index(path):
            # model_index.json names encoders as library `spectrogram_diffusion`.
            # The shim package lives next to this handler in the snapshot.
            sys.path.insert(0, os.path.abspath(path))
        device = "cuda" if torch.cuda.is_available() else "cpu"
        dtype = torch.float16 if device == "cuda" else torch.float32
        self.pipe = SpectrogramDiffusionPipeline.from_pretrained(
            model_path,
            torch_dtype=dtype,
        )
        try:
            self.pipe.to(device)
        except Exception:
            # MelGAN is ONNX and may refuse `.to()`. Move the PyTorch modules.
            for name in ("notes_encoder", "continuous_encoder", "decoder"):
                module = getattr(self.pipe, name, None)
                if module is not None:
                    module.to(device)
        self.processor = MidiProcessor()
        self.device = device

    def __call__(self, data: Any) -> dict[str, Any]:
        try:
            payload = data if isinstance(data, dict) else {"inputs": data}
            midi_bytes = _midi_bytes(payload)
            params = payload.get("parameters") if isinstance(payload.get("parameters"), dict) else {}
            kwargs: dict[str, Any] = {}
            steps = params.get("num_inference_steps")
            if steps is not None:
                kwargs["num_inference_steps"] = max(1, int(steps))

            with tempfile.NamedTemporaryFile(suffix=".mid", delete=True) as tmp:
                tmp.write(midi_bytes)
                tmp.flush()
                tokens = self.processor(tmp.name)
            if not tokens:
                raise ValueError("MIDI produced no spectrogram tokens (clip too short?)")
            audio = self.pipe(tokens, **kwargs)
            samples = _to_mono_float(audio)
            wav = _wav_bytes(samples, SAMPLE_RATE)
            return {
                "audio_base64": base64.b64encode(wav).decode("ascii"),
                "sample_rate": SAMPLE_RATE,
                "format": "wav",
            }
        except Exception as err:
            raise RuntimeError(
                f"{type(err).__name__}: {err}; {_payload_preview(data)}\n{traceback.format_exc()}"
            ) from err


def _has_diffusers_index(path: str) -> bool:
    return bool(path) and os.path.isfile(os.path.join(path, "model_index.json"))


def _payload_preview(data: Any) -> str:
    if isinstance(data, dict):
        keys = ",".join(sorted(str(k) for k in data.keys()))
        raw = data.get("inputs", data.get("midi_base64"))
        return f"payload_keys={keys} inputs_type={type(raw).__name__}"
    return f"payload_type={type(data).__name__}"


def _midi_bytes(payload: dict[str, Any]) -> bytes:
    raw = payload.get("inputs")
    if raw is None:
        raw = payload.get("midi_base64", "")
    if isinstance(raw, list) and raw:
        raw = raw[0]
    if isinstance(raw, dict):
        raw = raw.get("midi_base64") or raw.get("inputs") or raw.get("data") or ""
    if isinstance(raw, (bytes, bytearray)):
        midi = bytes(raw)
    elif isinstance(raw, str) and raw:
        text = raw.strip()
        if text.lower().startswith("data:") and "," in text:
            text = text.split(",", 1)[1]
        midi = base64.b64decode(text)
    else:
        raise ValueError("missing base64 MIDI in inputs")
    if len(midi) < 8 or midi[:4] != b"MThd":
        raise ValueError("inputs is not a MIDI file")
    return midi


def _to_mono_float(audio: Any) -> np.ndarray:
    if hasattr(audio, "audios") and audio.audios is not None:
        arr = np.asarray(audio.audios, dtype=np.float32)
    elif isinstance(audio, (list, tuple)) and audio:
        arr = np.asarray(audio[0], dtype=np.float32)
    else:
        arr = np.asarray(audio, dtype=np.float32)
    arr = np.squeeze(arr)
    if arr.ndim > 1:
        arr = arr.reshape(-1)
    peak = float(np.max(np.abs(arr))) if arr.size else 0.0
    if peak > 1e-6:
        arr = arr * (0.89 / peak)
    return arr


def _wav_bytes(samples: np.ndarray, rate: int) -> bytes:
    pcm = np.clip(samples, -1.0, 1.0)
    pcm = (pcm * 32767.0).astype(np.int16)
    buf = io.BytesIO()
    with wave.open(buf, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(rate)
        wf.writeframes(pcm.tobytes())
    return buf.getvalue()