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,112 @@
1
+ """Audio loading and resampling utilities. WAV is handled by the stdlib;
2
+ other formats fall back to `soundfile`."""
3
+
4
+ import wave
5
+ from pathlib import Path
6
+ from typing import IO
7
+
8
+ import numpy as np
9
+ import torch
10
+
11
+ from muscriptor.utils.resample import resample_frac
12
+
13
+
14
+ def _read_wav_file(source) -> tuple[torch.Tensor, int]:
15
+ """Load a PCM WAV file using the stdlib `wave` module.
16
+
17
+ `source` may be a filesystem path or a binary file-like object.
18
+
19
+ Returns:
20
+ (wav, sr) where wav has shape [C, T] and is float32 in [-1, 1].
21
+ """
22
+ if hasattr(source, "read"):
23
+ opened = wave.open(source, "rb")
24
+ else:
25
+ opened = wave.open(str(source), "rb")
26
+ with opened as wf:
27
+ n_channels = wf.getnchannels()
28
+ sr = wf.getframerate()
29
+ sampwidth = wf.getsampwidth()
30
+ n_frames = wf.getnframes()
31
+ raw = wf.readframes(n_frames)
32
+
33
+ if sampwidth == 1:
34
+ data = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
35
+ data = (data - 128.0) / 128.0
36
+ elif sampwidth == 2:
37
+ data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
38
+ elif sampwidth == 3:
39
+ bytes_ = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3)
40
+ as_int32 = (
41
+ bytes_[:, 0].astype(np.int32)
42
+ | (bytes_[:, 1].astype(np.int32) << 8)
43
+ | (bytes_[:, 2].astype(np.int32) << 16)
44
+ )
45
+ as_int32 = np.where(as_int32 >= (1 << 23), as_int32 - (1 << 24), as_int32)
46
+ data = as_int32.astype(np.float32) / float(1 << 23)
47
+ elif sampwidth == 4:
48
+ data = np.frombuffer(raw, dtype=np.int32).astype(np.float32) / float(1 << 31)
49
+ else:
50
+ raise ValueError(f"Unsupported WAV sample width: {sampwidth} bytes")
51
+
52
+ data = data.reshape(-1, n_channels)
53
+ return torch.from_numpy(np.ascontiguousarray(data.T)), sr
54
+
55
+
56
+ def _read_non_wav_file(source: str | Path | IO[bytes]) -> tuple[torch.Tensor, int]:
57
+ """Load a non-WAV audio file using `soundfile`.
58
+
59
+ `source` may be a filesystem path or a binary file-like object (e.g. an
60
+ ``io.BytesIO`` of an uploaded file), since libsndfile reads either.
61
+
62
+ Returns:
63
+ (wav, sr) where wav has shape [C, T] and is float32 in [-1, 1].
64
+ """
65
+ try:
66
+ import soundfile as sf
67
+ except ImportError as e:
68
+ raise ImportError(
69
+ "soundfile is required to read non-WAV audio files. "
70
+ "Install with: `pip install soundfile` or `uvx --with soundfile`"
71
+ ) from e
72
+
73
+ target = str(source) if isinstance(source, (str, Path)) else source
74
+ data, sample_rate = sf.read(target, dtype="float32")
75
+ if data.ndim == 1:
76
+ data = data[:, None]
77
+ wav = torch.from_numpy(np.ascontiguousarray(data.T))
78
+ return wav, sample_rate
79
+
80
+
81
+ def resample(
82
+ waveform: torch.Tensor,
83
+ orig_freq: int,
84
+ new_freq: int,
85
+ ) -> torch.Tensor:
86
+ """Sinc resampler via julius `resample_frac`. Operates along the last dim."""
87
+ if orig_freq == new_freq:
88
+ return waveform
89
+ return resample_frac(waveform, int(orig_freq), int(new_freq))
90
+
91
+
92
+ def load_audio(path: str | Path, target_sr: int = 16000) -> torch.Tensor:
93
+ """Load an audio file and return a mono float32 tensor at target_sr.
94
+
95
+ PCM WAV files are read with the stdlib `wave` module. Other formats (mp3,
96
+ flac, ogg, m4a, …) are decoded via `soundfile`. Dispatch is by content, not
97
+ file extension, so misnamed files (e.g. an MP3 upload saved as .wav) still
98
+ load.
99
+
100
+ Returns:
101
+ Tensor of shape [1, T] at target_sr.
102
+ """
103
+ filepath = Path(path)
104
+ try:
105
+ wav, sr = _read_wav_file(str(filepath))
106
+ except (wave.Error, EOFError):
107
+ wav, sr = _read_non_wav_file(str(filepath))
108
+ if wav.shape[0] > 1:
109
+ wav = wav.mean(dim=0, keepdim=True)
110
+ if sr != target_sr:
111
+ wav = resample(wav, sr, target_sr)
112
+ return wav
@@ -0,0 +1,145 @@
1
+ """FluidSynth-based MIDI auralization.
2
+
3
+ Synthesizes a MIDI file with FluidSynth and blends the result with the
4
+ original audio into a stereo mix (L = original, R = synthesis).
5
+
6
+ Requires:
7
+ - fluidsynth on the system PATH
8
+ - soundfile Python package (already a muscriptor dependency)
9
+ """
10
+
11
+ import os
12
+ import subprocess
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import soundfile as sf
18
+
19
+ from muscriptor.utils.audio import load_audio
20
+
21
+ _DEFAULT_SOUNDFONT = Path(__file__).parent.parent.parent / "MuseScore_General.sf2"
22
+ _SAMPLE_RATE = 44100
23
+
24
+
25
+ def _load_mono_44k(path: Path) -> np.ndarray:
26
+ """Return a mono float32 numpy array at 44100 Hz for any audio file."""
27
+ wav = load_audio(str(path), target_sr=_SAMPLE_RATE) # [1, T]
28
+ return wav[0].numpy()
29
+
30
+
31
+ def _resolve_soundfont(soundfont_path: str | Path | None) -> Path:
32
+ soundfont_path = Path(soundfont_path) if soundfont_path else _DEFAULT_SOUNDFONT
33
+ if not soundfont_path.exists():
34
+ raise FileNotFoundError(
35
+ f"SoundFont not found: {soundfont_path}\n"
36
+ "Pass --soundfont /path/to/file.sf2 or ensure the bundled "
37
+ "MuseScore_General.sf2 is present in the muscriptor project root."
38
+ )
39
+ return soundfont_path
40
+
41
+
42
+ def _synthesize_midi(midi_path: Path, soundfont_path: Path) -> np.ndarray:
43
+ """Render a MIDI file with FluidSynth → mono float32 array at 44100 Hz.
44
+
45
+ Raises:
46
+ RuntimeError: If fluidsynth is not available or fails.
47
+ """
48
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
49
+ synth_tmp = tmp.name
50
+ try:
51
+ # Options must precede the positional soundfont/MIDI arguments:
52
+ # fluidsynth >= 2.5 silently ignores trailing options (exit 0, no
53
+ # output file written).
54
+ result = subprocess.run(
55
+ [
56
+ "fluidsynth", "-ni",
57
+ "-F", synth_tmp,
58
+ "-r", str(_SAMPLE_RATE),
59
+ str(soundfont_path), str(midi_path),
60
+ ],
61
+ capture_output=True,
62
+ )
63
+ if result.returncode != 0:
64
+ raise RuntimeError(
65
+ f"fluidsynth failed (exit {result.returncode}).\n"
66
+ "Ensure fluidsynth is installed and the SoundFont path is correct.\n"
67
+ f"stderr: {result.stderr.decode(errors='replace')}"
68
+ )
69
+ synth_audio, _ = sf.read(synth_tmp, dtype="float32")
70
+ if synth_audio.ndim > 1:
71
+ synth_audio = synth_audio.mean(axis=1)
72
+ return synth_audio
73
+ finally:
74
+ if os.path.exists(synth_tmp):
75
+ os.remove(synth_tmp)
76
+
77
+
78
+ def synthesize(
79
+ midi_path: str | Path,
80
+ output_path: str | Path,
81
+ soundfont_path: str | Path | None = None,
82
+ ) -> None:
83
+ """Render just the transcription: MIDI → mono WAV via FluidSynth.
84
+
85
+ Args:
86
+ midi_path: Path to the MIDI file to synthesize.
87
+ output_path: Destination WAV file path.
88
+ soundfont_path: Path to a ``.sf2`` SoundFont file.
89
+ Defaults to the bundled MuseScore_General.sf2.
90
+
91
+ Raises:
92
+ RuntimeError: If fluidsynth is not available or fails.
93
+ FileNotFoundError: If the SoundFont file is not found.
94
+ """
95
+ soundfont = _resolve_soundfont(soundfont_path)
96
+ synth_audio = _synthesize_midi(Path(midi_path), soundfont)
97
+ sf.write(str(output_path), synth_audio, _SAMPLE_RATE)
98
+
99
+
100
+ def auralize(
101
+ midi_path: str | Path,
102
+ original_audio_path: str | Path,
103
+ output_path: str | Path,
104
+ soundfont_path: str | Path | None = None,
105
+ ) -> None:
106
+ """Create a stereo auralization of a transcription.
107
+
108
+ Left channel: original audio
109
+ Right channel: FluidSynth MIDI synthesis (RMS-matched to original)
110
+
111
+ Args:
112
+ midi_path: Path to the MIDI file to synthesize.
113
+ original_audio_path: Path to the source audio file (any format soundfile supports).
114
+ output_path: Destination WAV file path.
115
+ soundfont_path: Path to a ``.sf2`` SoundFont file.
116
+ Defaults to the bundled MuseScore_General.sf2.
117
+
118
+ Raises:
119
+ RuntimeError: If fluidsynth is not available or fails.
120
+ FileNotFoundError: If the SoundFont file is not found.
121
+ """
122
+ original_audio_path = Path(original_audio_path)
123
+ output_path = Path(output_path)
124
+ soundfont = _resolve_soundfont(soundfont_path)
125
+
126
+ # 1. Synthesize MIDI via FluidSynth
127
+ synth_audio = _synthesize_midi(Path(midi_path), soundfont)
128
+
129
+ # 2. Load original audio at 44100 Hz mono
130
+ original_audio = _load_mono_44k(original_audio_path)
131
+
132
+ # 3. Pad both to the same length
133
+ length = max(len(original_audio), len(synth_audio))
134
+ original_audio = np.pad(original_audio, (0, length - len(original_audio)))
135
+ synth_audio = np.pad(synth_audio, (0, length - len(synth_audio)))
136
+
137
+ # 4. RMS-normalize synthesis to match the original's loudness
138
+ rms_orig = np.sqrt(np.mean(original_audio ** 2))
139
+ rms_synth = np.sqrt(np.mean(synth_audio ** 2))
140
+ if rms_synth > 1e-8:
141
+ synth_audio = synth_audio * (rms_orig / rms_synth)
142
+
143
+ # 5. Assemble stereo array [T, 2] and write WAV
144
+ stereo = np.stack([original_audio, synth_audio], axis=1)
145
+ sf.write(str(output_path), stereo, _SAMPLE_RATE)
@@ -0,0 +1,68 @@
1
+ """Weight download utility with caching in ~/.cache/muscriptor/."""
2
+
3
+ import hashlib
4
+ import urllib.request
5
+ from pathlib import Path
6
+ from huggingface_hub import hf_hub_download
7
+ from huggingface_hub.errors import HfHubHTTPError
8
+ from huggingface_hub.utils import EntryNotFoundError
9
+
10
+
11
+ _CACHE_DIR = Path.home() / ".cache" / "muscriptor"
12
+
13
+
14
+ def download_if_necessary(url: str | Path) -> Path:
15
+ """Resolve a weights location to a local file, downloading if necessary.
16
+
17
+ Args:
18
+ url: Where to find the weights:
19
+ - ``hf://<repo_id>/<path/in/repo>`` — downloaded via huggingface_hub.
20
+ - ``http(s)://…`` — fetched with a plain HTTP GET and cached under
21
+ the cache dir (filename prefixed with a hash of the URL).
22
+ - anything else (a local path, as ``str`` or ``Path``) — used as-is;
23
+ nothing is downloaded, but the file must already exist.
24
+
25
+ Returns:
26
+ Path to the local file.
27
+ """
28
+ if isinstance(url, str) and url.startswith("hf://"):
29
+ org, name, hf_filename = url[len("hf://") :].split("/", 2)
30
+ cached = hf_hub_download(repo_id=f"{org}/{name}", filename=hf_filename)
31
+ return Path(cached)
32
+
33
+ if isinstance(url, str) and url.startswith(("http://", "https://")):
34
+ # Prefix the cache filename with a hash of the URL so two different URLs
35
+ # that share a filename don't map to the same file.
36
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
37
+ filename = url.split("/")[-1].split("?")[0]
38
+ url_hash = hashlib.sha256(url.encode()).hexdigest()[:8]
39
+ dest = _CACHE_DIR / f"{url_hash}_{filename}"
40
+ if dest.exists():
41
+ return dest
42
+ print(f"Downloading {filename} …")
43
+ urllib.request.urlretrieve(url, dest)
44
+ return dest
45
+
46
+ # Local file — nothing to download, just check it's there.
47
+ path = Path(url)
48
+ if not path.exists():
49
+ raise FileNotFoundError(f"weights file not found: {path}")
50
+ return path
51
+
52
+
53
+ def download_companion(url: str | Path, filename: str) -> Path | None:
54
+ """Best-effort fetch of a sibling file from the same ``hf://`` repo.
55
+
56
+ Used to grab a model's ``config.json`` next to its weights. Returns the
57
+ local path, or ``None`` if ``url`` isn't an ``hf://`` URL or the file can't
58
+ be fetched — repo/file missing, gated, or offline (so callers can fall back
59
+ to other detection schemes rather than failing the whole load).
60
+ """
61
+ if not (isinstance(url, str) and url.startswith("hf://")):
62
+ return None
63
+ org, name, _ = url[len("hf://") :].split("/", 2)
64
+ try:
65
+ cached = hf_hub_download(repo_id=f"{org}/{name}", filename=filename)
66
+ except (EntryNotFoundError, HfHubHTTPError):
67
+ return None
68
+ return Path(cached)
@@ -0,0 +1,25 @@
1
+ """MIDI output utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+ from muscriptor.tokenizer.notes import Note, note2note_event, note_event2midi
6
+
7
+
8
+ def notes_to_midi(notes: list[Note], velocity: int = 100, tempo_bpm: int = 120):
9
+ """Convert a list of Note objects to a mido MidiFile."""
10
+ note_events = note2note_event(notes)
11
+ tempo_us = int(60_000_000 / tempo_bpm)
12
+ return note_event2midi(
13
+ note_events, output_file=None, velocity=velocity, tempo=tempo_us
14
+ )
15
+
16
+
17
+ def save_midi(
18
+ notes: list[Note],
19
+ path: str | Path,
20
+ velocity: int = 100,
21
+ tempo_bpm: int = 120,
22
+ ) -> None:
23
+ """Save a list of Note objects as a MIDI file."""
24
+ midi = notes_to_midi(notes, velocity=velocity, tempo_bpm=tempo_bpm)
25
+ midi.save(str(path))
@@ -0,0 +1,191 @@
1
+ # File under the MIT license, see https://github.com/adefossez/julius/LICENSE for details.
2
+ # Author: adefossez, 2020
3
+ """
4
+ Differentiable, Pytorch based resampling.
5
+ Implementation of Julius O. Smith algorithm for resampling.
6
+ See https://ccrma.stanford.edu/~jos/resample/ for details.
7
+ This implementation is specially optimized for when new_sr / old_sr is a fraction
8
+ with a small numerator and denominator when removing the gcd (e.g. new_sr = 700, old_sr = 500).
9
+
10
+ Very similar to [bmcfee/resampy](https://github.com/bmcfee/resampy) except this implementation
11
+ is optimized for the case mentioned before, while resampy is slower but more general.
12
+
13
+ """
14
+
15
+ import math
16
+ from typing import Optional
17
+
18
+ import torch
19
+ from torch.nn import functional as F
20
+
21
+
22
+ def sinc(x: torch.Tensor) -> torch.Tensor:
23
+ """sin(x) / x, with the limit value 1 at x == 0.
24
+
25
+ __Warning__: the input is not multiplied by `pi`!
26
+ """
27
+ return torch.where(
28
+ x == 0,
29
+ torch.tensor(1.0, device=x.device, dtype=x.dtype),
30
+ torch.sin(x) / x,
31
+ )
32
+
33
+
34
+ class ResampleFrac(torch.nn.Module):
35
+ """
36
+ Resampling from the sample rate `old_sr` to `new_sr`.
37
+ """
38
+
39
+ def __init__(
40
+ self, old_sr: int, new_sr: int, zeros: int = 24, rolloff: float = 0.945
41
+ ):
42
+ """
43
+ Args:
44
+ old_sr (int): sample rate of the input signal x.
45
+ new_sr (int): sample rate of the output.
46
+ zeros (int): number of zero crossing to keep in the sinc filter.
47
+ rolloff (float): use a lowpass filter that is `rolloff * new_sr / 2`,
48
+ to ensure sufficient margin due to the imperfection of the FIR filter used.
49
+ Lowering this value will reduce anti-aliasing, but will reduce some of the
50
+ highest frequencies.
51
+
52
+ Shape:
53
+
54
+ - Input: `[*, T]`
55
+ - Output: `[*, T']` with `T' = int(new_sr * T / old_sr)
56
+
57
+
58
+ .. caution::
59
+ After dividing `old_sr` and `new_sr` by their GCD, both should be small
60
+ for this implementation to be fast.
61
+
62
+ >>> import torch
63
+ >>> resample = ResampleFrac(4, 5)
64
+ >>> x = torch.randn(1000)
65
+ >>> print(len(resample(x)))
66
+ 1250
67
+ """
68
+ super().__init__()
69
+ if not isinstance(old_sr, int) or not isinstance(new_sr, int):
70
+ raise ValueError("old_sr and new_sr should be integers")
71
+ gcd = math.gcd(old_sr, new_sr)
72
+ self.old_sr = old_sr // gcd
73
+ self.new_sr = new_sr // gcd
74
+ self.zeros = zeros
75
+ self.rolloff = rolloff
76
+
77
+ self._init_kernels()
78
+
79
+ def _init_kernels(self):
80
+ if self.old_sr == self.new_sr:
81
+ return
82
+
83
+ kernels = []
84
+ sr = min(self.new_sr, self.old_sr)
85
+ # rolloff will perform antialiasing filtering by removing the highest frequencies.
86
+ # At first I thought I only needed this when downsampling, but when upsampling
87
+ # you will get edge artifacts without this, the edge is equivalent to zero padding,
88
+ # which will add high freq artifacts.
89
+ sr *= self.rolloff
90
+
91
+ # The key idea of the algorithm is that x(t) can be exactly reconstructed from x[i] (tensor)
92
+ # using the sinc interpolation formula:
93
+ # x(t) = sum_i x[i] sinc(pi * old_sr * (i / old_sr - t))
94
+ # We can then sample the function x(t) with a different sample rate:
95
+ # y[j] = x(j / new_sr)
96
+ # or,
97
+ # y[j] = sum_i x[i] sinc(pi * old_sr * (i / old_sr - j / new_sr))
98
+
99
+ # We see here that y[j] is the convolution of x[i] with a specific filter, for which
100
+ # we take an FIR approximation, stopping when we see at least `zeros` zeros crossing.
101
+ # But y[j+1] is going to have a different set of weights and so on, until y[j + new_sr].
102
+ # Indeed:
103
+ # y[j + new_sr] = sum_i x[i] sinc(pi * old_sr * ((i / old_sr - (j + new_sr) / new_sr))
104
+ # = sum_i x[i] sinc(pi * old_sr * ((i - old_sr) / old_sr - j / new_sr))
105
+ # = sum_i x[i + old_sr] sinc(pi * old_sr * (i / old_sr - j / new_sr))
106
+ # so y[j+new_sr] uses the same filter as y[j], but on a shifted version of x by `old_sr`.
107
+ # This will explain the F.conv1d after, with a stride of old_sr.
108
+ self._width = math.ceil(self.zeros * self.old_sr / sr)
109
+ # If old_sr is still big after GCD reduction, most filters will be very unbalanced, i.e.,
110
+ # they will have a lot of almost zero values to the left or to the right...
111
+ # There is probably a way to evaluate those filters more efficiently, but this is kept for
112
+ # future work.
113
+ idx = torch.arange(-self._width, self._width + self.old_sr).float()
114
+ for i in range(self.new_sr):
115
+ t = (-i / self.new_sr + idx / self.old_sr) * sr
116
+ t = t.clamp_(-self.zeros, self.zeros)
117
+ t *= math.pi
118
+ window = torch.cos(t / self.zeros / 2) ** 2
119
+ kernel = sinc(t) * window
120
+ # Renormalize kernel to ensure a constant signal is preserved.
121
+ kernel.div_(kernel.sum())
122
+ kernels.append(kernel)
123
+
124
+ self.register_buffer("kernel", torch.stack(kernels).view(self.new_sr, 1, -1))
125
+
126
+ def forward(
127
+ self, x: torch.Tensor, output_length: Optional[int] = None, full: bool = False
128
+ ):
129
+ """
130
+ Resample x.
131
+ Args:
132
+ x (Tensor): signal to resample, time should be the last dimension
133
+ output_length (None or int): This can be set to the desired output length
134
+ (last dimension). Allowed values are between 0 and
135
+ ceil(length * new_sr / old_sr). When None (default) is specified, the
136
+ floored output length will be used. In order to select the largest possible
137
+ size, use the `full` argument.
138
+ full (bool): return the longest possible output from the input. This can be useful
139
+ if you chain resampling operations, and want to give the `output_length` only
140
+ for the last one, while passing `full=True` to all the other ones.
141
+ """
142
+ if self.old_sr == self.new_sr:
143
+ return x
144
+ shape = x.shape
145
+ length = x.shape[-1]
146
+ x = x.reshape(-1, length)
147
+ x = F.pad(
148
+ x[:, None], (self._width, self._width + self.old_sr), mode="replicate"
149
+ )
150
+ ys = F.conv1d(x, self.kernel, stride=self.old_sr) # type: ignore
151
+ y = ys.transpose(1, 2).reshape(list(shape[:-1]) + [-1])
152
+
153
+ float_output_length = torch.as_tensor(self.new_sr * length / self.old_sr)
154
+ max_output_length = torch.ceil(float_output_length).long()
155
+ default_output_length = torch.floor(float_output_length).long()
156
+
157
+ if output_length is None:
158
+ applied_output_length = max_output_length if full else default_output_length
159
+ elif output_length < 0 or output_length > max_output_length:
160
+ raise ValueError(f"output_length must be between 0 and {max_output_length}")
161
+ else:
162
+ applied_output_length = torch.tensor(output_length)
163
+ if full:
164
+ raise ValueError("You cannot pass both full=True and output_length")
165
+ return y[..., :applied_output_length] # type: ignore
166
+
167
+ def __repr__(self):
168
+ return (
169
+ f"ResampleFrac(old_sr={self.old_sr}, new_sr={self.new_sr}, "
170
+ f"zeros={self.zeros}, rolloff={self.rolloff})"
171
+ )
172
+
173
+
174
+ def resample_frac(
175
+ x: torch.Tensor,
176
+ old_sr: int,
177
+ new_sr: int,
178
+ zeros: int = 24,
179
+ rolloff: float = 0.945,
180
+ output_length: Optional[int] = None,
181
+ full: bool = False,
182
+ ):
183
+ """
184
+ Functional version of `ResampleFrac`, refer to its documentation for more information.
185
+
186
+ ..warning::
187
+ If you call repeatidly this functions with the same sample rates, then the
188
+ resampling kernel will be recomputed everytime. For best performance, you should use
189
+ and cache an instance of `ResampleFrac`.
190
+ """
191
+ return ResampleFrac(old_sr, new_sr, zeros, rolloff).to(x)(x, output_length, full)
@@ -0,0 +1,89 @@
1
+ """Sampling utilities for autoregressive generation."""
2
+
3
+ import torch
4
+
5
+
6
+ def length_to_mask(lengths: torch.Tensor, max_len: int | None = None) -> torch.Tensor:
7
+ """Convert a tensor of sequence lengths to a boolean mask."""
8
+ assert len(lengths.shape) == 1
9
+ final_length = int(lengths.max().item()) if not max_len else max_len
10
+ final_length = max(final_length, 1)
11
+ return torch.arange(final_length, device=lengths.device)[None, :] < lengths[:, None]
12
+
13
+
14
+ def multinomial(
15
+ input: torch.Tensor, num_samples: int, replacement: bool = False, *, generator=None
16
+ ) -> torch.Tensor:
17
+ """torch.multinomial with arbitrary number of dimensions."""
18
+ input_ = input.reshape(-1, input.shape[-1])
19
+ output_ = torch.multinomial(
20
+ input_, num_samples=num_samples, replacement=replacement, generator=generator
21
+ )
22
+ output = output_.reshape(*list(input.shape[:-1]), -1)
23
+ return output
24
+
25
+
26
+ def sample_top_k(probs: torch.Tensor, k: int, num_samples: int = 1) -> torch.Tensor:
27
+ """Sample from top-k probabilities."""
28
+ top_k_value, _ = torch.topk(probs, k, dim=-1)
29
+ min_value_top_k = top_k_value[..., [-1]]
30
+ probs = probs * (probs >= min_value_top_k).float()
31
+ probs = probs / probs.sum(dim=-1, keepdim=True)
32
+ return multinomial(probs, num_samples=num_samples)
33
+
34
+
35
+ def sample_top_p(probs: torch.Tensor, p: float, num_samples: int = 1) -> torch.Tensor:
36
+ """Sample from nucleus (top-p) distribution."""
37
+ probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
38
+ probs_sum = torch.cumsum(probs_sort, dim=-1)
39
+ mask = probs_sum - probs_sort > p
40
+ probs_sort = probs_sort * (~mask).float()
41
+ probs_sort = probs_sort / probs_sort.sum(dim=-1, keepdim=True)
42
+ next_token = multinomial(probs_sort, num_samples=num_samples)
43
+ return torch.gather(probs_idx, -1, next_token)
44
+
45
+
46
+ def sample_from_probs(
47
+ probs: torch.Tensor, top_p: float = 0.0, top_k: int = 0
48
+ ) -> torch.Tensor:
49
+ """Sample one token from probs, optionally filtered by top-p or top-k."""
50
+ if top_p > 0.0:
51
+ return sample_top_p(probs, top_p)
52
+ if top_k > 0:
53
+ return sample_top_k(probs, top_k)
54
+ return multinomial(probs, num_samples=1)
55
+
56
+
57
+ def sample_stratified(
58
+ probs: torch.Tensor,
59
+ special_token: int,
60
+ first_temp: float,
61
+ second_temp: float = 1.0,
62
+ top_p: float = 0.0,
63
+ top_k: int = 0,
64
+ ) -> torch.Tensor:
65
+ """Stratified sampling: first decide special vs. non-special, then sample among non-special."""
66
+ eps = 1e-12
67
+ probs_special = probs[..., special_token : special_token + 1].clamp(
68
+ min=eps, max=1 - eps
69
+ )
70
+ logits_two = torch.cat(
71
+ [torch.log(probs_special), torch.log(1 - probs_special)], dim=-1
72
+ )
73
+ logits_two = logits_two / max(first_temp, eps)
74
+ probs_two = torch.softmax(logits_two, dim=-1)
75
+ probs_special_temp = probs_two[..., 0:1]
76
+ next_token_is_special = torch.rand_like(probs_special_temp).lt(probs_special_temp)
77
+
78
+ denom = (1 - probs_special).clamp(min=eps)
79
+ new_probs = probs.clone() / denom
80
+ new_probs[..., special_token] = 0.0
81
+ if second_temp > 0:
82
+ log_new = torch.log(new_probs.clamp(min=eps)) / second_temp
83
+ new_probs = torch.softmax(log_new, dim=-1)
84
+
85
+ next_token = sample_from_probs(new_probs, top_p=top_p, top_k=top_k)
86
+
87
+ return torch.where(
88
+ next_token_is_special, torch.full_like(next_token, special_token), next_token
89
+ )