muscriptor 0.1.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {muscriptor-0.1.0 → muscriptor-0.2.1}/.dockerignore +3 -3
  2. {muscriptor-0.1.0 → muscriptor-0.2.1}/.github/workflows/pypi.yml +9 -0
  3. {muscriptor-0.1.0 → muscriptor-0.2.1}/.gitignore +1 -2
  4. {muscriptor-0.1.0 → muscriptor-0.2.1}/Dockerfile +14 -11
  5. {muscriptor-0.1.0 → muscriptor-0.2.1}/PKG-INFO +22 -19
  6. {muscriptor-0.1.0 → muscriptor-0.2.1}/README.md +20 -17
  7. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/main.py +3 -3
  8. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/server.py +13 -1
  9. muscriptor-0.2.1/muscriptor/soundfonts.py +19 -0
  10. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/utils/auralization.py +19 -8
  11. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/utils/download.py +10 -1
  12. muscriptor-0.2.1/muscriptor/web_dist/assets/index-BZbHOTyZ.js +223 -0
  13. muscriptor-0.2.1/muscriptor/web_dist/assets/index-b8IJOPe2.css +2 -0
  14. muscriptor-0.2.1/muscriptor/web_dist/assets/spessasynth_processor.min-tdOdhV7p.js +18 -0
  15. muscriptor-0.2.1/muscriptor/web_dist/index.html +30 -0
  16. {muscriptor-0.1.0 → muscriptor-0.2.1}/pyproject.toml +7 -2
  17. {muscriptor-0.1.0 → muscriptor-0.2.1}/uv.lock +2 -2
  18. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/package.json +0 -2
  19. muscriptor-0.2.1/web/public/favicon.png +0 -0
  20. muscriptor-0.2.1/web/public/kyutai-logo.svg +9 -0
  21. muscriptor-0.2.1/web/public/mirelo-logo.svg +10 -0
  22. muscriptor-0.2.1/web/public/muscriptor-logo-v2.png +0 -0
  23. muscriptor-0.2.1/web/public/muscriptor-logo-v4-pink.svg +44 -0
  24. muscriptor-0.2.1/web/public/muscriptor-mark-v4-pink.svg +33 -0
  25. muscriptor-0.2.1/web/public/muscriptor-wave.svg +3 -0
  26. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/audio.ts +3 -2
  27. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/vite.config.ts +10 -2
  28. muscriptor-0.1.0/web/scripts/prepare-soundfonts.mjs +0 -69
  29. {muscriptor-0.1.0 → muscriptor-0.2.1}/.pre-commit-config.yaml +0 -0
  30. {muscriptor-0.1.0 → muscriptor-0.2.1}/.python-version +0 -0
  31. {muscriptor-0.1.0 → muscriptor-0.2.1}/LICENSE +0 -0
  32. {muscriptor-0.1.0 → muscriptor-0.2.1}/deploy.sh +0 -0
  33. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/__init__.py +0 -0
  34. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/__main__.py +0 -0
  35. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/events.py +0 -0
  36. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/models/__init__.py +0 -0
  37. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/models/lm.py +0 -0
  38. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/modules/__init__.py +0 -0
  39. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/modules/conditioners.py +0 -0
  40. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/modules/mel_spectrogram.py +0 -0
  41. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/modules/streaming.py +0 -0
  42. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/modules/transformer.py +0 -0
  43. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/tokenizer/__init__.py +0 -0
  44. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/tokenizer/mt3.py +0 -0
  45. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/tokenizer/notes.py +0 -0
  46. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/transcription_model.py +0 -0
  47. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/utils/__init__.py +0 -0
  48. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/utils/audio.py +0 -0
  49. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/utils/midi.py +0 -0
  50. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/utils/resample.py +0 -0
  51. {muscriptor-0.1.0 → muscriptor-0.2.1}/muscriptor/utils/sampling.py +0 -0
  52. {muscriptor-0.1.0/web/public → muscriptor-0.2.1/muscriptor/web_dist}/favicon.png +0 -0
  53. {muscriptor-0.1.0/web/public → muscriptor-0.2.1/muscriptor/web_dist}/kyutai-logo.svg +0 -0
  54. {muscriptor-0.1.0/web/public → muscriptor-0.2.1/muscriptor/web_dist}/mirelo-logo.svg +0 -0
  55. {muscriptor-0.1.0/web/public → muscriptor-0.2.1/muscriptor/web_dist}/muscriptor-logo-v2.png +0 -0
  56. {muscriptor-0.1.0/web/public → muscriptor-0.2.1/muscriptor/web_dist}/muscriptor-logo-v4-pink.svg +0 -0
  57. {muscriptor-0.1.0/web/public → muscriptor-0.2.1/muscriptor/web_dist}/muscriptor-mark-v4-pink.svg +0 -0
  58. {muscriptor-0.1.0/web/public → muscriptor-0.2.1/muscriptor/web_dist}/muscriptor-wave.svg +0 -0
  59. {muscriptor-0.1.0 → muscriptor-0.2.1}/swarm.yml +0 -0
  60. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/__init__.py +0 -0
  61. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/conftest.py +0 -0
  62. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/encode_helpers.py +0 -0
  63. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_cli.py +0 -0
  64. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_conditioners.py +0 -0
  65. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_events.py +0 -0
  66. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_integration.py +0 -0
  67. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_midi.py +0 -0
  68. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_notes.py +0 -0
  69. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_sampling.py +0 -0
  70. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_server.py +0 -0
  71. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_transcription_model.py +0 -0
  72. {muscriptor-0.1.0 → muscriptor-0.2.1}/tests/test_transformer.py +0 -0
  73. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/index.html +0 -0
  74. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/logo_muscriptor_final.png +0 -0
  75. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/package-lock.json +0 -0
  76. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/pnpm-lock.yaml +0 -0
  77. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/App.tsx +0 -0
  78. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/ConditioningPanel.tsx +0 -0
  79. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/Controls.tsx +0 -0
  80. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/DropOverlay.tsx +0 -0
  81. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/Footer.tsx +0 -0
  82. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/InstrumentList.tsx +0 -0
  83. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/OutputBar.tsx +0 -0
  84. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/PianoRollCanvas.tsx +0 -0
  85. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/WelcomeScreen.tsx +0 -0
  86. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/components/icons.tsx +0 -0
  87. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/hooks/useAudioEngine.ts +0 -0
  88. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/hooks/useTranscription.ts +0 -0
  89. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/instruments.ts +0 -0
  90. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/main.tsx +0 -0
  91. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/pianoroll.ts +0 -0
  92. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/progress.ts +0 -0
  93. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/sse.ts +0 -0
  94. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/style.css +0 -0
  95. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/src/vite-env.d.ts +0 -0
  96. {muscriptor-0.1.0 → muscriptor-0.2.1}/web/tsconfig.json +0 -0
