gamemlx 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.
- gamemlx/__init__.py +73 -0
- gamemlx/cli.py +199 -0
- gamemlx/config.py +113 -0
- gamemlx/d3pm.py +43 -0
- gamemlx/decoding.py +60 -0
- gamemlx/download.py +103 -0
- gamemlx/features.py +62 -0
- gamemlx/functional.py +48 -0
- gamemlx/model.py +231 -0
- gamemlx/modules.py +769 -0
- gamemlx/slicer.py +116 -0
- gamemlx/utils.py +27 -0
- gamemlx-0.1.0.dist-info/METADATA +84 -0
- gamemlx-0.1.0.dist-info/RECORD +16 -0
- gamemlx-0.1.0.dist-info/WHEEL +4 -0
- gamemlx-0.1.0.dist-info/entry_points.txt +2 -0
gamemlx/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""gamemlx: pure-MLX inference package for GAME (vocal MIDI extraction)."""
|
|
2
|
+
from .config import GamemlxConfig, load_config
|
|
3
|
+
from .model import GAMEModel, load_model as _load_model_local
|
|
4
|
+
|
|
5
|
+
__all__ = ["GAMEModel", "load_model", "load_config", "GamemlxConfig", "extract"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_model(name_or_path: str = "medium", precision: str = "fp32"):
|
|
9
|
+
"""Load a model by alias (small/medium/large), HF repo id, or local path.
|
|
10
|
+
|
|
11
|
+
Aliases download from HuggingFace (ModelScope fallback) into
|
|
12
|
+
~/.cache/gamemlx on first use. Returns (GAMEModel, lang_map).
|
|
13
|
+
"""
|
|
14
|
+
from .config import load_lang_map
|
|
15
|
+
from .download import download_model
|
|
16
|
+
model_dir = download_model(name_or_path, precision="both")
|
|
17
|
+
model = _load_model_local(model_dir, precision=precision)
|
|
18
|
+
lang_map = load_lang_map(model_dir)
|
|
19
|
+
return model, lang_map
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract(model, path, language=None, output_dir=None, tempo=120.0,
|
|
23
|
+
seg_threshold=0.2, seg_radius=0.02, est_threshold=0.2,
|
|
24
|
+
t0=0.0, nsteps=8, rng_seed=None):
|
|
25
|
+
"""High-level API: extract MIDI from a file or directory.
|
|
26
|
+
|
|
27
|
+
`model` is a GAMEModel (see load_model). Mirrors gamemlx.cli extract.
|
|
28
|
+
"""
|
|
29
|
+
import pathlib
|
|
30
|
+
|
|
31
|
+
import mlx.core as mx
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from .cli import _MidiWriter, _parse_filemap
|
|
35
|
+
from .config import load_lang_map
|
|
36
|
+
from .download import CACHE_DIR
|
|
37
|
+
from .slicer import Slicer
|
|
38
|
+
from .utils import get_language_id, load_audio
|
|
39
|
+
|
|
40
|
+
path = pathlib.Path(path)
|
|
41
|
+
filemap = _parse_filemap(path, {".wav", ".flac", ".mp3", ".aac", ".ogg"})
|
|
42
|
+
if output_dir is None:
|
|
43
|
+
output_dir = path if path.is_dir() else path.parent
|
|
44
|
+
output_dir = pathlib.Path(output_dir)
|
|
45
|
+
|
|
46
|
+
sr = model.config.features.audio_sample_rate
|
|
47
|
+
slicer = Slicer(sr=sr, threshold=-40., min_length=1000, min_interval=200,
|
|
48
|
+
max_sil_kept=100)
|
|
49
|
+
radius_frames = round(seg_radius / model.timestep)
|
|
50
|
+
ts = mx.array([t0 + i * (1 - t0) / nsteps for i in range(nsteps)],
|
|
51
|
+
dtype=mx.float32)
|
|
52
|
+
|
|
53
|
+
writer = _MidiWriter(output_dir, tempo)
|
|
54
|
+
for key, filepath in filemap.items():
|
|
55
|
+
lang_map = None # language resolved by caller knowledge; default id 0
|
|
56
|
+
language_id = get_language_id(language, lang_map if language else None)
|
|
57
|
+
wav = load_audio(filepath, sr=sr)
|
|
58
|
+
for chunk in slicer.slice(wav):
|
|
59
|
+
cw = np.asarray(chunk["waveform"], dtype=np.float32)
|
|
60
|
+
duration = cw.shape[0] / sr
|
|
61
|
+
rng = np.random.default_rng(rng_seed) if rng_seed is not None else None
|
|
62
|
+
dur, pres, sc = model(
|
|
63
|
+
mx.array(cw[None, :]), mx.array([[duration]], dtype=mx.float32),
|
|
64
|
+
boundary_threshold=seg_threshold, boundary_radius=radius_frames,
|
|
65
|
+
score_threshold=est_threshold, language=mx.array([language_id]),
|
|
66
|
+
t=ts, rng_matrices=rng,
|
|
67
|
+
)
|
|
68
|
+
mx.eval(dur, pres, sc)
|
|
69
|
+
writer.add_chunk(key, chunk["offset"], duration,
|
|
70
|
+
np.asarray(dur[0]), np.asarray(sc[0]),
|
|
71
|
+
np.asarray(pres[0]))
|
|
72
|
+
outs = [writer.flush(key) for key in list(filemap.keys())]
|
|
73
|
+
return outs
|
gamemlx/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""gamemlx CLI: extract MIDI from audio, mirroring GAME/infer.py extract."""
|
|
2
|
+
import glob
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .config import load_lang_map
|
|
9
|
+
from .download import download_model
|
|
10
|
+
from .model import load_model
|
|
11
|
+
from .slicer import Slicer
|
|
12
|
+
from .utils import get_language_id, load_audio
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate_exts(ctx, param, value):
|
|
16
|
+
return {"." + ext.strip().lower() for ext in value.split(",")}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_output_formats(ctx, param, value):
|
|
20
|
+
formats = {fmt.strip().lower() for fmt in value.split(",")}
|
|
21
|
+
supported = {"mid", "txt", "csv"}
|
|
22
|
+
if not formats.issubset(supported):
|
|
23
|
+
raise click.BadParameter(f"Unsupported formats: {formats - supported}.")
|
|
24
|
+
return formats
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_filemap(path: pathlib.Path, exts: set) -> dict[str, pathlib.Path]:
|
|
28
|
+
if path.is_file():
|
|
29
|
+
return {path.name: path}
|
|
30
|
+
files = [f for f in path.rglob("*") if f.is_file() and f.suffix.lower() in exts]
|
|
31
|
+
filemap = {f.relative_to(path).as_posix(): f for f in files}
|
|
32
|
+
if not filemap:
|
|
33
|
+
raise FileNotFoundError(f"No audio files found in directory: {path}")
|
|
34
|
+
return filemap
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _MidiWriter:
|
|
38
|
+
"""Equivalent of GAME SaveCombinedMidiFileCallback (aggregation + sorting)."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, output_dir: pathlib.Path, tempo: float):
|
|
41
|
+
self.output_dir = output_dir
|
|
42
|
+
self.tempo = tempo
|
|
43
|
+
self.notes: dict[str, list] = {}
|
|
44
|
+
|
|
45
|
+
def add_chunk(self, key, offset, length, durations, scores, presence):
|
|
46
|
+
import mlx.core as mx
|
|
47
|
+
import numpy as np
|
|
48
|
+
d = np.asarray(durations)
|
|
49
|
+
s = np.asarray(scores)
|
|
50
|
+
p = np.asarray(presence).astype(bool)
|
|
51
|
+
# exact torch: pad(d,(1,0)).cumsum.clamp(max=length).add(offset)
|
|
52
|
+
note_onset = np.concatenate([[0.0], np.cumsum(d)])[:len(d) + 1]
|
|
53
|
+
note_onset = np.minimum(note_onset, length) + offset
|
|
54
|
+
note_offset = np.minimum(np.cumsum(d), length) + offset
|
|
55
|
+
for onset, off, score, valid in zip(note_onset, note_offset, s, p):
|
|
56
|
+
if off - onset <= 0 or not valid:
|
|
57
|
+
continue
|
|
58
|
+
self.notes.setdefault(key, []).append((onset, off, float(score)))
|
|
59
|
+
|
|
60
|
+
def flush(self, key):
|
|
61
|
+
import mido
|
|
62
|
+
notes = self.notes.pop(key, [])
|
|
63
|
+
notes.sort(key=lambda x: (x[0], x[1], x[2]))
|
|
64
|
+
# clamp overlapping notes like torch callback
|
|
65
|
+
last_time = 0.0
|
|
66
|
+
cleaned = []
|
|
67
|
+
for onset, offset, pitch in notes:
|
|
68
|
+
onset = max(onset, last_time)
|
|
69
|
+
offset = max(offset, onset)
|
|
70
|
+
if offset <= onset:
|
|
71
|
+
continue
|
|
72
|
+
last_time = offset
|
|
73
|
+
cleaned.append((onset, offset, pitch))
|
|
74
|
+
track = mido.MidiTrack()
|
|
75
|
+
track.append(mido.MetaMessage("set_tempo", tempo=mido.bpm2tempo(self.tempo), time=0))
|
|
76
|
+
last_time = 0
|
|
77
|
+
for onset, offset, pitch in cleaned:
|
|
78
|
+
onset_ticks = round(onset * self.tempo * 8)
|
|
79
|
+
offset_ticks = round(offset * self.tempo * 8)
|
|
80
|
+
midi_pitch = round(pitch)
|
|
81
|
+
if offset_ticks <= onset_ticks:
|
|
82
|
+
continue
|
|
83
|
+
track.append(mido.Message("note_on", note=midi_pitch, time=onset_ticks - last_time))
|
|
84
|
+
track.append(mido.Message("note_off", note=midi_pitch, time=offset_ticks - onset_ticks))
|
|
85
|
+
last_time = offset_ticks
|
|
86
|
+
filepath = (self.output_dir / key).with_suffix(".mid")
|
|
87
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
with mido.MidiFile(charset="utf8") as midi_file:
|
|
89
|
+
midi_file.tracks.append(track)
|
|
90
|
+
midi_file.save(filepath)
|
|
91
|
+
return filepath
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.group()
|
|
95
|
+
def main():
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@main.command(name="extract", help="Extract MIDI from single or multiple audio files.")
|
|
100
|
+
@click.argument("path", type=click.Path(exists=True, dir_okay=True, file_okay=True,
|
|
101
|
+
readable=True, path_type=pathlib.Path))
|
|
102
|
+
@click.option("-m", "--model", type=str, required=True,
|
|
103
|
+
help="Model alias (small|medium|large, auto-download) or path to a local MLX model directory.")
|
|
104
|
+
@click.option("-l", "--language", type=str, default=None, show_default=False,
|
|
105
|
+
help="Language code for better segmentation if supported.")
|
|
106
|
+
@click.option("--output-formats", type=str, default="mid", show_default=True,
|
|
107
|
+
callback=_validate_output_formats,
|
|
108
|
+
help="Output formats: mid, txt, csv.")
|
|
109
|
+
@click.option("--tempo", type=click.FloatRange(min=0, min_open=True), default=120,
|
|
110
|
+
show_default=True, help="Tempo (BPM) for MIDI files.")
|
|
111
|
+
@click.option("--output-dir", type=click.Path(dir_okay=True, file_okay=False,
|
|
112
|
+
writable=True, path_type=pathlib.Path),
|
|
113
|
+
default=None, help="Directory for results (default: alongside input).")
|
|
114
|
+
@click.option("--precision", type=click.Choice(["fp32", "fp16"]), default="fp32",
|
|
115
|
+
show_default=True, help="Compute precision.")
|
|
116
|
+
@click.option("--seg-threshold", type=float, default=0.2, show_default=True,
|
|
117
|
+
help="Boundary decoding threshold.")
|
|
118
|
+
@click.option("--seg-radius", type=float, default=0.02, show_default=True,
|
|
119
|
+
help="Boundary decoding radius in seconds (divided by timestep, rounded).")
|
|
120
|
+
@click.option("--est-threshold", type=float, default=0.2, show_default=True,
|
|
121
|
+
help="Presence threshold.")
|
|
122
|
+
@click.option("--t0", type=float, default=0.0, show_default=True, help="D3PM start T.")
|
|
123
|
+
@click.option("--nsteps", type=int, default=8, show_default=True, help="D3PM steps.")
|
|
124
|
+
@click.option("--input-formats", type=str, default="wav,flac,mp3,aac,ogg",
|
|
125
|
+
show_default=True, callback=_validate_exts)
|
|
126
|
+
@click.option("--rng-seed", type=int, default=None,
|
|
127
|
+
help="numpy seed for d3pm randomness (default: nondeterministic).")
|
|
128
|
+
@click.pass_context
|
|
129
|
+
def extract(ctx, path, model, language, output_formats, tempo, output_dir, precision,
|
|
130
|
+
seg_threshold, seg_radius, est_threshold, t0, nsteps, input_formats, rng_seed):
|
|
131
|
+
import time
|
|
132
|
+
|
|
133
|
+
import mlx.core as mx
|
|
134
|
+
import numpy as np
|
|
135
|
+
|
|
136
|
+
filemap = _parse_filemap(path, input_formats)
|
|
137
|
+
if output_dir is None:
|
|
138
|
+
output_dir = path if path.is_dir() else path.parent
|
|
139
|
+
|
|
140
|
+
t_start = time.time()
|
|
141
|
+
# -m accepts alias (small/medium/large -> auto-download), HF repo id, or local dir
|
|
142
|
+
model_arg = str(model)
|
|
143
|
+
if pathlib.Path(model_arg).is_dir():
|
|
144
|
+
model_dir = pathlib.Path(model_arg)
|
|
145
|
+
else:
|
|
146
|
+
model_dir = download_model(model_arg, precision="both")
|
|
147
|
+
m = load_model(model_dir, precision=precision)
|
|
148
|
+
sr = m.config.features.audio_sample_rate
|
|
149
|
+
timestep = m.timestep
|
|
150
|
+
lang_map = load_lang_map(model_dir)
|
|
151
|
+
language_id = get_language_id(language, lang_map)
|
|
152
|
+
|
|
153
|
+
slicer = Slicer(sr=sr, threshold=-40., min_length=1000, min_interval=200,
|
|
154
|
+
max_sil_kept=100)
|
|
155
|
+
radius_frames = round(seg_radius / timestep)
|
|
156
|
+
|
|
157
|
+
# d3pm t values: game ValidationConfig.d3pm_sample_ts_resolved
|
|
158
|
+
ts = [t0 + i * (1 - t0) / nsteps for i in range(nsteps)]
|
|
159
|
+
ts_mx = mx.array(ts, dtype=mx.float32)
|
|
160
|
+
|
|
161
|
+
writers = {}
|
|
162
|
+
if "mid" in output_formats:
|
|
163
|
+
writers["mid"] = _MidiWriter(output_dir, tempo)
|
|
164
|
+
|
|
165
|
+
n_chunks = 0
|
|
166
|
+
for key, filepath in filemap.items():
|
|
167
|
+
wav = load_audio(filepath, sr=sr)
|
|
168
|
+
chunks = slicer.slice(wav)
|
|
169
|
+
for chunk in chunks:
|
|
170
|
+
chunk_wav = np.asarray(chunk["waveform"], dtype=np.float32)
|
|
171
|
+
duration = chunk_wav.shape[0] / sr
|
|
172
|
+
rng = np.random.default_rng(rng_seed) if rng_seed is not None else None
|
|
173
|
+
dur, pres, sc = m(
|
|
174
|
+
mx.array(chunk_wav[None, :]),
|
|
175
|
+
mx.array([[duration]], dtype=mx.float32),
|
|
176
|
+
boundary_threshold=seg_threshold,
|
|
177
|
+
boundary_radius=radius_frames,
|
|
178
|
+
score_threshold=est_threshold,
|
|
179
|
+
language=mx.array([language_id]),
|
|
180
|
+
t=ts_mx,
|
|
181
|
+
rng_matrices=rng,
|
|
182
|
+
)
|
|
183
|
+
mx.eval(dur, pres, sc)
|
|
184
|
+
for w in writers.values():
|
|
185
|
+
w.add_chunk(key, chunk["offset"], duration,
|
|
186
|
+
np.asarray(dur[0]), np.asarray(sc[0]), np.asarray(pres[0]))
|
|
187
|
+
n_chunks += 1
|
|
188
|
+
|
|
189
|
+
for key in list(filemap.keys()):
|
|
190
|
+
for w in writers.values():
|
|
191
|
+
f = w.flush(key)
|
|
192
|
+
if "mid" in writers and w is writers["mid"]:
|
|
193
|
+
click.echo(f"Saved MIDI file: {f.as_posix()}")
|
|
194
|
+
click.echo(f"Done: {len(filemap)} file(s), {n_chunks} chunk(s), "
|
|
195
|
+
f"total {time.time() - t_start:.1f}s ({precision}).")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
gamemlx/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Model and inference configuration, mirroring GAME config.yaml structure.
|
|
2
|
+
|
|
3
|
+
Reference: GAME lib/config/schema.py (ModelConfig / InferenceConfig)
|
|
4
|
+
"""
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass
|
|
12
|
+
class FeaturesConfig:
|
|
13
|
+
audio_sample_rate: int = 44100
|
|
14
|
+
hop_size: int = 441
|
|
15
|
+
fft_size: int = 2048
|
|
16
|
+
win_size: int = 2048
|
|
17
|
+
n_mels: int = 80
|
|
18
|
+
fmin: float = 0.0
|
|
19
|
+
fmax: float = 8000.0
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def timestep(self) -> float:
|
|
23
|
+
return self.hop_size / self.audio_sample_rate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass
|
|
27
|
+
class BackbonesConfig:
|
|
28
|
+
cls: str
|
|
29
|
+
kwargs: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclasses.dataclass
|
|
33
|
+
class ModelConfigData:
|
|
34
|
+
mode: str = "d3pm"
|
|
35
|
+
use_languages: bool = True
|
|
36
|
+
num_languages: int = 127
|
|
37
|
+
region_cycle_len: int = 3
|
|
38
|
+
in_dim: int = 80
|
|
39
|
+
embedding_dim: int = 256
|
|
40
|
+
estimator_out_dim: int = 257
|
|
41
|
+
encoder: BackbonesConfig = None
|
|
42
|
+
segmenter: BackbonesConfig = None
|
|
43
|
+
estimator: BackbonesConfig = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclasses.dataclass
|
|
47
|
+
class InferenceConfigData:
|
|
48
|
+
midi_min: float = 0.0
|
|
49
|
+
midi_max: float = 128.0
|
|
50
|
+
midi_num_bins: int = 257
|
|
51
|
+
midi_std: float = 0.5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclasses.dataclass
|
|
55
|
+
class GamemlxConfig:
|
|
56
|
+
features: FeaturesConfig
|
|
57
|
+
model: ModelConfigData
|
|
58
|
+
inference: InferenceConfigData
|
|
59
|
+
raw: dict = dataclasses.field(default_factory=dict, repr=False)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def timestep(self) -> float:
|
|
63
|
+
return self.features.timestep
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config(path: str | pathlib.Path) -> GamemlxConfig:
|
|
67
|
+
"""Load config.yaml (GAME format: model: / inference: sections)."""
|
|
68
|
+
import yaml
|
|
69
|
+
|
|
70
|
+
path = pathlib.Path(path)
|
|
71
|
+
with open(path, "r", encoding="utf8") as f:
|
|
72
|
+
raw = yaml.safe_load(f)
|
|
73
|
+
|
|
74
|
+
m = raw["model"]
|
|
75
|
+
i = raw["inference"]
|
|
76
|
+
feats = i["features"]
|
|
77
|
+
spec = feats["spectrogram"]
|
|
78
|
+
features = FeaturesConfig(
|
|
79
|
+
audio_sample_rate=feats["audio_sample_rate"],
|
|
80
|
+
hop_size=feats["hop_size"],
|
|
81
|
+
fft_size=feats["fft_size"],
|
|
82
|
+
win_size=feats["win_size"],
|
|
83
|
+
n_mels=spec["num_bins"],
|
|
84
|
+
fmin=float(spec["fmin"]),
|
|
85
|
+
fmax=float(spec["fmax"]),
|
|
86
|
+
)
|
|
87
|
+
model = ModelConfigData(
|
|
88
|
+
mode=m["mode"],
|
|
89
|
+
use_languages=m["use_languages"],
|
|
90
|
+
num_languages=m["num_languages"],
|
|
91
|
+
region_cycle_len=m["region_cycle_len"],
|
|
92
|
+
in_dim=m["in_dim"],
|
|
93
|
+
embedding_dim=m["embedding_dim"],
|
|
94
|
+
estimator_out_dim=m["estimator_out_dim"],
|
|
95
|
+
encoder=BackbonesConfig(cls=m["encoder"]["cls"], kwargs=dict(m["encoder"]["kwargs"])),
|
|
96
|
+
segmenter=BackbonesConfig(cls=m["segmenter"]["cls"], kwargs=dict(m["segmenter"]["kwargs"])),
|
|
97
|
+
estimator=BackbonesConfig(cls=m["estimator"]["cls"], kwargs=dict(m["estimator"]["kwargs"])),
|
|
98
|
+
)
|
|
99
|
+
inference = InferenceConfigData(
|
|
100
|
+
midi_min=i["midi_min"],
|
|
101
|
+
midi_max=i["midi_max"],
|
|
102
|
+
midi_num_bins=i["midi_num_bins"],
|
|
103
|
+
midi_std=i["midi_std"],
|
|
104
|
+
)
|
|
105
|
+
return GamemlxConfig(features=features, model=model, inference=inference, raw=raw)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_lang_map(model_dir: str | pathlib.Path) -> dict[str, int] | None:
|
|
109
|
+
p = pathlib.Path(model_dir) / "lang_map.json"
|
|
110
|
+
if not p.exists():
|
|
111
|
+
return None
|
|
112
|
+
with open(p, "r", encoding="utf8") as f:
|
|
113
|
+
return json.load(f)
|
gamemlx/d3pm.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""D3PM schedule and boundary removal, aligned with GAME modules/d3pm.py.
|
|
2
|
+
|
|
3
|
+
Randomness: torch uses torch.rand_like (torch RNG). For exact alignment the
|
|
4
|
+
caller can inject rng matrices (same uniform values used on both the torch
|
|
5
|
+
side via monkeypatching and here). Default: numpy PCG64 generator.
|
|
6
|
+
"""
|
|
7
|
+
import mlx.core as mx
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def d3pm_time_schedule(t: mx.array) -> mx.array:
|
|
12
|
+
# p = (1 + cos(t * pi)) / 2
|
|
13
|
+
return (1.0 + mx.cos(t * np.pi)) / 2.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def remove_boundaries(boundaries: mx.array, p: mx.array, rnd: mx.array | None = None) -> mx.array:
|
|
17
|
+
"""boundaries [..., T] bool; p [...]; rnd optional injected uniform [boundary shape]."""
|
|
18
|
+
q = 1.0 - p[..., None]
|
|
19
|
+
if rnd is None:
|
|
20
|
+
shape = boundaries.shape
|
|
21
|
+
rnd_np = np.random.default_rng().random(shape, dtype=np.float32)
|
|
22
|
+
rnd = mx.array(rnd_np)
|
|
23
|
+
return (rnd <= q) & boundaries
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def remove_mutable_boundaries(boundaries: mx.array, immutable: mx.array, p: mx.array,
|
|
27
|
+
rnd: mx.array | None = None) -> mx.array:
|
|
28
|
+
"""torch remove_mutable_boundaries; rnd is the uniform matrix used for the
|
|
29
|
+
mutable-boundary removal draw (shape == boundaries shape)."""
|
|
30
|
+
boundaries_mutable = boundaries & ~immutable
|
|
31
|
+
n = boundaries.astype(mx.float32).sum(axis=-1)
|
|
32
|
+
m = boundaries_mutable.astype(mx.float32).sum(axis=-1)
|
|
33
|
+
P = mx.minimum(n * p / (m + 1e-8), 1.0)
|
|
34
|
+
boundaries_mutable_remain = remove_boundaries(boundaries_mutable, P, rnd=rnd)
|
|
35
|
+
return boundaries_mutable_remain | immutable
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_rng_matrices(rng: np.random.Generator, shapes: list[tuple]) -> list[mx.array]:
|
|
39
|
+
"""Pre-generate uniform random matrices for a whole d3pm loop (step-major)."""
|
|
40
|
+
out = []
|
|
41
|
+
for shape in shapes:
|
|
42
|
+
out.append(mx.array(rng.random(shape, dtype=np.float32)))
|
|
43
|
+
return out
|
gamemlx/decoding.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Decoding functions aligned with GAME modules/decoding.py."""
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
import mlx.core as mx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_local_extremum(x: mx.array, threshold: float | None = None, radius: int = 2,
|
|
8
|
+
maxima: bool = True) -> mx.array:
|
|
9
|
+
"""x: [..., T]. argmax within window [i-r, i+r] == radius (first occurrence),
|
|
10
|
+
matching torch unfold+argmax semantics."""
|
|
11
|
+
T = x.shape[-1]
|
|
12
|
+
infinite = float("+inf") if maxima else float("-inf")
|
|
13
|
+
x_pad = mx.pad(x, [(0, 0)] * (x.ndim - 1) + [(radius, radius)],
|
|
14
|
+
constant_values=infinite)
|
|
15
|
+
wins = mx.stack([x_pad[..., i:i + T] for i in range(2 * radius + 1)], axis=0) # [W, ..., T]
|
|
16
|
+
if maxima:
|
|
17
|
+
points = mx.argmax(wins, axis=0) == radius
|
|
18
|
+
if threshold is not None:
|
|
19
|
+
points = points & (x >= threshold)
|
|
20
|
+
else:
|
|
21
|
+
points = mx.argmin(wins, axis=0) == radius
|
|
22
|
+
if threshold is not None:
|
|
23
|
+
points = points & (x <= threshold)
|
|
24
|
+
return points
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decode_soft_boundaries(boundaries: mx.array, barriers: mx.array = None,
|
|
28
|
+
mask: mx.array = None, threshold: float = 0.5,
|
|
29
|
+
radius: int = 2) -> mx.array:
|
|
30
|
+
if mask is not None:
|
|
31
|
+
boundaries = mx.where(mask, boundaries, float("+inf"))
|
|
32
|
+
if barriers is not None:
|
|
33
|
+
boundaries = mx.where(barriers, float("+inf"), boundaries)
|
|
34
|
+
maxima = find_local_extremum(boundaries, threshold=threshold, radius=radius, maxima=True)
|
|
35
|
+
if mask is not None:
|
|
36
|
+
maxima = maxima & mask
|
|
37
|
+
return maxima
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def decode_gaussian_blurred_probs(probs: mx.array, min_val: float, max_val: float,
|
|
41
|
+
deviation: float, threshold: float = 0.1):
|
|
42
|
+
"""probs: [..., N] -> values [...], presence [...]. Aligned with torch version."""
|
|
43
|
+
ndim = probs.ndim
|
|
44
|
+
N = probs.shape[-1]
|
|
45
|
+
width = math.ceil(deviation / (max_val - min_val) * (N - 1))
|
|
46
|
+
idx = mx.arange(N) # [N]
|
|
47
|
+
center_values = mx.linspace(min_val, max_val, N) # torch.linspace(float32)
|
|
48
|
+
centers = mx.argmax(probs, axis=-1, keepdims=True)
|
|
49
|
+
start = mx.maximum(centers - width, 0)
|
|
50
|
+
end = mx.minimum(centers + width + 1, N)
|
|
51
|
+
# broadcast idx to probs shape: [1,...,N]
|
|
52
|
+
bshape = (1,) * (ndim - 1) + (N,)
|
|
53
|
+
idx_b = mx.broadcast_to(idx.reshape(bshape), probs.shape)
|
|
54
|
+
idx_masks = (idx_b >= start) & (idx_b < end)
|
|
55
|
+
weights = probs * idx_masks
|
|
56
|
+
product_sum = mx.sum(weights * center_values, axis=-1)
|
|
57
|
+
weight_sum = mx.sum(weights, axis=-1)
|
|
58
|
+
values = product_sum / (weight_sum + 1e-8)
|
|
59
|
+
presence = mx.max(probs, axis=-1) >= threshold
|
|
60
|
+
return values, presence
|
gamemlx/download.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Model download: HuggingFace primary, ModelScope fallback, local cache.
|
|
2
|
+
|
|
3
|
+
Sources (verified reachable):
|
|
4
|
+
primary HF: baicai1145/GAME-MLX-{small,medium,large}
|
|
5
|
+
fallback ModelScope: https://modelscope.cn/models/baicai1145/GAME-MLX-{...}
|
|
6
|
+
|
|
7
|
+
Files per model: config.yaml, lang_map.json,
|
|
8
|
+
weights.safetensors (fp32), weights.fp16.safetensors (fp16).
|
|
9
|
+
fp16 weights are fetched lazily; fp32 only when needed.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
|
|
14
|
+
HF_REPOS = {
|
|
15
|
+
"small": "baicai1145/GAME-MLX-small",
|
|
16
|
+
"medium": "baicai1145/GAME-MLX-medium",
|
|
17
|
+
"large": "baicai1145/GAME-MLX-large",
|
|
18
|
+
}
|
|
19
|
+
MODELSCOPE_BASE = "https://www.modelscope.cn/models/{repo}/resolve/master/{file}"
|
|
20
|
+
REQUIRED_FILES = ["config.yaml", "lang_map.json"]
|
|
21
|
+
|
|
22
|
+
CACHE_DIR = pathlib.Path(os.environ.get("GAMEMLX_CACHE",
|
|
23
|
+
pathlib.Path.home() / ".cache/gamemlx"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def weights_file(precision: str) -> str:
|
|
27
|
+
return "weights.fp16.safetensors" if precision == "fp16" else "weights.safetensors"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _hf_download(repo_id: str, filename: str, cache_dir: pathlib.Path) -> pathlib.Path:
|
|
31
|
+
from huggingface_hub import hf_hub_download
|
|
32
|
+
return pathlib.Path(hf_hub_download(
|
|
33
|
+
repo_id=repo_id, filename=filename, cache_dir=str(cache_dir / "hf"),
|
|
34
|
+
))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _modelscope_download(repo_id: str, filename: str, dest: pathlib.Path) -> pathlib.Path:
|
|
38
|
+
import urllib.request
|
|
39
|
+
url = MODELSCOPE_BASE.format(repo=repo_id, file=filename)
|
|
40
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
42
|
+
with urllib.request.urlopen(url) as r, open(tmp, "wb") as f:
|
|
43
|
+
while True:
|
|
44
|
+
chunk = r.read(1 << 20)
|
|
45
|
+
if not chunk:
|
|
46
|
+
break
|
|
47
|
+
f.write(chunk)
|
|
48
|
+
tmp.rename(dest)
|
|
49
|
+
return dest
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def download_model(name_or_path: str, precision: str = "fp16") -> pathlib.Path:
|
|
53
|
+
"""Download (or reuse cached) model; returns the local model directory.
|
|
54
|
+
|
|
55
|
+
name_or_path: 'small' | 'medium' | 'large' | HF repo id | local path.
|
|
56
|
+
precision: which weights to fetch ('fp16' default, 'fp32' optional, 'both').
|
|
57
|
+
"""
|
|
58
|
+
p = pathlib.Path(name_or_path).expanduser()
|
|
59
|
+
if (p / "config.yaml").is_file():
|
|
60
|
+
return p # local dir
|
|
61
|
+
if name_or_path not in HF_REPOS:
|
|
62
|
+
# allow full HF repo ids like 'baicai1145/GAME-MLX-medium'
|
|
63
|
+
alias = name_or_path.split("/")[-1].lower()
|
|
64
|
+
if alias not in HF_REPOS.values():
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Unknown model '{name_or_path}'. Aliases: {list(HF_REPOS)}, "
|
|
67
|
+
"or pass a local directory / full repo id.")
|
|
68
|
+
repo_id = name_or_path
|
|
69
|
+
else:
|
|
70
|
+
alias = name_or_path
|
|
71
|
+
repo_id = HF_REPOS[alias]
|
|
72
|
+
|
|
73
|
+
model_dir = CACHE_DIR / alias
|
|
74
|
+
model_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
def ensure(file: str) -> pathlib.Path:
|
|
77
|
+
target = model_dir / file
|
|
78
|
+
if target.is_file() and target.stat().st_size > 0:
|
|
79
|
+
return target
|
|
80
|
+
try:
|
|
81
|
+
got = _hf_download(repo_id, file, CACHE_DIR)
|
|
82
|
+
if not target.is_file():
|
|
83
|
+
try:
|
|
84
|
+
os.link(got, target)
|
|
85
|
+
except OSError:
|
|
86
|
+
import shutil
|
|
87
|
+
shutil.copyfile(got, target)
|
|
88
|
+
return target
|
|
89
|
+
except Exception as e:
|
|
90
|
+
try:
|
|
91
|
+
return _modelscope_download(repo_id, file, target)
|
|
92
|
+
except Exception as e2:
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"Failed to fetch {file} for {alias}: HF: {e}; ModelScope: {e2}"
|
|
95
|
+
) from e2
|
|
96
|
+
|
|
97
|
+
for f in REQUIRED_FILES:
|
|
98
|
+
ensure(f)
|
|
99
|
+
wanted = ["weights.fp16.safetensors", "weights.safetensors"] \
|
|
100
|
+
if precision == "both" else [weights_file(precision)]
|
|
101
|
+
for f in wanted:
|
|
102
|
+
ensure(f)
|
|
103
|
+
return model_dir
|
gamemlx/features.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""MelSpectrogram feature extraction numerically identical to GAME
|
|
2
|
+
StretchableMelSpectrogram (lib/feature/mel.py) at inference defaults:
|
|
3
|
+
|
|
4
|
+
- librosa mel filterbank (sr, n_fft=2048, n_mels=80, fmin, fmax, htk=False, norm='slaney')
|
|
5
|
+
- torch hann_window(2048): PERIODIC window (symmetric=False), divided by nothing
|
|
6
|
+
- reflect pad (win-hop)//2 and (win-hop+1)//2, center=False framing
|
|
7
|
+
- rFFT -> magnitude -> mel_basis @ spec -> log(clamp(., min=1e-5))
|
|
8
|
+
|
|
9
|
+
STFT computed with mlx rfft on strided frames (matches torch.stft numerics
|
|
10
|
+
within float32 error; validated in alignment tests).
|
|
11
|
+
"""
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
import mlx.core as mx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _librosa_mel(sr, n_fft, n_mels, fmin, fmax):
|
|
18
|
+
import librosa.filters
|
|
19
|
+
return librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels,
|
|
20
|
+
fmin=fmin, fmax=fmax, htk=False, norm="slaney")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MelSpectrogram:
|
|
24
|
+
def __init__(self, sample_rate=44100, n_mels=80, n_fft=2048, win_length=2048,
|
|
25
|
+
hop_length=441, fmin=0.0, fmax=8000.0, clip_val=1e-5):
|
|
26
|
+
self.sample_rate = sample_rate
|
|
27
|
+
self.n_fft = n_fft
|
|
28
|
+
self.win_size = win_length
|
|
29
|
+
self.hop_length = hop_length
|
|
30
|
+
self.fmin = fmin
|
|
31
|
+
self.fmax = fmax
|
|
32
|
+
self.clip_val = clip_val
|
|
33
|
+
self.n_mels = n_mels
|
|
34
|
+
mel_basis = _librosa_mel(sample_rate, n_fft, n_mels, fmin, fmax)
|
|
35
|
+
self.mel_basis_np = mel_basis.astype(np.float32)
|
|
36
|
+
self.mel_basis = mx.array(self.mel_basis_np)
|
|
37
|
+
# torch hann_window(w, periodic=True) == 0.5 - 0.5*cos(2*pi*i/w), i in [0, w)
|
|
38
|
+
i = np.arange(win_length, dtype=np.float64)
|
|
39
|
+
window_np = (0.5 - 0.5 * np.cos(2.0 * np.pi * i / win_length)).astype(np.float32)
|
|
40
|
+
self.window = mx.array(window_np)
|
|
41
|
+
|
|
42
|
+
def __call__(self, y: mx.array) -> mx.array:
|
|
43
|
+
"""y: [B, L] -> mel [B, n_mels, T] (like torch StretchableMelSpectrogram)."""
|
|
44
|
+
win, hop, n_fft = self.win_size, self.hop_length, self.n_fft
|
|
45
|
+
pad_l = (win - hop) // 2
|
|
46
|
+
pad_r = (win - hop + 1) // 2
|
|
47
|
+
y = mx.pad(y, [(0, 0), (pad_l, pad_r)], mode="reflect")
|
|
48
|
+
B, L = y.shape
|
|
49
|
+
n_frames = 1 + (L - win) // hop
|
|
50
|
+
if n_frames <= 0:
|
|
51
|
+
raise ValueError(f"waveform too short for one frame: {L} samples")
|
|
52
|
+
# frame via strided gather
|
|
53
|
+
starts = mx.arange(n_frames) * hop # [T]
|
|
54
|
+
idx = starts[:, None] + mx.arange(win)[None, :] # [T, win]
|
|
55
|
+
idx = mx.minimum(idx, L - 1) # torch would error; guard not triggered for valid lengths
|
|
56
|
+
frames = y[:, idx.reshape(-1)].reshape(B, n_frames, win) # [B, T, win]
|
|
57
|
+
frames = frames * self.window
|
|
58
|
+
spec = mx.fft.rfft(frames, n=n_fft, axis=-1) # [B, T, n_fft//2+1] complex64
|
|
59
|
+
mag = mx.abs(spec) # == torch complex abs
|
|
60
|
+
mel = self.mel_basis @ mag.transpose(0, 2, 1) # [B, mels, T]
|
|
61
|
+
mel = mx.log(mx.maximum(mel, self.clip_val))
|
|
62
|
+
return mel
|