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.
@@ -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