@@ -7,9 +7,9 @@
7
7
  *.mid
8
8
  .venv
9
9
  web/node_modules
10
- web/dist
11
- # Regenerated inside the web-builder stage from MuseScore_General.sf2.
12
- web/public/soundfonts
10
+ # Built fresh in the web-builder stage; a stale local build must not leak
11
+ # into the runtime image via `COPY muscriptor/`.
12
+ muscriptor/web_dist
13
13
  .git
14
14
  .gitignore
15
15
  __pycache__
@@ -15,6 +15,15 @@ jobs:
15
15
  - uses: actions/checkout@v2
16
16
  - name: Install uv
17
17
  uses: astral-sh/setup-uv@v7
18
+ - uses: actions/setup-node@v4
19
+ with:
20
+ node-version: 22
21
+ - name: Build frontend into muscriptor/web_dist
22
+ run: |
23
+ corepack enable
24
+ pnpm install --frozen-lockfile
25
+ pnpm run build
26
+ working-directory: web
18
27
  - name: Build wheels
19
28
  run: uv build
20
29
  - name: Publish package distributions to PyPI
@@ -7,8 +7,7 @@
7
7
  *.ogg
8
8
  *.sf2
9
9
  web/node_modules
10
- web/dist
11
- web/public/soundfonts/
10
+ muscriptor/web_dist/
12
11
  web/tsconfig.tsbuildinfo
