File size: 14,338 Bytes
cfbcb75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# SPDX-License-Identifier: Apache-2.0
"""HTTP server for DiffRhythm 1.2 Full on one Blackhole p100a, everything in one process.

    uvicorn tt_diffrhythm.server:app --host 0.0.0.0 --port 20000

DiffRhythm's own sampling loop runs here on the CPU and the CFM transformer and the stable-audio
VAE decoder run on the card, joined by `LocalWorker` instead of the Unix socket `remote.py` uses.
That split existed because DiffRhythm and tt-metal could not share an interpreter in the
deployment this port came from; an image built for this model alone resolves one environment that
satisfies both, so the socket is unnecessary there. See `local.py`.

Requires DiffRhythm itself (its `model`, `infer` and `g2p` trees) importable beside this one -- it
is the model, and this package only moves two of its stages onto the card.
"""
from __future__ import annotations

import base64
import io
import os
import random
import re
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from threading import Lock
from typing import Optional

import torch
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field

from .local import LocalWorker
from .remote import RemoteDiffRhythmDiT, RemoteVae

LICENSE = "apache-2.0"
WEIGHTS_REPO = "ASLP-lab/DiffRhythm-1_2-full"
VAE_REPO = "ASLP-lab/DiffRhythm-vae"
STYLE_REPO = "OpenMuQ/MuQ-MuLan-large"
STEPS = 32  # the schedule this port's shapes are compiled for
CFG_STRENGTH = 4.0
MAX_FRAMES = 6144  # the "full" checkpoint: 285 seconds at 21.5 frames per second
MIN_DURATION, MAX_DURATION = 96, 285
SAMPLE_RATE = 44100
UPSAMPLE = 2048  # VAE frames -> audio samples
TURN_WAIT_S = float(os.environ.get("MUSIC_TURN_WAIT_S", "1800"))

# DiffRhythm reads `./config/diffrhythm-1b.json` and `./g2p/g2p/vocab.json` relative to the working
# directory, so the server runs from the checkout rather than rewriting upstream's path handling.
DIFFRHYTHM_ROOT = os.environ.get("DIFFRHYTHM_ROOT", "")

TIMESTAMP = re.compile(r"^\[(\d{1,2}):(\d{2}(?:\.\d{1,3})?)\]\s*(.*)$")
SECTION = re.compile(r"^\[[A-Za-z][^\]]*\]$")

STATE: dict = {"status": "loading", "error": None, "generating": False, "load_s": None, "dram": None}
WORKER: Optional[LocalWorker] = None
CFM = TOKENIZER = STYLE = None
LOCK = Lock()


def _root() -> Path:
    """The DiffRhythm checkout: DIFFRHYTHM_ROOT, or the one shipped beside this package."""
    if DIFFRHYTHM_ROOT:
        return Path(DIFFRHYTHM_ROOT).resolve()
    here = Path(__file__).resolve().parent.parent
    if (here / "infer" / "infer_utils.py").is_file():
        return here
    raise RuntimeError("set DIFFRHYTHM_ROOT to the DiffRhythm checkout (the directory holding infer/)")


def _point_phonemizer_at_a_bundled_espeak() -> None:
    """Let phonemizer find a libespeak-ng that came from a wheel instead of a system package.

    `g2p.utils.g2p` builds its `EspeakBackend`s at import, so espeak has to be resolvable before
    upstream is imported at all. Everywhere but a container that means `apt install espeak-ng`;
    the `espeakng-loader` wheel carries the library and its data instead. Both have to be set --
    the library alone leaves espeak looking for `phontab` under the path it was built at, which
    does not exist here.

    Does nothing when the wheel is absent, or when PHONEMIZER_ESPEAK_LIBRARY already names a
    library: a system espeak-ng is the better answer where there is one, and an explicit choice
    is not ours to override. (Asking `EspeakWrapper` whether one is set is not the way to decide
    -- `library_path` is a property on the class, so reading it off the class hands back the
    property object, which is never None.)
    """
    if os.environ.get("PHONEMIZER_ESPEAK_LIBRARY"):
        return
    try:
        import espeakng_loader
        from phonemizer.backend.espeak.wrapper import EspeakWrapper
    except ImportError:
        return
    EspeakWrapper.set_library(str(espeakng_loader.get_library_path()))
    if hasattr(EspeakWrapper, "set_data_path"):
        EspeakWrapper.set_data_path(str(espeakng_loader.get_data_path()))
    else:  # older phonemizer: espeak reads the data path from its own environment variable
        os.environ.setdefault("ESPEAK_DATA_PATH", str(espeakng_loader.get_data_path()))


def _enter_checkout() -> Path:
    """Make upstream importable and its relative paths resolve, the way its own scripts do."""
    root = _root()
    _point_phonemizer_at_a_bundled_espeak()
    for path in (root, root / "infer"):
        if str(path) not in sys.path:
            sys.path.insert(0, str(path))
    os.chdir(root)
    return root


