Spaces:
Paused
Paused
File size: 10,930 Bytes
a9c75f0 e319a7b a9c75f0 e319a7b a9c75f0 e319a7b | 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 | ---
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](https://github.com/jayg996/BTC-ISMIR19) (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](https://github.com/muscriptor/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`
```json
{"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 |
```json
{
"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
}
```
- `time` and `duration` are seconds in the uploaded audio; `root` is a pitch
class (0 = C) and `intervals` the semitones above it that sound, so a client
can play the chord without knowing any music theory. Both are `null`/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_grid` is `null` when no tempo was found. `onset_delay` is always `null`
here: 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 one `beat_grid` shape from either backend.
- `label`, `root` and `intervals` are exactly what MuScriptor's own
`/transcribe` puts in its `transcription_complete` event.
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.
1. 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).
2. Set `ALLOWED_ORIGINS` under Settings β Variables to your frontend's origin.
3. Add a Hugging Face **write** token
([settings/tokens](https://huggingface.co/settings/tokens)) as the `HF_TOKEN`
secret of this GitHub repository.
4. Run the **Sync chord Space** workflow
(`.github/workflows/sync-hf-space.yml`) from the Actions tab, giving it the
Space as `owner/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:
```bash
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_VERSION` build args to what `uv.lock`
resolves. 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.toml` rather than
installed from `uv.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 a `uv sync` install β everything but torch floats within the
ranges `pyproject.toml` declares.
- 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 `chords` extra in `pyproject.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, `429`s 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:
1. `TORCH_NUM_THREADS=1` and `MAX_CONCURRENT_ANALYSES=1` β the Space stays up
and serves one analysis at a time.
2. `RATE_LIMIT_PER_WINDOW=2`, `RATE_LIMIT_HOURLY=10` β a normal session still
works; a script does not.
3. `MAX_AUDIO_SECONDS=120`, `MAX_UPLOAD_MB=10`, `ANALYZE_TIMEOUT_SEC=60` β caps
the cost of any single request.
4. `ALLOWED_ORIGINS` down to the production origin alone (drop
`http://localhost:5173`). This only stops browsers, not `curl`.
5. `ENFORCE_EDGE_SHARED_SECRET=true` with `EDGE_SHARED_SECRET` set (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 as `X-Forwarded-For`.
6. Set the Space to **private** in its settings, or pause it, and let the
frontend fall back to transcribing without a chord track.
|