muscriptor-chords / README.md
jerryzhou196
Initial chord service
e319a7b unverified
|
Raw History Blame Contribute Delete
10.9 kB
---
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.