muscriptor 0.2.2__tar.gz → 0.3.0__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 (115) hide show
  1. {muscriptor-0.2.2 → muscriptor-0.3.0}/.gitignore +2 -0
  2. {muscriptor-0.2.2 → muscriptor-0.3.0}/PKG-INFO +8 -1
  3. {muscriptor-0.2.2 → muscriptor-0.3.0}/README.md +6 -0
  4. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/accelerator.py +9 -1
  5. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/main.py +33 -5
  6. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/models/lm.py +1 -1
  7. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/server.py +153 -69
  8. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/tokenizer/notes.py +23 -1
  9. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/transcription_model.py +52 -10
  10. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/auralization.py +23 -11
  11. muscriptor-0.3.0/muscriptor/utils/beats.py +367 -0
  12. muscriptor-0.3.0/muscriptor/utils/midi.py +55 -0
  13. muscriptor-0.3.0/muscriptor/web_dist/assets/index-ClK01VrS.js +223 -0
  14. muscriptor-0.3.0/muscriptor/web_dist/assets/index-DfGntwya.css +2 -0
  15. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/index.html +2 -2
  16. {muscriptor-0.2.2/web/public → muscriptor-0.3.0/muscriptor/web_dist}/muscriptor-mark-v4.svg +3 -0
  17. {muscriptor-0.2.2 → muscriptor-0.3.0}/pyproject.toml +6 -1
  18. {muscriptor-0.2.2 → muscriptor-0.3.0}/swarm.yml +4 -3
  19. muscriptor-0.3.0/tests/test_beats.py +243 -0
  20. muscriptor-0.3.0/tests/test_midi.py +174 -0
  21. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_server.py +163 -3
  22. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_transcription_model.py +28 -0
  23. {muscriptor-0.2.2 → muscriptor-0.3.0}/uv.lock +1619 -13
  24. {muscriptor-0.2.2/muscriptor/web_dist → muscriptor-0.3.0/web/public}/muscriptor-mark-v4.svg +3 -0
  25. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/App.tsx +97 -30
  26. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/audio.ts +26 -0
  27. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/DropOverlay.tsx +1 -8
  28. muscriptor-0.3.0/web/src/components/Faq.tsx +143 -0
  29. muscriptor-0.3.0/web/src/components/FeedbackLine.tsx +68 -0
  30. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/Footer.tsx +27 -18
  31. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/InstrumentList.tsx +1 -1
  32. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/WelcomeScreen.tsx +56 -18
  33. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/hooks/useTranscription.ts +60 -13
  34. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/pianoroll.ts +273 -37
  35. muscriptor-0.3.0/web/src/sse.ts +138 -0
  36. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/style.css +7 -0
  37. muscriptor-0.2.2/muscriptor/utils/midi.py +0 -41
  38. muscriptor-0.2.2/muscriptor/web_dist/assets/index-Cc_b-ZGX.js +0 -223
  39. muscriptor-0.2.2/muscriptor/web_dist/assets/index-Dsp-ORJh.css +0 -2
  40. muscriptor-0.2.2/tests/test_midi.py +0 -63
  41. muscriptor-0.2.2/web/src/sse.ts +0 -68
  42. {muscriptor-0.2.2 → muscriptor-0.3.0}/.dockerignore +0 -0
  43. {muscriptor-0.2.2 → muscriptor-0.3.0}/.github/workflows/deploy.yml +0 -0
  44. {muscriptor-0.2.2 → muscriptor-0.3.0}/.github/workflows/id_ed25519.pub +0 -0
  45. {muscriptor-0.2.2 → muscriptor-0.3.0}/.github/workflows/pypi.yml +0 -0
  46. {muscriptor-0.2.2 → muscriptor-0.3.0}/.pre-commit-config.yaml +0 -0
  47. {muscriptor-0.2.2 → muscriptor-0.3.0}/.python-version +0 -0
  48. {muscriptor-0.2.2 → muscriptor-0.3.0}/Dockerfile +0 -0
  49. {muscriptor-0.2.2 → muscriptor-0.3.0}/LICENSE +0 -0
  50. {muscriptor-0.2.2 → muscriptor-0.3.0}/deploy.sh +0 -0
  51. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/__init__.py +0 -0
  52. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/__main__.py +0 -0
  53. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/events.py +0 -0
  54. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/models/__init__.py +0 -0
  55. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/__init__.py +0 -0
  56. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/conditioners.py +0 -0
  57. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/mel_spectrogram.py +0 -0
  58. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/streaming.py +0 -0
  59. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/transformer.py +0 -0
  60. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/soundfonts.py +0 -0
  61. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/tokenizer/__init__.py +0 -0
  62. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/tokenizer/mt3.py +0 -0
  63. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/__init__.py +0 -0
  64. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/audio.py +0 -0
  65. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/download.py +0 -0
  66. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/resample.py +0 -0
  67. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/sampling.py +0 -0
  68. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/assets/spessasynth_processor.min-tdOdhV7p.js +0 -0
  69. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/favicon.png +0 -0
  70. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/headache_by_lost_deposit_1min.mp3 +0 -0
  71. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/kyutai-logo.svg +0 -0
  72. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/mirelo-logo.svg +0 -0
  73. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/muscriptor-logo-v2.png +0 -0
  74. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/muscriptor-logo-v4.svg +0 -0
  75. {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/muscriptor-wave.svg +0 -0
  76. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/__init__.py +0 -0
  77. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/conftest.py +0 -0
  78. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/encode_helpers.py +0 -0
  79. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_cli.py +0 -0
  80. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_conditioners.py +0 -0
  81. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_download.py +0 -0
  82. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_events.py +0 -0
  83. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_integration.py +0 -0
  84. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_notes.py +0 -0
  85. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_prelude_forcing.py +0 -0
  86. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_sampling.py +0 -0
  87. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_strict_instruments.py +0 -0
  88. {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_transformer.py +0 -0
  89. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/index.html +0 -0
  90. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/logo_muscriptor_final.png +0 -0
  91. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/package-lock.json +0 -0
  92. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/package.json +0 -0
  93. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/pnpm-lock.yaml +0 -0
  94. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/favicon.png +0 -0
  95. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/headache_by_lost_deposit_1min.mp3 +0 -0
  96. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/kyutai-logo.svg +0 -0
  97. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/mirelo-logo.svg +0 -0
  98. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/muscriptor-logo-v2.png +0 -0
  99. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/muscriptor-logo-v4.svg +0 -0
  100. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/muscriptor-wave.svg +0 -0
  101. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/analytics.ts +0 -0
  102. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/Button.tsx +0 -0
  103. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/ConditioningPanel.tsx +0 -0
  104. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/ConsentBanner.tsx +0 -0
  105. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/Controls.tsx +0 -0
  106. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/OutputBar.tsx +0 -0
  107. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/PianoRollCanvas.tsx +0 -0
  108. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/icons.tsx +0 -0
  109. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/hooks/useAudioEngine.ts +0 -0
  110. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/instruments.ts +0 -0
  111. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/main.tsx +0 -0
  112. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/progress.ts +0 -0
  113. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/vite-env.d.ts +0 -0
  114. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/tsconfig.json +0 -0
  115. {muscriptor-0.2.2 → muscriptor-0.3.0}/web/vite.config.ts +0 -0
@@ -14,3 +14,5 @@ web/tsconfig.tsbuildinfo
14
14
  *.egg-info
15
15
  __pycache__
16
16
  .DS_Store
17
+ .claude/settings.local.json
18
+ .claude/worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muscriptor
3
- Version: 0.2.2
3
+ Version: 0.3.0
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
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
20
20
  Requires-Python: >=3.10
21
+ Requires-Dist: beat-this>=1.1
21
22
  Requires-Dist: einops>=0.4
22
23
  Requires-Dist: fastapi>=0.136.3
23
24
  Requires-Dist: httpx>=0.28.1
@@ -268,6 +269,12 @@ class TranscriptionModel:
268
269
  plus a tiny duration. Note: this tokenizer does not preserve
269
270
  velocity (loudness) — only onset/offset timing, pitch, and
270
271
  instrument are recovered.
272
+
273
+ Event times may all carry the same small lag (up to ~25 ms).
274
+ Correcting it needs the beat grid and the whole transcription's
275
+ onsets, which are only available once the stream has finished, so
276
+ prefer `transcribe_to_midi` when precise note timing matters — it
277
+ measures the lag and takes it out.
271
278
  """
272
279
 
273
280
  def transcribe_to_midi(
@@ -231,6 +231,12 @@ class TranscriptionModel:
231
231
  plus a tiny duration. Note: this tokenizer does not preserve
232
232
  velocity (loudness) — only onset/offset timing, pitch, and
233
233
  instrument are recovered.
234
+
235
+ Event times may all carry the same small lag (up to ~25 ms).
236
+ Correcting it needs the beat grid and the whole transcription's
237
+ onsets, which are only available once the stream has finished, so
238
+ prefer `transcribe_to_midi` when precise note timing matters — it
239
+ measures the lag and takes it out.
234
240
  """
235
241
 
236
242
  def transcribe_to_midi(
@@ -53,7 +53,15 @@ def synchronize() -> None:
53
53
  No-op if no accelerator is available.
54
54
  """
55
55
  if _HAS_TORCH_ACCELERATOR:
56
- torch.accelerator.synchronize()
56
+ # torch.accelerator.synchronize() still tries to init CUDA even on CPU-only systems
57
+ # Only call it if we actually have a non-CPU accelerator
58
+ try:
59
+ current_device = torch.accelerator.current_device_index()
60
+ if current_device >= 0:
61
+ torch.accelerator.synchronize()
62
+ except RuntimeError:
63
+ # No accelerator available (CUDA not found, etc.)
64
+ pass
57
65
  return
58
66
  if torch.cuda.is_available():
59
67
  torch.cuda.synchronize()
@@ -5,7 +5,7 @@ import json
5
5
  import sys
6
6
  from enum import Enum
7
7
  from pathlib import Path
8
- from typing import Annotated
8
+ from typing import Annotated, Literal
9
9
 
10
10
  import typer
11
11
 
@@ -15,6 +15,7 @@ from muscriptor.tokenizer.mt3 import (
15
15
  resolve_instrument_names,
16
16
  )
17
17
  from muscriptor.transcription_model import TranscriptionModel
18
+ from muscriptor.utils.beats import BeatDetectionError, TempoDetection
18
19
  from muscriptor.utils.download import ModelDownloadError
19
20
 
20
21
  app = typer.Typer(add_completion=False, help="muscriptor — audio-to-MIDI transcription")
@@ -196,6 +197,19 @@ def transcribe(
196
197
  ),
197
198
  ),
198
199
  ] = None,
200
+ # typer can't take the bool | Literal["best-effort"] union the API uses, so
201
+ # the CLI spells all three states as strings and converts below.
202
+ detect_tempo: Annotated[
203
+ Literal["true", "false", "best-effort"],
204
+ typer.Option(
205
+ help=(
206
+ "Detect the tempo and time signature from the audio and write "
207
+ "them into the MIDI. 'true' fails if no steady tempo is found, "
208
+ "'best-effort' warns and uses a placeholder 120 BPM with no "
209
+ "time signature, 'false' skips detection altogether."
210
+ ),
211
+ ),
212
+ ] = "best-effort",
199
213
  ) -> None:
200
214
  """Transcribe an audio file to MIDI."""
201
215
  instrument_names: list[str] | None = None
@@ -261,7 +275,19 @@ def transcribe(
261
275
  )
262
276
 
263
277
  if format == OutputFormat.midi:
264
- midi_bytes = model.transcribe_to_midi(**kwargs)
278
+ try:
279
+ mode: TempoDetection = {
280
+ "true": True,
281
+ "false": False,
282
+ "best-effort": "best-effort",
283
+ }[detect_tempo]
284
+ midi_bytes = model.transcribe_to_midi(**kwargs, detect_tempo=mode)
285
+ except BeatDetectionError as e:
286
+ typer.echo(f"Error: {e}", err=True)
287
+ typer.echo(
288
+ "Pass --detect-tempo best-effort or false to continue.", err=True
289
+ )
290
+ raise typer.Exit(1)
265
291
  if is_stdout:
266
292
  sys.stdout.buffer.write(midi_bytes)
267
293
  sys.stdout.buffer.flush()
@@ -307,9 +333,7 @@ def transcribe(
307
333
  typer.echo(f"Saved JSONL to {output}", err=True)
308
334
  else: # json
309
335
  events = [
310
- e
311
- for e in model.transcribe(**kwargs)
312
- if not isinstance(e, ProgressEvent)
336
+ e for e in model.transcribe(**kwargs) if not isinstance(e, ProgressEvent)
313
337
  ]
314
338
  payload = json.dumps([_event_to_dict(e) for e in events], indent=2)
315
339
  if is_stdout:
@@ -356,10 +380,14 @@ def serve(
356
380
  ] = None,
357
381
  ):
358
382
  """Run the HTTP transcription server (POST /transcribe → SSE event stream)."""
383
+ import logging
384
+
359
385
  import uvicorn
360
386
 
361
387
  from muscriptor.server import create_app
362
388
 
389
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
390
+
363
391
  _device = None if device == "auto" else device
364
392
  typer.echo("Loading model…")
365
393
  model = _load_model(model_path, _device, dtype)
@@ -257,7 +257,7 @@ class LMModel(nn.Module):
257
257
  # Generation
258
258
  # ------------------------------------------------------------------
259
259
 
260
- @torch.no_grad()
260
+ @torch.inference_mode()
261
261
  def generate(
262
262
  self,
263
263
  prompt: torch.Tensor | None = None,
@@ -4,8 +4,12 @@ POST /transcribe with an audio file (multipart/form-data field `file`; WAV,
4
4
  or any format soundfile/libsndfile can read — mp3, flac, ogg, m4a, …) returns
5
5
  `text/event-stream`. Each event's data is a JSON dict tagged by `type`:
6
6
  `start` / `end` note events (same shape as `muscriptor.main._event_to_dict`),
7
- `progress` chunk anchors (`{completed, total}`), and a final `midi` event
8
- carrying the base64-encoded .mid file.
7
+ `progress` chunk anchors (`{completed, total}`), and a final
8
+ `transcription_complete` event carrying the base64-encoded .mid file (`data`)
9
+ plus the detected `beat_grid`
10
+ (`{bpm, beats_per_bar, first_downbeat, onset_delay}`, or null if no tempo was
11
+ found). `onset_delay` is how late the streamed note times are against those
12
+ beats: the MIDI has it taken out already, an SSE consumer has to subtract it.
9
13
 
10
14
  POST /transcribe/midi takes the same upload but blocks until transcription
11
15
  completes and returns the raw `audio/midi` bytes directly (no SSE, no
@@ -24,9 +28,9 @@ import threading
24
28
  import time
25
29
  import wave
26
30
  from pathlib import Path
27
- from typing import Annotated
31
+ from typing import Annotated, Callable
28
32
 
29
- from fastapi import FastAPI, File, Form, HTTPException, UploadFile
33
+ from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
30
34
  from fastapi.responses import FileResponse, Response, StreamingResponse
31
35
  from fastapi.staticfiles import StaticFiles
32
36
  from starlette.background import BackgroundTask
@@ -36,6 +40,7 @@ from muscriptor.soundfonts import SF3_URL
36
40
  from muscriptor.tokenizer.mt3 import MT3_FULL_PLUS_GROUP_NAMES
37
41
  from muscriptor.transcription_model import TranscriptionModel
38
42
  from muscriptor.utils.audio import _read_non_wav_file, _read_wav_file
43
+ from muscriptor.utils.beats import BeatDetectionError, TempoDetection
39
44
  from muscriptor.utils.download import download_if_necessary
40
45
 
41
46
 
@@ -77,15 +82,85 @@ def create_app(model: TranscriptionModel, web_dir: str | Path | None = None) ->
77
82
  app = FastAPI(title="muscriptor")
78
83
 
79
84
  transcribe_lock = threading.Lock()
80
- lock_timeout_s = 60.0
81
- # Cancel event of the run currently holding the lock (or the last one to
82
- # have held it). A new /transcribe request sets it so an in-flight run
83
- # stops at its next event boundary instead of transcribing to completion
84
- # for a client that has moved on. This must not rely on TCP disconnects:
85
- # aborts don't always reach us (e.g. port forwards / proxies that keep the
86
- # upstream connection open after the browser aborts).
85
+ # State of the run currently holding the lock (or the last one to have held
86
+ # it), guarded by `cancel_guard`: its cancel event and the id of the client
87
+ # that started it. A new /transcribe from the SAME client (a resubmit in
88
+ # the same browser tab) sets the cancel event so the in-flight run stops at
89
+ # its next event boundary instead of transcribing to completion for a client
90
+ # that has moved on. A request from a DIFFERENT client never preempts — it
91
+ # waits for the lock like any other contender, so two independent browser
92
+ # windows don't kill each other's transcription. This scoping must be done
93
+ # here rather than by watching the socket: browser aborts don't always reach
94
+ # us (e.g. port forwards / proxies keep the upstream connection open after
95
+ # the browser aborts), so a same-tab resubmit can't be detected as a
96
+ # disconnect.
87
97
  cancel_guard = threading.Lock()
88
98
  current_cancel: threading.Event | None = None
99
+ current_client: str | None = None
100
+ # Bound on how long a same-client resubmit waits for the run it just
101
+ # cancelled to unwind and release the lock. That run stops within one
102
+ # chunk boundary, so this only needs to cover that latency — unlike
103
+ # genuine cross-client contention, which now fails instantly instead of
104
+ # sitting on the connection (see below).
105
+ preempt_wait_s = 5.0
106
+
107
+ async def acquire_transcribe_lock(
108
+ client_id: str | None, cancellable: bool
109
+ ) -> tuple[threading.Event | None, Callable[[], None]]:
110
+ """Acquire the single-transcription lock, preempting only a run started
111
+ by this same `client_id` (a resubmit): that run is signalled to stop
112
+ and this call waits up to `preempt_wait_s` for it to release the lock.
113
+ A different client — or an anonymous API caller with no id — never
114
+ preempts and is never preempted; it gets an immediate 503 instead of
115
+ waiting, so a caller retrying against another machine (e.g. behind a
116
+ load balancer like Traefik) doesn't have to hold the connection open.
117
+
118
+ Returns `(cancel, release)`: `cancel` is the new run's cancel event
119
+ (`None` when `cancellable` is False, e.g. the blocking /transcribe/midi
120
+ render, which can't be stopped mid-flight); `release` frees the lock at
121
+ most once, from whichever cleanup path runs first.
122
+ """
123
+ nonlocal current_cancel, current_client
124
+ deadline: float | None = None
125
+ while True:
126
+ with cancel_guard:
127
+ # Re-check each iteration so that even a same-client run which
128
+ # became "current" while we were already waiting (a resubmit
129
+ # that beat us to the lock) gets cancelled too — the newest
130
+ # request from a given client always wins.
131
+ preempting = (
132
+ current_cancel is not None
133
+ and client_id is not None
134
+ and current_client == client_id
135
+ )
136
+ if preempting:
137
+ current_cancel.set()
138
+ if not preempting:
139
+ acquired = await asyncio.to_thread(transcribe_lock.acquire, False)
140
+ if not acquired:
141
+ raise HTTPException(
142
+ status_code=503,
143
+ detail="server busy: another transcription is in progress",
144
+ )
145
+ break
146
+ if deadline is None:
147
+ deadline = time.monotonic() + preempt_wait_s
148
+ remaining = deadline - time.monotonic()
149
+ if remaining <= 0:
150
+ raise HTTPException(
151
+ status_code=503,
152
+ detail="server busy: another transcription is in progress",
153
+ )
154
+ acquired = await asyncio.to_thread(
155
+ transcribe_lock.acquire, True, min(0.1, remaining)
156
+ )
157
+ if acquired:
158
+ break
159
+ cancel = threading.Event() if cancellable else None
160
+ with cancel_guard:
161
+ current_cancel = cancel
162
+ current_client = client_id
163
+ return cancel, _make_release_once(transcribe_lock)
89
164
 
90
165
  @app.get("/health")
91
166
  async def health():
@@ -109,6 +184,9 @@ def create_app(model: TranscriptionModel, web_dir: str | Path | None = None) ->
109
184
  async def transcribe(
110
185
  file: Annotated[UploadFile, File()],
111
186
  instruments: Annotated[list[str], Form(default_factory=list)],
187
+ # "true" fails loudly on tempo detection errors, "false" doesn't even try
188
+ detect_tempo: Annotated[TempoDetection, Form()] = "best-effort",
189
+ x_client_id: Annotated[str | None, Header()] = None,
112
190
  ) -> StreamingResponse:
113
191
  data = await file.read()
114
192
  # PCM WAV goes through the stdlib reader (keeps WAV decoding byte-for-byte
@@ -135,37 +213,16 @@ def create_app(model: TranscriptionModel, web_dir: str | Path | None = None) ->
135
213
  detail=f"unknown instrument name(s): {', '.join(unknown)}",
136
214
  )
137
215
 
138
- # Preempt whoever holds the lock, then wait for it. The short acquire
139
- # timeout re-signals each second so that even a run that started while
140
- # we were already waiting (another preempting request that beat us to
141
- # the lock) gets cancelled too — the newest request always wins.
142
- nonlocal current_cancel
143
- deadline = time.monotonic() + lock_timeout_s
144
- while True:
145
- with cancel_guard:
146
- if current_cancel is not None:
147
- current_cancel.set()
148
- remaining = deadline - time.monotonic()
149
- if remaining <= 0:
150
- raise HTTPException(
151
- status_code=503,
152
- detail="server busy: another transcription is in progress",
153
- )
154
- acquired = await asyncio.to_thread(
155
- transcribe_lock.acquire, True, min(1.0, remaining)
156
- )
157
- if acquired:
158
- break
159
- cancel = threading.Event()
160
- with cancel_guard:
161
- current_cancel = cancel
162
-
163
- # Release exactly once, from whichever path runs first. The generator's
164
- # finally covers normal completion, errors and mid-stream disconnects;
165
- # the StreamingResponse background task covers the case where the client
166
- # disconnects *before* the generator is ever iterated (so its finally
167
- # would never run) — which otherwise leaks the lock forever.
168
- release_lock = _make_release_once(transcribe_lock)
216
+ # Acquire the single-transcription lock, preempting only a resubmit from
217
+ # this same client (see acquire_transcribe_lock). `release_lock` runs
218
+ # from whichever cleanup path fires first: the generator's finally
219
+ # (normal completion, errors, mid-stream disconnects) or the
220
+ # StreamingResponse background task (client disconnects before the
221
+ # generator is ever iterated, so its finally would never run) — either
222
+ # way the lock is released exactly once and never leaked.
223
+ cancel, release_lock = await acquire_transcribe_lock(
224
+ x_client_id, cancellable=True
225
+ )
169
226
 
170
227
  def gen():
171
228
  try:
@@ -205,9 +262,43 @@ def create_app(model: TranscriptionModel, web_dir: str | Path | None = None) ->
205
262
  # with the bytes base64-encoded.
206
263
  if cancel.is_set():
207
264
  return
208
- midi_bytes = model.events_to_midi_bytes(iter(events))
265
+ # Detect tempo/meter only now: it costs a few seconds of CPU and
266
+ # nothing before this point needs it, so the notes stream first.
267
+ grid = model.detect_beat_grid_for((wav, sr), detect_tempo)
268
+ # Measure the onset lag up here rather than leaving it to the MIDI
269
+ # writing, since the UI has to be told the very same number to move
270
+ # the notes it already drew.
271
+ if grid:
272
+ grid = grid.with_onset_delay(
273
+ [
274
+ ev.start_time
275
+ for ev in events
276
+ if isinstance(ev, NoteStartEvent)
277
+ ]
278
+ )
279
+ midi_bytes = model.events_to_midi_bytes(iter(events), beat_grid=grid)
209
280
  midi_b64 = base64.b64encode(midi_bytes).decode("ascii")
210
- payload = json.dumps({"type": "midi", "data": midi_b64})
281
+ # The grid rides along so the UI can draw bar lines instead of a
282
+ # fixed seconds grid; null when no tempo was detected.
283
+ payload = json.dumps(
284
+ {
285
+ "type": "transcription_complete",
286
+ "data": midi_b64,
287
+ # Only the fields the UI draws with; `grid.beats` is an
288
+ # ndarray and not JSON-serializable anyway.
289
+ "beat_grid": {
290
+ "bpm": grid.bpm,
291
+ "beats_per_bar": grid.beats_per_bar,
292
+ "first_downbeat": grid.first_downbeat,
293
+ # Seconds the streamed note times sit late against
294
+ # the beats; the MIDI already has it taken out, the
295
+ # UI has to subtract it from the notes it drew.
296
+ "onset_delay": grid.onset_delay,
297
+ }
298
+ if grid
299
+ else None,
300
+ }
301
+ )
211
302
  yield f"data: {payload}\n\n"
212
303
  finally:
213
304
  release_lock()
@@ -223,6 +314,9 @@ def create_app(model: TranscriptionModel, web_dir: str | Path | None = None) ->
223
314
  async def transcribe_midi(
224
315
  file: Annotated[UploadFile, File()],
225
316
  instruments: Annotated[list[str], Form(default_factory=list)],
317
+ # "true" fails loudly on tempo detection errors, "false" doesn't even try
318
+ detect_tempo: Annotated[TempoDetection, Form()] = "best-effort",
319
+ x_client_id: Annotated[str | None, Header()] = None,
226
320
  ) -> Response:
227
321
  """Transcribe an audio file and return the .mid file directly.
228
322
 
@@ -260,38 +354,28 @@ def create_app(model: TranscriptionModel, web_dir: str | Path | None = None) ->
260
354
  detail=f"unknown instrument name(s): {', '.join(unknown)}",
261
355
  )
262
356
 
263
- # Same mutual-exclusion as /transcribe (the model isn't safe to run
264
- # concurrently), but no preemption: this call doesn't stream, so
265
- # there's nothing to cancel mid-flight. A concurrent /transcribe
266
- # request will just wait for the lock like any other contender.
267
- nonlocal current_cancel
268
- deadline = time.monotonic() + lock_timeout_s
269
- while True:
270
- with cancel_guard:
271
- if current_cancel is not None:
272
- current_cancel.set()
273
- remaining = deadline - time.monotonic()
274
- if remaining <= 0:
275
- raise HTTPException(
276
- status_code=503,
277
- detail="server busy: another transcription is in progress",
278
- )
279
- acquired = await asyncio.to_thread(
280
- transcribe_lock.acquire, True, min(1.0, remaining)
281
- )
282
- if acquired:
283
- break
284
- with cancel_guard:
285
- current_cancel = None
286
-
357
+ # Same mutual exclusion as /transcribe, via the shared helper. This run
358
+ # is not cancellable (it doesn't stream, so there's nothing to stop
359
+ # mid-flight); cancellable=False records that, so nothing tries to
360
+ # preempt a blocking MIDI render. A non-browser API caller sends no
361
+ # client id and so neither preempts nor is preempted — it just waits for
362
+ # the lock like any other contender.
363
+ _cancel, release_lock = await acquire_transcribe_lock(
364
+ x_client_id, cancellable=False
365
+ )
287
366
  try:
288
367
  midi_bytes = await asyncio.to_thread(
289
368
  model.transcribe_to_midi,
290
369
  (wav, sr),
291
370
  instruments=instruments or None,
371
+ detect_tempo=detect_tempo,
292
372
  )
373
+ except BeatDetectionError as e:
374
+ # Only reachable with detect_tempo=true, where the caller wants an error
375
+ # if tempo detection fails.
376
+ raise HTTPException(status_code=422, detail=str(e)) from e
293
377
  finally:
294
- transcribe_lock.release()
378
+ release_lock()
295
379
 
296
380
  return Response(
297
381
  content=midi_bytes,
@@ -9,6 +9,8 @@ from dataclasses import dataclass
9
9
 
10
10
  from mido import Message, MetaMessage, MidiFile, MidiTrack, second2tick
11
11
 
12
+ from muscriptor.utils.beats import BAR_OFFSET_MARKER
13
+
12
14
  DRUM_PROGRAM = 128
13
15
  MINIMUM_NOTE_DURATION_SEC = 0.01
14
16
 
@@ -263,6 +265,8 @@ def note_event2midi(
263
265
  ticks_per_beat: int = 480,
264
266
  tempo: int = 500000,
265
267
  program_names: dict[int, str] | None = None,
268
+ beats_per_bar: int | None = None,
269
+ offset_s: float = 0.0,
266
270
  ) -> MidiFile:
267
271
  """Convert NoteEvent list to a type-1 (multi-track) MIDI file.
268
272
 
@@ -274,10 +278,25 @@ def note_event2midi(
274
278
 
275
279
  `program_names` maps a program number (DRUM_PROGRAM for drums) to the
276
280
  track name; unmapped programs fall back to "program <n>" / "drums".
281
+
282
+ `beats_per_bar` writes a time signature (denominator 4).
283
+ `offset_s` delays every event so bar lines land on real downbeats.
277
284
  """
278
285
  midi = MidiFile(ticks_per_beat=ticks_per_beat, type=1)
279
286
  meta_track = MidiTrack()
280
287
  meta_track.append(MetaMessage("set_tempo", tempo=tempo, time=0))
288
+ if beats_per_bar is not None:
289
+ meta_track.append(
290
+ MetaMessage(
291
+ "time_signature", numerator=beats_per_bar, denominator=4, time=0
292
+ )
293
+ )
294
+ if offset_s:
295
+ # Doesn't do anything for the MIDI but we just mark "we had to shift by this
296
+ # much to align the bars". Read by /auralize later
297
+ meta_track.append(
298
+ MetaMessage("marker", text=f"{BAR_OFFSET_MARKER}{offset_s:.4f}", time=0)
299
+ )
281
300
  midi.tracks.append(meta_track)
282
301
 
283
302
  drum_offset_events = []
@@ -302,7 +321,7 @@ def note_event2midi(
302
321
  track_ticks: dict[int, int] = {}
303
322
  current_tick = 0
304
323
  for ne in note_events:
305
- absolute_tick = round(second2tick(ne.time, ticks_per_beat, tempo))
324
+ absolute_tick = round(second2tick(ne.time + offset_s, ticks_per_beat, tempo))
306
325
  if absolute_tick < current_tick:
307
326
  raise ValueError(
308
327
  f"at ne.time {ne.time}, absolute_tick {absolute_tick} < current_tick {current_tick}"
@@ -328,6 +347,9 @@ def note_event2midi(
328
347
  gm_program = ne.program
329
348
  program_to_channel[key] = ne_channel
330
349
  track.append(MetaMessage("track_name", name=name, time=0))
350
+ # MuseScore ignores set_tempo in a conductor track that has no notes,
351
+ # so repeat it here. Harmless for hosts that read the meta track.
352
+ track.append(MetaMessage("set_tempo", tempo=tempo, time=0))
331
353
  track.append(
332
354
  Message(
333
355
  "program_change", program=gm_program, time=0, channel=ne_channel
@@ -14,7 +14,6 @@ from pathlib import Path
14
14
 
15
15
  import torch
16
16
  import torch.nn.functional as F
17
-
18
17
  from safetensors.torch import load_file
19
18
 
20
19
  import muscriptor.accelerator
@@ -27,12 +26,11 @@ from muscriptor.events import (
27
26
  decode_model_tokens,
28
27
  )
29
28
  from muscriptor.models.lm import LMModel, TorchAutocast
30
- from muscriptor.utils.download import download_companion, download_if_necessary
31
29
  from muscriptor.modules.conditioners import (
32
- MelSpectrogramConditioner,
33
30
  ClassConditioner,
34
- ConditioningProvider,
35
31
  ConditioningAttributes,
32
+ ConditioningProvider,
33
+ MelSpectrogramConditioner,
36
34
  WavCondition,
37
35
  )
38
36
  from muscriptor.tokenizer.mt3 import (
@@ -47,6 +45,13 @@ from muscriptor.tokenizer.notes import (
47
45
  validate_notes,
48
46
  )
49
47
  from muscriptor.utils.audio import load_audio, resample
48
+ from muscriptor.utils.beats import (
49
+ BeatDetectionError,
50
+ BeatGrid,
51
+ TempoDetection,
52
+ detect_grid,
53
+ )
54
+ from muscriptor.utils.download import download_companion, download_if_necessary
50
55
  from muscriptor.utils.midi import notes_to_midi
51
56
 
52
57
 
@@ -84,6 +89,7 @@ def _resolve_source(weights_path: str | Path | None) -> str | Path:
84
89
  return _HF_REPO_TEMPLATE.format(size=weights_path)
85
90
  return weights_path
86
91
 
92
+
87
93
  _SAMPLE_RATE = 16000
88
94
  # Must match the segment duration used during training / evaluation.
89
95
  _SEGMENT_DURATION = 5.0
@@ -360,6 +366,11 @@ class TranscriptionModel:
360
366
  ``prelude_forcing=False`` explicitly to trade chunk-boundary quality
361
367
  for batched throughput.
362
368
 
369
+ The event times may all carry the same small lag (up to ~25 ms) due to model
370
+ bias. Taking it out needs the beat grid and every onset in the transcription,
371
+ which only exist once the stream has finished, so prefer
372
+ :meth:`transcribe_to_midi` when precise note timing matters.
373
+
363
374
  Interleaved with the note events are coarse :class:`ProgressEvent`
364
375
  anchors (``completed`` of ``total`` chunks): one up front with
365
376
  ``completed == 0``, then one as each chunk finishes. Consumers that
@@ -451,9 +462,7 @@ class TranscriptionModel:
451
462
  file=sys.stderr,
452
463
  )
453
464
 
454
- def _resolve_batch_size(
455
- self, batch_size: int | None, prelude_forcing: bool
456
- ) -> int:
465
+ def _resolve_batch_size(self, batch_size: int | None, prelude_forcing: bool) -> int:
457
466
  """Default the batch size, favouring transcription quality.
458
467
 
459
468
  Prelude forcing needs chunks generated strictly in order, so while it
@@ -620,8 +629,10 @@ class TranscriptionModel:
620
629
  no_eos_is_ok: bool = True,
621
630
  beam_size: int = 1,
622
631
  prelude_forcing: bool = True,
632
+ detect_tempo: TempoDetection = "best-effort",
623
633
  ) -> bytes:
624
634
  """Same as :meth:`transcribe` but returns a MIDI file as bytes."""
635
+ beat_grid = self.detect_beat_grid_for(audio, detect_tempo)
625
636
  events = self.transcribe(
626
637
  audio,
627
638
  use_sampling=use_sampling,
@@ -633,10 +644,41 @@ class TranscriptionModel:
633
644
  beam_size=beam_size,
634
645
  prelude_forcing=prelude_forcing,
635
646
  )
636
- return self.events_to_midi_bytes(events)
647
+ return self.events_to_midi_bytes(events, beat_grid=beat_grid)
648
+
649
+ def detect_beat_grid_for(
650
+ self,
651
+ audio: str | Path | tuple[torch.Tensor, int],
652
+ mode: TempoDetection = "best-effort",
653
+ ) -> BeatGrid | None:
654
+ """Detect the beat grid of `audio`, or None if there isn't a usable one.
655
+
656
+ Accepts the same input forms as :meth:`transcribe`.
657
+ `mode` decides what a failed detection means: raise (True), skip detection
658
+ entirely (False), or warn and fall back to the placeholder tempo
659
+ ("best-effort").
660
+
661
+ The grid carries the tracked beat times along, so that writing the MIDI
662
+ can move the transcription's onsets onto them (see BeatGrid.onset_delay).
663
+ """
664
+ if mode is False:
665
+ return None
666
+ tensor, sample_rate = audio if isinstance(audio, tuple) else (audio, None)
667
+ try:
668
+ return detect_grid(self._load_wav(tensor, sample_rate), _SAMPLE_RATE)
669
+ except BeatDetectionError as e:
670
+ if mode is True:
671
+ raise
672
+ print(
673
+ f"Warning: {e}; falling back to the placeholder tempo",
674
+ file=sys.stderr,
675
+ )
676
+ return None
637
677
 
638
678
  def events_to_midi_bytes(
639
- self, events: Iterator[NoteStartEvent | NoteEndEvent | ProgressEvent]
679
+ self,
680
+ events: Iterator[NoteStartEvent | NoteEndEvent | ProgressEvent],
681
+ beat_grid: BeatGrid | None = None,
640
682
  ) -> bytes:
641
683
  """Reassemble Notes from a NoteStart/NoteEnd stream and serialize MIDI.
642
684
 
@@ -674,7 +716,7 @@ class TranscriptionModel:
674
716
  # don't drift from earlier reference outputs.
675
717
  notes = validate_notes(notes, fix=True)
676
718
  notes = trim_overlapping_notes(notes, sort=True)
677
- midi = notes_to_midi(notes, program_names=program_names)
719
+ midi = notes_to_midi(notes, program_names=program_names, beat_grid=beat_grid)
678
720
  buf = io.BytesIO()
679
721
  midi.save(file=buf)
680
722
  return buf.getvalue()