muscriptor 0.1.0__py3-none-any.whl

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/server.py ADDED
@@ -0,0 +1,267 @@
1
+ """FastAPI server exposing transcription as an SSE event stream.
2
+
3
+ POST /transcribe with an audio file (multipart/form-data field `file`; WAV,
4
+ or any format soundfile/libsndfile can read — mp3, flac, ogg, m4a, …) returns
5
+ `text/event-stream`. Each event's data is a JSON dict tagged by `type`:
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.
9
+ """
10
+
11
+ import asyncio
12
+ import base64
13
+ import dataclasses
14
+ import io
15
+ import json
16
+ import os
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ import wave
21
+ from pathlib import Path
22
+ from typing import Annotated
23
+
24
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
25
+ from fastapi.responses import Response, StreamingResponse
26
+ from fastapi.staticfiles import StaticFiles
27
+ from starlette.background import BackgroundTask
28
+
29
+ from muscriptor.events import NoteEndEvent, NoteStartEvent, ProgressEvent
30
+ from muscriptor.tokenizer.mt3 import MT3_FULL_PLUS_GROUP_NAMES
31
+ from muscriptor.transcription_model import TranscriptionModel
32
+ from muscriptor.utils.audio import _read_non_wav_file, _read_wav_file
33
+
34
+
35
+ def _make_release_once(lock: threading.Lock):
36
+ """Return a callable that releases `lock` at most once.
37
+
38
+ Safe to call from multiple cleanup paths (generator finally + response
39
+ background task), possibly from different threads, without risking a
40
+ double-release RuntimeError.
41
+ """
42
+ guard = threading.Lock()
43
+ released = False
44
+
45
+ def release():
46
+ nonlocal released
47
+ with guard:
48
+ if released:
49
+ return
50
+ released = True
51
+ lock.release()
52
+
53
+ return release
54
+
55
+
56
+ def event_to_dict(ev: NoteStartEvent | NoteEndEvent) -> dict:
57
+ if isinstance(ev, NoteStartEvent):
58
+ return {"type": "start", **dataclasses.asdict(ev)}
59
+ return {
60
+ "type": "end",
61
+ "end_time": ev.end_time,
62
+ "start_event_index": ev.start_event_index,
63
+ }
64
+
65
+
66
+ def create_app(model: TranscriptionModel, web_dir: str | Path | None = None) -> FastAPI:
67
+ app = FastAPI(title="muscriptor")
68
+
69
+ transcribe_lock = threading.Lock()
70
+ lock_timeout_s = 60.0
71
+ # Cancel event of the run currently holding the lock (or the last one to
72
+ # have held it). A new /transcribe request sets it so an in-flight run
73
+ # stops at its next event boundary instead of transcribing to completion
74
+ # for a client that has moved on. This must not rely on TCP disconnects:
75
+ # aborts don't always reach us (e.g. port forwards / proxies that keep the
76
+ # upstream connection open after the browser aborts).
77
+ cancel_guard = threading.Lock()
78
+ current_cancel: threading.Event | None = None
79
+
80
+ @app.get("/health")
81
+ async def health():
82
+ return {"status": "ok"}
83
+
84
+ @app.get("/instruments")
85
+ async def list_instruments():
86
+ return {"instruments": list(MT3_FULL_PLUS_GROUP_NAMES.keys())}
87
+
88
+ @app.post("/transcribe")
89
+ async def transcribe(
90
+ file: Annotated[UploadFile, File()],
91
+ instruments: Annotated[list[str], Form(default_factory=list)],
92
+ ) -> StreamingResponse:
93
+ data = await file.read()
94
+ # PCM WAV goes through the stdlib reader (keeps WAV decoding byte-for-byte
95
+ # identical to the CLI); anything that isn't a readable WAV (mp3, flac,
96
+ # ogg, m4a, …) falls back to soundfile/libsndfile. A genuinely
97
+ # undecodable upload (corrupt/truncated file, or a format libsndfile
98
+ # can't read) is the client's fault, so report it as a 400 rather than
99
+ # letting it surface as a 500.
100
+ try:
101
+ wav, sr = _read_wav_file(io.BytesIO(data))
102
+ except (wave.Error, EOFError):
103
+ try:
104
+ wav, sr = _read_non_wav_file(io.BytesIO(data))
105
+ except Exception as e:
106
+ raise HTTPException(
107
+ status_code=400,
108
+ detail=f"could not decode audio file '{file.filename}': {e}",
109
+ ) from e
110
+
111
+ unknown = [n for n in instruments if n not in MT3_FULL_PLUS_GROUP_NAMES]
112
+ if unknown:
113
+ raise HTTPException(
114
+ status_code=400,
115
+ detail=f"unknown instrument name(s): {', '.join(unknown)}",
116
+ )
117
+
118
+ # Preempt whoever holds the lock, then wait for it. The short acquire
119
+ # timeout re-signals each second so that even a run that started while
120
+ # we were already waiting (another preempting request that beat us to
121
+ # the lock) gets cancelled too — the newest request always wins.
122
+ nonlocal current_cancel
123
+ deadline = time.monotonic() + lock_timeout_s
124
+ while True:
125
+ with cancel_guard:
126
+ if current_cancel is not None:
127
+ current_cancel.set()
128
+ remaining = deadline - time.monotonic()
129
+ if remaining <= 0:
130
+ raise HTTPException(
131
+ status_code=503,
132
+ detail="server busy: another transcription is in progress",
133
+ )
134
+ acquired = await asyncio.to_thread(
135
+ transcribe_lock.acquire, True, min(1.0, remaining)
136
+ )
137
+ if acquired:
138
+ break
139
+ cancel = threading.Event()
140
+ with cancel_guard:
141
+ current_cancel = cancel
142
+
143
+ # Release exactly once, from whichever path runs first. The generator's
144
+ # finally covers normal completion, errors and mid-stream disconnects;
145
+ # the StreamingResponse background task covers the case where the client
146
+ # disconnects *before* the generator is ever iterated (so its finally
147
+ # would never run) — which otherwise leaks the lock forever.
148
+ release_lock = _make_release_once(transcribe_lock)
149
+
150
+ def gen():
151
+ try:
152
+ events: list[NoteStartEvent | NoteEndEvent] = []
153
+ # batch_size=1 so each chunk's notes stream out as soon as it is
154
+ # generated, instead of waiting for a whole batch of chunks.
155
+ # no_eos_is_ok=True so one runaway chunk that never emits EOS only
156
+ # warns (and keeps its notes) instead of aborting the whole stream.
157
+ for ev in model.transcribe(
158
+ (wav, sr),
159
+ instruments=instruments or None,
160
+ batch_size=1,
161
+ no_eos_is_ok=True,
162
+ ):
163
+ # A newer request preempted this run — stop generating
164
+ # (closing the model.transcribe generator) and release the
165
+ # lock via the finally, at most one chunk after the signal.
166
+ if cancel.is_set():
167
+ return
168
+ if isinstance(ev, ProgressEvent):
169
+ # Coarse chunk-completion anchor — forward it but keep it
170
+ # out of the note list the MIDI file is built from.
171
+ payload = json.dumps(
172
+ {
173
+ "type": "progress",
174
+ "completed": ev.completed,
175
+ "total": ev.total,
176
+ }
177
+ )
178
+ yield f"data: {payload}\n\n"
179
+ continue
180
+ events.append(ev)
181
+ payload = json.dumps(event_to_dict(ev))
182
+ yield f"data: {payload}\n\n"
183
+ # All notes streamed — build the MIDI file in memory (reusing the
184
+ # exact `muscriptor transcribe` logic) and send it as a final event
185
+ # with the bytes base64-encoded.
186
+ if cancel.is_set():
187
+ return
188
+ midi_bytes = model.events_to_midi_bytes(iter(events))
189
+ midi_b64 = base64.b64encode(midi_bytes).decode("ascii")
190
+ payload = json.dumps({"type": "midi", "data": midi_b64})
191
+ yield f"data: {payload}\n\n"
192
+ finally:
193
+ release_lock()
194
+
195
+ return StreamingResponse(
196
+ gen(),
197
+ media_type="text/event-stream",
198
+ background=BackgroundTask(release_lock),
199
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
200
+ )
201
+
202
+ @app.post("/auralize")
203
+ async def auralize(
204
+ midi: Annotated[UploadFile, File()],
205
+ audio: Annotated[UploadFile | None, File()] = None,
206
+ mode: Annotated[str, Form()] = "mix",
207
+ ):
208
+ """Render a transcription as WAV.
209
+
210
+ mode="mix": stereo, original audio (L) + FluidSynth synthesis (R);
211
+ requires the `audio` upload. mode="synth": mono, just the synthesis.
212
+ """
213
+ from muscriptor.utils.auralization import auralize as do_auralize
214
+ from muscriptor.utils.auralization import synthesize
215
+
216
+ if mode not in ("mix", "synth"):
217
+ raise HTTPException(status_code=400, detail=f"unknown mode: {mode!r}")
218
+ if mode == "mix" and audio is None:
219
+ raise HTTPException(
220
+ status_code=400, detail="mode='mix' requires an audio file"
221
+ )
222
+
223
+ midi_data = await midi.read()
224
+ tmp_paths: list[str] = []
225
+
226
+ with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as tmp_midi:
227
+ tmp_midi.write(midi_data)
228
+ midi_tmp = tmp_midi.name
229
+ tmp_paths.append(midi_tmp)
230
+
231
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_out:
232
+ out_tmp = tmp_out.name
233
+ tmp_paths.append(out_tmp)
234
+
235
+ try:
236
+ if mode == "synth":
237
+ synthesize(midi_path=midi_tmp, output_path=out_tmp)
238
+ else:
239
+ audio_data = await audio.read()
240
+ suffix = Path(audio.filename or "audio.wav").suffix.lower() or ".wav"
241
+ with tempfile.NamedTemporaryFile(
242
+ suffix=suffix, delete=False
243
+ ) as tmp_audio:
244
+ tmp_audio.write(audio_data)
245
+ tmp_paths.append(tmp_audio.name)
246
+ do_auralize(
247
+ midi_path=midi_tmp,
248
+ original_audio_path=tmp_audio.name,
249
+ output_path=out_tmp,
250
+ )
251
+ with open(out_tmp, "rb") as f:
252
+ wav_bytes = f.read()
253
+ except Exception as e:
254
+ raise HTTPException(status_code=500, detail=str(e))
255
+ finally:
256
+ for p in tmp_paths:
257
+ if os.path.exists(p):
258
+ os.unlink(p)
259
+
260
+ return Response(content=wav_bytes, media_type="audio/wav")
261
+
262
+ if web_dir is not None:
263
+ web_path = Path(web_dir)
264
+ if web_path.is_dir():
265
+ app.mount("/", StaticFiles(directory=web_path, html=True), name="web")
266
+
267
+ return app
@@ -0,0 +1,3 @@
1
+ from .notes import note_event2midi, note2note_event
2
+
3
+ __all__ = ["note_event2midi", "note2note_event"]
@@ -0,0 +1,231 @@
1
+ """MT3 MIDI tokenizer.
2
+
3
+ Adapted from YourMT3+ (https://github.com/mimbres/YourMT3) and audiocraft_trans.
4
+ """
5
+
6
+ import difflib
7
+ import logging
8
+ from collections.abc import Iterable
9
+
10
+ from muscriptor.tokenizer.notes import (
11
+ DRUM_PROGRAM,
12
+ SPECIAL_TOKENS,
13
+ build_event_vocab,
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def get_group_program_map(
20
+ instrument_vocabulary: str,
21
+ misc_programs: str,
22
+ is_mt3: bool = False,
23
+ include_drums: bool = False,
24
+ ) -> dict[int, list[int]]:
25
+ if instrument_vocabulary == "ONLY_PIANO":
26
+ ret = {0: list(range(128))}
27
+ elif instrument_vocabulary == "FULL":
28
+ ret = {i: [i] for i in range(128)}
29
+ elif instrument_vocabulary == "MT3_MIDI_PLUS":
30
+ ret = {
31
+ 0: list(range(8)),
32
+ 1: list(range(8, 16)),
33
+ 2: list(range(16, 24)),
34
+ 3: list(range(24, 32)),
35
+ 4: list(range(32, 40)),
36
+ 5: list(range(40, 56)),
37
+ 6: list(range(56, 64)),
38
+ 7: list(range(64, 72)),
39
+ 8: list(range(72, 80)),
40
+ 9: list(range(80, 88)),
41
+ 10: list(range(88, 96)),
42
+ 11: list(range(100, 102)),
43
+ }
44
+ elif instrument_vocabulary == "MT3_FULL_PLUS":
45
+ ret = {
46
+ 0: [0, 1, 3, 6, 7],
47
+ 1: [2, 4, 5],
48
+ 2: list(range(8, 16)),
49
+ 3: list(range(16, 24)),
50
+ 4: [24, 25],
51
+ 5: [26, 27, 28],
52
+ 6: [29, 30, 31],
53
+ 7: [32, 35],
54
+ 8: [33, 34, 36, 37, 38, 39],
55
+ 9: [40],
56
+ 10: [41],
57
+ 11: [42],
58
+ 12: [43],
59
+ 13: [46],
60
+ 14: [47],
61
+ 15: [48, 49, 44, 45],
62
+ 16: [50, 51],
63
+ 17: [52, 53, 54],
64
+ 18: [55],
65
+ 19: [56, 59],
66
+ 20: [57],
67
+ 21: [58],
68
+ 22: [60],
69
+ 23: [61, 62, 63],
70
+ 24: [64, 65],
71
+ 25: [66],
72
+ 26: [67],
73
+ 27: [68],
74
+ 28: [69],
75
+ 29: [70],
76
+ 30: [71],
77
+ 31: list(range(72, 80)),
78
+ 32: list(range(80, 88)),
79
+ 33: list(range(88, 96)),
80
+ 34: [100],
81
+ 35: [101],
82
+ }
83
+ elif instrument_vocabulary == "OURS_INSTRUMENT_GROUPS":
84
+ ret = {
85
+ 0: list(range(8)),
86
+ 1: list(range(24, 32)),
87
+ 2: list(range(32, 40)),
88
+ 3: list(range(40, 56)),
89
+ 4: list(range(56, 64)),
90
+ 5: list(range(16, 24)) + list(range(64, 80)),
91
+ 6: list(range(80, 96)),
92
+ 7: list(range(8, 16)) + list(range(112, 119)),
93
+ }
94
+ else:
95
+ assert False, instrument_vocabulary
96
+
97
+ if instrument_vocabulary == "MT3_FULL_PLUS" and not is_mt3:
98
+ not_assigned = set(range(130)) - set([v for vs in ret.values() for v in vs])
99
+ else:
100
+ not_assigned = set(range(128)) - set([v for vs in ret.values() for v in vs])
101
+ if include_drums:
102
+ not_assigned = not_assigned.union({DRUM_PROGRAM})
103
+ if misc_programs == "ONE_GROUP":
104
+ ret[len(ret)] = list(not_assigned)
105
+ elif misc_programs == "SINGLETON_GROUPS":
106
+ for p in not_assigned:
107
+ ret[len(ret)] = [p]
108
+ else:
109
+ assert misc_programs == "OMIT", misc_programs
110
+ return ret
111
+
112
+
113
+ # Human-readable names for the MT3_FULL_PLUS instrument groups (see
114
+ # get_group_program_map). Used by the CLI's --instruments option and the
115
+ # web app's /instruments endpoint. The group IDs index the model's learned
116
+ # program groups and must not change; only the user-facing names do.
117
+ # Notes the model still decodes into an omitted group surface as "program_<n>".
118
+ MT3_FULL_PLUS_GROUP_NAMES: dict[str, int] = {
119
+ "acoustic_piano": 0,
120
+ "electric_piano": 1,
121
+ "chromatic_percussion": 2,
122
+ "organ": 3,
123
+ "acoustic_guitar": 4,
124
+ "clean_electric_guitar": 5,
125
+ "distorted_electric_guitar": 6,
126
+ "acoustic_bass": 7,
127
+ "electric_bass": 8,
128
+ "violin": 9,
129
+ "viola": 10,
130
+ "cello": 11,
131
+ "contrabass": 12,
132
+ "orchestral_harp": 13,
133
+ "timpani": 14,
134
+ "string_ensemble": 15,
135
+ "synth_strings": 16,
136
+ "voice": 17,
137
+ "orchestra_hit": 18,
138
+ "trumpet": 19,
139
+ "trombone": 20,
140
+ "tuba": 21,
141
+ "french_horn": 22,
142
+ "brass_section": 23,
143
+ "soprano_and_alto_sax": 24,
144
+ "tenor_sax": 25,
145
+ "baritone_sax": 26,
146
+ "oboe": 27,
147
+ "english_horn": 28,
148
+ "bassoon": 29,
149
+ "clarinet": 30,
150
+ "flutes": 31,
151
+ "synth_lead": 32,
152
+ "synth_pad": 33,
153
+ "drums": 36,
154
+ }
155
+
156
+
157
+ def instrument_group_from_names(names: Iterable[str]) -> str:
158
+ """Map exact instrument group names to the model's conditioning string.
159
+
160
+ The strict counterpart of :func:`resolve_instrument_names`: every name
161
+ must appear verbatim in ``MT3_FULL_PLUS_GROUP_NAMES``. Raises ValueError
162
+ listing the unknown names otherwise.
163
+ """
164
+ names = list(names)
165
+ unknown = [n for n in names if n not in MT3_FULL_PLUS_GROUP_NAMES]
166
+ if unknown:
167
+ raise ValueError(
168
+ f"unknown instrument name(s): {', '.join(map(repr, unknown))}; "
169
+ f"valid names: {', '.join(MT3_FULL_PLUS_GROUP_NAMES)}"
170
+ )
171
+ return " ".join(str(MT3_FULL_PLUS_GROUP_NAMES[n]) for n in names)
172
+
173
+
174
+ def resolve_instrument_names(tokens: Iterable[str]) -> list[str]:
175
+ """Resolve loosely-typed instrument tokens to canonical group names.
176
+
177
+ Matching is case-insensitive; a token that is not an exact name may be
178
+ any substring that matches exactly one group name (``"timp"`` →
179
+ ``"timpani"``). Raises ValueError when a token is ambiguous (listing the
180
+ candidates) or matches nothing (suggesting close spellings).
181
+ """
182
+ resolved = []
183
+ for token in tokens:
184
+ t = token.strip().lower()
185
+ if t in MT3_FULL_PLUS_GROUP_NAMES:
186
+ resolved.append(t)
187
+ continue
188
+ hits = [n for n in MT3_FULL_PLUS_GROUP_NAMES if t in n]
189
+ if len(hits) == 1:
190
+ resolved.append(hits[0])
191
+ elif hits:
192
+ raise ValueError(
193
+ f"ambiguous instrument name {token!r}: "
194
+ f"matches {', '.join(hits)}"
195
+ )
196
+ else:
197
+ # Compare against each name AND its underscore-separated words,
198
+ # so a typo like "pinao" still surfaces "acoustic_piano".
199
+ def closeness(name: str) -> float:
200
+ return max(
201
+ difflib.SequenceMatcher(None, t, part).ratio()
202
+ for part in (name, *name.split("_"))
203
+ )
204
+
205
+ ranked = sorted(MT3_FULL_PLUS_GROUP_NAMES, key=closeness, reverse=True)
206
+ suggestions = [n for n in ranked[:3] if closeness(n) >= 0.6]
207
+ hint = (
208
+ f" — did you mean {', '.join(suggestions)}?"
209
+ if suggestions
210
+ else ""
211
+ )
212
+ raise ValueError(f"unknown instrument name {token!r}{hint}")
213
+ return resolved
214
+
215
+
216
+ class MT3Tokenizer:
217
+ def __init__(
218
+ self,
219
+ instrument_vocabulary: str = "FULL",
220
+ max_shift_steps: int = 1001,
221
+ frame_rate: int = 100,
222
+ ):
223
+ self.group_program_map = get_group_program_map(
224
+ instrument_vocabulary, misc_programs="SINGLETON_GROUPS", is_mt3=True
225
+ )
226
+ self.frame_rate = frame_rate
227
+ self._vocab = build_event_vocab(max_shift_steps)
228
+ self.num_tokens = len(self._vocab)
229
+ self.eos_id = SPECIAL_TOKENS.index("EOS")
230
+
231
+ logger.info(f"MT3Tokenizer: {self.num_tokens} tokens")