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()
|