def _load_cfm(root: Path, device: str = "cpu"):
    """`infer_utils.prepare_model` without the VAE (the card decodes) and without its
    CWD-relative download cache, so the weights land in the ordinary HF cache."""
    import json

    from huggingface_hub import hf_hub_download
    from infer_utils import CNENTokenizer, load_checkpoint
    from model import CFM as CFMModel
    from model import DiT

    with open(root / "config" / "diffrhythm-1b.json") as f:
        config = json.load(f)
    cfm = CFMModel(transformer=DiT(**config["model"], max_frames=MAX_FRAMES),
                   num_channels=config["model"]["mel_dim"], max_frames=MAX_FRAMES).to(device)
    cfm = load_checkpoint(cfm, hf_hub_download(WEIGHTS_REPO, "cfm_model.pt"), device=device, use_ema=False)
    return cfm, CNENTokenizer()


def _style_model(device: str = "cpu"):
    from muq import MuQMuLan

    return MuQMuLan.from_pretrained(STYLE_REPO).to(device).eval().float()


@asynccontextmanager
async def lifespan(_app: FastAPI):
    global WORKER, CFM, TOKENIZER, STYLE
    started = time.monotonic()
    try:
        import gc

        from huggingface_hub import hf_hub_download

        os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
        root = _enter_checkout()
        WORKER = LocalWorker(hf_hub_download(WEIGHTS_REPO, "cfm_model.pt"),
                             vae=hf_hub_download(VAE_REPO, "vae_model.pt")).start()
        cfm, TOKENIZER = _load_cfm(root)
        # Replacing the transformer drops the host copy of the big weights; the card holds them.
        cfm.transformer = RemoteDiffRhythmDiT(WORKER, cfm.transformer)
        CFM = cfm.float().eval()
        STYLE = _style_model()
        gc.collect()
        STATE.update(status="ok", load_s=round(time.monotonic() - started, 2),
                     dram=WORKER.stats.get("dram"), vae_warm_s=WORKER.stats.get("vae_warm_s"))
    except Exception as exc:
        STATE.update(status="error", error=f"{type(exc).__name__}: {exc}")
    try:
        yield
    finally:
        CFM = TOKENIZER = STYLE = None
        if WORKER is not None:
            WORKER.close()
            WORKER = None


app = FastAPI(title="DiffRhythm 1.2 Full on p100a", lifespan=lifespan, docs_url=None, redoc_url=None)


class Request(BaseModel):
    model_config = ConfigDict(extra="ignore")
    prompt: str = Field(min_length=1, max_length=4000, description="the style description")
    lyrics: str = Field(default="", max_length=20000, description="LRC, or plain lines to be timed")
    instrumental: bool = False
    duration: int = 180
    seed: Optional[int] = Field(default=None, ge=0, le=4294967295)
    inference_steps: Optional[int] = None


def _stamp(seconds: float) -> str:
    centiseconds = round(seconds * 100)
    minutes, remainder = divmod(centiseconds, 6000)
    return f"[{minutes:02d}:{remainder / 100:05.2f}]"


def _lrc(lyrics: str, duration: int, instrumental: bool) -> str:
    """Real LRC as given, or approximate starts derived for untimed lines.

    The model needs a timestamp on every line. Deriving them by word count is a convenience,
    not learned lyric alignment -- pass LRC when the timing matters."""
    if instrumental:
        return ""
    lines = [line.strip() for line in lyrics.splitlines() if line.strip()]
    lines = [line for line in lines if not SECTION.fullmatch(line)]
    if not lines:
        raise ValueError("vocals need lyrics; set instrumental=true for music without them")
    if len(lines) > 400:
        raise ValueError("at most 400 lyric lines")
    matches = [TIMESTAMP.match(line) for line in lines]
    if any(matches):
        if not all(matches):
            raise ValueError("use either plain lyrics or a timestamp on every line (LRC)")
        out, previous = [], -1.0
        for match in matches:
            minutes, seconds, line = match.groups()
            start = int(minutes) * 60 + float(seconds)
            if float(seconds) >= 60 or not previous <= start < duration:
                raise ValueError("LRC timestamps must be ordered and shorter than the song")
            previous = start
            if line.strip():  # an empty timed line marks a break; the tokenizer cannot encode it
                out.append(f"{_stamp(start)}{line.strip()}")
        if not out:
            raise ValueError("the LRC contains no singable lyrics")
        return "\n".join(out)
    if any(re.match(r"^\[\d", line) for line in lines):
        raise ValueError("invalid LRC timestamp; use [mm:ss.xx] before each line")
    intro, outro = min(12.0, duration * 0.06), min(10.0, duration * 0.05)
    weights = [max(3, len(line.split())) for line in lines]
    total, elapsed, out = sum(weights), 0, []
    for line, weight in zip(lines, weights):
        out.append(f"{_stamp(intro + (duration - intro - outro) * elapsed / total)}{line}")
        elapsed += weight
    return "\n".join(out)