13
12
  /dist
14
13
  *.egg-info
@@ -8,14 +8,8 @@ RUN corepack enable
8
8
  COPY web/package.json web/pnpm-lock.yaml ./
9
9
  RUN pnpm install --frozen-lockfile
10
10
 
11
- # Download the soundfonts: the .sf3 the frontend streams from /soundfonts/,
12
- # and the .sf2 for /auralize (to /MuseScore_General.sf2, one level above the
13
- # package root — the runtime stage copies it from here). Runs in its own
14
- # layer so day-to-day source changes don't re-trigger the 253 MB download.
15
- COPY web/scripts/prepare-soundfonts.mjs scripts/
16
- RUN node scripts/prepare-soundfonts.mjs
17
-
18
11
  COPY web/ ./
12
+ # vite outDir is ../muscriptor/web_dist, i.e. /muscriptor/web_dist in this stage.
19
13
  RUN pnpm run build
20
14
 
21
15
 
@@ -38,15 +32,24 @@ RUN --mount=type=cache,target=/root/.cache/uv \
38
32
  --mount=type=bind,source=uv.lock,target=uv.lock \
39
33
  uv sync --no-install-project
40
34
 
35
+ # Pre-fetch the soundfonts (253 MB: .sf2 for /auralize, .sf3 for the web UI)
36
+ # into the HF hub cache so a fresh container doesn't download them on first
37
+ # use. Runs off just the two files it needs, before the full source COPY,
38
+ # so day-to-day code changes don't re-trigger the download.
39
+ COPY muscriptor/soundfonts.py muscriptor/utils/download.py /tmp/prewarm/
40
+ RUN /app/.venv/bin/python -c "\
41
+ import sys; sys.path.insert(0, '/tmp/prewarm'); \
42
+ import soundfonts, download; \
43
+ download.download_if_necessary(soundfonts.SF2_URL); \
44
+ download.download_if_necessary(soundfonts.SF3_URL)" \
45
+ && rm -rf /tmp/prewarm
46
+
41
47
  COPY pyproject.toml uv.lock README.md ./
42
- # SoundFont for auralization; auralize() defaults to <repo-root>/MuseScore_General.sf2.
43
- # Reuse the copy the web-builder stage downloaded — no need for it in the context.
44
- COPY --from=web-builder /MuseScore_General.sf2 ./
45
48
  COPY muscriptor/ ./muscriptor/
46
49
  RUN --mount=type=cache,target=/root/.cache/uv \
47
50
  uv sync
48
51
 
49
- COPY --from=web-builder /web/dist ./web/dist
52
+ COPY --from=web-builder /muscriptor/web_dist ./muscriptor/web_dist
50
53
 
51
54
 
52
55
  EXPOSE 8000
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muscriptor
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Audio-to-MIDI transcription using a transformer language model
5
5
  Project-URL: Homepage, https://github.com/muscriptor/muscriptor
6
6
  Project-URL: Repository, https://github.com/muscriptor/muscriptor
@@ -27,7 +27,7 @@ Requires-Dist: numpy>=1.24
27
27
  Requires-Dist: python-multipart>=0.0.29
28
28
  Requires-Dist: safetensors>=0.4
29
29
  Requires-Dist: soundfile>=0.14.0
30
- Requires-Dist: torch<2.7,>=2.0
30
+ Requires-Dist: torch>=2.0
31
31
  Requires-Dist: typer>=0.10
32
32
  Requires-Dist: uvicorn[standard]>=0.48.0
33
33
  Description-Content-Type: text/markdown
