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
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
"""TranscriptionModel: main user-facing entry point."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
import torch.nn.functional as F
|
|
17
|
+
|
|
18
|
+
from safetensors.torch import load_file
|
|
19
|
+
|
|
20
|
+
from muscriptor.events import (
|
|
21
|
+
ChunkBoundary,
|
|
22
|
+
NoteEndEvent,
|
|
23
|
+
NoteStartEvent,
|
|
24
|
+
ProgressEvent,
|
|
25
|
+
decode_model_tokens,
|
|
26
|
+
)
|
|
27
|
+
from muscriptor.models.lm import LMModel, TorchAutocast
|
|
28
|
+
from muscriptor.utils.download import download_companion, download_if_necessary
|
|
29
|
+
from muscriptor.modules.conditioners import (
|
|
30
|
+
MelSpectrogramConditioner,
|
|
31
|
+
ClassConditioner,
|
|
32
|
+
ConditioningProvider,
|
|
33
|
+
ConditioningAttributes,
|
|
34
|
+
WavCondition,
|
|
35
|
+
)
|
|
36
|
+
from muscriptor.tokenizer.mt3 import (
|
|
37
|
+
MT3_FULL_PLUS_GROUP_NAMES,
|
|
38
|
+
MT3Tokenizer,
|
|
39
|
+
instrument_group_from_names,
|
|
40
|
+
)
|
|
41
|
+
from muscriptor.tokenizer.notes import (
|
|
42
|
+
DRUM_PROGRAM,
|
|
43
|
+
Note,
|
|
44
|
+
trim_overlapping_notes,
|
|
45
|
+
validate_notes,
|
|
46
|
+
)
|
|
47
|
+
from muscriptor.utils.audio import load_audio, resample
|
|
48
|
+
from muscriptor.utils.midi import notes_to_midi
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextlib.contextmanager
|
|
52
|
+
def _timed(label: str, store: list[tuple[str, float]] | None = None):
|
|
53
|
+
"""Print and (optionally) record how long a block of work takes."""
|
|
54
|
+
if torch.cuda.is_available():
|
|
55
|
+
torch.cuda.synchronize()
|
|
56
|
+
t0 = time.perf_counter()
|
|
57
|
+
yield
|
|
58
|
+
if torch.cuda.is_available():
|
|
59
|
+
torch.cuda.synchronize()
|
|
60
|
+
dt = time.perf_counter() - t0
|
|
61
|
+
print(f"[muscriptor] {label}: {dt:.2f}s", file=sys.stderr)
|
|
62
|
+
if store is not None:
|
|
63
|
+
store.append((label, dt))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Published model variants live at hf://MuScriptor/muscriptor-<size>. A bare
|
|
67
|
+
# size keyword ("small"/"medium"/"large") resolves to the matching repo; the
|
|
68
|
+
# architecture is then read from that repo's config.json (see _resolve_config).
|
|
69
|
+
_HF_REPO_TEMPLATE = "hf://MuScriptor/muscriptor-{size}/model.safetensors"
|
|
70
|
+
_MODEL_SIZES = ("small", "medium", "large")
|
|
71
|
+
_DEFAULT_SIZE = "medium"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_source(weights_path: str | Path | None) -> str | Path:
|
|
75
|
+
"""Map a --model value to a weights location.
|
|
76
|
+
|
|
77
|
+
A size keyword ("small"/"medium"/"large") — or None, which defaults to
|
|
78
|
+
``medium`` — becomes the corresponding HuggingFace repo URL. Anything else
|
|
79
|
+
(a local path, an ``hf://`` or ``http(s)://`` URL) is passed through as-is.
|
|
80
|
+
"""
|
|
81
|
+
if weights_path is None:
|
|
82
|
+
weights_path = _DEFAULT_SIZE
|
|
83
|
+
if isinstance(weights_path, str) and weights_path in _MODEL_SIZES:
|
|
84
|
+
return _HF_REPO_TEMPLATE.format(size=weights_path)
|
|
85
|
+
return weights_path
|
|
86
|
+
|
|
87
|
+
_SAMPLE_RATE = 16000
|
|
88
|
+
# Must match the segment duration used during training / evaluation.
|
|
89
|
+
_SEGMENT_DURATION = 5.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class _ModelConfig:
|
|
94
|
+
dim: int
|
|
95
|
+
num_heads: int
|
|
96
|
+
num_layers: int
|
|
97
|
+
card: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Per-variant configs, keyed by the size that appears in the HF repo name
|
|
101
|
+
# (muscriptor-<size>). Each published repo also ships these values in its
|
|
102
|
+
# config.json; this table is the fallback when no config.json is present.
|
|
103
|
+
_CONFIGS: dict[str, _ModelConfig] = {
|
|
104
|
+
"large": _ModelConfig(dim=1536, num_heads=24, num_layers=48, card=1395),
|
|
105
|
+
"medium": _ModelConfig(dim=1024, num_heads=16, num_layers=24, card=1395),
|
|
106
|
+
"small": _ModelConfig(dim=768, num_heads=12, num_layers=14, card=1393),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_DEFAULT_CONFIG = _CONFIGS["large"]
|
|
110
|
+
|
|
111
|
+
# Legacy local checkpoints identified by the 8-hex tag in their filename,
|
|
112
|
+
# mapped to the equivalent variant config.
|
|
113
|
+
_LEGACY_CONFIGS: dict[str, _ModelConfig] = {
|
|
114
|
+
"01684fbb": _CONFIGS["large"],
|
|
115
|
+
"0ac4ce03": _CONFIGS["small"],
|
|
116
|
+
"8f59580c": _CONFIGS["medium"],
|
|
117
|
+
"e84904c4": _CONFIGS["large"],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_CONFIG_FILENAME = "config.json"
|
|
121
|
+
_CONFIG_FIELDS = ("dim", "num_heads", "num_layers", "card")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _config_from_json(path: Path) -> _ModelConfig:
|
|
125
|
+
"""Read a _ModelConfig from a HuggingFace-style config.json."""
|
|
126
|
+
data = json.loads(path.read_text())
|
|
127
|
+
return _ModelConfig(**{field: data[field] for field in _CONFIG_FIELDS})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve_config(source: str | Path, weights_path: Path) -> _ModelConfig:
|
|
131
|
+
"""Determine the model architecture for a set of weights.
|
|
132
|
+
|
|
133
|
+
Resolution order, most to least authoritative:
|
|
134
|
+
1. ``config.json`` sitting next to the weights — the self-describing,
|
|
135
|
+
HuggingFace-idiomatic source of truth (local dir or hf:// repo).
|
|
136
|
+
2. the ``muscriptor-<size>`` segment of an ``hf://`` repo name.
|
|
137
|
+
3. the legacy 8-hex tag embedded in a local checkpoint filename.
|
|
138
|
+
"""
|
|
139
|
+
config_path = weights_path.parent / _CONFIG_FILENAME
|
|
140
|
+
if not config_path.exists():
|
|
141
|
+
fetched = download_companion(source, _CONFIG_FILENAME)
|
|
142
|
+
if fetched is not None:
|
|
143
|
+
config_path = fetched
|
|
144
|
+
if config_path.exists():
|
|
145
|
+
return _config_from_json(config_path)
|
|
146
|
+
|
|
147
|
+
m = re.search(r"muscriptor-(large|medium|small)", str(source))
|
|
148
|
+
if m:
|
|
149
|
+
return _CONFIGS[m.group(1)]
|
|
150
|
+
|
|
151
|
+
m = re.search(r"_([0-9a-f]{8})_", weights_path.name)
|
|
152
|
+
if m and m.group(1) in _LEGACY_CONFIGS:
|
|
153
|
+
return _LEGACY_CONFIGS[m.group(1)]
|
|
154
|
+
return _DEFAULT_CONFIG
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _remap_single_codebook_keys(state_dict: dict) -> dict:
|
|
158
|
+
"""Adapt legacy multi-codebook checkpoints to the single-stream LMModel.
|
|
159
|
+
|
|
160
|
+
Older checkpoints store the token embedding and output head as the first
|
|
161
|
+
entry of an ``nn.ModuleList`` (``emb.0.*`` / ``linears.0.*``). LMModel is
|
|
162
|
+
single-stream, so those map to ``emb.*`` / ``linear.*``. Checkpoints with a
|
|
163
|
+
second codebook (``emb.1.*`` etc.) are unsupported and rejected.
|
|
164
|
+
"""
|
|
165
|
+
if any(k.startswith(("emb.1.", "linears.1.")) for k in state_dict):
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"Checkpoint has more than one codebook (n_q > 1); "
|
|
168
|
+
"only single-stream models are supported."
|
|
169
|
+
)
|
|
170
|
+
remapped = {}
|
|
171
|
+
for key, value in state_dict.items():
|
|
172
|
+
if key.startswith("emb.0."):
|
|
173
|
+
key = "emb." + key[len("emb.0.") :]
|
|
174
|
+
elif key.startswith("linears.0."):
|
|
175
|
+
key = "linear." + key[len("linears.0.") :]
|
|
176
|
+
remapped[key] = value
|
|
177
|
+
return remapped
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_model(device: torch.device, cfg: _ModelConfig = _DEFAULT_CONFIG) -> LMModel:
|
|
181
|
+
mel_cond = MelSpectrogramConditioner(
|
|
182
|
+
output_dim=cfg.dim,
|
|
183
|
+
device=device,
|
|
184
|
+
sample_rate=_SAMPLE_RATE,
|
|
185
|
+
n_fft=2048,
|
|
186
|
+
frame_rate=100,
|
|
187
|
+
n_mel_bins=512,
|
|
188
|
+
log_scale=True,
|
|
189
|
+
eps=1e-6,
|
|
190
|
+
normalize_audio=False,
|
|
191
|
+
)
|
|
192
|
+
inst_cond = ClassConditioner(num_classes=1000, output_dim=cfg.dim, device=device)
|
|
193
|
+
ds_cond = ClassConditioner(num_classes=4, output_dim=cfg.dim, device=device)
|
|
194
|
+
|
|
195
|
+
condition_provider = ConditioningProvider(
|
|
196
|
+
conditioners={
|
|
197
|
+
"self_wav": mel_cond,
|
|
198
|
+
"instrument_group": inst_cond,
|
|
199
|
+
"dataset_name": ds_cond,
|
|
200
|
+
},
|
|
201
|
+
device=device,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
autocast = None
|
|
205
|
+
if device.type == "cuda":
|
|
206
|
+
autocast = TorchAutocast(enabled=True, device_type="cuda", dtype=torch.float16)
|
|
207
|
+
|
|
208
|
+
model = LMModel(
|
|
209
|
+
condition_provider=condition_provider,
|
|
210
|
+
card=cfg.card,
|
|
211
|
+
dim=cfg.dim,
|
|
212
|
+
num_heads=cfg.num_heads,
|
|
213
|
+
hidden_scale=4,
|
|
214
|
+
cfg_coef=1.0,
|
|
215
|
+
autocast=autocast,
|
|
216
|
+
# StreamingTransformer kwargs (forwarded via **kwargs)
|
|
217
|
+
num_layers=cfg.num_layers,
|
|
218
|
+
max_period=10000,
|
|
219
|
+
device=device,
|
|
220
|
+
)
|
|
221
|
+
return model
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _build_instrument_for_program(tokenizer: MT3Tokenizer) -> Callable[[int], str]:
|
|
225
|
+
"""Map a decoded program int → human-readable instrument name.
|
|
226
|
+
|
|
227
|
+
MT3_FULL_PLUS groups multiple GM programs together; the decoded program
|
|
228
|
+
is always the first program of the group. We map that representative
|
|
229
|
+
back to the readable group name.
|
|
230
|
+
"""
|
|
231
|
+
group_map = tokenizer.group_program_map
|
|
232
|
+
program_to_name: dict[int, str] = {}
|
|
233
|
+
for name, gid in MT3_FULL_PLUS_GROUP_NAMES.items():
|
|
234
|
+
if gid in group_map and group_map[gid]:
|
|
235
|
+
program_to_name[group_map[gid][0]] = name
|
|
236
|
+
|
|
237
|
+
def lookup(program: int) -> str:
|
|
238
|
+
if program == DRUM_PROGRAM:
|
|
239
|
+
return "drums"
|
|
240
|
+
return program_to_name.get(program, f"program_{program}")
|
|
241
|
+
|
|
242
|
+
return lookup
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TranscriptionModel:
|
|
246
|
+
"""Transcribes audio to MIDI using the muscriptor model.
|
|
247
|
+
|
|
248
|
+
Example::
|
|
249
|
+
|
|
250
|
+
from pathlib import Path
|
|
251
|
+
|
|
252
|
+
model = TranscriptionModel.load_model()
|
|
253
|
+
for event in model.transcribe("audio.wav"):
|
|
254
|
+
print(event)
|
|
255
|
+
|
|
256
|
+
Path("out.mid").write_bytes(model.transcribe_to_midi("audio.wav"))
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(self, model: LMModel, tokenizer: MT3Tokenizer, device: torch.device):
|
|
260
|
+
self._model = model
|
|
261
|
+
self._tokenizer = tokenizer
|
|
262
|
+
self._device = device
|
|
263
|
+
self._instrument_for_program = _build_instrument_for_program(tokenizer)
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def load_model(
|
|
267
|
+
cls,
|
|
268
|
+
weights_path: str | Path | None = None,
|
|
269
|
+
device: str | torch.device | None = None,
|
|
270
|
+
) -> "TranscriptionModel":
|
|
271
|
+
"""Load model weights and return a ready-to-use TranscriptionModel.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
weights_path: A size keyword (``"small"``/``"medium"``/``"large"``)
|
|
275
|
+
selecting a published HuggingFace variant, a local safetensors
|
|
276
|
+
path, an ``hf://`` or ``https://`` URL, or None. If None, the
|
|
277
|
+
default ``medium`` variant is downloaded from HuggingFace.
|
|
278
|
+
Remote URLs are cached under ~/.cache/muscriptor/.
|
|
279
|
+
device: Torch device to use. Defaults to CUDA if available.
|
|
280
|
+
"""
|
|
281
|
+
if device is None:
|
|
282
|
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
283
|
+
elif isinstance(device, str):
|
|
284
|
+
device = torch.device(device)
|
|
285
|
+
|
|
286
|
+
source = _resolve_source(weights_path)
|
|
287
|
+
weights_path = download_if_necessary(source)
|
|
288
|
+
model = _build_model(device, _resolve_config(source, weights_path))
|
|
289
|
+
model.eval()
|
|
290
|
+
|
|
291
|
+
state_dict = load_file(weights_path, device=str(device))
|
|
292
|
+
state_dict = _remap_single_codebook_keys(state_dict)
|
|
293
|
+
model.load_state_dict(state_dict)
|
|
294
|
+
model.to(device)
|
|
295
|
+
|
|
296
|
+
tokenizer = MT3Tokenizer(
|
|
297
|
+
instrument_vocabulary="MT3_FULL_PLUS",
|
|
298
|
+
max_shift_steps=1001,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return cls(model=model, tokenizer=tokenizer, device=device)
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
def transcribe(
|
|
305
|
+
self,
|
|
306
|
+
audio: str | Path | tuple[torch.Tensor, int],
|
|
307
|
+
use_sampling: bool = False,
|
|
308
|
+
temperature: float = 1.0,
|
|
309
|
+
cfg_coef: float = 1.0,
|
|
310
|
+
instruments: list[str] | None = None,
|
|
311
|
+
batch_size: int | None = None,
|
|
312
|
+
no_eos_is_ok: bool = True,
|
|
313
|
+
beam_size: int = 1,
|
|
314
|
+
) -> Iterator[NoteStartEvent | NoteEndEvent | ProgressEvent]:
|
|
315
|
+
"""Transcribe audio into a stream of note events.
|
|
316
|
+
|
|
317
|
+
See the README for full argument documentation and the streaming /
|
|
318
|
+
chunk-ordering guarantees. The audio is split into 5-second chunks;
|
|
319
|
+
within each chunk events arrive in temporal order, and all events
|
|
320
|
+
from chunk N are yielded before any event from chunk N+1.
|
|
321
|
+
|
|
322
|
+
Interleaved with the note events are coarse :class:`ProgressEvent`
|
|
323
|
+
anchors (``completed`` of ``total`` chunks): one up front with
|
|
324
|
+
``completed == 0``, then one as each chunk finishes. Consumers that
|
|
325
|
+
only care about notes can ignore them.
|
|
326
|
+
"""
|
|
327
|
+
if batch_size is None:
|
|
328
|
+
batch_size = 4 if self._device.type == "cuda" else 1
|
|
329
|
+
|
|
330
|
+
# Exact names only here — the CLI resolves abbreviations before
|
|
331
|
+
# calling in (resolve_instrument_names).
|
|
332
|
+
instrument_group = (
|
|
333
|
+
instrument_group_from_names(instruments) if instruments else None
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
timings: list[tuple[str, float]] = []
|
|
337
|
+
t_total = time.perf_counter()
|
|
338
|
+
|
|
339
|
+
if isinstance(audio, tuple):
|
|
340
|
+
tensor, sample_rate = audio
|
|
341
|
+
with _timed("load audio", timings):
|
|
342
|
+
wav = self._load_wav(tensor, sample_rate)
|
|
343
|
+
else:
|
|
344
|
+
with _timed("load audio", timings):
|
|
345
|
+
wav = self._load_wav(audio, None)
|
|
346
|
+
|
|
347
|
+
total_samples = wav.shape[-1]
|
|
348
|
+
total_duration = total_samples / _SAMPLE_RATE
|
|
349
|
+
|
|
350
|
+
segment_samples = int(_SEGMENT_DURATION * _SAMPLE_RATE)
|
|
351
|
+
num_chunks = math.ceil(total_samples / segment_samples)
|
|
352
|
+
max_gen_len = 2000
|
|
353
|
+
print(
|
|
354
|
+
f"[muscriptor] audio: {total_duration:.1f}s → {num_chunks} chunk(s) of {_SEGMENT_DURATION}s",
|
|
355
|
+
file=sys.stderr,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
with _timed("build conditions", timings):
|
|
359
|
+
all_conditions: list[ConditioningAttributes] = []
|
|
360
|
+
seek_times: list[float] = []
|
|
361
|
+
for i in range(num_chunks):
|
|
362
|
+
start = i * segment_samples
|
|
363
|
+
chunk = wav[:, start : start + segment_samples]
|
|
364
|
+
if chunk.shape[-1] < segment_samples:
|
|
365
|
+
chunk = F.pad(chunk, (0, segment_samples - chunk.shape[-1]))
|
|
366
|
+
all_conditions.append(
|
|
367
|
+
self._build_conditions(chunk, instrument_group)[0]
|
|
368
|
+
)
|
|
369
|
+
seek_times.append(i * _SEGMENT_DURATION)
|
|
370
|
+
|
|
371
|
+
t_gen = time.perf_counter()
|
|
372
|
+
|
|
373
|
+
# Up-front anchor: tells consumers the total chunk count and gives them a
|
|
374
|
+
# timing baseline (t0) for the first chunk, before any tokens are gen'd.
|
|
375
|
+
yield ProgressEvent(completed=0, total=num_chunks)
|
|
376
|
+
|
|
377
|
+
yield from decode_model_tokens(
|
|
378
|
+
self._generate_token_stream(
|
|
379
|
+
all_conditions,
|
|
380
|
+
seek_times,
|
|
381
|
+
batch_size,
|
|
382
|
+
max_gen_len,
|
|
383
|
+
use_sampling,
|
|
384
|
+
temperature,
|
|
385
|
+
cfg_coef,
|
|
386
|
+
no_eos_is_ok,
|
|
387
|
+
beam_size,
|
|
388
|
+
),
|
|
389
|
+
self._tokenizer._vocab,
|
|
390
|
+
self._instrument_for_program,
|
|
391
|
+
frame_rate=self._tokenizer.frame_rate,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if torch.cuda.is_available():
|
|
395
|
+
torch.cuda.synchronize()
|
|
396
|
+
print(
|
|
397
|
+
f"[muscriptor] generate total: {time.perf_counter() - t_gen:.2f}s",
|
|
398
|
+
file=sys.stderr,
|
|
399
|
+
)
|
|
400
|
+
print(
|
|
401
|
+
f"[muscriptor] transcribe total: {time.perf_counter() - t_total:.2f}s "
|
|
402
|
+
f"({total_duration:.1f}s audio)",
|
|
403
|
+
file=sys.stderr,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
def _generate_token_stream(
|
|
408
|
+
self,
|
|
409
|
+
all_conditions: list[ConditioningAttributes],
|
|
410
|
+
seek_times: list[float],
|
|
411
|
+
batch_size: int,
|
|
412
|
+
max_gen_len: int,
|
|
413
|
+
use_sampling: bool,
|
|
414
|
+
temperature: float,
|
|
415
|
+
cfg_coef: float,
|
|
416
|
+
no_eos_is_ok: bool,
|
|
417
|
+
beam_size: int = 1,
|
|
418
|
+
) -> Iterator[int | ChunkBoundary | ProgressEvent]:
|
|
419
|
+
"""Generate tokens and yield them per chunk, as soon as they are ready.
|
|
420
|
+
|
|
421
|
+
The model emits one token per chunk per timestep across the batch, but
|
|
422
|
+
the decoder consumes whole chunks in order. So within each batch we
|
|
423
|
+
stream the first chunk's tokens live as they are generated and buffer
|
|
424
|
+
the others; once the first chunk hits EOS we flush the next chunk's
|
|
425
|
+
buffered tokens and stream it live, and so on. EOS (and anything after
|
|
426
|
+
it) is dropped.
|
|
427
|
+
"""
|
|
428
|
+
eos_id = self._tokenizer.eos_id
|
|
429
|
+
num_chunks = len(seek_times)
|
|
430
|
+
|
|
431
|
+
def boundary(chunk_index: int) -> ChunkBoundary:
|
|
432
|
+
next_seek_time = (
|
|
433
|
+
seek_times[chunk_index + 1] if chunk_index + 1 < num_chunks else None
|
|
434
|
+
)
|
|
435
|
+
return ChunkBoundary(seek_times[chunk_index], next_seek_time)
|
|
436
|
+
|
|
437
|
+
for batch_start in range(0, num_chunks, batch_size):
|
|
438
|
+
batch_conditions = all_conditions[batch_start : batch_start + batch_size]
|
|
439
|
+
n = len(batch_conditions)
|
|
440
|
+
buffers: list[list[int]] = [[] for _ in range(n)]
|
|
441
|
+
done = [False] * n
|
|
442
|
+
active = 0 # within-batch index of the chunk streaming live
|
|
443
|
+
|
|
444
|
+
# The first chunk in the batch streams live from the start.
|
|
445
|
+
yield boundary(batch_start)
|
|
446
|
+
|
|
447
|
+
for step in self._model.generate(
|
|
448
|
+
conditions=batch_conditions,
|
|
449
|
+
max_gen_len=max_gen_len,
|
|
450
|
+
use_sampling=use_sampling,
|
|
451
|
+
temp=temperature,
|
|
452
|
+
top_k=0,
|
|
453
|
+
top_p=0.0,
|
|
454
|
+
cfg_coef=cfg_coef,
|
|
455
|
+
early_stop_on_token=eos_id,
|
|
456
|
+
beam_size=beam_size,
|
|
457
|
+
):
|
|
458
|
+
row = step.tolist() # one token per chunk: [n]
|
|
459
|
+
for j in range(n):
|
|
460
|
+
if done[j]:
|
|
461
|
+
continue
|
|
462
|
+
tok = row[j]
|
|
463
|
+
if tok == eos_id:
|
|
464
|
+
done[j] = True
|
|
465
|
+
elif j == active:
|
|
466
|
+
yield tok
|
|
467
|
+
else:
|
|
468
|
+
buffers[j].append(tok)
|
|
469
|
+
# When the live chunk finishes, flush and stream the next one(s).
|
|
470
|
+
while active < n and done[active]:
|
|
471
|
+
active += 1
|
|
472
|
+
if active < n:
|
|
473
|
+
yield boundary(batch_start + active)
|
|
474
|
+
yield from buffers[active]
|
|
475
|
+
buffers[active] = []
|
|
476
|
+
|
|
477
|
+
# Any chunk still open never emitted EOS within max_gen_len.
|
|
478
|
+
for j in range(active, n):
|
|
479
|
+
if not done[j]:
|
|
480
|
+
chunk_index = batch_start + j
|
|
481
|
+
msg = (
|
|
482
|
+
f"chunk {chunk_index} (seek={seek_times[chunk_index]:.1f}s) "
|
|
483
|
+
f"did not emit EOS within {max_gen_len} tokens"
|
|
484
|
+
)
|
|
485
|
+
if no_eos_is_ok:
|
|
486
|
+
warnings.warn(msg, RuntimeWarning, stacklevel=2)
|
|
487
|
+
else:
|
|
488
|
+
raise RuntimeError(
|
|
489
|
+
msg + " (this is only raised under --strict-eos)"
|
|
490
|
+
)
|
|
491
|
+
# The live (active) chunk has already streamed; emit the rest.
|
|
492
|
+
if j != active:
|
|
493
|
+
yield boundary(batch_start + j)
|
|
494
|
+
yield from buffers[j]
|
|
495
|
+
|
|
496
|
+
# This batch's chunks are fully generated: emit a completion anchor.
|
|
497
|
+
# (batch_size=1 on the web path => one event per chunk.) The event
|
|
498
|
+
# trails the chunk's tokens, so by the time it surfaces from
|
|
499
|
+
# decode_model_tokens all of that chunk's notes have been yielded.
|
|
500
|
+
yield ProgressEvent(completed=batch_start + n, total=num_chunks)
|
|
501
|
+
|
|
502
|
+
# ------------------------------------------------------------------
|
|
503
|
+
def transcribe_to_midi(
|
|
504
|
+
self,
|
|
505
|
+
audio: str | Path | tuple[torch.Tensor, int],
|
|
506
|
+
use_sampling: bool = False,
|
|
507
|
+
temperature: float = 1.0,
|
|
508
|
+
cfg_coef: float = 1.0,
|
|
509
|
+
instruments: list[str] | None = None,
|
|
510
|
+
batch_size: int | None = None,
|
|
511
|
+
no_eos_is_ok: bool = True,
|
|
512
|
+
beam_size: int = 1,
|
|
513
|
+
) -> bytes:
|
|
514
|
+
"""Same as :meth:`transcribe` but returns a MIDI file as bytes."""
|
|
515
|
+
events = self.transcribe(
|
|
516
|
+
audio,
|
|
517
|
+
use_sampling=use_sampling,
|
|
518
|
+
temperature=temperature,
|
|
519
|
+
cfg_coef=cfg_coef,
|
|
520
|
+
instruments=instruments,
|
|
521
|
+
batch_size=batch_size,
|
|
522
|
+
no_eos_is_ok=no_eos_is_ok,
|
|
523
|
+
beam_size=beam_size,
|
|
524
|
+
)
|
|
525
|
+
return self.events_to_midi_bytes(events)
|
|
526
|
+
|
|
527
|
+
def events_to_midi_bytes(
|
|
528
|
+
self, events: Iterator[NoteStartEvent | NoteEndEvent | ProgressEvent]
|
|
529
|
+
) -> bytes:
|
|
530
|
+
"""Reassemble Notes from a NoteStart/NoteEnd stream and serialize MIDI.
|
|
531
|
+
|
|
532
|
+
Shared by :meth:`transcribe_to_midi` and the HTTP server, so the MIDI
|
|
533
|
+
bytes are identical regardless of how the events were obtained.
|
|
534
|
+
"""
|
|
535
|
+
notes: list[Note] = []
|
|
536
|
+
open_notes: dict[int, Note] = {}
|
|
537
|
+
for ev in events:
|
|
538
|
+
if isinstance(ev, ProgressEvent):
|
|
539
|
+
continue
|
|
540
|
+
if isinstance(ev, NoteStartEvent):
|
|
541
|
+
is_drum = ev.instrument == "drums"
|
|
542
|
+
program = (
|
|
543
|
+
DRUM_PROGRAM
|
|
544
|
+
if is_drum
|
|
545
|
+
else self._program_for_instrument(ev.instrument)
|
|
546
|
+
)
|
|
547
|
+
note = Note(
|
|
548
|
+
is_drum=is_drum,
|
|
549
|
+
program=program,
|
|
550
|
+
onset=ev.start_time,
|
|
551
|
+
offset=ev.start_time, # patched on NoteEndEvent
|
|
552
|
+
pitch=ev.pitch,
|
|
553
|
+
)
|
|
554
|
+
open_notes[ev.index] = note
|
|
555
|
+
else: # NoteEndEvent
|
|
556
|
+
note = open_notes.pop(ev.start_event_index)
|
|
557
|
+
note.offset = ev.end_time
|
|
558
|
+
notes.append(note)
|
|
559
|
+
|
|
560
|
+
# Match the legacy decoder's note-cleanup pass so the MIDI bytes
|
|
561
|
+
# don't drift from earlier reference outputs.
|
|
562
|
+
notes = validate_notes(notes, fix=True)
|
|
563
|
+
notes = trim_overlapping_notes(notes, sort=True)
|
|
564
|
+
midi = notes_to_midi(notes)
|
|
565
|
+
buf = io.BytesIO()
|
|
566
|
+
midi.save(file=buf)
|
|
567
|
+
return buf.getvalue()
|
|
568
|
+
|
|
569
|
+
def _program_for_instrument(self, instrument: str) -> int:
|
|
570
|
+
"""Inverse of `_instrument_for_program` for non-drum instruments."""
|
|
571
|
+
if not hasattr(self, "_inst_to_program"):
|
|
572
|
+
group_map = self._tokenizer.group_program_map
|
|
573
|
+
self._inst_to_program = {
|
|
574
|
+
name: group_map[gid][0]
|
|
575
|
+
for name, gid in MT3_FULL_PLUS_GROUP_NAMES.items()
|
|
576
|
+
if gid in group_map and group_map[gid]
|
|
577
|
+
}
|
|
578
|
+
if instrument in self._inst_to_program:
|
|
579
|
+
return self._inst_to_program[instrument]
|
|
580
|
+
# fallback for unknown names like "program_42"
|
|
581
|
+
if instrument.startswith("program_"):
|
|
582
|
+
return int(instrument.removeprefix("program_"))
|
|
583
|
+
raise ValueError(f"Unknown instrument name: {instrument!r}")
|
|
584
|
+
|
|
585
|
+
# ------------------------------------------------------------------
|
|
586
|
+
def _load_wav(
|
|
587
|
+
self, audio: str | Path | torch.Tensor, sample_rate: int | None
|
|
588
|
+
) -> torch.Tensor:
|
|
589
|
+
"""Return mono float32 waveform at 16 kHz, shape [1, T]."""
|
|
590
|
+
if isinstance(audio, (str, Path)):
|
|
591
|
+
wav = load_audio(audio, target_sr=_SAMPLE_RATE)
|
|
592
|
+
else:
|
|
593
|
+
wav = audio.float()
|
|
594
|
+
if wav.dim() == 1:
|
|
595
|
+
wav = wav.unsqueeze(0)
|
|
596
|
+
if wav.dim() == 3:
|
|
597
|
+
wav = wav.squeeze(0)
|
|
598
|
+
if wav.shape[0] > 1:
|
|
599
|
+
wav = wav.mean(0, keepdim=True)
|
|
600
|
+
if sample_rate is not None and sample_rate != _SAMPLE_RATE:
|
|
601
|
+
wav = resample(wav, sample_rate, _SAMPLE_RATE)
|
|
602
|
+
return wav.to(self._device)
|
|
603
|
+
|
|
604
|
+
def _build_conditions(
|
|
605
|
+
self,
|
|
606
|
+
wav: torch.Tensor,
|
|
607
|
+
instrument_group: str | None = None,
|
|
608
|
+
) -> list[ConditioningAttributes]:
|
|
609
|
+
"""Build a single-element list of ConditioningAttributes for one 5-second chunk."""
|
|
610
|
+
T = wav.shape[-1]
|
|
611
|
+
wav_3d = wav.unsqueeze(0) # [1, 1, T]
|
|
612
|
+
length = torch.tensor([T], device=self._device)
|
|
613
|
+
wav_cond = WavCondition(
|
|
614
|
+
wav=wav_3d,
|
|
615
|
+
length=length,
|
|
616
|
+
sample_rate=[_SAMPLE_RATE],
|
|
617
|
+
path=[None],
|
|
618
|
+
seek_time=[0.0],
|
|
619
|
+
)
|
|
620
|
+
return [
|
|
621
|
+
ConditioningAttributes(
|
|
622
|
+
wav={"self_wav": wav_cond},
|
|
623
|
+
text={
|
|
624
|
+
"instrument_group": instrument_group,
|
|
625
|
+
# Always unconditional on dataset: the null/pad class.
|
|
626
|
+
"dataset_name": None,
|
|
627
|
+
},
|
|
628
|
+
)
|
|
629
|
+
]
|
|
File without changes
|