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/__init__.py +7 -0
- muscriptor/__main__.py +3 -0
- muscriptor/events.py +208 -0
- muscriptor/main.py +327 -0
- muscriptor/models/__init__.py +0 -0
- muscriptor/models/lm.py +514 -0
- muscriptor/modules/__init__.py +0 -0
- muscriptor/modules/conditioners.py +321 -0
- muscriptor/modules/mel_spectrogram.py +106 -0
- muscriptor/modules/streaming.py +68 -0
- muscriptor/modules/transformer.py +202 -0
- muscriptor/server.py +267 -0
- muscriptor/tokenizer/__init__.py +3 -0
- muscriptor/tokenizer/mt3.py +231 -0
- muscriptor/tokenizer/notes.py +344 -0
- muscriptor/transcription_model.py +629 -0
- muscriptor/utils/__init__.py +0 -0
- muscriptor/utils/audio.py +112 -0
- muscriptor/utils/auralization.py +145 -0
- muscriptor/utils/download.py +68 -0
- muscriptor/utils/midi.py +25 -0
- muscriptor/utils/resample.py +191 -0
- muscriptor/utils/sampling.py +89 -0
- muscriptor-0.1.0.dist-info/METADATA +336 -0
- muscriptor-0.1.0.dist-info/RECORD +28 -0
- muscriptor-0.1.0.dist-info/WHEEL +4 -0
- muscriptor-0.1.0.dist-info/entry_points.txt +2 -0
- muscriptor-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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")
|