PMI_Calculator_GPU / pipeline.py
luna0805's picture
Update pipeline.py
52a1ac3 verified
Raw
History Blame Contribute Delete
26 kB
# ============================================================================
# pipeline.py — audio -> Demucs (vocal stem) -> YourMT3+ (transcription)
# -> full post-processing -> notes
#
# Transcription engine: YourMT3+ (YPTF.MoE+Multi, noPS checkpoint), loaded once
# at startup on GPU. The model code is cloned from the official HF Space at
# build time (see Dockerfile) into /app/yourmt3.
#
# Post-processing (ported verbatim from the validated Colab pipeline):
# raw notes -> tempo-driven merge (drop_short + register fold + conservative
# merge) -> octave repair -> vibrato collapse -> key estimation + snapping
# (from pmi_core) -> out-of-key fragment regularization -> final notes
#
# YourMT3+ outputs no per-note confidence (velocity is constant), so the
# snapping evidence comes from duration + isolation only; SNAP_THRESH is
# recalibrated accordingly inside pmi_core.
# ============================================================================
import os, sys, glob, shutil
import numpy as np
import librosa
import soundfile as sf
import pretty_midi
def _patch_dependency_conflicts():
import importlib.metadata as _md
_orig_version = _md.version
def _fake_version(name):
n = (name or "").replace("_", "-").lower()
if n == "huggingface-hub":
return "0.999.0" # satisfies transformers' <1.0 pin
try:
return _orig_version(name)
except Exception:
return "99.0.0" # missing metadata: high ver so >= checks pass
_md.version = _fake_version
try: # transformers may read via the backport instead
import importlib_metadata as _imd
_imd.version = _fake_version
except Exception:
pass
try:
import lightning_utilities.core.imports as _lui
except Exception:
return # lightning absent: nothing to guard
_BLOCK = {"torch_fidelity", "pesq", "pystoi", "fast_bss_eval", "lpips",
"sacrebleu", "nltk", "rouge_score", "scienceplots"}
_orig_bool = _lui.RequirementCache.__bool__
def _patched_bool(self):
req = getattr(self, "requirement", "") or ""
mod = getattr(self, "module", "") or ""
for _b in _BLOCK:
if req == _b or mod == _b or req.startswith(_b):
return False
return _orig_bool(self)
_lui.RequirementCache.__bool__ = _patched_bool
_patch_dependency_conflicts() # before anything can import transformers/torchmetrics
def _ensure_transformers():
try:
import transformers # already present?
return
except Exception:
pass
import subprocess, sys as _sys
# transformers + tokenizers themselves, no deps (so hub isn't downgraded)
subprocess.run([_sys.executable, "-m", "pip", "install", "--no-cache-dir",
"--no-deps", "transformers==4.45.1", "tokenizers==0.20.3"],
check=True)
subprocess.run([_sys.executable, "-m", "pip", "install", "--no-cache-dir",
"--no-deps", "regex", "safetensors>=0.4.1"],
check=True)
# ---------------------------------------------------------------------------
# YourMT3+ model loading (once, at import time)
# ---------------------------------------------------------------------------
# On ZeroGPU (Gradio SDK) there is no Dockerfile, so the YourMT3+ code and
# checkpoint are fetched at import time into a writable directory.
_HOME = os.environ.get("HOME", "/home/user")
YOURMT3_DIR = os.environ.get("YOURMT3_DIR", os.path.join(_HOME, "yourmt3"))
_WORK = os.environ.get("MT3_WORK", "/tmp/mt3_work")
os.makedirs(os.path.join(_WORK, "model_output"), exist_ok=True)
_CKPT_NAME = "mc13_256_g4_all_v7_mt3f_sqr_rms_moe_wf4_n8k2_silu_rope_rp_b36_nops"
def _ensure_yourmt3():
"""Clone the YourMT3+ code (no LFS) and download the checkpoint into the
repo at amt/logs/2024/<name>/checkpoints/. Runs once at import."""
import subprocess
ckpt = os.path.join(YOURMT3_DIR, "amt", "logs", "2024", _CKPT_NAME,
"checkpoints", "last.ckpt")
if os.path.exists(ckpt) and os.path.getsize(ckpt) > 100_000_000:
return
if not os.path.isdir(YOURMT3_DIR):
env = dict(os.environ, GIT_LFS_SKIP_SMUDGE="1")
subprocess.run(["git", "clone", "--depth", "1",
"https://huggingface.co/spaces/mimbres/YourMT3", YOURMT3_DIR],
check=True, env=env)
# NB: YourMT3+'s Python deps are installed at BUILD time via our
# requirements.txt (not here) so the runtime environment is never
# mutated — mutating it at runtime corrupts numpy/hub/transformers.
from huggingface_hub import snapshot_download
dl = snapshot_download(repo_id="mimbres/YourMT3",
allow_patterns=[f"logs/2024/{_CKPT_NAME}/*"],
local_dir=os.path.join(_HOME, "yourmt3-ckpt"))
dst = os.path.join(YOURMT3_DIR, "amt", "logs")
shutil.rmtree(dst, ignore_errors=True)
os.makedirs(dst, exist_ok=True)
shutil.copytree(os.path.join(dl, "logs", "2024"),
os.path.join(dst, "2024"), dirs_exist_ok=True)
if not (os.path.exists(ckpt) and os.path.getsize(ckpt) > 100_000_000):
raise RuntimeError("YourMT3+ checkpoint download failed (git-lfs pointer?)")
_ensure_yourmt3()
_ensure_transformers()
sys.path.insert(0, os.path.join(YOURMT3_DIR, "amt", "src"))
sys.path.insert(0, YOURMT3_DIR)
_model = None
def _patch_torchaudio_load():
"""torchaudio 2.11 routes load()/save() through torchcodec, whose shared
libraries need a matching system FFmpeg and can fail to load in the image.
We only ever read standard WAV files (the demucs vocal stem we wrote with
soundfile), so replace torchaudio.load with a soundfile-based reader.
Idempotent; safe to call more than once."""
import torch
import torchaudio as ta
if getattr(ta.load, "_sf_patched", False):
return
def _sf_load(uri, *args, **kwargs):
data, sr = sf.read(str(uri), dtype="float32", always_2d=True)
wav = torch.from_numpy(data.T.copy()) # (channels, samples)
return wav, int(sr)
_sf_load._sf_patched = True
ta.load = _sf_load
def _load_model():
"""Load YourMT3+ once. Called lazily on first transcription request so the
web server comes up fast; the first request pays the load cost."""
global _model
if _model is not None:
return _model
cwd = os.getcwd()
try:
os.chdir(YOURMT3_DIR) # checkpoint paths are resolved relative to the repo
from model_helper import load_model_checkpoint # noqa: import from cloned repo
checkpoint = ("mc13_256_g4_all_v7_mt3f_sqr_rms_moe_wf4_n8k2_silu_rope_rp_b36_nops"
"@last.ckpt")
args = [checkpoint, "-p", "2024", "-tk", "mc13_full_plus_256",
"-dec", "multi-t5", "-nl", "26", "-enc", "perceiver-tf",
"-sqr", "1", "-ff", "moe", "-wf", "4", "-nmoe", "8",
"-kmoe", "2", "-act", "silu", "-epe", "rope", "-rp", "1",
"-ac", "spec", "-hop", "300", "-atc", "1", "-pr", "16"]
m = load_model_checkpoint(args=args, device="cpu")
# NB: stays on CPU here; moved to GPU inside the @spaces.GPU call.
_model = m
return _model
finally:
os.chdir(cwd)
def _audio_info(path):
"""soundfile-based audio metadata (torchaudio.info was removed upstream)."""
with sf.SoundFile(path) as f:
sr, n_frames, n_ch = f.samplerate, len(f), f.channels
return dict(filepath=path,
track_name=os.path.basename(path).rsplit(".", 1)[0],
sample_rate=int(sr), bits_per_sample=16,
num_channels=int(n_ch), num_frames=int(n_frames),
duration=int(n_frames / sr), encoding="pcm_s")
def yourmt3_transcribe(audio_path):
"""Vocal-stem audio -> raw notes [(start, end, pitch)] via YourMT3+.
transcribe() writes './model_output/<track>.mid' relative to cwd."""
model = _load_model()
# ZeroGPU: a GPU is only attached inside the @spaces.GPU call, so the model
# is moved here (a no-op if it is already resident).
#
# This FAILS LOUDLY if there is no GPU, and must keep doing so. The
# checkpoint is loaded at fp16 (-pr 16), and fp16 inference on CPU is not
# properly supported in PyTorch -- it returns numerical garbage, which
# surfaces as a dense mess of nonsense notes rather than as an error. The
# previous version skipped the move when cuda.is_available() was False and
# swallowed exceptions, so a worker whose GPU was not yet attached silently
# transcribed on CPU and produced exactly that garbage. Plausible-looking
# but wrong output is the worst failure mode for a forensic tool: better to
# raise and let the caller retry than to hand back numbers nobody can trust.
import torch
if not torch.cuda.is_available():
raise RuntimeError(
"No GPU attached inside the transcription call. Refusing to run "
"fp16 inference on CPU, which yields invalid output. Please retry.")
model.to("cuda")
_patch_torchaudio_load() # model_helper.transcribe reads audio via torchaudio.load
cwd = os.getcwd()
try:
os.chdir(_WORK)
from model_helper import transcribe # same cloned module
out_rel = transcribe(model, _audio_info(audio_path))
src = out_rel if os.path.isabs(out_rel) else os.path.join(_WORK, out_rel)
if not os.path.exists(src):
raise RuntimeError(f"YourMT3+ returned {out_rel!r} but the file is missing")
pm = pretty_midi.PrettyMIDI(src)
cands = [i for i in pm.instruments if not i.is_drum and len(i.notes) > 0]
if not cands:
return []
inst = max(cands, key=lambda i: len(i.notes))
raw = [(float(n.start), float(n.end), int(n.pitch)) for n in inst.notes]
raw.sort(key=lambda t: (t[0], t[1], t[2]))
try:
os.unlink(src)
except OSError:
pass
return raw
finally:
os.chdir(cwd)
# ---------------------------------------------------------------------------
# Demucs vocal separation
# ---------------------------------------------------------------------------
DEMUCS_OUT = os.environ.get("DEMUCS_OUT", "/tmp/demucs_out")
def _patch_demucs_save():
"""torchaudio 2.11 routes ta.save() through torchcodec, whose GPU wheel can
fail to load if its CUDA build does not match the image. Replace demucs'
save_audio with a soundfile-based writer so the separation result is saved
regardless of the torchcodec state.
demucs.separate does `from .audio import save_audio`, which copies the
reference into its own namespace — so BOTH modules must be patched."""
try:
import demucs.audio as _da
import demucs.separate as _ds
except ImportError:
return
def _sf_save_audio(wav, path, samplerate, **kwargs):
arr = wav.detach().cpu().numpy() if hasattr(wav, "detach") else np.asarray(wav)
if arr.ndim == 2: # (channels, samples) -> (samples, channels)
arr = arr.T
sf.write(str(path), arr, int(samplerate))
_da.save_audio = _sf_save_audio
_ds.save_audio = _sf_save_audio
# Which demucs model carries which stem. The vocal path stays on htdemucs on
# purpose: that is the model the PMI benchmark was validated against, so it must
# not change silently. guitar/piano only exist in htdemucs_6s, which is
# experimental -- guitar is usable, piano bleeds badly (upstream's own caveat).
DEMUCS_MODEL = {"vocals": "htdemucs", "guitar": "htdemucs_6s", "piano": "htdemucs_6s"}
def demucs_stem(audio_path, stem="vocals"):
"""Separate one stem. Defaults to vocals -- the validated path.
A UNIQUE output dir per call (never a filename-based cache): a shared cache
keyed on the basename caused cross-request bleed, where a second upload with
the same basename matched the first request's leftover stem and transcribed
the WRONG audio. A fresh dir per call guarantees we separate the audio we
were actually given.
"""
if stem not in DEMUCS_MODEL:
stem = "vocals"
model = DEMUCS_MODEL[stem]
import uuid
out_dir = os.path.join(DEMUCS_OUT, uuid.uuid4().hex)
os.makedirs(out_dir, exist_ok=True)
tag = os.path.basename(audio_path).rsplit(".", 1)[0]
_patch_demucs_save()
import demucs.separate as _ds
# Do NOT pass --segment: htdemucs is a Transformer and refuses any segment
# longer than the 7.8 s it was trained on ("FATAL: Cannot use a Transformer
# model with a longer segment than it was trained for"), so there is no
# window-size win to be had here. --shifts is likewise left at its default:
# it multiplies inference time by the shift count for ~0.2 dB SDR.
_ds.main([f"--two-stems={stem}", "-n", model, "-o", out_dir, audio_path])
out = glob.glob(f"{out_dir}/{model}/{tag}/{stem}.wav")
if not out: # fall back to any match under this call's dir
out = glob.glob(f"{out_dir}/*/*/{stem}.wav")
if not out:
raise RuntimeError(f"demucs produced no {stem}.wav for " + audio_path)
return out[0]
# ---------------------------------------------------------------------------
# Tempo estimation (from the vocal stem)
# ---------------------------------------------------------------------------
TEMPO_PRIOR = 120.0
TEMPO_CLAMP = (50.0, 200.0)
def estimate_bpm(audio_path, prior=TEMPO_PRIOR, clamp=TEMPO_CLAMP):
y, sr = librosa.load(audio_path, sr=16000, mono=True)
try:
tempo, _ = librosa.beat.beat_track(y=y, sr=sr, start_bpm=prior)
bpm = float(np.atleast_1d(tempo).ravel()[0])
except Exception:
bpm = prior
if not np.isfinite(bpm) or bpm <= 0:
bpm = prior
return float(np.clip(bpm, *clamp))
# ---------------------------------------------------------------------------
# Tempo -> merge thresholds (octave-folded; verbatim from the batch pipeline)
# ---------------------------------------------------------------------------
FRAG_BEATS = 1/8
GAP_BEATS = 1/8
MINDUR_BEATS = 1/16
FRAG_CLAMP = (0.05, 0.16)
GAP_CLAMP = (0.025, 0.10)
MINDUR_CLAMP = (0.03, 0.09)
FOLD_BAND = (70.0, 140.0)
def merge_thresholds_from_tempo(tempo):
bpm = np.atleast_1d(np.asarray(tempo, dtype=float)).ravel()
bpm = float(bpm[0]) if bpm.size else 0.0
while bpm < FOLD_BAND[0]:
bpm *= 2.0
while bpm > FOLD_BAND[1]:
bpm /= 2.0
beat = 60.0 / bpm
return (float(np.clip(beat * MINDUR_BEATS, *MINDUR_CLAMP)),
float(np.clip(beat * GAP_BEATS, *GAP_CLAMP)),
float(np.clip(beat * FRAG_BEATS, *FRAG_CLAMP)))
# ---------------------------------------------------------------------------
# Post-processing tunables (verbatim from the batch pipeline)
# ---------------------------------------------------------------------------
REGISTER_FOLD = 11
OCT_CTX_WIN = 4
OCT_GOOD_JUMP = 7
OCT_NEAR = 2
OCT_CONF_GATE = None
VIB_BAND = 2
VIB_MIN_RUN = 3
VIB_MAX_PIECE = 0.30
VIB_MAX_SPAN = 2
FRAG_BAND = 2
FRAG_SHORT_FRAC = 0.60
CLUSTER_MAX_DUR = 1.2
def _merge_same_pitch(notes, max_gap, frag_dur=None):
"""Join consecutive SAME-PITCH notes separated by less than max_gap.
frag_dur guards against destroying real repeats: when given, a pair is only
merged if at least one side is shorter than it, so two full-length notes on
the same pitch (a genuine re-articulation) are left alone. Pass None to merge
on gap alone, which is what the out-of-key pass wants -- there the notes have
just been retuned to a common pitch and the runs are fragments by definition.
Takes and returns lists of [start, end, pitch, ...]; extra fields on the
first note of a run are preserved, since merging extends its end time.
"""
if not notes:
return []
merged = [list(notes[0])]
for n in notes[1:]:
s, e, p = n[0], n[1], n[2]
ls, le, lp = merged[-1][0], merged[-1][1], merged[-1][2]
same = (p == lp) and (s - le < max_gap)
if frag_dur is not None:
same = same and ((le - ls) < frag_dur or (e - s) < frag_dur)
if same:
merged[-1][1] = e
else:
merged.append(list(n))
return merged
def _grow_narrow_band(notes, i, band, max_piece=None):
n = len(notes); j = i + 1
while j < n:
seg = [notes[k][2] for k in range(i, j + 1)]
center = np.median(seg)
within = max(abs(pp - center) for pp in seg) <= band
short_ok = (max_piece is None or
all((notes[k][1] - notes[k][0]) <= max_piece for k in range(i, j + 1)))
if within and short_ok:
j += 1
else:
break
return j
def octave_repair(notes, win=OCT_CTX_WIN, good_jump=OCT_GOOD_JUMP,
near_oct=OCT_NEAR, conf_gate=OCT_CONF_GATE):
if len(notes) < 3:
return notes
out = [list(n) for n in notes]
P = [n[2] for n in out]
for i in range(len(out)):
lo = max(0, i - win); hi = min(len(out), i + win + 1)
ctx = [P[j] for j in range(lo, hi) if j != i]
if not ctx:
continue
center = float(np.median(ctx))
cur = out[i][2]
if abs(cur - center) <= good_jump:
continue
if conf_gate is not None and len(out[i]) > 3 and out[i][3] >= conf_gate:
continue
best, best_d = cur, abs(cur - center)
for shift in (-12, 12, -24, 24):
c2 = cur + shift
if not (40 <= c2 <= 95):
continue
if abs(abs(cur - center) - abs(shift)) > near_oct:
continue
d = abs(c2 - center)
if d < best_d:
best, best_d = c2, d
if best != cur:
out[i][2] = best; P[i] = best
return [tuple(n) for n in out]
def _vibrato_runs(notes, band=VIB_BAND, min_run=VIB_MIN_RUN, max_piece=VIB_MAX_PIECE):
n = len(notes)
if n < min_run:
return []
p = np.array([nn[2] for nn in notes])
runs, i = [], 0
while i < n - min_run + 1:
j = _grow_narrow_band(notes, i, band, max_piece)
if j - i >= min_run:
seg = p[i:j]; d = np.diff(seg); nz = d[d != 0]
reversals = int(np.sum(nz[:-1] * nz[1:] < 0)) if len(nz) >= 2 else 0
if reversals >= 1 and len(set(seg.tolist())) >= 2:
runs.append((i, j))
i = j
else:
i += 1
return runs
def collapse_vibrato(notes, band=VIB_BAND, min_run=VIB_MIN_RUN,
max_piece=VIB_MAX_PIECE, max_span=VIB_MAX_SPAN):
runs = _vibrato_runs(notes, band, min_run, max_piece)
actions = {}
for (i, j) in runs:
seg = notes[i:j]
span = max(nn[2] for nn in seg) - min(nn[2] for nn in seg)
if span > max_span:
continue
wt = {}
for nn in seg:
wt[nn[2]] = wt.get(nn[2], 0.0) + (nn[1] - nn[0])
actions[i] = (j, int(max(wt, key=wt.get)))
if not actions:
return [tuple(nn) for nn in notes]
out, k, n = [], 0, len(notes)
while k < n:
if k in actions:
j, center = actions[k]; seg = notes[k:j]
s, e = seg[0][0], seg[-1][1]
out.append((s, e, center)); k = j
else:
out.append(tuple(notes[k])); k += 1
return out
def postprocess_notes(notes, tempo=None, min_dur=0.05, merge_gap=0.04,
frag_dur=0.08, fix_octaves=True, collapse_vib=True):
"""drop_short + register fold + conservative merge + octave repair +
vibrato collapse. Thresholds are tempo-driven when tempo is given."""
if tempo is not None:
min_dur, merge_gap, frag_dur = merge_thresholds_from_tempo(tempo)
notes = sorted([list(n) for n in notes])
if not notes:
return []
med = int(np.median([p for _, _, p, *_ in notes]))
for n in notes:
while n[2] - med > REGISTER_FOLD:
n[2] -= 12
while med - n[2] > REGISTER_FOLD:
n[2] += 12
merged = _merge_same_pitch(notes, merge_gap, frag_dur)
result = [tuple(n[:3]) for n in merged]
if fix_octaves:
result = octave_repair(result)
if collapse_vib:
result = collapse_vibrato(result)
# Drop what is STILL too short, after every reassembly step. Filtering first
# -- as this did previously -- deleted the fragments of a broken-up note
# before merge/vibrato-collapse could put them back, so a sustained note
# could vanish outright. Vibrato was the worst case: its fragments differ in
# pitch, so the merge loop (which requires p == lp) can never rescue them --
# only collapse_vibrato can, and it runs last. Anything still under min_dur
# here is a fragment nothing could reassemble, i.e. genuine noise.
result = [n for n in result if (n[1] - n[0]) >= min_dur]
return result
# ---------------------------------------------------------------------------
# Key-aware final stage: snapping (via pmi_core) + OOK fragment regularization
# ---------------------------------------------------------------------------
import pmi_core # single source of truth for K-S estimation + snapping
def regularize_oot_fragments(notes, info, tempo=None, band=FRAG_BAND,
short_frac=FRAG_SHORT_FRAC, max_dur=CLUSTER_MAX_DUR):
"""Inside TIGHT narrow-band clusters (most notes short AND cluster brief),
retune SHORT + OUT-OF-KEY notes to the cluster's longest in-key note, then
merge same-pitch neighbours. In-key / normal-length notes are never moved."""
n = len(notes)
if n < 2 or info is None:
return [tuple(x) for x in notes]
scale = pmi_core.scale_for(info["tonic"], info["mode"])
_, _, frag_dur = merge_thresholds_from_tempo(tempo) if tempo else (0, 0, 0.08)
p = [int(x[2]) for x in notes]
dur = [x[1] - x[0] for x in notes]
short = [d < frag_dur for d in dur]
oot = [(pp % 12) not in scale for pp in p]
out = [list(x) for x in notes]
i = 0
while i < n:
j = _grow_narrow_band(notes, i, band)
seg = range(i, j); size = j - i
tot = notes[j - 1][1] - notes[i][0]
n_short = sum(short[k] for k in seg)
in_idx = [k for k in seg if not oot[k]]
targets = [k for k in seg if short[k] and oot[k]]
tight = (size >= 2 and n_short / size >= short_frac and tot <= max_dur)
if tight and in_idx and targets:
tgt = p[max(in_idx, key=lambda k: dur[k])]
for k in targets:
out[k][2] = tgt
i = j
else:
i += 1
merged = _merge_same_pitch(out, frag_dur)
return [tuple(x[:3]) for x in merged]
def notes_from_audio(vocal_path, tempo=None):
"""Full transcription line for the /transcribe endpoint:
vocal stem -> YourMT3+ -> tempo-driven postprocess -> key estimation +
snapping (pmi_core.estimate_key_locked) -> OOK fragment regularization."""
raw = yourmt3_transcribe(vocal_path)
if not raw:
return []
if tempo is None:
tempo = estimate_bpm(vocal_path)
pp = postprocess_notes(raw, tempo=tempo)
if not pp:
return []
key, cls = pmi_core.estimate_key_locked(pp, confs=None)
if key is None:
return pp
snapped_pcs = pmi_core.corrected_pitches(pp, cls)
snapped = []
for (s, e, p), new_pc in zip(pp, snapped_pcs):
octave = (int(p) // 12) * 12
best = octave + new_pc
if abs(best - p) > 6:
best += 12 if best < p else -12
snapped.append((s, e, int(best)))
return regularize_oot_fragments(snapped, key, tempo=tempo)
# ---------------------------------------------------------------------------
# Chroma for the note-completion hints (assistive only, never auto-fills).
# ---------------------------------------------------------------------------
def chroma_from_audio(vocal_path, sr=22050, hop=512):
"""Per-frame chroma (pitch-class salience) + per-frame energy of the vocal
stem, for the editor's note-completion hints.
When the user completes a note the model missed, we show what the AUDIO
carries there. Evidence, not a decision -- the user picks.
Two arrays are returned because they answer different questions:
chroma : WHICH pitch class dominates. Normalised per frame (strongest
pc = 255). Division by the frame max preserves the *shape*
within a frame, so how peaked vs flat it is survives -- that is
what tells us whether the audio is decisive or ambiguous.
energy : WHETHER there is any signal at all. Per-frame RMS, scaled to the
track max. The per-frame chroma normalisation deliberately
throws loudness away, so silence would otherwise amplify into a
confident-looking peak. This array is what stops the hint from
claiming "100%" over a breath or a gap.
chroma is frame-major (T x 12): frontend reads data[frame*12 + pc].
"""
y, _ = librosa.load(vocal_path, sr=sr, mono=True)
C = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=hop) # 12 x T
C = C / (C.max(axis=0, keepdims=True) + 1e-9) # per-frame shape
q = np.clip(C * 255.0, 0, 255).astype(np.uint8) # 12 x T
rms = librosa.feature.rms(y=y, frame_length=2048, hop_length=hop)[0] # T
rms = rms[: q.shape[1]]
if rms.shape[0] < q.shape[1]: # pad if short
rms = np.pad(rms, (0, q.shape[1] - rms.shape[0]))
e = rms / (rms.max() + 1e-9)
e = np.clip(e * 255.0, 0, 255).astype(np.uint8) # T
return {"data": q.T.tobytes(), "energy": e.tobytes(),
"n_frames": int(q.shape[1]), "sr": int(sr), "hop": int(hop)}