def _wav(waveform: torch.Tensor) -> bytes:
    """[channels, samples] float -> peak-normalized 16-bit 44.1 kHz WAV."""
    import soundfile as sf

    waveform = waveform.float().cpu()
    if not torch.isfinite(waveform).all():
        raise RuntimeError("non-finite audio; retry with a different seed")
    peak = waveform.abs().max().item()
    if peak < 1e-7:
        raise RuntimeError("silent audio; retry with a different seed")
    buf = io.BytesIO()
    sf.write(buf, (waveform / peak).clamp(-1, 1).T.numpy(), SAMPLE_RATE, subtype="PCM_16", format="WAV")
    return buf.getvalue()


@app.get("/health")
def health():
    return dict(STATE)


@app.get("/info")
def info():
    return dict(STATE) | {
        "model": WEIGHTS_REPO,
        "license": LICENSE,
        "device": "Tenstorrent Blackhole p100a",
        "on_card": ["CFM transformer", "stable-audio VAE decoder"],
        "on_cpu": ["MuQ-MuLan style encoder", "lyric tokenizer", "sampler", "post-processing"],
        "inference_steps": STEPS,
        "cfg_strength": CFG_STRENGTH,
        "task_modes": ["text-to-music"],
        "duration_limits": {"min": MIN_DURATION, "max": MAX_DURATION},
    }


@app.post("/predict")
def predict(request: Request):
    if not request.prompt.strip():
        raise HTTPException(400, "Empty prompt")
    try:
        if request.inference_steps is not None and request.inference_steps != STEPS:
            raise ValueError(f"this port runs {STEPS} steps (the schedule its shapes are compiled for)")
        if not MIN_DURATION <= request.duration <= MAX_DURATION:
            raise ValueError(f"duration must be between {MIN_DURATION} and {MAX_DURATION} seconds "
                             "(use ACE-Step for longer songs)")
        lrc = _lrc(request.lyrics, request.duration, request.instrumental)
    except ValueError as exc:
        raise HTTPException(422, str(exc)) from exc
    if STATE["status"] != "ok" or CFM is None:
        raise HTTPException(503, STATE["error"] or "Model is not ready")
    if not LOCK.acquire(timeout=TURN_WAIT_S):
        raise HTTPException(409, "Another song is in progress")
    try:
        import numpy as np
        from infer_utils import (get_lrc_token, get_negative_style_prompt, get_reference_latent,
                                 get_style_prompt)

        STATE["generating"] = True
        t0 = time.perf_counter()
        seed = request.seed if request.seed is not None else random.SystemRandom().randrange(2**32)
        random.seed(seed)
        np.random.seed(seed)
        torch.manual_seed(seed)
        with torch.inference_mode():
            style = get_style_prompt(STYLE, prompt=request.prompt).float()
            text, start_time, end_frame, song_duration = get_lrc_token(
                MAX_FRAMES, lrc, TOKENIZER, request.duration, "cpu")
            negative = get_negative_style_prompt("cpu").float()
            cond, segments = get_reference_latent("cpu", MAX_FRAMES, False, None, None, None)
            latents, _ = CFM.sample(
                cond=cond, text=text, duration=end_frame, max_duration=end_frame,
                song_duration=song_duration, style_prompt=style, negative_style_prompt=negative,
                steps=STEPS, cfg_strength=CFG_STRENGTH, start_time=start_time,
                latent_pred_segments=segments, batch_infer_num=1, seed=seed)
            latent = latents[0].float().transpose(1, 2).contiguous()
            vae = RemoteVae(WORKER, upsample=UPSAMPLE)
            waveform = vae(latent)[0]
        wav = _wav(waveform)
        timing = {
            "dit_s": round(sum(CFM.transformer.seconds), 2),
            "dit_calls": len(CFM.transformer.seconds),
            "vae_s": round(sum(vae.seconds), 2),
            "total_s": round(time.perf_counter() - t0, 2),
        }
        CFM.transformer.seconds.clear()
        # The card holds no song once the VAE has run, so the shim's "already prepared" cache has
        # to go with it -- the next request's text and conditioning differ and would otherwise be
        # skipped. A per-song shim would not need this; a server keeps one across songs.
        CFM.transformer.reset()
        return {
            "audio": base64.b64encode(wav).decode(),
            "format": "wav",
            "model": WEIGHTS_REPO,
            "license": LICENSE,
            "seed": seed,
            "duration": request.duration,
            "lrc": lrc,
            "inference_steps": STEPS,
            "timing_s": timing,
        }
    except Exception as exc:
        if CFM is not None:
            CFM.transformer.reset()
        if WORKER is not None and WORKER.failed:
            STATE.update(status="error", error=str(exc))
        raise HTTPException(503, str(exc)) from exc
    finally:
        STATE["generating"] = False
        LOCK.release()