Spaces:
Paused
Download README.md from jerrdeh/muscriptor-chords: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/spaces/jerrdeh/muscriptor-chords/resolve/main/README.md
- Command line
-
hf download hf://spaces/jerrdeh/muscriptor-chords/README.md
-
curl -L -o README.md https://huggingface.co/spaces/jerrdeh/muscriptor-chords/resolve/main/README.md
title: MuScriptor Chords
emoji: πΈ
colorFrom: indigo
colorTo: purple
sdk: docker
app_port: 7860
pinned: false
license: mit
short_description: Chord recognition for MuScriptor, on CPU
MuScriptor chord service
Chord recognition from audio, served as a small HTTP API from a free CPU Space.
Upload a recording to POST /analyze and get back the chord track β symbol,
start time, duration, and the notes each chord is made of β plus the beat grid
the chord boundaries were snapped to.
The model is BTC (Bi-directional Transformer for Chord Recognition, Park et al., ISMIR 2019), large-vocabulary checkpoint: 12 roots Γ 14 qualities plus "no chord". It runs on the CPU in a couple of seconds per minute of audio, which is the whole reason this endpoint exists separately β MuScriptor's note transcription needs a GPU, chord recognition does not, so the chord track is served from here and the GPU is only rented when someone asks for notes.
Nothing musical is implemented in app.py. Chords, beat tracking and audio
decoding are imported from the muscriptor package installed into the image,
so this Space and the main server return the same chords for the same file.
API
https://<owner>-muscriptor-chords.hf.space
GET /health, HEAD /health
{"status": "ok", "version": "0.3.0", "model": "BTC-large-voca"}
Cheap, never rate limited, and answers before the model has finished loading β
"status" is "loading" until it has. A free Space sleeps when it is idle, so
a client should ping /health to wake it before uploading anything, and wait
for "ok" before expecting /analyze to be quick.
POST /analyze β multipart/form-data
| field | type | default | meaning |
|---|---|---|---|
file |
audio upload | required | any format libsndfile/ffmpeg reads (wav, mp3, flac, ogg, m4a, β¦) |
detect_tempo |
"best-effort"ο½"true"ο½"false" |
"best-effort" |
when a beat grid is found, chord boundaries snap to it. "true" fails the request when no grid is found, "false" skips beat tracking entirely |
{
"chords": [
{"time": 0.0, "duration": 1.85, "label": "C", "root": 0, "intervals": [0, 4, 7]}
],
"beat_grid": {"bpm": 120.0, "beats_per_bar": 4, "first_downbeat": 0.12, "onset_delay": null},
"duration": 12.34,
"processing_time_ms": 4210
}
timeanddurationare seconds in the uploaded audio;rootis a pitch class (0 = C) andintervalsthe semitones above it that sound, so a client can play the chord without knowing any music theory. Both arenull/empty for an"N.C."span.- The list starts at the first real chord. Silence a recording opens with is
not a chord change;
"N.C."spans between chords are kept, since they say the harmony stopped. - Chord times need no correction β they are snapped to the beats already.
beat_gridisnullwhen no tempo was found.onset_delayis alwaysnullhere: it measures how late transcribed notes sit against the beats, and this service transcribes none. The field is in the payload so that a client can consume onebeat_gridshape from either backend.label,rootandintervalsare exactly what MuScriptor's own/transcribeputs in itstranscription_completeevent.
Errors are a sanitized {"detail": "β¦"}: 400 undecodable audio (or no beat
grid when detect_tempo=true), 401 missing/incorrect shared secret when one
is enforced, 413 too large or too long, 429 rate limited (with
Retry-After), 503 all analysis slots busy, 504 analysis timed out.
Details never quote a traceback, a package or a path β those go to the Space
logs instead.
Configuration
Every knob is an environment variable, set under the Space's Settings β Variables and secrets. Changing one restarts the Space; none of them need a rebuild.
| variable | default | purpose |
|---|---|---|
ALLOWED_ORIGINS |
https://muscriptor.vercel.app,http://localhost:5173 |
CSV allowlist of browser origins for CORS. * is ignored on purpose: with it, any page on the internet could spend this Space's CPU from a visitor's browser. Set it to the real frontend origin for your deployment. |
RATE_LIMIT_WINDOW_SEC |
60 |
Length of the burst window, in seconds. |
RATE_LIMIT_PER_WINDOW |
5 |
Analyses one client may start per burst window. |
RATE_LIMIT_HOURLY |
40 |
Analyses one client may start per hour. |
MAX_CONCURRENT_ANALYSES |
2 |
Analyses running at once; requests beyond it get 503 rather than queueing behind a busy CPU. |
ANALYZE_TIMEOUT_SEC |
180 |
Wall-clock budget for one analysis, after which the client gets 504. |
MAX_UPLOAD_MB |
25 |
Upload size cap, checked while reading so an oversized body is never fully buffered. |
MAX_AUDIO_SECONDS |
600 |
Duration cap, checked after decoding β a small file can still be an hour long. |
ENFORCE_EDGE_SHARED_SECRET |
false |
When true, /analyze requires the shared secret header. |
EDGE_SHARED_SECRET |
(unset) | The secret itself. Store it as a secret, not a variable. |
EDGE_SHARED_SECRET_HEADER |
X-Edge-Auth |
Header the secret is read from. |
ENABLE_PUBLIC_API_DOCS |
false |
Swagger (/docs), ReDoc (/redoc) and /openapi.json. Off by default: they are a map of the API for anyone who finds the URL. |
TORCH_NUM_THREADS |
(unset) | Cap torch's thread pool. Worth setting to 1 when MAX_CONCURRENT_ANALYSES is above 1, since torch otherwise takes every core for each analysis. |
LOG_LEVEL |
INFO |
Python logging level. |
Rate limiting keys on the client IP as Hugging Face presents it: a Space never
sees the caller's socket, so the address is taken from the left-most entry of
X-Forwarded-For (request.client.host is the same HF router for everybody).
That header is client-controllable and an abuser can rotate it β the real
bounds on damage are MAX_CONCURRENT_ANALYSES and ANALYZE_TIMEOUT_SEC.
No Hugging Face token is needed at runtime. The BTC checkpoint comes from GitHub (SHA-256 pinned) and the beat tracker's from a public HF repo; the gated MuScriptor transcription weights are never loaded here.
Deploying
The Space repository is generated from this one β do not edit it by hand, a sync overwrites it.
- Create the Space once: New Space on Hugging Face, SDK Docker,
hardware CPU basic (free), name it
muscriptor-chords(any name works; it only has to match what the frontend calls). - Set
ALLOWED_ORIGINSunder Settings β Variables to your frontend's origin. - Add a Hugging Face write token
(settings/tokens) as the
HF_TOKENsecret of this GitHub repository. - Run the Sync chord Space workflow
(
.github/workflows/sync-hf-space.yml) from the Actions tab, giving it the Space asowner/name. It assembles the Space contents and force-pushes them; Hugging Face then builds the image.
The workflow lays the Space repository out as:
README.md β deploy/huggingface/README.md (this file; its frontmatter configures the Space)
Dockerfile β deploy/huggingface/Dockerfile
.dockerignore β deploy/huggingface/.dockerignore
deploy/huggingface/app.py
pyproject.toml, uv.lock, LICENSE, LICENSE-BTC
muscriptor/ β the package the service imports
Hugging Face insists on README.md and Dockerfile at the repository root;
everything else keeps the layout it has in this repository. The build context
is the repository root, which is why the Dockerfile's COPY paths read
pyproject.toml, muscriptor/ and deploy/huggingface/app.py β the same
paths work from a checkout here:
docker build -f deploy/huggingface/Dockerfile -t muscriptor-chords .
docker run --rm -p 7860:7860 muscriptor-chords
curl -F file=@web/public/headache_by_lost_deposit_10s.mp3 http://localhost:7860/analyze
To push without GitHub Actions, do by hand what the workflow does: copy those
files into a clone of the Space and git push.
About the image
python:3.11-slim, ffmpeg + libsndfile1, running as uid 10001. Every cache the process writes (HOME,HF_HOME, numba's) is redirected under/home/app, since a Space's non-root user cannot write/root/.cache.- torch and torchaudio are installed from PyTorch's CPU index, pinned by
the
TORCH_VERSION/TORCHAUDIO_VERSIONbuild args to whatuv.lockresolves. The PyPI wheels bundle over 2 GB of CUDA that this Space would never use. Bump the two together when the lockfile moves. - The rest of the dependencies are resolved from
pyproject.tomlrather than installed fromuv.lock: the lock pins the PyPI (CUDA) build of torch, which would undo the line above. The trade-off is that a Space image is not bit-identical to auv syncinstall β everything but torch floats within the rangespyproject.tomldeclares. - The BTC checkpoint is downloaded and digest-checked during the build, the way the repository's root Dockerfile pre-warms its soundfonts, so a cold container answers its first request without fetching weights. The beat tracker's checkpoint is pre-fetched too, best-effort.
- There is no
chordsextra inpyproject.toml, and adding one would not make this image smaller: everything MuScriptor declares as a Python dependency is also needed here (torch, librosa, soundfile, beat-this, mido, fastapi β¦). What this Space does not need β MuseScore, fluidsynth, the soundfonts, the web build β are system packages and build steps, not Python dependencies, and they are simply absent from this Dockerfile.
If the Space is being abused
Symptoms: the queue is permanently full, 429s in the logs from a handful of
addresses, or the free CPU quota disappearing. In rough order of how much they
cost a legitimate user, all of them Settings β Variables, no rebuild:
TORCH_NUM_THREADS=1andMAX_CONCURRENT_ANALYSES=1β the Space stays up and serves one analysis at a time.RATE_LIMIT_PER_WINDOW=2,RATE_LIMIT_HOURLY=10β a normal session still works; a script does not.MAX_AUDIO_SECONDS=120,MAX_UPLOAD_MB=10,ANALYZE_TIMEOUT_SEC=60β caps the cost of any single request.ALLOWED_ORIGINSdown to the production origin alone (drophttp://localhost:5173). This only stops browsers, notcurl.ENFORCE_EDGE_SHARED_SECRET=truewithEDGE_SHARED_SECRETset (and the same value configured in the frontend) β closes the endpoint to everything but the deployment. This is the switch that actually stops a determined caller, since IP-based limits are only as good asX-Forwarded-For.- Set the Space to private in its settings, or pause it, and let the frontend fall back to transcribing without a chord track.