File size: 17,825 Bytes
5230709 ba08271 5230709 | 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 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 | # Scribe β BASE Station's audio β lead sheet / MIDI engine (Hugging Face Space).
#
# Built ONLY from permissively licensed components so the output is usable
# commercially (replaces SheetSage2, which is CC-BY-NC-4.0):
# notes Spotify Basic Pitch Apache-2.0
# beats CPJKU beat_this MIT
# chords BTC (jayg996/BTC-ISMIR19) MIT
# key/form librosa + our own code ISC
# ABC our own writer (below)
#
# POST /transcribe multipart `file` OR form `audio_url` (+ melody_only, title)
# -> { abc, midi (base64), summary, abc_error, warnings }
# GET /health -> per-component load state
#
# Same response contract as the SheetSage2 engine it replaces, so the app's
# score view and composition footprint hash need no changes.
#
# CPU-only by design: every component runs in well under real time on CPU, so
# the Space needs no GPU. Work is SERIAL behind a lock.
import io
import os
import sys
import base64
import tempfile
import threading
import traceback
import subprocess
import urllib.request
from types import SimpleNamespace
from typing import Optional
import numpy as np
import librosa
import yaml
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from starlette.concurrency import run_in_threadpool
BTC_DIR = "/app/btc"
MAX_BYTES = 200 * 1024 * 1024
MAX_SECONDS = 15 * 60
ENGINE_ID = "scribe-v1"
COMPONENTS = "basic-pitch 0.4.0 (Apache-2.0) + beat_this final0 (MIT) + BTC large-voca (MIT) + librosa (ISC)"
app = FastAPI(title="Scribe β BASE Station")
_LOCK = threading.Lock()
STATE = {"basic_pitch": "loading", "beat_this": "loading", "btc": "loading"}
ERRORS = {}
M = {}
# ββ Component loading (background, so port 7860 binds at once) βββββββββββββββ
def _load(name, fn):
try:
fn()
STATE[name] = "ready"
except Exception as e:
traceback.print_exc()
STATE[name], ERRORS[name] = "failed", str(e)
def _load_basic_pitch():
from basic_pitch import ICASSP_2022_MODEL_PATH
from basic_pitch.inference import Model
M["bp"] = Model(ICASSP_2022_MODEL_PATH)
def _load_beat_this():
from beat_this.inference import File2Beats
M["beats"] = File2Beats(checkpoint_path="final0", device="cpu", dbn=False)
def _load_btc():
import torch
# BTC predates NumPy 1.20 and uses the removed np.float / np.int aliases.
for alias, typ in (("float", float), ("int", int)):
if not hasattr(np, alias):
setattr(np, alias, typ)
sys.path.insert(0, BTC_DIR)
from btc_model import BTC_model
from utils.mir_eval_modules import audio_file_to_features, idx2voca_chord
# Parsed here rather than via upstream HParams, which calls yaml.load
# without a Loader and fails on PyYAML 6.
with open(os.path.join(BTC_DIR, "run_config.yaml")) as f:
cfg = yaml.safe_load(f)
cfg["feature"]["large_voca"] = True
cfg["model"]["num_chords"] = 170
config = SimpleNamespace(**cfg)
model = BTC_model(config=config.model)
ckpt = torch.load(os.path.join(BTC_DIR, "test", "btc_model_large_voca.pt"), map_location="cpu", weights_only=False)
model.load_state_dict(ckpt["model"])
model.eval()
M["btc"] = dict(model=model, mean=ckpt["mean"], std=ckpt["std"], config=config,
features=audio_file_to_features, vocab=idx2voca_chord())
def _load_all():
_load("basic_pitch", _load_basic_pitch)
_load("beat_this", _load_beat_this)
_load("btc", _load_btc)
threading.Thread(target=_load_all, daemon=True).start()
# ββ Analysis stages ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def detect_beats(wav, y, sr, warnings):
if STATE["beat_this"] == "ready":
beats, downbeats = M["beats"](wav)
return np.asarray(beats, float), np.asarray(downbeats, float)
warnings.append("beat_this unavailable β used librosa beat tracking, no downbeats")
_, frames = librosa.beat.beat_track(y=y, sr=sr)
return librosa.frames_to_time(frames, sr=sr), np.array([])
def detect_chords(wav, warnings):
if STATE["btc"] != "ready":
warnings.append("chord model unavailable β no chords")
return []
import torch
b = M["btc"]
feat, per_sec, _ = b["features"](wav, b["config"])
feat = (feat.T - b["mean"]) / b["std"]
T = b["config"].model["timestep"]
n = feat.shape[0]
feat = np.pad(feat, ((0, (-n) % T), (0, 0)))
preds = []
with torch.no_grad():
x = torch.tensor(feat, dtype=torch.float32).unsqueeze(0)
for t in range(feat.shape[0] // T):
out, _ = b["model"].self_attn_layers(x[:, t * T:(t + 1) * T, :])
p, _ = b["model"].output_layer(out)
preds.extend(p.squeeze(0).tolist())
preds = preds[:n]
rows, start = [], 0
for i in range(1, n + 1):
if i == n or preds[i] != preds[start]:
label = b["vocab"][int(preds[start])]
if label not in ("N", "X"):
rows.append({"start": round(start * per_sec, 3), "end": round(i * per_sec, 3), "label": label})
start = i
return rows
MAJOR = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
MINOR = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
MAJOR_NAMES = ["C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
MINOR_NAMES = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"]
def detect_key(y, sr):
"""KrumhanslβSchmuckler over the harmonic component's mean chroma."""
chroma = librosa.feature.chroma_cqt(y=librosa.effects.harmonic(y), sr=sr).mean(axis=1)
best = max(((np.corrcoef(chroma, np.roll(prof, t))[0, 1], t, mode)
for mode, prof in (("major", MAJOR), ("minor", MINOR)) for t in range(12)))
_, tonic, mode = best
return tonic, mode, f"{(MAJOR_NAMES if mode == 'major' else MINOR_NAMES)[tonic]} {mode}"
def detect_sections(y, sr, beats, duration):
"""Beat-synchronous agglomerative segmentation; repeated material shares a
letter (A, B, Cβ¦). Labels describe similarity, not verse/chorus function."""
hop = 512
feat = np.vstack([librosa.util.normalize(librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=hop)),
librosa.util.normalize(librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13, hop_length=hop))])
bounds = librosa.util.fix_frames(librosa.time_to_frames(beats, sr=sr, hop_length=hop), x_min=0, x_max=feat.shape[1])
sync = librosa.util.sync(feat, bounds, aggregate=np.median, pad=False)
if sync.shape[1] < 4:
return [{"start": 0.0, "end": round(duration, 3), "label": "A"}]
k = int(min(max(round(duration / 20), 3), 10, sync.shape[1] - 1))
seg = librosa.segment.agglomerative(sync, k)
col_times = librosa.frames_to_time(bounds[:-1], sr=sr, hop_length=hop)
starts = [float(col_times[i]) for i in seg]
ends = starts[1:] + [duration]
means = np.array([sync[:, a:b].mean(axis=1) for a, b in zip(seg, list(seg[1:]) + [sync.shape[1]])])
from scipy.cluster.hierarchy import linkage, fcluster
ids = fcluster(linkage(means, "average", metric="cosine"), t=min(4, len(means)), criterion="maxclust") \
if len(means) > 1 else [1]
letters, out = {}, []
for s, e, c in zip(starts, ends, ids):
letters.setdefault(c, chr(ord("A") + len(letters)))
out.append({"start": round(s, 3), "end": round(e, 3), "label": letters[c]})
return out
def melody_grid(notes, beats):
"""Skyline melody on a sixteenth-note grid derived from the beat grid."""
cells = []
for b0, b1 in zip(beats[:-1], beats[1:]):
step = (b1 - b0) / 4
for s in range(4):
t = b0 + s * step
mid = t + step / 2
best = None
for idx, (ns, ne, p, amp, _) in enumerate(notes):
if 48 <= p <= 88 and amp >= 0.25 and ((ns <= mid < ne) or (t <= ns < t + step)):
if best is None or p > best[0]:
best = (int(p), idx)
cells.append((t, t + step, best))
return cells
# ββ ABC writer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
SHARP_ORDER, FLAT_ORDER = "FCGDAEB", "BEADGCF"
MAJOR_SIG = {0: 0, 7: 1, 2: 2, 9: 3, 4: 4, 11: 5, 6: 6, 1: -5, 8: -4, 3: -3, 10: -2, 5: -1}
NATURAL = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
VALID = [16, 12, 8, 6, 4, 3, 2, 1]
QUAL = {"maj": "", "min": "m", "dim": "dim", "aug": "+", "min6": "m6", "maj6": "6", "min7": "m7",
"minmaj7": "mM7", "maj7": "maj7", "7": "7", "dim7": "dim7", "hdim7": "m7b5", "sus2": "sus2", "sus4": "sus4"}
def chord_symbol(label):
root, _, q = label.partition(":")
return root + QUAL.get(q or "maj", q)
def key_signature(tonic, mode):
n = MAJOR_SIG[(tonic + 3) % 12 if mode == "minor" else tonic]
sig = {l: 0 for l in NATURAL}
for l in (SHARP_ORDER[:n] if n > 0 else FLAT_ORDER[:-n]):
sig[l] = 1 if n > 0 else -1
return sig, n < 0
def spell(p, sig, flat_key):
pc = p % 12
opts = [(l, a) for l in NATURAL for a in (-1, 0, 1) if (NATURAL[l] + a) % 12 == pc]
diatonic = [o for o in opts if sig[o[0]] == o[1]]
if diatonic:
return diatonic[0]
pref = -1 if flat_key else 1
return next((o for o in opts if o[1] == 0), None) or next((o for o in opts if o[1] == pref), opts[0])
def abc_pitch(p, sig, flat_key, bar_state):
letter, alter = spell(p, sig, flat_key)
octave = (p - alter) // 12 - 1
acc = ""
key = (letter, octave)
if bar_state.get(key, sig[letter]) != alter:
acc = {1: "^", -1: "_", 0: "="}[alter]
bar_state[key] = alter
name = letter if octave <= 4 else letter.lower()
marks = "," * (4 - octave) if octave < 4 else "'" * (octave - 5) if octave > 5 else ""
return acc + name + marks
def split_len(n):
parts = []
while n > 0:
v = next(v for v in VALID if v <= n)
parts.append(v)
n -= v
return parts
def build_abc(cells, beats, downbeats, chords, tonic, mode, tempo, bpb, title):
sig, flat_key = key_signature(tonic, mode)
first = 0
if len(downbeats):
first = int(np.argmin(np.abs(beats - downbeats[0]))) % bpb
per_bar = bpb * 4
bars, i = [], 0
if first:
bars.append(cells[: first * 4])
i = first * 4
while i < len(cells):
bars.append(cells[i:i + per_bar])
i += per_bar
def nid_of(cell):
return cell[2][1] if cell[2] else None
def chord_at(t):
return next((chord_symbol(c["label"]) for c in chords if c["start"] <= t < c["end"]), None)
body, last_chord = [], None
for bi, bar in enumerate(bars):
bar_state, tokens, j = {}, [], 0
while j < len(bar):
nid = nid_of(bar[j])
here = chord_at(bar[j][0])
k = j + 1
# A group ends when the note changes, or on a beat where the chord
# changes (so the chord symbol has a note to sit on).
while k < len(bar) and nid_of(bar[k]) == nid and not (k % 4 == 0 and chord_at(bar[k][0]) != here):
k += 1
sym = chord_at(bar[j][0]) if j % 4 == 0 else None
prefix = f'"{sym}"' if sym and sym != last_chord else ""
if sym:
last_chord = sym
pitch = abc_pitch(bar[j][2][0], sig, flat_key, bar_state) if nid is not None else "z"
parts = split_len(k - j)
tie_next = nid is not None and (
(k < len(bar) and nid_of(bar[k]) == nid) or
(k == len(bar) and bi + 1 < len(bars) and bars[bi + 1] and nid_of(bars[bi + 1][0]) == nid))
for pi, n in enumerate(parts):
tie = "-" if nid is not None and (pi < len(parts) - 1 or tie_next) else ""
tokens.append((prefix if pi == 0 else "") + pitch + (str(n) if n != 1 else "") + tie)
j = k
body.append(" ".join(tokens))
lines = [" | ".join(body[i:i + 4]) + " |" for i in range(0, len(body), 4)]
key_name = (MAJOR_NAMES if mode == "major" else MINOR_NAMES)[tonic] + ("m" if mode == "minor" else "")
header = [f"X:1", f"T:{title or 'Transcription'}", f"M:{bpb}/4" if bpb != 6 else "M:6/8", "L:1/16"]
if tempo:
header.append(f"Q:1/4={int(round(tempo))}")
header.append(f"K:{key_name}")
return "\n".join(header + lines) + "\n"
def melody_midi(cells, tempo):
import pretty_midi
pm = pretty_midi.PrettyMIDI(initial_tempo=float(tempo or 120))
inst = pretty_midi.Instrument(program=0, name="Melody")
cur = None
for t0, t1, c in cells + [(None, None, None)]:
if cur and (c is None or c[1] != cur[2]):
inst.notes.append(pretty_midi.Note(velocity=90, pitch=cur[0], start=cur[1], end=cur[3]))
cur = None
if c and cur is None:
cur = [c[0], t0, c[1], t1]
elif c and cur:
cur[3] = t1
pm.instruments.append(inst)
return pm
def analyse(wav, melody_only, title):
warnings = []
y, sr = librosa.load(wav, sr=22050, mono=True)
duration = len(y) / sr
if duration > MAX_SECONDS:
raise HTTPException(413, "Tracks longer than 15 minutes are not supported")
beats, downbeats = detect_beats(wav, y, sr, warnings)
if len(beats) < 4:
raise HTTPException(422, "No steady beat found β this track can't be notated")
tempo = round(60.0 / float(np.median(np.diff(beats))), 1)
bpb = 4
if len(downbeats) > 2:
counts = [int(np.sum((beats >= a - 0.05) & (beats < b - 0.05))) for a, b in zip(downbeats[:-1], downbeats[1:])]
vals, freq = np.unique([c for c in counts if 2 <= c <= 7] or [4], return_counts=True)
bpb = int(vals[np.argmax(freq)])
meter = "6/8" if bpb == 6 else f"{bpb}/4"
tonic, mode, key_label = detect_key(y, sr)
chords = detect_chords(wav, warnings)
sections = detect_sections(y, sr, beats, duration)
notes, full_midi = [], None
if STATE["basic_pitch"] == "ready":
from basic_pitch.inference import predict
_, full_midi, notes = predict(wav, M["bp"])
else:
warnings.append("note model unavailable β no melody")
cells = melody_grid(notes, beats)
abc, abc_error = "", None
try:
abc = build_abc(cells, beats, downbeats, chords, tonic, mode, tempo, bpb, title)
except Exception as e:
traceback.print_exc()
abc_error = f"Lead sheet could not be written: {e}"
midi_obj = melody_midi(cells, tempo) if (melody_only or full_midi is None) else full_midi
buf = io.BytesIO()
midi_obj.write(buf)
return {
"abc": abc,
"abc_error": abc_error,
"midi": base64.b64encode(buf.getvalue()).decode("ascii"),
"summary": {
"keys": [{"start": 0.0, "end": round(duration, 3), "label": key_label}],
"chords": chords,
"sections": sections,
"tempo_bpm": tempo,
"meter": meter,
"beat_count": int(len(beats)),
},
"warnings": warnings,
"model_id": ENGINE_ID,
"components": COMPONENTS,
}
# ββ HTTP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _download(url):
if not url.startswith("https://"):
raise HTTPException(400, "audio_url must be https")
req = urllib.request.Request(url, headers={"User-Agent": "BASE-Station-Scribe/1.0"})
with urllib.request.urlopen(req, timeout=120) as r:
data = r.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise HTTPException(413, "audio file too large")
return data
@app.post("/transcribe")
async def transcribe(
file: Optional[UploadFile] = File(None),
audio_url: Optional[str] = Form(None),
melody_only: bool = Form(False),
title: Optional[str] = Form(None),
):
if "loading" in STATE.values():
raise HTTPException(503, "Engine warming up β try again shortly")
if STATE["basic_pitch"] != "ready" and STATE["btc"] != "ready":
raise HTTPException(503, f"Engine failed to load: {ERRORS}")
if file is not None:
audio_bytes = await file.read()
elif audio_url:
audio_bytes = await run_in_threadpool(_download, audio_url)
else:
raise HTTPException(400, "Send an audio file or audio_url")
if not audio_bytes:
raise HTTPException(400, "Empty audio")
def work():
with _LOCK, tempfile.TemporaryDirectory() as d:
src, wav = os.path.join(d, "in"), os.path.join(d, "audio.wav")
with open(src, "wb") as f:
f.write(audio_bytes)
# One decode for every stage: MP3/FLAC/M4A all become 44.1k WAV.
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", src, "-ac", "2", "-ar", "44100", wav],
capture_output=True)
if r.returncode != 0:
raise HTTPException(400, "Could not decode audio")
return analyse(wav, melody_only, (title or "").strip()[:120])
try:
return await run_in_threadpool(work)
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(500, f"Transcription failed: {e}")
@app.get("/health")
def health():
return {"status": "ok", "engine": ENGINE_ID, "components": COMPONENTS, "state": STATE, "errors": ERRORS} |