Spaces:
Paused
Paused
Download deploy/huggingface/app.py from jerrdeh/muscriptor-chords: direct link, hf CLI and curl.
- Browser
- Download file 21.3 kB
-
https://huggingface.co/spaces/jerrdeh/muscriptor-chords/resolve/main/deploy/huggingface/app.py
- Command line
-
hf download hf://spaces/jerrdeh/muscriptor-chords/deploy/huggingface/app.py
-
curl -L -o app.py https://huggingface.co/spaces/jerrdeh/muscriptor-chords/resolve/main/deploy/huggingface/app.py
21.3 kB
| """Chord recognition as a standalone HTTP service, for a Hugging Face Space. | |
| BTC runs comfortably on a CPU, which the note transcription model does not, so | |
| the chord track is split off the main server and served from a free HF Docker | |
| Space: the static web UI calls this Space for chords and only rents a GPU when | |
| someone asks for notes. | |
| Nothing musical is implemented here. The chords, the beat grid and the audio | |
| decoding all come from the installed `muscriptor` package, and the JSON is | |
| shaped exactly like the `chords` / `beat_grid` fields of the | |
| `transcription_complete` SSE event `muscriptor/server.py` already emits, so the | |
| web UI keeps a single chord type no matter which backend answered. | |
| What *is* implemented here is everything a public, unauthenticated, free-tier | |
| endpoint needs and an in-house server behind a VPN does not: an origin | |
| allowlist, per-client rate limits, a cap on how many analyses run at once, a | |
| wall-clock timeout, upload size and duration caps, an optional shared secret, | |
| and error bodies that say what the client did wrong without saying anything | |
| about this machine. Every one of those is an environment variable, so the knobs | |
| can be turned down from the Space's settings page during an incident without a | |
| rebuild β see the README. | |
| """ | |
| import asyncio | |
| import hmac | |
| import io | |
| import logging | |
| import os | |
| import time | |
| import wave | |
| from collections import defaultdict, deque | |
| from importlib.metadata import PackageNotFoundError, version as package_version | |
| from typing import Annotated | |
| import torch | |
| from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from muscriptor.utils.audio import _read_non_wav_file, _read_wav_file, resample | |
| from muscriptor.utils.beats import BeatDetectionError, TempoDetection, detect_grid | |
| from muscriptor.utils.chords import detect_chords, prefers_flats, published_chords | |
| logging.basicConfig( | |
| level=os.environ.get("LOG_LEVEL", "INFO").upper(), | |
| format="%(asctime)s %(levelname)s %(name)s %(message)s", | |
| ) | |
| logger = logging.getLogger("muscriptor.chords") | |
| # The rate the audio is handed to both models at. `TranscriptionModel._load_wav` | |
| # resamples to this before calling either of them, and the beat tracker and BTC | |
| # both resample again internally from whatever they are given β so feeding them | |
| # 16 kHz mono here is what makes this service's output identical to the main | |
| # server's for the same file, rather than merely similar. | |
| SAMPLE_RATE = 16000 | |
| MODEL_NAME = "BTC-large-voca" | |
| try: | |
| VERSION = package_version("muscriptor") | |
| except PackageNotFoundError: # running from a source checkout without an install | |
| VERSION = "0.0.0+unknown" | |
| def _env_str(name: str, default: str) -> str: | |
| value = os.environ.get(name, "").strip() | |
| return value or default | |
| def _env_int(name: str, default: int) -> int: | |
| try: | |
| return int(_env_str(name, str(default))) | |
| except ValueError: | |
| logger.warning("%s is not an integer; using %d", name, default) | |
| return default | |
| def _env_bool(name: str, default: bool = False) -> bool: | |
| return _env_str(name, "true" if default else "false").lower() in ( | |
| "1", | |
| "true", | |
| "yes", | |
| "on", | |
| ) | |
| # Browsers are the only intended caller, so the allowlist is the deployment's | |
| # actual origins and never "*": with "*" any page on the internet could spend | |
| # this Space's CPU budget from a visitor's browser. The default covers the | |
| # production frontend and a local `vite dev`; a Space deployed under a | |
| # different domain must set ALLOWED_ORIGINS. | |
| ALLOWED_ORIGINS = [ | |
| origin.strip() | |
| for origin in _env_str( | |
| "ALLOWED_ORIGINS", "https://muscriptor.vercel.app,http://localhost:5173" | |
| ).split(",") | |
| if origin.strip() and origin.strip() != "*" | |
| ] | |
| # Two limits per client, because they defend against different things: the | |
| # window catches a script hammering the endpoint, the hourly budget catches a | |
| # patient one that stays just under it. Both are deliberately small β a single | |
| # analysis is seconds of CPU on a two-core free-tier box. | |
| RATE_LIMIT_WINDOW_SEC = _env_int("RATE_LIMIT_WINDOW_SEC", 60) | |
| RATE_LIMIT_PER_WINDOW = _env_int("RATE_LIMIT_PER_WINDOW", 5) | |
| RATE_LIMIT_HOURLY = _env_int("RATE_LIMIT_HOURLY", 40) | |
| # How many analyses may hold the CPU at once, and how long any one of them may | |
| # take. The Space has two cores; letting a third request in only makes all | |
| # three slower and pushes them all towards the timeout. | |
| MAX_CONCURRENT_ANALYSES = _env_int("MAX_CONCURRENT_ANALYSES", 2) | |
| ANALYZE_TIMEOUT_SEC = _env_int("ANALYZE_TIMEOUT_SEC", 180) | |
| # Two caps rather than one: bytes are checked before anything is decoded (a | |
| # 300 MB upload must not be buffered to find out it is too big), seconds are | |
| # checked after, since a small FLAC can still be an hour long. | |
| MAX_UPLOAD_MB = _env_int("MAX_UPLOAD_MB", 25) | |
| MAX_AUDIO_SECONDS = _env_int("MAX_AUDIO_SECONDS", 600) | |
| # An optional shared secret the Vercel frontend (or an edge function in front | |
| # of it) sends along, so the Space can be closed to everything but the | |
| # deployment without standing up real auth. Off by default: the Space is | |
| # useless without it only if EDGE_SHARED_SECRET is also set. | |
| ENFORCE_EDGE_SHARED_SECRET = _env_bool("ENFORCE_EDGE_SHARED_SECRET", False) | |
| EDGE_SHARED_SECRET = os.environ.get("EDGE_SHARED_SECRET", "") | |
| EDGE_SHARED_SECRET_HEADER = _env_str("EDGE_SHARED_SECRET_HEADER", "X-Edge-Auth") | |
| # Interactive docs are a map of the API for anyone who finds the Space URL; | |
| # they are off unless someone explicitly wants them while developing. | |
| ENABLE_PUBLIC_API_DOCS = _env_bool("ENABLE_PUBLIC_API_DOCS", False) | |
| # torch defaults to one thread per core, which oversubscribes the box as soon | |
| # as two analyses run at once. Unset means "leave torch alone". | |
| TORCH_NUM_THREADS = _env_int("TORCH_NUM_THREADS", 0) | |
| if TORCH_NUM_THREADS > 0: | |
| torch.set_num_threads(TORCH_NUM_THREADS) | |
| # CPU only, always: this Space is on the free tier and BTC does not need more. | |
| DEVICE = torch.device("cpu") | |
| class _RateLimiter: | |
| """Per-client request timestamps, checked against a burst and an hourly cap. | |
| In-memory and per-process, which is all a single-container Space can do β | |
| and enough, since the thing being protected is this container's own CPU. | |
| Clients that stop calling are forgotten on the next sweep so a stream of | |
| one-shot IPs cannot grow the table without bound. | |
| """ | |
| def __init__(self) -> None: | |
| self._hits: defaultdict[str, deque[float]] = defaultdict(deque) | |
| self._lock = asyncio.Lock() | |
| self._last_sweep = 0.0 | |
| async def check(self, client: str) -> None: | |
| """Record a request from `client`, raising 429 if it is over a limit.""" | |
| now = time.monotonic() | |
| async with self._lock: | |
| self._sweep(now) | |
| hits = self._hits[client] | |
| while hits and now - hits[0] > 3600: | |
| hits.popleft() | |
| in_window = sum(1 for hit in hits if now - hit <= RATE_LIMIT_WINDOW_SEC) | |
| if in_window >= RATE_LIMIT_PER_WINDOW: | |
| retry_after = RATE_LIMIT_WINDOW_SEC | |
| elif len(hits) >= RATE_LIMIT_HOURLY: | |
| retry_after = int(3600 - (now - hits[0])) + 1 | |
| else: | |
| hits.append(now) | |
| return | |
| logger.warning( | |
| "rate limited %s (%d in window, %d in hour)", client, in_window, len(hits) | |
| ) | |
| raise HTTPException( | |
| status_code=429, | |
| detail="too many requests, try again later", | |
| headers={"Retry-After": str(max(1, retry_after))}, | |
| ) | |
| def _sweep(self, now: float) -> None: | |
| """Drop clients with nothing left inside the hourly window.""" | |
| if now - self._last_sweep < 300: | |
| return | |
| self._last_sweep = now | |
| for client in [ | |
| c for c, hits in self._hits.items() if not hits or now - hits[-1] > 3600 | |
| ]: | |
| del self._hits[client] | |
| _rate_limiter = _RateLimiter() | |
| _analysis_slots = asyncio.Semaphore(MAX_CONCURRENT_ANALYSES) | |
| # Flipped by the background warm-up below. /health reports "loading" until then | |
| # rather than blocking, because the UI pings /health to wake a sleeping Space | |
| # and needs an answer within its own timeout, not after the model has loaded. | |
| _model_ready = False | |
| def _load_models() -> None: | |
| """Load BTC (and pull the beat tracker's imports in) once, off the loop. | |
| Both are cached module-side by the muscriptor package β `chords.load_model` | |
| keeps the network per device, and beat_this caches its checkpoint on disk β | |
| so this is purely about paying the cost at startup instead of inside the | |
| first request. The checkpoint itself is baked into the image (see the | |
| Dockerfile), so this normally touches nothing but the local cache. | |
| """ | |
| from muscriptor.utils.chords import load_model | |
| load_model(DEVICE) | |
| try: | |
| # Importing beat_this is what drags in torchaudio and soxr β seconds of | |
| # import time that would otherwise land on whoever asks for a beat grid | |
| # first. Not fatal: chords without a grid are still chords. | |
| import beat_this.inference # noqa: F401 | |
| except Exception: | |
| logger.exception("beat tracker unavailable; /analyze will return no beat grid") | |
| async def _warm_up() -> None: | |
| global _model_ready | |
| started = time.monotonic() | |
| try: | |
| await asyncio.to_thread(_load_models) | |
| except Exception: | |
| # Left not-ready on purpose: /health keeps reporting "loading", which is | |
| # what the UI shows as "still waking up" rather than a broken analysis. | |
| logger.exception("model warm-up failed") | |
| return | |
| _model_ready = True | |
| logger.info("models ready in %.1fs", time.monotonic() - started) | |
| async def _lifespan(app: FastAPI): | |
| task = asyncio.create_task(_warm_up()) | |
| yield | |
| task.cancel() | |
| app = FastAPI( | |
| title="muscriptor chord service", | |
| version=VERSION, | |
| lifespan=_lifespan, | |
| # Both the UI and the schema it is generated from, so a probe of /docs, | |
| # /redoc or /openapi.json finds nothing to enumerate. | |
| docs_url="/docs" if ENABLE_PUBLIC_API_DOCS else None, | |
| redoc_url="/redoc" if ENABLE_PUBLIC_API_DOCS else None, | |
| openapi_url="/openapi.json" if ENABLE_PUBLIC_API_DOCS else None, | |
| ) | |
| async def _reject_oversized_bodies(request: Request, call_next): | |
| """Refuse an over-large upload before anything parses it. | |
| This has to be middleware rather than a check in the endpoint: by the time | |
| a handler with an `UploadFile` parameter runs, Starlette has already read | |
| the whole multipart body (spooling it to a temp file). Rejecting on the | |
| declared Content-Length is what keeps a 300 MB upload from being buffered | |
| just to be told it was too big; the byte counting in `_read_capped` then | |
| catches bodies that declared nothing or lied. | |
| The allowance over the cap is multipart framing β boundaries, headers, the | |
| other form fields β which is a few hundred bytes, not megabytes. | |
| """ | |
| declared = request.headers.get("content-length") | |
| if declared and declared.isdigit(): | |
| if int(declared) > MAX_UPLOAD_MB * 1024 * 1024 + 64 * 1024: | |
| return JSONResponse( | |
| status_code=413, | |
| content={"detail": f"audio file is larger than {MAX_UPLOAD_MB} MB"}, | |
| ) | |
| return await call_next(request) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=ALLOWED_ORIGINS, | |
| allow_credentials=False, # nothing here is per-user; no cookies, no tokens | |
| allow_methods=["GET", "HEAD", "POST", "OPTIONS"], | |
| allow_headers=["Content-Type", EDGE_SHARED_SECRET_HEADER], | |
| max_age=600, | |
| ) | |
| async def _unhandled(request: Request, exc: Exception) -> JSONResponse: | |
| """Last line of defence against leaking this machine into a response body. | |
| FastAPI's default 500 page is fine, but anything that reaches it has an | |
| exception message attached in the logs only if we put it there β so log the | |
| detail (with the traceback) and hand the client a sentence that says | |
| nothing about paths, packages or filenames. | |
| """ | |
| logger.exception("unhandled error on %s", request.url.path) | |
| return JSONResponse(status_code=500, content={"detail": "internal error"}) | |
| def _client_id(request: Request) -> str: | |
| """Who to rate limit, as best as this can be known behind the HF router. | |
| A Space never sees the caller's socket: requests arrive from Hugging | |
| Face's front end, so `request.client.host` is the same proxy address for | |
| everyone and rate limiting on it would throttle the whole world together. | |
| The origin address is the first entry of X-Forwarded-For (the proxy chain | |
| appends its own peer to the right, so the left-most entry is the client). | |
| It is client-controllable β a determined abuser can rotate it β but the | |
| only alternative is no per-client limit at all; MAX_CONCURRENT_ANALYSES | |
| and the timeout are what actually bound the damage. | |
| """ | |
| forwarded = request.headers.get("x-forwarded-for", "") | |
| if forwarded: | |
| return forwarded.split(",")[0].strip()[:64] | |
| return request.client.host if request.client else "unknown" | |
| def _check_shared_secret(request: Request) -> None: | |
| """Enforce the edge secret, when the deployment has turned it on.""" | |
| if not ENFORCE_EDGE_SHARED_SECRET: | |
| return | |
| if not EDGE_SHARED_SECRET: | |
| # Misconfiguration, not a client error: refusing everything is the safe | |
| # reading of "enforce a secret I was never given". | |
| logger.error( | |
| "ENFORCE_EDGE_SHARED_SECRET is set but EDGE_SHARED_SECRET is empty" | |
| ) | |
| raise HTTPException(status_code=503, detail="service unavailable") | |
| presented = request.headers.get(EDGE_SHARED_SECRET_HEADER.lower(), "") | |
| if not hmac.compare_digest(presented, EDGE_SHARED_SECRET): | |
| raise HTTPException(status_code=401, detail="unauthorized") | |
| async def _read_capped(file: UploadFile) -> bytes: | |
| """The upload's bytes, refusing anything over MAX_UPLOAD_MB with a 413. | |
| The declared size was already checked by `_reject_oversized_bodies`; this | |
| is the same cap applied to what actually arrived, which is what catches a | |
| body that declared no length or under-declared it. | |
| """ | |
| limit = MAX_UPLOAD_MB * 1024 * 1024 | |
| chunks: list[bytes] = [] | |
| total = 0 | |
| while chunk := await file.read(1024 * 1024): | |
| total += len(chunk) | |
| if total > limit: | |
| raise HTTPException( | |
| status_code=413, detail=f"audio file is larger than {MAX_UPLOAD_MB} MB" | |
| ) | |
| chunks.append(chunk) | |
| return b"".join(chunks) | |
| def _decode(data: bytes, filename: str | None) -> tuple[torch.Tensor, int]: | |
| """Decode an upload to (wav [C, T], sample rate), exactly as /transcribe does. | |
| PCM WAV goes through the stdlib reader (keeping WAV decoding byte-for-byte | |
| identical to the CLI); anything that isn't a readable WAV β mp3, flac, ogg, | |
| m4a, β¦ β falls back to soundfile/libsndfile. A genuinely undecodable upload | |
| is the client's fault, so it is a 400 rather than a 500; unlike the main | |
| server, the decoder's own message stays in the log, since libsndfile's | |
| errors quote paths and internals. | |
| """ | |
| try: | |
| return _read_wav_file(io.BytesIO(data)) | |
| except (wave.Error, EOFError): | |
| try: | |
| return _read_non_wav_file(io.BytesIO(data)) | |
| except Exception as e: | |
| logger.info("undecodable upload %r: %s", filename, e) | |
| raise HTTPException( | |
| status_code=400, detail="could not decode the uploaded audio file" | |
| ) from e | |
| def _to_mono_16k(wav: torch.Tensor, sample_rate: int) -> torch.Tensor: | |
| """[C, T] at any rate to [1, T] at SAMPLE_RATE β `_load_wav`'s tensor path.""" | |
| wav = wav.float() | |
| if wav.dim() == 1: | |
| wav = wav.unsqueeze(0) | |
| if wav.dim() == 3: | |
| wav = wav.squeeze(0) | |
| if wav.shape[0] > 1: | |
| wav = wav.mean(0, keepdim=True) | |
| if sample_rate != SAMPLE_RATE: | |
| wav = resample(wav, sample_rate, SAMPLE_RATE) | |
| return wav | |
| def _analyze_audio(wav: torch.Tensor, detect_tempo: TempoDetection) -> dict: | |
| """Beat grid then chords for one mono 16 kHz waveform. Runs in a worker thread. | |
| The order matters: the grid is detected first so the chord boundaries can be | |
| snapped to it, which is what puts a chord change on a bar line instead of a | |
| 32nd note off one. Without a grid the boundaries stay where the model heard | |
| them, and `beat_grid` comes back null. | |
| """ | |
| grid = None | |
| if detect_tempo is not False: | |
| try: | |
| grid = detect_grid(wav, SAMPLE_RATE) | |
| except BeatDetectionError as e: | |
| if detect_tempo is True: | |
| # The client asked for a grid or nothing; this is about the | |
| # audio it sent, so it is safe (and useful) to say so. | |
| raise HTTPException(status_code=400, detail=f"no beat grid: {e}") from e | |
| logger.info("no beat grid (%s); continuing without one", e) | |
| recognized = detect_chords(wav, SAMPLE_RATE, grid, device=DEVICE) | |
| # Decided once over every span, so the symbols are spelled the way the main | |
| # server would spell them for the same recording. | |
| flats = prefers_flats(recognized) | |
| return { | |
| "chords": [ | |
| { | |
| "time": chord.start, | |
| "duration": chord.end - chord.start, | |
| "label": chord.label(flats), | |
| "root": chord.root, | |
| "intervals": list(chord.intervals), | |
| } | |
| for chord in published_chords(recognized) | |
| ], | |
| "beat_grid": { | |
| "bpm": grid.bpm, | |
| "beats_per_bar": grid.beats_per_bar, | |
| "first_downbeat": grid.first_downbeat, | |
| # Always null here, and that is not an omission: onset_delay is how | |
| # late the *transcribed notes* sit against the beats, and this | |
| # service transcribes none. The field stays in the payload so the | |
| # UI can consume one beat_grid shape from either backend. | |
| "onset_delay": grid.onset_delay, | |
| } | |
| if grid | |
| else None, | |
| } | |
| async def health() -> dict: | |
| """Liveness and readiness in one, deliberately cheap and never rate limited. | |
| The UI pings this before it uploads anything, to wake a Space that has gone | |
| to sleep β so it has to answer while the model is still loading, and has to | |
| stay outside the rate limiter or the ping would eat the client's budget. | |
| """ | |
| return { | |
| "status": "ok" if _model_ready else "loading", | |
| "version": VERSION, | |
| "model": MODEL_NAME, | |
| } | |
| async def analyze( | |
| request: Request, | |
| file: Annotated[UploadFile, File()], | |
| # Same three-valued flag as /transcribe: "true" fails loudly when no tempo | |
| # is found, "false" doesn't even try, "best-effort" falls back to no grid. | |
| detect_tempo: Annotated[TempoDetection, Form()] = "best-effort", | |
| ) -> dict: | |
| """Recognize the chords in an uploaded recording. | |
| The gates run cheapest-first β secret, rate limit, byte cap, decode, | |
| duration cap β so that abuse is rejected before it costs anything, and only | |
| then is one of the few analysis slots taken. | |
| """ | |
| _check_shared_secret(request) | |
| await _rate_limiter.check(_client_id(request)) | |
| data = await _read_capped(file) | |
| wav, sample_rate = _decode(data, file.filename) | |
| duration = wav.shape[-1] / sample_rate | |
| if duration > MAX_AUDIO_SECONDS: | |
| raise HTTPException( | |
| status_code=413, | |
| detail=f"audio is {duration:.0f}s long, the limit is {MAX_AUDIO_SECONDS}s", | |
| ) | |
| if _analysis_slots.locked(): | |
| # Queueing instead would just hand every waiting client a timeout; a | |
| # 503 lets the UI say "busy, try again" while it is still true. | |
| raise HTTPException(status_code=503, detail="all analysis slots are busy") | |
| wav = _to_mono_16k(wav, sample_rate) | |
| started = time.monotonic() | |
| await _analysis_slots.acquire() | |
| task = asyncio.create_task(asyncio.to_thread(_analyze_audio, wav, detect_tempo)) | |
| # Released when the *work* finishes, not when this request stops waiting for | |
| # it: torch calls in a worker thread cannot be cancelled, so a timed-out | |
| # analysis is still burning a core. Freeing the slot at the timeout would | |
| # admit a second request onto the same busy CPU. | |
| task.add_done_callback(lambda _: _analysis_slots.release()) | |
| try: | |
| result = await asyncio.wait_for(asyncio.shield(task), ANALYZE_TIMEOUT_SEC) | |
| except asyncio.TimeoutError: | |
| logger.warning("analysis timed out after %ds", ANALYZE_TIMEOUT_SEC) | |
| raise HTTPException(status_code=504, detail="analysis timed out") from None | |
| result["duration"] = duration | |
| result["processing_time_ms"] = int((time.monotonic() - started) * 1000) | |
| logger.info( | |
| "analyzed %.1fs of audio in %dms (%d chords)", | |
| duration, | |
| result["processing_time_ms"], | |
| len(result["chords"]), | |
| ) | |
| return result | |