@@ -41,7 +41,7 @@ Description-Content-Type: text/markdown
41
41
  MuScriptor is a multi-instrument music transcription model developed by [Kyutai](https://kyutai.org) and [Mirelo](https://www.mirelo.ai).
42
42
  MuScriptor is the first music transcription model that has been trained on a large scale dataset of 170k songs from classical music to heavy metal.
43
43
 
44
- [Online Demo](https://muscriptor.kyutai.org) | [Paper] (coming soon) | [HuggingFace](https://huggingface.co/MuScriptor)
44
+ [Online Demo](https://muscriptor.kyutai.org) | [Paper](https://arxiv.org/abs/2607.08168v1) | [HuggingFace](https://huggingface.co/MuScriptor)
45
45
 
46
46
  <!-- TODO: record the demo GIF (web UI piano roll), save it as assets/demo.gif,
47
47
  then uncomment:
@@ -276,11 +276,15 @@ uv sync
276
276
  cd web && pnpm install && pnpm run build && cd ..
277
277
  ```
278
278
 
279
- `pnpm run build` is required once — the FastAPI server auto-mounts
280
- `web/dist/` if it exists. The first build downloads two soundfonts:
281
- `MuseScore_General.sf2` (215 MB, to the repo root, used by `/auralize`) and
282
- `MuseScore_General.sf3` (38 MB, the compressed build the UI plays); later
283
- builds skip files already on disk.
279
+ `pnpm run build` is required once — it outputs to `muscriptor/web_dist/`,
280
+ which the FastAPI server auto-mounts if it exists (and which ships inside
281
+ the PyPI wheel, so `uvx muscriptor serve` works without a checkout).
282
+
283
+ The soundfonts are not bundled: the server fetches
284
+ `MuseScore_General.sf2` (215 MB, used by `/auralize`) and
285
+ `MuseScore_General.sf3` (38 MB, the compressed build the UI plays) from
286
+ [MuScriptor/assets](https://huggingface.co/MuScriptor/assets) on first use
287
+ and caches them locally (see `muscriptor/soundfonts.py`).
284
288
 
285
289
  ### Run
286
290
 
@@ -305,8 +309,8 @@ a WAV onto the page.
305
309
  is local-only.
306
310
  - Playback runs a full SoundFont synthesizer ([SpessaSynth](https://github.com/spessasus/spessasynth_lib))
307
311
  in the browser, fed with `MuseScore_General.sf3` — the same soundfont the
308
- `/auralize` endpoint uses, served by the app itself from `/soundfonts/`,
309
- no third-party CDN.
312
+ `/auralize` endpoint uses, served by the app itself from `/soundfonts/`
313
+ (cached server-side), no third-party CDN.
310
314
 
311
315
  ## License
312
316
 
@@ -322,15 +326,14 @@ distributed under its own (MIT) license.
322
326
 
323
327
  ## Citation
324
328
 
325
- <!-- TODO: fill in the BibTeX entry when the paper is out:
326
329
  ```bibtex
327
- @techreport{muscriptor2026,
328
- title = {MuScriptor: ...},
329
- author = {...},
330
- year = {2026},
331
- url = {...},
330
+ @misc{rouard2026muscriptoropenmodelmultiinstrument,
331
+ title={MuScriptor: An Open Model for Multi-Instrument Music Transcription},
332
+ author={Simon Rouard and Michael Krause and Axel Roebel and Carl-Johann Simon-Gabriel and Alexandre Défossez},
333
+ year={2026},
334
+ eprint={2607.08168},
335
+ archivePrefix={arXiv},
336
+ primaryClass={cs.SD},
337
+ url={https://arxiv.org/abs/2607.08168},
332
338
  }
333
339
  ```
334
- -->
335
-
336
- The paper is coming soon — a citation entry will be added here with it.
@@ -7,7 +7,7 @@
7
7
  MuScriptor is a multi-instrument music transcription model developed by [Kyutai](https://kyutai.org) and [Mirelo](https://www.mirelo.ai).
8
8
  MuScriptor is the first music transcription model that has been trained on a large scale dataset of 170k songs from classical music to heavy metal.
9
9
 
10
- [Online Demo](https://muscriptor.kyutai.org) | [Paper] (coming soon) | [HuggingFace](https://huggingface.co/MuScriptor)
10
+ [Online Demo](https://muscriptor.kyutai.org) | [Paper](https://arxiv.org/abs/2607.08168v1) | [HuggingFace](https://huggingface.co/MuScriptor)
11
11
 
12
12
  <!-- TODO: record the demo GIF (web UI piano roll), save it as assets/demo.gif,
13
13
  then uncomment:
@@ -242,11 +242,15 @@ uv sync
242
242
  cd web && pnpm install && pnpm run build && cd ..
243
243
  ```
244
244
 
245
- `pnpm run build` is required once — the FastAPI server auto-mounts
246
- `web/dist/` if it exists. The first build downloads two soundfonts:
247
- `MuseScore_General.sf2` (215 MB, to the repo root, used by `/auralize`) and
248
- `MuseScore_General.sf3` (38 MB, the compressed build the UI plays); later
249
- builds skip files already on disk.
245
+ `pnpm run build` is required once — it outputs to `muscriptor/web_dist/`,
246
+ which the FastAPI server auto-mounts if it exists (and which ships inside
247
+ the PyPI wheel, so `uvx muscriptor serve` works without a checkout).
248
+
249
+ The soundfonts are not bundled: the server fetches
250
+ `MuseScore_General.sf2` (215 MB, used by `/auralize`) and
251
+ `MuseScore_General.sf3` (38 MB, the compressed build the UI plays) from
252
+ [MuScriptor/assets](https://huggingface.co/MuScriptor/assets) on first use
253
+ and caches them locally (see `muscriptor/soundfonts.py`).
250
254
 
251
255
  ### Run
252
256
 
@@ -271,8 +275,8 @@ a WAV onto the page.
271
275
  is local-only.
272
276
  - Playback runs a full SoundFont synthesizer ([SpessaSynth](https://github.com/spessasus/spessasynth_lib))
273
277
  in the browser, fed with `MuseScore_General.sf3` — the same soundfont the
274
- `/auralize` endpoint uses, served by the app itself from `/soundfonts/`,
275
- no third-party CDN.
278
+ `/auralize` endpoint uses, served by the app itself from `/soundfonts/`
279
+ (cached server-side), no third-party CDN.
276
280
 
277
281
  ## License
278
282
 
@@ -288,15 +292,14 @@ distributed under its own (MIT) license.
288
292
 
289
293
  ## Citation
290
294
 
291
- <!-- TODO: fill in the BibTeX entry when the paper is out:
292
295
  ```bibtex
293
- @techreport{muscriptor2026,
294
- title = {MuScriptor: ...},
295
- author = {...},
296
- year = {2026},
297
- url = {...},
296
+ @misc{rouard2026muscriptoropenmodelmultiinstrument,
297
+ title={MuScriptor: An Open Model for Multi-Instrument Music Transcription},
298
+ author={Simon Rouard and Michael Krause and Axel Roebel and Carl-Johann Simon-Gabriel and Alexandre Défossez},
299
+ year={2026},
300
+ eprint={2607.08168},
301
+ archivePrefix={arXiv},
302
+ primaryClass={cs.SD},
303
+ url={https://arxiv.org/abs/2607.08168},
298
304
  }
299
305
  ```
300
- -->
301
-
302
- The paper is coming soon — a citation entry will be added here with it.
@@ -137,8 +137,8 @@ def transcribe(
137
137
  typer.Option(
138
138
  "--soundfont",
139
139
  help=(
140
- "Path to a .sf2 SoundFont for auralization. "
141
- "Defaults to the bundled MuseScore_General.sf2."
140
+ "Path to a .sf2 SoundFont for auralization. Defaults to "
141
+ "MuseScore_General.sf2, downloaded once and cached locally."
142
142
  ),
143
143
  ),
144
144
  ] = None,
@@ -307,7 +307,7 @@ def serve(
307
307
  _device = None if device == "auto" else device
308
308
  typer.echo("Loading model…")
309
309
  model = TranscriptionModel.load_model(weights_path=model_path, device=_device)
310
- web_dir = Path(__file__).resolve().parent.parent / "web" / "dist"
310
+ web_dir = Path(__file__).resolve().parent / "web_dist"
311
311
  fastapi_app = create_app(model, web_dir=web_dir if web_dir.is_dir() else None)
312
312
  uvicorn.run(fastapi_app, host=host, port=port)
313
313
 
@@ -22,14 +22,16 @@ from pathlib import Path
22
22
  from typing import Annotated
23
23
 
24
24
  from fastapi import FastAPI, File, Form, HTTPException, UploadFile
25
- from fastapi.responses import Response, StreamingResponse
25
+ from fastapi.responses import FileResponse, Response, StreamingResponse
26
26
  from fastapi.staticfiles import StaticFiles
27
27
  from starlette.background import BackgroundTask
28
28
 
29
29
  from muscriptor.events import NoteEndEvent, NoteStartEvent, ProgressEvent
30
+ from muscriptor.soundfonts import SF3_URL
30
31
  from muscriptor.tokenizer.mt3 import MT3_FULL_PLUS_GROUP_NAMES
31
32
  from muscriptor.transcription_model import TranscriptionModel
32
33
  from muscriptor.utils.audio import _read_non_wav_file, _read_wav_file
34
+ from muscriptor.utils.download import download_if_necessary
33
35
 
34
36
 
35
37
  def _make_release_once(lock: threading.Lock):
@@ -85,6 +87,16 @@ def create_app(model: TranscriptionModel, web_dir: str | Path | None = None) ->
85
87
  async def list_instruments():
86
88
  return {"instruments": list(MT3_FULL_PLUS_GROUP_NAMES.keys())}
87
89
 
90
+ @app.get("/soundfonts/MuseScore_General.sf3")
91
+ async def soundfont() -> FileResponse:
92
+ """Compressed soundfont for the web UI's in-browser synthesizer.
93
+
94
+ Fetched from SF3_URL on first request (in a worker thread, so the
95
+ event loop keeps serving) and cached locally.
96
+ """
97
+ path = await asyncio.to_thread(download_if_necessary, SF3_URL)
98
+ return FileResponse(path, media_type="application/octet-stream")
99
+
88
100
  @app.post("/transcribe")
89
101
  async def transcribe(
90
102
  file: Annotated[UploadFile, File()],
@@ -0,0 +1,19 @@
1
+ """Runtime sources for the MuseScore General soundfonts.
2
+
3
+ Neither file ships in the package: both are fetched lazily through
4
+ :func:`muscriptor.utils.download.download_if_necessary` and cached locally
5
+ (``hf://`` URLs land in the HuggingFace hub cache). The upstream origin is
6
+ MuseScore's distribution mirror,
7
+ https://ftp.osuosl.org/pub/musescore/soundfont/MuseScore_General/, re-hosted
8
+ at https://huggingface.co/MuScriptor/assets (MIT license).
9
+ """
10
+
11
+ # Full soundfont (215 MB), rendered server-side by fluidsynth — the
12
+ # /auralize endpoint and the CLI's --auralize flag.
13
+ # SHA-256: ee51d2c4b1525e70f19a45909c4fd7a2e26d91d115fa89dbf5a6bc413d8b9bf3
14
+ SF2_URL = "hf://MuScriptor/assets/MuseScore_General.sf2"
15
+
16
+ # Vorbis-compressed build (38 MB) of the same soundfont, served to the web
17
+ # UI's in-browser spessasynth_lib synthesizer (GET /soundfonts/…).
18
+ # SHA-256: 5b85b6c2c61d10b2b91cddd41efcce7b25cd31c8271d511c73afafbef20b6fa3
19
+ SF3_URL = "hf://MuScriptor/assets/MuseScore_General.sf3"
@@ -16,9 +16,14 @@ from pathlib import Path
16
16
  import numpy as np
17
17
  import soundfile as sf
18
18
 
19
+ from muscriptor.soundfonts import SF2_URL
19
20
  from muscriptor.utils.audio import load_audio
21
+ from muscriptor.utils.download import download_if_necessary
20
22
 
21
- _DEFAULT_SOUNDFONT = Path(__file__).parent.parent.parent / "MuseScore_General.sf2"
23
+ # Pre-downloaded copy at the repo root (kept for checkouts and Docker images
24
+ # that already have one); absent that, the soundfont is fetched from SF2_URL
25
+ # and cached under ~/.cache/muscriptor/.
26
+ _LOCAL_SOUNDFONT = Path(__file__).parent.parent.parent / "MuseScore_General.sf2"
22
27
  _SAMPLE_RATE = 44100
23
28
 
24
29
 
@@ -29,12 +34,16 @@ def _load_mono_44k(path: Path) -> np.ndarray:
29
34
 
30
35
 
31
36
  def _resolve_soundfont(soundfont_path: str | Path | None) -> Path:
32
- soundfont_path = Path(soundfont_path) if soundfont_path else _DEFAULT_SOUNDFONT
37
+ if soundfont_path is None:
38
+ if _LOCAL_SOUNDFONT.exists():
39
+ return _LOCAL_SOUNDFONT
40
+ return download_if_necessary(SF2_URL)
41
+ soundfont_path = Path(soundfont_path)
33
42
  if not soundfont_path.exists():
34
43
  raise FileNotFoundError(
35
44
  f"SoundFont not found: {soundfont_path}\n"
36
- "Pass --soundfont /path/to/file.sf2 or ensure the bundled "
37
- "MuseScore_General.sf2 is present in the muscriptor project root."
45
+ "Pass --soundfont /path/to/file.sf2, or omit it to use "
46
+ "MuseScore_General.sf2 (downloaded once and cached)."
38
47
  )
39
48
  return soundfont_path
40
49
 
@@ -85,8 +94,9 @@ def synthesize(
85
94
  Args:
86
95
  midi_path: Path to the MIDI file to synthesize.
87
96
  output_path: Destination WAV file path.
88
- soundfont_path: Path to a ``.sf2`` SoundFont file.
89
- Defaults to the bundled MuseScore_General.sf2.
97
+ soundfont_path: Path to a ``.sf2`` SoundFont file. Defaults to
98
+ MuseScore_General.sf2, downloaded on first use and cached
99
+ locally (see :mod:`muscriptor.soundfonts`).
90
100
 
91
101
  Raises:
92
102
  RuntimeError: If fluidsynth is not available or fails.
@@ -112,8 +122,9 @@ def auralize(
112
122
  midi_path: Path to the MIDI file to synthesize.
113
123
  original_audio_path: Path to the source audio file (any format soundfile supports).
114
124
  output_path: Destination WAV file path.
115
- soundfont_path: Path to a ``.sf2`` SoundFont file.
116
- Defaults to the bundled MuseScore_General.sf2.
125
+ soundfont_path: Path to a ``.sf2`` SoundFont file. Defaults to
126
+ MuseScore_General.sf2, downloaded on first use and cached
127
+ locally (see :mod:`muscriptor.soundfonts`).
117
128
 
118
129
  Raises:
119
130
  RuntimeError: If fluidsynth is not available or fails.
@@ -1,6 +1,7 @@
1
1
  """Weight download utility with caching in ~/.cache/muscriptor/."""
2
2
 
3
3
  import hashlib
4
+ import os
4
5
  import urllib.request
5
6
  from pathlib import Path
6
7
  from huggingface_hub import hf_hub_download
@@ -40,7 +41,15 @@ def download_if_necessary(url: str | Path) -> Path:
40
41
  if dest.exists():
41
42
  return dest
42
43
  print(f"Downloading {filename} …")
43
- urllib.request.urlretrieve(url, dest)
44
+ # Download to a per-process temp file, then rename: an interrupted or
45
+ # concurrent download must never leave a partial file at `dest`, where
46
+ # it would be mistaken for a complete one forever after.
47
+ tmp = dest.with_name(f"{dest.name}.part{os.getpid()}")
48
+ try:
49
+ urllib.request.urlretrieve(url, tmp)
50
+ os.replace(tmp, dest)
51
+ finally:
52
+ tmp.unlink(missing_ok=True)
44
53
  return dest
45
54
 
46
55
  # Local file — nothing to download, just check it's there.