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.
- {muscriptor-0.2.2 → muscriptor-0.3.0}/.gitignore +2 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/PKG-INFO +8 -1
- {muscriptor-0.2.2 → muscriptor-0.3.0}/README.md +6 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/accelerator.py +9 -1
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/main.py +33 -5
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/models/lm.py +1 -1
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/server.py +153 -69
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/tokenizer/notes.py +23 -1
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/transcription_model.py +52 -10
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/auralization.py +23 -11
- muscriptor-0.3.0/muscriptor/utils/beats.py +367 -0
- muscriptor-0.3.0/muscriptor/utils/midi.py +55 -0
- muscriptor-0.3.0/muscriptor/web_dist/assets/index-ClK01VrS.js +223 -0
- muscriptor-0.3.0/muscriptor/web_dist/assets/index-DfGntwya.css +2 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/index.html +2 -2
- {muscriptor-0.2.2/web/public → muscriptor-0.3.0/muscriptor/web_dist}/muscriptor-mark-v4.svg +3 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/pyproject.toml +6 -1
- {muscriptor-0.2.2 → muscriptor-0.3.0}/swarm.yml +4 -3
- muscriptor-0.3.0/tests/test_beats.py +243 -0
- muscriptor-0.3.0/tests/test_midi.py +174 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_server.py +163 -3
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_transcription_model.py +28 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/uv.lock +1619 -13
- {muscriptor-0.2.2/muscriptor/web_dist → muscriptor-0.3.0/web/public}/muscriptor-mark-v4.svg +3 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/App.tsx +97 -30
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/audio.ts +26 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/DropOverlay.tsx +1 -8
- muscriptor-0.3.0/web/src/components/Faq.tsx +143 -0
- muscriptor-0.3.0/web/src/components/FeedbackLine.tsx +68 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/Footer.tsx +27 -18
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/InstrumentList.tsx +1 -1
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/WelcomeScreen.tsx +56 -18
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/hooks/useTranscription.ts +60 -13
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/pianoroll.ts +273 -37
- muscriptor-0.3.0/web/src/sse.ts +138 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/style.css +7 -0
- muscriptor-0.2.2/muscriptor/utils/midi.py +0 -41
- muscriptor-0.2.2/muscriptor/web_dist/assets/index-Cc_b-ZGX.js +0 -223
- muscriptor-0.2.2/muscriptor/web_dist/assets/index-Dsp-ORJh.css +0 -2
- muscriptor-0.2.2/tests/test_midi.py +0 -63
- muscriptor-0.2.2/web/src/sse.ts +0 -68
- {muscriptor-0.2.2 → muscriptor-0.3.0}/.dockerignore +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/.github/workflows/deploy.yml +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/.github/workflows/id_ed25519.pub +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/.github/workflows/pypi.yml +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/.pre-commit-config.yaml +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/.python-version +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/Dockerfile +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/LICENSE +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/deploy.sh +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/__init__.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/__main__.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/events.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/models/__init__.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/__init__.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/conditioners.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/mel_spectrogram.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/streaming.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/modules/transformer.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/soundfonts.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/tokenizer/__init__.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/tokenizer/mt3.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/__init__.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/audio.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/download.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/resample.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/utils/sampling.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/assets/spessasynth_processor.min-tdOdhV7p.js +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/favicon.png +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/headache_by_lost_deposit_1min.mp3 +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/kyutai-logo.svg +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/mirelo-logo.svg +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/muscriptor-logo-v2.png +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/muscriptor-logo-v4.svg +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/muscriptor/web_dist/muscriptor-wave.svg +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/__init__.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/conftest.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/encode_helpers.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_cli.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_conditioners.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_download.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_events.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_integration.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_notes.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_prelude_forcing.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_sampling.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_strict_instruments.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/tests/test_transformer.py +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/index.html +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/logo_muscriptor_final.png +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/package-lock.json +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/package.json +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/pnpm-lock.yaml +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/favicon.png +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/headache_by_lost_deposit_1min.mp3 +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/kyutai-logo.svg +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/mirelo-logo.svg +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/muscriptor-logo-v2.png +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/muscriptor-logo-v4.svg +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/public/muscriptor-wave.svg +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/analytics.ts +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/Button.tsx +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/ConditioningPanel.tsx +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/ConsentBanner.tsx +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/Controls.tsx +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/OutputBar.tsx +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/PianoRollCanvas.tsx +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/components/icons.tsx +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/hooks/useAudioEngine.ts +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/instruments.ts +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/main.tsx +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/progress.ts +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/src/vite-env.d.ts +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/tsconfig.json +0 -0
- {muscriptor-0.2.2 → muscriptor-0.3.0}/web/vite.config.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: muscriptor
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
-
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
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
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
264
|
-
#
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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,
|
|
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()
|