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,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)
|
muscriptor/utils/midi.py
ADDED
|
@@ -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
|
+
)
|